b9e37952e6
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.
Infrastructure:
- 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, so the build does not rot.
docker/smoke.py runs the whole pipeline inside the image against
the real compiler, with the LLM scripted.
- .gitea/workflows ci (lint + tests in a venv, since the runner image's Python is
Debian-managed and refuses installs into itself), docker (build,
verify, optionally push), ingest (submit a book by hand).
The docker workflow is not a throwaway build: it leaves the image, the layer cache,
an idle `lean-bench` container and the `lean-bench-data` / `lean-bench-books`
volumes in place on the runner, and never prunes, so the environment is there to be
used afterwards. Pushing to the registry is optional and cannot fail the build --
Gitea's container registry does not accept the built-in Actions token, so that needs
a REGISTRY_TOKEN secret with write:package.
58 tests; they need neither network nor Lean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci5T3gWDhhmqDec3e1XtYZ
182 lines
6.4 KiB
Python
182 lines
6.4 KiB
Python
"""The whole run, in order: load -> detect -> formalize -> verify -> store.
|
|
|
|
``ingest`` is what the CLI, the Docker entrypoint and CI all call. Re-ingesting a
|
|
book (or just some of its pages) deletes exactly the rows those pages produced last
|
|
time and writes the new ones, so a source can be redone without collateral damage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .config import Settings
|
|
from .db import Database, ItemRow, item_id
|
|
from .detect import DetectionReport, detect_document
|
|
from .document import Document
|
|
from .formalize import Formalization, formalize_all, verification_mode
|
|
from .llm import build_client
|
|
from .verify import LeanChecker, NullChecker
|
|
|
|
|
|
@dataclass
|
|
class IngestResult:
|
|
document: Document
|
|
report: DetectionReport
|
|
formalizations: list[Formalization] = field(default_factory=list)
|
|
stored: list[str] = field(default_factory=list)
|
|
removed: int = 0
|
|
|
|
def summary(self) -> dict[str, Any]:
|
|
verified = sum(1 for f in self.formalizations if f.verified)
|
|
proved = sum(1 for f in self.formalizations if f.proved)
|
|
skipped = sum(1 for f in self.formalizations if f.skipped)
|
|
return {
|
|
"source": self.document.title,
|
|
"source_hash": self.document.sha256,
|
|
"pages": len(self.document),
|
|
"detected": len(self.report.detections),
|
|
"statements": sum(1 for d in self.report.detections if d.kind == "statement"),
|
|
"problems": sum(1 for d in self.report.detections if d.kind == "problem"),
|
|
"multi_page": sum(1 for d in self.report.detections if d.spans_pages > 1),
|
|
"unresolved": len(self.report.unresolved),
|
|
"formalized": len(self.formalizations) - skipped,
|
|
"skipped": skipped,
|
|
"verified": verified,
|
|
"proved": proved,
|
|
"stored": len(self.stored),
|
|
"removed": self.removed,
|
|
}
|
|
|
|
|
|
def build_checker(settings: Settings, *, enabled: bool = True):
|
|
if not enabled:
|
|
return NullChecker()
|
|
checker = LeanChecker.from_settings(settings.lean)
|
|
return checker if checker.available() else NullChecker()
|
|
|
|
|
|
def to_row(document: Document, formalization: Formalization) -> ItemRow:
|
|
detection = formalization.detection
|
|
identifier = item_id(
|
|
document.sha256,
|
|
detection.start_page,
|
|
detection.start_index,
|
|
detection.end_page,
|
|
detection.end_index,
|
|
)
|
|
verify = formalization.verify
|
|
return ItemRow(
|
|
id=identifier,
|
|
source_hash=document.sha256,
|
|
kind=detection.kind,
|
|
label=detection.label,
|
|
summary=detection.summary,
|
|
informal=formalization.informal,
|
|
start_page=detection.start_page,
|
|
start_index=detection.start_index,
|
|
end_page=detection.end_page,
|
|
end_index=detection.end_index,
|
|
page_checksum=document.page(detection.start_page).checksum,
|
|
source_text=detection.text,
|
|
lean_name=formalization.name,
|
|
lean_code=formalization.lean_code,
|
|
imports=formalization.imports,
|
|
has_proof=formalization.has_proof,
|
|
verified=bool(verify and verify.ok),
|
|
proved=bool(verify and verify.proved),
|
|
verify_mode=verify.mode if verify else verification_mode(detection),
|
|
diagnostics=(verify.diagnostics if verify else ""),
|
|
confidence=formalization.confidence,
|
|
match_method=detection.match_method,
|
|
match_score=detection.match_score,
|
|
truncated=detection.truncated,
|
|
types=formalization.index_types,
|
|
)
|
|
|
|
|
|
def store(database: Database, document: Document,
|
|
formalizations: Sequence[Formalization]) -> list[str]:
|
|
database.upsert_source(
|
|
document.sha256,
|
|
title=document.title,
|
|
path=str(document.path),
|
|
pages=len(document),
|
|
extractor=document.extractor,
|
|
meta=document.meta,
|
|
)
|
|
stored: list[str] = []
|
|
for formalization in formalizations:
|
|
if formalization.skipped or not formalization.lean_code:
|
|
continue
|
|
stored.append(database.upsert_item(to_row(document, formalization)))
|
|
return stored
|
|
|
|
|
|
def ingest(
|
|
source: str | Path,
|
|
*,
|
|
settings: Settings | None = None,
|
|
client: Any = None,
|
|
database: Database | None = None,
|
|
checker: Any = None,
|
|
first_page: int | None = None,
|
|
last_page: int | None = None,
|
|
title: str = "",
|
|
replace: bool = True,
|
|
store_results: bool = True,
|
|
progress: Callable[[str], None] | None = None,
|
|
) -> IngestResult:
|
|
settings = settings or Settings.from_env()
|
|
client = client or build_client(settings.llm)
|
|
checker = checker if checker is not None else build_checker(settings)
|
|
owns_database = database is None
|
|
database = database or Database(settings.database)
|
|
|
|
say = progress or (lambda _message: None)
|
|
document = Document.load(source, title=title)
|
|
say(f"{document.title}: {len(document)} pages, sha256 {document.sha256[:12]}, "
|
|
f"extractor {document.extractor}")
|
|
|
|
result = IngestResult(document=document, report=DetectionReport())
|
|
try:
|
|
if store_results and replace:
|
|
if first_page or last_page:
|
|
result.removed = database.delete_pages(
|
|
document.sha256, first_page or 1, last_page or len(document)
|
|
)
|
|
else:
|
|
result.removed = database.delete_source(document.sha256)
|
|
if result.removed:
|
|
say(f"removed {result.removed} item(s) from the previous run")
|
|
|
|
say("detecting formalizable passages...")
|
|
result.report = detect_document(
|
|
client,
|
|
document,
|
|
first_page=first_page,
|
|
last_page=last_page,
|
|
max_window_pages=settings.max_window_pages,
|
|
progress=progress,
|
|
)
|
|
say(result.report.summary())
|
|
|
|
say("formalizing...")
|
|
result.formalizations = formalize_all(
|
|
client,
|
|
result.report.detections,
|
|
checker=checker,
|
|
document_title=document.title,
|
|
repair_attempts=settings.repair_attempts,
|
|
progress=progress,
|
|
)
|
|
|
|
if store_results:
|
|
result.stored = store(database, document, result.formalizations)
|
|
say(f"stored {len(result.stored)} item(s) in {database.path}")
|
|
finally:
|
|
if owns_database:
|
|
database.close()
|
|
return result
|