// router/decisiontrace.go — what the cascade tells the per-turn decision record. // // The cascade's arbitration is order (V-558): the first grammar whose Build // agrees wins, and the model and the classifier are only reached because nobody // upstream did. None of that is visible afterwards, so V-564 has each stage say // its piece into the record riding the context. Nothing here reads the record // back and nothing here can change a route — a nil recorder is the normal case // in the fixture runner and every router test. package router import ( "context" "github.com/kami/maven/internal/decision" ) // 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 // has three structural holes and they are three different defects, so "thinned" // alone is not enough to act on. func thinReason(d *Decision) string { switch { case d.Intent == IntentFact && !d.Slots.HasKey: return "a fact with no key even after the parser tried" case d.Intent == IntentAct && !d.Slots.HasFn: return "an act that never resolved to an allowlisted fn" case d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text): return "a reminder with no subject to say at the hour" default: return "below the clarify threshold" } } // Reasons a stage-0 grammar did not take a turn. Kept apart because they are // different defects: a pattern that never matched is a rule that does not know // the shape, a Build that declined is a rule that knew the shape and refused // the content (narrative-query and the wakeword acts do this by design), and a // grammar after the winner was never consulted at all. const ( reasonNoMatch = "pattern did not match" reasonBuildDeclmn = "matched the shape, Build declined the content" reasonEarlierClaim = "an earlier grammar claimed the turn" ) // noteGrammarOutcomes records the stage-0 pass. examined is how many grammars // were reached; declined holds the names whose Build said no; won is the winner // or empty. Everything past the winner is named as never asked, because that // silence is the thing the hardcoded order hides. func (r *Router) noteGrammarOutcomes(ctx context.Context, examined int, declined map[int]bool, won string, intent Intent) { rec := decision.From(ctx) if rec == nil { return } for i, g := range r.grammars { switch { case i >= examined: rec.Note(decision.Claim{ Stage: decision.StageZero, Claimant: g.Name, Outcome: decision.NeverAsked, Reason: reasonEarlierClaim, }) case g.Name == won: rec.Note(decision.Scored(decision.StageZero, g.Name, string(intent), 1.0, decision.Won, "")) case declined[i]: rec.Note(decision.Claim{ Stage: decision.StageZero, Claimant: g.Name, Outcome: decision.Declined, Reason: reasonBuildDeclmn, }) default: rec.Note(decision.Claim{ Stage: decision.StageZero, Claimant: g.Name, Outcome: decision.Declined, Reason: reasonNoMatch, }) } } }