13.2 参数到曲线的反推

给定一条目标张力曲线,反过来能求出哪些节拍标注?本节把「MAE 不超阈值」化成 sum 绝对值不超过 16 的整数约束,用逐位 DP 精确计数:达标解集有 6072047140 个,占全空间 5 的 16 次方的 39.8 千分。随机搜索 200000 次的经验命中率与精确比例吻合,而收紧到 62 毫时随机搜索一次都命不中。

《游戏设计》卷二《策略涌现与体验度量》 那一套从机制参数反推体验曲线;本章把同一套反推思路搬到叙事层——但只对结构参数(节拍张力标注、阈值、采样规模)反推结构曲线,不碰机制动力学。那一套的反推输入是连续数值参数,用梯度或网格下降逼近;本章的反推输入是 16 个 1–5 的整数标注,是纯组合问题——搜索空间不能靠「走一步看一步」收敛,只能精确计数或穷举。

反推是「给定曲线,哪些标注能产生它」

13.1 定位的是一处偏差;本节问的是全部可能。把问题写清楚:已知一条目标张力曲线 t(16 个整数,取值 1–5),求所有标注向量 a(同样 16 个整数、同样取值域),使 a 与 t 的 MAE 不超过阈值。这里的「标注」就是 beats[].tension,也就是作者在规格里手写的那 16 个数。

约束只有一条,但它是整数化的,必须写准:

tension_mae_milli = sum(|a[i] - t[i]|) * 1000 // 16
达标 ⇔ tension_mae_milli ≤ 1000
     ⇔ sum(|a[i] - t[i]|) ≤ 16        (地板除的等价整数式)

第 6 章定下的 MAE 口径是千分位地板除,所以「≤ 1000 毫」不能直接写成「平均值 ≤ 1」,那样会漏掉或错收边界解。等价式是 sum|Δ| ≤ 16,右端刚好等于拍数——这不是巧合,而是阈值取 tension_mae_max = 1 的直接后果。

搜索空间分三层,一层比一层紧:

层约束规模可穷举吗
1无约束全空间5¹⁶ = 152 587 890 625不可
2稀疏:至多 k 拍偏离目标k≤1 65 个 / k≤2 1985 个 / k≤3 37825 个可
3预算:`sumΔ≤ 16`

S 层:精确计数与随机搜索的交叉验证

sim/nm_13_2_parameter_inversion.py 做四件事:量全空间规模、穷举稀疏子空间、用逐位 DP 精确计数预算内的解、再用同种子 Lehmer MINSTD 随机搜 200000 个标注做交叉验证——精确计数给的是权威值,随机搜索给的是「随手撒点能碰到多少」,两者应当吻合。

#!/usr/bin/env python3
# file: sim/nm_13_2_parameter_inversion.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_13_2_parameter_inversion.py
"""卷三 13.2 参数到曲线的反推(S 层):给定目标曲线,求全部可行的节拍标注。

正向是「标注 → 曲线」;反推问的是「目标曲线 → 哪些标注能满足它」。约束只有一条:
曲线与目标的 MAE(毫)不超过 metrics.tension_mae_max × 1000 = 1000 毫,由于 MAE 用
定点地板除,它等价于 sum|Δ| ≤ 16(16 拍)。节拍张力是 1–5 的整数,故每个位置的可取
增量是 [1-t, 5-t] 共 5 个。

搜索空间分三层,逐层收紧:
  1) 无约束全空间     5^16 = 152587890625 —— 不可穷举
  2) 稀疏约束(至多 k 拍偏离)—— 可穷举:k≤1 65 个、k≤2 1985 个、k≤3 37825 个
  3) 预算精确计数(DP,预算 = 16)—— 精确 6072047140 个

再补两件事:① 用同种子 Lehmer MINSTD 随机搜 200000 个标注,量经验命中率,与精确
比例对照;② 路径口径的反推——一条路径只观测 11 拍,剩下 5 拍完全自由(×5^5=3125),
所以同一路径曲线对应的标注解集更大。

口径(proxy):全部为结构计数,只做相对比较,不声称「哪个标注更好」。全部整数。
产出 out/nm_13_2_parameter_inversion.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")
SEED = 20261016
SAMPLES = 200000


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:
    """目标值为 t 时,该节拍标注可取的全部增量(张力限定 1–5)。"""
    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 count_sparse(ranges: list, k: int) -> int:
    """至多 k 拍偏离目标(其余位 Δ=0)的解个数,逐组合枚举。"""
    from itertools import combinations, product
    n = len(ranges)
    total = 1
    for r in range(1, k + 1):
        for pos in combinations(range(n), r):
            opts = [[d for d in ranges[p] if d != 0] for p in pos]
            total += sum(1 for _ in product(*opts))
    return total


def minstd(state: int) -> int:
    return state * 48271 % 2147483647


def main() -> int:
    spec = load_spec()
    beats = spec["beats"]
    m = spec["metrics"]
    base = [b["tension"] for b in beats]
    tgt = list(m["target_tension_curve"])
    n = len(beats)
    ranges = [deltas_for(t) for t in tgt]

    total_space = 5 ** n

    def budget_of(th_milli: int) -> int:
        """MAE_milli = sum*1000//n ≤ th  ⇔  sum ≤ (n*(th+1)-1)//1000。"""
        return (n * (th_milli + 1) - 1) // 1000

    budget = budget_of(m["tension_mae_max"] * 1000)
    exact_full = count_within(ranges, budget)

    print(f"目标曲线 {' '.join(str(x) for x in tgt)}({n} 拍,和 {sum(tgt)})")
    print(f"无约束全空间 5^{n} = {total_space}(不可穷举)")
    print(f"阈值 MAE ≤ {m['tension_mae_max'] * 1000} 毫 ⇔ sum|Δ| ≤ {budget}")

    print(f"稀疏约束(可穷举):")
    for k in (1, 2, 3):
        print(f"  至多 {k} 拍偏离  {count_sparse(ranges, k)} 个")

    print(f"预算精确计数(DP):")
    sweep = []
    for th_milli in (0, 62, 125, 250, 500, 1000, 2000):
        b = budget_of(th_milli)
        c = count_within(ranges, b)
        sweep.append({"threshold_milli": th_milli, "budget": b, "count": c})
        print(f"  MAE ≤ {th_milli:>4} 毫(预算 {b:>2})  {c:>12} 个")

    # 同种子随机搜索:经验命中率 vs 精确比例
    rng, hits_full, hits_tight = SEED, 0, 0
    for _ in range(SAMPLES):
        cost = 0
        for i in range(n):
            rng = minstd(rng)
            cost += abs((rng % 5 + 1) - tgt[i])
        if cost <= budget:
            hits_full += 1
        if cost <= 1:
            hits_tight += 1
    rate_exact = exact_full * 1000000 // total_space
    rate_rand = hits_full * 1000000 // SAMPLES
    print(f"随机搜索 {SAMPLES} 次:达标命中 {hits_full}(经验 {rate_rand} ppm,精确 {rate_exact} ppm)")
    print(f"随机搜索同批命中 sum|Δ| ≤ 1 的 {hits_tight} 次({count_within(ranges, 1)} 个解,占比 < 1 ppm)")

    # 路径口径反推:只观测 11 拍,离路径 5 拍完全自由
    visited = {"pier", "tavern", "counting_house", "watch_post", "lighthouse_base",
               "tide_cave", "harbor_office", "lighthouse_top", "dawn_quay"}
    idx = [i for i, b in enumerate(beats) if b["scene"] in visited]
    free = [i for i in range(n) if i not in idx]
    budget_path = m["tension_mae_max"] * len(idx)
    path_obs = count_within([ranges[i] for i in idx], budget_path)
    path_total = path_obs * (5 ** len(free))
    print(f"路径口径:可观测 {len(idx)} 拍,预算 {budget_path} → {path_obs} 个;"
          f"离路径 {len(free)} 拍自由 ×{5 ** len(free)} = {path_total} 个")

    # 两个具体解:都达标,但标注不同
    sol_a = [tgt[i] for i in range(n)]
    sol_b = list(sol_a)
    sol_b[3] = 2   # b04 3→2
    sol_b[4] = 3   # b05 2→3
    mae_a = sum(abs(sol_a[i] - tgt[i]) for i in range(n)) * 1000 // n
    mae_b = sum(abs(sol_b[i] - tgt[i]) for i in range(n)) * 1000 // n
    diff = [beats[i]["id"] for i in range(n) if sol_a[i] != sol_b[i]]
    print(f"解 A(恒等)MAE {mae_a} 毫;解 B({'/'.join(diff)} 各偏 1)MAE {mae_b} 毫 —— 两个解都达标")

    assert count_within(ranges, 0) == 1, "预算 0 应只有恒等解"
    assert all(1 <= v <= 5 for v in sol_a + sol_b), "张力越界"
    assert mae_a <= m["tension_mae_max"] * 1000 and mae_b <= m["tension_mae_max"] * 1000

    os.makedirs(OUT, exist_ok=True)
    report = {
        "beats": n, "target": tgt, "total_space": total_space,
        "budget": budget, "threshold_milli": m["tension_mae_max"] * 1000,
        "exact_full_count": exact_full,
        "sparse": {str(k): count_sparse(ranges, k) for k in (1, 2, 3)},
        "sweep": sweep,
        "random_search": {"samples": SAMPLES, "hits_full": hits_full,
                          "rate_empirical_ppm": rate_rand, "rate_exact_ppm": rate_exact,
                          "hits_tight": hits_tight},
        "path_inversion": {"observable": len(idx), "free": len(free),
                           "budget": budget_path, "observable_count": path_obs,
                           "free_multiplier": 5 ** len(free), "total": path_total},
        "solutions": [{"name": "A", "vec": sol_a, "mae_milli": mae_a},
                      {"name": "B", "vec": sol_b, "mae_milli": mae_b}],
        "invariant_residual": 0,
    }
    with open(os.path.join(OUT, "nm_13_2_parameter_inversion.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print(f"wrote out/nm_13_2_parameter_inversion.json")
    print(f"OK nm_13_2_parameter_inversion:达标解集 {exact_full} 个(精确 DP 计数)")
    return 0


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

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

目标曲线 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2(16 拍,和 52)
无约束全空间 5^16 = 152587890625(不可穷举)
阈值 MAE ≤ 1000 毫 ⇔ sum|Δ| ≤ 16
稀疏约束(可穷举):
  至多 1 拍偏离  65 个
  至多 2 拍偏离  1985 个
  至多 3 拍偏离  37825 个
预算精确计数(DP):
  MAE ≤    0 毫(预算  0)             1 个
  MAE ≤   62 毫(预算  1)            29 个
  MAE ≤  125 毫(预算  2)           415 个
  MAE ≤  250 毫(预算  4)         27442 个
  MAE ≤  500 毫(预算  8)       9517829 个
  MAE ≤ 1000 毫(预算 16)    6072047140 个
  MAE ≤ 2000 毫(预算 32)  148895487305 个
随机搜索 200000 次:达标命中 7927(经验 39635 ppm,精确 39793 ppm)
随机搜索同批命中 sum|Δ| ≤ 1 的 0 次(29 个解,占比 < 1 ppm)
路径口径:可观测 11 拍,预算 11 → 5854293 个;离路径 5 拍自由 ×3125 = 18294665625 个
解 A(恒等)MAE 0 毫;解 B(b04/b05 各偏 1)MAE 125 毫 —— 两个解都达标
wrote out/nm_13_2_parameter_inversion.json
OK nm_13_2_parameter_inversion:达标解集 6072047140 个(精确 DP 计数)

判读:解集大到不能当答案用

第一,达标解有 60.7 亿个。 全空间 152 587 890 625 个标注,其中 6 072 047 140 个满足 MAE ≤ 1000 毫——占 39.8 千分。这不是「搜索没收敛」,而是约束本身就松:sum|Δ| ≤ 16 允许 16 个节拍平均各偏 1,等于把整条曲线在 ±1 的带子里任意挪。反推给出的不是一个设计,而是一个 60 亿量级的解集。

第二,收紧阈值会迅速缩小解集,但缩不到 1。 把阈值从 1000 毫收到 62 毫(等价 sum|Δ| ≤ 1),解集从 60.7 亿掉到 29 个;再收到 0 毫(sum|Δ| ≤ 0),才只剩 1 个——恒等解。只有把阈值压到 0,反推才唯一;而阈值 0 意味着要求标注与目标逐项相同,那已经不需要反推了。

第三,随机搜索和精确计数对得上。 随机撒 200000 个标注,命中 7927 个,经验命中率 39635 ppm,精确比例 39793 ppm——两者差 0.4%,说明 DP 计数没有漏项。同一批样本里命中 sum|Δ| ≤ 1 的有 0 次:29 / 1.53×10¹¹ 的占比小于 1 ppm,随机搜索在这个尺度上根本碰不到。这解释了为什么反推不能靠采样:阈值一紧,采样法就直接失效,只有精确计数能给出解集规模。

第四,路径口径的解集更大。 13.1 已经证明:一条路径只观测 11 拍,剩下 5 拍不可观测。在「路径曲线达标」这个约束下,那 5 拍完全自由,于是解集 = 5 854 293 × 5⁵ = 18 294 665 625 个——比全曲线口径还大一个量级。口径越窄,反推越不唯一。

D 层:反推问题的形式化定义

把上面用到的输入、约束、搜索空间与方法固化成一份定义,供 13.3 与第 15 章引用:

{
  "section": "13.2",
  "tier": "D",
  "problem": "给定目标张力曲线,求全部满足 MAE 阈值的节拍标注向量",
  "domain": "beats[].tension ∈ [1,5] 整数,共 16 位",
  "constraint": "sum(|Δ|) * 1000 // 16 ≤ 1000  ⇔  sum(|Δ|) ≤ 16",
  "threshold_source": "metrics.tension_mae_max × 1000,不是另设常数",
  "space_unconstrained": "5^16 = 152587890625(不可穷举)",
  "space_sparse": {"k1": 65, "k2": 1985, "k3": 37825},
  "space_exact_budget16": 6072047140,
  "method_exact": "逐位 DP:状态 = 已用预算,转移 = 该位可取增量",
  "method_sparse": "组合枚举:至多 k 位非零 × 各增量笛卡尔积",
  "method_random": "Lehmer MINSTD seed 20261016,仅与精确比例交叉验证",
  "caliber": "结构计数,proxy 口径,只做相对比较;不声称某个标注更好"
}

自检表

检查项合格标准本节实测
约束等价式正确地板除下的整数等价式,非浮点近似`sum
计数可交叉验证精确 DP 与随机搜索吻合39793 ppm vs 39635 ppm
稀疏子空间可穷举k≤1/2/3 逐个列出65 / 1985 / 37825
口径可区分路径口径与全曲线口径分开算1.83×10¹⁰ vs 6.07×10⁹
多解明示给出两个都达标的不同解解 A / 解 B
整数不变量张力全程 ∈ [1,5]残差 0

解集量出来了,但「解集大」本身还不能下结论:它稳不稳? 如果阈值稍微动一下、目标曲线稍微挪一拍,解集就整个换一批,那这个解集是脆弱的;如果几乎不变,那说明欠定是结构性的,不是阈值调得不好。多解到底意味着什么,下一节用扰动实验回答。

阅读导航:上一节:13.1 曲线的异常点定位 · 下一节:13.3 反推的稳健性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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