《游戏设计》卷二《策略涌现与体验度量》 那一套从机制参数反推体验曲线;本章把同一套反推思路搬到叙事层——但只对结构参数(节拍张力标注、阈值、采样规模)反推结构曲线,不碰机制动力学。那一套的反推输入是连续数值参数,用梯度或网格下降逼近;本章的反推输入是 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 反推的稳健性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。