站内游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本章把「叙事失调」写成一个可算的残差并给出告警阈值。上一节把残差量成了静态的 8,但一个不会动的数当不了哨兵——本节要回答的是:规格改一处,这个数朝哪个方向动、动多少。
9.2 设定-行为对齐的回归
一致性残差的价值不在「此刻等于几」,而在「改一处之后怎么变」。设定与行为的对齐不是一次性的检查,而是随规格演化的回归信号:补上一个缺失的状态变量,残差应当下降;删掉一个立场提供者,残差也应当下降;反过来,给某个角色偷偷加一条无门秘密,残差必须上升——上升才说明这个指标真的在盯「声明与行为对不对得上」,而不是一个与改动无关的常数。
本节在内存克隆上做七种规格变异,每次重算残差,输出一张回归表。变异只改内存副本,真源 config/story_spec.json 一个字节都不动——这是「回归测试」与「改配置」的分界。
S 层:七种规格变异 → 残差回归表
脚本复用 9.1 的检测器,只替换被变异的那一处,然后重算 D / K / S / R 四项:
#!/usr/bin/env python3
# file: sim/nm_09_2_alignment_regression.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_09_2_alignment_regression.py
"""卷三 9.2 设定-行为对齐的回归(S 层)。
一致性残差(9.1)只有成为**回归信号**才有用:规格改一处,残差必须**朝正确方向动**。
本节在**内存克隆**上做七种规格变异,重算残差,输出一张回归表:
base / fix_domain(补 state.vars)/ gate_secret(给砚的无门秘密加门)/ gate_curfew(给禁区
入口加门)/ drop_stance(清掉沈的 add 提供者)/ regress_secret(给徽加无门秘密,回归方向)/
fix_all(四项修复叠加)。
**变异只改内存副本,config/story_spec.json 一个字节都不动**。残差是 proxy:失配计数,只做
相对比较。复用 9.1 的检测器。产出 out/nm_09_2_regression.json。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import nm_09_1_residual_metric as m1
N, SEED = 2000, 20261016
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
def _find(spec: dict, node_id: str, choice_id: str) -> dict:
n = next(x for x in spec["dialogue"] if x["id"] == node_id)
return next(c for c in n["choices"] if c["id"] == choice_id)
def fix_domain(spec: dict) -> None:
for cid in ("lin", "hai"):
if cid not in spec["state"]["vars"]:
spec["state"]["vars"].append(cid)
def gate_secret(spec: dict) -> None:
"""给砚的两个无门秘密提供者加门——砚从此无无门提供者,K 降 1。"""
for cid in ("c_tc_3a", "c_tc_3b"):
_find(spec, "d_tc_3", cid)["when"] = ["flag", "cave_explored", 1]
def gate_curfew(spec: dict) -> None:
_find(spec, "d_hub_a2", "c_hub_a2a")["when"] = ["flag", "saw_lighthouse_light", 1]
def drop_stance(spec: dict) -> None:
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
(c.get("effects") or {}).get("add", {}).pop("shen", None)
def regress_secret(spec: dict) -> None:
_find(spec, "d_ch_1", "c_ch_1a")["effects"]["set"]["hui_trusts"] = 1
def gate_all_secret(spec: dict) -> None:
"""把三个失配角色的**全部**无门秘密提供者加门:yan 2 个、lin 3 个、hai 2 个。"""
gate_secret(spec)
for cid in ("c_wp_1a", "c_wp_1b", "c_wp_1c", "c_pier_1b", "c_tc_2b"):
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
if c["id"] == cid:
c["when"] = ["flag", "knows_yan_missing", 1]
def gate_all_curfew(spec: dict) -> None:
gate_curfew(spec)
_find(spec, "d_hub_b2", "c_hub_b2a")["when"] = ["flag", "saw_lighthouse_light", 1]
MUTATIONS = [
("base", None), ("fix_domain", fix_domain), ("gate_secret", gate_secret),
("gate_curfew", gate_curfew), ("drop_stance", drop_stance),
("regress_secret", regress_secret),
("fix_all", lambda s: (fix_domain(s), gate_all_secret(s), gate_all_curfew(s), drop_stance(s))),
]
def residual_of(spec: dict) -> dict:
st = m1.static_residual(spec)
d, k = len(st["D"]), sum(1 for x in st["K"] if x["mismatch"])
s, r = len(st["S"]), len(st["R"])
return {"D": d, "K": k, "S": s, "R": r, "total": d + k + s + r}
def main() -> int:
base_spec = m1.load_spec()
rows, base_total = [], None
print(f"spec={base_spec['meta']['title']} 回归:规格变异 → 残差(proxy:失配计数,只做相对比较)")
print(f" {'变异':<15}{'D':>3}{'K':>3}{'S':>3}{'R':>3}{'残差':>6}{'Δ':>5}")
for name, fn in MUTATIONS:
spec = copy.deepcopy(base_spec)
if fn:
fn(spec)
r = residual_of(spec)
base_total = r["total"] if base_total is None else base_total
rows.append({"mutation": name, **r, "delta": r["total"] - base_total})
print(f" {name:<15}{r['D']:>3}{r['K']:>3}{r['S']:>3}{r['R']:>3}{r['total']:>6}{r['total'] - base_total:>+5}")
d = {x["mutation"]: x for x in rows}
assert d["fix_domain"]["D"] == 0 and d["drop_stance"]["S"] == 0, "声明域 / 立场修复未归零"
assert d["gate_secret"]["K"] == d["base"]["K"] - 1 and d["gate_curfew"]["R"] == d["base"]["R"] - 1, "加门未降残差"
assert d["regress_secret"]["K"] == d["base"]["K"] + 1 and d["fix_all"]["total"] == 0, "回归方向或 fix_all 不符"
print(" ✓ 六条方向断言全过(fix_domain/gate_secret/gate_curfew/drop_stance/regress_secret/fix_all)")
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_09_2_regression.json"), "w", encoding="utf-8") as fh:
json.dump({"baseline_total": base_total, "rows": rows, "seed": SEED}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_09_2_regression.json")
print(f"OK nm_09_2_alignment_regression:基线残差 {base_total},7 种变异方向全部符合预期")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_09_2_alignment_regression.py:
spec=雾港夜航 回归:规格变异 → 残差(proxy:失配计数,只做相对比较)
变异 D K S R 残差 Δ
base 2 3 1 2 8 +0
fix_domain 0 3 1 2 6 -2
gate_secret 2 2 1 2 7 -1
gate_curfew 2 3 1 1 7 -1
drop_stance 2 3 0 2 7 -1
regress_secret 2 4 1 2 9 +1
fix_all 0 0 0 0 0 -8
✓ 六条方向断言全过(fix_domain/gate_secret/gate_curfew/drop_stance/regress_secret/fix_all)
wrote out/nm_09_2_regression.json
OK nm_09_2_alignment_regression:基线残差 8,7 种变异方向全部符合预期
判读:修复朝下、回归朝上
修复方向全部朝下。 fix_domain 把 lin / hai 补进 state.vars,D 从 2 归 0,残差 8 → 6;gate_secret 给砚的两个无门提供者加门,K 从 3 降 2,残差 → 7;gate_curfew 给一条禁区入口加门,R 从 2 降 1,残差 → 7;drop_stance 清掉沈的 add 提供者,S 从 1 归 0,残差 → 7。四项修复叠加(fix_all)把残差压到 0——对齐是可以做到的,代价是逐处补门。
回归方向朝上。 regress_secret 给徽的某条选项偷偷加了无门秘密提供者,K 从 3 升 4,残差 8 → 9。这一行是整张表的重点:一个只会下降的指标是坏的,能双向响应才是回归信号。指标若对「变坏」无反应,规格就会在无声中漂移。
四类失配各自可归因。 每行只动一类,D / K / S / R 各自单列——fix_domain 只动 D、gate_secret 只动 K,其余三列不动。这让残差可归因:残差涨了,是哪一类涨的、涨在哪个角色或哪条选项上,表里一眼可见。
B 层:修复阶梯与信任门两条扫描
回归表只给了七个离散点。B 层把它拉成两条连续扫描:修复阶梯把四项修复按顺序逐步叠加(k = 0…4),检查残差是否单调下降;信任门扫描把 9.1 里「低信任」的判据 τ(默认 85)从 0 扫到 100,看路径级秘密实现率怎么动。任何一级只记软告警,脚本全部 exit 0——B 层的口径是「把偏离报出来」,不是「让构建失败」。
#!/usr/bin/env python3
# file: sim/nm_09_2_mutation_sweep.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_09_2_mutation_sweep.py
"""卷三 9.2 对齐回归的参数扰动(B 层,软告警不硬失败)。
把 9.2 的「规格变异 → 残差」做成两条连续扫描,只打印软告警:
① 修复步数 k = 0…4:cumulative 应用四项修复,看残差阶梯是否**单调下降**;
某一级 Δ >= 0(修复没让残差下降)记一条 WARN。
② 信任门 τ = 0…100:扫描「低信任」判据(默认 85),看路径级秘密实现率怎么动;
偏离基线 >= 100‰ 记一条 WARN。
**全部 exit 0**——任何一级只记 WARN,断言写「告警条数已报告」,不写「必须无告警」。扰动只
改内存副本,不写回真源。复用 9.1/9.2 的检测器。产出 out/nm_09_2_sweep.json。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import nm_09_1_residual_metric as m1
import nm_09_2_alignment_regression as r2
N, SEED = 2000, 20261016
TAUS = [0, 20, 40, 60, 85, 100]
WARN_TAU_PERMILLE = 100
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
STEPS = [("base", []), ("+fix_domain", [r2.fix_domain]),
("+gate_secret", [r2.fix_domain, r2.gate_all_secret]),
("+gate_curfew", [r2.fix_domain, r2.gate_all_secret, r2.gate_all_curfew]),
("+drop_stance", [r2.fix_domain, r2.gate_all_secret, r2.gate_all_curfew, r2.drop_stance])]
def secret_rate(spec: dict, tau: int) -> int:
old = m1.TRUST_GATE
m1.TRUST_GATE = tau
try:
return m1.sample_paths(spec, N, SEED)["secret_paths"] * 1000 // N
finally:
m1.TRUST_GATE = old
def main() -> int:
base_spec = m1.load_spec()
warns: list[str] = []
print(f"spec={base_spec['meta']['title']} 对齐回归扰动(B 层:软告警不硬失败,全部 exit 0)")
print("\n① 修复步数 k → 残差阶梯(cumulative)")
print(f" {'步':<14}{'残差':>5}{'Δ':>5} 判定")
step_rows, prev = [], None
for name, fns in STEPS:
spec = copy.deepcopy(base_spec)
for fn in fns:
fn(spec)
tot = r2.residual_of(spec)["total"]
delta = None if prev is None else tot - prev
step_rows.append({"step": name, "total": tot, "delta": delta})
bad = delta is not None and delta >= 0
if bad:
warns.append(f"修复步 {name}:残差未下降(Δ {delta:+d})")
print(f" {name:<14}{tot:>5}{'' if delta is None else f'{delta:+d}':>5} {'WARN' if bad else 'OK'}")
prev = tot
print(f"\n② 信任门 τ → 路径级秘密实现率(采样 {N} 条)")
print(f" {'τ':>4}{'秘密‰':>7}{'Δ‰':>6} 判定")
base_rate = secret_rate(base_spec, m1.TRUST_GATE)
tau_rows = []
for tau in TAUS:
pm = secret_rate(base_spec, tau)
rel = pm - base_rate
bad = abs(rel) >= WARN_TAU_PERMILLE
tau_rows.append({"tau": tau, "permille": pm, "delta": rel})
if bad:
warns.append(f"τ={tau}:秘密实现率偏离基线 {rel:+d}‰")
print(f" {tau:>4}{pm:>7}{rel:>+6} {'WARN' if bad else 'OK'}")
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_09_2_sweep.json"), "w", encoding="utf-8") as fh:
json.dump({"steps": step_rows, "tau": tau_rows, "baseline_tau": m1.TRUST_GATE,
"warning_count": len(warns), "warnings": warns}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print()
print("\n".join(f"WARN {w}" for w in warns) if warns else "无软告警:修复阶梯单调、τ 扫描均在阈内")
print("wrote out/nm_09_2_sweep.json")
print(f"OK nm_09_2_mutation_sweep:告警条数已报告 = {len(warns)} 条,exit 0(B 层口径)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_09_2_mutation_sweep.py:
spec=雾港夜航 对齐回归扰动(B 层:软告警不硬失败,全部 exit 0)
① 修复步数 k → 残差阶梯(cumulative)
步 残差 Δ 判定
base 8 OK
+fix_domain 6 -2 OK
+gate_secret 3 -3 OK
+gate_curfew 1 -2 OK
+drop_stance 0 -1 OK
② 信任门 τ → 路径级秘密实现率(采样 2000 条)
τ 秘密‰ Δ‰ 判定
0 0 -908 WARN
20 807 -101 WARN
40 843 -65 OK
60 889 -19 OK
85 908 +0 OK
100 908 +0 OK
WARN τ=0:秘密实现率偏离基线 -908‰
WARN τ=20:秘密实现率偏离基线 -101‰
wrote out/nm_09_2_sweep.json
OK nm_09_2_mutation_sweep:告警条数已报告 = 2 条,exit 0(B 层口径)
修复阶梯单调下降。 8 → 6 → 3 → 1 → 0,四级全为负 Δ,无一条 WARN。这说明四项修复互不抵消:每一项都独立地让声明与行为更对齐。
信任门扫描暴露了判据的敏感区。 τ 越小(把「低信任」定义得越苛刻),被算作「低信任触达秘密」的路径就越少:τ = 0 时归 0(任何路径都达不到「好感 < 0」),τ = 20 时 807‰。这条扫描不是缺陷报告,而是敏感性报告:它告诉读者「秘密实现率 908‰」这个数有多依赖 τ = 85 这个约定。换判据,数就变——这正是代理指标必须显式声明读法的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 变异不动真源 | 七种变异全部 copy.deepcopy,config/story_spec.json 未改 |
| 方向断言 | 六条断言覆盖四类失配的升与降,脚本 assert |
| 归因可读 | 每行只动一类,D/K/S/R 单列,残差可归因到类别 |
| B 层口径 | 扰动只记 WARN,断言写「告警条数已报告」,exit 0 |
| proxy 声明 | 残差是计数、实现率是占比,只做相对比较 |
回归表把「残差」变成了「残差的变化率」——它回答了「改动是否让对齐变好」。但一个回归信号还需要最后一件事:多大的残差才值得报警。下一节给这个数配上阈值与告警策略。
阅读导航:上一节:9.1 一致性残差的度量 · 下一节:9.3 失调的告警阈值 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。