diff --git a/internal/router/capability.go b/internal/router/capability.go new file mode 100644 index 0000000..1da3856 --- /dev/null +++ b/internal/router/capability.go @@ -0,0 +1,140 @@ +package router + +// CapabilitySelection — the result of the explicit capability-selection stage. +// It says what executable capability matched, separate from what kind of turn +// this is (RouteDecision/Decision) and separate from the downstream action +// artifact (ActionCandidate). +// +// CapabilitySelection is the authoritative record of which exact function was +// selected and by which component. Decision.Slots.Fn/Args/HasFn remain as +// compatibility representations that are populated FROM the selection; later +// action execution must not depend on those compatibility fields. +type CapabilitySelection struct { + // Fn — the resolved function/tool identity. Empty when no capability + // matched the input. + Fn string + + // Args — positional arguments passed to the tool. May be nil when Fn + // is empty or when the match produced no arguments. + Args []string + + // Resolved — whether a capability was matched. Fn may be non-empty + // even when Resolved is false (grammar-fixed paths set Fn without + // going through the general selector). This field distinguishes "the + // selector ran and matched" from "a deterministic path set Fn". + Resolved bool + + // Method — which component actually selected the function. Five + // disjoint values from ActionResolutionMethod; empty when no function + // was resolved. + Method ActionResolutionMethod + + // InputKind — what the selector selected against. Distinguishes the + // raw utterance from LLM-cleaned text, so the observability trace + // can name the exact input the matcher saw. + InputKind SelectionInputKind + + // Producer — which cascade stage produced the routing decision that + // led here. Carried for observability; not used for dispatch. + Producer RouteProducer + + // Confidence — the routing confidence from the decision. Carried for + // observability; not used for dispatch. + Confidence float64 +} + +// SelectionInputKind — what the selector selected against. Three disjoint +// values. +type SelectionInputKind string + +const ( + // SelectionRaw — the selector matched against the original raw + // utterance from the user. + SelectionRaw SelectionInputKind = "raw" + + // SelectionLLMText — the selector matched against LLM-normalized or + // cleaned action text from Slots.Text, which may differ from the raw + // utterance. + SelectionLLMText SelectionInputKind = "llm_text" + + // SelectionDeterministic — the selector was bypassed because a + // deterministic grammar already resolved the function. The selector + // did not run; this records that the bypass happened. + SelectionDeterministic SelectionInputKind = "deterministic" +) + +// applyCapabilityToSlots propagates the CapabilitySelection result into the +// Decision.Slots compatibility fields. This preserves backward compatibility +// for code that still reads Slots.Fn/Args/HasFn, while CapabilitySelection +// remains the authoritative record. Later action execution must read the +// candidate produced from CapabilitySelection, not these compatibility fields. +func applyCapabilityToSlots(dec *Decision, sel CapabilitySelection) { + dec.CapabilitySelection = sel + if sel.Resolved { + dec.Slots.Fn = sel.Fn + dec.Slots.Args = sel.Args + dec.Slots.HasFn = true + dec.Slots.ResolvedBy = sel.Method + } +} + +// SelectCapability is the explicit capability-selection stage. It sits between +// route resolution and action candidate production, answering: which exact +// executable capability matched this turn? +// +// Resolution order: +// 1. HasFn already set (grammar-fixed or extractor raw): bypass the general +// selector. CapabilitySelection records the existing result with +// InputKind=SelectionDeterministic. +// 2. Raw utterance not matching the allowlist, but LLM cleaned text available: +// try Acts.Match(LLM text). This is the extractor_llm_text path. +// 3. No match on either input: unresolved. +// +// The matcher algorithm, enabled-tool set, alias behavior, fuzzy-prefix +// behavior, and ordering are all unchanged — SelectCapability delegates to +// the same Acts.Match call that Extract and ResolveActionCandidate always used. +func SelectCapability(dec Decision, m ActMatcher) CapabilitySelection { + // Non-act intents have no capability to select. + if dec.Intent != IntentAct { + return CapabilitySelection{ + Producer: dec.Producer, + Confidence: dec.Confidence, + } + } + + // Deterministic path: a grammar or the raw extractor already resolved + // the function. The general selector does not re-run. + if dec.Slots.HasFn { + return CapabilitySelection{ + Fn: dec.Slots.Fn, + Args: dec.Slots.Args, + Resolved: true, + Method: dec.Slots.ResolvedBy, + InputKind: SelectionDeterministic, + Producer: dec.Producer, + Confidence: dec.Confidence, + } + } + + // General path: the raw utterance did not match. Try the LLM-cleaned + // text when it differs from the raw utterance. + if m != nil && dec.Slots.Text != "" && dec.Slots.Text != dec.Utterance { + if fn, args, ok := m.Match(dec.Slots.Text); ok { + return CapabilitySelection{ + Fn: fn, + Args: args, + Resolved: true, + Method: ActionResolutionExtractorLLMText, + InputKind: SelectionLLMText, + Producer: dec.Producer, + Confidence: dec.Confidence, + } + } + } + + // Unresolved: no capability matched on any input. + return CapabilitySelection{ + Producer: dec.Producer, + Confidence: dec.Confidence, + } +} diff --git a/internal/router/capability_test.go b/internal/router/capability_test.go new file mode 100644 index 0000000..5d30a5c --- /dev/null +++ b/internal/router/capability_test.go @@ -0,0 +1,396 @@ +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 + } +}