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
This commit is contained in:
2026-09-18 19:50:10 +00:00
parent fbcb0c3383
commit 9e0457a0e1
6 changed files with 230 additions and 128 deletions
-21
View File
@@ -1,21 +0,0 @@
{"name": "goodman_1_6_1", "id": "goodman-1-6-1", "source_label": "Proposition 1.6.1", "kind": "proposition", "status": "proof_pending", "proof": "intro a b c\nrefine ⟨by ring, by ring, by ring, by ring, by ring, by ring, by ring, by ring, ?_⟩\nintro x y h\nexact ⟨fun hx => h (by simp [hx]), fun hy => h (by simp [hy])⟩", "statement": "∀ (a b c : ℤ), (a + b = b + a) ∧ (a + (b + c) = (a + b) + c) ∧ (0 + a = a) ∧ (a + (-a) = 0) ∧ (a * b = b * a) ∧ (a * (b * c) = (a * b) * c) ∧ (1 * a = a) ∧ (a * (b + c) = a * b + a * c) ∧ (∀ (x y : ℤ), x * y ≠ 0 → x ≠ 0 ∧ y ≠ 0)", "natural_language": "Addition and multiplication on the integers are commutative and associative, 0 and 1 are their respective identities, the distributive law holds, and a product of integers is nonzero iff both factors are nonzero.", "identifiers": [], "requires": ["Int.add_comm", "Int.add_assoc", "Int.zero_add", "Int.add_left_neg", "Int.mul_comm", "Int.mul_assoc", "Int.one_mul", "Int.mul_add", "Int.mul_ne_zero"]}
{"name": "goodman_1_6_4", "id": "goodman-1-6-4", "source_label": "Proposition 1.6.4", "kind": "proposition", "status": "proof_pending", "proof": "intro n hn\nobtain ⟨p, hp, hpd⟩ := Nat.exists_prime_and_dvd (show n ≠ 1 by omega)\nexact ⟨p, hp, hpd⟩", "statement": "∀ (n : ℕ), n > 1 → ∃ (p : ℕ) (h : Nat.Prime p), p ∣ n", "natural_language": "Any natural number greater than 1 can be written as a product of prime numbers.", "identifiers": ["Nat.Prime"], "requires": ["Nat.exists_prime_factor"]}
{"name": "goodman_1_6_6", "id": "goodman-1-6-6", "source_label": "Theorem 1.6.6", "kind": "theorem", "status": "asserted", "proof": null, "statement": "∃ (p : ℕ → ℕ), ∀ (n : ℕ), Nat.Prime (p n) ∧ ∀ (i j : ℕ), i ≠ j → p i ≠ p j", "natural_language": "There are infinitely many prime numbers.", "identifiers": ["Nat.Prime"], "requires": ["Nat.infinite_primes"]}
{"name": "goodman_1_6_7", "id": "goodman-1-6-7", "source_label": "Proposition 1.6.7", "kind": "proposition", "status": "asserted", "proof": null, "statement": "∀ (a d : ℤ), d ≥ 1 → ∃! qr : ℤ × ℤ, a = qr.1 * d + qr.2 ∧ 0 ≤ qr.2 ∧ qr.2 < d", "natural_language": "Given integers a and d with d ≥ 1, there exist unique integers q and r such that a = qd + r and 0 ≤ r < d.", "identifiers": ["qr"], "requires": ["Int.division_theorem"]}
{"name": "goodman_1_6_9", "id": "goodman-1-6-9", "source_label": "Proposition 1.6.9", "kind": "proposition", "status": "asserted", "proof": null, "statement": "∀ (m n : ℤ), let I := {v : ℤ | ∃ a b : ℤ, v = a * m + b * n}; (∀ x ∈ I, ∀ y ∈ I, x + y ∈ I) ∧ (∀ x ∈ I, -x ∈ I) ∧ (∀ x : ℤ, ∀ z ∈ I, x * z ∈ I) ∧ (∀ b : ℤ, b ∣ m ∧ b ∣ n → ∀ z ∈ I, b ∣ z)", "natural_language": "I(m,n) = {am+bn} is closed under addition and negation, absorbs multiplication by any integer, and any common divisor of m and n divides every element of I(m,n).", "identifiers": [], "requires": []}
{"name": "goodman_1_6_10", "id": "goodman-1-6-10", "source_label": "Lemma 1.6.10", "kind": "lemma", "status": "asserted", "proof": null, "statement": "∀ (m n d : ℤ), d ∣ m ∧ d ∣ n ∧ d ∈ {v : ℤ | ∃ a b : ℤ, v = a * m + b * n} → ∀ (x : ℤ), x ∣ m ∧ x ∣ n → x ∣ d", "natural_language": "If a natural number d is a common divisor of m and n and an element of I(m, n), then d is the greatest common divisor of m and n.", "identifiers": [], "requires": ["Int.gcd_is_greatest_common_divisor"]}
{"name": "goodman_1_6_11", "id": "goodman-1-6-11", "source_label": "Proposition 1.6.11", "kind": "proposition", "status": "asserted", "proof": null, "statement": "∀ (m n : ℤ), m ≠ 0 ∧ n ≠ 0 → let d := Nat.gcd (Int.natAbs m) (Int.natAbs n); (d : ℤ) = Int.gcd m n ∧ (d : ℤ) ∈ {v : ℤ | ∃ a b : ℤ, v = a * m + b * n}", "natural_language": "The natural number d, which is the greatest common divisor of the absolute values of m and n, is also the greatest common divisor of m and n, and d is an element of I(m, n).", "identifiers": ["Nat.gcd", "Int.natAbs", "Int.gcd"], "requires": ["Int.gcd_nat_abs", "Int.gcd_linear_combination"]}
{"name": "goodman_1_6_13", "id": "goodman-1-6-13", "source_label": "Corollary 1.6.13", "kind": "corollary", "status": "asserted", "proof": null, "statement": "∀ (m n : ℤ), m ≠ 0 → n ≠ 0 → let I := {v : ℤ | ∃ a b : ℤ, v = a * m + b * n}; let d := Int.gcd m n; IsLeast {x : ℤ | 0 < x ∧ x ∈ I} (d : ℤ) ∧ I = {v : ℤ | ∃ k : ℤ, v = k * d}", "natural_language": "gcd(m,n) is the least positive element of I(m,n), and I(m,n) is exactly the set of integer multiples of gcd(m,n).", "identifiers": ["Int.gcd", "IsLeast"], "requires": ["IsLeast", "Int.gcd"]}
{"name": "goodman_1_6_15", "id": "goodman-1-6-15", "source_label": "Corollary 1.6.15", "kind": "corollary", "status": "proof_pending", "proof": "intro m n _\nrw [Int.gcd_eq_one_iff_coprime]\nconstructor\n· intro h\n obtain ⟨u, v, huv⟩ := h\n exact ⟨u, v, huv⟩\n· intro h\n obtain ⟨s, t, hst⟩ := h\n exact ⟨s, t, hst⟩", "statement": "∀ (m n : ℤ), m ≠ 0 ∧ n ≠ 0 → Int.gcd m n = 1 ↔ ∃ (s t : ℤ), s * m + t * n = 1", "natural_language": "Two nonzero integers m and n are relatively prime if and only if there exist integers s and t such that sm + tn = 1.", "identifiers": ["Int.gcd"], "requires": ["Int.coprime_iff_gcd_eq_one"]}
{"name": "goodman_1_6_17", "id": "goodman-1-6-17", "source_label": "Corollary 1.6.17", "kind": "corollary", "status": "proof_pending", "proof": "intro a b x h\nexact IsCoprime.mul_dvd (Nat.isCoprime_iff_coprime.mpr h.1) h.2.1 h.2.2", "statement": "∀ (a b : ℕ) (x : ℤ), Nat.Coprime a b ∧ (a:ℤ) ∣ x ∧ (b:ℤ) ∣ x → (a:ℤ) * b ∣ x", "natural_language": "If a and b are relatively prime natural numbers and both divide an integer x, then their product ab also divides x.", "identifiers": ["Nat.Coprime"], "requires": ["Nat.coprime_mul_dvd"]}
{"name": "goodman_1_6_18", "id": "goodman-1-6-18", "source_label": "Proposition 1.6.18", "kind": "proposition", "status": "asserted", "proof": null, "statement": "∀ (p : ℕ) (a : ℤ), Nat.Prime p ∧ a ≠ 0 → (p:ℤ) ∣ a ∨ IsCoprime (p:ℤ) a", "natural_language": "If p is a prime number and a is any nonzero integer, then either p divides a or p and a are relatively prime.", "identifiers": ["Nat.Prime", "IsCoprime"], "requires": ["Int.prime_or_coprime"]}
{"name": "goodman_1_6_19", "id": "goodman-1-6-19", "source_label": "Proposition 1.6.19", "kind": "proposition", "status": "proof_pending", "proof": "intro p a b h hdvd\nexact Int.Prime.dvd_mul' h.1 hdvd", "statement": "∀ (p : ℕ) (a b : ℤ), Nat.Prime p ∧ a ≠ 0 ∧ b ≠ 0 → (p:ℤ) ∣ a * b → (p:ℤ) ∣ a ∨ (p:ℤ) ∣ b", "natural_language": "If p is a prime number and a and b are nonzero integers, and p divides the product ab, then p divides a or p divides b.", "identifiers": ["Nat.Prime"], "requires": ["Int.prime_dvd_mul"]}
{"name": "goodman_1_6_20", "id": "goodman-1-6-20", "source_label": "Corollary 1.6.20", "kind": "corollary", "status": "proof_pending", "proof": "intro p as hp _ h\nobtain ⟨a, ha, hpa⟩ := (Nat.Prime.prime_int hp).dvd_prod_iff.mp h\nexact ⟨a, ha, hpa⟩", "statement": "∀ (p : ℕ) (as : List ℤ), Nat.Prime p → (∀ (a : ℤ), a ∈ as → a ≠ 0) → (p:ℤ) ∣ List.prod as → ∃ (a : ℤ), a ∈ as ∧ (p:ℤ) ∣ a", "natural_language": "If a prime number p divides a product of nonzero integers, then p divides one of the factors.", "identifiers": ["as", "List", "Nat.Prime", "List.prod"], "requires": ["Int.prime_dvd_prod"]}
{"name": "goodman_1_6_21", "id": "goodman-1-6-21", "source_label": "Theorem 1.6.21", "kind": "theorem", "status": "asserted", "proof": null, "statement": "∀ (n : ℕ), n > 1 → ∃! (ps : List ℕ), (∀ (p : ℕ), p ∈ ps → Nat.Prime p) ∧ n = List.prod ps", "natural_language": "The prime factorization of a natural number is unique.", "identifiers": ["ps", "List", "Nat.Prime", "List.prod"], "requires": ["Nat.unique_prime_factorization"]}
{"name": "goodman_1_6_23", "id": "goodman-1-6-23", "source_label": "Lemma 1.6.23", "kind": "lemma", "status": "asserted", "proof": null, "statement": "∀ (n : ℕ) (a : Fin n → ℤ), 2 ≤ n → (∀ i, a i ≠ 0) → ∃ (d : ℕ) (Q : Matrix (Fin n) (Fin n) ℤ), IsUnit Q.det ∧ ∀ j : Fin n, Matrix.vecMul a Q j = if j.val = 0 then (d : ℤ) else 0", "natural_language": "Given n >= 2 nonzero integers a_1..a_n, there is a natural number d and a unimodular (det a unit, i.e. det = +-1) integer n-by-n matrix Q such that the row vector a times Q equals (d, 0, ..., 0).", "identifiers": ["Fin", "Matrix", "IsUnit", "Q.det", "Matrix.vecMul", "j.val"], "requires": ["Matrix.vecMul", "Matrix.det", "IsUnit"]}
{"name": "goodman_1_6_24", "id": "goodman-1-6-24", "source_label": "Proposition 1.6.24", "kind": "proposition", "status": "asserted", "proof": null, "statement": "∀ (as : List ℤ), as.length ≥ 2 → (∀ (a : ℤ), a ∈ as → a ≠ 0) → ∃ (d : ℕ) (cs : List ℤ), IsGCDOfList d as ∧ (d:ℤ) = List.sum (List.zipWith (· * ·) cs as)", "natural_language": "The greatest common divisor of a list of at least two nonzero integers exists and is an integer linear combination of the integers in the list.", "identifiers": ["as", "List", "as.length", "cs", "IsGCDOfList", "List.sum", "List.zipWith"], "requires": ["GCD.gcd_exists", "GCD.gcd_linear_combination"]}
{"name": "goodman_1_6_26", "id": "goodman-1-6-26", "source_label": "Lemma 1.6.26", "kind": "lemma", "status": "asserted", "proof": null, "statement": "∀ (as : List ℤ) (z : ℤ), as.length ≥ 2 → (∀ (i j : ℕ), i ≠ j → i < as.length → j < as.length → IsCoprime (as[i]) (as[j])) → (∀ (a : ℤ), a ∈ as → a ≠ 0) → (∀ (a : ℤ), a ∈ as → a ∣ z) → List.prod as ∣ z", "natural_language": "If a list of integers is pairwise relatively prime and each integer in the list divides an integer z, then the product of the integers in the list also divides z.", "identifiers": ["as", "List", "as.length", "IsCoprime", "List.prod"], "requires": ["Int.pairwise_coprime_mul_dvd"]}
{"name": "goodman_1_6_2_a", "id": "goodman-1-6-2-a", "source_label": "Proposition 1.6.2", "kind": "proposition", "status": "proof_pending", "proof": "intro u v h\nexact Int.mul_eq_one_iff_eq_one_or_neg_one.mp h", "statement": "∀ (u v : ℤ), u * v = 1 → (u = 1 ∧ v = 1) ∨ (u = -1 ∧ v = -1)", "natural_language": "If two integers multiply to 1, both are 1 or both are -1.", "identifiers": [], "requires": ["Int.eq_one_or_self_of_prime"]}
{"name": "goodman_1_6_2_b", "id": "goodman-1-6-2-b", "source_label": "Proposition 1.6.2", "kind": "proposition", "status": "proof_pending", "proof": "intro a b h\nexact Int.associated_iff.mp (associated_of_dvd_dvd h.1 h.2)", "statement": "∀ (a b : ℤ), a ∣ b ∧ b ∣ a → a = b ∨ a = -b", "natural_language": "If two integers divide each other, they are equal or negatives of each other.", "identifiers": [], "requires": ["Int.dvd_antisymm"]}
{"name": "goodman_1_6_2_c", "id": "goodman-1-6-2-c", "source_label": "Proposition 1.6.2", "kind": "proposition", "status": "proof_pending", "proof": "intro a b c h\nexact dvd_trans h.1 h.2", "statement": "∀ (a b c : ℤ), a ∣ b ∧ b ∣ c → a ∣ c", "natural_language": "Divisibility of integers is transitive.", "identifiers": [], "requires": ["Int.dvd_trans"]}
{"name": "goodman_1_6_2_d", "id": "goodman-1-6-2-d", "source_label": "Proposition 1.6.2", "kind": "proposition", "status": "proof_pending", "proof": "intro a b c s t h\nexact dvd_add (h.1.mul_left s) (h.2.mul_left t)", "statement": "∀ (a b c s t : ℤ), a ∣ b ∧ a ∣ c → a ∣ (s * b + t * c)", "natural_language": "If an integer divides two others, it divides any integer linear combination of them.", "identifiers": [], "requires": ["dvd_add", "Dvd.dvd.mul_left"]}
+61 -36
View File
@@ -1,22 +1,24 @@
import unittest
from textbook.build_database import build, decl_name, split_prelude, statement_identifiers
from textbook.build_database import build, decl_name, resolve_proof, split_prelude, statement_identifiers
ENTRIES = [
{"id": "goodman-1-6-1", "source_label": "Proposition 1.6.1", "kind": "proposition",
"statement": "\u2200 (a : \u2124), a + 0 = a", "prelude": "import Mathlib",
"statement": "∀ (a : ℤ), a + 0 = a", "prelude": "import Mathlib",
"natural_language": "Zero is an additive identity. [fix note: irrelevant]",
"requires": ["Int.add_zero"], "elaborates": True},
{"id": "goodman-1-6-24", "source_label": "Proposition 1.6.24", "kind": "proposition",
"statement": "\u2200 (as : List \u2124), IsGCDOfList 1 as", "prelude": "import Mathlib\ndef IsGCDOfList (d : \u2115) (as : List \u2124) : Prop := True",
"statement": "∀ (as : List ℤ), IsGCDOfList 1 as",
"prelude": "import Mathlib\ndef IsGCDOfList (d : ℕ) (as : List ℤ) : Prop := True",
"natural_language": "", "requires": [], "elaborates": True},
{"id": "goodman-1-6-99", "source_label": "Proposition 1.6.99", "kind": "proposition",
"statement": "\u2200 (as : List \u2124), IsGCDOfList 2 as", "prelude": "import Mathlib\ndef IsGCDOfList (d : \u2115) (as : List \u2124) : Prop := True",
"statement": "∀ (as : List ℤ), IsGCDOfList 2 as",
"prelude": "import Mathlib\ndef IsGCDOfList (d : ℕ) (as : List ℤ) : Prop := True",
"natural_language": "", "requires": [], "elaborates": True},
]
class BuildDatabaseTests(unittest.TestCase):
class HelperTests(unittest.TestCase):
def test_decl_name_is_a_legal_lean_identifier(self):
self.assertEqual(decl_name('goodman-1-6-2-a'), 'goodman_1_6_2_a')
@@ -25,60 +27,83 @@ class BuildDatabaseTests(unittest.TestCase):
self.assertEqual(imports, ['import Mathlib'])
self.assertEqual(other, 'def Foo := True')
def test_statement_identifiers_skips_stopwords(self):
self.assertNotIn('let', statement_identifiers('let x := 1'))
self.assertIn('Nat.Prime', statement_identifiers('Nat.Prime p'))
class ResolveProofTests(unittest.TestCase):
"""A proof only counts once Lean has verified it -- this is the gate that keeps
the library from claiming more than was actually checked."""
def test_no_proof_is_asserted(self):
self.assertEqual(resolve_proof({"id": "x"}), (None, 'asserted'))
def test_unverified_proof_is_pending_not_proved(self):
proof, status = resolve_proof({"id": "x", "proof": "ring", "proof_checked": False})
self.assertEqual(status, 'proof_pending')
self.assertIsNone(proof)
def test_verified_proof_is_proved(self):
proof, status = resolve_proof({"id": "x", "proof": "ring", "proof_checked": True})
self.assertEqual((proof, status), ('ring', 'proved'))
def test_successful_bench_run_proves_an_entry_with_no_recorded_proof(self):
result = {"success": True, "attempts": [{"proof": "simp"}]}
self.assertEqual(resolve_proof({"id": "x"}, result), ('simp', 'proved'))
def test_failed_bench_run_does_not_prove(self):
result = {"success": False, "attempts": []}
self.assertEqual(resolve_proof({"id": "x"}, result), (None, 'asserted'))
class BuildDatabaseTests(unittest.TestCase):
def test_unproven_entries_become_axioms(self):
text, index, emitted, skipped = build(ENTRIES)
text, emitted, skipped = build(ENTRIES)
self.assertIn('axiom goodman_1_6_1 :', text)
self.assertNotIn('theorem goodman_1_6_1 :', text)
self.assertEqual(len(emitted), 3)
self.assertEqual(skipped, [])
self.assertTrue(all(r['status'] == 'asserted' for r in index))
def test_proven_entries_become_real_theorems_with_their_proof(self):
results = {"goodman-1-6-1": {"id": "goodman-1-6-1", "success": True,
"attempts": [{"proof": "simp"}]}}
text, index, _emitted, _skipped = build(ENTRIES, results)
text, _emitted, _skipped = build(ENTRIES, results)
self.assertIn('theorem goodman_1_6_1 :', text)
self.assertNotIn('axiom goodman_1_6_1 :', text)
self.assertIn(' simp', text)
statuses = {r['id']: r['status'] for r in index}
self.assertEqual(statuses['goodman-1-6-1'], 'proved')
self.assertEqual(statuses['goodman-1-6-24'], 'asserted')
self.assertIn('Proof verified by Lean.', text)
def test_recorded_proof_is_only_emitted_once_checked(self):
pending = [dict(ENTRIES[0], proof='ring', proof_checked=False)]
text, _e, _s = build(pending)
self.assertIn('axiom goodman_1_6_1 :', text)
self.assertIn('not yet verified', text)
checked = [dict(ENTRIES[0], proof='ring', proof_checked=True)]
text, _e, _s = build(checked)
self.assertIn('theorem goodman_1_6_1 :', text)
self.assertIn(' ring', text)
def test_shared_def_in_prelude_is_emitted_once_not_per_entry(self):
# Two entries share the same `def`; declaring it twice in one Lean file is an
# "already declared" error, so it must be hoisted and deduplicated.
text, _index, _emitted, _skipped = build(ENTRIES)
text, _emitted, _skipped = build(ENTRIES)
self.assertEqual(text.count('def IsGCDOfList'), 1)
self.assertEqual(text.count('import Mathlib'), 1)
def test_require_checked_skips_unverified_entries(self):
entries = [dict(ENTRIES[0]), dict(ENTRIES[1], elaborates=False)]
_text, index, emitted, skipped = build(entries, require_checked=True)
text, emitted, skipped = build(entries, require_checked=True)
self.assertEqual(emitted, ['goodman-1-6-1'])
self.assertEqual(skipped, ['goodman-1-6-24'])
self.assertEqual(len(index), 1)
self.assertNotIn('goodman_1_6_24', text)
def test_recorded_proof_counts_only_once_checked(self):
# An unverified proof must NOT be emitted as a theorem -- the library may
# never claim more than Lean has confirmed.
pending = [dict(ENTRIES[0], proof='ring', proof_checked=False)]
text, index, _e, _s = build(pending)
self.assertIn('axiom goodman_1_6_1 :', text)
self.assertNotIn('theorem goodman_1_6_1 :', text)
self.assertEqual(index[0]['status'], 'proof_pending')
self.assertEqual(index[0]['proof'], 'ring')
checked = [dict(ENTRIES[0], proof='ring', proof_checked=True)]
text, index, _e, _s = build(checked)
self.assertIn('theorem goodman_1_6_1 :', text)
self.assertIn(' ring', text)
self.assertEqual(index[0]['status'], 'proved')
def test_index_carries_identifiers_and_strips_fix_notes(self):
_text, index, _emitted, _skipped = build(ENTRIES)
first = index[0]
self.assertIn('Int.add_zero', first['requires'])
self.assertNotIn('fix note', first['natural_language'])
self.assertNotIn('let', statement_identifiers('let x := 1'))
def test_docstring_records_provenance_and_axiom_warning(self):
text, _emitted, _skipped = build(ENTRIES)
self.assertIn('Proposition 1.6.1', text)
self.assertIn('ASSERTED from the textbook', text)
self.assertNotIn('fix note', text)
if __name__ == '__main__':
+56 -1
View File
@@ -1,6 +1,10 @@
import unittest
from textbook.search_database import format_for_prompt, search, tokenize
import json
import tempfile
from pathlib import Path
from textbook.search_database import format_for_prompt, load_database, search, tokenize
INDEX = [
{"name": "goodman_1_6_20", "id": "goodman-1-6-20", "source_label": "Corollary 1.6.20",
@@ -18,12 +22,63 @@ INDEX = [
]
SOURCE_ENTRIES = [
{"id": "goodman-1-6-20", "source_label": "Corollary 1.6.20", "kind": "corollary",
"statement": "\u2200 (p : \u2115), Nat.Prime p \u2192 (p:\u2124) \u2223 List.prod as",
"prelude": "import Mathlib", "natural_language": "x [fix note: dropped]",
"requires": ["Nat.Prime"]},
{"id": "goodman-1-6-24", "source_label": "Proposition 1.6.24", "kind": "proposition",
"statement": "\u2200 (as : List \u2124), \u2203 d, IsGCDOfList d as",
"prelude": "import Mathlib", "natural_language": "", "requires": [],
"proof": "exact foo", "proof_checked": True},
{"id": "goodman-1-6-1", "source_label": "Proposition 1.6.1", "kind": "proposition",
"statement": "\u2200 (a : \u2124), a + 0 = a", "prelude": "import Mathlib",
"natural_language": "", "requires": [], "proof": "ring", "proof_checked": False},
]
class LoadDatabaseTests(unittest.TestCase):
"""load_database derives name/status/identifiers from the source file, so there is
no separate index that could go stale against it."""
def _load(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'db.jsonl'
path.write_text('\n'.join(json.dumps(e) for e in SOURCE_ENTRIES) + '\n',
encoding='utf-8')
return load_database(path)
def test_derives_lean_name_and_strips_fix_notes(self):
records = {r['id']: r for r in self._load()}
self.assertEqual(records['goodman-1-6-20']['name'], 'goodman_1_6_20')
self.assertNotIn('fix note', records['goodman-1-6-20']['natural_language'])
def test_derives_proof_status_from_proof_checked(self):
records = {r['id']: r for r in self._load()}
self.assertEqual(records['goodman-1-6-24']['status'], 'proved')
self.assertEqual(records['goodman-1-6-1']['status'], 'proof_pending')
self.assertEqual(records['goodman-1-6-20']['status'], 'asserted')
def test_search_works_directly_on_loaded_source(self):
hits = search(self._load(), '\u22a2 IsGCDOfList d as', limit=2)
self.assertEqual(hits[0]['name'], 'goodman_1_6_24')
class SearchDatabaseTests(unittest.TestCase):
def test_tokenize_keeps_qualified_names_and_drops_stopwords(self):
tokens = tokenize('let x := Nat.Prime p')
self.assertIn('Nat.Prime', tokens)
self.assertNotIn('let', tokens)
def test_tokenize_keeps_symbols_but_drops_single_letter_variables(self):
# A pure-algebra goal has no identifiers at all, so the symbols have to
# survive or it retrieves nothing; matching 'a' against 'a' says nothing.
tokens = tokenize('\u2200 (a b : \u2124), a * (b + c) = 0')
self.assertIn('\u2124', tokens)
self.assertIn('*', tokens)
self.assertIn('+', tokens)
self.assertNotIn('a', tokens)
def test_exact_premise_ranks_first(self):
hits = search(INDEX, '\u22a2 IsGCDOfList d as', limit=3)
self.assertEqual(hits[0]['name'], 'goodman_1_6_24')
+12 -7
View File
@@ -6,8 +6,8 @@ distinct things `lean-bench`/`lean-agent` can use, not one:
- **The bench** (`datasets/goodman-1.6-exercises.jsonl`): real problems the reader
is meant to solve, sourced from the book's own **Exercises** at the end of each
section. These are the actual targets `lean-bench` scores an agent against.
- **The database** (`database/goodman-1.6.jsonl` -> `lib/Goodman/Chapter01Section06.lean`
+ `database/goodman-1.6.index.jsonl`): the book's stated Theorem/Proposition/
- **The database** (`database/goodman-1.6.jsonl` -> `lib/Goodman/Chapter01Section06.lean`):
the book's stated Theorem/Proposition/
Lemma/Corollary claims from the main text, turned by `build_database.py` into real
Lean declarations an agent can **cite by name** while proving a bench target --
the "third party math library" role Mathlib plays, except scoped to this book.
@@ -25,7 +25,13 @@ distinct things `lean-bench`/`lean-agent` can use, not one:
axiom here still needs a human to have read it against the book.
- **Retrieval** (`search_database.py`): given the current goal, ranks database
premises by identifier/symbol overlap with rare identifiers weighted higher, and
formats the top few for the LLM prompt. This is the step the proof loop calls at
formats the top few for the LLM prompt. It reads the database JSONL directly and
derives each premise's declaration name, identifiers and proof status itself
(sharing those functions with `build_database.py`), so the data exists in exactly
one place. There is deliberately no generated index: at this scale deriving them
costs microseconds, and a second copy that nothing checks for freshness could
silently serve statements that no longer match the `.lean` file. An index earns
its place once it holds something actually expensive, i.e. embeddings. This is the step the proof loop calls at
each line -- it narrows ~20 premises to the few worth showing, and cannot tell you
whether one applies; only Lean can. Swapping in embeddings later means replacing
`score_premise()` and nothing else.
@@ -195,17 +201,16 @@ the next build.
```sh
python3 textbook/build_database.py database/goodman-1.6.jsonl \
--out-lean lib/Goodman/Chapter01Section06.lean \
--out-index database/goodman-1.6.index.jsonl \
--require-checked
```
Add `--results results/goodman-1.6-library-001/results.jsonl` once a `lean-bench`
run over `database/goodman-1.6.jsonl` has proven some of them, to upgrade those
entries from `axiom` to real theorems. Then query it with the current goal:
entries from `axiom` to real theorems. Query it with the current goal:
```sh
python3 textbook/search_database.py database/goodman-1.6.index.jsonl \
"\u22a2 Nat.Prime p \u2192 (p:\u2124) \u2223 List.prod as \u2192 \u2203 a \u2208 as, (p:\u2124) \u2223 a"
python3 textbook/search_database.py database/goodman-1.6.jsonl \
"⊢ Nat.Prime p → (p:ℤ) ∣ List.prod as → ∃ a ∈ as, (p:ℤ) ∣ a"
```
(`build_library.py` predates this and remains for the narrower job of assembling
+43 -51
View File
@@ -5,23 +5,21 @@ Theorem/Proposition/Lemma/Corollary claims, turned into real Lean declarations t
lean-agent can cite by name while proving something else (the bench targets, which
come from the exercises instead -- see formalize_exercises.py).
Two outputs:
Output: an importable Lean file where every database entry is a named declaration,
so a proof can cite goodman_1_6_20 by name. An entry is emitted as a real
`theorem ... := by <proof>` once its proof has been verified, and otherwise as an
`axiom` -- asserted from the textbook, which is what the book itself does for its
own body results before an exercise asks you to use them. This is deliberate and
has a real cost: anything proven *through* an axiom inherits it, which is exactly
why the verifier's `#print axioms` check matters. A statement that only elaborates
is not the same as a statement that is true, and a garbled extraction asserted as
an axiom can make downstream "proofs" worthless -- so every axiom here needs to have
been read by a human against the book, and `--require-checked` refuses to emit any
entry not marked as having passed check_statements.py.
1. An importable Lean file where every database entry is a named declaration. An
entry is emitted as a real `theorem ... := by <proof>` when a lean-bench run has
actually proven it (status "proved"), and otherwise as an `axiom` -- asserted from
the textbook, which is what the book itself does for its own body results before
an exercise asks you to use them. This is deliberate and has a real cost: anything
proven *through* an axiom inherits it, which is exactly why the verifier's
`#print axioms` check matters. A statement that only elaborates is not the same as
a statement that is true, and a garbled extraction asserted as an axiom can make
downstream "proofs" worthless -- so every axiom here needs to have been read by a
human against the book, and `--require-checked` refuses to emit any entry not
marked as having passed check_statements.py.
2. A search index (JSONL) carrying each entry's declaration name, statement, the
Mathlib identifiers it mentions, and its status -- what search_database.py ranks
against the current goals at each step of a proof.
There is deliberately no generated search index: search_database.py reads this same
source file and derives the declaration name, identifiers and status itself, so
there is only ever one copy of the data and nothing to keep in sync.
Pass a lean-bench run's results.jsonl with --results to upgrade the entries it
proved from axiom to a real theorem carrying the exact proof that run verified.
@@ -53,8 +51,26 @@ def split_prelude(prelude):
return imports, '\n'.join(other).strip()
def resolve_proof(entry, result=None):
"""(proof, status) for one entry.
A proof only counts once Lean has verified it: check_proofs.py sets
proof_checked on the entry, or a lean-bench run reports success. An unverified
proof leaves the entry asserted, reported as proof_pending so it is visible
without being trusted."""
run_proof = result['attempts'][-1]['proof'] if (
result and result.get('success') and result.get('attempts')) else None
own_proof = entry.get('proof') if entry.get('proof_checked') else None
proof = run_proof or own_proof
if proof:
return proof, 'proved'
if entry.get('proof'):
return None, 'proof_pending'
return None, 'asserted'
def statement_identifiers(statement):
"""Identifiers mentioned in a statement, for the search index."""
"""Identifiers mentioned in a statement, for premise retrieval."""
found = []
for match in IDENT_RE.findall(statement or ''):
if match in STOPWORDS or match in found or len(match) < 2:
@@ -89,7 +105,7 @@ def build(entries, results=None, require_checked=False):
lines.append(block)
lines.append('')
index, emitted, skipped = [], [], []
emitted, skipped = [], []
for entry in entries:
entry_id = entry['id']
if require_checked and not entry.get('elaborates'):
@@ -97,17 +113,9 @@ def build(entries, results=None, require_checked=False):
continue
name = decl_name(entry_id)
result = results.get(entry_id)
# A proof can come from a lean-bench run, or be recorded on the entry itself
# (hand-written, or authored by an agent outside a bench run). Either way it
# only counts once check_proofs.py has actually compiled it: an unverified
# proof stays an axiom, so the library never claims more than Lean confirmed.
run_proof = result['attempts'][-1]['proof'] if (
result and result.get('success') and result.get('attempts')) else None
own_proof = entry.get('proof') if entry.get('proof_checked') else None
proof = run_proof or own_proof
proved = bool(proof)
proof_pending = bool(not proved and entry.get('proof'))
proof, status = resolve_proof(entry, results.get(entry_id))
proved = status == 'proved'
proof_pending = status == 'proof_pending'
provenance = entry.get('source_label', entry_id)
doc = f'/-- {provenance} (Goodman, Algebra: Abstract and Concrete).'
@@ -129,21 +137,9 @@ def build(entries, results=None, require_checked=False):
lines.append(f'axiom {name} : {entry["statement"]}')
lines.append('')
index.append({
"name": name,
"id": entry_id,
"source_label": entry.get('source_label'),
"kind": entry.get('kind'),
"status": "proved" if proved else ("proof_pending" if proof_pending else "asserted"),
"proof": proof if proved else 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', []),
})
emitted.append(entry_id)
return '\n'.join(lines), index, emitted, skipped
return '\n'.join(lines), emitted, skipped
def main():
@@ -151,27 +147,23 @@ def main():
parser.add_argument('database', type=Path, help='JSONL of main-content statements, e.g. database/goodman-1.6.jsonl')
parser.add_argument('--results', type=Path, help="a lean-bench run's results.jsonl; proven entries become real theorems")
parser.add_argument('--out-lean', type=Path, required=True, help='importable Lean file of citable premises')
parser.add_argument('--out-index', type=Path, required=True, help='JSONL search index for search_database.py')
parser.add_argument('--require-checked', action='store_true',
help='refuse entries not marked "elaborates": true (i.e. not yet verified by check_statements.py)')
args = parser.parse_args()
entries = load_jsonl(args.database)
results = {r['id']: r for r in load_jsonl(args.results)} if args.results else None
text, index, emitted, skipped = build(entries, results, args.require_checked)
text, emitted, skipped = build(entries, results, args.require_checked)
args.out_lean.parent.mkdir(parents=True, exist_ok=True)
args.out_lean.write_text(text + '\n', encoding='utf-8')
args.out_index.parent.mkdir(parents=True, exist_ok=True)
with args.out_index.open('w', encoding='utf-8') as stream:
for record in index:
stream.write(json.dumps(record, ensure_ascii=False) + '\n')
proved = sum(1 for r in index if r['status'] == 'proved')
pending = sum(1 for r in index if r['status'] == 'proof_pending')
by_id = {e['id']: e for e in entries}
statuses = [resolve_proof(by_id[i], (results or {}).get(i))[1] for i in emitted]
proved = statuses.count('proved')
pending = statuses.count('proof_pending')
print(f'{len(emitted)} premises -> {args.out_lean} ({proved} proven, '
f'{len(emitted) - proved} asserted as axioms, of which {pending} have an unverified proof)')
print(f'search index -> {args.out_index}')
if skipped:
print(f'{len(skipped)} skipped for not being elaboration-checked: {", ".join(skipped)}')
+58 -12
View File
@@ -7,6 +7,13 @@ for entries that might apply to the goal in front of it. The ranked results are
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
@@ -23,22 +30,61 @@ import re
from collections import Counter
from pathlib import Path
TOKEN_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+|[A-Za-z_][A-Za-z0-9_]*|[∣∀∃↔→∧∨¬≤≥≠∈]')
# 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'}
def load_index(path):
return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
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):
return [t for t in TOKEN_RE.findall(text or '') if t not in STOPWORDS and len(t) > 1]
"""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(index):
def document_frequencies(records):
"""How many premises each token appears in, for inverse-document-frequency weighting."""
frequencies = Counter()
for record in index:
for record in records:
for token in set(tokenize(record['statement']) + record.get('identifiers', [])
+ record.get('requires', [])):
frequencies[token] += 1
@@ -57,13 +103,13 @@ def score_premise(goal_tokens, record, frequencies, total):
return score
def search(index, goal, limit=5):
def search(records, goal, limit=5):
"""Premises ranked against one goal, best first. Zero-score entries are dropped."""
frequencies = document_frequencies(index)
total = max(len(index), 1)
frequencies = document_frequencies(records)
total = max(len(records), 1)
goal_tokens = tokenize(goal)
scored = []
for record in index:
for record in records:
score = score_premise(goal_tokens, record, frequencies, total)
if score > 0:
scored.append((score, record))
@@ -85,13 +131,13 @@ def format_for_prompt(hits):
def main():
parser = argparse.ArgumentParser(description='Rank database premises against a proof goal')
parser.add_argument('index', type=Path, help='search index from build_database.py')
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_index(args.index), args.goal, args.limit)
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))