package router import ( "context" "reflect" "testing" "time" "github.com/kami/maven/internal/claim" ) func TestClaimOfBands(t *testing.T) { cases := []struct { name string dec Decision want claim.Band }{ { name: "stage 0 is anchored", dec: Decision{ Utterance: "сколько времени", Stage: 0, Intent: IntentSystem, Confidence: 1.0, Slots: Slots{Text: "сколько времени"}, }, want: claim.BandAnchored, }, { name: "the llm path is structural", dec: Decision{ Utterance: "выпил воды", Stage: 1, Intent: IntentFact, Confidence: llmFullConfidence, Slots: Slots{Key: "water", Value: "1", HasKey: true, Text: "выпил воды"}, }, want: claim.BandStructural, }, { name: "the classifier is nearest", dec: Decision{ Utterance: "что нового", Stage: 2, Intent: IntentQuery, Confidence: 0.91, Slots: Slots{Text: "что нового"}, }, want: claim.BandNearest, }, { name: "a structural hole vetoes whoever found it", dec: Decision{ Utterance: "запиши", Stage: 1, Intent: IntentFact, Confidence: llmThinConfidence, Slots: Slots{Text: "запиши"}, }, want: claim.BandVetoed, }, { name: "clarify vetoes a classifier decision", dec: Decision{ Utterance: "сделай это", Stage: 3, Intent: IntentNote, Confidence: 0.2, Clarify: true, Slots: Slots{Text: "сделай это"}, }, want: claim.BandVetoed, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := ClaimOf("test", tc.dec) if got.Band != tc.want { t.Errorf("band = %v, want %v (veto %q)", got.Band, tc.want, got.Veto) } }) } } // The veto has to name the hole. Folding all three arms into llmThinConfidence // is what lost the reason, and 0.3 tells a reader that something was wrong but // never which thing. func TestClaimOfVetoNamesTheHole(t *testing.T) { cases := []struct { name string dec Decision want string }{ {"keyless fact", Decision{Intent: IntentFact}, "fact with no key"}, {"act with no fn", Decision{Intent: IntentAct}, "act with no allowlisted fn"}, {"subjectless reminder", Decision{Intent: IntentReminder, Slots: Slots{Text: "напомни"}}, "reminder with no subject"}, {"below the gate", Decision{Intent: IntentQuery, Clarify: true}, "below the confidence gate"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := ClaimOf("test", tc.dec) if !got.Vetoed() { t.Fatalf("no veto, want one about %q", tc.want) } if len(got.Veto) < len(tc.want) || got.Veto[:len(tc.want)] != tc.want { t.Errorf("veto = %q, want it to start with %q", got.Veto, tc.want) } }) } } // A route with every slot filled must NOT be vetoed. The three arms are // structural holes, not a tax on every decision. func TestClaimOfCompleteRouteIsNotVetoed(t *testing.T) { d := Decision{ Utterance: "напомни позвонить маме в семь", Stage: 1, Intent: IntentReminder, Confidence: llmFullConfidence, Slots: Slots{Text: "позвонить маме", Time: time.Now(), HasTime: true}, } c := ClaimOf("llm", d) if c.Vetoed() { t.Errorf("complete reminder vetoed: %q", c.Veto) } if c.Band != claim.BandStructural { t.Errorf("band = %v, want structural", c.Band) } } func TestClaimOfCoverageAndFilledSlots(t *testing.T) { d := Decision{ Utterance: "напомни позвонить маме", Stage: 0, Intent: IntentReminder, Confidence: 1.0, Slots: Slots{Text: "позвонить маме"}, } c := ClaimOf("reminder-wakeword", d) // The grammar captures what follows the verb, so "напомни" itself is // unexplained. Under-reporting is the safe direction. if len(c.Consumed) != 2 || len(c.Unexplained) != 1 { t.Errorf("consumed %q / unexplained %q, want 2 and 1", c.Consumed, c.Unexplained) } if got := c.Coverage(); got < 0.66 || got > 0.67 { t.Errorf("coverage = %v, want about 2/3", got) } if c.Intent != string(IntentReminder) { t.Errorf("intent = %q", c.Intent) } if len(c.Filled) != 1 || c.Filled[0] != "text" { t.Errorf("filled = %q, want [text]", c.Filled) } if c.Claimant != "reminder-wakeword" { t.Errorf("claimant = %q", c.Claimant) } } // The point of V-565's "additive" constraint, asserted rather than trusted: // building a claim reads a Decision and changes nothing about it, so the // classifier floor and the two consumers of Confidence are untouched. func TestClaimOfLeavesTheDecisionAlone(t *testing.T) { r := New(Config{ Grammars: []Grammar{ReminderGrammar()}, Extractor: Extractor{}, Threshold: 0.55, }) before, err := r.Route(context.Background(), "напомни полить цветы", time.Now()) if err != nil { t.Fatalf("Route: %v", err) } after := before _ = ClaimOf("reminder-wakeword", after) if !reflect.DeepEqual(after, before) { t.Errorf("ClaimOf mutated the decision: %+v vs %+v", after, before) } if before.Confidence != 1.0 { t.Errorf("stage 0 confidence = %v, want 1.0 — the float still has to work", before.Confidence) } }