9.2 设定-行为对齐的回归

一致性残差只有能在规格改动后朝正确方向移动,才是可用的回归信号。本节在内存克隆上做七种规格变异——补声明域、给秘密与禁则加门、清掉立场提供者,以及一次故意的回归——把残差重算成一张回归表,再用修复阶梯与信任门两条扫描检查单调性。残差全部是代理指标,只做相对比较。

站内游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本章把「叙事失调」写成一个可算的残差并给出告警阈值。上一节把残差量成了静态的 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 失调的告警阈值 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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