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.
- checksums.yaml +4 -4
- data/README.md +272 -7
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
- data/lib/decision_agent/dsl/schema_validator.rb +51 -13
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/index.html +49 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/server.rb +594 -23
- data/lib/decision_agent.rb +60 -2
- metadata +53 -73
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
504
|
-
|
|
446
|
+
when "after_date"
|
|
447
|
+
# Checks if date is after specified date
|
|
448
|
+
compare_dates(actual_value, expected_value, :>)
|
|
505
449
|
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
510
|
-
|
|
456
|
+
date = parse_date(actual_value)
|
|
457
|
+
return false unless date
|
|
511
458
|
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
|
|
468
|
+
date = parse_date(actual_value)
|
|
469
|
+
return false unless date
|
|
523
470
|
|
|
524
|
-
|
|
525
|
-
|
|
471
|
+
expected_day = normalize_day_of_week(expected_value)
|
|
472
|
+
return false unless expected_day
|
|
526
473
|
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
return false unless actual_value
|
|
482
|
+
start_date = parse_date(actual_value)
|
|
483
|
+
return false unless start_date
|
|
536
484
|
|
|
537
|
-
|
|
538
|
-
|
|
485
|
+
params = parse_duration_params(expected_value)
|
|
486
|
+
return false unless params
|
|
539
487
|
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
return false unless target_date
|
|
491
|
+
duration = (end_date - start_date).abs
|
|
492
|
+
compare_duration_result(duration, params)
|
|
546
493
|
|
|
547
|
-
|
|
494
|
+
when "duration_minutes"
|
|
495
|
+
# Calculates duration between two dates in minutes
|
|
496
|
+
return false unless actual_value
|
|
548
497
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
return false unless actual_value
|
|
498
|
+
start_date = parse_date(actual_value)
|
|
499
|
+
return false unless start_date
|
|
552
500
|
|
|
553
|
-
|
|
554
|
-
|
|
501
|
+
params = parse_duration_params(expected_value)
|
|
502
|
+
return false unless params
|
|
555
503
|
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
return false unless target_date
|
|
507
|
+
duration = ((end_date - start_date).abs / 60.0)
|
|
508
|
+
compare_duration_result(duration, params)
|
|
562
509
|
|
|
563
|
-
|
|
510
|
+
when "duration_hours"
|
|
511
|
+
# Calculates duration between two dates in hours
|
|
512
|
+
return false unless actual_value
|
|
564
513
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
return false unless actual_value
|
|
514
|
+
start_date = parse_date(actual_value)
|
|
515
|
+
return false unless start_date
|
|
568
516
|
|
|
569
|
-
|
|
570
|
-
|
|
517
|
+
params = parse_duration_params(expected_value)
|
|
518
|
+
return false unless params
|
|
571
519
|
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
return false unless target_date
|
|
523
|
+
duration = ((end_date - start_date).abs / 3600.0)
|
|
524
|
+
compare_duration_result(duration, params)
|
|
578
525
|
|
|
579
|
-
|
|
526
|
+
when "duration_days"
|
|
527
|
+
# Calculates duration between two dates in days
|
|
528
|
+
return false unless actual_value
|
|
580
529
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
return false unless actual_value
|
|
530
|
+
start_date = parse_date(actual_value)
|
|
531
|
+
return false unless start_date
|
|
584
532
|
|
|
585
|
-
|
|
586
|
-
|
|
533
|
+
params = parse_duration_params(expected_value)
|
|
534
|
+
return false unless params
|
|
587
535
|
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
return false unless actual_value
|
|
548
|
+
start_date = parse_date(actual_value)
|
|
549
|
+
return false unless start_date
|
|
600
550
|
|
|
601
|
-
|
|
602
|
-
|
|
551
|
+
params = parse_date_arithmetic_params(expected_value)
|
|
552
|
+
return false unless params
|
|
603
553
|
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
+
when "subtract_days"
|
|
566
|
+
# Subtracts days from a date and compares
|
|
567
|
+
return false unless actual_value
|
|
612
568
|
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
619
|
-
|
|
572
|
+
params = parse_date_arithmetic_params(expected_value)
|
|
573
|
+
return false unless params
|
|
620
574
|
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
586
|
+
when "add_hours"
|
|
587
|
+
# Adds hours to a date and compares
|
|
588
|
+
return false unless actual_value
|
|
630
589
|
|
|
631
|
-
|
|
632
|
-
|
|
590
|
+
start_date = parse_date(actual_value)
|
|
591
|
+
return false unless start_date
|
|
633
592
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
return false unless actual_value
|
|
593
|
+
params = parse_date_arithmetic_params(expected_value, :hours)
|
|
594
|
+
return false unless params
|
|
637
595
|
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
642
|
-
compare_numeric_result(month, expected_value)
|
|
605
|
+
compare_date_result?(result_date, target_date, params)
|
|
643
606
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
607
|
+
when "subtract_hours"
|
|
608
|
+
# Subtracts hours from a date and compares
|
|
609
|
+
return false unless actual_value
|
|
647
610
|
|
|
648
|
-
|
|
649
|
-
|
|
611
|
+
start_date = parse_date(actual_value)
|
|
612
|
+
return false unless start_date
|
|
650
613
|
|
|
651
|
-
|
|
652
|
-
|
|
614
|
+
params = parse_date_arithmetic_params(expected_value, :hours)
|
|
615
|
+
return false unless params
|
|
653
616
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
659
|
-
return false unless date
|
|
626
|
+
compare_date_result?(result_date, target_date, params)
|
|
660
627
|
|
|
661
|
-
|
|
662
|
-
|
|
628
|
+
when "add_minutes"
|
|
629
|
+
# Adds minutes to a date and compares
|
|
630
|
+
return false unless actual_value
|
|
663
631
|
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
672
|
-
|
|
635
|
+
params = parse_date_arithmetic_params(expected_value, :minutes)
|
|
636
|
+
return false unless params
|
|
673
637
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
679
|
-
compare_rate_result(rate, expected_value)
|
|
647
|
+
compare_date_result?(result_date, target_date, params)
|
|
680
648
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
687
|
-
|
|
653
|
+
start_date = parse_date(actual_value)
|
|
654
|
+
return false unless start_date
|
|
688
655
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return false if time_span <= 0
|
|
656
|
+
params = parse_date_arithmetic_params(expected_value, :minutes)
|
|
657
|
+
return false unless params
|
|
692
658
|
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
return false if time_span <= 0
|
|
675
|
+
date = parse_date(actual_value)
|
|
676
|
+
return false unless date
|
|
707
677
|
|
|
708
|
-
|
|
709
|
-
|
|
678
|
+
hour = date.hour
|
|
679
|
+
compare_numeric_result(hour, expected_value)
|
|
710
680
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
return false if numeric_array.empty?
|
|
685
|
+
date = parse_date(actual_value)
|
|
686
|
+
return false unless date
|
|
721
687
|
|
|
722
|
-
|
|
723
|
-
|
|
688
|
+
day = date.day
|
|
689
|
+
compare_numeric_result(day, expected_value)
|
|
724
690
|
|
|
725
|
-
|
|
726
|
-
|
|
691
|
+
when "month"
|
|
692
|
+
# Extracts month (1-12) and compares
|
|
693
|
+
return false unless actual_value
|
|
727
694
|
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
739
|
-
|
|
701
|
+
when "year"
|
|
702
|
+
# Extracts year and compares
|
|
703
|
+
return false unless actual_value
|
|
740
704
|
|
|
741
|
-
|
|
742
|
-
|
|
705
|
+
date = parse_date(actual_value)
|
|
706
|
+
return false unless date
|
|
743
707
|
|
|
744
|
-
|
|
745
|
-
|
|
708
|
+
year = date.year
|
|
709
|
+
compare_numeric_result(year, expected_value)
|
|
746
710
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
|
|
764
|
-
|
|
728
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
729
|
+
return false if timestamps.size < 2
|
|
765
730
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
772
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
780
|
-
|
|
743
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
744
|
+
return false if timestamps.size < 2
|
|
781
745
|
|
|
782
|
-
|
|
783
|
-
|
|
746
|
+
sorted_timestamps = timestamps.sort
|
|
747
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
748
|
+
return false if time_span <= 0
|
|
784
749
|
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
797
|
-
|
|
758
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
759
|
+
return false if timestamps.size < 2
|
|
798
760
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
|
|
843
|
-
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
854
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1126
|
+
# Allows comparison between numeric types (Float, Integer, etc.) or same string types
|
|
1023
1127
|
def self.comparable?(val1, val2)
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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)
|