decision_agent 0.2.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +313 -8
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/agent.rb +72 -1
  5. data/lib/decision_agent/context.rb +1 -0
  6. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  7. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  8. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  9. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  10. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  11. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  12. data/lib/decision_agent/decision.rb +102 -2
  13. data/lib/decision_agent/dmn/adapter.rb +135 -0
  14. data/lib/decision_agent/dmn/cache.rb +306 -0
  15. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  16. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  17. data/lib/decision_agent/dmn/errors.rb +30 -0
  18. data/lib/decision_agent/dmn/exporter.rb +217 -0
  19. data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
  20. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  21. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  22. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  23. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  24. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  25. data/lib/decision_agent/dmn/importer.rb +77 -0
  26. data/lib/decision_agent/dmn/model.rb +197 -0
  27. data/lib/decision_agent/dmn/parser.rb +191 -0
  28. data/lib/decision_agent/dmn/testing.rb +333 -0
  29. data/lib/decision_agent/dmn/validator.rb +315 -0
  30. data/lib/decision_agent/dmn/versioning.rb +229 -0
  31. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  32. data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
  33. data/lib/decision_agent/dsl/schema_validator.rb +53 -14
  34. data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
  35. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  36. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  37. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  38. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  39. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  40. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  41. data/lib/decision_agent/simulation/errors.rb +18 -0
  42. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  43. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  44. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  45. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  46. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  47. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  48. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  49. data/lib/decision_agent/simulation.rb +17 -0
  50. data/lib/decision_agent/version.rb +1 -1
  51. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  52. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  53. data/lib/decision_agent/web/public/app.js +119 -0
  54. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  55. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  56. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  57. data/lib/decision_agent/web/public/index.html +52 -0
  58. data/lib/decision_agent/web/public/simulation.html +130 -0
  59. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  60. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  61. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  62. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  63. data/lib/decision_agent/web/public/styles.css +86 -0
  64. data/lib/decision_agent/web/server.rb +1059 -23
  65. data/lib/decision_agent.rb +60 -2
  66. metadata +105 -61
  67. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  68. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  69. data/spec/ab_testing/ab_test_spec.rb +0 -270
  70. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
  71. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  72. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  73. data/spec/activerecord_thread_safety_spec.rb +0 -553
  74. data/spec/advanced_operators_spec.rb +0 -3150
  75. data/spec/agent_spec.rb +0 -289
  76. data/spec/api_contract_spec.rb +0 -430
  77. data/spec/audit_adapters_spec.rb +0 -92
  78. data/spec/auth/access_audit_logger_spec.rb +0 -394
  79. data/spec/auth/authenticator_spec.rb +0 -112
  80. data/spec/auth/password_reset_spec.rb +0 -294
  81. data/spec/auth/permission_checker_spec.rb +0 -207
  82. data/spec/auth/permission_spec.rb +0 -73
  83. data/spec/auth/rbac_adapter_spec.rb +0 -550
  84. data/spec/auth/rbac_config_spec.rb +0 -82
  85. data/spec/auth/role_spec.rb +0 -51
  86. data/spec/auth/session_manager_spec.rb +0 -172
  87. data/spec/auth/session_spec.rb +0 -112
  88. data/spec/auth/user_spec.rb +0 -130
  89. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  90. data/spec/context_spec.rb +0 -127
  91. data/spec/decision_agent_spec.rb +0 -96
  92. data/spec/decision_spec.rb +0 -423
  93. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  94. data/spec/dsl_validation_spec.rb +0 -648
  95. data/spec/edge_cases_spec.rb +0 -353
  96. data/spec/evaluation_spec.rb +0 -364
  97. data/spec/evaluation_validator_spec.rb +0 -165
  98. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  99. data/spec/examples.txt +0 -1633
  100. data/spec/issue_verification_spec.rb +0 -759
  101. data/spec/json_rule_evaluator_spec.rb +0 -587
  102. data/spec/monitoring/alert_manager_spec.rb +0 -378
  103. data/spec/monitoring/metrics_collector_spec.rb +0 -499
  104. data/spec/monitoring/monitored_agent_spec.rb +0 -222
  105. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  106. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  107. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  108. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  109. data/spec/performance_optimizations_spec.rb +0 -486
  110. data/spec/replay_edge_cases_spec.rb +0 -699
  111. data/spec/replay_spec.rb +0 -210
  112. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  113. data/spec/scoring_spec.rb +0 -225
  114. data/spec/spec_helper.rb +0 -60
  115. data/spec/testing/batch_test_importer_spec.rb +0 -693
  116. data/spec/testing/batch_test_runner_spec.rb +0 -307
  117. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  118. data/spec/testing/test_result_comparator_spec.rb +0 -392
  119. data/spec/testing/test_scenario_spec.rb +0 -113
  120. data/spec/thread_safety_spec.rb +0 -482
  121. data/spec/thread_safety_spec.rb.broken +0 -878
  122. data/spec/versioning/adapter_spec.rb +0 -156
  123. data/spec/versioning_spec.rb +0 -1030
  124. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  125. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  126. data/spec/web_ui_rack_spec.rb +0 -1840
@@ -26,15 +26,20 @@ module DecisionAgent
26
26
  attr_reader :regex_cache, :path_cache, :date_cache, :geospatial_cache, :param_cache
27
27
  end
28
28
 
29
- def self.evaluate(condition, context)
29
+ def self.evaluate(condition, context, enriched_context_hash: nil, trace_collector: nil)
30
30
  return false unless condition.is_a?(Hash)
31
31
 
32
+ # Use enriched context hash if provided, otherwise create mutable copy
33
+ # This ensures all conditions in the same evaluation share the same enriched hash
34
+ enriched = enriched_context_hash
35
+ enriched ||= context.to_h.dup
36
+
32
37
  if condition.key?("all")
33
- evaluate_all(condition["all"], context)
38
+ evaluate_all(condition["all"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
34
39
  elsif condition.key?("any")
35
- evaluate_any(condition["any"], context)
40
+ evaluate_any(condition["any"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
36
41
  elsif condition.key?("field")
37
- evaluate_field_condition(condition, context)
42
+ evaluate_field_condition(condition, context, enriched_context_hash: enriched, trace_collector: trace_collector)
38
43
  else
39
44
  false
40
45
  end
@@ -42,952 +47,1054 @@ module DecisionAgent
42
47
 
43
48
  # Evaluates 'all' condition - returns true only if ALL sub-conditions are true
44
49
  # Empty array returns true (vacuous truth)
45
- def self.evaluate_all(conditions, context)
50
+ def self.evaluate_all(conditions, context, enriched_context_hash: nil, trace_collector: nil)
46
51
  return true if conditions.is_a?(Array) && conditions.empty?
47
52
  return false unless conditions.is_a?(Array)
48
53
 
49
- conditions.all? { |cond| evaluate(cond, context) }
54
+ # Use enriched context hash if provided, otherwise create mutable copy
55
+ # All conditions share the same enriched hash so data enrichment persists
56
+ enriched = enriched_context_hash
57
+ enriched ||= context.to_h.dup
58
+
59
+ conditions.all? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
50
60
  end
51
61
 
52
62
  # Evaluates 'any' condition - returns true if AT LEAST ONE sub-condition is true
53
63
  # Empty array returns false (no options to match)
54
- def self.evaluate_any(conditions, context)
64
+ def self.evaluate_any(conditions, context, enriched_context_hash: nil, trace_collector: nil)
55
65
  return false unless conditions.is_a?(Array)
56
66
 
57
- conditions.any? { |cond| evaluate(cond, context) }
67
+ # Use enriched context hash if provided, otherwise create mutable copy
68
+ # All conditions share the same enriched hash so data enrichment persists
69
+ enriched = enriched_context_hash
70
+ enriched ||= context.to_h.dup
71
+
72
+ conditions.any? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
58
73
  end
59
74
 
60
75
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
61
- def self.evaluate_field_condition(condition, context)
76
+ def self.evaluate_field_condition(condition, context, enriched_context_hash: nil, trace_collector: nil)
62
77
  field = condition["field"]
63
78
  op = condition["op"]
64
79
  expected_value = condition["value"]
65
80
 
66
- context_hash = context.to_h
67
- actual_value = get_nested_value(context_hash, field)
68
-
69
- case op
70
- when "eq"
71
- # Equality - uses Ruby's == for comparison
72
- actual_value == expected_value
73
-
74
- when "neq"
75
- # Not equal - inverse of ==
76
- actual_value != expected_value
77
-
78
- when "gt"
79
- # Greater than - only for comparable types (numbers, strings)
80
- comparable?(actual_value, expected_value) && actual_value > expected_value
81
-
82
- when "gte"
83
- # Greater than or equal - only for comparable types
84
- comparable?(actual_value, expected_value) && actual_value >= expected_value
85
-
86
- when "lt"
87
- # Less than - only for comparable types
88
- comparable?(actual_value, expected_value) && actual_value < expected_value
89
-
90
- when "lte"
91
- # Less than or equal - only for comparable types
92
- comparable?(actual_value, expected_value) && actual_value <= expected_value
93
-
94
- when "in"
95
- # Array membership - checks if actual_value is in the expected array
96
- Array(expected_value).include?(actual_value)
97
-
98
- when "present"
99
- # PRESENT SEMANTICS:
100
- # Returns true if value exists AND is not empty
101
- # - nil: false
102
- # - Empty string "": false
103
- # - Empty array []: false
104
- # - Empty hash {}: false
105
- # - Zero 0: true (zero is a valid value)
106
- # - False boolean: true (false is a valid value)
107
- # - Non-empty values: true
108
- !actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
109
-
110
- when "blank"
111
- # BLANK SEMANTICS:
112
- # Returns true if value is nil OR empty
113
- # - nil: true
114
- # - Empty string "": true
115
- # - Empty array []: true
116
- # - Empty hash {}: true
117
- # - Zero 0: false (zero is a valid value)
118
- # - False boolean: false (false is a valid value)
119
- # - Non-empty values: false
120
- actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
121
-
122
- # STRING OPERATORS
123
- when "contains"
124
- # Checks if string contains substring (case-sensitive)
125
- string_operator?(actual_value, expected_value) &&
126
- actual_value.include?(expected_value)
127
-
128
- when "starts_with"
129
- # Checks if string starts with prefix (case-sensitive)
130
- string_operator?(actual_value, expected_value) &&
131
- actual_value.start_with?(expected_value)
132
-
133
- when "ends_with"
134
- # Checks if string ends with suffix (case-sensitive)
135
- string_operator?(actual_value, expected_value) &&
136
- actual_value.end_with?(expected_value)
137
-
138
- when "matches"
139
- # Matches string against regular expression
140
- # expected_value can be a string (converted to regex) or Regexp object
141
- return false unless actual_value.is_a?(String)
142
- return false if expected_value.nil?
143
-
144
- begin
145
- regex = get_cached_regex(expected_value)
146
- !regex.match(actual_value).nil?
147
- rescue RegexpError
148
- false
149
- end
150
-
151
- # NUMERIC OPERATORS
152
- when "between"
153
- # Checks if numeric value is between min and max (inclusive)
154
- # expected_value should be [min, max] or {min: x, max: y}
155
- return false unless actual_value.is_a?(Numeric)
156
-
157
- range = parse_range(expected_value)
158
- return false unless range
159
-
160
- actual_value.between?(range[:min], range[:max])
161
-
162
- when "modulo"
163
- # Checks if value modulo divisor equals remainder
164
- # expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
165
- return false unless actual_value.is_a?(Numeric)
166
-
167
- params = parse_modulo_params(expected_value)
168
- return false unless params
169
-
170
- (actual_value % params[:divisor]) == params[:remainder]
171
-
172
- # MATHEMATICAL FUNCTIONS
173
- # Trigonometric functions
174
- when "sin"
175
- # Checks if sin(field_value) equals expected_value
176
- # expected_value is the expected result of sin(actual_value)
177
- return false unless actual_value.is_a?(Numeric)
178
- return false unless expected_value.is_a?(Numeric)
179
-
180
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
181
- result = Math.sin(actual_value)
182
- (result - expected_value).abs < 1e-10
183
-
184
- when "cos"
185
- # Checks if cos(field_value) equals expected_value
186
- # expected_value is the expected result of cos(actual_value)
187
- return false unless actual_value.is_a?(Numeric)
188
- return false unless expected_value.is_a?(Numeric)
189
-
190
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
191
- result = Math.cos(actual_value)
192
- (result - expected_value).abs < 1e-10
193
-
194
- when "tan"
195
- # Checks if tan(field_value) equals expected_value
196
- # expected_value is the expected result of tan(actual_value)
197
- return false unless actual_value.is_a?(Numeric)
198
- return false unless expected_value.is_a?(Numeric)
199
-
200
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
201
- result = Math.tan(actual_value)
202
- (result - expected_value).abs < 1e-10
203
-
204
- # Exponential and logarithmic functions
205
- when "sqrt"
206
- # Checks if sqrt(field_value) equals expected_value
207
- # expected_value is the expected result of sqrt(actual_value)
208
- return false unless actual_value.is_a?(Numeric)
209
- return false unless expected_value.is_a?(Numeric)
210
- return false if actual_value.negative? # sqrt of negative number is invalid
211
-
212
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
213
- result = Math.sqrt(actual_value)
214
- (result - expected_value).abs < 1e-10
215
-
216
- when "power"
217
- # Checks if power(field_value, exponent) equals result
218
- # expected_value should be [exponent, result] or {exponent: x, result: y}
219
- return false unless actual_value.is_a?(Numeric)
220
-
221
- params = parse_power_params(expected_value)
222
- return false unless params
223
-
224
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
225
- result = actual_value**params[:exponent]
226
- (result - params[:result]).abs < 1e-10
227
-
228
- when "exp"
229
- # Checks if exp(field_value) equals expected_value
230
- # expected_value is the expected result of exp(actual_value) (e^actual_value)
231
- return false unless actual_value.is_a?(Numeric)
232
- return false unless expected_value.is_a?(Numeric)
233
-
234
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
235
- result = Math.exp(actual_value)
236
- (result - expected_value).abs < 1e-10
237
-
238
- when "log"
239
- # Checks if log(field_value) equals expected_value
240
- # expected_value is the expected result of log(actual_value) (natural logarithm)
241
- return false unless actual_value.is_a?(Numeric)
242
- return false unless expected_value.is_a?(Numeric)
243
- return false if actual_value <= 0 # log of non-positive number is invalid
244
-
245
- # OPTIMIZE: Use epsilon comparison instead of round for better performance
246
- result = Math.log(actual_value)
247
- (result - expected_value).abs < 1e-10
248
-
249
- # Rounding and absolute value functions
250
- when "round"
251
- # Checks if round(field_value) equals expected_value
252
- # expected_value is the expected result of round(actual_value)
253
- return false unless actual_value.is_a?(Numeric)
254
- return false unless expected_value.is_a?(Numeric)
255
-
256
- actual_value.round == expected_value
257
-
258
- when "floor"
259
- # Checks if floor(field_value) equals expected_value
260
- # expected_value is the expected result of floor(actual_value)
261
- return false unless actual_value.is_a?(Numeric)
262
- return false unless expected_value.is_a?(Numeric)
263
-
264
- actual_value.floor == expected_value
265
-
266
- when "ceil"
267
- # Checks if ceil(field_value) equals expected_value
268
- # expected_value is the expected result of ceil(actual_value)
269
- return false unless actual_value.is_a?(Numeric)
270
- return false unless expected_value.is_a?(Numeric)
271
-
272
- actual_value.ceil == expected_value
273
-
274
- when "abs"
275
- # Checks if abs(field_value) equals expected_value
276
- # expected_value is the expected result of abs(actual_value)
277
- return false unless actual_value.is_a?(Numeric)
278
- return false unless expected_value.is_a?(Numeric)
279
-
280
- actual_value.abs == expected_value
281
-
282
- # Aggregation functions
283
- when "min"
284
- # Checks if min(field_value) equals expected_value
285
- # field_value should be an array, expected_value is the minimum value
286
- return false unless actual_value.is_a?(Array)
287
- return false if actual_value.empty?
288
- return false unless expected_value.is_a?(Numeric)
289
-
290
- actual_value.min == expected_value
291
-
292
- when "max"
293
- # Checks if max(field_value) equals expected_value
294
- # field_value should be an array, expected_value is the maximum value
295
- return false unless actual_value.is_a?(Array)
296
- return false if actual_value.empty?
297
- return false unless expected_value.is_a?(Numeric)
298
-
299
- actual_value.max == expected_value
300
-
301
- # STATISTICAL AGGREGATIONS
302
- when "sum"
303
- # Checks if sum of numeric array equals expected_value
304
- # expected_value can be numeric or hash with comparison operators
305
- return false unless actual_value.is_a?(Array)
306
- return false if actual_value.empty?
307
-
308
- # OPTIMIZE: calculate sum in single pass, filtering as we go
309
- sum_value = 0.0
310
- found_numeric = false
311
- actual_value.each do |v|
312
- if v.is_a?(Numeric)
313
- sum_value += v
314
- found_numeric = true
315
- end
316
- end
317
- return false unless found_numeric
318
-
319
- compare_aggregation_result(sum_value, expected_value)
320
-
321
- when "average", "mean"
322
- # Checks if average of numeric array equals expected_value
323
- return false unless actual_value.is_a?(Array)
324
- return false if actual_value.empty?
325
-
326
- # OPTIMIZE: calculate sum and count in single pass
327
- sum_value = 0.0
328
- count = 0
329
- actual_value.each do |v|
330
- if v.is_a?(Numeric)
331
- sum_value += v
332
- count += 1
333
- end
334
- end
335
- return false if count.zero?
336
-
337
- avg_value = sum_value / count
338
- compare_aggregation_result(avg_value, expected_value)
339
-
340
- when "median"
341
- # Checks if median of numeric array equals expected_value
342
- return false unless actual_value.is_a?(Array)
343
- return false if actual_value.empty?
344
-
345
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
346
- return false if numeric_array.empty?
347
-
348
- median_value = if numeric_array.size.odd?
349
- numeric_array[numeric_array.size / 2]
350
- else
351
- (numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
352
- end
353
- compare_aggregation_result(median_value, expected_value)
354
-
355
- when "stddev", "standard_deviation"
356
- # Checks if standard deviation of numeric array equals expected_value
357
- return false unless actual_value.is_a?(Array)
358
- return false if actual_value.size < 2
359
-
360
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
361
- return false if numeric_array.size < 2
362
-
363
- mean = numeric_array.sum.to_f / numeric_array.size
364
- variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
365
- stddev_value = Math.sqrt(variance)
366
- compare_aggregation_result(stddev_value, expected_value)
367
-
368
- when "variance"
369
- # Checks if variance of numeric array equals expected_value
370
- return false unless actual_value.is_a?(Array)
371
- return false if actual_value.size < 2
372
-
373
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
374
- return false if numeric_array.size < 2
375
-
376
- mean = numeric_array.sum.to_f / numeric_array.size
377
- variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
378
- compare_aggregation_result(variance_value, expected_value)
379
-
380
- when "percentile"
381
- # Checks if Nth percentile of numeric array meets threshold
382
- # expected_value: {percentile: 95, threshold: 200} or {percentile: 95, gt: 200, lt: 500}
383
- return false unless actual_value.is_a?(Array)
384
- return false if actual_value.empty?
385
-
386
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
387
- return false if numeric_array.empty?
388
-
389
- params = parse_percentile_params(expected_value)
390
- return false unless params
391
-
392
- percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
393
- percentile_value = if percentile_index == percentile_index.to_i
394
- numeric_array[percentile_index.to_i]
395
- else
396
- lower = numeric_array[percentile_index.floor]
397
- upper = numeric_array[percentile_index.ceil]
398
- lower + ((upper - lower) * (percentile_index - percentile_index.floor))
399
- end
400
-
401
- compare_percentile_result(percentile_value, params)
402
-
403
- when "count"
404
- # Checks if count of array elements meets threshold
405
- # expected_value can be numeric or hash with comparison operators
406
- return false unless actual_value.is_a?(Array)
407
-
408
- count_value = actual_value.size
409
- compare_aggregation_result(count_value, expected_value)
410
-
411
- # DATE/TIME OPERATORS
412
- when "before_date"
413
- # Checks if date is before specified date
414
- compare_dates(actual_value, expected_value, :<)
415
-
416
- when "after_date"
417
- # Checks if date is after specified date
418
- compare_dates(actual_value, expected_value, :>)
419
-
420
- when "within_days"
421
- # Checks if date is within N days from now (past or future)
422
- # expected_value is number of days
423
- return false unless actual_value
424
- return false unless expected_value.is_a?(Numeric)
425
-
426
- date = parse_date(actual_value)
427
- return false unless date
428
-
429
- now = Time.now
430
- diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
431
- diff_days <= expected_value
432
-
433
- when "day_of_week"
434
- # Checks if date falls on specified day of week
435
- # expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
436
- return false unless actual_value
437
-
438
- date = parse_date(actual_value)
439
- return false unless date
440
-
441
- expected_day = normalize_day_of_week(expected_value)
442
- return false unless expected_day
443
-
444
- date.wday == expected_day
445
-
446
- # DURATION CALCULATIONS
447
- when "duration_seconds"
448
- # Calculates duration between two dates in seconds
449
- # expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
450
- return false unless actual_value
451
-
452
- start_date = parse_date(actual_value)
453
- return false unless start_date
454
-
455
- params = parse_duration_params(expected_value)
456
- return false unless params
457
-
458
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
459
- return false unless end_date
460
-
461
- duration = (end_date - start_date).abs
462
- compare_duration_result(duration, params)
463
-
464
- when "duration_minutes"
465
- # Calculates duration between two dates in minutes
466
- return false unless actual_value
467
-
468
- start_date = parse_date(actual_value)
469
- return false unless start_date
470
-
471
- params = parse_duration_params(expected_value)
472
- return false unless params
473
-
474
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
475
- return false unless end_date
476
-
477
- duration = ((end_date - start_date).abs / 60.0)
478
- compare_duration_result(duration, params)
479
-
480
- when "duration_hours"
481
- # Calculates duration between two dates in hours
482
- return false unless actual_value
483
-
484
- start_date = parse_date(actual_value)
485
- return false unless start_date
486
-
487
- params = parse_duration_params(expected_value)
488
- return false unless params
489
-
490
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
491
- return false unless end_date
492
-
493
- duration = ((end_date - start_date).abs / 3600.0)
494
- compare_duration_result(duration, params)
495
-
496
- when "duration_days"
497
- # Calculates duration between two dates in days
498
- return false unless actual_value
81
+ # Special handling for "don't care" conditions (from DMN "-" entries)
82
+ if field == "__always_match__" && op == "eq" && expected_value == true
83
+ trace_collector&.add_trace(Explainability::ConditionTrace.new(
84
+ field: field,
85
+ operator: op,
86
+ expected_value: expected_value,
87
+ actual_value: true,
88
+ result: true
89
+ ))
90
+ return true
91
+ end
499
92
 
500
- start_date = parse_date(actual_value)
501
- return false unless start_date
93
+ # Use enriched context hash if provided, otherwise create mutable copy
94
+ # This ensures all conditions in the same evaluation share the same enriched hash
95
+ context_hash = enriched_context_hash || context.to_h.dup
96
+ actual_value = get_nested_value(context_hash, field)
502
97
 
503
- params = parse_duration_params(expected_value)
504
- return false unless params
98
+ result = case op
99
+ when "eq"
100
+ # Equality - uses Ruby's == for comparison
101
+ actual_value == expected_value
102
+
103
+ when "neq"
104
+ # Not equal - inverse of ==
105
+ actual_value != expected_value
106
+
107
+ when "gt"
108
+ # Greater than - only for comparable types (numbers, strings)
109
+ comparable?(actual_value, expected_value) && actual_value > expected_value
110
+
111
+ when "gte"
112
+ # Greater than or equal - only for comparable types
113
+ comparable?(actual_value, expected_value) && actual_value >= expected_value
114
+
115
+ when "lt"
116
+ # Less than - only for comparable types
117
+ comparable?(actual_value, expected_value) && actual_value < expected_value
118
+
119
+ when "lte"
120
+ # Less than or equal - only for comparable types
121
+ comparable?(actual_value, expected_value) && actual_value <= expected_value
122
+
123
+ when "in"
124
+ # Array membership - checks if actual_value is in the expected array
125
+ Array(expected_value).include?(actual_value)
126
+
127
+ when "present"
128
+ # PRESENT SEMANTICS:
129
+ # Returns true if value exists AND is not empty
130
+ # - nil: false
131
+ # - Empty string "": false
132
+ # - Empty array []: false
133
+ # - Empty hash {}: false
134
+ # - Zero 0: true (zero is a valid value)
135
+ # - False boolean: true (false is a valid value)
136
+ # - Non-empty values: true
137
+ !actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
138
+
139
+ when "blank"
140
+ # BLANK SEMANTICS:
141
+ # Returns true if value is nil OR empty
142
+ # - nil: true
143
+ # - Empty string "": true
144
+ # - Empty array []: true
145
+ # - Empty hash {}: true
146
+ # - Zero 0: false (zero is a valid value)
147
+ # - False boolean: false (false is a valid value)
148
+ # - Non-empty values: false
149
+ actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
150
+
151
+ # STRING OPERATORS
152
+ when "contains"
153
+ # Checks if string contains substring (case-sensitive)
154
+ string_operator?(actual_value, expected_value) &&
155
+ actual_value.include?(expected_value)
156
+
157
+ when "starts_with"
158
+ # Checks if string starts with prefix (case-sensitive)
159
+ string_operator?(actual_value, expected_value) &&
160
+ actual_value.start_with?(expected_value)
161
+
162
+ when "ends_with"
163
+ # Checks if string ends with suffix (case-sensitive)
164
+ string_operator?(actual_value, expected_value) &&
165
+ actual_value.end_with?(expected_value)
166
+
167
+ when "matches"
168
+ # Matches string against regular expression
169
+ # expected_value can be a string (converted to regex) or Regexp object
170
+ if !actual_value.is_a?(String) || expected_value.nil?
171
+ false
172
+ else
173
+ begin
174
+ regex = get_cached_regex(expected_value)
175
+ !regex.match(actual_value).nil?
176
+ rescue RegexpError
177
+ false
178
+ end
179
+ end
180
+
181
+ # NUMERIC OPERATORS
182
+ when "between"
183
+ # Checks if numeric value is between min and max (inclusive)
184
+ # expected_value should be [min, max] or {min: x, max: y}
185
+ if actual_value.is_a?(Numeric)
186
+ range = parse_range(expected_value)
187
+ range ? actual_value.between?(range[:min], range[:max]) : false
188
+ else
189
+ false
190
+ end
191
+
192
+ when "modulo"
193
+ # Checks if value modulo divisor equals remainder
194
+ # expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
195
+ if actual_value.is_a?(Numeric)
196
+ params = parse_modulo_params(expected_value)
197
+ params ? (actual_value % params[:divisor]) == params[:remainder] : false
198
+ else
199
+ false
200
+ end
201
+
202
+ # MATHEMATICAL FUNCTIONS
203
+ # Trigonometric functions
204
+ when "sin"
205
+ # Checks if sin(field_value) equals expected_value
206
+ # expected_value is the expected result of sin(actual_value)
207
+ return false unless actual_value.is_a?(Numeric)
208
+ return false unless expected_value.is_a?(Numeric)
209
+
210
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
211
+ result = Math.sin(actual_value)
212
+ (result - expected_value).abs < 1e-10
213
+
214
+ when "cos"
215
+ # Checks if cos(field_value) equals expected_value
216
+ # expected_value is the expected result of cos(actual_value)
217
+ return false unless actual_value.is_a?(Numeric)
218
+ return false unless expected_value.is_a?(Numeric)
219
+
220
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
221
+ result = Math.cos(actual_value)
222
+ (result - expected_value).abs < 1e-10
223
+
224
+ when "tan"
225
+ # Checks if tan(field_value) equals expected_value
226
+ # expected_value is the expected result of tan(actual_value)
227
+ return false unless actual_value.is_a?(Numeric)
228
+ return false unless expected_value.is_a?(Numeric)
229
+
230
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
231
+ result = Math.tan(actual_value)
232
+ (result - expected_value).abs < 1e-10
233
+
234
+ # Exponential and logarithmic functions
235
+ when "sqrt"
236
+ # Checks if sqrt(field_value) equals expected_value
237
+ # expected_value is the expected result of sqrt(actual_value)
238
+ return false unless actual_value.is_a?(Numeric)
239
+ return false unless expected_value.is_a?(Numeric)
240
+ return false if actual_value.negative? # sqrt of negative number is invalid
241
+
242
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
243
+ result = Math.sqrt(actual_value)
244
+ (result - expected_value).abs < 1e-10
245
+
246
+ when "power"
247
+ # Checks if power(field_value, exponent) equals result
248
+ # expected_value should be [exponent, result] or {exponent: x, result: y}
249
+ return false unless actual_value.is_a?(Numeric)
250
+
251
+ params = parse_power_params(expected_value)
252
+ return false unless params
253
+
254
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
255
+ result = actual_value**params[:exponent]
256
+ (result - params[:result]).abs < 1e-10
257
+
258
+ when "exp"
259
+ # Checks if exp(field_value) equals expected_value
260
+ # expected_value is the expected result of exp(actual_value) (e^actual_value)
261
+ return false unless actual_value.is_a?(Numeric)
262
+ return false unless expected_value.is_a?(Numeric)
263
+
264
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
265
+ result = Math.exp(actual_value)
266
+ (result - expected_value).abs < 1e-10
267
+
268
+ when "log"
269
+ # Checks if log(field_value) equals expected_value
270
+ # expected_value is the expected result of log(actual_value) (natural logarithm)
271
+ return false unless actual_value.is_a?(Numeric)
272
+ return false unless expected_value.is_a?(Numeric)
273
+ return false if actual_value <= 0 # log of non-positive number is invalid
274
+
275
+ # OPTIMIZE: Use epsilon comparison instead of round for better performance
276
+ result = Math.log(actual_value)
277
+ (result - expected_value).abs < 1e-10
278
+
279
+ # Rounding and absolute value functions
280
+ when "round"
281
+ # Checks if round(field_value) equals expected_value
282
+ # expected_value is the expected result of round(actual_value)
283
+ return false unless actual_value.is_a?(Numeric)
284
+ return false unless expected_value.is_a?(Numeric)
285
+
286
+ actual_value.round == expected_value
287
+
288
+ when "floor"
289
+ # Checks if floor(field_value) equals expected_value
290
+ # expected_value is the expected result of floor(actual_value)
291
+ return false unless actual_value.is_a?(Numeric)
292
+ return false unless expected_value.is_a?(Numeric)
293
+
294
+ actual_value.floor == expected_value
295
+
296
+ when "ceil"
297
+ # Checks if ceil(field_value) equals expected_value
298
+ # expected_value is the expected result of ceil(actual_value)
299
+ return false unless actual_value.is_a?(Numeric)
300
+ return false unless expected_value.is_a?(Numeric)
301
+
302
+ actual_value.ceil == expected_value
303
+
304
+ when "abs"
305
+ # Checks if abs(field_value) equals expected_value
306
+ # expected_value is the expected result of abs(actual_value)
307
+ return false unless actual_value.is_a?(Numeric)
308
+ return false unless expected_value.is_a?(Numeric)
309
+
310
+ actual_value.abs == expected_value
311
+
312
+ # Aggregation functions
313
+ when "min"
314
+ # Checks if min(field_value) equals expected_value
315
+ # field_value should be an array, expected_value is the minimum value
316
+ return false unless actual_value.is_a?(Array)
317
+ return false if actual_value.empty?
318
+ return false unless expected_value.is_a?(Numeric)
319
+
320
+ actual_value.min == expected_value
321
+
322
+ when "max"
323
+ # Checks if max(field_value) equals expected_value
324
+ # field_value should be an array, expected_value is the maximum value
325
+ return false unless actual_value.is_a?(Array)
326
+ return false if actual_value.empty?
327
+ return false unless expected_value.is_a?(Numeric)
328
+
329
+ actual_value.max == expected_value
330
+
331
+ # STATISTICAL AGGREGATIONS
332
+ when "sum"
333
+ # Checks if sum of numeric array equals expected_value
334
+ # expected_value can be numeric or hash with comparison operators
335
+ return false unless actual_value.is_a?(Array)
336
+ return false if actual_value.empty?
337
+
338
+ # OPTIMIZE: calculate sum in single pass, filtering as we go
339
+ sum_value = 0.0
340
+ found_numeric = false
341
+ actual_value.each do |v|
342
+ if v.is_a?(Numeric)
343
+ sum_value += v
344
+ found_numeric = true
345
+ end
346
+ end
347
+ return false unless found_numeric
348
+
349
+ compare_aggregation_result(sum_value, expected_value)
350
+
351
+ when "average", "mean"
352
+ # Checks if average of numeric array equals expected_value
353
+ return false unless actual_value.is_a?(Array)
354
+ return false if actual_value.empty?
355
+
356
+ # OPTIMIZE: calculate sum and count in single pass
357
+ sum_value = 0.0
358
+ count = 0
359
+ actual_value.each do |v|
360
+ if v.is_a?(Numeric)
361
+ sum_value += v
362
+ count += 1
363
+ end
364
+ end
365
+ return false if count.zero?
366
+
367
+ avg_value = sum_value / count
368
+ compare_aggregation_result(avg_value, expected_value)
369
+
370
+ when "median"
371
+ # Checks if median of numeric array equals expected_value
372
+ return false unless actual_value.is_a?(Array)
373
+ return false if actual_value.empty?
374
+
375
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
376
+ return false if numeric_array.empty?
377
+
378
+ median_value = if numeric_array.size.odd?
379
+ numeric_array[numeric_array.size / 2]
380
+ else
381
+ (numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
382
+ end
383
+ compare_aggregation_result(median_value, expected_value)
384
+
385
+ when "stddev", "standard_deviation"
386
+ # Checks if standard deviation of numeric array equals expected_value
387
+ return false unless actual_value.is_a?(Array)
388
+ return false if actual_value.size < 2
389
+
390
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
391
+ return false if numeric_array.size < 2
392
+
393
+ mean = numeric_array.sum.to_f / numeric_array.size
394
+ variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
395
+ stddev_value = Math.sqrt(variance)
396
+ compare_aggregation_result(stddev_value, expected_value)
397
+
398
+ when "variance"
399
+ # Checks if variance of numeric array equals expected_value
400
+ return false unless actual_value.is_a?(Array)
401
+ return false if actual_value.size < 2
402
+
403
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
404
+ return false if numeric_array.size < 2
405
+
406
+ mean = numeric_array.sum.to_f / numeric_array.size
407
+ variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
408
+ compare_aggregation_result(variance_value, expected_value)
409
+
410
+ when "percentile"
411
+ # Checks if Nth percentile of numeric array meets threshold
412
+ # expected_value: {percentile: 95, threshold: 200} or {percentile: 95, gt: 200, lt: 500}
413
+ return false unless actual_value.is_a?(Array)
414
+ return false if actual_value.empty?
415
+
416
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
417
+ return false if numeric_array.empty?
418
+
419
+ params = parse_percentile_params(expected_value)
420
+ return false unless params
421
+
422
+ percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
423
+ percentile_value = if percentile_index == percentile_index.to_i
424
+ numeric_array[percentile_index.to_i]
425
+ else
426
+ lower = numeric_array[percentile_index.floor]
427
+ upper = numeric_array[percentile_index.ceil]
428
+ lower + ((upper - lower) * (percentile_index - percentile_index.floor))
429
+ end
430
+
431
+ compare_percentile_result(percentile_value, params)
432
+
433
+ when "count"
434
+ # Checks if count of array elements meets threshold
435
+ # expected_value can be numeric or hash with comparison operators
436
+ return false unless actual_value.is_a?(Array)
437
+
438
+ count_value = actual_value.size
439
+ compare_aggregation_result(count_value, expected_value)
440
+
441
+ # DATE/TIME OPERATORS
442
+ when "before_date"
443
+ # Checks if date is before specified date
444
+ compare_dates(actual_value, expected_value, :<)
505
445
 
506
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
507
- return false unless end_date
446
+ when "after_date"
447
+ # Checks if date is after specified date
448
+ compare_dates(actual_value, expected_value, :>)
508
449
 
509
- duration = ((end_date - start_date).abs / 86_400.0)
510
- compare_duration_result(duration, params)
450
+ when "within_days"
451
+ # Checks if date is within N days from now (past or future)
452
+ # expected_value is number of days
453
+ return false unless actual_value
454
+ return false unless expected_value.is_a?(Numeric)
511
455
 
512
- # DATE ARITHMETIC
513
- when "add_days"
514
- # Adds days to a date and compares
515
- # expected_value: {days: 7, compare: "lt", target: "now"} or {days: 7, eq: target_date}
516
- return false unless actual_value
456
+ date = parse_date(actual_value)
457
+ return false unless date
517
458
 
518
- start_date = parse_date(actual_value)
519
- return false unless start_date
459
+ now = Time.now
460
+ diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
461
+ diff_days <= expected_value
520
462
 
521
- params = parse_date_arithmetic_params(expected_value)
522
- return false unless params
463
+ when "day_of_week"
464
+ # Checks if date falls on specified day of week
465
+ # expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
466
+ return false unless actual_value
523
467
 
524
- result_date = start_date + (params[:days] * 86_400)
525
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
526
- return false unless target_date
468
+ date = parse_date(actual_value)
469
+ return false unless date
527
470
 
528
- compare_date_result?(result_date, target_date, params)
471
+ expected_day = normalize_day_of_week(expected_value)
472
+ return false unless expected_day
529
473
 
530
- when "subtract_days"
531
- # Subtracts days from a date and compares
532
- return false unless actual_value
474
+ date.wday == expected_day
533
475
 
534
- start_date = parse_date(actual_value)
535
- return false unless start_date
476
+ # DURATION CALCULATIONS
477
+ when "duration_seconds"
478
+ # Calculates duration between two dates in seconds
479
+ # expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
480
+ return false unless actual_value
536
481
 
537
- params = parse_date_arithmetic_params(expected_value)
538
- return false unless params
482
+ start_date = parse_date(actual_value)
483
+ return false unless start_date
539
484
 
540
- result_date = start_date - (params[:days] * 86_400)
541
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
542
- return false unless target_date
485
+ params = parse_duration_params(expected_value)
486
+ return false unless params
543
487
 
544
- compare_date_result?(result_date, target_date, params)
488
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
489
+ return false unless end_date
545
490
 
546
- when "add_hours"
547
- # Adds hours to a date and compares
548
- return false unless actual_value
491
+ duration = (end_date - start_date).abs
492
+ compare_duration_result(duration, params)
549
493
 
550
- start_date = parse_date(actual_value)
551
- return false unless start_date
494
+ when "duration_minutes"
495
+ # Calculates duration between two dates in minutes
496
+ return false unless actual_value
552
497
 
553
- params = parse_date_arithmetic_params(expected_value, :hours)
554
- return false unless params
498
+ start_date = parse_date(actual_value)
499
+ return false unless start_date
555
500
 
556
- result_date = start_date + (params[:hours] * 3600)
557
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
558
- return false unless target_date
501
+ params = parse_duration_params(expected_value)
502
+ return false unless params
559
503
 
560
- compare_date_result?(result_date, target_date, params)
504
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
505
+ return false unless end_date
561
506
 
562
- when "subtract_hours"
563
- # Subtracts hours from a date and compares
564
- return false unless actual_value
507
+ duration = ((end_date - start_date).abs / 60.0)
508
+ compare_duration_result(duration, params)
565
509
 
566
- start_date = parse_date(actual_value)
567
- return false unless start_date
510
+ when "duration_hours"
511
+ # Calculates duration between two dates in hours
512
+ return false unless actual_value
568
513
 
569
- params = parse_date_arithmetic_params(expected_value, :hours)
570
- return false unless params
514
+ start_date = parse_date(actual_value)
515
+ return false unless start_date
571
516
 
572
- result_date = start_date - (params[:hours] * 3600)
573
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
574
- return false unless target_date
517
+ params = parse_duration_params(expected_value)
518
+ return false unless params
575
519
 
576
- compare_date_result?(result_date, target_date, params)
520
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
521
+ return false unless end_date
577
522
 
578
- when "add_minutes"
579
- # Adds minutes to a date and compares
580
- return false unless actual_value
523
+ duration = ((end_date - start_date).abs / 3600.0)
524
+ compare_duration_result(duration, params)
581
525
 
582
- start_date = parse_date(actual_value)
583
- return false unless start_date
526
+ when "duration_days"
527
+ # Calculates duration between two dates in days
528
+ return false unless actual_value
584
529
 
585
- params = parse_date_arithmetic_params(expected_value, :minutes)
586
- return false unless params
530
+ start_date = parse_date(actual_value)
531
+ return false unless start_date
587
532
 
588
- result_date = start_date + (params[:minutes] * 60)
589
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
590
- return false unless target_date
533
+ params = parse_duration_params(expected_value)
534
+ return false unless params
591
535
 
592
- compare_date_result?(result_date, target_date, params)
536
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
537
+ return false unless end_date
593
538
 
594
- when "subtract_minutes"
595
- # Subtracts minutes from a date and compares
596
- return false unless actual_value
539
+ duration = ((end_date - start_date).abs / 86_400.0)
540
+ compare_duration_result(duration, params)
597
541
 
598
- start_date = parse_date(actual_value)
599
- return false unless start_date
542
+ # DATE ARITHMETIC
543
+ when "add_days"
544
+ # Adds days to a date and compares
545
+ # expected_value: {days: 7, compare: "lt", target: "now"} or {days: 7, eq: target_date}
546
+ return false unless actual_value
600
547
 
601
- params = parse_date_arithmetic_params(expected_value, :minutes)
602
- return false unless params
548
+ start_date = parse_date(actual_value)
549
+ return false unless start_date
603
550
 
604
- result_date = start_date - (params[:minutes] * 60)
605
- target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
606
- return false unless target_date
551
+ params = parse_date_arithmetic_params(expected_value)
552
+ return false unless params
607
553
 
608
- compare_date_result?(result_date, target_date, params)
554
+ result_date = start_date + (params[:days] * 86_400)
555
+ target_date = if params[:target] == "now"
556
+ Time.now
557
+ else
558
+ parse_date(get_nested_value(context_hash,
559
+ params[:target]))
560
+ end
561
+ return false unless target_date
609
562
 
610
- # TIME COMPONENT EXTRACTION
611
- when "hour_of_day"
612
- # Extracts hour of day (0-23) and compares
613
- return false unless actual_value
563
+ compare_date_result?(result_date, target_date, params)
614
564
 
615
- date = parse_date(actual_value)
616
- return false unless date
565
+ when "subtract_days"
566
+ # Subtracts days from a date and compares
567
+ return false unless actual_value
617
568
 
618
- hour = date.hour
619
- compare_numeric_result(hour, expected_value)
569
+ start_date = parse_date(actual_value)
570
+ return false unless start_date
620
571
 
621
- when "day_of_month"
622
- # Extracts day of month (1-31) and compares
623
- return false unless actual_value
572
+ params = parse_date_arithmetic_params(expected_value)
573
+ return false unless params
624
574
 
625
- date = parse_date(actual_value)
626
- return false unless date
575
+ result_date = start_date - (params[:days] * 86_400)
576
+ target_date = if params[:target] == "now"
577
+ Time.now
578
+ else
579
+ parse_date(get_nested_value(context_hash,
580
+ params[:target]))
581
+ end
582
+ return false unless target_date
627
583
 
628
- day = date.day
629
- compare_numeric_result(day, expected_value)
584
+ compare_date_result?(result_date, target_date, params)
630
585
 
631
- when "month"
632
- # Extracts month (1-12) and compares
633
- return false unless actual_value
586
+ when "add_hours"
587
+ # Adds hours to a date and compares
588
+ return false unless actual_value
634
589
 
635
- date = parse_date(actual_value)
636
- return false unless date
590
+ start_date = parse_date(actual_value)
591
+ return false unless start_date
637
592
 
638
- month = date.month
639
- compare_numeric_result(month, expected_value)
593
+ params = parse_date_arithmetic_params(expected_value, :hours)
594
+ return false unless params
640
595
 
641
- when "year"
642
- # Extracts year and compares
643
- return false unless actual_value
596
+ result_date = start_date + (params[:hours] * 3600)
597
+ target_date = if params[:target] == "now"
598
+ Time.now
599
+ else
600
+ parse_date(get_nested_value(context_hash,
601
+ params[:target]))
602
+ end
603
+ return false unless target_date
644
604
 
645
- date = parse_date(actual_value)
646
- return false unless date
605
+ compare_date_result?(result_date, target_date, params)
647
606
 
648
- year = date.year
649
- compare_numeric_result(year, expected_value)
607
+ when "subtract_hours"
608
+ # Subtracts hours from a date and compares
609
+ return false unless actual_value
650
610
 
651
- when "week_of_year"
652
- # Extracts week of year (1-52) and compares
653
- return false unless actual_value
611
+ start_date = parse_date(actual_value)
612
+ return false unless start_date
654
613
 
655
- date = parse_date(actual_value)
656
- return false unless date
614
+ params = parse_date_arithmetic_params(expected_value, :hours)
615
+ return false unless params
657
616
 
658
- week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
659
- compare_numeric_result(week, expected_value)
617
+ result_date = start_date - (params[:hours] * 3600)
618
+ target_date = if params[:target] == "now"
619
+ Time.now
620
+ else
621
+ parse_date(get_nested_value(context_hash,
622
+ params[:target]))
623
+ end
624
+ return false unless target_date
660
625
 
661
- # RATE CALCULATIONS
662
- when "rate_per_second"
663
- # Calculates rate per second from array of timestamps
664
- # expected_value: {max: 10} or {min: 5, max: 100}
665
- return false unless actual_value.is_a?(Array)
666
- return false if actual_value.empty?
626
+ compare_date_result?(result_date, target_date, params)
667
627
 
668
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
669
- return false if timestamps.size < 2
628
+ when "add_minutes"
629
+ # Adds minutes to a date and compares
630
+ return false unless actual_value
670
631
 
671
- sorted_timestamps = timestamps.sort
672
- time_span = sorted_timestamps.last - sorted_timestamps.first
673
- return false if time_span <= 0
632
+ start_date = parse_date(actual_value)
633
+ return false unless start_date
674
634
 
675
- rate = timestamps.size.to_f / time_span
676
- compare_rate_result(rate, expected_value)
635
+ params = parse_date_arithmetic_params(expected_value, :minutes)
636
+ return false unless params
677
637
 
678
- when "rate_per_minute"
679
- # Calculates rate per minute from array of timestamps
680
- return false unless actual_value.is_a?(Array)
681
- return false if actual_value.empty?
638
+ result_date = start_date + (params[:minutes] * 60)
639
+ target_date = if params[:target] == "now"
640
+ Time.now
641
+ else
642
+ parse_date(get_nested_value(context_hash,
643
+ params[:target]))
644
+ end
645
+ return false unless target_date
682
646
 
683
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
684
- return false if timestamps.size < 2
647
+ compare_date_result?(result_date, target_date, params)
685
648
 
686
- sorted_timestamps = timestamps.sort
687
- time_span = sorted_timestamps.last - sorted_timestamps.first
688
- return false if time_span <= 0
649
+ when "subtract_minutes"
650
+ # Subtracts minutes from a date and compares
651
+ return false unless actual_value
689
652
 
690
- rate = (timestamps.size.to_f / time_span) * 60.0
691
- compare_rate_result(rate, expected_value)
653
+ start_date = parse_date(actual_value)
654
+ return false unless start_date
692
655
 
693
- when "rate_per_hour"
694
- # Calculates rate per hour from array of timestamps
695
- return false unless actual_value.is_a?(Array)
696
- return false if actual_value.empty?
656
+ params = parse_date_arithmetic_params(expected_value, :minutes)
657
+ return false unless params
697
658
 
698
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
699
- return false if timestamps.size < 2
659
+ result_date = start_date - (params[:minutes] * 60)
660
+ target_date = if params[:target] == "now"
661
+ Time.now
662
+ else
663
+ parse_date(get_nested_value(context_hash,
664
+ params[:target]))
665
+ end
666
+ return false unless target_date
700
667
 
701
- sorted_timestamps = timestamps.sort
702
- time_span = sorted_timestamps.last - sorted_timestamps.first
703
- return false if time_span <= 0
668
+ compare_date_result?(result_date, target_date, params)
704
669
 
705
- rate = (timestamps.size.to_f / time_span) * 3600.0
706
- compare_rate_result(rate, expected_value)
670
+ # TIME COMPONENT EXTRACTION
671
+ when "hour_of_day"
672
+ # Extracts hour of day (0-23) and compares
673
+ return false unless actual_value
707
674
 
708
- # MOVING WINDOW CALCULATIONS
709
- when "moving_average"
710
- # Calculates moving average over window
711
- # expected_value: {window: 5, threshold: 100} or {window: 5, gt: 100}
712
- return false unless actual_value.is_a?(Array)
713
- return false if actual_value.empty?
675
+ date = parse_date(actual_value)
676
+ return false unless date
714
677
 
715
- # OPTIMIZE: filter once and reuse
716
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
717
- return false if numeric_array.empty?
678
+ hour = date.hour
679
+ compare_numeric_result(hour, expected_value)
718
680
 
719
- params = parse_moving_window_params(expected_value)
720
- return false unless params
681
+ when "day_of_month"
682
+ # Extracts day of month (1-31) and compares
683
+ return false unless actual_value
721
684
 
722
- window = [params[:window], numeric_array.size].min
723
- return false if window < 1
685
+ date = parse_date(actual_value)
686
+ return false unless date
724
687
 
725
- # OPTIMIZE: use slice instead of last for better performance
726
- window_array = numeric_array.slice(-window, window)
727
- moving_avg = window_array.sum.to_f / window
728
- compare_moving_window_result(moving_avg, params)
688
+ day = date.day
689
+ compare_numeric_result(day, expected_value)
729
690
 
730
- when "moving_sum"
731
- # Calculates moving sum over window
732
- return false unless actual_value.is_a?(Array)
733
- return false if actual_value.empty?
691
+ when "month"
692
+ # Extracts month (1-12) and compares
693
+ return false unless actual_value
734
694
 
735
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
736
- return false if numeric_array.empty?
695
+ date = parse_date(actual_value)
696
+ return false unless date
737
697
 
738
- params = parse_moving_window_params(expected_value)
739
- return false unless params
698
+ month = date.month
699
+ compare_numeric_result(month, expected_value)
740
700
 
741
- window = [params[:window], numeric_array.size].min
742
- return false if window < 1
701
+ when "year"
702
+ # Extracts year and compares
703
+ return false unless actual_value
743
704
 
744
- # OPTIMIZE: use slice instead of last
745
- window_array = numeric_array.slice(-window, window)
746
- moving_sum = window_array.sum
747
- compare_moving_window_result(moving_sum, params)
705
+ date = parse_date(actual_value)
706
+ return false unless date
748
707
 
749
- when "moving_max"
750
- # Calculates moving max over window
751
- return false unless actual_value.is_a?(Array)
752
- return false if actual_value.empty?
708
+ year = date.year
709
+ compare_numeric_result(year, expected_value)
753
710
 
754
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
755
- return false if numeric_array.empty?
711
+ when "week_of_year"
712
+ # Extracts week of year (1-52) and compares
713
+ return false unless actual_value
756
714
 
757
- params = parse_moving_window_params(expected_value)
758
- return false unless params
715
+ date = parse_date(actual_value)
716
+ return false unless date
759
717
 
760
- window = [params[:window], numeric_array.size].min
761
- return false if window < 1
718
+ week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
719
+ compare_numeric_result(week, expected_value)
762
720
 
763
- # OPTIMIZE: use slice instead of last, iterate directly for max
764
- window_array = numeric_array.slice(-window, window)
765
- moving_max = window_array.max
766
- compare_moving_window_result(moving_max, params)
721
+ # RATE CALCULATIONS
722
+ when "rate_per_second"
723
+ # Calculates rate per second from array of timestamps
724
+ # expected_value: {max: 10} or {min: 5, max: 100}
725
+ return false unless actual_value.is_a?(Array)
726
+ return false if actual_value.empty?
767
727
 
768
- when "moving_min"
769
- # Calculates moving min over window
770
- return false unless actual_value.is_a?(Array)
771
- return false if actual_value.empty?
728
+ timestamps = actual_value.map { |ts| parse_date(ts) }.compact
729
+ return false if timestamps.size < 2
772
730
 
773
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
774
- return false if numeric_array.empty?
731
+ sorted_timestamps = timestamps.sort
732
+ time_span = sorted_timestamps.last - sorted_timestamps.first
733
+ return false if time_span <= 0
775
734
 
776
- params = parse_moving_window_params(expected_value)
777
- return false unless params
735
+ rate = timestamps.size.to_f / time_span
736
+ compare_rate_result(rate, expected_value)
778
737
 
779
- window = [params[:window], numeric_array.size].min
780
- return false if window < 1
738
+ when "rate_per_minute"
739
+ # Calculates rate per minute from array of timestamps
740
+ return false unless actual_value.is_a?(Array)
741
+ return false if actual_value.empty?
781
742
 
782
- # OPTIMIZE: use slice instead of last
783
- window_array = numeric_array.slice(-window, window)
784
- moving_min = window_array.min
785
- compare_moving_window_result(moving_min, params)
743
+ timestamps = actual_value.map { |ts| parse_date(ts) }.compact
744
+ return false if timestamps.size < 2
786
745
 
787
- # FINANCIAL CALCULATIONS
788
- when "compound_interest"
789
- # Calculates compound interest: A = P(1 + r/n)^(nt)
790
- # expected_value: {rate: 0.05, periods: 12, result: 1050} or {rate: 0.05, periods: 12, compare: "gt", threshold: 1000}
791
- return false unless actual_value.is_a?(Numeric)
746
+ sorted_timestamps = timestamps.sort
747
+ time_span = sorted_timestamps.last - sorted_timestamps.first
748
+ return false if time_span <= 0
792
749
 
793
- params = parse_compound_interest_params(expected_value)
794
- return false unless params
750
+ rate = (timestamps.size.to_f / time_span) * 60.0
751
+ compare_rate_result(rate, expected_value)
795
752
 
796
- principal = actual_value
797
- rate = params[:rate]
798
- periods = params[:periods]
799
- result = principal * ((1 + (rate / periods))**periods)
753
+ when "rate_per_hour"
754
+ # Calculates rate per hour from array of timestamps
755
+ return false unless actual_value.is_a?(Array)
756
+ return false if actual_value.empty?
800
757
 
801
- if params[:result]
802
- (result.round(2) == params[:result].round(2))
803
- else
804
- compare_financial_result(result, params)
805
- end
758
+ timestamps = actual_value.map { |ts| parse_date(ts) }.compact
759
+ return false if timestamps.size < 2
806
760
 
807
- when "present_value"
808
- # Calculates present value: PV = FV / (1 + r)^n
809
- # expected_value: {rate: 0.05, periods: 10, result: 613.91}
810
- return false unless actual_value.is_a?(Numeric)
761
+ sorted_timestamps = timestamps.sort
762
+ time_span = sorted_timestamps.last - sorted_timestamps.first
763
+ return false if time_span <= 0
811
764
 
812
- params = parse_present_value_params(expected_value)
813
- return false unless params
765
+ rate = (timestamps.size.to_f / time_span) * 3600.0
766
+ compare_rate_result(rate, expected_value)
814
767
 
815
- future_value = actual_value
816
- rate = params[:rate]
817
- periods = params[:periods]
818
- present_value = future_value / ((1 + rate)**periods)
768
+ # MOVING WINDOW CALCULATIONS
769
+ when "moving_average"
770
+ # Calculates moving average over window
771
+ # expected_value: {window: 5, threshold: 100} or {window: 5, gt: 100}
772
+ return false unless actual_value.is_a?(Array)
773
+ return false if actual_value.empty?
819
774
 
820
- if params[:result]
821
- (present_value.round(2) == params[:result].round(2))
822
- else
823
- compare_financial_result(present_value, params)
824
- end
775
+ # OPTIMIZE: filter once and reuse
776
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
777
+ return false if numeric_array.empty?
825
778
 
826
- when "future_value"
827
- # Calculates future value: FV = PV * (1 + r)^n
828
- # expected_value: {rate: 0.05, periods: 10, result: 1628.89}
829
- return false unless actual_value.is_a?(Numeric)
779
+ params = parse_moving_window_params(expected_value)
780
+ return false unless params
830
781
 
831
- params = parse_future_value_params(expected_value)
832
- return false unless params
782
+ window = [params[:window], numeric_array.size].min
783
+ return false if window < 1
833
784
 
834
- present_value = actual_value
835
- rate = params[:rate]
836
- periods = params[:periods]
837
- future_value = present_value * ((1 + rate)**periods)
785
+ # OPTIMIZE: use slice instead of last for better performance
786
+ window_array = numeric_array.slice(-window, window)
787
+ moving_avg = window_array.sum.to_f / window
788
+ compare_moving_window_result(moving_avg, params)
838
789
 
839
- if params[:result]
840
- (future_value.round(2) == params[:result].round(2))
841
- else
842
- compare_financial_result(future_value, params)
843
- end
844
-
845
- when "payment"
846
- # Calculates loan payment: PMT = P * [r(1+r)^n] / [(1+r)^n - 1]
847
- # expected_value: {rate: 0.05, periods: 12, result: 100}
848
- return false unless actual_value.is_a?(Numeric)
790
+ when "moving_sum"
791
+ # Calculates moving sum over window
792
+ return false unless actual_value.is_a?(Array)
793
+ return false if actual_value.empty?
849
794
 
850
- params = parse_payment_params(expected_value)
851
- return false unless params
795
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
796
+ return false if numeric_array.empty?
852
797
 
853
- principal = actual_value
854
- rate = params[:rate]
855
- periods = params[:periods]
798
+ params = parse_moving_window_params(expected_value)
799
+ return false unless params
856
800
 
857
- return false if rate <= 0 || periods <= 0
801
+ window = [params[:window], numeric_array.size].min
802
+ return false if window < 1
803
+
804
+ # OPTIMIZE: use slice instead of last
805
+ window_array = numeric_array.slice(-window, window)
806
+ moving_sum = window_array.sum
807
+ compare_moving_window_result(moving_sum, params)
808
+
809
+ when "moving_max"
810
+ # Calculates moving max over window
811
+ return false unless actual_value.is_a?(Array)
812
+ return false if actual_value.empty?
813
+
814
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
815
+ return false if numeric_array.empty?
816
+
817
+ params = parse_moving_window_params(expected_value)
818
+ return false unless params
819
+
820
+ window = [params[:window], numeric_array.size].min
821
+ return false if window < 1
822
+
823
+ # OPTIMIZE: use slice instead of last, iterate directly for max
824
+ window_array = numeric_array.slice(-window, window)
825
+ moving_max = window_array.max
826
+ compare_moving_window_result(moving_max, params)
858
827
 
859
- payment = if rate.zero?
860
- principal / periods
861
- else
862
- principal * (rate * ((1 + rate)**periods)) / (((1 + rate)**periods) - 1)
863
- end
828
+ when "moving_min"
829
+ # Calculates moving min over window
830
+ return false unless actual_value.is_a?(Array)
831
+ return false if actual_value.empty?
832
+
833
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
834
+ return false if numeric_array.empty?
835
+
836
+ params = parse_moving_window_params(expected_value)
837
+ return false unless params
838
+
839
+ window = [params[:window], numeric_array.size].min
840
+ return false if window < 1
841
+
842
+ # OPTIMIZE: use slice instead of last
843
+ window_array = numeric_array.slice(-window, window)
844
+ moving_min = window_array.min
845
+ compare_moving_window_result(moving_min, params)
846
+
847
+ # FINANCIAL CALCULATIONS
848
+ when "compound_interest"
849
+ # Calculates compound interest: A = P(1 + r/n)^(nt)
850
+ # expected_value: {rate: 0.05, periods: 12, result: 1050} or {rate: 0.05, periods: 12, compare: "gt", threshold: 1000}
851
+ return false unless actual_value.is_a?(Numeric)
852
+
853
+ params = parse_compound_interest_params(expected_value)
854
+ return false unless params
855
+
856
+ principal = actual_value
857
+ rate = params[:rate]
858
+ periods = params[:periods]
859
+ result = principal * ((1 + (rate / periods))**periods)
860
+
861
+ if params[:result]
862
+ (result.round(2) == params[:result].round(2))
863
+ else
864
+ compare_financial_result(result, params)
865
+ end
866
+
867
+ when "present_value"
868
+ # Calculates present value: PV = FV / (1 + r)^n
869
+ # expected_value: {rate: 0.05, periods: 10, result: 613.91}
870
+ return false unless actual_value.is_a?(Numeric)
871
+
872
+ params = parse_present_value_params(expected_value)
873
+ return false unless params
864
874
 
865
- if params[:result]
866
- (payment.round(2) == params[:result].round(2))
867
- else
868
- compare_financial_result(payment, params)
869
- end
870
-
871
- # STRING AGGREGATIONS
872
- when "join"
873
- # Joins array of strings with separator
874
- # expected_value: {separator: ",", result: "a,b,c"} or {separator: ",", contains: "a"}
875
- return false unless actual_value.is_a?(Array)
876
- return false if actual_value.empty?
877
-
878
- string_array = actual_value.map(&:to_s)
879
- params = parse_join_params(expected_value)
880
- return false unless params
881
-
882
- joined = string_array.join(params[:separator])
883
-
884
- if params[:result]
885
- joined == params[:result]
886
- elsif params[:contains]
887
- joined.include?(params[:contains])
888
- else
889
- false
890
- end
891
-
892
- when "length"
893
- # Gets length of string or array
894
- # expected_value: {max: 500} or {min: 10, max: 100}
895
- return false if actual_value.nil?
896
-
897
- length_value = if actual_value.is_a?(String) || actual_value.is_a?(Array)
898
- actual_value.length
899
- else
900
- return false
901
- end
902
-
903
- compare_length_result(length_value, expected_value)
904
-
905
- # COLLECTION OPERATORS
906
- when "contains_all"
907
- # Checks if array contains all specified elements
908
- # expected_value should be an array
909
- return false unless actual_value.is_a?(Array)
910
- return false unless expected_value.is_a?(Array)
911
- return true if expected_value.empty?
912
-
913
- # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
914
- # For small arrays, Set overhead is minimal; for large arrays, huge win
915
- actual_set = actual_value.to_set
916
- expected_value.all? { |item| actual_set.include?(item) }
917
-
918
- when "contains_any"
919
- # Checks if array contains any of the specified elements
920
- # expected_value should be an array
921
- return false unless actual_value.is_a?(Array)
922
- return false unless expected_value.is_a?(Array)
923
- return false if expected_value.empty?
924
-
925
- # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
926
- # Early exit on first match for better performance
927
- actual_set = actual_value.to_set
928
- expected_value.any? { |item| actual_set.include?(item) }
929
-
930
- when "intersects"
931
- # Checks if two arrays have any common elements
932
- # expected_value should be an array
933
- return false unless actual_value.is_a?(Array)
934
- return false unless expected_value.is_a?(Array)
935
- return false if actual_value.empty? || expected_value.empty?
936
-
937
- # OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
938
- # Check smaller array against larger set for better performance
939
- if actual_value.size <= expected_value.size
940
- expected_set = expected_value.to_set
941
- actual_value.any? { |item| expected_set.include?(item) }
942
- else
943
- actual_set = actual_value.to_set
944
- expected_value.any? { |item| actual_set.include?(item) }
945
- end
875
+ future_value = actual_value
876
+ rate = params[:rate]
877
+ periods = params[:periods]
878
+ present_value = future_value / ((1 + rate)**periods)
879
+
880
+ if params[:result]
881
+ (present_value.round(2) == params[:result].round(2))
882
+ else
883
+ compare_financial_result(present_value, params)
884
+ end
885
+
886
+ when "future_value"
887
+ # Calculates future value: FV = PV * (1 + r)^n
888
+ # expected_value: {rate: 0.05, periods: 10, result: 1628.89}
889
+ return false unless actual_value.is_a?(Numeric)
890
+
891
+ params = parse_future_value_params(expected_value)
892
+ return false unless params
893
+
894
+ present_value = actual_value
895
+ rate = params[:rate]
896
+ periods = params[:periods]
897
+ future_value = present_value * ((1 + rate)**periods)
898
+
899
+ if params[:result]
900
+ (future_value.round(2) == params[:result].round(2))
901
+ else
902
+ compare_financial_result(future_value, params)
903
+ end
904
+
905
+ when "payment"
906
+ # Calculates loan payment: PMT = P * [r(1+r)^n] / [(1+r)^n - 1]
907
+ # expected_value: {rate: 0.05, periods: 12, result: 100}
908
+ return false unless actual_value.is_a?(Numeric)
909
+
910
+ params = parse_payment_params(expected_value)
911
+ return false unless params
912
+
913
+ principal = actual_value
914
+ rate = params[:rate]
915
+ periods = params[:periods]
916
+
917
+ return false if rate <= 0 || periods <= 0
918
+
919
+ payment = if rate.zero?
920
+ principal / periods
921
+ else
922
+ principal * (rate * ((1 + rate)**periods)) / (((1 + rate)**periods) - 1)
923
+ end
946
924
 
947
- when "subset_of"
948
- # Checks if array is a subset of another array
949
- # All elements in actual_value must be in expected_value
950
- return false unless actual_value.is_a?(Array)
951
- return false unless expected_value.is_a?(Array)
952
- return true if actual_value.empty?
953
-
954
- # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
955
- expected_set = expected_value.to_set
956
- actual_value.all? { |item| expected_set.include?(item) }
957
-
958
- # GEOSPATIAL OPERATORS
959
- when "within_radius"
960
- # Checks if point is within radius of center point
961
- # actual_value: {lat: y, lon: x} or [lat, lon]
962
- # expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
963
- point = parse_coordinates(actual_value)
964
- return false unless point
965
-
966
- params = parse_radius_params(expected_value)
967
- return false unless params
968
-
969
- # Cache geospatial distance calculations
970
- distance = get_cached_distance(point, params[:center])
971
- distance <= params[:radius]
972
-
973
- when "in_polygon"
974
- # Checks if point is inside a polygon using ray casting algorithm
975
- # actual_value: {lat: y, lon: x} or [lat, lon]
976
- # expected_value: array of vertices [{lat: y, lon: x}, ...] or [[lat, lon], ...]
977
- point = parse_coordinates(actual_value)
978
- return false unless point
979
-
980
- polygon = parse_polygon(expected_value)
981
- return false unless polygon
982
- return false if polygon.size < 3 # Need at least 3 vertices
983
-
984
- point_in_polygon?(point, polygon)
925
+ if params[:result]
926
+ (payment.round(2) == params[:result].round(2))
927
+ else
928
+ compare_financial_result(payment, params)
929
+ end
930
+
931
+ # STRING AGGREGATIONS
932
+ when "join"
933
+ # Joins array of strings with separator
934
+ # expected_value: {separator: ",", result: "a,b,c"} or {separator: ",", contains: "a"}
935
+ return false unless actual_value.is_a?(Array)
936
+ return false if actual_value.empty?
937
+
938
+ string_array = actual_value.map(&:to_s)
939
+ params = parse_join_params(expected_value)
940
+ return false unless params
941
+
942
+ joined = string_array.join(params[:separator])
943
+
944
+ if params[:result]
945
+ joined == params[:result]
946
+ elsif params[:contains]
947
+ joined.include?(params[:contains])
948
+ else
949
+ false
950
+ end
951
+
952
+ when "length"
953
+ # Gets length of string or array
954
+ # expected_value: {max: 500} or {min: 10, max: 100}
955
+ return false if actual_value.nil?
956
+
957
+ length_value = if actual_value.is_a?(String) || actual_value.is_a?(Array)
958
+ actual_value.length
959
+ else
960
+ return false
961
+ end
962
+
963
+ compare_length_result(length_value, expected_value)
964
+
965
+ # COLLECTION OPERATORS
966
+ when "contains_all"
967
+ # Checks if array contains all specified elements
968
+ # expected_value should be an array
969
+ return false unless actual_value.is_a?(Array)
970
+ return false unless expected_value.is_a?(Array)
971
+ return true if expected_value.empty?
972
+
973
+ # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
974
+ # For small arrays, Set overhead is minimal; for large arrays, huge win
975
+ actual_set = actual_value.to_set
976
+ expected_value.all? { |item| actual_set.include?(item) }
977
+
978
+ when "contains_any"
979
+ # Checks if array contains any of the specified elements
980
+ # expected_value should be an array
981
+ return false unless actual_value.is_a?(Array)
982
+ return false unless expected_value.is_a?(Array)
983
+ return false if expected_value.empty?
984
+
985
+ # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
986
+ # Early exit on first match for better performance
987
+ actual_set = actual_value.to_set
988
+ expected_value.any? { |item| actual_set.include?(item) }
989
+
990
+ when "intersects"
991
+ # Checks if two arrays have any common elements
992
+ # expected_value should be an array
993
+ return false unless actual_value.is_a?(Array)
994
+ return false unless expected_value.is_a?(Array)
995
+ return false if actual_value.empty? || expected_value.empty?
996
+
997
+ # OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
998
+ # Check smaller array against larger set for better performance
999
+ if actual_value.size <= expected_value.size
1000
+ expected_set = expected_value.to_set
1001
+ actual_value.any? { |item| expected_set.include?(item) }
1002
+ else
1003
+ actual_set = actual_value.to_set
1004
+ expected_value.any? { |item| actual_set.include?(item) }
1005
+ end
1006
+
1007
+ when "subset_of"
1008
+ # Checks if array is a subset of another array
1009
+ # All elements in actual_value must be in expected_value
1010
+ return false unless actual_value.is_a?(Array)
1011
+ return false unless expected_value.is_a?(Array)
1012
+ return true if actual_value.empty?
1013
+
1014
+ # OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
1015
+ expected_set = expected_value.to_set
1016
+ actual_value.all? { |item| expected_set.include?(item) }
1017
+
1018
+ # GEOSPATIAL OPERATORS
1019
+ when "within_radius"
1020
+ # Checks if point is within radius of center point
1021
+ # actual_value: {lat: y, lon: x} or [lat, lon]
1022
+ # expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
1023
+ point = parse_coordinates(actual_value)
1024
+ return false unless point
1025
+
1026
+ params = parse_radius_params(expected_value)
1027
+ return false unless params
1028
+
1029
+ # Cache geospatial distance calculations
1030
+ distance = get_cached_distance(point, params[:center])
1031
+ distance <= params[:radius]
1032
+
1033
+ when "in_polygon"
1034
+ # Checks if point is inside a polygon using ray casting algorithm
1035
+ # actual_value: {lat: y, lon: x} or [lat, lon]
1036
+ # expected_value: array of vertices [{lat: y, lon: x}, ...] or [[lat, lon], ...]
1037
+ point = parse_coordinates(actual_value)
1038
+ return false unless point
1039
+
1040
+ polygon = parse_polygon(expected_value)
1041
+ return false unless polygon
1042
+ return false if polygon.size < 3 # Need at least 3 vertices
1043
+
1044
+ point_in_polygon?(point, polygon)
1045
+
1046
+ when "fetch_from_api"
1047
+ # Fetches data from external API and enriches context
1048
+ # expected_value: { endpoint: :endpoint_name, params: {...}, mapping: {...} }
1049
+ return false unless expected_value.is_a?(Hash)
1050
+ return false unless expected_value[:endpoint] || expected_value["endpoint"]
1051
+
1052
+ begin
1053
+ endpoint_name = (expected_value[:endpoint] || expected_value["endpoint"]).to_sym
1054
+ params = expand_template_params(expected_value[:params] || expected_value["params"] || {}, context_hash)
1055
+ mapping = expected_value[:mapping] || expected_value["mapping"] || {}
1056
+
1057
+ # Get data enrichment client
1058
+ client = DecisionAgent.data_enrichment_client
1059
+
1060
+ # Fetch data from API
1061
+ response_data = client.fetch(endpoint_name, params: params, use_cache: true)
1062
+
1063
+ # Apply mapping if provided and merge into context_hash
1064
+ if mapping.any?
1065
+ mapped_data = apply_mapping(response_data, mapping)
1066
+ # Merge mapped data into context_hash for subsequent conditions
1067
+ mapped_data.each do |key, value|
1068
+ context_hash[key] = value
1069
+ end
1070
+ # Return true if fetch succeeded and mapping applied
1071
+ mapped_data.any?
1072
+ else
1073
+ # Return true if fetch succeeded
1074
+ !response_data.nil?
1075
+ end
1076
+ rescue StandardError => e
1077
+ # Log error but return false (fail-safe)
1078
+ warn "Data enrichment error: #{e.message}" if ENV["DEBUG"]
1079
+ false
1080
+ end
1081
+
1082
+ else
1083
+ # Unknown operator - returns false (fail-safe)
1084
+ # Note: Validation should catch this earlier
1085
+ false
1086
+ end
1087
+
1088
+ # Add trace if collector is provided
1089
+ trace_collector&.add_trace(Explainability::ConditionTrace.new(
1090
+ field: field,
1091
+ operator: op,
1092
+ expected_value: expected_value,
1093
+ actual_value: actual_value,
1094
+ result: result
1095
+ ))
985
1096
 
986
- else
987
- # Unknown operator - returns false (fail-safe)
988
- # Note: Validation should catch this earlier
989
- false
990
- end
1097
+ result
991
1098
  end
992
1099
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
993
1100
 
@@ -1016,15 +1123,54 @@ module DecisionAgent
1016
1123
  end
1017
1124
 
1018
1125
  # Checks if two values can be compared with <, >, <=, >=
1019
- # Only allows comparison between values of the same type
1126
+ # Allows comparison between numeric types (Float, Integer, etc.) or same string types
1020
1127
  def self.comparable?(val1, val2)
1021
- (val1.is_a?(Numeric) || val1.is_a?(String)) &&
1022
- (val2.is_a?(Numeric) || val2.is_a?(String)) &&
1023
- val1.instance_of?(val2.class)
1128
+ # Both are numeric - allow comparison between different numeric types
1129
+ # (e.g., Integer and Float are comparable in Ruby)
1130
+ return true if val1.is_a?(Numeric) && val2.is_a?(Numeric)
1131
+
1132
+ # Both are strings - require exact same type
1133
+ return val1.instance_of?(val2.class) if val1.is_a?(String) && val2.is_a?(String)
1134
+
1135
+ false
1024
1136
  end
1025
1137
 
1026
1138
  # Helper methods for new operators
1027
1139
 
1140
+ # Expand template parameters (e.g., "{{customer.ssn}}") from context
1141
+ def self.expand_template_params(params, context_hash)
1142
+ return {} unless params.is_a?(Hash)
1143
+
1144
+ params.transform_values do |value|
1145
+ expand_template_value(value, context_hash)
1146
+ end
1147
+ end
1148
+
1149
+ # Expand a single template value
1150
+ def self.expand_template_value(value, context_hash)
1151
+ return value unless value.is_a?(String)
1152
+ return value unless value.match?(/\{\{.*\}\}/)
1153
+
1154
+ # Extract path from {{path}} syntax
1155
+ value.gsub(/\{\{([^}]+)\}\}/) do |_match|
1156
+ path = Regexp.last_match(1).strip
1157
+ get_nested_value(context_hash, path) || value
1158
+ end
1159
+ end
1160
+
1161
+ # Apply mapping to API response data
1162
+ # Mapping format: { source_key: "target_key" }
1163
+ # Example: { score: "credit_score" } means map response[:score] to context["credit_score"]
1164
+ def self.apply_mapping(response_data, mapping)
1165
+ return {} unless response_data.is_a?(Hash)
1166
+ return {} unless mapping.is_a?(Hash)
1167
+
1168
+ mapping.each_with_object({}) do |(source_key, target_key), result|
1169
+ source_value = get_nested_value(response_data, source_key.to_s)
1170
+ result[target_key.to_s] = source_value unless source_value.nil?
1171
+ end
1172
+ end
1173
+
1028
1174
  # String operator validation
1029
1175
  def self.string_operator?(actual_value, expected_value)
1030
1176
  actual_value.is_a?(String) && expected_value.is_a?(String)