File size: 19,242 Bytes
a3c590d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 | """docs/PLAN_REDISENO_PROVER.md — demostrador local con servidor simulado (0 API, sin GPU):
etapa 0 sobre el teorema completo, autocorrección con nombres sugeridos por el RAG, línea base
`baseline_prover`, degradación ante salidas ilegibles del Crítico, y las piezas puras."""
from __future__ import annotations
import json
from ragsolver.agents.agents import Agents, load_agents_config
from ragsolver.agents.schemas import Problem, Verdict
from ragsolver.llm import LLMResponse
from ragsolver.orchestrator import graph as G
from ragsolver.orchestrator.run import effective_config, run_prover_only
from ragsolver.prover import extract_tactics, goal_to_theorem, prove, renamed_inaccessible, unknown_names
from ragsolver.rag.schema import Citation, Hit, RetrievalResult
PROBLEM = Problem(problem_id="p1", informal_statement="x = x", formal_statement="theorem p1 (x : ℝ) (h : 0 < x) : x ≤ x + 1 := by sorry", header="open Real")
def _answer(tactics: str, name: str = "p1") -> str:
return f"<think>plan</think>\nProof plan...\n```lean4\nimport Mathlib\n\ntheorem {name} (x : ℝ) (h : 0 < x) : x ≤ x + 1 := by\n" + "\n".join(" " + l for l in tactics.splitlines()) + "\n```"
class FakeProver:
def __init__(self, answers):
self.answers, self.requests = list(answers), []
def sample(self, messages, k, seed0=13):
self.requests.append((messages, k))
out = [self.answers.pop(0) if self.answers else "(nothing)" for _ in range(k)]
return out
class FakeChecker:
"""ok si la táctica es `linarith`; `Real.add_pow_le_pow_mul_pow_of_sq_le_sq` (nombre viejo) da unknown constant."""
def __init__(self):
self.checked = []
def check(self, stmt, tactics, header="", hb=400000):
self.checked.append(tactics)
if "old_lemma_name" in tactics:
return Verdict(ok=False, errors=[{"line": 3, "col": 2, "message": "Unknown constant `old_lemma_name`"}])
return Verdict(ok=tactics.strip() in ("linarith", "exact le_add_of_nonneg_right zero_le_one"), errors=[] if tactics.strip() in ("linarith", "exact le_add_of_nonneg_right zero_le_one") else [{"line": 3, "col": 2, "message": "linarith failed"}])
def signatures(self, names, header=""):
return {n: "∀ {a b : α}, 0 ≤ b → a ≤ a + b" for n in names if n == "le_add_of_nonneg_right"}
def goals(self, stmt, skeleton, header="", hb=400000):
return [], []
class FakeRetriever:
def __init__(self):
self.requests = []
def search(self, req):
self.requests.append(req)
cit = Citation(citation_id="M1", chunk_id="mathlib:le_add_of_nonneg_right:0", corpus="mathlib", unit_type="mathlib_decl", label="x", locator={"decl_name": "le_add_of_nonneg_right"}, retrieved_for={"problem_id": "p1"})
return RetrievalResult(hits=[Hit(chunk_id=cit.chunk_id, corpus="mathlib", unit_type="mathlib_decl", rank=1, text="", metadata={"decl_name": "le_add_of_nonneg_right"}, citation=cit)])
class NoLLM:
exhausted, models, tokens_in, tokens_out = [], ["none"], 0, 0
def generate(self, *a, **k):
raise AssertionError("la etapa 0 no debe llamar a la API")
def test_root_stage_solves_with_zero_api_calls(tmp_path):
base = load_agents_config()
cfg = effective_config(base, "full")
orch = G.Orchestrator(Agents(NoLLM(), base, trace_dir=str(tmp_path)), FakeRetriever(), FakeChecker(), FakeProver([_answer("nlinarith"), _answer("linarith")] + ["x"] * 6))
rep = orch.solve(PROBLEM, cfg, "run:p1")
assert rep.status == "done" and rep.llm_calls == 0 and rep.solved_by == "prover_root"
assert ":= by\n linarith" in rep.lean_code and rep.prover_samples >= 2
def test_correction_round_gets_rag_name_suggestions_and_cites_them():
pr = FakeProver([_answer("exact old_lemma_name zero_le_one")] + ["(nothing)"] * 3 + [_answer("exact le_add_of_nonneg_right zero_le_one")])
ck, rt = FakeChecker(), FakeRetriever()
tac, log = prove(pr, ck, PROBLEM.formal_statement, PROBLEM.header, "p1", k=4, correction_rounds=1, retriever=rt)
assert tac == "exact le_add_of_nonneg_right zero_le_one" and log["solved_in"] == "correction1"
fix_msg = pr.requests[-1][0][-1]["content"]
assert "old_lemma_name` does not exist" in fix_msg and "le_add_of_nonneg_right : " in fix_msg # sugerencia validada contra Lean
assert log["suggested"] == {"le_add_of_nonneg_right": "∀ {a b : α}, 0 ≤ b → a ≤ a + b"} and rt.requests[0].mode == "error"
# sin RAG: la corrección lleva sólo los errores de Lean
pr2 = FakeProver([_answer("exact old_lemma_name zero_le_one")] + ["(nothing)"] * 3 + ["(nothing)"])
prove(pr2, FakeChecker(), PROBLEM.formal_statement, PROBLEM.header, "p1", k=4, correction_rounds=1, retriever=None)
assert "Existing declarations" not in pr2.requests[-1][0][-1]["content"]
def test_baseline_prover_has_no_rag_and_no_api():
cfg = effective_config(load_agents_config(), "baseline_prover")
rep = run_prover_only(PROBLEM, cfg, FakeChecker(), FakeProver([_answer("linarith")] + ["x"] * 7))
assert rep.status == "done" and rep.llm_calls == 0 and rep.solved_by == "prover_root"
def test_pure_helpers():
out = "x\n```lean4\ntheorem t (x : ℝ) : x = x := by\n rfl\n\ntheorem other : True := by\n trivial\n```"
assert extract_tactics(out, "t") == "rfl"
assert extract_tactics("no code", "t") is None and extract_tactics("```lean4\ntheorem t : True := by\n```", "t") is None
assert unknown_names([{"message": "unknown identifier 'foo.bar'"}, {"message": "Unknown constant `baz`"}]) == ["foo.bar", "baz"]
g = "n : ℕ\ninst✝ : Fact (Nat.Prime n)\nx✝ : ℕ\nh : x✝ ∣ n\n⊢ x✝ = 1 ∨ x✝ = n"
assert goal_to_theorem(g, "g") == "theorem g (n : ℕ) [Fact (Nat.Prime n)] (x_0 : ℕ) (h : x_0 ∣ n) : x_0 = 1 ∨ x_0 = n := by sorry"
assert renamed_inaccessible(g) == ["x_0"]
assert goal_to_theorem("no turnstile", "g") is None
def test_disk_cache_reuses_identical_samples(tmp_path, monkeypatch):
from ragsolver.prover import LocalProver
calls = {"n": 0}
class Resp:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {"choices": [{"message": {"content": "```lean4\ntheorem t : True := by\n trivial\n```"}}], "usage": {"completion_tokens": 7}}
def post(url, json):
calls["n"] += 1
return Resp()
p1 = LocalProver(url="http://x", cache_path=str(tmp_path / "c.jsonl"))
monkeypatch.setattr(p1.client, "post", post)
msgs = [{"role": "user", "content": "q"}]
a = p1.sample(msgs, 2)
p2 = LocalProver(url="http://x", cache_path=str(tmp_path / "c.jsonl")) # otra corrida: lee la caché del disco
monkeypatch.setattr(p2.client, "post", post)
assert p2.sample(msgs, 2) == a and calls["n"] == 2 # 2 semillas distintas, generadas una sola vez
assert p2.sample(msgs, 3)[2] and calls["n"] == 3 # sólo la semilla nueva va al servidor
class DraftLLM:
"""Planificador + Generador para DRAFT; cualquier otro agente es un error (no hay respaldo por API)."""
exhausted, models, tokens_in, tokens_out = [], ["fake"], 0, 0
def __init__(self):
self.prompts = []
def generate(self, prompt, schema=None, images=(), system=None, max_tokens=None):
self.prompts.append(prompt)
if "Decompose the problem" in prompt:
out = {"strategy": "STRATEGY-AM-GM", "subgoals": [{"subgoal_id": "s1", "statement_nl": "STEP-ONE x is positive"}]}
elif "proof sketch in natural language" in prompt:
out = {"sketch": "SKETCH-TEXT add one to both sides", "used_citations": []}
else:
raise AssertionError(f"llamada a la API fuera de DRAFT: {prompt[:80]}")
return LLMResponse(json.dumps(out), "fake", 10, 5)
class GoalChecker(FakeChecker):
def goals(self, stmt, skeleton, header="", hb=400000):
return [{"goal": "x : ℝ\nh : 0 < x\n⊢ x ≤ x + 1", "proof_state": 0}], []
def test_api_fallback_off_ends_after_stage0_without_api_calls(tmp_path):
cfg = effective_config(load_agents_config(), "full")
assert cfg["prover"]["api_fallback"] is False and not cfg["prover"]["dsp"]
orch = G.Orchestrator(Agents(NoLLM(), load_agents_config(), trace_dir=str(tmp_path)), FakeRetriever(), FakeChecker(), FakeProver(["x"] * 12))
rep = orch.solve(PROBLEM, cfg, "run:p1")
assert rep.status == "fail" and rep.llm_calls == 0 and any("api_fallback" in e for e in rep.error_types)
def test_draft_puts_plan_sketch_and_rag_lemmas_in_the_prover_prompt(tmp_path, monkeypatch):
monkeypatch.setattr(G.P, "prior_channel", lambda goal, k=32: ["le_add_of_nonneg_right"])
monkeypatch.setattr(G.P, "load_prior", lambda: ({"decl_name": "le_add_of_nonneg_right", "type": "0 ≤ b → a ≤ a + b"},))
base = load_agents_config()
cfg = effective_config(base, "dsp")
llm = DraftLLM()
# etapa 0: 8 muestras + 2 correcciones fallan; DRAFT: la primera muestra con el borrador verifica
pr = FakeProver([_answer("nlinarith")] * 10 + [_answer("linarith")])
orch = G.Orchestrator(Agents(llm, base, trace_dir=str(tmp_path)), FakeRetriever(), GoalChecker(), pr)
rep = orch.solve(PROBLEM, cfg, "run:p1")
assert rep.status == "done" and rep.solved_by == "prover_draft" and rep.llm_calls == 2
draft_prompt = pr.requests[-1][0][-1]["content"]
assert "STRATEGY-AM-GM" in draft_prompt and "STEP-ONE" in draft_prompt and "SKETCH-TEXT" in draft_prompt
assert "le_add_of_nonneg_right : 0 ≤ b → a ≤ a + b" in draft_prompt
assert "le_add_of_nonneg_right" in {c.locator.get("decl_name") for c in rep.citations} # el lema mostrado queda citado
assert "SKETCH-TEXT" not in pr.requests[0][0][-1]["content"] # la etapa 0 no lleva borrador
class RepairChecker(FakeChecker):
"""Las pruebas del demostrador tienen un paso malo (`BAD`): el resto elabora y cierra."""
def __init__(self):
super().__init__()
self.steps = []
def check(self, stmt, tactics, header="", hb=400000):
ok = "BAD" not in tactics and "sorry" not in tactics
return Verdict(ok=ok, errors=[] if ok else [{"line": 3, "col": 2, "message": "nlinarith failed"}])
def goals(self, stmt, skeleton, header="", hb=400000):
return [{"goal": "x : ℝ\nh : 0 < x\n⊢ 0 ≤ 1", "proof_state": 0}, {"goal": "x : ℝ\nh : 0 < x\nh1 : 0 ≤ 1\n⊢ x ≤ x + 1", "proof_state": 1}], []
def step(self, proof_state, tactic, timeout=60, heartbeats=None, gen=None):
self.steps.append(tactic)
return "BAD" not in tactic, "" if "BAD" not in tactic else "nlinarith failed", []
def test_repair_closes_the_bad_step_of_a_failed_prover_proof_with_zero_api(tmp_path, monkeypatch):
monkeypatch.setattr(G.P, "prior_channel", lambda goal, k=32: ["le_add_of_nonneg_right"])
monkeypatch.setattr(G.P, "load_prior", lambda: ({"decl_name": "le_add_of_nonneg_right", "type": "0 ≤ b → a ≤ a + b"},))
base = load_agents_config()
cfg = effective_config(base, "repair")
assert cfg["prover"]["repair"] and not cfg["prover"]["api_fallback"]
ck = RepairChecker()
near_miss = _answer("have h1 : (0 : ℝ) ≤ 1 := by\n BAD\nlinarith")
orch = G.Orchestrator(Agents(NoLLM(), base, trace_dir=str(tmp_path)), FakeRetriever(), ck, FakeProver([near_miss] * 10))
rep = orch.solve(PROBLEM, cfg, "run:p1")
assert rep.status == "done" and rep.solved_by == "prover_repair" and rep.llm_calls == 0
assert "BAD" not in rep.lean_code and "linarith" in rep.lean_code # el paso del demostrador que sí servía se conserva
assert ck.steps[0] == "BAD" # primero su propio bloque; la batería cierra el paso que fallaba
class SketchLLM:
"""El esqueleto en Lean: un paso `have` con sorry y el cierre; la primera ronda no trae bloque de código."""
exhausted, models, tokens_in, tokens_out = [], ["fake"], 0, 0
def __init__(self):
self.prompts = []
def generate(self, prompt, schema=None, images=(), system=None, max_tokens=None):
assert schema is None and "Lean 4 proof skeleton" in prompt # texto, no JSON (DECISIONS #124)
self.prompts.append(prompt)
text = "I could not decide." if len(self.prompts) == 1 else _answer("have h1 : (0 : ℝ) ≤ 1 := by sorry\nlinarith")
return LLMResponse(text, "fake", 10, 5)
def test_sketch_skeleton_steps_are_closed_by_automation_and_the_prover(tmp_path, monkeypatch):
monkeypatch.setattr(G.P, "prior_channel", lambda goal, k=32: ["le_add_of_nonneg_right"])
monkeypatch.setattr(G.P, "load_prior", lambda: ({"decl_name": "le_add_of_nonneg_right", "type": "0 ≤ b → a ≤ a + b"},))
base = load_agents_config()
cfg = effective_config(base, "sketch")
llm = SketchLLM()
orch = G.Orchestrator(Agents(NoLLM(), base, trace_dir=str(tmp_path)), FakeRetriever(), RepairChecker(), FakeProver(["x"] * 12), Agents(llm, base, trace_dir=str(tmp_path)))
rep = orch.solve(PROBLEM, cfg, "run:p1")
assert rep.status == "done" and rep.solved_by == "prover_sketch" and rep.llm_calls == 2
assert "theorem p1" in llm.prompts[1] and "had no ```lean4 block" in llm.prompts[1] # la ronda 2 recibe el motivo
assert "sorry" not in rep.lean_code.split(":= by", 1)[1] and "linarith" in rep.lean_code
def test_proof_corpus_parsing_strips_comments_and_keeps_statement():
from ragsolver.rag.proofs import norm_statement, parse
raw = "import Mathlib\nopen Real\n\n/- prove that $x \\le x + 1$ -/\ntheorem lean_workbook_1 (x : ℝ) : x ≤ x + 1 := by\n /- informal\n explanation -/\n -- a comment\n nlinarith [sq_nonneg x]\n"
p = parse(raw)
assert p == {"informal": "prove that $x \\le x + 1$", "statement": "theorem lean_workbook_1 (x : ℝ) : x ≤ x + 1", "proof": "nlinarith [sq_nonneg x]"}
assert norm_statement(p["statement"]) == norm_statement("theorem other (x : ℝ) : x ≤ x + 1 := by sorry")
assert parse("no theorem here") is None
def test_rag_proofs_tries_retrieved_proofs_then_puts_them_in_the_prover_prompt(tmp_path, monkeypatch):
base = load_agents_config()
cfg = effective_config(base, "rag_proofs")
hit = {"problem_id": "lean_workbook_7", "statement": "theorem lean_workbook_7 (y : ℝ) : y ≤ y + 2", "proof": "nlinarith [sq_nonneg y]", "sim": 0.81}
monkeypatch.setattr(G, "similar_proofs", lambda text, k=3, max_sim=0.95: [hit])
ck = FakeChecker()
pr = FakeProver(["x"] * 8 + [_answer("linarith")] + ["x"] * 7)
rep = G.Orchestrator(Agents(NoLLM(), base, trace_dir=str(tmp_path)), FakeRetriever(), ck, pr).solve(PROBLEM, cfg, "run:p1")
assert rep.status == "done" and rep.solved_by == "prover_examples" and rep.llm_calls == 0
assert "nlinarith [sq_nonneg y]" in ck.checked # primero la prueba recuperada tal cual (no cierra: otro enunciado)
prompt = pr.requests[-1][0][-1]["content"]
assert "theorem lean_workbook_7 (y : ℝ) : y ≤ y + 2 := by\n nlinarith [sq_nonneg y]" in prompt
assert "lean_workbook_7" not in pr.requests[0][0][-1]["content"] # la etapa 0 no lleva ejemplos (comparte caché)
# la prueba recuperada que cierra el enunciado tal cual resuelve sin GPU
monkeypatch.setattr(G, "similar_proofs", lambda text, k=3, max_sim=0.95: [{**hit, "proof": "linarith"}])
pr2 = FakeProver(["x"] * 8)
rep2 = G.Orchestrator(Agents(NoLLM(), base, trace_dir=str(tmp_path)), FakeRetriever(), FakeChecker(), pr2).solve(PROBLEM, cfg, "run:p1")
assert rep2.solved_by == "prover_examples" and len(pr2.requests) == 2 # sólo los 2 lotes de la etapa 0
class StaleChecker(RepairChecker):
"""El primer `step` reinicia el REPL (timeout de otro hilo): los proof_state de antes dejan de valer."""
def __init__(self):
super().__init__()
self.gen, self.n_goals = 1, 0
def goals(self, stmt, skeleton, header="", hb=400000):
self.n_goals += 1
gs, errs = super().goals(stmt, skeleton, header, hb)
return [{**g, "gen": self.gen} for g in gs], errs
def step(self, proof_state, tactic, timeout=60, heartbeats=None, gen=None):
self.gen = 2
if gen != self.gen:
return False, "stale proof state (lean repl restarted)", []
return super().step(proof_state, tactic, timeout, heartbeats, gen)
def test_close_reelaborates_the_skeleton_when_the_repl_restarts_instead_of_leaving_steps_unproved(tmp_path, monkeypatch):
# eval-v4: tras un reinicio del REPL, todos los pasos siguientes quedaban "stale" sin probarse
monkeypatch.setattr(G.P, "prior_channel", lambda goal, k=32: ["le_add_of_nonneg_right"])
monkeypatch.setattr(G.P, "load_prior", lambda: ({"decl_name": "le_add_of_nonneg_right", "type": "0 ≤ b → a ≤ a + b"},))
base = load_agents_config()
ck = StaleChecker()
near_miss = _answer("have h1 : (0 : ℝ) ≤ 1 := by\n BAD\nlinarith")
rep = G.Orchestrator(Agents(NoLLM(), base, trace_dir=str(tmp_path)), FakeRetriever(), ck, FakeProver([near_miss] * 10)).solve(PROBLEM, effective_config(base, "repair"), "run:p1")
assert rep.status == "done" and rep.solved_by == "prover_repair" and ck.n_goals == 2 # esqueleto elaborado otra vez
def test_heartbeat_limit_covers_multiline_blocks():
from ragsolver.lean.verify import limit_heartbeats
assert limit_heartbeats(" linarith ", 5) == "set_option maxHeartbeats 5 in linarith"
assert limit_heartbeats("rename_i x\nnlinarith [sq_nonneg x]\n <;> simp", 5) == "set_option maxHeartbeats 5 in\n· rename_i x\n nlinarith [sq_nonneg x]\n <;> simp"
def test_nested_config_overrides_merge_with_base():
base = load_agents_config()
p32 = effective_config(base, "baseline_prover32")["prover"]
assert p32["k_root"] == 32 and p32["max_tokens"] == base["prover"]["max_tokens"] and p32["enabled"]
assert effective_config(base, "dsp")["prover"]["dsp"] is True and effective_config(base, "dsp")["prover"]["k_draft"] == base["prover"]["k_draft"]
def test_batches_stop_early_once_solved():
pr = FakeProver([_answer("linarith")] + ["x"] * 3 + ["y"] * 4)
tac, log = prove(pr, FakeChecker(), PROBLEM.formal_statement, PROBLEM.header, "p1", k=8, correction_rounds=0, batch=4)
assert tac == "linarith" and len(pr.requests) == 1 and log["samples"] == 4
def test_dead_server_stops_the_run_instead_of_recording_false_failures(tmp_path, monkeypatch):
import httpx
import pytest
from ragsolver.prover import LocalProver, ProverUnavailable
p = LocalProver(url="http://x", cache_path=None)
monkeypatch.setattr(p.client, "post", lambda url, json: (_ for _ in ()).throw(httpx.ConnectError("refused")))
monkeypatch.setattr(p, "alive", lambda: False)
with pytest.raises(ProverUnavailable):
p.sample([{"role": "user", "content": "q"}], 2)
class DeadProver:
def sample(self, messages, k, seed0=13):
raise ProverUnavailable("caído")
orch = G.Orchestrator(Agents(NoLLM(), load_agents_config(), trace_dir=str(tmp_path)), FakeRetriever(), FakeChecker(), DeadProver())
with pytest.raises(ProverUnavailable): # ni `fail` ni el bucle por API: el runner corta y se reanuda
orch.solve(PROBLEM, effective_config(load_agents_config(), "full"), "run:p1")
|