d3555d9a52
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.
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)
|