14.3 演化的告警

指标变了不等于指标变坏了。本节把 14.2 的增量按三级分类:OK(没变)、DRIFT(变了但未越声明阈值)、CROSS(越过阈值才判得动方向)。再以 B 层把两处规格参数连续扫一遍,扫出相变点,全程只发软告警、始终 exit 0——扰动不该让构建硬失败。

站内 《游戏设计》卷一《玩法机制与可执行规格》 做的是玩法规格的消费与演化对机制指标的影响;本章做的是叙事规格演化对路径与曲线的影响。度量对象不同——那边看一局战斗的动力学(回合数、资源、行动密度),这边看整张故事图的路径分布(覆盖率、结局分布、张力曲线)。同一份整数化真源,一边量「这一局打了几个回合」,另一边量「这张图被走成了什么形状」。

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 指标的定义与口径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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