跳到主要内容

一份‘反证 Collatz 猜想’的 Lean 代码,为什么同时通过了两个 checker

· 阅读需 17 分钟

2026 年 7 月 28 日,Lean Zulip 的 lean4 stream 出现了一个很难忽略的 topic:Counterexample to the Lean Conjecture (Soundness Bug)

帖子里的三个事实放在一起,几乎像一道故意设计的判断题:

  1. 一个不到 500 行的 Lean 仓库声称“反证”了 Collatz 猜想。
  2. 它没有 sorry#print axioms 没暴露隐藏公理,当时的 comparator.live 和旧版 nanoda 都接受了它。
  3. 它没有解决 Collatz。它真正构造出来的是一个利用 kernel soundness bug 的无公理 False

这不是“形式化验证忽然失效”的故事,也不是一个模型真的完成了世纪数学突破。它更具体,也更值得研究:一份 proof artifact 把 Lean 官方 kernel 的漏检旧版 nanoda 的另一处漏检 拼在一起,恰好穿过了两套原本彼此独立的检查。

一句话结论

这份 Collatz“反证”同时通过两个 checker,并不是两个 checker 共享同一个 bug。Lean 在 nested-inductive 翻译时漏查了一个参数;旧版 nanoda 又错误接受了一个 non-structure projection。artifact 把一个表达式放进“Lean 完全没检查、旧 nanoda 检查错了”的交集里。

系列说明

这是三篇系列的主文,完整讲事件、技术机制、社区讨论与修复。两篇工具补充分别解释 Comparator 到底在检查什么nanoda 为什么能作为另一个 Lean kernel。不打开补充文章,也不影响理解本文。

先说清楚:它反证的不是 Collatz

原始仓库是 xrchz/CollatzLean。Ramana Kumar 在 7 月 25 日提交了 a793574,commit 标题是 Finish disproof

从公开 theorem surface 看,它像一个正常的数学结论:

theorem exists_nonterminating_orbit : ∃ n, Diverges n :=
limitingProfile_is_exceptional

theorem not_conjecture : ¬ Conjecture :=
exceptionalOrbit_refutes_conjecture exists_nonterminating_orbit

如果这些 theorem 真由 Lean 的逻辑规则推出,那么 Collatz conjecture 的确被反驳了。问题在于,仓库里的 proof term 不是通过正常数学构造得到的。Collatz/ProfileEnvelope.leanCollatz/Limit.lean 使用 Lean metaprogramming API 直接拼 Expr、投影和 Declaration,再通过 addDecl 把声明交给 kernel。

这种写法本身并不等于作弊。Lean 的设计允许 tactic 和 metaprogram 任意复杂,甚至完全不可信;安全边界在于它们最终生成的 term 必须由 kernel 检查。真正的问题是:这次 kernel 恰好漏看了一部分 term。

所以 Zulip topic 标题里的 “Lean Conjecture” 是一个黑色幽默式的双关。被给出 counterexample 的不是数学上的 Collatz conjecture,而是“Lean 的 checked kernel 不会接受 False”这条人们平时依赖的 soundness 假设。

三天里发生了什么

时间(UTC)事件结果
7 月 25 日CollatzLean@a793574 提交 Finish disproofCollatz 外衣下的 exploit artifact 公开
7 月 26–27 日Lean Kernel Arena 合并 #81、#82、#83;nanoda_lib#22 修复nanoda 的 projection 与 inductive metadata 检查被加固
7 月 28 日 03:28Kiran 提交 lean4#14576把问题缩成普通 checked addDecl 可接受的无公理 False
7 月 28 日 05:08–13:39Leonardo de Moura 提交并合并 lean4#14577Lean 补上 nested-inductive 参数检查
7 月 28 日 07:16Jack McCarthy 发起 Zulip 主帖39 名参与者留下 202 条消息
7 月 29 日comparator.live 改用 Comparator + nanoda旧 Lean 上由 nanoda 拒绝;新 Lean 自己先拒绝
7 月 30 日lean-eval#486 与 submissions #895 合并LeanEval 强制 standard kernel 与 nanoda 同时接受

这里有个容易误读的时间关系:CollatzLean commit 早于公开的 Kernel Arena nanoda 报告和修复。

Ramana Kumar 后来确认有一个或多个 LLM 参与了仓库生成,但他说 nanoda 报告的时间重合很可能只是巧合,同时也保留了“不知道模型是否已见过相关材料”的不确定性。

现有证据不足以把这件事写成“AI 读取新漏洞报告后主动攻击 Lean”。能够确认的只有 LLM involvement,以及最终 artifact 确实组合了两处独立漏洞。

Lean kernel 漏看了什么

Kiran 的 issue 给出了一份自包含 reproducer。它特意说明,代码走的是普通、应当受检查的 addDecl 路径,没有使用:

  • sorry
  • unsafeCast
  • debug.skipKernelTC
  • addDeclWithoutChecking
  • FFI
  • 手工篡改 .olean

这点很重要。如果 reproducer 只是显式关闭 kernel checking,那不构成 kernel soundness bug。这里的声明确实提交给了 checked path,只是检查不完整。

那个本来不该合法的 projection

reproducer 先定义几种简单类型:

inductive P : Prop where | mk (b : Bool)
structure C where b : Bool
inductive W : Type where | mk (p : P)
inductive L (α : Type) (b : Bool) : Type where | mk
inductive T : Bool → Prop where | mk : T true

随后 metaprogram 构造一个 nested inductive E。它的 constructor type 中含有大致这样的 nested occurrence:

L (E w) (C.0 (C.0 w))

但局部变量 w 的类型是 W,不是 structure C。对 w 连续做 C 的 field projection 明显不应通过 type checking。

Lean 处理 nested inductive 时,并不是原样保留所有 nested occurrence。它会把 I Ds is 一类应用翻译成辅助 inductive 声明。bug 就出在这次翻译里:参数 Ds 从生成的辅助类型中被丢掉了,但 kernel 没有在完整 inductive environment 可用后单独检查这些参数。

lean4#14577 的修复说明很直接:nested occurrence 的 parametric arguments 过去会逃过 type checking;修复后,kernel 在正在声明的 inductive types 可用时逐一检查它们。

这才是根本 bug。后面那些 hash collision、cache 和复杂 let,都是把“一个表达式没人检查”放大成 False 的工程。

从漏查一个参数,到证明 False

光把一个 ill-typed subexpression 塞进 environment 还不够。要生成最终的 boom : False,artifact 还需要让 Lean 在不同上下文里对相关表达式产生矛盾的类型判断。

代码先构造两个语义不同的 Boolean expression:

let exceptionalParity := stageParity (mkConst ``Bool.false) 78670
let descendingParity := stageParity (mkConst ``Bool.true) 24083
unless exceptionalParity.hash == descendingParity.hash &&
exceptionalParity.approxDepth == descendingParity.approxDepth do
throwError "finite-stage parity summaries are not aligned"

一个最终归约到 false,另一个归约到 true。数字 7867024083 不是数学常数,而是为 expression hash collision 挑出来的 padding。代码还要求两者的 approxDepth 相同。

后续大致做了六件事:

  1. 用这两个碰撞表达式构造 dependent values。
  2. 把不合法 projection 放进 nested-inductive 参数,也就是 Lean 漏检的位置。
  3. 安排多个局部 let,让 type inference 先沿“正常”路径缓存一个结果。
  4. 用相同 hash 和 approxDepth 避开某些快速区分路径。
  5. 在另一条路径上命中不该复用的 infer cache 结果。
  6. 最终得到一个被视为 T false 的值,而 T 唯一的 constructor 只能构造 T true;对这个不可能值做 elimination,得到 False

Joachim Breitner 在消息 613671451 里强调了这段工程的复杂度。

作者不仅找到一个没被 type-check 的位置,还用了 carefully crafted expressions、hash collision 和 infer cache 布局,把它做成了 proof of false。

需要再强调一次:hash collision 不是 Lean 的 soundness bug。hash 本来就只该用于加速,正确实现必须在碰撞时回退到结构或 definitional equality 检查。真正打开缺口的是 nested-inductive 参数漏检;collision 和 cache choreography 负责把缺口武器化。

为什么代码看起来像 Collatz 数学

原始仓库没有使用 EWT 这种方便写 issue 的名字。它把构造放进一套 Collatz 语义外衣:

  • OrbitToken
  • OrbitCarrier
  • NormalizedStep
  • ProfileEnvelope
  • synchronizedDescentProfile
  • limitingDescentProfile

比如它会检查“finite-stage parity summaries are aligned”,再构造“stable dependent envelope”。只看 theorem 名和注释,读者容易把它理解成某种有限阶段收敛或极限论证。

但关键代码并没有藏到看不见的地方:仓库直接调用 mkProjmkAppExpr.hashapproxDepthaddDecl。Kiran 在讨论中表示,仓库总量不到 500 行、meta code 很醒目,而且它声称解决的是 Collatz,任何有经验的 verification researcher 都会把它当成需要深挖的异常样本。他把这种呈现描述为“cheeky”,但不认为这是一次试图秘密夹带 bug 的恶意提交。

社区对这种披露风格没有完全一致的评价。有人觉得数学命名增加了不必要的混淆,也有人认为它恰好展示了真实对抗性 proof artifact 会是什么样。无论站在哪边,Kiran 的最小 issue 都完成了一件关键工作:把“Collatz 叙事”与“Lean kernel bug”分开,让修复者可以直接面对一个短小的 False reproducer。

第一个工具:Comparator 为什么会通过

Comparator 的主要任务,不是证明 Lean kernel 本身没有 bug。它防的是另一组常见攻击:

  • Challenge 和 Solution 实际证明的 statement 不一致。
  • untrusted imports 重新定义了 statement 依赖的 constant。
  • 宏、notation 或 typeclass 让同一段表面文字表达不同含义。
  • proof 偷用了 sorryAx 或未列入 allowlist 的 axiom。
  • build-time meta code 修改文件、环境或 trusted artifacts。

Comparator 会分别 sandbox-build/export Challenge 与 Solution,比较目标 theorem 的 statement 和传递依赖 constant graph,检查 reachable axioms,最后把 exported environment replay 到 kernel。

这份 exploit 恰好满足了 Comparator 当时能验证的条件:

  • statement 没有漂移;
  • 没用禁止 axiom;
  • sandbox 没被逃逸;
  • exported proof 被当时有 bug 的 standard Lean kernel 接受。

因此 comparator.live 当时显示 Success 并不神秘。它把 proof 交给了与漏洞处在同一信任边界内的 kernel,没有另一个实现来否决结果。

7 月 29 日,Rob Simmons 在消息 613492055 记录:Jack 最初贴出的链接确实曾返回成功;随后 comparator.live 改为文档所称的 gold standard,也就是 Comparator 配至少一个 external checker,这里使用 nanoda。

改完后,Lean 4.32.0 的 proof 被 nanoda 拒绝;到修复后的 Lean 4.32.2,standard kernel 自己就先拒绝了它。

事件证明的是“Comparator 需要独立 kernel 才能覆盖 kernel implementation bug”,不是“Comparator 没有用”。没有它,statement drift、hidden axiom 和 build-time manipulation 仍然没有被解决。

第二个工具:为什么旧 nanoda 也通过了

这里最容易写错成一句笼统的“nanoda 也有相同漏洞”。事实恰好相反。

nanoda 是 Rust 编写的外部 Lean type checker。对 Kiran 的最小 Lean reproducer,修复版 nanoda 会检查 constructor 的完整原始类型,因此能够看到 Lean nested-inductive translation 丢掉的那个畸形参数,并独立拒绝它。

原始 Collatz artifact 之所以能通过旧 nanoda,是因为它额外触发了 nanoda 自己的一处 projection bug。

Lean Kernel Arena PR #81 把问题压缩成了这个示意:

inductive Bad : Prop
| mk1 : False → Bad
| mk2 : True → Bad

theorem bad : False := (Bad.mk2 True.intro).1

Bad 有两个 constructor,不是 structure。.1 却把 Bad.mk2 True.intro 当成可以从第一个 constructor 投影字段的 structure value。旧 nanoda 没有严格验证“被投影的 inductive 确实是合法 structure”,于是按错误的 constructor metadata 推出了 False

Collatz artifact 里的 OrbitCarrierNormalizedStep 正好构造出这类 non-structure projection。结果变成:

Joachim 在消息 613170568 里的概括最准确:

a proof that passes two independent checkers with two independent bugs

这也是为什么版本信息不能省略:

  • 修复版 nanoda 拒绝 Lean-only 最小 reproducer。
  • 旧版 nanoda 接受包含额外 non-structure projection 的原始 Collatz artifact。
  • ammkrn/nanoda_lib#22 在 7 月 27 日加强了 structure、constructor、inductive 与 recursor auxiliary-data 检查。

一句“nanoda 能过/不能过”没有意义,必须同时写明 artifact、checker commit 和 export format 版本。

社区真正争论的,不只是一个 C++ 漏检

nested inductive 缺少完整、独立的规格

Chris Bailey 把 nested inductive 称作 kernel 最复杂的区域之一,并指出其理论与实现细节没有一份脱离 C++ codebase 的完整严格说明。Jeremy Chen 也提醒,Lean4Lean 当时并没有完整的 inductive specification,相关实现基本沿用了 official kernel,因此同样会带上这处 bug。

这揭示了“另写一个 checker”的边界。如果第二个实现照抄第一个实现的复杂分支,它们在代码仓库上独立,却未必在错误模式上独立。implementation diversity 有价值,但真正强的独立性还需要独立规格、独立实现策略和持续的 adversarial test suite。

LLM 参与,不等于已证明 AI 自主攻击

直接证据只有两点:Ramana 确认 LLM 参与;artifact 展现了相当高的 exploit engineering 水平。

无法确认的部分更多:哪个模型写了哪段、模型是否看过 nanoda 报告、人是否提示它寻找 verifier bug、它是否“理解”自己在做 soundness exploit。把这些空白补成一个完整的 AI 攻击故事,会比原始 artifact 更像虚构。

但 Joseph Myers 提出的风险不依赖这次 provenance 是否能完全还原:当 agent 有能力合法解决数学题,也有能力寻找 verifier bug 时,重大结果不能只问“CI 绿了吗”。审查还要判断 proof architecture 是否对应预期数学论证,尤其要检查 custom metaprogramming、直接 Expr 构造和声明注入。

proof digestion 没有被形式化证明淘汰

形式化 proof 的强项,是把庞大推理压缩到一个小 trusted checker 可以机械核验的对象。但“checker 接受”从来不是一句脱离前提的绝对结论。它依赖:

  • formal statement 与人类想表达的问题一致;
  • imports 和 definitions 是可信的;
  • proof 没使用未允许 axioms;
  • build 与 export 没被攻击;
  • kernel logic 和具体实现没有被 exploit;
  • 运行 verifier 的系统没有撒谎。

Comparator、external checker 和 proof digestion 分别覆盖不同层。把其中任意一层叫作“最终答案”,都会掩盖剩下的信任假设。

修复后的验证链是什么

Lean 自身在 #14577 补上了漏掉的 type check。工具层也同时改变。

LeanEval PR #486 不再让单个 problem 自己决定是否启用 nanoda。WorkspaceTest.lean 会读取 committed config 后强制覆盖:

let config := config.setObjVal! "enable_nanoda" (Json.bool true)

如果 nanoda_bin 不存在,evaluation 失败,而不是静默回退到单 kernel。当前流程可以画成:

这是比“再跑一次 comparator”强得多的变化,因为第二次 replay 使用不同语言、不同代码和不同实现结构。

但它仍不是数学意义上的绝对保证。Lean 官方文档列出的剩余假设包括:Lean logic sound、Comparator plumbing 正确、sandbox 安全、所有 checker 不会同时命中 implementation bug、trusted Challenge 没有人为错误或误导。此次事件甚至展示了“不同 checker 的不同 bug 可以被同一个 artifact 组合利用”这种低概率但真实的失败模式。

这件事留下的五条实用规则

1. 把“statement integrity”和“kernel soundness”分开

Comparator 很擅长确认你证明的是 trusted Challenge,而不是一个被换过定义的相似句子。external kernel 擅长减少单个 kernel implementation bug。两件事不能互相替代。

2. 记录精确版本,而不是只写工具名

验证报告至少要带:Lean toolchain、Comparator、landrun、lean4export、external checker 的 commit;原始 artifact 与 minimized MWE 也应同时保留。否则下一位复现者只会得到互相矛盾的“我这里能过”和“我这里不能过”。

3. 对 major AI proof 审计 meta code

自定义 tactic 很正常,不能看到 meta 就判定恶意。但直接构造 Expr、调用 addDecl、手写 projection、比较 hash/depth,应该触发更高等级的人工检查。尤其当这些代码与数学叙事之间的关系说不清时。

4. external checker 必须 fail closed

配置里写了 enable_nanoda 不够。CI 必须证明 binary 确实存在、确实执行,缺失时整个 validation 失败。LeanEval #486 专门加入了这条行为。

5. 多 checker 是风险降低,不是神谕

两个实现通常比一个好,特别是在 host language、term representation 和检查策略不同的时候。但 checker 数量不是 soundness 的形式证明。更长期的工作包括独立规格、kernel verification、adversarial regression corpus,以及对复杂 inductive machinery 的更清晰理论描述。

最后

这份代码没有反证 Collatz。它反证了一种更随意的验证习惯:只要 lake build 成功、#print axioms 干净、某个网页显示绿色,就把整条信任链压成一句“Lean verified”。

更准确的说法应该带上主语和前提:某个明确版本的 checker,在某个明确配置和 trusted statement 下,接受了这份 artifact。

Lean 社区对事件的响应其实相当快:最小化、修 kernel、补 external checker、把 nanoda 变成 LeanEval 的强制门槛,前后不到三天。真正需要记住的不是“Lean 也会有 bug”这句常识,而是修复所体现的分层思路:statement、axiom、sandbox、kernel replay、implementation diversity、human digestion,各自解决不同问题。

工具补充

主要来源