From 6ce77ef9e8367db6fdcca5c8f46c50ece38ce594 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:28:11 +1000 Subject: [PATCH 01/11] feat(performance): compare baseline reports --- README.md | 4 + cmd/stave-performance-compare/main.go | 79 +++++ cmd/stave-performance-compare/main_test.go | 51 ++++ docs/performance-baseline.md | 25 ++ performance/comparison.go | 281 ++++++++++++++++++ performance/comparison_test.go | 112 +++++++ .../rigor/generated/dependency-inventory.json | 19 +- scripts/rigor/generated/public-api.txt | 10 + 8 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 cmd/stave-performance-compare/main.go create mode 100644 cmd/stave-performance-compare/main_test.go create mode 100644 docs/performance-baseline.md create mode 100644 performance/comparison.go create mode 100644 performance/comparison_test.go diff --git a/README.md b/README.md index b2f645f..757d41d 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,10 @@ The unreleased v1.1.0 development checkout also includes a comparing canonical transcript artifacts without re-executing an application model. +Use the unreleased [performance baseline comparator](docs/performance-baseline.md) +for same-host report comparisons; it preserves the existing absolute performance +budgets. + ## Feedback and contributing Report bugs or propose features through [GitHub issues](https://github.com/ben-ranford/stave/issues). diff --git a/cmd/stave-performance-compare/main.go b/cmd/stave-performance-compare/main.go new file mode 100644 index 0000000..dc36632 --- /dev/null +++ b/cmd/stave-performance-compare/main.go @@ -0,0 +1,79 @@ +// Command stave-performance-compare compares two saved performance reports. +// It never writes or refreshes a baseline. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/ben-ranford/stave/performance" +) + +const ( + exitSuccess = 0 + exitRegression = 2 + exitInvalid = 3 + exitUsage = 64 +) + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + +func run(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("stave-performance-compare", flag.ContinueOnError) + flags.SetOutput(stderr) + baselinePath := flags.String("baseline", "", "checked-in baseline performance report") + candidatePath := flags.String("candidate", "", "candidate performance report") + if err := flags.Parse(args); err != nil || flags.NArg() != 0 || *baselinePath == "" || *candidatePath == "" { + fmt.Fprintln(stderr, "usage: stave-performance-compare -baseline baseline.json -candidate candidate.json") + return exitUsage + } + baseline, err := load(*baselinePath) + if err != nil { + return invalid(stdout) + } + candidate, err := load(*candidatePath) + if err != nil { + return invalid(stdout) + } + comparison, err := performance.Compare(baseline, candidate) + if err != nil { + return invalid(stdout) + } + if err := write(stdout, comparison); err != nil { + return exitInvalid + } + if !comparison.Passed { + return exitRegression + } + return exitSuccess +} + +func load(path string) (performance.Report, error) { + file, err := os.Open(path) + if err != nil { + return performance.Report{}, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, performance.MaxReportBytes+1)) + if err != nil { + return performance.Report{}, err + } + return performance.DecodeReport(data) +} + +func invalid(stdout io.Writer) int { + _ = write(stdout, map[string]string{"status": "invalid", "error": "report failed bounded structural or compatibility validation"}) + return exitInvalid +} + +func write(stdout io.Writer, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + _, err = fmt.Fprintln(stdout, string(data)) + return err +} diff --git a/cmd/stave-performance-compare/main_test.go b/cmd/stave-performance-compare/main_test.go new file mode 100644 index 0000000..dec55b7 --- /dev/null +++ b/cmd/stave-performance-compare/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ben-ranford/stave/capability" + "github.com/ben-ranford/stave/layout" + "github.com/ben-ranford/stave/performance" +) + +func TestRunDistinguishesMatchRegressionAndInvalid(t *testing.T) { + dir := t.TempDir() + baseline, candidate := filepath.Join(dir, "baseline.json"), filepath.Join(dir, "candidate.json") + report := fixtureReport() + writeFixture(t, baseline, report) + writeFixture(t, candidate, report) + if code := run([]string{"-baseline", baseline, "-candidate", candidate}, &bytes.Buffer{}, &bytes.Buffer{}); code != exitSuccess { + t.Fatalf("match code=%d", code) + } + report.Measurements[0].P95 = 120 * time.Nanosecond + writeFixture(t, candidate, report) + if code := run([]string{"-baseline", baseline, "-candidate", candidate}, &bytes.Buffer{}, &bytes.Buffer{}); code != exitRegression { + t.Fatalf("regression code=%d", code) + } + if err := os.WriteFile(candidate, []byte(`{"host":"secret"`), 0600); err != nil { + t.Fatal(err) + } + if code := run([]string{"-baseline", baseline, "-candidate", candidate}, &bytes.Buffer{}, &bytes.Buffer{}); code != exitInvalid { + t.Fatalf("invalid code=%d", code) + } +} + +func writeFixture(t *testing.T, path string, report performance.Report) { + t.Helper() + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func fixtureReport() performance.Report { + return performance.Report{Host: "host", GoVersion: "go1.22.0", GOOS: "linux", GOARCH: "amd64", CPUs: 8, Nodes: 10000, NodeShape: "balanced", Renderer: "stave.render/v1", Viewport: layout.Size{Width: 120, Height: 40}, Capabilities: capability.Manifest{Width: 120, Height: 40}, Reproducibility: performance.Reproducibility{Invocation: []string{"stave-performance", "-strict"}, SampleCount: 101, Strict: true, GOMAXPROCS: 1}, AllocBytes: 100, AllocLimit: 200, AllocWithin: true, IdleCPU: performance.RatioMeasurement{Name: "idle_cpu.percent_one_core", Value: .2, Limit: 1, AllWithinBudget: true}, Measurements: []performance.Measurement{{Name: "render.p95", Samples: 101, P95: 100 * time.Nanosecond, Limit: time.Microsecond, AllWithinBudget: true}}} +} diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..7be9735 --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,25 @@ +# Performance baseline comparison + +`stave-performance-compare` is an unreleased v1.1.0 development command. It +compares two saved reports produced by `stave-performance`; it never updates or +creates a baseline. + +```sh +go run ./cmd/stave-performance -strict -out baseline.json +go run ./cmd/stave-performance -strict -out candidate.json +go run ./cmd/stave-performance-compare -baseline baseline.json -candidate candidate.json +``` + +Both reports must be from the same host, Go version, OS/architecture, CPU +count, fixture, capabilities, and exact run parameters. The `-out` destination +is excluded because it names the artifact rather than a measurement parameter. +Source revisions are recorded and may differ. The comparator rejects reports +that fail their existing absolute budgets, change their budget schema, or use +incompatible environment or reproducibility metadata. + +It emits the versioned `stave.performance.comparison/v1` JSON envelope. Each +metric has an explicit threshold: p95 measurements and allocation allow a 10% +increase to absorb ordinary same-host noise, while idle CPU allows 0.10 +percentage points. Exit status is `0` when all deltas are within tolerance, `2` +for a valid regression, `3` for invalid or incompatible input, and `64` for +usage errors. diff --git a/performance/comparison.go b/performance/comparison.go new file mode 100644 index 0000000..3c5fcd1 --- /dev/null +++ b/performance/comparison.go @@ -0,0 +1,281 @@ +package performance + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "reflect" + "sort" + "unicode/utf8" +) + +const ( + ComparisonSchemaVersion = "stave.performance.comparison/v1" + MaxReportBytes = 16 << 20 +) + +// ComparisonPolicy makes the same-host noise allowance explicit. All values +// are increases: relative fractions for p95/allocation and percentage points +// for idle CPU. +type ComparisonPolicy struct { + MeasurementTolerance float64 + AllocationTolerance float64 + IdleCPUTolerance float64 +} + +var DefaultComparisonPolicy = ComparisonPolicy{MeasurementTolerance: 0.10, AllocationTolerance: 0.10, IdleCPUTolerance: 0.10} + +// Comparison is an opt-in envelope for comparing two existing performance +// report artifacts. Report itself intentionally remains unchanged. +type Comparison struct { + SchemaVersion string `json:"schemaVersion"` + Policy string `json:"policy"` + BaselineRevision string `json:"baselineRevision,omitempty"` + CandidateRevision string `json:"candidateRevision,omitempty"` + Deltas []MetricDelta `json:"deltas"` + Passed bool `json:"passed"` +} + +// MetricDelta describes one deterministic comparison result. Values are raw +// report units: nanoseconds for p95, bytes for allocation, and percentage +// points for idle CPU. +type MetricDelta struct { + Metric string `json:"metric"` + Unit string `json:"unit"` + Baseline float64 `json:"baseline"` + Candidate float64 `json:"candidate"` + Delta float64 `json:"delta"` + PercentDelta float64 `json:"percentDelta,omitempty"` + Tolerance float64 `json:"tolerance"` + ToleranceUnit string `json:"toleranceUnit"` + WithinTolerance bool `json:"withinTolerance"` +} + +// DecodeReport strictly decodes an existing performance report without +// changing its wire schema. It rejects duplicate and unknown fields so a +// baseline cannot silently reinterpret saved measurements. +func DecodeReport(data []byte) (Report, error) { + if len(data) > MaxReportBytes { + return Report{}, fmt.Errorf("performance report exceeds %d-byte limit", MaxReportBytes) + } + data = bytes.TrimSpace(data) + if len(data) == 0 || !utf8.Valid(data) || data[0] != '{' { + return Report{}, errors.New("performance report must be a UTF-8 JSON object") + } + if err := validateJSONKeys(data, 0); err != nil { + return Report{}, fmt.Errorf("decode performance report: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var report Report + if err := decoder.Decode(&report); err != nil { + return Report{}, fmt.Errorf("decode performance report: %w", err) + } + if err := ensureReportEOF(decoder); err != nil { + return Report{}, err + } + if err := ValidateReport(report); err != nil { + return Report{}, err + } + return report, nil +} + +// Compare requires reports from the same declared environment and run policy. +// Source revisions may differ because that is the point of a baseline check. +// It never updates either input or creates a baseline. +func Compare(baseline, candidate Report) (Comparison, error) { + return CompareWithPolicy(baseline, candidate, DefaultComparisonPolicy) +} + +// CompareWithPolicy compares compatible reports with an explicit validated +// same-host noise policy. It never updates either input or creates a baseline. +func CompareWithPolicy(baseline, candidate Report, policy ComparisonPolicy) (Comparison, error) { + if err := validatePolicy(policy); err != nil { + return Comparison{}, err + } + if err := ValidateReport(baseline); err != nil { + return Comparison{}, fmt.Errorf("invalid baseline report: %w", err) + } + if err := ValidateReport(candidate); err != nil { + return Comparison{}, fmt.Errorf("invalid candidate report: %w", err) + } + if err := compatibleEnvironment(baseline, candidate); err != nil { + return Comparison{}, err + } + deltas := make([]MetricDelta, 0, len(baseline.Measurements)+2) + for _, metric := range baseline.Measurements { + candidateMetric := measurementByName(candidate.Measurements, metric.Name) + deltas = append(deltas, relativeDelta(metric.Name, "nanoseconds", float64(metric.P95), float64(candidateMetric.P95), policy.MeasurementTolerance)) + } + deltas = append(deltas, + relativeDelta("alloc_bytes", "bytes", float64(baseline.AllocBytes), float64(candidate.AllocBytes), policy.AllocationTolerance), + absoluteDelta("idle_cpu.percent_one_core", "percentage_points", baseline.IdleCPU.Value, candidate.IdleCPU.Value, policy.IdleCPUTolerance), + ) + sort.Slice(deltas, func(i, j int) bool { return deltas[i].Metric < deltas[j].Metric }) + passed := true + for _, delta := range deltas { + passed = passed && delta.WithinTolerance + } + return Comparison{SchemaVersion: ComparisonSchemaVersion, Policy: fmt.Sprintf("same host, Go version, OS/architecture, CPU count, fixture, capabilities, and run parameters; source revisions may differ; %.2f%% p95, %.2f%% allocation, and %.2f percentage-point idle-CPU tolerance", policy.MeasurementTolerance*100, policy.AllocationTolerance*100, policy.IdleCPUTolerance), BaselineRevision: baseline.Reproducibility.VCSRevision, CandidateRevision: candidate.Reproducibility.VCSRevision, Deltas: deltas, Passed: passed}, nil +} + +func validatePolicy(policy ComparisonPolicy) error { + for _, value := range []float64{policy.MeasurementTolerance, policy.AllocationTolerance, policy.IdleCPUTolerance} { + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 { + return errors.New("comparison tolerances must be finite and non-negative") + } + } + return nil +} + +func ValidateReport(report Report) error { + if report.Host == "" || report.GoVersion == "" || report.GOOS == "" || report.GOARCH == "" || report.CPUs < 1 || report.Nodes < 1 || report.NodeShape == "" || report.Renderer == "" || report.Viewport.Width < 1 || report.Viewport.Height < 1 { + return errors.New("performance report has incomplete environment metadata") + } + if report.Reproducibility.SampleCount < 1 || report.Reproducibility.GOMAXPROCS < 1 || len(report.Reproducibility.Invocation) == 0 { + return errors.New("performance report has incomplete reproducibility metadata") + } + if report.AllocBytes > report.AllocLimit || !report.AllocWithin { + return errors.New("performance report fails its allocation budget") + } + if report.IdleCPU.Name != "idle_cpu.percent_one_core" || math.IsNaN(report.IdleCPU.Value) || math.IsInf(report.IdleCPU.Value, 0) || math.IsNaN(report.IdleCPU.Limit) || math.IsInf(report.IdleCPU.Limit, 0) || report.IdleCPU.Value < 0 || report.IdleCPU.Limit <= 0 || report.IdleCPU.Value >= report.IdleCPU.Limit || !report.IdleCPU.AllWithinBudget { + return errors.New("performance report fails its idle CPU budget") + } + if len(report.Measurements) == 0 { + return errors.New("performance report has no measurements") + } + seen := map[string]struct{}{} + for _, measurement := range report.Measurements { + if measurement.Name == "" || measurement.Samples < 1 || measurement.P50 < 0 || measurement.P95 < 0 || measurement.P99 < 0 || measurement.Limit <= 0 || measurement.P95 > measurement.Limit || !measurement.AllWithinBudget { + return fmt.Errorf("performance report fails absolute budget for %q", measurement.Name) + } + if _, duplicate := seen[measurement.Name]; duplicate { + return fmt.Errorf("performance report has duplicate metric %q", measurement.Name) + } + seen[measurement.Name] = struct{}{} + } + return nil +} + +func compatibleEnvironment(baseline, candidate Report) error { + if baseline.Host != candidate.Host || baseline.GoVersion != candidate.GoVersion || baseline.GOOS != candidate.GOOS || baseline.GOARCH != candidate.GOARCH || baseline.CPUs != candidate.CPUs || baseline.Nodes != candidate.Nodes || baseline.NodeShape != candidate.NodeShape || baseline.Renderer != candidate.Renderer || baseline.Viewport != candidate.Viewport || !reflect.DeepEqual(baseline.Capabilities, candidate.Capabilities) { + return errors.New("performance reports use incompatible environment or fixture schema") + } + if baseline.Reproducibility.SampleCount != candidate.Reproducibility.SampleCount || baseline.Reproducibility.Strict != candidate.Reproducibility.Strict || baseline.Reproducibility.GOMAXPROCS != candidate.Reproducibility.GOMAXPROCS || baseline.Reproducibility.VCSModified != candidate.Reproducibility.VCSModified || !reflect.DeepEqual(comparableInvocation(baseline.Reproducibility.Invocation), comparableInvocation(candidate.Reproducibility.Invocation)) { + return errors.New("performance reports use incompatible reproducibility parameters") + } + if baseline.AllocLimit != candidate.AllocLimit || baseline.IdleCPU.Limit != candidate.IdleCPU.Limit || baseline.IdleCPU.Name != candidate.IdleCPU.Name || len(baseline.Measurements) != len(candidate.Measurements) { + return errors.New("performance reports use incompatible absolute budget schema") + } + for _, measurement := range baseline.Measurements { + candidateMetric := measurementByName(candidate.Measurements, measurement.Name) + if candidateMetric.Name == "" || candidateMetric.Limit != measurement.Limit || candidateMetric.Samples != measurement.Samples { + return fmt.Errorf("performance reports use incompatible metric schema for %q", measurement.Name) + } + } + return nil +} + +// comparableInvocation preserves measured run parameters while excluding the +// artifact destination. The existing command records -out in os.Args, but two +// distinct report files must be comparable without making their paths a false +// environment difference. +func comparableInvocation(invocation []string) []string { + result := make([]string, 0, len(invocation)) + for i := 0; i < len(invocation); i++ { + if invocation[i] == "-out" { + i++ + continue + } + if len(invocation[i]) > len("-out=") && invocation[i][:len("-out=")] == "-out=" { + continue + } + result = append(result, invocation[i]) + } + return result +} + +func measurementByName(measurements []Measurement, name string) Measurement { + for _, measurement := range measurements { + if measurement.Name == name { + return measurement + } + } + return Measurement{} +} + +func relativeDelta(metric, unit string, baseline, candidate, tolerance float64) MetricDelta { + delta := candidate - baseline + percent := 0.0 + withinTolerance := candidate == 0 + if baseline > 0 { + percent = delta / baseline + withinTolerance = percent <= tolerance + } + return MetricDelta{Metric: metric, Unit: unit, Baseline: baseline, Candidate: candidate, Delta: delta, PercentDelta: percent, Tolerance: tolerance, ToleranceUnit: "relative", WithinTolerance: withinTolerance} +} + +func absoluteDelta(metric, unit string, baseline, candidate, tolerance float64) MetricDelta { + delta := candidate - baseline + return MetricDelta{Metric: metric, Unit: unit, Baseline: baseline, Candidate: candidate, Delta: delta, Tolerance: tolerance, ToleranceUnit: "absolute", WithinTolerance: delta <= tolerance && !math.IsNaN(candidate) && !math.IsInf(candidate, 0)} +} + +func validateJSONKeys(data []byte, depth int) error { + if depth > 64 { + return errors.New("performance report JSON nesting exceeds limit") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok || (delim != '{' && delim != '[') { + return errors.New("invalid performance report JSON") + } + seen := map[string]struct{}{} + for decoder.More() { + if delim == '{' { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("invalid performance report object key") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate performance report key %q", key) + } + seen[key] = struct{}{} + } + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return err + } + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') { + if err := validateJSONKeys(trimmed, depth+1); err != nil { + return err + } + } + } + if _, err := decoder.Token(); err != nil { + return err + } + return ensureReportEOF(decoder) +} + +func ensureReportEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("trailing JSON") + } + return err + } + return nil +} diff --git a/performance/comparison_test.go b/performance/comparison_test.go new file mode 100644 index 0000000..c2a215a --- /dev/null +++ b/performance/comparison_test.go @@ -0,0 +1,112 @@ +package performance + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/ben-ranford/stave/capability" + "github.com/ben-ranford/stave/layout" +) + +func TestCompareProducesDeterministicDeltasAndAllowsSourceRevisionChange(t *testing.T) { + baseline := comparisonFixture() + candidate := comparisonFixture() + candidate.Reproducibility.VCSRevision = "candidate" + baseline.Reproducibility.Invocation = append(baseline.Reproducibility.Invocation, "-out", "baseline.json") + candidate.Reproducibility.Invocation = append(candidate.Reproducibility.Invocation, "-out", "candidate.json") + candidate.Measurements[0].P95 = 105 * time.Nanosecond + comparison, err := Compare(baseline, candidate) + if err != nil { + t.Fatal(err) + } + if !comparison.Passed || comparison.SchemaVersion != ComparisonSchemaVersion || len(comparison.Deltas) != 3 { + t.Fatalf("comparison=%+v", comparison) + } + if comparison.BaselineRevision != "baseline" || comparison.CandidateRevision != "candidate" { + t.Fatalf("revisions=%q/%q", comparison.BaselineRevision, comparison.CandidateRevision) + } +} + +func TestCompareWithPolicyRejectsInvalidTolerancesAndReports(t *testing.T) { + baseline := comparisonFixture() + for _, policy := range []ComparisonPolicy{{MeasurementTolerance: -1}, {MeasurementTolerance: math.NaN()}, {MeasurementTolerance: math.Inf(1)}} { + if _, err := CompareWithPolicy(baseline, baseline, policy); err == nil { + t.Fatal("CompareWithPolicy() accepted invalid tolerance") + } + } + for _, mutate := range []func(*Report){ + func(r *Report) { r.IdleCPU.Value = math.NaN() }, + func(r *Report) { r.IdleCPU.Value = -1 }, + func(r *Report) { r.Measurements[0].P95 = -time.Nanosecond }, + } { + invalid := comparisonFixture() + mutate(&invalid) + if _, err := Compare(baseline, invalid); err == nil { + t.Fatal("Compare() accepted invalid direct API report") + } + } +} + +func TestCompareRejectsRegressionEnvironmentAndBudgetChanges(t *testing.T) { + baseline := comparisonFixture() + cases := []struct { + name string + mutate func(*Report) + wantOK bool + }{ + {"regression", func(r *Report) { r.Measurements[0].P95 = 120 * time.Nanosecond }, false}, + {"host", func(r *Report) { r.Host = "other" }, true}, + {"run parameters", func(r *Report) { r.Reproducibility.SampleCount++ }, true}, + {"absolute budget", func(r *Report) { r.Measurements[0].Limit++ }, true}, + {"failed absolute budget", func(r *Report) { r.Measurements[0].AllWithinBudget = false }, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + candidate := comparisonFixture() + tc.mutate(&candidate) + comparison, err := Compare(baseline, candidate) + if tc.wantOK { + if err == nil { + t.Fatal("Compare() accepted incompatible reports") + } + return + } + if err != nil || comparison.Passed { + t.Fatalf("Compare() comparison=%+v err=%v", comparison, err) + } + }) + } +} + +func TestDecodeReportRejectsDuplicateAndUnknownFields(t *testing.T) { + report := comparisonFixture() + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeReport(data); err != nil { + t.Fatalf("DecodeReport() error=%v", err) + } + for _, invalid := range [][]byte{ + append(append([]byte(nil), data[:len(data)-1]...), []byte(`,"host":"other"}`)...), + append(append([]byte(nil), data[:len(data)-1]...), []byte(`,"unexpected":true}`)...), + append(data, []byte(` {}`)...), + } { + if _, err := DecodeReport(invalid); err == nil { + t.Fatal("DecodeReport() accepted hostile artifact") + } + } +} + +func comparisonFixture() Report { + return Report{ + Host: "host", GoVersion: "go1.22.0", GOOS: "linux", GOARCH: "amd64", CPUs: 8, + Nodes: 10000, NodeShape: "balanced-binary", Renderer: "stave.render/v1", Viewport: layout.Size{Width: 120, Height: 40}, Capabilities: capability.Manifest{Width: 120, Height: 40}, + Reproducibility: Reproducibility{Invocation: []string{"stave-performance", "-strict"}, SampleCount: 101, Strict: true, GOMAXPROCS: 1, VCSRevision: "baseline"}, + AllocBytes: 100, AllocLimit: 200, AllocWithin: true, + IdleCPU: RatioMeasurement{Name: "idle_cpu.percent_one_core", Value: 0.20, Limit: 1, AllWithinBudget: true}, + Measurements: []Measurement{{Name: "render.p95", Samples: 101, P95: 100 * time.Nanosecond, Limit: time.Microsecond, AllWithinBudget: true}}, + } +} diff --git a/scripts/rigor/generated/dependency-inventory.json b/scripts/rigor/generated/dependency-inventory.json index f146df5..7bf9f44 100644 --- a/scripts/rigor/generated/dependency-inventory.json +++ b/scripts/rigor/generated/dependency-inventory.json @@ -160,6 +160,18 @@ "time" ] }, + { + "importPath": "github.com/ben-ranford/stave/cmd/stave-performance-compare", + "dir": "cmd/stave-performance-compare", + "imports": [ + "encoding/json", + "flag", + "fmt", + "github.com/ben-ranford/stave/performance", + "io", + "os" + ] + }, { "importPath": "github.com/ben-ranford/stave/cmd/stave-replay", "dir": "cmd/stave-replay", @@ -404,6 +416,7 @@ "importPath": "github.com/ben-ranford/stave/performance", "dir": "performance", "imports": [ + "bytes", "context", "encoding/json", "errors", @@ -418,10 +431,14 @@ "github.com/ben-ranford/stave/secret", "github.com/ben-ranford/stave/semantic", "github.com/ben-ranford/stave/surface", + "io", + "math", + "reflect", "runtime", "runtime/metrics", "sort", - "time" + "time", + "unicode/utf8" ] }, { diff --git a/scripts/rigor/generated/public-api.txt b/scripts/rigor/generated/public-api.txt index bba8902..fe63c54 100644 --- a/scripts/rigor/generated/public-api.txt +++ b/scripts/rigor/generated/public-api.txt @@ -428,9 +428,14 @@ type Nop struct { } type Observer interface { Observe(context.Context, Event) } [github.com/ben-ranford/stave/performance] +const ComparisonSchemaVersion untyped string = "stave.performance.comparison/v1" +const MaxReportBytes untyped int = 16777216 const Samples untyped int = 101 func ActionAckLine() ([]byte, error) +func Compare(baseline, candidate Report) (Comparison, error) +func CompareWithPolicy(baseline, candidate Report, policy ComparisonPolicy) (Comparison, error) func Context() context.Context +func DecodeReport(data []byte) (Report, error) func Fixture(n int) (semantic.Tree, error) func Measure(fn func()) time.Duration func MeasureIdleCPU(window time.Duration) RatioMeasurement @@ -440,6 +445,7 @@ func ReduceCounter(ctx context.Context, count int, ev event.Event) (int, []effec func ResizeStormRecovery(count int) error func SurfaceFixture(w, h int) (surface.Surface, error) func TerminalRestoreAfterCancellation() error +func ValidateReport(report Report) error method (*measurementDriver) Close() error method (*measurementDriver) Draw(context.Context, surface.Surface, surface.Patch) error method (*measurementDriver) Events() <-chan event.Event @@ -448,10 +454,14 @@ method (*measurementDriver) ReadSecret(context.Context, input.SecretPrompt) (sec method (*measurementDriver) Restore(context.Context) error type Attempt struct { P50 time.Duration `json:"p50"`; P95 time.Duration `json:"p95"`; P99 time.Duration `json:"p99"` } type Budget struct { Name string `json:"name"`; Limit time.Duration `json:"limit"` } +type Comparison struct { SchemaVersion string `json:"schemaVersion"`; Policy string `json:"policy"`; BaselineRevision string `json:"baselineRevision,omitempty"`; CandidateRevision string `json:"candidateRevision,omitempty"`; Deltas []MetricDelta `json:"deltas"`; Passed bool `json:"passed"` } +type ComparisonPolicy struct { MeasurementTolerance float64; AllocationTolerance float64; IdleCPUTolerance float64 } type Measurement struct { Name string `json:"name"`; Samples int `json:"samples"`; P50 time.Duration `json:"p50"`; P95 time.Duration `json:"p95"`; P99 time.Duration `json:"p99"`; AllWithinBudget bool `json:"allWithinBudget"`; Limit time.Duration `json:"limit"`; Disposition string `json:"disposition,omitempty"`; Attempts []Attempt `json:"attempts,omitempty"` } +type MetricDelta struct { Metric string `json:"metric"`; Unit string `json:"unit"`; Baseline float64 `json:"baseline"`; Candidate float64 `json:"candidate"`; Delta float64 `json:"delta"`; PercentDelta float64 `json:"percentDelta,omitempty"`; Tolerance float64 `json:"tolerance"`; ToleranceUnit string `json:"toleranceUnit"`; WithinTolerance bool `json:"withinTolerance"` } type RatioMeasurement struct { Name string `json:"name"`; Window time.Duration `json:"window"`; Value float64 `json:"value"`; Limit float64 `json:"limit"`; AllWithinBudget bool `json:"allWithinBudget"`; Disposition string `json:"disposition,omitempty"`; Attempts []float64 `json:"attempts,omitempty"` } type Report struct { GeneratedAt time.Time `json:"generatedAt"`; Host string `json:"host"`; GoVersion string `json:"goVersion"`; GOOS string `json:"goos"`; GOARCH string `json:"goarch"`; CPUs int `json:"cpus"`; Nodes int `json:"nodes"`; NodeShape string `json:"nodeDistribution"`; Renderer string `json:"renderer"`; Viewport layout.Size `json:"viewport"`; Capabilities capability.Manifest `json:"capabilities"`; Reproducibility Reproducibility `json:"reproducibility"`; AllocBytes uint64 `json:"allocBytes"`; AllocLimit uint64 `json:"allocLimitBytes"`; AllocWithin bool `json:"allocWithinBudget"`; IdleCPU RatioMeasurement `json:"idleCpu"`; Measurements []Measurement `json:"measurements"`; Invariants []string `json:"invariants"` } type Reproducibility struct { Invocation []string `json:"invocation"`; SampleCount int `json:"sampleCount"`; Strict bool `json:"strict"`; GOMAXPROCS int `json:"gomaxprocs"`; VCSRevision string `json:"vcsRevision,omitempty"`; VCSModified bool `json:"vcsModified,omitempty"` } +var DefaultComparisonPolicy ComparisonPolicy [github.com/ben-ranford/stave/primitive] const Grid Direction = "grid" From e44d9a508474eba2ac51e5395304cab6af29bd72 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:08:46 +1000 Subject: [PATCH 02/11] fix(performance): compare runs across executable locations --- docs/performance-baseline.md | 13 ++++++++----- performance/comparison.go | 9 ++++----- performance/comparison_test.go | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 7be9735..d677aa5 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -11,11 +11,14 @@ go run ./cmd/stave-performance-compare -baseline baseline.json -candidate candid ``` Both reports must be from the same host, Go version, OS/architecture, CPU -count, fixture, capabilities, and exact run parameters. The `-out` destination -is excluded because it names the artifact rather than a measurement parameter. -Source revisions are recorded and may differ. The comparator rejects reports -that fail their existing absolute budgets, change their budget schema, or use -incompatible environment or reproducibility metadata. +count, fixture, capabilities, and exact run parameters. The executable path +(`os.Args[0]`) is excluded because launch and build locations can differ +between runs or revisions, and the `-out` destination is excluded because it +names the artifact rather than a measurement parameter. All remaining arguments +and environment metadata are compared. Source revisions are recorded and may +differ. The comparator rejects reports that fail their existing absolute +budgets, change their budget schema, or use incompatible environment or +reproducibility metadata. It emits the versioned `stave.performance.comparison/v1` JSON envelope. Each metric has an explicit threshold: p95 measurements and allocation allow a 10% diff --git a/performance/comparison.go b/performance/comparison.go index 3c5fcd1..50f40c5 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -179,13 +179,12 @@ func compatibleEnvironment(baseline, candidate Report) error { return nil } -// comparableInvocation preserves measured run parameters while excluding the -// artifact destination. The existing command records -out in os.Args, but two -// distinct report files must be comparable without making their paths a false -// environment difference. +// comparableInvocation preserves measured run parameters while excluding +// launch and artifact locations. os.Args[0] can be an ephemeral go-build path, +// while -out names an artifact; neither changes a measurement run. func comparableInvocation(invocation []string) []string { result := make([]string, 0, len(invocation)) - for i := 0; i < len(invocation); i++ { + for i := 1; i < len(invocation); i++ { if invocation[i] == "-out" { i++ continue diff --git a/performance/comparison_test.go b/performance/comparison_test.go index c2a215a..f09d0f6 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -29,6 +29,25 @@ func TestCompareProducesDeterministicDeltasAndAllowsSourceRevisionChange(t *test } } +func TestCompareIgnoresNonMeasurementInvocationPaths(t *testing.T) { + baseline := comparisonFixture() + candidate := comparisonFixture() + baseline.Reproducibility.Invocation = []string{"/private/var/folders/a/go-build123/b001/exe/stave-performance", "-strict", "-out", "baseline.json"} + candidate.Reproducibility.Invocation = []string{"/private/var/folders/b/go-build456/b001/exe/stave-performance", "-strict", "-out", "candidate.json"} + if _, err := Compare(baseline, candidate); err != nil { + t.Fatalf("Compare() rejected equivalent invocation paths: %v", err) + } +} + +func TestCompareRejectsMeasuredInvocationArgumentChange(t *testing.T) { + baseline := comparisonFixture() + candidate := comparisonFixture() + candidate.Reproducibility.Invocation = []string{"/tmp/go-build/exe/stave-performance", "-strict=false"} + if _, err := Compare(baseline, candidate); err == nil { + t.Fatal("Compare() accepted different invocation arguments") + } +} + func TestCompareWithPolicyRejectsInvalidTolerancesAndReports(t *testing.T) { baseline := comparisonFixture() for _, policy := range []ComparisonPolicy{{MeasurementTolerance: -1}, {MeasurementTolerance: math.NaN()}, {MeasurementTolerance: math.Inf(1)}} { From 454e17bd24abc616106aeb7152898b1e8ee6008f Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:12:30 +1000 Subject: [PATCH 03/11] refactor(performance): simplify report key validation --- performance/comparison.go | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/performance/comparison.go b/performance/comparison.go index 50f40c5..4e7388b 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -238,18 +238,9 @@ func validateJSONKeys(data []byte, depth int) error { seen := map[string]struct{}{} for decoder.More() { if delim == '{' { - keyToken, err := decoder.Token() - if err != nil { + if err := validateJSONObjectKey(decoder, seen); err != nil { return err } - key, ok := keyToken.(string) - if !ok { - return errors.New("invalid performance report object key") - } - if _, exists := seen[key]; exists { - return fmt.Errorf("duplicate performance report key %q", key) - } - seen[key] = struct{}{} } var raw json.RawMessage if err := decoder.Decode(&raw); err != nil { @@ -268,6 +259,22 @@ func validateJSONKeys(data []byte, depth int) error { return ensureReportEOF(decoder) } +func validateJSONObjectKey(decoder *json.Decoder, seen map[string]struct{}) error { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("invalid performance report object key") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate performance report key %q", key) + } + seen[key] = struct{}{} + return nil +} + func ensureReportEOF(decoder *json.Decoder) error { var extra any if err := decoder.Decode(&extra); err != io.EOF { From bb9942b2c6ad4d6609d913fdc31e0a270c42d4a8 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:35:48 +1000 Subject: [PATCH 04/11] fix(performance): validate comparable report metadata --- cmd/stave-performance-compare/main_test.go | 3 +- docs/performance-baseline.md | 11 ++++-- performance/comparison.go | 34 +++++++++++-------- performance/comparison_test.go | 29 +++++++++++++--- .../rigor/generated/dependency-inventory.json | 1 + 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/cmd/stave-performance-compare/main_test.go b/cmd/stave-performance-compare/main_test.go index dec55b7..76023d1 100644 --- a/cmd/stave-performance-compare/main_test.go +++ b/cmd/stave-performance-compare/main_test.go @@ -23,6 +23,7 @@ func TestRunDistinguishesMatchRegressionAndInvalid(t *testing.T) { t.Fatalf("match code=%d", code) } report.Measurements[0].P95 = 120 * time.Nanosecond + report.Measurements[0].P99 = 120 * time.Nanosecond writeFixture(t, candidate, report) if code := run([]string{"-baseline", baseline, "-candidate", candidate}, &bytes.Buffer{}, &bytes.Buffer{}); code != exitRegression { t.Fatalf("regression code=%d", code) @@ -47,5 +48,5 @@ func writeFixture(t *testing.T, path string, report performance.Report) { } func fixtureReport() performance.Report { - return performance.Report{Host: "host", GoVersion: "go1.22.0", GOOS: "linux", GOARCH: "amd64", CPUs: 8, Nodes: 10000, NodeShape: "balanced", Renderer: "stave.render/v1", Viewport: layout.Size{Width: 120, Height: 40}, Capabilities: capability.Manifest{Width: 120, Height: 40}, Reproducibility: performance.Reproducibility{Invocation: []string{"stave-performance", "-strict"}, SampleCount: 101, Strict: true, GOMAXPROCS: 1}, AllocBytes: 100, AllocLimit: 200, AllocWithin: true, IdleCPU: performance.RatioMeasurement{Name: "idle_cpu.percent_one_core", Value: .2, Limit: 1, AllWithinBudget: true}, Measurements: []performance.Measurement{{Name: "render.p95", Samples: 101, P95: 100 * time.Nanosecond, Limit: time.Microsecond, AllWithinBudget: true}}} + return performance.Report{Host: "host", GoVersion: "go1.22.0", GOOS: "linux", GOARCH: "amd64", CPUs: 8, Nodes: 10000, NodeShape: "balanced", Renderer: "stave.render/v1", Viewport: layout.Size{Width: 120, Height: 40}, Capabilities: capability.Manifest{Width: 120, Height: 40}, Reproducibility: performance.Reproducibility{Invocation: []string{"stave-performance", "-strict"}, SampleCount: 101, Strict: true, GOMAXPROCS: 1}, AllocBytes: 100, AllocLimit: 200, AllocWithin: true, IdleCPU: performance.RatioMeasurement{Name: "idle_cpu.percent_one_core", Window: time.Second, Value: .2, Limit: 1, AllWithinBudget: true}, Measurements: []performance.Measurement{{Name: "render.p95", Samples: 101, P95: 100 * time.Nanosecond, P99: 100 * time.Nanosecond, Limit: time.Microsecond, AllWithinBudget: true}}} } diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index d677aa5..04b0188 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -11,11 +11,13 @@ go run ./cmd/stave-performance-compare -baseline baseline.json -candidate candid ``` Both reports must be from the same host, Go version, OS/architecture, CPU -count, fixture, capabilities, and exact run parameters. The executable path +count, declared fixture metadata, capabilities, and exact run parameters. The executable path (`os.Args[0]`) is excluded because launch and build locations can differ -between runs or revisions, and the `-out` destination is excluded because it +between runs or revisions, and the `-out`/`--out` destination is excluded because it names the artifact rather than a measurement parameter. All remaining arguments -and environment metadata are compared. Source revisions are recorded and may +and environment metadata are compared. The report does not contain a fixture +content hash, so matching declared fixture metadata does not prove identical +fixture source content. Source revisions are recorded and may differ. The comparator rejects reports that fail their existing absolute budgets, change their budget schema, or use incompatible environment or reproducibility metadata. @@ -26,3 +28,6 @@ increase to absorb ordinary same-host noise, while idle CPU allows 0.10 percentage points. Exit status is `0` when all deltas are within tolerance, `2` for a valid regression, `3` for invalid or incompatible input, and `64` for usage errors. + +`percentDelta` is omitted when its baseline is zero because a relative change +is undefined in that case. diff --git a/performance/comparison.go b/performance/comparison.go index 4e7388b..c828748 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -9,6 +9,7 @@ import ( "math" "reflect" "sort" + "strings" "unicode/utf8" ) @@ -102,12 +103,13 @@ func CompareWithPolicy(baseline, candidate Report, policy ComparisonPolicy) (Com if err := ValidateReport(candidate); err != nil { return Comparison{}, fmt.Errorf("invalid candidate report: %w", err) } - if err := compatibleEnvironment(baseline, candidate); err != nil { + candidateMeasurements := measurementIndex(candidate.Measurements) + if err := compatibleEnvironment(baseline, candidate, candidateMeasurements); err != nil { return Comparison{}, err } deltas := make([]MetricDelta, 0, len(baseline.Measurements)+2) for _, metric := range baseline.Measurements { - candidateMetric := measurementByName(candidate.Measurements, metric.Name) + candidateMetric := candidateMeasurements[metric.Name] deltas = append(deltas, relativeDelta(metric.Name, "nanoseconds", float64(metric.P95), float64(candidateMetric.P95), policy.MeasurementTolerance)) } deltas = append(deltas, @@ -132,7 +134,7 @@ func validatePolicy(policy ComparisonPolicy) error { } func ValidateReport(report Report) error { - if report.Host == "" || report.GoVersion == "" || report.GOOS == "" || report.GOARCH == "" || report.CPUs < 1 || report.Nodes < 1 || report.NodeShape == "" || report.Renderer == "" || report.Viewport.Width < 1 || report.Viewport.Height < 1 { + if report.Host == "" || report.Host == "unknown" || report.GoVersion == "" || report.GOOS == "" || report.GOARCH == "" || report.CPUs < 1 || report.Nodes < 1 || report.NodeShape == "" || report.Renderer == "" || report.Viewport.Width < 1 || report.Viewport.Height < 1 { return errors.New("performance report has incomplete environment metadata") } if report.Reproducibility.SampleCount < 1 || report.Reproducibility.GOMAXPROCS < 1 || len(report.Reproducibility.Invocation) == 0 { @@ -141,15 +143,20 @@ func ValidateReport(report Report) error { if report.AllocBytes > report.AllocLimit || !report.AllocWithin { return errors.New("performance report fails its allocation budget") } - if report.IdleCPU.Name != "idle_cpu.percent_one_core" || math.IsNaN(report.IdleCPU.Value) || math.IsInf(report.IdleCPU.Value, 0) || math.IsNaN(report.IdleCPU.Limit) || math.IsInf(report.IdleCPU.Limit, 0) || report.IdleCPU.Value < 0 || report.IdleCPU.Limit <= 0 || report.IdleCPU.Value >= report.IdleCPU.Limit || !report.IdleCPU.AllWithinBudget { + if report.IdleCPU.Name != "idle_cpu.percent_one_core" || report.IdleCPU.Window <= 0 || math.IsNaN(report.IdleCPU.Value) || math.IsInf(report.IdleCPU.Value, 0) || math.IsNaN(report.IdleCPU.Limit) || math.IsInf(report.IdleCPU.Limit, 0) || report.IdleCPU.Value < 0 || report.IdleCPU.Limit <= 0 || report.IdleCPU.Value >= report.IdleCPU.Limit || !report.IdleCPU.AllWithinBudget { return errors.New("performance report fails its idle CPU budget") } + for _, attempt := range report.IdleCPU.Attempts { + if math.IsNaN(attempt) || math.IsInf(attempt, 0) || attempt < 0 { + return errors.New("performance report has invalid idle CPU attempts") + } + } if len(report.Measurements) == 0 { return errors.New("performance report has no measurements") } seen := map[string]struct{}{} for _, measurement := range report.Measurements { - if measurement.Name == "" || measurement.Samples < 1 || measurement.P50 < 0 || measurement.P95 < 0 || measurement.P99 < 0 || measurement.Limit <= 0 || measurement.P95 > measurement.Limit || !measurement.AllWithinBudget { + if measurement.Name == "" || measurement.Samples < 1 || measurement.P50 < 0 || measurement.P50 > measurement.P95 || measurement.P95 > measurement.P99 || measurement.P95 > measurement.Limit || measurement.Limit <= 0 || !measurement.AllWithinBudget { return fmt.Errorf("performance report fails absolute budget for %q", measurement.Name) } if _, duplicate := seen[measurement.Name]; duplicate { @@ -160,7 +167,7 @@ func ValidateReport(report Report) error { return nil } -func compatibleEnvironment(baseline, candidate Report) error { +func compatibleEnvironment(baseline, candidate Report, candidateMeasurements map[string]Measurement) error { if baseline.Host != candidate.Host || baseline.GoVersion != candidate.GoVersion || baseline.GOOS != candidate.GOOS || baseline.GOARCH != candidate.GOARCH || baseline.CPUs != candidate.CPUs || baseline.Nodes != candidate.Nodes || baseline.NodeShape != candidate.NodeShape || baseline.Renderer != candidate.Renderer || baseline.Viewport != candidate.Viewport || !reflect.DeepEqual(baseline.Capabilities, candidate.Capabilities) { return errors.New("performance reports use incompatible environment or fixture schema") } @@ -171,7 +178,7 @@ func compatibleEnvironment(baseline, candidate Report) error { return errors.New("performance reports use incompatible absolute budget schema") } for _, measurement := range baseline.Measurements { - candidateMetric := measurementByName(candidate.Measurements, measurement.Name) + candidateMetric := candidateMeasurements[measurement.Name] if candidateMetric.Name == "" || candidateMetric.Limit != measurement.Limit || candidateMetric.Samples != measurement.Samples { return fmt.Errorf("performance reports use incompatible metric schema for %q", measurement.Name) } @@ -185,11 +192,11 @@ func compatibleEnvironment(baseline, candidate Report) error { func comparableInvocation(invocation []string) []string { result := make([]string, 0, len(invocation)) for i := 1; i < len(invocation); i++ { - if invocation[i] == "-out" { + if invocation[i] == "-out" || invocation[i] == "--out" { i++ continue } - if len(invocation[i]) > len("-out=") && invocation[i][:len("-out=")] == "-out=" { + if strings.HasPrefix(invocation[i], "-out=") || strings.HasPrefix(invocation[i], "--out=") { continue } result = append(result, invocation[i]) @@ -197,13 +204,12 @@ func comparableInvocation(invocation []string) []string { return result } -func measurementByName(measurements []Measurement, name string) Measurement { +func measurementIndex(measurements []Measurement) map[string]Measurement { + indexed := make(map[string]Measurement, len(measurements)) for _, measurement := range measurements { - if measurement.Name == name { - return measurement - } + indexed[measurement.Name] = measurement } - return Measurement{} + return indexed } func relativeDelta(metric, unit string, baseline, candidate, tolerance float64) MetricDelta { diff --git a/performance/comparison_test.go b/performance/comparison_test.go index f09d0f6..1e03b62 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -17,6 +17,7 @@ func TestCompareProducesDeterministicDeltasAndAllowsSourceRevisionChange(t *test baseline.Reproducibility.Invocation = append(baseline.Reproducibility.Invocation, "-out", "baseline.json") candidate.Reproducibility.Invocation = append(candidate.Reproducibility.Invocation, "-out", "candidate.json") candidate.Measurements[0].P95 = 105 * time.Nanosecond + candidate.Measurements[0].P99 = 105 * time.Nanosecond comparison, err := Compare(baseline, candidate) if err != nil { t.Fatal(err) @@ -32,8 +33,8 @@ func TestCompareProducesDeterministicDeltasAndAllowsSourceRevisionChange(t *test func TestCompareIgnoresNonMeasurementInvocationPaths(t *testing.T) { baseline := comparisonFixture() candidate := comparisonFixture() - baseline.Reproducibility.Invocation = []string{"/private/var/folders/a/go-build123/b001/exe/stave-performance", "-strict", "-out", "baseline.json"} - candidate.Reproducibility.Invocation = []string{"/private/var/folders/b/go-build456/b001/exe/stave-performance", "-strict", "-out", "candidate.json"} + baseline.Reproducibility.Invocation = []string{"/private/var/folders/a/go-build123/b001/exe/stave-performance", "-strict", "--out=baseline.json"} + candidate.Reproducibility.Invocation = []string{"/private/var/folders/b/go-build456/b001/exe/stave-performance", "-strict", "--out", "candidate.json"} if _, err := Compare(baseline, candidate); err != nil { t.Fatalf("Compare() rejected equivalent invocation paths: %v", err) } @@ -58,7 +59,14 @@ func TestCompareWithPolicyRejectsInvalidTolerancesAndReports(t *testing.T) { for _, mutate := range []func(*Report){ func(r *Report) { r.IdleCPU.Value = math.NaN() }, func(r *Report) { r.IdleCPU.Value = -1 }, + func(r *Report) { r.IdleCPU.Window = 0 }, + func(r *Report) { r.Host = "unknown" }, + func(r *Report) { r.IdleCPU.Attempts = []float64{math.NaN()} }, + func(r *Report) { r.IdleCPU.Attempts = []float64{math.Inf(1)} }, + func(r *Report) { r.IdleCPU.Attempts = []float64{-1} }, + func(r *Report) { r.Measurements[0].P50 = r.Measurements[0].P95 + time.Nanosecond }, func(r *Report) { r.Measurements[0].P95 = -time.Nanosecond }, + func(r *Report) { r.Measurements[0].P99 = r.Measurements[0].P95 - time.Nanosecond }, } { invalid := comparisonFixture() mutate(&invalid) @@ -75,7 +83,9 @@ func TestCompareRejectsRegressionEnvironmentAndBudgetChanges(t *testing.T) { mutate func(*Report) wantOK bool }{ - {"regression", func(r *Report) { r.Measurements[0].P95 = 120 * time.Nanosecond }, false}, + {"regression", func(r *Report) { + r.Measurements[0].P95, r.Measurements[0].P99 = 120*time.Nanosecond, 120*time.Nanosecond + }, false}, {"host", func(r *Report) { r.Host = "other" }, true}, {"run parameters", func(r *Report) { r.Reproducibility.SampleCount++ }, true}, {"absolute budget", func(r *Report) { r.Measurements[0].Limit++ }, true}, @@ -125,7 +135,16 @@ func comparisonFixture() Report { Nodes: 10000, NodeShape: "balanced-binary", Renderer: "stave.render/v1", Viewport: layout.Size{Width: 120, Height: 40}, Capabilities: capability.Manifest{Width: 120, Height: 40}, Reproducibility: Reproducibility{Invocation: []string{"stave-performance", "-strict"}, SampleCount: 101, Strict: true, GOMAXPROCS: 1, VCSRevision: "baseline"}, AllocBytes: 100, AllocLimit: 200, AllocWithin: true, - IdleCPU: RatioMeasurement{Name: "idle_cpu.percent_one_core", Value: 0.20, Limit: 1, AllWithinBudget: true}, - Measurements: []Measurement{{Name: "render.p95", Samples: 101, P95: 100 * time.Nanosecond, Limit: time.Microsecond, AllWithinBudget: true}}, + IdleCPU: RatioMeasurement{Name: "idle_cpu.percent_one_core", Window: time.Second, Value: 0.20, Limit: 1, AllWithinBudget: true}, + Measurements: []Measurement{{Name: "render.p95", Samples: 101, P95: 100 * time.Nanosecond, P99: 100 * time.Nanosecond, Limit: time.Microsecond, AllWithinBudget: true}}, + } +} + +func TestComparePreservesP95AbsoluteBudget(t *testing.T) { + report := comparisonFixture() + report.Measurements[0].P99 = report.Measurements[0].Limit + time.Nanosecond + report.IdleCPU.Attempts = []float64{report.IdleCPU.Limit + 1, report.IdleCPU.Value} + if _, err := Compare(report, report); err != nil { + t.Fatalf("valid p95/retry report rejected: %v", err) } } diff --git a/scripts/rigor/generated/dependency-inventory.json b/scripts/rigor/generated/dependency-inventory.json index 7bf9f44..b4389cf 100644 --- a/scripts/rigor/generated/dependency-inventory.json +++ b/scripts/rigor/generated/dependency-inventory.json @@ -437,6 +437,7 @@ "runtime", "runtime/metrics", "sort", + "strings", "time", "unicode/utf8" ] From 22df60a72fe4f6a5e8e8e8133b54534592307973 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:05:07 +1000 Subject: [PATCH 05/11] fix(performance): allow sequential report build provenance --- docs/performance-baseline.md | 6 ++++-- performance/comparison.go | 2 +- performance/comparison_test.go | 9 +++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 04b0188..41f2c25 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -17,8 +17,10 @@ between runs or revisions, and the `-out`/`--out` destination is excluded becaus names the artifact rather than a measurement parameter. All remaining arguments and environment metadata are compared. The report does not contain a fixture content hash, so matching declared fixture metadata does not prove identical -fixture source content. Source revisions are recorded and may -differ. The comparator rejects reports that fail their existing absolute +fixture source content. Source revisions and dirty-worktree flags are recorded provenance and may +differ, including when writing the first report changes the next build’s dirty +flag. The idle CPU window records actual elapsed time, so it is validated as +positive but is not required to match exactly between runs. The comparator rejects reports that fail their existing absolute budgets, change their budget schema, or use incompatible environment or reproducibility metadata. diff --git a/performance/comparison.go b/performance/comparison.go index c828748..e1a9d27 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -171,7 +171,7 @@ func compatibleEnvironment(baseline, candidate Report, candidateMeasurements map if baseline.Host != candidate.Host || baseline.GoVersion != candidate.GoVersion || baseline.GOOS != candidate.GOOS || baseline.GOARCH != candidate.GOARCH || baseline.CPUs != candidate.CPUs || baseline.Nodes != candidate.Nodes || baseline.NodeShape != candidate.NodeShape || baseline.Renderer != candidate.Renderer || baseline.Viewport != candidate.Viewport || !reflect.DeepEqual(baseline.Capabilities, candidate.Capabilities) { return errors.New("performance reports use incompatible environment or fixture schema") } - if baseline.Reproducibility.SampleCount != candidate.Reproducibility.SampleCount || baseline.Reproducibility.Strict != candidate.Reproducibility.Strict || baseline.Reproducibility.GOMAXPROCS != candidate.Reproducibility.GOMAXPROCS || baseline.Reproducibility.VCSModified != candidate.Reproducibility.VCSModified || !reflect.DeepEqual(comparableInvocation(baseline.Reproducibility.Invocation), comparableInvocation(candidate.Reproducibility.Invocation)) { + if baseline.Reproducibility.SampleCount != candidate.Reproducibility.SampleCount || baseline.Reproducibility.Strict != candidate.Reproducibility.Strict || baseline.Reproducibility.GOMAXPROCS != candidate.Reproducibility.GOMAXPROCS || !reflect.DeepEqual(comparableInvocation(baseline.Reproducibility.Invocation), comparableInvocation(candidate.Reproducibility.Invocation)) { return errors.New("performance reports use incompatible reproducibility parameters") } if baseline.AllocLimit != candidate.AllocLimit || baseline.IdleCPU.Limit != candidate.IdleCPU.Limit || baseline.IdleCPU.Name != candidate.IdleCPU.Name || len(baseline.Measurements) != len(candidate.Measurements) { diff --git a/performance/comparison_test.go b/performance/comparison_test.go index 1e03b62..fd159a7 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -148,3 +148,12 @@ func TestComparePreservesP95AbsoluteBudget(t *testing.T) { t.Fatalf("valid p95/retry report rejected: %v", err) } } + +func TestCompareAllowsBuildProvenanceAndObservedIdleWindowChanges(t *testing.T) { + baseline, candidate := comparisonFixture(), comparisonFixture() + candidate.Reproducibility.VCSModified = !baseline.Reproducibility.VCSModified + candidate.IdleCPU.Window += time.Nanosecond + if _, err := Compare(baseline, candidate); err != nil { + t.Fatalf("source provenance or observed elapsed window rejected: %v", err) + } +} From 0fd080dc5b56277d28caa24b3239f226d26bc473 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:05:07 +1000 Subject: [PATCH 06/11] refactor(performance): isolate idle CPU report validation --- performance/comparison.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/performance/comparison.go b/performance/comparison.go index e1a9d27..e76c057 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -143,13 +143,8 @@ func ValidateReport(report Report) error { if report.AllocBytes > report.AllocLimit || !report.AllocWithin { return errors.New("performance report fails its allocation budget") } - if report.IdleCPU.Name != "idle_cpu.percent_one_core" || report.IdleCPU.Window <= 0 || math.IsNaN(report.IdleCPU.Value) || math.IsInf(report.IdleCPU.Value, 0) || math.IsNaN(report.IdleCPU.Limit) || math.IsInf(report.IdleCPU.Limit, 0) || report.IdleCPU.Value < 0 || report.IdleCPU.Limit <= 0 || report.IdleCPU.Value >= report.IdleCPU.Limit || !report.IdleCPU.AllWithinBudget { - return errors.New("performance report fails its idle CPU budget") - } - for _, attempt := range report.IdleCPU.Attempts { - if math.IsNaN(attempt) || math.IsInf(attempt, 0) || attempt < 0 { - return errors.New("performance report has invalid idle CPU attempts") - } + if err := validateIdleCPUReport(report.IdleCPU); err != nil { + return err } if len(report.Measurements) == 0 { return errors.New("performance report has no measurements") @@ -167,6 +162,18 @@ func ValidateReport(report Report) error { return nil } +func validateIdleCPUReport(metric RatioMeasurement) error { + if metric.Name != "idle_cpu.percent_one_core" || metric.Window <= 0 || math.IsNaN(metric.Value) || math.IsInf(metric.Value, 0) || math.IsNaN(metric.Limit) || math.IsInf(metric.Limit, 0) || metric.Value < 0 || metric.Limit <= 0 || metric.Value >= metric.Limit || !metric.AllWithinBudget { + return errors.New("performance report fails its idle CPU budget") + } + for _, attempt := range metric.Attempts { + if math.IsNaN(attempt) || math.IsInf(attempt, 0) || attempt < 0 { + return errors.New("performance report has invalid idle CPU attempts") + } + } + return nil +} + func compatibleEnvironment(baseline, candidate Report, candidateMeasurements map[string]Measurement) error { if baseline.Host != candidate.Host || baseline.GoVersion != candidate.GoVersion || baseline.GOOS != candidate.GOOS || baseline.GOARCH != candidate.GOARCH || baseline.CPUs != candidate.CPUs || baseline.Nodes != candidate.Nodes || baseline.NodeShape != candidate.NodeShape || baseline.Renderer != candidate.Renderer || baseline.Viewport != candidate.Viewport || !reflect.DeepEqual(baseline.Capabilities, candidate.Capabilities) { return errors.New("performance reports use incompatible environment or fixture schema") From 11ce539ae34f87294d525e4473a96a1671b09b18 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:44:52 +1000 Subject: [PATCH 07/11] fix(performance): stream nested report validation --- performance/comparison.go | 36 ++++++++++++++++++---------------- performance/comparison_test.go | 28 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/performance/comparison.go b/performance/comparison.go index e76c057..dbe4af3 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -236,16 +236,27 @@ func absoluteDelta(metric, unit string, baseline, candidate, tolerance float64) } func validateJSONKeys(data []byte, depth int) error { - if depth > 64 { - return errors.New("performance report JSON nesting exceeds limit") - } decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := validateJSONValue(decoder, depth); err != nil { + return err + } + return ensureReportEOF(decoder) +} + +func validateJSONValue(decoder *json.Decoder, depth int) error { token, err := decoder.Token() if err != nil { return err } - delim, ok := token.(json.Delim) - if !ok || (delim != '{' && delim != '[') { + delim, container := token.(json.Delim) + if !container { + return nil + } + if depth > 64 { + return errors.New("performance report JSON nesting exceeds limit") + } + if delim != '{' && delim != '[' { return errors.New("invalid performance report JSON") } seen := map[string]struct{}{} @@ -255,21 +266,12 @@ func validateJSONKeys(data []byte, depth int) error { return err } } - var raw json.RawMessage - if err := decoder.Decode(&raw); err != nil { + if err := validateJSONValue(decoder, depth+1); err != nil { return err } - trimmed := bytes.TrimSpace(raw) - if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') { - if err := validateJSONKeys(trimmed, depth+1); err != nil { - return err - } - } - } - if _, err := decoder.Token(); err != nil { - return err } - return ensureReportEOF(decoder) + _, err = decoder.Token() + return err } func validateJSONObjectKey(decoder *json.Decoder, seen map[string]struct{}) error { diff --git a/performance/comparison_test.go b/performance/comparison_test.go index fd159a7..0c39197 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -3,6 +3,8 @@ package performance import ( "encoding/json" "math" + "runtime" + "strings" "testing" "time" @@ -157,3 +159,29 @@ func TestCompareAllowsBuildProvenanceAndObservedIdleWindowChanges(t *testing.T) t.Fatalf("source provenance or observed elapsed window rejected: %v", err) } } + +func TestReportJSONValidationDoesNotCopyEnclosingSubtrees(t *testing.T) { + data := []byte(`{"unknown":` + strings.Repeat("[", 48) + `"` + strings.Repeat("x", 1<<20) + `"` + strings.Repeat("]", 48) + `}`) + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + if err := validateJSONKeys(data, 0); err != nil { + t.Fatal(err) + } + runtime.ReadMemStats(&after) + // Allow decoder buffering and token copies, but not one full value copy per ancestor. + if allocated := after.TotalAlloc - before.TotalAlloc; allocated > uint64(12*len(data)) { + t.Fatalf("nested JSON validation allocated %d bytes for %d input bytes", allocated, len(data)) + } +} + +func TestReportJSONValidationRetainsNestedContracts(t *testing.T) { + for _, data := range []string{`{"a":[{"x":1,"x":2}]}`, `{"a":[1,]}`, `{"a":[]} {}`, strings.Repeat("[", 66) + `0` + strings.Repeat("]", 66)} { + if err := validateJSONKeys([]byte(data), 0); err == nil { + t.Fatalf("invalid nested JSON accepted: %.80s", data) + } + } + if err := validateJSONKeys([]byte(strings.Repeat("[", 65)+`0`+strings.Repeat("]", 65)), 0); err != nil { + t.Fatalf("depth boundary rejected: %v", err) + } +} From 97932f0b6f871d436bc33bbc56d5f7bf153956ed Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:20:08 +1000 Subject: [PATCH 08/11] fix(performance): preserve zero deltas and reject key aliases --- performance/comparison.go | 108 +++++++++++++++++++++---- performance/comparison_test.go | 96 ++++++++++++++++++++++ scripts/rigor/generated/public-api.txt | 1 + 3 files changed, 191 insertions(+), 14 deletions(-) diff --git a/performance/comparison.go b/performance/comparison.go index dbe4af3..b97dd13 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -55,6 +55,22 @@ type MetricDelta struct { WithinTolerance bool `json:"withinTolerance"` } +// MarshalJSON preserves the public float64 API while making percentDelta +// optional only for relative metrics with a zero baseline. A defined 0% delta +// remains visible, while undefined relative and absolute deltas omit the field. +func (delta MetricDelta) MarshalJSON() ([]byte, error) { + type metricDeltaWire MetricDelta + var percent *float64 + if delta.ToleranceUnit == "relative" && delta.Baseline != 0 { + value := delta.PercentDelta + percent = &value + } + return json.Marshal(struct { + metricDeltaWire + PercentDelta *float64 `json:"percentDelta,omitempty"` + }{metricDeltaWire: metricDeltaWire(delta), PercentDelta: percent}) +} + // DecodeReport strictly decodes an existing performance report without // changing its wire schema. It rejects duplicate and unknown fields so a // baseline cannot silently reinterpret saved measurements. @@ -66,7 +82,7 @@ func DecodeReport(data []byte) (Report, error) { if len(data) == 0 || !utf8.Valid(data) || data[0] != '{' { return Report{}, errors.New("performance report must be a UTF-8 JSON object") } - if err := validateJSONKeys(data, 0); err != nil { + if err := validateReportJSONKeys(data); err != nil { return Report{}, fmt.Errorf("decode performance report: %w", err) } decoder := json.NewDecoder(bytes.NewReader(data)) @@ -235,16 +251,25 @@ func absoluteDelta(metric, unit string, baseline, candidate, tolerance float64) return MetricDelta{Metric: metric, Unit: unit, Baseline: baseline, Candidate: candidate, Delta: delta, Tolerance: tolerance, ToleranceUnit: "absolute", WithinTolerance: delta <= tolerance && !math.IsNaN(candidate) && !math.IsInf(candidate, 0)} } +func validateReportJSONKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := validateTypedJSONValue(decoder, 0, reflect.TypeOf(Report{})); err != nil { + return err + } + return ensureReportEOF(decoder) +} + func validateJSONKeys(data []byte, depth int) error { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() - if err := validateJSONValue(decoder, depth); err != nil { + if err := validateTypedJSONValue(decoder, depth, nil); err != nil { return err } return ensureReportEOF(decoder) } -func validateJSONValue(decoder *json.Decoder, depth int) error { +func validateTypedJSONValue(decoder *json.Decoder, depth int, typ reflect.Type) error { token, err := decoder.Token() if err != nil { return err @@ -259,35 +284,90 @@ func validateJSONValue(decoder *json.Decoder, depth int) error { if delim != '{' && delim != '[' { return errors.New("invalid performance report JSON") } - seen := map[string]struct{}{} - for decoder.More() { - if delim == '{' { - if err := validateJSONObjectKey(decoder, seen); err != nil { + typ = indirectJSONType(typ) + switch delim { + case '{': + fields := jsonStructFields(typ) + seen := map[string]struct{}{} + for decoder.More() { + key, err := validateJSONObjectKey(decoder, seen) + if err != nil { + return err + } + fieldType := reflect.Type(nil) + if fields != nil { + var exists bool + fieldType, exists = fields[key] + if !exists { + for name := range fields { + if strings.EqualFold(key, name) { + return fmt.Errorf("noncanonical performance report key %q, want %q", key, name) + } + } + } + } + if err := validateTypedJSONValue(decoder, depth+1, fieldType); err != nil { return err } } - if err := validateJSONValue(decoder, depth+1); err != nil { - return err + case '[': + var element reflect.Type + if typ != nil && (typ.Kind() == reflect.Array || typ.Kind() == reflect.Slice) { + element = typ.Elem() + } + for decoder.More() { + if err := validateTypedJSONValue(decoder, depth+1, element); err != nil { + return err + } } } _, err = decoder.Token() return err } -func validateJSONObjectKey(decoder *json.Decoder, seen map[string]struct{}) error { +func indirectJSONType(typ reflect.Type) reflect.Type { + for typ != nil && typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + return typ +} + +func jsonStructFields(typ reflect.Type) map[string]reflect.Type { + if typ == nil || typ.Kind() != reflect.Struct { + return nil + } + fields := make(map[string]reflect.Type) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.PkgPath != "" { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + fields[name] = field.Type + } + return fields +} + +func validateJSONObjectKey(decoder *json.Decoder, seen map[string]struct{}) (string, error) { keyToken, err := decoder.Token() if err != nil { - return err + return "", err } key, ok := keyToken.(string) if !ok { - return errors.New("invalid performance report object key") + return "", errors.New("invalid performance report object key") } if _, exists := seen[key]; exists { - return fmt.Errorf("duplicate performance report key %q", key) + return "", fmt.Errorf("duplicate performance report key %q", key) } seen[key] = struct{}{} - return nil + return key, nil } func ensureReportEOF(decoder *json.Decoder) error { diff --git a/performance/comparison_test.go b/performance/comparison_test.go index 0c39197..339e7f6 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -1,6 +1,7 @@ package performance import ( + "bytes" "encoding/json" "math" "runtime" @@ -111,6 +112,80 @@ func TestCompareRejectsRegressionEnvironmentAndBudgetChanges(t *testing.T) { } } +func TestComparisonJSONDistinguishesDefinedAndUndefinedZeroPercentDelta(t *testing.T) { + baseline, candidate := comparisonFixture(), comparisonFixture() + comparison, err := Compare(baseline, candidate) + if err != nil { + t.Fatal(err) + } + if percent := percentDeltaForMetric(t, comparison, "alloc_bytes"); percent == nil || *percent != 0 { + t.Fatalf("defined zero percent delta = %v, want 0", percent) + } + if percentDeltaFieldCount(t, comparison, "alloc_bytes") != 1 { + t.Fatal("defined percent delta was emitted more than once") + } + + baseline.AllocBytes, candidate.AllocBytes = 0, 0 + comparison, err = Compare(baseline, candidate) + if err != nil { + t.Fatal(err) + } + if percentDeltaForMetric(t, comparison, "alloc_bytes") != nil { + t.Fatal("undefined zero-baseline percent delta was emitted") + } +} + +func percentDeltaForMetric(t *testing.T, comparison Comparison, metric string) *float64 { + t.Helper() + data, err := json.Marshal(comparison) + if err != nil { + t.Fatal(err) + } + var wire struct { + Deltas []struct { + Metric string `json:"metric"` + PercentDelta *float64 `json:"percentDelta"` + } `json:"deltas"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + for _, delta := range wire.Deltas { + if delta.Metric == metric { + return delta.PercentDelta + } + } + t.Fatalf("comparison omitted metric %q", metric) + return nil +} + +func percentDeltaFieldCount(t *testing.T, comparison Comparison, metric string) int { + t.Helper() + data, err := json.Marshal(comparison) + if err != nil { + t.Fatal(err) + } + var wire struct { + Deltas []json.RawMessage `json:"deltas"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + for _, delta := range wire.Deltas { + var header struct { + Metric string `json:"metric"` + } + if err := json.Unmarshal(delta, &header); err != nil { + t.Fatal(err) + } + if header.Metric == metric { + return bytes.Count(delta, []byte(`"percentDelta"`)) + } + } + t.Fatalf("comparison omitted metric %q", metric) + return 0 +} + func TestDecodeReportRejectsDuplicateAndUnknownFields(t *testing.T) { report := comparisonFixture() data, err := json.Marshal(report) @@ -131,6 +206,27 @@ func TestDecodeReportRejectsDuplicateAndUnknownFields(t *testing.T) { } } +func TestDecodeReportRejectsCaseVariantTypedKeys(t *testing.T) { + data, err := json.Marshal(comparisonFixture()) + if err != nil { + t.Fatal(err) + } + for _, replacement := range []struct { + old, new []byte + }{ + {[]byte(`"host":"host"`), []byte(`"host":"host","Host":"other"`)}, + {[]byte(`"sampleCount":101`), []byte(`"sampleCount":101,"SampleCount":101`)}, + } { + mutated := bytes.Replace(data, replacement.old, replacement.new, 1) + if bytes.Equal(mutated, data) { + t.Fatalf("fixture did not add case-variant key %s", replacement.new) + } + if _, err := DecodeReport(mutated); err == nil { + t.Fatalf("DecodeReport() accepted case-variant typed key %s", replacement.new) + } + } +} + func comparisonFixture() Report { return Report{ Host: "host", GoVersion: "go1.22.0", GOOS: "linux", GOARCH: "amd64", CPUs: 8, diff --git a/scripts/rigor/generated/public-api.txt b/scripts/rigor/generated/public-api.txt index fe63c54..562b4d7 100644 --- a/scripts/rigor/generated/public-api.txt +++ b/scripts/rigor/generated/public-api.txt @@ -452,6 +452,7 @@ method (*measurementDriver) Events() <-chan event.Event method (*measurementDriver) Open(context.Context, capability.Policy) (capability.Manifest, error) method (*measurementDriver) ReadSecret(context.Context, input.SecretPrompt) (secret.Handle, error) method (*measurementDriver) Restore(context.Context) error +method (MetricDelta) MarshalJSON() ([]byte, error) type Attempt struct { P50 time.Duration `json:"p50"`; P95 time.Duration `json:"p95"`; P99 time.Duration `json:"p99"` } type Budget struct { Name string `json:"name"`; Limit time.Duration `json:"limit"` } type Comparison struct { SchemaVersion string `json:"schemaVersion"`; Policy string `json:"policy"`; BaselineRevision string `json:"baselineRevision,omitempty"`; CandidateRevision string `json:"candidateRevision,omitempty"`; Deltas []MetricDelta `json:"deltas"`; Passed bool `json:"passed"` } From d2e45c6d0067f01449c93c878da5a58b15d8046c Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:40:32 +1000 Subject: [PATCH 09/11] fix(performance): compare attempt counts and reuse report schemas --- performance/comparison.go | 15 ++++- performance/comparison_test.go | 62 +++++++++++++++++++ .../rigor/generated/dependency-inventory.json | 1 + 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/performance/comparison.go b/performance/comparison.go index b97dd13..02fbf21 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -10,6 +10,7 @@ import ( "reflect" "sort" "strings" + "sync" "unicode/utf8" ) @@ -197,12 +198,12 @@ func compatibleEnvironment(baseline, candidate Report, candidateMeasurements map if baseline.Reproducibility.SampleCount != candidate.Reproducibility.SampleCount || baseline.Reproducibility.Strict != candidate.Reproducibility.Strict || baseline.Reproducibility.GOMAXPROCS != candidate.Reproducibility.GOMAXPROCS || !reflect.DeepEqual(comparableInvocation(baseline.Reproducibility.Invocation), comparableInvocation(candidate.Reproducibility.Invocation)) { return errors.New("performance reports use incompatible reproducibility parameters") } - if baseline.AllocLimit != candidate.AllocLimit || baseline.IdleCPU.Limit != candidate.IdleCPU.Limit || baseline.IdleCPU.Name != candidate.IdleCPU.Name || len(baseline.Measurements) != len(candidate.Measurements) { + if baseline.AllocLimit != candidate.AllocLimit || baseline.IdleCPU.Limit != candidate.IdleCPU.Limit || baseline.IdleCPU.Name != candidate.IdleCPU.Name || len(baseline.IdleCPU.Attempts) != len(candidate.IdleCPU.Attempts) || len(baseline.Measurements) != len(candidate.Measurements) { return errors.New("performance reports use incompatible absolute budget schema") } for _, measurement := range baseline.Measurements { candidateMetric := candidateMeasurements[measurement.Name] - if candidateMetric.Name == "" || candidateMetric.Limit != measurement.Limit || candidateMetric.Samples != measurement.Samples { + if candidateMetric.Name == "" || candidateMetric.Limit != measurement.Limit || candidateMetric.Samples != measurement.Samples || len(candidateMetric.Attempts) != len(measurement.Attempts) { return fmt.Errorf("performance reports use incompatible metric schema for %q", measurement.Name) } } @@ -332,10 +333,17 @@ func indirectJSONType(typ reflect.Type) reflect.Type { return typ } +// jsonStructFieldCache stores immutable field maps keyed by the framework type. +// Typed array entries reuse their schema without allocating a map per object. +var jsonStructFieldCache sync.Map // map[reflect.Type]map[string]reflect.Type + func jsonStructFields(typ reflect.Type) map[string]reflect.Type { if typ == nil || typ.Kind() != reflect.Struct { return nil } + if cached, ok := jsonStructFieldCache.Load(typ); ok { + return cached.(map[string]reflect.Type) + } fields := make(map[string]reflect.Type) for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) @@ -351,7 +359,8 @@ func jsonStructFields(typ reflect.Type) map[string]reflect.Type { } fields[name] = field.Type } - return fields + actual, _ := jsonStructFieldCache.LoadOrStore(typ, fields) + return actual.(map[string]reflect.Type) } func validateJSONObjectKey(decoder *json.Decoder, seen map[string]struct{}) (string, error) { diff --git a/performance/comparison_test.go b/performance/comparison_test.go index 339e7f6..1561c2e 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -112,6 +112,46 @@ func TestCompareRejectsRegressionEnvironmentAndBudgetChanges(t *testing.T) { } } +func TestCompareRequiresEqualAttemptCounts(t *testing.T) { + baseline, candidate := comparisonFixture(), comparisonFixture() + baseline.Measurements[0].Attempts = []Attempt{ + {P50: time.Nanosecond, P95: 2 * time.Nanosecond, P99: 3 * time.Nanosecond}, + {P50: 4 * time.Nanosecond, P95: 5 * time.Nanosecond, P99: 6 * time.Nanosecond}, + } + candidate.Measurements[0].Attempts = []Attempt{ + {P50: 7 * time.Nanosecond, P95: 8 * time.Nanosecond, P99: 9 * time.Nanosecond}, + {P50: 10 * time.Nanosecond, P95: 11 * time.Nanosecond, P99: 12 * time.Nanosecond}, + } + baseline.IdleCPU.Attempts = []float64{0.01, 0.02} + candidate.IdleCPU.Attempts = []float64{0.03, 0.04} + if _, err := Compare(baseline, candidate); err != nil { + t.Fatalf("Compare() rejected equal attempt counts with different timings: %v", err) + } + + for _, mutate := range []struct { + name string + apply func(*Report) + }{ + {"measurement", func(report *Report) { + report.Measurements[0].Attempts = append(report.Measurements[0].Attempts, Attempt{}) + }}, + {"idle CPU", func(report *Report) { + report.IdleCPU.Attempts = append(report.IdleCPU.Attempts, 0.05) + }}, + } { + t.Run(mutate.name, func(t *testing.T) { + mismatched := candidate + mismatched.Measurements = append([]Measurement(nil), candidate.Measurements...) + mismatched.Measurements[0].Attempts = append([]Attempt(nil), candidate.Measurements[0].Attempts...) + mismatched.IdleCPU.Attempts = append([]float64(nil), candidate.IdleCPU.Attempts...) + mutate.apply(&mismatched) + if _, err := Compare(baseline, mismatched); err == nil { + t.Fatal("Compare() accepted mismatched attempt counts") + } + }) + } +} + func TestComparisonJSONDistinguishesDefinedAndUndefinedZeroPercentDelta(t *testing.T) { baseline, candidate := comparisonFixture(), comparisonFixture() comparison, err := Compare(baseline, candidate) @@ -281,3 +321,25 @@ func TestReportJSONValidationRetainsNestedContracts(t *testing.T) { t.Fatalf("depth boundary rejected: %v", err) } } + +func TestReportJSONValidationCachesTypedFieldMaps(t *testing.T) { + data := []byte(`{"measurements":[` + strings.Repeat(`{},`, 9_999) + `{}]}`) + if err := validateReportJSONKeys(data); err != nil { + t.Fatal(err) + } + measure := func(validate func([]byte) error) uint64 { + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + if err := validate(data); err != nil { + t.Fatal(err) + } + runtime.ReadMemStats(&after) + return after.TotalAlloc - before.TotalAlloc + } + generic := measure(func(data []byte) error { return validateJSONKeys(data, 0) }) + typed := measure(validateReportJSONKeys) + if typed > generic+uint64(4*len(data)) { + t.Fatalf("typed field schema validation allocated %d bytes versus generic %d for %d-byte input", typed, generic, len(data)) + } +} diff --git a/scripts/rigor/generated/dependency-inventory.json b/scripts/rigor/generated/dependency-inventory.json index b4389cf..1ed81f8 100644 --- a/scripts/rigor/generated/dependency-inventory.json +++ b/scripts/rigor/generated/dependency-inventory.json @@ -438,6 +438,7 @@ "runtime/metrics", "sort", "strings", + "sync", "time", "unicode/utf8" ] From 0dd289286eaa2a47589c0868bd58659bf2a670f8 Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:40:32 +1000 Subject: [PATCH 10/11] refactor(performance): simplify typed report validation --- performance/comparison.go | 84 +++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/performance/comparison.go b/performance/comparison.go index 02fbf21..9dcc510 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -282,50 +282,66 @@ func validateTypedJSONValue(decoder *json.Decoder, depth int, typ reflect.Type) if depth > 64 { return errors.New("performance report JSON nesting exceeds limit") } - if delim != '{' && delim != '[' { - return errors.New("invalid performance report JSON") - } typ = indirectJSONType(typ) switch delim { case '{': - fields := jsonStructFields(typ) - seen := map[string]struct{}{} - for decoder.More() { - key, err := validateJSONObjectKey(decoder, seen) - if err != nil { - return err - } - fieldType := reflect.Type(nil) - if fields != nil { - var exists bool - fieldType, exists = fields[key] - if !exists { - for name := range fields { - if strings.EqualFold(key, name) { - return fmt.Errorf("noncanonical performance report key %q, want %q", key, name) - } - } - } - } - if err := validateTypedJSONValue(decoder, depth+1, fieldType); err != nil { - return err - } - } + return validateTypedJSONObject(decoder, depth, typ) case '[': - var element reflect.Type - if typ != nil && (typ.Kind() == reflect.Array || typ.Kind() == reflect.Slice) { - element = typ.Elem() + return validateTypedJSONArray(decoder, depth, typ) + default: + return errors.New("invalid performance report JSON") + } +} + +func validateTypedJSONObject(decoder *json.Decoder, depth int, typ reflect.Type) error { + fields := jsonStructFields(typ) + seen := map[string]struct{}{} + for decoder.More() { + key, err := validateJSONObjectKey(decoder, seen) + if err != nil { + return err + } + fieldType, err := typedJSONField(fields, key) + if err != nil { + return err + } + if err := validateTypedJSONValue(decoder, depth+1, fieldType); err != nil { + return err } - for decoder.More() { - if err := validateTypedJSONValue(decoder, depth+1, element); err != nil { - return err - } + } + _, err := decoder.Token() + return err +} + +func validateTypedJSONArray(decoder *json.Decoder, depth int, typ reflect.Type) error { + var element reflect.Type + if typ != nil && (typ.Kind() == reflect.Array || typ.Kind() == reflect.Slice) { + element = typ.Elem() + } + for decoder.More() { + if err := validateTypedJSONValue(decoder, depth+1, element); err != nil { + return err } } - _, err = decoder.Token() + _, err := decoder.Token() return err } +func typedJSONField(fields map[string]reflect.Type, key string) (reflect.Type, error) { + if fields == nil { + return nil, nil + } + if fieldType, exists := fields[key]; exists { + return fieldType, nil + } + for name := range fields { + if strings.EqualFold(key, name) { + return nil, fmt.Errorf("noncanonical performance report key %q, want %q", key, name) + } + } + return nil, nil +} + func indirectJSONType(typ reflect.Type) reflect.Type { for typ != nil && typ.Kind() == reflect.Pointer { typ = typ.Elem() From 19e60263275ba70aeb999f5a1e96ec1112f973ed Mon Sep 17 00:00:00 2001 From: Ben Ranford <84072202+ben-ranford@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:00:37 +1000 Subject: [PATCH 11/11] fix(performance): validate retained collection evidence --- docs/performance-baseline.md | 7 +++- performance/comparison.go | 40 ++++++++++++++++-- performance/comparison_test.go | 75 +++++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 41f2c25..b37701d 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -15,7 +15,12 @@ count, declared fixture metadata, capabilities, and exact run parameters. The ex (`os.Args[0]`) is excluded because launch and build locations can differ between runs or revisions, and the `-out`/`--out` destination is excluded because it names the artifact rather than a measurement parameter. All remaining arguments -and environment metadata are compared. The report does not contain a fixture +and environment metadata are compared. Declared invariants must match in order, +and retained-attempt dispositions must match because they declare the fixed +collector policy. Retained attempts, when present, must contain nonnegative, +ordered percentile tuples. The aggregate must match the existing collector’s +selection rule, and the idle CPU value must equal its lowest retained attempt. +The report does not contain a fixture content hash, so matching declared fixture metadata does not prove identical fixture source content. Source revisions and dirty-worktree flags are recorded provenance and may differ, including when writing the first report changes the next build’s dirty diff --git a/performance/comparison.go b/performance/comparison.go index 9dcc510..b885171 100644 --- a/performance/comparison.go +++ b/performance/comparison.go @@ -171,6 +171,9 @@ func ValidateReport(report Report) error { if measurement.Name == "" || measurement.Samples < 1 || measurement.P50 < 0 || measurement.P50 > measurement.P95 || measurement.P95 > measurement.P99 || measurement.P95 > measurement.Limit || measurement.Limit <= 0 || !measurement.AllWithinBudget { return fmt.Errorf("performance report fails absolute budget for %q", measurement.Name) } + if err := validateMeasurementAttempts(measurement); err != nil { + return fmt.Errorf("performance report has invalid retained attempts for %q: %w", measurement.Name, err) + } if _, duplicate := seen[measurement.Name]; duplicate { return fmt.Errorf("performance report has duplicate metric %q", measurement.Name) } @@ -179,31 +182,62 @@ func ValidateReport(report Report) error { return nil } +func validateMeasurementAttempts(measurement Measurement) error { + if len(measurement.Attempts) == 0 { + return nil + } + var selected Attempt + for _, attempt := range measurement.Attempts { + if attempt.P50 < 0 || attempt.P50 > attempt.P95 || attempt.P95 > attempt.P99 { + return errors.New("attempt percentiles are not ordered non-negative durations") + } + // This deliberately mirrors the existing collector's zero-P95 sentinel: + // a zero selection is replaced by the next retained attempt. + if selected.P95 == 0 || attempt.P95 < selected.P95 { + selected = attempt + } + } + if measurement.P50 != selected.P50 || measurement.P95 != selected.P95 || measurement.P99 != selected.P99 { + return errors.New("aggregate does not match the selected retained attempt") + } + return nil +} + func validateIdleCPUReport(metric RatioMeasurement) error { if metric.Name != "idle_cpu.percent_one_core" || metric.Window <= 0 || math.IsNaN(metric.Value) || math.IsInf(metric.Value, 0) || math.IsNaN(metric.Limit) || math.IsInf(metric.Limit, 0) || metric.Value < 0 || metric.Limit <= 0 || metric.Value >= metric.Limit || !metric.AllWithinBudget { return errors.New("performance report fails its idle CPU budget") } + if len(metric.Attempts) == 0 { + return nil + } + minimum := metric.Attempts[0] for _, attempt := range metric.Attempts { if math.IsNaN(attempt) || math.IsInf(attempt, 0) || attempt < 0 { return errors.New("performance report has invalid idle CPU attempts") } + if attempt < minimum { + minimum = attempt + } + } + if metric.Value != minimum { + return errors.New("idle CPU value does not match the lowest retained attempt") } return nil } func compatibleEnvironment(baseline, candidate Report, candidateMeasurements map[string]Measurement) error { - if baseline.Host != candidate.Host || baseline.GoVersion != candidate.GoVersion || baseline.GOOS != candidate.GOOS || baseline.GOARCH != candidate.GOARCH || baseline.CPUs != candidate.CPUs || baseline.Nodes != candidate.Nodes || baseline.NodeShape != candidate.NodeShape || baseline.Renderer != candidate.Renderer || baseline.Viewport != candidate.Viewport || !reflect.DeepEqual(baseline.Capabilities, candidate.Capabilities) { + if baseline.Host != candidate.Host || baseline.GoVersion != candidate.GoVersion || baseline.GOOS != candidate.GOOS || baseline.GOARCH != candidate.GOARCH || baseline.CPUs != candidate.CPUs || baseline.Nodes != candidate.Nodes || baseline.NodeShape != candidate.NodeShape || baseline.Renderer != candidate.Renderer || baseline.Viewport != candidate.Viewport || !reflect.DeepEqual(baseline.Capabilities, candidate.Capabilities) || !reflect.DeepEqual(baseline.Invariants, candidate.Invariants) { return errors.New("performance reports use incompatible environment or fixture schema") } if baseline.Reproducibility.SampleCount != candidate.Reproducibility.SampleCount || baseline.Reproducibility.Strict != candidate.Reproducibility.Strict || baseline.Reproducibility.GOMAXPROCS != candidate.Reproducibility.GOMAXPROCS || !reflect.DeepEqual(comparableInvocation(baseline.Reproducibility.Invocation), comparableInvocation(candidate.Reproducibility.Invocation)) { return errors.New("performance reports use incompatible reproducibility parameters") } - if baseline.AllocLimit != candidate.AllocLimit || baseline.IdleCPU.Limit != candidate.IdleCPU.Limit || baseline.IdleCPU.Name != candidate.IdleCPU.Name || len(baseline.IdleCPU.Attempts) != len(candidate.IdleCPU.Attempts) || len(baseline.Measurements) != len(candidate.Measurements) { + if baseline.AllocLimit != candidate.AllocLimit || baseline.IdleCPU.Limit != candidate.IdleCPU.Limit || baseline.IdleCPU.Name != candidate.IdleCPU.Name || baseline.IdleCPU.Disposition != candidate.IdleCPU.Disposition || len(baseline.IdleCPU.Attempts) != len(candidate.IdleCPU.Attempts) || len(baseline.Measurements) != len(candidate.Measurements) { return errors.New("performance reports use incompatible absolute budget schema") } for _, measurement := range baseline.Measurements { candidateMetric := candidateMeasurements[measurement.Name] - if candidateMetric.Name == "" || candidateMetric.Limit != measurement.Limit || candidateMetric.Samples != measurement.Samples || len(candidateMetric.Attempts) != len(measurement.Attempts) { + if candidateMetric.Name == "" || candidateMetric.Limit != measurement.Limit || candidateMetric.Samples != measurement.Samples || candidateMetric.Disposition != measurement.Disposition || len(candidateMetric.Attempts) != len(measurement.Attempts) { return fmt.Errorf("performance reports use incompatible metric schema for %q", measurement.Name) } } diff --git a/performance/comparison_test.go b/performance/comparison_test.go index 1561c2e..33c8264 100644 --- a/performance/comparison_test.go +++ b/performance/comparison_test.go @@ -124,6 +124,10 @@ func TestCompareRequiresEqualAttemptCounts(t *testing.T) { } baseline.IdleCPU.Attempts = []float64{0.01, 0.02} candidate.IdleCPU.Attempts = []float64{0.03, 0.04} + baseline.Measurements[0].P50, baseline.Measurements[0].P95, baseline.Measurements[0].P99 = time.Nanosecond, 2*time.Nanosecond, 3*time.Nanosecond + candidate.Measurements[0].P50, candidate.Measurements[0].P95, candidate.Measurements[0].P99 = 7*time.Nanosecond, 8*time.Nanosecond, 9*time.Nanosecond + baseline.IdleCPU.Value = 0.01 + candidate.IdleCPU.Value = 0.03 if _, err := Compare(baseline, candidate); err != nil { t.Fatalf("Compare() rejected equal attempt counts with different timings: %v", err) } @@ -133,7 +137,7 @@ func TestCompareRequiresEqualAttemptCounts(t *testing.T) { apply func(*Report) }{ {"measurement", func(report *Report) { - report.Measurements[0].Attempts = append(report.Measurements[0].Attempts, Attempt{}) + report.Measurements[0].Attempts = append(report.Measurements[0].Attempts, Attempt{P50: 12 * time.Nanosecond, P95: 13 * time.Nanosecond, P99: 14 * time.Nanosecond}) }}, {"idle CPU", func(report *Report) { report.IdleCPU.Attempts = append(report.IdleCPU.Attempts, 0.05) @@ -152,6 +156,75 @@ func TestCompareRequiresEqualAttemptCounts(t *testing.T) { } } +func TestCompareRejectsDifferentDeclaredInvariantsAndDisposition(t *testing.T) { + baseline := comparisonFixture() + for _, mutate := range []struct { + name string + apply func(*Report) + }{ + {"invariants", func(report *Report) { report.Invariants = []string{"fixture", "changed"} }}, + {"measurement disposition", func(report *Report) { report.Measurements[0].Disposition = "median retained attempt" }}, + {"idle CPU disposition", func(report *Report) { report.IdleCPU.Disposition = "mean retained window" }}, + } { + t.Run(mutate.name, func(t *testing.T) { + candidate := comparisonFixture() + mutate.apply(&candidate) + if _, err := Compare(baseline, candidate); err == nil { + t.Fatal("Compare() accepted incompatible declared collection contract") + } + }) + } +} + +func TestValidateReportRetainedAttemptsMatchCollectorSelection(t *testing.T) { + valid := comparisonFixture() + valid.Measurements[0].Attempts = []Attempt{ + {P50: 0, P95: 0, P99: 0}, + {P50: 4 * time.Nanosecond, P95: 5 * time.Nanosecond, P99: 6 * time.Nanosecond}, + {P50: 3 * time.Nanosecond, P95: 4 * time.Nanosecond, P99: 7 * time.Nanosecond}, + } + // The collector's zero-P95 sentinel replaces the initial zero attempt, then + // picks the lowest following P95. + valid.Measurements[0].P50, valid.Measurements[0].P95, valid.Measurements[0].P99 = 3*time.Nanosecond, 4*time.Nanosecond, 7*time.Nanosecond + valid.IdleCPU.Attempts = []float64{0.4, 0.2, 0.3} + valid.IdleCPU.Value = 0.2 + if err := ValidateReport(valid); err != nil { + t.Fatalf("ValidateReport() rejected collector-selected attempts: %v", err) + } + + tie := comparisonFixture() + tie.Measurements[0].Attempts = []Attempt{ + {P50: time.Nanosecond, P95: 2 * time.Nanosecond, P99: 3 * time.Nanosecond}, + {P50: 0, P95: 2 * time.Nanosecond, P99: 4 * time.Nanosecond}, + } + tie.Measurements[0].P50, tie.Measurements[0].P95, tie.Measurements[0].P99 = time.Nanosecond, 2*time.Nanosecond, 3*time.Nanosecond + if err := ValidateReport(tie); err != nil { + t.Fatalf("ValidateReport() rejected first retained tie: %v", err) + } + + for _, mutate := range []struct { + name string + apply func(*Report) + }{ + {"wrong selected aggregate", func(report *Report) { report.Measurements[0].P95 = 5 * time.Nanosecond }}, + {"unordered attempt", func(report *Report) { + report.Measurements[0].Attempts[0] = Attempt{P50: 3 * time.Nanosecond, P95: 2 * time.Nanosecond, P99: 4 * time.Nanosecond} + }}, + {"idle CPU not minimum", func(report *Report) { report.IdleCPU.Value = 0.3 }}, + } { + t.Run(mutate.name, func(t *testing.T) { + invalid := valid + invalid.Measurements = append([]Measurement(nil), valid.Measurements...) + invalid.Measurements[0].Attempts = append([]Attempt(nil), valid.Measurements[0].Attempts...) + invalid.IdleCPU.Attempts = append([]float64(nil), valid.IdleCPU.Attempts...) + mutate.apply(&invalid) + if err := ValidateReport(invalid); err == nil { + t.Fatal("ValidateReport() accepted inconsistent retained attempts") + } + }) + } +} + func TestComparisonJSONDistinguishesDefinedAndUndefinedZeroPercentDelta(t *testing.T) { baseline, candidate := comparisonFixture(), comparisonFixture() comparison, err := Compare(baseline, candidate)