CamiloS1111 commited on
Commit
44b0bc2
·
verified ·
1 Parent(s): ce000c2

Upload folder using huggingface_hub

Browse files
ragsolver/__init__.py ADDED
File without changes
ragsolver/agents/agents.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agentes (Rol A) — Planificador, Generador, Autoformalizador, Crítico y las dos
2
+ líneas base de la propuesta §8.2. Cada uno: arma un prompt (configs/agents.yaml),
3
+ hace UNA llamada LLM con salida JSON validada por Pydantic y devuelve el modelo.
4
+
5
+ El contexto de recuperación llega ya formateado (docs/PLAN_RAG.md Fase 4, tarea 4):
6
+ lemas como `[M1] name : signature — description (module)`; ejemplares como
7
+ `[I1] statement — key idea — source, page`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import textwrap
15
+ import time
16
+
17
+ import yaml
18
+
19
+ from ragsolver.agents.schemas import BaselineAOutput, BaselineBOutput, CriticOutput, FillOutput, FormalizerOutput, GeneratorOutput, PlannerOutput, Problem
20
+ from ragsolver.llm import LLM, LLMResponse
21
+ from ragsolver.logging.jsonl_writer import append_event
22
+ from ragsolver.rag.retrieve import mathlib_table_name
23
+ from ragsolver.rag.schema import Hit, TraceEvent, VersionsPin
24
+
25
+
26
+ def load_agents_config(path: str = "configs/agents.yaml") -> dict:
27
+ return yaml.safe_load(open(path, encoding="utf-8"))
28
+
29
+
30
+ def format_lemmas(hits: list[Hit]) -> str:
31
+ lines = []
32
+ for h in hits:
33
+ m = h.metadata
34
+ desc = (m.get("informal_description") or "")[:300].replace("\n", " ")
35
+ line = f"[{h.citation.citation_id}] {m.get('decl_name')} : {(m.get('signature') or '')[:400]}"
36
+ lines.append(line + (f" — {desc}" if desc else "") + (f" ({m.get('module_path')})" if m.get("module_path") else ""))
37
+ return "\n".join(lines) or "(none)"
38
+
39
+
40
+ def format_exemplars(hits: list[Hit]) -> str:
41
+ lines = []
42
+ for h in hits:
43
+ m = h.metadata
44
+ stmt = (h.content_md or h.text).split("## Solución")[0].strip().replace("\n", " ")[:500]
45
+ idea = (m.get("context_summary") or "")[:400].replace("\n", " ")
46
+ figs = "; ".join((f.get("description") or "")[:200] for f in h.figures) if h.figures else ""
47
+ lines.append(f"[{h.citation.citation_id}] {stmt} — {idea} — {h.citation.label}" + (f" — figure: {figs}" if figs else ""))
48
+ return "\n".join(lines) or "(none)"
49
+
50
+
51
+ class InvalidLLMOutput(Exception):
52
+ """El modelo no devolvió JSON válido ni en el reintento (p.ej. generación degenerada hasta
53
+ `max_tokens`): el problema cuenta como fallo del sistema, no como error de infraestructura."""
54
+
55
+ def __init__(self, agent: str, tokens_in: int, tokens_out: int, calls: int, model: str):
56
+ super().__init__(f"{agent}: salida JSON inválida tras {calls} llamadas")
57
+ self.agent, self.tokens_in, self.tokens_out, self.calls, self.model = agent, tokens_in, tokens_out, calls, model
58
+
59
+
60
+ class Agents:
61
+ def __init__(self, llm: LLM, config: dict | None = None, trace_dir: str = "data/eval/traces", versions: dict | None = None):
62
+ self.llm = llm
63
+ self.cfg = config or load_agents_config()
64
+ self.trace_dir = trace_dir
65
+ self.versions = versions or yaml.safe_load(open("configs/versions.yaml", encoding="utf-8"))
66
+
67
+ def _versions_pin(self) -> VersionsPin:
68
+ return VersionsPin(
69
+ embedding_model=self.versions["embedding"]["model"],
70
+ reranker_model=self.versions["reranker"]["model"],
71
+ index_version=self.versions["index"]["version"],
72
+ mathlib_tag=self.versions["lean"]["mathlib_tag"],
73
+ mathlib_table=mathlib_table_name(),
74
+ )
75
+
76
+ def _call(self, agent: str, prompt: str, schema_model, run_id: str, problem_id: str, attempt: int, images=()):
77
+ t0 = time.time()
78
+ resp = self.llm.generate(prompt, schema=schema_model.model_json_schema(), images=images, system=self.cfg["prompts"]["system"])
79
+ try:
80
+ out = schema_model.model_validate_json(resp.text)
81
+ except ValueError: # JSON truncado por `max_tokens` o inválido: un reintento con el doble de tokens (cuenta en el presupuesto)
82
+ resp2 = self.llm.generate(prompt, schema=schema_model.model_json_schema(), images=images, system=self.cfg["prompts"]["system"], max_tokens=2 * int(getattr(self.llm, "max_tokens", None) or 4000))
83
+ resp = LLMResponse(text=resp2.text, model=resp2.model, tokens_in=resp.tokens_in + resp2.tokens_in, tokens_out=resp.tokens_out + resp2.tokens_out, calls=resp.calls + resp2.calls, cost_usd=resp.cost_usd + resp2.cost_usd, attempts=resp.attempts + resp2.attempts)
84
+ try:
85
+ out = schema_model.model_validate_json(resp2.text)
86
+ except ValueError as e:
87
+ # ACC-1: el evento del intento roto lleva sus propios llm_calls/tokens/costo (antes
88
+ # `self.trace()` los ponía siempre en 0, aunque sí se habían gastado).
89
+ append_event(
90
+ TraceEvent(
91
+ run_id=run_id,
92
+ problem_id=problem_id,
93
+ attempt=attempt,
94
+ agent=agent,
95
+ latency_ms=(time.time() - t0) * 1000,
96
+ llm_calls=resp.calls,
97
+ tokens=resp.tokens_in + resp.tokens_out,
98
+ request={"model": resp.model, "tokens_in": resp.tokens_in, "tokens_out": resp.tokens_out, "cost_usd": round(resp.cost_usd, 6), "attempts": resp.attempts, "decision": {"invalid_json": str(e)[:200]}},
99
+ versions=self._versions_pin(),
100
+ ),
101
+ base_dir=self.trace_dir,
102
+ )
103
+ raise InvalidLLMOutput(agent, resp.tokens_in, resp.tokens_out, resp.calls, resp.model) from e
104
+ # RF-7: decisión tomada por el agente (salida validada, truncada) junto a tokens y latencia
105
+ decision = {k: (v[:600] if isinstance(v, str) else v) for k, v in out.model_dump().items()}
106
+ append_event(
107
+ TraceEvent(
108
+ run_id=run_id,
109
+ problem_id=problem_id,
110
+ attempt=attempt,
111
+ agent=agent,
112
+ latency_ms=(time.time() - t0) * 1000,
113
+ llm_calls=1,
114
+ tokens=resp.tokens_in + resp.tokens_out,
115
+ request={"model": resp.model, "prompt_chars": len(prompt), "prompt_version": str(self.cfg["prompt_version"]), "tokens_in": resp.tokens_in, "tokens_out": resp.tokens_out, "cost_usd": round(resp.cost_usd, 6), "attempts": resp.attempts, "decision": decision},
116
+ versions=self._versions_pin(),
117
+ ),
118
+ base_dir=self.trace_dir,
119
+ )
120
+ return out, resp
121
+
122
+ def trace(self, agent: str, run_id: str, problem_id: str, attempt: int, latency_ms: float, decision: dict) -> None:
123
+ """Evento de traza para pasos sin LLM (verificador): veredicto + latencia (RF-5, RF-7)."""
124
+ append_event(TraceEvent(run_id=run_id, problem_id=problem_id, attempt=attempt, agent=agent, latency_ms=latency_ms, llm_calls=0, tokens=0, request={"decision": decision}), base_dir=self.trace_dir)
125
+
126
+ def plan(self, problem: Problem, exemplars: list[Hit], run_id: str, attempt: int, feedback: str = "") -> tuple[PlannerOutput, object]:
127
+ prompt = self.cfg["prompts"]["planner"].format(informal=problem.informal_statement, formal=problem.formal_statement, exemplars=format_exemplars(exemplars), feedback=feedback or "(none)")
128
+ return self._call("planner", prompt, PlannerOutput, run_id, problem.problem_id, attempt)
129
+
130
+ def generate(self, problem: Problem, plan: PlannerOutput, lemmas: list[Hit], exemplars: list[Hit], feedback: str, run_id: str, attempt: int) -> tuple[GeneratorOutput, object]:
131
+ plan_txt = f"Strategy: {plan.strategy}\n" + "\n".join(f"{s.subgoal_id}. {s.statement_nl} (hint: {s.formal_hint})" for s in plan.subgoals)
132
+ images = [f["image_path"] for h in exemplars[:2] for f in h.figures[:1] if f.get("image_path")] if self.cfg.get("llm_supports_vision") else []
133
+ prompt = self.cfg["prompts"]["generator"].format(informal=problem.informal_statement, formal=problem.formal_statement, plan=plan_txt, lemmas=format_lemmas(lemmas), exemplars=format_exemplars(exemplars), feedback=feedback or "(none)")
134
+ n = int(self.cfg.get("generator_samples", 1) or 1)
135
+ if n <= 1:
136
+ return self._call("generator", prompt, GeneratorOutput, run_id, problem.problem_id, attempt, images=images)
137
+ samples = [self._call("generator", prompt, GeneratorOutput, run_id, problem.problem_id, attempt, images=images) for _ in range(n)]
138
+ best = max(range(n), key=lambda i: sum(_jaccard(samples[i][0].sketch, samples[j][0].sketch) for j in range(n) if j != i))
139
+ out, resp = samples[best]
140
+ # BUG-5: cost_usd y attempts de las n muestras también se suman (antes se perdían: costaba
141
+ # como si cada muestra hubiese costado 0 y 1 intento, aunque alguna hubiese reintentado).
142
+ resp = type(resp)(resp.text, resp.model, sum(r.tokens_in for _, r in samples), sum(r.tokens_out for _, r in samples), calls=n, cost_usd=sum(r.cost_usd for _, r in samples), attempts=sum(r.attempts for _, r in samples))
143
+ return out, resp
144
+
145
+ def formalize(self, problem: Problem, sketch: str, lemmas: list[Hit], lean_error: str, run_id: str, attempt: int, skeleton: bool = False, proved: list[str] = ()) -> tuple[FormalizerOutput, object]:
146
+ if skeleton: # docs/PLAN_MEJORA_RAG.md Fase 2: `have … := by sorry` por afirmación; los goals se cierran después
147
+ prompt = self.cfg["prompts"]["formalizer_skeleton"].format(formal=problem.formal_statement, sketch=sketch, lemmas=format_lemmas(lemmas), proved="\n".join(proved) or "(none)", lean_error=lean_error or "(none)")
148
+ else:
149
+ prompt = self.cfg["prompts"]["formalizer"].format(formal=problem.formal_statement, sketch=sketch, lemmas=format_lemmas(lemmas), lean_error=lean_error or "(none)")
150
+ out, resp = self._call("formalizer", prompt, FormalizerOutput, run_id, problem.problem_id, attempt)
151
+ out.proof_tactics = strip_code_fences(out.proof_tactics)
152
+ return out, resp
153
+
154
+ def sketch(self, problem: Problem, feedback: str, run_id: str, attempt: int) -> tuple[str, LLMResponse]:
155
+ """Esqueleto de la prueba en Lean (pasos `have … := by sorry`), en TEXTO: con salida JSON y razonamiento el
156
+ modelo gastaba todo su presupuesto y devolvía vacío (DECISIONS #124). La extracción la hace el grafo."""
157
+ t0 = time.time()
158
+ prompt = self.cfg["prompts"]["sketcher"].format(informal=problem.informal_statement, formal=f"{problem.header}\n\n{problem.formal_statement}".strip(), feedback=feedback or "(none)")
159
+ resp = self.llm.generate(prompt, system=self.cfg["prompts"]["system"], max_tokens=self.cfg.get("sketch_max_tokens"))
160
+ append_event(TraceEvent(run_id=run_id, problem_id=problem.problem_id, attempt=attempt, agent="sketcher", latency_ms=(time.time() - t0) * 1000, llm_calls=resp.calls, tokens=resp.tokens_in + resp.tokens_out, request={"model": resp.model, "prompt_chars": len(prompt), "prompt_version": str(self.cfg["prompt_version"]), "tokens_in": resp.tokens_in, "tokens_out": resp.tokens_out, "cost_usd": round(resp.cost_usd, 6), "attempts": resp.attempts, "decision": {"sketch": resp.text[-2000:]}}, versions=self._versions_pin()), base_dir=self.trace_dir)
161
+ return resp.text, resp
162
+
163
+ def fill(self, problem: Problem, goals_txt: str, run_id: str, attempt: int) -> tuple[FillOutput, object]:
164
+ prompt = self.cfg["prompts"]["fill"].format(formal=problem.formal_statement, goals=goals_txt)
165
+ out, resp = self._call("fill", prompt, FillOutput, run_id, problem.problem_id, attempt)
166
+ for p in out.proofs:
167
+ p.tactics = strip_code_fences(p.tactics)
168
+ return out, resp
169
+
170
+ def critique(self, problem: Problem, sketch: str, lean_code: str, lean_error: str, history: list[dict], run_id: str, attempt: int) -> tuple[CriticOutput, object]:
171
+ hist = "\n".join(f"attempt {h['attempt']}: {h.get('error_type', '?')} — {h.get('summary', '')[:200]}" for h in history) or "(first attempt)"
172
+ prompt = self.cfg["prompts"]["critic"].format(formal=problem.formal_statement, sketch=sketch, lean_code=lean_code, lean_error=lean_error, history=hist)
173
+ return self._call("critic", prompt, CriticOutput, run_id, problem.problem_id, attempt)
174
+
175
+ def baseline(self, mode: str, problem: Problem, run_id: str) -> tuple[BaselineAOutput | BaselineBOutput, object]:
176
+ prompt = self.cfg["prompts"][mode].format(formal=problem.formal_statement, informal=problem.informal_statement)
177
+ out, resp = self._call(mode, prompt, BaselineAOutput if mode == "baseline_a" else BaselineBOutput, run_id, problem.problem_id, 1)
178
+ if mode == "baseline_a":
179
+ out.proof_tactics = strip_code_fences(out.proof_tactics)
180
+ return out, resp
181
+
182
+
183
+ def _jaccard(a: str, b: str) -> float:
184
+ ta, tb = set(re.findall(r"[\w^+\-*/=<>≤≥]+", a.lower())), set(re.findall(r"[\w^+\-*/=<>≤≥]+", b.lower()))
185
+ return len(ta & tb) / len(ta | tb) if ta | tb else 0.0
186
+
187
+
188
+ def strip_code_fences(code: str) -> str:
189
+ """Quita vallas ```lean, un enunciado repetido, y la sangría común (el LLM suele traer las
190
+ tácticas ya indentadas a 2 espacios; `splice_proof` las vuelve a indentar, y si sólo la
191
+ primera línea perdía la sangría el bloque quedaba mal anidado y Lean daba `unsolved goals`)."""
192
+ code = re.sub(r"^\s*```[a-zA-Z0-9]*\n?", "", code.rstrip())
193
+ code = re.sub(r"\n?```$", "", code)
194
+ # si el modelo repitió el enunciado, quedarse con lo que sigue a `:= by`
195
+ m = re.search(r":=\s*by\s*\n", code)
196
+ if code.lstrip().startswith(("theorem", "lemma", "example")) and m:
197
+ code = code[m.end():]
198
+ code = re.sub(r"^\s*(?::=\s*)?by[ \t]*\n", "", code) # el modelo a veces empieza con un `by` suelto (8/30 intentos en eval_v1): sobra, el empalme ya lo pone
199
+ code = textwrap.dedent(code.strip("\n")).strip("\n")
200
+ # la primera línea llega sin sangría y las demás con la del editor del modelo ("rcases h1 …\n rcases h2 …",
201
+ # smoke 2026-10-14: Lean daba `unexpected identifier`): si la primera línea no abre un bloque, el resto
202
+ # baja a su nivel
203
+ lines = code.splitlines()
204
+ rest = [l for l in lines[1:] if l.strip()]
205
+ if rest and not lines[0].startswith("calc") and not re.search(r"(\bby|:=|=>|\bwith|\bdo|\bthen|\belse|\bfrom|[,(⟨\[])\s*$", lines[0]):
206
+ k = min(len(l) - len(l.lstrip()) for l in rest)
207
+ if k:
208
+ code = "\n".join([lines[0]] + [l[k:] if l.strip() else l for l in lines[1:]])
209
+ return code
ragsolver/agents/schemas.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contratos JSON Schema de los agentes (RNF-4; docs/PLAN_RAG.md §8.3, propuesta §6.3).
2
+
3
+ Cada agente es una función pura `state -> delta` cuya salida LLM se valida contra
4
+ uno de estos modelos (`model_json_schema()` va como `response_format` al LLM).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from ragsolver.rag.schema import Citation
14
+
15
+ ErrorType = Literal["lean_syntax", "missing_lemma", "misformalization", "timeout", "planning", "unknown"]
16
+ CriticAction = Literal["retry", "replan", "abandon"]
17
+
18
+
19
+ class Problem(BaseModel):
20
+ problem_id: str
21
+ source: str = "trivial" # trivial | minif2f_v2 | putnambench
22
+ split: str = "dev"
23
+ informal_statement: str
24
+ formal_statement: str # `theorem foo ... := by sorry`
25
+ area: str = ""
26
+ difficulty: int | None = None
27
+ imports: list[str] = Field(default_factory=list) # imports adicionales al de configs/agents.yaml
28
+ header: str = "" # p.ej. `open BigOperators Real Nat Topology` (miniF2F); sin imports
29
+
30
+
31
+ class Subgoal(BaseModel):
32
+ subgoal_id: str
33
+ statement_nl: str
34
+ formal_hint: str = ""
35
+ search_queries: list[str] = Field(default_factory=list, max_length=3)
36
+ math_area: str = ""
37
+
38
+
39
+ class PlannerOutput(BaseModel):
40
+ strategy: str
41
+ subgoals: list[Subgoal] = Field(min_length=1, max_length=6)
42
+
43
+
44
+ class GeneratorOutput(BaseModel):
45
+ sketch: str
46
+ used_citations: list[str] = Field(default_factory=list) # ids M*/I*
47
+
48
+
49
+ class FormalizerOutput(BaseModel):
50
+ proof_tactics: str # sólo el cuerpo tras `:= by` — el enunciado no lo toca el LLM
51
+ notes: str = ""
52
+
53
+
54
+ class GoalProof(BaseModel):
55
+ goal_id: str # "G1", "G2", … en el orden en que se le mostraron los goals
56
+ tactics: str # bloque que reemplaza el `sorry` de ese goal
57
+
58
+
59
+ class FillOutput(BaseModel):
60
+ """FILL (docs/PLAN_MEJORA_RAG.md §4.1): pruebas de los goals que la batería CLOSE no cerró."""
61
+
62
+ proofs: list[GoalProof] = Field(default_factory=list)
63
+
64
+
65
+ class CriticOutput(BaseModel):
66
+ error_type: ErrorType
67
+ action: CriticAction
68
+ missing_identifiers: list[str] = Field(default_factory=list)
69
+ feedback: str
70
+
71
+
72
+ class BaselineAOutput(BaseModel):
73
+ """Línea base A: un solo LLM escribe la prueba Lean. Sólo el campo que la línea base produce, y
74
+ obligatorio: con un esquema común de campos opcionales DeepSeek dejaba `proof_tactics` vacío y
75
+ escribía la prueba en lenguaje natural (58/60 pruebas vacías, DECISIONS #65)."""
76
+
77
+ proof_tactics: str # como FormalizerOutput: sólo el cuerpo tras `:= by`
78
+ notes: str = ""
79
+
80
+
81
+ class BaselineBOutput(BaseModel):
82
+ """Línea base B: prueba en lenguaje natural + autoevaluación (sin verificación)."""
83
+
84
+ natural_language_proof: str
85
+ self_assessed_correct: bool
86
+
87
+
88
+ class Verdict(BaseModel):
89
+ ok: bool
90
+ errors: list[dict] = Field(default_factory=list) # {line, col, message}
91
+ uses_sorry: bool = False
92
+ axioms: list[str] = Field(default_factory=list) # VER-1: `#print axioms`; ok=True exige subconjunto de STANDARD_AXIOMS
93
+ used_constants: list[str] = Field(default_factory=list) # constantes del término aceptado (REPL `all_tactics`): `used_in_proof` real
94
+ seconds: float = 0.0
95
+ lean_file: str = ""
96
+ raw: str = ""
97
+
98
+
99
+ class SolutionReport(BaseModel):
100
+ problem_id: str
101
+ config: str
102
+ status: Literal["done", "fail", "error"]
103
+ attempts: int = 0
104
+ replans: int = 0
105
+ llm_calls: int = 0
106
+ retrieval_calls: int = 0 # búsquedas del retriever (0 llamadas LLM cada una)
107
+ tokens_in: int = 0
108
+ tokens_out: int = 0
109
+ wall_seconds: float = 0.0
110
+ lean_code: str = ""
111
+ verdict: Verdict | None = None
112
+ citations: list[Citation] = Field(default_factory=list) # con used_in_proof / used_in_sketch resueltos
113
+ n_retrieved: int = 0
114
+ n_used_in_sketch: int = 0
115
+ n_used_in_proof: int = 0
116
+ error_types: list[str] = Field(default_factory=list) # taxonomía por intento
117
+ re_retrievals: int = 0
118
+ re_retrievals_ok: int = 0
119
+ models: list[str] = Field(default_factory=list)
120
+ self_assessed_correct: bool | None = None # sólo línea base B
121
+ solved_by: str = "" # prover_root | prover_goal | agents | auto (docs/PLAN_REDISENO_PROVER.md)
122
+ prover_samples: int = 0 # muestras del demostrador local (0 API)
ragsolver/cli.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI `ragsolver` (docs/PLAN_RAG.md Fase 2 tarea 9, Fase 3 tarea 7).
2
+
3
+ ragsolver search "..." [--corpus mathlib|informal] [--mode nl|formal|error|exemplar] [--k 8]
4
+ ragsolver ingest --pdf <pdf> [--skip-llm] [--sections none|front|all]
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> None:
13
+ argv = sys.argv[1:] if argv is None else argv
14
+ cmd, rest = (argv[0], argv[1:]) if argv else ("", [])
15
+ if cmd == "search":
16
+ from ragsolver.rag.retrieve import _cli
17
+
18
+ _cli(rest)
19
+ elif cmd == "ingest":
20
+ import json
21
+
22
+ from ragsolver.rag.ingest_informal import run
23
+
24
+ args = dict(zip(rest[::2], rest[1::2]))
25
+ rep = run(args.get("--pdf", "problemas resueltos olimpiadas.pdf"), args.get("--mineru-dir"), skip_llm="--skip-llm" in rest, sections=args.get("--sections", "none"))
26
+ print(json.dumps({k: v for k, v in rep.items() if k not in ("gaps", "formula_diffs")}, indent=2, ensure_ascii=False))
27
+ else:
28
+ print(__doc__)
29
+ sys.exit(0 if cmd in ("", "-h", "--help") else 2)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
ragsolver/lean/verify.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verificador (Rol B): el kernel de Lean 4 es la única fuente de verdad.
2
+
3
+ Modelo de amenazas (docs/AUDIT_2026-09-23.md SEC-1/VER-1; niveles de
4
+ https://lean-lang.org/doc/reference/latest/ValidatingProofs/). El texto de
5
+ tácticas lo escribe un LLM que puede venir de un proveedor no confiable
6
+ (DECISIONS #62 enruta a varios) o estar contaminado por un corpus de terceros
7
+ (prompt injection). Antes de mandarlo a Lean:
8
+ 1. `find_forbidden_tactic` (nivel 1: nada de comandos/metaprogramación)
9
+ rechaza `#comando`, metaprogramación, declaraciones nuevas, cambios de
10
+ entorno y caminos que no pasan por el kernel (`sorry`, `native_decide`,
11
+ `decide +native`). El Autoformalizador sólo puede devolver tácticas.
12
+ 2. Tras un veredicto sin errores, se audita `#print axioms <nombre>`
13
+ (nivel 2): sólo se acepta si usa como mucho `propext`, `Classical.choice`
14
+ y `Quot.sound` — si aparece `Lean.trustCompiler`/`Lean.ofReduceBool` hubo
15
+ evaluación nativa, no verificación del kernel (Kimina-Prover tuvo 15/225
16
+ soluciones así: github.com/MoonshotAI/Kimina-Prover-Preview/issues/11).
17
+ No hay sandbox de sistema operativo todavía (Fase 10 de docs/PLAN_CORRECCIONES.md):
18
+ esto no protege contra bugs del elaborador de Lean en sí, ni limita el uso de
19
+ CPU/disco de una táctica válida pero cara (el heartbeat sólo limita pasos de
20
+ elaboración).
21
+
22
+ Dos backends con el mismo `Verdict`:
23
+ - `LeanChecker` (por defecto): REPL persistente vía `lean-interact` — `import Mathlib`
24
+ una sola vez (~3-5 min) y después ~0.3 s por teorema (medido, docs/DECISIONS.md #40).
25
+ - `verify(...)`: un proceso `lake env lean` por verificación (sin estado; ~3 min
26
+ cada uno por el import). Respaldo y prueba de humo.
27
+
28
+ El enunciado NUNCA lo escribe el LLM: se toma del problema y sólo se sustituye
29
+ `sorry` por las tácticas — así el LLM no puede debilitar el teorema.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import re
36
+ import textwrap
37
+ import threading
38
+ import subprocess
39
+ import time
40
+ from pathlib import Path
41
+
42
+ from ragsolver.agents.schemas import Verdict
43
+
44
+ _MSG_RE = re.compile(r"^(?P<file>[^\n:]+\.lean):(?P<line>\d+):(?P<col>\d+): (?P<sev>error|warning|info): ?(?P<msg>.*)$")
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Validación de tácticas (SEC-1). El Autoformalizador sólo debe devolver el
48
+ # bloque tras `:= by` (docs/PLAN_RAG.md §6, Fase 4 tarea 4); cualquier otra
49
+ # cosa es sospechosa.
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _COMMENT_RE = re.compile(r"--[^\n]*|/-.*?-/", re.S)
53
+ # `open Foo in <táctica>` sólo trae un nombre a escala: no ejecuta código, se permite.
54
+ _SAFE_OPEN_IN_RE = re.compile(r"\bopen(?:\s+[A-Za-z_][\w'.]*)+\s+in\b")
55
+ # `#eval`/`#print`/... en CUALQUIER posición, no sólo al inicio de línea: `open X in
56
+ # #eval ...` es sintaxis válida de Lean y sería un bypass de un chequeo anclado a línea.
57
+ _HASH_CMD_RE = re.compile(r"#[A-Za-z_]")
58
+ _SORRY_RE = re.compile(r"(?<![\w'])sorry(?![\w'])")
59
+ _NATIVE_DECIDE_RE = re.compile(r"\bdecide\s*\+\s*native\b")
60
+ _IO_RE = re.compile(r"\bIO\.")
61
+ _DENIED_WORD_RE = re.compile(
62
+ r"(?<![\w'])("
63
+ r"sorry|admit|native_decide|axiom|opaque"
64
+ r"|ofReduceBool|trustCompiler" # el axioma detrás de native_decide/decide +native (VER-1); rechazo temprano por nombre, además de la auditoría de axiomas
65
+ r"|run_tac|run_cmd|run_elab|elab_rules|elab|macro_rules|macro|notation|infix|infixl|infixr|prefix|postfix"
66
+ r"|attribute|unsafe|implemented_by|extern"
67
+ r"|set_option|import|open"
68
+ r"|theorem|lemma|def|abbrev|instance|example|structure|inductive|class|namespace|section|end"
69
+ r")(?![\w'])"
70
+ )
71
+
72
+
73
+ def find_forbidden_tactic(proof_tactics: str) -> str | None:
74
+ """Primer patrón prohibido en el texto de tácticas, o None si es seguro: sin
75
+ `#comando`, sin metaprogramación, sin declaraciones nuevas, sin cambios de
76
+ entorno (salvo `open X in`) y sin caminos que evitan el kernel (`sorry`,
77
+ `native_decide`, `decide +native`, `IO.*`)."""
78
+ stripped = _COMMENT_RE.sub(" ", proof_tactics)
79
+ if _HASH_CMD_RE.search(stripped):
80
+ return "#-command"
81
+ if _NATIVE_DECIDE_RE.search(stripped):
82
+ return "decide +native"
83
+ if _IO_RE.search(stripped):
84
+ return "IO."
85
+ stripped = _SAFE_OPEN_IN_RE.sub(" ", stripped)
86
+ m = _DENIED_WORD_RE.search(stripped)
87
+ return m.group(1) if m else None
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Auditoría de axiomas (VER-1). "Si aparece Lean.trustCompiler, la evaluación
92
+ # nativa se usó" en vez del kernel (guía oficial de Lean, enlace arriba).
93
+ # ---------------------------------------------------------------------------
94
+
95
+ _AXIOMS_RE = re.compile(r"depends on axioms:\s*\[([^\]]*)\]")
96
+ STANDARD_AXIOMS = frozenset({"propext", "Classical.choice", "Quot.sound"})
97
+
98
+
99
+ def parse_axioms(text: str) -> list[str]:
100
+ """Extrae la lista de `#print axioms <name>` ("depends on axioms: [...]";
101
+ sin coincidencia -> "no depende de ningún axioma" -> lista vacía)."""
102
+ m = _AXIOMS_RE.search(text)
103
+ return [a.strip() for a in m.group(1).split(",") if a.strip()] if m else []
104
+
105
+
106
+ def non_standard_axioms(axioms: list[str]) -> list[str]:
107
+ return [a for a in axioms if a not in STANDARD_AXIOMS]
108
+
109
+
110
+ def _axiom_check(errors: list[dict], uses_sorry: bool, axioms: list[str]) -> tuple[bool, list[dict]]:
111
+ """ok sólo si no hay errores, no usa `sorry` y los axiomas son un subconjunto
112
+ de los estándar; si no, agrega un error legible (para el Crítico) sin perder
113
+ los que ya había."""
114
+ if errors or uses_sorry:
115
+ return False, errors
116
+ bad = non_standard_axioms(axioms)
117
+ if bad:
118
+ return False, errors + [{"line": 0, "col": 0, "message": f"non-standard axioms: {', '.join(bad)}"}]
119
+ return True, errors
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Fuente única REPL <-> reporte (BUG-2): una sola función construye el bloque
124
+ # que se verifica Y el que se reporta, para que no puedan divergir.
125
+ # ---------------------------------------------------------------------------
126
+
127
+ _THEOREM_NAME_RE = re.compile(r"^\s*(?:theorem|lemma)\s+([A-Za-z_][\w'.]*)")
128
+
129
+
130
+ def _lean_project() -> Path:
131
+ """`RAGSOLVER_LEAN_PROJECT` > copia en ext4 (~/ragsolver_cache/lean_project, misma tag,
132
+ 10x más rápida que /mnt/c para cargar los .olean de Mathlib) > lean_project/ del repo."""
133
+ env = os.environ.get("RAGSOLVER_LEAN_PROJECT")
134
+ if env:
135
+ return Path(env)
136
+ cache = Path.home() / "ragsolver_cache" / "lean_project"
137
+ if (cache / ".lake" / "packages" / "mathlib" / ".lake" / "build").exists():
138
+ return cache
139
+ return Path(__file__).resolve().parents[3] / "lean_project"
140
+
141
+
142
+ LEAN_PROJECT = _lean_project()
143
+
144
+
145
+ def splice_proof(formal_statement: str, proof_tactics: str) -> str:
146
+ """Reemplaza el `sorry` final del enunciado por el bloque de tácticas (indentado)."""
147
+ stmt = formal_statement.rstrip()
148
+ tactics = textwrap.dedent(proof_tactics.strip("\n")) # sangría común fuera; la relativa (bloques `have ... := by`) se conserva
149
+ body = "\n".join(" " + line if line.strip() else "" for line in tactics.splitlines())
150
+ for pat in (r":=\s*by\s+sorry\s*$", r":=\s*sorry\s*$"):
151
+ if re.search(pat, stmt):
152
+ return re.sub(pat, lambda _m: ":= by\n" + body, stmt)
153
+ return stmt + " := by\n" + body
154
+
155
+
156
+ _HAVE_BY_RE = re.compile(r"^have\b.*:=\s*by\b(.*)$")
157
+
158
+
159
+ def skeletonize(proof: str) -> tuple[str, list[str | None]]:
160
+ """Prueba del LLM -> (esqueleto, candidatos), docs/PLAN_MEJORA_RAG.md Fase 2.
161
+
162
+ El modelo casi nunca escribe el esqueleto con `sorry` que se le pide: escribe la prueba entera
163
+ (smoke del 2026-10-14). En vez de depender de que obedezca, cada `have … := by <bloque>` de nivel
164
+ superior pasa a `have … := by sorry` y su bloque queda como **candidato** para ese goal; las líneas
165
+ tras el último `have` (el cierre) pasan a un `sorry` con ese cierre como candidato. Sin `have`, la
166
+ prueba entera es el candidato del goal raíz. El resto (`rcases`, `intro`, `have … := término`) queda
167
+ tal cual. `candidatos[k]` corresponde al k-ésimo `sorry` del esqueleto (None = no hay candidato).
168
+ """
169
+ lines = textwrap.dedent(proof.strip("\n")).splitlines()
170
+ top = lambda l: bool(l.strip()) and not l[0].isspace()
171
+ blocks = [] # (inicio, fin exclusivo, cabecera, candidato)
172
+ i = 0
173
+ while i < len(lines):
174
+ m = _HAVE_BY_RE.match(lines[i]) if top(lines[i]) else None
175
+ if not m:
176
+ i += 1
177
+ continue
178
+ j = i + 1
179
+ while j < len(lines) and not top(lines[j]):
180
+ j += 1
181
+ inline = m.group(1).strip()
182
+ body = textwrap.dedent("\n".join(lines[i + 1 : j])).strip("\n")
183
+ block = "\n".join(x for x in (inline, body) if x)
184
+ blocks.append((i, j, lines[i][: m.start(1)].rstrip(), block))
185
+ i = j
186
+ if not blocks:
187
+ body = "\n".join(lines).strip("\n")
188
+ return "sorry", [None if not body or body == "sorry" else body]
189
+ out: list[str] = []
190
+ cands: list[str | None] = []
191
+ pos = 0
192
+ for start, end, head, block in blocks:
193
+ for l in lines[pos:start]:
194
+ out.append(l)
195
+ cands += [None] * count_sorries(l)
196
+ out.append(f"{head} sorry")
197
+ cands.append(None if not block or block == "sorry" else block)
198
+ pos = end
199
+ tail = "\n".join(lines[pos:]).strip("\n")
200
+ if tail:
201
+ out.append("sorry")
202
+ cands.append(None if tail == "sorry" else tail)
203
+ return "\n".join(out), cands
204
+
205
+
206
+ def count_sorries(skeleton: str) -> int:
207
+ return len(_SORRY_RE.findall(skeleton))
208
+
209
+
210
+ def fill_sorries(skeleton: str, blocks: list[str]) -> str:
211
+ """Reemplaza el k-ésimo `sorry` del esqueleto por `blocks[k]` (docs/PLAN_MEJORA_RAG.md Fase 2).
212
+ Un bloque de una línea va en el lugar del `sorry`; uno de varias líneas va en líneas nuevas con
213
+ la sangría de la línea del `sorry` + 2, salvo que al `sorry` le siga texto en la misma línea
214
+ (`exact foo (by sorry)`): entonces va entre paréntesis y separado por `;`."""
215
+ it = iter(blocks)
216
+ out = []
217
+ for line in skeleton.splitlines():
218
+ parts = _SORRY_RE.split(line)
219
+ if len(parts) == 1:
220
+ out.append(line)
221
+ continue
222
+ pad = " " * (len(line) - len(line.lstrip()) + 2)
223
+ new = parts[0]
224
+ for rest in parts[1:]:
225
+ block = textwrap.dedent(next(it, "sorry").strip("\n")).strip("\n")
226
+ if "\n" not in block:
227
+ new += block
228
+ elif rest.strip():
229
+ new += "(" + "; ".join(l.strip() for l in block.splitlines() if l.strip()) + ")"
230
+ else:
231
+ new = new.rstrip() + "\n" + "\n".join(pad + l if l.strip() else "" for l in block.splitlines())
232
+ new += rest
233
+ out.append(new)
234
+ return "\n".join(out)
235
+
236
+
237
+ def theorem_block(formal_statement: str, proof_tactics: str, header: str = "", max_heartbeats: int = 400000) -> tuple[str, str]:
238
+ """Bloque que se envía al REPL (sin `import`: ya está en el entorno) y que
239
+ también se reporta como `lean_code` vía `build_file` (BUG-2): `open`s del
240
+ header + `set_option maxHeartbeats N in` + el enunciado con las tácticas
241
+ empalmadas + `#print axioms <nombre>` para la auditoría de VER-1. Devuelve
242
+ (código, nombre del teorema).
243
+
244
+ `formal_statement` debe empezar con `theorem <nombre>` o `lemma <nombre>`
245
+ (el formato que exige `Problem.formal_statement`); un `example` anónimo se
246
+ renombra de forma determinista y mecánica (no lo hace el LLM) para poder
247
+ auditar sus axiomas."""
248
+ stmt = formal_statement.rstrip()
249
+ m = _THEOREM_NAME_RE.match(stmt)
250
+ if m:
251
+ name = m.group(1)
252
+ elif stmt.lstrip().startswith("example"):
253
+ name = "_ragsolver_goal"
254
+ stmt = re.sub(r"^\s*example\b", f"theorem {name}", stmt, count=1)
255
+ else:
256
+ raise ValueError(f"formal_statement debe empezar con 'theorem', 'lemma' o 'example': {stmt[:60]!r}")
257
+ opens = "\n".join(l for l in header.splitlines() if l.strip() and not l.strip().startswith("import"))
258
+ code = (opens + "\n" if opens else "") + f"set_option maxHeartbeats {max_heartbeats} in\n" + splice_proof(stmt, proof_tactics) + f"\n#print axioms {name}"
259
+ return code, name
260
+
261
+
262
+ def build_file(formal_statement: str, proof_tactics: str, imports: list[str], max_heartbeats: int = 400000, header: str = "") -> str:
263
+ """Archivo Lean completo y autocontenido: el mismo texto que se le pide al
264
+ REPL, más el `import` inicial (que ahí ya viene del entorno). Es el
265
+ `lean_code` que se reporta (BUG-2): reproduce exactamente lo verificado,
266
+ incluidos los `open` del header."""
267
+ block, _ = theorem_block(formal_statement, proof_tactics, header, max_heartbeats)
268
+ hdr = "\n".join(f"import {m}" for m in dict.fromkeys(imports)) + "\n\n"
269
+ return hdr + block + "\n"
270
+
271
+
272
+ def parse_messages(output: str) -> list[dict]:
273
+ msgs: list[dict] = []
274
+ for line in output.splitlines():
275
+ m = _MSG_RE.match(line)
276
+ if m:
277
+ msgs.append({"line": int(m["line"]), "col": int(m["col"]), "severity": m["sev"], "message": m["msg"]})
278
+ elif msgs:
279
+ msgs[-1]["message"] += "\n" + line
280
+ return msgs
281
+
282
+
283
+ def run_lean(source: str, name: str, project: Path = LEAN_PROJECT, timeout_s: int = 900) -> tuple[int | None, str, float, Path]:
284
+ scratch = project / "scratch"
285
+ scratch.mkdir(exist_ok=True)
286
+ path = scratch / f"{re.sub(r'[^A-Za-z0-9_]+', '_', name)}.lean"
287
+ path.write_text(source, encoding="utf-8")
288
+ env = {**os.environ, "PATH": f"{Path.home() / '.elan' / 'bin'}:{os.environ.get('PATH', '')}"}
289
+ t0 = time.time()
290
+ try:
291
+ proc = subprocess.run(["lake", "env", "lean", str(path.relative_to(project))], cwd=project, env=env, capture_output=True, text=True, timeout=timeout_s)
292
+ return proc.returncode, proc.stdout + proc.stderr, time.time() - t0, path
293
+ except subprocess.TimeoutExpired as e:
294
+ return None, (e.stdout or "") + (e.stderr or "") if isinstance(e.stdout, str) else "", time.time() - t0, path
295
+
296
+
297
+ def verify(formal_statement: str, proof_tactics: str, name: str, imports: list[str], timeout_s: int = 900, max_heartbeats: int = 400000, project: Path = LEAN_PROJECT, header: str = "") -> Verdict:
298
+ forbidden = find_forbidden_tactic(proof_tactics)
299
+ if forbidden:
300
+ return Verdict(ok=False, errors=[{"line": 0, "col": 0, "message": f"forbidden token in proof: {forbidden}"}])
301
+ source = build_file(formal_statement, proof_tactics, imports, max_heartbeats, header)
302
+ code, out, secs, path = run_lean(source, name, project, timeout_s)
303
+ msgs = parse_messages(out)
304
+ errors = [m for m in msgs if m["severity"] == "error"]
305
+ uses_sorry = any("sorry" in m["message"] for m in msgs)
306
+ if code is None:
307
+ errors.append({"line": 0, "col": 0, "message": f"timeout after {timeout_s}s"})
308
+ return Verdict(ok=False, errors=errors, uses_sorry=uses_sorry, seconds=round(secs, 1), lean_file=str(path), raw=out[-4000:])
309
+ axioms = parse_axioms("\n".join(m["message"] for m in msgs if m["severity"] == "info"))
310
+ ok, errors = _axiom_check(errors, uses_sorry, axioms)
311
+ return Verdict(ok=ok, errors=errors, uses_sorry=uses_sorry, axioms=axioms, seconds=round(secs, 1), lean_file=str(path), raw=out[-4000:])
312
+
313
+
314
+ def limit_heartbeats(tactic: str, heartbeats: int) -> str:
315
+ """`set_option maxHeartbeats N in` sobre una táctica. Un bloque de varias líneas (el de un paso que escribió
316
+ el modelo o el demostrador) va dentro de `·`, que enfoca el goal y debe cerrarlo como el bloque en su sitio:
317
+ sin eso el límite no lo cubría y un bloque lento agotaba el timeout de reloj, reiniciaba el REPL y dejaba
318
+ "stale" los pasos de todos los hilos (eval-v4, DECISIONS #128)."""
319
+ body = tactic.strip()
320
+ if "\n" not in body:
321
+ return f"set_option maxHeartbeats {int(heartbeats)} in {body}"
322
+ return f"set_option maxHeartbeats {int(heartbeats)} in\n· " + body.replace("\n", "\n ")
323
+
324
+
325
+ class LeanChecker:
326
+ """REPL persistente: el entorno con los imports se crea una vez y se reutiliza."""
327
+
328
+ def __init__(self, imports: list[str] = ("Mathlib",), project: Path | None = None, timeout_s: int = 900):
329
+ self.imports = list(imports)
330
+ self.project = project or LEAN_PROJECT
331
+ self.timeout_s = timeout_s
332
+ self._server = None
333
+ self._env = None
334
+ self._gen = 0 # sube en cada arranque del REPL: un `proof_state` sólo vale en la generación que lo creó
335
+ self._lock = threading.Lock() # un solo REPL; ~0.3 s por comprobación, no vale la pena más
336
+
337
+ @property
338
+ def gen(self) -> int:
339
+ """Generación del REPL vivo (-1 si cayó): un `proof_state` de otra generación ya no vale."""
340
+ return self._gen if self._server is not None else -1
341
+
342
+ def _start(self) -> None:
343
+ from lean_interact import Command, LeanREPLConfig, LeanServer
344
+ from lean_interact.project import LocalProject
345
+
346
+ os.environ["PATH"] = f"{Path.home() / '.elan' / 'bin'}:{os.environ.get('PATH', '')}"
347
+ self._server = LeanServer(LeanREPLConfig(project=LocalProject(directory=str(self.project), auto_build=False)))
348
+ self._gen += 1
349
+ r = self._server.run(Command(cmd="\n".join(f"import {m}" for m in dict.fromkeys(self.imports))), timeout=max(self.timeout_s, 1800))
350
+ errors = [m.data for m in r.messages if m.severity == "error"]
351
+ if errors:
352
+ raise RuntimeError(f"imports fallaron en el REPL: {errors[:2]}")
353
+ self._env = r.env
354
+
355
+ def check(self, formal_statement: str, proof_tactics: str, header: str = "", max_heartbeats: int = 400000) -> Verdict:
356
+ forbidden = find_forbidden_tactic(proof_tactics)
357
+ if forbidden:
358
+ return Verdict(ok=False, errors=[{"line": 0, "col": 0, "message": f"forbidden token in proof: {forbidden}"}])
359
+ with self._lock:
360
+ return self._check_locked(formal_statement, proof_tactics, header, max_heartbeats)
361
+
362
+ def _check_locked(self, formal_statement: str, proof_tactics: str, header: str, max_heartbeats: int) -> Verdict:
363
+ from lean_interact import Command
364
+
365
+ if self._server is None or not self._server.is_alive():
366
+ self._start()
367
+ code, _name = theorem_block(formal_statement, proof_tactics, header, max_heartbeats)
368
+ t0 = time.time()
369
+ try:
370
+ # all_tactics: constantes que usa cada táctica -> qué lemas citados usa DE VERDAD la prueba
371
+ r = self._server.run(Command(cmd=code, env=self._env, all_tactics=True), timeout=self.timeout_s)
372
+ except Exception as e: # timeout o REPL caído: se reinicia en la próxima llamada
373
+ self._server = None
374
+ return Verdict(ok=False, errors=[{"line": 0, "col": 0, "message": f"lean repl failure: {type(e).__name__}: {str(e)[:200]}"}], seconds=round(time.time() - t0, 1), raw=code[-2000:])
375
+ msgs = [{"line": m.start_pos.line if m.start_pos else 0, "col": m.start_pos.column if m.start_pos else 0, "severity": m.severity, "message": m.data} for m in r.messages]
376
+ errors = [m for m in msgs if m["severity"] == "error"]
377
+ uses_sorry = any("sorry" in m["message"] for m in msgs)
378
+ axioms = parse_axioms("\n".join(m["message"] for m in msgs if m["severity"] == "info"))
379
+ ok, errors = _axiom_check(errors, uses_sorry, axioms)
380
+ used = sorted({c for t in getattr(r, "tactics", None) or [] for c in t.used_constants})
381
+ return Verdict(ok=ok, errors=errors, uses_sorry=uses_sorry, axioms=axioms, used_constants=used, seconds=round(time.time() - t0, 1), raw=code[-2000:])
382
+
383
+ def _run(self, query, timeout: float | None = None):
384
+ """Una consulta al REPL con el lock tomado por el llamador; timeout o REPL caído -> None
385
+ (se reinicia en la próxima llamada, y los `proof_state` anteriores dejan de valer)."""
386
+ if self._server is None or not self._server.is_alive():
387
+ self._start()
388
+ try:
389
+ return self._server.run(query, timeout=timeout or self.timeout_s)
390
+ except Exception:
391
+ self._server = None
392
+ return None
393
+
394
+ def goals(self, formal_statement: str, skeleton: str, header: str = "", max_heartbeats: int = 400000) -> tuple[list[dict], list[dict]]:
395
+ """Modo sonda (docs/PLAN_MEJORA_RAG.md Fase 2): elabora un esqueleto con `sorry` y devuelve
396
+ ([{goal, proof_state}] en el orden en que aparecen los `sorry`, errores de elaboración). Nunca
397
+ produce un `Verdict`: el único camino a `ok` sigue siendo `check` sobre el archivo final, que
398
+ rechaza `sorry`. Fuera de `sorry`, el esqueleto pasa por el mismo validador que una prueba."""
399
+ from lean_interact import Command
400
+
401
+ forbidden = find_forbidden_tactic(_SORRY_RE.sub(" skip ", skeleton))
402
+ if forbidden:
403
+ return [], [{"line": 0, "col": 0, "message": f"forbidden token in proof: {forbidden}"}]
404
+ code, _name = theorem_block(formal_statement, skeleton, header, max_heartbeats)
405
+ with self._lock:
406
+ r = self._run(Command(cmd=code, env=self._env))
407
+ if r is None or not hasattr(r, "sorries"):
408
+ return [], [{"line": 0, "col": 0, "message": f"lean repl failure: {str(getattr(r, 'message', r))[:200]}"}]
409
+ errors = [{"line": m.start_pos.line if m.start_pos else 0, "col": m.start_pos.column if m.start_pos else 0, "message": m.data} for m in r.messages if m.severity == "error"]
410
+ found = sorted(r.sorries, key=lambda s: (s.start_pos.line, s.start_pos.column) if s.start_pos else (0, 0))
411
+ return [{"goal": s.goal, "proof_state": s.proof_state, "gen": self._gen} for s in found], errors
412
+
413
+ def step(self, proof_state: int, tactic: str, timeout: float = 120, heartbeats: int | None = None, gen: int | None = None) -> tuple[bool, str, list[str]]:
414
+ """Aplica `tactic` a un `proof_state` de `goals` sin re-elaborar el teorema (`ProofStep`).
415
+ Devuelve (cerrado, error o estado, mensajes info — p.ej. el "Try this" de `try?`). La táctica
416
+ pasa por `find_forbidden_tactic` antes de tocar Lean, igual que en `check`."""
417
+ from lean_interact import ProofStep
418
+
419
+ forbidden = find_forbidden_tactic(tactic)
420
+ if forbidden:
421
+ return False, f"forbidden token in proof: {forbidden}", []
422
+ if heartbeats:
423
+ # el límite lo pone el sistema DESPUÉS del validador (que prohíbe `set_option` al LLM): Lean
424
+ # corta por heartbeats antes que el timeout de reloj, que reinicia el REPL (~5 min) e invalida
425
+ # los `proof_state` de los demás hilos
426
+ tactic = limit_heartbeats(tactic, heartbeats)
427
+ with self._lock:
428
+ if gen is not None and (gen != self._gen or self._server is None):
429
+ # el REPL se reinició (timeout de otra táctica): ese número de estado ya no existe o, peor,
430
+ # es otro goal del REPL nuevo
431
+ return False, "stale proof state (lean repl restarted)", []
432
+ r = self._run(ProofStep(tactic=tactic, proof_state=proof_state), timeout=timeout)
433
+ if r is None or not hasattr(r, "proof_status"): # LeanError (la táctica falló) o REPL caído
434
+ return False, str(getattr(r, "message", "lean repl failure"))[:2000], []
435
+ errs = [m.data for m in r.messages if m.severity == "error"]
436
+ infos = [m.data for m in r.messages if m.severity == "info"]
437
+ return r.proof_status == "Completed" and not errs, "\n".join(errs)[:2000] or r.proof_status, infos
438
+
439
+ def signatures(self, names: list[str], header: str = "") -> dict[str, str]:
440
+ """{nombre: tipo} de los `names` que resuelve `#check @nombre` en el entorno cargado, uno por
441
+ comando (exacto, a diferencia de `check_identifiers_exist`). `header` aporta los `open` con
442
+ los que esos nombres se usan en las pruebas (p.ej. `open Nat` resuelve `succ_le_iff`)."""
443
+ from lean_interact import Command
444
+
445
+ opens = " ".join(l.strip()[len("open"):].strip() for l in header.splitlines() if l.strip().startswith("open"))
446
+ prefix = f"open {opens} in\n" if opens else ""
447
+ out: dict[str, str] = {}
448
+ with self._lock:
449
+ for n in names:
450
+ # con los `open`, un nombre de la raíz puede quedar ambiguo (`sq_nonneg` vs `Nat.sq_nonneg`):
451
+ # la prueba lo desambigua por tipo, `#check` no -> se reintenta sin los `open`
452
+ for pre in dict.fromkeys((prefix, "")):
453
+ r = self._run(Command(cmd=f"{pre}#check @{n}", env=self._env), timeout=60)
454
+ msgs = getattr(r, "messages", None)
455
+ if msgs is not None and not any(m.severity == "error" for m in msgs):
456
+ info = next((m.data for m in msgs if m.severity == "info"), "")
457
+ out[n] = info.split(" : ", 1)[1].strip() if " : " in info else info
458
+ break
459
+ return out
460
+
461
+ def check_identifiers_exist(self, decl_names: list[str]) -> tuple[int, int]:
462
+ """(existen, total): cuántos de `decl_names` resuelve el entorno cargado,
463
+ vía `#check @nombre`. Consistencia índice <-> Lean (`docs/PLAN_RAG.md
464
+ §12`): si el índice tiene `decl_name` que no existen en esta versión de
465
+ Mathlib, el RAG cita lemas fantasma. No es una prueba de un LLM: no pasa
466
+ por `find_forbidden_tactic`. Aproximado (cuenta errores totales, no los
467
+ mapea 1 a 1 con cada nombre) — suficiente para un aviso, no aborta nada."""
468
+ from lean_interact import Command
469
+
470
+ if not decl_names:
471
+ return 0, 0
472
+ with self._lock:
473
+ if self._server is None or not self._server.is_alive():
474
+ self._start()
475
+ code = "\n".join(f"#check @{n}" for n in decl_names)
476
+ try:
477
+ r = self._server.run(Command(cmd=code, env=self._env), timeout=self.timeout_s)
478
+ except Exception:
479
+ return 0, len(decl_names)
480
+ n_errors = sum(1 for m in r.messages if m.severity == "error")
481
+ return max(0, len(decl_names) - n_errors), len(decl_names)
482
+
483
+ def close(self) -> None:
484
+ if self._server is not None:
485
+ self._server.kill()
486
+ self._server = None
487
+
488
+
489
+ def identifiers_used(proof_tactics: str, decl_names: list[str]) -> list[str]:
490
+ """Nombres de Mathlib citados que aparecen en el código aceptado (used_in_proof, §6.4)."""
491
+ return [d for d in decl_names if re.search(r"(?<![\w.'])" + re.escape(d) + r"(?![\w'.])", proof_tactics)]
ragsolver/llm.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cliente LLM/VLM único para agentes (Fase 4) y enriquecimiento (Fase 2).
2
+
3
+ Proveedores (configs/versions.yaml, secciones `llm` / `vlm_enrichment`):
4
+ - `gemini`: REST `generativelanguage.googleapis.com` (key en GEMINI_API_KEY).
5
+ - `openai_compatible`: `/chat/completions` con `base_url` (OpenRouter, OpenAI,
6
+ Ollama, vLLM...); key en la variable que diga `api_key_env`.
7
+
8
+ Un solo método: `generate(prompt, schema=None, images=(), system=None)` ->
9
+ `LLMResponse(text, model, tokens_in, tokens_out)`. Con `schema` (JSON Schema
10
+ estándar) la salida es JSON validado; para Gemini se traduce al subconjunto
11
+ OpenAPI que acepta `responseSchema`.
12
+
13
+ Cadena de modelos: `model` + `fallback_models`; al agotar la cuota diaria de uno
14
+ (429 con quotaId *PerDay*) se pasa al siguiente. Reintentos (LLM-1, hasta 4,
15
+ `_MAX_ATTEMPTS`): error de transporte o 429/5xx -> 3 s / exponencial; **nunca**
16
+ un timeout de lectura (el proveedor puede haber generado — y cobrado — la
17
+ respuesta antes de que el timeout cortara la conexión; reintentarlo la
18
+ duplicaría). Sin caché aquí (la tiene el Enricher).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import json
25
+ import os
26
+ import threading
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ import httpx
31
+ import yaml
32
+ from tenacity import Retrying, retry_if_exception
33
+
34
+ _GEMINI_API = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
35
+ # ponytail: fijo, no por instancia — si algún día hace falta variar el número de reintentos entre
36
+ # secciones (llm/vlm_enrichment), pasar `max_attempts` al constructor y usarlo en el `Retrying` de abajo.
37
+ _MAX_ATTEMPTS = 4
38
+ # Un 429 es una solicitud RECHAZADA (no se cobra): con el proveedor saturado ("engine_overloaded",
39
+ # DECISIONS #103-#104) conviene esperar más en vez de perder el par con lo ya gastado.
40
+ _MAX_ATTEMPTS_429 = 10
41
+
42
+
43
+ class DailyQuotaExhausted(Exception):
44
+ """429 con quotaId *PerDay*: el modelo no vuelve hasta mañana -> cambiar de modelo."""
45
+
46
+
47
+ @dataclass
48
+ class LLMResponse:
49
+ text: str
50
+ model: str
51
+ tokens_in: int = 0
52
+ tokens_out: int = 0
53
+ calls: int = 1 # >1 cuando una respuesta agrega varias llamadas (self-consistency)
54
+ cost_usd: float = 0.0 # OpenRouter `usage.cost` (exacto, por proveedor); 0 si el backend no lo informa
55
+ attempts: int = 1 # LLM-1: cuántos intentos hizo _generate_with (reintentos de transporte/429/5xx)
56
+
57
+ def json(self) -> dict:
58
+ return json.loads(self.text)
59
+
60
+
61
+ def _is_daily_quota(response: httpx.Response) -> bool:
62
+ try:
63
+ details = response.json()["error"].get("details", [])
64
+ except Exception:
65
+ return False
66
+ return any("PerDay" in v.get("quotaId", "") for d in details for v in d.get("violations", []))
67
+
68
+
69
+ def _retryable(exc: BaseException) -> bool:
70
+ if isinstance(exc, httpx.HTTPStatusError):
71
+ return exc.response.status_code in (429, 500, 502, 503, 504)
72
+ if isinstance(exc, httpx.ReadTimeout):
73
+ # LLM-1: un timeout de LECTURA puede significar que el proveedor ya generó (y cobró) la
74
+ # respuesta y el corte fue nuestro; reintentar la duplicaría. ConnectTimeout y el resto de
75
+ # TransportError sí son seguros de reintentar (nada se envió o el proveedor no respondió nada).
76
+ return False
77
+ # JSON truncado/degenerado NO se reintenta aquí: cada intento cuesta `max_tokens` de salida y
78
+ # el reintento con `stop_after_attempt` alto no los contaba — fue la causa del sobrecosto de
79
+ # eval_v1 (DECISIONS #59). El único reintento (con el doble de tokens) lo hace `Agents._call`,
80
+ # y se contabiliza.
81
+ return isinstance(exc, httpx.TransportError)
82
+
83
+
84
+ def _wait(retry_state) -> float:
85
+ exc = retry_state.outcome.exception()
86
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
87
+ return min(90.0, 4.0 * 2 ** (retry_state.attempt_number - 1))
88
+ return 3.0
89
+
90
+
91
+ def _stop(retry_state) -> bool:
92
+ exc = retry_state.outcome.exception()
93
+ is_429 = isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429
94
+ return retry_state.attempt_number >= (_MAX_ATTEMPTS_429 if is_429 else _MAX_ATTEMPTS)
95
+
96
+
97
+ def _log_retry(retry_state) -> None:
98
+ exc = retry_state.outcome.exception()
99
+ print(f"[llm retry] intento {retry_state.attempt_number} falló: {type(exc).__name__}: {str(exc)[:150]}", flush=True)
100
+
101
+
102
+ def _gemini_schema(schema: dict) -> dict:
103
+ """JSON Schema (Pydantic) -> subconjunto OpenAPI de Gemini: resuelve `$ref`/`$defs`,
104
+ tipos en mayúsculas, `anyOf [X, null]` -> X nullable, sin claves no soportadas."""
105
+ defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
106
+
107
+ def conv(node):
108
+ if isinstance(node, list):
109
+ return [conv(n) for n in node]
110
+ if not isinstance(node, dict):
111
+ return node
112
+ if "$ref" in node:
113
+ return conv(defs[node["$ref"].rsplit("/", 1)[1]])
114
+ if "anyOf" in node:
115
+ options = [o for o in node["anyOf"] if o.get("type") != "null"]
116
+ nullable = len(options) < len(node["anyOf"])
117
+ base = conv(options[0]) if len(options) == 1 else {"anyOf": conv(options)}
118
+ if nullable:
119
+ base["nullable"] = True
120
+ return base
121
+ out = {}
122
+ for k, v in node.items():
123
+ if k in ("additionalProperties", "$schema", "title", "default", "$defs", "definitions", "examples"):
124
+ continue
125
+ if k == "type" and isinstance(v, str):
126
+ out[k] = v.upper()
127
+ elif k == "properties":
128
+ out[k] = {pk: conv(pv) for pk, pv in v.items()}
129
+ elif k == "items":
130
+ out[k] = conv(v)
131
+ else:
132
+ out[k] = v
133
+ return out
134
+
135
+ return conv(schema)
136
+
137
+
138
+ def _read_dotenv(name: str, path: str = ".env") -> str | None:
139
+ p = Path(path)
140
+ if not p.exists():
141
+ return None
142
+ for line in p.read_text(encoding="utf-8").splitlines():
143
+ if line.startswith(name + "="):
144
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
145
+ return None
146
+
147
+
148
+ class LLM:
149
+ def __init__(self, versions_yaml: str = "configs/versions.yaml", section: str = "llm", config: dict | None = None):
150
+ cfg = config or yaml.safe_load(open(versions_yaml, encoding="utf-8"))[section]
151
+ self.provider = cfg.get("provider", "gemini")
152
+ self.models: list[str] = [cfg["model"], *(cfg.get("fallback_models") or [])]
153
+ self.base_url = (cfg.get("base_url") or "").rstrip("/")
154
+ key_env = cfg.get("api_key_env") or ("GEMINI_API_KEY" if self.provider == "gemini" else "OPENROUTER_API_KEY")
155
+ self.api_key = os.environ.get(key_env) or _read_dotenv(key_env)
156
+ if not self.api_key:
157
+ raise RuntimeError(f"{key_env} no está en el entorno ni en .env")
158
+ self.temperature = float(cfg.get("temperature", 0.1))
159
+ self.seed = cfg.get("seed") # RNF-2: semilla fijada en cada llamada (Gemini `seed`, OpenAI `seed`)
160
+ self.max_tokens = cfg.get("max_tokens") # tope de salida (incluye el razonamiento en modelos "thinking")
161
+ self.reasoning_effort = cfg.get("reasoning_effort") # OpenRouter: off | low | medium | high
162
+ self.timeout_s = float(cfg.get("timeout_s", 300)) # LLM-1: bajado de 600-900; 4000 tok de salida al proveedor más lento medido son ~210s (#62)
163
+ self.provider_routing = cfg.get("provider_routing") # OpenRouter `provider` (max_price, sort, order…), se envía tal cual
164
+ self._model_idx = 0
165
+ self._lock = threading.Lock()
166
+ self.exhausted: list[str] = []
167
+ self.calls = 0
168
+ self.tokens_in = 0
169
+ self.tokens_out = 0
170
+ self.cost_usd = 0.0
171
+ self._client = httpx.Client(timeout=self.timeout_s)
172
+
173
+ @property
174
+ def model(self) -> str:
175
+ return self.models[self._model_idx]
176
+
177
+ def generate(self, prompt: str, schema: dict | None = None, images: tuple[str | Path, ...] | list = (), system: str | None = None, max_tokens: int | None = None) -> LLMResponse:
178
+ while True:
179
+ model = self.model
180
+ try:
181
+ return self._generate_with(model, prompt, schema, tuple(images), system, max_tokens)
182
+ except DailyQuotaExhausted:
183
+ with self._lock:
184
+ if self.models[self._model_idx] == model:
185
+ if self._model_idx + 1 >= len(self.models):
186
+ raise
187
+ self._model_idx += 1
188
+ self.exhausted.append(model)
189
+
190
+ def _generate_with(self, model: str, prompt: str, schema: dict | None, images: tuple, system: str | None, max_tokens: int | None) -> LLMResponse:
191
+ # `Retrying` en vez del decorador `@retry`: así se cuenta el número de intentos de esta
192
+ # llamada (LLM-1, `resp.attempts`) sin estado compartido entre hilos (el runner usa `--workers`).
193
+ n_attempts = 0
194
+ for attempt in Retrying(retry=retry_if_exception(_retryable), wait=_wait, stop=_stop, before_sleep=_log_retry, reraise=True):
195
+ with attempt:
196
+ n_attempts += 1
197
+ if self.provider == "gemini":
198
+ resp = self._gemini(model, prompt, schema, images, system, max_tokens)
199
+ else:
200
+ resp = self._openai(model, prompt, schema, images, system, max_tokens)
201
+ resp.attempts = n_attempts
202
+ with self._lock:
203
+ self.calls += 1
204
+ self.tokens_in += resp.tokens_in
205
+ self.tokens_out += resp.tokens_out
206
+ self.cost_usd += resp.cost_usd
207
+ return resp
208
+
209
+ # -- backends ----------------------------------------------------------------
210
+
211
+ def _gemini(self, model, prompt, schema, images, system, max_tokens) -> LLMResponse:
212
+ parts = [{"text": prompt}] + [
213
+ {"inline_data": {"mime_type": "image/png", "data": base64.b64encode(Path(p).read_bytes()).decode()}} for p in images
214
+ ]
215
+ gen: dict = {"temperature": self.temperature}
216
+ if self.seed is not None:
217
+ gen["seed"] = int(self.seed)
218
+ if max_tokens or self.max_tokens:
219
+ gen["maxOutputTokens"] = int(max_tokens or self.max_tokens)
220
+ if schema is not None:
221
+ gen.update(responseMimeType="application/json", responseSchema=_gemini_schema(schema))
222
+ body: dict = {"contents": [{"parts": parts}], "generationConfig": gen}
223
+ if system:
224
+ body["systemInstruction"] = {"parts": [{"text": system}]}
225
+ r = self._client.post(_GEMINI_API.format(model=model), headers={"x-goog-api-key": self.api_key}, json=body)
226
+ if r.status_code == 429 and _is_daily_quota(r):
227
+ raise DailyQuotaExhausted(model)
228
+ r.raise_for_status()
229
+ data = r.json()
230
+ cand = data["candidates"][0]
231
+ text = "".join(p.get("text", "") for p in cand.get("content", {}).get("parts", []) if not p.get("thought"))
232
+ usage = data.get("usageMetadata", {})
233
+ return LLMResponse(text, model, usage.get("promptTokenCount", 0), usage.get("candidatesTokenCount", 0) + usage.get("thoughtsTokenCount", 0))
234
+
235
+ def _openai(self, model, prompt, schema, images, system, max_tokens) -> LLMResponse:
236
+ content: list[dict] = [{"type": "text", "text": prompt}] + [
237
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(Path(p).read_bytes()).decode()}} for p in images
238
+ ]
239
+ messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": content}]
240
+ body: dict = {"model": model, "messages": messages, "temperature": self.temperature}
241
+ if self.seed is not None:
242
+ body["seed"] = int(self.seed)
243
+ if max_tokens or self.max_tokens:
244
+ body["max_tokens"] = int(max_tokens or self.max_tokens)
245
+ if "openrouter" in self.base_url:
246
+ body["usage"] = {"include": True} # costo exacto en la respuesta
247
+ if self.provider_routing:
248
+ body["provider"] = self.provider_routing
249
+ if self.reasoning_effort: # "off" = sin cadena de razonamiento (DeepSeek V4 Flash: 24 s vs 76-90 s por llamada)
250
+ body["reasoning"] = {"enabled": False} if self.reasoning_effort == "off" else {"effort": self.reasoning_effort}
251
+ if schema is not None:
252
+ body["response_format"] = {"type": "json_schema", "json_schema": {"name": "output", "schema": schema, "strict": False}}
253
+ headers = {"Authorization": f"Bearer {self.api_key}"}
254
+ if "openrouter" in self.base_url:
255
+ headers["HTTP-Referer"] = "https://github.com/ragsolver" # atribución opcional que pide OpenRouter
256
+ r = self._client.post(f"{self.base_url}/chat/completions", headers=headers, json=body)
257
+ if (r.status_code == 429 and "daily" in r.text.lower()) or r.status_code in (402, 403):
258
+ # 402/403 en OpenRouter = key sin crédito o fuera de su tope: se pasa al siguiente modelo y, si
259
+ # todos fallan, el runner corta limpio (status quota_exhausted) en vez de grabar 300 pares "error"
260
+ raise DailyQuotaExhausted(f"{model}: HTTP {r.status_code} {r.text[:120]}")
261
+ r.raise_for_status()
262
+ data = r.json()
263
+ text = data["choices"][0]["message"]["content"] or ""
264
+ usage = data.get("usage", {})
265
+ return LLMResponse(text, model, usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), cost_usd=float(usage.get("cost") or 0.0))
ragsolver/logging/__init__.py ADDED
File without changes
ragsolver/logging/jsonl_writer.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Escritor de traza JSONL (RNF-5, docs/PLAN_RAG.md §7.6).
2
+
3
+ Un archivo por corrida (`run_id`), un evento por línea. `append_event` es la
4
+ única función que necesita el resto del código: no hay clase, no hay estado
5
+ oculto más allá del path del archivo — ponytail: no crear un logger genérico
6
+ para un único formato de evento.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import threading
13
+ from pathlib import Path
14
+
15
+ from ragsolver.rag.schema import TraceEvent
16
+
17
+
18
+ def trace_path(run_id: str, base_dir: str | Path = "data/eval/traces") -> Path:
19
+ base = Path(base_dir)
20
+ base.mkdir(parents=True, exist_ok=True)
21
+ return base / f"{run_id}.jsonl"
22
+
23
+
24
+ _LOCK = threading.Lock()
25
+
26
+
27
+ def append_event(event: TraceEvent, base_dir: str | Path = "data/eval/traces") -> Path:
28
+ """Serializa `event` como una línea JSON y la agrega al archivo de la corrida."""
29
+ path = trace_path(event.run_id, base_dir)
30
+ with _LOCK, path.open("a", encoding="utf-8") as f:
31
+ f.write(event.model_dump_json() + "\n")
32
+ return path
33
+
34
+
35
+ def read_events(run_id: str, base_dir: str | Path = "data/eval/traces") -> list[TraceEvent]:
36
+ path = trace_path(run_id, base_dir)
37
+ if not path.exists():
38
+ return []
39
+ with path.open(encoding="utf-8") as f:
40
+ return [TraceEvent.model_validate(json.loads(line)) for line in f if line.strip()]
ragsolver/orchestrator/graph.py ADDED
@@ -0,0 +1,715 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orquestador (docs/PLAN_RAG.md §5.3, Fase 4; propuesta §6.1-6.2; docs/PLAN_MEJORA_RAG.md §4).
2
+
3
+ PROVE (demostrador local, 0 API) -> [DRAFT: plan + prueba informal de la API en el prompt del demostrador]
4
+ -> (si `prover.api_fallback` o no hay demostrador) PLAN -> RETRIEVE -> GENERATE -> FORMALIZE -> [CLOSE -> FILL]
5
+ -> VERIFY -> (DONE | CRITIQUE -> retry/replan/fail)
6
+
7
+ Con `goal_mode` (por defecto) el Autoformalizador escribe un esqueleto `have … := by sorry`; CLOSE
8
+ obtiene del REPL el goal de cada `sorry`, recupera premisas PARA ESE GOAL (rag/premises.py) y prueba
9
+ una batería de tácticas con ellas (0 LLM); FILL (1 llamada) sólo ve los goals que quedaron abiertos.
10
+ Los goals cerrados quedan en `proved` y se reutilizan en reintentos y replans (D5 del plan).
11
+
12
+ LangGraph `StateGraph` sobre un dict. El estado lleva `evidence` (citas
13
+ consultadas, §6.4), la recuperación por subobjetivo y contadores de presupuesto
14
+ (RNF-3: `llm_budget` llamadas por problema). El retriever cuesta 0 llamadas.
15
+ Sin checkpointer persistente (ponytail: MemorySaver/SQLite cuando haga falta
16
+ reanudar corridas largas).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import time
23
+ from typing import Any, TypedDict
24
+
25
+ from langgraph.graph import END, START, StateGraph
26
+
27
+ from ragsolver.agents.agents import Agents, InvalidLLMOutput
28
+ from ragsolver.agents.schemas import CriticOutput, FillOutput, Problem, SolutionReport, Verdict
29
+ from ragsolver.lean.verify import LeanChecker, build_file, count_sorries, fill_sorries, identifiers_used, skeletonize, verify
30
+ from ragsolver.llm import DailyQuotaExhausted
31
+ from ragsolver.prover import ProverUnavailable, draft_hint, examples_hint, extract_tactics, goal_to_theorem, prove as prover_prove, renamed_inaccessible
32
+ from ragsolver.rag import premises as P
33
+ from ragsolver.rag.proofs import similar as similar_proofs
34
+ from ragsolver.rag.retrieve import Retriever
35
+ from ragsolver.rag.schema import Citation, Hit, RetrievalRequest
36
+
37
+
38
+ class State(TypedDict, total=False):
39
+ problem: Problem
40
+ cfg: dict
41
+ run_id: str
42
+ exemplars: list[Hit]
43
+ plan: Any
44
+ lemmas: list[Hit]
45
+ evidence: dict[str, Citation]
46
+ citation_ids: dict[str, str]
47
+ sketch: str
48
+ used_citations: list[str]
49
+ proof_tactics: str
50
+ lean_code: str
51
+ verdict: Verdict | None
52
+ lean_error: str
53
+ feedback: str
54
+ missing_identifiers: list[str]
55
+ attempt: int
56
+ replans: int
57
+ history: list[dict]
58
+ error_types: list[str]
59
+ re_retrievals: int
60
+ re_retrievals_ok: int
61
+ llm_calls: int
62
+ retrieval_calls: int
63
+ tokens_in: int
64
+ tokens_out: int
65
+ models: list[str]
66
+ status: str
67
+ fail_reason: str
68
+ t0: float
69
+ skeleton: str
70
+ candidates: list # bloque del LLM para cada `sorry` del esqueleto (None = sin candidato)
71
+ goals: list[dict] # [{goal, proof_state, block, premises}] del esqueleto actual
72
+ proved: dict[str, str] # goal normalizado -> bloque que lo cerró (se conserva entre intentos y replans)
73
+ prover_samples: int
74
+ solved_by: str
75
+
76
+
77
+ def _count(state: State, resp) -> dict:
78
+ return {
79
+ "llm_calls": state.get("llm_calls", 0) + getattr(resp, "calls", 1),
80
+ "tokens_in": state.get("tokens_in", 0) + resp.tokens_in,
81
+ "tokens_out": state.get("tokens_out", 0) + resp.tokens_out,
82
+ "models": list(dict.fromkeys(state.get("models", []) + [resp.model])),
83
+ }
84
+
85
+
86
+ # lo que REPAIR y SKETCH conservan de un CLOSE que no terminó la prueba: pasos probados y contabilidad
87
+ _CLOSE_KEEP = ("proved", "prover_samples", "evidence", "citation_ids", "lemmas", "retrieval_calls")
88
+
89
+
90
+ def _over_budget(state: State) -> bool:
91
+ return state.get("llm_calls", 0) >= state["cfg"]["llm_budget"]
92
+
93
+
94
+ def _format_error(verdict: Verdict) -> str:
95
+ return "\n".join(f"line {e['line']}:{e['col']}: {e['message']}" for e in verdict.errors)[:3000] or ("uses sorry" if verdict.uses_sorry else "unknown error")
96
+
97
+
98
+ def _assign_citation_ids(state: State, hits: list[Hit], prefix: str) -> dict[str, str]:
99
+ """BUG-1: mapa `chunk_id -> citation_id` estable durante todo el problema —
100
+ reutiliza el id ya asignado a un chunk que vuelve a aparecer (tras un replan
101
+ o una re-recuperación) y sólo asigna el siguiente número libre del prefijo a
102
+ los chunks nuevos. Antes `retrieve()` renumeraba `M1..Mn` en cada llamada:
103
+ un chunk que ya estaba en `evidence` con `M3` podía volver a verse como `M1`
104
+ en la siguiente búsqueda, y el `M1` real quedaba duplicado (34/300 reportes
105
+ de `eval_v1` con `citation_id` repetidos, afectando `used_in_sketch`)."""
106
+ ids = dict(state.get("citation_ids", {}))
107
+ next_n = max((int(v[len(prefix):]) for v in ids.values() if v.startswith(prefix) and v[len(prefix):].isdigit()), default=0) + 1
108
+ for h in hits:
109
+ if h.chunk_id not in ids:
110
+ ids[h.chunk_id] = f"{prefix}{next_n}"
111
+ next_n += 1
112
+ h.citation.citation_id = ids[h.chunk_id]
113
+ return ids
114
+
115
+
116
+ def _goal_key(goal: str) -> str:
117
+ return " ".join(goal.split())
118
+
119
+
120
+ def render_tactic(entry: dict, use_rag: bool, premises: list[str], hints: list[str]) -> str | None:
121
+ """Táctica de la batería CLOSE para una entrada de `close.tactics` (None = se omite)."""
122
+ if not use_rag:
123
+ return entry.get("plain")
124
+ tac = entry["rag"]
125
+ if ("{premises}" in tac and not premises) or ("{hints}" in tac and not hints):
126
+ return entry.get("plain")
127
+ return tac.replace("{premises}", ", ".join(premises)).replace("{hints}", ", ".join(hints))
128
+
129
+
130
+ def concrete_block(tactic: str, infos: list[str]) -> str:
131
+ """`try?` cierra el goal y sugiere el script concreto ("Try this: [apply] exact …"): ese script es
132
+ el que va al archivo final, para que el kernel nunca acepte una táctica con `?`."""
133
+ if not tactic.rstrip().endswith("?"):
134
+ return tactic
135
+ for info in infos:
136
+ for line in info.splitlines():
137
+ line = line.strip()
138
+ if line.startswith("[apply] "):
139
+ return line[len("[apply] "):]
140
+ return tactic
141
+
142
+
143
+ def close_goal(checker: LeanChecker, goal: dict, tactics: list[dict], use_rag: bool, premises: list[str], hints: list[str], timeout_s: float = 60, heartbeats: int | None = 200000) -> tuple[str | None, list[dict]]:
144
+ """Batería CLOSE sobre un goal (docs/PLAN_MEJORA_RAG.md Fase 4): la primera táctica que lo cierra
145
+ gana. Devuelve (bloque concreto o None, [{tactic, closed, error}])."""
146
+ log = []
147
+ for entry in tactics:
148
+ tac = render_tactic(entry, use_rag, premises, hints)
149
+ if not tac:
150
+ continue
151
+ closed, msg, infos = checker.step(goal["proof_state"], tac, timeout=timeout_s, heartbeats=heartbeats, gen=goal.get("gen"))
152
+ log.append({"tactic": tac[:200], "closed": closed, "error": "" if closed else msg[:300]})
153
+ if closed:
154
+ return concrete_block(tac, infos), log
155
+ if "stale proof state" in msg or "lean repl failure" in msg: # el REPL se reinició: el goal queda abierto
156
+ break
157
+ return None, log
158
+
159
+
160
+ def _premise_hit(name: str, sig: str, dense: dict[str, Hit], ctx: dict) -> Hit:
161
+ if name in dense:
162
+ return dense[name]
163
+ cit = Citation(citation_id="M0", chunk_id=f"mathlib:{name}:0", corpus="mathlib", unit_type="mathlib_decl", label=name, locator={"decl_name": name, "module_path": "", "docs_url": ""}, retrieved_for=ctx)
164
+ return Hit(chunk_id=cit.chunk_id, corpus="mathlib", unit_type="mathlib_decl", rank=1, text=f"{name} : {sig}", metadata={"decl_name": name, "signature": sig}, citation=cit)
165
+
166
+
167
+ def select_premises(retriever: Retriever | None, goal: str, k: int = 16, n_candidates: int = 50, rerank: bool = False, ctx: dict | None = None, dense_channel: bool = False) -> tuple[list[str], dict[str, Hit]]:
168
+ """Premisas para un goal de Lean (docs/PLAN_MEJORA_RAG.md Fase 3): canal A (prior filtrado por los
169
+ símbolos del goal) y, si `dense_channel`, canal C (índice denso con la meta del goal) fusionado con
170
+ RRF. Devuelve (nombres, hits densos por nombre). 0 llamadas LLM.
171
+
172
+ El canal C está apagado por defecto (`close.dense_channel`, DECISIONS #98): recall@16 por goal 0.028
173
+ frente a 0.327 del prior, y fusionado con RRF baja el total a 0.097."""
174
+ a = P.prior_channel(goal, k=2 * k)
175
+ dense: dict[str, Hit] = {}
176
+ if retriever is not None and dense_channel:
177
+ try:
178
+ req = RetrievalRequest(queries=[P.goal_target(goal)[:2000] or goal[:2000]], mode="formal", corpora=["mathlib"], k=k, n_candidates=n_candidates, rerank=rerank, include_figures=False, context=ctx or {"problem_id": "", "agent": "close"})
179
+ dense = {h.metadata.get("decl_name"): h for h in retriever.search(req).hits if h.metadata.get("decl_name")}
180
+ except Exception: # sin índice denso (tests, demo sin tabla): sólo el prior
181
+ pass
182
+ return P.fuse(a, list(dense), k=k), dense
183
+
184
+
185
+ class Orchestrator:
186
+ def __init__(self, agents: Agents, retriever: Retriever | None, checker: LeanChecker | None = None, prover=None, drafter: Agents | None = None):
187
+ self.agents = agents
188
+ self.retriever = retriever
189
+ self.checker = checker # None -> un proceso `lake env lean` por verificación
190
+ self.prover = prover # demostrador local (ragsolver.prover.LocalProver) o None
191
+ self.drafter = drafter # Agents con el LLM que escribe el borrador de DRAFT (`llm_draft`, con razonamiento); None = `agents`
192
+ self.graph = self._build()
193
+
194
+ # -- nodos ---------------------------------------------------------------------
195
+
196
+ def _prover_cfg(self, state: State) -> dict | None:
197
+ pcfg = state["cfg"].get("prover") or {}
198
+ return pcfg if pcfg.get("enabled") and self.prover is not None and self.checker is not None else None
199
+
200
+ def _api_loop(self, state: State) -> bool:
201
+ """¿Corre el bucle de agentes por API (PLAN → … → CRITIQUE) cuando el demostrador no resuelve? Sin
202
+ demostrador, siempre; con él, sólo si `prover.api_fallback` (en eval-v3 costó US$0.06 en los 9
203
+ problemas que el demostrador no resolvió y no resolvió ninguno)."""
204
+ pcfg = self._prover_cfg(state)
205
+ return pcfg is None or bool(pcfg.get("api_fallback", True))
206
+
207
+ def prove(self, state: State) -> dict:
208
+ """Etapa 0 (docs/PLAN_REDISENO_PROVER.md): el demostrador local intenta el teorema completo, 0 API.
209
+ Con RAG, la autocorrección recibe nombres que existen en el Mathlib fijado (se citan)."""
210
+ pcfg = self._prover_cfg(state)
211
+ if pcfg is None:
212
+ return {}
213
+ cfg, problem = state["cfg"], state["problem"]
214
+ name = re.match(r"\s*(?:theorem|lemma)\s+(\S+)", problem.formal_statement)
215
+ if not name:
216
+ return {}
217
+ retr = self.retriever if cfg["rag"] and pcfg.get("rag_names") else None
218
+ tac, log = prover_prove(self.prover, self.checker, problem.formal_statement, problem.header, name.group(1), k=pcfg["k_root"], correction_rounds=pcfg.get("correction_rounds", 1), retriever=retr, max_heartbeats=cfg["lean"]["max_heartbeats"])
219
+ self.agents.trace("prover", state["run_id"], problem.problem_id, 1, log.get("seconds", 0) * 1000, {"stage": "root", "solved": bool(tac), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}, "suggested": list(log.get("suggested", {}))})
220
+ delta = self._cite(state, log.get("suggested", {}), "prover")
221
+ delta["prover_samples"] = state.get("prover_samples", 0) + log["samples"] + log["rounds"] * 2
222
+ if tac:
223
+ delta.update(proof_tactics=tac, solved_by="prover_root")
224
+ return delta
225
+ if pcfg.get("examples") and cfg["rag"]:
226
+ delta.update(self.examples({**state, **delta}, name.group(1), pcfg))
227
+ if pcfg.get("repair") and not delta.get("solved_by"):
228
+ delta.update(self.repair({**state, **delta}, log.get("candidates", []), pcfg["repair"]))
229
+ if pcfg.get("sketch") and not delta.get("solved_by"):
230
+ delta.update(self.sketch({**state, **delta}, name.group(1), pcfg["sketch"]))
231
+ if not delta.get("solved_by") and not pcfg.get("dsp") and not self._api_loop(state):
232
+ delta.update(status="fail", fail_reason="prover: sin prueba (respaldo por API apagado: prover.api_fallback)")
233
+ return delta
234
+
235
+ def examples(self, state: State, name: str, pcfg: dict) -> dict:
236
+ """EXAMPLES (`prover.examples`, RAG de pruebas, `rag/proofs.py`): si la etapa 0 falla, las pruebas
237
+ verificadas de los `examples` problemas más parecidos (embeddings sobre Lean Workbook) (1) se prueban tal
238
+ cual sobre el enunciado (0 GPU: la misma táctica a veces cierra un problema análogo) y (2) van como
239
+ ejemplos en el prompt del demostrador (`k_examples` muestras, como DRAFT; su control es
240
+ `baseline_prover16`)."""
241
+ cfg, problem = state["cfg"], state["problem"]
242
+ t0 = time.time()
243
+ hits = similar_proofs(f"{problem.informal_statement}\n{problem.formal_statement}", k=pcfg["examples"], max_sim=pcfg.get("max_sim", 0.95))
244
+ info = {"stage": "examples", "retrieved": [(h["problem_id"], h["sim"]) for h in hits]}
245
+ delta = {"retrieval_calls": state.get("retrieval_calls", 0) + 1}
246
+ for h in hits:
247
+ if self.checker.check(problem.formal_statement, h["proof"], problem.header, cfg["lean"]["max_heartbeats"]).ok:
248
+ self.agents.trace("prover", state["run_id"], problem.problem_id, 1, (time.time() - t0) * 1000, {**info, "solved": True, "solved_in": f"direct:{h['problem_id']}"})
249
+ return {**delta, "proof_tactics": h["proof"], "solved_by": "prover_examples"}
250
+ if not hits:
251
+ return delta
252
+ tac, log = prover_prove(self.prover, self.checker, problem.formal_statement, problem.header, name, k=pcfg.get("k_examples", 8), correction_rounds=0, max_heartbeats=cfg["lean"]["max_heartbeats"], hint=examples_hint(hits))
253
+ self.agents.trace("prover", state["run_id"], problem.problem_id, 1, (time.time() - t0) * 1000, {**info, "solved": bool(tac), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}})
254
+ delta["prover_samples"] = state.get("prover_samples", 0) + log["samples"]
255
+ if tac:
256
+ delta.update(proof_tactics=tac, solved_by="prover_examples")
257
+ return delta
258
+
259
+ def repair(self, state: State, candidates: list[str], n: int) -> dict:
260
+ """REPAIR (0 API, `prover.repair`): una prueba fallida del demostrador suele tener casi todos los pasos
261
+ bien. Se parte en sus `have` como la del Formalizador (`skeletonize`) y pasa por CLOSE: el bloque del
262
+ propio demostrador primero, la batería y el demostrador sobre el lema corto sólo en los pasos que fallan.
263
+ Se prueban las `n` mejores (menos errores de Lean) cuyo esqueleto elabora; lo probado queda en `proved`."""
264
+ keep: dict = {}
265
+ tried = 0
266
+ for tac in candidates:
267
+ if tried >= n:
268
+ break
269
+ skeleton, cands = skeletonize(tac)
270
+ if count_sorries(skeleton) < 2: # sin `have` no hay nada que partir: es la misma prueba que ya falló
271
+ continue
272
+ d = self.close({**state, **keep, "skeleton": skeleton, "candidates": cands})
273
+ keep.update({k: d[k] for k in _CLOSE_KEEP if k in d})
274
+ if not d.get("goals"): # el esqueleto no elabora (falla el cierre o la estructura): cuesta una llamada al REPL
275
+ continue
276
+ tried += 1
277
+ if "proof_tactics" in d:
278
+ return {**keep, "proof_tactics": d["proof_tactics"], "solved_by": "prover_repair"}
279
+ return keep
280
+
281
+ def sketch(self, state: State, name: str, rounds: int) -> dict:
282
+ """SKETCH (`prover.sketch` rondas; receta de DeepSeek-Prover-V2): el modelo con razonamiento (`llm_draft`)
283
+ escribe el esqueleto en Lean, pasos `have … := by sorry`; CLOSE cierra cada paso con la batería y el
284
+ demostrador sobre el lema corto. Si quedan pasos abiertos, la ronda siguiente le muestra cuáles para que
285
+ los parta más; si el esqueleto no elabora, el error de Lean."""
286
+ delta: dict = {}
287
+ feedback = ""
288
+ for rnd in range(rounds):
289
+ if _over_budget({**state, **delta}):
290
+ break
291
+ text, resp = (self.drafter or self.agents).sketch(state["problem"], feedback, state["run_id"], rnd + 1)
292
+ delta.update(_count({**state, **delta}, resp))
293
+ tac = extract_tactics(text, name)
294
+ if not tac:
295
+ feedback = f"Your answer had no ```lean4 block with `theorem {name}`."
296
+ continue
297
+ skeleton, cands = skeletonize(tac)
298
+ d = self.close({**state, **delta, "skeleton": skeleton, "candidates": cands})
299
+ delta.update({k: d[k] for k in _CLOSE_KEEP if k in d})
300
+ if "proof_tactics" in d:
301
+ return {**delta, "proof_tactics": d["proof_tactics"], "solved_by": "prover_sketch"}
302
+ if not d.get("goals"):
303
+ feedback = f"Previous skeleton:\n{tac[:3000]}\nIt does not compile in Lean:\n{d.get('lean_error', '')[:1500]}"
304
+ else:
305
+ open_ = "\n\n".join(g["goal"] for g in d["goals"] if not g.get("block"))
306
+ feedback = f"Previous skeleton:\n{tac[:3000]}\nNo automation could prove these steps (Lean goals). Split them into smaller `have` steps or use another argument:\n{open_[:3000]}"
307
+ return delta
308
+
309
+ def draft(self, state: State) -> dict:
310
+ """DRAFT (Draft-Sketch-Prove, `prover.dsp`): si la etapa 0 no resolvió, el modelo de la API escribe el
311
+ plan y la prueba informal (Planificador + Generador, 2 llamadas) y el demostrador local recibe eso,
312
+ junto con los lemas del RAG para el goal raíz, DENTRO de su prompt (`k_draft` muestras). Es el lugar
313
+ donde la API y el RAG pueden mover la prueba: el demostrador sabe Lean y el modelo general razona en
314
+ lenguaje natural."""
315
+ if _over_budget(state):
316
+ return {"status": "fail", "fail_reason": "budget"}
317
+ pcfg, cfg, problem = self._prover_cfg(state), state["cfg"], state["problem"]
318
+ agents = self.drafter or self.agents
319
+ exemplars, delta = self._exemplars(state)
320
+ plan, r1 = agents.plan(problem, exemplars, state["run_id"], 1)
321
+ delta.update(plan=plan, exemplars=exemplars, **_count(state, r1))
322
+ out, r2 = agents.generate(problem, plan, [], exemplars, "", state["run_id"], 1)
323
+ delta.update(sketch=out.sketch, used_citations=out.used_citations, **_count({**state, **delta}, r2))
324
+ lemmas: dict[str, str] = {}
325
+ if cfg["rag"]: # canal A (prior de competición) sobre el goal raíz, con su firma
326
+ goals, errors = self.checker.goals(problem.formal_statement, "sorry", problem.header, cfg["lean"]["max_heartbeats"])
327
+ if len(goals) == 1 and not errors:
328
+ prior = {r["decl_name"]: r.get("type", "") for r in P.load_prior()}
329
+ lemmas = {n: prior.get(n, "") for n in P.prior_channel(goals[0]["goal"], k=pcfg.get("k_lemmas", 10))}
330
+ hint = draft_hint(plan.strategy, [s.statement_nl for s in plan.subgoals], out.sketch, lemmas)
331
+ name = re.match(r"\s*(?:theorem|lemma)\s+(\S+)", problem.formal_statement).group(1)
332
+ tac, log = prover_prove(self.prover, self.checker, problem.formal_statement, problem.header, name, k=pcfg.get("k_draft", 8), correction_rounds=0, max_heartbeats=cfg["lean"]["max_heartbeats"], hint=hint)
333
+ self.agents.trace("prover", state["run_id"], problem.problem_id, 1, log.get("seconds", 0) * 1000, {"stage": "draft", "solved": bool(tac), "lemmas": list(lemmas), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}})
334
+ delta.update(self._cite({**state, **delta}, lemmas, "prover"))
335
+ delta["prover_samples"] = state.get("prover_samples", 0) + log["samples"]
336
+ if tac:
337
+ delta.update(proof_tactics=tac, solved_by="prover_draft")
338
+ elif not self._api_loop(state):
339
+ delta.update(status="fail", fail_reason="prover: sin prueba tras DRAFT (respaldo por API apagado: prover.api_fallback)")
340
+ return delta
341
+
342
+ def _exemplars(self, state: State) -> tuple[list[Hit], dict]:
343
+ """Ejemplares del corpus informal para el Planificador (0 LLM) y su delta de citas/evidencia."""
344
+ cfg, problem = state["cfg"], state["problem"]
345
+ exemplars: list[Hit] = []
346
+ searched = cfg["rag"] and "informal" in cfg["corpora"] and self.retriever is not None
347
+ if searched:
348
+ req = RetrievalRequest(queries=[problem.informal_statement[:2000]], mode="exemplar", corpora=["informal"], k=cfg["retrieval"]["k_informal"], n_candidates=cfg["retrieval"]["n_candidates"], rerank=cfg["retrieval"]["rerank"], context={"problem_id": problem.problem_id, "subgoal_id": "", "attempt": state.get("attempt", 1), "agent": "planner", "run_id": state["run_id"]})
349
+ exemplars = self.retriever.search(req).hits
350
+ citation_ids = _assign_citation_ids(state, exemplars, "I")
351
+ evidence = dict(state.get("evidence", {}))
352
+ for h in exemplars:
353
+ evidence.setdefault(h.chunk_id, h.citation)
354
+ return exemplars, {"evidence": evidence, "citation_ids": citation_ids, "retrieval_calls": state.get("retrieval_calls", 0) + int(searched)}
355
+
356
+ def _cite(self, state: State, suggested: dict[str, str], agent: str) -> dict:
357
+ """Nombres que propuso el RAG (sugerencias de la autocorrección) como citas `M*`."""
358
+ if not suggested:
359
+ return {}
360
+ ctx = {"problem_id": state["problem"].problem_id, "subgoal_id": "", "attempt": state.get("attempt", 1), "agent": agent, "run_id": state["run_id"]}
361
+ hits = [_premise_hit(n, sig, {}, ctx) for n, sig in suggested.items()]
362
+ evidence = dict(state.get("evidence", {}))
363
+ ids = _assign_citation_ids(state, hits, "M")
364
+ for h in hits:
365
+ evidence.setdefault(h.chunk_id, h.citation)
366
+ return {"evidence": evidence, "citation_ids": ids}
367
+
368
+ def plan(self, state: State) -> dict:
369
+ if _over_budget(state):
370
+ return {"status": "fail", "fail_reason": "budget"}
371
+ problem = state["problem"]
372
+ exemplars, ex_delta = self._exemplars(state)
373
+ feedback = ""
374
+ if state.get("plan") is not None: # replan (DES-1): razón del Crítico + plan anterior + lo ya probado
375
+ proved = [P.goal_target(k) for k in state.get("proved", {})]
376
+ feedback = f"Critic: {state.get('feedback', '')}\nPrevious strategy: {state['plan'].strategy}" + ("\nAlready proved (reuse as facts):\n" + "\n".join(f"- {t}" for t in proved) if proved else "")
377
+ plan, resp = self.agents.plan(problem, exemplars, state["run_id"], state.get("attempt", 1), feedback=feedback)
378
+ return {"exemplars": exemplars, "plan": plan, "feedback": "", "solved_by": "", **ex_delta, **_count(state, resp)}
379
+
380
+ def retrieve(self, state: State) -> dict:
381
+ cfg, problem = state["cfg"], state["problem"]
382
+ if not cfg["rag"] or self.retriever is None:
383
+ return {"lemmas": [], "missing_identifiers": []}
384
+ hits: dict[str, Hit] = {}
385
+ k = cfg["retrieval"]["k_mathlib"]
386
+ ctx = lambda sg, agent: {"problem_id": problem.problem_id, "subgoal_id": sg, "attempt": state.get("attempt", 1), "agent": agent, "run_id": state["run_id"]}
387
+ re_ok, re_n = state.get("re_retrievals_ok", 0), state.get("re_retrievals", 0)
388
+ n_calls = state.get("retrieval_calls", 0)
389
+ missing = state.get("missing_identifiers", [])
390
+ if missing: # re-recuperación tras `lema faltante` (0 llamadas LLM)
391
+ re_n += 1
392
+ queries = [f"unknown identifier '{m}'" for m in missing[:3]] + [state.get("lean_error", "")[:500] or problem.formal_statement[:500]]
393
+ res = self.retriever.search(RetrievalRequest(queries=queries[:4], mode="error", corpora=["mathlib"], k=k, n_candidates=cfg["retrieval"]["n_candidates"], rerank=cfg["retrieval"]["rerank"], context=ctx("", "critic")))
394
+ n_calls += 1
395
+ if any(h.metadata.get("decl_name") in missing for h in res.hits):
396
+ re_ok += 1
397
+ for h in res.hits:
398
+ hits.setdefault(h.chunk_id, h)
399
+ for sg in [] if cfg.get("goal_mode") and self.checker is not None else state["plan"].subgoals: # goal_mode: las premisas se recuperan por goal en CLOSE
400
+ queries = ([sg.statement_nl] + list(sg.search_queries))[:4]
401
+ res = self.retriever.search(RetrievalRequest(queries=queries, mode="nl", corpora=["mathlib"], k=k, n_candidates=cfg["retrieval"]["n_candidates"], rerank=cfg["retrieval"]["rerank"], rerank_against=sg.statement_nl, context=ctx(sg.subgoal_id, "retriever")))
402
+ n_calls += 1
403
+ for h in res.hits:
404
+ hits.setdefault(h.chunk_id, h)
405
+ lemmas = list(hits.values())
406
+ citation_ids = _assign_citation_ids(state, lemmas, "M")
407
+ evidence = dict(state.get("evidence", {}))
408
+ for h in lemmas:
409
+ evidence.setdefault(h.chunk_id, h.citation)
410
+ return {"lemmas": lemmas, "evidence": evidence, "citation_ids": citation_ids, "missing_identifiers": [], "re_retrievals": re_n, "re_retrievals_ok": re_ok, "retrieval_calls": n_calls}
411
+
412
+ def generate(self, state: State) -> dict:
413
+ if _over_budget(state):
414
+ return {"status": "fail", "fail_reason": "budget"}
415
+ out, resp = self.agents.generate(state["problem"], state["plan"], state.get("lemmas", []), state.get("exemplars", []), state.get("feedback", ""), state["run_id"], state.get("attempt", 1))
416
+ return {"sketch": out.sketch, "used_citations": out.used_citations, **_count(state, resp)}
417
+
418
+ def formalize(self, state: State) -> dict:
419
+ if _over_budget(state):
420
+ return {"status": "fail", "fail_reason": "budget"}
421
+ goal_mode = bool(state["cfg"].get("goal_mode")) and self.checker is not None
422
+ proved = [f"have : {P.goal_target(k)} := by {b}" for k, b in state.get("proved", {}).items()]
423
+ out, resp = self.agents.formalize(state["problem"], state["sketch"], state.get("lemmas", []), state.get("lean_error", ""), state["run_id"], state.get("attempt", 1), skeleton=goal_mode, proved=proved)
424
+ if goal_mode: # la prueba del modelo se parte en goals; sus bloques son el primer candidato de cada uno
425
+ skeleton, candidates = skeletonize(out.proof_tactics)
426
+ return {"skeleton": skeleton, "candidates": candidates, "proof_tactics": skeleton, **_count(state, resp)}
427
+ lean = state["cfg"]["lean"]
428
+ problem = state["problem"]
429
+ # BUG-2: `lean_code` reportado == lo que verifica `verify()` (mismo `theorem_block`, mismos `open`).
430
+ code = build_file(problem.formal_statement, out.proof_tactics, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
431
+ return {"proof_tactics": out.proof_tactics, "lean_code": code, **_count(state, resp)}
432
+
433
+ def close(self, state: State) -> dict:
434
+ """CLOSE (0 LLM): goals del esqueleto -> premisas por goal -> batería de tácticas."""
435
+ cfg, problem = state["cfg"], state["problem"]
436
+ lean, ccfg = cfg["lean"], cfg["close"]
437
+ skeleton = state["skeleton"]
438
+ goals, errors = self.checker.goals(problem.formal_statement, skeleton, problem.header, lean["max_heartbeats"])
439
+ n_tokens = count_sorries(skeleton)
440
+ if errors or len(goals) != n_tokens:
441
+ msg = "\n".join(f"line {e['line']}:{e['col']}: {e['message']}" for e in errors) or f"the skeleton has {n_tokens} `sorry` but Lean found {len(goals)} goals: use exactly one `sorry` per goal"
442
+ self.agents.trace("close", state["run_id"], problem.problem_id, state.get("attempt", 1), 0, {"skeleton_ok": False, "error": msg[:300]})
443
+ code = build_file(problem.formal_statement, skeleton, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
444
+ return {"goals": [], "lean_error": msg[:3000], "lean_code": code, "verdict": Verdict(ok=False, errors=errors or [{"line": 0, "col": 0, "message": msg}])}
445
+ use_rag = bool(cfg["rag"]) and self.retriever is not None
446
+ proved = dict(state.get("proved", {}))
447
+ evidence = dict(state.get("evidence", {}))
448
+ new_hits: list[Hit] = []
449
+ n_calls = state.get("retrieval_calls", 0)
450
+ candidates = state.get("candidates") or [None] * len(goals)
451
+ n_prover_goals, prover_samples = 0, state.get("prover_samples", 0)
452
+ pcfg = self._prover_cfg(state)
453
+
454
+ def refresh() -> None: # el REPL se reinició (timeout de este hilo o de otro): proof_state nuevos
455
+ fresh, errs = self.checker.goals(problem.formal_statement, skeleton, problem.header, lean["max_heartbeats"])
456
+ if not errs and len(fresh) == len(goals):
457
+ for h, f in zip(goals, fresh):
458
+ h.update(proof_state=f["proof_state"], gen=f["gen"])
459
+
460
+ for i, g in enumerate(goals):
461
+ g["candidate"] = candidates[i] if i < len(candidates) else None
462
+ for _ in range(2): # 2.º intento sólo si el REPL se reinició a mitad del goal (eval-v4: pasos "stale" sin probar)
463
+ if getattr(self.checker, "gen", g.get("gen")) != g.get("gen"):
464
+ refresh()
465
+ prover_ok = pcfg is not None and n_prover_goals < pcfg.get("max_goals", 3)
466
+ block, names, hits, log, n = self._close_one(state, g, i, proved, use_rag, prover_ok)
467
+ n_calls += hits is not None
468
+ new_hits += hits or []
469
+ prover_samples += n
470
+ n_prover_goals += any(t["tactic"] == "prover" for t in log)
471
+ if block or not any("stale proof state" in t["error"] for t in log):
472
+ break
473
+ g.update(block=block, premises=names, log=log)
474
+ if block:
475
+ proved[_goal_key(g["goal"])] = block
476
+ self.agents.trace("close", state["run_id"], problem.problem_id, state.get("attempt", 1), 0, {"goal": i, "closed": bool(block), "block": (block or "")[:200], "premises": names, "tried": log})
477
+ citation_ids = _assign_citation_ids(state, new_hits, "M")
478
+ for h in new_hits:
479
+ evidence.setdefault(h.chunk_id, h.citation)
480
+ delta = {"goals": goals, "prover_samples": prover_samples, "lean_error": "", "proved": proved, "evidence": evidence, "citation_ids": citation_ids, "lemmas": list({h.chunk_id: h for h in state.get("lemmas", []) + new_hits}.values()), "retrieval_calls": n_calls}
481
+ if all(g["block"] for g in goals):
482
+ delta["proof_tactics"] = fill_sorries(skeleton, [g["block"] for g in goals])
483
+ return delta
484
+
485
+ def _close_one(self, state: State, g: dict, i: int, proved: dict, use_rag: bool, prover_ok: bool) -> tuple[str | None, list[str], list[Hit] | None, list[dict], int]:
486
+ """Un goal de CLOSE: lo ya probado en un intento anterior, el bloque del modelo para ese paso, la batería
487
+ con las premisas del goal y, si `prover_ok`, el demostrador sobre el lema corto. Devuelve (bloque o None,
488
+ premisas, hits del RAG o None si no se buscó, log, muestras del demostrador)."""
489
+ ccfg, hb = state["cfg"]["close"], state["cfg"]["lean"]["max_heartbeats"]
490
+ key = _goal_key(g["goal"])
491
+ if key in proved:
492
+ closed, _msg, _ = self.checker.step(g["proof_state"], proved[key], timeout=ccfg["timeout_s"], heartbeats=hb, gen=g.get("gen"))
493
+ if closed:
494
+ return proved[key], [], None, [{"tactic": "reused", "closed": True, "error": ""}], 0
495
+ cand_log = []
496
+ if g.get("candidate"):
497
+ closed, msg, _ = self.checker.step(g["proof_state"], g["candidate"], timeout=ccfg["timeout_s"], heartbeats=hb, gen=g.get("gen"))
498
+ cand_log = [{"tactic": "llm", "closed": closed, "error": "" if closed else msg[:300]}]
499
+ if closed:
500
+ return g["candidate"], [], None, cand_log, 0
501
+ names, hints, hits = [], [], None
502
+ if use_rag:
503
+ names, hits = self._premises(state, g["goal"], ccfg["k_premises"], i)
504
+ hints = P.nlinarith_hints(g["goal"])
505
+ block, log = close_goal(self.checker, g, ccfg["tactics"], use_rag, names, hints, ccfg["timeout_s"])
506
+ n = 0
507
+ if block is None and prover_ok:
508
+ block, plog, n = self._prove_goal(state, g, i, self._prover_cfg(state))
509
+ log = log + plog
510
+ return block, names, hits, cand_log + log, n
511
+
512
+ def _prove_goal(self, state: State, g: dict, i: int, pcfg: dict) -> tuple[str | None, list[dict], int]:
513
+ """Un goal abierto como lema autónomo para el demostrador (receta DeepSeek-Prover-V2); el bloque se
514
+ acepta sólo si cierra el goal EN SU CONTEXTO (`step`), con `rename_i` para los nombres inaccesibles."""
515
+ name = f"rs_goal_{i}"
516
+ stmt = goal_to_theorem(g["goal"], name)
517
+ if stmt is None:
518
+ return None, [{"tactic": "prover", "closed": False, "error": "goal no convertible a lema"}], 0
519
+ problem = state["problem"]
520
+ tac, plog = prover_prove(self.prover, self.checker, stmt, problem.header, name, k=pcfg.get("k_goal", 2), correction_rounds=0, max_heartbeats=state["cfg"]["lean"]["max_heartbeats"])
521
+ n = plog["samples"]
522
+ if not tac:
523
+ return None, [{"tactic": "prover", "closed": False, "error": f"{plog['extracted']}/{n} muestras extraídas, ninguna verifica"}], n
524
+ renamed = renamed_inaccessible(g["goal"])
525
+ block = (f"rename_i {' '.join(renamed)}\n" if renamed else "") + tac
526
+ closed, msg, _ = self.checker.step(g["proof_state"], block, timeout=state["cfg"]["close"]["timeout_s"], heartbeats=state["cfg"]["lean"]["max_heartbeats"], gen=g.get("gen"))
527
+ return (block if closed else None), [{"tactic": "prover", "closed": closed, "error": "" if closed else msg[:300]}], n
528
+
529
+ def _premises(self, state: State, goal: str, k: int, i: int) -> tuple[list[str], list[Hit]]:
530
+ problem = state["problem"]
531
+ ctx = {"problem_id": problem.problem_id, "subgoal_id": f"G{i + 1}", "attempt": state.get("attempt", 1), "agent": "close", "run_id": state["run_id"]}
532
+ ccfg = state["cfg"]["close"]
533
+ names, dense = select_premises(self.retriever, goal, k, state["cfg"]["retrieval"]["n_candidates"], state["cfg"]["retrieval"]["rerank"], ctx, dense_channel=bool(ccfg.get("dense_channel")))
534
+ prior = {r["decl_name"]: r for r in P.load_prior()}
535
+ return names, [_premise_hit(n, prior.get(n, {}).get("type", ""), dense, ctx) for n in names]
536
+
537
+ def fill(self, state: State) -> dict:
538
+ """FILL (1 llamada): los goals que CLOSE no cerró, cada uno con su goal exacto y sus premisas."""
539
+ if _over_budget(state):
540
+ return {"status": "fail", "fail_reason": "budget"}
541
+ problem, goals = state["problem"], [dict(g) for g in state["goals"]]
542
+ open_ = [i for i, g in enumerate(goals) if not g.get("block")]
543
+ sigs = {h.metadata.get("decl_name"): h.metadata.get("signature", "") for h in state.get("lemmas", [])}
544
+ parts = []
545
+ for j, i in enumerate(open_):
546
+ g = goals[i]
547
+ lem = "\n".join(f" {n} : {sigs.get(n, '')[:300]}" for n in g.get("premises", [])) or " (none)"
548
+ err = next((t["error"] for t in reversed(g.get("log", [])) if t["error"]), "")
549
+ prev = ""
550
+ if g.get("candidate"):
551
+ cand_err = next((t["error"] for t in g.get("log", []) if t["tactic"] == "llm"), "")
552
+ prev = f"\nYour previous attempt for this goal (rejected by Lean):\n{g['candidate'][:1500]}\nLean error: {cand_err[:400]}"
553
+ parts.append(f"[G{j + 1}]\n{g['goal']}\nLemmas:\n{lem}{prev}\nAutomation error: {err[:300] or '(none)'}")
554
+ try:
555
+ out, resp = self.agents.fill(problem, "\n\n".join(parts), state["run_id"], state.get("attempt", 1))
556
+ except InvalidLLMOutput as e: # #110: sin propuestas válidas los goals siguen abiertos; no se tira el problema
557
+ out, resp = FillOutput(proofs=[]), e
558
+ by_id = {p.goal_id.strip().upper(): p.tactics for p in out.proofs}
559
+ proved = dict(state.get("proved", {}))
560
+ for j, i in enumerate(open_):
561
+ tac = by_id.get(f"G{j + 1}", "").strip()
562
+ if not tac:
563
+ goals[i]["block"] = "done" # sin respuesta: el goal queda abierto y Lean lo reporta
564
+ continue
565
+ closed, msg, _ = self.checker.step(goals[i]["proof_state"], tac, timeout=state["cfg"]["close"]["timeout_s"], gen=goals[i].get("gen"))
566
+ goals[i]["block"] = tac
567
+ if closed:
568
+ proved[_goal_key(goals[i]["goal"])] = tac
569
+ return {"goals": goals, "proved": proved, "proof_tactics": fill_sorries(state["skeleton"], [g["block"] for g in goals]), **_count(state, resp)}
570
+
571
+ def verify(self, state: State) -> dict:
572
+ lean = state["cfg"]["lean"]
573
+ problem = state["problem"]
574
+ if self.checker is not None:
575
+ v = self.checker.check(problem.formal_statement, state["proof_tactics"], problem.header, lean["max_heartbeats"])
576
+ else:
577
+ v = verify(problem.formal_statement, state["proof_tactics"], f"{problem.problem_id}_a{state.get('attempt', 1)}", lean["imports"] + problem.imports, lean["timeout_s"], lean["max_heartbeats"], header=problem.header)
578
+ self.agents.trace("verifier", state["run_id"], problem.problem_id, state.get("attempt", 1), v.seconds * 1000, {"ok": v.ok, "uses_sorry": v.uses_sorry, "n_errors": len(v.errors), "first_error": (v.errors[0]["message"][:300] if v.errors else "")})
579
+ # BUG-2: `lean_code` reportado == lo verificado (en goal_mode el archivo se arma recién aquí)
580
+ code = build_file(problem.formal_statement, state["proof_tactics"], lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
581
+ if v.ok:
582
+ return {"verdict": v, "status": "done", "lean_error": "", "lean_code": code, "solved_by": state.get("solved_by") or "agents"}
583
+ return {"verdict": v, "lean_error": _format_error(v), "lean_code": code}
584
+
585
+ def critique(self, state: State) -> dict:
586
+ cfg = state["cfg"]
587
+ attempt = state.get("attempt", 1)
588
+ history = list(state.get("history", []))
589
+ if cfg["k_max"] <= 1: # ablación "sin Crítico": un solo intento, sin llamada
590
+ history.append({"attempt": attempt, "error_type": "unknown", "summary": state["lean_error"][:200]})
591
+ return {"status": "fail", "fail_reason": "exhausted", "history": history, "error_types": state.get("error_types", []) + ["unknown"]}
592
+ if _over_budget(state):
593
+ return {"status": "fail", "fail_reason": "budget", "history": history}
594
+ try:
595
+ out, resp = self.agents.critique(state["problem"], state["sketch"], state["lean_code"], state["lean_error"], history, state["run_id"], attempt)
596
+ except InvalidLLMOutput as e: # #110: crítica ilegible -> reintento con los errores de Lean como feedback
597
+ out, resp = CriticOutput(error_type="unknown", action="retry", feedback=f"Fix the Lean errors:\n{state['lean_error'][:1500]}"), e
598
+ history.append({"attempt": attempt, "error_type": out.error_type, "action": out.action, "summary": out.feedback[:300]})
599
+ error_types = state.get("error_types", []) + [out.error_type]
600
+ delta = {"history": history, "error_types": error_types, "feedback": out.feedback, "missing_identifiers": out.missing_identifiers, "attempt": attempt + 1, **_count(state, resp)}
601
+ if out.action == "abandon":
602
+ return {**delta, "status": "fail", "fail_reason": "abandon"}
603
+ if attempt + 1 > cfg["k_max"]:
604
+ return {**delta, "status": "fail", "fail_reason": "exhausted"}
605
+ if out.action == "replan":
606
+ if state.get("replans", 0) >= cfg["max_replans"]:
607
+ return {**delta, "status": "fail", "fail_reason": "exhausted"}
608
+ return {**delta, "replans": state.get("replans", 0) + 1, "status": "replan"}
609
+ return {**delta, "status": "retry"}
610
+
611
+ # -- grafo -------------------------------------------------------------------
612
+
613
+ @staticmethod
614
+ def _guard(node):
615
+ """JSON inválido tras el reintento (`InvalidLLMOutput`) = el intento termina en fallo dentro del
616
+ grafo, conservando lo acumulado (llamadas, tokens, evidencia, historial). Antes la excepción
617
+ salía de `invoke` y el reporte perdía todo lo anterior (DECISIONS #61).
618
+
619
+ ACC-1: cualquier OTRA excepción (un bug, el REPL de Lean, la red) se captura igual, a nivel de
620
+ nodo — el estado acumulado hasta ese punto ya está fusionado por LangGraph y no se pierde,
621
+ a diferencia del `except Exception` que tenía `solve()` antes, que reemplazaba todo el estado
622
+ final por `{**init, "fail_reason": ...}` y descartaba llamadas/tokens/evidencia ya generados."""
623
+
624
+ def guarded(state: State) -> dict:
625
+ try:
626
+ return node(state)
627
+ except InvalidLLMOutput as e: # e lleva tokens_in/tokens_out/calls/model, como una respuesta
628
+ return {"status": "fail", "fail_reason": f"llm_output_invalid:{e.agent}", **_count(state, e)} # `report` lo agrega a error_types
629
+ except (DailyQuotaExhausted, ProverUnavailable):
630
+ raise # sin modelos disponibles hoy o sin demostrador: debe salir para que el runner corte la corrida y reanude mañana
631
+ except Exception as e:
632
+ return {"status": "error", "fail_reason": f"exception: {type(e).__name__}: {str(e)[:300]}"}
633
+
634
+ return guarded
635
+
636
+ def _build(self):
637
+ g = StateGraph(State)
638
+ for name in ("prove", "draft", "plan", "retrieve", "generate", "formalize", "close", "fill", "verify", "critique"):
639
+ g.add_node(name, self._guard(getattr(self, name)))
640
+ g.add_edge(START, "prove")
641
+ g.add_conditional_edges("prove", lambda s: "end" if s.get("status") in ("fail", "error") else ("verify" if s.get("solved_by") in ("prover_root", "prover_examples", "prover_repair", "prover_sketch") else ("draft" if (self._prover_cfg(s) or {}).get("dsp") else "plan")), {"verify": "verify", "draft": "draft", "plan": "plan", "end": END})
642
+ g.add_conditional_edges("draft", lambda s: "end" if s.get("status") in ("fail", "error") else ("verify" if s.get("solved_by") == "prover_draft" else "plan"), {"verify": "verify", "plan": "plan", "end": END})
643
+ g.add_conditional_edges("plan", lambda s: "end" if s.get("status") in ("fail", "error") else "retrieve", {"retrieve": "retrieve", "end": END})
644
+ g.add_conditional_edges("retrieve", lambda s: "end" if s.get("status") == "error" else "generate", {"generate": "generate", "end": END})
645
+ g.add_conditional_edges("generate", lambda s: "end" if s.get("status") in ("fail", "error") else "formalize", {"formalize": "formalize", "end": END})
646
+ g.add_conditional_edges("formalize", lambda s: "end" if s.get("status") in ("fail", "error") else ("close" if s.get("skeleton") is not None and s["cfg"].get("goal_mode") and self.checker is not None else "verify"), {"close": "close", "verify": "verify", "end": END})
647
+ # CLOSE: esqueleto que no elabora -> Crítico; todo cerrado -> VERIFY; quedan goals -> FILL
648
+ g.add_conditional_edges("close", lambda s: "end" if s.get("status") == "error" else ("critique" if not s.get("goals") and s.get("lean_error") else ("verify" if all(x.get("block") for x in s.get("goals", [])) else "fill")), {"critique": "critique", "verify": "verify", "fill": "fill", "end": END})
649
+ g.add_conditional_edges("fill", lambda s: "end" if s.get("status") in ("fail", "error") else "verify", {"verify": "verify", "end": END})
650
+ # si la etapa 0 dio una prueba que VERIFY no acepta (no debería: ya pasó `check`), siguen los agentes
651
+ g.add_conditional_edges("verify", lambda s: "end" if s.get("status") in ("done", "error") or not self._api_loop(s) else ("plan" if s.get("solved_by") in ("prover_root", "prover_examples", "prover_repair", "prover_sketch", "prover_draft") and not s.get("history") else "critique"), {"critique": "critique", "plan": "plan", "end": END})
652
+ g.add_conditional_edges(
653
+ "critique",
654
+ lambda s: {"retry": ("retrieve" if s.get("missing_identifiers") else "generate"), "replan": "plan"}.get(s.get("status"), "end"),
655
+ {"retrieve": "retrieve", "generate": "generate", "plan": "plan", "end": END},
656
+ )
657
+ return g.compile()
658
+
659
+ def solve(self, problem: Problem, cfg: dict, run_id: str) -> SolutionReport:
660
+ t0 = time.time()
661
+ if problem.imports: # nadie usa imports adicionales por problema hoy (auditoría BUG-4); error explícito en vez de ignorarlos en silencio
662
+ return self.report(problem, cfg, {"fail_reason": f"unsupported: Problem.imports no soportado: {problem.imports}"}, "fail", 0.0)
663
+ init: State = {"problem": problem, "cfg": cfg, "run_id": run_id, "attempt": 1, "replans": 0, "evidence": {}, "history": [], "error_types": [], "llm_calls": 0, "retrieval_calls": 0, "tokens_in": 0, "tokens_out": 0, "models": [], "status": "running", "lean_error": "", "feedback": "", "missing_identifiers": []}
664
+ try:
665
+ final = self.graph.invoke(init, config={"recursion_limit": 60})
666
+ # ACC-1: "error" (excepción capturada dentro de un nodo, ver `_guard`) es un estado
667
+ # terminal distinto de "fail" (el grafo llegó a FAIL de forma normal); cualquier otro
668
+ # valor (no debería darse aquí) cae a "fail" por seguridad.
669
+ status = final.get("status") if final.get("status") in ("done", "error") else "fail"
670
+ except (DailyQuotaExhausted, ProverUnavailable):
671
+ raise # sin modelos disponibles hoy o sin demostrador: el runner corta la corrida y se reanuda mañana
672
+ except Exception as e: # red de seguridad final: no debería alcanzarse con _guard ya en cada nodo
673
+ final, status = {**init, "fail_reason": f"exception: {type(e).__name__}: {str(e)[:300]}"}, "error"
674
+ return self.report(problem, cfg, final, status, time.time() - t0)
675
+
676
+ @staticmethod
677
+ def report(problem: Problem, cfg: dict, final: dict, status: str, wall: float) -> SolutionReport:
678
+ used_ids = set(final.get("used_citations", []))
679
+ proof = final.get("proof_tactics", "") if status == "done" else ""
680
+ # `used_in_proof`: la constante está en el término que aceptó el kernel (REPL). Nombrarla en el texto no
681
+ # basta: `grind +lax [16 lemas]` nombra 16 y usa uno o dos (smoke 2026-10-14, DECISIONS #101). Sin esa
682
+ # información (backend `lake`), el criterio textual de antes.
683
+ verdict = final.get("verdict")
684
+ used_consts = set(verdict.used_constants) if status == "done" and verdict is not None and verdict.used_constants else None
685
+ citations = []
686
+ for c in final.get("evidence", {}).values():
687
+ c = c.model_copy()
688
+ c.used_in_sketch = c.citation_id in used_ids
689
+ name = c.locator.get("decl_name") or ""
690
+ c.used_in_proof = bool(proof) and c.corpus == "mathlib" and (name in used_consts if used_consts is not None else bool(identifiers_used(proof, [name])))
691
+ citations.append(c)
692
+ return SolutionReport(
693
+ problem_id=problem.problem_id,
694
+ config=cfg["name"],
695
+ status=status,
696
+ attempts=final.get("attempt", 1) if status == "done" else (len(final.get("history", [])) or 1),
697
+ replans=final.get("replans", 0),
698
+ llm_calls=final.get("llm_calls", 0),
699
+ retrieval_calls=final.get("retrieval_calls", 0),
700
+ tokens_in=final.get("tokens_in", 0),
701
+ tokens_out=final.get("tokens_out", 0),
702
+ wall_seconds=round(wall, 1),
703
+ lean_code=final.get("lean_code", ""),
704
+ verdict=final.get("verdict"),
705
+ citations=citations,
706
+ n_retrieved=len(citations),
707
+ n_used_in_sketch=sum(c.used_in_sketch for c in citations),
708
+ n_used_in_proof=sum(c.used_in_proof for c in citations),
709
+ error_types=final.get("error_types", []) + ([final["fail_reason"]] if final.get("fail_reason") else []),
710
+ re_retrievals=final.get("re_retrievals", 0),
711
+ re_retrievals_ok=final.get("re_retrievals_ok", 0),
712
+ models=final.get("models", []),
713
+ solved_by=final.get("solved_by", "") if status == "done" else "",
714
+ prover_samples=final.get("prover_samples", 0),
715
+ )
ragsolver/orchestrator/run.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Corrida de problemas × configuraciones de ablación (propuesta §8.2) -> SolutionReport
2
+ por (problema, config) en data/eval/runs/<run_id>/results.jsonl + resumen.
3
+
4
+ Uso: python -m ragsolver.orchestrator.run --problems data/eval/problems/dev_trivial.jsonl --configs full,no_rag
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ import json
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+
16
+ from ragsolver.agents.agents import Agents, InvalidLLMOutput, load_agents_config
17
+ from ragsolver.agents.schemas import Problem, SolutionReport
18
+ from ragsolver.lean.verify import LeanChecker, build_file, verify
19
+ from ragsolver.llm import LLM, DailyQuotaExhausted
20
+ from ragsolver.logging.jsonl_writer import trace_path
21
+ from ragsolver.orchestrator.graph import Orchestrator, close_goal
22
+ from ragsolver.prover import ProverUnavailable, local_prover
23
+ from ragsolver.rag.embed import embed_queries
24
+ from ragsolver.rag.retrieve import Retriever
25
+
26
+
27
+ def load_problems(path: str | Path) -> list[Problem]:
28
+ return [Problem.model_validate_json(l) for l in Path(path).read_text(encoding="utf-8").splitlines() if l.strip()]
29
+
30
+
31
+ def check_index_consistency(checker: LeanChecker, retriever: Retriever, n: int = 20, seed: int = 13) -> None:
32
+ """`PLAN_RAG.md §12`: `decl_name` al azar de la tabla que sirve el corpus
33
+ `mathlib`, comprobados con `#check` contra el Lean real del verificador — si
34
+ el índice tiene nombres que no existen en esta versión de Mathlib, el RAG
35
+ cita lemas fantasma. No aborta la corrida, sólo avisa en el log; cualquier
36
+ fallo (tabla ausente, REPL caído) se ignora igual."""
37
+ import random
38
+
39
+ try:
40
+ table = retriever._table("mathlib")
41
+ rows = table.search().select(["decl_name"]).limit(table.count_rows()).to_list()
42
+ names = [r["decl_name"] for r in rows if r.get("decl_name")]
43
+ if not names:
44
+ return
45
+ sample = random.Random(seed).sample(names, min(n, len(names)))
46
+ found, total = checker.check_identifiers_exist(sample)
47
+ print(f"[index-check] {found}/{total} decl_name de la tabla '{retriever.tables.get('mathlib', 'mathlib')}' existen en Lean", flush=True)
48
+ if found < total - 1: # tolera 1 falso positivo del `#check` aproximado
49
+ print(f"[index-check] AVISO: {total - found} nombres del índice no se resolvieron en Lean — posibles lemas fantasma", flush=True)
50
+ except Exception as e:
51
+ print(f"[index-check] omitido: {type(e).__name__}: {e}", flush=True)
52
+
53
+
54
+ def effective_config(base: dict, name: str) -> dict:
55
+ """Config efectiva de una ablación: claves del yaml + sobreescrituras de `configs[name]`."""
56
+ over = dict(base["configs"][name])
57
+ cfg = {
58
+ "name": name,
59
+ "mode": over.pop("mode", "graph"),
60
+ "rag": over.pop("rag", True),
61
+ "corpora": ["mathlib", "informal"],
62
+ "k_max": over.pop("k_max", base["k_max"]),
63
+ "max_replans": base["max_replans"],
64
+ "llm_budget": base["llm_budget"],
65
+ "retrieval": dict(base["retrieval"]),
66
+ "lean": dict(base["lean"]),
67
+ "goal_mode": over.pop("goal_mode", base.get("goal_mode", False)),
68
+ "close": dict(base.get("close") or {}),
69
+ "prover": dict(base.get("prover") or {}),
70
+ }
71
+ for k, v in over.items(): # un dict (p.ej. `prover: {k_root: 32}`) se fusiona con el de base, no lo reemplaza
72
+ cfg[k] = {**cfg[k], **v} if isinstance(v, dict) and isinstance(cfg.get(k), dict) else v
73
+ return cfg
74
+
75
+
76
+ def drafter_agents(base: dict, trace_dir: str) -> Agents | None:
77
+ """Agentes con el LLM de `llm_draft` (mismo modelo, CON razonamiento) para el borrador de DRAFT; None si
78
+ la sección no existe en `configs/versions.yaml` (DRAFT usa entonces los agentes normales)."""
79
+ import yaml
80
+
81
+ if "llm_draft" not in (yaml.safe_load(open("configs/versions.yaml", encoding="utf-8")) or {}):
82
+ return None
83
+ return Agents(LLM(section="llm_draft"), base, trace_dir=trace_dir)
84
+
85
+
86
+ def run_auto(problem: Problem, cfg: dict, checker: LeanChecker | None, agents: Agents | None = None, run_id: str = "") -> SolutionReport:
87
+ """Línea base `baseline_auto` (docs/PLAN_MEJORA_RAG.md Fase 6): 0 llamadas LLM, sólo la batería
88
+ CLOSE sin premisas sobre el goal raíz. Es lo que Lean resuelve solo; el sistema con agentes tiene
89
+ que superarla para justificar sus llamadas."""
90
+ t0 = time.time()
91
+ rep = SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", attempts=1, error_types=["unknown"])
92
+ if checker is None:
93
+ rep.error_types = ["unsupported: baseline_auto requiere el REPL de Lean"]
94
+ return rep
95
+ lean = cfg["lean"]
96
+ goals, errors = checker.goals(problem.formal_statement, "sorry", problem.header, lean["max_heartbeats"])
97
+ block, log = (close_goal(checker, goals[0], cfg["close"]["tactics"], False, [], [], cfg["close"]["timeout_s"]) if len(goals) == 1 and not errors else (None, []))
98
+ if block:
99
+ rep.verdict = checker.check(problem.formal_statement, block, problem.header, lean["max_heartbeats"])
100
+ rep.lean_code = build_file(problem.formal_statement, block, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
101
+ if rep.verdict.ok:
102
+ rep.status, rep.error_types = "done", []
103
+ if agents is not None:
104
+ agents.trace("close", run_id, problem.problem_id, 1, (time.time() - t0) * 1000, {"goal": 0, "closed": bool(block), "block": (block or "")[:200], "tried": log})
105
+ rep.wall_seconds = round(time.time() - t0, 1)
106
+ return rep
107
+
108
+
109
+ def run_prover_only(problem: Problem, cfg: dict, checker: LeanChecker | None, prover, agents: Agents | None = None, run_id: str = "") -> SolutionReport:
110
+ """Línea base `baseline_prover` (docs/PLAN_REDISENO_PROVER.md): 0 API, sólo el demostrador local sobre
111
+ el teorema completo (k_root muestras + autocorrección con los errores de Lean, SIN sugerencias del RAG)."""
112
+ import re as _re
113
+
114
+ from ragsolver.prover import prove
115
+
116
+ t0 = time.time()
117
+ rep = SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", attempts=1, error_types=["unknown"])
118
+ if checker is None or prover is None:
119
+ rep.error_types = ["unsupported: baseline_prover requiere el REPL y el demostrador local"]
120
+ return rep
121
+ pcfg, lean = cfg["prover"], cfg["lean"]
122
+ name = _re.match(r"\s*(?:theorem|lemma)\s+(\S+)", problem.formal_statement).group(1)
123
+ tac, log = prove(prover, checker, problem.formal_statement, problem.header, name, k=pcfg["k_root"], correction_rounds=pcfg.get("correction_rounds", 1), retriever=None, max_heartbeats=lean["max_heartbeats"])
124
+ rep.prover_samples = log["samples"] + log["rounds"] * 2
125
+ if tac:
126
+ rep.verdict = checker.check(problem.formal_statement, tac, problem.header, lean["max_heartbeats"])
127
+ rep.lean_code = build_file(problem.formal_statement, tac, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
128
+ if rep.verdict.ok:
129
+ rep.status, rep.error_types, rep.solved_by = "done", [], "prover_root"
130
+ if agents is not None:
131
+ agents.trace("prover", run_id, problem.problem_id, 1, (time.time() - t0) * 1000, {"stage": "baseline_prover", "solved": bool(tac), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}})
132
+ rep.wall_seconds = round(time.time() - t0, 1)
133
+ return rep
134
+
135
+
136
+ def run_baseline(mode: str, problem: Problem, cfg: dict, agents: Agents, run_id: str, checker: LeanChecker | None = None) -> SolutionReport:
137
+ if problem.imports: # mismo guard que Orchestrator.solve (BUG-4): nadie usa imports adicionales por problema hoy
138
+ return SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", error_types=[f"unsupported: Problem.imports no soportado: {problem.imports}"])
139
+ t0 = time.time()
140
+ out, resp = agents.baseline(mode, problem, run_id)
141
+ rep = SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", attempts=1, llm_calls=1, tokens_in=resp.tokens_in, tokens_out=resp.tokens_out, models=[resp.model])
142
+ if mode == "baseline_a":
143
+ lean = cfg["lean"]
144
+ if checker is not None:
145
+ v = checker.check(problem.formal_statement, out.proof_tactics, problem.header, lean["max_heartbeats"])
146
+ else:
147
+ v = verify(problem.formal_statement, out.proof_tactics, f"{problem.problem_id}_{mode}", lean["imports"] + problem.imports, lean["timeout_s"], lean["max_heartbeats"], header=problem.header)
148
+ rep.verdict, rep.status = v, ("done" if v.ok else "fail")
149
+ # BUG-2: mismo `header` que se verificó, para que `lean_code` sea fiel.
150
+ rep.lean_code = build_file(problem.formal_statement, out.proof_tactics, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
151
+ if not v.ok:
152
+ rep.error_types = ["unknown"]
153
+ else: # baseline_b: sin verificación formal; se reporta su autoevaluación (métrica "falsos positivos evitados")
154
+ rep.self_assessed_correct = out.self_assessed_correct
155
+ rep.status = "fail"
156
+ rep.wall_seconds = round(time.time() - t0, 1)
157
+ return rep
158
+
159
+
160
+ def run_suite(problems: list[Problem], config_names: list[str], out_dir: str | Path, agents_yaml: str = "configs/agents.yaml", db_path: str = "data/index/lancedb", run_id: str | None = None, workers: int = 1) -> dict:
161
+ """Corre problemas × configs. **Reanudable**: con el mismo `run_id`, los pares
162
+ (config, problema) ya presentes en results.jsonl se saltan; si se agota la cuota
163
+ diaria de todos los modelos, corta limpio (status "quota_exhausted") para reanudar
164
+ al día siguiente con el mismo comando. `workers` > 1 resuelve varios pares a la vez
165
+ (hilos: el cuello de botella es la latencia del LLM; el REPL y las trazas van con lock)."""
166
+ base = load_agents_config(agents_yaml)
167
+ run_id = run_id or f"run-{time.strftime('%Y%m%d-%H%M')}-{uuid.uuid4().hex[:6]}"
168
+ out = Path(out_dir) / run_id
169
+ out.mkdir(parents=True, exist_ok=True)
170
+ results_path = out / "results.jsonl"
171
+ results: list[SolutionReport] = [SolutionReport.model_validate_json(l) for l in results_path.read_text(encoding="utf-8").splitlines() if l.strip()] if results_path.exists() else []
172
+ errors = [r for r in results if r.status == "error"]
173
+ if errors: # fallo de infraestructura (excepción, red, key): se reintentan; quedan auditados en errors.jsonl
174
+ with (out / "errors.jsonl").open("a", encoding="utf-8") as f:
175
+ f.writelines(r.model_dump_json() + "\n" for r in errors)
176
+ results = [r for r in results if r.status != "error"]
177
+ results_path.write_text("".join(r.model_dump_json() + "\n" for r in results), encoding="utf-8")
178
+ print(f"[resume] {len(errors)} pares con status=error se reintentan (movidos a errors.jsonl)", flush=True)
179
+ done = {(r.config, r.problem_id) for r in results}
180
+ if done:
181
+ print(f"[resume] {len(done)} resultados previos en {results_path}", flush=True)
182
+
183
+ llm = LLM(section="llm")
184
+ agents = Agents(llm, base, trace_dir=str(out / "traces"))
185
+ retriever = Retriever(db_path=db_path)
186
+ retriever.trace_dir = str(out / "traces")
187
+ checker = LeanChecker(imports=base["lean"]["imports"], timeout_s=base["lean"]["timeout_s"]) if base["lean"].get("backend", "repl") == "repl" else None
188
+ prover = local_prover(base.get("prover") or {})
189
+ needs = [n for n in config_names if (c := effective_config(base, n))["mode"] == "baseline_prover" or any(c["prover"].get(x) for x in ("dsp", "repair", "examples"))]
190
+ if needs and prover is None: # sin demostrador estas configuraciones degradarían en silencio (fallos falsos o el bucle por API)
191
+ raise SystemExit(f"[prover] {needs} necesitan el demostrador local y no responde: `bash scripts/prover_server.sh start`")
192
+ drafter = drafter_agents(base, str(out / "traces"))
193
+ orchestrator = Orchestrator(agents, retriever, checker, prover, drafter)
194
+ if checker is not None: # arranca el REPL (`import Mathlib`, minutos) fuera del reloj de los problemas
195
+ checker.check("theorem _warmup : True := by sorry", "trivial")
196
+ if getattr(retriever, "versions", None): # carga el embedder antes de los hilos (los fakes de los tests no lo tienen)
197
+ embed_queries(["warmup"], model_name=retriever.versions["embedding"]["model"])
198
+ if checker is not None and getattr(retriever, "versions", None):
199
+ check_index_consistency(checker, retriever)
200
+
201
+ def solve_pair(name: str, problem: Problem) -> SolutionReport:
202
+ cfg = effective_config(base, name)
203
+ pid = f"{run_id}:{name}:{problem.problem_id}"
204
+ tp = trace_path(pid, out / "traces")
205
+ if tp.exists(): # intento anterior interrumpido (sin fila en results.jsonl): se aparta para no mezclar eventos
206
+ (out / "traces" / "interrupted").mkdir(exist_ok=True)
207
+ tp.rename(out / "traces" / "interrupted" / f"{tp.stem}.{int(time.time())}.jsonl")
208
+ if cfg["mode"] == "baseline_auto":
209
+ return run_auto(problem, cfg, checker, agents, pid)
210
+ if cfg["mode"] == "baseline_prover":
211
+ return run_prover_only(problem, cfg, checker, prover, agents, pid)
212
+ if cfg["mode"].startswith("baseline"):
213
+ try:
214
+ return run_baseline(cfg["mode"], problem, cfg, agents, pid, checker)
215
+ except DailyQuotaExhausted:
216
+ raise
217
+ except Exception as e: # una línea base que explota no puede tumbar la corrida entera
218
+ # ACC-1: si es InvalidLLMOutput, .model existe (el modelo que produjo la salida rota);
219
+ # antes esta fila quedaba con `models=[]`, sin poder saber qué modelo falló.
220
+ models = [e.model] if isinstance(e, InvalidLLMOutput) and getattr(e, "model", None) else []
221
+ return SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail" if isinstance(e, InvalidLLMOutput) else "error", attempts=1, llm_calls=getattr(e, "calls", 0), tokens_in=getattr(e, "tokens_in", 0), tokens_out=getattr(e, "tokens_out", 0), models=models, error_types=[f"llm_output_invalid:{e.agent}" if isinstance(e, InvalidLLMOutput) else f"exception: {type(e).__name__}: {str(e)[:300]}"])
222
+ return orchestrator.solve(problem, cfg, pid)
223
+
224
+ pairs = [(n, p) for n in config_names for p in problems if (n, p.problem_id) not in done]
225
+ status = "completed"
226
+ with results_path.open("a", encoding="utf-8") as f, ThreadPoolExecutor(max_workers=workers) as pool:
227
+ futures = {pool.submit(solve_pair, n, p): (n, p) for n, p in pairs}
228
+ try:
229
+ for fut in as_completed(futures):
230
+ name, problem = futures[fut]
231
+ rep = fut.result()
232
+ results.append(rep)
233
+ f.write(rep.model_dump_json() + "\n")
234
+ f.flush()
235
+ print(f"[{name}] {problem.problem_id}: {rep.status} attempts={rep.attempts} llm_calls={rep.llm_calls} used_in_proof={rep.n_used_in_proof}/{rep.n_retrieved} {rep.wall_seconds}s", flush=True)
236
+ except DailyQuotaExhausted as e:
237
+ status = "quota_exhausted"
238
+ print(f"[stop] cuota diaria agotada en todos los modelos ({e}); reanudar mañana con --run-id {run_id}", flush=True)
239
+ for fu in futures:
240
+ fu.cancel()
241
+ except ProverUnavailable as e:
242
+ status = "prover_down"
243
+ print(f"[stop] el demostrador local dejó de responder ({e}); `bash scripts/prover_server.sh start` y reanudar con --run-id {run_id}", flush=True)
244
+ for fu in futures:
245
+ fu.cancel()
246
+ if checker is not None:
247
+ checker.close()
248
+ llms = [llm] + ([drafter.llm] if drafter is not None else [])
249
+ calls, cost = sum(x.calls for x in llms), sum(getattr(x, "cost_usd", 0.0) for x in llms)
250
+ print(f"[cost] {calls} llamadas, {sum(x.tokens_in for x in llms)} tokens de entrada, {sum(x.tokens_out for x in llms)} de salida, US${cost:.4f} (OpenRouter usage.cost)", flush=True)
251
+ summary = summarize(results)
252
+ (out / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
253
+ pending = [(n, p.problem_id) for n in config_names for p in problems if (n, p.problem_id) not in {(r.config, r.problem_id) for r in results}]
254
+ return {"run_id": run_id, "out": str(out), "status": status, "n_done": len(results), "n_pending": len(pending), "summary": summary, "llm_calls_total": calls, "cost_usd_total": round(cost, 4), "models_exhausted": llm.exhausted}
255
+
256
+
257
+ def summarize(results: list[SolutionReport]) -> dict:
258
+ by_cfg: dict[str, list[SolutionReport]] = {}
259
+ for r in results:
260
+ by_cfg.setdefault(r.config, []).append(r)
261
+ summary = {}
262
+ for name, rs in by_cfg.items():
263
+ n = len(rs)
264
+ solved = sum(r.status == "done" for r in rs)
265
+ retrieved = sum(r.n_retrieved for r in rs)
266
+ summary[name] = {
267
+ "n": n,
268
+ "pass@1": round(solved / n, 3) if n else None,
269
+ "pass@1_wilson95": wilson(solved, n),
270
+ "llm_calls_mean": round(sum(r.llm_calls for r in rs) / n, 2) if n else None,
271
+ "tokens_mean": round(sum(r.tokens_in + r.tokens_out for r in rs) / n) if n else None,
272
+ "wall_s_median": sorted(r.wall_seconds for r in rs)[n // 2] if n else None,
273
+ "retrieval_calls_mean": round(sum(r.retrieval_calls for r in rs) / n, 2) if n else None,
274
+ "llm_calls_mean_ci95_bootstrap": bootstrap_mean_ci([r.llm_calls for r in rs]),
275
+ "tokens_mean_ci95_bootstrap": bootstrap_mean_ci([r.tokens_in + r.tokens_out for r in rs]),
276
+ "wall_s_mean_ci95_bootstrap": bootstrap_mean_ci([r.wall_seconds for r in rs]),
277
+ "pass_by_attempt": pass_by_attempt(rs), # §8.3 robustez del refinamiento: acumulado por intento
278
+ "lemma_use_precision": round(sum(r.n_used_in_proof for r in rs) / retrieved, 3) if retrieved else None,
279
+ "re_retrieval_success": round(sum(r.re_retrievals_ok for r in rs) / sum(r.re_retrievals for r in rs), 3) if sum(r.re_retrievals for r in rs) else None,
280
+ "error_taxonomy": _count_errors(rs),
281
+ "self_assessed_correct": sum(1 for r in rs if r.self_assessed_correct) if any(r.self_assessed_correct is not None for r in rs) else None,
282
+ }
283
+ return summary
284
+
285
+
286
+ def _count_errors(rs: list[SolutionReport]) -> dict:
287
+ counts: dict[str, int] = {}
288
+ for r in rs:
289
+ for e in r.error_types:
290
+ counts[e] = counts.get(e, 0) + 1
291
+ return counts
292
+
293
+
294
+ def pass_by_attempt(rs: list[SolutionReport]) -> dict[str, float]:
295
+ """Fracción resuelta con ≤ i intentos (la ganancia marginal es la diferencia entre columnas)."""
296
+ n = len(rs)
297
+ k_max = max((r.attempts for r in rs), default=1)
298
+ return {str(i): round(sum(1 for r in rs if r.status == "done" and r.attempts <= i) / n, 3) for i in range(1, k_max + 1)} if n else {}
299
+
300
+
301
+ def bootstrap_mean_ci(xs: list[float], n_boot: int = 1000, seed: int = 13) -> list[float] | None:
302
+ """IC 95 % bootstrap de la media (propuesta §8.4: costo y latencia)."""
303
+ import random
304
+
305
+ if not xs:
306
+ return None
307
+ rng = random.Random(seed)
308
+ means = sorted(sum(rng.choice(xs) for _ in xs) / len(xs) for _ in range(n_boot))
309
+ return [round(means[int(0.025 * n_boot)], 2), round(means[int(0.975 * n_boot) - 1], 2)]
310
+
311
+
312
+ def wilson(k: int, n: int, z: float = 1.96) -> list[float] | None:
313
+ if not n:
314
+ return None
315
+ p = k / n
316
+ d = 1 + z * z / n
317
+ c = (p + z * z / (2 * n)) / d
318
+ h = z * ((p * (1 - p) / n + z * z / (4 * n * n)) ** 0.5) / d
319
+ return [round(max(0.0, c - h), 3), round(min(1.0, c + h), 3)]
320
+
321
+
322
+ if __name__ == "__main__":
323
+ ap = argparse.ArgumentParser()
324
+ ap.add_argument("--problems", required=True)
325
+ ap.add_argument("--configs", default="full")
326
+ ap.add_argument("--out", default="data/eval/runs")
327
+ ap.add_argument("--run-id", default=None)
328
+ ap.add_argument("--workers", type=int, default=1)
329
+ args = ap.parse_args()
330
+ res = run_suite(load_problems(args.problems), args.configs.split(","), args.out, run_id=args.run_id, workers=args.workers)
331
+ print(json.dumps(res, indent=2, ensure_ascii=False))
ragsolver/prover.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Demostrador especializado en Lean servido en la GPU local (docs/PLAN_REDISENO_PROVER.md).
2
+
3
+ Goedel-Prover-V2-8B (Apache-2.0; 84.6 % pass@32 en miniF2F según su paper) vía `llama-server`
4
+ (OpenAI-compatible, Vulkan). Genera k pruebas en paralelo; de cada una se toma SÓLO el bloque de
5
+ tácticas del teorema pedido y se empalma en el enunciado del problema (el modelo nunca puede debilitar
6
+ el enunciado); cada candidato pasa por `LeanChecker.check` (validador + kernel + axiomas).
7
+
8
+ Autocorrección: el modelo se entrenó con Lean 4.9 y su Mathlib; el verificador usa v4.28, así que usa
9
+ nombres renombrados o borrados. Ante `unknown identifier/constant`, el RAG busca declaraciones parecidas
10
+ QUE EXISTEN en el entorno (índice + `#check`) y se las da al modelo junto con los errores de Lean.
11
+ Costo de API: 0 (todo local).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import textwrap
21
+ import threading
22
+ import time
23
+ from concurrent.futures import ThreadPoolExecutor
24
+ from pathlib import Path
25
+
26
+ import httpx
27
+
28
+ PROMPT = """Complete the following Lean 4 code:
29
+
30
+ ```lean4
31
+ {}```
32
+
33
+ Before producing the Lean 4 code to formally prove the given theorem, provide a detailed proof plan outlining the main proof steps and strategies.
34
+ The plan should highlight key ideas, intermediate lemmas, and proof structures that will guide the construction of the final formal proof."""
35
+
36
+ _FENCE_RE = re.compile(r"```(?:lean4|lean)?[ \t]*\n(.*?)```", re.S)
37
+ _UNKNOWN_RE = re.compile(r"[Uu]nknown (?:identifier|constant) [`']([^`']+)[`']")
38
+
39
+
40
+ def prover_url() -> str:
41
+ """`RAGSOLVER_PROVER_URL` o el `llama-server` de Windows en el gateway de WSL, puerto 8082."""
42
+ if os.environ.get("RAGSOLVER_PROVER_URL"):
43
+ return os.environ["RAGSOLVER_PROVER_URL"].rstrip("/")
44
+ gw = "127.0.0.1"
45
+ try:
46
+ for line in open("/proc/net/route"):
47
+ f = line.split()
48
+ if f[1] == "00000000":
49
+ gw = ".".join(str(int(f[2][i : i + 2], 16)) for i in (6, 4, 2, 0))
50
+ break
51
+ except OSError:
52
+ pass
53
+ return f"http://{gw}:8082"
54
+
55
+
56
+ def lean_input(formal_statement: str, header: str) -> str:
57
+ """El archivo que espera el modelo: imports, opens y el teorema con `sorry`."""
58
+ stmt = re.sub(r":=\s*by\s+sorry\s*$|:=\s*sorry\s*$", "", formal_statement.rstrip()).rstrip()
59
+ opens = "\n".join(l for l in header.splitlines() if l.strip() and not l.strip().startswith("import"))
60
+ return f"import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 400000\n\n{opens}\n\n{stmt} := by\n sorry"
61
+
62
+
63
+ def extract_tactics(output: str, theorem_name: str) -> str | None:
64
+ """Tácticas del teorema `theorem_name` en el último bloque de código de la salida, o None.
65
+ Se descarta todo lo demás (enunciado, lemas auxiliares, comandos): el enunciado lo pone el problema."""
66
+ for block in reversed(_FENCE_RE.findall(output)):
67
+ m = re.search(rf"(?:theorem|lemma)\s+{re.escape(theorem_name)}\b.*?:=\s*by[ \t]*\n(.*)", block, re.S)
68
+ if not m:
69
+ continue
70
+ lines = m.group(1).splitlines()
71
+ body = []
72
+ for line in lines: # el cuerpo termina en la primera línea sin sangría (otra declaración, comando)
73
+ if line.strip() and not line[0].isspace():
74
+ break
75
+ body.append(line)
76
+ tactics = "\n".join(body).strip("\n")
77
+ if tactics.strip():
78
+ from textwrap import dedent
79
+
80
+ return dedent(tactics)
81
+ return None
82
+
83
+
84
+ def unknown_names(errors: list[dict]) -> list[str]:
85
+ return list(dict.fromkeys(n for e in errors for n in _UNKNOWN_RE.findall(e.get("message", ""))))
86
+
87
+
88
+ class ProverUnavailable(RuntimeError):
89
+ """El servidor del demostrador dejó de responder: el runner corta la corrida (como con la cuota diaria)."""
90
+
91
+
92
+ def local_prover(pcfg: dict) -> LocalProver | None:
93
+ """El demostrador si `prover.enabled` y el servidor responde; si no, None (el sistema sigue sin él)."""
94
+ if not pcfg.get("enabled"):
95
+ return None
96
+ p = LocalProver(max_tokens=pcfg.get("max_tokens", 12000), temperature=pcfg.get("temperature", 0.8))
97
+ if p.alive():
98
+ print(f"[prover] demostrador local en {p.url}", flush=True)
99
+ return p
100
+ print(f"[prover] AVISO: {p.url} no responde; se corre sin demostrador local", flush=True)
101
+ return None
102
+
103
+
104
+ class LocalProver:
105
+ def __init__(self, url: str | None = None, max_tokens: int = 12000, temperature: float = 0.8, timeout_s: float = 1800, cache_path: str | None = "data/eval/prover_cache.jsonl"):
106
+ self.url = url or prover_url()
107
+ self.max_tokens, self.temperature, self.timeout_s = max_tokens, temperature, timeout_s
108
+ self.client = httpx.Client(timeout=timeout_s)
109
+ self.tokens_out = 0
110
+ self.calls = 0
111
+ # caché en disco por (mensajes, semilla, parámetros): dos configuraciones que piden la misma muestra
112
+ # (p.ej. `baseline_prover` y la etapa 0 de `full`) la generan una sola vez, y quedan pareadas
113
+ self.cache_path = Path(cache_path) if cache_path else None
114
+ self._cache: dict[str, str] = {}
115
+ self._lock = threading.Lock()
116
+ if self.cache_path and self.cache_path.exists():
117
+ for line in self.cache_path.open(encoding="utf-8"):
118
+ if line.strip():
119
+ d = json.loads(line)
120
+ self._cache[d["key"]] = d["out"]
121
+
122
+ def alive(self) -> bool:
123
+ try:
124
+ return self.client.get(f"{self.url}/health", timeout=5).status_code == 200
125
+ except httpx.HTTPError:
126
+ return False
127
+
128
+ def _chat(self, messages: list[dict], seed: int) -> str:
129
+ key = hashlib.sha256(json.dumps([messages, seed, self.max_tokens, self.temperature], ensure_ascii=False).encode()).hexdigest()
130
+ if key in self._cache:
131
+ return self._cache[key]
132
+ r = self.client.post(f"{self.url}/v1/chat/completions", json={"messages": messages, "max_tokens": self.max_tokens, "temperature": self.temperature, "top_p": 0.95, "seed": seed})
133
+ r.raise_for_status()
134
+ d = r.json()
135
+ self.calls += 1
136
+ self.tokens_out += (d.get("usage") or {}).get("completion_tokens", 0)
137
+ msg = d["choices"][0]["message"]
138
+ # el razonamiento (si el servidor lo separa) va ANTES: la extracción toma el último bloque de código
139
+ out = ((msg.get("reasoning_content") or "") + "\n" + (msg.get("content") or "")).strip()
140
+ with self._lock:
141
+ self._cache[key] = out
142
+ if self.cache_path:
143
+ with self.cache_path.open("a", encoding="utf-8") as f:
144
+ f.write(json.dumps({"key": key, "out": out}, ensure_ascii=False) + "\n")
145
+ return out
146
+
147
+ def sample(self, messages: list[dict], k: int, seed0: int = 13) -> list[str]:
148
+ with ThreadPoolExecutor(max_workers=k) as pool:
149
+ outs = list(pool.map(lambda i: self._safe_chat(messages, seed0 + i), range(k)))
150
+ # una muestra que falla se tolera; todas fallando con el servidor caído no son "muestras sin prueba":
151
+ # la corrida se detiene y se reanuda (eval-v4: el servidor murió y 22 problemas quedaron como fallos falsos)
152
+ if outs and all(o.startswith("(prover error") for o in outs) and not self.alive():
153
+ raise ProverUnavailable(f"{self.url} no responde")
154
+ return outs
155
+
156
+ def _safe_chat(self, messages: list[dict], seed: int) -> str:
157
+ try:
158
+ return self._chat(messages, seed)
159
+ except httpx.HTTPError as e:
160
+ return f"(prover error: {type(e).__name__})"
161
+
162
+
163
+ def name_suggestions(names: list[str], checker, retriever=None, header: str = "", k: int = 4) -> tuple[str, dict[str, str]]:
164
+ """RAG para la autocorrección: por cada nombre desconocido, declaraciones parecidas que SÍ existen en
165
+ el entorno de Lean del verificador (búsqueda en el índice por los tokens del nombre + `#check`)."""
166
+ lines, suggested = [], {}
167
+ for n in names[:4]:
168
+ cands: list[str] = []
169
+ if retriever is not None:
170
+ from ragsolver.rag.schema import RetrievalRequest
171
+
172
+ try:
173
+ res = retriever.search(RetrievalRequest(queries=[f"unknown identifier '{n}'"], mode="error", corpora=["mathlib"], k=k * 2, n_candidates=50, rerank=False, include_figures=False, context={"problem_id": "", "agent": "prover"}))
174
+ cands = [h.metadata.get("decl_name") for h in res.hits if h.metadata.get("decl_name")]
175
+ except Exception:
176
+ cands = []
177
+ sigs = checker.signatures(cands[: k * 2], header=header) if cands else {}
178
+ suggested.update(list(sigs.items())[:k])
179
+ if sigs:
180
+ lines.append(f"`{n}` does not exist in this Mathlib version. Existing declarations with similar names:\n" + "\n".join(f" {c} : {t[:200]}" for c, t in list(sigs.items())[:k]))
181
+ else:
182
+ lines.append(f"`{n}` does not exist in this Mathlib version.")
183
+ return "\n".join(lines), suggested
184
+
185
+
186
+ def draft_hint(strategy: str, steps: list[str], sketch: str, lemmas: dict[str, str], max_sketch: int = 4000) -> str:
187
+ """Draft-Sketch-Prove: el plan y la prueba informal del modelo de la API, más los lemas que recuperó el
188
+ RAG (que existen en el Mathlib fijado), como guía dentro del prompt del demostrador."""
189
+ parts = ["Here is an informal proof written by another model. It may contain mistakes: use it as guidance, not as ground truth.", f"Strategy: {strategy}"]
190
+ parts += [f"{i}. {s}" for i, s in enumerate(steps, 1)]
191
+ parts.append(f"Informal proof:\n{sketch[:max_sketch]}")
192
+ if lemmas:
193
+ parts.append("Mathlib lemmas that may be useful (they exist in the installed Mathlib version):\n" + "\n".join(f" {n} : {t[:200]}" for n, t in lemmas.items()))
194
+ return "\n\n".join(parts)
195
+
196
+
197
+ def examples_hint(hits: list[dict], max_proof: int = 1500) -> str:
198
+ """RAG de pruebas (`rag/proofs.py`): pruebas verificadas de problemas parecidos como ejemplos en el prompt."""
199
+ blocks = [f"```lean4\n{h['statement']} := by\n{textwrap.indent(h['proof'][:max_proof], ' ')}\n```" for h in hits]
200
+ return "Verified Lean 4 proofs of similar problems (older Mathlib: some names may have changed). Reuse their techniques and hints where they fit:\n\n" + "\n\n".join(blocks)
201
+
202
+
203
+ def prove(prover: LocalProver, checker, formal_statement: str, header: str, theorem_name: str, k: int = 4, correction_rounds: int = 1, retriever=None, max_heartbeats: int = 400000, batch: int = 4, hint: str = "") -> tuple[str | None, dict]:
204
+ """k muestras en lotes de `batch` en paralelo, con salida temprana: la primera que pasa `check` gana
205
+ (los problemas fáciles no pagan las k). Si ninguna, una ronda de autocorrección sobre las (hasta) 2
206
+ muestras con tácticas extraíbles: errores de Lean + sugerencias de nombres (RAG, si `retriever`).
207
+ `hint` (Draft-Sketch-Prove, `draft_hint`) se agrega al prompt; el prompt cambia, así que sus muestras
208
+ no comparten caché con las de la etapa 0."""
209
+ t0 = time.time()
210
+ user = PROMPT.format(lean_input(formal_statement, header)) + (f"\n\n{hint}" if hint else "")
211
+ log = {"samples": 0, "extracted": 0, "rounds": 0, "corrections_with_rag": 0, "suggested": {}}
212
+ pending, failed = [], [] # failed: todo candidato extraíble que Lean rechazó, para REPAIR (graph.py)
213
+ for start in range(0, k, batch):
214
+ outs = prover.sample([{"role": "user", "content": user}], min(batch, k - start), seed0=13 + start)
215
+ log["samples"] += len(outs) # generadas (trabajo de GPU), no sólo las revisadas antes del éxito
216
+ for out in outs:
217
+ tac = extract_tactics(out, theorem_name)
218
+ if not tac:
219
+ continue
220
+ log["extracted"] += 1
221
+ v = checker.check(formal_statement, tac, header, max_heartbeats)
222
+ if v.ok:
223
+ log["seconds"] = round(time.time() - t0, 1)
224
+ return tac, {**log, "solved_in": "sample"}
225
+ pending.append((out, tac, v))
226
+ failed.append((tac, v))
227
+ for rnd in range(correction_rounds):
228
+ if not pending:
229
+ break
230
+ log["rounds"] += 1
231
+ nxt = []
232
+ for out, tac, v in pending[:2]:
233
+ err = "\n".join(f"line {e['line']}: {e['message'][:400]}" for e in v.errors[:6])
234
+ names = unknown_names(v.errors)
235
+ hint, sugg = name_suggestions(names, checker, retriever, header) if names else ("", {})
236
+ log["suggested"].update(sugg)
237
+ log["corrections_with_rag"] += bool(sugg)
238
+ fix = f"The proof failed to compile in Lean 4 (recent Mathlib). Errors:\n{err}\n" + (f"\n{hint}\n" if hint else "") + "\nFix the proof and output the complete corrected Lean 4 code."
239
+ answer = out.rsplit("</think>", 1)[-1][-6000:] # la respuesta final, sin el razonamiento
240
+ outs2 = prover.sample([{"role": "user", "content": user}, {"role": "assistant", "content": answer}, {"role": "user", "content": fix}], 1, seed0=100 + rnd)
241
+ tac2 = extract_tactics(outs2[0], theorem_name)
242
+ if not tac2:
243
+ continue
244
+ v2 = checker.check(formal_statement, tac2, header, max_heartbeats)
245
+ if v2.ok:
246
+ log["seconds"] = round(time.time() - t0, 1)
247
+ return tac2, {**log, "solved_in": f"correction{rnd + 1}"}
248
+ nxt.append((outs2[0], tac2, v2))
249
+ failed.append((tac2, v2))
250
+ pending = nxt
251
+ log["seconds"] = round(time.time() - t0, 1)
252
+ # casi-pruebas para REPAIR, menos errores primero: Lean sigue elaborando tras un `have` que falla, así que
253
+ # ≤ 2 errores ≈ ≤ 2 pasos malos; con más, reparar paso a paso cuesta horas de GPU (smoke 2026-10-21)
254
+ log["candidates"] = [t for t, v in sorted(failed, key=lambda x: len(x[1].errors)) if len(v.errors) <= 2]
255
+ return None, log
256
+
257
+
258
+ _DAGGER_RE = re.compile(r"([A-Za-z_][\w']*)✝(\d*)")
259
+
260
+
261
+ def goal_to_theorem(goal: str, name: str) -> str | None:
262
+ """Un goal pretty-printed del REPL ("a b : ℝ\\nh : 0 < a\\n⊢ T") como teorema autónomo, para pedírselo
263
+ al demostrador (receta de DeepSeek-Prover-V2: cada subobjetivo como lema). Los nombres inaccesibles
264
+ (`x✝`) se renombran; las instancias (`inst✝ : Fact p`) pasan a `[Fact p]`. None si no se puede."""
265
+ lines = [l for l in goal.splitlines() if l.strip() and not l.startswith("case ")]
266
+ if not lines or "⊢" not in goal:
267
+ return None
268
+ hyps, target = [], None
269
+ for i, l in enumerate(lines):
270
+ if l.startswith("⊢"):
271
+ target = " ".join([l[1:].strip()] + [x.strip() for x in lines[i + 1 :]])
272
+ break
273
+ if l[0].isspace() and hyps: # continuación de la hipótesis anterior
274
+ hyps[-1] += " " + l.strip()
275
+ else:
276
+ hyps.append(l.strip())
277
+ if target is None:
278
+ return None
279
+ binders = []
280
+ for h in hyps:
281
+ if " : " not in h:
282
+ return None
283
+ names, typ = h.split(" : ", 1)
284
+ if all(n.startswith("inst✝") for n in names.split()):
285
+ binders.append(f"[{typ}]")
286
+ else:
287
+ binders.append(f"({names} : {typ})")
288
+ stmt = f"theorem {name} " + " ".join(binders) + f" : {target} := by sorry"
289
+ return _DAGGER_RE.sub(lambda m: f"{m.group(1)}_{m.group(2) or 0}", stmt)
290
+
291
+
292
+ def renamed_inaccessible(goal: str) -> list[str]:
293
+ """Nombres que `goal_to_theorem` dio a las hipótesis inaccesibles (no instancias), en orden: un
294
+ bloque probado para el lema autónomo funciona en el contexto original con `rename_i <estos>` delante."""
295
+ names = []
296
+ for l in goal.split("⊢", 1)[0].splitlines():
297
+ if " : " in l and not l[0].isspace():
298
+ for n in l.split(" : ", 1)[0].split():
299
+ m = _DAGGER_RE.fullmatch(n)
300
+ if m and not n.startswith("inst✝"):
301
+ names.append(f"{m.group(1)}_{m.group(2) or 0}")
302
+ return names
ragsolver/rag/__init__.py ADDED
File without changes
ragsolver/rag/embed.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wrapper de embeddings (Qwen3-Embedding) usado por ambos corpus.
2
+
3
+ docs/PLAN_RAG.md §9.1: un solo modelo para Mathlib e informal. Documentos se
4
+ embeben sin instrucción; las consultas llevan instrucción (recomendación del
5
+ model card de Qwen3-Embedding — mejora retrieval sin tocar el índice).
6
+
7
+ Backends (`RAGSOLVER_EMBED_BACKEND`):
8
+ - `torch` (default): sentence-transformers en CPU (bf16), ~5 textos/s.
9
+ - `llama`: `llama-server` (llama.cpp, Vulkan) corriendo del lado de Windows con el
10
+ GGUF f16 del mismo modelo — usa la RX 9060 XT esquivando el bug de ROCm en WSL2
11
+ (docs/DECISIONS.md #48): ~48 textos/s, vectores idénticos (cos ≥ 0.9998).
12
+ URL en `RAGSOLVER_EMBED_URL` (default: gateway de WSL, puerto 8081).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import threading
19
+ from functools import lru_cache
20
+
21
+ # Un solo hilo carga y usa el modelo de torch: cargas concurrentes (lru_cache no lo impide) duplican
22
+ # la RAM y dejan el modelo a medio convertir a bf16 ("expected m1 and m2 to have the same dtype")
23
+ _TORCH_LOCK = threading.Lock()
24
+
25
+ QUERY_INSTRUCTION = "Instruct: Given a mathematical query, retrieve relevant Mathlib declarations or worked problems.\nQuery: "
26
+
27
+
28
+ @lru_cache(maxsize=1)
29
+ def _get_model(model_name: str):
30
+ # Import perezoso: torch/sentence-transformers son pesados y no hacen falta
31
+ # para el resto del módulo (ingesta, esquema, índice sin embeddings).
32
+ import torch
33
+ from sentence_transformers import SentenceTransformer
34
+
35
+ device = "cuda" if torch.cuda.is_available() else "cpu"
36
+ # ponytail: sin flash_attention_2 (CUDA-only, no compila en ROCm —
37
+ # docs/PLAN_RAG.md §9.4); se usa la implementación de atención por defecto.
38
+ # bfloat16 en CPU: mitad de RAM (1.2 GB vs 2.4 GB para 0.6B); la máquina tiene 7.5 GB
39
+ # y el pipeline de Fase 4 convive con el REPL de Lean (docs/DECISIONS.md #40).
40
+ dtype = torch.bfloat16 if os.environ.get("RAGSOLVER_EMBED_BF16", "1") == "1" else torch.float32
41
+ return SentenceTransformer(model_name, device=device, model_kwargs={"torch_dtype": dtype})
42
+
43
+
44
+ def _backend() -> str:
45
+ return os.environ.get("RAGSOLVER_EMBED_BACKEND", "torch")
46
+
47
+
48
+ def _llama_url() -> str:
49
+ url = os.environ.get("RAGSOLVER_EMBED_URL")
50
+ if url:
51
+ return url
52
+ import subprocess
53
+
54
+ gw = subprocess.run(["sh", "-c", "ip route show default | awk '{print $3}'"], capture_output=True, text=True).stdout.strip() or "127.0.0.1"
55
+ return f"http://{gw}:8081/v1/embeddings"
56
+
57
+
58
+ def _llama_embed(texts: list[str], batch_size: int = 64, workers: int = 8) -> list[list[float]]:
59
+ """Lotes concurrentes contra llama-server (8 slots). Textos truncados a 6000 caracteres
60
+ (ventana por slot de 2048 tokens); el servidor normaliza L2."""
61
+ from concurrent.futures import ThreadPoolExecutor
62
+
63
+ import httpx
64
+
65
+ url = _llama_url()
66
+ client = httpx.Client(timeout=900)
67
+ batches = [[t[:6000] for t in texts[i : i + batch_size]] for i in range(0, len(texts), batch_size)]
68
+
69
+ def one(batch):
70
+ for attempt in range(5):
71
+ try:
72
+ r = client.post(url, json={"input": batch})
73
+ r.raise_for_status()
74
+ data = sorted(r.json()["data"], key=lambda d: d["index"])
75
+ return [d["embedding"] for d in data]
76
+ except Exception:
77
+ if attempt == 4:
78
+ raise
79
+ return []
80
+
81
+ with ThreadPoolExecutor(workers) as ex:
82
+ out = []
83
+ for vecs in ex.map(one, batches):
84
+ out.extend(vecs)
85
+ return out
86
+
87
+
88
+ def embed_documents(texts: list[str], model_name: str, batch_size: int = 32) -> list[list[float]]:
89
+ """Embebe texto de documento (sin instrucción). Usado en la ingesta (offline)."""
90
+ if _backend() == "llama":
91
+ return _llama_embed(texts)
92
+ with _TORCH_LOCK:
93
+ model = _get_model(model_name)
94
+ vectors = model.encode(texts, batch_size=batch_size, show_progress_bar=False, normalize_embeddings=True)
95
+ return vectors.tolist()
96
+
97
+
98
+ def embed_queries(texts: list[str], model_name: str, batch_size: int = 8, instruction: str | None = None) -> list[list[float]]:
99
+ """Embebe texto de consulta (con instrucción). Usado por el retriever (online).
100
+
101
+ `instruction`: texto tras "Instruct:" (configs/retrieval.yaml `query_instructions`);
102
+ por defecto la instrucción con la que se midió la Fase 1.
103
+ """
104
+ prefix = f"Instruct: {instruction}\nQuery: " if instruction else QUERY_INSTRUCTION
105
+ with _QUERY_CACHE_LOCK:
106
+ found = {t: _QUERY_CACHE.get((prefix, t, model_name)) for t in texts}
107
+ missing = [t for t, v in found.items() if v is None]
108
+ if missing:
109
+ if _backend() == "llama":
110
+ vectors_list = _llama_embed([prefix + t for t in missing])
111
+ else:
112
+ with _TORCH_LOCK:
113
+ model = _get_model(model_name)
114
+ vectors_list = model.encode([prefix + t for t in missing], batch_size=batch_size, show_progress_bar=False, normalize_embeddings=True).tolist()
115
+ with _QUERY_CACHE_LOCK: # BUG-6: dict mutado desde varios hilos (--workers) sin lock podía romper la evicción a mitad de iteración
116
+ for t, v in zip(missing, vectors_list):
117
+ found[t] = v
118
+ _QUERY_CACHE[(prefix, t, model_name)] = v
119
+ while len(_QUERY_CACHE) > _QUERY_CACHE_MAX: # ponytail: LRU aproximado (FIFO)
120
+ _QUERY_CACHE.pop(next(iter(_QUERY_CACHE)), None)
121
+ return [found[t] for t in texts]
122
+
123
+
124
+ # Caché de embeddings de consulta (docs/PLAN_RAG.md Fase 3, tarea 1): el orquestador
125
+ # repite consultas entre intentos (mismo subobjetivo, re-recuperación tras error).
126
+ _QUERY_CACHE: dict[tuple[str, str, str], list[float]] = {}
127
+ _QUERY_CACHE_MAX = 512
128
+ _QUERY_CACHE_LOCK = threading.Lock()
ragsolver/rag/embed_corpus.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embebe un corpus normalizado (data/corpus/*.jsonl) in-place: agrega
2
+ `embedding`, `embedding_model`, `embedding_dim`, `index_version` a cada línea.
3
+
4
+ Genérico para ambos corpus (mathlib/informal) — sólo depende de que cada línea
5
+ tenga un campo `text` (docs/PLAN_RAG.md §9.1: un solo modelo para ambos).
6
+
7
+ Uso: python -m ragsolver.rag.embed_corpus --in data/corpus/mathlib.jsonl --out data/corpus/mathlib_embedded.jsonl
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ from ragsolver.rag.embed import embed_documents
20
+
21
+
22
+ def run(in_path: str, out_path: str, versions_path: str = "configs/versions.yaml", batch_size: int = 32, chunk: int = 2048) -> dict:
23
+ """Streaming: lee/embebe/escribe por bloques de `chunk` filas (278k filas × 1024 floats no caben en RAM)."""
24
+ with open(versions_path, encoding="utf-8") as f:
25
+ versions = yaml.safe_load(f)
26
+ model_name = versions["embedding"]["model"]
27
+ index_version = versions["index"]["version"]
28
+
29
+ out = Path(out_path)
30
+ out.parent.mkdir(parents=True, exist_ok=True)
31
+ n, dim, t0 = 0, 0, time.time()
32
+ with open(in_path, encoding="utf-8") as fin, out.open("w", encoding="utf-8") as fout:
33
+ rows: list[dict] = []
34
+
35
+ def flush():
36
+ nonlocal n, dim
37
+ if not rows:
38
+ return
39
+ vectors = embed_documents([r["text"] for r in rows], model_name=model_name, batch_size=batch_size)
40
+ for row, vec in zip(rows, vectors):
41
+ row["embedding"] = vec
42
+ row["embedding_model"] = model_name
43
+ row["embedding_dim"] = len(vec)
44
+ row["index_version"] = index_version
45
+ fout.write(json.dumps(row, ensure_ascii=False) + "\n")
46
+ n += len(rows)
47
+ dim = len(vectors[0]) if vectors else dim
48
+ rows.clear()
49
+ print(f" {n} textos, {n / (time.time() - t0):.1f}/s", flush=True)
50
+
51
+ for line in fin:
52
+ if line.strip():
53
+ rows.append(json.loads(line))
54
+ if len(rows) >= chunk:
55
+ flush()
56
+ flush()
57
+ dt = time.time() - t0
58
+ return {"n": n, "seconds": round(dt, 1), "texts_per_sec": round(n / dt, 2) if dt > 0 else None, "model": model_name, "dim": dim, "backend": __import__("os").environ.get("RAGSOLVER_EMBED_BACKEND", "torch"), "out": str(out)}
59
+
60
+
61
+ if __name__ == "__main__":
62
+ ap = argparse.ArgumentParser()
63
+ ap.add_argument("--in", dest="in_path", required=True)
64
+ ap.add_argument("--out", dest="out_path", required=True)
65
+ ap.add_argument("--versions", default="configs/versions.yaml")
66
+ ap.add_argument("--batch-size", type=int, default=32)
67
+ ap.add_argument("--backend", default=None, choices=["torch", "llama"], help="por defecto RAGSOLVER_EMBED_BACKEND o torch")
68
+ args = ap.parse_args()
69
+ if args.backend:
70
+ import os
71
+
72
+ os.environ["RAGSOLVER_EMBED_BACKEND"] = args.backend
73
+ stats = run(args.in_path, args.out_path, args.versions, args.batch_size)
74
+ print(json.dumps(stats, indent=2))
ragsolver/rag/enrich.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 2, tarea 6 — enriquecimiento offline con el VLM/LLM (docs/PLAN_RAG.md §6.2).
2
+
3
+ Proveedor: el que diga configs/versions.yaml `vlm_enrichment` (ragsolver.llm.LLM:
4
+ Gemini u OpenAI-compatible). Todas las llamadas son offline (indexación); el
5
+ retriever sigue en 0 llamadas (RNF-3).
6
+
7
+ Operaciones (prompts versionados en configs/ingest.yaml `enrich:`):
8
+ - transcribe_region: Plan B acotado — transcribe una región que MinerU se saltó.
9
+ - describe_figure (6a): recorte + enunciado -> descripción estructurada.
10
+ - qa_formulas (6b): imagen de página + LaTeX extraído -> LaTeX corregido.
11
+ - summarize_card (6c): problem card -> context_summary, math_area, techniques, ...
12
+ - split_solution: casos ambiguos del emparejamiento (numeral perdido por el OCR).
13
+
14
+ Caché JSONL por operación (data/parsed/<doc_id>/enrich/<op>.jsonl) con clave =
15
+ sha256(prompt_version + entradas): re-ejecutar la ingesta no repite llamadas.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import re
23
+ import threading
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from difflib import SequenceMatcher
26
+ from pathlib import Path
27
+
28
+ import yaml
29
+
30
+ from ragsolver.llm import LLM, DailyQuotaExhausted # noqa: F401 (re-export para tests/llamadores)
31
+
32
+ _FIGURE_SCHEMA = {
33
+ "type": "object",
34
+ "properties": {
35
+ "description": {"type": "string"},
36
+ "labels": {"type": "array", "items": {"type": "string"}},
37
+ "objects": {"type": "array", "items": {"type": "string"}},
38
+ "relations": {"type": "array", "items": {"type": "string"}},
39
+ "given_values": {"type": "array", "items": {"type": "string"}},
40
+ "figure_type": {"type": "string", "enum": ["geometric_diagram", "grid", "plot", "table_image", "other"]},
41
+ },
42
+ "required": ["description", "labels", "objects", "relations", "given_values", "figure_type"],
43
+ }
44
+ _FORMULAS_SCHEMA = {
45
+ "type": "object",
46
+ "properties": {
47
+ "items": {
48
+ "type": "array",
49
+ "items": {
50
+ "type": "object",
51
+ "properties": {"i": {"type": "integer"}, "latex": {"type": "string"}, "changed": {"type": "boolean"}},
52
+ "required": ["i", "latex", "changed"],
53
+ },
54
+ }
55
+ },
56
+ "required": ["items"],
57
+ }
58
+ _CARD_SCHEMA = {
59
+ "type": "object",
60
+ "properties": {
61
+ "context_summary": {"type": "string"},
62
+ "math_area": {"type": "array", "items": {"type": "string"}},
63
+ "techniques": {"type": "array", "items": {"type": "string"}},
64
+ "theorems_used": {"type": "array", "items": {"type": "string"}},
65
+ "difficulty": {"type": "integer"},
66
+ "keywords": {"type": "array", "items": {"type": "string"}},
67
+ },
68
+ "required": ["context_summary", "math_area", "techniques", "theorems_used", "difficulty", "keywords"],
69
+ }
70
+ _SPLIT_SCHEMA = {"type": "object", "properties": {"first_line": {"type": "string"}}, "required": ["first_line"]}
71
+
72
+
73
+ class Enricher:
74
+ def __init__(self, cache_dir: str | Path, ingest_yaml: str = "configs/ingest.yaml", versions_yaml: str = "configs/versions.yaml", llm: LLM | None = None):
75
+ cfg = yaml.safe_load(open(ingest_yaml, encoding="utf-8"))["enrich"]
76
+ self.llm = llm or LLM(versions_yaml, section="vlm_enrichment")
77
+ self.prompts = cfg
78
+ self.version = str(cfg["prompt_version"])
79
+ self.cache_dir = Path(cache_dir)
80
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
81
+ self._cache: dict[str, dict] = {}
82
+ self._lock = threading.Lock()
83
+
84
+ @property
85
+ def models(self) -> list[str]:
86
+ return self.llm.models
87
+
88
+ @property
89
+ def model(self) -> str:
90
+ return self.llm.model
91
+
92
+ @property
93
+ def exhausted(self) -> list[str]:
94
+ return self.llm.exhausted
95
+
96
+ @property
97
+ def calls(self) -> int:
98
+ return self.llm.calls
99
+
100
+ # -- caché ---------------------------------------------------------------
101
+
102
+ def _cache_file(self, op: str) -> Path:
103
+ return self.cache_dir / f"{op}.jsonl"
104
+
105
+ def _load(self, op: str) -> dict:
106
+ if op not in self._cache:
107
+ self._cache[op] = {}
108
+ f = self._cache_file(op)
109
+ if f.exists():
110
+ for line in f.read_text(encoding="utf-8").splitlines():
111
+ if line.strip():
112
+ rec = json.loads(line)
113
+ self._cache[op][rec["key"]] = (rec["result"], rec.get("model", self.llm.models[0]))
114
+ return self._cache[op]
115
+
116
+ def _key(self, op: str, *parts: str) -> str:
117
+ # El prompt de la operación entra en la clave: editar un prompt invalida sólo su caché.
118
+ # El modelo NO entra: un resultado ya calculado por cualquier modelo de la cadena se reutiliza.
119
+ h = hashlib.sha256(("\x00".join([self.version, op, self.prompts.get(op, ""), *parts])).encode("utf-8"))
120
+ return h.hexdigest()[:24]
121
+
122
+ def _cached(self, op: str, key: str, compute) -> tuple:
123
+ """Devuelve (resultado, modelo que lo produjo)."""
124
+ with self._lock:
125
+ cache = self._load(op)
126
+ if key in cache:
127
+ return cache[key]
128
+ result, model = compute() # fuera del lock: las llamadas HTTP corren en paralelo
129
+ with self._lock:
130
+ cache[key] = (result, model)
131
+ with self._cache_file(op).open("a", encoding="utf-8") as f:
132
+ f.write(json.dumps({"key": key, "model": model, "result": result}, ensure_ascii=False) + "\n")
133
+ return result, model
134
+
135
+ def map(self, fn, items, workers: int = 3) -> list:
136
+ """Aplica `fn` a `items` en paralelo (pocos hilos: la cuota es por minuto)."""
137
+ with ThreadPoolExecutor(max_workers=workers) as ex:
138
+ return list(ex.map(fn, items))
139
+
140
+ # -- llamadas (delegadas a ragsolver.llm.LLM) --------------------------------
141
+
142
+ def _generate(self, parts: list[dict], schema: dict | None) -> tuple[str, str]:
143
+ """(texto, modelo). `parts`: [{"text": ...}] y/o [{"image": path}]."""
144
+ prompt = "\n".join(p["text"] for p in parts if "text" in p)
145
+ images = [p["image"] for p in parts if "image" in p]
146
+ r = self.llm.generate(prompt, schema=schema, images=images)
147
+ return r.text, r.model
148
+
149
+ def _json(self, parts: list[dict], schema: dict) -> tuple[dict, str]:
150
+ text, model = self._generate(parts, schema)
151
+ return json.loads(text), model
152
+
153
+ # -- operaciones -----------------------------------------------------------
154
+
155
+ def transcribe_region(self, png_path: str | Path) -> str:
156
+ img = _image_part(png_path)
157
+ key = self._key("transcribe", _sha(png_path))
158
+ text, _ = self._cached("transcribe", key, lambda: (lambda t, m: (t.strip(), m))(*self._generate([{"text": self.prompts["transcribe"]}, img], None)))
159
+ return text
160
+
161
+ def describe_figure(self, png_path: str | Path, statement: str, solution: str = "") -> dict:
162
+ img = _image_part(png_path)
163
+ prompt = f"{self.prompts['figure']}\n\nProblem statement (Spanish):\n{statement}\n\nSolution excerpt (Spanish):\n{solution[:2500]}"
164
+ key = self._key("figure", _sha(png_path), statement, solution[:2500])
165
+ d, model = self._cached("figure", key, lambda: self._json([{"text": prompt}, img], _FIGURE_SCHEMA))
166
+ return {**d, "model": model}
167
+
168
+ def qa_formulas(self, page_png: str | Path, latex_list: list[str], inline: list[bool] | None = None) -> list[dict]:
169
+ """Devuelve [{i, latex, changed}] alineado con `latex_list`; `inline[i]` marca fórmulas en línea."""
170
+ if not latex_list:
171
+ return []
172
+ inline = inline or [False] * len(latex_list)
173
+ listing = "\n".join(f"[{i}] {s}" for i, s in enumerate(latex_list))
174
+ prompt = f"{self.prompts['formulas']}\n\n{listing}"
175
+ key = self._key("formulas", _sha(page_png), listing)
176
+ res, _ = self._cached("formulas", key, lambda: self._json([{"text": prompt}, _image_part(page_png)], _FORMULAS_SCHEMA))
177
+ out = [{"i": i, "latex": s, "changed": False} for i, s in enumerate(latex_list)]
178
+ for it in res.get("items", []):
179
+ i = it.get("i")
180
+ if not (isinstance(i, int) and 0 <= i < len(out) and it.get("changed")):
181
+ continue
182
+ if _alnum(it["latex"]) == _alnum(latex_list[i]):
183
+ continue # cambio sólo de puntuación/espaciado: ruido, se ignora
184
+ if _acceptable_fix(latex_list[i], it["latex"], inline[i]):
185
+ out[i] = {"i": i, "latex": it["latex"], "changed": True}
186
+ else:
187
+ out[i]["rejected"] = it["latex"]
188
+ return out
189
+
190
+ def summarize_card(self, content_md: str, figure_descriptions: list[str]) -> dict:
191
+ prompt = self.prompts["card"].format(math_areas=", ".join(self.prompts["math_areas"]), techniques=", ".join(self.prompts["techniques"]))
192
+ figs = "\n".join(f"- {d}" for d in figure_descriptions) or "(none)"
193
+ body = f"{prompt}\n\n## Problem and solution (Spanish, Markdown+LaTeX)\n{content_md}\n\n## Figure descriptions\n{figs}"
194
+ key = self._key("card", content_md, figs)
195
+ res, model = self._cached("card", key, lambda: self._json([{"text": body}], _CARD_SCHEMA))
196
+ res = {**res, "model": model}
197
+ res["math_area"] = [a for a in res.get("math_area", []) if a in self.prompts["math_areas"]] or ["other"]
198
+ res["difficulty"] = min(5, max(1, int(res.get("difficulty", 3))))
199
+ return res
200
+
201
+ def split_solution(self, n: int, merged_text: str, statement_prev: str, statement_n: str) -> str:
202
+ prompt = self.prompts["split"].format(prev=n - 1, n=n)
203
+ body = f"{prompt}\n\n## Problem {n-1}\n{statement_prev}\n\n## Problem {n}\n{statement_n}\n\n## Text\n{merged_text}"
204
+ key = self._key("split", str(n), merged_text)
205
+ res, _ = self._cached("split", key, lambda: self._json([{"text": body}], _SPLIT_SCHEMA))
206
+ return res.get("first_line", "").strip()
207
+
208
+
209
+ def _alnum(latex: str) -> str:
210
+ return re.sub(r"[^0-9A-Za-z^_{}]", "", latex)
211
+
212
+
213
+ _CONTENT_RE = re.compile(r"[0-9+\-=<>/*]|\\(?:frac|sqrt|sum|prod|int|binom|cdot|times|le|ge|leq|geq|neq|equiv|in|pm|infty|angle|underbrace|overbrace)")
214
+
215
+
216
+ def _garbage(chunk: str) -> bool:
217
+ """Trozo de LaTeX que se puede borrar sin perder matemática: envoltorios
218
+ (\\mathrm{...}, \\hat{...}) o texto absorbido por el OCR ("¿Cuál", "y"), sin
219
+ dígitos ni operadores."""
220
+ if _CONTENT_RE.search(chunk):
221
+ return False
222
+ letters = re.sub(r"[^A-Za-z]", "", re.sub(r"\\[A-Za-z]+", "", chunk))
223
+ return len(letters) <= 6
224
+
225
+
226
+ def _canon(latex: str) -> str:
227
+ """Normaliza diferencias cosméticas antes de comparar (espacios, puntos suspensivos, espaciado LaTeX)."""
228
+ s = re.sub(r"\\[lc]?dots", "...", latex)
229
+ s = re.sub(r"\\[,;!:]|\\q?quad|~", "", s)
230
+ return re.sub(r"\s+", "", s)
231
+
232
+
233
+ def _acceptable_fix(old_latex: str, new_latex: str, inline: bool) -> bool:
234
+ """Medido sobre el PDF de ejemplo (docs/DECISIONS.md #19): el VLM propone ruido de
235
+ puntuación, absorbe texto vecino en fórmulas inline, recorta contenido real cuando
236
+ el OCR fusionó texto+matemática, y a veces devuelve LaTeX destrozado (`\\underset`
237
+ -> `\\raw` con caracteres de control; `\\mathbb{R}` -> `ℒ`). Reglas:
238
+ (a) caracteres de control o no-ASCII nuevos -> rechazo;
239
+ (b) borrado puro -> aceptar sólo si cada trozo borrado es basura/envoltorio;
240
+ (c) otro cambio -> similitud >= 0.8 (arreglo local) y, en inline, no alargar."""
241
+ if re.search(r"[\x00-\x1f]", new_latex) or (set(new_latex) - set(old_latex)) & {c for c in new_latex if ord(c) > 127}:
242
+ return False
243
+ old, new = _canon(old_latex), _canon(new_latex)
244
+ if new == old or _alnum(new) == _alnum(old):
245
+ return False
246
+ ops = SequenceMatcher(None, old, new, autojunk=False).get_opcodes()
247
+ inserted = "".join(new[j1:j2] for tag, _, _, j1, j2 in ops if tag in ("insert", "replace"))
248
+ removed = [old[i1:i2] for tag, i1, i2, _, _ in ops if tag in ("delete", "replace")]
249
+ if not inserted:
250
+ return all(_garbage(chunk) for chunk in removed)
251
+ if inline and len(_alnum(new)) > len(_alnum(old)):
252
+ return False
253
+ return SequenceMatcher(None, old, new, autojunk=False).ratio() >= 0.8
254
+
255
+
256
+ def _sha(path: str | Path) -> str:
257
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]
258
+
259
+
260
+ def _image_part(path: str | Path) -> dict:
261
+ return {"image": str(path)}
ragsolver/rag/evaluate.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 1, tarea 6 / Fase 3 aceptación — evaluación de retrieval sobre MathlibQR
2
+ (docs/PLAN_RAG.md §10, §11).
3
+
4
+ Compara 4 variantes (denso, BM25, híbrido, híbrido+rerank) con Recall@k/nDCG@10/MRR
5
+ vía `rag/metrics.py` (no `ranx`: dos corridas largas del CLI devolvieron valores
6
+ absurdos, docs/DECISIONS.md #43; `metrics.py` se verifica igual a `ranx` en
7
+ `tests/test_metrics.py`), usando `full_name` de cada fila de MathlibQR como
8
+ documento gold. Las cuatro variantes pasan por `Retriever.search` (la misma
9
+ función que usan los agentes) — sólo cambian `channels` y `rerank`, no hay una
10
+ segunda implementación de la búsqueda.
11
+
12
+ IMPORTANTE: la cobertura (`n_gold_covered_in_table`) depende de la tabla que se
13
+ evalúe. Con la muestra corregida (docs/DECISIONS.md #31-32, `mathlib_sample.jsonl`)
14
+ la cobertura es 83.8 % (793/946); con el corpus completo (`mathlib_full`) el
15
+ recall real baja a ~0.41-0.45 (#56, #68) — el número de la muestra sobreestima
16
+ por el tamaño acotado del pool de candidatos, no sólo por cobertura.
17
+
18
+ Uso: python -m ragsolver.rag.evaluate --qr data/eval/MathlibQR.json [--limit 20]
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import random
26
+ import tempfile
27
+
28
+ from ragsolver.rag.metrics import evaluate_run
29
+ from ragsolver.rag.retrieve import Retriever
30
+ from ragsolver.rag.schema import RetrievalRequest
31
+
32
+ QUERY_FIELDS = ["q1a_lean", "q1b_latex", "q1c_natural", "q2_slogan", "q3_nickname", "q4_special_case"]
33
+
34
+ VARIANTS = { # nombre -> (canales, rerank)
35
+ "denso": (("dense",), False),
36
+ "bm25": (("sparse",), False),
37
+ "hibrido": (("dense", "sparse"), False),
38
+ "hibrido_rerank": (("dense", "sparse"), True),
39
+ }
40
+
41
+
42
+ def _build_queries(qr_path: str, limit: int | None = None, sample: int | None = None, seed: int = 13) -> list[dict]:
43
+ rows = json.load(open(qr_path, encoding="utf-8"))
44
+ queries = []
45
+ for row in rows:
46
+ gold_id = f"mathlib:{row['full_name']}:0"
47
+ for field in QUERY_FIELDS:
48
+ text = row.get(field, "")
49
+ if text:
50
+ queries.append({"query_id": f"{row['id']}:{field}", "text": text, "gold_id": gold_id})
51
+ if sample: # subconjunto aleatorio reproducible (el rerank en CPU no alcanza para las 946)
52
+ queries = random.Random(seed).sample(queries, min(sample, len(queries)))
53
+ return queries[:limit] if limit else queries
54
+
55
+
56
+ def evaluate_variants(retriever: Retriever, qr_path: str, table_name: str = "mathlib", n_candidates: int = 50, k: int = 10, limit: int | None = None, sample: int | None = None, variants: list[str] | None = None) -> dict:
57
+ queries = _build_queries(qr_path, limit, sample)
58
+ chosen = {name: VARIANTS[name] for name in (variants or VARIANTS)}
59
+ retriever.tables["mathlib"] = table_name # la tabla evaluada sirve el corpus "mathlib"
60
+ table = retriever._table("mathlib")
61
+ all_ids = {r["chunk_id"] for r in table.search().select(["chunk_id"]).limit(table.count_rows()).to_list()}
62
+ n_covered = sum(1 for q in queries if q["gold_id"] in all_ids)
63
+ qrels = {q["query_id"]: {q["gold_id"]: 1} for q in queries}
64
+
65
+ retriever.trace_dir = tempfile.mkdtemp(prefix="ragsolver-eval-") # no ensuciar data/eval/traces
66
+ runs: dict[str, dict] = {name: {} for name in chosen}
67
+ for q in queries:
68
+ for name, (channels, rerank) in chosen.items():
69
+ retriever.channels = channels
70
+ req = RetrievalRequest(queries=[q["text"]], corpora=["mathlib"], k=n_candidates, n_candidates=n_candidates, rerank=rerank, include_figures=False, context={"problem_id": "eval", "agent": "evaluate"})
71
+ hits = retriever.search(req).hits
72
+ runs[name][q["query_id"]] = {h.chunk_id: (h.citation.scores.rerank if rerank else h.citation.scores.fused) for h in hits}
73
+ retriever.channels = ("dense", "sparse")
74
+
75
+ return {
76
+ "n_queries": len(queries),
77
+ "n_gold_covered_in_table": n_covered,
78
+ "coverage_ratio": round(n_covered / len(queries), 3) if queries else 0.0,
79
+ **{name: evaluate_run(qrels, run, ks=(k,)) for name, run in runs.items()},
80
+ "_runs": runs, # hits crudos por consulta: permiten recomputar las métricas sin repetir búsquedas
81
+ }
82
+
83
+
84
+ if __name__ == "__main__":
85
+ ap = argparse.ArgumentParser()
86
+ ap.add_argument("--db-path", default="data/index/lancedb")
87
+ ap.add_argument("--table", default="mathlib")
88
+ ap.add_argument("--qr", default="data/eval/MathlibQR.json")
89
+ ap.add_argument("--k", type=int, default=10)
90
+ ap.add_argument("--limit", type=int, default=None, help="evaluar sólo las primeras N consultas")
91
+ ap.add_argument("--sample", type=int, default=None, help="muestra aleatoria de N consultas (semilla fija)")
92
+ ap.add_argument("--variants", default=None, help="p.ej. denso,bm25,hibrido (por defecto las 4)")
93
+ ap.add_argument("--out", default=None, help="JSON con métricas y hits crudos (_runs)")
94
+ args = ap.parse_args()
95
+ out = evaluate_variants(Retriever(db_path=args.db_path), args.qr, args.table, k=args.k, limit=args.limit, sample=args.sample, variants=args.variants.split(",") if args.variants else None)
96
+ if args.out:
97
+ json.dump(out, open(args.out, "w", encoding="utf-8"), default=float)
98
+ print(json.dumps({k: v for k, v in out.items() if k != "_runs"}, indent=2, default=float))
ragsolver/rag/figures.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 2, tareas 3-4 — recortes de figuras a partir de los bloques `image` de
2
+ MinerU, unidos a la red de seguridad de clusters vectoriales (docs/PLAN_RAG.md §6.2).
3
+
4
+ Un recorte por bloque imagen, con padding para que entren las etiquetas de los
5
+ puntos (A, B, O...) que el PDF deja como texto suelto alrededor del dibujo.
6
+ `figure_id = {doc_id}:fig:{page}:{block}` coincide con el locator markdown
7
+ `.../page:{page}/block:{block}` que ya usa parse_structure, así el enlace
8
+ figura <-> problem card no necesita nada más.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from pathlib import Path
15
+
16
+ import pymupdf
17
+
18
+ from ragsolver.rag.ingest_pdf import detect_vector_figures
19
+ from ragsolver.rag.mineru_blocks import _item_bboxes
20
+ from ragsolver.rag.schema import Figure
21
+
22
+
23
+ def _covered(rect: pymupdf.Rect, boxes: list[pymupdf.Rect]) -> float:
24
+ """Fracción de `rect` cubierta por la unión (aproximada por suma) de `boxes`."""
25
+ if rect.is_empty or rect.get_area() == 0:
26
+ return 0.0
27
+ area = sum((rect & b).get_area() for b in boxes if not (rect & b).is_empty)
28
+ return min(1.0, area / rect.get_area())
29
+
30
+
31
+ def _crop(page: pymupdf.Page, rect: pymupdf.Rect, out: Path, dpi: int) -> str:
32
+ out.parent.mkdir(parents=True, exist_ok=True)
33
+ page.get_pixmap(dpi=dpi, clip=rect & page.rect).save(out)
34
+ return hashlib.sha256(out.read_bytes()).hexdigest()[:16]
35
+
36
+
37
+ def crop_figures(
38
+ pdf_path: str | Path,
39
+ pages: dict[int, list[dict]],
40
+ out_dir: str | Path,
41
+ doc_id: str,
42
+ pad_pt: float = 10.0,
43
+ dpi: int = 200,
44
+ min_overlap: float = 0.6,
45
+ ) -> tuple[list[Figure], list[str]]:
46
+ """Devuelve (figuras, avisos). Avisos = clusters vectoriales que ningún bloque de
47
+ MinerU cubre (>= min_overlap): se recortan igual con detected_by=vector_cluster."""
48
+ doc = pymupdf.open(pdf_path)
49
+ out_dir = Path(out_dir)
50
+ vectors: dict[int, list[pymupdf.Rect]] = {}
51
+ for v in detect_vector_figures(pdf_path):
52
+ vectors.setdefault(v.page, []).append(pymupdf.Rect(*v.bbox))
53
+
54
+ figures: list[Figure] = []
55
+ warnings: list[str] = []
56
+ for pg in sorted(pages):
57
+ page = doc[pg - 1]
58
+ W, H = page.rect.width, page.rect.height
59
+ to_pt = lambda bb: pymupdf.Rect(bb[0] * W, bb[1] * H, bb[2] * W, bb[3] * H)
60
+ all_boxes = [to_pt(bb) for b in pages[pg] for bb in _item_bboxes(b)]
61
+ image_boxes = []
62
+ for b in pages[pg]:
63
+ if b["type"] not in ("image", "chart"):
64
+ continue
65
+ caption = " ".join(
66
+ it_c.get("content", "")
67
+ for it in b["content"] if it["type"] in ("image_caption", "image_footnote")
68
+ for it_c in it["content"]
69
+ ).strip()
70
+ for it in b["content"]:
71
+ if it["type"] not in ("image_body", "chart_body"):
72
+ continue
73
+ rect = to_pt(it["bbox"])
74
+ image_boxes.append(rect)
75
+ padded = rect + (-pad_pt, -pad_pt, pad_pt, pad_pt)
76
+ block = it["index"] + 1
77
+ png = out_dir / f"p{pg:03d}_b{block}.png"
78
+ sha = _crop(page, padded, png, dpi)
79
+ has_vector = any(_covered(v, [rect]) >= min_overlap for v in vectors.get(pg, []))
80
+ figures.append(
81
+ Figure(
82
+ figure_id=f"{doc_id}:fig:{pg}:{block}",
83
+ image_path=str(png),
84
+ image_sha=sha,
85
+ page=pg,
86
+ bbox=[round(x, 4) for x in it["bbox"]],
87
+ detected_by="both" if has_vector else "layout",
88
+ caption=caption,
89
+ )
90
+ )
91
+ # Red de seguridad: dibujo vectorial que MinerU no cubrió con ningún bloque
92
+ # (ni imagen, ni ecuación/texto — los radicales del Equation Editor caen en
93
+ # bloques equation/text y no cuentan como figura perdida).
94
+ for k, v in enumerate(vectors.get(pg, []), start=1):
95
+ if _covered(v, all_boxes) >= min_overlap:
96
+ continue
97
+ png = out_dir / f"p{pg:03d}_v{k}.png"
98
+ sha = _crop(page, v, png, dpi)
99
+ figures.append(
100
+ Figure(
101
+ figure_id=f"{doc_id}:fig:{pg}:v{k}",
102
+ image_path=str(png),
103
+ image_sha=sha,
104
+ page=pg,
105
+ bbox=[round(v.x0 / W, 4), round(v.y0 / H, 4), round(v.x1 / W, 4), round(v.y1 / H, 4)],
106
+ detected_by="vector_cluster",
107
+ )
108
+ )
109
+ warnings.append(f"página {pg}: cluster vectorial {tuple(round(x) for x in v)} sin bloque de MinerU -> {png.name}")
110
+ return figures, warnings
ragsolver/rag/index.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Índice LanceDB: construcción de tabla + búsqueda densa/FTS + fusión RRF.
2
+
3
+ docs/PLAN_RAG.md §6.3 y §9.2. Una tabla por corpus (`mathlib`, `informal`).
4
+ No hay clase "Store" genérica: son funciones sobre `lancedb.Table`, que ya es
5
+ la abstracción — ponytail rung 2 (reusar lo que ya existe).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import lancedb
14
+ from lancedb.index import FTS
15
+
16
+ MATHLIB_FTS_COLUMNS = ["decl_name", "informal_name", "informal_description", "signature"]
17
+ INFORMAL_FTS_COLUMNS = ["text", "keywords", "theorems_used"]
18
+
19
+ _FTS_COLUMN = "_fts_text" # columna derivada: lancedb >=0.25 sólo indexa FTS de a un campo
20
+
21
+
22
+ def load_jsonl(path: str | Path) -> list[dict]:
23
+ rows = []
24
+ with open(path, encoding="utf-8") as f:
25
+ for line in f:
26
+ if line.strip():
27
+ rows.append(json.loads(line))
28
+ return rows
29
+
30
+
31
+ def _write(db, table, table_name: str, batch: list[dict]):
32
+ if table is None:
33
+ return db.create_table(table_name, data=batch)
34
+ table.add(batch) # devuelve un AddResult (truthy) en lancedb >= 0.25: no encadenar con `or`
35
+ return table
36
+
37
+
38
+ def build_table(db_path: str, table_name: str, rows, fts_columns: list[str] | None = None, chunk: int = 5000) -> lancedb.table.Table:
39
+ """`fts_columns=None` crea una tabla de metadatos sin índice FTS (p.ej. `figures`).
40
+ `rows` puede ser un iterador (JSONL grande): se escribe por bloques de `chunk` filas."""
41
+ db = lancedb.connect(db_path)
42
+ if table_name in db.list_tables().tables:
43
+ db.drop_table(table_name)
44
+
45
+ def prep(row):
46
+ if fts_columns:
47
+ # lancedb >=0.25 sólo indexa FTS de un campo por índice; concatenamos las
48
+ # columnas buscables en una columna derivada en vez de mantener N índices.
49
+ return {**row, _FTS_COLUMN: " ".join(str(row.get(c, "")) for c in fts_columns)}
50
+ return row
51
+
52
+ table = None
53
+ batch: list[dict] = []
54
+ for row in rows:
55
+ batch.append(prep(row))
56
+ if len(batch) >= chunk:
57
+ table = _write(db, table, table_name, batch)
58
+ batch = []
59
+ if batch or table is None:
60
+ table = _write(db, table, table_name, batch)
61
+ if fts_columns:
62
+ table.create_index(_FTS_COLUMN, config=FTS())
63
+ return table
64
+
65
+
66
+ def iter_jsonl(path: str | Path):
67
+ with open(path, encoding="utf-8") as f:
68
+ for line in f:
69
+ if line.strip():
70
+ yield json.loads(line)
71
+
72
+
73
+ def open_table(db_path: str, table_name: str) -> lancedb.table.Table:
74
+ db = lancedb.connect(db_path)
75
+ return db.open_table(table_name)
76
+
77
+
78
+ # Índice ANN (sólo tablas grandes, p.ej. `mathlib_full`): IVF con cuantización escalar de 8 bits y
79
+ # re-ranking exacto de los `refine_factor × k` mejores → casi exacto con ~1 % de las lecturas. En la
80
+ # búsqueda plana (sin índice) estos parámetros no tienen efecto. Ver DECISIONS #60.
81
+ ANN_NPROBES = 32
82
+ ANN_REFINE = 5
83
+
84
+
85
+ def build_ann_index(table: lancedb.table.Table, num_partitions: int = 256) -> None:
86
+ table.create_index(metric="l2", vector_column_name="embedding", index_type="IVF_SQ", num_partitions=num_partitions, replace=True)
87
+
88
+
89
+ def vector_search(table: lancedb.table.Table, query_vector: list[float], k: int, where: str | None = None) -> list[dict]:
90
+ q = table.search(query_vector, vector_column_name="embedding").nprobes(ANN_NPROBES).refine_factor(ANN_REFINE)
91
+ if where:
92
+ q = q.where(where, prefilter=True)
93
+ return q.limit(k).to_list()
94
+
95
+
96
+ def fts_search(table: lancedb.table.Table, query_text: str, k: int, where: str | None = None) -> list[dict]:
97
+ q = table.search(query_text, query_type="fts", fts_columns=_FTS_COLUMN)
98
+ if where:
99
+ q = q.where(where, prefilter=True)
100
+ return q.limit(k).to_list()
101
+
102
+
103
+ def sql_quote(value: str) -> str:
104
+ return "'" + str(value).replace("'", "''") + "'"
105
+
106
+
107
+ def exact_match(table: lancedb.table.Table, column: str, values: list[str], where: str | None = None) -> list[dict]:
108
+ """Filas cuyo `column` es exactamente uno de `values` (modo `error`: identificador Lean literal)."""
109
+ if not values:
110
+ return []
111
+ expr = f"{column} IN ({', '.join(sql_quote(v) for v in values)})"
112
+ if where:
113
+ expr = f"({expr}) AND ({where})"
114
+ return table.search().where(expr).limit(len(values)).to_list()
115
+
116
+
117
+ def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
118
+ """Reciprocal Rank Fusion estándar (docs/PLAN_RAG.md §6.3).
119
+
120
+ `ranked_lists`: una lista de listas de ids, ya ordenadas por relevancia
121
+ (rank 0 = mejor). Devuelve ids fusionados ordenados por score RRF desc.
122
+ """
123
+ scores: dict[str, float] = {}
124
+ for ranked in ranked_lists:
125
+ for rank, doc_id in enumerate(ranked):
126
+ scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
127
+ return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ import argparse
132
+
133
+ ap = argparse.ArgumentParser(description="Construye (o reemplaza) una tabla LanceDB desde un JSONL embebido.")
134
+ ap.add_argument("--in", dest="in_path", default=None, help="p.ej. data/corpus/informal_embedded.jsonl (no hace falta con --ann-only)")
135
+ ap.add_argument("--table", required=True, choices=["mathlib", "mathlib_full", "informal", "figures"])
136
+ ap.add_argument("--db-path", default="data/index/lancedb")
137
+ ap.add_argument("--ann-only", action="store_true", help="no reconstruye: sólo crea el índice ANN (IVF_SQ) sobre la tabla existente")
138
+ args = ap.parse_args()
139
+ if args.ann_only:
140
+ table = lancedb.connect(args.db_path).open_table(args.table)
141
+ build_ann_index(table)
142
+ print(json.dumps({"table": args.table, "rows": table.count_rows(), "indices": [str(i.index_type) for i in table.list_indices()]}))
143
+ raise SystemExit
144
+ if not args.in_path:
145
+ ap.error("--in es obligatorio salvo con --ann-only")
146
+ fts = {"mathlib": MATHLIB_FTS_COLUMNS, "mathlib_full": MATHLIB_FTS_COLUMNS, "informal": INFORMAL_FTS_COLUMNS, "figures": None}[args.table]
147
+ table = build_table(args.db_path, args.table, iter_jsonl(args.in_path), fts_columns=fts)
148
+ print(json.dumps({"table": args.table, "rows": table.count_rows(), "db_path": args.db_path}))
ragsolver/rag/ingest_informal.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 2 — Corpus B de punta a punta (docs/PLAN_RAG.md §6.2, tareas 3-9):
2
+
3
+ MinerU (JSON de bloques)
4
+ -> huecos del layout transcritos por VLM (Plan B acotado)
5
+ -> QA de fórmulas por página (6b)
6
+ -> Markdown regenerado -> problem cards (parse_structure)
7
+ -> soluciones con numeral perdido: split por LLM
8
+ -> recortes de figuras + descripciones (6a)
9
+ -> resumen contextual por card (6c)
10
+ -> chunks `problem_card` + `figure` -> data/corpus/informal.jsonl, figures.jsonl
11
+
12
+ Idempotente: MinerU no se re-ejecuta (lee su salida ya guardada) y toda llamada
13
+ al VLM/LLM queda cacheada en data/parsed/<doc_id>/enrich/. `--skip-llm` produce
14
+ el corpus sin enriquecimiento (context_summary vacío), útil para tests.
15
+
16
+ Uso: python -m ragsolver.rag.ingest_informal --pdf "problemas resueltos olimpiadas.pdf"
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import hashlib
23
+ import json
24
+ from pathlib import Path
25
+
26
+ import pymupdf
27
+ import yaml
28
+
29
+ from ragsolver.rag.enrich import Enricher
30
+ from ragsolver.rag.figures import crop_figures
31
+ from ragsolver.rag.ingest_pdf import Inventory, inventory
32
+ from ragsolver.rag.mineru_blocks import (
33
+ extract_item_numbers,
34
+ find_uncovered_regions,
35
+ image_locators,
36
+ insert_block,
37
+ load_pages,
38
+ render_markdown,
39
+ section_chunks,
40
+ text_block,
41
+ )
42
+ from ragsolver.rag.parse_structure import pair_problems_and_solutions, parse_markdown, split_item
43
+ from ragsolver.rag.schema import Figure, InformalChunk
44
+
45
+
46
+ def build_document_record(inv: Inventory, pdf_path: str) -> dict:
47
+ # Metadatos leídos del propio PDF (portada + metadata de Acrobat) durante el
48
+ # análisis inicial de la sesión de diseño — no vienen de un LLM.
49
+ return {
50
+ "doc_id": inv.doc_id,
51
+ "title": "Colectivo de Problemas resueltos para Estudiantes de Alto Rendimiento",
52
+ "authors": ["Miguel Angel Moreno Núñez", "Eduardo Tellechea Armenta"],
53
+ "year": 2002,
54
+ "source_type": "olympiad_collection",
55
+ "language": "es",
56
+ "file_path": pdf_path,
57
+ "sha256": inv.sha256,
58
+ "n_pages": inv.n_pages,
59
+ "license": "",
60
+ "redistributable": False, # no se conoce la licencia -> no publicar en HF
61
+ "parser": "mineru",
62
+ "parser_version": "4.0.3",
63
+ "parser_tier": "standard",
64
+ "text_layer_reliable": inv.text_layer_reliable,
65
+ "index_version": "0.1.0",
66
+ }
67
+
68
+
69
+ def _page_png(doc: pymupdf.Document, page: int, out_dir: Path, dpi: int, clip: pymupdf.Rect | None = None, name: str | None = None) -> Path:
70
+ out = out_dir / (name or f"page_{page:03d}.png")
71
+ if not out.exists():
72
+ out.parent.mkdir(parents=True, exist_ok=True)
73
+ doc[page - 1].get_pixmap(dpi=dpi, clip=clip).save(out)
74
+ return out
75
+
76
+
77
+ def fill_gaps(pdf_path: str, pages: dict, enricher: Enricher, out_dir: Path, min_words: int, dpi: int) -> list[dict]:
78
+ """Plan B acotado: transcribe con el VLM las regiones que MinerU no cubrió y las
79
+ inserta como bloques de texto sintéticos en orden de lectura."""
80
+ doc = pymupdf.open(pdf_path)
81
+ log = []
82
+ for r in find_uncovered_regions(pdf_path, pages, min_words=min_words):
83
+ page = doc[r.page - 1]
84
+ W, H = page.rect.width, page.rect.height
85
+ x0, y0, x1, y1 = r.bbox
86
+ clip = pymupdf.Rect(0.08 * W, y0 - 6, 0.95 * W, y1 + 6) # ancho completo de columna
87
+ png = _page_png(doc, r.page, out_dir / "gaps", dpi, clip=clip, name=f"gap_p{r.page:03d}.png")
88
+ md = enricher.transcribe_region(png)
89
+ insert_block(pages, r.page, text_block(md, [clip.x0 / W, clip.y0 / H, clip.x1 / W, clip.y1 / H]))
90
+ log.append({"page": r.page, "bbox_pt": [round(v) for v in r.bbox], "text_layer": r.words, "transcription": md})
91
+ return log
92
+
93
+
94
+ def qa_formulas(pdf_path: str, pages: dict, enricher: Enricher, out_dir: Path, dpi: int) -> list[dict]:
95
+ """6b: por página con >= 1 ecuación, el VLM revisa el LaTeX contra la imagen.
96
+ Modifica los bloques in-place; devuelve el registro de cambios."""
97
+ doc = pymupdf.open(pdf_path)
98
+ jobs = []
99
+ for pg in sorted(pages):
100
+ refs: list[tuple[dict, bool]] = [] # (contenedor con clave "content", es inline)
101
+ for b in pages[pg]:
102
+ if b.get("synthetic"):
103
+ continue
104
+ if b["type"] == "equation":
105
+ refs.append((b, False))
106
+ elif isinstance(b.get("content"), list):
107
+ refs += [(it, True) for it in b["content"] if it.get("type") == "equation_inline"]
108
+ if refs:
109
+ jobs.append((pg, refs, _page_png(doc, pg, out_dir / "pages", dpi)))
110
+ results = enricher.map(lambda j: enricher.qa_formulas(j[2], [r["content"] for r, _ in j[1]], inline=[i for _, i in j[1]]), jobs)
111
+ diffs = []
112
+ for (pg, refs, _), items in zip(jobs, results):
113
+ for item in items:
114
+ ref, _ = refs[item["i"]]
115
+ if item["changed"]:
116
+ diffs.append({"page": pg, "before": ref["content"], "after": item["latex"]})
117
+ ref["content"] = item["latex"]
118
+ elif "rejected" in item:
119
+ diffs.append({"page": pg, "before": ref["content"], "rejected": item["rejected"]})
120
+ return diffs
121
+
122
+
123
+ def recover_missing_solutions(parsed: dict, enricher: Enricher) -> list[str]:
124
+ """Soluciones cuyo numeral perdió el OCR: el LLM señala la primera línea dentro
125
+ de la solución anterior y `split_item` corta ahí (docs/PLAN_RAG.md: 'casos ambiguos')."""
126
+ problems = {p.number: p for p in parsed["problems"]}
127
+ solutions = {s.number: s for s in parsed["solutions"]}
128
+ notes = []
129
+ for n in sorted(problems):
130
+ if n in solutions or (n - 1) not in solutions:
131
+ continue
132
+ prev = solutions[n - 1]
133
+ first_line = enricher.split_solution(n, prev.text_md, problems[n - 1].text_md, problems[n].text_md)
134
+ res = split_item(prev, n, first_line) if first_line else None
135
+ if res is None:
136
+ notes.append(f"solución {n}: el LLM no encontró un corte verbatim ('{first_line[:60]}')")
137
+ continue
138
+ solutions[n - 1], solutions[n] = res
139
+ notes.append(f"solución {n}: recuperada por split de la {n - 1} en '{first_line[:60]}'")
140
+ parsed["solutions"] = [solutions[k] for k in sorted(solutions)]
141
+ return notes
142
+
143
+
144
+ def build_chunks(cards: list[dict], figures_by_locator: dict[str, Figure], doc_id: str, enricher: Enricher | None) -> tuple[list[InformalChunk], list[Figure]]:
145
+ empty_meta = {"context_summary": "", "math_area": [], "techniques": [], "theorems_used": [], "difficulty": None, "keywords": []}
146
+ prepared = []
147
+ for card in cards:
148
+ n = card["item_number"]
149
+ prob, sol = card["problem"], card["solution"]
150
+ statement = prob.text_md if prob else ""
151
+ solution = sol.text_md if sol else ""
152
+ card_id = f"informal:{doc_id}:problem_card:{n}"
153
+ figs = [figures_by_locator[loc] for loc in card["image_locators"] if loc in figures_by_locator]
154
+ for f in figs:
155
+ f.linked_chunk_ids = [card_id]
156
+ prepared.append({"n": n, "prob": prob, "sol": sol, "statement": statement, "solution": solution, "card_id": card_id, "figs": figs})
157
+
158
+ if enricher is not None: # 6a en paralelo
159
+ jobs = [(f, c["statement"], c["solution"]) for c in prepared for f in c["figs"]]
160
+
161
+ def _describe(job):
162
+ f, statement, solution = job
163
+ d = enricher.describe_figure(f.image_path, statement, solution)
164
+ f.description, f.labels, f.objects = d["description"], d["labels"], d["objects"]
165
+ f.relations, f.given_values, f.figure_type = d["relations"], d["given_values"], d["figure_type"]
166
+ f.description_model, f.description_version = d["model"], enricher.version
167
+
168
+ enricher.map(_describe, jobs)
169
+
170
+ for c in prepared:
171
+ parts = [c["statement"]] if c["statement"] else []
172
+ if c["solution"]:
173
+ parts.append("## Solución\n\n" + c["solution"])
174
+ if any(f.description for f in c["figs"]):
175
+ parts.append("## Figures\n\n" + "\n\n".join(f"[{f.figure_id}] {f.description}" for f in c["figs"] if f.description))
176
+ c["content_md"] = "\n\n".join(parts)
177
+
178
+ if enricher is not None: # 6c en paralelo
179
+ metas = enricher.map(lambda c: enricher.summarize_card(c["content_md"], [f.description for f in c["figs"] if f.description]), prepared)
180
+ else:
181
+ metas = [dict(empty_meta) for _ in prepared]
182
+
183
+ chunks: list[InformalChunk] = []
184
+ used_figures: list[Figure] = []
185
+ for c, meta in zip(prepared, metas):
186
+ n, figs, card_id, content_md = c["n"], c["figs"], c["card_id"], c["content_md"]
187
+ used_figures += figs
188
+ text = (meta["context_summary"] + "\n\n" + content_md).strip()
189
+ pages = [p for item in (c["prob"], c["sol"]) if item for p in (item.page_start, item.page_end)]
190
+ chunks.append(
191
+ InformalChunk(
192
+ chunk_id=card_id,
193
+ doc_id=doc_id,
194
+ unit_type="problem_card",
195
+ text=text,
196
+ content_md=content_md,
197
+ context_summary=meta["context_summary"],
198
+ page_start=min(pages) if pages else None,
199
+ page_end=max(pages) if pages else None,
200
+ section_path=["Problemario", str(n)],
201
+ item_number=n,
202
+ math_area=meta["math_area"],
203
+ techniques=meta["techniques"],
204
+ theorems_used=meta["theorems_used"],
205
+ difficulty=meta["difficulty"],
206
+ keywords=meta["keywords"],
207
+ figure_ids=[f.figure_id for f in figs],
208
+ children_ids=[f"informal:{doc_id}:figure:{f.figure_id.rsplit(':fig:', 1)[1]}" for f in figs if f.description],
209
+ content_sha=hashlib.sha256(content_md.encode("utf-8")).hexdigest()[:16],
210
+ index_version="0.1.0",
211
+ )
212
+ )
213
+ for f in figs:
214
+ if not f.description:
215
+ continue
216
+ chunks.append(
217
+ InformalChunk(
218
+ chunk_id=f"informal:{doc_id}:figure:{f.figure_id.rsplit(':fig:', 1)[1]}",
219
+ doc_id=doc_id,
220
+ unit_type="figure",
221
+ text=f"{f.description}\n\nProblem: {c['statement']}",
222
+ content_md=f.description,
223
+ parent_id=card_id,
224
+ page_start=f.page,
225
+ page_end=f.page,
226
+ bboxes=[[f.page, *f.bbox]],
227
+ section_path=["Problemario", str(n)],
228
+ item_number=n,
229
+ language="en",
230
+ math_area=meta["math_area"],
231
+ keywords=f.labels + f.objects,
232
+ figure_ids=[f.figure_id],
233
+ content_sha=hashlib.sha256(f.description.encode("utf-8")).hexdigest()[:16],
234
+ index_version="0.1.0",
235
+ )
236
+ )
237
+ return chunks, used_figures
238
+
239
+
240
+ def run(pdf_path: str, mineru_dir: str | None = None, parsed_root: str = "data/parsed", corpus_dir: str = "data/corpus", skip_llm: bool = False, ingest_yaml: str = "configs/ingest.yaml", sections: str = "none") -> dict:
241
+ """`sections`: `none` (sólo problem cards), `front` (además, chunks `paper_section` de lo
242
+ anterior al Problemario), `all` (todo el documento por secciones: papers/apuntes)."""
243
+ cfg = yaml.safe_load(open(ingest_yaml, encoding="utf-8"))["pdf"]
244
+ inv = inventory(pdf_path)
245
+ doc_id = inv.doc_id
246
+ out_dir = Path(parsed_root) / doc_id
247
+ mineru_dir = mineru_dir or str(out_dir / "mineru")
248
+ pages = load_pages(mineru_dir)
249
+ report_numerals = extract_item_numbers(pages)
250
+ doc_short = doc_id[:7] # MinerU usa los 7 primeros hex del sha256 en sus locators
251
+ enricher = None if skip_llm else Enricher(out_dir / "enrich")
252
+ report: dict = {"doc_id": doc_id, "n_pages": inv.n_pages, "item_numbers_from_equations": report_numerals}
253
+
254
+ if enricher is not None:
255
+ report["gaps"] = fill_gaps(pdf_path, pages, enricher, out_dir, cfg["gap_min_words"], cfg["render_dpi"])
256
+ report["formula_diffs"] = qa_formulas(pdf_path, pages, enricher, out_dir, cfg["render_dpi"])
257
+
258
+ md = render_markdown(pages, doc_short)
259
+ (out_dir / "full.fixed.md").write_text(md, encoding="utf-8")
260
+ parsed = parse_markdown(md)
261
+ if enricher is not None:
262
+ report["split_notes"] = recover_missing_solutions(parsed, enricher)
263
+ cards, warnings = pair_problems_and_solutions(parsed)
264
+
265
+ figures, fig_warnings = crop_figures(pdf_path, pages, out_dir / "figures", doc_id, pad_pt=cfg["figure_crop_padding_pt"], dpi=cfg["render_dpi"], min_overlap=cfg["vector_cluster_min_overlap"])
266
+ by_id = {f.figure_id: f for f in figures}
267
+ by_locator = {}
268
+ for loc, (pg, _) in image_locators(pages, doc_short).items():
269
+ fid = f"{doc_id}:fig:{pg}:{loc.rsplit(':', 1)[1]}"
270
+ if fid in by_id:
271
+ by_locator[loc] = by_id[fid]
272
+ # ponytail: figuras vectoriales no cubiertas por MinerU (ninguna en el PDF de ejemplo)
273
+ # se asignan al primer ítem cuyo rango de páginas las contiene; afinar por orden de
274
+ # lectura si aparece un corpus donde sí ocurra.
275
+ for f in figures:
276
+ if f.detected_by == "vector_cluster":
277
+ for card in cards:
278
+ items = [it for it in (card["problem"], card["solution"]) if it]
279
+ if any(it.page_start <= f.page <= it.page_end for it in items):
280
+ loc = f"vector:{f.figure_id}"
281
+ card["image_locators"].append(loc)
282
+ by_locator[loc] = f
283
+ break
284
+
285
+ chunks, used_figures = build_chunks(cards, by_locator, doc_id, enricher)
286
+ if sections != "none": # docs/PLAN_RAG.md §6.2 paso 7: papers/apuntes -> paper_section (400-800 tokens)
287
+ for sc in section_chunks(md, doc_id, stop_at="Problemario" if sections == "front" else None):
288
+ chunks.append(InformalChunk(**sc, text=sc["content_md"], content_sha=hashlib.sha256(sc["content_md"].encode("utf-8")).hexdigest()[:16], index_version="0.1.0"))
289
+ report["n_sections"] = sum(1 for c in chunks if c.unit_type == "paper_section")
290
+
291
+ corpus = Path(corpus_dir)
292
+ corpus.mkdir(parents=True, exist_ok=True)
293
+ with (corpus / "informal.jsonl").open("w", encoding="utf-8") as f:
294
+ for c in chunks:
295
+ f.write(c.model_dump_json() + "\n")
296
+ with (corpus / "figures.jsonl").open("w", encoding="utf-8") as f:
297
+ for fig in used_figures:
298
+ f.write(fig.model_dump_json() + "\n")
299
+ (out_dir / "document.json").write_text(json.dumps(build_document_record(inv, pdf_path), ensure_ascii=False, indent=2), encoding="utf-8")
300
+
301
+ report.update(
302
+ n_cards=len(cards),
303
+ n_paired=sum(1 for c in cards if c["problem"] and c["solution"]),
304
+ n_chunks=len(chunks),
305
+ n_figures=len(used_figures),
306
+ n_figures_detected=len(figures),
307
+ figure_warnings=fig_warnings,
308
+ warnings=warnings,
309
+ llm_calls=enricher.calls if enricher else 0,
310
+ models_exhausted=enricher.exhausted if enricher else [],
311
+ )
312
+ (out_dir / "ingest_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
313
+ return report
314
+
315
+
316
+ if __name__ == "__main__":
317
+ ap = argparse.ArgumentParser()
318
+ ap.add_argument("--pdf", default="problemas resueltos olimpiadas.pdf")
319
+ ap.add_argument("--mineru-dir", default=None, help="por defecto data/parsed/<doc_id>/mineru")
320
+ ap.add_argument("--skip-llm", action="store_true")
321
+ ap.add_argument("--sections", default="none", choices=["none", "front", "all"], help="chunks paper_section: none | front (antes del Problemario) | all (papers/apuntes)")
322
+ args = ap.parse_args()
323
+ rep = run(args.pdf, args.mineru_dir, skip_llm=args.skip_llm, sections=args.sections)
324
+ print(json.dumps({k: v for k, v in rep.items() if k not in ("gaps", "formula_diffs")}, indent=2, ensure_ascii=False))
325
+ print("gaps:", len(rep.get("gaps", [])), "formula_diffs:", len(rep.get("formula_diffs", [])))
ragsolver/rag/ingest_mathlib.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 1 — Corpus A: normaliza el JSONL de LeanSearch v2 al esquema MathlibChunk.
2
+
3
+ docs/PLAN_RAG.md §6.1. Entrada: `lsv2-mathlib-v4.28.0-rc1.jsonl` (310 579 registros,
4
+ FrenzyMath/lsv2-mathlib-v4.28.0-rc1-jsonl, Apache-2.0). Salida: `data/corpus/mathlib.jsonl`
5
+ con un `MathlibChunk` por línea (sin `embedding` todavía — eso lo hace `embed.py`).
6
+
7
+ Uso: python -m ragsolver.rag.ingest_mathlib [--config configs/ingest.yaml] [--limit N]
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import hashlib
14
+ import json
15
+ import re
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ from ragsolver.rag.schema import MathlibChunk
21
+
22
+
23
+ def _load_config(path: str) -> dict:
24
+ with open(path, encoding="utf-8") as f:
25
+ return yaml.safe_load(f)["mathlib"]
26
+
27
+
28
+ def _should_exclude(record: dict, cfg: dict) -> bool:
29
+ if record.get("kind") in cfg["exclude_kinds"]:
30
+ return True
31
+ module_path = ".".join(record.get("module_name") or [])
32
+ if any(module_path.startswith(p) for p in cfg["exclude_module_prefixes"]):
33
+ return True
34
+ decl_name = ".".join(record.get("name") or [])
35
+ if any(sub in decl_name for sub in cfg["exclude_name_substrings"]):
36
+ return True
37
+ # lemas de ecuación autogenerados (`foo.eq_1`, `foo.eq_def`), sin borrar lemas reales como
38
+ # `Nat.Prime.eq_one_or_self_of_dvd` (docs/PLAN_MEJORA_RAG.md §2.4, Fase 3 tarea 4b)
39
+ if cfg.get("exclude_name_regex") and re.search(cfg["exclude_name_regex"], decl_name):
40
+ return True
41
+ return False
42
+
43
+
44
+ def _docs_url(module_path: str, decl_name: str, base: str) -> str:
45
+ return f"{base}/{module_path.replace('.', '/')}.html#{decl_name}"
46
+
47
+
48
+ def record_to_chunk(record: dict, cfg: dict, mathlib_tag: str, lean_version: str) -> MathlibChunk | None:
49
+ names = record.get("name") or []
50
+ if not names:
51
+ return None
52
+ # `name` es la lista de COMPONENTES del nombre (["GCDMonoid", "dvd_gcd"]), no
53
+ # alias: el nombre completo es su unión con "." (docs/DECISIONS.md #31).
54
+ decl_name = ".".join(names)
55
+ module_path = ".".join(record.get("module_name") or [])
56
+ informal_name = record.get("informal_name", "") or ""
57
+ informal_description = record.get("informal_description", "") or ""
58
+ signature = record.get("signature", "") or record.get("type", "") or ""
59
+
60
+ text = "\n".join(part for part in [informal_name, informal_description, "", signature] if part is not None).strip()
61
+ content_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
62
+
63
+ return MathlibChunk(
64
+ chunk_id=f"mathlib:{decl_name}:0",
65
+ doc_id=cfg["doc_id"],
66
+ text=text,
67
+ content_sha=content_sha,
68
+ decl_name=decl_name,
69
+ decl_names_alt=[],
70
+ module_path=module_path,
71
+ kind=record.get("kind", ""),
72
+ signature=signature,
73
+ type=record.get("type", "") or "",
74
+ value=record.get("value", "") or "",
75
+ informal_name=informal_name,
76
+ informal_description=informal_description,
77
+ mathlib_tag=mathlib_tag,
78
+ lean_version=lean_version,
79
+ docs_url=_docs_url(module_path, decl_name, cfg["docs_url_base"]),
80
+ )
81
+
82
+
83
+ def run(config_path: str = "configs/ingest.yaml", versions_path: str = "configs/versions.yaml", limit: int | None = None) -> dict:
84
+ cfg = _load_config(config_path)
85
+ with open(versions_path, encoding="utf-8") as f:
86
+ versions = yaml.safe_load(f)
87
+ mathlib_tag = versions["lean"]["mathlib_tag"]
88
+ lean_version = versions["lean"]["toolchain"]
89
+
90
+ raw_path = Path(cfg["raw_jsonl"])
91
+ out_path = Path(cfg["out_jsonl"])
92
+ out_path.parent.mkdir(parents=True, exist_ok=True)
93
+
94
+ n_in = n_out = n_excluded = 0
95
+ with raw_path.open(encoding="utf-8") as fin, out_path.open("w", encoding="utf-8") as fout:
96
+ for line in fin:
97
+ if not line.strip():
98
+ continue
99
+ n_in += 1
100
+ if limit is not None and n_in > limit:
101
+ break
102
+ record = json.loads(line)
103
+ if _should_exclude(record, cfg):
104
+ n_excluded += 1
105
+ continue
106
+ chunk = record_to_chunk(record, cfg, mathlib_tag, lean_version)
107
+ if chunk is None:
108
+ n_excluded += 1
109
+ continue
110
+ fout.write(chunk.model_dump_json(exclude={"embedding"}) + "\n")
111
+ n_out += 1
112
+
113
+ return {"n_in": n_in, "n_out": n_out, "n_excluded": n_excluded, "out_path": str(out_path)}
114
+
115
+
116
+ if __name__ == "__main__":
117
+ parser = argparse.ArgumentParser()
118
+ parser.add_argument("--config", default="configs/ingest.yaml")
119
+ parser.add_argument("--versions", default="configs/versions.yaml")
120
+ parser.add_argument("--limit", type=int, default=None, help="sólo las primeras N líneas del raw (debug)")
121
+ args = parser.parse_args()
122
+ stats = run(args.config, args.versions, args.limit)
123
+ print(json.dumps(stats, indent=2))
ragsolver/rag/ingest_pdf.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 2 — Corpus B: inventario, render y red de seguridad de figuras vectoriales.
2
+
3
+ docs/PLAN_RAG.md §6.2, Fase 2 tareas 1-4. El parseo estructural (MinerU +
4
+ emparejamiento problema/solución) y el enriquecimiento VLM (tareas 5-6) van en
5
+ módulos separados porque dependen de MinerU y de un proveedor de LLM/VLM
6
+ respectivamente — ver docs/PROGRESS.md.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import pymupdf
17
+
18
+ # Heurística de fiabilidad de la capa de texto (docs/PLAN_RAG.md §3): líneas de
19
+ # 1-2 caracteres sueltas y tokens matemáticos aislados delatan una capa de texto
20
+ # que separó exponentes/subíndices de su base (p.ej. "2^2001" -> "22001").
21
+ _SHORT_LINE_RE = re.compile(r"^\s*.{1,2}\s*$")
22
+
23
+
24
+ def sha256_file(path: str | Path) -> str:
25
+ h = hashlib.sha256()
26
+ with open(path, "rb") as f:
27
+ for chunk in iter(lambda: f.read(1 << 20), b""):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+
32
+ @dataclass
33
+ class Inventory:
34
+ doc_id: str
35
+ sha256: str
36
+ n_pages: int
37
+ text_layer_reliable: bool
38
+ short_line_ratio: float
39
+
40
+
41
+ def inventory(pdf_path: str | Path) -> Inventory:
42
+ sha = sha256_file(pdf_path)
43
+ doc = pymupdf.open(pdf_path)
44
+ n_pages = len(doc)
45
+
46
+ total_lines = short_lines = 0
47
+ for page in doc:
48
+ for line in page.get_text().splitlines():
49
+ if not line.strip():
50
+ continue
51
+ total_lines += 1
52
+ if _SHORT_LINE_RE.match(line):
53
+ short_lines += 1
54
+ ratio = short_lines / total_lines if total_lines else 0.0
55
+
56
+ return Inventory(
57
+ doc_id=sha[:12],
58
+ sha256=sha,
59
+ n_pages=n_pages,
60
+ text_layer_reliable=ratio < 0.05, # umbral conservador; documentos "limpios" están muy por debajo
61
+ short_line_ratio=round(ratio, 4),
62
+ )
63
+
64
+
65
+ def render_pages(pdf_path: str | Path, out_dir: str | Path, dpi: int = 200) -> list[Path]:
66
+ out_dir = Path(out_dir)
67
+ out_dir.mkdir(parents=True, exist_ok=True)
68
+ doc = pymupdf.open(pdf_path)
69
+ paths = []
70
+ for i, page in enumerate(doc):
71
+ pix = page.get_pixmap(dpi=dpi)
72
+ p = out_dir / f"page_{i + 1:03d}.png"
73
+ pix.save(p)
74
+ paths.append(p)
75
+ return paths
76
+
77
+
78
+ @dataclass
79
+ class VectorFigure:
80
+ page: int # 1-based
81
+ bbox: tuple[float, float, float, float]
82
+ n_paths: int
83
+
84
+
85
+ def detect_vector_figures(pdf_path: str | Path, min_size_pt: float = 15.0, padding: float = 8.0) -> list[VectorFigure]:
86
+ """Red de seguridad de Fase 2, tarea 4: agrupa trazos vectoriales en figuras.
87
+
88
+ No depende de MinerU — se usa para verificar que MinerU no se comió ningún
89
+ diagrama de línea (docs/PLAN_RAG.md, riesgo "MinerU clasifica mal diagramas").
90
+
91
+ Filtra por tamaño mínimo (ambas dimensiones), no por cantidad de trazos: se
92
+ midió sobre el PDF de ejemplo (docs/DECISIONS.md) que diagramas geométricos
93
+ reales pueden tener sólo 2 trazos (un círculo + una línea), mientras que
94
+ reglas/subrayados decorativos con 1 solo trazo son anchos y muy bajos
95
+ (~12-15pt de alto). `min_size_pt=15` separa eso; recalibrar si se agregan
96
+ corpus con otro estilo de diagramación.
97
+ """
98
+ doc = pymupdf.open(pdf_path)
99
+ figures: list[VectorFigure] = []
100
+ for i, page in enumerate(doc):
101
+ drawings = page.get_drawings()
102
+ if not drawings:
103
+ continue
104
+ clusters = page.cluster_drawings(drawings=drawings)
105
+ for rect in clusters:
106
+ if min(rect.width, rect.height) < min_size_pt:
107
+ continue
108
+ padded = pymupdf.Rect(rect) + (-padding, -padding, padding, padding)
109
+ n_paths = sum(1 for d in drawings if padded.intersects(d["rect"]))
110
+ figures.append(VectorFigure(page=i + 1, bbox=tuple(padded), n_paths=n_paths))
111
+ return figures
ragsolver/rag/metrics.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métricas de IR (docs/PLAN_RAG.md §11.2) sin dependencias: recall@k, nDCG@k (relevancia
2
+ binaria, varios relevantes por consulta) y MRR. Reemplazan a `ranx` en las corridas
3
+ largas: en dos corridas de evaluación `ranx` devolvió valores absurdos (recall 0.11 con
4
+ las mismas listas de hits que, recomputadas desde las trazas, dan 0.76 —
5
+ docs/DECISIONS.md #43). `tests/test_metrics.py` verifica la igualdad con ranx.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+
13
+ def ranked_ids(scored: dict[str, float]) -> list[str]:
14
+ return [d for d, _ in sorted(scored.items(), key=lambda kv: -kv[1])]
15
+
16
+
17
+ def recall_at_k(ranked: list[str], relevant: set[str], k: int) -> float:
18
+ return len(relevant & set(ranked[:k])) / len(relevant) if relevant else 0.0
19
+
20
+
21
+ def ndcg_at_k(ranked: list[str], relevant: set[str], k: int) -> float:
22
+ dcg = sum(1.0 / math.log2(i + 2) for i, d in enumerate(ranked[:k]) if d in relevant)
23
+ idcg = sum(1.0 / math.log2(i + 2) for i in range(min(len(relevant), k)))
24
+ return dcg / idcg if idcg else 0.0
25
+
26
+
27
+ def mrr(ranked: list[str], relevant: set[str]) -> float:
28
+ return next((1.0 / (i + 1) for i, d in enumerate(ranked) if d in relevant), 0.0)
29
+
30
+
31
+ def evaluate_run(qrels: dict[str, dict[str, int]], run: dict[str, dict[str, float]], ks: tuple[int, ...] = (10,)) -> dict[str, float]:
32
+ """Promedio sobre las consultas de `qrels` (consulta sin hits = 0, como ranx)."""
33
+ out: dict[str, float] = {}
34
+ n = len(qrels) or 1
35
+ for k in ks:
36
+ out[f"recall@{k}"] = sum(recall_at_k(ranked_ids(run.get(q, {})), set(rel), k) for q, rel in qrels.items()) / n
37
+ out[f"ndcg@{k}"] = sum(ndcg_at_k(ranked_ids(run.get(q, {})), set(rel), k) for q, rel in qrels.items()) / n
38
+ out["mrr"] = sum(mrr(ranked_ids(run.get(q, {})), set(rel)) for q, rel in qrels.items()) / n
39
+ return out
ragsolver/rag/mineru_blocks.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 2 — lectura de la salida de MinerU 4 (esquema `docvortex.middle` 2.0).
2
+
3
+ MinerU deja un JSON por rango de páginas con bloques tipados (text / equation /
4
+ image / table / paragraph_title / ...) con bbox normalizado y contenido (LaTeX
5
+ para ecuaciones). Este módulo:
6
+
7
+ 1. Carga esos bloques (`load_pages`).
8
+ 2. Cruza la capa de texto del PDF contra los bbox de MinerU para encontrar
9
+ contenido que el layout se saltó (`find_uncovered_regions`) — el PDF de
10
+ ejemplo perdió una solución entera (pág. 64) y varias líneas sueltas.
11
+ La capa de texto NO sirve para leer fórmulas (docs/PLAN_RAG.md §3), pero
12
+ sí para saber *dónde hay texto*.
13
+ 3. Regenera el Markdown con el mismo formato que `mineru-kit` (`render_markdown`),
14
+ para poder insertar bloques sintéticos (transcripciones VLM de las regiones
15
+ perdidas) y correcciones de LaTeX antes de parsear la estructura.
16
+
17
+ Bloques sintéticos: dicts con la misma forma (`type`, `bbox`, `content`) más
18
+ `synthetic: True`; `insert_block` los ubica por orden de lectura (y0).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import glob
24
+ import json
25
+ import re
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ import pymupdf
30
+
31
+ # Numeral de ítem que MinerU absorbió dentro de una ecuación en display:
32
+ # "2 7. A = B", "\begin{array}{l} 5 2. \text{...}", "... \tag{88.}"
33
+ # Sólo con el punto pegado al último dígito (un decimal espaciado sale "1 0 . 5").
34
+ _LEADING_NUM_RE = re.compile(r"^(?:\\begin\{array\}\{[a-z ]*\}\s*)?(\d)(?:\s?(\d))?(?:\s?(\d))?\.\s+")
35
+ _TAG_NUM_RE = re.compile(r"\\tag\s*\{(\d{1,3})\.\}\s*$")
36
+
37
+
38
+ def load_pages(mineru_dir: str | Path) -> dict[int, list[dict]]:
39
+ """{página 1-based: [bloques en orden de lectura]} a partir de los *.json de MinerU."""
40
+ pages: dict[int, list[dict]] = {}
41
+ for f in sorted(glob.glob(str(Path(mineru_dir) / "*.json"))):
42
+ for p in json.load(open(f, encoding="utf-8"))["pages"]:
43
+ pages[p["page_idx"] + 1] = sorted(p["blocks"], key=lambda b: b["index"])
44
+ return pages
45
+
46
+
47
+ def _item_bboxes(block: dict) -> list[list[float]]:
48
+ boxes = [block["bbox"]]
49
+ if isinstance(block.get("content"), list):
50
+ boxes += [it["bbox"] for it in block["content"] if isinstance(it, dict) and "bbox" in it]
51
+ return boxes
52
+
53
+
54
+ @dataclass
55
+ class UncoveredRegion:
56
+ page: int # 1-based
57
+ bbox: tuple[float, float, float, float] # puntos PDF
58
+ words: str
59
+
60
+
61
+ def find_uncovered_regions(
62
+ pdf_path: str | Path, pages: dict[int, list[dict]], min_words: int = 4, tol_pt: float = 4.0
63
+ ) -> list[UncoveredRegion]:
64
+ """Palabras de la capa de texto cuyo centro no cae en ningún bbox de MinerU.
65
+
66
+ Una región por página (unión de las palabras sueltas); se ignora el pie de
67
+ página. `min_words` filtra ruido (separadores "* * *", etc.).
68
+ """
69
+ doc = pymupdf.open(pdf_path)
70
+ regions = []
71
+ for pg in range(1, len(doc) + 1):
72
+ page = doc[pg - 1]
73
+ W, H = page.rect.width, page.rect.height
74
+ boxes = [
75
+ pymupdf.Rect(x0 * W - tol_pt, y0 * H - tol_pt, x1 * W + tol_pt, y1 * H + tol_pt)
76
+ for b in pages.get(pg, [])
77
+ for x0, y0, x1, y1 in _item_bboxes(b)
78
+ ]
79
+ loose = []
80
+ for x0, y0, x1, y1, word, *_ in page.get_text("words"):
81
+ if y0 > 0.92 * H:
82
+ continue
83
+ c = pymupdf.Point((x0 + x1) / 2, (y0 + y1) / 2)
84
+ if not any(bx.contains(c) for bx in boxes):
85
+ loose.append((pymupdf.Rect(x0, y0, x1, y1), word))
86
+ if len(loose) >= min_words:
87
+ r = pymupdf.Rect(loose[0][0])
88
+ for rr, _ in loose[1:]:
89
+ r |= rr
90
+ regions.append(UncoveredRegion(pg, tuple(r), " ".join(w for _, w in loose)))
91
+ return regions
92
+
93
+
94
+ def insert_block(pages: dict[int, list[dict]], page: int, block: dict) -> None:
95
+ """Inserta un bloque sintético en `page` por orden de lectura (y0 del bbox)."""
96
+ blocks = pages.setdefault(page, [])
97
+ y0 = block["bbox"][1]
98
+ pos = next((i for i, b in enumerate(blocks) if b["bbox"][1] > y0), len(blocks))
99
+ blocks.insert(pos, {**block, "synthetic": True})
100
+
101
+
102
+ def text_block(text: str, bbox_norm: list[float]) -> dict:
103
+ return {"type": "text", "index": -1, "bbox": list(bbox_norm), "content": [{"type": "text", "content": text}]}
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Render Markdown (mismo formato que `mineru-kit`, ver data/parsed/*/full.md)
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def _inline(items: list[dict]) -> str:
112
+ out = []
113
+ for it in items:
114
+ if it["type"] == "equation_inline":
115
+ out.append(f"${it['content']}$")
116
+ elif isinstance(it.get("content"), list): # index: items anidados
117
+ out.append(_inline(it["content"]))
118
+ else:
119
+ s = it.get("content", "")
120
+ for style in it.get("styles", []):
121
+ s = {"bold": f"**{s}**", "superscript": f"<sup>{s}</sup>", "subscript": f"<sub>{s}</sub>"}.get(style, s)
122
+ out.append(s)
123
+ return "".join(out)
124
+
125
+
126
+ def _table(html: str) -> str:
127
+ rows = [re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", r, flags=re.S) for r in re.findall(r"<tr[^>]*>(.*?)</tr>", html, flags=re.S)]
128
+ rows = [[c.strip() for c in r] for r in rows if r]
129
+ if not rows:
130
+ return html
131
+ width = max(len(r) for r in rows)
132
+ lines = ["| " + " | ".join(r + [""] * (width - len(r))) + " |" for r in rows]
133
+ lines.insert(1, "| " + " | ".join(["---"] * width) + " |")
134
+ return "\n".join(lines)
135
+
136
+
137
+ def split_item_number(latex: str) -> tuple[int | None, str]:
138
+ """Devuelve (número de ítem, latex sin el numeral) si la ecuación lo absorbió."""
139
+ m = _LEADING_NUM_RE.match(latex)
140
+ if m:
141
+ n = int("".join(g for g in m.groups() if g))
142
+ prefix = latex[: m.start(1)] # conserva un \begin{array} si lo había
143
+ return n, prefix + latex[m.end() :]
144
+ m = _TAG_NUM_RE.search(latex)
145
+ if m:
146
+ return int(m.group(1)), latex[: m.start()].rstrip()
147
+ return None, latex
148
+
149
+
150
+ def extract_item_numbers(pages: dict[int, list[dict]]) -> list[tuple[int, int]]:
151
+ """Saca el numeral absorbido en ecuaciones a un bloque de texto propio ("N.")
152
+ justo antes. Correr ANTES de la QA de fórmulas (el VLM lo borra como basura)."""
153
+ found = []
154
+ for pg, blocks in pages.items():
155
+ i = 0
156
+ while i < len(blocks):
157
+ b = blocks[i]
158
+ if b["type"] == "equation":
159
+ n, latex = split_item_number(b["content"])
160
+ if n is not None:
161
+ b["content"] = latex
162
+ blocks.insert(i, {**text_block(f"{n}.", b["bbox"]), "synthetic": True})
163
+ found.append((pg, n))
164
+ i += 1
165
+ i += 1
166
+ return found
167
+
168
+
169
+ def render_page(blocks: list[dict], page: int, n_pages: int, doc_short: str, tier: str) -> str:
170
+ parts = [f"<!-- page {page} of {n_pages} -->"]
171
+ for b in blocks:
172
+ t = b["type"]
173
+ if t == "page_number":
174
+ continue
175
+ if t in ("text",):
176
+ parts.append(_inline(b["content"]))
177
+ elif t in ("paragraph_title", "doc_title"):
178
+ level = 1 if t == "doc_title" else b.get("level", 2)
179
+ parts.append("#" * level + " " + _inline(b["content"]))
180
+ elif t == "equation":
181
+ parts.append(f"$$\n{b['content']}\n$$")
182
+ elif t in ("image", "chart"):
183
+ for it in b["content"]:
184
+ if it["type"] in ("image_body", "chart_body"):
185
+ parts.append(f"![Image block](doc:{doc_short}/tier:{tier}/page:{page}/block:{it['index'] + 1})")
186
+ else: # image_caption / image_footnote
187
+ parts.append(_inline(it["content"]))
188
+ elif t == "table":
189
+ for it in b["content"]:
190
+ parts.append(_table(it["content"]) if it["type"] == "table_body" else _inline(it["content"]))
191
+ elif t == "index":
192
+ parts.append("\n".join(f"- {_inline([it])}" for it in b["content"]))
193
+ else:
194
+ parts.append(_inline(b["content"]) if isinstance(b["content"], list) else str(b["content"]))
195
+ return "\n\n".join(p for p in parts if p.strip())
196
+
197
+
198
+ def render_markdown(pages: dict[int, list[dict]], doc_short: str, tier: str = "standard") -> str:
199
+ n = max(pages)
200
+ return "\n\n".join(render_page(pages[pg], pg, n, doc_short, tier) for pg in sorted(pages)) + "\n"
201
+
202
+
203
+ def image_locators(pages: dict[int, list[dict]], doc_short: str, tier: str = "standard") -> dict[str, tuple[int, list[float]]]:
204
+ """{locator markdown: (página, bbox normalizado)} para cada image_body/chart_body."""
205
+ out = {}
206
+ for pg, blocks in pages.items():
207
+ for b in blocks:
208
+ if b["type"] in ("image", "chart"):
209
+ for it in b["content"]:
210
+ if it["type"] in ("image_body", "chart_body"):
211
+ out[f"doc:{doc_short}/tier:{tier}/page:{pg}/block:{it['index'] + 1}"] = (pg, it["bbox"])
212
+ return out
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Chunking por secciones (docs/PLAN_RAG.md §6.2, paso 7: papers/apuntes -> `paper_section`)
217
+ # ---------------------------------------------------------------------------
218
+
219
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
220
+
221
+
222
+ def section_chunks(md_text: str, doc_id: str, max_chars: int = 3000, min_chars: int = 200, stop_at: str | None = None) -> list[dict]:
223
+ """Corta el Markdown en chunks `paper_section` de ~400-800 tokens (≈ `max_chars`) que
224
+ respetan párrafos y títulos; `section_path` = títulos vigentes; `page_start/end` desde
225
+ los marcadores `<!-- page N of T -->`. `stop_at`: título en el que se deja de cortar
226
+ (p.ej. "Problemario", cuyo contenido va por el camino de problem cards)."""
227
+ chunks: list[dict] = []
228
+ path: list[str] = []
229
+ page = 1
230
+ buf: list[str] = []
231
+ buf_pages: list[int] = []
232
+
233
+ def flush():
234
+ text = "\n\n".join(buf).strip()
235
+ if len(text) >= min_chars:
236
+ n = len(chunks)
237
+ chunks.append({"chunk_id": f"informal:{doc_id}:paper_section:{n}", "doc_id": doc_id, "unit_type": "paper_section", "section_path": list(path), "page_start": min(buf_pages), "page_end": max(buf_pages), "content_md": text, "order": n})
238
+ buf.clear()
239
+ buf_pages.clear()
240
+
241
+ for para in re.split(r"\n\s*\n", md_text):
242
+ para = para.strip()
243
+ if not para:
244
+ continue
245
+ pm = re.match(r"<!--\s*page (\d+) of \d+\s*-->", para)
246
+ if pm:
247
+ page = int(pm.group(1))
248
+ continue
249
+ hm = _HEADING_RE.match(para)
250
+ if hm:
251
+ title = hm.group(2).strip()
252
+ if stop_at and title.lower().startswith(stop_at.lower()):
253
+ break
254
+ flush()
255
+ level = len(hm.group(1))
256
+ path = path[: level - 1] + [title]
257
+ continue
258
+ if re.fullmatch(r"\d{1,3}", para): # número de página suelto
259
+ continue
260
+ if buf and sum(len(p) for p in buf) + len(para) > max_chars:
261
+ flush()
262
+ buf.append(para)
263
+ buf_pages.append(page)
264
+ flush()
265
+ return chunks
ragsolver/rag/parse_structure.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 2, tarea 5 — parser estructural sobre el Markdown de MinerU.
2
+
3
+ Emparienta problema N (sección "Problemario") con su solución N (sección
4
+ "Soluciones"), y extrae los locators de imagen (`doc:.../block:N`) que caen
5
+ dentro de cada ítem. No usa LLM: es la ruta determinista que describe
6
+ docs/PLAN_RAG.md §6.2 ("casos ambiguos -> una llamada LLM", que no aplica acá
7
+ porque el documento tiene una estructura numerada regular).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+
15
+ _PAGE_RE = re.compile(r"^<!--\s*page (\d+) of \d+\s*-->\s*$")
16
+ # "21. texto", "## 21. texto", o "13." solo (la solución sigue en la línea siguiente,
17
+ # frecuente cuando el ítem arranca directo con una fórmula en bloque).
18
+ _ITEM_RE = re.compile(r"^(?:## )?(\d{1,3})\.\s*(?:\S.*)?$")
19
+ _IMAGE_RE = re.compile(r"!\[Image block\]\((doc:[^)]+)\)")
20
+
21
+
22
+ @dataclass
23
+ class Item:
24
+ number: int
25
+ text_md: str
26
+ page_start: int
27
+ page_end: int
28
+ image_locators: list[str] = field(default_factory=list)
29
+
30
+
31
+ def _split_section(lines: list[tuple[int, str]]) -> list[Item]:
32
+ """`lines`: [(page_num, line_text), ...] ya recortado a una sección.
33
+
34
+ Sólo se acepta un nuevo ítem si su número es >= el siguiente esperado
35
+ (no-decreciente, no estrictamente secuencial: a veces el número de un
36
+ ítem real queda atrapado dentro de una fórmula LaTeX -- p.ej. `\tag{100.}`
37
+ -- y ese ítem nunca hace match, lo cual no debe romper el resto de la
38
+ numeración). Rechazar los números MENORES al esperado es lo que evita que
39
+ un sub-caso dentro de una explicación (p.ej. "1. Prolongar el segmento
40
+ DC...", "2. Prolongar DA...") se confunda con el inicio de un ítem nuevo y
41
+ pise al ítem real con ese número -- pasó de verdad con la solución 1 en el
42
+ PDF de ejemplo (ver docs/DECISIONS.md).
43
+ """
44
+ items: list[Item] = []
45
+ current: list[tuple[int, str]] | None = None
46
+ current_number: int | None = None
47
+ expected = 1
48
+
49
+ # La sección "Soluciones" del PDF de ejemplo arranca con la solución 1 sin
50
+ # repetir el numeral: si el primer ítem numerado es el 2, lo que hay antes
51
+ # (salvo el título de sección) es el ítem 1.
52
+ first = next((i for i, (_, t) in enumerate(lines) if _ITEM_RE.match(t)), None)
53
+ if first is not None and int(_ITEM_RE.match(lines[first][1]).group(1)) == 2:
54
+ current, current_number, expected = [(p, t) for p, t in lines[:first] if not t.startswith("## ")], 1, 2
55
+ lines = lines[first:]
56
+
57
+ def flush():
58
+ nonlocal current, current_number
59
+ if current_number is not None and current:
60
+ text = "\n".join(t for _, t in current).strip()
61
+ pages = [p for p, _ in current]
62
+ images = _IMAGE_RE.findall(text)
63
+ items.append(Item(current_number, text, min(pages), max(pages), images))
64
+ current, current_number = [], None
65
+
66
+ for page, line in lines:
67
+ m = _ITEM_RE.match(line)
68
+ number = int(m.group(1)) if m else None
69
+ if number is not None and number >= expected:
70
+ flush()
71
+ current_number = number
72
+ expected = number + 1
73
+ current = [(page, line)]
74
+ elif current is not None:
75
+ current.append((page, line))
76
+ flush()
77
+ return items
78
+
79
+
80
+ def parse_markdown(md_text: str) -> dict:
81
+ """Devuelve {"problems": [Item...], "solutions": [Item...], "warnings": [...]}."""
82
+ lines_with_page: list[tuple[int, str]] = []
83
+ page = 0
84
+ for raw_line in md_text.splitlines():
85
+ pm = _PAGE_RE.match(raw_line)
86
+ if pm:
87
+ page = int(pm.group(1))
88
+ continue
89
+ lines_with_page.append((page, raw_line))
90
+
91
+ full_text = "\n".join(t for _, t in lines_with_page)
92
+ idx_problemario = full_text.find("## Problemario")
93
+ idx_soluciones = full_text.find("## Soluciones")
94
+ idx_bibliografia = full_text.find("## Bibliografía")
95
+
96
+ warnings: list[str] = []
97
+ for name, idx in [("Problemario", idx_problemario), ("Soluciones", idx_soluciones), ("Bibliografía", idx_bibliografia)]:
98
+ if idx == -1:
99
+ warnings.append(f"sección '{name}' no encontrada")
100
+
101
+ def _char_offset_to_line_index(char_offset: int) -> int:
102
+ acc = 0
103
+ for i, (_, t) in enumerate(lines_with_page):
104
+ acc += len(t) + 1
105
+ if acc > char_offset:
106
+ return i
107
+ return len(lines_with_page)
108
+
109
+ i_problemario = _char_offset_to_line_index(idx_problemario) if idx_problemario >= 0 else 0
110
+ i_soluciones = _char_offset_to_line_index(idx_soluciones) if idx_soluciones >= 0 else len(lines_with_page)
111
+ i_bibliografia = _char_offset_to_line_index(idx_bibliografia) if idx_bibliografia >= 0 else len(lines_with_page)
112
+
113
+ problems = _split_section(lines_with_page[i_problemario:i_soluciones])
114
+ solutions = _split_section(lines_with_page[i_soluciones:i_bibliografia])
115
+
116
+ return {"problems": problems, "solutions": solutions, "warnings": warnings}
117
+
118
+
119
+ def split_item(item: Item, n: int, first_line: str) -> tuple[Item, Item] | None:
120
+ """Parte `item` en (item, nuevo ítem `n`) en la línea `first_line` (verbatim).
121
+
122
+ Si esa línea está dentro de un bloque `$$`, el corte sube al `$$` de apertura.
123
+ Devuelve None si la línea no aparece. Los locators de imagen se reparten según
124
+ en qué mitad quedan.
125
+ """
126
+ lines = item.text_md.splitlines()
127
+ first_line = first_line.strip()
128
+ idx = next((i for i, l in enumerate(lines) if l.strip() == first_line), None)
129
+ if idx is None:
130
+ idx = next((i for i, l in enumerate(lines) if first_line and first_line in l), None)
131
+ if idx is None or idx == 0:
132
+ return None
133
+ opens = [i for i, l in enumerate(lines[:idx]) if l.strip() == "$$"]
134
+ if len(opens) % 2 == 1: # dentro de un display: cortar en su apertura
135
+ idx = opens[-1]
136
+ head, tail = "\n".join(lines[:idx]).strip(), "\n".join(lines[idx:]).strip()
137
+ if not tail.startswith(f"{n}."):
138
+ tail = f"{n}.\n\n{tail}"
139
+ return (
140
+ Item(item.number, head, item.page_start, item.page_end, _IMAGE_RE.findall(head)),
141
+ Item(n, tail, item.page_start, item.page_end, _IMAGE_RE.findall(tail)),
142
+ )
143
+
144
+
145
+ def pair_problems_and_solutions(parsed: dict) -> tuple[list[dict], list[str]]:
146
+ """Empareja por número. Devuelve (cards, warnings) — cards con problema+solución."""
147
+ problems_by_n = {p.number: p for p in parsed["problems"]}
148
+ solutions_by_n = {s.number: s for s in parsed["solutions"]}
149
+
150
+ warnings = list(parsed["warnings"])
151
+ all_numbers = sorted(set(problems_by_n) | set(solutions_by_n))
152
+ cards = []
153
+ for n in all_numbers:
154
+ prob = problems_by_n.get(n)
155
+ sol = solutions_by_n.get(n)
156
+ if prob is None:
157
+ warnings.append(f"solución {n} sin problema correspondiente")
158
+ if sol is None:
159
+ warnings.append(f"problema {n} sin solución correspondiente")
160
+ cards.append(
161
+ {
162
+ "item_number": n,
163
+ "problem": prob,
164
+ "solution": sol,
165
+ "image_locators": (prob.image_locators if prob else []) + (sol.image_locators if sol else []),
166
+ }
167
+ )
168
+ return cards, warnings
ragsolver/rag/premises.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Premisas por goal de Lean (docs/PLAN_MEJORA_RAG.md Fases 1, 3 y 4).
2
+
3
+ El input del retrieval es el goal pretty-printed que devuelve el REPL en cada `sorry`, no el
4
+ enunciado en lenguaje natural:
5
+ - canal A, prior de competición: lemas por frecuencia en pruebas de Lean Workbook
6
+ (`scripts/build_premise_prior.py`), filtrados por los símbolos del goal;
7
+ - canal B, selección nativa de Lean: se consume dentro de CLOSE (`grind +suggestions`), no como
8
+ lista de nombres (su lista para goals de competición es ruido: `not_fermat_42`, `BitVec.*`);
9
+ - canal C, índice denso actual con el goal como consulta (`Retriever`, modo `formal`).
10
+ A y C se fusionan con RRF. Todo cuesta 0 llamadas LLM.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import re
18
+ from functools import lru_cache
19
+ from itertools import combinations
20
+ from pathlib import Path
21
+
22
+ from ragsolver.rag.index import rrf_fuse
23
+
24
+ PRIOR_PATH = "data/corpus/premise_prior.jsonl"
25
+
26
+ IDENT_RE = re.compile(r"(?<![\w.'])([A-Za-z_][\w']*(?:\.[A-Za-z_][\w']*)*)")
27
+ # palabras de tácticas y sintaxis que el regex de identificadores confunde con premisas
28
+ TACTIC_WORDS = {
29
+ "by", "have", "exact", "apply", "intro", "intros", "rcases", "obtain", "refine", "refine'", "constructor", "simp", "simp_all", "norm_num", "linarith", "nlinarith",
30
+ "omega", "ring", "ring_nf", "field_simp", "positivity", "decide", "rfl", "use", "cases", "cases'", "induction", "induction'", "rw", "calc", "show", "fun", "at", "with",
31
+ "this", "norm_cast", "push_cast", "exact_mod_cast", "interval_cases", "by_contra", "by_cases", "push_neg", "split_ifs", "gcongr", "aesop", "tauto", "zify", "qify",
32
+ "simp_arith", "rw_mod_cast", "apply_fun", "native_decide", "nlinarith!", "linarith!", "contradiction", "exfalso", "assumption", "trivial", "specialize", "subst",
33
+ }
34
+ # `foo.mpr`, `foo.symm.le`: el lema es `foo`
35
+ FIELD_SUFFIXES = {"mp", "mpr", "symm", "le", "lt", "ne", "ne'", "trans", "elim", "1", "2", "resolve_left", "resolve_right", "out", "mono", "ge", "gt", "not_lt", "not_le", "trans_le", "trans_lt"}
36
+ # constantes de plomería que aparecen en `used_constants` de cualquier `rw`/`simp`/`linarith` y no son premisas
37
+ PLUMBING_PREFIXES = ("Mathlib.Tactic.", "Mathlib.Meta.", "Lean.", "Std.Tactic.", "Aesop.", "Linarith.", "Nat.Linear.", "Int.Linear.", "Mathlib.Algebra.Order.Field.Basic.")
38
+ PLUMBING = {"Eq.mpr", "Eq.mp", "congrArg", "congrFun", "congr", "id", "Eq.refl", "rfl", "of_eq_true", "eq_self", "propext", "Eq.trans", "Iff.refl", "trivial", "True.intro", "Eq.rec", "Eq.ndrec", "absurd", "Classical.byContradiction", "Decidable.byContradiction", "Not.intro", "eq_true", "eq_false", "False.elim", "id_eq", "Iff.intro", "And.intro"}
39
+ _RELATION_RE = re.compile(r"[=≤<≥>≠↔∣∈∉¬∧∨]|\bFalse\b|\bTrue\b")
40
+ # símbolos que "anclan" un lema a un dominio: si el lema los menciona y el goal no, no aplica
41
+ SALIENT_RE = re.compile(r"[A-Z][\w']*(?:\.[\w']+)+|√|∑|∏|∣|%|!|⌊|⌈|ℕ|ℤ|ℝ|ℚ|ℂ|π|\blogb\b|\bchoose\b|\bgcd\b|\blcm\b|\babs\b|\|")
42
+ _GENERIC_SALIENT = {"Nat.cast", "Int.cast", "OfNat.ofNat", "HPow.hPow", "HMul.hMul", "HAdd.hAdd", "HSub.hSub", "HDiv.hDiv"}
43
+
44
+
45
+ def strip_field(name: str, known: set[str] | None = None) -> str:
46
+ """`sub_ne_zero.mpr` -> `sub_ne_zero` (si `known` se da, sólo cuando la base está en `known`)."""
47
+ parts = name.split(".")
48
+ while len(parts) > 1 and parts[-1] in FIELD_SUFFIXES:
49
+ parts.pop()
50
+ if known is not None and ".".join(parts) in known:
51
+ return ".".join(parts)
52
+ return ".".join(parts) if known is None else name
53
+
54
+
55
+ def is_lemma_type(type_str: str) -> bool:
56
+ """Heurística: el tipo de un lema es una proposición (relación, conectivo); el de una definición
57
+ (`ℝ → ℝ`, `ℕ → ℕ → Prop`) no. ponytail: textual; `#print` exacto si aparecen falsos positivos."""
58
+ t = type_str.strip()
59
+ return bool(t) and not t.endswith("Prop") and not t.endswith("Type") and bool(_RELATION_RE.search(t))
60
+
61
+
62
+ def is_premise_constant(name: str) -> bool:
63
+ return bool(name) and name not in PLUMBING and not name.startswith(PLUMBING_PREFIXES) and "._" not in name and ".match_" not in name and ".proof_" not in name and not name.split(".")[-1].startswith("inst")
64
+
65
+
66
+ def proof_tokens(proof: str) -> set[str]:
67
+ """Identificadores candidatos a premisa en el cuerpo de una prueba (tras `:= by`), sin
68
+ tácticas ni accesos a campo; la validación contra Lean la hace quien llama."""
69
+ body = proof.split(":= by", 1)[1] if ":= by" in proof else proof
70
+ return {strip_field(t) for t in IDENT_RE.findall(body) if t not in TACTIC_WORDS and ("_" in t or "." in t) and len(t) > 3}
71
+
72
+
73
+ def goal_target(goal: str) -> str:
74
+ """La meta de un goal pretty-printed (lo que sigue al último `⊢`)."""
75
+ return goal.rsplit("⊢", 1)[-1].strip()
76
+
77
+
78
+ def salient(text: str) -> set[str]:
79
+ return {s for s in SALIENT_RE.findall(text) if s not in _GENERIC_SALIENT}
80
+
81
+
82
+ @lru_cache(maxsize=2)
83
+ def load_prior(path: str = PRIOR_PATH) -> tuple[dict, ...]:
84
+ p = Path(path)
85
+ if not p.exists():
86
+ return ()
87
+ rows = [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
88
+ for r in rows:
89
+ r["_salient"] = frozenset(salient(r.get("type", "")))
90
+ return tuple(rows)
91
+
92
+
93
+ def prior_channel(goal: str, k: int = 32, prior: tuple[dict, ...] | None = None, filtered: bool = True) -> list[str]:
94
+ """Canal A: top-k del prior. `filtered`: descarta los lemas anclados a símbolos que el goal no
95
+ tiene (un lema de `√` no sirve para un goal de `%`); los genéricos (`sq_nonneg`) pasan siempre."""
96
+ prior = load_prior() if prior is None else prior
97
+ if not filtered:
98
+ return [r["decl_name"] for r in prior[:k]]
99
+ g = salient(goal)
100
+ return [r["decl_name"] for r in prior if r["_salient"] <= g][:k]
101
+
102
+
103
+ def nlinarith_hints(goal: str, max_hints: int = 12) -> list[str]:
104
+ """Instancias de los lemas más frecuentes del prior (`sq_nonneg`, `mul_pos`) con las variables
105
+ del goal — el patrón `nlinarith [sq_nonneg (a - b), …]` de las pruebas de competición."""
106
+ hyps = [l for l in goal.split("⊢", 1)[0].splitlines() if " : " in l]
107
+ num_vars, pos_hyps = [], []
108
+ for l in hyps:
109
+ names, typ = l.split(" : ", 1)
110
+ typ = typ.strip()
111
+ if typ in ("ℝ", "ℚ", "ℤ", "ℕ"):
112
+ num_vars += names.split()
113
+ elif re.fullmatch(r"0 < [\w']+", typ) or re.fullmatch(r"[\w']+ > 0", typ):
114
+ pos_hyps += names.split()
115
+ hints = [f"sq_nonneg ({a} - {b})" for a, b in combinations(num_vars, 2)]
116
+ hints += [f"sq_nonneg ({a} + {b})" for a, b in combinations(num_vars, 2)]
117
+ hints += [f"mul_pos {a} {b}" for a, b in combinations(pos_hyps, 2)]
118
+ hints += [f"sq_nonneg {a}" for a in num_vars]
119
+ return hints[:max_hints]
120
+
121
+
122
+ def fuse(*ranked: list[str], k: int = 16) -> list[str]:
123
+ """RRF de los canales (nombres de declaración), sin duplicados."""
124
+ return [n for n, _ in rrf_fuse([r for r in ranked if r], k=60)][:k]
125
+
126
+
127
+ def log_prior(freq: int) -> float:
128
+ return math.log(max(freq, 1))
ragsolver/rag/proofs.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG de pruebas (DECISIONS #127): pruebas Lean verificadas de los problemas más parecidos al pedido.
2
+
3
+ El RAG de lemas no le aporta al demostrador (los lemas genéricos ya los conoce, DECISIONS #109-#115); lo que
4
+ le falta es cómo se resolvió un problema parecido (TacticToe, Tactician, Rango: recuperar pruebas, no sólo
5
+ premisas). Corpus: Goedel-LM/Lean-workbook-proofs (29 750 pruebas verificadas, MIT; la misma descarga que el
6
+ prior de `scripts/build_premise_prior.py`), sin las que coinciden con un enunciado de miniF2F. Se indexa el
7
+ enunciado informal (el comentario `/- … -/`) + el enunciado Lean con Qwen3-Embedding, el modelo del resto del
8
+ RAG, en la tabla `proofs`; la prueba va sin comentarios.
9
+
10
+ Construcción: `python -m ragsolver.rag.proofs build` (con `RAGSOLVER_EMBED_BACKEND=llama`, minutos en la GPU).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import re
18
+ import textwrap
19
+ from functools import lru_cache
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+ TABLE = "proofs"
25
+ INSTRUCTION = "Given a competition mathematics problem, retrieve similar problems whose Lean 4 proofs use the same techniques"
26
+
27
+
28
+ def _model() -> str:
29
+ return yaml.safe_load(open("configs/versions.yaml", encoding="utf-8"))["embedding"]["model"]
30
+
31
+
32
+ def norm_statement(s: str) -> str:
33
+ """Enunciado sin nombre ni espacios redundantes (para excluir los de miniF2F, como el prior)."""
34
+ s = re.sub(r"^\s*theorem\s+\S+", "", s.split(":= by", 1)[0])
35
+ return re.sub(r"\s+", " ", s).strip()
36
+
37
+
38
+ def parse(full_proof: str) -> dict | None:
39
+ """Archivo de Lean Workbook -> {informal, statement, proof}; None si no hay `theorem … := by`."""
40
+ i = full_proof.find("theorem")
41
+ m = re.search(r":=\s*by\b", full_proof[i:]) if i >= 0 else None
42
+ if not m:
43
+ return None
44
+ body = re.sub(r"/-.*?-/", "", full_proof[i + m.end() :], flags=re.S)
45
+ lines = [l for l in body.splitlines() if l.strip() and not l.strip().startswith("--")]
46
+ proof = textwrap.dedent("\n".join(lines)).strip()
47
+ if not proof:
48
+ return None
49
+ return {"informal": " ".join(re.findall(r"/-(.*?)-/", full_proof[:i], re.S)).strip(), "statement": full_proof[i : i + m.start()].strip(), "proof": proof}
50
+
51
+
52
+ def build(parquet: str, db_path: str, minif2f: list[str]) -> dict:
53
+ import pyarrow.parquet as pq
54
+
55
+ from ragsolver.rag.embed import embed_documents
56
+ from ragsolver.rag.index import build_table
57
+
58
+ excluded = {norm_statement(json.loads(l)["formal_statement"]) for path in minif2f for l in open(path, encoding="utf-8")}
59
+ rows, n_excl = [], 0
60
+ for r in pq.read_table(parquet).to_pylist():
61
+ p = parse(r["full_proof"])
62
+ if p is None:
63
+ continue
64
+ if norm_statement(p["statement"]) in excluded:
65
+ n_excl += 1
66
+ continue
67
+ rows.append({"problem_id": r["problem_id"], **p})
68
+ vecs = embed_documents([f"{r['informal']}\n{r['statement']}"[:1500] for r in rows], _model())
69
+ build_table(db_path, TABLE, ({**r, "embedding": v} for r, v in zip(rows, vecs)), fts_columns=None)
70
+ return {"n_rows": len(rows), "n_excluded_minif2f": n_excl}
71
+
72
+
73
+ @lru_cache(maxsize=1)
74
+ def _table(db_path: str):
75
+ from ragsolver.rag.index import open_table
76
+
77
+ return open_table(db_path, TABLE)
78
+
79
+
80
+ def similar(text: str, k: int = 3, max_sim: float = 0.95, db_path: str = "data/index/lancedb") -> list[dict]:
81
+ """Las k pruebas más parecidas a `text` (informal + enunciado Lean) con similitud coseno < `max_sim`:
82
+ por encima es el mismo problema (o casi) y la prueba sería la respuesta, no un ejemplo."""
83
+ from ragsolver.rag.embed import embed_queries
84
+ from ragsolver.rag.index import vector_search
85
+
86
+ q = embed_queries([text[:1500]], _model(), instruction=INSTRUCTION)[0]
87
+ out = []
88
+ for row in vector_search(_table(db_path), q, 4 * k):
89
+ sim = sum(a * b for a, b in zip(q, row["embedding"])) # vectores normalizados
90
+ if sim < max_sim:
91
+ out.append({"problem_id": row["problem_id"], "statement": row["statement"], "proof": row["proof"], "sim": round(sim, 4)})
92
+ if len(out) == k:
93
+ break
94
+ return out
95
+
96
+
97
+ def main() -> None:
98
+ ap = argparse.ArgumentParser()
99
+ ap.add_argument("cmd", choices=["build", "preview"])
100
+ ap.add_argument("--parquet", default="data/raw/lean_workbook_proofs/data/train-00000-of-00001.parquet")
101
+ ap.add_argument("--minif2f", nargs="+", default=["data/eval/minif2f_v2/miniF2F_v2s.jsonl", "data/eval/minif2f_v2/miniF2F_v2c.jsonl"])
102
+ ap.add_argument("--db-path", default="data/index/lancedb")
103
+ ap.add_argument("--problems", nargs="*", default=[], help="preview: JSONL de problemas")
104
+ ap.add_argument("--max-sim", type=float, default=1.01)
105
+ args = ap.parse_args()
106
+ if args.cmd == "build":
107
+ print(json.dumps(build(args.parquet, args.db_path, args.minif2f)), flush=True)
108
+ return
109
+ for path in args.problems: # preview: qué recuperaría cada problema (para revisar contaminación a mano)
110
+ for l in Path(path).read_text(encoding="utf-8").splitlines():
111
+ p = json.loads(l)
112
+ print(f"\n## {p['problem_id']}\n{p['formal_statement'][:300]}")
113
+ for h in similar(f"{p['informal_statement']}\n{p['formal_statement']}", 3, args.max_sim, args.db_path):
114
+ print(f" [{h['sim']}] {h['problem_id']}: {h['statement'][:250]}")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
ragsolver/rag/retrieve.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fase 3 — Servicio de recuperación y citación (docs/PLAN_RAG.md §6.3, §6.4, §8).
2
+
3
+ `Retriever.search(RetrievalRequest) -> RetrievalResult`:
4
+ plantilla por `mode` -> por consulta: denso top-N + FTS top-N (prefiltro SQL)
5
+ -> RRF multi-consulta y multi-canal -> rerank (cross-encoder) contra `rerank_against`
6
+ -> dedupe (figura vs. su problem card; máx. por módulo opcional) -> top-k
7
+ -> Citation[] + figuras adjuntas + evento JSONL.
8
+
9
+ Modo `error`: los identificadores Lean del mensaje se buscan de forma exacta
10
+ (`decl_name IN (...)`) y se fijan al tope del resultado; el resto de la consulta
11
+ va por los canales normales. Costo en llamadas LLM: siempre 0 (RNF-3).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import time
19
+ import uuid
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+ from ragsolver.logging.jsonl_writer import append_event
26
+ from ragsolver.rag.embed import embed_queries
27
+ from ragsolver.rag.index import exact_match, fts_search, open_table, rrf_fuse, sql_quote, vector_search
28
+ from ragsolver.rag.schema import (
29
+ Citation,
30
+ CitationScores,
31
+ Hit,
32
+ HitRecord,
33
+ RetrievalFilters,
34
+ RetrievalRequest,
35
+ RetrievalResult,
36
+ RetrievalTrace,
37
+ TraceEvent,
38
+ VersionsPin,
39
+ )
40
+
41
+ # `unknown identifier 'Nat.Prime.two_le'`, `unknown constant 'Foo.bar'`, `` `Foo.bar` ``
42
+ _QUOTED_IDENT_RE = re.compile(r"(?:identifier|constant|namespace|declaration)\s+'([^']+)'|`([^`\s]+)`")
43
+ _DOTTED_IDENT_RE = re.compile(r"\b[A-Z][A-Za-z0-9_']*(?:\.[A-Za-z0-9_']+)+\b")
44
+
45
+
46
+ def lean_identifiers(text: str) -> list[str]:
47
+ """Identificadores Lean citados en un mensaje de error (sin duplicados, en orden)."""
48
+ found = [a or b for a, b in _QUOTED_IDENT_RE.findall(text)]
49
+ found += _DOTTED_IDENT_RE.findall(_QUOTED_IDENT_RE.sub(" ", text)) # los citados ya están; evita `Baz.qux` de `Baz.qux'`
50
+ return list(dict.fromkeys(f for f in found if f))
51
+
52
+
53
+ def _mathlib_citation_label(row: dict) -> str:
54
+ return f"{row.get('decl_name', '?')} — {row.get('module_path', '?')}"
55
+
56
+
57
+ def _mathlib_locator(row: dict) -> dict:
58
+ return {
59
+ "decl_name": row.get("decl_name"),
60
+ "module_path": row.get("module_path"),
61
+ "docs_url": row.get("docs_url"),
62
+ }
63
+
64
+
65
+ def _informal_citation_label(row: dict) -> str:
66
+ item = row.get("item_number")
67
+ page = row.get("page_start")
68
+ suffix = f", ítem {item}" if item else ""
69
+ suffix += f", p. {page}" if page else ""
70
+ return f"{row.get('doc_id', '?')}{suffix}"
71
+
72
+
73
+ def _informal_locator(row: dict) -> dict:
74
+ return {
75
+ "doc_id": row.get("doc_id"),
76
+ "page_start": row.get("page_start"),
77
+ "page_end": row.get("page_end"),
78
+ "item_number": row.get("item_number"),
79
+ "figure_ids": list(row.get("figure_ids") or []),
80
+ }
81
+
82
+
83
+ _LABEL_BUILDERS = {"mathlib": _mathlib_citation_label, "informal": _informal_citation_label}
84
+ _LOCATOR_BUILDERS = {"mathlib": _mathlib_locator, "informal": _informal_locator}
85
+
86
+
87
+ def mathlib_table_name() -> str:
88
+ """MET-1: qué tabla LanceDB sirve el corpus `mathlib` — misma fuente de verdad
89
+ para el `Retriever` y para el pin de versiones que registran los agentes."""
90
+ return os.environ.get("RAGSOLVER_MATHLIB_TABLE", "mathlib")
91
+
92
+
93
+ def sql_filter(corpus: str, filters: RetrievalFilters) -> str | None:
94
+ """Prefiltro SQL de LanceDB a partir de §8.1 `filters` (sólo los campos que aplican al corpus)."""
95
+ clauses = []
96
+ if filters.doc_ids:
97
+ clauses.append(f"doc_id IN ({', '.join(sql_quote(d) for d in filters.doc_ids)})")
98
+ if filters.unit_types:
99
+ clauses.append(f"unit_type IN ({', '.join(sql_quote(u) for u in filters.unit_types)})")
100
+ if corpus == "mathlib" and filters.kinds:
101
+ clauses.append(f"kind IN ({', '.join(sql_quote(k) for k in filters.kinds)})")
102
+ if corpus == "informal" and filters.math_area:
103
+ clauses.append(f"array_has_any(math_area, [{', '.join(sql_quote(a) for a in filters.math_area)}])")
104
+ return " AND ".join(clauses) or None
105
+
106
+
107
+ class Retriever:
108
+ def __init__(self, db_path: str = "data/index/lancedb", versions_path: str = "configs/versions.yaml", config_path: str = "configs/retrieval.yaml"):
109
+ self.db_path = db_path
110
+ self.versions = yaml.safe_load(open(versions_path, encoding="utf-8"))
111
+ self.config = yaml.safe_load(open(config_path, encoding="utf-8")) if Path(config_path).exists() else {}
112
+ self.channels: tuple[str, ...] = ("dense", "sparse") # sólo para ablaciones de evaluate.py
113
+ self.trace_dir = "data/eval/traces"
114
+ # el corpus "mathlib" puede servirse desde otra tabla (p.ej. `mathlib_full`, 278k) sin tocar filtros ni citas
115
+ self.tables = {"mathlib": mathlib_table_name()}
116
+
117
+ @lru_cache(maxsize=8)
118
+ def _table(self, name: str):
119
+ return open_table(self.db_path, self.tables.get(name, name))
120
+
121
+ @lru_cache(maxsize=1)
122
+ def _reranker(self):
123
+ import torch
124
+ from sentence_transformers import CrossEncoder
125
+
126
+ # bf16 en CPU: mitad de RAM que fp32 (docs/DECISIONS.md #40); RAGSOLVER_EMBED_BF16=0 restaura fp32
127
+ dtype = torch.bfloat16 if os.environ.get("RAGSOLVER_EMBED_BF16", "1") == "1" else torch.float32
128
+ return CrossEncoder(self.versions["reranker"]["model"], device="cpu", model_kwargs={"torch_dtype": dtype})
129
+
130
+ # -- candidatos ------------------------------------------------------------
131
+
132
+ def _candidates(self, corpus: str, request: RetrievalRequest) -> tuple[dict, list[list[str]], list[str]]:
133
+ """(filas por id, listas rankeadas para RRF, ids fijados al tope)."""
134
+ table = self._table(corpus)
135
+ where = sql_filter(corpus, request.filters)
136
+ instruction = (self.config.get("query_instructions") or {}).get(request.mode)
137
+ rows_by_id: dict = {}
138
+ ranked: list[list[str]] = []
139
+ pinned: list[str] = []
140
+
141
+ if request.mode == "error" and corpus == "mathlib":
142
+ idents = [i for q in request.queries for i in lean_identifiers(q)]
143
+ for row in exact_match(table, "decl_name", idents, where):
144
+ rows_by_id.setdefault(row["chunk_id"], row)
145
+ pinned.append(row["chunk_id"])
146
+ if idents: # tokens del identificador ("Nat Prime two_le") como consulta FTS extra
147
+ sparse = fts_search(table, " ".join(re.sub(r"[._']", " ", i) for i in idents), request.n_candidates, where)
148
+ for row in sparse:
149
+ rows_by_id.setdefault(row["chunk_id"], row)
150
+ ranked.append([r["chunk_id"] for r in sparse])
151
+
152
+ vectors = embed_queries(request.queries, model_name=self.versions["embedding"]["model"], instruction=instruction) if "dense" in self.channels else [None] * len(request.queries)
153
+ for q_text, q_vec in zip(request.queries, vectors):
154
+ if "dense" in self.channels:
155
+ dense = vector_search(table, q_vec, request.n_candidates, where)
156
+ for row in dense:
157
+ rows_by_id.setdefault(row["chunk_id"], row)
158
+ ranked.append([r["chunk_id"] for r in dense])
159
+ if "sparse" in self.channels:
160
+ sparse = fts_search(table, q_text, request.n_candidates, where)
161
+ for row in sparse:
162
+ rows_by_id.setdefault(row["chunk_id"], row)
163
+ ranked.append([r["chunk_id"] for r in sparse])
164
+ return rows_by_id, ranked, list(dict.fromkeys(pinned))
165
+
166
+ # -- dedupe ------------------------------------------------------------------
167
+
168
+ def _dedupe(self, corpus: str, rows: list[dict]) -> list[dict]:
169
+ out: list[dict] = []
170
+ if corpus == "informal":
171
+ # figura cuyo problem card también fue recuperado: se colapsa en el card
172
+ # (que ya lleva `figure_ids`); la figura sola sigue siendo un hit válido.
173
+ ids = {r["chunk_id"] for r in rows}
174
+ rows = [r for r in rows if not (r.get("unit_type") == "figure" and r.get("parent_id") in ids)]
175
+ per_module: dict[str, int] = {}
176
+ limit = int(self.config.get("max_per_module") or 0)
177
+ for r in rows:
178
+ if corpus == "mathlib" and limit:
179
+ m = r.get("module_path", "")
180
+ per_module[m] = per_module.get(m, 0) + 1
181
+ if per_module[m] > limit:
182
+ continue
183
+ out.append(r)
184
+ return out
185
+
186
+ def _figures(self, figure_ids: list[str]) -> list[dict]:
187
+ if not figure_ids:
188
+ return []
189
+ try:
190
+ table = self._table("figures")
191
+ except Exception: # tabla `figures` ausente (índice construido sin corpus informal)
192
+ return []
193
+ rows = exact_match(table, "figure_id", figure_ids)
194
+ keep = ("figure_id", "image_path", "description", "page", "bbox", "figure_type", "labels")
195
+ return [{k: r.get(k) for k in keep} for r in rows]
196
+
197
+ # -- búsqueda ----------------------------------------------------------------
198
+
199
+ def search(self, request: RetrievalRequest) -> RetrievalResult:
200
+ t0 = time.time()
201
+ ablation = self.config.get("ablation") or {}
202
+ corpora = [c for c in request.corpora if c in (ablation.get("corpora") or request.corpora)]
203
+ if ablation.get("enabled", True) is False:
204
+ corpora = []
205
+
206
+ all_hits: list[Hit] = []
207
+ for corpus in corpora:
208
+ rows_by_id, ranked, pinned = self._candidates(corpus, request)
209
+ fused = rrf_fuse(ranked, k=int(self.config.get("rrf_k", 60)))
210
+ fused_score = dict(fused)
211
+ order_ids = pinned + [cid for cid, _ in fused if cid not in pinned]
212
+ candidates = self._dedupe(corpus, [rows_by_id[c] for c in order_ids if c in rows_by_id])[: request.n_candidates]
213
+
214
+ rerank_scores: dict[str, float] = {}
215
+ if request.rerank and candidates:
216
+ against = request.rerank_against or request.queries[0]
217
+ scores = self._reranker().predict([(against, r["text"]) for r in candidates])
218
+ rerank_scores = {r["chunk_id"]: float(s) for r, s in zip(candidates, scores)}
219
+ unpinned = sorted((r for r in candidates if r["chunk_id"] not in pinned), key=lambda r: rerank_scores[r["chunk_id"]], reverse=True)
220
+ candidates = [r for r in candidates if r["chunk_id"] in pinned] + unpinned
221
+
222
+ prefix = "M" if corpus == "mathlib" else "I"
223
+ for rank, row in enumerate(candidates[: request.k], start=1):
224
+ cid = row["chunk_id"]
225
+ citation = Citation(
226
+ citation_id=f"{prefix}{rank}",
227
+ chunk_id=cid,
228
+ corpus=corpus,
229
+ unit_type=row.get("unit_type", ""),
230
+ label=_LABEL_BUILDERS[corpus](row),
231
+ locator=_LOCATOR_BUILDERS[corpus](row),
232
+ scores=CitationScores(fused=fused_score.get(cid), rerank=rerank_scores.get(cid)),
233
+ retrieved_for=request.context,
234
+ snippet=(row.get("content_md") or row.get("text") or "")[:300],
235
+ )
236
+ figures = self._figures(list(row.get("figure_ids") or [])) if (corpus == "informal" and request.include_figures) else []
237
+ all_hits.append(
238
+ Hit(
239
+ chunk_id=cid,
240
+ corpus=corpus,
241
+ unit_type=row.get("unit_type", ""),
242
+ rank=rank,
243
+ text=row.get("text", ""),
244
+ content_md=row.get("content_md", ""),
245
+ metadata={k: v for k, v in row.items() if k not in ("embedding", "text", "content_md", "_fts_text", "_distance", "_score")},
246
+ figures=figures,
247
+ citation=citation,
248
+ )
249
+ )
250
+
251
+ latency_ms = (time.time() - t0) * 1000
252
+ pin = VersionsPin(
253
+ embedding_model=self.versions["embedding"]["model"],
254
+ reranker_model=self.versions["reranker"]["model"],
255
+ index_version=self.versions["index"]["version"],
256
+ mathlib_tag=self.versions["lean"]["mathlib_tag"],
257
+ mathlib_table=self.tables.get("mathlib", "mathlib"),
258
+ )
259
+ # TRZ-1: si el llamador pasa `context.run_id` (el orquestador pasa el pid de la corrida), el
260
+ # evento va al MISMO archivo que los eventos de los agentes de ese (config, problema) en vez
261
+ # de crear un archivo nuevo por búsqueda. Sin run_id (CLI, demo, evaluate.py) se mantiene el
262
+ # comportamiento anterior: un archivo por búsqueda, con sufijo aleatorio.
263
+ append_event(
264
+ TraceEvent(
265
+ run_id=request.context.run_id or f"{request.context.problem_id}:{uuid.uuid4().hex[:8]}",
266
+ problem_id=request.context.problem_id,
267
+ subgoal_id=request.context.subgoal_id,
268
+ attempt=request.context.attempt,
269
+ agent="retriever",
270
+ latency_ms=latency_ms,
271
+ llm_calls=0,
272
+ request=request.model_dump(mode="json"),
273
+ n_candidates=request.n_candidates,
274
+ hits=[HitRecord(chunk_id=h.chunk_id, rank=h.rank, scores=h.citation.scores) for h in all_hits],
275
+ versions=pin,
276
+ ),
277
+ base_dir=self.trace_dir,
278
+ )
279
+ return RetrievalResult(
280
+ hits=all_hits,
281
+ citations=[h.citation for h in all_hits],
282
+ trace=RetrievalTrace(latency_ms=latency_ms, n_candidates=request.n_candidates, versions=pin),
283
+ )
284
+
285
+
286
+ def _cli(argv: list[str] | None = None) -> None:
287
+ """`ragsolver search "query" --corpus mathlib --k 8` (docs/PLAN_RAG.md Fase 3, tarea 7)."""
288
+ import argparse
289
+ import json as _json
290
+
291
+ ap = argparse.ArgumentParser(prog="ragsolver search")
292
+ ap.add_argument("query", nargs="+", help="una o más consultas (se fusionan con RRF)")
293
+ ap.add_argument("--corpus", action="append", choices=["mathlib", "informal"], help="repetible; por defecto mathlib")
294
+ ap.add_argument("--mode", default="nl", choices=["nl", "formal", "error", "exemplar"])
295
+ ap.add_argument("--k", type=int, default=8)
296
+ ap.add_argument("--no-rerank", action="store_true")
297
+ ap.add_argument("--math-area", action="append", default=[])
298
+ ap.add_argument("--db-path", default="data/index/lancedb")
299
+ args = ap.parse_args(argv)
300
+
301
+ req = RetrievalRequest(
302
+ queries=args.query,
303
+ mode=args.mode,
304
+ corpora=args.corpus or ["mathlib"],
305
+ k=args.k,
306
+ rerank=not args.no_rerank,
307
+ filters={"math_area": args.math_area},
308
+ context={"problem_id": "cli", "agent": "cli"},
309
+ )
310
+ result = Retriever(db_path=args.db_path).search(req)
311
+ for hit in result.hits:
312
+ sc = hit.citation.scores
313
+ print(f"[{hit.citation.citation_id}] {hit.citation.label} (fused={sc.fused and round(sc.fused, 4)}, rerank={sc.rerank and round(sc.rerank, 3)})")
314
+ print(f" {hit.text[:160]!r}")
315
+ for fig in hit.figures:
316
+ print(f" fig {fig['figure_id']}: {fig['image_path']} — {(fig['description'] or '')[:100]!r}")
317
+ print(_json.dumps({"latency_ms": round(result.trace.latency_ms, 1), "n_hits": len(result.hits)}))
318
+
319
+
320
+ if __name__ == "__main__":
321
+ _cli()
ragsolver/rag/schema.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contratos tipados del módulo RAG (RNF-4).
2
+
3
+ Espejo exacto de docs/PLAN_RAG.md §7 (esquema de metadatos) y §8 (contratos del
4
+ recuperador). No importar nada de ragsolver.agents/ragsolver.orchestrator desde
5
+ aquí — el RAG es una capa independiente que los agentes consumen (docs/PLAN_RAG.md §0).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ Corpus = Literal["mathlib", "informal"]
16
+ QueryMode = Literal["nl", "formal", "error", "exemplar"]
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # §7.1 — documents
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ class Document(BaseModel):
24
+ doc_id: str
25
+ title: str = ""
26
+ authors: list[str] = Field(default_factory=list)
27
+ year: int | None = None
28
+ source_type: Literal["olympiad_collection", "paper", "textbook", "notes", "mathlib"]
29
+ language: str = "en"
30
+ file_path: str = ""
31
+ sha256: str = ""
32
+ n_pages: int = 0
33
+ url: str = ""
34
+ license: str = ""
35
+ redistributable: bool = False
36
+ parser: str = ""
37
+ parser_version: str = ""
38
+ parser_tier: str = ""
39
+ text_layer_reliable: bool | None = None
40
+ ingested_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
41
+ index_version: str = ""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # §7.2 — chunks (núcleo común) + §7.3/§7.4 extensiones por corpus
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class ChunkBase(BaseModel):
50
+ """Campos comunes a las dos tablas LanceDB (`mathlib`, `informal`)."""
51
+
52
+ chunk_id: str
53
+ doc_id: str
54
+ corpus: Corpus
55
+ unit_type: str
56
+ text: str # lo que se embebe y se indexa en BM25/FTS
57
+ embedding: list[float] | None = None
58
+ embedding_model: str = ""
59
+ embedding_dim: int = 0
60
+ index_version: str = ""
61
+ content_sha: str = ""
62
+
63
+
64
+ class InformalChunk(ChunkBase):
65
+ """Tabla `informal`: problem_card, solution_step, figure, paper_section, theorem."""
66
+
67
+ corpus: Literal["informal"] = "informal"
68
+ parent_id: str | None = None
69
+ children_ids: list[str] = Field(default_factory=list)
70
+ order: int = 0
71
+ content_md: str = ""
72
+ context_summary: str = ""
73
+ page_start: int | None = None
74
+ page_end: int | None = None
75
+ bboxes: list[list[float]] = Field(default_factory=list) # [page, x0, y0, x1, y1] normalizados
76
+ section_path: list[str] = Field(default_factory=list)
77
+ item_number: int | None = None
78
+ language: str = "es"
79
+ math_area: list[str] = Field(default_factory=list)
80
+ techniques: list[str] = Field(default_factory=list)
81
+ theorems_used: list[str] = Field(default_factory=list)
82
+ mathlib_candidates: list[str] = Field(default_factory=list)
83
+ difficulty: int | None = None
84
+ keywords: list[str] = Field(default_factory=list)
85
+ figure_ids: list[str] = Field(default_factory=list)
86
+ refs_to: list[str] = Field(default_factory=list)
87
+
88
+
89
+ class MathlibChunk(ChunkBase):
90
+ """Tabla `mathlib`: una declaración por registro (§7.4)."""
91
+
92
+ corpus: Literal["mathlib"] = "mathlib"
93
+ unit_type: Literal["mathlib_decl"] = "mathlib_decl"
94
+ decl_name: str
95
+ decl_names_alt: list[str] = Field(default_factory=list)
96
+ module_path: str = ""
97
+ kind: str = ""
98
+ signature: str = ""
99
+ type: str = ""
100
+ value: str = ""
101
+ informal_name: str = ""
102
+ informal_description: str = ""
103
+ docstring: str = ""
104
+ dependencies: list[str] = Field(default_factory=list)
105
+ mathlib_tag: str = ""
106
+ lean_version: str = ""
107
+ docs_url: str = ""
108
+
109
+
110
+ class Figure(BaseModel):
111
+ """§7.3 — tabla `figures`; también existe como InformalChunk(unit_type='figure')."""
112
+
113
+ figure_id: str
114
+ image_path: str
115
+ image_sha: str = ""
116
+ page: int
117
+ bbox: list[float]
118
+ detected_by: Literal["layout", "vector_cluster", "raster", "both"]
119
+ caption: str = ""
120
+ description: str = ""
121
+ labels: list[str] = Field(default_factory=list)
122
+ objects: list[str] = Field(default_factory=list)
123
+ relations: list[str] = Field(default_factory=list)
124
+ given_values: list[str] = Field(default_factory=list)
125
+ figure_type: Literal["geometric_diagram", "grid", "plot", "table_image", "other"] = "other"
126
+ linked_chunk_ids: list[str] = Field(default_factory=list)
127
+ description_model: str = ""
128
+ description_version: str = ""
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # §7.5 — Citation
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class RetrievalContext(BaseModel):
137
+ problem_id: str
138
+ subgoal_id: str = ""
139
+ attempt: int = 0
140
+ agent: str = ""
141
+ run_id: str = "" # TRZ-1: si se da, el evento de traza va al archivo de esta corrida en vez de uno por búsqueda
142
+
143
+
144
+ class CitationScores(BaseModel):
145
+ dense: float | None = None
146
+ sparse: float | None = None
147
+ fused: float | None = None
148
+ rerank: float | None = None
149
+
150
+
151
+ class Citation(BaseModel):
152
+ citation_id: str
153
+ chunk_id: str
154
+ corpus: Corpus
155
+ unit_type: str
156
+ label: str
157
+ locator: dict = Field(default_factory=dict)
158
+ scores: CitationScores = Field(default_factory=CitationScores)
159
+ retrieved_for: RetrievalContext
160
+ used_in_proof: bool = False
161
+ used_in_sketch: bool = False
162
+ snippet: str = ""
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # §7.6 — evento de traza JSONL (RNF-5)
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ class VersionsPin(BaseModel):
171
+ embedding_model: str = ""
172
+ reranker_model: str = ""
173
+ index_version: str = ""
174
+ mathlib_tag: str = ""
175
+ mathlib_table: str = "" # MET-1: qué tabla sirve el corpus `mathlib` (`mathlib` muestra / `mathlib_full`)
176
+
177
+
178
+ class HitRecord(BaseModel):
179
+ chunk_id: str
180
+ rank: int
181
+ scores: CitationScores = Field(default_factory=CitationScores)
182
+
183
+
184
+ class TraceEvent(BaseModel):
185
+ run_id: str
186
+ problem_id: str
187
+ subgoal_id: str = ""
188
+ attempt: int = 0
189
+ agent: str = "retriever"
190
+ ts: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
191
+ latency_ms: float = 0.0
192
+ llm_calls: int = 0 # el retriever SIEMPRE reporta 0 (RNF-3, §6.3 del plan)
193
+ tokens: int = 0
194
+ request: dict = Field(default_factory=dict)
195
+ n_candidates: int = 0
196
+ hits: list[HitRecord] = Field(default_factory=list)
197
+ versions: VersionsPin = Field(default_factory=VersionsPin)
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # §8.1/§8.2 — contratos del recuperador
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ class RetrievalFilters(BaseModel):
206
+ math_area: list[str] = Field(default_factory=list)
207
+ doc_ids: list[str] = Field(default_factory=list)
208
+ unit_types: list[str] = Field(default_factory=list)
209
+ kinds: list[str] = Field(default_factory=list)
210
+
211
+
212
+ class RetrievalRequest(BaseModel):
213
+ queries: list[str] = Field(min_length=1, max_length=4)
214
+ rerank_against: str | None = None
215
+ mode: QueryMode = "nl"
216
+ corpora: list[Corpus] = Field(default_factory=lambda: ["mathlib"])
217
+ k: int = 8
218
+ n_candidates: int = 50
219
+ filters: RetrievalFilters = Field(default_factory=RetrievalFilters)
220
+ rerank: bool = True
221
+ include_figures: bool = True
222
+ context: RetrievalContext
223
+
224
+
225
+ class Hit(BaseModel):
226
+ chunk_id: str
227
+ corpus: Corpus
228
+ unit_type: str
229
+ rank: int
230
+ text: str
231
+ content_md: str = ""
232
+ metadata: dict = Field(default_factory=dict)
233
+ figures: list[dict] = Field(default_factory=list)
234
+ citation: Citation
235
+
236
+
237
+ class RetrievalTrace(BaseModel):
238
+ latency_ms: float = 0.0
239
+ n_candidates: int = 0
240
+ versions: VersionsPin = Field(default_factory=VersionsPin)
241
+
242
+
243
+ class RetrievalResult(BaseModel):
244
+ hits: list[Hit] = Field(default_factory=list)
245
+ citations: list[Citation] = Field(default_factory=list)
246
+ trace: RetrievalTrace = Field(default_factory=RetrievalTrace)