站内游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本章把「叙事失调」写成一个可算的残差并给出告警阈值。前两节已经能量出残差、也能量出它随规格改动怎么动,但还剩最后一问:残差是 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–4 | 6 | 平台区:只有 fix_all 放过,阈值变化无影响 |
| 6 | 5 | 开始放过单项修复 |
| 8 | 1 | 过松:基线自己不再告警 |
| 10–16 | 0 | 失明:任何状态都不告警 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 阈值是策略 | 显式写成公式 τ* = 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 出曲线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。