cataract 0.1.3 → 0.1.4

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-manual-rubies.yml +27 -0
  3. data/.overcommit.yml +1 -1
  4. data/.rubocop.yml +62 -0
  5. data/.rubocop_todo.yml +186 -0
  6. data/BENCHMARKS.md +60 -139
  7. data/CHANGELOG.md +10 -0
  8. data/README.md +30 -2
  9. data/Rakefile +49 -22
  10. data/cataract.gemspec +4 -1
  11. data/ext/cataract/cataract.c +47 -47
  12. data/ext/cataract/css_parser.c +17 -33
  13. data/ext/cataract/merge.c +6 -0
  14. data/lib/cataract/at_rule.rb +8 -9
  15. data/lib/cataract/declaration.rb +18 -0
  16. data/lib/cataract/import_resolver.rb +3 -4
  17. data/lib/cataract/pure/byte_constants.rb +69 -0
  18. data/lib/cataract/pure/helpers.rb +35 -0
  19. data/lib/cataract/pure/imports.rb +255 -0
  20. data/lib/cataract/pure/merge.rb +1146 -0
  21. data/lib/cataract/pure/parser.rb +1236 -0
  22. data/lib/cataract/pure/serializer.rb +590 -0
  23. data/lib/cataract/pure/specificity.rb +206 -0
  24. data/lib/cataract/pure.rb +130 -0
  25. data/lib/cataract/rule.rb +22 -13
  26. data/lib/cataract/stylesheet.rb +14 -9
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +18 -5
  29. metadata +12 -25
  30. data/benchmarks/benchmark_harness.rb +0 -193
  31. data/benchmarks/benchmark_merging.rb +0 -121
  32. data/benchmarks/benchmark_optimization_comparison.rb +0 -168
  33. data/benchmarks/benchmark_parsing.rb +0 -153
  34. data/benchmarks/benchmark_ragel_removal.rb +0 -56
  35. data/benchmarks/benchmark_runner.rb +0 -70
  36. data/benchmarks/benchmark_serialization.rb +0 -180
  37. data/benchmarks/benchmark_shorthand.rb +0 -109
  38. data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
  39. data/benchmarks/benchmark_specificity.rb +0 -124
  40. data/benchmarks/benchmark_string_allocation.rb +0 -151
  41. data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
  42. data/benchmarks/benchmark_to_s_cached.rb +0 -55
  43. data/benchmarks/benchmark_value_splitter.rb +0 -54
  44. data/benchmarks/benchmark_yjit.rb +0 -158
  45. data/benchmarks/benchmark_yjit_workers.rb +0 -61
  46. data/benchmarks/profile_to_s.rb +0 -23
  47. data/benchmarks/speedup_calculator.rb +0 -83
  48. data/benchmarks/system_metadata.rb +0 -81
  49. data/benchmarks/templates/benchmarks.md.erb +0 -221
  50. data/benchmarks/yjit_tests.rb +0 -141
  51. data/scripts/fuzzer/run.rb +0 -828
  52. data/scripts/fuzzer/worker.rb +0 -99
  53. data/scripts/generate_benchmarks_md.rb +0 -155
@@ -1,151 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Benchmark string allocation optimization impact
5
- # This compares parsing performance with rb_str_buf_new vs rb_str_new_cstr
6
- #
7
- # Usage:
8
- # 1. Run without optimization:
9
- # rake compile && ruby test/benchmarks/benchmark_string_allocation.rb
10
- #
11
- # 2. Recompile with optimization:
12
- # CFLAGS="-DUSE_STR_BUF_OPTIMIZATION" rake compile
13
- #
14
- # 3. Run with optimization:
15
- # ruby test/benchmarks/benchmark_string_allocation.rb
16
- #
17
- # The benchmark will automatically detect which version is running and save
18
- # results to a JSON file for comparison.
19
-
20
- require 'benchmark/ips'
21
- require 'fileutils'
22
-
23
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
24
- require 'cataract'
25
-
26
- # State files for benchmark-ips to compare across runs
27
- # Store in hidden directory to keep them out of the way
28
- # Use separate files for each test so we only compare like-to-like
29
- RESULTS_DIR = File.expand_path('.benchmark_results', __dir__)
30
- FileUtils.mkdir_p(RESULTS_DIR)
31
-
32
- RESULTS_FILE_PARSE = File.join(RESULTS_DIR, 'string_allocation_parse.json')
33
- RESULTS_FILE_ITERATE = File.join(RESULTS_DIR, 'string_allocation_iterate.json')
34
- RESULTS_FILE_10X = File.join(RESULTS_DIR, 'string_allocation_10x.json')
35
-
36
- # Large CSS fixture - using Bootstrap 5 CSS for realistic benchmark
37
- LARGE_CSS_FIXTURE = File.read(File.expand_path('../test/fixtures/bootstrap.css', __dir__))
38
-
39
- # Detect which version we're running by checking the compile-time constant
40
- actual_mode = Cataract::STRING_ALLOC_MODE
41
- # Label based on what's actually running (buffer is the default/production mode)
42
- mode_label = actual_mode == :buffer ? 'buffer' : 'dynamic'
43
-
44
- puts '=' * 80
45
- puts 'String Allocation Optimization Benchmark'
46
- puts '=' * 80
47
- puts "Ruby version: #{RUBY_VERSION}"
48
- puts "String allocation mode: #{actual_mode.inspect}"
49
- if actual_mode == :buffer
50
- puts ' → Using rb_str_buf_new (pre-allocated buffers, production default)'
51
- else
52
- puts ' → Using rb_str_new_cstr (dynamic allocation, disabled for comparison)'
53
- end
54
- puts '=' * 80
55
- puts
56
- puts 'This benchmark focuses on at-rules that build selector strings:'
57
- puts ' - @font-face (large descriptor blocks)'
58
- puts ' - @property (selector with prelude)'
59
- puts ' - @keyframes (selector building)'
60
- puts ' - @page (selector with pseudo)'
61
- puts ' - @counter-style (selector with name)'
62
- puts
63
- puts "CSS fixture: #{LARGE_CSS_FIXTURE.lines.count} lines, #{LARGE_CSS_FIXTURE.bytesize} bytes"
64
- puts '=' * 80
65
- puts
66
-
67
- parser = Cataract::Stylesheet.new
68
- parser.add_block(LARGE_CSS_FIXTURE)
69
- GC.start
70
- # Verify we actually parsed everything
71
- raise 'Parse failed' if parser.rules_count.zero?
72
-
73
- puts "\n#{'=' * 80}"
74
- puts 'TEST 1: Parse CSS with many at-rules'
75
- puts '=' * 80
76
-
77
- Benchmark.ips do |x|
78
- x.config(time: 10, warmup: 2)
79
-
80
- x.report(mode_label) do
81
- parser = Cataract::Stylesheet.new
82
- parser.add_block(LARGE_CSS_FIXTURE)
83
- end
84
-
85
- x.save! RESULTS_FILE_PARSE
86
- x.compare!
87
- end
88
-
89
- GC.start
90
-
91
- puts "\n#{'=' * 80}"
92
- puts 'TEST 2: Parse + iterate through all rules'
93
- puts '=' * 80
94
-
95
- Benchmark.ips do |x|
96
- x.config(time: 10, warmup: 2)
97
-
98
- x.report(mode_label) do
99
- parser = Cataract::Stylesheet.new
100
- parser.add_block(LARGE_CSS_FIXTURE)
101
-
102
- count = 0
103
- parser.select(&:selector?).each do |rule|
104
- # Force string to be used
105
- _ = rule.selector.length
106
- _ = rule.declarations.to_s
107
- count += 1
108
- end
109
-
110
- raise 'No rules found' if count.zero?
111
- end
112
-
113
- x.save! RESULTS_FILE_ITERATE
114
- x.compare!
115
- end
116
-
117
- GC.start
118
-
119
- puts "\n#{'=' * 80}"
120
- puts 'TEST 3: Multiple parse operations (10x)'
121
- puts '=' * 80
122
-
123
- Benchmark.ips do |x|
124
- x.config(time: 10, warmup: 2)
125
-
126
- x.report(mode_label) do
127
- 10.times do
128
- parser = Cataract::Stylesheet.new
129
- parser.add_block(LARGE_CSS_FIXTURE)
130
- end
131
- end
132
-
133
- x.save! RESULTS_FILE_10X
134
- x.compare!
135
- end
136
-
137
- puts "\n#{'=' * 80}"
138
- puts 'Results saved to:'
139
- puts " - #{RESULTS_FILE_PARSE}"
140
- puts " - #{RESULTS_FILE_ITERATE}"
141
- puts " - #{RESULTS_FILE_10X}"
142
- puts ''
143
- puts 'To compare dynamic vs buffer (default):'
144
- puts ' 1. Run with dynamic: DISABLE_STR_BUF_OPTIMIZATION=1 rake compile && ruby test/benchmarks/benchmark_string_allocation.rb'
145
- puts ' 2. Run with buffer: rake compile && ruby test/benchmarks/benchmark_string_allocation.rb'
146
- puts ' 3. Each test will automatically compare buffer vs dynamic'
147
- puts ''
148
- puts 'The benchmark verifies the compilation mode via Cataract::STRING_ALLOC_MODE'
149
- puts ' :dynamic = rb_str_new_cstr (dynamic allocation)'
150
- puts ' :buffer = rb_str_buf_new (pre-allocated, production default)'
151
- puts '=' * 80
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'benchmark/ips'
4
-
5
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
- require 'cataract'
7
-
8
- puts '=' * 60
9
- puts 'Stylesheet#to_s: Ruby vs C Implementation'
10
- puts '=' * 60
11
- puts ''
12
-
13
- bootstrap_css = File.read('test/fixtures/bootstrap.css')
14
- stylesheet = Cataract.parse_css(bootstrap_css)
15
-
16
- puts "Parsing bootstrap.css: #{stylesheet.size} rules"
17
- puts ''
18
-
19
- # Verify both versions produce same output
20
- ruby_output = stylesheet.to_s
21
- c_output = Cataract._stylesheet_to_s_c(stylesheet.rules, stylesheet.charset)
22
-
23
- if ruby_output == c_output
24
- puts '✓ Ruby and C versions produce identical output'
25
- else
26
- puts '✗ WARNING: Ruby and C versions produce different output!'
27
- puts "Ruby length: #{ruby_output.length}"
28
- puts "C length: #{c_output.length}"
29
-
30
- # Find first difference
31
- ruby_output.chars.each_with_index do |char, i|
32
- next unless char != c_output[i]
33
-
34
- puts "First difference at position #{i}:"
35
- puts " Ruby: #{ruby_output[(i - 20)..(i + 20)].inspect}"
36
- puts " C: #{c_output[(i - 20)..(i + 20)].inspect}"
37
- break
38
- end
39
- end
40
-
41
- puts ''
42
- puts 'Benchmarking to_s only (cache cleared each iteration)...'
43
- puts ''
44
-
45
- # Parse once outside benchmark
46
- PARSER = Cataract.parse_css(bootstrap_css)
47
-
48
- Benchmark.ips do |x|
49
- x.config(time: 10, warmup: 3)
50
-
51
- x.report('Ruby (serialize_to_css)') do
52
- PARSER.instance_variable_set(:@serialized, nil) # Clear cache
53
- PARSER.to_s
54
- end
55
-
56
- x.report('C (_stylesheet_to_s_c)') do
57
- PARSER.instance_variable_set(:@serialized, nil) # Clear cache (apples-to-apples)
58
- Cataract._stylesheet_to_s_c(PARSER.rules, PARSER.charset)
59
- end
60
-
61
- x.compare!
62
- end
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'benchmark/ips'
4
-
5
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
- require 'cataract'
7
-
8
- puts '=' * 60
9
- puts 'CACHED to_s BENCHMARK'
10
- puts '=' * 60
11
- puts ''
12
-
13
- bootstrap_css = File.read('test/fixtures/bootstrap.css')
14
-
15
- puts 'Comparing:'
16
- puts ' - Fresh parse → to_s (first call, uncached)'
17
- puts ' - Repeated to_s (cached, should be instant)'
18
- puts ''
19
-
20
- Benchmark.ips do |x|
21
- x.config(time: 10, warmup: 3)
22
-
23
- x.report('parse + to_s (uncached)') do
24
- stylesheet = Cataract.parse_css(bootstrap_css)
25
- stylesheet.to_s
26
- end
27
-
28
- x.report('to_s (cached)') do
29
- # Parse once outside the benchmark
30
- stylesheet = Cataract.parse_css(bootstrap_css)
31
- stylesheet.to_s # Prime cache
32
-
33
- # Now benchmark cached access
34
- stylesheet.to_s
35
- end
36
-
37
- x.compare!
38
- end
39
-
40
- puts ''
41
- puts '=' * 60
42
- puts 'Real-world scenario: Multiple to_s calls'
43
- puts '=' * 60
44
- puts ''
45
-
46
- stylesheet = Cataract.parse_css(bootstrap_css)
47
-
48
- puts 'Calling to_s 10 times...'
49
- require 'benchmark'
50
- time = Benchmark.measure do
51
- 10.times { stylesheet.to_s }
52
- end
53
-
54
- puts "Total time: #{time.real.round(4)}s"
55
- puts 'First call does all work, next 9 are free!'
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'benchmark/ips'
4
-
5
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
- require 'cataract'
7
-
8
- # Get current git branch
9
- branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
10
- branch = 'unknown' if branch.empty?
11
-
12
- puts '=' * 60
13
- puts "Value Splitter Benchmark: #{branch}"
14
- puts '=' * 60
15
- puts ''
16
-
17
- # Test cases covering different scenarios
18
- test_cases = {
19
- 'simple' => '1px 2px 3px 4px',
20
- 'functions' => '10px calc(100% - 20px) 5px',
21
- 'rgb' => 'rgb(255, 0, 0) blue rgba(0, 0, 0, 0.5)',
22
- 'quotes' => "'Helvetica Neue', Arial, sans-serif",
23
- 'complex' => "10px calc(100% - 20px) 'Font Name' rgb(255, 0, 0)",
24
- 'long' => '1px 2px 3px 4px 5px 6px 7px 8px 9px 10px 11px 12px 13px 14px 15px'
25
- }
26
-
27
- puts 'Test cases:'
28
- test_cases.each do |name, value|
29
- result = Cataract.split_value(value)
30
- puts " #{name}: '#{value}' => #{result.length} tokens"
31
- end
32
- puts ''
33
-
34
- Benchmark.ips do |x|
35
- x.config(time: 10, warmup: 3)
36
-
37
- test_cases.each do |name, value|
38
- x.report("#{branch}:#{name.ljust(12)}") do
39
- Cataract.split_value(value)
40
- end
41
- end
42
-
43
- x.compare!
44
-
45
- # Save results to file for cross-branch comparison
46
- x.save! 'test/.benchmark_results/value_splitter.json'
47
- x.hold! 'test/.benchmark_results/value_splitter.json'
48
- end
49
-
50
- puts ''
51
- puts '=' * 60
52
- puts 'Results saved to test/.benchmark_results/value_splitter.json'
53
- puts 'Switch git branches and run again to compare!'
54
- puts '=' * 60
@@ -1,158 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'benchmark_harness'
4
- require 'open3'
5
- require 'json'
6
-
7
- # Load the local development version, not installed gem
8
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
9
- require 'cataract'
10
-
11
- # YJIT Benchmark Supervisor
12
- # Spawns two subprocesses (with/without YJIT) and combines results
13
- class YjitBenchmark < BenchmarkHarness
14
- def self.benchmark_name
15
- 'yjit'
16
- end
17
-
18
- def self.description
19
- 'Ruby-side operations with and without YJIT'
20
- end
21
-
22
- def self.metadata
23
- {
24
- 'operations' => [
25
- 'property access',
26
- 'declaration merging',
27
- 'to_s generation',
28
- 'parse + iterate'
29
- ],
30
- 'note' => 'C extension performance is the same regardless of YJIT. This measures Ruby code.'
31
- }
32
- end
33
-
34
- def self.speedup_config
35
- # Compare without YJIT (baseline) vs with YJIT (comparison)
36
- {
37
- baseline_matcher: SpeedupCalculator::Matchers.without_yjit,
38
- comparison_matcher: SpeedupCalculator::Matchers.with_yjit,
39
- test_case_key: nil # No test_cases array, just operations
40
- }
41
- end
42
-
43
- def sanity_checks
44
- # Verify basic operations work
45
- decls = Cataract::Declarations.new
46
- decls['color'] = 'red'
47
- raise 'Property access failed' unless decls['color']
48
-
49
- parser = Cataract::Stylesheet.new
50
- sample_css = 'body { margin: 0; }'
51
- parser.add_block(sample_css)
52
- raise 'Parse failed' if parser.rules_count.zero?
53
- end
54
-
55
- def call
56
- worker_script = File.expand_path('benchmark_yjit_workers.rb', __dir__)
57
-
58
- # Clean up any leftover worker files from previous runs
59
- without_path = File.join(RESULTS_DIR, 'yjit_without.json')
60
- with_path = File.join(RESULTS_DIR, 'yjit_with.json')
61
- FileUtils.rm_f(without_path)
62
- FileUtils.rm_f(with_path)
63
-
64
- puts 'Running YJIT benchmarks via subprocesses...'
65
- puts
66
-
67
- # Run without YJIT
68
- puts '→ Running without YJIT (--disable-yjit)...'
69
- stdout_without, status_without = run_subprocess(['ruby', '--disable-yjit', worker_script])
70
- unless status_without.success?
71
- raise "Worker without YJIT failed:\n#{stdout_without}"
72
- end
73
-
74
- puts stdout_without
75
- puts
76
-
77
- # Run with YJIT
78
- puts '→ Running with YJIT (--yjit)...'
79
- stdout_with, status_with = run_subprocess(['ruby', '--yjit', worker_script])
80
- unless status_with.success?
81
- raise "Worker with YJIT failed:\n#{stdout_with}"
82
- end
83
-
84
- puts stdout_with
85
- puts
86
-
87
- # Combine results
88
- combine_worker_results
89
- end
90
-
91
- private
92
-
93
- def run_subprocess(command)
94
- stdout, stderr, status = Open3.capture3(*command)
95
-
96
- # Print stderr if present (warnings, etc)
97
- unless stderr.empty?
98
- puts "⚠️ stderr: #{stderr}"
99
- end
100
-
101
- [stdout, status]
102
- end
103
-
104
- def combine_worker_results
105
- without_path = File.join(RESULTS_DIR, 'yjit_without.json')
106
- with_path = File.join(RESULTS_DIR, 'yjit_with.json')
107
-
108
- # Check both files exist
109
- unless File.exist?(without_path) && File.exist?(with_path)
110
- raise "Worker results not found:\n #{without_path}\n #{with_path}"
111
- end
112
-
113
- # Read both JSON files
114
- without_data = JSON.parse(File.read(without_path))
115
- with_data = JSON.parse(File.read(with_path))
116
-
117
- # Combine into single benchmark result
118
- combined_data = {
119
- 'name' => self.class.benchmark_name,
120
- 'description' => self.class.description,
121
- 'metadata' => self.class.metadata,
122
- 'timestamp' => Time.now.iso8601,
123
- 'results' => []
124
- }
125
-
126
- # Merge results from both workers
127
- combined_data['results'].concat(without_data['results']) if without_data['results']
128
- combined_data['results'].concat(with_data['results']) if with_data['results']
129
-
130
- # Calculate speedups using configured strategy
131
- config = self.class.speedup_config
132
- if config
133
- calculator = SpeedupCalculator.new(
134
- results: combined_data['results'],
135
- test_cases: combined_data['metadata']['operations'],
136
- baseline_matcher: config[:baseline_matcher],
137
- comparison_matcher: config[:comparison_matcher],
138
- test_case_key: config[:test_case_key]
139
- )
140
-
141
- speedup_stats = calculator.calculate
142
- combined_data['metadata']['speedups'] = speedup_stats if speedup_stats
143
- end
144
-
145
- # Write combined file
146
- combined_path = File.join(RESULTS_DIR, "#{self.class.benchmark_name}.json")
147
- File.write(combined_path, JSON.pretty_generate(combined_data))
148
-
149
- # Clean up worker files
150
- File.delete(without_path)
151
- File.delete(with_path)
152
-
153
- puts "✓ Combined results saved to #{combined_path}"
154
- end
155
- end
156
-
157
- # Run if executed directly
158
- YjitBenchmark.run if __FILE__ == $PROGRAM_NAME
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'benchmark_harness'
4
- require_relative 'yjit_tests'
5
-
6
- # Load the local development version, not installed gem
7
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
8
- require 'cataract'
9
-
10
- # Worker benchmark: YJIT disabled
11
- # Called via subprocess with --disable-yjit flag
12
- class YjitWithoutBenchmark < BenchmarkHarness
13
- include YjitTests
14
-
15
- def self.benchmark_name
16
- 'yjit_without'
17
- end
18
-
19
- def self.description
20
- 'Ruby-side operations without YJIT'
21
- end
22
-
23
- def self.metadata
24
- YjitTests.metadata
25
- end
26
-
27
- def self.speedup_config
28
- YjitTests.speedup_config
29
- end
30
- end
31
-
32
- # Worker benchmark: YJIT enabled
33
- # Called via subprocess with --yjit flag
34
- class YjitWithBenchmark < BenchmarkHarness
35
- include YjitTests
36
-
37
- def self.benchmark_name
38
- 'yjit_with'
39
- end
40
-
41
- def self.description
42
- 'Ruby-side operations with YJIT'
43
- end
44
-
45
- def self.metadata
46
- YjitTests.metadata
47
- end
48
-
49
- def self.speedup_config
50
- YjitTests.speedup_config
51
- end
52
- end
53
-
54
- # CLI entry point - run the appropriate worker based on YJIT status
55
- if __FILE__ == $PROGRAM_NAME
56
- if defined?(RubyVM::YJIT.enabled?) && RubyVM::YJIT.enabled?
57
- YjitWithBenchmark.run
58
- else
59
- YjitWithoutBenchmark.run
60
- end
61
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'ruby-prof'
4
-
5
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
- require 'cataract'
7
-
8
- bootstrap_css = File.read('test/fixtures/bootstrap.css')
9
- stylesheet = Cataract.parse_css(bootstrap_css)
10
-
11
- puts "Profiling Stylesheet#to_s on bootstrap.css (#{stylesheet.size} rules)"
12
- puts ''
13
-
14
- # Profile
15
- RubyProf.start
16
-
17
- 10.times { stylesheet.to_s }
18
-
19
- result = RubyProf.stop
20
-
21
- # Print flat profile
22
- printer = RubyProf::FlatPrinter.new(result)
23
- printer.print($stdout, min_percent: 1)
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Generic speedup calculator for benchmark results
4
- # Compares "baseline" vs "comparison" results and calculates speedup stats
5
- #
6
- # CONVENTION: Result names must use format "tool_name: test_case_id"
7
- # Example: "css_parser: CSS1", "cataract: CSS1"
8
- class SpeedupCalculator
9
- # @param results [Array<Hash>] Array of benchmark results with 'name' and 'central_tendency'
10
- # @param test_cases [Array<Hash>] Array of test case metadata to annotate with speedups
11
- # @param baseline_matcher [Proc] Block that returns true if result is baseline (e.g., css_parser)
12
- # @param comparison_matcher [Proc] Block that returns true if result is comparison (e.g., cataract)
13
- # @param test_case_key [Symbol] Key in test_cases hash to match against test case id from result name
14
- def initialize(results:, test_cases:, baseline_matcher:, comparison_matcher:, test_case_key: nil)
15
- @results = results
16
- @test_cases = test_cases
17
- @baseline_matcher = baseline_matcher
18
- @comparison_matcher = comparison_matcher
19
- @test_case_key = test_case_key
20
- end
21
-
22
- # Calculate speedups and return stats hash
23
- # Also annotates test_cases with individual speedups if test_case_key provided
24
- # @return [Hash] { min: Float, max: Float, avg: Float } or nil if no pairs found
25
- def calculate
26
- speedups = []
27
-
28
- # Group results by test case (everything after ':')
29
- grouped = @results.group_by { |result| extract_test_case(result['name']) }
30
-
31
- grouped.each do |test_case_id, group_results|
32
- baseline = group_results.find(&@baseline_matcher)
33
- comparison = group_results.find(&@comparison_matcher)
34
-
35
- next unless baseline && comparison
36
-
37
- speedup = comparison['central_tendency'].to_f / baseline['central_tendency']
38
- speedups << speedup
39
-
40
- # Annotate test case metadata if provided
41
- if @test_case_key && @test_cases
42
- test_case = @test_cases.find { |tc| tc[@test_case_key.to_s] == test_case_id }
43
- test_case['speedup'] = speedup.round(2) if test_case
44
- end
45
- end
46
-
47
- return nil if speedups.empty?
48
-
49
- {
50
- 'min' => speedups.min.round(2),
51
- 'max' => speedups.max.round(2),
52
- 'avg' => (speedups.sum / speedups.size).round(2)
53
- }
54
- end
55
-
56
- private
57
-
58
- # Extract test case id from result name
59
- # "css_parser: CSS1" -> "CSS1"
60
- # "cataract gem: all" -> "all"
61
- def extract_test_case(name)
62
- name.split(':').last.strip
63
- end
64
-
65
- # Common matchers
66
- class Matchers
67
- def self.css_parser
68
- ->(result) { result['name'].include?('css_parser') }
69
- end
70
-
71
- def self.cataract
72
- ->(result) { result['name'].include?('cataract') }
73
- end
74
-
75
- def self.with_yjit
76
- ->(result) { result['name'].include?('YJIT') && !result['name'].include?('no YJIT') }
77
- end
78
-
79
- def self.without_yjit
80
- ->(result) { result['name'].include?('no YJIT') }
81
- end
82
- end
83
- end