Compare commits

...

13 Commits

Author SHA1 Message Date
claude 7138086c3f The routing heads run in Go now, so say so (V-664)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:33:35 +04:00
claude 83e168f326 Record what the routing heads score in Go (V-664)
Two defects were found on the way: the tokenizer read every long word
backwards, and the clarify head was discarded below the intent threshold.
Both numbers are in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:32:48 +04:00
claude a4abcdefa3 Give the daemon a heads_path and a fixture arm (V-664)
embedder.heads_path is empty by default and deploy/mavend.json sets
it. A missing or broken weights file logs and leaves the heads nil,
because refusing to start over a routing accelerator would trade a
working box for a better one.

TestONNXRoutingHeads is the same cascade TestONNXBaseline scores with
one arm added, so the two are directly comparable. It also checks the
Go tokenizer against the Python one, since the heads were trained
through transformers and are read through a hand-written tokenizer: a
mismatch shows up here as a score below what Python measured on the
same weights, and nowhere else. That is how the reversed word pieces
were found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:24:47 +04:00
claude 68a3c85186 Wire the heads between stage 0 and the resident model (V-664)
They run before the model because they are two orders of magnitude
faster and score better on both halves of the route. They decline
rather than clarify, so a declined turn carries on to the model and
then the classifier, which is what a box with no weights file does on
every turn. Nil heads are byte-for-byte the cascade that shipped
before this.

Measured on the 96-case fixture, classifier+ONNX either way:

  intent       76.0% -> 96.9%
  destination  36.4% -> 75.8%
  false clarify   0 -> 1
  missed clarify  8 -> 1
  p50          24.5ms -> 27.9ms

That beats the gemma-4-12b cascade on both halves, 84.4% and 72.7%, at
a twelfth of its 329ms. The four remaining destination misses are all
calendar, which is the stage 0 trade V-660 flagged and the owner has
not called yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:24:37 +04:00
claude 88c086482e Load the routing heads and read three of the four (V-664)
The heads trained in V-661 ran nowhere. This loads the exported graph
and reads intent, destination and clarify off one forward pass. It
declines below 0.6 max softmax rather than clarifying, so a declined
turn reaches whatever is behind it.

The slot head is exported and deliberately not read: slots already
come from the stage-2 extractor, and mapping BIO tags back to text
needs character offsets the tokenizer does not keep.

The clarify head decides on its own and decides first. It answers a
different question from the intent head, so a low intent confidence is
no reason to discard it. Reading it only above the intent threshold
cost 6 of the 8 ambiguous cases on the fixture: the word for water
reads as intent act at 0.23 and clarify at 0.98.

0.6 is the knee measured on the intent fixture: every higher value up
to 0.9 drops right answers and keeps the same two wrong ones.

The body is a fine-tuned COPY of the resident embedder and must never
replace it, because memory recall depends on that file scoring what it
scored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:24:37 +04:00
claude feabf9f350 The tokenizer read every long word backwards (V-664)
encodeWord backtracks the Viterbi path from the end of the word and
prepends each piece, which puts them back in reading order. A second
reverse after that loop undid it. So "query: вода" tokenized to
[0 12 1294 41 12489 2] where the reference tokenizer gives
[0 41 1294 12 12489 2], and every multi-piece Russian word reached the
model with its pieces in the wrong order.

Measured on the recall fixture, same 27 cases either way:

  recall@1  70.4% -> 77.8%
  recall@3  85.2% -> 96.3%
  answered after gate  63.0% -> 66.7%
  false recall  0/5 -> 1/5

The classifier barely moves, 76.0% to 75.0% on the routing fixture,
because seeds and queries were mangled the same way and cosine survived
it. Recall is where it cost, because a stored passage and a live query
are different lengths and break differently.

The embedder id now names a tokenizer revision. Stored vectors were
written under rev 1 and no longer sit in the same space as a query
embedded now, and the model file's name never moved, so nothing would
have triggered ReembedAll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:23:56 +04:00
kami 50c6637c1b Merge pull request 'The usage harness cannot read the query source badge' (#205) from task/662-usage-harness-source-badge into master 2026-08-08 19:59:14 +02:00
claude ee9d55ca95 Measure what the two clarify bounds bought (V-663)
Tail turns 21 to 17, the longest ride 8 turns to 4, and the two worst
replies in the corpus are gone: "спасибо" and "привет" are no longer
answered with "Сейчас 21:25. В какой день?".

MaxRides is not what fired. With the pleasantry counted as an aside the run
of asides is unbroken, so MaxSuspends reached three and ended it. Rides is
the backstop for the shape where an answer really does break the run, and
no turn in this corpus reaches it. Said so rather than crediting the new
bound.

Four rides did not move. They are asides against a question the owner never
answers, which MaxSuspends already bounds at four turns each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:49:32 +04:00
claude a886217223 A greeting is not a failed answer (V-663)
classifyTurnRole read "спасибо" and "привет" as answers to whatever was
parked, so she re-asked "В какой день?" at a man saying thank you and
spent one of three attempts doing it. That attempt is a bound meant to end
the ride, so the pleasantry both produced the worst reply in the corpus and
paid for the privilege.

They are asides now: answered as themselves, the question resumed on the
tail, no attempt spent, one ride counted.

The set is a new closed lexicon entry, matched as WHOLE utterances. Every
token rule tried was wrong on something. "вечер" answers "это утра или
вечера?" and "нет" answers a confirm, so anything that could fill a slot
stays out. The control words stay out too, because isCancel owns them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:43:15 +04:00
claude de9884e063 Count the rides a question takes, without the reset (V-663)
MaxSuspends did not move the number it was written for. Twenty-six of 140
turns carried a parked clarify tail before it landed and twenty-six after.

Two bounds rearm each other. An aside spends no attempt, so MaxAttempts
never reaches it. A turn reading as a failed answer zeroes Suspends, so
MaxSuspends never reaches the asides. Alternating them restores each bound
with the other's traffic. Measured on 2026-08-08: one question about a
reminder's day rode turns 7 to 13.

PendingQuestion.Rides is the same event counted without the resets. Set
once, incremented only in noteSuspended, carried across the re-park in
askRemainingGap, read by nothing that could lower it. MaxRides is 4, one
looser than MaxSuspends so the tighter statement about a run stays
reachable.

It ends the measured ride one turn early and no more. Most of that ride is
attempts, spent because classifyTurnRole reads "спасибо" and "привет" as
failed answers. Said so in the constant and in the design doc rather than
claiming a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:38:32 +04:00
claude bbefda66e2 Read the source column off the badge, not off the wording (V-662)
The third run of the same 140 turns, with the harness fix in. Sixty-eight
turns name a source.

Two findings the wording could not carry. The unfixed homelab turns are
claimed by weather and by feeds, which the destination fixture predicted.
And agenda questions are claimed by the personal boundary and by Praxis,
not by the calendar: 3 of 6, the same 3 of 6 the destination fixture and
every routing-head seed score.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:31:22 +04:00
claude a37c4138a1 Read the source badge under the name the server writes (V-662)
scripts/usage-run.py read the redirect parameter "src". cmd/mavweb/chat.go
writes it as "s". So Source came back empty on all 140 turns of both
fortnight runs, and every finding in those two docs is read off the reply
wording instead of off the badge.

Re-run confirms the column now arrives: 68 of 140 turns name a source.
The two homelab misses are now direct evidence rather than inference.
"какая скорость у меня сейчас?" is claimed by weather and
"хватает ли места под новые бэкапы?" by feeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:30:54 +04:00
kami d6f391430f Merge pull request 'Re-run the fortnight against merged master' (#204) from task/661-post-merge-usage-rerun into master 2026-08-08 19:15:51 +02:00
30 changed files with 993 additions and 41 deletions
+30 -3
View File
@@ -308,9 +308,36 @@ 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.
**Nothing of this runs in Go.** The weights are `heads.pt` and `out/body_heads/`
on workpc. Reaching the daemon needs an ONNX export and a caller. The resident
e5-small must not be replaced by the copy, because recall depends on that file.
**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
+1 -1
View File
@@ -19,7 +19,7 @@ func TestChatAnswersWithNoLlamaServer(t *testing.T) {
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
emb := router.NewHashEmbedder(1024)
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead), nil)
h.replier = newLLMReplier(dead, nil)
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
+12 -2
View File
@@ -489,15 +489,19 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
if !q.CanResume() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.noteDropped(ctx)
log.Printf("voice: clarify — the question about %s stepped aside %d times; letting the request go", q.Missing[0], q.Suspends)
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
return
}
q.Suspends++
// Rides is the same event counted without the reset (V-663). Incremented
// beside Suspends and never anywhere else, so the two cannot disagree about
// what happened, only about how much of it they remember.
q.Rides++
q.Asked = h.now()
h.clarifyStore.Put(dialogueIDOf(ctx), q)
rt.resume = question
rt.suspended = true
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends)
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
@@ -540,6 +544,11 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
// Suspends is not carried, and by this point it is already zero: the answer
// path resets it (V-654). Left off the literal so the zero is stated where
// the struct is built, rather than inherited from a field nobody names.
//
// Rides IS carried, and that is the whole point of it (V-663). This is the
// same request under a second question, not a new one, so the turns it has
// already ridden still count against it. Dropping the field here is exactly
// the re-basing that let one question ride twenty-six replies.
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
Intent: q.Intent,
Slots: merged,
@@ -550,6 +559,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
TTL: clarifyTTL,
Attempts: q.Attempts + 1,
MaxAttempts: q.MaxAttempts,
Rides: q.Rides,
})
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
return question, true
+2 -2
View File
@@ -317,7 +317,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
h, _, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(1024)
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, nil)
h.router = buildRouter(emb, h.matcher, 0.55, nil, nil)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
@@ -671,7 +671,7 @@ func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
t.Helper()
h, st, _ := newClarifyHandler(t)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st
}
+1 -1
View File
@@ -25,7 +25,7 @@ func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
return &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+1 -1
View File
@@ -171,7 +171,7 @@ func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Tim
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st, now
}
+1 -1
View File
@@ -24,7 +24,7 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, 0.55, nil)
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
h := &reactiveHandler{
api: api,
+1 -1
View File
@@ -20,7 +20,7 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+1 -1
View File
@@ -45,7 +45,7 @@ func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+2 -2
View File
@@ -22,7 +22,7 @@ func TestReactiveNotesReminders(t *testing.T) {
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, 0.55, nil)
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
h := &reactiveHandler{
api: api,
@@ -104,7 +104,7 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, matcher, 0.55, nil),
router: buildRouter(emb, matcher, 0.55, nil, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
+1 -1
View File
@@ -474,7 +474,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
// used to be built on a nil API, which meant any scenario that produced an
// act panicked the moment the matcher was consulted.
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
w.handler = &reactiveHandler{
stt: simTranscriber{},
+32
View File
@@ -186,6 +186,28 @@ func carriesReminderVerb(text string) bool {
return false
}
// isPleasantry matches the WHOLE utterance against lexicon.Pleasantries, after
// lowercasing and dropping the punctuation a greeting carries.
//
// Whole utterance and not tokens. Every token rule tried here was wrong on
// something: "вечер" answers "это утра или вечера?", "нет" answers a confirm,
// and "спокойной" alone is not an utterance at all. A greeting is a fixed
// phrase, so matching it as one costs nothing and claims nothing else.
func isPleasantry(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
t = strings.Trim(t, " .,!?…")
t = strings.Join(strings.Fields(t), " ")
if t == "" {
return false
}
for _, p := range lexicon.Pleasantries() {
if t == p {
return true
}
}
return false
}
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
// which cost nothing and never depend on the model that produced the routing.
// It is also the whole answer when there is no route to read — the classifier
@@ -225,6 +247,16 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
// hour, and no route saying "question" changes that. It works because the
// extractor no longer reads a day word as the current clock, so a sentence
// that names no hour now fills nothing to weigh.
// A pleasantry is neither (V-663). "спасибо" and "привет" fell through to
// roleAnswer, so a question about a reminder's DAY was re-asked at a man
// saying thank you, and the retry it spent was one of the three bounds
// meant to end the ride. It is an aside: answered as itself, the question
// resumed on the tail, no attempt spent, one ride counted. Placed above the
// content gate because "доброе утро" has content and states nothing, so
// neither half of the evidence below can reach it.
if q != nil && isPleasantry(text) {
return roleAside
}
own := false
if len(ownContent(text)) > 0 {
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
+65
View File
@@ -372,4 +372,69 @@ func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
if q.Suspends != 0 {
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
}
// The ride it already took is carried across the re-park (V-663). Resetting
// both counters here is what let one question ride twenty-six replies.
if q.Rides != 1 {
t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides)
}
}
// TestTwoBoundsCannotRearmEachOther — V-663.
//
// MaxSuspends landed and the measurement did not move: twenty-six of 140 turns
// carried a tail before it and twenty-six after. This is the shape it misses,
// taken from the 2026-08-08 run, where one question rode turns 7 to 13.
//
// An aside spends no attempt, so MaxAttempts never reaches it. A turn that
// reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the
// asides either. Alternating the two rearms each bound with the other's
// traffic. Rides counts both kinds and is never reset, so it is what ends this.
func TestTwoBoundsCannotRearmEachOther(t *testing.T) {
ctx := context.Background()
h, _ := newRoutingClarifyHandler(t)
id := dialogueIDFor(sourceText, "web")
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
// Two asides. Each one rides and neither spends an attempt.
for i := 0; i < 2; i++ {
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("aside %d: the question must come back, got %q", i+1, reply)
}
}
q := h.clarifyStore.Get(id, h.now())
if q == nil || q.Rides != 2 || q.Suspends != 2 {
t.Fatalf("after two asides: %+v", q)
}
// A pleasantry. It used to read as a failed answer, so she re-asked the
// question at a man saying thank you and spent an attempt doing it. Now it
// is an aside: answered as itself, question on the tail, one more ride.
reply := h.handleText(ctx, "web", "спасибо")
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("a pleasantry lost the parked question: %q", reply)
}
q = h.clarifyStore.Get(id, h.now())
if q == nil || q.Attempts != 1 {
t.Fatalf("a pleasantry spent an attempt: %+v", q)
}
if q.Rides != 3 {
t.Fatalf("a pleasantry rode free: %+v", q)
}
// One more ride of any kind and the request goes, out loud.
reply = h.handleText(ctx, "web", "какие у меня напоминания?")
if !strings.Contains(reply, clarifyDropped) {
t.Fatalf("the question rode four asides and was let go in silence: %q", reply)
}
if strings.HasSuffix(reply, resumed) {
t.Fatalf("a question she has let go must not be asked again: %q", reply)
}
if h.clarifyStore.Get(id, h.now()) != nil {
t.Fatal("the question must be gone once she has said she let it go")
}
}
+29 -3
View File
@@ -36,7 +36,9 @@ type voiceWiring struct {
sessions *voice.Sessions
voiceSink delivery.Sink
embedder router.Embedder
handler *reactiveHandler // the reactive handler for IPC Chat
// heads — the routing heads, nil unless embedder.heads_path is set.
heads *router.RouterHeads
handler *reactiveHandler // the reactive handler for IPC Chat
// worker clients (set when configured as Remote): closed on shutdown so
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
sttClient *worker.Client
@@ -72,6 +74,9 @@ func (w *voiceWiring) close() {
if w.embedder != nil {
_ = w.embedder.Close()
}
if w.heads != nil {
_ = w.heads.Close()
}
if w.server != nil {
_ = w.server.Close()
}
@@ -147,6 +152,24 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
emb = router.NewHashEmbedder(1024)
}
w.embedder = emb
// ----- router: routing heads (only when configured, and never fatal) -----
// A missing or broken weights file logs and leaves w.heads nil, which is
// byte-for-byte the cascade that shipped before V-664. Refusing to start
// over a routing accelerator would trade a working box for a better one.
if cfg.Voice.Embedder != nil && cfg.Voice.Embedder.HeadsPath != "" {
h, err := router.NewRouterHeads(
cfg.Voice.Embedder.HeadsPath,
cfg.Voice.Embedder.TokenizerPath,
)
if err != nil {
log.Printf("voice: routing heads unavailable, cascade unchanged: %v", err)
} else {
log.Printf("voice: routing heads loaded from %s", cfg.Voice.Embedder.HeadsPath)
w.heads = h
}
}
repairFactVectors(dataStore, emb)
checkStoredEmbedder(dataStore, emb)
// Retention is enforced on write, which is not enough on its own: a box that
@@ -223,7 +246,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
// fallback, so a model error never breaks a turn.
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), hot))
rtr := buildRouter(emb, matcher, threshold,
pickLLMRouter(cfg.Voice.UseLLMRouter(), hot), w.heads)
// ----- sessions registry (shared with voicesink) -----
sessions := voice.NewSessions()
@@ -390,7 +414,8 @@ func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
// intent from seedDir (models/seeds/<intent>.txt) — see seedClassifier
// below for the current intent list and file names.
// - Threshold is from voice.router_threshold config (default 0.55).
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
llmR *router.LLMRouter, heads *router.RouterHeads) *router.Router {
cls := router.NewClassifier(emb)
seedClassifier(cls)
grammars := router.DefaultGrammars(acts)
@@ -442,6 +467,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
},
Threshold: threshold,
LLM: llmR,
Heads: heads,
})
}
+2 -1
View File
@@ -231,7 +231,8 @@
"embedder": {
"model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx",
"tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json",
"lib_path": "/opt/maven/lib/libonnxruntime.so"
"lib_path": "/opt/maven/lib/libonnxruntime.so",
"heads_path": "/opt/maven/models/embedder/router-heads/router_heads.onnx"
},
"llm_router": true,
"query_min_score": 0.55,
+23
View File
@@ -308,6 +308,29 @@ The count is of CONSECUTIVE step-asides. It resets the moment he answers, in
too. "Позвонить маме" against a question about the time is still him in the
exchange. The retry it costs is bound enough on its own.
#### And it may ride four turns in all
Decided 2026-08-08 (V-663), because the bound above did not move the number it
was written for. Twenty-six of 140 turns carried a tail before it landed and
twenty-six carried one after.
Two bounds rearm each other. An aside spends no attempt, so `MaxAttempts` never
reaches it. A turn that reads as a failed answer zeroes `Suspends`, so
`MaxSuspends` never reaches the asides. Alternating them, each bound is restored
by the other's traffic. Measured on 2026-08-08: one question about a reminder's
day rode turns 7 to 13. It ended only because turn 14 was a new request.
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
taken out. It is set once, incremented only in `noteSuspended`, carried across
the re-park in `askRemainingGap`, and read by nothing that could lower it.
`MaxRides` is 4, one looser than `MaxSuspends` so that the tighter statement
about a run stays reachable.
This is a bound, not a cure. It ends the measured ride one turn early. Most of
that ride's length is attempts, spent because `classifyTurnRole` reads "спасибо"
and "привет" as failed answers to a question about a day. That is the next
thing to fix and it is not a bound.
The re-ask is also two sentences rather than one. It used to be spliced onto the
answer with a comma. On a real answer that buries the question in the tail of
one run-on thought:
@@ -0,0 +1,88 @@
# The parked clarify ride, bounded and re-measured
Date: 2026-08-08, V-663. Same 140 turns, same driver, third and fourth runs of
the day. Before is `d6f3914`, after is that plus two changes.
## What was measured before
One question about a reminder's day rode turns 6 to 13. It ended only because
turn 14 was a new request. Two of those turns are the worst replies in the
corpus:
```text
спасибо -> Сейчас 21:25. В какой день?
привет -> Сейчас 21:25. В какой день?
```
V-654 had already added `MaxSuspends` and the tail count had not moved.
## Why three bounds let it happen
The TTL, `MaxAttempts` and `MaxSuspends` all exist and all were rearmed.
An aside spends no attempt, so `MaxAttempts` never reaches it. A turn reading as
a failed answer zeroes `Suspends`, so `MaxSuspends` never reaches the asides.
`спасибо` read as a failed answer. So the pleasantry both produced the reply
above and paid for it out of the budget meant to end the ride.
## The two changes
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
taken out. Set once, incremented only in `noteSuspended`, carried across the
re-park in `askRemainingGap`. `MaxRides` is 4, one looser than `MaxSuspends` so
that the tighter statement about a run stays reachable.
A pleasantry is an aside, not a failed answer. The set is a new closed lexicon
entry, matched as whole utterances rather than tokens. `вечер` answers `это утра
или вечера?` and `нет` answers a confirm. Anything that could fill a slot stays
out.
## Result
| | before | after |
|---|---|---|
| turns carrying a clarify tail | 21 | 17 |
| turns carrying any failure string | 31 | 29 |
| the longest ride | 8 turns | 4 turns |
The turns carrying a tail, by number:
```text
before 6 7 8 9 10 11 12 13 53 54 55 56 57 99 102 103 116 136 137 138 139
after 6 7 8 9 53 54 55 56 57 99 100 101 116 136 137 138 139
```
Turn 10 is the change. It now reads:
```text
спасибо -> Прошлую просьбу отпускаю. Пожалуйста, я всегда готова помочь тебе.
```
She lets the request go, says so, and answers the man. Turns 11 to 13 are clean.
**`MaxRides` is not what fired.** The pleasantry is an aside now, so it no
longer breaks the run. `MaxSuspends` reached three on turn 10 and ended it.
`Rides` is the backstop for the shape where an answer really does break the run.
No turn in this corpus reaches it.
## What did not move
Four rides are untouched. Turns 53 to 57 are five consecutive asides against a
reminder missing its day. Turn 58 is a new request that drops it. Nothing
pleasant appears in that run, so neither change applies. Turns 99 to 101 shifted
by one, and 116 and 136 to 139 are unchanged.
So the fix is worth four turns of twenty-one. What is left is asides against a
question the owner never answers. `MaxSuspends` was written for that shape and
does bound it, at four turns each.
## Not attributable
Latency moved p50 1.1s to 1.5s and p95 2.8s to 3.0s, and the 34.3s outlier in
the earlier run is gone. Both runs had the workstation up. Read none of it as
caused by this change.
One unrelated defect appeared in the after run and is recorded here because it
is visible in the transcript. Turn 4 answered `Я записала твою привычкуRegarding
coffee without sugar.` That is English leaking into a Russian reply with no
space in front of it. It is a phrasing defect and it has no task yet.
@@ -0,0 +1,146 @@
# The routing heads, running in Go
Date: 2026-08-08. Vikunja V-664.
Weights: `router_heads.onnx`, fp32, exported from `heads.pt` on workpc.
Fixture: `internal/router/eval/ru_routing_v1.json`, 96 cases, 33 carrying a destination.
Runner: `make t PKG=./internal/router/eval/ RUN=TestONNXRoutingHeads`.
The four heads of V-661 ran nowhere. This is the number they score through the
Go cascade. Same fixture and same grammars as `TestONNXBaseline`, and only the
middle stage varies.
## Headline
| | classifier + ONNX | heads + classifier | gemma-4-12b cascade |
|---|---|---|---|
| intent | 75.0% (72/96) | **96.9% (93/96)** | 84.4% |
| destination | 33.3% (11/33) | **75.8% (25/33)** | 72.7% |
| false clarify | 0 | 1 | 2 |
| missed clarify | 8 | 1 | 1 |
| p50 | 24.5ms | 27.9ms | 329ms |
A 118M encoder beats the 12B teacher it was distilled from. It wins on both
halves of the route, at a twelfth of the latency. The workstation stays the
better phraser and is no longer the better router.
The p50 is not the heads. Most of it is the classifier's own embedder pass on
the turns the heads decline, plus process warm-up on the first case. The heads'
own forward pass measures 7.3ms on workpc.
## Two defects were in the way, and the first was not in the heads
**The tokenizer read every long word backwards.** `encodeWord` backtracks the
Viterbi path from the end of a word and prepends each piece. That puts them back
in reading order, and a second reverse after the loop undid it. So
`query: вода` tokenized to `[0 12 1294 41 12489 2]` where the reference
tokenizer gives `[0 41 1294 12 12489 2]`.
It was found here and only here. The heads were trained through transformers and
are read through the hand-written tokenizer. So a mismatch shows up as a score
far below what Python measured on the same weights. Nothing else in the suite
compares the two.
Measured on the recall fixture, same 27 cases either way:
| | reversed | fixed |
|---|---|---|
| recall@1 | 70.4% (19/27) | **77.8% (21/27)** |
| recall@3 | 85.2% (23/27) | **96.3% (26/27)** |
| answered after gate | 63.0% | 66.7% |
| wrong note on top | 8 | 6 |
| false recall | 0/5 | 1/5 |
The classifier barely moved, 76.0% to 75.0%, and destination 36.4% to 33.3%.
Both are one case on 96 and neither is a finding. Seeds and queries were mangled
the same way, so cosine survived it. Recall is where it cost, because a stored
passage and a live query are different lengths and break differently.
The one new false recall is the honest cost and it is not being hidden. A
sharper embedder scores every candidate higher, including the ones that should
have stayed under the gate. That is the same trade `2026-08-04-recall-e5-small.md`
recorded when e5-small replaced MiniLM.
The embedder id now carries a tokenizer revision, `model_quantized@384/tok2`.
Stored vectors were written under rev 1 and no longer sit in the same space as a
query embedded now. The model file's name never moved, so nothing would have
triggered `ReembedAll`. On the box the marker fired on start, and the re-embed
rewrote 65 notes and 19 facts in 5 seconds.
**The clarify head was being thrown away.** It was read only when the intent head
cleared its own threshold. That cost 6 of the 8 ambiguous cases. `вода` reads as intent
`act` at 0.233 and clarify at 0.983. Burying that handed the turn to the
classifier, which routed it confidently and never asked. The clarify head answers
a different question, which is whether there is enough here to act on at all. So
it decides on its own and decides first.
| | intent-gated | clarify decides first |
|---|---|---|
| intent | 90.6% | 96.9% |
| missed clarify | 7 | 1 |
| false clarify | 0 | 1 |
## The threshold is measured, not chosen
Max softmax over the intent head, on the 88 cases carrying an intent:
| threshold | kept | accuracy kept | wrong kept | right dropped |
|---|---|---|---|---|
| 0.5 | 84 | 96.4% | 3 | 2 |
| **0.6** | **81** | **97.5%** | **2** | **4** |
| 0.7 | 75 | 97.3% | 2 | 10 |
| 0.8 | 64 | 96.9% | 2 | 21 |
| 0.9 | 46 | 100.0% | 0 | 37 |
0.6 is the knee. Every value from 0.7 to 0.85 drops right answers and keeps the
same two wrong ones. 0.9 is the only value that clears them, and it costs 37
correct routes to do it.
## Quantization was measured and rejected
| build | size | intent | destination | p50 |
|---|---|---|---|---|
| fp32 | 470MB | 83/88 (94.3%) | 28/33 (84.8%) | 7.3ms |
| int8 | 118MB | 79/88 (89.8%) | 26/33 (78.8%) | 4.0ms |
| fp16 | 235MB | will not load | — | — |
Python numbers, on the heads alone rather than through the cascade. int8 costs
4.5 points of intent and 6 of destination to save 3ms. The cascade around it has
a p50 over a second when the resident model answers. The fp16 graph is broken:
`convert_float_to_float16` leaves a Cast node emitting float16 where the graph
expects float, and onnxruntime refuses the session. It was not worth fixing.
The exporter also had to be told to write one file. It splits weights into a
`.onnx.data` sidecar by default. This onnxruntime resolves that path against the
process working directory rather than the model. A split graph loads from one
directory only.
## What is still wrong
**Four of the eight destination misses are calendar.** Training cannot move them.
The possessive agenda rules claim those cases at stage 0 and name nothing on
purpose. That caution was free while nothing downstream could name anything
either. It has now cost four points in three separate measurements. The call is
the owner's and it is still open.
**The slot head is exported and not read.** Slots come from the stage-2
extractor. Mapping BIO tags back to text needs character offsets the unigram
tokenizer does not keep, which is its own piece of work.
**`поужинал` is a false clarify**, which is the same defect `thinSingleToken`
was narrowed for on 2026-08-01, arriving now from a different direction.
## On the box
Deployed to homesrv the same day. `voice: routing heads loaded` on start, and
`/trace` shows `routing-heads` winning or thinning every turn. The resident model
and the classifier are both marked never asked. Live probes:
```text
что такое TCP? -> kiwix a real definition
кто такой Линус Торвальдс? -> kiwix a real answer
во сколько я лёг вчера -> personal не нашла у тебя такой записи
вода -> thinned to clarify at 0.233 / 0.983
```
A missing or broken weights file logs and leaves the heads nil, which is
byte-for-byte the cascade that shipped before this.
+47 -6
View File
@@ -80,12 +80,53 @@ V-655 was never going to touch this. A parked clarify is dialogue state and not
a query source. It remains the single worst thing about talking to her. The week test, the
fortnight test and this re-run all report it unchanged.
## A gap in the harness
## A gap in the harness, fixed and re-run the same day
`ipc.ChatReply.Source` came back empty on all 140 turns, in both runs. The
driver reads it from the redirect query string and there is nothing there. So
the badge that says which query source claimed a turn is invisible to the
harness, and every finding above is read off the reply text instead.
driver read the redirect parameter `src` and `cmd/mavweb/chat.go` writes `s`.
So every finding above is read off the reply text instead of off the badge.
That is worth fixing before the next re-run. Reading the source directly would
have shown the two homelab misses without inferring them from the wording.
Fixed in V-662 and the 140 turns were driven a third time. Sixty-eight of them
name a source. The rest are not query turns and never reach `queryWalk`.
| source | turns |
|---|---|
| search | 27 |
| memory | 13 |
| personal | 9 |
| weather | 5 |
| calendar | 3 |
| attention | 3 |
| list | 2 |
| feeds | 2 |
| tasks, money, self, habits | 1 each |
## What the badge shows that the wording did not
The two unfixed homelab turns are now direct evidence.
```text
какая скорость у меня сейчас? -> weather
хватает ли места под новые бэкапы? -> feeds
```
Both are guessing sources claiming a turn about the box, exactly as the
destination fixture predicted.
The badge also names a defect the wording hid. **Agenda questions are being
claimed by the personal boundary and by Praxis, not by the calendar.**
```text
во сколько у меня встреча? -> personal не нашла у тебя такой записи
когда у меня встреча? -> attention у Praxis нет источников
что у меня в понедельник? -> personal не нашла у тебя такой записи
```
Calendar claimed 3 turns of the 6 that asked about the calendar. That is the
same 3/6 the destination fixture scores and the same 3/6 every seed of the
routing head scores. Three measurements agree. The cause is the one V-660 named. The possessive
agenda rules claim these at stage 0 and name no destination, so the walk
reaches `personal` and `attention` first.
This is the third independent confirmation that the possessive agenda rules
should name the calendar. That call is still the owner's.
+10
View File
@@ -37,6 +37,16 @@ type EmbedderConfig struct {
ModelPath string `json:"model_path,omitempty"`
TokenizerPath string `json:"tokenizer_path,omitempty"`
LibPath string `json:"lib_path,omitempty"`
// HeadsPath — the routing heads graph, which is a fine-tuned COPY of the
// model above with four linear heads on its pooled output (V-664). Empty
// means no heads, and the cascade runs exactly as it did before they
// existed. It shares LibPath and TokenizerPath, and router_heads.json is
// read from the same directory.
//
// It must never be pointed at ModelPath. Memory recall depends on the
// resident copy scoring what it scored, and the fine-tuned one does not.
HeadsPath string `json:"heads_path,omitempty"`
}
// WeatherConfig configures the weather provider for voice queries.
+48 -1
View File
@@ -47,6 +47,11 @@ type PendingQuestion struct {
// charging it a retry is the V-554 shape. See CanResume for why it is
// counted at all.
Suspends int
// Rides counts every turn this question has ridden out on the end of
// someone else's reply, over the whole life of the request. Unlike Suspends
// it is never reset and never re-based, which is the only property that
// matters about it (V-663).
Rides int
}
// MaxSuspends — how many times one question may step aside and come back before
@@ -63,10 +68,52 @@ type PendingQuestion struct {
// is that he has moved on and has not said so.
const MaxSuspends = 3
// MaxRides — how many turns one question may ride out on the end of an
// unrelated reply, counted over its whole life (V-663).
//
// MaxSuspends did not move the measurement it was written for. Twenty-six of
// 140 turns carried a tail before it landed and twenty-six carried one after.
// Every bound on this question is rearmed by something ordinary:
//
// - The TTL is an inactivity timer, and both noteSuspended and reaskOrGiveUp
// restart it, so it cannot arrive while he keeps talking.
// - Suspends is zeroed by any turn that reads as an answer, which is where
// "спасибо" and "привет" land. It resets before anything is known to have
// been filled.
// - askRemainingGap builds a fresh question for the second gap, so a reminder
// with two gaps gets a new allowance halfway through.
//
// So Suspends only bites on four strictly consecutive side queries with nothing
// chat-like between them, which is not the shape real conversation has. Rides is
// the same idea with the resets taken out: set once, incremented, carried
// across a re-park, and read by nothing that could lower it.
//
// The shape it is aimed at is measured, not imagined. In the 2026-08-08 run one
// question about a reminder's day rode turns 7 to 13 and ended only because
// turn 14 was a new request. Three asides, then two turns that read as failed
// answers, then two more asides. The asides spend no attempt and the answers
// reset Suspends, so the two bounds take turns being rearmed by the other's
// traffic.
//
// Four, not three. It has to be looser than MaxSuspends or that bound is dead
// code, because Rides is never lower than Suspends and would always fire first.
//
// Do not read this as a fix for the whole ride. It ends the measured one a turn
// early and no more. Most of that ride's length is attempts, spent by turns
// like "спасибо" and "привет" being read as failed answers to a question about
// a day. That is a defect in classifyTurnRole and not in any bound here.
const MaxRides = 4
// CanResume reports whether this question may step aside once more. False ⇒ the
// caller lets the request go and says so; it must never simply stop resuming,
// because a question dropped in silence reads as one that was answered.
func (q *PendingQuestion) CanResume() bool { return q.Suspends < MaxSuspends }
//
// Two bounds, and they answer different questions. Suspends asks whether he has
// walked away from this exchange in the last few turns. Rides asks whether this
// question has been riding long enough that the answer is no regardless.
func (q *PendingQuestion) CanResume() bool {
return q.Suspends < MaxSuspends && q.Rides < MaxRides
}
// Action reads the parked question as the typed action it is assembling
// (pending.go). Derived rather than stored: the question's fields stay the one
+5
View File
@@ -119,6 +119,11 @@ func PartsOfDay() []string { return words("parts_of_day") }
// ReminderVerbs returns the imperatives that open a reminder.
func ReminderVerbs() []string { return words("reminder_verbs") }
// Pleasantries returns the whole utterances that greet, thank or say goodbye.
// Whole utterances and not tokens: see the set's own note for why the tokens
// are unsafe alone.
func Pleasantries() []string { return words("pleasantries") }
// TaskDoneWords returns the words that finish a task, and TaskDropWords the
// words that abandon one. Two sets rather than one with a value, because the
// store records which of the two happened and the caller has to say so.
+13
View File
@@ -176,6 +176,19 @@
"morning", "afternoon", "evening", "night"
]
},
"pleasantries": {
"note": "Whole utterances that greet, thank or say goodbye. They ask for nothing and answer nothing, so a parked question must neither consume them as a failed answer nor be dropped by them (V-663). Matched as WHOLE utterances and never as tokens, because the tokens are not safe alone: \"вечер\" answers \"это утра или вечера?\" and \"нет\" answers a confirm. Anything that could fill a slot stays out. The control words (\"стоп\", \"отмена\") stay out too, because isCancel already owns them and they mean something stronger.",
"words": [
"привет", "приветик", "здравствуй", "здравствуйте",
"доброе утро", "добрый день", "добрый вечер",
"пока", "прощай", "до свидания", "спокойной ночи",
"спасибо", "спасибо тебе", "большое спасибо", "благодарю",
"извини", "извините", "прости", "простите",
"hi", "hello", "hey", "bye", "goodbye",
"good morning", "good evening", "good night",
"thanks", "thank you", "thanks a lot", "sorry"
]
},
"reminder_verbs": {
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530). The alarm verbs joined them in V-627. \"разбуди меня в 6:30\" is a reminder that fires at the hour he gets up, and the set knew no form of it, so an alarm reached IntentReminder only by resembling one to the embedder.",
"words": [
+6 -3
View File
@@ -14,12 +14,15 @@ import (
"github.com/kami/maven/internal/decision"
)
// The two routing engines, named as claimants. They are one stage and not two,
// because only one of them ever runs: the classifier is reached when the model
// is absent or errored, never alongside it.
// The three routing engines, named as claimants. The model and the classifier
// are one stage and not two, because only one of them ever runs: the classifier
// is reached when the model is absent or errored, never alongside it. The heads
// run before both and decline on low confidence, so they can appear beside
// either one in a record.
const (
claimantLLM = "llm-router"
claimantClassifier = "classifier"
claimantHeads = "routing-heads"
)
// thinReason names which arm of gateLLMDecision cut the confidence. The gate
+12 -2
View File
@@ -1,10 +1,13 @@
package router
import "testing"
import (
"strings"
"testing"
)
func TestEmbedderIDFromModelPath(t *testing.T) {
got := modelIDFromPath("/opt/maven/models/embedder/multilingual-e5-small.onnx")
if got != "multilingual-e5-small@384" {
if got != "multilingual-e5-small@384/tok2" {
t.Fatalf("modelIDFromPath = %q", got)
}
// A different model file must produce a different id, even at 384 dim.
@@ -12,6 +15,13 @@ func TestEmbedderIDFromModelPath(t *testing.T) {
if old == got {
t.Fatal("two different models share one id")
}
// The tokenizer is half of what makes a vector, and it changes under a
// model file whose name never moves (V-664). An id that ignored it would
// leave stored passages in one space and every new query in another, with
// nothing to trigger the re-embed.
if !strings.Contains(got, "/tok") {
t.Fatalf("id %q does not name the tokenizer revision", got)
}
}
func TestEmbedderIDIncludesDim(t *testing.T) {
+86
View File
@@ -0,0 +1,86 @@
package eval
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/router"
)
// TestONNXRoutingHeads — the cascade with the routing heads wired, which is
// what V-664 deploys. Opt-in via MAVEN_ONNX_LIB, same as TestONNXBaseline, and
// one TestONNX* per process.
//
// The comparison worth reading is against TestONNXBaseline, which is the same
// cascade with the same grammars and the same classifier floor and no heads.
// Only the middle arm varies.
//
// It also checks the Go unigram tokenizer against the Python one, because the
// heads were trained through transformers and are read through a hand-written
// tokenizer. A mismatch shows up here as a score below what Python measured on
// the same weights, and nowhere else.
func TestONNXRoutingHeads(t *testing.T) {
lib := os.Getenv("MAVEN_ONNX_LIB")
if lib == "" {
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
}
// Absolute, because onnxruntime resolves a graph's external weights file
// against the model path it was given, and a relative one lands in the
// test's working directory.
root, err := filepath.Abs("../../..")
if err != nil {
t.Fatal(err)
}
model := filepath.Join(root, "models/embedder/multilingual-e5-small/model_quantized.onnx")
tok := filepath.Join(root, "models/embedder/multilingual-e5-small/tokenizer.json")
heads := filepath.Join(root, "models/embedder/router-heads/router_heads.onnx")
for _, p := range []string{lib, model, tok, heads} {
if _, err := os.Stat(p); err != nil {
t.Skipf("missing %s: %v", p, err)
}
}
emb, err2 := router.NewONNXEmbedder(model, tok, lib)
if err2 != nil {
t.Skipf("onnx embedder unavailable: %v", err2)
}
err = nil
defer emb.Close()
h, err := router.NewRouterHeads(heads, tok)
if err != nil {
t.Skipf("routing heads unavailable: %v", err)
}
defer h.Close()
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
rep, err := Score(context.Background(), "heads+classifier", withHeads(t, emb, h), f)
if err != nil {
t.Fatalf("Score: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
}
// withHeads mirrors newBaselineRouter and adds the one arm under test. It is a
// separate function rather than a parameter so the baseline's signature stays
// the shape every other test calls it with.
func withHeads(t *testing.T, emb router.Embedder, h *router.RouterHeads) *router.Router {
t.Helper()
acts := router.DefaultActMatcher{Fns: actFns}
return router.New(router.Config{
Grammars: baselineGrammars(acts),
Classifier: newBaselineClassifier(t, emb),
Extractor: router.Extractor{
Time: router.StubDateTimeParser{},
Acts: acts,
Facts: router.DefaultFactParser{},
},
Threshold: config.DefaultRouterThreshold,
Heads: h,
})
}
+243
View File
@@ -0,0 +1,243 @@
package router
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
ort "github.com/yalue/onnxruntime_go"
)
// The routing heads (V-546, V-661, V-664). Four linear heads over one masked
// mean pool of a fine-tuned copy of multilingual-e5-small: intent,
// destination, BIO slot tags and clarify. Trained on workpc, exported to ONNX,
// and read here.
//
// Why this is not the classifier. The classifier compares one utterance to
// frozen seed phrases by cosine. A head is a softmax over the label set, so it
// cannot name a value that does not exist, and its max is a calibratable
// confidence where Confidence: 1.0 was a hardcode.
//
// Why it is not the resident model either. It answers in single-digit
// milliseconds against the model's p50 of 1.19s, and it names a destination
// the classifier arm never names at all.
//
// The body is a COPY of the embedder weights, fine-tuned. It must never
// replace models/embedder/multilingual-e5-small — memory recall depends on
// that file scoring what it scored.
//
// The slot head is exported and deliberately not read. Slots already come from
// the stage-2 extractor, and mapping BIO tags back to text needs character
// offsets the unigram tokenizer does not keep. Reading it is separate work.
const (
// headsSeq — the sequence length the heads were trained at. Padding is
// masked out of both attention and the pool, so this changes nothing but
// truncation, and truncation is what training did at 64.
headsSeq = 64
// headsThreshold — max softmax over the intent head, below which the heads
// decline and the cascade carries on to the resident model.
//
// 0.6 is the knee measured on the 88-case intent fixture
// (docs/evals/2026-08-08-routing-heads-in-go.md). It keeps 81 of 88 cases
// at 97.5% accuracy. Every higher value up to 0.9 drops right answers and
// keeps the same two wrong ones, so it buys nothing.
headsThreshold = 0.6
)
// RouterHeads runs the exported graph. Nil is a working value everywhere: a
// deployment with no weights file routes exactly as it did before this
// existed.
type RouterHeads struct {
tokenizer *unigramTokenizer
session *ort.DynamicSession[int64, float32]
intents []Intent
sources []Source
threshold float64
}
// headsMeta — router_heads.json, written beside the weights by the exporter.
// The label order is the head's output order and cannot be inferred from Go.
type headsMeta struct {
Intents []string `json:"intents"`
Sources []string `json:"sources"`
Prefix string `json:"prefix"`
}
// NewRouterHeads loads the graph and its label order. modelPath points at the
// .onnx; the external weights and router_heads.json sit beside it.
//
// It assumes the ONNX environment is already initialised, because the embedder
// does that at startup and the runtime allows it once.
func NewRouterHeads(modelPath, tokenizerPath string) (*RouterHeads, error) {
metaPath := filepath.Join(filepath.Dir(modelPath), "router_heads.json")
raw, err := os.ReadFile(metaPath)
if err != nil {
return nil, fmt.Errorf("heads: read %s: %w", metaPath, err)
}
var meta headsMeta
if err := json.Unmarshal(raw, &meta); err != nil {
return nil, fmt.Errorf("heads: parse %s: %w", metaPath, err)
}
if meta.Prefix != queryPrefix {
return nil, fmt.Errorf("heads: trained with prefix %q, this build uses %q",
meta.Prefix, queryPrefix)
}
intents := make([]Intent, len(meta.Intents))
for i, s := range meta.Intents {
intents[i] = Intent(s)
}
sources := make([]Source, len(meta.Sources))
for i, s := range meta.Sources {
// SourceUnknown is not in Sources, because it is the absence of a
// choice. It is a class the head can emit, and the one it should emit
// often, so it is allowed here and nowhere else.
if s != string(SourceUnknown) && !ValidSource(Source(s)) {
return nil, fmt.Errorf("heads: unknown destination %q in %s", s, metaPath)
}
sources[i] = Source(s)
}
tok, err := newUnigramTokenizer(tokenizerPath)
if err != nil {
return nil, fmt.Errorf("heads: tokenizer: %w", err)
}
session, err := ort.NewDynamicSession[int64, float32](
modelPath,
[]string{"input_ids", "attention_mask"},
[]string{"intent", "source", "slots", "clarify"},
)
if err != nil {
return nil, fmt.Errorf("heads: create session: %w", err)
}
return &RouterHeads{
tokenizer: tok,
session: session,
intents: intents,
sources: sources,
threshold: headsThreshold,
}, nil
}
func (h *RouterHeads) Close() error {
if h == nil {
return nil
}
h.session.Destroy()
return nil
}
// headsResult — one forward pass, read back.
type headsResult struct {
Intent Intent
Source Source
Confidence float64
Clarify bool
}
// Route runs the heads and reports whether they are confident enough to answer.
// A false second return is a decline, not an error: the cascade goes on to the
// resident model, which is what happens today.
func (h *RouterHeads) Route(ctx context.Context, utterance string) (headsResult, bool, error) {
if h == nil {
return headsResult{}, false, nil
}
ids, mask, _ := h.tokenizer.Encode(queryPrefix + utterance)
ids, mask = ids[:headsSeq], mask[:headsSeq]
// The tokenizer pads and truncates to its own length, which is longer than
// this one. Cutting the tail can cut the separator with it, so put it back.
if mask[headsSeq-1] == 1 {
ids[headsSeq-1] = sepTokenID
}
shape := ort.NewShape(1, headsSeq)
idsT, err := ort.NewTensor(shape, ids)
if err != nil {
return headsResult{}, false, fmt.Errorf("heads: ids tensor: %w", err)
}
defer idsT.Destroy()
maskT, err := ort.NewTensor(shape, mask)
if err != nil {
return headsResult{}, false, fmt.Errorf("heads: mask tensor: %w", err)
}
defer maskT.Destroy()
intentT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(h.intents))))
if err != nil {
return headsResult{}, false, fmt.Errorf("heads: intent tensor: %w", err)
}
defer intentT.Destroy()
sourceT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(h.sources))))
if err != nil {
return headsResult{}, false, fmt.Errorf("heads: source tensor: %w", err)
}
defer sourceT.Destroy()
slotsT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, headsSeq, int64(numBIOTags)))
if err != nil {
return headsResult{}, false, fmt.Errorf("heads: slots tensor: %w", err)
}
defer slotsT.Destroy()
clarifyT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 2))
if err != nil {
return headsResult{}, false, fmt.Errorf("heads: clarify tensor: %w", err)
}
defer clarifyT.Destroy()
if err := h.session.Run(
[]*ort.Tensor[int64]{idsT, maskT},
[]*ort.Tensor[float32]{intentT, sourceT, slotsT, clarifyT},
); err != nil {
return headsResult{}, false, fmt.Errorf("heads: run: %w", err)
}
// The graph applies its own softmax, so these are probabilities and the max
// is the same number the eval calibrated the threshold against.
i, conf := argmax(intentT.GetData())
res := headsResult{
Intent: h.intents[i],
Confidence: conf,
}
cl := clarifyT.GetData()
res.Clarify = len(cl) == 2 && cl[1] > cl[0]
// The destination head is trained on query rows and is meaningless on any
// other intent, the same way queryWalk is never reached by one.
if res.Intent == IntentQuery {
s, _ := argmax(sourceT.GetData())
res.Source = h.sources[s]
}
// The clarify head decides on its own, and it decides first. It answers a
// different question from the intent head — not which intent, but whether
// there is enough here to act on at all — so a low intent confidence is no
// reason to discard it. It is usually the same turns: "вода" reads as
// intent act at 0.23 and clarify at 0.98, and letting the intent threshold
// bury that hands the turn to the classifier, which routes it confidently
// and never asks.
if res.Clarify {
return res, true, nil
}
if conf < h.threshold {
return res, false, nil
}
return res, true, nil
}
// numBIOTags — O plus B- and I- for each of Maven's five slots. The head is not
// read, but the graph writes it and the output tensor has to be the right size.
const numBIOTags = 11
func argmax(v []float32) (int, float64) {
best, bestV := 0, math.Inf(-1)
for i, x := range v {
if float64(x) > bestV {
best, bestV = i, float64(x)
}
}
return best, bestV
}
+18 -8
View File
@@ -66,12 +66,19 @@ func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, e
func (e *onnxEmbedder) Dim() int { return embedDim }
// ID names the loaded model for the DB marker (Vikunja #378): the model file's
// own name plus the dimension, so pointing the config at another model changes
// the string on its own.
// own name, the dimension, and the tokenizer revision, so pointing the config
// at another model changes the string on its own.
func (e *onnxEmbedder) ID() string { return e.id }
// tokenizerRev — bumped whenever the tokenizer changes what it emits for the
// same text, because that changes every vector while the model file's name
// stays put. Rev 2 is the fix for the reversed word pieces (V-664): stored
// passages embedded under rev 1 no longer sit in the same space as a query
// embedded now, and ReembedAll rewrites them because this string moved.
const tokenizerRev = 2
// modelIDFromPath turns /opt/.../multilingual-e5-small.onnx into
// "multilingual-e5-small@384".
// "multilingual-e5-small@384/tok2".
func modelIDFromPath(modelPath string) string {
name := modelPath
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
@@ -81,7 +88,7 @@ func modelIDFromPath(modelPath string) string {
if name == "" {
name = "onnx"
}
return fmt.Sprintf("%s@%d", name, embedDim)
return fmt.Sprintf("%s@%d/tok%d", name, embedDim, tokenizerRev)
}
// Embed treats the text as a query. The classifier compares one short
@@ -339,14 +346,17 @@ func (t *unigramTokenizer) encodeWord(word string) []int64 {
}
}
// Backtracking walks the word from its end, and prepending each piece puts
// it back in reading order. There used to be a second reverse after this
// loop, which undid it: every multi-piece word came out backwards, and
// "query: вода" tokenized to [0 12 1294 41 12489 2] where the reference
// tokenizer gives [0 41 1294 12 12489 2] (V-664). A transformer reads
// position, so the pieces of a long Russian word were being read in the
// wrong order on every turn.
var result []int64
for i := n; i > 0; i = prev[i] {
result = append([]int64{bestID[i]}, result...)
}
// Reverse
for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 {
result[l], result[r] = result[r], result[l]
}
return result
}
+62
View File
@@ -31,6 +31,12 @@ type Config struct {
// error/parse failure, falls through to the classifier (never fails the
// turn on the model).
LLM *LLMRouter
// Heads — optional routing heads over the fine-tuned embedder copy. When
// set, Route consults them after stage 0 and before the LLM router. They
// decline below their own confidence threshold, so a low-confidence turn
// reaches the model exactly as it does today. Nil is the shipped-before
// behaviour and costs nothing.
Heads *RouterHeads
}
// Router — the deterministic cascade. Route never guesses: stage 0 wins
@@ -42,6 +48,7 @@ type Router struct {
extractor Extractor
threshold float64
llm *LLMRouter
heads *RouterHeads
}
func New(cfg Config) *Router {
@@ -51,6 +58,7 @@ func New(cfg Config) *Router {
extractor: cfg.Extractor,
threshold: cfg.Threshold,
llm: cfg.LLM,
heads: cfg.Heads,
}
}
@@ -98,6 +106,60 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
}
r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "")
// stage 0b — routing heads (when wired). A softmax over the label set, so
// it cannot name an intent or a destination that does not exist, and its
// max is a real confidence. It runs before the model because it is three
// orders of magnitude faster and scores better on both halves of the route.
//
// It declines below its threshold rather than clarifying. A declined turn
// carries on to the model and then the classifier, which is what a box with
// no weights file does on every turn.
if r.heads != nil {
res, ok, err := r.heads.Route(ctx, utterance)
switch {
case err != nil:
log.Printf("router: heads fell through to the rest of the cascade: %v", err)
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantHeads,
Outcome: decision.Declined, Reason: "error: " + err.Error(),
})
case !ok:
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
string(res.Intent), res.Confidence, decision.Declined,
"below the heads confidence threshold"))
default:
d := Decision{
Utterance: utterance,
Stage: 2,
Intent: res.Intent,
Confidence: res.Confidence,
Source: res.Source,
Clarify: res.Clarify,
}
r.fillSlots(ctx, &d, now)
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
})
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantClassifier,
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
})
outcome, reason := decision.Won, ""
if d.Clarify {
outcome, reason = decision.Thinned, "the clarify head says there is too little here to act on"
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
string(d.Intent), d.Confidence, outcome, reason))
return d, nil
}
} else {
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantHeads,
Outcome: decision.NeverAsked, Reason: "no routing heads are wired",
})
}
// stage 1a — LLM router (when wired). It reasons over the utterance instead
// of nearest-centroid guessing. On any error/parse-fail, fall through to the
// classifier cascade (never fail the turn on the model).
+5 -1
View File
@@ -54,7 +54,11 @@ def turn(text):
q = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query)
return {
"reply": q.get("r", [""])[0],
"source": q.get("src", q.get("source", [""]))[0],
# "s", not "src". cmd/mavweb/chat.go writes the badge under that
# name, and reading the wrong one cost both fortnight runs their
# source column: every finding in those docs is inferred from the
# reply wording instead.
"source": q.get("s", [""])[0],
"trace": q.get("t", [""])[0],
"secs": dt,
}