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.
- checksums.yaml +4 -4
- data/README.md +272 -7
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
- data/lib/decision_agent/dsl/schema_validator.rb +51 -13
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/index.html +49 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/server.rb +594 -23
- data/lib/decision_agent.rb +60 -2
- metadata +53 -73
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- 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
|