Files
Maven/docs/evals/2026-08-08-routing-heads-two-head.md
claude e69f1bd0cf Fix the floor corpus and re-measure the destination head (V-661)
The first 120 floor rows carried one sentence shape, because the generator
varies a topic and ambiguity is not a topic. Rotating six shapes takes the
floor 3/7 to 6/7 and the destination mean 75.8% to 80.8%.

Calendar stays 3/6 at every seed. The possessive agenda rules claim those
cases at stage 0 and name nothing, so no label reaches the head.

Also corrects the floor-case count in three files: five of the seven are
homelab, not six.
2026-08-08 20:10:14 +04:00

8.8 KiB
Raw Permalink Blame History

Two heads on e5-small, and the first destination the router did not need a model for

Measured 2026-08-08 on workpc (Radeon RX 7900 GRE, ROCm). Covers V-661, step 3 of docs/plans/18-routing-heads-on-e5-small.md. Workspace is ~/Programs/embed-training, scripts gen_query_source.py, label_source.py, build_heads_corpus.py, train_heads.py, score_confidence.py.

Two heads, not four

Intent is 7 classes and destination is 12 plus the SourceUnknown floor, sharing one masked mean pool over one forward pass. The plan asked for four. Two of them have no labels and neither is a GPU problem.

Mood is cut, not deferred. Maven's enum is neutral, happy, thinking, tired, confused and it describes her own reply state, not the speaker's emotion. psytechlab/EmpatheticIntents-ru was the only candidate and its 32 emotion labels do not map onto it. There is nothing to train against.

BIO slot tags stay in the MASSIVE body. No Maven-domain span corpus exists. 2026-08-08-massive-warm-start.md records that no second Russian slot-filling corpus is reachable at all.

The destination loss is masked with ignore_index. Only a query turn reaches queryWalk, so a reminder contributes nothing to it.

Where the destination labels came from

V-660 taught the router prompt to name a destination. That made gemma-4-12b a teacher, and this distils it.

Labelling the 300 query rows already in train_v5.jsonl gave 277 destinations. The shape was unusable: the floor 101, calendar 62, recall 47, and feeds and attention at zero. A 13-way softmax cannot learn a class with no examples.

gen_query_source.py is the destination half of gen_corpus.py and runs the same two passes. Gemma writes questions whose answer lives in one named place. The daemon's own routeSystem prompt then routes each one back. A line survives only when the intent is query and the source is the destination it was generated for. The glosses are copied verbatim out of route_system.txt, so the generator and the labeller work from one definition.

The prompt and the GBNF are dumped from internal/router/llmrouter.go by TestDumpPrompt, never retyped. The workspace held its own copies and V-660 changed both.

1229 kept of 2373 generated, 51.8%. Merged corpus is 3664 rows carrying 1727 destinations:

rows rows
the floor 220 world 132
calendar 180 money 126
tasks 169 list 124
recall 167 self, feeds, attention 120 each
weather 136 network 103
home 50

home is thin because the agreement filter rejected most of what was generated for it. A question about the house routes act more often than query. That is the filter working, and 50 is the finding rather than a shortfall.

The floor was regenerated once. The first 120 rows carried one sentence shape across eight topics. That shape was "что там с X" and its two synonyms. Every named destination varied and only the floor collapsed. The reason is that the generator varies a topic, and ambiguity is not a topic.

gen_query_source.py now rotates six floor shapes. A почему question, a yes or no question, and a question carried by intonation alone. Then a better-or-worse question, a status question, and an existence question. That is a fix to degenerate generation. It is not fitting to the fixture, whose floor cases are homelab operations and match none of the six.

The 1229 generated rows carry intent: null. Every one is a query by construction. There are five times as many as the corpus has query rows, so including them would make query half the intent corpus.

Result

Three seeds, two bodies, epoch chosen on the intent dev slice and never on a destination number.

body floor corpus intent mean destination mean
warm-started out/body_massive one shape 93.6% 75.8%
stock multilingual-e5-small one shape 93.6% 76.8%
warm-started out/body_massive six shapes 93.6% 80.8%

Best single run is destination 29/33 (87.9%), seed 0 on the rotated floor. Peak 1.68GB of 17.2GB, under four minutes end to end.

Against the two arms already measured on the same 33 labelled cases:

destination
classifier cascade (V-659) 12/33 (36.4%)
cascade + gemma-4-12b (V-660) 24/33 (72.7%)
two heads on e5-small 29/33 (87.9%)

A 118M encoder beats the 12B teacher it was distilled from, on the fixture. The per-destination split is where it happens: recall 15/15 and world 5/5. Recall was 0/15 on the cascade and 14/15 through gemma.

Read 87.9% as one seed of a mean of 80.8%, not as a headline. Three seeds score 29, 25 and 26 of 33. One case is 3 points on a fixture this small.

Intent is not comparable to the 73/96 and 81/96 figures those two arms scored. A softmax has no clarify class. The head's fixture is the 88 cases that carry an intent, and the 8 want_clarify cases are scored separately below.

The MASSIVE warm-start is worth nothing here either

Step 2 measured it at +0.4 points of intent accuracy and called that inside seed noise. Destination was the open question, because MASSIVE has a definition_word slot that looked like a SourceWorld signal sitting in a head already trained.

It is not. The two bodies score the same intent mean to one decimal. Stock is one point ahead on destination, which is a third of one case. Nothing here argues for keeping the warm-start step. Dropping it removes a dependency on a corpus pull that datasets 5.0 cannot do.

The floor moved, calendar did not

Before the rotation, all seven misses at seed 0 were the floor and calendar. The head named a destination where the fixture says walk the chain, and it was confident doing it. "почему сервер тормозит" read world at 0.80. "хватает ли места под новые бэкапы" read network at 0.82. Those are the five homelab cases V-659 flagged, where SourceRecall, SourceNetwork and SourceAttention all overlap. mavpoll writes its observations into the fact store recall reads.

one shape six shapes
the floor 3/7 6/7, 6/7, 5/7
calendar 3/6 3/6, 3/6, 3/6
recall 15/15 15/15 at seed 0
world 5/5 5/5

The floor was a corpus defect and it cost 3 cases. Sentence variety carried it, not homelab vocabulary, which the training rows still do not contain.

Calendar is 3/6 at every seed and is a different problem. It is the shape V-660 named. The possessive agenda rules claim those cases at stage 0 and deliberately name nothing, so no destination label reaches the head. Training cannot move a case the head never sees. That one wants the owner's call.

Max softmax separates, weakly, and the gate stays

The plan argues max softmax is a calibratable confidence where Confidence: 1.0 was a hardcode. Measured on the intent head:

n mean confidence
correct 80 0.897
wrong 8 0.705
want_clarify 8 0.685

The softest correct answer is 0.66 and 3 of 8 clarify cases sit below it. So a single cut buys three clarifies at no false-clarify cost, and no more.

The other five explain themselves. "напомни" scores 0.94 as reminder and "сделай это" scores 0.80 as act. Both are intent-certain and slot-empty, and confidence was never the signal there. gateLLMDecision already catches exactly that shape, an act with no allowlisted fn or a keyless fact, and it keeps doing so. The head replaces the hardcode. It does not replace the gate.

An incident worth recording

The first generation run produced zero rows for eight destinations. mavgpud yields the card when another process wants it (V-488) and llama-server answers 503 until the model is back. Every generate call inside that window burned one of the destination's batches. The run walked its own cap without a single successful call. The log said 503 260 times, and the summary line said 64.1% keep rate, which read as success.

call() now retries a 503 with backoff. A generator that treats an unloaded model as a bad generation is a silent-corpus bug, not a slow one.

What this does not measure

Nothing here runs in Go. The heads are a heads.pt and an out/body_heads/ on workpc. Reaching the daemon needs an ONNX export and a caller. The resident e5-small must not be replaced by this copy: recall depends on that file, and EmbedQuery/EmbedPassage are its contract.

The destination fixture carries 4 of 13 classes: recall 15, floor 7, calendar 6, world 5. tasks, money, list, home, network, feeds, attention and self have no gold case. So 78.8% is silent on eight destinations that together hold 800 training rows.

Latency was not measured. A forward pass of a 118M encoder should beat a 1.7B decoder on a query turn. That is arithmetic, not a number from this box.