xmi 0.6.1 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cc49c1f04320b672d11b3bff319bc4aee6695a59b3989595af4f2e92e0a62f9
4
- data.tar.gz: 8f366089bc8ad283935a06aa8b1191a54be1a26ed81014a692e59abdd26d332a
3
+ metadata.gz: 594be55008babe25af18895181e47ff22127d597bc56cc19c2c0b2717682e821
4
+ data.tar.gz: aea140572fcae2dd36e4d8b5fce6e3d8fefffe1a30fc4ce74a6ff18fd5f8f409
5
5
  SHA512:
6
- metadata.gz: c90c0d2a39bc0d17452e87f8bf11ac9cb5349c1fda65b7bf65bbea2ee5e57e85f87e7b07ef3da805f75f7d12bf4eb8be2375ad777faeeee993410bdf1b4d950f
7
- data.tar.gz: 7d7d33c2bde1c98942542733af1e53d2f7e66a6f57cd1421763b7d5b6e2b3a79fc9232953442fbac89135abf747cb0ba995efaaf7e40f85d3ddac23e94e1b0d4
6
+ metadata.gz: 12c4a3dc4575038dfde21225351477d37177cda25004cc517690b0bdbfa86aa4014ad5d2204ce10e5d0718d5d9ab4fed82cea3cefa9610c56a6bc3accd0c4142
7
+ data.tar.gz: b12d8eafaf2e5dcbf306769b809df0f5b80b8ca161aa4fda49e17cdeea13af5008d39b92e5f3066a73c276fe7d9a0fa3a87546fe53179cd1eeeee4f51ecbdb26
data/.rubocop_todo.yml CHANGED
@@ -113,9 +113,9 @@ Lint/ConstantDefinitionInBlock:
113
113
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
114
114
  Metrics/AbcSize:
115
115
  Exclude:
116
- - 'lib/tasks/benchmark_runner.rb'
117
- - 'lib/tasks/performance_comparator.rb'
118
- - 'lib/tasks/performance_helpers.rb'
116
+ - 'lib/xmi/performance/runner.rb'
117
+ - 'lib/xmi/performance/comparator.rb'
118
+ - 'lib/xmi/performance/helpers.rb'
119
119
  - 'lib/xmi/ea_root/code_generation.rb'
120
120
  - 'lib/xmi/sparx/index.rb'
121
121
  - 'lib/xmi/version_registry.rb'
@@ -131,7 +131,7 @@ Metrics/BlockLength:
131
131
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
132
132
  Metrics/CyclomaticComplexity:
133
133
  Exclude:
134
- - 'lib/tasks/performance_helpers.rb'
134
+ - 'lib/xmi/performance/helpers.rb'
135
135
  - 'lib/xmi/ea_root/code_generation.rb'
136
136
  - 'lib/xmi/sparx/index.rb'
137
137
 
@@ -144,7 +144,7 @@ Metrics/MethodLength:
144
144
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
145
145
  Metrics/PerceivedComplexity:
146
146
  Exclude:
147
- - 'lib/tasks/performance_helpers.rb'
147
+ - 'lib/xmi/performance/helpers.rb'
148
148
  - 'lib/xmi/ea_root/code_generation.rb'
149
149
  - 'lib/xmi/sparx/index.rb'
150
150
  - 'lib/xmi/version_registry.rb'
@@ -155,7 +155,7 @@ Metrics/PerceivedComplexity:
155
155
  # AllowedIdentifiers: TLS1_1, TLS1_2, capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64
156
156
  Naming/VariableNumber:
157
157
  Exclude:
158
- - 'lib/tasks/benchmark_runner.rb'
158
+ - 'lib/xmi/performance/runner.rb'
159
159
  - 'lib/xmi/v20110701.rb'
160
160
  - 'lib/xmi/v20131001.rb'
161
161
  - 'lib/xmi/v20161101.rb'
@@ -247,6 +247,6 @@ Style/BlockDelimiters:
247
247
  # Configuration parameters: AllowedClasses.
248
248
  Style/OneClassPerFile:
249
249
  Exclude:
250
- - 'lib/tasks/benchmark_runner.rb'
250
+ - 'lib/xmi/performance/runner.rb'
251
251
  - 'lib/xmi.rb'
252
252
  - 'lib/xmi/namespace/dynamic.rb'
@@ -1,34 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "performance_comparator"
4
- require_relative "benchmark_runner"
3
+ require "xmi"
5
4
 
6
5
  desc "Run performance benchmarks"
7
6
  namespace :performance do
8
7
  desc "Compare performance of current branch against base branch (default: main)"
9
8
  task :compare do
10
- PerformanceComparator.new.run
9
+ Xmi::Performance::Comparator.new.run
11
10
  end
12
11
 
13
12
  desc "Run benchmarks on current branch only (for development)"
14
13
  task :run do
15
- runner = BenchmarkRunner.new(run_time: 5)
14
+ runner = Xmi::Performance::Runner.new(run_time: 5)
16
15
  runner.run_benchmarks
17
16
  end
18
17
 
19
18
  desc "Quick benchmark run (faster, less accurate)"
20
19
  task :quick do
21
- runner = BenchmarkRunner.new(run_time: 2, warmup: 1)
20
+ runner = Xmi::Performance::Runner.new(run_time: 2, warmup: 1)
22
21
  runner.run_benchmarks
23
22
  end
24
23
 
25
24
  desc "Run benchmarks and output as JSON"
26
25
  task :json do
27
26
  require "json"
28
- runner = BenchmarkRunner.new(run_time: 5)
27
+ runner = Xmi::Performance::Runner.new(run_time: 5)
29
28
 
30
- # Suppress pretty output, just get results
31
- results = runner.send(:run_benchmarks)
29
+ results = runner.run_benchmarks
32
30
 
33
31
  output = results.each_with_object({}) do |(label, metrics), h|
34
32
  ips = (metrics[:lower] + metrics[:upper]) / 2.0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Xmi
6
+ module Performance
7
+ # Orchestrates the performance comparison flow: loads the Runner
8
+ # into Base and Current namespaces, runs both, and reports
9
+ # regressions.
10
+ class Comparator
11
+ REPO_ROOT = File.expand_path(File.join(__dir__, "..", "..", ".."))
12
+ DEFAULT_RUN_TIME = 10
13
+ DEFAULT_THRESHOLD = 0.10 # 10% (more lenient for complex operations)
14
+ DEFAULT_BASE = "main"
15
+
16
+ def run
17
+ setup_environment
18
+ run_benchmarks_comparison
19
+ end
20
+
21
+ private
22
+
23
+ def setup_environment
24
+ Dir.chdir(REPO_ROOT)
25
+ Helpers.load_runner_into(Helpers::Current)
26
+ Helpers.load_runner_into(Helpers::Base)
27
+ end
28
+
29
+ def run_benchmarks_comparison
30
+ all_current = {}
31
+ all_base = {}
32
+
33
+ puts Helpers::Term.header("Performance Comparison", color: Helpers::CYAN)
34
+ puts
35
+ puts " #{Helpers::DIM}Comparing#{Helpers::CLEAR}:"
36
+ puts " #{Helpers::CYAN} Current#{Helpers::CLEAR}: #{Helpers.current_branch}"
37
+ puts " #{Helpers::CYAN} Base#{Helpers::CLEAR}: #{DEFAULT_BASE}"
38
+ puts " #{Helpers::CYAN} Threshold#{Helpers::CLEAR}: #{(DEFAULT_THRESHOLD * 100).round(0)}% regression allowed"
39
+ puts
40
+
41
+ base_runner = Helpers::Base::Runner.new(
42
+ run_time: DEFAULT_RUN_TIME,
43
+ )
44
+ current_runner = Helpers::Current::Runner.new(
45
+ run_time: DEFAULT_RUN_TIME,
46
+ )
47
+
48
+ Helpers.run_benchmarks(
49
+ base_runner,
50
+ current_runner,
51
+ DEFAULT_THRESHOLD,
52
+ all_base,
53
+ all_current,
54
+ )
55
+
56
+ summary = Helpers.summary_report(
57
+ all_current,
58
+ all_base,
59
+ DEFAULT_BASE,
60
+ DEFAULT_RUN_TIME,
61
+ DEFAULT_THRESHOLD,
62
+ )
63
+
64
+ handle_results(summary)
65
+ end
66
+
67
+ def handle_results(summary)
68
+ puts
69
+ if summary[:regressions].any?
70
+ puts " #{Helpers::RED}#{Helpers::BOLD}❌ PERFORMANCE REGRESSIONS DETECTED#{Helpers::CLEAR}"
71
+ puts " #{Helpers::RED}#{summary[:regressions].length} benchmark(s) regressed beyond threshold#{Helpers::CLEAR}"
72
+ puts
73
+ exit(1)
74
+ else
75
+ puts " #{Helpers::GREEN}#{Helpers::BOLD}✅ ALL BENCHMARKS PASSED#{Helpers::CLEAR}"
76
+ puts
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+
8
+ module Xmi
9
+ module Performance
10
+ # Shared helpers for the performance benchmark rake tasks.
11
+ # Provides terminal formatting, git/process helpers, and the
12
+ # dual-namespace Runner loader used by the comparison flow.
13
+ module Helpers
14
+ # ANSI color codes for terminal output
15
+ CLEAR = "\e[0m"
16
+ BOLD = "\e[1m"
17
+ DIM = "\e[2m"
18
+ CYAN = "\e[36m"
19
+ GREEN = "\e[32m"
20
+ YELLOW = "\e[33m"
21
+ RED = "\e[31m"
22
+ GRAY = "\e[90m"
23
+ MAGENTA = "\e[35m"
24
+
25
+ # Terminal formatting helpers
26
+ module Term
27
+ extend self
28
+
29
+ HL = "─"
30
+ VL = "│"
31
+ TL = "┌"
32
+ TR = "┐"
33
+ BL = "└"
34
+ BR = "┘"
35
+
36
+ def header(title, color: Helpers::CYAN)
37
+ width = 78
38
+ line = HL * width
39
+ puts
40
+ puts "#{color}#{TL}#{line}#{TR}#{CLEAR}"
41
+ puts "#{color}#{VL}#{CLEAR} #{BOLD}#{color}#{title}#{CLEAR}#{' ' * (width - title.length - 4)}#{color}#{VL}#{CLEAR}"
42
+ puts "#{color}#{BL}#{line}#{BR}#{CLEAR}"
43
+ end
44
+
45
+ def sep(char: HL, width: 78)
46
+ puts "#{DIM}#{char * width}#{CLEAR}"
47
+ end
48
+ end
49
+
50
+ # Dual-namespace containers. The Runner class is cloned into
51
+ # each so that Base and Current hold independent class objects
52
+ # (preserving the shape of the comparison API even though both
53
+ # currently resolve to the same benchmark code).
54
+ module Base
55
+ end
56
+
57
+ module Current
58
+ end
59
+
60
+ class << self
61
+ # Clone Xmi::Performance::Runner into the given namespace as
62
+ # `::Runner`. Replaces the old module_eval-of-a-top-level-file
63
+ # mechanism, which required require_relative and a top-level
64
+ # class definition incompatible with autoload.
65
+ def load_runner_into(namespace)
66
+ Xmi::Performance::Runner # force autoload
67
+ namespace.const_set(:Runner, Xmi::Performance::Runner.clone)
68
+ end
69
+
70
+ def ruby_exec(cmd, env: {})
71
+ Open3.capture3(env, cmd)
72
+ end
73
+
74
+ def current_branch
75
+ stdout, = ruby_exec("git rev-parse --abbrev-ref HEAD")
76
+ stdout.strip
77
+ end
78
+
79
+ def run_benchmarks(base_runner, current_runner, threshold, all_base,
80
+ all_current)
81
+ base_results = base_runner.run_benchmarks
82
+ curr_results = current_runner.run_benchmarks
83
+
84
+ all_base.merge!(base_results)
85
+ all_current.merge!(curr_results)
86
+
87
+ comparison_rows = []
88
+
89
+ curr_results.each do |label, result|
90
+ base_result = base_results[label]
91
+ cmp = compare_metrics(label, result, base_result, threshold)
92
+ comparison_rows << cmp
93
+ end
94
+
95
+ print_comparison_table(comparison_rows, threshold)
96
+ end
97
+
98
+ def print_comparison_table(comparison_rows, threshold)
99
+ rows = comparison_rows.map do |cmp|
100
+ {
101
+ benchmark: cmp[:label],
102
+ base_ips: cmp[:base_ips]&.round(1),
103
+ curr_ips: cmp[:curr_ips]&.round(1),
104
+ change: cmp[:change] ? "#{(cmp[:change] * 100).round(1)}%" : "N/A",
105
+ status: if cmp[:base_ips].nil?
106
+ "NEW"
107
+ elsif cmp[:change] < -threshold
108
+ "REGRESSED"
109
+ else
110
+ "OK"
111
+ end,
112
+ }
113
+ end
114
+
115
+ return if rows.empty?
116
+
117
+ puts " #{'Benchmark'.ljust(40)} #{'Base IPS'.rjust(12)} #{'Curr IPS'.rjust(12)} #{'Change'.rjust(10)} #{'Status'.rjust(10)}"
118
+ puts " #{DIM}#{'─' * 86}#{CLEAR}"
119
+
120
+ rows.each do |row|
121
+ status_color = case row[:status]
122
+ when "REGRESSED" then RED
123
+ when "NEW" then YELLOW
124
+ else GREEN
125
+ end
126
+ row[:status] == "REGRESSED" ? RED : DIM
127
+
128
+ puts " #{row[:benchmark].ljust(40)} #{format('%-12.1f',
129
+ row[:base_ips] || 0)} #{format('%-12.1f',
130
+ row[:curr_ips] || 0)} #{format('%-10s', row[:change]).gsub('%',
131
+ '%%')} #{status_color}#{row[:status].rjust(10)}#{CLEAR}"
132
+ end
133
+
134
+ puts
135
+ end
136
+
137
+ def compare_metrics(label, curr, base, threshold)
138
+ unless base
139
+ return { label: label, base_ips: nil, curr_ips: nil, change: nil,
140
+ regressed: false }
141
+ end
142
+
143
+ base_ips = base.fetch(:lower)
144
+ curr_ips = curr.fetch(:upper)
145
+ change = (curr_ips - base_ips) / base_ips.to_f
146
+
147
+ {
148
+ label: label,
149
+ base_ips: base_ips,
150
+ curr_ips: curr_ips,
151
+ change: change,
152
+ regressed: change < -threshold,
153
+ }
154
+ end
155
+
156
+ def summary_report(current_results, base_results, base, run_time, threshold)
157
+ summary = {
158
+ run_time: run_time,
159
+ threshold: threshold,
160
+ branch: current_branch,
161
+ base: base,
162
+ regressions: [],
163
+ new_benchmarks: [],
164
+ }
165
+
166
+ current_results.each do |label, metrics|
167
+ base_result = base_results[label]
168
+ cmp = compare_metrics(label, metrics, base_result, threshold)
169
+
170
+ if base_result.nil?
171
+ summary[:new_benchmarks] << label
172
+ next
173
+ end
174
+
175
+ next unless cmp[:regressed]
176
+
177
+ summary[:regressions] << {
178
+ label: label,
179
+ base_ips: cmp[:base_ips],
180
+ curr_ips: cmp[:curr_ips],
181
+ delta_fraction: cmp[:change],
182
+ }
183
+ end
184
+
185
+ log_regressions(summary[:regressions], threshold)
186
+ log_new_benchmarks(summary[:new_benchmarks])
187
+ summary
188
+ end
189
+
190
+ def log_new_benchmarks(new_benchmarks)
191
+ return if new_benchmarks.empty?
192
+
193
+ puts
194
+ puts "#{YELLOW}🆕 New benchmarks (not in base branch):#{CLEAR}"
195
+ new_benchmarks.each do |label|
196
+ puts " • #{label}"
197
+ end
198
+ end
199
+
200
+ def log_regressions(regressions, threshold)
201
+ return if regressions.empty?
202
+
203
+ puts
204
+ puts "#{RED}⚠️ Performance Regressions Detected#{CLEAR}"
205
+ puts "#{RED} (< -#{(threshold * 100).round(2)}% IPS)#{CLEAR}"
206
+ puts
207
+ regressions.each do |regression|
208
+ delta = regression[:delta_fraction]
209
+ base_ips = regression[:base_ips]
210
+ curr_ips = regression[:curr_ips]
211
+
212
+ delta_str = delta ? format("%+0.2f%%", delta * 100) : "N/A"
213
+ base_str = base_ips ? format("%.2f", base_ips) : "N/A"
214
+ curr_str = curr_ips ? format("%.2f", curr_ips) : "N/A"
215
+
216
+ puts " #{BOLD}#{regression[:label]}#{CLEAR}"
217
+ puts " #{GRAY}base: #{base_str} IPS#{CLEAR}"
218
+ puts " #{RED}curr: #{curr_str} IPS#{CLEAR}"
219
+ puts " #{RED}change: #{delta_str}#{CLEAR}"
220
+ puts
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+ require "stringio"
5
+
6
+ module Xmi
7
+ module Performance
8
+ # Pretty terminal formatting for benchmark output.
9
+ module Term
10
+ CLEAR = "\e[0m"
11
+ BOLD = "\e[1m"
12
+ DIM = "\e[2m"
13
+ RED = "\e[31m"
14
+ GREEN = "\e[32m"
15
+ YELLOW = "\e[33m"
16
+ CYAN = "\e[36m"
17
+ MAGENTA = "\e[35m"
18
+
19
+ HL = "─"
20
+ VL = "│"
21
+ TL = "┌"
22
+ TR = "┐"
23
+ BL = "└"
24
+ BR = "┘"
25
+
26
+ def self.header(title, color: CYAN)
27
+ width = 78
28
+ line = HL * width
29
+ puts
30
+ puts "#{color}#{TL}#{line}#{TR}#{CLEAR}"
31
+ puts "#{color}#{VL}#{CLEAR} #{BOLD}#{color}#{title}#{CLEAR}#{' ' * (width - title.length - 4)}#{color}#{VL}#{CLEAR}"
32
+ puts "#{color}#{BL}#{line}#{BR}#{CLEAR}"
33
+ end
34
+
35
+ def self.sep(char: HL, width: 78)
36
+ puts "#{DIM}#{char * width}#{CLEAR}"
37
+ end
38
+
39
+ def self.env_info(ruby_version, platform)
40
+ puts
41
+ puts " #{DIM}Environment:#{CLEAR}"
42
+ puts " #{VL} Ruby #{ruby_version} on #{platform}#{' ' * (60 - ruby_version.length - platform.length)}#{VL}"
43
+ puts " #{DIM}#{BL}#{HL * 76}#{BR}#{CLEAR}"
44
+ puts
45
+ end
46
+
47
+ def self.category(title, icon:, description:, failure_means:,
48
+ compare_against: nil)
49
+ puts
50
+ puts "#{CYAN}#{VL}#{CLEAR} #{BOLD}#{MAGENTA}#{icon} #{title}#{CLEAR}"
51
+ puts
52
+ puts " #{DIM}#{description}#{CLEAR}"
53
+ puts
54
+
55
+ if compare_against
56
+ puts " #{CYAN}Comparing against:#{CLEAR} #{compare_against}"
57
+ puts
58
+ end
59
+
60
+ puts " #{YELLOW}⚠️ Failure means:#{CLEAR} #{failure_means}"
61
+ puts
62
+ sep(width: 76)
63
+ puts
64
+ end
65
+ end
66
+
67
+ # Runs XMI parsing benchmarks against fixture files and reports
68
+ # iterations-per-second with error margins.
69
+ class Runner
70
+ REPO_ROOT = File.expand_path(File.join(__dir__, "..", "..", ".."))
71
+
72
+ DEFAULT_RUN_TIME = 5
73
+ DEFAULT_WARMUP = 2
74
+
75
+ CATEGORIES = {
76
+ xmi_parsing: {
77
+ name: "XMI Parsing",
78
+ icon: "📄",
79
+ description: "XMI parsing performance tests. Measures how quickly we can convert XMI files into Ruby objects.",
80
+ failure_means: "Slow XMI parsing impacts all downstream operations. A regression here means users will experience delays when processing XMI documents.",
81
+ compare_against: "Previous branch (main).",
82
+ },
83
+ }.freeze
84
+
85
+ BENCHMARKS = {
86
+ xmi_parsing: [
87
+ { name: "XMI 2.4.2 (small)", method: :xmi_parse_242_small,
88
+ desc: "XMI 2.4.2 ~100KB file" },
89
+ { name: "XMI 2.4.2 (medium)", method: :xmi_parse_242_medium,
90
+ desc: "XMI 2.4.2 ~500KB file with extensions" },
91
+ { name: "XMI 2.4.2 (large)", method: :xmi_parse_242_large,
92
+ desc: "XMI 2.4.2 ~3.5MB file" },
93
+ { name: "XMI 2.5.1", method: :xmi_parse_251,
94
+ desc: "XMI 2.5.1 ~100KB file" },
95
+ ],
96
+ }.freeze
97
+
98
+ FIXTURES = {
99
+ xmi_parse_242_small: "spec/fixtures/xmi-v2-4-2-default.xmi",
100
+ xmi_parse_242_medium: "spec/fixtures/xmi-v2-4-2-default-with-citygml.xmi",
101
+ xmi_parse_242_large: "spec/fixtures/full-242.xmi",
102
+ xmi_parse_251: "spec/fixtures/ea-xmi-2.5.1.xmi",
103
+ }.freeze
104
+
105
+ def initialize(run_time: nil, warmup: nil, benchmark: nil)
106
+ @run_time = run_time || DEFAULT_RUN_TIME
107
+ @warmup = warmup || DEFAULT_WARMUP
108
+ @benchmark = benchmark
109
+ @results = {}
110
+ @env_shown = false
111
+ @all_results = []
112
+ end
113
+
114
+ def run_benchmarks
115
+ Term.header("XMI Performance Benchmarks", color: Term::CYAN)
116
+
117
+ unless @env_shown
118
+ Term.env_info(RUBY_VERSION, RUBY_PLATFORM)
119
+ @env_shown = true
120
+ end
121
+
122
+ BENCHMARKS.each do |category, tests|
123
+ run_category(category, tests)
124
+ end
125
+
126
+ print_summary
127
+
128
+ @results
129
+ end
130
+
131
+ private
132
+
133
+ def run_category(category, tests)
134
+ config = CATEGORIES[category]
135
+ Term.category(
136
+ config[:name],
137
+ icon: config[:icon],
138
+ description: config[:description],
139
+ failure_means: config[:failure_means],
140
+ compare_against: config[:compare_against],
141
+ )
142
+
143
+ category_results = []
144
+
145
+ tests.each do |test|
146
+ original_stdout = $stdout
147
+ $stdout = StringIO.new
148
+
149
+ result = run_single_test(test[:method])
150
+ (result[:lower] + result[:upper]) / 2.0
151
+ category_results << { name: test[:name], result: result }
152
+
153
+ $stdout = original_stdout
154
+ end
155
+
156
+ puts " #{'Benchmark'.ljust(40)} #{'IPS'.rjust(12)} #{'Deviation'.rjust(12)}"
157
+ puts " #{Term::DIM}#{Term::HL * 66}#{Term::CLEAR}"
158
+
159
+ category_results.each do |r|
160
+ ips = (r[:result][:lower] + r[:result][:upper]) / 2.0
161
+ deviation = calculate_deviation(r[:result])
162
+ label = "#{config[:name]}: #{r[:name]}"
163
+ @all_results << { label: label, ips: ips }
164
+ @results[label] = r[:result]
165
+
166
+ puts " #{r[:name].ljust(40)} #{format('%.2f',
167
+ ips).rjust(12)} #{format('%.1f%%',
168
+ deviation).rjust(12)}"
169
+ end
170
+
171
+ puts
172
+ end
173
+
174
+ def run_single_test(method)
175
+ fixture_path = FIXTURES[method]
176
+ raise "Unknown fixture: #{method}" unless fixture_path
177
+
178
+ full_path = File.join(REPO_ROOT, fixture_path)
179
+ full_path = fixture_path unless File.exist?(full_path)
180
+
181
+ xml_content = File.read(full_path)
182
+
183
+ case method
184
+ when :xmi_parse_242_small, :xmi_parse_242_medium, :xmi_parse_242_large, :xmi_parse_251
185
+ measure_time { Xmi::Sparx::Root.parse_xml(xml_content) }
186
+ else
187
+ raise "Unknown benchmark: #{method}"
188
+ end
189
+ end
190
+
191
+ def measure(&)
192
+ job = Benchmark::IPS::Job.new
193
+ job.config(time: @run_time, warmup: @warmup)
194
+ job.report("test", &)
195
+ job.run
196
+
197
+ entry = job.full_report.entries.first
198
+ samples = entry.stats.samples
199
+
200
+ return { lower: 0, upper: 0 } if samples.empty?
201
+
202
+ mean = samples.sum.to_f / samples.size
203
+ variance = samples.sum { |x| (x - mean)**2 } / (samples.size - 1)
204
+ std_dev = Math.sqrt(variance)
205
+ error_margin = std_dev / mean
206
+ error_pct = error_margin.round(4)
207
+
208
+ { lower: mean.round(4) * (1 - error_pct),
209
+ upper: mean.round(4) * (1 + error_pct) }
210
+ end
211
+
212
+ def measure_time
213
+ times = []
214
+ iterations = 5
215
+
216
+ iterations.times do
217
+ start_t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+ yield
219
+ finish_t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ times << (finish_t - start_t)
221
+ end
222
+
223
+ mean = times.sum / times.size
224
+ variance = times.sum { |t| (t - mean)**2 } / (times.size - 1)
225
+ std_dev = Math.sqrt(variance)
226
+
227
+ lower_time = [mean - std_dev, mean * 0.5].max
228
+ lower_ips = (1.0 / (lower_time * 1.5)).round(4)
229
+ upper_ips = (1.0 / mean).round(4)
230
+
231
+ if mean < 0.001
232
+ upper_ips = (1.0 / mean).round(4)
233
+ lower_ips = (upper_ips * 0.8).round(4)
234
+ end
235
+
236
+ { lower: lower_ips, upper: upper_ips }
237
+ end
238
+
239
+ def calculate_deviation(metrics)
240
+ return 0 if metrics[:upper].zero?
241
+
242
+ ((metrics[:upper] - metrics[:lower]) / metrics[:upper] * 100).round(1)
243
+ end
244
+
245
+ def print_summary
246
+ puts
247
+ Term.sep(width: 78)
248
+ puts
249
+ puts " #{Term::BOLD}#{Term::MAGENTA}SUMMARY#{Term::CLEAR}"
250
+ puts
251
+
252
+ @all_results.each do |r|
253
+ puts " #{r[:label].ljust(60)} #{format('%.2f', r[:ips]).rjust(10)} IPS"
254
+ end
255
+
256
+ puts
257
+ puts " #{Term::DIM}#{@all_results.length} benchmarks completed#{Term::CLEAR}"
258
+ puts
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xmi
4
+ # Benchmarking and performance comparison tooling.
5
+ #
6
+ # Loaded on demand via autoload — never required at gem runtime.
7
+ # The rake tasks in lib/tasks/performance.rake load this module
8
+ # through `require "xmi"` and reference Xmi::Performance::*
9
+ # constants.
10
+ module Performance
11
+ autoload :Helpers, "xmi/performance/helpers"
12
+ autoload :Comparator, "xmi/performance/comparator"
13
+ autoload :Runner, "xmi/performance/runner"
14
+ end
15
+ end
@@ -5,7 +5,7 @@ module Xmi
5
5
  # UML `<packagedElement xmi:type="uml:AssociationClass">`. An
6
6
  # association that is also a class (UML 2.5 §11.4).
7
7
  #
8
- # Phase A of TODO.next/01: subclass is a type tag on
8
+ # Phase A: subclass is a type tag on
9
9
  # PackagedElement. The union-bag attribute set is inherited
10
10
  # unchanged so existing consumers keep working. Phase B
11
11
  # (narrowing attrs to the subclass) is future work.