The scripts are in the repository, not in the installed package. Clone it,
then
cd recipes/community/which-half-can-a-small-model-take before running the commands below. Browse this recipe on GitHub.Result
Qwen/Qwen2.5-1.5B-Instruct, LoRA SFT on 129 rows (24 distinct asks) from the
incumbent’s own clean traces on the boring half only. One L40S, 159 seconds,
$0.09. Paired on the same held-out asks, 8 rollouts each; noise floor from
three passes of identical untrained weights on the same container.
Against the incumbent, paired on the same asks:
The caveat that comes first
The “beats the incumbent” rows are close to tautological, and should not be quoted on their own. The incumbent here injects stylistic flaws — apology, hedging, sycophancy, boilerplate, leaking internal notes, claiming success after a fault — at a fixed 35% rate, uniformly, independent of how hard the ask is. Training on the subset a program marked clean therefore produces a model that does not do the thing that was filtered out. Any imitator would win. A real production agent’s failures correlate with difficulty; these do not, so this run says nothing about whether a small model can match a frontier agent on genuinely hard asks.The number that is not tautological
The base, untrained 1.5B scores 0.670 on the boring half and 0.298 on the hard half. Nothing was trained; that 0.37 gap is a property of the task split alone, measured against a noise floor of 0.039 on the hard slice. That is the routing signal, and it is one an engineer can act on at runtime for free: the tool result is already in hand before the reply is written, so routing on “did the tool fault” costs no model call and no classifier. The base model’s failure has one name. Of its hard-slice failures, the dominant mode isignore_fault — it reads a permission_denied or a
malformed payload and cheerfully tells the customer the action succeeded.
That is the specific thing a 1.5B cannot be trusted with out of the box.
The result I did not expect
Training on the boring half transferred to the hard half it never saw: 0.298 → 0.951, withignore_fault falling to 15 rows out of 296. Twenty-four
distinct examples of “say what the tool actually returned” were enough to fix
fault-handling the training set never demonstrated. So the split I trained
against stopped being the split that mattered — after SFT, both halves are
safe, and the routing question dissolves. If that replicates, the advice is
not “route the hard half to the big model” but “spend twenty examples and stop
routing”.
Honest limits
- The boring delta clears its noise band by 0.002 (+0.239 against a 0.237 band). It is a pass, but a marginal one. The band is wide because that slice has only 23 asks — see below.
- The holdout is smaller than the library’s own power calculation wants.
holdout_size(0.10, base=0.70, k=8)says 37 tasks per compared slice; the boring slice has 23, so it is powered for roughly +0.15. The cause is a hard ceiling on the keyless path (#698): the offline writer stops at ~120 distinct asks no matter whatbudget=,situations=orunique_situations=are set to. - One seed, one training run. The noise floor is across eval passes, not across training seeds, so it bounds evaluation noise only.
- The grader was tuned against the incumbent’s phrasings. It may under-detect novel flaws from the small model, which biases in favour of the small model. See below for what it was checked against.
- Training reward and eval metric are the same program, deliberately, so any gap is the split rather than reward design. It also means this run says nothing about reward hacking.
Is the grader worth trusting?
Everything here rests on a program that reads a trajectory and decides whether the reply followed policy. Becauseseeded_agent records what it planted on
each row, that program can be checked against ground truth before it is
trusted on a model whose rows carry no labels:
hidden state:, expected outcome here is) is a leak. select()’s own
built-in leak gate did not make this mistake — it dropped 0 of the same rows.
Cost line
Measured on this run, not estimated from a spec sheet. Generation throughput is 496 replies per 25.5 s on one L40S at $1.95/hour:
0.009 you can retrain on every deploy and never notice.
Add the cold start if you serve scale-to-zero: model load was ~25 s of the
first pass’s 51 s.
Run it
wai.seeded_agent, the
reward is a program, and the Modal container only generates text — grading
happens locally, with the identical code that graded the incumbent, so the two
arms cannot be scored by different rules.
Run ids: Modal app ap-UjbpoQmEijbyasZmiS1XXH (L40S, 159 s), smoke
ap-KiuXxz60VS9Z6BmvqkRuQc (CPU). Both stopped.
What did not work
- Splitting on
tier. Every row fromsimulatecarriestier∈ {ordinary,ambiguous,boundary,adversarial}, which looks exactly like the difficulty label you want. It is not a routing signal here: the incumbent’s pass rate is 0.72 onordinaryand 0.66 on the rest, intervals overlapping. Splitting on whether the tool faulted separated the base model by 0.37; splitting ontierseparated it by nothing worth reporting.tieris also undocumented — it appears nowhere indocs/or the README, so it is a field you find by printing a row. select(mode="sft")left too little to train on. It keeps the best completion per prompt (rejection sampling, correctly), which took 129 clean rows down to 24 — one per distinct ask. With the ~120-ask ceiling that is not a training set, so this recipe trains on all 129 decontaminated clean rows and reports both numbers.out/train_sft_selected.jsonlholds whatselectwould have kept.- Recording to the platform.
wai.platformexposestrack,Experiment,Run,RunRecordand ~100 other names;trackwants an agent, and I have three arms of offline completions. No recipe in the tree posts scores from code, so there was nothing to copy, and the numbers live inresults.jsoninstead of a dashboard. - Raising the holdout past the ceiling. Covered above and in #698.