|
| 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