598f7fd662
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.
One Docker layer per Lean step, each long one under `timeout`:
the gigabyte cache fetch earns a layer that a later failure never
invalidates, and a stalled step fails with a message instead of
sitting silent for an hour. There is deliberately no `lake build`
of our own library -- verification runs `lake env lean` against
Mathlib's oleans and needs nothing else, and building the library
was pure cost. 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 build runs with --progress=plain, because BuildKit's
default renderer collapses a long step into one rewritten line
and a stall then looks exactly like progress.
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
432 lines
17 KiB
Python
432 lines
17 KiB
Python
"""Step 4: the indexed store.
|
|
|
|
SQLite, one file, real indexes. Every item records
|
|
|
|
* the Lean code and what Lean said about it,
|
|
* the types it is indexed under -- its inputs, or its own type when it is not a
|
|
proof -- which is what makes search by relevance possible,
|
|
* which book it came from (by content hash) and which page it starts on,
|
|
|
|
so re-running any part of any source replaces exactly the old rows and nothing else:
|
|
``delete_source`` for a whole book, ``delete_pages`` for a page range.
|
|
|
|
``export_jsonl`` writes the same rows out as text, which is what belongs in git.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sqlite3
|
|
from collections.abc import Iterable, Iterator, Sequence
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
SCHEMA = """
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA foreign_keys = ON;
|
|
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sources (
|
|
hash TEXT PRIMARY KEY, -- sha256 of the source file
|
|
title TEXT NOT NULL DEFAULT '',
|
|
path TEXT NOT NULL DEFAULT '',
|
|
pages INTEGER NOT NULL DEFAULT 0,
|
|
extractor TEXT NOT NULL DEFAULT '',
|
|
ingested_at TEXT NOT NULL,
|
|
meta TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS items (
|
|
id TEXT PRIMARY KEY,
|
|
source_hash TEXT NOT NULL REFERENCES sources(hash) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL, -- 'statement' (case 1) | 'problem' (case 2)
|
|
label TEXT NOT NULL DEFAULT '',
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
informal TEXT NOT NULL DEFAULT '',
|
|
start_page INTEGER NOT NULL, -- the page an item is filed under
|
|
start_index INTEGER NOT NULL,
|
|
end_page INTEGER NOT NULL,
|
|
end_index INTEGER NOT NULL,
|
|
page_checksum TEXT NOT NULL DEFAULT '',
|
|
source_text TEXT NOT NULL DEFAULT '',
|
|
lean_name TEXT NOT NULL DEFAULT '',
|
|
lean_code TEXT NOT NULL DEFAULT '',
|
|
imports TEXT NOT NULL DEFAULT '[]',
|
|
has_proof INTEGER NOT NULL DEFAULT 0,
|
|
verified INTEGER NOT NULL DEFAULT 0,
|
|
proved INTEGER NOT NULL DEFAULT 0,
|
|
verify_mode TEXT NOT NULL DEFAULT '',
|
|
diagnostics TEXT NOT NULL DEFAULT '',
|
|
confidence REAL NOT NULL DEFAULT 0,
|
|
match_method TEXT NOT NULL DEFAULT '',
|
|
match_score REAL NOT NULL DEFAULT 0,
|
|
truncated INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS item_types (
|
|
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL, -- 'Nat', 'Polynomial', 'Group', ...
|
|
role TEXT NOT NULL, -- 'input' | 'goal' | 'own'
|
|
PRIMARY KEY (item_id, name, role)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_items_source_page ON items(source_hash, start_page);
|
|
CREATE INDEX IF NOT EXISTS idx_items_kind ON items(kind);
|
|
CREATE INDEX IF NOT EXISTS idx_items_verified ON items(verified, proved);
|
|
CREATE INDEX IF NOT EXISTS idx_types_name ON item_types(name);
|
|
CREATE INDEX IF NOT EXISTS idx_types_name_role ON item_types(name, role);
|
|
"""
|
|
|
|
# A standalone (not external-content) FTS index: it costs a second copy of the text
|
|
# and buys ordinary INSERT/DELETE, which external-content tables do not give you.
|
|
FTS_SCHEMA = """
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
|
|
id UNINDEXED, label, summary, informal, source_text, lean_code
|
|
);
|
|
"""
|
|
|
|
|
|
def now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def item_id(source_hash: str, start_page: int, start_index: int,
|
|
end_page: int, end_index: int) -> str:
|
|
"""Stable identity: same span of the same book is the same item, always."""
|
|
key = f"{source_hash}:{start_page}:{start_index}:{end_page}:{end_index}"
|
|
return hashlib.sha1(key.encode("utf-8")).hexdigest()[:24]
|
|
|
|
|
|
@dataclass
|
|
class ItemRow:
|
|
id: str
|
|
source_hash: str
|
|
kind: str
|
|
label: str
|
|
summary: str
|
|
informal: str
|
|
start_page: int
|
|
start_index: int
|
|
end_page: int
|
|
end_index: int
|
|
page_checksum: str
|
|
source_text: str
|
|
lean_name: str
|
|
lean_code: str
|
|
imports: list[str]
|
|
has_proof: bool
|
|
verified: bool
|
|
proved: bool
|
|
verify_mode: str
|
|
diagnostics: str
|
|
confidence: float
|
|
match_method: str
|
|
match_score: float
|
|
truncated: bool
|
|
types: list[tuple[str, str]]
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
data = {k: v for k, v in self.__dict__.items() if k != "types"}
|
|
data["types"] = [{"name": n, "role": r} for n, r in self.types]
|
|
return data
|
|
|
|
|
|
class Database:
|
|
"""Thin, explicit wrapper. No ORM, no magic, no surprises at 3am."""
|
|
|
|
def __init__(self, path: str | Path = "database/leanbench.sqlite") -> None:
|
|
self.path = Path(path)
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.connection = sqlite3.connect(str(self.path))
|
|
self.connection.row_factory = sqlite3.Row
|
|
self.connection.executescript(SCHEMA)
|
|
self.fts = self._try_fts()
|
|
self.connection.execute(
|
|
"INSERT INTO meta(key, value) VALUES('schema_version', ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
(str(SCHEMA_VERSION),),
|
|
)
|
|
self.connection.commit()
|
|
|
|
def _try_fts(self) -> bool:
|
|
try:
|
|
self.connection.executescript(FTS_SCHEMA)
|
|
return True
|
|
except sqlite3.OperationalError:
|
|
return False # SQLite built without FTS5; everything else still works
|
|
|
|
def close(self) -> None:
|
|
self.connection.close()
|
|
|
|
def __enter__(self) -> Database:
|
|
return self
|
|
|
|
def __exit__(self, *exc: object) -> None:
|
|
self.close()
|
|
|
|
@contextmanager
|
|
def transaction(self) -> Iterator[sqlite3.Connection]:
|
|
try:
|
|
yield self.connection
|
|
self.connection.commit()
|
|
except Exception:
|
|
self.connection.rollback()
|
|
raise
|
|
|
|
# -- sources -----------------------------------------------------------
|
|
|
|
def upsert_source(
|
|
self,
|
|
hash: str,
|
|
*,
|
|
title: str = "",
|
|
path: str = "",
|
|
pages: int = 0,
|
|
extractor: str = "",
|
|
meta: dict | None = None,
|
|
) -> None:
|
|
with self.transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO sources(hash, title, path, pages, extractor, ingested_at, meta)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(hash) DO UPDATE SET
|
|
title = excluded.title, path = excluded.path, pages = excluded.pages,
|
|
extractor = excluded.extractor, ingested_at = excluded.ingested_at,
|
|
meta = excluded.meta
|
|
""",
|
|
(hash, title, path, pages, extractor, now(),
|
|
json.dumps(meta or {}, ensure_ascii=False)),
|
|
)
|
|
|
|
def sources(self) -> list[dict[str, Any]]:
|
|
rows = self.connection.execute(
|
|
"""
|
|
SELECT s.*, (SELECT COUNT(*) FROM items i WHERE i.source_hash = s.hash) AS items
|
|
FROM sources s ORDER BY s.ingested_at DESC
|
|
"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
# -- writing items -----------------------------------------------------
|
|
|
|
def upsert_item(self, row: ItemRow) -> str:
|
|
with self.transaction() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO items(
|
|
id, source_hash, kind, label, summary, informal,
|
|
start_page, start_index, end_page, end_index, page_checksum,
|
|
source_text, lean_name, lean_code, imports, has_proof, verified,
|
|
proved, verify_mode, diagnostics, confidence, match_method,
|
|
match_score, truncated, created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
kind=excluded.kind, label=excluded.label, summary=excluded.summary,
|
|
informal=excluded.informal, page_checksum=excluded.page_checksum,
|
|
source_text=excluded.source_text, lean_name=excluded.lean_name,
|
|
lean_code=excluded.lean_code, imports=excluded.imports,
|
|
has_proof=excluded.has_proof, verified=excluded.verified,
|
|
proved=excluded.proved, verify_mode=excluded.verify_mode,
|
|
diagnostics=excluded.diagnostics, confidence=excluded.confidence,
|
|
match_method=excluded.match_method, match_score=excluded.match_score,
|
|
truncated=excluded.truncated, created_at=excluded.created_at
|
|
""",
|
|
(
|
|
row.id, row.source_hash, row.kind, row.label, row.summary, row.informal,
|
|
row.start_page, row.start_index, row.end_page, row.end_index,
|
|
row.page_checksum, row.source_text, row.lean_name, row.lean_code,
|
|
json.dumps(row.imports, ensure_ascii=False),
|
|
int(row.has_proof), int(row.verified), int(row.proved), row.verify_mode,
|
|
row.diagnostics[-4000:], float(row.confidence), row.match_method,
|
|
float(row.match_score), int(row.truncated), now(),
|
|
),
|
|
)
|
|
connection.execute("DELETE FROM item_types WHERE item_id = ?", (row.id,))
|
|
connection.executemany(
|
|
"INSERT OR IGNORE INTO item_types(item_id, name, role) VALUES(?,?,?)",
|
|
[(row.id, name, role) for name, role in row.types],
|
|
)
|
|
self._reindex(row.id)
|
|
return row.id
|
|
|
|
def _reindex(self, item_id_: str) -> None:
|
|
if not self.fts:
|
|
return
|
|
with self.transaction() as connection:
|
|
connection.execute("DELETE FROM items_fts WHERE id = ?", (item_id_,))
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO items_fts(id, label, summary, informal, source_text, lean_code)
|
|
SELECT id, label, summary, informal, source_text, lean_code
|
|
FROM items WHERE id = ?
|
|
""",
|
|
(item_id_,),
|
|
)
|
|
|
|
# -- removing, so a source can be redone -------------------------------
|
|
|
|
def delete_source(self, source_hash: str) -> int:
|
|
with self.transaction() as connection:
|
|
cursor = connection.execute("DELETE FROM items WHERE source_hash = ?", (source_hash,))
|
|
connection.execute("DELETE FROM sources WHERE hash = ?", (source_hash,))
|
|
return cursor.rowcount
|
|
|
|
def delete_pages(self, source_hash: str, first_page: int, last_page: int) -> int:
|
|
"""Drop items *filed under* pages in this range, so those pages can be redone."""
|
|
with self.transaction() as connection:
|
|
cursor = connection.execute(
|
|
"DELETE FROM items WHERE source_hash = ? AND start_page BETWEEN ? AND ?",
|
|
(source_hash, first_page, last_page),
|
|
)
|
|
return cursor.rowcount
|
|
|
|
# -- reading -----------------------------------------------------------
|
|
|
|
def _types_for(self, ids: Sequence[str]) -> dict[str, list[tuple[str, str]]]:
|
|
if not ids:
|
|
return {}
|
|
marks = ",".join("?" * len(ids))
|
|
rows = self.connection.execute(
|
|
f"SELECT item_id, name, role FROM item_types WHERE item_id IN ({marks})", tuple(ids)
|
|
).fetchall()
|
|
out: dict[str, list[tuple[str, str]]] = {}
|
|
for row in rows:
|
|
out.setdefault(row["item_id"], []).append((row["name"], row["role"]))
|
|
return out
|
|
|
|
def _hydrate(self, rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]:
|
|
rows = list(rows)
|
|
types = self._types_for([row["id"] for row in rows])
|
|
items = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item["imports"] = json.loads(item.get("imports") or "[]")
|
|
item["types"] = [{"name": n, "role": r} for n, r in types.get(row["id"], [])]
|
|
items.append(item)
|
|
return items
|
|
|
|
def get(self, id_: str) -> dict[str, Any] | None:
|
|
row = self.connection.execute("SELECT * FROM items WHERE id = ?", (id_,)).fetchone()
|
|
return self._hydrate([row])[0] if row else None
|
|
|
|
def search(
|
|
self,
|
|
*,
|
|
types: Sequence[str] = (),
|
|
roles: Sequence[str] = (),
|
|
kind: str | None = None,
|
|
source_hash: str | None = None,
|
|
page: int | None = None,
|
|
verified: bool | None = None,
|
|
proved: bool | None = None,
|
|
text: str | None = None,
|
|
match_all_types: bool = False,
|
|
limit: int = 50,
|
|
) -> list[dict[str, Any]]:
|
|
"""The point of the index: find items by the types they are about."""
|
|
where: list[str] = []
|
|
params: list[Any] = []
|
|
|
|
if types:
|
|
marks = ",".join("?" * len(types))
|
|
clause = (
|
|
f"SELECT item_id FROM item_types WHERE name IN ({marks})"
|
|
)
|
|
params.extend(types)
|
|
if roles:
|
|
clause += " AND role IN (" + ",".join("?" * len(roles)) + ")"
|
|
params.extend(roles)
|
|
if match_all_types:
|
|
clause += " GROUP BY item_id HAVING COUNT(DISTINCT name) = ?"
|
|
params.append(len(set(types)))
|
|
where.append(f"i.id IN ({clause})")
|
|
if kind:
|
|
where.append("i.kind = ?")
|
|
params.append(kind)
|
|
if source_hash:
|
|
where.append("i.source_hash = ?")
|
|
params.append(source_hash)
|
|
if page is not None:
|
|
where.append("? BETWEEN i.start_page AND i.end_page")
|
|
params.append(page)
|
|
if verified is not None:
|
|
where.append("i.verified = ?")
|
|
params.append(int(verified))
|
|
if proved is not None:
|
|
where.append("i.proved = ?")
|
|
params.append(int(proved))
|
|
if text:
|
|
if self.fts:
|
|
where.append("i.id IN (SELECT id FROM items_fts WHERE items_fts MATCH ?)")
|
|
params.append(text)
|
|
else:
|
|
where.append("(i.source_text LIKE ? OR i.lean_code LIKE ? OR i.label LIKE ?)")
|
|
params.extend([f"%{text}%"] * 3)
|
|
|
|
sql = "SELECT i.* FROM items i"
|
|
if where:
|
|
sql += " WHERE " + " AND ".join(where)
|
|
sql += " ORDER BY i.proved DESC, i.verified DESC, i.start_page, i.start_index LIMIT ?"
|
|
params.append(limit)
|
|
return self._hydrate(self.connection.execute(sql, params).fetchall())
|
|
|
|
def type_counts(self, *, role: str | None = None, limit: int = 40) -> list[dict[str, Any]]:
|
|
sql = "SELECT name, role, COUNT(*) AS items FROM item_types"
|
|
params: list[Any] = []
|
|
if role:
|
|
sql += " WHERE role = ?"
|
|
params.append(role)
|
|
sql += " GROUP BY name, role ORDER BY items DESC LIMIT ?"
|
|
params.append(limit)
|
|
return [dict(row) for row in self.connection.execute(sql, params).fetchall()]
|
|
|
|
def stats(self) -> dict[str, Any]:
|
|
one = self.connection.execute(
|
|
"""
|
|
SELECT COUNT(*) AS items,
|
|
SUM(kind = 'statement') AS statements,
|
|
SUM(kind = 'problem') AS problems,
|
|
SUM(verified) AS verified,
|
|
SUM(proved) AS proved
|
|
FROM items
|
|
"""
|
|
).fetchone()
|
|
stats = {k: (one[k] or 0) for k in one.keys()}
|
|
stats["sources"] = self.connection.execute("SELECT COUNT(*) FROM sources").fetchone()[0]
|
|
stats["types"] = self.connection.execute(
|
|
"SELECT COUNT(DISTINCT name) FROM item_types"
|
|
).fetchone()[0]
|
|
return stats
|
|
|
|
# -- export ------------------------------------------------------------
|
|
|
|
def export_jsonl(self, path: str | Path, *, source_hash: str | None = None,
|
|
include_source_text: bool = False) -> int:
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
sql = "SELECT * FROM items"
|
|
params: tuple = ()
|
|
if source_hash:
|
|
sql += " WHERE source_hash = ?"
|
|
params = (source_hash,)
|
|
sql += " ORDER BY source_hash, start_page, start_index"
|
|
rows = self._hydrate(self.connection.execute(sql, params).fetchall())
|
|
with path.open("w", encoding="utf-8") as stream:
|
|
for row in rows:
|
|
if not include_source_text:
|
|
row.pop("source_text", None)
|
|
row.pop("diagnostics", None)
|
|
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
return len(rows)
|