CLAUDE.md was 805 lines and it is loaded into every session, so every line
costs. The routing section alone was 412 of them, and it was a chronological
log of every measurement since 2026-07-31: four re-measurements of the same
fixture, the history of each of the four routing heads, and the reasoning
behind every grammar.
None of that is a rule. An agent about to edit the router needs to know that
the classifier is the floor, that queryWalk only takes sources out, and that
heads_path must never point at model_path. It does not need the seed spread of
the third head to read the file at all.
So docs/routing.md is a living doc under the tier convention, and it carries
the reasoning and the numbers. CLAUDE.md keeps the constraints and points at
it. 805 lines to 490, with the routing section at 60.
The same cut is applied to the header block and to the world chain under
non-goals: the current fact and the eval filename stay, the "measured on date
D it went from A to B" narrative moves out or is dropped.
Nothing was deleted without checking. Every backticked literal in the old file
was diffed against the two new ones, and the forty that fell out were reviewed
one by one. Nine were facts rather than narrative and are restored: the
ecosystem default URLs, the voice.llm_router flag and pickLLMRouter, the four
head eval filenames, handlePraxisAct, SourceAccuracy, and the rule that
calendar-query names the calendar where the possessive agenda rules do not.
A closing section states the file's own contract, so the next agent adds a
measurement to docs/evals/ instead of a paragraph here.
diff-budget.sh blocked on 1544 changed lines. It counts markdown, which the
repo's own pre-commit hook exempts, and this commit touches nothing else.
The 2026-08-09 model swap was measured on routing the same day and E4B lost
four destination cases. Phrasing was not measured, and phrasing is the half the
owner hears.
E4B scores nudges 15/15 and the talk fixture 29/36 at p50 516ms, against the
resident model's 25/36 at p50 2.97s on the same 36 cases. lang, feminine and
address are all 36/36, where the resident model loses three on address. Every
failure is ontopic and none is a parse error.
29/36 is one case off the ceiling. The temperature sweep of 2026-08-05 found
two reply cases that fail at every temperature and named a defect in the reply
path, capping the fixture at 30/36. Both are in E4B's failure list. So the swap
costs nothing on phrasing.
One defect no check catches: in chat E4B writes "Я записала несколько идей!"
when nothing was stored. A claim to have saved something is a claim about state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
A ZIM title carries a leading capital and the utterance does not: /A/фотосинтез
is a 404 and /A/Фотосинтез is a 200. TitleCandidates tries the spoken form
first, so a title that begins lowercase on purpose keeps its chance.
That takes the measurement from four right to five, and the fifth is the one
that mattered. "столица Франции" returned "Список столиц Олимпийских игр"
and now returns Париж, through a title redirect the ZIM already held. The
2026-08-05 measurement named that case as the one no lexical signal could
reach. Retrieval by title reaches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Kiwix ranks by keyword overlap, which the package doc has said since it was
written: "why is the sky blue" finds a TV episode. queryKiwix sent the whole
Russian sentence, because the verbatim path added by V-508 skips the rewriter
that would have reduced it.
Measured against the Russian ZIM on 2026-08-09, over eight questions. Four
reach the right article where they did not: TCP was "Перехват TCP-соединения"
and is TCP, фотосинтез was "C4-фотосинтез" and is Фотосинтез, Линус Торвальдс
was "Tux", and "кто написал Войну и мир" was "Радуйся, мир (Доктор Кто)".
Two were already right and stay right. Two are still wrong and were wrong
before. Nothing regressed.
kiwix.Topic drops the narrative request, the interrogative and a verb behind
one, and keeps everything else. A word it cannot classify is more likely the
topic than noise. TitlePath tries the exact article first, since a ZIM is
addressable by title and a wrong title is a 404.
The gate this task set out to build does not exist. Query-to-passage cosine
scored 0.79-0.91 on answerable questions and 0.75-0.84 on unanswerable ones,
and the sets overlap. The wrong TCP article scored 0.8653, above five of six
unanswerable rows. e5 measures topic, not whether the passage answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Naming a destination takes the guessing query sources off a turn, and the
personal boundary is one of them. Every other guesser costs an answer when it
is wrongly dropped. This one costs the rule that a question about him never
reaches an upstream engine.
Three deciders name a destination now and two of them infer it: the routing
heads and the resident model. Decision.SourceAnchored says a stage 0 grammar
read the words instead. queryWalk honours it for the source marked
boundary: true and for no other, so the rest of the table is unchanged.
Owner's call of 2026-08-09.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Speech is the four piper fixtures mavsttd already scores against, so nothing
of the owner's voice is committed. Non-speech is white noise at the same RMS
as the clip beside it.
Silero calls 0 noise frames speech where the energy threshold calls 68 to 99,
and hears all four spoken clips. 509us per 30ms frame, 1.7% of one core on
the slower machine.
White noise is a floor and not a proof. It says nothing about a television,
which is speech, or a fan, which is narrowband.
silero-vad replaces the energy threshold when -vad-model points at it.
Everything after the speech decision is the same state machine: the speech
hold, the silence hold, the length cap and the utterance buffer.
The model window is 512 samples and the capture frame is 480, so silero.go
re-chunks across frames. main.go claimed the two matched, which was true of
silero v4.
Stage two, the wake word, is not here. It needs a Russian keyword model that
does not exist yet.
Owner's call. E4B is 4.2GB against 6.7GB plus a 0.86GB draft, so with CW2
resident the card holds 5.8GB of 16GB instead of 9.2GB.
Measured against a same-session 12B control on the 96-case fixture: 83.3% full
against 84.4%, 89.6% intent-only against 91.7%, destination 19/33 against
23/33, p50 294ms against 344ms. Destination is the column that moved. E4B names
nothing where the 12B names recall or calendar, which walks the whole chain
rather than answering wrong.
MTP is gone with the 12B and cannot come back. It is a separate gguf of
architecture gemma4-assistant with nextn_predict_layers=4, and the only one on
disk is trained against the 12B's hidden states. Neither target gguf carries
nextn tensors, so neither self-speculates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
serve.py lived only on workpc, which was fine while systemd launched it and is
not fine now that mavgpud does. Two endpoints and no framework: /health answers
503 until the model is loaded, /transcribe takes raw PCM and returns
{"text","confidence"}.
The unit carries CW2_TOKEN through EnvironmentFile and the child inherits it,
so the token is never a flag value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
CW2 is a ROCm process, so it registers on the KFD like any contender. Running
it as its own systemd unit made mavgpud yield llama-server to it every few
seconds. The gemma-4-12b arm was down for eight minutes on 2026-08-09 and
routing had silently fallen back to the resident model.
So mavgpud takes an `stt` block and runs the transcriber itself. `foreign` now
excludes every child rather than one pid, which is the fix. Yielding is all or
nothing, because a job that wants the card wants all of it. Idle unloading
stays llama-server's alone: CW2 holds 1.6GB and unloading it would only send
the next voice turn to the homesrv floor.
Maven still talks to the transcriber directly on 8081. There is no proxy,
because with no idle timer there is nothing for one to measure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
The block is inert until the code in PR #208 lands, and deleting it sends
every utterance back to mavsttd, which is what the box does today.
Port 8081 and not mavgpud's 8080, because whisper.cpp cannot load
CrisperWhisper 2.0 at all and it runs under transformers as its own service.
The token comes from deploy/telegram.env like every other secret here. It is
what stops anything on the LAN posting audio to that port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
sttSeam is modelSeam for audio and sits at the same place in wireVoice, so
the voice path and the meeting recorder share one transcriber as they
always have.
A box with no workstation.stt block behaves byte-for-byte as it did before
this existed: the floor is handed back untouched and nothing probes. An
empty URL is normalised to no block at all, the way the model block already
works.
Health defaults to the URL's origin rather than the URL itself, because the
transcribe endpoint names a path and appending would ask for
/transcribe/health. A block with no token logs once that anything on the
LAN can post audio to that port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Same arrangement as llm.Pair and for the same reason. The microphone is at
workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4%
WER in Russian against 27.5% for the ggml-small.bin homesrv loads. The
workstation is never assumed up: it sleeps, and the card is often held.
Admission is a cached atomic written only by the prober, so no voice turn
ever waits on a machine that may be asleep.
Speech-to-text has only the silent half of the degradation rule. A worse
transcript is still a turn, so there is nothing to name a gap about and
Transcribe always falls back. That is the whole difference from llm.Pair,
which also carries CompleteRemote for callers that must refuse instead. A
remote that dies mid-request corrects the cache and falls back in the same
turn, which is what TestPairFallsBackWhenRemoteFails pins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
mavsttd is whisper.cpp linked into a Go daemon and reached over a unix
socket. CrisperWhisper 2.0 cannot be reached that way. whisper.cpp derives
its language count from the vocabulary size, and CW2's 51897 tokens shift
seven special token ids, so it never loads at all.
So it runs under transformers on workpc and this is the client. Same
stt.Transcriber interface and one method, a second transport rather than a
second seam. The body is the PCM itself, because a minute of 16kHz mono is
under 2MB raw and the format is fixed by audio.PCM16kMono.
Audio is the most sensitive thing that crosses this seam, so the client
carries a bearer token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Turbo in Intended mode scores 10.4% WER on 200 Golos crowd clips, against
27.5% for the ggml-small.bin the box loads today. It also beats its own base
model and CW2 large, which inverts what the card implies about turbo.
The mode choice is not settled by this corpus. Intended and verbatim disagree
on 29 of 200 after normalization, and the disagreement is script rather than
disfluency. Golos crowd carries almost no disfluency to disagree about.
whisper.cpp cannot load CW2: num_languages() derives from n_vocab and CW2's
51897 shifts seven special token ids. So the runtime is workpc under V-486,
with whisper on homesrv as the floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
embedder.heads_path is empty by default and deploy/mavend.json sets
it. A missing or broken weights file logs and leaves the heads nil,
because refusing to start over a routing accelerator would trade a
working box for a better one.
TestONNXRoutingHeads is the same cascade TestONNXBaseline scores with
one arm added, so the two are directly comparable. It also checks the
Go tokenizer against the Python one, since the heads were trained
through transformers and are read through a hand-written tokenizer: a
mismatch shows up here as a score below what Python measured on the
same weights, and nowhere else. That is how the reversed word pieces
were found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
They run before the model because they are two orders of magnitude
faster and score better on both halves of the route. They decline
rather than clarify, so a declined turn carries on to the model and
then the classifier, which is what a box with no weights file does on
every turn. Nil heads are byte-for-byte the cascade that shipped
before this.
Measured on the 96-case fixture, classifier+ONNX either way:
intent 76.0% -> 96.9%
destination 36.4% -> 75.8%
false clarify 0 -> 1
missed clarify 8 -> 1
p50 24.5ms -> 27.9ms
That beats the gemma-4-12b cascade on both halves, 84.4% and 72.7%, at
a twelfth of its 329ms. The four remaining destination misses are all
calendar, which is the stage 0 trade V-660 flagged and the owner has
not called yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
The heads trained in V-661 ran nowhere. This loads the exported graph
and reads intent, destination and clarify off one forward pass. It
declines below 0.6 max softmax rather than clarifying, so a declined
turn reaches whatever is behind it.
The slot head is exported and deliberately not read: slots already
come from the stage-2 extractor, and mapping BIO tags back to text
needs character offsets the tokenizer does not keep.
The clarify head decides on its own and decides first. It answers a
different question from the intent head, so a low intent confidence is
no reason to discard it. Reading it only above the intent threshold
cost 6 of the 8 ambiguous cases on the fixture: the word for water
reads as intent act at 0.23 and clarify at 0.98.
0.6 is the knee measured on the intent fixture: every higher value up
to 0.9 drops right answers and keeps the same two wrong ones.
The body is a fine-tuned COPY of the resident embedder and must never
replace it, because memory recall depends on that file scoring what it
scored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
encodeWord backtracks the Viterbi path from the end of the word and
prepends each piece, which puts them back in reading order. A second
reverse after that loop undid it. So "query: вода" tokenized to
[0 12 1294 41 12489 2] where the reference tokenizer gives
[0 41 1294 12 12489 2], and every multi-piece Russian word reached the
model with its pieces in the wrong order.
Measured on the recall fixture, same 27 cases either way:
recall@1 70.4% -> 77.8%
recall@3 85.2% -> 96.3%
answered after gate 63.0% -> 66.7%
false recall 0/5 -> 1/5
The classifier barely moves, 76.0% to 75.0% on the routing fixture,
because seeds and queries were mangled the same way and cosine survived
it. Recall is where it cost, because a stored passage and a live query
are different lengths and break differently.
The embedder id now names a tokenizer revision. Stored vectors were
written under rev 1 and no longer sit in the same space as a query
embedded now, and the model file's name never moved, so nothing would
have triggered ReembedAll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Tail turns 21 to 17, the longest ride 8 turns to 4, and the two worst
replies in the corpus are gone: "спасибо" and "привет" are no longer
answered with "Сейчас 21:25. В какой день?".
MaxRides is not what fired. With the pleasantry counted as an aside the run
of asides is unbroken, so MaxSuspends reached three and ended it. Rides is
the backstop for the shape where an answer really does break the run, and
no turn in this corpus reaches it. Said so rather than crediting the new
bound.
Four rides did not move. They are asides against a question the owner never
answers, which MaxSuspends already bounds at four turns each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
classifyTurnRole read "спасибо" and "привет" as answers to whatever was
parked, so she re-asked "В какой день?" at a man saying thank you and
spent one of three attempts doing it. That attempt is a bound meant to end
the ride, so the pleasantry both produced the worst reply in the corpus and
paid for the privilege.
They are asides now: answered as themselves, the question resumed on the
tail, no attempt spent, one ride counted.
The set is a new closed lexicon entry, matched as WHOLE utterances. Every
token rule tried was wrong on something. "вечер" answers "это утра или
вечера?" and "нет" answers a confirm, so anything that could fill a slot
stays out. The control words stay out too, because isCancel owns them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
MaxSuspends did not move the number it was written for. Twenty-six of 140
turns carried a parked clarify tail before it landed and twenty-six after.
Two bounds rearm each other. An aside spends no attempt, so MaxAttempts
never reaches it. A turn reading as a failed answer zeroes Suspends, so
MaxSuspends never reaches the asides. Alternating them restores each bound
with the other's traffic. Measured on 2026-08-08: one question about a
reminder's day rode turns 7 to 13.
PendingQuestion.Rides is the same event counted without the resets. Set
once, incremented only in noteSuspended, carried across the re-park in
askRemainingGap, read by nothing that could lower it. MaxRides is 4, one
looser than MaxSuspends so the tighter statement about a run stays
reachable.
It ends the measured ride one turn early and no more. Most of that ride is
attempts, spent because classifyTurnRole reads "спасибо" and "привет" as
failed answers. Said so in the constant and in the design doc rather than
claiming a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
The third run of the same 140 turns, with the harness fix in. Sixty-eight
turns name a source.
Two findings the wording could not carry. The unfixed homelab turns are
claimed by weather and by feeds, which the destination fixture predicted.
And agenda questions are claimed by the personal boundary and by Praxis,
not by the calendar: 3 of 6, the same 3 of 6 the destination fixture and
every routing-head seed score.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
scripts/usage-run.py read the redirect parameter "src". cmd/mavweb/chat.go
writes it as "s". So Source came back empty on all 140 turns of both
fortnight runs, and every finding in those two docs is read off the reply
wording instead of off the badge.
Re-run confirms the column now arrives: 68 of 140 turns name a source.
The two homelab misses are now direct evidence rather than inference.
"какая скорость у меня сейчас?" is claimed by weather and
"хватает ли места под новые бэкапы?" by feeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
V-655 fixed four of the six turns a guessing query source claimed. The
clean win is 'что такое TCP?', which stage 0 names world and search now
answers instead of weather asking for a city. 'что я сохранил про Сочи?'
is no longer read as a capture.
The two that did not move are both homelab questions, which is the enum
and not the walk. SourceRecall, SourceNetwork and SourceAttention overlap
on every question about the box, and the destination fixture already
flagged that cluster.
The parked clarify is unchanged at 26 turns. It is dialogue state and no
query source could have touched it.
Latency is reported and not attributed. The workstation was up for the
re-run and its state during the baseline was never recorded.
Also corrects the baseline's '49 of 140 turns'. That was the sum of
occurrences. It is 41 turns.
CLAUDE.md said naming SourceWorld leaves his notes, his facts and the
personal boundary running first. The first two are true and the third is
not. The boundary is marked guesses: true, so queryWalk drops it whenever
the named destination is not recall.
That is deliberate and tested. It is what stops the boundary answering
'кто такой Линус Торвальдс?' with 'не нашла у тебя такой записи'. But it
means a destination a model wrote can take the boundary off a turn about
him, and the doc claimed the opposite.
Flagged as the owner's call rather than changed. Only the utterance leaves
the box either way.
Clarify is not a value of intent. It is a second question over the same
pooled vector: can Maven act on this at all. The eight want_clarify fixture
cases sat outside every number the heads measured, because a softmax has no
clarify class.
gen_clarify.py makes the class the corpus lacks. Every existing row was
generated FOR an intent, so every one is answerable. The router-prompt
agreement filter cannot work here, because routeGrammar has no clarify value
and a generated line always agrees with itself. A judge replaces it.
The first judge called 24 of 40 answerable rows underspecified. It judged
against a generic assistant, one that asks where about lunch. Restating
Maven's contract took that to 16 of 60, with all eight fixture cases caught.
Three seeds: 7.0 of 8 caught, 2.3 false of 88. The cascade today misses 1 and
produces 2. Confidence separates too, 0.851 right against 0.604 wrong.