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