From f1bdefa4d6261a9c1f69d43a34b668a7701d9d14 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 20 Oct 2026 14:00:58 +0100 Subject: [PATCH 1/2] fix(ladder): floor tranche amounts to the stored scale instead of rounding them up Tranche amounts are float64 stored in NUMERIC(20,6). Two seams could round a planned commitment up, against the go precision contract (pkg/ladder LadderCapability.PurchaseLayer, go#354/#403/#438: floor, never round up; refuse outside [0.001, 1000000] USD/hr; truncate to five decimals): - buildTrancheDBRows converted the exact *big.Rat to the nearest float64. - Postgres rounded the float's decimal half-up to six places on insert, so 0.0009995 was stored as the 0.001 minimum, 999999.9999999 as 1000000 and 0.1234599 as 0.123460 (five-decimal wire 0.12346 instead of 0.12345). The planner now floors to six decimals (config.LadderAmountFloat), and the store writes an exact decimal and refuses an amount the column would round (ladderAmountText), so every stored amount reads back as the same float64. No migration: six decimals already exceed the provider's five. TestLadderB1_DailyRerunNeverExceedsTarget now requires live <= target on every day (half-up rounding over-planned to 10.000001 on day 7) and allows the floored fixed point to sit up to one micro-dollar per ramp step below it. Refs #1182 Co-Authored-By: Claude Opus 5.5 --- internal/config/ladder_provenance.go | 41 ++++++++ internal/config/ladder_provenance_test.go | 82 ++++++++++++++++ internal/config/store_postgres_ladder.go | 8 +- ...tgres_ladder_precision_integration_test.go | 94 +++++++++++++++++++ internal/server/handler_ladder.go | 11 ++- ...ndler_ladder_immediate_integration_test.go | 10 +- internal/server/handler_ladder_test.go | 23 +++++ 7 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 internal/config/store_postgres_ladder_precision_integration_test.go diff --git a/internal/config/ladder_provenance.go b/internal/config/ladder_provenance.go index a9aa624bc..96fe71757 100644 --- a/internal/config/ladder_provenance.go +++ b/internal/config/ladder_provenance.go @@ -4,6 +4,8 @@ import ( "fmt" "math" "math/big" + "strconv" + "strings" "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" ) @@ -173,3 +175,42 @@ func LadderInFlightOnDemand(rows []LadderInFlightRow) (float64, error) { } return nettingFloat(total) } + +// ladderAmountScale is the fractional-digit scale of +// ladder_tranches.amount_usd_hr (NUMERIC(20,6), migration 000081). +const ladderAmountScale = 6 + +// LadderAmountFloat converts an exact planned tranche amount to the float64 a +// tranche row stores, flooring it to the column's six decimal places. The go +// precision contract (pkg/ladder LadderCapability.PurchaseLayer, go#354) +// floors provider commitments and forbids callers rounding a *big.Rat up on the +// way to float64; nearest-float conversion can do exactly that just below a +// decimal boundary. Flooring at six decimals keeps a provider's five-decimal +// truncation identical to truncating the exact amount and keeps an amount +// below a provider minimum below it. The result's shortest decimal form is the +// floored value itself (at most 15 significant digits below 1e9 USD/hr), so the +// store writes it without any further rounding. +func LadderAmountFloat(amount *big.Rat) float64 { + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(ladderAmountScale), nil) + floored := new(big.Int).Mul(amount.Num(), scale) + floored.Div(floored, amount.Denom()) // Euclidean division: floor for the positive denominator + f, _ := new(big.Rat).SetFrac(floored, scale).Float64() + return f +} + +// ladderAmountText is the exact decimal insertLadderTranchesTx writes for a +// tranche amount. Sent as a float, the amount's shortest decimal would be +// rounded half-up to the column's scale by Postgres, which can carry it across +// a provider's five-decimal truncation boundary or up to a provider minimum. +// An amount the column cannot hold exactly is refused instead, so every stored +// amount reads back as the same float64. +func ladderAmountText(amount float64) (string, error) { + if math.IsNaN(amount) || math.IsInf(amount, 0) { + return "", fmt.Errorf("amount_usd_hr must be finite, got %v", amount) + } + text := strconv.FormatFloat(amount, 'f', -1, 64) + if _, fraction, ok := strings.Cut(text, "."); ok && len(fraction) > ladderAmountScale { + return "", fmt.Errorf("amount_usd_hr %s has more than %d decimal places and would be rounded by the column; floor it with LadderAmountFloat", text, ladderAmountScale) + } + return text, nil +} diff --git a/internal/config/ladder_provenance_test.go b/internal/config/ladder_provenance_test.go index cb2c16ec7..d65ac85a6 100644 --- a/internal/config/ladder_provenance_test.go +++ b/internal/config/ladder_provenance_test.go @@ -3,6 +3,7 @@ package config import ( "math" "math/big" + "strconv" "strings" "testing" @@ -109,3 +110,84 @@ func TestNewLadderInFlightRow_FloatFieldsFeedAllocate(t *testing.T) { require.NoError(t, err) require.Equal(t, math.SmallestNonzeroFloat64, row.Rate, "a rate below float range must not become 0") } + +// spCommitmentWire mirrors cloud-commitments-go +// providers/aws/internal/spcommitment.Normalize at the version go.mod pins +// (go#403, contract at pkg/ladder/capability.go): refuse an amount outside +// [0.001, 1000000] USD/hr, else truncate its shortest decimal to five places. +// ok=false is a refusal; the returned rat is the submitted commitment. +func spCommitmentWire(amount float64) (*big.Rat, bool) { + if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0.001 || amount > 1000000 { + return nil, false + } + whole, fraction, _ := strings.Cut(strconv.FormatFloat(amount, 'f', -1, 64), ".") + if len(fraction) > 5 { + fraction = fraction[:5] + } + wire, _ := new(big.Rat).SetString(whole + "." + fraction + "0") + return wire, true +} + +// spCommitmentWireExact is the commitment the contract would submit for the +// exact planned amount: the same range check, then a floor to five places. +func spCommitmentWireExact(amount *big.Rat) (*big.Rat, bool) { + if amount.Cmp(big.NewRat(1, 1000)) < 0 || amount.Cmp(big.NewRat(1000000, 1)) > 0 { + return nil, false + } + scaled := new(big.Int).Mul(amount.Num(), big.NewInt(100000)) + return new(big.Rat).SetFrac(scaled.Div(scaled, amount.Denom()), big.NewInt(100000)), true +} + +func requireSameWire(t *testing.T, want *big.Rat, wantOK bool, got *big.Rat, gotOK bool, msg string) { + t.Helper() + require.Equal(t, wantOK, gotOK, "%s: provider refusal must not change", msg) + if wantOK { + require.Zero(t, want.Cmp(got), "%s: submitted commitment %s, want %s", msg, got.FloatString(6), want.FloatString(6)) + } +} + +// Issue #1182: a planned amount is floored to the six-decimal column, never +// rounded up, so the provider submits what it would for the exact amount. +func TestLadderAmountFloat_FloorsToStoredScaleWithoutRoundingUp(t *testing.T) { + for _, tc := range []struct{ planned, want string }{ + {"1/3", "0.333333"}, + {"12345999999999999/100000000000000000", "0.123459"}, // nearest float64 is 0.12346 + {"1234567/10000000", "0.123456"}, + {"9995/10000000", "0.000999"}, // half-up would reach the 0.001 minimum + {"9999999999999/10000000", "999999.999999"}, // half-up would reach 1000000 + {"99999999999/100000", "999999.99999"}, + {"1000000", "1000000"}, + {"1/10", "0.1"}, + {"1/1000", "0.001"}, + {"1/10000000", "0"}, + } { + planned, ok := new(big.Rat).SetString(tc.planned) + require.True(t, ok, tc.planned) + got := LadderAmountFloat(planned) + text, err := ladderAmountText(got) + require.NoError(t, err, tc.planned) + require.Equal(t, tc.want, text, tc.planned) + stored, _ := new(big.Rat).SetString(text) + require.LessOrEqual(t, stored.Cmp(planned), 0, "%s: stored amount must not exceed the plan", tc.planned) + want, wantOK := spCommitmentWireExact(planned) + wire, wireOK := spCommitmentWire(got) + requireSameWire(t, want, wantOK, wire, wireOK, tc.planned) + } +} + +// Issue #1182: the store refuses an amount the NUMERIC(20,6) column would +// round rather than letting Postgres round it half-up. +func TestLadderAmountText_RefusesAmountsTheColumnWouldRound(t *testing.T) { + for amount, want := range map[float64]string{ + 0.001: "0.001", 1000000: "1000000", 999999.99999: "999999.99999", + 999999.999999: "999999.999999", 0.1: "0.1", 0.29: "0.29", 12.5: "12.5", + } { + got, err := ladderAmountText(amount) + require.NoError(t, err) + require.Equal(t, want, got) + } + for _, amount := range []float64{0.1234599, 0.30000000000000004 /* float64 0.1+0.2 */, 1.0 / 3, 0.0009995, 999999.9999999, math.NaN(), math.Inf(1), math.Inf(-1)} { + _, err := ladderAmountText(amount) + require.Error(t, err, "%v", amount) + } +} diff --git a/internal/config/store_postgres_ladder.go b/internal/config/store_postgres_ladder.go index 71994fbb2..7c171158d 100644 --- a/internal/config/store_postgres_ladder.go +++ b/internal/config/store_postgres_ladder.go @@ -297,18 +297,22 @@ func insertLadderTranchesTx(ctx context.Context, tx pgx.Tx, tranches []LadderTra if err != nil { return fmt.Errorf("ladder_tranche %s: %w", tr.ID, err) } + amount, err := ladderAmountText(tr.AmountUSDHr) + if err != nil { + return fmt.Errorf("ladder_tranche %s: %w", tr.ID, err) + } _, err = tx.Exec(ctx, ` INSERT INTO ladder_tranches ( id, config_id, run_id, layer_type, amount_usd_hr, term, payment_option, scheduled_date, status, execution_id, settled_at, amount_basis, commitment_rate, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) + ) VALUES ($1, $2, $3, $4, $5::numeric, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) `, tr.ID, tr.ConfigID, tr.RunID, string(tr.LayerType), - tr.AmountUSDHr, + amount, string(tr.Term), string(tr.PaymentOption), tr.ScheduledDate, diff --git a/internal/config/store_postgres_ladder_precision_integration_test.go b/internal/config/store_postgres_ladder_precision_integration_test.go new file mode 100644 index 000000000..b9400ceba --- /dev/null +++ b/internal/config/store_postgres_ladder_precision_integration_test.go @@ -0,0 +1,94 @@ +//go:build integration + +package config + +import ( + "context" + "math" + "math/big" + "sync/atomic" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/stretchr/testify/require" +) + +// Issue #1182: tranche amounts are float64 stored in NUMERIC(20,6). Against a +// real Postgres, every amount the store accepts must read back as the same +// float64 through the claim path a tranche executor will use, so the go +// precision contract (range check, then five-decimal truncation; see +// spCommitmentWire) gives the same answer for the stored amount as for the +// written one, and for planner amounts, as for the exact plan. +func TestLadderTrancheAmount_RoundTripsProviderPrecisionContract(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + store := setupLadderStore(ctx, t) + cfgID := seedLadderConfig(ctx, t, store) + asOf := time.Now().UTC().Truncate(time.Microsecond) + due := asOf.Add(-time.Minute) + + written := map[string]float64{} + planned := map[string]*big.Rat{} + var rows []LadderTrancheDB + add := func(amount float64, plan *big.Rat) { + tr := lifecycleTranche(cfgID, amount, ladder.TrancheStatusScheduled, due) + written[tr.ID], planned[tr.ID] = amount, plan + rows = append(rows, tr) + } + // Contract edges the column holds exactly, including values float64 + // cannot represent exactly (0.1, 0.29, 999999.99999) and the range ends. + for _, amount := range []float64{ + 0.001, 0.00101, 0.000999, 0.1, 0.29, 0.12345, 1.00001, 123456.78901, + 999999.99999, 999999.999999, 1000000, 1000000.000001, 99999999.999999, + } { + add(amount, nil) + } + // Exact planner amounts finer than the column, floored by LadderAmountFloat. + for _, text := range []string{ + "1/3", "12345999999999999/100000000000000000", "1234567/10000000", + "9995/10000000", "9999999999999/10000000", "1000000", + } { + plan, ok := new(big.Rat).SetString(text) + require.True(t, ok, text) + add(LadderAmountFloat(plan), plan) + } + require.NoError(t, store.SaveLadderTranches(ctx, rows)) + + var calls atomic.Int64 + claimed, err := store.ClaimDueLadderTranches(ctx, cfgID, asOf, len(rows), newExecCreator(ctx, t, store, &calls)) + require.NoError(t, err) + require.Len(t, claimed, len(rows)) + for i := range claimed { + tr := &claimed[i] + amount, ok := written[tr.ID] + require.True(t, ok, tr.ID) + require.Equal(t, math.Float64bits(amount), math.Float64bits(tr.AmountUSDHr), "stored %v read back as %v", amount, tr.AmountUSDHr) + var text string + require.NoError(t, store.db.QueryRow(ctx, `SELECT amount_usd_hr::text FROM ladder_tranches WHERE id = $1`, tr.ID).Scan(&text)) + stored, _ := new(big.Rat).SetString(text) + wantText, err := ladderAmountText(amount) + require.NoError(t, err) + decimal, _ := new(big.Rat).SetString(wantText) + require.Zero(t, decimal.Cmp(stored), "column holds %s for %s", text, wantText) + + want, wantOK := spCommitmentWire(amount) + if plan := planned[tr.ID]; plan != nil { + require.LessOrEqual(t, stored.Cmp(plan), 0, "stored %s exceeds planned %s", text, plan.RatString()) + want, wantOK = spCommitmentWireExact(plan) + } + got, gotOK := spCommitmentWire(tr.AmountUSDHr) + requireSameWire(t, want, wantOK, got, gotOK, text) + } + + // An amount the column would round half-up is refused, and its batch + // rolls back, instead of being stored as a different amount. + for _, amount := range []float64{0.1234599, 0.0009995, 999999.9999999, 1.0 / 3, math.NaN()} { + first := lifecycleTranche(cfgID, 1, ladder.TrancheStatusScheduled, due) + bad := lifecycleTranche(cfgID, amount, ladder.TrancheStatusScheduled, due) + require.Error(t, store.SaveLadderTranches(ctx, []LadderTrancheDB{first, bad}), "%v", amount) + var count int + require.NoError(t, store.db.QueryRow(ctx, `SELECT count(*) FROM ladder_tranches`).Scan(&count)) + require.Equal(t, len(rows), count, "refused batch for %v must roll back", amount) + } +} diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index 163381334..85508c5f3 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -711,15 +711,16 @@ func buildTrancheDBRows(tranches []pkgladder.Tranche, runID string, configID *st rows := make([]config.LadderTrancheDB, 0, len(tranches)) for i := range tranches { tr := &tranches[i] // use pointer to avoid copying 152-byte Tranche struct per iteration - // AmountUSDHr: parse the RatString back to float64. A missing or - // malformed RatString means BuildTranches produced a broken tranche; - // that should never happen after Validate() passes, so surface it as - // an error instead of writing a $0 row. + // AmountUSDHr: parse the RatString and floor it to the stored scale + // (never round a planned commitment up; see LadderAmountFloat). A + // missing or malformed RatString means BuildTranches produced a broken + // tranche; that should never happen after Validate() passes, so + // surface it as an error instead of writing a $0 row. r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour) if !ok || r == nil { return nil, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour) } - amountUSDHr, _ := r.Float64() + amountUSDHr := config.LadderAmountFloat(r) runIDCopy := runID if tr.CommitmentRate == "" { return nil, fmt.Errorf("tranche %s: empty commitment rate", tr.ID) diff --git a/internal/server/handler_ladder_immediate_integration_test.go b/internal/server/handler_ladder_immediate_integration_test.go index 3580ae5c0..533feade7 100644 --- a/internal/server/handler_ladder_immediate_integration_test.go +++ b/internal/server/handler_ladder_immediate_integration_test.go @@ -228,7 +228,10 @@ func TestLadderB1_NextRunNetsImmediateRowWithinGrace(t *testing.T) { } // Fixed point: re-running daily for 40 days (nothing fires: no executor) never -// leaves more live commitment than target-E, and ends at the target. +// leaves more live commitment than target-E, and ends at the target. Each ramp +// step is floored to the six-decimal column (#1182), never rounded up, so the +// fixed point can sit at most one micro-dollar per ramp step below the target +// (far under the provider's five-decimal precision) and never above it. func TestLadderB1_DailyRerunNeverExceedsTarget(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() @@ -236,9 +239,10 @@ func TestLadderB1_DailyRerunNeverExceedsTarget(t *testing.T) { for day := 0; day <= 40; day++ { e.run(ctx, t, e.start.Add(time.Duration(day)*24*time.Hour)) live := e.live(ctx, t) - require.LessOrEqual(t, live, ladderB1Target+1e-6, "day %d over-plans: live=%v", day, live) + require.LessOrEqual(t, live, ladderB1Target, "day %d over-plans: live=%v", day, live) } - assert.InDelta(t, ladderB1Target, e.live(ctx, t), 1e-6) + floorSlack := float64(len(ladderB1TwoStep))*1e-6 + 1e-9 // + float8 noise on the summed NUMERIC + assert.GreaterOrEqual(t, e.live(ctx, t), ladderB1Target-floorSlack) } // completeBeforeStaleE completes a tranche inside GetLayerStates and then diff --git a/internal/server/handler_ladder_test.go b/internal/server/handler_ladder_test.go index 7566f406f..fde5c7a80 100644 --- a/internal/server/handler_ladder_test.go +++ b/internal/server/handler_ladder_test.go @@ -1119,6 +1119,29 @@ func TestBuildTrancheDBRows_MalformedAmount_Errors(t *testing.T) { assert.Nil(t, rows, "no rows may be returned when any tranche amount is malformed") } +// Issue #1182: the planner's exact amount is floored to the six-decimal +// tranche column, never rounded to the nearest float64 (which can land on or +// above the next decimal and so above what the plan asked for). +func TestBuildTrancheDBRows_FloorsAmountToStoredScale(t *testing.T) { + cfgID := "cfg-1" + for planned, want := range map[string]float64{ + "12345999999999999/100000000000000000": 0.123459, // nearest float64 is 0.12346 + "9999999999999/10000000": 999999.999999, + "1/3": 0.333333, + "3/2": 1.5, + } { + rows, err := buildTrancheDBRows([]pkgladder.Tranche{{ + ID: "tr-1", RunID: "run-1", Layer: pkgladder.LayerComputeSP, + Term: pkgladder.Term1Year, PaymentOption: pkgladder.PaymentNoUpfront, + Status: pkgladder.TrancheStatusScheduled, AmountUSDPerHour: planned, + CommitmentRate: "1", FireAfter: time.Now(), + }}, "run-1", &cfgID) + require.NoError(t, err, planned) + require.Len(t, rows, 1) + assert.Equal(t, want, rows[0].AmountUSDHr, planned) + } +} + // ============================================================ // ratToFloat64Ptr: nil stays nil, non-nil converts correctly // ============================================================ From 606ecafeed221eea7156dd64b10e512ca126a286 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 21 Oct 2026 08:31:23 +0100 Subject: [PATCH 2/2] fix(ladder): drop tranches that floor to $0 and floor the display plan Address the #1199 review. A positive planned amount below 0.000001 USD/hr floors to 0 at the six-decimal tranche column; buildTrancheDBRows now drops that tranche with a log line instead of persisting a $0 scheduled row, and refuses a zero or negative planned amount outright. immediateTrancheAmounts sums the same floored amounts, so plan_json never shows more than the rows netting reads. Document that the floored float can round up only above about 1e9 USD/hr. Refs #1182 Co-Authored-By: Claude Opus 5.5 --- internal/config/ladder_provenance.go | 37 +++++++++----- internal/server/handler_ladder.go | 65 ++++++++++++++++++------- internal/server/handler_ladder_test.go | 67 ++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 29 deletions(-) diff --git a/internal/config/ladder_provenance.go b/internal/config/ladder_provenance.go index 96fe71757..e10b18ed2 100644 --- a/internal/config/ladder_provenance.go +++ b/internal/config/ladder_provenance.go @@ -180,21 +180,34 @@ func LadderInFlightOnDemand(rows []LadderInFlightRow) (float64, error) { // ladder_tranches.amount_usd_hr (NUMERIC(20,6), migration 000081). const ladderAmountScale = 6 -// LadderAmountFloat converts an exact planned tranche amount to the float64 a -// tranche row stores, flooring it to the column's six decimal places. The go -// precision contract (pkg/ladder LadderCapability.PurchaseLayer, go#354) -// floors provider commitments and forbids callers rounding a *big.Rat up on the -// way to float64; nearest-float conversion can do exactly that just below a -// decimal boundary. Flooring at six decimals keeps a provider's five-decimal -// truncation identical to truncating the exact amount and keeps an amount -// below a provider minimum below it. The result's shortest decimal form is the -// floored value itself (at most 15 significant digits below 1e9 USD/hr), so the -// store writes it without any further rounding. -func LadderAmountFloat(amount *big.Rat) float64 { +// LadderAmountFloor floors an exact planned tranche amount to the six decimal +// places ladder_tranches.amount_usd_hr stores, returning the exact floored +// decimal. A positive amount below 0.000001 floors to zero; callers must not +// persist that as a $0 tranche. +func LadderAmountFloor(amount *big.Rat) *big.Rat { scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(ladderAmountScale), nil) floored := new(big.Int).Mul(amount.Num(), scale) floored.Div(floored, amount.Denom()) // Euclidean division: floor for the positive denominator - f, _ := new(big.Rat).SetFrac(floored, scale).Float64() + return new(big.Rat).SetFrac(floored, scale) +} + +// LadderAmountFloat converts an exact planned tranche amount to the float64 a +// tranche row stores, flooring it to the column's six decimal places (see +// LadderAmountFloor). The go precision contract (pkg/ladder +// LadderCapability.PurchaseLayer, go#354) floors provider commitments and +// forbids callers rounding a *big.Rat up on the way to float64; nearest-float +// conversion can do exactly that just below a decimal boundary. Flooring at six +// decimals keeps a provider's five-decimal truncation identical to truncating +// the exact amount and keeps an amount below a provider minimum below it. +// +// Below 1e9 USD/hr the floored value has at most 15 significant digits, so the +// result's shortest decimal form is the floored value itself and the store +// writes it without any further rounding. Above about 1e9 USD/hr the nearest +// float64 of the floored decimal can round it up by less than one float64 ulp, +// and nothing here refuses that; it is unreachable under the provider's 1e6 +// USD/hr cap. +func LadderAmountFloat(amount *big.Rat) float64 { + f, _ := LadderAmountFloor(amount).Float64() return f } diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index 85508c5f3..50ef53324 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -680,9 +680,32 @@ func sumTrancheRowAmounts(rows []config.LadderTrancheDB) float64 { return sum } +// storedTrancheAmount is the amount a tranche row persists: the planned exact +// amount floored to the column's six decimals (never rounded up; see +// config.LadderAmountFloor). It fails loud on a malformed or non-positive +// planned amount: BuildTranches never emits one, so it means a broken tranche, +// and writing it would be a $0 (or negative) row. stored=false means the +// positive amount is below 0.000001 USD/hr and floors to zero; the caller +// drops that tranche instead of persisting a $0 row. Such an amount is far +// below the provider minimum (0.001 USD/hr), so it could never be purchased. +func storedTrancheAmount(tr *pkgladder.Tranche) (amount *big.Rat, stored bool, err error) { + r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour) + if !ok || r == nil { + return nil, false, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour) + } + if r.Sign() <= 0 { + return nil, false, fmt.Errorf("tranche %s: non-positive amount_usd_per_hour %q", tr.ID, tr.AmountUSDPerHour) + } + floored := config.LadderAmountFloor(r) + return floored, floored.Sign() > 0, nil +} + // immediateTrancheAmounts sums, per layer, the tranches that fire at the run -// start (FireAfter <= now: the AfterDays==0 ramp step). It fails loud on a -// malformed amount, like buildTrancheDBRows. +// start (FireAfter <= now: the AfterDays==0 ramp step). It sums the stored +// (floored) amounts, so the display plan never shows more than the persisted +// rows, and skips the tranches buildTrancheDBRows drops; a layer whose +// immediate tranches all floor to zero has no entry. It fails loud on a +// malformed or non-positive amount, like buildTrancheDBRows. func immediateTrancheAmounts(tranches []pkgladder.Tranche, now time.Time) (map[pkgladder.LayerType]*big.Rat, error) { sums := make(map[pkgladder.LayerType]*big.Rat) for i := range tranches { @@ -690,9 +713,12 @@ func immediateTrancheAmounts(tranches []pkgladder.Tranche, now time.Time) (map[p if tr.FireAfter.After(now) { continue } - r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour) - if !ok { - return nil, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour) + r, stored, err := storedTrancheAmount(tr) + if err != nil { + return nil, err + } + if !stored { + continue } if sums[tr.Layer] == nil { sums[tr.Layer] = new(big.Rat) @@ -703,24 +729,29 @@ func immediateTrancheAmounts(tranches []pkgladder.Tranche, now time.Time) (map[p } // buildTrancheDBRows converts pkg/ladder Tranche rows to config.LadderTrancheDB -// rows ready for SaveLadderTranches. It fails loud on a malformed -// AmountUSDPerHour (money path): rather than persist a silently 0-coerced -// $0 tranche, it returns an error so the whole run is counted Errored and -// nothing partial is written. +// rows ready for SaveLadderTranches. It fails loud on a malformed or +// non-positive AmountUSDPerHour (money path): rather than persist a silently +// 0-coerced $0 tranche, it returns an error so the whole run is counted +// Errored and nothing partial is written. A positive amount below 0.000001 +// USD/hr, which the six-decimal column would store as 0, is dropped with a log +// line instead (see storedTrancheAmount): it is a planner remainder no provider +// can buy, and refusing it would fail the same run again on every rerun. func buildTrancheDBRows(tranches []pkgladder.Tranche, runID string, configID *string) ([]config.LadderTrancheDB, error) { rows := make([]config.LadderTrancheDB, 0, len(tranches)) for i := range tranches { tr := &tranches[i] // use pointer to avoid copying 152-byte Tranche struct per iteration // AmountUSDHr: parse the RatString and floor it to the stored scale - // (never round a planned commitment up; see LadderAmountFloat). A - // missing or malformed RatString means BuildTranches produced a broken - // tranche; that should never happen after Validate() passes, so - // surface it as an error instead of writing a $0 row. - r, ok := new(big.Rat).SetString(tr.AmountUSDPerHour) - if !ok || r == nil { - return nil, fmt.Errorf("tranche %s: malformed amount_usd_per_hour %q (not a parseable rational)", tr.ID, tr.AmountUSDPerHour) + // (never round a planned commitment up; see config.LadderAmountFloor). + r, stored, err := storedTrancheAmount(tr) + if err != nil { + return nil, err + } + if !stored { + log.Printf("ladder_run: run %s: dropping tranche %s (layer %s): amount_usd_per_hour %s floors to 0 at the stored six-decimal scale", + runID, tr.ID, tr.Layer, tr.AmountUSDPerHour) + continue } - amountUSDHr := config.LadderAmountFloat(r) + amountUSDHr, _ := r.Float64() runIDCopy := runID if tr.CommitmentRate == "" { return nil, fmt.Errorf("tranche %s: empty commitment rate", tr.ID) diff --git a/internal/server/handler_ladder_test.go b/internal/server/handler_ladder_test.go index fde5c7a80..ded433904 100644 --- a/internal/server/handler_ladder_test.go +++ b/internal/server/handler_ladder_test.go @@ -1142,6 +1142,73 @@ func TestBuildTrancheDBRows_FloorsAmountToStoredScale(t *testing.T) { } } +func flooringTestTranche(id string, layer pkgladder.LayerType, amount string, fireAfter time.Time) pkgladder.Tranche { + return pkgladder.Tranche{ + ID: id, RunID: "run-1", Layer: layer, + Term: pkgladder.Term1Year, PaymentOption: pkgladder.PaymentNoUpfront, + Status: pkgladder.TrancheStatusScheduled, AmountUSDPerHour: amount, + CommitmentRate: "1", FireAfter: fireAfter, + } +} + +// Issue #1199 review: a positive planned amount below 0.000001 floors to 0 at +// the stored scale. It must be dropped, never persisted as a $0 scheduled +// tranche; 0.000001 itself is kept. +func TestBuildTrancheDBRows_DropsAmountFlooringToZero(t *testing.T) { + cfgID := "cfg-1" + now := time.Now() + rows, err := buildTrancheDBRows([]pkgladder.Tranche{ + flooringTestTranche("tr-tiny", pkgladder.LayerComputeSP, "1/10000000", now), + flooringTestTranche("tr-under", pkgladder.LayerComputeSP, "9/10000000", now), + flooringTestTranche("tr-micro", pkgladder.LayerComputeSP, "1/1000000", now), + flooringTestTranche("tr-big", pkgladder.LayerComputeSP, "3/2", now), + }, "run-1", &cfgID) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, "tr-micro", rows[0].ID) + assert.Equal(t, 1e-6, rows[0].AmountUSDHr) + assert.Equal(t, "tr-big", rows[1].ID) + assert.Equal(t, 1.5, rows[1].AmountUSDHr) + for i := range rows { + assert.Positive(t, rows[i].AmountUSDHr, rows[i].ID) + } +} + +// A zero or negative planned amount is a broken tranche (BuildTranches never +// emits one): fail loud rather than write a $0 or negative row. +func TestBuildTrancheDBRows_NonPositiveAmountErrors(t *testing.T) { + cfgID := "cfg-1" + for _, amount := range []string{"0", "-1/2", "-1/10000000"} { + rows, err := buildTrancheDBRows([]pkgladder.Tranche{ + flooringTestTranche("tr-bad", pkgladder.LayerComputeSP, amount, time.Now()), + }, "run-1", &cfgID) + require.ErrorContains(t, err, "tr-bad", amount) + assert.Nil(t, rows, amount) + } +} + +// The display plan's immediate amounts are the floored amounts the rows store, +// so plan_json never shows more than the rows netting reads; a layer whose +// immediate tranches all floor to 0 gets no purchase amount. +func TestImmediateTrancheAmounts_SumsStoredAmounts(t *testing.T) { + now := time.Now() + sums, err := immediateTrancheAmounts([]pkgladder.Tranche{ + flooringTestTranche("tr-a", pkgladder.LayerComputeSP, "1/3", now), + flooringTestTranche("tr-b", pkgladder.LayerComputeSP, "1/3", now.Add(-time.Minute)), + flooringTestTranche("tr-later", pkgladder.LayerComputeSP, "5", now.Add(24*time.Hour)), + flooringTestTranche("tr-tiny", pkgladder.LayerConvertibleRI, "9/10000000", now), + }, now) + require.NoError(t, err) + require.Len(t, sums, 1, "a layer whose immediate amount floors to 0 has no entry") + require.NotNil(t, sums[pkgladder.LayerComputeSP]) + assert.Equal(t, "0.666666", sums[pkgladder.LayerComputeSP].FloatString(6), "two floored 1/3 tranches, not 2/3") + + _, err = immediateTrancheAmounts([]pkgladder.Tranche{ + flooringTestTranche("tr-zero", pkgladder.LayerComputeSP, "0", now), + }, now) + require.ErrorContains(t, err, "tr-zero") +} + // ============================================================ // ratToFloat64Ptr: nil stays nil, non-nil converts correctly // ============================================================