economics: v2 knobs — fleet box prices, mem overage, break, pool
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+103
−9
internal/economics/scenario.go+44 −1internal/economics/scenario_test.go+50 −1internal/usage/usage.go+9 −7
diff --git a/internal/economics/scenario.go b/internal/economics/scenario.go| index cdc52f7d5..911c0ddd9 100644 |
| --- a/internal/economics/scenario.go |
| +++ b/internal/economics/scenario.go |
| @@ -47,6 +47,9 @@ type RateSheet struct { |
| EgressGBUSD float64 `json:"egress_gb_usd"` |
| StorageGBMonthUSD float64 `json:"storage_gb_month_usd"` |
| InstanceHourUSD float64 `json:"instance_hour_usd"` |
| + BoxMonthServerUSD float64 `json:"box_month_server_usd"` |
| + BoxMonthDeviceUSD float64 `json:"box_month_device_usd"` |
| + MemGBHourUSD float64 `json:"mem_gb_hour_usd"` |
| } |
| // FreeTier is per-account monthly allowances, subtracted from meters before |
| @@ -58,6 +61,18 @@ type FreeTier struct { |
| StorageGB float64 `json:"storage_gb"` |
| } |
| +// FleetBreak is a volume discount on fleet box pricing. |
| +type FleetBreak struct { |
| + MinBoxes float64 `json:"min_boxes"` |
| + Discount float64 `json:"discount"` |
| +} |
| + |
| +// Pool is a revenue-sharing pool for fleet deployments. |
| +type Pool struct { |
| + RevenueFrac float64 `json:"revenue_frac"` |
| + TopupUSD float64 `json:"topup_usd"` |
| +} |
| + |
| // Scenario is one complete knob-set, stored at ScenarioKey(Name). |
| type Scenario struct { |
| Schema int `json:"schema"` // 1 |
| @@ -68,6 +83,8 @@ type Scenario struct { |
| Margin float64 `json:"margin"` // cost-plus: (price-cost)/price, so 0.8 = 5x cost |
| Sheet RateSheet `json:"sheet"` |
| FreeTier FreeTier `json:"free_tier"` |
| + FleetBreak FleetBreak `json:"fleet_break"` |
| + Pool Pool `json:"pool"` |
| // BillServices names which of a bill's Services lines the dashboard's |
| // model-vs-bill reconciliation (Task 13) counts as CARLOS spend — the |
| // "counts as CARLOS" checkbox state, round-tripped through the saved |
| @@ -129,8 +146,13 @@ func DefaultScenario() Scenario { |
| EgressGBUSD: 0.15, |
| StorageGBMonthUSD: 0.05, |
| InstanceHourUSD: 0.01, |
| + BoxMonthServerUSD: 5, |
| + BoxMonthDeviceUSD: 1, |
| + MemGBHourUSD: 0, |
| }, |
| - FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1}, |
| + FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1}, |
| + FleetBreak: FleetBreak{MinBoxes: 10, Discount: 0.4}, |
| + Pool: Pool{RevenueFrac: 0.10, TopupUSD: 200}, |
| } |
| } |
| @@ -157,5 +179,26 @@ func (sc *Scenario) Validate() error { |
| if _, ok := sc.RateCard.Boxes[""]; !ok { |
| return fmt.Errorf("economics: rate card needs a %q default box spec", "") |
| } |
| + if sc.FleetBreak.Discount < 0 || sc.FleetBreak.Discount >= 1 { |
| + return fmt.Errorf("economics: fleet_break.discount %v: want [0,1)", sc.FleetBreak.Discount) |
| + } |
| + if sc.FleetBreak.MinBoxes < 0 { |
| + return fmt.Errorf("economics: fleet_break.min_boxes %v: want >= 0", sc.FleetBreak.MinBoxes) |
| + } |
| + if sc.Pool.RevenueFrac < 0 || sc.Pool.RevenueFrac > 1 { |
| + return fmt.Errorf("economics: pool.revenue_frac %v: want [0,1]", sc.Pool.RevenueFrac) |
| + } |
| + if sc.Pool.TopupUSD < 0 { |
| + return fmt.Errorf("economics: pool.topup_usd %v: want >= 0", sc.Pool.TopupUSD) |
| + } |
| + if sc.Sheet.BoxMonthServerUSD < 0 { |
| + return fmt.Errorf("economics: sheet.box_month_server_usd %v: want >= 0", sc.Sheet.BoxMonthServerUSD) |
| + } |
| + if sc.Sheet.BoxMonthDeviceUSD < 0 { |
| + return fmt.Errorf("economics: sheet.box_month_device_usd %v: want >= 0", sc.Sheet.BoxMonthDeviceUSD) |
| + } |
| + if sc.Sheet.MemGBHourUSD < 0 { |
| + return fmt.Errorf("economics: sheet.mem_gb_hour_usd %v: want >= 0", sc.Sheet.MemGBHourUSD) |
| + } |
| return nil |
| } |
diff --git a/internal/economics/scenario_test.go b/internal/economics/scenario_test.go| index b07355aa1..d2742a7c8 100644 |
| --- a/internal/economics/scenario_test.go |
| +++ b/internal/economics/scenario_test.go |
| @@ -1,6 +1,9 @@ |
| package economics |
| -import "testing" |
| +import ( |
| + "encoding/json" |
| + "testing" |
| +) |
| func TestKeys(t *testing.T) { |
| if got := ScenarioKey("launch-pricing"); got != "economics/scenarios/launch-pricing.json" { |
| @@ -58,3 +61,49 @@ func TestValidScenarioName(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestDefaultScenarioV2Knobs(t *testing.T) { |
| + sc := DefaultScenario() |
| + if sc.Sheet.BoxMonthServerUSD != 5 || sc.Sheet.BoxMonthDeviceUSD != 1 { |
| + t.Fatalf("box prices = %v/%v", sc.Sheet.BoxMonthServerUSD, sc.Sheet.BoxMonthDeviceUSD) |
| + } |
| + if sc.FleetBreak.MinBoxes != 10 || sc.FleetBreak.Discount != 0.4 { |
| + t.Fatalf("fleet break = %+v", sc.FleetBreak) |
| + } |
| + if sc.Pool.RevenueFrac != 0.10 || sc.Pool.TopupUSD != 200 { |
| + t.Fatalf("pool = %+v", sc.Pool) |
| + } |
| + if err := sc.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestValidateRejectsV2(t *testing.T) { |
| + cases := []func(*Scenario){ |
| + func(s *Scenario) { s.FleetBreak.Discount = 1.0 }, |
| + func(s *Scenario) { s.FleetBreak.Discount = -0.1 }, |
| + func(s *Scenario) { s.FleetBreak.MinBoxes = -1 }, |
| + func(s *Scenario) { s.Pool.RevenueFrac = 1.5 }, |
| + func(s *Scenario) { s.Pool.TopupUSD = -5 }, |
| + func(s *Scenario) { s.Sheet.MemGBHourUSD = -0.01 }, |
| + } |
| + for i, mutate := range cases { |
| + sc := DefaultScenario() |
| + sc.Name = "test" |
| + mutate(&sc) |
| + if err := sc.Validate(); err == nil { |
| + t.Fatalf("case %d validated", i) |
| + } |
| + } |
| +} |
| + |
| +func TestOldScenarioStillParses(t *testing.T) { |
| + old := `{"schema":1,"name":"x","rate_card":{"boxes":{"":{"hour_usd":0.02,"cores":2,"mem_gb":2}}},"allocation":{"policy":"cpu"},"mode":"cost-plus","margin":0.8}` |
| + var sc Scenario |
| + if err := json.Unmarshal([]byte(old), &sc); err != nil { |
| + t.Fatal(err) |
| + } |
| + if sc.Pool.RevenueFrac != 0 || sc.FleetBreak.MinBoxes != 0 { |
| + t.Fatalf("zero-defaults broken: %+v %+v", sc.Pool, sc.FleetBreak) |
| + } |
| +} |
diff --git a/internal/usage/usage.go b/internal/usage/usage.go| index 70a7695e8..65c586505 100644 |
| --- a/internal/usage/usage.go |
| +++ b/internal/usage/usage.go |
| @@ -14,13 +14,15 @@ import "time" |
| // Meter identifiers — the canonical billing vocabulary. |
| const ( |
| - MeterCPUCoreHours = "cpu_core_hours" |
| - MeterMemGBHours = "mem_gb_hours" |
| - MeterEgressGB = "egress_gb" |
| - MeterRequests1k = "requests_1k" |
| - MeterStorageGBMonth = "storage_gb_month" |
| - MeterAppMonths = "app_months" |
| - MeterInstanceHours = "instance_hours" |
| + MeterCPUCoreHours = "cpu_core_hours" |
| + MeterMemGBHours = "mem_gb_hours" |
| + MeterEgressGB = "egress_gb" |
| + MeterRequests1k = "requests_1k" |
| + MeterStorageGBMonth = "storage_gb_month" |
| + MeterAppMonths = "app_months" |
| + MeterInstanceHours = "instance_hours" |
| + MeterFleetBoxHoursServer = "fleet_box_hours_server" |
| + MeterFleetBoxHoursDevice = "fleet_box_hours_device" |
| ) |
| // DayOf is the UTC date label used in every usage key. |