diff --git a/internal/config/ladder_provenance.go b/internal/config/ladder_provenance.go index a9aa624b..e10b18ed 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,55 @@ 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 + +// 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 + 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 +} + +// 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 cb2c16ec..d65ac85a 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 71994fbb..7c171158 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 00000000..b9400ceb --- /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 16338133..50ef5332 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,21 +729,27 @@ 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 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. - 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: parse the RatString and floor it to the stored scale + // (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, _ := r.Float64() runIDCopy := runID diff --git a/internal/server/handler_ladder_immediate_integration_test.go b/internal/server/handler_ladder_immediate_integration_test.go index 951b60e3..521c00f4 100644 --- a/internal/server/handler_ladder_immediate_integration_test.go +++ b/internal/server/handler_ladder_immediate_integration_test.go @@ -225,7 +225,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() @@ -233,9 +236,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 7566f406..ded43390 100644 --- a/internal/server/handler_ladder_test.go +++ b/internal/server/handler_ladder_test.go @@ -1119,6 +1119,96 @@ 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) + } +} + +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 // ============================================================