perfgate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CHANGELOG.md +18 -0
  5. data/CONTRIBUTING.md +73 -0
  6. data/LICENSE +201 -0
  7. data/NOT_FINALIZED.md +70 -0
  8. data/README.md +86 -0
  9. data/ROADMAP.md +125 -0
  10. data/Rakefile +12 -0
  11. data/SECURITY.md +63 -0
  12. data/docs/README.md +7 -0
  13. data/docs/architecture.md +125 -0
  14. data/docs/compatibility.md +49 -0
  15. data/docs/launch-article.md +97 -0
  16. data/docs/onboarding.md +122 -0
  17. data/docs/telemetry.md +81 -0
  18. data/examples/rails-rspec-app/.github/workflows/baseline.yml +48 -0
  19. data/examples/rails-rspec-app/README.md +38 -0
  20. data/examples/rails-rspec-app/spec/jobs/invoice_job_spec.rb +15 -0
  21. data/examples/rails-rspec-app/spec/requests/checkout_spec.rb +24 -0
  22. data/exe/perfgate +7 -0
  23. data/lib/perfgate/cli/compare_command.rb +91 -0
  24. data/lib/perfgate/cli/run_command.rb +123 -0
  25. data/lib/perfgate/cli/run_comparison_reporter.rb +77 -0
  26. data/lib/perfgate/cli.rb +60 -0
  27. data/lib/perfgate/comparison/deterministic_metric_decision.rb +34 -0
  28. data/lib/perfgate/comparison/diagnostics.rb +70 -0
  29. data/lib/perfgate/comparison/engine.rb +79 -0
  30. data/lib/perfgate/comparison/metric_change.rb +84 -0
  31. data/lib/perfgate/comparison/metric_decision.rb +39 -0
  32. data/lib/perfgate/comparison/statistical_metric_decision.rb +75 -0
  33. data/lib/perfgate/comparison/workload_comparison.rb +98 -0
  34. data/lib/perfgate/config/defaults.rb +70 -0
  35. data/lib/perfgate/config/env_overrides.rb +54 -0
  36. data/lib/perfgate/config/schema.rb +53 -0
  37. data/lib/perfgate/config/validator.rb +64 -0
  38. data/lib/perfgate/config.rb +136 -0
  39. data/lib/perfgate/errors.rb +20 -0
  40. data/lib/perfgate/execution/process_runner.rb +71 -0
  41. data/lib/perfgate/execution/runner.rb +60 -0
  42. data/lib/perfgate/execution/sample_context.rb +66 -0
  43. data/lib/perfgate/fingerprints/compatibility.rb +46 -0
  44. data/lib/perfgate/fingerprints/components.rb +98 -0
  45. data/lib/perfgate/fingerprints/workload_definition.rb +30 -0
  46. data/lib/perfgate/instrumentation/allocations.rb +20 -0
  47. data/lib/perfgate/instrumentation/duration.rb +20 -0
  48. data/lib/perfgate/instrumentation/gc.rb +30 -0
  49. data/lib/perfgate/instrumentation/sql_activity.rb +50 -0
  50. data/lib/perfgate/instrumentation.rb +36 -0
  51. data/lib/perfgate/metrics/.gitkeep +0 -0
  52. data/lib/perfgate/policy/engine.rb +73 -0
  53. data/lib/perfgate/rails/.gitkeep +0 -0
  54. data/lib/perfgate/report/console.rb +57 -0
  55. data/lib/perfgate/report/markdown.rb +101 -0
  56. data/lib/perfgate/reporting/.gitkeep +0 -0
  57. data/lib/perfgate/rspec/discovery.rb +31 -0
  58. data/lib/perfgate/rspec/id_resolver.rb +30 -0
  59. data/lib/perfgate/rspec/workload_builder.rb +48 -0
  60. data/lib/perfgate/rspec.rb +14 -0
  61. data/lib/perfgate/serialization/run_result.rb +58 -0
  62. data/lib/perfgate/statistics/mann_whitney_u.rb +84 -0
  63. data/lib/perfgate/statistics/summary.rb +60 -0
  64. data/lib/perfgate/storage/adapter.rb +24 -0
  65. data/lib/perfgate/storage/archive.rb +69 -0
  66. data/lib/perfgate/storage/filesystem.rb +121 -0
  67. data/lib/perfgate/telemetry/.gitkeep +0 -0
  68. data/lib/perfgate/version.rb +5 -0
  69. data/lib/perfgate/workloads/registry.rb +50 -0
  70. data/lib/perfgate/workloads/workload.rb +26 -0
  71. data/lib/perfgate.rb +46 -0
  72. data/perfgate.gemspec +41 -0
  73. data/schemas/comparison-result-v1.schema.json +7 -0
  74. data/schemas/run-result-v1.schema.json +7 -0
  75. data/sig/perfgate.rbs +4 -0
  76. metadata +139 -0
data/exe/perfgate ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "baseline"
5
+ require "baseline/cli"
6
+
7
+ Baseline::CLI.start(ARGV)
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../config"
5
+ require_relative "../comparison/engine"
6
+ require_relative "../policy/engine"
7
+ require_relative "../storage/filesystem"
8
+ require_relative "../report/console"
9
+ require_relative "../report/markdown"
10
+
11
+ module Perfgate
12
+ class CLI
13
+ # Implements `baseline compare` (spec section 10.3): loads two
14
+ # already-produced result bundles, runs them through the comparison
15
+ # engine and policy engine, saves the comparison document, prints a
16
+ # console (or Markdown, with --format markdown) report, and exits
17
+ # with the CI exit code from spec section 17.
18
+ class CompareCommand
19
+ def initialize(argv)
20
+ @argv = argv.dup
21
+ @options = { config: "perfgate.yml" }
22
+ end
23
+
24
+ def call
25
+ parse_options!
26
+ require_bundle_options!
27
+ config = load_configuration
28
+
29
+ comparison_result = compare(config)
30
+ policy_result = Policy::Engine.evaluate(comparison_result: comparison_result, config: config)
31
+
32
+ comparison_path = save(config, comparison_result)
33
+ report(comparison_result, policy_result, comparison_path)
34
+ policy_result.fetch("exit_code")
35
+ end
36
+
37
+ private
38
+
39
+ def compare(config)
40
+ baseline_run = load_bundle(@options[:baseline])
41
+ candidate_run = load_bundle(@options[:candidate])
42
+
43
+ Comparison::Engine.compare(baseline_run: baseline_run, candidate_run: candidate_run, config: config)
44
+ end
45
+
46
+ def require_bundle_options!
47
+ return if @options[:baseline] && @options[:candidate]
48
+
49
+ raise Perfgate::ConfigurationError, "baseline compare requires both --baseline and --candidate"
50
+ end
51
+
52
+ def load_configuration
53
+ config = Perfgate::Config.load(@options[:config])
54
+ Perfgate.configuration = config
55
+ config
56
+ end
57
+
58
+ def load_bundle(reference)
59
+ Storage::Filesystem.new(root: File.dirname(reference)).load_run(reference)
60
+ end
61
+
62
+ def save(config, comparison_result)
63
+ Storage::Filesystem.new(root: @options[:output] || config.storage_path).save_comparison(comparison_result)
64
+ end
65
+
66
+ def parse_options!
67
+ option_parser.parse!(@argv)
68
+ end
69
+
70
+ def option_parser
71
+ OptionParser.new do |opts|
72
+ opts.on("--baseline PATH") { |v| @options[:baseline] = v }
73
+ opts.on("--candidate PATH") { |v| @options[:candidate] = v }
74
+ opts.on("--config PATH") { |v| @options[:config] = v }
75
+ opts.on("--output PATH") { |v| @options[:output] = v }
76
+ opts.on("--format FORMAT") { |v| @options[:format] = v }
77
+ end
78
+ end
79
+
80
+ def report(comparison_result, policy_result, comparison_path)
81
+ if @options[:format] == "markdown"
82
+ puts Report::Markdown.render(comparison_result: comparison_result, policy_result: policy_result,
83
+ comparison_path: comparison_path)
84
+ else
85
+ puts Report::Console.render(comparison_result: comparison_result, policy_result: policy_result)
86
+ puts "Machine-readable result: #{comparison_path}"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../config"
5
+ require_relative "../execution/process_runner"
6
+ require_relative "../serialization/run_result"
7
+ require_relative "../storage/filesystem"
8
+ require_relative "run_comparison_reporter"
9
+
10
+ module Perfgate
11
+ class CLI
12
+ # Implements `baseline run` (spec section 10.2): discovers workloads
13
+ # via the RSpec integration, executes each workload's warmup+samples
14
+ # in an isolated child process, and writes a filesystem result
15
+ # bundle. With --compare PATH, immediately compares the fresh run
16
+ # against a reference bundle and reports/exits like `baseline
17
+ # compare` would -- this is the single combined step the spec's
18
+ # GitHub Actions example (section 19.1) invokes. --format markdown
19
+ # additionally writes a summary.md into the output directory for a
20
+ # GitHub job summary.
21
+ class RunCommand
22
+ def initialize(argv)
23
+ @argv = argv.dup
24
+ @options = { config: "perfgate.yml" }
25
+ end
26
+
27
+ def call
28
+ parse_options!
29
+ config = load_configuration
30
+ run_result = execute(config)
31
+ run_dir = save(config, run_result)
32
+
33
+ @options[:compare] ? compare_and_report(config, run_result, run_dir) : run_only_report(run_result, run_dir)
34
+ end
35
+
36
+ private
37
+
38
+ def load_configuration
39
+ config = Perfgate::Config.load(@options[:config])
40
+ Perfgate.configuration = config
41
+ config
42
+ end
43
+
44
+ def execute(config)
45
+ require "perfgate/rspec"
46
+ load_spec_files
47
+ Perfgate::RSpec::Discovery.call
48
+
49
+ results = Perfgate.registry.map { |workload| Execution::ProcessRunner.new(workload).call }
50
+ Serialization::RunResult.build(results, config: config)
51
+ end
52
+
53
+ def save(config, run_result)
54
+ Storage::Filesystem.new(root: @options[:output] || config.storage_path).save_run(run_result)
55
+ end
56
+
57
+ def parse_options!
58
+ option_parser.parse!(@argv)
59
+ end
60
+
61
+ def option_parser # rubocop:disable Metrics/AbcSize
62
+ OptionParser.new do |opts|
63
+ opts.on("--config PATH") { |v| @options[:config] = v }
64
+ opts.on("--output PATH") { |v| @options[:output] = v }
65
+ opts.on("--only PATTERN") { |v| @options[:only] = v }
66
+ opts.on("--format FORMAT") { |v| @options[:format] = v }
67
+ opts.on("--fail-on MODE") { |v| @options[:fail_on] = v }
68
+ opts.on("--seed N", Integer) { |v| @options[:seed] = v }
69
+ opts.on("--profile NAME") { |v| @options[:profile] = v }
70
+ opts.on("--compare PATH") { |v| @options[:compare] = v }
71
+ end
72
+ end
73
+
74
+ def load_spec_files
75
+ options = ::RSpec::Core::ConfigurationOptions.new(spec_paths)
76
+ options.configure(::RSpec.configuration)
77
+ ::RSpec.configuration.load_spec_files
78
+ end
79
+
80
+ def spec_paths
81
+ @argv.empty? ? ["spec"] : @argv
82
+ end
83
+
84
+ def run_only_report(run_result, run_dir)
85
+ puts "baseline run: #{run_result["workloads"].size} workload(s) -> #{run_dir}"
86
+ run_result["workloads"].each { |workload| report_workload(workload) }
87
+ write_run_only_summary(run_result, run_dir)
88
+ exit_code(run_result)
89
+ end
90
+
91
+ def report_workload(workload)
92
+ summary = workload.dig("summary", "duration_ns")
93
+ if workload["status"] == "completed" && summary
94
+ median_ms = summary["median"] / 1_000_000.0
95
+ puts format(" %<id>-40s median=%<median>.2fms (n=%<count>d)",
96
+ id: workload["id"], median: median_ms, count: workload["samples"].size)
97
+ else
98
+ puts " #{workload["id"]}: #{workload["status"]} (#{workload["error"]})"
99
+ end
100
+ end
101
+
102
+ def exit_code(run_result)
103
+ run_result["workloads"].any? { |w| w["status"] == "error" } ? 1 : 0
104
+ end
105
+
106
+ def write_run_only_summary(run_result, run_dir)
107
+ return unless @options[:format] == "markdown"
108
+
109
+ rows = run_result["workloads"].map { |w| "| #{w["id"]} | #{w["status"]} |" }
110
+ content = (["## Baseline Run", "", "No reference bundle was compared against.", "",
111
+ "| Workload | Status |", "|---|---|"] + rows).join("\n")
112
+ File.write(File.join(run_dir, "summary.md"), content)
113
+ end
114
+
115
+ def compare_and_report(config, run_result, run_dir)
116
+ reporter = RunComparisonReporter.new(reference_path: @options[:compare],
117
+ output_root: @options[:output] || config.storage_path,
118
+ format: @options[:format])
119
+ reporter.call(config: config, run_result: run_result, run_dir: run_dir)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../comparison/engine"
4
+ require_relative "../policy/engine"
5
+ require_relative "../report/console"
6
+ require_relative "../report/markdown"
7
+ require_relative "../storage/filesystem"
8
+
9
+ module Perfgate
10
+ class CLI
11
+ # Handles `baseline run`'s optional --compare PATH step: loads the
12
+ # reference bundle (tolerating a missing one, since the spec's
13
+ # GitHub Actions example downloads it with continue-on-error),
14
+ # compares it against the freshly-run result, saves and reports the
15
+ # comparison, and returns the CI exit code. Split out of RunCommand
16
+ # to keep both classes under RuboCop's length limits.
17
+ class RunComparisonReporter
18
+ def initialize(reference_path:, output_root:, format:)
19
+ @reference_path = reference_path
20
+ @output_root = output_root
21
+ @format = format
22
+ end
23
+
24
+ def call(config:, run_result:, run_dir:)
25
+ reference_run = load_reference
26
+ return missing_baseline_report(config, run_dir) unless reference_run
27
+
28
+ comparison_result = Comparison::Engine.compare(baseline_run: reference_run, candidate_run: run_result,
29
+ config: config)
30
+ report_comparison(config, comparison_result, run_dir)
31
+ end
32
+
33
+ private
34
+
35
+ def load_reference
36
+ Storage::Filesystem.new(root: File.dirname(@reference_path)).load_run(@reference_path)
37
+ rescue Perfgate::ResultBundleError
38
+ nil
39
+ end
40
+
41
+ def missing_baseline_report(config, run_dir)
42
+ policy_result = Policy::Engine.evaluate_missing_baseline(config: config)
43
+ puts "baseline run: no baseline found at #{@reference_path} -> #{policy_result["status"]}"
44
+ write_summary(run_dir, "## Baseline Run\n\nNo baseline was found at `#{@reference_path}`.") if markdown?
45
+ policy_result.fetch("exit_code")
46
+ end
47
+
48
+ def report_comparison(config, comparison_result, run_dir)
49
+ policy_result = Policy::Engine.evaluate(comparison_result: comparison_result, config: config)
50
+ comparison_path = Storage::Filesystem.new(root: @output_root).save_comparison(comparison_result)
51
+
52
+ render(comparison_result, policy_result, comparison_path, run_dir)
53
+ policy_result.fetch("exit_code")
54
+ end
55
+
56
+ def render(comparison_result, policy_result, comparison_path, run_dir)
57
+ markdown = Report::Markdown.render(comparison_result: comparison_result, policy_result: policy_result,
58
+ comparison_path: comparison_path)
59
+ if markdown?
60
+ puts markdown
61
+ write_summary(run_dir, markdown)
62
+ else
63
+ puts Report::Console.render(comparison_result: comparison_result, policy_result: policy_result)
64
+ puts "Machine-readable result: #{comparison_path}"
65
+ end
66
+ end
67
+
68
+ def markdown?
69
+ @format == "markdown"
70
+ end
71
+
72
+ def write_summary(run_dir, content)
73
+ File.write(File.join(run_dir, "summary.md"), content)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ # Entry point for the `baseline` executable. `run` and `compare` are
5
+ # implemented (Milestones 1-4); `init`, `report`, `doctor`, and
6
+ # `schema` are planned for later milestones (spec section 10).
7
+ class CLI
8
+ def self.start(argv)
9
+ new(argv).run
10
+ end
11
+
12
+ def initialize(argv)
13
+ @argv = argv
14
+ end
15
+
16
+ def run
17
+ dispatch(*@argv)
18
+ rescue Perfgate::ConfigurationError => e
19
+ warn "baseline: #{e.message}"
20
+ 2
21
+ rescue Perfgate::ResultBundleError, Perfgate::WorkloadError => e
22
+ warn "baseline: #{e.message}"
23
+ 3
24
+ rescue Perfgate::Error => e
25
+ warn "baseline: #{e.message}"
26
+ 1
27
+ end
28
+
29
+ private
30
+
31
+ def dispatch(command = nil, *rest)
32
+ case command
33
+ when "run" then run_run_command(rest)
34
+ when "compare" then run_compare_command(rest)
35
+ when nil then usage
36
+ else unknown_command(command)
37
+ end
38
+ end
39
+
40
+ def run_run_command(rest)
41
+ require_relative "cli/run_command"
42
+ RunCommand.new(rest).call
43
+ end
44
+
45
+ def run_compare_command(rest)
46
+ require_relative "cli/compare_command"
47
+ CompareCommand.new(rest).call
48
+ end
49
+
50
+ def usage
51
+ warn "usage: baseline <command> [options]"
52
+ 1
53
+ end
54
+
55
+ def unknown_command(command)
56
+ warn "baseline: unknown or not-yet-implemented command #{command.inspect}"
57
+ 1
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "metric_change"
4
+
5
+ module Perfgate
6
+ module Comparison
7
+ # Deterministic decision path used for count-like metrics (currently
8
+ # only sql_count): no statistical test is applied, since query
9
+ # counts don't carry the same run-to-run noise that timing and
10
+ # allocation metrics do (spec section 16.3).
11
+ module DeterministicMetricDecision
12
+ module_function
13
+
14
+ def call(metric, baseline_samples, candidate_samples, config)
15
+ change = MetricChange.summarize(metric, baseline_samples, candidate_samples)
16
+ thresholds = config.dig(:comparison, :practical_thresholds, metric.to_sym) || {}
17
+ decision = verdict(change, thresholds)
18
+
19
+ MetricChange.result(change, confidence: nil, practically_significant: decision != "pass", noisy: false,
20
+ decision: decision)
21
+ end
22
+
23
+ def verdict(change, thresholds)
24
+ warning_absolute = thresholds[:warning_absolute] || 0
25
+ failure_percent = thresholds[:failure_percent] || Float::INFINITY
26
+
27
+ return "pass" if change[:absolute_change] <= 0
28
+ return "pass" if change[:absolute_change] < warning_absolute
29
+
30
+ change[:change_percent] >= failure_percent ? "fail" : "warn"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ module Comparison
5
+ # Simple, deterministic diagnostic rules (spec 20.3). These exist to
6
+ # point a reader at a likely explanation for a regression, never to
7
+ # claim a source-code root cause: "SQL query count increased by 5"
8
+ # is a fact about the sample data, not a guess about which line of
9
+ # code changed.
10
+ module Diagnostics
11
+ module_function
12
+
13
+ def for_workload(metrics)
14
+ [
15
+ sql_count_rule(metrics),
16
+ sql_duration_without_count_rule(metrics),
17
+ allocations_rule(metrics),
18
+ duration_without_resource_change_rule(metrics),
19
+ *noise_rules(metrics)
20
+ ].compact
21
+ end
22
+
23
+ def environment_changed_rules(compatibility)
24
+ return [] if compatibility["status"] == "compatible"
25
+
26
+ compatibility.fetch("differences", []).map do |difference|
27
+ "Environment changed: #{difference["field"]} (#{difference["severity"]})."
28
+ end
29
+ end
30
+
31
+ def regressed?(metric_result)
32
+ metric_result && metric_result["decision"] != "pass" && (metric_result["absolute_change"] || 0).positive?
33
+ end
34
+
35
+ def sql_count_rule(metrics)
36
+ metric = metrics["sql_count"]
37
+ return nil unless regressed?(metric)
38
+
39
+ "SQL query count increased by #{metric["absolute_change"].to_i}."
40
+ end
41
+
42
+ def sql_duration_without_count_rule(metrics)
43
+ return nil unless regressed?(metrics["sql_duration"]) && !regressed?(metrics["sql_count"])
44
+
45
+ "SQL duration increased without a corresponding increase in query count."
46
+ end
47
+
48
+ def allocations_rule(metrics)
49
+ metric = metrics["allocations"]
50
+ return nil unless regressed?(metric)
51
+
52
+ "Allocations increased by #{metric["change_percent"]}%."
53
+ end
54
+
55
+ def duration_without_resource_change_rule(metrics)
56
+ return nil unless regressed?(metrics["duration"])
57
+
58
+ resource_metrics = metrics.values_at("sql_count", "sql_duration", "allocations").compact
59
+ return nil if resource_metrics.empty? || resource_metrics.any? { |metric| metric["decision"] != "pass" }
60
+
61
+ "Duration increased while SQL and allocation metrics remained stable."
62
+ end
63
+
64
+ def noise_rules(metrics)
65
+ metrics.select { |_name, metric| metric["noisy"] }
66
+ .map { |name, _metric| "Sample variability is high for #{name}; treat this decision with caution." }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workload_comparison"
4
+ require_relative "diagnostics"
5
+ require_relative "../fingerprints/compatibility"
6
+
7
+ module Perfgate
8
+ module Comparison
9
+ # Builds the schema_version 1 comparison-result document (spec
10
+ # section 14.2) from a baseline run-result and a candidate
11
+ # run-result. This is the seam between the fingerprinting/statistics
12
+ # primitives and the CLI: it never touches storage or exit codes
13
+ # (that's Policy::Engine's job), only produces the structured
14
+ # decision document.
15
+ #
16
+ # Compatibility is checked first (spec section 15): when the runs are
17
+ # incompatible, no per-workload metric decisions are computed at all
18
+ # -- Baseline never silently compares runs it can't vouch for.
19
+ module Engine
20
+ SCHEMA_VERSION = 1
21
+
22
+ module_function
23
+
24
+ def compare(baseline_run:, candidate_run:, config: Perfgate.configuration)
25
+ compatibility = evaluate_compatibility(baseline_run, candidate_run, config)
26
+ workloads = workloads_for(compatibility, baseline_run, candidate_run, config)
27
+
28
+ build_document(baseline_run, candidate_run, compatibility, workloads)
29
+ end
30
+
31
+ def build_document(baseline_run, candidate_run, compatibility, workloads)
32
+ {
33
+ "schema_version" => SCHEMA_VERSION,
34
+ "baseline_run_id" => baseline_run["run_id"],
35
+ "candidate_run_id" => candidate_run["run_id"],
36
+ "compatibility" => compatibility,
37
+ "decision" => overall_decision(compatibility, workloads),
38
+ "workloads" => workloads,
39
+ "diagnostics" => diagnostics(baseline_run, candidate_run, compatibility)
40
+ }
41
+ end
42
+
43
+ def workloads_for(compatibility, baseline_run, candidate_run, config)
44
+ return [] if compatibility["status"] == "incompatible"
45
+
46
+ WorkloadComparison.compare_all(baseline_run, candidate_run, config)
47
+ end
48
+
49
+ def evaluate_compatibility(baseline_run, candidate_run, config)
50
+ Fingerprints::Compatibility.evaluate(
51
+ baseline_components: baseline_run.fetch("fingerprint", {}),
52
+ candidate_components: candidate_run.fetch("fingerprint", {}), config: config
53
+ )
54
+ end
55
+
56
+ def overall_decision(compatibility, workloads)
57
+ return "incompatible" if compatibility["status"] == "incompatible"
58
+
59
+ decisions = workloads.map { |w| w["decision"] }
60
+ return "fail" if decisions.include?("fail")
61
+ return "warn" if decisions.intersect?(%w[warn incomparable removed_workload new_workload])
62
+
63
+ "pass"
64
+ end
65
+
66
+ def diagnostics(baseline_run, candidate_run, compatibility)
67
+ diagnostics = []
68
+ diagnostics << { "code" => "empty_baseline", "message" => "baseline run has no workloads" } if
69
+ baseline_run.fetch("workloads", []).empty?
70
+ diagnostics << { "code" => "empty_candidate", "message" => "candidate run has no workloads" } if
71
+ candidate_run.fetch("workloads", []).empty?
72
+ Diagnostics.environment_changed_rules(compatibility).each do |message|
73
+ diagnostics << { "code" => "environment_changed", "message" => message }
74
+ end
75
+ diagnostics
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../statistics/summary"
4
+
5
+ module Perfgate
6
+ module Comparison
7
+ # Shared helpers for building a metric comparison result and for
8
+ # computing the baseline->candidate change that every decision path
9
+ # (statistical or deterministic) needs (spec section 14.2's
10
+ # `workloads[].metrics` entries).
11
+ module MetricChange
12
+ module_function
13
+
14
+ def inconclusive(metric)
15
+ base_fields(metric).merge("decision" => "inconclusive")
16
+ end
17
+
18
+ def base_fields(metric)
19
+ {
20
+ "metric" => metric.to_s,
21
+ "baseline_median" => nil,
22
+ "candidate_median" => nil,
23
+ "change_percent" => nil,
24
+ "absolute_change" => nil,
25
+ "confidence" => nil,
26
+ "practically_significant" => false,
27
+ "noisy" => false
28
+ }
29
+ end
30
+
31
+ def summarize(metric, baseline_samples, candidate_samples)
32
+ baseline_summary = Statistics::Summary.call(baseline_samples)
33
+ candidate_summary = Statistics::Summary.call(candidate_samples)
34
+ absolute_change = candidate_summary["median"] - baseline_summary["median"]
35
+ change_percent = percent_change(baseline_summary["median"], absolute_change)
36
+
37
+ build_change(metric, baseline_summary, candidate_summary, absolute_change, change_percent)
38
+ end
39
+
40
+ def build_change(metric, baseline_summary, candidate_summary, absolute_change, change_percent)
41
+ {
42
+ metric: metric.to_s,
43
+ baseline_summary: baseline_summary,
44
+ candidate_summary: candidate_summary,
45
+ absolute_change: absolute_change,
46
+ change_percent: change_percent
47
+ }
48
+ end
49
+
50
+ def percent_change(baseline_median, absolute_change)
51
+ return 0.0 if baseline_median.zero?
52
+
53
+ ((absolute_change / baseline_median.to_f) * 100).round(2)
54
+ end
55
+
56
+ def result(change, confidence:, practically_significant:, noisy:, decision:)
57
+ change_fields(change).merge(
58
+ "confidence" => confidence, "practically_significant" => practically_significant,
59
+ "noisy" => noisy, "decision" => decision
60
+ )
61
+ end
62
+
63
+ def change_fields(change)
64
+ {
65
+ "metric" => change[:metric],
66
+ "baseline_median" => change[:baseline_summary]["median"],
67
+ "candidate_median" => change[:candidate_summary]["median"],
68
+ "change_percent" => change[:change_percent],
69
+ "absolute_change" => change[:absolute_change]
70
+ }
71
+ end
72
+
73
+ # A metric is "noisy" when its own baseline spread (MAD relative to
74
+ # its median) is large enough that a modest shift in medians could
75
+ # plausibly be explained by run-to-run variance alone (spec 16.5).
76
+ def noisy?(baseline_summary, config)
77
+ median = baseline_summary["median"]
78
+ return false if median.zero?
79
+
80
+ (baseline_summary["mad"] / median.to_f) > config.comparison_noise_ratio_threshold
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "metric_change"
4
+ require_relative "statistical_metric_decision"
5
+ require_relative "deterministic_metric_decision"
6
+
7
+ module Perfgate
8
+ module Comparison
9
+ # Decides pass/warn/fail/inconclusive for a single metric on a single
10
+ # workload (spec section 16). A regression is only ever reported as
11
+ # "fail" when it clears both bars: a practically-significant change
12
+ # (spec 16.4's thresholds) and statistical support that it isn't just
13
+ # sampling noise (spec 16.3's Mann-Whitney test) -- see
14
+ # spikes/FINDINGS.md for why Milestone 0 settled on requiring both.
15
+ # `sql_count` is the one exception: it's compared deterministically
16
+ # (spec 16.3), since query counts are rarely continuous/noisy the way
17
+ # timing and allocation metrics are.
18
+ module MetricDecision
19
+ DETERMINISTIC_METRICS = %w[sql_count].freeze
20
+
21
+ module_function
22
+
23
+ def call(metric:, baseline_samples:, candidate_samples:, config: Perfgate.configuration)
24
+ return MetricChange.inconclusive(metric) unless enough_samples?(baseline_samples, candidate_samples, config)
25
+
26
+ if DETERMINISTIC_METRICS.include?(metric.to_s)
27
+ DeterministicMetricDecision.call(metric, baseline_samples, candidate_samples, config)
28
+ else
29
+ StatisticalMetricDecision.call(metric, baseline_samples, candidate_samples, config)
30
+ end
31
+ end
32
+
33
+ def enough_samples?(baseline_samples, candidate_samples, config)
34
+ minimum = config.comparison_minimum_samples
35
+ baseline_samples.size >= minimum && candidate_samples.size >= minimum
36
+ end
37
+ end
38
+ end
39
+ end