The plan was 30M → 85M. Both halves of that were wrong, and the corpus told on them.

The talk model answers “tell me about the ocean” and “tell me about mountains” with openers that rhyme. That’s been the known weakness for a week: input-sensitivity 0.65, against llama3.2:3b’s 0.73. The fix seemed obvious — more parameters, more registers, less monotony.

So the day started by measuring the corpus, which is not the same as assuming things about it.

conversation.jsonl        117,658 pairs
distinct 4-word openers    85,911
opener entropy              15.84 bits of a possible 16.39   (96.6% of maximum)
top opener share             0.26%

That is a corpus with essentially nothing wrong with it. Now the model:

corpus contains its top opener at    0.26%
the 30M emits it at                 10.4%     ~40x
the 85M emits it at                 25.0%     ~96x

Forty times. The diversity was already in the data and something was throwing it away.

argmax is a monotony machine

reply() generated with logits.argmax(). Greedy decoding takes the mode of the distribution on every single step, so two inputs landing in nearby hidden states emit byte-identical strings — which is exactly what a stem-diversity metric punishes. The model was never failing to represent variety. It was being asked, at every token, to discard it.

The fix is sampling, and sampling immediately breaks something else. stable — same input, same reply, 8 out of 8 — is a floor every checkpoint currently passes at 1.00, and it exists because a chat model that answers differently each time is not usable.

So the RNG is seeded from a blake2b hash of the prompt. One input always decodes to one reply; different inputs start from different RNG states and explore different regions instead of all funnelling to the mode. Stem-diversity by construction.

Not hash(). Python salts string hashing per process, so hash() would have made replies stable within a run and different across runs — the precise failure that floor is built to catch, and one a single-process benchmark would never have surfaced.

decodercleaninput-sensstem-divsubjectstablelatency
greedy (was)0.940.650.650.801.00529ms
k=3 t=0.5 (now)0.920.940.941.001.00576ms
llama3.2:3b0.210.730.731.001.0020,618ms

0.65 → 0.94, past the 3B on the one axis it was winning. No retraining. No new pairs. The two hundredths of clean we paid is truncation — babble went to zero.

The 85M is worse, and that’s the interesting part

Same corpus, same probes, 2.8× the parameters:

talk-121k/talk-30m   clean 0.90   input-sens 0.58    534ms
talk-121k/talk-85m   clean 0.85   input-sens 0.58   1158ms

Identical input-sensitivity. Worse fluency. Twice the latency. And its top opener fires a quarter of the time versus the 30M’s eighth.

More capacity doesn’t learn to discriminate. It learns the dominant register harder — sharper mode-seeking, deeper collapse. Third time scaling has lost in this repo, after the reader wall and the size sweep. Publishing it because it’s negative.

Then the same lesson, twice more

Teaching the model to say “I don’t understand.” Every checkpoint scores 0.00 on nonsense, and so does the 3B. Ask it "asdkjfh alskdjfh" and it says “Ah, that’s amazing! Any plans for a short walk instead?” — a fluent, confident answer to keyboard mash.

A gate in front of the model would have fixed the number today: three rules, 6/6 recall, 0/42 false positives, measured. It wasn’t used. talk_bench calls itself an un-bribable ruler for the owned model, and filtering its input while reporting the model’s score is precisely the Day 76 failure wearing a new hat.

So: fix the weights. Which produced this.

abstain on nonsense    0.02 → 0.45     looks like progress
FALSE abstain on real  0.00 → 0.31     it now refuses "thank you so much"

Worthless, and the two-sided bench is the only reason that was visible. A model that abstains at everything scores 1.00 on the flattering axis.

The diagnosis was in which real inputs it refused:

real inputs it REFUSED   mean 16.8 chars    'hi!'  'thanks!'  'hey there'
real inputs it ANSWERED  mean 27.2 chars

Every one short. And the training corpus, checked afterward:

OOD training inputs   mean 15.0 chars
real corpus inputs    mean 34.0 chars

Length alone almost separated the two classes. The model learned length. Every per-family score falls out of that and nothing else: symbols scored 9/10 because it’s the shortest family, repetition 2/10 because it’s the longest and looks real. It never learned what nonsense is. It learned what short is.

The card grader had the same disease. grade_batch was dropping each card’s code field before showing it to the checker — so 41% of html cards and 59% of css and web-api cards were being judged on their question text alone, and condemned for it. All three html flags were false positives. With --apply, that deletes good cards.

The one that worked

compose/focus.py. “I never said he ate my sandwich” is seven words and seven different claims, and the only thing that changes is which word carries the pitch accent. Stress I and someone else said it; stress said and you implied it; stress my and he ate the wrong person’s lunch.

The accent marks a contrasted constituent, and the implicature is mechanically derivable. No model, no library, no network — a lexicon and word position.

20/20. llama3.2:3b scores 4/20, which is chance. 2.5µs against 469ms.

Its failure mode is the tell: the 3B picks one meaning per sentence and ignores the accent entirely. That isn’t a knowledge gap a bigger model closes — it’s a structural relation a fluent model has no reason to represent. Which makes it a composer’s job, and the thesis in miniature.

What today actually was

Four systems, one disease. Argmax took the mode because it was cheaper than discriminating. The cold checker graded on question text because that was all it was shown. The fine-tune learned short because short was sufficient. The 85M learned the dominant register because capacity spent on the majority pattern pays better than capacity spent on the tail.

None of them broke loudly. Every one produced a plausible number first — 0.45 abstention looked like progress, three flagged html cards looked like a working grader, and a bigger model looked like the obvious answer to monotony.

A training result needs a story for why it worked before it can be believed.

The corollary is cheaper than it sounds: measure the data before you blame the model. Twenty minutes of counting openers would have saved the 85M run — and it’s the reason the decoder fix took an afternoon instead of a fortnight of minting.