站内 《游戏设计》卷一《玩法机制与可执行规格》 做的是玩法规格的消费与演化对机制指标的影响;本章做的是叙事规格演化对路径与曲线的影响。度量对象不同——那边看一局战斗的动力学(回合数、资源、行动密度),这边看整张故事图的路径分布(覆盖率、结局分布、张力曲线)。同一份整数化真源,一边量「这一局打了几个回合」,另一边量「这张图被走成了什么形状」。
14.1 量出了影响面,但影响面是一堆集合差,还不能回答「这次改动该不该合入」。这一节把它压成一张回归对照表:左边是路径级影响,右边是四个指标的绝对值与增量,最后一列对着 metrics 里的声明阈值逐格判越阈。全部数字本机实跑,全部指标都是 proxy。
14.2 路径与指标的回归
回归的口径要先定清楚,否则「表」会变成一堆没法比较的数:
- 行 = 五处改动各一行,外加一行基线。
- 列 = 路径级(阻断路径数、终局状态集对称差)与指标级(覆盖率、结局熵、张力 MAE、一致性残差)两组。
- 判据 =
config/story_spec.json的metrics段里已经写死的声明阈值:覆盖率 ≥ 850‰、结局熵 ≥ 1500 毫比特、张力 MAE ≤ 1000 毫。阈值是设计意图的书面表达,不是回归脚本自己拍的。
另外要落一份回归基线:把原规格的四个指标存成 out/nm_14_2_baseline.json,以后每次改规格只需重跑本脚本,就能与基线比。基线文件不存在时脚本会建立它,存在时读回核对——这就是「回归」两个字在工程上的落点。
S 层:把影响面压成对照表
sim/nm_14_2_regression_table.py 只 import 14.1 的内核(apply_mutation / collect / metrics),不重复实现推进语义。它对五处改动各算一行,并把 CSV 与 JSON 一起落盘。
#!/usr/bin/env python3
# file: sim/nm_14_2_regression_table.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_14_2_regression_table.py
"""卷三 14.2 路径与指标的回归(S 层):把改动前后做成一张对照表。
复用 14.1 的影响面内核(只 import sim/nm_14_1_evolution_impact.py,不改它),
对五处小改动各算一列:
路径级(来自 500 条基线的重放)
blocked_paths 被阻断的路径数
state_symdiff 终局状态集(规范串)的对称差大小
指标级(全部整数,千分位 / 毫)
coverage_permille 走过的去重边 / 全部去重边
ending_entropy_milli_bits 500 条采样的结局分布熵
tension_mae_milli 全 16 个节拍的实际曲线 vs 目标曲线
consistency_residual 第 9 章同一口径的静态残差 D + K + S + R
同时落一份**回归基线** out/nm_14_2_baseline.json(原规格的四个指标),后续演化
只需再跑一次本脚本即可与基线比对。全部整数,不出现浮点;只读 config/story_spec.json。
产出 out/nm_14_2_regression.json / .csv / out/nm_14_2_baseline.json。
"""
from __future__ import annotations
import csv
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import nm_14_1_evolution_impact as imp1 # noqa: E402 只读 import:apply_mutation / collect / metrics
ROOT = imp1.ROOT
OUT = imp1.OUT
BASELINE = os.path.join(OUT, "nm_14_2_baseline.json")
JSON_PATH = os.path.join(OUT, "nm_14_2_regression.json")
CSV_PATH = os.path.join(OUT, "nm_14_2_regression.csv")
METRIC_KEYS = ["coverage_permille", "ending_entropy_milli_bits",
"tension_mae_milli", "consistency_residual"]
METRIC_LABEL = {"coverage_permille": "分支覆盖率(‰)",
"ending_entropy_milli_bits": "结局分布熵(毫比特)",
"tension_mae_milli": "张力 MAE(毫)",
"consistency_residual": "一致性残差(计数)"}
def thresholds(spec: dict) -> dict:
m = spec["metrics"]
return {"coverage_permille": ("min", m["coverage_target_permille"]),
"ending_entropy_milli_bits": ("min", m["ending_entropy_min_milli_bits"]),
"tension_mae_milli": ("max", m["tension_mae_max"] * 1000),
"consistency_residual": ("ref", 0)}
def crossed(key: str, value: int, thr: dict) -> bool:
kind, lim = thr[key]
if kind == "min":
return value < lim
if kind == "max":
return value > lim
return False
def build_rows(spec: dict, seqs: list[list[str]], cfg: dict, base: dict) -> list[dict]:
"""五处改动各一行:路径级影响 + 四个指标的绝对值与增量。"""
thr = thresholds(spec)
rows = []
for mut in cfg["mutations"]:
m_spec = imp1.apply_mutation(spec, mut)
c = imp1.collect(m_spec, seqs)
mm = imp1.metrics(m_spec, seqs)
delta = {k: mm[k] - base[k] for k in METRIC_KEYS}
rows.append({
"id": mut["id"], "note": mut["note"], "kind": mut["kind"],
"blocked_paths": c["blocked"],
"state_symdiff": len(c["canonical"] ^ base["canonical_set"]),
"dedup_edges_total": mm["dedup_edges_total"],
"metrics": {k: mm[k] for k in METRIC_KEYS},
"delta": delta,
"threshold_crossed": [k for k in METRIC_KEYS if crossed(k, mm[k], thr)],
})
return rows
def main() -> int:
spec = imp1.load_json(os.path.join(ROOT, "config/story_spec.json"))
cfg = imp1.load_json(imp1.CONFIG)
base_rows = imp1.load_json(imp1.BASELINE)
seqs = [r["choices"] for r in base_rows]
base_collect = imp1.collect(spec, seqs)
base_metrics = imp1.metrics(spec, seqs)
base = {**base_metrics, "canonical_set": base_collect["canonical"]}
# 回归基线文件:不存在则建立,存在则读回核对
if os.path.exists(BASELINE):
with open(BASELINE, encoding="utf-8") as fh:
saved = json.load(fh)
same = all(saved.get(k) == base[k] for k in METRIC_KEYS)
print(f"回归基线 {os.path.relpath(BASELINE, ROOT)}({os.path.getsize(BASELINE)} 字节,"
f"n={saved.get('samples')})与本机现算一致:{same}")
else:
os.makedirs(OUT, exist_ok=True)
with open(BASELINE, "w", encoding="utf-8") as fh:
json.dump({**base_metrics, "samples": len(seqs), "seed": cfg["baseline"]["seed"]},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"回归基线不存在 → 已建立 {os.path.relpath(BASELINE, ROOT)}({os.path.getsize(BASELINE)} 字节)")
rows = build_rows(spec, seqs, cfg, base)
thr = thresholds(spec)
print()
print(f"基线 覆盖 {base['coverage_permille']}‰ 熵 {base['ending_entropy_milli_bits']} 毫比特"
f" MAE {base['tension_mae_milli']} 毫 残差 {base['consistency_residual']}"
f" (去重边 {base['dedup_edges_total']},采样 {len(seqs)} 条)")
print()
head = f"{'改动':<26}{'阻断':>6}{'状态差':>7}"
for k in METRIC_KEYS:
head += f"{METRIC_LABEL[k][:6]:>9}"
print(head)
base_line = f"{'基线':<26}{0:>6}{0:>7}"
for k in METRIC_KEYS:
base_line += f"{base[k]:>9}"
print(base_line)
for r in rows:
line = f"{r['id']:<26}{r['blocked_paths']:>6}{r['state_symdiff']:>7}"
for k in METRIC_KEYS:
line += f"{r['metrics'][k]:>9}"
print(line)
print()
print("增量(改动后 − 基线):")
for r in rows:
parts = " ".join(f"{METRIC_LABEL[k][:6]} {r['delta'][k]:+d}" for k in METRIC_KEYS)
cross = "越过声明阈值: " + (", ".join(METRIC_LABEL[k] for k in r["threshold_crossed"]) or "无")
print(f" {r['id']:<26} {parts} [{cross}]")
print()
print(f"声明阈值:覆盖率 ≥ {thr['coverage_permille'][1]}‰ · 结局熵 ≥ "
f"{thr['ending_entropy_milli_bits'][1]} 毫比特 · 张力 MAE ≤ {thr['tension_mae_milli'][1]} 毫")
for r in rows:
assert 0 <= r["metrics"]["coverage_permille"] <= 1000, "覆盖率须在 [0,1000]‰"
assert r["metrics"]["ending_entropy_milli_bits"] >= 0, "熵非负"
assert r["metrics"]["tension_mae_milli"] >= 0, "MAE 非负"
assert r["blocked_paths"] <= len(seqs)
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["mutation", "blocked_paths", "state_symdiff", "dedup_edges_total"]
+ [f"{k}" for k in METRIC_KEYS] + [f"delta_{k}" for k in METRIC_KEYS]
+ ["threshold_crossed"])
for r in rows:
w.writerow([r["id"], r["blocked_paths"], r["state_symdiff"], r["dedup_edges_total"]]
+ [r["metrics"][k] for k in METRIC_KEYS]
+ [r["delta"][k] for k in METRIC_KEYS]
+ ["|".join(r["threshold_crossed"])])
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"baseline": {k: base[k] for k in METRIC_KEYS} | {"dedup_edges_total": base["dedup_edges_total"]},
"thresholds": {k: {"kind": thr[k][0], "limit": thr[k][1]} for k in METRIC_KEYS},
"rows": rows}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_14_2_regression.json / .csv")
print("OK nm_14_2_regression_table:五处改动的路径级与指标级对照表已落盘")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_14_2_regression_table.py(首次运行,基线文件尚不存在):
回归基线不存在 → 已建立 out/nm_14_2_baseline.json(327 字节)
基线 覆盖 1000‰ 熵 1747 毫比特 MAE 0 毫 残差 8 (去重边 56,采样 500 条)
改动 阻断 状态差 分支覆盖率( 结局分布熵( 张力 MAE 一致性残差(
基线 0 0 1000 1747 0 8
M1_gate_dawn_fallback 127 254 1000 1669 0 8
M2_raise_lit_threshold 30 60 1000 1534 0 8
M3_add_shortcut_goto 0 0 982 1747 0 8
M4_beat_tension_shift 0 0 1000 1747 62 8
M5_gate_lighthouse_entry 123 193 1000 1749 0 7
增量(改动后 − 基线):
M1_gate_dawn_fallback 分支覆盖率( +0 结局分布熵( -78 张力 MAE +0 一致性残差( +0 [越过声明阈值: 无]
M2_raise_lit_threshold 分支覆盖率( +0 结局分布熵( -213 张力 MAE +0 一致性残差( +0 [越过声明阈值: 无]
M3_add_shortcut_goto 分支覆盖率( -18 结局分布熵( +0 张力 MAE +0 一致性残差( +0 [越过声明阈值: 无]
M4_beat_tension_shift 分支覆盖率( +0 结局分布熵( +0 张力 MAE +62 一致性残差( +0 [越过声明阈值: 无]
M5_gate_lighthouse_entry 分支覆盖率( +0 结局分布熵( +2 张力 MAE +0 一致性残差( -1 [越过声明阈值: 无]
声明阈值:覆盖率 ≥ 850‰ · 结局熵 ≥ 1500 毫比特 · 张力 MAE ≤ 1000 毫
wrote out/nm_14_2_regression.json / .csv
OK nm_14_2_regression_table:五处改动的路径级与指标级对照表已落盘
(第二次起第一行换成「回归基线 …(327 字节,n=500)与本机现算一致:True」——基线一旦落盘,回归就是读回比对而不是重算。)
判读:五处改动全是「变了但未越阈」
第一,表里没有一格越阈。 声明阈值是覆盖率 ≥ 850‰、结局熵 ≥ 1500 毫比特、张力 MAE ≤ 1000 毫,而五处改动的实际值最差也只是覆盖率 982‰、熵 1534 毫比特、MAE 62 毫——都还在阈值以内。这不是「改动没影响」,而是「影响还没大到违反设计意图」。
第二,覆盖率与熵这两列的方向常常相反。 M1/M2/M5 都是阻断型改动:M1 的熵 1747→1669(−78),M2 的熵 1747→1534(−213),M5 的熵 1747→1749(+2,反而升了一点)。同时 M1/M2/M5 的覆盖率都停在 1000‰——因为被阻断的路径是不再到达某些边,但 500 条基线本来就只覆盖了全部去重边的一部分,阻断没有减少「走过的边种类」。只看一列会得出错误结论。
第三,M3 与 M4 只有一列动,且是两列里不同的那一列。 M3(加 goto)只动覆盖率(1000→982‰,分母 56→57),M4(改节拍张力)只动 MAE(0→62 毫)。其余指标一格没变。「改动打在哪个指标上」是有指纹的——把五行的增量竖着看,每行的非零列就是这次改动的签名。
第四,增量列带符号,但符号不等于好坏。 M3 的 −18‰ 看着像「退化」,可它来自分母变大、行为未变;M5 的 −1 残差看着像「改善」,但那只是删掉了一个 R 类规则失配。符号只说方向,方向的好坏要靠阈值或设计意图来判——这正是下一节「指标变了 ≠ 指标变坏了」要展开的。
| 改动 | 路径级 | 覆盖率 Δ | 熵 Δ | MAE Δ | 残差 Δ | 越阈 |
|---|---|---|---|---|---|---|
| M1 加 flag 门 | 阻断 127 / 状态差 254 | 0 | −78 | 0 | 0 | 无 |
| M2 改阈值 | 阻断 30 / 状态差 60 | 0 | −213 | 0 | 0 | 无 |
| M3 加 goto | 全 0 | −18 | 0 | 0 | 0 | 无 |
| M4 改节拍张力 | 全 0 | 0 | 0 | +62 | 0 | 无 |
| M5 加 flag 门 | 阻断 123 / 状态差 193 | 0 | +2 | 0 | −1 | 无 |
表落成了 out/nm_14_2_regression.csv(给表格工具)与 out/nm_14_2_regression.json(给脚本),以后每次改规格重跑一次就能比对。所有数值都是 proxy 口径下的结构量,只做相对比较。
自检表
| 检查项 | 合格标准 | 本节实测 |
|---|---|---|
| 基线落盘 | 原规格四指标写入 baseline.json | 327 字节,n=500 |
| 对照完整 | 5 行改动 × 4 指标 | 20 格齐 |
| 阈值来源 | 读 metrics 声明值 | 850‰ / 1500 毫比特 / 1000 毫 |
| 增量口径 | 改动后 − 基线,带符号 | 见增量列 |
| 落盘产物 | JSON + CSV 双份 | 均已写出 |
表能看出一格越没越阈,但它不会替你回答「这次演化要不要告警、告警到什么程度」。下一节把「指标变了」和「指标变坏了」分开:前者是 DRIFT,后者只有越过声明阈值(CROSS)才判得动,而 B 层的扰动扫描只发软告警、始终 exit 0。
阅读导航:上一节:14.1 规格演化的影响面 · 下一节:14.3 演化的告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。