Files
claude-bot 598f7fd662
ci / test (push) Successful in 1m10s
ci / lean-project (push) Successful in 6s
docker / image (push) Successful in 3m6s
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.
                   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
2026-09-19 16:22:09 +00:00

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)