diff --git a/pkg/ladder/allocate.go b/pkg/ladder/allocate.go new file mode 100644 index 000000000..116837d8e --- /dev/null +++ b/pkg/ladder/allocate.go @@ -0,0 +1,714 @@ +package ladder + +import ( + "fmt" + "math" + "math/big" + "slices" + "time" +) + +// minAllocatableGapNum and minAllocatableGapDen define the smallest gap that +// triggers a new allocation as the exact rational 1/100 ($0.01/hr +// on-demand-equivalent). Gaps at or below this value result in a Hold with a +// rationale explaining that the target is already met. Expressed as an exact +// numerator/denominator pair (not a float64 literal) so the threshold used in +// comparisons is exactly one cent with no binary-float representation error. +const ( + minAllocatableGapNum = 1 + minAllocatableGapDen = 100 +) + +// minAllocatableGap returns the exact minimum allocatable gap threshold +// ($0.01/hr) as a *big.Rat. +func minAllocatableGap() *big.Rat { + return big.NewRat(minAllocatableGapNum, minAllocatableGapDen) +} + +// AllocationInput carries everything Allocate needs. All provider data must be +// pre-fetched by the caller; Allocate performs no I/O. +// +// Contract on LayerStates: for every LayerSpec in Layers, LayerStates must +// contain an entry whose ExistingUSDPerHour and ExpiringUSDPerHour are both +// non-nil. Providers must supply an explicit zero rather than a nil pointer +// when a field is genuinely zero; a nil pointer is treated as missing data +// and causes Allocate to return an error (fail loud: incomplete provider data +// aborts the run). +// +// DataSources lists the data feeds (e.g. "cost-explorer", "cloudwatch") used +// to derive the inputs. The slice is propagated verbatim to every Allocation +// and PlannedAction produced by Allocate so that approval emails and audit +// logs identify the source of each decision. +// +// 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 +} + +// 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. +type Allocation struct { + Layer LayerType + GapUSDPerHour *big.Rat + Rationale string + DataSources []string +} + +// AllocateResult is the output of Allocate. +// +// Allocations may be accompanied by informational Holds (e.g. "buffer +// utilization unknown; reshape not evaluated", or a sub-minimum split amount +// that was skipped) and by Reshapes for under-utilized buffer layers. +// Reshapes are detected independently of the purchase gap, so they can appear +// alongside an early-exit Hold (baseline unavailable, target met). A run is a +// no-op if and only if Allocations and Reshapes are both empty (see IsNoOp); +// in that case Holds carries the explanation (baseline unavailable, gap below +// the minimum threshold, or every split amount below the minimum). +type AllocateResult struct { + Allocations []Allocation + Reshapes []PlannedAction + Holds []PlannedAction +} + +// IsNoOp reports whether the result proposes no state-changing actions: +// no allocations to purchase and no buffer reshapes. Informational Holds do +// not affect no-op status. +func (r *AllocateResult) IsNoOp() bool { + return len(r.Allocations) == 0 && len(r.Reshapes) == 0 +} + +// Allocate determines how much new commitment to place on each layer for one +// ladder scope run. All monetary values are computed in exact *big.Rat +// arithmetic; float64 inputs are converted at the boundary via ratFromFloat. +// +// Steps (see inline comments): +// 1. Validate config, layers (enums + topology), and layer states. +// 2. Detect under-utilized buffer layers (reshape, or informational hold +// when utilization is unknown on a non-empty buffer layer). This runs +// INDEPENDENTLY of the purchase gap: an over-committed account is exactly +// when reshaping matters, so the early no-purchase exits below must not +// 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. +// 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). +// 8. Enforce MaxActionsPerRun (error if exceeded). +func Allocate(in *AllocationInput) (*AllocateResult, error) { + if in == nil { + return nil, fmt.Errorf("allocation input must not be nil") + } + if err := validateAllocateInput(in); err != nil { + return nil, err + } + reshapes, maintHolds := detectReshapes(in.Layers, in.LayerStates, in.Config.BufferUtilizationThresholdPct, in.DataSources) + if in.Baseline.LowWaterUSDPerHour == nil { + return withMaintenance(baselineUnavailableHold(in), reshapes, maintHolds), nil + } + nets, existingTotal, err := computeNetExisting(in.Layers, in.LayerStates) + if err != nil { + return nil, err + } + lowWater, target, err := computeBaselineAndTarget(in) + if err != nil { + return nil, err + } + // The second min arm is defensive: it guards against future target + // derivations exceeding the observed floor. Today target <= lowWater + // always holds because TargetCoveragePct is validated to be <= 100. + gap := minRat(new(big.Rat).Sub(target, existingTotal), new(big.Rat).Sub(lowWater, existingTotal)) + if gap.Cmp(minAllocatableGap()) <= 0 { + return withMaintenance(gapBelowMinHold(gap, lowWater, target, existingTotal, in), reshapes, maintHolds), nil + } + return buildResult(in, lowWater, target, existingTotal, gap, nets, reshapes, maintHolds) +} + +// withMaintenance merges independently detected buffer maintenance actions +// (reshapes and their informational holds) into an early-exit hold result so +// a no-purchase run never masks needed buffer reshaping. +func withMaintenance(result *AllocateResult, reshapes, maintHolds []PlannedAction) *AllocateResult { + result.Reshapes = append(result.Reshapes, reshapes...) + result.Holds = append(result.Holds, maintHolds...) + return result +} + +// ratFromFloat converts v to *big.Rat, rejecting NaN, Inf, and negative values. +// The name parameter is embedded in the error message so callers can identify +// which field triggered the rejection. +func ratFromFloat(name string, v float64) (*big.Rat, error) { + if math.IsNaN(v) { + return nil, fmt.Errorf("%s: NaN is not a valid monetary value", name) + } + if math.IsInf(v, 0) { + return nil, fmt.Errorf("%s: Inf is not a valid monetary value", name) + } + if v < 0 { + return nil, fmt.Errorf("%s: negative value %g is not allowed", name, v) + } + return new(big.Rat).SetFloat64(v), nil +} + +// minRat returns the smaller of a and b without modifying either. +func minRat(a, b *big.Rat) *big.Rat { + if a.Cmp(b) <= 0 { + return a + } + return b +} + +// fmtRatUSD renders r as a fixed-4-decimal USD/hr string for use in rationales. +// Using 4 decimal places rather than 2 to preserve meaningful precision for +// small gap values. +func fmtRatUSD(r *big.Rat) string { + f, _ := r.Float64() + return fmt.Sprintf("$%.4f/hr", f) +} + +// validateAllocateInput validates the config, layer list, and layer states in +// one pass. Returns the first error encountered. +func validateAllocateInput(in *AllocationInput) error { + if err := in.Config.Validate(); err != nil { + return fmt.Errorf("config: %w", err) + } + if len(in.Layers) == 0 { + return fmt.Errorf("layers must not be empty") + } + if err := validateLayers(in.Layers); err != nil { + return fmt.Errorf("layers: %w", err) + } + if err := validateLayerStates(in.Layers, in.LayerStates); err != nil { + return fmt.Errorf("layer_states: %w", err) + } + return nil +} + +// validateLayers checks the layer list for structural consistency: +// - every layer type and role is a recognized enum value +// - no duplicate layer types across specs +// - exactly one RoleFlex layer +// - at most one RoleBase layer and at most one RoleBuffer layer +// - the only permitted multi-role layer is the base+buffer merge (Azure +// reservation shape); RoleFlex must not share a layer with any other role +func validateLayers(layers []LayerSpec) error { + if err := validateLayerUniqueness(layers); err != nil { + return err + } + var flexCount, baseCount, bufferCount int + for _, ls := range layers { + if err := validateLayerEnums(ls); err != nil { + return err + } + if err := validateLayerRoleCombo(ls); err != nil { + return err + } + if slices.Contains(ls.Roles, RoleFlex) { + flexCount++ + } + if slices.Contains(ls.Roles, RoleBase) { + baseCount++ + } + if slices.Contains(ls.Roles, RoleBuffer) { + bufferCount++ + } + } + return validateRoleCounts(flexCount, baseCount, bufferCount) +} + +// validateLayerUniqueness rejects duplicate LayerTypes across specs: a layer +// appearing twice would double-count its state and produce ambiguous +// allocations. +func validateLayerUniqueness(layers []LayerSpec) error { + seen := make(map[LayerType]bool, len(layers)) + for _, ls := range layers { + if seen[ls.Type] { + return fmt.Errorf("duplicate layer type %q", ls.Type) + } + seen[ls.Type] = true + } + return nil +} + +// validateLayerEnums rejects unknown layer types and roles. A typo'd role +// would otherwise pass silently: the layer's commitments still count toward +// existing coverage but the layer could never receive an allocation, a +// silent degradation of the plan. +func validateLayerEnums(ls LayerSpec) error { + if err := ls.Type.Validate(); err != nil { + return fmt.Errorf("layer type: %w", err) + } + for _, r := range ls.Roles { + if err := r.Validate(); err != nil { + return fmt.Errorf("layer %q: role: %w", ls.Type, err) + } + } + return nil +} + +// validateLayerRoleCombo permits single-role layers and the base+buffer merge +// only. RoleFlex combined with any other role (including all three roles on +// one layer) is rejected: no provider topology merges flex with base or +// buffer, so such a spec indicates a provider implementation bug. +func validateLayerRoleCombo(ls LayerSpec) error { + if slices.Contains(ls.Roles, RoleFlex) && + (slices.Contains(ls.Roles, RoleBase) || slices.Contains(ls.Roles, RoleBuffer)) { + return fmt.Errorf("layer %q: %s role must not be combined with other roles (only the %s+%s merge is allowed)", + ls.Type, RoleFlex, RoleBase, RoleBuffer) + } + return nil +} + +// validateRoleCounts enforces the per-role cardinality rules across the +// whole layer list. +func validateRoleCounts(flexCount, baseCount, bufferCount int) error { + if flexCount != 1 { + return fmt.Errorf("exactly one %s layer required, got %d", RoleFlex, flexCount) + } + if baseCount > 1 { + return fmt.Errorf("at most one %s layer allowed, got %d", RoleBase, baseCount) + } + if bufferCount > 1 { + return fmt.Errorf("at most one %s layer allowed, got %d", RoleBuffer, bufferCount) + } + return nil +} + +// validateLayerStates requires every layer in layers to have an entry in states +// with non-nil ExistingUSDPerHour and ExpiringUSDPerHour. Nil pointers are +// treated as missing data (fail loud: providers must supply explicit zeros). +// A non-nil UtilizationPct must be finite and within [0, 100]. +func validateLayerStates(layers []LayerSpec, states map[LayerType]LayerState) error { + for _, ls := range layers { + st, ok := states[ls.Type] + if !ok { + return fmt.Errorf("layer %q: missing state entry (provider data incomplete)", ls.Type) + } + if st.ExistingUSDPerHour == nil { + return fmt.Errorf("layer %q: ExistingUSDPerHour is nil; provider must supply explicit zero if none", ls.Type) + } + if st.ExpiringUSDPerHour == nil { + return fmt.Errorf("layer %q: ExpiringUSDPerHour is nil; provider must supply explicit zero if none", ls.Type) + } + if err := validateUtilizationPct(ls.Type, st.UtilizationPct); err != nil { + return err + } + } + return validateNoUnknownStateKeys(layers, states) +} + +// validateNoUnknownStateKeys rejects LayerStates entries whose key is not in +// the supported layer list. Such entries would be silently excluded from +// existing-coverage accounting, understating E and OVERSTATING the purchase +// gap (a money-path bug, not a tolerable extra). +func validateNoUnknownStateKeys(layers []LayerSpec, states map[LayerType]LayerState) error { + known := make(map[LayerType]bool, len(layers)) + for _, ls := range layers { + known[ls.Type] = true + } + for key := range states { + if !known[key] { + return fmt.Errorf("layer_states contains unknown layer %q not present in the supported layers; its commitments would be excluded from coverage accounting", key) + } + } + return nil +} + +// validateUtilizationPct rejects non-finite (NaN/Inf) or out-of-range +// utilization percentages. A NaN would otherwise compare false against the +// reshape threshold and silently skip reshape detection; that is a data bug +// that must abort the run, not degrade it. nil means "not measured" and is +// valid (handled downstream with an informational hold). +func validateUtilizationPct(layer LayerType, pct *float64) error { + if pct == nil { + return nil + } + if math.IsNaN(*pct) || math.IsInf(*pct, 0) { + return fmt.Errorf("layer %q: UtilizationPct must be finite, got %g", layer, *pct) + } + if *pct < 0 || *pct > 100 { + return fmt.Errorf("layer %q: UtilizationPct %g must be in [0, 100]", layer, *pct) + } + return nil +} + +// findLayerForRole returns the first LayerType that carries the given role. +func findLayerForRole(layers []LayerSpec, role LayerRole) (LayerType, bool) { + for _, ls := range layers { + if slices.Contains(ls.Roles, role) { + return ls.Type, true + } + } + return "", false +} + +// computeNetExisting converts all layer states to exact *big.Rat values and +// returns each layer's net (existing - expiring) plus the grand total. A +// layer whose expiring amount exceeds its existing amount is an inconsistent +// provider snapshot (expiring is defined as a share of existing) and aborts +// the run with an explicit error rather than being silently clamped. +func computeNetExisting(layers []LayerSpec, states map[LayerType]LayerState) (map[LayerType]*big.Rat, *big.Rat, error) { + total := new(big.Rat) + nets := make(map[LayerType]*big.Rat, len(layers)) + for _, ls := range layers { + st := states[ls.Type] + existing, err := ratFromFloat(string(ls.Type)+".ExistingUSDPerHour", *st.ExistingUSDPerHour) + if err != nil { + return nil, nil, err + } + expiring, err := ratFromFloat(string(ls.Type)+".ExpiringUSDPerHour", *st.ExpiringUSDPerHour) + if err != nil { + return nil, nil, err + } + net := new(big.Rat).Sub(existing, expiring) + if net.Sign() < 0 { + return nil, nil, fmt.Errorf("%s: ExpiringUSDPerHour (%s) exceeds ExistingUSDPerHour (%s); inconsistent provider snapshot", + ls.Type, fmtRatUSD(expiring), fmtRatUSD(existing)) + } + nets[ls.Type] = net + total.Add(total, net) + } + return nets, total, nil +} + +// computeBaselineAndTarget derives the low-water baseline and the coverage +// target (low-water * TargetCoveragePct / 100) as exact *big.Rat values. +// Returns an error if the float64 inputs are invalid (NaN, Inf, or negative). +func computeBaselineAndTarget(in *AllocationInput) (lowWater, target *big.Rat, err error) { + lowWater, err = ratFromFloat("low_water_usd_per_hour", *in.Baseline.LowWaterUSDPerHour) + if err != nil { + return nil, nil, err + } + pct, err := ratFromFloat("target_coverage_pct", in.Config.TargetCoveragePct) + if err != nil { + return nil, nil, err + } + hundred := new(big.Rat).SetInt64(100) + target = new(big.Rat).Mul(lowWater, new(big.Rat).Quo(pct, hundred)) + return lowWater, target, nil +} + +// baselineUnavailableHold returns a Hold result explaining that the baseline +// could not be computed. This is a designed in-band no-op, not a silent fallback. +func baselineUnavailableHold(in *AllocationInput) *AllocateResult { + flexLayer, _ := findLayerForRole(in.Layers, RoleFlex) + return &AllocateResult{ + Holds: []PlannedAction{ + { + Action: ActionHold, + Layer: flexLayer, + Rationale: "baseline unavailable: LowWaterUSDPerHour is nil; cannot compute allocation gap", + DataSources: in.DataSources, + }, + }, + } +} + +// gapBelowMinHold returns a Hold result explaining that the target is already +// met. Concrete numbers (low-water, target %, existing, gap) are embedded in +// the rationale. +func gapBelowMinHold(gap, lowWater, target, existingTotal *big.Rat, in *AllocationInput) *AllocateResult { + flexLayer, _ := findLayerForRole(in.Layers, RoleFlex) + rationale := fmt.Sprintf( + "target already met: low_water=%s, target=%.2f%%, existing=%s, target_commitment=%s, gap=%s <= min_allocatable=%s", + fmtRatUSD(lowWater), in.Config.TargetCoveragePct, fmtRatUSD(existingTotal), fmtRatUSD(target), + fmtRatUSD(gap), fmtRatUSD(minAllocatableGap()), + ) + return &AllocateResult{ + Holds: []PlannedAction{ + { + Action: ActionHold, + Layer: flexLayer, + Rationale: rationale, + DataSources: in.DataSources, + }, + }, + } +} + +// buildResult assembles the full AllocateResult after the gap has been +// 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) + if err != nil { + return nil, err + } + allocs, err = applyCapGuardrail(allocs, in.Config.MaxHourlyCommitPerRun) + if err != nil { + return nil, err + } + allocs, skipHolds := dropSubMinimumAllocations(allocs, in.DataSources) + totalActions := len(allocs) + len(reshapes) + if totalActions > in.Config.MaxActionsPerRun { + return nil, fmt.Errorf( + "plan exceeds max_actions_per_run (%d): %d allocations + %d reshapes = %d actions; adjust config or reduce scope", + in.Config.MaxActionsPerRun, len(allocs), len(reshapes), totalActions, + ) + } + return &AllocateResult{ + Allocations: allocs, + Reshapes: reshapes, + Holds: append(maintHolds, skipHolds...), + }, nil +} + +// dropSubMinimumAllocations removes allocations whose amount fell below the +// minimum allocatable threshold after splitting and cap scaling, emitting an +// informational Hold for each so the skipped amount is never silently lost. +// Amounts exactly at the threshold are kept. +func dropSubMinimumAllocations(allocs []Allocation, dataSources []string) ([]Allocation, []PlannedAction) { + minGap := minAllocatableGap() + var kept []Allocation + var holds []PlannedAction + for _, a := range allocs { + if a.GapUSDPerHour.Cmp(minGap) < 0 { + holds = append(holds, PlannedAction{ + Action: ActionHold, + Layer: a.Layer, + Rationale: fmt.Sprintf("allocation of %s on layer %s is below minimum allocatable amount; skipped (minimum %s)", + fmtRatUSD(a.GapUSDPerHour), a.Layer, fmtRatUSD(minGap)), + DataSources: dataSources, + }) + continue + } + kept = append(kept, a) + } + return kept, holds +} + +// splitGap distributes gap across the base, buffer, and flex roles. It handles +// the Azure merged-layer case where a single layer carries both RoleBase and +// RoleBuffer. When BufferFraction > 0 but no layer carries RoleBuffer, it +// returns an explicit error rather than emitting an allocation aimed at a +// 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) { + bufferFracRat, err := ratFromFloat("buffer_fraction", in.Config.BufferFraction) + if err != nil { + return nil, err + } + bufferGap := new(big.Rat).Mul(gap, bufferFracRat) + coreGap := new(big.Rat).Sub(gap, bufferGap) + + flexLayer, _ := findLayerForRole(in.Layers, RoleFlex) + baseLayer, hasBase := findLayerForRole(in.Layers, RoleBase) + bufferLayer, hasBuffer := findLayerForRole(in.Layers, RoleBuffer) + if bufferGap.Sign() > 0 && !hasBuffer { + 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) + if err != nil { + return nil, err + } + flexGap := new(big.Rat).Sub(coreGap, baseGap) + + ctx := allocationContext{ + lowWater: lowWater, + target: target, + existing: existingTotal, + gap: gap, + baseNote: baseNote, + dataSources: in.DataSources, + cfg: in.Config, + } + return assembleAllocations(&ctx, baseGap, flexGap, bufferGap, baseLayer, flexLayer, bufferLayer, hasBase), nil +} + +// allocationContext bundles shared values passed to assembleAllocations so the +// signature stays within a readable parameter count. +type allocationContext struct { + lowWater *big.Rat + target *big.Rat + existing *big.Rat + gap *big.Rat + baseNote string + dataSources []string + cfg LadderConfig +} + +// computeBaseGap determines how much of the core gap should be directed to the +// base layer. When there is no base layer, or the stable baseline is unknown, +// the entire core gap routes to flex (explainable degradation: flex covers +// 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) { + if !hasBase { + return new(big.Rat), "no base layer configured; routing all core gap to flex", nil + } + if baseline.StableUSDPerHour == nil { + return new(big.Rat), "stable usage unknown; routing all core gap to flex", nil + } + stableRat, err := ratFromFloat("stable_usd_per_hour", *baseline.StableUSDPerHour) + if err != nil { + return nil, "", err + } + existingBaseNet := nets[baseLayer] + if existingBaseNet == nil { + existingBaseNet = new(big.Rat) + } + stableLeft := new(big.Rat).Sub(stableRat, existingBaseNet) + if stableLeft.Sign() < 0 { + stableLeft = new(big.Rat) // already at or above stable level + } + if coreGap.Cmp(stableLeft) <= 0 { + return new(big.Rat).Set(coreGap), "", nil + } + return new(big.Rat).Set(stableLeft), "", nil +} + +// assembleAllocations builds the Allocation slice, handling the Azure +// merged-layer case (same layer carries both RoleBase and RoleBuffer). +func assembleAllocations(ctx *allocationContext, baseGap, flexGap, bufferGap *big.Rat, baseLayer, flexLayer, bufferLayer LayerType, hasBase bool) []Allocation { + var allocs []Allocation + azureMerge := hasBase && bufferLayer == baseLayer + + if azureMerge { + mergedGap := new(big.Rat).Add(baseGap, bufferGap) + if mergedGap.Sign() > 0 { + // The base routing note (e.g. "stable usage unknown") explains why + // the BASE share is zero; it belongs on the flex allocation that + // received the rerouted core gap, never on the merged base+buffer + // allocation. When baseGap > 0 the note is empty anyway. + allocs = append(allocs, mkAllocation(ctx, bufferLayer, mergedGap, "base+buffer", "")) + } + } else { + if hasBase && baseGap.Sign() > 0 { + allocs = append(allocs, mkAllocation(ctx, baseLayer, baseGap, "base", ctx.baseNote)) + } + if bufferGap.Sign() > 0 { + allocs = append(allocs, mkAllocation(ctx, bufferLayer, bufferGap, "buffer", "")) + } + } + if flexGap.Sign() > 0 { + allocs = append(allocs, mkAllocation(ctx, flexLayer, flexGap, "flex", flexRoutingNote(ctx.baseNote, hasBase, baseGap))) + } + return allocs +} + +// flexRoutingNote propagates the base routing note to the flex allocation when +// the base gap is zero or no base layer exists (e.g. "stable usage unknown; +// routing all core gap to flex"). Returns "" when the note does not apply. +// Split out of assembleAllocations to keep its cyclomatic complexity within +// the pre-commit hook limit. +func flexRoutingNote(baseNote string, hasBase bool, baseGap *big.Rat) string { + if baseNote != "" && (!hasBase || baseGap.Sign() == 0) { + return baseNote + } + return "" +} + +// mkAllocation constructs a single Allocation 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 != "" { + detail = "; " + extraNote + } + rationale := fmt.Sprintf( + "%s layer: low_water=%s, target=%.2f%%, existing=%s, target_commitment=%s, total_gap=%s, %s_gap=%s, buffer_fraction=%.2f%s", + role, + fmtRatUSD(ctx.lowWater), + ctx.cfg.TargetCoveragePct, + fmtRatUSD(ctx.existing), + fmtRatUSD(ctx.target), + fmtRatUSD(ctx.gap), + role, + fmtRatUSD(layerGap), + ctx.cfg.BufferFraction, + detail, + ) + return Allocation{ + Layer: layer, + GapUSDPerHour: layerGap, + Rationale: rationale, + DataSources: ctx.dataSources, + } +} + +// applyCapGuardrail scales all allocations proportionally when the total +// exceeds Config.MaxHourlyCommitPerRun. 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 { + return allocs, nil + } + capRat, err := ratFromFloat("max_hourly_commit_per_run", *maxPerRun) + if err != nil { + return nil, err + } + total := new(big.Rat) + for _, a := range allocs { + total.Add(total, a.GapUSDPerHour) + } + if total.Sign() <= 0 || capRat.Cmp(total) >= 0 { + return allocs, nil + } + scale := new(big.Rat).Quo(capRat, total) + scalePct, _ := new(big.Rat).Mul(new(big.Rat).Set(scale), new(big.Rat).SetInt64(100)).Float64() + scaled := make([]Allocation, len(allocs)) + for i, a := range allocs { + scaledGap := new(big.Rat).Mul(a.GapUSDPerHour, scale) + scaled[i] = Allocation{ + Layer: a.Layer, + GapUSDPerHour: scaledGap, + Rationale: a.Rationale + fmt.Sprintf("; capped by max_hourly_commit_per_run: scaled to %s (%.2f%% of requested)", + fmtRatUSD(scaledGap), scalePct), + DataSources: a.DataSources, + } + } + return scaled, nil +} + +// detectReshapes examines every RoleBuffer layer that has existing +// commitments. When utilization is below the threshold, it emits an +// ActionReshape. When utilization is nil, it emits an informational +// ActionHold (explainable: no data, no decision). Buffer layers with zero +// existing commitments are skipped entirely: there is nothing to reshape, +// and a fresh account must not receive noise holds or nonsense reshape +// recommendations for empty layers. +func detectReshapes(layers []LayerSpec, states map[LayerType]LayerState, threshold float64, dataSources []string) (reshapes, holds []PlannedAction) { + for _, ls := range layers { + if !slices.Contains(ls.Roles, RoleBuffer) { + continue + } + st := states[ls.Type] + // ExistingUSDPerHour is guaranteed non-nil by validateLayerStates. + if *st.ExistingUSDPerHour == 0 { + continue // empty buffer layer: nothing to reshape, no noise + } + if st.UtilizationPct == nil { + holds = append(holds, PlannedAction{ + Action: ActionHold, + Layer: ls.Type, + Rationale: fmt.Sprintf("buffer layer %s: utilization unknown; reshape not evaluated", ls.Type), + DataSources: dataSources, + }) + continue + } + if *st.UtilizationPct < threshold { + reshapes = append(reshapes, PlannedAction{ + Action: ActionReshape, + Layer: ls.Type, + Rationale: fmt.Sprintf( + "buffer layer %s: utilization %.1f%% is below threshold %.1f%%; reshape recommended", + ls.Type, *st.UtilizationPct, threshold, + ), + DataSources: dataSources, + }) + } + } + return reshapes, holds +} diff --git a/pkg/ladder/allocate_test.go b/pkg/ladder/allocate_test.go new file mode 100644 index 000000000..a5bb072bb --- /dev/null +++ b/pkg/ladder/allocate_test.go @@ -0,0 +1,1483 @@ +package ladder + +import ( + "maps" + "math" + "math/big" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/CUDly/pkg/common" +) + +// --- helpers --- + +func ptr[T any](v T) *T { return &v } + +// validConfigAWS returns a fully populated valid LadderConfig for AWS tests. +func validConfigAWS() LadderConfig { + return LadderConfig{ + Scope: Scope{Provider: common.ProviderAWS, AccountID: "123456789012"}, + TargetCoveragePct: 100, + BufferFraction: 0.10, + BaselinePercentile: 5, + LookbackDays: 30, + Mode: ModeEmailApproval, + Cadence: CadenceWeekly, + Ramp: RampSchedule{Steps: []RampStep{{AfterDays: 0, Fraction: 1.0}}}, + MaxActionsPerRun: 10, + BufferUtilizationThresholdPct: DefaultBufferUtilizationThresholdPct, + } +} + +// validConfigAzure returns a fully populated valid LadderConfig for Azure +// tests, mirroring validConfigAWS with an Azure scope. +func validConfigAzure() LadderConfig { + cfg := validConfigAWS() + cfg.Scope = Scope{Provider: common.ProviderAzure, AccountID: "sub-abc"} + return cfg +} + +// azureLayers returns the merged Azure ladder: the reservation layer carries +// both base and buffer roles; the savings plan is the flex layer. +func azureLayers() []LayerSpec { + return []LayerSpec{ + {Type: LayerAzureReservation, Roles: []LayerRole{RoleBase, RoleBuffer}}, + {Type: LayerAzureSavingsPlan, Roles: []LayerRole{RoleFlex}}, + } +} + +// awsLayers returns the standard 3-layer AWS ladder: +// +// EC2InstanceSP = base, ComputeSP = flex, ConvertibleRI = buffer. +func awsLayers() []LayerSpec { + return []LayerSpec{ + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleBase}}, + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + {Type: LayerConvertibleRI, Roles: []LayerRole{RoleBuffer}}, + } +} + +// zeroStates returns LayerStates with zero existing and zero expiring for all +// provided layers. +func zeroStates(layers []LayerSpec) map[LayerType]LayerState { + m := make(map[LayerType]LayerState, len(layers)) + for _, ls := range layers { + m[ls.Type] = LayerState{ + Layer: ls.Type, + ExistingUSDPerHour: ptr(0.0), + ExpiringUSDPerHour: ptr(0.0), + } + } + return m +} + +// withExisting returns a copy of states with the given layer's ExistingUSDPerHour set. +func withExisting(states map[LayerType]LayerState, layer LayerType, v float64) map[LayerType]LayerState { + out := make(map[LayerType]LayerState, len(states)) + maps.Copy(out, states) + s := out[layer] + s.ExistingUSDPerHour = ptr(v) + out[layer] = s + return out +} + +// withExpiring returns a copy of states with the given layer's ExpiringUSDPerHour set. +func withExpiring(states map[LayerType]LayerState, layer LayerType, v float64) map[LayerType]LayerState { + out := make(map[LayerType]LayerState, len(states)) + maps.Copy(out, states) + s := out[layer] + s.ExpiringUSDPerHour = ptr(v) + out[layer] = s + return out +} + +// withBufferUtilization returns a copy of states with the AWS buffer layer's +// (ConvertibleRI) UtilizationPct set. All utilization-focused tests use the +// standard AWS layer set, so the layer is fixed. +func withBufferUtilization(states map[LayerType]LayerState, v float64) map[LayerType]LayerState { + out := make(map[LayerType]LayerState, len(states)) + maps.Copy(out, states) + s := out[LayerConvertibleRI] + s.UtilizationPct = ptr(v) + out[LayerConvertibleRI] = s + return out +} + +// ratEq reports whether got equals a *big.Rat with numerator num and +// denominator den in lowest terms. Uses Cmp so both sides are normalized. +func ratEq(t *testing.T, label string, got *big.Rat, num, den int64) { + t.Helper() + want := new(big.Rat).SetFrac64(num, den) + if got.Cmp(want) != 0 { + t.Errorf("%s: got %s, want %d/%d", label, got.RatString(), num, den) + } +} + +func nowFixed() time.Time { return time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) } + +// --- tests --- + +func TestAllocate_NilInput(t *testing.T) { + t.Parallel() + _, err := Allocate(nil) + if err == nil { + t.Fatal("expected error for nil input, got nil") + } +} + +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(), + DataSources: []string{"cost-explorer"}, + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 0 { + t.Errorf("expected no allocations, got %d", len(result.Allocations)) + } + if len(result.Holds) != 1 { + t.Fatalf("expected 1 hold, got %d", len(result.Holds)) + } + if !strings.Contains(result.Holds[0].Rationale, "baseline unavailable") { + t.Errorf("hold rationale missing 'baseline unavailable': %q", result.Holds[0].Rationale) + } + if result.Holds[0].Layer == "" { + t.Errorf("hold layer must not be empty") + } + if !result.IsNoOp() { + t.Errorf("nil-baseline result must be a no-op") + } +} + +func TestAllocate_GapBelowMin(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // existing = $10/hr on the flex layer -> net E = $10/hr + // 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 0 { + t.Errorf("expected no allocations, got %d", len(result.Allocations)) + } + if len(result.Holds) == 0 { + t.Fatal("expected at least one hold") + } + r := result.Holds[0].Rationale + for _, want := range []string{"$10.0000/hr", "100.00%", "target already met"} { + if !strings.Contains(r, want) { + t.Errorf("hold rationale missing %q: %q", want, r) + } + } + if !result.IsNoOp() { + t.Errorf("gap-below-min result must be a no-op") + } +} + +// TestAllocate_ExistingAboveTarget_Hold covers the negative-gap case where +// existing commitments exceed the coverage target (E > Ctgt): the gap is +// negative, which is below the minimum threshold, so the run holds. +func TestAllocate_ExistingAboveTarget_Hold(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 0 { + t.Errorf("expected no allocations when existing exceeds target, got %d", len(result.Allocations)) + } + if len(result.Holds) == 0 { + t.Fatal("expected a hold when existing exceeds target") + } + r := result.Holds[0].Rationale + // "$-5.0000/hr" pins the rendering of the negative gap value. + for _, want := range []string{"target already met", "$15.0000/hr", "$10.0000/hr", "$-5.0000/hr"} { + if !strings.Contains(r, want) { + t.Errorf("hold rationale missing %q: %q", want, r) + } + } + if !result.IsNoOp() { + t.Errorf("existing-above-target result must be a no-op") + } +} + +// TestAllocate_AWS3LayerSplit verifies exact big.Rat arithmetic for the +// standard 3-layer AWS case. BufferFraction = 0.5 (1/2) is used because 0.5 +// IS exactly representable as float64, giving clean rational intermediates. +// +// Setup: +// +// B=$10/hr, S=$4/hr, Ctgt=$10/hr (100%), buffer_fraction=1/2 +// existing: base=$1/hr, flex=$0, buffer=$0; expiring: all $0 +// E = $1/hr +// gap = min(10-1, 10-1) = 9/1 +// bufferGap = 9 * 1/2 = 9/2 +// coreGap = 9 - 9/2 = 9/2 +// stableLeft = 4 - 1 = 3 -> baseGap = min(9/2, 3) = 3 (9/2=4.5 > 3) +// flexGap = 9/2 - 3 = 3/2 +func TestAllocate_AWS3LayerSplit(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + cfg.BufferFraction = 0.5 // exactly representable as float64 + layers := awsLayers() + states := zeroStates(layers) + 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(), + DataSources: []string{"cost-explorer"}, + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 3 { + t.Fatalf("expected 3 allocations, got %d: %+v", len(result.Allocations), result.Allocations) + } + if result.IsNoOp() { + t.Errorf("result with allocations must not be a no-op") + } + + allocMap := make(map[LayerType]*big.Rat, 3) + for _, a := range result.Allocations { + allocMap[a.Layer] = a.GapUSDPerHour + } + + ratEq(t, "base (EC2InstanceSP)", allocMap[LayerEC2InstanceSP], 3, 1) // $3/hr + ratEq(t, "buffer (ConvertibleRI)", allocMap[LayerConvertibleRI], 9, 2) // $4.50/hr + ratEq(t, "flex (ComputeSP)", allocMap[LayerComputeSP], 3, 2) // $1.50/hr + + // total must equal gap = $9/hr exactly + total := new(big.Rat) + for _, a := range result.Allocations { + total.Add(total, a.GapUSDPerHour) + } + ratEq(t, "total gap", total, 9, 1) + + // rationales must contain concrete numbers + for _, a := range result.Allocations { + if !strings.Contains(a.Rationale, "$10.0000/hr") { + t.Errorf("layer %s rationale missing low_water number: %q", a.Layer, a.Rationale) + } + if !strings.Contains(a.Rationale, "100.00%") { + t.Errorf("layer %s rationale missing target %%: %q", a.Layer, a.Rationale) + } + } +} + +func TestAllocate_BufferFractionZero(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, a := range result.Allocations { + if a.Layer == LayerConvertibleRI { + t.Errorf("expected no buffer allocation with buffer_fraction=0, got %s", a.GapUSDPerHour.RatString()) + } + } +} + +// TestAllocate_StableUnknown_BaseZeroFlexGetsAll uses buffer_fraction=0.5 for +// exact arithmetic. With no StableUSDPerHour, baseGap=0 and all core gap goes +// to flex. The flex rationale must mention "stable usage unknown". +// +// B=$10, E=$0, gap=$10, bufferGap=5/1, coreGap=5/1, baseGap=0, flexGap=5/1 +func TestAllocate_StableUnknown_BaseZeroFlexGetsAll(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + allocMap := make(map[LayerType]*big.Rat) + for _, a := range result.Allocations { + allocMap[a.Layer] = a.GapUSDPerHour + } + if g, ok := allocMap[LayerEC2InstanceSP]; ok && g.Sign() > 0 { + t.Errorf("base layer should be 0 when stable unknown, got %s", g.RatString()) + } + ratEq(t, "flex", allocMap[LayerComputeSP], 5, 1) + ratEq(t, "buffer", allocMap[LayerConvertibleRI], 5, 1) + + // flex rationale must mention "stable usage unknown" (routing note) + for _, a := range result.Allocations { + if a.Layer == LayerComputeSP { + if !strings.Contains(a.Rationale, "stable usage unknown") { + t.Errorf("flex rationale missing 'stable usage unknown': %q", a.Rationale) + } + } + } +} + +// TestAllocate_NoBaseLayer_NoteNamesActualReason verifies that when the layer +// set has no RoleBase layer at all, the flex rationale carries the +// "no base layer configured" note rather than the misleading +// "stable usage unknown" message (the stable baseline IS known here). +func TestAllocate_NoBaseLayer_NoteNamesActualReason(t *testing.T) { + t.Parallel() + // flex + buffer only, no base layer + layers := []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + {Type: LayerConvertibleRI, Roles: []LayerRole{RoleBuffer}}, + } + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + foundFlex := false + for _, a := range result.Allocations { + if a.Layer == LayerComputeSP { + foundFlex = true + if !strings.Contains(a.Rationale, "no base layer configured") { + t.Errorf("flex rationale missing 'no base layer configured': %q", a.Rationale) + } + if strings.Contains(a.Rationale, "stable usage unknown") { + t.Errorf("flex rationale must not claim stable is unknown when it is known: %q", a.Rationale) + } + } + } + if !foundFlex { + t.Fatal("expected a flex allocation") + } +} + +// TestAllocate_ExpiringExceedsExisting_Error verifies the fail-loud contract: +// a layer reporting more expiring than existing commitment is an inconsistent +// provider snapshot (expiring is defined as a share of existing) and must +// abort the run with an explicit error, never be silently clamped to zero. +func TestAllocate_ExpiringExceedsExisting_Error(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // ConvertibleRI: existing=$2, expiring=$5 -> inconsistent snapshot + states = withExisting(states, LayerConvertibleRI, 2.0) + 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(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for expiring > existing, got nil") + } + for _, want := range []string{"exceeds ExistingUSDPerHour", "inconsistent provider snapshot", "$5.0000/hr", "$2.0000/hr"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } +} + +// TestAllocate_UtilizationClamp verifies min(Ctgt-E, B-E) when coverage=100%. +// When Ctgt = B (100% coverage), both sides of min are equal and gap = B - E. +func TestAllocate_UtilizationClamp(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + cfg.TargetCoveragePct = 100 // Ctgt = B + layers := awsLayers() + states := zeroStates(layers) + 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(), + } + // B=$10, Ctgt=$10, E=$3, gap=min(7,7)=$7 + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + total := new(big.Rat) + for _, a := range result.Allocations { + total.Add(total, a.GapUSDPerHour) + } + ratEq(t, "gap when Ctgt=B", total, 7, 1) +} + +// TestAllocate_AzureMergedBaseBuffer verifies that when a single layer carries +// both RoleBase and RoleBuffer (Azure reservation pattern), the base and buffer +// gaps are combined into one Allocation on that layer. BufferFraction=0.5 for +// exact rational arithmetic. +// +// B=$10, S=$4, E=$0, gap=$10, bufferGap=5/1, coreGap=5/1 +// baseGap=min(5,4)=4/1, flexGap=1/1 +// merged (reservation) = 4+5 = 9/1 +func TestAllocate_AzureMergedBaseBuffer(t *testing.T) { + t.Parallel() + // AzureReservation = base + buffer; AzureSavingsPlan = flex + layers := azureLayers() + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 2 { + t.Fatalf("expected 2 allocations (merged + flex), got %d: %+v", len(result.Allocations), result.Allocations) + } + allocMap := make(map[LayerType]*big.Rat) + for _, a := range result.Allocations { + allocMap[a.Layer] = a.GapUSDPerHour + } + ratEq(t, "azure merged base+buffer", allocMap[LayerAzureReservation], 9, 1) + ratEq(t, "azure flex", allocMap[LayerAzureSavingsPlan], 1, 1) + + // merged allocation rationale must contain "base+buffer" + for _, a := range result.Allocations { + if a.Layer == LayerAzureReservation { + if !strings.Contains(a.Rationale, "base+buffer") { + t.Errorf("merged rationale missing 'base+buffer': %q", a.Rationale) + } + } + } +} + +// TestAllocate_CapScaling verifies proportional scaling when total exceeds the +// per-run cap. The scaled sum must equal the cap exactly (big.Rat arithmetic). +func TestAllocate_CapScaling(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + hourlyCap := 5.0 // $5/hr cap; uncapped gap would be $10/hr + cfg.MaxHourlyCommitPerRun = &hourlyCap + + layers := awsLayers() + in := &AllocationInput{ + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + total := new(big.Rat) + for _, a := range result.Allocations { + total.Add(total, a.GapUSDPerHour) + } + // total must equal the cap exactly + ratEq(t, "scaled total == cap", total, 5, 1) + + // Each rationale must mention the capping AND the final post-cap amount. + // Pre-cap: base=$4, buffer=$1, flex=$5 (gap $10, scale 1/2), so the + // post-cap figures are $2, $0.50, and $2.50 respectively. + wantScaledTo := map[LayerType]string{ + LayerEC2InstanceSP: "scaled to $2.0000/hr", + LayerConvertibleRI: "scaled to $0.5000/hr", + LayerComputeSP: "scaled to $2.5000/hr", + } + for _, a := range result.Allocations { + if !strings.Contains(a.Rationale, "capped by max_hourly_commit_per_run") { + t.Errorf("layer %s rationale missing cap note: %q", a.Layer, a.Rationale) + } + if want := wantScaledTo[a.Layer]; !strings.Contains(a.Rationale, want) { + t.Errorf("layer %s rationale missing post-cap amount %q: %q", a.Layer, want, a.Rationale) + } + if !strings.Contains(a.Rationale, "50.00% of requested") { + t.Errorf("layer %s rationale missing scale percentage: %q", a.Layer, a.Rationale) + } + } +} + +func TestAllocate_MaxActionsExceeded(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + cfg.MaxActionsPerRun = 2 // 3 allocations will exceed this + layers := awsLayers() + in := &AllocationInput{ + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0), StableUSDPerHour: ptr(4.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for max_actions_per_run exceeded, got nil") + } + if !strings.Contains(err.Error(), "max_actions_per_run") { + t.Errorf("error missing 'max_actions_per_run': %v", err) + } +} + +func TestAllocate_MissingLayerState(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + delete(states, LayerConvertibleRI) // remove buffer layer state + + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for missing layer state, got nil") + } + if !strings.Contains(err.Error(), "missing state entry") { + t.Errorf("error missing 'missing state entry': %v", err) + } +} + +func TestAllocate_NilExistingUSDPerHour(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + s := states[LayerConvertibleRI] + s.ExistingUSDPerHour = nil + states[LayerConvertibleRI] = s + + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for nil ExistingUSDPerHour, got nil") + } + if !strings.Contains(err.Error(), "ExistingUSDPerHour is nil") { + t.Errorf("error missing expected text: %v", err) + } +} + +func TestAllocate_NilExpiringUSDPerHour(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + s := states[LayerConvertibleRI] + s.ExpiringUSDPerHour = nil + states[LayerConvertibleRI] = s + + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for nil ExpiringUSDPerHour, got nil") + } + if !strings.Contains(err.Error(), "ExpiringUSDPerHour is nil") { + t.Errorf("error missing expected text: %v", err) + } +} + +func TestAllocate_NaNInput(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + states = withExisting(states, LayerConvertibleRI, math.NaN()) + + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for NaN ExistingUSDPerHour, got nil") + } + if !strings.Contains(err.Error(), "NaN") { + t.Errorf("error missing 'NaN': %v", err) + } +} + +func TestAllocate_NegativeInput(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + states = withExisting(states, LayerComputeSP, -1.0) + + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for negative ExistingUSDPerHour, got nil") + } + if !strings.Contains(err.Error(), "negative") { + t.Errorf("error missing 'negative': %v", err) + } +} + +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(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for negative LowWaterUSDPerHour, got nil") + } + if !strings.Contains(err.Error(), "negative") { + t.Errorf("error missing 'negative': %v", err) + } +} + +func TestAllocate_ReshapeEmittedUnderThreshold(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // Buffer layer has existing commitments and utilization at 70%, threshold + // at 90% -> reshape expected. (Empty buffer layers are skipped, so the + // layer must have non-zero existing for the reshape path to trigger.) + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Reshapes) != 1 { + t.Fatalf("expected 1 reshape, got %d", len(result.Reshapes)) + } + r := result.Reshapes[0] + if r.Layer != LayerConvertibleRI { + t.Errorf("reshape on wrong layer: %s", r.Layer) + } + if !strings.Contains(r.Rationale, "70.0%") { + t.Errorf("reshape rationale missing utilization: %q", r.Rationale) + } + if !strings.Contains(r.Rationale, "90.0%") { + t.Errorf("reshape rationale missing threshold: %q", r.Rationale) + } + if result.IsNoOp() { + t.Errorf("result with reshapes must not be a no-op") + } +} + +func TestAllocate_NoReshapeAtThreshold(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // Non-empty buffer layer with utilization exactly at threshold -> no reshape + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Reshapes) != 0 { + t.Errorf("expected no reshape at threshold, got %d", len(result.Reshapes)) + } +} + +func TestAllocate_NoReshapeAboveThreshold(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // Non-empty buffer layer with utilization above threshold -> no reshape + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Reshapes) != 0 { + t.Errorf("expected no reshape above threshold, got %d", len(result.Reshapes)) + } +} + +func TestAllocate_UtilizationNil_InformationalHold(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // Buffer layer has existing commitments but no utilization data -> + // 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should have an informational hold about unknown utilization + found := false + for _, h := range result.Holds { + if h.Layer == LayerConvertibleRI && strings.Contains(h.Rationale, "utilization unknown") { + found = true + break + } + } + if !found { + t.Errorf("expected informational hold for nil utilization on non-empty buffer layer, holds: %+v", result.Holds) + } +} + +// TestAllocate_EmptyBufferLayer_NoNoise verifies that a buffer layer with zero +// existing commitments emits neither a reshape nor an informational hold, even +// when UtilizationPct is present and 0.0 (a fresh account must not get a +// nonsense reshape recommendation for an empty layer). +func TestAllocate_EmptyBufferLayer_NoNoise(t *testing.T) { + t.Parallel() + layers := awsLayers() + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Reshapes) != 0 { + t.Errorf("expected no reshape for empty buffer layer, got %d: %+v", len(result.Reshapes), result.Reshapes) + } + for _, h := range result.Holds { + if h.Layer == LayerConvertibleRI { + t.Errorf("expected no informational hold for empty buffer layer, got: %q", h.Rationale) + } + } +} + +func TestRatFromFloat_Validation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + v float64 + wantErr bool + }{ + {"valid zero", 0.0, false}, + {"valid positive", 1.5, false}, + {"NaN", math.NaN(), true}, + {"+Inf", math.Inf(1), true}, + {"-Inf", math.Inf(-1), true}, + {"negative", -0.01, true}, + } + for _, c := range cases { + _, err := ratFromFloat(c.name, c.v) + if c.wantErr && err == nil { + t.Errorf("ratFromFloat(%q, %v) = nil, want error", c.name, c.v) + } + if !c.wantErr && err != nil { + t.Errorf("ratFromFloat(%q, %v) = %v, want nil", c.name, c.v, err) + } + } +} + +// TestRatFromFloat_NegativeIsError additionally asserts that the error message +// names the negativity so callers can identify the rejection reason. +func TestRatFromFloat_NegativeIsError(t *testing.T) { + t.Parallel() + _, err := ratFromFloat("test", -0.01) + if err == nil { + t.Fatal("expected error for negative value, got nil") + } + if !strings.Contains(err.Error(), "negative") { + t.Errorf("error missing 'negative': %v", err) + } +} + +func TestAllocate_InvalidConfig(t *testing.T) { + t.Parallel() + layers := awsLayers() + cfg := validConfigAWS() + cfg.TargetCoveragePct = 0 // invalid + in := &AllocationInput{ + Config: cfg, + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for invalid config, got nil") + } +} + +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(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for empty layers, got nil") + } +} + +func TestAllocate_NoFlexLayer(t *testing.T) { + t.Parallel() + // One base layer + one buffer layer, no flex -> fails validateLayers + // (flexCount=0, exactly one required). + layers := []LayerSpec{ + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleBase}}, + {Type: LayerConvertibleRI, Roles: []LayerRole{RoleBuffer}}, + } + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for missing flex layer, got nil") + } + if !strings.Contains(err.Error(), string(RoleFlex)) { + t.Errorf("error missing 'flex' mention: %v", err) + } +} + +// TestAllocate_TwoBaseLayers verifies that two distinct RoleBase layers are +// rejected by validateLayers. +func TestAllocate_TwoBaseLayers(t *testing.T) { + t.Parallel() + layers := []LayerSpec{ + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleBase}}, + {Type: LayerConvertibleRI, Roles: []LayerRole{RoleBase}}, + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + } + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for two base layers, got nil") + } + if !strings.Contains(err.Error(), "at most one") { + t.Errorf("error missing 'at most one': %v", err) + } +} + +// TestAllocate_TwoFlexLayers verifies that two RoleFlex layers are rejected by +// validateLayers. +func TestAllocate_TwoFlexLayers(t *testing.T) { + t.Parallel() + layers := []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleFlex}}, + } + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: zeroStates(layers), + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for two flex layers, got nil") + } + if !strings.Contains(err.Error(), "exactly one") { + t.Errorf("error missing 'exactly one': %v", err) + } +} + +// TestAllocate_LayerTopologyValidation covers the hardened topology +// invariants: duplicate layer types, more than one buffer-role layer, and +// any multi-role merge other than base+buffer are rejected; the Azure +// base+buffer merge stays accepted. +func TestAllocate_LayerTopologyValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + errContains string // empty means the topology must be accepted + layers []LayerSpec + }{ + { + name: "duplicate layer types rejected", + layers: []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + {Type: LayerComputeSP, Roles: []LayerRole{RoleBuffer}}, + }, + errContains: "duplicate layer type", + }, + { + name: "two buffer layers rejected", + layers: []LayerSpec{ + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleBase}}, + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + {Type: LayerConvertibleRI, Roles: []LayerRole{RoleBuffer}}, + {Type: LayerAzureReservation, Roles: []LayerRole{RoleBuffer}}, + }, + errContains: "at most one buffer", + }, + { + name: "flex+buffer merge rejected", + layers: []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex, RoleBuffer}}, + }, + errContains: "must not be combined", + }, + { + name: "flex+base merge rejected", + layers: []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex, RoleBase}}, + }, + errContains: "must not be combined", + }, + { + name: "all three roles on one layer rejected", + layers: []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleBase, RoleFlex, RoleBuffer}}, + }, + errContains: "must not be combined", + }, + { + name: "bogus layer type rejected", + layers: []LayerSpec{ + {Type: "bogus", Roles: []LayerRole{RoleFlex}}, + }, + errContains: "unknown layer type", + }, + { + name: "bogus role rejected", + layers: []LayerSpec{ + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + {Type: LayerConvertibleRI, Roles: []LayerRole{"bogus-role"}}, + }, + errContains: "unknown layer role", + }, + { + name: "azure base+buffer merge accepted", + layers: azureLayers(), + }, + } + 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(), + } + _, err := Allocate(in) + if c.errContains == "" { + if err != nil { + t.Fatalf("expected topology to be accepted, got error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", c.errContains) + } + if !strings.Contains(err.Error(), c.errContains) { + t.Errorf("error missing %q: %v", c.errContains, err) + } + }) + } +} + +// TestAllocate_UtilizationPctValidation verifies that a non-nil UtilizationPct +// must be finite and within [0, 100]. A NaN previously compared false against +// the reshape threshold and silently skipped reshape detection; it must abort +// the run instead. +func TestAllocate_UtilizationPctValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + pct float64 + wantErr bool + }{ + {"NaN rejected", math.NaN(), true}, + {"+Inf rejected", math.Inf(1), true}, + {"-Inf rejected", math.Inf(-1), true}, + {"negative rejected", -1.0, true}, + {"above 100 rejected", 101.0, true}, + {"zero accepted", 0.0, false}, + {"exactly 100 accepted", 100.0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + layers := awsLayers() + states := zeroStates(layers) + // non-empty buffer layer so the utilization value is evaluated + 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(), + } + _, err := Allocate(in) + if c.wantErr { + if err == nil { + t.Fatalf("expected error for UtilizationPct %v, got nil", c.pct) + } + if !strings.Contains(err.Error(), "UtilizationPct") { + t.Errorf("error missing 'UtilizationPct': %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error for UtilizationPct %v: %v", c.pct, err) + } + }) + } +} + +// TestAllocate_NoBufferLayerWithBufferFraction_Error is a regression test for +// a money-path bug: with a base+flex-only topology and BufferFraction > 0, +// splitGap used to emit an Allocation on an empty ("") layer type, i.e. a +// purchase directive aimed at a nonexistent layer. It must instead fail loud. +func TestAllocate_NoBufferLayerWithBufferFraction_Error(t *testing.T) { + t.Parallel() + // base + flex only, no buffer-role layer + layers := []LayerSpec{ + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleBase}}, + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + } + 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(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for buffer_fraction > 0 with no buffer-role layer, got nil") + } + if !strings.Contains(err.Error(), "no buffer-role layer") { + t.Errorf("error missing 'no buffer-role layer': %v", err) + } +} + +// TestAllocate_NoBufferLayerZeroFraction_OK verifies that a base+flex-only +// topology is valid when BufferFraction is 0: the buffer gap is zero, so the +// run succeeds with base and flex allocations only and no empty-layer output. +// +// B=$10, S=$4, E=$0, gap=$10, bufferGap=0, coreGap=10 +// baseGap=min(10,4)=4/1, flexGap=6/1 +func TestAllocate_NoBufferLayerZeroFraction_OK(t *testing.T) { + t.Parallel() + layers := []LayerSpec{ + {Type: LayerEC2InstanceSP, Roles: []LayerRole{RoleBase}}, + {Type: LayerComputeSP, Roles: []LayerRole{RoleFlex}}, + } + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 2 { + t.Fatalf("expected 2 allocations (base + flex), got %d: %+v", len(result.Allocations), result.Allocations) + } + allocMap := make(map[LayerType]*big.Rat) + for _, a := range result.Allocations { + if a.Layer == "" { + t.Fatalf("allocation with empty layer type emitted: %+v", a) + } + allocMap[a.Layer] = a.GapUSDPerHour + } + ratEq(t, "base (EC2InstanceSP)", allocMap[LayerEC2InstanceSP], 4, 1) + ratEq(t, "flex (ComputeSP)", allocMap[LayerComputeSP], 6, 1) +} + +// TestAllocate_TargetMet_ReshapeStillEmitted is a regression test: reshape +// detection must run independently of the purchase gap. An over-committed +// account (target met, negative or zero gap) with an under-utilized buffer is +// exactly the case where reshaping matters, so the target-met early exit must +// emit BOTH the hold and the reshape. +func TestAllocate_TargetMet_ReshapeStillEmitted(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + // E = 8 (flex) + 2 (buffer) = $10/hr = B -> target met -> hold path. + states = withExisting(states, LayerComputeSP, 8.0) + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Holds) == 0 || !strings.Contains(result.Holds[0].Rationale, "target already met") { + t.Errorf("expected target-met hold, holds: %+v", result.Holds) + } + if len(result.Reshapes) != 1 { + t.Fatalf("expected 1 reshape alongside the target-met hold, got %d", len(result.Reshapes)) + } + if !strings.Contains(result.Reshapes[0].Rationale, "70.0%") { + t.Errorf("reshape rationale missing utilization: %q", result.Reshapes[0].Rationale) + } + if result.IsNoOp() { + t.Errorf("result with a reshape must not be a no-op") + } +} + +// TestAllocate_NilBaseline_ReshapeStillEmitted is the second independence +// regression: even when the baseline is unavailable (no gap can be computed), +// buffer reshape detection needs only layer states and must still run. +func TestAllocate_NilBaseline_ReshapeStillEmitted(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + foundBaselineHold := false + for _, h := range result.Holds { + if strings.Contains(h.Rationale, "baseline unavailable") { + foundBaselineHold = true + } + } + if !foundBaselineHold { + t.Errorf("expected baseline-unavailable hold, holds: %+v", result.Holds) + } + if len(result.Reshapes) != 1 { + t.Fatalf("expected 1 reshape alongside the baseline hold, got %d", len(result.Reshapes)) + } + if result.IsNoOp() { + t.Errorf("result with a reshape must not be a no-op") + } +} + +// TestAllocate_UnknownLayerStateKey_Error verifies that a LayerStates entry +// not present in the supported layer list is rejected: it would otherwise be +// silently excluded from existing-coverage accounting, overstating the +// purchase gap. +func TestAllocate_UnknownLayerStateKey_Error(t *testing.T) { + t.Parallel() + layers := awsLayers() + states := zeroStates(layers) + states[LayerAzureReservation] = LayerState{ + Layer: LayerAzureReservation, + ExistingUSDPerHour: ptr(5.0), + ExpiringUSDPerHour: ptr(0.0), + } + in := &AllocationInput{ + Config: validConfigAWS(), + Layers: layers, + Baseline: UsageBaseline{LowWaterUSDPerHour: ptr(10.0)}, + LayerStates: states, + Now: nowFixed(), + } + _, err := Allocate(in) + if err == nil { + t.Fatal("expected error for unknown layer state key, got nil") + } + for _, want := range []string{"unknown layer", string(LayerAzureReservation)} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } +} + +// TestAllocate_SubMinimumBufferSkipped verifies that a split amount below the +// minimum allocatable threshold is dropped WITH an informational hold naming +// the layer and the skipped amount, while the other allocations stay intact. +// +// B=$10, S=$4, E=$0, gap=$10, fraction=0.0005 -> bufferGap=$0.005 < $0.01 +// base=$4 and flex=$5.995 both stay. +func TestAllocate_SubMinimumBufferSkipped(t *testing.T) { + t.Parallel() + cfg := validConfigAWS() + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 2 { + t.Fatalf("expected 2 allocations (base + flex), got %d: %+v", len(result.Allocations), result.Allocations) + } + for _, a := range result.Allocations { + if a.Layer == LayerConvertibleRI { + t.Errorf("sub-minimum buffer allocation must be dropped, got %s", a.GapUSDPerHour.RatString()) + } + } + foundSkip := false + for _, h := range result.Holds { + if h.Layer == LayerConvertibleRI && + strings.Contains(h.Rationale, "below minimum allocatable amount; skipped") && + strings.Contains(h.Rationale, "$0.0050/hr") { + foundSkip = true + } + } + if !foundSkip { + t.Errorf("expected informational hold for skipped sub-minimum buffer amount, holds: %+v", result.Holds) + } +} + +// TestAllocate_AllSplitsSubMinimum verifies the boundary case from the review: +// gap $0.011 with fraction 0.10 leaves every split amount below the $0.01 +// minimum (buffer $0.0011, flex $0.0099 with stable unknown), so every +// allocation is dropped with its own informational hold and the run is a +// no-op. Nothing is silently lost. +func TestAllocate_AllSplitsSubMinimum(t *testing.T) { + t.Parallel() + layers := awsLayers() + in := &AllocationInput{ + Config: validConfigAWS(), // fraction 0.10 + 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(), + } + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 0 { + t.Fatalf("expected all sub-minimum allocations dropped, got %d: %+v", len(result.Allocations), result.Allocations) + } + skips := 0 + for _, h := range result.Holds { + if strings.Contains(h.Rationale, "below minimum allocatable amount; skipped") { + skips++ + } + } + if skips != 2 { // buffer and flex splits were both sub-minimum + t.Errorf("expected 2 skip holds (buffer + flex), got %d: %+v", skips, result.Holds) + } + if !result.IsNoOp() { + t.Errorf("all-skipped result must be a no-op") + } +} + +// TestAllocate_AzureMerged_StableUnknown_NoteOnFlexOnly verifies that the +// "stable usage unknown" routing note lands ONLY on the flex allocation that +// received the rerouted core gap, not on the merged base+buffer allocation +// whose base share is zero. +func TestAllocate_AzureMerged_StableUnknown_NoteOnFlexOnly(t *testing.T) { + t.Parallel() + layers := azureLayers() + 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(), + } + // bufferGap=5, coreGap=5, baseGap=0 (stable unknown), flexGap=5, merged=5 + result, err := Allocate(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Allocations) != 2 { + t.Fatalf("expected 2 allocations (merged + flex), got %d: %+v", len(result.Allocations), result.Allocations) + } + for _, a := range result.Allocations { + hasNote := strings.Contains(a.Rationale, "stable usage unknown") + switch a.Layer { + case LayerAzureReservation: + if hasNote { + t.Errorf("merged base+buffer rationale must NOT carry the stable-unknown note: %q", a.Rationale) + } + case LayerAzureSavingsPlan: + if !hasNote { + t.Errorf("flex rationale must carry the stable-unknown note: %q", a.Rationale) + } + } + } +} + +// TestAllocateResult_IsNoOp verifies IsNoOp across the result shapes. +func TestAllocateResult_IsNoOp(t *testing.T) { + t.Parallel() + amount := new(big.Rat).SetInt64(1) + cases := []struct { + name string + result AllocateResult + want bool + }{ + {"empty result", AllocateResult{}, true}, + { + "holds only", + AllocateResult{Holds: []PlannedAction{{Action: ActionHold, Layer: LayerComputeSP, Rationale: "x"}}}, + true, + }, + { + "with allocation", + AllocateResult{Allocations: []Allocation{{Layer: LayerComputeSP, GapUSDPerHour: amount, Rationale: "x"}}}, + false, + }, + { + "with reshape", + AllocateResult{Reshapes: []PlannedAction{{Action: ActionReshape, Layer: LayerConvertibleRI, Rationale: "x"}}}, + false, + }, + { + "allocation and holds", + AllocateResult{ + Allocations: []Allocation{{Layer: LayerComputeSP, GapUSDPerHour: amount, Rationale: "x"}}, + Holds: []PlannedAction{{Action: ActionHold, Layer: LayerConvertibleRI, Rationale: "y"}}, + }, + false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.result.IsNoOp(); got != c.want { + t.Errorf("IsNoOp() = %v, want %v", got, c.want) + } + }) + } +} diff --git a/pkg/ladder/types.go b/pkg/ladder/types.go index 0b2753b51..299d094c2 100644 --- a/pkg/ladder/types.go +++ b/pkg/ladder/types.go @@ -268,6 +268,11 @@ const ( // the usage baseline when none is specified. DefaultLookbackDays = 30 + // DefaultBufferUtilizationThresholdPct is the buffer-layer utilization + // percentage below which the engine emits a reshape recommendation. + // A value of 90 means reshape when commitments are less than 90% utilized. + DefaultBufferUtilizationThresholdPct = 90.0 + // rampSumEpsilon is the acceptable absolute error when validating that // RampStep fractions sum to 1.0. Floating-point arithmetic can introduce // small rounding error, so we allow a small tolerance rather than @@ -354,6 +359,10 @@ type LadderConfig struct { // MaxActionsPerRun limits how many PlannedActions the engine may execute // per run. Must be > 0. MaxActionsPerRun int + // BufferUtilizationThresholdPct is the utilization percentage below which a + // buffer layer is flagged for reshape. Must be in (0, 100]. Use + // DefaultBufferUtilizationThresholdPct when no threshold is configured. + BufferUtilizationThresholdPct float64 } // Validate checks that all configuration fields are within valid ranges and @@ -407,10 +416,12 @@ func (c *LadderConfig) validateBaselineBounds() error { return nil } -// validateRunLimits checks the per-run spend and action caps. The hourly -// cap must be a positive finite number when set: NaN and +/-Inf are both -// rejected because big.Rat.SetFloat64 returns nil for non-finite values, -// which would panic downstream. +// validateRunLimits checks the per-run spend and action caps and the buffer +// utilization threshold used for reshape decisions. The hourly cap must be a +// positive finite number when set: NaN and +/-Inf are both rejected because +// big.Rat.SetFloat64 returns nil for non-finite values, which would panic +// downstream. The buffer utilization threshold check is NaN-hostile via +// withinExclIncl (NaN would otherwise slip past a plain range comparison). func (c *LadderConfig) validateRunLimits() error { if v := c.MaxHourlyCommitPerRun; v != nil { if math.IsNaN(*v) || math.IsInf(*v, 0) || !(*v > 0) { @@ -420,6 +431,9 @@ func (c *LadderConfig) validateRunLimits() error { if c.MaxActionsPerRun <= 0 { return fmt.Errorf("max_actions_per_run %d must be > 0", c.MaxActionsPerRun) } + if !withinExclIncl(c.BufferUtilizationThresholdPct, 0, 100) { + return fmt.Errorf("buffer_utilization_threshold_pct %g must be in (0, 100]", c.BufferUtilizationThresholdPct) + } return nil } diff --git a/pkg/ladder/types_test.go b/pkg/ladder/types_test.go index 6d897755c..665c50fe3 100644 --- a/pkg/ladder/types_test.go +++ b/pkg/ladder/types_test.go @@ -678,15 +678,16 @@ func TestLadderConfigValidate(t *testing.T) { // valid is a fully-populated valid config used as the baseline for each // mutation. valid := LadderConfig{ - Scope: Scope{Provider: common.ProviderAWS, AccountID: "123456789012"}, - TargetCoveragePct: 80, - BufferFraction: 0.1, - BaselinePercentile: 5, - LookbackDays: 30, - Mode: ModeEmailApproval, - Cadence: CadenceWeekly, - Ramp: validRamp(), - MaxActionsPerRun: 10, + Scope: Scope{Provider: common.ProviderAWS, AccountID: "123456789012"}, + TargetCoveragePct: 80, + BufferFraction: 0.1, + BaselinePercentile: 5, + LookbackDays: 30, + Mode: ModeEmailApproval, + Cadence: CadenceWeekly, + Ramp: validRamp(), + MaxActionsPerRun: 10, + BufferUtilizationThresholdPct: DefaultBufferUtilizationThresholdPct, } cases := []struct { mutate func(c *LadderConfig) @@ -849,6 +850,44 @@ func TestLadderConfigValidate(t *testing.T) { mutate: func(c *LadderConfig) { c.MaxActionsPerRun = -1 }, wantErr: true, }, + { + name: "buffer utilization threshold zero is invalid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = 0 }, + wantErr: true, + }, + { + name: "buffer utilization threshold negative is invalid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = -1 }, + wantErr: true, + }, + { + name: "buffer utilization threshold above 100 is invalid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = 100.01 }, + wantErr: true, + }, + { + name: "buffer utilization threshold exactly 100 is valid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = 100 }, + wantErr: false, + }, + { + name: "buffer utilization threshold small positive is valid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = 0.1 }, + wantErr: false, + }, + { + // NaN compares false against every bound, so a plain + // "<= 0 || > 100" range check would admit it; withinExclIncl + // rejects it explicitly. + name: "NaN buffer utilization threshold is invalid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = math.NaN() }, + wantErr: true, + }, + { + name: "infinite buffer utilization threshold is invalid", + mutate: func(c *LadderConfig) { c.BufferUtilizationThresholdPct = math.Inf(1) }, + wantErr: true, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) {