diff --git a/cmd/cost_diff.go b/cmd/cost_diff.go new file mode 100644 index 0000000..d81d00b --- /dev/null +++ b/cmd/cost_diff.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "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" +) + +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: "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) +} + +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 { + 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 +} diff --git a/cmd/cost_explain.go b/cmd/cost_explain.go new file mode 100644 index 0000000..6defcd5 --- /dev/null +++ b/cmd/cost_explain.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + + 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") + } + 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 := 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 * (actual.amount - forecast.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") + } + + 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" + 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() + +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 +} diff --git a/cmd/cost_variance_test.go b/cmd/cost_variance_test.go new file mode 100644 index 0000000..8567b82 --- /dev/null +++ b/cmd/cost_variance_test.go @@ -0,0 +1,521 @@ +package cmd + +import ( + "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" +) + +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 +} + +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 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") + } + }) + } +} + +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 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) +} + +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 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 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 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 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) +} + +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) + 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") + + 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) +} + +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") +} + +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) +} + +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/docs/cost-variance.md b/docs/cost-variance.md new file mode 100644 index 0000000..4a315a9 --- /dev/null +++ b/docs/cost-variance.md @@ -0,0 +1,20 @@ +# Local cost variance + +`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). + +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": [] +} +``` + +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 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. + +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`. diff --git a/pkg/variance/v1/diff.go b/pkg/variance/v1/diff.go new file mode 100644 index 0000000..21e5a62 --- /dev/null +++ b/pkg/variance/v1/diff.go @@ -0,0 +1,241 @@ +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) + } + 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}, 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...) + 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} + 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 { + 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 !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)) + 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..9e9e45b --- /dev/null +++ b/pkg/variance/v1/diff_test.go @@ -0,0 +1,135 @@ +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") + } +} 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 new file mode 100644 index 0000000..5d6b717 --- /dev/null +++ b/pkg/variance/v1/variance.go @@ -0,0 +1,377 @@ +// Package v1 compares validated cost observations without inferring causal delivery claims. +package v1 + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strings" + "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 strings.TrimSpace(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/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 new file mode 100644 index 0000000..8efb20b --- /dev/null +++ b/schemas/cost-variance/v1/result.schema.json @@ -0,0 +1,37 @@ +{ + "$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"}} + }, + "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"}}}, + "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/schemas/cost-variance/v1/schema.json b/schemas/cost-variance/v1/schema.json new file mode 100644 index 0000000..4b77c77 --- /dev/null +++ b/schemas/cost-variance/v1/schema.json @@ -0,0 +1,38 @@ +{ + "$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"}}}} + } +} 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} +]