Local models break things well and test them badly
Field note. One model, two seats, one run — and the economics that fall out of it.
The comparison, inside a single process
corral’s adversarial pool has three seats. A mutant-generator plants violations in your code. A test-writer authors tests to kill the ones your suite missed. A test-critic reads the result for tests that pass without testing anything.
In one run we put qwen3.5:9b-q8_0 — a 9-billion-parameter model on one
consumer GPU — in both the generator and the writer seat. Same model, same
file, same process, same forty-five minutes. The target was
internal/admission/admission.go: 117 lines, standard library only, with a real
test suite written by a human.
As the generator it worked:
dev-adequacy: the dev's OWN tests scored 41% (killed 9 of 22 graded mutants, 13 survived — bugs the dev's tests miss)Twenty-two of its mutants compiled and were graded. Thirteen defeated a real, human-written test suite.
As the writer, in the same run:
test-writer could not produce a compiling test after 3 attempts — 13 survivor(s) found but not proven-killedNot a bad test. No test. Three attempts, none of which got past go vet.
A hosted model on the identical thirteen survivors proved eight of them.
It is not a Qwen problem
One model family proves nothing, so we swapped the writer seat to gemma4:12b —
different lab, larger model — and changed nothing else. It also failed, three
attempts, and the failures are worth reading:
admission_corral_test.go:1:1: expected 'package', found Theadmission_corral_test.go:21:6: Lease redeclared in this blockProse leaking above the package line, and a model redefining a type the
package already exports. Both are the same miss: it wrote a self-contained
answer instead of a file that composes with the code around it. The hosted
challenger, same run, proved 13 of 15.
It is not simply “Go is strict”, either
Go rejects a lot at the gate — an unused import, a redeclared type, wrong arity.
So we ran the same shape against Python, where the compile check is
py_compile, which validates syntax and nothing more.
We predicted the local writer would clear the weaker gate and then fail at runtime. That prediction was wrong, and the way it was wrong is the finding:
the pool's authored test graded soundly (CompliantPass=true CanaryKilled=true Total=3) but killed NONE of the 3 survivor(s) — a real 'tried and missed', not an ungraded runIt wrote a valid, sound, correctly-passing 15-case test suite. It parsed, it
ran, it passed against the unmutated code. It simply killed nothing. The tests
it wrote were test_thumb, test_large, test_all_presets — a textbook
characterization suite, re-verifying the happy paths the developer’s tests
already covered. It never went near the survivors. The hosted model, same three,
killed two.
So the gate was never the barrier. It only changed where the failure became visible: Go rejects the local writer before it can be graded, Python lets it through to be measured, and the measurement says it tried and missed.
What the generator seat actually delivers
Meanwhile the same class of model, in the destructive seat, is doing real work.
Auditing mahmoud/boltons — a pure-Python utility library with 472 passing
tests — a local generator produced 261 gradeable mutants across ten files.
Of those, 142 survived the project’s own suite, and a hosted writer then
proved 46 of them catchable by execution.
Not one of those 261 mutants cost an API call.
The economics nobody mentions
The generator is the heaviest seat in the pool. It plants dozens of mutants per file; the writer authors one test per file. So the seat that consumes the most compute is exactly the seat a local model is good at.
Measured on a 5-file Go cell, with the generator and goal-derivation local and only the writer billed:
39,043 input / 1,700 output tokens · 18 model calls · 13 minutesThat is roughly 7,800 paid input tokens per file for a full adversarial audit. Our earlier estimate — made when the generator was also hosted — was about three times higher. Moving the heavy seat onto hardware you already own is most of the difference.
Four things that will bite you
A reasoning model can return an empty answer with no error. Qwen 3+,
DeepSeek-R1 and Gemma 4 route their answer through a separate thinking field.
When the budget runs out mid-reasoning the request still returns HTTP 200 with an
empty body:
gemma4:12b plain thinking=424c content=0c done=lengthgemma4:12b think:false thinking=0c content=19c done=stopWe nearly published “a Google 12B model cannot author a Go test” on the strength
of three reproducible empty files. It was our missing think: false. Three
identical failures is not evidence when you have not checked the transport.
Newer beats bigger. qwen2.5-coder:14b produced zero usable mutants on a
Go file — every SEARCH anchor carried one extra leading tab, so nothing applied.
The smaller, newer qwen3.5:9b does the same job well. Patch-structure fidelity
tracks model generation, not parameter count.
Concurrency costs VRAM, and nothing warns you. A --swarm N flag reads as
“audit N files at once”, which is right for hosted models — someone else’s GPU.
Against a local daemon it also means N concurrent KV caches stacked on a resident
model. With 9.8GB of weights on a 16GB card, --swarm 2 timed out; --swarm 1
graded the same file at 0.84.
Default timeouts are written for hosted latency. Eight of sixteen files in
one repo failed with context deadline exceeded at a 300-second per-request
ceiling. Nothing was hanging — the box was a consumer GPU doing a long prefill.
Raising it graded twelve of them and took that panel’s proven-gap count from 36
to 53. The default is now 600s.
What we would tell someone standing up a local herd
Put the local model where the work is destructive. Planting a fault needs no compositional understanding of the surrounding package, and a bad output is free — the compile gate throws it away and records it as evidence about the generator rather than about your tests.
Keep a hosted model where the work is constructive. Authoring a test means composing with existing code, satisfying a compiler, and aiming at a specific gap. That is where every local model we tried fell over, in two languages, from two labs.
A mixed herd is not a compromise between a cheap tier and a good one. It is putting each model where its actual strength is — and the pleasant accident is that the expensive seat is the one that runs for free.
Evidence: two Go repositories and one Python library, one arm each, single
runs. The writer-seat failures reproduce across two model families and two
languages; the generator results are from internal/admission/admission.go and
mahmoud/boltons. Numbers are counts, not rates, because n is small — and every
kill rate we publish carries run-to-run variance we have
measured
and do not hide.