附录 A 情感模型公式、参数表与数据集索引

卷三《叙事体验与路径度量》的附录:把度量侧的规格输入、六个代理指标的口径、五个定义式与本卷工作区的文件清单汇成一张可核对的总表,并给出可运行性命令与排错顺序。全部数字均来自本机实跑,随正文各章一并更新,便于在写新章节或修改叙事规格时随时对照、重跑与核对。

本附录是卷三的检索层:把「度量要读哪些字段、指标怎么定义、本卷落了哪些文件」收成几张表。它不引入新结论——每个数字都能在正文对应小节找到,也能用本页命令重跑出来。

卷内文件命名约定

层路径
S 层sim/nm_<章>_<节>_<name>.py(共享内核 sim/nm_<章>_common.py 只读导入,不单独成节)
P 层core/src/inkwell/nm_*.ts · core/test/nm_*.test.ts
W 层web/build_nm<章>.mjs · web/verify_nm<章>.mjs
D 层 / 产物config/nm_*.json · `out/nm_*.<json

度量侧的索引生成器

下面这个脚本是本附录的全部数据来源。它只清点、不判定——判定在第 15 章的指标总表里。

#!/usr/bin/env python3
# file: sim/nm_appendix_index.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_appendix_index.py
"""附录 A 的索引生成器(度量侧):清点度量要用到的规格字段与本卷数据集。

与卷二 ne_appendix_index.py 分工:那个清点「引擎执行时要用到哪些字段」,
本脚本清点「度量时要从规格里取哪些量、这些量各自的口径是什么」。
只清点,不判定——判定在第 15 章的指标总表里。
"""
from __future__ import annotations

import glob
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..")
SPEC = os.path.join(ROOT, "config/story_spec.json")

# 本卷引入的六个代理指标(口径见第 15 章)。只列名与量纲,不列判定阈值。
PROXIES = [
    ("分支覆盖率", "千分", "走过的对话边 / 全部对话边"),
    ("结局分布熵", "毫比特", "5 结局的分布均匀度(按采样批次算)"),
    ("张力曲线 MAE", "毫", "路径张力序列 vs metrics.target_tension_curve"),
    ("好感度曲线 MAE", "毫", "路径好感度序列 vs 角色声明的弧光目标"),
    ("一致性残差", "计数", "设定声明与行为效果之间的失配条数"),
    ("节奏密度偏差", "千分", "沿路径的事件密度 vs 张力峰谷的错配程度"),
]


def inv(rel: str, pat: str) -> int:
    return len(glob.glob(os.path.join(ROOT, rel, pat)))


def main() -> int:
    with open(SPEC, encoding="utf-8") as fh:
        spec = json.load(fh)

    beats = spec["beats"]
    m = spec["metrics"]

    print("== 度量输入:节拍与张力 ==")
    tens = [b["tension"] for b in beats]
    print(f"节拍数 {len(beats)}  张力范围 {min(tens)}–{max(tens)}  张力之和 {sum(tens)}")
    hist = {}
    for t in tens:
        hist[t] = hist.get(t, 0) + 1
    print(f"张力直方图 {' '.join(f'{k}:{hist[k]}' for k in sorted(hist))}")
    acts = {}
    for b in beats:
        acts.setdefault(b["act"], []).append(b["tension"])
    for a in sorted(acts):
        v = acts[a]
        print(f"  第 {a} 幕 {len(v)} 拍  均 {sum(v) * 1000 // len(v)} 毫")
    print(f"张力序列 {' '.join(str(t) for t in tens)}")

    print("\n== 度量输入:目标曲线与阈值 ==")
    tgt = m["target_tension_curve"]
    print(f"目标张力曲线 {' '.join(str(t) for t in tgt)}")
    same = len(tgt) == len(tens) and all(a == b for a, b in zip(tens, tgt))
    print(f"与 beats[].tension 逐项相同 {same}(当前规格自洽的事实,见 5.2)")
    print(f"覆盖目标 {m['coverage_target_permille']} 千分")
    print(f"结局熵下限 {m['ending_entropy_min_milli_bits']} 毫比特")
    print(f"好感度 MAE 上限 {m['affinity_mae_max']} 毫  张力 MAE 上限 {m['tension_mae_max'] * 1000} 毫")

    print("\n== 度量输入:场景图与结局 ==")
    sg = spec["scene_graph"]
    print(f"场景节点 {len(sg['nodes'])}  边 {len(sg['edges'])}"
          f"  带条件边 {sum(1 for e in sg['edges'] if e.get('when'))}")
    print(f"带解锁条件的场景 {sum(1 for n in sg['nodes'] if n.get('enter_when'))}")
    ends = spec["endings"]
    print(f"结局 {len(ends)}  {' '.join(e.get('id', '?') for e in ends)}")

    print("\n== 度量输入:好感度变量与阈值门 ==")
    for c in spec["characters"]:
        a = c.get("affinity")
        if not a:
            continue
        print(f"  {c['id']:<6} 初值 {a['init']:<3} 区间 {a['range'][0]}–{a['range'][1]}"
              f"  阈值 {len(a.get('thresholds') or [])} 门 {len(a.get('gates') or [])}"
              f"  弧光阶段 {len((c.get('arc') or {}).get('stages') or [])}")
    st = spec["state"]
    print(f"  flag {len(st['flags'])}  变量 {len(st['vars'])}  物品 {len(st['items'])}"
          f"  声明位点合计 {len(st['flags']) + len(st['vars']) + len(st['items'])}")

    print("\n== 本卷引入的代理指标(口径表) ==")
    for name, unit, how in PROXIES:
        print(f"  {name:<12} {unit:<5} {how}")

    print("\n== 本卷数据集索引(工作区实盘) ==")
    rows = [("S 层脚本", "sim", "nm_*.py"), ("P 层源码", "core/src/inkwell", "nm_*.ts"),
            ("P 层测试", "core/test", "nm_*.test.ts"), ("W 层脚本", "web", "*nm*.mjs"),
            ("D 层配置", "config", "nm_*.json"), ("产物 JSON", "out", "nm_*.json"),
            ("产物 CSV", "out", "nm_*.csv"), ("产物 SVG", "out", "nm_*.svg"),
            ("产物 PNG", "out/shots", "nm*.png"), ("原型 HTML", "web/dist", "nm*.html")]
    for label, rel, pat in rows:
        print(f"  {label:<10} {rel + '/' + pat:<22} {inv(rel, pat)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 运行索引生成器并打印清点结果
cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_appendix_index.py
== 度量输入:节拍与张力 ==
节拍数 16  张力范围 1–5  张力之和 52
张力直方图 1:1 2:4 3:4 4:4 5:3
  第 1 幕 5 拍  均 2000 毫
  第 2 幕 6 拍  均 3666 毫
  第 3 幕 5 拍  均 4000 毫
张力序列 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2

== 度量输入:目标曲线与阈值 ==
目标张力曲线 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2
与 beats[].tension 逐项相同 True(当前规格自洽的事实,见 5.2)
覆盖目标 850 千分
结局熵下限 1500 毫比特
好感度 MAE 上限 12 毫  张力 MAE 上限 1000 毫

== 度量输入:场景图与结局 ==
场景节点 14  边 16  带条件边 5
带解锁条件的场景 5
结局 5  e_lit e_dark e_truth e_silence e_loss

== 度量输入:好感度变量与阈值门 ==
  yan    初值 20  区间 0–100  阈值 3 门 3  弧光阶段 3
  hui    初值 50  区间 0–100  阈值 3 门 3  弧光阶段 3
  shen   初值 50  区间 0–100  阈值 3 门 3  弧光阶段 3
  mu     初值 50  区间 0–100  阈值 3 门 3  弧光阶段 3
  lin    初值 40  区间 0–100  阈值 3 门 3  弧光阶段 3
  hai    初值 60  区间 0–100  阈值 3 门 3  弧光阶段 3
  flag 18  变量 4  物品 9  声明位点合计 31

== 本卷引入的代理指标(口径表) ==
  分支覆盖率        千分    走过的对话边 / 全部对话边
  结局分布熵        毫比特   5 结局的分布均匀度(按采样批次算)
  张力曲线 MAE     毫     路径张力序列 vs metrics.target_tension_curve
  好感度曲线 MAE    毫     路径好感度序列 vs 角色声明的弧光目标
  一致性残差        计数    设定声明与行为效果之间的失配条数
  节奏密度偏差       千分    沿路径的事件密度 vs 张力峰谷的错配程度

== 本卷数据集索引(工作区实盘) ==
  S 层脚本      sim/nm_*.py            66
  P 层源码      core/src/inkwell/nm_*.ts 3
  P 层测试      core/test/nm_*.test.ts 4
  W 层脚本      web/*nm*.mjs           4
  D 层配置      config/nm_*.json       12
  产物 JSON    out/nm_*.json          59
  产物 CSV     out/nm_*.csv           12
  产物 SVG     out/nm_*.svg           5
  产物 PNG     out/shots/nm*.png      2
  原型 HTML    web/dist/nm*.html      2

度量输入:节拍与张力

张力曲线读 beats。16 个节拍分三幕,tension 是作者标注的 1–5 整数——它是序数量,只能比大小,不能当间隔量做加减。序列为 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2,三幕均值单调上升是这份规格的结构特征,不是普适规律。

项值
节拍数 / 张力之和16 / 52
直方图1:1 · 2:4 · 3:4 · 4:4 · 5:3
三幕均值第 1 幕 5 拍 2000 毫 · 第 2 幕 6 拍 3666 毫 · 第 3 幕 5 拍 4000 毫

度量输入:目标曲线与阈值

metrics 段给出四组阈值,它们就是本卷全部判据的来源——指标超阈值只产生告警,不硬失败(B 层口径)。

字段值量纲用在哪
target_tension_curve1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 21–5 整数第 5、6 章张力 MAE
coverage_target_permille850千分第 2 章覆盖率
ending_entropy_min_milli_bits1500毫比特第 3 章结局熵
tension_mae_max1(1000 毫)1–5 档第 5、6、13、14 章
affinity_mae_max12(12000 毫)1–5 档第 8、15 章

这里有一处规格自洽的事实:beats[].tension 与 target_tension_curve 在真源里逐项相同,所以当前规格下张力 MAE 恒为 0 毫。这不是指标失效,而是说明标注与目标此刻一致;MAE 真正起作用是在规格演化时(第 14 章)——改了节拍标注却没同步目标曲线它就会抬起来(实测 b10 从 5 降到 4,全曲线 MAE 抬到 62 毫)。

度量输入:场景图与结局

项值
场景节点14
声明边16(带条件 5 条)
带解锁条件的场景5
结局5(e_lit / e_dark / e_truth / e_silence / e_loss)

一处必须记住的不同构:声明的场景图与对话层实际可达的场景转移不是同一张图——16 条声明边里有 5 条被 d_hub_a1/a2/b2 这类枢纽节点绕开、对话层另有 13 条未声明的转移(合计 24 条)。所以第 10.2 节画的是对话节点图(48 节点 / 56 去重边)而非场景图,覆盖口径才与第 2 章一致。

度量输入:好感度变量与阈值门

六个角色都声明了 affinity,但只有四个进入 state.vars:

角色初值区间阈值门弧光阶段在状态表
yan200–100333是
hui500–100333是
shen500–100333是
mu500–100333是
lin400–100333否
hai600–100333否

lin 与 hai 属于「已声明但未被引用」:全篇没有任何选项 add 过它们,也没有任何条件读它们。声明域比状态域宽,这是第 8、9 章共同的输入,第 15 章的一致性残差把它计为一条失配。状态域规模:flag 18 · 变量 4 · 物品 9,声明位点合计 31。

本卷引入的六个代理指标

全部是 proxy,只做相对比较,禁「更感人」「更好玩」「提升 N 倍」「玩家满意度 X%」这类表述(第 15.3 节把这条写成正面清单)。

指标量纲定义首次出现
分支覆盖率千分走过的对话边 / 全部去重对话边第 2.2 节
结局分布熵毫比特5 结局的分布均匀度(按采样批次算)第 3.1 节
张力曲线 MAE毫路径张力序列 vs metrics.target_tension_curve第 5.2 节
好感度曲线 MAE毫路径好感度序列 vs 随机采样均值曲线第 15.1 节
一致性残差计数设定声明与行为效果之间的失配条数第 9.1 节
节奏密度偏差千分沿路径的事件密度 vs 张力峰谷的错配程度第 7.2 节

好感度 MAE 是唯一一条规格里给了阈值却没给目标曲线的指标(metrics 有 affinity_mae_max = 12,但没有 target_affinity_curve 字段)。第 15.1 节因此首次给出它的定义:确定性路径曲线对随机 2000 条采样均值曲线的逐步绝对偏离,单位毫。

五个定义式

全部走定点整数、地板除,config/ 与状态里不出现浮点。

张力 MAE_毫   = Σ|实际张力 − 目标张力| × 1000 / 覆盖节拍数
结局熵_毫比特 = Σ(−{p_i} × log2 {p_i}) × 1000      # {p_i} = 第 i 个结局的采样占比
覆盖率_千分   = |走过的边 ∩ 全部去重边| × 1000 / |全部去重边|
一致性残差    = D(声明未用) + K(角色未实现) + S(立场矛盾) + R(秘密可达性失配)
不变量残差    = 越界次数 + 非幂等次数                   # 必须恒为 0,非零即缺陷

熵的上界是 floor(log2 5 × 1000) = 2321,单结局为 0,阈值 1500 约为上界的 64.6%。覆盖率的分母是去重后的对话边(56 条)而不是选项数(115 条)——两种口径差一倍以上(446‰ vs 217‰),第 2.2 节把这件事写成「口径声明」。

可运行性命令与排错顺序

# snippet: 四层可运行性命令速查(S / P / W / B)
cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_05_2_tension_release.py   # S:Python 侧度量
cd /Users/bingrong.yan/.inkwellbook/inkwell && node --test 'core/test/nm_04_2_*.test.ts'                # P:必须带引号 glob,裸目录 MODULE_NOT_FOUND
cd /Users/bingrong.yan/.inkwellbook/inkwell && node web/build_nm12.mjs && node web/verify_nm12.mjs      # W:NODE_PATH 对 ESM import 无效,用 createRequire
cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_14_3_perturb_sweep.py   # B:参数扰动,只产软告警,恒 exit 0

按这个顺序排查,能覆盖本卷绝大多数失败:

  1. 脚本跑不通 → 先确认用的是 /opt/homebrew/bin/python3(3.14.6)。本机无 matplotlib / scipy / networkx,凡画图章节一律纯 xml.etree 手写 SVG。
  2. 数字对不上正文 → 本卷所有数字以实跑为准;两者不符时是正文写错,不是脚本写错。
  3. B 层脚本返回非 0 → 这是缺陷。B 层约定是「只产软告警、恒 exit 0」,断言写「告警条数已报告」,不写「必须无告警」。
  4. W 层断言「已绘制像素 == 0」失败 → Playwright 冷启动首个 page 读 canvas 像素恒为空白,必须插一行预热页,断言放宽为「≥ 1 px」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长