SilverSight/scripts/prove.py

381 lines
15 KiB
Python

#!/usr/bin/env python3
"""
prove.py — Lean proof-filling loop with 5-check verification pipeline.
Adapted from Peng et al. (2026) pipeline-math verify.sh:
1. SHA pin check — frozen theorem stubs haven't drifted
2. Banned keywords — no sorry/native_decide/admit/axiom in proof files
3. lake build clean — 0 errors, 0 unexpected warnings
4. #print axioms — proof depends only on {propext, Classical.choice, Quot.sound}
5. Discharge gate — @Frozen = @Proof := rfl (proof matches theorem type)
Metric: build_errors * 1000 + sorries (lower = better, 0 = perfect).
"""
import os, sys, time, json, subprocess, re, hashlib, shutil
from pathlib import Path
import urllib.request
# ── Config ──────────────────────────────────────────────────────────────
SILVERSIGHT_DIR = os.path.expanduser("~/SilverSight")
LEAN_FILE = os.environ.get("TARGET_FILE", "formal/CoreFormalism/Bind.lean")
LLM_MODEL = os.environ.get("LLM_MODEL", "phi4:14b")
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "http://100.92.88.64:11434/v1")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "sk-local")
TIME_BUDGET_S = int(os.environ.get("TIME_BUDGET_S", "600"))
BANNED_KEYWORDS = ["sorryAx", "native_decide", "admit", "unsafe",
"implemented_by", "ofReduceBool"]
ALLOWED_AXIOMS = {"propext", "Classical.choice", "Quot.sound"}
# ── LLM client ─────────────────────────────────────────────────────────
def llm_complete(prompt, system="You are a Lean 4 proof engineer."):
body = json.dumps({
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"max_tokens": 4096,
}).encode()
req = urllib.request.Request(
f"{LLM_BASE_URL}/chat/completions",
data=body,
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {LLM_API_KEY}"},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=120)
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
except Exception as e:
print(f"LLM error: {e}")
return None
# ─── Check 1: SHA pins ────────────────────────────────────────────────
def check_sha_pins(lean_file):
"""Check SHA-256 of file against pinned hash in scripts/frozen.sha256."""
pins_path = Path(SILVERSIGHT_DIR) / "scripts" / "frozen.sha256"
if not pins_path.exists():
return True, "no pins file (SKIP)"
rel_path = os.path.relpath(
os.path.join(SILVERSIGHT_DIR, lean_file),
SILVERSIGHT_DIR
)
content = pins_path.read_text()
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
if len(parts) == 2 and parts[1] == rel_path:
pinned = parts[0]
actual = sha256_of(os.path.join(SILVERSIGHT_DIR, lean_file))
if pinned == actual:
return True, f"SHA pin match: {rel_path}"
else:
return False, f"SHA pin MISMATCH {rel_path}: pinned={pinned[:16]} actual={actual[:16]}"
return True, "no pin for this file (SKIP)"
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as f:
h.update(f.read())
return h.hexdigest()
# ─── Check 2: Banned keywords ─────────────────────────────────────────
def check_banned_keywords(text, allow_sorry=False):
"""Check for banned keywords in Lean code. Returns (pass, message)."""
cleaned = strip_comments(text)
for kw in BANNED_KEYWORDS:
if re.search(r'\b' + re.escape(kw) + r'\b', cleaned):
return False, f"banned keyword: {kw}"
if not allow_sorry:
if re.search(r'\bsorry\b', cleaned):
return False, "banned keyword: sorry (not allowed in proof files)"
return True, "no banned keywords"
def strip_comments(s):
"""Strip Lean comments (-- line, /- ... -/ block)."""
out = []
i, n = 0, len(s)
depth = 0
while i < n:
two = s[i:i+2]
if depth == 0 and two == "--":
j = s.find("\n", i)
i = j if j != -1 else n
elif two == "/-":
depth += 1
i += 2
elif depth > 0 and two == "-/":
depth -= 1
i += 2
elif depth > 0:
i += 1
else:
out.append(s[i])
i += 1
return "".join(out)
# ─── Check 3: lake build ──────────────────────────────────────────────
def lake_build(target=None, timeout=120):
"""Run `lake build` and return (error_count, output)."""
cmd = ["lake", "build"]
if target:
cmd.append(target)
try:
result = subprocess.run(
cmd, cwd=SILVERSIGHT_DIR, capture_output=True, text=True,
timeout=timeout,
)
output = result.stdout + result.stderr
errors = output.count("error:")
warnings = output.count("warning:") - output.count("declaration uses .sorry.")
return errors, warnings, output
except subprocess.TimeoutExpired:
return 999, 0, "TIMEOUT"
except Exception as e:
return 999, 0, str(e)
# ─── Check 4: #print axioms ───────────────────────────────────────────
def check_axioms(module_name):
"""Check that a module's axioms are within the allowed set."""
ax_file = os.path.join(SILVERSIGHT_DIR, ".lake", "tmp_ax_check.lean")
os.makedirs(os.path.dirname(ax_file), exist_ok=True)
with open(ax_file, "w") as f:
f.write(f"import {module_name}\n")
f.write(f"#print axioms {module_name}\n")
try:
result = subprocess.run(
["lake", "env", "lean", ax_file],
cwd=SILVERSIGHT_DIR, capture_output=True, text=True, timeout=60,
)
output = result.stdout + result.stderr
os.remove(ax_file)
# Parse axiom line
m = re.search(r"'([^']+)' depends on axioms:\s*\[([^\]]+)\]", output)
if m:
axioms = set(a.strip() for a in m.group(2).split(",") if a.strip())
bad = axioms - ALLOWED_AXIOMS
if bad:
return False, f"unexpected axioms: {bad}"
return True, f"axioms OK: {axioms}"
return True, "no axiom line (module might be empty)"
except Exception as e:
return False, f"axiom check error: {e}"
# ─── Check 5: Discharge gate ──────────────────────────────────────────
def check_discharge_gate(lean_file):
"""Check that Discharge.lean compiles — proof ↔ theorem type gate."""
discharge_path = os.path.join(
os.path.dirname(os.path.join(SILVERSIGHT_DIR, lean_file)),
"Discharge.lean"
)
if not os.path.exists(discharge_path):
return True, "no Discharge.lean (SKIP)"
discharge_module = "Discharge"
err, warn, out = lake_build(target=discharge_module, timeout=60)
if err == 0:
return True, "Discharge gate PASS"
return False, f"Discharge gate FAIL ({err} errors)"
# ── Proof filling ──────────────────────────────────────────────────────
def count_sorries(text):
cleaned = strip_comments(text)
return len(re.findall(r'\bsorry\b', cleaned))
def get_sorry_context(filepath):
path = Path(os.path.join(SILVERSIGHT_DIR, filepath))
if not path.exists():
return None, None, 0, "file not found"
text = path.read_text()
n = count_sorries(text)
if n == 0:
return text, None, 0, "no sorries found"
# Find the first theorem/lemma that contains a sorry
blocks = list(re.finditer(
r'(theorem\s+\w+|lemma\s+\w+|def\s+\w+)[\s\S]*?(?=\b(theorem|lemma|def)\b|\Z)',
text
))
for m in blocks:
block = m.group(0)
if 'sorry' in block:
return text, block, n, None
return text, None, n, "could not find sorry block"
def fill_sorry(filepath, proof_text):
path = Path(os.path.join(SILVERSIGHT_DIR, filepath))
text = path.read_text()
new_text = text.replace("sorry", proof_text.strip(), 1)
path.write_text(new_text)
return new_text
def restore_file(filepath, original):
path = Path(os.path.join(SILVERSIGHT_DIR, filepath))
path.write_text(original)
# ── Main experiment loop ────────────────────────────────────────────────
def experiment():
print("=" * 60)
print(" AutoResearch: Lean Proof Filling (5-check pipeline)")
print(f" Model: {LLM_MODEL}")
print(f" File: {LEAN_FILE}")
print(f" Budget: {TIME_BUDGET_S}s")
print("=" * 60)
t_start = time.time()
t_end = t_start + TIME_BUDGET_S
results = []
best_score = 999999
initial_sorries = count_sorries(Path(os.path.join(SILVERSIGHT_DIR, LEAN_FILE)).read_text())
print(f" Initial sorries: {initial_sorries}")
print()
while time.time() < t_end:
iteration = len(results) + 1
t_iter = time.time()
text, context, n, err = get_sorry_context(LEAN_FILE)
if err:
current = count_sorries(Path(os.path.join(SILVERSIGHT_DIR, LEAN_FILE)).read_text())
if current == 0:
print(f"\n*** All {initial_sorries} sorries filled! ***")
break
print(f"\n[{iteration}] {err}")
break
# Ask LLM to fill the sorry
sys_prompt = "You are a Lean 4 proof engineer. Write complete, compilable proofs."
prompt = f"""Fill the `sorry` in this Lean 4 theorem.
Write ONLY the proof body — the text that replaces `sorry` after `:= by`.
Do NOT repeat the theorem header. Use `by` block syntax, not `begin`/`end`.
Output raw Lean code only, no markdown.
{context}
Proof body to replace `sorry`:"""
proof = llm_complete(prompt, sys_prompt)
if not proof:
print(f" [{iteration}] LLM returned nothing, skipping")
results.append({"iteration": iteration, "error": "no response"})
continue
# Post-process LLM output
proof = proof.strip()
if proof.startswith("```"):
proof = proof.split("```")[1] if "```" in proof[3:] else proof
if proof.startswith("lean"):
proof = proof[4:]
proof = proof.strip().rstrip("`").strip()
if proof.startswith("theorem") or proof.startswith("lemma") or proof.startswith("def"):
lines = proof.split("\n")
for i, line in enumerate(lines):
if line.strip().startswith(":= by"):
proof = "\n".join(lines[i+1:])
break
else:
proof = lines[-1]
# Apply proof
original = Path(os.path.join(SILVERSIGHT_DIR, LEAN_FILE)).read_text()
fill_sorry(LEAN_FILE, proof)
# ── 5-check pipeline ────────────────────────────────────────
checks_passed = True
new_text = Path(os.path.join(SILVERSIGHT_DIR, LEAN_FILE)).read_text()
# Check 1: SHA pins
pin_ok, pin_msg = check_sha_pins(LEAN_FILE)
if not pin_ok:
print(f" [{iteration}] CHECK 1 FAIL: {pin_msg}")
checks_passed = False
# Check 2: Banned keywords
kw_ok, kw_msg = check_banned_keywords(new_text, allow_sorry=False)
if not kw_ok:
print(f" [{iteration}] CHECK 2 FAIL: {kw_msg}")
checks_passed = False
# Check 3: lake build
build_errors, build_warnings, build_out = lake_build(timeout=120)
current_sorries = count_sorries(new_text)
elapsed = time.time() - t_iter
score = build_errors * 1000 + current_sorries
status = "KEEP" if checks_passed and score < best_score else "DISCARD"
if status == "KEEP":
# Check 4: #print axioms (only if build passed)
if build_errors == 0:
module_name = LEAN_FILE.replace("/", ".").replace(".lean", "")
ax_ok, ax_msg = check_axioms(module_name)
if not ax_ok:
print(f" [{iteration}] CHECK 4 FAIL: {ax_msg}")
checks_passed = False
status = "DISCARD"
# Check 5: Discharge gate
if checks_passed:
dg_ok, dg_msg = check_discharge_gate(LEAN_FILE)
if not dg_ok:
print(f" [{iteration}] CHECK 5 FAIL: {dg_msg}")
checks_passed = False
status = "DISCARD"
if status == "KEEP":
best_score = score
subprocess.run(
["git", "add", "-f", LEAN_FILE],
cwd=SILVERSIGHT_DIR, capture_output=True
)
subprocess.run(
["git", "commit", "-m",
f"autoresearch: {build_errors} err/{current_sorries} sorries (iter {iteration})"],
cwd=SILVERSIGHT_DIR, capture_output=True
)
if status == "DISCARD":
restore_file(LEAN_FILE, original)
checks_str = ""
if not checks_passed:
checks_str = " CHECKS FAILED"
print(f" [{iteration:3d}] build_err={build_errors} sorries={current_sorries:3d}"
f" elapsed={elapsed:.1f}s [{status}]{checks_str}")
results.append({
"iteration": iteration,
"sorries": current_sorries,
"build_errors": build_errors,
"status": status,
"checks_passed": checks_passed,
"elapsed_s": round(elapsed, 1),
})
if current_sorries == 0 and build_errors == 0:
print(f"\n*** ALL PROOFS COMPLETE! ***")
break
total = time.time() - t_start
print(f"\n{'='*60}")
print(f" Summary: {len(results)} iterations, {total:.0f}s")
print(f" Best score (errors*1000+sorries): {best_score}")
print(f" Model: {LLM_MODEL}")
print(f"{'='*60}")
if __name__ == "__main__":
experiment()