Skip to content

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 递归排序嵌套键
  • [ ] 全部测试向量通过(含负例)
  • [ ] 通过交叉实现差分测试

10. 相关文档