494c719a5d
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
161 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|