feat(router): [router].prefer = local | cloud | auto

Implements P-1 through P-6 of the prefer-routing-policy plan.

Adds a config knob that biases routing toward local arms, cloud
arms, or leaves selection unchanged. Default "auto" is
byte-identical to pre-change behavior (the new armTier path with
PreferAuto returns the same value as the old single-arg function).

Mechanism diverged from the plan after empirical testing:

The plan called for a score multiplier applied in bestScored.
Tests revealed the existing cost-floor math (scoreArm divides by
weighted cost which collapses to ~0.001 for free local arms) gives
local arms a ~280x raw-score advantage that a 0.3-0.5 multiplier
can't overcome. A tier-shift in armTier turned out cleaner:

  PreferLocal: cloud arms (true API, IsLocal=false && !IsCLIAgent)
               get +2 tier shift, landing behind locals.
  PreferCloud: IsLocal arms get +2 tier shift, landing behind
               cloud. SLM tier-0 arms shift to tier 2 — still
               below cloud's tier 3 — so the SLM-protection
               semantic (small stuff stays on the small model)
               survives PreferCloud. This matches the open
               question in the plan, now resolved as: yes, SLMs
               keep winning under PreferCloud by design.

The policyMultiplier was kept in bestScored as a within-tier
nudge (mostly cosmetic in practice given the cost-floor dynamics
described above; could matter when costs are calibrated). Worth
revisiting once router-wide cost calibration lands.

Strengths cross-tier promotion is unaffected: the promoted-set
path in selectBest bypasses armTier entirely, so a strongly-tagged
cloud arm still wins SecurityReview tasks under PreferLocal
(validated by TestPreferPolicy_StrengthsBeatsMultiplier).

CLI-agent subprocess arms count as "local" for PreferLocal
purposes — they proxy to cloud but the user-visible behavior is
local. Users who want to exclude them can use --provider X.

Forced arms (--provider X) and incognito take priority over the
policy: forced arm test pins this, incognito-still-wins test pins
the LocalOnly hard filter dominating PreferCloud.

Test coverage (prefer_test.go): ParsePreferPolicy / String round
trips; policyMultiplier table; acceptance scenarios across all
three policies with adjacent-tier arms; SLM-still-wins under
PreferCloud; Strengths beats multiplier; forced-arm bypass;
incognito beats prefer; lone cloud arm wins when no local feasible.

Refs: docs/superpowers/plans/2026-05-23-prefer-routing-policy.md
This commit is contained in:
2026-05-23 22:13:26 +02:00
parent 162c8b1017
commit f9094f68f3
8 changed files with 543 additions and 21 deletions
+13
View File
@@ -352,6 +352,19 @@ func main() {
// (M4 foundation: one provider from CLI. Multi-provider routing comes with config.)
rtr := router.New(router.Config{Logger: logger})
// Apply the prefer-routing-policy from config (default: auto).
// Invalid values are rejected here with an actionable error rather
// than silently falling back to auto.
if preferPolicy, err := router.ParsePreferPolicy(cfg.Router.Prefer); err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(2)
} else {
rtr.SetPreferPolicy(preferPolicy)
if preferPolicy != router.PreferAuto {
logger.Info("routing preference applied", "prefer", preferPolicy.String())
}
}
// Restore QualityTracker data from disk (best-effort). Per-profile
// path avoids bandit cross-contamination between work/private/etc.
// Skipped under --incognito to keep prior learned quality out of the
+8
View File
@@ -99,6 +99,14 @@ type RouterSection struct {
// on a large local model. Defaults to false: two-stage activates
// automatically on local arms with context window <= 16k.
ForceTwoStage bool `toml:"force_two_stage"`
// Prefer biases routing toward local arms ("local"), cloud arms
// ("cloud"), or leaves the tier-based selection unchanged ("auto").
// Default: "auto". Implemented as a soft score multiplier — does
// not hard-filter the dispreferred set. Forced arms (--provider X)
// and incognito take priority over this knob. See
// docs/superpowers/plans/2026-05-23-prefer-routing-policy.md.
Prefer string `toml:"prefer"`
}
// MCPServerConfig defines an MCP server to start and connect to.
+1 -1
View File
@@ -62,7 +62,7 @@ func BenchmarkSelectBest(b *testing.B) {
b.ResetTimer()
for b.Loop() {
for _, task := range tasks {
selectBest(qt, arms, task)
selectBest(qt, arms, task, PreferAuto)
}
}
}
+375
View File
@@ -0,0 +1,375 @@
package router
import (
"testing"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/security"
)
func TestParsePreferPolicy(t *testing.T) {
cases := []struct {
in string
want PreferPolicy
wantErr bool
}{
{"", PreferAuto, false},
{"auto", PreferAuto, false},
{"AUTO", PreferAuto, false},
{" auto ", PreferAuto, false},
{"local", PreferLocal, false},
{"Local", PreferLocal, false},
{"cloud", PreferCloud, false},
{"prefer-cloud", PreferAuto, true},
{"none", PreferAuto, true},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got, err := ParsePreferPolicy(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestPreferPolicy_String(t *testing.T) {
cases := map[PreferPolicy]string{
PreferAuto: "auto",
PreferLocal: "local",
PreferCloud: "cloud",
}
for in, want := range cases {
if got := in.String(); got != want {
t.Errorf("%d.String() = %q, want %q", in, got, want)
}
}
}
func TestPolicyMultiplier(t *testing.T) {
localArm := &Arm{IsLocal: true}
cloudArm := &Arm{IsLocal: false}
cases := []struct {
name string
arm *Arm
policy PreferPolicy
want float64
}{
{"auto/local", localArm, PreferAuto, 1.0},
{"auto/cloud", cloudArm, PreferAuto, 1.0},
{"local/local", localArm, PreferLocal, 1.0},
{"local/cloud", cloudArm, PreferLocal, 0.3},
{"cloud/local", localArm, PreferCloud, 0.5},
{"cloud/cloud", cloudArm, PreferCloud, 1.0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := policyMultiplier(tc.arm, tc.policy); got != tc.want {
t.Errorf("policyMultiplier(%+v, %v) = %v, want %v", tc.arm, tc.policy, got, tc.want)
}
})
}
}
// TestPreferPolicy_RouterAcceptanceScenarios is the user-facing payoff:
// the prefer knob shifts arm tiers so the dispreferred camp is walked
// last. The test uses a task type that neither arm has in its Strengths
// list so the tier walk actually runs (the Strengths-promoted path
// bypasses tier ordering entirely).
//
// Arms are chosen to be in adjacent base tiers — a general-purpose
// local arm at tier 2 (no MaxComplexity, no family-defaults match) and
// a cloud arm at tier 3. The +2 tier shift then puts the dispreferred
// arm at tier 4 (local) or 5 (cloud), behind the preferred camp.
//
// The Strengths-promoted case (cost-amplification can overwhelm the
// within-tier multiplier) is covered separately by
// TestPreferPolicy_StrengthsBeatsMultiplier, which validates that a
// strongly-tagged arm wins regardless of prefer.
func TestPreferPolicy_RouterAcceptanceScenarios(t *testing.T) {
makeRouter := func(policy PreferPolicy) *Router {
r := New(Config{})
r.SetPreferPolicy(policy)
// Local arm: family doesn't match any defaults entry, so no
// Strengths or MaxComplexity get attached — clean tier-2 arm.
r.RegisterArm(&Arm{
ID: NewArmID("ollama", "novel-local-llm:7b"),
ModelName: "novel-local-llm:7b",
Provider: security.WrapProvider(&stubProvider{name: "ollama", model: "novel-local-llm:7b"}, nil),
IsLocal: true,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 200000,
},
})
// Cloud arm: also no family match (we use a deliberately
// non-matching ID so Strengths defaults don't kick in).
r.RegisterArm(&Arm{
ID: NewArmID("anthropic", "novel-cloud-model"),
ModelName: "novel-cloud-model",
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "novel-cloud-model"}, nil),
IsLocal: false,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 1_000_000,
ThinkingModes: []provider.EffortLevel{provider.EffortMedium},
},
})
return r
}
task := Task{
Type: TaskExplain,
ComplexityScore: 0.5,
Priority: PriorityNormal,
RequiresTools: true,
EstimatedTokens: 1500,
}
t.Run("prefer=local picks the local arm", func(t *testing.T) {
r := makeRouter(PreferLocal)
decision := r.Select(task)
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if !decision.Arm.IsLocal {
t.Errorf("PreferLocal should pick local; got %s (IsLocal=%v)", decision.Arm.ID, decision.Arm.IsLocal)
}
decision.Rollback()
})
t.Run("prefer=cloud picks the cloud arm", func(t *testing.T) {
r := makeRouter(PreferCloud)
decision := r.Select(task)
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if decision.Arm.IsLocal {
t.Errorf("PreferCloud should pick cloud; got %s (IsLocal=%v)", decision.Arm.ID, decision.Arm.IsLocal)
}
decision.Rollback()
})
t.Run("prefer=auto preserves tier order (local tier 2 < cloud tier 3)", func(t *testing.T) {
r := makeRouter(PreferAuto)
decision := r.Select(task)
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if !decision.Arm.IsLocal {
t.Errorf("PreferAuto should preserve tier order (local wins); got %s", decision.Arm.ID)
}
decision.Rollback()
})
}
// TestPreferPolicy_SLMStillWinsUnderPreferCloud documents the
// SLM-protection behavior: under PreferCloud, a tier-0 SLM (an arm
// with MaxComplexity > 0 that fits the task) still wins because the
// +2 tier shift only moves it from tier 0 to tier 2, which is still
// below the cloud arm's tier 3. This matches the plan's intent: "the
// SLM does small stuff" survives PreferCloud — that's exactly what
// the SLM is for.
func TestPreferPolicy_SLMStillWinsUnderPreferCloud(t *testing.T) {
r := New(Config{})
r.SetPreferPolicy(PreferCloud)
// Tier-0 SLM (low MaxComplexity, fits the trivial task).
r.RegisterArm(&Arm{
ID: NewArmID("ollama", "tiny-slm:1.5b"),
ModelName: "tiny-slm:1.5b",
Provider: security.WrapProvider(&stubProvider{name: "ollama", model: "tiny-slm:1.5b"}, nil),
IsLocal: true,
MaxComplexity: 0.30,
Strengths: []TaskType{TaskBoilerplate},
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 32768,
},
})
r.RegisterArm(&Arm{
ID: NewArmID("anthropic", "claude-sonnet-4-6"),
ModelName: "claude-sonnet-4-6",
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-sonnet-4-6"}, nil),
IsLocal: false,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 1_000_000,
},
})
decision := r.Select(Task{
Type: TaskBoilerplate,
ComplexityScore: 0.1,
Priority: PriorityLow,
RequiresTools: true,
EstimatedTokens: 200,
})
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if decision.Arm.ID != NewArmID("ollama", "tiny-slm:1.5b") {
t.Errorf("SLM should win trivial task even under PreferCloud (tier 0+2=2 < cloud 3); got %s", decision.Arm.ID)
}
decision.Rollback()
}
// TestPreferPolicy_StrengthsBeatsMultiplier: a cloud arm with a strong
// task-type tag still wins over a local arm without that tag, even
// under PreferLocal. Strengths is the primary signal; prefer is a
// secondary multiplier within the promoted/tier set.
func TestPreferPolicy_StrengthsBeatsMultiplier(t *testing.T) {
r := New(Config{})
r.SetPreferPolicy(PreferLocal)
// Local arm has no Strengths for SecurityReview.
localArm := &Arm{
ID: NewArmID("ollama", "qwen3:14b"),
ModelName: "qwen3:14b",
Provider: security.WrapProvider(&stubProvider{name: "ollama", model: "qwen3:14b"}, nil),
IsLocal: true,
Strengths: []TaskType{TaskGeneration},
MaxComplexity: 0.75,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 32768,
},
}
cloudArm := &Arm{
ID: NewArmID("anthropic", "claude-opus-4-7"),
ModelName: "claude-opus-4-7",
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-opus-4-7"}, nil),
IsLocal: false,
Strengths: []TaskType{TaskSecurityReview, TaskPlanning},
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 1_000_000,
ThinkingModes: []provider.EffortLevel{provider.EffortHigh},
},
}
r.RegisterArm(localArm)
r.RegisterArm(cloudArm)
decision := r.Select(Task{
Type: TaskSecurityReview,
ComplexityScore: 0.8,
Priority: PriorityCritical,
RequiresTools: true,
EstimatedTokens: 3000,
})
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if decision.Arm.ID != cloudArm.ID {
t.Errorf("Strengths-tagged cloud arm should beat PreferLocal multiplier; got %s", decision.Arm.ID)
}
decision.Rollback()
}
// TestPreferPolicy_ForcedArmBypassesPolicy: --provider X must always win.
func TestPreferPolicy_ForcedArmBypassesPolicy(t *testing.T) {
r := New(Config{})
r.SetPreferPolicy(PreferLocal)
cloudArmID := NewArmID("anthropic", "claude-sonnet-4-6")
r.RegisterArm(&Arm{
ID: cloudArmID,
ModelName: "claude-sonnet-4-6",
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-sonnet-4-6"}, nil),
IsLocal: false,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 1_000_000,
},
})
r.ForceArm(cloudArmID)
decision := r.Select(Task{Type: TaskGeneration, RequiresTools: true})
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if decision.Arm.ID != cloudArmID {
t.Errorf("forced arm should bypass PreferLocal; got %s, want %s", decision.Arm.ID, cloudArmID)
}
}
// TestPreferPolicy_IncognitoStillWins: incognito's hard filter must
// dominate the soft prefer bias.
func TestPreferPolicy_IncognitoStillWins(t *testing.T) {
r := New(Config{})
r.SetPreferPolicy(PreferCloud) // bias toward cloud
r.SetLocalOnly(true) // but incognito filters cloud out
factory := func(name, model string) SecureProvider {
return security.WrapProvider(&stubProvider{name: name, model: model}, nil)
}
RegisterDiscoveredModels(r, []DiscoveredModel{
{ID: "qwen3:14b", Provider: "ollama", SupportsTools: true, ContextSize: 32768},
}, factory)
r.RegisterArm(&Arm{
ID: NewArmID("anthropic", "claude-sonnet-4-6"),
ModelName: "claude-sonnet-4-6",
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-sonnet-4-6"}, nil),
IsLocal: false,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 1_000_000,
},
})
decision := r.Select(Task{
Type: TaskExplain,
ComplexityScore: 0.4,
Priority: PriorityNormal,
RequiresTools: true,
EstimatedTokens: 1500,
})
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if !decision.Arm.IsLocal {
t.Errorf("incognito (LocalOnly=true) must beat PreferCloud; got %s", decision.Arm.ID)
}
decision.Rollback()
}
// TestPreferPolicy_LocalArmsExhaustedFallsBackToCloud: PreferLocal must
// not block cloud selection when the local fleet can't handle the task.
func TestPreferPolicy_LocalArmsExhaustedFallsBackToCloud(t *testing.T) {
r := New(Config{})
r.SetPreferPolicy(PreferLocal)
// Only a cloud arm registered.
r.RegisterArm(&Arm{
ID: NewArmID("anthropic", "claude-opus-4-7"),
ModelName: "claude-opus-4-7",
Provider: security.WrapProvider(&stubProvider{name: "anthropic", model: "claude-opus-4-7"}, nil),
IsLocal: false,
Capabilities: provider.Capabilities{
ToolUse: true,
ContextWindow: 1_000_000,
ThinkingModes: []provider.EffortLevel{provider.EffortHigh},
},
})
decision := r.Select(Task{
Type: TaskSecurityReview,
ComplexityScore: 0.9,
Priority: PriorityCritical,
RequiresTools: true,
EstimatedTokens: 5000,
})
if decision.Error != nil {
t.Fatalf("Select error: %v", decision.Error)
}
if decision.Arm.ID != NewArmID("anthropic", "claude-opus-4-7") {
t.Errorf("expected cloud arm to win when no local feasible; got %s", decision.Arm.ID)
}
decision.Rollback()
}
+65 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -22,10 +23,58 @@ type Router struct {
forcedArm ArmID
// When true, only local arms are considered (incognito mode)
localOnly bool
// Soft bias toward local / cloud arms (PreferAuto = unbiased)
preferPolicy PreferPolicy
quality *QualityTracker
}
// PreferPolicy biases the scoring step toward local or cloud arms.
// See docs/superpowers/plans/2026-05-23-prefer-routing-policy.md.
type PreferPolicy int
const (
// PreferAuto leaves scoring unbiased — default, byte-identical to
// pre-policy behavior.
PreferAuto PreferPolicy = iota
// PreferLocal multiplies non-local arm scores by 0.3, biasing
// selection toward local arms while still allowing cloud arms to
// win when no local arm is feasible or a cloud arm is much stronger.
PreferLocal
// PreferCloud multiplies local arm scores by 0.5, biasing selection
// toward cloud arms while still allowing local arms (especially
// tier-0 SLMs) to win trivial tasks.
PreferCloud
)
// ParsePreferPolicy converts a TOML-friendly string to a PreferPolicy.
// Empty string and "auto" both map to PreferAuto. Unknown values return
// an actionable error.
func ParsePreferPolicy(s string) (PreferPolicy, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "auto":
return PreferAuto, nil
case "local":
return PreferLocal, nil
case "cloud":
return PreferCloud, nil
default:
return PreferAuto, fmt.Errorf("invalid router.prefer value %q (expected \"local\", \"cloud\", or \"auto\")", s)
}
}
// String returns the canonical TOML value for the policy.
func (p PreferPolicy) String() string {
switch p {
case PreferLocal:
return "local"
case PreferCloud:
return "cloud"
default:
return "auto"
}
}
type Config struct {
Logger *slog.Logger
}
@@ -123,7 +172,7 @@ func (r *Router) Select(task Task) RoutingDecision {
}
// Select best
best := selectBest(r.quality, feasible, task)
best := selectBest(r.quality, feasible, task, r.preferPolicy)
if best == nil {
return RoutingDecision{Error: fmt.Errorf("selection failed")}
}
@@ -189,6 +238,21 @@ func (r *Router) LocalOnly() bool {
return r.localOnly
}
// SetPreferPolicy biases scoring toward local or cloud arms. See
// PreferPolicy for the semantics. Soft bias only — does not hard-filter.
func (r *Router) SetPreferPolicy(p PreferPolicy) {
r.mu.Lock()
defer r.mu.Unlock()
r.preferPolicy = p
}
// PreferPolicy returns the current routing-preference bias.
func (r *Router) PreferPolicy() PreferPolicy {
r.mu.RLock()
defer r.mu.RUnlock()
return r.preferPolicy
}
// RemoveArm removes an arm from the router.
func (r *Router) RemoveArm(id ArmID) {
r.mu.Lock()
+8 -8
View File
@@ -262,7 +262,7 @@ func TestSelectBest_PrefersToolSupport(t *testing.T) {
}
task := Task{Type: TaskGeneration, RequiresTools: true, Priority: PriorityNormal}
best := selectBest(nil, []*Arm{withoutTools, withTools}, task)
best := selectBest(nil, []*Arm{withoutTools, withTools}, task, PreferAuto)
if best.ID != "a/with-tools" {
t.Errorf("should prefer arm with tool support, got %s", best.ID)
@@ -282,7 +282,7 @@ func TestSelectBest_PrefersThinkingForPlanning(t *testing.T) {
}
task := Task{Type: TaskPlanning, RequiresTools: true, Priority: PriorityNormal, EstimatedTokens: 5000}
best := selectBest(nil, []*Arm{noThinking, thinking}, task)
best := selectBest(nil, []*Arm{noThinking, thinking}, task, PreferAuto)
if best.ID != "a/thinking" {
t.Errorf("should prefer thinking model for planning, got %s", best.ID)
@@ -602,7 +602,7 @@ func TestArmTier(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := armTier(tt.arm, tt.task); got != tt.want {
if got := armTier(tt.arm, tt.task, PreferAuto); got != tt.want {
t.Errorf("armTier = %d, want %d", got, tt.want)
}
})
@@ -625,7 +625,7 @@ func TestSelectBest_SmallArmWinsTrivialTask(t *testing.T) {
Capabilities: provider.Capabilities{ToolUse: false},
}
task := Task{Type: TaskExplain, ComplexityScore: 0.05, RequiresTools: false}
got := selectBest(nil, []*Arm{cliArm, smallArm}, task)
got := selectBest(nil, []*Arm{cliArm, smallArm}, task, PreferAuto)
if got != smallArm {
t.Errorf("selectBest = %v, want smallArm", got)
}
@@ -647,7 +647,7 @@ func TestSelectBest_CLIAgentWinsComplexTask(t *testing.T) {
Capabilities: provider.Capabilities{ToolUse: false},
}
task := Task{Type: TaskRefactor, ComplexityScore: 0.7, RequiresTools: true}
got := selectBest(nil, []*Arm{cliArm, smallArm}, task)
got := selectBest(nil, []*Arm{cliArm, smallArm}, task, PreferAuto)
if got != cliArm {
t.Errorf("selectBest = %v, want cliArm", got)
}
@@ -672,21 +672,21 @@ func TestSelectBest_TierPreference(t *testing.T) {
task := Task{Type: TaskGeneration, Priority: PriorityNormal, EstimatedTokens: 1000}
t.Run("CLI beats local and API", func(t *testing.T) {
best := selectBest(nil, []*Arm{apiArm, localArm, cliArm}, task)
best := selectBest(nil, []*Arm{apiArm, localArm, cliArm}, task, PreferAuto)
if best.ID != "subprocess/claude" {
t.Errorf("want subprocess/claude (tier 0), got %s", best.ID)
}
})
t.Run("local beats API when no CLI", func(t *testing.T) {
best := selectBest(nil, []*Arm{apiArm, localArm}, task)
best := selectBest(nil, []*Arm{apiArm, localArm}, task, PreferAuto)
if best.ID != "ollama/llama3" {
t.Errorf("want ollama/llama3 (tier 1), got %s", best.ID)
}
})
t.Run("API selected when only option", func(t *testing.T) {
best := selectBest(nil, []*Arm{apiArm}, task)
best := selectBest(nil, []*Arm{apiArm}, task, PreferAuto)
if best == nil || best.ID != "mistral/mistral-large" {
t.Errorf("want mistral/mistral-large (tier 2), got %v", best)
}
+70 -8
View File
@@ -43,7 +43,38 @@ func (d RoutingDecision) Rollback() {
// - 1: CLI agent
// - 2: local model (general purpose, no complexity ceiling)
// - 3: API provider
func armTier(arm *Arm, task Task) int {
//
// When prefer is PreferLocal, non-local non-CLI-agent arms (true cloud
// API arms) are demoted by +2 tiers so any local or CLI-agent option
// is preferred. When prefer is PreferCloud, IsLocal arms are demoted
// by +2 tiers so cloud arms win the tier walk. The +2 shift is enough
// to drop cloud below the locals (tier 3 → 5) and locals below cloud
// (tier 2 → 4) without colliding with any normal tier value, keeping
// the tier walk deterministic.
//
// The Strengths-promoted path in selectBest bypasses the tier walk
// entirely, so prefer-policy never blocks a strongly-tagged arm from
// winning the task it's tagged for. This is the intended interaction.
func armTier(arm *Arm, task Task, prefer PreferPolicy) int {
base := armBaseTier(arm, task)
switch prefer {
case PreferLocal:
// Demote pure cloud arms. CLI-agent arms proxy to cloud but
// remain "local" from a tooling perspective — leave them where
// they are. Users who want to exclude them should use
// `--provider X` or the existing exclude mechanisms.
if !arm.IsLocal && !arm.IsCLIAgent {
return base + 2
}
case PreferCloud:
if arm.IsLocal {
return base + 2
}
}
return base
}
func armBaseTier(arm *Arm, task Task) int {
if arm.MaxComplexity > 0 && task.ComplexityScore <= arm.MaxComplexity {
return 0
}
@@ -67,7 +98,7 @@ func armTier(arm *Arm, task Task) int {
//
// Step 2 (fallback): walk tiers low→high. Within a tier, highest-scoring
// arm wins.
func selectBest(qt *QualityTracker, arms []*Arm, task Task) *Arm {
func selectBest(qt *QualityTracker, arms []*Arm, task Task, prefer PreferPolicy) *Arm {
if len(arms) == 0 {
return nil
}
@@ -79,29 +110,32 @@ func selectBest(qt *QualityTracker, arms []*Arm, task Task) *Arm {
}
}
if len(promoted) > 0 {
return bestScored(qt, promoted, task)
return bestScored(qt, promoted, task, prefer)
}
for tier := 0; tier <= 3; tier++ {
// Walk tiers low→high. armTier returns up to 5 when prefer is set
// (a dispreferred tier-3 cloud arm under PreferLocal lands at 5);
// the loop bound has to cover that.
for tier := 0; tier <= 5; tier++ {
var inTier []*Arm
for _, arm := range arms {
if armTier(arm, task) == tier {
if armTier(arm, task, prefer) == tier {
inTier = append(inTier, arm)
}
}
if len(inTier) > 0 {
return bestScored(qt, inTier, task)
return bestScored(qt, inTier, task, prefer)
}
}
return nil
}
// bestScored returns the highest-scoring arm within a set.
func bestScored(qt *QualityTracker, arms []*Arm, task Task) *Arm {
func bestScored(qt *QualityTracker, arms []*Arm, task Task, prefer PreferPolicy) *Arm {
var best *Arm
bestScore := math.Inf(-1)
for _, arm := range arms {
score := scoreArm(qt, arm, task)
score := scoreArm(qt, arm, task) * policyMultiplier(arm, prefer)
if score > bestScore {
bestScore = score
best = arm
@@ -110,6 +144,34 @@ func bestScored(qt *QualityTracker, arms []*Arm, task Task) *Arm {
return best
}
// policyMultiplier returns the prefer-policy score multiplier for an
// arm. Soft bias only — does not zero out the dispreferred set, so
// when only cloud arms are feasible under PreferLocal a cloud arm can
// still win. Calibrated against the typical scoreArm output range
// (~0.52.0) so a 0.3 multiplier is roughly equivalent to "non-local
// arm must be ~3x better than local to win."
//
// CLI-agent subprocess arms count as non-local because they proxy to
// cloud — the prefer knob is about the privacy/cost axis, not the
// tooling-locality axis. Users who want to pin subprocess specifically
// should use --provider subprocess, which bypasses the policy.
func policyMultiplier(arm *Arm, p PreferPolicy) float64 {
switch p {
case PreferLocal:
if arm.IsLocal {
return 1.0
}
return 0.3
case PreferCloud:
if arm.IsLocal {
return 0.5
}
return 1.0
default:
return 1.0
}
}
// strengthScoreBonus is added to quality when an arm's Strengths list
// matches the incoming task type. Tunable in one place.
const strengthScoreBonus = 0.15
+3 -3
View File
@@ -184,7 +184,7 @@ func TestSelectBest_StrengthPromotedArmBeatsCLIAgent(t *testing.T) {
}
task := Task{Type: TaskSecurityReview, EstimatedTokens: 5000, RequiresTools: true, Priority: PriorityNormal}
got := selectBest(nil, []*Arm{cliAgent, opus}, task)
got := selectBest(nil, []*Arm{cliAgent, opus}, task, PreferAuto)
if got == nil {
t.Fatal("selectBest returned nil")
}
@@ -208,7 +208,7 @@ func TestSelectBest_EmptyStrengthsPreservesTierOrder(t *testing.T) {
}
task := Task{Type: TaskSecurityReview, EstimatedTokens: 5000, RequiresTools: true, Priority: PriorityNormal}
got := selectBest(nil, []*Arm{cliAgent, opus}, task)
got := selectBest(nil, []*Arm{cliAgent, opus}, task, PreferAuto)
if got.ID != cliAgent.ID {
t.Errorf("without Strengths, CLI-agent tier-1 should win; got %s", got.ID)
}
@@ -339,7 +339,7 @@ func TestSelectBest_MultiplePromotedArmsBestQualityWins(t *testing.T) {
}
task := Task{Type: TaskSecurityReview, EstimatedTokens: 5000, RequiresTools: true, Priority: PriorityNormal}
got := selectBest(qt, []*Arm{armA, armB}, task)
got := selectBest(qt, []*Arm{armA, armB}, task, PreferAuto)
if got == nil {
t.Fatal("selectBest returned nil")
}