router: migrate refusesCommand and ActHasEntityTarget to CapabilitySelection (slice 6c)

Migrate the two remaining action-routing consumers from compatibility
Decision.Slings fields to authoritative Decision.CapabilitySelection:

- refusesCommand: reads CapabilitySelection.Fn instead of Slots.Fn
- ActHasEntityTarget: reads CapabilitySelection.Resolved and
  CapabilitySelection.Args instead of Slots.HasFn and Slots.Args

Slots.Text remains the source for entity text when positional args do
not contain the target (unchanged).

Regression tests prove:
- prohibited sentinel preserved byte-for-byte through SelectCapability
- blanked Slots.Fn/Args/HasFn do not affect migrated consumers
- Praxis/Hexis entity-target routing unchanged
- stage-0 deterministic act unchanged
- classifier/extractor act unchanged

do not remove the compatibility mirrors yet.
This commit is contained in:
2026-09-06 21:43:14 +04:00
parent 18900dd613
commit 0a2e194e76
4 changed files with 251 additions and 20 deletions
+1 -1
View File
@@ -45,5 +45,5 @@ func (h *reactiveHandler) resolveCommandProhibition(ctx context.Context, text st
// The sentinel cannot be renamed into an enabled function, and the original
// utterance remains the authority even when a model rewrites Slots.Text.
func refusesCommand(dec router.Decision) bool {
return dec.Slots.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
return dec.CapabilitySelection.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"testing"
"github.com/kami/maven/internal/router"
)
// TestRefusesCommandUsesCapabilitySelection proves that refusesCommand reads
// CapabilitySelection.Fn rather than the compatibility Slots.Fn. When
// CapabilitySelection is populated with the prohibited sentinel but Slots.Fn
// is blank, the refusal must still fire.
func TestRefusesCommandUsesCapabilitySelection(t *testing.T) {
dec := router.Decision{
Intent: router.IntentAct,
CapabilitySelection: router.CapabilitySelection{
Fn: router.ProhibitedActFn,
Resolved: true,
},
// Slots compatibility fields deliberately blank.
}
if !refusesCommand(dec) {
t.Fatal("refusesCommand should return true when CapabilitySelection.Fn == ProhibitedActFn")
}
}
// TestRefusesCommandUtteranceFallback proves that the utterance-based
// prohibition check still works as a defense-in-depth belt when
// CapabilitySelection does not carry the sentinel.
func TestRefusesCommandUtteranceFallback(t *testing.T) {
dec := router.Decision{
Utterance: "don't restart nginx",
}
if !refusesCommand(dec) {
t.Fatal("refusesCommand should return true for a prohibited utterance")
}
}
// TestRefusesCommandNonProhibitedCapability proves that an act with a
// non-prohibited capability is NOT refused even when Slots.Fn happens to
// carry the prohibited sentinel (cross-contamination).
func TestRefusesCommandNonProhibitedCapability(t *testing.T) {
dec := router.Decision{
Intent: router.IntentAct,
Utterance: "restart nginx",
CapabilitySelection: router.CapabilitySelection{
Fn: "restart",
Resolved: true,
},
}
if refusesCommand(dec) {
t.Fatal("refusesCommand should return false for a non-prohibited capability")
}
}
// TestRefusesCommandProhibitedSentinelPreservedThroughPipeline proves that the
// command-prohibition grammar sentinel survives through SelectCapability into
// CapabilitySelection byte-for-byte.
func TestRefusesCommandProhibitedSentinelPreservedThroughPipeline(t *testing.T) {
dec := router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{
Fn: router.ProhibitedActFn,
HasFn: true,
},
}
sel := router.SelectCapability(dec, nil)
dec.CapabilitySelection = sel
if dec.CapabilitySelection.Fn != router.ProhibitedActFn {
t.Errorf("CapabilitySelection.Fn = %q, want %q", dec.CapabilitySelection.Fn, router.ProhibitedActFn)
}
if !dec.CapabilitySelection.Resolved {
t.Error("CapabilitySelection.Resolved should be true")
}
if !refusesCommand(dec) {
t.Fatal("refusesCommand should return true after pipeline preserves the sentinel")
}
}
+4 -4
View File
@@ -14,12 +14,12 @@ func ActHasEntityTarget(decision Decision) bool {
if decision.Intent != IntentAct {
return false
}
if decision.Slots.HasFn {
if len(decision.Slots.Args) > 0 {
return hasNamedEntityToken(decision.Slots.Args)
if decision.CapabilitySelection.Resolved {
if len(decision.CapabilitySelection.Args) > 0 {
return hasNamedEntityToken(decision.CapabilitySelection.Args)
}
// A resident-model act may name the accepted verb and its target in
// Text while leaving Args empty. HasFn establishes that the first token
// Text while leaving Args empty. Resolved establishes that the first token
// is the operation; only a meaningful tail can establish the entity.
tokens := planTokens(decision.Slots.Text)
return len(tokens) > 1 && hasNamedEntityToken(tokens[1:])
+167 -15
View File
@@ -10,37 +10,42 @@ func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
}{
{
name: "matched function and argument",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx",
}},
dec: Decision{Intent: IntentAct,
CapabilitySelection: CapabilitySelection{Fn: "restart", Resolved: true, Args: []string{"nginx"}},
Slots: Slots{Text: "restart nginx"},
},
want: true,
},
{
name: "model function and target text",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "restart", HasFn: true, Text: "перезапусти гитею",
}},
dec: Decision{Intent: IntentAct,
CapabilitySelection: CapabilitySelection{Fn: "restart", Resolved: true},
Slots: Slots{Text: "перезапусти гитею"},
},
want: true,
},
{
name: "matched function alone",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "выключи", HasFn: true, Text: "выключи",
}},
dec: Decision{Intent: IntentAct,
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true},
Slots: Slots{Text: "выключи"},
},
want: false,
},
{
name: "matched function with politeness only",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "выключи", HasFn: true, Args: []string{"пожалуйста"}, Text: "выключи пожалуйста",
}},
dec: Decision{Intent: IntentAct,
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true, Args: []string{"пожалуйста"}},
Slots: Slots{Text: "выключи пожалуйста"},
},
want: false,
},
{
name: "matched function with anaphora only",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "выключи", HasFn: true, Args: []string{"его"}, Text: "выключи его",
}},
dec: Decision{Intent: IntentAct,
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true, Args: []string{"его"}},
Slots: Slots{Text: "выключи его"},
},
want: false,
},
{
@@ -67,3 +72,150 @@ func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
})
}
}
// TestActHasEntityTarget_BlankedSlotsStillWorks proves that ActHasEntityTarget
// reads CapabilitySelection.Resolved and CapabilitySelection.Args, not the
// compatibility Slots.Fn/Args/HasFn. When Slots fields are blank but
// CapabilitySelection is populated, behavior must remain correct.
func TestActHasEntityTarget_BlankedSlotsStillWorks(t *testing.T) {
// Resolved capability with entity in Args, but Slots.Fn/Args/HasFn are blank.
dec := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: "restart",
Resolved: true,
Args: []string{"nginx"},
},
Slots: Slots{Text: "restart nginx"},
}
if !ActHasEntityTarget(dec) {
t.Fatal("should detect entity in CapabilitySelection.Args even with blank Slots")
}
// Resolved capability with entity in Text (no Args), Slots.Fn/HasFn blank.
dec2 := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: "restart",
Resolved: true,
},
Slots: Slots{Text: "перезапусти гитею"},
}
if !ActHasEntityTarget(dec2) {
t.Fatal("should detect entity in Slots.Text via CapabilitySelection.Resolved branch")
}
// Unresolved capability with Slots.Fn set (simulating stale compat state).
// Must NOT enter the resolved branch.
dec3 := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Resolved: false,
},
Slots: Slots{Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx"},
}
if !ActHasEntityTarget(dec3) {
t.Fatal("unresolved capability with stale Slots should still find entity via text fallback")
}
}
// TestActHasEntityTarget_PraxisHexisRoutingUnchanged proves that the Praxis
// and Hexis entity-target routing semantics are unchanged by the migration.
func TestActHasEntityTarget_PraxisHexisRoutingUnchanged(t *testing.T) {
// Praxis act: resolved capability with entity target.
praxis := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: "entity_attention",
Resolved: true,
},
Slots: Slots{Text: "что с muzick"},
}
if !ActHasEntityTarget(praxis) {
t.Fatal("Praxis entity act should have entity target")
}
// Hexis act: unresolved capability, entity in text.
hexis := Decision{
Intent: IntentAct,
Slots: Slots{Text: "перезапусти muzick indexer"},
}
if !ActHasEntityTarget(hexis) {
t.Fatal("Hexis entity act should have entity target")
}
}
// TestActHasEntityTarget_Stage0DeterministicActUnchanged proves that a
// stage-0 grammar-fixed act (like command prohibition) is handled correctly.
func TestActHasEntityTarget_Stage0DeterministicActUnchanged(t *testing.T) {
// Prohibited act: resolved capability, no entity in Args or Text.
// "вот это" — filler particle + demonstrative, no named entity.
prohibited := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: ProhibitedActFn,
Resolved: true,
},
Slots: Slots{Text: "вот это"},
}
if ActHasEntityTarget(prohibited) {
t.Fatal("prohibited act with demonstrative-only tail should not have entity target")
}
// Prohibited act WITH entity text still reports the entity — the function
// checks entity presence, not prohibition status.
prohibitedWithEntity := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: ProhibitedActFn,
Resolved: true,
},
Slots: Slots{Text: "don't restart nginx"},
}
if !ActHasEntityTarget(prohibitedWithEntity) {
t.Fatal("prohibited act with entity text should still report entity target")
}
// Task status act: resolved capability, no entity.
taskStatus := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: TaskStatusFn,
Resolved: true,
},
Slots: Slots{Text: "задачи"},
}
if ActHasEntityTarget(taskStatus) {
t.Fatal("task status act should not have entity target")
}
}
// TestActHasEntityTarget_ClassifierExtractorActUnchanged proves that the
// classifier/extractor act path is unchanged.
func TestActHasEntityTarget_ClassifierExtractorActUnchanged(t *testing.T) {
// Classifier resolved act with entity in Args.
dec := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Fn: "restart",
Resolved: true,
Args: []string{"nginx"},
},
Slots: Slots{Text: "restart nginx"},
}
if !ActHasEntityTarget(dec) {
t.Fatal("classifier resolved act with entity in Args should have entity target")
}
// Classifier unresolved act: entity in text.
dec2 := Decision{
Intent: IntentAct,
CapabilitySelection: CapabilitySelection{
Resolved: false,
},
Slots: Slots{Text: "перезапусти muzick"},
}
if !ActHasEntityTarget(dec2) {
t.Fatal("classifier unresolved act with entity in text should have entity target")
}
}