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).