serialbench 0.1.3 → 0.3.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/push-to-data.sh +65 -0
  3. data/.github/workflows/benchmark.yml +134 -168
  4. data/.github/workflows/release.yml +68 -13
  5. data/Gemfile +2 -0
  6. data/README.adoc +5 -0
  7. data/config/benchmarks/full-json.yml +25 -0
  8. data/config/benchmarks/full-toml.yml +25 -0
  9. data/config/benchmarks/full-xml.yml +26 -0
  10. data/config/benchmarks/full-yaml.yml +25 -0
  11. data/config/benchmarks/full.yml +9 -4
  12. data/lib/serialbench/benchmark_runner.rb +20 -23
  13. data/lib/serialbench/cli/benchmark_cli.rb +1 -1
  14. data/lib/serialbench/cli/resultset_cli.rb +40 -0
  15. data/lib/serialbench/cli.rb +1 -8
  16. data/lib/serialbench/models/benchmark_result.rb +2 -0
  17. data/lib/serialbench/models/platform.rb +5 -2
  18. data/lib/serialbench/serializers/base_serializer.rb +14 -2
  19. data/lib/serialbench/serializers/json/rapidjson_serializer.rb +0 -4
  20. data/lib/serialbench/serializers/xml/base_xml_serializer.rb +6 -5
  21. data/lib/serialbench/serializers/xml/leptris_serializer.rb +174 -0
  22. data/lib/serialbench/serializers/xml/libxml_serializer.rb +9 -0
  23. data/lib/serialbench/serializers/xml/nokogiri_serializer.rb +4 -0
  24. data/lib/serialbench/serializers/xml/oga_serializer.rb +8 -0
  25. data/lib/serialbench/serializers/yaml/base_yaml_serializer.rb +1 -3
  26. data/lib/serialbench/serializers/yaml/psych_serializer.rb +0 -4
  27. data/lib/serialbench/serializers.rb +3 -1
  28. data/lib/serialbench/site_generator.rb +56 -2
  29. data/lib/serialbench/version.rb +1 -1
  30. data/lib/serialbench.rb +1 -1
  31. metadata +9 -15
  32. data/lib/serialbench/templates/assets/css/benchmark_report.css +0 -535
  33. data/lib/serialbench/templates/assets/css/format_based.css +0 -474
  34. data/lib/serialbench/templates/assets/css/themes.css +0 -589
  35. data/lib/serialbench/templates/assets/js/chart_helpers.js +0 -411
  36. data/lib/serialbench/templates/assets/js/dashboard.js +0 -795
  37. data/lib/serialbench/templates/assets/js/navigation.js +0 -142
  38. data/lib/serialbench/templates/base.liquid +0 -49
  39. data/lib/serialbench/templates/format_based.liquid +0 -507
  40. data/lib/serialbench/templates/partials/chart_section.liquid +0 -4
@@ -0,0 +1,26 @@
1
+ # Per-format split of full.yml: each format runs in its own process so
2
+ # memory profiling of large documents cannot compound across formats
3
+ # (windows runners OOMed with all four formats in one process).
4
+ name: full-xml
5
+
6
+ data_sizes:
7
+ - small
8
+ - medium
9
+ - large
10
+
11
+ formats:
12
+ - xml
13
+
14
+ iterations:
15
+ small: 10
16
+ medium: 3
17
+ large: 1
18
+
19
+ operations:
20
+ - parse
21
+ - generate
22
+ - streaming
23
+ - xpath
24
+ - memory
25
+
26
+ warmup: 2
@@ -0,0 +1,25 @@
1
+ # Per-format split of full.yml: each format runs in its own process so
2
+ # memory profiling of large documents cannot compound across formats
3
+ # (windows runners OOMed with all four formats in one process).
4
+ name: full-yaml
5
+
6
+ data_sizes:
7
+ - small
8
+ - medium
9
+ - large
10
+
11
+ formats:
12
+ - yaml
13
+
14
+ iterations:
15
+ small: 10
16
+ medium: 3
17
+ large: 1
18
+
19
+ operations:
20
+ - parse
21
+ - generate
22
+ - streaming
23
+ - memory
24
+
25
+ warmup: 2
@@ -15,15 +15,20 @@ formats:
15
15
  - yaml
16
16
  - toml
17
17
 
18
+ # Trimmed from 20/5/2 (warmup 3): a healthy leg took ~70 minutes at that
19
+ # setting, which both inflates weekly cost and leaves legs exposed to
20
+ # runner-eviction flakiness. 10/3/1 with warmup 2 halves runtime with
21
+ # acceptable added noise.
18
22
  iterations:
19
- small: 20
20
- medium: 5
21
- large: 2
23
+ small: 10
24
+ medium: 3
25
+ large: 1
22
26
 
23
27
  operations:
24
28
  - parse
25
29
  - generate
26
30
  - streaming
31
+ - xpath
27
32
  - memory
28
33
 
29
- warmup: 3
34
+ warmup: 2
@@ -24,6 +24,19 @@ module Serialbench
24
24
  load_test_data
25
25
  end
26
26
 
27
+ # Each operation maps to a lambda. Adding an operation = one entry.
28
+ OPERATIONS = {
29
+ 'parsing' => ->(s, data) { s.parse(data) },
30
+ 'generation' => ->(s, data) { s.generate(s.parse(data)) },
31
+ 'xpath' => lambda { |s, data|
32
+ doc = s.parse(data)
33
+ s.xpath_query(doc, '//book')
34
+ s.xpath_query(doc, "//book[@id='101']")
35
+ s.xpath_query(doc, '//book[price > 30]/title')
36
+ },
37
+ 'streaming' => ->(s, data) { s.stream_parse(data) { |_event, _data| } },
38
+ }.freeze
39
+
27
40
  def run_all_benchmarks
28
41
  puts 'Serialbench - Running comprehensive serialization performance tests'
29
42
  puts '=' * 70
@@ -32,34 +45,18 @@ module Serialbench
32
45
  puts "Test data sizes: #{@test_data.keys.join(', ')}"
33
46
  puts
34
47
 
48
+ results = {}
49
+ OPERATIONS.each do |name, handler|
50
+ results[name.to_sym] = run_benchmark_type(name, name, &handler)
51
+ end
52
+ results[:memory] = run_memory_benchmarks
53
+
35
54
  Models::BenchmarkResult.new(
36
55
  serializers: Serializers.information,
37
- parsing: run_parsing_benchmarks,
38
- generation: run_generation_benchmarks,
39
- memory: run_memory_benchmarks,
40
- streaming: run_streaming_benchmarks
56
+ **results
41
57
  )
42
58
  end
43
59
 
44
- def run_parsing_benchmarks
45
- run_benchmark_type('parsing', 'parse') do |serializer, data|
46
- serializer.parse(data)
47
- end
48
- end
49
-
50
- def run_generation_benchmarks
51
- run_benchmark_type('generation', 'generation') do |serializer, data|
52
- document = serializer.parse(data)
53
- serializer.generate(document)
54
- end
55
- end
56
-
57
- def run_streaming_benchmarks
58
- run_benchmark_type('streaming', 'stream parse') do |serializer, data|
59
- serializer.stream_parse(data) { |event, data| }
60
- end
61
- end
62
-
63
60
  def run_memory_benchmarks
64
61
  puts "\nRunning memory usage benchmarks..."
65
62
  return [] unless defined?(::MemoryProfiler)
@@ -190,7 +190,7 @@ module Serialbench
190
190
  say "🏗️ Generating HTML site for result: #{result_path}", :green
191
191
 
192
192
  # Use the unified site generator for results
193
- Serialbench::SiteGenerator.generate_for_result(result, options[:output_dir])
193
+ puts 'Site generation moved to serialbench.github.io'
194
194
 
195
195
  say '✅ HTML site generated successfully!', :green
196
196
  say "Site location: #{options[:output_dir]}", :cyan
@@ -213,6 +213,46 @@ module Serialbench
213
213
  exit 1
214
214
  end
215
215
 
216
+ desc 'export-data RESULTSET_PATH OUTPUT_JSON', 'Export a resultset as the site dashboard JSON payload'
217
+ long_desc <<~DESC
218
+ Write the aggregated dashboard payload (combined_results, environments,
219
+ metadata) for a resultset as JSON. This is the data contract consumed
220
+ by the Astro site in site/.
221
+
222
+ Optionally also export the raw per-environment YAML files and the
223
+ complete resultset YAML for download links.
224
+
225
+ Examples:
226
+ serialbench resultset export-data results/sets/weekly site/src/data/sample.json --raw-data-dir site/public/data
227
+ DESC
228
+ method_option 'raw-data-dir', type: :string,
229
+ desc: 'Also write per-environment YAML and resultset.yaml to this directory'
230
+ def export_data(resultset_path, output_json)
231
+ resultset = Serialbench::Models::ResultSet.load(resultset_path)
232
+
233
+ if resultset.results.empty?
234
+ say "ResultSet '#{resultset_path}' contains no runs", :yellow
235
+ say "Use 'serialbench resultset add-result' to add runs first", :white
236
+ exit 1
237
+ end
238
+
239
+ payload = Serialbench::SiteGenerator.resultset_payload(resultset)
240
+ output_dir = File.dirname(File.expand_path(output_json))
241
+ FileUtils.mkdir_p(output_dir)
242
+ File.write(output_json, JSON.pretty_generate(payload))
243
+
244
+ say "✅ Dashboard payload exported: #{output_json}", :green
245
+ say "Environments: #{payload['environments'].size}", :cyan
246
+
247
+ if options['raw-data-dir']
248
+ Serialbench::SiteGenerator.export_raw_data(resultset, options['raw-data-dir'])
249
+ say "📦 Raw YAML exported to: #{options['raw-data-dir']}", :cyan
250
+ end
251
+ rescue StandardError => e
252
+ say "Error exporting data: #{e.message}", :red
253
+ exit 1
254
+ end
255
+
216
256
  desc 'list', 'List all available resultsets'
217
257
  long_desc <<~DESC
218
258
  List all resultsets in the results/sets/ directory.
@@ -4,9 +4,9 @@ require 'thor'
4
4
  require_relative 'cli/base_cli'
5
5
  require_relative 'cli/environment_cli'
6
6
  require_relative 'cli/benchmark_cli'
7
- require_relative 'cli/resultset_cli'
8
7
  require_relative 'cli/ruby_build_cli'
9
8
  require_relative 'cli/validate_cli'
9
+ require_relative 'cli/resultset_cli'
10
10
 
11
11
  module Serialbench
12
12
  # Main CLI entry point for the new object-oriented command structure
@@ -17,9 +17,6 @@ module Serialbench
17
17
  desc 'benchmark SUBCOMMAND', 'Manage individual benchmark runs'
18
18
  subcommand 'benchmark', Serialbench::Cli::BenchmarkCli
19
19
 
20
- desc 'resultset SUBCOMMAND', 'Manage benchmark resultsets (collections of runs)'
21
- subcommand 'resultset', Serialbench::Cli::ResultsetCli
22
-
23
20
  desc 'ruby_build SUBCOMMAND', 'Manage Ruby-Build definitions for validation'
24
21
  subcommand 'ruby_build', Serialbench::Cli::RubyBuildCli
25
22
 
@@ -45,7 +42,6 @@ module Serialbench
45
42
  COMMANDS:
46
43
  environment Manage benchmark environments (Docker, ASDF, Local)
47
44
  benchmark Manage individual benchmark runs
48
- resultset Manage benchmark resultsets (collections of runs)
49
45
  ruby-build Manage Ruby-Build definitions for validation
50
46
  version Show version information
51
47
  help Show this help message
@@ -63,12 +59,9 @@ module Serialbench
63
59
  serialbench benchmark execute my-benchmark.yml
64
60
 
65
61
  # Create a result set for comparison
66
- serialbench resultset create comparison-set
67
- serialbench resultset add-result comparison-set results/my-benchmark
68
62
 
69
63
  # Generate static sites
70
64
  serialbench benchmark build-site results/my-benchmark
71
- serialbench resultset build-site resultsets/comparison-set
72
65
 
73
66
  For detailed help on any command, use:
74
67
  serialbench COMMAND help
@@ -68,6 +68,7 @@ module Serialbench
68
68
  attribute :generation, IterationPerformance, collection: true
69
69
  attribute :memory, MemoryPerformance, collection: true
70
70
  attribute :streaming, IterationPerformance, collection: true
71
+ attribute :xpath, IterationPerformance, collection: true
71
72
 
72
73
  key_value do
73
74
  map 'serializers', to: :serializers
@@ -75,6 +76,7 @@ module Serialbench
75
76
  map 'generation', to: :generation
76
77
  map 'memory', to: :memory
77
78
  map 'streaming', to: :streaming
79
+ map 'xpath', to: :xpath
78
80
  end
79
81
  end
80
82
  end
@@ -15,8 +15,11 @@ module Serialbench
15
15
  class Platform < Lutaml::Model::Serializable
16
16
  attribute :platform_string, :string
17
17
  attribute :kind, :string, default: -> { 'local' }
18
- attribute :os, :string, default: -> { detect_os }
19
- attribute :arch, :string, default: -> { detect_arch }
18
+ # Fully qualified: lutaml-model 0.8+ instance-evals default
19
+ # lambdas, so bare detect_os/detect_arch (class methods) no
20
+ # longer resolve there.
21
+ attribute :os, :string, default: -> { Serialbench::Models::Platform.detect_os }
22
+ attribute :arch, :string, default: -> { Serialbench::Models::Platform.detect_arch }
20
23
  attribute :ruby_version, :string, default: -> { RUBY_VERSION }
21
24
  attribute :ruby_platform, :string, default: -> { RUBY_PLATFORM }
22
25
  attribute :ruby_build_tag, :string
@@ -1,3 +1,5 @@
1
+ require 'set'
2
+
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require 'singleton'
@@ -33,9 +35,19 @@ module Serialbench
33
35
  result
34
36
  end
35
37
 
38
+ # Capabilities: a Set of symbols declaring what this adapter can do.
39
+ # Subclasses override to add symbols; the base handles every query.
40
+ # This replaces the 12+ individual supports_X? predicates.
41
+ def capabilities
42
+ Set.new(%i[dom parse generate])
43
+ end
44
+
45
+ def supports?(capability)
46
+ capabilities.include?(capability)
47
+ end
48
+
36
49
  def supports_streaming?
37
- # Override in subclasses that support streaming
38
- false
50
+ supports?(:sax) || supports?(:streaming)
39
51
  end
40
52
 
41
53
  def require_library(library_name)
@@ -32,10 +32,6 @@ module Serialbench
32
32
  RapidJSON.dump(object)
33
33
  end
34
34
 
35
- def features
36
- %w[parsing generation high-performance c-extension]
37
- end
38
-
39
35
  private
40
36
 
41
37
  def require_library(library_name)
@@ -23,13 +23,14 @@ module Serialbench
23
23
  generate(document, options)
24
24
  end
25
25
 
26
- # XML-specific features
26
+ # XML-specific features derive from the capability set
27
27
  def features
28
28
  {
29
- xpath: supports_xpath?,
30
- namespaces: supports_namespaces?,
31
- validation: supports_validation?,
32
- streaming: supports_streaming?
29
+ xpath: supports?(:xpath),
30
+ namespaces: supports?(:namespaces),
31
+ validation: supports?(:validation),
32
+ streaming: supports?(:sax) || supports?(:streaming),
33
+ stax: supports?(:stax)
33
34
  }
34
35
  end
35
36
 
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_xml_serializer'
4
+
5
+ module Serialbench
6
+ module Serializers
7
+ module Xml
8
+ class LeptrisSerializer < BaseXmlSerializer
9
+ def name
10
+ 'leptris'
11
+ end
12
+
13
+ def parse(xml_string)
14
+ require 'leptris'
15
+ require 'leptris/xml'
16
+ Leptris::XML.parse(xml_string)
17
+ end
18
+
19
+ def generate(data)
20
+ require 'leptris'
21
+ require 'leptris/xml'
22
+ if data.is_a?(Leptris::XML::Document) || data.is_a?(Leptris::XML::Node)
23
+ data.to_xml(indent: 2)
24
+ else
25
+ build_document_from_data(data).to_xml(indent: 2)
26
+ end
27
+ end
28
+
29
+ def parse_streaming(xml_string, &block)
30
+ require 'leptris'
31
+ require 'leptris/xml'
32
+
33
+ handler = StreamingHandler.new(&block)
34
+ parser = Leptris::XML::SAX::Parser.new(handler)
35
+ parser.parse_memory(xml_string)
36
+ handler.elements_processed
37
+ end
38
+
39
+ def supports_streaming?
40
+ true
41
+ end
42
+
43
+ def capabilities
44
+ super | Set.new(%i[xpath namespaces sax stax])
45
+ end
46
+
47
+ def xpath_query(document, expression)
48
+ require 'leptris'
49
+ require 'leptris/xml'
50
+ result = document.xpath(expression)
51
+ result.size
52
+ end
53
+
54
+ # leptris-ruby >= 1.6 exposes Leptris::XML::Pull, a StAX-style
55
+ # cursor (one event per #next). Declared by gem version so the
56
+ # check never triggers the FFI library load.
57
+
58
+
59
+ def version
60
+ return 'unknown' unless available?
61
+
62
+ require 'leptris'
63
+ require 'leptris/xml'
64
+ Leptris::VERSION
65
+ end
66
+
67
+ def library_require_name
68
+ 'leptris'
69
+ end
70
+
71
+ # The gem's require succeeds even when the libtaurus shared library is
72
+ # missing -- the FFI load happens lazily at first use. Probe with a
73
+ # real parse so "available" means "actually usable".
74
+ def available?
75
+ return @available if defined?(@available)
76
+
77
+ @available = begin
78
+ require 'leptris'
79
+ require 'leptris/xml'
80
+ Leptris::XML.parse('<probe/>')
81
+ true
82
+ rescue StandardError, LoadError
83
+ false
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def build_document_from_data(data, root_name = 'root')
90
+ document = Leptris::XML.parse('<root/>')
91
+ document.root.name = sanitize_element_name(root_name)
92
+ append_data(document.root, data)
93
+ document
94
+ end
95
+
96
+ def append_data(element, data)
97
+ case data
98
+ when Hash
99
+ data.each do |key, value|
100
+ child = element.document.create_element(sanitize_element_name(key.to_s))
101
+ element.add_child(child)
102
+ append_data(child, value)
103
+ end
104
+ when Array
105
+ data.each do |item|
106
+ child = element.document.create_element('item')
107
+ element.add_child(child)
108
+ append_data(child, item)
109
+ end
110
+ else
111
+ element.content = data.to_s
112
+ end
113
+ end
114
+
115
+ def sanitize_element_name(name)
116
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
117
+ sanitized = "element_#{sanitized}" if sanitized.empty? || sanitized =~ /\A\d/
118
+ sanitized
119
+ end
120
+
121
+ # SAX handler counting elements, mirroring the Nokogiri adapter's
122
+ # StreamingHandler shape (Leptris SAX delivers attrs as [name, value]
123
+ # pairs, same as Nokogiri::XML::SAX). Plain duck-typed class --
124
+ # Leptris::XML::SAX::Parser needs no base class, and subclassing it
125
+ # would trigger the FFI library load at file-load time.
126
+ class StreamingHandler
127
+ attr_reader :elements_processed
128
+
129
+ def initialize(&block)
130
+ @block = block
131
+ @elements_processed = 0
132
+ @element_stack = []
133
+ end
134
+
135
+ # No-op callbacks the parser invokes unconditionally (the base
136
+ # Leptris::XML::SAX::Document normally provides these).
137
+ def start_document; end
138
+ def end_document; end
139
+ def xmldecl(_version, _encoding, _standalone); end
140
+ def comment(_string); end
141
+ def processing_instruction(_name, _content); end
142
+ def start_prefix_mapping(_prefix, _uri); end
143
+ def end_prefix_mapping(_prefix); end
144
+ def warning(_string); end
145
+ def error(_message, _line = 0, _column = 0); end
146
+
147
+ def start_element(name, attrs = [])
148
+ @elements_processed += 1
149
+ @element_stack.push({ name: name, attributes: Hash[attrs], children: [], text: '' })
150
+ end
151
+
152
+ def end_element(_name)
153
+ element = @element_stack.pop
154
+ if @element_stack.empty?
155
+ @block&.call(element)
156
+ else
157
+ @element_stack.last[:children] << element
158
+ end
159
+ end
160
+
161
+ def characters(string)
162
+ return if string.strip.empty?
163
+
164
+ @element_stack.last[:text] += string if @element_stack.any?
165
+ end
166
+
167
+ def cdata_block(string)
168
+ @element_stack.last[:text] += string if @element_stack.any?
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -38,6 +38,15 @@ module Serialbench
38
38
  handler.elements_processed
39
39
  end
40
40
 
41
+ def supports_xpath?
42
+ true
43
+ end
44
+
45
+ # The binding exposes a pull-parser cursor (XML::Reader)
46
+ def supports_stax?
47
+ true
48
+ end
49
+
41
50
  def supports_streaming?
42
51
  true
43
52
  end
@@ -38,6 +38,10 @@ module Serialbench
38
38
  handler.elements_processed
39
39
  end
40
40
 
41
+ def capabilities
42
+ super | Set.new(%i[xpath namespaces stax])
43
+ end
44
+
41
45
  def supports_streaming?
42
46
  true
43
47
  end
@@ -38,6 +38,14 @@ module Serialbench
38
38
  handler.elements_processed
39
39
  end
40
40
 
41
+ def supports_xpath?
42
+ true
43
+ end
44
+
45
+ def xpath_query(document, expression)
46
+ document.xpath(expression).size
47
+ end
48
+
41
49
  def supports_streaming?
42
50
  true
43
51
  end
@@ -16,9 +16,7 @@ module Serialbench
16
16
  end
17
17
 
18
18
  def features
19
- features = %w[parsing generation]
20
- features << 'streaming' if supports_streaming?
21
- features
19
+ { streaming: supports_streaming? }
22
20
  end
23
21
 
24
22
  # Default YAML generation options
@@ -36,10 +36,6 @@ module Serialbench
36
36
  Psych.dump(object)
37
37
  end
38
38
 
39
- def features
40
- %w[parsing generation built-in]
41
- end
42
-
43
39
  private
44
40
 
45
41
  def require_library(library_name)
@@ -10,6 +10,7 @@ require_relative 'serializers/xml/ox_serializer'
10
10
  require_relative 'serializers/xml/nokogiri_serializer'
11
11
  require_relative 'serializers/xml/oga_serializer'
12
12
  require_relative 'serializers/xml/libxml_serializer'
13
+ require_relative 'serializers/xml/leptris_serializer'
13
14
 
14
15
  # JSON Serializers
15
16
  require_relative 'serializers/json/base_json_serializer'
@@ -38,7 +39,8 @@ module Serialbench
38
39
  Xml::OxSerializer,
39
40
  Xml::NokogiriSerializer,
40
41
  Xml::OgaSerializer,
41
- Xml::LibxmlSerializer
42
+ Xml::LibxmlSerializer,
43
+ Xml::LeptrisSerializer
42
44
  ],
43
45
  json: [
44
46
  Json::JsonSerializer,
@@ -30,6 +30,48 @@ module Serialbench
30
30
  generator.generate_site
31
31
  end
32
32
 
33
+ def self.resultset_payload(resultset)
34
+ new(output_path: '/dev/null', resultset: resultset).resultset_payload
35
+ end
36
+
37
+ def self.export_raw_data(resultset, data_dir)
38
+ new(output_path: '/dev/null', resultset: resultset).export_raw_data_to(data_dir)
39
+ end
40
+
41
+ def resultset_payload
42
+ transform_resultset_for_dashboard(@resultset)
43
+ .merge('libraries' => libraries_information(@resultset))
44
+ end
45
+
46
+ # Static capability matrix (adapter declarations) plus the versions
47
+ # actually measured in this resultset.
48
+ def libraries_information(resultset)
49
+ versions = {}
50
+ resultset.results.each do |result|
51
+ serializers = result.benchmark_result.serializers || []
52
+ serializers.each { |si| versions[si.name] ||= si.version }
53
+ end
54
+
55
+ Serialbench::Serializers.all.filter_map do |adapter|
56
+ next if adapter.name == 'syck'
57
+
58
+ features = adapter.features
59
+ next if features.is_a?(Array)
60
+
61
+ {
62
+ 'name' => adapter.name,
63
+ 'format' => adapter.format.to_s,
64
+ 'version' => versions[adapter.name] || 'unknown',
65
+ 'features' => features.transform_values { |v| v == true }
66
+ }
67
+ end
68
+ end
69
+
70
+ def export_raw_data_to(data_dir)
71
+ FileUtils.mkdir_p(data_dir)
72
+ export_resultset(@resultset, data_dir)
73
+ end
74
+
33
75
  def generate_site
34
76
  target_name = @result ? @result.environment_config.name : @resultset.name
35
77
 
@@ -140,6 +182,18 @@ module Serialbench
140
182
  }
141
183
  end
142
184
 
185
+ # Environment identity for the site: use the CI platform_string
186
+ # ("macos-26-ruby-3.4") when it is runner-shaped, so each runner label is
187
+ # its own environment; fall back to os-arch for other sources (docker,
188
+ # local runs, older resultsets).
189
+ def env_identity(result)
190
+ platform_string = result.platform.platform_string
191
+ ruby = result.platform.ruby_version
192
+ return platform_string if platform_string&.end_with?("-ruby-#{ruby}")
193
+
194
+ "#{result.platform.os}-#{result.platform.arch}-ruby-#{ruby}"
195
+ end
196
+
143
197
  # Transform a ResultSet (collection of Results) into dashboard-compatible format
144
198
  # Combines all results into a single dashboard structure
145
199
  def transform_resultset_for_dashboard(resultset)
@@ -148,7 +202,7 @@ module Serialbench
148
202
 
149
203
  resultset.results.each do |result|
150
204
  # Create unique env key for this result
151
- env_key = "#{result.platform.os}-#{result.platform.arch}-ruby-#{result.platform.ruby_version}"
205
+ env_key = env_identity(result)
152
206
 
153
207
  # Merge this result's data into combined_results
154
208
  result_combined = build_combined_results(result, env_key)
@@ -258,7 +312,7 @@ module Serialbench
258
312
 
259
313
  def export_single_result(result, data_dir)
260
314
  # Create filename based on platform info
261
- env_key = "#{result.platform.os}-#{result.platform.arch}-ruby-#{result.platform.ruby_version}"
315
+ env_key = env_identity(result)
262
316
  filename = "#{env_key}.yaml"
263
317
  filepath = File.join(data_dir, filename)
264
318