From 68a3c8518622845958f209dfda9d8da7fe2000ae Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 8 Aug 2026 22:24:37 +0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- internal/router/decisiontrace.go | 9 +++-- internal/router/router.go | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/internal/router/decisiontrace.go b/internal/router/decisiontrace.go index f66ac2a..140ec20 100644 --- a/internal/router/decisiontrace.go +++ b/internal/router/decisiontrace.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index 2a6415d..3ad9653 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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).