Files
lean-bench/textbook/search_database.py
T
claude-bot 9e0457a0e1 Drop the generated search index; derive it from the source instead
The index file was 12KB of which ~96% was a byte-for-byte copy of
database/goodman-1.6.jsonl. Its only original content was three derived
fields: `name` (literally id.replace('-','_')), `status` (a two-line
computation over proof_checked), and `identifiers` (a regex over the
statement). It was a cache for work that costs microseconds, and it
created a real hazard: nothing checked that it matched its source, so
editing a statement and forgetting to regenerate would leave
search_database.py serving a premise whose text no longer matched the
generated .lean file.

- search_database.py now reads database/goodman-1.6.jsonl directly via a
  new load_database(), deriving name/identifiers/status on the fly and
  importing those functions from build_database.py so there is one
  definition of each rather than two that can drift.
- build_database.py drops --out-index and index generation, and its
  proof/status derivation is factored into resolve_proof() shared with
  retrieval.
- database/goodman-1.6.index.jsonl deleted.

Also fixes a retrieval bug the removal surfaced: the tokenizer only
matched ASCII identifiers, so a goal that is pure algebra
("a * (b + c) = a * b + a * c") tokenized to nothing and retrieved
nothing at all -- it now keeps type and operator symbols, and drops
single-letter variable names, which say nothing when matched. That goal
now correctly finds Proposition 1.6.1, and the identifier-rich goals
still rank their exact premise first by a wide margin (9.3 vs 4.4).

Tests restructured accordingly: the proof gate is now pinned directly on
resolve_proof in both directions, plus new coverage for load_database
deriving status from proof_checked and for the symbol tokenization.
49 tests, 48 green (the one failure remains the pre-existing unrelated
lean_agent import error in test_dataset.py).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vbLFFJiHSZqGZHEkjvG5x
2026-09-18 19:50:10 +00:00

146 lines
6.6 KiB
Python

"""Rank premises from the database against the current proof goals.
This is the retrieval step of the intended lean-agent loop: rather than asking the
LLM to prove a target in one shot, the agent keeps a list of open goals (the proof
state, as vscode-lean4 displays it), and at each step searches the premise database
for entries that might apply to the goal in front of it. The ranked results are what
gets shown to the LLM alongside the goal, so the next line of Lean it writes can
cite a real premise by name instead of inventing a lemma.
It reads the database JSONL directly and derives each premise's declaration name,
identifiers and proof status on the fly, sharing those functions with
build_database.py. There is no precomputed index: at this scale deriving them costs
microseconds, and a second generated copy of the data would only create something
that can silently go stale against the source. A precomputed index earns its place
when it holds something genuinely expensive, i.e. embeddings.
Scoring here is deliberately simple and dependency-free: overlap of identifiers and
symbols between the goal and each premise, weighted so that a rare identifier
matters more than a common one (`IsGCDOfList` appearing in both is a much stronger
signal than `Int`), plus a small bonus for an already-proven premise over one merely
asserted as an axiom from the book. It is a ranking heuristic, not a decision
procedure: it narrows 20-odd premises to the few worth showing, and cannot tell you
whether a premise actually applies -- only Lean can. Swapping in embeddings later
means replacing score_premise() and nothing else.
"""
import argparse
import json
import math
import re
from collections import Counter
from pathlib import Path
# Qualified names (Nat.Prime) first so they aren't split, then bare identifiers, then
# the type and operator symbols that carry signal. Arithmetic operators are included
# deliberately: a goal that is pure algebra ("a * (b + c) = a * b + a * c") has no
# identifiers at all, and without these it would tokenize to nothing and retrieve
# nothing. They appear in nearly every premise, so inverse-document-frequency
# weighting already discounts them to almost zero -- they only decide ties.
TOKEN_RE = re.compile(
r'[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+'
r'|[A-Za-z_][A-Za-z0-9_]*'
r'|[∣∀∃↔→∧∨¬≤≥≠∈ℤℕℚℝ×∑∏]'
r'|[+*=/-]')
STOPWORDS = {'let', 'in', 'fun', 'if', 'then', 'else', 'true', 'false', 'Prop', 'Type', 'this'}
try:
from .build_database import decl_name, resolve_proof, statement_identifiers
except ImportError:
from build_database import decl_name, resolve_proof, statement_identifiers
def load_database(path, results=None):
"""Database entries in the shape search() ranks, derived from the source file."""
entries = [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
records = []
for entry in entries:
proof, status = resolve_proof(entry, (results or {}).get(entry['id']))
records.append({
"name": decl_name(entry['id']),
"id": entry['id'],
"source_label": entry.get('source_label'),
"kind": entry.get('kind'),
"status": status,
"proof": proof or entry.get('proof'),
"statement": entry['statement'],
"natural_language": entry.get('natural_language', '').split('[fix note:')[0].strip(),
"identifiers": statement_identifiers(entry['statement']),
"requires": entry.get('requires', []),
})
return records
SYMBOLS = set('∣∀∃↔→∧∨¬≤≥≠∈ℤℕℚℝ×∑∏+*=/-')
def tokenize(text):
"""Identifiers and meaningful symbols. Single-letter variable names are dropped
(matching `a` against `a` says nothing), but single-character symbols are kept."""
return [t for t in TOKEN_RE.findall(text or '')
if t not in STOPWORDS and (len(t) > 1 or t in SYMBOLS)]
def document_frequencies(records):
"""How many premises each token appears in, for inverse-document-frequency weighting."""
frequencies = Counter()
for record in records:
for token in set(tokenize(record['statement']) + record.get('identifiers', [])
+ record.get('requires', [])):
frequencies[token] += 1
return frequencies
def score_premise(goal_tokens, record, frequencies, total):
premise_tokens = set(tokenize(record['statement']) + record.get('identifiers', [])
+ record.get('requires', []))
score = 0.0
for token in set(goal_tokens) & premise_tokens:
# Rare tokens are far more informative than ones every premise mentions.
score += math.log(1 + total / (1 + frequencies.get(token, 0)))
if record.get('status') == 'proved':
score *= 1.1 # prefer a real theorem over an axiom asserted from the book
return score
def search(records, goal, limit=5):
"""Premises ranked against one goal, best first. Zero-score entries are dropped."""
frequencies = document_frequencies(records)
total = max(len(records), 1)
goal_tokens = tokenize(goal)
scored = []
for record in records:
score = score_premise(goal_tokens, record, frequencies, total)
if score > 0:
scored.append((score, record))
scored.sort(key=lambda pair: (-pair[0], pair[1]['name']))
return [{**record, "score": round(score, 3)} for score, record in scored[:limit]]
def format_for_prompt(hits):
"""The premise list as it would be shown to the LLM next to the current goal."""
if not hits:
return '(no database premise looks relevant to this goal)'
lines = []
for hit in hits:
status = 'proven' if hit['status'] == 'proved' else 'asserted from the book (axiom)'
lines.append(f"{hit['name']} [{hit['source_label']}, {status}, score {hit['score']}]\n"
f" {hit['statement']}")
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description='Rank database premises against a proof goal')
parser.add_argument('database', type=Path, help='database JSONL, e.g. database/goodman-1.6.jsonl')
parser.add_argument('goal', help='the current goal, as Lean prints it')
parser.add_argument('--limit', type=int, default=5)
parser.add_argument('--json', action='store_true', help='emit raw JSON instead of prompt text')
args = parser.parse_args()
hits = search(load_database(args.database), args.goal, args.limit)
print(json.dumps(hits, ensure_ascii=False, indent=2) if args.json else format_for_prompt(hits))
if __name__ == '__main__':
main()