Files
Maven/internal/router/claim_test.go
T
claude 494c719a5d router: update all Route callers for NormalizedInput + add invariant tests (slice 11)
Migrate 54 test call sites to construct NormalizedInput{Text: ...}.
Add invariant tests:
- TestNormalizedInputReachesRouteIntact: ingress NormalizedInput reaches Route
- TestTryFastPathReceivesMatchText: TryFastPath gets the same input
- TestMatchTextDoesNotChangeRouting: same Text + different MatchText → same Decision
- TestDecisionUtteranceEqualsInputText: Decision.Utterance == input.Text
2026-09-07 01:32:49 +04:00

161 lines
5.0 KiB
Go

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(), NormalizedInput{Text: "напомни полить цветы"}, 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)
}
}