4.3 回放的逐位一致
Godot 对话历史与后果日志 存的是选择记录本身;本节要证明的是另一件事:同一串选择序列,在两条独立实现(Python 与 TypeScript)上推进出的状态,逐位相同。4.2 保证了选择序列同种子可复现,4.3 把断言下沉一层——从「序列一致」到「推进之后的状态摘要一致」。这是本套「三处消费」的地基。
摘要只对状态做,不对文本做
一个状态里有节点、好感度、flag、物品、路径。要把它们压成一个可比对的短值,本套用 FNV-1a 32 位摘要,输入是状态的 canonical 串(键名排序、k=v 逗号连接)。
关键纪律:摘要只对整数状态做,不对台词文本做。 文本涉及编码、换行、Unicode 归一化,跨语言逐字比对是不可靠的;而节点名、整数好感度、布尔 flag、路径是一串确定的 ASCII,可以稳定地哈希。所以本节从头到尾只对状态做摘要,正文里也只对整数状态说「逐位一致」。
两侧的实现必须共享同一种 32 位溢出语义,这是逐位一致成立的技术前提:
# snippet: Python 侧 FNV-1a 32 位(& 0xFFFFFFFF 截断到 32 位)
h = 2166136261
for ch in canonical(s):
h = (h ^ ord(ch)) & 0xFFFFFFFF
h = (h * 16777619) & 0xFFFFFFFF
// snippet: TypeScript 侧 FNV-1a 32 位(Math.imul 做 32 位乘法)
let h = 2166136261;
for (let i = 0; i < str.length; i += 1) {
h = (h ^ str.charCodeAt(i)) >>> 0;
h = Math.imul(h, 16777619) >>> 0;
}
Math.imul 把两个 32 位整数相乘并只保留低 32 位,Python 的 & 0xFFFFFFFF 做的是同一件事。如果用普通浮点乘法(h * 16777619),乘积约 7.2×10¹⁶ 会超过 2⁵³,尾数被静默截断——两侧就会分道扬镳。这一点不是理论,本节的 P 层会把它实测出来。
S 层:Python 侧算 500 条摘要
脚本采样 500 条路径,对每条终局状态算 canonical 串与摘要,落 out/ne_04_3_py.json 供 TS 侧逐条比对。
#!/usr/bin/env python3
# file: sim/ne_04_3_bitwise.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_04_3_bitwise.py
"""卷二 4.3 回放的逐位一致(S 层,Python 侧):把 500 条选择序列各自推进一遍,
对**状态**(不是文本)算 canonical 串与 FNV-1a 32 位摘要,落 out/ne_04_3_py.json。
TS 侧 core/test/ne_04_3_bitwise.test.ts 读同一份产物逐条比对 canonical 与 digest。
Python 用 & 0xFFFFFFFF 实现 32 位溢出,TS 用 Math.imul —— 两侧溢出语义必须一致。
"""
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")
JSON_PATH = os.path.join(OUT, "ne_04_3_py.json")
SEED, N, MASK32 = 20261016, 500, 0xFFFFFFFF
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def cond_ok(c, s: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return s["flags"].get(c[1], 0) == c[2]
if k == "item":
return s["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
if k == "and":
return cond_ok(c[1], s) and cond_ok(c[2], s)
if k == "or":
return cond_ok(c[1], s) or cond_ok(c[2], s)
if k == "not":
return not cond_ok(c[1], s)
raise ValueError(f"unknown cond op: {k}")
def init_state(spec: dict) -> dict:
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
"path": [spec["meta"]["start"]]}
def node_by_id(spec: dict, nid: str) -> dict:
return next(n for n in spec["dialogue"] if n["id"] == nid)
def apply_choice(spec: dict, s: dict, cid: str) -> dict:
n = node_by_id(spec, s["node"])
c = next(x for x in (n.get("choices") or []) if x["id"] == cid)
if not cond_ok(c.get("when"), s):
raise ValueError(f"choice unavailable: {cid}")
aff, flags, items, eff = dict(s["aff"]), dict(s["flags"]), dict(s["items"]), c.get("effects") or {}
for k in eff.get("set", {}):
flags[k] = eff["set"][k]
for k in eff.get("add", {}):
aff[k] = max(0, min(100, aff.get(k, 0) + eff["add"][k]))
for k in eff.get("grant", {}):
items[k] = eff["grant"][k]
for k in eff.get("revoke", {}):
items[k] = 0
return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
"path": [*s["path"], c["goto"]]}
def canonical(s: dict) -> str:
aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
return f"{s['node']}|{aff}|{fl}|{it}|{'>'.join(s['path'])}"
def digest(s: dict) -> int:
"""FNV-1a 32 位:& 0xFFFFFFFF 与 TS 的 Math.imul 对应同一种溢出语义。"""
h = 2166136261
for ch in canonical(s):
h = (h ^ ord(ch)) & MASK32
h = (h * 16777619) & MASK32
return h
def sample_paths(spec: dict, n: int, seed: int) -> list[list[str]]:
rng, out = seed, []
for _ in range(n):
s, trail = init_state(spec), []
for _step in range(200):
if node_by_id(spec, s["node"]).get("ending"):
break
opts = [c for c in (node_by_id(spec, s["node"]).get("choices") or []) if cond_ok(c.get("when"), s)]
if not opts:
break
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
trail.append(c["id"])
s = apply_choice(spec, s, c["id"])
out.append(trail)
return out
def main() -> int:
spec = load_spec()
rows = []
for i, p in enumerate(sample_paths(spec, N, SEED)):
s = init_state(spec)
for cid in p:
s = apply_choice(spec, s, cid)
rows.append({"i": i, "choices": p, "canonical": canonical(s), "digest": digest(s),
"node": s["node"], "ending": node_by_id(spec, s["node"]).get("ending", "")})
known = {n["id"] for n in spec["dialogue"]}
for r in rows:
for part in r["canonical"].split("|")[1].split(","):
assert 0 <= int(part.split("=")[1]) <= 100, "好感度越界"
assert r["node"] in known, f"未知节点 {r['node']}"
print(f"spec={spec['meta']['title']} n={N} seed={SEED} digest=FNV-1a 32 位")
print(f"Python 侧:{len(rows)} 条路径全部算出 canonical 与 digest")
print("前 3 条摘要(Python):")
for r in rows[:3]:
print(f" #{r['i']} digest={r['digest']:>10} node={r['node']:<6} canonical={r['canonical'][:72]}")
print(f"摘要去重后 {len({r['digest'] for r in rows})} 个不同值 / {len(rows)} 条路径")
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump(rows, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"wrote out/ne_04_3_py.json rows={len(rows)}")
print("OK ne_04_3_bitwise:Python 侧 500 条摘要已落盘,待 TS 侧逐位比对")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_04_3_bitwise.py:
spec=雾港夜航 n=500 seed=20261016 digest=FNV-1a 32 位
Python 侧:500 条路径全部算出 canonical 与 digest
前 3 条摘要(Python):
#0 digest=3976226298 node=e_dark canonical=e_dark|hui=27,mu=50,shen=90,yan=35|dawn_choice_made,knows_bell_legend,me
#1 digest=3848604931 node=e_dark canonical=e_dark|hui=47,mu=40,shen=65,yan=35|cave_explored,dawn_choice_made,found_
#2 digest=1198029437 node=e_dark canonical=e_dark|hui=42,mu=55,shen=55,yan=65|cave_explored,found_yan_journal,has_l
摘要去重后 499 个不同值 / 500 条路径
wrote out/ne_04_3_py.json rows=500
OK ne_04_3_bitwise:Python 侧 500 条摘要已落盘,待 TS 侧逐位比对
P 层:TS 侧读产物逐位比对
门禁读 out/ne_04_3_py.json,用 TS 内核重放同 500 条选择序列,逐条比对 canonical 串与 digest;最后一条测试专门把「用 Math.imul」与「用普通浮点乘法」的结果拉开——证明溢出语义不是可选项。
// file: core/test/ne_04_3_bitwise.test.ts
// tier: P
// verify: node --test 'core/test/ne_04_3_bitwise.test.ts'
// 卷二 4.3(P 层门禁):TS 侧重放 500 条路径,canonical 与 digest 与 Python 侧逐位相同。
// 只对整数状态做摘要,不对文本做。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { canonical, digest, replay } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { digestNaive, mismatches } from "../src/inkwell/ne_04_3_bitwise.ts";
import type { Row } from "../src/inkwell/ne_04_3_bitwise.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
const rows = JSON.parse(readFileSync(join(ROOT, "out/ne_04_3_py.json"), "utf8")) as Row[];
test("Python 侧产物 500 行齐全", () => {
assert.equal(rows.length, 500);
assert.equal(rows[0].i, 0);
assert.equal(rows[499].i, 499);
});
test("TS 侧重放 500 条路径:canonical 与 digest 与 Python 侧逐位相同", () => {
assert.deepEqual(mismatches(spec, rows), [], "存在逐位不一致的行");
});
test("前 3 条摘要与 Python 侧对照表一致", () => {
const want = rows.slice(0, 3).map((r) => r.digest);
const got = rows.slice(0, 3).map((r) => digest(replay(spec, r.choices)));
assert.deepEqual(got, want);
for (let i = 0; i < 3; i += 1) assert.equal(canonical(replay(spec, rows[i].choices)), rows[i].canonical);
});
test("Math.imul 是关键:普通浮点乘法会因 2^53 精度丢失而与 Python 不一致", () => {
const diverged = rows.filter((r) => digestNaive(replay(spec, r.choices)) !== digest(replay(spec, r.choices)));
assert.ok(diverged.length > 0, "错误示范应至少在一行上与正确摘要不同");
// 正确摘要逐位一致,错误摘要却有偏差——差异只来自溢出语义
assert.equal(mismatches(spec, rows).length, 0);
});
本机实跑 node --test 'core/test/ne_04_3_bitwise.test.ts':
✔ Python 侧产物 500 行齐全 (0.47375ms)
✔ TS 侧重放 500 条路径:canonical 与 digest 与 Python 侧逐位相同 (18.2935ms)
✔ 前 3 条摘要与 Python 侧对照表一致 (0.377125ms)
✔ Math.imul 是关键:普通浮点乘法会因 2^53 精度丢失而与 Python 不一致 (40.1825ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
判读:500 条逐位一致
第一,500 条路径全部逐位相同。 第 2 条测试的 mismatches 返回空数组——Python 与 TypeScript 两条独立实现,对同 500 条选择序列推进出的状态,canonical 串与摘要都一模一样。前 3 条的摘要值可以并排读:
| 序号 | 终点 | Python digest | TS digest | 一致 |
|---|---|---|---|---|
| #0 | e_dark | 3976226298 | 3976226298 | 是 |
| #1 | e_dark | 3848604931 | 3848604931 | 是 |
| #2 | e_dark | 1198029437 | 1198029437 | 是 |
这三个值与卷一 sim/na_02_digest.py 落下的 out/digest_py.json 前 3 条摘要逐位相同——同一算法、同一种子、同一份规格,跨卷也自洽。
第二,摘要有分辨力。 500 条路径去重后得到 499 个不同摘要——只有两条路径的终局状态完全相同。这说明摘要不是「怎么算都差不多」的弱指纹,而是能把不同状态区分开的强指纹。若它退化成一个常数,逐位一致就毫无意义。
第三,Math.imul 与 & 0xFFFFFFFF 缺一不可。 第 4 条测试里,用普通浮点乘法算的 digestNaive 在全部 500 行上都与正确摘要不同——例如第 0 行,正确值是 3976226298,错误示范给出 3966342112。两者只差一次乘法的溢出语义,结果却全错。这正是跨语言摘要最容易踩的坑:算法一样、参数一样,溢出处理不一样,结果就不一样。
| 观察 | 数字 | 读法 |
|---|---|---|
| 逐位相同的路径数 | 500 / 500 | 两条独立实现状态摘要一致 |
| 摘要去重值 | 499 / 500 | 摘要有分辨力,非弱指纹 |
| 错误溢出示范偏离 | 500 / 500 | Math.imul 不是可选项 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 摘要只对状态 | 输入是 canonical 串,不含任何台词文本 |
| 键序确定 | 两侧都按键名排序,k=v 逗号连接 |
| 溢出语义一致 | Python & 0xFFFFFFFF 对应 TS Math.imul |
| 逐位比对 | 500 条路径的 canonical 与 digest 全等 |
| 反例可复现 | 去掉 Math.imul 后摘要确实偏离 |
到这里,第 4 章把「回放」这条链走完了:选择序列与路径是同一趟推进的两种表示,同种子必然同回放,跨语言的状态摘要逐位一致。下一个问题是这条链的规模——所有可达状态一共有多少个、从起点能不能全部走到。第 5 章进入状态空间的穷举。
阅读导航:上一节:4.2 同种子同回放 · 下一节:5.1 状态空间的规模 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。