rspec-flake-classifier 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 (31) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +301 -0
  4. data/Rakefile +8 -0
  5. data/exe/rspec-flake +6 -0
  6. data/lib/rspec/flake/classifier/classify/classifier.rb +228 -0
  7. data/lib/rspec/flake/classifier/classify/context.rb +41 -0
  8. data/lib/rspec/flake/classifier/classify/result.rb +44 -0
  9. data/lib/rspec/flake/classifier/cli.rb +298 -0
  10. data/lib/rspec/flake/classifier/configuration.rb +40 -0
  11. data/lib/rspec/flake/classifier/coverage_snapshot.rb +89 -0
  12. data/lib/rspec/flake/classifier/deflaker.rb +102 -0
  13. data/lib/rspec/flake/classifier/evaluation.rb +127 -0
  14. data/lib/rspec/flake/classifier/example_history.rb +24 -0
  15. data/lib/rspec/flake/classifier/features.rb +42 -0
  16. data/lib/rspec/flake/classifier/formatter.rb +194 -0
  17. data/lib/rspec/flake/classifier/integrations.rb +247 -0
  18. data/lib/rspec/flake/classifier/predictor.rb +144 -0
  19. data/lib/rspec/flake/classifier/probe_evidence.rb +77 -0
  20. data/lib/rspec/flake/classifier/rerun/bisect_dependency_search.rb +81 -0
  21. data/lib/rspec/flake/classifier/rerun/isolated_runner.rb +69 -0
  22. data/lib/rspec/flake/classifier/rerun/protocol.rb +83 -0
  23. data/lib/rspec/flake/classifier/rerun/result.rb +82 -0
  24. data/lib/rspec/flake/classifier/runtime_controls.rb +63 -0
  25. data/lib/rspec/flake/classifier/sensitivity.rb +82 -0
  26. data/lib/rspec/flake/classifier/signature.rb +59 -0
  27. data/lib/rspec/flake/classifier/store/jsonl_store.rb +131 -0
  28. data/lib/rspec/flake/classifier/version.rb +13 -0
  29. data/lib/rspec/flake/classifier.rb +285 -0
  30. data/sig/rspec/flake/classifier.rbs +176 -0
  31. metadata +135 -0
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../classifier"
6
+
7
+ module RSpec
8
+ module FlakeClassifier
9
+ class CLI
10
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
11
+ @argv = argv.dup
12
+ @stdout = stdout
13
+ @stderr = stderr
14
+ @options = { store: Configuration::DEFAULT_STORE, format: "text" }
15
+ end
16
+
17
+ def run
18
+ command = argv.shift
19
+ return usage(1) unless command
20
+
21
+ case command
22
+ when "investigate" then investigate
23
+ when "classify" then classify
24
+ when "features" then features
25
+ when "predict" then predict
26
+ when "train" then train
27
+ when "evaluate" then evaluate
28
+ when "sensitivity" then sensitivity
29
+ when "report" then report
30
+ else
31
+ stderr.puts("Unknown command: #{command}")
32
+ usage(1)
33
+ end
34
+ rescue OptionParser::ParseError, Error => e
35
+ stderr.puts(e.message)
36
+ 1
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :argv, :stdout, :stderr, :options
42
+
43
+ def investigate
44
+ parser = OptionParser.new do |parser|
45
+ parser.banner = "Usage: rspec-flake investigate EXAMPLE_ID [options]"
46
+ parser.on("--seed SEED", Integer) { |value| options[:seed] = value }
47
+ parser.on("--attempts N", Integer) { |value| options[:attempts] = value }
48
+ parser.on("--prior EXAMPLE_ID") { |value| (options[:prior_examples] ||= []) << value }
49
+ parser.on("--prior-file PATH") { |value| options[:prior_file] = value }
50
+ parser.on("--store PATH") { |value| options[:store] = value }
51
+ parser.on("--json") { options[:format] = "json" }
52
+ end
53
+ parser.parse!(argv)
54
+ example_id = argv.shift || raise(Error, "EXAMPLE_ID is required")
55
+
56
+ configuration.store = options[:store]
57
+ configuration.same_order_runs = options[:attempts] if options[:attempts]
58
+ result = RSpec::FlakeClassifier.investigate(
59
+ example_id,
60
+ seed: options[:seed],
61
+ prior_examples: prior_examples
62
+ )
63
+ write_result(result.to_h)
64
+ 0
65
+ end
66
+
67
+ def classify
68
+ parser = OptionParser.new do |parser|
69
+ parser.banner = "Usage: rspec-flake classify [MESSAGE] [options]"
70
+ parser.on("--from-store") { options[:from_store] = true }
71
+ parser.on("--store PATH") { |value| options[:store] = value }
72
+ parser.on("--json") { options[:format] = "json" }
73
+ end
74
+ parser.parse!(argv)
75
+
76
+ if options[:from_store]
77
+ classify_store
78
+ else
79
+ message = argv.join(" ")
80
+ raise Error, "MESSAGE is required unless --from-store is used" if message.empty?
81
+
82
+ write_result(RSpec::FlakeClassifier.classify(message: message).to_h)
83
+ end
84
+ 0
85
+ end
86
+
87
+ def classify_store
88
+ store = Store::JSONLStore.new(options[:store])
89
+ entries = store.entries.map do |entry|
90
+ result = RSpec::FlakeClassifier.classify(message: entry.fetch("normalized", ""))
91
+ store.update_classification(
92
+ entry.fetch("digest"),
93
+ classification: result,
94
+ metadata: { classified_by: "rspec-flake classify" }
95
+ )
96
+ end
97
+ write_result("entries" => entries)
98
+ end
99
+
100
+ def report
101
+ parser = OptionParser.new do |parser|
102
+ parser.banner = "Usage: rspec-flake report [options]"
103
+ parser.on("--store PATH") { |value| options[:store] = value }
104
+ parser.on("--json") { options[:format] = "json" }
105
+ end
106
+ parser.parse!(argv)
107
+ entries = Store::JSONLStore.new(options[:store]).entries
108
+ write_result(report_hash(entries))
109
+ 0
110
+ end
111
+
112
+ def features
113
+ parser = OptionParser.new do |parser|
114
+ parser.banner = "Usage: rspec-flake features FILE [options]"
115
+ parser.on("--duration SECONDS", Float) { |value| options[:duration] = value }
116
+ parser.on("--json") { options[:format] = "json" }
117
+ end
118
+ parser.parse!(argv)
119
+ file = argv.shift || raise(Error, "FILE is required")
120
+ write_result(RSpec::FlakeClassifier::Features.new.extract(file: file, duration: options[:duration]))
121
+ 0
122
+ end
123
+
124
+ def sensitivity
125
+ parser = OptionParser.new do |parser|
126
+ parser.banner = "Usage: rspec-flake sensitivity EXAMPLE_ID [options]"
127
+ parser.on("--seed SEED", Integer) { |value| options[:seed] = value }
128
+ parser.on("--factor FACTOR") { |value| (options[:factors] ||= []) << value.to_sym }
129
+ parser.on("--json") { options[:format] = "json" }
130
+ end
131
+ parser.parse!(argv)
132
+ example_id = argv.shift || raise(Error, "EXAMPLE_ID is required")
133
+ factors = options[:factors] || configuration.sensitivity_factors
134
+ write_result(RSpec::FlakeClassifier.sensitivity(example_id, factors: factors, seed: options[:seed]).to_h)
135
+ 0
136
+ end
137
+
138
+ def predict
139
+ parser = OptionParser.new do |parser|
140
+ parser.banner = "Usage: rspec-flake predict FILE... [options]"
141
+ parser.on("--weights PATH") { |value| options[:weights] = value }
142
+ parser.on("--json") { options[:format] = "json" }
143
+ end
144
+ parser.parse!(argv)
145
+ raise Error, "at least one FILE is required" if argv.empty?
146
+
147
+ extractor = RSpec::FlakeClassifier::Features.new
148
+ features = argv.map { |file| extractor.extract(file: file) }
149
+ write_result("predictions" => predictor.rank(features))
150
+ 0
151
+ end
152
+
153
+ def train
154
+ parser = OptionParser.new do |parser|
155
+ parser.banner = "Usage: rspec-flake train FEATURES_JSON_OR_JSONL [options]"
156
+ parser.on("--out PATH") { |value| options[:out] = value }
157
+ parser.on("--json") { options[:format] = "json" }
158
+ end
159
+ parser.parse!(argv)
160
+ file = argv.shift || raise(Error, "FEATURES_JSON_OR_JSONL is required")
161
+ model = RSpec::FlakeClassifier::Predictor.train(read_records(file))
162
+ File.write(options[:out], JSON.pretty_generate(model.to_h)) if options[:out]
163
+ write_result(model.to_h)
164
+ 0
165
+ end
166
+
167
+ def evaluate
168
+ parser = OptionParser.new do |parser|
169
+ parser.banner = "Usage: rspec-flake evaluate [options]"
170
+ parser.on("--predictions PATH") { |value| options[:predictions] = value }
171
+ parser.on("--ground-truth PATH") { |value| options[:ground_truth] = value }
172
+ parser.on("--deflaker PATH") { |value| options[:deflaker_records] = value }
173
+ parser.on("--signatures PATH") { |value| options[:signature_records] = value }
174
+ parser.on("--idflakies PATH") { |value| options[:idflakies_records] = value }
175
+ parser.on("--json") { options[:format] = "json" }
176
+ end
177
+ parser.parse!(argv)
178
+ result = evaluation_result
179
+ raise Error, "no evaluation inputs provided" if result.empty?
180
+
181
+ write_result(result)
182
+ 0
183
+ end
184
+
185
+ def report_hash(entries)
186
+ labels = Hash.new(0)
187
+ entries.each do |entry|
188
+ Array(entry["labels"]).each { |label| labels[label] += entry.fetch("occurrences", 1).to_i }
189
+ end
190
+
191
+ {
192
+ "total_signatures" => entries.length,
193
+ "total_occurrences" => entries.sum { |entry| entry.fetch("occurrences", 1).to_i },
194
+ "labels" => labels.sort.to_h,
195
+ "entries" => entries
196
+ }
197
+ end
198
+
199
+ def write_result(hash)
200
+ if options[:format] == "json"
201
+ stdout.puts(JSON.pretty_generate(hash))
202
+ else
203
+ stdout.puts(render_text(hash))
204
+ end
205
+ end
206
+
207
+ def render_text(hash)
208
+ return render_entries(hash.fetch("entries")) if hash.key?("entries")
209
+
210
+ hash.map { |key, value| "#{key}: #{value.inspect}" }.join("\n")
211
+ end
212
+
213
+ def render_entries(entries)
214
+ return "No flake signatures recorded." if entries.empty?
215
+
216
+ entries.map do |entry|
217
+ labels = Array(entry["labels"]).join(", ")
218
+ "#{entry["digest"]} occurrences=#{entry["occurrences"]} labels=#{labels}"
219
+ end.join("\n")
220
+ end
221
+
222
+ def usage(code)
223
+ stdout.puts(<<~TEXT)
224
+ Usage:
225
+ rspec-flake investigate EXAMPLE_ID [--seed SEED] [--attempts N] [--json]
226
+ rspec-flake classify MESSAGE [--json]
227
+ rspec-flake classify --from-store [--store PATH] [--json]
228
+ rspec-flake features FILE [--duration SECONDS] [--json]
229
+ rspec-flake predict FILE... [--weights PATH] [--json]
230
+ rspec-flake train FEATURES_JSON_OR_JSONL [--out PATH] [--json]
231
+ rspec-flake evaluate [--predictions PATH --ground-truth PATH] [--deflaker PATH] [--signatures PATH] [--idflakies PATH] [--json]
232
+ rspec-flake sensitivity EXAMPLE_ID [--seed SEED] [--factor FACTOR] [--json]
233
+ rspec-flake report [--store PATH] [--json]
234
+ TEXT
235
+ code
236
+ end
237
+
238
+ def prior_examples
239
+ Array(options[:prior_examples]) + prior_examples_from_file
240
+ end
241
+
242
+ def prior_examples_from_file
243
+ return [] unless options[:prior_file]
244
+
245
+ File.readlines(options[:prior_file], chomp: true).map(&:strip).reject(&:empty?)
246
+ end
247
+
248
+ def configuration
249
+ RSpec::FlakeClassifier.configuration
250
+ end
251
+
252
+ def predictor
253
+ return RSpec::FlakeClassifier::Predictor.load(options[:weights]) if options[:weights]
254
+
255
+ RSpec::FlakeClassifier::Predictor.new
256
+ end
257
+
258
+ def evaluation_result
259
+ evaluator = RSpec::FlakeClassifier::Evaluation.new
260
+ result = {}
261
+ if options[:predictions] && options[:ground_truth]
262
+ result["classification"] = evaluator.classification(
263
+ predictions: read_records(options[:predictions]),
264
+ ground_truth: read_records(options[:ground_truth])
265
+ )
266
+ end
267
+ result["deflaker"] = evaluator.deflaker(records: read_records(options[:deflaker_records])) if options[:deflaker_records]
268
+ result["signatures"] = evaluator.signatures(records: read_records(options[:signature_records])) if options[:signature_records]
269
+ result["idflakies"] = evaluator.idflakies(records: read_records(options[:idflakies_records])) if options[:idflakies_records]
270
+ result
271
+ end
272
+
273
+ def read_records(path)
274
+ content = File.read(path)
275
+ stripped = content.strip
276
+ return [] if stripped.empty?
277
+
278
+ parsed = json_records(stripped)
279
+ return parsed if parsed
280
+
281
+ stripped.each_line.map { |line| JSON.parse(line) }
282
+ end
283
+
284
+ def json_records(content)
285
+ parsed = JSON.parse(content)
286
+ return parsed if parsed.is_a?(Array)
287
+ return parsed.fetch("entries") if parsed.key?("entries")
288
+ return parsed.fetch("predictions") if parsed.key?("predictions")
289
+ return parsed.fetch("records") if parsed.key?("records")
290
+ return parsed.fetch("features") if parsed.key?("features")
291
+
292
+ [parsed]
293
+ rescue JSON::ParserError
294
+ nil
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ class Configuration
6
+ DEFAULT_STORE = ".rspec_flake_store"
7
+
8
+ attr_accessor :store, :auto_rerun, :deflaker, :rspec_command,
9
+ :same_order_runs, :skip_known_flakes, :coverage_provider,
10
+ :changed_lines_provider, :probe_provider, :run_sensitivity,
11
+ :sensitivity_factors
12
+
13
+ def initialize
14
+ @store = DEFAULT_STORE
15
+ @auto_rerun = false
16
+ @deflaker = false
17
+ @rspec_command = nil
18
+ @same_order_runs = 3
19
+ @skip_known_flakes = false
20
+ @coverage_provider = nil
21
+ @changed_lines_provider = nil
22
+ @probe_provider = nil
23
+ @run_sensitivity = false
24
+ @sensitivity_factors = %i[time randomness network]
25
+ end
26
+
27
+ def auto_rerun_attempts
28
+ return same_order_runs unless auto_rerun.is_a?(Hash)
29
+
30
+ Integer(auto_rerun.fetch(:failures, same_order_runs))
31
+ rescue ArgumentError, TypeError
32
+ same_order_runs
33
+ end
34
+
35
+ def store_path
36
+ store.to_s.empty? ? DEFAULT_STORE : store.to_s
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+
5
+ module RSpec
6
+ module FlakeClassifier
7
+ class CoverageSnapshot
8
+ def self.capture
9
+ return null unless Coverage.respond_to?(:running?) && Coverage.running?
10
+ return null unless Coverage.respond_to?(:peek_result)
11
+
12
+ new(Coverage.peek_result)
13
+ rescue RuntimeError
14
+ null
15
+ end
16
+
17
+ def self.null
18
+ new({})
19
+ end
20
+
21
+ def initialize(data)
22
+ @data = normalize(data)
23
+ end
24
+
25
+ def delta
26
+ after = self.class.capture_snapshot
27
+ after.each_with_object({}) do |(file, counts), result|
28
+ changed = line_delta(Array(@data[file]), counts)
29
+ result[relative_path(file)] = changed unless changed.empty?
30
+ end
31
+ end
32
+
33
+ def self.capture_snapshot
34
+ return {} unless Coverage.respond_to?(:running?) && Coverage.running?
35
+ return {} unless Coverage.respond_to?(:peek_result)
36
+
37
+ normalize_static(Coverage.peek_result)
38
+ rescue RuntimeError
39
+ {}
40
+ end
41
+
42
+ def self.normalize_static(data)
43
+ data.each_with_object({}) do |(file, value), result|
44
+ result[file.to_s] = line_counts(value)
45
+ end
46
+ end
47
+
48
+ def self.line_counts(value)
49
+ return Array(value) if value.is_a?(Array)
50
+ return Array(value[:lines]) if value.is_a?(Hash) && value.key?(:lines)
51
+ return Array(value["lines"]) if value.is_a?(Hash) && value.key?("lines")
52
+
53
+ []
54
+ end
55
+
56
+ private
57
+
58
+ def normalize(data)
59
+ self.class.normalize_static(data)
60
+ end
61
+
62
+ def line_delta(before, after)
63
+ after.each_with_index.each_with_object([]) do |(after_count, index), lines|
64
+ next unless count_positive?(after_count)
65
+
66
+ before_count = before[index]
67
+ lines << index + 1 if count_value(after_count) > count_value(before_count)
68
+ end
69
+ end
70
+
71
+ def count_positive?(count)
72
+ count_value(count).positive?
73
+ end
74
+
75
+ def count_value(count)
76
+ Integer(count || 0)
77
+ rescue ArgumentError, TypeError
78
+ 0
79
+ end
80
+
81
+ def relative_path(file)
82
+ path = file.to_s
83
+ return path unless path.start_with?(Dir.pwd)
84
+
85
+ path.delete_prefix("#{Dir.pwd}/")
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RSpec
6
+ module FlakeClassifier
7
+ class Deflaker
8
+ Result = Struct.new(:suspected, :reason, :covered_lines, :changed_lines, keyword_init: true) do
9
+ def suspected?
10
+ suspected
11
+ end
12
+
13
+ def to_h
14
+ {
15
+ "suspected" => suspected,
16
+ "reason" => reason,
17
+ "covered_lines" => covered_lines,
18
+ "changed_lines" => changed_lines
19
+ }
20
+ end
21
+ end
22
+
23
+ def suspect?(coverage:, changed_lines: nil, base: "HEAD")
24
+ covered = normalize_lines(coverage)
25
+ changed = normalize_lines(changed_lines || git_changed_lines(base))
26
+ intersection = intersect(covered, changed)
27
+
28
+ Result.new(
29
+ suspected: !covered.empty? && intersection.empty?,
30
+ reason: intersection.empty? ? "covered code did not intersect changed lines" : "covered changed lines",
31
+ covered_lines: covered,
32
+ changed_lines: changed
33
+ )
34
+ end
35
+
36
+ def git_changed_lines(base = "HEAD")
37
+ stdout, _stderr, status = Open3.capture3("git", "diff", "--unified=0", base)
38
+ return {} unless status.success?
39
+
40
+ parse_diff(stdout)
41
+ end
42
+
43
+ def parse_diff(diff)
44
+ changed = Hash.new { |hash, key| hash[key] = [] }
45
+ current_file = nil
46
+
47
+ diff.each_line do |line|
48
+ if (match = line.match(%r{\A\+\+\+ b/(.+)}))
49
+ current_file = match[1]
50
+ next
51
+ end
52
+
53
+ next unless current_file
54
+
55
+ match = line.match(/\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
56
+ next unless match
57
+
58
+ start = match[1].to_i
59
+ length = (match[2] || "1").to_i
60
+ next if length.zero?
61
+
62
+ changed[current_file].concat((start...(start + length)).to_a)
63
+ end
64
+
65
+ changed.transform_values { |lines| lines.uniq.sort }
66
+ end
67
+
68
+ private
69
+
70
+ def normalize_lines(lines)
71
+ case lines
72
+ when Hash
73
+ lines.each_with_object({}) do |(file, file_lines), result|
74
+ normalized = Array(file_lines).map(&:to_i).reject(&:zero?).uniq.sort
75
+ result[file.to_s] = normalized unless normalized.empty?
76
+ end
77
+ when Array
78
+ lines.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |entry, result|
79
+ file, line = normalize_entry(entry)
80
+ result[file] << line if file && line.positive?
81
+ end.transform_values { |file_lines| file_lines.uniq.sort }
82
+ else
83
+ {}
84
+ end
85
+ end
86
+
87
+ def normalize_entry(entry)
88
+ return [entry[:file].to_s, entry[:line].to_i] if entry.is_a?(Hash) && entry.key?(:file)
89
+ return [entry["file"].to_s, entry["line"].to_i] if entry.is_a?(Hash) && entry.key?("file")
90
+
91
+ Array(entry).then { |file, line| [file.to_s, line.to_i] }
92
+ end
93
+
94
+ def intersect(left, right)
95
+ left.each_with_object({}) do |(file, lines), result|
96
+ common = lines & Array(right[file])
97
+ result[file] = common unless common.empty?
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ class Evaluation
6
+ def classification(predictions:, ground_truth:)
7
+ expected = index_by_id(ground_truth)
8
+ rows = Array(predictions).filter_map do |prediction|
9
+ id = record_id(prediction)
10
+ truth = Array(expected[id])
11
+ next if id.nil? || truth.empty?
12
+
13
+ predicted = labels_for(prediction)
14
+ {
15
+ "id" => id,
16
+ "truth" => truth,
17
+ "predicted" => predicted,
18
+ "top1" => truth.include?(predicted.first),
19
+ "top2" => !(truth & predicted.first(2)).empty?
20
+ }
21
+ end
22
+ metric_hash(rows)
23
+ end
24
+
25
+ def deflaker(records:)
26
+ flaky = Array(records).select { |record| flaky?(record) }
27
+ captured = flaky.select { |record| deflaker_suspected?(record) }
28
+ {
29
+ "flaky_count" => flaky.length,
30
+ "captured_count" => captured.length,
31
+ "recall" => ratio(captured.length, flaky.length)
32
+ }
33
+ end
34
+
35
+ def signatures(records:)
36
+ flaky = Array(records).select { |record| flaky?(record) }
37
+ signed = flaky.select { |record| signature_for(record) }
38
+ collision_groups = signed.group_by { |record| signature_for(record) }.values.count do |group|
39
+ group.flat_map { |record| labels_for(record) }.uniq.length > 1
40
+ end
41
+ {
42
+ "flaky_count" => flaky.length,
43
+ "signed_count" => signed.length,
44
+ "capture_rate" => ratio(signed.length, flaky.length),
45
+ "collision_groups" => collision_groups
46
+ }
47
+ end
48
+
49
+ def idflakies(records:)
50
+ counts = Hash.new(0)
51
+ Array(records).each do |record|
52
+ order_type = record["order_type"] || record[:order_type] || record.dig("investigation", "order_type")
53
+ counts[order_type || "unknown"] += 1
54
+ end
55
+ total = counts.values.sum
56
+ {
57
+ "total" => total,
58
+ "counts" => counts.sort.to_h,
59
+ "od_ratio" => ratio(counts["od"], total),
60
+ "nod_ratio" => ratio(counts["nod"], total)
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def index_by_id(records)
67
+ Array(records).each_with_object({}) do |record, result|
68
+ id = record_id(record)
69
+ result[id] = labels_for(record) if id
70
+ end
71
+ end
72
+
73
+ def metric_hash(rows)
74
+ {
75
+ "total" => rows.length,
76
+ "top1_accuracy" => ratio(rows.count { |row| row.fetch("top1") }, rows.length),
77
+ "top2_accuracy" => ratio(rows.count { |row| row.fetch("top2") }, rows.length),
78
+ "rows" => rows
79
+ }
80
+ end
81
+
82
+ def record_id(record)
83
+ record["id"] || record[:id] || record["example_id"] || record[:example_id] ||
84
+ record["file"] || record[:file]
85
+ end
86
+
87
+ def labels_for(record)
88
+ labels = record["labels"] || record[:labels] ||
89
+ record.dig("classification", "labels") ||
90
+ record["categories"] || record[:categories] ||
91
+ record["truth"] || record[:truth] ||
92
+ record["label"] || record[:label]
93
+ Array(labels).map { |label| label_category(label) }.reject(&:empty?)
94
+ end
95
+
96
+ def label_category(label)
97
+ return label.fetch("category", label.fetch(:category, label.to_s)) if label.respond_to?(:fetch)
98
+
99
+ label.to_s
100
+ end
101
+
102
+ def flaky?(record)
103
+ return true if record["flaky"] == true || record[:flaky] == true
104
+ return true if record["status"].to_s == "flaky" || record[:status].to_s == "flaky"
105
+ return true if record.dig("classification", "status").to_s == "flaky"
106
+
107
+ labels_for(record).any? { |label| label.include?("flaky") || label == "network" || label == "time" }
108
+ end
109
+
110
+ def deflaker_suspected?(record)
111
+ labels_for(record).include?("suspected_flaky_deflaker") ||
112
+ record.dig("metadata", "deflaker", "suspected") == true ||
113
+ record.dig("deflaker", "suspected") == true
114
+ end
115
+
116
+ def signature_for(record)
117
+ record["signature"] || record[:signature] || record["digest"] || record[:digest]
118
+ end
119
+
120
+ def ratio(numerator, denominator)
121
+ return 0.0 if denominator.to_i.zero?
122
+
123
+ (numerator.to_f / denominator).round(4)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module FlakeClassifier
5
+ class ExampleHistory
6
+ def initialize
7
+ @ids = []
8
+ end
9
+
10
+ def prior_examples
11
+ @ids.dup
12
+ end
13
+
14
+ def record(example)
15
+ id = example.respond_to?(:id) ? example.id : nil
16
+ @ids << id if id && !@ids.include?(id)
17
+ end
18
+
19
+ def clear
20
+ @ids.clear
21
+ end
22
+ end
23
+ end
24
+ end