experiment · 2026-07-08--aengus--tinker-rl-derisk

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.

18% → 88.5%
gpt-oss-20b held-out pass rate, 80 GRPO iters on the v3 curriculum (~$14)
11% → 90.6%
on task families never present in training — transfer, not memorization
94.2%
RL checkpoint + 3-turn compile-error feedback (base 3-turn: 46%)
18→55→88→94
the ladder: base → SFT warm-start → GRPO → GRPO + error-feedback retries (v3 heldout, %)

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
one GRPO iteration (16 problems × G=8 = 128 rollouts, ~300k sampled tokens, ≈$0.09 on gpt-oss-20b)

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
everything self-contained; each grade = one tempfile + one 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.

versionsizetrain / heldoutwhy it exists
v146hand-written seed set; rl1 trained on it
v275eval-onlyOOD probe: definition families never trained on
v39771 / 264 difficulty tiers (47/18/23/9); the frozen ID comparator
v4317291 / 26parametric 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.

runmodeltrain datawhat variedheld-out result
rl1gpt-oss-20bv160 iters22.9 → 85.4% (OOD v2: 10.9 → 90.6%)
rl2 / rl3Qwen3.5-4Bv1 / v2rl3: temp 1.15, 12k tokensdead by iter ~10 — variance exhaustion
rl4gpt-oss-120bv340 iters45.2 → 76.0%
rl5gpt-oss-20bv380 iters — headline run18.3 → 88.5% (OOD v2: 90.0%)
rl6 / rl7 / rl8gpt-oss-20bv3lr 1e-5 / 1.6e-4 / seed 1, 20 iters48.1 / 73.1 / ~76% @ it19 — LR-robust
sft1gpt-oss-20bgold proofsSFT only, no RL18.3 → 54.8% for $0.30
rl9gpt-oss-20bv43-epoch SFT warm-start → GRPO, 150 iterscapped 79.8% — SFT entropy tax
rl10gpt-oss-20bv41-epoch SFT + temp 1.2 remedy72.1% — remedy insufficient
rl11gpt-oss-20bv4multi-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

runmodeltasks / itersbaselineafter RLcost
rl1gpt-oss-20bv1 · 6022.9%85.4%~$8
rl1→v2gpt-oss-20b ckptv2 heldout (unseen families)10.9%90.6%$0.15
rl5gpt-oss-20bv3 · 8018.3%88.5%~$14
rl4gpt-oss-120bv3 · 4045.2%76.0%~$4.5
rl2/rl3Qwen3.5-4Bv1/v2 · killed ~1025.0%55.0%~$6
mtrl5 ckpt, 3 turnsv3 heldout46.2% (base 3-turn)94.2%$0.08
rl9gpt-oss-20bSFT+GRPO · 317-task pool · 15018.3%79.8%$2.3
rl10gpt-oss-20bmild-SFT + temp1.2 GRPO · 8018.3%72.1%$1.5
rl11gpt-oss-20bmulti-turn GRPO · 2-turn episodes · 60~40%92.3% peak$6.5
sftgpt-oss-20bv3 · 4 epochs on 71 golds18.3%54.8%$0.30
sweepgpt-oss-20bv3 · 20 · lr 1e-5 / 4e-5 / 1.6e-418.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
a tier-3 task as the model sees it (defs abridged)

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]
tier 3+ needs helper lemmas discovered inside the tactic block — 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).

generationtaskst1/t2/t3/t4splitwhat's new
v1 hand templates4625/9/8/434 train / 12 heldNat, List, Tree families
v2 unseen families7539/16/16/455 / 20pow, fact, even, snoc, sumlist, take/drop — for the transfer test
v3 harder curriculum9747/18/23/971 / 26filter/count/all with if-splits, tree height, distributivity
v4 parametric factory317 pool84/127/97/9291 / 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):

modelturn 1+turn 2+turn 3total
base gpt-oss-20b23%+4%+19%46%
base gpt-oss-120b48%+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/.

conditionpass rateΔ attributed to
base, as emitted18.8%
base, leading by stripped50.0%+31pts: by duplicating the harness's := by (117/160 attempts)
base, + hanging-indent fixed79.4%+29pts: continuation lines indented as if inside by (123/160 attempts)
RL'd checkpoint, as emitted90.0%+10.6pts: genuine proof skill
format vs skill decomposition
left: regex feature frequencies per attempt (format pathologies vanish under RL; substantive features like have/generalizing persist or grow) · right: the decomposition

Read 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)
other real base failures on the same task: 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 fence

The 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]
RL'd gpt-oss-20b on 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

rl5 learning curve
rl5 · gpt-oss-20b · v3 curriculum (97 tasks) — best run
rl1 learning curve
rl1 · gpt-oss-20b · v1 (46 tasks) — plateaus once train pool saturates
rl4 learning curve
rl4 · gpt-oss-120b · the strong model responds equally well
rl3 learning curve
rl3 · Qwen3.5-4B · fast burst, then group-variance exhaustion
rl9 curve
rl9 · SFT warm-start + GRPO on 317-task pool — cheap, capped by entropy loss
rl10 curve
rl10 · mild SFT + temp 1.2 — remedy insufficient
rl11 curve
rl11 · multi-turn GRPO — retries trained away

Model selection (inference grid, pre-RL)

cost vs pass scatter
grid1 · 5 models × 46 tasks × k=4 · $1.40 total · 244s
tier breakdown
pass rate by difficulty tier — base models die above tier 1

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

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):

tinker-rl-derisk — ALL runs

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.