13.1 跨语言摘要与 digest

摘要用 FNV-1a 32 位,只对 canonical 规范串(状态)做,不对台词文本做。本节实测同一批 500 条路径(seed=20261016)在 Python 与 TypeScript 两侧逐位相同 500 / 500,前 3 条摘要为 3976226298、3848604931、1198029437,并与卷一同种子采样逐位相同,证明卷二未另立真源。

《游戏设计》卷一《玩法机制与可执行规格》 证明的是同一份玩法规格在仿真 / 内核 / 原型 / 基准四种消费下数值一致——它的真源是数值与规则,消费对象是一局战斗的状态轨迹。本章证明的是另一件事:同一份叙事规格在 Python / TypeScript / 浏览器三个宿主下推进出的状态摘要逐位相同,并明确划出「什么可以不同、什么必须相同」的容差边界。本节先立摘要本身。

13.1 跨语言摘要与 digest

一个状态里有节点、好感度、flag、物品、路径。要把它们压成一个可比对的短值,本套用 FNV-1a 32 位摘要,输入是状态的 canonical 规范串——节点、好感度、置位 flag、持有物品、路径五段用竖线拼成。

关键纪律:摘要只对整数状态做,不对台词文本做。 文本涉及编码、换行与 Unicode 归一化,跨语言把它当判据是不可靠的;而节点名、整数好感度、布尔 flag、路径是一串确定的 ASCII,可以稳定地哈希。所以本节从头到尾只对状态做摘要,正文里也只对整数状态说「逐位一致」。

摘要的输入:canonical 串的四段

段内容例
1终局节点e_dark
24 个好感度(键名排序,k=v)hui=27,mu=50,shen=90,yan=35
3置位 flag(排序)dawn_choice_made,knows_bell_legend,…
4持有物品(排序)journal,ledger_page,…
5路径(> 连接)d_pier_1>…>e_dark

两侧的实现必须共享同一种 32 位溢出语义:Python 用 & 0xFFFFFFFF 截断,TypeScript 用 Math.imul。若用普通浮点乘法,乘积约 7.2×10¹⁶ 会超过 2⁵³、尾数被静默截断,两侧立刻分道扬镳——13.2 会把这一点实测出来。

S 层:Python 侧算 500 条摘要并与卷一比对

脚本采样 500 条路径(seed=20261016),逐条算 canonical 与摘要,落 out/ne_13_1_py.json;再读卷一 sim/na_02_digest.py 落下的 out/digest_py.json 逐条比对——同算法、同种子、同一份规格,跨卷必须逐位相同。

#!/usr/bin/env python3
# file: sim/ne_13_1_cross_digest.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_13_1_cross_digest.py
"""卷二 13.1 跨语言摘要与 digest(S 层,Python 侧)。
摘要 = FNV-1a 32 位,只对 canonical 规范串(状态)做,不对台词文本做。
采样 N=500 条路径(seed=20261016),逐条算 canonical 与 digest,落 out/ne_13_1_py.json。
并与卷一 sim/na_02_digest.py 落下的 out/digest_py.json 逐条比对——同算法、同种子、
同一份规格,跨卷必须逐位相同,这就是「卷二未另立真源」的证据。
"""
from __future__ import annotations

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ne_11_common import OUT, apply_effects, canonical, cond_ok, fnv1a, init_state, load_spec  # noqa: E402

PY_JSON = os.path.join(OUT, "ne_13_1_py.json")
SUM_JSON = os.path.join(OUT, "ne_13_1_summary.json")
V1_JSON = os.path.join(OUT, "digest_py.json")
SEED, N = 20261016, 500


def node(spec: dict, nid: str) -> dict:
    return next(n for n in spec["dialogue"] if n["id"] == nid)


def sample_paths(spec: dict, n: int, seed: int) -> list[list[str]]:
    """Lehmer MINSTD:state = state * 48271 % 2147483647。"""
    rng, out = seed, []
    for _ in range(n):
        s, trail = init_state(spec), []
        for _step in range(200):
            cur = node(spec, s["node"])
            if cur.get("ending"):
                break
            opts = [c for c in (cur.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_effects(c.get("effects") or {}, s, c["goto"])
        out.append(trail)
    return out


def rows_of(spec: dict, paths: list[list[str]]) -> list[dict]:
    rows = []
    for i, p in enumerate(paths):
        s = init_state(spec)
        for cid in p:
            c = next(x for x in (node(spec, s["node"]).get("choices") or []) if x["id"] == cid)
            s = apply_effects(c.get("effects") or {}, s, c["goto"])
        rows.append({"i": i, "choices": p, "canonical": canonical(s),
                     "digest": fnv1a(canonical(s).encode("utf-8")),
                     "node": s["node"], "ending": node(spec, s["node"]).get("ending", "")})
    return rows


def main() -> int:
    spec = load_spec()
    rows = rows_of(spec, sample_paths(spec, N, SEED))
    with open(V1_JSON, encoding="utf-8") as fh:
        v1 = json.load(fh)
    same_v1 = sum(1 for a, b in zip(rows, v1)
                  if a["digest"] == b["digest"] and a["canonical"] == b["canonical"])
    uniq = len({r["digest"] for r in rows})

    print(f"spec={spec['meta']['title']}  digest=FNV-1a 32 位  只对 canonical 状态串做,不对文本做")
    print(f"采样 n={N}  seed={SEED};本侧(ne_13_1)算出 {len(rows)} 条 canonical 与 digest")
    print(f"与卷一 na_02_digest.py 的 out/digest_py.json 逐位相同:{same_v1} / {N} 条")
    print(f"摘要去重后 {uniq} 个不同值 / {len(rows)} 条路径")
    print("前 3 条摘要(本侧 = 卷一):")
    for a, b in zip(rows[:3], v1[:3]):
        print(f"  #{a['i']}  digest={a['digest']:>10}  (卷一 {b['digest']:>10})  node={a['node']:<6}  ending={a['ending']}")

    assert same_v1 == N, "与卷一摘要不一致,卷二另立了真源"
    os.makedirs(OUT, exist_ok=True)
    with open(PY_JSON, "w", encoding="utf-8") as fh:
        json.dump(rows, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    with open(SUM_JSON, "w", encoding="utf-8") as fh:
        json.dump({"n": N, "seed": SEED, "same_as_volume1": same_v1, "unique_digest": uniq,
                   "first3_digest": [r["digest"] for r in rows[:3]]}, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print(f"wrote out/ne_13_1_py.json rows={len(rows)}")
    print("OK ne_13_1_cross_digest:500/500 与卷一逐位相同,摘要非弱指纹")
    return 0


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

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

spec=雾港夜航  digest=FNV-1a 32 位  只对 canonical 状态串做,不对文本做
采样 n=500  seed=20261016;本侧(ne_13_1)算出 500 条 canonical 与 digest
与卷一 na_02_digest.py 的 out/digest_py.json 逐位相同:500 / 500 条
摘要去重后 499 个不同值 / 500 条路径
前 3 条摘要(本侧 = 卷一):
  #0  digest=3976226298  (卷一 3976226298)  node=e_dark  ending=离港
  #1  digest=3848604931  (卷一 3848604931)  node=e_dark  ending=离港
  #2  digest=1198029437  (卷一 1198029437)  node=e_dark  ending=离港
wrote out/ne_13_1_py.json rows=500
OK ne_13_1_cross_digest:500/500 与卷一逐位相同,摘要非弱指纹

P 层:TS 侧重放并逐位比对

门禁读 out/ne_13_1_py.json,用 TS 内核 replay() 重放同 500 条选择序列,逐条比对 canonical 串与摘要;再读卷一产物核对前 3 条。这就是「同一批 N 条路径逐位相同的条数」两侧对齐的地方。

// file: core/test/ne_13_1_cross_digest.test.ts
// tier: P
// verify: node --test 'core/test/ne_13_1_cross_digest.test.ts'
// 卷二 13.1:TS 侧重放 500 条路径,与 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 { asciiOnly, bitwiseIdentical, firstThree } from "../src/inkwell/ne_13_1_cross_digest.ts";
import type { Row } from "../src/inkwell/ne_13_1_cross_digest.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_13_1_py.json"), "utf8")) as Row[];
const v1 = JSON.parse(readFileSync(join(ROOT, "out/digest_py.json"), "utf8")) as Row[];

test("TS 侧重放 500 条:与 Python 侧 canonical 与 digest 逐位相同 500 / 500", () => {
  assert.equal(rows.length, 500);
  assert.equal(bitwiseIdentical(spec, rows), 500);
  console.log(`跨语言逐位相同 ${bitwiseIdentical(spec, rows)} / ${rows.length} 条`);
});

test("前 3 条摘要两侧一致,且与卷一 digest_py.json 逐位相同", () => {
  const want = [3976226298, 3848604931, 1198029437];
  assert.deepEqual(firstThree(rows), want);
  assert.deepEqual(firstThree(v1), want);
  for (let i = 0; i < 3; i += 1) {
    const s = replay(spec, rows[i].choices);
    assert.equal(canonical(s), rows[i].canonical);
    assert.equal(digest(s), v1[i].digest);
  }
  console.log(`前 3 条摘要 ${JSON.stringify(want)}:卷二 = 卷一`);
});

test("摘要输入是纯 ASCII 状态串,不含任何台词文本", () => {
  assert.equal(asciiOnly(rows), true);
  assert.match(rows[0].canonical, /^[\x20-\x7e]*$/);
  console.log(`500 条 canonical 全为 ASCII:${asciiOnly(rows)}`);
});

本机实跑 node --test 'core/test/ne_13_1_cross_digest.test.ts':

跨语言逐位相同 500 / 500 条
前 3 条摘要 [3976226298,3848604931,1198029437]:卷二 = 卷一
500 条 canonical 全为 ASCII:true
✔ TS 侧重放 500 条:与 Python 侧 canonical 与 digest 逐位相同 500 / 500 (27.644959ms)
✔ 前 3 条摘要两侧一致,且与卷一 digest_py.json 逐位相同 (0.58775ms)
✔ 摘要输入是纯 ASCII 状态串,不含任何台词文本 (0.541709ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

判读:500/500 与 499 个不同值

第一,两侧 500 条全部逐位相同。 Python 侧算出 500 条 canonical 与摘要,TS 侧用另一份独立实现重放同一批选择序列,bitwiseIdentical 返回 500——500 条路径一条不差。

第二,与卷一逐位相同。 同一份规格、同一个种子(20261016)、同一算法,卷一 out/digest_py.json 与卷二 out/ne_13_1_py.json 的前 3 条摘要都是 3976226298 / 3848604931 / 1198029437,500 条全部相等。这证明卷二没有另立真源——它消费的是同一份规格。

第三,摘要有分辨力。 500 条路径去重后得到 499 个不同值——只有两条路径终局状态相同。若摘要退化成一个常数,逐位一致就毫无意义。

观察数字读法
两侧逐位相同500 / 500两条独立实现状态摘要一致
与卷一逐位相同500 / 500卷二未另立真源
摘要去重值499 / 500摘要有分辨力,非弱指纹
canonical 非 ASCII 字符0摘要不含任何台词文本

自检表

检查项合格标准
摘要只对状态输入是 canonical 串,不含任何台词文本
键序确定两侧都按键名排序,k=v 逗号连接
溢出语义一致Python & 0xFFFFFFFF 对应 TS Math.imul
逐位比对500 条路径的 canonical 与 digest 全等
跨卷自洽与卷一 digest_py.json 同种子采样逐位相同

摘要立住了,接下来的问题是:同一个摘要值,能不能在三种不同的宿主上分别算出来。下一节把 Python / Node / 浏览器三条链摆到同一条确定性策略路径上。

阅读导航:上一节:12.3 文本的一致性校验 · 下一节:13.2 同源不同宿主 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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