Skip to content

Commit 772d3a1

Browse files
fluffy314cursoragent
authored andcommitted
fix(autoresearch): certify independent reconstruction attempts
Preserve theorem-local context while redacting only target bodies, classify every bounded OProver and Lean attempt, and gate hardware route certification on reconstructable canaries. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dc44e19 commit 772d3a1

3 files changed

Lines changed: 782 additions & 0 deletions

File tree

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
"""Fail-closed, independently-auditable OProver theorem reconstruction.
2+
3+
The target proof body is never included in the prompt or persisted package.
4+
OProver output remains untrusted until the exact project Lean executable accepts
5+
it in a temporary source file.
6+
"""
7+
from __future__ import annotations
8+
9+
import hashlib
10+
import json
11+
import re
12+
import subprocess
13+
import tempfile
14+
from dataclasses import asdict, dataclass
15+
from enum import Enum
16+
from pathlib import Path
17+
from typing import Callable, Protocol
18+
19+
20+
class ReconstructionStatus(str, Enum):
21+
PROVIDER_ADAPTER_FAILED = "PROVIDER/ADAPTER_FAILED"
22+
NO_CANDIDATE = "NO_CANDIDATE"
23+
LEAN_REJECTED = "LEAN_REJECTED"
24+
SEARCH_EXHAUSTED = "SEARCH_EXHAUSTED"
25+
INDEPENDENTLY_VERIFIED = "INDEPENDENTLY_VERIFIED"
26+
27+
28+
_DECLARATION = re.compile(
29+
r"(?m)^(?:@\[.*\]\s*)*(?:(?:private|protected|noncomputable)\s+)*"
30+
r"(?:theorem|lemma|def|abbrev|example|instance|structure|class|inductive)\s+"
31+
)
32+
_FENCE = re.compile(r"```(?:lean4?|Lean4?)?\s*(.*?)```", re.DOTALL)
33+
_THINK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
34+
35+
36+
def _sha256(value: str | bytes) -> str:
37+
if isinstance(value, str):
38+
value = value.encode()
39+
return hashlib.sha256(value).hexdigest()
40+
41+
42+
@dataclass(frozen=True)
43+
class ReconstructionPackage:
44+
schema_version: int
45+
theorem_id: str
46+
source_path: str
47+
namespace: str
48+
imports: tuple[str, ...]
49+
target_header: str
50+
preserved_context: str
51+
prompt_context: str
52+
retrieval_refs: tuple[str, ...]
53+
retrieval_context: tuple[str, ...]
54+
source_hash: str
55+
environment_hash: str
56+
theorem_hash: str
57+
dependency_prefix_hash: str
58+
original_proof_hash: str
59+
60+
def prompt_source(self) -> str:
61+
return f"{self.prompt_context}{self.target_header}\n"
62+
63+
def public_record(self) -> dict:
64+
body = asdict(self)
65+
# The original proof hash binds the source without exposing the body.
66+
return body
67+
68+
69+
@dataclass(frozen=True)
70+
class ProviderCandidate:
71+
text: str
72+
stop_reason: str = "unknown"
73+
prompt_tokens: int = 0
74+
completion_tokens: int = 0
75+
76+
77+
class ReconstructionProvider(Protocol):
78+
def generate(
79+
self, *, prompt: str, seed: int, max_tokens: int,
80+
) -> ProviderCandidate: ...
81+
82+
83+
@dataclass(frozen=True)
84+
class LeanResult:
85+
accepted: bool
86+
output: str
87+
timed_out: bool = False
88+
89+
90+
@dataclass(frozen=True)
91+
class ReconstructionAttempt:
92+
index: int
93+
seed: int
94+
status: str
95+
raw_stop_reason: str
96+
raw_output_hash: str
97+
parser_result: str
98+
candidate_hashes: tuple[str, ...]
99+
selected_candidate_hash: str
100+
lean_output: str
101+
lean_timed_out: bool
102+
prompt_hash: str
103+
prompt_tokens: int
104+
completion_tokens: int
105+
106+
107+
@dataclass(frozen=True)
108+
class ReconstructionResult:
109+
status: str
110+
package_hash: str
111+
verified_candidate_hash: str
112+
attempts: tuple[ReconstructionAttempt, ...]
113+
114+
115+
def build_reconstruction_package(
116+
*,
117+
source_path: Path,
118+
project_root: Path,
119+
theorem_id: str,
120+
environment_hash: str,
121+
retrieval_refs: tuple[str, ...] = (),
122+
retrieval_context: tuple[str, ...] = (),
123+
prompt_context_chars: int = 48_000,
124+
) -> ReconstructionPackage:
125+
"""Preserve all declarations before the target and redact only its body."""
126+
source_path = Path(source_path).resolve()
127+
project_root = Path(project_root).resolve()
128+
source = source_path.read_text(encoding="utf-8")
129+
short_name = theorem_id.rsplit(".", 1)[-1]
130+
target = re.search(
131+
r"(?m)^(?P<indent>[ \t]*)(?:(?:private|protected)\s+)?"
132+
rf"(?P<kind>theorem|lemma)\s+(?:{re.escape(theorem_id)}|"
133+
rf"{re.escape(short_name)})\b",
134+
source,
135+
)
136+
if target is None:
137+
raise ValueError("RECONSTRUCTION_TARGET_NOT_FOUND")
138+
if target.group("indent"):
139+
raise ValueError("RECONSTRUCTION_TARGET_MUST_BE_TOP_LEVEL")
140+
assignment = source.find(":=", target.start())
141+
if assignment < 0:
142+
raise ValueError("RECONSTRUCTION_TARGET_ASSIGNMENT_NOT_FOUND")
143+
next_declaration = _DECLARATION.search(source, assignment + 2)
144+
body_end = next_declaration.start() if next_declaration else len(source)
145+
target_header = source[target.start():assignment + 2].rstrip()
146+
original_body = source[assignment + 2:body_end]
147+
if not original_body.strip():
148+
raise ValueError("RECONSTRUCTION_TARGET_BODY_EMPTY")
149+
150+
prefix = source[:target.start()]
151+
import_lines = tuple(re.findall(r"(?m)^import\s+.+$", prefix))
152+
namespaces = re.findall(r"(?m)^namespace\s+([A-Za-z0-9_'.]+)\s*$", prefix)
153+
namespace = ".".join(namespaces)
154+
if prompt_context_chars < 1:
155+
raise ValueError("PROMPT_CONTEXT_BUDGET_MUST_BE_POSITIVE")
156+
if len(prefix) <= prompt_context_chars:
157+
prompt_context = prefix
158+
else:
159+
cutoff = len(prefix) - prompt_context_chars
160+
declaration = _DECLARATION.search(prefix, cutoff)
161+
context_start = declaration.start() if declaration else cutoff
162+
prompt_context = (
163+
"\n".join(import_lines)
164+
+ "\n\n"
165+
+ prefix[context_start:].lstrip()
166+
)
167+
relative = source_path.relative_to(project_root)
168+
dependency_prefix_hash = _sha256(prefix)
169+
theorem_hash = _sha256(target_header)
170+
package = ReconstructionPackage(
171+
schema_version=2,
172+
theorem_id=theorem_id,
173+
source_path=str(relative),
174+
namespace=namespace,
175+
imports=import_lines,
176+
target_header=target_header,
177+
preserved_context=prefix,
178+
prompt_context=prompt_context,
179+
retrieval_refs=tuple(retrieval_refs),
180+
retrieval_context=tuple(retrieval_context),
181+
source_hash=_sha256(source),
182+
environment_hash=environment_hash,
183+
theorem_hash=theorem_hash,
184+
dependency_prefix_hash=dependency_prefix_hash,
185+
original_proof_hash=_sha256(original_body),
186+
)
187+
if original_body.strip() in package.prompt_source():
188+
raise RuntimeError("TARGET_PROOF_LEAK_DETECTED")
189+
return package
190+
191+
192+
def build_native_prompt(
193+
package: ReconstructionPackage,
194+
*,
195+
previous_attempt: str = "",
196+
compiler_feedback: str = "",
197+
) -> str:
198+
"""Use the prompt shape published with OProver, with bounded feedback."""
199+
retrieval = "\n".join(package.retrieval_context) or "(none available)"
200+
return (
201+
"**Current Task:**\n"
202+
"Complete the following Lean 4 code. Return a proof beginning with `by` "
203+
"in a Lean fence or as plain Lean.\n\n"
204+
f"```lean4\n{package.prompt_source()}```\n\n"
205+
"**Relevant retrieved declarations (not target proofs):**\n"
206+
f"{retrieval}\n\n"
207+
"Before producing the Lean 4 proof, provide a concise proof plan. "
208+
"Use the preserved imports, namespace, local definitions, and earlier "
209+
"certified dependency lemmas. Do not restate or modify the theorem.\n\n"
210+
f"**Previous Failed Attempt:**\n```lean4\n{previous_attempt}\n```\n\n"
211+
f"**Error Messages:**\n{compiler_feedback}\n"
212+
)
213+
214+
215+
def extract_lean_candidates(text: str) -> tuple[str, ...]:
216+
"""Extract proof terms from fenced and plain OProver responses."""
217+
clean = _THINK.sub("", text).strip()
218+
regions = [match.group(1).strip() for match in _FENCE.finditer(clean)]
219+
regions.append(_FENCE.sub("", clean).strip())
220+
options: list[str] = []
221+
for region in regions:
222+
if not region:
223+
continue
224+
if ":=" in region:
225+
options.append(region.rsplit(":=", 1)[1].strip())
226+
markers = tuple(match.start() for match in re.finditer(r"(?m)(?:^|\s)\bby\b", region))
227+
options.extend(region[index:].strip() for index in reversed(markers))
228+
if region.startswith(("exact ", "simpa", "simp", "aesop", "omega", "linarith")):
229+
options.append("by\n " + region)
230+
normalized = []
231+
for option in options:
232+
option = option.strip()
233+
if option.startswith("by") and option not in normalized:
234+
normalized.append(option)
235+
return tuple(normalized)
236+
237+
238+
def verify_candidate_with_project_lean(
239+
package: ReconstructionPackage,
240+
candidate: str,
241+
project_root: Path,
242+
*,
243+
timeout_seconds: int = 120,
244+
) -> LeanResult:
245+
"""Insert one candidate into one isolated theorem and run project Lean."""
246+
with tempfile.TemporaryDirectory(prefix="kakeya-reconstruct-") as raw:
247+
source = Path(raw) / "Reconstruction.lean"
248+
source.write_text(
249+
f"{package.preserved_context}{package.target_header}\n{candidate}\n",
250+
encoding="utf-8",
251+
)
252+
try:
253+
result = subprocess.run(
254+
["lake", "env", "lean", str(source)],
255+
cwd=Path(project_root),
256+
stdout=subprocess.PIPE,
257+
stderr=subprocess.STDOUT,
258+
text=True,
259+
timeout=timeout_seconds,
260+
check=False,
261+
)
262+
except subprocess.TimeoutExpired as exc:
263+
output = str(exc.stdout or exc.stderr or "")
264+
return LeanResult(False, output[-8000:], True)
265+
return LeanResult(result.returncode == 0, result.stdout[-8000:], False)
266+
267+
268+
def run_pass_at_k(
269+
*,
270+
package: ReconstructionPackage,
271+
provider: ReconstructionProvider,
272+
project_root: Path,
273+
k: int,
274+
base_seed: int = 0,
275+
max_tokens: int = 4096,
276+
lean_verify: Callable[
277+
[ReconstructionPackage, str, Path], LeanResult
278+
] | None = None,
279+
) -> ReconstructionResult:
280+
if k < 1:
281+
raise ValueError("PASS_AT_K_REQUIRES_POSITIVE_K")
282+
verifier = lean_verify or (
283+
lambda pkg, candidate, root: verify_candidate_with_project_lean(
284+
pkg, candidate, root,
285+
)
286+
)
287+
attempts: list[ReconstructionAttempt] = []
288+
previous = ""
289+
feedback = ""
290+
package_hash = _sha256(json.dumps(
291+
package.public_record(), sort_keys=True, separators=(",", ":"),
292+
))
293+
for index in range(k):
294+
seed = base_seed + index
295+
prompt = build_native_prompt(
296+
package, previous_attempt=previous, compiler_feedback=feedback,
297+
)
298+
prompt_hash = _sha256(prompt)
299+
try:
300+
generated = provider.generate(
301+
prompt=prompt, seed=seed, max_tokens=max_tokens,
302+
)
303+
except Exception as exc:
304+
attempts.append(ReconstructionAttempt(
305+
index, seed, ReconstructionStatus.PROVIDER_ADAPTER_FAILED.value,
306+
"provider_exception", "", "provider_failed", (), "",
307+
f"{type(exc).__name__}:{str(exc)[:500]}", False, prompt_hash, 0, 0,
308+
))
309+
return ReconstructionResult(
310+
ReconstructionStatus.PROVIDER_ADAPTER_FAILED.value,
311+
package_hash, "", tuple(attempts),
312+
)
313+
options = extract_lean_candidates(generated.text)
314+
hashes = tuple(_sha256(item) for item in options)
315+
if not options:
316+
attempts.append(ReconstructionAttempt(
317+
index, seed, ReconstructionStatus.NO_CANDIDATE.value,
318+
generated.stop_reason, _sha256(generated.text), "no_lean_proof",
319+
(), "", "", False, prompt_hash, generated.prompt_tokens,
320+
generated.completion_tokens,
321+
))
322+
previous = generated.text[-4000:]
323+
feedback = "No Lean proof beginning with `by` was extracted."
324+
continue
325+
selected_hash = ""
326+
last = LeanResult(False, "")
327+
for option, candidate_hash in zip(options, hashes):
328+
last = verifier(package, option, Path(project_root))
329+
if last.accepted:
330+
selected_hash = candidate_hash
331+
attempts.append(ReconstructionAttempt(
332+
index, seed, ReconstructionStatus.INDEPENDENTLY_VERIFIED.value,
333+
generated.stop_reason, _sha256(generated.text),
334+
f"extracted:{len(options)}", hashes, selected_hash, "",
335+
False, prompt_hash, generated.prompt_tokens,
336+
generated.completion_tokens,
337+
))
338+
return ReconstructionResult(
339+
ReconstructionStatus.INDEPENDENTLY_VERIFIED.value,
340+
package_hash, selected_hash, tuple(attempts),
341+
)
342+
attempts.append(ReconstructionAttempt(
343+
index, seed, ReconstructionStatus.LEAN_REJECTED.value,
344+
generated.stop_reason, _sha256(generated.text),
345+
f"extracted:{len(options)}", hashes, "", last.output,
346+
last.timed_out, prompt_hash, generated.prompt_tokens,
347+
generated.completion_tokens,
348+
))
349+
previous = options[0][-4000:]
350+
feedback = last.output[-4000:] or "Lean rejected the candidate."
351+
return ReconstructionResult(
352+
ReconstructionStatus.SEARCH_EXHAUSTED.value,
353+
package_hash, "", tuple(attempts),
354+
)

0 commit comments

Comments
 (0)