站内 《游戏设计》卷一《玩法机制与可执行规格》 做的是玩法规格的消费与演化对机制指标的影响;本章做的是叙事规格演化对路径与曲线的影响。度量对象不同——那边看一局战斗的动力学(回合数、资源、行动密度),这边看整张故事图的路径分布(覆盖率、结局分布、张力曲线)。同一份整数化真源,一边量「这一局打了几个回合」,另一边量「这张图被走成了什么形状」。
14.2 的对照表列出了增量,但增量带符号并不等于结论。这一节先把增量按三级分类,再用 B 层的连续扰动扫描把「阈值在哪里被越过」扫出来。全部数字本机实跑,全部指标都是 proxy,B 层只发软告警、始终 exit 0。
14.3 演化的告警
告警的第一原则是别把「指标变了」当成「指标变坏了」:
- OK:增量 = 0,什么都没动。
- DRIFT:增量 ≠ 0,但没有越过任何声明阈值。指标确实变了,可没有任何书面声明被违反。此时只能报变化幅度,不能判方向。
- CROSS:越过声明阈值。阈值是设计意图的书面表达,越过它,方向才由阈值本身给出(比如「结局熵跌破 1500」就是可判定的「分支收敛过度」)。
S 层:把增量分成三级
sim/nm_14_3_warning_scan.py 复用 14.1 的内核与 14.2 的对照表,对「改动 × 指标」的每个组合打上 OK / DRIFT / CROSS。
#!/usr/bin/env python3
# file: sim/nm_14_3_warning_scan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_14_3_warning_scan.py
"""卷三 14.3 演化的告警(S 层):把 14.2 的增量分类成三级。
复用 14.1 的内核与 14.2 的对照表(只 import,不改),对每个「改动 × 指标」组合判定:
OK 增量 = 0
DRIFT 增量 ≠ 0 但未越阈值 指标**变了**,但没有任何**声明**被违反
CROSS 越过声明阈值 指标的**声明**被违反,方向由阈值本身给出
**「指标变了」与「指标变坏了」是两件事。** 只对 CROSS 报「违反声明」——阈值
(coverage_target_permille / ending_entropy_min_milli_bits / tension_mae_max)是
**设计意图**的书面表达,越过它才有一个可判定的方向;DRIFT 一律只报变化幅度,不判方向。
全部整数,不出现浮点;软告警不硬失败。产出 out/nm_14_3_warning.json。
"""
from __future__ import annotations
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
import nm_14_2_regression_table as reg # noqa: E402 只读 import
ROOT = imp1.ROOT
OUT = imp1.OUT
JSON_PATH = os.path.join(OUT, "nm_14_3_warning.json")
def classify(delta: int, crossed: bool) -> str:
if delta == 0:
return "OK"
return "CROSS" if crossed else "DRIFT"
def main() -> int:
spec = imp1.load_json(os.path.join(ROOT, "config/story_spec.json"))
cfg = imp1.load_json(imp1.CONFIG)
seqs = [r["choices"] for r in imp1.load_json(imp1.BASELINE)]
base = {**imp1.metrics(spec, seqs), "canonical_set": imp1.collect(spec, seqs)["canonical"]}
rows = reg.build_rows(spec, seqs, cfg, base)
findings = [{"mutation": r["id"], "metric": k, "delta": r["delta"][k],
"crossed": k in r["threshold_crossed"],
"level": classify(r["delta"][k], k in r["threshold_crossed"])}
for r in rows for k in reg.METRIC_KEYS]
counts = {lv: sum(1 for f in findings if f["level"] == lv) for lv in ("OK", "DRIFT", "CROSS")}
print(f"扫描 {len(rows)} 处改动 × {len(reg.METRIC_KEYS)} 个指标 = {len(findings)} 个组合")
print(f" OK {counts['OK']} · DRIFT {counts['DRIFT']} · CROSS {counts['CROSS']}")
print()
print(f"{'改动':<26}{'指标':<24}{'增量':>8}{'级别':>8}")
for f in findings:
if f["level"] != "OK":
print(f"{f['mutation']:<26}{reg.METRIC_LABEL[f['metric']]:<24}{f['delta']:>+8}{f['level']:>8}")
print()
print("「指标变了」= 上面所有非 OK 行;「指标变坏了」= 只有 CROSS 行可判方向")
if counts["CROSS"]:
for f in findings:
if f["level"] == "CROSS":
print(f"WARN CROSS {f['mutation']} · {reg.METRIC_LABEL[f['metric']]} 越过声明阈值")
else:
print("本轮五处小改动均未越过任何声明阈值:全部是 DRIFT,无一 CROSS")
print("(DRIFT 只报变化幅度,不判方向——判方向需要阈值之外的设计意图)")
assert counts["OK"] + counts["DRIFT"] + counts["CROSS"] == len(findings)
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"counts": counts, "findings": findings,
"note": "CROSS = 越过声明阈值(可判方向);DRIFT = 仅变化,不判方向"},
fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_14_3_warning.json")
print(f"OK nm_14_3_warning_scan:OK {counts['OK']} / DRIFT {counts['DRIFT']} / CROSS {counts['CROSS']}")
return 0
if __name__ == "__main__":
sys.exit(main())
扫描 5 处改动 × 4 个指标 = 20 个组合
OK 14 · DRIFT 6 · CROSS 0
改动 指标 增量 级别
M1_gate_dawn_fallback 结局分布熵(毫比特) -78 DRIFT
M2_raise_lit_threshold 结局分布熵(毫比特) -213 DRIFT
M3_add_shortcut_goto 分支覆盖率(‰) -18 DRIFT
M4_beat_tension_shift 张力 MAE(毫) +62 DRIFT
M5_gate_lighthouse_entry 结局分布熵(毫比特) +2 DRIFT
M5_gate_lighthouse_entry 一致性残差(计数) -1 DRIFT
「指标变了」= 上面所有非 OK 行;「指标变坏了」= 只有 CROSS 行可判方向
本轮五处小改动均未越过任何声明阈值:全部是 DRIFT,无一 CROSS
(DRIFT 只报变化幅度,不判方向——判方向需要阈值之外的设计意图)
wrote out/nm_14_3_warning.json
OK nm_14_3_warning_scan:OK 14 / DRIFT 6 / CROSS 0
20 个组合里 14 个 OK、6 个 DRIFT、0 个 CROSS——五处小改动都「变了但没坏」。
B 层:连续扰动 → 软告警
单点改动都没越阈,那阈值在哪里会被越过?把参数连续扫一遍就能看到相变点。sim/nm_14_3_perturb_sweep.py 扫两把「扫帚」:灯守门槛 T 从 40 到 90,张力 ≤ 3 的节拍逐个 +2。越阈只打印 WARN,脚本始终 exit 0——扰动扫描的职责是报告,不是拦住构建。
#!/usr/bin/env python3
# file: sim/nm_14_3_perturb_sweep.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_14_3_perturb_sweep.py
"""卷三 14.3 演化的告警(B 层):参数扰动 → 软告警,不硬失败。
14.3 的主档位是 B:把两处规格参数**连续扫**一遍,看指标从哪里开始越过**声明阈值**。
扫帚 A 「灯守」结局的好感度门槛 T:40 → 90(步长 10),6 个点。
量被阻断路径、覆盖率、结局熵、一致性残差。
扫帚 B 把张力 ≤ 3 的节拍逐个 +2(0 → 9 个),量张力 MAE。
每个点按声明阈值(覆盖率 ≥ 850‰、结局熵 ≥ 1500 毫比特、张力 MAE ≤ 1000 毫)判定是否
越过,越过的点打印 **WARN**——**软告警**:只打印,脚本始终 exit 0,不做硬断言。
数字全部本机实跑;全部整数,不出现浮点。产出 out/nm_14_3_sweep.json。
"""
from __future__ import annotations
import copy
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:collect / metrics
ROOT = imp1.ROOT
OUT = imp1.OUT
JSON_PATH = os.path.join(OUT, "nm_14_3_sweep.json")
def set_lit_threshold(spec: dict, t: int) -> dict:
clone = copy.deepcopy(spec)
for n in clone["dialogue"]:
for c in (n.get("choices") or []):
if c["id"] == "c_dq_2a":
c["when"] = ["and", ["flag", "found_yan_journal", 1], ["aff", "yan", ">=", t]]
return clone
def shift_beats(spec: dict, k: int) -> dict:
"""把张力 ≤ 3 的前 k 个节拍各 +2(保证张力仍落在 1–5 整数域内)。"""
clone = copy.deepcopy(spec)
done = 0
for b in clone["beats"]:
if done >= k:
break
if b["tension"] <= 3:
b["tension"] += 2
done += 1
assert done == k, f"可加 2 的节拍不足 {k} 个"
return clone
def evaluate(spec: dict, seqs: list, thr: dict) -> dict:
mm = imp1.metrics(spec, seqs)
crossed = []
if mm["coverage_permille"] < thr["coverage_permille"]:
crossed.append("coverage")
if mm["ending_entropy_milli_bits"] < thr["ending_entropy_min_milli_bits"]:
crossed.append("entropy")
if mm["tension_mae_milli"] > thr["tension_mae_max_milli"]:
crossed.append("mae")
return {"blocked": mm["blocked"], "coverage_permille": mm["coverage_permille"],
"ending_entropy_milli_bits": mm["ending_entropy_milli_bits"],
"tension_mae_milli": mm["tension_mae_milli"],
"consistency_residual": mm["consistency_residual"], "crossed": crossed}
def main() -> int:
spec = imp1.load_json(os.path.join(ROOT, "config/story_spec.json"))
seqs = [r["choices"] for r in imp1.load_json(imp1.BASELINE)]
m = spec["metrics"]
thr = {"coverage_permille": m["coverage_target_permille"],
"ending_entropy_min_milli_bits": m["ending_entropy_min_milli_bits"],
"tension_mae_max_milli": m["tension_mae_max"] * 1000}
warns = 0
print("扫帚 A:灯守门槛 T = 40..90(步长 10)")
print(f"{'T':>4}{'阻断':>7}{'覆盖‰':>8}{'熵毫比特':>10}{'残差':>6} 越阈")
sweep_a, first_entropy_cross = [], None
for t in range(40, 95, 10):
r = evaluate(set_lit_threshold(spec, t), seqs, thr) | {"T": t}
sweep_a.append(r)
flag = "entropy" in r["crossed"]
first_entropy_cross = t if flag and first_entropy_cross is None else first_entropy_cross
print(f"{t:>4}{r['blocked']:>7}{r['coverage_permille']:>8}{r['ending_entropy_milli_bits']:>10}"
f"{r['consistency_residual']:>6} {'CROSS' if flag else '-'}")
if flag:
warns += 1
print(f"WARN 扫帚A T={t}:结局熵 {r['ending_entropy_milli_bits']} 毫比特"
f" 低于声明阈值 {thr['ending_entropy_min_milli_bits']}(软告警,不硬失败)")
print()
print("扫帚 B:张力 ≤ 3 的节拍逐个 +2(0..9 个)")
print(f"{'k':>4}{'MAE毫':>8} 越阈")
sweep_b, first_mae_cross = [], None
for k in range(0, 10):
r = evaluate(shift_beats(spec, k), seqs, thr) | {"k": k}
sweep_b.append(r)
flag = "mae" in r["crossed"]
first_mae_cross = k if flag and first_mae_cross is None else first_mae_cross
print(f"{k:>4}{r['tension_mae_milli']:>8} {'CROSS' if flag else '-'}")
if flag:
warns += 1
print(f"WARN 扫帚B k={k}:张力 MAE {r['tension_mae_milli']} 毫"
f" 高于声明阈值 {thr['tension_mae_max_milli']}(软告警,不硬失败)")
floor = min(r["ending_entropy_milli_bits"] for r in sweep_a)
print()
print(f"扫帚 A 结局熵地板 {floor} 毫比特(灯守门槛 T≥80 后灯守完全不可达,四结局分布不再变化)")
if first_entropy_cross is None:
print(f"扫帚 A 全程未跌破声明阈值:熵地板 {floor} ≥ {thr['ending_entropy_min_milli_bits']},"
f"恰好压线(相差 {floor - thr['ending_entropy_min_milli_bits']} 毫比特)")
print(f"相变点:节拍 +2 达 k={first_mae_cross} 起张力 MAE 越过声明阈值 "
f"({thr['tension_mae_max_milli']} 毫)")
print(f"软告警合计 {warns} 条(全部 WARN,exit 0)")
assert first_mae_cross is not None, "扫帚 B 应扫出张力 MAE 的相变点"
assert all(1 <= b["tension"] <= 5 for b in shift_beats(spec, 9)["beats"]), "张力须落在 1–5"
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"thresholds": thr, "sweep_lit_threshold": sweep_a, "sweep_beat_shift": sweep_b,
"entropy_floor_milli_bits": floor, "first_entropy_cross_T": first_entropy_cross,
"first_mae_cross_k": first_mae_cross, "warns": warns},
fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_14_3_sweep.json")
print(f"OK nm_14_3_perturb_sweep:扫出相变点,{warns} 条软告警,exit 0")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
扫帚 A:灯守门槛 T = 40..90(步长 10)
T 阻断 覆盖‰ 熵毫比特 残差 越阈
40 0 1000 1747 8 -
50 14 1000 1673 8 -
60 24 1000 1598 8 -
70 30 1000 1534 8 -
80 32 982 1500 8 -
90 32 982 1500 8 -
扫帚 B:张力 ≤ 3 的节拍逐个 +2(0..9 个)
k MAE毫 越阈
0 0 -
1 125 -
2 250 -
3 375 -
4 500 -
5 625 -
6 750 -
7 875 -
8 1000 -
9 1125 CROSS
WARN 扫帚B k=9:张力 MAE 1125 毫 高于声明阈值 1000(软告警,不硬失败)
扫帚 A 结局熵地板 1500 毫比特(灯守门槛 T≥80 后灯守完全不可达,四结局分布不再变化)
扫帚 A 全程未跌破声明阈值:熵地板 1500 ≥ 1500,恰好压线(相差 0 毫比特)
相变点:节拍 +2 达 k=9 起张力 MAE 越过声明阈值 (1000 毫)
软告警合计 1 条(全部 WARN,exit 0)
wrote out/nm_14_3_sweep.json
OK nm_14_3_perturb_sweep:扫出相变点,1 条软告警,exit 0
判读:相变点与「恰好压线」
第一,两把扫帚扫出两种不同形态。 扫帚 A(灯守门槛)是单调退化:门槛越升,越多序列走不到灯守,熵从 1747 一路掉到 1500 的地板;门槛 ≥ 80 后灯守完全不可达,熵停在 1500 不再变。扫帚 B(节拍 +2)是线性累加:每加一个节拍,张力 MAE 加 125 毫,到 k=9 时 1125 毫——刚好越过 1000 毫的声明阈值,这是本轮唯一的 CROSS。
第二,扫帚 A 的「恰好压线」比越阈更有信息量。 熵地板停在 1500,而声明阈值也是 1500——相差 0 毫比特。也就是说,即使把灯守门槛推到最极端,结局熵也只是贴住下限、没有跌破。这不是巧合,是这张故事图的结构决定的:四结局里灯守被拿掉后,剩下三个结局的分布熵恰好等于 1500。「没越阈」不等于「安全」——它可能正踩在线上。
第三,B 层只发 1 条软告警,且 exit 0。 扰动扫描的职责是报告相变点,不是拦住构建。如果它一见越阈就非零退出,那任何一次合法的设计调参都会被它拦下来。软告警写进日志、写进 out/nm_14_3_sweep.json,让人来判断。
所有数字都是 proxy 口径下的结构量:熵、覆盖率、MAE、残差只做相对比较,不声称任何体验结论。「指标变了」是 DRIFT,「指标变坏了」才需要 CROSS 加设计意图——这一条是整个第 14 章的收束。
自检表
| 检查项 | 合格标准 | 本节实测 |
|---|---|---|
| 三级分类 | OK / DRIFT / CROSS 全覆盖 | 14 / 6 / 0 |
| 阈值来源 | 读 metrics 声明值 | 850‰ / 1500 毫比特 / 1000 毫 |
| 相变点 | 扫出越过阈值的参数点 | 扫帚 B k=9,MAE 1125 毫 |
| 软告警 | 越阈只 WARN,不硬失败 | 1 条 WARN,exit 0 |
至此第 14 章闭环:14.1 用集合差量影响面,14.2 把影响面压成回归对照表,14.3 把增量分成三级并扫出相变点。下一章换一个更基础的问题——这些指标本身该怎么定义、口径该怎么写死,否则不同人量出的「覆盖率」根本没法比。
阅读导航:上一节:14.2 路径与指标的回归 · 下一节:15.1 指标的定义与口径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。