RL on formal verification via Tinker: de-risked
GRPO (LoRA r32, importance-sampling loss) on Lean 4 proof-completion tasks
in the rocq-lean-proofs format, graded locally by lean + axiom audit.
One overnight session, ≈$50 of the $700 budget.
All code, data, and results live in PR #138 (theorem-labs/binary-equivalence-taiga) — browse the files there.
The whole pipeline, in plain terms
1 · One RL iteration, end to end
Sampling and gradient steps run remotely on Tinker; grading is a plain local Python function call in between — no container, no build system. That is the whole reason this task family was picked for de-risking.
data/tasks_v3.jsonl
│ pick 16 problems
▼
┌─────────────────┐ 8 samples each ┌──────────────────┐
│ build prompt │ ─────────────────▶ │ Tinker sampler │ (remote GPU)
└─────────────────┘ └────────┬─────────┘
│ 128 completions
▼
extract_proof() ← last ```lean fence
│
┌────────────── GRADER (local, in-process) ───────────────────┐
│ 1. regex guards (sorry/axiom/#/macro/…) ── hit? → fail │
│ 2. splice tactic block into statement + add #print axioms │
│ 3. tempfile → subprocess `lean` (60s timeout, ≤16 parallel) │
│ 4. axiom report ⊆ {propext, Quot.sound, Classical.choice}? │
└───────────────────────────┬──────────────────────────────────┘
│ reward ∈ {0,1} per sample
▼
advantage = reward − group mean
(all-pass / all-fail groups dropped)
│
▼
┌──────────────────┐ forward_backward + optim_step ┌──────────────────┐
│ build Datums │ ─────────────────────────────▶│ Tinker trainer │ (remote)
└──────────────────┘ └────────┬─────────┘
│ new LoRA weights
▼
refresh sampler ─▶ next iter
2 · What's on disk
experiments/2026-07-08--aengus--tinker-rl-derisk/ ├── data/tasks_v3.jsonl # tasks: defs + statement + gold proof + split ├── src/ │ ├── tasks.py # Task dataclass, file_with_proof() splicer │ ├── prompts.py # prompt builder, extract_proof() │ ├── grader.py # THE GRADER — grade_sync(), ~100 lines │ ├── rl.py # GRPO loop (calls the grader inline) │ └── engine.py / evalrun.py # eval path (same grader) ├── results/<run>/ │ ├── config.json # hparams │ ├── metrics.jsonl # 1 line per RL iteration │ └── rollouts.jsonl # every sample + reward + fail reason └── (machine-wide) ~/.elan/…/bin/lean # Lean 4.14.0 — the only external dep
lean subprocess (~0.07s)3 · How the dataset was built (and iterated)
Two candidate task families existed in the real repos. Family A (full Rocq→Lean translation, containerized, tool-driven) was rejected for de-risking: it needs Docker per rollout and base models score 0.0 — no gradient signal to learn from. Family B (Lean proof completion) won on three tests: locally gradeable in ~0.07s, base models in the 15–60% trainable band, objectively binary reward.
The build recipe, per version: write definition blocks → write theorem statements over them
→ write a gold proof for every task → run every gold proof through the actual
grader (anything failing gets fixed or dropped, so 100% of shipped tasks are provably solvable)
→ freeze into JSONL with a split-preserving --freeze flag so the held-out
comparator never shifts. For v4, hand-writing was replaced by six parametric generators
(affine identities, repeat-sums, double recursions) stamped from random seeds; generated gold
proofs still had to pass the grader.
| version | size | train / heldout | why it exists |
|---|---|---|---|
| v1 | 46 | — | hand-written seed set; rl1 trained on it |
| v2 | 75 | eval-only | OOD probe: definition families never trained on |
| v3 | 97 | 71 / 26 | 4 difficulty tiers (47/18/23/9); the frozen ID comparator |
| v4 | 317 | 291 / 26 | parametric generators — bigger pool once v3 ran dry |
ID vs OOD. In-distribution = the 26 frozen v3 held-out tasks (same families as training, unseen theorems). Out-of-distribution = the v2 families never present in training — rl1/rl5 transferring to ~90% there is the strongest evidence this is generalization, not memorization.
4 · How this compares to the real Rocq→Lean task
Honestly: a deliberately simplified proxy. The real task is Rocq→Lean translation
plus equivalence proofs, with the agent inside a Docker container running bash, a file editor,
a Rocq REPL and lean_export, up to 300 turns, 32k context, and a weighted
multi-file reward — base models score 0.0 on it. This proxy is single-turn, text-only, one
theorem, binary reward. It validates the GRPO plumbing, group-variance economics, SFT
bootstrapping, and that most base-model failure is fixable output protocol. It does
not validate long-horizon credit assignment, tool-call round-tripping, debugging from
compiler errors over many turns, container state, or the translation step itself — see the
bridging ladder in the findings.
5 · Inference on open models
All inference goes through Tinker's sampling API: a model registry maps each model to its
chat renderer and per-token prices; an async engine caps 100 concurrent requests, logs JSONL
transcripts, and meters cost live; the parser takes the last ```lean fence.
Grid 1: 5 models × 46 tasks × 4 samples for $1.40 total — gpt-oss-120b
best and cheapest per pass (59.8%); gpt-oss-20b at 23.4% had both headroom and gradient signal,
so it became the RL workhorse. Completions are thinking-dominated: ~2.4k sampled
tokens/attempt on gpt-oss-20b, ~9.3k on Qwen3.5-4B, versus final proofs of 1–10 tactic lines.
6 · Every RL run and its hyperparameters
Shared across all runs: GRPO with LoRA rank 32, importance-sampling loss, group-centered advantages (zero-variance groups dropped), lr 4e-5, group size 8, 16 problems/iter, temperature 1.0, 8192 max tokens, held-out eval every 5 iters at k=4 — deviations noted below.
| run | model | train data | what varied | held-out result |
|---|---|---|---|---|
| rl1 | gpt-oss-20b | v1 | 60 iters | 22.9 → 85.4% (OOD v2: 10.9 → 90.6%) |
| rl2 / rl3 | Qwen3.5-4B | v1 / v2 | rl3: temp 1.15, 12k tokens | dead by iter ~10 — variance exhaustion |
| rl4 | gpt-oss-120b | v3 | 40 iters | 45.2 → 76.0% |
| rl5 | gpt-oss-20b | v3 | 80 iters — headline run | 18.3 → 88.5% (OOD v2: 90.0%) |
| rl6 / rl7 / rl8 | gpt-oss-20b | v3 | lr 1e-5 / 1.6e-4 / seed 1, 20 iters | 48.1 / 73.1 / ~76% @ it19 — LR-robust |
| sft1 | gpt-oss-20b | gold proofs | SFT only, no RL | 18.3 → 54.8% for $0.30 |
| rl9 | gpt-oss-20b | v4 | 3-epoch SFT warm-start → GRPO, 150 iters | capped 79.8% — SFT entropy tax |
| rl10 | gpt-oss-20b | v4 | 1-epoch SFT + temp 1.2 remedy | 72.1% — remedy insufficient |
| rl11 | gpt-oss-20b | v4 | multi-turn GRPO: ≤2 turns, grader error fed back, 12 probs/iter | ~40 → 92.3% peak; mean turns 1.9 → 1.17 |
7 · The grader, precisely
Everything happens in one function, grade_sync() in src/grader.py
(~100 lines), whose Verdict.passed becomes the 0/1 reward. Four stages:
(1) regex guards over the tactic block — sorry,
admit, axiom, native_decide, ^#,
macro/elab/set_option/unsafe — any hit
fails before Lean ever runs; (2) splice the block after := by in
the harness-owned statement and append #print axioms; (3) compile
with plain lean (60s timeout, ≤16 parallel processes); (4) parse
the axiom report — required because a file containing sorry still exits 0; the
sorryAx axiom surfaces there instead. Every failure gets exactly one
machine-readable reason (forbidden_token, compile_error,
timeout, bad_axioms, no_axiom_report), and in the
multi-turn variant the compile-error detail is the retry message. Reward-hack audits over 374
grid-1 and 676 rl1 passes: zero suspicious proofs.
RL scoreboard — all numbers on held-out tasks
| run | model | tasks / iters | baseline | after RL | cost |
|---|---|---|---|---|---|
| rl1 | gpt-oss-20b | v1 · 60 | 22.9% | 85.4% | ~$8 |
| rl1→v2 | gpt-oss-20b ckpt | v2 heldout (unseen families) | 10.9% | 90.6% | $0.15 |
| rl5 | gpt-oss-20b | v3 · 80 | 18.3% | 88.5% | ~$14 |
| rl4 | gpt-oss-120b | v3 · 40 | 45.2% | 76.0% | ~$4.5 |
| rl2/rl3 | Qwen3.5-4B | v1/v2 · killed ~10 | 25.0% | 55.0% | ~$6 |
| mt | rl5 ckpt, 3 turns | v3 heldout | 46.2% (base 3-turn) | 94.2% | $0.08 |
| rl9 | gpt-oss-20b | SFT+GRPO · 317-task pool · 150 | 18.3% | 79.8% | $2.3 |
| rl10 | gpt-oss-20b | mild-SFT + temp1.2 GRPO · 80 | 18.3% | 72.1% | $1.5 |
| rl11 | gpt-oss-20b | multi-turn GRPO · 2-turn episodes · 60 | ~40% | 92.3% peak | $6.5 |
| sft | gpt-oss-20b | v3 · 4 epochs on 71 golds | 18.3% | 54.8% | $0.30 |
| sweep | gpt-oss-20b | v3 · 20 · lr 1e-5 / 4e-5 / 1.6e-4 | 18.3% | 48.1 / 76.0 / 73.1% | ~$7 |
The task, up close
Each task is one Lean 4 theorem over custom definitions (never library names —
so simp can't just look the lemma up). The harness renders the full file with the
proof replaced by sorry and shows it to the model inside an instruction prompt:
def myapp {α : Type} : List α → List α → List α
| [], ys => ys
| x :: xs, ys => x :: myapp xs ys
def myrev {α : Type} : List α → List α
| [] => []
| x :: xs => myapp (myrev xs) [x]
theorem myrev_involutive : ∀ (l : List Nat), myrev (myrev l) = l := by
sorry
#print axioms myrev_involutive
How the agent interacts. There are no tools and no file editing. The model
replies with ONE fenced code block containing only the tactic script; the harness splices it
into the fixed theorem (Task.file_with_proof), appends #print axioms,
runs lean file.lean (~0.07s), and parses the axiom report. Because the model never
controls the statement, statement-weakening is impossible; sorry, injected
axioms and native_decide all surface in the axiom report and score 0.
A typical passing answer:
have happ_nil : ∀ (l : List Nat), myapp l [] = l := by intro l induction l with | nil => rfl | cons x xs ih => simp [myapp, ih] have hrevapp : ∀ (l1 l2 : List Nat), myrev (myapp l1 l2) = myapp (myrev l2) (myrev l1) := by ... intro l induction l with | nil => rfl | cons x xs ih => simp [myrev, hrevapp, ih, myapp]
have … := by …The dataset grew in four certified generations — every task ships with a
gold proof that must pass the real grader before inclusion (this caught my own broken proofs
~10 times; failure modes: no ring_nf in core Lean, omega needing
simp [Nat.mul_add, Nat.add_mul] to atomize nonlinear terms, indentation of
multi-line case branches). Difficulty tiers: 1 = concrete computation (rfl
closes it), 2 = one induction, 3 = helper lemmas, 4 = multi-helper (e.g.
mymul_comm via three haves).
| generation | tasks | t1/t2/t3/t4 | split | what's new |
|---|---|---|---|---|
| v1 hand templates | 46 | 25/9/8/4 | 34 train / 12 held | Nat, List, Tree families |
| v2 unseen families | 75 | 39/16/16/4 | 55 / 20 | pow, fact, even, snoc, sumlist, take/drop — for the transfer test |
| v3 harder curriculum | 97 | 47/18/23/9 | 71 / 26 | filter/count/all with if-splits, tree height, distributivity |
| v4 parametric factory | 317 pool | 84/127/97/9 | 291 / 26 (v3 held fixed) | random names+constants over affine recursions — unbounded generator, 220/220 certified |
Splits are tier-stratified and FROZEN across generations (later builds pass
--freeze), so one fixed 26-task heldout set anchors every RL comparison.
Multi-turn and retries. On failure the grader's actual output goes back as a user turn — "Your proof failed. Verdict: compile_error … Provide a corrected tactic block" — and the model tries again (≤3 turns in eval, ≤2 in RL). Who benefits from retries, per the solved-by-turn distribution (v3 heldout, 2 episodes/task):
| model | turn 1 | +turn 2 | +turn 3 | total |
|---|---|---|---|---|
| base gpt-oss-20b | 23% | +4% | +19% | 46% |
| base gpt-oss-120b | 48% | +15% | +10% | 73% |
| RL'd gpt-oss-20b (rl5) | 81% | +8% | +6% | 94% |
Two distinct effects: for base models, retries are a big multiplier (compile errors carry real signal — the weak 20b uses all three turns; note its odd +4/+19 pattern: turn 2 often repeats the mistake, turn 3 fixes it). After RL, most value is already in turn 1 (42 of 49 solves) — and when we train through the retry loop (rl11: episode-level reward, per-turn credit), the policy internalizes the correction: mean episode length falls 1.9 → 1.17 turns while episode success rises ~40% → 92% peak. RL converts a retry technique into a first-try skill.
Where the SFT data came from. No external corpus: the dataset's own
certified gold proofs. Each training example is the standard task prompt plus the gold tactic
block wrapped in a ```lean fence as the assistant turn, rendered by the tinker-cookbook
renderer's build_supervised_example — cross-entropy weights are non-zero only on
the assistant tokens, so the model learns to EMIT proofs, not to parrot prompts. 291 examples ×
3–4 epochs ≈ $0.30, lifting gpt-oss-20b 18.3% → 54.8% before any RL. (Same mechanism the
rl-rocq-lean plan calls "gold traces"; here gold = generator templates, there gold = harvested
successful rollouts.)
Before vs after: how the model's attempts change
Same model (gpt-oss-20b), same 20 held-out tasks, 8 samples each, before and after RL.
To separate format from skill we re-grade every base attempt under a
3-step normalizer (raw → leading-by stripped → hanging-indent fixed); method
and per-task table in scripts/analyze_format_vs_skill.py,
data in results/format_vs_skill/.
| condition | pass rate | Δ attributed to |
|---|---|---|
| base, as emitted | 18.8% | |
base, leading by stripped | 50.0% | +31pts: by duplicating the harness's := by (117/160 attempts) |
| base, + hanging-indent fixed | 79.4% | +29pts: continuation lines indented as if inside by (123/160 attempts) |
| RL'd checkpoint, as emitted | 90.0% | +10.6pts: genuine proof skill |
have/generalizing persist or grow) · right: the decompositionRead this honestly: most of the headline RL lift on THIS model is output
protocol, which reward fixes in ~4 iterations — and which a 20-line normalizer in the harness
would also fix for free. The defensible skill claim is the last +10.6pts, and it concentrates
exactly where you'd want: tier-3 helper-lemma tasks
(myadd_comm 2/8 → 5/8, mylen_rev 4/8 → 7/8,
mytake_drop_app 5/8 → 6/8 after full normalization), while
mydouble_add stays 0/8 → 0/8 — RL sharpened competence, it did not invent
mathematics.
What a genuine (non-format) base failure looks like — the most common
skill error is inducting without generalizing, which leaves the induction
hypothesis conditional and the goal unprovable (base attempt, format-normalized, still
fails):
have h1 : myadd n m = n + m := by induction n with ← no `generalizing m` | zero => simp [myadd] | succ n ih => simp [myadd, ih, Nat.add_comm, ...] ... -- lean: unsolved goals -- ih : myadd n m = n + m → myadd m n = m + n ← ih is CONDITIONAL -- ⊢ myadd m n + 1 = n + (m + 1)
simpa [myadd, ih m]
("function expected at ih"), a simp set that loops to maxRecDepth, re-declaring the whole
theorem inside the block, and prose leaking into the fenceThe RL'd model's passing version of the same helper lemma — correct
generalizing clause and a simp set that terminates:
intro n m
have h_add : ∀ n m, myadd n m = n + m := by
intro n m
induction n generalizing m with
| zero => simp [myadd]
| succ n ih =>
simp [myadd, ih, Nat.succ_add, Nat.succ_eq_add_one,
Nat.add_comm, Nat.add_left_comm, Nat.add_assoc]
simp [h_add, Nat.add_comm]
myadd_comm (passes; 5/8 samples succeed vs base 2/8 normalized)Thinking length barely changes (~2.5k → 2.9k tokens): the improvement is in the emitted
proof, not extra deliberation. And the format lesson generalizes: protocol failure was also
the real task's measured blocker (base models 0.0 because of lakefile.toml) —
this experiment shows reward eliminates that failure class quickly once gradient
exists.
Learning curves
Model selection (inference grid, pre-RL)
gpt-oss-120b is both the strongest and the cheapest evaluator (59.8%, $0.12/run). gpt-oss-20b was picked for RL: most headroom, cheapest training ($0.36/Mtok), and pass@4 = 50% meant half the task groups already carried gradient.
Findings
- Grading is a non-problem. Single-file
leanchecks run in 0.07s. The model emits only the tactic block — the harness owns the theorem statement and appends#print axioms, closing statement-weakening,sorry, injected axioms andnative_decide. Audits of 1,050+ passing proofs found zero hacks. - The binding constraint is curriculum, not compute. GRPO gradient exists only in groups with mixed pass/fail. Qwen3.5-4B went deterministic-bimodal by iter ~10 (zero-variance groups → zero gradient). Widening the task pool (46→97 tasks) roughly doubled gradient lifetime and took the same model family from a 55% plateau to 88.5%.
- RL beats scaffolding, and they compose. 3-turn error feedback lifts base gpt-oss-20b from 23% to 46%; RL alone reaches 88.5% single-turn; together, 94.2%.
- Multi-turn RL works first try. Training through 2-turn error-feedback episodes (episode reward, per-turn datums) took heldout episode success from ~40% to 92.3% peak while mean episode length fell 1.9 → 1.17 turns — the policy internalizes the correction instead of relying on the retry. This is the training-loop structure the real agentic task needs.
- SFT warm-start: 6× cheaper, lower ceiling. SFT→GRPO converges fast and concisely ($2.30 for a 150-iteration run) but collapses sample diversity — pass@k ≈ pass@1 — so GRPO starves and caps at ~80% where cold GRPO reached 88.5%. Gentler SFT plus higher temperature does not recover the gap. Use SFT where the base scores ~0 or cost dominates; go cold where ceiling matters.
- Task factory works. Parametric generators (src/factory.py) certified 220/220 auto-generated tasks in one pass — continual curriculum generation is mechanically easy in this format.
- Robust to hyperparameters. A 4× learning-rate range (4e-5 to 1.6e-4) lands within ±5pts at iteration 19, and a replicate run confirms; only lr 1e-5 lags (and is still +30pts). No collapse at any setting tried.
- Gains transfer. Checkpoints score 90%+ on task families that did not exist in the training universe, and on difficulty tiers where the base model scored 0%.
Path to the real rl-rocq-lean isomorphism task: scale the task generator into a continual curriculum at the capability frontier, SFT-warm-start where base pass rate ≈ 0, then port the loop to the multi-turn tool setting.
Transcripts on Docent
All 66,056 eval and RL transcripts are merged into one Docent collection, shared with the Theorem Labs organization (read access):
Inside Docent, filter by metadata.run to isolate any experiment — e.g.
grid1 (model-selection grid), rl5-gptoss20b-v3 (headline GRPO run),
rl9-warmstart-pool (SFT warm-start), mt-rl5-20b (multi-turn retries),
base-gptoss20b-heldout-v3 (base-model baseline). Each run also carries
task_id, iter/sample, tier, pass/fail
reason, and a reward/passed score. Known gap:
rl11-multiturn rollouts were not uploaded (its trainer doesn't write rollouts.jsonl).
Registry: results/docent_collections.json (key __merged__);
reproducible via scripts/merge_docent_collections.py.