diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dca0ee..4d5f1cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- **BREAKING `ladder.Allocate` converts the on-demand-equivalent gap to + commitment dollars (go#349).** Tranches were bought 1:1 against an + on-demand-priced gap, over-buying by 1/rate (a 30% discount bought 43% too + much). `AllocationInput.InFlightUSDPerHour *float64` is replaced by + `InFlight []InFlightTranche` (commitment USD/hr plus the rate stored with + each tranche, netted as commitment/rate) and a required `CommitmentRates` + map (a partial map is fine: a layer that receives an allocation without a + rate is held, never bought 1:1; a rate outside (0, 1] or non-finite is an + error). `Allocation.GapUSDPerHour` is now commitment USD/hr, with + `OnDemandEquivalentUSDPerHour` and `Rate` alongside; the per-run cap applies + to commitment dollars. The base split now also counts in-flight base + commitment against the stable level. `BuildTranches` rejects an `Allocation` + without a rate in (0, 1] and stamps it on `Tranche.CommitmentRate`. The + amounts are exact; the purchase boundary must round commitment DOWN. + Tranches persisted before this change are in the old unit and carry no + rate. Consumer: the platform's `internal/server/handler_ladder.go` only. + - **BREAKING `ladder.TrancheResult.BuyNow` removed; `BuildTranches` now emits a `Tranche` for every ramp step, including `AfterDays == 0`** (its `FireAfter` equals `TrancheInput.Now`). The immediate step was returned as a `BuyNow` diff --git a/pkg/ladder/allocate.go b/pkg/ladder/allocate.go index d41635a..86e4c23 100644 --- a/pkg/ladder/allocate.go +++ b/pkg/ladder/allocate.go @@ -35,19 +35,27 @@ func minAllocatableGap() *big.Rat { // and causes Allocate to return an error (fail loud: incomplete provider data // aborts the run). // -// InFlightUSDPerHour is the hourly commitment already in flight for this -// config's scope: the sum of its scheduled (not-yet-fired) ladder tranches -// ONLY. Fired/completed tranches are deliberately excluded because they are -// executed purchases already reflected in each layer's ExistingUSDPerHour; -// counting them here as well would double-subtract and under-purchase. Its -// unit is the gap's: on-demand-equivalent USD/hr until the OD-to-commitment -// conversion lands in Allocate (go#349), commitment USD/hr after. It is -// required non-nil (fail loud) so callers must always explicitly account for -// in-flight commitment. A genuinely zero in-flight must be passed as a pointer -// to 0.0, not nil. Subtracting in-flight from the gap prevents new runs from -// re-planning commitment that is already scheduled, eliminating the pile-up -// that occurs when multiple daily runs each see the full gap before prior -// scheduled tranches fire. +// Units (go#349): Baseline, LayerStates and the gap are on-demand-equivalent +// (OD) USD/hr. InFlight and everything Allocate returns as GapUSDPerHour are +// commitment USD/hr (what is actually bought). CommitmentRates bridges them. +// +// InFlight is the commitment already in flight for this config's scope: its +// scheduled (not-yet-fired) ladder tranches ONLY, each with the rate stored +// when it was planned. Fired/completed tranches are deliberately excluded +// because they are executed purchases already reflected in each layer's +// ExistingUSDPerHour; counting them here as well would double-subtract and +// under-purchase. Allocate converts each tranche back to OD (commitment / +// rate) before netting, so an OD figure and a commitment figure are never +// 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. +// +// 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 +// may be partial: a layer that would receive an allocation but has no rate is +// held with an explanation (never bought 1:1, never re-routed to another +// layer). A present value outside (0, 1] or non-finite is an error. // // DataSources lists the data feeds (e.g. "cost-explorer", "cloudwatch") used // to derive the inputs. The slice is propagated verbatim to every Allocation @@ -56,24 +64,33 @@ func minAllocatableGap() *big.Rat { // // Now must be set by the caller; Allocate never calls time.Now internally. type AllocationInput struct { - Now time.Time - LayerStates map[LayerType]LayerState - Layers []LayerSpec - DataSources []string - Baseline UsageBaseline - Config LadderConfig - InFlightUSDPerHour *float64 -} - -// Allocation is a per-layer sizing decision produced by Allocate. The -// GapUSDPerHour field carries the positive hourly commitment delta this layer -// should grow by. Ramp and tranche logic (PR 3) converts Allocations into -// time-indexed purchase tranches. + Now time.Time + LayerStates map[LayerType]LayerState + Layers []LayerSpec + DataSources []string + Baseline UsageBaseline + Config LadderConfig + InFlight []InFlightTranche + CommitmentRates map[LayerType]float64 +} + +// Allocation is a per-layer sizing decision produced by Allocate. +// GapUSDPerHour is the positive hourly COMMITMENT delta this layer should grow +// by (what is bought and stored in tranches); it equals +// OnDemandEquivalentUSDPerHour * Rate, exactly. The purchase boundary must +// round it DOWN to the provider's commitment precision, never up. Ramp and +// tranche logic (BuildTranches) converts Allocations into time-indexed +// purchase tranches. type Allocation struct { Layer LayerType GapUSDPerHour *big.Rat - Rationale string - DataSources []string + // OnDemandEquivalentUSDPerHour is the on-demand-priced usage this + // commitment covers; the unit of target/existing/gap in plan displays. + OnDemandEquivalentUSDPerHour *big.Rat + // Rate is the commitment rate applied (commitment per OD dollar), in (0, 1]. + Rate *big.Rat + Rationale string + DataSources []string } // AllocateResult is the output of Allocate. @@ -115,9 +132,12 @@ func (r *AllocateResult) IsNoOp() bool { // 4. Compute net existing per layer; derive gap. // 5. Gap <= minimum threshold -> Hold with numbers in rationale, plus any // buffer maintenance from step 2. -// 6. Split gap across base, flex, and buffer roles. -// 7. Apply per-run spend cap (proportional scaling), then drop sub-minimum -// allocations with an informational hold each (never silently lost). +// 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). +// 7. Apply per-run spend cap (proportional scaling, in commitment dollars), +// then drop sub-minimum allocations with an informational hold each +// (never silently lost). // 8. Truncate to MaxActionsPerRun: sort purchases by GapUSDPerHour descending, // keep the largest, emit one ActionHold per dropped action for auditability // (reshapes are always retained first; no truncated money action is silently lost). @@ -140,9 +160,13 @@ func Allocate(in *AllocationInput) (*AllocateResult, error) { if err != nil { return nil, err } - inFlight, err := ratFromFloat("in_flight_usd_hr", *in.InFlightUSDPerHour) + rates, err := parseCommitmentRates(in.CommitmentRates) + if err != nil { + return nil, err + } + inFlightByLayer, inFlight, err := inFlightToOD(in.Layers, in.InFlight) if err != nil { - return nil, fmt.Errorf("in_flight_usd_hr: %w", err) + return nil, err } // gap = min(target-E, lowWater-E) - inFlight. // The two-arm min is defensive: it guards against future target derivations @@ -158,7 +182,7 @@ func Allocate(in *AllocationInput) (*AllocateResult, error) { 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, reshapes, maintHolds) + return buildResult(in, lowWater, target, existingTotal, gap, nets, inFlightByLayer, rates, reshapes, maintHolds) } // withMaintenance merges independently detected buffer maintenance actions @@ -205,8 +229,11 @@ func fmtRatUSD(r *big.Rat) string { // validateAllocateInput validates the config, layer list, layer states, and // the required in-flight commitment figure. Returns the first error encountered. func validateAllocateInput(in *AllocationInput) error { - if in.InFlightUSDPerHour == nil { - return fmt.Errorf("InFlightUSDPerHour must not be nil: callers must always supply the in-flight commitment; pass a pointer to 0.0 when there is no in-flight commitment") + if in.InFlight == nil { + return fmt.Errorf("InFlight must not be nil: callers must always supply the in-flight commitment; pass an empty slice when there is none") + } + if in.CommitmentRates == nil { + return fmt.Errorf("CommitmentRates must not be nil: callers must always supply commitment rates (a layer without one is held, never bought 1:1)") } if err := in.Config.Validate(); err != nil { return fmt.Errorf("config: %w", err) @@ -470,19 +497,21 @@ func gapBelowMinHold(gap, lowWater, target, existingTotal, inFlight *big.Rat, in // confirmed to exceed the minimum threshold. Buffer maintenance actions // (reshapes and their informational holds) are detected by the caller, // independently of the gap, and merged here. -func buildResult(in *AllocationInput, lowWater, target, existingTotal, gap *big.Rat, nets map[LayerType]*big.Rat, reshapes, maintHolds []PlannedAction) (*AllocateResult, error) { - allocs, err := splitGap(in, gap, nets, lowWater, target, existingTotal) +func buildResult(in *AllocationInput, lowWater, target, existingTotal, gap *big.Rat, nets, inFlight, rates map[LayerType]*big.Rat, reshapes, maintHolds []PlannedAction) (*AllocateResult, error) { + allocs, err := splitGap(in, gap, nets, inFlight, lowWater, target, existingTotal) if err != nil { return nil, err } + allocs, rateHolds := convertAllocations(allocs, rates, in.DataSources) allocs, err = applyCapGuardrail(allocs, in.Config.MaxHourlyCommitPerRun) if err != nil { return nil, err } allocs, skipHolds := dropSubMinimumAllocations(allocs, in.DataSources) allocs, reshapes, truncHolds := truncateToMaxActions(allocs, reshapes, in.Config.MaxActionsPerRun, in.LayerStates, in.DataSources) - holds := make([]PlannedAction, 0, len(maintHolds)+len(skipHolds)+len(truncHolds)) + holds := make([]PlannedAction, 0, len(maintHolds)+len(rateHolds)+len(skipHolds)+len(truncHolds)) holds = append(holds, maintHolds...) + holds = append(holds, rateHolds...) holds = append(holds, skipHolds...) holds = append(holds, truncHolds...) return &AllocateResult{ @@ -648,7 +677,7 @@ func layerUtilPctOrMax(states map[LayerType]LayerState, layer LayerType) float64 // nonexistent layer (fail loud: a purchase directive must always name a real // layer). BufferFraction == 0 with no buffer layer is valid: the buffer gap is // zero and nothing is allocated to it. -func splitGap(in *AllocationInput, gap *big.Rat, nets map[LayerType]*big.Rat, lowWater, target, existingTotal *big.Rat) ([]Allocation, error) { +func splitGap(in *AllocationInput, gap *big.Rat, nets, inFlight map[LayerType]*big.Rat, lowWater, target, existingTotal *big.Rat) ([]Allocation, error) { bufferFracRat, err := ratFromFloat("buffer_fraction", in.Config.BufferFraction) if err != nil { return nil, err @@ -663,7 +692,7 @@ func splitGap(in *AllocationInput, gap *big.Rat, nets map[LayerType]*big.Rat, lo return nil, fmt.Errorf("buffer_fraction %g > 0 but no buffer-role layer configured", in.Config.BufferFraction) } - baseGap, baseNote, err := computeBaseGap(coreGap, hasBase, in.Baseline, baseLayer, nets) + baseGap, baseNote, err := computeBaseGap(coreGap, hasBase, in.Baseline, baseLayer, nets, inFlight) if err != nil { return nil, err } @@ -699,7 +728,7 @@ type allocationContext struct { // everything base covers, at a slightly lower discount). The two conditions // produce distinct rationale notes so the approval email states the actual // reason. -func computeBaseGap(coreGap *big.Rat, hasBase bool, baseline UsageBaseline, baseLayer LayerType, nets map[LayerType]*big.Rat) (*big.Rat, string, error) { +func computeBaseGap(coreGap *big.Rat, hasBase bool, baseline UsageBaseline, baseLayer LayerType, nets, inFlight map[LayerType]*big.Rat) (*big.Rat, string, error) { if !hasBase { return new(big.Rat), "no base layer configured; routing all core gap to flex", nil } @@ -714,7 +743,12 @@ func computeBaseGap(coreGap *big.Rat, hasBase bool, baseline UsageBaseline, base if existingBaseNet == nil { existingBaseNet = new(big.Rat) } + // In-flight base tranches (OD) count toward the stable level too, or the + // base layer could be allocated past stable while earlier tranches wait. stableLeft := new(big.Rat).Sub(stableRat, existingBaseNet) + if f := inFlight[baseLayer]; f != nil { + stableLeft.Sub(stableLeft, f) + } if stableLeft.Sign() < 0 { stableLeft = new(big.Rat) // already at or above stable level } @@ -765,8 +799,9 @@ func flexRoutingNote(baseNote string, hasBase bool, baseGap *big.Rat) string { return "" } -// mkAllocation constructs a single Allocation with a self-explanatory rationale -// that embeds the concrete numbers used in the decision. +// mkAllocation constructs a single OD-sized Allocation (commitment amount and +// rate are set by convertAllocations) with a self-explanatory rationale that +// embeds the concrete numbers used in the decision. func mkAllocation(ctx *allocationContext, layer LayerType, layerGap *big.Rat, role, extraNote string) Allocation { detail := "" if extraNote != "" { @@ -786,15 +821,15 @@ func mkAllocation(ctx *allocationContext, layer LayerType, layerGap *big.Rat, ro detail, ) return Allocation{ - Layer: layer, - GapUSDPerHour: layerGap, - Rationale: rationale, - DataSources: ctx.dataSources, + Layer: layer, + OnDemandEquivalentUSDPerHour: layerGap, + Rationale: rationale, + DataSources: ctx.dataSources, } } // applyCapGuardrail scales all allocations proportionally when the total -// exceeds Config.MaxHourlyCommitPerRun. Returns the original slice unchanged +// commitment exceeds Config.MaxHourlyCommitPerRun (commitment USD/hr). Returns the original slice unchanged // when no cap is configured or the total is within the cap. func applyCapGuardrail(allocs []Allocation, maxPerRun *float64) ([]Allocation, error) { if maxPerRun == nil || len(allocs) == 0 { @@ -817,8 +852,10 @@ func applyCapGuardrail(allocs []Allocation, maxPerRun *float64) ([]Allocation, e for i, a := range allocs { scaledGap := new(big.Rat).Mul(a.GapUSDPerHour, scale) scaled[i] = Allocation{ - Layer: a.Layer, - GapUSDPerHour: scaledGap, + Layer: a.Layer, + GapUSDPerHour: scaledGap, + OnDemandEquivalentUSDPerHour: new(big.Rat).Mul(a.OnDemandEquivalentUSDPerHour, scale), + Rate: a.Rate, Rationale: a.Rationale + fmt.Sprintf("; capped by max_hourly_commit_per_run: scaled to %s (%.2f%% of requested)", fmtRatUSD(scaledGap), scalePct), DataSources: a.DataSources, diff --git a/pkg/ladder/allocate_test.go b/pkg/ladder/allocate_test.go index 3e81447..df0a230 100644 --- a/pkg/ladder/allocate_test.go +++ b/pkg/ladder/allocate_test.go @@ -116,6 +116,18 @@ func ratEq(t *testing.T, label string, got *big.Rat, num, den int64) { } } +// noInFlight is the explicit "nothing in flight" value (nil is an error). +func noInFlight() []InFlightTranche { return []InFlightTranche{} } + +// unitRates gives every layer rate 1 (no discount), under which the OD and +// commitment units coincide and the pre-go#349 expectations hold. +func unitRates() map[LayerType]float64 { + return map[LayerType]float64{ + LayerComputeSP: 1, LayerEC2InstanceSP: 1, LayerConvertibleRI: 1, + LayerAzureReservation: 1, LayerAzureSavingsPlan: 1, + } +} + func nowFixed() time.Time { return time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) } // --- tests --- @@ -129,7 +141,7 @@ func TestAllocate_CoveragePctDoesNotChangeAllocation(t *testing.T) { in := &AllocationInput{ Config: cfg, Layers: layers, LayerStates: states, Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, - Now: nowFixed(), InFlightUSDPerHour: ptr(0.0), + Now: nowFixed(), InFlight: noInFlight(), CommitmentRates: unitRates(), } want, err := Allocate(in) if err != nil { @@ -165,13 +177,13 @@ func TestAllocate_NilBaseline(t *testing.T) { t.Parallel() layers := awsLayers() in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{}, // LowWaterUSDPerHour is nil - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), - DataSources: []string{"cost-explorer"}, + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{}, // LowWaterUSDPerHour is nil + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), + DataSources: []string{"cost-explorer"}, } result, err := Allocate(in) if err != nil { @@ -202,12 +214,12 @@ func TestAllocate_GapBelowMin(t *testing.T) { // B = $10/hr, Ctgt = $10/hr (100% coverage), gap = 0 -> below min states = withExisting(states, LayerComputeSP, 10.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -240,12 +252,12 @@ func TestAllocate_ExistingAboveTarget_Hold(t *testing.T) { // existing = $15/hr on flex vs B = Ctgt = $10/hr -> gap = -5 states = withExisting(states, LayerComputeSP, 15.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -292,13 +304,13 @@ func TestAllocate_AWS3LayerSplit(t *testing.T) { states = withExisting(states, LayerEC2InstanceSP, 1.0) // $1/hr on base in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), - DataSources: []string{"cost-explorer"}, + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), + DataSources: []string{"cost-explorer"}, } result, err := Allocate(in) if err != nil { @@ -344,12 +356,12 @@ func TestAllocate_BufferFractionZero(t *testing.T) { cfg.BufferFraction = 0 // no buffer allocation layers := awsLayers() in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -373,12 +385,12 @@ func TestAllocate_StableUnknown_BaseZeroFlexGetsAll(t *testing.T) { cfg.BufferFraction = 0.5 // exactly representable layers := awsLayers() in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, // StableUSDPerHour nil - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, // StableUSDPerHour nil + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -418,12 +430,12 @@ func TestAllocate_NoBaseLayer_NoteNamesActualReason(t *testing.T) { cfg := validConfigAWS() cfg.BufferFraction = 0.5 in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -459,12 +471,12 @@ func TestAllocate_ExpiringExceedsExisting_Error(t *testing.T) { states = withExpiring(states, LayerConvertibleRI, 5.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -488,12 +500,12 @@ func TestAllocate_UtilizationClamp(t *testing.T) { states = withExisting(states, LayerComputeSP, 3.0) // E = $3/hr in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } // B=$10, Ctgt=$10, E=$3, gap=min(7,7)=$7 result, err := Allocate(in) @@ -522,12 +534,12 @@ func TestAllocate_AzureMergedBaseBuffer(t *testing.T) { cfg := validConfigAzure() cfg.BufferFraction = 0.5 // exactly representable in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -563,12 +575,12 @@ func TestAllocate_CapScaling(t *testing.T) { layers := awsLayers() in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -618,13 +630,13 @@ func TestAllocate_MaxActionsExceeded_Truncates(t *testing.T) { cfg.MaxActionsPerRun = 2 // 3 allocations would normally be produced layers := awsLayers() in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), - DataSources: []string{"cost-explorer"}, + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), + DataSources: []string{"cost-explorer"}, } result, err := Allocate(in) // Regression: pre-fix this returned an error; post-fix must succeed. @@ -708,13 +720,13 @@ func TestAllocate_MaxActions_ReshapeRetained(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 5.0) states = withBufferUtilization(states, 50.0) // below 90% threshold -> reshape in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), - DataSources: []string{"cost-explorer"}, + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), + DataSources: []string{"cost-explorer"}, } result, err := Allocate(in) if err != nil { @@ -764,13 +776,13 @@ func TestAllocate_MaxActions_ReshapesFillCap(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 5.0) states = withBufferUtilization(states, 50.0) // below 90% threshold -> reshape in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), - DataSources: []string{"cost-explorer"}, + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), + DataSources: []string{"cost-explorer"}, } result, err := Allocate(in) if err != nil { @@ -828,13 +840,13 @@ func TestAllocate_ExactCap_Untouched(t *testing.T) { cfg.MaxActionsPerRun = 3 // exactly the number of allocations produced layers := awsLayers() in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), - DataSources: []string{"cost-explorer"}, + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), + DataSources: []string{"cost-explorer"}, } result, err := Allocate(in) if err != nil { @@ -859,12 +871,12 @@ func TestAllocate_MissingLayerState(t *testing.T) { delete(states, LayerConvertibleRI) // remove buffer layer state in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -884,12 +896,12 @@ func TestAllocate_NilExistingUSDPerHour(t *testing.T) { states[LayerConvertibleRI] = s in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -909,12 +921,12 @@ func TestAllocate_NilExpiringUSDPerHour(t *testing.T) { states[LayerConvertibleRI] = s in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -932,12 +944,12 @@ func TestAllocate_NaNInput(t *testing.T) { states = withExisting(states, LayerConvertibleRI, math.NaN()) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -955,12 +967,12 @@ func TestAllocate_NegativeInput(t *testing.T) { states = withExisting(states, LayerComputeSP, -1.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -975,12 +987,12 @@ func TestAllocate_NegativeBaseline(t *testing.T) { t.Parallel() layers := awsLayers() in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(-5.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(-5.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1001,12 +1013,12 @@ func TestAllocate_ReshapeEmittedUnderThreshold(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 2.0) states = withBufferUtilization(states, 70.0) in := &AllocationInput{ - Config: validConfigAWS(), // threshold=90% - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), // threshold=90% + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1038,12 +1050,12 @@ func TestAllocate_NoReshapeAtThreshold(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 2.0) states = withBufferUtilization(states, DefaultBufferUtilizationThresholdPct) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1062,12 +1074,12 @@ func TestAllocate_NoReshapeAboveThreshold(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 2.0) states = withBufferUtilization(states, 95.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1086,12 +1098,12 @@ func TestAllocate_UtilizationNil_InformationalHold(t *testing.T) { // informational hold. (Empty buffer layers are skipped and emit nothing.) states = withExisting(states, LayerConvertibleRI, 2.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1120,12 +1132,12 @@ func TestAllocate_EmptyBufferLayer_NoNoise(t *testing.T) { states := zeroStates(layers) // buffer existing = 0 states = withBufferUtilization(states, 0.0) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1185,12 +1197,12 @@ func TestAllocate_InvalidConfig(t *testing.T) { cfg := validConfigAWS() cfg.TargetCoveragePct = 0 // invalid in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1201,12 +1213,12 @@ func TestAllocate_InvalidConfig(t *testing.T) { func TestAllocate_EmptyLayers(t *testing.T) { t.Parallel() in := &AllocationInput{ - Config: validConfigAWS(), - Layers: nil, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: map[LayerType]LayerState{}, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: nil, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: map[LayerType]LayerState{}, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1223,12 +1235,12 @@ func TestAllocate_NoFlexLayer(t *testing.T) { {Type: LayerConvertibleRI, Roles: []LayerRole{RoleBuffer}}, } in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1249,12 +1261,12 @@ func TestAllocate_TwoBaseLayers(t *testing.T) { {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, } in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1274,12 +1286,12 @@ func TestAllocate_TwoFlexLayers(t *testing.T) { {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleFlex}}, } in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1363,12 +1375,12 @@ func TestAllocate_LayerTopologyValidation(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { in := &AllocationInput{ - Config: validConfigAzure(), - Layers: c.layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(c.layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAzure(), + Layers: c.layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(c.layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if c.errContains == "" { @@ -1414,12 +1426,12 @@ func TestAllocate_UtilizationPctValidation(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 2.0) states = withBufferUtilization(states, c.pct) in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if c.wantErr { @@ -1451,12 +1463,12 @@ func TestAllocate_NoBufferLayerWithBufferFraction_Error(t *testing.T) { } cfg := validConfigAWS() // BufferFraction = 0.10 > 0 in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1482,12 +1494,12 @@ func TestAllocate_NoBufferLayerZeroFraction_OK(t *testing.T) { cfg := validConfigAWS() cfg.BufferFraction = 0 in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1521,12 +1533,12 @@ func TestAllocate_TargetMet_ReshapeStillEmitted(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 2.0) states = withBufferUtilization(states, 70.0) // below 90% threshold in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1556,12 +1568,12 @@ func TestAllocate_NilBaseline_ReshapeStillEmitted(t *testing.T) { states = withExisting(states, LayerConvertibleRI, 2.0) states = withBufferUtilization(states, 70.0) // below 90% threshold in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{}, // LowWaterUSDPerHour nil - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{}, // LowWaterUSDPerHour nil + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1598,12 +1610,12 @@ func TestAllocate_UnknownLayerStateKey_Error(t *testing.T) { ExpiringUSDPerHour: ptr(0.0), } in := &AllocationInput{ - Config: validConfigAWS(), - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, - LayerStates: states, - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } _, err := Allocate(in) if err == nil { @@ -1628,12 +1640,12 @@ func TestAllocate_SubMinimumBufferSkipped(t *testing.T) { cfg.BufferFraction = 0.0005 layers := awsLayers() in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1673,10 +1685,10 @@ func TestAllocate_AllSplitsSubMinimum(t *testing.T) { Layers: layers, // gap = $0.011 (just above the min-gap threshold); stable unknown so // base gets nothing and flex receives the whole core gap of $0.0099. - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(0.011)}, - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(0.011)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } result, err := Allocate(in) if err != nil { @@ -1709,12 +1721,12 @@ func TestAllocate_AzureMerged_StableUnknown_NoteOnFlexOnly(t *testing.T) { cfg := validConfigAzure() cfg.BufferFraction = 0.5 in := &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, // stable unknown - LayerStates: zeroStates(layers), - Now: nowFixed(), - InFlightUSDPerHour: ptr(0.0), + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, // stable unknown + LayerStates: zeroStates(layers), + Now: nowFixed(), + InFlight: noInFlight(), CommitmentRates: unitRates(), } // bufferGap=5, coreGap=5, baseGap=0 (stable unknown), flexGap=5, merged=5 result, err := Allocate(in) @@ -1764,13 +1776,14 @@ func TestAllocate_ConvergenceNetting(t *testing.T) { cfg := validConfigAWS() cfg.TargetCoveragePct = targetPct return &AllocationInput{ - Config: cfg, - Layers: layers, - Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(lowWater), StableUSDPerHour: ptr(lowWater * 0.9)}, - LayerStates: states, - Now: nowFixed(), - DataSources: []string{"cost-explorer"}, - InFlightUSDPerHour: &inFlight, + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(lowWater), StableUSDPerHour: ptr(lowWater * 0.9)}, + LayerStates: states, + Now: nowFixed(), + DataSources: []string{"cost-explorer"}, + InFlight: []InFlightTranche{{Layer: LayerComputeSP, CommitmentUSDPerHour: inFlight, Rate: 1}}, + CommitmentRates: unitRates(), } } @@ -1885,7 +1898,7 @@ func TestAllocateResult_IsNoOp(t *testing.T) { } // TestAllocate_NilInFlightRejected pins the fail-loud contract: a nil -// InFlightUSDPerHour is an error, never an implicit zero. A caller that forgot +// InFlight is an error, never an implicit zero. A caller that forgot // to fetch in-flight commitment would otherwise re-plan already-scheduled // commitment. func TestAllocate_NilInFlightRejected(t *testing.T) { @@ -1894,13 +1907,13 @@ func TestAllocate_NilInFlightRejected(t *testing.T) { in := &AllocationInput{ Config: validConfigAWS(), Layers: layers, LayerStates: zeroStates(layers), Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(8.0)}, - Now: nowFixed(), + Now: nowFixed(), CommitmentRates: unitRates(), } res, err := Allocate(in) if err == nil { - t.Fatalf("Allocate() = %+v, nil error; want error for nil InFlightUSDPerHour", res) + t.Fatalf("Allocate() = %+v, nil error; want error for nil InFlight", res) } - if !strings.Contains(err.Error(), "InFlightUSDPerHour must not be nil") { - t.Errorf("error %q does not name InFlightUSDPerHour", err) + if !strings.Contains(err.Error(), "InFlight must not be nil") { + t.Errorf("error %q does not name InFlight", err) } } diff --git a/pkg/ladder/allocate_units.go b/pkg/ladder/allocate_units.go new file mode 100644 index 0000000..dbad979 --- /dev/null +++ b/pkg/ladder/allocate_units.go @@ -0,0 +1,125 @@ +package ladder + +import ( + "fmt" + "math" + "math/big" +) + +// Unit rule (go#349): sizing is on-demand-equivalent (OD) USD/hr, everything +// that is bought or stored is commitment USD/hr. A commitment rate r in (0, 1] +// is the commitment dollars needed per OD dollar covered, so +// +// commitment = od * r od = commitment / r +// +// Allocate converts exactly once, after the gap is split across layers and +// before the per-run cap, the minimum-allocatable filter and BuildTranches. +// Conversion is exact big.Rat arithmetic; rounding to the provider's +// commitment precision happens at the purchase boundary and MUST round down so +// a purchase never exceeds the planned commitment. + +// InFlightTranche is a scheduled (not-yet-fired) ladder tranche in commitment +// USD/hr together with the rate that was stored with it when it was planned. +// Allocate converts it back to OD with that rate (od = commitment / Rate), so +// a tranche is netted at the rate it was sized with, not at today's. +type InFlightTranche struct { + Layer LayerType + CommitmentUSDPerHour float64 + // Rate is the commitment rate stored with the tranche. Required in (0, 1]; + // a tranche without a valid rate is a data error, never treated as 1:1. + Rate float64 +} + +// validateCommitmentRate converts a float rate to an exact rational and rejects +// anything outside (0, 1] (NaN, Inf, non-positive, or above 1: a commitment +// cheaper than nothing or dearer than on-demand is a data error). +func validateCommitmentRate(name string, v float64) (*big.Rat, error) { + if math.IsNaN(v) || math.IsInf(v, 0) { + return nil, fmt.Errorf("%s: commitment rate %g is not finite", name, v) + } + if v <= 0 || v > 1 { + return nil, fmt.Errorf("%s: commitment rate %g must be in (0, 1]", name, v) + } + return new(big.Rat).SetFloat64(v), nil +} + +// parseCommitmentRates validates every supplied rate. Absent layers are not an +// error here: a layer without a rate only matters if it receives an +// allocation (convertAllocations holds it). +func parseCommitmentRates(rates map[LayerType]float64) (map[LayerType]*big.Rat, error) { + out := make(map[LayerType]*big.Rat, len(rates)) + for layer, v := range rates { + r, err := validateCommitmentRate("commitment_rates["+string(layer)+"]", v) + if err != nil { + return nil, err + } + out[layer] = r + } + return out, nil +} + +// inFlightToOD converts the in-flight tranches to OD-equivalent USD/hr, per +// layer and in total. Every tranche must name a configured layer and carry a +// valid stored rate. +func inFlightToOD(layers []LayerSpec, inFlight []InFlightTranche) (map[LayerType]*big.Rat, *big.Rat, error) { + known := make(map[LayerType]bool, len(layers)) + for _, ls := range layers { + known[ls.Type] = true + } + perLayer := make(map[LayerType]*big.Rat, len(inFlight)) + total := new(big.Rat) + for i, t := range inFlight { + name := fmt.Sprintf("in_flight[%d] (%s)", i, t.Layer) + if !known[t.Layer] { + return nil, nil, fmt.Errorf("%s: layer is not in the supported layers; its commitment would be silently un-netted", name) + } + c, err := ratFromFloat(name+".commitment_usd_per_hour", t.CommitmentUSDPerHour) + if err != nil { + return nil, nil, err + } + r, err := validateCommitmentRate(name, t.Rate) + if err != nil { + return nil, nil, err + } + od := new(big.Rat).Quo(c, r) + if cur, ok := perLayer[t.Layer]; ok { + cur.Add(cur, od) + } else { + perLayer[t.Layer] = od + } + total.Add(total, od) + } + return perLayer, total, nil +} + +// convertAllocations turns OD-sized allocations into commitment-sized ones. +// An allocation on a layer with no rate is dropped with an explainable Hold: +// its share is not re-routed to another layer and no rate is ever guessed. +func convertAllocations(allocs []Allocation, rates map[LayerType]*big.Rat, dataSources []string) ([]Allocation, []PlannedAction) { + out := make([]Allocation, 0, len(allocs)) + var holds []PlannedAction + for _, a := range allocs { + r, ok := rates[a.Layer] + if !ok { + holds = append(holds, PlannedAction{ + Action: ActionHold, + Layer: a.Layer, + Rationale: fmt.Sprintf("no commitment rate for layer %s: skipped %s on-demand-equivalent (never assumed 1:1)", + a.Layer, fmtRatUSD(a.OnDemandEquivalentUSDPerHour)), + DataSources: dataSources, + }) + continue + } + a.Rate = new(big.Rat).Set(r) + a.GapUSDPerHour = new(big.Rat).Mul(a.OnDemandEquivalentUSDPerHour, r) + a.Rationale += fmt.Sprintf("; commitment_rate=%s, commitment=%s", fmtRatRate(r), fmtRatUSD(a.GapUSDPerHour)) + out = append(out, a) + } + return out, holds +} + +// fmtRatRate renders a commitment rate with 4 decimals for rationales. +func fmtRatRate(r *big.Rat) string { + f, _ := r.Float64() + return fmt.Sprintf("%.4f", f) +} diff --git a/pkg/ladder/allocate_units_test.go b/pkg/ladder/allocate_units_test.go new file mode 100644 index 0000000..8060a2e --- /dev/null +++ b/pkg/ladder/allocate_units_test.go @@ -0,0 +1,375 @@ +package ladder + +import ( + "math" + "math/big" + "math/rand" + "strings" + "testing" +) + +// unitsInput builds a flex-only style input: low water lw, no stable level +// (so the whole core gap goes to flex), buffer fraction bf. +func unitsInput(lw, bf float64, rates map[LayerType]float64, inFlight []InFlightTranche) *AllocationInput { + layers := awsLayers() + cfg := validConfigAWS() + cfg.BufferFraction = bf + return &AllocationInput{ + Config: cfg, Layers: layers, LayerStates: zeroStates(layers), + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(lw)}, + Now: nowFixed(), DataSources: []string{"cost-explorer"}, + InFlight: inFlight, CommitmentRates: rates, + } +} + +func allocByLayer(res *AllocateResult) map[LayerType]Allocation { + out := map[LayerType]Allocation{} + for _, a := range res.Allocations { + out[a.Layer] = a + } + return out +} + +func mustAllocate(t *testing.T, in *AllocationInput) *AllocateResult { + t.Helper() + res, err := Allocate(in) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + return res +} + +func approx(t *testing.T, label string, got *big.Rat, want float64) { + t.Helper() + f, _ := got.Float64() + if math.Abs(f-want) > 1e-9 { + t.Errorf("%s = %g, want %g", label, f, want) + } +} + +// TestAllocate_OverBuyReplay replays the go#349 over-buy with numbers: $10/hr +// of uncovered on-demand usage, no existing commitment, a 0.7 commitment rate +// (30% discount). Before the conversion Allocate returned GapUSDPerHour = 10 +// (commitment dollars bought 1:1, 43% too much); a second run with 7 in flight +// netted 10 - 7 = 3 and bought again. Now the commitment is 7 and the +// in-flight 7 (at its stored rate 0.7) nets the gap to zero. +func TestAllocate_OverBuyReplay(t *testing.T) { + t.Parallel() + rates := map[LayerType]float64{LayerComputeSP: 0.7} + res := mustAllocate(t, unitsInput(10, 0, rates, noInFlight())) + if len(res.Allocations) != 1 { + t.Fatalf("allocations = %+v, want 1", res.Allocations) + } + a := res.Allocations[0] + approx(t, "commitment", a.GapUSDPerHour, 7) + ratEq(t, "on-demand-equivalent", a.OnDemandEquivalentUSDPerHour, 10, 1) + + res2 := mustAllocate(t, unitsInput(10, 0, rates, []InFlightTranche{{Layer: LayerComputeSP, CommitmentUSDPerHour: 7, Rate: 0.7}})) + if !res2.IsNoOp() || len(res2.Holds) != 1 || !strings.Contains(res2.Holds[0].Rationale, "target already met") { + t.Fatalf("second run = %+v, want a target-met Hold (no re-buy)", res2) + } +} + +func TestAllocate_RateOneIsIdentity(t *testing.T) { + t.Parallel() + res := mustAllocate(t, unitsInput(10, 0, unitRates(), noInFlight())) + a := res.Allocations[0] + ratEq(t, "commitment", a.GapUSDPerHour, 10, 1) + ratEq(t, "od", a.OnDemandEquivalentUSDPerHour, 10, 1) +} + +func TestAllocate_InFlightNettedAtStoredRateNotCurrent(t *testing.T) { + t.Parallel() + // 3.5 commitment @ stored 0.5 = 7 OD; current rate 0.75; gap 10-7 = 3 OD. + inflight := []InFlightTranche{{Layer: LayerComputeSP, CommitmentUSDPerHour: 3.5, Rate: 0.5}} + res := mustAllocate(t, unitsInput(10, 0, map[LayerType]float64{LayerComputeSP: 0.75}, inflight)) + a := res.Allocations[0] + ratEq(t, "od", a.OnDemandEquivalentUSDPerHour, 3, 1) + ratEq(t, "commitment", a.GapUSDPerHour, 9, 4) +} + +func TestAllocate_ExpiringWithRate(t *testing.T) { + t.Parallel() + in := unitsInput(10, 0, map[LayerType]float64{LayerComputeSP: 0.5}, noInFlight()) + in.LayerStates = withExpiring(withExisting(in.LayerStates, LayerComputeSP, 4), LayerComputeSP, 2) + a := mustAllocate(t, in).Allocations[0] // E = 4-2 = 2, OD gap 8 + ratEq(t, "od", a.OnDemandEquivalentUSDPerHour, 8, 1) + ratEq(t, "commitment", a.GapUSDPerHour, 4, 1) +} + +// mixedInput reproduces TestAllocate_AWS3LayerSplit (OD split: base 3, buffer +// 9/2, flex 3/2) with a different exact rate per layer. +func mixedInput(cap *float64) *AllocationInput { + rates := map[LayerType]float64{LayerEC2InstanceSP: 0.5, LayerConvertibleRI: 0.25, LayerComputeSP: 0.75} + in := unitsInput(10, 0.5, rates, noInFlight()) + in.Baseline.StableUSDPerHour = ptr(4.0) + in.LayerStates = withExisting(in.LayerStates, LayerEC2InstanceSP, 1) + in.Config.MaxHourlyCommitPerRun = cap + return in +} + +func TestAllocate_MixedRatesSplitInODConvertPerLayer(t *testing.T) { + t.Parallel() + m := allocByLayer(mustAllocate(t, mixedInput(nil))) + ratEq(t, "base od", m[LayerEC2InstanceSP].OnDemandEquivalentUSDPerHour, 3, 1) + ratEq(t, "base c", m[LayerEC2InstanceSP].GapUSDPerHour, 3, 2) + ratEq(t, "buffer od", m[LayerConvertibleRI].OnDemandEquivalentUSDPerHour, 9, 2) + ratEq(t, "buffer c", m[LayerConvertibleRI].GapUSDPerHour, 9, 8) + ratEq(t, "flex od", m[LayerComputeSP].OnDemandEquivalentUSDPerHour, 3, 2) + ratEq(t, "flex c", m[LayerComputeSP].GapUSDPerHour, 9, 8) +} + +func TestAllocate_CapIsInCommitmentDollars(t *testing.T) { + t.Parallel() + // Uncapped commitment total = 3/2 + 9/8 + 9/8 = 15/4; cap 3 scales by 4/5. + res := mustAllocate(t, mixedInput(ptr(3.0))) + total := new(big.Rat) + for _, a := range res.Allocations { + total.Add(total, a.GapUSDPerHour) + wantOD := new(big.Rat).Quo(a.GapUSDPerHour, a.Rate) + if wantOD.Cmp(a.OnDemandEquivalentUSDPerHour) != 0 { + t.Errorf("%s: od %s != commitment/rate %s after cap", a.Layer, a.OnDemandEquivalentUSDPerHour, wantOD) + } + } + ratEq(t, "capped total", total, 3, 1) +} + +func TestAllocate_MissingRateHoldsOnlyThatLayer(t *testing.T) { + t.Parallel() + rates := map[LayerType]float64{LayerEC2InstanceSP: 0.5, LayerConvertibleRI: 0.25} // no flex + res := mustAllocate(t, mixedInputWithRates(rates)) + m := allocByLayer(res) + if _, ok := m[LayerComputeSP]; ok { + t.Fatalf("flex must not be allocated without a rate: %+v", res.Allocations) + } + ratEq(t, "base c unchanged, not re-routed", m[LayerEC2InstanceSP].GapUSDPerHour, 3, 2) + ratEq(t, "buffer c unchanged, not re-routed", m[LayerConvertibleRI].GapUSDPerHour, 9, 8) + var found bool + for _, h := range res.Holds { + if h.Layer == LayerComputeSP && strings.Contains(h.Rationale, "no commitment rate") && h.Action == ActionHold { + found = true + } + } + if !found { + t.Errorf("want a no-commitment-rate Hold for %s, got %+v", LayerComputeSP, res.Holds) + } +} + +func mixedInputWithRates(rates map[LayerType]float64) *AllocationInput { + in := mixedInput(nil) + in.CommitmentRates = rates + return in +} + +func TestAllocate_NoRateAtAllIsHoldNeverOneToOne(t *testing.T) { + t.Parallel() + res := mustAllocate(t, unitsInput(10, 0, map[LayerType]float64{}, noInFlight())) + if !res.IsNoOp() || len(res.Holds) != 1 || !strings.Contains(res.Holds[0].Rationale, "never assumed 1:1") { + t.Fatalf("result = %+v, want one no-rate Hold and no allocation", res) + } +} + +func TestAllocate_MissingRateOnIdleLayerIsFine(t *testing.T) { + t.Parallel() + // Only flex receives an allocation (no base stable level, no buffer). + res := mustAllocate(t, unitsInput(10, 0, map[LayerType]float64{LayerComputeSP: 0.5}, noInFlight())) + if len(res.Allocations) != 1 || len(res.Holds) != 0 { + t.Fatalf("result = %+v, want exactly one allocation and no holds", res) + } +} + +func TestAllocate_InvalidRatesAreErrors(t *testing.T) { + t.Parallel() + for _, v := range []float64{math.NaN(), math.Inf(1), 0, -0.5, 1.0000001, 2} { + in := unitsInput(10, 0, map[LayerType]float64{LayerComputeSP: v}, noInFlight()) + if res, err := Allocate(in); err == nil { + t.Errorf("rate %g: Allocate = %+v, want error", v, res) + } + } + // A bad rate on a layer that receives nothing is still a data error. + in := unitsInput(10, 0, map[LayerType]float64{LayerComputeSP: 0.5, LayerConvertibleRI: 7}, noInFlight()) + if _, err := Allocate(in); err == nil { + t.Error("invalid rate on an idle layer must still fail loud") + } +} + +func TestAllocate_NilRatesRejected(t *testing.T) { + t.Parallel() + _, err := Allocate(unitsInput(10, 0, nil, noInFlight())) + if err == nil || !strings.Contains(err.Error(), "CommitmentRates must not be nil") { + t.Fatalf("err = %v, want CommitmentRates nil rejection", err) + } +} + +func TestAllocate_InvalidInFlightIsError(t *testing.T) { + t.Parallel() + rates := map[LayerType]float64{LayerComputeSP: 0.5} + for name, tr := range map[string]InFlightTranche{ + "zero rate (unset)": {Layer: LayerComputeSP, CommitmentUSDPerHour: 1}, + "rate above 1": {Layer: LayerComputeSP, CommitmentUSDPerHour: 1, Rate: 1.5}, + "NaN rate": {Layer: LayerComputeSP, CommitmentUSDPerHour: 1, Rate: math.NaN()}, + "negative amount": {Layer: LayerComputeSP, CommitmentUSDPerHour: -1, Rate: 0.5}, + "NaN amount": {Layer: LayerComputeSP, CommitmentUSDPerHour: math.NaN(), Rate: 0.5}, + "unknown layer": {Layer: LayerAzureSavingsPlan, CommitmentUSDPerHour: 1, Rate: 0.5}, + } { + if res, err := Allocate(unitsInput(10, 0, rates, []InFlightTranche{tr})); err == nil { + t.Errorf("%s: Allocate = %+v, want error", name, res) + } + } +} + +// TestAllocate_BaseSplitCountsInFlightBase pins amendment 8: base in-flight +// counts toward the stable level, or base can overshoot stable while earlier +// base tranches wait to fire. +func TestAllocate_BaseSplitCountsInFlightBase(t *testing.T) { + t.Parallel() + rates := map[LayerType]float64{LayerEC2InstanceSP: 0.5, LayerComputeSP: 0.5} + // 1.5 commitment @ 0.5 = 3 OD in flight on base; stable 4 => 1 OD left. + inflight := []InFlightTranche{{Layer: LayerEC2InstanceSP, CommitmentUSDPerHour: 1.5, Rate: 0.5}} + in := unitsInput(10, 0, rates, inflight) + in.Baseline.StableUSDPerHour = ptr(4.0) + m := allocByLayer(mustAllocate(t, in)) + ratEq(t, "base od", m[LayerEC2InstanceSP].OnDemandEquivalentUSDPerHour, 1, 1) // old math: 4 + ratEq(t, "flex od", m[LayerComputeSP].OnDemandEquivalentUSDPerHour, 6, 1) // gap 10-3=7, minus base 1 +} + +func TestAllocate_SubMinimumAfterConversionIsDropped(t *testing.T) { + t.Parallel() + // OD gap 0.015 > 0.01 passes the target-met check; commitment 0.0075 < 0.01. + res := mustAllocate(t, unitsInput(0.015, 0, map[LayerType]float64{LayerComputeSP: 0.5}, noInFlight())) + if len(res.Allocations) != 0 || len(res.Holds) != 1 || !strings.Contains(res.Holds[0].Rationale, "below minimum") { + t.Fatalf("result = %+v, want the sub-minimum commitment dropped with a Hold", res) + } +} + +// TestAllocate_UnitProperties checks the exact-arithmetic invariants over a +// grid of random OD gaps, buffer fractions and rates (all dyadic so float +// inputs are exact rationals), then feeds the allocations to BuildTranches. +func TestAllocate_UnitProperties(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewSource(349)) + dyadic := func() float64 { return float64(1+rng.Intn(16)) / 16 } // (0, 1] + for i := 0; i < 300; i++ { + rates := map[LayerType]float64{LayerEC2InstanceSP: dyadic(), LayerComputeSP: dyadic(), LayerConvertibleRI: dyadic()} + in := unitsInput(float64(1+rng.Intn(4000))/16, float64(rng.Intn(4))/4, rates, noInFlight()) + in.Baseline.StableUSDPerHour = ptr(float64(rng.Intn(2000)) / 16) + res := mustAllocate(t, in) + sumOD := new(big.Rat) + for _, a := range res.Allocations { + r := new(big.Rat).SetFloat64(rates[a.Layer]) + want := new(big.Rat).Mul(a.OnDemandEquivalentUSDPerHour, r) + if a.GapUSDPerHour.Cmp(want) != 0 || a.Rate.Cmp(r) != 0 { + t.Fatalf("iter %d %s: c=%s, want od*r=%s", i, a.Layer, a.GapUSDPerHour, want) + } + if a.GapUSDPerHour.Cmp(a.OnDemandEquivalentUSDPerHour) > 0 { + t.Fatalf("iter %d %s: commitment %s exceeds od %s", i, a.Layer, a.GapUSDPerHour, a.OnDemandEquivalentUSDPerHour) + } + sumOD.Add(sumOD, new(big.Rat).Quo(a.GapUSDPerHour, a.Rate)) + } + assertTrancheConservation(t, i, in, res, sumOD) + } +} + +// assertTrancheConservation checks the exactly-once conversion: the tranche +// amounts sum to the allocation commitments, each tranche carries its rate, +// and converting back with the stored rates recovers the OD gap. +func assertTrancheConservation(t *testing.T, iter int, in *AllocationInput, res *AllocateResult, sumOD *big.Rat) { + t.Helper() + if len(res.Allocations) == 0 { + return + } + cfg := in.Config + cfg.Ramp = RampSchedule{Steps: []RampStep{{AfterDays: 0, Fraction: 0.5}, {AfterDays: 7, Fraction: 0.5}}} + tr, err := BuildTranches(&TrancheInput{ + Config: &cfg, RunID: "r", Term: Term1Year, PaymentOption: PaymentNoUpfront, + NewID: seqID("t"), Now: in.Now, Allocations: res.Allocations, + }) + if err != nil { + t.Fatalf("iter %d BuildTranches: %v", iter, err) + } + sumC, backOD := new(big.Rat), new(big.Rat) + for _, tranche := range tr.Tranches { + c, _ := new(big.Rat).SetString(tranche.AmountUSDPerHour) + r, ok := new(big.Rat).SetString(tranche.CommitmentRate) + if !ok { + t.Fatalf("iter %d: tranche %s has no commitment rate", iter, tranche.ID) + } + sumC.Add(sumC, c) + backOD.Add(backOD, new(big.Rat).Quo(c, r)) + } + wantC := new(big.Rat) + for _, a := range res.Allocations { + wantC.Add(wantC, a.GapUSDPerHour) + } + if sumC.Cmp(wantC) != 0 || backOD.Cmp(sumOD) != 0 { + t.Fatalf("iter %d: tranche sum %s (want %s), od round-trip %s (want %s)", iter, sumC, wantC, backOD, sumOD) + } +} + +// TestAllocate_LowerRateNeverRaisesCommitment: for a fixed OD gap, the +// commitment is monotone in r and at most the OD gap. +func TestAllocate_LowerRateNeverRaisesCommitment(t *testing.T) { + t.Parallel() + prev := new(big.Rat) + for k := 1; k <= 16; k++ { + rates := map[LayerType]float64{LayerComputeSP: float64(k) / 16} + c := mustAllocate(t, unitsInput(10, 0, rates, noInFlight())).Allocations[0].GapUSDPerHour + if c.Cmp(prev) <= 0 { + t.Fatalf("r=%d/16: commitment %s not above previous %s", k, c, prev) + } + prev = c + } + ratEq(t, "r=1 commitment", prev, 10, 1) +} + +func TestAllocate_CappedTotalNeverExceedsCap(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewSource(7)) + for i := 0; i < 100; i++ { + capUSD := float64(1+rng.Intn(64)) / 16 + res := mustAllocate(t, mixedInput(&capUSD)) + total := new(big.Rat) + for _, a := range res.Allocations { + total.Add(total, a.GapUSDPerHour) + } + if total.Cmp(new(big.Rat).SetFloat64(capUSD)) > 0 { + t.Fatalf("cap %g: total commitment %s exceeds cap", capUSD, total) + } + } +} + +func TestBuildTranches_RejectsUnconvertedAllocation(t *testing.T) { + t.Parallel() + for name, rate := range map[string]*big.Rat{ + "nil": nil, "zero": new(big.Rat), "negative": big.NewRat(-1, 2), "above one": big.NewRat(3, 2), + } { + a := mkAlloc(LayerComputeSP, 5) + a.Rate = rate + in := baseInput(baseConfig(threeStepRamp()), []Allocation{a}, nowFixed()) + _, err := BuildTranches(in) + if err == nil || !strings.Contains(err.Error(), "rate must be in (0, 1]") { + t.Errorf("%s: err = %v, want rate rejection", name, err) + } + } +} + +func TestTranche_ValidateCommitmentRate(t *testing.T) { + t.Parallel() + for rate, wantErr := range map[string]bool{ + "": false, "7/10": false, "1": false, "0.5": false, + "0": true, "-1/2": true, "11/10": true, "abc": true, + } { + tr := validTrancheForRate() + tr.CommitmentRate = rate + if err := tr.Validate(); (err != nil) != wantErr { + t.Errorf("rate %q: err = %v, wantErr %v", rate, err, wantErr) + } + } +} + +func validTrancheForRate() Tranche { + return Tranche{ + ID: "t1", RunID: "r1", FireAfter: nowFixed(), Status: TrancheStatusScheduled, + AmountUSDPerHour: "7", Layer: LayerComputeSP, Term: Term1Year, PaymentOption: PaymentNoUpfront, + } +} diff --git a/pkg/ladder/ladder.go b/pkg/ladder/ladder.go index b531e38..2e2eba7 100644 --- a/pkg/ladder/ladder.go +++ b/pkg/ladder/ladder.go @@ -119,6 +119,9 @@ func validateInputAllocations(allocs []Allocation) error { if a.Rationale == "" { return fmt.Errorf("allocation[%d]: rationale is required (money-path auditability)", i) } + if a.Rate == nil || a.Rate.Sign() <= 0 || a.Rate.Cmp(big.NewRat(1, 1)) > 0 { + return fmt.Errorf("allocation[%d]: rate must be in (0, 1] (an unconverted allocation must never be bought 1:1)", i) + } } return nil } @@ -262,6 +265,7 @@ func buildTranche(in *TrancheInput, alloc Allocation, step RampStep, stepIdx int Layer: alloc.Layer, Term: in.Term, PaymentOption: in.PaymentOption, + CommitmentRate: alloc.Rate.RatString(), } if err := tr.Validate(); err != nil { return Tranche{}, fmt.Errorf("tranche (layer %s, step %d): %w", alloc.Layer, stepIdx, err) diff --git a/pkg/ladder/ladder_test.go b/pkg/ladder/ladder_test.go index 8703386..bc02de8 100644 --- a/pkg/ladder/ladder_test.go +++ b/pkg/ladder/ladder_test.go @@ -77,6 +77,7 @@ func mkAlloc(layer LayerType, gapUSD int64) Allocation { return Allocation{ Layer: layer, GapUSDPerHour: new(big.Rat).SetInt64(gapUSD), + Rate: big.NewRat(1, 1), Rationale: fmt.Sprintf("test rationale for %s", layer), DataSources: []string{"test-source"}, } @@ -157,6 +158,7 @@ func TestBuildTranches_ThreeStepRamp(t *testing.T) { { Layer: LayerComputeSP, GapUSDPerHour: gap, + Rate: big.NewRat(1, 1), Rationale: "flex layer gap", DataSources: []string{"cost-explorer"}, }, @@ -218,6 +220,7 @@ func TestBuildTranches_FullyDelayedRamp(t *testing.T) { { Layer: LayerComputeSP, GapUSDPerHour: gap, + Rate: big.NewRat(1, 1), Rationale: "delayed ramp test", DataSources: []string{"cost-explorer"}, }, @@ -254,6 +257,7 @@ func TestBuildTranches_SingleStep(t *testing.T) { { Layer: LayerComputeSP, GapUSDPerHour: gap, + Rate: big.NewRat(1, 1), Rationale: "single step test", DataSources: []string{"cost-explorer"}, }, @@ -376,6 +380,7 @@ func TestBuildTranches_InexactFractionRemainder(t *testing.T) { { Layer: LayerComputeSP, GapUSDPerHour: gap, + Rate: big.NewRat(1, 1), Rationale: "inexact fraction test", DataSources: []string{"test"}, }, @@ -419,6 +424,7 @@ func TestBuildTranches_TinyGapExactness(t *testing.T) { { Layer: LayerComputeSP, GapUSDPerHour: gap, + Rate: big.NewRat(1, 1), Rationale: "tiny gap test", DataSources: []string{"test"}, }, @@ -766,6 +772,7 @@ func TestBuildTranches_EpsilonOvershootClamped(t *testing.T) { { Layer: LayerComputeSP, GapUSDPerHour: gap, + Rate: big.NewRat(1, 1), Rationale: "epsilon overshoot test", DataSources: []string{"test"}, }, diff --git a/pkg/ladder/plan.go b/pkg/ladder/plan.go index ede7b0c..463a4d2 100644 --- a/pkg/ladder/plan.go +++ b/pkg/ladder/plan.go @@ -16,7 +16,8 @@ import ( type PlannedAction struct { Action ActionType Layer LayerType - // AmountUSDPerHour is the hourly commitment delta. It must be non-nil and + // AmountUSDPerHour is the hourly commitment delta in commitment USD/hr + // (not on-demand-equivalent). It must be non-nil and // positive for ActionPurchase, and must be nil for ActionHold and // ActionReshape (whose financial impact is implicit in the underlying // exchange operation). diff --git a/pkg/ladder/store.go b/pkg/ladder/store.go index f0dfb9f..e9227c4 100644 --- a/pkg/ladder/store.go +++ b/pkg/ladder/store.go @@ -149,11 +149,10 @@ type Tranche struct { FireAfter time.Time // FiredAt is nil until the tranche actually fires. FiredAt *time.Time - // AmountUSDPerHour is the hourly commitment delta for this tranche step. - // Unit: the same unit as Allocation.GapUSDPerHour, which is on-demand- - // equivalent USD/hr until the OD-to-commitment conversion lands in - // Allocate (go#349); after that it is commitment USD/hr, and tranches - // persisted before it are in the old unit. + // AmountUSDPerHour is the hourly commitment delta for this tranche step, + // in commitment USD/hr (the unit of Allocation.GapUSDPerHour, and what the + // executor buys unchanged). Tranches persisted before go#349 are in the + // old on-demand-equivalent unit and carry no CommitmentRate. // Validation requires any string that big.Rat.SetString parses as a // positive rational (e.g. "3/2", "1.5"); producers should emit the // canonical big.Rat.RatString() form, which is lossless and round-trips @@ -176,7 +175,14 @@ type Tranche struct { // PaymentNoUpfront). Must pass PaymentOption.Validate, for the same // reason as Term. PaymentOption PaymentOption - StepIndex int + // CommitmentRate is the commitment rate (commitment per on-demand dollar, + // in (0, 1]) the amount was sized with, as a big.Rat string. Netting + // divides AmountUSDPerHour by it to get back to on-demand-equivalent. + // BuildTranches always sets it; empty only on rows persisted before + // go#349, which must not be netted or executed. When set it must parse + // into (0, 1]. + CommitmentRate string + StepIndex int } // Validate checks that the tranche is self-consistent: non-empty ID and @@ -231,6 +237,22 @@ func (t *Tranche) validateExecutionFields() error { if err := t.PaymentOption.Validate(); err != nil { return fmt.Errorf("payment_option: %w", err) } + return t.validateCommitmentRate() +} + +// validateCommitmentRate accepts an empty rate (legacy rows) and otherwise +// requires a rational in (0, 1]. +func (t *Tranche) validateCommitmentRate() error { + if t.CommitmentRate == "" { + return nil + } + r, ok := new(big.Rat).SetString(t.CommitmentRate) + if !ok { + return fmt.Errorf("commitment_rate %q must parse as a rational (big.Rat SetString)", t.CommitmentRate) + } + if r.Sign() <= 0 || r.Cmp(big.NewRat(1, 1)) > 0 { + return fmt.Errorf("commitment_rate %s must be in (0, 1]", t.CommitmentRate) + } return nil }