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.
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
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""Turning quoted snippets back into exact character offsets.
|
||||
|
||||
Language models are bad at counting characters and good at quoting. So the detector
|
||||
asks for short verbatim quotes at the start and end of each item, and this module
|
||||
converts those quotes into hard indices into the page text:
|
||||
|
||||
1. exact substring match;
|
||||
2. whitespace-insensitive match (PDF text is full of stray line breaks);
|
||||
3. fuzzy match with difflib, above a similarity floor.
|
||||
|
||||
Anything that fails all three is reported as unresolved rather than guessed at.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
_WS = re.compile(r"\s+")
|
||||
MIN_RATIO = 0.72
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Match:
|
||||
start: int
|
||||
end: int
|
||||
method: str
|
||||
score: float
|
||||
|
||||
|
||||
def _collapse(text: str) -> tuple[str, list[int]]:
|
||||
"""Whitespace-collapsed text plus a map from collapsed index -> original index."""
|
||||
out: list[str] = []
|
||||
mapping: list[int] = []
|
||||
previous_space = True # strip leading whitespace
|
||||
for index, char in enumerate(text):
|
||||
if char.isspace():
|
||||
if previous_space:
|
||||
continue
|
||||
out.append(" ")
|
||||
mapping.append(index)
|
||||
previous_space = True
|
||||
else:
|
||||
out.append(char)
|
||||
mapping.append(index)
|
||||
previous_space = False
|
||||
return "".join(out), mapping
|
||||
|
||||
|
||||
def find(text: str, needle: str, start_at: int = 0) -> Match | None:
|
||||
"""Locate ``needle`` in ``text`` at or after ``start_at``."""
|
||||
needle = (needle or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
|
||||
exact = text.find(needle, start_at)
|
||||
if exact != -1:
|
||||
return Match(exact, exact + len(needle), "exact", 1.0)
|
||||
|
||||
haystack, mapping = _collapse(text)
|
||||
if not haystack:
|
||||
return None
|
||||
probe, _ = _collapse(needle)
|
||||
if not probe:
|
||||
return None
|
||||
|
||||
# where in the collapsed text does start_at land?
|
||||
floor = 0
|
||||
for collapsed_index, original_index in enumerate(mapping):
|
||||
if original_index >= start_at:
|
||||
floor = collapsed_index
|
||||
break
|
||||
else:
|
||||
floor = len(mapping)
|
||||
|
||||
loose = haystack.find(probe, floor)
|
||||
if loose == -1 and floor: # allow wrapping back if nothing ahead
|
||||
loose = haystack.find(probe)
|
||||
if loose != -1:
|
||||
start = mapping[loose]
|
||||
last = min(loose + len(probe) - 1, len(mapping) - 1)
|
||||
return Match(start, mapping[last] + 1, "whitespace", 1.0)
|
||||
|
||||
best = _fuzzy(haystack, probe, floor)
|
||||
if best is None:
|
||||
return None
|
||||
lo, hi, score = best
|
||||
last = min(hi - 1, len(mapping) - 1)
|
||||
return Match(mapping[lo], mapping[last] + 1, "fuzzy", score)
|
||||
|
||||
|
||||
def _fuzzy(haystack: str, probe: str, floor: int) -> tuple[int, int, float] | None:
|
||||
"""Slide a window of the probe's length and keep the best-scoring position."""
|
||||
size = len(probe)
|
||||
if size < 8 or len(haystack) < size:
|
||||
return None
|
||||
step = max(1, size // 4)
|
||||
matcher = difflib.SequenceMatcher(autojunk=False, b=probe)
|
||||
best: tuple[int, int, float] | None = None
|
||||
for start in range(floor, len(haystack) - size + 1, step):
|
||||
window = haystack[start : start + size]
|
||||
matcher.set_seq1(window)
|
||||
if matcher.real_quick_ratio() < MIN_RATIO or matcher.quick_ratio() < MIN_RATIO:
|
||||
continue
|
||||
score = matcher.ratio()
|
||||
if score >= MIN_RATIO and (best is None or score > best[2]):
|
||||
best = (start, start + size, score)
|
||||
if score > 0.97:
|
||||
break
|
||||
if best is None:
|
||||
return None
|
||||
# refine around the coarse hit
|
||||
lo, hi, score = best
|
||||
for start in range(max(floor, lo - step), min(len(haystack) - size, lo + step) + 1):
|
||||
window = haystack[start : start + size]
|
||||
matcher.set_seq1(window)
|
||||
candidate = matcher.ratio()
|
||||
if candidate > score:
|
||||
lo, hi, score = start, start + size, candidate
|
||||
return lo, hi, score
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Span:
|
||||
start: int
|
||||
end: int
|
||||
method: str
|
||||
score: float
|
||||
|
||||
@property
|
||||
def resolved(self) -> bool:
|
||||
return self.end > self.start
|
||||
|
||||
|
||||
def resolve_span(
|
||||
text: str,
|
||||
start_quote: str,
|
||||
end_quote: str,
|
||||
*,
|
||||
search_from: int = 0,
|
||||
fallback_length: int = 1200,
|
||||
) -> Span | None:
|
||||
"""Resolve a (start quote, end quote) pair into one span of ``text``."""
|
||||
head = find(text, start_quote, search_from)
|
||||
if head is None:
|
||||
return None
|
||||
tail = find(text, end_quote, head.start)
|
||||
if tail is None or tail.end <= head.start:
|
||||
# The tail quote is the fragile one (models paraphrase endings). Rather than
|
||||
# dropping a real item, fall back to a bounded chunk from the start quote.
|
||||
end = min(len(text), head.start + fallback_length)
|
||||
return Span(head.start, end, head.method + "+truncated", head.score * 0.5)
|
||||
method = head.method if head.method == tail.method else f"{head.method}/{tail.method}"
|
||||
return Span(head.start, tail.end, method, min(head.score, tail.score))
|
||||
Reference in New Issue
Block a user