diff --git a/cmd/epp/runner/runner.go b/cmd/epp/runner/runner.go index 7d2b9db10d..83c5fb5f22 100644 --- a/cmd/epp/runner/runner.go +++ b/cmd/epp/runner/runner.go @@ -79,6 +79,7 @@ import ( "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/saturationdetector/concurrency" "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization" "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits" + "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback" "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/admitter/latencyslo" "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/admitter/probabilisticadmitter" reqdataprodprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/approximateprefix" @@ -591,6 +592,7 @@ func (r *Runner) registerInTreePlugins() { fwkplugin.Register(edf.EDFOrderingPolicyType, edf.EDFOrderingPolicyFactory) fwkplugin.Register(slodeadline.SLODeadlineOrderingPolicyType, slodeadline.SLODeadlineOrderingPolicyFactory) fwkplugin.Register(usagelimits.StaticUsageLimitPolicyType, usagelimits.StaticPolicyFactory) + fwkplugin.Register(priorityholdback.PolicyType, priorityholdback.PolicyFactory) // Register Request level data producer plugins as defaults for their respective data keys. fwkplugin.RegisterAsDefaultProducer(reqdataprodprefix.ApproxPrefixCachePluginType, reqdataprodprefix.ApproxPrefixCacheFactory, attrprefix.PrefixCacheMatchInfoDataKey) diff --git a/pkg/epp/framework/plugins/flowcontrol/usagelimits/README.md b/pkg/epp/framework/plugins/flowcontrol/usagelimits/README.md index e03781a02d..416ef7cf90 100644 --- a/pkg/epp/framework/plugins/flowcontrol/usagelimits/README.md +++ b/pkg/epp/framework/plugins/flowcontrol/usagelimits/README.md @@ -25,4 +25,5 @@ flowControl: --- ## Related Documentation +- [Priority Holdback Policy](priorityholdback/README.md) - [Flow Control Overview](../fairness/README.md) diff --git a/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/README.md b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/README.md new file mode 100644 index 0000000000..8e2a323e05 --- /dev/null +++ b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/README.md @@ -0,0 +1,73 @@ +# Priority Holdback Policy Plugin + +**Type:** `priority-holdback-policy` + +A usage limit policy that computes differentiated admission ceilings per priority level. As pool saturation rises, lower-priority traffic is gated first, reserving capacity for higher-priority work. + +This can be used in place the default static usage limit policy (which applies a single ceiling to all priorities) with priority-aware stepped gating. + +## What It Does + +Each active priority level receives an admission ceiling in [0.0, 1.0]. During each dispatch cycle, the Flow Controller compares current pool saturation against each priority's ceiling. When saturation exceeds a priority's ceiling, that priority's traffic is held back. + +Higher-priority traffic continues to flow until saturation reaches its own (higher) ceiling, providing quality-of-service differentiation under load. + +## Configuration + +Behavior is configured via two independent parameters: `shape` and `domain`. The resulting ceilings always range from `maxCeiling` for the highest priority to `minCeiling` for the lowest. + +### `shape` + +The interpolation curve used to distribute ceilings across the range. Currently only `"linear"` is supported. + +### `domain` + +How priority levels are mapped to positions in the ceiling range. + +#### `rank` (default) + +Distributes ceilings in equal steps by ordinal rank, ignoring numerical priority values. Ceilings are evenly spaced across `[minCeiling, maxCeiling]`. + + c_i = maxCeiling - i * (maxCeiling - minCeiling) / (N - 1) + +Where `i` is the index in descending priority order (0 = highest) and `N` is the count of active priorities. + +Use when priorities represent ordinal categories (e.g., "critical", "normal", "batch") where the numerical values are arbitrary labels. + +#### `value` + +Scales ceilings proportionally to the numerical priority value within the observed active range. + + r_i = (p_i - pMin) / (pMax - pMin) + c_i = minCeiling + r_i * (maxCeiling - minCeiling) + +Use when the numerical spacing between priority values carries meaning and priorities that are numerically close should behave similarly under pressure. + +**Parameters:** + +- `shape` (string, optional, default: `"linear"`): Interpolation curve. Currently only `"linear"` is supported. +- `domain` (string, optional, default: `"rank"`): Priority mapping: `"rank"` or `"value"`. +- `minCeiling` (float64, required, no default): Ceiling for the lowest priority. Must be in `[0.0, 1.0)`. +- `maxCeiling` (float64, optional, default: `1.0`): Ceiling for the highest priority. Must be in `(0.0, 1.0]`. + +`minCeiling` is required because it determines how aggressively low-priority traffic is gated and there is no universally correct default. + +**Configuration Example:** +```yaml +plugins: + - type: priority-holdback-policy + name: my-holdback-policy + parameters: + shape: linear + domain: rank + minCeiling: 0.4 + maxCeiling: 0.9 +flowControl: + usageLimitPolicyPluginRef: my-holdback-policy +``` + +--- + +## Related Documentation +- [Static Usage Limit Policy](../README.md) +- [Flow Control Overview](../../fairness/README.md) diff --git a/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/config.go b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/config.go new file mode 100644 index 0000000000..45ab3816bc --- /dev/null +++ b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/config.go @@ -0,0 +1,167 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package priorityholdback + +import ( + "errors" + "fmt" + + "k8s.io/utils/ptr" +) + +// shape constants define the interpolation curve applied across the ceiling range. +// Only "linear" is supported; additional shapes (sigmoid, exponential, step, etc.) may be added. +const ( + shapeLinear = "linear" +) + +// domain constants define how priority levels are mapped to positions in the ceiling range. +const ( + // domainRank maps by ordinal rank, ignoring numerical priority values. + domainRank = "rank" + // domainValue maps proportionally to numerical priority values. + domainValue = "value" +) + +const ( + defaultShape = shapeLinear + defaultDomain = domainRank + defaultMaxCeiling float64 = 1.0 +) + +// apiConfig represents the external configuration schema for the priority holdback policy. +// It is designed to be deserialized from JSON via the plugin's raw parameters. +type apiConfig struct { + // Shape selects the interpolation curve used to distribute ceilings across the range. + // + // Optional, defaults to "linear". Currently only "linear" is supported. + Shape *string `json:"shape,omitempty"` + + // Domain selects how priority levels are mapped to positions in the ceiling range. + // - "rank": equal spacing by ordinal rank, ignoring numerical values. + // - "value": spacing proportional to numerical priority differences. + // + // Optional, defaults to "rank". + Domain *string `json:"domain,omitempty"` + + // MinCeiling is the admission ceiling assigned to the lowest-priority traffic. + // Determines how aggressively the lowest priority is gated as saturation rises. + // + // Required. Must be in [0.0, 1.0) and strictly less than MaxCeiling. + MinCeiling *float64 `json:"minCeiling"` + + // MaxCeiling is the admission ceiling assigned to the highest-priority traffic. + // A value of 1.0 means the highest priority is only gated at full saturation. + // + // Defaults to 1.0 if unset. Must be in (0.0, 1.0] and strictly greater than MinCeiling. + MaxCeiling *float64 `json:"maxCeiling,omitempty"` +} + +// config is the internal, fully-validated configuration used by the policy. +type config struct { + shape string + domain string + minCeiling float64 + maxCeiling float64 +} + +// buildConfig applies the configuration lifecycle (defaulting and validation) and translates the +// external schema into the internal domain model. +// The provided apiConfig is copied to prevent mutation side-effects. +func buildConfig(apiCfg *apiConfig) (*config, error) { + var safeCfg apiConfig + if apiCfg != nil { + safeCfg = *apiCfg + } + + if err := checkRequired(&safeCfg); err != nil { + return nil, fmt.Errorf("invalid priority holdback policy configuration: %w", err) + } + + applyDefaults(&safeCfg) + + if err := validateConfig(&safeCfg); err != nil { + return nil, fmt.Errorf("invalid priority holdback policy configuration: %w", err) + } + + return &config{ + shape: *safeCfg.Shape, + domain: *safeCfg.Domain, + minCeiling: *safeCfg.MinCeiling, + maxCeiling: *safeCfg.MaxCeiling, + }, nil +} + +// checkRequired verifies that mandatory fields are present before defaulting. +func checkRequired(cfg *apiConfig) error { + if cfg.MinCeiling == nil { + return errors.New("minCeiling is required") + } + return nil +} + +// applyDefaults populates unset optional fields with their standard defaults. +func applyDefaults(cfg *apiConfig) { + if cfg.Shape == nil { + cfg.Shape = ptr.To(defaultShape) + } + if cfg.Domain == nil { + cfg.Domain = ptr.To(defaultDomain) + } + if cfg.MaxCeiling == nil { + cfg.MaxCeiling = ptr.To(defaultMaxCeiling) + } +} + +// validateConfig checks the constraints of the fully defaulted configuration. +// It aggregates all validation failures rather than failing on the first error. +func validateConfig(cfg *apiConfig) error { + var errs []error + + if cfg.Shape != nil { + switch *cfg.Shape { + case shapeLinear: + default: + errs = append(errs, fmt.Errorf("unsupported shape %q, must be %q", + *cfg.Shape, shapeLinear)) + } + } + + if cfg.Domain != nil { + switch *cfg.Domain { + case domainRank, domainValue: + default: + errs = append(errs, fmt.Errorf("unsupported domain %q, must be one of: %q, %q", + *cfg.Domain, domainRank, domainValue)) + } + } + + if cfg.MinCeiling != nil && (*cfg.MinCeiling < 0.0 || *cfg.MinCeiling >= 1.0) { + errs = append(errs, fmt.Errorf("minCeiling must be in [0.0, 1.0), got %f", *cfg.MinCeiling)) + } + + if cfg.MaxCeiling != nil && (*cfg.MaxCeiling <= 0.0 || *cfg.MaxCeiling > 1.0) { + errs = append(errs, fmt.Errorf("maxCeiling must be in (0.0, 1.0], got %f", *cfg.MaxCeiling)) + } + + if cfg.MinCeiling != nil && cfg.MaxCeiling != nil && *cfg.MinCeiling >= *cfg.MaxCeiling { + errs = append(errs, fmt.Errorf("minCeiling (%f) must be strictly less than maxCeiling (%f)", + *cfg.MinCeiling, *cfg.MaxCeiling)) + } + + return errors.Join(errs...) +} diff --git a/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/priority_holdback.go b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/priority_holdback.go new file mode 100644 index 0000000000..c88fb04e5f --- /dev/null +++ b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/priority_holdback.go @@ -0,0 +1,146 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package priorityholdback implements a UsageLimitPolicy that computes differentiated admission +// ceilings per priority level. Lower-priority traffic is gated at lower saturation thresholds, +// reserving capacity for higher-priority work as the pool approaches saturation. +// +// Behavior is configured via two independent parameters: +// - shape: the interpolation curve (currently "linear"; future: sigmoid, exponential, etc.). +// - domain: how priorities map to positions ("rank" for ordinal, "value" for proportional). +package priorityholdback + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol" + "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin" +) + +// PolicyType is the registration type for the priority holdback usage limit policy. +const PolicyType = "priority-holdback-policy" + +// PolicyFactory creates a priorityHoldbackPolicy from JSON config. +func PolicyFactory(name string, params *json.Decoder, _ plugin.Handle) (plugin.Plugin, error) { + var apiCfg apiConfig + if params != nil { + if err := params.Decode(&apiCfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal priority holdback policy config: %w", err) + } + } + cfg, err := buildConfig(&apiCfg) + if err != nil { + return nil, err + } + return newPriorityHoldbackPolicy(*cfg).withName(name), nil +} + +// priorityHoldbackPolicy gates lower-priority traffic as saturation rises. The gating strategy +// is resolved to a function at construction time to avoid per-dispatch branching. +type priorityHoldbackPolicy struct { + name string + cMax float64 + cMin float64 + computeFn func(cMin, cMax float64, priorities []int) (ceilings []float64) +} + +var _ flowcontrol.UsageLimitPolicy = &priorityHoldbackPolicy{} + +func newPriorityHoldbackPolicy(cfg config) *priorityHoldbackPolicy { + var fn func(cMin, cMax float64, priorities []int) (ceilings []float64) + switch cfg.domain { + case domainRank: + fn = computeLimitStepwiseSpread + case domainValue: + fn = computeLimitLinearProportional + } + return &priorityHoldbackPolicy{ + name: PolicyType, + cMax: cfg.maxCeiling, + cMin: cfg.minCeiling, + computeFn: fn, + } +} + +func (p *priorityHoldbackPolicy) withName(name string) *priorityHoldbackPolicy { + if name != "" { + p.name = name + } + return p +} + +func (p *priorityHoldbackPolicy) Name() string { + return p.name +} + +func (p *priorityHoldbackPolicy) TypedName() plugin.TypedName { + return plugin.TypedName{ + Type: PolicyType, + Name: p.name, + } +} + +// ComputeLimit returns an admission ceiling for each priority. With a single active priority, +// holdback is bypassed (ceiling = cMax) to preserve work-conserving behavior. +func (p *priorityHoldbackPolicy) ComputeLimit(_ context.Context, _ float64, priorities []int) (ceilings []float64) { + if len(priorities) == 0 { + return []float64{} + } + if len(priorities) == 1 { + return []float64{p.cMax} + } + // Ceilings are monotonically decreasing as priorities are ordered from highest to lowest per UsageLimitPolicy contract. + // New strategies (e.g. sigmoid/static definition) could require explicit monotizing sweep. + return p.computeFn(p.cMin, p.cMax, priorities) +} + +// computeLimitStepwiseSpread divides [cMin, cMax] into equal steps by rank. +// c_i = cMax - i * (cMax - cMin) / (N - 1) +func computeLimitStepwiseSpread(cMin, cMax float64, priorities []int) (ceilings []float64) { + ceilings = make([]float64, len(priorities)) + spread := cMax - cMin + n := float64(len(priorities) - 1) + for i := range priorities { + ceilings[i] = cMax - float64(i)*spread/n + } + return ceilings +} + +// computeLimitLinearProportional scales ceilings proportionally to numerical priority values. +// r_i = (p_i - pMin) / (pMax - pMin) +// c_i = cMin + r_i * (cMax - cMin) +func computeLimitLinearProportional(cMin, cMax float64, priorities []int) (ceilings []float64) { + ceilings = make([]float64, len(priorities)) + pMin := float64(priorities[len(priorities)-1]) + pMax := float64(priorities[0]) + pRange := pMax - pMin + // All priorities share the same value; no differentiation possible. + // Also avoid division by zero in the formula for r_i. + if pRange == 0 { + for i := range priorities { + ceilings[i] = cMax + } + return ceilings + } + spread := cMax - cMin + for i := range priorities { + r := (float64(priorities[i]) - pMin) / pRange + ceilings[i] = cMin + r*spread + } + return ceilings +} diff --git a/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/priority_holdback_test.go b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/priority_holdback_test.go new file mode 100644 index 0000000000..d0584ba469 --- /dev/null +++ b/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback/priority_holdback_test.go @@ -0,0 +1,518 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package priorityholdback + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin" +) + +// --------------------------------------------------------------------------- +// Factory tests +// --------------------------------------------------------------------------- + +func TestPolicyFactory(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config []byte + wantError bool + }{ + { + name: "valid rank domain config", + config: []byte(`{"domain": "rank", "minCeiling": 0.5}`), + wantError: false, + }, + { + name: "valid value domain config", + config: []byte(`{"domain": "value", "minCeiling": 0.3, "maxCeiling": 0.9}`), + wantError: false, + }, + { + name: "valid config with explicit maxCeiling", + config: []byte(`{"domain": "rank", "minCeiling": 0.2, "maxCeiling": 0.8}`), + wantError: false, + }, + { + name: "defaults applied for shape and domain", + config: []byte(`{"minCeiling": 0.5}`), + wantError: false, + }, + { + name: "explicit shape linear", + config: []byte(`{"shape": "linear", "minCeiling": 0.5}`), + wantError: false, + }, + { + name: "missing minCeiling", + config: []byte(`{"domain": "rank"}`), + wantError: true, + }, + { + name: "empty config", + config: []byte(`{}`), + wantError: true, + }, + { + name: "nil config", + config: nil, + wantError: true, + }, + { + name: "unsupported shape", + config: []byte(`{"shape": "sigmoid", "minCeiling": 0.5}`), + wantError: true, + }, + { + name: "unsupported domain", + config: []byte(`{"domain": "unknown", "minCeiling": 0.5}`), + wantError: true, + }, + { + name: "minCeiling negative", + config: []byte(`{"minCeiling": -0.1}`), + wantError: true, + }, + { + name: "minCeiling equals 1.0", + config: []byte(`{"minCeiling": 1.0}`), + wantError: true, + }, + { + name: "maxCeiling zero", + config: []byte(`{"minCeiling": 0.5, "maxCeiling": 0.0}`), + wantError: true, + }, + { + name: "maxCeiling exceeds 1.0", + config: []byte(`{"minCeiling": 0.5, "maxCeiling": 1.1}`), + wantError: true, + }, + { + name: "minCeiling equals maxCeiling", + config: []byte(`{"minCeiling": 0.5, "maxCeiling": 0.5}`), + wantError: true, + }, + { + name: "minCeiling greater than maxCeiling", + config: []byte(`{"minCeiling": 0.8, "maxCeiling": 0.5}`), + wantError: true, + }, + { + name: "invalid JSON", + config: []byte(`{not json}`), + wantError: true, + }, + { + name: "minCeiling zero is valid", + config: []byte(`{"minCeiling": 0.0}`), + wantError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + p, err := PolicyFactory("test-policy", fwkplugin.StrictDecoder(tc.config), nil) + if tc.wantError { + require.Error(t, err) + require.Nil(t, p) + } else { + require.NoError(t, err) + require.NotNil(t, p) + } + }) + } +} + +func TestPolicyFactory_DefaultMaxCeiling(t *testing.T) { + t.Parallel() + p, err := PolicyFactory("test", fwkplugin.StrictDecoder( + []byte(`{"minCeiling": 0.5}`)), nil) + require.NoError(t, err) + + policy := p.(*priorityHoldbackPolicy) + assert.Equal(t, 1.0, policy.cMax, "maxCeiling should default to 1.0") +} + +func TestPolicyFactory_TypedName(t *testing.T) { + t.Parallel() + p, err := PolicyFactory("my-policy", fwkplugin.StrictDecoder( + []byte(`{"minCeiling": 0.5}`)), nil) + require.NoError(t, err) + + tn := p.(interface{ TypedName() fwkplugin.TypedName }).TypedName() + assert.Equal(t, PolicyType, tn.Type) + assert.Equal(t, "my-policy", tn.Name) +} + +// --------------------------------------------------------------------------- +// ComputeLimit corner cases +// --------------------------------------------------------------------------- + +func TestComputeLimit_EmptyPriorities(t *testing.T) { + t.Parallel() + policy := newPriorityHoldbackPolicy(config{ + shape: shapeLinear, + domain: domainRank, + minCeiling: 0.5, + maxCeiling: 1.0, + }) + + ceilings := policy.ComputeLimit(t.Context(), 0.5, []int{}) + assert.Empty(t, ceilings) + assert.NotNil(t, ceilings, "should return empty slice, not nil") +} + +func TestComputeLimit_SinglePriority(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + domain string + cMax float64 + }{ + {"rank domain", domainRank, 1.0}, + {"value domain", domainValue, 1.0}, + {"custom maxCeiling", domainRank, 0.9}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + policy := newPriorityHoldbackPolicy(config{ + shape: shapeLinear, + domain: tc.domain, + minCeiling: 0.5, + maxCeiling: tc.cMax, + }) + ceilings := policy.ComputeLimit(t.Context(), 0.5, []int{10}) + require.Len(t, ceilings, 1) + assert.Equal(t, tc.cMax, ceilings[0], "single priority should bypass holdback with cMax") + }) + } +} + +// --------------------------------------------------------------------------- +// Stepwise-spread tests (domain: rank) +// --------------------------------------------------------------------------- + +func TestStepwiseSpread_TwoPriorities(t *testing.T) { + t.Parallel() + ceilings := computeLimitStepwiseSpread(0.5, 1.0, []int{100, 10}) + require.Len(t, ceilings, 2) + assert.InDelta(t, 1.0, ceilings[0], 1e-9, "highest priority gets cMax") + assert.InDelta(t, 0.5, ceilings[1], 1e-9, "lowest priority gets cMin") +} + +func TestStepwiseSpread_ThreePriorities(t *testing.T) { + t.Parallel() + ceilings := computeLimitStepwiseSpread(0.5, 1.0, []int{100, 50, 10}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 1.0, ceilings[0], 1e-9) + assert.InDelta(t, 0.75, ceilings[1], 1e-9) + assert.InDelta(t, 0.5, ceilings[2], 1e-9) +} + +func TestStepwiseSpread_FourPriorities(t *testing.T) { + t.Parallel() + ceilings := computeLimitStepwiseSpread(0.5, 1.0, []int{100, 75, 50, 10}) + require.Len(t, ceilings, 4) + assert.InDelta(t, 1.0, ceilings[0], 1e-9) + assert.InDelta(t, 0.8333333, ceilings[1], 1e-6) + assert.InDelta(t, 0.6666666, ceilings[2], 1e-6) + assert.InDelta(t, 0.5, ceilings[3], 1e-9) +} + +func TestStepwiseSpread_IgnoresNumericalValues(t *testing.T) { + t.Parallel() + + // Stepwise should produce the same ceilings regardless of priority values, + // as long as the count and order are the same. + ceilingsA := computeLimitStepwiseSpread(0.5, 1.0, []int{100, 50, 10}) + ceilingsB := computeLimitStepwiseSpread(0.5, 1.0, []int{1000, 2, 1}) + + require.Len(t, ceilingsA, 3) + require.Len(t, ceilingsB, 3) + for i := range ceilingsA { + assert.InDelta(t, ceilingsA[i], ceilingsB[i], 1e-9, + "stepwise ceilings should be identical regardless of priority values") + } +} + +func TestStepwiseSpread_MonotonicallyDecreasing(t *testing.T) { + t.Parallel() + ceilings := computeLimitStepwiseSpread(0.2, 0.9, []int{50, 40, 30, 20, 10}) + for i := 1; i < len(ceilings); i++ { + assert.Greater(t, ceilings[i-1], ceilings[i], + "ceiling[%d] (%f) should be greater than ceiling[%d] (%f)", i-1, ceilings[i-1], i, ceilings[i]) + } +} + +func TestStepwiseSpread_BoundaryValues(t *testing.T) { + t.Parallel() + ceilings := computeLimitStepwiseSpread(0.0, 1.0, []int{10, 5, 1}) + assert.InDelta(t, 1.0, ceilings[0], 1e-9, "highest priority gets cMax") + assert.InDelta(t, 0.0, ceilings[len(ceilings)-1], 1e-9, "lowest priority gets cMin") +} + +func TestStepwiseSpread_NarrowRange(t *testing.T) { + t.Parallel() + ceilings := computeLimitStepwiseSpread(0.8, 0.9, []int{10, 5, 1}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 0.9, ceilings[0], 1e-9) + assert.InDelta(t, 0.85, ceilings[1], 1e-9) + assert.InDelta(t, 0.8, ceilings[2], 1e-9) +} + +// --------------------------------------------------------------------------- +// Linear-proportional tests (domain: value) +// --------------------------------------------------------------------------- + +func TestLinearProportional_TwoPriorities(t *testing.T) { + t.Parallel() + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{100, 10}) + require.Len(t, ceilings, 2) + assert.InDelta(t, 1.0, ceilings[0], 1e-9, "highest priority gets cMax") + assert.InDelta(t, 0.5, ceilings[1], 1e-9, "lowest priority gets cMin") +} + +func TestLinearProportional_ThreePriorities_EvenSpacing(t *testing.T) { + t.Parallel() + // Priorities {90, 50, 10}: 50 is equidistant from both endpoints. + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{90, 50, 10}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 1.0, ceilings[0], 1e-9) + assert.InDelta(t, 0.75, ceilings[1], 1e-9) + assert.InDelta(t, 0.5, ceilings[2], 1e-9) +} + +func TestLinearProportional_ThreePriorities_SkewedLow(t *testing.T) { + t.Parallel() + // Priorities {100, 50, 10}: 50 is numerically closer to 10 than to 100. + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{100, 50, 10}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 1.0, ceilings[0], 1e-9) + assert.InDelta(t, 0.7222222, ceilings[1], 1e-6) + assert.InDelta(t, 0.5, ceilings[2], 1e-9) +} + +func TestLinearProportional_SkewedDistribution(t *testing.T) { + t.Parallel() + // Priorities {1, 2, 100}: 2 is nearly indistinguishable from 1. + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{100, 2, 1}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 1.0, ceilings[0], 1e-9) + assert.InDelta(t, 0.505050, ceilings[1], 1e-5, "priority 2 should be close to cMin") + assert.InDelta(t, 0.5, ceilings[2], 1e-9) +} + +func TestLinearProportional_DuplicatePriorities(t *testing.T) { + t.Parallel() + // All same value: pRange = 0, should return cMax for all. + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{10, 10, 10}) + require.Len(t, ceilings, 3) + for i, c := range ceilings { + assert.InDelta(t, 1.0, c, 1e-9, "ceiling[%d] should be cMax when all priorities are equal", i) + } +} + +func TestLinearProportional_TwoDuplicatePriorities(t *testing.T) { + t.Parallel() + // Two same values at the bottom. + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{100, 10, 10}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 1.0, ceilings[0], 1e-9) + assert.InDelta(t, 0.5, ceilings[1], 1e-9, "duplicate lowest priorities get cMin") + assert.InDelta(t, 0.5, ceilings[2], 1e-9) +} + +func TestLinearProportional_MonotonicallyDecreasing(t *testing.T) { + t.Parallel() + ceilings := computeLimitLinearProportional(0.2, 0.9, []int{50, 40, 30, 20, 10}) + for i := 1; i < len(ceilings); i++ { + assert.GreaterOrEqual(t, ceilings[i-1], ceilings[i], + "ceiling[%d] (%f) should be >= ceiling[%d] (%f)", i-1, ceilings[i-1], i, ceilings[i]) + } +} + +func TestLinearProportional_NegativePriorities(t *testing.T) { + t.Parallel() + ceilings := computeLimitLinearProportional(0.5, 1.0, []int{10, -5, -20}) + require.Len(t, ceilings, 3) + assert.InDelta(t, 1.0, ceilings[0], 1e-9, "highest priority gets cMax") + assert.InDelta(t, 0.5, ceilings[2], 1e-9, "lowest priority gets cMin") + assert.Greater(t, ceilings[1], ceilings[2], "middle priority should be between cMin and cMax") + assert.Less(t, ceilings[1], ceilings[0], "middle priority should be between cMin and cMax") +} + +func TestLinearProportional_BoundaryValues(t *testing.T) { + t.Parallel() + ceilings := computeLimitLinearProportional(0.0, 1.0, []int{10, 5, 1}) + assert.InDelta(t, 1.0, ceilings[0], 1e-9, "highest priority gets cMax") + assert.InDelta(t, 0.0, ceilings[len(ceilings)-1], 1e-9, "lowest priority gets cMin") +} + +// --------------------------------------------------------------------------- +// Cross-domain comparison +// --------------------------------------------------------------------------- + +func TestDomains_ConvergeOnEvenSpacing(t *testing.T) { + t.Parallel() + // When priorities are evenly distributed relative to their range, both domains + // should produce the same ceilings. + priorities := []int{100, 55, 10} + rank := computeLimitStepwiseSpread(0.5, 1.0, priorities) + value := computeLimitLinearProportional(0.5, 1.0, priorities) + + require.Len(t, rank, 3) + require.Len(t, value, 3) + for i := range rank { + assert.InDelta(t, rank[i], value[i], 1e-9, + "domains should converge for evenly spaced priorities at index %d", i) + } +} + +func TestDomains_DivergeOnSkewedSpacing(t *testing.T) { + t.Parallel() + // When priorities are skewed, value domain should give the middle priority a different + // ceiling than rank domain. + priorities := []int{100, 2, 1} + rank := computeLimitStepwiseSpread(0.5, 1.0, priorities) + value := computeLimitLinearProportional(0.5, 1.0, priorities) + + // Endpoints should be the same. + assert.InDelta(t, rank[0], value[0], 1e-9, "highest priority should match") + assert.InDelta(t, rank[2], value[2], 1e-9, "lowest priority should match") + + // Middle priority should differ: rank gives 0.75, value gives ~0.505. + assert.InDelta(t, 0.75, rank[1], 1e-9) + assert.Less(t, value[1], 0.51, "value domain should give priority 2 a ceiling near cMin") + assert.Greater(t, rank[1]-value[1], 0.2, "domains should meaningfully diverge") +} + +// --------------------------------------------------------------------------- +// Config validation tests +// --------------------------------------------------------------------------- + +func TestBuildConfig_RequiredFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg apiConfig + wantErr string + }{ + { + name: "minCeiling missing", + cfg: apiConfig{}, + wantErr: "minCeiling is required", + }, + { + name: "minCeiling missing with domain set", + cfg: apiConfig{Domain: ptrStr("rank")}, + wantErr: "minCeiling is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := buildConfig(&tc.cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestBuildConfig_ValidConfigs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg apiConfig + wantMin float64 + wantMax float64 + wantShape string + wantDomain string + }{ + { + name: "defaults for shape and domain", + cfg: apiConfig{MinCeiling: ptrFloat(0.5)}, + wantMin: 0.5, + wantMax: 1.0, + wantShape: "linear", + wantDomain: "rank", + }, + { + name: "explicit rank domain", + cfg: apiConfig{Domain: ptrStr("rank"), MinCeiling: ptrFloat(0.5)}, + wantMin: 0.5, + wantMax: 1.0, + wantShape: "linear", + wantDomain: "rank", + }, + { + name: "value domain with explicit maxCeiling", + cfg: apiConfig{Domain: ptrStr("value"), MinCeiling: ptrFloat(0.3), MaxCeiling: ptrFloat(0.9)}, + wantMin: 0.3, + wantMax: 0.9, + wantShape: "linear", + wantDomain: "value", + }, + { + name: "minCeiling at zero", + cfg: apiConfig{MinCeiling: ptrFloat(0.0)}, + wantMin: 0.0, + wantMax: 1.0, + wantShape: "linear", + wantDomain: "rank", + }, + { + name: "explicit shape and domain", + cfg: apiConfig{Shape: ptrStr("linear"), Domain: ptrStr("value"), MinCeiling: ptrFloat(0.4)}, + wantMin: 0.4, + wantMax: 1.0, + wantShape: "linear", + wantDomain: "value", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg, err := buildConfig(&tc.cfg) + require.NoError(t, err) + assert.Equal(t, tc.wantShape, cfg.shape) + assert.Equal(t, tc.wantDomain, cfg.domain) + assert.Equal(t, tc.wantMin, cfg.minCeiling) + assert.Equal(t, tc.wantMax, cfg.maxCeiling) + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func ptrStr(s string) *string { return &s } +func ptrFloat(f float64) *float64 { return &f }