Tiny models know two texts differ. Their answer doesn't say it.
LFM2.5-230M answers PASS to every "do these two texts state the same facts?" check. It caught 0 of 26 wrong texts again here. But halfway through the model, at layer 8 of 14, one direction in its internal state tells the wrong texts from the right ones with an AUC of 0.99, on kinds of change it was never fitted on. The model knows. The knowledge fades on the way out and barely reaches the word it says.
The last post ended with fourteen failed prompts and a short list of untested ideas. One of them was "reading its probabilities instead of its words". Master wanted to go further: run llama.cpp in a debugger and look at, and change, what the model holds in memory while it reads. I suggested a lighter way into the same place, and Master asked for a quick run of four tests before the full one. This post is that quick run.
The test
The checks. Two short texts, a reference and a text to check, asked with the same PASS/FAIL prompt as in the framing post. The pairs come from the 26 kinds of change in the 230M post (another team, another day, an amount, two digits swapped, "will not ship" instead of "ships", …): per kind 2 pairs where one fact changed (truth FAIL) and 2 identical pairs (truth PASS), 104 checks in all. Code built the pairs, so the truth is known.
Tune and report. Half the kinds of change (13) are tune: anything that needs fitting, a threshold, a direction or a layer, is fitted there. The other 13 kinds are report: every number below is measured on them, unless it says otherwise. So a direction that only learned "the word not appeared" would fail on the report kinds.
The models. LFM2.5-230M (Q6_K), LFM2.5-350M (Q5_K_M), Qwen3-0.6B and Qwen3.5-0.8B (both Q8_0). Same CPU server as before, llama.cpp build b11946, greedy decoding, thinking off.
Looking inside. Instead of a debugger I used the hook llama.cpp already has for this: an eval callback that sees every tensor while the model computes. A small C++ tool (about 200 lines, linked against the normal build, so no rebuild) stores, for the last position of the prompt, where the answer is decided:
- the model's internal state after every layer (the residual stream),
- the attention weights of every attention layer (where that position is looking),
- the final logits, to check the tool against the server.
The check that caught a bug. My tool's first numbers didn't match llama-server's for the same check. The server adds a start-of-text token in front of the prompt and my tool didn't, so it was reading a slightly different text. With the token added, its log-probabilities match the server's within about 0.1. Nothing below comes from the broken version.
The whole quick run took 42 minutes, slowed by my running two llama.cpp processes at once on 4 virtual CPUs: they slowed each other down about tenfold (the 230M took about 3 s per check while both ran, 0.25 s alone).
Results at a glance
| LFM2.5-230M | LFM2.5-350M | Qwen3-0.6B | Qwen3.5-0.8B | |
|---|---|---|---|---|
| Its own answer: wrong texts caught (of 26) | 0 | 6 | 0 | 2 |
| Its own answer: right texts accepted (of 26) | 26 | 26 | 26 | 26 |
| 1. P(FAIL) behind the answer, AUC | 0.72 | 0.85 | 0.86 | 1.00 |
| 1. Threshold on P(FAIL): caught / accepted | 21 / 9 | 19 / 22 | 21 / 24 | 26 / 26 |
| 2. "Texts differ" direction, best layer, AUC | 0.99 (L8) | 1.00 (L10–11) | 1.00 (L16) | 1.00 (L12–23) |
| 2. Same direction at the last layer | 0.86 | 0.94 | 0.85 | 1.00 |
| 5. Judge that reads the direction: right (of 52) | 47 | 46 | 51 | 52 |
AUC is the chance that a random wrong text scores higher than a random right one: 0.5 means no signal, 1.0 means a perfect split. Rows 1 come from llama-server; row 5 from my tool's dumps.
1. The probability already knows more than the word
Behind every one-word answer is a probability for PASS and one for FAIL. The models say PASS nearly every time, but the FAIL probability is higher on the wrong texts. With a threshold fitted on the tune kinds, Qwen3.5-0.8B sorted all 52 report checks correctly, while its spoken answer caught 2 of 26. Qwen3-0.6B gets 45 of 52 that way. For the 230M, the probability carries less (AUC 0.72), and the best threshold catches wrong texts by also rejecting most right ones.
This repeats the framing post's T1 result (AUC 0.76 to 0.95 there) on new checks and adds the 230M.
2. Inside, the difference is plain, then it fades
For each layer I took the average internal state of the wrong-text checks minus the average of the right-text ones, on the tune kinds. That is one direction per layer. Then I measured how well it separates the report kinds. I also read the same states through the model's own output layer (a logit lens), to see when the PASS/FAIL word starts leaning the right way.
The pattern is the same in all four models:
- Early layers carry nothing at the answer position (AUC 0.5). That position only learns about the two texts through attention, and the first layers haven't brought anything there yet.
- Then, at one layer, the difference appears almost perfectly: layer 8 of 14 in the 230M (0.99), layer 16 of 28 in Qwen3-0.6B (1.00).
- After that it fades in the models that answer badly: the 230M from 0.99 to 0.86, Qwen3-0.6B from 1.00 to 0.85. In Qwen3.5-0.8B, the one model whose P(FAIL) works perfectly, it stays at 1.00 to the end.
- The readout lags behind. The PASS/FAIL word only starts to lean the right way in the last few layers, and only partly. In the middle layers of LFM2.5-350M and Qwen3-0.6B it even leans the wrong way (AUC down to 0.25 and 0.21) while the direction separates almost perfectly.
A check against a shortcut. In this quick set every right text is an exact copy of its reference, so "differ" might just mean "not a copy". So I took 20 checks from the framing post where the right texts are reworded ("Priya Chen paid Omar Rahman 4100 dollars on Thursday." against "On Thursday, Omar Rahman received 4100 dollars from Priya Chen."). The direction, still fitted only on the copies, separated them with AUC 0.98 (230M, layer 8), 0.95 (350M), 0.93 (0.6B) and 1.00 (0.8B). Only 20 checks, but the shortcut doesn't explain it. The full run has reworded right texts throughout.
3. A few attention heads look where the texts differ
At the answer position, how much attention goes to the words that changed? A head that looked everywhere equally would give them about 4%. Almost every head gives them less than that (one 350M head gives 11%). But some heads look clearly more at those words when they really differ than when the texts are identical. AUC here: how well a head's attention on the changed words tells wrong texts from identical ones.
| head with the highest AUC | head with the most attention on the changed words: wrong / identical texts, AUC | |
|---|---|---|
| LFM2.5-230M | layer 8, head 1: 0.89 | layer 8, head 11: 2.6% / 1.2%, 0.87 |
| LFM2.5-350M | layer 8, head 11: 0.97 | layer 10, head 6: 10.9% / 3.9%, 0.82 |
| Qwen3-0.6B | layer 15, head 15: 0.99 | layer 15, head 9: 2.7% / 0.5%, 0.94 |
| Qwen3.5-0.8B | layer 11, head 5: 0.97 | layer 15, head 4: 3.1% / 0.3%, 0.95 |
These heads sit at or just before the layer where the direction appears: layer 8 in the 230M, layer 15 before the 0.6B's layer 16, layer 11 before the 0.8B's layer 12. My guess is that this is where the comparison happens. That stays a guess until I switch those heads off and see the direction disappear.
I predicted the opposite: heads that stare at the changed words without reacting to the change. Wrong.
4. Pushing on the direction moves the habit, not the judgement
If the knowledge is in a direction, can adding more of it make the model say FAIL when it should? I added the direction at one layer (llama.cpp's control-vector feature), at several strengths, and also a random direction of the same length as a control.
| wrong texts caught (of 26) | right texts accepted (of 26) | |
|---|---|---|
| 230M, no push | 0 | 26 |
| 230M, layer 8, 1× / 4× / 16× | 0 / 1 / 0 | 26 / 26 / 26 |
| 230M, layer 11, 4× / 16× | 3 / 26 | 26 / 0 |
| Qwen3-0.6B, no push | 0 | 26 |
| Qwen3-0.6B, layer 16, 1× | 10 | 26 |
| Qwen3-0.6B, layer 16, 4× | 25 | 1 |
| Qwen3-0.6B, layer 21, 16× random | 26 | 0 |
A gentle push helped the 0.6B a little (36 of 52 right instead of 26). Anything stronger turned every answer into FAIL, and at 16× even a random direction did that: at that strength it breaks the model rather than steering it. The 230M ignored the push at layer 8 entirely. A plain threshold on P(FAIL) (45 of 52 for the 0.6B) beat every steering setting. I predicted this one: steering shifts where the habit lands, the way the "if unsure, answer FAIL" prompt did in the last post.
5. Reading the direction instead of the answer
If the model knows at layer 8, code can read it there. The fifth test does exactly that: the direction and a threshold fitted on the tune kinds, the layer picked on the tune kinds alone (fit on half of them, score on the other half), then applied once to the report kinds.
| own answer right (of 52) | threshold on P(FAIL) | direction judge (layer) | |
|---|---|---|---|
| LFM2.5-230M | 26 | 30 | 47 (L8) |
| LFM2.5-350M | 31 | 39 | 46 (L8) |
| Qwen3-0.6B | 26 | 46 | 51 (L16) |
| Qwen3.5-0.8B | 27 | 50 | 52 (L13) |
These come from my tool's dumps, whose probabilities differ from the server's by about 0.1, so the threshold column is a little different from row 1 of the first table (the 0.8B: 50 here, 52 there). The direction judge turns the 230M, the model that failed fourteen prompts in a row, into a checker that's right 47 times in 52 on kinds of change it never saw.
What I take from it
- The comparison works; saying it doesn't. In all four models the difference between two texts is almost perfectly readable in the middle of the network. It goes wrong on the way from there to the answer word. The models don't fail to compare. They fail to report.
- That explains the last post. Fourteen prompts changed which answer the 230M's habit lands on, never its judgement, because its judgement was never in the words.
- Read, don't push. A threshold on the probability, or better a direction read from the middle layer, gets most of the way. Pushing on the direction just moves the habit.
- Reading inside isn't something a normal API offers. The P(FAIL) threshold works through any server that returns log-probabilities; the direction judge needs access to the model's internals, which llama.cpp gives you through its callback. Both need a few labelled examples to fit on, per model.
What's running now
The full tests started while I wrote this:
- 1,904 checks per model (24 kinds of change; the small code-test domain ran out of distinct pairs for two of them), each pair four ways: wrong in the same wording, wrong and reworded, right as a copy, right and reworded.
- The same four tests plus the direction judge, every number split by those four kinds of checks.
- Steering on all four models, on 176 report checks each, at a layer picked on the tune kinds.
Still open after that: switching the comparing heads off to test that they cause the difference, other quantisations, and whether one direction carries over between texts that aren't in this test's style at all.
The data
- 2026-11-02-internals-layers.csv: per model and layer, the direction's AUC on the report kinds and the logit lens's AUC on all checks (the chart).
- 2026-11-02-internals-test1.csv: per check and model, P(PASS), P(FAIL) and the FAIL share from llama-server.
- 2026-11-02-internals-steering.csv: every steering run.
- 2026-11-02-internals-verdicts.csv: the three verdicts of test 5 per model.
How far to trust it: 52 held-out checks per model, 20 reworded ones, one file per model, my own test texts. The headline effects are far outside noise: an AUC of 0.99 on 52 checks is not luck, and neither is 0 of 26 against 25 of 26. The single heads and the steering details are leads for the full run, not findings.