| """Batched Lean verifier (Phase 3). |
| |
| One `lean --json` invocation verifies a whole batch of candidate proof attempts: |
| each candidate is emitted as its own theorem in a single generated file, and the |
| per-theorem diagnostics (closed / invalid / resulting goals) are parsed back. |
| |
| Measured on this machine: ~5 s per invocation for Mathlib import, and the |
| per-candidate cost is essentially free (24 candidates in 5.2 s vs 4 in 5.8 s), |
| which is what makes search affordable without a REPL. |
| """ |
| import json |
| import os |
| import re |
| import subprocess |
| import tempfile |
| import threading |
| import time |
|
|
| HOME = os.path.expanduser('~') |
| |
| |
| |
| LEAN_BIN = os.environ.get('LEANOAR_LEAN_BIN', |
| f'{HOME}/leanprover/toolchains/lean-4.35.0-rc2-linux/bin/lean') |
| MATHLIB = os.environ.get('LEANOAR_MATHLIB', f'{HOME}/leanprover/mathlib4') |
|
|
|
|
| def lean_env(): |
| env = dict(os.environ) |
| pkgs = [] |
| pdir = f'{MATHLIB}/.lake/packages' |
| if os.path.isdir(pdir): |
| for p in sorted(os.listdir(pdir)): |
| lib = f'{pdir}/{p}/.lake/build/lib/lean' |
| if os.path.isdir(lib): |
| pkgs.append(lib) |
| env['LEAN_PATH'] = ':'.join([f'{MATHLIB}/.lake/build/lib/lean'] + pkgs) |
| env['PATH'] = os.path.dirname(LEAN_BIN) + ':' + env.get('PATH', '') |
| return env |
|
|
|
|
| def split_decl(statement): |
| """`theorem foo (x : Nat) : x = x := sorry` -> ('foo', ' (x : Nat) : x = x') |
| |
| The trailing `:= sorry` / `:= by sorry` is dropped so we can append `:= by ...`. |
| """ |
| s = statement.strip() |
| s = re.sub(r':=\s*(by\s+)?sorry\s*$', '', s).rstrip() |
| m = re.match(r'(?:theorem|lemma|example)\s+([A-Za-z0-9_.\'«»!?]+)', s) |
| if m: |
| name = m.group(1) |
| rest = s[m.end():] |
| else: |
| name = None |
| rest = s |
| return name, rest |
|
|
|
|
| def indent(tactic, first=2, cont=4): |
| lines = [l.strip() for l in tactic.strip().split('\n') if l.strip()] |
| if not lines: |
| return '' |
| out = [' ' * first + lines[0]] |
| for l in lines[1:]: |
| out.append(' ' * cont + l) |
| return '\n'.join(out) |
|
|
|
|
| |
| |
| MISSING_IMPORT_RE = re.compile( |
| r'unknown (?:identifier|constant|namespace|declaration|tactic|module|package)' |
| r'|object file .* does not exist' |
| r'|failed to synthesize', re.I) |
|
|
|
|
| def min_imports(verifier, header, statement, tag='minimp'): |
| """Modules that suffice to elaborate `statement` on this machine, via Mathlib's `#min_imports`. |
| |
| One full `import Mathlib` call per problem (~3.4 s) that pays for itself at once: every |
| later verification call then imports ~1.7 s of modules instead of ~3.4 s (measured 3.41 s -> |
| 1.74 s per call, 1.9x). Returns None when the answer is unusable, in which case callers must |
| keep the original header. |
| """ |
| name, rest = split_decl(statement) |
| hdr = header.strip() |
| if not hdr.startswith('import '): |
| hdr = 'import Mathlib\n' + hdr |
| src = [hdr, '', f'theorem {name or "goal"}_{tag}{rest} := by sorry', '', |
| '#min_imports', ''] |
| try: |
| r = verifier.run_file('\n'.join(src) + '\n', name=f'{name or "goal"}_{tag}') |
| except Exception: |
| return None |
| mods = [] |
| for m in r['messages']: |
| mods += re.findall(r'public import ([A-Za-z0-9_.«»]+)', str(m.get('data', ''))) |
| mods = list(dict.fromkeys(mods)) |
| if r['returncode'] != 0 or not any(m.get('severity') == 'information' for m in r['messages']): |
| return None |
| if len(mods) > 12 or 'Mathlib' in mods: |
| return None |
| return mods |
|
|
|
|
| def narrowed_header(verifier, header, statement): |
| """Narrowed replacement for `import Mathlib`, or None when none can be built. |
| |
| The statement's minimal imports (`#min_imports`), always plus `Mathlib.Tactic` because the |
| injected automation table (`ring`, `omega`, `simp_all`, ...) and most model tactics need it, |
| plus the original `open ...` lines. Measured 3.41 s -> 1.74 s per `lean --json` call. |
| """ |
| mods = min_imports(verifier, header, statement) |
| if mods is None: |
| return None |
| opens = [l for l in header.splitlines() if not l.strip().startswith('import ')] |
| imports = [f'import {m}' for m in mods] |
| if 'Mathlib.Tactic' not in mods: |
| imports.append('import Mathlib.Tactic') |
| return '\n'.join(imports + opens).strip() |
|
|
|
|
| class LeanBatchVerifier: |
| """Runs one `lean --json` per batch of candidate proof attempts.""" |
|
|
| def __init__(self, workdir=None, timeout=600, keep_files=True, verbose=False): |
| self.workdir = workdir or f'{HOME}/leanprover/work/search' |
| os.makedirs(self.workdir, exist_ok=True) |
| self.timeout = timeout |
| self.keep_files = keep_files |
| self.verbose = verbose |
| self._lock = threading.Lock() |
| self.n_calls = 0 |
| self.lean_seconds = 0.0 |
| self.self_check() |
|
|
| def self_check(self): |
| """Warm the disk cache once and make sure a known proof compiles.""" |
| t0 = time.time() |
| r = self.run_file('import Mathlib\ntheorem _selfcheck : 1 + 1 = 2 := by norm_num\n', |
| name='selfcheck') |
| self.warm_seconds = time.time() - t0 |
| if r['returncode'] != 0 or r['messages']: |
| raise RuntimeError(f'verifier self-check failed: {r["messages"][:2]}') |
|
|
| |
| def run_file(self, source, name='batch', timeout=None): |
| path = os.path.join(self.workdir, f'{name}.lean') |
| with open(path, 'w') as f: |
| f.write(source) |
| t0 = time.time() |
| try: |
| p = subprocess.run([LEAN_BIN, '--json', path], |
| cwd=self.workdir, env=lean_env(), timeout=timeout or self.timeout, |
| capture_output=True, text=True) |
| out, rc, timed_out = p.stdout, p.returncode, False |
| except subprocess.TimeoutExpired as e: |
| out, rc, timed_out = (e.stdout or b'').decode('utf-8', 'replace') if isinstance(e.stdout, bytes) else (e.stdout or ''), -1, True |
| dt = time.time() - t0 |
| with self._lock: |
| self.n_calls += 1 |
| self.lean_seconds += dt |
| msgs = [] |
| for line in out.splitlines(): |
| line = line.strip() |
| if line.startswith('{'): |
| try: |
| msgs.append(json.loads(line)) |
| except json.JSONDecodeError: |
| pass |
| if not self.keep_files: |
| os.remove(path) |
| return {'messages': msgs, 'returncode': rc, 'timed_out': timed_out, |
| 'seconds': dt, 'path': path, 'stdout': out if rc == -1 or not msgs else ''} |
|
|
| |
| def verify(self, header, items, name='batch', timeout=None): |
| """items: list of dicts {name, decl_rest, tactics: [str, ...]} |
| (`decl_rest` = the piece after the theorem name, e.g. " (x : Nat) : x = x") |
| |
| Returns list of per-item dicts: {status: closed|open|invalid|timeout, |
| goals, error, seconds} aligned with `items`. |
| """ |
| src = [header.strip(), ''] |
| if not src[0].startswith('import '): |
| src.insert(0, 'import Mathlib') |
| nxt = sum(c.count('\n') + 1 for c in src) + 1 |
| spans = [] |
| for i, it in enumerate(items): |
| tname = it.get('name') or f'goal_{i}' |
| |
| uniq = f'{re.sub(r"[^A-Za-z0-9_]", "_", tname)}_v{i}' |
| head = f'theorem {uniq}{it["decl_rest"]} := by' |
| start = nxt |
| src.append(head) |
| nxt += head.count('\n') + 1 |
| for t in it['tactics']: |
| block = indent(t) |
| src.append(block) |
| nxt += block.count('\n') + 1 |
| end = nxt - 1 |
| src.append('') |
| nxt += 1 |
| spans.append((start, end, i, uniq)) |
| source = '\n'.join(src) + '\n' |
| r = self.run_file(source, name=name, timeout=timeout) |
| out = [{'status': 'timeout', 'goals': None, 'error': 'lean timeout'} for _ in items] |
| if r['timed_out']: |
| return out, r |
| |
| |
| |
| |
| starts = [s for s, _e, _i, _u in spans] |
| buckets = [[] for _ in items] |
| other = [] |
| for m in r['messages']: |
| ln = m.get('pos', {}).get('line', -1) |
| idx = None |
| for j, s in enumerate(starts): |
| if ln >= s: |
| idx = spans[j][2] |
| else: |
| break |
| if idx is None: |
| other.append(m) |
| else: |
| buckets[idx].append(m) |
| for i, msgs in enumerate(buckets): |
| errs = [m for m in msgs if m.get('severity') == 'error'] |
| sorry = [m for m in msgs if 'sorry' in str(m.get('data', '')).lower()] |
| hard = [m for m in errs if m.get('kind') != 'Tactic.unsolvedGoals'] |
| goal_msgs = [m for m in errs if m.get('kind') == 'Tactic.unsolvedGoals'] |
| if hard: |
| out[i] = {'status': 'invalid', 'goals': None, |
| 'error': str(hard[0].get('data'))[:400]} |
| elif sorry: |
| out[i] = {'status': 'invalid', 'goals': None, 'error': 'uses sorry'} |
| elif goal_msgs: |
| g = str(goal_msgs[-1].get('data', '')).replace('unsolved goals\n', '').strip() |
| out[i] = {'status': 'open', 'goals': g, 'error': None} |
| else: |
| out[i] = {'status': 'maybe-closed', 'goals': None, 'error': None} |
| return out, r |
|
|
| def verify_many(self, header, items, chunk=24, parallel=4, name='batch', timeout=None): |
| """Verify `items` in chunks of `chunk`, running up to `parallel` chunks at once. |
| |
| Returns (results, raws): results aligned with `items`; raws per chunk. Measured |
| motivation: >90% of search wall-clock is Lean elaboration, and every chunk is an |
| independent `lean --json` process (~150 MB RSS), so concurrency on a 16-core box |
| is the cheapest remaining speed-up. |
| """ |
| chunks = [items[i:i + chunk] for i in range(0, len(items), chunk)] |
| if parallel <= 1 or len(chunks) <= 1: |
| out, raws = [], [] |
| for i, c in enumerate(chunks): |
| r, raw = self.verify(header, c, name=f'{name}_c{i}', timeout=timeout) |
| out.extend(r) |
| raws.append(raw) |
| return out, raws |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| results, raws = [None] * len(chunks), [None] * len(chunks) |
| with ThreadPoolExecutor(max_workers=min(parallel, len(chunks))) as ex: |
| futs = {ex.submit(self.verify, header, c, f'{name}_c{i}', timeout): i |
| for i, c in enumerate(chunks)} |
| for f in as_completed(futs): |
| i = futs[f] |
| results[i], raws[i] = f.result() |
| out = [x for r in results for x in r] |
| return out, raws |
|
|
| def confirm(self, header, name, decl_rest, tactics, timeout=None): |
| """Standalone re-check of ONE claimed proof. |
| |
| A batch entry is only 'maybe-closed' (absence of errors is not proof of |
| success: syntax errors can land outside the item's line span). This compiles |
| the proof alone and demands: exit code 0, no error messages, no `sorry`. |
| """ |
| hdr = header.strip() if header.strip().startswith('import ') else f'import Mathlib\n{header.strip()}' |
| uniq = f'{re.sub(r"[^A-Za-z0-9_]", "_", name or "goal")}_confirm' |
| src = [hdr, '', f'theorem {uniq}{decl_rest} := by'] |
| for t in tactics: |
| src.append(indent(t)) |
| src.append('') |
| r = self.run_file('\n'.join(src) + '\n', name=f'{uniq}_confirm', timeout=timeout) |
| msgs = r['messages'] |
| errs = [m for m in msgs if m.get('severity') == 'error'] |
| sorry = [m for m in msgs if 'sorry' in str(m.get('data', '')).lower()] |
| ok = (r['returncode'] == 0) and not errs and not sorry and not r['timed_out'] |
| return {'ok': ok, 'returncode': r['returncode'], 'n_errors': len(errs), |
| 'sorry': bool(sorry), 'seconds': r['seconds'], |
| 'messages': [str(m.get('data'))[:200] for m in msgs[:3]]} |
|
|
| def initial_state(self, header, name, decl_rest, tactic='skip'): |
| """Get the initial goal text of a theorem by applying a no-op tactic.""" |
| items = [{'name': name, 'decl_rest': decl_rest, 'tactics': [tactic]}] |
| res, raw = self.verify(header, items, name=f'{name}_init') |
| r = res[0] |
| if r['status'] == 'open': |
| return r['goals'], raw |
| if r['status'] == 'maybe-closed': |
| return None, raw |
| return None, raw |
|
|
|
|
| if __name__ == '__main__': |
| v = LeanBatchVerifier() |
| print(f'warm self-check in {v.warm_seconds:.1f}s') |
| header = 'import Mathlib' |
| stmt = 'theorem t (n : ℕ) : n + 0 = n := sorry' |
| nm, rest = split_decl(stmt) |
| goal, raw = v.initial_state(header, nm, rest) |
| print('initial goal:', repr(goal)) |
| items = [{'name': nm, 'decl_rest': rest, 'tactics': [t]} |
| for t in ['simp', 'omega', 'bogus_tac', 'norm_num', 'rfl', 'induction n with | zero => simp | succ n ih => simp [Nat.add_succ]']] |
| res, raw2 = v.verify(header, items, name='t_batch') |
| for it, r in zip(items, res): |
| print(f' {it["tactics"][0][:44]:46s} -> {r["status"]:8s} {(r["error"] or r["goals"] or "")[:60]!r}') |
| print(f'lean calls={v.n_calls} lean_seconds={v.lean_seconds:.1f} ' |
| f'(init {raw["seconds"]:.1f}s + batch {raw2["seconds"]:.1f}s for {len(items)} candidates)') |
|
|