decision_agent 0.2.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +313 -8
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/agent.rb +72 -1
  5. data/lib/decision_agent/context.rb +1 -0
  6. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  7. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  8. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  9. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  10. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  11. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  12. data/lib/decision_agent/decision.rb +102 -2
  13. data/lib/decision_agent/dmn/adapter.rb +135 -0
  14. data/lib/decision_agent/dmn/cache.rb +306 -0
  15. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  16. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  17. data/lib/decision_agent/dmn/errors.rb +30 -0
  18. data/lib/decision_agent/dmn/exporter.rb +217 -0
  19. data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
  20. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  21. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  22. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  23. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  24. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  25. data/lib/decision_agent/dmn/importer.rb +77 -0
  26. data/lib/decision_agent/dmn/model.rb +197 -0
  27. data/lib/decision_agent/dmn/parser.rb +191 -0
  28. data/lib/decision_agent/dmn/testing.rb +333 -0
  29. data/lib/decision_agent/dmn/validator.rb +315 -0
  30. data/lib/decision_agent/dmn/versioning.rb +229 -0
  31. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  32. data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
  33. data/lib/decision_agent/dsl/schema_validator.rb +53 -14
  34. data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
  35. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  36. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  37. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  38. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  39. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  40. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  41. data/lib/decision_agent/simulation/errors.rb +18 -0
  42. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  43. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  44. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  45. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  46. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  47. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  48. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  49. data/lib/decision_agent/simulation.rb +17 -0
  50. data/lib/decision_agent/version.rb +1 -1
  51. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  52. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  53. data/lib/decision_agent/web/public/app.js +119 -0
  54. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  55. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  56. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  57. data/lib/decision_agent/web/public/index.html +52 -0
  58. data/lib/decision_agent/web/public/simulation.html +130 -0
  59. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  60. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  61. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  62. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  63. data/lib/decision_agent/web/public/styles.css +86 -0
  64. data/lib/decision_agent/web/server.rb +1059 -23
  65. data/lib/decision_agent.rb +60 -2
  66. metadata +105 -61
  67. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  68. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  69. data/spec/ab_testing/ab_test_spec.rb +0 -270
  70. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
  71. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  72. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  73. data/spec/activerecord_thread_safety_spec.rb +0 -553
  74. data/spec/advanced_operators_spec.rb +0 -3150
  75. data/spec/agent_spec.rb +0 -289
  76. data/spec/api_contract_spec.rb +0 -430
  77. data/spec/audit_adapters_spec.rb +0 -92
  78. data/spec/auth/access_audit_logger_spec.rb +0 -394
  79. data/spec/auth/authenticator_spec.rb +0 -112
  80. data/spec/auth/password_reset_spec.rb +0 -294
  81. data/spec/auth/permission_checker_spec.rb +0 -207
  82. data/spec/auth/permission_spec.rb +0 -73
  83. data/spec/auth/rbac_adapter_spec.rb +0 -550
  84. data/spec/auth/rbac_config_spec.rb +0 -82
  85. data/spec/auth/role_spec.rb +0 -51
  86. data/spec/auth/session_manager_spec.rb +0 -172
  87. data/spec/auth/session_spec.rb +0 -112
  88. data/spec/auth/user_spec.rb +0 -130
  89. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  90. data/spec/context_spec.rb +0 -127
  91. data/spec/decision_agent_spec.rb +0 -96
  92. data/spec/decision_spec.rb +0 -423
  93. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  94. data/spec/dsl_validation_spec.rb +0 -648
  95. data/spec/edge_cases_spec.rb +0 -353
  96. data/spec/evaluation_spec.rb +0 -364
  97. data/spec/evaluation_validator_spec.rb +0 -165
  98. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  99. data/spec/examples.txt +0 -1633
  100. data/spec/issue_verification_spec.rb +0 -759
  101. data/spec/json_rule_evaluator_spec.rb +0 -587
  102. data/spec/monitoring/alert_manager_spec.rb +0 -378
  103. data/spec/monitoring/metrics_collector_spec.rb +0 -499
  104. data/spec/monitoring/monitored_agent_spec.rb +0 -222
  105. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  106. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  107. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  108. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  109. data/spec/performance_optimizations_spec.rb +0 -486
  110. data/spec/replay_edge_cases_spec.rb +0 -699
  111. data/spec/replay_spec.rb +0 -210
  112. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  113. data/spec/scoring_spec.rb +0 -225
  114. data/spec/spec_helper.rb +0 -60
  115. data/spec/testing/batch_test_importer_spec.rb +0 -693
  116. data/spec/testing/batch_test_runner_spec.rb +0 -307
  117. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  118. data/spec/testing/test_result_comparator_spec.rb +0 -392
  119. data/spec/testing/test_scenario_spec.rb +0 -113
  120. data/spec/thread_safety_spec.rb +0 -482
  121. data/spec/thread_safety_spec.rb.broken +0 -878
  122. data/spec/versioning/adapter_spec.rb +0 -156
  123. data/spec/versioning_spec.rb +0 -1030
  124. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  125. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  126. data/spec/web_ui_rack_spec.rb +0 -1840
@@ -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,12 +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
- @errors << "#{rule_path}.then: Missing required field 'decision'" unless decision
277
+ # Check if decision exists (including false and 0, but not nil)
278
+ @errors << "#{rule_path}.then: Missing required field 'decision'" if decision.nil?
279
+ end
260
280
 
261
- # Validate optional weight
281
+ def validate_then_clause_weight(then_clause, rule_path)
262
282
  weight = then_clause["weight"] || then_clause[:weight]
263
283
 
264
284
  if weight && !weight.is_a?(Numeric)
@@ -266,8 +286,9 @@ module DecisionAgent
266
286
  elsif weight && (weight < 0.0 || weight > 1.0)
267
287
  @errors << "#{rule_path}.then.weight: Must be between 0.0 and 1.0, got #{weight}"
268
288
  end
289
+ end
269
290
 
270
- # Validate optional reason
291
+ def validate_then_clause_reason(then_clause, rule_path)
271
292
  reason = then_clause["reason"] || then_clause[:reason]
272
293
 
273
294
  return unless reason && !reason.is_a?(String)
@@ -275,6 +296,24 @@ module DecisionAgent
275
296
  @errors << "#{rule_path}.then.reason: Must be a string, got #{reason.class}"
276
297
  end
277
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
+
278
317
  def format_errors
279
318
  header = "Rule DSL validation failed with #{@errors.size} error#{'s' if @errors.size > 1}:\n\n"
280
319
  numbered_errors = @errors.map.with_index { |err, idx| " #{idx + 1}. #{err}" }.join("\n")
@@ -0,0 +1,308 @@
1
+ require_relative "../dmn/adapter"
2
+ require_relative "../dmn/errors"
3
+ require_relative "base"
4
+ require_relative "json_rule_evaluator"
5
+
6
+ module DecisionAgent
7
+ module Evaluators
8
+ # Evaluates DMN decision models
9
+ class DmnEvaluator < Base
10
+ attr_reader :model, :decision_id
11
+
12
+ def initialize(model:, decision_id:, name: nil)
13
+ @model = model
14
+ @decision_id = decision_id.to_s
15
+ @name = name || "DmnEvaluator(#{@decision_id})"
16
+
17
+ # Find and validate decision
18
+ @decision = @model.find_decision(@decision_id)
19
+ raise Dmn::InvalidDmnModelError, "Decision '#{@decision_id}' not found" unless @decision
20
+ raise Dmn::InvalidDmnModelError, "Decision '#{@decision_id}' has no decision table" unless @decision.decision_table
21
+
22
+ # Convert to JSON rules for execution
23
+ adapter = Dmn::Adapter.new(@decision.decision_table)
24
+ @rules_json = adapter.to_json_rules
25
+
26
+ # Create internal JSON rule evaluator
27
+ @json_evaluator = JsonRuleEvaluator.new(
28
+ rules_json: @rules_json,
29
+ name: @name
30
+ )
31
+
32
+ # Freeze for thread safety
33
+ @model.freeze
34
+ @decision_id.freeze
35
+ @name.freeze
36
+ freeze
37
+ end
38
+
39
+ def evaluate(context, feedback: {})
40
+ hit_policy = @decision.decision_table.hit_policy
41
+
42
+ # Collect explainability traces
43
+ explainability_result = collect_explainability(context, hit_policy)
44
+
45
+ # Short-circuit for FIRST and PRIORITY policies
46
+ if %w[FIRST PRIORITY].include?(hit_policy)
47
+ first_match = find_first_matching_evaluation(context, explainability_result: explainability_result)
48
+ return first_match if first_match
49
+
50
+ # If no match found, return nil (consistent with apply_first_policy behavior)
51
+ return nil
52
+ end
53
+
54
+ # For UNIQUE, ANY, COLLECT - need all matches
55
+ matching_evaluations = find_all_matching_evaluations(context, explainability_result: explainability_result)
56
+
57
+ # Apply hit policy to select the appropriate evaluation
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
74
+ end
75
+
76
+ private
77
+
78
+ def evaluator_name
79
+ @name
80
+ end
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
+
125
+ # Find first matching rule (for short-circuiting)
126
+ def find_first_matching_evaluation(context, explainability_result: nil, feedback: {})
127
+ ctx = context.is_a?(Context) ? context : Context.new(context)
128
+ rules = @rules_json["rules"] || []
129
+
130
+ rules.each do |rule|
131
+ if_clause = rule["if"]
132
+ next unless if_clause
133
+
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
143
+
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
+
155
+ return Evaluation.new(
156
+ decision: then_clause["decision"],
157
+ weight: then_clause["weight"] || 1.0,
158
+ reason: then_clause["reason"] || "Rule matched",
159
+ evaluator_name: @name,
160
+ metadata: metadata
161
+ )
162
+ end
163
+
164
+ nil
165
+ end
166
+
167
+ # Find all matching rules (not just first)
168
+ def find_all_matching_evaluations(context, explainability_result: nil, feedback: {})
169
+ ctx = context.is_a?(Context) ? context : Context.new(context)
170
+ rules = @rules_json["rules"] || []
171
+ matching = []
172
+
173
+ rules.each do |rule|
174
+ if_clause = rule["if"]
175
+ next unless if_clause
176
+
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
186
+
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
+
198
+ matching << Evaluation.new(
199
+ decision: then_clause["decision"],
200
+ weight: then_clause["weight"] || 1.0,
201
+ reason: then_clause["reason"] || "Rule matched",
202
+ evaluator_name: @name,
203
+ metadata: metadata
204
+ )
205
+ end
206
+
207
+ matching
208
+ end
209
+
210
+ # Apply hit policy to matching evaluations
211
+ def apply_hit_policy(matching_evaluations)
212
+ hit_policy = @decision.decision_table.hit_policy
213
+
214
+ case hit_policy
215
+ when "UNIQUE"
216
+ apply_unique_policy(matching_evaluations)
217
+ when "FIRST"
218
+ apply_first_policy(matching_evaluations)
219
+ when "PRIORITY"
220
+ apply_priority_policy(matching_evaluations)
221
+ when "ANY"
222
+ apply_any_policy(matching_evaluations)
223
+ when "COLLECT"
224
+ apply_collect_policy(matching_evaluations)
225
+ else
226
+ # Default to FIRST if unknown policy
227
+ apply_first_policy(matching_evaluations)
228
+ end
229
+ end
230
+
231
+ # UNIQUE: Exactly one rule must match
232
+ def apply_unique_policy(matching_evaluations)
233
+ case matching_evaluations.size
234
+ when 0
235
+ raise Dmn::InvalidDmnModelError,
236
+ "UNIQUE hit policy requires exactly one matching rule, but none matched"
237
+ when 1
238
+ matching_evaluations.first
239
+ else
240
+ rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }.join(", ")
241
+ raise Dmn::InvalidDmnModelError,
242
+ "UNIQUE hit policy requires exactly one matching rule, but #{matching_evaluations.size} matched: #{rule_ids}"
243
+ end
244
+ end
245
+
246
+ # FIRST: Return first matching rule (already in order)
247
+ def apply_first_policy(matching_evaluations)
248
+ return nil if matching_evaluations.empty?
249
+
250
+ matching_evaluations.first
251
+ end
252
+
253
+ # PRIORITY: Return rule with highest priority
254
+ # For now, we use rule order as priority (first rule = highest priority)
255
+ # In full DMN spec, outputs can have priority values defined
256
+ def apply_priority_policy(matching_evaluations)
257
+ return nil if matching_evaluations.empty?
258
+
259
+ # For now, return first match (rules are already in priority order)
260
+ # Future enhancement: check output priority values if defined
261
+ matching_evaluations.first
262
+ end
263
+
264
+ # ANY: All matching rules must have same output
265
+ def apply_any_policy(matching_evaluations)
266
+ return nil if matching_evaluations.empty?
267
+
268
+ # Check that all decisions are the same
269
+ first_decision = matching_evaluations.first.decision
270
+ all_same = matching_evaluations.all? { |e| e.decision == first_decision }
271
+
272
+ unless all_same
273
+ decisions = matching_evaluations.map(&:decision).uniq.join(", ")
274
+ rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }.join(", ")
275
+ raise Dmn::InvalidDmnModelError,
276
+ "ANY hit policy requires all matching rules to have the same output, " \
277
+ "but found different outputs: #{decisions} (rules: #{rule_ids})"
278
+ end
279
+
280
+ matching_evaluations.first
281
+ end
282
+
283
+ # COLLECT: Return all matching rules
284
+ # Since Evaluation expects a single decision, we'll return the first one
285
+ # but include metadata about all matches
286
+ def apply_collect_policy(matching_evaluations)
287
+ return nil if matching_evaluations.empty?
288
+
289
+ # Return first evaluation but include all matches in metadata
290
+ first = matching_evaluations.first
291
+ all_decisions = matching_evaluations.map(&:decision)
292
+ all_rule_ids = matching_evaluations.map { |e| e.metadata[:rule_id] }
293
+
294
+ Evaluation.new(
295
+ decision: first.decision,
296
+ weight: first.weight,
297
+ reason: "COLLECT: #{matching_evaluations.size} rules matched",
298
+ evaluator_name: @name,
299
+ metadata: first.metadata.merge(
300
+ collect_count: matching_evaluations.size,
301
+ collect_decisions: all_decisions,
302
+ collect_rule_ids: all_rule_ids
303
+ )
304
+ )
305
+ end
306
+ end
307
+ end
308
+ end
@@ -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