File size: 10,348 Bytes
63f41a6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | #!/usr/bin/env python
"""Elaborated-type hash of formal statements (plan v3 Phase 0a item 5, first bullet).
Why: token/alpha hashes (p2_common.statement_sha / alpha_sha) are blind to hypothesis order, notation
(`x β Finset.range n` vs `x < n`-style rewrites that elaborate to the same Expr), implicit coercion spelling, and
`open` differences. The elaborated type of `theorem _align_ref <sig> := by sorry` in the pinned Lean env is
not. Expr uses de Bruijn indices for bound variables, so binder names never reach the hash.
What is hashed (all decided in Lean, `LEAN_DUMP`, then canonicalised and hashed in pure Python):
* the statement type is split with `forallTelescope` into binders and a conclusion;
* instance-implicit binders are DROPPED, and so are instance-implicit ARGUMENTS of every application
(`getFunInfo` paramInfo), so `@HAdd.hAdd β β β inst a b` is `HAdd.hAdd β β β a b`;
* data binders (non-Prop) keep their order (later types may depend on earlier ones) and are named by that
ordinal `v0, v1, ...`; Prop binders (hypotheses) are serialised with the variables as `v_k`, and the
hypothesis list is SORTED, so reordering hypotheses does not change the hash;
* universe levels are dropped; binder names/infos inside terms are dropped; `mdata` is stripped; numerals
keep their type argument (`OfNat.ofNat β 2` differs from `OfNat.ofNat β 2`).
Known blind spots (by design, documented): permuting the order of data variables that are interchangeable is not
canonicalised; hypotheses that mention other hypotheses (proof-dependent types) are serialised with `?h`.
# unit-testable pure parts: parse_dump, canonical_form, elab_sha, build_lean_prelude
# <=50 statements validation (2 Lean workers max):
.venv/bin/python scripts/p2/supply/elab_hash.py --in .tmp/p2/problems/minif2f.jsonl --out .tmp/p2/supply/elab_minif2f.jsonl --limit 50 --workers 2
# FULL RUN (GPU-free window only; ~0.5-3 s/statement/worker after header warm-up; resumable by --out):
.venv/bin/python scripts/p2/supply/elab_hash.py --in <set>.clean.jsonl --out <set>.elab.jsonl --workers 2
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any, Iterable, Optional
REPO = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO))
MARK = "ALIGN_ELAB_HASH "
NS = "AlignElabHash"
REF = "_align_elab_ref"
#: Trusted Lean prelude, elaborated once per worker BEFORE the header's `open`s (pristine namespace).
LEAN_DUMP = r'''
namespace AlignElabHash
open Lean Meta
partial def ser (nm : FVarId β Option String) (e : Expr) : MetaM String := do
match e with
| .bvar i => return s!"#{i}"
| .fvar id => return (nm id).getD "?fv"
| .mvar _ => return "?m"
| .sort l => return s!"Sort({toString l})"
| .const n _ => return n.toString
| .lit (.natVal n) => return toString n
| .lit (.strVal s) => return s!"\"{s}\""
| .mdata _ b => ser nm b
| .proj s i b => return s!"{s}.{i}({β ser nm b})"
| .lam _ t b _ => return s!"(fun {β ser nm t} => {β ser nm b})"
| .forallE _ t b _ => return s!"(all {β ser nm t} , {β ser nm b})"
| .letE _ t v b _ => return s!"(let {β ser nm t} := {β ser nm v} in {β ser nm b})"
| .app .. =>
let f := e.getAppFn
let args := e.getAppArgs
let mut keep : Array Bool := args.map fun _ => true
try
let info β getFunInfoNArgs f args.size
for i in [0:args.size] do
if i < info.paramInfo.size && info.paramInfo[i]!.isInstImplicit then
keep := keep.set! i false
catch _ => pure ()
let mut out := #[β ser nm f]
for i in [0:args.size] do
if keep[i]! then out := out.push (β ser nm args[i]!)
return "(" ++ " ".intercalate out.toList ++ ")"
def dump (n : Name) : Elab.Command.CommandElabM Unit := do
let some ci := (β getEnv).find? n | throwError "no such declaration {n}"
let js β Elab.Command.liftTermElabM do
forallTelescope ci.type fun xs concl => do
let mut names : List (FVarId Γ String) := []
let mut vars : Array String := #[]
let mut hyps : Array String := #[]
let mut k := 0
for x in xs do
let d β x.fvarId!.getDecl
if d.binderInfo.isInstImplicit then continue
let nmf := fun (id : FVarId) => (names.find? (fun p => p.1 == id)).map (Β·.2)
let ty β ser nmf d.type
if (β isProp d.type) then
hyps := hyps.push ty
else
vars := vars.push ty
names := (x.fvarId!, s!"v{k}") :: names
k := k + 1
let nmf := fun (id : FVarId) => (names.find? (fun p => p.1 == id)).map (Β·.2)
let c β ser nmf concl
let hs := hyps.toList.map fun h => Json.str h
return Json.compress (Json.mkObj [("vars", Json.arr (vars.toList.map Json.str).toArray), ("hyps", Json.arr hs.toArray), ("concl", Json.str c)])
logInfo m!"ALIGN_ELAB_HASH {js}"
end AlignElabHash
'''
# --------------------------------------------------------------------------- pure parts
def build_lean_prelude() -> str:
return LEAN_DUMP
def build_ref_command(sig_text: str) -> str:
return f"theorem {REF} {sig_text} := by\n sorry"
DUMP_COMMAND = f"#eval {NS}.dump `{REF}"
def parse_dump(messages: Iterable[dict]) -> dict:
"""Extract the JSON the Lean dump logged. Raises ValueError with the Lean error otherwise."""
errs = []
for m in messages:
if not isinstance(m, dict):
continue
data = str(m.get("data", ""))
if m.get("severity") == "error":
errs.append(data)
elif MARK in data:
return json.loads(data.split(MARK, 1)[1].strip())
raise ValueError("no dump in output" + (": " + errs[0][:200] if errs else ""))
def canonical_form(dump: dict) -> str:
"""Hypotheses sorted (multiset order-invariance); data binders and conclusion kept in order."""
if not {"vars", "hyps", "concl"} <= set(dump):
raise ValueError(f"dump lacks fields: {sorted(dump)}")
return json.dumps({"vars": list(dump["vars"]), "hyps": sorted(dump["hyps"]), "concl": dump["concl"]},
ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def elab_sha(dump: dict) -> str:
return hashlib.sha256(canonical_form(dump).encode("utf-8")).hexdigest()
# --------------------------------------------------------------------------- Lean side
def _load_common():
if "p2_common" in sys.modules:
return sys.modules["p2_common"]
spec = importlib.util.spec_from_file_location("p2_common", REPO / "scripts" / "p2" / "p2_common.py")
mod = importlib.util.module_from_spec(spec)
sys.modules["p2_common"] = mod
spec.loader.exec_module(mod)
return mod
def hash_statement(worker, statement: str, timeout_s: float = 120.0) -> dict:
"""Elaborate one statement in the pinned env and return {elab_sha, dump} or {error}."""
from align.verify.lean.repl_pool import ReplError, ReplProtocolError
from align.verify.lean.strict import parse_statement
ref = parse_statement(statement)
if not ref.ok:
return {"error": f"unsupported_statement: {ref.problem}"}
try:
env = worker.header_env(ref.header_text, LEAN_DUMP)
except ReplProtocolError as e:
return {"error": "header_error: " + " ".join(str(e).split())[:200]}
except ReplError as e:
return {"error": e.code if hasattr(e, "code") else type(e).__name__}
try:
r1 = worker.command(build_ref_command(ref.sig_text), env, timeout_s)
errs = [m for m in r1.get("messages", []) if isinstance(m, dict) and m.get("severity") == "error"]
if errs or "env" not in r1:
return {"error": "elab_error: " + " ".join(str(errs[0].get("data", r1) if errs else r1).split())[:200]}
r2 = worker.command(DUMP_COMMAND, r1["env"], timeout_s)
dump = parse_dump(r2.get("messages", []))
except ReplError as e:
return {"error": e.code if hasattr(e, "code") else type(e).__name__}
except ValueError as e:
return {"error": f"dump_error: {e}"}
return {"elab_sha": elab_sha(dump), "dump": dump}
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--in", dest="inp", required=True)
ap.add_argument("--out", required=True, help="JSONL {problem_id, elab_sha | error}; existing ids are skipped (resume)")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--workers", type=int, default=2)
ap.add_argument("--timeout", type=float, default=120.0)
a = ap.parse_args(argv)
if not 1 <= a.workers <= 2:
raise SystemExit("--workers must be 1 or 2 (>2 needs the heavy-job lock; do the full run in a GPU-free window)")
C = _load_common()
from align.utils import guard
from align.verify.lean import ReplPool, find_toolchain
from align.verify.lean.repl_pool import ReplLimits
guard.preflight_host_check(strict=True)
done = set()
if os.path.exists(a.out):
done = {r["problem_id"] for r in C.iter_jsonl(a.out)}
rows = []
for r in C.iter_jsonl(a.inp):
if r["problem_id"] not in done:
rows.append(r)
if a.limit and len(rows) >= a.limit:
break
tc = find_toolchain()
pool = ReplPool.from_toolchain(tc, n_workers=a.workers, limits=ReplLimits(), preflight=True,
scratch_dir=str(Path(os.environ.get("TMPDIR", str(REPO / ".tmp"))) / "p2_elab_pool"))
t0 = time.time()
ok = 0
with C.AppendJsonl(a.out) as out:
for i in range(0, len(rows), 16):
chunk = rows[i : i + 16]
res = pool.map(lambda w, r: hash_statement(w, r["formal_statement"], a.timeout), chunk)
for r, x in zip(chunk, res):
if isinstance(x, BaseException):
raise x
out.append({"problem_id": r["problem_id"], **({"elab_sha": x["elab_sha"]} if "elab_sha" in x else {"error": x["error"]})})
ok += "elab_sha" in x
pool.close()
print(json.dumps({"statements": len(rows), "hashed": ok, "errors": len(rows) - ok, "seconds": round(time.time() - t0, 1)}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|