From b3bde92a92082d2e2c2be35aeaeaf23a8f4eba23 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 9 Nov 2026 23:28:44 +0100 Subject: [PATCH 1/4] feat(aws/ladder): add the WithBufferFraction option to AWSLadder WithBufferFraction stores the buffer fraction the stable-usage estimator will apply. It must be finite and in [0, 1) and is chained like WithWriteSide; the last call wins and a rejected call leaves the stored value unchanged. Nothing reads it yet, so behaviour is unchanged. Part of #31 Co-Authored-By: Claude Sonnet 5.5 --- providers/aws/ladder/ladder.go | 20 ++++++++++- providers/aws/ladder/ladder_test.go | 56 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) 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..868d3c96 100644 --- a/providers/aws/ladder/ladder_test.go +++ b/providers/aws/ladder/ladder_test.go @@ -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) +} From 554fda612503c22f1bf27f4c841a12dead7ae632 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 9 Nov 2026 23:28:58 +0100 Subject: [PATCH 2/4] feat(aws/ladder): estimate stable usage for the EC2 Instance SP base layer GetUsageBaseline now fills StableUSDPerHour when the caller set a buffer fraction. The estimate is the minimum of the low-water mark, the 10th percentile of the series and the lowest of the last three days, scaled by one minus the buffer fraction. It is nil for a series under 14 days, for a series where the 10th percentile is under half the median, and for a non-positive result, so the engine keeps routing the gap to the flex layer in those cases. Without the option the value stays nil and behaviour is unchanged. The percentile block moves into baselineStats to keep GetUsageBaseline within the complexity limit. Part of #31 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + providers/aws/ladder/baseline.go | 41 +++-- providers/aws/ladder/baseline_sdk_test.go | 9 ++ providers/aws/ladder/stable_usage.go | 90 +++++++++++ providers/aws/ladder/stable_usage_test.go | 180 ++++++++++++++++++++++ 5 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 providers/aws/ladder/stable_usage.go create mode 100644 providers/aws/ladder/stable_usage_test.go 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/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/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..25648f9c --- /dev/null +++ b/providers/aws/ladder/stable_usage_test.go @@ -0,0 +1,180 @@ +package ladder + +import ( + "context" + "math" + "math/rand/v2" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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 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} + for range 1000 { + series := make([]float64, 14+rng.IntN(47)) + for i := range series { + series[i] = float64(rng.IntN(200)) / 10 + } + 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 + } + 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) + } +} + +// 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) + }) +} From 50215ac06f69b60806aa1d604418de2a3d3987ce Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 9 Nov 2026 23:31:39 +0100 Subject: [PATCH 3/4] docs(ladder): document the stable-usage estimate and test it through Allocate The UsageBaseline.StableUSDPerHour doc now names the cases where it stays nil (a short or volatile series, or no buffer fraction configured). A new test feeds a real GetUsageBaseline result to ladder.Allocate: a stable series gets a base layer allocation capped by the estimate, a volatile one gets none and the flex layer's rationale says stable usage is unknown. Closes #31 Co-Authored-By: Claude Sonnet 5.5 --- pkg/ladder/types.go | 3 +- providers/aws/ladder/ladder_test.go | 8 +-- providers/aws/ladder/stable_usage_test.go | 66 +++++++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) 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/ladder_test.go b/providers/aws/ladder/ladder_test.go index 868d3c96..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) diff --git a/providers/aws/ladder/stable_usage_test.go b/providers/aws/ladder/stable_usage_test.go index 25648f9c..3a7dbbf5 100644 --- a/providers/aws/ladder/stable_usage_test.go +++ b/providers/aws/ladder/stable_usage_test.go @@ -3,12 +3,17 @@ 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. @@ -178,3 +183,64 @@ func TestGetUsageBaseline_StableUsage(t *testing.T) { 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 gives the base layer a share capped at Stable", func(t *testing.T) { + t.Parallel() + res := allocateFromBaseline(t, rep(30, 10), 0.1) + var base *ladder.Allocation + for i := range res.Allocations { + if res.Allocations[i].Layer == ladder.LayerEC2InstanceSP { + base = &res.Allocations[i] + } + } + require.NotNil(t, base, "%+v", res) + assert.Equal(t, 1, base.OnDemandEquivalentUSDPerHour.Sign()) + assert.LessOrEqual(t, base.OnDemandEquivalentUSDPerHour.Cmp(big.NewRat(9, 1)), 0) + }) + 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") + }) +} From b7d625f1caa42ccec9434b6ea6fad4407bb47ae2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 9 Nov 2026 23:45:23 +0100 Subject: [PATCH 4/4] test(aws/ladder): make the stable-usage tests bind on the estimate The end-to-end test now uses a series where the stable estimate, not the core gap, bounds the base layer, so a doubled or inflated estimate fails it. The property test draws mostly steady series and asserts that most produce an estimate, and the invalid-input table covers a negative and an above-one buffer fraction. Part of #31 Co-Authored-By: Claude Sonnet 5.5 --- providers/aws/ladder/stable_usage_test.go | 31 +++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/providers/aws/ladder/stable_usage_test.go b/providers/aws/ladder/stable_usage_test.go index 3a7dbbf5..deaefebb 100644 --- a/providers/aws/ladder/stable_usage_test.go +++ b/providers/aws/ladder/stable_usage_test.go @@ -95,6 +95,8 @@ func TestEstimateStableUSDPerHour_InvalidInputsAreNil(t *testing.T) { {"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 { @@ -111,10 +113,11 @@ 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] = float64(rng.IntN(200)) / 10 + 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() @@ -129,11 +132,13 @@ func TestEstimateStableUSDPerHour_Property(t *testing.T) { 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. @@ -220,18 +225,22 @@ func allocateFromBaseline(t *testing.T, vals []float64, f float64) *ladder.Alloc func TestStableUsage_EndToEndAllocate(t *testing.T) { t.Parallel() - t.Run("stable series gives the base layer a share capped at Stable", func(t *testing.T) { + t.Run("stable series caps the base layer at Stable", func(t *testing.T) { t.Parallel() - res := allocateFromBaseline(t, rep(30, 10), 0.1) - var base *ladder.Allocation - for i := range res.Allocations { - if res.Allocations[i].Layer == ladder.LayerEC2InstanceSP { - base = &res.Allocations[i] - } + // 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.NotNil(t, base, "%+v", res) - assert.Equal(t, 1, base.OnDemandEquivalentUSDPerHour.Sign()) - assert.LessOrEqual(t, base.OnDemandEquivalentUSDPerHour.Cmp(big.NewRat(9, 1)), 0) + 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()