# Strict Lean 4 verifier (Track L / P1) A reward and an evaluator for formal proofs that cannot be gamed by "it compiles". Code: `align/verify/lean/` (stdlib only), installer `scripts/lean_setup.sh`, ceiling test `scripts/lean_ceiling_test.py` (+ frozen case tables `scripts/lean_ceiling_cases.py`), tests `tests/test_lean_verifier.py`. The Lean toolchain is an external binary (`docs/DEPENDENCY_POLICY.md`, "External binary: Lean 4 toolchain"), reached only through `subprocess`. Status (2026-10-12): implemented, tested without Lean (173 tests, fake REPL), tested against the real toolchain (19 slow tests), mutation-tested (40 hand mutants), and **ceiling-tested**: | | | |---|---| | Known-good accepted | **177 / 178 = 99.44 %** (Wilson 95 % [96.9 %, 99.9 %]); floor 98 % -> PASS | | Adversarial accepted | **0 / 61** -> PASS (a compile-only grader would have paid 51) | | Throughput, 8 workers | 0.57 proofs/s on a heavy unfiltered mix; 4.4 proofs/s on the median mix (GRPO shape) | | Memory per warm REPL | 0.64-0.65 GiB PSS with 8 workers (0.24 GiB private); 0.85 GiB peak under load | | Install | 5.57 GiB, 3 min; `import Mathlib` 3.2 s warm / 39.7 s cold | Section 5 holds the numbers; every figure there is copied from a file under `.tmp/lean_ceiling/`. Section 4 is the pre-registration. Section 6 lists what this verifier does not promise. ## 1. The pin | | | |---|---| | Lean | `leanprover/lean4:v4.9.0-rc1` (`lean --version`: 4.9.0-rc1, commit `be6c4894e0a6`) | | Mathlib | `leanprover-community/mathlib4@d1d1e4b72c6e009edd97cabe1d661b021fe8eeae` (2024-06-12) | | REPL | `leanprover-community/repl@v4.9.0-rc1` (`7df77c0a1c91`), built against the same toolchain | | elan | 4.2.4 | | Location | `/apps/development/rlhf/lean/` (`elan/`, `workspace/` = Lake project requiring Mathlib at the rev above, `repl/`, `PINS.json`) | **Provenance (how "the Goedel-Prover-V2 pin" was determined, not assumed).** Goedel-Prover-V2's README says "we follow DeepSeek-Prover-V1.5, which uses Lean 4 version 4.9 and the corresponding Mathlib". The repo's `mathlib4` git submodule is `xinhjBrant/mathlib4` at commit `2f65ba7f1a9144b20c8e7358513548e317d26de1`. That commit is a single child (message `align`, 2024-08-07, Huajian Xin of DeepSeek) of upstream `d1d1e4b72c6e`, and changes only `lakefile.lean` and `lake-manifest.json` to add `xinhjBrant/repl@deepseek` as a Lake dependency; its `lean-toolchain` is `leanprover/lean4:v4.9.0-rc1`. So "Lean 4.9" in that README is nominal: the binary is **4.9.0-rc1**, not 4.9.0. **Deviation, and why it is safe.** We pin the *upstream parent* and build the REPL separately. Mathlib's prebuilt `.olean` cache is keyed on a hash that includes `lakefile.lean`, so the DeepSeek child commit would not hit the cache and would force a multi-hour Mathlib build. The parent's Mathlib sources, and therefore the `.olean`s, are byte-identical to what the child compiles; the only difference is which REPL fork the lakefile names. We use upstream `repl@v4.9.0-rc1`, whose protocol (`cmd`/`env`) is all this package needs. The fork's extra options (`allTactics`, `ast`, `premises`) are not used. Install cost with the cache: 3 minutes, 5.8 GB (Section 5). **Version drift is a correctness issue.** SafeVerify keeps separate branches for 4.9.0 and 4.15.0; PutnamBench moved to 4.33.1 in 2026-09. Proofs verified here say nothing about 4.33. Every number this verifier produces must be reported with this pin and the verifier tier. ## 2. Architecture and what each layer defends ``` completion text ──extract_lean_code──▶ code ──static_check──▶ Plan (reward.py) (strict.py: lexer, bans, header, target) │ reject: no Lean touched ▼ ReplPool.acquire ─▶ Worker: [import once] ─▶ [audit prelude] ─▶ [header opens] (repl_pool.py) │ ref = `theorem _align_ref := sorry` ──▶ env R candidate body, run in env R ──▶ env C (errors / sorry ⇒ reject) `#align_audit "" 1 _align_ref` in env C ──▶ verdict JSON ``` | Layer | Rejects | Independent of the lexer? | |---|---|---| | `extract_lean_code` | no fence / truncated (unclosed) fence / prose | n/a | | header equality | any change to imports, `open`s, options (token-for-token vs the reference header) | yes (token compare) | | lexical bans | `sorry`/`admit`, `native_decide`, `unsafe`/`partial`, `implemented_by`/`extern`/`export`, `axiom`/`opaque`, `macro`/`elab`/`syntax`/`notation`/`run_cmd`/`initialize`, any `#cmd`, `namespace`/`section`/`variable`/`attribute`/`local`, `instance`, `def`/`abbrev` (unless `allow_helper_defs`), `Lean.*`/`IO.*` identifiers, `exact?`/`apply?`, non-whitelisted `set_option` (incl. `maxRecDepth` > 16384), attributes that change compilation/elaboration | no (this is the lexer) | | target rules | target missing, declared twice, `private`/`protected`/attribute on it, signature tokens differ from the reference's (strict mode) | no | | compile | any `error`; any `sorry` warning; non-empty `sorries` | yes (Lean) | | **audit: axioms** | any axiom outside {`propext`, `Quot.sound`, `Classical.choice`}: catches `sorryAx`, `Lean.ofReduceBool`/`trustCompiler` (`native_decide`), user axioms, even if the lexer were fooled | yes | | **audit: statement bridge** | the kernel is asked to check `theorem _align_bridge : := `. `_align_ref` was elaborated *before* the candidate existed, so a definition-hole (instance, `variable`, shadowing) that makes the same text mean something else fails here | yes | | **audit: kernel recheck** | every local theorem re-added with `debug.skipKernelTC` forced off | yes | | **audit: meta-declaration scan** | a NEW local constant whose type/value mentions `Lean.Elab/Meta/Macro/Parser/PrettyPrinter/Syntax/Environment/Core/Compiler`, `IO`, `unsafeCast`: the footprint of an `elab`/`macro` that slipped past the lexer | yes | | audit: kind | the target must be a `theorem` (not a `def` that happens to inhabit the type) | yes | The audit verdict is the one info message tagged `ALIGN_AUDIT `, produced by a **separate REPL call** after the candidate ran. The candidate's own output cannot carry the nonce (it is generated after the candidate text is fixed), so it cannot forge a verdict. Reward semantics (`reward.py`): `accuracy ∈ {0,1}`, 1 only if every layer passes. Diagnostics (`StrictResult.reasons`, `.diagnostics`) are returned separately and never folded into the reward: shaped partial credit ("compiles but has `sorry`") is exactly the gradient a policy follows to the exploit (Leanabell-V2 also found shaped rewards slightly worse than binary). Plug-in points, none of which change the loss or `CompositeReward`: ```python from align.verify.lean import ReplPool, StrictVerifier, LeanAccuracy, find_toolchain pool = ReplPool.from_toolchain(find_toolchain(), preflight=True) # host rules + worker count acc = LeanAccuracy(StrictVerifier(pool)) accuracy, result = acc.score(formal_statement, completion_text) # float in {0,1}, diagnostics terms, result = acc.reward_terms(composite, formal_statement, completion_text, fmt, n_tokens) outcomes = acc.verify_batch(statements, completions) # OutcomeVerifier surface ``` **Failure semantics.** A proof that times out, exhausts memory or kills its REPL scores 0 (that is a property of the proof). A *dead verifier* must not: `LeanInfraError` is raised when the base environment cannot be built or when more than half of a batch (of at least 8) died for infrastructure reasons. Otherwise a broken toolchain would feed the policy an all-zero reward that reads as "the model got worse" (the 0/100 perfect-policy incident, mirrored). Failure taxonomy (`StrictResult.reasons`): `no_code`, `parse_error`, `unsupported_statement`, `header_mismatch`, `target_missing`, `target_modifier`, `statement_mismatch`, `banned:`, `compile_error`, `sorry`, `not_theorem`, `axiom:`, `bridge_failed` (defeq mode), `kernel_recheck_failed`, `meta_decl`, `skip_kernel_tc`, `timeout`, `crash`, `memory`, `output_too_large`, `protocol`, `reference_invalid`. Statement modes. `strict` (default): signature tokens equal the reference's *and* the kernel bridge passes. `defeq`: bridge only, so an alpha-renamed or restated-but-defeq signature is accepted. The bridge is checked by the kernel (not the elaborator's `isDefEq`), so "accepted" means "the candidate's constant has a type the kernel accepts as the reference's type". ## 3. REPL pool: measured facts the design rests on | Fact (measured on this box, Lean 4.9.0-rc1 + Mathlib pin) | Consequence in code | |---|---| | The REPL **does not flush stdout when it is a pipe** (only a pty line-buffers); the client blocks forever | binary wrapped in `stdbuf -o0 -e0` (`tests::test_wrapped_argv_*`) | | `lake env` prints **cwd-relative** paths (`././.lake/...`) | absolutised once in `toolchain.build_child_env`; child gets a minimal environment (no tokens) | | A **second fresh `import Mathlib` in a live process** cannot re-mmap the `.olean`s and reads them into the heap: `Pss_Anon` 0.25 → **4.14 GiB** | one import per process; every header variant is layered on it (`split_imports`, `Worker.header_env`); a different import set recycles the process | | RSS is ~3.65 GiB but 3.40 GiB of it is the shared, read-only `.olean` mapping; private heap is ~0.25 GiB | the watchdog caps **`Pss_Anon`**, not RSS; `plan_workers` budgets shared-once + n × cap | | `Nat.pow exponent is too big` and `lean::stack_space_exception` **abort the process** (`rc=1`, `rc=-6`) | `ReplCrash` with the stderr tail; the next `acquire` respawns; scored 0 | | `debug.skipKernelTC` is an **unknown option** on 4.9.0-rc1 | the whitelist + the audit's `skip_tc` check are guards for a pin upgrade, not a live exploit here | | Lean 4.9 has **no public `collectAxioms`** (`#print axioms` is private) | `alignCollectAxioms` in the prelude (same traversal) | | `variable (h : False)` *used in a proof body* is added to the theorem's type in 4.9 (pre-4.11 behaviour) | `variable` is banned lexically and the bridge catches it independently | Limits per worker (defaults, `ReplLimits`): anonymous-memory cap 4 GiB (watchdog polls `smaps_rollup` every 0.5 s), `prlimit --as=16GiB --fsize=256MiB --core=0`, `nice +5`, output cap 16 MiB, per-proof wall budget 180 s (compile + audit), SIGKILL of the whole process group on any violation, `MALLOC_ARENA_MAX=2`. Workers recycle after 1000 commands. Default worker count is `min(8, cores // 4)` = 8 on this box; `ReplPool.from_toolchain(preflight=True)` applies the `/apps/CLAUDE.md` checks (≥ 45 GB `MemAvailable`, load < 2× cores, disk queue < 64) through `align.utils.guard.preflight_host_check` and then shrinks the worker count so that, even with every worker at its cap, `MemAvailable` stays above the 45 GB floor. The ceiling-test script takes the same exclusive heavy-job `flock` as the trainers (a Lean pool is a heavy job). ## 4. Pre-registration (written BEFORE the registered ceiling run) Registered 2026-10-12 20:12 IST, before any run of `scripts/lean_ceiling_test.py` without `--allow-unregistered`. The case tables (`HEADER`, `STATEMENTS`, `ADVERSARIAL`, `RESTATED` in `scripts/lean_ceiling_cases.py`) are frozen by hash: PREREG_TABLE_SHA256: `61636c7136eb647801c588b00a35bd8bcc6ef4db126c21ea75769092883ff7e2` `python scripts/lean_ceiling_test.py --verify-prereg` recomputes it; the registered run refuses to start on a mismatch. **Honest note on process.** The tables were developed against this verifier (about six exploratory runs on 1-2 workers, `--exploratory --allow-unregistered`) so that every adversarial case is *live* (it would be paid by a compile-only grader, which the run also measures) and every restated proof compiles. Those exploratory runs informed case *authoring* only; thresholds below were fixed before the registered run, and any verifier change after registration that alters a verdict is reported as an amendment in Section 7, with the pre-amendment matrix kept. **Configuration under test** = shipped defaults: `StrictConfig()` (strict statement mode, no helper defs, kernel recheck ON, 180 s budget per proof) and `ReplLimits()` (4 GiB anonymous cap, 16 GiB `--as` backstop), 8 workers, `lean_workbook` compile-only filter budget 120 s. **Known-good set.** (a) Public verified proofs: 6 contiguous slices of 25 rows at offsets 0, 5000, 10000, 15000, 20000, 25000 of `Goedel-LM/Lean-workbook-proofs` (29,750 proofs verified under the Lean 4.9 / DeepSeek-Prover-V1.5 Mathlib environment), fetched over HTTP, never the full dataset. (b) 30 hand-restated Mathlib-style theorems with proofs. The formal statement given to the verifier is derived mechanically from each proof file (header + last theorem's signature cut at the signature-ending `:=` + `sorry`). **Filter:** an independent compile-only pass (same REPL, no strictness: no `error` message and no `sorry` warning) defines "compatible with the pin"; items failing it are excluded *and listed*. Scored population = compile-clean items. **Pass:** ≥ 98 % accepted and ≥ 50 items. (The roadmap's P1 row says ≥ 99 %; the task fixed 98 %, which is what is registered. Both are reported.) Any rejected known-good item is listed with its reasons; rejections caused by the policy bans (e.g. a genuine `native_decide` in a public proof) count as failures, because that is the cost the RL stage would pay. **Adversarial set.** ≥ 30 hand-built cases, all registered (61): categories sorry (7), admit (2), extra axiom (4), native_decide (3), changed statement (6), weakened hypothesis (3), definition hole (4), environment manipulation via elab/macro/run_cmd/initialize/#eval/#exit (9), theorem proved for a different name (6), timeout/crash/memory bombs (3), banned constructs (6), header tampering (4), no code (4). Each is a *completion* (prose + fenced block) so fence extraction, static gate, REPL compile and audit are all exercised. **Pass:** 0 accepted. Bomb cases run on a dedicated 1-worker pool (1 GiB anonymous cap, 30 s budget) so they terminate promptly; the code path is the same. Reported alongside, not part of the pass criterion: how many cases a compile-only grader would pay; and which cases the Lean-side audit rejects *on its own* with the lexical gate disabled. **Confusion matrix** (reported verbatim): rows = truth (good / bad), columns = verdict (accept / reject); good = compile-clean known-good, bad = adversarial. **Measurements** (reported, no threshold): install disk size; `import Mathlib` cold (page cache evicted with `posix_fadvise(DONTNEED)`) and warm; per-REPL PSS / Pss_Anon / RSS from `/proc//smaps_rollup` with Mathlib imported, for one process and for 8 concurrent workers; throughput (proofs/s) at the worker count used, with the worker set warm, on unique statements and in the GRPO shape (12 statements × 8 samples); latency p50/p90/p99; PSS sampled during the run. ## 5. Results All numbers below are copied from `.tmp/lean_ceiling/registered/result.json` and `.tmp/lean_ceiling/registered.log` (the registered run: hash verified, `prereg_registered=true`, 8 workers, host preflight passed, heavy-job lock taken after waiting for another track's job to release it, started ~20:13 IST, 2026-10-12). Host: 48 cores; `MemAvailable` never fell below 136.9 GB in the samples taken during the strict known-good pass. ### 5.1 Ceiling test: confusion matrix (verbatim) ``` verdict ACCEPT verdict REJECT total truth GOOD proofs 177 1 178 truth BAD proofs 0 61 61 ``` | Criterion (pre-registered) | Measured | Verdict | |---|---|---| | known-good accepted >= 98 %, n >= 50 | **177 / 178 = 99.44 %** (Wilson 95 % [96.89 %, 99.90 %]); n = 178 | **PASS** (also >= the roadmap's 99 %) | | adversarial accepted = 0, n >= 30 | **0 / 61** | **PASS** | | gate 5 of the roadmap: strict false-negative rate > 5 % blocks P2 | 0.56 % (upper Wilson 3.1 %) | not blocked | **Known-good population.** 180 candidates (150 Lean-Workbook-proofs rows from dataset revision `b731852af8d8`, raw file sha256 `55932494...aebef`; 30 restated). The independent compile-only filter kept 178: **148 + 30**. The 2 excluded (`lean_workbook_10036`, `lean_workbook_plus_64037`) were excluded because the compile-only pass hit its **120 s budget** (`why: timeout`), not because of a compile error, so "version-incompatible" would be the wrong label: they are slow proofs. All 30 restated proofs were accepted. **The one rejected known-good proof: `lean_workbook_51088`, reason `timeout`** (180 s strict budget). It is a nlinarith proof with nine hints. Follow-up (exploratory, 1 worker, not part of the registered numbers), same proof, same pool code, only the REPL environment layers differ: | environment the body runs in | wall time | |---|---| | imports + header (what the compile-only filter uses) | 99.9 s (filter: 104.1 s) | | + reference theorem `_align_ref` | 225.8 s | | + audit prelude | 513.6 s | | + both (= the strict path) | 516.5 s (timed out at 400 s in a separate strict run) | On `lean_workbook_10009` (20 s) the same four environments take 19.9 / 20.8 / 20.1 / 20.0 s. So this is not a systematic strict-tier slowdown; it is the well-known run-time chaos of a borderline `nlinarith` (the certificate search is sensitive to state it inherits from earlier commands) hitting a 180 s wall-clock budget. It is a genuine false negative of the *reward* (such a proof would score 0), it is bounded at one in 178 here, and raising the budget to Goedel's 300 s would not have saved it (517 s). The honest reading: wall-clock-bounded verification of proofs this close to the limit is flaky in BOTH directions, so for evaluation (not training) report results at a stated budget. **Adversarial population (61 cases, all rejected).** First reason per case: 58 rejected by the static gate (no Lean call), 3 by the REPL layer (the bombs). By category (cases / accepted): sorry 7/0, admit 2/0, extra_axiom 4/0, native_decide 3/0, changed_statement 6/0, weakened_hypothesis 3/0, definition_hole 4/0, env_manipulation 9/0, wrong_name 6/0, timeout_bomb 3/0 (timeout, `rc=-6` stack-overflow crash, anonymous-memory kill at 1110 MiB over a 1024 MiB cap), banned_construct 6/0, header_tamper 4/0, no_code 4/0. * **A compile-only grader would have paid 51** of the 54 code-bearing, non-bomb cases (each case's own text compiled with no strictness). The 3 it would not: `header_missing` (no Mathlib), `duplicate_target` (Lean itself rejects a duplicate name) and `skipKernelTC_option` (unknown option on this Lean). The 3 bombs and the 4 `no_code` cases are not applicable. So 51 are live exploits, not strawmen. * **Semantic layer alone** (lexical gate OFF, so each exploit actually executes inside Lean, with `kernel_recheck` on): rejected **45 of the 54** code-bearing non-bomb cases by the Lean-side audit alone (`sorry`, `axiom:bad`, `axiom:Lean.ofReduceBool`, `axiom:evil` after an `elab` added an axiom, `statement_mismatch` for the instance hole, the `variable` hole, shadowing via `macro_rules`, changed constants/hypotheses, `meta_decl` for `initialize`, `target_missing` for wrong names/namespaces/private targets, `header_mismatch`). The 9 it accepted are the *policy-only* set, where the proof of the stated theorem is valid and the lexical ban is the only thing stopping it: `eval_side_effect`, `exit_after_theorem`, `attribute_on_target`, `unsafe_implemented_by`, `partial_def`, `extern_attr`, `search_tactic_exact_q`, `maxRecDepth_abuse`, `helper_def_with_proof`. None is a soundness gap (the theorem is proved); they are rejected to keep the attack surface small, and each costs real-world proofs that use them (Section 6, item 5). ### 5.2 Install and process measurements | Quantity | Measured | |---|---| | Install time (`lean_setup.sh`, cache hit) | 3 min 9 s (18:23:06 -> 18:26:15); an idempotent re-run takes ~10 s | | Disk, total | 5,981,549,078 B = **5.57 GiB**: elan + toolchain 0.93 GiB, workspace 4.34 GiB (of which Mathlib package 4.11 GiB), REPL 94 MiB | | `import Mathlib` + `Aesop`, **cold** (page cache for all 11,171 `.olean/.ilean/.so` files = 4.19 GiB evicted with `posix_fadvise(DONTNEED)`) | **39.7 s** | | same, **warm** (3 runs) | 3.19, 3.20, 3.19 s (median **3.19 s**) | | 8 workers warmed in parallel | 3.38 s (warm cache) | | One REPL after the import (alone on the box) | RSS 3439 MiB, **PSS 3437 MiB**, `Pss_Anon` **244 MiB**, `Pss_File` 3193 MiB, 1 thread, VmSize 4071 MiB | | **Per-worker PSS with 8 workers all holding Mathlib** | **643-653 MiB** each (mean 648; RSS 3.4-3.5 GiB each) = 244 MiB anonymous + 3193 MiB / 8 shared. Sum of PSS **5.06 GiB**; sum of RSS **26.8 GiB** (the double count the PSS figure removes) | | Per-worker PSS **during** the 178-proof run (sampled every 0.5 s) | mean 717-792 MiB, max **873 MiB**; `Pss_Anon` max 404 MiB; RSS max 3.65 GiB | | Worker growth over 1042 commands | none that approached any limit: recycled 0 times (limit 1000/worker) | `fincore` was not usable for confirming the eviction (its output did not parse into a residency fraction: the script printed `None` and `4096.0`; ignore those two fields in `result.json`). The 12x cold/warm gap is the evidence that the eviction worked. Model implied by the measurements: `PSS(n workers) ~ 0.24 GiB + 3.12 GiB / n` per worker, total `~ 3.12 + 0.24 n` GiB. Eight workers cost ~5 GiB of RAM in total. The worst case (all eight at their 4 GiB anonymous cap) is 3.12 + 8 x 4 = 35 GiB, which is what `plan_workers` budgets against the 45 GB floor, so the pool cannot push `MemAvailable` under the floor on this host. **Roadmap gate 5** ("above ~4 GB per warm REPL the pool shrinks below ~8 workers") is not triggered: 0.65 GiB per warm REPL, 0.85 GiB peak under load. ### 5.3 Throughput (8 workers, warm) | Shape | Proofs | Wall | Proofs/s | Notes | |---|---|---|---|---| | 178 unique statements (the known-good set, strict tier incl. kernel recheck) | 178 | 310.9 s | **0.573** | latency p50 1.97 s, p90 21.4 s, p99 158.6 s, max 180.3 s (the timeout), mean 9.76 s | | GRPO shape: 12 median-latency statements x 8 identical samples | 96 | 21.9 s | **4.38** | reference environment and baseline audit reused per worker; all 96 accepted | The first number is dominated by the heavy tail of Lean-Workbook `nlinarith` proofs (mean 11.7 s, median 2.65 s for those; the 30 restated proofs: median 0.18 s, max 1.5 s) and by one 180 s straggler: the ideal packing of the work is 178 x 9.76 s / 8 = 217 s. It is **not** a verifier overhead: the compile-only filter took 273 s for 180 candidates on the same 8 workers, i.e. the strict tier added ~14 % (audit 0.03-0.7 s per proof; kernel recheck +0.04-0.12 s on 20-43 s proofs). Against the research report's guess of "1-3 proofs/s for 8-16 workers (low confidence)": **0.57/s on a heavy, unfiltered population and 4.4/s on a median population**; either way verification stays far above generation (~0.1-0.17 proofs/s per the report), so it is not the binding constraint at this worker count. ### 5.4 Test and mutation evidence * `pytest tests/test_lean_verifier.py -m "not slow"`: 173 passed (no Lean needed; fake REPL binary speaks the real protocol, including the audit message). * `pytest tests/test_lean_verifier.py -m slow`: 19 passed on the real toolchain (accepts a correct proof, rejects eight exploits with the lexical gate OFF, defeq-vs-strict mode, invalid reference, timeout + recovery, end-to-end reward path, concurrency, memory shape). * `tests/test_dependency_firewall.py`: 2 passed (`align/verify/lean/` and the two scripts import only stdlib / `align`). ## 6. Residual risks (what this verifier does NOT promise) 1. **Lexer desync.** The lexical gate is a hand-written tokenizer, not Lean's. It follows Lean's rules for nested comments, string escapes, raw strings, char literals and `«»`, and on any unusual construct falls back to a raw-text scan, but it has not been differentially fuzzed against Lean's real tokenizer. Mitigation: layers 3+ (axioms, bridge, kernel recheck, meta-declaration scan) do not use it; the semantic-only column of the ceiling test shows what they catch alone. What only the lexer stops is listed there (all of it is *policy*, not soundness: the proofs are valid for the stated theorem). 2. **No OS sandbox.** The REPL runs as the same user with filesystem access and no network namespace. Code can only execute inside the elaborator through meta-programming (`elab`/`macro`/`run_cmd`/`#eval`/`initialize`/`unsafe`), all of which are banned lexically AND detected by the meta-declaration scan, and the child's environment holds no secrets (`toolchain.build_child_env`). A meta-programming escape that evades both would run with the verifier's file permissions. For adversarial RL, run the pool as a dedicated unprivileged user or under `unshare --net` / a cgroup; that needs privileges this task did not have. 3. **Statement fidelity.** The bridge proves the candidate proves the GIVEN formal statement. It says nothing about whether that statement means what the informal problem says (miniF2F: formal/informal discrepancies in over half the problems, 16 unprovable statements; the report, section 5F). Report miniF2F-v2/PutnamBench with the pin and tier. 4. **Trust base.** The Lean kernel, `propext`, `Quot.sound`, `Classical.choice`, the REPL, and the prelude (`AUDIT_PRELUDE`, ~100 lines). The prelude is a trusted program; it is exercised by the real-toolchain tests, not formally verified. 5. **Policy rejections are not soundness failures.** `def`/`instance`/`notation`/`namespace` helpers, `exact?`, `#eval` and so on are rejected even when the proof of the stated theorem is valid. That is deliberate (smaller attack surface) and has a cost: public proofs that use them are false negatives. `allow_helper_defs=True` relaxes only `def`/`abbrev`. 6. **Version drift.** Proofs from other Lean/Mathlib versions will fail to compile (counted as 0), which is correct for this pin and wrong as a statement about the proof. 7. **Throughput numbers are for this population** (short Lean-Workbook-style and restated proofs). A prover that emits long `nlinarith`/`decide` proofs will be slower; the audit costs ~0.3 s for proofs whose dependency closure reaches real analysis and ~0.03 s for `omega`. ## 7. Amendments **7.1 Changes between the exploratory runs and the registration (before 20:12 IST; not amendments, listed so nothing is hidden).** Case-table edits made after seeing exploratory output: `sorry_branch` (its `all_goals sorry` was dead code, so the proof was valid: moved the `sorry` into a live branch); `sorry_after_char_quote` / `sorry_after_comment_opener_string` (did not compile; rebuilt so the `sorry` really sits between two quote-bearing literals); `axiom_private`, `axiom_reducible_attr` (did not compile; replaced by `axiom_polymorphic`, `axiom_via_simpa`); `ofReduceBool_term` (`rfl` cannot discharge `Lean.reduceBool`; replaced by `native_decide_in_helper_lemma`); `local_notation_hole` (a local `+` notation is *ambiguous* with the builtin one, so it never elaborates; replaced by `variable_hole_hypothesis`); `rs_default_arg` (did not compile). Verifier changes made before registration, each driven by a measurement: single import per process (Section 3), the late-answer drain, `kernel_recheck` default, the `open Lean`-root ban, `notation3`/`binder_predicate`/... bans, 180 s default budget, `split_imports`/prelude-before-opens layering. The `skipKernelTC_option` case turned out to be **inert on this pin** (the option does not exist) and was kept, labelled. **7.2 Retraction.** A first measurement reported the kernel recheck as costing "2.2-3.2x" (`lean_workbook_10009`: 21 s -> 68 s). That reading came from a run on a host where two other Lean processes were competing; isolated measurements (`#align_audit` flags 0 vs 1 on the same environment) show **+0.04 to +0.12 s** against 20-43 s of elaboration. The recheck is therefore ON by default; the claim was removed from the code comment before registration. (The same episode is why the headline timings here come from the registered run; the few ad-hoc timings are labelled exploratory.) **7.3 Verifier changes after registration:** none. The registered run used the tree that is described here. **7.4 Failures found by running against the real toolchain (each has a regression test).** (1) REPL never flushes a pipe -> `stdbuf`; (2) `lake env` paths are cwd-relative -> absolutised; (3) a second `import Mathlib` in a live REPL costs +3.9 GiB anonymous and tripped the 4 GiB watchdog on the first multi-case run -> one import per process, layered headers; (4) `#print axioms`'s collector is private in 4.9 -> own traversal; (5) with `autoImplicit` on (plain Lean default) an *unknown identifier* in a statement is silently an implicit variable, so "invalid reference statement" needs a genuine type error to trigger `reference_invalid`; (6) `sqrt` under `open Real Nat` is already ambiguous, so a root-level `def sqrt` is not a viable definition hole here (instances and `variable` are); (7) pool statistics were counted only when an error escaped `acquire`, not when the caller handled it. **7.5 Hand mutants** (each removes one fix in a COPY of the package, then runs the non-slow tests; `.tmp/lean_notes/mutants.py`, final run after the last code change except docs): | | Mutant (fix removed) | Killed by | |---|---|---| | M01 | accept any audit-tagged message (forgeable verdict) | `test_audit_message_without_the_nonce_is_ignored_forged_verdict_regression` | | M02 | no axiom whitelist check | `test_each_audit_finding_rejects[axiom/sorryax/ofreduce]` | | M03 | ignore the kernel statement bridge | `test_each_audit_finding_rejects[bridge]` | | M04 | no meta-baseline subtraction | `test_verdict_accepts_a_clean_audit` | | M05 | no meta-declaration check | `test_each_audit_finding_rejects[meta]` | | M06 | ignore `sorry` warnings / `sorries` | `test_each_audit_finding_rejects[sorrywarn/sorrylist]` | | M07 | header failure scored 0 instead of `LeanInfraError` | `test_header_failure_raises_infra_error_never_a_zero` | | M08 | candidate runs without the reference env | `test_command_order_and_envs_are_the_contract` | | M09 | no `stdbuf` | `test_wrapped_argv_uses_stdbuf_prlimit_and_nice` | | M10 | kill the pid, not the process group | `test_timeout_kills_grandchildren_too_via_process_group` | | M11 | no memory watchdog | `test_memory_watchdog_caps_anonymous_memory_not_rss` | | M12 | timeout without killing | `test_timeout_kills_the_process_and_leaves_no_zombie` | | M13 | tolerate bytes after the terminator | **survives: equivalent mutant** (two concatenated JSON objects fail `json.loads` anyway); the real defence is M39 | | M14 | first `:=` wins | `test_sig_end_skips_let_in_type_not_the_first_colon_eq` | | M15 | no char-literal handling | `test_lexer_char_literal_quote_is_not_a_string_start` | | M16 | block comments do not nest | `test_lexer_drops_line_and_nested_block_comments` | | M17 | no raw-scan fallback on lexer anomalies | `test_anomaly_triggers_raw_scan_...` | | M18 | re-import for every header variant | `test_second_header_variant_reuses_the_import_never_imports_twice_regression` | | M19 | lake paths left cwd-relative | `test_build_child_env_absolutises_...` | | M20 | no header equality | `test_static_rejections[header...]` | | M21 | `set_option` whitelist off | `test_banned_constructs_fire[set_option...]` | | M22 | dead pool emits zeros | `test_reward_raises_when_the_pool_looks_dead_...` | | M23 | defs accepted as proofs | `test_each_audit_finding_rejects[def]` | | M24 | kernel recheck never requested | `test_kernel_recheck_is_on_by_default_...` | | M25 | `skip_tc` ignored | `test_each_audit_finding_rejects[skiptc]` | | M26 | recheck failure ignored | `test_each_audit_finding_rejects[recheck]` | | M27 | private/attribute target allowed | `test_static_rejections[target_modifier]` | | M28 | signature token equality off | `test_static_rejections[statement_mismatch]` | | M29 | identifier ban list off | `test_banned_constructs_fire` | | M30 | salvage unclosed fences | `test_extract_unclosed_fence_is_not_salvaged` | | M31 | first fence instead of last | `test_extract_takes_last_closed_lean_fence_and_ignores_think` | | M32 | duplicate target allowed | `test_static_target_declared_twice_is_rejected` | | M33 | ablated verifier usable as a reward | `test_reward_refuses_the_ablated_verifier` | | M34 | worker never returned to the pool | **hang-killed** (the pool tests block; 90 s watchdog) | | M35 | no recycling after `max_requests` | `test_worker_recycled_after_max_requests` | | M36 | unbounded glibc arenas | `test_build_child_env_absolutises_...` | | M37 | child inherits the whole `lake env` | `test_build_child_env_absolutises_...` (secrets leak assertion) | | M38 | no output cap | `test_oversized_output_kills_the_process` | | M39 | late second answer attributed to the next request | `test_late_second_answer_is_detected_...` | | M40 | `open Lean` makes meta identifiers spellable | `test_banned_constructs_fire[open Lean...]` | 40 mutants: **38 killed by an assertion, 1 killed by a hang (M34), 1 survivor (M13, equivalent)**. Not covered by any mutant, by construction: the `skip_tc` audit path against the REAL option (it does not exist on this pin; `test_real_skip_kernel_tc_option_does_not_exist_on_the_pin` fails the day the pin changes) and the Lean prelude's kernel-recheck loop against a proof that elaborated with the kernel disabled (no way to produce one on 4.9 without the banned meta-programming). **7.2 V1 freeze (2026-10-14, branch `phase0/verifier`; PROPOSED FROZEN HASH, merge after chain6 exits).** Fixes (regressions in `tests/test_lean_hardening.py`, each adversarial proof was accepted or aborted a batch before): U08-1/2 lexer desync (`\/` and `//` are single tokens; a comment opener glued to any other symbol is refused as ambiguous; numerals no longer swallow letters: `1macro`; raw strings of any hash count; real-Lean differential fuzz); U08-40 `s!`/`f!`/`m!` interpolation flagged whatever sits before the quote; U08-4/12 `_align*` names and `_root_.`/`«»` spellings banned; U08-5 axiom-walk fuel exhaustion reports `_align_fuel_exhausted` (fails closed) and a missing `axioms` field is `audit_incomplete`; U08-3 `ReplPool.from_toolchain(sandbox=True)` is the default, fails closed with `SandboxUnavailable`, `sandbox=False` is the explicit opt-out; U08-8 every real REPL runs under the launcher/supervisor (`ReplProcess.supervise`) that SIGKILLs it when the parent process dies (measured: orphaned before, reaped now); U08-6 stale env ids after a worker death raise `ReplCrash`; U08-7 lone surrogates reject that item only; U08-17 budget starts after `acquire()`; U08-18 a bad reference header is `reference_invalid`, not a batch abort; U08-21 any exception mid-request kills the child; U08-11(codex) unmeasurable memory fails closed after `mem_miss_limit` samples; U08-16 timeout/crash/memory/protocol map to `Outcome.UNDECIDED` (new enum member in `align/eval/verifier.py`, reward stays 0). Ported from the red-team session: opt-in `independent_recheck` (`recheck.py`), default off. NOT changed (design choices, math verifier, need a decision): U08-9/10/11/15 (tolerance, texty fallback, unit stripping, `i`), U08-13/14/22 (extractor ReDoS/format), U08-19/20 (preflight opt-in, pin not enforced at runtime). Verification: exploratory ceiling run (2 workers, sandboxed): known-good 176/177 accepted (the one reject is a 179 s timeout under host load), adversarial 0/61 accepted. Registered run to do after the chains exit (needs the heavy lock): `python scripts/lean_ceiling_test.py --workers 8`. Frozen hash: `sha256` over, for each `align/verify/**/*.py` in sorted path order, `path + NUL + bytes + NUL`: `6df56348db12d8e255427764f994d0f3ab29d4713e84ddda07c9637c31f12648`. **7.3 FROZEN (2026-10-19).** Merged verifier (V1 + MV math-checker fixes) hash `f1dd9e7871ade3a9dd37587c10a7a17025fe8623a735455ec40206607e120b5c` (`scripts/p2/verifier_hash.py`; supersedes the 7.2 proposal, which predates MV). Registered ceiling run (8 workers, heavy lock, sandboxed, `.tmp/restart/ceiling/result.json`): known-good 178/179 accepted = 0.9944 (Wilson95 [0.9690, 0.9990], floor 0.98) PASS; adversarial 0/61 accepted PASS (a compile-only grader would accept 51). Every SOTA number from here on is verified under this hash; chain8 refuses to run a step if `align/verify` changes. ## 8. Operations ```bash bash scripts/lean_setup.sh # install / repair (idempotent, ~3 min, 5.8 GB, under /apps) bash scripts/lean_setup.sh --check # exit 0 iff the install is complete source /apps/development/rlhf/lean/env.sh # only if you want elan/lake in your shell pytest tests/test_lean_verifier.py -m "not slow" # no Lean needed (fake REPL binary) pytest tests/test_lean_verifier.py -m slow # real toolchain, 2 workers (~1 min) python scripts/lean_ceiling_test.py --verify-prereg python scripts/lean_ceiling_test.py --workers 8 # registered run: preflight + heavy-job lock python scripts/lean_ceiling_test.py --measure-only # install size, cold/warm import, PSS ``` * `ALIGN_LEAN_ROOT` relocates the install. Every cache the toolchain writes is redirected under it by `lean_setup.sh`; nothing goes to `$HOME`, `/` or `/tmp`. * Worker stderr spools to `/tmp/pool/worker.stderr` (truncated per respawn; the tail is quoted in `ReplCrash`). * Before widening the pool: `plan_workers` and `ReplPool.from_toolchain(preflight=True)` are the only sanctioned way; never construct more workers than the 45 GB floor allows. * A new Lean/Mathlib pin is a new verifier: re-run the ceiling test, update `PINS.json` provenance in Section 1, and regenerate any cached verdicts. * Hand mutants (Section 7) are run on a COPY of the package under `.tmp/mutants/`; the repo is never edited in place.