decision_agent 0.3.0 → 1.1.0

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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -14
  3. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -13
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  10. data/lib/decision_agent/agent.rb +78 -9
  11. data/lib/decision_agent/audit/adapter.rb +2 -0
  12. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  13. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  14. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  15. data/lib/decision_agent/auth/authenticator.rb +2 -0
  16. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  18. data/lib/decision_agent/auth/permission.rb +2 -0
  19. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  20. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  22. data/lib/decision_agent/auth/role.rb +2 -0
  23. data/lib/decision_agent/auth/session.rb +2 -0
  24. data/lib/decision_agent/auth/session_manager.rb +2 -0
  25. data/lib/decision_agent/auth/user.rb +2 -0
  26. data/lib/decision_agent/context.rb +14 -0
  27. data/lib/decision_agent/decision.rb +113 -4
  28. data/lib/decision_agent/dmn/adapter.rb +2 -0
  29. data/lib/decision_agent/dmn/cache.rb +2 -2
  30. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  31. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  32. data/lib/decision_agent/dmn/errors.rb +2 -0
  33. data/lib/decision_agent/dmn/exporter.rb +2 -0
  34. data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
  35. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  36. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  38. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  39. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  40. data/lib/decision_agent/dmn/importer.rb +2 -0
  41. data/lib/decision_agent/dmn/model.rb +2 -4
  42. data/lib/decision_agent/dmn/parser.rb +2 -0
  43. data/lib/decision_agent/dmn/testing.rb +3 -2
  44. data/lib/decision_agent/dmn/validator.rb +5 -3
  45. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  46. data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
  47. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  48. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  49. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  50. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  51. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  52. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  53. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  54. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  55. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  56. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  57. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  58. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  59. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  60. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  61. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  62. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  63. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  64. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  65. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  66. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  67. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  68. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  69. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  70. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  71. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  72. data/lib/decision_agent/dsl/schema_validator.rb +37 -14
  73. data/lib/decision_agent/errors.rb +2 -0
  74. data/lib/decision_agent/evaluation.rb +14 -2
  75. data/lib/decision_agent/evaluators/base.rb +2 -0
  76. data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
  77. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
  78. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  79. data/lib/decision_agent/explainability/condition_trace.rb +85 -0
  80. data/lib/decision_agent/explainability/explainability_result.rb +50 -0
  81. data/lib/decision_agent/explainability/rule_trace.rb +41 -0
  82. data/lib/decision_agent/explainability/trace_collector.rb +26 -0
  83. data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
  84. data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
  85. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  87. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  88. data/lib/decision_agent/replay/replay.rb +4 -1
  89. data/lib/decision_agent/scoring/base.rb +2 -0
  90. data/lib/decision_agent/scoring/consensus.rb +2 -0
  91. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  92. data/lib/decision_agent/scoring/threshold.rb +2 -0
  93. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  94. data/lib/decision_agent/simulation/errors.rb +20 -0
  95. data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
  96. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
  97. data/lib/decision_agent/simulation/replay_engine.rb +488 -0
  98. data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
  99. data/lib/decision_agent/simulation/scenario_library.rb +165 -0
  100. data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
  101. data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
  102. data/lib/decision_agent/simulation.rb +19 -0
  103. data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
  104. data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
  105. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  106. data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
  107. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  108. data/lib/decision_agent/version.rb +3 -1
  109. data/lib/decision_agent/versioning/activerecord_adapter.rb +108 -43
  110. data/lib/decision_agent/versioning/adapter.rb +9 -0
  111. data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
  112. data/lib/decision_agent/versioning/version_manager.rb +9 -0
  113. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  114. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  115. data/lib/decision_agent/web/dmn_editor.rb +8 -67
  116. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  117. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  118. data/lib/decision_agent/web/public/app.js +186 -26
  119. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  120. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  121. data/lib/decision_agent/web/public/dmn-editor.js +74 -8
  122. data/lib/decision_agent/web/public/index.html +69 -3
  123. data/lib/decision_agent/web/public/login.html +1 -1
  124. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  125. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  126. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  127. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  128. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  129. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  130. data/lib/decision_agent/web/public/simulation.html +146 -0
  131. data/lib/decision_agent/web/public/simulation_impact.html +495 -0
  132. data/lib/decision_agent/web/public/simulation_replay.html +547 -0
  133. data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
  134. data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
  135. data/lib/decision_agent/web/public/styles.css +65 -0
  136. data/lib/decision_agent/web/public/users.html +1 -1
  137. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  138. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  139. data/lib/decision_agent/web/server.rb +2126 -1374
  140. data/lib/decision_agent.rb +19 -1
  141. data/lib/generators/decision_agent/install/install_generator.rb +2 -0
  142. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
  143. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  144. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
  145. data/lib/generators/decision_agent/install/templates/migration.rb +2 -0
  146. data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
  147. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
  148. metadata +103 -89
  149. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  150. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  151. data/spec/ab_testing/ab_test_spec.rb +0 -270
  152. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  153. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  154. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  155. data/spec/activerecord_thread_safety_spec.rb +0 -553
  156. data/spec/advanced_operators_spec.rb +0 -3150
  157. data/spec/agent_spec.rb +0 -289
  158. data/spec/api_contract_spec.rb +0 -430
  159. data/spec/audit_adapters_spec.rb +0 -92
  160. data/spec/auth/access_audit_logger_spec.rb +0 -394
  161. data/spec/auth/authenticator_spec.rb +0 -112
  162. data/spec/auth/password_reset_spec.rb +0 -294
  163. data/spec/auth/permission_checker_spec.rb +0 -207
  164. data/spec/auth/permission_spec.rb +0 -73
  165. data/spec/auth/rbac_adapter_spec.rb +0 -778
  166. data/spec/auth/rbac_config_spec.rb +0 -82
  167. data/spec/auth/role_spec.rb +0 -51
  168. data/spec/auth/session_manager_spec.rb +0 -172
  169. data/spec/auth/session_spec.rb +0 -112
  170. data/spec/auth/user_spec.rb +0 -130
  171. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  172. data/spec/context_spec.rb +0 -127
  173. data/spec/decision_agent_spec.rb +0 -96
  174. data/spec/decision_spec.rb +0 -423
  175. data/spec/dmn/decision_graph_spec.rb +0 -282
  176. data/spec/dmn/decision_tree_spec.rb +0 -203
  177. data/spec/dmn/feel/errors_spec.rb +0 -18
  178. data/spec/dmn/feel/functions_spec.rb +0 -400
  179. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  180. data/spec/dmn/feel/types_spec.rb +0 -176
  181. data/spec/dmn/feel_parser_spec.rb +0 -489
  182. data/spec/dmn/hit_policy_spec.rb +0 -202
  183. data/spec/dmn/integration_spec.rb +0 -226
  184. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  185. data/spec/dsl_validation_spec.rb +0 -648
  186. data/spec/edge_cases_spec.rb +0 -353
  187. data/spec/evaluation_spec.rb +0 -364
  188. data/spec/evaluation_validator_spec.rb +0 -165
  189. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  190. data/spec/examples.txt +0 -1909
  191. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  192. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  193. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  194. data/spec/issue_verification_spec.rb +0 -759
  195. data/spec/json_rule_evaluator_spec.rb +0 -587
  196. data/spec/monitoring/alert_manager_spec.rb +0 -378
  197. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  198. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  199. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  200. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  201. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  202. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  203. data/spec/performance_optimizations_spec.rb +0 -493
  204. data/spec/replay_edge_cases_spec.rb +0 -699
  205. data/spec/replay_spec.rb +0 -210
  206. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  207. data/spec/scoring_spec.rb +0 -225
  208. data/spec/spec_helper.rb +0 -60
  209. data/spec/testing/batch_test_importer_spec.rb +0 -693
  210. data/spec/testing/batch_test_runner_spec.rb +0 -307
  211. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  212. data/spec/testing/test_result_comparator_spec.rb +0 -392
  213. data/spec/testing/test_scenario_spec.rb +0 -113
  214. data/spec/thread_safety_spec.rb +0 -490
  215. data/spec/thread_safety_spec.rb.broken +0 -878
  216. data/spec/versioning/adapter_spec.rb +0 -156
  217. data/spec/versioning_spec.rb +0 -1030
  218. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  219. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  220. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../dmn/adapter"
2
4
  require_relative "../dmn/errors"
3
5
  require_relative "base"
@@ -39,9 +41,12 @@ module DecisionAgent
39
41
  def evaluate(context, feedback: {})
40
42
  hit_policy = @decision.decision_table.hit_policy
41
43
 
44
+ # Collect explainability traces
45
+ explainability_result = collect_explainability(context, hit_policy)
46
+
42
47
  # Short-circuit for FIRST and PRIORITY policies
43
48
  if %w[FIRST PRIORITY].include?(hit_policy)
44
- first_match = find_first_matching_evaluation(context, feedback: feedback)
49
+ first_match = find_first_matching_evaluation(context, explainability_result: explainability_result)
45
50
  return first_match if first_match
46
51
 
47
52
  # If no match found, return nil (consistent with apply_first_policy behavior)
@@ -49,10 +54,25 @@ module DecisionAgent
49
54
  end
50
55
 
51
56
  # For UNIQUE, ANY, COLLECT - need all matches
52
- matching_evaluations = find_all_matching_evaluations(context, feedback: feedback)
57
+ matching_evaluations = find_all_matching_evaluations(context, explainability_result: explainability_result)
53
58
 
54
59
  # Apply hit policy to select the appropriate evaluation
55
- apply_hit_policy(matching_evaluations)
60
+ result = apply_hit_policy(matching_evaluations)
61
+
62
+ # Add explainability to metadata by creating new Evaluation with updated metadata
63
+ if result && explainability_result
64
+ metadata = result.metadata.dup
65
+ metadata[:explainability] = explainability_result.to_h
66
+ result = Evaluation.new(
67
+ decision: result.decision,
68
+ weight: result.weight,
69
+ reason: result.reason,
70
+ evaluator_name: result.evaluator_name,
71
+ metadata: metadata
72
+ )
73
+ end
74
+
75
+ result
56
76
  end
57
77
 
58
78
  private
@@ -61,8 +81,51 @@ module DecisionAgent
61
81
  @name
62
82
  end
63
83
 
84
+ # Collect explainability traces for DMN evaluation
85
+ def collect_explainability(context, hit_policy)
86
+ ctx = context.is_a?(Context) ? context : Context.new(context)
87
+ rules = @rules_json["rules"] || []
88
+ rule_traces = []
89
+
90
+ rules.each do |rule|
91
+ rule_id = rule["id"] || "rule_#{rules.index(rule)}"
92
+ if_clause = rule["if"]
93
+ next unless if_clause
94
+
95
+ # Create trace collector for this rule
96
+ trace_collector = Explainability::TraceCollector.new
97
+
98
+ # Evaluate condition with tracing
99
+ matched = Dsl::ConditionEvaluator.evaluate(
100
+ if_clause,
101
+ ctx,
102
+ trace_collector: trace_collector
103
+ )
104
+
105
+ then_clause = rule["then"] || {}
106
+ rule_trace = Explainability::RuleTrace.new(
107
+ rule_id: rule_id,
108
+ matched: matched,
109
+ condition_traces: trace_collector.traces,
110
+ decision: then_clause["decision"],
111
+ weight: then_clause["weight"],
112
+ reason: then_clause["reason"]
113
+ )
114
+
115
+ rule_traces << rule_trace
116
+
117
+ # Stop after first match for FIRST/PRIORITY policies (short-circuit evaluation)
118
+ break if matched && %w[FIRST PRIORITY].include?(hit_policy)
119
+ end
120
+
121
+ Explainability::ExplainabilityResult.new(
122
+ evaluator_name: @name,
123
+ rule_traces: rule_traces
124
+ )
125
+ end
126
+
64
127
  # Find first matching rule (for short-circuiting)
65
- def find_first_matching_evaluation(context, feedback: {})
128
+ def find_first_matching_evaluation(context, explainability_result: nil, feedback: {})
66
129
  ctx = context.is_a?(Context) ? context : Context.new(context)
67
130
  rules = @rules_json["rules"] || []
68
131
 
@@ -70,20 +133,33 @@ module DecisionAgent
70
133
  if_clause = rule["if"]
71
134
  next unless if_clause
72
135
 
73
- next unless Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
136
+ # If explainability is already collected, use the trace data
137
+ matched = if explainability_result
138
+ rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
139
+ rule_trace&.matched
140
+ else
141
+ Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
142
+ end
143
+
144
+ next unless matched
74
145
 
75
146
  then_clause = rule["then"]
147
+ metadata = {
148
+ type: "dmn_rule",
149
+ rule_id: rule["id"],
150
+ ruleset: @rules_json["ruleset"],
151
+ hit_policy: @decision.decision_table.hit_policy
152
+ }
153
+
154
+ # Add explainability data to metadata
155
+ metadata[:explainability] = explainability_result.to_h if explainability_result
156
+
76
157
  return Evaluation.new(
77
158
  decision: then_clause["decision"],
78
159
  weight: then_clause["weight"] || 1.0,
79
160
  reason: then_clause["reason"] || "Rule matched",
80
161
  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
- }
162
+ metadata: metadata
87
163
  )
88
164
  end
89
165
 
@@ -91,7 +167,7 @@ module DecisionAgent
91
167
  end
92
168
 
93
169
  # Find all matching rules (not just first)
94
- def find_all_matching_evaluations(context, feedback: {})
170
+ def find_all_matching_evaluations(context, explainability_result: nil, feedback: {})
95
171
  ctx = context.is_a?(Context) ? context : Context.new(context)
96
172
  rules = @rules_json["rules"] || []
97
173
  matching = []
@@ -100,20 +176,33 @@ module DecisionAgent
100
176
  if_clause = rule["if"]
101
177
  next unless if_clause
102
178
 
103
- next unless Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
179
+ # If explainability is already collected, use the trace data
180
+ matched = if explainability_result
181
+ rule_trace = explainability_result.rule_traces.find { |rt| rt.rule_id == (rule["id"] || "rule_#{rules.index(rule)}") }
182
+ rule_trace&.matched
183
+ else
184
+ Dsl::ConditionEvaluator.evaluate(if_clause, ctx)
185
+ end
186
+
187
+ next unless matched
104
188
 
105
189
  then_clause = rule["then"]
190
+ metadata = {
191
+ type: "dmn_rule",
192
+ rule_id: rule["id"],
193
+ ruleset: @rules_json["ruleset"],
194
+ hit_policy: @decision.decision_table.hit_policy
195
+ }
196
+
197
+ # Add explainability data to metadata (will be added to final result)
198
+ # For now, we'll add it to the final result after hit policy is applied
199
+
106
200
  matching << Evaluation.new(
107
201
  decision: then_clause["decision"],
108
202
  weight: then_clause["weight"] || 1.0,
109
203
  reason: then_clause["reason"] || "Rule matched",
110
204
  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
- }
205
+ metadata: metadata
117
206
  )
118
207
  end
119
208
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
 
3
5
  module DecisionAgent
@@ -19,38 +21,81 @@ module DecisionAgent
19
21
  end
20
22
 
21
23
  def evaluate(context, feedback: {})
22
- ctx = context.is_a?(Context) ? context : Context.new(context)
24
+ ctx = context.is_a?(DecisionAgent::Context) ? context : DecisionAgent::Context.new(context)
25
+
26
+ # Collect explainability traces (this also finds the matching rule)
27
+ explainability_result = collect_explainability(ctx)
23
28
 
24
- matched_rule = find_first_matching_rule(ctx)
29
+ # Find the matched rule from explainability result
30
+ matched_rule_trace = explainability_result&.matched_rules&.first
31
+ return nil unless matched_rule_trace
25
32
 
33
+ # Find the original rule to get the then clause
34
+ rules = @ruleset["rules"] || []
35
+ matched_rule = rules.find { |r| (r["id"] || "rule_#{rules.index(r)}") == matched_rule_trace.rule_id }
26
36
  return nil unless matched_rule
27
37
 
28
38
  then_clause = matched_rule["then"]
29
39
 
40
+ metadata = {
41
+ type: "json_rule",
42
+ rule_id: matched_rule["id"],
43
+ ruleset: @ruleset_name
44
+ }
45
+
46
+ # Add explainability data to metadata
47
+ metadata[:explainability] = explainability_result.to_h if explainability_result
48
+
30
49
  Evaluation.new(
31
50
  decision: then_clause["decision"],
32
51
  weight: then_clause["weight"] || 1.0,
33
52
  reason: then_clause["reason"] || "Rule matched",
34
53
  evaluator_name: @name,
35
- metadata: {
36
- type: "json_rule",
37
- rule_id: matched_rule["id"],
38
- ruleset: @ruleset_name
39
- }
54
+ metadata: metadata
40
55
  )
41
56
  end
42
57
 
43
58
  private
44
59
 
45
- def find_first_matching_rule(context)
60
+ def collect_explainability(context)
46
61
  rules = @ruleset["rules"] || []
62
+ rule_traces = []
47
63
 
48
- rules.find do |rule|
64
+ rules.each do |rule|
65
+ rule_id = rule["id"] || "rule_#{rules.index(rule)}"
49
66
  if_clause = rule["if"]
50
- next false unless if_clause
67
+ next unless if_clause
68
+
69
+ # Create trace collector for this rule
70
+ trace_collector = Explainability::TraceCollector.new
51
71
 
52
- Dsl::ConditionEvaluator.evaluate(if_clause, context)
72
+ # Evaluate condition with tracing
73
+ matched = Dsl::ConditionEvaluator.evaluate(
74
+ if_clause,
75
+ context,
76
+ trace_collector: trace_collector
77
+ )
78
+
79
+ then_clause = rule["then"] || {}
80
+ rule_trace = Explainability::RuleTrace.new(
81
+ rule_id: rule_id,
82
+ matched: matched,
83
+ condition_traces: trace_collector.traces,
84
+ decision: then_clause["decision"],
85
+ weight: then_clause["weight"],
86
+ reason: then_clause["reason"]
87
+ )
88
+
89
+ rule_traces << rule_trace
90
+
91
+ # Stop after first match (short-circuit evaluation)
92
+ break if matched
53
93
  end
94
+
95
+ Explainability::ExplainabilityResult.new(
96
+ evaluator_name: @name,
97
+ rule_traces: rule_traces
98
+ )
54
99
  end
55
100
 
56
101
  # Deep freeze helper method
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Evaluators
3
5
  class StaticEvaluator < Base
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Explainability
5
+ # Represents a single condition evaluation with its result and values
6
+ class ConditionTrace
7
+ attr_reader :field, :operator, :expected_value, :actual_value, :result, :description
8
+
9
+ def initialize(field:, operator:, expected_value:, actual_value:, result:, description: nil)
10
+ @field = field.to_s.freeze
11
+ @operator = operator.to_s.freeze
12
+ @expected_value = expected_value
13
+ @actual_value = actual_value
14
+ @result = result
15
+ @description = description ? description.to_s.freeze : generate_description
16
+ freeze
17
+ end
18
+
19
+ def passed?
20
+ @result == true
21
+ end
22
+
23
+ def failed?
24
+ @result == false
25
+ end
26
+
27
+ def to_s
28
+ @description
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ field: @field,
34
+ operator: @operator,
35
+ expected_value: @expected_value,
36
+ actual_value: @actual_value,
37
+ result: @result,
38
+ description: @description
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def generate_description
45
+ case @operator
46
+ when "eq"
47
+ "#{@field} = #{format_value(@actual_value)}"
48
+ when "neq"
49
+ "#{@field} != #{format_value(@expected_value)}"
50
+ when "gt"
51
+ "#{@field} > #{format_value(@expected_value)}"
52
+ when "gte"
53
+ "#{@field} >= #{format_value(@expected_value)}"
54
+ when "lt"
55
+ "#{@field} < #{format_value(@expected_value)}"
56
+ when "lte"
57
+ "#{@field} <= #{format_value(@expected_value)}"
58
+ when "in"
59
+ "#{@field} in #{format_value(@expected_value)}"
60
+ when "contains"
61
+ "#{@field} contains #{format_value(@expected_value)}"
62
+ when "present"
63
+ "#{@field} is present"
64
+ when "blank"
65
+ "#{@field} is blank"
66
+ else
67
+ "#{@field} #{@operator} #{format_value(@expected_value)}"
68
+ end
69
+ end
70
+
71
+ def format_value(value)
72
+ case value
73
+ when String
74
+ value.inspect
75
+ when Array, Hash
76
+ value.inspect
77
+ when Time, Date, DateTime
78
+ value.to_s
79
+ else
80
+ value.to_s
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Explainability
5
+ # Container for all explainability data from a decision evaluation
6
+ class ExplainabilityResult
7
+ attr_reader :rule_traces, :evaluator_name
8
+
9
+ def initialize(evaluator_name:, rule_traces: [])
10
+ @evaluator_name = evaluator_name.to_s.freeze
11
+ @rule_traces = Array(rule_traces).freeze
12
+ freeze
13
+ end
14
+
15
+ def matched_rules
16
+ @rule_traces.select(&:matched)
17
+ end
18
+
19
+ def all_passed_conditions
20
+ @rule_traces.flat_map(&:passed_conditions)
21
+ end
22
+
23
+ def all_failed_conditions
24
+ @rule_traces.flat_map(&:failed_conditions)
25
+ end
26
+
27
+ def because(verbose: false) # rubocop:disable Lint/UnusedMethodArgument
28
+ # verbose parameter kept for API compatibility, but currently both modes return same format
29
+ all_passed_conditions.map(&:description)
30
+ end
31
+
32
+ def failed_conditions(verbose: false)
33
+ if verbose
34
+ all_failed_conditions.map(&:to_h)
35
+ else
36
+ all_failed_conditions.map(&:description)
37
+ end
38
+ end
39
+
40
+ def to_h(verbose: false)
41
+ {
42
+ evaluator_name: @evaluator_name,
43
+ rule_traces: @rule_traces.map(&:to_h), # Always include full rule traces for reconstruction
44
+ because: because(verbose: verbose),
45
+ failed_conditions: failed_conditions(verbose: verbose)
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Explainability
5
+ # Represents the trace of a rule evaluation including all conditions
6
+ class RuleTrace
7
+ attr_reader :rule_id, :matched, :condition_traces, :decision, :weight, :reason
8
+
9
+ def initialize(rule_id:, matched:, condition_traces: [], decision: nil, weight: nil, reason: nil)
10
+ @rule_id = rule_id.to_s.freeze
11
+ @matched = matched
12
+ @condition_traces = Array(condition_traces).freeze
13
+ @decision = decision ? decision.to_s.freeze : nil
14
+ @weight = weight
15
+ @reason = reason ? reason.to_s.freeze : nil
16
+ freeze
17
+ end
18
+
19
+ def passed_conditions
20
+ @condition_traces.select(&:passed?)
21
+ end
22
+
23
+ def failed_conditions
24
+ @condition_traces.select(&:failed?)
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ rule_id: @rule_id,
30
+ matched: @matched,
31
+ decision: @decision,
32
+ weight: @weight,
33
+ reason: @reason,
34
+ condition_traces: @condition_traces.map(&:to_h),
35
+ passed_conditions: passed_conditions.map(&:description),
36
+ failed_conditions: failed_conditions.map(&:description)
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ module Explainability
5
+ # Collects condition traces during evaluation
6
+ class TraceCollector
7
+ attr_reader :traces
8
+
9
+ def initialize
10
+ @traces = []
11
+ end
12
+
13
+ def add_trace(trace)
14
+ @traces << trace
15
+ end
16
+
17
+ def clear
18
+ @traces.clear
19
+ end
20
+
21
+ def empty?
22
+ @traces.empty?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "monitor"
2
4
 
3
5
  module DecisionAgent
@@ -16,6 +18,7 @@ module DecisionAgent
16
18
  @alert_handlers = []
17
19
  @check_interval = 60 # seconds
18
20
  @monitoring_thread = nil
21
+ @rule_counter = 0
19
22
  freeze_config
20
23
  end
21
24
 
@@ -176,21 +179,6 @@ module DecisionAgent
176
179
  end
177
180
  end
178
181
 
179
- def self.decision_anomaly(expected_rate: 100, variance: 0.3)
180
- lambda do |stats|
181
- total = stats.dig(:decisions, :total) || 0
182
- time_range = stats.dig(:summary, :time_range)
183
-
184
- # Simple anomaly detection based on rate
185
- return false unless time_range
186
-
187
- lower_bound = expected_rate * (1 - variance)
188
- upper_bound = expected_rate * (1 + variance)
189
-
190
- total < lower_bound || total > upper_bound
191
- end
192
- end
193
-
194
182
  private
195
183
 
196
184
  def freeze_config
@@ -198,7 +186,10 @@ module DecisionAgent
198
186
  end
199
187
 
200
188
  def generate_rule_id(name)
201
- "#{sanitize_name(name)}_#{Time.now.to_i}_#{rand(1000)}"
189
+ synchronize do
190
+ @rule_counter += 1
191
+ "#{sanitize_name(name)}_#{Time.now.to_i}_#{@rule_counter}"
192
+ end
202
193
  end
203
194
 
204
195
  def sanitize_name(name)