5.8 KiB
Slice 6b: Capability Selection Boundary
2026-09-06, from 05f79173
1. CapabilitySelection / selector contract
type CapabilitySelection struct {
Fn string
Args []string
Resolved bool
Method ActionResolutionMethod
InputKind SelectionInputKind
Producer RouteProducer
Confidence float64
}
type SelectionInputKind string
const (
SelectionRaw SelectionInputKind = "raw"
SelectionLLMText SelectionInputKind = "llm_text"
SelectionDeterministic SelectionInputKind = "deterministic"
)
func SelectCapability(dec Decision, m ActMatcher) CapabilitySelection
func applyCapabilityToSlots(dec *Decision, sel CapabilitySelection)
SelectCapability is the single entry point for capability selection. It sits
between route resolution and action candidate production.
applyCapabilityToSlots propagates the selection into Decision.Slots.Fn/Args/HasFn
for backward compatibility. CapabilitySelection is the authoritative record.
2. Where the selector lives
internal/router/capability.go — new file, 115 lines.
Called from Router.Route in internal/router/router.go after each cascade path:
- Stage-0 grammar path (line ~108)
- Stage-0b heads path (line ~148)
- Stage-1a LLM path (line ~190)
- Stage-1 classifier path (line ~250)
3. Old vs new ownership
| Concern | Before | After |
|---|---|---|
| Raw capability extraction | Extractor.Extract(IntentAct) inside fillMatchedSlots |
Same extractor, but CapabilitySelection is the authoritative record |
| LLM text capability backfill | Embedded in fillSlots (lines 319-324) |
Moved to SelectCapability |
| Fallback matcher | ResolveActionCandidate (lines 160-171) |
Same location, unchanged |
| Resolution method tracking | Slots.ResolvedBy only |
CapabilitySelection.Method (authoritative), Slots.ResolvedBy (compatibility) |
4. Compatibility fields
Decision.Slots.Fn/Args/HasFn/ResolvedBy are still populated by
applyCapabilityToSlots from the CapabilitySelection result. They exist for
backward compatibility with code that reads slots directly (tests, rebuilt
decisions). The ownership distinction is documented on the Decision struct.
5. Before/after flow
Before:
Route → Decision (Slots.Fn set by extractor/fillSlots)
↓
resolveAction → ResolveActionCandidate(dec, matcher)
→ if Slots.HasFn: candidate from slots
→ else: fallback matcher
After:
Route → Decision
↓
fillMatchedSlots (time, key extraction)
↓
SelectCapability(dec, matcher) → CapabilitySelection
↓
applyCapabilityToSlots(dec, sel) → populates Slots.Fn/Args/HasFn for compat
↓
resolveAction → ResolveActionCandidate(dec, matcher)
→ if CapabilitySelection.Resolved: candidate from selection
→ else if Slots.HasFn: backward compat path
→ else: fallback matcher
6. Fixture matrix before/after
Routing fixture (11 act cases):
grammar_fixed=2, grammar_matcher=2, extractor_raw=4, extractor_llm_text=0, fallback_matcher=0
resolved: 8, unresolved: 3
Ecosystem fixture (24 act cases):
grammar_fixed=12, grammar_matcher=0, extractor_raw=11, extractor_llm_text=0, fallback_matcher=0
resolved: 23, unresolved: 1
Identical before and after. Zero distribution change.
7. Fallback matcher usage
The fallback matcher in ResolveActionCandidate is unchanged. It still runs
when CapabilitySelection.Resolved == false and Slots.HasFn == false. The
fallback matcher fires zero times in the eval fixture, consistent with previous
measurements.
8. Tests
New test file: internal/router/capability_test.go — 14 tests.
| Test | What it pins |
|---|---|
TestSelectCapability_DeterministicBypass |
Grammar-fixed acts bypass selector |
TestSelectCapability_ExtractorRawBypass |
Extractor-raw acts bypass selector |
TestSelectCapability_GrammarMatcherBypass |
Grammar-matcher acts bypass selector |
TestSelectCapability_LLMTextMatch |
LLM cleaned text resolves when raw misses |
TestSelectCapability_LLMTextSameAsUtterance |
No double-match when Text == Utterance |
TestSelectCapability_Unresolved |
No match on any input |
TestSelectCapability_NonActIntent |
Non-act returns empty selection |
TestSelectCapability_NilMatcher |
Nil matcher does not panic |
TestApplyCapabilityToSlots_PopulatesCompatibilityFields |
Compat fields populated from selection |
TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots |
Unresolved selection does not overwrite slots |
TestResolveActionCandidate_CapabilitySelectionSource |
Selection produces route-sourced candidate |
TestResolveActionCandidate_CapabilitySelectionOverSlots |
CapabilitySelection takes precedence over Slots.HasFn |
TestResolveActionCandidate_BackwardCompatSlotsHasFn |
Decisions with HasFn but no CapabilitySelection still work |
TestRouterRoute_CapabilitySelectionPopulated |
Router.Route sets CapabilitySelection on Decision |
TestSelectionInputKindConstants |
Three input kind constants are distinct |
Modified tests: actioncandidate_test.go — 8 tests updated to set
CapabilitySelection alongside Slots.HasFn.
9. Fn/Args result confirmation
Every Fn and Args value is unchanged:
- Routing fixture: identical distribution (8 resolved, 3 unresolved)
- Ecosystem fixture: identical distribution (23 resolved, 1 unresolved)
- All 57 router tests pass
- All cmd/mavend tests pass
- All eval fixture tests pass
10. Newly exposed architectural problems
None. The extraction is clean and mechanical. The backward compatibility
path in ResolveActionCandidate (checking Slots.HasFn when CapabilitySelection
is not set) is a temporary bridge that should be removed when all call sites
produce decisions through the router.
11. Commit hash
Pending commit on branch task/slice-6b-capability-selection.