router: add provenance tests for ActionResolutionMethod

Pin each path: grammar_fixed, grammar_matcher, extractor_raw,
extractor_llm_text, fallback_matcher. Verify unresolved has empty
ResolvedBy. Verify fn/args remain byte-for-byte identical.
This commit is contained in:
2026-09-06 13:50:22 +04:00
parent 06adc4702d
commit 66f06796cb
+178
View File
@@ -352,3 +352,181 @@ func TestValidateActionCandidate_IssuesPopulatedOnInvalid(t *testing.T) {
t.Error("invalid result has no issues")
}
}
// --- ResolutionMethod provenance tests ---
// TestResolveActionCandidate_GrammarFixed pins that a stage-0 grammar that
// hardcodes fn (praxis/task-status) carries grammar_fixed provenance.
func TestResolveActionCandidate_GrammarFixed(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "resolve_item", HasFn: true,
ResolvedBy: ActionResolutionGrammarFixed,
},
}
c := ResolveActionCandidate(dec, nil)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate")
}
if c.ResolvedBy != ActionResolutionGrammarFixed {
t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy)
}
if c.Fn != "resolve_item" {
t.Errorf("Fn = %q, want resolve_item", c.Fn)
}
}
// TestResolveActionCandidate_GrammarMatcher pins that a stage-0 grammar that
// invokes the ActMatcher (wakeword-act) carries grammar_matcher provenance.
func TestResolveActionCandidate_GrammarMatcher(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
ResolvedBy: ActionResolutionGrammarMatcher,
},
}
c := ResolveActionCandidate(dec, nil)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate")
}
if c.ResolvedBy != ActionResolutionGrammarMatcher {
t.Errorf("ResolvedBy = %q, want grammar_matcher", c.ResolvedBy)
}
}
// TestResolveActionCandidate_ExtractorRaw pins that a classifier/heads-routed
// act whose extractor matched carries extractor_raw provenance.
func TestResolveActionCandidate_ExtractorRaw(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", HasFn: true,
ResolvedBy: ActionResolutionExtractorRaw,
},
Producer: RouteProducerClassifier,
}
c := ResolveActionCandidate(dec, nil)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate")
}
if c.ResolvedBy != ActionResolutionExtractorRaw {
t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy)
}
if c.Producer != RouteProducerClassifier {
t.Errorf("Producer = %q, want classifier", c.Producer)
}
}
// TestResolveActionCandidate_ExtractorLLMText pins that an LLM-routed act
// whose function was resolved from the LLM's cleaned text carries
// extractor_llm_text provenance.
func TestResolveActionCandidate_ExtractorLLMText(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", HasFn: true,
ResolvedBy: ActionResolutionExtractorLLMText,
},
Producer: RouteProducerLLM,
}
c := ResolveActionCandidate(dec, nil)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate")
}
if c.ResolvedBy != ActionResolutionExtractorLLMText {
t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy)
}
if c.Producer != RouteProducerLLM {
t.Errorf("Producer = %q, want llm", c.Producer)
}
}
// TestResolveActionCandidate_FallbackMatcher pins that a matcher fallback
// carry fallback_matcher provenance.
func TestResolveActionCandidate_FallbackMatcher(t *testing.T) {
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
dec := Decision{
Intent: IntentAct,
Slots: Slots{Text: "restart nginx"},
}
c := ResolveActionCandidate(dec, m)
if !c.ActionResolved() {
t.Fatal("expected resolved candidate")
}
if c.ResolvedBy != ActionResolutionFallbackMatcher {
t.Errorf("ResolvedBy = %q, want fallback_matcher", c.ResolvedBy)
}
if c.Source != ActionSourceMatcher {
t.Errorf("Source = %q, want matcher", c.Source)
}
}
// TestResolveActionCandidate_UnresolvedNoFalseMethod pins that an unresolved
// candidate (matcher miss) has empty ResolvedBy.
func TestResolveActionCandidate_UnresolvedNoFalseMethod(t *testing.T) {
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
dec := Decision{
Intent: IntentAct,
Slots: Slots{Text: "deploy the thing"},
}
c := ResolveActionCandidate(dec, m)
if c.ActionResolved() {
t.Fatal("expected unresolved candidate")
}
if c.ResolvedBy != "" {
t.Errorf("ResolvedBy = %q, want empty for unresolved", c.ResolvedBy)
}
}
// TestResolveActionCandidate_PropagatesResolvedBy pins that ResolvedBy
// travels from Slots through to ActionCandidate for every route-sourced case.
func TestResolveActionCandidate_PropagatesResolvedBy(t *testing.T) {
methods := []ActionResolutionMethod{
ActionResolutionGrammarFixed,
ActionResolutionGrammarMatcher,
ActionResolutionExtractorRaw,
ActionResolutionExtractorLLMText,
}
for _, m := range methods {
t.Run(string(m), func(t *testing.T) {
dec := Decision{
Intent: IntentAct,
Slots: Slots{Fn: "restart", HasFn: true, ResolvedBy: m},
}
c := ResolveActionCandidate(dec, nil)
if c.ResolvedBy != m {
t.Errorf("ResolvedBy = %q, want %q", c.ResolvedBy, m)
}
})
}
}
// TestResolveActionCandidate_FnArgsIdentical pins that adding ResolvedBy
// does not change the selected fn or args for any path.
func TestResolveActionCandidate_FnArgsIdentical(t *testing.T) {
// Route-sourced.
dec := Decision{
Intent: IntentAct,
Slots: Slots{
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
ResolvedBy: ActionResolutionGrammarMatcher,
},
}
c := ResolveActionCandidate(dec, nil)
if c.Fn != "restart" || len(c.Args) != 1 || c.Args[0] != "nginx" {
t.Errorf("route fn/args changed: Fn=%q Args=%v", c.Fn, c.Args)
}
// Matcher-sourced.
m := DefaultActMatcher{Fns: []string{"restart"}}
dec2 := Decision{
Intent: IntentAct,
Slots: Slots{Text: "restart nginx"},
}
c2 := ResolveActionCandidate(dec2, m)
if c2.Fn != "restart" || len(c2.Args) != 1 || c2.Args[0] != "nginx" {
t.Errorf("matcher fn/args changed: Fn=%q Args=%v", c2.Fn, c2.Args)
}
}