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,287 @@
|
|
|
1
|
+
require_relative "errors"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Simulation
|
|
5
|
+
# Engine for shadow testing - comparing new rules against production without affecting outcomes
|
|
6
|
+
class ShadowTestEngine
|
|
7
|
+
attr_reader :production_agent, :version_manager
|
|
8
|
+
|
|
9
|
+
def initialize(production_agent:, version_manager: nil)
|
|
10
|
+
@production_agent = production_agent
|
|
11
|
+
@version_manager = version_manager || Versioning::VersionManager.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Execute shadow test - compare shadow version against production
|
|
15
|
+
# @param context [Hash, Context] Context for decision
|
|
16
|
+
# @param shadow_version [String, Integer, Hash] Shadow rule version to test
|
|
17
|
+
# @param options [Hash] Test options
|
|
18
|
+
# - :track_differences [Boolean] Track and return differences (default: true)
|
|
19
|
+
# - :record_results [Boolean] Record results for later analysis (default: false)
|
|
20
|
+
# @return [Hash] Shadow test result
|
|
21
|
+
def test(context:, shadow_version:, options: {})
|
|
22
|
+
options = {
|
|
23
|
+
track_differences: true,
|
|
24
|
+
record_results: false
|
|
25
|
+
}.merge(options)
|
|
26
|
+
|
|
27
|
+
ctx = normalize_context(context)
|
|
28
|
+
production_decision = execute_production_decision(ctx)
|
|
29
|
+
shadow_decision = execute_shadow_decision(ctx, shadow_version)
|
|
30
|
+
|
|
31
|
+
result = build_comparison_result(ctx, production_decision, shadow_decision)
|
|
32
|
+
add_differences(result, production_decision, shadow_decision) if options[:track_differences] && !result[:matches]
|
|
33
|
+
record_result(result, shadow_version) if options[:record_results]
|
|
34
|
+
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_context(context)
|
|
39
|
+
context.is_a?(Context) ? context : Context.new(context)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute_production_decision(context)
|
|
43
|
+
@production_agent.decide(context: context)
|
|
44
|
+
rescue NoEvaluationsError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def execute_shadow_decision(context, shadow_version)
|
|
49
|
+
shadow_agent = build_shadow_agent(shadow_version)
|
|
50
|
+
shadow_agent.decide(context: context)
|
|
51
|
+
rescue NoEvaluationsError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_comparison_result(context, production_decision, shadow_decision)
|
|
56
|
+
{
|
|
57
|
+
context: context.to_h,
|
|
58
|
+
production_decision: production_decision&.decision,
|
|
59
|
+
production_confidence: production_decision&.confidence || 0.0,
|
|
60
|
+
shadow_decision: shadow_decision&.decision,
|
|
61
|
+
shadow_confidence: shadow_decision&.confidence || 0.0,
|
|
62
|
+
matches: production_decision&.decision == shadow_decision&.decision,
|
|
63
|
+
confidence_delta: (shadow_decision&.confidence || 0.0) - (production_decision&.confidence || 0.0),
|
|
64
|
+
timestamp: Time.now.utc.iso8601
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def add_differences(result, production_decision, shadow_decision)
|
|
69
|
+
result[:differences] = {
|
|
70
|
+
decision_mismatch: true,
|
|
71
|
+
production_explanations: production_decision&.explanations || [],
|
|
72
|
+
shadow_explanations: shadow_decision&.explanations || []
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Batch shadow test multiple contexts
|
|
77
|
+
# @param contexts [Array<Hash>] Array of contexts to test
|
|
78
|
+
# @param shadow_version [String, Integer, Hash] Shadow rule version
|
|
79
|
+
# @param options [Hash] Test options
|
|
80
|
+
# - :parallel [Boolean] Use parallel execution (default: true)
|
|
81
|
+
# - :thread_count [Integer] Number of threads (default: 4)
|
|
82
|
+
# - :progress_callback [Proc] Progress callback
|
|
83
|
+
# @return [Hash] Batch shadow test results
|
|
84
|
+
def batch_test(contexts:, shadow_version:, options: {})
|
|
85
|
+
options = {
|
|
86
|
+
parallel: true,
|
|
87
|
+
thread_count: 4,
|
|
88
|
+
progress_callback: nil,
|
|
89
|
+
track_differences: true,
|
|
90
|
+
record_results: false
|
|
91
|
+
}.merge(options)
|
|
92
|
+
|
|
93
|
+
shadow_agent = build_shadow_agent(shadow_version)
|
|
94
|
+
results = execute_contexts_with_progress(contexts, shadow_version, shadow_agent, options)
|
|
95
|
+
|
|
96
|
+
build_batch_report(results)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def execute_contexts_with_progress(contexts, shadow_version, shadow_agent, options)
|
|
100
|
+
results = []
|
|
101
|
+
mutex = Mutex.new
|
|
102
|
+
progress_tracker = ProgressTracker.new(contexts.size, options[:progress_callback])
|
|
103
|
+
|
|
104
|
+
if options[:parallel] && contexts.size > 1
|
|
105
|
+
execute_parallel(contexts, shadow_agent, options, mutex) do |result|
|
|
106
|
+
mutex.synchronize do
|
|
107
|
+
results << result
|
|
108
|
+
progress_tracker.increment
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
execute_sequential_contexts(contexts, shadow_version, options, results, progress_tracker)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
results
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def execute_sequential_contexts(contexts, shadow_version, options, results, progress_tracker)
|
|
119
|
+
contexts.each_with_index do |context, _index|
|
|
120
|
+
result = test(context: context, shadow_version: shadow_version, options: options)
|
|
121
|
+
results << result
|
|
122
|
+
progress_tracker.increment
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Helper class for tracking progress
|
|
127
|
+
class ProgressTracker
|
|
128
|
+
def initialize(total, callback)
|
|
129
|
+
@total = total
|
|
130
|
+
@callback = callback
|
|
131
|
+
@completed = 0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def increment
|
|
135
|
+
@completed += 1
|
|
136
|
+
return unless @callback
|
|
137
|
+
|
|
138
|
+
@callback.call(
|
|
139
|
+
completed: @completed,
|
|
140
|
+
total: @total,
|
|
141
|
+
percentage: (@completed.to_f / @total * 100).round(2)
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get shadow test summary statistics
|
|
147
|
+
# @param shadow_version [String, Integer, Hash] Shadow version ID
|
|
148
|
+
# @return [Hash] Summary statistics
|
|
149
|
+
def get_summary(_shadow_version)
|
|
150
|
+
# In a real implementation, this would query stored results
|
|
151
|
+
# For now, return empty summary
|
|
152
|
+
{
|
|
153
|
+
total_tests: 0,
|
|
154
|
+
matches: 0,
|
|
155
|
+
mismatches: 0,
|
|
156
|
+
match_rate: 0.0,
|
|
157
|
+
average_confidence_delta: 0.0
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def build_shadow_agent(shadow_version)
|
|
164
|
+
version_hash = resolve_version(shadow_version)
|
|
165
|
+
evaluators = build_evaluators_from_version(version_hash)
|
|
166
|
+
Agent.new(
|
|
167
|
+
evaluators: evaluators,
|
|
168
|
+
scoring_strategy: @production_agent.scoring_strategy,
|
|
169
|
+
audit_adapter: Audit::NullAdapter.new
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def resolve_version(version)
|
|
174
|
+
case version
|
|
175
|
+
when String, Integer
|
|
176
|
+
version_data = @version_manager.get_version(version_id: version)
|
|
177
|
+
raise InvalidShadowTestError, "Shadow version not found: #{version}" unless version_data
|
|
178
|
+
|
|
179
|
+
version_data
|
|
180
|
+
when Hash
|
|
181
|
+
version
|
|
182
|
+
else
|
|
183
|
+
raise InvalidShadowTestError, "Invalid shadow version format: #{version.class}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_evaluators_from_version(version)
|
|
188
|
+
content = version[:content] || version["content"]
|
|
189
|
+
raise InvalidShadowTestError, "Shadow version has no content" unless content
|
|
190
|
+
|
|
191
|
+
if content.is_a?(Hash) && content[:evaluators]
|
|
192
|
+
build_evaluators_from_config(content[:evaluators])
|
|
193
|
+
elsif content.is_a?(Hash) && (content[:rules] || content["rules"])
|
|
194
|
+
[Evaluators::JsonRuleEvaluator.new(rules_json: content)]
|
|
195
|
+
else
|
|
196
|
+
raise InvalidShadowTestError, "Cannot build evaluators from shadow version"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build_evaluators_from_config(configs)
|
|
201
|
+
Array(configs).map do |config|
|
|
202
|
+
case config[:type] || config["type"]
|
|
203
|
+
when "json_rule"
|
|
204
|
+
Evaluators::JsonRuleEvaluator.new(rules_json: config[:rules] || config["rules"])
|
|
205
|
+
when "dmn"
|
|
206
|
+
model = config[:model] || config["model"]
|
|
207
|
+
decision_id = config[:decision_id] || config["decision_id"]
|
|
208
|
+
Evaluators::DmnEvaluator.new(model: model, decision_id: decision_id)
|
|
209
|
+
else
|
|
210
|
+
raise InvalidShadowTestError, "Unknown evaluator type: #{config[:type]}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def execute_parallel(contexts, shadow_agent, options, _mutex, &block)
|
|
216
|
+
thread_count = [options[:thread_count], contexts.size].min
|
|
217
|
+
queue = Queue.new
|
|
218
|
+
contexts.each { |c| queue << c }
|
|
219
|
+
|
|
220
|
+
threads = Array.new(thread_count) do
|
|
221
|
+
Thread.new do
|
|
222
|
+
process_contexts_from_queue(queue, shadow_agent, options, &block)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
threads.each(&:join)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def process_contexts_from_queue(queue, shadow_agent, options)
|
|
230
|
+
loop do
|
|
231
|
+
context = dequeue_context(queue)
|
|
232
|
+
break unless context
|
|
233
|
+
|
|
234
|
+
ctx = normalize_context(context)
|
|
235
|
+
production_decision = execute_production_decision(ctx)
|
|
236
|
+
shadow_decision = execute_shadow_decision_in_parallel(ctx, shadow_agent)
|
|
237
|
+
|
|
238
|
+
result = build_comparison_result(ctx, production_decision, shadow_decision)
|
|
239
|
+
add_differences(result, production_decision, shadow_decision) if options[:track_differences] && !result[:matches]
|
|
240
|
+
|
|
241
|
+
yield result
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def dequeue_context(queue)
|
|
246
|
+
queue.pop(true)
|
|
247
|
+
rescue StandardError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def execute_shadow_decision_in_parallel(context, shadow_agent)
|
|
252
|
+
shadow_agent.decide(context: context)
|
|
253
|
+
rescue NoEvaluationsError
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def record_result(_result, shadow_version)
|
|
258
|
+
# In a real implementation, this would store results in a database or file
|
|
259
|
+
# For now, this is a placeholder
|
|
260
|
+
shadow_version.is_a?(Hash) ? shadow_version[:id] || shadow_version["id"] : shadow_version
|
|
261
|
+
# Store result for later analysis
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_batch_report(results)
|
|
265
|
+
total = results.size
|
|
266
|
+
matches = results.count { |r| r[:matches] }
|
|
267
|
+
mismatches = total - matches
|
|
268
|
+
confidence_deltas = results.map { |r| r[:confidence_delta] }.compact
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
total_tests: total,
|
|
272
|
+
matches: matches,
|
|
273
|
+
mismatches: mismatches,
|
|
274
|
+
match_rate: total.positive? ? (matches.to_f / total) : 0,
|
|
275
|
+
average_confidence_delta: confidence_deltas.any? ? confidence_deltas.sum / confidence_deltas.size : 0,
|
|
276
|
+
max_confidence_delta: confidence_deltas.map(&:abs).max || 0,
|
|
277
|
+
decision_distribution: {
|
|
278
|
+
production: results.group_by { |r| r[:production_decision] }.transform_values(&:count),
|
|
279
|
+
shadow: results.group_by { |r| r[:shadow_decision] }.transform_values(&:count)
|
|
280
|
+
},
|
|
281
|
+
mismatched_results: results.reject { |r| r[:matches] },
|
|
282
|
+
results: results
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|