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,5 +1,35 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "set"
2
4
 
5
+ # Operator mixins
6
+ require_relative "operators/base"
7
+ require_relative "operators/basic_comparison_operators"
8
+ require_relative "operators/string_operators"
9
+ require_relative "operators/numeric_operators"
10
+ require_relative "operators/mathematical_operators"
11
+ require_relative "operators/statistical_aggregations"
12
+ require_relative "operators/date_time_operators"
13
+ require_relative "operators/duration_operators"
14
+ require_relative "operators/date_arithmetic_operators"
15
+ require_relative "operators/time_component_operators"
16
+ require_relative "operators/rate_operators"
17
+ require_relative "operators/moving_window_operators"
18
+ require_relative "operators/financial_operators"
19
+ require_relative "operators/string_aggregations"
20
+ require_relative "operators/collection_operators"
21
+ require_relative "operators/geospatial_operators"
22
+
23
+ # Helper modules
24
+ require_relative "helpers/cache_helpers"
25
+ require_relative "helpers/date_helpers"
26
+ require_relative "helpers/geospatial_helpers"
27
+ require_relative "helpers/template_helpers"
28
+ require_relative "helpers/parameter_parsing_helpers"
29
+ require_relative "helpers/comparison_helpers"
30
+ require_relative "helpers/operator_evaluation_helpers"
31
+ require_relative "helpers/utility_helpers"
32
+
3
33
  module DecisionAgent
4
34
  module Dsl
5
35
  # Evaluates conditions in the rule DSL against a context
@@ -8,7 +38,6 @@ module DecisionAgent
8
38
  # - Field conditions with various operators
9
39
  # - Nested field access via dot notation (e.g., "user.profile.role")
10
40
  # - Logical operators (all/any)
11
- # rubocop:disable Metrics/ClassLength
12
41
  class ConditionEvaluator
13
42
  # Thread-safe caches for performance optimization
14
43
  @regex_cache = {}
@@ -26,15 +55,20 @@ module DecisionAgent
26
55
  attr_reader :regex_cache, :path_cache, :date_cache, :geospatial_cache, :param_cache
27
56
  end
28
57
 
29
- def self.evaluate(condition, context)
58
+ def self.evaluate(condition, context, enriched_context_hash: nil, trace_collector: nil)
30
59
  return false unless condition.is_a?(Hash)
31
60
 
61
+ # Use enriched context hash if provided, otherwise create mutable copy
62
+ # This ensures all conditions in the same evaluation share the same enriched hash
63
+ enriched = enriched_context_hash
64
+ enriched ||= context.to_h.dup
65
+
32
66
  if condition.key?("all")
33
- evaluate_all(condition["all"], context)
67
+ evaluate_all(condition["all"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
34
68
  elsif condition.key?("any")
35
- evaluate_any(condition["any"], context)
69
+ evaluate_any(condition["any"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
36
70
  elsif condition.key?("field")
37
- evaluate_field_condition(condition, context)
71
+ evaluate_field_condition(condition, context, enriched_context_hash: enriched, trace_collector: trace_collector)
38
72
  else
39
73
  false
40
74
  end
@@ -42,957 +76,90 @@ module DecisionAgent
42
76
 
43
77
  # Evaluates 'all' condition - returns true only if ALL sub-conditions are true
44
78
  # Empty array returns true (vacuous truth)
45
- def self.evaluate_all(conditions, context)
79
+ def self.evaluate_all(conditions, context, enriched_context_hash: nil, trace_collector: nil)
46
80
  return true if conditions.is_a?(Array) && conditions.empty?
47
81
  return false unless conditions.is_a?(Array)
48
82
 
49
- conditions.all? { |cond| evaluate(cond, context) }
83
+ # Use enriched context hash if provided, otherwise create mutable copy
84
+ # All conditions share the same enriched hash so data enrichment persists
85
+ enriched = enriched_context_hash
86
+ enriched ||= context.to_h.dup
87
+
88
+ conditions.all? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
50
89
  end
51
90
 
52
91
  # Evaluates 'any' condition - returns true if AT LEAST ONE sub-condition is true
53
92
  # Empty array returns false (no options to match)
54
- def self.evaluate_any(conditions, context)
93
+ def self.evaluate_any(conditions, context, enriched_context_hash: nil, trace_collector: nil)
55
94
  return false unless conditions.is_a?(Array)
56
95
 
57
- conditions.any? { |cond| evaluate(cond, context) }
96
+ # Use enriched context hash if provided, otherwise create mutable copy
97
+ # All conditions share the same enriched hash so data enrichment persists
98
+ enriched = enriched_context_hash
99
+ enriched ||= context.to_h.dup
100
+
101
+ conditions.any? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
58
102
  end
59
103
 
60
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
61
- def self.evaluate_field_condition(condition, context)
104
+ def self.evaluate_field_condition(condition, context, enriched_context_hash: nil, trace_collector: nil)
62
105
  field = condition["field"]
63
106
  op = condition["op"]
64
107
  expected_value = condition["value"]
65
108
 
66
109
  # Special handling for "don't care" conditions (from DMN "-" entries)
67
- return true if field == "__always_match__" && op == "eq" && expected_value == true
110
+ result = handle_dont_care_condition(field, op, expected_value, trace_collector)
111
+ return result if result == true
68
112
 
69
- context_hash = context.to_h
113
+ # Use enriched context hash if provided, otherwise create mutable copy
114
+ # This ensures all conditions in the same evaluation share the same enriched hash
115
+ context_hash = enriched_context_hash || context.to_h.dup
70
116
  actual_value = get_nested_value(context_hash, field)
71
117
 
72
- case op
73
- when "eq"
74
- # Equality - uses Ruby's == for comparison
75
- actual_value == expected_value
76
-
77
- when "neq"
78
- # Not equal - inverse of ==
79
- actual_value != expected_value
80
-
81
- when "gt"
82
- # Greater than - only for comparable types (numbers, strings)
83
- comparable?(actual_value, expected_value) && actual_value > expected_value
84
-
85
- when "gte"
86
- # Greater than or equal - only for comparable types
87
- comparable?(actual_value, expected_value) && actual_value >= expected_value
88
-
89
- when "lt"
90
- # Less than - only for comparable types
91
- comparable?(actual_value, expected_value) && actual_value < expected_value
92
-
93
- when "lte"
94
- # Less than or equal - only for comparable types
95
- comparable?(actual_value, expected_value) && actual_value <= expected_value
96
-
97
- when "in"
98
- # Array membership - checks if actual_value is in the expected array
99
- Array(expected_value).include?(actual_value)
100
-
101
- when "present"
102
- # PRESENT SEMANTICS:
103
- # Returns true if value exists AND is not empty
104
- # - nil: false
105
- # - Empty string "": false
106
- # - Empty array []: false
107
- # - Empty hash {}: false
108
- # - Zero 0: true (zero is a valid value)
109
- # - False boolean: true (false is a valid value)
110
- # - Non-empty values: true
111
- !actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
112
-
113
- when "blank"
114
- # BLANK SEMANTICS:
115
- # Returns true if value is nil OR empty
116
- # - nil: true
117
- # - Empty string "": true
118
- # - Empty array []: true
119
- # - Empty hash {}: true
120
- # - Zero 0: false (zero is a valid value)
121
- # - False boolean: false (false is a valid value)
122
- # - Non-empty values: false
123
- actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
124
-
125
- # STRING OPERATORS
126
- when "contains"
127
- # Checks if string contains substring (case-sensitive)
128
- string_operator?(actual_value, expected_value) &&
129
- actual_value.include?(expected_value)
130
-
131
- when "starts_with"
132
- # Checks if string starts with prefix (case-sensitive)
133
- string_operator?(actual_value, expected_value) &&
134
- actual_value.start_with?(expected_value)
135
-
136
- when "ends_with"
137
- # Checks if string ends with suffix (case-sensitive)
138
- string_operator?(actual_value, expected_value) &&
139
- actual_value.end_with?(expected_value)
140
-
141
- when "matches"
142
- # Matches string against regular expression
143
- # expected_value can be a string (converted to regex) or Regexp object
144
- return false unless actual_value.is_a?(String)
145
- return false if expected_value.nil?
146
-
147
- begin
148
- regex = get_cached_regex(expected_value)
149
- !regex.match(actual_value).nil?
150
- rescue RegexpError
151
- false
152
- end
153
-
154
- # NUMERIC OPERATORS
155
- when "between"
156
- # Checks if numeric value is between min and max (inclusive)
157
- # expected_value should be [min, max] or {min: x, max: y}
158
- return false unless actual_value.is_a?(Numeric)
159
-
160
- range = parse_range(expected_value)
161
- return false unless range
162
-
163
- actual_value.between?(range[:min], range[:max])
164
-
165
- when "modulo"
166
- # Checks if value modulo divisor equals remainder
167
- # expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
168
- return false unless actual_value.is_a?(Numeric)
169
-
170
- params = parse_modulo_params(expected_value)
171
- return false unless params
172
-
173
- (actual_value % params[:divisor]) == params[:remainder]
174
-
175
- # MATHEMATICAL FUNCTIONS
176
- # Trigonometric functions
177
- when "sin"
178
- # Checks if sin(field_value) equals expected_value
179
- # expected_value is the expected result of sin(actual_value)
180
- return false unless actual_value.is_a?(Numeric)
181
- return false unless expected_value.is_a?(Numeric)
182
-
183
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
184
- result = Math.sin(actual_value)
185
- (result - expected_value).abs < 1e-10
186
-
187
- when "cos"
188
- # Checks if cos(field_value) equals expected_value
189
- # expected_value is the expected result of cos(actual_value)
190
- return false unless actual_value.is_a?(Numeric)
191
- return false unless expected_value.is_a?(Numeric)
192
-
193
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
194
- result = Math.cos(actual_value)
195
- (result - expected_value).abs < 1e-10
196
-
197
- when "tan"
198
- # Checks if tan(field_value) equals expected_value
199
- # expected_value is the expected result of tan(actual_value)
200
- return false unless actual_value.is_a?(Numeric)
201
- return false unless expected_value.is_a?(Numeric)
202
-
203
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
204
- result = Math.tan(actual_value)
205
- (result - expected_value).abs < 1e-10
206
-
207
- # Exponential and logarithmic functions
208
- when "sqrt"
209
- # Checks if sqrt(field_value) equals expected_value
210
- # expected_value is the expected result of sqrt(actual_value)
211
- return false unless actual_value.is_a?(Numeric)
212
- return false unless expected_value.is_a?(Numeric)
213
- return false if actual_value.negative? # sqrt of negative number is invalid
214
-
215
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
216
- result = Math.sqrt(actual_value)
217
- (result - expected_value).abs < 1e-10
218
-
219
- when "power"
220
- # Checks if power(field_value, exponent) equals result
221
- # expected_value should be [exponent, result] or {exponent: x, result: y}
222
- return false unless actual_value.is_a?(Numeric)
223
-
224
- params = parse_power_params(expected_value)
225
- return false unless params
226
-
227
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
228
- result = actual_value**params[:exponent]
229
- (result - params[:result]).abs < 1e-10
230
-
231
- when "exp"
232
- # Checks if exp(field_value) equals expected_value
233
- # expected_value is the expected result of exp(actual_value) (e^actual_value)
234
- return false unless actual_value.is_a?(Numeric)
235
- return false unless expected_value.is_a?(Numeric)
236
-
237
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
238
- result = Math.exp(actual_value)
239
- (result - expected_value).abs < 1e-10
240
-
241
- when "log"
242
- # Checks if log(field_value) equals expected_value
243
- # expected_value is the expected result of log(actual_value) (natural logarithm)
244
- return false unless actual_value.is_a?(Numeric)
245
- return false unless expected_value.is_a?(Numeric)
246
- return false if actual_value <= 0 # log of non-positive number is invalid
247
-
248
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
249
- result = Math.log(actual_value)
250
- (result - expected_value).abs < 1e-10
251
-
252
- # Rounding and absolute value functions
253
- when "round"
254
- # Checks if round(field_value) equals expected_value
255
- # expected_value is the expected result of round(actual_value)
256
- return false unless actual_value.is_a?(Numeric)
257
- return false unless expected_value.is_a?(Numeric)
258
-
259
- actual_value.round == expected_value
260
-
261
- when "floor"
262
- # Checks if floor(field_value) equals expected_value
263
- # expected_value is the expected result of floor(actual_value)
264
- return false unless actual_value.is_a?(Numeric)
265
- return false unless expected_value.is_a?(Numeric)
266
-
267
- actual_value.floor == expected_value
268
-
269
- when "ceil"
270
- # Checks if ceil(field_value) equals expected_value
271
- # expected_value is the expected result of ceil(actual_value)
272
- return false unless actual_value.is_a?(Numeric)
273
- return false unless expected_value.is_a?(Numeric)
274
-
275
- actual_value.ceil == expected_value
276
-
277
- when "abs"
278
- # Checks if abs(field_value) equals expected_value
279
- # expected_value is the expected result of abs(actual_value)
280
- return false unless actual_value.is_a?(Numeric)
281
- return false unless expected_value.is_a?(Numeric)
282
-
283
- actual_value.abs == expected_value
284
-
285
- # Aggregation functions
286
- when "min"
287
- # Checks if min(field_value) equals expected_value
288
- # field_value should be an array, expected_value is the minimum value
289
- return false unless actual_value.is_a?(Array)
290
- return false if actual_value.empty?
291
- return false unless expected_value.is_a?(Numeric)
292
-
293
- actual_value.min == expected_value
294
-
295
- when "max"
296
- # Checks if max(field_value) equals expected_value
297
- # field_value should be an array, expected_value is the maximum value
298
- return false unless actual_value.is_a?(Array)
299
- return false if actual_value.empty?
300
- return false unless expected_value.is_a?(Numeric)
301
-
302
- actual_value.max == expected_value
303
-
304
- # STATISTICAL AGGREGATIONS
305
- when "sum"
306
- # Checks if sum of numeric array equals expected_value
307
- # expected_value can be numeric or hash with comparison operators
308
- return false unless actual_value.is_a?(Array)
309
- return false if actual_value.empty?
310
-
311
- # OPTIMIZE: calculate sum in single pass, filtering as we go
312
- sum_value = 0.0
313
- found_numeric = false
314
- actual_value.each do |v|
315
- if v.is_a?(Numeric)
316
- sum_value += v
317
- found_numeric = true
318
- end
319
- end
320
- return false unless found_numeric
321
-
322
- compare_aggregation_result(sum_value, expected_value)
323
-
324
- when "average", "mean"
325
- # Checks if average of numeric array equals expected_value
326
- return false unless actual_value.is_a?(Array)
327
- return false if actual_value.empty?
328
-
329
- # OPTIMIZE: calculate sum and count in single pass
330
- sum_value = 0.0
331
- count = 0
332
- actual_value.each do |v|
333
- if v.is_a?(Numeric)
334
- sum_value += v
335
- count += 1
336
- end
337
- end
338
- return false if count.zero?
339
-
340
- avg_value = sum_value / count
341
- compare_aggregation_result(avg_value, expected_value)
342
-
343
- when "median"
344
- # Checks if median of numeric array equals expected_value
345
- return false unless actual_value.is_a?(Array)
346
- return false if actual_value.empty?
347
-
348
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
349
- return false if numeric_array.empty?
350
-
351
- median_value = if numeric_array.size.odd?
352
- numeric_array[numeric_array.size / 2]
353
- else
354
- (numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
355
- end
356
- compare_aggregation_result(median_value, expected_value)
357
-
358
- when "stddev", "standard_deviation"
359
- # Checks if standard deviation of numeric array equals expected_value
360
- return false unless actual_value.is_a?(Array)
361
- return false if actual_value.size < 2
362
-
363
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
364
- return false if numeric_array.size < 2
365
-
366
- mean = numeric_array.sum.to_f / numeric_array.size
367
- variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
368
- stddev_value = Math.sqrt(variance)
369
- compare_aggregation_result(stddev_value, expected_value)
370
-
371
- when "variance"
372
- # Checks if variance of numeric array equals expected_value
373
- return false unless actual_value.is_a?(Array)
374
- return false if actual_value.size < 2
375
-
376
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
377
- return false if numeric_array.size < 2
378
-
379
- mean = numeric_array.sum.to_f / numeric_array.size
380
- variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
381
- compare_aggregation_result(variance_value, expected_value)
382
-
383
- when "percentile"
384
- # Checks if Nth percentile of numeric array meets threshold
385
- # expected_value: {percentile: 95, threshold: 200} or {percentile: 95, gt: 200, lt: 500}
386
- return false unless actual_value.is_a?(Array)
387
- return false if actual_value.empty?
388
-
389
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
390
- return false if numeric_array.empty?
391
-
392
- params = parse_percentile_params(expected_value)
393
- return false unless params
394
-
395
- percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
396
- percentile_value = if percentile_index == percentile_index.to_i
397
- numeric_array[percentile_index.to_i]
398
- else
399
- lower = numeric_array[percentile_index.floor]
400
- upper = numeric_array[percentile_index.ceil]
401
- lower + ((upper - lower) * (percentile_index - percentile_index.floor))
402
- end
403
-
404
- compare_percentile_result(percentile_value, params)
405
-
406
- when "count"
407
- # Checks if count of array elements meets threshold
408
- # expected_value can be numeric or hash with comparison operators
409
- return false unless actual_value.is_a?(Array)
410
-
411
- count_value = actual_value.size
412
- compare_aggregation_result(count_value, expected_value)
413
-
414
- # DATE/TIME OPERATORS
415
- when "before_date"
416
- # Checks if date is before specified date
417
- compare_dates(actual_value, expected_value, :<)
418
-
419
- when "after_date"
420
- # Checks if date is after specified date
421
- compare_dates(actual_value, expected_value, :>)
422
-
423
- when "within_days"
424
- # Checks if date is within N days from now (past or future)
425
- # expected_value is number of days
426
- return false unless actual_value
427
- return false unless expected_value.is_a?(Numeric)
428
-
429
- date = parse_date(actual_value)
430
- return false unless date
431
-
432
- now = Time.now
433
- diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
434
- diff_days <= expected_value
435
-
436
- when "day_of_week"
437
- # Checks if date falls on specified day of week
438
- # expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
439
- return false unless actual_value
440
-
441
- date = parse_date(actual_value)
442
- return false unless date
443
-
444
- expected_day = normalize_day_of_week(expected_value)
445
- return false unless expected_day
446
-
447
- date.wday == expected_day
448
-
449
- # DURATION CALCULATIONS
450
- when "duration_seconds"
451
- # Calculates duration between two dates in seconds
452
- # expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
453
- return false unless actual_value
454
-
455
- start_date = parse_date(actual_value)
456
- return false unless start_date
457
-
458
- params = parse_duration_params(expected_value)
459
- return false unless params
460
-
461
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
462
- return false unless end_date
463
-
464
- duration = (end_date - start_date).abs
465
- compare_duration_result(duration, params)
466
-
467
- when "duration_minutes"
468
- # Calculates duration between two dates in minutes
469
- return false unless actual_value
470
-
471
- start_date = parse_date(actual_value)
472
- return false unless start_date
473
-
474
- params = parse_duration_params(expected_value)
475
- return false unless params
476
-
477
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
478
- return false unless end_date
479
-
480
- duration = ((end_date - start_date).abs / 60.0)
481
- compare_duration_result(duration, params)
482
-
483
- when "duration_hours"
484
- # Calculates duration between two dates in hours
485
- return false unless actual_value
486
-
487
- start_date = parse_date(actual_value)
488
- return false unless start_date
489
-
490
- params = parse_duration_params(expected_value)
491
- return false unless params
492
-
493
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
494
- return false unless end_date
495
-
496
- duration = ((end_date - start_date).abs / 3600.0)
497
- compare_duration_result(duration, params)
498
-
499
- when "duration_days"
500
- # Calculates duration between two dates in days
501
- return false unless actual_value
502
-
503
- start_date = parse_date(actual_value)
504
- return false unless start_date
505
-
506
- params = parse_duration_params(expected_value)
507
- return false unless params
508
-
509
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
510
- return false unless end_date
511
-
512
- duration = ((end_date - start_date).abs / 86_400.0)
513
- compare_duration_result(duration, params)
514
-
515
- # DATE ARITHMETIC
516
- when "add_days"
517
- # Adds days to a date and compares
518
- # expected_value: {days: 7, compare: "lt", target: "now"} or {days: 7, eq: target_date}
519
- return false unless actual_value
520
-
521
- start_date = parse_date(actual_value)
522
- return false unless start_date
523
-
524
- params = parse_date_arithmetic_params(expected_value)
525
- return false unless params
526
-
527
- result_date = start_date + (params[:days] * 86_400)
528
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
529
- return false unless target_date
530
-
531
- compare_date_result?(result_date, target_date, params)
532
-
533
- when "subtract_days"
534
- # Subtracts days from a date and compares
535
- return false unless actual_value
536
-
537
- start_date = parse_date(actual_value)
538
- return false unless start_date
539
-
540
- params = parse_date_arithmetic_params(expected_value)
541
- return false unless params
542
-
543
- result_date = start_date - (params[:days] * 86_400)
544
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
545
- return false unless target_date
546
-
547
- compare_date_result?(result_date, target_date, params)
548
-
549
- when "add_hours"
550
- # Adds hours to a date and compares
551
- return false unless actual_value
552
-
553
- start_date = parse_date(actual_value)
554
- return false unless start_date
555
-
556
- params = parse_date_arithmetic_params(expected_value, :hours)
557
- return false unless params
558
-
559
- result_date = start_date + (params[:hours] * 3600)
560
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
561
- return false unless target_date
562
-
563
- compare_date_result?(result_date, target_date, params)
564
-
565
- when "subtract_hours"
566
- # Subtracts hours from a date and compares
567
- return false unless actual_value
568
-
569
- start_date = parse_date(actual_value)
570
- return false unless start_date
571
-
572
- params = parse_date_arithmetic_params(expected_value, :hours)
573
- return false unless params
574
-
575
- result_date = start_date - (params[:hours] * 3600)
576
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
577
- return false unless target_date
578
-
579
- compare_date_result?(result_date, target_date, params)
580
-
581
- when "add_minutes"
582
- # Adds minutes to a date and compares
583
- return false unless actual_value
584
-
585
- start_date = parse_date(actual_value)
586
- return false unless start_date
587
-
588
- params = parse_date_arithmetic_params(expected_value, :minutes)
589
- return false unless params
590
-
591
- result_date = start_date + (params[:minutes] * 60)
592
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
593
- return false unless target_date
594
-
595
- compare_date_result?(result_date, target_date, params)
596
-
597
- when "subtract_minutes"
598
- # Subtracts minutes from a date and compares
599
- return false unless actual_value
600
-
601
- start_date = parse_date(actual_value)
602
- return false unless start_date
603
-
604
- params = parse_date_arithmetic_params(expected_value, :minutes)
605
- return false unless params
606
-
607
- result_date = start_date - (params[:minutes] * 60)
608
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
609
- return false unless target_date
610
-
611
- compare_date_result?(result_date, target_date, params)
612
-
613
- # TIME COMPONENT EXTRACTION
614
- when "hour_of_day"
615
- # Extracts hour of day (0-23) and compares
616
- return false unless actual_value
617
-
618
- date = parse_date(actual_value)
619
- return false unless date
620
-
621
- hour = date.hour
622
- compare_numeric_result(hour, expected_value)
623
-
624
- when "day_of_month"
625
- # Extracts day of month (1-31) and compares
626
- return false unless actual_value
627
-
628
- date = parse_date(actual_value)
629
- return false unless date
630
-
631
- day = date.day
632
- compare_numeric_result(day, expected_value)
633
-
634
- when "month"
635
- # Extracts month (1-12) and compares
636
- return false unless actual_value
637
-
638
- date = parse_date(actual_value)
639
- return false unless date
640
-
641
- month = date.month
642
- compare_numeric_result(month, expected_value)
643
-
644
- when "year"
645
- # Extracts year and compares
646
- return false unless actual_value
647
-
648
- date = parse_date(actual_value)
649
- return false unless date
650
-
651
- year = date.year
652
- compare_numeric_result(year, expected_value)
653
-
654
- when "week_of_year"
655
- # Extracts week of year (1-52) and compares
656
- return false unless actual_value
657
-
658
- date = parse_date(actual_value)
659
- return false unless date
660
-
661
- week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
662
- compare_numeric_result(week, expected_value)
663
-
664
- # RATE CALCULATIONS
665
- when "rate_per_second"
666
- # Calculates rate per second from array of timestamps
667
- # expected_value: {max: 10} or {min: 5, max: 100}
668
- return false unless actual_value.is_a?(Array)
669
- return false if actual_value.empty?
670
-
671
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
672
- return false if timestamps.size < 2
673
-
674
- sorted_timestamps = timestamps.sort
675
- time_span = sorted_timestamps.last - sorted_timestamps.first
676
- return false if time_span <= 0
677
-
678
- rate = timestamps.size.to_f / time_span
679
- compare_rate_result(rate, expected_value)
680
-
681
- when "rate_per_minute"
682
- # Calculates rate per minute from array of timestamps
683
- return false unless actual_value.is_a?(Array)
684
- return false if actual_value.empty?
685
-
686
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
687
- return false if timestamps.size < 2
688
-
689
- sorted_timestamps = timestamps.sort
690
- time_span = sorted_timestamps.last - sorted_timestamps.first
691
- return false if time_span <= 0
692
-
693
- rate = (timestamps.size.to_f / time_span) * 60.0
694
- compare_rate_result(rate, expected_value)
695
-
696
- when "rate_per_hour"
697
- # Calculates rate per hour from array of timestamps
698
- return false unless actual_value.is_a?(Array)
699
- return false if actual_value.empty?
700
-
701
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
702
- return false if timestamps.size < 2
703
-
704
- sorted_timestamps = timestamps.sort
705
- time_span = sorted_timestamps.last - sorted_timestamps.first
706
- return false if time_span <= 0
707
-
708
- rate = (timestamps.size.to_f / time_span) * 3600.0
709
- compare_rate_result(rate, expected_value)
710
-
711
- # MOVING WINDOW CALCULATIONS
712
- when "moving_average"
713
- # Calculates moving average over window
714
- # expected_value: {window: 5, threshold: 100} or {window: 5, gt: 100}
715
- return false unless actual_value.is_a?(Array)
716
- return false if actual_value.empty?
717
-
718
- # OPTIMIZE: filter once and reuse
719
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
720
- return false if numeric_array.empty?
721
-
722
- params = parse_moving_window_params(expected_value)
723
- return false unless params
724
-
725
- window = [params[:window], numeric_array.size].min
726
- return false if window < 1
727
-
728
- # OPTIMIZE: use slice instead of last for better performance
729
- window_array = numeric_array.slice(-window, window)
730
- moving_avg = window_array.sum.to_f / window
731
- compare_moving_window_result(moving_avg, params)
732
-
733
- when "moving_sum"
734
- # Calculates moving sum over window
735
- return false unless actual_value.is_a?(Array)
736
- return false if actual_value.empty?
737
-
738
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
739
- return false if numeric_array.empty?
118
+ # Try operator mixins to handle the operator
119
+ result = evaluate_operator(op, actual_value, expected_value, context_hash)
740
120
 
741
- params = parse_moving_window_params(expected_value)
742
- return false unless params
121
+ # Add trace if collector is provided
122
+ trace_collector&.add_trace(Explainability::ConditionTrace.new(
123
+ field: field,
124
+ operator: op,
125
+ expected_value: expected_value,
126
+ actual_value: actual_value,
127
+ result: result
128
+ ))
743
129
 
744
- window = [params[:window], numeric_array.size].min
745
- return false if window < 1
746
-
747
- # OPTIMIZE: use slice instead of last
748
- window_array = numeric_array.slice(-window, window)
749
- moving_sum = window_array.sum
750
- compare_moving_window_result(moving_sum, params)
751
-
752
- when "moving_max"
753
- # Calculates moving max over window
754
- return false unless actual_value.is_a?(Array)
755
- return false if actual_value.empty?
756
-
757
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
758
- return false if numeric_array.empty?
759
-
760
- params = parse_moving_window_params(expected_value)
761
- return false unless params
762
-
763
- window = [params[:window], numeric_array.size].min
764
- return false if window < 1
765
-
766
- # OPTIMIZE: use slice instead of last, iterate directly for max
767
- window_array = numeric_array.slice(-window, window)
768
- moving_max = window_array.max
769
- compare_moving_window_result(moving_max, params)
770
-
771
- when "moving_min"
772
- # Calculates moving min over window
773
- return false unless actual_value.is_a?(Array)
774
- return false if actual_value.empty?
775
-
776
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
777
- return false if numeric_array.empty?
778
-
779
- params = parse_moving_window_params(expected_value)
780
- return false unless params
781
-
782
- window = [params[:window], numeric_array.size].min
783
- return false if window < 1
784
-
785
- # OPTIMIZE: use slice instead of last
786
- window_array = numeric_array.slice(-window, window)
787
- moving_min = window_array.min
788
- compare_moving_window_result(moving_min, params)
789
-
790
- # FINANCIAL CALCULATIONS
791
- when "compound_interest"
792
- # Calculates compound interest: A = P(1 + r/n)^(nt)
793
- # expected_value: {rate: 0.05, periods: 12, result: 1050} or {rate: 0.05, periods: 12, compare: "gt", threshold: 1000}
794
- return false unless actual_value.is_a?(Numeric)
795
-
796
- params = parse_compound_interest_params(expected_value)
797
- return false unless params
798
-
799
- principal = actual_value
800
- rate = params[:rate]
801
- periods = params[:periods]
802
- result = principal * ((1 + (rate / periods))**periods)
803
-
804
- if params[:result]
805
- (result.round(2) == params[:result].round(2))
806
- else
807
- compare_financial_result(result, params)
808
- end
809
-
810
- when "present_value"
811
- # Calculates present value: PV = FV / (1 + r)^n
812
- # expected_value: {rate: 0.05, periods: 10, result: 613.91}
813
- return false unless actual_value.is_a?(Numeric)
814
-
815
- params = parse_present_value_params(expected_value)
816
- return false unless params
817
-
818
- future_value = actual_value
819
- rate = params[:rate]
820
- periods = params[:periods]
821
- present_value = future_value / ((1 + rate)**periods)
822
-
823
- if params[:result]
824
- (present_value.round(2) == params[:result].round(2))
825
- else
826
- compare_financial_result(present_value, params)
827
- end
828
-
829
- when "future_value"
830
- # Calculates future value: FV = PV * (1 + r)^n
831
- # expected_value: {rate: 0.05, periods: 10, result: 1628.89}
832
- return false unless actual_value.is_a?(Numeric)
833
-
834
- params = parse_future_value_params(expected_value)
835
- return false unless params
836
-
837
- present_value = actual_value
838
- rate = params[:rate]
839
- periods = params[:periods]
840
- future_value = present_value * ((1 + rate)**periods)
841
-
842
- if params[:result]
843
- (future_value.round(2) == params[:result].round(2))
844
- else
845
- compare_financial_result(future_value, params)
846
- end
847
-
848
- when "payment"
849
- # Calculates loan payment: PMT = P * [r(1+r)^n] / [(1+r)^n - 1]
850
- # expected_value: {rate: 0.05, periods: 12, result: 100}
851
- return false unless actual_value.is_a?(Numeric)
852
-
853
- params = parse_payment_params(expected_value)
854
- return false unless params
855
-
856
- principal = actual_value
857
- rate = params[:rate]
858
- periods = params[:periods]
859
-
860
- return false if rate <= 0 || periods <= 0
861
-
862
- payment = if rate.zero?
863
- principal / periods
864
- else
865
- principal * (rate * ((1 + rate)**periods)) / (((1 + rate)**periods) - 1)
866
- end
867
-
868
- if params[:result]
869
- (payment.round(2) == params[:result].round(2))
870
- else
871
- compare_financial_result(payment, params)
872
- end
873
-
874
- # STRING AGGREGATIONS
875
- when "join"
876
- # Joins array of strings with separator
877
- # expected_value: {separator: ",", result: "a,b,c"} or {separator: ",", contains: "a"}
878
- return false unless actual_value.is_a?(Array)
879
- return false if actual_value.empty?
880
-
881
- string_array = actual_value.map(&:to_s)
882
- params = parse_join_params(expected_value)
883
- return false unless params
884
-
885
- joined = string_array.join(params[:separator])
886
-
887
- if params[:result]
888
- joined == params[:result]
889
- elsif params[:contains]
890
- joined.include?(params[:contains])
891
- else
892
- false
893
- end
894
-
895
- when "length"
896
- # Gets length of string or array
897
- # expected_value: {max: 500} or {min: 10, max: 100}
898
- return false if actual_value.nil?
899
-
900
- length_value = if actual_value.is_a?(String) || actual_value.is_a?(Array)
901
- actual_value.length
902
- else
903
- return false
904
- end
905
-
906
- compare_length_result(length_value, expected_value)
907
-
908
- # COLLECTION OPERATORS
909
- when "contains_all"
910
- # Checks if array contains all specified elements
911
- # expected_value should be an array
912
- return false unless actual_value.is_a?(Array)
913
- return false unless expected_value.is_a?(Array)
914
- return true if expected_value.empty?
915
-
916
- # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
917
- # For small arrays, Set overhead is minimal; for large arrays, huge win
918
- actual_set = actual_value.to_set
919
- expected_value.all? { |item| actual_set.include?(item) }
920
-
921
- when "contains_any"
922
- # Checks if array contains any of the specified elements
923
- # expected_value should be an array
924
- return false unless actual_value.is_a?(Array)
925
- return false unless expected_value.is_a?(Array)
926
- return false if expected_value.empty?
927
-
928
- # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
929
- # Early exit on first match for better performance
930
- actual_set = actual_value.to_set
931
- expected_value.any? { |item| actual_set.include?(item) }
932
-
933
- when "intersects"
934
- # Checks if two arrays have any common elements
935
- # expected_value should be an array
936
- return false unless actual_value.is_a?(Array)
937
- return false unless expected_value.is_a?(Array)
938
- return false if actual_value.empty? || expected_value.empty?
939
-
940
- # OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
941
- # Check smaller array against larger set for better performance
942
- if actual_value.size <= expected_value.size
943
- expected_set = expected_value.to_set
944
- actual_value.any? { |item| expected_set.include?(item) }
945
- else
946
- actual_set = actual_value.to_set
947
- expected_value.any? { |item| actual_set.include?(item) }
948
- end
949
-
950
- when "subset_of"
951
- # Checks if array is a subset of another array
952
- # All elements in actual_value must be in expected_value
953
- return false unless actual_value.is_a?(Array)
954
- return false unless expected_value.is_a?(Array)
955
- return true if actual_value.empty?
956
-
957
- # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
958
- expected_set = expected_value.to_set
959
- actual_value.all? { |item| expected_set.include?(item) }
960
-
961
- # GEOSPATIAL OPERATORS
962
- when "within_radius"
963
- # Checks if point is within radius of center point
964
- # actual_value: {lat: y, lon: x} or [lat, lon]
965
- # expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
966
- point = parse_coordinates(actual_value)
967
- return false unless point
968
-
969
- params = parse_radius_params(expected_value)
970
- return false unless params
971
-
972
- # Cache geospatial distance calculations
973
- distance = get_cached_distance(point, params[:center])
974
- distance <= params[:radius]
975
-
976
- when "in_polygon"
977
- # Checks if point is inside a polygon using ray casting algorithm
978
- # actual_value: {lat: y, lon: x} or [lat, lon]
979
- # expected_value: array of vertices [{lat: y, lon: x}, ...] or [[lat, lon], ...]
980
- point = parse_coordinates(actual_value)
981
- return false unless point
982
-
983
- polygon = parse_polygon(expected_value)
984
- return false unless polygon
985
- return false if polygon.size < 3 # Need at least 3 vertices
986
-
987
- point_in_polygon?(point, polygon)
988
-
989
- else
990
- # Unknown operator - returns false (fail-safe)
991
- # Note: Validation should catch this earlier
992
- false
993
- end
130
+ result
994
131
  end
995
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
132
+
133
+ # Evaluates operator using mixins (in order of most common to least common)
134
+ # Returns the result from the first mixin that handles the operator, or false if unknown
135
+ def self.evaluate_operator(op, actual_value, expected_value, context_hash)
136
+ Helpers::OperatorEvaluationHelpers.evaluate_operator(
137
+ op, actual_value, expected_value, context_hash,
138
+ regex_cache: @regex_cache,
139
+ regex_cache_mutex: @regex_cache_mutex,
140
+ param_cache: @param_cache,
141
+ param_cache_mutex: @param_cache_mutex,
142
+ geospatial_cache: @geospatial_cache,
143
+ geospatial_cache_mutex: @geospatial_cache_mutex
144
+ )
145
+ end
146
+ private_class_method :evaluate_operator
147
+
148
+ # Handles "don't care" conditions from DMN "-" entries
149
+ # Returns true if this is a "don't care" condition, nil otherwise
150
+ def self.handle_dont_care_condition(field, op, expected_value, trace_collector)
151
+ return nil unless field == "__always_match__" && op == "eq" && expected_value == true
152
+
153
+ trace_collector&.add_trace(Explainability::ConditionTrace.new(
154
+ field: field,
155
+ operator: op,
156
+ expected_value: expected_value,
157
+ actual_value: true,
158
+ result: true
159
+ ))
160
+ true
161
+ end
162
+ private_class_method :handle_dont_care_condition
996
163
 
997
164
  # Retrieves nested values from a hash using dot notation
998
165
  #
@@ -1003,413 +170,207 @@ module DecisionAgent
1003
170
  #
1004
171
  # Supports both string and symbol keys in the hash
1005
172
  def self.get_nested_value(hash, key_path)
1006
- keys = get_cached_path(key_path)
1007
- keys.reduce(hash) do |memo, key|
1008
- return nil unless memo.is_a?(Hash)
1009
-
1010
- # OPTIMIZE: try symbol first (most common), then string
1011
- # Check key existence first to avoid double lookup
1012
- key_sym = key.to_sym
1013
- if memo.key?(key_sym)
1014
- memo[key_sym]
1015
- elsif memo.key?(key)
1016
- memo[key]
1017
- end
1018
- end
173
+ Helpers::UtilityHelpers.get_nested_value(
174
+ hash, key_path,
175
+ get_cached_path: method(:get_cached_path)
176
+ )
1019
177
  end
1020
178
 
1021
179
  # Checks if two values can be compared with <, >, <=, >=
1022
- # Only allows comparison between values of the same type
180
+ # Allows comparison between numeric types (Float, Integer, etc.) or same string types
1023
181
  def self.comparable?(val1, val2)
1024
- (val1.is_a?(Numeric) || val1.is_a?(String)) &&
1025
- (val2.is_a?(Numeric) || val2.is_a?(String)) &&
1026
- val1.instance_of?(val2.class)
182
+ Helpers::UtilityHelpers.comparable?(val1, val2)
1027
183
  end
1028
184
 
1029
- # Helper methods for new operators
185
+ # Floating point comparison with epsilon threshold
186
+ def self.epsilon_equal?(value1, value2, epsilon = 1e-10)
187
+ Helpers::UtilityHelpers.epsilon_equal?(value1, value2, epsilon)
188
+ end
189
+
190
+ # Expand template parameters (e.g., "{{customer.ssn}}") from context
191
+ def self.expand_template_params(params, context_hash)
192
+ Helpers::TemplateHelpers.expand_template_params(
193
+ params, context_hash,
194
+ get_nested_value: method(:get_nested_value)
195
+ )
196
+ end
197
+
198
+ # Expand a single template value
199
+ def self.expand_template_value(value, context_hash)
200
+ Helpers::TemplateHelpers.expand_template_value(
201
+ value, context_hash,
202
+ get_nested_value: method(:get_nested_value)
203
+ )
204
+ end
1030
205
 
1031
206
  # String operator validation
1032
207
  def self.string_operator?(actual_value, expected_value)
1033
- actual_value.is_a?(String) && expected_value.is_a?(String)
208
+ Helpers::UtilityHelpers.string_operator?(actual_value, expected_value)
1034
209
  end
1035
210
 
1036
211
  # Parse range for 'between' operator
1037
212
  # Accepts [min, max] or {min: x, max: y}
213
+ # Normalizes arrays to hash for better performance with large params
1038
214
  def self.parse_range(value)
1039
- # Generate cache key from normalized value
1040
- cache_key = normalize_param_cache_key(value, "range")
1041
-
1042
- # Fast path: check cache without lock
1043
- cached = @param_cache[cache_key]
1044
- return cached if cached
1045
-
1046
- # Slow path: parse and cache
1047
- @param_cache_mutex.synchronize do
1048
- @param_cache[cache_key] ||= parse_range_impl(value)
1049
- end
215
+ Helpers::ParameterParsingHelpers.parse_range(
216
+ value,
217
+ param_cache: @param_cache,
218
+ param_cache_mutex: @param_cache_mutex
219
+ )
1050
220
  end
1051
221
 
1052
222
  def self.parse_range_impl(value)
1053
- if value.is_a?(Array) && value.size == 2
1054
- { min: value[0], max: value[1] }
1055
- elsif value.is_a?(Hash)
1056
- # Normalize keys to symbols for consistency
1057
- min = value["min"] || value[:min]
1058
- max = value["max"] || value[:max]
1059
- return nil unless min && max
1060
-
1061
- { min: min, max: max }
1062
- end
223
+ Helpers::ParameterParsingHelpers.parse_range_impl(value)
1063
224
  end
1064
225
 
1065
226
  # Parse modulo parameters
1066
227
  # Accepts [divisor, remainder] or {divisor: x, remainder: y}
228
+ # Normalizes arrays to hash for better performance with large params
1067
229
  def self.parse_modulo_params(value)
1068
- # Generate cache key from normalized value
1069
- cache_key = normalize_param_cache_key(value, "modulo")
1070
-
1071
- # Fast path: check cache without lock
1072
- cached = @param_cache[cache_key]
1073
- return cached if cached
1074
-
1075
- # Slow path: parse and cache
1076
- @param_cache_mutex.synchronize do
1077
- @param_cache[cache_key] ||= parse_modulo_params_impl(value)
1078
- end
230
+ Helpers::ParameterParsingHelpers.parse_modulo_params(
231
+ value,
232
+ param_cache: @param_cache,
233
+ param_cache_mutex: @param_cache_mutex
234
+ )
1079
235
  end
1080
236
 
1081
237
  def self.parse_modulo_params_impl(value)
1082
- if value.is_a?(Array) && value.size == 2
1083
- { divisor: value[0], remainder: value[1] }
1084
- elsif value.is_a?(Hash)
1085
- # Normalize keys to symbols for consistency
1086
- divisor = value["divisor"] || value[:divisor]
1087
- remainder = value["remainder"] || value[:remainder]
1088
- return nil unless divisor && !remainder.nil?
1089
-
1090
- { divisor: divisor, remainder: remainder }
1091
- end
238
+ Helpers::ParameterParsingHelpers.parse_modulo_params_impl(value)
1092
239
  end
1093
240
 
1094
241
  # Parse power parameters
1095
242
  # Accepts [exponent, result] or {exponent: x, result: y}
243
+ # Normalizes arrays to hash for better performance with large params
1096
244
  def self.parse_power_params(value)
1097
- if value.is_a?(Array) && value.size == 2
1098
- { exponent: value[0], result: value[1] }
1099
- elsif value.is_a?(Hash)
1100
- exponent = value["exponent"] || value[:exponent]
1101
- result = value["result"] || value[:result]
1102
- return nil unless exponent && !result.nil?
1103
-
1104
- { exponent: exponent, result: result }
1105
- end
245
+ Helpers::ParameterParsingHelpers.parse_power_params(value)
246
+ end
247
+
248
+ # Parse atan2 parameters
249
+ # Accepts [y, result] or {y: x, result: y}
250
+ # Normalizes arrays to hash for better performance with large params
251
+ def self.parse_atan2_params(value)
252
+ Helpers::ParameterParsingHelpers.parse_atan2_params(value)
253
+ end
254
+
255
+ # Parse gcd/lcm parameters
256
+ # Accepts [other, result] or {other: x, result: y}
257
+ # Normalizes arrays to hash for better performance with large params
258
+ def self.parse_gcd_lcm_params(value)
259
+ Helpers::ParameterParsingHelpers.parse_gcd_lcm_params(value)
1106
260
  end
1107
261
 
1108
262
  # Parse date from string, Time, Date, or DateTime (with caching)
1109
263
  def self.parse_date(value)
1110
- case value
1111
- when Time, Date, DateTime
1112
- value
1113
- when String
1114
- get_cached_date(value)
1115
- end
1116
- rescue ArgumentError
1117
- nil
264
+ Helpers::DateHelpers.parse_date(
265
+ value,
266
+ get_cached_date: ->(date_string) { get_cached_date(date_string) }
267
+ )
1118
268
  end
1119
269
 
1120
270
  # Compare two dates with given operator
1121
271
  # Optimized: Early return if values are already Time/Date objects
1122
272
  def self.compare_dates(actual_value, expected_value, operator)
1123
- return false unless actual_value && expected_value
1124
-
1125
- # Fast path: Both are already Time/Date objects (no parsing needed)
1126
- actual_is_date = actual_value.is_a?(Time) || actual_value.is_a?(Date) || actual_value.is_a?(DateTime)
1127
- expected_is_date = expected_value.is_a?(Time) || expected_value.is_a?(Date) || expected_value.is_a?(DateTime)
1128
- return actual_value.send(operator, expected_value) if actual_is_date && expected_is_date
1129
-
1130
- # Slow path: Parse dates (with caching)
1131
- actual_date = parse_date(actual_value)
1132
- expected_date = parse_date(expected_value)
1133
-
1134
- return false unless actual_date && expected_date
1135
-
1136
- actual_date.send(operator, expected_date)
273
+ Helpers::DateHelpers.compare_dates(
274
+ actual_value, expected_value, operator,
275
+ parse_date: method(:parse_date)
276
+ )
1137
277
  end
1138
278
 
1139
279
  # Normalize day of week to 0-6 (Sunday=0)
1140
280
  def self.normalize_day_of_week(value)
1141
- case value
1142
- when Numeric
1143
- value.to_i % 7
1144
- when String
1145
- day_map = {
1146
- "sunday" => 0, "sun" => 0,
1147
- "monday" => 1, "mon" => 1,
1148
- "tuesday" => 2, "tue" => 2,
1149
- "wednesday" => 3, "wed" => 3,
1150
- "thursday" => 4, "thu" => 4,
1151
- "friday" => 5, "fri" => 5,
1152
- "saturday" => 6, "sat" => 6
1153
- }
1154
- day_map[value.downcase]
1155
- end
281
+ Helpers::DateHelpers.normalize_day_of_week(value)
1156
282
  end
1157
283
 
1158
284
  # Parse coordinates from hash or array
1159
285
  # Accepts {lat: y, lon: x}, {latitude: y, longitude: x}, or [lat, lon]
1160
286
  def self.parse_coordinates(value)
1161
- case value
1162
- when Hash
1163
- lat = value["lat"] || value[:lat] || value["latitude"] || value[:latitude]
1164
- lon = value["lon"] || value[:lon] || value["lng"] || value[:lng] ||
1165
- value["longitude"] || value[:longitude]
1166
- return nil unless lat && lon
1167
-
1168
- { lat: lat.to_f, lon: lon.to_f }
1169
- when Array
1170
- return nil unless value.size == 2
1171
-
1172
- { lat: value[0].to_f, lon: value[1].to_f }
1173
- end
287
+ Helpers::GeospatialHelpers.parse_coordinates(value)
1174
288
  end
1175
289
 
1176
290
  # Parse radius parameters
1177
291
  # expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
1178
292
  def self.parse_radius_params(value)
1179
- return nil unless value.is_a?(Hash)
1180
-
1181
- center_data = value["center"] || value[:center]
1182
- radius = value["radius"] || value[:radius]
1183
-
1184
- return nil unless center_data && radius
1185
-
1186
- center = parse_coordinates(center_data)
1187
- return nil unless center
1188
-
1189
- { center: center, radius: radius.to_f }
293
+ Helpers::GeospatialHelpers.parse_radius_params(
294
+ value,
295
+ parse_coordinates: method(:parse_coordinates)
296
+ )
1190
297
  end
1191
298
 
1192
299
  # Parse polygon vertices
1193
300
  # Accepts array of coordinate hashes or arrays
1194
301
  def self.parse_polygon(value)
1195
- return nil unless value.is_a?(Array)
1196
-
1197
- value.map { |vertex| parse_coordinates(vertex) }.compact
302
+ Helpers::GeospatialHelpers.parse_polygon(
303
+ value,
304
+ parse_coordinates: method(:parse_coordinates)
305
+ )
1198
306
  end
1199
307
 
1200
308
  # Calculate distance between two points using Haversine formula
1201
309
  # Returns distance in kilometers
1202
310
  def self.haversine_distance(point1, point2)
1203
- earth_radius_km = 6371.0
1204
-
1205
- lat1_rad = (point1[:lat] * Math::PI) / 180
1206
- lat2_rad = (point2[:lat] * Math::PI) / 180
1207
- delta_lat = ((point2[:lat] - point1[:lat]) * Math::PI) / 180
1208
- delta_lon = ((point2[:lon] - point1[:lon]) * Math::PI) / 180
1209
-
1210
- a = (Math.sin(delta_lat / 2)**2) +
1211
- (Math.cos(lat1_rad) * Math.cos(lat2_rad) *
1212
- (Math.sin(delta_lon / 2)**2))
1213
-
1214
- c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
1215
-
1216
- earth_radius_km * c
311
+ Helpers::GeospatialHelpers.haversine_distance(point1, point2)
1217
312
  end
1218
313
 
1219
314
  # Get cached distance between two points (with precision rounding for cache key)
1220
315
  def self.get_cached_distance(point1, point2)
1221
- # Round coordinates to 4 decimal places (~11m precision) for cache key
1222
- # This balances cache hit rate with precision
1223
- key = [
1224
- point1[:lat].round(4),
1225
- point1[:lon].round(4),
1226
- point2[:lat].round(4),
1227
- point2[:lon].round(4)
1228
- ].join(",")
1229
-
1230
- # Fast path: check cache without lock
1231
- cached = @geospatial_cache[key]
1232
- return cached if cached
1233
-
1234
- # Slow path: calculate and cache
1235
- @geospatial_cache_mutex.synchronize do
1236
- @geospatial_cache[key] ||= haversine_distance(point1, point2)
1237
- end
316
+ Helpers::CacheHelpers.get_cached_distance(
317
+ point1, point2,
318
+ geospatial_cache: @geospatial_cache,
319
+ geospatial_cache_mutex: @geospatial_cache_mutex,
320
+ haversine_distance: method(:haversine_distance)
321
+ )
1238
322
  end
1239
323
 
1240
324
  # Check if point is inside polygon using ray casting algorithm
1241
325
  def self.point_in_polygon?(point, polygon)
1242
- x = point[:lon]
1243
- y = point[:lat]
1244
- inside = false
1245
-
1246
- j = polygon.size - 1
1247
- polygon.size.times do |i|
1248
- xi = polygon[i][:lon]
1249
- yi = polygon[i][:lat]
1250
- xj = polygon[j][:lon]
1251
- yj = polygon[j][:lat]
1252
-
1253
- intersect = ((yi > y) != (yj > y)) &&
1254
- (x < ((((xj - xi) * (y - yi)) / (yj - yi)) + xi))
1255
- inside = !inside if intersect
1256
-
1257
- j = i
1258
- end
1259
-
1260
- inside
326
+ Helpers::GeospatialHelpers.point_in_polygon?(point, polygon)
1261
327
  end
1262
328
 
1263
329
  # Helper methods for new operators
1264
330
 
1265
331
  # Compare aggregation result with expected value (supports hash with comparison operators)
1266
- # rubocop:disable Metrics/PerceivedComplexity
332
+ # Delegates to Base utilities for consistency
1267
333
  def self.compare_aggregation_result(actual, expected)
1268
- if expected.is_a?(Hash)
1269
- result = true
1270
- result &&= (actual >= expected[:min]) if expected[:min]
1271
- result &&= (actual <= expected[:max]) if expected[:max]
1272
- result &&= (actual > expected[:gt]) if expected[:gt]
1273
- result &&= (actual < expected[:lt]) if expected[:lt]
1274
- result &&= (actual >= expected[:gte]) if expected[:gte]
1275
- result &&= (actual <= expected[:lte]) if expected[:lte]
1276
- result &&= (actual == expected[:eq]) if expected[:eq]
1277
- result
1278
- else
1279
- actual == expected
1280
- end
334
+ Operators::Base.compare_aggregation_result(actual, expected)
1281
335
  end
1282
- # rubocop:enable Metrics/PerceivedComplexity
1283
336
 
1284
337
  # Parse percentile parameters
1285
338
  def self.parse_percentile_params(value)
1286
- return nil unless value.is_a?(Hash)
1287
-
1288
- percentile = value["percentile"] || value[:percentile]
1289
- return nil unless percentile.is_a?(Numeric) && percentile >= 0 && percentile <= 100
1290
-
1291
- {
1292
- percentile: percentile.to_f,
1293
- threshold: value["threshold"] || value[:threshold],
1294
- gt: value["gt"] || value[:gt],
1295
- lt: value["lt"] || value[:lt],
1296
- gte: value["gte"] || value[:gte],
1297
- lte: value["lte"] || value[:lte],
1298
- eq: value["eq"] || value[:eq]
1299
- }
339
+ Helpers::ParameterParsingHelpers.parse_percentile_params(value)
1300
340
  end
1301
341
 
1302
342
  # Compare percentile result
1303
343
  def self.compare_percentile_result(actual, params)
1304
- result = true
1305
- result &&= (actual >= params[:threshold]) if params[:threshold]
1306
- result &&= (actual > params[:gt]) if params[:gt]
1307
- result &&= (actual < params[:lt]) if params[:lt]
1308
- result &&= (actual >= params[:gte]) if params[:gte]
1309
- result &&= (actual <= params[:lte]) if params[:lte]
1310
- result &&= (actual == params[:eq]) if params[:eq]
1311
- result
344
+ Helpers::ComparisonHelpers.compare_percentile_result(actual, params)
1312
345
  end
1313
346
 
1314
347
  # Parse duration parameters
1315
348
  def self.parse_duration_params(value)
1316
- return nil unless value.is_a?(Hash)
1317
-
1318
- end_field = value["end"] || value[:end]
1319
- return nil unless end_field
1320
-
1321
- {
1322
- end: end_field.to_s,
1323
- min: value["min"] || value[:min],
1324
- max: value["max"] || value[:max],
1325
- gt: value["gt"] || value[:gt],
1326
- lt: value["lt"] || value[:lt],
1327
- gte: value["gte"] || value[:gte],
1328
- lte: value["lte"] || value[:lte]
1329
- }
349
+ Helpers::ParameterParsingHelpers.parse_duration_params(value)
1330
350
  end
1331
351
 
1332
352
  # Compare duration result
1333
353
  def self.compare_duration_result(actual, params)
1334
- result = true
1335
- result &&= (actual >= params[:min]) if params[:min]
1336
- result &&= (actual <= params[:max]) if params[:max]
1337
- result &&= (actual > params[:gt]) if params[:gt]
1338
- result &&= (actual < params[:lt]) if params[:lt]
1339
- result &&= (actual >= params[:gte]) if params[:gte]
1340
- result &&= (actual <= params[:lte]) if params[:lte]
1341
- result
354
+ Helpers::ComparisonHelpers.compare_duration_result(actual, params)
1342
355
  end
1343
356
 
1344
357
  # Parse date arithmetic parameters
1345
358
  def self.parse_date_arithmetic_params(value, unit = :days)
1346
- return nil unless value.is_a?(Hash)
1347
-
1348
- unit_value = value[unit.to_s] || value[unit]
1349
- return nil unless unit_value.is_a?(Numeric)
1350
-
1351
- {
1352
- unit => unit_value.to_f,
1353
- target: value["target"] || value[:target] || "now",
1354
- compare: value["compare"] || value[:compare],
1355
- eq: value["eq"] || value[:eq],
1356
- gt: value["gt"] || value[:gt],
1357
- lt: value["lt"] || value[:lt],
1358
- gte: value["gte"] || value[:gte],
1359
- lte: value["lte"] || value[:lte]
1360
- }
359
+ Helpers::ParameterParsingHelpers.parse_date_arithmetic_params(value, unit)
1361
360
  end
1362
361
 
1363
362
  # Compare date result
1364
363
  def self.compare_date_result?(actual, target, params)
1365
- if params[:compare]
1366
- case params[:compare].to_s
1367
- when "eq", "=="
1368
- (actual - target).abs < 1
1369
- when "gt", ">"
1370
- actual > target
1371
- when "lt", "<"
1372
- actual < target
1373
- when "gte", ">="
1374
- actual >= target
1375
- when "lte", "<="
1376
- actual <= target
1377
- else
1378
- false
1379
- end
1380
- elsif params[:eq]
1381
- (actual - target).abs < 1
1382
- elsif params[:gt]
1383
- actual > target
1384
- elsif params[:lt]
1385
- actual < target
1386
- elsif params[:gte]
1387
- actual >= target
1388
- elsif params[:lte]
1389
- actual <= target
1390
- else
1391
- false
1392
- end
364
+ Helpers::ComparisonHelpers.compare_date_result?(actual, target, params)
1393
365
  end
1394
366
 
1395
367
  # Compare numeric result (for time component extraction)
1396
- # rubocop:disable Metrics/PerceivedComplexity
1397
368
  def self.compare_numeric_result(actual, expected)
1398
- if expected.is_a?(Hash)
1399
- result = true
1400
- result &&= (actual >= expected[:min]) if expected[:min]
1401
- result &&= (actual <= expected[:max]) if expected[:max]
1402
- result &&= (actual > expected[:gt]) if expected[:gt]
1403
- result &&= (actual < expected[:lt]) if expected[:lt]
1404
- result &&= (actual >= expected[:gte]) if expected[:gte]
1405
- result &&= (actual <= expected[:lte]) if expected[:lte]
1406
- result &&= (actual == expected[:eq]) if expected[:eq]
1407
- result
1408
- else
1409
- actual == expected
1410
- end
369
+ return actual == expected unless expected.is_a?(Hash)
370
+
371
+ Helpers::ComparisonHelpers.compare_numeric_with_hash(actual, expected)
1411
372
  end
1412
- # rubocop:enable Metrics/PerceivedComplexity
373
+ private_class_method :compare_numeric_result
1413
374
 
1414
375
  # Compare rate result
1415
376
  def self.compare_rate_result(actual, expected)
@@ -1418,97 +379,42 @@ module DecisionAgent
1418
379
 
1419
380
  # Parse moving window parameters
1420
381
  def self.parse_moving_window_params(value)
1421
- return nil unless value.is_a?(Hash)
1422
-
1423
- window = value["window"] || value[:window]
1424
- return nil unless window.is_a?(Numeric) && window.positive?
1425
-
1426
- {
1427
- window: window.to_i,
1428
- threshold: value["threshold"] || value[:threshold],
1429
- gt: value["gt"] || value[:gt],
1430
- lt: value["lt"] || value[:lt],
1431
- gte: value["gte"] || value[:gte],
1432
- lte: value["lte"] || value[:lte],
1433
- eq: value["eq"] || value[:eq]
1434
- }
382
+ Helpers::ParameterParsingHelpers.parse_moving_window_params(value)
1435
383
  end
1436
384
 
1437
385
  # Compare moving window result
1438
386
  def self.compare_moving_window_result(actual, params)
1439
- result = true
1440
- result &&= (actual >= params[:threshold]) if params[:threshold]
1441
- result &&= (actual > params[:gt]) if params[:gt]
1442
- result &&= (actual < params[:lt]) if params[:lt]
1443
- result &&= (actual >= params[:gte]) if params[:gte]
1444
- result &&= (actual <= params[:lte]) if params[:lte]
1445
- result &&= (actual == params[:eq]) if params[:eq]
1446
- result
387
+ Helpers::ComparisonHelpers.compare_moving_window_result(actual, params)
1447
388
  end
1448
389
 
1449
390
  # Parse compound interest parameters
1450
391
  def self.parse_compound_interest_params(value)
1451
- return nil unless value.is_a?(Hash)
1452
-
1453
- rate = value["rate"] || value[:rate]
1454
- periods = value["periods"] || value[:periods]
1455
- return nil unless rate && periods
1456
-
1457
- {
1458
- rate: rate.to_f,
1459
- periods: periods.to_i,
1460
- result: value["result"] || value[:result],
1461
- threshold: value["threshold"] || value[:threshold],
1462
- gt: value["gt"] || value[:gt],
1463
- lt: value["lt"] || value[:lt]
1464
- }
392
+ Helpers::ParameterParsingHelpers.parse_compound_interest_params(value)
1465
393
  end
1466
394
 
1467
395
  # Parse present value parameters
1468
396
  def self.parse_present_value_params(value)
1469
- return nil unless value.is_a?(Hash)
1470
-
1471
- rate = value["rate"] || value[:rate]
1472
- periods = value["periods"] || value[:periods]
1473
- return nil unless rate && periods
1474
-
1475
- {
1476
- rate: rate.to_f,
1477
- periods: periods.to_i,
1478
- result: value["result"] || value[:result],
1479
- threshold: value["threshold"] || value[:threshold]
1480
- }
397
+ Helpers::ParameterParsingHelpers.parse_present_value_params(value)
1481
398
  end
1482
399
 
1483
400
  # Parse future value parameters
1484
401
  def self.parse_future_value_params(value)
1485
- parse_present_value_params(value)
402
+ Helpers::ParameterParsingHelpers.parse_future_value_params(value)
1486
403
  end
1487
404
 
1488
405
  # Parse payment parameters
1489
406
  def self.parse_payment_params(value)
1490
- parse_compound_interest_params(value)
407
+ Helpers::ParameterParsingHelpers.parse_payment_params(value)
1491
408
  end
1492
409
 
1493
410
  # Compare financial result
1494
411
  def self.compare_financial_result(actual, params)
1495
- result = true
1496
- result &&= (actual >= params[:threshold]) if params[:threshold]
1497
- result &&= (actual > params[:gt]) if params[:gt]
1498
- result &&= (actual < params[:lt]) if params[:lt]
1499
- result
412
+ Helpers::ComparisonHelpers.compare_financial_result(actual, params)
1500
413
  end
1501
414
 
1502
415
  # Parse join parameters
1503
416
  def self.parse_join_params(value)
1504
- return nil unless value.is_a?(Hash)
1505
-
1506
- separator = value["separator"] || value[:separator] || ","
1507
- {
1508
- separator: separator.to_s,
1509
- result: value["result"] || value[:result],
1510
- contains: value["contains"] || value[:contains]
1511
- }
417
+ Helpers::ParameterParsingHelpers.parse_join_params(value)
1512
418
  end
1513
419
 
1514
420
  # Compare length result
@@ -1520,112 +426,73 @@ module DecisionAgent
1520
426
 
1521
427
  # Get or compile regex with caching
1522
428
  def self.get_cached_regex(pattern)
1523
- return pattern if pattern.is_a?(Regexp)
1524
-
1525
- # Fast path: check cache without lock
1526
- cached = @regex_cache[pattern]
1527
- return cached if cached
1528
-
1529
- # Slow path: compile and cache
1530
- @regex_cache_mutex.synchronize do
1531
- @regex_cache[pattern] ||= Regexp.new(pattern.to_s)
1532
- end
429
+ Helpers::CacheHelpers.get_cached_regex(
430
+ pattern,
431
+ regex_cache: @regex_cache,
432
+ regex_cache_mutex: @regex_cache_mutex
433
+ )
1533
434
  end
1534
435
 
1535
436
  # Get cached split path
1536
437
  def self.get_cached_path(key_path)
1537
- # Fast path: check cache without lock
1538
- cached = @path_cache[key_path]
1539
- return cached if cached
1540
-
1541
- # Slow path: split and cache
1542
- @path_cache_mutex.synchronize do
1543
- @path_cache[key_path] ||= key_path.to_s.split(".").freeze
1544
- end
438
+ Helpers::CacheHelpers.get_cached_path(
439
+ key_path,
440
+ path_cache: @path_cache,
441
+ path_cache_mutex: @path_cache_mutex
442
+ )
1545
443
  end
1546
444
 
1547
445
  # Get cached parsed date with fast-path for common formats
1548
446
  def self.get_cached_date(date_string)
1549
- # Fast path: check cache without lock
1550
- cached = @date_cache[date_string]
1551
- return cached if cached
1552
-
1553
- # Slow path: parse and cache
1554
- @date_cache_mutex.synchronize do
1555
- @date_cache[date_string] ||= parse_date_fast(date_string)
1556
- end
447
+ Helpers::CacheHelpers.get_cached_date(
448
+ date_string,
449
+ date_cache: @date_cache,
450
+ date_cache_mutex: @date_cache_mutex,
451
+ parse_date_fast: ->(str) { Helpers::DateHelpers.parse_date_fast(str) }
452
+ )
1557
453
  end
1558
454
 
1559
455
  # Fast-path date parsing for common formats (ISO8601, etc.)
1560
456
  # Falls back to Time.parse for other formats
1561
457
  def self.parse_date_fast(date_string)
1562
- return nil unless date_string.is_a?(String)
1563
-
1564
- # Fast-path: ISO8601 date format (YYYY-MM-DD)
1565
- if date_string.match?(/^\d{4}-\d{2}-\d{2}$/)
1566
- year, month, day = date_string.split("-").map(&:to_i)
1567
- begin
1568
- return Time.new(year, month, day)
1569
- rescue StandardError
1570
- nil
1571
- end
1572
- end
1573
-
1574
- # Fast-path: ISO8601 datetime format (YYYY-MM-DDTHH:MM:SS or YYYY-MM-DDTHH:MM:SSZ)
1575
- if date_string.match?(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
1576
- begin
1577
- # Try ISO8601 parsing first (faster than Time.parse for this format)
1578
- return Time.iso8601(date_string)
1579
- rescue ArgumentError
1580
- # Fall through to Time.parse
1581
- end
1582
- end
1583
-
1584
- # Fallback to Time.parse for other formats
1585
- Time.parse(date_string)
1586
- rescue ArgumentError, TypeError
1587
- nil
458
+ Helpers::DateHelpers.parse_date_fast(date_string)
1588
459
  end
1589
460
 
1590
461
  # Clear all caches (useful for testing or memory management)
1591
462
  def self.clear_caches!
1592
- @regex_cache_mutex.synchronize { @regex_cache.clear }
1593
- @path_cache_mutex.synchronize { @path_cache.clear }
1594
- @date_cache_mutex.synchronize { @date_cache.clear }
1595
- @geospatial_cache_mutex.synchronize { @geospatial_cache.clear }
1596
- @param_cache_mutex.synchronize { @param_cache.clear }
463
+ Helpers::CacheHelpers.clear_caches!(
464
+ regex_cache: @regex_cache,
465
+ path_cache: @path_cache,
466
+ date_cache: @date_cache,
467
+ geospatial_cache: @geospatial_cache,
468
+ param_cache: @param_cache
469
+ )
1597
470
  end
1598
471
 
1599
472
  # Get cache statistics
1600
473
  def self.cache_stats
474
+ stats = Helpers::CacheHelpers.cache_stats(
475
+ regex_cache: @regex_cache,
476
+ path_cache: @path_cache,
477
+ date_cache: @date_cache,
478
+ geospatial_cache: @geospatial_cache,
479
+ param_cache: @param_cache
480
+ )
1601
481
  {
1602
- regex_cache_size: @regex_cache.size,
1603
- path_cache_size: @path_cache.size,
1604
- date_cache_size: @date_cache.size,
1605
- geospatial_cache_size: @geospatial_cache.size,
1606
- param_cache_size: @param_cache.size
482
+ regex_cache_size: stats[:regex],
483
+ path_cache_size: stats[:path],
484
+ date_cache_size: stats[:date],
485
+ geospatial_cache_size: stats[:geospatial],
486
+ param_cache_size: stats[:param]
1607
487
  }
1608
488
  end
1609
489
 
1610
490
  # Normalize parameter value for cache key generation
1611
491
  # Converts hash keys to symbols for consistency
492
+ # Delegates to Base utilities for consistency
1612
493
  def self.normalize_param_cache_key(value, prefix)
1613
- case value
1614
- when Array
1615
- "#{prefix}:#{value.inspect}"
1616
- when Hash
1617
- # Normalize keys to symbols and sort for consistent cache keys
1618
- normalized = value.each_with_object({}) do |(k, v), h|
1619
- key = k.is_a?(String) ? k.to_sym : k
1620
- h[key] = v
1621
- end
1622
- sorted_keys = normalized.keys.sort
1623
- "#{prefix}:#{sorted_keys.map { |k| "#{k}:#{normalized[k]}" }.join(',')}"
1624
- else
1625
- "#{prefix}:#{value.inspect}"
1626
- end
494
+ Operators::Base.normalize_param_cache_key(value, prefix)
1627
495
  end
1628
- # rubocop:enable Metrics/ClassLength
1629
496
  end
1630
497
  end
1631
498
  end