14.3 一致性的残差与告警
独立游戏的叙事设计与分支对话 把「一致性」当作一种手感。本节把它压成一个整数:残差 = 规则失配 R + 立场失配 S + 秘密失配 K + 对齐缺口 A。四类都由 14.1 / 14.2 的检测器给出。残差是代理指标,只做相对比较——它数「有多少处对不上」,不判「这份规格好不好」。
把残差算出来
#!/usr/bin/env python3
# file: sim/na_14_3_residual.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_14_3_residual.py
"""14.3 一致性的残差(S 层):把一致性写成一个整数。
残差 = 规则失配 R + 立场失配 S + 秘密失配 K + 对齐缺口 A,四类由 14.1 / 14.2 的检测器
给出。残差是**计数**,只做相对比较:它回答「这份规格里有多少处设定声明与规格许可对不
上」,不回答「这份规格好不好」。`is_gated` 是可替换的门口径,是扰动层唯一的注入点;对
阈值 / 好感度初值 / 节拍密度的扰动见 na_14_3_perturb.py(B 层)。
"""
from __future__ import annotations
import json
import os
import sys
import na_14_1_dissonance as d1
import na_14_2_alignment as a2
def residual(spec: dict, is_gated=d1.gated) -> dict:
"""四类失配求和。is_gated 可替换,是扰动层唯一的注入点。"""
R = d1.rule_dissonance(spec, is_gated)
S = d1.stance_dissonance(spec)
K = d1.secret_dissonance(spec, is_gated)
A = a2.gaps(spec, is_gated)
r = sum(x["count"] for x in R)
s = len(S)
k = sum(1 for x in K if x["mismatch"])
return {"R": r, "S": s, "K": k, "A": A, "total": r + s + k + A}
def main() -> int:
spec = d1.load_spec()
b = residual(spec)
print(f"spec={spec['meta']['title']} 一致性残差(proxy:失配计数,只做相对比较)")
print(f" 规则失配 R = {b['R']} 立场失配 S = {b['S']} "
f"秘密失配 K = {b['K']} 对齐缺口 A = {b['A']}")
print(f" 基线残差 = R + S + K + A = {b['R']} + {b['S']} + {b['K']} + {b['A']} = {b['total']}")
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out/na14_residual.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"baseline": b}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/na14_residual.json")
print(f"OK na_14_3_residual:基线残差 {b['total']}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/na_14_3_residual.py:
spec=雾港夜航 一致性残差(proxy:失配计数,只做相对比较)
规则失配 R = 3 立场失配 S = 2 秘密失配 K = 1 对齐缺口 A = 1
基线残差 = R + S + K + A = 3 + 2 + 1 + 1 = 7
wrote out/na14_residual.json
OK na_14_3_residual:基线残差 7
扰动:三条轴,两种形状
这是B 层:对可扰动参数做扫掠,看残差与覆盖率的相变,软告警不硬失败(脚本恒 exit 0)。三条轴各探一种形状:
- 门有效阈值 τ:一个
when只有在「含 flag/item 条件」或「aff 阈值 ≥ τ」时才算真门。τ 升高会把低阈值的 aff 门判成无效门——这是结构分类的扰动,看残差怎么跳。 - 好感度初值 Δinit / 节拍密度 k:前者整体平移 4 个初值、重采样 8000 条路径(数值扰动,看覆盖率怎么滑);后者每 k 个 beat 保留一个,看张力怎么变。
#!/usr/bin/env python3
# file: sim/na_14_3_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/na_14_3_perturb.py
"""14.3 残差与覆盖率的参数扰动(B 层,软告警不硬失败)。
三条轴:① 门有效阈值 τ(aff 门阈值 < τ 即判无效)→ 残差相变;② 好感度初值 Δinit →
覆盖率相变;③ 节拍密度 k(每 k 个 beat 保留一个)→ 张力。扰动只改内存副本,不写回
config/story_spec.json;任何偏离只打印 WARN,exit 0。
"""
from __future__ import annotations
import json
import os
import sys
import na_14_1_dissonance as d1
import na_14_3_residual as res
TAUS = [0, 30, 40, 60, 85]
INIT_DELTAS = [-10, -5, 0, 5, 10]
BEAT_KEEP = [1, 2, 3, 4]
SAMPLE_N, SEED = 8000, 20261016
WARN_RESIDUAL = 1
WARN_COVERAGE = 50
def cond_kinds(c, kinds: set, ths: list) -> None:
if not c:
return
k = c[0]
if k == "aff":
kinds.add("aff")
ths.append(c[3])
elif k in ("flag", "item"):
kinds.add(k)
elif k in ("and", "or"):
cond_kinds(c[1], kinds, ths)
cond_kinds(c[2], kinds, ths)
elif k == "not":
cond_kinds(c[1], kinds, ths)
def effective(when, tau: int) -> bool:
"""门有效判据:含 flag/item 恒有效;纯 aff 门需阈值 >= tau。"""
if when is None:
return False
kinds, ths = set(), []
cond_kinds(when, kinds, ths)
if kinds & {"flag", "item"}:
return True
return bool(ths) and min(ths) >= tau
def gated_at(tau: int):
return lambda c: effective(c.get("when"), tau)
def eval_cond(c, st: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
if k == "not":
return not eval_cond(c[1], st)
raise ValueError(f"unknown cond op: {k}")
def apply_effects(eff: dict, st: dict) -> dict:
aff, flags, items = dict(st["aff"]), dict(st["flags"]), dict(st["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = min(100, max(0, aff.get(k, 0) + v))
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"aff": aff, "flags": flags, "items": items}
def coverage(spec: dict, delta: int) -> dict:
"""Δinit 下的路径覆盖率(千分比)与结局分布;MINSTD 可播种 PRNG。"""
by_id = {n["id"]: n for n in spec["dialogue"]}
total = len(d1.choices(spec))
aff0 = {c["id"]: min(100, max(0, c["affinity"]["init"] + delta)) for c in spec["characters"]}
init = {"aff": aff0, "flags": {f: 0 for f in spec["state"]["flags"]},
"items": {i: 0 for i in spec["state"]["items"]}}
rng = SEED
seen = set()
ends = {e["name"]: 0 for e in spec["endings"]}
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
for _ in range(SAMPLE_N):
node, st = spec["meta"]["start"], init
for _step in range(64):
cur = by_id[node]
if cur.get("ending"):
ends[cur["ending"]] += 1
break
opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), st)]
if not opts:
break
c = opts[rnd(len(opts))]
seen.add((node, c["id"]))
st = apply_effects(c.get("effects") or {}, st)
node = c["goto"]
return {"permille": (len(seen) * 1000) // max(1, total), "edges": len(seen), "endings": ends}
def main() -> int:
spec = d1.load_spec()
warns: list[str] = []
out: dict = {"sample_n": SAMPLE_N, "seed": SEED}
base = res.residual(spec)
print(f"spec={spec['meta']['title']} 基线残差 {base['total']}"
f"(R {base['R']} S {base['S']} K {base['K']} A {base['A']})")
print(f"软告警阈值:残差 Δ >= {WARN_RESIDUAL};覆盖率 Δ >= {WARN_COVERAGE}‰。全部 exit 0。")
print("\n① 门有效阈值 τ → 残差(τ 越高,越多 aff 门被判无效)")
print(f" {'τ':>4}{'R':>5}{'S':>5}{'K':>5}{'A':>5}{'残差':>7}{'Δ':>5}")
out["tau"] = {}
for tau in TAUS:
r = res.residual(spec, gated_at(tau))
d = r["total"] - base["total"]
out["tau"][str(tau)] = r
print(f" {tau:>4}{r['R']:>5}{r['S']:>5}{r['K']:>5}{r['A']:>5}{r['total']:>7}{d:>+5}")
if abs(d) >= WARN_RESIDUAL:
warns.append(f"τ={tau} 残差 {r['total']} 偏离基线 {d:+d}")
print(f"\n② 好感度初值扰动 Δinit → 路径覆盖率(采样 {SAMPLE_N} 条)")
print(f" {'Δinit':>6}{'覆盖边':>7}{'覆盖率':>8}{'Δ‰':>6} 结局分布")
cov = {d: coverage(spec, d) for d in INIT_DELTAS}
base_cov = cov[0]["permille"]
for d in INIT_DELTAS:
c = cov[d]
rel = c["permille"] - base_cov
print(f" {d:>+6}{c['edges']:>7}{c['permille']:>7}‰{rel:>+6} {c['endings']}")
if abs(rel) >= WARN_COVERAGE:
warns.append(f"Δinit={d:+d} 覆盖率偏离基线 {rel:+d}‰")
out["init"] = {str(k): v for k, v in cov.items()}
print("\n③ 节拍密度 k(每 k 个 beat 保留一个)→ 张力")
print(f" {'k':>3}{'保留':>5}{'平均张力':>9}{'高潮数':>7}")
out["beat"] = {}
for k in BEAT_KEEP:
kept = spec["beats"][::k]
mean = sum(b["tension"] for b in kept) / len(kept)
peaks = sum(1 for b in kept if b["tension"] == 5)
out["beat"][str(k)] = {"kept": len(kept), "mean": round(mean, 3), "peaks": peaks}
print(f" {k:>3}{len(kept):>5}{mean:>9.2f}{peaks:>7}")
print()
print("\n".join(f"WARN {w}" for w in warns) if warns
else "无软告警:三条扰动轴均未越过阈值")
out["warnings"] = warns
here = os.path.dirname(os.path.abspath(__file__))
p = os.path.join(here, "../out/na14_perturb.json")
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/na14_perturb.json")
print(f"OK na_14_3_perturb:扰动已跑完,{len(warns)} 条软告警,exit 0(B 层口径)")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/na_14_3_perturb.py:
spec=雾港夜航 基线残差 7(R 3 S 2 K 1 A 1)
软告警阈值:残差 Δ >= 1;覆盖率 Δ >= 50‰。全部 exit 0。
① 门有效阈值 τ → 残差(τ 越高,越多 aff 门被判无效)
τ R S K A 残差 Δ
0 3 2 1 1 7 +0
30 3 2 1 1 7 +0
40 3 2 1 1 7 +0
60 3 2 1 1 7 +0
85 4 2 2 1 9 +2
② 好感度初值扰动 Δinit → 路径覆盖率(采样 8000 条)
Δinit 覆盖边 覆盖率 Δ‰ 结局分布
-10 103 895‰ -105 {'灯守': 255, '离港': 4174, '真相': 38, '缄默': 1316, '沉没': 2217}
-5 104 904‰ -96 {'灯守': 338, '离港': 4123, '真相': 60, '缄默': 1305, '沉没': 2174}
+0 115 1000‰ +0 {'灯守': 417, '离港': 4048, '真相': 75, '缄默': 1294, '沉没': 2166}
+5 115 1000‰ +0 {'灯守': 528, '离港': 3953, '真相': 132, '缄默': 1288, '沉没': 2099}
+10 115 1000‰ +0 {'灯守': 599, '离港': 3930, '真相': 136, '缄默': 1275, '沉没': 2060}
③ 节拍密度 k(每 k 个 beat 保留一个)→ 张力
k 保留 平均张力 高潮数
1 16 3.25 3
2 8 3.12 1
3 6 3.33 2
4 4 3.00 1
WARN τ=85 残差 9 偏离基线 +2
WARN Δinit=-10 覆盖率偏离基线 -105‰
WARN Δinit=-5 覆盖率偏离基线 -96‰
wrote out/na14_perturb.json
OK na_14_3_perturb:扰动已跑完,3 条软告警,exit 0(B 层口径)
残差是阶跃,覆盖率是连续
- 残差是阶跃。τ 从 0 一路升到 60,残差一直是 7;到 τ = 85 才一次跳到 9(R 加 1、K 加 1)。原因是残差只依赖结构(哪些选项有门、哪些秘密有门),而
hui的秘密见证hui_trusts的三道门阈值最高是 60——τ 一旦越过 60,这三道门全被判无效,hui就落进「秘密无条件敞开」那一类。残差对数值不敏感,对结构敏感:它是阶跃函数,不是连续函数。 - 覆盖率是连续下滑。Δinit 从 0 往下,覆盖率从 1000‰ 掉到 904‰(−5)、895‰(−10)——初值越低,越多高阈值的门够不着,可走的边越少。往上则被 1000‰ 的封顶挡住(115/115 全走遍,加无可加)。这条轴是连续的。
软告警清单
脚本按阈值(残差 Δ ≥ 1、覆盖率 Δ ≥ 50‰)打了 3 条 WARN:τ=85 的残差偏离、Δinit=−5 与 Δinit=−10 的覆盖率偏离。这些是告警不是失败——B 层不判对错,只报告「哪一档扰动把指标推出了阈值带」。要看的是位置:残差的告警出现在 τ 越过 60 的那一格,覆盖率的告警出现在初值被压低的负向。
自检表
| 检查项 | 合格标准 | 本案例 |
|---|---|---|
| 残差可复算 | 由 14.1 / 14.2 的检测器给出,不手填 | 3 + 2 + 1 + 1 = 7 |
| 两种形状都出现 | 至少一条轴阶跃、一条轴连续 | 残差阶跃 / 覆盖率连续 |
| 软告警不硬失败 | 越过阈值只打 WARN,exit 0 | 3 条 WARN,exit 0 |
| 扰动不落盘 | 只改内存副本,不写回真源 | story_spec.json 未改 |
| 代理已声明 | 正文显式标注「只做相对比较」 | 首段一处 |
到这里,「叙事一致性」从一个形容词变成了一个可算、可扰动的整数:基线 7,阈值越过 60 时跳到 9。下一章离开一致性,转向工具链本身——规格、仿真、内核、原型各自的目录分工。
阅读导航:上一节:14.2 设定与行为的对齐检验 · 下一节:15.1 规格、仿真、内核、原型的目录分工 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。