decision_agent 0.3.0 → 1.0.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -0,0 +1,1002 @@
1
+ require_relative "errors"
2
+
3
+ module DecisionAgent
4
+ module Simulation
5
+ # Analyzer for what-if scenario simulation
6
+ # rubocop:disable Metrics/ClassLength
7
+ class WhatIfAnalyzer
8
+ attr_reader :agent, :version_manager
9
+
10
+ def initialize(agent:, version_manager: nil)
11
+ @agent = agent
12
+ @version_manager = version_manager || Versioning::VersionManager.new
13
+ end
14
+
15
+ # Analyze multiple scenarios
16
+ # @param scenarios [Array<Hash>] Array of context hashes to simulate
17
+ # @param rule_version [String, Integer, Hash, nil] Optional rule version to use
18
+ # @param options [Hash] Analysis options
19
+ # - :parallel [Boolean] Use parallel execution (default: true)
20
+ # - :thread_count [Integer] Number of threads (default: 4)
21
+ # - :sensitivity_analysis [Boolean] Perform sensitivity analysis (default: false)
22
+ # @return [Hash] Analysis results with decision outcomes
23
+ def analyze(scenarios:, rule_version: nil, options: {})
24
+ options = {
25
+ parallel: true,
26
+ thread_count: 4,
27
+ sensitivity_analysis: false
28
+ }.merge(options)
29
+
30
+ analysis_agent = build_agent_from_version(rule_version) if rule_version
31
+ analysis_agent ||= @agent
32
+
33
+ results = execute_scenarios(scenarios, analysis_agent, options)
34
+
35
+ report = {
36
+ scenarios: results,
37
+ total_scenarios: scenarios.size,
38
+ decision_distribution: results.group_by { |r| r[:decision] }.transform_values(&:count),
39
+ average_confidence: calculate_average_confidence(results)
40
+ }
41
+
42
+ report[:sensitivity] = perform_sensitivity_analysis(scenarios, analysis_agent) if options[:sensitivity_analysis]
43
+
44
+ report
45
+ end
46
+
47
+ # Perform sensitivity analysis to identify which inputs affect decisions most
48
+ # @param base_scenario [Hash] Base context to vary
49
+ # @param variations [Hash] Hash of field => [values] to test
50
+ # @param rule_version [String, Integer, Hash, nil] Optional rule version
51
+ # @return [Hash] Sensitivity analysis results
52
+ def sensitivity_analysis(base_scenario:, variations:, rule_version: nil)
53
+ analysis_agent = build_agent_from_version(rule_version) if rule_version
54
+ analysis_agent ||= @agent
55
+
56
+ base_decision = analysis_agent.decide(context: Context.new(base_scenario))
57
+ base_decision_value = base_decision.decision
58
+
59
+ sensitivity_results = analyze_field_variations(
60
+ base_scenario, variations, base_decision_value, analysis_agent
61
+ )
62
+
63
+ build_sensitivity_result(
64
+ base_scenario, base_decision_value, base_decision.confidence, sensitivity_results
65
+ )
66
+ end
67
+
68
+ def analyze_field_variations(base_scenario, variations, base_decision_value, analysis_agent)
69
+ variations.each_with_object({}) do |(field, values), results|
70
+ field_results = test_field_values(base_scenario, field, values, base_decision_value, analysis_agent)
71
+ results[field] = build_field_sensitivity(field_results, values.size, base_decision_value)
72
+ end
73
+ end
74
+
75
+ def test_field_values(base_scenario, field, values, base_decision_value, analysis_agent)
76
+ values.map do |value|
77
+ modified_scenario = base_scenario.dup
78
+ set_nested_value(modified_scenario, field, value)
79
+ decision = analysis_agent.decide(context: Context.new(modified_scenario))
80
+ build_field_result(value, decision, base_decision_value)
81
+ end
82
+ end
83
+
84
+ def build_field_result(value, decision, base_decision_value)
85
+ {
86
+ value: value,
87
+ decision: decision.decision,
88
+ confidence: decision.confidence,
89
+ changed: decision.decision != base_decision_value
90
+ }
91
+ end
92
+
93
+ def build_field_sensitivity(field_results, values_size, base_decision_value)
94
+ changed_count = field_results.count { |r| r[:changed] }
95
+ {
96
+ impact: changed_count.to_f / values_size,
97
+ results: field_results,
98
+ base_decision: base_decision_value
99
+ }
100
+ end
101
+
102
+ def build_sensitivity_result(base_scenario, base_decision_value, base_confidence, sensitivity_results)
103
+ {
104
+ base_scenario: base_scenario,
105
+ base_decision: base_decision_value,
106
+ base_confidence: base_confidence,
107
+ field_sensitivity: sensitivity_results,
108
+ most_sensitive_fields: sensitivity_results.sort_by { |_k, v| -v[:impact] }.to_h.keys
109
+ }
110
+ end
111
+
112
+ # Visualize decision boundaries for 1D or 2D parameter spaces
113
+ # @param base_scenario [Hash] Base context with fixed parameter values
114
+ # @param parameters [Hash] Hash of parameter_name => {min, max, steps} for 1D or 2 parameters for 2D
115
+ # @param rule_version [String, Integer, Hash, nil] Optional rule version to use
116
+ # @param options [Hash] Visualization options
117
+ # - :output_format [String] 'data', 'html', 'json' (default: 'data')
118
+ # - :resolution [Integer] Number of steps for grid generation (default: 50 for 1D, 20 for 2D)
119
+ # @return [Hash] Decision boundary data or visualization output
120
+ def visualize_decision_boundaries(base_scenario:, parameters:, rule_version: nil, options: {})
121
+ options = {
122
+ output_format: "data",
123
+ resolution: nil
124
+ }.merge(options)
125
+
126
+ analysis_agent = build_agent_from_version(rule_version) if rule_version
127
+ analysis_agent ||= @agent
128
+
129
+ # Validate parameters
130
+ param_keys = parameters.keys
131
+ raise ArgumentError, "Must specify 1 or 2 parameters for visualization" if param_keys.empty? || param_keys.size > 2
132
+
133
+ # Set default resolution
134
+ resolution = options[:resolution] || (param_keys.size == 1 ? 100 : 30)
135
+
136
+ if param_keys.size == 1
137
+ visualize_1d_boundary(base_scenario, param_keys.first, parameters[param_keys.first], analysis_agent, options)
138
+ else
139
+ config = {
140
+ base_scenario: base_scenario,
141
+ param1_name: param_keys[0],
142
+ param2_name: param_keys[1],
143
+ param1_config: parameters[param_keys[0]],
144
+ param2_config: parameters[param_keys[1]],
145
+ analysis_agent: analysis_agent,
146
+ resolution: resolution,
147
+ output_options: options
148
+ }
149
+ visualize_2d_boundary(config)
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def build_agent_from_version(version)
156
+ version_hash = resolve_version(version)
157
+ evaluators = build_evaluators_from_version(version_hash)
158
+ Agent.new(
159
+ evaluators: evaluators,
160
+ scoring_strategy: @agent.scoring_strategy,
161
+ audit_adapter: Audit::NullAdapter.new
162
+ )
163
+ end
164
+
165
+ def resolve_version(version)
166
+ case version
167
+ when String, Integer
168
+ version_data = @version_manager.get_version(version_id: version)
169
+ raise VersionComparisonError, "Version not found: #{version}" unless version_data
170
+
171
+ version_data
172
+ when Hash
173
+ version
174
+ else
175
+ raise VersionComparisonError, "Invalid version format: #{version.class}"
176
+ end
177
+ end
178
+
179
+ def build_evaluators_from_version(version)
180
+ content = version[:content] || version["content"]
181
+ return @agent.evaluators unless content
182
+
183
+ if content.is_a?(Hash) && content[:evaluators]
184
+ build_evaluators_from_config(content[:evaluators])
185
+ elsif content.is_a?(Hash) && (content[:rules] || content["rules"])
186
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
187
+ else
188
+ @agent.evaluators
189
+ end
190
+ end
191
+
192
+ def build_evaluators_from_config(configs)
193
+ Array(configs).map do |config|
194
+ case config[:type] || config["type"]
195
+ when "json_rule"
196
+ Evaluators::JsonRuleEvaluator.new(rules_json: config[:rules] || config["rules"])
197
+ when "dmn"
198
+ model = config[:model] || config["model"]
199
+ decision_id = config[:decision_id] || config["decision_id"]
200
+ Evaluators::DmnEvaluator.new(model: model, decision_id: decision_id)
201
+ else
202
+ raise VersionComparisonError, "Unknown evaluator type: #{config[:type]}"
203
+ end
204
+ end
205
+ end
206
+
207
+ def execute_scenarios(scenarios, analysis_agent, options)
208
+ results = []
209
+ mutex = Mutex.new
210
+
211
+ if options[:parallel] && scenarios.size > 1
212
+ execute_parallel(scenarios, analysis_agent, options, mutex) do |result|
213
+ mutex.synchronize { results << result }
214
+ end
215
+ else
216
+ scenarios.each do |scenario|
217
+ ctx = scenario.is_a?(Context) ? scenario : Context.new(scenario)
218
+ decision = analysis_agent.decide(context: ctx)
219
+ results << {
220
+ scenario: ctx.to_h,
221
+ decision: decision.decision,
222
+ confidence: decision.confidence,
223
+ explanations: decision.explanations
224
+ }
225
+ end
226
+ end
227
+
228
+ results
229
+ end
230
+
231
+ def execute_parallel(scenarios, analysis_agent, options, _mutex)
232
+ thread_count = [options[:thread_count], scenarios.size].min
233
+ queue = Queue.new
234
+ scenarios.each { |s| queue << s }
235
+
236
+ threads = Array.new(thread_count) do
237
+ Thread.new do
238
+ loop do
239
+ scenario = begin
240
+ queue.pop(true)
241
+ rescue StandardError
242
+ nil
243
+ end
244
+ break unless scenario
245
+
246
+ ctx = scenario.is_a?(Context) ? scenario : Context.new(scenario)
247
+ decision = analysis_agent.decide(context: ctx)
248
+ result = {
249
+ scenario: ctx.to_h,
250
+ decision: decision.decision,
251
+ confidence: decision.confidence,
252
+ explanations: decision.explanations
253
+ }
254
+ yield result
255
+ end
256
+ end
257
+ end
258
+
259
+ threads.each(&:join)
260
+ end
261
+
262
+ def calculate_average_confidence(results)
263
+ confidences = results.map { |r| r[:confidence] }.compact
264
+ confidences.any? ? confidences.sum / confidences.size : 0
265
+ end
266
+
267
+ def perform_sensitivity_analysis(scenarios, analysis_agent)
268
+ # Identify numeric fields that vary across scenarios
269
+ numeric_fields = identify_numeric_fields(scenarios)
270
+ return {} if numeric_fields.empty?
271
+
272
+ sensitivity = {}
273
+ numeric_fields.each do |field|
274
+ values = scenarios.map { |s| get_nested_value(s, field) }.compact.uniq
275
+ next if values.size < 2
276
+
277
+ # Test impact of varying this field
278
+ base_scenario = scenarios.first.dup
279
+ field_sensitivity = test_field_impact(base_scenario, field, values, analysis_agent)
280
+ sensitivity[field] = field_sensitivity if field_sensitivity
281
+ end
282
+
283
+ sensitivity
284
+ end
285
+
286
+ def identify_numeric_fields(scenarios)
287
+ return [] if scenarios.empty?
288
+
289
+ all_keys = scenarios.flat_map { |s| extract_keys(s) }.uniq
290
+ numeric_keys = []
291
+
292
+ all_keys.each do |key|
293
+ values = scenarios.map { |s| get_nested_value(s, key) }.compact
294
+ numeric_keys << key if values.all? { |v| v.is_a?(Numeric) }
295
+ end
296
+
297
+ numeric_keys
298
+ end
299
+
300
+ def extract_keys(hash, prefix = nil)
301
+ keys = []
302
+ hash.each do |k, v|
303
+ full_key = prefix ? "#{prefix}.#{k}" : k.to_s
304
+ if v.is_a?(Hash)
305
+ keys.concat(extract_keys(v, full_key))
306
+ else
307
+ keys << full_key
308
+ end
309
+ end
310
+ keys
311
+ end
312
+
313
+ def test_field_impact(base_scenario, field, values, analysis_agent)
314
+ base_decision = analysis_agent.decide(context: Context.new(base_scenario))
315
+ base_decision_value = base_decision.decision
316
+
317
+ changed_count = 0
318
+ values.each do |value|
319
+ modified = base_scenario.dup
320
+ set_nested_value(modified, field, value)
321
+ decision = analysis_agent.decide(context: Context.new(modified))
322
+ changed_count += 1 if decision.decision != base_decision_value
323
+ end
324
+
325
+ {
326
+ impact: changed_count.to_f / values.size,
327
+ values_tested: values.size,
328
+ decisions_changed: changed_count
329
+ }
330
+ end
331
+
332
+ def get_nested_value(hash, key)
333
+ keys = key.to_s.split(".")
334
+ keys.reduce(hash) do |h, k|
335
+ return nil unless h.is_a?(Hash)
336
+
337
+ h[k.to_sym] || h[k.to_s]
338
+ end
339
+ end
340
+
341
+ def set_nested_value(hash, key, value)
342
+ keys = key.to_s.split(".")
343
+ last_key = keys.pop
344
+ target = keys.reduce(hash) do |h, k|
345
+ h[k.to_sym] ||= {}
346
+ end
347
+ target[last_key.to_sym] = value
348
+ end
349
+
350
+ # Generate 1D decision boundary visualization
351
+ def visualize_1d_boundary(base_scenario, param_name, param_config, analysis_agent, options)
352
+ min, max, steps = extract_1d_params(param_config)
353
+ points = generate_1d_points(base_scenario, param_name, min, max, steps, analysis_agent)
354
+ boundaries = identify_1d_boundaries(points)
355
+ result = build_1d_result(param_name, min, max, points, boundaries)
356
+
357
+ format_visualization_output(result, options)
358
+ end
359
+
360
+ def extract_1d_params(param_config)
361
+ min = param_config[:min] || param_config["min"]
362
+ max = param_config[:max] || param_config["max"]
363
+ steps = param_config[:steps] || param_config["steps"] || 100
364
+
365
+ raise ArgumentError, "Parameter config must include :min and :max" unless min && max
366
+
367
+ [min, max, steps]
368
+ end
369
+
370
+ def generate_1d_points(base_scenario, param_name, min, max, steps, analysis_agent)
371
+ step_size = (max - min).to_f / steps
372
+ (0..steps).each_with_object([]) do |i, points|
373
+ value = min + (step_size * i)
374
+ point = evaluate_1d_point(base_scenario, param_name, value, analysis_agent)
375
+ points << point.merge(parameter: param_name, value: value)
376
+ end
377
+ end
378
+
379
+ def evaluate_1d_point(base_scenario, param_name, value, analysis_agent)
380
+ modified_scenario = base_scenario.dup
381
+ set_nested_value(modified_scenario, param_name, value)
382
+
383
+ decision = analysis_agent.decide(context: Context.new(modified_scenario))
384
+ { decision: decision.decision, confidence: decision.confidence }
385
+ rescue DecisionAgent::NoEvaluationsError
386
+ { decision: nil, confidence: 0.0 }
387
+ end
388
+
389
+ def identify_1d_boundaries(points)
390
+ points.each_cons(2).with_object([]) do |(p1, p2), boundaries|
391
+ next unless p1[:decision] != p2[:decision]
392
+
393
+ boundary_value = p1[:value] + ((p2[:value] - p1[:value]) / 2.0)
394
+ boundaries << {
395
+ value: boundary_value,
396
+ decision_from: p1[:decision],
397
+ decision_to: p2[:decision],
398
+ confidence_from: p1[:confidence],
399
+ confidence_to: p2[:confidence]
400
+ }
401
+ end
402
+ end
403
+
404
+ def build_1d_result(param_name, min, max, points, boundaries)
405
+ {
406
+ type: "1d_boundary",
407
+ parameter: param_name,
408
+ range: { min: min, max: max },
409
+ points: points,
410
+ boundaries: boundaries,
411
+ decision_distribution: points.any? ? points.group_by { |p| p[:decision] }.transform_values(&:count) : {}
412
+ }
413
+ end
414
+
415
+ # Generate 2D decision boundary visualization
416
+ def visualize_2d_boundary(config)
417
+ params = extract_2d_params(config[:param1_config], config[:param2_config])
418
+ grid_config = GridConfig.build(
419
+ base_scenario: config[:base_scenario],
420
+ param1_name: config[:param1_name],
421
+ param2_name: config[:param2_name],
422
+ params: params,
423
+ resolution: config[:resolution],
424
+ analysis_agent: config[:analysis_agent]
425
+ )
426
+ grid = generate_2d_grid(grid_config)
427
+ boundaries = identify_2d_boundaries(grid, config[:resolution])
428
+ result = build_2d_result(grid_config, grid, boundaries)
429
+
430
+ format_visualization_output(result, config[:output_options])
431
+ end
432
+
433
+ # Configuration object for 2D grid generation
434
+ class GridConfig
435
+ attr_reader :base_scenario, :param1_name, :param2_name, :min1, :max1, :min2, :max2, :resolution, :analysis_agent
436
+
437
+ def self.build(base_scenario:, param1_name:, param2_name:, params:, resolution:, analysis_agent:)
438
+ config_hash = {
439
+ base_scenario: base_scenario,
440
+ param1_name: param1_name,
441
+ param2_name: param2_name,
442
+ min1: params[:min1],
443
+ max1: params[:max1],
444
+ min2: params[:min2],
445
+ max2: params[:max2],
446
+ resolution: resolution,
447
+ analysis_agent: analysis_agent
448
+ }
449
+ new(config_hash)
450
+ end
451
+
452
+ private
453
+
454
+ def initialize(config)
455
+ @base_scenario = config[:base_scenario]
456
+ @param1_name = config[:param1_name]
457
+ @param2_name = config[:param2_name]
458
+ @min1 = config[:min1]
459
+ @max1 = config[:max1]
460
+ @min2 = config[:min2]
461
+ @max2 = config[:max2]
462
+ @resolution = config[:resolution]
463
+ @analysis_agent = config[:analysis_agent]
464
+ end
465
+ end
466
+
467
+ def extract_2d_params(param1_config, param2_config)
468
+ min1 = param1_config[:min] || param1_config["min"]
469
+ max1 = param1_config[:max] || param1_config["max"]
470
+ min2 = param2_config[:min] || param2_config["min"]
471
+ max2 = param2_config[:max] || param2_config["max"]
472
+
473
+ raise ArgumentError, "Parameter configs must include :min and :max" unless min1 && max1 && min2 && max2
474
+
475
+ { min1: min1, max1: max1, min2: min2, max2: max2 }
476
+ end
477
+
478
+ def generate_2d_grid(grid_config)
479
+ step1 = (grid_config.max1 - grid_config.min1).to_f / grid_config.resolution
480
+ step2 = (grid_config.max2 - grid_config.min2).to_f / grid_config.resolution
481
+
482
+ (0..grid_config.resolution).each_with_object([]) do |i, grid|
483
+ value1 = grid_config.min1 + (step1 * i)
484
+ row = generate_2d_row(grid_config, value1, min2: grid_config.min2, step2: step2)
485
+ grid << row
486
+ end
487
+ end
488
+
489
+ def generate_2d_row(grid_config, value1, min2:, step2:)
490
+ (0..grid_config.resolution).each_with_object([]) do |j, row|
491
+ value2 = min2 + (step2 * j)
492
+ point = evaluate_2d_point(grid_config, value1, value2)
493
+ row << point.merge(param1: value1, param2: value2)
494
+ end
495
+ end
496
+
497
+ def evaluate_2d_point(grid_config, value1, value2)
498
+ modified_scenario = grid_config.base_scenario.dup
499
+ set_nested_value(modified_scenario, grid_config.param1_name, value1)
500
+ set_nested_value(modified_scenario, grid_config.param2_name, value2)
501
+
502
+ decision = grid_config.analysis_agent.decide(context: Context.new(modified_scenario))
503
+ { decision: decision.decision, confidence: decision.confidence }
504
+ rescue DecisionAgent::NoEvaluationsError
505
+ { decision: nil, confidence: 0.0 }
506
+ end
507
+
508
+ def build_2d_result(grid_config, grid, boundaries)
509
+ decision_counts = grid.flatten.group_by { |p| p[:decision] }.transform_values(&:count)
510
+
511
+ {
512
+ type: "2d_boundary",
513
+ parameter1: grid_config.param1_name,
514
+ parameter2: grid_config.param2_name,
515
+ range1: { min: grid_config.min1, max: grid_config.max1 },
516
+ range2: { min: grid_config.min2, max: grid_config.max2 },
517
+ resolution: grid_config.resolution,
518
+ grid: grid,
519
+ boundaries: boundaries,
520
+ decision_distribution: decision_counts
521
+ }
522
+ end
523
+
524
+ # Identify boundary lines in 2D grid where decisions change
525
+ def identify_2d_boundaries(grid, resolution)
526
+ find_vertical_boundaries(grid, resolution) + find_horizontal_boundaries(grid, resolution)
527
+ end
528
+
529
+ def find_vertical_boundaries(grid, resolution)
530
+ (0..(resolution - 1)).each_with_object([]) do |i, boundaries|
531
+ (0..resolution).each do |j|
532
+ next unless j < resolution && grid[i][j][:decision] != grid[i][j + 1][:decision]
533
+
534
+ boundaries << {
535
+ type: "vertical",
536
+ row: i,
537
+ col: j,
538
+ decision_left: grid[i][j][:decision],
539
+ decision_right: grid[i][j + 1][:decision],
540
+ param1: grid[i][j][:param1],
541
+ param2_left: grid[i][j][:param2],
542
+ param2_right: grid[i][j + 1][:param2]
543
+ }
544
+ end
545
+ end
546
+ end
547
+
548
+ def find_horizontal_boundaries(grid, resolution)
549
+ (0..resolution).each_with_object([]) do |i, boundaries|
550
+ (0..(resolution - 1)).each do |j|
551
+ next unless i < resolution && grid[i][j][:decision] != grid[i + 1][j][:decision]
552
+
553
+ boundaries << {
554
+ type: "horizontal",
555
+ row: i,
556
+ col: j,
557
+ decision_top: grid[i][j][:decision],
558
+ decision_bottom: grid[i + 1][j][:decision],
559
+ param1_top: grid[i][j][:param1],
560
+ param1_bottom: grid[i + 1][j][:param1],
561
+ param2: grid[i][j][:param2]
562
+ }
563
+ end
564
+ end
565
+ end
566
+
567
+ # Format visualization output based on requested format
568
+ def format_visualization_output(data, options)
569
+ case options[:output_format]
570
+ when "html"
571
+ generate_html_visualization(data)
572
+ when "json"
573
+ require "json"
574
+ data.to_json
575
+ when "data"
576
+ data
577
+ else
578
+ data
579
+ end
580
+ end
581
+
582
+ # Generate HTML visualization with SVG/Canvas plotting
583
+ def generate_html_visualization(data)
584
+ <<~HTML
585
+ <!DOCTYPE html>
586
+ <html>
587
+ <head>
588
+ <title>Decision Boundary Visualization</title>
589
+ <style>
590
+ body {
591
+ font-family: Arial, sans-serif;
592
+ margin: 20px;
593
+ background: #f5f5f5;
594
+ }
595
+ .container {
596
+ max-width: 1200px;
597
+ margin: 0 auto;
598
+ background: white;
599
+ padding: 20px;
600
+ border-radius: 8px;
601
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
602
+ }
603
+ h1 { color: #333; }
604
+ .info {
605
+ background: #f0f0f0;
606
+ padding: 15px;
607
+ border-radius: 4px;
608
+ margin: 20px 0;
609
+ }
610
+ .chart-container {
611
+ margin: 20px 0;
612
+ text-align: center;
613
+ }
614
+ svg {
615
+ border: 1px solid #ddd;
616
+ background: white;
617
+ }
618
+ .legend {
619
+ display: flex;
620
+ justify-content: center;
621
+ gap: 20px;
622
+ margin: 20px 0;
623
+ flex-wrap: wrap;
624
+ }
625
+ .legend-item {
626
+ display: flex;
627
+ align-items: center;
628
+ gap: 8px;
629
+ }
630
+ .legend-color {
631
+ width: 20px;
632
+ height: 20px;
633
+ border: 1px solid #333;
634
+ }
635
+ </style>
636
+ </head>
637
+ <body>
638
+ <div class="container">
639
+ <h1>Decision Boundary Visualization</h1>
640
+ <div class="info">
641
+ #{generate_info_html(data)}
642
+ </div>
643
+ <div class="chart-container">
644
+ #{generate_chart_html(data)}
645
+ </div>
646
+ <div class="legend">
647
+ #{generate_legend_html(data)}
648
+ </div>
649
+ </div>
650
+ </body>
651
+ </html>
652
+ HTML
653
+ end
654
+
655
+ def generate_info_html(data)
656
+ if data[:type] == "1d_boundary"
657
+ generate_1d_info(data)
658
+ elsif data[:type] == "2d_boundary"
659
+ generate_2d_info(data)
660
+ else
661
+ ""
662
+ end
663
+ end
664
+
665
+ def generate_1d_info(data)
666
+ [
667
+ "<strong>Type:</strong> 1D Boundary Visualization",
668
+ "<strong>Parameter:</strong> #{data[:parameter]}",
669
+ "<strong>Range:</strong> #{data[:range][:min]} to #{data[:range][:max]}",
670
+ "<strong>Boundaries Found:</strong> #{data[:boundaries].size}",
671
+ "<strong>Decision Distribution:</strong> #{format_decision_distribution(data[:decision_distribution])}"
672
+ ].join("<br>")
673
+ end
674
+
675
+ def generate_2d_info(data)
676
+ [
677
+ "<strong>Type:</strong> 2D Boundary Visualization",
678
+ "<strong>Parameters:</strong> #{data[:parameter1]} vs #{data[:parameter2]}",
679
+ "<strong>Range 1:</strong> #{data[:range1][:min]} to #{data[:range1][:max]}",
680
+ "<strong>Range 2:</strong> #{data[:range2][:min]} to #{data[:range2][:max]}",
681
+ "<strong>Resolution:</strong> #{data[:resolution]}x#{data[:resolution]}",
682
+ "<strong>Boundaries Found:</strong> #{data[:boundaries].size}",
683
+ "<strong>Decision Distribution:</strong> #{format_decision_distribution(data[:decision_distribution])}"
684
+ ].join("<br>")
685
+ end
686
+
687
+ def format_decision_distribution(distribution)
688
+ distribution.map { |k, v| "#{k}: #{v}" }.join(", ")
689
+ end
690
+
691
+ def generate_chart_html(data)
692
+ if data[:type] == "1d_boundary"
693
+ generate_1d_chart_svg(data)
694
+ elsif data[:type] == "2d_boundary"
695
+ generate_2d_chart_svg(data)
696
+ else
697
+ "<p>Unsupported visualization type</p>"
698
+ end
699
+ end
700
+
701
+ def generate_1d_chart_svg(data)
702
+ chart_config = setup_1d_chart(data)
703
+ svg = "<svg width='#{chart_config[:width]}' height='#{chart_config[:height]}'>"
704
+
705
+ svg << draw_1d_regions(data, chart_config)
706
+ svg << draw_1d_boundaries(data, chart_config)
707
+ svg << draw_1d_axes(data, chart_config)
708
+
709
+ svg << "</svg>"
710
+ end
711
+
712
+ def setup_1d_chart(data)
713
+ width = 800
714
+ height = 400
715
+ margin = { top: 40, right: 40, bottom: 60, left: 60 }
716
+ chart_width = width - margin[:left] - margin[:right]
717
+ chart_height = height - margin[:top] - margin[:bottom]
718
+
719
+ decisions = data[:points].map { |p| p[:decision] }.uniq
720
+ colors = ["#58a6ff", "#3fb950", "#d29922", "#da3633", "#bc8cff", "#ff79c6", "#bd93f9"]
721
+ decision_colors = decisions.each_with_index.to_h { |d, i| [d, colors[i % colors.size]] }
722
+
723
+ min_val = data[:range][:min]
724
+ max_val = data[:range][:max]
725
+ x_scale = chart_width.to_f / (max_val - min_val)
726
+
727
+ {
728
+ width: width,
729
+ height: height,
730
+ margin: margin,
731
+ chart_width: chart_width,
732
+ chart_height: chart_height,
733
+ decision_colors: decision_colors,
734
+ min_val: min_val,
735
+ max_val: max_val,
736
+ x_scale: x_scale
737
+ }
738
+ end
739
+
740
+ def draw_1d_regions(data, config)
741
+ svg = ""
742
+ current_decision = nil
743
+ region_start = nil
744
+
745
+ data[:points].each do |point|
746
+ next unless point[:decision] != current_decision
747
+
748
+ if current_decision && region_start
749
+ region_end = config[:margin][:left] + ((point[:value] - config[:min_val]) * config[:x_scale])
750
+ color = config[:decision_colors][current_decision]
751
+ svg << build_region_rect(region_start, region_end, config, color)
752
+ end
753
+
754
+ current_decision = point[:decision]
755
+ region_start = config[:margin][:left] + ((point[:value] - config[:min_val]) * config[:x_scale])
756
+ end
757
+
758
+ if current_decision && region_start
759
+ region_end = config[:margin][:left] + config[:chart_width]
760
+ color = config[:decision_colors][current_decision]
761
+ svg << build_region_rect(region_start, region_end, config, color)
762
+ end
763
+
764
+ svg
765
+ end
766
+
767
+ def build_region_rect(region_start, region_end, config, color)
768
+ "<rect x='#{region_start}' y='#{config[:margin][:top]}' " \
769
+ "width='#{region_end - region_start}' height='#{config[:chart_height]}' " \
770
+ "fill='#{color}' opacity='0.3'/>"
771
+ end
772
+
773
+ def draw_1d_boundaries(data, config)
774
+ data[:boundaries].each_with_object("") do |boundary, svg|
775
+ x = config[:margin][:left] + ((boundary[:value] - config[:min_val]) * config[:x_scale])
776
+ svg << "<line x1='#{x}' y1='#{config[:margin][:top]}' x2='#{x}' " \
777
+ "y2='#{config[:margin][:top] + config[:chart_height]}' stroke='#000' " \
778
+ "stroke-width='2' stroke-dasharray='5,5'/>"
779
+ end
780
+ end
781
+
782
+ def draw_1d_axes(data, config)
783
+ svg = draw_1d_axis_lines(config)
784
+ svg << draw_1d_axis_label(data, config)
785
+ svg << draw_1d_tick_marks(config)
786
+ end
787
+
788
+ def draw_1d_axis_lines(config)
789
+ margin = config[:margin]
790
+ chart_width = config[:chart_width]
791
+ chart_height = config[:chart_height]
792
+
793
+ "<line x1='#{margin[:left]}' y1='#{margin[:top] + chart_height}' " \
794
+ "x2='#{margin[:left] + chart_width}' y2='#{margin[:top] + chart_height}' " \
795
+ "stroke='#333' stroke-width='2'/>" \
796
+ "<line x1='#{margin[:left]}' y1='#{margin[:top]}' " \
797
+ "x2='#{margin[:left]}' y2='#{margin[:top] + chart_height}' " \
798
+ "stroke='#333' stroke-width='2'/>"
799
+ end
800
+
801
+ def draw_1d_axis_label(data, config)
802
+ margin = config[:margin]
803
+ chart_width = config[:chart_width]
804
+
805
+ "<text x='#{margin[:left] + (chart_width / 2)}' y='#{config[:height] - 10}' " \
806
+ "text-anchor='middle' font-size='14' fill='#333'>#{data[:parameter]}</text>"
807
+ end
808
+
809
+ def draw_1d_tick_marks(config)
810
+ margin = config[:margin]
811
+ chart_height = config[:chart_height]
812
+
813
+ (0..4).each_with_object("") do |i, svg|
814
+ value = config[:min_val] + ((config[:max_val] - config[:min_val]) * i / 4.0)
815
+ x = margin[:left] + ((value - config[:min_val]) * config[:x_scale])
816
+ svg << "<line x1='#{x}' y1='#{margin[:top] + chart_height}' " \
817
+ "x2='#{x}' y2='#{margin[:top] + chart_height + 5}' " \
818
+ "stroke='#333' stroke-width='1'/>"
819
+ svg << "<text x='#{x}' y='#{config[:height] - 20}' text-anchor='middle' font-size='12' fill='#666'>#{value.round(2)}</text>"
820
+ end
821
+ end
822
+
823
+ def generate_2d_chart_svg(data)
824
+ chart_config = setup_2d_chart(data)
825
+ svg = "<svg width='#{chart_config[:width]}' height='#{chart_config[:height]}'>"
826
+
827
+ svg << draw_2d_grid(data, chart_config)
828
+ svg << draw_2d_boundaries(data, chart_config)
829
+ svg << draw_2d_axes(data, chart_config)
830
+
831
+ svg << "</svg>"
832
+ end
833
+
834
+ def setup_2d_chart(data)
835
+ dimensions = calculate_chart_dimensions
836
+ decision_colors = build_decision_colors(data)
837
+ scales = calculate_2d_scales(data, dimensions)
838
+
839
+ dimensions.merge(decision_colors).merge(scales)
840
+ end
841
+
842
+ def calculate_chart_dimensions
843
+ width = 600
844
+ height = 600
845
+ margin = { top: 40, right: 40, bottom: 60, left: 60 }
846
+ chart_width = width - margin[:left] - margin[:right]
847
+ chart_height = height - margin[:top] - margin[:bottom]
848
+
849
+ {
850
+ width: width,
851
+ height: height,
852
+ margin: margin,
853
+ chart_width: chart_width,
854
+ chart_height: chart_height
855
+ }
856
+ end
857
+
858
+ def build_decision_colors(data)
859
+ decisions = data[:grid].flatten.map { |p| p[:decision] }.uniq
860
+ colors = ["#58a6ff", "#3fb950", "#d29922", "#da3633", "#bc8cff", "#ff79c6", "#bd93f9"]
861
+ { decision_colors: decisions.each_with_index.to_h { |d, i| [d, colors[i % colors.size]] } }
862
+ end
863
+
864
+ def calculate_2d_scales(data, dimensions)
865
+ min1 = data[:range1][:min]
866
+ max1 = data[:range1][:max]
867
+ min2 = data[:range2][:min]
868
+ max2 = data[:range2][:max]
869
+
870
+ {
871
+ min1: min1,
872
+ max1: max1,
873
+ min2: min2,
874
+ max2: max2,
875
+ x_scale: dimensions[:chart_width].to_f / (max1 - min1),
876
+ y_scale: dimensions[:chart_height].to_f / (max2 - min2),
877
+ cell_width: dimensions[:chart_width].to_f / data[:resolution],
878
+ cell_height: dimensions[:chart_height].to_f / data[:resolution]
879
+ }
880
+ end
881
+
882
+ def draw_2d_grid(data, config)
883
+ data[:grid].each_with_index.with_object("") do |(row, i), svg|
884
+ row.each_with_index do |point, j|
885
+ x = config[:margin][:left] + (j * config[:cell_width])
886
+ y = config[:margin][:top] + (i * config[:cell_height])
887
+ color = config[:decision_colors][point[:decision]]
888
+ svg << "<rect x='#{x}' y='#{y}' width='#{config[:cell_width].ceil}' " \
889
+ "height='#{config[:cell_height].ceil}' fill='#{color}' opacity='0.6' " \
890
+ "stroke='#ddd' stroke-width='0.5'/>"
891
+ end
892
+ end
893
+ end
894
+
895
+ def draw_2d_boundaries(data, config)
896
+ sampled_boundaries = data[:boundaries].sample([data[:boundaries].size, 500].min)
897
+ sampled_boundaries.each_with_object("") do |boundary, svg|
898
+ svg << draw_boundary_line(boundary, config)
899
+ end
900
+ end
901
+
902
+ def draw_boundary_line(boundary, config)
903
+ case boundary[:type]
904
+ when "vertical"
905
+ draw_vertical_boundary(boundary, config)
906
+ when "horizontal"
907
+ draw_horizontal_boundary(boundary, config)
908
+ else
909
+ ""
910
+ end
911
+ end
912
+
913
+ def draw_vertical_boundary(boundary, config)
914
+ x = config[:margin][:left] + (boundary[:col] * config[:cell_width]) + config[:cell_width]
915
+ y1 = config[:margin][:top] + (boundary[:row] * config[:cell_height])
916
+ y2 = y1 + config[:cell_height]
917
+ "<line x1='#{x}' y1='#{y1}' x2='#{x}' y2='#{y2}' stroke='#000' stroke-width='2'/>"
918
+ end
919
+
920
+ def draw_horizontal_boundary(boundary, config)
921
+ x1 = config[:margin][:left] + (boundary[:col] * config[:cell_width])
922
+ x2 = x1 + config[:cell_width]
923
+ y = config[:margin][:top] + (boundary[:row] * config[:cell_height]) + config[:cell_height]
924
+ "<line x1='#{x1}' y1='#{y}' x2='#{x2}' y2='#{y}' stroke='#000' stroke-width='2'/>"
925
+ end
926
+
927
+ def draw_2d_axes(data, config)
928
+ svg = draw_2d_axis_lines(config)
929
+ svg << draw_2d_axis_labels(data, config)
930
+ svg << draw_2d_tick_marks(config)
931
+ end
932
+
933
+ def draw_2d_axis_lines(config)
934
+ margin = config[:margin]
935
+ chart_width = config[:chart_width]
936
+ chart_height = config[:chart_height]
937
+
938
+ "<line x1='#{margin[:left]}' y1='#{margin[:top] + chart_height}' " \
939
+ "x2='#{margin[:left] + chart_width}' y2='#{margin[:top] + chart_height}' " \
940
+ "stroke='#333' stroke-width='2'/>" \
941
+ "<line x1='#{margin[:left]}' y1='#{margin[:top]}' " \
942
+ "x2='#{margin[:left]}' y2='#{margin[:top] + chart_height}' " \
943
+ "stroke='#333' stroke-width='2'/>"
944
+ end
945
+
946
+ def draw_2d_axis_labels(data, config)
947
+ margin = config[:margin]
948
+ chart_width = config[:chart_width]
949
+ chart_height = config[:chart_height]
950
+
951
+ "<text x='#{margin[:left] + (chart_width / 2)}' y='#{config[:height] - 10}' " \
952
+ "text-anchor='middle' font-size='14' fill='#333'>#{data[:parameter1]}</text>" \
953
+ "<text x='15' y='#{margin[:top] + (chart_height / 2)}' " \
954
+ "text-anchor='middle' font-size='14' fill='#333' " \
955
+ "transform='rotate(-90, 15, #{margin[:top] + (chart_height / 2)})'>" \
956
+ "#{data[:parameter2]}</text>"
957
+ end
958
+
959
+ def draw_2d_tick_marks(config)
960
+ margin = config[:margin]
961
+ chart_height = config[:chart_height]
962
+
963
+ (0..4).each_with_object("") do |index, svg|
964
+ svg << draw_2d_x_tick(index, config, margin, chart_height)
965
+ svg << draw_2d_y_tick(index, config, margin)
966
+ end
967
+ end
968
+
969
+ def draw_2d_x_tick(index, config, margin, chart_height)
970
+ value1 = config[:min1] + ((config[:max1] - config[:min1]) * index / 4.0)
971
+ x = margin[:left] + ((value1 - config[:min1]) * config[:x_scale])
972
+ "<line x1='#{x}' y1='#{margin[:top] + chart_height}' " \
973
+ "x2='#{x}' y2='#{margin[:top] + chart_height + 5}' " \
974
+ "stroke='#333' stroke-width='1'/>" \
975
+ "<text x='#{x}' y='#{config[:height] - 20}' text-anchor='middle' font-size='10' fill='#666'>#{value1.round(2)}</text>"
976
+ end
977
+
978
+ def draw_2d_y_tick(index, config, margin)
979
+ value2 = config[:max2] - ((config[:max2] - config[:min2]) * index / 4.0)
980
+ y = margin[:top] + ((value2 - config[:min2]) * config[:y_scale])
981
+ "<line x1='#{margin[:left]}' y1='#{y}' x2='#{margin[:left] - 5}' y2='#{y}' stroke='#333' stroke-width='1'/>" \
982
+ "<text x='#{margin[:left] - 10}' y='#{y + 4}' text-anchor='end' font-size='10' fill='#666'>#{value2.round(2)}</text>"
983
+ end
984
+
985
+ def generate_legend_html(data)
986
+ decisions = if data[:type] == "1d_boundary"
987
+ data[:points].map { |p| p[:decision] }.uniq
988
+ else
989
+ data[:grid].flatten.map { |p| p[:decision] }.uniq
990
+ end
991
+
992
+ colors = ["#58a6ff", "#3fb950", "#d29922", "#da3633", "#bc8cff", "#ff79c6", "#bd93f9"]
993
+
994
+ decisions.map.with_index do |decision, i|
995
+ color = colors[i % colors.size]
996
+ "<div class='legend-item'><div class='legend-color' style='background: #{color};'></div><span>#{decision}</span></div>"
997
+ end.join
998
+ end
999
+ # rubocop:enable Metrics/ClassLength
1000
+ end
1001
+ end
1002
+ end