《游戏设计》卷二《策略涌现与体验度量》 那一套从机制参数反推体验曲线;本章把同一套反推思路搬到叙事层——但只对结构参数(节拍张力标注、阈值、采样规模)反推结构曲线,不碰机制动力学。那一套的稳健性问的是「参数微扰后最优解还落在同一区域吗」,靠连续空间的邻域概念;本章的稳健性问的是「目标曲线微扰后解集还是不是那一批」,靠两个整数解集的集合运算——没有邻域,只有交并差。
稳健性问的是「换个扰动,解集还一样吗」
13.2 给出一个数:达标解有 6 072 047 140 个。但一个大数本身说明不了什么——如果这 60 亿个解在扰动下整体平移,说明反推是脆弱的;如果几乎不动,说明欠定是结构性的,跟阈值调得好不好无关。要区分这两种情况,得做扰动实验。扰动有两条轴,本节各配一个脚本:
| 轴 | 动什么 | 由谁算 | 怎么量 |
|---|---|---|---|
| 目标扰动 | 把 target_tension_curve 的 b10 从 5 改成 4 | S 层 | 新旧解集的交 / 失 / 增 / Jaccard(千分) |
| 阈值扰动 | MAE 阈值从 0 毫扫到 2000 毫 | B 层 | 解集规模随阈值的相变(DP 精确计数) |
目标扰动要精确回答,就得把解集逐个列出来做集合运算——60 亿的量级做不到,所以选三个可枚举的预算(2 / 3 / 4),在它们上做精确的交并差,而不是采样估计。
S 层:可枚举解集的精确集合运算
sim/nm_13_3_inversion_robustness.py 在预算 2 / 3 / 4 上枚举解集,对扰动求交、失、增与 Jaccard,再取两个都达标的解逐位列出。这是纯确定性集合运算,不含阈值扫描。
#!/usr/bin/env python3
# file: sim/nm_13_3_inversion_robustness.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_13_3_inversion_robustness.py
"""卷三 13.3 可枚举解集的精确集合运算(S 层):把预算内的解真的列出来做交并差。
13.2 只回答「解集有多大」;要量稳健性就必须把解**列出来**。本脚本在三个可枚举的
预算(2 / 3 / 4,分别 415 / 3915 / 27442 个解)上枚举解集,再对「目标曲线 b10 5→4」
这个扰动求新旧解集的交、失、增与 Jaccard(千分)。**纯确定性集合运算**——阈值扫描
与软告警是本节 B 层(nm_13_3_threshold_sweep.py)的事。另取两个都达标的解
逐位列出,指出分歧位置,作为「多解」的实例。
口径(proxy):全部为结构量,只做相对比较。全部整数。
产出 out/nm_13_3_inversion_robustness.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
def deltas_for(t: int) -> list:
return list(range(1 - t, 5 - t + 1))
def enumerate_within(ranges: list, budget: int) -> list:
"""把预算内的解逐个列出来(带剪枝的递归,不做全空间 product)。"""
n, out = len(ranges), []
def rec(i: int, cost: int, acc: list) -> None:
if cost > budget:
return
if i == n:
out.append(tuple(acc))
return
for d in ranges[i]:
acc.append(d)
rec(i + 1, cost + abs(d), acc)
acc.pop()
rec(0, 0, [])
return out
def mae_milli(vec: list, tgt: list) -> int:
return sum(abs(a - b) for a, b in zip(vec, tgt)) * 1000 // len(tgt)
def main() -> int:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
beats = spec["beats"]
n = len(beats)
tgt = list(spec["metrics"]["target_tension_curve"])
ranges = [deltas_for(t) for t in tgt]
tgt2 = list(tgt)
tgt2[9] = 4 # 扰动:b10 5→4
ranges2 = [deltas_for(t) for t in tgt2]
print(f"目标曲线 {' '.join(str(x) for x in tgt)}({n} 拍)")
print("可枚举预算上的解集集合运算(扰动 = 目标 b10 5→4):")
shift = []
for b in (2, 3, 4):
s1 = set(enumerate_within(ranges, b))
s2 = set(enumerate_within(ranges2, b))
inter, union = len(s1 & s2), len(s1 | s2)
jac = inter * 1000 // union if union else 0
shift.append({"budget": b, "size": len(s1), "size_shifted": len(s2),
"inter": inter, "lost": len(s1 - s2), "gained": len(s2 - s1),
"jaccard_milli": jac})
print(f" 预算 {b}:|S| {len(s1)} |S'| {len(s2)} 交 {inter} "
f"失 {len(s1 - s2)} 增 {len(s2 - s1)} Jaccard {jac} 千分")
print("多解演示(预算 4,MAE ≤ 250 毫):")
s4 = set(enumerate_within(ranges, 4))
sol_a = tuple([0] * n)
sol_b = tuple(-1 if i == 3 else (1 if i == 4 else 0) for i in range(n))
sols = []
for name, vec in (("A", sol_a), ("B", sol_b)):
ann = [tgt[i] + vec[i] for i in range(n)]
diff = [beats[i]["id"] for i in range(n) if vec[i] != 0]
sols.append({"name": name, "ann": ann, "vec": list(vec), "diff": diff,
"mae_milli": mae_milli(ann, tgt), "in_set": vec in s4})
print(f" 解 {name}:标注 {' '.join(str(x) for x in ann)} "
f"MAE {mae_milli(ann, tgt)} 毫 偏离 {'/'.join(diff) if diff else '无'} "
f"在解集内 {vec in s4}")
print(" 同一曲线、不同标注,反推不唯一")
assert all(1 <= v <= 5 for r in sols for v in r["ann"]), "张力越界"
assert all(r["in_set"] for r in sols), "两个解都应在预算 4 的解集内"
os.makedirs(OUT, exist_ok=True)
report = {"beats": n, "target": tgt, "shift": shift, "solutions": sols,
"invariant_residual": 0}
with open(os.path.join(OUT, "nm_13_3_inversion_robustness.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_13_3_inversion_robustness.json")
print("OK nm_13_3_inversion_robustness:解集在扰动下近稳,两个达标解逐位列出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_13_3_inversion_robustness.py:
目标曲线 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2(16 拍)
可枚举预算上的解集集合运算(扰动 = 目标 b10 5→4):
预算 2:|S| 415 |S'| 443 交 415 失 0 增 28 Jaccard 936 千分
预算 3:|S| 3915 |S'| 4301 交 3915 失 0 增 386 Jaccard 910 千分
预算 4:|S| 27442 |S'| 30941 交 27441 失 1 增 3500 Jaccard 886 千分
多解演示(预算 4,MAE ≤ 250 毫):
解 A:标注 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2 MAE 0 毫 偏离 无 在解集内 True
解 B:标注 1 2 2 2 3 3 4 3 4 5 3 4 5 5 4 2 MAE 125 毫 偏离 b04/b05 在解集内 True
同一曲线、不同标注,反推不唯一
wrote out/nm_13_3_inversion_robustness.json
OK nm_13_3_inversion_robustness:解集在扰动下近稳,两个达标解逐位列出
B 层:阈值扰动下的解集相变
sim/nm_13_3_threshold_sweep.py 做另一条轴:把 MAE 阈值从 0 毫扫到 2000 毫,用逐位 DP 精确计数每档的解集规模。它是 B 层脚本——解集超过 10⁶ 只打 WARN、不返回非零,因为「反推欠定」是需要被报告的事实,不是脚本该失败的理由。
#!/usr/bin/env python3
# file: sim/nm_13_3_threshold_sweep.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_13_3_threshold_sweep.py
"""卷三 13.3 阈值扰动下的解集相变(B 层):扫描 + 软告警。**软告警不硬失败**。
解集对**目标扰动**的稳定性由本节 S 层(nm_13_3_inversion_robustness.py)做精确集合
运算;本脚本做另一条轴——**阈值扰动**:把 MAE 阈值从 0 毫扫到 2000 毫,用逐位 DP
精确计数每一档的解集规模,看它如何相变。阈值每松一档,解集涨几个数量级,这是本节
的核心事实,也是「达标」这个词离不开阈值的直接证据。
**多解意味着什么**:同一条曲线可以由大量不同的标注产生,所以「曲线达标」推不出
「标注是唯一的」——反推只能给出**解集**,不能给出**唯一设计**。这正是第 15 章
「指标不能声称什么」的一个具体实例,也是本节所有告警的由来。
口径(proxy):全部为结构量,只做相对比较,不声称「哪个标注更好」。
**B 层只产出软告警,一律 exit 0**。产出 out/nm_13_3_threshold_sweep.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
WARN_SIZE = 1000000
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def deltas_for(t: int) -> list:
return list(range(1 - t, 5 - t + 1))
def count_within(ranges: list, budget: int) -> int:
"""精确计数:sum|Δ| ≤ budget 的标注向量个数(逐位 DP,不枚举)。"""
cur = {0: 1}
for opts in ranges:
nxt = {}
for cost, n in cur.items():
for d in opts:
nc = cost + abs(d)
if nc <= budget:
nxt[nc] = nxt.get(nc, 0) + n
cur = nxt
return sum(cur.values())
def main() -> int:
spec = load_spec()
n = len(spec["beats"])
tgt = list(spec["metrics"]["target_tension_curve"])
ranges = [deltas_for(t) for t in tgt]
def budget_of(th: int) -> int:
"""MAE_milli = sum*1000//n ≤ th ⇔ sum ≤ (n*(th+1)-1)//1000。"""
return (n * (th + 1) - 1) // 1000
print(f"目标曲线 {' '.join(str(x) for x in tgt)}({n} 拍)")
print("阈值扰动扫描(精确 DP 计数,阈值以毫计):")
warns, sweep = [], []
for th in (0, 62, 125, 250, 500, 1000, 2000):
c = count_within(ranges, budget_of(th))
over = c > WARN_SIZE
if over:
warns.append(f"MAE ≤ {th} 毫 时解集 {c} 个,超过 {WARN_SIZE}")
sweep.append({"threshold_milli": th, "budget": budget_of(th),
"count": c, "over_warn_size": over})
print(f" MAE ≤ {th:>4} 毫(预算 {budget_of(th):>2}) 解集 {c:>12} 个 "
f"{'WARN 欠定' if over else 'ok'}")
ratio = sweep[-1]["count"] * 1000 // sweep[0]["count"]
print(f"相变跨度 阈值 0 → 2000 毫,解集从 {sweep[0]['count']} 涨到 "
f"{sweep[-1]['count']}({ratio} 倍,跨 {len(str(sweep[-1]['count'])) - 1} 个数量级)")
if not warns:
warns.append("无(各阈值下解集规模均未超限)")
print("软告警:")
for w in warns:
print(f" WARN {w}")
print(f"告警条数已报告:{len([w for w in warns if w.startswith('MAE')])} 条规模告警;exit 0")
assert count_within(ranges, 0) == 1, "预算 0 应只有恒等解"
os.makedirs(OUT, exist_ok=True)
report = {"beats": n, "target": tgt, "warn_size": WARN_SIZE, "sweep": sweep,
"growth_ratio": ratio, "warns": warns, "invariant_residual": 0}
with open(os.path.join(OUT, "nm_13_3_threshold_sweep.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_13_3_threshold_sweep.json")
print("OK nm_13_3_threshold_sweep:阈值每松一档解集涨几个数量级(软告警已报告)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_13_3_threshold_sweep.py:
目标曲线 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2(16 拍)
阈值扰动扫描(精确 DP 计数,阈值以毫计):
MAE ≤ 0 毫(预算 0) 解集 1 个 ok
MAE ≤ 62 毫(预算 1) 解集 29 个 ok
MAE ≤ 125 毫(预算 2) 解集 415 个 ok
MAE ≤ 250 毫(预算 4) 解集 27442 个 ok
MAE ≤ 500 毫(预算 8) 解集 9517829 个 WARN 欠定
MAE ≤ 1000 毫(预算 16) 解集 6072047140 个 WARN 欠定
MAE ≤ 2000 毫(预算 32) 解集 148895487305 个 WARN 欠定
相变跨度 阈值 0 → 2000 毫,解集从 1 涨到 148895487305(148895487305000 倍,跨 11 个数量级)
软告警:
WARN MAE ≤ 500 毫 时解集 9517829 个,超过 1000000
WARN MAE ≤ 1000 毫 时解集 6072047140 个,超过 1000000
WARN MAE ≤ 2000 毫 时解集 148895487305 个,超过 1000000
告警条数已报告:3 条规模告警;exit 0
wrote out/nm_13_3_threshold_sweep.json
OK nm_13_3_threshold_sweep:阈值每松一档解集涨几个数量级(软告警已报告)
判读:近稳,但稳得没有意义
阈值是解集规模的主开关,而且非线性。 从 0 毫到 250 毫,解集从 1 涨到 27442;从 250 毫到 1000 毫,再涨到 60.7 亿。每松一档涨几个数量级——所以「达标」这个词在没写阈值时毫无信息量。
目标扰动下解集近稳。 把 b10 从 5 挪到 4,三个预算上的 Jaccard 是 936‰ / 910‰ / 886‰,丢失的解只有 0 / 0 / 1 个(预算 4 里丢的那一个正是「b10 标成 1、其余全等于目标」,因为目标从 5 降到 4 后 Δ=−4 超出新取值域)。欠定是结构性的,不是阈值卡在临界点。
但「稳」恰恰是坏消息。 解 A(恒等,MAE 0 毫)与解 B(b04 3→2、b05 2→3,MAE 125 毫)都在解集内——两份不同的设计,反推只会说「两个都达标」。稳健性在这里不是优点的证明,而是不可识别性的证明。
多解意味着什么:指标不能声称「唯一设计」
13.1 说明能定位到什么取决于观测了什么;13.2 说明达标解有 60 亿个;13.3 说明这个解集在扰动下近稳。三条合起来指向同一结论:MAE 能判断「这份标注是否达标」,但不能判断「这份标注是不是那份设计」。 具体到实践,三件事不能做:不能反推「作者原本想标的张力」(解集里任意一个解都同样达标,指标没能力排序);不能把达标当收敛(达成方式有 60 亿种);不能跨口径比解集大小(路径口径 1.83×10¹⁰ 个解、全曲线口径 6.07×10⁹ 个解,数字不同不代表哪个更严格——它们观测的量根本不一样)。这是第 15 章「指标不能声称什么」在反推上的实例:度量给出的是判据,不是答案。
自检表
| 检查项 | 合格标准 | 本节实测 |
|---|---|---|
| 阈值相变可量 | 逐档给出解集规模 | 1 → 1.49×10¹¹ |
| 集合运算精确 | 在可枚举预算上求交并差 | 415 / 3915 / 27442 |
| 稳定性有数 | Jaccard 千分 + 失/增计数 | 936 / 910 / 886 千分 |
| 多解明示 | 两个达标解逐位列出 | 解 A / 解 B |
| B 层软告警 | 超限只 WARN,exit 0 | 3 条告警,exit 0 |
| 口径声明 | 结构量标 proxy,不外推 | 只做相对比较 |
反推这条线到这里收束:定位 → 反推 → 稳健性,三步都在说同一件事——度量能把「曲线偏了」翻译成可复现的数,但翻译不出「该怎么改」。下一章换一个方向问问题:如果规格真的改了,影响面有多大、哪些路径和指标会跟着动——那是演化回归要处理的对象,也是把本章的反推能力接到工程流程上的地方。
阅读导航:上一节:13.2 参数到曲线的反推 · 下一节:14.1 规格演化的影响面 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。