From f9de1dbc9f8b35e6ffb49c15e197f3cbc98e085f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 18:56:15 -0400 Subject: [PATCH 01/17] Explain cost variance and root cause from the CLI: variance-engine --- pkg/variance/v1/variance.go | 373 ++++++++++++++++++ pkg/variance/v1/variance_test.go | 207 ++++++++++ schemas/cost-variance/v1/result.schema.json | 33 ++ test/fixtures/cost-variance/v1/scenarios.json | 7 + 4 files changed, 620 insertions(+) create mode 100644 pkg/variance/v1/variance.go create mode 100644 pkg/variance/v1/variance_test.go create mode 100644 schemas/cost-variance/v1/result.schema.json create mode 100644 test/fixtures/cost-variance/v1/scenarios.json diff --git a/pkg/variance/v1/variance.go b/pkg/variance/v1/variance.go new file mode 100644 index 0000000..c194690 --- /dev/null +++ b/pkg/variance/v1/variance.go @@ -0,0 +1,373 @@ +// Package v1 compares validated cost observations without inferring causal delivery claims. +package v1 + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "time" + + cost "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" +) + +const SchemaVersion = "profitctl.cost-variance/v1" +const Rounding = 1e-6 // Currency amounts are reconciled before display rounding. + +type ExchangeRate struct { + From string `json:"from"` + To string `json:"to"` + Rate float64 `json:"rate"` // Units of To per one From; supplied snapshot, never fetched. + Source cost.SourceReference `json:"source"` +} + +type DriverPair struct { + Forecast cost.CostDriver `json:"forecast"` + Actual cost.CostDriver `json:"actual"` +} + +// Receipt identifies the exact pair of observations supporting a delivery attribution. +// A source label, commit string or timestamp alone is not an exact receipt. +type Receipt struct { + DriverID string `json:"driver_id"` + Kind string `json:"kind"` // commit, pull_request, release, or deployment + ForecastObservationID string `json:"forecast_observation_id"` + ActualObservationID string `json:"actual_observation_id"` + Source cost.SourceReference `json:"source"` +} + +type Input struct { + Forecast cost.CostObservation `json:"forecast"` + Actual *cost.CostObservation `json:"actual,omitempty"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + Drivers []DriverPair `json:"drivers"` + ExchangeRates []ExchangeRate `json:"exchange_rates,omitempty"` + Receipts []Receipt `json:"receipts,omitempty"` +} + +type Contribution struct { + DriverID string `json:"driver_id"` + Kind cost.DriverKind `json:"kind"` + Quantity float64 `json:"quantity"` + UnitPrice float64 `json:"unit_price"` + Distribution float64 `json:"distribution"` + Amount cost.Money `json:"amount"` + Confidence cost.Confidence `json:"confidence"` + ConfidenceScore float64 `json:"confidence_score"` + Attribution string `json:"attribution"` // modeled or unknown, never a causal assertion + MissingEvidence []string `json:"missing_evidence"` + Sources []cost.SourceReference `json:"sources"` + Receipts []Receipt `json:"receipts"` +} + +type Result struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` // available or unavailable + Window cost.TimeWindow `json:"window"` + ForecastObservationID string `json:"forecast_observation_id"` + ActualObservationID string `json:"actual_observation_id,omitempty"` + Forecast cost.Money `json:"forecast"` + Actual *cost.Money `json:"actual"` + AbsoluteVariance *cost.Money `json:"absolute_variance"` + PercentageVariance *float64 `json:"percentage_variance"` + Contributions []Contribution `json:"contributions"` + Residual *cost.Money `json:"residual"` + MissingEvidence []string `json:"missing_evidence"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + RoundingTolerance float64 `json:"rounding_tolerance"` + Sources []cost.SourceReference `json:"sources"` +} + +// Compare solves the multiplicative quantity/price/distribution cost equation +// via symmetric (Shapley) linear contributions. Residual always includes any +// ledger amount the supplied drivers cannot explain. No live exchange rates are used. +func Compare(in Input) (Result, error) { + if err := in.Forecast.Validate(); err != nil { + return Result{}, fmt.Errorf("forecast: %w", err) + } + result := Result{SchemaVersion: SchemaVersion, Status: "unavailable", Window: in.Forecast.Window, ForecastObservationID: in.Forecast.ID, Forecast: in.Forecast.TotalCost, RoundingTolerance: Rounding, Contributions: []Contribution{}, MissingEvidence: []string{}, Sources: []cost.SourceReference{in.Forecast.Evidence.TotalCost.Source}} + if in.Actual == nil { + if in.UnavailableReason == "" { + return Result{}, errors.New("actual is unavailable: unavailable_reason is required") + } + if len(in.Drivers) > 0 || len(in.Receipts) > 0 { + return Result{}, errors.New("unavailable actual cannot have driver pairs or receipts") + } + result.UnavailableReason = in.UnavailableReason + result.MissingEvidence = append(result.MissingEvidence, "actual cost: "+in.UnavailableReason) + return result, nil + } + actual := *in.Actual + if err := actual.Validate(); err != nil { + return Result{}, fmt.Errorf("actual: %w", err) + } + if in.UnavailableReason != "" { + return Result{}, errors.New("unavailable_reason must be empty when actual is present") + } + if in.Forecast.Window != actual.Window || in.Forecast.Dimensions.Workload != actual.Dimensions.Workload { + return Result{}, errors.New("forecast and actual must have identical windows and workloads") + } + convert, err := converter(in.Forecast.TotalCost.Currency, in.ExchangeRates) + if err != nil { + return Result{}, err + } + actualTotal, err := convert(actual.TotalCost) + if err != nil { + return Result{}, fmt.Errorf("actual total: %w", err) + } + delta := actualTotal - in.Forecast.TotalCost.Amount + if !finite(delta) { + return Result{}, errors.New("variance overflow") + } + result.Status = "available" + result.ActualObservationID = actual.ID + result.Actual = &cost.Money{Amount: actualTotal, Currency: result.Forecast.Currency} + result.AbsoluteVariance = &cost.Money{Amount: delta, Currency: result.Forecast.Currency} + result.Residual = &cost.Money{Amount: delta, Currency: result.Forecast.Currency} + result.Sources = append(result.Sources, actual.Evidence.TotalCost.Source) + for _, rate := range in.ExchangeRates { + if rate.To == result.Forecast.Currency { + result.Sources = append(result.Sources, rate.Source) + } + } + if in.Forecast.TotalCost.Amount != 0 { + p := 100 * delta / in.Forecast.TotalCost.Amount + if !finite(p) { + return Result{}, errors.New("percentage overflow") + } + result.PercentageVariance = &p + } + forecastIDs := make(map[string]bool) + actualIDs := make(map[string]bool) + for _, id := range in.Forecast.DriverIDs { + forecastIDs[id] = true + } + for _, id := range actual.DriverIDs { + actualIDs[id] = true + } + seen := make(map[string]bool) + for _, pair := range in.Drivers { + f, a := pair.Forecast, pair.Actual + if err := f.Validate(); err != nil { + return Result{}, fmt.Errorf("forecast driver %q: %w", f.ID, err) + } + if err := a.Validate(); err != nil { + return Result{}, fmt.Errorf("actual driver %q: %w", a.ID, err) + } + if f.ID != a.ID || seen[f.ID] || !forecastIDs[f.ID] || !actualIDs[a.ID] { + return Result{}, fmt.Errorf("driver pair %q must be unique and referenced by both observations", f.ID) + } + seen[f.ID] = true + if f.Window != result.Window || a.Window != result.Window || f.Kind != a.Kind || f.Dimensions.Workload != in.Forecast.Dimensions.Workload || a.Dimensions.Workload != actual.Dimensions.Workload { + return Result{}, fmt.Errorf("driver %q window, workload or kind mismatch", f.ID) + } + fq, fp, fd, err := factors(f, convert) + if err != nil { + return Result{}, fmt.Errorf("forecast driver %q: %w", f.ID, err) + } + aq, ap, ad, err := factors(a, convert) + if err != nil { + return Result{}, fmt.Errorf("actual driver %q: %w", a.ID, err) + } + if f.Quantity.Unit != a.Quantity.Unit && !(timeUnits[f.Quantity.Unit] > 0 && timeUnits[a.Quantity.Unit] > 0) { + return Result{}, fmt.Errorf("driver %q incompatible quantity units", f.ID) + } + // The per-unit currency amount was normalized to one canonical quantity unit. + parts := shapley([3]float64{fq, fp, fd}, [3]float64{aq, ap, ad}) + amount := parts[0] + parts[1] + parts[2] + if !finite(amount) { + return Result{}, fmt.Errorf("driver %q contribution overflow", f.ID) + } + c := Contribution{DriverID: f.ID, Kind: f.Kind, Quantity: parts[0], UnitPrice: parts[1], Distribution: parts[2], Amount: cost.Money{Amount: amount, Currency: result.Forecast.Currency}, Attribution: "modeled", MissingEvidence: []string{}, Sources: []cost.SourceReference{f.Evidence.Quantity.Source, f.Evidence.UnitPrice.Source, a.Evidence.Quantity.Source, a.Evidence.UnitPrice.Source}, Receipts: []Receipt{}} + for _, r := range in.Receipts { + if r.DriverID == f.ID { + if r.ForecastObservationID != in.Forecast.ID || r.ActualObservationID != actual.ID || r.Source.ArtifactIdentity == "" || r.Source.Type != cost.SourceRuntimeLedger || !validCapture(r.Source.CapturedAt) || !receiptKind(r.Kind) { + return Result{}, fmt.Errorf("driver %q has invalid exact receipt", f.ID) + } + c.Receipts = append(c.Receipts, r) + } + } + confidence(&c, f, a, delta) + result.Contributions = append(result.Contributions, c) + result.Residual.Amount -= amount + } + for _, r := range in.Receipts { + if !seen[r.DriverID] { + return Result{}, fmt.Errorf("receipt references unknown driver %q", r.DriverID) + } + } + sort.Slice(result.Contributions, func(i, j int) bool { + x, y := result.Contributions[i], result.Contributions[j] + if math.Abs(x.Amount.Amount) == math.Abs(y.Amount.Amount) { + return x.DriverID < y.DriverID + } + return math.Abs(x.Amount.Amount) > math.Abs(y.Amount.Amount) + }) + if math.Abs(result.Residual.Amount) <= Rounding { + result.Residual.Amount = 0 + } else { + result.MissingEvidence = append(result.MissingEvidence, "unexplained ledger variance: missing or incomplete driver evidence") + } + return result, nil +} + +func receiptKind(k string) bool { + switch k { + case "commit", "pull_request", "release", "deployment": + return true + } + return false +} + +// Rates are direct, non-ambiguous snapshots. Same-currency conversion is identity. +func converter(target string, rates []ExchangeRate) (func(cost.Money) (float64, error), error) { + table := map[string]float64{} + for _, r := range rates { + if r.From == r.To || r.From == "" || r.To == "" || !finite(r.Rate) || r.Rate <= 0 { + return nil, errors.New("invalid exchange rate") + } + if err := (cost.Money{Amount: 0, Currency: r.From}).Validate(); err != nil { + return nil, err + } + if err := (cost.Money{Amount: 0, Currency: r.To}).Validate(); err != nil { + return nil, err + } + if r.Source.ArtifactIdentity == "" || !validCapture(r.Source.CapturedAt) || r.Source.Type == "" { + return nil, errors.New("exchange rate requires source identity and timestamp") + } + key := r.From + "/" + r.To + if _, ok := table[key]; ok { + return nil, fmt.Errorf("duplicate exchange rate %s", key) + } + table[key] = r.Rate + } + return func(m cost.Money) (float64, error) { + rate := 1.0 + if m.Currency != target { + var ok bool + rate, ok = table[m.Currency+"/"+target] + if !ok { + return 0, fmt.Errorf("missing exchange rate %s/%s", m.Currency, target) + } + } + n := m.Amount * rate + if !finite(n) { + return 0, errors.New("currency conversion overflow") + } + return n, nil + }, nil +} + +var timeUnits = map[string]float64{"second": 1, "minute": 60, "hour": 3600, "day": 86400, "week": 604800} + +func factors(d cost.CostDriver, convert func(cost.Money) (float64, error)) (float64, float64, float64, error) { + q := d.Quantity.Value + per := d.UnitPrice.Per.Value + if scale := timeUnits[d.Quantity.Unit]; scale > 0 { + q *= scale + per *= scale + } + p, err := convert(d.UnitPrice.Amount) + if err != nil { + return 0, 0, 0, err + } + p /= per + dist := 1.0 + if d.Distribution != nil { + switch d.Distribution.Type { + case cost.DistributionNormal: + dist = *d.Distribution.Mean + case cost.DistributionUniform: + dist = (*d.Distribution.Min + *d.Distribution.Max) / 2 + case cost.DistributionExponential: + dist = 1 / *d.Distribution.Rate + } + if d.Distribution.Floor != nil { + dist = math.Max(dist, *d.Distribution.Floor) + } + } + if !finite(q) || !finite(p) || !finite(dist) { + return 0, 0, 0, errors.New("non-finite normalized factors") + } + return q, p, dist, nil +} +func shapley(f, a [3]float64) (out [3]float64) { + // Solve the 3-factor multilinear equation by averaging all six update orders. + for i := 0; i < 3; i++ { + for mask := 0; mask < 8; mask++ { + if mask&(1< Rounding && math.Abs(c.Amount.Amount) < .01*math.Abs(total) { + score = math.Min(score, .7) + } + if (f.Kind == cost.DriverCadence || f.Kind == cost.DriverConcurrency || f.Kind == cost.DriverUptime) && len(c.Receipts) == 0 { + c.MissingEvidence = append(c.MissingEvidence, "exact delivery receipt") + c.Attribution = "unknown" + score = math.Min(score, .35) + } + if f.Distribution != nil || a.Distribution != nil { + c.MissingEvidence = append(c.MissingEvidence, "distribution expectation is modeled, not observed causation") + score = math.Min(score, .7) + } + c.ConfidenceScore = score + switch { + case score >= .85: + c.Confidence = cost.ConfidenceHigh + case score >= .55: + c.Confidence = cost.ConfidenceMedium + default: + c.Confidence = cost.ConfidenceLow + } +} + +// MarshalJSONResult returns deterministic, indented JSON with a final newline. +func MarshalJSONResult(r Result) ([]byte, error) { + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} diff --git a/pkg/variance/v1/variance_test.go b/pkg/variance/v1/variance_test.go new file mode 100644 index 0000000..fd06da7 --- /dev/null +++ b/pkg/variance/v1/variance_test.go @@ -0,0 +1,207 @@ +package v1 + +import ( + "encoding/json" + "math" + "os" + "testing" + + cost "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" +) + +const stamp = "2026-08-01T00:00:00Z" + +var window = cost.TimeWindow{Start: "2026-07-01T00:00:00Z", End: stamp} + +func evidence(source cost.SourceType, kind cost.EvidenceKind, measurement cost.MeasurementKind, confidence cost.Confidence) cost.Evidence { + return cost.Evidence{Kind: kind, Measurement: measurement, Source: cost.SourceReference{Type: source, ArtifactIdentity: "fixture://variance", CapturedAt: stamp}, Confidence: confidence, ConfidenceRationale: "deterministic test evidence"} +} +func observation(id string, q, p, total float64) cost.CostObservation { + e := evidence(cost.SourceUserSupplied, cost.EvidenceUserSupplied, cost.MeasurementDeclared, cost.ConfidenceHigh) + return cost.CostObservation{SchemaVersion: cost.SchemaVersion, ID: id, DriverIDs: []string{"driver"}, Window: window, Quantity: cost.Quantity{Value: q, Unit: "command"}, UnitPrice: cost.UnitPrice{Amount: cost.Money{Amount: p, Currency: "USD"}, Per: cost.Quantity{Value: 1, Unit: "command"}}, TotalCost: cost.Money{Amount: total, Currency: "USD"}, Dimensions: cost.Dimensions{Workload: "worker"}, Evidence: cost.ClaimEvidence{Quantity: e, UnitPrice: e, TotalCost: e}} +} +func driver(kind cost.DriverKind, q, p float64) cost.CostDriver { + o := observation("base", q, p, q*p) + d := cost.CostDriver{SchemaVersion: cost.SchemaVersion, ID: "driver", Name: "test driver", Kind: kind, Quantity: o.Quantity, UnitPrice: o.UnitPrice, Window: window, Dimensions: o.Dimensions, Evidence: cost.DriverEvidence{Quantity: o.Evidence.Quantity, UnitPrice: o.Evidence.UnitPrice}} + if kind == cost.DriverVariable || kind == cost.DriverCadence { + d.Per = &cost.Quantity{Value: 1, Unit: "second"} + } + return d +} +func input(kind cost.DriverKind, fq, aq, fp, ap, ft, at float64) Input { + a := observation("actual", aq, ap, at) + return Input{Forecast: observation("forecast", fq, fp, ft), Actual: &a, Drivers: []DriverPair{{Forecast: driver(kind, fq, fp), Actual: driver(kind, aq, ap)}}} +} +func near(t *testing.T, got, want float64) { + t.Helper() + if math.Abs(got-want) > Rounding { + t.Fatalf("got %g, want %g", got, want) + } +} +func TestVarianceGoldenScenarios(t *testing.T) { + var cases []struct { + Name string `json:"name"` + Kind cost.DriverKind `json:"kind"` + FQ float64 `json:"forecast_quantity"` + AQ float64 `json:"actual_quantity"` + FP float64 `json:"forecast_price"` + AP float64 `json:"actual_price"` + Dominant string `json:"dominant"` + Variance float64 `json:"variance"` + } + data, err := os.ReadFile("../../../test/fixtures/cost-variance/v1/scenarios.json") + if err != nil { + t.Fatal(err) + } + if err = json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + in := input(tc.Kind, tc.FQ, tc.AQ, tc.FP, tc.AP, tc.FQ*tc.FP, tc.AQ*tc.AP) + r, err := Compare(in) + if err != nil { + t.Fatal(err) + } + near(t, r.AbsoluteVariance.Amount, tc.Variance) + near(t, r.Residual.Amount, 0) + c := r.Contributions[0] + near(t, c.Amount.Amount, tc.Variance) + switch tc.Dominant { + case "quantity": + if math.Abs(c.Quantity) <= math.Abs(c.UnitPrice) { + t.Fatal("quantity not dominant") + } + case "unit_price": + if math.Abs(c.UnitPrice) <= math.Abs(c.Quantity) { + t.Fatal("price not dominant") + } + } + if tc.Kind == cost.DriverCadence || tc.Kind == cost.DriverConcurrency { + if c.Attribution != "unknown" || len(c.MissingEvidence) == 0 { + t.Fatal("missing receipt must lower confidence") + } + } + }) + } +} +func TestVarianceMixedResidualAndJSON(t *testing.T) { + in := input(cost.DriverVariable, 10, 20, 2, 3, 21, 65) // model: 20 -> 60; ledger: 21 -> 65 + r, err := Compare(in) + if err != nil { + t.Fatal(err) + } + near(t, r.AbsoluteVariance.Amount, 44) + near(t, r.Contributions[0].Quantity, 25) + near(t, r.Contributions[0].UnitPrice, 15) + near(t, r.Residual.Amount, 4) + b, err := MarshalJSONResult(r) + if err != nil { + t.Fatal(err) + } + var decoded Result + if err = json.Unmarshal(b, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Window != window || decoded.Forecast.Currency != "USD" || decoded.Contributions[0].Sources[0].ArtifactIdentity == "" || decoded.Contributions[0].ConfidenceScore == 0 || decoded.Residual.Amount != 4 { + t.Fatalf("incomplete JSON: %s", b) + } +} +func TestVarianceMultipleDriversAndDistribution(t *testing.T) { + in := input(cost.DriverVariable, 10, 12, 2, 2, 40, 59) + second := DriverPair{Forecast: driver(cost.DriverFixed, 10, 2), Actual: driver(cost.DriverFixed, 10, 3)} + second.Forecast.ID = "second" + second.Actual.ID = "second" + in.Forecast.DriverIDs = append(in.Forecast.DriverIDs, "second") + in.Actual.DriverIDs = append(in.Actual.DriverIDs, "second") + in.Drivers = append(in.Drivers, second) + r, err := Compare(in) + if err != nil { + t.Fatal(err) + } + near(t, r.AbsoluteVariance.Amount, 19) + near(t, r.Residual.Amount, 5) + near(t, r.Contributions[0].Amount.Amount, 10) + near(t, r.Contributions[1].Amount.Amount, 4) + if len(r.MissingEvidence) == 0 { + t.Fatal("residual must name missing evidence") + } + // A changed stochastic expectation contributes separately, without a causal claim. + in = input(cost.DriverVariable, 10, 10, 2, 2, 40, 60) + m1, m2 := 2.0, 3.0 + s := 1.0 + in.Drivers[0].Forecast.Distribution = &cost.Distribution{Type: cost.DistributionNormal, Mean: &m1, StdDev: &s} + in.Drivers[0].Actual.Distribution = &cost.Distribution{Type: cost.DistributionNormal, Mean: &m2, StdDev: &s} + r, err = Compare(in) + if err != nil { + t.Fatal(err) + } + near(t, r.Contributions[0].Distribution, 20) + near(t, r.Residual.Amount, 0) +} + +func TestVarianceUnavailableAndZeroDenominator(t *testing.T) { + in := input(cost.DriverFixed, 0, 1, 2, 2, 0, 2) + r, err := Compare(in) + if err != nil { + t.Fatal(err) + } + if r.PercentageVariance != nil { + t.Fatal("zero denominator must have null percentage") + } + in.Actual = nil + in.Drivers = nil + in.UnavailableReason = "invoice not delivered" + r, err = Compare(in) + if err != nil { + t.Fatal(err) + } + if r.Actual != nil || r.AbsoluteVariance != nil || r.Residual != nil || r.Status != "unavailable" { + t.Fatal("unavailable cost cannot become zero") + } + in.UnavailableReason = "" + if _, err = Compare(in); err == nil { + t.Fatal("missing reason accepted") + } +} +func TestVarianceConversionsAndReceipts(t *testing.T) { + in := input(cost.DriverCadence, 1, 60, 2, 2, 2, 120) + in.Drivers[0].Forecast.Quantity.Unit = "hour" + in.Drivers[0].Forecast.UnitPrice.Per.Unit = "hour" + in.Drivers[0].Actual.Quantity.Unit = "minute" + in.Drivers[0].Actual.UnitPrice.Per.Unit = "minute" + // One hour at $2/hour, sixty minutes at $2/minute. + in.Receipts = []Receipt{{DriverID: "driver", Kind: "deployment", ForecastObservationID: "forecast", ActualObservationID: "actual", Source: cost.SourceReference{Type: cost.SourceRuntimeLedger, ArtifactIdentity: "deploy://exact", CapturedAt: stamp}}} + r, err := Compare(in) + if err != nil { + t.Fatal(err) + } + near(t, r.Residual.Amount, 0) + if r.Contributions[0].Attribution != "modeled" || r.Contributions[0].Confidence != cost.ConfidenceHigh { + t.Fatal("exact receipt should permit high modeled confidence") + } + in.Receipts[0].ActualObservationID = "wrong" + if _, err = Compare(in); err == nil { + t.Fatal("inexact receipt accepted") + } +} +func TestVarianceCurrencyAndValidation(t *testing.T) { + in := input(cost.DriverVariable, 10, 20, 2, 2, 20, 40) + in.Actual.TotalCost.Currency = "EUR" + in.Actual.UnitPrice.Amount.Currency = "EUR" + in.Drivers[0].Actual.UnitPrice.Amount.Currency = "EUR" + if _, err := Compare(in); err == nil { + t.Fatal("missing exchange rate accepted") + } + in.ExchangeRates = []ExchangeRate{{From: "EUR", To: "USD", Rate: 2, Source: cost.SourceReference{Type: cost.SourceUserSupplied, ArtifactIdentity: "fx://snapshot", CapturedAt: stamp}}} + r, err := Compare(in) + if err != nil { + t.Fatal(err) + } + near(t, r.AbsoluteVariance.Amount, 60) + near(t, r.Residual.Amount, 0) + in.Actual.Window.End = "2026-09-01T00:00:00Z" + if _, err = Compare(in); err == nil { + t.Fatal("mismatched window accepted") + } +} diff --git a/schemas/cost-variance/v1/result.schema.json b/schemas/cost-variance/v1/result.schema.json new file mode 100644 index 0000000..464c5ba --- /dev/null +++ b/schemas/cost-variance/v1/result.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://profitctl.dev/schemas/cost-variance/v1/result.schema.json", + "title": "ProfitCtl cost variance result v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "status", "window", "forecast_observation_id", "forecast", "actual", "absolute_variance", "percentage_variance", "contributions", "residual", "missing_evidence", "rounding_tolerance", "sources"], + "properties": { + "schema_version": {"const": "profitctl.cost-variance/v1"}, + "status": {"enum": ["available", "unavailable"]}, + "window": {"$ref": "#/$defs/window"}, + "forecast_observation_id": {"type": "string", "minLength": 1}, + "actual_observation_id": {"type": "string"}, + "forecast": {"$ref": "#/$defs/money"}, + "actual": {"oneOf": [{"$ref": "#/$defs/money"}, {"type": "null"}]}, + "absolute_variance": {"oneOf": [{"$ref": "#/$defs/money"}, {"type": "null"}]}, + "percentage_variance": {"type": ["number", "null"]}, + "residual": {"oneOf": [{"$ref": "#/$defs/money"}, {"type": "null"}]}, + "unavailable_reason": {"type": "string", "minLength": 1}, + "missing_evidence": {"type": "array", "items": {"type": "string"}}, + "rounding_tolerance": {"type": "number", "const": 0.000001}, + "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}}, + "contributions": {"type": "array", "items": {"$ref": "#/$defs/contribution"}} + }, + "$defs": { + "money": {"type": "object", "additionalProperties": false, "required": ["amount", "currency"], "properties": {"amount": {"type": "number"}, "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}}}, + "window": {"type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}}}, + "source": {"type": "object", "required": ["type", "captured_at"], "properties": {"type": {"type": "string"}, "artifact_identity": {"type": "string"}, "url": {"type": "string"}, "captured_at": {"type": "string"}}}, + "contribution": {"type": "object", "additionalProperties": false, "required": ["driver_id", "kind", "quantity", "unit_price", "distribution", "amount", "confidence", "confidence_score", "attribution", "missing_evidence", "sources", "receipts"], "properties": { + "driver_id": {"type": "string"}, "kind": {"type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}, "distribution": {"type": "number"}, "amount": {"$ref": "#/$defs/money"}, "confidence": {"enum": ["low", "medium", "high"]}, "confidence_score": {"type": "number", "minimum": 0, "maximum": 1}, "attribution": {"enum": ["modeled", "unknown"]}, "missing_evidence": {"type": "array", "items": {"type": "string"}}, "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}}, "receipts": {"type": "array", "items": {"type": "object", "required": ["driver_id", "kind", "forecast_observation_id", "actual_observation_id", "source"], "properties": {"driver_id": {"type": "string"}, "kind": {"enum": ["commit", "pull_request", "release", "deployment"]}, "forecast_observation_id": {"type": "string"}, "actual_observation_id": {"type": "string"}, "source": {"$ref": "#/$defs/source"}}}} + }} + } +} diff --git a/test/fixtures/cost-variance/v1/scenarios.json b/test/fixtures/cost-variance/v1/scenarios.json new file mode 100644 index 0000000..132449c --- /dev/null +++ b/test/fixtures/cost-variance/v1/scenarios.json @@ -0,0 +1,7 @@ +[ + {"name":"fixed-price","kind":"fixed","forecast_quantity":10,"actual_quantity":10,"forecast_price":2,"actual_price":2,"dominant":"none","variance":0}, + {"name":"usage-volume","kind":"variable","forecast_quantity":10,"actual_quantity":20,"forecast_price":2,"actual_price":2,"dominant":"quantity","variance":20}, + {"name":"cadence","kind":"cadence","forecast_quantity":10,"actual_quantity":20,"forecast_price":2,"actual_price":2,"dominant":"quantity","variance":20}, + {"name":"replica","kind":"concurrency","forecast_quantity":10,"actual_quantity":20,"forecast_price":2,"actual_price":2,"dominant":"quantity","variance":20}, + {"name":"price-change","kind":"variable","forecast_quantity":10,"actual_quantity":10,"forecast_price":2,"actual_price":3,"dominant":"unit_price","variance":10} +] From f601f06264a6524b0c1b1f83e5b091ddd4f48e05 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 19:20:33 -0400 Subject: [PATCH 02/17] Explain cost variance and root cause from the CLI: variance-engine --- pkg/variance/v1/diff.go | 231 +++++++++++++++++++++++++++ pkg/variance/v1/diff_test.go | 139 ++++++++++++++++ schemas/cost-variance/v1/schema.json | 22 +++ 3 files changed, 392 insertions(+) create mode 100644 pkg/variance/v1/diff.go create mode 100644 pkg/variance/v1/diff_test.go create mode 100644 schemas/cost-variance/v1/schema.json diff --git a/pkg/variance/v1/diff.go b/pkg/variance/v1/diff.go new file mode 100644 index 0000000..7f21279 --- /dev/null +++ b/pkg/variance/v1/diff.go @@ -0,0 +1,231 @@ +package v1 + +import ( + "errors" + "fmt" + "math" + "reflect" + "sort" + + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" +) + +// DriverContribution is an accounting decomposition, not a causal delivery claim. +type DriverContribution struct { + Driver string `json:"driver"` + Amount costv1.Money `json:"amount"` + Confidence string `json:"confidence"` + ConfidenceScore float64 `json:"confidence_score"` + Attribution string `json:"attribution"` + Evidence []costv1.Evidence `json:"evidence"` + MissingEvidence []string `json:"missing_evidence"` + RemedialActions []string `json:"remedial_actions"` +} + +type DiffPeriod struct { + Window costv1.TimeWindow `json:"window"` + Forecast costv1.CostObservation `json:"forecast"` + Actual costv1.CostObservation `json:"actual"` + VarianceAbsolute costv1.Money `json:"variance_absolute"` + VariancePercent float64 `json:"variance_percent"` + DriverContributions []DriverContribution `json:"driver_contributions"` + Residual costv1.Money `json:"residual"` + Confidence string `json:"confidence"` +} + +// DiffReport retains the original observations so that explain never needs to +// reconstruct provenance from totals. Percent uses the sum of forecasts. +type DiffReport struct { + SchemaVersion string `json:"schema_version"` + Forecast costv1.Money `json:"forecast"` + Actual costv1.Money `json:"actual"` + VarianceAbsolute costv1.Money `json:"variance_absolute"` + VariancePercent float64 `json:"variance_percent"` + DriverContributions []DriverContribution `json:"driver_contributions"` + Residual costv1.Money `json:"residual"` + Confidence string `json:"confidence"` + Periods []DiffPeriod `json:"periods"` +} + +type ExplainReport struct { + SchemaVersion string `json:"schema_version"` + Forecast costv1.Money `json:"forecast"` + Actual costv1.Money `json:"actual"` + VarianceAbsolute costv1.Money `json:"variance_absolute"` + VariancePercent float64 `json:"variance_percent"` + DriverContributions []DriverContribution `json:"driver_contributions"` + Residual costv1.Money `json:"residual"` + Confidence string `json:"confidence"` + Periods []DiffPeriod `json:"periods"` +} + +// Diff pairs observations by window and full workload dimensions, rather than +// pairing by list position. An unmatched or ambiguous period is an error. +func Diff(forecast, actual []costv1.CostObservation) (*DiffReport, error) { + if len(forecast) == 0 || len(forecast) != len(actual) { + return nil, errors.New("forecast and actual require the same nonzero number of observations") + } + result := &DiffReport{SchemaVersion: SchemaVersion, DriverContributions: []DriverContribution{}, Periods: []DiffPeriod{}} + used := make([]bool, len(actual)) + for i, f := range forecast { + if err := f.Validate(); err != nil { + return nil, fmt.Errorf("forecast[%d]: %w", i, err) + } + if f.TotalCost.Amount == 0 { + return nil, fmt.Errorf("forecast[%d]: zero total cost denominator", i) + } + match := -1 + for j, a := range actual { + if err := a.Validate(); err != nil { + return nil, fmt.Errorf("actual[%d]: %w", j, err) + } + if f.Window == a.Window && f.Dimensions == a.Dimensions { + if match >= 0 { + return nil, fmt.Errorf("ambiguous actual for forecast[%d]", i) + } + match = j + } + } + if match < 0 || used[match] { + return nil, fmt.Errorf("unmatched forecast[%d] window or dimensions", i) + } + used[match] = true + a := actual[match] + if f.TotalCost.Currency != a.TotalCost.Currency || f.Quantity.Unit != a.Quantity.Unit || f.UnitPrice.Per.Unit != a.UnitPrice.Per.Unit { + return nil, fmt.Errorf("forecast[%d]: currency or quantity units differ", i) + } + delta := a.TotalCost.Amount - f.TotalCost.Amount + fq, aq := f.Quantity.Value, a.Quantity.Value + fp, ap := f.UnitPrice.Amount.Amount/f.UnitPrice.Per.Value, a.UnitPrice.Amount.Amount/a.UnitPrice.Per.Value + quantity := (aq - fq) * (fp + ap) / 2 + price := (ap - fp) * (fq + aq) / 2 + residual := delta - quantity - price + if !finite(delta) || !finite(quantity) || !finite(price) || !finite(residual) { + return nil, fmt.Errorf("forecast[%d]: variance overflow", i) + } + currency := f.TotalCost.Currency + contributions := []DriverContribution{ + makeContribution("usage_volume", quantity, currency, f.Evidence.Quantity, a.Evidence.Quantity), + makeContribution("price_change", price, currency, f.Evidence.UnitPrice, a.Evidence.UnitPrice), + } + confidence := "high" + if math.Abs(residual) > Rounding { + confidence = "unknown" + } + for _, c := range contributions { + if c.Confidence != "high" { + confidence = "unknown" + } + } + period := DiffPeriod{Window: f.Window, Forecast: f, Actual: a, VarianceAbsolute: costv1.Money{Amount: delta, Currency: currency}, VariancePercent: delta / f.TotalCost.Amount * 100, DriverContributions: contributions, Residual: costv1.Money{Amount: residual, Currency: currency}, Confidence: confidence} + if !finite(period.VariancePercent) { + return nil, fmt.Errorf("forecast[%d]: percentage overflow", i) + } + result.Periods = append(result.Periods, period) + result.DriverContributions = append(result.DriverContributions, contributions...) + result.Forecast.Amount += f.TotalCost.Amount + result.Actual.Amount += a.TotalCost.Amount + result.Residual.Amount += residual + result.Forecast.Currency = currency + result.Actual.Currency = currency + result.Residual.Currency = currency + if result.Confidence == "" { + result.Confidence = confidence + } else if confidence != "high" { + result.Confidence = "unknown" + } + if i > 0 && forecast[0].TotalCost.Currency != currency { + return nil, errors.New("period currencies differ") + } + } + result.VarianceAbsolute = costv1.Money{Amount: result.Actual.Amount - result.Forecast.Amount, Currency: result.Forecast.Currency} + result.VariancePercent = result.VarianceAbsolute.Amount / result.Forecast.Amount * 100 + if !finite(result.VariancePercent) { + return nil, errors.New("aggregate percentage overflow") + } + sort.Slice(result.Periods, func(i, j int) bool { return result.Periods[i].Window.Start < result.Periods[j].Window.Start }) + sort.SliceStable(result.DriverContributions, func(i, j int) bool { + x, y := result.DriverContributions[i], result.DriverContributions[j] + if math.Abs(x.Amount.Amount) != math.Abs(y.Amount.Amount) { + return math.Abs(x.Amount.Amount) > math.Abs(y.Amount.Amount) + } + if x.Driver != y.Driver { + return x.Driver < y.Driver + } + return x.Evidence[0].Source.CapturedAt < y.Evidence[0].Source.CapturedAt + }) + return result, nil +} + +func makeContribution(name string, amount float64, currency string, f, a costv1.Evidence) DriverContribution { + c := DriverContribution{Driver: name, Amount: costv1.Money{Amount: amount, Currency: currency}, Evidence: []costv1.Evidence{f, a}, Confidence: "low", ConfidenceScore: .35, Attribution: "unknown", MissingEvidence: []string{"exact delivery receipt"}, RemedialActions: []string{}} + // Evidence of a measured change supports the accounting difference, but not + // the unsupported assertion that a particular deployment caused it. + if (a.Measurement == costv1.MeasurementMeasured || a.Kind == costv1.EvidenceBilled) && f.Confidence != costv1.ConfidenceLow && a.Confidence != costv1.ConfidenceLow { + c.Confidence = "medium" + c.ConfidenceScore = .7 + } + return c +} + +// Explain checks arithmetic even for untrusted JSON input; it never silently +// converts an unexplained residual into a named causal driver. +func Explain(report DiffReport) (*ExplainReport, error) { + if report.SchemaVersion != SchemaVersion || len(report.Periods) == 0 { + return nil, errors.New("invalid diff report") + } + recomputedForecast := 0.0 + recomputedActual := 0.0 + recomputedResidual := 0.0 + for i, p := range report.Periods { + if err := p.Forecast.Validate(); err != nil { + return nil, fmt.Errorf("period[%d] forecast: %w", i, err) + } + if err := p.Actual.Validate(); err != nil { + return nil, fmt.Errorf("period[%d] actual: %w", i, err) + } + if p.Forecast.Window != p.Actual.Window || p.Window != p.Forecast.Window || p.Forecast.Dimensions != p.Actual.Dimensions || p.Forecast.TotalCost.Currency != report.Forecast.Currency || p.Actual.TotalCost.Currency != report.Forecast.Currency { + return nil, fmt.Errorf("period[%d]: unmatched observation", i) + } + sum := p.Residual.Amount + for _, c := range p.DriverContributions { + sum += c.Amount.Amount + } + if !finite(sum) || math.Abs(sum-(p.Actual.TotalCost.Amount-p.Forecast.TotalCost.Amount)) > Rounding { + return nil, fmt.Errorf("period[%d]: contributions do not reconcile", i) + } + recomputedForecast += p.Forecast.TotalCost.Amount + recomputedActual += p.Actual.TotalCost.Amount + recomputedResidual += p.Residual.Amount + } + if math.Abs(recomputedForecast-report.Forecast.Amount) > Rounding || math.Abs(recomputedActual-report.Actual.Amount) > Rounding || math.Abs(recomputedResidual-report.Residual.Amount) > Rounding || math.Abs(report.VarianceAbsolute.Amount-(recomputedActual-recomputedForecast)) > Rounding || report.Forecast.Amount == 0 || math.Abs(report.VariancePercent-100*(recomputedActual-recomputedForecast)/recomputedForecast) > Rounding { + return nil, errors.New("report totals do not reconcile") + } + forecasts := make([]costv1.CostObservation, len(report.Periods)) + actuals := make([]costv1.CostObservation, len(report.Periods)) + for i, p := range report.Periods { + forecasts[i], actuals[i] = p.Forecast, p.Actual + } + canonical, err := Diff(forecasts, actuals) + if err != nil || !reflect.DeepEqual(*canonical, report) { + return nil, errors.New("diff report differs from validated observations") + } + contributions := append([]DriverContribution{}, report.DriverContributions...) + for i := range contributions { + c := &contributions[i] + if c.Attribution != "unknown" || len(c.MissingEvidence) == 0 || len(c.Evidence) != 2 || c.ConfidenceScore > .7 { + return nil, errors.New("unsupported causal attribution") + } + switch c.Driver { + case "usage_volume": + c.RemedialActions = []string{"Inspect workload demand and polling cadence before changing configuration"} + case "price_change": + c.RemedialActions = []string{"Verify the billed unit price against the maintained price catalog"} + default: + c.RemedialActions = []string{"Investigate the unexplained driver"} + } + } + // Explanations cannot claim high confidence when residual or receipts are missing. + confidence := "unknown" + return &ExplainReport{SchemaVersion: SchemaVersion, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.VarianceAbsolute, VariancePercent: report.VariancePercent, DriverContributions: contributions, Residual: report.Residual, Confidence: confidence, Periods: report.Periods}, nil +} diff --git a/pkg/variance/v1/diff_test.go b/pkg/variance/v1/diff_test.go new file mode 100644 index 0000000..1721fcf --- /dev/null +++ b/pkg/variance/v1/diff_test.go @@ -0,0 +1,139 @@ +package v1 + +import ( + "encoding/json" + "math" + "os" + "testing" + + cost "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" +) + +func TestCostDiffVarianceGolden(t *testing.T) { + var cases []struct { + Name string `json:"name"` + FQ float64 `json:"forecast_quantity"` + AQ float64 `json:"actual_quantity"` + FP float64 `json:"forecast_price"` + AP float64 `json:"actual_price"` + Dominant string `json:"dominant"` + Variance float64 `json:"variance"` + } + b, err := os.ReadFile("../../../test/fixtures/cost-variance/v1/scenarios.json") + if err != nil { + t.Fatal(err) + } + if err = json.Unmarshal(b, &cases); err != nil { + t.Fatal(err) + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + f := observation("forecast", tc.FQ, tc.FP, tc.FQ*tc.FP) + a := observation("actual", tc.AQ, tc.AP, tc.AQ*tc.AP) + report, err := Diff([]cost.CostObservation{f}, []cost.CostObservation{a}) + if err != nil { + t.Fatal(err) + } + if math.Abs(report.VarianceAbsolute.Amount-tc.Variance) > Rounding || math.Abs(report.Residual.Amount) > Rounding { + t.Fatalf("nonreconciling report: %+v", report) + } + switch tc.Dominant { + case "quantity": + if math.Abs(report.DriverContributions[0].Amount.Amount) <= math.Abs(report.DriverContributions[1].Amount.Amount) && report.DriverContributions[0].Driver == "usage_volume" { + t.Fatal("wrong dominant") + } + case "unit_price": + if report.DriverContributions[0].Driver != "price_change" { + t.Fatal("wrong dominant") + } + } + explained, err := Explain(*report) + if err != nil { + t.Fatal(err) + } + if explained.Confidence != "unknown" || len(explained.DriverContributions[0].MissingEvidence) == 0 || len(explained.DriverContributions[0].RemedialActions) == 0 { + t.Fatal("unsupported causal claim") + } + if _, err := json.Marshal(explained); err != nil { + t.Fatal(err) + } + }) + } +} +func TestCostDiffVarianceUpstashAndPeriods(t *testing.T) { + b, err := os.ReadFile("../../../test/fixtures/cost_contract/v1/upstash_idle_polling.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Observation cost.CostObservation `json:"observation"` + } + if err := json.Unmarshal(b, &fixture); err != nil { + t.Fatal(err) + } + f := fixture.Observation + a := f + a.ID = "actual" + a.Quantity.Value *= 1.5 + a.TotalCost.Amount *= 1.5 + r, err := Diff([]cost.CostObservation{f}, []cost.CostObservation{a}) + if err != nil { + t.Fatal(err) + } + if math.Abs(r.Residual.Amount) > Rounding || r.DriverContributions[0].Attribution != "unknown" || r.DriverContributions[0].ConfidenceScore > .35 { + t.Fatalf("unsupported claim: %+v", r) + } + // Independent windows are matched by identity, not by array position. + f2, a2 := f, a + f2.ID = "next_forecast" + a2.ID = "next_actual" + f2.Window.Start = "2026-08-01T00:00:00Z" + f2.Window.End = "2026-09-01T00:00:00Z" + a2.Window = f2.Window + f2.Evidence.Quantity.Source.CapturedAt = "2026-09-01" + f2.Evidence.UnitPrice.Source.CapturedAt = "2026-09-01" + f2.Evidence.TotalCost.Source.CapturedAt = "2026-09-01" + a2.Evidence = f2.Evidence + r, err = Diff([]cost.CostObservation{f2, f}, []cost.CostObservation{a, a2}) + if err != nil { + t.Fatal(err) + } + if len(r.Periods) != 2 || math.Abs(r.VarianceAbsolute.Amount-1.386) > Rounding { + t.Fatalf("bad multi-period result: %+v", r) + } + if _, err = Explain(*r); err != nil { + t.Fatal(err) + } +} + +func TestCostDiffVarianceResidualAndValidation(t *testing.T) { + f := observation("f", 10, 2, 21) + a := observation("a", 20, 3, 65) + r, err := Diff([]cost.CostObservation{f}, []cost.CostObservation{a}) + if err != nil { + t.Fatal(err) + } + if math.Abs(r.Residual.Amount-4) > Rounding { + t.Fatalf("residual %v", r.Residual) + } + r.Periods[0].Residual.Amount = 0 + if _, err = Explain(*r); err == nil { + t.Fatal("tampered report accepted") + } + r.Periods[0].Residual.Amount = 4 + r.DriverContributions[0].Amount.Amount++ + if _, err = Explain(*r); err == nil { + t.Fatal("tampered driver accepted") + } + a.Window.End = "2026-09-01T00:00:00Z" + if _, err = Diff([]cost.CostObservation{f}, []cost.CostObservation{a}); err == nil { + t.Fatal("unmatched window accepted") + } + if _, err = Diff([]cost.CostObservation{f}, nil); err == nil { + t.Fatal("unavailable actual accepted as zero") + } + f.TotalCost.Amount = 0 + if _, err = Diff([]cost.CostObservation{f}, []cost.CostObservation{observation("a", 20, 3, 60)}); err == nil { + t.Fatal("zero denominator accepted") + } +} diff --git a/schemas/cost-variance/v1/schema.json b/schemas/cost-variance/v1/schema.json new file mode 100644 index 0000000..f43a14e --- /dev/null +++ b/schemas/cost-variance/v1/schema.json @@ -0,0 +1,22 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://profitctl.dev/schemas/cost-variance/v1/schema.json", + "title":"Cost variance diff or explanation", + "type":"object", + "additionalProperties":false, + "required":["schema_version","forecast","actual","variance_absolute","variance_percent","driver_contributions","residual","confidence","periods"], + "properties":{ + "schema_version":{"const":"profitctl.cost-variance/v1"}, + "forecast":{"$ref":"#/$defs/money"},"actual":{"$ref":"#/$defs/money"}, + "variance_absolute":{"$ref":"#/$defs/money"},"variance_percent":{"type":"number"}, + "residual":{"$ref":"#/$defs/money"},"confidence":{"enum":["high","medium","low","unknown"]}, + "driver_contributions":{"type":"array","items":{"$ref":"#/$defs/driver"}}, + "periods":{"type":"array","minItems":1,"items":{"type":"object","required":["window","forecast","actual","variance_absolute","variance_percent","driver_contributions","residual","confidence"],"properties":{"window":{"$ref":"#/$defs/window"},"forecast":{"$ref":"#/$defs/observation"},"actual":{"$ref":"#/$defs/observation"},"variance_absolute":{"$ref":"#/$defs/money"},"variance_percent":{"type":"number"},"driver_contributions":{"type":"array","items":{"$ref":"#/$defs/driver"}},"residual":{"$ref":"#/$defs/money"},"confidence":{"enum":["high","medium","low","unknown"]}},"additionalProperties":false}} + }, + "$defs":{ + "money":{"type":"object","required":["amount","currency"],"properties":{"amount":{"type":"number"},"currency":{"type":"string","pattern":"^[A-Z]{3}$"}},"additionalProperties":false}, + "window":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}}, + "observation":{"type":"object","required":["schema_version","id","driver_ids","window","quantity","unit_price","total_cost","dimensions","evidence"],"properties":{"schema_version":{"const":"profitctl.cost/v1"},"id":{"type":"string"},"driver_ids":{"type":"array"},"window":{"$ref":"#/$defs/window"},"quantity":{"type":"object"},"unit_price":{"type":"object"},"total_cost":{"$ref":"#/$defs/money"},"dimensions":{"type":"object"},"evidence":{"type":"object"}}}, + "driver":{"type":"object","additionalProperties":false,"required":["driver","amount","confidence","confidence_score","attribution","evidence","missing_evidence","remedial_actions"],"properties":{"driver":{"type":"string"},"amount":{"$ref":"#/$defs/money"},"confidence":{"enum":["high","medium","low","unknown"]},"confidence_score":{"type":"number","minimum":0,"maximum":1},"attribution":{"const":"unknown"},"evidence":{"type":"array","minItems":2,"items":{"type":"object","required":["kind","measurement","source","confidence","confidence_rationale"],"properties":{"source":{"type":"object","required":["type","captured_at"],"properties":{"type":{"type":"string"},"captured_at":{"type":"string"},"artifact_identity":{"type":"string"}}}}}},"missing_evidence":{"type":"array","items":{"type":"string"}},"remedial_actions":{"type":"array","items":{"type":"string"}}}} + } +} From fb903cf59b2a12a3a1cc713a6e32a4e3b4dd9b4f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 19:03:01 -0400 Subject: [PATCH 03/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_diff.go | 140 ++++++++++++++++++++++++++++++++++++ cmd/cost_explain.go | 47 ++++++++++++ cmd/cost_variance_test.go | 145 ++++++++++++++++++++++++++++++++++++++ docs/cost-variance.md | 14 ++++ 4 files changed, 346 insertions(+) create mode 100644 cmd/cost_diff.go create mode 100644 cmd/cost_explain.go create mode 100644 cmd/cost_variance_test.go create mode 100644 docs/cost-variance.md diff --git a/cmd/cost_diff.go b/cmd/cost_diff.go new file mode 100644 index 0000000..cac0258 --- /dev/null +++ b/cmd/cost_diff.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" +) + +var costDiffCmd = newCostDiffCommand() + +func init() { rootCmd.AddCommand(costDiffCmd) } + +func newCostDiffCommand() *cobra.Command { + command := &cobra.Command{ + Use: "cost_diff", Short: "Compare forecast and actual cost observations as JSON", + Args: cobra.NoArgs, + } + command.Flags().String("forecast", "", "Forecast CostObservation JSON file") + command.Flags().String("actual", "", "Actual CostObservation JSON file") + command.Flags().StringP("output", "o", "", "Optional JSON output file (also writes to stdout)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + forecastPath, _ := cmd.Flags().GetString("forecast") + actualPath, _ := cmd.Flags().GetString("actual") + output, _ := cmd.Flags().GetString("output") + if strings.TrimSpace(forecastPath) == "" || strings.TrimSpace(actualPath) == "" { + return wrapExit(2, errors.New("--forecast and --actual are required")) + } + if err := rejectVarianceOutputAlias(output, forecastPath, actualPath); err != nil { + return wrapExit(2, err) + } + forecast, err := readCostObservations(forecastPath) + if err != nil { + return wrapExit(2, fmt.Errorf("forecast: %w", err)) + } + for i, observation := range forecast { + if observation.TotalCost.Amount == 0 { + return wrapExit(2, fmt.Errorf("forecast observation[%d]: zero total cost denominator for percent variance", i)) + } + } + actual, err := readCostObservations(actualPath) + if err != nil { + return wrapExit(2, fmt.Errorf("actual: %w", err)) + } + report, err := variancev1.Diff(forecast, actual) + if err != nil { + return wrapExit(2, fmt.Errorf("diff: %w", err)) + } + return emitVarianceJSON(cmd, output, report) + } + return command +} + +// A single observation is convenient for one-period comparisons; an array +// allows the same command to compare multiple windows without dropping data. +func readCostObservations(path string) ([]costv1.CostObservation, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var observations []costv1.CostObservation + if len(bytes.TrimSpace(data)) == 0 { + return nil, errors.New("empty JSON document") + } + if bytes.TrimSpace(data)[0] == '[' { + if err := strictVarianceJSON(data, &observations); err != nil { + return nil, err + } + } else { + var observation costv1.CostObservation + if err := strictVarianceJSON(data, &observation); err != nil { + return nil, err + } + observations = []costv1.CostObservation{observation} + } + if len(observations) == 0 { + return nil, errors.New("at least one observation is required") + } + for i, observation := range observations { + if err := observation.Validate(); err != nil { + return nil, fmt.Errorf("observation[%d]: %w", i, err) + } + } + return observations, nil +} + +func strictVarianceJSON(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err + } + return nil +} + +func rejectVarianceOutputAlias(output string, inputs ...string) error { + if strings.TrimSpace(output) == "" { + return nil + } + for _, input := range inputs { + alias, err := pathsAlias(output, input) + if err != nil { + return fmt.Errorf("compare output paths: %w", err) + } + if alias { + return fmt.Errorf("--output must not reference input %q", input) + } + } + return nil +} + +func emitVarianceJSON(cmd *cobra.Command, output string, report any) error { + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { + return wrapExit(3, fmt.Errorf("encode variance report: %w", err)) + } + payload = append(payload, '\n') + if strings.TrimSpace(output) != "" { + if err := os.WriteFile(output, payload, 0600); err != nil { + return wrapExit(3, fmt.Errorf("write output: %w", err)) + } + } + if _, err := cmd.OutOrStdout().Write(payload); err != nil { + return wrapExit(3, fmt.Errorf("write stdout: %w", err)) + } + return nil +} diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go new file mode 100644 index 0000000..080db0c --- /dev/null +++ b/cmd/cost_explain.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" +) + +var costExplainCmd = newCostExplainCommand() + +func init() { rootCmd.AddCommand(costExplainCmd) } + +func newCostExplainCommand() *cobra.Command { + command := &cobra.Command{ + Use: "cost_explain", Short: "Rank evidenced drivers of a cost difference as JSON", + Args: cobra.NoArgs, + } + command.Flags().StringP("input", "i", "", "Diff report JSON file") + command.Flags().StringP("output", "o", "", "Optional JSON output file (also writes to stdout)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + input, _ := cmd.Flags().GetString("input") + output, _ := cmd.Flags().GetString("output") + if strings.TrimSpace(input) == "" { + return wrapExit(2, fmt.Errorf("--input is required")) + } + if err := rejectVarianceOutputAlias(output, input); err != nil { + return wrapExit(2, err) + } + data, err := os.ReadFile(input) + if err != nil { + return wrapExit(2, fmt.Errorf("read diff report: %w", err)) + } + var diff variancev1.DiffReport + if err := strictVarianceJSON(data, &diff); err != nil { + return wrapExit(2, fmt.Errorf("decode diff report: %w", err)) + } + report, err := variancev1.Explain(diff) + if err != nil { + return wrapExit(2, fmt.Errorf("explain: %w", err)) + } + return emitVarianceJSON(cmd, output, report) + } + return command +} diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go new file mode 100644 index 0000000..fda7786 --- /dev/null +++ b/cmd/cost_variance_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func varianceObservation(t *testing.T, id string, quantity float64) costv1.CostObservation { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "test", "fixtures", "cost_contract", "v1", "upstash_idle_polling.json")) + require.NoError(t, err) + var fixture struct { + Observation costv1.CostObservation `json:"observation"` + } + require.NoError(t, json.Unmarshal(data, &fixture)) + observation := fixture.Observation + observation.ID = id + observation.Quantity.Value = quantity + observation.TotalCost.Amount = quantity * observation.UnitPrice.Amount.Amount + require.NoError(t, observation.Validate()) + return observation +} + +func varianceWriteJSON(t *testing.T, file string, value any) { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + require.NoError(t, os.WriteFile(file, data, 0600)) +} + +func varianceExecute(t *testing.T, command *cobra.Command, args ...string) ([]byte, error) { + t.Helper() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&bytes.Buffer{}) + command.SetArgs(args) + err := command.Execute() + return output.Bytes(), err +} + +func TestCostDiffAndCostExplainJSON(t *testing.T) { + directory := t.TempDir() + forecastPath := filepath.Join(directory, "forecast.json") + actualPath := filepath.Join(directory, "actual.json") + diffPath := filepath.Join(directory, "diff.json") + explainPath := filepath.Join(directory, "explain.json") + varianceWriteJSON(t, forecastPath, varianceObservation(t, "forecast", 1000000)) + varianceWriteJSON(t, actualPath, varianceObservation(t, "actual", 1500000)) + + output, err := varianceExecute(t, newCostDiffCommand(), "--forecast", forecastPath, "--actual", actualPath, "--output", diffPath) + require.NoError(t, err) + disk, err := os.ReadFile(diffPath) + require.NoError(t, err) + require.Equal(t, output, disk) + var diff map[string]any + require.NoError(t, json.Unmarshal(output, &diff)) + require.NotEmpty(t, diff["schema_version"]) + + output, err = varianceExecute(t, newCostExplainCommand(), "--input", diffPath, "--output", explainPath) + require.NoError(t, err) + disk, err = os.ReadFile(explainPath) + require.NoError(t, err) + require.Equal(t, output, disk) + var explanation map[string]any + require.NoError(t, json.Unmarshal(output, &explanation)) + require.NotEmpty(t, explanation["schema_version"]) +} + +func TestCostDiffInputErrors(t *testing.T) { + directory := t.TempDir() + forecastPath := filepath.Join(directory, "forecast.json") + actualPath := filepath.Join(directory, "actual.json") + valid := varianceObservation(t, "forecast", 1000000) + varianceWriteJSON(t, forecastPath, valid) + varianceWriteJSON(t, actualPath, varianceObservation(t, "actual", 1500000)) + + cases := []struct { + name string + mutate func() + want string + }{ + {"missing flag", func() {}, "--forecast and --actual"}, + {"malformed JSON", func() { require.NoError(t, os.WriteFile(actualPath, []byte("{"), 0600)) }, "actual:"}, + {"missing evidence", func() { bad := valid; bad.Evidence.Quantity = costv1.Evidence{}; varianceWriteJSON(t, actualPath, bad) }, "evidence.quantity"}, + {"zero forecast", func() { + zero := valid + zero.Quantity.Value = 0 + zero.TotalCost.Amount = 0 + varianceWriteJSON(t, forecastPath, zero) + }, "zero"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + varianceWriteJSON(t, forecastPath, valid) + varianceWriteJSON(t, actualPath, varianceObservation(t, "actual", 1500000)) + test.mutate() + args := []string{"--forecast", forecastPath, "--actual", actualPath} + if test.name == "missing flag" { + args = nil + } + _, err := varianceExecute(t, newCostDiffCommand(), args...) + require.Equal(t, 2, ExitCode(err)) + require.ErrorContains(t, err, test.want) + }) + } +} + +func TestCostExplainInvalidAndOutputAlias(t *testing.T) { + directory := t.TempDir() + input := filepath.Join(directory, "diff.json") + require.NoError(t, os.WriteFile(input, []byte("{}{}"), 0600)) + _, err := varianceExecute(t, newCostExplainCommand(), "--input", input) + require.Equal(t, 2, ExitCode(err)) + require.ErrorContains(t, err, "decode diff report") + _, err = varianceExecute(t, newCostExplainCommand(), "--input", input, "--output", input) + require.Equal(t, 2, ExitCode(err)) + require.ErrorContains(t, err, "--output must not reference input") + _, err = varianceExecute(t, newCostExplainCommand()) + require.Equal(t, 2, ExitCode(err)) + require.ErrorContains(t, err, "--input is required") + _, err = varianceExecute(t, newCostDiffCommand(), "--forecast", input, "--actual", input, "--output", input) + require.Equal(t, 2, ExitCode(err)) + require.ErrorContains(t, err, "--output must not reference input") +} + +func TestCostVarianceCommandFlags(t *testing.T) { + for _, name := range []string{"forecast", "actual", "output"} { + require.NotNil(t, costDiffCmd.Flags().Lookup(name)) + } + for _, name := range []string{"input", "output"} { + require.NotNil(t, costExplainCmd.Flags().Lookup(name)) + } + for _, name := range []string{"cost_diff", "cost_explain"} { + command, _, err := rootCmd.Find([]string{name}) + require.NoError(t, err) + require.Equal(t, name, command.Name()) + } +} diff --git a/docs/cost-variance.md b/docs/cost-variance.md new file mode 100644 index 0000000..ef4f741 --- /dev/null +++ b/docs/cost-variance.md @@ -0,0 +1,14 @@ +# Cost variance CLI + +The commands operate on local JSON only; they make no provider calls and do not modify a ledger. + +```sh +profitctl cost_diff --forecast forecast.json --actual actual.json --output diff.json +profitctl cost_explain --input diff.json --output explanation.json +``` + +Both commands print their JSON report to stdout even when `--output` (`-o`) writes a copy to disk. `cost_diff` accepts either one `profitctl.cost/v1` `CostObservation` per file or an array of observations for multiple periods. Use matching windows, currency, and workload dimensions. Every observation must carry quantity, unit-price, and total-cost evidence with a source identity. The forecast is the percentage denominator: a zero forecast cannot yield a meaningful percent change and is rejected, not silently replaced with zero. `cost_explain` reads the exact report produced by `cost_diff`; never substitute an invoice or a free-form summary. + +The versioned output contract is the [cost variance JSON schema](../schemas/cost-variance/v1/schema.json). Reports retain source references (including artifact identity and captured time), units, windows, and any exact delivery receipts. A high-confidence attribution requires direct supporting evidence; medium confidence indicates partial support; low confidence indicates weak or synthetic support. An unknown attribution means evidence does not establish a cause, especially when an exact commit, pull-request, release, or deployment receipt is missing. A source label alone does not prove causation. A nonzero residual is the part of actual-minus-forecast cost not explained by named drivers; inspect it instead of distributing it across unsupported causes. Never treat unavailable actual cost as zero. + +Invalid inputs (malformed JSON, missing evidence, unmatched observations, or zero percentage denominator) exit with code **2** and a diagnostic on stderr. File or stdout write failures exit with code **3**. No partial JSON is printed on invalid input; an `--output` path that aliases any input is rejected before reading or writing it. From fe9aa528fecc42e4c7a111fe318a7047ba01f645 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 19:20:49 -0400 Subject: [PATCH 04/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_diff.go | 197 +++++++++++---------------- cmd/cost_explain.go | 117 +++++++++++----- cmd/cost_variance_test.go | 277 +++++++++++++++++++++----------------- docs/cost-variance.md | 22 +-- 4 files changed, 330 insertions(+), 283 deletions(-) diff --git a/cmd/cost_diff.go b/cmd/cost_diff.go index cac0258..4affe57 100644 --- a/cmd/cost_diff.go +++ b/cmd/cost_diff.go @@ -1,140 +1,103 @@ package cmd import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" - costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" - variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" - "github.com/spf13/cobra" + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" ) var costDiffCmd = newCostDiffCommand() func init() { rootCmd.AddCommand(costDiffCmd) } +// Diff compares two observations without inventing driver evidence. Use Compare +// with a variancev1.Input when driver pairs, exchange rates or receipts exist. +func Diff(ctx context.Context, forecast, actual *costv1.CostObservation) (*variancev1.Result, error) { + if err := ctx.Err(); err != nil { return nil, err } + if forecast == nil { return nil, errors.New("forecast observation is required") } + in := variancev1.Input{Forecast: *forecast, Actual: actual} + if actual == nil { in.UnavailableReason = "actual observation not supplied" } + result, err := variancev1.Compare(in) + if err != nil { return nil, err } + return &result, nil +} + func newCostDiffCommand() *cobra.Command { - command := &cobra.Command{ - Use: "cost_diff", Short: "Compare forecast and actual cost observations as JSON", - Args: cobra.NoArgs, - } - command.Flags().String("forecast", "", "Forecast CostObservation JSON file") - command.Flags().String("actual", "", "Actual CostObservation JSON file") - command.Flags().StringP("output", "o", "", "Optional JSON output file (also writes to stdout)") - command.RunE = func(cmd *cobra.Command, _ []string) error { - forecastPath, _ := cmd.Flags().GetString("forecast") - actualPath, _ := cmd.Flags().GetString("actual") - output, _ := cmd.Flags().GetString("output") - if strings.TrimSpace(forecastPath) == "" || strings.TrimSpace(actualPath) == "" { - return wrapExit(2, errors.New("--forecast and --actual are required")) - } - if err := rejectVarianceOutputAlias(output, forecastPath, actualPath); err != nil { - return wrapExit(2, err) - } - forecast, err := readCostObservations(forecastPath) - if err != nil { - return wrapExit(2, fmt.Errorf("forecast: %w", err)) - } - for i, observation := range forecast { - if observation.TotalCost.Amount == 0 { - return wrapExit(2, fmt.Errorf("forecast observation[%d]: zero total cost denominator for percent variance", i)) - } - } - actual, err := readCostObservations(actualPath) - if err != nil { - return wrapExit(2, fmt.Errorf("actual: %w", err)) - } - report, err := variancev1.Diff(forecast, actual) - if err != nil { - return wrapExit(2, fmt.Errorf("diff: %w", err)) - } - return emitVarianceJSON(cmd, output, report) - } - return command + command := &cobra.Command{Use: "diff", Short: "Compare local forecast and actual costs", Args: cobra.NoArgs} + command.Flags().StringP("input", "i", "", "Variance input JSON (observations, driver pairs, exchange rates and receipts)") + command.Flags().String("forecast", "", "Forecast CostObservation JSON (without driver pairs)") + command.Flags().String("actual", "", "Actual CostObservation JSON (without driver pairs)") + command.Flags().StringP("output", "o", "", "Optional JSON output file (also prints to stdout)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + input, _ := cmd.Flags().GetString("input") + forecastPath, _ := cmd.Flags().GetString("forecast") + actualPath, _ := cmd.Flags().GetString("actual") + output, _ := cmd.Flags().GetString("output") + if (input == "" && (forecastPath == "" || actualPath == "")) || (input != "" && (forecastPath != "" || actualPath != "")) { + return wrapExit(2, errors.New("provide --input or both --forecast and --actual")) + } + if err := rejectVarianceOutputAlias(output, input, forecastPath, actualPath); err != nil { return wrapExit(2, err) } + var in variancev1.Input + if input != "" { + if err := readVarianceJSON(input, &in); err != nil { return wrapExit(2, fmt.Errorf("input: %w", err)) } + } else { + if err := readVarianceJSON(forecastPath, &in.Forecast); err != nil { return wrapExit(2, fmt.Errorf("forecast: %w", err)) } + var actual costv1.CostObservation + if err := readVarianceJSON(actualPath, &actual); err != nil { return wrapExit(2, fmt.Errorf("actual: %w", err)) } + in.Actual = &actual + } + if err := cmd.Context().Err(); err != nil { return wrapExit(2, err) } + report, err := variancev1.Compare(in) + if err != nil { return wrapExit(2, fmt.Errorf("diff: %w", err)) } + return emitVarianceJSON(cmd, output, report) + } + return command } -// A single observation is convenient for one-period comparisons; an array -// allows the same command to compare multiple windows without dropping data. -func readCostObservations(path string) ([]costv1.CostObservation, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var observations []costv1.CostObservation - if len(bytes.TrimSpace(data)) == 0 { - return nil, errors.New("empty JSON document") - } - if bytes.TrimSpace(data)[0] == '[' { - if err := strictVarianceJSON(data, &observations); err != nil { - return nil, err - } - } else { - var observation costv1.CostObservation - if err := strictVarianceJSON(data, &observation); err != nil { - return nil, err - } - observations = []costv1.CostObservation{observation} - } - if len(observations) == 0 { - return nil, errors.New("at least one observation is required") - } - for i, observation := range observations { - if err := observation.Validate(); err != nil { - return nil, fmt.Errorf("observation[%d]: %w", i, err) - } - } - return observations, nil +func readVarianceJSON(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { return err } + return strictVarianceJSON(data, target) } func strictVarianceJSON(data []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { - if err == nil { - return errors.New("multiple JSON values are not allowed") - } - return err - } - return nil + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { return err } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err == nil { return errors.New("multiple JSON values are not allowed") } + return err + } + return nil } func rejectVarianceOutputAlias(output string, inputs ...string) error { - if strings.TrimSpace(output) == "" { - return nil - } - for _, input := range inputs { - alias, err := pathsAlias(output, input) - if err != nil { - return fmt.Errorf("compare output paths: %w", err) - } - if alias { - return fmt.Errorf("--output must not reference input %q", input) - } - } - return nil + if strings.TrimSpace(output) == "" { return nil } + for _, input := range inputs { + if input == "" { continue } + alias, err := pathsAlias(output, input) + if err != nil { return fmt.Errorf("compare output paths: %w", err) } + if alias { return fmt.Errorf("--output must not reference input %q", input) } + } + return nil } func emitVarianceJSON(cmd *cobra.Command, output string, report any) error { - payload, err := json.MarshalIndent(report, "", " ") - if err != nil { - return wrapExit(3, fmt.Errorf("encode variance report: %w", err)) - } - payload = append(payload, '\n') - if strings.TrimSpace(output) != "" { - if err := os.WriteFile(output, payload, 0600); err != nil { - return wrapExit(3, fmt.Errorf("write output: %w", err)) - } - } - if _, err := cmd.OutOrStdout().Write(payload); err != nil { - return wrapExit(3, fmt.Errorf("write stdout: %w", err)) - } - return nil + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { return wrapExit(3, fmt.Errorf("encode variance report: %w", err)) } + payload = append(payload, '\n') + if strings.TrimSpace(output) != "" { + if err := os.WriteFile(output, payload, 0600); err != nil { return wrapExit(3, fmt.Errorf("write output: %w", err)) } + } + if _, err := cmd.OutOrStdout().Write(payload); err != nil { return wrapExit(3, fmt.Errorf("write stdout: %w", err)) } + return nil } diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 080db0c..82ee432 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -1,47 +1,94 @@ package cmd import ( - "fmt" - "os" - "strings" + "context" + "errors" + "fmt" + "math" + "strings" - variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" - "github.com/spf13/cobra" + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" ) +// DriverContribution preserves the modeled decomposition and adds non-causal +// suggestions. Receipts and source references remain on the embedded contribution. +type DriverContribution struct { + variancev1.Contribution + RemedialActions []string `json:"remedial_actions"` +} + +type ExplainReport struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Window costv1.TimeWindow `json:"window"` + ForecastObservationID string `json:"forecast_observation_id"` + ActualObservationID string `json:"actual_observation_id,omitempty"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + Confidence costv1.Confidence `json:"confidence"` + ConfidenceScore float64 `json:"confidence_score"` + Forecast costv1.Money `json:"forecast"` + Actual *costv1.Money `json:"actual"` + VarianceAbsolute *costv1.Money `json:"variance_absolute"` + VariancePercent *float64 `json:"variance_percent"` + DriverContributions []DriverContribution `json:"driver_contributions"` + Residual *costv1.Money `json:"residual"` + MissingEvidence []string `json:"missing_evidence"` + Sources []costv1.SourceReference `json:"sources"` +} + +// Explain ranks existing modeled evidence; it never asserts delivery causation. +func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, error) { + if err := ctx.Err(); err != nil { return nil, err } + if report == nil || report.SchemaVersion != variancev1.SchemaVersion || (report.Status != "available" && report.Status != "unavailable") { return nil, errors.New("valid cost variance result is required") } + if report.Status == "available" && (report.Actual == nil || report.AbsoluteVariance == nil || report.Residual == nil) { return nil, errors.New("available result lacks actual, variance or residual") } + if report.Status == "available" && (math.IsNaN(report.Residual.Amount) || math.IsInf(report.Residual.Amount, 0) || math.IsNaN(report.AbsoluteVariance.Amount) || math.IsInf(report.AbsoluteVariance.Amount, 0)) { return nil, errors.New("non-finite variance or residual") } + if report.Status == "unavailable" && (report.Actual != nil || report.AbsoluteVariance != nil || report.Residual != nil || len(report.Contributions) != 0) { return nil, errors.New("unavailable result cannot contain measured variance") } + out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} + if out.MissingEvidence == nil { out.MissingEvidence = []string{} } + if report.Status == "available" { + out.ConfidenceScore = 1 + sum := report.Residual.Amount + for _, c := range report.Contributions { + if c.Amount.Currency != report.Forecast.Currency || math.IsNaN(c.Amount.Amount) || math.IsInf(c.Amount.Amount, 0) || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { return nil, errors.New("invalid driver contribution") } + sum += c.Amount.Amount + out.ConfidenceScore = math.Min(out.ConfidenceScore, c.ConfidenceScore) + action := "Review workload measurements and cost evidence before adjusting this driver" + switch c.Kind { + case costv1.DriverCadence: action = "Review polling interval and measure command volume before changing cadence" + case costv1.DriverConcurrency: action = "Review replica count and utilization before resizing" + case costv1.DriverVariable: action = "Review request volume and per-unit price before tuning usage" + case costv1.DriverUptime: action = "Review measured uptime and scheduling before changing runtime" + case costv1.DriverFixed: action = "Review contracted unit price before renegotiating" + } + out.DriverContributions = append(out.DriverContributions, DriverContribution{Contribution: c, RemedialActions: []string{action}}) + } + if math.Abs(sum-report.AbsoluteVariance.Amount) > variancev1.Rounding { return nil, errors.New("driver contributions and residual do not reconcile") } + if math.Abs(report.Residual.Amount) > variancev1.Rounding || len(report.Contributions) == 0 { out.ConfidenceScore = math.Min(out.ConfidenceScore, .35) } + switch { case out.ConfidenceScore >= .85: out.Confidence = costv1.ConfidenceHigh; case out.ConfidenceScore >= .55: out.Confidence = costv1.ConfidenceMedium; default: out.Confidence = costv1.ConfidenceLow } + } + return out, nil +} + var costExplainCmd = newCostExplainCommand() func init() { rootCmd.AddCommand(costExplainCmd) } func newCostExplainCommand() *cobra.Command { - command := &cobra.Command{ - Use: "cost_explain", Short: "Rank evidenced drivers of a cost difference as JSON", - Args: cobra.NoArgs, - } - command.Flags().StringP("input", "i", "", "Diff report JSON file") - command.Flags().StringP("output", "o", "", "Optional JSON output file (also writes to stdout)") - command.RunE = func(cmd *cobra.Command, _ []string) error { - input, _ := cmd.Flags().GetString("input") - output, _ := cmd.Flags().GetString("output") - if strings.TrimSpace(input) == "" { - return wrapExit(2, fmt.Errorf("--input is required")) - } - if err := rejectVarianceOutputAlias(output, input); err != nil { - return wrapExit(2, err) - } - data, err := os.ReadFile(input) - if err != nil { - return wrapExit(2, fmt.Errorf("read diff report: %w", err)) - } - var diff variancev1.DiffReport - if err := strictVarianceJSON(data, &diff); err != nil { - return wrapExit(2, fmt.Errorf("decode diff report: %w", err)) - } - report, err := variancev1.Explain(diff) - if err != nil { - return wrapExit(2, fmt.Errorf("explain: %w", err)) - } - return emitVarianceJSON(cmd, output, report) - } - return command + command := &cobra.Command{Use: "explain", Short: "Rank evidenced cost variance drivers", Args: cobra.NoArgs} + command.Flags().StringP("input", "i", "", "Diff result JSON file") + command.Flags().StringP("output", "o", "", "Optional JSON output file (also prints to stdout)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + input, _ := cmd.Flags().GetString("input") + output, _ := cmd.Flags().GetString("output") + if strings.TrimSpace(input) == "" { return wrapExit(2, errors.New("--input is required")) } + if err := rejectVarianceOutputAlias(output, input); err != nil { return wrapExit(2, err) } + var diff variancev1.Result + if err := readVarianceJSON(input, &diff); err != nil { return wrapExit(2, fmt.Errorf("decode diff result: %w", err)) } + report, err := Explain(cmd.Context(), &diff) + if err != nil { return wrapExit(2, fmt.Errorf("explain: %w", err)) } + return emitVarianceJSON(cmd, output, report) + } + return command } diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index fda7786..90267d1 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -1,145 +1,176 @@ package cmd import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "testing" + "bytes" + "context" + "encoding/json" + "math" + "os" + "path/filepath" + "testing" - costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" ) -func varianceObservation(t *testing.T, id string, quantity float64) costv1.CostObservation { - t.Helper() - data, err := os.ReadFile(filepath.Join("..", "test", "fixtures", "cost_contract", "v1", "upstash_idle_polling.json")) - require.NoError(t, err) - var fixture struct { - Observation costv1.CostObservation `json:"observation"` - } - require.NoError(t, json.Unmarshal(data, &fixture)) - observation := fixture.Observation - observation.ID = id - observation.Quantity.Value = quantity - observation.TotalCost.Amount = quantity * observation.UnitPrice.Amount.Amount - require.NoError(t, observation.Validate()) - return observation +func varianceFixture(t *testing.T, id string, quantity, price, total float64, kind costv1.DriverKind) (costv1.CostObservation, costv1.CostDriver) { + t.Helper() + source := costv1.SourceReference{Type: costv1.SourceSyntheticFixture, ArtifactIdentity: "fixture://variance", CapturedAt: "2026-08-01T00:00:00Z"} + e := costv1.Evidence{Kind: costv1.EvidenceUserSupplied, Measurement: costv1.MeasurementDeclared, Source: source, Confidence: costv1.ConfidenceHigh, ConfidenceRationale: "deterministic fixture"} + window := costv1.TimeWindow{Start:"2026-07-01T00:00:00Z", End:"2026-08-01T00:00:00Z"} + q := costv1.Quantity{Value:quantity, Unit:"command"} + p := costv1.UnitPrice{Amount:costv1.Money{Amount:price, Currency:"USD"}, Per:costv1.Quantity{Value:1, Unit:"command"}} + o := costv1.CostObservation{SchemaVersion:costv1.SchemaVersion, ID:id, DriverIDs:[]string{"driver"}, Window:window, Quantity:q, UnitPrice:p, TotalCost:costv1.Money{Amount:total, Currency:"USD"}, Dimensions:costv1.Dimensions{Workload:"worker"}, Evidence:costv1.ClaimEvidence{Quantity:e, UnitPrice:e, TotalCost:e}} + d := costv1.CostDriver{SchemaVersion:costv1.SchemaVersion, ID:"driver", Name:"fixture driver", Kind:kind, Quantity:q, UnitPrice:p, Window:window, Dimensions:o.Dimensions, Evidence:costv1.DriverEvidence{Quantity:e, UnitPrice:e}} + if kind == costv1.DriverVariable || kind == costv1.DriverCadence { d.Per = &costv1.Quantity{Value:1, Unit:"second"} } + require.NoError(t, o.Validate()) + require.NoError(t, d.Validate()) + return o, d } func varianceWriteJSON(t *testing.T, file string, value any) { - t.Helper() - data, err := json.Marshal(value) - require.NoError(t, err) - require.NoError(t, os.WriteFile(file, data, 0600)) + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + require.NoError(t, os.WriteFile(file, data, 0600)) } - func varianceExecute(t *testing.T, command *cobra.Command, args ...string) ([]byte, error) { - t.Helper() - var output bytes.Buffer - command.SetOut(&output) - command.SetErr(&bytes.Buffer{}) - command.SetArgs(args) - err := command.Execute() - return output.Bytes(), err + t.Helper() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&bytes.Buffer{}) + command.SetArgs(args) + err := command.Execute() + return output.Bytes(), err } -func TestCostDiffAndCostExplainJSON(t *testing.T) { - directory := t.TempDir() - forecastPath := filepath.Join(directory, "forecast.json") - actualPath := filepath.Join(directory, "actual.json") - diffPath := filepath.Join(directory, "diff.json") - explainPath := filepath.Join(directory, "explain.json") - varianceWriteJSON(t, forecastPath, varianceObservation(t, "forecast", 1000000)) - varianceWriteJSON(t, actualPath, varianceObservation(t, "actual", 1500000)) - - output, err := varianceExecute(t, newCostDiffCommand(), "--forecast", forecastPath, "--actual", actualPath, "--output", diffPath) - require.NoError(t, err) - disk, err := os.ReadFile(diffPath) - require.NoError(t, err) - require.Equal(t, output, disk) - var diff map[string]any - require.NoError(t, json.Unmarshal(output, &diff)) - require.NotEmpty(t, diff["schema_version"]) +func TestCostDiffExplainJSON(t *testing.T) { + for _, kind := range []costv1.DriverKind{costv1.DriverFixed, costv1.DriverVariable, costv1.DriverCadence, costv1.DriverConcurrency} { + t.Run(string(kind),func(t *testing.T){ + f, fd := varianceFixture(t,"forecast",10,2,20,kind) + a, ad := varianceFixture(t,"actual",20,2,40,kind) + dir := t.TempDir() + input, diffPath := filepath.Join(dir,"input.json"),filepath.Join(dir,"diff.json") + varianceWriteJSON(t,input,variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) + payload, err := varianceExecute(t,newCostDiffCommand(),"--input",input,"--output",diffPath) + require.NoError(t,err) + disk, err := os.ReadFile(diffPath); require.NoError(t,err); require.Equal(t,payload,disk) + var diff variancev1.Result + require.NoError(t,json.Unmarshal(payload,&diff)) + require.Equal(t,variancev1.SchemaVersion,diff.SchemaVersion) + require.Equal(t,f.Window,diff.Window) + require.Equal(t,20.0,diff.AbsoluteVariance.Amount) + require.InDelta(t,0,diff.Residual.Amount,variancev1.Rounding) + require.Equal(t,"USD",diff.Contributions[0].Amount.Currency) + require.NotEmpty(t,diff.Contributions[0].Sources[0].ArtifactIdentity) + require.NotEmpty(t,diff.Contributions[0].Sources[0].CapturedAt) + explain,err := varianceExecute(t,newCostExplainCommand(),"--input",diffPath) + require.NoError(t,err) + var report ExplainReport + require.NoError(t,json.Unmarshal(explain,&report)) + require.Len(t,report.DriverContributions,1) + require.NotEmpty(t,report.DriverContributions[0].RemedialActions) + require.Greater(t,report.DriverContributions[0].ConfidenceScore,0.0) + if kind == costv1.DriverCadence || kind == costv1.DriverConcurrency { + require.Equal(t,"unknown",report.DriverContributions[0].Attribution) + require.Contains(t,report.DriverContributions[0].MissingEvidence,"exact delivery receipt") + } + }) + } +} - output, err = varianceExecute(t, newCostExplainCommand(), "--input", diffPath, "--output", explainPath) - require.NoError(t, err) - disk, err = os.ReadFile(explainPath) - require.NoError(t, err) - require.Equal(t, output, disk) - var explanation map[string]any - require.NoError(t, json.Unmarshal(output, &explanation)) - require.NotEmpty(t, explanation["schema_version"]) +func TestCostVariancePriceAndReceipts(t *testing.T) { + f,fd := varianceFixture(t,"forecast",10,2,20,costv1.DriverVariable) + a,ad := varianceFixture(t,"actual",10,3,30,costv1.DriverVariable) + result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) + require.NoError(t,err) + require.Greater(t,math.Abs(result.Contributions[0].UnitPrice),math.Abs(result.Contributions[0].Quantity)) + require.InDelta(t,0,result.Residual.Amount,variancev1.Rounding) + f,fd = varianceFixture(t,"forecast",10,2,20,costv1.DriverCadence) + a,ad = varianceFixture(t,"actual",20,2,40,costv1.DriverCadence) + receipt := variancev1.Receipt{DriverID:"driver",Kind:"deployment",ForecastObservationID:f.ID,ActualObservationID:a.ID,Source:costv1.SourceReference{Type:costv1.SourceRuntimeLedger,ArtifactIdentity:"deployment://exact",CapturedAt:"2026-08-01T00:00:00Z"}} + result,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}},Receipts:[]variancev1.Receipt{receipt}}) + require.NoError(t,err) + require.Equal(t,"modeled",result.Contributions[0].Attribution) + require.Len(t,result.Contributions[0].Receipts,1) + receipt.ActualObservationID = "other" + _,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}},Receipts:[]variancev1.Receipt{receipt}}) + require.Error(t,err) } -func TestCostDiffInputErrors(t *testing.T) { - directory := t.TempDir() - forecastPath := filepath.Join(directory, "forecast.json") - actualPath := filepath.Join(directory, "actual.json") - valid := varianceObservation(t, "forecast", 1000000) - varianceWriteJSON(t, forecastPath, valid) - varianceWriteJSON(t, actualPath, varianceObservation(t, "actual", 1500000)) +func TestCostVarianceResidualZeroAndUnavailable(t *testing.T) { + f,fd := varianceFixture(t,"forecast",10,2,21,costv1.DriverVariable) + a,ad := varianceFixture(t,"actual",20,3,65,costv1.DriverVariable) + result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) + require.NoError(t,err) + require.InDelta(t,4,result.Residual.Amount,variancev1.Rounding) + report,err := Explain(context.Background(),&result) + require.NoError(t,err) + require.Equal(t,4.0,report.Residual.Amount) + require.NotEmpty(t,report.MissingEvidence) + result.AbsoluteVariance.Amount++ + _,err = Explain(context.Background(),&result) + require.ErrorContains(t,err,"reconcile") + f.TotalCost.Amount = 0 + result,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) + require.NoError(t,err) + require.Nil(t,result.PercentageVariance) + unavailable,err := Diff(context.Background(),&f,nil) + require.NoError(t,err) + require.Equal(t,"unavailable",unavailable.Status) + require.Nil(t,unavailable.Actual) + require.Nil(t,unavailable.AbsoluteVariance) + _,err = Explain(context.Background(),unavailable) + require.NoError(t,err) +} - cases := []struct { - name string - mutate func() - want string - }{ - {"missing flag", func() {}, "--forecast and --actual"}, - {"malformed JSON", func() { require.NoError(t, os.WriteFile(actualPath, []byte("{"), 0600)) }, "actual:"}, - {"missing evidence", func() { bad := valid; bad.Evidence.Quantity = costv1.Evidence{}; varianceWriteJSON(t, actualPath, bad) }, "evidence.quantity"}, - {"zero forecast", func() { - zero := valid - zero.Quantity.Value = 0 - zero.TotalCost.Amount = 0 - varianceWriteJSON(t, forecastPath, zero) - }, "zero"}, - } - for _, test := range cases { - t.Run(test.name, func(t *testing.T) { - varianceWriteJSON(t, forecastPath, valid) - varianceWriteJSON(t, actualPath, varianceObservation(t, "actual", 1500000)) - test.mutate() - args := []string{"--forecast", forecastPath, "--actual", actualPath} - if test.name == "missing flag" { - args = nil - } - _, err := varianceExecute(t, newCostDiffCommand(), args...) - require.Equal(t, 2, ExitCode(err)) - require.ErrorContains(t, err, test.want) - }) - } +func TestCostDiffInvalidInputAndAliases(t *testing.T) { + f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) + a,_ := varianceFixture(t,"actual",10,3,30,costv1.DriverFixed) + dir:=t.TempDir(); forecast:=filepath.Join(dir,"forecast.json"); actual:=filepath.Join(dir,"actual.json") + varianceWriteJSON(t,forecast,f); varianceWriteJSON(t,actual,a) + _,err:=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual) + require.NoError(t,err) + _,err=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual,"--output",forecast) + require.Equal(t,2,ExitCode(err)) + _,err=varianceExecute(t,newCostDiffCommand()) + require.Equal(t,2,ExitCode(err)) + require.NoError(t,os.WriteFile(actual,[]byte("{}{}"),0600)) + _,err=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual) + require.Equal(t,2,ExitCode(err)) + a.Evidence.Quantity = costv1.Evidence{} + varianceWriteJSON(t,actual,a) + _,err=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual) + require.Equal(t,2,ExitCode(err)) + _,err=varianceExecute(t,newCostExplainCommand(),"--input",actual) + require.Equal(t,2,ExitCode(err)) + _,err=varianceExecute(t,newCostExplainCommand(),"--input",actual,"--output",actual) + require.Equal(t,2,ExitCode(err)) } -func TestCostExplainInvalidAndOutputAlias(t *testing.T) { - directory := t.TempDir() - input := filepath.Join(directory, "diff.json") - require.NoError(t, os.WriteFile(input, []byte("{}{}"), 0600)) - _, err := varianceExecute(t, newCostExplainCommand(), "--input", input) - require.Equal(t, 2, ExitCode(err)) - require.ErrorContains(t, err, "decode diff report") - _, err = varianceExecute(t, newCostExplainCommand(), "--input", input, "--output", input) - require.Equal(t, 2, ExitCode(err)) - require.ErrorContains(t, err, "--output must not reference input") - _, err = varianceExecute(t, newCostExplainCommand()) - require.Equal(t, 2, ExitCode(err)) - require.ErrorContains(t, err, "--input is required") - _, err = varianceExecute(t, newCostDiffCommand(), "--forecast", input, "--actual", input, "--output", input) - require.Equal(t, 2, ExitCode(err)) - require.ErrorContains(t, err, "--output must not reference input") +func TestCostDiffUnavailableAndZeroJSON(t *testing.T) { + f,_ := varianceFixture(t,"forecast",0,2,0,costv1.DriverFixed) + a,_ := varianceFixture(t,"actual",1,2,2,costv1.DriverFixed) + dir:=t.TempDir(); input:=filepath.Join(dir,"input.json") + varianceWriteJSON(t,input,variancev1.Input{Forecast:f,Actual:&a}) + payload,err:=varianceExecute(t,newCostDiffCommand(),"--input",input) + require.NoError(t,err) + var result variancev1.Result + require.NoError(t,json.Unmarshal(payload,&result)) + require.Nil(t,result.PercentageVariance) + varianceWriteJSON(t,input,variancev1.Input{Forecast:f,UnavailableReason:"invoice not delivered"}) + payload,err=varianceExecute(t,newCostDiffCommand(),"--input",input) + require.NoError(t,err) + require.NoError(t,json.Unmarshal(payload,&result)) + require.Equal(t,"unavailable",result.Status) + require.Nil(t,result.Actual) + require.Nil(t,result.Residual) } -func TestCostVarianceCommandFlags(t *testing.T) { - for _, name := range []string{"forecast", "actual", "output"} { - require.NotNil(t, costDiffCmd.Flags().Lookup(name)) - } - for _, name := range []string{"input", "output"} { - require.NotNil(t, costExplainCmd.Flags().Lookup(name)) - } - for _, name := range []string{"cost_diff", "cost_explain"} { - command, _, err := rootCmd.Find([]string{name}) - require.NoError(t, err) - require.Equal(t, name, command.Name()) - } +func TestCostExplainInvalidReconciliation(t *testing.T) { + result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} + _,err:=Explain(context.Background(),&result) + require.Error(t,err) } diff --git a/docs/cost-variance.md b/docs/cost-variance.md index ef4f741..4a315a9 100644 --- a/docs/cost-variance.md +++ b/docs/cost-variance.md @@ -1,14 +1,20 @@ -# Cost variance CLI +# Local cost variance -The commands operate on local JSON only; they make no provider calls and do not modify a ledger. +`profitctl diff --input variance-input.json --output diff.json` compares cost observations and modeled driver pairs. `profitctl explain --input diff.json --output explanation.json` adds evidence-preserving, non-automatic review suggestions. Both print JSON to stdout; `--output` writes the same JSON to disk. Neither command calls a provider or changes a ledger. For totals-only comparisons, `profitctl diff --forecast forecast.json --actual actual.json` accepts two `profitctl.cost/v1` observations (no driver attribution; the difference remains residual). -```sh -profitctl cost_diff --forecast forecast.json --actual actual.json --output diff.json -profitctl cost_explain --input diff.json --output explanation.json +Input is a `variance/v1.Input` JSON object: + +```json +{ + "forecast": {"schema_version": "profitctl.cost/v1", "id": "...", "driver_ids": ["polls"], "window": {"start": "...", "end": "..."}, "quantity": {"value": 1, "unit": "command"}, "unit_price": {"amount": {"amount": 1, "currency": "USD"}, "per": {"value": 1, "unit": "command"}}, "total_cost": {"amount": 1, "currency": "USD"}, "dimensions": {"workload": "worker"}, "evidence": {"quantity": "...", "unit_price": "...", "total_cost": "..."}}, + "actual": null, + "unavailable_reason": "invoice not delivered", + "drivers": [] +} ``` -Both commands print their JSON report to stdout even when `--output` (`-o`) writes a copy to disk. `cost_diff` accepts either one `profitctl.cost/v1` `CostObservation` per file or an array of observations for multiple periods. Use matching windows, currency, and workload dimensions. Every observation must carry quantity, unit-price, and total-cost evidence with a source identity. The forecast is the percentage denominator: a zero forecast cannot yield a meaningful percent change and is rejected, not silently replaced with zero. `cost_explain` reads the exact report produced by `cost_diff`; never substitute an invoice or a free-form summary. +The abbreviated evidence placeholders above must be replaced by complete `cost/v1` evidence objects with source identity, capture date, confidence and rationale. For an available comparison, set `actual` to a validated observation and omit `unavailable_reason`. To explain drivers supply `drivers` as pairs of full `{ "forecast": CostDriver, "actual": CostDriver }` records referenced by both observations. Optional `exchange_rates` provide snapshot currency conversions and `receipts` provide exact observation-paired delivery evidence. Missing exchange rates fail; no live rates are fetched. The forecast and actual windows and workload must match. -The versioned output contract is the [cost variance JSON schema](../schemas/cost-variance/v1/schema.json). Reports retain source references (including artifact identity and captured time), units, windows, and any exact delivery receipts. A high-confidence attribution requires direct supporting evidence; medium confidence indicates partial support; low confidence indicates weak or synthetic support. An unknown attribution means evidence does not establish a cause, especially when an exact commit, pull-request, release, or deployment receipt is missing. A source label alone does not prove causation. A nonzero residual is the part of actual-minus-forecast cost not explained by named drivers; inspect it instead of distributing it across unsupported causes. Never treat unavailable actual cost as zero. +The diff JSON matches [`result.schema.json`](../schemas/cost-variance/v1/result.schema.json): `profitctl.cost-variance/v1`, status, window, observation IDs, currency-denominated forecast/actual/absolute variance and residual, percentage variance, contributions, missing evidence, rounding tolerance, and timestamped source references. A zero forecast gives `percentage_variance: null`; unavailable actual gives null actual, variance and residual, not a fabricated zero. Contributions are ranked by absolute amount; the residual is never hidden. `explain` returns the same amounts under `variance_absolute` and `variance_percent`, plus ranked `driver_contributions` containing their evidence, confidence score, receipts, and review actions. Review actions are suggestions, never automatic remediation or asserted root causes. Without an exact receipt, delivery attribution is `unknown` and missing evidence is named. A source label alone cannot establish causation. -Invalid inputs (malformed JSON, missing evidence, unmatched observations, or zero percentage denominator) exit with code **2** and a diagnostic on stderr. File or stdout write failures exit with code **3**. No partial JSON is printed on invalid input; an `--output` path that aliases any input is rejected before reading or writing it. +Malformed/invalid input and aliased output paths return exit code 2; output failures return code 3. No partial JSON is printed for invalid input. The exported `cmd.Diff(ctx, forecast, actual)` offers a totals-only comparison returning `*variancev1.Result`; `cmd.Explain(ctx, result)` returns `*cmd.ExplainReport`. For full attribution call `variancev1.Compare(variancev1.Input)` first. The peer engine's concrete exported type is `Result` (not `DiffReport`) and its comparison entry point is `Compare`. From 52bde8d341aae0c769b21f4a1e1747a03bf13945 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 19:41:19 -0400 Subject: [PATCH 05/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_variance_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 90267d1..dd5795d 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -18,7 +18,7 @@ import ( func varianceFixture(t *testing.T, id string, quantity, price, total float64, kind costv1.DriverKind) (costv1.CostObservation, costv1.CostDriver) { t.Helper() source := costv1.SourceReference{Type: costv1.SourceSyntheticFixture, ArtifactIdentity: "fixture://variance", CapturedAt: "2026-08-01T00:00:00Z"} - e := costv1.Evidence{Kind: costv1.EvidenceUserSupplied, Measurement: costv1.MeasurementDeclared, Source: source, Confidence: costv1.ConfidenceHigh, ConfidenceRationale: "deterministic fixture"} + e := costv1.Evidence{Kind: costv1.EvidencePredicted, Measurement: costv1.MeasurementSynthetic, Source: source, Confidence: costv1.ConfidenceLow, ConfidenceRationale: "deterministic fixture"} window := costv1.TimeWindow{Start:"2026-07-01T00:00:00Z", End:"2026-08-01T00:00:00Z"} q := costv1.Quantity{Value:quantity, Unit:"command"} p := costv1.UnitPrice{Amount:costv1.Money{Amount:price, Currency:"USD"}, Per:costv1.Quantity{Value:1, Unit:"command"}} From 648b07bf08aecf293cfe88ac37d2ca404de49fe3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 20:52:40 -0400 Subject: [PATCH 06/17] Explain cost variance and root cause from the CLI: variance-engine --- pkg/variance/v1/diff.go | 36 ++++--- pkg/variance/v1/diff_test.go | 4 - pkg/variance/v1/zero_test.go | 104 ++++++++++++++++++++ schemas/cost-variance/v1/result.schema.json | 4 + schemas/cost-variance/v1/schema.json | 56 +++++++---- 5 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 pkg/variance/v1/zero_test.go diff --git a/pkg/variance/v1/diff.go b/pkg/variance/v1/diff.go index 7f21279..21e5a62 100644 --- a/pkg/variance/v1/diff.go +++ b/pkg/variance/v1/diff.go @@ -27,7 +27,7 @@ type DiffPeriod struct { Forecast costv1.CostObservation `json:"forecast"` Actual costv1.CostObservation `json:"actual"` VarianceAbsolute costv1.Money `json:"variance_absolute"` - VariancePercent float64 `json:"variance_percent"` + VariancePercent *float64 `json:"variance_percent"` DriverContributions []DriverContribution `json:"driver_contributions"` Residual costv1.Money `json:"residual"` Confidence string `json:"confidence"` @@ -40,7 +40,7 @@ type DiffReport struct { Forecast costv1.Money `json:"forecast"` Actual costv1.Money `json:"actual"` VarianceAbsolute costv1.Money `json:"variance_absolute"` - VariancePercent float64 `json:"variance_percent"` + VariancePercent *float64 `json:"variance_percent"` DriverContributions []DriverContribution `json:"driver_contributions"` Residual costv1.Money `json:"residual"` Confidence string `json:"confidence"` @@ -52,7 +52,7 @@ type ExplainReport struct { Forecast costv1.Money `json:"forecast"` Actual costv1.Money `json:"actual"` VarianceAbsolute costv1.Money `json:"variance_absolute"` - VariancePercent float64 `json:"variance_percent"` + VariancePercent *float64 `json:"variance_percent"` DriverContributions []DriverContribution `json:"driver_contributions"` Residual costv1.Money `json:"residual"` Confidence string `json:"confidence"` @@ -71,9 +71,6 @@ func Diff(forecast, actual []costv1.CostObservation) (*DiffReport, error) { if err := f.Validate(); err != nil { return nil, fmt.Errorf("forecast[%d]: %w", i, err) } - if f.TotalCost.Amount == 0 { - return nil, fmt.Errorf("forecast[%d]: zero total cost denominator", i) - } match := -1 for j, a := range actual { if err := a.Validate(); err != nil { @@ -117,9 +114,13 @@ func Diff(forecast, actual []costv1.CostObservation) (*DiffReport, error) { confidence = "unknown" } } - period := DiffPeriod{Window: f.Window, Forecast: f, Actual: a, VarianceAbsolute: costv1.Money{Amount: delta, Currency: currency}, VariancePercent: delta / f.TotalCost.Amount * 100, DriverContributions: contributions, Residual: costv1.Money{Amount: residual, Currency: currency}, Confidence: confidence} - if !finite(period.VariancePercent) { - return nil, fmt.Errorf("forecast[%d]: percentage overflow", i) + period := DiffPeriod{Window: f.Window, Forecast: f, Actual: a, VarianceAbsolute: costv1.Money{Amount: delta, Currency: currency}, DriverContributions: contributions, Residual: costv1.Money{Amount: residual, Currency: currency}, Confidence: confidence} + if f.TotalCost.Amount != 0 { + percentage := delta / f.TotalCost.Amount * 100 + if !finite(percentage) { + return nil, fmt.Errorf("forecast[%d]: percentage overflow", i) + } + period.VariancePercent = &percentage } result.Periods = append(result.Periods, period) result.DriverContributions = append(result.DriverContributions, contributions...) @@ -139,9 +140,15 @@ func Diff(forecast, actual []costv1.CostObservation) (*DiffReport, error) { } } result.VarianceAbsolute = costv1.Money{Amount: result.Actual.Amount - result.Forecast.Amount, Currency: result.Forecast.Currency} - result.VariancePercent = result.VarianceAbsolute.Amount / result.Forecast.Amount * 100 - if !finite(result.VariancePercent) { - return nil, errors.New("aggregate percentage overflow") + if !finite(result.Forecast.Amount) || !finite(result.Actual.Amount) || !finite(result.Residual.Amount) || !finite(result.VarianceAbsolute.Amount) { + return nil, errors.New("aggregate variance overflow") + } + if result.Forecast.Amount != 0 { + percentage := result.VarianceAbsolute.Amount / result.Forecast.Amount * 100 + if !finite(percentage) { + return nil, errors.New("aggregate percentage overflow") + } + result.VariancePercent = &percentage } sort.Slice(result.Periods, func(i, j int) bool { return result.Periods[i].Window.Start < result.Periods[j].Window.Start }) sort.SliceStable(result.DriverContributions, func(i, j int) bool { @@ -198,7 +205,10 @@ func Explain(report DiffReport) (*ExplainReport, error) { recomputedActual += p.Actual.TotalCost.Amount recomputedResidual += p.Residual.Amount } - if math.Abs(recomputedForecast-report.Forecast.Amount) > Rounding || math.Abs(recomputedActual-report.Actual.Amount) > Rounding || math.Abs(recomputedResidual-report.Residual.Amount) > Rounding || math.Abs(report.VarianceAbsolute.Amount-(recomputedActual-recomputedForecast)) > Rounding || report.Forecast.Amount == 0 || math.Abs(report.VariancePercent-100*(recomputedActual-recomputedForecast)/recomputedForecast) > Rounding { + if !finite(recomputedForecast) || !finite(recomputedActual) || !finite(recomputedResidual) || !finite(report.Forecast.Amount) || !finite(report.Actual.Amount) || !finite(report.Residual.Amount) || !finite(report.VarianceAbsolute.Amount) || math.Abs(recomputedForecast-report.Forecast.Amount) > Rounding || math.Abs(recomputedActual-report.Actual.Amount) > Rounding || math.Abs(recomputedResidual-report.Residual.Amount) > Rounding || math.Abs(report.VarianceAbsolute.Amount-(recomputedActual-recomputedForecast)) > Rounding { + return nil, errors.New("report totals do not reconcile") + } + if (recomputedForecast == 0 && report.VariancePercent != nil) || (recomputedForecast != 0 && (report.VariancePercent == nil || !finite(*report.VariancePercent) || math.Abs(*report.VariancePercent-100*(recomputedActual-recomputedForecast)/recomputedForecast) > Rounding)) { return nil, errors.New("report totals do not reconcile") } forecasts := make([]costv1.CostObservation, len(report.Periods)) diff --git a/pkg/variance/v1/diff_test.go b/pkg/variance/v1/diff_test.go index 1721fcf..9e9e45b 100644 --- a/pkg/variance/v1/diff_test.go +++ b/pkg/variance/v1/diff_test.go @@ -132,8 +132,4 @@ func TestCostDiffVarianceResidualAndValidation(t *testing.T) { if _, err = Diff([]cost.CostObservation{f}, nil); err == nil { t.Fatal("unavailable actual accepted as zero") } - f.TotalCost.Amount = 0 - if _, err = Diff([]cost.CostObservation{f}, []cost.CostObservation{observation("a", 20, 3, 60)}); err == nil { - t.Fatal("zero denominator accepted") - } } diff --git a/pkg/variance/v1/zero_test.go b/pkg/variance/v1/zero_test.go new file mode 100644 index 0000000..32bdfff --- /dev/null +++ b/pkg/variance/v1/zero_test.go @@ -0,0 +1,104 @@ +package v1 + +import ( + "encoding/json" + "math" + "testing" + + cost "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" +) + +func TestZeroForecastPeriodsAndAggregate(t *testing.T) { + f := observation("f", 0, 2, 0) + a := observation("a", 2, 2, 4) + nextF, nextA := f, a + nextF.ID, nextA.ID = "next-f", "next-a" + nextF.Window.Start, nextF.Window.End = stamp, "2026-09-01T00:00:00Z" + nextA.Window = nextF.Window + for _, tc := range []struct { + name string + forecast, actual []cost.CostObservation + aggregateNull bool + }{ + {"zero aggregate", []cost.CostObservation{f}, []cost.CostObservation{a}, true}, + {"mixed periods", []cost.CostObservation{f, observation("other-f", 3, 2, 6)}, []cost.CostObservation{a, observation("other-a", 4, 2, 8)}, false}, + {"zero periods", []cost.CostObservation{f, nextF}, []cost.CostObservation{a, nextA}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if len(tc.forecast) > 1 && tc.name == "mixed periods" { + tc.forecast[1].Window = nextF.Window + tc.actual[1].Window = nextF.Window + } + r, err := Diff(tc.forecast, tc.actual) + if err != nil { + t.Fatal(err) + } + if (r.VariancePercent == nil) != tc.aggregateNull || r.Periods[0].VariancePercent != nil { + t.Fatalf("wrong undefined percentages: %+v", r) + } + if !tc.aggregateNull && (r.VariancePercent == nil || math.Abs(*r.VariancePercent-100) > Rounding || r.Periods[1].VariancePercent == nil) { + t.Fatalf("lost numeric percentage: %+v", r) + } + sum := r.Residual.Amount + for _, c := range r.DriverContributions { + sum += c.Amount.Amount + } + near(t, sum, r.VarianceAbsolute.Amount) + explained, err := Explain(*r) + if err != nil { + t.Fatal(err) + } + if (explained.VariancePercent == nil) != tc.aggregateNull || explained.Periods[0].VariancePercent != nil { + t.Fatal("explanation lost undefined percentage") + } + b, err := json.Marshal(explained) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err = json.Unmarshal(b, &raw); err != nil { + t.Fatal(err) + } + if tc.aggregateNull && raw["variance_percent"] != nil { + t.Fatalf("expected null: %s", b) + } + if raw["periods"].([]any)[0].(map[string]any)["variance_percent"] != nil { + t.Fatalf("expected period null: %s", b) + } + tampered := *r + if tc.aggregateNull { + v := 0.0 + tampered.VariancePercent = &v + } else { + tampered.VariancePercent = nil + } + if _, err = Explain(tampered); err == nil { + t.Fatal("invalid percentage accepted") + } + }) + } + in := input(cost.DriverFixed, 0, 2, 2, 2, 0, 4) + result, err := Compare(in) + if err != nil { + t.Fatal(err) + } + if result.PercentageVariance != nil || result.AbsoluteVariance.Amount != 4 { + t.Fatalf("canonical comparison: %+v", result) + } + b, err := MarshalJSONResult(result) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err = json.Unmarshal(b, &raw); err != nil { + t.Fatal(err) + } + if raw["percentage_variance"] != nil { + t.Fatalf("canonical percentage not null: %s", b) + } + sum := result.Residual.Amount + for _, c := range result.Contributions { + sum += c.Amount.Amount + } + near(t, sum, result.AbsoluteVariance.Amount) +} diff --git a/schemas/cost-variance/v1/result.schema.json b/schemas/cost-variance/v1/result.schema.json index 464c5ba..8efb20b 100644 --- a/schemas/cost-variance/v1/result.schema.json +++ b/schemas/cost-variance/v1/result.schema.json @@ -22,6 +22,10 @@ "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}}, "contributions": {"type": "array", "items": {"$ref": "#/$defs/contribution"}} }, + "allOf": [ + {"if": {"properties": {"status": {"const": "available"}}}, "then": {"required": ["actual_observation_id"], "properties": {"actual": {"$ref": "#/$defs/money"}, "absolute_variance": {"$ref": "#/$defs/money"}, "residual": {"$ref": "#/$defs/money"}}, "not": {"required": ["unavailable_reason"]}}}, + {"if": {"properties": {"status": {"const": "unavailable"}}}, "then": {"required": ["unavailable_reason"], "properties": {"actual": {"type": "null"}, "absolute_variance": {"type": "null"}, "percentage_variance": {"type": "null"}, "residual": {"type": "null"}, "contributions": {"maxItems": 0}}, "not": {"required": ["actual_observation_id"]}}} + ], "$defs": { "money": {"type": "object", "additionalProperties": false, "required": ["amount", "currency"], "properties": {"amount": {"type": "number"}, "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}}}, "window": {"type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}}}, diff --git a/schemas/cost-variance/v1/schema.json b/schemas/cost-variance/v1/schema.json index f43a14e..4b77c77 100644 --- a/schemas/cost-variance/v1/schema.json +++ b/schemas/cost-variance/v1/schema.json @@ -1,22 +1,38 @@ { - "$schema":"https://json-schema.org/draft/2020-12/schema", - "$id":"https://profitctl.dev/schemas/cost-variance/v1/schema.json", - "title":"Cost variance diff or explanation", - "type":"object", - "additionalProperties":false, - "required":["schema_version","forecast","actual","variance_absolute","variance_percent","driver_contributions","residual","confidence","periods"], - "properties":{ - "schema_version":{"const":"profitctl.cost-variance/v1"}, - "forecast":{"$ref":"#/$defs/money"},"actual":{"$ref":"#/$defs/money"}, - "variance_absolute":{"$ref":"#/$defs/money"},"variance_percent":{"type":"number"}, - "residual":{"$ref":"#/$defs/money"},"confidence":{"enum":["high","medium","low","unknown"]}, - "driver_contributions":{"type":"array","items":{"$ref":"#/$defs/driver"}}, - "periods":{"type":"array","minItems":1,"items":{"type":"object","required":["window","forecast","actual","variance_absolute","variance_percent","driver_contributions","residual","confidence"],"properties":{"window":{"$ref":"#/$defs/window"},"forecast":{"$ref":"#/$defs/observation"},"actual":{"$ref":"#/$defs/observation"},"variance_absolute":{"$ref":"#/$defs/money"},"variance_percent":{"type":"number"},"driver_contributions":{"type":"array","items":{"$ref":"#/$defs/driver"}},"residual":{"$ref":"#/$defs/money"},"confidence":{"enum":["high","medium","low","unknown"]}},"additionalProperties":false}} - }, - "$defs":{ - "money":{"type":"object","required":["amount","currency"],"properties":{"amount":{"type":"number"},"currency":{"type":"string","pattern":"^[A-Z]{3}$"}},"additionalProperties":false}, - "window":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}}}, - "observation":{"type":"object","required":["schema_version","id","driver_ids","window","quantity","unit_price","total_cost","dimensions","evidence"],"properties":{"schema_version":{"const":"profitctl.cost/v1"},"id":{"type":"string"},"driver_ids":{"type":"array"},"window":{"$ref":"#/$defs/window"},"quantity":{"type":"object"},"unit_price":{"type":"object"},"total_cost":{"$ref":"#/$defs/money"},"dimensions":{"type":"object"},"evidence":{"type":"object"}}}, - "driver":{"type":"object","additionalProperties":false,"required":["driver","amount","confidence","confidence_score","attribution","evidence","missing_evidence","remedial_actions"],"properties":{"driver":{"type":"string"},"amount":{"$ref":"#/$defs/money"},"confidence":{"enum":["high","medium","low","unknown"]},"confidence_score":{"type":"number","minimum":0,"maximum":1},"attribution":{"const":"unknown"},"evidence":{"type":"array","minItems":2,"items":{"type":"object","required":["kind","measurement","source","confidence","confidence_rationale"],"properties":{"source":{"type":"object","required":["type","captured_at"],"properties":{"type":{"type":"string"},"captured_at":{"type":"string"},"artifact_identity":{"type":"string"}}}}}},"missing_evidence":{"type":"array","items":{"type":"string"}},"remedial_actions":{"type":"array","items":{"type":"string"}}}} - } + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://profitctl.dev/schemas/cost-variance/v1/schema.json", + "title": "ProfitCtl cost explain CLI report v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "status", "window", "forecast_observation_id", "confidence", "confidence_score", "forecast", "actual", "variance_absolute", "variance_percent", "driver_contributions", "residual", "missing_evidence", "sources"], + "properties": { + "schema_version": {"const": "profitctl.cost-variance/v1"}, + "status": {"enum": ["available", "unavailable"]}, + "window": {"$ref": "#/$defs/window"}, + "forecast_observation_id": {"type": "string", "minLength": 1}, + "actual_observation_id": {"type": "string", "minLength": 1}, + "unavailable_reason": {"type": "string", "minLength": 1}, + "confidence": {"enum": ["low", "medium", "high"]}, + "confidence_score": {"type": "number", "minimum": 0, "maximum": 1}, + "forecast": {"$ref": "#/$defs/money"}, + "actual": {"$ref": "#/$defs/nullableMoney"}, + "variance_absolute": {"$ref": "#/$defs/nullableMoney"}, + "variance_percent": {"type": ["number", "null"]}, + "driver_contributions": {"type": "array", "items": {"$ref": "#/$defs/contribution"}}, + "residual": {"$ref": "#/$defs/nullableMoney"}, + "missing_evidence": {"type": "array", "items": {"type": "string"}}, + "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}} + }, + "allOf": [ + {"if": {"properties": {"status": {"const": "available"}}}, "then": {"required": ["actual_observation_id"], "properties": {"actual": {"$ref": "#/$defs/money"}, "variance_absolute": {"$ref": "#/$defs/money"}, "residual": {"$ref": "#/$defs/money"}}, "not": {"required": ["unavailable_reason"]}}}, + {"if": {"properties": {"status": {"const": "unavailable"}}}, "then": {"required": ["unavailable_reason"], "properties": {"actual": {"type": "null"}, "variance_absolute": {"type": "null"}, "variance_percent": {"type": "null"}, "residual": {"type": "null"}, "driver_contributions": {"maxItems": 0}}, "not": {"required": ["actual_observation_id"]}}} + ], + "$defs": { + "money": {"type": "object", "additionalProperties": false, "required": ["amount", "currency"], "properties": {"amount": {"type": "number"}, "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}}}, + "nullableMoney": {"oneOf": [{"$ref": "#/$defs/money"}, {"type": "null"}]}, + "window": {"type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}}}, + "source": {"type": "object", "required": ["type", "captured_at"], "properties": {"type": {"type": "string"}, "captured_at": {"type": "string"}, "artifact_identity": {"type": "string"}, "url": {"type": "string"}, "refresh_owner": {"type": "string"}, "refresh_cadence": {"type": "string"}, "stale_after": {"type": "string"}}, "additionalProperties": false}, + "receipt": {"type": "object", "additionalProperties": false, "required": ["driver_id", "kind", "forecast_observation_id", "actual_observation_id", "source"], "properties": {"driver_id": {"type": "string"}, "kind": {"enum": ["commit", "pull_request", "release", "deployment"]}, "forecast_observation_id": {"type": "string"}, "actual_observation_id": {"type": "string"}, "source": {"$ref": "#/$defs/source"}}}, + "contribution": {"type": "object", "additionalProperties": false, "required": ["driver_id", "kind", "quantity", "unit_price", "distribution", "amount", "confidence", "confidence_score", "attribution", "missing_evidence", "sources", "receipts", "remedial_actions"], "properties": {"driver_id": {"type": "string"}, "kind": {"enum": ["fixed", "variable", "cadence", "concurrency", "uptime"]}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}, "distribution": {"type": "number"}, "amount": {"$ref": "#/$defs/money"}, "confidence": {"enum": ["low", "medium", "high"]}, "confidence_score": {"type": "number", "minimum": 0, "maximum": 1}, "attribution": {"enum": ["modeled", "unknown"]}, "missing_evidence": {"type": "array", "items": {"type": "string"}}, "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}}, "receipts": {"type": "array", "items": {"$ref": "#/$defs/receipt"}}, "remedial_actions": {"type": "array", "minItems": 1, "items": {"type": "string"}}}} + } } From 494420705e3e38b9e76deeca0afa2a82977bb12a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 22:31:20 -0400 Subject: [PATCH 07/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_explain.go | 17 +++++++++++++ cmd/cost_variance_test.go | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 82ee432..4d1447f 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -43,12 +43,29 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er if err := ctx.Err(); err != nil { return nil, err } if report == nil || report.SchemaVersion != variancev1.SchemaVersion || (report.Status != "available" && report.Status != "unavailable") { return nil, errors.New("valid cost variance result is required") } if report.Status == "available" && (report.Actual == nil || report.AbsoluteVariance == nil || report.Residual == nil) { return nil, errors.New("available result lacks actual, variance or residual") } + if report.Status == "available" { + for _, field := range []struct { name string; money *costv1.Money }{{"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { + if field.money.Currency != report.Forecast.Currency { return nil, fmt.Errorf("%s currency %q differs from forecast currency %q", field.name, field.money.Currency, report.Forecast.Currency) } + } + } if report.Status == "available" && (math.IsNaN(report.Residual.Amount) || math.IsInf(report.Residual.Amount, 0) || math.IsNaN(report.AbsoluteVariance.Amount) || math.IsInf(report.AbsoluteVariance.Amount, 0)) { return nil, errors.New("non-finite variance or residual") } if report.Status == "unavailable" && (report.Actual != nil || report.AbsoluteVariance != nil || report.Residual != nil || len(report.Contributions) != 0) { return nil, errors.New("unavailable result cannot contain measured variance") } out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} if out.MissingEvidence == nil { out.MissingEvidence = []string{} } if report.Status == "available" { out.ConfidenceScore = 1 + // Result carries total-cost sources, not the observations' claim evidence. + // Provenance can limit confidence but cannot prove a high-confidence total. + for i, name := range []string{"forecast", "actual"} { + score := .35 + if i < len(report.Sources) { + switch report.Sources[i].Type { + case costv1.SourceInvoice, costv1.SourceProfitCtlDerived: score = .7 + } + } + out.MissingEvidence = append(out.MissingEvidence, name+" total cost: high-confidence claim evidence unavailable") + out.ConfidenceScore = math.Min(out.ConfidenceScore, score) + } sum := report.Residual.Amount for _, c := range report.Contributions { if c.Amount.Currency != report.Forecast.Currency || math.IsNaN(c.Amount.Amount) || math.IsInf(c.Amount.Amount, 0) || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { return nil, errors.New("invalid driver contribution") } diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index dd5795d..e6887f2 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -169,6 +169,56 @@ func TestCostDiffUnavailableAndZeroJSON(t *testing.T) { require.Nil(t,result.Residual) } +func TestCostExplainTotalCurrencies(t *testing.T) { + f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) + a,_ := varianceFixture(t,"actual",10,3,30,costv1.DriverFixed) + base,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) + require.NoError(t,err) + for _, field := range []string{"actual", "absolute_variance", "residual"} { + t.Run(field,func(t *testing.T){ + result := base + actual, absolute, residual := *base.Actual, *base.AbsoluteVariance, *base.Residual + result.Actual, result.AbsoluteVariance, result.Residual = &actual, &absolute, &residual + switch field { + case "actual": result.Actual.Currency = "EUR" + case "absolute_variance": result.AbsoluteVariance.Currency = "EUR" + case "residual": result.Residual.Currency = "EUR" + } + _,err := Explain(context.Background(),&result) + require.ErrorContains(t,err,field) + require.ErrorContains(t,err,"EUR") + require.ErrorContains(t,err,"USD") + require.NotContains(t,err.Error(),"reconcile") + }) + } + report,err := Explain(context.Background(),&base) + require.NoError(t,err) + require.Equal(t,"USD",report.Actual.Currency) + require.Equal(t,"USD",report.VarianceAbsolute.Currency) + require.Equal(t,"USD",report.Residual.Currency) +} + +func TestCostExplainCapsStrongDriversByTotalEvidence(t *testing.T) { + f,fd := varianceFixture(t,"forecast",10,2,20,costv1.DriverVariable) + a,ad := varianceFixture(t,"actual",20,2,40,costv1.DriverVariable) + strong := costv1.Evidence{Kind:costv1.EvidenceObserved,Measurement:costv1.MeasurementMeasured,Source:costv1.SourceReference{Type:costv1.SourceTelemetry,ArtifactIdentity:"telemetry://driver",CapturedAt:"2026-08-01T00:00:00Z"},Confidence:costv1.ConfidenceHigh,ConfidenceRationale:"measured driver"} + fd.Evidence.Quantity,ad.Evidence.Quantity = strong,strong + strong.Source.Type = costv1.SourceInvoice + strong.Source.ArtifactIdentity = "invoice://driver" + strong.Kind = costv1.EvidenceBilled + fd.Evidence.UnitPrice,ad.Evidence.UnitPrice = strong,strong + result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) + require.NoError(t,err) + require.Equal(t,costv1.ConfidenceHigh,result.Contributions[0].Confidence) + report,err := Explain(context.Background(),&result) + require.NoError(t,err) + require.Equal(t,costv1.ConfidenceLow,report.Confidence) + require.LessOrEqual(t,report.ConfidenceScore,.35) + require.Equal(t,costv1.ConfidenceHigh,report.DriverContributions[0].Confidence) + require.Contains(t,report.MissingEvidence,"forecast total cost: high-confidence claim evidence unavailable") + require.Contains(t,report.MissingEvidence,"actual total cost: high-confidence claim evidence unavailable") +} + func TestCostExplainInvalidReconciliation(t *testing.T) { result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} _,err:=Explain(context.Background(),&result) From ae0cdcc6be7cd4e0df76dd6de8b56800ba127c5f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:10:49 -0400 Subject: [PATCH 08/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_explain.go | 13 +++---------- cmd/cost_variance_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 4d1447f..0bcc05a 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -54,17 +54,10 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er if out.MissingEvidence == nil { out.MissingEvidence = []string{} } if report.Status == "available" { out.ConfidenceScore = 1 - // Result carries total-cost sources, not the observations' claim evidence. - // Provenance can limit confidence but cannot prove a high-confidence total. - for i, name := range []string{"forecast", "actual"} { - score := .35 - if i < len(report.Sources) { - switch report.Sources[i].Type { - case costv1.SourceInvoice, costv1.SourceProfitCtlDerived: score = .7 - } - } + // Result has sources but no total-cost claim confidence; provenance cannot establish it. + out.ConfidenceScore = .35 + for _, name := range []string{"forecast", "actual"} { out.MissingEvidence = append(out.MissingEvidence, name+" total cost: high-confidence claim evidence unavailable") - out.ConfidenceScore = math.Min(out.ConfidenceScore, score) } sum := report.Residual.Amount for _, c := range report.Contributions { diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index e6887f2..7d3ccb8 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -219,6 +219,45 @@ func TestCostExplainCapsStrongDriversByTotalEvidence(t *testing.T) { require.Contains(t,report.MissingEvidence,"actual total cost: high-confidence claim evidence unavailable") } +func TestCostExplainSourceLabelsCannotPromoteTotalConfidence(t *testing.T) { + for _, sourceType := range []costv1.SourceType{costv1.SourceInvoice, costv1.SourceProfitCtlDerived} { + t.Run(string(sourceType),func(t *testing.T){ + f,fd := varianceFixture(t,"forecast",10,2,20,costv1.DriverVariable) + a,ad := varianceFixture(t,"actual",20,2,40,costv1.DriverVariable) + for _, observation := range []*costv1.CostObservation{&f,&a} { + observation.Evidence.TotalCost.Source.Type = sourceType + if sourceType == costv1.SourceInvoice { + observation.Evidence.TotalCost.Kind = costv1.EvidenceBilled + observation.Evidence.TotalCost.Measurement = costv1.MeasurementMeasured + } else { + observation.Evidence.TotalCost.Measurement = costv1.MeasurementDerived + } + } + strong := costv1.Evidence{Kind:costv1.EvidenceObserved,Measurement:costv1.MeasurementMeasured,Source:costv1.SourceReference{Type:costv1.SourceTelemetry,ArtifactIdentity:"telemetry://driver",CapturedAt:"2026-08-01T00:00:00Z"},Confidence:costv1.ConfidenceHigh,ConfidenceRationale:"measured driver"} + fd.Evidence.Quantity,ad.Evidence.Quantity = strong,strong + strong.Source.Type = costv1.SourceInvoice + strong.Source.ArtifactIdentity = "invoice://driver" + strong.Kind = costv1.EvidenceBilled + fd.Evidence.UnitPrice,ad.Evidence.UnitPrice = strong,strong + result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) + require.NoError(t,err) + require.Equal(t,"available",result.Status) + require.InDelta(t,0,result.Residual.Amount,variancev1.Rounding) + require.Equal(t,costv1.ConfidenceHigh,result.Contributions[0].Confidence) + require.Greater(t,result.Contributions[0].ConfidenceScore,.7) + require.Equal(t,sourceType,result.Sources[0].Type) + require.Equal(t,sourceType,result.Sources[1].Type) + report,err := Explain(context.Background(),&result) + require.NoError(t,err) + require.Equal(t,costv1.ConfidenceLow,report.Confidence) + require.LessOrEqual(t,report.ConfidenceScore,.35) + require.Equal(t,result.Contributions[0].ConfidenceScore,report.DriverContributions[0].ConfidenceScore) + require.Contains(t,report.MissingEvidence,"forecast total cost: high-confidence claim evidence unavailable") + require.Contains(t,report.MissingEvidence,"actual total cost: high-confidence claim evidence unavailable") + }) + } +} + func TestCostExplainInvalidReconciliation(t *testing.T) { result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} _,err:=Explain(context.Background(),&result) From b1c9ae07125a5a89d309abd7199370493c53163a Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:27:46 -0400 Subject: [PATCH 09/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_explain.go | 26 +++++++++++++++++--- cmd/cost_variance_test.go | 50 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 0bcc05a..cdf68a8 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -42,13 +42,32 @@ type ExplainReport struct { func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, error) { if err := ctx.Err(); err != nil { return nil, err } if report == nil || report.SchemaVersion != variancev1.SchemaVersion || (report.Status != "available" && report.Status != "unavailable") { return nil, errors.New("valid cost variance result is required") } + for _, field := range []struct { name string; money *costv1.Money }{{"forecast", &report.Forecast}, {"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { + if field.money != nil && (math.IsNaN(field.money.Amount) || math.IsInf(field.money.Amount, 0)) { return nil, fmt.Errorf("%s.amount must be finite", field.name) } + } + if report.PercentageVariance != nil && (math.IsNaN(*report.PercentageVariance) || math.IsInf(*report.PercentageVariance, 0)) { return nil, errors.New("percentage_variance must be finite") } + if report.Status == "unavailable" && report.PercentageVariance != nil { return nil, errors.New("percentage_variance must be null when actual is unavailable") } if report.Status == "available" && (report.Actual == nil || report.AbsoluteVariance == nil || report.Residual == nil) { return nil, errors.New("available result lacks actual, variance or residual") } if report.Status == "available" { for _, field := range []struct { name string; money *costv1.Money }{{"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { if field.money.Currency != report.Forecast.Currency { return nil, fmt.Errorf("%s currency %q differs from forecast currency %q", field.name, field.money.Currency, report.Forecast.Currency) } } } - if report.Status == "available" && (math.IsNaN(report.Residual.Amount) || math.IsInf(report.Residual.Amount, 0) || math.IsNaN(report.AbsoluteVariance.Amount) || math.IsInf(report.AbsoluteVariance.Amount, 0)) { return nil, errors.New("non-finite variance or residual") } + if report.Status == "available" { + delta := report.Actual.Amount - report.Forecast.Amount + if math.IsNaN(delta) || math.IsInf(delta, 0) || math.Abs(delta-report.AbsoluteVariance.Amount) > variancev1.Rounding { + return nil, errors.New("absolute_variance.amount must equal actual.amount minus forecast.amount within rounding tolerance") + } + if report.Forecast.Amount == 0 { + if report.PercentageVariance != nil { return nil, errors.New("percentage_variance must be null when forecast.amount is zero") } + } else { + expected := 100 * report.AbsoluteVariance.Amount / report.Forecast.Amount + if math.IsNaN(expected) || math.IsInf(expected, 0) { return nil, errors.New("percentage_variance overflows for supplied amounts") } + if report.PercentageVariance == nil || math.Abs(*report.PercentageVariance-expected) > 100*variancev1.Rounding/math.Abs(report.Forecast.Amount) { + return nil, fmt.Errorf("percentage_variance must equal 100 * absolute_variance.amount / forecast.amount (%.12g) within rounding tolerance", expected) + } + } + } if report.Status == "unavailable" && (report.Actual != nil || report.AbsoluteVariance != nil || report.Residual != nil || len(report.Contributions) != 0) { return nil, errors.New("unavailable result cannot contain measured variance") } out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} if out.MissingEvidence == nil { out.MissingEvidence = []string{} } @@ -60,8 +79,9 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er out.MissingEvidence = append(out.MissingEvidence, name+" total cost: high-confidence claim evidence unavailable") } sum := report.Residual.Amount - for _, c := range report.Contributions { - if c.Amount.Currency != report.Forecast.Currency || math.IsNaN(c.Amount.Amount) || math.IsInf(c.Amount.Amount, 0) || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { return nil, errors.New("invalid driver contribution") } + for i, c := range report.Contributions { + if math.IsNaN(c.Amount.Amount) || math.IsInf(c.Amount.Amount, 0) { return nil, fmt.Errorf("contributions[%d].amount.amount must be finite", i) } + if c.Amount.Currency != report.Forecast.Currency || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { return nil, errors.New("invalid driver contribution") } sum += c.Amount.Amount out.ConfidenceScore = math.Min(out.ConfidenceScore, c.ConfidenceScore) action := "Review workload measurements and cost evidence before adjusting this driver" diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 7d3ccb8..600e1dc 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -112,7 +112,7 @@ func TestCostVarianceResidualZeroAndUnavailable(t *testing.T) { require.NotEmpty(t,report.MissingEvidence) result.AbsoluteVariance.Amount++ _,err = Explain(context.Background(),&result) - require.ErrorContains(t,err,"reconcile") + require.ErrorContains(t,err,"absolute_variance") f.TotalCost.Amount = 0 result,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) require.NoError(t,err) @@ -258,6 +258,54 @@ func TestCostExplainSourceLabelsCannotPromoteTotalConfidence(t *testing.T) { } } +func TestCostExplainSuppliedTotalsAndPercentages(t *testing.T) { + f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) + a,_ := varianceFixture(t,"actual",20,2,40,costv1.DriverFixed) + base,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) + require.NoError(t,err) + require.Equal(t,100.0,*base.PercentageVariance) + require.Equal(t,20.0,base.Residual.Amount) + for _, tc := range []struct{name string; change func(*variancev1.Result); errorField string}{ + {"concealed total mismatch",func(r *variancev1.Result){r.AbsoluteVariance.Amount=10;r.Residual.Amount=10},"absolute_variance"}, + {"incorrect percentage",func(r *variancev1.Result){p:=50.0;r.PercentageVariance=&p},"percentage_variance"}, + {"missing defined percentage",func(r *variancev1.Result){r.PercentageVariance=nil},"percentage_variance"}, + {"numeric zero forecast",func(r *variancev1.Result){r.Forecast.Amount=0;r.AbsoluteVariance.Amount=40;r.Residual.Amount=40;p:=0.0;r.PercentageVariance=&p},"percentage_variance"}, + {"numeric unavailable",func(r *variancev1.Result){r.Status="unavailable";r.Actual=nil;r.AbsoluteVariance=nil;r.Residual=nil;p:=100.0;r.PercentageVariance=&p},"percentage_variance"}, + {"nonfinite forecast",func(r *variancev1.Result){r.Forecast.Amount=math.NaN()},"forecast.amount"}, + {"nonfinite actual",func(r *variancev1.Result){r.Actual.Amount=math.Inf(1)},"actual.amount"}, + {"nonfinite absolute",func(r *variancev1.Result){r.AbsoluteVariance.Amount=math.NaN()},"absolute_variance.amount"}, + {"nonfinite residual",func(r *variancev1.Result){r.Residual.Amount=math.Inf(-1)},"residual.amount"}, + {"nonfinite percentage",func(r *variancev1.Result){p:=math.NaN();r.PercentageVariance=&p},"percentage_variance"}, + {"nonfinite contribution",func(r *variancev1.Result){r.Contributions=[]variancev1.Contribution{{Amount:costv1.Money{Amount:math.NaN(),Currency:"USD"},Attribution:"modeled",ConfidenceScore:.35}}},"contributions[0].amount.amount"}, + } { + t.Run(tc.name,func(t *testing.T){ + r:=base + actual,absolute,residual:=*base.Actual,*base.AbsoluteVariance,*base.Residual + r.Actual,r.AbsoluteVariance,r.Residual=&actual,&absolute,&residual + tc.change(&r) + _,err:=Explain(context.Background(),&r) + require.ErrorContains(t,err,tc.errorField) + }) + } + report,err:=Explain(context.Background(),&base) + require.NoError(t,err) + require.Equal(t,100.0,*report.VariancePercent) + zero:=base + zero.Forecast.Amount=0 + zero.AbsoluteVariance=&costv1.Money{Amount:40,Currency:"USD"} + zero.Residual=&costv1.Money{Amount:40,Currency:"USD"} + zero.PercentageVariance=nil + report,err=Explain(context.Background(),&zero) + require.NoError(t,err) + require.Nil(t,report.VariancePercent) + unavailable:=base + unavailable.Status="unavailable" + unavailable.Actual,unavailable.AbsoluteVariance,unavailable.Residual,unavailable.PercentageVariance=nil,nil,nil,nil + report,err=Explain(context.Background(),&unavailable) + require.NoError(t,err) + require.Nil(t,report.VariancePercent) +} + func TestCostExplainInvalidReconciliation(t *testing.T) { result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} _,err:=Explain(context.Background(),&result) From f5a428c92764d1c34845bca27d91959426c950f0 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 19:57:39 -0400 Subject: [PATCH 10/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_explain.go | 8 ++++++++ cmd/cost_variance_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index cdf68a8..2eaf5eb 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -69,6 +69,14 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er } } if report.Status == "unavailable" && (report.Actual != nil || report.AbsoluteVariance != nil || report.Residual != nil || len(report.Contributions) != 0) { return nil, errors.New("unavailable result cannot contain measured variance") } + if report.Status == "unavailable" { + if strings.TrimSpace(report.UnavailableReason) == "" { return nil, errors.New("unavailable_reason is required when actual is unavailable") } + visible := false + for _, evidence := range report.MissingEvidence { + if strings.Contains(evidence, report.UnavailableReason) { visible = true; break } + } + if !visible { return nil, errors.New("missing_evidence must name unavailable_reason when actual is unavailable") } + } out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} if out.MissingEvidence == nil { out.MissingEvidence = []string{} } if report.Status == "available" { diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 600e1dc..2565bab 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -301,11 +301,43 @@ func TestCostExplainSuppliedTotalsAndPercentages(t *testing.T) { unavailable:=base unavailable.Status="unavailable" unavailable.Actual,unavailable.AbsoluteVariance,unavailable.Residual,unavailable.PercentageVariance=nil,nil,nil,nil + unavailable.UnavailableReason="invoice not delivered" + unavailable.MissingEvidence=[]string{"actual cost: invoice not delivered"} report,err=Explain(context.Background(),&unavailable) require.NoError(t,err) require.Nil(t,report.VariancePercent) } +func TestCostExplainUnavailableReasonEvidence(t *testing.T) { + f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) + base,err := Diff(context.Background(),&f,nil) + require.NoError(t,err) + for _, tc := range []struct{name,reason string; evidence []string; errorField string}{ + {"missing reason","",[]string{"actual cost unavailable"},"unavailable_reason"}, + {"blank reason"," \t ",[]string{"actual cost: \t "},"unavailable_reason"}, + {"missing evidence","invoice not delivered",nil,"missing_evidence"}, + {"unrelated evidence","invoice not delivered",[]string{"actual cost: ledger pending"},"missing_evidence"}, + {"blank evidence","invoice not delivered",[]string{" "},"missing_evidence"}, + {"valid supplied reason","invoice not delivered",[]string{"actual cost: invoice not delivered"},""}, + } { + t.Run(tc.name,func(t *testing.T){ + result := *base + result.UnavailableReason = tc.reason + result.MissingEvidence = tc.evidence + report,err := Explain(context.Background(),&result) + if tc.errorField != "" { + require.ErrorContains(t,err,tc.errorField) + return + } + require.NoError(t,err) + require.Equal(t,tc.reason,report.UnavailableReason) + require.Contains(t,report.MissingEvidence,"actual cost: "+tc.reason) + require.Nil(t,report.Actual) + require.Nil(t,report.VariancePercent) + }) + } +} + func TestCostExplainInvalidReconciliation(t *testing.T) { result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} _,err:=Explain(context.Background(),&result) From 4d17343410a3218eb2c2a26dc4b123a61e8468c0 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 20:08:46 -0400 Subject: [PATCH 11/17] Explain cost variance and root cause from the CLI: variance-engine --- pkg/variance/v1/unavailable_reason_test.go | 52 ++++++++++++++++++++++ pkg/variance/v1/variance.go | 3 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 pkg/variance/v1/unavailable_reason_test.go diff --git a/pkg/variance/v1/unavailable_reason_test.go b/pkg/variance/v1/unavailable_reason_test.go new file mode 100644 index 0000000..47f7e6a --- /dev/null +++ b/pkg/variance/v1/unavailable_reason_test.go @@ -0,0 +1,52 @@ +package v1_test + +import ( + "context" + "strings" + "testing" + + "github.com/IntelIP/ProfitCtl/cmd" + cost "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variance "github.com/IntelIP/ProfitCtl/pkg/variance/v1" +) + +func TestCompareUnavailableReasonMatchesExplain(t *testing.T) { + e := cost.Evidence{ + Kind: cost.EvidenceUserSupplied, Measurement: cost.MeasurementDeclared, + Source: cost.SourceReference{Type: cost.SourceUserSupplied, ArtifactIdentity: "fixture://forecast", CapturedAt: "2026-08-01T00:00:00Z"}, + Confidence: cost.ConfidenceLow, ConfidenceRationale: "locally supplied forecast", + } + forecast := cost.CostObservation{ + SchemaVersion: cost.SchemaVersion, ID: "forecast", + DriverIDs: []string{"driver"}, + Window: cost.TimeWindow{Start: "2026-07-01T00:00:00Z", End: "2026-08-01T00:00:00Z"}, + Quantity: cost.Quantity{Value: 1, Unit: "command"}, + UnitPrice: cost.UnitPrice{Amount: cost.Money{Amount: 20, Currency: "USD"}, Per: cost.Quantity{Value: 1, Unit: "command"}}, + TotalCost: cost.Money{Amount: 20, Currency: "USD"}, + Dimensions: cost.Dimensions{Workload: "worker"}, + Evidence: cost.ClaimEvidence{Quantity: e, UnitPrice: e, TotalCost: e}, + } + for _, reason := range []string{"", " \t\n "} { + t.Run("invalid_"+strings.ReplaceAll(reason, "\n", "newline"), func(t *testing.T) { + _, err := variance.Compare(variance.Input{Forecast: forecast, UnavailableReason: reason}) + if err == nil || !strings.Contains(err.Error(), "unavailable_reason") { + t.Fatalf("expected actionable unavailable_reason error for %q, got %v", reason, err) + } + }) + } + reason := " invoice not delivered " + result, err := variance.Compare(variance.Input{Forecast: forecast, UnavailableReason: reason}) + if err != nil { + t.Fatal(err) + } + if result.UnavailableReason != reason || len(result.MissingEvidence) != 1 || !strings.Contains(result.MissingEvidence[0], reason) { + t.Fatalf("supplied reason not preserved and visible: %+v", result) + } + explanation, err := cmd.Explain(context.Background(), &result) + if err != nil { + t.Fatalf("Compare result must be explainable: %v", err) + } + if explanation.UnavailableReason != reason || len(explanation.MissingEvidence) != 1 || !strings.Contains(explanation.MissingEvidence[0], reason) { + t.Fatalf("explanation lost supplied reason: %+v", explanation) + } +} diff --git a/pkg/variance/v1/variance.go b/pkg/variance/v1/variance.go index c194690..6032955 100644 --- a/pkg/variance/v1/variance.go +++ b/pkg/variance/v1/variance.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "sort" + "strings" "time" cost "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" @@ -88,7 +89,7 @@ func Compare(in Input) (Result, error) { } result := Result{SchemaVersion: SchemaVersion, Status: "unavailable", Window: in.Forecast.Window, ForecastObservationID: in.Forecast.ID, Forecast: in.Forecast.TotalCost, RoundingTolerance: Rounding, Contributions: []Contribution{}, MissingEvidence: []string{}, Sources: []cost.SourceReference{in.Forecast.Evidence.TotalCost.Source}} if in.Actual == nil { - if in.UnavailableReason == "" { + if strings.TrimSpace(in.UnavailableReason) == "" { return Result{}, errors.New("actual is unavailable: unavailable_reason is required") } if len(in.Drivers) > 0 || len(in.Receipts) > 0 { From d8b11c7ea07b2872a95e42f533f78068a30e2f33 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 20:56:51 -0400 Subject: [PATCH 12/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_explain.go | 5 +++-- cmd/cost_variance_test.go | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 2eaf5eb..80304d1 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -73,9 +73,10 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er if strings.TrimSpace(report.UnavailableReason) == "" { return nil, errors.New("unavailable_reason is required when actual is unavailable") } visible := false for _, evidence := range report.MissingEvidence { - if strings.Contains(evidence, report.UnavailableReason) { visible = true; break } + detail := strings.TrimLeft(evidence, " \t\n\r") + if strings.HasPrefix(detail, "actual cost:") && strings.Contains(detail[len("actual cost:"):], report.UnavailableReason) { visible = true; break } } - if !visible { return nil, errors.New("missing_evidence must name unavailable_reason when actual is unavailable") } + if !visible { return nil, errors.New("missing_evidence must identify actual cost and name unavailable_reason when actual is unavailable") } } out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} if out.MissingEvidence == nil { out.MissingEvidence = []string{} } diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 2565bab..f127974 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -338,6 +338,22 @@ func TestCostExplainUnavailableReasonEvidence(t *testing.T) { } } +func TestCostExplainMissingActualEvidenceIdentity(t *testing.T) { + f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) + result,err := Diff(context.Background(),&f,nil) + require.NoError(t,err) + result.UnavailableReason = "invoice not delivered" + result.MissingEvidence = []string{"forecast invoice not delivered"} + _,err = Explain(context.Background(),result) + require.ErrorContains(t,err,"missing_evidence") + require.ErrorContains(t,err,"actual cost") + + result.MissingEvidence = []string{"actual cost: invoice not delivered"} + report,err := Explain(context.Background(),result) + require.NoError(t,err) + require.Contains(t,report.MissingEvidence,"actual cost: invoice not delivered") +} + func TestCostExplainInvalidReconciliation(t *testing.T) { result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} _,err:=Explain(context.Background(),&result) From 89e528a4e36548cfd711ba27c3100678296e9fe8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 21:30:29 -0400 Subject: [PATCH 13/17] Explain cost variance and root cause from the CLI: variance-cli --- cmd/cost_explain.go | 8 ++++++- cmd/cost_variance_test.go | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 80304d1..404cc3a 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" @@ -63,7 +64,7 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er } else { expected := 100 * report.AbsoluteVariance.Amount / report.Forecast.Amount if math.IsNaN(expected) || math.IsInf(expected, 0) { return nil, errors.New("percentage_variance overflows for supplied amounts") } - if report.PercentageVariance == nil || math.Abs(*report.PercentageVariance-expected) > 100*variancev1.Rounding/math.Abs(report.Forecast.Amount) { + if report.PercentageVariance == nil || math.Abs(*report.PercentageVariance-expected) > variancev1.Rounding { return nil, fmt.Errorf("percentage_variance must equal 100 * absolute_variance.amount / forecast.amount (%.12g) within rounding tolerance", expected) } } @@ -104,6 +105,11 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er out.DriverContributions = append(out.DriverContributions, DriverContribution{Contribution: c, RemedialActions: []string{action}}) } if math.Abs(sum-report.AbsoluteVariance.Amount) > variancev1.Rounding { return nil, errors.New("driver contributions and residual do not reconcile") } + sort.SliceStable(out.DriverContributions, func(i, j int) bool { + x, y := out.DriverContributions[i].Contribution, out.DriverContributions[j].Contribution + if math.Abs(x.Amount.Amount) == math.Abs(y.Amount.Amount) { return x.DriverID < y.DriverID } + return math.Abs(x.Amount.Amount) > math.Abs(y.Amount.Amount) + }) if math.Abs(report.Residual.Amount) > variancev1.Rounding || len(report.Contributions) == 0 { out.ConfidenceScore = math.Min(out.ConfidenceScore, .35) } switch { case out.ConfidenceScore >= .85: out.Confidence = costv1.ConfidenceHigh; case out.ConfidenceScore >= .55: out.Confidence = costv1.ConfidenceMedium; default: out.Confidence = costv1.ConfidenceLow } } diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index f127974..764eef4 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -308,6 +308,54 @@ func TestCostExplainSuppliedTotalsAndPercentages(t *testing.T) { require.Nil(t,report.VariancePercent) } +func TestCostExplainTinyForecastPercentage(t *testing.T) { + f,_ := varianceFixture(t,"forecast",1,0.000001,0.000001,costv1.DriverFixed) + a,_ := varianceFixture(t,"actual",2,0.000001,0.000002,costv1.DriverFixed) + result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) + require.NoError(t,err) + require.Equal(t,100.0,*result.PercentageVariance) + supplied := result + zero := 0.0 + supplied.PercentageVariance = &zero + _,err = Explain(context.Background(),&supplied) + require.ErrorContains(t,err,"percentage_variance") + correct := 100.0 + supplied.PercentageVariance = &correct + report,err := Explain(context.Background(),&supplied) + require.NoError(t,err) + require.Equal(t,100.0,*report.VariancePercent) +} + +func TestCostExplainRanksSuppliedContributions(t *testing.T) { + f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) + a,_ := varianceFixture(t,"actual",20,2,40,costv1.DriverFixed) + result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) + require.NoError(t,err) + source := costv1.SourceReference{Type:costv1.SourceRuntimeLedger,ArtifactIdentity:"receipt://small",CapturedAt:"2026-08-01T00:00:00Z"} + receipt := variancev1.Receipt{DriverID:"small",Kind:"deployment",ForecastObservationID:f.ID,ActualObservationID:a.ID,Source:source} + small := variancev1.Contribution{DriverID:"small",Kind:costv1.DriverCadence,Amount:costv1.Money{Amount:-5,Currency:"USD"},Confidence:costv1.ConfidenceLow,ConfidenceScore:.35,Attribution:"unknown",MissingEvidence:[]string{"exact delivery receipt"},Sources:[]costv1.SourceReference{source},Receipts:[]variancev1.Receipt{receipt}} + large := variancev1.Contribution{DriverID:"large",Kind:costv1.DriverVariable,Amount:costv1.Money{Amount:25,Currency:"USD"},Confidence:costv1.ConfidenceLow,ConfidenceScore:.35,Attribution:"modeled",Sources:[]costv1.SourceReference{{Type:costv1.SourceSyntheticFixture,ArtifactIdentity:"fixture://large",CapturedAt:source.CapturedAt}}} + result.Contributions = []variancev1.Contribution{small,large} + result.Residual.Amount = 0 + report,err := Explain(context.Background(),&result) + require.NoError(t,err) + require.Equal(t,[]string{"large","small"},[]string{report.DriverContributions[0].DriverID,report.DriverContributions[1].DriverID}) + require.Equal(t,large,report.DriverContributions[0].Contribution) + require.Equal(t,small,report.DriverContributions[1].Contribution) + require.Contains(t,report.DriverContributions[0].RemedialActions[0],"request volume") + require.Contains(t,report.DriverContributions[1].RemedialActions[0],"polling interval") + require.Equal(t,[]variancev1.Contribution{small,large},result.Contributions) + + result.Contributions = []variancev1.Contribution{small,large} + result.Contributions[0].Amount.Amount = -10 + result.Contributions[1].Amount.Amount = 10 + result.Residual.Amount = 20 + report,err = Explain(context.Background(),&result) + require.NoError(t,err) + require.Equal(t,[]string{"large","small"},[]string{report.DriverContributions[0].DriverID,report.DriverContributions[1].DriverID}) + require.Equal(t,"small",result.Contributions[0].DriverID) +} + func TestCostExplainUnavailableReasonEvidence(t *testing.T) { f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) base,err := Diff(context.Background(),&f,nil) From 9a56442c7d4c2f574385d99d6f6dca83788617ad Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 21:47:17 -0400 Subject: [PATCH 14/17] style: format cost variance CLI implementation --- cmd/cost_diff.go | 188 ++++++---- cmd/cost_explain.go | 295 +++++++++------ cmd/cost_variance_test.go | 738 ++++++++++++++++++++------------------ 3 files changed, 680 insertions(+), 541 deletions(-) diff --git a/cmd/cost_diff.go b/cmd/cost_diff.go index 4affe57..d81d00b 100644 --- a/cmd/cost_diff.go +++ b/cmd/cost_diff.go @@ -1,18 +1,18 @@ package cmd import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" - costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" - variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" - "github.com/spf13/cobra" + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" ) var costDiffCmd = newCostDiffCommand() @@ -22,82 +22,122 @@ func init() { rootCmd.AddCommand(costDiffCmd) } // Diff compares two observations without inventing driver evidence. Use Compare // with a variancev1.Input when driver pairs, exchange rates or receipts exist. func Diff(ctx context.Context, forecast, actual *costv1.CostObservation) (*variancev1.Result, error) { - if err := ctx.Err(); err != nil { return nil, err } - if forecast == nil { return nil, errors.New("forecast observation is required") } - in := variancev1.Input{Forecast: *forecast, Actual: actual} - if actual == nil { in.UnavailableReason = "actual observation not supplied" } - result, err := variancev1.Compare(in) - if err != nil { return nil, err } - return &result, nil + if err := ctx.Err(); err != nil { + return nil, err + } + if forecast == nil { + return nil, errors.New("forecast observation is required") + } + in := variancev1.Input{Forecast: *forecast, Actual: actual} + if actual == nil { + in.UnavailableReason = "actual observation not supplied" + } + result, err := variancev1.Compare(in) + if err != nil { + return nil, err + } + return &result, nil } func newCostDiffCommand() *cobra.Command { - command := &cobra.Command{Use: "diff", Short: "Compare local forecast and actual costs", Args: cobra.NoArgs} - command.Flags().StringP("input", "i", "", "Variance input JSON (observations, driver pairs, exchange rates and receipts)") - command.Flags().String("forecast", "", "Forecast CostObservation JSON (without driver pairs)") - command.Flags().String("actual", "", "Actual CostObservation JSON (without driver pairs)") - command.Flags().StringP("output", "o", "", "Optional JSON output file (also prints to stdout)") - command.RunE = func(cmd *cobra.Command, _ []string) error { - input, _ := cmd.Flags().GetString("input") - forecastPath, _ := cmd.Flags().GetString("forecast") - actualPath, _ := cmd.Flags().GetString("actual") - output, _ := cmd.Flags().GetString("output") - if (input == "" && (forecastPath == "" || actualPath == "")) || (input != "" && (forecastPath != "" || actualPath != "")) { - return wrapExit(2, errors.New("provide --input or both --forecast and --actual")) - } - if err := rejectVarianceOutputAlias(output, input, forecastPath, actualPath); err != nil { return wrapExit(2, err) } - var in variancev1.Input - if input != "" { - if err := readVarianceJSON(input, &in); err != nil { return wrapExit(2, fmt.Errorf("input: %w", err)) } - } else { - if err := readVarianceJSON(forecastPath, &in.Forecast); err != nil { return wrapExit(2, fmt.Errorf("forecast: %w", err)) } - var actual costv1.CostObservation - if err := readVarianceJSON(actualPath, &actual); err != nil { return wrapExit(2, fmt.Errorf("actual: %w", err)) } - in.Actual = &actual - } - if err := cmd.Context().Err(); err != nil { return wrapExit(2, err) } - report, err := variancev1.Compare(in) - if err != nil { return wrapExit(2, fmt.Errorf("diff: %w", err)) } - return emitVarianceJSON(cmd, output, report) - } - return command + command := &cobra.Command{Use: "diff", Short: "Compare local forecast and actual costs", Args: cobra.NoArgs} + command.Flags().StringP("input", "i", "", "Variance input JSON (observations, driver pairs, exchange rates and receipts)") + command.Flags().String("forecast", "", "Forecast CostObservation JSON (without driver pairs)") + command.Flags().String("actual", "", "Actual CostObservation JSON (without driver pairs)") + command.Flags().StringP("output", "o", "", "Optional JSON output file (also prints to stdout)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + input, _ := cmd.Flags().GetString("input") + forecastPath, _ := cmd.Flags().GetString("forecast") + actualPath, _ := cmd.Flags().GetString("actual") + output, _ := cmd.Flags().GetString("output") + if (input == "" && (forecastPath == "" || actualPath == "")) || (input != "" && (forecastPath != "" || actualPath != "")) { + return wrapExit(2, errors.New("provide --input or both --forecast and --actual")) + } + if err := rejectVarianceOutputAlias(output, input, forecastPath, actualPath); err != nil { + return wrapExit(2, err) + } + var in variancev1.Input + if input != "" { + if err := readVarianceJSON(input, &in); err != nil { + return wrapExit(2, fmt.Errorf("input: %w", err)) + } + } else { + if err := readVarianceJSON(forecastPath, &in.Forecast); err != nil { + return wrapExit(2, fmt.Errorf("forecast: %w", err)) + } + var actual costv1.CostObservation + if err := readVarianceJSON(actualPath, &actual); err != nil { + return wrapExit(2, fmt.Errorf("actual: %w", err)) + } + in.Actual = &actual + } + if err := cmd.Context().Err(); err != nil { + return wrapExit(2, err) + } + report, err := variancev1.Compare(in) + if err != nil { + return wrapExit(2, fmt.Errorf("diff: %w", err)) + } + return emitVarianceJSON(cmd, output, report) + } + return command } func readVarianceJSON(path string, target any) error { - data, err := os.ReadFile(path) - if err != nil { return err } - return strictVarianceJSON(data, target) + data, err := os.ReadFile(path) + if err != nil { + return err + } + return strictVarianceJSON(data, target) } func strictVarianceJSON(data []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { return err } - if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { - if err == nil { return errors.New("multiple JSON values are not allowed") } - return err - } - return nil + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err + } + return nil } func rejectVarianceOutputAlias(output string, inputs ...string) error { - if strings.TrimSpace(output) == "" { return nil } - for _, input := range inputs { - if input == "" { continue } - alias, err := pathsAlias(output, input) - if err != nil { return fmt.Errorf("compare output paths: %w", err) } - if alias { return fmt.Errorf("--output must not reference input %q", input) } - } - return nil + if strings.TrimSpace(output) == "" { + return nil + } + for _, input := range inputs { + if input == "" { + continue + } + alias, err := pathsAlias(output, input) + if err != nil { + return fmt.Errorf("compare output paths: %w", err) + } + if alias { + return fmt.Errorf("--output must not reference input %q", input) + } + } + return nil } func emitVarianceJSON(cmd *cobra.Command, output string, report any) error { - payload, err := json.MarshalIndent(report, "", " ") - if err != nil { return wrapExit(3, fmt.Errorf("encode variance report: %w", err)) } - payload = append(payload, '\n') - if strings.TrimSpace(output) != "" { - if err := os.WriteFile(output, payload, 0600); err != nil { return wrapExit(3, fmt.Errorf("write output: %w", err)) } - } - if _, err := cmd.OutOrStdout().Write(payload); err != nil { return wrapExit(3, fmt.Errorf("write stdout: %w", err)) } - return nil + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { + return wrapExit(3, fmt.Errorf("encode variance report: %w", err)) + } + payload = append(payload, '\n') + if strings.TrimSpace(output) != "" { + if err := os.WriteFile(output, payload, 0600); err != nil { + return wrapExit(3, fmt.Errorf("write output: %w", err)) + } + } + if _, err := cmd.OutOrStdout().Write(payload); err != nil { + return wrapExit(3, fmt.Errorf("write stdout: %w", err)) + } + return nil } diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 404cc3a..e5565e6 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -1,119 +1,176 @@ package cmd import ( - "context" - "errors" - "fmt" - "math" - "sort" - "strings" + "context" + "errors" + "fmt" + "math" + "sort" + "strings" - costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" - variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" - "github.com/spf13/cobra" + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" ) // DriverContribution preserves the modeled decomposition and adds non-causal // suggestions. Receipts and source references remain on the embedded contribution. type DriverContribution struct { - variancev1.Contribution - RemedialActions []string `json:"remedial_actions"` + variancev1.Contribution + RemedialActions []string `json:"remedial_actions"` } type ExplainReport struct { - SchemaVersion string `json:"schema_version"` - Status string `json:"status"` - Window costv1.TimeWindow `json:"window"` - ForecastObservationID string `json:"forecast_observation_id"` - ActualObservationID string `json:"actual_observation_id,omitempty"` - UnavailableReason string `json:"unavailable_reason,omitempty"` - Confidence costv1.Confidence `json:"confidence"` - ConfidenceScore float64 `json:"confidence_score"` - Forecast costv1.Money `json:"forecast"` - Actual *costv1.Money `json:"actual"` - VarianceAbsolute *costv1.Money `json:"variance_absolute"` - VariancePercent *float64 `json:"variance_percent"` - DriverContributions []DriverContribution `json:"driver_contributions"` - Residual *costv1.Money `json:"residual"` - MissingEvidence []string `json:"missing_evidence"` - Sources []costv1.SourceReference `json:"sources"` + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Window costv1.TimeWindow `json:"window"` + ForecastObservationID string `json:"forecast_observation_id"` + ActualObservationID string `json:"actual_observation_id,omitempty"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + Confidence costv1.Confidence `json:"confidence"` + ConfidenceScore float64 `json:"confidence_score"` + Forecast costv1.Money `json:"forecast"` + Actual *costv1.Money `json:"actual"` + VarianceAbsolute *costv1.Money `json:"variance_absolute"` + VariancePercent *float64 `json:"variance_percent"` + DriverContributions []DriverContribution `json:"driver_contributions"` + Residual *costv1.Money `json:"residual"` + MissingEvidence []string `json:"missing_evidence"` + Sources []costv1.SourceReference `json:"sources"` } // Explain ranks existing modeled evidence; it never asserts delivery causation. func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, error) { - if err := ctx.Err(); err != nil { return nil, err } - if report == nil || report.SchemaVersion != variancev1.SchemaVersion || (report.Status != "available" && report.Status != "unavailable") { return nil, errors.New("valid cost variance result is required") } - for _, field := range []struct { name string; money *costv1.Money }{{"forecast", &report.Forecast}, {"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { - if field.money != nil && (math.IsNaN(field.money.Amount) || math.IsInf(field.money.Amount, 0)) { return nil, fmt.Errorf("%s.amount must be finite", field.name) } - } - if report.PercentageVariance != nil && (math.IsNaN(*report.PercentageVariance) || math.IsInf(*report.PercentageVariance, 0)) { return nil, errors.New("percentage_variance must be finite") } - if report.Status == "unavailable" && report.PercentageVariance != nil { return nil, errors.New("percentage_variance must be null when actual is unavailable") } - if report.Status == "available" && (report.Actual == nil || report.AbsoluteVariance == nil || report.Residual == nil) { return nil, errors.New("available result lacks actual, variance or residual") } - if report.Status == "available" { - for _, field := range []struct { name string; money *costv1.Money }{{"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { - if field.money.Currency != report.Forecast.Currency { return nil, fmt.Errorf("%s currency %q differs from forecast currency %q", field.name, field.money.Currency, report.Forecast.Currency) } - } - } - if report.Status == "available" { - delta := report.Actual.Amount - report.Forecast.Amount - if math.IsNaN(delta) || math.IsInf(delta, 0) || math.Abs(delta-report.AbsoluteVariance.Amount) > variancev1.Rounding { - return nil, errors.New("absolute_variance.amount must equal actual.amount minus forecast.amount within rounding tolerance") - } - if report.Forecast.Amount == 0 { - if report.PercentageVariance != nil { return nil, errors.New("percentage_variance must be null when forecast.amount is zero") } - } else { - expected := 100 * report.AbsoluteVariance.Amount / report.Forecast.Amount - if math.IsNaN(expected) || math.IsInf(expected, 0) { return nil, errors.New("percentage_variance overflows for supplied amounts") } - if report.PercentageVariance == nil || math.Abs(*report.PercentageVariance-expected) > variancev1.Rounding { - return nil, fmt.Errorf("percentage_variance must equal 100 * absolute_variance.amount / forecast.amount (%.12g) within rounding tolerance", expected) - } - } - } - if report.Status == "unavailable" && (report.Actual != nil || report.AbsoluteVariance != nil || report.Residual != nil || len(report.Contributions) != 0) { return nil, errors.New("unavailable result cannot contain measured variance") } - if report.Status == "unavailable" { - if strings.TrimSpace(report.UnavailableReason) == "" { return nil, errors.New("unavailable_reason is required when actual is unavailable") } - visible := false - for _, evidence := range report.MissingEvidence { - detail := strings.TrimLeft(evidence, " \t\n\r") - if strings.HasPrefix(detail, "actual cost:") && strings.Contains(detail[len("actual cost:"):], report.UnavailableReason) { visible = true; break } - } - if !visible { return nil, errors.New("missing_evidence must identify actual cost and name unavailable_reason when actual is unavailable") } - } - out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} - if out.MissingEvidence == nil { out.MissingEvidence = []string{} } - if report.Status == "available" { - out.ConfidenceScore = 1 - // Result has sources but no total-cost claim confidence; provenance cannot establish it. - out.ConfidenceScore = .35 - for _, name := range []string{"forecast", "actual"} { - out.MissingEvidence = append(out.MissingEvidence, name+" total cost: high-confidence claim evidence unavailable") - } - sum := report.Residual.Amount - for i, c := range report.Contributions { - if math.IsNaN(c.Amount.Amount) || math.IsInf(c.Amount.Amount, 0) { return nil, fmt.Errorf("contributions[%d].amount.amount must be finite", i) } - if c.Amount.Currency != report.Forecast.Currency || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { return nil, errors.New("invalid driver contribution") } - sum += c.Amount.Amount - out.ConfidenceScore = math.Min(out.ConfidenceScore, c.ConfidenceScore) - action := "Review workload measurements and cost evidence before adjusting this driver" - switch c.Kind { - case costv1.DriverCadence: action = "Review polling interval and measure command volume before changing cadence" - case costv1.DriverConcurrency: action = "Review replica count and utilization before resizing" - case costv1.DriverVariable: action = "Review request volume and per-unit price before tuning usage" - case costv1.DriverUptime: action = "Review measured uptime and scheduling before changing runtime" - case costv1.DriverFixed: action = "Review contracted unit price before renegotiating" - } - out.DriverContributions = append(out.DriverContributions, DriverContribution{Contribution: c, RemedialActions: []string{action}}) - } - if math.Abs(sum-report.AbsoluteVariance.Amount) > variancev1.Rounding { return nil, errors.New("driver contributions and residual do not reconcile") } - sort.SliceStable(out.DriverContributions, func(i, j int) bool { - x, y := out.DriverContributions[i].Contribution, out.DriverContributions[j].Contribution - if math.Abs(x.Amount.Amount) == math.Abs(y.Amount.Amount) { return x.DriverID < y.DriverID } - return math.Abs(x.Amount.Amount) > math.Abs(y.Amount.Amount) - }) - if math.Abs(report.Residual.Amount) > variancev1.Rounding || len(report.Contributions) == 0 { out.ConfidenceScore = math.Min(out.ConfidenceScore, .35) } - switch { case out.ConfidenceScore >= .85: out.Confidence = costv1.ConfidenceHigh; case out.ConfidenceScore >= .55: out.Confidence = costv1.ConfidenceMedium; default: out.Confidence = costv1.ConfidenceLow } - } - return out, nil + if err := ctx.Err(); err != nil { + return nil, err + } + if report == nil || report.SchemaVersion != variancev1.SchemaVersion || (report.Status != "available" && report.Status != "unavailable") { + return nil, errors.New("valid cost variance result is required") + } + for _, field := range []struct { + name string + money *costv1.Money + }{{"forecast", &report.Forecast}, {"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { + if field.money != nil && (math.IsNaN(field.money.Amount) || math.IsInf(field.money.Amount, 0)) { + return nil, fmt.Errorf("%s.amount must be finite", field.name) + } + } + if report.PercentageVariance != nil && (math.IsNaN(*report.PercentageVariance) || math.IsInf(*report.PercentageVariance, 0)) { + return nil, errors.New("percentage_variance must be finite") + } + if report.Status == "unavailable" && report.PercentageVariance != nil { + return nil, errors.New("percentage_variance must be null when actual is unavailable") + } + if report.Status == "available" && (report.Actual == nil || report.AbsoluteVariance == nil || report.Residual == nil) { + return nil, errors.New("available result lacks actual, variance or residual") + } + if report.Status == "available" { + for _, field := range []struct { + name string + money *costv1.Money + }{{"actual", report.Actual}, {"absolute_variance", report.AbsoluteVariance}, {"residual", report.Residual}} { + if field.money.Currency != report.Forecast.Currency { + return nil, fmt.Errorf("%s currency %q differs from forecast currency %q", field.name, field.money.Currency, report.Forecast.Currency) + } + } + } + if report.Status == "available" { + delta := report.Actual.Amount - report.Forecast.Amount + if math.IsNaN(delta) || math.IsInf(delta, 0) || math.Abs(delta-report.AbsoluteVariance.Amount) > variancev1.Rounding { + return nil, errors.New("absolute_variance.amount must equal actual.amount minus forecast.amount within rounding tolerance") + } + if report.Forecast.Amount == 0 { + if report.PercentageVariance != nil { + return nil, errors.New("percentage_variance must be null when forecast.amount is zero") + } + } else { + expected := 100 * report.AbsoluteVariance.Amount / report.Forecast.Amount + if math.IsNaN(expected) || math.IsInf(expected, 0) { + return nil, errors.New("percentage_variance overflows for supplied amounts") + } + if report.PercentageVariance == nil || math.Abs(*report.PercentageVariance-expected) > variancev1.Rounding { + return nil, fmt.Errorf("percentage_variance must equal 100 * absolute_variance.amount / forecast.amount (%.12g) within rounding tolerance", expected) + } + } + } + if report.Status == "unavailable" && (report.Actual != nil || report.AbsoluteVariance != nil || report.Residual != nil || len(report.Contributions) != 0) { + return nil, errors.New("unavailable result cannot contain measured variance") + } + if report.Status == "unavailable" { + if strings.TrimSpace(report.UnavailableReason) == "" { + return nil, errors.New("unavailable_reason is required when actual is unavailable") + } + visible := false + for _, evidence := range report.MissingEvidence { + detail := strings.TrimLeft(evidence, " \t\n\r") + if strings.HasPrefix(detail, "actual cost:") && strings.Contains(detail[len("actual cost:"):], report.UnavailableReason) { + visible = true + break + } + } + if !visible { + return nil, errors.New("missing_evidence must identify actual cost and name unavailable_reason when actual is unavailable") + } + } + out := &ExplainReport{SchemaVersion: report.SchemaVersion, Status: report.Status, Window: report.Window, ForecastObservationID: report.ForecastObservationID, ActualObservationID: report.ActualObservationID, UnavailableReason: report.UnavailableReason, Forecast: report.Forecast, Actual: report.Actual, VarianceAbsolute: report.AbsoluteVariance, VariancePercent: report.PercentageVariance, Residual: report.Residual, DriverContributions: []DriverContribution{}, MissingEvidence: report.MissingEvidence, Sources: report.Sources, Confidence: costv1.ConfidenceLow, ConfidenceScore: 0} + if out.MissingEvidence == nil { + out.MissingEvidence = []string{} + } + if report.Status == "available" { + out.ConfidenceScore = 1 + // Result has sources but no total-cost claim confidence; provenance cannot establish it. + out.ConfidenceScore = .35 + for _, name := range []string{"forecast", "actual"} { + out.MissingEvidence = append(out.MissingEvidence, name+" total cost: high-confidence claim evidence unavailable") + } + sum := report.Residual.Amount + for i, c := range report.Contributions { + if math.IsNaN(c.Amount.Amount) || math.IsInf(c.Amount.Amount, 0) { + return nil, fmt.Errorf("contributions[%d].amount.amount must be finite", i) + } + if c.Amount.Currency != report.Forecast.Currency || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { + return nil, errors.New("invalid driver contribution") + } + sum += c.Amount.Amount + out.ConfidenceScore = math.Min(out.ConfidenceScore, c.ConfidenceScore) + action := "Review workload measurements and cost evidence before adjusting this driver" + switch c.Kind { + case costv1.DriverCadence: + action = "Review polling interval and measure command volume before changing cadence" + case costv1.DriverConcurrency: + action = "Review replica count and utilization before resizing" + case costv1.DriverVariable: + action = "Review request volume and per-unit price before tuning usage" + case costv1.DriverUptime: + action = "Review measured uptime and scheduling before changing runtime" + case costv1.DriverFixed: + action = "Review contracted unit price before renegotiating" + } + out.DriverContributions = append(out.DriverContributions, DriverContribution{Contribution: c, RemedialActions: []string{action}}) + } + if math.Abs(sum-report.AbsoluteVariance.Amount) > variancev1.Rounding { + return nil, errors.New("driver contributions and residual do not reconcile") + } + sort.SliceStable(out.DriverContributions, func(i, j int) bool { + x, y := out.DriverContributions[i].Contribution, out.DriverContributions[j].Contribution + if math.Abs(x.Amount.Amount) == math.Abs(y.Amount.Amount) { + return x.DriverID < y.DriverID + } + return math.Abs(x.Amount.Amount) > math.Abs(y.Amount.Amount) + }) + if math.Abs(report.Residual.Amount) > variancev1.Rounding || len(report.Contributions) == 0 { + out.ConfidenceScore = math.Min(out.ConfidenceScore, .35) + } + switch { + case out.ConfidenceScore >= .85: + out.Confidence = costv1.ConfidenceHigh + case out.ConfidenceScore >= .55: + out.Confidence = costv1.ConfidenceMedium + default: + out.Confidence = costv1.ConfidenceLow + } + } + return out, nil } var costExplainCmd = newCostExplainCommand() @@ -121,19 +178,27 @@ var costExplainCmd = newCostExplainCommand() func init() { rootCmd.AddCommand(costExplainCmd) } func newCostExplainCommand() *cobra.Command { - command := &cobra.Command{Use: "explain", Short: "Rank evidenced cost variance drivers", Args: cobra.NoArgs} - command.Flags().StringP("input", "i", "", "Diff result JSON file") - command.Flags().StringP("output", "o", "", "Optional JSON output file (also prints to stdout)") - command.RunE = func(cmd *cobra.Command, _ []string) error { - input, _ := cmd.Flags().GetString("input") - output, _ := cmd.Flags().GetString("output") - if strings.TrimSpace(input) == "" { return wrapExit(2, errors.New("--input is required")) } - if err := rejectVarianceOutputAlias(output, input); err != nil { return wrapExit(2, err) } - var diff variancev1.Result - if err := readVarianceJSON(input, &diff); err != nil { return wrapExit(2, fmt.Errorf("decode diff result: %w", err)) } - report, err := Explain(cmd.Context(), &diff) - if err != nil { return wrapExit(2, fmt.Errorf("explain: %w", err)) } - return emitVarianceJSON(cmd, output, report) - } - return command + command := &cobra.Command{Use: "explain", Short: "Rank evidenced cost variance drivers", Args: cobra.NoArgs} + command.Flags().StringP("input", "i", "", "Diff result JSON file") + command.Flags().StringP("output", "o", "", "Optional JSON output file (also prints to stdout)") + command.RunE = func(cmd *cobra.Command, _ []string) error { + input, _ := cmd.Flags().GetString("input") + output, _ := cmd.Flags().GetString("output") + if strings.TrimSpace(input) == "" { + return wrapExit(2, errors.New("--input is required")) + } + if err := rejectVarianceOutputAlias(output, input); err != nil { + return wrapExit(2, err) + } + var diff variancev1.Result + if err := readVarianceJSON(input, &diff); err != nil { + return wrapExit(2, fmt.Errorf("decode diff result: %w", err)) + } + report, err := Explain(cmd.Context(), &diff) + if err != nil { + return wrapExit(2, fmt.Errorf("explain: %w", err)) + } + return emitVarianceJSON(cmd, output, report) + } + return command } diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 764eef4..7e19516 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -1,409 +1,443 @@ package cmd import ( - "bytes" - "context" - "encoding/json" - "math" - "os" - "path/filepath" - "testing" + "bytes" + "context" + "encoding/json" + "math" + "os" + "path/filepath" + "testing" - costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" - variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" + costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" ) func varianceFixture(t *testing.T, id string, quantity, price, total float64, kind costv1.DriverKind) (costv1.CostObservation, costv1.CostDriver) { - t.Helper() - source := costv1.SourceReference{Type: costv1.SourceSyntheticFixture, ArtifactIdentity: "fixture://variance", CapturedAt: "2026-08-01T00:00:00Z"} - e := costv1.Evidence{Kind: costv1.EvidencePredicted, Measurement: costv1.MeasurementSynthetic, Source: source, Confidence: costv1.ConfidenceLow, ConfidenceRationale: "deterministic fixture"} - window := costv1.TimeWindow{Start:"2026-07-01T00:00:00Z", End:"2026-08-01T00:00:00Z"} - q := costv1.Quantity{Value:quantity, Unit:"command"} - p := costv1.UnitPrice{Amount:costv1.Money{Amount:price, Currency:"USD"}, Per:costv1.Quantity{Value:1, Unit:"command"}} - o := costv1.CostObservation{SchemaVersion:costv1.SchemaVersion, ID:id, DriverIDs:[]string{"driver"}, Window:window, Quantity:q, UnitPrice:p, TotalCost:costv1.Money{Amount:total, Currency:"USD"}, Dimensions:costv1.Dimensions{Workload:"worker"}, Evidence:costv1.ClaimEvidence{Quantity:e, UnitPrice:e, TotalCost:e}} - d := costv1.CostDriver{SchemaVersion:costv1.SchemaVersion, ID:"driver", Name:"fixture driver", Kind:kind, Quantity:q, UnitPrice:p, Window:window, Dimensions:o.Dimensions, Evidence:costv1.DriverEvidence{Quantity:e, UnitPrice:e}} - if kind == costv1.DriverVariable || kind == costv1.DriverCadence { d.Per = &costv1.Quantity{Value:1, Unit:"second"} } - require.NoError(t, o.Validate()) - require.NoError(t, d.Validate()) - return o, d + t.Helper() + source := costv1.SourceReference{Type: costv1.SourceSyntheticFixture, ArtifactIdentity: "fixture://variance", CapturedAt: "2026-08-01T00:00:00Z"} + e := costv1.Evidence{Kind: costv1.EvidencePredicted, Measurement: costv1.MeasurementSynthetic, Source: source, Confidence: costv1.ConfidenceLow, ConfidenceRationale: "deterministic fixture"} + window := costv1.TimeWindow{Start: "2026-07-01T00:00:00Z", End: "2026-08-01T00:00:00Z"} + q := costv1.Quantity{Value: quantity, Unit: "command"} + p := costv1.UnitPrice{Amount: costv1.Money{Amount: price, Currency: "USD"}, Per: costv1.Quantity{Value: 1, Unit: "command"}} + o := costv1.CostObservation{SchemaVersion: costv1.SchemaVersion, ID: id, DriverIDs: []string{"driver"}, Window: window, Quantity: q, UnitPrice: p, TotalCost: costv1.Money{Amount: total, Currency: "USD"}, Dimensions: costv1.Dimensions{Workload: "worker"}, Evidence: costv1.ClaimEvidence{Quantity: e, UnitPrice: e, TotalCost: e}} + d := costv1.CostDriver{SchemaVersion: costv1.SchemaVersion, ID: "driver", Name: "fixture driver", Kind: kind, Quantity: q, UnitPrice: p, Window: window, Dimensions: o.Dimensions, Evidence: costv1.DriverEvidence{Quantity: e, UnitPrice: e}} + if kind == costv1.DriverVariable || kind == costv1.DriverCadence { + d.Per = &costv1.Quantity{Value: 1, Unit: "second"} + } + require.NoError(t, o.Validate()) + require.NoError(t, d.Validate()) + return o, d } func varianceWriteJSON(t *testing.T, file string, value any) { - t.Helper() - data, err := json.Marshal(value) - require.NoError(t, err) - require.NoError(t, os.WriteFile(file, data, 0600)) + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + require.NoError(t, os.WriteFile(file, data, 0600)) } func varianceExecute(t *testing.T, command *cobra.Command, args ...string) ([]byte, error) { - t.Helper() - var output bytes.Buffer - command.SetOut(&output) - command.SetErr(&bytes.Buffer{}) - command.SetArgs(args) - err := command.Execute() - return output.Bytes(), err + t.Helper() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&bytes.Buffer{}) + command.SetArgs(args) + err := command.Execute() + return output.Bytes(), err } func TestCostDiffExplainJSON(t *testing.T) { - for _, kind := range []costv1.DriverKind{costv1.DriverFixed, costv1.DriverVariable, costv1.DriverCadence, costv1.DriverConcurrency} { - t.Run(string(kind),func(t *testing.T){ - f, fd := varianceFixture(t,"forecast",10,2,20,kind) - a, ad := varianceFixture(t,"actual",20,2,40,kind) - dir := t.TempDir() - input, diffPath := filepath.Join(dir,"input.json"),filepath.Join(dir,"diff.json") - varianceWriteJSON(t,input,variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) - payload, err := varianceExecute(t,newCostDiffCommand(),"--input",input,"--output",diffPath) - require.NoError(t,err) - disk, err := os.ReadFile(diffPath); require.NoError(t,err); require.Equal(t,payload,disk) - var diff variancev1.Result - require.NoError(t,json.Unmarshal(payload,&diff)) - require.Equal(t,variancev1.SchemaVersion,diff.SchemaVersion) - require.Equal(t,f.Window,diff.Window) - require.Equal(t,20.0,diff.AbsoluteVariance.Amount) - require.InDelta(t,0,diff.Residual.Amount,variancev1.Rounding) - require.Equal(t,"USD",diff.Contributions[0].Amount.Currency) - require.NotEmpty(t,diff.Contributions[0].Sources[0].ArtifactIdentity) - require.NotEmpty(t,diff.Contributions[0].Sources[0].CapturedAt) - explain,err := varianceExecute(t,newCostExplainCommand(),"--input",diffPath) - require.NoError(t,err) - var report ExplainReport - require.NoError(t,json.Unmarshal(explain,&report)) - require.Len(t,report.DriverContributions,1) - require.NotEmpty(t,report.DriverContributions[0].RemedialActions) - require.Greater(t,report.DriverContributions[0].ConfidenceScore,0.0) - if kind == costv1.DriverCadence || kind == costv1.DriverConcurrency { - require.Equal(t,"unknown",report.DriverContributions[0].Attribution) - require.Contains(t,report.DriverContributions[0].MissingEvidence,"exact delivery receipt") - } - }) - } + for _, kind := range []costv1.DriverKind{costv1.DriverFixed, costv1.DriverVariable, costv1.DriverCadence, costv1.DriverConcurrency} { + t.Run(string(kind), func(t *testing.T) { + f, fd := varianceFixture(t, "forecast", 10, 2, 20, kind) + a, ad := varianceFixture(t, "actual", 20, 2, 40, kind) + dir := t.TempDir() + input, diffPath := filepath.Join(dir, "input.json"), filepath.Join(dir, "diff.json") + varianceWriteJSON(t, input, variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + payload, err := varianceExecute(t, newCostDiffCommand(), "--input", input, "--output", diffPath) + require.NoError(t, err) + disk, err := os.ReadFile(diffPath) + require.NoError(t, err) + require.Equal(t, payload, disk) + var diff variancev1.Result + require.NoError(t, json.Unmarshal(payload, &diff)) + require.Equal(t, variancev1.SchemaVersion, diff.SchemaVersion) + require.Equal(t, f.Window, diff.Window) + require.Equal(t, 20.0, diff.AbsoluteVariance.Amount) + require.InDelta(t, 0, diff.Residual.Amount, variancev1.Rounding) + require.Equal(t, "USD", diff.Contributions[0].Amount.Currency) + require.NotEmpty(t, diff.Contributions[0].Sources[0].ArtifactIdentity) + require.NotEmpty(t, diff.Contributions[0].Sources[0].CapturedAt) + explain, err := varianceExecute(t, newCostExplainCommand(), "--input", diffPath) + require.NoError(t, err) + var report ExplainReport + require.NoError(t, json.Unmarshal(explain, &report)) + require.Len(t, report.DriverContributions, 1) + require.NotEmpty(t, report.DriverContributions[0].RemedialActions) + require.Greater(t, report.DriverContributions[0].ConfidenceScore, 0.0) + if kind == costv1.DriverCadence || kind == costv1.DriverConcurrency { + require.Equal(t, "unknown", report.DriverContributions[0].Attribution) + require.Contains(t, report.DriverContributions[0].MissingEvidence, "exact delivery receipt") + } + }) + } } func TestCostVariancePriceAndReceipts(t *testing.T) { - f,fd := varianceFixture(t,"forecast",10,2,20,costv1.DriverVariable) - a,ad := varianceFixture(t,"actual",10,3,30,costv1.DriverVariable) - result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) - require.NoError(t,err) - require.Greater(t,math.Abs(result.Contributions[0].UnitPrice),math.Abs(result.Contributions[0].Quantity)) - require.InDelta(t,0,result.Residual.Amount,variancev1.Rounding) - f,fd = varianceFixture(t,"forecast",10,2,20,costv1.DriverCadence) - a,ad = varianceFixture(t,"actual",20,2,40,costv1.DriverCadence) - receipt := variancev1.Receipt{DriverID:"driver",Kind:"deployment",ForecastObservationID:f.ID,ActualObservationID:a.ID,Source:costv1.SourceReference{Type:costv1.SourceRuntimeLedger,ArtifactIdentity:"deployment://exact",CapturedAt:"2026-08-01T00:00:00Z"}} - result,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}},Receipts:[]variancev1.Receipt{receipt}}) - require.NoError(t,err) - require.Equal(t,"modeled",result.Contributions[0].Attribution) - require.Len(t,result.Contributions[0].Receipts,1) - receipt.ActualObservationID = "other" - _,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}},Receipts:[]variancev1.Receipt{receipt}}) - require.Error(t,err) + f, fd := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverVariable) + a, ad := varianceFixture(t, "actual", 10, 3, 30, costv1.DriverVariable) + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + require.NoError(t, err) + require.Greater(t, math.Abs(result.Contributions[0].UnitPrice), math.Abs(result.Contributions[0].Quantity)) + require.InDelta(t, 0, result.Residual.Amount, variancev1.Rounding) + f, fd = varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverCadence) + a, ad = varianceFixture(t, "actual", 20, 2, 40, costv1.DriverCadence) + receipt := variancev1.Receipt{DriverID: "driver", Kind: "deployment", ForecastObservationID: f.ID, ActualObservationID: a.ID, Source: costv1.SourceReference{Type: costv1.SourceRuntimeLedger, ArtifactIdentity: "deployment://exact", CapturedAt: "2026-08-01T00:00:00Z"}} + result, err = variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}, Receipts: []variancev1.Receipt{receipt}}) + require.NoError(t, err) + require.Equal(t, "modeled", result.Contributions[0].Attribution) + require.Len(t, result.Contributions[0].Receipts, 1) + receipt.ActualObservationID = "other" + _, err = variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}, Receipts: []variancev1.Receipt{receipt}}) + require.Error(t, err) } func TestCostVarianceResidualZeroAndUnavailable(t *testing.T) { - f,fd := varianceFixture(t,"forecast",10,2,21,costv1.DriverVariable) - a,ad := varianceFixture(t,"actual",20,3,65,costv1.DriverVariable) - result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) - require.NoError(t,err) - require.InDelta(t,4,result.Residual.Amount,variancev1.Rounding) - report,err := Explain(context.Background(),&result) - require.NoError(t,err) - require.Equal(t,4.0,report.Residual.Amount) - require.NotEmpty(t,report.MissingEvidence) - result.AbsoluteVariance.Amount++ - _,err = Explain(context.Background(),&result) - require.ErrorContains(t,err,"absolute_variance") - f.TotalCost.Amount = 0 - result,err = variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) - require.NoError(t,err) - require.Nil(t,result.PercentageVariance) - unavailable,err := Diff(context.Background(),&f,nil) - require.NoError(t,err) - require.Equal(t,"unavailable",unavailable.Status) - require.Nil(t,unavailable.Actual) - require.Nil(t,unavailable.AbsoluteVariance) - _,err = Explain(context.Background(),unavailable) - require.NoError(t,err) + f, fd := varianceFixture(t, "forecast", 10, 2, 21, costv1.DriverVariable) + a, ad := varianceFixture(t, "actual", 20, 3, 65, costv1.DriverVariable) + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + require.NoError(t, err) + require.InDelta(t, 4, result.Residual.Amount, variancev1.Rounding) + report, err := Explain(context.Background(), &result) + require.NoError(t, err) + require.Equal(t, 4.0, report.Residual.Amount) + require.NotEmpty(t, report.MissingEvidence) + result.AbsoluteVariance.Amount++ + _, err = Explain(context.Background(), &result) + require.ErrorContains(t, err, "absolute_variance") + f.TotalCost.Amount = 0 + result, err = variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a}) + require.NoError(t, err) + require.Nil(t, result.PercentageVariance) + unavailable, err := Diff(context.Background(), &f, nil) + require.NoError(t, err) + require.Equal(t, "unavailable", unavailable.Status) + require.Nil(t, unavailable.Actual) + require.Nil(t, unavailable.AbsoluteVariance) + _, err = Explain(context.Background(), unavailable) + require.NoError(t, err) } func TestCostDiffInvalidInputAndAliases(t *testing.T) { - f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) - a,_ := varianceFixture(t,"actual",10,3,30,costv1.DriverFixed) - dir:=t.TempDir(); forecast:=filepath.Join(dir,"forecast.json"); actual:=filepath.Join(dir,"actual.json") - varianceWriteJSON(t,forecast,f); varianceWriteJSON(t,actual,a) - _,err:=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual) - require.NoError(t,err) - _,err=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual,"--output",forecast) - require.Equal(t,2,ExitCode(err)) - _,err=varianceExecute(t,newCostDiffCommand()) - require.Equal(t,2,ExitCode(err)) - require.NoError(t,os.WriteFile(actual,[]byte("{}{}"),0600)) - _,err=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual) - require.Equal(t,2,ExitCode(err)) - a.Evidence.Quantity = costv1.Evidence{} - varianceWriteJSON(t,actual,a) - _,err=varianceExecute(t,newCostDiffCommand(),"--forecast",forecast,"--actual",actual) - require.Equal(t,2,ExitCode(err)) - _,err=varianceExecute(t,newCostExplainCommand(),"--input",actual) - require.Equal(t,2,ExitCode(err)) - _,err=varianceExecute(t,newCostExplainCommand(),"--input",actual,"--output",actual) - require.Equal(t,2,ExitCode(err)) + f, _ := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 10, 3, 30, costv1.DriverFixed) + dir := t.TempDir() + forecast := filepath.Join(dir, "forecast.json") + actual := filepath.Join(dir, "actual.json") + varianceWriteJSON(t, forecast, f) + varianceWriteJSON(t, actual, a) + _, err := varianceExecute(t, newCostDiffCommand(), "--forecast", forecast, "--actual", actual) + require.NoError(t, err) + _, err = varianceExecute(t, newCostDiffCommand(), "--forecast", forecast, "--actual", actual, "--output", forecast) + require.Equal(t, 2, ExitCode(err)) + _, err = varianceExecute(t, newCostDiffCommand()) + require.Equal(t, 2, ExitCode(err)) + require.NoError(t, os.WriteFile(actual, []byte("{}{}"), 0600)) + _, err = varianceExecute(t, newCostDiffCommand(), "--forecast", forecast, "--actual", actual) + require.Equal(t, 2, ExitCode(err)) + a.Evidence.Quantity = costv1.Evidence{} + varianceWriteJSON(t, actual, a) + _, err = varianceExecute(t, newCostDiffCommand(), "--forecast", forecast, "--actual", actual) + require.Equal(t, 2, ExitCode(err)) + _, err = varianceExecute(t, newCostExplainCommand(), "--input", actual) + require.Equal(t, 2, ExitCode(err)) + _, err = varianceExecute(t, newCostExplainCommand(), "--input", actual, "--output", actual) + require.Equal(t, 2, ExitCode(err)) } func TestCostDiffUnavailableAndZeroJSON(t *testing.T) { - f,_ := varianceFixture(t,"forecast",0,2,0,costv1.DriverFixed) - a,_ := varianceFixture(t,"actual",1,2,2,costv1.DriverFixed) - dir:=t.TempDir(); input:=filepath.Join(dir,"input.json") - varianceWriteJSON(t,input,variancev1.Input{Forecast:f,Actual:&a}) - payload,err:=varianceExecute(t,newCostDiffCommand(),"--input",input) - require.NoError(t,err) - var result variancev1.Result - require.NoError(t,json.Unmarshal(payload,&result)) - require.Nil(t,result.PercentageVariance) - varianceWriteJSON(t,input,variancev1.Input{Forecast:f,UnavailableReason:"invoice not delivered"}) - payload,err=varianceExecute(t,newCostDiffCommand(),"--input",input) - require.NoError(t,err) - require.NoError(t,json.Unmarshal(payload,&result)) - require.Equal(t,"unavailable",result.Status) - require.Nil(t,result.Actual) - require.Nil(t,result.Residual) + f, _ := varianceFixture(t, "forecast", 0, 2, 0, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 1, 2, 2, costv1.DriverFixed) + dir := t.TempDir() + input := filepath.Join(dir, "input.json") + varianceWriteJSON(t, input, variancev1.Input{Forecast: f, Actual: &a}) + payload, err := varianceExecute(t, newCostDiffCommand(), "--input", input) + require.NoError(t, err) + var result variancev1.Result + require.NoError(t, json.Unmarshal(payload, &result)) + require.Nil(t, result.PercentageVariance) + varianceWriteJSON(t, input, variancev1.Input{Forecast: f, UnavailableReason: "invoice not delivered"}) + payload, err = varianceExecute(t, newCostDiffCommand(), "--input", input) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(payload, &result)) + require.Equal(t, "unavailable", result.Status) + require.Nil(t, result.Actual) + require.Nil(t, result.Residual) } func TestCostExplainTotalCurrencies(t *testing.T) { - f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) - a,_ := varianceFixture(t,"actual",10,3,30,costv1.DriverFixed) - base,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) - require.NoError(t,err) - for _, field := range []string{"actual", "absolute_variance", "residual"} { - t.Run(field,func(t *testing.T){ - result := base - actual, absolute, residual := *base.Actual, *base.AbsoluteVariance, *base.Residual - result.Actual, result.AbsoluteVariance, result.Residual = &actual, &absolute, &residual - switch field { - case "actual": result.Actual.Currency = "EUR" - case "absolute_variance": result.AbsoluteVariance.Currency = "EUR" - case "residual": result.Residual.Currency = "EUR" - } - _,err := Explain(context.Background(),&result) - require.ErrorContains(t,err,field) - require.ErrorContains(t,err,"EUR") - require.ErrorContains(t,err,"USD") - require.NotContains(t,err.Error(),"reconcile") - }) - } - report,err := Explain(context.Background(),&base) - require.NoError(t,err) - require.Equal(t,"USD",report.Actual.Currency) - require.Equal(t,"USD",report.VarianceAbsolute.Currency) - require.Equal(t,"USD",report.Residual.Currency) + f, _ := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 10, 3, 30, costv1.DriverFixed) + base, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a}) + require.NoError(t, err) + for _, field := range []string{"actual", "absolute_variance", "residual"} { + t.Run(field, func(t *testing.T) { + result := base + actual, absolute, residual := *base.Actual, *base.AbsoluteVariance, *base.Residual + result.Actual, result.AbsoluteVariance, result.Residual = &actual, &absolute, &residual + switch field { + case "actual": + result.Actual.Currency = "EUR" + case "absolute_variance": + result.AbsoluteVariance.Currency = "EUR" + case "residual": + result.Residual.Currency = "EUR" + } + _, err := Explain(context.Background(), &result) + require.ErrorContains(t, err, field) + require.ErrorContains(t, err, "EUR") + require.ErrorContains(t, err, "USD") + require.NotContains(t, err.Error(), "reconcile") + }) + } + report, err := Explain(context.Background(), &base) + require.NoError(t, err) + require.Equal(t, "USD", report.Actual.Currency) + require.Equal(t, "USD", report.VarianceAbsolute.Currency) + require.Equal(t, "USD", report.Residual.Currency) } func TestCostExplainCapsStrongDriversByTotalEvidence(t *testing.T) { - f,fd := varianceFixture(t,"forecast",10,2,20,costv1.DriverVariable) - a,ad := varianceFixture(t,"actual",20,2,40,costv1.DriverVariable) - strong := costv1.Evidence{Kind:costv1.EvidenceObserved,Measurement:costv1.MeasurementMeasured,Source:costv1.SourceReference{Type:costv1.SourceTelemetry,ArtifactIdentity:"telemetry://driver",CapturedAt:"2026-08-01T00:00:00Z"},Confidence:costv1.ConfidenceHigh,ConfidenceRationale:"measured driver"} - fd.Evidence.Quantity,ad.Evidence.Quantity = strong,strong - strong.Source.Type = costv1.SourceInvoice - strong.Source.ArtifactIdentity = "invoice://driver" - strong.Kind = costv1.EvidenceBilled - fd.Evidence.UnitPrice,ad.Evidence.UnitPrice = strong,strong - result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) - require.NoError(t,err) - require.Equal(t,costv1.ConfidenceHigh,result.Contributions[0].Confidence) - report,err := Explain(context.Background(),&result) - require.NoError(t,err) - require.Equal(t,costv1.ConfidenceLow,report.Confidence) - require.LessOrEqual(t,report.ConfidenceScore,.35) - require.Equal(t,costv1.ConfidenceHigh,report.DriverContributions[0].Confidence) - require.Contains(t,report.MissingEvidence,"forecast total cost: high-confidence claim evidence unavailable") - require.Contains(t,report.MissingEvidence,"actual total cost: high-confidence claim evidence unavailable") + f, fd := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverVariable) + a, ad := varianceFixture(t, "actual", 20, 2, 40, costv1.DriverVariable) + strong := costv1.Evidence{Kind: costv1.EvidenceObserved, Measurement: costv1.MeasurementMeasured, Source: costv1.SourceReference{Type: costv1.SourceTelemetry, ArtifactIdentity: "telemetry://driver", CapturedAt: "2026-08-01T00:00:00Z"}, Confidence: costv1.ConfidenceHigh, ConfidenceRationale: "measured driver"} + fd.Evidence.Quantity, ad.Evidence.Quantity = strong, strong + strong.Source.Type = costv1.SourceInvoice + strong.Source.ArtifactIdentity = "invoice://driver" + strong.Kind = costv1.EvidenceBilled + fd.Evidence.UnitPrice, ad.Evidence.UnitPrice = strong, strong + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + require.NoError(t, err) + require.Equal(t, costv1.ConfidenceHigh, result.Contributions[0].Confidence) + report, err := Explain(context.Background(), &result) + require.NoError(t, err) + require.Equal(t, costv1.ConfidenceLow, report.Confidence) + require.LessOrEqual(t, report.ConfidenceScore, .35) + require.Equal(t, costv1.ConfidenceHigh, report.DriverContributions[0].Confidence) + require.Contains(t, report.MissingEvidence, "forecast total cost: high-confidence claim evidence unavailable") + require.Contains(t, report.MissingEvidence, "actual total cost: high-confidence claim evidence unavailable") } func TestCostExplainSourceLabelsCannotPromoteTotalConfidence(t *testing.T) { - for _, sourceType := range []costv1.SourceType{costv1.SourceInvoice, costv1.SourceProfitCtlDerived} { - t.Run(string(sourceType),func(t *testing.T){ - f,fd := varianceFixture(t,"forecast",10,2,20,costv1.DriverVariable) - a,ad := varianceFixture(t,"actual",20,2,40,costv1.DriverVariable) - for _, observation := range []*costv1.CostObservation{&f,&a} { - observation.Evidence.TotalCost.Source.Type = sourceType - if sourceType == costv1.SourceInvoice { - observation.Evidence.TotalCost.Kind = costv1.EvidenceBilled - observation.Evidence.TotalCost.Measurement = costv1.MeasurementMeasured - } else { - observation.Evidence.TotalCost.Measurement = costv1.MeasurementDerived - } - } - strong := costv1.Evidence{Kind:costv1.EvidenceObserved,Measurement:costv1.MeasurementMeasured,Source:costv1.SourceReference{Type:costv1.SourceTelemetry,ArtifactIdentity:"telemetry://driver",CapturedAt:"2026-08-01T00:00:00Z"},Confidence:costv1.ConfidenceHigh,ConfidenceRationale:"measured driver"} - fd.Evidence.Quantity,ad.Evidence.Quantity = strong,strong - strong.Source.Type = costv1.SourceInvoice - strong.Source.ArtifactIdentity = "invoice://driver" - strong.Kind = costv1.EvidenceBilled - fd.Evidence.UnitPrice,ad.Evidence.UnitPrice = strong,strong - result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a,Drivers:[]variancev1.DriverPair{{Forecast:fd,Actual:ad}}}) - require.NoError(t,err) - require.Equal(t,"available",result.Status) - require.InDelta(t,0,result.Residual.Amount,variancev1.Rounding) - require.Equal(t,costv1.ConfidenceHigh,result.Contributions[0].Confidence) - require.Greater(t,result.Contributions[0].ConfidenceScore,.7) - require.Equal(t,sourceType,result.Sources[0].Type) - require.Equal(t,sourceType,result.Sources[1].Type) - report,err := Explain(context.Background(),&result) - require.NoError(t,err) - require.Equal(t,costv1.ConfidenceLow,report.Confidence) - require.LessOrEqual(t,report.ConfidenceScore,.35) - require.Equal(t,result.Contributions[0].ConfidenceScore,report.DriverContributions[0].ConfidenceScore) - require.Contains(t,report.MissingEvidence,"forecast total cost: high-confidence claim evidence unavailable") - require.Contains(t,report.MissingEvidence,"actual total cost: high-confidence claim evidence unavailable") - }) - } + for _, sourceType := range []costv1.SourceType{costv1.SourceInvoice, costv1.SourceProfitCtlDerived} { + t.Run(string(sourceType), func(t *testing.T) { + f, fd := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverVariable) + a, ad := varianceFixture(t, "actual", 20, 2, 40, costv1.DriverVariable) + for _, observation := range []*costv1.CostObservation{&f, &a} { + observation.Evidence.TotalCost.Source.Type = sourceType + if sourceType == costv1.SourceInvoice { + observation.Evidence.TotalCost.Kind = costv1.EvidenceBilled + observation.Evidence.TotalCost.Measurement = costv1.MeasurementMeasured + } else { + observation.Evidence.TotalCost.Measurement = costv1.MeasurementDerived + } + } + strong := costv1.Evidence{Kind: costv1.EvidenceObserved, Measurement: costv1.MeasurementMeasured, Source: costv1.SourceReference{Type: costv1.SourceTelemetry, ArtifactIdentity: "telemetry://driver", CapturedAt: "2026-08-01T00:00:00Z"}, Confidence: costv1.ConfidenceHigh, ConfidenceRationale: "measured driver"} + fd.Evidence.Quantity, ad.Evidence.Quantity = strong, strong + strong.Source.Type = costv1.SourceInvoice + strong.Source.ArtifactIdentity = "invoice://driver" + strong.Kind = costv1.EvidenceBilled + fd.Evidence.UnitPrice, ad.Evidence.UnitPrice = strong, strong + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + require.NoError(t, err) + require.Equal(t, "available", result.Status) + require.InDelta(t, 0, result.Residual.Amount, variancev1.Rounding) + require.Equal(t, costv1.ConfidenceHigh, result.Contributions[0].Confidence) + require.Greater(t, result.Contributions[0].ConfidenceScore, .7) + require.Equal(t, sourceType, result.Sources[0].Type) + require.Equal(t, sourceType, result.Sources[1].Type) + report, err := Explain(context.Background(), &result) + require.NoError(t, err) + require.Equal(t, costv1.ConfidenceLow, report.Confidence) + require.LessOrEqual(t, report.ConfidenceScore, .35) + require.Equal(t, result.Contributions[0].ConfidenceScore, report.DriverContributions[0].ConfidenceScore) + require.Contains(t, report.MissingEvidence, "forecast total cost: high-confidence claim evidence unavailable") + require.Contains(t, report.MissingEvidence, "actual total cost: high-confidence claim evidence unavailable") + }) + } } func TestCostExplainSuppliedTotalsAndPercentages(t *testing.T) { - f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) - a,_ := varianceFixture(t,"actual",20,2,40,costv1.DriverFixed) - base,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) - require.NoError(t,err) - require.Equal(t,100.0,*base.PercentageVariance) - require.Equal(t,20.0,base.Residual.Amount) - for _, tc := range []struct{name string; change func(*variancev1.Result); errorField string}{ - {"concealed total mismatch",func(r *variancev1.Result){r.AbsoluteVariance.Amount=10;r.Residual.Amount=10},"absolute_variance"}, - {"incorrect percentage",func(r *variancev1.Result){p:=50.0;r.PercentageVariance=&p},"percentage_variance"}, - {"missing defined percentage",func(r *variancev1.Result){r.PercentageVariance=nil},"percentage_variance"}, - {"numeric zero forecast",func(r *variancev1.Result){r.Forecast.Amount=0;r.AbsoluteVariance.Amount=40;r.Residual.Amount=40;p:=0.0;r.PercentageVariance=&p},"percentage_variance"}, - {"numeric unavailable",func(r *variancev1.Result){r.Status="unavailable";r.Actual=nil;r.AbsoluteVariance=nil;r.Residual=nil;p:=100.0;r.PercentageVariance=&p},"percentage_variance"}, - {"nonfinite forecast",func(r *variancev1.Result){r.Forecast.Amount=math.NaN()},"forecast.amount"}, - {"nonfinite actual",func(r *variancev1.Result){r.Actual.Amount=math.Inf(1)},"actual.amount"}, - {"nonfinite absolute",func(r *variancev1.Result){r.AbsoluteVariance.Amount=math.NaN()},"absolute_variance.amount"}, - {"nonfinite residual",func(r *variancev1.Result){r.Residual.Amount=math.Inf(-1)},"residual.amount"}, - {"nonfinite percentage",func(r *variancev1.Result){p:=math.NaN();r.PercentageVariance=&p},"percentage_variance"}, - {"nonfinite contribution",func(r *variancev1.Result){r.Contributions=[]variancev1.Contribution{{Amount:costv1.Money{Amount:math.NaN(),Currency:"USD"},Attribution:"modeled",ConfidenceScore:.35}}},"contributions[0].amount.amount"}, - } { - t.Run(tc.name,func(t *testing.T){ - r:=base - actual,absolute,residual:=*base.Actual,*base.AbsoluteVariance,*base.Residual - r.Actual,r.AbsoluteVariance,r.Residual=&actual,&absolute,&residual - tc.change(&r) - _,err:=Explain(context.Background(),&r) - require.ErrorContains(t,err,tc.errorField) - }) - } - report,err:=Explain(context.Background(),&base) - require.NoError(t,err) - require.Equal(t,100.0,*report.VariancePercent) - zero:=base - zero.Forecast.Amount=0 - zero.AbsoluteVariance=&costv1.Money{Amount:40,Currency:"USD"} - zero.Residual=&costv1.Money{Amount:40,Currency:"USD"} - zero.PercentageVariance=nil - report,err=Explain(context.Background(),&zero) - require.NoError(t,err) - require.Nil(t,report.VariancePercent) - unavailable:=base - unavailable.Status="unavailable" - unavailable.Actual,unavailable.AbsoluteVariance,unavailable.Residual,unavailable.PercentageVariance=nil,nil,nil,nil - unavailable.UnavailableReason="invoice not delivered" - unavailable.MissingEvidence=[]string{"actual cost: invoice not delivered"} - report,err=Explain(context.Background(),&unavailable) - require.NoError(t,err) - require.Nil(t,report.VariancePercent) + f, _ := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 20, 2, 40, costv1.DriverFixed) + base, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a}) + require.NoError(t, err) + require.Equal(t, 100.0, *base.PercentageVariance) + require.Equal(t, 20.0, base.Residual.Amount) + for _, tc := range []struct { + name string + change func(*variancev1.Result) + errorField string + }{ + {"concealed total mismatch", func(r *variancev1.Result) { r.AbsoluteVariance.Amount = 10; r.Residual.Amount = 10 }, "absolute_variance"}, + {"incorrect percentage", func(r *variancev1.Result) { p := 50.0; r.PercentageVariance = &p }, "percentage_variance"}, + {"missing defined percentage", func(r *variancev1.Result) { r.PercentageVariance = nil }, "percentage_variance"}, + {"numeric zero forecast", func(r *variancev1.Result) { + r.Forecast.Amount = 0 + r.AbsoluteVariance.Amount = 40 + r.Residual.Amount = 40 + p := 0.0 + r.PercentageVariance = &p + }, "percentage_variance"}, + {"numeric unavailable", func(r *variancev1.Result) { + r.Status = "unavailable" + r.Actual = nil + r.AbsoluteVariance = nil + r.Residual = nil + p := 100.0 + r.PercentageVariance = &p + }, "percentage_variance"}, + {"nonfinite forecast", func(r *variancev1.Result) { r.Forecast.Amount = math.NaN() }, "forecast.amount"}, + {"nonfinite actual", func(r *variancev1.Result) { r.Actual.Amount = math.Inf(1) }, "actual.amount"}, + {"nonfinite absolute", func(r *variancev1.Result) { r.AbsoluteVariance.Amount = math.NaN() }, "absolute_variance.amount"}, + {"nonfinite residual", func(r *variancev1.Result) { r.Residual.Amount = math.Inf(-1) }, "residual.amount"}, + {"nonfinite percentage", func(r *variancev1.Result) { p := math.NaN(); r.PercentageVariance = &p }, "percentage_variance"}, + {"nonfinite contribution", func(r *variancev1.Result) { + r.Contributions = []variancev1.Contribution{{Amount: costv1.Money{Amount: math.NaN(), Currency: "USD"}, Attribution: "modeled", ConfidenceScore: .35}} + }, "contributions[0].amount.amount"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := base + actual, absolute, residual := *base.Actual, *base.AbsoluteVariance, *base.Residual + r.Actual, r.AbsoluteVariance, r.Residual = &actual, &absolute, &residual + tc.change(&r) + _, err := Explain(context.Background(), &r) + require.ErrorContains(t, err, tc.errorField) + }) + } + report, err := Explain(context.Background(), &base) + require.NoError(t, err) + require.Equal(t, 100.0, *report.VariancePercent) + zero := base + zero.Forecast.Amount = 0 + zero.AbsoluteVariance = &costv1.Money{Amount: 40, Currency: "USD"} + zero.Residual = &costv1.Money{Amount: 40, Currency: "USD"} + zero.PercentageVariance = nil + report, err = Explain(context.Background(), &zero) + require.NoError(t, err) + require.Nil(t, report.VariancePercent) + unavailable := base + unavailable.Status = "unavailable" + unavailable.Actual, unavailable.AbsoluteVariance, unavailable.Residual, unavailable.PercentageVariance = nil, nil, nil, nil + unavailable.UnavailableReason = "invoice not delivered" + unavailable.MissingEvidence = []string{"actual cost: invoice not delivered"} + report, err = Explain(context.Background(), &unavailable) + require.NoError(t, err) + require.Nil(t, report.VariancePercent) } func TestCostExplainTinyForecastPercentage(t *testing.T) { - f,_ := varianceFixture(t,"forecast",1,0.000001,0.000001,costv1.DriverFixed) - a,_ := varianceFixture(t,"actual",2,0.000001,0.000002,costv1.DriverFixed) - result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) - require.NoError(t,err) - require.Equal(t,100.0,*result.PercentageVariance) - supplied := result - zero := 0.0 - supplied.PercentageVariance = &zero - _,err = Explain(context.Background(),&supplied) - require.ErrorContains(t,err,"percentage_variance") - correct := 100.0 - supplied.PercentageVariance = &correct - report,err := Explain(context.Background(),&supplied) - require.NoError(t,err) - require.Equal(t,100.0,*report.VariancePercent) + f, _ := varianceFixture(t, "forecast", 1, 0.000001, 0.000001, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 2, 0.000001, 0.000002, costv1.DriverFixed) + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a}) + require.NoError(t, err) + require.Equal(t, 100.0, *result.PercentageVariance) + supplied := result + zero := 0.0 + supplied.PercentageVariance = &zero + _, err = Explain(context.Background(), &supplied) + require.ErrorContains(t, err, "percentage_variance") + correct := 100.0 + supplied.PercentageVariance = &correct + report, err := Explain(context.Background(), &supplied) + require.NoError(t, err) + require.Equal(t, 100.0, *report.VariancePercent) } func TestCostExplainRanksSuppliedContributions(t *testing.T) { - f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) - a,_ := varianceFixture(t,"actual",20,2,40,costv1.DriverFixed) - result,err := variancev1.Compare(variancev1.Input{Forecast:f,Actual:&a}) - require.NoError(t,err) - source := costv1.SourceReference{Type:costv1.SourceRuntimeLedger,ArtifactIdentity:"receipt://small",CapturedAt:"2026-08-01T00:00:00Z"} - receipt := variancev1.Receipt{DriverID:"small",Kind:"deployment",ForecastObservationID:f.ID,ActualObservationID:a.ID,Source:source} - small := variancev1.Contribution{DriverID:"small",Kind:costv1.DriverCadence,Amount:costv1.Money{Amount:-5,Currency:"USD"},Confidence:costv1.ConfidenceLow,ConfidenceScore:.35,Attribution:"unknown",MissingEvidence:[]string{"exact delivery receipt"},Sources:[]costv1.SourceReference{source},Receipts:[]variancev1.Receipt{receipt}} - large := variancev1.Contribution{DriverID:"large",Kind:costv1.DriverVariable,Amount:costv1.Money{Amount:25,Currency:"USD"},Confidence:costv1.ConfidenceLow,ConfidenceScore:.35,Attribution:"modeled",Sources:[]costv1.SourceReference{{Type:costv1.SourceSyntheticFixture,ArtifactIdentity:"fixture://large",CapturedAt:source.CapturedAt}}} - result.Contributions = []variancev1.Contribution{small,large} - result.Residual.Amount = 0 - report,err := Explain(context.Background(),&result) - require.NoError(t,err) - require.Equal(t,[]string{"large","small"},[]string{report.DriverContributions[0].DriverID,report.DriverContributions[1].DriverID}) - require.Equal(t,large,report.DriverContributions[0].Contribution) - require.Equal(t,small,report.DriverContributions[1].Contribution) - require.Contains(t,report.DriverContributions[0].RemedialActions[0],"request volume") - require.Contains(t,report.DriverContributions[1].RemedialActions[0],"polling interval") - require.Equal(t,[]variancev1.Contribution{small,large},result.Contributions) + f, _ := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 20, 2, 40, costv1.DriverFixed) + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a}) + require.NoError(t, err) + source := costv1.SourceReference{Type: costv1.SourceRuntimeLedger, ArtifactIdentity: "receipt://small", CapturedAt: "2026-08-01T00:00:00Z"} + receipt := variancev1.Receipt{DriverID: "small", Kind: "deployment", ForecastObservationID: f.ID, ActualObservationID: a.ID, Source: source} + small := variancev1.Contribution{DriverID: "small", Kind: costv1.DriverCadence, Amount: costv1.Money{Amount: -5, Currency: "USD"}, Confidence: costv1.ConfidenceLow, ConfidenceScore: .35, Attribution: "unknown", MissingEvidence: []string{"exact delivery receipt"}, Sources: []costv1.SourceReference{source}, Receipts: []variancev1.Receipt{receipt}} + large := variancev1.Contribution{DriverID: "large", Kind: costv1.DriverVariable, Amount: costv1.Money{Amount: 25, Currency: "USD"}, Confidence: costv1.ConfidenceLow, ConfidenceScore: .35, Attribution: "modeled", Sources: []costv1.SourceReference{{Type: costv1.SourceSyntheticFixture, ArtifactIdentity: "fixture://large", CapturedAt: source.CapturedAt}}} + result.Contributions = []variancev1.Contribution{small, large} + result.Residual.Amount = 0 + report, err := Explain(context.Background(), &result) + require.NoError(t, err) + require.Equal(t, []string{"large", "small"}, []string{report.DriverContributions[0].DriverID, report.DriverContributions[1].DriverID}) + require.Equal(t, large, report.DriverContributions[0].Contribution) + require.Equal(t, small, report.DriverContributions[1].Contribution) + require.Contains(t, report.DriverContributions[0].RemedialActions[0], "request volume") + require.Contains(t, report.DriverContributions[1].RemedialActions[0], "polling interval") + require.Equal(t, []variancev1.Contribution{small, large}, result.Contributions) - result.Contributions = []variancev1.Contribution{small,large} - result.Contributions[0].Amount.Amount = -10 - result.Contributions[1].Amount.Amount = 10 - result.Residual.Amount = 20 - report,err = Explain(context.Background(),&result) - require.NoError(t,err) - require.Equal(t,[]string{"large","small"},[]string{report.DriverContributions[0].DriverID,report.DriverContributions[1].DriverID}) - require.Equal(t,"small",result.Contributions[0].DriverID) + result.Contributions = []variancev1.Contribution{small, large} + result.Contributions[0].Amount.Amount = -10 + result.Contributions[1].Amount.Amount = 10 + result.Residual.Amount = 20 + report, err = Explain(context.Background(), &result) + require.NoError(t, err) + require.Equal(t, []string{"large", "small"}, []string{report.DriverContributions[0].DriverID, report.DriverContributions[1].DriverID}) + require.Equal(t, "small", result.Contributions[0].DriverID) } func TestCostExplainUnavailableReasonEvidence(t *testing.T) { - f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) - base,err := Diff(context.Background(),&f,nil) - require.NoError(t,err) - for _, tc := range []struct{name,reason string; evidence []string; errorField string}{ - {"missing reason","",[]string{"actual cost unavailable"},"unavailable_reason"}, - {"blank reason"," \t ",[]string{"actual cost: \t "},"unavailable_reason"}, - {"missing evidence","invoice not delivered",nil,"missing_evidence"}, - {"unrelated evidence","invoice not delivered",[]string{"actual cost: ledger pending"},"missing_evidence"}, - {"blank evidence","invoice not delivered",[]string{" "},"missing_evidence"}, - {"valid supplied reason","invoice not delivered",[]string{"actual cost: invoice not delivered"},""}, - } { - t.Run(tc.name,func(t *testing.T){ - result := *base - result.UnavailableReason = tc.reason - result.MissingEvidence = tc.evidence - report,err := Explain(context.Background(),&result) - if tc.errorField != "" { - require.ErrorContains(t,err,tc.errorField) - return - } - require.NoError(t,err) - require.Equal(t,tc.reason,report.UnavailableReason) - require.Contains(t,report.MissingEvidence,"actual cost: "+tc.reason) - require.Nil(t,report.Actual) - require.Nil(t,report.VariancePercent) - }) - } + f, _ := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverFixed) + base, err := Diff(context.Background(), &f, nil) + require.NoError(t, err) + for _, tc := range []struct { + name, reason string + evidence []string + errorField string + }{ + {"missing reason", "", []string{"actual cost unavailable"}, "unavailable_reason"}, + {"blank reason", " \t ", []string{"actual cost: \t "}, "unavailable_reason"}, + {"missing evidence", "invoice not delivered", nil, "missing_evidence"}, + {"unrelated evidence", "invoice not delivered", []string{"actual cost: ledger pending"}, "missing_evidence"}, + {"blank evidence", "invoice not delivered", []string{" "}, "missing_evidence"}, + {"valid supplied reason", "invoice not delivered", []string{"actual cost: invoice not delivered"}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + result := *base + result.UnavailableReason = tc.reason + result.MissingEvidence = tc.evidence + report, err := Explain(context.Background(), &result) + if tc.errorField != "" { + require.ErrorContains(t, err, tc.errorField) + return + } + require.NoError(t, err) + require.Equal(t, tc.reason, report.UnavailableReason) + require.Contains(t, report.MissingEvidence, "actual cost: "+tc.reason) + require.Nil(t, report.Actual) + require.Nil(t, report.VariancePercent) + }) + } } func TestCostExplainMissingActualEvidenceIdentity(t *testing.T) { - f,_ := varianceFixture(t,"forecast",10,2,20,costv1.DriverFixed) - result,err := Diff(context.Background(),&f,nil) - require.NoError(t,err) - result.UnavailableReason = "invoice not delivered" - result.MissingEvidence = []string{"forecast invoice not delivered"} - _,err = Explain(context.Background(),result) - require.ErrorContains(t,err,"missing_evidence") - require.ErrorContains(t,err,"actual cost") + f, _ := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverFixed) + result, err := Diff(context.Background(), &f, nil) + require.NoError(t, err) + result.UnavailableReason = "invoice not delivered" + result.MissingEvidence = []string{"forecast invoice not delivered"} + _, err = Explain(context.Background(), result) + require.ErrorContains(t, err, "missing_evidence") + require.ErrorContains(t, err, "actual cost") - result.MissingEvidence = []string{"actual cost: invoice not delivered"} - report,err := Explain(context.Background(),result) - require.NoError(t,err) - require.Contains(t,report.MissingEvidence,"actual cost: invoice not delivered") + result.MissingEvidence = []string{"actual cost: invoice not delivered"} + report, err := Explain(context.Background(), result) + require.NoError(t, err) + require.Contains(t, report.MissingEvidence, "actual cost: invoice not delivered") } func TestCostExplainInvalidReconciliation(t *testing.T) { - result:=variancev1.Result{SchemaVersion:variancev1.SchemaVersion,Status:"available",Actual:&costv1.Money{Amount:2,Currency:"USD"},AbsoluteVariance:&costv1.Money{Amount:2,Currency:"USD"},Residual:&costv1.Money{Amount:math.NaN(),Currency:"USD"}} - _,err:=Explain(context.Background(),&result) - require.Error(t,err) + result := variancev1.Result{SchemaVersion: variancev1.SchemaVersion, Status: "available", Actual: &costv1.Money{Amount: 2, Currency: "USD"}, AbsoluteVariance: &costv1.Money{Amount: 2, Currency: "USD"}, Residual: &costv1.Money{Amount: math.NaN(), Currency: "USD"}} + _, err := Explain(context.Background(), &result) + require.Error(t, err) } From b021e497ab2b9e015308dc2a9ac35d05b5c9dd37 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 21:55:33 -0400 Subject: [PATCH 15/17] fix: validate variance percentages against measured totals --- cmd/cost_explain.go | 4 ++-- cmd/cost_variance_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index e5565e6..21dcd94 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -84,12 +84,12 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er return nil, errors.New("percentage_variance must be null when forecast.amount is zero") } } else { - expected := 100 * report.AbsoluteVariance.Amount / report.Forecast.Amount + expected := delta / report.Forecast.Amount * 100 if math.IsNaN(expected) || math.IsInf(expected, 0) { return nil, errors.New("percentage_variance overflows for supplied amounts") } if report.PercentageVariance == nil || math.Abs(*report.PercentageVariance-expected) > variancev1.Rounding { - return nil, fmt.Errorf("percentage_variance must equal 100 * absolute_variance.amount / forecast.amount (%.12g) within rounding tolerance", expected) + return nil, fmt.Errorf("percentage_variance must equal 100 * (actual.amount - forecast.amount) / forecast.amount (%.12g) within rounding tolerance", expected) } } } diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 7e19516..e7dca60 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -441,3 +441,21 @@ func TestCostExplainInvalidReconciliation(t *testing.T) { _, err := Explain(context.Background(), &result) require.Error(t, err) } + +func TestCostExplainTinyForecastCannotHidePercentage(t *testing.T) { + f, _ := varianceFixture(t, "forecast", 1, 0.0000001, 0.0000001, costv1.DriverFixed) + a, _ := varianceFixture(t, "actual", 2, 0.0000001, 0.0000002, costv1.DriverFixed) + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a}) + require.NoError(t, err) + require.Equal(t, 100.0, *result.PercentageVariance) + report, err := Explain(context.Background(), &result) + require.NoError(t, err) + require.Equal(t, 100.0, *report.VariancePercent) + supplied := result + supplied.AbsoluteVariance = &costv1.Money{Amount: 0, Currency: "USD"} + supplied.Residual = &costv1.Money{Amount: 0, Currency: "USD"} + zero := 0.0 + supplied.PercentageVariance = &zero + _, err = Explain(context.Background(), &supplied) + require.ErrorContains(t, err, "percentage_variance") +} From 967681c1632e1df7f2404175a889dd188b4c99be Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 22:02:12 -0400 Subject: [PATCH 16/17] fix: reject unsupported driver evidence in explanations --- cmd/cost_explain.go | 20 ++++++++++++++++++ cmd/cost_variance_test.go | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go index 21dcd94..6defcd5 100644 --- a/cmd/cost_explain.go +++ b/cmd/cost_explain.go @@ -7,6 +7,7 @@ import ( "math" "sort" "strings" + "time" costv1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" variancev1 "github.com/IntelIP/ProfitCtl/pkg/variance/v1" @@ -131,6 +132,25 @@ func Explain(ctx context.Context, report *variancev1.Result) (*ExplainReport, er if c.Amount.Currency != report.Forecast.Currency || c.ConfidenceScore < 0 || c.ConfidenceScore > 1 || math.IsNaN(c.ConfidenceScore) || (c.Attribution != "unknown" && c.Attribution != "modeled") { return nil, errors.New("invalid driver contribution") } + + for _, receipt := range c.Receipts { + _, timestampErr := time.Parse(time.RFC3339, receipt.Source.CapturedAt) + if timestampErr != nil { + _, timestampErr = time.Parse(time.DateOnly, receipt.Source.CapturedAt) + } + validKind := receipt.Kind == "commit" || receipt.Kind == "pull_request" || receipt.Kind == "release" || receipt.Kind == "deployment" + if receipt.DriverID != c.DriverID || receipt.ForecastObservationID != report.ForecastObservationID || receipt.ActualObservationID != report.ActualObservationID || report.ForecastObservationID == "" || report.ActualObservationID == "" || strings.TrimSpace(receipt.Source.ArtifactIdentity) == "" || receipt.Source.Type != costv1.SourceRuntimeLedger || timestampErr != nil || !validKind { + return nil, fmt.Errorf("contributions[%d] has an invalid exact receipt", i) + } + } + if (c.Kind == costv1.DriverCadence || c.Kind == costv1.DriverConcurrency || c.Kind == costv1.DriverUptime) && c.Attribution == "modeled" && len(c.Receipts) == 0 { + return nil, fmt.Errorf("contributions[%d] modeled attribution requires an exact receipt", i) + } + for _, source := range c.Sources { + if source.Type == costv1.SourceSyntheticFixture && (c.Confidence != costv1.ConfidenceLow || c.ConfidenceScore > .35) { + return nil, fmt.Errorf("contributions[%d] synthetic evidence requires low confidence", i) + } + } sum += c.Amount.Amount out.ConfidenceScore = math.Min(out.ConfidenceScore, c.ConfidenceScore) action := "Review workload measurements and cost evidence before adjusting this driver" diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index e7dca60..22691c1 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -459,3 +459,47 @@ func TestCostExplainTinyForecastCannotHidePercentage(t *testing.T) { _, err = Explain(context.Background(), &supplied) require.ErrorContains(t, err, "percentage_variance") } + +func TestCostExplainRejectsUnsupportedDriverEvidence(t *testing.T) { + f, fd := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverCadence) + a, ad := varianceFixture(t, "actual", 20, 2, 40, costv1.DriverCadence) + base, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + require.NoError(t, err) + _, err = Explain(context.Background(), &base) + require.NoError(t, err) + receipt := variancev1.Receipt{DriverID: "driver", Kind: "deployment", ForecastObservationID: f.ID, ActualObservationID: a.ID, Source: costv1.SourceReference{Type: costv1.SourceRuntimeLedger, ArtifactIdentity: "deployment://exact", CapturedAt: "2026-08-01T00:00:00Z"}} + for _, tc := range []struct { + name string + change func(*variancev1.Contribution) + field string + }{ + {"modeled without receipt", func(c *variancev1.Contribution) { c.Attribution = "modeled" }, "exact receipt"}, + {"wrong observation receipt", func(c *variancev1.Contribution) { + r := receipt + r.ActualObservationID = "other" + c.Receipts = []variancev1.Receipt{r} + c.Attribution = "modeled" + }, "exact receipt"}, + {"invalid receipt date", func(c *variancev1.Contribution) { + r := receipt + r.Source.CapturedAt = "invalid" + c.Receipts = []variancev1.Receipt{r} + c.Attribution = "modeled" + }, "exact receipt"}, + {"synthetic high confidence", func(c *variancev1.Contribution) { c.Confidence = costv1.ConfidenceHigh; c.ConfidenceScore = 1 }, "synthetic"}, + } { + t.Run(tc.name, func(t *testing.T) { + result := base + result.Contributions = append([]variancev1.Contribution(nil), base.Contributions...) + tc.change(&result.Contributions[0]) + _, err := Explain(context.Background(), &result) + require.ErrorContains(t, err, tc.field) + }) + } + valid := base + valid.Contributions = append([]variancev1.Contribution(nil), base.Contributions...) + valid.Contributions[0].Receipts = []variancev1.Receipt{receipt} + valid.Contributions[0].Attribution = "modeled" + _, err = Explain(context.Background(), &valid) + require.NoError(t, err) +} From 8fa8ab43472238019657bba4af631970e5ef2c86 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 3 Oct 2026 20:35:45 -0400 Subject: [PATCH 17/17] fix: keep synthetic variance evidence explainable --- cmd/cost_variance_test.go | 16 ++++++++++++++++ pkg/variance/v1/variance.go | 3 +++ 2 files changed, 19 insertions(+) diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go index 22691c1..8567b82 100644 --- a/cmd/cost_variance_test.go +++ b/cmd/cost_variance_test.go @@ -503,3 +503,19 @@ func TestCostExplainRejectsUnsupportedDriverEvidence(t *testing.T) { _, err = Explain(context.Background(), &valid) require.NoError(t, err) } + +func TestCostDiffSyntheticMediumEvidenceRemainsExplainable(t *testing.T) { + f, fd := varianceFixture(t, "forecast", 10, 2, 20, costv1.DriverVariable) + a, ad := varianceFixture(t, "actual", 20, 2, 40, costv1.DriverVariable) + for _, d := range []*costv1.CostDriver{&fd, &ad} { + d.Evidence.Quantity.Confidence = costv1.ConfidenceMedium + d.Evidence.UnitPrice.Confidence = costv1.ConfidenceMedium + require.NoError(t, d.Validate()) + } + result, err := variancev1.Compare(variancev1.Input{Forecast: f, Actual: &a, Drivers: []variancev1.DriverPair{{Forecast: fd, Actual: ad}}}) + require.NoError(t, err) + _, err = Explain(context.Background(), &result) + require.NoError(t, err, "valid diff output must remain explainable") + require.Equal(t, costv1.ConfidenceLow, result.Contributions[0].Confidence) + require.LessOrEqual(t, result.Contributions[0].ConfidenceScore, .35) +} diff --git a/pkg/variance/v1/variance.go b/pkg/variance/v1/variance.go index 6032955..5d6b717 100644 --- a/pkg/variance/v1/variance.go +++ b/pkg/variance/v1/variance.go @@ -334,6 +334,9 @@ func validCapture(s string) bool { func confidence(c *Contribution, f, a cost.CostDriver, total float64) { score := 1.0 for _, e := range []cost.Evidence{f.Evidence.Quantity, f.Evidence.UnitPrice, a.Evidence.Quantity, a.Evidence.UnitPrice} { + if e.Source.Type == cost.SourceSyntheticFixture { + score = math.Min(score, .35) + } switch e.Confidence { case cost.ConfidenceLow: score = math.Min(score, .35)