数据截至 (上游 commit b77d61291399)
压缩器全家桶 — 每种内容各有各的「不能破坏什么」
30 秒导读: 上一章讲的是「一段内容怎么被路由到对的压缩器」(01-pipeline-and-router)。这一章讲被路由到之后发生了什么——十来个压缩器,每个吃一类内容,各自用完全不同的算法,但设计套路是同一个:先钉死一条不能破坏的不变量,再围着它设计降级路径。JSON 数组不能改 schema,源码不能变得不可解析,表格不能把值挪错列,日志不能把两类错误合并成一类。做不到就退回原文,绝不交付「看起来压缩了但含义变了」的结果。
1. 一句话总纲:压缩器 = 一条不变量 + 一条降级路径
先把全家桶摆出来。看这张表的方法:第三列才是每个压缩器真正的设计约束,第二列的算法是为了满足第三列才长成那样的。
| 内容类型 | 压缩手法 | 不能破坏什么(不变量) | 破不了时的降级 |
|---|---|---|---|
| JSON 数组 | 无损列折叠 → 有损丢行 + CCR 哨兵 | 输出里只能有原数组里的元素,不加包装、不加生成文本 | 原样返回(lossless_only:uncompacted) |
| 源码 | AST 解析 → 保签名 + 截函数体 | 输出必须仍能被 tree-sitter / ast.parse 解析 | 退回原文;或转交 Kompress(此时明确标 syntax_valid=False) |
| grep/rg 结果 | 每文件保首末 + 按分打分选行 | file:line:content 三元组不能错位 | 原样返回 |
| 日志 / 构建输出 | 分级打分 + 保守去重 + 栈帧折叠 | 两条不同类别的错误不能被折成一条 | 只做前缀保留的保守归一化 |
| git diff | 按 hunk 选,收缩上下文行 | 增删行本身不删(always_keep_*) | 原样返回 |
| YAML/TOML/INI | 三层:可逆折叠 → 注释省略 → schema 折叠 | 引号里的 # 是数据不是注释 | 检测到块标量就整层关掉 |
| HTML | trafilatura 抽正文 | 这是抽取不是压缩,正文一个字不改 | 抽不出来返回空串 |
| CSV / markdown 表 | 转 records → 交给 SmartCrusher | 行的单元格数必须等于表头数 | 参差表判为「非表格」原样放行 |
| 纯文本(快) | 句子打分 + shingle 去近重 | 抽取式:保留的是原句原词,不改写 | 段数不够就不压 |
| 纯文本(准) | ModernBERT 逐 token 保留打分 | 数字/路径/错误码/标志位必须留下 | 三级超时 → passthrough,连错三次熄火 |
共同的降级哲学只有一句:压缩失败是可接受的,压错是不可接受的。仓库里把这条写成了一份 feedback memory,反复被引用:feedback_no_silent_fallbacks.md(见 headroom/transforms/smart_crusher.py:269、headroom/transforms/diff_compressor.py:83)。
三层降级模型长这样,从左往右是尝试顺序,命中即停:
┌──────────────┐ 够省 ┌────────────────────────┐
一段内容 ──▶│ ① 无损重排 │ ─────▶│ 交付,不需要任何召回 │
│ 同样的信息 │ └────────────────────────┘
│ 更少的字节 │
└──────┬───────┘
│ 不够省 / 不适用
▼
┌──────────────┐ 允许 ┌────────────────────────┐
│ ② 有损裁剪 │ ─────▶│ 交付 + 留召回记号 │
│ 丢一部分 │ │ <<ccr:HASH ...>> │
└──────┬───────┘ └────────────────────────┘
│ 不允许(严格无损 / 保护行 / 语法坏了)
▼
┌──────────────┐
│ ③ 原样返回 │ 这次不省了,但绝不出错
└──────────────┘
第 ② 步那个记号怎么兑现,是下一章 03-ccr 的内容;这里只关心「什么时候有资格走到第 ②/③ 步」。
2. 所有压缩器长的都是同一副骨架
不管吃的是 JSON 还是日志,代码结构都是三段。先建立这个心智模型,后面每一节就只是在填三个格子。
原始文本
│
▼
┌──────────┐ 把无结构的字节变成有结构的单元
│ ① 解析 │ JSON→rows / 源码→AST / 日志→LogLine / 表→records
└────┬─────┘
│
▼
┌──────────┐ 给每个单元打分,再决定保谁
│ ② 选择 │ 位置锚点 + 异常检测 + 用户问题相关性 + 自适应配额
└────┬─────┘
│
▼
┌──────────┐ 渲染 + 自检;自检不过就整段回滚
│ ③ 渲染 │ csv-schema / 合法源码 / 折叠后的日志
└──────────┘
第 ② 步里的「用户问题相关性」和「自适应配额」是跨压缩器共享的两个模块,值得单独一节讲(见 §7)。它们是「同一份 grep 结果,你问的问题不同,留下来的行就不同」的原因。
3. JSON 数组 — SmartCrusher
JSON 数组是 agent 工具输出里最常见的形状:一次 list_issues 回来 800 条记录,每条 20 个字段,字段名逐行重复。这也是省得最多的地方。
3.1 它守的那条不变量:schema-preserving
配置类的 docstring 把话说死了:输出里只能包含原数组里的元素,没有包装层、没有生成的文字、没有额外的元数据键(headroom/transforms/smart_crusher.py:161 SmartCrusherConfig,docstring 第一行 SCHEMA-PRESERVING)。
为什么这条这么重要?因为下游代码会拿到压缩后的数组接着遍历:
# 示意,非源码 —— 下游消费者的典型写法
entries = json.loads(tool_output)
for e in entries:
print(e["level"], e["msg"]) # 它假设每一项都是同一个 schema
如果压缩器往数组里塞一个 {"summary": "共 800 条,略去 785 条"},这段循环立刻 KeyError。所以 SmartCrusher 只会少给几行,不会改行的形状。
唯一的例外是丢行时补的那个哨兵对象,而且它被设计成尽量不破坏这条不变量:
{"_ccr_dropped": "<<ccr:abc123def456 785_rows_offloaded>>"}
它仍然是个 dict,数组仍然是「对象数组」,只是多了一个众所周知的键(Rust 侧生成于 crates/headroom-core/src/transforms/smart_crusher/crusher.rs:560-575)。Python 侧给出常量和过滤器,让下游一行代码把它摘掉:CCR_SENTINEL_KEY(headroom/transforms/smart_crusher.py:79)、is_ccr_sentinel(:82)、strip_ccr_sentinels(:87)。strip_ccr_sentinels 还刻意对非 list 输入原样返回,这样调用方可以直接包住 json.loads 的结果而不用先判形状。
3.2 先试无损:csv-schema 列折叠
有损丢行需要模型再花一次工具调用去取回,无损重排不需要。所以 SmartCrusher 是无损优先的。
一个 3 行的数组,原样序列化是这样(字段名重复 3 遍):
[{"id":1,"lvl":"INFO","msg":"start"},
{"id":2,"lvl":"WARN","msg":"slow"},
{"id":3,"lvl":"INFO","msg":"done"}]
csv-schema 渲染器把它折成一行声明 + 三行 CSV(crates/headroom-core/src/transforms/smart_crusher/compaction/formatter.rs:256 write_table):
[3]{id:int,lvl:str,msg:str}
1,INFO,start
2,WARN,slow
3,INFO,done
信息一个字没少,字节掉了一半以上。可选的渲染器有三种,由 CompactionStage::SUPPORTED_FORMAT_NAMES 声明(crates/headroom-core/src/transforms/smart_crusher/compaction/mod.rs:95):
| 渲染器 | 形状 | 什么时候选它 |
|---|---|---|
csv-schema | 声明行 + CSV 行 | 默认。字节最省 |
json | 紧凑 JSON | 下游要结构化消费、调试 |
markdown-kv | 每行 - key: value | 用 token 换模型读取准确率 |
无损不是无条件采用的,得先赢过一个门槛。Rust 侧算出渲染后的字节数,和原数组的估算字节数比,省得不够就放弃无损、落到有损路径(crates/headroom-core/src/transforms/smart_crusher/crusher.rs:816-845):
// crusher.rs:833 —— 真实源码片段
if savings_ratio >= self.config.lossless_min_savings_ratio {
门槛默认 0.15,而且 Python 和 Rust 两侧必须保持一致——Python 那侧的注释直接点名 config.rs 说「两者必须步调一致」(headroom/transforms/smart_crusher.py:186-193)。为什么无损的门槛比有损低?注释也说了:无损输出不需要 CCR 往返,模型想看更多行时直接就在眼前,所以它值得一个更宽松的准入。
3.3 再退有损:选行策略与「必留」约束
无损没赢,才进有损。有损路径的核心是选哪些行留下,由四个策略分工(crates/headroom-core/src/transforms/smart_crusher/planning.rs:97 create_plan 分发):
| 策略 | 适用形状 | 主信号 |
|---|---|---|
TimeSeries | 有时间戳的序列 | 变点前后 ±2 行全留 |
TopN | 带分数字段的排序结果 | 按分数取前 N |
ClusterSample | 日志式 | 按 message 前 50 字符的 md5 分簇,每簇留 2 条 |
SmartSample | 兜底 | 位置锚点 + 数值异常(> variance_threshold σ)+ 变点 ±1 |
四个策略的骨架是同一套,写在 planning.rs:5-20 的模块注释里:锚点 → 策略专属信号 → 错误关键词 → 查询锚点与相关性 → TOIN 学到的字段 → 去重收尾。
其中「错误关键词」这一步是必留约束,不受配额限制。 它以 Constraint trait 的形式注入(planning.rs:85 apply_constraints),OSS 默认装了两条:保留含错误关键词的行、保留结构异常的行。一个 800 条日志的数组里那唯一一条 FATAL,不会因为「统计上不显著」被采样掉。
统计上再聪明的采样,也不知道「这行是合规审计记录,消失了要出事」。所以 #1705 在 Python 侧额外加了一层 audit-safe 模式,而且刻意不下沉到 Rust:
┌── 压缩前:按 protected_patterns 扫出「保护行」
crush_array_json ──▶ Rust 统计采样 ──▶ 结果
└── 压缩后:比对,少了的保护行原样补回(splice)
└── 补完还是少 ──▶ fail closed:整段返回原文
三个方法各司其职:_scan_protected_rows(headroom/transforms/smart_crusher.py:581)扫描,_splice_missing_protected(:596)用 Counter 做多重度感知的补回(两条一模一样的保护行会被分别记账),_apply_audit_safe_protection(:625)做补回后的复核。复核仍然不够时,默认按 fail_closed_on_protected_loss=True 整段回滚(:669-678)——宁可不压,不可漏。
配套的小细节也很讲究:_compile_protected_patterns(:546)在构造时就编译正则,编不过直接抛 ValueError。理由写在 docstring 里——把非法正则悄悄当成「没有保护行」,恰恰会让调用方以为自己保护了、其实没有。
还有一处只有踩过才知道的细节:补回后只在真的补了东西时才重新序列化(:654-660)。因为 Python 的 json.dumps 和 Rust 的 serde_json 在非 ASCII 转义上不一定一致,不改就别碰,保持和 Rust 输出字节相同。
3.4 两道「不许有损」的总闸
有两个开关能把整条有损路径关掉,语义不同。
lossless_only 是模式级的:无损压缩照做, 但任何需要留 CCR 记号的路径(丢行、opaque blob 外置)一律改成「不压」。输出保证无记号、字节可完全还原(headroom/transforms/smart_crusher.py:194-200)。Rust 侧在有损路径入口留了一句 debug_assert!,把这条约束钉在代码里:
// crusher.rs:881 —— 真实源码片段
debug_assert!(
!self.config.lossless_only,
"lossy path reached under lossless_only — the early return \
above must keep this codepath (and its CCR store write) \
unreachable in strict lossless mode",
);
这句断言不是装饰。Python 侧的按调用覆盖(crush(..., lossless_only=True))靠的是临时换一个 Rust crusher 实例(_build_rust,headroom/transforms/smart_crusher.py:446),而那个备用实例的 CCR store 之所以能保持空,前提正是「严格无损模式下永远走不到写 store 的那行」。断言坏了,备用实例的 store 就会分叉。
enable_ccr_marker 是记号级的:ccr_config.enabled=False 和 inject_retrieval_marker=False 任一为假,就把它压成 False(:397-399)。此时行照丢,但既不发记号也不写 store——发一个 prompt 里没人能引用的记号毫无意义,而在用户明确关掉的情况下偷偷存一份原文更是意外的副作用。
3.5 这个类如今只是 Rust 的一层壳
SmartCrusher 的 Python 实现已经退役(2026-04-27,Stage 3c.1b),整个类现在是 headroom._core.SmartCrusher 的 PyO3 壳(模块 docstring:headroom/transforms/smart_crusher.py:1-19)。退役前用 17 份录制 fixture 做过逐字节比对。
哪些还留在 Python,是有讲究的:
| 留在 Python 的部分 | 为什么不下沉 |
|---|---|
SmartCrusherConfig / CrushResult 两个 dataclass | 调用方在用 asdict()、__dict__、dataclass 模式匹配 |
apply() 的消息遍历、digest 标记插入、token 计数 | 是围着压缩调用的胶水,不是压缩本身 |
| TOIN 学习记录 | Rust 不知道 TOIN;不接回来这条学习链就断了 |
| CCR 记号镜像到 Python store | /v1/retrieve 查的是 Python store,和 Rust 进程内 store 是两个东西(#389) |
| audit-safe 保护 | 纯 Python 后处理,_rust_cfg_kwargs 刻意不传这几个字段(:215-225) |
导入是硬导入,没有 Python 兜底(:270-275)。轮子没编译就直接崩,理由还是那句:响亮地失败好过悄悄降级。
GIL 也是有意释放的。 PyO3 侧对 crush / smart_crush_content 都套了 py.detach(...)(crates/headroom-py/src/lib.rs:764、:777),这样代理里其它 Python 线程能在 JSON 解析 + 递归处理 + 逐数组压缩这段重活期间继续跑。
TOIN 记录还有一个容易漏的过滤条件:只在 was_modified=True 并且 strategy != "passthrough" 时才记(:512、:866)。因为 Rust 有时只是把 JSON 重新规范化了一遍空白,was_modified 就翻成 True 了——这种「压缩」没有任何学习价值。
3.6 两个 serde_json feature 是正确性要求,不是性能选项
工作区 Cargo.toml:50 给 serde_json 开了三个 feature,前两个直接关系到正确性:
| feature | 干什么 | 不开会怎样 |
|---|---|---|
preserve_order | Value::Object 底层换成 IndexMap,保留 JSON 的解析顺序 | 默认 BTreeMap 会按键排序,每一个多键对象的输出都和 Python 的 dict 插入序不一致,逐字节比对全挂 |
arbitrary_precision | Value::Number 保留源文里的字面数字串 | 1.0 会塌成 1,12345678901234567 会掉精度——违反「未改动的字节必须原样透传」这条 Realignment I1 不变量 |
raw_value | 暴露 RawValue,未解析的 JSON 片段 | 未修改的 messages[*] 无法按原始字节切片转发 |
第二条尤其值得记住:一个压缩器如果把 1.0 变成 1,它就已经在说原文没说的话了,哪怕数值上相等。这和 §5.4 里表格「参差行一律不折」是同一个原则的两种表现。
3.7 为什么宁可抛 NotImplementedError
SmartCrusher.__init__ 的签名里还留着 relevance_config 和 scorer 两个参数,但任一非 None 就直接抛异常:
# headroom/transforms/smart_crusher.py:345-352 —— 真实源码片段
if relevance_config is not None or scorer is not None:
raise NotImplementedError(
"SmartCrusher: custom `relevance_config` / `scorer` "
"overrides are not yet supported by the Rust-backed "
...
这是全仓库最能代表设计取向的一段。 参数保留是为了源码兼容,不静默忽略是因为:调用方如果依赖一个自定义打分函数,而我们把它丢了,他拿回来的压缩结果是错的,而且他看不见。压缩器的输出天生难以核对——你没法一眼看出「这 15 行是不是该留的那 15 行」。正因为看不见,静默降级在这里的代价比在别的系统里高得多。
同一条逻辑还出现在另一处:compaction_format 即使在 with_compaction=False(该参数会被忽略)的情况下也照样校验,不认识的名字直接 ValueError(:430-438)。理由是「旋钮碰巧没被用到」不等于「配错了没关系」。
4. 源码 — CodeAwareCompressor
4.1 不变量:输出必须还能被解析
代码压缩的 诱惑是「按 token 重要度删词」,但那样删出来的东西不是代码了,模型读到会开始编。所以这个压缩器的不变量是硬的:压缩后的字符串必须仍然是合法源码。
它的实现思路可以一句话概括:保住骨架,削掉血肉。导入、签名、类型标注、装饰器、类型定义一律保留;函数体按重要度分配行数预算,超预算的部分折成一行注释。
一个 Python 函数压完长这样(_compress_function_ast 的输出形状,headroom/transforms/code_compressor.py:1857-1893):
def process_data(items: List[str]) -> List[str]:
"""Process a list of items."""
results = []
# [8 lines omitted; calls: normalize, validate]
pass
三处细节都不是随手写的:
- 签名和类型标注一字不改——模型靠它判断怎么调用。
# [8 lines omitted; calls: ...]里的 calls 列表来自符号分析,告诉模型这段被折叠的代码调用了谁(_make_omitted_comment,:2453)。- 冒号语言(Python)额外补一行
pass,否则空函数体语法就错了(:1890-1892)。
4.2 多语言靠数据表,不靠 if-else
11 种语言各声明一张 AST 节点类型表,如果每种写一套 _extract_python/_extract_go,这个文件会失控。它 改成由表驱动,提取和压缩逻辑只有一份(LangConfig,:292;表本体 _LANG_CONFIGS,:328)。
数一下容易差一个:
CodeLanguage枚举有 12 个成员(:213),但其中UNKNOWN不是语言;_LANG_CONFIGS实际只有 11 条(PYTHON / JAVASCRIPT / TYPESCRIPT / GO / RUST / JAVA / C / CPP / PERL / CSHARP / PHP)。再者,PERL 虽然配置在表里,却被_UNSAFE_TREE_SITTER_LANGUAGES(:106)排除在 AST 路径之外——配置在,但不启用(详见 §9)。
一条语言配置声明这些东西:
| 字段 | 声明什么 | Python 的取值 |
|---|---|---|
import_nodes | 哪些节点算导入 | import_statement 等 |
function_nodes / class_nodes | 哪些是函数 / 类 | function_definition / class_definition |
body_node_types | 哪个子节点是函数体 | block |
decorator_node | 装饰器包装节点 | decorated_definition |
comment_prefix | 注释前缀 | # |
uses_colon_after_signature | 冒号语言还是花括号语言 | True |
opaque_node_types | 整块保留、不递归进去的节点 | (C# 用它挡 #if) |
opaque_node_types 那条注释值得单独看(注释 :321-324,字段声明 :325):递归进 C# 的 #if 块会把它的后代逐个收集一遍,而顶层又会把整个包装节点原样再发一次,内容就重复了。所以宁可要这个漏检(不压 #if 里的内容),也不要那个重复。
4.3 预算怎么分:重要的函数留得多
不是每个函数都砍到 5 行,而是按重要度分配预算。两步:
第一步,给每个符号打分(_analyze_symbol_importance,:917)。信号是加起来的原始分,再做 min-max 归一化到 0~1——归一化是关键,它让分数在这个文件内部相对,所以工具库、测试文件、编排器都能自适应:
| 信号 | 加多少 | 直觉 |
|---|---|---|
| 被引用次数 | +refs | 文件里到处在用的符号更重要 |
| 是公开符号 | +1.0 | 下划线开头的是内部实现 |
| 扇出(它调了几个本文件符号) | +0.5 × fan_out | 编排型函数是主干 |
| Python dunder / Go 首字母大写 | +2.0 / +1.0 | 语言约定的重要性 |
| 用户问题里点了它的名字 | +3.0 | 权重最大的单项 |
最后一条就是「用户问题改变保留谁」在代码压缩里的落点(_symbol_in_context,:2414),权重比其它所有信号都高。
第二步,把总预算按 score × body_size 加权分下去(_allocate_body_budget,:1072)。总预算 = 总行数 × target_compression_rate − 固定行数(导入、签名这些反正要留的)。分数有个 score_floor = 0.05 的地板,保证最不重要的函数也不至于 分到 0 行。
max_body_lines 始终是硬上限,预算再多也不能超(_get_body_limit,:2438)。
4.4 按语句截断,不按行截断
拿到 body_limit 之后,最容易写错的实现是「切前 N 行」。切在 if x and ( 中间,输出就废了。
真实实现走的是 AST 语句边界(:1796-1856):遍历 body_node.children,每个 named child 就是一条完整语句,整条整条地留,加不下就停:
# headroom/transforms/code_compressor.py:1851 —— 真实源码片段
if kept_line_count + stmt_line_count > body_limit and stmts_kept > 0:
break
注意 and stmts_kept > 0:第一条语句哪怕超预算也要留下,不然函数体是空的。
另外两处只有踩过才知道的坑:
- 行切片用行号,不用字节偏移。tree-sitter 的字节偏移不含首行的前导空白,直接切会丢掉嵌套方法的缩进(:1638-1642 的注释)。
- Go 的
statement_list包装节点要拆开(:1828-1836)。当成一条语句处理的话,它的行范围会吞掉 block 自己的闭花括号行,后面就多出一个}。
4.5 三道闸门:先自检,不过就回滚
压完之后是三道独立的检查,任何一道不过都返回原文。
压缩产物
│
▼
① _verify_syntax ── 不过 ──▶ (Python 专属) 逐节点重试
│ 过 └── 还不过 ──▶ 返回原文
▼
② ratio < 0.05 ? ── 是 ────▶ 返回原文(压过头 = 丢数据)
│ 否
▼
③ ratio < 0.8 ? ── 是 ────▶ 存 CCR + 追加召回记号
│
▼
交付
第 ① 道(_verify_syntax,:2084)对 Python 做双保险:ast.parse 加 compile(),再过一遍 tree-sitter 查 ERROR 和 MISSING 节点(_has_syntax_issues,:2486)。查 MISSING 而不只查 ERROR 是必要的——tree-sitter 会「脑补」出缺失 token 让树看起来完整。
Python 专属的重试(recover_invalid_python_nodes,:1249)很巧:只有当原文本身合法、压缩后不合法时才启动,这时给每个节点装一个 candidate_validator——把候选文本拼回整个模块,整体 ast.parse 过了才采用这个候选,否则该节点退回原文(:1355-1372)。逐节点回滚,而不是整文件放弃。
第 ② 道(:1275-1276)是「压过头等于丢数据」的经验闸:留下不到 5% 的 token,基本上意味着结构提取出了 bug。
第 ③ 道降级路径 _fallback_compress(:2103)有一个诚实得可贵的细节: 转交 Kompress 之后,结果里明确写 syntax_valid=False,并附注释「Kompress 不保证语法合法」。它没有假装自己仍然守着那条不变量。
Rust 端的移植(crates/headroom-core/src/transforms/code_compressor.rs)把最要命的约束写在模块头上:逐字节对齐要求两侧的 tree-sitter 语法版本产出节点级完全相同的 AST,所以 Cargo 里把 tree-sitter-python 之类精确 pin 到 =0.25.0,和录制 fixture 时的 PyPI wheel 版本一致(:11-20)。同一份注释也坦白了范围:非 ASCII 标识符下 Python 侧按字节切 str 本身就是隐性 bug,两边会分叉,所以非 ASCII 明确划在 parity 范围之外(:22-31)。
5. 半结构化六件套:各有各的折叠手法
这六个吃的都是「有行有列但不是 JSON」的文本。它们体量都不大,但每个都有一处非做不可的判断,下面按「折什么 / 守什么」两栏读。
5.1 search_compressor — 每文件保首末
grep/ripgrep 的输出是 file:line:content。折叠手法是按文件分组,每个文件保首尾各一条,剩下的名额按分数填(_select_matches,headroom/transforms/search_compressor.py:293)。首尾必留是因为它们标出了这个文件里匹配的范围。
打分很朴素(_score_matches,:247):查询词命中 +0.3、错误模式命中 +0.5 递减、配置关键词 +0.4,封顶 1.0。有意思的是它为 CJK 加了字符 bigram(_cjk_bigrams,:66)——中文查询没有空格,按词切分永远命中不了。
三个 bug 修复记在模块 docstring 里(:20-38),都是解析层的:Windows 盘符 C:\ 被当成行号分隔符导致整段丢失、文件名里的 - 被排除在路径字符集之外、CCR 存储失败被静默吞掉。「解析器把行认错了」是这类压缩器最典型的失败模式——不是压得不好,是压了别的东西。
5.2 log_compressor — 保守去重与栈帧折叠
日志的折叠手法有三层:按 level 打分选行(_score_line,headroom/transforms/log_compressor.py:323)、相似 warning 去重、栈追踪折帧。
去重必须保守。 老实现把整行的数字/路径/十六进制都归一化,结果两条类别不同、只是尾部形状相似的错误被折成了一条。新实现在第一个 : 或 = 处切开,只归一化尾部,前缀原样保留(_dedupe_similar,:419):
# headroom/transforms/log_compressor.py:428-433 —— 真实源码片段
split_at = next((i for i, c in enumerate(content) if c in (":", "=")), len(content))
prefix = content[:split_at]
suffix = content[split_at:]
suffix = digit_re.sub("N", suffix)
栈追踪不是尾部截断,是折叠中间。 超过 stack_trace_max_lines 时,保住消息头 + 前 trace_head_frames 帧 + 至多 trace_app_frames 帧应用代码,把运行时/标准库帧折成一行 [... N frames collapsed](配置项与注释见 :112-119)。因为报错原因在栈顶,你的代码在栈中段,盲截尾巴恰好切掉后者。
还有一处 Rust 端修的老 bug:旧的状态机遇到空行就认为栈结束,而 Python 的链式异常回溯里空行是内部结构,一截就丢中段(模块 docstring :22-26)。
选完行之后还会补上下文:每条选中行前后各 error_context_lines 行一并带上(_add_context,:445)——错误信息本身经常没有信息量,信息在它上面那行。
5.3 diff_compressor — 增删行本身绝不动
git diff 的折叠手法是按 hunk 选、收缩上下文行,而 always_keep_additions / always_keep_deletions 默认 True(headroom/transforms/diff_compressor.py:37-38)。这就是它的不变量:+/- 行是 diff 的全部信息量,只有周围的上下文行可以砍。
这个类同样已经全量下沉到 Rust,Python 只剩 dataclass 和一层壳(:1-19)。壳里最要紧的一个方法是 _persist_to_python_ccr(:129),它修的是一个很典型的双 store 问题:Rust 侧发出的记号带的是 MD5(original)[:24],而 Python store 自 PR #395 起默认用 SHA-256(original)[:24],不显式传 explicit_hash 的话,每一个 diff 记号在生产里都是悬空的(#816)。
它还额外暴露一个 compress_with_stats(:154), 返回 Rust-only 的统计结构(每文件掉了几个 hunk、修剪了几行上下文)。Python 侧没有对应类型,所以老老实实标成 Any,不硬造一个假的镜像。
5.4 config_compressor — 三层,越往后越需要召回
YAML/TOML/INI 没有原生压缩器,magika 会把它们标成 SOURCE_CODE 然后落到有损散文路径去——那是不可接受的。这个模块补上三层,按「需不需要召回」排序(headroom/transforms/config_compressor.py:9-23):
| 层 | 干什么 | 可逆性 |
|---|---|---|
| Tier 1 | 相同行 / 重复多行段落折到精确逆变换的记号后面,并自校验往返 | 完全可逆,无 CCR 模式也能用 |
| Tier 2 | 整行注释和空行省略,留 Retrieve original: hash=… | 靠 CCR |
| Tier 3 | TOML 的 [[array-of-tables]] 用 tomllib 解析,桥到 csv-schema | 靠 CCR |
守的那条不变量是:引号里的 # 是数据,不是注释。_elision_safe(:238)在 YAML 有块标量(|/>)或 TOML 有多行字符串时直接返回 False,把整个 Tier 2 关掉。注释写得很明白:检测故意过宽,拿不准就不省(:53-55)。
Tier 3 只桥 TOML,不桥 YAML/INI,理由同样是不变量:tomllib 是标准库参考解析器,解出来的记录是 ground truth;YAML/INI 需要非标准库或自制解析,一旦解错就会「说原文没说的话」(:17-23)。而且 Tier 3 必须先存住原文才敢发折叠结果——_store_original 返回 None 就整个放弃(:228-230)。
5.5 html_extractor — 这是抽取,不是压缩
模块 docstring 第一段就澄清了定位:这是在删结构噪声(script/style/nav/ads/footer),不是在删 token(headroom/transforms/html_extractor.py:1-7)。典型减少 70-90%,而正文零损失。
实现是薄薄一层 trafilatura 封装(extract,:109),加上元数据抽取(标题、作者、日期)。抽不出来时返回空串并 debug 日志一句(:144-146),不做任何猜测。
is_html_content(:199)是个纯启发式:<!doctype html / <html 开头直接判 True,否则数前 2000 字符里出现了几个 HTML 标签指示词,≥2 才算。
5.6 tabular_ingest — 参差表一律不碰
CSV/TSV、markdown 表、定宽表都没有原生压缩器。这个模块只做 text → records 的桥,压缩本身交回 SmartCrusher(:1-11)。
它的不变量是全家桶里最锋利的一条:行的单元格数必须等于表头数。
# headroom/transforms/tabular_ingest.py:141-143 —— 真实源码片段
width = len(headers)
if any(len(row) != width for row in rows):
return None
注释解释得毫不含糊(:137-140):参差行 zip 成 records 会把值挪到错误的列下面,而压缩后的表绝不能陈述原表没有陈述过的事实(#1652)。所以判为「非表格」,原样放行。
采纳与否还有一道门:SmartCrusher 压的是 JSON 形式,得拿它的输出和原始表格文本比字节,省不到就不采纳(:194-195)——本来就紧凑的 CSV 未必赢得过它自己。
5.7 text_crusher — 抽取式,不改写
TextCrusher 是给大段散文准备的请求路径安全的替代品:毫秒级,而 ModernBERT 是分钟级(headroom/transforms/text_crusher.py:1-12)。
它的不变量写在类 docstring 里:输出是输入原句的逐字保留(各自 trim、用换行重连),按原顺序;它只做选择,不做改写(:36-39)。
算法在 Rust 侧:按句切段,用 recency + 查询相关 + 结构显著性三项加权打分,再用全局 word-shingle 索引压近重复,按 target_ratio 取头部(crates/headroom-core/src/transforms/text_crusher/crusher.rs:1-12)。相关性那一项复用的是 SmartCrusher 用的同一个 BM25 打分器,没有重新实现一份——这正是 §7 要讲的共享层。
CJK 走单独的 ICU(UAX#29 + 词典)分句分词路径,纯 ASCII 输入逐字节保持原行为(同上 :9-12)。