decision_agent 0.3.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -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,955 +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
81
  # Special handling for "don't care" conditions (from DMN "-" entries)
67
- return true if field == "__always_match__" && op == "eq" && expected_value == true
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
68
92
 
69
- context_hash = context.to_h
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
70
96
  actual_value = get_nested_value(context_hash, field)
71
97
 
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
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, :<)
502
445
 
503
- start_date = parse_date(actual_value)
504
- return false unless start_date
446
+ when "after_date"
447
+ # Checks if date is after specified date
448
+ compare_dates(actual_value, expected_value, :>)
505
449
 
506
- params = parse_duration_params(expected_value)
507
- return false unless 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)
508
455
 
509
- end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
510
- return false unless end_date
456
+ date = parse_date(actual_value)
457
+ return false unless date
511
458
 
512
- duration = ((end_date - start_date).abs / 86_400.0)
513
- compare_duration_result(duration, params)
459
+ now = Time.now
460
+ diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
461
+ diff_days <= expected_value
514
462
 
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
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
520
467
 
521
- start_date = parse_date(actual_value)
522
- return false unless start_date
468
+ date = parse_date(actual_value)
469
+ return false unless date
523
470
 
524
- params = parse_date_arithmetic_params(expected_value)
525
- return false unless params
471
+ expected_day = normalize_day_of_week(expected_value)
472
+ return false unless expected_day
526
473
 
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
474
+ date.wday == expected_day
530
475
 
531
- compare_date_result?(result_date, target_date, params)
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
532
481
 
533
- when "subtract_days"
534
- # Subtracts days from a date and compares
535
- return false unless actual_value
482
+ start_date = parse_date(actual_value)
483
+ return false unless start_date
536
484
 
537
- start_date = parse_date(actual_value)
538
- return false unless start_date
485
+ params = parse_duration_params(expected_value)
486
+ return false unless params
539
487
 
540
- params = parse_date_arithmetic_params(expected_value)
541
- return false unless params
488
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
489
+ return false unless end_date
542
490
 
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
491
+ duration = (end_date - start_date).abs
492
+ compare_duration_result(duration, params)
546
493
 
547
- compare_date_result?(result_date, target_date, params)
494
+ when "duration_minutes"
495
+ # Calculates duration between two dates in minutes
496
+ return false unless actual_value
548
497
 
549
- when "add_hours"
550
- # Adds hours to a date and compares
551
- return false unless actual_value
498
+ start_date = parse_date(actual_value)
499
+ return false unless start_date
552
500
 
553
- start_date = parse_date(actual_value)
554
- return false unless start_date
501
+ params = parse_duration_params(expected_value)
502
+ return false unless params
555
503
 
556
- params = parse_date_arithmetic_params(expected_value, :hours)
557
- return false unless params
504
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
505
+ return false unless end_date
558
506
 
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
507
+ duration = ((end_date - start_date).abs / 60.0)
508
+ compare_duration_result(duration, params)
562
509
 
563
- compare_date_result?(result_date, target_date, params)
510
+ when "duration_hours"
511
+ # Calculates duration between two dates in hours
512
+ return false unless actual_value
564
513
 
565
- when "subtract_hours"
566
- # Subtracts hours from a date and compares
567
- return false unless actual_value
514
+ start_date = parse_date(actual_value)
515
+ return false unless start_date
568
516
 
569
- start_date = parse_date(actual_value)
570
- return false unless start_date
517
+ params = parse_duration_params(expected_value)
518
+ return false unless params
571
519
 
572
- params = parse_date_arithmetic_params(expected_value, :hours)
573
- return false unless params
520
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
521
+ return false unless end_date
574
522
 
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
523
+ duration = ((end_date - start_date).abs / 3600.0)
524
+ compare_duration_result(duration, params)
578
525
 
579
- compare_date_result?(result_date, target_date, params)
526
+ when "duration_days"
527
+ # Calculates duration between two dates in days
528
+ return false unless actual_value
580
529
 
581
- when "add_minutes"
582
- # Adds minutes to a date and compares
583
- return false unless actual_value
530
+ start_date = parse_date(actual_value)
531
+ return false unless start_date
584
532
 
585
- start_date = parse_date(actual_value)
586
- return false unless start_date
533
+ params = parse_duration_params(expected_value)
534
+ return false unless params
587
535
 
588
- params = parse_date_arithmetic_params(expected_value, :minutes)
589
- return false unless params
536
+ end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
537
+ return false unless end_date
590
538
 
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
539
+ duration = ((end_date - start_date).abs / 86_400.0)
540
+ compare_duration_result(duration, params)
594
541
 
595
- compare_date_result?(result_date, target_date, params)
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
596
547
 
597
- when "subtract_minutes"
598
- # Subtracts minutes from a date and compares
599
- return false unless actual_value
548
+ start_date = parse_date(actual_value)
549
+ return false unless start_date
600
550
 
601
- start_date = parse_date(actual_value)
602
- return false unless start_date
551
+ params = parse_date_arithmetic_params(expected_value)
552
+ return false unless params
603
553
 
604
- params = parse_date_arithmetic_params(expected_value, :minutes)
605
- return false unless 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
606
562
 
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
563
+ compare_date_result?(result_date, target_date, params)
610
564
 
611
- compare_date_result?(result_date, target_date, params)
565
+ when "subtract_days"
566
+ # Subtracts days from a date and compares
567
+ return false unless actual_value
612
568
 
613
- # TIME COMPONENT EXTRACTION
614
- when "hour_of_day"
615
- # Extracts hour of day (0-23) and compares
616
- return false unless actual_value
569
+ start_date = parse_date(actual_value)
570
+ return false unless start_date
617
571
 
618
- date = parse_date(actual_value)
619
- return false unless date
572
+ params = parse_date_arithmetic_params(expected_value)
573
+ return false unless params
620
574
 
621
- hour = date.hour
622
- compare_numeric_result(hour, expected_value)
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
623
583
 
624
- when "day_of_month"
625
- # Extracts day of month (1-31) and compares
626
- return false unless actual_value
584
+ compare_date_result?(result_date, target_date, params)
627
585
 
628
- date = parse_date(actual_value)
629
- return false unless date
586
+ when "add_hours"
587
+ # Adds hours to a date and compares
588
+ return false unless actual_value
630
589
 
631
- day = date.day
632
- compare_numeric_result(day, expected_value)
590
+ start_date = parse_date(actual_value)
591
+ return false unless start_date
633
592
 
634
- when "month"
635
- # Extracts month (1-12) and compares
636
- return false unless actual_value
593
+ params = parse_date_arithmetic_params(expected_value, :hours)
594
+ return false unless params
637
595
 
638
- date = parse_date(actual_value)
639
- return false unless date
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
640
604
 
641
- month = date.month
642
- compare_numeric_result(month, expected_value)
605
+ compare_date_result?(result_date, target_date, params)
643
606
 
644
- when "year"
645
- # Extracts year and compares
646
- return false unless actual_value
607
+ when "subtract_hours"
608
+ # Subtracts hours from a date and compares
609
+ return false unless actual_value
647
610
 
648
- date = parse_date(actual_value)
649
- return false unless date
611
+ start_date = parse_date(actual_value)
612
+ return false unless start_date
650
613
 
651
- year = date.year
652
- compare_numeric_result(year, expected_value)
614
+ params = parse_date_arithmetic_params(expected_value, :hours)
615
+ return false unless params
653
616
 
654
- when "week_of_year"
655
- # Extracts week of year (1-52) and compares
656
- return false unless actual_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
657
625
 
658
- date = parse_date(actual_value)
659
- return false unless date
626
+ compare_date_result?(result_date, target_date, params)
660
627
 
661
- week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
662
- compare_numeric_result(week, expected_value)
628
+ when "add_minutes"
629
+ # Adds minutes to a date and compares
630
+ return false unless actual_value
663
631
 
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?
632
+ start_date = parse_date(actual_value)
633
+ return false unless start_date
670
634
 
671
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
672
- return false if timestamps.size < 2
635
+ params = parse_date_arithmetic_params(expected_value, :minutes)
636
+ return false unless params
673
637
 
674
- sorted_timestamps = timestamps.sort
675
- time_span = sorted_timestamps.last - sorted_timestamps.first
676
- return false if time_span <= 0
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
677
646
 
678
- rate = timestamps.size.to_f / time_span
679
- compare_rate_result(rate, expected_value)
647
+ compare_date_result?(result_date, target_date, params)
680
648
 
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?
649
+ when "subtract_minutes"
650
+ # Subtracts minutes from a date and compares
651
+ return false unless actual_value
685
652
 
686
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
687
- return false if timestamps.size < 2
653
+ start_date = parse_date(actual_value)
654
+ return false unless start_date
688
655
 
689
- sorted_timestamps = timestamps.sort
690
- time_span = sorted_timestamps.last - sorted_timestamps.first
691
- return false if time_span <= 0
656
+ params = parse_date_arithmetic_params(expected_value, :minutes)
657
+ return false unless params
692
658
 
693
- rate = (timestamps.size.to_f / time_span) * 60.0
694
- compare_rate_result(rate, expected_value)
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
695
667
 
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?
668
+ compare_date_result?(result_date, target_date, params)
700
669
 
701
- timestamps = actual_value.map { |ts| parse_date(ts) }.compact
702
- return false if timestamps.size < 2
670
+ # TIME COMPONENT EXTRACTION
671
+ when "hour_of_day"
672
+ # Extracts hour of day (0-23) and compares
673
+ return false unless actual_value
703
674
 
704
- sorted_timestamps = timestamps.sort
705
- time_span = sorted_timestamps.last - sorted_timestamps.first
706
- return false if time_span <= 0
675
+ date = parse_date(actual_value)
676
+ return false unless date
707
677
 
708
- rate = (timestamps.size.to_f / time_span) * 3600.0
709
- compare_rate_result(rate, expected_value)
678
+ hour = date.hour
679
+ compare_numeric_result(hour, expected_value)
710
680
 
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?
681
+ when "day_of_month"
682
+ # Extracts day of month (1-31) and compares
683
+ return false unless actual_value
717
684
 
718
- # OPTIMIZE: filter once and reuse
719
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
720
- return false if numeric_array.empty?
685
+ date = parse_date(actual_value)
686
+ return false unless date
721
687
 
722
- params = parse_moving_window_params(expected_value)
723
- return false unless params
688
+ day = date.day
689
+ compare_numeric_result(day, expected_value)
724
690
 
725
- window = [params[:window], numeric_array.size].min
726
- return false if window < 1
691
+ when "month"
692
+ # Extracts month (1-12) and compares
693
+ return false unless actual_value
727
694
 
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)
695
+ date = parse_date(actual_value)
696
+ return false unless date
732
697
 
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?
698
+ month = date.month
699
+ compare_numeric_result(month, expected_value)
737
700
 
738
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
739
- return false if numeric_array.empty?
701
+ when "year"
702
+ # Extracts year and compares
703
+ return false unless actual_value
740
704
 
741
- params = parse_moving_window_params(expected_value)
742
- return false unless params
705
+ date = parse_date(actual_value)
706
+ return false unless date
743
707
 
744
- window = [params[:window], numeric_array.size].min
745
- return false if window < 1
708
+ year = date.year
709
+ compare_numeric_result(year, expected_value)
746
710
 
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)
711
+ when "week_of_year"
712
+ # Extracts week of year (1-52) and compares
713
+ return false unless actual_value
751
714
 
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?
715
+ date = parse_date(actual_value)
716
+ return false unless date
756
717
 
757
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
758
- return false if numeric_array.empty?
718
+ week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
719
+ compare_numeric_result(week, expected_value)
759
720
 
760
- params = parse_moving_window_params(expected_value)
761
- return false unless 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?
762
727
 
763
- window = [params[:window], numeric_array.size].min
764
- return false if window < 1
728
+ timestamps = actual_value.map { |ts| parse_date(ts) }.compact
729
+ return false if timestamps.size < 2
765
730
 
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)
731
+ sorted_timestamps = timestamps.sort
732
+ time_span = sorted_timestamps.last - sorted_timestamps.first
733
+ return false if time_span <= 0
770
734
 
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?
735
+ rate = timestamps.size.to_f / time_span
736
+ compare_rate_result(rate, expected_value)
775
737
 
776
- numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
777
- return false if numeric_array.empty?
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?
778
742
 
779
- params = parse_moving_window_params(expected_value)
780
- return false unless params
743
+ timestamps = actual_value.map { |ts| parse_date(ts) }.compact
744
+ return false if timestamps.size < 2
781
745
 
782
- window = [params[:window], numeric_array.size].min
783
- return false if window < 1
746
+ sorted_timestamps = timestamps.sort
747
+ time_span = sorted_timestamps.last - sorted_timestamps.first
748
+ return false if time_span <= 0
784
749
 
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)
750
+ rate = (timestamps.size.to_f / time_span) * 60.0
751
+ compare_rate_result(rate, expected_value)
789
752
 
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)
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?
795
757
 
796
- params = parse_compound_interest_params(expected_value)
797
- return false unless params
758
+ timestamps = actual_value.map { |ts| parse_date(ts) }.compact
759
+ return false if timestamps.size < 2
798
760
 
799
- principal = actual_value
800
- rate = params[:rate]
801
- periods = params[:periods]
802
- result = principal * ((1 + (rate / periods))**periods)
761
+ sorted_timestamps = timestamps.sort
762
+ time_span = sorted_timestamps.last - sorted_timestamps.first
763
+ return false if time_span <= 0
803
764
 
804
- if params[:result]
805
- (result.round(2) == params[:result].round(2))
806
- else
807
- compare_financial_result(result, params)
808
- end
765
+ rate = (timestamps.size.to_f / time_span) * 3600.0
766
+ compare_rate_result(rate, expected_value)
809
767
 
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)
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?
814
774
 
815
- params = parse_present_value_params(expected_value)
816
- return false unless params
775
+ # OPTIMIZE: filter once and reuse
776
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
777
+ return false if numeric_array.empty?
817
778
 
818
- future_value = actual_value
819
- rate = params[:rate]
820
- periods = params[:periods]
821
- present_value = future_value / ((1 + rate)**periods)
779
+ params = parse_moving_window_params(expected_value)
780
+ return false unless params
822
781
 
823
- if params[:result]
824
- (present_value.round(2) == params[:result].round(2))
825
- else
826
- compare_financial_result(present_value, params)
827
- end
782
+ window = [params[:window], numeric_array.size].min
783
+ return false if window < 1
828
784
 
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)
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)
833
789
 
834
- params = parse_future_value_params(expected_value)
835
- return false unless params
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?
836
794
 
837
- present_value = actual_value
838
- rate = params[:rate]
839
- periods = params[:periods]
840
- future_value = present_value * ((1 + rate)**periods)
795
+ numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
796
+ return false if numeric_array.empty?
841
797
 
842
- if params[:result]
843
- (future_value.round(2) == params[:result].round(2))
844
- else
845
- compare_financial_result(future_value, params)
846
- end
798
+ params = parse_moving_window_params(expected_value)
799
+ return false unless params
847
800
 
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)
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)
852
827
 
853
- params = parse_payment_params(expected_value)
854
- return false unless params
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
855
874
 
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
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
949
924
 
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)
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
+ ))
988
1096
 
989
- else
990
- # Unknown operator - returns false (fail-safe)
991
- # Note: Validation should catch this earlier
992
- false
993
- end
1097
+ result
994
1098
  end
995
1099
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
996
1100
 
@@ -1019,15 +1123,54 @@ module DecisionAgent
1019
1123
  end
1020
1124
 
1021
1125
  # Checks if two values can be compared with <, >, <=, >=
1022
- # Only allows comparison between values of the same type
1126
+ # Allows comparison between numeric types (Float, Integer, etc.) or same string types
1023
1127
  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)
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
1027
1136
  end
1028
1137
 
1029
1138
  # Helper methods for new operators
1030
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
+
1031
1174
  # String operator validation
1032
1175
  def self.string_operator?(actual_value, expected_value)
1033
1176
  actual_value.is_a?(String) && expected_value.is_a?(String)