14.3 一致性的残差与告警

把一致性写成一个整数:残差 = 规则失配 R + 立场失配 S + 秘密失配 K + 对齐缺口 A,本案例基线为 7。本节是 B 层:对门有效阈值、好感度初值、节拍密度做扰动,观察残差与覆盖率的相变——残差在阈值越过 60 时阶跃(7→9),覆盖率随初值连续下滑(−10 时 895‰),输出 3 条软告警但 exit 0。

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 03 条 WARN,exit 0
扰动不落盘只改内存副本,不写回真源story_spec.json 未改
代理已声明正文显式标注「只做相对比较」首段一处

到这里,「叙事一致性」从一个形容词变成了一个可算、可扰动的整数:基线 7,阈值越过 60 时跳到 9。下一章离开一致性,转向工具链本身——规格、仿真、内核、原型各自的目录分工。

阅读导航:上一节:14.2 设定与行为的对齐检验 · 下一节:15.1 规格、仿真、内核、原型的目录分工 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长