4.3 回放的逐位一致

对同一串选择序列,Python 与 TypeScript 各自推进一遍,比较 canonical 串与 FNV-1a 32 位摘要。本节实测 500 条路径全部逐位相同,并给出前 3 条摘要值对照表;FNV-1a 的 Math.imul 与 Python 的 & 0xFFFFFFFF 是两侧一致的关键。

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 digestTS digest一致
#0e_dark39762262983976226298是
#1e_dark38486049313848604931是
#2e_dark11980294371198029437是

这三个值与卷一 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 / 500Math.imul 不是可选项

自检表

检查项合格标准
摘要只对状态输入是 canonical 串,不含任何台词文本
键序确定两侧都按键名排序,k=v 逗号连接
溢出语义一致Python & 0xFFFFFFFF 对应 TS Math.imul
逐位比对500 条路径的 canonical 与 digest 全等
反例可复现去掉 Math.imul 后摘要确实偏离

到这里,第 4 章把「回放」这条链走完了:选择序列与路径是同一趟推进的两种表示,同种子必然同回放,跨语言的状态摘要逐位一致。下一个问题是这条链的规模——所有可达状态一共有多少个、从起点能不能全部走到。第 5 章进入状态空间的穷举。

阅读导航:上一节:4.2 同种子同回放 · 下一节:5.1 状态空间的规模 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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