diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md new file mode 100644 index 0000000..5bbed7b --- /dev/null +++ b/docs/contracts/cost-contract-v1.md @@ -0,0 +1,178 @@ +# Cost Driver and Observation Contract v1 + +Status: PCTL-18 candidate + +Schema version: `profitctl.cost/v1` + +## Purpose + +This contract lets ProfitCtl compare forecast drivers with cost observations +without treating provenance, measurement, billing, or user input as +interchangeable evidence. + +Contract code lives in `pkg/contracts/cost/v1`. Structural JSON Schemas live in +`schemas/cost-contract/v1`. Go `Validate` methods enforce cross-field rules that +JSON Schema cannot express reliably, including matching units and currencies, +ordered time windows, and evidence/source compatibility. + +This version is additive. It does not change `simulate`, current scenario math, +CLI output, or provider access. + +## Driver Taxonomy + +| Kind | Meaning | Example | +| --- | --- | --- | +| `fixed` | Commitment independent of workload volume | One platform commitment per month | +| `variable` | Quantity scaling with another unit | 10,000 requests per user | +| `cadence` | Repeated work over time | 1,386,000 polling commands per month | +| `concurrency` | Simultaneously active capacity | Four workers | +| `uptime` | Active duration over a window | 720 worker-hours per month | + +Every driver requires: + +- canonical quantity unit; +- explicit RFC3339 start and end; +- price amount, ISO 4217 currency, and price basis; +- workload dimension; +- separate quantity and unit-price evidence, each with evidence class, + measurement status, source identity, capture time, confidence, and rationale. + +`variable` and `cadence` drivers also require a `per` scale basis. A variable +driver cannot omit whether it scales per `user`, `paid_user`, or another +canonical subject. A cadence driver cannot omit its time denominator. +Every present `per` denominator must be greater than zero. +Cadence denominators use `second`, `minute`, `hour`, `day`, `week`, `month`, +or `year`. +Uptime quantities use the same canonical time units. + +Variable drivers may carry an explicit `normal`, `uniform`, or `exponential` +distribution. Distribution-specific parameters preserve the stochastic basis +used by stress forecasts instead of silently substituting deterministic +`units_per_user`. +Legacy normal and uniform distributions also carry `floor: 0`, matching +ProfitCtl's non-negative sampler. + +Units use lowercase canonical identifiers such as `request`, `command`, +`token`, `gibibyte`, `user`, `worker`, `hour`, or `month`. Ambiguous storage +units such as `GB`/`gb`, free-text ratios, and generic `unit`/`units` fail +validation. + +## Observation Envelope + +An observation binds one or more driver IDs to: + +- one explicit time window; +- quantity and unit; +- unit price, price basis, and currency; +- total cost and currency; +- provider, service, region, tier, and workload dimensions where applicable; +- separate evidence for quantity, unit price, and total cost. + +Claim-level evidence matters. Telemetry may own quantity while a provider +catalog owns a planning rate and an invoice owns billed total. One source must +not silently stand in for all three claims. + +Claim authority is validated: + +- quantity: template, user input, repo detection, telemetry, runtime ledger, + synthetic fixture, or legacy scenario; +- unit price: template, user input, repo detection, invoice, provider catalog, + synthetic fixture, or legacy scenario; +- total cost: template, user input, invoice, ProfitCtl-derived artifact, + synthetic fixture, or legacy scenario. + +Telemetry and runtime-ledger sources cannot silently become price or total-cost +evidence. Provider catalogs cannot silently become usage or billed-total +evidence. + +## Evidence Semantics + +Evidence kinds: + +- `predicted`: forecast or derived planning value; +- `observed`: measured or explicitly synthetic observation; +- `billed`: billed value backed by invoice evidence; +- `user_supplied`: declared operator value. + +Measurement status is separate: + +- `measured`: allowed only for telemetry, runtime-ledger, or invoice sources; +- `synthetic`: allowed only for a named synthetic fixture; +- `derived`: calculated from other explicit claims; +- `declared`: asserted assumption. + +Rules: + +- provenance alone never proves measurement; +- `billed` requires invoice source; +- `user_supplied` requires user-supplied source; +- synthetic observations stay visibly synthetic; +- derived totals must equal quantity multiplied by unit price and divided by + price-basis quantity within floating-point tolerance; +- missing source identity, capture time, confidence rationale, units, time + windows, or currency fails closed. + +Provider-catalog evidence additionally requires `refresh_owner`, +`refresh_cadence`, and `stale_after`, and cannot claim high confidence. +Catalog evidence stale before the enclosing driver or observation window fails +closed. +Template evidence also cannot claim high confidence. +Repo-detected evidence is likewise capped at medium until confirmed by user or +measurement evidence. + +## Current Scenario Compatibility + +Legacy scenarios remain authoritative for current simulation behavior. +`MapLegacyCosts` creates additive v1 driver views and does not mutate legacy +inputs or the cost engine. + +Mapping rules: + +| Legacy field | v1 mapping | +| --- | --- | +| `fixed_costs[].amount` | unit price per `commitment` | +| `fixed_costs[].period` | explicit legacy-normalized commitment count and time basis | +| `variable_costs[].cost_per_unit` | unit-price amount | +| `variable_costs[].units_per_user` | quantity per one `user` | +| `variable_costs[].distribution` and parameters | explicit v1 stochastic distribution | +| absent variable unit | caller must supply explicit name-to-unit mapping | +| absent scenario currency/window | caller must supply both | +| absent source | low-confidence `legacy_scenario`; never measured | + +Legacy normalization details: + +- daily fixed costs become 30 commitments per month because the current engine + uses a fixed 30-day simulated month; +- monthly fixed costs become one commitment per month; +- yearly fixed costs become one commitment per year; +- legacy driver IDs derive from cost kind and name, so harmless list reordering + does not break observation bindings; +- `user_scope: paid_users` maps to `paid_user`; other legacy variable costs map + to `user`. + +The compatibility fixture +`test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json` maps the +current `valid_config.yml` scenario. Tests prove mapped drivers validate and +legacy calculated totals remain unchanged. + +## Upstash Idle-Polling Fixture + +`test/fixtures/cost_contract/v1/upstash_idle_polling.json` provides one +forecast-to-observation mapping with 1,386,000 synthetic polling commands. + +The fixture is not live Upstash evidence: + +- quantity is marked `observed` plus `synthetic`; +- rate is marked `user_supplied` plus `declared`; +- total is marked `predicted` plus `derived`; +- confidence remains low; +- fixture text explicitly rejects a current-price or billing claim. + +No provider calls, credentials, or live data are required. + +## Versioning + +Breaking field, enum, or semantic changes require a new schema namespace such +as `profitctl.cost/v2` and new fixture directory. Additive v1 changes must keep +existing v1 fixtures valid. Current scenario behavior changes require separate +versioned product authority. diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go new file mode 100644 index 0000000..200d9e4 --- /dev/null +++ b/pkg/contracts/cost/v1/legacy.go @@ -0,0 +1,224 @@ +package v1 + +import ( + "crypto/sha256" + "errors" + "fmt" + "strings" + + "github.com/IntelIP/ProfitCtl/pkg/types" +) + +type LegacyMapping struct { + ArtifactIdentity string `json:"artifact_identity" yaml:"artifact_identity"` + CapturedAt string `json:"captured_at" yaml:"captured_at"` + Currency string `json:"currency" yaml:"currency"` + Window TimeWindow `json:"window" yaml:"window"` + VariableUnits map[string]string `json:"variable_units" yaml:"variable_units"` +} + +// MapLegacyCosts maps current scenario inputs into v1 drivers without changing +// simulator behavior. Legacy variable costs have no unit field, so callers must +// provide an explicit name-to-unit mapping. +func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapping LegacyMapping) ([]CostDriver, error) { + if strings.TrimSpace(mapping.ArtifactIdentity) == "" { + return nil, errors.New("artifact_identity is required") + } + if !validCaptureTime(mapping.CapturedAt) { + return nil, errors.New("captured_at must be an RFC3339 timestamp or ISO date") + } + if err := (Money{Currency: mapping.Currency}).Validate(); err != nil { + return nil, err + } + if err := mapping.Window.Validate(); err != nil { + return nil, fmt.Errorf("window: %w", err) + } + + drivers := make([]CostDriver, 0, len(fixed)+len(variable)) + seenIDs := make(map[string]string, len(fixed)+len(variable)) + for _, cost := range fixed { + quantity, periodUnit, err := legacyFixedBasis(cost.Period) + if err != nil { + return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) + } + id := stableLegacyID("fixed", cost.Name) + if prior, exists := seenIDs[id]; exists { + return nil, fmt.Errorf("legacy costs %q and %q have ambiguous stable identity", prior, cost.Name) + } + seenIDs[id] = cost.Name + driver := CostDriver{ + SchemaVersion: SchemaVersion, + ID: id, + Name: cost.Name, + Kind: DriverFixed, + Quantity: Quantity{Value: quantity, Unit: "commitment"}, + Per: &Quantity{Value: 1, Unit: periodUnit}, + UnitPrice: UnitPrice{ + Amount: Money{Amount: cost.Amount, Currency: mapping.Currency}, + Per: Quantity{Value: 1, Unit: "commitment"}, + }, + Window: mapping.Window, + Dimensions: Dimensions{Workload: cost.Name}, + Evidence: legacyDriverEvidence(cost.Source, mapping), + } + if err := driver.Validate(); err != nil { + return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) + } + drivers = append(drivers, driver) + } + + for _, cost := range variable { + unit := mapping.VariableUnits[cost.Name] + if err := validateUnit(unit); err != nil { + return nil, fmt.Errorf("variable cost %q unit: %w", cost.Name, err) + } + subjectUnit := "user" + if types.NormalizeVariableCostUserScope(cost.UserScope) == types.UserScopePaidUsers { + subjectUnit = "paid_user" + } + id := stableLegacyID("variable", cost.Name) + if prior, exists := seenIDs[id]; exists { + return nil, fmt.Errorf("legacy costs %q and %q have ambiguous stable identity", prior, cost.Name) + } + seenIDs[id] = cost.Name + driver := CostDriver{ + SchemaVersion: SchemaVersion, + ID: id, + Name: cost.Name, + Kind: DriverVariable, + Quantity: Quantity{Value: cost.UnitsPerUser, Unit: unit}, + Per: &Quantity{Value: 1, Unit: subjectUnit}, + UnitPrice: UnitPrice{ + Amount: Money{Amount: cost.CostPerUnit, Currency: mapping.Currency}, + Per: Quantity{Value: 1, Unit: unit}, + }, + Window: mapping.Window, + Dimensions: Dimensions{Workload: cost.Name}, + Evidence: legacyDriverEvidence(cost.Source, mapping), + Distribution: legacyDistribution(cost), + } + if err := driver.Validate(); err != nil { + return nil, fmt.Errorf("variable cost %q: %w", cost.Name, err) + } + drivers = append(drivers, driver) + } + return drivers, nil +} + +func stableLegacyID(kind, name string) string { + digest := sha256.Sum256([]byte(kind + "\x00" + name)) + return fmt.Sprintf("legacy-%s-%x", kind, digest[:8]) +} + +func legacyDistribution(cost types.VariableCost) *Distribution { + if cost.Distribution == "" { + return nil + } + distribution := &Distribution{ + Type: DistributionType(cost.Distribution), + Mean: cloneFloat(cost.Mean), + StdDev: cloneFloat(cost.StdDev), + Min: cloneFloat(cost.Min), + Max: cloneFloat(cost.Max), + Rate: cloneFloat(cost.Rate), + } + if cost.Distribution == types.DistNormal || cost.Distribution == types.DistUniform { + floor := 0.0 + distribution.Floor = &floor + } + return distribution +} + +func cloneFloat(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +func legacyDriverEvidence(source *types.CostSource, mapping LegacyMapping) DriverEvidence { + quantity := legacyEvidence(source, mapping) + unitPrice := legacyEvidence(source, mapping) + if err := validateClaimAuthority("quantity", quantity.Source.Type); err != nil { + quantity = legacyEvidence(nil, mapping) + quantity.ConfidenceRationale = "Legacy source was not authoritative for quantity; mapped as an unmeasured scenario assumption." + } + if err := validateClaimAuthority("unit_price", unitPrice.Source.Type); err != nil { + unitPrice = legacyEvidence(nil, mapping) + unitPrice.ConfidenceRationale = "Legacy source was not authoritative for unit price; mapped as an unmeasured scenario assumption." + } + return DriverEvidence{Quantity: quantity, UnitPrice: unitPrice} +} + +func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { + evidence := Evidence{ + Kind: EvidencePredicted, + Measurement: MeasurementDeclared, + Source: SourceReference{ + Type: SourceLegacyScenario, + ArtifactIdentity: mapping.ArtifactIdentity, + CapturedAt: mapping.CapturedAt, + }, + Confidence: ConfidenceLow, + ConfidenceRationale: "Legacy scenario assumption mapped without claiming runtime measurement.", + } + if source == nil { + return evidence + } + + // Legacy provider_catalog entries predate the required refresh policy. Keep + // them mappable without upgrading incomplete provenance into v1 catalog + // evidence. + if SourceType(source.Type) == SourceProviderCatalog { + evidence.Source.URL = source.URL + evidence.Source.CapturedAt = legacyCaptureAt(source.CapturedAt, mapping.CapturedAt) + evidence.ConfidenceRationale = "Legacy provider catalog assumption mapped without v1 refresh policy or current-price claim." + if source.CapturedAt != "" && !validCaptureTime(source.CapturedAt) { + evidence.ConfidenceRationale += " Original capture text: " + source.CapturedAt + "." + } + return evidence + } + + evidence.Source.Type = SourceType(source.Type) + evidence.Source.URL = source.URL + evidence.Source.CapturedAt = legacyCaptureAt(source.CapturedAt, mapping.CapturedAt) + evidence.Confidence = Confidence(source.Confidence) + if strings.TrimSpace(source.Note) != "" { + evidence.ConfidenceRationale = source.Note + } + if source.CapturedAt != "" && !validCaptureTime(source.CapturedAt) { + evidence.ConfidenceRationale += " Original capture text: " + source.CapturedAt + "." + } + if evidence.Source.Type == SourceTemplate && evidence.Confidence == ConfidenceHigh { + evidence.Confidence = ConfidenceMedium + evidence.ConfidenceRationale += " Legacy template confidence capped at medium by v1." + evidence.ConfidenceRationale = strings.TrimSpace(evidence.ConfidenceRationale) + } + if evidence.Source.Type == SourceRepoDetected && evidence.Confidence == ConfidenceHigh { + evidence.Confidence = ConfidenceMedium + evidence.ConfidenceRationale += " Legacy repo-detected confidence capped at medium by v1." + evidence.ConfidenceRationale = strings.TrimSpace(evidence.ConfidenceRationale) + } + return evidence +} + +func legacyCaptureAt(sourceCapture, fallback string) string { + if validCaptureTime(sourceCapture) { + return sourceCapture + } + return fallback +} + +func legacyFixedBasis(period types.CostPeriod) (float64, string, error) { + switch period { + case types.PeriodDaily: + return 30, "month", nil + case types.PeriodMonthly: + return 1, "month", nil + case types.PeriodYearly: + return 1, "year", nil + default: + return 0, "", fmt.Errorf("unsupported period %q", period) + } +} diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go new file mode 100644 index 0000000..c47ebed --- /dev/null +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -0,0 +1,311 @@ +package v1_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/IntelIP/ProfitCtl/internal/config" + v1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + "github.com/IntelIP/ProfitCtl/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestLegacyScenarioMapsForwardWithoutChangingCosts(t *testing.T) { + var fixture struct { + SourceScenario string `json:"source_scenario"` + Mapping v1.LegacyMapping `json:"mapping"` + Expected []struct { + Name string `json:"name"` + Kind v1.DriverKind `json:"kind"` + Unit string `json:"unit"` + PerUnit string `json:"per_unit"` + } `json:"expected"` + } + decodeFixture(t, "legacy_valid_config_mapping.json", &fixture) + + root := repoRoot(t) + cfg, err := config.ParseConfig(filepath.Join(root, fixture.SourceScenario)) + require.NoError(t, err) + before := *cfg + + drivers, err := v1.MapLegacyCosts(cfg.FixedCosts, cfg.VariableCosts, fixture.Mapping) + require.NoError(t, err) + require.Len(t, drivers, len(fixture.Expected)) + + for i, expected := range fixture.Expected { + require.NoError(t, drivers[i].Validate()) + require.Equal(t, expected.Name, drivers[i].Name) + require.Equal(t, expected.Kind, drivers[i].Kind) + require.Equal(t, expected.Unit, drivers[i].Quantity.Unit) + require.NotNil(t, drivers[i].Per) + require.Equal(t, expected.PerUnit, drivers[i].Per.Unit) + if expected.Kind == v1.DriverVariable { + require.NotNil(t, drivers[i].Distribution) + } + } + + require.Equal(t, before, *cfg) +} + +func TestLegacyMappingPreservesStochasticVariableBasis(t *testing.T) { + rate := 0.001 + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "LLM Tokens", + CostPerUnit: 0.01, + UnitsPerUser: 5000, + Distribution: types.DistExponential, + Rate: &rate, + }}, v1.LegacyMapping{ + ArtifactIdentity: "stress.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"LLM Tokens": "token"}, + }) + require.NoError(t, err) + require.NotNil(t, drivers[0].Distribution) + require.Equal(t, v1.DistributionExponential, drivers[0].Distribution.Type) + require.Equal(t, rate, *drivers[0].Distribution.Rate) + require.NotEqual(t, drivers[0].Quantity.Value, 1/rate) +} + +func TestLegacyMappingPreservesNormalNonNegativeFloor(t *testing.T) { + mean, stddev := 1.0, 10.0 + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "Bursty requests", + CostPerUnit: 0.01, + UnitsPerUser: 1, + Distribution: types.DistNormal, + Mean: &mean, + StdDev: &stddev, + }}, v1.LegacyMapping{ + ArtifactIdentity: "normal.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"Bursty requests": "request"}, + }) + require.NoError(t, err) + require.NotNil(t, drivers[0].Distribution.Floor) + require.Zero(t, *drivers[0].Distribution.Floor) + *drivers[0].Distribution.Mean = 99 + require.Equal(t, 1.0, mean) +} + +func TestLegacyMappingPreservesUniformNonNegativeFloor(t *testing.T) { + minimum, maximum := -10.0, 10.0 + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "Burst range", + CostPerUnit: 0.01, + UnitsPerUser: 1, + Distribution: types.DistUniform, + Min: &minimum, + Max: &maximum, + }}, v1.LegacyMapping{ + ArtifactIdentity: "uniform.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"Burst range": "request"}, + }) + require.NoError(t, err) + require.Equal(t, minimum, *drivers[0].Distribution.Min) + require.NotNil(t, drivers[0].Distribution.Floor) + require.Zero(t, *drivers[0].Distribution.Floor) +} + +func TestLegacyMappingUsesStableIDsAcrossReordering(t *testing.T) { + first := types.FixedCost{Name: "Database", Amount: 20, Period: types.PeriodMonthly} + second := types.FixedCost{Name: "Worker", Amount: 30, Period: types.PeriodMonthly} + mapping := v1.LegacyMapping{ + ArtifactIdentity: "stable.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + } + original, err := v1.MapLegacyCosts([]types.FixedCost{first, second}, nil, mapping) + require.NoError(t, err) + reordered, err := v1.MapLegacyCosts([]types.FixedCost{second, first}, nil, mapping) + require.NoError(t, err) + require.Equal(t, original[0].ID, reordered[1].ID) + require.Equal(t, original[1].ID, reordered[0].ID) +} + +func TestLegacyMappingCapsHighConfidenceTemplate(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Template database", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceTemplate, + Confidence: types.CostSourceConfidenceHigh, + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "template.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, v1.SourceTemplate, drivers[0].Evidence.Quantity.Source.Type) + require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Quantity.Confidence) +} + +func TestLegacyMappingCapsHighConfidenceRepoDetected(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Detected worker", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceRepoDetected, + Confidence: types.CostSourceConfidenceHigh, + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "detected.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Quantity.Confidence) +} + +func TestLegacyMappingFallsBackFromFreeFormCaptureText(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Database", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceUserSupplied, + Confidence: types.CostSourceConfidenceMedium, + CapturedAt: "May 2026", + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "capture.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, "2026-08-01", drivers[0].Evidence.Quantity.Source.CapturedAt) + require.Contains(t, drivers[0].Evidence.Quantity.ConfidenceRationale, "May 2026") +} + +func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { + data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "valid_config.yml")) + require.NoError(t, err) + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "profit.yml") + require.NoError(t, os.WriteFile(configPath, data, 0o600)) + cfg, err := config.ParseConfig(configPath) + require.NoError(t, err) + + _, err = v1.MapLegacyCosts(cfg.FixedCosts, cfg.VariableCosts, v1.LegacyMapping{ + ArtifactIdentity: "profit.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.ErrorContains(t, err, "variable cost") + require.ErrorContains(t, err, "unit") +} + +func TestLegacyMappingPreservesPaidUserScope(t *testing.T) { + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "Payment processing", + CostPerUnit: 0.30, + UnitsPerUser: 1, + UserScope: types.UserScopePaidUsers, + }}, v1.LegacyMapping{ + ArtifactIdentity: "paid.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"Payment processing": "payment"}, + }) + require.NoError(t, err) + require.Equal(t, "paid_user", drivers[0].Per.Unit) +} + +func TestLegacyMappingPreservesThirtyDayDailyNormalization(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Daily worker", + Amount: 2, + Period: types.PeriodDaily, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "daily.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-07-01T00:00:00Z", + End: "2026-08-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, float64(30), drivers[0].Quantity.Value) + require.Equal(t, "month", drivers[0].Per.Unit) + require.Equal(t, float64(60), drivers[0].Quantity.Value*drivers[0].UnitPrice.Amount.Amount) +} + +func TestLegacyMappingDowngradesIncompleteProviderCatalogProvenance(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Catalog-backed database", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceProviderCatalog, + URL: "https://example.com/pricing", + CapturedAt: "2026-07-31", + Confidence: types.CostSourceConfidenceMedium, + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "legacy.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, v1.SourceLegacyScenario, drivers[0].Evidence.Quantity.Source.Type) + require.Equal(t, "https://example.com/pricing", drivers[0].Evidence.Quantity.Source.URL) + require.Equal(t, v1.ConfidenceLow, drivers[0].Evidence.Quantity.Confidence) + require.Contains(t, drivers[0].Evidence.Quantity.ConfidenceRationale, "without v1 refresh policy") +} + +func TestCompatibilityFixtureIsStableJSON(t *testing.T) { + path := filepath.Join(repoRoot(t), "test", "fixtures", "cost_contract", "v1", "legacy_valid_config_mapping.json") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.True(t, json.Valid(data)) +} diff --git a/pkg/contracts/cost/v1/types.go b/pkg/contracts/cost/v1/types.go new file mode 100644 index 0000000..373e484 --- /dev/null +++ b/pkg/contracts/cost/v1/types.go @@ -0,0 +1,161 @@ +// Package v1 defines ProfitCtl's first versioned cost-driver and +// cost-observation contract. +package v1 + +const SchemaVersion = "profitctl.cost/v1" + +type DriverKind string + +const ( + DriverFixed DriverKind = "fixed" + DriverVariable DriverKind = "variable" + DriverCadence DriverKind = "cadence" + DriverConcurrency DriverKind = "concurrency" + DriverUptime DriverKind = "uptime" +) + +type EvidenceKind string + +const ( + EvidencePredicted EvidenceKind = "predicted" + EvidenceObserved EvidenceKind = "observed" + EvidenceBilled EvidenceKind = "billed" + EvidenceUserSupplied EvidenceKind = "user_supplied" +) + +type MeasurementKind string + +const ( + MeasurementMeasured MeasurementKind = "measured" + MeasurementSynthetic MeasurementKind = "synthetic" + MeasurementDerived MeasurementKind = "derived" + MeasurementDeclared MeasurementKind = "declared" +) + +type SourceType string + +const ( + SourceTemplate SourceType = "template" + SourceUserSupplied SourceType = "user_supplied" + SourceRepoDetected SourceType = "repo_detected" + SourceTelemetry SourceType = "telemetry" + SourceRuntimeLedger SourceType = "runtime_ledger" + SourceInvoice SourceType = "invoice" + SourceProviderCatalog SourceType = "provider_catalog" + SourceProfitCtlDerived SourceType = "profitctl_derived" + SourceSyntheticFixture SourceType = "synthetic_fixture" + SourceLegacyScenario SourceType = "legacy_scenario" +) + +type Confidence string + +const ( + ConfidenceLow Confidence = "low" + ConfidenceMedium Confidence = "medium" + ConfidenceHigh Confidence = "high" +) + +type DistributionType string + +const ( + DistributionNormal DistributionType = "normal" + DistributionUniform DistributionType = "uniform" + DistributionExponential DistributionType = "exponential" +) + +// Distribution preserves the stochastic workload basis used by legacy stress +// simulations. Parameters are distribution-specific. +type Distribution struct { + Type DistributionType `json:"type" yaml:"type"` + Mean *float64 `json:"mean,omitempty" yaml:"mean,omitempty"` + StdDev *float64 `json:"stddev,omitempty" yaml:"stddev,omitempty"` + Min *float64 `json:"min,omitempty" yaml:"min,omitempty"` + Max *float64 `json:"max,omitempty" yaml:"max,omitempty"` + Rate *float64 `json:"rate,omitempty" yaml:"rate,omitempty"` + Floor *float64 `json:"floor,omitempty" yaml:"floor,omitempty"` +} + +type Quantity struct { + Value float64 `json:"value" yaml:"value"` + Unit string `json:"unit" yaml:"unit"` +} + +type Money struct { + Amount float64 `json:"amount" yaml:"amount"` + Currency string `json:"currency" yaml:"currency"` +} + +type UnitPrice struct { + Amount Money `json:"amount" yaml:"amount"` + Per Quantity `json:"per" yaml:"per"` +} + +type TimeWindow struct { + Start string `json:"start" yaml:"start"` + End string `json:"end" yaml:"end"` +} + +type Dimensions struct { + Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` + Service string `json:"service,omitempty" yaml:"service,omitempty"` + Region string `json:"region,omitempty" yaml:"region,omitempty"` + Tier string `json:"tier,omitempty" yaml:"tier,omitempty"` + Workload string `json:"workload" yaml:"workload"` +} + +type SourceReference struct { + Type SourceType `json:"type" yaml:"type"` + ArtifactIdentity string `json:"artifact_identity,omitempty" yaml:"artifact_identity,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + CapturedAt string `json:"captured_at" yaml:"captured_at"` + RefreshOwner string `json:"refresh_owner,omitempty" yaml:"refresh_owner,omitempty"` + RefreshCadence string `json:"refresh_cadence,omitempty" yaml:"refresh_cadence,omitempty"` + StaleAfter string `json:"stale_after,omitempty" yaml:"stale_after,omitempty"` +} + +// Evidence classifies a claim separately from its provenance. A source label +// alone never establishes that a value was measured. +type Evidence struct { + Kind EvidenceKind `json:"kind" yaml:"kind"` + Measurement MeasurementKind `json:"measurement" yaml:"measurement"` + Source SourceReference `json:"source" yaml:"source"` + Confidence Confidence `json:"confidence" yaml:"confidence"` + ConfidenceRationale string `json:"confidence_rationale" yaml:"confidence_rationale"` +} + +type CostDriver struct { + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Kind DriverKind `json:"kind" yaml:"kind"` + Quantity Quantity `json:"quantity" yaml:"quantity"` + Per *Quantity `json:"per,omitempty" yaml:"per,omitempty"` + UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` + Window TimeWindow `json:"window" yaml:"window"` + Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` + Evidence DriverEvidence `json:"evidence" yaml:"evidence"` + Distribution *Distribution `json:"distribution,omitempty" yaml:"distribution,omitempty"` +} + +type DriverEvidence struct { + Quantity Evidence `json:"quantity" yaml:"quantity"` + UnitPrice Evidence `json:"unit_price" yaml:"unit_price"` +} + +type ClaimEvidence struct { + Quantity Evidence `json:"quantity" yaml:"quantity"` + UnitPrice Evidence `json:"unit_price" yaml:"unit_price"` + TotalCost Evidence `json:"total_cost" yaml:"total_cost"` +} + +type CostObservation struct { + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + DriverIDs []string `json:"driver_ids" yaml:"driver_ids"` + Window TimeWindow `json:"window" yaml:"window"` + Quantity Quantity `json:"quantity" yaml:"quantity"` + UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` + TotalCost Money `json:"total_cost" yaml:"total_cost"` + Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` + Evidence ClaimEvidence `json:"evidence" yaml:"evidence"` +} diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go new file mode 100644 index 0000000..bf66e33 --- /dev/null +++ b/pkg/contracts/cost/v1/validate.go @@ -0,0 +1,471 @@ +package v1 + +import ( + "errors" + "fmt" + "math" + "net/url" + "regexp" + "strings" + "time" +) + +var ( + canonicalUnitPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) + idPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,127}$`) +) + +func (d CostDriver) Validate() error { + if d.SchemaVersion != SchemaVersion { + return fmt.Errorf("schema_version must be %q", SchemaVersion) + } + if err := validateID(d.ID); err != nil { + return fmt.Errorf("id: %w", err) + } + if strings.TrimSpace(d.Name) == "" { + return errors.New("name is required") + } + if !validDriverKind(d.Kind) { + return fmt.Errorf("unsupported driver kind %q", d.Kind) + } + if (d.Kind == DriverVariable || d.Kind == DriverCadence) && d.Per == nil { + return fmt.Errorf("%s driver requires per scale basis", d.Kind) + } + if err := d.Quantity.Validate(); err != nil { + return fmt.Errorf("quantity: %w", err) + } + if d.Kind == DriverUptime && !isTimeUnit(d.Quantity.Unit) { + return errors.New("uptime driver quantity.unit must be a canonical time unit") + } + if d.Per != nil { + if err := d.Per.Validate(); err != nil { + return fmt.Errorf("per: %w", err) + } + if d.Per.Value <= 0 { + return errors.New("per.value must be greater than zero") + } + if d.Kind == DriverCadence && !isTimeUnit(d.Per.Unit) { + return errors.New("cadence driver per.unit must be a canonical time unit") + } + } + if err := d.UnitPrice.Validate(); err != nil { + return fmt.Errorf("unit_price: %w", err) + } + if d.UnitPrice.Per.Unit != d.Quantity.Unit { + return fmt.Errorf("unit_price.per unit %q must match quantity unit %q", d.UnitPrice.Per.Unit, d.Quantity.Unit) + } + if err := d.Window.Validate(); err != nil { + return fmt.Errorf("window: %w", err) + } + if err := d.Dimensions.Validate(); err != nil { + return fmt.Errorf("dimensions: %w", err) + } + driverClaims := []struct { + name string + evidence Evidence + }{ + {"quantity", d.Evidence.Quantity}, + {"unit_price", d.Evidence.UnitPrice}, + } + for _, claim := range driverClaims { + if err := claim.evidence.Validate(); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + if err := validateClaimAuthority(claim.name, claim.evidence.Source.Type); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + if err := validateEvidenceFreshForWindow(claim.evidence, d.Window); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + } + if d.Distribution != nil { + if d.Kind != DriverVariable { + return errors.New("distribution is only valid for variable drivers") + } + if err := d.Distribution.Validate(); err != nil { + return fmt.Errorf("distribution: %w", err) + } + } + return nil +} + +func (d Distribution) Validate() error { + validPositive := func(value *float64) bool { + return value != nil && !math.IsNaN(*value) && !math.IsInf(*value, 0) && *value > 0 + } + switch d.Type { + case DistributionNormal: + if !validPositive(d.Mean) || !validPositive(d.StdDev) { + return errors.New("normal distribution requires positive mean and stddev") + } + case DistributionUniform: + if d.Min == nil || d.Max == nil || + math.IsNaN(*d.Min) || math.IsInf(*d.Min, 0) || + math.IsNaN(*d.Max) || math.IsInf(*d.Max, 0) || + *d.Min >= *d.Max { + return errors.New("uniform distribution requires finite min less than max") + } + if d.Floor == nil { + return errors.New("uniform distribution requires explicit non-negative floor") + } + case DistributionExponential: + if !validPositive(d.Rate) { + return errors.New("exponential distribution requires positive rate") + } + default: + return fmt.Errorf("unsupported distribution type %q", d.Type) + } + if d.Floor != nil && (math.IsNaN(*d.Floor) || math.IsInf(*d.Floor, 0) || *d.Floor < 0) { + return errors.New("floor must be a finite non-negative number") + } + return nil +} + +func (o CostObservation) Validate() error { + if o.SchemaVersion != SchemaVersion { + return fmt.Errorf("schema_version must be %q", SchemaVersion) + } + if err := validateID(o.ID); err != nil { + return fmt.Errorf("id: %w", err) + } + if len(o.DriverIDs) == 0 { + return errors.New("driver_ids must contain at least one driver") + } + seenDriverIDs := make(map[string]struct{}, len(o.DriverIDs)) + for i, driverID := range o.DriverIDs { + if err := validateID(driverID); err != nil { + return fmt.Errorf("driver_ids[%d]: %w", i, err) + } + if _, exists := seenDriverIDs[driverID]; exists { + return fmt.Errorf("driver_ids[%d]: duplicate driver id %q", i, driverID) + } + seenDriverIDs[driverID] = struct{}{} + } + if err := o.Window.Validate(); err != nil { + return fmt.Errorf("window: %w", err) + } + if err := o.Quantity.Validate(); err != nil { + return fmt.Errorf("quantity: %w", err) + } + if err := o.UnitPrice.Validate(); err != nil { + return fmt.Errorf("unit_price: %w", err) + } + if o.UnitPrice.Per.Unit != o.Quantity.Unit { + return fmt.Errorf("unit_price.per unit %q must match quantity unit %q", o.UnitPrice.Per.Unit, o.Quantity.Unit) + } + if err := o.TotalCost.Validate(); err != nil { + return fmt.Errorf("total_cost: %w", err) + } + if o.UnitPrice.Amount.Currency != o.TotalCost.Currency { + return errors.New("unit_price and total_cost currencies must match") + } + if err := o.Dimensions.Validate(); err != nil { + return fmt.Errorf("dimensions: %w", err) + } + claims := []struct { + name string + evidence Evidence + }{ + {"quantity", o.Evidence.Quantity}, + {"unit_price", o.Evidence.UnitPrice}, + {"total_cost", o.Evidence.TotalCost}, + } + for _, claim := range claims { + if err := claim.evidence.Validate(); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + if err := validateClaimAuthority(claim.name, claim.evidence.Source.Type); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + if err := validateEvidenceFreshForWindow(claim.evidence, o.Window); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + } + if o.Evidence.TotalCost.Measurement == MeasurementDerived { + expected := o.Quantity.Value * o.UnitPrice.Amount.Amount / o.UnitPrice.Per.Value + if math.IsNaN(expected) || math.IsInf(expected, 0) { + return errors.New("derived total_cost calculation must remain finite") + } + tolerance := math.Max(1e-9, math.Abs(expected)*1e-9) + if math.Abs(o.TotalCost.Amount-expected) > tolerance { + return fmt.Errorf("derived total_cost amount %.12g must equal quantity times unit price %.12g", o.TotalCost.Amount, expected) + } + } + return nil +} + +func (q Quantity) Validate() error { + if math.IsNaN(q.Value) || math.IsInf(q.Value, 0) || q.Value < 0 { + return errors.New("value must be a finite number greater than or equal to zero") + } + return validateUnit(q.Unit) +} + +func (m Money) Validate() error { + if math.IsNaN(m.Amount) || math.IsInf(m.Amount, 0) || m.Amount < 0 { + return errors.New("amount must be a finite number greater than or equal to zero") + } + if !currencyPattern.MatchString(m.Currency) { + return errors.New("currency must be an uppercase ISO 4217 code") + } + return nil +} + +func (p UnitPrice) Validate() error { + if err := p.Amount.Validate(); err != nil { + return fmt.Errorf("amount: %w", err) + } + if err := p.Per.Validate(); err != nil { + return fmt.Errorf("per: %w", err) + } + if p.Per.Value <= 0 { + return errors.New("per.value must be greater than zero") + } + return nil +} + +func (w TimeWindow) Validate() error { + start, err := time.Parse(time.RFC3339, w.Start) + if err != nil { + return errors.New("start must be an RFC3339 timestamp") + } + end, err := time.Parse(time.RFC3339, w.End) + if err != nil { + return errors.New("end must be an RFC3339 timestamp") + } + if !start.Before(end) { + return errors.New("start must be before end") + } + return nil +} + +func (d Dimensions) Validate() error { + if strings.TrimSpace(d.Workload) == "" { + return errors.New("workload is required") + } + return nil +} + +func (e Evidence) Validate() error { + if !validEvidenceKind(e.Kind) { + return fmt.Errorf("unsupported evidence kind %q", e.Kind) + } + if !validMeasurementKind(e.Measurement) { + return fmt.Errorf("unsupported measurement kind %q", e.Measurement) + } + if !validSourceType(e.Source.Type) { + return fmt.Errorf("unsupported source type %q", e.Source.Type) + } + if !validConfidence(e.Confidence) { + return fmt.Errorf("unsupported confidence %q", e.Confidence) + } + if strings.TrimSpace(e.ConfidenceRationale) == "" { + return errors.New("confidence_rationale is required") + } + if strings.TrimSpace(e.Source.ArtifactIdentity) == "" && strings.TrimSpace(e.Source.URL) == "" { + return errors.New("source requires artifact_identity or url") + } + if e.Source.URL != "" { + parsed, err := url.ParseRequestURI(e.Source.URL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return errors.New("source.url must be an absolute URI") + } + } + if !validCaptureTime(e.Source.CapturedAt) { + return errors.New("source.captured_at must be an RFC3339 timestamp or ISO date") + } + if e.Source.Type == SourceProviderCatalog { + if strings.TrimSpace(e.Source.RefreshOwner) == "" || + strings.TrimSpace(e.Source.RefreshCadence) == "" || + strings.TrimSpace(e.Source.StaleAfter) == "" { + return errors.New("provider_catalog source requires refresh_owner, refresh_cadence, and stale_after") + } + if !validCaptureTime(e.Source.StaleAfter) { + return errors.New("source.stale_after must be an RFC3339 timestamp or ISO date") + } + capturedAt, _ := parseCaptureTime(e.Source.CapturedAt) + staleAfter, _ := parseCaptureTime(e.Source.StaleAfter) + if !capturedAt.Before(staleAfter) { + return errors.New("source.stale_after must be after source.captured_at") + } + if e.Confidence == ConfidenceHigh { + return errors.New("provider_catalog evidence cannot claim high confidence") + } + } + if e.Source.Type == SourceTemplate && e.Confidence == ConfidenceHigh { + return errors.New("template evidence cannot claim high confidence") + } + if e.Source.Type == SourceRepoDetected && e.Confidence == ConfidenceHigh { + return errors.New("repo_detected evidence cannot claim high confidence") + } + if e.Source.Type == SourceSyntheticFixture && e.Confidence == ConfidenceHigh { + return errors.New("synthetic_fixture evidence cannot claim high confidence") + } + if e.Source.Type == SourceLegacyScenario && e.Confidence == ConfidenceHigh { + return errors.New("legacy_scenario evidence cannot claim high confidence") + } + if e.Source.Type == SourceProfitCtlDerived && e.Measurement != MeasurementDerived { + return errors.New("profitctl_derived evidence must use derived measurement") + } + if e.Measurement == MeasurementMeasured && e.Source.Type != SourceTelemetry && + e.Source.Type != SourceRuntimeLedger && e.Source.Type != SourceInvoice { + return errors.New("measured evidence requires telemetry, runtime_ledger, or invoice source") + } + if e.Measurement == MeasurementSynthetic && e.Source.Type != SourceSyntheticFixture { + return errors.New("synthetic measurement requires synthetic_fixture source") + } + if e.Kind == EvidenceBilled && e.Source.Type != SourceInvoice { + return errors.New("billed evidence requires invoice source") + } + if e.Kind == EvidenceBilled && e.Measurement != MeasurementMeasured { + return errors.New("billed evidence must be measured") + } + if e.Kind == EvidenceUserSupplied && e.Source.Type != SourceUserSupplied { + return errors.New("user_supplied evidence requires user_supplied source") + } + if e.Kind == EvidenceUserSupplied && e.Measurement != MeasurementDeclared { + return errors.New("user_supplied evidence must be declared") + } + if e.Kind == EvidenceObserved && e.Measurement != MeasurementMeasured && e.Measurement != MeasurementSynthetic { + return errors.New("observed evidence must be measured or synthetic") + } + if e.Kind == EvidencePredicted && e.Measurement == MeasurementMeasured { + return errors.New("predicted evidence cannot claim measured status") + } + return nil +} + +func validateClaimAuthority(claim string, source SourceType) error { + var allowed bool + switch claim { + case "quantity": + switch source { + case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceTelemetry, + SourceRuntimeLedger, SourceSyntheticFixture, SourceLegacyScenario: + allowed = true + } + case "unit_price": + switch source { + case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceInvoice, + SourceProviderCatalog, SourceSyntheticFixture, SourceLegacyScenario: + allowed = true + } + case "total_cost": + switch source { + case SourceTemplate, SourceUserSupplied, SourceInvoice, + SourceProfitCtlDerived, SourceSyntheticFixture, SourceLegacyScenario: + allowed = true + } + default: + return fmt.Errorf("unsupported claim %q", claim) + } + if !allowed { + return fmt.Errorf("source type %q is not authoritative for %s claim", source, claim) + } + return nil +} + +func validateEvidenceFreshForWindow(evidence Evidence, window TimeWindow) error { + if evidence.Source.Type != SourceProviderCatalog { + return nil + } + staleAfter, err := parseCaptureTime(evidence.Source.StaleAfter) + if err != nil { + return errors.New("source.stale_after must be an RFC3339 timestamp or ISO date") + } + windowStart, err := time.Parse(time.RFC3339, window.Start) + if err != nil { + return errors.New("window.start must be an RFC3339 timestamp") + } + if staleAfter.Before(windowStart) { + return errors.New("provider_catalog source is stale before window start") + } + return nil +} + +func validateUnit(unit string) error { + if !canonicalUnitPattern.MatchString(unit) { + return errors.New("unit must be a non-empty canonical lowercase identifier") + } + switch unit { + case "unit", "units", "gb", "mb", "kb": + return fmt.Errorf("ambiguous unit %q is not allowed", unit) + } + return nil +} + +func validateID(id string) error { + if !idPattern.MatchString(id) { + return errors.New("must be a non-empty canonical identifier") + } + return nil +} + +func validCaptureTime(value string) bool { + _, err := parseCaptureTime(value) + return err == nil +} + +func parseCaptureTime(value string) (time.Time, error) { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + return time.Parse(time.DateOnly, value) +} + +func isTimeUnit(unit string) bool { + switch unit { + case "second", "minute", "hour", "day", "week", "month", "year": + return true + default: + return false + } +} + +func validDriverKind(value DriverKind) bool { + switch value { + case DriverFixed, DriverVariable, DriverCadence, DriverConcurrency, DriverUptime: + return true + default: + return false + } +} + +func validEvidenceKind(value EvidenceKind) bool { + switch value { + case EvidencePredicted, EvidenceObserved, EvidenceBilled, EvidenceUserSupplied: + return true + default: + return false + } +} + +func validMeasurementKind(value MeasurementKind) bool { + switch value { + case MeasurementMeasured, MeasurementSynthetic, MeasurementDerived, MeasurementDeclared: + return true + default: + return false + } +} + +func validSourceType(value SourceType) bool { + switch value { + case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceTelemetry, + SourceRuntimeLedger, SourceInvoice, SourceProviderCatalog, + SourceProfitCtlDerived, SourceSyntheticFixture, SourceLegacyScenario: + return true + default: + return false + } +} + +func validConfidence(value Confidence) bool { + switch value { + case ConfidenceLow, ConfidenceMedium, ConfidenceHigh: + return true + default: + return false + } +} diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go new file mode 100644 index 0000000..24265d4 --- /dev/null +++ b/pkg/contracts/cost/v1/validate_test.go @@ -0,0 +1,370 @@ +package v1_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + v1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + "github.com/stretchr/testify/require" +) + +func TestAllDriverKindsValidate(t *testing.T) { + for _, kind := range []v1.DriverKind{ + v1.DriverFixed, + v1.DriverVariable, + v1.DriverCadence, + v1.DriverConcurrency, + v1.DriverUptime, + } { + t.Run(string(kind), func(t *testing.T) { + driver := validDriver() + driver.Kind = kind + if kind == v1.DriverCadence { + driver.Per = &v1.Quantity{Value: 1, Unit: "minute"} + } + if kind == v1.DriverUptime { + driver.Quantity.Unit = "hour" + driver.UnitPrice.Per.Unit = "hour" + } + require.NoError(t, driver.Validate()) + }) + } +} + +func TestDriverRejectsMissingMalformedAndAmbiguousUnits(t *testing.T) { + for _, unit := range []string{"", "commands / month", "GB", "gb", "units"} { + t.Run(unit, func(t *testing.T) { + driver := validDriver() + driver.Quantity.Unit = unit + driver.UnitPrice.Per.Unit = unit + require.Error(t, driver.Validate()) + }) + } +} + +func TestDriverRejectsMissingTimeWindow(t *testing.T) { + driver := validDriver() + driver.Window = v1.TimeWindow{} + require.ErrorContains(t, driver.Validate(), "window") +} + +func TestScalingDriversRequirePerBasis(t *testing.T) { + for _, kind := range []v1.DriverKind{v1.DriverVariable, v1.DriverCadence} { + driver := validDriver() + driver.Kind = kind + driver.Per = nil + require.ErrorContains(t, driver.Validate(), "requires per scale basis") + + driver.Per = &v1.Quantity{Value: 0, Unit: "user"} + require.ErrorContains(t, driver.Validate(), "per.value must be greater than zero") + } +} + +func TestEveryPresentPerBasisMustBePositive(t *testing.T) { + for _, kind := range []v1.DriverKind{ + v1.DriverFixed, + v1.DriverVariable, + v1.DriverCadence, + v1.DriverConcurrency, + v1.DriverUptime, + } { + driver := validDriver() + driver.Kind = kind + if kind == v1.DriverUptime { + driver.Quantity.Unit = "hour" + driver.UnitPrice.Per.Unit = "hour" + } + driver.Per = &v1.Quantity{Value: 0, Unit: "user"} + require.ErrorContains(t, driver.Validate(), "per.value must be greater than zero") + } +} + +func TestCadenceRequiresTimeBasis(t *testing.T) { + driver := validDriver() + driver.Kind = v1.DriverCadence + driver.Per = &v1.Quantity{Value: 1, Unit: "user"} + require.ErrorContains(t, driver.Validate(), "canonical time unit") + + driver.Per.Unit = "minute" + require.NoError(t, driver.Validate()) +} + +func TestUptimeRequiresTimeQuantity(t *testing.T) { + driver := validDriver() + driver.Kind = v1.DriverUptime + require.ErrorContains(t, driver.Validate(), "canonical time unit") + + driver.Quantity.Unit = "hour" + driver.UnitPrice.Per.Unit = "hour" + require.NoError(t, driver.Validate()) +} + +func TestUniformDistributionAllowsZeroMinimum(t *testing.T) { + minimum, maximum := 0.0, 10.0 + floor := 0.0 + driver := validDriver() + driver.Distribution = &v1.Distribution{ + Type: v1.DistributionUniform, + Min: &minimum, + Max: &maximum, + Floor: &floor, + } + require.NoError(t, driver.Validate()) +} + +func TestEvidenceDoesNotTreatProvenanceAsMeasurement(t *testing.T) { + evidence := validEvidence() + evidence.Measurement = v1.MeasurementMeasured + evidence.Source.Type = v1.SourceProviderCatalog + evidence.Source.RefreshOwner = "catalog-maintainer" + evidence.Source.RefreshCadence = "30d" + evidence.Source.StaleAfter = "2026-09-01" + require.ErrorContains(t, evidence.Validate(), "measured evidence requires") +} + +func TestBilledEvidenceRequiresInvoice(t *testing.T) { + evidence := validEvidence() + evidence.Kind = v1.EvidenceBilled + require.ErrorContains(t, evidence.Validate(), "invoice") + + evidence.Source.Type = v1.SourceInvoice + evidence.Measurement = v1.MeasurementDeclared + require.ErrorContains(t, evidence.Validate(), "must be measured") + + evidence.Measurement = v1.MeasurementMeasured + require.NoError(t, evidence.Validate()) +} + +func TestObservationEnforcesClaimSpecificAuthority(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Evidence.UnitPrice = v1.Evidence{ + Kind: v1.EvidenceObserved, + Measurement: v1.MeasurementMeasured, + Source: v1.SourceReference{ + Type: v1.SourceTelemetry, + ArtifactIdentity: "telemetry", + CapturedAt: "2026-08-01", + }, + Confidence: v1.ConfidenceHigh, + ConfidenceRationale: "Measured usage only.", + } + require.ErrorContains(t, fixture.Observation.Validate(), "not authoritative for unit_price") +} + +func TestObservationValidatesDerivedTotalArithmetic(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Evidence.TotalCost.Source.Type = v1.SourceProfitCtlDerived + fixture.Observation.Evidence.TotalCost.Source.ArtifactIdentity = "profitctl://derived/upstash-total" + require.NoError(t, fixture.Observation.Validate()) + fixture.Observation.TotalCost.Amount = 999 + require.ErrorContains(t, fixture.Observation.Validate(), "derived total_cost") +} + +func TestProfitCtlDerivedEvidenceRequiresDerivedMeasurement(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Evidence.TotalCost.Source.Type = v1.SourceProfitCtlDerived + fixture.Observation.Evidence.TotalCost.Source.ArtifactIdentity = "profitctl://derived/total" + fixture.Observation.Evidence.TotalCost.Measurement = v1.MeasurementDeclared + require.ErrorContains(t, fixture.Observation.Validate(), "must use derived measurement") +} + +func TestObservationRejectsOverflowingDerivedTotal(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Quantity.Value = 1e308 + fixture.Observation.UnitPrice.Amount.Amount = 1e308 + fixture.Observation.TotalCost.Amount = 0 + fixture.Observation.Evidence.TotalCost.Source.Type = v1.SourceProfitCtlDerived + fixture.Observation.Evidence.TotalCost.Source.ArtifactIdentity = "profitctl://derived/overflow" + require.ErrorContains(t, fixture.Observation.Validate(), "must remain finite") +} + +func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceProviderCatalog + require.ErrorContains(t, evidence.Validate(), "refresh_owner") + + evidence.Source.RefreshOwner = "catalog-maintainer" + evidence.Source.RefreshCadence = "30d" + evidence.Source.StaleAfter = "2026-09-01" + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "cannot claim high confidence") + + evidence.Confidence = v1.ConfidenceMedium + require.NoError(t, evidence.Validate()) + + evidence.Source.StaleAfter = "2026-07-31" + require.ErrorContains(t, evidence.Validate(), "must be after") +} + +func TestDriverRejectsCatalogStaleBeforeWindow(t *testing.T) { + driver := validDriver() + driver.Evidence.UnitPrice.Source.Type = v1.SourceProviderCatalog + driver.Evidence.UnitPrice.Source.RefreshOwner = "catalog-maintainer" + driver.Evidence.UnitPrice.Source.RefreshCadence = "30d" + driver.Evidence.UnitPrice.Source.CapturedAt = "2026-07-01" + driver.Evidence.UnitPrice.Source.StaleAfter = "2026-07-31" + require.ErrorContains(t, driver.Validate(), "stale before window start") + + driver.Evidence.UnitPrice.Source.StaleAfter = "2026-08-15" + require.NoError(t, driver.Validate()) +} + +func TestDriverEnforcesClaimSpecificAuthority(t *testing.T) { + driver := validDriver() + driver.Evidence.UnitPrice.Source.Type = v1.SourceTelemetry + require.ErrorContains(t, driver.Validate(), "not authoritative for unit_price") + + driver = validDriver() + driver.Evidence.Quantity.Source.Type = v1.SourceProviderCatalog + driver.Evidence.Quantity.Source.RefreshOwner = "catalog-maintainer" + driver.Evidence.Quantity.Source.RefreshCadence = "30d" + driver.Evidence.Quantity.Source.StaleAfter = "2026-08-15" + require.ErrorContains(t, driver.Validate(), "not authoritative for quantity") +} + +func TestTemplateEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceTemplate + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "template evidence cannot claim high confidence") +} + +func TestRepoDetectedEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceRepoDetected + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "repo_detected evidence cannot claim high confidence") +} + +func TestSyntheticFixtureEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Kind = v1.EvidenceObserved + evidence.Measurement = v1.MeasurementSynthetic + evidence.Source.Type = v1.SourceSyntheticFixture + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "synthetic_fixture evidence cannot claim high confidence") +} + +func TestLegacyScenarioEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceLegacyScenario + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "legacy_scenario evidence cannot claim high confidence") +} + +func TestUserSuppliedEvidenceMustBeDeclared(t *testing.T) { + evidence := validEvidence() + evidence.Kind = v1.EvidenceUserSupplied + evidence.Measurement = v1.MeasurementDerived + require.ErrorContains(t, evidence.Validate(), "must be declared") +} + +func TestObservationRejectsDuplicateDriverIDs(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.DriverIDs = append(fixture.Observation.DriverIDs, fixture.Observation.DriverIDs[0]) + require.ErrorContains(t, fixture.Observation.Validate(), "duplicate driver id") +} + +func TestUpstashForecastToObservationFixture(t *testing.T) { + var fixture struct { + Drivers []v1.CostDriver `json:"drivers"` + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + + require.Len(t, fixture.Drivers, 1) + require.NoError(t, fixture.Drivers[0].Validate()) + require.NoError(t, fixture.Observation.Validate()) + require.Equal(t, fixture.Drivers[0].ID, fixture.Observation.DriverIDs[0]) + require.Equal(t, v1.MeasurementSynthetic, fixture.Observation.Evidence.Quantity.Measurement) + require.Equal(t, v1.SourceUserSupplied, fixture.Observation.Evidence.UnitPrice.Source.Type) +} + +func TestSchemasContainValidJSON(t *testing.T) { + root := repoRoot(t) + schemaDir := filepath.Join(root, "schemas", "cost-contract", "v1") + entries, err := os.ReadDir(schemaDir) + require.NoError(t, err) + require.Len(t, entries, 3) + + ids := map[string]bool{} + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(schemaDir, entry.Name())) + require.NoError(t, err) + var schema map[string]any + require.NoError(t, json.Unmarshal(data, &schema), entry.Name()) + id, ok := schema["$id"].(string) + require.True(t, ok, entry.Name()) + require.False(t, ids[id], entry.Name()) + ids[id] = true + } +} + +func validDriver() v1.CostDriver { + return v1.CostDriver{ + SchemaVersion: v1.SchemaVersion, + ID: "driver-1", + Name: "Requests", + Kind: v1.DriverVariable, + Quantity: v1.Quantity{Value: 10, Unit: "request"}, + Per: &v1.Quantity{Value: 1, Unit: "user"}, + UnitPrice: v1.UnitPrice{ + Amount: v1.Money{Amount: 0.001, Currency: "USD"}, + Per: v1.Quantity{Value: 1, Unit: "request"}, + }, + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + Dimensions: v1.Dimensions{Workload: "api_requests"}, + Evidence: v1.DriverEvidence{ + Quantity: validEvidence(), + UnitPrice: validEvidence(), + }, + } +} + +func validEvidence() v1.Evidence { + return v1.Evidence{ + Kind: v1.EvidencePredicted, + Measurement: v1.MeasurementDeclared, + Source: v1.SourceReference{ + Type: v1.SourceUserSupplied, + ArtifactIdentity: "test", + CapturedAt: "2026-08-01", + }, + Confidence: v1.ConfidenceMedium, + ConfidenceRationale: "Explicit test assumption.", + } +} + +func decodeFixture(t *testing.T, name string, target any) { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "cost_contract", "v1", name)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, target)) +} + +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) + require.NoError(t, err) + return root +} diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json new file mode 100644 index 0000000..1ae30d9 --- /dev/null +++ b/schemas/cost-contract/v1/common.schema.json @@ -0,0 +1,383 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/IntelIP/ProfitCtl/schemas/cost-contract/v1/common.schema.json", + "$defs": { + "quantity": { + "type": "object", + "additionalProperties": false, + "required": ["value", "unit"], + "properties": { + "value": { "type": "number", "minimum": 0 }, + "unit": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,62}$", + "not": { "enum": ["unit", "units", "gb", "mb", "kb"] } + } + } + }, + "money": { + "type": "object", + "additionalProperties": false, + "required": ["amount", "currency"], + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + } + }, + "unit_price": { + "type": "object", + "additionalProperties": false, + "required": ["amount", "per"], + "properties": { + "amount": { "$ref": "#/$defs/money" }, + "per": { + "allOf": [ + { "$ref": "#/$defs/quantity" }, + { "properties": { "value": { "exclusiveMinimum": 0 } } } + ] + } + } + }, + "window": { + "type": "object", + "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": { "type": "string", "format": "date-time" }, + "end": { "type": "string", "format": "date-time" } + } + }, + "dimensions": { + "type": "object", + "additionalProperties": false, + "required": ["workload"], + "properties": { + "provider": { "type": "string" }, + "service": { "type": "string" }, + "region": { "type": "string" }, + "tier": { "type": "string" }, + "workload": { "type": "string", "pattern": ".*\\S.*" } + } + }, + "distribution": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { "enum": ["normal", "uniform", "exponential"] }, + "mean": { "type": "number", "exclusiveMinimum": 0 }, + "stddev": { "type": "number", "exclusiveMinimum": 0 }, + "min": { "type": "number" }, + "max": { "type": "number" }, + "rate": { "type": "number", "exclusiveMinimum": 0 }, + "floor": { "type": "number", "minimum": 0 } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "normal" } } }, + "then": { "required": ["mean", "stddev"] } + }, + { + "if": { "properties": { "type": { "const": "uniform" } } }, + "then": { "required": ["min", "max", "floor"] } + }, + { + "if": { "properties": { "type": { "const": "exponential" } } }, + "then": { "required": ["rate"] } + } + ] + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["type", "captured_at"], + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "provider_catalog" } + }, + "required": ["type"] + }, + "then": { + "required": [ + "refresh_owner", + "refresh_cadence", + "stale_after" + ] + } + } + ], + "anyOf": [ + { "required": ["artifact_identity"] }, + { "required": ["url"] } + ], + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "telemetry", + "runtime_ledger", + "invoice", + "provider_catalog", + "profitctl_derived", + "synthetic_fixture", + "legacy_scenario" + ] + }, + "artifact_identity": { "type": "string", "pattern": ".*\\S.*" }, + "url": { + "type": "string", + "format": "uri", + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*://[^/?#\\s]+[^\\s]*$" + }, + "captured_at": { + "anyOf": [ + { "type": "string", "format": "date" }, + { "type": "string", "format": "date-time" } + ], + "description": "ISO date or RFC3339 timestamp." + }, + "refresh_owner": { "type": "string", "pattern": ".*\\S.*" }, + "refresh_cadence": { "type": "string", "pattern": ".*\\S.*" }, + "stale_after": { + "anyOf": [ + { "type": "string", "format": "date" }, + { "type": "string", "format": "date-time" } + ], + "description": "ISO date or RFC3339 timestamp." + } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "provider_catalog" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "legacy_scenario" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "synthetic_fixture" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, + { + "if": { + "properties": { + "measurement": { "const": "measured" } + }, + "required": ["measurement"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { "enum": ["telemetry", "runtime_ledger", "invoice"] } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "observed" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "enum": ["measured", "synthetic"] } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "predicted" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "enum": ["synthetic", "derived", "declared"] } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "user_supplied" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "const": "declared" }, + "source": { + "properties": { + "type": { "const": "user_supplied" } + } + } + } + } + }, + { + "if": { + "properties": { + "measurement": { "const": "synthetic" } + }, + "required": ["measurement"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { "const": "synthetic_fixture" } + } + } + } + } + }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "profitctl_derived" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "measurement": { "const": "derived" } + } + } + }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "repo_detected" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "template" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "billed" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "const": "measured" }, + "source": { + "properties": { + "type": { "const": "invoice" } + } + } + } + } + } + ], + "required": [ + "kind", + "measurement", + "source", + "confidence", + "confidence_rationale" + ], + "properties": { + "kind": { + "enum": ["predicted", "observed", "billed", "user_supplied"] + }, + "measurement": { + "enum": ["measured", "synthetic", "derived", "declared"] + }, + "source": { "$ref": "#/$defs/source" }, + "confidence": { "enum": ["low", "medium", "high"] }, + "confidence_rationale": { "type": "string", "pattern": ".*\\S.*" } + } + } + } +} diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json new file mode 100644 index 0000000..0469f75 --- /dev/null +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/IntelIP/ProfitCtl/schemas/cost-contract/v1/cost-driver.schema.json", + "title": "ProfitCtl Cost Driver v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "name", + "kind", + "quantity", + "unit_price", + "window", + "dimensions", + "evidence" + ], + "properties": { + "schema_version": { "const": "profitctl.cost/v1" }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + }, + "name": { "type": "string", "pattern": ".*\\S.*" }, + "kind": { + "enum": ["fixed", "variable", "cadence", "concurrency", "uptime"] + }, + "quantity": { + "$ref": "common.schema.json#/$defs/quantity" + }, + "per": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/quantity" }, + { "properties": { "value": { "exclusiveMinimum": 0 } } } + ] + }, + "unit_price": { + "$ref": "common.schema.json#/$defs/unit_price" + }, + "window": { + "$ref": "common.schema.json#/$defs/window" + }, + "dimensions": { + "$ref": "common.schema.json#/$defs/dimensions" + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["quantity", "unit_price"], + "properties": { + "quantity": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "telemetry", + "runtime_ledger", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + }, + "unit_price": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "invoice", + "provider_catalog", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + } + } + }, + "distribution": { + "$ref": "common.schema.json#/$defs/distribution" + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { "enum": ["variable", "cadence"] } + }, + "required": ["kind"] + }, + "then": { + "required": ["per"] + } + }, + { + "if": { + "properties": { + "kind": { "const": "uptime" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "quantity": { + "properties": { + "unit": { + "enum": ["second", "minute", "hour", "day", "week", "month", "year"] + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "cadence" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "per": { + "properties": { + "unit": { + "enum": ["second", "minute", "hour", "day", "week", "month", "year"] + } + } + } + } + } + }, + { + "if": { + "required": ["distribution"] + }, + "then": { + "properties": { + "kind": { "const": "variable" } + } + } + } + ] +} diff --git a/schemas/cost-contract/v1/cost-observation.schema.json b/schemas/cost-contract/v1/cost-observation.schema.json new file mode 100644 index 0000000..01186f6 --- /dev/null +++ b/schemas/cost-contract/v1/cost-observation.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/IntelIP/ProfitCtl/schemas/cost-contract/v1/cost-observation.schema.json", + "title": "ProfitCtl Cost Observation v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "driver_ids", + "window", + "quantity", + "unit_price", + "total_cost", + "dimensions", + "evidence" + ], + "properties": { + "schema_version": { "const": "profitctl.cost/v1" }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + }, + "driver_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + } + }, + "window": { + "$ref": "common.schema.json#/$defs/window" + }, + "quantity": { + "$ref": "common.schema.json#/$defs/quantity" + }, + "unit_price": { + "$ref": "common.schema.json#/$defs/unit_price" + }, + "total_cost": { + "$ref": "common.schema.json#/$defs/money" + }, + "dimensions": { + "$ref": "common.schema.json#/$defs/dimensions" + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["quantity", "unit_price", "total_cost"], + "properties": { + "quantity": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "telemetry", + "runtime_ledger", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + }, + "unit_price": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "invoice", + "provider_catalog", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + }, + "total_cost": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "invoice", + "profitctl_derived", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + } + } + } + } +} diff --git a/test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json b/test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json new file mode 100644 index 0000000..8a39b29 --- /dev/null +++ b/test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json @@ -0,0 +1,26 @@ +{ + "source_scenario": "test/fixtures/valid_config.yml", + "mapping": { + "artifact_identity": "test/fixtures/valid_config.yml", + "captured_at": "2026-08-01", + "currency": "USD", + "window": { + "start": "2026-08-01T00:00:00Z", + "end": "2026-09-01T00:00:00Z" + }, + "variable_units": { + "API Calls": "request", + "Storage (S3)": "gibibyte", + "LLM Tokens": "token" + } + }, + "expected": [ + { "name": "AWS EC2", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "Database (RDS)", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "Auth Service (Auth0)", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "Dev Team Tools", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "API Calls", "kind": "variable", "unit": "request", "per_unit": "user" }, + { "name": "Storage (S3)", "kind": "variable", "unit": "gibibyte", "per_unit": "user" }, + { "name": "LLM Tokens", "kind": "variable", "unit": "token", "per_unit": "user" } + ] +} diff --git a/test/fixtures/cost_contract/v1/upstash_idle_polling.json b/test/fixtures/cost_contract/v1/upstash_idle_polling.json new file mode 100644 index 0000000..d3d63af --- /dev/null +++ b/test/fixtures/cost_contract/v1/upstash_idle_polling.json @@ -0,0 +1,134 @@ +{ + "fixture_status": "synthetic_historical_shape", + "note": "Deterministic local fixture. Quantities mirror the accepted idle-polling case; rate is explicitly user-supplied and is not a current Upstash price.", + "drivers": [ + { + "schema_version": "profitctl.cost/v1", + "id": "upstash-idle-poll-cadence", + "name": "Idle worker polling commands", + "kind": "cadence", + "quantity": { + "value": 1386000, + "unit": "command" + }, + "per": { + "value": 1, + "unit": "month" + }, + "unit_price": { + "amount": { + "amount": 0.000001, + "currency": "USD" + }, + "per": { + "value": 1, + "unit": "command" + } + }, + "window": { + "start": "2026-07-01T00:00:00Z", + "end": "2026-08-01T00:00:00Z" + }, + "dimensions": { + "provider": "upstash", + "service": "redis", + "tier": "synthetic", + "workload": "idle_worker_polling" + }, + "evidence": { + "quantity": { + "kind": "predicted", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Historical command quantity supplied for a compatibility fixture; no runtime measurement claim." + }, + "unit_price": { + "kind": "predicted", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Synthetic user-supplied unit rate; no current provider price or billing claim." + } + } + } + ], + "observation": { + "schema_version": "profitctl.cost/v1", + "id": "upstash-idle-polling-synthetic-observation", + "driver_ids": [ + "upstash-idle-poll-cadence" + ], + "window": { + "start": "2026-07-01T00:00:00Z", + "end": "2026-08-01T00:00:00Z" + }, + "quantity": { + "value": 1386000, + "unit": "command" + }, + "unit_price": { + "amount": { + "amount": 0.000001, + "currency": "USD" + }, + "per": { + "value": 1, + "unit": "command" + } + }, + "total_cost": { + "amount": 1.386, + "currency": "USD" + }, + "dimensions": { + "provider": "upstash", + "service": "redis", + "tier": "synthetic", + "workload": "idle_worker_polling" + }, + "evidence": { + "quantity": { + "kind": "observed", + "measurement": "synthetic", + "source": { + "type": "synthetic_fixture", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json#observation.quantity", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Synthetic fixture preserves historical shape but does not prove runtime measurement." + }, + "unit_price": { + "kind": "user_supplied", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json#observation.unit_price", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Synthetic rate for contract validation; not current provider pricing." + }, + "total_cost": { + "kind": "predicted", + "measurement": "derived", + "source": { + "type": "synthetic_fixture", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json#observation.total_cost", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Derived deterministically from synthetic quantity and user-supplied rate." + } + } + } +}