DPE Hash 算法 wire 契约 v1
跨语言 SDK 实现 DPE 三层内容寻址 hash 的逐字节契约。任何语言的实现只要严格遵循本文,就能与 TFRobot 内核产出完全相同的 content_hash / page_hash / doc_hash。
!!! note "状态" 起草中(v1)· 2026-09-18 · 归属 TFRS-379 · 内核算法源 TFROB-374
本文是**符合性规范的单一事实来源**。内核实现与各语言 SDK 均为它的符合性实现,**而非**「以某份代码为准、其余照抄」。这一定位是跨实现强一致的前提。
1. 设计意图
三层 hash 与 Git 的 blob / tree / commit 对象模型同构:
| 层 | 类比 | 输入 |
|---|---|---|
content_hash |
blob | 单个 element 的内容 |
page_hash |
tree | 页标题 + 该页全部 element 的 content_hash 序列 |
doc_hash |
commit | 文档元信息 + 全部 page_hash 序列 |
由此获得 Merkle 式可组合性:改动单个 element 只需重算该 element 及其上溯的 page、doc 三层。
增量投递协议(push-protocol-v1.md)依赖该性质:客户端只需持有上层的 hash 骨架即可与服务端协商缺口。
2. 通用原语
2.1 concat_parts —— 长度前缀拼接
concat_parts(parts: list[bytes]) -> bytes:
return 各 part 依次拼接:<4 字节大端长度><part 字节>
即每个 part 前置 4 字节大端(big-endian)无符号长度,再拼接其原始字节。
为什么需要长度前缀:若直接拼接,["ab", "c"] 与 ["a", "bc"] 会产生相同字节序列,导致不同内容碰撞。长度前缀消除该歧义。
2.2 digest —— 摘要取值
digest(b) = sha256(b).hexdigest()[:32]
注意截断:取十六进制摘要的前 32 个字符(128 位),不是完整 64 字符。
2.3 text_or_empty —— None 归一化
规范中出现的 X or "" 一律表示:None → 空字符串,其余取原值,再以 UTF-8 编码为 bytes。
!!! danger "禁止语言默认的字符串化"
绝不可用 f"{value}" / str(value) / String(value) 之类的隐式转换。None 必须显式转为空串——否则会产生字面量 "None" / "null" / "nil" 静默污染 hash,且不会报错。
3. content_hash —— element 层
content_hash(ele) = digest( concat_parts( hash_parts(ele) ) )
hash_parts() 按 element 的 category 多态分派。全库恰好四个实现:
category |
parts 序列 |
|---|---|
Image |
[text or "", channel, image_mime_type or ""] |
Table |
[text or "", ele_metadata.text_as_html or ""] |
Formula |
[text or "", ele_metadata.text_as_html or ""] |
| 其它全部 | [text or ""] |
其中 Image 的 channel 按固定优先级取第一个非空值:
channel = image_url or image_base64 or image_path or ""
!!! warning "Image 的三通道不可省略"
Image 的 text 通常为空(无 OCR 或纯插图),身份完全落在 metadata。若省略 channel 与 image_mime_type,两张不同图片会产生相同 hash,导致服务端 LCS 把「换图」识别为「未变」——漏检率接近 100%。
!!! warning "Table / Formula 必须纳入 text_as_html"
这两类的 text 只是简单展开,结构化差异全在 text_as_html。省略会导致表格结构变更被漏检。
不进 content_hash 的字段
seq_in_page / file_uri / ele_id / page_id 均不进 element hash。
由此得到一条重要性质:element 在页内移动位置不改变其 content_hash——LCS 因此能把它识别为 EQUAL + 位置变化(走 sync_element 优化路径),而非删除重建。
4. page_hash —— page 层
page_hash(page) = digest( concat_parts(
[title or ""] + [e.content_hash or "" for e in page.elements]
) )
!!! note "顺序即数组顺序"
元素序列取自 page.elements 的数组顺序,不是按 seq_in_page 排序。协议层(见 push-protocol-v1.md §3)因此规定 manifest 中不含 seq_in_page,数组顺序即阅读顺序——不设冗余字段,即从结构上排除二者不一致的可能。
不进 page_hash 的字段
page_id / doc_id / entrance_ele_id / page_metadata 均不进。
其中 page_metadata 的排除是刻意的:它承载 A2A contextId 等运行期信息,与内容指纹正交。
5. doc_hash —— document 层
doc_hash(doc) = digest( concat_parts([
str(file_uri),
file_type.value, # 枚举的 .value
title or "",
json_stable(doc_metadata),
] + [p.page_hash or "" for p in sorted(doc.pages, key=page.number)]))
5.1 file_type 取枚举的 .value
用 file_type.value(如 "markdown"、"rtf"),不是 str(enum)。
!!! warning "语言差异陷阱"
Python 的 str(SomeEnum.MEMBER) 会产出 "TFFileType.RTF" 这类耦合类名与成员名的字符串,且不同语言、不同库对枚举的字符串化结果各不相同("RTF" / "TFFileType.RTF" / 数字序号)。
规范要求:**取枚举声明时的名义值**,而非运行时的 `toString` 结果。
5.2 title 当前恒为空串
doc_hash 公式中含 title,但 Document 当前 schema 未声明 title 字段,实现取 getattr(doc, "title", None) or "" 兜底,即当前恒为 ""。
这是向前兼容写法:未来若加入 title 字段,现有文档等同于「全部带 title=""」,新增的熵由 algo_version 从 v1 升 v2 兜底。
SDK 实现要求:title 写入公式的空串占位必须保留(不能因为「当前恒空」就把它从 parts 里删掉),否则未来内核加入该字段时,所有既有 SDK 会静默算错。
5.3 页序列按 number 排序
page_hash 序列按 page.number 升序排列。
!!! warning "页编号必须稳定且唯一"
该排序依赖 number 的稳定性。客户端若每次投递重新从 0 顺序编号,在文档中部插入一页会使后续页的 number 全部位移,导致 doc_hash 及页配对全面失配。
6. json_stable —— 稳定 JSON 序列化
这是跨语言实现最容易出错的一处。
json_stable(x) = json.dumps(x, sort_keys=True, ensure_ascii=False)
输入是 doc_metadata.model_dump(mode="json") 的结果——即先把嵌套的 Pydantic 模型转为 JSON 原生类型(datetime / Decimal / Enum 等转为字符串或数字),再序列化。
6.1 ⚠️ 分隔符带空格
!!! danger "Python json.dumps 的默认分隔符是 (', ', ': ') —— 带空格"
上式中未指定 separators,因此沿用 Python 默认值,产出形如 {"a": 1, "b": 2} 的带空格文本。
而其它语言的默认紧凑序列化器**一律不产生空格**,必须显式对齐:
| 语言 | 默认行为 | 是否匹配 |
| --- | --- | --- |
| Python `json.dumps`(未指定 separators) | `{"a": 1, "b": 2}` | ✅ 基准 |
| JavaScript `JSON.stringify` | `{"a":1,"b":2}` | ❌ |
| Go `encoding/json.Marshal` | `{"a":1,"b":2}` | ❌ |
| Java Jackson(默认) | `{"a":1,"b":2}` | ❌ |
实测对照:
```
基准(内核实际产出) {"a": "中文", "b": 1, "c": [1, 2]}
JSON.stringify {"a":"中文","b":1,"c":[1,2]}
```
**各语言 SDK 必须显式指定等价于 `separators=(", ", ": ")` 的分隔符策略。**
6.2 ⚠️ 键排序按 Unicode 码点
sort_keys=True 在 Python 中按字符串比较排序,即 Unicode 码点序。
!!! danger "JS 的字符串排序是 UTF-16 码元序,与码点序不同"
JavaScript Array.prototype.sort() 对字符串按 UTF-16 码元比较。对 U+FFFF 以上的字符(emoji、部分生僻汉字、扩展区 CJK),码元序与码点序不一致。
中文内容场景中 emoji 极为常见,该差异会**必然**被触发。SDK 必须实现**按码点**排序,不可直接调用语言的默认字符串排序。
6.3 非 ASCII 不转义
ensure_ascii=False 要求非 ASCII 字符以 UTF-8 原样输出,不得转义为 \uXXXX。
多数语言的默认行为恰好符合(JS / Go 均不转义),但 Java 的部分 JSON 库默认会转义,需显式关闭。
6.4 递归生效
sort_keys 是递归的——嵌套 dict 的键同样排序。SDK 实现必须递归处理,不可只排顶层。
7. 一致性测试向量
!!! tip "强一致靠共享向量,不靠共享代码" 「各语言照抄同一份实现」无法保证长期一致——跨语言实现必然漂移,且漂移是静默的。
行业做法(TLS / Unicode UCD / JSON-LD / BLAKE3)是:**规范仓库发布测试向量,所有实现跑同一套向量逐字节比对。** 这是各语言 SDK 与内核保持强一致的唯一可靠机制。
7.1 向量格式
vectors/
└── v1/
├── manifest.json # 向量清单(用例名 → 文件)
├── element_text.json
├── element_image_channels.json
├── element_table_html.json
├── page_ordering.json
├── doc_metadata_unicode.json
└── …
单个向量文件:
{
"algo_version": "v1",
"case": "image_channel_priority",
"description": "Image 三通道优先级:image_url 优先于 image_base64",
"input": { "…完整 Document(含 pages / elements / doc_metadata)…" },
"expect": {
"content_hashes": ["…与 input 中 element 顺序一一对应…"],
"page_hashes": ["…与 page.number 升序一一对应…"],
"doc_hash": "…"
}
}
7.2 必须覆盖的用例
| 类别 | 用例 |
|---|---|
| 空值归一化 | text=None / ele_metadata.text_as_html=None / image_mime_type=None 均须等价于空串 |
| 图片三通道 | image_url > image_base64 > image_path 的优先级;仅一个通道变化时 hash 必须变化 |
| 表格 / 公式 | 仅 text_as_html 变化(text 不变)时 hash 必须变化 |
| 页内顺序 | 同一组 element 改变数组顺序 → page_hash 必须变化;单个 element 改变位置但 content_hash 不变 |
| Unicode | CJK、emoji(U+FFFF 以上)、组合字符(带变音符号)、RTL 文本 |
doc_metadata 键序 |
含非 ASCII 键与嵌套对象的 metadata,验证码点排序 + 带空格分隔符 + 不转义 |
| 页编号 | 页 number 非连续、非从 0 开始时的排序行为 |
| 边界 | 空文档(0 页)、空页(0 element)、超长单 element |
| 负例 | 预期不同 hash 的近似输入对(防止实现退化为「永远返回常量」) |
!!! warning "必须包含负例向量" 仅有「正例」(给定输入求期望 hash)无法发现实现退化为常量函数或忽略部分字段的情况。每个正例应配一个仅有一处差异的近似输入,断言其 hash 不同。
7.3 交叉实现差分测试
CI 矩阵:内核 + 每个语言 SDK 跑同一套向量,逐字节比对。
新增 algo_version 时,向量先行——向量先红,各实现才明确知道需要跟进。
8. 版本管理
8.1 版本载体
算法版本的载体是 hash_strategy_uri,格式:
hash-strategy://{register_name}?algo_version={version}
范例:
hash-strategy://default?algo_version=v1
hash-strategy://image-source?algo_version=v1
它是一个可扩展参数包(内核已预留多参形态,如 hash-strategy://tabular?algo_version=v3&include_coords=true),因此比裸的「算法版本号」表达力更强。协议原样透传该 URI,不另立 hash_algo_version 字段。
8.2 版本记录
该 URI 随 Document 持久化(hash_strategy_uri 列,NOT NULL),因此每个文档都记录了自己是用哪个算法版本算的——满足「某文档使用哪个版本的 Hash 算法」的需求,无需额外机制。
8.3 升级语义
| 情况 | 内核行为 |
|---|---|
新旧文档 hash_strategy_uri 相同 |
正常 LCS reconcile |
新旧文档 hash_strategy_uri 不同 |
自动走全删全建(_full_rebuild),不做跨版本比对 |
!!! tip "跨版本不需要数据迁移" hash 值在不同算法版本间不可比较,因此内核不做增量、直接重建。存量文档会在下次同步时自然收敛到新版本,无需离线迁移脚本。
8.4 SDK 的版本分派
SDK 必须按 algo_version 分派到对应实现,而非只实现最新版:
# 示意:SDK 内部结构
def element_hash(ele, algo_version: str) -> str:
if algo_version == "v1":
return _v1.element_hash(ele)
raise UnsupportedAlgoVersion(algo_version)
同时 SDK 需维护 SUPPORTED_ALGO_VERSIONS 声明,供调用方与服务端能力文档(见 push-protocol-v1.md §2)做交集协商。
8.5 版本废弃
服务端通过 accepted_hash_strategies allowlist 控制可接受的版本。关停旧版本不需要改代码——运维调整配置即可。
关停前应观察期:能力文档中先标 deprecated_at,一段时间后再移出 allowlist。
9. 实现检查清单
各语言 SDK 实现完成后逐项核对:
- [ ]
concat_parts使用 4 字节大端长度前缀 - [ ] 摘要取
sha256十六进制值的前 32 字符 - [ ] 所有可选字段经
or ""归一化,无隐式字符串化 - [ ]
Image/Table/Formula的多态 parts 逐字段对齐 - [ ]
Image三通道优先级正确 - [ ]
page_hash取数组顺序,非按seq_in_page排序 - [ ]
doc_hash的页序列按page.number升序 - [ ]
file_type取枚举.value,非toString - [ ]
title空串占位保留 - [ ]
json_stable使用带空格分隔符 - [ ]
json_stable键排序按 Unicode 码点(非 UTF-16 码元) - [ ]
json_stable非 ASCII 不转义 - [ ]
json_stable递归排序嵌套键 - [ ] 全部测试向量通过(含负例)
- [ ] 通过交叉实现差分测试