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
@@ -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
- field = condition["field"] || condition[:field]
166
- operator = condition["op"] || condition[:op]
167
- value = condition["value"] || condition[:value]
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
- # Validate value (not required for 'present' and 'blank')
181
- if !%w[present blank].include?(operator.to_s) && value.nil?
182
- @errors << "#{path}: Field condition missing 'value' key for operator '#{operator}'"
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
- # Validate dot-notation in field path
186
- validate_field_path(field, path) if field
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
- # Validate decision
257
- decision = then_clause["decision"] || then_clause[:decision]
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
- # Validate optional weight
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
- # Validate optional reason
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, feedback: feedback)
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, feedback: feedback)
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
- next unless Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
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
- next unless Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
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
- matched_rule = find_first_matching_rule(ctx)
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 find_first_matching_rule(context)
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
- Dsl::ConditionEvaluator.evaluate(if_clause, context)
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
- "#{sanitize_name(name)}_#{Time.now.to_i}_#{rand(1000)}"
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