Skip to content

Commit 201b877

Browse files
cristimclaude
andcommitted
fix(ladder): net in-flight tranches per row basis in on-demand-equivalent dollars
LadderPlanBuilder now receives per-row LadderInFlightRow values (layer, commitment, rate) instead of one float. Commitment rows carry their stored rate, legacy on-demand rows carry rate 1, and a NULL basis still fails loud in netting. The handler adapter sums the rows exactly and rounds up only when the float is below the exact total, so legacy-only data is unchanged. The amend ceiling reads the same rows (NULL basis assumed on-demand there, per #886). GetInFlightLadderCommitUSDHr, a raw mixed-basis SUM with no production caller, is removed and its tests assert the on-demand netting. Refs #799 Refs LeanerCloud/cloud-commitments-go#349 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b49b494 commit 201b877

21 files changed

Lines changed: 355 additions & 161 deletions

‎internal/analytics/collector_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ func (m *mockConfigStore) SaveLadderRun(_ context.Context, run *config.LadderRun
876876
return run, nil
877877
}
878878
func (m *mockConfigStore) PlanAndSaveLadderRun(_ context.Context, _ string, _ time.Time, build config.LadderPlanBuilder) (*config.LadderRunDB, error) {
879-
run, _, err := build(0)
879+
run, _, err := build(nil)
880880
return run, err
881881
}
882882
func (m *mockConfigStore) GetLadderRun(_ context.Context, _ string) (*config.LadderRunDB, error) {

‎internal/config/ladder_provenance.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"fmt"
55
"math"
66
"math/big"
7+
8+
"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
79
)
810

911
type LadderAmountBasis string
@@ -85,3 +87,89 @@ func nettingFloat(total *big.Rat) (float64, error) {
8587
}
8688
return value, nil
8789
}
90+
91+
// LadderInFlightRow is one netted tranche. Rate is rounded down and the
92+
// commitment up, so CommitmentUSDPerHour/Rate never understates the exact
93+
// on-demand-equivalent amount. Legacy on-demand rows carry Rate 1.
94+
type LadderInFlightRow struct {
95+
Layer ladder.LayerType
96+
CommitmentUSDPerHour float64
97+
Rate float64
98+
exactOD *big.Rat // set by the store so legacy totals stay bit-identical to the exact sum
99+
}
100+
101+
// assumeOnDemandIfUnknown covers only the amend path: rows from before 000121
102+
// have no basis and #886 deliberately left them unknown, so the amend ceiling
103+
// ASSUMES on-demand-equivalent (its behavior before the basis existed).
104+
// Netting never calls this and keeps failing loud on an unknown basis.
105+
func assumeOnDemandIfUnknown(tr *LadderTrancheDB) *LadderTrancheDB {
106+
if tr.AmountBasis != nil || tr.CommitmentRate != nil {
107+
return tr
108+
}
109+
assumed, basis := *tr, LadderAmountOnDemand
110+
assumed.AmountBasis = &basis
111+
return &assumed
112+
}
113+
114+
func newLadderInFlightRow(amount string, tr *LadderTrancheDB) (LadderInFlightRow, error) {
115+
od, err := nettingOnDemand(amount, tr)
116+
if err != nil {
117+
return LadderInFlightRow{}, err
118+
}
119+
canonical, err := trancheCommitmentRate(tr)
120+
if err != nil {
121+
return LadderInFlightRow{}, err
122+
}
123+
rate := big.NewRat(1, 1)
124+
if canonical != nil {
125+
if rate, err = parseStoredCommitmentRate(*canonical); err != nil {
126+
return LadderInFlightRow{}, err
127+
}
128+
}
129+
commitment, _ := new(big.Rat).SetString(amount)
130+
commitmentUp, err := nettingFloat(commitment)
131+
if err != nil {
132+
return LadderInFlightRow{}, err
133+
}
134+
rateDown, _ := rate.Float64()
135+
if new(big.Rat).SetFloat64(rateDown).Cmp(rate) > 0 {
136+
rateDown = math.Nextafter(rateDown, 0)
137+
}
138+
rateDown = max(rateDown, math.SmallestNonzeroFloat64)
139+
return LadderInFlightRow{Layer: tr.LayerType, CommitmentUSDPerHour: commitmentUp, Rate: rateDown, exactOD: od}, nil
140+
}
141+
142+
func (r LadderInFlightRow) onDemand() (*big.Rat, error) {
143+
if r.exactOD != nil {
144+
return r.exactOD, nil
145+
}
146+
commitment := new(big.Rat).SetFloat64(r.CommitmentUSDPerHour)
147+
if commitment == nil || commitment.Sign() < 0 || !(r.Rate > 0 && r.Rate <= 1) {
148+
return nil, fmt.Errorf("invalid in-flight row: commitment %v at rate %v", r.CommitmentUSDPerHour, r.Rate)
149+
}
150+
return commitment.Quo(commitment, new(big.Rat).SetFloat64(r.Rate)), nil
151+
}
152+
153+
func sumLadderOnDemand(rows ...[]LadderInFlightRow) (*big.Rat, error) {
154+
total := new(big.Rat)
155+
for _, set := range rows {
156+
for _, r := range set {
157+
od, err := r.onDemand()
158+
if err != nil {
159+
return nil, err
160+
}
161+
total.Add(total, od)
162+
}
163+
}
164+
return total, nil
165+
}
166+
167+
// LadderInFlightOnDemand is the rows' on-demand-equivalent total, summed
168+
// exactly and rounded up to the next float only when the float is below it.
169+
func LadderInFlightOnDemand(rows []LadderInFlightRow) (float64, error) {
170+
total, err := sumLadderOnDemand(rows)
171+
if err != nil {
172+
return 0, err
173+
}
174+
return nettingFloat(total)
175+
}

‎internal/config/ladder_provenance_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,38 @@ func TestLadderNetting_ExactRatesAndBoundary(t *testing.T) {
4545
_, err := nettingFloat(new(big.Rat).SetInt(new(big.Int).Lsh(big.NewInt(1), 2048)))
4646
require.Error(t, err)
4747
}
48+
49+
func TestLadderInFlightOnDemand_RoundsAwayFromUnderstating(t *testing.T) {
50+
native, third := LadderAmountCommitment, "1/3"
51+
tr := &LadderTrancheDB{AmountBasis: &native, CommitmentRate: &third}
52+
row, err := newLadderInFlightRow("1.000001", tr)
53+
require.NoError(t, err)
54+
exact := big.NewRat(3000003, 1000000)
55+
require.LessOrEqual(t, new(big.Rat).SetFloat64(row.Rate).Cmp(big.NewRat(1, 3)), 0, "rate rounds down")
56+
require.GreaterOrEqual(t, new(big.Rat).SetFloat64(row.CommitmentUSDPerHour).Cmp(big.NewRat(1000001, 1000000)), 0, "commitment rounds up")
57+
row.exactOD = nil // consumers that only see the floats must still not understate
58+
total, err := LadderInFlightOnDemand([]LadderInFlightRow{row})
59+
require.NoError(t, err)
60+
require.GreaterOrEqual(t, new(big.Rat).SetFloat64(total).Cmp(exact), 0)
61+
}
62+
63+
func TestLadderInFlightOnDemand_RejectsUnusableRows(t *testing.T) {
64+
for _, row := range []LadderInFlightRow{
65+
{CommitmentUSDPerHour: 1, Rate: 0}, {CommitmentUSDPerHour: 1, Rate: 1.5}, {CommitmentUSDPerHour: 1, Rate: math.NaN()},
66+
{CommitmentUSDPerHour: -1, Rate: 1}, {CommitmentUSDPerHour: math.Inf(1), Rate: 1},
67+
} {
68+
_, err := LadderInFlightOnDemand([]LadderInFlightRow{row})
69+
require.Error(t, err, "%+v", row)
70+
}
71+
}
72+
73+
func TestAssumeOnDemandIfUnknown_OnlyForUnprovenBasis(t *testing.T) {
74+
assumed := assumeOnDemandIfUnknown(&LadderTrancheDB{})
75+
require.NotNil(t, assumed.AmountBasis)
76+
require.Equal(t, LadderAmountOnDemand, *assumed.AmountBasis)
77+
rate := "1/2"
78+
orphan := &LadderTrancheDB{CommitmentRate: &rate}
79+
require.Same(t, orphan, assumeOnDemandIfUnknown(orphan), "a rate without a basis is corrupt, not legacy")
80+
_, err := nettingOnDemand("1", &LadderTrancheDB{})
81+
require.Error(t, err, "netting still refuses an unknown basis")
82+
}

‎internal/config/store_postgres_ladder.go‎

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ func insertLadderTranchesTx(ctx context.Context, tx pgx.Tx, tranches []LadderTra
327327

328328
// cancelOverdueLadderTranchesTx marks the config's scheduled tranches that have
329329
// outlived LadderFireGrace as cancelled: scheduled_date <= ladderGraceCutoff(asOf),
330-
// the exact complement of the netting bound in GetInFlightLadderCommitUSDHr.
330+
// the exact complement of the netting bound in inFlightLadderRows.
331331
// No executor transitions tranches yet (issue #118), so without this the
332332
// overdue amount grows with every run and a future due-row sweep would buy all
333333
// of it at once (#544). Rows already linked to an execution are left alone.
@@ -410,34 +410,6 @@ func insertLadderRunWithTranchesTx(ctx context.Context, tx pgx.Tx, run *LadderRu
410410
return &scanned, nil
411411
}
412412

413-
// GetInFlightLadderCommitUSDHr returns the total hourly USD commitment already
414-
// in flight for the given config: the SUM of amount_usd_hr for ladder_tranches
415-
// rows where config_id=$1 and either
416-
// - status = 'scheduled' and scheduled_date > ladderGraceCutoff(asOf): not yet
417-
// bought and still fireable (future, or due but within LadderFireGrace), or
418-
// - status = 'fired': claimed for purchase but not yet resolved (approval
419-
// pending, in progress, or outcome unknown). Fired nets regardless of age;
420-
// only a transition to completed or failed releases it.
421-
//
422-
// Excluded: completed (counted in the engine's ExistingUSDPerHour, but only if
423-
// that figure was read after the tranche became visible: a caller that read
424-
// ExistingUSDPerHour before this call must also add settledLadderCommit with
425-
// ladderSettleAnchor(asOf), as PlanAndSaveLadderRun does, or a tranche that
426-
// completes in between is netted nowhere), failed and cancelled (never bought; counting
427-
// them would silently under-buy), and scheduled rows past the grace window
428-
// (cancelled by cancelOverdueLadderTranchesTx with the complementary bound).
429-
//
430-
// Returns a non-nil *float64 (zero when nothing is in flight); never returns
431-
// nil without a non-nil error, so callers can pass it directly to
432-
// AllocationInput.InFlightUSDPerHour without an extra nil-guard.
433-
func (s *PostgresStore) GetInFlightLadderCommitUSDHr(ctx context.Context, configID string, asOf time.Time) (*float64, error) {
434-
total, err := inFlightLadderCommit(ctx, s.db, configID, asOf)
435-
if err != nil {
436-
return nil, err
437-
}
438-
return &total, nil
439-
}
440-
441413
// LatestLadderRunStartedAt returns the maximum started_at for the given
442414
// config_id, or nil when no run has been recorded yet. Drives the per-cadence
443415
// self-gate in handleLadderRun (Q6).

‎internal/config/store_postgres_ladder_amend_ceiling.go‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,7 @@ func checkLadderConfigCeiling(ctx context.Context, tx pgx.Tx, configID, oldAmoun
5151
if err != nil {
5252
return err
5353
}
54-
inFlight, err := inFlightLadderCommit(ctx, tx, configID, now)
55-
if err != nil {
56-
return err
57-
}
58-
settled, err := settledLadderCommit(ctx, tx, configID, ladderSettleAnchor(snap.anchor))
54+
inFlight, settled, err := ladderCommittedOnDemand(ctx, tx, configID, now, ladderSettleAnchor(snap.anchor))
5955
if err != nil {
6056
return err
6157
}
@@ -71,6 +67,24 @@ func checkLadderConfigCeiling(ctx context.Context, tx pgx.Tx, configID, oldAmoun
7167
return nil
7268
}
7369

70+
// ladderCommittedOnDemand nets the in-flight and settled-after-anchor rows to
71+
// on-demand-equivalent dollars, assuming on-demand for rows with no stored basis.
72+
func ladderCommittedOnDemand(ctx context.Context, tx pgx.Tx, configID string, now, anchor time.Time) (inFlight, settled float64, err error) {
73+
inFlightRows, err := inFlightLadderRows(ctx, tx, true, configID, now)
74+
if err != nil {
75+
return 0, 0, err
76+
}
77+
settledRows, err := settledLadderRows(ctx, tx, true, configID, anchor)
78+
if err != nil {
79+
return 0, 0, err
80+
}
81+
if inFlight, err = LadderInFlightOnDemand(inFlightRows); err != nil {
82+
return 0, 0, err
83+
}
84+
settled, err = LadderInFlightOnDemand(settledRows)
85+
return inFlight, settled, err
86+
}
87+
7488
type ladderCeilingSnapshot struct {
7589
anchor time.Time
7690
ceiling, oldAmount, newAmount float64

‎internal/config/store_postgres_ladder_amend_ceiling_integration_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (f *ceilingFixture) plan(target, existing, baseline *float64, amounts ...fl
4747
asOf := time.Now().UTC().Add(-time.Duration(10-f.runs) * time.Minute).Truncate(time.Microsecond)
4848
runID := uuid.NewString()
4949
var ids []string
50-
_, err := f.store.PlanAndSaveLadderRun(f.ctx, f.cfgID, asOf, func(float64) (*LadderRunDB, []LadderTrancheDB, error) {
50+
_, err := f.store.PlanAndSaveLadderRun(f.ctx, f.cfgID, asOf, func([]LadderInFlightRow) (*LadderRunDB, []LadderTrancheDB, error) {
5151
trs := make([]LadderTrancheDB, len(amounts))
5252
for i, a := range amounts {
5353
trs[i] = lifecycleTranche(f.cfgID, a, ladder.TrancheStatusScheduled, time.Now().UTC().Add(time.Hour))
@@ -77,9 +77,9 @@ func (f *ceilingFixture) amend(id string, amount float64) error {
7777

7878
func (f *ceilingFixture) inFlight() float64 {
7979
f.t.Helper()
80-
v, err := inFlightLadderCommit(f.ctx, f.store.db, f.cfgID, time.Now())
80+
rows, err := inFlightLadderRows(f.ctx, f.store.db, true, f.cfgID, time.Now())
8181
require.NoError(f.t, err)
82-
return v
82+
return mustOnDemand(f.t, rows)
8383
}
8484

8585
func (f *ceilingFixture) move(id string, from, to ladder.TrancheStatus) {
@@ -177,10 +177,10 @@ func TestLadderAmendCeiling_WaitsForPlanner(t *testing.T) {
177177
asOf := time.Now().UTC().Truncate(time.Microsecond)
178178
go func() {
179179
runID := uuid.NewString()
180-
_, err := f.store.PlanAndSaveLadderRun(f.ctx, f.cfgID, asOf, func(inFlight float64) (*LadderRunDB, []LadderTrancheDB, error) {
180+
_, err := f.store.PlanAndSaveLadderRun(f.ctx, f.cfgID, asOf, func(rows []LadderInFlightRow) (*LadderRunDB, []LadderTrancheDB, error) {
181181
close(entered)
182182
<-gate
183-
tr := lifecycleTranche(f.cfgID, 10-inFlight, ladder.TrancheStatusScheduled, time.Now().UTC().Add(time.Hour))
183+
tr := lifecycleTranche(f.cfgID, 10-mustOnDemand(f.t, rows), ladder.TrancheStatusScheduled, time.Now().UTC().Add(time.Hour))
184184
basis := LadderAmountOnDemand
185185
tr.AmountBasis = &basis
186186
tr.RunID = &runID
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//go:build integration
2+
3+
package config
4+
5+
// Amend-path behavior per stored amount basis (PR A). Commitment rows are
6+
// seeded directly: no writer produces them yet.
7+
8+
import (
9+
"math"
10+
"math/big"
11+
"strconv"
12+
"testing"
13+
"time"
14+
15+
"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func mustOnDemand(t *testing.T, rows []LadderInFlightRow) float64 {
20+
t.Helper()
21+
v, err := LadderInFlightOnDemand(rows)
22+
require.NoError(t, err)
23+
return v
24+
}
25+
26+
func (f *ceilingFixture) setBasis(id, basis, rate string) {
27+
f.t.Helper()
28+
_, err := f.store.db.Exec(f.ctx, "UPDATE ladder_tranches SET amount_basis=NULLIF($2,''),commitment_rate=NULLIF($3,'') WHERE id=$1", id, basis, rate)
29+
require.NoError(f.t, err)
30+
}
31+
32+
func seedNettingRows(t *testing.T, f *ceilingFixture, basis LadderAmountBasis, rate string, amounts ...string) {
33+
t.Helper()
34+
for _, a := range amounts {
35+
v, err := strconv.ParseFloat(a, 64)
36+
require.NoError(t, err)
37+
tr := lifecycleTranche(f.cfgID, v, ladder.TrancheStatusFired, lockTestAsOf.Add(-time.Hour))
38+
tr.AmountBasis = &basis
39+
if rate != "" {
40+
tr.CommitmentRate = &rate
41+
}
42+
require.NoError(t, f.store.SaveLadderTranches(f.ctx, []LadderTrancheDB{tr}))
43+
}
44+
}
45+
46+
func plannedRows(t *testing.T, f *ceilingFixture) []LadderInFlightRow {
47+
t.Helper()
48+
var got []LadderInFlightRow
49+
_, err := f.store.PlanAndSaveLadderRun(f.ctx, f.cfgID, lockTestAsOf, func(rows []LadderInFlightRow) (*LadderRunDB, []LadderTrancheDB, error) {
50+
got = rows
51+
return lockTestRun(f.cfgID), nil, nil
52+
})
53+
require.NoError(t, err)
54+
return got
55+
}
56+
57+
// The reference is the pre-change algorithm: sum the decimals exactly, then
58+
// round up to a float only when the nearest float is below the sum.
59+
func TestLadderNetting_LegacyOnlyRowsKeepTheExactTotalBitForBit(t *testing.T) {
60+
f := newCeilingFixture(t)
61+
amounts := []string{"0.1", "0.2", "0.3", "1234.567891", "0.000001", "0.000001", "7.7", "19.999999"}
62+
seedNettingRows(t, f, LadderAmountOnDemand, "", amounts...)
63+
exact := new(big.Rat)
64+
for _, a := range amounts {
65+
v, ok := new(big.Rat).SetString(a)
66+
require.True(t, ok)
67+
exact.Add(exact, v)
68+
}
69+
want, err := nettingFloat(exact)
70+
require.NoError(t, err)
71+
got := mustOnDemand(t, plannedRows(t, f))
72+
require.Equal(t, math.Float64bits(want), math.Float64bits(got))
73+
}
74+
75+
func TestLadderNetting_ThirdRateRowNeverNetsBelowTheExactRational(t *testing.T) {
76+
f := newCeilingFixture(t)
77+
seedNettingRows(t, f, LadderAmountCommitment, "1/3", "1.000001")
78+
rows := plannedRows(t, f)
79+
require.Len(t, rows, 1)
80+
require.LessOrEqual(t, new(big.Rat).SetFloat64(rows[0].Rate).Cmp(big.NewRat(1, 3)), 0)
81+
got := mustOnDemand(t, rows)
82+
require.GreaterOrEqual(t, new(big.Rat).SetFloat64(got).Cmp(big.NewRat(3000003, 1000000)), 0)
83+
}
84+
85+
func TestLadderNetting_NullBasisStillFailsLoudBeforeBuilder(t *testing.T) {
86+
f := newCeilingFixture(t)
87+
_, ids := f.plan(fptr(10), fptr(0), fptr(100), 5)
88+
f.setBasis(ids[0], "", "")
89+
built := false
90+
_, err := f.store.PlanAndSaveLadderRun(f.ctx, f.cfgID, time.Now().UTC().Truncate(time.Microsecond), func([]LadderInFlightRow) (*LadderRunDB, []LadderTrancheDB, error) {
91+
built = true
92+
return nil, nil, nil
93+
})
94+
require.Error(t, err)
95+
require.False(t, built)
96+
}

‎internal/config/store_postgres_ladder_lifecycle_integration_test.go‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,9 @@ func newExecCreator(ctx context.Context, t *testing.T, store *PostgresStore, cal
5757

5858
func inFlight(ctx context.Context, t *testing.T, store *PostgresStore, cfgID string, asOf time.Time) float64 {
5959
t.Helper()
60-
v, err := store.GetInFlightLadderCommitUSDHr(ctx, cfgID, asOf)
60+
rows, err := inFlightLadderRows(ctx, store.db, true, cfgID, asOf)
6161
require.NoError(t, err)
62-
require.NotNil(t, v)
63-
return *v
62+
return mustOnDemand(t, rows)
6463
}
6564

6665
// Property: for every status x scheduled_date offset around the grace

0 commit comments

Comments
 (0)