From 31a11ca019719ab8453074c40e2a9ddc5713ae6a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 14 Oct 2026 00:30:08 +0200 Subject: [PATCH 1/2] refactor(config): add RampSchedule.CumulativePercent as the single ramp-progress definition GetCurrentCoverage now derives from it so later per-step sizing and the coverage figure cannot drift apart. Co-Authored-By: Claude Sonnet 5.5 --- internal/config/types.go | 20 ++++++++++++-------- internal/config/types_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/internal/config/types.go b/internal/config/types.go index a34e03b98..aa73ebb10 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" + "math" "strings" "time" @@ -276,16 +277,19 @@ var PresetRampSchedules = map[string]RampSchedule{ }, } -// GetCurrentCoverage calculates the current effective coverage based on ramp progress. -func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64 { +// CumulativePercent is the share of the base (0..100) a ramp has bought once +// step steps are complete: step*PercentPerStep capped at 100, and 100 for an +// immediate schedule. It is the one definition of cumulative ramp progress. +func (r *RampSchedule) CumulativePercent(step int) float64 { if r.Type == "immediate" { - return baseCoverage + return 100 } - completedPercent := float64(r.CurrentStep) * r.PercentPerStep - if completedPercent > 100 { - completedPercent = 100 - } - return baseCoverage * completedPercent / 100 + return math.Min(100, float64(step)*r.PercentPerStep) +} + +// GetCurrentCoverage calculates the current effective coverage based on ramp progress. +func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64 { + return baseCoverage * r.CumulativePercent(r.CurrentStep) / 100 } // GetNextPurchaseDate calculates when the next purchase step should occur. diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 19bdd90c4..9c820c1f7 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -93,6 +93,37 @@ func TestRampSchedule_GetCurrentCoverage(t *testing.T) { } } +func TestRampSchedule_CumulativePercent(t *testing.T) { + weekly := RampSchedule{Type: "weekly", PercentPerStep: 25, TotalSteps: 4} + custom := RampSchedule{Type: "custom", PercentPerStep: 40, TotalSteps: 3} + tests := []struct { + name string + schedule RampSchedule + step int + expected float64 + }{ + {"immediate is always 100", RampSchedule{Type: "immediate"}, 0, 100}, + {"weekly step 0", weekly, 0, 0}, + {"weekly step 1", weekly, 1, 25}, + {"weekly step 4", weekly, 4, 100}, + {"custom 3x40 step 2", custom, 2, 80}, + {"custom 3x40 step 3 clamps to 100", custom, 3, 100}, + {"step beyond the ramp stays at 100", weekly, 9, 100}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.schedule.CumulativePercent(tt.step)) + }) + } +} + +// GetCurrentCoverage must stay the base scaled by CumulativePercent at the +// schedule's own CurrentStep, so the two cannot drift. +func TestRampSchedule_GetCurrentCoverageUsesCumulativePercent(t *testing.T) { + s := RampSchedule{Type: "custom", PercentPerStep: 40, TotalSteps: 3, CurrentStep: 2} + assert.Equal(t, 80*s.CumulativePercent(2)/100, s.GetCurrentCoverage(80)) +} + func TestRampSchedule_GetNextPurchaseDate(t *testing.T) { now := time.Now() startDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) From 6d16bbd57a49234346ea3889654cf5363751581a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 14 Oct 2026 00:42:31 +0200 Subject: [PATCH 2/2] fix(config): keep immediate ramp coverage exact GetCurrentCoverage returned base*100/100 for immediate schedules, which is not exact in floating point. Return the base unchanged as before. Co-Authored-By: Claude Sonnet 5.5 --- internal/config/types.go | 3 +++ internal/config/types_test.go | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/internal/config/types.go b/internal/config/types.go index aa73ebb10..60d11945b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -289,6 +289,9 @@ func (r *RampSchedule) CumulativePercent(step int) float64 { // GetCurrentCoverage calculates the current effective coverage based on ramp progress. func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64 { + if r.Type == "immediate" { + return baseCoverage // base*100/100 is not exact in floating point + } return baseCoverage * r.CumulativePercent(r.CurrentStep) / 100 } diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 9c820c1f7..f85ba30a0 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -1,6 +1,7 @@ package config import ( + "math" "testing" "time" @@ -124,6 +125,15 @@ func TestRampSchedule_GetCurrentCoverageUsesCumulativePercent(t *testing.T) { assert.Equal(t, 80*s.CumulativePercent(2)/100, s.GetCurrentCoverage(80)) } +// An immediate schedule returns the base untouched: base*100/100 would turn +// 0.1694 into 0.16939999999999997 and math.MaxFloat64 into +Inf. +func TestRampSchedule_GetCurrentCoverageImmediateIsExact(t *testing.T) { + s := RampSchedule{Type: "immediate"} + for _, base := range []float64{0.1694, 80, math.MaxFloat64} { + assert.Equal(t, base, s.GetCurrentCoverage(base)) + } +} + func TestRampSchedule_GetNextPurchaseDate(t *testing.T) { now := time.Now() startDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)