Files
Maven/docs/plans/6b-capability-selection-boundary.md
T

154 lines
5.8 KiB
Markdown

# Slice 6b: Capability Selection Boundary
*2026-09-06, from 05f79173*
## 1. CapabilitySelection / selector contract
```go
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`.