diff --git a/CLAUDE.md b/CLAUDE.md index 6f8727a..6c0215a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,172 +1,185 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) working 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. +This file is loaded into every session, so it carries rules and not history. A +measurement lives in `docs/evals/-.md` and is never edited after the +day. A subsystem's reasoning lives in a living doc under `docs/`. When a line +here says "see X", read X before changing that subsystem. -**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. +| Read this | Before | +|---|---| +| `docs/routing.md` | touching `internal/router/` or `queryWalk` | +| `docs/offload.md` | touching a daemon seam or adding a model caller | +| `docs/ecosystem.md` | touching Nexus, Praxis or Hexis | +| `docs/rearchitecture.md`, `docs/design.md` | changing the shape of anything | +| `AGENTS.md` | local preview, screenshots, model downloads | -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. +## What Maven is -**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`. +A self-hosted, privacy-first voice assistant in Russian and English. Go daemons +talk over unix sockets. One resident small model routes and phrases. whisper.cpp +does speech-to-text and piper does text-to-speech. -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. +The deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega +iGPU (`n_gpu_layers: 99`, compose passes `/dev/dri` and the render gid). The +resident model stays at 1.7B or under either way. -**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. +### The resident model -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. +**Qwen3-1.7B** (`UD-Q4_K_XL`), stock, not yet the CPT'd one. It is a Thinking +variant, so `n_ctx` is 4096. Reasoning tokens need the room, and 4096 is what +every score was measured at. -**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` +The target is the locally CPT'd Qwen3-1.7B (V-122, training in flight). Stock +already speaks good Russian. What it gets wrong is the persona. It writes `я рад` +where Maven needs `рада`. + +**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on +2026-07-31 and both are unusable in Russian +(`docs/evals/2026-07-31-model-bakeoff.md`). Their published IFEval and BFCL +numbers are English-only. + +Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm`. +That **shadows** the repo's `models/llm/`, so a 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`. + +### The workstation + +Model work moved to workpc on 2026-08-02 (owner's call). homesrv cannot grow a +GPU and workpc has 16GB of VRAM. So the resident model, speech-to-text and +text-to-speech are preferred remotes with a floor on homesrv. + +Three rules: + +- **The workstation is never assumed up.** +- **Fall back silently** when it would only do the job better. +- **Name the gap** when the resident model cannot do the job at all. A world + question goes through `LLMPhraser.PhraseWorld` and returns `worldGap` + (`cmd/mavend/worldmodel.go`) rather than an invented answer. A box with no + `workstation` block behaves exactly as it did before the seam. + +Routing and replies prefer the workstation through `modelSeam`. Nudge and +reminder phrasing prefer it inside the phraser. `docs/offload.md` says which +caller is which. + +**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:` and `passage:` prefixes it was trained with. Calling plain +`Embed` on a note is a bug. See `docs/evals/2026-08-04-recall-e5-small.md`. + +### Speech-to-text + +`sttSeam` in `cmd/mavend/voicewire.go` builds an `stt.Pair` beside `modelSeam`. +It prefers CrisperWhisper 2.0 turbo on workpc with mavsttd as the floor. It takes +only the silent half of the rule, because a worse transcript is still a turn. So +`stt.Pair` has no `TranscribeRemote` and 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. +(`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`). -## Build & test +**whisper.cpp cannot load CW2 at all.** It reads its language count off the +vocabulary size. CW2's 51897 tokens shift seven special token ids. So CW2 is its +own transformers service on port 8081 (`deploy/cw2/serve.py`). +`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. -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`: +**mavgpud runs that service as a second child.** This 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 model arm down for eight minutes on 2026-08-09. The +card needs one owner. **Any GPU service added beside mavgpud goes in +`cmd/mavgpud`, never in systemd.** CW2 is on the yield clock and not the idle +one. At 1.6GB it denies the card to nobody. + +Text-to-speech has not moved. piper on homesrv is the only synthesizer. + +## Build and 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 build-web # one daemon (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. +**Do not hand-write the CGO preamble.** Past sessions pasted it about 390 times, +and that is where the shell-quoting failures came from. This box runs zsh, so an +unquoted `-run Test*` dies on "no matches found" before `go` is ever reached. -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. +`make 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 sets `MAVEN_ONNX_LIB`, which the hand-written recipe did not. +The four `TestONNX*` measurements self-skip when that variable is unset and the +run still prints `ok`. So every targeted eval done the old way reported the hash +ratchet while reading as a real embedder score. ## 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. | +| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB and IPC socket. | +| `mavweb` | HTTP UI and PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`), WebAuthn auth. | | `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. | +| `mavwaked` | Wake-word and VAD gate. **Not deployed anywhere yet.** | +| `mavenclient` | Voice loop client (mic, stt, core, tts). **Not deployed anywhere yet.** | +| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. | | `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`. | +| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. | +| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and 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. +Two binaries have no Makefile target and neither is deployed. `mavseal` 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. -| 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 wire +protocol. `deploy/mavend.json` sets socket paths, model paths and the phraser and +embedder blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`. -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. +### Who is in compose, and who is not -**`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. +**`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, +`mavpoll`. Count against compose, not against the table above. Four 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. +`mavmaild` and `mavcaldav` are commented out, each with the reason beside it. The +first needs a mail account and the second a CalDAV account, and this box has +neither. Two things ride on the CalDAV absence (V-644). Agenda questions route to +`IntentQuery` at stage 0, 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. -**`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. +`mavwaked` and `mavenclient` are absent **by decision** (V-463, +`docs/plans/17-where-the-voice-loop-runs.md`). homesrv has a microphone, because +it is a laptop, but it is in the wrong room. They belong on a client machine +where the owner is standing, and that machine is workpc. `ipc.Dial` already takes +`tcp://host:port?token=...`, so V-515 is a deployment and not a build. -**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 then the wake word and the VAD gate are covered by unit tests and nothing +else. Push-to-talk through `/dash` is what QA covers. Deploying them does not by +itself prove a wake word. `mavwaked` has no keyword model (V-487 stage two), so +the loop runs open until that lands. -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. +**Passwords are read from files, never taken as flag values.** `mavcaldav` uses +`-pass-file` and `-render-pass-file`. `mavpoll` and `mavmaild` follow the same +rule. ## 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 +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 @@ -176,585 +189,251 @@ Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordina | 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`) | +| **Praxis** | Operational attention and item lifecycle. What needs looking at, what changed, what is 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. +All three are `nil` unless configured and every one 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. +- **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. + resolves. `actionFact` sets `Subject`, and `cmd/mavend/factenrichment.go` + resolves it in the background. +- **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. + 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 +## Routing -`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. +**Read `docs/routing.md` before touching `internal/router/` or `queryWalk`.** It +carries the stage-by-stage reasoning, every measurement, and why each rule +exists. What follows is only what must not be broken. -- **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. +A route produces two decisions. **Intent** is one of seven values. **Source** is +where the answer lives and is read on `IntentQuery` alone. They are scored +separately, because one number hides which one moved. -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. +The cascade is stage 0 grammars, then the routing heads, then the resident model, +then the classifier. Every stage may decline and the next one answers. -**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. +- **The classifier is the floor, not dead code.** It runs when the resident model + is off. It runs when there is no llama-server, and on any error. Deleting it + makes a model outage a broken turn. +- **Any model error falls through**, so a turn never breaks on a model. +- **`baselineGrammars` in `eval_test.go` mirrors `buildRouter`.** Add a grammar + to one and it belongs in both, or the fixture scores a set nobody runs. +- **Go's `\b` is ASCII-only** and never fires after a Cyrillic letter. A Russian + pattern needs an explicit `(\s|[?!.]|$)`. +- **`PraxisGrammars()` is the only path to Praxis**, not a faster one. The model + reaches Praxis 0/12 alone, because nothing in the router prompt names a Praxis + capability. +- **`voice.embedder.heads_path` must never point 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. Fine-tune a copy of the weights. +- **Bump `tokenizerRev` on any change to what `encodeWord` emits.** The embedder + id carries the revision. So a tokenizer fix triggers `ReembedAll` the way + swapping the model file does. +- **A new rung in the `runTurn` ladder needs its name in `preRouteLadder`** + (`cmd/mavend/decisiontrace.go`). Otherwise that rung is silently missing from + the decision record. +- **Routing traces are retained 14 days**, enforced on write and again on start. + The utterance is stored in clear and nothing reads it outward. `Store.Wipe` + deletes it with everything else. -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. +### `queryWalk` and the destination -**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. +`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, and above all it carries "the owner's data first, then the world". -**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. +`SourceUnknown` is a real value and it is the floor. Nothing named a destination, +so the daemon walks the whole chain. Naming `SourceWorld` does not send the turn +outside on its own. -**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. +What comes out is only the sources marked `guesses: true`. Those decide a turn is +theirs by cosine against frozen seeds, then answer whatever they claimed. A +source that looks rather than guesses is always asked. **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. +so naming `SourceWorld` drops it. **Only a stage 0 grammar may drop it** +(owner's call, V-666). `Decision.SourceAnchored` carries the provenance, and +`queryWalk` reads it for the source marked `boundary: true` and no other. So +every other guesser still comes off the turn, whoever named the destination. +`TestOnlyAGrammarMayDropTheBoundary` and `TestNamingRecallKeepsTheBoundary` pin +both directions. -**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. +### Current numbers -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. +| Arm | Intent | Destination | p50 | +|---|---|---|---| +| classifier + ONNX | 76.0% | 36.4% | 16.6µs | +| resident Qwen3-1.7B, cascade | 80.2% | not measured | 1.19s | +| routing heads, cascade | 96.9% | 75.8% | 27.9ms | +| workstation gemma-4-E4B, cascade | 89.6% | 57.6% | 294ms | -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. +Judge a routing change against the classifier and the resident model, since those +are what always answer. The fixture has grown from 77 cases to 96, so a number is +comparable only to another number on the same fixture. ## 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. +All phrasing paths emit `{"response":"...","mood":"..."}`, falling back 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`. `cmd/mavend/replier_llm.go` +wraps the last of those, holds the stub fallback, and does no parsing of its own. +Mood is a fixed enum. -## Russian patterns — three mechanisms, no fourth +The router prompt is a separate contract: +`[{"intent":, key?, value?, text?, verb?}, ...]` over 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 stay identical. -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: +## Russian patterns: three mechanisms, no fourth -- **`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. +Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call). +A regex whose output is a fact or a route is the defect. A regex over structured +input, such as HTML, MIME, JSON, a URL or an argv list, is not. Before writing a +Russian word list, pick one of these: -Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the -`TestONNX*` tests, not eyeballed. +- **`internal/lexicon`** for 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 `в семь` and `к семи` are one + hour. +- **`internal/morph`** for grammar, from the vendored golem Russian dictionary. + `IsVerbForm` and `SameWord`. Lemma matching is BROADER than stem-plus-one-ending, + so a verb slot meaning 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** for 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. +- **The ecosystem trio** when the answer is not in the utterance at all. Identity + is Nexus's, never a local pattern. -## Non-goals (hard constraints) +Seeds are scoring data. Editing one moves a recogniser and must be re-measured +against the `TestONNX*` tests, not eyeballed. -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`. +## Non-goals and hard constraints -**"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: +Not a nag, not autonomous. -- **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 +**The persona is feminine.** Russian self-reference uses feminine forms: `рада` +not `рад`, `поняла` not `понял`. The owner is male and she speaks to him +informally. Use "ты", singular, never "вы" or "ваш", and never "он" or "его". She talks TO +the owner, not about him. Pet names such as "милый" are forbidden. The name +"Ками" is not. `CheckAddress`, `CheckFeminine` and `CheckCringe` in +`internal/phraser/eval/checks.go` enforce this, scored by `make eval-phrasing`. + +**"Never phones home" is deprecated** (owner's call, 2026-07-31). A 1.7B does not +know enough to answer world questions, so she reads 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 reading his facts, + notes, calendar, tasks or house runs before anything outside. The personal + boundary sits between them. Reading beats recalling for a small model. +- **The owner's notes and facts are never search input.** Only the utterance goes + out. Never the persona block, the history, or matched notes. +- **External search is allowed and off unless configured**, like weather and + telegram. The code default is off. `deploy/mavend.json` ships a `search` block, + so it is on for this box and deleting the block turns it off again. +- **In the world, live search leads and the ZIMs are the fallback** (owner's + call, 2026-08-02). A self-hosted SearXNG 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. + +### The world chain + +`Response.Empty()` is the whole gate and there is no quality threshold in front +of it. Four signals were tried and none separates 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 +(`docs/evals/2026-08-05-search-quality-signals.md`). **The embedder is not a +fifth signal**: query-to-passage cosine measures topic and not whether the +passage answers, and the two sets overlap +(`docs/evals/2026-08-09-kiwix-topic-retrieval.md`). + +The connect phase alone is capped at `dialTimeout` (1.5s), because a blackholed +host once cost the owner 8 seconds. A slow instance that did connect keeps the +full 8 (`docs/evals/2026-08-05-kiwix-offline-fallback.md`). + +**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. + +**Kiwix ranks by keyword overlap.** Never send it a whole sentence. +`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. `TitleCandidates` tries the spoken form and +then the capitalized one. Both apply on the verbatim path alone. The rewriter +already reduces a question, and reducing twice takes the topic off its input +(V-668). + +**Which query source claimed a turn is readable on `/chat`** as a badge beside +the reply. It is 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. ## 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 `