《游戏设计》卷一《玩法机制与可执行规格》 那一套给的是玩法规格的度量口径;本章给的是叙事规格的度量口径——指标集不同(那边是一局的动力学,这边是整张故事图的路径形状),但「整数化 + 确定性 + 只做相对比较」的纪律同源。15.1 把七条指标收进一张表,紧接着的问题就是:这张表里的数,能跟谁比?
15.2 指标的可比性与局限
15.1 那张表有一个危险的读法:把七个数并排一看,人就会忍不住问「哪个更重要」「总分多少」「能不能跨作品比」。本节的任务是把这三条路堵死——而且不靠声明堵,靠实跑堵:用三个可复现的实验,证明七条指标只在同一规格、同一口径、同一样本量下才可比。
三条局限
- 量纲不可相加:七条指标合成不出一个总分(3 / 3 组跨量纲求和被拒);
- 跨规格不可比:换一份规格,同一个数含义就变(同一路径 446 → 438 / 454);
- 样本量约束:熵只在固定 n 下可比(n=50 熵 1756 > n=20000 熵 1714)。
局限一:量纲不可相加
七条指标落在四个量纲上——千分(覆盖率、密度偏差)、毫比特(熵)、毫(两条 MAE)、计数(两条残差)。脚本里有一个 combine() 守卫:量纲不同就 raise ValueError。本节抽三组跨量纲求和来试,3 / 3 全被拒:permille vs milli、milli_bits vs permille、count vs milli_bits。
结论很硬:七条指标没有合法的求和口径。想要一个「叙事质量总分」,就得先给千分、毫比特、毫、计数定权重——而这个权重没有任何真源支撑,纯属拍脑袋。所以本卷不给总分。
局限二:跨规格不可比
第二条更隐蔽:同一个数,换一份规格,含义就变了。 分支覆盖率的分母是「全部去重对话边」,而这个分母是规格自己写的。本节做一个最小扰动实验:保持那条 25 条边的确定性路径一步不动,只在规格里加一条选项(分母 56→57)、再删一条选项(分母 56→55):
| 规格变体 | 去重边 | 路径边 | 覆盖率 |
|---|---|---|---|
| 基准 | 56 | 25 | 446 千分 |
| 加一条选项 | 57 | 25 | 438 千分 |
| 删一条选项 | 55 | 25 | 454 千分 |
路径没变,数变了。 这条路径一步都没多走,覆盖率却从 446 漂到 438 / 454。同理,目标覆盖率 850 千分、熵上界 log2(5) = 2321 毫比特,都是这份规格自己写死的字段,不是放之四海皆准的标准。所以「跨规格比覆盖率」,比的是两份规格的分母,不是两条路径的丰富度。
局限三:熵与样本量
第三条专门说熵。熵对稀有类目和样本量都敏感。本节把采样量从 50 扫到 20000,盯住最稀有的结局「真相」:
| n | 看到的结局 | 熵(毫比特) |
|---|---|---|
| 50 | 4 / 5 | 1756 |
| 200 | 5 / 5 | 1687 |
| 1000 | 5 / 5 | 1713 |
| 5000 | 5 / 5 | 1730 |
| 20000 | 5 / 5 | 1714 |
看第一行和最后一行:n=50 时「真相」一次都没出现,熵 1756 反而高于 n=20000 的 1714。小样本的熵方向不定——少数几个结局占比一抖,熵就飘。跨 5 个种子再验一遍:n=50 的熵极差 172,n=1000 的极差收窄到 59。而最稀有的「真相」占比只有 8 千分,期望出现一次需要 n ≈ 112。所以熵只在固定样本量下可比;要跨样本量比,先把 n 钉死。
S 层:三个实验一次跑完
脚本 sim/nm_15_2_comparability.py 把三个实验串起来:量纲守卫逐组试拒、规格加删一条选项各重算一次覆盖率、采样量从 50 扫到 20000 看熵:
#!/usr/bin/env python3
# file: sim/nm_15_2_comparability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_15_2_comparability.py
"""卷三 15.2 指标的可比性与局限(S 层):这张表里的数能跟谁比。
三个实验:一、四个量纲(千分 / 毫比特 / 毫 / 计数)两两求和一律拒绝,七条指标不能
合成一个「叙事质量分」;二、同一条确定性路径(25 条边),规格加 / 删一条选项使分母
56→57 / 55,千分比立刻 446→438 / 454——路径没变数变了,目标覆盖率与熵上界都是这份
规格自己写死的字段;三、熵对样本量与稀有类目敏感,n=50 缺最稀有结局「真相」而熵反
高于 n=20000,故熵只在固定样本量下可比。全部整数、全部 proxy。产出
out/nm_15_2_comparability.json。
"""
from __future__ import annotations
import json
import math
import os
import sys
import nm_15_common as c
OBSERVED = {"branch_coverage": 446, "ending_entropy": 1714, "tension_curve_mae": 0,
"affinity_curve_mae": 5739, "consistency_residual": 8,
"density_deviation": 5, "invariant_residual": 0}
def main() -> int:
spec = c.load_json("config/story_spec.json")
by = {n["id"]: n for n in spec["dialogue"]}
table = c.load_json("config/nm_15_1_metric_definition_table.json")["metrics"]
units = {e["metric"]: e["unit"] for e in table}
# ---- 一 量纲不可相加 ----
pairs = [("branch_coverage", "tension_curve_mae"),
("ending_entropy", "branch_coverage"),
("consistency_residual", "ending_entropy")]
refused = 0
print("== 一 量纲不可相加 ==")
for k in OBSERVED:
print(f" {k:<22} {OBSERVED[k]:>6} {units[k]}")
for a, b in pairs:
try:
c.combine(units[a], OBSERVED[a], units[b], OBSERVED[b])
print(f" 相加 {a} + {b} 意外通过")
except ValueError as exc:
refused += 1
print(f" 相加 {a} + {b} → 拒绝({exc})")
print(f" 跨量纲相加 {refused} / {len(pairs)} 被拒绝:七条指标没有合法的求和口径")
# ---- 二 跨规格不可比 ----
base_edges = c.edges_of(spec)
det = c.det_edges(spec)
base_permille = len(det) * 1000 // len(base_edges)
add_spec = json.loads(json.dumps(spec))
by_id = {n["id"]: n for n in add_spec["dialogue"]}
add_node, add_goto = None, None
for n in add_spec["dialogue"]:
for m in add_spec["dialogue"]:
if n.get("choices") and m["id"] != n["id"] and (n["id"], m["id"]) not in base_edges:
add_node, add_goto = n["id"], m["id"]
break
if add_node:
break
by_id[add_node]["choices"].append({"id": "c_mut_add", "text": "(扰动新增)", "goto": add_goto})
add_edges = c.edges_of(add_spec)
add_permille = len(c.det_edges(add_spec)) * 1000 // len(add_edges)
del_spec = json.loads(json.dumps(spec))
det_nodes = set(c.walk(spec, None)[1])
del_node, del_idx = None, None
for n in del_spec["dialogue"]:
if n["id"] in det_nodes or len(n.get("choices") or []) < 2:
continue
seen = {}
for ch in n["choices"]:
seen[ch["goto"]] = seen.get(ch["goto"], 0) + 1
for i, ch in enumerate(n["choices"]):
if seen[ch["goto"]] == 1:
del_node, del_idx = n["id"], i
break
if del_node:
break
removed = {n["id"]: n for n in del_spec["dialogue"]}[del_node]["choices"].pop(del_idx)["goto"]
del_edges = c.edges_of(del_spec)
del_permille = len(c.det_edges(del_spec)) * 1000 // len(del_edges)
print("== 二 跨规格不可比(同一条 25 条边的确定性路径) ==")
print(f" 基准 去重边 {len(base_edges)} 路径边 {len(det)} → {base_permille} 千分")
print(f" 加一条选项 去重边 {len(add_edges)} 路径边 {len(det)} → {add_permille} 千分"
f"({add_node} → {add_goto})")
print(f" 删一条选项 去重边 {len(del_edges)} 路径边 {len(det)} → {del_permille} 千分"
f"({del_node} → {removed})")
print(f" 同一条路径、同一个策略:分母一变,千分比 {base_permille} → {add_permille} / {del_permille}")
print(f" 目标覆盖率 {spec['metrics']['coverage_target_permille']} 千分是规格字段(非通用标准);"
f"熵上界 log2(5) = {int(math.log2(5) * 1000)} 毫比特同理")
# ---- 三 样本量约束 ----
print("== 三 样本量对熵的影响 ==")
ent_rows = []
for n in (50, 200, 1000, 5000, 20000):
rng, dist = c.SEED, {}
for _ in range(n):
end, _, _, _, rng, _ = c.walk(spec, rng, 0)
key = by[end].get("ending", "(未终局)")
dist[key] = dist.get(key, 0) + 1
e = c.entropy_milli_bits(list(dist.values()), n)
ent_rows.append({"n": n, "entropy_milli_bits": e, "distribution": dist, "endings_seen": len(dist)})
print(f" n={n:<6} 结局 {len(dist)}/5 熵 {e} 毫比特 {dist}")
spread = []
for n in (50, 1000):
es = []
for s_i in range(5):
rng, dist = c.SEED + s_i * 7919, {}
for _ in range(n):
end, _, _, _, rng, _ = c.walk(spec, rng, 0)
key = by[end].get("ending", "(未终局)")
dist[key] = dist.get(key, 0) + 1
es.append(c.entropy_milli_bits(list(dist.values()), n))
spread.append({"n": n, "seeds": es, "min": min(es), "max": max(es), "range": max(es) - min(es)})
print(f" n={n:<6} 跨 5 个种子 熵 {min(es)}~{max(es)} 极差 {max(es) - min(es)}")
rare = ent_rows[-1]["distribution"].get("真相", 0)
print(f" 最稀有结局「真相」占比 {rare}/{ent_rows[-1]['n']} = "
f"{rare * 1000 // ent_rows[-1]['n']} 千分;期望出现 1 次需 n ≈ {ent_rows[-1]['n'] // max(rare, 1)}")
print(f" n=50 时「真相」出现 0 次,熵 {ent_rows[0]['entropy_milli_bits']} 反而高于 "
f"n=20000 的 {ent_rows[-1]['entropy_milli_bits']}:小样本的熵方向不定")
print(" 结论:熵只在固定样本量下可比;跨样本量比必须先固定 n,跨规格比无意义")
# ---- 规则表 lint(D 层,config/nm_15_2_comparability_rules.json) ----
rules = c.load_json("config/nm_15_2_comparability_rules.json")
rule_ids = [r["id"] for r in rules["rules"]]
no_ev = [r["id"] for r in rules["rules"] if r["allowed"] is False and not r.get("evidence")]
covered = {p["metric"] for p in rules["per_metric"]}
missing = sorted(set(units) - covered)
print("== 规则表 lint ==")
print(f" 规则 {len(rule_ids)} 条({' '.join(rule_ids)}) 允许 "
f"{sum(1 for r in rules['rules'] if r['allowed'])} 条 / 禁止 "
f"{sum(1 for r in rules['rules'] if not r['allowed'])} 条")
print(f" 逐指标覆盖 {len(covered)} / {len(units)} 未覆盖 {missing}")
print(f" 禁止规则缺证据 {len(no_ev)} 条")
assert not missing and not no_ev and len(rule_ids) == 6, "规则表 lint 失败"
assert rules["per_metric"][6]["across_spec"] is True, "唯一可跨规格引用的指标应为整数不变量残差"
assert base_permille == 446 and refused == len(pairs)
os.makedirs(c.OUT, exist_ok=True)
with open(os.path.join(c.OUT, "nm_15_2_comparability.json"), "w", encoding="utf-8") as fh:
json.dump({"seed": c.SEED, "units": units, "values": OBSERVED, "dimension_refused": refused,
"spec_variants": [
{"name": "base", "edges": len(base_edges), "path_edges": len(det),
"permille": base_permille},
{"name": "add_one_choice", "edges": len(add_edges), "path_edges": len(det),
"permille": add_permille, "at": f"{add_node}->{add_goto}"},
{"name": "drop_one_choice", "edges": len(del_edges), "path_edges": len(det),
"permille": del_permille, "at": f"{del_node}->{removed}"}],
"entropy_by_n": ent_rows, "entropy_seed_spread": spread,
"uniform_upper_milli_bits": int(math.log2(len(spec["endings"])) * 1000),
"proxy": True}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_15_2_comparability.json")
print("OK nm_15_2_comparability:量纲不可加、跨规格不可比、样本量约束三条局限已实测")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_15_2_comparability.py:
== 一 量纲不可相加 ==
branch_coverage 446 permille
ending_entropy 1714 milli_bits
tension_curve_mae 0 milli
affinity_curve_mae 5739 milli
consistency_residual 8 count
density_deviation 5 permille
invariant_residual 0 count
相加 branch_coverage + tension_curve_mae → 拒绝(量纲不同不可相加:permille vs milli)
相加 ending_entropy + branch_coverage → 拒绝(量纲不同不可相加:milli_bits vs permille)
相加 consistency_residual + ending_entropy → 拒绝(量纲不同不可相加:count vs milli_bits)
跨量纲相加 3 / 3 被拒绝:七条指标没有合法的求和口径
== 二 跨规格不可比(同一条 25 条边的确定性路径) ==
基准 去重边 56 路径边 25 → 446 千分
加一条选项 去重边 57 路径边 25 → 438 千分(d_pier_1 → d_tavern_1)
删一条选项 去重边 55 路径边 25 → 454 千分(d_mr_3 → d_ho_1)
同一条路径、同一个策略:分母一变,千分比 446 → 438 / 454
目标覆盖率 850 千分是规格字段(非通用标准);熵上界 log2(5) = 2321 毫比特同理
== 三 样本量对熵的影响 ==
n=50 结局 4/5 熵 1756 毫比特 {'离港': 24, '沉没': 14, '缄默': 6, '灯守': 6}
n=200 结局 5/5 熵 1687 毫比特 {'离港': 104, '沉没': 60, '缄默': 21, '灯守': 12, '真相': 3}
n=1000 结局 5/5 熵 1713 毫比特 {'离港': 508, '沉没': 278, '缄默': 149, '灯守': 56, '真相': 9}
n=5000 结局 5/5 熵 1730 毫比特 {'离港': 2502, '沉没': 1364, '缄默': 815, '灯守': 270, '真相': 49}
n=20000 结局 5/5 熵 1714 毫比特 {'离港': 10074, '沉没': 5507, '缄默': 3223, '灯守': 1018, '真相': 178}
n=50 跨 5 个种子 熵 1662~1834 极差 172
n=1000 跨 5 个种子 熵 1687~1746 极差 59
最稀有结局「真相」占比 178/20000 = 8 千分;期望出现 1 次需 n ≈ 112
n=50 时「真相」出现 0 次,熵 1756 反而高于 n=20000 的 1714:小样本的熵方向不定
结论:熵只在固定样本量下可比;跨样本量比必须先固定 n,跨规格比无意义
== 规则表 lint ==
规则 6 条(R1 R2 R3 R4 R5 R6) 允许 2 条 / 禁止 4 条
逐指标覆盖 7 / 7 未覆盖 []
禁止规则缺证据 0 条
wrote out/nm_15_2_comparability.json
OK nm_15_2_comparability:量纲不可加、跨规格不可比、样本量约束三条局限已实测
三个实验各自的 assert 都钉住了方向:跨量纲相加必须 3 / 3 被拒、基准覆盖率必须是 446 千分、规则表必须 6 条且逐指标覆盖 7 / 7。实验不是「演示」,是「门禁」——这三条局限一旦被破坏,脚本直接 FAIL。
D 层:可比性规则表
规则落 config/nm_15_2_comparability_rules.json:6 条规则(允许 2 条 / 禁止 4 条)+ 逐指标的可比性标注:
{
"section": "15.2",
"tier": "D",
"note": "指标可比性规则表。allowed=false 的规则都在 sim/nm_15_2_comparability.py 里有实跑证据;本节只做相对比较,不做绝对判断。",
"units": {"permille": "千分比(整数地板除)", "milli_bits": "毫比特(Shannon 熵 × 1000 取整)",
"milli": "毫(1 点 = 1000 毫)", "count": "计数(整数)"},
"rules": [
{"id": "R1", "rule": "量纲不同的两个指标相加", "allowed": false,
"evidence": "3 / 3 组跨量纲求和被 combine() 拒绝(permille vs milli / milli_bits vs permille / count vs milli_bits)"},
{"id": "R2", "rule": "同一规格、同一口径、同一采样规模下的前后对照", "allowed": true,
"evidence": "这是 6.3 声明里 allowed_use 的第一条:同一规格内两条曲线的相对偏离"},
{"id": "R3", "rule": "跨规格比较(分母 / 目标 / 结局数不同)", "allowed": false,
"evidence": "同一条 25 条边的确定性路径:去重边 56 → 446‰、57 → 438‰、55 → 454‰;路径没变,数变了"},
{"id": "R4", "rule": "熵跨样本量比较", "allowed": false,
"evidence": "n=50 熵 1756 毫比特 > n=20000 熵 1714 毫比特,而 n=50 时最稀有结局一次都没出现"},
{"id": "R5", "rule": "整数不变量残差跨规格引用", "allowed": true,
"evidence": "恒等判据(必须为 0),不依赖任何分母、目标或采样规模"},
{"id": "R6", "rule": "把七条指标合成一个「叙事质量总分」", "allowed": false,
"evidence": "四个量纲之间没有合法权重,合成即 R1 的越界"}
],
"per_metric": [
{"metric": "branch_coverage", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
"note": "分母随规格变(56 / 57 / 55),故只在同一规格同一分母下可比"},
{"metric": "ending_entropy", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
"note": "上界 log2(结局数) 随规格变;小样本的熵方向不定"},
{"metric": "tension_curve_mae", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": false,
"note": "目标曲线是规格字段,换规格即换目标;无采样随机性"},
{"metric": "affinity_curve_mae", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
"note": "基线曲线由采样算出,n 必须固定;真源里没有 target_affinity_curve"},
{"metric": "consistency_residual", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": false,
"note": "静态计数,无采样随机性;跨规格比的是一份规格自己的失配条数"},
{"metric": "density_deviation", "within_spec_before_after": true, "across_spec": false, "needs_fixed_n": true,
"note": "可读带 [250, 750] 千分是 7.2 的设计参数;窗口密度依赖采样"},
{"metric": "invariant_residual", "within_spec_before_after": true, "across_spec": true, "needs_fixed_n": false,
"note": "恒等判据,唯一可跨规格引用的指标"}
]
}
表里最关键的一行是 invariant_residual 的 across_spec: true——七条指标里只有整数不变量残差可以跨规格引用,因为它是恒等判据(必须为 0),不依赖任何分母、目标或采样规模。其余六条都被标成 across_spec: false,needs_fixed_n 视指标而定。
判读
第一,可比性是「口径的一部分」,不是附加说明。 一个数能跟谁比、不能跟谁比,和它怎么算一样重要。15.1 定义了「怎么算」,本节定义了「跟谁比」,两节合起来才是一条指标的完整口径。第二,跨规格比是本卷最常被误用的操作。 覆盖率 446 和覆盖率 1000 摆在一起,看起来是「一条路径 vs 全部路径」,其实分母一旦不同,「446」这个词本身就不指同一样东西。本节用加删一条选项把它做成可见的:路径没动,数动了——这就是跨规格比较失真的最小例子。
第三,样本量是熵的隐藏参数。 报熵的时候不报 n,等于报了一个没有刻度的数。n=50 的 1756 和 n=20000 的 1714 谁大谁小,离开 n 就没有意义。这一条会被 16.1 的装配检查表收进去,作为「指标必须带口径」的一部分。
自检表
| 检查项 | 实测 | 合格标准 |
|---|---|---|
| 量纲守卫 | 3 / 3 被拒 | 跨量纲相加必拒 |
| 跨规格漂移 | 446 → 438 / 454 | 路径不变、分母变 |
| 熵样本量表 | 5 档 n 全跑 | 至少覆盖 50 与 20000 |
| 规则表 | 6 条,覆盖 7 / 7 | 无未覆盖指标 |
| 跨规格可引用 | 仅 1 条 | 只有整数不变量残差 |
阅读导航:上一节:15.1 指标的定义与口径 · 下一节:15.3 指标不能声称什么 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。