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