cataract 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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/.clang-tidy +30 -0
  3. data/.github/workflows/ci-macos.yml +12 -0
  4. data/.github/workflows/ci.yml +77 -0
  5. data/.github/workflows/test.yml +76 -0
  6. data/.gitignore +45 -0
  7. data/.overcommit.yml +38 -0
  8. data/.rubocop.yml +83 -0
  9. data/BENCHMARKS.md +201 -0
  10. data/CHANGELOG.md +1 -0
  11. data/Gemfile +27 -0
  12. data/LICENSE +21 -0
  13. data/RAGEL_MIGRATION.md +60 -0
  14. data/README.md +292 -0
  15. data/Rakefile +209 -0
  16. data/benchmarks/benchmark_harness.rb +193 -0
  17. data/benchmarks/benchmark_merging.rb +121 -0
  18. data/benchmarks/benchmark_optimization_comparison.rb +168 -0
  19. data/benchmarks/benchmark_parsing.rb +153 -0
  20. data/benchmarks/benchmark_ragel_removal.rb +56 -0
  21. data/benchmarks/benchmark_runner.rb +70 -0
  22. data/benchmarks/benchmark_serialization.rb +180 -0
  23. data/benchmarks/benchmark_shorthand.rb +109 -0
  24. data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
  25. data/benchmarks/benchmark_specificity.rb +124 -0
  26. data/benchmarks/benchmark_string_allocation.rb +151 -0
  27. data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
  28. data/benchmarks/benchmark_to_s_cached.rb +55 -0
  29. data/benchmarks/benchmark_value_splitter.rb +54 -0
  30. data/benchmarks/benchmark_yjit.rb +158 -0
  31. data/benchmarks/benchmark_yjit_workers.rb +61 -0
  32. data/benchmarks/profile_to_s.rb +23 -0
  33. data/benchmarks/speedup_calculator.rb +83 -0
  34. data/benchmarks/system_metadata.rb +81 -0
  35. data/benchmarks/templates/benchmarks.md.erb +221 -0
  36. data/benchmarks/yjit_tests.rb +141 -0
  37. data/cataract.gemspec +34 -0
  38. data/cliff.toml +92 -0
  39. data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
  40. data/examples/color_conversion_visual_test/generate.rb +202 -0
  41. data/examples/color_conversion_visual_test/template.html.erb +259 -0
  42. data/examples/css_analyzer/analyzer.rb +164 -0
  43. data/examples/css_analyzer/analyzers/base.rb +33 -0
  44. data/examples/css_analyzer/analyzers/colors.rb +133 -0
  45. data/examples/css_analyzer/analyzers/important.rb +88 -0
  46. data/examples/css_analyzer/analyzers/properties.rb +61 -0
  47. data/examples/css_analyzer/analyzers/specificity.rb +68 -0
  48. data/examples/css_analyzer/templates/report.html.erb +575 -0
  49. data/examples/css_analyzer.rb +69 -0
  50. data/examples/github_analysis.html +5343 -0
  51. data/ext/cataract/cataract.c +1086 -0
  52. data/ext/cataract/cataract.h +174 -0
  53. data/ext/cataract/css_parser.c +1435 -0
  54. data/ext/cataract/extconf.rb +48 -0
  55. data/ext/cataract/import_scanner.c +174 -0
  56. data/ext/cataract/merge.c +973 -0
  57. data/ext/cataract/shorthand_expander.c +902 -0
  58. data/ext/cataract/specificity.c +213 -0
  59. data/ext/cataract/value_splitter.c +116 -0
  60. data/ext/cataract_color/cataract_color.c +16 -0
  61. data/ext/cataract_color/color_conversion.c +1687 -0
  62. data/ext/cataract_color/color_conversion.h +136 -0
  63. data/ext/cataract_color/color_conversion_lab.c +571 -0
  64. data/ext/cataract_color/color_conversion_named.c +259 -0
  65. data/ext/cataract_color/color_conversion_oklab.c +547 -0
  66. data/ext/cataract_color/extconf.rb +23 -0
  67. data/ext/cataract_old/cataract.c +393 -0
  68. data/ext/cataract_old/cataract.h +250 -0
  69. data/ext/cataract_old/css_parser.c +933 -0
  70. data/ext/cataract_old/extconf.rb +67 -0
  71. data/ext/cataract_old/import_scanner.c +174 -0
  72. data/ext/cataract_old/merge.c +776 -0
  73. data/ext/cataract_old/shorthand_expander.c +902 -0
  74. data/ext/cataract_old/specificity.c +213 -0
  75. data/ext/cataract_old/stylesheet.c +290 -0
  76. data/ext/cataract_old/value_splitter.c +116 -0
  77. data/lib/cataract/at_rule.rb +97 -0
  78. data/lib/cataract/color_conversion.rb +18 -0
  79. data/lib/cataract/declarations.rb +332 -0
  80. data/lib/cataract/import_resolver.rb +210 -0
  81. data/lib/cataract/rule.rb +131 -0
  82. data/lib/cataract/stylesheet.rb +716 -0
  83. data/lib/cataract/stylesheet_scope.rb +257 -0
  84. data/lib/cataract/version.rb +5 -0
  85. data/lib/cataract.rb +107 -0
  86. data/lib/tasks/gem.rake +158 -0
  87. data/scripts/fuzzer/run.rb +828 -0
  88. data/scripts/fuzzer/worker.rb +99 -0
  89. data/scripts/generate_benchmarks_md.rb +155 -0
  90. metadata +135 -0
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'benchmark_harness'
4
+
5
+ # Load the local development version, not installed gem
6
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
+ require 'cataract'
8
+
9
+ # CSS Merging Benchmark
10
+ class MergingBenchmark < BenchmarkHarness
11
+ def self.benchmark_name
12
+ 'merging'
13
+ end
14
+
15
+ def self.description
16
+ 'Time to merge multiple CSS rule sets with same selector'
17
+ end
18
+
19
+ def self.metadata
20
+ {
21
+ 'test_cases' => [
22
+ {
23
+ 'name' => 'No shorthand properties (large)',
24
+ 'key' => 'no_shorthand',
25
+ 'css' => (".test { color: red; background-color: blue; display: block; position: relative; width: 100px; height: 50px; }\n" * 100)
26
+ },
27
+ {
28
+ 'name' => 'Simple properties',
29
+ 'key' => 'simple',
30
+ 'css' => ".test { color: black; margin: 10px; }\n.test { padding: 5px; }"
31
+ },
32
+ {
33
+ 'name' => 'Cascade with specificity',
34
+ 'key' => 'cascade',
35
+ 'css' => ".test { color: black; }\n#test { color: red; }\n.test { margin: 10px; }"
36
+ },
37
+ {
38
+ 'name' => 'Important declarations',
39
+ 'key' => 'important',
40
+ 'css' => ".test { color: black !important; }\n#test { color: red; }\n.test { margin: 10px; }"
41
+ },
42
+ {
43
+ 'name' => 'Shorthand expansion',
44
+ 'key' => 'shorthand',
45
+ 'css' => ".test { margin: 10px 20px; }\n.test { margin-left: 5px; }\n.test { padding: 1em 2em 3em 4em; }"
46
+ },
47
+ {
48
+ 'name' => 'Complex merging',
49
+ 'key' => 'complex',
50
+ 'css' => "body { margin: 0; padding: 0; }\n.container { width: 100%; margin: 0 auto; }\n#main { background: white; color: black; }\n.button { padding: 10px 20px; border: 1px solid #ccc; }\n.button:hover { background: #f0f0f0; }\n.button.primary { background: blue !important; color: white; }"
51
+ }
52
+ ]
53
+ }
54
+ end
55
+
56
+ def self.speedup_config
57
+ {
58
+ baseline_matcher: SpeedupCalculator::Matchers.css_parser,
59
+ comparison_matcher: SpeedupCalculator::Matchers.cataract,
60
+ test_case_key: :key
61
+ }
62
+ end
63
+
64
+ def sanity_checks
65
+ require 'css_parser'
66
+
67
+ # Verify merging works correctly
68
+ css = ".test { color: black; }\n.test { margin: 10px; }"
69
+ cataract_rules = Cataract.parse_css(css)
70
+ cataract_merged = Cataract.merge(cataract_rules)
71
+
72
+ raise 'Cataract merge failed' if cataract_merged.rules.empty?
73
+
74
+ merged_decls = cataract_merged.rules.first.declarations
75
+ raise 'Cataract merge incorrect' unless merged_decls.any? { |d| d.property == 'color' }
76
+ end
77
+
78
+ def call
79
+ self.class.metadata['test_cases'].each do |test_case|
80
+ benchmark_test_case(test_case)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def benchmark_test_case(test_case)
87
+ puts '=' * 80
88
+ puts "TEST: #{test_case['name']}"
89
+ puts '=' * 80
90
+
91
+ key = test_case['key']
92
+ css = test_case['css']
93
+
94
+ # Pre-parse the CSS for both implementations
95
+ cataract_rules = Cataract.parse_css(css)
96
+
97
+ parser = CssParser::Parser.new
98
+ parser.add_block!(css)
99
+ rule_sets = []
100
+ parser.each_selector do |selector, declarations, _specificity|
101
+ rule_sets << CssParser::RuleSet.new(selectors: selector, block: declarations)
102
+ end
103
+
104
+ benchmark(key) do |x|
105
+ x.config(time: 5, warmup: 2)
106
+
107
+ x.report("css_parser: #{key}") do
108
+ CssParser.merge(rule_sets)
109
+ end
110
+
111
+ x.report("cataract: #{key}") do
112
+ Cataract.merge(cataract_rules)
113
+ end
114
+
115
+ x.compare!
116
+ end
117
+ end
118
+ end
119
+
120
+ # Run if executed directly
121
+ MergingBenchmark.run if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'benchmark/ips'
5
+ require 'optparse'
6
+
7
+ # Load the local development version, not installed gem
8
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
9
+ require 'cataract'
10
+
11
+ # =============================================================================
12
+ # Generic Optimization Comparison Benchmark
13
+ # =============================================================================
14
+ #
15
+ # This benchmark compares performance between different compile-time
16
+ # optimizations using benchmark-ips's hold! functionality.
17
+ #
18
+ # Usage:
19
+ # 1. Compile with baseline configuration:
20
+ # $ rake clean compile
21
+ # $ ruby test/benchmarks/benchmark_optimization_comparison.rb --baseline
22
+ #
23
+ # 2. In same terminal, compile with optimization:
24
+ # $ rake clean && env USE_LIKELY_UNLIKELY=1 rake compile
25
+ # $ ruby test/benchmarks/benchmark_optimization_comparison.rb --optimized
26
+ #
27
+ # 3. benchmark-ips will show comparison results from both runs
28
+ #
29
+ # The benchmark tests merge/cascade performance on bootstrap.css (~10k rules).
30
+ # =============================================================================
31
+
32
+ module OptimizationBenchmark
33
+ def self.run(variant: nil)
34
+ puts '=' * 80
35
+ puts 'OPTIMIZATION COMPARISON BENCHMARK'
36
+ puts '=' * 80
37
+
38
+ # Display current compile-time flags
39
+ puts "\nCurrently compiled with:"
40
+ Cataract::COMPILE_FLAGS.each do |flag, enabled|
41
+ status = enabled ? '✓ ENABLED' : '✗ disabled'
42
+ puts " #{flag}: #{status}"
43
+ end
44
+ puts ''
45
+
46
+ # Load bootstrap.css fixture
47
+ fixtures_dir = File.expand_path('../test/fixtures', __dir__)
48
+ bootstrap_css_path = File.join(fixtures_dir, 'bootstrap.css')
49
+
50
+ unless File.exist?(bootstrap_css_path)
51
+ puts "❌ ERROR: bootstrap.css not found at #{bootstrap_css_path}"
52
+ exit 1
53
+ end
54
+
55
+ bootstrap_css = File.read(bootstrap_css_path)
56
+ puts 'Test file: bootstrap.css'
57
+ puts " Lines: #{bootstrap_css.lines.count}"
58
+ puts " Size: #{bootstrap_css.bytesize} bytes (#{(bootstrap_css.bytesize / 1024.0).round(1)} KB)"
59
+
60
+ # Parse once to get rules for merge benchmark
61
+ puts "\nParsing bootstrap.css to get rules..."
62
+ parser = Cataract::Stylesheet.new
63
+ begin
64
+ parser.add_block(bootstrap_css)
65
+ rules = parser.instance_variable_get(:@raw_rules) # Get raw rules array
66
+ puts " ✅ Parsed successfully (#{rules.length} rules)"
67
+ rescue StandardError => e
68
+ puts " ❌ ERROR: Failed to parse: #{e.message}"
69
+ exit 1
70
+ end
71
+
72
+ # Verify merge works before benchmarking
73
+ puts "\nVerifying merge..."
74
+ begin
75
+ merged = Cataract.apply_cascade(rules)
76
+ puts " ✅ Merged successfully (#{merged.length} declarations)"
77
+ rescue StandardError => e
78
+ puts " ❌ ERROR: Failed to merge: #{e.message}"
79
+ exit 1
80
+ end
81
+
82
+ # Auto-detect variant if not specified
83
+ if variant.nil?
84
+ # Check any optimization flag
85
+ has_optimization = Cataract::COMPILE_FLAGS.any? do |flag, enabled|
86
+ enabled && flag != :str_buf_optimization && flag != :debug
87
+ end
88
+ variant = has_optimization ? 'optimized' : 'baseline'
89
+ end
90
+
91
+ puts "\n#{'=' * 80}"
92
+ puts "RUNNING BENCHMARK (variant: #{variant})"
93
+ puts '=' * 80
94
+ puts 'Timing: 20s measurement, 5s warmup'
95
+ puts ''
96
+
97
+ Benchmark.ips do |x|
98
+ x.config(time: 20, warmup: 5)
99
+
100
+ x.report("merge_bootstrap_#{variant}") do
101
+ Cataract.apply_cascade(rules)
102
+ end
103
+ end
104
+
105
+ puts "\n#{'=' * 80}"
106
+ puts "DONE - #{variant}"
107
+ puts '=' * 80
108
+ end
109
+
110
+ def self.print_usage
111
+ puts <<~USAGE
112
+ Usage: #{$PROGRAM_NAME}
113
+
114
+ This benchmark will automatically detect which variant is compiled
115
+ and run the appropriate test.
116
+
117
+ Workflow:
118
+ # 1. Build baseline
119
+ rake clean && rake compile
120
+ ruby test/benchmarks/benchmark_optimization_comparison.rb --baseline
121
+
122
+ # 2. Build with optimization (e.g., LIKELY/UNLIKELY)
123
+ rake clean && USE_LIKELY_UNLIKELY=1 rake compile
124
+ ruby test/benchmarks/benchmark_optimization_comparison.rb --optimized
125
+
126
+ # 3. Compare results (benchmark-ips will show comparison automatically)
127
+
128
+ Specific optimization flags:
129
+ LIKELY/UNLIKELY:
130
+ USE_LIKELY_UNLIKELY=1 rake compile
131
+
132
+ Loop unrolling (raw CFLAGS still work):
133
+ CFLAGS="-funroll-loops" rake compile
134
+
135
+ Aggressive optimization:
136
+ CFLAGS="-O3 -march=native -funroll-loops" rake compile
137
+ USAGE
138
+ end
139
+ end
140
+
141
+ # =============================================================================
142
+ # Main
143
+ # =============================================================================
144
+
145
+ if __FILE__ == $PROGRAM_NAME
146
+ require 'optparse'
147
+
148
+ variant = nil
149
+
150
+ OptionParser.new do |opts|
151
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
152
+
153
+ opts.on('--baseline', 'Run benchmark labeled as baseline') do
154
+ variant = 'baseline'
155
+ end
156
+
157
+ opts.on('--optimized', 'Run benchmark labeled as optimized') do
158
+ variant = 'optimized'
159
+ end
160
+
161
+ opts.on('-h', '--help', 'Show this help message') do
162
+ OptimizationBenchmark.print_usage
163
+ exit 0
164
+ end
165
+ end.parse!
166
+
167
+ OptimizationBenchmark.run(variant: variant)
168
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'benchmark_harness'
4
+
5
+ # Load the local development version, not installed gem
6
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
+ require 'cataract'
8
+
9
+ # CSS Parsing Performance Benchmark
10
+ class ParsingBenchmark < BenchmarkHarness
11
+ def self.benchmark_name
12
+ 'parsing'
13
+ end
14
+
15
+ def self.description
16
+ 'Time to parse CSS into internal data structures'
17
+ end
18
+
19
+ def self.metadata
20
+ instance = new
21
+ {
22
+ 'test_cases' => [
23
+ {
24
+ 'name' => "Small CSS (#{instance.css1.lines.count} lines, #{(instance.css1.length / 1024.0).round(1)}KB)",
25
+ 'fixture' => 'CSS1',
26
+ 'lines' => instance.css1.lines.count,
27
+ 'bytes' => instance.css1.length
28
+ },
29
+ {
30
+ 'name' => "Medium CSS with @media (#{instance.css2.lines.count} lines, #{(instance.css2.length / 1024.0).round(1)}KB)",
31
+ 'fixture' => 'CSS2',
32
+ 'lines' => instance.css2.lines.count,
33
+ 'bytes' => instance.css2.length
34
+ }
35
+ ]
36
+ # speedups will be calculated automatically by harness
37
+ }
38
+ end
39
+
40
+ # Uses default speedup_config from harness (css_parser vs cataract, test_case_key: :fixture)
41
+
42
+ def sanity_checks
43
+ # Check css_parser gem is available
44
+ require 'css_parser'
45
+
46
+ # Verify fixtures parse correctly
47
+ parser = Cataract::Stylesheet.new
48
+ parser.add_block(css1)
49
+ raise 'CSS1 sanity check failed: expected rules' if parser.rules_count.zero?
50
+
51
+ parser = Cataract::Stylesheet.new
52
+ parser.add_block(css2)
53
+ raise 'CSS2 sanity check failed: expected rules' if parser.rules_count.zero?
54
+ end
55
+
56
+ def call
57
+ run_css1_benchmark
58
+ run_css2_benchmark
59
+ show_correctness_comparison
60
+ end
61
+
62
+ def css1
63
+ @css1 ||= File.read(File.join(fixtures_dir, 'css1_sample.css'))
64
+ end
65
+
66
+ def css2
67
+ @css2 ||= File.read(File.join(fixtures_dir, 'css2_sample.css'))
68
+ end
69
+
70
+ private
71
+
72
+ def fixtures_dir
73
+ @fixtures_dir ||= File.expand_path('../test/fixtures', __dir__)
74
+ end
75
+
76
+ def run_css1_benchmark
77
+ puts '=' * 80
78
+ puts "TEST: CSS1 (#{css1.lines.count} lines, #{css1.length} chars)"
79
+ puts '=' * 80
80
+
81
+ benchmark('css1') do |x|
82
+ x.config(time: 5, warmup: 2)
83
+
84
+ x.report('css_parser gem: CSS1') do
85
+ parser = CssParser::Parser.new(import: false, io_exceptions: false)
86
+ parser.add_block!(css1)
87
+ end
88
+
89
+ x.report('cataract: CSS1') do
90
+ parser = Cataract::Stylesheet.new
91
+ parser.add_block(css1)
92
+ end
93
+
94
+ x.compare!
95
+ end
96
+ end
97
+
98
+ def run_css2_benchmark
99
+ puts "\n#{'=' * 80}"
100
+ puts "TEST: CSS2 with @media (#{css2.lines.count} lines, #{css2.length} chars)"
101
+ puts '=' * 80
102
+
103
+ benchmark('css2') do |x|
104
+ x.config(time: 5, warmup: 2)
105
+
106
+ x.report('css_parser gem: CSS2') do
107
+ parser = CssParser::Parser.new(import: false, io_exceptions: false)
108
+ parser.add_block!(css2)
109
+ end
110
+
111
+ x.report('cataract: CSS2') do
112
+ parser = Cataract::Stylesheet.new
113
+ parser.add_block(css2)
114
+ end
115
+
116
+ x.compare!
117
+ end
118
+ end
119
+
120
+ def show_correctness_comparison
121
+ puts "\n#{'=' * 80}"
122
+ puts 'CORRECTNESS VALIDATION (CSS2)'
123
+ puts '=' * 80
124
+
125
+ # Test Cataract
126
+ parser = Cataract::Stylesheet.new
127
+ parser.add_block(css2)
128
+ cataract_rules = parser.rules_count
129
+ puts "Cataract found #{cataract_rules} rules"
130
+
131
+ # Test css_parser
132
+ css_parser = CssParser::Parser.new(import: false, io_exceptions: false)
133
+ css_parser.add_block!(css2)
134
+ css_parser_rules = 0
135
+ css_parser.each_selector { css_parser_rules += 1 }
136
+ puts "css_parser found #{css_parser_rules} rules"
137
+
138
+ unless cataract_rules == css_parser_rules
139
+ puts '⚠️ Different number of rules parsed'
140
+ puts ' Note: css_parser has a known bug with ::after pseudo-elements'
141
+ puts ' (it concatenates them with previous rules instead of parsing separately)'
142
+ end
143
+
144
+ # Show sample output
145
+ puts "\nSample Cataract output:"
146
+ parser.select(&:selector?).first(5).each do |rule|
147
+ puts " #{rule.selector}: #{rule.declarations} (spec: #{rule.specificity})"
148
+ end
149
+ end
150
+ end
151
+
152
+ # Run if executed directly
153
+ ParsingBenchmark.run if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark/ips'
4
+
5
+ # Load the local development version, not installed gem
6
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
+ require 'cataract'
8
+
9
+ # Get current branch name
10
+ branch = `git rev-parse --abbrev-ref HEAD`.strip
11
+
12
+ puts '=' * 80
13
+ puts 'RAGEL REMOVAL BENCHMARK - Parsing Performance Comparison'
14
+ puts '=' * 80
15
+ puts "Current branch: #{branch}"
16
+ puts
17
+
18
+ # Load bootstrap.css fixture (large real-world CSS file)
19
+ fixtures_dir = File.expand_path('../test/fixtures', __dir__)
20
+ bootstrap_css = File.read(File.join(fixtures_dir, 'bootstrap.css'))
21
+
22
+ puts "Bootstrap CSS: #{bootstrap_css.lines.count} lines, #{bootstrap_css.bytesize} bytes"
23
+ puts
24
+
25
+ # Verify parsing works
26
+ puts 'Verifying parsing works...'
27
+ parser = Cataract::Stylesheet.new
28
+ parser.add_block(bootstrap_css)
29
+ puts " ✅ Parsed successfully (#{parser.rules_count} rules)"
30
+ puts
31
+
32
+ puts '=' * 80
33
+ puts 'BENCHMARK: Bootstrap CSS Parsing'
34
+ puts '=' * 80
35
+ puts
36
+
37
+ Benchmark.ips do |x|
38
+ x.config(time: 5, warmup: 2)
39
+
40
+ x.report("#{branch}:parse_bootstrap") do
41
+ parser = Cataract::Stylesheet.new
42
+ parser.add_block(bootstrap_css)
43
+ end
44
+
45
+ x.compare!
46
+
47
+ # Save results to file for cross-branch comparison
48
+ x.save! 'test/.benchmark_results/ragel_removal.json'
49
+ x.hold! 'test/.benchmark_results/ragel_removal.json'
50
+ end
51
+
52
+ puts
53
+ puts '=' * 80
54
+ puts 'Results saved to test/.benchmark_results/ragel_removal.json'
55
+ puts 'Switch git branches, recompile, and run again to compare!'
56
+ puts '=' * 80
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark/ips'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ # Unified benchmark runner that outputs both human-readable console output
8
+ # and structured JSON for documentation generation.
9
+ class BenchmarkRunner
10
+ RESULTS_DIR = File.expand_path('.results', __dir__)
11
+
12
+ attr_reader :name, :description, :metadata
13
+
14
+ # @param name [String] Short name for this benchmark (e.g., "parsing", "specificity")
15
+ # @param description [String] One-line description of what's being measured
16
+ # @param metadata [Hash] Additional metadata (fixture info, test cases, etc.)
17
+ def initialize(name:, description:, metadata: {})
18
+ @name = name
19
+ @description = description
20
+ @metadata = metadata
21
+ @results = []
22
+
23
+ FileUtils.mkdir_p(RESULTS_DIR)
24
+ end
25
+
26
+ # Run a benchmark-ips block and capture results
27
+ # @yield [Benchmark::IPS::Job] The benchmark-ips job
28
+ def run
29
+ json_path = File.join(RESULTS_DIR, "#{@name}.json")
30
+
31
+ Benchmark.ips do |x|
32
+ # Allow benchmark to configure itself
33
+ yield x
34
+
35
+ # Enable JSON output
36
+ x.json!(json_path)
37
+ end
38
+
39
+ # Read the generated JSON and enhance with metadata
40
+ raw_data = JSON.parse(File.read(json_path))
41
+ enhanced_data = {
42
+ 'name' => @name,
43
+ 'description' => @description,
44
+ 'metadata' => @metadata,
45
+ 'timestamp' => Time.now.iso8601,
46
+ 'results' => raw_data
47
+ }
48
+
49
+ File.write(json_path, JSON.pretty_generate(enhanced_data))
50
+ end
51
+
52
+ # Helper to format results as a comparison hash
53
+ # @param label [String] Label for this result
54
+ # @param baseline [String] Baseline label to compare against
55
+ # @return [Hash] Structured comparison data
56
+ def self.format_comparison(label:, baseline:, results:)
57
+ baseline_result = results.find { |r| r['name'] == baseline }
58
+ comparison_result = results.find { |r| r['name'] == label }
59
+
60
+ return nil unless baseline_result && comparison_result
61
+
62
+ speedup = comparison_result['central_tendency'].to_f / baseline_result['central_tendency']
63
+
64
+ {
65
+ 'label' => label,
66
+ 'baseline' => baseline,
67
+ 'speedup' => speedup.round(2)
68
+ }
69
+ end
70
+ end