15.1 跨语言摘要与 digest
《引擎实现与体验打磨》 那套管的是引擎侧——渲染、资源、API,把画面画出来;本节管的是更靠下、也更硬的一件事:同一份规格在三处消费(Python 仿真、TS 内核、浏览器原型)跑完之后,能不能给出同一串摘要。5.3 原型与内核的同源性 已经证明浏览器与 Node 跑的是同一段源码,11.3 数值的可复现 划了机制层与表现层的边界。本节把两条收口:摘要不是打印用的,它是一份契约。
摘要为什么能当契约
ashlib.digest(snap) 与 digestSnap(s) 不是「碰巧一样」,而是必须一样。它们各自把快照里参与胜负判定的 12 个字段,按字母序拼成一段无空格的规范化 JSON。三件事同时成立,字符串才可能逐字相同:
| 条件 | 做法 | 少它会怎样 |
|---|---|---|
| 键序 | 两侧都按字母序插入 | Python 的 sort_keys=True 对齐 TS 的手工字母序插入,顺序不同字符串就不同 |
| 类型 | 值全是整数 / 布尔 / 字符串数组 | Python 50.0 序列化成 "50.0",JS 50.0 却是 "50",同一个数两种写法 |
| 分隔符 | 无空格(, 与 :) | Python 默认 , 与 : ,与 TS 的 JSON.stringify 一比全是空格噪音 |
三者缺一,字符串就在某个宿主上飘掉。这也是为什么第 15 章要把跨语言 digest 单独立节:它是唯一可以写「逐位一致」的地方——11.3 已经声明过这条边界,本节把它落到一串能贴进终端的字符上。
三个场景
只跑一个默认场景不足以证明契约——那可能只是巧合。本节固定三个场景,让胜负、中途态、随机性各被覆盖一次:
| 场景 | 序列 | 种子 | 关注点 |
|---|---|---|---|
| 终态 | 完整 25 条 | 20261014 | 胜负已定(enemyHp=0) |
| 前缀 | 前 6 条 | 20261014 | 中途快照,胜负未定、手牌更多 |
| 换 seed | 完整 25 条 | 42 | 洗牌序变,手牌与出牌都变 |
S 层:三场景摘要 + 契约自检
脚本对每个场景算一遍 Python 摘要,并做四项契约自检:键集合等于 DIGEST_KEYS、键已按字母序、字符串里没有小数点、能反序列化后重算回原串。若 out/mech15_digests.json 已存在(由 W 层落盘),再拼出三方对照。
#!/usr/bin/env python3
# file: sim/gd_mech_15_digest.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_15_digest.py
"""15.1 跨语言摘要与 digest(S 层)。
同一份 play_spec.json,三个场景,Python 侧的 ashlib.digest 必须与 TS 侧的
digestSnap 逐字相同。三个场景:
终态 跑完整段默认策略序列(enemyHp<=0,胜负已定);
前缀 只跑前 6 条指令(中途快照,胜负未定、手牌更多);
换 seed 种子从 20261014 换成 42,洗牌序变,手牌与出牌都变。
摘要能当契约,靠三件事同时成立——**键按字母序、值全是整数/布尔/字符串数组、
序列化无空格**。少任何一条,字符串就会在某个宿主上飘掉。本脚本:
① 三场景 Python 摘要 + 契约自检(键集合 = DIGEST_KEYS、无小数点、可反序列化);
② 读 out/mech15_digests.json(浏览器侧与 Node 侧,由 web/verify_mech15.mjs 落),
三方逐字对照;缺失时只打 Python 列并提示,不因此失败;
③ 落 out/gd_mech_15_digest.json 与 out/mech15_digest.csv。
"""
from __future__ import annotations
import copy
import json
import os
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def scenarios(spec: dict) -> list[tuple[str, dict, list]]:
"""三个场景:终态 / 前缀 / 换 seed。"""
seq = list(spec["strategy"])
seed42 = copy.deepcopy(spec)
seed42["seed"] = 42
return [
("终态", spec, seq),
("前缀", spec, seq[:6]),
("换 seed", seed42, seq),
]
def contract_check(digest: str) -> dict:
"""摘要契约自检:键集合、无小数点、可反序列化、布尔小写。"""
obj = json.loads(digest)
keys = list(obj.keys())
return {
"len": len(digest),
"keys": keys,
"keys_sorted": keys == sorted(keys),
"keys_match": keys == sorted(ashlib.DIGEST_KEYS),
"no_dot": "." not in digest,
"has_bool": ("true" in digest) or ("false" in digest),
"roundtrip": ashlib.digest(obj) == digest,
}
def main() -> int:
spec = ashlib.load_spec()
rows = []
checks = []
for name, sp, seq in scenarios(spec):
d = ashlib.digest(ashlib.run_spec(sp, seq))
c = contract_check(d)
rows.append([name, str(c["len"]), d])
checks.append(c)
# 契约必须逐场景成立:键集合、字母序、无小数点、可反序列化。
ok = all(
c["keys_match"] and c["keys_sorted"] and c["no_dot"] and c["roundtrip"]
for c in checks
)
csv_path = os.path.join(ROOT, "out", "mech15_digest.csv")
ashlib.write_csv(csv_path, ["scenario", "len", "digest"], rows)
# 浏览器侧 + Node 侧(W 层产物);缺失不失败。
wpath = os.path.join(ROOT, "out", "mech15_digests.json")
browser = None
if os.path.exists(wpath):
browser = json.load(open(wpath, encoding="utf-8"))
py = {name: d for name, _, d in [(r[0], r[1], r[2]) for r in rows]}
three_way = None
if browser is not None:
three_way = []
for sc in browser["scenarios"]:
three_way.append({
"name": sc["name"],
"python": py[sc["name"]],
"node": sc["nodeDigest"],
"browser": sc["browserDigest"],
"all_equal": py[sc["name"]] == sc["nodeDigest"] == sc["browserDigest"],
})
report = {
"spec": spec["id"],
"scenarios": [
{"name": r[0], "len": int(r[1]), "digest": r[2], "contract": checks[i]}
for i, r in enumerate(rows)
],
"contractOk": ok,
"threeWay": three_way,
"threeWayOk": None if three_way is None else all(t["all_equal"] for t in three_way),
}
out = os.path.join(ROOT, "out", "gd_mech_15_digest.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("scenario len digest")
for name, ln, d in rows:
print(f"{name} {ln} {d}")
print(f"contract_keys={ashlib.DIGEST_KEYS == sorted(ashlib.DIGEST_KEYS)} "
f"keys_match={all(c['keys_match'] for c in checks)} "
f"no_dot={all(c['no_dot'] for c in checks)} "
f"roundtrip={all(c['roundtrip'] for c in checks)}")
if three_way is None:
print("three_way=pending(缺 out/mech15_digests.json,先跑 web/verify_mech15.mjs)")
else:
for t in three_way:
print(f"three_way {t['name']} python==node==browser -> {t['all_equal']}")
print(f"three_way_ok={report['threeWayOk']}")
print(f"wrote out/gd_mech_15_digest.json wrote out/mech15_digest.csv rows={len(rows)}")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_15_digest.py:
scenario len digest
终态 172 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
前缀 187 {"block":5,"cardCount":12,"damageConserved":true,"dealt":23,"enemyHp":27,"energy":2,"hand":["guard","strike","strike","strike"],"over":false,"played":4,"playerHp":54,"turn":2,"won":false}
换 seed 167 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","heavy"],"over":true,"played":10,"playerHp":52,"turn":4,"won":true}
contract_keys=True keys_match=True no_dot=True roundtrip=True
three_way 终态 python==node==browser -> True
three_way 前缀 python==node==browser -> True
three_way 换 seed python==node==browser -> True
three_way_ok=True
wrote out/gd_mech_15_digest.json wrote out/mech15_digest.csv rows=3
三条摘要长度不同(172 / 187 / 167),因为它们内容不同:前缀场景胜负未定,hand 是四张牌、over:false,长度自然更长;换 seed 场景手牌只剩两张,长度更短。摘要不是定长指纹,是内容的规范化投影——长度本身也是内容的一部分。
P 层:同一组摘要,TS 侧逐字断言
TS 侧不重算「应该是什么」,而是把 Python 侧那三串硬编码进测试,逐字比对。另外给三条反例,把「键序 / 整数 / 无浮点缺一不可」变成可执行的断言(完整用例见 core/test/gd_mech_15_digest.test.ts):
// snippet: core/test/gd_mech_15_digest.test.ts 的核心断言(完整版见工作区)
// 与 sim/gd_mech_15_digest.py 的实跑输出逐字相同(三场景)。
const PY = {
终态: '{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,' +
'"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,' +
'"playerHp":48,"turn":4,"won":true}',
前缀: '{"block":5,"cardCount":12,"damageConserved":true,"dealt":23,"enemyHp":27,' +
'"energy":2,"hand":["guard","strike","strike","strike"],"over":false,"played":4,' +
'"playerHp":54,"turn":2,"won":false}',
换seed: '{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,' +
'"energy":1,"hand":["strike","heavy"],"over":true,"played":10,' +
'"playerHp":52,"turn":4,"won":true}',
};
test("三场景:TS digest 与 Python 侧逐字相同", () => {
const seq = SPEC.strategy;
assert.equal(digestSnap(runSpec(SPEC, seq)), PY.终态);
assert.equal(digestSnap(runSpec(SPEC, seq.slice(0, 6))), PY.前缀);
assert.equal(digestSnap(runSpec({ ...SPEC, seed: 42 }, seq)), PY.换seed);
});
test("摘要契约:12 键、字母序、无小数点、长度随场景变", () => {
const d = digestSnap(runSpec(SPEC, SPEC.strategy));
assert.deepEqual(Object.keys(JSON.parse(d)), KEYS); // 键集合 + 插入序 = 字母序
assert.equal(KEYS.length, 12);
assert.equal(d.includes("."), false); // 无浮点
assert.equal(d.length, 172);
});
test("反例二:浮点值让两侧字符串不同(值必须是整数)", () => {
assert.equal(JSON.stringify({ dealt: 50.0 }), '{"dealt":50}');
assert.equal(JSON.stringify({ dealt: 50 }), '{"dealt":50}');
});
test("反例三:BigInt 无法进摘要(JSON.stringify 直接抛)", () => {
assert.throws(() => JSON.stringify({ dealt: 50n }), TypeError);
});
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_15_digest.test.ts':
✔ 三场景:TS digest 与 Python 侧逐字相同 (0.755ms)
✔ 摘要契约:12 键、字母序、无小数点、长度随场景变 (0.524333ms)
✔ 反例一:键序变了,字符串就变(digestSnap 必须按字母序插入) (0.084167ms)
✔ 反例二:浮点值让两侧字符串不同(值必须是整数) (0.092834ms)
✔ 反例三:BigInt 无法进摘要(JSON.stringify 直接抛) (0.221958ms)
✔ 纯函数:同规格同序列,跑两遍 digest 恒等 (0.069125ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
W 层:浏览器这一处
浏览器与 Node 跑的是同一段内核(5.3 已证),所以第三个宿主不需要新写实现。第 15 章的页面比第 5 章多一个能力:入口 run(seq, seed) 支持种子覆写,好把「换 seed」场景也搬进浏览器。
// snippet: web/build_mech15.mjs 暴露的入口(完整版见工作区)
globalThis.__ashdeck = {
run(seq, seed) {
const sp = JSON.parse(JSON.stringify(SPEC));
if (seq) sp.strategy = seq;
if (seed !== undefined && seed !== null) sp.seed = seed;
snap = runSpec(sp);
render(snap);
return digestSnap(snap);
},
canvasDrawn: false,
};
实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech15.mjs --page out/mech15.html --shot out/shots/mech15.png:
=== 第 15.1 节浏览器侧断言 ===
ok 场景「终态」浏览器 digest == Node 侧 digest
ok 场景「前缀」浏览器 digest == Node 侧 digest
ok 场景「换 seed」浏览器 digest == Node 侧 digest
ok 两遍独立加载终态 digest 逐字一致(确定性)
ok 换前缀确实换了结果(注入被真的消费)
ok 换 seed 确实换了结果(seed 覆写生效)
ok canvas 已绘制(115200 px >= 1)
ok 截图已落盘(55187 B)
SCENARIO 终态 len=172 match=true
SCENARIO 前缀 len=187 match=true
SCENARIO 换 seed len=167 match=true
PAINTED=115200 SHOT_BYTES=55187 ALL_MATCH=true
OK W 层通过
三处对照表
三个场景,三处消费,九条摘要——没有一条不同。下表每一行是一条场景,三列分别取自 ashlib.digest、Node 的 digestSnap、浏览器内联的同一个 digestSnap:
| 场景 | Python(ashlib.digest) | Node(digestSnap) | 浏览器(内联同段) |
|---|---|---|---|
| 终态 | {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true} | 同左 | 同左 |
| 前缀 | {"block":5,"cardCount":12,"damageConserved":true,"dealt":23,"enemyHp":27,"energy":2,"hand":["guard","strike","strike","strike"],"over":false,"played":4,"playerHp":54,"turn":2,"won":false} | 同左 | 同左 |
| 换 seed | {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","heavy"],"over":true,"played":10,"playerHp":52,"turn":4,"won":true} | 同左 | 同左 |
「同左」不是省事——它恰好是本节要说的结论:同一列换成任何一列,字符都不变。第 5 章只对默认场景做了三方对照,本节把它扩到三个场景:胜负态、中途态、随机性各覆盖一次,契约才算真的被验过。
键序、整数、无浮点,为什么缺一不可
三条反例各自堵一类失效,且都能在测试里跑出来:
| 反例 | 现象 | 断言 |
|---|---|---|
| 键序 | {"b":1,"a":2} ≠ {"a":2,"b":1} | digestSnap 必须按字母序插入键 |
| 浮点 | Python 50.0 → "50.0",JS 50.0 → "50" | 机制层的值必须是整数,且落在安全整数域内 |
| BigInt | JSON.stringify({dealt: 50n}) 抛 TypeError | 摘要里不能出现 BigInt(幂与增长量走另一条路) |
换宿主时,这三条是必须先过的关。它们不是风格问题,是「同一串字符」能否成立的结构条件。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_mech_15_digest.py | out/gd_mech_15_digest.json + CSV | 三场景契约全绿,three_way_ok=True |
node --test 'core/test/gd_mech_15_digest.test.ts' | P 层断言 | 6 用例全绿 / 6 pass / 0 fail |
node web/verify_mech15.mjs --page out/mech15.html --shot out/shots/mech15.png | out/mech15_digests.json + 截图 | 8 条断言全绿 / ALL_MATCH=true |
摘要对齐之后,「三处消费」里还有一层更隐蔽的差异:宿主本身。下一节把 Python 的 int 与 JS 的 number、BigInt、字符串编码摆在一起,划清哪些量必须逐位一致、哪些只能按容差。
阅读导航:上一节:14.3 从机制到体验的断言 · 下一节:15.2 同源不同宿主 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。