13.3 反推的稳健性

解集有多大是一回事,稳不稳是另一回事。本节用两条轴量反推的稳健性:把目标曲线的 b10 从 5 挪到 4,在可枚举的预算上精确求新旧解集的交集与 Jaccard 千分;再把 MAE 阈值从 0 毫扫到 2000 毫看解集规模的相变。结论是解集近稳但规模巨大,曲线达标推不出唯一标注。

《游戏设计》卷二《策略涌现与体验度量》 那一套从机制参数反推体验曲线;本章把同一套反推思路搬到叙事层——但只对结构参数(节拍张力标注、阈值、采样规模)反推结构曲线,不碰机制动力学。那一套的稳健性问的是「参数微扰后最优解还落在同一区域吗」,靠连续空间的邻域概念;本章的稳健性问的是「目标曲线微扰后解集还是不是那一批」,靠两个整数解集的集合运算——没有邻域,只有交并差。

稳健性问的是「换个扰动,解集还一样吗」

13.2 给出一个数:达标解有 6 072 047 140 个。但一个大数本身说明不了什么——如果这 60 亿个解在扰动下整体平移,说明反推是脆弱的;如果几乎不动,说明欠定是结构性的,跟阈值调得好不好无关。要区分这两种情况,得做扰动实验。扰动有两条轴,本节各配一个脚本:

轴动什么由谁算怎么量
目标扰动把 target_tension_curve 的 b10 从 5 改成 4S 层新旧解集的交 / 失 / 增 / 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 03 条告警,exit 0
口径声明结构量标 proxy,不外推只做相对比较

反推这条线到这里收束:定位 → 反推 → 稳健性,三步都在说同一件事——度量能把「曲线偏了」翻译成可复现的数,但翻译不出「该怎么改」。下一章换一个方向问问题:如果规格真的改了,影响面有多大、哪些路径和指标会跟着动——那是演化回归要处理的对象,也是把本章的反推能力接到工程流程上的地方。

阅读导航:上一节:13.2 参数到曲线的反推 · 下一节:14.1 规格演化的影响面 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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