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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- 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
|