Skip to content

feat(ladder)!: convert on-demand gap to commitment dollars in Allocate - #353

Open
cristim wants to merge 1 commit into
fix/ladder-od-to-commitment-349from
feat/ladder-od-to-commitment-alloc-349
Open

cristim wants to merge 1 commit into
fix/ladder-od-to-commitment-349from
feat/ladder-od-to-commitment-alloc-349

Conversation

@cristim

@cristim cristim commented Oct 13, 2026

Copy link
Copy Markdown
Member

Refs #349

Stacked on #352 (base branch fix/ladder-od-to-commitment-349, head c8af0b6). Retarget to main once #352 merges. This PR is pkg/ladder only; the capability method (GetCommitmentRates), the AWS adapter, and the platform wiring are separate PRs. It is a breaking pkg API change on purpose (the platform's compile break is the pin gate).

What

Sizing (baseline, target, existing, gap) is on-demand-equivalent USD/hr, but tranches were bought 1:1, over-buying by 1/rate (30% discount: +43%). Allocate now:

  • splits the OD gap across layers (unchanged), then converts each allocation: commitment = od * rate, exact big.Rat, once, before the cap, the minimum filter and BuildTranches;
  • takes CommitmentRates map[LayerType]float64 (required non-nil, may be partial) and InFlight []InFlightTranche{Layer, CommitmentUSDPerHour, Rate} (required non-nil) replacing InFlightUSDPerHour *float64; in-flight is netted as commitment / stored rate;
  • holds a layer that receives an allocation but has no rate (own share only, never re-routed, never 1:1); a rate outside (0, 1] or non-finite, or an invalid in-flight entry, is an error;
  • applies max_hourly_commit_per_run and the minimum allocatable amount to commitment dollars; the early "target met" check stays on the OD gap;
  • base split now subtracts in-flight base (OD) from the stable level (previously base could be allocated past stable while earlier tranches waited).

Allocation gains OnDemandEquivalentUSDPerHour and Rate; BuildTranches rejects an Allocation without a rate in (0, 1] and stamps it on the new Tranche.CommitmentRate (empty only on pre-go#349 rows). Rounding: Allocate is exact and never rounds up; the purchase boundary must floor commitment to the provider precision (documented on Allocation; implemented in the AWS adapter PR).

Units (verified per field)

OD: Baseline.*, LayerState.*, the gap, Allocation.OnDemandEquivalentUSDPerHour. Commitment: InFlight, Allocation.GapUSDPerHour, Tranche.AmountUSDPerHour, PlannedAction.AmountUSDPerHour, the per-run cap.

Tests

New allocate_units_test.go. Replay with numbers (OD 10, rate 0.7): commitment 7 (before: 10); second run with 7 in flight at 0.7 holds (before: gap 3, re-buy). Also: rate 1 identity, in-flight netted at the stored rate not the current one, expiring, mixed per-layer rates, cap in commitment dollars, missing/NaN/0/>1 rates, nil InFlight/CommitmentRates, invalid in-flight, base-in-flight, sub-minimum after conversion, and seeded exact-rational properties (c = od*r, c <= od, sum c/r == OD gap, monotone in r, capped total <= cap, tranche sum == allocation commitment, tranche rate round-trip). Mutating the conversion to 1:1 fails 8 of them; removing the base in-flight subtraction fails its test. allocate_test.go/ladder_test.go changes are mechanical (rate 1 everywhere, gofmt realignment).

Not verified

No live data: the rate values come from #352, whose CE field semantics still need manual verification before real money.

🤖 Generated with Claude Code

#349)

Sizing is on-demand-equivalent USD/hr but tranches were bought 1:1, over-buying
by 1/rate (30% discount: +43%). Allocate now splits the gap in on-demand
terms, then converts each allocation with its layer's commitment rate
(exact big.Rat). The per-run cap and minimum-allocatable filter apply to
commitment dollars. A layer without a rate is held, never bought 1:1; a rate
outside (0, 1] or non-finite is an error.

In-flight tranches carry the rate stored with them and are netted as
commitment/rate. The base split now counts in-flight base against stable.
BuildTranches rejects an Allocation without a rate and stamps it on
Tranche.CommitmentRate.

Refs #349

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cristim cristim added triaged Item has been triaged urgency/this-sprint Within the current sprint priority/p1 Next up; this sprint severity/high Significant harm impact/many Affects most users effort/m Days type/bug Defect labels Oct 13, 2026
@coderabbitai

coderabbitai Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-go/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 3826029f-bac4-48c5-b9c0-dc0bbcae08d5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Gate review (Opus, money path) at 5e56d74

Verdict: APPROVED-pending-retarget at 5e56d74bd225a0c66ec19c01a352da8ca99608e8. Not merged: stacked on #352 (head c8af0b6). After #352 merges, retarget to main, update-branch, and re-run the gate (CI never ran on this PR: ci.yml only triggers for PRs to main/develop).

Local evidence (git archive 5e56d74, GOTOOLCHAIN=go1.26.9): go test ./pkg/ladder -race -count=1 271 passed; go build ./..., go vet ./ladder, golangci-lint run ./ladder/... (pkg module) clean.

Checks

  1. Direction: allocate_units.go:108 c = od * r; :86 in-flight od = c / r_stored. Hand-computed scratch test (not committed): lowWater 100, target 100%, stable 60, buffer 0.1, rates base 0.6 / flex 0.7 / buffer 0.8, in-flight base 6c@0.6 (=10 OD) + flex 3.5c@0.5 (=7 OD): gap 83 OD -> buffer 8.3 OD = 6.64c, base min(74.7, 60-10)=50 OD = 30c, flex 24.7 OD = 17.29c. All exact matches.
  2. Missing rate: convertAllocations (allocate_units.go:96-104) holds the layer's own share; same scenario without a flex rate: flex Hold "skipped $24.7000/hr", base 30c and buffer 6.64c unchanged (not re-routed). NaN/Inf/<=0/>1 error (:36-44); nil CommitmentRates/InFlight error (allocate.go:232-237).
  3. Exactness: big.Rat throughout; SetFloat64 is exact, so nothing rounds up inside pkg. Floor-at-purchase deferral is documented (allocate_units.go:15-18, Allocation doc allocate.go:77-83) but not tracked by any issue. Also note the platform today persists r.Float64() into amount_usd_hr NUMERIC(20,6) (platform handler_ladder.go buildTrancheDBRows, migration 000081): nearest-float then Postgres half-up rounding can store up to ~$0.0000005/hr more than the planned commitment per tranche. Tiny, but it violates "never round up": PR-D must floor to 6 dp before insert (and the AWS adapter floor to SP precision). Please file a tracking issue (non-blocking for this PR).
  4. Cap unit: applied after conversion on commitment $ (allocate.go:840-870). Platform LadderConfigDB.MaxHourlyCommitPerRun doc (config/types.go:1242) already says "hourly commitment delta a single run may purchase", and pre-fix the gap was bought 1:1, so the same stored number still bounds purchased dollars: no loosening of spend. It now covers 1/r more OD usage per run; the UI label must say commitment $/hr (PR-D). Min-allocatable on commitment, early "target met" on OD gap: as documented.
  5. API break: no callers outside pkg/ladder in this repo (providers/aws/ladder does not call Allocate). Platform breaks at internal/server/handler_ladder.go:342-349 (InFlightUSDPerHour) and internal/config/interfaces.go:625, store_postgres_ladder.go:411 docs + fakes: pin bump + PR-D required. cli/mcp: no pkg/ladder imports.
  6. Base split (allocate.go:746-751): subtracting in-flight base OD from stable is correct and not a double count (total in-flight is netted once from the gap; this only lowers the base ceiling). Nit: for the Azure merged base+buffer layer, buffer in-flight also lowers the base ceiling, shifting a little core gap to flex; total unchanged. Non-blocking.
  7. Mutation (17 mutants on changed lines, 1 run): all killed, including conversion 1:1 (9 fails), inverted rate (9), in-flight without division (3) or multiplied (3), missing rate -> 1:1 (2), held share re-routed to another layer (1), cap summed on OD (1), base in-flight subtraction removed (1), rate >1 / =0 accepted, BuildTranches rate guard removed, tranche rate not stamped, in-flight total not netted (4). No survivors.

Follow-ups (non-blocking, for PR-D / platform): floor-before-persist (item 3); executor and in-flight query must refuse empty Tranche.CommitmentRate legacy rows (store.go accepts empty by design); CommitmentRate is a float-derived RatString (e.g. 3152519739159347/4503599627370496), so the NUMERIC column needs the documented netting tolerance; #352 CE field semantics still need manual verification before real money.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant