Files
Maven/internal/router/capability_test.go
T
claude a6a62a2d56 router: introduce CapabilitySelection type and SelectCapability stage (slice 6b)
Add the explicit capability-selection boundary between route resolution
and action candidate production. SelectCapability is the single entry
point for selecting which executable capability matched an IntentAct turn.

Three input kinds: raw, llm_text, deterministic. Decision.CapabilitySelection
is the authoritative record; Decision.Slots.Fn/Args/HasFn remain as
compatibility representations populated from the selection.
2026-09-06 21:01:59 +04:00

397 lines
12 KiB
Go

package router
import (
"testing"
)
// --- SelectCapability tests ---
// TestSelectCapability_DeterministicBypass pins that a grammar-fixed act
// bypasses the general selector and records the existing result.
func TestSelectCapability_DeterministicBypass(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "resolve_item", HasFn: true,
ResolvedBy: ActionResolutionGrammarFixed,
},
Producer: RouteProducerGrammar,
Confidence: 1.0,
}
sel := SelectCapability(dec, nil)
if !sel.Resolved {
t.Fatal("expected resolved")
}
if sel.Fn != "resolve_item" {
t.Errorf("Fn = %q, want resolve_item", sel.Fn)
}
if sel.Method != ActionResolutionGrammarFixed {
t.Errorf("Method = %q, want grammar_fixed", sel.Method)
}
if sel.InputKind != SelectionDeterministic {
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
}
if sel.Producer != RouteProducerGrammar {
t.Errorf("Producer = %q, want grammar", sel.Producer)
}
if sel.Confidence != 1.0 {
t.Errorf("Confidence = %f, want 1.0", sel.Confidence)
}
}
// TestSelectCapability_ExtractorRawBypass pins that an extractor-raw act
// bypasses the general selector.
func TestSelectCapability_ExtractorRawBypass(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
ResolvedBy: ActionResolutionExtractorRaw,
},
Producer: RouteProducerClassifier,
}
sel := SelectCapability(dec, nil)
if !sel.Resolved {
t.Fatal("expected resolved")
}
if sel.Fn != "restart" {
t.Errorf("Fn = %q, want restart", sel.Fn)
}
if sel.Method != ActionResolutionExtractorRaw {
t.Errorf("Method = %q, want extractor_raw", sel.Method)
}
if sel.InputKind != SelectionDeterministic {
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
}
}
// TestSelectCapability_GrammarMatcherBypass pins that a grammar-matcher act
// (wakeword-act) bypasses the general selector.
func TestSelectCapability_GrammarMatcherBypass(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
ResolvedBy: ActionResolutionGrammarMatcher,
},
Producer: RouteProducerGrammar,
}
sel := SelectCapability(dec, nil)
if !sel.Resolved {
t.Fatal("expected resolved")
}
if sel.Method != ActionResolutionGrammarMatcher {
t.Errorf("Method = %q, want grammar_matcher", sel.Method)
}
if sel.InputKind != SelectionDeterministic {
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
}
}
// TestSelectCapability_LLMTextMatch pins that when the raw utterance did not
// match but LLM cleaned text does, the selector resolves from LLM text.
func TestSelectCapability_LLMTextMatch(t *testing.T) {
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
dec := Decision{
Intent: IntentAct,
Utterance: "maven could you restart nginx",
Slots: Slots{
Text: "restart nginx",
},
Producer: RouteProducerLLM,
}
sel := SelectCapability(dec, m)
if !sel.Resolved {
t.Fatal("expected resolved from LLM text")
}
if sel.Fn != "restart" {
t.Errorf("Fn = %q, want restart", sel.Fn)
}
if len(sel.Args) != 1 || sel.Args[0] != "nginx" {
t.Errorf("Args = %v, want [nginx]", sel.Args)
}
if sel.Method != ActionResolutionExtractorLLMText {
t.Errorf("Method = %q, want extractor_llm_text", sel.Method)
}
if sel.InputKind != SelectionLLMText {
t.Errorf("InputKind = %q, want llm_text", sel.InputKind)
}
}
// TestSelectCapability_LLMTextSameAsUtterance pins that when Slots.Text equals
// the utterance, the selector does NOT try LLM text (no second attempt).
func TestSelectCapability_LLMTextSameAsUtterance(t *testing.T) {
m := DefaultActMatcher{Fns: []string{"restart"}}
dec := Decision{
Intent: IntentAct,
Utterance: "restart nginx",
Slots: Slots{Text: "restart nginx"},
Producer: RouteProducerClassifier,
}
sel := SelectCapability(dec, m)
// Text == Utterance means no LLM cleaned text; raw match should have
// been done by the extractor. Since HasFn is false, selector sees no
// LLM text to try.
if sel.Resolved {
t.Fatal("expected unresolved when Text == Utterance and no HasFn")
}
}
// TestSelectCapability_Unresolved pins that when neither the grammar/extractor
// nor the LLM text matches, the selection is unresolved.
func TestSelectCapability_Unresolved(t *testing.T) {
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
dec := Decision{
Intent: IntentAct,
Utterance: "deploy the thing",
Slots: Slots{Text: "deploy the thing"},
Producer: RouteProducerClassifier,
}
sel := SelectCapability(dec, m)
if sel.Resolved {
t.Fatal("expected unresolved")
}
if sel.Fn != "" {
t.Errorf("Fn = %q, want empty", sel.Fn)
}
}
// TestSelectCapability_NonActIntent pins that a non-act intent returns an
// empty selection.
func TestSelectCapability_NonActIntent(t *testing.T) {
dec := Decision{
Intent: IntentFact,
Slots: Slots{Key: "water", HasKey: true},
}
sel := SelectCapability(dec, nil)
if sel.Resolved {
t.Fatal("expected unresolved for non-act")
}
}
// TestSelectCapability_NilMatcher pins that a nil matcher does not panic
// and produces an unresolved selection when no grammar matched.
func TestSelectCapability_NilMatcher(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Utterance: "restart nginx",
Slots: Slots{Text: "restart nginx"},
Producer: RouteProducerClassifier,
}
sel := SelectCapability(dec, nil)
if sel.Resolved {
t.Fatal("expected unresolved with nil matcher")
}
}
// --- applyCapabilityToSlots tests ---
// TestApplyCapabilityToSlots_PopulatesCompatibilityFields pins that the
// compatibility fields on Decision.Slots are populated from the selection.
func TestApplyCapabilityToSlots_PopulatesCompatibilityFields(t *testing.T) {
dec := Decision{}
sel := CapabilitySelection{
Fn: "restart",
Args: []string{"nginx"},
Resolved: true,
Method: ActionResolutionExtractorRaw,
InputKind: SelectionDeterministic,
Producer: RouteProducerClassifier,
Confidence: 0.85,
}
applyCapabilityToSlots(&dec, sel)
if dec.CapabilitySelection.Fn != "restart" {
t.Errorf("CapabilitySelection.Fn = %q, want restart", dec.CapabilitySelection.Fn)
}
if !dec.Slots.HasFn {
t.Error("Slots.HasFn should be true")
}
if dec.Slots.Fn != "restart" {
t.Errorf("Slots.Fn = %q, want restart", dec.Slots.Fn)
}
if len(dec.Slots.Args) != 1 || dec.Slots.Args[0] != "nginx" {
t.Errorf("Slots.Args = %v, want [nginx]", dec.Slots.Args)
}
if dec.Slots.ResolvedBy != ActionResolutionExtractorRaw {
t.Errorf("Slots.ResolvedBy = %q, want extractor_raw", dec.Slots.ResolvedBy)
}
}
// TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots pins that an unresolved
// selection does not populate the compatibility fields.
func TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots(t *testing.T) {
dec := Decision{Slots: Slots{Fn: "old", HasFn: true}}
sel := CapabilitySelection{
Resolved: false,
Producer: RouteProducerClassifier,
}
applyCapabilityToSlots(&dec, sel)
if dec.CapabilitySelection.Resolved {
t.Error("CapabilitySelection.Resolved should be false")
}
// Compatibility fields should remain unchanged.
if dec.Slots.Fn != "old" {
t.Errorf("Slots.Fn = %q, want old (unchanged)", dec.Slots.Fn)
}
if !dec.Slots.HasFn {
t.Error("Slots.HasFn should still be true")
}
}
// --- ResolveActionCandidate from CapabilitySelection tests ---
// TestResolveActionCandidate_CapabilitySelectionSource pins that a resolved
// CapabilitySelection produces a route-sourced candidate.
func TestResolveActionCandidate_CapabilitySelectionSource(t *testing.T) {
dec := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
Method: ActionResolutionExtractorRaw,
},
Producer: RouteProducerClassifier,
}
c := ResolveActionCandidate(dec, nil)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate")
}
if c.Fn != "restart" {
t.Errorf("Fn = %q, want restart", c.Fn)
}
if c.Source != ActionSourceRoute {
t.Errorf("Source = %q, want route", c.Source)
}
if c.ResolvedBy != ActionResolutionExtractorRaw {
t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy)
}
}
// TestResolveActionCandidate_CapabilitySelectionOverSlots pins that
// CapabilitySelection takes precedence over Slots.HasFn when both are set.
func TestResolveActionCandidate_CapabilitySelectionOverSlots(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "old_fn", HasFn: true,
ResolvedBy: ActionResolutionExtractorRaw,
},
CapabilitySelection: CapabilitySelection{
Fn: "new_fn", Resolved: true,
Method: ActionResolutionExtractorLLMText,
},
}
c := ResolveActionCandidate(dec, nil)
if c.Fn != "new_fn" {
t.Errorf("Fn = %q, want new_fn (CapabilitySelection wins)", c.Fn)
}
if c.ResolvedBy != ActionResolutionExtractorLLMText {
t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy)
}
}
// TestResolveActionCandidate_BackwardCompatSlotsHasFn pins that decisions
// with Slots.HasFn but no CapabilitySelection still work (backward compat).
func TestResolveActionCandidate_BackwardCompatSlotsHasFn(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
ResolvedBy: ActionResolutionGrammarFixed,
},
}
c := ResolveActionCandidate(dec, nil)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate from backward compat")
}
if c.Fn != "restart" {
t.Errorf("Fn = %q, want restart", c.Fn)
}
if c.ResolvedBy != ActionResolutionGrammarFixed {
t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy)
}
}
// --- Router integration: CapabilitySelection populated by Route ---
// TestRouterRoute_CapabilitySelectionPopulated pins that Router.Route sets
// CapabilitySelection on the returned Decision for each cascade path.
func TestRouterRoute_CapabilitySelectionPopulated(t *testing.T) {
r := newTestRouter(t, 0.3)
// Stage-0 grammar path: wakeword-act.
d, err := r.Route(t.Context(), "maven restart nginx", refNow())
if err != nil {
t.Fatal(err)
}
if !d.CapabilitySelection.Resolved {
t.Error("stage-0: CapabilitySelection not resolved")
}
if d.CapabilitySelection.Fn != "restart" {
t.Errorf("stage-0: Fn = %q, want restart", d.CapabilitySelection.Fn)
}
if d.CapabilitySelection.InputKind != SelectionDeterministic {
t.Errorf("stage-0: InputKind = %q, want deterministic", d.CapabilitySelection.InputKind)
}
// Classifier path: raw utterance matches allowlist.
d, err = r.Route(t.Context(), "restart nginx", refNow())
if err != nil {
t.Fatal(err)
}
if d.Intent != IntentAct {
t.Skipf("classifier routed to %q, not act", d.Intent)
}
if !d.CapabilitySelection.Resolved {
t.Error("classifier: CapabilitySelection not resolved")
}
if d.CapabilitySelection.Fn != "restart" {
t.Errorf("classifier: Fn = %q, want restart", d.CapabilitySelection.Fn)
}
}
// TestRouterRoute_ClassifierUnresolvedAct pins that an act intent where the
// raw utterance does not match the allowlist has an unresolved selection.
func TestRouterRoute_ClassifierUnresolvedAct(t *testing.T) {
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
emb := NewHashEmbedder(1024)
c := NewClassifier(emb)
seedClassifier(t, c)
ex := Extractor{Acts: m}
r := New(Config{
Classifier: c,
Extractor: ex,
Threshold: 0.3,
})
d, err := r.Route(t.Context(), "deploy the thing", refNow())
if err != nil {
t.Fatal(err)
}
if d.Intent != IntentAct {
t.Skipf("classifier routed to %q, not act", d.Intent)
}
if d.CapabilitySelection.Resolved {
t.Error("expected unresolved selection for non-matching utterance")
}
}
// --- SelectionInputKind constants ---
// TestSelectionInputKindConstants pins that the three input kind constants
// are distinct and non-empty.
func TestSelectionInputKindConstants(t *testing.T) {
kinds := []SelectionInputKind{SelectionRaw, SelectionLLMText, SelectionDeterministic}
seen := make(map[SelectionInputKind]bool)
for _, k := range kinds {
if k == "" {
t.Error("SelectionInputKind constant is empty")
}
if seen[k] {
t.Errorf("SelectionInputKind %q appears twice", k)
}
seen[k] = true
}
}