Add textbook formalization pipeline (extract -> LLM formalize -> elaboration check -> library assembly)
- extract_section.py: slice a book section out of a pdftotext -layout dump and list its labeled Theorem/Proposition/Lemma/Corollary/Definition items. - formalize.py: LLM-formalize a section into Lean 4 candidate statements (preferring Mathlib vocabulary) plus a definitions manifest. - check_statements.py: elaboration-only sanity check against a real Mathlib-backed Lean project (must run outside the Cowork sandbox, which has no Lean toolchain and can't run macOS binaries anyway). - build_library.py: assemble solved lean-bench results into a reusable .lean file, reusing each winning proof verbatim. - llm.py: standalone OpenAI-compatible chat client + .env loader, kept independent of lean_agent so this pipeline works without it installed. - Source PDFs and raw/layout text dumps are gitignored (copyrighted material); only code and the final short Lean statement datasets are tracked.
This commit is contained in:
@@ -3,3 +3,9 @@ __pycache__/
|
||||
.venv/
|
||||
.env
|
||||
results/
|
||||
|
||||
# Source textbook and raw/derived full-text dumps: copyrighted material, never commit.
|
||||
# Only code and the final short Lean statement datasets under datasets/ are tracked.
|
||||
*.pdf
|
||||
textbook/*.txt
|
||||
textbook/*_raw.txt
|
||||
|
||||
@@ -26,3 +26,9 @@ python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
mock 返回固定证明,仍由真实 Lean 检查。真实 LLM 需配置 endpoint/model 或适配器。
|
||||
|
||||
## 教科书形式化流水线
|
||||
|
||||
`textbook/` 下有一套从教材 PDF 生成 lean-bench 数据集的工具(LLM 辅助抽取
|
||||
Theorem/Proposition/Lemma/Corollary,配 Definition 清单,事后把证出来的结果
|
||||
组装成可复用的 Lean 库文件),用法见 [textbook/README.md](textbook/README.md)。
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from textbook.build_library import build
|
||||
|
||||
|
||||
def write_jsonl(path, records):
|
||||
path.write_text('\n'.join(json.dumps(r) for r in records) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
class BuildLibraryTests(unittest.TestCase):
|
||||
def test_build_includes_only_solved_theorems_and_hoists_shared_imports(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
dataset = directory / 'dataset.jsonl'
|
||||
results = directory / 'results.jsonl'
|
||||
write_jsonl(dataset, [
|
||||
{"id": "solved-one", "statement": "True", "prelude": "import Mathlib",
|
||||
"source_label": "Theorem 1", "natural_language": "trivial"},
|
||||
{"id": "unsolved-one", "statement": "False", "prelude": "import Mathlib",
|
||||
"source_label": "Theorem 2", "natural_language": "not actually true"},
|
||||
])
|
||||
write_jsonl(results, [
|
||||
{"id": "solved-one", "success": True,
|
||||
"attempts": [{"proof": "trivial", "success": True, "diagnostics": ""}]},
|
||||
{"id": "unsolved-one", "success": False, "attempts": []},
|
||||
])
|
||||
|
||||
text, solved, skipped = build(dataset, results)
|
||||
|
||||
self.assertEqual(solved, ['solved-one'])
|
||||
self.assertEqual(skipped, ['unsolved-one'])
|
||||
self.assertIn('import Mathlib', text)
|
||||
self.assertEqual(text.count('import Mathlib'), 1) # hoisted once, not per-theorem
|
||||
self.assertIn('theorem solved_one : True := by', text)
|
||||
self.assertNotIn('unsolved_one', text)
|
||||
|
||||
def test_build_keeps_non_import_prelude_attached_per_theorem(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
dataset = directory / 'dataset.jsonl'
|
||||
results = directory / 'results.jsonl'
|
||||
write_jsonl(dataset, [
|
||||
{"id": "custom-def", "statement": "myPred 1", "prelude": "def myPred (n : Nat) := True",
|
||||
"source_label": "Theorem 3", "natural_language": ""},
|
||||
])
|
||||
write_jsonl(results, [
|
||||
{"id": "custom-def", "success": True,
|
||||
"attempts": [{"proof": "trivial", "success": True, "diagnostics": ""}]},
|
||||
])
|
||||
|
||||
text, solved, skipped = build(dataset, results)
|
||||
|
||||
self.assertIn('def myPred (n : Nat) := True', text)
|
||||
self.assertEqual(solved, ['custom-def'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
import unittest
|
||||
from textbook.extract_section import extract_section, labeled_statements
|
||||
|
||||
FIXTURE = """\
|
||||
1.6. Divisibility in the Integers
|
||||
intro text
|
||||
|
||||
Proposition 1.6.1.
|
||||
body
|
||||
|
||||
Theorem 1.6.6. There are infinitely many prime numbers.
|
||||
Proof. blah
|
||||
36 1. ALGEBRAIC THEMES
|
||||
|
||||
|
||||
Theorem 1.6.6. There are infinitely many prime numbers.
|
||||
Proof continues after a page break, repeats the label.
|
||||
|
||||
1.7. Modular Arithmetic
|
||||
next section text
|
||||
"""
|
||||
|
||||
|
||||
class ExtractSectionTests(unittest.TestCase):
|
||||
def test_extract_section_slices_between_headings(self):
|
||||
section = extract_section(FIXTURE, '1.6. Divisibility in the Integers', '1.7. Modular Arithmetic')
|
||||
self.assertIn('Theorem 1.6.6', section)
|
||||
self.assertNotIn('next section text', section)
|
||||
|
||||
def test_extract_section_missing_heading_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
extract_section(FIXTURE, '1.6. Divisibility in the Integers', '9.9. Does Not Exist')
|
||||
|
||||
def test_labeled_statements_dedupes_and_orders(self):
|
||||
section = extract_section(FIXTURE, '1.6. Divisibility in the Integers', '1.7. Modular Arithmetic')
|
||||
items = labeled_statements(section, '1.6.')
|
||||
labels = [item['label'] for item in items]
|
||||
# Theorem 1.6.6 appears twice (page-break repeat) but must be kept once, and
|
||||
# book order (1.6.1 before 1.6.6) must be preserved.
|
||||
self.assertEqual(labels, ['1.6.1', '1.6.6'])
|
||||
|
||||
def test_labeled_statements_filters_by_prefix(self):
|
||||
section = extract_section(FIXTURE, '1.6. Divisibility in the Integers', '1.7. Modular Arithmetic')
|
||||
items = labeled_statements(section, '1.7.')
|
||||
self.assertEqual(items, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from textbook.formalize import extract_json_block, validate
|
||||
|
||||
VALID_REPLY = """Here is the result:
|
||||
```json
|
||||
{
|
||||
"definitions": [
|
||||
{"label": "Definition 1.6.3", "concept": "prime number", "mathlib_ref": "Nat.Prime",
|
||||
"lean_code": null, "notes": ""}
|
||||
],
|
||||
"theorems": [
|
||||
{"id": "goodman-1-6-6", "source_label": "Theorem 1.6.6", "kind": "theorem",
|
||||
"statement": "\\u2200 n : \\u2115, \\u2203 p, p > n \\u2227 p.Prime",
|
||||
"prelude": "import Mathlib", "natural_language": "There are infinitely many primes.",
|
||||
"requires": ["Nat.Prime"], "include_in_bench": true, "exclude_reason": null}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
class FormalizeParsingTests(unittest.TestCase):
|
||||
def test_extract_json_block_from_fenced_reply(self):
|
||||
data = extract_json_block(VALID_REPLY)
|
||||
self.assertEqual(set(data), {"definitions", "theorems"})
|
||||
|
||||
def test_extract_json_block_without_fence_falls_back_to_whole_text(self):
|
||||
payload = '{"theorems": [], "definitions": []}'
|
||||
self.assertEqual(extract_json_block(payload), json.loads(payload))
|
||||
|
||||
def test_validate_accepts_well_formed_response(self):
|
||||
data = extract_json_block(VALID_REPLY)
|
||||
theorems, definitions = validate(data)
|
||||
self.assertEqual(len(theorems), 1)
|
||||
self.assertEqual(len(definitions), 1)
|
||||
|
||||
def test_validate_rejects_missing_field(self):
|
||||
data = {"theorems": [{"id": "x", "source_label": "Theorem 1", "statement": "True"}],
|
||||
"definitions": []}
|
||||
with self.assertRaises(ValueError):
|
||||
validate(data)
|
||||
|
||||
def test_validate_rejects_duplicate_ids(self):
|
||||
entry = {"id": "dup", "source_label": "Theorem 1", "kind": "theorem", "statement": "True",
|
||||
"prelude": "import Mathlib", "include_in_bench": True}
|
||||
data = {"theorems": [entry, dict(entry)], "definitions": []}
|
||||
with self.assertRaises(ValueError):
|
||||
validate(data)
|
||||
|
||||
def test_validate_rejects_non_object_response(self):
|
||||
with self.assertRaises(ValueError):
|
||||
validate([])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
# Textbook formalization pipeline
|
||||
|
||||
把一本教科书(目前是 Goodman, *Algebra: Abstract and Concrete*)里正文的
|
||||
Theorem/Proposition/Lemma/Corollary 转成 lean-bench 能跑的任务 JSONL,配套
|
||||
Definition 清单(优先复用 Mathlib,Mathlib 没有的才手写),再由 lean-agent 在
|
||||
一个真正带 Mathlib 的 Lean 项目里验证/证明。这条流水线本身不跑 Lean,也不联网
|
||||
以外没有第三方依赖。
|
||||
|
||||
## 这条流水线不做什么
|
||||
|
||||
- 不产出证明。生成的只是**待证命题**(`statement` 字段),证明留给
|
||||
`lean-agent`/`lean-bench` 去跑。
|
||||
- 不在 Cowork 沙盒里验证 Lean 语句能不能通过类型检查——沙盒里没有装
|
||||
Lean/Lake/Mathlib(架构也不兼容:本机 `~/.elan` 下是 macOS 二进制,沙盒是
|
||||
Linux VM,跑不了),所以 `check_statements.py` 必须在你本机、且是一个已经
|
||||
`lake exe cache get` 过的 Mathlib 项目里跑,见下面"本机环境准备"。
|
||||
- 不会把教科书 PDF 或者大段原文提取结果提交进 git——`.gitignore` 里已经排除
|
||||
了 `*.pdf` 和 `textbook/*.txt`,这些是版权内容,只应该留在你本机。只有代码、
|
||||
生成的最终数据集(短的 Lean 语句 + 自己改写的简短英文释义)会进仓库。
|
||||
|
||||
## 用法
|
||||
|
||||
### 1. 从 PDF 提取一章的版式文本
|
||||
|
||||
```sh
|
||||
pdftotext -layout -f <起始pdf页码> -l <结束pdf页码> uiowa-algebra.pdf textbook/chapterNN_layout.txt
|
||||
```
|
||||
|
||||
页码用 `pdfinfo`/`pypdf` 先核对——书里印的页码和 PDF 物理页码不是一回事(前
|
||||
面有目录、序言)。
|
||||
|
||||
### 2. 切出一个 section,列出带编号的条目清单
|
||||
|
||||
```sh
|
||||
python3 textbook/extract_section.py textbook/chapterNN_layout.txt \
|
||||
--start-heading "1.6. Divisibility in the Integers" \
|
||||
--end-heading "1.7. Modular Arithmetic" \
|
||||
--section-prefix "1.6." \
|
||||
--out-text textbook/section_1_6.txt \
|
||||
--out-index textbook/section_1_6.index.json
|
||||
```
|
||||
|
||||
`--start-heading`/`--end-heading` 必须和版式文本里独占一行的章节标题**逐字**
|
||||
一致(提取时打印出的书本身的标题行);`--end-heading` 留空就切到文件末尾。
|
||||
输出的 index JSON 是这个 section 里所有 `Theorem/Proposition/Lemma/
|
||||
Corollary/Definition N.N.N` 开头的条目清单(自动去重了跨页重复的标签),用来
|
||||
让下一步的 LLM 不漏项、不编项目。
|
||||
|
||||
### 3. 用 LLM 把 section 形式化成候选 Lean 语句
|
||||
|
||||
```sh
|
||||
export DASHSCOPE_API_KEY=... # 或者写进 lean-bench/.env(已 gitignore)
|
||||
python3 textbook/formalize.py \
|
||||
textbook/section_1_6.txt textbook/section_1_6.index.json \
|
||||
--model qwen-max --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 \
|
||||
--out-dataset datasets/goodman-1.6.jsonl \
|
||||
--out-definitions textbook/goodman-1.6.definitions.json \
|
||||
--out-excluded textbook/goodman-1.6.excluded.jsonl
|
||||
```
|
||||
|
||||
模型会为每条 Theorem/Proposition/Lemma/Corollary 产出一个 Lean 4 命题
|
||||
(`statement`)、需要的 `prelude`(通常就是 `import Mathlib`)、一句自己改写
|
||||
的英文释义,以及用到的 Mathlib 标识符列表;为每条相关的 Definition 产出一个
|
||||
清单条目——如果 Mathlib 已经有对应概念(`mathlib_ref` 非空)就不写 Lean 代
|
||||
码,只有 Mathlib 没有的概念才会给出手写的 `def`。判断这条目根本没法形式化
|
||||
(纯计算题、内容太模糊/被 PDF 提取污染到读不出原意)的条目,会带着
|
||||
`include_in_bench: false` 和原因单独写进 `--out-excluded`,不会进真正的数据
|
||||
集,但也不会被静默丢弃——都留痕方便你复核。
|
||||
|
||||
源 PDF 的数学符号提取质量很差(≡、∈、上下标经常丢失或乱码),这一步依赖模
|
||||
型自己对标准教材内容的知识去猜测/还原真实含义,**不是逐字翻译提取出来的文
|
||||
本**。所以生成的候选语句必须经过下一步验证,不能直接信。
|
||||
|
||||
### 4. 在你本机验证候选语句能不能通过类型检查
|
||||
|
||||
见下面"本机环境准备"先搭好一个带 Mathlib 的 Lake 项目,然后:
|
||||
|
||||
```sh
|
||||
python3 textbook/check_statements.py datasets/goodman-1.6.jsonl \
|
||||
--lean-project /path/to/your/mathlib-project \
|
||||
--lean-command '["lake","env","lean"]' \
|
||||
--report textbook/goodman-1.6.elab-report.jsonl
|
||||
```
|
||||
|
||||
这一步只检查"这句 Lean 能不能通过类型检查"(用 `sorry` 占位),**不检查证明
|
||||
是否存在、也不检查是否忠实于原书**——分别对应两件不同的事:语法/引用是否正
|
||||
确,跟数学内容是否正确。失败的条目(`FAIL`)要么是模型编造了不存在的
|
||||
Mathlib 标识符,要么是语句本身写错了,需要你告诉我具体哪条、Lean 报的什么
|
||||
错,我再改。
|
||||
|
||||
### 5. 用 lean-agent/lean-bench 真正证明
|
||||
|
||||
```sh
|
||||
lean-bench datasets/goodman-1.6.jsonl --output results/goodman-1.6-001 \
|
||||
--lean-project /path/to/your/mathlib-project --lean-command '["lake","env","lean"]' \
|
||||
--model ... --base-url ...
|
||||
```
|
||||
|
||||
### 6. 把证出来的定理组装成一份可复用的库文件
|
||||
|
||||
```sh
|
||||
python3 textbook/build_library.py datasets/goodman-1.6.jsonl \
|
||||
results/goodman-1.6-001/results.jsonl --out lib/Chapter01Section06.lean
|
||||
```
|
||||
|
||||
只有这次跑真的证出来的定理(`results.jsonl` 里 `success: true`)才会被收进
|
||||
去,直接复用验证时确认过的那个证明,不会引入新的证明风险。后续章节要用这些
|
||||
已证定理时,`import` 这份文件——注意这只是把已证结果攒成一个文件,不是一个真
|
||||
正的 Lake 包/模块边界。
|
||||
|
||||
## 本机环境准备:一个带 Mathlib 的 Lake 项目
|
||||
|
||||
Cowork 这边的沙盒没有 Lean,`check_statements.py` 和真正跑 `lean-agent`/
|
||||
`lean-bench` 都需要在你本机(或者别的有 Lean 的环境)完成。如果还没有这样一
|
||||
个项目:
|
||||
|
||||
```sh
|
||||
cd ~/ProjectResearch # 随便一个地方
|
||||
lake +leanprover/lean4:v4.33.1 new mathlib-bench math
|
||||
cd mathlib-bench
|
||||
lake exe cache get # 下载 Mathlib 预编译缓存,比从源码编译快得多
|
||||
lake build
|
||||
```
|
||||
|
||||
之后 `--lean-project` 就指到 `mathlib-bench` 这个目录,`--lean-command` 用
|
||||
`'["lake","env","lean"]'`。
|
||||
|
||||
## 反馈循环
|
||||
|
||||
我(claude-bot)在 ProjectResearch 里生成/修改数据集和代码 → 提交并 push 到
|
||||
`Zayden-Jung/lean-bench`(用 claude-bot 的 token) → 你在本机 `git pull` →
|
||||
跑 `check_statements.py` / `lean-bench` → 把失败的条目 id 和 Lean 报的错发
|
||||
给我 → 我改完再 push。不需要额外工具,就是正常的 git 往返。
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Assemble a reusable Lean library file from a completed lean-bench run.
|
||||
|
||||
Only theorems that actually solved (results.jsonl success == true) go in; each one's
|
||||
exact winning proof (its last, successful attempt) is reused verbatim, re-wrapped in the
|
||||
same `theorem <id> : <statement> := by ...` shape lean-bench's verifier already checked,
|
||||
so the assembled file carries no new proof risk beyond what that run already confirmed --
|
||||
this script does not itself call Lean.
|
||||
|
||||
Plain `import ...` preludes are deduplicated and hoisted to the top of the file; any
|
||||
other prelude content (a local definition, say) is kept attached to its own theorem
|
||||
instead of being collapsed, since it isn't necessarily shared with the rest.
|
||||
|
||||
A library assembled this way is only as trustworthy as the run it came from: rerun
|
||||
check_statements.py / lean-bench yourself before importing it into something else you
|
||||
care about, and treat --module-name as documentation, not a real Lean module boundary
|
||||
(this script emits one flat .lean file, not a package).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_jsonl(path):
|
||||
return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
|
||||
|
||||
def build(dataset_path, results_path):
|
||||
dataset = {t['id']: t for t in load_jsonl(dataset_path)}
|
||||
results = {r['id']: r for r in load_jsonl(results_path)}
|
||||
|
||||
shared_imports = set()
|
||||
per_theorem_prelude = {}
|
||||
for task_id, task in dataset.items():
|
||||
prelude = task.get('prelude', '').strip()
|
||||
if not prelude:
|
||||
continue
|
||||
if all(line.strip().startswith('import ') for line in prelude.splitlines()):
|
||||
shared_imports.update(line.strip() for line in prelude.splitlines())
|
||||
else:
|
||||
per_theorem_prelude[task_id] = prelude
|
||||
|
||||
lines = ['-- Generated by textbook/build_library.py -- do not hand-edit proofs;',
|
||||
'-- regenerate from a fresh lean-bench run instead.', '']
|
||||
lines.extend(sorted(shared_imports))
|
||||
lines.append('')
|
||||
|
||||
solved, skipped = [], []
|
||||
for task_id, task in dataset.items():
|
||||
result = results.get(task_id)
|
||||
if not result or not result.get('success') or not result.get('attempts'):
|
||||
skipped.append(task_id)
|
||||
continue
|
||||
proof = result['attempts'][-1]['proof']
|
||||
comment = f'-- {task.get("source_label", task_id)}: {task.get("natural_language", "")}'.rstrip()
|
||||
lines.append(comment)
|
||||
if task_id in per_theorem_prelude:
|
||||
lines.append(per_theorem_prelude[task_id])
|
||||
lean_name = task_id.replace('-', '_')
|
||||
lines.append(f'theorem {lean_name} : {task["statement"]} := by')
|
||||
lines.extend(' ' + line for line in proof.splitlines())
|
||||
lines.append('')
|
||||
solved.append(task_id)
|
||||
|
||||
return '\n'.join(lines), solved, skipped
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Assemble a Lean library file from a solved lean-bench dataset')
|
||||
parser.add_argument('dataset', type=Path)
|
||||
parser.add_argument('results', type=Path, help="a lean-bench run's results.jsonl")
|
||||
parser.add_argument('--out', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
text, solved, skipped = build(args.dataset, args.results)
|
||||
args.out.write_text(text + '\n', encoding='utf-8')
|
||||
print(f'{len(solved)} theorems assembled -> {args.out}')
|
||||
if skipped:
|
||||
print(f'{len(skipped)} skipped (not solved in this run): {", ".join(skipped)}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Elaboration-only sanity check for candidate Lean statements from formalize.py.
|
||||
|
||||
Checks whether `theorem _ : statement := by sorry` type-checks under the task's
|
||||
prelude -- i.e. whether the statement is well-formed Lean using identifiers that
|
||||
actually exist. This does NOT verify the statement is provable, or even that it
|
||||
faithfully represents the book (that's still on you to spot-check); it only catches
|
||||
hallucinated Mathlib names and syntax errors for free, before spending any lean-agent
|
||||
proof attempts on them.
|
||||
|
||||
Must run on a machine with `lean`/`lake` installed and, for anything using Mathlib
|
||||
(almost everything here), a Lake project that already has Mathlib built -- point
|
||||
--lean-project at it and pass --lean-command '["lake","env","lean"]'. This script
|
||||
cannot run inside a Cowork sandbox: see lean-bench/textbook/README.md.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElabResult:
|
||||
ok: bool
|
||||
diagnostics: str
|
||||
|
||||
|
||||
def elaborates(prelude, statement, command=None, cwd=None, timeout=30):
|
||||
command = command or ['lean']
|
||||
source = (prelude + '\n' if prelude else '') + 'theorem lean_bench_sanity_check : ' + statement + ' := by sorry\n'
|
||||
with tempfile.TemporaryDirectory(prefix='lean-sanity-', dir=cwd) as directory:
|
||||
path = Path(directory) / 'SanityCheck.lean'
|
||||
path.write_text(source, encoding='utf-8')
|
||||
try:
|
||||
result = subprocess.run([*command, str(path)], cwd=cwd, capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ElabResult(False, 'Lean timeout')
|
||||
diagnostics = result.stdout + result.stderr
|
||||
ok = result.returncode == 0 and 'error:' not in diagnostics
|
||||
return ElabResult(ok, diagnostics[-8000:])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Check that candidate statements elaborate (not that they are provable)')
|
||||
parser.add_argument('dataset', type=Path, help='JSONL with id, statement, optional prelude')
|
||||
parser.add_argument('--lean-command', default='["lean"]', help='JSON argv array; e.g. ["lake","env","lean"]')
|
||||
parser.add_argument('--lean-project', help='Lean/Lake project working directory (needs Mathlib built)')
|
||||
parser.add_argument('--timeout', type=float, default=60)
|
||||
parser.add_argument('--report', type=Path, help='Write JSONL of {id, ok, diagnostics} here')
|
||||
args = parser.parse_args()
|
||||
|
||||
command = json.loads(args.lean_command)
|
||||
tasks = [json.loads(line) for line in args.dataset.read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
reports = []
|
||||
failed = 0
|
||||
for task in tasks:
|
||||
result = elaborates(task.get('prelude', ''), task['statement'], command, args.lean_project, args.timeout)
|
||||
reports.append({"id": task['id'], "ok": result.ok, "diagnostics": result.diagnostics})
|
||||
print(('OK ' if result.ok else 'FAIL') + ' ' + task['id'])
|
||||
if not result.ok:
|
||||
failed += 1
|
||||
|
||||
if args.report:
|
||||
with args.report.open('w', encoding='utf-8') as stream:
|
||||
for r in reports:
|
||||
stream.write(json.dumps(r, ensure_ascii=False) + '\n')
|
||||
|
||||
print(f'{len(tasks) - failed}/{len(tasks)} elaborate cleanly')
|
||||
raise SystemExit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Slice one book section out of a `pdftotext -layout` dump and list its labeled
|
||||
Theorem/Proposition/Lemma/Corollary/Definition statements, as input for formalize.py.
|
||||
|
||||
Produces two files: the section's raw text, and a JSON checklist of labeled items
|
||||
(kind, label, first line) in book order. The checklist exists so formalize.py's LLM
|
||||
step has an explicit inventory to cover and cannot silently skip or invent items.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LABEL_RE = re.compile(r'^(Definition|Theorem|Proposition|Lemma|Corollary)\s+(\d+\.\d+(?:\.\d+)?)\.?\s*(.*)$')
|
||||
|
||||
|
||||
def find_heading_line(lines, heading):
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == heading:
|
||||
return i
|
||||
raise ValueError(f'Heading not found: {heading!r}')
|
||||
|
||||
|
||||
def extract_section(full_text, start_heading, end_heading):
|
||||
lines = full_text.split('\n')
|
||||
start = find_heading_line(lines, start_heading)
|
||||
end = find_heading_line(lines, end_heading) if end_heading else len(lines)
|
||||
if end <= start:
|
||||
raise ValueError('end_heading must come after start_heading')
|
||||
return '\n'.join(lines[start:end])
|
||||
|
||||
|
||||
def labeled_statements(section_text, section_prefix):
|
||||
"""Unique labeled items in section_text whose label starts with section_prefix
|
||||
(e.g. '1.6.'), keeping only the first occurrence of each label: later matches are
|
||||
almost always proof continuations across a page break or cross-references, not new
|
||||
statements. Returned sorted in book order (numeric, not lexical)."""
|
||||
seen = {}
|
||||
for line in section_text.split('\n'):
|
||||
line = line.strip()
|
||||
m = LABEL_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
kind, label, rest = m.group(1), m.group(2), m.group(3)
|
||||
if not label.startswith(section_prefix) or label in seen:
|
||||
continue
|
||||
seen[label] = {"kind": kind, "label": label, "first_line": line[:120]}
|
||||
|
||||
def sort_key(item):
|
||||
return tuple(int(p) for p in item['label'].split('.'))
|
||||
|
||||
return sorted(seen.values(), key=sort_key)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Slice one section out of a pdftotext -layout dump')
|
||||
parser.add_argument('layout_txt', type=Path)
|
||||
parser.add_argument('--start-heading', required=True, help="e.g. '1.6. Divisibility in the Integers'")
|
||||
parser.add_argument('--end-heading', help="e.g. '1.7. Modular Arithmetic'; omit for end of file")
|
||||
parser.add_argument('--section-prefix', required=True, help="e.g. '1.6.'")
|
||||
parser.add_argument('--out-text', type=Path, required=True)
|
||||
parser.add_argument('--out-index', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
full_text = args.layout_txt.read_text(encoding='utf-8')
|
||||
section_text = extract_section(full_text, args.start_heading, args.end_heading)
|
||||
args.out_text.write_text(section_text, encoding='utf-8')
|
||||
index = labeled_statements(section_text, args.section_prefix)
|
||||
args.out_index.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(f'{len(index)} labeled statements -> {args.out_index}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""LLM-assisted formalization of a textbook section into Lean 4 candidate statements.
|
||||
|
||||
Reads a section's extracted text and labeled-statement checklist (from
|
||||
extract_section.py) and asks an LLM to produce:
|
||||
(a) a Lean 4 theorem statement for every genuine Theorem/Proposition/Lemma/Corollary,
|
||||
preferring Mathlib's existing vocabulary over reinventing definitions, and
|
||||
(b) a manifest of every Definition the theorems depend on, pointing at the matching
|
||||
Mathlib name when one exists and only asking for hand-written Lean `def`s when it
|
||||
doesn't.
|
||||
|
||||
This step never runs Lean. The extracted PDF text can have corrupted math symbols
|
||||
(missing =/in, dropped superscripts) from the source PDF's font encoding; the model is
|
||||
told to lean on its own knowledge of standard textbook mathematics plus the section
|
||||
context, and to flag low-confidence items rather than guess silently. Always run
|
||||
check_statements.py against a real Mathlib-backed Lean project before trusting the
|
||||
output, and before spending lean-agent proof attempts on it.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .llm import ChatClient, load_dotenv # imported as textbook.formalize
|
||||
except ImportError:
|
||||
from llm import ChatClient, load_dotenv # run directly: python3 textbook/formalize.py
|
||||
|
||||
SYSTEM_PROMPT = """You are formalizing a section of an undergraduate abstract algebra \
|
||||
textbook (Goodman, "Algebra: Abstract and Concrete") into Lean 4 using Mathlib.
|
||||
|
||||
You will receive:
|
||||
1. The raw text of one book section, extracted from a PDF. WARNING: the source PDF's \
|
||||
math font encoding is broken in this extraction -- symbols like congruence, set \
|
||||
membership, comparisons, superscripts and subscripts are frequently dropped or garbled. \
|
||||
Use your own knowledge of standard undergraduate number theory / algebra to reconstruct \
|
||||
the intended mathematics from context; do not invent content that is not actually in the \
|
||||
section.
|
||||
2. A checklist of every labeled Theorem/Proposition/Lemma/Corollary/Definition in the \
|
||||
section (by number), so you know exactly what to cover -- do not skip any, and do not \
|
||||
invent ones that are not listed.
|
||||
|
||||
Produce exactly two JSON arrays, "definitions" and "theorems", as a single JSON object \
|
||||
inside one ```json fenced code block, and nothing else outside that block.
|
||||
|
||||
Each element of "definitions" (only for Definition-labeled entries the theorems below \
|
||||
actually depend on):
|
||||
{
|
||||
"label": "Definition 1.6.3",
|
||||
"concept": "short name, e.g. 'prime number'",
|
||||
"mathlib_ref": "Mathlib identifier that already means this, e.g. 'Nat.Prime', or null \
|
||||
if Mathlib has no equivalent",
|
||||
"lean_code": "a standalone Lean 4 `def ...` ONLY if mathlib_ref is null, else null",
|
||||
"notes": "anything a formalizer should know, e.g. where the book's definition differs \
|
||||
from Mathlib's convention"
|
||||
}
|
||||
|
||||
Each element of "theorems" (one per Theorem/Proposition/Lemma/Corollary in the \
|
||||
checklist):
|
||||
{
|
||||
"id": "goodman-1-6-6",
|
||||
"source_label": "Theorem 1.6.6",
|
||||
"kind": "theorem",
|
||||
"statement": "the Lean 4 proposition only, i.e. what goes after `theorem name : `, \
|
||||
using Mathlib vocabulary (Nat.Prime, Nat.gcd, Int.gcd, etc.) wherever possible instead \
|
||||
of reinventing definitions",
|
||||
"prelude": "Lean import lines this statement needs, usually just \\"import Mathlib\\"",
|
||||
"natural_language": "a short original English restatement in your own words (do not \
|
||||
quote the source text verbatim)",
|
||||
"requires": ["list of Mathlib identifiers the statement uses"],
|
||||
"include_in_bench": true,
|
||||
"exclude_reason": null
|
||||
}
|
||||
|
||||
Set "include_in_bench" to false (with a short "exclude_reason") instead of omitting an \
|
||||
entry when a checklist item turns out to not be a genuine formalizable claim (e.g. it is \
|
||||
actually a definition misfiled under a theorem-style label, a purely numerical one-off \
|
||||
computation with no general statement, or the extracted text is too corrupted to recover \
|
||||
its meaning confidently) -- still emit the entry so nothing silently disappears, just \
|
||||
mark it excluded.
|
||||
|
||||
id must be the label with dots and spaces replaced by hyphens, lowercased, prefixed \
|
||||
"goodman-". Do not include a proof, "sorry", or "by" -- statement is only the \
|
||||
proposition, no theorem keyword or name. Do not wrap the JSON in anything other than a \
|
||||
single ```json fenced block."""
|
||||
|
||||
|
||||
def user_prompt(section_text, checklist):
|
||||
checklist_lines = '\n'.join(f"- {item['kind']} {item['label']}: {item['first_line']}"
|
||||
for item in checklist)
|
||||
return (f"Checklist of labeled items in this section:\n{checklist_lines}\n\n"
|
||||
f"Section text:\n{section_text}")
|
||||
|
||||
|
||||
def extract_json_block(text):
|
||||
match = re.search(r'```json\s*\n(.*?)```', text, re.S)
|
||||
payload = match.group(1) if match else text
|
||||
return json.loads(payload)
|
||||
|
||||
|
||||
def validate(data):
|
||||
if not isinstance(data, dict) or 'theorems' not in data or 'definitions' not in data:
|
||||
raise ValueError('Response must be a JSON object with "theorems" and "definitions" arrays')
|
||||
theorems, definitions = data['theorems'], data['definitions']
|
||||
if not isinstance(theorems, list) or not isinstance(definitions, list):
|
||||
raise ValueError('"theorems" and "definitions" must be arrays')
|
||||
seen_ids = set()
|
||||
for item in theorems:
|
||||
for field in ('id', 'source_label', 'statement', 'prelude', 'include_in_bench'):
|
||||
if field not in item:
|
||||
raise ValueError(f'theorem entry missing field {field!r}: {item}')
|
||||
if item['id'] in seen_ids:
|
||||
raise ValueError(f'Duplicate theorem id: {item["id"]}')
|
||||
seen_ids.add(item['id'])
|
||||
for item in definitions:
|
||||
for field in ('label', 'concept', 'mathlib_ref'):
|
||||
if field not in item:
|
||||
raise ValueError(f'definition entry missing field {field!r}: {item}')
|
||||
return theorems, definitions
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='LLM-formalize a book section into Lean 4 candidate statements')
|
||||
parser.add_argument('section_text', type=Path)
|
||||
parser.add_argument('statement_index', type=Path)
|
||||
parser.add_argument('--out-dataset', type=Path, required=True, help='JSONL of theorems with include_in_bench=true')
|
||||
parser.add_argument('--out-definitions', type=Path, required=True, help='JSON manifest of definitions')
|
||||
parser.add_argument('--out-excluded', type=Path, help='JSONL of theorem entries the model excluded, for review')
|
||||
parser.add_argument('--model', required=True)
|
||||
parser.add_argument('--base-url', default='https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
parser.add_argument('--api-key-env', default='DASHSCOPE_API_KEY')
|
||||
parser.add_argument('--env-file', type=Path, default=Path(__file__).resolve().parents[1] / '.env')
|
||||
parser.add_argument('--timeout', type=float, default=300)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_dotenv(args.env_file)
|
||||
client = ChatClient(args.model, args.base_url, args.api_key_env, args.timeout)
|
||||
checklist = json.loads(args.statement_index.read_text(encoding='utf-8'))
|
||||
section_text = args.section_text.read_text(encoding='utf-8')
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt(section_text, checklist)}]
|
||||
reply = client.complete(messages)
|
||||
data = extract_json_block(reply)
|
||||
theorems, definitions = validate(data)
|
||||
|
||||
included = [t for t in theorems if t.get('include_in_bench')]
|
||||
excluded = [t for t in theorems if not t.get('include_in_bench')]
|
||||
|
||||
with args.out_dataset.open('w', encoding='utf-8') as stream:
|
||||
for item in included:
|
||||
stream.write(json.dumps(item, ensure_ascii=False) + '\n')
|
||||
args.out_definitions.write_text(json.dumps(definitions, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
if args.out_excluded:
|
||||
with args.out_excluded.open('w', encoding='utf-8') as stream:
|
||||
for item in excluded:
|
||||
stream.write(json.dumps(item, ensure_ascii=False) + '\n')
|
||||
|
||||
print(f'{len(included)} theorems -> {args.out_dataset}')
|
||||
if excluded:
|
||||
note = f' (see {args.out_excluded})' if args.out_excluded else ' (pass --out-excluded to keep them)'
|
||||
print(f'{len(excluded)} excluded{note}')
|
||||
print(f'{len(definitions)} definitions -> {args.out_definitions}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Minimal OpenAI-compatible chat client, standalone (no third-party deps, no import of
|
||||
lean_agent) so this pipeline works regardless of whether lean-agent is pip-installed.
|
||||
Mirrors lean_agent.core.HTTPProvider on purpose; kept separate deliberately."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
|
||||
def load_dotenv(path):
|
||||
"""Load KEY=VALUE lines from a .env file into os.environ (does not overwrite vars
|
||||
already set). Silently does nothing if the file does not exist."""
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text(encoding='utf-8').splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, _, value = line.partition('=')
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
class ChatClient:
|
||||
"""OpenAI-compatible /chat/completions caller."""
|
||||
|
||||
def __init__(self, model, base_url, api_key_env='DASHSCOPE_API_KEY', timeout=300):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key_env = api_key_env
|
||||
self.timeout = timeout
|
||||
|
||||
def complete(self, messages, temperature=0.2):
|
||||
key = os.environ.get(self.api_key_env)
|
||||
if not key:
|
||||
raise RuntimeError(f'Missing API key in environment variable {self.api_key_env}')
|
||||
headers = {"Content-Type": "application/json", "Authorization": "Bearer " + key}
|
||||
payload = {"model": self.model, "messages": messages, "temperature": temperature}
|
||||
request = urllib.request.Request(self.base_url + '/chat/completions',
|
||||
data=json.dumps(payload).encode(), headers=headers)
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
data = json.load(response)
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
if not isinstance(content, str):
|
||||
raise ValueError("Provider returned non-text content")
|
||||
return content
|
||||
@@ -0,0 +1,117 @@
|
||||
[
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.1",
|
||||
"first_line": "Proposition 1.6.1."
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.2",
|
||||
"first_line": "Proposition 1.6.2. Let a, b, c, u, and v denote integers."
|
||||
},
|
||||
{
|
||||
"kind": "Definition",
|
||||
"label": "1.6.3",
|
||||
"first_line": "Definition 1.6.3. A natural number is prime if it is greater than 1 and not"
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.4",
|
||||
"first_line": "Proposition 1.6.4. Any natural number other than 1 can be written as a"
|
||||
},
|
||||
{
|
||||
"kind": "Theorem",
|
||||
"label": "1.6.6",
|
||||
"first_line": "Theorem 1.6.6. There are infinitely many prime numbers."
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.7",
|
||||
"first_line": "Proposition 1.6.7. Given integers a and d , with d \u0015 1, there exist unique"
|
||||
},
|
||||
{
|
||||
"kind": "Definition",
|
||||
"label": "1.6.8",
|
||||
"first_line": "Definition 1.6.8. A natural number d is the greatest common divisor of"
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.9",
|
||||
"first_line": "Proposition 1.6.9. For integers n and m, let"
|
||||
},
|
||||
{
|
||||
"kind": "Lemma",
|
||||
"label": "1.6.10",
|
||||
"first_line": "Lemma 1.6.10. Let m and n be nonzero integers. If a natural number d"
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.11",
|
||||
"first_line": "Proposition 1.6.11. The natural number nr is the greatest common divisor"
|
||||
},
|
||||
{
|
||||
"kind": "Corollary",
|
||||
"label": "1.6.13",
|
||||
"first_line": "Corollary 1.6.13. Let m and n be nonzero integers, and write d D"
|
||||
},
|
||||
{
|
||||
"kind": "Definition",
|
||||
"label": "1.6.14",
|
||||
"first_line": "Definition 1.6.14. Nonzero integers m and n are relatively prime if"
|
||||
},
|
||||
{
|
||||
"kind": "Corollary",
|
||||
"label": "1.6.15",
|
||||
"first_line": "Corollary 1.6.15. Two nonzero integers m and n are relatively prime if"
|
||||
},
|
||||
{
|
||||
"kind": "Corollary",
|
||||
"label": "1.6.17",
|
||||
"first_line": "Corollary 1.6.17. Suppose that a and b are relatively prime natural num-"
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.18",
|
||||
"first_line": "Proposition 1.6.18. If p is a prime number and a is any nonzero integer,"
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.19",
|
||||
"first_line": "Proposition 1.6.19. Let p be a prime number, and a and b nonzero inte-"
|
||||
},
|
||||
{
|
||||
"kind": "Corollary",
|
||||
"label": "1.6.20",
|
||||
"first_line": "Corollary 1.6.20. Suppose that a prime number p divides a product"
|
||||
},
|
||||
{
|
||||
"kind": "Theorem",
|
||||
"label": "1.6.21",
|
||||
"first_line": "Theorem 1.6.21. The prime factorization of a natural number is unique."
|
||||
},
|
||||
{
|
||||
"kind": "Definition",
|
||||
"label": "1.6.22",
|
||||
"first_line": "Definition 1.6.22. A natural number d is the greatest common divisor of"
|
||||
},
|
||||
{
|
||||
"kind": "Lemma",
|
||||
"label": "1.6.23",
|
||||
"first_line": "Lemma 1.6.23. Given nonzero integers a1 ; a2 ; : : : ; an (n \u0015 2), there is a"
|
||||
},
|
||||
{
|
||||
"kind": "Proposition",
|
||||
"label": "1.6.24",
|
||||
"first_line": "Proposition 1.6.24. The greatest common divisor of nonzero inte-"
|
||||
},
|
||||
{
|
||||
"kind": "Definition",
|
||||
"label": "1.6.25",
|
||||
"first_line": "Definition 1.6.25. We say that non-zero integers a1 ; : : : ; an are relatively"
|
||||
},
|
||||
{
|
||||
"kind": "Lemma",
|
||||
"label": "1.6.26",
|
||||
"first_line": "Lemma 1.6.26. Let a1 ; : : : ; an be pairwise relatively prime integers, and"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user