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:
2026-09-17 17:04:19 +00:00
parent 99638be637
commit caedf2e6f4
13 changed files with 871 additions and 0 deletions
+6
View File
@@ -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
+6
View File
@@ -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)。
+61
View File
@@ -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()
+49
View File
@@ -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()
+58
View File
@@ -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()
+133
View File
@@ -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 往返。
View File
+82
View File
@@ -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()
+75
View File
@@ -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()
+73
View File
@@ -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()
+165
View File
@@ -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()
+46
View File
@@ -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
+117
View File
@@ -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"
}
]