Files
lean-bench/leanbench/formalize.py
T
Zayden-Jung d3555d9a52
ci / test (push) Failing after 12s
ci / lean-project (push) Successful in 5s
docker / image (push) Failing after 5s
Rebuild lean-bench: pages in, verified indexed Lean out
Replaces the previous tree entirely. The pipeline is four separable pieces, each
testable on its own:

1. leanbench/llm.py      provider-neutral OpenAI-compatible connector, stdlib only,
                        with retries, JSON coercion and a scripted client so the
                        rest of the code is testable without a network.
2. leanbench/detect.py   per-page detection of formalizable mathematics. Case 1 is
                        mathematics asserted as fact (definition/theorem/lemma/...,
                        deliberately not sub-classified); case 2 is a problem left
                        open to the reader. Hits come back as spans -- start page
                        and index, end page and index -- so several per page is
                        normal. A hit cut off by the page end makes the detector
                        re-read that page together with the next, repeatedly, so an
                        item spread over three pages is found whole.
                        Offsets come from verbatim quotes resolved by
                        leanbench/anchors.py (exact -> whitespace-insensitive ->
                        fuzzy), because models quote well and count badly.
3. leanbench/formalize.py + leanbench/verify.py
                        each span becomes one Lean 4 declaration, judged by the real
                        compiler: full elaboration for case 1, statement legality
                        only for case 2, which has no proof to check. Lean's own
                        diagnostics feed back for a repair attempt. "verified" (Lean
                        accepted it) and "proved" (accepted with no sorry) are kept
                        strictly apart.
4. leanbench/db.py       indexed SQLite plus JSONL export. Every item is indexed by
                        the types of its inputs, or by its own type when it is not a
                        proof, and records the source book (sha256) and the page it
                        starts on -- so `drop --source H --pages A-B` followed by a
                        re-ingest redoes exactly those pages and nothing else.

Also: lean/ Lake project pinned to Mathlib, docker/ image carrying Lean 4 and a
prebuilt Mathlib (the Mathlib revision and the toolchain are resolved against each
other at build time rather than both hardcoded), and .gitea/workflows for tests,
image build and push to the Gitea registry, and a manual "submit a book" run.

58 tests; they need neither network nor Lean.
2026-09-19 07:05:52 +00:00

285 lines
9.7 KiB
Python

"""Step 3: every detected span becomes Lean 4 code, then Lean gets the last word.
A ``statement`` item becomes whatever declaration fits -- ``def``, ``structure``,
``theorem`` -- with a proof only if the source actually gave one. A ``problem`` item
is an open question, so it becomes a statement with ``sorry`` for the proof and only
the statement's legality is checked.
When Lean rejects the code, the diagnostics go back to the model and it gets another
go: a compiler error is the cheapest, most honest feedback available.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from typing import Any
from .detect import KIND_PROBLEM, Detection
from .llm import extract_code
from .typeindex import index_types
from .verify import MODE_FULL, MODE_STATEMENT, VerifyResult, contains_sorry
SYSTEM_PROMPT = """\
You translate mathematics from books into Lean 4 (Mathlib). You write Lean 4 with
Mathlib naming and syntax -- never Lean 3, never `begin ... end`.
You are given one passage, extracted from a PDF, so expect broken line breaks,
hyphenation and mangled symbols; reconstruct what the mathematics must have been.
Produce one self-contained top-level declaration that captures the passage:
- a definition in the passage -> `def`, `abbrev`, `structure`, `class` or `instance`;
- a theorem / proposition / lemma / corollary -> `theorem`;
- an exercise or open problem -> `theorem` whose statement is the thing to be shown.
Rules:
- Prefer existing Mathlib definitions over re-defining them. Never invent Mathlib
lemma or definition names: if you are unsure a name exists, restate the maths with
primitives you are sure of.
- State the mathematics faithfully. A statement that is trivially true, or that
quantifies over the wrong thing, is a failure even if it compiles.
- Give the declaration a descriptive snake_case name.
- Include a proof ONLY when the passage itself contains the proof and you are
confident in it; otherwise end the declaration with `sorry`.
- If the passage is not formalizable as a single declaration, say so with
"skip": true and a short reason.
Reply with JSON only:
{"skip": false,
"reason": "",
"imports": ["Mathlib"],
"name": "snake_case_name",
"lean_code": "theorem snake_case_name (n : \\u2115) : ... := by\\n sorry",
"has_proof": false,
"informal": "one-line English restatement",
"index_types": ["Int", "Nat"],
"confidence": 0.0}
"index_types" lists the Lean types this item is about: for a theorem, the types of
its inputs and hypotheses; for a definition, the type of the thing being defined.
"""
REPAIR_PROMPT = """\
Lean rejected that code. Fix it and reply with the same JSON shape.
Do not paper over the error with `sorry` in place of a statement, and do not change
what the passage says in order to make it compile. If a Mathlib name does not exist,
restate the mathematics with primitives that do.
Lean diagnostics:
{diagnostics}
"""
@dataclass
class Formalization:
"""The Lean rendering of one detected item, plus what Lean thought of it."""
detection: Detection
lean_code: str = ""
imports: list[str] = field(default_factory=lambda: ["Mathlib"])
name: str = ""
informal: str = ""
has_proof: bool = False
confidence: float = 0.0
skipped: bool = False
skip_reason: str = ""
attempts: int = 0
verify: VerifyResult | None = None
index_types: list[tuple[str, str]] = field(default_factory=list)
@property
def verified(self) -> bool:
return bool(self.verify and self.verify.ok)
@property
def proved(self) -> bool:
return bool(self.verify and self.verify.proved)
def as_dict(self) -> dict[str, Any]:
return {
"detection": self.detection.as_dict(),
"lean_code": self.lean_code,
"imports": self.imports,
"name": self.name,
"informal": self.informal,
"has_proof": self.has_proof,
"confidence": self.confidence,
"skipped": self.skipped,
"skip_reason": self.skip_reason,
"attempts": self.attempts,
"verify": self.verify.as_dict() if self.verify else None,
"index_types": [{"name": n, "role": r} for n, r in self.index_types],
}
def verification_mode(detection: Detection) -> str:
"""Open problems carry no proof, so only their statement can be checked."""
return MODE_STATEMENT if detection.kind == KIND_PROBLEM else MODE_FULL
def _user_prompt(detection: Detection, document_title: str = "") -> str:
where = (
f"pages {detection.start_page}-{detection.end_page}"
if detection.end_page != detection.start_page
else f"page {detection.start_page}"
)
kind_note = (
"This is an exercise / open problem: state it, do not solve it. End with `sorry`."
if detection.kind == KIND_PROBLEM
else "This is asserted as mathematics in the book."
)
header = f"Source: {document_title or 'book'}, {where}"
if detection.label:
header += f", labelled {detection.label!r}"
return (
f"{header}\n{kind_note}\n\nPassage:\n<<<BEGIN>>>\n{detection.text}\n<<<END>>>"
)
def _clean_code(payload: dict) -> str:
code = payload.get("lean_code") or payload.get("code") or ""
if not isinstance(code, str):
return ""
code = extract_code(code, "lean")
# models occasionally keep the import lines inside the declaration body
lines = [line for line in code.splitlines() if not re.match(r"^\s*import\s", line)]
return "\n".join(lines).strip()
def _imports(payload: dict) -> list[str]:
raw = payload.get("imports")
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, list):
raw = []
modules = [str(m).strip() for m in raw if str(m).strip()]
return modules or ["Mathlib"]
def formalize(
client: Any,
detection: Detection,
*,
checker: Any = None,
document_title: str = "",
repair_attempts: int = 1,
) -> Formalization:
"""One detected span -> one Lean declaration, verified as far as it can be."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _user_prompt(detection, document_title)},
]
result = Formalization(detection=detection)
mode = verification_mode(detection)
for attempt in range(repair_attempts + 1):
payload = client.complete_json(messages)
if not isinstance(payload, dict):
payload = {}
result.attempts = attempt + 1
if payload.get("skip"):
result.skipped = True
result.skip_reason = str(payload.get("reason") or "model declined")
return result
code = _clean_code(payload)
if not code:
result.skipped = True
result.skip_reason = "model returned no Lean code"
return result
result.lean_code = code
result.imports = _imports(payload)
result.name = str(payload.get("name") or "").strip()
result.informal = str(payload.get("informal") or "").strip()
result.has_proof = bool(payload.get("has_proof")) and not contains_sorry(code)
try:
result.confidence = float(payload.get("confidence") or 0.0)
except (TypeError, ValueError):
result.confidence = 0.0
extra = payload.get("index_types")
result.index_types = index_types(code, extra if isinstance(extra, list) else [])
if checker is None:
return result
result.verify = checker.check(code, mode=mode, imports=result.imports)
if result.verify.ok or result.verify.skipped or attempt >= repair_attempts:
return result
messages = messages + [
{"role": "assistant", "content": _echo(payload, code)},
{
"role": "user",
"content": REPAIR_PROMPT.format(
diagnostics=(result.verify.diagnostics or "")[-3000:]
),
},
]
return result
def _echo(payload: dict, code: str) -> str:
import json
return json.dumps({**payload, "lean_code": code}, ensure_ascii=False)[:6000]
def formalize_all(
client: Any,
detections: list[Detection],
*,
checker: Any = None,
document_title: str = "",
repair_attempts: int = 1,
progress: Callable[[str], None] | None = None,
on_error: str = "skip",
) -> list[Formalization]:
results: list[Formalization] = []
for position, detection in enumerate(detections, 1):
try:
formalization = formalize(
client,
detection,
checker=checker,
document_title=document_title,
repair_attempts=repair_attempts,
)
except Exception as error: # noqa: BLE001 - one bad item must not kill a book
if on_error == "raise":
raise
formalization = Formalization(
detection=detection, skipped=True, skip_reason=f"formalizer failed: {error}"
)
results.append(formalization)
if progress:
status = (
"skipped"
if formalization.skipped
else "proved"
if formalization.proved
else "verified"
if formalization.verified
else "unverified"
)
progress(
f" [{position}/{len(detections)}] {detection.label or detection.kind} "
f"@ {detection.locator}: {status}"
)
return results
def as_dicts(formalizations: list[Formalization]) -> list[dict]:
return [f.as_dict() for f in formalizations]
__all__ = [
"Formalization",
"formalize",
"formalize_all",
"verification_mode",
"as_dicts",
"asdict",
]