Files
Maven/internal/router/claim_test.go
T
claude 4d94277836 build a claim from a Decision, beside the existing path (V-565)
router.ClaimOf maps a Decision onto the common unit. Stage 0 is anchored,
the LLM path is structural, the classifier is nearest, and anything with a
structural hole is vetoed whoever produced it.

The veto recovers the reason gateLLMDecision throws away. Folding three
named holes into llmThinConfidence leaves 0.3, which says something was
wrong and never which thing, so the same conditions are read here as
sentences a trace can print.

Nothing in Route calls this. Decision.Confidence keeps its float and keeps
working, because r.threshold and gateLLMDecision read it and the classifier
is the failure floor. TestClaimOfLeavesTheDecisionAlone asserts that.
TestONNXBaseline is unchanged at 64/91.
2026-08-06 00:54:55 +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(), "напомни полить цветы", 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)
}
}