9.3 失调的告警阈值

残差算出来了,可超过多少才算告警?本节把阈值写成一条显式策略:先算声明基数当分母,取它的四分之一作默认阈值,再把上一节的七种规格变异逐个判定;最后扫描阈值的取值区间,看清阈值过紧与过松各自把告警变成什么样。残差与残差率全部是代理指标,只做相对比较。

站内游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本章把「叙事失调」写成一个可算的残差并给出告警阈值。前两节已经能量出残差、也能量出它随规格改动怎么动,但还剩最后一问:残差是 8 还是 9,究竟算不算问题?没有阈值,残差就只是一个数,不是信号。

9.3 失调的告警阈值

阈值不是从真源里「读」出来的,它是一条策略:作者说「失配超过多少我就得当回事」。所以本节的产出不是一个「正确」的阈值,而是一条显式、可调、可审阅的策略——写成公式、给出默认值、并把它对参数的敏感性一并报出来。

三步走。第一步,给残差配一个分母:残差是「失配计数」,孤立地看 8 没有尺度,得先数出「本来有多少处声明是可以对不上的」,即声明基数。第二步,定默认阈值:取声明基数的四分之一,含义是「失配不超过四分之一就不报警」。第三步,判定:把 9.2 的七个规格变异状态各判一次,基线该告警、修到位的状态该放过。

S 层:声明基数 → 阈值 → 判定表

#!/usr/bin/env python3
# file: sim/nm_09_3_warning_threshold.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_09_3_warning_threshold.py
"""卷三 9.3 失调的告警阈值(S 层)。

残差算出来了,问题是**超过多少算告警**。本节把阈值写成一条显式策略,并算出:

  ① 声明基数 D_total:characters(各声明 affinity)+ rules + lore + 敌对派系数——残差
     是「失配计数」,基数给出它的分母,`残差率 = R × 1000 // D_total`(千分位)。
  ② 默认阈值 τ*:取声明基数的四分之一(`D_total // 4`),代表「失配不超过四分之一」。
  ③ 判定表:把 9.2 的七个变异状态各算一次残差,逐个判定 `R > τ*` → WARN / OK。

阈值是**策略参数**不是真值:τ* 只决定「多敏感才报警」。本节断言基线告警、修复后不告警,
并如实报告当前规格相对严格阈值的差距。残差与残差率都是 proxy:只做相对比较。只读真源 +
9.1/9.2 的检测器。产出 out/nm_09_3_threshold.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

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")


def declaration_base(spec: dict) -> dict:
    """残差的分母:每一处「可对不上的声明」。"""
    chars = len(spec["characters"])          # 各声明 affinity
    rules = len(spec["world"]["rules"])
    lore = len(spec["world"]["lore"])
    enemies = len(r2.m1.ENEMY) // 2          # 敌对派系数(唯一一对 guild↔church)
    return {"characters": chars, "rules": rules, "lore": lore,
            "enemy_pairs": enemies, "total": chars + rules + lore + enemies}


def main() -> int:
    base_spec = m1.load_spec()
    dbase = declaration_base(base_spec)
    tau = dbase["total"] // 4

    states = []
    for name, fn in r2.MUTATIONS:
        spec = copy.deepcopy(base_spec)
        if fn:
            fn(spec)
        states.append({"state": name, "residual": r2.residual_of(spec)["total"]})

    base_r = next(x["residual"] for x in states if x["state"] == "base")
    rate = base_r * 1000 // dbase["total"]

    print(f"spec={base_spec['meta']['title']}  失调告警阈值(proxy:失配计数与残差率,只做相对比较)")
    print(f"声明基数 D_total = characters {dbase['characters']} + rules {dbase['rules']}"
          f" + lore {dbase['lore']} + 敌对派系 {dbase['enemy_pairs']} = {dbase['total']}")
    print(f"基线残差 R = {base_r}   残差率 = {base_r} × 1000 // {dbase['total']} = {rate}‰")
    print(f"默认阈值 τ* = D_total // 4 = {tau}(失配不超过四分之一)")
    print(f"   {'状态':<15}{'残差':>5}{'判定 R>τ*':>11}")
    for x in states:
        warn = x["residual"] > tau
        x["warn"] = warn
        print(f"   {x['state']:<15}{x['residual']:>5}{'WARN' if warn else 'OK':>11}")

    warn_n = sum(1 for x in states if x["warn"])
    assert next(x for x in states if x["state"] == "base")["warn"], "基线应触发告警(R > τ*)"
    assert not next(x for x in states if x["state"] == "fix_all")["warn"], "修复后不应告警(R <= τ*)"
    print(f"   告警 {warn_n} / {len(states)} 个状态(基线告警、fix_all 不告警)")

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_09_3_threshold.json"), "w", encoding="utf-8") as fh:
        json.dump({"declaration_base": dbase, "baseline_residual": base_r, "residual_rate_permille": rate,
                   "tau": tau, "states": states, "warn_count": warn_n}, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_09_3_threshold.json")
    print(f"OK nm_09_3_warning_threshold:τ*={tau},基线 {base_r} > τ* 告警,fix_all 0 <= τ* 不告警")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/nm_09_3_warning_threshold.py:

spec=雾港夜航  失调告警阈值(proxy:失配计数与残差率,只做相对比较)
声明基数 D_total = characters 6 + rules 4 + lore 6 + 敌对派系 1 = 17
基线残差 R = 8   残差率 = 8 × 1000 // 17 = 470‰
默认阈值 τ* = D_total // 4 = 4(失配不超过四分之一)
   状态                残差    判定 R>τ*
   base               8       WARN
   fix_domain         6       WARN
   gate_secret        7       WARN
   gate_curfew        7       WARN
   drop_stance        7       WARN
   regress_secret     9       WARN
   fix_all            0         OK
   告警 6 / 7 个状态(基线告警、fix_all 不告警)
wrote out/nm_09_3_threshold.json
OK nm_09_3_warning_threshold:τ*=4,基线 8 > τ* 告警,fix_all 0 <= τ* 不告警

判读:残差率 470‰,默认阈值放过不了任何单项修复

第一,残差有了尺度。 声明基数 D_total = 6 + 4 + 6 + 1 = 17——六个角色的好感声明、四条禁则、六条 lore、一对敌对派系。基线残差 8 除以 17,得残差率 470‰:声明里将近一半的「可对不上的地方」当前确实对不上。这个数比裸的 8 更能说明问题。

第二,τ = 4 只放过「修到底」的状态。* 判定表里,只有 fix_all(残差 0)不告警,其余六个状态全告警——包括只做单项修复的 fix_domain(6)、gate_secret(7)。这是有意的严格:默认策略是「要么全对齐,要么报警」。想让单项修复过关,就得把 τ 抬高。

第三,也是本节要诚实交代的:默认阈值相对当前规格是紧的。 基线 8 远高于 τ* = 4,意味着这份规格在默认策略下长期处于告警态。这不是 bug,而是「规格确实没对齐到四分之一以内」的如实反映。阈值不改变残差,只改变「报警的嗓门」——下一节的扫描会把这层关系摊开。

B 层:把阈值扫成一条曲线

τ 是策略参数,那它对结果有多敏感?B 层把 τ 从 0 扫到 16,对七个状态逐个判定 R > τ,看告警数怎么随 τ 下降。阈值过松(τ ≥ 基线残差 8)时基线自己都不告警,记一条软告警——脚本全部 exit 0。

#!/usr/bin/env python3
# file: sim/nm_09_3_threshold_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_09_3_threshold_perturb.py
"""卷三 9.3 告警阈值的参数扰动(B 层,软告警不硬失败)。

阈值 τ 是**策略参数**:τ 越大,越宽松,告警越少。本节扫描 τ = 0…16,对 9.2 的七个变异
状态逐个判定 `R > τ`,报告「告警条数」如何随 τ 下降:

  - τ 过紧(τ < 基线残差的最小可接受值):所有状态都告警,噪声大;
  - τ = τ*(=4):只把「修到位的状态」放过,是默认策略;
  - τ 过松(τ >= 基线残差 8):基线自己都不告警——**阈值失效**,记一条 WARN。

**全部 exit 0**——τ 过松只记 WARN,断言写「告警条数已报告」,不写「必须无告警」。扰动只改
内存里的阈值参数,不写回真源。复用 9.1/9.2 的检测器。产出 out/nm_09_3_perturb.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
import nm_09_3_warning_threshold as w3

TAUS = [0, 2, 4, 6, 8, 10, 12, 16]
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")


def main() -> int:
    base_spec = m1.load_spec()
    residuals = []
    for name, fn in r2.MUTATIONS:
        spec = copy.deepcopy(base_spec)
        if fn:
            fn(spec)
        residuals.append((name, r2.residual_of(spec)["total"]))
    base_r = next(r for n, r in residuals if n == "base")
    default_tau = w3.declaration_base(base_spec)["total"] // 4

    warns: list[str] = []
    print(f"spec={base_spec['meta']['title']}  告警阈值扰动(B 层:软告警不硬失败,全部 exit 0)")
    print(f"基线残差 {base_r},默认 τ* = {default_tau};扫描 τ = {TAUS}")
    print(f"   {'τ':>4}{'告警数':>7}{'放过':>5}  判定")
    rows = []
    for tau in TAUS:
        flagged = [n for n, r in residuals if r > tau]
        passed = [n for n, r in residuals if r <= tau]
        too_loose = base_r <= tau
        rows.append({"tau": tau, "warn_count": len(flagged), "flagged": flagged,
                     "passed": passed, "baseline_warns": not too_loose})
        if too_loose:
            warns.append(f"τ={tau}:阈值过松,基线残差 {base_r} 不再告警(阈值失效)")
        print(f"   {tau:>4}{len(flagged):>7}{len(passed):>5}  {'WARN 阈值过松' if too_loose else 'OK'}")

    assert rows[0]["warn_count"] >= rows[-1]["warn_count"], "告警数应随 τ 单调不增"
    assert next(r for r in rows if r["tau"] == default_tau)["baseline_warns"], "τ* 下基线应告警"

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_09_3_perturb.json"), "w", encoding="utf-8") as fh:
        json.dump({"baseline_residual": base_r, "default_tau": default_tau,
                   "rows": rows, "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_3_perturb.json")
    print(f"OK nm_09_3_threshold_perturb:告警条数已报告 = {len(warns)} 条,exit 0(B 层口径)")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/nm_09_3_threshold_perturb.py:

spec=雾港夜航  告警阈值扰动(B 层:软告警不硬失败,全部 exit 0)
基线残差 8,默认 τ* = 4;扫描 τ = [0, 2, 4, 6, 8, 10, 12, 16]
      τ    告警数   放过  判定
      0      6    1  OK
      2      6    1  OK
      4      6    1  OK
      6      5    2  OK
      8      1    6  WARN 阈值过松
     10      0    7  WARN 阈值过松
     12      0    7  WARN 阈值过松
     16      0    7  WARN 阈值过松

WARN τ=8:阈值过松,基线残差 8 不再告警(阈值失效)
WARN τ=10:阈值过松,基线残差 8 不再告警(阈值失效)
WARN τ=12:阈值过松,基线残差 8 不再告警(阈值失效)
WARN τ=16:阈值过松,基线残差 8 不再告警(阈值失效)
wrote out/nm_09_3_perturb.json
OK nm_09_3_threshold_perturb:告警条数已报告 = 4 条,exit 0(B 层口径)

告警数随 τ 单调不增:6 → 6 → 6 → 5 → 1 → 0。这条单调性是阈值策略的底线——阈值只调敏感度,不制造非单调的怪现象(脚本用 assert 守住)。

过松会「失明」。 τ ≥ 8 时,基线残差 8 自己都不再告警(R > τ 取等不成立)。一个「基线都不报警」的阈值等于没有阈值——这是 B 层要报的软告警,四条全落在 τ ≥ 8 区间。反过来说,τ = 0/2/4 三档下告警数都是 6,说明在 0 到 4 之间,阈值对结果不敏感:这是选 τ* = 4 的一个附带好处——默认值落在平台区,不在悬崖边。

阈值 τ告警数含义
0–46平台区:只有 fix_all 放过,阈值变化无影响
65开始放过单项修复
81过松:基线自己不再告警
10–160失明:任何状态都不告警

自检表

检查项合格标准
阈值是策略显式写成公式 τ* = D_total // 4,标明是策略参数不是真值
分母显式声明基数 D_total 逐项列出,残差率给出千分位
双向断言基线应告警、fix_all 不应告警,脚本 assert
如实报告承认默认阈值对当前规格偏紧(基线长期告警),不粉饰
B 层口径过松只记 WARN,断言写「告警条数已报告」,exit 0

至此第 9 章闭环:9.1 把叙事失调量成残差,9.2 把它变成随规格演化的回归信号,9.3 给它配上阈值与告警策略。下一章换一个完全不同的输出——不再出数字,而是用纯 xml.etree 把曲线画成 SVG。

阅读导航:上一节:9.2 设定-行为对齐的回归 · 下一节:10.1 纯 xml.etree 出曲线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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