本附录是卷三的检索层:把「度量要读哪些字段、指标怎么定义、本卷落了哪些文件」收成几张表。它不引入新结论——每个数字都能在正文对应小节找到,也能用本页命令重跑出来。
卷内文件命名约定
| 层 | 路径 |
|---|---|
| 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_curve | 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2 | 1–5 整数 | 第 5、6 章张力 MAE |
coverage_target_permille | 850 | 千分 | 第 2 章覆盖率 |
ending_entropy_min_milli_bits | 1500 | 毫比特 | 第 3 章结局熵 |
tension_mae_max | 1(1000 毫) | 1–5 档 | 第 5、6、13、14 章 |
affinity_mae_max | 12(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:
| 角色 | 初值 | 区间 | 阈值 | 门 | 弧光阶段 | 在状态表 |
|---|---|---|---|---|---|---|
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 | 否 |
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
按这个顺序排查,能覆盖本卷绝大多数失败:
- 脚本跑不通 → 先确认用的是
/opt/homebrew/bin/python3(3.14.6)。本机无 matplotlib / scipy / networkx,凡画图章节一律纯xml.etree手写 SVG。 - 数字对不上正文 → 本卷所有数字以实跑为准;两者不符时是正文写错,不是脚本写错。
- B 层脚本返回非 0 → 这是缺陷。B 层约定是「只产软告警、恒 exit 0」,断言写「告警条数已报告」,不写「必须无告警」。
- W 层断言「已绘制像素 == 0」失败 → Playwright 冷启动首个 page 读 canvas 像素恒为空白,必须插一行预热页,断言放宽为「≥ 1 px」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。