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

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.