diff --git a/CHANGELOG.md b/CHANGELOG.md index 33bb66a4..de33cca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **`ladder`/`aws`:** `AWSLadder.WithBufferFraction` and a stable-usage estimator: when the fraction is set, `GetUsageBaseline` fills `StableUSDPerHour` with the lowest of the low-water mark, the 10th percentile and the last 3 days, times `1 - fraction`. It stays nil when the option is unset, the series has fewer than 14 days, or the 10th percentile is under half the median (#31). - **AWS/Redshift:** when the `AttemptJournal` also implements `common.ClaimReader`, the reserved-node dedupe skips nodes another purchase recorded as bought, before and after the send. A claim read error refuses before the send or leaves the outcome unknown after it; if the skip leaves one of several matches and the token has no sending or outcome_unknown record, the purchase is refused as ambiguous (#700). - **`pkg`:** optional `common.ClaimReader` (`ClaimedByOthers(ctx, token, ids)`), which an `AttemptJournal` may implement so a provider can skip commitments another purchase recorded as bought (#700). - **`pkg`:** `common.Commitment.CostKnown` reports whether `Cost` holds a price the provider reported. The zero value means unknown, so a `Cost` of 0 without `CostKnown` must not be read as free. Producers set it in a follow-up (#74). diff --git a/pkg/ladder/types.go b/pkg/ladder/types.go index 4dcfe317..bf85d13b 100644 --- a/pkg/ladder/types.go +++ b/pkg/ladder/types.go @@ -453,7 +453,8 @@ type UsageBaseline struct { // percentile (e.g., the 5th percentile of hourly spend). LowWaterUSDPerHour *float64 // StableUSDPerHour is the estimated stable portion after applying the - // buffer fraction to LowWaterUSDPerHour. + // buffer fraction to LowWaterUSDPerHour. It is nil when the provider cannot + // estimate it (short or volatile series, or no buffer fraction configured). StableUSDPerHour *float64 // Basis says what the series measures: total demand (the default, empty) // or residual uncovered usage already net of existing commitments. See diff --git a/providers/aws/ladder/baseline.go b/providers/aws/ladder/baseline.go index 06ebb287..350f2f2f 100644 --- a/providers/aws/ladder/baseline.go +++ b/providers/aws/ladder/baseline.go @@ -52,13 +52,11 @@ const maxSeriesAgeDays = 3 // covers EC2 on-demand cost only, not RDS/ElastiCache/etc.). // - LowWaterUSDPerHour is the nearest-rank percentile of the daily series // (see nearestRankPercentile). -// - StableUSDPerHour is nil: per the pkg/ladder contract (types.go), Stable -// is the post-buffer-fraction estimate — a producer obligation this -// implementation cannot yet meet (no stable-usage estimator exists for -// AWS). Returning nil triggers the engine's documented degradation -// ("stable usage unknown; routing all core gap to flex"), which is honest -// and conservative. Do NOT alias it to LowWater: the engine consumes -// Stable verbatim as the base-layer cap and would over-commit the base. +// - StableUSDPerHour is nil unless WithBufferFraction was called and the +// series is long and steady enough (see estimateStableUSDPerHour). When set +// it is the post-buffer-fraction estimate the pkg/ladder contract requires. +// Do NOT alias it to LowWater: the engine consumes Stable verbatim as the +// base-layer cap and would over-commit the base. // // Error conditions: // - Series empty: hard error (no data from the coverage source). @@ -115,19 +113,14 @@ func (a *AWSLadder) GetUsageBaseline(ctx context.Context, scope ladder.Scope, lo return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: %w", vErr) } - lowWater, err := nearestRankPercentile(series, percentile) + lowWater, stable, err := a.baselineStats(series, percentile) if err != nil { - return ladder.UsageBaseline{}, fmt.Errorf("GetUsageBaseline: percentile computation failed: %w", err) + return ladder.UsageBaseline{}, err } - // StableUSDPerHour is intentionally nil: the pkg/ladder contract defines - // Stable as the post-buffer-fraction estimate (a producer obligation), and - // no stable-usage estimator exists for AWS yet. nil triggers the engine's - // documented "stable usage unknown; routing all core gap to flex" - // degradation — honest and conservative until a real estimator lands. return ladder.UsageBaseline{ LowWaterUSDPerHour: ptr(lowWater), - StableUSDPerHour: nil, + StableUSDPerHour: stable, Basis: ladder.BaselineBasisResidual, Series: series, LookbackDays: lookbackDays, @@ -302,3 +295,21 @@ func nearestRankPercentile(data []float64, p float64) (float64, error) { } return sorted[rank-1], nil } + +// baselineStats derives the low-water mark and, when a buffer fraction was +// configured, the stable estimate. Errors are returned fully prefixed. It is +// split out so GetUsageBaseline stays within the cyclomatic-complexity limit. +func (a *AWSLadder) baselineStats(series []float64, percentile float64) (lowWater float64, stable *float64, err error) { + lowWater, err = nearestRankPercentile(series, percentile) + if err != nil { + return 0, nil, fmt.Errorf("GetUsageBaseline: percentile computation failed: %w", err) + } + if a.bufferFraction == nil { + return lowWater, nil, nil + } + stable, err = estimateStableUSDPerHour(series, lowWater, *a.bufferFraction) + if err != nil { + return 0, nil, fmt.Errorf("GetUsageBaseline: stable usage estimate: %w", err) + } + return lowWater, stable, nil +} diff --git a/providers/aws/ladder/baseline_sdk_test.go b/providers/aws/ladder/baseline_sdk_test.go index a7ad68b9..be0daaba 100644 --- a/providers/aws/ladder/baseline_sdk_test.go +++ b/providers/aws/ladder/baseline_sdk_test.go @@ -120,6 +120,15 @@ func TestScopedBaselineParentCompatible(t *testing.T) { if mode == "account" || mode == "reverse" { assert.Equal(t, int32(2), calls.Load()) } + if mode == "account" { + // 28 days at 10/h then 2 days at 2/h: P10 = 10, min3 = 2, so 2 * (1 - 0.1). + _, err = a.WithBufferFraction(0.1) + require.NoError(t, err) + bl, err = a.GetUsageBaseline(context.Background(), scope, 30, 5) + require.NoError(t, err) + require.NotNil(t, bl.StableUSDPerHour) + assert.InDelta(t, 1.8, *bl.StableUSDPerHour, 1e-9) + } }) } } diff --git a/providers/aws/ladder/ladder.go b/providers/aws/ladder/ladder.go index a2046d2f..99f242df 100644 --- a/providers/aws/ladder/ladder.go +++ b/providers/aws/ladder/ladder.go @@ -3,6 +3,7 @@ package ladder import ( "errors" "fmt" + "math" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" @@ -95,7 +96,10 @@ type AWSLadder struct { exchange exchangeRunner // write side; nil until WithWriteSide is called spRates spCommitmentRateSource observed observedRateSource - cfg Config + // bufferFraction is nil until WithBufferFraction is called; nil keeps the + // stable-usage estimate off, so callers that never opt in see no change. + bufferFraction *float64 + cfg Config } // New constructs an AWSLadder. The five required read-side interfaces must be @@ -188,3 +192,17 @@ func (a *AWSLadder) WithWriteSide(riP riPurchaser, spP spPurchaser, ex exchangeR a.exchange = ex return a, nil } + +// WithBufferFraction sets the buffer fraction used to haircut the stable-usage +// estimate and returns the same instance for chaining. f must be finite and in +// [0, 1). A later call overwrites an earlier one, and a failed call leaves the +// stored value unchanged. +func (a *AWSLadder) WithBufferFraction(f float64) (*AWSLadder, error) { + // Same rule as LadderConfig.Validate in pkg/ladder, which is inline there and + // cannot be imported; the negated form also rejects NaN and +Inf. + if math.IsNaN(f) || !(f >= 0 && f < 1) { + return nil, fmt.Errorf("AWSLadder.WithBufferFraction: buffer_fraction %g must be in [0, 1)", f) + } + a.bufferFraction = &f + return a, nil +} diff --git a/providers/aws/ladder/ladder_test.go b/providers/aws/ladder/ladder_test.go index 2c38b190..b2cbd525 100644 --- a/providers/aws/ladder/ladder_test.go +++ b/providers/aws/ladder/ladder_test.go @@ -778,10 +778,10 @@ func TestGetUsageBaseline_SingleDaySeries_ReturnsThatValue(t *testing.T) { require.NoError(t, err) require.NotNil(t, bl.LowWaterUSDPerHour) assert.InDelta(t, 3.0, *bl.LowWaterUSDPerHour, 1e-9) - // StableUSDPerHour must be nil: no stable-usage estimator exists yet, and - // the pkg/ladder contract defines Stable as post-buffer-fraction (aliasing - // it to LowWater would make the engine over-commit the base layer). nil - // triggers the engine's documented "route all core gap to flex" degradation. + // StableUSDPerHour must be nil without WithBufferFraction: the pkg/ladder + // contract defines Stable as post-buffer-fraction (aliasing it to LowWater + // would make the engine over-commit the base layer). nil triggers the + // engine's documented "route all core gap to flex" degradation. assert.Nil(t, bl.StableUSDPerHour) assert.Equal(t, 7, bl.LookbackDays) assert.InDelta(t, 5.0, bl.Percentile, 1e-9) @@ -1140,3 +1140,59 @@ func makeConstantPoints(n int, value float64) []DailyPoint { } return out } + +// --------------------------------------------------------------------------- +// WithBufferFraction +// --------------------------------------------------------------------------- + +func TestWithBufferFraction_Validation(t *testing.T) { + t.Parallel() + tests := []struct { + name string + f float64 + wantErr string + }{ + {"NaN", math.NaN(), "AWSLadder.WithBufferFraction: buffer_fraction NaN must be in [0, 1)"}, + {"+Inf", math.Inf(1), "AWSLadder.WithBufferFraction: buffer_fraction +Inf must be in [0, 1)"}, + {"negative", -0.1, "AWSLadder.WithBufferFraction: buffer_fraction -0.1 must be in [0, 1)"}, + {"one", 1.0, "AWSLadder.WithBufferFraction: buffer_fraction 1 must be in [0, 1)"}, + {"above one", 1.5, "AWSLadder.WithBufferFraction: buffer_fraction 1.5 must be in [0, 1)"}, + {"zero accepted", 0, ""}, + {"just below one accepted", 0.999, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + prev := 0.25 + _, err := a.WithBufferFraction(prev) + require.NoError(t, err) + + got, err := a.WithBufferFraction(tt.f) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + assert.Nil(t, got) + require.NotNil(t, a.bufferFraction) + assert.Equal(t, prev, *a.bufferFraction, "failed call must leave the stored value unchanged") + return + } + require.NoError(t, err) + assert.Same(t, a, got) + require.NotNil(t, a.bufferFraction) + assert.Equal(t, tt.f, *a.bufferFraction) + }) + } +} + +func TestWithBufferFraction_UnsetAndLastCallWins(t *testing.T) { + t.Parallel() + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + assert.Nil(t, a.bufferFraction) + + _, err := a.WithBufferFraction(0.1) + require.NoError(t, err) + _, err = a.WithBufferFraction(0.3) + require.NoError(t, err) + require.NotNil(t, a.bufferFraction) + assert.Equal(t, 0.3, *a.bufferFraction) +} diff --git a/providers/aws/ladder/stable_usage.go b/providers/aws/ladder/stable_usage.go new file mode 100644 index 00000000..d0229bdc --- /dev/null +++ b/providers/aws/ladder/stable_usage.go @@ -0,0 +1,90 @@ +package ladder + +import ( + "math" + "slices" +) + +const ( + // minStableSeriesDays is two full weekly cycles, so weekday/weekend + // seasonality is visible before a base layer is sized from the series. + minStableSeriesDays = 14 + // stableFloorPercentile is independent of the configured low-water + // percentile, which can be up to 100 (the series maximum). + stableFloorPercentile = 10 + // minFloorToMedianRatio gates volatile series: the floor must be at least + // this share of typical usage. 0.5 is exact in binary, so boundaries are exact. + minFloorToMedianRatio = 0.5 + // trailingMinDays catches a sudden drop that the percentile terms miss. + trailingMinDays = 3 +) + +// finiteNonNeg reports whether v is finite and non-negative; NaN fails >=. +func finiteNonNeg(v float64) bool { return v >= 0 && !math.IsInf(v, 0) } + +// unitFraction reports whether f is in [0, 1); NaN fails the comparisons. +func unitFraction(f float64) bool { return f >= 0 && f < 1 } + +// validStableInputs guarantees a non-empty series of finite non-negative +// values, a finite non-negative lowWater and f in [0, 1). +func validStableInputs(series []float64, lowWater, f float64) bool { + if len(series) == 0 { + return false + } + for _, v := range series { + if !finiteNonNeg(v) { + return false + } + } + return finiteNonNeg(lowWater) && unitFraction(f) +} + +// stableQuantiles returns the floor percentile and the nearest-rank median +// (the lower median for even N). +func stableQuantiles(series []float64) (p10, median float64, err error) { + p10, err = nearestRankPercentile(series, stableFloorPercentile) + if err != nil { + return 0, 0, err + } + median, err = nearestRankPercentile(series, 50) + if err != nil { + return 0, 0, err + } + return p10, median, nil +} + +// stableGatePasses is true for a long enough, steady, non-zero series. The +// ratio is compared by multiplication, and median > 0 stops an all-zero series +// passing 0 >= 0.5*0. +func stableGatePasses(n int, p10, median float64) bool { + return n >= minStableSeriesDays && median > 0 && p10 >= minFloorToMedianRatio*median +} + +// stableBase is the lowest of the low-water mark, the floor percentile and the +// trailing minimum, so every term can only lower the estimate. PRECONDITION: +// len(series) >= trailingMinDays; the minStableSeriesDays gate must run first. +func stableBase(series []float64, lowWater, p10 float64) float64 { + return min(lowWater, p10, slices.Min(series[len(series)-trailingMinDays:])) +} + +// estimateStableUSDPerHour returns the post-buffer stable usage, or nil when +// the series cannot support one (invalid input, short, volatile, or zero). +// The error is reserved for a percentile failure, which validStableInputs makes +// unreachable. +func estimateStableUSDPerHour(series []float64, lowWater, bufferFraction float64) (*float64, error) { + if !validStableInputs(series, lowWater, bufferFraction) { + return nil, nil + } + p10, median, err := stableQuantiles(series) + if err != nil { + return nil, err + } + if !stableGatePasses(len(series), p10, median) { + return nil, nil + } + v := stableBase(series, lowWater, p10) * (1 - bufferFraction) + if v <= 0 || math.IsInf(v, 0) || math.IsNaN(v) { + return nil, nil + } + return ptr(v), nil +} diff --git a/providers/aws/ladder/stable_usage_test.go b/providers/aws/ladder/stable_usage_test.go new file mode 100644 index 00000000..deaefebb --- /dev/null +++ b/providers/aws/ladder/stable_usage_test.go @@ -0,0 +1,255 @@ +package ladder + +import ( + "context" + "math" + "math/big" + "math/rand/v2" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" +) + +// rep returns n copies of v. +func rep(n int, v float64) []float64 { + out := make([]float64, n) + for i := range out { + out[i] = v + } + return out +} + +// estimate derives lowWater from the series at percentile p, as GetUsageBaseline does. +func estimate(t *testing.T, series []float64, p, f float64) *float64 { + t.Helper() + lw, err := nearestRankPercentile(series, p) + require.NoError(t, err) + got, err := estimateStableUSDPerHour(series, lw, f) + require.NoError(t, err) + return got +} + +func TestEstimateStableUSDPerHour_Golden(t *testing.T) { + t.Parallel() + const f = 0.1 + tests := []struct { + name string + series []float64 + p float64 + f float64 + want float64 // 0 means nil + }{ + {"a stable series", rep(30, 10), 5, f, 9.0}, + {"g zero fraction applies no haircut", rep(30, 10), 5, 0, 10.0}, + {"b volatile: 10% of days at 1", slices.Concat(rep(3, 1), rep(27, 10)), 5, f, 0}, + {"c 13 points is short", rep(13, 10), 5, f, 0}, + {"c 14 points is enough", rep(14, 10), 5, f, 9.0}, + {"d floor exactly half the median passes", slices.Concat(rep(3, 5.0), rep(27, 10)), 5, f, 4.5}, + {"d floor just under half the median is nil", slices.Concat(rep(3, 4.999), rep(27, 10)), 5, f, 0}, + // P10 is rank 3 (8); any floor percentile giving rank 3 is equivalent. + {"e1 floor percentile binds below lowWater", slices.Concat([]float64{6, 7, 8}, rep(27, 10)), 50, f, 7.2}, + {"e2 lowWater clamp binds below P10", slices.Concat([]float64{7, 7, 9}, rep(27, 10)), 5, f, 6.3}, + {"e3 max percentile never exceeds the body", slices.Concat(rep(29, 10), []float64{20}), 100, f, 9.0}, + {"e4 week-long decline sizes on the low level", slices.Concat(rep(23, 10), rep(7, 6)), 50, f, 5.4}, + {"e5b third-from-last day zero is nil", slices.Concat(rep(27, 10), []float64{0, 10, 10}), 5, f, 0}, + {"e5b fourth-from-last day zero is outside the window", slices.Concat(rep(26, 10), []float64{0, 10, 10, 10}), 5, f, 9.0}, + {"e5i last day zero is nil", slices.Concat(rep(29, 10), []float64{0}), 5, f, 0}, + {"e5ii last two days zero is nil at p50", slices.Concat(rep(28, 10), rep(2, 0)), 50, f, 0}, + {"e5iii last three days zero is nil", slices.Concat(rep(27, 10), rep(3, 0)), 50, f, 0}, + {"e6 all zeros is nil", rep(30, 0), 5, f, 0}, + {"e7 even N uses the lower median", slices.Concat(rep(7, 4), rep(7, 10)), 5, f, 3.6}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := estimate(t, tt.series, tt.p, tt.f) + if tt.want == 0 { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + assert.InDelta(t, tt.want, *got, 1e-9) + }) + } +} + +func TestEstimateStableUSDPerHour_InvalidInputsAreNil(t *testing.T) { + t.Parallel() + ok := rep(30, 10) + tests := []struct { + name string + series []float64 + lowWater float64 + f float64 + }{ + {"empty", nil, 10, 0.1}, + {"NaN element", slices.Concat(rep(29, 10), []float64{math.NaN()}), 10, 0.1}, + {"Inf element", slices.Concat(rep(29, 10), []float64{math.Inf(1)}), 10, 0.1}, + {"negative element", slices.Concat(rep(29, 10), []float64{-1}), 10, 0.1}, + {"NaN lowWater", ok, math.NaN(), 0.1}, + {"Inf lowWater", ok, math.Inf(1), 0.1}, + {"fraction one", ok, 10, 1}, + {"fraction negative", ok, 10, -0.5}, + {"fraction above one", ok, 10, 1.5}, + {"fraction NaN", ok, 10, math.NaN()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := estimateStableUSDPerHour(tt.series, tt.lowWater, tt.f) + require.NoError(t, err) + assert.Nil(t, got) + }) + } +} + +func TestEstimateStableUSDPerHour_Property(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewPCG(1, 2)) //nolint:gosec // Deterministic test data, not security. + percentiles := []float64{1, 5, 10, 50, 100} + nonNil := 0 + for range 1000 { + series := make([]float64, 14+rng.IntN(47)) + for i := range series { + series[i] = 10 + float64(rng.IntN(61)-30)/10 // Steady: 10 plus noise in [-3, 3]. + } + p := percentiles[rng.IntN(len(percentiles))] + f := rng.Float64() + lw, err := nearestRankPercentile(series, p) + require.NoError(t, err) + p10, err := nearestRankPercentile(series, 10) + require.NoError(t, err) + min3 := slices.Min(series[len(series)-3:]) + + got, err := estimateStableUSDPerHour(series, lw, f) + require.NoError(t, err) + if got == nil { + continue + } + nonNil++ + assert.Greater(t, *got, 0.0) + assert.LessOrEqual(t, *got, lw*(1-f)+1e-12) + assert.LessOrEqual(t, *got, p10*(1-f)+1e-12) + assert.LessOrEqual(t, *got, min3*(1-f)+1e-12) + } + assert.Greater(t, nonNil, 500, "generator must mostly pass the gate or the checks are vacuous") +} + +// stablePublic runs GetUsageBaseline on vals (oldest first); f nil leaves the option unset. +func stablePublic(t *testing.T, vals []float64, lookback int, f *float64) (*float64, *float64) { + t.Helper() + points := makeRecentPoints(len(vals)) + for i := range points { + points[i].USDPerHour = vals[i] + } + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{onDemandPoints: points}, &fakeUtilizationSource{}) + if f != nil { + _, err := a.WithBufferFraction(*f) + require.NoError(t, err) + } + bl, err := a.GetUsageBaseline(context.Background(), testScope(), lookback, 5) + require.NoError(t, err) + return bl.StableUSDPerHour, bl.LowWaterUSDPerHour +} + +func TestGetUsageBaseline_StableUsage(t *testing.T) { + t.Parallel() + f := 0.1 + + t.Run("h option unset keeps stable nil on a qualifying series", func(t *testing.T) { + t.Parallel() + stable, _ := stablePublic(t, rep(30, 10), 30, nil) + assert.Nil(t, stable) + }) + t.Run("option set yields the haircut estimate, not aliased to lowWater", func(t *testing.T) { + t.Parallel() + stable, low := stablePublic(t, rep(30, 10), 30, &f) + require.NotNil(t, stable) + assert.InDelta(t, 9.0, *stable, 1e-9) + assert.NotSame(t, low, stable) + }) + t.Run("volatile series is nil", func(t *testing.T) { + t.Parallel() + stable, _ := stablePublic(t, slices.Concat(rep(3, 1), rep(27, 10)), 30, &f) + assert.Nil(t, stable) + }) + t.Run("short series is nil, 14 days is enough", func(t *testing.T) { + t.Parallel() + stable, _ := stablePublic(t, rep(13, 10), 13, &f) + assert.Nil(t, stable) + stable, _ = stablePublic(t, rep(14, 10), 14, &f) + require.NotNil(t, stable) + assert.InDelta(t, 9.0, *stable, 1e-9) + }) +} + +// allocateFromBaseline feeds a real GetUsageBaseline result to the engine with +// no existing commitment and a unit rate on every layer. +func allocateFromBaseline(t *testing.T, vals []float64, f float64) *ladder.AllocateResult { + t.Helper() + points := makeRecentPoints(len(vals)) + for i := range points { + points[i].USDPerHour = vals[i] + } + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{onDemandPoints: points}, &fakeUtilizationSource{}) + _, err := a.WithBufferFraction(f) + require.NoError(t, err) + bl, err := a.GetUsageBaseline(context.Background(), testScope(), len(vals), 5) + require.NoError(t, err) + + zero := 0.0 + states := map[ladder.LayerType]ladder.LayerState{} + rates := map[ladder.LayerType]float64{} + for _, spec := range a.SupportedLayers() { + states[spec.Type] = ladder.LayerState{Layer: spec.Type, ExistingUSDPerHour: &zero, ExpiringUSDPerHour: &zero} + rates[spec.Type] = 1 + } + res, err := ladder.Allocate(&ladder.AllocationInput{ + Config: ladder.LadderConfig{Scope: testScope(), Mode: ladder.ModeEmailApproval, Cadence: ladder.CadenceWeekly, + TargetCoveragePct: 100, BaselinePercentile: 5, LookbackDays: len(vals), MaxActionsPerRun: 10, + BufferFraction: f, + BufferUtilizationThresholdPct: ladder.DefaultBufferUtilizationThresholdPct, + Ramp: ladder.RampSchedule{Steps: []ladder.RampStep{{AfterDays: 0, Fraction: 1}}}}, + Layers: a.SupportedLayers(), LayerStates: states, CommitmentRates: rates, InFlight: []ladder.InFlightTranche{}, + Baseline: bl, Now: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC), DataSources: []string{"constructed"}, + }) + require.NoError(t, err) + return res +} + +func TestStableUsage_EndToEndAllocate(t *testing.T) { + t.Parallel() + t.Run("stable series caps the base layer at Stable", func(t *testing.T) { + t.Parallel() + // min3 = 6 gives Stable 5.4 < coreGap 9, so the cap binds. + res := allocateFromBaseline(t, slices.Concat(rep(27, 10), []float64{6, 10, 10}), 0.1) + got := map[ladder.LayerType]*big.Rat{} + for _, al := range res.Allocations { + got[al.Layer] = al.OnDemandEquivalentUSDPerHour + } + require.Len(t, got, 3) + // Amounts derive from float64 inputs (0.9 * 6), so compare within 1e-12. + base, _ := got[ladder.LayerEC2InstanceSP].Float64() + assert.InDelta(t, 5.4, base, 1e-12) + flex, _ := got[ladder.LayerComputeSP].Float64() + assert.InDelta(t, 3.6, flex, 1e-12) + buffer, _ := got[ladder.LayerConvertibleRI].Float64() + assert.InDelta(t, 1.0, buffer, 1e-12) + }) + t.Run("volatile series routes the whole core gap to flex", func(t *testing.T) { + t.Parallel() + res := allocateFromBaseline(t, slices.Concat(rep(3, 1), rep(27, 10)), 0.1) + noted := false + for _, al := range res.Allocations { + assert.NotEqual(t, ladder.LayerEC2InstanceSP, al.Layer) + noted = noted || strings.Contains(al.Rationale, "stable usage unknown") + } + assert.True(t, noted, "flex allocation should carry the stable-usage-unknown note") + }) +}