Download code/leanoar/lean_batch.py from Duoia/duotactic: direct link, hf CLI and curl.
- Browser
- Download file 14.7 kB
-
https://huggingface.co/Duoia/duotactic/resolve/main/code/leanoar/lean_batch.py
- Command line
-
hf download hf://Duoia/duotactic/code/leanoar/lean_batch.py
-
curl -L -o lean_batch.py https://huggingface.co/Duoia/duotactic/resolve/main/code/leanoar/lean_batch.py
14.7 kB
| """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('~') | |
| # Overridable so this package can run outside the author's machine layout: | |
| # export LEANOAR_LEAN_BIN=/path/to/lean (the `lean` binary of the 4.35.0-rc2 toolchain) | |
| # export LEANOAR_MATHLIB=/path/to/mathlib4 (checkout with a prebuilt .lake cache) | |
| 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) | |
| # Errors that may mean "this file lacked an import" rather than "this tactic is wrong". | |
| # The narrow-import path re-checks such candidates against the full `import Mathlib`. | |
| 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 '): # miniF2F headers carry only `open ...` | |
| 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: # a broken probe must never break the search | |
| 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 # the probe itself did not run to completion | |
| if len(mods) > 12 or 'Mathlib' in mods: | |
| return None # no saving over `import Mathlib`: keep the original | |
| return mods # may be [] (statement needs almost nothing) | |
| 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]}') | |
| # ---------- raw ---------- | |
| 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 ''} | |
| # ---------- batch API ---------- | |
| 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 '): # miniF2F headers carry only `open ...` | |
| src.insert(0, 'import Mathlib') | |
| nxt = sum(c.count('\n') + 1 for c in src) + 1 # line number of the next chunk | |
| spans = [] | |
| for i, it in enumerate(items): | |
| tname = it.get('name') or f'goal_{i}' | |
| # unique declaration name per item | |
| 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 | |
| # Assign every message to the LAST item that started at or before its line. | |
| # (Requiring strict membership mis-assigns syntax errors, which Lean reports | |
| # on the line after the offending tactic, and silently turns them into | |
| # "no errors => closed" false positives.) | |
| 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 # theorem closed by a no-op?! (trivially true goal) | |
| 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)') | |