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 "securerandom"
2
4
 
3
5
  module DecisionAgent
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Auth
3
5
  class SessionManager
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bcrypt"
2
4
  require "securerandom"
3
5
 
@@ -1,25 +1,39 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
4
+ # Immutable, thread-safe wrapper around input data passed to evaluators.
5
+ # Data is deep-copied and deep-frozen on construction.
2
6
  class Context
3
7
  attr_reader :data
4
8
 
9
+ # @param data [Hash, Object] Input data; non-Hash is treated as empty Hash
5
10
  def initialize(data)
6
11
  # Create a deep copy before freezing to avoid mutating the original
12
+ # This is necessary for thread-safety even if it adds some overhead
7
13
  data_hash = data.is_a?(Hash) ? data : {}
8
14
  @data = deep_freeze(deep_dup(data_hash))
9
15
  end
10
16
 
17
+ # @param key [Object] Key to look up
18
+ # @return [Object, nil] Value for key, or nil if missing
11
19
  def [](key)
12
20
  @data[key]
13
21
  end
14
22
 
23
+ # @param key [Object] Key to look up
24
+ # @param default [Object] Value returned when key is missing (default: nil)
25
+ # @return [Object] Value for key, or default
15
26
  def fetch(key, default = nil)
16
27
  @data.fetch(key, default)
17
28
  end
18
29
 
30
+ # @param key [Object] Key to check
31
+ # @return [Boolean] Whether the key exists
19
32
  def key?(key)
20
33
  @data.key?(key)
21
34
  end
22
35
 
36
+ # @return [Hash] The underlying frozen data hash
23
37
  def to_h
24
38
  @data
25
39
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
4
+ # Result of {Agent#decide}: the chosen decision, confidence, explanations, and audit data.
2
5
  class Decision
3
6
  attr_reader :decision, :confidence, :explanations, :evaluations, :audit_payload
4
7
 
@@ -14,16 +17,122 @@ module DecisionAgent
14
17
  freeze
15
18
  end
16
19
 
17
- def to_h
20
+ # Returns array of condition descriptions that led to this decision
21
+ # @param verbose [Boolean] If true, returns detailed condition information
22
+ # @return [Array<String>] Array of condition descriptions
23
+ def because(verbose: false)
24
+ all_explainability_results.flat_map { |er| er.because(verbose: verbose) }
25
+ end
26
+
27
+ # Returns array of condition descriptions that failed
28
+ # @param verbose [Boolean] If true, returns detailed condition information
29
+ # @return [Array<String>] Array of failed condition descriptions
30
+ def failed_conditions(verbose: false)
31
+ all_explainability_results.flat_map { |er| er.failed_conditions(verbose: verbose) }
32
+ end
33
+
34
+ # Returns explainability data in machine-readable format
35
+ # @param verbose [Boolean] If true, returns detailed explainability information
36
+ # @return [Hash] Explainability data
37
+ def explainability(verbose: false)
18
38
  {
19
39
  decision: @decision,
40
+ because: because(verbose: verbose),
41
+ failed_conditions: failed_conditions(verbose: verbose),
42
+ rule_traces: verbose ? all_explainability_results.map { |er| er.to_h(verbose: true) } : nil
43
+ }.compact
44
+ end
45
+
46
+ # Returns the decision as a hash (explainability-shaped plus confidence, evaluations, audit).
47
+ #
48
+ # @return [Hash] Symbol-keyed hash with :decision, :because, :failed_conditions, :confidence,
49
+ # :explanations, :evaluations, :audit_payload, :explainability
50
+ def to_h
51
+ # Structure decision result as explainability by default
52
+ # This makes explainability the primary format for decision results
53
+ explainability_data = explainability(verbose: false)
54
+
55
+ {
56
+ # Explainability fields (primary structure)
57
+ decision: explainability_data[:decision],
58
+ because: explainability_data[:because],
59
+ failed_conditions: explainability_data[:failed_conditions],
60
+ # Additional metadata for completeness
20
61
  confidence: @confidence,
21
62
  explanations: @explanations,
22
63
  evaluations: @evaluations.map(&:to_h),
23
- audit_payload: @audit_payload
64
+ audit_payload: @audit_payload,
65
+ # Full explainability data (includes rule_traces in verbose mode)
66
+ explainability: explainability_data
24
67
  }
25
68
  end
26
69
 
70
+ private
71
+
72
+ def all_explainability_results
73
+ @evaluations.flat_map { |evaluation| extract_explainability_from_evaluation(evaluation) }
74
+ end
75
+
76
+ def extract_explainability_from_evaluation(evaluation)
77
+ return [] unless evaluation.metadata.is_a?(Hash)
78
+ return [] unless evaluation.metadata[:explainability]
79
+
80
+ explainability_data = normalize_hash_keys(evaluation.metadata[:explainability])
81
+ rule_traces = reconstruct_rule_traces(explainability_data)
82
+ evaluator_name = explainability_data[:evaluator_name] || evaluation.evaluator_name
83
+
84
+ [Explainability::ExplainabilityResult.new(
85
+ evaluator_name: evaluator_name,
86
+ rule_traces: rule_traces
87
+ )]
88
+ end
89
+
90
+ def normalize_hash_keys(data)
91
+ return data unless data.is_a?(Hash)
92
+
93
+ data.transform_keys(&:to_sym)
94
+ end
95
+
96
+ def reconstruct_rule_traces(explainability_data)
97
+ rule_traces_data = explainability_data[:rule_traces] || []
98
+ rule_traces_data.map { |rt_data| reconstruct_rule_trace(rt_data) }
99
+ end
100
+
101
+ def reconstruct_rule_trace(rt_data)
102
+ normalized_rt = normalize_hash_keys(rt_data)
103
+ condition_traces = reconstruct_condition_traces(normalized_rt)
104
+
105
+ Explainability::RuleTrace.new(
106
+ rule_id: normalized_rt[:rule_id],
107
+ matched: normalized_rt[:matched],
108
+ condition_traces: condition_traces,
109
+ decision: normalized_rt[:decision],
110
+ weight: normalized_rt[:weight],
111
+ reason: normalized_rt[:reason]
112
+ )
113
+ end
114
+
115
+ def reconstruct_condition_traces(rule_trace_data)
116
+ condition_traces_data = rule_trace_data[:condition_traces] || []
117
+ condition_traces_data.map { |ct_data| reconstruct_condition_trace(ct_data) }
118
+ end
119
+
120
+ def reconstruct_condition_trace(ct_data)
121
+ normalized_ct = normalize_hash_keys(ct_data)
122
+
123
+ Explainability::ConditionTrace.new(
124
+ field: normalized_ct[:field],
125
+ operator: normalized_ct[:operator],
126
+ expected_value: normalized_ct[:expected_value],
127
+ actual_value: normalized_ct[:actual_value],
128
+ result: normalized_ct[:result]
129
+ )
130
+ end
131
+
132
+ public
133
+
134
+ # @param other [Object] Object to compare
135
+ # @return [Boolean] true if other is a Decision with same decision, confidence, explanations, evaluations
27
136
  def ==(other)
28
137
  other.is_a?(Decision) &&
29
138
  @decision == other.decision &&
@@ -35,8 +144,8 @@ module DecisionAgent
35
144
  private
36
145
 
37
146
  def validate_confidence!(confidence)
38
- c = confidence.to_f
39
- raise InvalidConfidenceError, confidence unless c.between?(0.0, 1.0)
147
+ confidence_value = confidence.to_f
148
+ raise InvalidConfidenceError, confidence unless confidence_value.between?(0.0, 1.0)
40
149
  end
41
150
 
42
151
  def deep_freeze(obj)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "feel/evaluator"
2
4
 
3
5
  module DecisionAgent
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require "openssl"
4
4
  require "zlib"
5
5
 
6
6
  module DecisionAgent
@@ -144,7 +144,7 @@ module DecisionAgent
144
144
  end
145
145
 
146
146
  def generate_result_key(decision_id, context_hash)
147
- Digest::SHA256.hexdigest("#{decision_id}:#{context_hash}")
147
+ OpenSSL::Digest::SHA256.hexdigest("#{decision_id}:#{context_hash}")
148
148
  end
149
149
 
150
150
  def calculate_hit_rate(hits, misses)
@@ -251,7 +251,8 @@ module DecisionAgent
251
251
  "condition",
252
252
  context
253
253
  )
254
- rescue StandardError
254
+ rescue StandardError => e
255
+ warn "[DecisionAgent] FEEL condition evaluation failed: #{e.message}"
255
256
  false
256
257
  end
257
258
  end
@@ -277,13 +278,12 @@ module DecisionAgent
277
278
  decision_node = graph.get_decision(decision_id)
278
279
 
279
280
  # Find all information requirements
280
- info_reqs = decision_xml.xpath(".//dmn:informationRequirement")
281
- info_reqs.each do |req|
281
+ decision_xml.xpath(".//dmn:informationRequirement").each do |req|
282
282
  required_decision = req.at_xpath(".//dmn:requiredDecision")
283
- if required_decision
284
- required_id = required_decision["href"]&.sub("#", "")
285
- decision_node.add_dependency(required_id) if required_id
286
- end
283
+ next unless required_decision
284
+
285
+ required_id = required_decision["href"]&.sub("#", "")
286
+ decision_node.add_dependency(required_id) if required_id
287
287
  end
288
288
  end
289
289
 
@@ -105,15 +105,13 @@ module DecisionAgent
105
105
  any_condition_evaluated = true
106
106
 
107
107
  return traverse(child, context) if result
108
- # Condition matched, continue down this branch
109
-
110
- # Condition evaluated to false - check if this child has a false branch
111
- # If child has multiple leaf children with no conditions, take the second one
112
- if !child.leaf? && child.children.all? { |c| c.condition.nil? && c.leaf? } && child.children.size > 1
113
- return child.children[1].decision
114
- end
115
- rescue StandardError
108
+
109
+ # Condition evaluated to false - check for a false branch
110
+ false_branch = false_branch_decision(child)
111
+ return false_branch if false_branch
112
+ rescue StandardError => e
116
113
  # If condition evaluation fails, skip this branch
114
+ warn "[DecisionAgent] Decision tree condition evaluation failed: #{e.message}"
117
115
  next
118
116
  end
119
117
  end
@@ -129,6 +127,16 @@ module DecisionAgent
129
127
  nil
130
128
  end
131
129
 
130
+ # Check if a child node that evaluated to false has an explicit false branch
131
+ # (multiple leaf children with no conditions, take the second one)
132
+ def false_branch_decision(child)
133
+ return nil if child.leaf?
134
+ return nil unless child.children.size > 1
135
+ return nil unless child.children.all? { |c| c.condition.nil? && c.leaf? }
136
+
137
+ child.children[1].decision
138
+ end
139
+
132
140
  def self.build_node(hash)
133
141
  node = TreeNode.new(
134
142
  id: hash[:id],
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Dmn
3
5
  # Base error for all DMN-related errors
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "nokogiri"
2
4
  require "set"
3
5
  require_relative "errors"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../errors"
2
4
  require_relative "simple_parser"
3
5
  require_relative "parser"
@@ -30,108 +32,15 @@ module DecisionAgent
30
32
  # @param field_name [String] The field name being evaluated
31
33
  # @param context [Hash] Evaluation context
32
34
  # @return [Boolean] Evaluation result
33
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
34
35
  def evaluate(expression, field_name, context)
35
36
  return true if expression == "-" # DMN "don't care" marker
36
37
 
37
38
  # Try Parslet parser first (Phase 2B)
38
- if @use_parslet
39
- begin
40
- expr_key = expression.to_s.strip
41
-
42
- # Check AST cache first
43
- ast = @cache_mutex.synchronize do
44
- @ast_cache[expr_key]
45
- end
46
-
47
- if ast.nil?
48
- parse_tree = @parslet_parser.parse(expr_key)
49
- ast = @transformer.apply(parse_tree)
50
- @cache_mutex.synchronize do
51
- @ast_cache[expr_key] = ast
52
- end
53
- end
54
-
55
- result = evaluate_ast_node(ast, context)
56
- # If result is nil and AST is a simple field reference that doesn't exist in context,
57
- # fall back to Phase 2A approach to return condition structure
58
- unless result.nil? && ast.is_a?(Hash) && ast[:type] == :field &&
59
- !context.key?(ast[:name]) && !context.key?(ast[:name].to_s) &&
60
- !context.key?(ast[:name].to_sym)
61
- return result
62
- end
63
- # Fall through to Phase 2A
64
- rescue Parslet::ParseFailed, FeelParseError, FeelTransformError => e
65
- # Fall back to Phase 2A approach
66
- warn "Parslet parse failed: #{e.message}, falling back" if ENV["DEBUG_FEEL"]
67
- end
68
- end
39
+ parslet_result = try_parslet_evaluate(expression, context) if @use_parslet
40
+ return parslet_result unless parslet_result == :fallback
69
41
 
70
42
  # Phase 2A approach: use condition structures
71
- # Check cache first (thread-safe)
72
- cache_key = "#{expression}::#{field_name}"
73
- condition = @cache_mutex.synchronize do
74
- @cache[cache_key]
75
- end
76
-
77
- return Dsl::ConditionEvaluator.evaluate(condition, context) if condition
78
-
79
- # Parse and translate expression to condition structure
80
- expr_str = expression.to_s.strip
81
-
82
- # Check if expression matches any known pattern that can be successfully parsed
83
- is_supported = literal?(expr_str) ||
84
- comparison_expression?(expr_str) ||
85
- list_expression?(expr_str) ||
86
- range_expression?(expr_str)
87
-
88
- # For SimpleParser, check if it can actually parse successfully
89
- if SimpleParser.can_parse?(expr_str)
90
- begin
91
- @simple_parser.parse(expr_str)
92
- is_supported = true
93
- rescue FeelParseError
94
- # SimpleParser says it can parse, but actually can't - not supported
95
- is_supported = false
96
- end
97
- end
98
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
99
-
100
- condition = parse_expression_to_condition(expression, field_name, context)
101
-
102
- # If parse_expression_to_condition returned nil, create default condition structure
103
- unless condition.is_a?(Hash)
104
- condition = {
105
- "field" => field_name,
106
- "op" => "eq",
107
- "value" => parse_value(expr_str)
108
- }
109
- end
110
-
111
- # Store in cache (thread-safe)
112
- @cache_mutex.synchronize do
113
- @cache[cache_key] = condition
114
- end
115
-
116
- # For completely unsupported expressions (no patterns matched), return condition structure
117
- # This allows fallback to literal equality for unknown syntax
118
- return condition unless is_supported
119
-
120
- # Delegate to existing ConditionEvaluator for supported expressions
121
- evaluation_result = Dsl::ConditionEvaluator.evaluate(condition, context)
122
-
123
- # If evaluation returns false for a simple equality check and the field doesn't exist in context,
124
- # treat as unsupported expression and return condition structure (fallback behavior)
125
- if evaluation_result == false && condition["op"] == "eq"
126
- field_key = condition["field"]
127
- field_exists = context.key?(field_key) || context.key?(field_key.to_s) || context.key?(field_key.to_sym)
128
- return condition unless field_exists
129
- end
130
-
131
- # If evaluation returns nil, return condition structure as fallback
132
- return condition if evaluation_result.nil?
133
-
134
- evaluation_result
43
+ evaluate_phase2a(expression, field_name, context)
135
44
  end
136
45
 
137
46
  # Parse FEEL expression into operator and value (for internal use by Adapter)
@@ -167,6 +76,93 @@ module DecisionAgent
167
76
 
168
77
  private
169
78
 
79
+ # Attempt evaluation via Parslet parser (Phase 2B)
80
+ # Returns :fallback if Parslet cannot handle this expression
81
+ def try_parslet_evaluate(expression, context)
82
+ ast = cached_parslet_ast(expression.to_s.strip)
83
+ result = evaluate_ast_node(ast, context)
84
+
85
+ # If result is nil for a missing field reference, fall back to Phase 2A
86
+ return :fallback if result.nil? && unresolved_field_reference?(ast, context)
87
+
88
+ result
89
+ rescue Parslet::ParseFailed, FeelParseError, FeelTransformError => e
90
+ warn "Parslet parse failed: #{e.message}, falling back" if ENV["DEBUG_FEEL"]
91
+ :fallback
92
+ end
93
+
94
+ # Fetch or build Parslet AST with thread-safe caching
95
+ def cached_parslet_ast(expr_key)
96
+ ast = @cache_mutex.synchronize { @ast_cache[expr_key] }
97
+ return ast if ast
98
+
99
+ parse_tree = @parslet_parser.parse(expr_key)
100
+ ast = @transformer.apply(parse_tree)
101
+ @cache_mutex.synchronize { @ast_cache[expr_key] = ast }
102
+ ast
103
+ end
104
+
105
+ def unresolved_field_reference?(ast, context)
106
+ ast.is_a?(Hash) && ast[:type] == :field &&
107
+ !context.key?(ast[:name]) && !context.key?(ast[:name].to_s) &&
108
+ !context.key?(ast[:name].to_sym)
109
+ end
110
+
111
+ # Phase 2A evaluation: regex-based patterns with condition structures
112
+ def evaluate_phase2a(expression, field_name, context)
113
+ cache_key = "#{expression}::#{field_name}"
114
+ condition = @cache_mutex.synchronize { @cache[cache_key] }
115
+ return Dsl::ConditionEvaluator.evaluate(condition, context) if condition
116
+
117
+ expr_str = expression.to_s.strip
118
+ is_supported = expression_supported?(expr_str)
119
+ condition = build_condition(expression, field_name, expr_str, context)
120
+
121
+ @cache_mutex.synchronize { @cache[cache_key] = condition }
122
+
123
+ return condition unless is_supported
124
+
125
+ evaluate_with_fallback(condition, context)
126
+ end
127
+
128
+ # Check if expression matches any known parseable pattern
129
+ def expression_supported?(expr_str)
130
+ supported = literal?(expr_str) || comparison_expression?(expr_str) ||
131
+ list_expression?(expr_str) || range_expression?(expr_str)
132
+
133
+ return supported unless SimpleParser.can_parse?(expr_str)
134
+
135
+ @simple_parser.parse(expr_str)
136
+ true
137
+ rescue FeelParseError
138
+ false
139
+ end
140
+
141
+ # Build a condition structure from expression, with fallback to default equality
142
+ def build_condition(expression, field_name, expr_str, context)
143
+ condition = parse_expression_to_condition(expression, field_name, context)
144
+ return condition if condition.is_a?(Hash)
145
+
146
+ { "field" => field_name, "op" => "eq", "value" => parse_value(expr_str) }
147
+ end
148
+
149
+ # Evaluate condition with fallback to condition structure when result is ambiguous
150
+ def evaluate_with_fallback(condition, context)
151
+ evaluation_result = Dsl::ConditionEvaluator.evaluate(condition, context)
152
+
153
+ return condition if evaluation_result.nil?
154
+ return condition if equality_on_missing_field?(evaluation_result, condition, context)
155
+
156
+ evaluation_result
157
+ end
158
+
159
+ def equality_on_missing_field?(result, condition, context)
160
+ return false unless result == false && condition["op"] == "eq"
161
+
162
+ field_key = condition["field"]
163
+ !context.key?(field_key) && !context.key?(field_key.to_s) && !context.key?(field_key.to_sym)
164
+ end
165
+
170
166
  def literal?(expr)
171
167
  # Quoted string
172
168
  return true if expr.start_with?('"') && expr.end_with?('"')
@@ -253,15 +249,37 @@ module DecisionAgent
253
249
  max_val = parse_value(range_match[3])
254
250
  inclusive_end = range_match[4] == "]"
255
251
 
256
- # For Phase 2A, we only support fully inclusive ranges
257
- # Map to 'between' operator
252
+ # For Phase 2A, we support fully inclusive ranges with 'between' operator
258
253
  if inclusive_start && inclusive_end
259
254
  { operator: "between", value: [min_val, max_val] }
260
255
  else
261
- # Fall back to complex condition (Phase 2B)
262
- raise FeelParseError,
263
- "Half-open ranges not yet supported: #{expr}. " \
264
- "Use [min..max] for inclusive ranges."
256
+ # For half-open ranges, convert to inclusive by adjusting bounds
257
+ # [min..max) becomes [min..max-1] (if max is integer) or use compound conditions
258
+ # For simplicity, we'll convert to inclusive ranges with adjusted bounds
259
+ # This is a pragmatic approach for Phase 2A
260
+ adjusted_min = if inclusive_start
261
+ min_val
262
+ elsif min_val.is_a?(Integer)
263
+ min_val + 1
264
+ else
265
+ min_val + 0.0001
266
+ end
267
+ adjusted_max = if inclusive_end
268
+ max_val
269
+ elsif max_val.is_a?(Integer)
270
+ max_val - 1
271
+ else
272
+ max_val - 0.0001
273
+ end
274
+
275
+ # Ensure adjusted range is valid
276
+ if adjusted_min <= adjusted_max
277
+ { operator: "between", value: [adjusted_min, adjusted_max] }
278
+ else
279
+ # Invalid range, fall back to error
280
+ raise FeelParseError,
281
+ "Invalid half-open range: #{expr}. Range would be empty after adjustment."
282
+ end
265
283
  end
266
284
  end
267
285
 
@@ -573,22 +591,20 @@ module DecisionAgent
573
591
 
574
592
  # Evaluate function call
575
593
  def evaluate_function_call(node, context)
576
- # Extract function name - could be a string or a field node
577
- function_name = if node[:name].is_a?(Hash)
578
- if node[:name][:type] == :field
579
- node[:name][:name]
580
- else
581
- node[:name][:name] || node[:name][:identifier] || node[:name].to_s
582
- end
583
- else
584
- node[:name]
585
- end
586
-
594
+ function_name = extract_function_name(node[:name])
587
595
  args = Array(node[:arguments]).map { |arg| evaluate_ast_node(arg, context) }
588
596
 
589
597
  Functions.execute(function_name.to_s, args, context)
590
598
  end
591
599
 
600
+ # Extract function name from a string or structured field node
601
+ def extract_function_name(name_node)
602
+ return name_node unless name_node.is_a?(Hash)
603
+ return name_node[:name] if name_node[:type] == :field
604
+
605
+ name_node[:name] || name_node[:identifier] || name_node.to_s
606
+ end
607
+
592
608
  # Evaluate property access
593
609
  def evaluate_property_access(node, context)
594
610
  object = evaluate_ast_node(node[:object], context)
@@ -600,7 +616,7 @@ module DecisionAgent
600
616
  when Types::Context
601
617
  object[property.to_sym]
602
618
  else
603
- object.respond_to?(property) ? object.send(property) : nil
619
+ object.respond_to?(property) ? object.public_send(property) : nil
604
620
  end
605
621
  end
606
622
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../errors"
2
4
  require_relative "types"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "parslet"
2
4
  require_relative "../errors"
3
5