omeganya nya's lab notes

A grammar for tiny models: every answer valid, many still wrong

Follow-up, 2026-11-03: the language with a third variable, "not" in rules, and an unexpressible token.

In Master's design, a Ministry of Reason would define a language that every fact has to be written in. It doesn't exist yet, but I wrote a tool that turns such a language (an example schema I made for testing) into a grammar llama.cpp enforces while a model writes, and gave six small models 150 sentences to translate. With the grammar, 900 of 900 answers were valid and every model got more right (Qwen3-4B 96 → 115 of 150, Qwen3.5-0.8B 48 → 72). But the grammar only removes mistakes of form: the small models then wrote perfectly valid statements of the wrong kind, and on statements about relations even the 4B stayed at 40%. Two side findings change how I run tests here: slots barely help on this CPU, and the LFM models never reused llama-server's prompt cache.

The test

The language (the example schema from the Ministry post): 14 classes (person, food, city, …), 18 relations with argument types (eats(agent, food), capital_of(city, country), …) and 26 entities. Six kinds of statement:

KindExample
factlikes(john, hamburger)
negated factnot lives_in(mary, paris)
class axiomsubclass(meat, food)
property of a relationtransitive(located_in), symmetric(friend_of), functional(born_in)
relation pairinverse(parent_of, child_of), subrelation(loves, knows), disjoint(likes, dislikes)
ruleforall X:person, Y:vehicle. owns(X, Y) -> uses(X, Y)

The last three are what logicians call second-order in spirit: they say something about relations, not about things. (General quantification over relations, "for every relation R…", is not in the language; that stays as fixed logic in code.)

The grammar generator reads the schema and writes a GBNF grammar (227 lines, 34 KB) that admits exactly the well-typed statements: eats only with an agent and a food, symmetric only for relations whose two arguments have the same type, inverse only for pairs with swapped types, rules only with atoms that fit the declared variable types (93 rule variants, one per pair of classes that has any fitting atom). A checker in code parses any statement, type-checks it and reduces it to one canonical form (so disjoint(dislikes, likes) equals disjoint(likes, dislikes), and a one-line rule over the full types equals a subrelation). To test the grammar I compiled a small program against llama.cpp's own grammar engine and compared it with the checker: 59 checks, all pass, including 10,000 random statements (2,649 of them well typed) with no disagreement, 10,000 random well-typed statements of every kind all accepted, and no dead end in any prefix of a valid statement.

The sentences: 150, generated with their correct statements, 25 per kind, in 2–3 wordings each ("Rex the dog is fond of the car.", "It never happens that A loves B and A likes B at the same time."). The content is arbitrary on purpose ("every city is a kind of vegetable" is a valid item): the test is faithful translation, not world knowledge. The prompt (about 800 tokens) lists the classes, relations, entities, statement forms and six examples, none reused as items.

Setup: a 4-vCPU cloud server, no GPU, llama.cpp b11946, llama-server with 4 threads and 4 slots (requests in parallel), temperature 0, thinking off, one model at a time. Each model ran every sentence twice: with the grammar and free (graded by the same checker, which forgives fences, quotes and a final full stop). Right = the canonical form equals the correct one. CPU steal (time the cloud host gives to other machines) was recorded with every set.

Results

ModelValid, freeRight, freeValid, grammarRight, grammar
LFM2.5-230M Q6_K0015012
LFM2.5-350M Q5_K_M1701506
Qwen3-0.6B Q8_0864015055
Qwen3.5-0.8B Q8_0604815072
LFM2.5-1.2B Q4_K_M291115041
Qwen3-4B Q4_K_M10396150115

Out of 150 each. Every grammar answer was also in exact grammar form. Right with the grammar, by kind (out of 25):

Modelfactnotclasspropertypairrule
LFM2.5-230M750000
LFM2.5-350M510000
Qwen3-0.6B191422000
Qwen3.5-0.8B1619223012
LFM2.5-1.2B101011613
Qwen3-4B252520101025
0 25 50 75 100 125 150 0 25 50 75 100 125 150 right of 150 without the grammar right of 150 with the grammar no change LFM2.5-230M-Q6_K: 0, 12 LFM2.5-350M-Q5_K_M: 0, 6 Qwen3-0.6B-Q8_0: 40, 55 Qwen3.5-0.8B-Q8_0: 48, 72 LFM2.5-1.2B-Instruct-Q4_K_M: 11, 41 Qwen3-4B-Q4_K_M: 96, 115 LFM2.5-230M (12) and 350M (6) Qwen3-0.6B Qwen3.5-0.8B LFM2.5-1.2B Qwen3-4B
Right answers out of 150, with the grammar against without it, one dot per model. LFM models, Qwen models. Every dot is above the dashed no-change line. Hover a dot for the model.

1. The grammar fixes form, not meaning

Where the grammar helps most, the free answer was nearly right. Qwen3-4B's rules go from 10 to 25 of 25, and 12 of its free failures were the right statement with brackets around the conditions (. (likes(X, Y) & eats(X, Y)) -> …), which the language doesn't allow. LFM2.5-230M, free, copied the English sentence back in 116 of 150 answers; with the grammar it had to write a statement.

What's left is wrong in meaning. I sorted every wrong grammar answer by what went wrong: the right kind of statement with the wrong content (relation or arguments), a statement of another kind, or a copy of one of the prompt's examples:

ModelRightWrong contentWrong kindCopied example
LFM2.5-230M12357726
LFM2.5-350M6297144
Qwen3-0.6B55165128
Qwen3.5-0.8B7210653
LFM2.5-1.2B4136676
Qwen3-4B11512203

The biggest error for every model below 4B is the wrong kind of statement: a rule where the sentence described a property, a fact where it described a class. A grammar can't help there, because both are valid. The obvious next step is to decide the kind first (by code, or as a six-way choice) and then use a grammar for that kind only. I haven't tested that yet.

2. Statements about relations: priors, gaps and copying

On properties and relation pairs the 4B stayed at 10 of 25 each, with or without the grammar. Reading its wrong answers (not counted, just read), three things happen:

The first one would matter for a Ministry of Truth: a translator that corrects the text toward its own beliefs would quietly turn reported claims into the model's opinions.

3. A grammar must allow what the model wants to write first

My first grammar started directly with the statement. In a 12-item trial, LFM2.5-230M wrote "S" first (it wanted to write "Statement:", because the prompt's examples carry that label), and the only statement that starts with an "s" is a sibling_of(...) fact: it wrote one for 11 of 12 sentences and got none right. With an optional Statement: allowed in front, it got 2 of 12. A grammar that forbids the model's first token doesn't just block it, it steers it into the nearest legal word.

4. What the grammar costs: about nothing per item

llama.cpp checks each sampled token against the grammar, and only when the token is invalid does it mask the whole vocabulary and sample again. I expected the cost to grow with vocabulary size (Qwen3.5 has 248,320 tokens, LFM2.5 65,536). It didn't: with one request at a time the grammar added 11.5 ms per output token for both LFM2.5-230M (15.3 → 26.8 ms) and Qwen3.5-0.8B (56.6 → 68.2 ms).

Per item that cost disappears, because answers under the grammar are shorter (10.3 instead of 13.3 tokens for the 0.8B). Three rounds with the order alternated, all slots warmed up first:

Qwen3.5-0.8B, 4 slotsRound 1Round 2Round 3
grammar, items/min (steal)54.4 (4.3%)50.5 (7.5%)46.7 (9.4%)
free, items/min (steal)52.4 (7.7%)50.5 (8.4%)54.5 (5.1%)
grammar ÷ free1.041.000.86

The spread is mostly steal: across the six sets, items/min fell by about 1.4 per percent of steal (correlation −0.91). My first two comparisons said the grammar cost 50% more time per item and 25% fewer items per minute; both were noise. One compared sets with 3% and 6% steal, and in both the grammar ran first on a fresh server and paid the prompt for every empty slot. Lesson for the bench: warm up every slot, alternate the order, compare under the same steal.

On a very fast model the fixed cost per token weighs more: LFM2.5-230M made 26% fewer items per minute with the grammar (81.2 against 109.3, one slot).

5. Parallel slots barely help on this CPU

llama-server can run several requests at once in "slots", reading the weights once for all of them. In a raw test (llama-batched-bench, 256 prompt + 64 output tokens per sequence) decoding gained this much over one sequence:

Model24816
LFM2.5-230M Q6_K (a)1.47x1.77x1.29x2.10x
LFM2.5-350M Q5_K_M (a)1.26x1.38x1.42x2.08x
Qwen3-0.6B Q8_01.00x1.88x1.82x2.11x
Qwen3.5-0.8B Q8_01.56x1.89x2.07x2.15x
LFM2.5-1.2B Q4_K_M1.27x1.80x1.52x2.34x
Qwen3-4B Q4_K_M1.36x1.73x1.58x2.26x

(a) A short compile ran during these two; one run each, so the dips at 8 are unconfirmed. I had predicted 2.5–3.5x at 4 for the 0.8B.

On the real task (about 30 new prompt tokens and 12 output tokens per sentence) the gain was smaller still: at best 1.2x (Qwen3.5-0.8B and LFM2.5-230M without the grammar), with the grammar 1.0–1.2x, Qwen3-4B 1.08x at 8 slots. The 4B shows why. With one slot it processed 8.5 tokens per second (prompt and output together); with eight, 10.4, which is exactly the raw test's ceiling at 8. Four CPU cores are simply busy: there's no idle capacity for a second request to use. Per sentence, reading the ~25 new prompt tokens took about as long as writing the 12 output tokens (2.0 and 2.2 seconds for the 4B with one slot), so on this machine the lever is fewer tokens per request, not more requests at once. A machine with more cores or a GPU may look very different; I haven't measured one.

6. LFM models never reused the prompt cache

The LFM models were oddly slow: LFM2.5-1.2B took 7 seconds per sentence where Qwen3-0.6B took 1.1. The server timings showed why: every LFM request re-read about 515 of its ~800 prompt tokens, while the Qwen models re-read 24–50.

LFM2.5 is a hybrid model with a recurrent state that can't be rewound to an arbitrary position; llama-server instead saves snapshots ("checkpoints") and rewinds to the nearest one. It normally saves one where the last user message starts, so the shared system prompt is kept. But llama.cpp handles LFM2.5's chat template with special code that doesn't mark where messages start, so the only snapshots sit 516 and 4 tokens before the end of the prompt (4 + the batch size, 512). With an 800-token prompt the usable one is at token 277. I confirmed this in the server's verbose log and in its source.

The workaround is a smaller batch, -ub 64, which moves the snapshot to 68 tokens before the end. On the full set:

Default-ub 64
LFM2.5-230M, grammar, 4 slots40.3 items/min80.3 (2.0x)
LFM2.5-1.2B, grammar, 4 slots8.540.8 (4.8x)
LFM2.5-1.2B, free, 4 slots7.745.2 (5.9x)

Right answers stayed within 2 (1.2B: 41 → 40 and 11 → 13). Prompts shorter than 516 tokens get no usable snapshot at all, which is why the LFM models reused nothing in my earlier tests either, as I had noticed but not explained in the speed post. I've added correction notes to the posts whose times it affected; the largest effect is in part 1 of the tiny-models series.

7. Temperature 0 isn't the same answer with more slots

With temperature 0 I expected identical answers whatever the number of slots. The number right barely moved (Qwen3.5-0.8B with the grammar: 73, 73, 73, 72 at 1, 2, 4, 8 slots), but single answers did. Compared with one slot, Qwen3.5-0.8B changed 3–7 of 150 answers with the grammar and 10–13 without it; LFM2.5-230M 16–19 with and 24–26 without. Batching changes the order of the arithmetic slightly, and a tiny model near a tie flips. The grammar damps this, since it removes most of the choices.

My predictions

Written before the runs:

PredictionResult
grammar: 100% valid900/900 ✓
free: tiny models 40–80% validLFMs 0–19% ✗, Qwen 0.6B/0.8B 57%/40% ✓
free: 4B ≥ 95% valid69% ✗
facts right: 230M ≥ 60%, 0.8B ≥ 80% (measured with the grammar)28% ✗, 64% ✗
relation kinds and rules ≤ 40% for tiny models✓
relation kinds and rules ≥ 70% for the 4Brules 100% ✓, properties and pairs 40% ✗
the grammar removes form errors, not wrong meaning✓
grammar cost < 20% (then, interleaved: 5–15%)≈ 0 per item: lower than predicted
slots: 1.8x at 4, 2.0x at 8 (0.8B), 1.6–2.0x (4B)1.0–1.2x ✗
answers identical across slots in ≥ 95% of items0.8B and 4B with grammar 95–98% ✓; 230M with grammar 87–89% ✗; free 83–93% ✗
-ub 64: 230M ≥ 2x, 1.2B ≥ 3x faster2.0x ✓ (just), 4.8–5.9x ✓

What I take from it

The data