深入 Lean 11:在 Mathlib 上证一个小定理
向 Mathlib 贡献一条引理,从选题到合并大约需要经历六个阶段:确认缺口、搜索已有引理、写证明、通过本地 lint、跑通 CI、完成 PR 审核。本文以一个具体的 List 辅助引理为例,完整走一遍这条路径。
选题:找一个真实的缺口
Mathlib 的条目超过十五万条,盲目选题很容易踩在别人已经证过的结论上。有效的选题通常来自两个方向:一是在自己的项目里遇到 exact? 无法命中的目标;二是在 Zulip 的 #mathlib4 频道里找标了 easy 标签的 issue。
本文选用的定理是 List.length_zipWith,即:
1 | |
在实际的 Mathlib 里,这条结论已经存在(List.length_zipWith),但选它作例子的原因是它足够小、结构清晰,可以完整演示所有步骤,而不会把证明本身的复杂度淹没流程细节。实际投稿时,需要找 Mathlib 确实没有的定理。
搜索已有引理:避免重复劳动
提交一条 Mathlib 已有的引理是 PR 被关闭最常见的原因。正式动笔前需要过以下三关。
用 exact? 和 apply? 探查
在本地的 .lean 文件里写下目标类型,然后用 exact?:
1 | |
InfoView 面板会列出所有类型能匹配的候选引理。apply? 的作用类似,但它允许目标尚未完全具化,适合在证明中途探路。两个 tactic 都依赖 Lean Language Server 实时索引 Mathlib,第一次运行可能需要等待几秒。
用 Loogle 做全文搜索
Loogle 是 Mathlib 的在线引理搜索引擎,支持三种查询模式:
- 按名称子串搜索:输入
zipWith length,返回所有名字同时含这两个词的引理。 - 按类型签名搜索:输入
List.zipWith _ _ _ |>.length,返回结论或前提含该模式的引理。 - 按参数类型搜索:输入
List α → List β → ℕ,返回所有接受两个 List 并返回自然数的引理。
三种模式可以组合。对 zipWith 系列查一遍之后,如果 Loogle 返回空或返回的条目类型与目标不匹配,说明这条引理确实缺失,可以继续。
在 Mathlib4Docs 上确认
Loogle 依赖索引可能有延迟。最终确认要到 Mathlib4Docs 用全文搜索直接查引理名。命名规范见下一节,知道命名规律后能大幅提高搜索精度。
命名约定与风格指南
Mathlib 的命名规则比多数库更严格,审核者会直接拒绝不符合规范的命名。
命名层级结构
引理名由命名空间 + 谓词 + 参数特征拼成,各部分之间用下划线分隔。例如:
1 | |
常见谓词缩写:comm(交换律)、assoc(结合律)、left/right(左/右版本)、zero/one/succ(特化到零/一/后继)、nil/cons(List 特化)。
@[simp] 标注的判断标准
Mathlib 对 @[simp] 有明确的准入条件,不是所有等式引理都适合标注:
- 结论必须是等式或 iff,且右侧比左侧"更简单"(结构更小或范式更统一)。
- 不引入新的、作用域比左侧更大的变量。
- 不与已有
simp集合产生循环(a = b和b = a同时是@[simp]就会死循环)。
List.length_zipWith 是一个典型的好 @[simp] 候选:左侧含复合表达式 (zipWith ...).length,右侧是 min,右侧更接近范式,且不引入新变量。
@[ext]、@[norm_cast] 的作用域
@[ext] 加在"外延相等"引理上,指导 ext tactic 自动展开。@[norm_cast] 加在 coercion 相关等式上,指导 norm_cast 和 push_cast 规范化强制转换。两者都有专门的 lint 检查,误加会触发 warning。
完整的证明文件
实际要提交的 .lean 文件结构如下:
1 | |
theorem 和 def 都可以用结构递归写法(多个方程子句),这是 term mode 的做法;也可以在 tactic mode 里用 induction 展开。上面用了 term/tactic 混合写法:外层方程子句是 term mode 的模式匹配,每个分支的 by 引入 tactic 块。
纯 tactic 版本同样合法:
1 | |
两种写法对 Mathlib 都可以接受,审核者更常见的偏好是:如果模式匹配对应结构明显,用 term mode 的方程子句;如果证明步骤需要大量 rewriting,用 tactic mode。
InfoView 辅助定位
在 VS Code 里把光标放在 simp only [zipWith, length_cons] 行后,InfoView 面板显示的是该行执行后的剩余目标:
1 | |
从这个目标可以直接读出 rw [ih t₂] 能将左侧的 (zipWith f t₁ t₂).length 替换为 min t₁.length t₂.length,再用 simp [Nat.succ_min_succ] 收束。InfoView 是调试 tactic 序列最高效的工具,比反复运行整个文件快得多。
本地 Lint
在提交 PR 之前,本地跑 #lint 是必须的。Mathlib 的 lint 工具链远比标准 Lean lint 更细。
基础 #lint 命令
在文件末尾加入:
1 | |
Lean 会对当前文件跑完整的 Mathlib linter 套件,包括:
dupNamespace:检查引理名和命名空间是否重复(如List.List.foo)。simpNF:检查@[simp]引理的左侧是否已经是simp范式(如果左侧已能被已有 simp 规则化简,说明这条引理永远不会被触发)。unusedArguments:检查前提里是否有从不使用的变量。docBlame:检查theorem/def是否都有文档注释/-- ... -/。
simpNF 是最容易触发的。例如,如果写了 @[simp] theorem foo : f (g x) = h x,但 f ∘ g 已有一条 simp 规则会把 f (g x) 化简成其他形式,那 simpNF 就会报错,因为 foo 的 lhs 永远不会以"待化简"的形式出现在目标里。
类型类相关 lint
@[ext] 引理需要满足:引理的形式必须是 ∀ x y, (∀ ..., x.field = y.field) → x = y,否则 ext tactic 无法识别。lint 工具会检查 ext 引理的格式。
风格检查
除了 #lint,Mathlib 还要求:
- 文档注释用
/-- ... -/,不用--。 - 行宽不超过 100 字符。
- 前导空格只用空格,不用 Tab。
import按字母序排列(实际上 CI 会自动检查并修正)。
在本地可以用 lake exe lint-style 命令检查风格问题,它会输出具体行号和建议。
CI 流水线
Mathlib 的 CI 跑在 GitHub Actions 上,每次 push 触发。了解 CI 的构成能大幅降低等待时间。
CI 的主要阶段
-
构建 Mathlib:Lean 会从 cache 中拉取已有的
.olean文件(Lean 的编译产物),只重新编译改动文件及其依赖。Mathlib 有专属的 cache server(leanprover-community/mathlib4的 CI cache),通常不需要从头编译整个库。 -
lint 检查:对改动文件跑完整
#lint套件,任何一条 linter 报错都会阻断合并。 -
文档生成:检查文档注释能正常渲染为 Mathlib4Docs 格式。
-
lake build:确认整个 Mathlib 在加入新文件后还能完整构建。
常见 CI 失败原因
simpNF lint 报错是最高频的。修复方式通常是:要么去掉 @[simp],要么调整引理的方向(把左右侧对调),要么把前提中某个会被 simp 折叠的表达式换成更底层的形式。
timeout 是第二常见的问题,通常出现在 decide 或 omega 被用在大数计算上。这种情况下需要换成手动证明或更高效的 tactic。
sorry 造成的构建失败会被 CI 里的专用检查拦截,Mathlib 会拒绝任何含 sorry 的 PR,包括通过宏间接引入的 sorry(如旧版的 native_decide 在某些情况下会静默展开成 sorry)。
在本地模拟 CI
在本地跑:
1 | |
等到本地 lake build 无报错、#lint 无 error 之后再推送,能大幅减少不必要的 CI 轮次。
PR 流程与审核
开 PR 的前置准备
Mathlib 要求:
- Fork
leanprover-community/mathlib4,在 fork 上建新分支(命名格式通常是feat/list-zipwith-length)。 - PR 标题格式:
feat(Data.List.ZipWith): add length_zipWith,命名空间放在括号里。 - 每条 PR 最好只改一个文件或一组强相关文件,改动范围越小,审核越快。
PR 的 description 写法
Mathlib 有 PR 模板,需要填写:
- 改动内容简述(一句话)。
- 为什么这条引理有用(应用场景或引用某个 issue)。
- 如果改动了已有文件,说明是否破坏了向下兼容。
审核流程
Mathlib 用 bors-ng(一个 merge bot)管理合并队列。审核者会在 PR 上留评论,常见的反馈模式:
- 要求改命名:最常见,审核者会直接给出他们认为正确的名字。
- 要求加
@[simp]或去掉@[simp]:审核者会解释理由,通常是基于simp集合的整体一致性。 - 要求补充文档注释或例子:如果引理的适用场景不显然,会要求加
/-- ... -/注释和example演示。 - 要求把证明改得更短:Mathlib 倾向于用
simp一步完成的证明,而不是多步手动 rewriting。
审核通过后,审核者会用 bors r+ 触发 merge bot,bot 会把 PR 加入合并队列,在 CI 全部通过后自动合并到 main。整个过程通常需要几小时到一两天。
修改与回应
审核意见出现后,直接在同一分支上追加 commit,不需要重开 PR。Mathlib 不要求在提交前 rebase,但合并前 bot 会自动处理 merge conflict。如果审核者要求的改动较大(比如完全重构证明结构),可以先在本地做好再推送,避免碎片化的 commit 堆积。
交叉参考
以下文章与本文内容有较强关联,建议配合阅读:
- 深入 Lean 09:Mathlib 搜索技巧:
exact?、apply?、Loogle 的详细用法和搜索策略。 - 深入 Lean 05:simp 与 norm_num:
simp集合管理、@[simp]优先级与调试方法。 - 深入 Lean 04:核心 tactic 精讲:
induction、cases、rw、simp only的基础用法。
练习
练习 1:证明 List.length_zip
标准库里 List.zip 是 zipWith 的特化(zip = zipWith Prod.mk)。请不借助 length_zipWith 定理,直接对 List.zip 做结构归纳,证明:
1 | |
证明完成后,用 exact? 确认 Mathlib 里是否已有这条引理,并对比名字是否一致。
练习 2:搜索 Nat.add_left_cancel 的等价引理
用三种方式各搜一遍:exact?(在 tactic 块内)、Loogle(Nat add cancel)、Mathlib4Docs(全文搜索 add_left_cancel)。记录三种方式各找到哪些候选,以及它们的类型签名是否完全相同。
练习 3:为一条自定义引理跑 #lint 并修复报错
写下面这条引理(故意不加文档注释,且名字不符合规范):
1 | |
在文件末尾加 #lint,观察 docBlame(缺少文档注释)和 simpNF(Nat.min 与 min 在当前环境下是否为 simp 范式)两个 linter 的输出,然后按提示修复,直到 #lint 零报错。
参考资料
- Mathlib4 贡献指南
- Mathlib 命名约定
- Loogle 在线搜索
- Mathlib4Docs
- bors-ng 文档
- Lean 社区 Zulip(
#mathlib4和#new members频道)
