From 284a500115ce0494dfe14c3b5b5baec2e6a79a24 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 16 Oct 2026 15:04:35 +0100 Subject: [PATCH] fix(ladder): stop subtracting existing commitment from residual baselines The AWS ladder baseline counts only uncovered EC2 On Demand spend, but Allocate treated every baseline as total demand and subtracted existing commitment again. A $4/hr residual with a $6/hr Compute SP held as "target already met" (gap 4 - 6 = -2) while $4/hr was uncovered. Add UsageBaseline.Basis. On BaselineBasisResidual, Allocate sizes low_water * target% - in_flight (never above the residual) and does not subtract existing from the stable level for the base split. A partial target with existing commitment is held, since its gap needs covered usage the residual excludes. Total-basis behavior is unchanged; unknown basis values are an error. The AWS adoption follows once this pkg version is published. Refs #282 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++ pkg/ladder/allocate.go | 34 +++- pkg/ladder/allocate_residual.go | 118 +++++++++++++ pkg/ladder/allocate_residual_test.go | 239 +++++++++++++++++++++++++++ pkg/ladder/capability.go | 4 +- pkg/ladder/types.go | 5 + 6 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 pkg/ladder/allocate_residual.go create mode 100644 pkg/ladder/allocate_residual_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5f1cd2..8b3780b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- **`ladder.Allocate` no longer subtracts existing commitment from a residual + baseline a second time (#282).** New `UsageBaseline.Basis` + (`BaselineBasisTotal`, the default including the empty value, or + `BaselineBasisResidual`). A residual baseline counts only usage existing + commitments left uncovered, so Allocate sizes `low_water * target% - + in_flight` (never above the residual) instead of `low_water - existing`, + which held a $4/hr residual with a $6/hr Savings Plan as "target already + met". A residual baseline with a target below 100% and existing commitment + is held (the gap needs the covered usage the residual excludes). Expiring + commitment is not added back on that basis. Unknown basis values are an + error. Additive: total-basis results are unchanged. The AWS ladder adopts the + residual basis in a follow-up once this pkg version is published. + - **`azure/savingsplans`:** the commitment currency is no longer hardcoded to USD. `SavingsPlanDetails.Currency` (new, additive) is sent in the purchase and validate bodies and returned by `GetOfferingDetails`; empty or malformed diff --git a/pkg/ladder/allocate.go b/pkg/ladder/allocate.go index da2436fa..190dd6f4 100644 --- a/pkg/ladder/allocate.go +++ b/pkg/ladder/allocate.go @@ -49,7 +49,9 @@ func minAllocatableGap() *big.Rat { // mixed. It is required non-nil (fail loud): callers must always explicitly // account for in-flight commitment; pass an empty non-nil slice for none. // Subtracting in-flight from the gap prevents new runs from re-planning -// commitment that is already scheduled. +// commitment that is already scheduled. For a residual baseline InFlight +// must also cover active commitment not yet reflected in the observed series +// (see BaselineBasis). // // CommitmentRates holds, per layer, the commitment dollars needed per OD // dollar covered for a NEW purchase (in (0, 1]). It is required non-nil and @@ -129,9 +131,12 @@ func (r *AllocateResult) IsNoOp() bool { // mask it. Reshape detection needs only valid layer states. // 3. Nil baseline -> Hold (explainable no-op, not an error), plus any // buffer maintenance from step 2. -// 4. Compute net existing per layer; derive gap. +// 4. Compute net existing per layer; derive gap (existing is not subtracted +// from a residual baseline, which already excludes it; see BaselineBasis). // 5. Gap <= minimum threshold -> Hold with numbers in rationale, plus any // buffer maintenance from step 2. +// A residual baseline with a partial target and existing commitment is +// held (the gap needs the covered usage the residual excludes). // 6. Split the OD gap across base, flex, and buffer roles, then convert each // allocation to commitment dollars with its layer's rate (a layer without // a rate is held, never bought 1:1). @@ -168,7 +173,10 @@ func Allocate(in *AllocationInput) (*AllocateResult, error) { if err != nil { return nil, err } - // gap = min(target-E, lowWater-E) - inFlight. + // gap = min(target-E, lowWater-E) - inFlight, where E is zero for a + // residual baseline: its series already excludes existing coverage, so + // subtracting E again would under-buy and falsely report the target met + // (go#282; see BaselineBasis). // The two-arm min is defensive: it guards against future target derivations // exceeding the observed floor (today target<=lowWater always holds because // TargetCoveragePct is validated to be <=100). Subtracting inFlight nets @@ -177,12 +185,16 @@ func Allocate(in *AllocationInput) (*AllocateResult, error) { // Only scheduled (not-yet-fired) tranches are netted here; fired tranches // are already reflected in existingTotal (E), so netting them too would // double-count and under-commit. - preInflightGap := minRat(new(big.Rat).Sub(target, existingTotal), new(big.Rat).Sub(lowWater, existingTotal)) + gapExisting, gapNets := existingForBasis(in.Baseline.Basis, nets, existingTotal) + preInflightGap := minRat(new(big.Rat).Sub(target, gapExisting), new(big.Rat).Sub(lowWater, gapExisting)) gap := new(big.Rat).Sub(preInflightGap, inFlight) if gap.Cmp(minAllocatableGap()) <= 0 { return withMaintenance(gapBelowMinHold(gap, lowWater, target, existingTotal, inFlight, in), reshapes, maintHolds), nil } - return buildResult(in, lowWater, target, existingTotal, gap, nets, inFlightByLayer, rates, reshapes, maintHolds) + if hold := residualPartialTargetHold(in, lowWater, gap); hold != nil { + return withMaintenance(hold, reshapes, maintHolds), nil + } + return buildResult(in, lowWater, target, existingTotal, gap, gapNets, inFlightByLayer, rates, reshapes, maintHolds) } // withMaintenance merges independently detected buffer maintenance actions @@ -238,6 +250,9 @@ func validateAllocateInput(in *AllocationInput) error { if err := in.Config.Validate(); err != nil { return fmt.Errorf("config: %w", err) } + if err := in.Baseline.Basis.Validate(); err != nil { + return fmt.Errorf("baseline: %w", err) + } if len(in.Layers) == 0 { return fmt.Errorf("layers must not be empty") } @@ -477,9 +492,9 @@ func baselineUnavailableHold(in *AllocationInput) *AllocateResult { func gapBelowMinHold(gap, lowWater, target, existingTotal, inFlight *big.Rat, in *AllocationInput) *AllocateResult { flexLayer, _ := findLayerForRole(in.Layers, RoleFlex) rationale := fmt.Sprintf( - "target already met: low_water=%s, target=%.2f%%, existing=%s, in_flight=%s, target_od=%s, gap=%s <= min_allocatable=%s", + "target already met: low_water=%s, target=%.2f%%, existing=%s, in_flight=%s, target_od=%s, gap=%s <= min_allocatable=%s%s", fmtRatUSD(lowWater), in.Config.TargetCoveragePct, fmtRatUSD(existingTotal), fmtRatUSD(inFlight), - fmtRatUSD(target), fmtRatUSD(gap), fmtRatUSD(minAllocatableGap()), + fmtRatUSD(target), fmtRatUSD(gap), fmtRatUSD(minAllocatableGap()), basisNote(in.Baseline.Basis), ) return &AllocateResult{ Holds: []PlannedAction{ @@ -704,6 +719,7 @@ func splitGap(in *AllocationInput, gap *big.Rat, nets, inFlight map[LayerType]*b existing: existingTotal, gap: gap, baseNote: baseNote, + basisNote: basisNote(in.Baseline.Basis), dataSources: in.DataSources, cfg: in.Config, } @@ -718,6 +734,7 @@ type allocationContext struct { existing *big.Rat gap *big.Rat baseNote string + basisNote string dataSources []string cfg LadderConfig } @@ -808,7 +825,7 @@ func mkAllocation(ctx *allocationContext, layer LayerType, layerGap *big.Rat, ro detail = "; " + extraNote } rationale := fmt.Sprintf( - "%s layer: low_water=%s, target=%.2f%%, existing=%s, target_od=%s, total_gap=%s, %s_gap=%s, buffer_fraction=%.2f%s", + "%s layer: low_water=%s, target=%.2f%%, existing=%s, target_od=%s, total_gap=%s, %s_gap=%s, buffer_fraction=%.2f%s%s", role, fmtRatUSD(ctx.lowWater), ctx.cfg.TargetCoveragePct, @@ -819,6 +836,7 @@ func mkAllocation(ctx *allocationContext, layer LayerType, layerGap *big.Rat, ro fmtRatUSD(layerGap), ctx.cfg.BufferFraction, detail, + ctx.basisNote, ) return Allocation{ Layer: layer, diff --git a/pkg/ladder/allocate_residual.go b/pkg/ladder/allocate_residual.go new file mode 100644 index 00000000..e687dd0d --- /dev/null +++ b/pkg/ladder/allocate_residual.go @@ -0,0 +1,118 @@ +package ladder + +import ( + "fmt" + "math/big" +) + +// BaselineBasis says what a UsageBaseline series measures (go#282). +// +// A total baseline (BaselineBasisTotal, or the empty legacy value) is all +// eligible on-demand-equivalent demand, covered or not; Allocate nets the +// existing commitment out of it: gap = min(target, low_water) - existing. +// +// A residual baseline (BaselineBasisResidual) is only the usage that existing +// commitments left uncovered, so existing coverage is already excluded. +// Subtracting existing commitment from it again under-buys and can claim +// "target already met" while usage is still uncovered. For a residual +// baseline Allocate sizes: +// +// gap = low_water * target_pct/100 - in_flight +// +// which never exceeds the observed residual. With target_pct = 100 this is the +// exact remaining gap. Below 100 the true gap is pct*(C+R) - C, where C is the +// covered usage the residual excludes; it is at most pct*R, but it cannot be +// sized without C, so when any layer holds existing commitment the run is held +// rather than guessed (see residualPartialTargetHold). With no existing +// commitment in any layer the residual and total bases coincide. +// +// Expiring commitment is not added back for a residual baseline: its coverage +// is still in effect during the observed window, and converting native +// commitment dollars to the usage it covers needs rates the engine does not +// have. Usage that becomes uncovered after expiry shows up in later residual +// observations (a later top-up, never an over-buy). +// +// InFlight contract for a residual baseline: the series only reflects +// commitments whose coverage was already observed. Callers must include in +// InFlight every commitment that is active but not yet reflected in the +// series (for example one bought after the last observed day, given +// cost-data reporting lag), or Allocate re-plans it. +type BaselineBasis string + +const ( + // BaselineBasisTotal marks a series of total eligible on-demand-equivalent + // demand (covered plus uncovered). The empty value means the same. + BaselineBasisTotal BaselineBasis = "total" + // BaselineBasisResidual marks a series of usage left uncovered by existing + // commitments (already net of them). + BaselineBasisResidual BaselineBasis = "residual" +) + +// Validate rejects an unrecognized basis: guessing one would either +// double-subtract existing commitment or skip netting it. +func (b BaselineBasis) Validate() error { + switch b { + case "", BaselineBasisTotal, BaselineBasisResidual: + return nil + default: + return fmt.Errorf("baseline basis %q is not recognized (want %q or %q)", b, BaselineBasisTotal, BaselineBasisResidual) + } +} + +func (b BaselineBasis) isResidual() bool { return b == BaselineBasisResidual } + +// existingForBasis returns the existing commitment to subtract from the +// baseline and the per-layer nets the base split counts against the stable +// level. A residual baseline already excludes existing coverage, so both are +// zero for it. +func existingForBasis(basis BaselineBasis, nets map[LayerType]*big.Rat, existingTotal *big.Rat) (gapExisting *big.Rat, gapNets map[LayerType]*big.Rat) { + if !basis.isResidual() { + return existingTotal, nets + } + return new(big.Rat), map[LayerType]*big.Rat{} +} + +// residualPartialTargetHold returns a Hold when a residual baseline is paired +// with a target below 100% while some layer holds existing commitment: the +// partial target depends on the covered usage the residual excludes, so the +// gap cannot be sized from the baseline. gap is the pct*R - in_flight upper +// bound already known to exceed the minimum (a smaller bound is reported as +// "target already met" before this is called). Returns nil when the residual +// gap is exact. +func residualPartialTargetHold(in *AllocationInput, lowWater, gap *big.Rat) *AllocateResult { + if !in.Baseline.Basis.isResidual() || in.Config.TargetCoveragePct >= 100 || !hasExistingCommitment(in.Layers, in.LayerStates) { + return nil + } + flexLayer, _ := findLayerForRole(in.Layers, RoleFlex) + rationale := fmt.Sprintf( + "residual baseline cannot size a partial target: target=%.2f%% < 100%% with existing commitment; the gap depends on covered usage the residual excludes (upper bound %s from low_water=%s), so nothing is bought; use target 100%% or a total-demand baseline", + in.Config.TargetCoveragePct, fmtRatUSD(gap), fmtRatUSD(lowWater), + ) + return &AllocateResult{Holds: []PlannedAction{{ + Action: ActionHold, + Layer: flexLayer, + Rationale: rationale, + DataSources: in.DataSources, + }}} +} + +// hasExistingCommitment reports whether any layer holds existing commitment +// (expiring or not: until it expires its coverage is excluded from a residual +// baseline). States are validated non-nil before this runs. +func hasExistingCommitment(layers []LayerSpec, states map[LayerType]LayerState) bool { + for _, ls := range layers { + if *states[ls.Type].ExistingUSDPerHour > 0 { + return true + } + } + return false +} + +// basisNote is appended to rationales so a residual-basis decision is +// explainable: "existing" is shown but was not subtracted. +func basisNote(basis BaselineBasis) string { + if !basis.isResidual() { + return "" + } + return "; baseline_basis=residual (existing commitment already excluded from low_water, not subtracted again)" +} diff --git a/pkg/ladder/allocate_residual_test.go b/pkg/ladder/allocate_residual_test.go new file mode 100644 index 00000000..84c834e0 --- /dev/null +++ b/pkg/ladder/allocate_residual_test.go @@ -0,0 +1,239 @@ +package ladder + +import ( + "math/big" + "math/rand" + "strings" + "testing" +) + +// residualInput builds an AWS-shaped input (EC2 Instance SP base, Compute SP +// flex, Convertible RI buffer) with no buffer, no stable level, target pct and +// a low-water lw on the given basis. states defaults to all-zero layers. +func residualInput(basis BaselineBasis, lw, pct float64, states map[LayerType]LayerState, rates map[LayerType]float64, inFlight []InFlightTranche) *AllocationInput { + layers := awsLayers() + cfg := validConfigAWS() + cfg.BufferFraction = 0 + cfg.TargetCoveragePct = pct + if states == nil { + states = zeroStates(layers) + } + return &AllocationInput{ + Config: cfg, Layers: layers, LayerStates: states, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(lw), Basis: basis}, + Now: nowFixed(), DataSources: []string{"cost-explorer"}, + InFlight: inFlight, CommitmentRates: rates, + } +} + +func computeSPRate(r float64) map[LayerType]float64 { + return map[LayerType]float64{LayerComputeSP: r, LayerEC2InstanceSP: r, LayerConvertibleRI: r} +} + +func odTotal(res *AllocateResult) *big.Rat { + sum := new(big.Rat) + for _, a := range res.Allocations { + sum.Add(sum, a.OnDemandEquivalentUSDPerHour) + } + return sum +} + +func commitmentTotal(res *AllocateResult) *big.Rat { + sum := new(big.Rat) + for _, a := range res.Allocations { + sum.Add(sum, a.GapUSDPerHour) + } + return sum +} + +func requireSingleHold(t *testing.T, res *AllocateResult, want string) { + t.Helper() + if !res.IsNoOp() || len(res.Holds) != 1 || !strings.Contains(res.Holds[0].Rationale, want) { + t.Fatalf("result = %+v, want exactly one Hold containing %q and no purchases", res, want) + } +} + +// TestAllocate_ResidualBaseline_Issue282Replay replays the go#282 numbers: +// target 100%, buffer 0, no in-flight, a $6/hr existing Compute SP and a +// $4/hr residual (uncovered) on-demand low water. On the total basis (the only +// behavior before the fix) the engine computes 4 - 6 = -2 and holds with +// "target already met" although $4/hr is uncovered. On the residual basis it +// sizes the $4/hr residual (commitment 4 * 0.7 = 2.8) and never more. +func TestAllocate_ResidualBaseline_Issue282Replay(t *testing.T) { + t.Parallel() + states := withExisting(zeroStates(awsLayers()), LayerComputeSP, 6) + + before := mustAllocate(t, residualInput(BaselineBasisTotal, 4, 100, states, computeSPRate(0.7), noInFlight())) + requireSingleHold(t, before, "gap=$-2.0000/hr") + + res := mustAllocate(t, residualInput(BaselineBasisResidual, 4, 100, states, computeSPRate(0.7), noInFlight())) + if len(res.Allocations) != 1 || res.Allocations[0].Layer != LayerComputeSP { + t.Fatalf("allocations = %+v, want one compute-sp allocation", res.Allocations) + } + a := res.Allocations[0] + ratEq(t, "od", a.OnDemandEquivalentUSDPerHour, 4, 1) + approx(t, "commitment", a.GapUSDPerHour, 2.8) + if !strings.Contains(a.Rationale, "baseline_basis=residual") || !strings.Contains(a.Rationale, "existing=$6.0000/hr") { + t.Errorf("rationale %q must show existing and the residual basis", a.Rationale) + } +} + +// TestAllocate_ResidualBaseline_ZeroExistingMatchesTotal: with no existing +// commitment in any layer both bases agree (residual == total demand), at a +// full and a partial target: R=10 -> 10; R=10 at 80% -> 8. +func TestAllocate_ResidualBaseline_ZeroExistingMatchesTotal(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + pct float64 + wantOD int64 + }{{100, 10}, {80, 8}} { + for _, basis := range []BaselineBasis{"", BaselineBasisTotal, BaselineBasisResidual} { + res := mustAllocate(t, residualInput(basis, 10, tc.pct, nil, unitRates(), noInFlight())) + ratEq(t, string(basis)+" od", odTotal(res), tc.wantOD, 1) + } + } +} + +// TestAllocate_ResidualBaseline_FullyCoveredHolds: existing >= demand leaves a +// zero residual: $6/hr Compute SP, R=0 -> target met. Around the $0.01 +// minimum: R=0.005 holds (gap <= min), R=0.02 buys exactly 0.02. +func TestAllocate_ResidualBaseline_FullyCoveredHolds(t *testing.T) { + t.Parallel() + states := withExisting(zeroStates(awsLayers()), LayerComputeSP, 6) + requireSingleHold(t, mustAllocate(t, residualInput(BaselineBasisResidual, 0, 100, states, unitRates(), noInFlight())), "target already met") + requireSingleHold(t, mustAllocate(t, residualInput(BaselineBasisResidual, 0.005, 100, states, unitRates(), noInFlight())), "baseline_basis=residual") + res := mustAllocate(t, residualInput(BaselineBasisResidual, 0.02, 100, states, unitRates(), noInFlight())) + approx(t, "od", odTotal(res), 0.02) +} + +// TestAllocate_ResidualBaseline_PartialTargetWithExistingHolds: at 80% the true +// gap is 0.8*(C+4) - C, which depends on the covered usage C the residual +// excludes, so nothing is bought (upper bound 0.8*4 = 3.2 is reported). When +// even that upper bound is below the minimum (R=0.01: 0.008) it is a plain +// target-met Hold. +func TestAllocate_ResidualBaseline_PartialTargetWithExistingHolds(t *testing.T) { + t.Parallel() + states := withExisting(zeroStates(awsLayers()), LayerComputeSP, 6) + res := mustAllocate(t, residualInput(BaselineBasisResidual, 4, 80, states, unitRates(), noInFlight())) + requireSingleHold(t, res, "residual baseline cannot size a partial target") + if !strings.Contains(res.Holds[0].Rationale, "upper bound $3.2000/hr") { + t.Errorf("rationale %q must report the 3.2 upper bound", res.Holds[0].Rationale) + } + requireSingleHold(t, mustAllocate(t, residualInput(BaselineBasisResidual, 0.01, 80, states, unitRates(), noInFlight())), "target already met") +} + +// TestAllocate_ResidualBaseline_InFlightNetted: R=4 with $6/hr existing. +// 2.8 in flight at 0.7 is 4 OD -> hold; 1.4 at 0.7 is 2 OD -> buy OD 2 +// (commitment 1.4). +func TestAllocate_ResidualBaseline_InFlightNetted(t *testing.T) { + t.Parallel() + states := withExisting(zeroStates(awsLayers()), LayerComputeSP, 6) + full := []InFlightTranche{{Layer: LayerComputeSP, CommitmentUSDPerHour: 2.8, Rate: 0.7}} + requireSingleHold(t, mustAllocate(t, residualInput(BaselineBasisResidual, 4, 100, states, computeSPRate(0.7), full)), "target already met") + + half := []InFlightTranche{{Layer: LayerComputeSP, CommitmentUSDPerHour: 1.4, Rate: 0.7}} + res := mustAllocate(t, residualInput(BaselineBasisResidual, 4, 100, states, computeSPRate(0.7), half)) + approx(t, "od", odTotal(res), 2) + approx(t, "commitment", commitmentTotal(res), 1.4) +} + +// TestAllocate_ResidualBaseline_ExpiringNotAddedBack: $6/hr existing of which +// $2/hr expires. Total basis: 4 - (6-2) = 0 -> hold. Residual: 4, not 4+2: +// expiring coverage is still in effect in the observed window. +func TestAllocate_ResidualBaseline_ExpiringNotAddedBack(t *testing.T) { + t.Parallel() + states := withExisting(zeroStates(awsLayers()), LayerComputeSP, 6) + st := states[LayerComputeSP] + st.ExpiringUSDPerHour = ptr(2.0) + states[LayerComputeSP] = st + + requireSingleHold(t, mustAllocate(t, residualInput(BaselineBasisTotal, 4, 100, states, unitRates(), noInFlight())), "target already met") + ratEq(t, "residual od", odTotal(mustAllocate(t, residualInput(BaselineBasisResidual, 4, 100, states, unitRates(), noInFlight()))), 4, 1) +} + +// TestAllocate_ResidualBaseline_BaseSplitIgnoresExistingBase: stable residual +// 5, existing base (EC2 Instance SP) 3, R=10, buffer 10%. The residual stable +// level already excludes the existing base, so base gets min(9, 5) = 5 (the +// total basis would give 5-3 = 2), flex 4, buffer 1; total OD stays R = 10. +func TestAllocate_ResidualBaseline_BaseSplitIgnoresExistingBase(t *testing.T) { + t.Parallel() + states := withExisting(zeroStates(awsLayers()), LayerEC2InstanceSP, 3) + in := residualInput(BaselineBasisResidual, 10, 100, states, unitRates(), noInFlight()) + in.Config.BufferFraction = 0.10 + in.Baseline.StableUSDPerHour = ptr(5.0) + res := mustAllocate(t, in) + by := allocByLayer(res) + approx(t, "base", by[LayerEC2InstanceSP].OnDemandEquivalentUSDPerHour, 5) + approx(t, "flex", by[LayerComputeSP].OnDemandEquivalentUSDPerHour, 4) + approx(t, "buffer", by[LayerConvertibleRI].OnDemandEquivalentUSDPerHour, 1) + approx(t, "total", odTotal(res), 10) +} + +func TestAllocate_UnknownBaselineBasisIsError(t *testing.T) { + t.Parallel() + if _, err := Allocate(residualInput("uncovered", 4, 100, nil, unitRates(), noInFlight())); err == nil || !strings.Contains(err.Error(), "baseline basis") { + t.Fatalf("err = %v, want an unrecognized baseline basis error", err) + } +} + +// TestAllocate_ResidualBaseline_NeverExceedsResidual is a seeded property +// check over random existing/expiring/in-flight/rates/buffer/cap: on the +// residual basis the OD bought never exceeds R - in_flight_od (so never the +// true residual R), commitment never exceeds OD, and a partial target with +// existing commitment never buys. +func TestAllocate_ResidualBaseline_NeverExceedsResidual(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewSource(282)) //nolint:gosec // deterministic test data + for i := 0; i < 2000; i++ { + in, residual, inFlightOD := randomResidualInput(rng) + res := mustAllocate(t, in) + od := odTotal(res) + bound := new(big.Rat).Sub(residual, inFlightOD) + if bound.Sign() < 0 { + bound = new(big.Rat) + } + if od.Cmp(bound) > 0 || od.Cmp(residual) > 0 { + t.Fatalf("case %d: bought OD %s > residual %s - in_flight %s", i, od.RatString(), residual.RatString(), inFlightOD.RatString()) + } + if commitmentTotal(res).Cmp(od) > 0 { + t.Fatalf("case %d: commitment %s > OD %s", i, commitmentTotal(res).RatString(), od.RatString()) + } + if in.Config.TargetCoveragePct < 100 && hasExistingCommitment(in.Layers, in.LayerStates) && len(res.Allocations) > 0 { + t.Fatalf("case %d: partial target with existing commitment bought %+v", i, res.Allocations) + } + } +} + +func randomResidualInput(rng *rand.Rand) (in *AllocationInput, residual, inFlightOD *big.Rat) { + layers := awsLayers() + states := zeroStates(layers) + for _, ls := range layers { + if rng.Intn(2) == 0 { + continue + } + e := float64(rng.Intn(2000)) / 100 + x := e * float64(rng.Intn(3)) / 2 + states[ls.Type] = LayerState{Layer: ls.Type, ExistingUSDPerHour: ptr(e), ExpiringUSDPerHour: ptr(x)} + } + rate := float64(rng.Intn(100)+1) / 100 + lw := float64(rng.Intn(2000)) / 100 + pct := 100.0 + if rng.Intn(3) == 0 { + pct = float64(rng.Intn(99) + 1) + } + var inFlight []InFlightTranche + inFlightOD = new(big.Rat) + if rng.Intn(2) == 0 { + c := float64(rng.Intn(500)) / 100 + inFlight = append(inFlight, InFlightTranche{Layer: LayerComputeSP, CommitmentUSDPerHour: c, Rate: rate}) + inFlightOD = new(big.Rat).Quo(new(big.Rat).SetFloat64(c), new(big.Rat).SetFloat64(rate)) + } else { + inFlight = noInFlight() + } + in = residualInput(BaselineBasisResidual, lw, pct, states, computeSPRate(rate), inFlight) + in.Config.BufferFraction = float64(rng.Intn(5)) / 10 + if rng.Intn(4) == 0 { + in.Config.MaxHourlyCommitPerRun = ptr(float64(rng.Intn(500)+1) / 100) + } + return in, new(big.Rat).SetFloat64(lw), inFlightOD +} diff --git a/pkg/ladder/capability.go b/pkg/ladder/capability.go index 769a5130..71a4195c 100644 --- a/pkg/ladder/capability.go +++ b/pkg/ladder/capability.go @@ -44,7 +44,9 @@ type LadderCapability interface { GetLayerStates(ctx context.Context, scope Scope) (map[LayerType]LayerState, error) // GetUsageBaseline computes a statistical baseline from historical - // on-demand usage over the given lookback window and percentile. + // on-demand usage over the given lookback window and percentile. A + // provider whose series counts only usage left uncovered by existing + // commitments must set Basis to BaselineBasisResidual. GetUsageBaseline(ctx context.Context, scope Scope, lookbackDays int, percentile float64) (UsageBaseline, error) // PurchaseLayer buys commitments for the given layer using the provided diff --git a/pkg/ladder/types.go b/pkg/ladder/types.go index ccc8446f..4dcfe317 100644 --- a/pkg/ladder/types.go +++ b/pkg/ladder/types.go @@ -455,6 +455,11 @@ type UsageBaseline struct { // StableUSDPerHour is the estimated stable portion after applying the // buffer fraction to LowWaterUSDPerHour. StableUSDPerHour *float64 + // Basis says what the series measures: total demand (the default, empty) + // or residual uncovered usage already net of existing commitments. See + // BaselineBasis; Allocate never subtracts existing commitment from a + // residual baseline a second time (go#282). + Basis BaselineBasis // Series holds the raw hourly values used for computation. It is optional // and may be nil when only the summary statistics are needed (e.g., when // populating the approval email body).