14.3 回归与告警

把基线摘要、结局分布、整数不变量残差落成回归基线文件,规格每改一次就重跑比对。本节给三类告警:摘要失配、分布漂移、残差非零,各配一个内存内注入负例,实测三组全部触发。告警只打印 WARN 并 exit 0,与 B 层口径一致;而负例测试必须硬失败。

14.3 回归与告警

站内服务端的叙事分支状态架构 用版本号与幂等重放保证状态在服务端不乱,那是架构层的正确性。本节是引擎层的回归:规格不是一次写死的,它会被人改——补一句台词、调一个 add、动一个阈值。每改一次,昨天还成立的结论就可能不再成立。本节把「当前规格算出来的东西」存成回归基线,让改动可见、可告警。全程不涉及网络、数据库、并发,只在一份规格与一个内核里跑。

基线是什么:三个量

基线不是一个数字,而是三个不同粒度的量:

量定义作用
摘要 summary_digest固定批次(N 条同种子路径)终局状态摘要的 FNV-1a 汇总最敏感:任何状态变化都改它
分布 ending_distribution同一批路径的 5 结局计数定位:告诉你哪个结局动了
残差 residual整数不变量残差(越界 + 静态死路 + 零选项状态 + 缺失结局)判真伪:规格是否已内部破掉

三者的敏感度与信息量相反:摘要在最前面报警(什么都可能触发它),分布紧随其后(指出漂移方向),残差最后(只在规格真的坏了时才非零)。所以三类告警不是互斥的——一次改动常常同时触发摘要失配与分布漂移;残差非零则是最严重的信号。

三类告警

告警触发条件强度
summary_mismatchsummary_digest 与基线不同软
distribution_drift任一结局占比偏离基线超过 5‰软
residual_nonzero整数不变量残差 != 0软

三类都是软告警:打印 WARN、exit 0,与 B 层口径一致——因为「分布变了」不等于「规格错了」,它可能正是这次改动的目的。真正该硬失败的,是负例测试:如果注入一个已知破坏,告警却一声不响,那才必须 exit 1。

S 层:建立基线并比对

S 层脚本先看基线文件在不在:不在就写一份、在就读进来比对;随后跑三组内存内注入负例,演示三类告警各自会开火:

#!/usr/bin/env python3
# file: sim/ne_14_3_regression.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_14_3_regression.py
"""卷二 14.3 回归与告警(S 层)。

把「基线摘要 + 结局分布 + 残差」落成回归基线文件,再跑一次比对:

  · 摘要摘要 summary_digest —— 固定批次(N 条同种子路径)终局状态摘要的 FNV-1a 汇总;
  · 结局分布 ending_distribution —— 同一批路径的 5 结局计数;
  · 残差 residual —— 整数不变量残差(好感度越界 + 静态死路 + 零选项状态 + 缺失结局),恒为 0。

三类告警(**软告警**:打印 WARN、exit 0,与 B 层口径一致):
  ① 摘要失配 summary_mismatch —— summary_digest 与基线不同;
  ② 分布漂移 distribution_drift —— 任一结局占比偏离基线超过阈值(千分比);
  ③ 残差非零 residual_nonzero —— 整数不变量残差 != 0。

同时跑三组**内存内**注入负例(不改 config/story_spec.json),演示三类告警确实会开火。
随机一律自实现可播种 PRNG(Lehmer MINSTD),禁用语言内置 random。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as base  # noqa: E402  复用 load_spec / eval_cond / apply_effects

ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
BASELINE = os.path.join(OUT, "ne_14_3_baseline.json")
N = 2000
SEED = 20261016
STEP_BUDGET = 200
DIST_DRIFT_PERMILLE_MAX = 5


def init_state(spec: dict) -> dict:
    aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])},
            "path": [spec["meta"]["start"]]}


def canonical(s: dict) -> str:
    aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
    fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
    it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
    return f"{s['node']}|{aff}|{fl}|{it}|{'>'.join(s['path'])}"


def digest(s: dict) -> int:
    h = 2166136261
    for ch in canonical(s):
        h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
    return h


def measure(spec: dict, n: int = N, seed: int = SEED) -> dict:
    """跑一批同种子路径,返回 (摘要, 分布, 残差) 三元组。"""
    by = {x["id"]: x for x in spec["dialogue"]}
    start = spec["meta"]["start"]
    rng = seed

    def rnd(mod: int) -> int:
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % mod

    dist = {e["name"]: 0 for e in spec["endings"]}
    aff_bad = 0
    zero_opt = 0
    digests = []
    for _ in range(n):
        s = init_state(spec)
        while True:
            cur = by.get(s["node"])
            if cur is None or cur.get("ending"):
                if cur is not None:
                    dist[cur["ending"]] += 1
                    digests.append(digest(s))
                break
            opts = [c for c in (cur.get("choices") or []) if base.eval_cond(c.get("when"), s)]
            if not opts:
                zero_opt += 1
                break
            c = opts[rnd(len(opts))]
            ns = base.apply_effects(c.get("effects") or {}, {"aff": s["aff"], "flags": s["flags"], "items": s["items"]})
            s = {"node": c["goto"], "aff": ns["aff"], "flags": ns["flags"], "items": ns["items"],
                 "path": [*s["path"], c["goto"]]}
            for v in spec["state"]["vars"]:
                if not (0 <= s["aff"][v] <= 100):
                    aff_bad += 1

    summary = 2166136261
    for d in digests:
        for ch in str(d):
            summary = ((summary ^ ord(ch)) * 16777619) & 0xFFFFFFFF
    static_dead = sum(1 for x in spec["dialogue"] if not (x.get("choices") or []) and not x.get("ending"))
    missing = sum(1 for e in spec["endings"] if dist[e["name"]] == 0)
    residual = aff_bad + static_dead + zero_opt + missing
    return {"summary_digest": summary, "ending_distribution": dist,
            "residual": residual, "n": n, "seed": seed,
            "residual_parts": {"affinity_out_of_range": aff_bad, "static_dead_ends": static_dead,
                               "zero_option_states": zero_opt, "missing_endings": missing}}


def drift_permille(base_dist: dict, cur_dist: dict, n: int) -> int:
    worst = 0
    for k in base_dist:
        d = abs(cur_dist.get(k, 0) - base_dist[k]) * 1000 // max(1, n)
        if d > worst:
            worst = d
    return worst


def compare(base_metrics: dict, cur: dict) -> list:
    """返回告警列表(软告警,不影响 exit code)。"""
    alarms = []
    if cur["summary_digest"] != base_metrics["summary_digest"]:
        alarms.append(("summary_mismatch", f"基线 {base_metrics['summary_digest']} → 现 {cur['summary_digest']}"))
    drift = drift_permille(base_metrics["ending_distribution"], cur["ending_distribution"], cur["n"])
    if drift > DIST_DRIFT_PERMILLE_MAX:
        alarms.append(("distribution_drift", f"最大占比偏离 {drift}‰(阈值 {DIST_DRIFT_PERMILLE_MAX}‰)"))
    if cur["residual"] != 0:
        alarms.append(("residual_nonzero", f"残差 {cur['residual']} {cur['residual_parts']}"))
    return alarms


def mutate_add(spec: dict, choice_id: str, key: str, delta: int) -> dict:
    clone = json.loads(json.dumps(spec))
    for x in clone["dialogue"]:
        for c in (x.get("choices") or []):
            if c["id"] == choice_id:
                c.setdefault("effects", {}).setdefault("add", {})[key] += delta
    return clone


def mutate_when(spec: dict, choice_id: str, new_when) -> dict:
    clone = json.loads(json.dumps(spec))
    for x in clone["dialogue"]:
        for c in (x.get("choices") or []):
            if c["id"] == choice_id:
                c["when"] = new_when
    return clone


def mutate_init(spec: dict, char_id: str, val: int) -> dict:
    clone = json.loads(json.dumps(spec))
    for c in clone["characters"]:
        if c["id"] == char_id:
            c["affinity"]["init"] = val
    return clone


def main() -> int:
    spec = base.load_spec()
    cur = measure(spec)

    # ---- 建立或读取基线 ----
    if not os.path.exists(BASELINE):
        os.makedirs(OUT, exist_ok=True)
        with open(BASELINE, "w", encoding="utf-8") as fh:
            json.dump(cur, fh, ensure_ascii=False, indent=2)
            fh.write("\n")
        print(f"基线不存在 → 已建立 {os.path.relpath(BASELINE, ROOT)}({os.path.getsize(BASELINE)} 字节)")
        base_metrics = cur
    else:
        with open(BASELINE, encoding="utf-8") as fh:
            base_metrics = json.load(fh)
        print(f"基线 {os.path.relpath(BASELINE, ROOT)}({os.path.getsize(BASELINE)} 字节,n={base_metrics['n']},seed={base_metrics['seed']})")

    # ---- 正常比对 ----
    alarms = compare(base_metrics, cur)
    print(f"当前:摘要 {cur['summary_digest']} · 分布 {cur['ending_distribution']} · 残差 {cur['residual']}")
    if alarms:
        for kind, detail in alarms:
            print(f"WARN {kind}:{detail}")
    else:
        print("比对通过:摘要一致、分布无漂移、残差 0")

    # ---- 三组注入负例:证明三类告警会开火(内存内,不改 config) ----
    demos = [
        ("摘要失配", mutate_add(spec, "c_pier_1b", "hui", 1)),
        ("分布漂移", mutate_when(spec, "c_dq_2b", None)),
        ("残差非零", mutate_init(spec, "yan", 150)),
    ]
    fired = 0
    for label, mutated in demos:
        m = measure(mutated)
        al = compare(cur, m)
        kinds = [k for k, _ in al]
        print(f"负例[{label}] 摘要 {m['summary_digest']} · 残差 {m['residual']} → 告警 {kinds}")
        for kind, detail in al:
            print(f"WARN {kind}:{detail}")
        if al:
            fired += 1
    print(f"三组负例触发告警 {fired}/3(软告警:全部 WARN,exit 0)")
    if fired < 3:
        print("FAIL 有负例未触发任何告警,告警器形同虚设")
        return 1
    print("OK ne_14_3_regression:基线可比对、三类告警均可触发(软告警不硬失败)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/ne_14_3_regression.py(基线已存在):

基线 out/ne_14_3_baseline.json(351 字节,n=2000,seed=20261016)
当前:摘要 4205648192 · 分布 {'灯守': 117, '离港': 1032, '真相': 21, '缄默': 304, '沉没': 526} · 残差 0
比对通过:摘要一致、分布无漂移、残差 0
负例[摘要失配] 摘要 3795856790 · 残差 0 → 告警 ['summary_mismatch']
WARN summary_mismatch:基线 4205648192 → 现 3795856790
负例[分布漂移] 摘要 734158025 · 残差 0 → 告警 ['summary_mismatch', 'distribution_drift']
WARN summary_mismatch:基线 4205648192 → 现 734158025
WARN distribution_drift:最大占比偏离 305‰(阈值 5‰)
负例[残差非零] 摘要 1543395875 · 残差 17227 → 告警 ['summary_mismatch', 'distribution_drift', 'residual_nonzero']
WARN summary_mismatch:基线 4205648192 → 现 1543395875
WARN distribution_drift:最大占比偏离 32‰(阈值 5‰)
WARN residual_nonzero:残差 17227 {'affinity_out_of_range': 17227, 'static_dead_ends': 0, 'zero_option_states': 0, 'missing_endings': 0}
三组负例触发告警 3/3(软告警:全部 WARN,exit 0)
OK ne_14_3_regression:基线可比对、三类告警均可触发(软告警不硬失败)

正常比对通过:摘要 4205648192 一致、分布无漂移、残差 0。三组负例各触发告警:①改一个 add 值(2→3)→ summary_mismatch;②把真相结局的门 c_dq_2b 改成无条件 → distribution_drift 305‰;③把 yan 初值改成 150 → residual_nonzero 17227。三组全部 exit 0——软告警不硬失败。

基线文件

基线文件是纯 JSON,体积 351 字节,落在 out/ne_14_3_baseline.json:

{
  "summary_digest": 4205648192,
  "ending_distribution": {
    "灯守": 117,
    "离港": 1032,
    "真相": 21,
    "缄默": 304,
    "沉没": 526
  },
  "residual": 0,
  "n": 2000,
  "seed": 20261016,
  "residual_parts": {
    "affinity_out_of_range": 0,
    "static_dead_ends": 0,
    "zero_option_states": 0,
    "missing_endings": 0
  }
}

改一个 add 值就会让 summary_digest 从 4205648192 跳到 3795856790——摘要失配是最灵敏的探针。但它只告诉你「变了」,不告诉你「哪里变」。要定位,看分布漂移:把真相结局的门去掉后,distribution_drift 报到 305‰,一眼就知道偏的是真相那条线。要判断「规格是不是真的坏了」,看残差:把 yan 初值设成 150 后,残差跳到 17227(全部来自 affinity_out_of_range),其余三项仍为 0——残差把「哪一类不变量被破坏」也一并报了出来。

一个细节值得写清:分布漂移的阈值必须高于采样噪声。本节固定 2000 条同种子路径,同种子同规格两次跑出的分布逐项相同(摘要也相同),所以基线比对是零噪声的;阈值 5‰ 只用来挡「改一处导致的小抖动」,不挡「换种子重采样」——后者本来就该重算基线。要更稳就把 N 调大,代价是跑得更慢。

告警的强度分级

层行为为什么
负例测试必须 硬 fail(exit 1)注入已知破坏却无声,是告警器失效,绝不能放过
基线比对软告警(WARN,exit 0)分布变化可能是改动的目的,硬失败会挡住正常迭代

这条分级与全书一致:能被证明是错的(负例)硬失败,只是变了(基线)软告警。把两者倒过来,要么让告警器永远沉默,要么让每次正常改动都被拦下。

自检表

检查项合格标准
基线三量摘要 / 分布 / 残差都落盘,粒度互补
零噪声同种子同规格两次比对逐项相同
三类告警可触发摘要失配 / 分布漂移 / 残差非零各有一个注入负例
软硬分级基线比对 WARN + exit 0,负例硬 fail
真源不动注入在克隆体上做,story_spec.json 零改动

基线让「改动可见」,告警让「改动可判」。第 14 章到此把内核的可测试性这条线走完:属性测试管性质、模糊测试管随机输入、回归基线管改动。下一章换一个维度——规格变大之后,求值与遍历的开销会怎么长。

阅读导航:上一节:14.2 随机路径的模糊测试 · 下一节:15.1 规格规模的增长 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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