Making a small model write OCaml

Every method we tried to lift Qwen3 4B/8B on LiveCodeBench-multilingual OCaml, ranked by what it bought: 150 held-out problems, 16 samples at t=0.6, pass@1 against the hidden tests, paired bootstrap on every delta. The winning recipe is a stack (distil, GRPO, thinking, best-of-16 by the printed tests) reaching 0.5686 from 0.0733; the 4B follows to within 3.5 points. The verified self-loop lifts the best single sample to 0.3808, and feeding its pools back in at k=50 reaches 0.4059 single-sample and 0.6067 with the test filter. Sonnet 5 one-shots 0.8867 of the same problems.

On this page
  1. The stack, stage by stage
  2. What each family bought
  3. The self-distillation loop: the filter decides whether it co
  4. More data: MBPP-X transfers in only one direction
  5. The current best recipe: verified self-loop pools, k=50, bro
  6. RL from the base model does not work here
  7. The prompt is worth as much as the training
  8. Sampling and selecting, against the oracle
  9. Errors by difficulty and failure type
  10. Selecting without running anything: the correctness probe
  11. The ceiling: Sonnet 5 on the same problems
  12. Replicating the Agnostics paper
  13. What did not work
  14. Protocol

The stack, stage by stage

Bar is pass@1; the tick is the oracle, the best any picker could do from those same 16 samples. Thinking and best-of-16 are inference-only, so the untrained model gets them too.

Both trained and untrained end within 3 to 7 points of their own oracle: the distance between 0.5686 and 0.4859 is training raising the pool, not the picker improving.

StageWhat it ispass@1gainoracle@16
Qwen3-8B
0standard prompt, one sample0.07330.2533
1distil on its own thinking-mode answers that compile0.2246+0.1510.4067
2GRPO on that checkpoint, hidden-test reward0.2350+0.0100.4600
3thinking enabled at deployment0.3546+0.1200.6133
4best-of-16 by the printed tests0.5686+0.2140.6133
untrained + thinking 111 probs0.2545+0.1810.5045
untrained + best-of-160.2467+0.1730.2533
untrained + both 111 probs0.4859+0.4120.5045
Qwen3-4B
0standard prompt, one sample0.01870.0733
1distil from the 8B's compiling thinking-mode answers0.1733+0.1550.3867
2GRPO on that checkpointrunning
3thinking enabled at deployment0.3246+0.1510.5667
4best-of-16 by the printed tests0.5332+0.2090.5667
untrained + thinking0.0842+0.0660.3467
untrained + best-of-160.0733+0.0550.0733
untrained + both0.3422+0.3240.3467

What "distil" means here, precisely

No larger model is involved: the teacher is the same 8B with thinking enabled (0.2545 against 0.0733), and its rollouts become targets with the reasoning trace stripped. The only filter is the type-checker (1,854 of 2,392 kept); filtering harder toward correctness is monotonically worse (0.2263 > 0.2125 > 0.2017). LoRA rank 32, saturating at 2 epochs.

What each family bought

Best arm per method, same base model and eval.

MethodSignal it trains onpass@1
self-distillation x4, verified filter, thinkingown answers passing the train problems' tests, accumulated0.3808
distillation then GRPO, thinkingcompiling own answers, then hidden tests0.3546
distillation, thinkingcompiling own answers0.3287
distillation, no thinkingcompiling own answers0.2246
SMC-EMpublic tests, resampled0.1237
self-filter, no teachercompiles0.1171
GRPO / RLVR from basehidden tests0.1062
base + 5-line OCaml cheat sheetnothing, prompt only0.0975
IGUANA, importance weightingpublic tests, weighted0.0963
Qwen3-8B, untrained0.0733
The gap is the teacher, not the algorithm. Everything that learns only from a verifier applied to its own base-model samples lands in 0.09 to 0.12. What breaks out is training on answers a stronger sampler produced, here the same 8B with thinking on. RL adds to that checkpoint but cannot substitute for it.

The self-distillation loop: the filter decides whether it compounds or collapses

Each round: k=8 thinking rollouts of the 300 training problems from the previous checkpoint, filter, strip the traces, train a fresh LoRA on the base model; eval thinking, k=16, held-out 150. Every cell is the same measurement: pass@1 on the held-out 150 in thinking mode, estimated from 16 samples per problem. In the charts, solid lines are pass@1 and dashed lines are oracle@16 from the same rollouts (the share of problems where any of the 16 samples passes); the verified line is drawn wider, so on the shared trunk through round 2, where the two chains are the same run, both colors stay visible. The columns differ only in the filter applied to the TRAINING data between rounds: compile keeps every rollout that type-checks; verified keeps only rollouts that pass their own training problem's tests (the train split's suite, never the eval set) and accumulates kept pools across rounds. The chains are identical through round 2 and diverge at round 3. Starting points: untrained 8B 0.2485 (re-measured on the full 150 in the k=50 re-eval), untrained 4B 0.0842; the 4B's round-1 adapter (239 rows) scores 0.2550, so the loop's first and smallest round does most of the lifting.

round8B, compile8B, verified4B, compile4B, verified
untrained0.24850.0842
10.3287 (= sol_think)0.2550 (239-row adapter)
20.33870.3025
30.32250.36630.24580.2967
40.31620.38080.20750.3213

The compile-filter loop collapses and its own pool hides it: both sizes peak at round 2 and fall, the 4B by 9x the ±0.006 noise floor, while pool pass keeps rising (0.3654 to 0.4029 on the 8B). Verification turns collapse into compounding: keep only rollouts that pass their own training problem's tests, accumulate the pools, and the chain rises to 0.3808. Accumulation alone is not the fix (0.3246), and this was bet against in writing: purity loses to compile-filtering in one-shot distillation, but it is the only filter that resists distribution narrowing.

The 4B chain never saw a teacher: 0.0842 to 0.3213, against 0.3246 for the teacher-distilled 4B. Round 1 trains on 239 rows the untrained model produced and already reaches 0.2550; the next three rounds add 0.066 between them.

More data: MBPP-X transfers in only one direction

Matched A/B, both arms a fresh LoRA on base Qwen3-8B, same hyperparameters, pools generated by the same checkpoint; arm B adds Ag-MBPP-X rows to the LCB training set.

armrowsLCB 150Ag-MBPP-X 95
A: LCB only1,4970.33290.6178
B: LCB + MBPP-X3,1310.34210.6796
seed noise (same data, two runs)1,497+0.0058

The MBPP-X gain is decisive (+0.0618, CI [+0.024, +0.101]); the LCB gain (+0.0092, P=0.723) is indistinguishable from the seed-noise row. Transfer runs one way only, and mixing 52% out-of-domain rows costs nothing on LCB. ±0.006 is the seed-noise floor for single-run adapter comparisons here.

The current best recipe: verified self-loop pools, k=50, broader data

A fresh LoRA on the base model per size, trained on k=50 thinking rollouts drawn from the round-3 verified self-loop checkpoints over both training sets (LCB-train 300 and MBPP-X-train 264), pass-filtered. Evaluated on the same 150 held-out problems at k=50.

cellpass@1+ compiles+ public testsoracle@50
8B, thinking0.40590.50000.60670.6933
4B, thinking0.33150.42000.58000.6467
8B, no thinking0.19640.24000.37330.4333
4B, no thinking0.1557

Both campaign records, and the pool improved as much as the picker: oracle@50 reaches 0.6933 against 0.6733 for the previous best 8B pool. The nothink rows move the other way (0.1964 and 0.1557 against 0.2246 and 0.1733 for the original distillation): training on verified thinking rollouts buys thinking accuracy and costs direct-answer accuracy.

This does not overturn the MBPP-X result above. These models change four things at once against that matched A/B: a stronger generator, k=50 rather than k=8, the hidden-test filter rather than the compile filter, and MBPP-X rows added. The clean question was answered by the matched pair, and the answer was no.

RL from the base model does not work here

Six levers, each its own trained arm against the same 0.0975 reference.

Leverpass@1vs reference
graded reward: compile 0.25 + public 0.25 + hidden 0.50.1062+0.009 (P=0.93)
2 epochs instead of 10.0975+0.000 (P=0.51)
the paper's training cheat-sheet prefix0.0892−0.008 (P=0.10)
hidden-test reward via SMC-EM0.0838−0.014
the paper's own 2,000-problem training set0.0717−0.026 (P=0.001)
per-token vs sequence loss, t=0.7 vs 1.0measured nulls

The graded reward is the informative failure: it lifted gradient-carrying rollout groups from 38% to 95% and still moved only +0.009, not significant. Starvation was real but not binding; the base model's own success rate is.

Practical reading. Spend the first compute on supervised data from a better sampler. On the distilled checkpoint the same GRPO is worth +4.4 and +2.6 points, both significant.

The prompt is worth as much as the training

Qwen3-8B, untrainedpass@1
bare prompt0.0488
one sentence of language guidance0.0733
a 5-line OCaml cheat sheet: operators, casts, I/O idioms0.0975

Five lines of prompt buy what the RL buys (0.0975 either way), and they stack: sheet + 16 samples + test filter reaches 0.2841 with no training. It hurts the strongest distilled checkpoints (−0.020 to −0.033), so: on for base and RL-band models, off for distilled ones.

Sampling and selecting, against the oracle

Model1 sample+ compiles+ public testsoracle
8B distil + GRPO + thinking0.35460.44550.56860.6133
8B distil + thinking (no GRPO)0.32870.41590.55500.5867
4B distil + thinking0.32460.39840.53320.5667
8B untrained + cheat sheet0.09750.20120.28410.2933
4B untrained + thinking0.08420.31170.34220.3467
4B untrained, no thinking0.01870.05250.07330.0733

The filter uses only tests printed in the problem statement and recovers 92 to 97% of oracle on every arm. It only re-ranks what the sampler produced, so it cannot replace training.

What changes at k=50: the pool is not saturated

The two best thinking arms resampled at 50 per problem, same 150 problems. Everything else on this page stays at k=16; this is the only table where two ks meet.

armkrandom+ compiles+ public testsoracle
8B distil + GRPO + thinking160.35770.44870.57270.6180
500.38670.44000.59330.6867
8B distil + thinking (no GRPO)160.33170.42700.56370.6047
500.34670.43330.60000.6733

pass@1 is stable (0.3596 at k=50 against 0.3546 at k=16), so the k=16 rows are not undersampled; the pool keeps growing (oracle +7 points on both arms); and the filter converts part of it, best-of-50 reaching 0.6000. No sample passed the hidden suite while failing the public tests, so the ceiling is set entirely by plausible-but-wrong programs.

Errors by difficulty and failure type

Every sample of every stage, labelled with difficulty and one mutually-exclusive failure reason; compiling-but-failing samples were re-executed against the hidden suite.

Stageeasy (50)medium (46)hard (54)all
Qwen3-8B
0. untrained0.20250.01770.00120.0733
1. distil0.57870.09780.00460.2246
2. distil + GRPO0.58370.11140.01740.2350
3. distil + GRPO + thinking0.74120.26090.07640.3546
untrained + thinking 111 probs0.59700.13710.03120.2545
Qwen3-4B
0. untrained0.05630.00000.00000.0187
1. distil0.44370.06930.01160.1733
3. distil + GRPO + thinking0.72500.19570.06370.3246
untrained + thinking0.22500.02580.00350.0842
The headline is carried by easy problems. Hard problems end under 0.08 on every trained arm: training made the model reliable on what it nearly solved, not able to solve what it could not. The untrained 4B scores exactly zero on all 46 medium and 54 hard problems.

What the failures are

Absolute = share of all 16 samples (comparable across stages); relative = share of that stage's failures only.

Stagepasswrong answercrashtimeoutsyntaxtype errorunboundtruncatedother
Qwen3-8B, % of all 16 samples
0. untrained7.36.54.22.033.021.320.52.52.6
1. distil22.524.89.810.24.916.66.42.52.4
2. distil + GRPO23.525.67.510.35.416.26.22.23.1
3. distil + GRPO + thinking35.510.89.85.79.38.27.011.02.7
distil + thinking, no GRPO32.98.712.06.010.47.58.610.73.2
untrained + thinking25.44.86.01.322.112.014.411.03.0
Qwen3-4B, % of all 16 samples
0. untrained1.91.00.80.848.811.224.98.22.5
1. distil17.323.912.88.66.518.28.12.42.1
3. distil + GRPO + thinking32.514.910.56.210.013.87.60.73.9
untrained + thinking8.41.20.60.249.012.919.36.91.5

Training relocates errors more than it removes them: distillation collapses syntax from 33% to 5% of samples and the mass reappears as wrong answers (6.5→24.8%) and timeouts (2.0→10.2%). Thinking converts wrong answers into passes (25.6→10.8%) and pays in truncation (2.2→11.0%), a budget artifact and the clearest remaining lever. The dominant crashes are stdin parsing, the teacher's Scanf pathology at runtime.

Selecting without running anything: the correctness probe

Without a toolchain or sandbox, can a classifier read the model's own activations and pick the right sample? One teacher-forced prefill, mean-pooled hidden states from one layer, logistic regression; baselines are the compile gate and an off-the-shelf LM judge.

Does it discriminate?

ProbeWinner on validationPooled AUROCWithin-problemMixed problems
base 8B, linearmean pool, layer 27/36, C=0.10.83650.66037
base 8B, MLPlast pool, layer 36/360.90950.73337
base 4B, linearmean pool, layer 36/36, C=0.010.88450.74711

The drop from 0.84 pooled to 0.66 within-problem is the whole story: most of what the probe knows is which problem is hard, and only within-problem ranking matters to a selector. An MLP head buys +0.073 AUROC while changing not one selected sample.

The selection ladder

Selection ruleWhat it costsuntrained 8Bdistilled 8B
one sample, no selection0.07330.2246
probe argmax1 forward pass0.12670.3067
compile gateocamlc per sample0.14770.2579
LM judge, Python-trained, zero-shot8B forward pass0.10670.2733
compile + LM judgeboth0.20000.2933
compile + probeboth0.17330.3333
public testsexecution + sandbox0.24670.3844
public tests + probeboth0.24670.4000
oracle0.25330.4067

Does the probe know anything the tests do not? On the distilled checkpoint, yes: +0.0156 over the public filter (CI [+0.0036, +0.0303]), closing 70% of the gap to oracle. On the untrained model it moves nothing.

The probe wins where it is deployed: on the distilled checkpoint it beats the compile gate (0.3067 against 0.2579) with no toolchain, because distillation fixed the syntax errors the compiler was catching. The public-test filter stays strongest wherever a sandbox exists.

Negative results, and what to distrust

Pipeline and reproduction: experiments/ocaml_clf/ (dataset build, sharded activation extraction, probe training, ladders), reusing the LCB probe tooling unchanged.

The ceiling: Sonnet 5 on the same problems

One reply per problem, no retries, no feedback, no tools: a sandboxed subagent with every tool denied, verified by a live probe; compilation and grading stay on our side. The prompt is byte-identical to the content the Qwen3 arms are evaluated with.

armpass@1 (standard 150)full test split (154)
Sonnet 5, frozen eval prompt0.8867 133/1500.8831

0.8867 in one sample, against 0.4059 for our best single-sample arm: the headroom is capability, not prompt or selection. Sonnet type-checks 96.7% of programs, so the syntax battle that consumes the small models does not exist for it; its 18 failures are almost all semantic.

Why this was run: teacher data, not just a number. Every reply is recorded with its reasoning trace, a correct-by-execution OCaml pool from a far stronger sampler. The sandbox blocks lookup but cannot rule out pretraining exposure, which would inflate the capability claim without damaging the teacher-data value.

Replicating the Agnostics paper

Their reported OCaml numbers and ours under their protocol: their prompt, their sampling, their comparator, 20 samples at t=0.2.

Cellreportedoursverdict
Qwen3-4B, untrained11.8reproduces
Qwen3-8B, untrained04.4does not reproduce
8B, RL gain over its own baseline+7+6.5reproduces
4B, after their RL recipe72.3does not reproduce

Their 8B baseline is the anomaly, not their pipeline: their 4B baseline lands where ours does, and we exhausted the request side. Their headline 8B 0 to 7 is measured against a baseline our replication puts at 4.4. Their 4B gain does not reproduce under LoRA on any axis; full fine-tuning on 8xH100 is the one untested lever. The effect size itself replicates, +6.5 against their +7.

What did not work

Protocol