Ray5th commited on
Commit
4be8b34
·
unverified ·
2 Parent(s): 4a3b0d03ac681e

Merge pull request #1 from ray5th/claude/intelligent-varahamihira-81b22c

Browse files
.env.example DELETED
@@ -1 +0,0 @@
1
- ANTHROPIC_API_KEY=your_key_here
 
 
.gitignore DELETED
@@ -1,9 +0,0 @@
1
- .env
2
- .venv/
3
- venv/
4
- data/mathlib.index
5
- data/mathlib_meta.pkl
6
- __pycache__/
7
- *.pyc
8
- *.pyo
9
- .DS_Store
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,107 +1,334 @@
1
- # Agentic Lean 4 Theorem Prover
2
 
3
- An agentic LLM system that iteratively generates and validates formal Lean 4 proofs using a Claude-powered feedback loop, grounded in Mathlib4 via FAISS RAG.
4
 
5
- ## Features
6
 
7
- - 🤖 **Agentic loop** Claude generates proofs, Lean validates them, errors are fed back for self-correction
8
- - 📚 **Mathlib4 RAG** — FAISS semantic search retrieves relevant verified lemmas to ground each generation
9
- - 🔁 **Self-correcting** — Parses compiler errors and re-prompts until successful compilation or max retries
10
- - 🖥️ **Interactive CLI** — Pretty terminal output with `rich`, syntax highlighting, live feedback
11
 
12
- ## Setup
13
 
14
- ### Prerequisites
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- - Python 3.11+
17
- - [Lean 4 + elan](https://leanprover.github.io/lean4/doc/setup.html) installed and `lean` on your `$PATH`
18
- - An Anthropic API key
19
 
20
- ### Install
 
 
 
 
 
 
 
 
21
 
22
- ```bash
23
- git clone https://github.com/YOUR_HANDLE/lean4-helper
24
- cd lean4-helper
25
 
26
- python -m venv .venv && source .venv/bin/activate
27
- pip install -r requirements.txt
28
 
29
- cp .env.example .env
30
- # Edit .env and add your ANTHROPIC_API_KEY
 
31
  ```
32
 
33
- ## Usage
 
 
 
 
34
 
35
- ### Step 1: Build the FAISS Index (one-time)
36
 
37
- First, extract lemmas from your local Mathlib4 source:
38
 
39
- ```bash
40
- python extract_mathlib.py --mathlib-path ~/.elan/toolchains/leanprover-lean4-v4.X.0/lib/lean/library
 
 
 
 
 
41
  ```
42
 
43
- Then build the FAISS index:
44
 
45
- ```bash
46
- python -m src.retriever
 
 
 
 
 
 
 
 
47
  ```
48
 
49
- ### Step 2: Run the Prover
50
 
51
- ```bash
52
- # Interactive mode
53
- python -m src.cli
 
54
 
55
- # Inline theorem
56
- python -m src.cli --theorem "theorem add_comm (a b : Nat) : a + b = b + a := by sorry"
 
 
 
 
 
 
 
 
 
57
 
58
- # From a .lean file
59
- python -m src.cli --file my_theorem.lean
60
 
61
- # Without RAG (faster, less accurate)
62
- python -m src.cli --no-rag --max-retries 8
 
 
 
63
  ```
64
 
65
- ## Project Structure
66
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ```
68
- lean4-helper/
69
- ├── src/
70
- │ ├── __init__.py
71
- │ ├── lean_env.py # Lean 4 compiler wrapper & error parser
72
- │ ├── retriever.py # FAISS index build & retrieval
73
- │ ├── agent.py # Agentic Claude feedback loop
74
- │ └── cli.py # Rich interactive CLI
75
- ├── data/ # FAISS index + metadata (git-ignored)
76
- ├── extract_mathlib.py # Mathlib4 lemma extractor
77
- ├── requirements.txt
78
- ├── .env.example
79
- └── README.md
80
  ```
81
 
82
- ## How It Works
 
 
 
 
 
 
 
 
 
83
 
 
 
 
84
  ```
 
 
 
 
 
 
 
 
85
  User Theorem
86
 
87
 
88
- FAISS Retrieval ──► Top-K Mathlib Lemmas
 
 
 
 
 
 
 
 
 
89
 
90
 
91
- Claude Prompt (theorem + context + history)
 
92
 
93
 
94
- Generated Lean 4 Proof
 
95
 
96
 
97
- Lean Compiler
98
- ├── ✅ Success ──► Return proof
99
- └── ❌ Failure ──► Append error to history ──► Claude (retry)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ```
101
 
102
- ## Benchmarks
 
 
 
 
 
 
103
 
104
- | Configuration | First-attempt success rate |
105
- |----------------------|---------------------------|
106
- | Baseline (no RAG) | ~18% |
107
- | With FAISS RAG | ~38% |
 
1
+ ## Brief Non-Technical MVP: AI Lean 4 Proof Assistant
2
 
3
+ ### Project goal
4
 
5
+ Build an AI assistant that helps users write correct **Lean 4 mathematical proofs**.
6
 
7
+ The user provides a theorem. The system searches Lean’s Mathlib library, finds useful related theorems, asks an AI model to write a proof, checks the proof using Lean, and automatically retries if the proof fails.
 
 
 
8
 
9
+ The key idea is:
10
 
11
+ ```text
12
+ AI suggests the proof.
13
+ Lean verifies the proof.
14
+ Only Lean decides if the answer is correct.
15
+ ```
16
+
17
+ ---
18
+
19
+ ## MVP flow
20
+
21
+ ```text
22
+ User enters theorem
23
+
24
+ Lean tooling reads the theorem and current proof goal
25
+
26
+ System retrieves useful Mathlib lemmas
27
+
28
+ System reranks the lemmas and keeps the best ones
29
+
30
+ LangChain organizes the prompt and retrieval flow
31
+
32
+ Local LLM generates a Lean proof
33
+
34
+ Lean checks the proof
35
+
36
+ If correct → return final proof
37
+ If wrong → send Lean error back to the AI and retry
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Simple explanation
43
+
44
+ This project is like an **AI coding assistant for mathematical proofs**.
45
+
46
+ But unlike a normal chatbot, it does not just guess. It uses Lean itself to check whether the proof is truly valid.
47
+
48
+ The assistant works in three stages:
49
+
50
+ ```text
51
+ 1. Search
52
+ Find useful existing Mathlib theorems.
53
+
54
+ 2. Generate
55
+ Ask the AI model to write a proof using those theorems.
56
+
57
+ 3. Verify
58
+ Run the proof through Lean and retry if Lean finds an error.
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Main MVP components
64
+
65
+ ### 1. Lean tooling
66
+
67
+ Use **LeanInteract** to connect Python with Lean 4.
68
+
69
+ LeanInteract lets Python interact with Lean through the Lean REPL, which means your program can send Lean code, inspect proof goals, and receive Lean feedback programmatically. ([GitHub][1])
70
+
71
+ In simple terms:
72
+
73
+ ```text
74
+ LeanInteract lets the AI system talk to Lean.
75
+ ```
76
+
77
+ Instead of only seeing an error like:
78
+
79
+ ```text
80
+ type mismatch
81
+ ```
82
+
83
+ the system can see more useful information like:
84
+
85
+ ```text
86
+ Current goal:
87
+ n : Nat
88
+ ⊢ n = n
89
+ ```
90
+
91
+ That makes the retry much smarter.
92
+
93
+ ---
94
+
95
+ ### 2. Retrieve Mathlib lemmas
96
+
97
+ The system searches Mathlib for useful theorems.
98
+
99
+ For example, if the theorem is about even numbers, the retriever may find lemmas related to:
100
+
101
+ ```text
102
+ Even
103
+ Odd
104
+ Nat.even_or_odd
105
+ parity
106
+ multiplication
107
+ powers
108
+ ```
109
+
110
+ This is the “retrieve” part.
111
+
112
+ Use:
113
+
114
+ ```text
115
+ FAISS for fast semantic search
116
+ BM25 / keyword search for exact Lean names
117
+ ```
118
+
119
+ FAISS is useful for quickly finding similar text from a large collection of embedded documents, while keyword search helps when exact theorem names matter.
120
+
121
+ ---
122
+
123
+ ### 3. Rerank retrieved lemmas
124
+
125
+ Retrieval alone is not enough.
126
+
127
+ The system may retrieve 50 possible Mathlib lemmas, but many may be weak or irrelevant. So we add a **reranker**.
128
 
129
+ The improved flow is:
 
 
130
 
131
+ ```text
132
+ Retrieve top 50 lemmas
133
+
134
+ Rerank them based on the current theorem and proof goal
135
+
136
+ Keep the best 8–12 lemmas
137
+
138
+ Send only those to the AI model
139
+ ```
140
 
141
+ A Cross-Encoder reranker is useful here because it scores each query-document pair more carefully than the first-pass retriever. Sentence Transformers describes this as a common retrieve-and-rerank setup where fast retrieval finds candidates and the Cross-Encoder reranks them for better relevance. ([SentenceTransformers][2])
 
 
142
 
143
+ In simple terms:
 
144
 
145
+ ```text
146
+ Retrieve finds possible useful lemmas.
147
+ Rerank chooses the best ones.
148
  ```
149
 
150
+ This improves the quality of the context given to the AI.
151
+
152
+ ---
153
+
154
+ ### 4. LangChain role
155
 
156
+ Use **LangChain** for orchestration, not for proof correctness.
157
 
158
+ LangChain can help organize:
159
 
160
+ ```text
161
+ retrieval
162
+ reranking
163
+ prompt templates
164
+ document formatting
165
+ LLM calls
166
+ retry history
167
  ```
168
 
169
+ LangChain has retrieval utilities such as contextual compression, where retrieved documents can be filtered or shortened before sending them to the model. ([LangChain][3])
170
 
171
+ But LangChain does **not** make Lean proofs correct by itself.
172
+
173
+ The correctness still comes from:
174
+
175
+ ```text
176
+ Lean compiler
177
+ Lean proof state
178
+ Mathlib retrieval quality
179
+ LLM proof generation
180
+ retry loop
181
  ```
182
 
183
+ So the right positioning is:
184
 
185
+ ```text
186
+ LangChain manages the workflow.
187
+ Lean verifies the truth.
188
+ ```
189
 
190
+ ---
191
+
192
+ ### 5. Local LLM first
193
+
194
+ For the MVP, use a local model first.
195
+
196
+ Example:
197
+
198
+ ```text
199
+ Ollama + Qwen Coder
200
+ ```
201
 
202
+ Why local first?
 
203
 
204
+ ```text
205
+ No API cost
206
+ Easy to test many times
207
+ Good for debugging the pipeline
208
+ Private and simple for development
209
  ```
210
 
211
+ Later, once the workflow works, replace the local model with Claude.
212
 
213
+ The architecture stays the same.
214
+
215
+ ---
216
+
217
+ ### 6. Lean verification and retry
218
+
219
+ After the AI generates a proof, the system sends it to Lean.
220
+
221
+ If Lean accepts the proof:
222
+
223
+ ```text
224
+ Success → return proof
225
  ```
226
+
227
+ If Lean rejects the proof:
228
+
229
+ ```text
230
+ Failure capture Lean error add error to prompt → retry
 
 
 
 
 
 
 
231
  ```
232
 
233
+ Example:
234
+
235
+ ```text
236
+ Attempt 1:
237
+ AI generates proof.
238
+ Lean says: unknown theorem name.
239
+
240
+ Attempt 2:
241
+ AI sees the error and tries a different lemma.
242
+ Lean says: type mismatch.
243
 
244
+ Attempt 3:
245
+ AI fixes the proof.
246
+ Lean accepts it.
247
  ```
248
+
249
+ This is the most important part of the MVP.
250
+
251
+ ---
252
+
253
+ ## Final MVP architecture
254
+
255
+ ```text
256
  User Theorem
257
 
258
 
259
+ LeanInteract
260
+ Get theorem goal and Lean feedback
261
+
262
+
263
+ Hybrid Retrieval
264
+ FAISS semantic search + keyword search
265
+
266
+
267
+ Reranker
268
+ Keep only the most useful Mathlib lemmas
269
 
270
 
271
+ LangChain Prompt Flow
272
+ Organize theorem, lemmas, history, and Lean errors
273
 
274
 
275
+ Local LLM
276
+ Generate Lean proof
277
 
278
 
279
+ Lean Checker
280
+ Verify proof
281
+
282
+ ├── Success → Return final proof
283
+
284
+ └── Failure → Retry with Lean error
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Libraries to use
290
+
291
+ | Purpose | Library |
292
+ | ---------------------------- | ------------------------------------------------- |
293
+ | Interact with Lean | `LeanInteract` |
294
+ | Semantic retrieval | `sentence-transformers` |
295
+ | Vector search | `faiss-cpu` |
296
+ | Keyword search | `rank-bm25` |
297
+ | Reranking | `sentence-transformers` CrossEncoder or FlashRank |
298
+ | Workflow orchestration | `LangChain` |
299
+ | More advanced workflow graph | `LangGraph` |
300
+ | Local model | `Ollama` |
301
+ | Future stronger model | Claude API |
302
+
303
+ LangGraph is useful later if you want the proof process to behave like a clear state machine: retrieve → generate → verify → retry → success/failure. LangGraph is designed for stateful agent workflows, which fits this kind of multi-step proof loop. ([Emergent Mind][4])
304
+
305
+ ---
306
+
307
+ ## Non-technical value proposition
308
+
309
+ This MVP creates an AI assistant that helps people write mathematically verified Lean proofs.
310
+
311
+ It does not only generate text. It checks every answer with Lean, learns from errors, and keeps trying until it produces a valid proof.
312
+
313
+ The project demonstrates:
314
+
315
+ ```text
316
+ AI-assisted formal reasoning
317
+ verified code generation
318
+ search over mathematical knowledge
319
+ automatic error correction
320
+ LLM + compiler feedback loops
321
  ```
322
 
323
+ ---
324
+
325
+ ## One-line MVP summary
326
+
327
+ ```text
328
+ An AI-powered Lean 4 proof assistant that searches Mathlib, reranks useful lemmas, generates a proof with a local LLM, checks it using Lean tooling, and automatically retries using Lean errors until the proof is verified.
329
+ ```
330
 
331
+ [1]: https://github.com/augustepoiroux/LeanInteract?utm_source=chatgpt.com "LeanInteract: A Python Interface for Lean 4"
332
+ [2]: https://sbert.net/examples/sentence_transformer/applications/retrieve_rerank/README.html?utm_source=chatgpt.com "Retrieve & Re-Rank Pipeline"
333
+ [3]: https://www.langchain.com/blog/improving-document-retrieval-with-contextual-compression?utm_source=chatgpt.com "Improving Document Retrieval with Contextual Compression"
334
+ [4]: https://www.emergentmind.com/topics/langgraph?utm_source=chatgpt.com "LangGraph: Modular LLM Agent Orchestration"
extract_mathlib.py DELETED
@@ -1,82 +0,0 @@
1
- """
2
- extract_mathlib.py
3
-
4
- Extracts lemma names, type signatures, and docstrings from Mathlib4 source files
5
- and writes them to data/mathlib_lemmas.jsonl for indexing.
6
-
7
- Usage:
8
- python extract_mathlib.py --mathlib-path /path/to/mathlib4
9
-
10
- Each output line is a JSON object:
11
- {"name": "Nat.add_comm", "type": "∀ (n m : ℕ), n + m = m + n", "doc": "..."}
12
- """
13
-
14
- import argparse
15
- import json
16
- import os
17
- import re
18
- from pathlib import Path
19
-
20
- # Regex patterns for Lean 4 declarations
21
- DECL_PATTERN = re.compile(
22
- r'(?P<doc>/--.*?-/\s*)?' # optional docstring
23
- r'(?:theorem|lemma|def|abbrev)\s+'
24
- r'(?P<name>[\w\.\']+)\s*'
25
- r'(?P<rest>[^:=]*?):\s*'
26
- r'(?P<type>.+?)\s*(?::=|where|by)',
27
- re.DOTALL
28
- )
29
-
30
- DOC_PATTERN = re.compile(r'/--\s*(.*?)\s*-/', re.DOTALL)
31
-
32
-
33
- def extract_from_file(path: Path) -> list[dict]:
34
- """Extract lemma entries from a single .lean file."""
35
- text = path.read_text(encoding="utf-8", errors="ignore")
36
- entries = []
37
- for m in DECL_PATTERN.finditer(text):
38
- name = m.group("name").strip()
39
- raw_type = m.group("type").strip()
40
- # Clean up multi-line types
41
- clean_type = " ".join(raw_type.split())
42
- doc = ""
43
- if m.group("doc"):
44
- doc_match = DOC_PATTERN.search(m.group("doc"))
45
- if doc_match:
46
- doc = " ".join(doc_match.group(1).split())
47
- entries.append({"name": name, "type": clean_type, "doc": doc})
48
- return entries
49
-
50
-
51
- def extract_mathlib(mathlib_path: str, output_path: str = "data/mathlib_lemmas.jsonl"):
52
- """Walk all .lean files in mathlib_path and write JSONL output."""
53
- root = Path(mathlib_path)
54
- os.makedirs(Path(output_path).parent, exist_ok=True)
55
-
56
- total = 0
57
- with open(output_path, "w") as out:
58
- for lean_file in sorted(root.rglob("*.lean")):
59
- entries = extract_from_file(lean_file)
60
- for entry in entries:
61
- out.write(json.dumps(entry) + "\n")
62
- total += len(entries)
63
- if total % 1000 == 0:
64
- print(f" Extracted {total} lemmas so far...")
65
-
66
- print(f"Done. {total} lemmas written to {output_path}")
67
-
68
-
69
- if __name__ == "__main__":
70
- parser = argparse.ArgumentParser(description="Extract Mathlib4 lemmas to JSONL")
71
- parser.add_argument(
72
- "--mathlib-path",
73
- required=True,
74
- help="Path to the root of the Mathlib4 source tree (e.g. ~/.elan/toolchains/...)",
75
- )
76
- parser.add_argument(
77
- "--output",
78
- default="data/mathlib_lemmas.jsonl",
79
- help="Output JSONL file path.",
80
- )
81
- args = parser.parse_args()
82
- extract_mathlib(args.mathlib_path, args.output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
problems/simple_add.lean ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import Mathlib
2
+
3
+ theorem add_zero_simple (n : ℕ) : n + 0 = n := by
4
+ sorry
problems/test_problem.lean ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import Mathlib
2
+
3
+ theorem square_root_two_irrational (n m : ℕ) (h : n^2 = 2 * m^2) (hm : m ≠ 0) : False := by
4
+ sorry
requirements.txt CHANGED
@@ -1,6 +1,9 @@
1
- anthropic>=0.30.0
2
- faiss-cpu>=1.8.0
3
- sentence-transformers>=2.7.0
4
- rich>=13.7.1
5
- pexpect>=4.9.0
6
- python-dotenv>=1.0.1
 
 
 
 
1
+ lean-interact
2
+ ollama
3
+ langchain
4
+ langchain-community
5
+ langchain-ollama
6
+ langgraph
7
+ faiss-cpu
8
+ rank-bm25
9
+ sentence-transformers
scripts/build_index.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ One-time script to build the FAISS + BM25 index from Mathlib4 source files.
4
+ Run this before using the LangGraph agent for the first time:
5
+
6
+ python scripts/build_index.py
7
+
8
+ Optional flags:
9
+ --mathlib-root PATH Path to Mathlib4 source (auto-detected if omitted)
10
+ --max-files N Limit to first N .lean files (useful for quick testing)
11
+ --index-dir PATH Where to save the index (default: data/mathlib_index)
12
+ """
13
+ import sys
14
+ import os
15
+ import argparse
16
+
17
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src')))
18
+
19
+ from retriever import MathLibRetriever
20
+
21
+
22
+ def main():
23
+ parser = argparse.ArgumentParser(description="Build Mathlib FAISS index.")
24
+ parser.add_argument("--mathlib-root", default=None, help="Path to Mathlib4 source root")
25
+ parser.add_argument("--max-files", type=int, default=None, help="Limit number of .lean files processed")
26
+ parser.add_argument("--index-dir", default=None, help="Directory to save the index")
27
+ args = parser.parse_args()
28
+
29
+ retriever = MathLibRetriever(index_dir=args.index_dir)
30
+
31
+ if retriever.is_index_built() and not args.max_files:
32
+ print(f"Index already exists at {retriever.index_dir}. Delete it to rebuild.")
33
+ return
34
+
35
+ retriever.build(mathlib_root=args.mathlib_root, max_files=args.max_files)
36
+ print("Done. Index is ready for use.")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
scripts/run_agent.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import os
4
+ import argparse
5
+
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src')))
7
+
8
+ from langgraph_agent import LangGraphAgent
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description="Run the LangGraph Lean Proof Agent on a file.")
13
+ parser.add_argument("file", help="Path to the .lean file to solve")
14
+ parser.add_argument("--model", default="qwen3-vl:4b", help="Ollama model name")
15
+ parser.add_argument("--retries", type=int, default=5, help="Max retries")
16
+ parser.add_argument("--index-dir", default=None, help="Path to pre-built FAISS index directory")
17
+
18
+ args = parser.parse_args()
19
+
20
+ print(f"Starting LangGraph Proof Agent with model: {args.model}")
21
+ agent = LangGraphAgent(
22
+ model_name=args.model,
23
+ max_retries=args.retries,
24
+ index_dir=args.index_dir,
25
+ )
26
+
27
+ success = agent.solve_file(args.file)
28
+
29
+ if success:
30
+ print("\nSuccess! The proof has been verified.")
31
+ else:
32
+ print("\nFailed to verify the proof.")
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
src/__init__.py DELETED
@@ -1 +0,0 @@
1
- # lean4-helper src package
 
 
src/agent.py DELETED
@@ -1,159 +0,0 @@
1
- """
2
- agent.py
3
-
4
- The core agentic self-correcting loop.
5
-
6
- Given a Lean 4 theorem statement, this agent will:
7
- 1. Retrieve relevant Mathlib4 lemmas via FAISS.
8
- 2. Prompt Claude to generate a proof.
9
- 3. Validate the proof with the Lean compiler.
10
- 4. On failure, feed the compiler error back to Claude and retry.
11
- 5. Return the final proof on success or raise after max retries.
12
- """
13
-
14
- import os
15
- from typing import Optional
16
-
17
- import anthropic
18
-
19
- from .lean_env import LeanResult, run_lean
20
- from .retriever import Retriever
21
-
22
- SYSTEM_PROMPT = """\
23
- You are an expert Lean 4 theorem prover with deep knowledge of Mathlib4.
24
- Your task is to write a complete, valid Lean 4 proof for the given theorem.
25
-
26
- Rules:
27
- - Output ONLY valid Lean 4 code. No explanation, no markdown, no fences.
28
- - Begin with the necessary imports (e.g., `import Mathlib`).
29
- - Use `theorem` or `lemma` keyword as appropriate.
30
- - Use the provided relevant Mathlib4 lemmas as hints.
31
- - If a previous attempt failed with a compiler error, fix only the reported issue.
32
- """
33
-
34
-
35
- def _build_user_message(
36
- theorem: str,
37
- context_lemmas: list[dict],
38
- previous_error: Optional[str] = None,
39
- previous_code: Optional[str] = None,
40
- ) -> str:
41
- """
42
- Construct the user message for Claude.
43
- """
44
- parts = []
45
-
46
- # Relevant Mathlib4 lemmas from RAG
47
- if context_lemmas:
48
- parts.append("## Potentially Relevant Mathlib4 Lemmas\n")
49
- for lemma in context_lemmas:
50
- name = lemma.get("name", "?")
51
- typ = lemma.get("type", "?")
52
- doc = lemma.get("doc", "")
53
- parts.append(f"- `{name} : {typ}`" + (f" — {doc}" if doc else ""))
54
- parts.append("")
55
-
56
- # The theorem to prove
57
- parts.append(f"## Theorem to Prove\n```lean\n{theorem}\n```\n")
58
-
59
- # Feedback from previous attempt
60
- if previous_code and previous_error:
61
- parts.append("## Previous Attempt (FAILED)\n")
62
- parts.append(f"```lean\n{previous_code}\n```\n")
63
- parts.append(f"**Compiler Error:**\n```\n{previous_error}\n```\n")
64
- parts.append("Please fix the error above and output the corrected proof.")
65
- else:
66
- parts.append("Please generate a complete Lean 4 proof for the theorem above.")
67
-
68
- return "\n".join(parts)
69
-
70
-
71
- class ProverAgent:
72
- """
73
- Agentic prover that iteratively generates and validates Lean 4 proofs.
74
- """
75
-
76
- def __init__(
77
- self,
78
- retriever: Optional[Retriever] = None,
79
- model: str = "claude-opus-4-5",
80
- max_retries: int = 5,
81
- k_lemmas: int = 5,
82
- lean_timeout: int = 60,
83
- ):
84
- """
85
- Args:
86
- retriever: Optional FAISS Retriever. If None, no RAG context is used.
87
- model: Claude model ID to use.
88
- max_retries: Maximum number of proof attempts before giving up.
89
- k_lemmas: Number of Mathlib lemmas to retrieve per query.
90
- lean_timeout: Timeout in seconds for the Lean compiler.
91
- """
92
- api_key = os.environ.get("ANTHROPIC_API_KEY")
93
- if not api_key:
94
- raise EnvironmentError(
95
- "ANTHROPIC_API_KEY is not set. Please add it to your .env file."
96
- )
97
- self.client = anthropic.Anthropic(api_key=api_key)
98
- self.retriever = retriever
99
- self.model = model
100
- self.max_retries = max_retries
101
- self.k_lemmas = k_lemmas
102
- self.lean_timeout = lean_timeout
103
-
104
- def prove(self, theorem: str, on_attempt=None) -> tuple[bool, str, int]:
105
- """
106
- Attempt to prove the given Lean 4 theorem statement.
107
-
108
- Args:
109
- theorem: A Lean 4 theorem/lemma declaration string.
110
- on_attempt: Optional callback(attempt, code, result) for streaming UI.
111
-
112
- Returns:
113
- (success, final_code, num_attempts)
114
- """
115
- # RAG: retrieve relevant lemmas
116
- context_lemmas = []
117
- if self.retriever:
118
- context_lemmas = self.retriever.retrieve(theorem, k=self.k_lemmas)
119
-
120
- messages = []
121
- previous_code = None
122
- previous_error = None
123
-
124
- for attempt in range(1, self.max_retries + 1):
125
- user_content = _build_user_message(
126
- theorem=theorem,
127
- context_lemmas=context_lemmas,
128
- previous_error=previous_error,
129
- previous_code=previous_code,
130
- )
131
-
132
- messages.append({"role": "user", "content": user_content})
133
-
134
- # Call Claude
135
- response = self.client.messages.create(
136
- model=self.model,
137
- max_tokens=2048,
138
- system=SYSTEM_PROMPT,
139
- messages=messages,
140
- )
141
- generated_code = response.content[0].text.strip()
142
-
143
- # Append assistant response to conversation history
144
- messages.append({"role": "assistant", "content": generated_code})
145
-
146
- # Validate with Lean compiler
147
- lean_result: LeanResult = run_lean(generated_code, timeout=self.lean_timeout)
148
-
149
- if on_attempt:
150
- on_attempt(attempt, generated_code, lean_result)
151
-
152
- if lean_result.success:
153
- return True, generated_code, attempt
154
-
155
- # Feed error back for next iteration
156
- previous_code = generated_code
157
- previous_error = "\n".join(lean_result.errors) or lean_result.output[:500]
158
-
159
- return False, previous_code or "", self.max_retries
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/cli.py DELETED
@@ -1,162 +0,0 @@
1
- """
2
- cli.py
3
-
4
- Interactive CLI for the Agentic Lean 4 Theorem Prover.
5
-
6
- Usage:
7
- python -m src.cli
8
- python -m src.cli --theorem "theorem add_comm (a b : Nat) : a + b = b + a := by sorry"
9
- python -m src.cli --file theorems.lean
10
- """
11
-
12
- import argparse
13
- import os
14
- import sys
15
- from pathlib import Path
16
-
17
- from dotenv import load_dotenv
18
- from rich.console import Console
19
- from rich.markdown import Markdown
20
- from rich.panel import Panel
21
- from rich.syntax import Syntax
22
- from rich.text import Text
23
- from rich import print as rprint
24
- from rich.rule import Rule
25
- from rich.spinner import Spinner
26
- from rich.live import Live
27
- from rich import box
28
-
29
- from .agent import ProverAgent
30
- from .lean_env import LeanResult
31
-
32
- # Load .env for ANTHROPIC_API_KEY
33
- load_dotenv()
34
-
35
- console = Console()
36
-
37
- BANNER = """
38
- [bold cyan]╔══════════════════════════════════════════════════╗[/bold cyan]
39
- [bold cyan]║ 🧮 Agentic Lean 4 Theorem Prover ║[/bold cyan]
40
- [bold cyan]║ Powered by Claude + FAISS + Mathlib4 RAG ║[/bold cyan]
41
- [bold cyan]╚══════════════════════════════════════════════════╝[/bold cyan]
42
- """
43
-
44
-
45
- def on_attempt_callback(attempt: int, code: str, result: LeanResult):
46
- """Rich-formatted callback for each proof attempt."""
47
- console.print(Rule(f"[bold]Attempt {attempt}[/bold]", style="dim"))
48
- console.print(Syntax(code, "lean", theme="monokai", line_numbers=True))
49
-
50
- if result.success:
51
- console.print(Panel("[bold green]✅ Lean accepted this proof![/bold green]", box=box.ROUNDED))
52
- else:
53
- errors_text = "\n".join(result.errors) if result.errors else result.output[:300]
54
- console.print(
55
- Panel(
56
- f"[bold red]❌ Compiler rejected proof:[/bold red]\n\n[red]{errors_text}[/red]",
57
- box=box.ROUNDED,
58
- )
59
- )
60
-
61
-
62
- def run_prover(theorem: str, use_rag: bool = True, max_retries: int = 5):
63
- """
64
- Main entry point: set up the agent and run it on the given theorem.
65
- """
66
- console.print(BANNER)
67
- console.print(Panel(f"[bold yellow]Theorem to prove:[/bold yellow]\n\n{theorem}", box=box.ROUNDED))
68
-
69
- # Optionally initialize retriever
70
- retriever = None
71
- if use_rag:
72
- index_path = "data/mathlib.index"
73
- meta_path = "data/mathlib_meta.pkl"
74
- if os.path.exists(index_path) and os.path.exists(meta_path):
75
- from .retriever import Retriever
76
- console.print("[dim]Loading FAISS index for Mathlib4 RAG...[/dim]")
77
- retriever = Retriever(index_path=index_path, meta_path=meta_path)
78
- console.print("[green]✓ FAISS index loaded.[/green]\n")
79
- else:
80
- console.print(
81
- "[yellow]⚠ FAISS index not found. Running without RAG context.[/yellow]\n"
82
- "[dim]Run `python -m src.retriever` to build the index first.[/dim]\n"
83
- )
84
-
85
- agent = ProverAgent(retriever=retriever, max_retries=max_retries)
86
-
87
- console.print(Rule("[bold]Starting Proof Search[/bold]", style="cyan"))
88
- success, final_code, num_attempts = agent.prove(theorem, on_attempt=on_attempt_callback)
89
-
90
- console.print(Rule(style="cyan"))
91
- if success:
92
- console.print(
93
- Panel(
94
- f"[bold green]🎉 Proof found in {num_attempts} attempt(s)![/bold green]",
95
- box=box.DOUBLE,
96
- )
97
- )
98
- console.print("\n[bold]Final Proof:[/bold]")
99
- console.print(Syntax(final_code, "lean", theme="monokai", line_numbers=True))
100
- else:
101
- console.print(
102
- Panel(
103
- f"[bold red]💀 Failed to find a proof after {num_attempts} attempts.[/bold red]\n"
104
- "[dim]Try increasing --max-retries or refining the theorem statement.[/dim]",
105
- box=box.DOUBLE,
106
- )
107
- )
108
- if final_code:
109
- console.print("\n[bold]Last Generated Code:[/bold]")
110
- console.print(Syntax(final_code, "lean", theme="monokai", line_numbers=True))
111
-
112
-
113
- def main():
114
- parser = argparse.ArgumentParser(
115
- description="Agentic Lean 4 Theorem Prover powered by Claude + FAISS + Mathlib4"
116
- )
117
- group = parser.add_mutually_exclusive_group()
118
- group.add_argument(
119
- "--theorem",
120
- type=str,
121
- help="A Lean 4 theorem declaration string to prove.",
122
- )
123
- group.add_argument(
124
- "--file",
125
- type=str,
126
- help="Path to a .lean file containing the theorem to prove.",
127
- )
128
- parser.add_argument(
129
- "--no-rag",
130
- action="store_true",
131
- help="Disable Mathlib4 RAG retrieval.",
132
- )
133
- parser.add_argument(
134
- "--max-retries",
135
- type=int,
136
- default=5,
137
- help="Maximum number of proof attempts (default: 5).",
138
- )
139
- args = parser.parse_args()
140
-
141
- if args.file:
142
- theorem = Path(args.file).read_text().strip()
143
- elif args.theorem:
144
- theorem = args.theorem.strip()
145
- else:
146
- console.print("[bold cyan]Enter your Lean 4 theorem (end with Ctrl+D on a new line):[/bold cyan]")
147
- try:
148
- lines = sys.stdin.read()
149
- theorem = lines.strip()
150
- except KeyboardInterrupt:
151
- console.print("\n[yellow]Cancelled.[/yellow]")
152
- sys.exit(0)
153
-
154
- if not theorem:
155
- console.print("[red]No theorem provided. Exiting.[/red]")
156
- sys.exit(1)
157
-
158
- run_prover(theorem=theorem, use_rag=not args.no_rag, max_retries=args.max_retries)
159
-
160
-
161
- if __name__ == "__main__":
162
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/langgraph_agent.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, TypedDict
3
+
4
+ from langgraph.graph import END, StateGraph
5
+
6
+ from lean_verifier import LeanEnvironment
7
+ from rag_chain import RAGProofChain
8
+ from retriever import MathLibRetriever
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # State
13
+ # ---------------------------------------------------------------------------
14
+
15
+ class ProofState(TypedDict):
16
+ file_path: str
17
+ lean_code: str
18
+ goals: List[str]
19
+ errors: List[str]
20
+ attempt: int
21
+ max_retries: int
22
+ status: str # "pending" | "success" | "failed"
23
+ retrieved_lemmas: list
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Nodes
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _read_file(path: str) -> str:
31
+ with open(path, "r") as f:
32
+ return f.read()
33
+
34
+
35
+ def _write_file(path: str, code: str) -> None:
36
+ with open(path, "w") as f:
37
+ f.write(code)
38
+
39
+
40
+ def _extract_lean_code(text: str) -> str:
41
+ if "```lean" in text:
42
+ return text.split("```lean")[1].split("```")[0].strip()
43
+ if "```" in text:
44
+ return text.split("```")[1].split("```")[0].strip()
45
+ return text.strip()
46
+
47
+
48
+ def make_verify_node(lean_env: LeanEnvironment):
49
+ def verify_node(state: ProofState) -> ProofState:
50
+ print(f"\n--- Attempt {state['attempt'] + 1} / {state['max_retries']} ---")
51
+ code = _read_file(state["file_path"])
52
+ result = lean_env.verify_proof(code)
53
+
54
+ new_status = "success" if result["status"] == "success" else "pending"
55
+ if new_status == "success":
56
+ print("Proof verified successfully!")
57
+ else:
58
+ print(
59
+ f"Verification failed. "
60
+ f"Errors: {len(result['errors'])}, Goals: {len(result['goals'])}"
61
+ )
62
+
63
+ return {
64
+ **state,
65
+ "lean_code": code,
66
+ "errors": result["errors"],
67
+ "goals": result["goals"],
68
+ "status": new_status,
69
+ }
70
+ return verify_node
71
+
72
+
73
+ def make_retrieve_node(retriever: MathLibRetriever):
74
+ def retrieve_node(state: ProofState) -> ProofState:
75
+ query = " ".join(state["goals"] + state["errors"])
76
+ print("Retrieving relevant Mathlib lemmas…")
77
+ lemmas = retriever.retrieve(query)
78
+ print(f" Retrieved {len(lemmas)} lemma(s).")
79
+ return {**state, "retrieved_lemmas": lemmas}
80
+ return retrieve_node
81
+
82
+
83
+ def make_generate_node(chain: RAGProofChain):
84
+ def generate_node(state: ProofState) -> ProofState:
85
+ print("Generating proof with LLM…")
86
+ raw = chain.generate(
87
+ lean_code=state["lean_code"],
88
+ goals=state["goals"],
89
+ errors=state["errors"],
90
+ retrieved_lemmas=state["retrieved_lemmas"],
91
+ )
92
+ new_code = _extract_lean_code(raw)
93
+
94
+ if not new_code or new_code.strip() == state["lean_code"].strip():
95
+ print("LLM produced no changes.")
96
+ return {**state, "attempt": state["attempt"] + 1, "status": "failed"}
97
+
98
+ _write_file(state["file_path"], new_code)
99
+ print("File updated.")
100
+ return {
101
+ **state,
102
+ "lean_code": new_code,
103
+ "attempt": state["attempt"] + 1,
104
+ }
105
+ return generate_node
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Router
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def should_continue(state: ProofState) -> str:
113
+ if state["status"] == "success":
114
+ return END
115
+ if state["attempt"] >= state["max_retries"]:
116
+ return END
117
+ return "retrieve"
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Graph assembly
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def build_graph(lean_env: LeanEnvironment, retriever: MathLibRetriever, chain: RAGProofChain):
125
+ g = StateGraph(ProofState)
126
+
127
+ g.add_node("verify", make_verify_node(lean_env))
128
+ g.add_node("retrieve", make_retrieve_node(retriever))
129
+ g.add_node("generate", make_generate_node(chain))
130
+
131
+ g.set_entry_point("verify")
132
+ g.add_conditional_edges("verify", should_continue, {"retrieve": "retrieve", END: END})
133
+ g.add_edge("retrieve", "generate")
134
+ g.add_edge("generate", "verify")
135
+
136
+ return g.compile()
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Public entry point
141
+ # ---------------------------------------------------------------------------
142
+
143
+ class LangGraphAgent:
144
+ def __init__(
145
+ self,
146
+ model_name: str = "qwen3-vl:4b",
147
+ max_retries: int = 5,
148
+ index_dir: str | None = None,
149
+ ):
150
+ self._lean_env = LeanEnvironment(use_mathlib=True)
151
+ self._retriever = MathLibRetriever(index_dir=index_dir)
152
+ self._chain = RAGProofChain(model_name=model_name)
153
+ self._graph = build_graph(self._lean_env, self._retriever, self._chain)
154
+ self._max_retries = max_retries
155
+
156
+ def solve_file(self, file_path: str) -> bool:
157
+ if not os.path.exists(file_path):
158
+ print(f"Error: {file_path} not found.")
159
+ return False
160
+
161
+ initial: ProofState = {
162
+ "file_path": file_path,
163
+ "lean_code": "",
164
+ "goals": [],
165
+ "errors": [],
166
+ "attempt": 0,
167
+ "max_retries": self._max_retries,
168
+ "status": "pending",
169
+ "retrieved_lemmas": [],
170
+ }
171
+
172
+ final = self._graph.invoke(initial)
173
+ return final["status"] == "success"
src/lean_env.py DELETED
@@ -1,83 +0,0 @@
1
- """
2
- lean_env.py
3
-
4
- Wrapper around the Lean 4 compiler. Sends Lean 4 code to the compiler,
5
- captures stdout/stderr, and parses the output to determine success or failure.
6
- """
7
-
8
- import subprocess
9
- import tempfile
10
- import os
11
- import re
12
- from dataclasses import dataclass
13
- from typing import Optional
14
-
15
-
16
- @dataclass
17
- class LeanResult:
18
- success: bool
19
- output: str
20
- errors: list[str]
21
- raw: str
22
-
23
-
24
- def run_lean(code: str, timeout: int = 60) -> LeanResult:
25
- """
26
- Write `code` to a temporary .lean file, run `lean` on it, and parse
27
- the compiler output.
28
-
29
- Args:
30
- code: Full Lean 4 source code string to validate.
31
- timeout: Maximum seconds to wait for the compiler.
32
-
33
- Returns:
34
- A LeanResult with success flag, cleaned output, and parsed errors.
35
- """
36
- with tempfile.NamedTemporaryFile(
37
- mode="w", suffix=".lean", delete=False
38
- ) as f:
39
- f.write(code)
40
- tmp_path = f.name
41
-
42
- try:
43
- result = subprocess.run(
44
- ["lean", tmp_path],
45
- capture_output=True,
46
- text=True,
47
- timeout=timeout,
48
- )
49
- raw = result.stdout + result.stderr
50
- errors = _parse_errors(raw)
51
- success = result.returncode == 0 and len(errors) == 0
52
- return LeanResult(success=success, output=raw.strip(), errors=errors, raw=raw)
53
- except FileNotFoundError:
54
- return LeanResult(
55
- success=False,
56
- output="",
57
- errors=["Lean 4 not found. Please install it via elan."],
58
- raw="",
59
- )
60
- except subprocess.TimeoutExpired:
61
- return LeanResult(
62
- success=False,
63
- output="",
64
- errors=[f"Lean compiler timed out after {timeout}s."],
65
- raw="",
66
- )
67
- finally:
68
- os.unlink(tmp_path)
69
-
70
-
71
- def _parse_errors(output: str) -> list[str]:
72
- """
73
- Extract human-readable error messages from Lean 4 compiler output.
74
- Lean 4 errors look like:
75
- /path/to/file.lean:10:5: error: unknown identifier 'foo'
76
- """
77
- pattern = re.compile(r".*?:\d+:\d+:\s*(error|warning):\s*(.+)")
78
- errors = []
79
- for line in output.splitlines():
80
- m = pattern.match(line)
81
- if m and m.group(1) == "error":
82
- errors.append(m.group(2).strip())
83
- return errors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lean_verifier.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List
2
+
3
+ from lean_interact import LeanREPLConfig, LeanServer, Command, TempRequireProject, LeanRequire
4
+
5
+
6
+ class LeanEnvironment:
7
+ """
8
+ Manages the Lean REPL environment for verifying Lean 4 proofs.
9
+ """
10
+
11
+ def __init__(self, use_mathlib: bool = True, lean_version: str = "v4.8.0"):
12
+ """
13
+ Initializes the Lean environment.
14
+
15
+ Args:
16
+ use_mathlib (bool): If True, configures a TempRequireProject with Mathlib.
17
+ This may take a while to build on the first run.
18
+ lean_version (str): The Lean 4 version to use. Default is v4.8.0.
19
+ """
20
+ self.lean_version = lean_version
21
+ self.use_mathlib = use_mathlib
22
+
23
+ if self.use_mathlib:
24
+ # We use TempRequireProject with mathlib as specified in lean_interact documentation
25
+ project = TempRequireProject(
26
+ lean_version=self.lean_version,
27
+ require="mathlib"
28
+ )
29
+ self.config = LeanREPLConfig(project=project)
30
+ else:
31
+ self.config = LeanREPLConfig(lean_version=self.lean_version)
32
+
33
+ self.server = LeanServer(self.config)
34
+
35
+ def verify_proof(self, lean_code: str) -> Dict[str, Any]:
36
+ """
37
+ Executes a block of Lean code and verifies if it is a correct proof.
38
+
39
+ Args:
40
+ lean_code (str): The full Lean 4 code string containing imports, theorem statement, and proof.
41
+
42
+ Returns:
43
+ dict: A dictionary containing the status, errors (if any), and goals (if open sorries remain).
44
+ """
45
+ response = self.server.run(Command(cmd=lean_code))
46
+
47
+ errors = []
48
+ goals = []
49
+
50
+ # Check for error or warning messages
51
+ if hasattr(response, 'messages') and response.messages:
52
+ for msg in response.messages:
53
+ if msg.severity in ['error', 'warning']:
54
+ # E.g., 'declaration uses 'sorry'' is a warning, but we might want to capture it
55
+ errors.append(msg.data)
56
+
57
+ # Check for open goals (sorries)
58
+ if hasattr(response, 'sorries') and response.sorries:
59
+ for sorry in response.sorries:
60
+ if sorry.goal:
61
+ goals.append(sorry.goal)
62
+
63
+ is_success = len(errors) == 0 and len(goals) == 0
64
+
65
+ return {
66
+ "status": "success" if is_success else "failure",
67
+ "errors": errors,
68
+ "goals": goals,
69
+ "env": getattr(response, "env", None)
70
+ }
src/lmm_client.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ollama
2
+ from typing import List, Dict, Any, Optional
3
+
4
+ class LMMClient:
5
+ """
6
+ Client for interacting with local LMMs via Ollama.
7
+ Focuses on Qwen3-VL:4B for high-reasoning tasks.
8
+ """
9
+
10
+ def __init__(self, model_name: str = "qwen3-vl:4b"):
11
+ self.model_name = model_name
12
+
13
+ def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
14
+ """
15
+ Sends a chat request to the model.
16
+ """
17
+ messages = []
18
+ if system_prompt:
19
+ messages.append({'role': 'system', 'content': system_prompt})
20
+
21
+ messages.append({'role': 'user', 'content': prompt})
22
+
23
+ response = ollama.chat(
24
+ model=self.model_name,
25
+ messages=messages
26
+ )
27
+ return response['message']['content']
28
+
29
+ def generate_proof_steps(self, lean_code: str, goals: List[str], errors: List[str]) -> str:
30
+ """
31
+ Specific helper to generate proof steps based on current Lean state.
32
+ """
33
+ system_prompt = (
34
+ "You are an expert Lean 4 proof assistant. "
35
+ "Your goal is to complete the proof by replacing 'sorry' with valid Lean 4 code. "
36
+ "Use Mathlib theorems where appropriate. "
37
+ "Respond ONLY with the corrected Lean code block."
38
+ )
39
+
40
+ prompt = f"""
41
+ Current Lean Code:
42
+ ```lean
43
+ {lean_code}
44
+ ```
45
+
46
+ Current Proof Goals:
47
+ {chr(10).join(goals)}
48
+
49
+ Lean Errors:
50
+ {chr(10).join(errors)}
51
+
52
+ Please provide the corrected Lean code. Focus on solving the current goals and fixing the errors.
53
+ """
54
+ return self.chat(prompt, system_prompt)
src/mathlib_corpus.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import glob
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from langchain_core.documents import Document
8
+
9
+
10
+ # Regex to capture optional docstring + declaration line
11
+ _DECL_PATTERN = re.compile(
12
+ r'(?:/--\s*(.*?)\s*-/)?\s*' # optional /-- docstring -/
13
+ r'(?:@\[.*?\]\s*)*' # optional attributes
14
+ r'(theorem|lemma|def|noncomputable def)\s+'
15
+ r'(\S+)\s*' # declaration name
16
+ r'(.*?)\s*:=', # everything up to :=
17
+ re.DOTALL,
18
+ )
19
+
20
+
21
+ def _find_mathlib_root() -> Optional[str]:
22
+ """
23
+ Returns the path to the Mathlib4 source directory, searching common locations.
24
+ """
25
+ candidates = [
26
+ # Lake package cache (used by lean-interact's TempRequireProject)
27
+ os.path.expanduser("~/.elan/toolchains"),
28
+ os.path.expanduser("~/.cache/mathlib"),
29
+ # Nix / Homebrew Lean setups
30
+ "/usr/local/lib/lean",
31
+ "/opt/homebrew/lib/lean",
32
+ ]
33
+
34
+ # Also check for a .lake/packages directory next to the current file
35
+ here = Path(__file__).resolve().parent.parent
36
+ lake_pkg = here / ".lake" / "packages" / "mathlib" / "Mathlib"
37
+ if lake_pkg.exists():
38
+ return str(lake_pkg.parent)
39
+
40
+ for root in candidates:
41
+ if not os.path.isdir(root):
42
+ continue
43
+ # Walk up to 4 levels looking for a Mathlib directory
44
+ for dirpath, dirnames, _ in os.walk(root):
45
+ depth = dirpath.replace(root, "").count(os.sep)
46
+ if depth > 4:
47
+ dirnames.clear()
48
+ continue
49
+ if "Mathlib" in dirnames:
50
+ return dirpath
51
+ return None
52
+
53
+
54
+ def _parse_lean_file(path: str) -> List[Document]:
55
+ """
56
+ Extracts theorem/lemma/def declarations from a single .lean file.
57
+ Returns a list of Documents with page_content = "<name> : <signature>".
58
+ """
59
+ try:
60
+ text = Path(path).read_text(encoding="utf-8", errors="ignore")
61
+ except OSError:
62
+ return []
63
+
64
+ docs = []
65
+ for match in _DECL_PATTERN.finditer(text):
66
+ docstring = (match.group(1) or "").strip()
67
+ kind = match.group(2)
68
+ name = match.group(3)
69
+ signature = re.sub(r'\s+', ' ', match.group(4)).strip()
70
+
71
+ content = f"{name} : {signature}"
72
+ if docstring:
73
+ content = f"{docstring}\n{content}"
74
+
75
+ line = text[: match.start()].count("\n") + 1
76
+ docs.append(Document(
77
+ page_content=content,
78
+ metadata={"kind": kind, "name": name, "file": path, "line": line},
79
+ ))
80
+ return docs
81
+
82
+
83
+ class MathLibCorpus:
84
+ """
85
+ Extracts LangChain Documents from Mathlib4 source files on disk.
86
+ """
87
+
88
+ def __init__(self, mathlib_root: Optional[str] = None):
89
+ self.mathlib_root = mathlib_root or _find_mathlib_root()
90
+
91
+ def extract(self, max_files: Optional[int] = None) -> List[Document]:
92
+ """
93
+ Walks Mathlib source files and extracts declaration Documents.
94
+
95
+ Args:
96
+ max_files: If set, stop after processing this many .lean files
97
+ (useful for quick tests).
98
+
99
+ Returns:
100
+ List of LangChain Documents, one per declaration found.
101
+ """
102
+ if not self.mathlib_root:
103
+ raise RuntimeError(
104
+ "Could not locate Mathlib4 source. "
105
+ "Pass mathlib_root explicitly or run `lake exe cache get` first."
106
+ )
107
+
108
+ pattern = os.path.join(self.mathlib_root, "**", "*.lean")
109
+ files = glob.glob(pattern, recursive=True)
110
+ if max_files:
111
+ files = files[:max_files]
112
+
113
+ docs: List[Document] = []
114
+ for path in files:
115
+ docs.extend(_parse_lean_file(path))
116
+
117
+ return docs
src/proof_agent.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langgraph_agent import LangGraphAgent
2
+
3
+
4
+ class ProofAgent:
5
+ """Thin compatibility wrapper around LangGraphAgent."""
6
+
7
+ def __init__(self, model_name: str = "qwen3-vl:4b", max_retries: int = 5):
8
+ self._agent = LangGraphAgent(model_name=model_name, max_retries=max_retries)
9
+
10
+ def solve_file(self, file_path: str) -> bool:
11
+ return self._agent.solve_file(file_path)
src/rag_chain.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from langchain_core.documents import Document
4
+ from langchain_core.output_parsers import StrOutputParser
5
+ from langchain_core.prompts import ChatPromptTemplate
6
+ from langchain_ollama import OllamaLLM
7
+
8
+
9
+ _SYSTEM = (
10
+ "You are an expert Lean 4 proof assistant with deep knowledge of Mathlib. "
11
+ "Your task is to complete the proof by replacing every `sorry` with valid Lean 4 tactic code. "
12
+ "Use only Mathlib theorems and tactics. "
13
+ "Respond ONLY with the corrected Lean code inside a single ```lean ... ``` block."
14
+ )
15
+
16
+ _HUMAN = """\
17
+ ## Current Lean Code
18
+ ```lean
19
+ {lean_code}
20
+ ```
21
+
22
+ ## Open Proof Goals
23
+ {goals}
24
+
25
+ ## Lean Errors
26
+ {errors}
27
+
28
+ ## Relevant Mathlib Lemmas
29
+ {retrieved_lemmas}
30
+
31
+ Provide the corrected Lean code that solves all goals and fixes all errors.
32
+ """
33
+
34
+
35
+ def _format_docs(docs: List[Document]) -> str:
36
+ if not docs:
37
+ return "(none retrieved)"
38
+ return "\n".join(
39
+ f"- `{d.metadata.get('name', '?')}`: {d.page_content}" for d in docs
40
+ )
41
+
42
+
43
+ class RAGProofChain:
44
+ """
45
+ LangChain LCEL chain: retrieved context + proof state → corrected Lean code.
46
+ """
47
+
48
+ def __init__(self, model_name: str = "qwen3-vl:4b"):
49
+ prompt = ChatPromptTemplate.from_messages([
50
+ ("system", _SYSTEM),
51
+ ("human", _HUMAN),
52
+ ])
53
+ llm = OllamaLLM(model=model_name)
54
+ self._chain = prompt | llm | StrOutputParser()
55
+
56
+ def generate(
57
+ self,
58
+ lean_code: str,
59
+ goals: List[str],
60
+ errors: List[str],
61
+ retrieved_lemmas: List[Document],
62
+ ) -> str:
63
+ """
64
+ Generate corrected Lean code given the current proof state and retrieved lemmas.
65
+ """
66
+ return self._chain.invoke({
67
+ "lean_code": lean_code,
68
+ "goals": "\n".join(goals) or "(none)",
69
+ "errors": "\n".join(errors) or "(none)",
70
+ "retrieved_lemmas": _format_docs(retrieved_lemmas),
71
+ })
src/retriever.py CHANGED
@@ -1,113 +1,126 @@
1
- """
2
- retriever.py
3
-
4
- Builds and queries a FAISS index over Mathlib4 lemma docstrings.
5
-
6
- Usage:
7
- # Build index from a jsonl file of lemmas:
8
- build_index("data/mathlib_lemmas.jsonl", "data/mathlib.index")
9
-
10
- # Query at runtime:
11
- retriever = Retriever("data/mathlib.index", "data/mathlib_lemmas.jsonl")
12
- lemmas = retriever.retrieve("commutativity of addition", k=5)
13
- """
14
-
15
- import json
16
  import os
17
- import pickle
18
  from pathlib import Path
19
- from typing import Optional
20
 
21
- import faiss
22
- import numpy as np
23
- from sentence_transformers import SentenceTransformer
 
 
 
 
 
24
 
25
- EMBEDDING_MODEL = "all-MiniLM-L6-v2"
26
- INDEX_FILE = "data/mathlib.index"
27
- META_FILE = "data/mathlib_meta.pkl"
28
 
 
 
 
29
 
30
- class Retriever:
 
31
  """
32
- Manages the FAISS index and performs semantic retrieval of Mathlib4 lemmas.
 
 
 
33
  """
34
 
35
  def __init__(
36
  self,
37
- index_path: str = INDEX_FILE,
38
- meta_path: str = META_FILE,
39
- model_name: str = EMBEDDING_MODEL,
40
  ):
41
- self.model = SentenceTransformer(model_name)
42
- self.index = faiss.read_index(index_path)
43
- with open(meta_path, "rb") as f:
44
- self.metadata = pickle.load(f) # list of dicts: {name, type, doc}
45
 
46
- def retrieve(self, query: str, k: int = 5) -> list[dict]:
 
 
 
 
 
 
 
47
  """
48
- Return the top-k most relevant Mathlib4 lemmas for a given query.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  Args:
51
- query: Natural language or Lean theorem statement.
52
- k: Number of results to return.
53
 
54
  Returns:
55
- List of lemma dicts with keys 'name', 'type', 'doc'.
56
  """
57
- embedding = self.model.encode([query], normalize_embeddings=True)
58
- embedding = np.array(embedding, dtype=np.float32)
59
- _, indices = self.index.search(embedding, k)
60
- return [self.metadata[i] for i in indices[0] if i < len(self.metadata)]
61
-
62
-
63
- def build_index(
64
- lemmas_jsonl: str = "data/mathlib_lemmas.jsonl",
65
- index_path: str = INDEX_FILE,
66
- meta_path: str = META_FILE,
67
- model_name: str = EMBEDDING_MODEL,
68
- ) -> None:
69
- """
70
- Build a FAISS index from a JSONL file of Mathlib4 lemmas.
71
-
72
- Each line in `lemmas_jsonl` should be a JSON object with:
73
- - "name": lemma name (str)
74
- - "type": Lean type signature (str)
75
- - "doc": docstring or description (str, optional)
76
-
77
- Args:
78
- lemmas_jsonl: Path to the input JSONL file.
79
- index_path: Where to save the FAISS index file.
80
- meta_path: Where to save the metadata pickle file.
81
- model_name: SentenceTransformer model to use for embeddings.
82
- """
83
- print(f"Loading lemmas from {lemmas_jsonl}...")
84
- metadata = []
85
- texts = []
86
- with open(lemmas_jsonl, "r") as f:
87
- for line in f:
88
- entry = json.loads(line.strip())
89
- metadata.append(entry)
90
- # Build a rich text representation for embedding
91
- text = f"{entry.get('name', '')} : {entry.get('type', '')}. {entry.get('doc', '')}"
92
- texts.append(text)
93
-
94
- print(f"Loaded {len(texts)} lemmas. Generating embeddings...")
95
- model = SentenceTransformer(model_name)
96
- embeddings = model.encode(texts, normalize_embeddings=True, show_progress_bar=True)
97
- embeddings = np.array(embeddings, dtype=np.float32)
98
-
99
- print("Building FAISS index...")
100
- dim = embeddings.shape[1]
101
- index = faiss.IndexFlatIP(dim) # Inner product = cosine similarity (normalized)
102
- index.add(embeddings)
103
-
104
- os.makedirs(Path(index_path).parent, exist_ok=True)
105
- faiss.write_index(index, index_path)
106
- with open(meta_path, "wb") as f:
107
- pickle.dump(metadata, f)
108
-
109
- print(f"Index saved to {index_path} ({len(texts)} vectors, dim={dim})")
110
-
111
-
112
- if __name__ == "__main__":
113
- build_index()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
2
  from pathlib import Path
3
+ from typing import List, Optional
4
 
5
+ from langchain_community.retrievers import BM25Retriever
6
+ from langchain_community.vectorstores import FAISS
7
+ from langchain_community.cross_encoders import HuggingFaceCrossEncoder
8
+ from langchain.retrievers import EnsembleRetriever
9
+ from langchain.retrievers.document_compressors import CrossEncoderReranker
10
+ from langchain.retrievers import ContextualCompressionRetriever
11
+ from langchain_huggingface import HuggingFaceEmbeddings
12
+ from langchain_core.documents import Document
13
 
14
+ from mathlib_corpus import MathLibCorpus
 
 
15
 
16
+ _DEFAULT_INDEX_DIR = Path(__file__).resolve().parent.parent / "data" / "mathlib_index"
17
+ _EMBED_MODEL = "all-MiniLM-L6-v2"
18
+ _RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
19
 
20
+
21
+ class MathLibRetriever:
22
  """
23
+ Hybrid FAISS + BM25 retriever with CrossEncoder reranking over Mathlib lemmas.
24
+
25
+ On first use, call build() to create and persist the index.
26
+ Subsequent runs load from disk automatically.
27
  """
28
 
29
  def __init__(
30
  self,
31
+ index_dir: Optional[str] = None,
32
+ top_k: int = 20,
33
+ rerank_top_k: int = 5,
34
  ):
35
+ self.index_dir = Path(index_dir) if index_dir else _DEFAULT_INDEX_DIR
36
+ self.top_k = top_k
37
+ self.rerank_top_k = rerank_top_k
38
+ self._retriever = None
39
 
40
+ # ------------------------------------------------------------------
41
+ # Public API
42
+ # ------------------------------------------------------------------
43
+
44
+ def build(self, mathlib_root: Optional[str] = None, max_files: Optional[int] = None) -> None:
45
+ """
46
+ Extract Mathlib documents, build FAISS + BM25 indices, and persist to disk.
47
+ Call this once (via scripts/build_index.py) before first use.
48
  """
49
+ print("Extracting Mathlib corpus…")
50
+ corpus = MathLibCorpus(mathlib_root=mathlib_root)
51
+ docs = corpus.extract(max_files=max_files)
52
+ print(f" {len(docs)} declarations extracted.")
53
+
54
+ embeddings = self._embeddings()
55
+
56
+ print("Building FAISS index…")
57
+ faiss_store = FAISS.from_documents(docs, embeddings)
58
+ self.index_dir.mkdir(parents=True, exist_ok=True)
59
+ faiss_store.save_local(str(self.index_dir))
60
+ print(f" Index saved to {self.index_dir}")
61
+
62
+ self._retriever = self._build_retriever(faiss_store, docs)
63
+
64
+ def retrieve(self, query: str, k: Optional[int] = None) -> List[Document]:
65
+ """
66
+ Retrieve and rerank the most relevant Mathlib lemmas for a query.
67
 
68
  Args:
69
+ query: Natural-language or Lean-syntax query (e.g., proof goals + errors).
70
+ k: Number of results to return after reranking (defaults to self.rerank_top_k).
71
 
72
  Returns:
73
+ List of Documents ranked by relevance.
74
  """
75
+ if self._retriever is None:
76
+ self._load()
77
+ results = self._retriever.invoke(query)
78
+ return results[: k or self.rerank_top_k]
79
+
80
+ def is_index_built(self) -> bool:
81
+ return (self.index_dir / "index.faiss").exists()
82
+
83
+ # ------------------------------------------------------------------
84
+ # Internal helpers
85
+ # ------------------------------------------------------------------
86
+
87
+ def _embeddings(self) -> HuggingFaceEmbeddings:
88
+ return HuggingFaceEmbeddings(model_name=_EMBED_MODEL)
89
+
90
+ def _load(self) -> None:
91
+ if not self.is_index_built():
92
+ raise RuntimeError(
93
+ f"No FAISS index found at {self.index_dir}. "
94
+ "Run `python scripts/build_index.py` first."
95
+ )
96
+ print("Loading FAISS index from disk…")
97
+ embeddings = self._embeddings()
98
+ faiss_store = FAISS.load_local(
99
+ str(self.index_dir),
100
+ embeddings,
101
+ allow_dangerous_deserialization=True,
102
+ )
103
+
104
+ # Re-build BM25 from FAISS docstore
105
+ docs = list(faiss_store.docstore._dict.values())
106
+ self._retriever = self._build_retriever(faiss_store, docs)
107
+
108
+ def _build_retriever(self, faiss_store: FAISS, docs: List[Document]):
109
+ faiss_retriever = faiss_store.as_retriever(
110
+ search_kwargs={"k": self.top_k}
111
+ )
112
+ bm25_retriever = BM25Retriever.from_documents(docs)
113
+ bm25_retriever.k = self.top_k
114
+
115
+ ensemble = EnsembleRetriever(
116
+ retrievers=[faiss_retriever, bm25_retriever],
117
+ weights=[0.6, 0.4],
118
+ )
119
+
120
+ cross_encoder = HuggingFaceCrossEncoder(model_name=_RERANK_MODEL)
121
+ reranker = CrossEncoderReranker(model=cross_encoder, top_n=self.rerank_top_k)
122
+
123
+ return ContextualCompressionRetriever(
124
+ base_compressor=reranker,
125
+ base_retriever=ensemble,
126
+ )
 
 
 
 
 
tests/test_lean_verifier.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ import sys
3
+ import os
4
+
5
+ # Add src to Python path
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src')))
7
+
8
+ from lean_verifier import LeanEnvironment
9
+
10
+ class TestLeanVerifier(unittest.TestCase):
11
+ @classmethod
12
+ def setUpClass(cls):
13
+ # We'll use Mathlib here since our goal is to verify it works with the MVP setup
14
+ cls.lean_env = LeanEnvironment(use_mathlib=True)
15
+
16
+ def test_correct_proof(self):
17
+ lean_code = """
18
+ import Mathlib
19
+
20
+ theorem add_comm_test (n m : Nat) : n + m = m + n := by
21
+ exact Nat.add_comm n m
22
+ """
23
+ result = self.lean_env.verify_proof(lean_code)
24
+ self.assertEqual(result["status"], "success")
25
+ self.assertEqual(len(result["errors"]), 0)
26
+ self.assertEqual(len(result["goals"]), 0)
27
+
28
+ def test_incorrect_proof_type_mismatch(self):
29
+ lean_code = """
30
+ import Mathlib
31
+
32
+ theorem add_comm_test (n m : Nat) : n + m = m + n := by
33
+ exact n
34
+ """
35
+ result = self.lean_env.verify_proof(lean_code)
36
+ self.assertEqual(result["status"], "failure")
37
+ self.assertTrue(any("type mismatch" in err or "application type mismatch" in err for err in result["errors"]),
38
+ f"Expected type mismatch error, got: {result['errors']}")
39
+
40
+ def test_incomplete_proof_sorry(self):
41
+ lean_code = """
42
+ import Mathlib
43
+
44
+ theorem my_incomplete_thm (n : Nat) : n = 5 → n = 5 := by
45
+ sorry
46
+ """
47
+ result = self.lean_env.verify_proof(lean_code)
48
+ self.assertEqual(result["status"], "failure")
49
+ # Ensure it has an error indicating sorry
50
+ self.assertTrue(any("uses 'sorry'" in err for err in result["errors"]),
51
+ f"Expected sorry warning/error, got: {result['errors']}")
52
+ # Ensure it outputs the goal
53
+ self.assertEqual(len(result["goals"]), 1)
54
+ self.assertIn("⊢ n = 5 → n = 5", result["goals"][0])
55
+
56
+ if __name__ == '__main__':
57
+ unittest.main()