necropsy 0.2.0 → 0.3.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. metadata +85 -1
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Necropsy
7
+ module Bench
8
+ # Builds a deterministic, explicitly-unreviewed queue from normalized reports.
9
+ # The queue is evidence collection tooling; it never supplies labels or passes
10
+ # the public claim gate by itself.
11
+ class ReviewQueue
12
+ ACTIONABLE_STATES = %w[unreachable unused candidate].freeze
13
+ HIGH_CONFIDENCES = %w[high certain].freeze
14
+ CONFIDENCE_ORDER = { 'certain' => 0, 'high' => 1, 'medium' => 2, 'low' => 3 }.freeze
15
+ VERSION = 1
16
+
17
+ def initialize(reports:, target_reviewed_high: 300, limit: nil)
18
+ @reports = reports
19
+ @target_reviewed_high = Integer(target_reviewed_high)
20
+ @limit = limit.nil? ? @target_reviewed_high : Integer(limit)
21
+ validate_options!
22
+ rescue ArgumentError, TypeError
23
+ raise Error, 'Review queue target and limit must be integers'
24
+ end
25
+
26
+ def call
27
+ validate_reports!
28
+ available = actionable_entries
29
+ entries = available.sort_by { |entry| sort_key(entry) }.first(limit)
30
+ high_entries = entries.select { |entry| HIGH_CONFIDENCES.include?(entry.fetch('confidence')) }
31
+ {
32
+ 'schema_version' => VERSION,
33
+ 'status' => 'pending',
34
+ 'selection' => 'actionable findings, confidence ascending, then corpus and identity',
35
+ 'target_reviewed_high_candidates' => target_reviewed_high,
36
+ 'available_actionable_candidates' => available.length,
37
+ 'queued_candidates' => entries.length,
38
+ 'reviewed_candidates' => 0,
39
+ 'reviewed_high_candidates' => 0,
40
+ 'pending_candidates' => entries.length,
41
+ 'pending_high_candidates' => high_entries.length,
42
+ 'target_shortfall' => [target_reviewed_high - high_entries.length, 0].max,
43
+ 'claim_gate_passed' => false,
44
+ 'corpora' => corpus_summary(available, entries),
45
+ 'provenance' => provenance,
46
+ 'entries' => entries
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :reports, :target_reviewed_high, :limit
53
+
54
+ def validate_options!
55
+ raise Error, 'Review queue target must be positive' unless target_reviewed_high.positive?
56
+ raise Error, 'Review queue limit must be positive' unless limit.positive?
57
+ end
58
+
59
+ def validate_reports!
60
+ raise Error, 'Review queue reports must be a mapping' unless reports.is_a?(Hash)
61
+
62
+ reports.each do |corpus, report|
63
+ raise Error, 'Review queue corpus names must be non-empty strings' if corpus.to_s.empty?
64
+ raise Error, "Review queue report #{corpus} must contain findings" unless
65
+ report.is_a?(Hash) && report['findings'].is_a?(Array)
66
+ end
67
+ end
68
+
69
+ def actionable_entries
70
+ report_pairs.flat_map do |corpus, report|
71
+ report.fetch('findings').filter_map do |finding|
72
+ next unless actionable?(finding)
73
+
74
+ {
75
+ 'corpus' => corpus.to_s,
76
+ 'id' => finding.fetch('id').to_s,
77
+ 'definition_id' => finding['definition_id'],
78
+ 'path' => finding['path'],
79
+ 'line' => finding['line'],
80
+ 'end_line' => finding['end_line'],
81
+ 'loc' => finding['loc'],
82
+ 'state' => finding.fetch('state').to_s,
83
+ 'confidence' => finding.fetch('confidence').to_s,
84
+ 'category' => finding['category'].to_s,
85
+ 'status' => 'pending',
86
+ 'review_class' => review_class(finding)
87
+ }.compact
88
+ end
89
+ end
90
+ end
91
+
92
+ def actionable?(finding)
93
+ finding['candidate'] == true || ACTIONABLE_STATES.include?(finding['state'].to_s)
94
+ end
95
+
96
+ def review_class(finding)
97
+ return 'high_candidate' if HIGH_CONFIDENCES.include?(finding['confidence'].to_s)
98
+
99
+ 'candidate'
100
+ end
101
+
102
+ def sort_key(entry)
103
+ [
104
+ CONFIDENCE_ORDER.fetch(entry.fetch('confidence'), CONFIDENCE_ORDER.length),
105
+ entry.fetch('corpus'),
106
+ entry.fetch('category'),
107
+ entry.fetch('id'),
108
+ entry['definition_id'].to_s
109
+ ]
110
+ end
111
+
112
+ def corpus_summary(available, entries)
113
+ available_by_corpus = available.group_by { |entry| entry.fetch('corpus') }
114
+ reports.keys.map(&:to_s).sort.to_h do |corpus|
115
+ available_entries = available_by_corpus.fetch(corpus, [])
116
+ queued = entries.select { |entry| entry.fetch('corpus') == corpus }
117
+ [corpus, {
118
+ 'available_candidates' => available_entries.length,
119
+ 'queued_candidates' => queued.length,
120
+ 'queued_high_candidates' => queued.count { |entry| HIGH_CONFIDENCES.include?(entry.fetch('confidence')) }
121
+ }]
122
+ end
123
+ end
124
+
125
+ def provenance
126
+ report_pairs.to_h do |corpus, report|
127
+ canonical = canonicalize(report)
128
+ [corpus.to_s, {
129
+ 'sha256' => Digest::SHA256.hexdigest(JSON.generate(canonical)),
130
+ 'finding_count' => report.fetch('findings').length
131
+ }]
132
+ end
133
+ end
134
+
135
+ def report_pairs
136
+ reports.to_a.sort_by { |corpus, _report| corpus.to_s }
137
+ end
138
+
139
+ def canonicalize(value)
140
+ case value
141
+ when Hash
142
+ value.keys.map(&:to_s).sort.to_h do |key|
143
+ original = value.key?(key) ? key : value.keys.find { |candidate| candidate.to_s == key }
144
+ [key, canonicalize(value.fetch(original))]
145
+ end
146
+ when Array
147
+ value.map { |item| canonicalize(item) }
148
+ else
149
+ value
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Bench
5
+ class SafetyMutationHarness
6
+ Result = Data.define(:name, :detected, :violations)
7
+ HEALTH_RANK = { invalid: 0, degraded: 1, complete: 2 }.freeze
8
+
9
+ def initialize(baseline:)
10
+ @baseline = baseline
11
+ end
12
+
13
+ def evaluate(mutants)
14
+ Hash(mutants).sort.to_h do |name, report|
15
+ violations = violations_for(report)
16
+ [name.to_s, Result.new(name: name.to_s, detected: violations.any?, violations: violations.freeze)]
17
+ end
18
+ end
19
+
20
+ def assert_all_detected!(mutants)
21
+ results = evaluate(mutants)
22
+ survivors = results.values.reject(&:detected).map(&:name)
23
+ raise Error, "Safety mutations survived: #{survivors.join(', ')}" if survivors.any?
24
+
25
+ results
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :baseline
31
+
32
+ def violations_for(mutant)
33
+ violations = []
34
+ added = actionable_ids(mutant) - actionable_ids(baseline)
35
+ violations << "actionable candidates grew: #{added.sort.join(', ')}" if added.any?
36
+ bypassed = blocked_ids(baseline) & actionable_ids(mutant)
37
+ violations << "blocked definitions became actionable: #{bypassed.sort.join(', ')}" if bypassed.any?
38
+ if health_rank(mutant) > health_rank(baseline) && !baseline.analysis_health.complete?
39
+ violations << 'incomplete analysis was promoted to healthier status'
40
+ end
41
+ violations
42
+ end
43
+
44
+ def actionable_ids(report)
45
+ report.actionable_candidates(min_confidence: :low).to_set { |finding| finding.node.graph_id }
46
+ end
47
+
48
+ def blocked_ids(report)
49
+ report.findings.select { |finding| finding.classification == :blocked }.to_set do |finding|
50
+ finding.node.graph_id
51
+ end
52
+ end
53
+
54
+ def health_rank(report)
55
+ HEALTH_RANK.fetch(report.analysis_health.status)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'open3'
7
+ require 'yaml'
8
+
9
+ require_relative 'candidate_union'
10
+ require_relative 'precision_gate'
11
+ require_relative 'report_normalizer'
12
+
13
+ module Necropsy
14
+ module Bench
15
+ class SeedRunner
16
+ def initialize(
17
+ manifest_path:,
18
+ output_dir:,
19
+ io: $stdout,
20
+ clock: Process.method(:clock_gettime),
21
+ analyzer: nil,
22
+ feature_ablation: {},
23
+ rss_reader: nil,
24
+ revision_reader: nil,
25
+ dirty_reader: nil
26
+ )
27
+ @manifest_path = File.expand_path(manifest_path)
28
+ @output_dir = File.expand_path(output_dir)
29
+ @io = io
30
+ @clock = clock
31
+ @analyzer = analyzer || lambda { |root, config_path|
32
+ Necropsy.analyze(root: root, config_path: config_path, profile: true)
33
+ }
34
+ @feature_ablation = feature_ablation
35
+ @rss_reader = rss_reader || method(:read_process_rss)
36
+ @revision_reader = revision_reader || method(:read_git_revision)
37
+ @dirty_reader = dirty_reader || method(:tracked_git_dirty?)
38
+ end
39
+
40
+ def call(update_golden_reason: nil)
41
+ prepare_output
42
+ reports = {}
43
+ corpus_runs = manifest.fetch('corpora').sort.map do |id, definition|
44
+ run_corpus(id, definition, reports)
45
+ end
46
+ union = CandidateUnion.new(
47
+ manifest: manifest,
48
+ repository_root: repository_root,
49
+ reports: reports,
50
+ diagnostics: diagnostics
51
+ ).call
52
+ write_json(File.join(output_dir, 'candidate_union.json'), union)
53
+ update_golden(update_golden_reason, corpus_runs) if update_golden_reason
54
+ golden = golden_status
55
+ report_golden_status(golden)
56
+ summary = build_summary(corpus_runs, union, golden)
57
+ write_json(File.join(output_dir, 'summary.json'), summary)
58
+ summary
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :manifest_path, :output_dir, :io, :clock, :analyzer, :feature_ablation, :rss_reader,
64
+ :revision_reader, :dirty_reader
65
+
66
+ def manifest
67
+ @manifest ||= YAML.safe_load_file(manifest_path, aliases: false).tap do |payload|
68
+ raise Error, 'Benchmark manifest schema_version must be 1' unless payload&.fetch('schema_version', nil) == 1
69
+ end
70
+ rescue Psych::Exception => e
71
+ raise Error, "Could not parse benchmark manifest: #{e.message}"
72
+ end
73
+
74
+ def repository_root
75
+ @repository_root ||= File.expand_path(manifest.fetch('repository_root'), File.dirname(manifest_path))
76
+ end
77
+
78
+ def reports_dir
79
+ File.join(output_dir, 'reports')
80
+ end
81
+
82
+ def diagnostics
83
+ @diagnostics ||= []
84
+ end
85
+
86
+ def prepare_output
87
+ FileUtils.mkdir_p(reports_dir)
88
+ Dir.glob(File.join(reports_dir, '*.json')).each { |path| FileUtils.rm_f(path) }
89
+ end
90
+
91
+ def run_corpus(id, definition, reports)
92
+ path = corpus_path(definition)
93
+ return skipped_corpus(id, definition, path) unless path && File.directory?(path)
94
+
95
+ revision_error = pinned_corpus_error(definition, path)
96
+ return failed_revision(id, definition, revision_error) if revision_error
97
+
98
+ samples = performance_sample_count
99
+ measurements = Array.new(samples) do
100
+ started_at = monotonic_time
101
+ report = analyzer.call(path, config_path(path, definition))
102
+ wall_time = monotonic_time - started_at
103
+ normalized = ReportNormalizer.new(report: report, corpus: id).call
104
+ {
105
+ report: report,
106
+ normalized: normalized,
107
+ wall_time: wall_time,
108
+ rss: rss_measurement(id),
109
+ allocated_objects: report.performance_profile&.dig('totals', 'allocated_objects'),
110
+ artifact_size_bytes: JSON.generate(normalized).bytesize
111
+ }
112
+ end
113
+ report = measurements.last.fetch(:report)
114
+ normalized = measurements.last.fetch(:normalized)
115
+ reports[id] = normalized
116
+ write_json(File.join(reports_dir, "#{id}.json"), normalized)
117
+ io.puts "#{id}: generated"
118
+ performance = performance_distribution(measurements)
119
+ analysis_profile = report.performance_profile if report.respond_to?(:performance_profile)
120
+ performance['analysis_profile'] = analysis_profile if analysis_profile
121
+ {
122
+ 'id' => id,
123
+ 'status' => 'generated',
124
+ 'revision' => definition['revision'],
125
+ 'metrics' => normalized.fetch('metrics'),
126
+ 'performance' => performance
127
+ }.compact
128
+ rescue Error, SystemCallError => e
129
+ message = "#{id} failed: #{e.message}"
130
+ diagnostics << message
131
+ io.puts message
132
+ { 'id' => id, 'status' => 'failed', 'diagnostic' => message }
133
+ end
134
+
135
+ def performance_sample_count
136
+ value = Integer(manifest.fetch('performance_samples', 1))
137
+ raise Error, 'performance_samples must be between 1 and 20' unless value.between?(1, 20)
138
+
139
+ value
140
+ rescue ArgumentError, TypeError
141
+ raise Error, 'performance_samples must be between 1 and 20'
142
+ end
143
+
144
+ def performance_distribution(measurements)
145
+ wall_times = measurements.map { |measurement| measurement.fetch(:wall_time) }
146
+ allocations = measurements.filter_map { |measurement| measurement[:allocated_objects] }
147
+ artifact_sizes = measurements.map { |measurement| measurement.fetch(:artifact_size_bytes) }
148
+ rss = measurements.map { |measurement| measurement.fetch(:rss) }.max_by { |value| rss_value(value) || -1 }
149
+ {
150
+ 'sample_count' => measurements.length,
151
+ 'wall_time_seconds' => mean(wall_times).round(6),
152
+ 'wall_time_p95_seconds' => percentile(wall_times, 0.95).round(6),
153
+ 'wall_time_max_seconds' => wall_times.max.round(6),
154
+ 'allocated_objects_p95' => percentile(allocations, 0.95),
155
+ 'allocated_objects_max' => allocations.max,
156
+ 'artifact_size_p95_bytes' => percentile(artifact_sizes, 0.95),
157
+ 'artifact_size_max_bytes' => artifact_sizes.max
158
+ }.compact.merge(rss)
159
+ end
160
+
161
+ def percentile(values, quantile)
162
+ return if values.empty?
163
+
164
+ sorted = values.sort
165
+ sorted.fetch([(sorted.length * quantile).ceil - 1, 0].max)
166
+ end
167
+
168
+ def mean(values)
169
+ values.sum.to_f / values.length
170
+ end
171
+
172
+ def rss_value(measurement)
173
+ measurement['process_hwm_kb'] || measurement['process_rss_kb']
174
+ end
175
+
176
+ def corpus_path(definition)
177
+ env_path = ENV[definition['path_env'].to_s] if definition['path_env']
178
+ relative = env_path || definition['path']
179
+ File.expand_path(relative, repository_root) if relative && !relative.empty?
180
+ end
181
+
182
+ def config_path(path, definition)
183
+ config = definition['config']
184
+ return unless config
185
+
186
+ repository_config = File.expand_path(config, repository_root)
187
+ return repository_config if File.file?(repository_config)
188
+
189
+ File.expand_path(config, path)
190
+ end
191
+
192
+ def skipped_corpus(id, definition, path)
193
+ source = definition['path_env'] ? "set #{definition['path_env']}" : path.inspect
194
+ message = "#{id} skipped: corpus unavailable (#{source})"
195
+ diagnostics << message
196
+ io.puts message
197
+ { 'id' => id, 'status' => 'skipped', 'revision' => definition['revision'], 'diagnostic' => message }.compact
198
+ end
199
+
200
+ def pinned_corpus_error(definition, path)
201
+ expected = definition['git_commit']
202
+ return unless expected
203
+
204
+ actual = revision_reader.call(path, 'HEAD')
205
+ return "expected Git HEAD #{expected}, got #{actual || 'not a Git checkout'} at #{path}" unless actual == expected
206
+
207
+ tag = definition['git_tag']
208
+ tag_revision = revision_reader.call(path, "refs/tags/#{tag}^{commit}") if tag
209
+ return "expected Git tag #{tag} at #{expected}, got #{tag_revision || 'missing'}" if tag && tag_revision != expected
210
+
211
+ "tracked Git changes present at #{path}" if dirty_reader.call(path)
212
+ end
213
+
214
+ def failed_revision(id, definition, reason)
215
+ message = "#{id} failed: #{reason}"
216
+ diagnostics << message
217
+ io.puts message
218
+ { 'id' => id, 'status' => 'failed', 'revision' => definition['git_commit'], 'diagnostic' => message }
219
+ end
220
+
221
+ def rss_measurement(id)
222
+ measurement = rss_reader.call
223
+ return measurement if measurement
224
+
225
+ message = "#{id}: RSS unavailable on this platform"
226
+ diagnostics << message unless diagnostics.include?(message)
227
+ { 'rss_status' => 'unavailable', 'rss_diagnostic' => message }
228
+ end
229
+
230
+ def read_process_rss
231
+ status_path = '/proc/self/status'
232
+ if File.file?(status_path)
233
+ match = File.read(status_path).match(/^VmHWM:\s+(\d+)\s+kB$/)
234
+ if match
235
+ return {
236
+ 'process_hwm_kb' => match[1].to_i,
237
+ 'rss_kind' => 'cumulative_process_high_water_mark',
238
+ 'rss_scope' => 'benchmark_runner_process'
239
+ }
240
+ end
241
+ end
242
+
243
+ output = IO.popen(['ps', '-o', 'rss=', '-p', Process.pid.to_s], &:read)
244
+ rss = Integer(output.strip, exception: false)
245
+ return unless rss
246
+
247
+ {
248
+ 'process_rss_kb' => rss,
249
+ 'rss_kind' => 'current_process_rss_after_corpus',
250
+ 'rss_scope' => 'benchmark_runner_process'
251
+ }
252
+ rescue ArgumentError, SystemCallError
253
+ nil
254
+ end
255
+
256
+ def read_git_revision(path, reference)
257
+ output, status = Open3.capture2e('git', '-C', path, 'rev-parse', reference)
258
+ status.success? ? output.strip : nil
259
+ rescue SystemCallError
260
+ nil
261
+ end
262
+
263
+ def tracked_git_dirty?(path)
264
+ output, status = Open3.capture2e('git', '-C', path, 'status', '--porcelain', '--untracked-files=no')
265
+ !status.success? || !output.strip.empty?
266
+ rescue SystemCallError
267
+ true
268
+ end
269
+
270
+ def monotonic_time
271
+ clock.call(Process::CLOCK_MONOTONIC)
272
+ end
273
+
274
+ def build_summary(corpus_runs, union, golden)
275
+ {
276
+ 'schema_version' => 1,
277
+ 'manifest' => File.basename(manifest_path),
278
+ 'corpora' => corpus_runs,
279
+ 'candidate_union' => union.fetch('summary'),
280
+ 'feature_ablation' => deterministic_payload(feature_ablation),
281
+ 'precision_gate' => precision_gate(union),
282
+ 'golden' => golden,
283
+ 'diagnostics' => diagnostics.sort
284
+ }
285
+ end
286
+
287
+ def precision_gate(union)
288
+ policy = manifest['precision_gate']
289
+ unless policy
290
+ return {
291
+ 'schema_version' => 1,
292
+ 'enforced' => false,
293
+ 'compatibility' => 'manifest without precision_gate retains the benchmark-v1 policy',
294
+ 'passed' => true
295
+ }
296
+ end
297
+
298
+ PrecisionGate.new(
299
+ policy: policy,
300
+ candidate_union_summary: union.fetch('summary'),
301
+ feature_ablation: feature_ablation
302
+ ).call
303
+ end
304
+
305
+ def deterministic_payload(value)
306
+ case value
307
+ when Hash
308
+ value.sort.to_h { |key, nested| [key.to_s, deterministic_payload(nested)] }
309
+ when Array
310
+ value.map { |nested| deterministic_payload(nested) }
311
+ else
312
+ value
313
+ end
314
+ end
315
+
316
+ def golden_status
317
+ golden_dir = File.expand_path(manifest.fetch('golden_dir'), repository_root)
318
+ return { 'status' => 'missing', 'differences' => deterministic_artifacts } unless File.directory?(golden_dir)
319
+
320
+ integrity = golden_integrity(golden_dir)
321
+ return integrity unless integrity['status'] == 'valid'
322
+
323
+ expected = artifact_contents(golden_dir)
324
+ actual = artifact_contents(output_dir)
325
+ differences = (expected.keys | actual.keys).reject { |path| expected[path] == actual[path] }.sort
326
+ { 'status' => differences.empty? ? 'match' : 'drift', 'differences' => differences }
327
+ end
328
+
329
+ def golden_integrity(golden_dir)
330
+ metadata_path = File.join(golden_dir, 'metadata.json')
331
+ return { 'status' => 'invalid', 'differences' => ['metadata.json missing'] } unless File.file?(metadata_path)
332
+
333
+ metadata = JSON.parse(File.read(metadata_path))
334
+ reason = metadata['update_reason'].to_s.strip
335
+ return { 'status' => 'invalid', 'differences' => ['update reason missing'] } if reason.empty?
336
+
337
+ actual = artifact_digests(golden_dir)
338
+ expected = metadata.fetch('artifacts', {})
339
+ differences = (actual.keys | expected.keys).reject { |path| actual[path] == expected[path] }.sort
340
+ return { 'status' => 'invalid', 'differences' => differences } unless differences.empty?
341
+
342
+ { 'status' => 'valid', 'differences' => [] }
343
+ rescue JSON::ParserError
344
+ { 'status' => 'invalid', 'differences' => ['metadata.json malformed'] }
345
+ end
346
+
347
+ def deterministic_artifacts
348
+ ['candidate_union.json'] + Dir.glob(File.join(output_dir, 'reports', '*.json')).map do |path|
349
+ File.join('reports', File.basename(path))
350
+ end.sort
351
+ end
352
+
353
+ def artifact_contents(root)
354
+ paths = ['candidate_union.json'] + Dir.glob(File.join(root, 'reports', '*.json')).map do |path|
355
+ File.join('reports', File.basename(path))
356
+ end.sort
357
+ paths.to_h do |relative|
358
+ path = File.join(root, relative)
359
+ [relative, File.file?(path) ? File.binread(path) : nil]
360
+ end
361
+ end
362
+
363
+ def artifact_digests(root)
364
+ artifact_contents(root).transform_values { |contents| Digest::SHA256.hexdigest(contents) }
365
+ end
366
+
367
+ def report_golden_status(golden)
368
+ differences = golden.fetch('differences')
369
+ suffix = differences.empty? ? '' : " (#{differences.join(', ')})"
370
+ io.puts "golden: #{golden.fetch('status')}#{suffix}"
371
+ end
372
+
373
+ def write_json(path, payload)
374
+ FileUtils.mkdir_p(File.dirname(path))
375
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
376
+ end
377
+
378
+ def update_golden(reason, corpus_runs)
379
+ raise Error, 'Updating benchmark golden files requires a non-empty reason' if reason.to_s.strip.empty?
380
+
381
+ ensure_required_corpora_generated!(corpus_runs)
382
+
383
+ golden_dir = File.expand_path(manifest.fetch('golden_dir'), repository_root)
384
+ FileUtils.mkdir_p(File.join(golden_dir, 'reports'))
385
+ FileUtils.cp(File.join(output_dir, 'candidate_union.json'), File.join(golden_dir, 'candidate_union.json'))
386
+ sources = Dir.glob(File.join(output_dir, 'reports', '*.json'))
387
+ sources.each do |source|
388
+ FileUtils.cp(source, File.join(golden_dir, 'reports', File.basename(source)))
389
+ end
390
+ write_json(File.join(golden_dir, 'metadata.json'), {
391
+ 'schema_version' => 1,
392
+ 'update_reason' => reason.strip,
393
+ 'artifacts' => artifact_digests(golden_dir).sort.to_h
394
+ })
395
+ end
396
+
397
+ def ensure_required_corpora_generated!(corpus_runs)
398
+ statuses = corpus_runs.to_h { |corpus| [corpus.fetch('id'), corpus.fetch('status')] }
399
+ missing = manifest.fetch('corpora').filter_map do |id, definition|
400
+ id if definition['required'] == true && statuses[id] != 'generated'
401
+ end.sort
402
+ return if missing.empty?
403
+
404
+ raise Error, "Cannot update golden files; required corpora were not generated: #{missing.join(', ')}"
405
+ end
406
+ end
407
+ end
408
+ end