Repository navigation
Explain cost variance and root cause from the CLI #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f9de1db
f601f06
fb903cf
fe9aa52
52bde8d
648b07b
4944207
ae0cdcc
b1c9ae0
f5a428c
4d17343
d8b11c7
89e528a
9a56442
b021e49
967681c
4a475bd
8fa8ab4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The diff result and explain report both emit AGENTS.md reference: AGENTS.md:L20-L20 Useful? React with 👍 / 👎. |
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
profitctl explainreads a hand-edited or corrupt diff file, this gate accepts any report with the right version and status if its arithmetic reconciles, then copies unchecked fields into the output. For example, blank observation IDs, an empty window, and empty money currencies can pass with a zero forecast and reconciled actual/variance/residual, but the emitted explanation is rejected byschemas/cost-variance/v1/schema.json(which requires non-empty IDs, RFC3339 window timestamps, and three-letter currencies). Validate the completeResultcontract before constructing the report.AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.