# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS. Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`, compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way. **Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one. It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have: 67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the talk fixture. See `docs/evals/2026-07-31-model-bakeoff.md`. It is a Thinking variant, so `n_ctx` is 4096 — reasoning tokens need the room, and 4096 is what the scores above were measured at. The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight). Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`, masculine, where Maven needs `рада`. That is what the CPT is for. **Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers "столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's `models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident model is a one-line change to `phraser.model_path` in `deploy/mavend.json`. See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and `AGENTS.md` for local-preview + model-download recipes. **Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small, quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:` prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced MiniLM and bought ten points of recall@1 and 2.5× the speed; see `docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487 are the work. Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner hears instead of an invented answer. A box with no `workstation` block behaves exactly as it did before the seam: naming a gap requires a gap. The offload table in `docs/offload.md` says which caller is which. **Speech-to-text moved on 2026-08-09** (V-486). `sttSeam` in `cmd/mavend/voicewire.go` builds an `stt.Pair` beside `modelSeam`, preferring CrisperWhisper 2.0 turbo on workpc with mavsttd as the floor. It takes only the silent half of the rule. A worse transcript is still a turn, so `stt.Pair` has no `TranscribeRemote`. The fallback is never spoken. CW2 turbo scores **10.4% WER in Russian against 27.5%** for the `ggml-small.bin` mavsttd loads, over 200 Golos clips (`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`). It runs in Intended mode, not Verbatim, though that corpus cannot separate the two. **whisper.cpp cannot load CW2 at all.** It reads its language count off the vocabulary size, and CW2's 51897 tokens shift seven special token ids. So it is not a second endpoint on mavgpud. It is its own transformers service on port 8081 (`deploy/cw2/serve.py`), which Maven reaches directly. `stt.HTTPTranscriber` posts raw PCM to it with a bearer token, because audio is the most sensitive thing that crosses this seam. The switch is `workstation.stt` in `deploy/mavend.json`, and deleting the block sends every utterance to mavsttd. **mavgpud runs that service as a second child.** That is not an optimisation. CW2 is a ROCm process on the same card, so it registers on the KFD like any contender. Under its own systemd unit it made mavgpud evict llama-server every few seconds. That took the gemma-4-12b arm down for eight minutes on 2026-08-09 before anyone noticed. The card needs one owner. Any GPU service added beside this daemon has the same defect, so add it to `cmd/mavgpud` and not to systemd. CW2 is on the yield clock and not the idle one. At 1.6GB it denies the card to nobody, and unloading it would only send the next voice turn to the homesrv floor. Text-to-speech has not moved and piper on homesrv is still the only synthesizer. ## Build & test CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`: ```sh make build # all 11 binaries make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO) make test # go test -race across ./internal/... ./cmd/... with CGO env set ``` Run one package or one test with `make t`. **Do not hand-write the CGO preamble.** Past sessions pasted it about 390 times. That is where the shell-quoting failures came from. This box runs zsh, so an unquoted `-run Test*` or `--include=*.go` dies on "no matches found" before `go` is ever reached. ```sh make t PKG=./internal/router/ make t PKG=./cmd/mavend/ RUN=TestSimulator make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race ``` `t` carries `-race`, so a green `make t` cannot turn red under `make test`. It carries `-count=1`, so a cached PASS from before your edit is never mistaken for a result. It also sets `MAVEN_ONNX_LIB`, which the hand-written recipe did not. The four `TestONNX*` measurements self-skip when that variable is unset. The run still prints `ok`. So every targeted eval done the old way reported the hash ratchet while reading as a real embedder score. Pure-Go packages (`router`, `memory`, `mavweb`, …) also run under a plain `go test ./pkg/`, but `make t` works everywhere and is one thing to remember. ## The daemons (`cmd/`) | Binary | Role | |---|---| | `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. | | `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. | | `mavsttd` | Speech-to-text (whisper.cpp, CGO). | | `mavttsd` | Text-to-speech (piper subprocess). | | `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. | | `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. | | `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. | | `mavcaldav` | CalDAV calendar sync. | | `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. | | `mavgpud` | GPU supervisor. **Runs on workpc, not homesrv** — own unit, `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything, it reads `/health` through `llm.Pair`. | | `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. | Two more binaries have no Makefile target and are built with `go run` or `go build` when they are needed. Neither is deployed. | Binary | Role | |---|---| | `mavseal` | Recovery tool. Encrypts a live tmpfs working copy back to the ciphertext file when mavend was killed before `defer st.Close()` sealed it. | | `labelgen` | Runs the stage 0 grammars over utterances and prints JSONL, the training data for the routing heads (V-546). | Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored `deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks. **`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.** Count against compose, not against the table. Four of the nine daemons are absent, and each absence has a different reason. `mavmaild` and `mavcaldav` are commented out in compose, each with the reason written beside it: the first needs a mail account, the second a CalDAV account, and this box has neither. `mavcaldav` used to appear nowhere at all, which was an oversight; it became a recorded decision on 07-08-2026 (V-644). Two things ride on that absence and the block names them. Agenda questions route to `IntentQuery` at stage 0 (V-498) and the `calendar` query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the same facts, so the gate's "do not nag mid-meeting" is permanently false. Its password is read from a file (`-pass-file`, and `-render-pass-file` for the render collection), never taken as a flag value, which is the rule `mavpoll` and `mavmaild` follow too. **`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`). homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word daemon there listens to nobody. They belong on a client machine where the owner is standing. **That machine is workpc** (owner's correction, 2026-08-05). This section used to say no such machine existed, which was written when the workstation was only a model host. It is where he sits most of the day and it has the microphone. `ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so the two daemons need deploying, not building. V-515 is that deployment. Until they are deployed, **the wake word and the VAD gate are covered by unit tests and by nothing else**, and push-to-talk through `/dash` is what QA actually covers. Note that deploying them does not by itself prove a wake word: `mavwaked` gates on energy and has no keyword model (V-487), so the loop runs open until that lands. ## The ecosystem: Nexus, Praxis, Hexis Maven is one of four services. It owns conversation and personal memory. It does not own identity, operational state, or execution. Full contract in `docs/ecosystem.md`. ```text Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates. ``` | Service | Owns | Maven's client | Configured at | |---|---|---|---| | **Nexus** | Canonical entity ids, names, aliases, relationships. Projects, services, devices, people, pets, places. | `nexusClient` in `cmd/mavend/ecosystem.go`, `POST /api/v1/resolve` | `nexus.url` (`http://nexus:9740`) | | **Praxis** | Operational attention and item lifecycle. What needs looking at, what changed, what is still unresolved. | `praxisClient`, the HTTP tools API under `/api/v1/tools/` | `praxis.url` (`http://praxis:8989`) | | **Hexis** | The capability registry and the only path to executing anything. | vendored `github.com/kami/hexis/pkg/client` | `hexis.url` (`http://hexis:9741`) | All three are `nil` unless configured, and every one of them degrades on its own. An outage means a named gap in the answer, never a broken turn and never a guess. Rules that are not negotiable: - **No component reads another component's database.** Praxis attention comes over HTTP, never from its SQLite file. - **Identity lives in Nexus.** Do not invent a local fact key for something Nexus resolves. `actionFact` already sets `Subject`, and `cmd/mavend/factenrichment.go` resolves it in the background against Nexus. - **Free text never reaches a mutating Hexis call.** Resolve to a canonical entity id first. Ambiguous resolution asks the owner, it does not pick. - **LLM output is not authorization.** Confirmation binds capability id, target entity, arguments, requester and expiry. See `cmd/mavend/confirm.go`. - **Praxis lifecycle words mean different things.** Surfaced is not acknowledged, acknowledged is not resolved, execution success is not recovery. Reading an item aloud calls `Surface`, never `Acknowledge`. - **No automatic attention-to-action path.** Digestion may summarise Praxis. It may not call Hexis. Every cross-service call carries a correlation id minted once per action (`withCorrelationID`), a contract version header, and `X-Requested-By: maven`. ## Routing — read this before touching the router `internal/router/` has TWO layered engines. **The LLM router is now the default and it is on in deploy** — this section used to say it was wired `nil`, which stopped being true on 2026-07-31. - **LLM router (the intended design, docs/rearchitecture.md):** the resident Qwen3-1.7B (`llmrouter.go`) emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via `pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router` (`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`. - **Classifier cascade (the failure floor, not dead code):** `classifier.go` + `embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades), and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known cause of weak RU query handling, but a turn must never break on the model. Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier fallback. Any LLM error falls through to the classifier so a turn never breaks on the model. **A stage-0 decision is slot-extracted too, since 06-08-2026** (V-572). `fillMatchedSlots` in `router.go` runs the stage-2 extractor over whatever a grammar built and fills only the slots it left empty — a matched value always wins, because the rule read a literal pattern and the extractor guesses. It did not run before, so `ReminderGrammar` handed the daemon `HasTime: false` for "напомни в 11:00 позвонить маме" and `missingFor` read the silence as absence and asked "Когда?". It applies to every grammar and is inert for all but the reminder: `Extract` fills Time, Fn and Key and nothing else, and the query, clock, agenda, feed, list, task and narrative rules all emit intents with no such slot. Benchmarked at 20000x, a stage-0 query costs 3.7µs against 3.9µs before. **`Slots.Text` is deliberately not filled** — a grammar that left it empty meant it, and `agendaQueryBuild` hands the query chain the utterance itself. Fixture unchanged at 64/91, with "slots deferred to daemon" 6 → 0. Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8% full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from `docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores 77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy, not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency work off the bakeoff table. **Re-measured 2026-08-05 on the fixture as it now stands, 91 cases** (V-320 item 2, `docs/evals/2026-08-05-routing-resident-model.md`): cascade + resident model scores **75.8% full / 80.2% intent-only at p50 1.19s / p95 1.65s**. That is a new baseline and not a movement, because 14 cases were added since the 77-case number above. The model alone scores 37.4% full against 61.5% intent-only, and the gap is slots rather than routing: it routes `reminder` and leaves the time to the daemon, which is what the contract asks. To re-run it, start a **second** llama-server on a fixed host port — the resident one binds `--port 0` inside the container and no host process can reach it. **The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing completes through `llm.Pair` against the model mavgpud holds, which is better than the resident model and about 2.5× faster. gemma-4-12b scored **84.4% full / 93.5% intent-only at p50 329ms** (`docs/evals/2026-08-02-workstation-gemma4-12b.md`, Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a routing change against the classifier and the resident model, since those are what always answer. **The workstation runs gemma-4-E4B since 2026-08-09** (owner's call), and it is a step down measured the same day (`docs/evals/2026-08-09-e4b-vs-12b-routing.md`). Against a same-session 12B control it scores **83.3% full / 89.6% intent-only, destination 19/33 against 23/33, at p50 294ms against 344ms**. So it costs four destination cases and buys 50ms. Read destination as the finding: it names nothing where the 12B names `recall` or `calendar`, which is safe but walks the whole chain. It also has no MTP and cannot be given any here. The only `gemma4-assistant` draft on disk is trained against the 12B's hidden states. **Phrasing was the unmeasured half and it is measured now** (`docs/evals/2026-08-09-e4b-phrasing.md`). E4B scores nudges 15/15 and the 36-case talk fixture **29/36 at p50 516ms**, against the resident model's 25/36 at p50 2.97s. Persona is clean: `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. The 2026-08-05 temperature sweep put this fixture's ceiling at 30/36, because two reply cases fail at every temperature (V-537), and both are in E4B's failure list. So the swap costs nothing here. One defect no check catches: in chat E4B claims "Я записала несколько идей!" when nothing was stored, which is a wrong claim about state. **The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546, `docs/plans/18-routing-heads-on-e5-small.md`). Routing has a bounded output space, so it is classification, and the 118M multilingual-e5-small is already resident. Three heads on one forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30 minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value that does not exist. And max softmax is a calibratable confidence, where `Confidence: 1.0` was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs memory recall. Training it in place couples routing accuracy to recall@1, with nothing in the suite to name the trade. **Two of those heads are trained as of 08-08-2026, and they are not the three above** (V-661, `docs/evals/2026-08-08-routing-heads-two-head.md`). Intent and destination share one masked mean pool. Destination scores a mean **80.8%** over three seeds, best **29/33 (87.9%)**. The classifier cascade scores 12/33 and the cascade with gemma-4-12b scores 24/33, so a 118M encoder beats the 12B teacher it was distilled from. Read the best run as one seed and not a headline, because one case is 3 points on a fixture this small. Recall is 15/15 and world is 5/5. Intent is 93.6% mean over three seeds. That is **not** comparable to the 76.0% and 84.4% those two arms scored: a softmax has no clarify class, so the head's fixture is the 88 cases carrying an intent. **A fourth head asks instead of guessing, same day** (V-661, `docs/evals/2026-08-08-clarify-head-four-head.md`). Clarify is not a value of intent, so a softmax cannot emit it. It is a second question over the same pooled vector: can Maven act on this at all. That is why the head's fixture was 88 cases and not 96. Over three seeds it catches **7.0 of the 8 `want_clarify` cases and produces 2.3 false clarifies of 88**. The cascade today misses 1 and produces 2, so this is parity with no rules in front of it. Accuracy is the wrong number here and a head that never asks scores 91.7%. Confidence is the other half. Max softmax over the intent head reads **0.851 where it is right against 0.604 where it is wrong**, ranking right above wrong in 83.4% of pairs. `Confidence: 1.0` was a hardcode, and this replaces it with a signal. The two are not the same signal: one says which intent is unclear, the other says the utterance carries too little to act on. **The fourth head is not free the way the third was.** Intent, destination and slot F1 each move down one to four points, inside the seed spread. `поужинал` is a false clarify on every seed, which is the same defect `thinSingleToken` was narrowed for on 2026-08-01. The corpus for it is generated, because every existing row is answerable by construction. **The router-prompt agreement filter cannot work here**, since `routeGrammar` has no clarify value and a generated line always agrees with itself. A gemma judge replaces it. The first judge called 24 of 40 answerable rows underspecified, because it judged against a generic assistant rather than against Maven's contract. **Mood is cut, not deferred.** The enum describes her own reply state, not the speaker's emotion, and no dataset maps onto it. **A third head landed the same day** (`docs/evals/2026-08-08-slot-head-three-head.md`). BIO slot tags had no Maven-domain corpus, which was true of found corpora and false of made ones. `label_slots.py` distils spans out of gemma-4-12b under a GBNF closed over Maven's own five slots. A span survives only when it is a literal substring of the utterance, so the agreement filter costs no second call. 2178 spans over 1702 rows, 37 dropped, nothing unparsed. Three heads score intent **92.8%**, destination **82.8%** and slot span F1 **72.4%** over three seeds. The slot head is free: both other numbers move less than their own seed spread. Epoch selection reads the intent dev slice alone. Slot F1 is still climbing when it stops, which costs about 4 points. The MASSIVE warm-start of step 2 is worth nothing here. Stock e5-small ties it on intent and leads by a third of a case on destination. Nothing argues for keeping that step. The floor was a corpus defect and it is fixed. The first 120 floor rows carried one sentence shape, so the head named a destination where the fixture says walk the chain. Rotating six shapes took the floor 3/7 to 6/7 and destination 75.8% to 80.8%. What is left is calendar at 3/6 on every seed, which training cannot move: the possessive agenda rules claim those cases at stage 0 and name nothing, so no label reaches the head. That is the same trade V-660 flagged and it wants the owner's call. **The heads run in Go and route every turn, since 08-08-2026** (V-664, `docs/evals/2026-08-08-routing-heads-in-go.md`). This section used to say nothing of it ran. `RouterHeads` in `internal/router/heads.go` loads `router_heads.onnx` and reads intent, destination and clarify off one forward pass. It is stage 0b: after the grammars, **before** the resident model, and the classifier is still behind both. Through the cascade it scores intent **96.9%** and destination **75.8%** at p50 27.9ms. That beats the gemma-4-12b cascade, 84.4% and 72.7%, at a twelfth of its 329ms. The workstation stays the better phraser and is no longer the better router. Three rules around it. The **clarify head decides first**, before the intent threshold. It answers a different question. A thin utterance scores low intent by construction, so gating it cost 6 of 8 ambiguous cases. The **destination head is read on `IntentQuery` only**, since no other intent reaches `queryWalk`. And `headsThreshold` is 0.6, the measured knee: every value to 0.85 drops right answers and keeps the same two wrong ones. `voice.embedder.heads_path` is the whole switch. Empty, missing or unloadable means the heads are nil and the cascade is byte-for-byte what shipped before them. **It must never be pointed at `model_path`.** The resident e5-small must not be replaced by the fine-tuned copy. Recall depends on that file scoring what it scored. **The hand-written tokenizer read every long word backwards** until this task (`encodeWord`, `onnxembedder.go`). It cost recall@1 7.4 points and recall@3 11.1. Nothing caught it because seeds and queries were mangled the same way, so cosine survived. The heads found it. They are trained through transformers and read through this. The embedder id now carries a tokenizer revision (`@384/tok2`), so fixing the tokenizer triggers `ReembedAll` the way swapping the model file does. Bump `tokenizerRev` on any change to what it emits. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja #359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see `gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway. Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly executing, so the fixture and the daemon disagree about what is correct there. The third, `"поужинал"`, was a real defect: the single-token rule was an English intuition and does not transfer to Russian, where one word is routinely a whole sentence. Narrowed 01-08-2026. `thinSingleToken` (`internal/router/singletoken.go`) still thins a bare one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, intent-only 74.0% → 75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule. Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to `IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% → 72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an explicit `(\s|[?!.]|$)`. Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside `AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar` (`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a latency and fixture defect, not a correctness one. The narrative rule reads the same `narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a joke, a bedtime story, herself — because the query chain has no source that answers those. New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) → 58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no llama-server in that run), so judge it again before quoting a cascade number. Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()` (`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because "отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. **These grammars are the only path to Praxis, not a faster one.** Measured 2026-08-05 with the resident model as router (V-517, `docs/evals/2026-08-05-reach-llm-router.md`): the model alone reaches Praxis **0/12**, the same as the classifier alone, because nothing in the router prompt names a Praxis capability and there is no string for it to write. Through the cascade it is 11/12. Deleting these rules costs every point. Praxis reach was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability alias, and that slot is filled from the deployment's enabled tool names, which no Praxis alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5** (`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative** lifecycle word ("готово", "принято") needs an item named beside it, while a bare **imperative** ("закрывай") may ask which one. The bare arm additionally requires that the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead of the house. A demonstrative ("отметь это как сделанное") resolves against `h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to the cascade rather than transitioning the wrong item. **Who claimed a turn is now recorded, and so is who did not** (V-564, umbrella V-558). Arbitration between the claimants on the utterance stream is order, hardcoded in the pre-route resolver ladder, in `buildRouter` and in `querySources`. `internal/decision` records one `Record` per turn: every claimant, what it would have made the turn, the score it reported, and whether it won, declined, lost on score, was thinned by a gate or was **never asked**. The record rides the context, the same seam `querysource.go` uses, so a claim site cannot change a route and a context with no record costs nothing. It is installed in `runTurn`, so the mic, telegram and the web all leave the same trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`), read over `ipc.TurnDecisions` and rendered as the second table on `/trace`. **It also persists, since 06-08-2026, and that reverses what this section used to say** (V-629, `docs/plans/21-persisting-the-routing-trace.md`). The old rule was that nothing persists, because a turn record is read minutes later or never. The owner reversed it: the routing heads (V-546) cannot be fitted or calibrated without real utterances, and 9 of the 31 modes in `internal/modes` have no seed example at all. The ring did not move. It is still what `/trace` reads and still what a test with no store gets. `cmd/mavend/routingtrace.go` is a second sink beside it, writing `routing_traces` (migration #23). The utterance is stored in clear, because a 384-dimension vector of a short sentence is substantially recoverable and storing vectors instead would be a privacy claim we cannot support. What makes it safe is the same thing that makes the fact store safe. Retention is 14 days, enforced on write and again on start, so a box that goes quiet does not keep every row. Nothing reads it outward, and the rule that his notes and facts are never search input covers this table. `Store.Wipe` deletes it with everything else. A correction (V-630) is promoted out into a seed-shaped row in `routing_labels` (migration #24) and kept, because a label is not a transcript. The transcript still expires. The gesture that writes one is two buttons beside the reply on `/chat`, reached over `ipc.CorrectTurn` and the trace id that now rides back on `ipc.ChatReply`. A turn marked wrong with no target is a usable negative, so naming the intent is never required. The target is one of the seven intents and never free text. **All three reaches offer it as of 06-08-2026**, and this section used to say only `/chat` did. Voice is the `repair` rung, which has read spoken corrections since V-455 and now writes the durable label beside the classifier seed it always wrote; a spoken negative with no target is its own rung, `repair-negative` (V-636, `docs/plans/22-correcting-a-turn.md`). Telegram is an inline keyboard under the reply, and it needed the chat to become readable first — **telegram is no longer outbound only** (V-637, `docs/plans/23-inbound-telegram.md`). The poller is dark unless the `telegram` block says `intake`, it long-polls because the box takes no inbound connections, it accepts `chat_id` and no other sender, and it drops whatever queued while the daemon was down. It reaches the daemon through `ipc.CoreAPI` alone, so a chat turn takes the path `POST /api/chat` takes. Note that the turn source is still `tap:text` for both, so provenance cannot tell a chat turn from a typed one. Adding a rung to the ladder in `runTurn` means adding its name to `preRouteLadder` in `cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record. **A route now says where the answer lives, not only that the turn is a question** (V-655, 07-08-2026). `query` was a shrug. The cascade sorted an utterance into one of seven intents, with stage 0, the resident model and the classifier behind it. Then `IntentQuery` handed the turn to `querySources` in the daemon. That is twenty-two branches deciding by seed similarity in a fixed order. It has no fixture and no accuracy number, no model arm and no floor. `Decision.Source` (`internal/router/source.go`) is the second half of the route. Twelve destinations, not twenty-two. The three recall passes plus `fact-by-key` are one destination from outside. So are search, Kiwix and the URL reader. **`SourceUnknown` is a real value and it is the floor.** Nothing named a destination, so the daemon walks the whole chain. That is byte-for-byte what shipped before the field existed. The classifier arm names nothing, so a box whose model is down routes queries exactly as it did. `queryWalk` in `cmd/mavend/actions_query.go` takes sources **out** and moves none. That is the safety argument and it is not negotiable. The table's order is load-bearing. Every comment on it argues a reason between two sources, and above all it carries "the owner's data first, then the world". Naming `SourceWorld` does not send the turn outside on its own. His notes and his facts still run first, because they look rather than guess. **The personal boundary is the one exception and it is deliberate.** It guesses, so naming `SourceWorld` drops it. That is what stops it answering "кто такой Линус Торвальдс?" with "не нашла у тебя такой записи", which it did on 2026-08-07. `TestNamingRecallKeepsTheBoundary` pins the other half: naming `SourceRecall` keeps the boundary in front of the world. **Only a stage 0 grammar may drop it** (owner's call, 09-08-2026, V-666). The question of who is allowed to was open until then. Three deciders name a destination and two of them infer it: the routing heads and the resident model. An inferred `SourceWorld` on a question about him would reach SearXNG, and that widens what is asked rather than costing a local answer. So `Decision.SourceAnchored` carries the provenance. It is a field and not `Stage == 0`. Stage 0 also means confidence 1.0 and an anchored claim band, and one of those could stop implying the others. `queryWalk` reads it for the source marked `boundary: true` and for no other. So every other guesser still comes off the turn, whoever named the destination. `TestOnlyAGrammarMayDropTheBoundary` pins both directions. `definitionQueryPattern` claims "кто такой X", so the 2026-08-07 case is still anchored and still answered. What comes out is only the sources that **guess**. Those decide a turn is theirs by cosine against frozen seeds, then answer whatever they claimed. They hold no table that could come back empty. Weather is the pure case and has no local data at all. It was measured on the box on 2026-08-07 (`docs/evals/2026-08-07-week-of-usage.md` section 4). It answered both "что такое TCP?" and "сколько будет 17 на 23?" with "для какого города?". The feed answered "какой у меня любимый язык?" with kernel headlines. The personal boundary answered "кто такой Линус Торвальдс?" with "не нашла у тебя такой записи". A source that guesses is marked `guesses: true` in the table. One that looks is not, and it is always asked. Stage 0 fills the destination where a rule already knows it. `WorldQueryGrammars()` (`internal/router/worldquery.go`) claims "что такое X" and "сколько будет 17 на 23". It is wired after the agenda rules and **before** the feed and list rules. "что такое лента" is a definition question, and the feed rule would take it on the noun alone. `calendar-query` and `event-time-query` name the calendar. The possessive agenda rules deliberately do not. "что у меня в списке покупок" matches `agenda-query`, and naming the calendar there would take the list source off the turn. Fixture unchanged at **69/91 classifier+ONNX**, measured both sides. That is the expected result, because it scores intent and no case here changes intent. **The destination has its own fixture and its own number as of 08-08-2026** (V-659, `docs/evals/2026-08-08-destination-fixture.md`). This section used to say it had neither. `want_source` on `eval.Case` is a pointer, because the destination has three states and a bare string has two. Absent is every intent but query, which never reaches `queryWalk`. Present and empty is the `SourceUnknown` contract: name nothing and walk the chain. Present and named is a destination the route must produce. Thirty-three of ninety-six cases carry one. A destination miss does **not** fail the case. It lands in `Outcome.SourceReason` and never in `Reasons`, so `Accuracy` and `IntentAccuracy` mean what they meant and `SourceAccuracy` is a second number over the labelled cases only. Intent and destination are two decisions, and one number hides which one moved. A route that lost its intent scores no destination hit, or a clarify would satisfy an empty label for free. Measured classifier+ONNX: intent **73/96 (76.0%)**, destination **12/33 (36.4%)**. The split is the finding. World is 5/5, because a stage 0 rule names it. The `SourceUnknown` floor is 5/7. Calendar is 2/6, because the possessive agenda rules deliberately do not name it. And **recall is 0/15, because nothing anywhere names it**. Those turns are still answered, since the chain walks recall early. Recall is the number the fourth head has to move. Seven cases assert the floor and five of them are homelab operations. They cluster because `SourceRecall`, `SourceNetwork` and `SourceAttention` overlap on every question about the box. The other two are `ru-query-005` and `ru-query-014`. No query source reads the reminder store, and a deadline could sit in tasks, the calendar or Praxis. `mavpoll` writes its netdata and uptime-kuma observations into the fact store recall reads. That is a finding about the enum, not a gap in the labelling. The owner confirmed all seven floor labels on 08-08-2026, so they are a decision rather than an agent's guess. `baselineGrammars` in `eval_test.go` mirrors `buildRouter` and had drifted: `WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror, so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points of destination and nothing else. Check that function when adding a grammar. **The model arm landed the same day** (V-660, `docs/evals/2026-08-08-destination-model-arm.md`). `routeGrammar` carries a `source` rule closed over `router.Sources` plus the empty floor, so the model cannot emit a destination that does not exist. The prompt lists the twelve in Russian and says `""` is a normal answer to give often. `LLMRouter.Route` reads it back through `ValidSource` and on `IntentQuery` alone. Against gemma-4-12b on the workstation the cascade scores destination **24/33 (72.7%)** with intent unmoved at 84.4%, and **recall goes 0/15 to 14/15**. The resident Qwen3-1.7B is unmeasured, because it binds `--port 0` inside the container. **Stage 0 now costs four destination points.** It did not before. The four cases the cascade loses and the model alone wins are all calendar. The possessive agenda rules claim them first and name nothing on purpose. That caution was free while nothing downstream could name anything either. It is not free now, and the fix is the owner's call rather than a quiet edit. The last arm is V-546. Intent, mood and BIO slot tags were already three heads on one forward pass of the resident e5-small. Destination is a fourth head on the same pass, and 72.7% from a 12B teacher is the label source for training it. ## LLM output contract All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when the model skips the JSON. **One parser, `parseResponseMood` in `internal/phraser/parse.go`**, and every path reaches it: the six `LLMPhraser` methods, `PhraseWorld`, and `Replier.PhraseReply`, which `cmd/mavend/replier_llm.go` wraps — that file holds the stub fallback and no parsing of its own. The legacy `{"body","summary"}` fallback was deleted on 2026-08-06 (V-397): it was the contract before `{"response","mood"}` replaced it, no prompt asks for that shape, the GBNF cannot emit it, and no test covered it. Mood is a fixed enum. Router prompt is a separate contract: `[{"intent":, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder, note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training workspace enforces that the Go and relabelling prompts remain identical. ## Russian patterns — three mechanisms, no fourth Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a pattern, and the resident model cannot be asked per turn either). A regex whose output is a fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an argv list — is not. Before writing a Russian word list, pick one of these: - **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives, capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months, spoken hours. Editing a word is a data change, and there is exactly one copy: months used to live in three files. Cardinals carry the oblique forms, because a spoken time declines and `в семь` / `к семи` are one hour. - **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm` and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb slot that means the imperative must be matched exactly — `говори` and `говорил` are one lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`). - **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the turn's own query vector. Same shape as the personal boundary in `personalboundary.go`, with one difference: a topic must clear the runner-up by `topicMargin`, because a false claim here spends a network scan rather than one honest "не знаю". The old keyword tests stay as the offline floor and may remain narrow, since they are no longer the only answer. - **The ecosystem trio** — when the answer is not in the utterance at all. Identity is Nexus's, never a local pattern. Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the `TestONNX*` tests, not eyeballed. ## Non-goals (hard constraints) Not a nag, not autonomous. Maven's persona is **feminine** — Russian self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его" (she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name ("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in `internal/phraser/eval/checks.go`, scored by `make eval-phrasing`. **"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer world questions, so she needs to read external sources. What replaces it: - **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing about Maven is reported to anyone, and inference stays on the box. - **The owner's data first, then the world.** Every source that reads the owner's facts, notes, calendar, tasks or house runs before anything outside, and the personal boundary sits between them. Reading beats recalling for a small model. - **In the world, live search leads and the ZIMs are the fallback** (owner's call, 2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on homesrv answer when the search is empty, unreachable, or the line is down. **Verified with the line down on 2026-08-05** (V-508, `docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing, the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow instance that did connect keeps the full 8. **A Russian question reads `wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter is the workaround for an English book and is skipped there. Kiwix catalog names come from the filename, not the `` field. **That verbatim path sent the whole sentence to a keyword engine until 09-08-2026** (V-668, `docs/evals/2026-08-09-kiwix-topic-retrieval.md`). Kiwix ranks by keyword overlap, so the question words outrank the one word naming the article. "что такое TCP" returned "Перехват TCP-соединения". "кто написал Войну и мир" returned an episode of Doctor Who. `kiwix.Topic` drops the narrative request, the interrogative and a verb behind one. `kiwix.TitlePath` tries the exact article first, since a ZIM is addressable by title and a wrong title is a 404. Five of eight questions reach the right article where they did not, one was already right, and nothing regressed. The title needs its leading capital, so `TitleCandidates` tries the spoken form and then the capitalized one. **"столица Франции" is answered by a title redirect to Париж**, which is the case the 2026-08-05 measurement named as unreachable by any lexical signal. Both apply on the verbatim path alone. The rewriter already reduces a question, and reducing twice takes the topic off its input. `Response.Empty()` is the whole gate and there is no quality threshold in front of it: the three signals one could read were measured on 2026-08-05 and none of them separate a real question from an invented one. Token overlap would cost "столица Франции" its answer, because the answer is Париж and that word is not in the question. See `docs/evals/2026-08-05-search-quality-signals.md` (V-539). **The embedder is not a fourth signal**, measured 2026-08-09 (V-668). Query-to-passage cosine scores 0.79 to 0.91 on answerable questions and 0.75 to 0.84 on unanswerable ones, and the sets overlap. The wrong TCP article scored 0.8653, above five of six unanswerable rows. It measures topic and not whether the passage answers, so no threshold splits them. **Which query source claimed a turn is readable on `/chat`** as a badge beside the reply, carried on `ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It rides the context, so `handleText` keeps the one string signature the mic, telegram and the web share. - **External search is allowed and off unless configured**, like the weather and telegram capabilities. The code default is still off. `deploy/mavend.json` now ships a `search` block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns it off again. - **The owner's notes and facts are never search input.** Looking up why the sky is blue and sending the owner's stored personal notes to an upstream engine are different acts. Only the utterance goes out, never the persona block, history, or matched notes. ## Web UI conventions Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" ""}}` and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link. Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go, and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No per-page `