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
|
@@ -20,6 +20,7 @@ module DecisionAgent
|
|
|
20
20
|
join length
|
|
21
21
|
contains_all contains_any intersects subset_of
|
|
22
22
|
within_radius in_polygon
|
|
23
|
+
fetch_from_api
|
|
23
24
|
].freeze
|
|
24
25
|
|
|
25
26
|
CONDITION_TYPES = %w[all any field].freeze
|
|
@@ -162,9 +163,10 @@ module DecisionAgent
|
|
|
162
163
|
end
|
|
163
164
|
|
|
164
165
|
def validate_field_condition(condition, path)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
# Use key? to properly handle false values (|| would treat false as falsy)
|
|
167
|
+
field = extract_key_value(condition, "field", :field)
|
|
168
|
+
operator = extract_key_value(condition, "op", :op)
|
|
169
|
+
value = extract_key_value(condition, "value", :value)
|
|
168
170
|
|
|
169
171
|
# Validate field
|
|
170
172
|
@errors << "#{path}: Field condition missing 'field' key" unless field
|
|
@@ -176,14 +178,24 @@ module DecisionAgent
|
|
|
176
178
|
end
|
|
177
179
|
|
|
178
180
|
validate_operator(operator, path)
|
|
181
|
+
validate_field_condition_value(operator, value, path)
|
|
182
|
+
validate_fetch_from_api_value(value, path) if (operator.to_s == "fetch_from_api") && value
|
|
183
|
+
validate_field_path(field, path) if field
|
|
184
|
+
end
|
|
179
185
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
end
|
|
186
|
+
def extract_key_value(hash, string_key, symbol_key)
|
|
187
|
+
return hash[string_key] if hash.key?(string_key)
|
|
188
|
+
return hash[symbol_key] if hash.key?(symbol_key)
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_field_condition_value(operator, value, path)
|
|
194
|
+
# Validate value (not required for 'present', 'blank', and 'fetch_from_api' has special validation)
|
|
195
|
+
return if %w[present blank fetch_from_api].include?(operator.to_s)
|
|
196
|
+
return unless value.nil?
|
|
197
|
+
|
|
198
|
+
@errors << "#{path}: Field condition missing 'value' key for operator '#{operator}'"
|
|
187
199
|
end
|
|
188
200
|
|
|
189
201
|
def validate_operator(operator, path)
|
|
@@ -253,13 +265,20 @@ module DecisionAgent
|
|
|
253
265
|
return
|
|
254
266
|
end
|
|
255
267
|
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
validate_then_clause_decision(then_clause, rule_path)
|
|
269
|
+
validate_then_clause_weight(then_clause, rule_path)
|
|
270
|
+
validate_then_clause_reason(then_clause, rule_path)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_then_clause_decision(then_clause, rule_path)
|
|
274
|
+
# Use key? to properly handle false values (|| would treat false as falsy)
|
|
275
|
+
decision = extract_key_value(then_clause, "decision", :decision)
|
|
258
276
|
|
|
259
277
|
# Check if decision exists (including false and 0, but not nil)
|
|
260
278
|
@errors << "#{rule_path}.then: Missing required field 'decision'" if decision.nil?
|
|
279
|
+
end
|
|
261
280
|
|
|
262
|
-
|
|
281
|
+
def validate_then_clause_weight(then_clause, rule_path)
|
|
263
282
|
weight = then_clause["weight"] || then_clause[:weight]
|
|
264
283
|
|
|
265
284
|
if weight && !weight.is_a?(Numeric)
|
|
@@ -267,8 +286,9 @@ module DecisionAgent
|
|
|
267
286
|
elsif weight && (weight < 0.0 || weight > 1.0)
|
|
268
287
|
@errors << "#{rule_path}.then.weight: Must be between 0.0 and 1.0, got #{weight}"
|
|
269
288
|
end
|
|
289
|
+
end
|
|
270
290
|
|
|
271
|
-
|
|
291
|
+
def validate_then_clause_reason(then_clause, rule_path)
|
|
272
292
|
reason = then_clause["reason"] || then_clause[:reason]
|
|
273
293
|
|
|
274
294
|
return unless reason && !reason.is_a?(String)
|
|
@@ -276,6 +296,24 @@ module DecisionAgent
|
|
|
276
296
|
@errors << "#{rule_path}.then.reason: Must be a string, got #{reason.class}"
|
|
277
297
|
end
|
|
278
298
|
|
|
299
|
+
def validate_fetch_from_api_value(value, path)
|
|
300
|
+
unless value.is_a?(Hash)
|
|
301
|
+
@errors << "#{path}: 'fetch_from_api' operator requires 'value' to be a hash with 'endpoint', 'params', and optional 'mapping'"
|
|
302
|
+
return
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
endpoint = value["endpoint"] || value[:endpoint]
|
|
306
|
+
@errors << "#{path}: 'fetch_from_api' operator requires 'endpoint' in value hash" unless endpoint
|
|
307
|
+
|
|
308
|
+
params = value["params"] || value[:params]
|
|
309
|
+
@errors << "#{path}: 'fetch_from_api' operator 'params' must be a hash if provided" unless params.nil? || params.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
mapping = value["mapping"] || value[:mapping]
|
|
312
|
+
return if mapping.nil? || mapping.is_a?(Hash)
|
|
313
|
+
|
|
314
|
+
@errors << "#{path}: 'fetch_from_api' operator 'mapping' must be a hash if provided"
|
|
315
|
+
end
|
|
316
|
+
|
|
279
317
|
def format_errors
|
|
280
318
|
header = "Rule DSL validation failed with #{@errors.size} error#{'s' if @errors.size > 1}:\n\n"
|
|
281
319
|
numbered_errors = @errors.map.with_index { |err, idx| " #{idx + 1}. #{err}" }.join("\n")
|
|
@@ -39,9 +39,12 @@ module DecisionAgent
|
|
|
39
39
|
def evaluate(context, feedback: {})
|
|
40
40
|
hit_policy = @decision.decision_table.hit_policy
|
|
41
41
|
|
|
42
|
+
# Collect explainability traces
|
|
43
|
+
explainability_result = collect_explainability(context, hit_policy)
|
|
44
|
+
|
|
42
45
|
# Short-circuit for FIRST and PRIORITY policies
|
|
43
46
|
if %w[FIRST PRIORITY].include?(hit_policy)
|
|
44
|
-
first_match = find_first_matching_evaluation(context,
|
|
47
|
+
first_match = find_first_matching_evaluation(context, explainability_result: explainability_result)
|
|
45
48
|
return first_match if first_match
|
|
46
49
|
|
|
47
50
|
# If no match found, return nil (consistent with apply_first_policy behavior)
|
|
@@ -49,10 +52,25 @@ module DecisionAgent
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
# For UNIQUE, ANY, COLLECT - need all matches
|
|
52
|
-
matching_evaluations = find_all_matching_evaluations(context,
|
|
55
|
+
matching_evaluations = find_all_matching_evaluations(context, explainability_result: explainability_result)
|
|
53
56
|
|
|
54
57
|
# Apply hit policy to select the appropriate evaluation
|
|
55
|
-
apply_hit_policy(matching_evaluations)
|
|
58
|
+
result = apply_hit_policy(matching_evaluations)
|
|
59
|
+
|
|
60
|
+
# Add explainability to metadata by creating new Evaluation with updated metadata
|
|
61
|
+
if result && explainability_result
|
|
62
|
+
metadata = result.metadata.dup
|
|
63
|
+
metadata[:explainability] = explainability_result.to_h
|
|
64
|
+
result = Evaluation.new(
|
|
65
|
+
decision: result.decision,
|
|
66
|
+
weight: result.weight,
|
|
67
|
+
reason: result.reason,
|
|
68
|
+
evaluator_name: result.evaluator_name,
|
|
69
|
+
metadata: metadata
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result
|
|
56
74
|
end
|
|
57
75
|
|
|
58
76
|
private
|
|
@@ -61,8 +79,51 @@ module DecisionAgent
|
|
|
61
79
|
@name
|
|
62
80
|
end
|
|
63
81
|
|
|
82
|
+
# Collect explainability traces for DMN evaluation
|
|
83
|
+
def collect_explainability(context, hit_policy)
|
|
84
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
85
|
+
rules = @rules_json["rules"] || []
|
|
86
|
+
rule_traces = []
|
|
87
|
+
|
|
88
|
+
rules.each do |rule|
|
|
89
|
+
rule_id = rule["id"] || "rule_#{rules.index(rule)}"
|
|
90
|
+
if_clause = rule["if"]
|
|
91
|
+
next unless if_clause
|
|
92
|
+
|
|
93
|
+
# Create trace collector for this rule
|
|
94
|
+
trace_collector = Explainability::TraceCollector.new
|
|
95
|
+
|
|
96
|
+
# Evaluate condition with tracing
|
|
97
|
+
matched = Dsl::ConditionEvaluator.evaluate(
|
|
98
|
+
if_clause,
|
|
99
|
+
ctx,
|
|
100
|
+
trace_collector: trace_collector
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
then_clause = rule["then"] || {}
|
|
104
|
+
rule_trace = Explainability::RuleTrace.new(
|
|
105
|
+
rule_id: rule_id,
|
|
106
|
+
matched: matched,
|
|
107
|
+
condition_traces: trace_collector.traces,
|
|
108
|
+
decision: then_clause["decision"],
|
|
109
|
+
weight: then_clause["weight"],
|
|
110
|
+
reason: then_clause["reason"]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
rule_traces << rule_trace
|
|
114
|
+
|
|
115
|
+
# Stop after first match for FIRST/PRIORITY policies (short-circuit evaluation)
|
|
116
|
+
break if matched && %w[FIRST PRIORITY].include?(hit_policy)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Explainability::ExplainabilityResult.new(
|
|
120
|
+
evaluator_name: @name,
|
|
121
|
+
rule_traces: rule_traces
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
64
125
|
# Find first matching rule (for short-circuiting)
|
|
65
|
-
def find_first_matching_evaluation(context, feedback: {})
|
|
126
|
+
def find_first_matching_evaluation(context, explainability_result: nil, feedback: {})
|
|
66
127
|
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
67
128
|
rules = @rules_json["rules"] || []
|
|
68
129
|
|
|
@@ -70,20 +131,33 @@ module DecisionAgent
|
|
|
70
131
|
if_clause = rule["if"]
|
|
71
132
|
next unless if_clause
|
|
72
133
|
|
|
73
|
-
|
|
134
|
+
# If explainability is already collected, use the trace data
|
|
135
|
+
matched = if explainability_result
|
|
136
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
137
|
+
rule_trace&.matched
|
|
138
|
+
else
|
|
139
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
next unless matched
|
|
74
143
|
|
|
75
144
|
then_clause = rule["then"]
|
|
145
|
+
metadata = {
|
|
146
|
+
type: "dmn_rule",
|
|
147
|
+
rule_id: rule["id"],
|
|
148
|
+
ruleset: @rules_json["ruleset"],
|
|
149
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Add explainability data to metadata
|
|
153
|
+
metadata[:explainability] = explainability_result.to_h if explainability_result
|
|
154
|
+
|
|
76
155
|
return Evaluation.new(
|
|
77
156
|
decision: then_clause["decision"],
|
|
78
157
|
weight: then_clause["weight"] || 1.0,
|
|
79
158
|
reason: then_clause["reason"] || "Rule matched",
|
|
80
159
|
evaluator_name: @name,
|
|
81
|
-
metadata:
|
|
82
|
-
type: "dmn_rule",
|
|
83
|
-
rule_id: rule["id"],
|
|
84
|
-
ruleset: @rules_json["ruleset"],
|
|
85
|
-
hit_policy: @decision.decision_table.hit_policy
|
|
86
|
-
}
|
|
160
|
+
metadata: metadata
|
|
87
161
|
)
|
|
88
162
|
end
|
|
89
163
|
|
|
@@ -91,7 +165,7 @@ module DecisionAgent
|
|
|
91
165
|
end
|
|
92
166
|
|
|
93
167
|
# Find all matching rules (not just first)
|
|
94
|
-
def find_all_matching_evaluations(context, feedback: {})
|
|
168
|
+
def find_all_matching_evaluations(context, explainability_result: nil, feedback: {})
|
|
95
169
|
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
96
170
|
rules = @rules_json["rules"] || []
|
|
97
171
|
matching = []
|
|
@@ -100,20 +174,33 @@ module DecisionAgent
|
|
|
100
174
|
if_clause = rule["if"]
|
|
101
175
|
next unless if_clause
|
|
102
176
|
|
|
103
|
-
|
|
177
|
+
# If explainability is already collected, use the trace data
|
|
178
|
+
matched = if explainability_result
|
|
179
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
180
|
+
rule_trace&.matched
|
|
181
|
+
else
|
|
182
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
next unless matched
|
|
104
186
|
|
|
105
187
|
then_clause = rule["then"]
|
|
188
|
+
metadata = {
|
|
189
|
+
type: "dmn_rule",
|
|
190
|
+
rule_id: rule["id"],
|
|
191
|
+
ruleset: @rules_json["ruleset"],
|
|
192
|
+
hit_policy: @decision.decision_table.hit_policy
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Add explainability data to metadata (will be added to final result)
|
|
196
|
+
# For now, we'll add it to the final result after hit policy is applied
|
|
197
|
+
|
|
106
198
|
matching << Evaluation.new(
|
|
107
199
|
decision: then_clause["decision"],
|
|
108
200
|
weight: then_clause["weight"] || 1.0,
|
|
109
201
|
reason: then_clause["reason"] || "Rule matched",
|
|
110
202
|
evaluator_name: @name,
|
|
111
|
-
metadata:
|
|
112
|
-
type: "dmn_rule",
|
|
113
|
-
rule_id: rule["id"],
|
|
114
|
-
ruleset: @rules_json["ruleset"],
|
|
115
|
-
hit_policy: @decision.decision_table.hit_policy
|
|
116
|
-
}
|
|
203
|
+
metadata: metadata
|
|
117
204
|
)
|
|
118
205
|
end
|
|
119
206
|
|
|
@@ -19,37 +19,97 @@ module DecisionAgent
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def evaluate(context, feedback: {})
|
|
22
|
-
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
22
|
+
ctx = context.is_a?(DecisionAgent::Context) ? context : DecisionAgent::Context.new(context)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# Collect explainability traces (this also finds the matching rule)
|
|
25
|
+
explainability_result = collect_explainability(ctx)
|
|
25
26
|
|
|
27
|
+
# Find the matched rule from explainability result
|
|
28
|
+
matched_rule_trace = explainability_result&.matched_rules&.first
|
|
29
|
+
return nil unless matched_rule_trace
|
|
30
|
+
|
|
31
|
+
# Find the original rule to get the then clause
|
|
32
|
+
rules = @ruleset["rules"] || []
|
|
33
|
+
matched_rule = rules.find { |r| (r["id"] || "rule_#{rules.index(r)}") == matched_rule_trace.rule_id }
|
|
26
34
|
return nil unless matched_rule
|
|
27
35
|
|
|
28
36
|
then_clause = matched_rule["then"]
|
|
29
37
|
|
|
38
|
+
metadata = {
|
|
39
|
+
type: "json_rule",
|
|
40
|
+
rule_id: matched_rule["id"],
|
|
41
|
+
ruleset: @ruleset_name
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Add explainability data to metadata
|
|
45
|
+
metadata[:explainability] = explainability_result.to_h if explainability_result
|
|
46
|
+
|
|
30
47
|
Evaluation.new(
|
|
31
48
|
decision: then_clause["decision"],
|
|
32
49
|
weight: then_clause["weight"] || 1.0,
|
|
33
50
|
reason: then_clause["reason"] || "Rule matched",
|
|
34
51
|
evaluator_name: @name,
|
|
35
|
-
metadata:
|
|
36
|
-
type: "json_rule",
|
|
37
|
-
rule_id: matched_rule["id"],
|
|
38
|
-
ruleset: @ruleset_name
|
|
39
|
-
}
|
|
52
|
+
metadata: metadata
|
|
40
53
|
)
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
private
|
|
44
57
|
|
|
45
|
-
def
|
|
58
|
+
def collect_explainability(context)
|
|
59
|
+
rules = @ruleset["rules"] || []
|
|
60
|
+
rule_traces = []
|
|
61
|
+
|
|
62
|
+
rules.each do |rule|
|
|
63
|
+
rule_id = rule["id"] || "rule_#{rules.index(rule)}"
|
|
64
|
+
if_clause = rule["if"]
|
|
65
|
+
next unless if_clause
|
|
66
|
+
|
|
67
|
+
# Create trace collector for this rule
|
|
68
|
+
trace_collector = Explainability::TraceCollector.new
|
|
69
|
+
|
|
70
|
+
# Evaluate condition with tracing
|
|
71
|
+
matched = Dsl::ConditionEvaluator.evaluate(
|
|
72
|
+
if_clause,
|
|
73
|
+
context,
|
|
74
|
+
trace_collector: trace_collector
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
then_clause = rule["then"] || {}
|
|
78
|
+
rule_trace = Explainability::RuleTrace.new(
|
|
79
|
+
rule_id: rule_id,
|
|
80
|
+
matched: matched,
|
|
81
|
+
condition_traces: trace_collector.traces,
|
|
82
|
+
decision: then_clause["decision"],
|
|
83
|
+
weight: then_clause["weight"],
|
|
84
|
+
reason: then_clause["reason"]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
rule_traces << rule_trace
|
|
88
|
+
|
|
89
|
+
# Stop after first match (short-circuit evaluation)
|
|
90
|
+
break if matched
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Explainability::ExplainabilityResult.new(
|
|
94
|
+
evaluator_name: @name,
|
|
95
|
+
rule_traces: rule_traces
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def find_first_matching_rule(context, explainability_result = nil)
|
|
46
100
|
rules = @ruleset["rules"] || []
|
|
47
101
|
|
|
48
102
|
rules.find do |rule|
|
|
49
103
|
if_clause = rule["if"]
|
|
50
104
|
next false unless if_clause
|
|
51
105
|
|
|
52
|
-
|
|
106
|
+
# If explainability is already collected, use the trace data
|
|
107
|
+
if explainability_result
|
|
108
|
+
rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
|
|
109
|
+
rule_trace&.matched
|
|
110
|
+
else
|
|
111
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, context)
|
|
112
|
+
end
|
|
53
113
|
end
|
|
54
114
|
end
|
|
55
115
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Explainability
|
|
3
|
+
# Represents a single condition evaluation with its result and values
|
|
4
|
+
class ConditionTrace
|
|
5
|
+
attr_reader :field, :operator, :expected_value, :actual_value, :result, :description
|
|
6
|
+
|
|
7
|
+
def initialize(field:, operator:, expected_value:, actual_value:, result:, description: nil)
|
|
8
|
+
@field = field.to_s.freeze
|
|
9
|
+
@operator = operator.to_s.freeze
|
|
10
|
+
@expected_value = expected_value
|
|
11
|
+
@actual_value = actual_value
|
|
12
|
+
@result = result
|
|
13
|
+
@description = description ? description.to_s.freeze : generate_description
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def passed?
|
|
18
|
+
@result == true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failed?
|
|
22
|
+
@result == false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_s
|
|
26
|
+
@description
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
field: @field,
|
|
32
|
+
operator: @operator,
|
|
33
|
+
expected_value: @expected_value,
|
|
34
|
+
actual_value: @actual_value,
|
|
35
|
+
result: @result,
|
|
36
|
+
description: @description
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def generate_description
|
|
43
|
+
case @operator
|
|
44
|
+
when "eq"
|
|
45
|
+
"#{@field} = #{format_value(@actual_value)}"
|
|
46
|
+
when "neq"
|
|
47
|
+
"#{@field} != #{format_value(@expected_value)}"
|
|
48
|
+
when "gt"
|
|
49
|
+
"#{@field} > #{format_value(@expected_value)}"
|
|
50
|
+
when "gte"
|
|
51
|
+
"#{@field} >= #{format_value(@expected_value)}"
|
|
52
|
+
when "lt"
|
|
53
|
+
"#{@field} < #{format_value(@expected_value)}"
|
|
54
|
+
when "lte"
|
|
55
|
+
"#{@field} <= #{format_value(@expected_value)}"
|
|
56
|
+
when "in"
|
|
57
|
+
"#{@field} in #{format_value(@expected_value)}"
|
|
58
|
+
when "contains"
|
|
59
|
+
"#{@field} contains #{format_value(@expected_value)}"
|
|
60
|
+
when "present"
|
|
61
|
+
"#{@field} is present"
|
|
62
|
+
when "blank"
|
|
63
|
+
"#{@field} is blank"
|
|
64
|
+
else
|
|
65
|
+
"#{@field} #{@operator} #{format_value(@expected_value)}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_value(value)
|
|
70
|
+
case value
|
|
71
|
+
when String
|
|
72
|
+
value.inspect
|
|
73
|
+
when Array, Hash
|
|
74
|
+
value.inspect
|
|
75
|
+
when Time, Date, DateTime
|
|
76
|
+
value.to_s
|
|
77
|
+
else
|
|
78
|
+
value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Explainability
|
|
3
|
+
# Container for all explainability data from a decision evaluation
|
|
4
|
+
class ExplainabilityResult
|
|
5
|
+
attr_reader :rule_traces, :evaluator_name
|
|
6
|
+
|
|
7
|
+
def initialize(evaluator_name:, rule_traces: [])
|
|
8
|
+
@evaluator_name = evaluator_name.to_s.freeze
|
|
9
|
+
@rule_traces = Array(rule_traces).freeze
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def matched_rules
|
|
14
|
+
@rule_traces.select(&:matched)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def evaluated_rules
|
|
18
|
+
@rule_traces
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all_passed_conditions
|
|
22
|
+
@rule_traces.flat_map(&:passed_conditions)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def all_failed_conditions
|
|
26
|
+
@rule_traces.flat_map(&:failed_conditions)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def because(verbose: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
30
|
+
# verbose parameter kept for API compatibility, but currently both modes return same format
|
|
31
|
+
all_passed_conditions.map(&:description)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def failed_conditions(verbose: false)
|
|
35
|
+
if verbose
|
|
36
|
+
all_failed_conditions.map(&:to_h)
|
|
37
|
+
else
|
|
38
|
+
all_failed_conditions.map(&:description)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h(verbose: false)
|
|
43
|
+
{
|
|
44
|
+
evaluator_name: @evaluator_name,
|
|
45
|
+
rule_traces: @rule_traces.map(&:to_h), # Always include full rule traces for reconstruction
|
|
46
|
+
because: because(verbose: verbose),
|
|
47
|
+
failed_conditions: failed_conditions(verbose: verbose)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Explainability
|
|
3
|
+
# Represents the trace of a rule evaluation including all conditions
|
|
4
|
+
class RuleTrace
|
|
5
|
+
attr_reader :rule_id, :matched, :condition_traces, :decision, :weight, :reason
|
|
6
|
+
|
|
7
|
+
def initialize(rule_id:, matched:, condition_traces: [], decision: nil, weight: nil, reason: nil)
|
|
8
|
+
@rule_id = rule_id.to_s.freeze
|
|
9
|
+
@matched = matched
|
|
10
|
+
@condition_traces = Array(condition_traces).freeze
|
|
11
|
+
@decision = decision ? decision.to_s.freeze : nil
|
|
12
|
+
@weight = weight
|
|
13
|
+
@reason = reason ? reason.to_s.freeze : nil
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def passed_conditions
|
|
18
|
+
@condition_traces.select(&:passed?)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failed_conditions
|
|
22
|
+
@condition_traces.select(&:failed?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
rule_id: @rule_id,
|
|
28
|
+
matched: @matched,
|
|
29
|
+
decision: @decision,
|
|
30
|
+
weight: @weight,
|
|
31
|
+
reason: @reason,
|
|
32
|
+
condition_traces: @condition_traces.map(&:to_h),
|
|
33
|
+
passed_conditions: passed_conditions.map(&:description),
|
|
34
|
+
failed_conditions: failed_conditions.map(&:description)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Explainability
|
|
3
|
+
# Collects condition traces during evaluation
|
|
4
|
+
class TraceCollector
|
|
5
|
+
attr_reader :traces
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@traces = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def add_trace(trace)
|
|
12
|
+
@traces << trace
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clear
|
|
16
|
+
@traces.clear
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def empty?
|
|
20
|
+
@traces.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -16,6 +16,7 @@ module DecisionAgent
|
|
|
16
16
|
@alert_handlers = []
|
|
17
17
|
@check_interval = 60 # seconds
|
|
18
18
|
@monitoring_thread = nil
|
|
19
|
+
@rule_counter = 0
|
|
19
20
|
freeze_config
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -198,7 +199,10 @@ module DecisionAgent
|
|
|
198
199
|
end
|
|
199
200
|
|
|
200
201
|
def generate_rule_id(name)
|
|
201
|
-
|
|
202
|
+
synchronize do
|
|
203
|
+
@rule_counter += 1
|
|
204
|
+
"#{sanitize_name(name)}_#{Time.now.to_i}_#{@rule_counter}"
|
|
205
|
+
end
|
|
202
206
|
end
|
|
203
207
|
|
|
204
208
|
def sanitize_name(name)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Simulation
|
|
3
|
+
# Base error class for simulation module
|
|
4
|
+
class SimulationError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Error raised when scenario execution fails
|
|
7
|
+
class ScenarioExecutionError < SimulationError; end
|
|
8
|
+
|
|
9
|
+
# Error raised when historical data is invalid
|
|
10
|
+
class InvalidHistoricalDataError < SimulationError; end
|
|
11
|
+
|
|
12
|
+
# Error raised when version comparison fails
|
|
13
|
+
class VersionComparisonError < SimulationError; end
|
|
14
|
+
|
|
15
|
+
# Error raised when shadow test configuration is invalid
|
|
16
|
+
class InvalidShadowTestError < SimulationError; end
|
|
17
|
+
end
|
|
18
|
+
end
|