diff --git a/internal/routing/policy.go b/internal/routing/policy.go new file mode 100644 index 0000000..1ea09d1 --- /dev/null +++ b/internal/routing/policy.go @@ -0,0 +1,47 @@ +package routing + +// Decision is the route picked for a single skill call. +type Decision int + +const ( + DecideLocal Decision = iota + DecideClaude +) + +func (d Decision) String() string { + if d == DecideLocal { + return "local" + } + return "claude" +} + +// Policy holds the floor/ceil thresholds for routing decisions. +// +// Rules (in order): +// +// 1. passRate == nil → DecideLocal (default-to-local for cost-routable skills) +// 2. *passRate >= Floor → DecideLocal (trust local) +// 3. *passRate < Ceil → DecideClaude (don't trust local) +// 4. otherwise (sample band) → requestHash low bit picks: 0=local, 1=claude +type Policy struct { + Floor float64 + Ceil float64 +} + +// Decide returns the routing decision for a single call. +// requestHash is consulted only when passRate is in the sample band [Ceil, Floor). +func (p Policy) Decide(passRate *float64, requestHash uint64) Decision { + if passRate == nil { + return DecideLocal + } + if *passRate >= p.Floor { + return DecideLocal + } + if *passRate < p.Ceil { + return DecideClaude + } + if requestHash&1 == 0 { + return DecideLocal + } + return DecideClaude +} diff --git a/internal/routing/policy_test.go b/internal/routing/policy_test.go new file mode 100644 index 0000000..86d9841 --- /dev/null +++ b/internal/routing/policy_test.go @@ -0,0 +1,36 @@ +package routing_test + +import ( + "testing" + + "github.com/mathiasbq/supervisor/internal/routing" + "github.com/stretchr/testify/assert" +) + +func ptr(f float64) *float64 { return &f } + +func TestPolicyDecide(t *testing.T) { + p := routing.Policy{Floor: 0.9, Ceil: 0.7} + + cases := []struct { + name string + passRate *float64 + hash uint64 + want routing.Decision + }{ + {"null pass rate → local", nil, 0, routing.DecideLocal}, + {"null pass rate, hash irrelevant → local", nil, 0xDEADBEEF, routing.DecideLocal}, + {"at floor → local", ptr(0.9), 0, routing.DecideLocal}, + {"above floor → local", ptr(0.95), 0, routing.DecideLocal}, + {"below ceil → claude", ptr(0.5), 0, routing.DecideClaude}, + {"at ceil → sample-band even-hash → local", ptr(0.7), 0, routing.DecideLocal}, + {"sample band, even hash → local", ptr(0.8), 2, routing.DecideLocal}, + {"sample band, odd hash → claude", ptr(0.8), 3, routing.DecideClaude}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, p.Decide(tc.passRate, tc.hash)) + }) + } +}