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,638 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module DecisionAgent
6
+ module Simulation
7
+ # Monte Carlo simulator for probabilistic decision outcomes
8
+ #
9
+ # Allows you to model input variables with probability distributions
10
+ # and run simulations to understand decision outcome probabilities.
11
+ #
12
+ # @example
13
+ # simulator = MonteCarloSimulator.new(agent: agent)
14
+ #
15
+ # # Define probabilistic inputs
16
+ # distributions = {
17
+ # credit_score: { type: :normal, mean: 650, stddev: 50 },
18
+ # amount: { type: :uniform, min: 50_000, max: 200_000 }
19
+ # }
20
+ #
21
+ # # Run simulation
22
+ # results = simulator.simulate(
23
+ # distributions: distributions,
24
+ # iterations: 10_000,
25
+ # base_context: { name: "John Doe" }
26
+ # )
27
+ #
28
+ # puts "Decision probabilities: #{results[:decision_probabilities]}"
29
+ # puts "Average confidence: #{results[:average_confidence]}"
30
+ # rubocop:disable Metrics/ClassLength
31
+ class MonteCarloSimulator
32
+ attr_reader :agent, :version_manager
33
+
34
+ def initialize(agent:, version_manager: nil)
35
+ @agent = agent
36
+ @version_manager = version_manager || Versioning::VersionManager.new
37
+ end
38
+
39
+ # Run Monte Carlo simulation with probabilistic input distributions
40
+ #
41
+ # @param distributions [Hash] Hash of field_name => distribution_config
42
+ # Distribution configs support:
43
+ # - { type: :normal, mean: Float, stddev: Float } - Normal distribution
44
+ # - { type: :uniform, min: Numeric, max: Numeric } - Uniform distribution
45
+ # - { type: :lognormal, mean: Float, stddev: Float } - Log-normal distribution
46
+ # - { type: :exponential, lambda: Float } - Exponential distribution
47
+ # - { type: :discrete, values: Array, probabilities: Array } - Discrete distribution
48
+ # - { type: :triangular, min: Numeric, mode: Numeric, max: Numeric } - Triangular distribution
49
+ # @param iterations [Integer] Number of Monte Carlo iterations (default: 10_000)
50
+ # @param base_context [Hash] Base context values that are fixed (not probabilistic)
51
+ # @param rule_version [String, Integer, Hash, nil] Optional rule version to use
52
+ # @param options [Hash] Simulation options
53
+ # - :parallel [Boolean] Use parallel execution (default: true)
54
+ # - :thread_count [Integer] Number of threads (default: 4)
55
+ # - :seed [Integer] Random seed for reproducibility (default: nil)
56
+ # - :confidence_level [Float] Confidence level for intervals (default: 0.95)
57
+ # @return [Hash] Simulation results with decision probabilities and statistics
58
+ def simulate(distributions:, iterations: 10_000, base_context: {}, rule_version: nil, options: {})
59
+ options = {
60
+ parallel: true,
61
+ thread_count: 4,
62
+ seed: nil,
63
+ confidence_level: 0.95
64
+ }.merge(options)
65
+
66
+ # Set random seed for reproducibility
67
+ srand(options[:seed]) if options[:seed]
68
+
69
+ # Validate distributions
70
+ validate_distributions!(distributions)
71
+
72
+ # Build agent from version if specified
73
+ analysis_agent = build_agent_from_version(rule_version) if rule_version
74
+ analysis_agent ||= @agent
75
+
76
+ # Run Monte Carlo iterations
77
+ results = run_iterations(
78
+ distributions: distributions,
79
+ base_context: base_context,
80
+ iterations: iterations,
81
+ agent: analysis_agent,
82
+ options: options
83
+ )
84
+
85
+ # Calculate statistics (pass requested iterations count)
86
+ calculate_statistics(results, options[:confidence_level], requested_iterations: iterations)
87
+ end
88
+
89
+ # Run sensitivity analysis using Monte Carlo simulation
90
+ # Varies one distribution parameter at a time to see its impact
91
+ #
92
+ # @param base_distributions [Hash] Base probabilistic input distributions
93
+ # @param sensitivity_params [Hash] Hash of field => parameter variations
94
+ # Example: { credit_score: { mean: [600, 650, 700], stddev: [40, 50, 60] } }
95
+ # @param iterations [Integer] Number of iterations per sensitivity test
96
+ # @param base_context [Hash] Base context values
97
+ # @param options [Hash] Simulation options
98
+ # @return [Hash] Sensitivity analysis results
99
+ def sensitivity_analysis(
100
+ base_distributions:,
101
+ sensitivity_params:,
102
+ iterations: 5_000,
103
+ base_context: {},
104
+ options: {}
105
+ )
106
+ options = {
107
+ parallel: true,
108
+ thread_count: 4,
109
+ seed: nil,
110
+ confidence_level: 0.95
111
+ }.merge(options)
112
+
113
+ srand(options[:seed]) if options[:seed]
114
+
115
+ sensitivity_results = analyze_sensitivity_params(
116
+ base_distributions, sensitivity_params, iterations, base_context, options
117
+ )
118
+
119
+ {
120
+ sensitivity_results: sensitivity_results,
121
+ base_distributions: base_distributions,
122
+ iterations_per_test: iterations
123
+ }
124
+ end
125
+
126
+ def analyze_sensitivity_params(base_distributions, sensitivity_params, iterations, base_context, options)
127
+ sensitivity_params.each_with_object({}) do |(field, param_variations), results|
128
+ results[field] = analyze_field_sensitivity(
129
+ base_distributions, field, param_variations, iterations, base_context, options
130
+ )
131
+ end
132
+ end
133
+
134
+ def analyze_field_sensitivity(base_distributions, field, param_variations, iterations, base_context, options)
135
+ param_variations.each_with_object({}) do |(param_name, param_values), field_results|
136
+ config = {
137
+ base_distributions: base_distributions,
138
+ field: field,
139
+ param_name: param_name,
140
+ param_values: param_values,
141
+ iterations: iterations,
142
+ base_context: base_context,
143
+ options: options
144
+ }
145
+ param_results = run_parameter_variations(config)
146
+ field_results[param_name] = build_parameter_result(param_name, param_values, param_results)
147
+ end
148
+ end
149
+
150
+ def run_parameter_variations(config)
151
+ config[:param_values].map do |param_value|
152
+ modified_distributions = create_modified_distribution(
153
+ config[:base_distributions], config[:field], config[:param_name], param_value
154
+ )
155
+ result = simulate(
156
+ distributions: modified_distributions,
157
+ iterations: config[:iterations],
158
+ base_context: config[:base_context],
159
+ options: config[:options].merge(parallel: false)
160
+ )
161
+ build_param_result(param_value, result)
162
+ end
163
+ end
164
+
165
+ def create_modified_distribution(base_distributions, field, param_name, param_value)
166
+ modified = base_distributions.dup
167
+ modified[field] = modified[field].dup
168
+ modified[field][param_name] = param_value
169
+ modified
170
+ end
171
+
172
+ def build_param_result(param_value, result)
173
+ {
174
+ param_value: param_value,
175
+ decision_probabilities: result[:decision_probabilities],
176
+ average_confidence: result[:average_confidence],
177
+ confidence_intervals: result[:confidence_intervals]
178
+ }
179
+ end
180
+
181
+ def build_parameter_result(param_name, param_values, param_results)
182
+ {
183
+ parameter: param_name,
184
+ values_tested: param_values,
185
+ results: param_results,
186
+ impact_analysis: analyze_parameter_impact(param_results)
187
+ }
188
+ end
189
+
190
+ private
191
+
192
+ def validate_distributions!(distributions)
193
+ distributions.each do |field, config|
194
+ raise ArgumentError, "Distribution config for #{field} must be a Hash" unless config.is_a?(Hash)
195
+ raise ArgumentError, "Distribution config for #{field} must include :type" unless config[:type] || config["type"]
196
+
197
+ type = config[:type] || config["type"]
198
+ validate_distribution_type!(field, type, config)
199
+ end
200
+ end
201
+
202
+ def validate_distribution_type!(field, type, config)
203
+ case type.to_sym
204
+ when :normal
205
+ validate_normal_distribution(field, config)
206
+ when :uniform
207
+ validate_uniform_distribution(field, config)
208
+ when :lognormal
209
+ validate_lognormal_distribution(field, config)
210
+ when :exponential
211
+ validate_exponential_distribution(field, config)
212
+ when :discrete
213
+ validate_discrete_distribution(field, config)
214
+ when :triangular
215
+ validate_triangular_distribution(field, config)
216
+ else
217
+ raise ArgumentError, "Unknown distribution type: #{type} for field #{field}"
218
+ end
219
+ end
220
+
221
+ def validate_normal_distribution(field, config)
222
+ return if (config[:mean] || config["mean"]) && (config[:stddev] || config["stddev"])
223
+
224
+ raise ArgumentError, "Normal distribution for #{field} requires :mean and :stddev"
225
+ end
226
+
227
+ def validate_uniform_distribution(field, config)
228
+ return if (config[:min] || config["min"]) && (config[:max] || config["max"])
229
+
230
+ raise ArgumentError, "Uniform distribution for #{field} requires :min and :max"
231
+ end
232
+
233
+ def validate_lognormal_distribution(field, config)
234
+ return if (config[:mean] || config["mean"]) && (config[:stddev] || config["stddev"])
235
+
236
+ raise ArgumentError, "Log-normal distribution for #{field} requires :mean and :stddev"
237
+ end
238
+
239
+ def validate_exponential_distribution(field, config)
240
+ return if config[:lambda] || config["lambda"]
241
+
242
+ raise ArgumentError, "Exponential distribution for #{field} requires :lambda"
243
+ end
244
+
245
+ def validate_discrete_distribution(field, config)
246
+ values = config[:values] || config["values"]
247
+ probs = config[:probabilities] || config["probabilities"]
248
+ raise ArgumentError, "Discrete distribution for #{field} requires :values and :probabilities" unless values && probs
249
+
250
+ raise ArgumentError, "Discrete distribution for #{field}: values and probabilities must have same length" unless values.size == probs.size
251
+
252
+ sum = probs.sum
253
+ return if (sum - 1.0).abs < 0.001
254
+
255
+ raise ArgumentError, "Discrete distribution for #{field}: probabilities must sum to 1.0 (got #{sum})"
256
+ end
257
+
258
+ def validate_triangular_distribution(field, config)
259
+ return if (config[:min] || config["min"]) && (config[:mode] || config["mode"]) && (config[:max] || config["max"])
260
+
261
+ raise ArgumentError, "Triangular distribution for #{field} requires :min, :mode, and :max"
262
+ end
263
+
264
+ def build_agent_from_version(version)
265
+ version_hash = resolve_version(version)
266
+ evaluators = build_evaluators_from_version(version_hash)
267
+ Agent.new(
268
+ evaluators: evaluators,
269
+ scoring_strategy: @agent.scoring_strategy,
270
+ audit_adapter: Audit::NullAdapter.new
271
+ )
272
+ end
273
+
274
+ def resolve_version(version)
275
+ case version
276
+ when String, Integer
277
+ version_data = @version_manager.get_version(version_id: version)
278
+ raise VersionComparisonError, "Version not found: #{version}" unless version_data
279
+
280
+ version_data
281
+ when Hash
282
+ version
283
+ else
284
+ raise VersionComparisonError, "Invalid version format: #{version.class}"
285
+ end
286
+ end
287
+
288
+ def build_evaluators_from_version(version)
289
+ content = version[:content] || version["content"]
290
+ return @agent.evaluators unless content
291
+
292
+ if content.is_a?(Hash) && content[:evaluators]
293
+ build_evaluators_from_config(content[:evaluators])
294
+ elsif content.is_a?(Hash) && (content[:rules] || content["rules"])
295
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
296
+ else
297
+ @agent.evaluators
298
+ end
299
+ end
300
+
301
+ def build_evaluators_from_config(configs)
302
+ Array(configs).map do |config|
303
+ case config[:type] || config["type"]
304
+ when "json_rule"
305
+ Evaluators::JsonRuleEvaluator.new(rules_json: config[:rules] || config["rules"])
306
+ when "dmn"
307
+ model = config[:model] || config["model"]
308
+ decision_id = config[:decision_id] || config["decision_id"]
309
+ Evaluators::DmnEvaluator.new(model: model, decision_id: decision_id)
310
+ else
311
+ raise VersionComparisonError, "Unknown evaluator type: #{config[:type]}"
312
+ end
313
+ end
314
+ end
315
+
316
+ def run_iterations(distributions:, base_context:, iterations:, agent:, options:)
317
+ if options[:parallel] && iterations > 100
318
+ run_parallel_iterations(distributions, base_context, iterations, agent, options)
319
+ else
320
+ results = []
321
+ attempted = 0
322
+ iterations.times do
323
+ attempted += 1
324
+ context = sample_context(distributions, base_context)
325
+ begin
326
+ decision = agent.decide(context: Context.new(context))
327
+ results << {
328
+ context: context,
329
+ decision: decision.decision,
330
+ confidence: decision.confidence,
331
+ explanations: decision.explanations
332
+ }
333
+ rescue NoEvaluationsError
334
+ # Skip iterations where no evaluators return a decision
335
+ # This can happen when rules don't match the sampled context
336
+ next
337
+ end
338
+ end
339
+ # Store attempted count in results metadata
340
+ results.instance_variable_set(:@attempted_iterations, attempted) if results.respond_to?(:instance_variable_set)
341
+ results
342
+ end
343
+ end
344
+
345
+ def run_parallel_iterations(distributions, base_context, iterations, agent, options)
346
+ thread_count = [options[:thread_count], iterations].min
347
+ iterations_per_thread = (iterations.to_f / thread_count).ceil
348
+
349
+ threads = create_iteration_threads(
350
+ thread_count, iterations_per_thread, distributions, base_context, agent
351
+ )
352
+ all_results = collect_thread_results(threads)
353
+ limit_results_to_count(all_results, iterations)
354
+ end
355
+
356
+ def create_iteration_threads(thread_count, iterations_per_thread, distributions, base_context, agent)
357
+ Array.new(thread_count) do
358
+ Thread.new do
359
+ run_thread_iterations(iterations_per_thread, distributions, base_context, agent)
360
+ end
361
+ end
362
+ end
363
+
364
+ def run_thread_iterations(iterations_per_thread, distributions, base_context, agent)
365
+ thread_results = []
366
+ thread_attempted = 0
367
+ iterations_per_thread.times do
368
+ thread_attempted += 1
369
+ result = attempt_iteration(distributions, base_context, agent)
370
+ thread_results << result if result
371
+ end
372
+ store_attempted_count(thread_results, thread_attempted)
373
+ thread_results
374
+ end
375
+
376
+ def attempt_iteration(distributions, base_context, agent)
377
+ context = sample_context(distributions, base_context)
378
+ decision = agent.decide(context: Context.new(context))
379
+ {
380
+ context: context,
381
+ decision: decision.decision,
382
+ confidence: decision.confidence,
383
+ explanations: decision.explanations
384
+ }
385
+ rescue StandardError => e
386
+ warn "[DecisionAgent] Monte Carlo iteration failed: #{e.message}"
387
+ nil
388
+ end
389
+
390
+ def store_attempted_count(results, attempted)
391
+ return unless results.respond_to?(:instance_variable_set)
392
+
393
+ results.instance_variable_set(:@attempted_iterations, attempted)
394
+ end
395
+
396
+ def collect_thread_results(threads)
397
+ all_results = threads.map(&:value).flatten.compact
398
+ total_attempted = calculate_total_attempted(threads)
399
+ store_attempted_count(all_results, total_attempted)
400
+ all_results
401
+ end
402
+
403
+ def calculate_total_attempted(threads)
404
+ threads.map do |t|
405
+ results = t.value
406
+ results.instance_variable_get(:@attempted_iterations) if results.respond_to?(:instance_variable_get)
407
+ end.compact.sum
408
+ end
409
+
410
+ def limit_results_to_count(results, iterations)
411
+ results.first(iterations)
412
+ end
413
+
414
+ def sample_context(distributions, base_context)
415
+ context = base_context.dup
416
+
417
+ distributions.each do |field, config|
418
+ value = sample_from_distribution(config)
419
+ set_nested_value(context, field, value)
420
+ end
421
+
422
+ context
423
+ end
424
+
425
+ def sample_from_distribution(config)
426
+ type = (config[:type] || config["type"]).to_sym
427
+
428
+ case type
429
+ when :normal
430
+ sample_normal(config)
431
+ when :uniform
432
+ sample_uniform(config)
433
+ when :lognormal
434
+ sample_lognormal(config)
435
+ when :exponential
436
+ sample_exponential(config)
437
+ when :discrete
438
+ sample_discrete(config[:values] || config["values"], config[:probabilities] || config["probabilities"])
439
+ when :triangular
440
+ sample_triangular(config[:min] || config["min"], config[:mode] || config["mode"], config[:max] || config["max"])
441
+ else
442
+ raise ArgumentError, "Unknown distribution type: #{type}"
443
+ end
444
+ end
445
+
446
+ def sample_normal(config)
447
+ mean = config[:mean] || config["mean"]
448
+ stddev = config[:stddev] || config["stddev"]
449
+ # Box-Muller transform for normal distribution
450
+ u1 = rand
451
+ u2 = rand
452
+ z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math::PI * u2)
453
+ mean + (z0 * stddev)
454
+ end
455
+
456
+ def sample_uniform(config)
457
+ min = config[:min] || config["min"]
458
+ max = config[:max] || config["max"]
459
+ min + (rand * (max - min))
460
+ end
461
+
462
+ def sample_lognormal(config)
463
+ mean = config[:mean] || config["mean"]
464
+ stddev = config[:stddev] || config["stddev"]
465
+ # Sample from normal, then exponentiate
466
+ u1 = rand
467
+ u2 = rand
468
+ z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math::PI * u2)
469
+ normal_sample = mean + (z0 * stddev)
470
+ Math.exp(normal_sample)
471
+ end
472
+
473
+ def sample_exponential(config)
474
+ lambda = config[:lambda] || config["lambda"]
475
+ -Math.log(rand) / lambda
476
+ end
477
+
478
+ def sample_discrete(values, probabilities)
479
+ random_val = rand
480
+ cumulative = 0.0
481
+
482
+ values.each_with_index do |value, i|
483
+ cumulative += probabilities[i]
484
+ return value if random_val <= cumulative
485
+ end
486
+
487
+ values.last
488
+ end
489
+
490
+ def sample_triangular(min, mode, max)
491
+ uniform_val = rand
492
+ fraction = (mode - min).to_f / (max - min)
493
+
494
+ if uniform_val < fraction
495
+ min + Math.sqrt(uniform_val * (max - min) * (mode - min))
496
+ else
497
+ max - Math.sqrt((1 - uniform_val) * (max - min) * (max - mode))
498
+ end
499
+ end
500
+
501
+ def calculate_statistics(results, confidence_level, requested_iterations: nil)
502
+ iterations_count = requested_iterations || results.size
503
+ return empty_statistics(iterations_count, confidence_level) if results.empty?
504
+
505
+ decision_stats = calculate_decision_statistics(results)
506
+ confidence_stats = calculate_confidence_statistics(results, confidence_level)
507
+ decision_specific_stats = calculate_decision_specific_statistics(results, decision_stats)
508
+
509
+ {
510
+ iterations: iterations_count,
511
+ decision_counts: decision_stats[:counts],
512
+ decision_probabilities: decision_stats[:probabilities],
513
+ decision_stats: decision_specific_stats,
514
+ average_confidence: confidence_stats[:average],
515
+ confidence_stddev: confidence_stats[:stddev],
516
+ confidence_intervals: {
517
+ confidence: confidence_stats[:interval],
518
+ level: confidence_level
519
+ },
520
+ results: results
521
+ }
522
+ end
523
+
524
+ def calculate_decision_statistics(results)
525
+ total = results.size
526
+ decision_counts = results.group_by { |r| r[:decision] }.transform_values(&:count)
527
+ decision_probabilities = decision_counts.transform_values { |count| count.to_f / total }
528
+
529
+ { counts: decision_counts, probabilities: decision_probabilities }
530
+ end
531
+
532
+ def calculate_confidence_statistics(results, confidence_level)
533
+ confidences = results.map { |r| r[:confidence] }.compact
534
+ avg_confidence = confidences.any? ? confidences.sum / confidences.size : 0.0
535
+
536
+ if confidences.size > 1
537
+ variance = confidences.map { |c| (c - avg_confidence)**2 }.sum / confidences.size
538
+ stddev_confidence = Math.sqrt(variance)
539
+ confidence_interval = calculate_confidence_interval(confidences, confidence_level)
540
+ else
541
+ stddev_confidence = 0.0
542
+ confidence_interval = { lower: avg_confidence, upper: avg_confidence }
543
+ end
544
+
545
+ { average: avg_confidence, stddev: stddev_confidence, interval: confidence_interval }
546
+ end
547
+
548
+ def calculate_decision_specific_statistics(results, decision_stats)
549
+ decision_stats[:counts].each_with_object({}) do |(decision, _count), stats|
550
+ decision_results = results.select { |r| r[:decision] == decision }
551
+ decision_confidences = decision_results.map { |r| r[:confidence] }.compact
552
+
553
+ next unless decision_confidences.any?
554
+
555
+ decision_avg_confidence = decision_confidences.sum / decision_confidences.size
556
+ stats[decision] = {
557
+ count: decision_stats[:counts][decision],
558
+ probability: decision_stats[:probabilities][decision],
559
+ average_confidence: decision_avg_confidence
560
+ }
561
+
562
+ next unless decision_confidences.size > 1
563
+
564
+ decision_variance = decision_confidences.map { |c| (c - decision_avg_confidence)**2 }.sum / decision_confidences.size
565
+ stats[decision][:confidence_stddev] = Math.sqrt(decision_variance)
566
+ end
567
+ end
568
+
569
+ def calculate_confidence_interval(values, level)
570
+ return { lower: values.first, upper: values.first } if values.size <= 1
571
+
572
+ sorted = values.sort
573
+ alpha = 1.0 - level
574
+ lower_percentile = (alpha / 2.0) * 100
575
+ upper_percentile = (1.0 - (alpha / 2.0)) * 100
576
+
577
+ lower_idx = (lower_percentile / 100.0 * (sorted.size - 1)).round
578
+ upper_idx = (upper_percentile / 100.0 * (sorted.size - 1)).round
579
+
580
+ {
581
+ lower: sorted[[lower_idx, 0].max],
582
+ upper: sorted[[upper_idx, sorted.size - 1].min]
583
+ }
584
+ end
585
+
586
+ def empty_statistics(attempted_iterations = 0, confidence_level = 0.95)
587
+ {
588
+ iterations: attempted_iterations,
589
+ decision_counts: {},
590
+ decision_probabilities: {},
591
+ decision_stats: {},
592
+ average_confidence: 0.0,
593
+ confidence_stddev: 0.0,
594
+ confidence_intervals: { confidence: { lower: 0.0, upper: 0.0 }, level: confidence_level },
595
+ results: []
596
+ }
597
+ end
598
+
599
+ def analyze_parameter_impact(param_results)
600
+ return {} if param_results.empty?
601
+
602
+ # Calculate how much decision probabilities change across parameter values
603
+ all_decisions = param_results.flat_map { |r| r[:decision_probabilities].keys }.uniq
604
+
605
+ impact = {}
606
+ all_decisions.each do |decision|
607
+ probabilities = param_results.map { |r| r[:decision_probabilities][decision] || 0.0 }
608
+ min_prob = probabilities.min
609
+ max_prob = probabilities.max
610
+ range = max_prob - min_prob
611
+
612
+ impact[decision] = {
613
+ min_probability: min_prob,
614
+ max_probability: max_prob,
615
+ range: range,
616
+ sensitivity: if range > 0.1
617
+ "high"
618
+ else
619
+ (range > 0.05 ? "medium" : "low")
620
+ end
621
+ }
622
+ end
623
+
624
+ impact
625
+ end
626
+
627
+ def set_nested_value(hash, key, value)
628
+ keys = key.to_s.split(".")
629
+ last_key = keys.pop
630
+ target = keys.reduce(hash) do |h, k|
631
+ h[k.to_sym] ||= {}
632
+ end
633
+ target[last_key.to_sym] = value
634
+ end
635
+ # rubocop:enable Metrics/ClassLength
636
+ end
637
+ end
638
+ end