diff --git a/database/goodman-1.6.index.jsonl b/database/goodman-1.6.index.jsonl deleted file mode 100644 index f1911cb..0000000 --- a/database/goodman-1.6.index.jsonl +++ /dev/null @@ -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"]} diff --git a/tests/test_build_database.py b/tests/test_build_database.py index bcb53f3..2b0592c 100644 --- a/tests/test_build_database.py +++ b/tests/test_build_database.py @@ -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__': diff --git a/tests/test_search_database.py b/tests/test_search_database.py index 4fdc5dd..4cf5091 100644 --- a/tests/test_search_database.py +++ b/tests/test_search_database.py @@ -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') diff --git a/textbook/README.md b/textbook/README.md index 9eddbf6..8b1d97f 100644 --- a/textbook/README.md +++ b/textbook/README.md @@ -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 diff --git a/textbook/build_database.py b/textbook/build_database.py index 794becc..6d26cdf 100644 --- a/textbook/build_database.py +++ b/textbook/build_database.py @@ -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 ` 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 ` 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)}') diff --git a/textbook/search_database.py b/textbook/search_database.py index ddccace..c3ddef9 100644 --- a/textbook/search_database.py +++ b/textbook/search_database.py @@ -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))