carlos / platform Public

economics: v2 knobs — fleet box prices, mem overage, break, pool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 39eee15885e0c6316cdfc47ee09a9426829c0f4e parent 197f682
3 files changed, +103 −9
  • internal/economics/scenario.go +44 −1
  • internal/economics/scenario_test.go +50 −1
  • internal/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.