decision_agent 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/decision_agent/dsl/condition_evaluator.rb +1129 -12
- data/lib/decision_agent/dsl/schema_validator.rb +10 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/public/app.js +119 -1
- data/lib/decision_agent/web/public/index.html +68 -0
- data/spec/advanced_operators_spec.rb +2147 -0
- data/spec/examples.txt +1633 -0
- metadata +2 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
1
3
|
module DecisionAgent
|
|
2
4
|
module Dsl
|
|
3
5
|
# Evaluates conditions in the rule DSL against a context
|
|
@@ -6,6 +8,7 @@ module DecisionAgent
|
|
|
6
8
|
# - Field conditions with various operators
|
|
7
9
|
# - Nested field access via dot notation (e.g., "user.profile.role")
|
|
8
10
|
# - Logical operators (all/any)
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
12
|
class ConditionEvaluator
|
|
10
13
|
# Thread-safe caches for performance optimization
|
|
11
14
|
@regex_cache = {}
|
|
@@ -14,9 +17,13 @@ module DecisionAgent
|
|
|
14
17
|
@path_cache_mutex = Mutex.new
|
|
15
18
|
@date_cache = {}
|
|
16
19
|
@date_cache_mutex = Mutex.new
|
|
20
|
+
@geospatial_cache = {}
|
|
21
|
+
@geospatial_cache_mutex = Mutex.new
|
|
22
|
+
@param_cache = {}
|
|
23
|
+
@param_cache_mutex = Mutex.new
|
|
17
24
|
|
|
18
25
|
class << self
|
|
19
|
-
attr_reader :regex_cache, :path_cache, :date_cache
|
|
26
|
+
attr_reader :regex_cache, :path_cache, :date_cache, :geospatial_cache, :param_cache
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
def self.evaluate(condition, context)
|
|
@@ -56,7 +63,8 @@ module DecisionAgent
|
|
|
56
63
|
op = condition["op"]
|
|
57
64
|
expected_value = condition["value"]
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
context_hash = context.to_h
|
|
67
|
+
actual_value = get_nested_value(context_hash, field)
|
|
60
68
|
|
|
61
69
|
case op
|
|
62
70
|
when "eq"
|
|
@@ -161,6 +169,245 @@ module DecisionAgent
|
|
|
161
169
|
|
|
162
170
|
(actual_value % params[:divisor]) == params[:remainder]
|
|
163
171
|
|
|
172
|
+
# MATHEMATICAL FUNCTIONS
|
|
173
|
+
# Trigonometric functions
|
|
174
|
+
when "sin"
|
|
175
|
+
# Checks if sin(field_value) equals expected_value
|
|
176
|
+
# expected_value is the expected result of sin(actual_value)
|
|
177
|
+
return false unless actual_value.is_a?(Numeric)
|
|
178
|
+
return false unless expected_value.is_a?(Numeric)
|
|
179
|
+
|
|
180
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
181
|
+
result = Math.sin(actual_value)
|
|
182
|
+
(result - expected_value).abs < 1e-10
|
|
183
|
+
|
|
184
|
+
when "cos"
|
|
185
|
+
# Checks if cos(field_value) equals expected_value
|
|
186
|
+
# expected_value is the expected result of cos(actual_value)
|
|
187
|
+
return false unless actual_value.is_a?(Numeric)
|
|
188
|
+
return false unless expected_value.is_a?(Numeric)
|
|
189
|
+
|
|
190
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
191
|
+
result = Math.cos(actual_value)
|
|
192
|
+
(result - expected_value).abs < 1e-10
|
|
193
|
+
|
|
194
|
+
when "tan"
|
|
195
|
+
# Checks if tan(field_value) equals expected_value
|
|
196
|
+
# expected_value is the expected result of tan(actual_value)
|
|
197
|
+
return false unless actual_value.is_a?(Numeric)
|
|
198
|
+
return false unless expected_value.is_a?(Numeric)
|
|
199
|
+
|
|
200
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
201
|
+
result = Math.tan(actual_value)
|
|
202
|
+
(result - expected_value).abs < 1e-10
|
|
203
|
+
|
|
204
|
+
# Exponential and logarithmic functions
|
|
205
|
+
when "sqrt"
|
|
206
|
+
# Checks if sqrt(field_value) equals expected_value
|
|
207
|
+
# expected_value is the expected result of sqrt(actual_value)
|
|
208
|
+
return false unless actual_value.is_a?(Numeric)
|
|
209
|
+
return false unless expected_value.is_a?(Numeric)
|
|
210
|
+
return false if actual_value.negative? # sqrt of negative number is invalid
|
|
211
|
+
|
|
212
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
213
|
+
result = Math.sqrt(actual_value)
|
|
214
|
+
(result - expected_value).abs < 1e-10
|
|
215
|
+
|
|
216
|
+
when "power"
|
|
217
|
+
# Checks if power(field_value, exponent) equals result
|
|
218
|
+
# expected_value should be [exponent, result] or {exponent: x, result: y}
|
|
219
|
+
return false unless actual_value.is_a?(Numeric)
|
|
220
|
+
|
|
221
|
+
params = parse_power_params(expected_value)
|
|
222
|
+
return false unless params
|
|
223
|
+
|
|
224
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
225
|
+
result = actual_value**params[:exponent]
|
|
226
|
+
(result - params[:result]).abs < 1e-10
|
|
227
|
+
|
|
228
|
+
when "exp"
|
|
229
|
+
# Checks if exp(field_value) equals expected_value
|
|
230
|
+
# expected_value is the expected result of exp(actual_value) (e^actual_value)
|
|
231
|
+
return false unless actual_value.is_a?(Numeric)
|
|
232
|
+
return false unless expected_value.is_a?(Numeric)
|
|
233
|
+
|
|
234
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
235
|
+
result = Math.exp(actual_value)
|
|
236
|
+
(result - expected_value).abs < 1e-10
|
|
237
|
+
|
|
238
|
+
when "log"
|
|
239
|
+
# Checks if log(field_value) equals expected_value
|
|
240
|
+
# expected_value is the expected result of log(actual_value) (natural logarithm)
|
|
241
|
+
return false unless actual_value.is_a?(Numeric)
|
|
242
|
+
return false unless expected_value.is_a?(Numeric)
|
|
243
|
+
return false if actual_value <= 0 # log of non-positive number is invalid
|
|
244
|
+
|
|
245
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
246
|
+
result = Math.log(actual_value)
|
|
247
|
+
(result - expected_value).abs < 1e-10
|
|
248
|
+
|
|
249
|
+
# Rounding and absolute value functions
|
|
250
|
+
when "round"
|
|
251
|
+
# Checks if round(field_value) equals expected_value
|
|
252
|
+
# expected_value is the expected result of round(actual_value)
|
|
253
|
+
return false unless actual_value.is_a?(Numeric)
|
|
254
|
+
return false unless expected_value.is_a?(Numeric)
|
|
255
|
+
|
|
256
|
+
actual_value.round == expected_value
|
|
257
|
+
|
|
258
|
+
when "floor"
|
|
259
|
+
# Checks if floor(field_value) equals expected_value
|
|
260
|
+
# expected_value is the expected result of floor(actual_value)
|
|
261
|
+
return false unless actual_value.is_a?(Numeric)
|
|
262
|
+
return false unless expected_value.is_a?(Numeric)
|
|
263
|
+
|
|
264
|
+
actual_value.floor == expected_value
|
|
265
|
+
|
|
266
|
+
when "ceil"
|
|
267
|
+
# Checks if ceil(field_value) equals expected_value
|
|
268
|
+
# expected_value is the expected result of ceil(actual_value)
|
|
269
|
+
return false unless actual_value.is_a?(Numeric)
|
|
270
|
+
return false unless expected_value.is_a?(Numeric)
|
|
271
|
+
|
|
272
|
+
actual_value.ceil == expected_value
|
|
273
|
+
|
|
274
|
+
when "abs"
|
|
275
|
+
# Checks if abs(field_value) equals expected_value
|
|
276
|
+
# expected_value is the expected result of abs(actual_value)
|
|
277
|
+
return false unless actual_value.is_a?(Numeric)
|
|
278
|
+
return false unless expected_value.is_a?(Numeric)
|
|
279
|
+
|
|
280
|
+
actual_value.abs == expected_value
|
|
281
|
+
|
|
282
|
+
# Aggregation functions
|
|
283
|
+
when "min"
|
|
284
|
+
# Checks if min(field_value) equals expected_value
|
|
285
|
+
# field_value should be an array, expected_value is the minimum value
|
|
286
|
+
return false unless actual_value.is_a?(Array)
|
|
287
|
+
return false if actual_value.empty?
|
|
288
|
+
return false unless expected_value.is_a?(Numeric)
|
|
289
|
+
|
|
290
|
+
actual_value.min == expected_value
|
|
291
|
+
|
|
292
|
+
when "max"
|
|
293
|
+
# Checks if max(field_value) equals expected_value
|
|
294
|
+
# field_value should be an array, expected_value is the maximum value
|
|
295
|
+
return false unless actual_value.is_a?(Array)
|
|
296
|
+
return false if actual_value.empty?
|
|
297
|
+
return false unless expected_value.is_a?(Numeric)
|
|
298
|
+
|
|
299
|
+
actual_value.max == expected_value
|
|
300
|
+
|
|
301
|
+
# STATISTICAL AGGREGATIONS
|
|
302
|
+
when "sum"
|
|
303
|
+
# Checks if sum of numeric array equals expected_value
|
|
304
|
+
# expected_value can be numeric or hash with comparison operators
|
|
305
|
+
return false unless actual_value.is_a?(Array)
|
|
306
|
+
return false if actual_value.empty?
|
|
307
|
+
|
|
308
|
+
# OPTIMIZE: calculate sum in single pass, filtering as we go
|
|
309
|
+
sum_value = 0.0
|
|
310
|
+
found_numeric = false
|
|
311
|
+
actual_value.each do |v|
|
|
312
|
+
if v.is_a?(Numeric)
|
|
313
|
+
sum_value += v
|
|
314
|
+
found_numeric = true
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
return false unless found_numeric
|
|
318
|
+
|
|
319
|
+
compare_aggregation_result(sum_value, expected_value)
|
|
320
|
+
|
|
321
|
+
when "average", "mean"
|
|
322
|
+
# Checks if average of numeric array equals expected_value
|
|
323
|
+
return false unless actual_value.is_a?(Array)
|
|
324
|
+
return false if actual_value.empty?
|
|
325
|
+
|
|
326
|
+
# OPTIMIZE: calculate sum and count in single pass
|
|
327
|
+
sum_value = 0.0
|
|
328
|
+
count = 0
|
|
329
|
+
actual_value.each do |v|
|
|
330
|
+
if v.is_a?(Numeric)
|
|
331
|
+
sum_value += v
|
|
332
|
+
count += 1
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
return false if count.zero?
|
|
336
|
+
|
|
337
|
+
avg_value = sum_value / count
|
|
338
|
+
compare_aggregation_result(avg_value, expected_value)
|
|
339
|
+
|
|
340
|
+
when "median"
|
|
341
|
+
# Checks if median of numeric array equals expected_value
|
|
342
|
+
return false unless actual_value.is_a?(Array)
|
|
343
|
+
return false if actual_value.empty?
|
|
344
|
+
|
|
345
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
|
|
346
|
+
return false if numeric_array.empty?
|
|
347
|
+
|
|
348
|
+
median_value = if numeric_array.size.odd?
|
|
349
|
+
numeric_array[numeric_array.size / 2]
|
|
350
|
+
else
|
|
351
|
+
(numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
|
|
352
|
+
end
|
|
353
|
+
compare_aggregation_result(median_value, expected_value)
|
|
354
|
+
|
|
355
|
+
when "stddev", "standard_deviation"
|
|
356
|
+
# Checks if standard deviation of numeric array equals expected_value
|
|
357
|
+
return false unless actual_value.is_a?(Array)
|
|
358
|
+
return false if actual_value.size < 2
|
|
359
|
+
|
|
360
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
361
|
+
return false if numeric_array.size < 2
|
|
362
|
+
|
|
363
|
+
mean = numeric_array.sum.to_f / numeric_array.size
|
|
364
|
+
variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
|
|
365
|
+
stddev_value = Math.sqrt(variance)
|
|
366
|
+
compare_aggregation_result(stddev_value, expected_value)
|
|
367
|
+
|
|
368
|
+
when "variance"
|
|
369
|
+
# Checks if variance of numeric array equals expected_value
|
|
370
|
+
return false unless actual_value.is_a?(Array)
|
|
371
|
+
return false if actual_value.size < 2
|
|
372
|
+
|
|
373
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
374
|
+
return false if numeric_array.size < 2
|
|
375
|
+
|
|
376
|
+
mean = numeric_array.sum.to_f / numeric_array.size
|
|
377
|
+
variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
|
|
378
|
+
compare_aggregation_result(variance_value, expected_value)
|
|
379
|
+
|
|
380
|
+
when "percentile"
|
|
381
|
+
# Checks if Nth percentile of numeric array meets threshold
|
|
382
|
+
# expected_value: {percentile: 95, threshold: 200} or {percentile: 95, gt: 200, lt: 500}
|
|
383
|
+
return false unless actual_value.is_a?(Array)
|
|
384
|
+
return false if actual_value.empty?
|
|
385
|
+
|
|
386
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
|
|
387
|
+
return false if numeric_array.empty?
|
|
388
|
+
|
|
389
|
+
params = parse_percentile_params(expected_value)
|
|
390
|
+
return false unless params
|
|
391
|
+
|
|
392
|
+
percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
|
|
393
|
+
percentile_value = if percentile_index == percentile_index.to_i
|
|
394
|
+
numeric_array[percentile_index.to_i]
|
|
395
|
+
else
|
|
396
|
+
lower = numeric_array[percentile_index.floor]
|
|
397
|
+
upper = numeric_array[percentile_index.ceil]
|
|
398
|
+
lower + ((upper - lower) * (percentile_index - percentile_index.floor))
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
compare_percentile_result(percentile_value, params)
|
|
402
|
+
|
|
403
|
+
when "count"
|
|
404
|
+
# Checks if count of array elements meets threshold
|
|
405
|
+
# expected_value can be numeric or hash with comparison operators
|
|
406
|
+
return false unless actual_value.is_a?(Array)
|
|
407
|
+
|
|
408
|
+
count_value = actual_value.size
|
|
409
|
+
compare_aggregation_result(count_value, expected_value)
|
|
410
|
+
|
|
164
411
|
# DATE/TIME OPERATORS
|
|
165
412
|
when "before_date"
|
|
166
413
|
# Checks if date is before specified date
|
|
@@ -196,38 +443,517 @@ module DecisionAgent
|
|
|
196
443
|
|
|
197
444
|
date.wday == expected_day
|
|
198
445
|
|
|
446
|
+
# DURATION CALCULATIONS
|
|
447
|
+
when "duration_seconds"
|
|
448
|
+
# Calculates duration between two dates in seconds
|
|
449
|
+
# expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
|
|
450
|
+
return false unless actual_value
|
|
451
|
+
|
|
452
|
+
start_date = parse_date(actual_value)
|
|
453
|
+
return false unless start_date
|
|
454
|
+
|
|
455
|
+
params = parse_duration_params(expected_value)
|
|
456
|
+
return false unless params
|
|
457
|
+
|
|
458
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
459
|
+
return false unless end_date
|
|
460
|
+
|
|
461
|
+
duration = (end_date - start_date).abs
|
|
462
|
+
compare_duration_result(duration, params)
|
|
463
|
+
|
|
464
|
+
when "duration_minutes"
|
|
465
|
+
# Calculates duration between two dates in minutes
|
|
466
|
+
return false unless actual_value
|
|
467
|
+
|
|
468
|
+
start_date = parse_date(actual_value)
|
|
469
|
+
return false unless start_date
|
|
470
|
+
|
|
471
|
+
params = parse_duration_params(expected_value)
|
|
472
|
+
return false unless params
|
|
473
|
+
|
|
474
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
475
|
+
return false unless end_date
|
|
476
|
+
|
|
477
|
+
duration = ((end_date - start_date).abs / 60.0)
|
|
478
|
+
compare_duration_result(duration, params)
|
|
479
|
+
|
|
480
|
+
when "duration_hours"
|
|
481
|
+
# Calculates duration between two dates in hours
|
|
482
|
+
return false unless actual_value
|
|
483
|
+
|
|
484
|
+
start_date = parse_date(actual_value)
|
|
485
|
+
return false unless start_date
|
|
486
|
+
|
|
487
|
+
params = parse_duration_params(expected_value)
|
|
488
|
+
return false unless params
|
|
489
|
+
|
|
490
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
491
|
+
return false unless end_date
|
|
492
|
+
|
|
493
|
+
duration = ((end_date - start_date).abs / 3600.0)
|
|
494
|
+
compare_duration_result(duration, params)
|
|
495
|
+
|
|
496
|
+
when "duration_days"
|
|
497
|
+
# Calculates duration between two dates in days
|
|
498
|
+
return false unless actual_value
|
|
499
|
+
|
|
500
|
+
start_date = parse_date(actual_value)
|
|
501
|
+
return false unless start_date
|
|
502
|
+
|
|
503
|
+
params = parse_duration_params(expected_value)
|
|
504
|
+
return false unless params
|
|
505
|
+
|
|
506
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
507
|
+
return false unless end_date
|
|
508
|
+
|
|
509
|
+
duration = ((end_date - start_date).abs / 86_400.0)
|
|
510
|
+
compare_duration_result(duration, params)
|
|
511
|
+
|
|
512
|
+
# DATE ARITHMETIC
|
|
513
|
+
when "add_days"
|
|
514
|
+
# Adds days to a date and compares
|
|
515
|
+
# expected_value: {days: 7, compare: "lt", target: "now"} or {days: 7, eq: target_date}
|
|
516
|
+
return false unless actual_value
|
|
517
|
+
|
|
518
|
+
start_date = parse_date(actual_value)
|
|
519
|
+
return false unless start_date
|
|
520
|
+
|
|
521
|
+
params = parse_date_arithmetic_params(expected_value)
|
|
522
|
+
return false unless params
|
|
523
|
+
|
|
524
|
+
result_date = start_date + (params[:days] * 86_400)
|
|
525
|
+
target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
|
|
526
|
+
return false unless target_date
|
|
527
|
+
|
|
528
|
+
compare_date_result?(result_date, target_date, params)
|
|
529
|
+
|
|
530
|
+
when "subtract_days"
|
|
531
|
+
# Subtracts days from a date and compares
|
|
532
|
+
return false unless actual_value
|
|
533
|
+
|
|
534
|
+
start_date = parse_date(actual_value)
|
|
535
|
+
return false unless start_date
|
|
536
|
+
|
|
537
|
+
params = parse_date_arithmetic_params(expected_value)
|
|
538
|
+
return false unless params
|
|
539
|
+
|
|
540
|
+
result_date = start_date - (params[:days] * 86_400)
|
|
541
|
+
target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
|
|
542
|
+
return false unless target_date
|
|
543
|
+
|
|
544
|
+
compare_date_result?(result_date, target_date, params)
|
|
545
|
+
|
|
546
|
+
when "add_hours"
|
|
547
|
+
# Adds hours to a date and compares
|
|
548
|
+
return false unless actual_value
|
|
549
|
+
|
|
550
|
+
start_date = parse_date(actual_value)
|
|
551
|
+
return false unless start_date
|
|
552
|
+
|
|
553
|
+
params = parse_date_arithmetic_params(expected_value, :hours)
|
|
554
|
+
return false unless params
|
|
555
|
+
|
|
556
|
+
result_date = start_date + (params[:hours] * 3600)
|
|
557
|
+
target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
|
|
558
|
+
return false unless target_date
|
|
559
|
+
|
|
560
|
+
compare_date_result?(result_date, target_date, params)
|
|
561
|
+
|
|
562
|
+
when "subtract_hours"
|
|
563
|
+
# Subtracts hours from a date and compares
|
|
564
|
+
return false unless actual_value
|
|
565
|
+
|
|
566
|
+
start_date = parse_date(actual_value)
|
|
567
|
+
return false unless start_date
|
|
568
|
+
|
|
569
|
+
params = parse_date_arithmetic_params(expected_value, :hours)
|
|
570
|
+
return false unless params
|
|
571
|
+
|
|
572
|
+
result_date = start_date - (params[:hours] * 3600)
|
|
573
|
+
target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
|
|
574
|
+
return false unless target_date
|
|
575
|
+
|
|
576
|
+
compare_date_result?(result_date, target_date, params)
|
|
577
|
+
|
|
578
|
+
when "add_minutes"
|
|
579
|
+
# Adds minutes to a date and compares
|
|
580
|
+
return false unless actual_value
|
|
581
|
+
|
|
582
|
+
start_date = parse_date(actual_value)
|
|
583
|
+
return false unless start_date
|
|
584
|
+
|
|
585
|
+
params = parse_date_arithmetic_params(expected_value, :minutes)
|
|
586
|
+
return false unless params
|
|
587
|
+
|
|
588
|
+
result_date = start_date + (params[:minutes] * 60)
|
|
589
|
+
target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
|
|
590
|
+
return false unless target_date
|
|
591
|
+
|
|
592
|
+
compare_date_result?(result_date, target_date, params)
|
|
593
|
+
|
|
594
|
+
when "subtract_minutes"
|
|
595
|
+
# Subtracts minutes from a date and compares
|
|
596
|
+
return false unless actual_value
|
|
597
|
+
|
|
598
|
+
start_date = parse_date(actual_value)
|
|
599
|
+
return false unless start_date
|
|
600
|
+
|
|
601
|
+
params = parse_date_arithmetic_params(expected_value, :minutes)
|
|
602
|
+
return false unless params
|
|
603
|
+
|
|
604
|
+
result_date = start_date - (params[:minutes] * 60)
|
|
605
|
+
target_date = params[:target] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:target]))
|
|
606
|
+
return false unless target_date
|
|
607
|
+
|
|
608
|
+
compare_date_result?(result_date, target_date, params)
|
|
609
|
+
|
|
610
|
+
# TIME COMPONENT EXTRACTION
|
|
611
|
+
when "hour_of_day"
|
|
612
|
+
# Extracts hour of day (0-23) and compares
|
|
613
|
+
return false unless actual_value
|
|
614
|
+
|
|
615
|
+
date = parse_date(actual_value)
|
|
616
|
+
return false unless date
|
|
617
|
+
|
|
618
|
+
hour = date.hour
|
|
619
|
+
compare_numeric_result(hour, expected_value)
|
|
620
|
+
|
|
621
|
+
when "day_of_month"
|
|
622
|
+
# Extracts day of month (1-31) and compares
|
|
623
|
+
return false unless actual_value
|
|
624
|
+
|
|
625
|
+
date = parse_date(actual_value)
|
|
626
|
+
return false unless date
|
|
627
|
+
|
|
628
|
+
day = date.day
|
|
629
|
+
compare_numeric_result(day, expected_value)
|
|
630
|
+
|
|
631
|
+
when "month"
|
|
632
|
+
# Extracts month (1-12) and compares
|
|
633
|
+
return false unless actual_value
|
|
634
|
+
|
|
635
|
+
date = parse_date(actual_value)
|
|
636
|
+
return false unless date
|
|
637
|
+
|
|
638
|
+
month = date.month
|
|
639
|
+
compare_numeric_result(month, expected_value)
|
|
640
|
+
|
|
641
|
+
when "year"
|
|
642
|
+
# Extracts year and compares
|
|
643
|
+
return false unless actual_value
|
|
644
|
+
|
|
645
|
+
date = parse_date(actual_value)
|
|
646
|
+
return false unless date
|
|
647
|
+
|
|
648
|
+
year = date.year
|
|
649
|
+
compare_numeric_result(year, expected_value)
|
|
650
|
+
|
|
651
|
+
when "week_of_year"
|
|
652
|
+
# Extracts week of year (1-52) and compares
|
|
653
|
+
return false unless actual_value
|
|
654
|
+
|
|
655
|
+
date = parse_date(actual_value)
|
|
656
|
+
return false unless date
|
|
657
|
+
|
|
658
|
+
week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
|
|
659
|
+
compare_numeric_result(week, expected_value)
|
|
660
|
+
|
|
661
|
+
# RATE CALCULATIONS
|
|
662
|
+
when "rate_per_second"
|
|
663
|
+
# Calculates rate per second from array of timestamps
|
|
664
|
+
# expected_value: {max: 10} or {min: 5, max: 100}
|
|
665
|
+
return false unless actual_value.is_a?(Array)
|
|
666
|
+
return false if actual_value.empty?
|
|
667
|
+
|
|
668
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
669
|
+
return false if timestamps.size < 2
|
|
670
|
+
|
|
671
|
+
sorted_timestamps = timestamps.sort
|
|
672
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
673
|
+
return false if time_span <= 0
|
|
674
|
+
|
|
675
|
+
rate = timestamps.size.to_f / time_span
|
|
676
|
+
compare_rate_result(rate, expected_value)
|
|
677
|
+
|
|
678
|
+
when "rate_per_minute"
|
|
679
|
+
# Calculates rate per minute from array of timestamps
|
|
680
|
+
return false unless actual_value.is_a?(Array)
|
|
681
|
+
return false if actual_value.empty?
|
|
682
|
+
|
|
683
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
684
|
+
return false if timestamps.size < 2
|
|
685
|
+
|
|
686
|
+
sorted_timestamps = timestamps.sort
|
|
687
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
688
|
+
return false if time_span <= 0
|
|
689
|
+
|
|
690
|
+
rate = (timestamps.size.to_f / time_span) * 60.0
|
|
691
|
+
compare_rate_result(rate, expected_value)
|
|
692
|
+
|
|
693
|
+
when "rate_per_hour"
|
|
694
|
+
# Calculates rate per hour from array of timestamps
|
|
695
|
+
return false unless actual_value.is_a?(Array)
|
|
696
|
+
return false if actual_value.empty?
|
|
697
|
+
|
|
698
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
699
|
+
return false if timestamps.size < 2
|
|
700
|
+
|
|
701
|
+
sorted_timestamps = timestamps.sort
|
|
702
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
703
|
+
return false if time_span <= 0
|
|
704
|
+
|
|
705
|
+
rate = (timestamps.size.to_f / time_span) * 3600.0
|
|
706
|
+
compare_rate_result(rate, expected_value)
|
|
707
|
+
|
|
708
|
+
# MOVING WINDOW CALCULATIONS
|
|
709
|
+
when "moving_average"
|
|
710
|
+
# Calculates moving average over window
|
|
711
|
+
# expected_value: {window: 5, threshold: 100} or {window: 5, gt: 100}
|
|
712
|
+
return false unless actual_value.is_a?(Array)
|
|
713
|
+
return false if actual_value.empty?
|
|
714
|
+
|
|
715
|
+
# OPTIMIZE: filter once and reuse
|
|
716
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
717
|
+
return false if numeric_array.empty?
|
|
718
|
+
|
|
719
|
+
params = parse_moving_window_params(expected_value)
|
|
720
|
+
return false unless params
|
|
721
|
+
|
|
722
|
+
window = [params[:window], numeric_array.size].min
|
|
723
|
+
return false if window < 1
|
|
724
|
+
|
|
725
|
+
# OPTIMIZE: use slice instead of last for better performance
|
|
726
|
+
window_array = numeric_array.slice(-window, window)
|
|
727
|
+
moving_avg = window_array.sum.to_f / window
|
|
728
|
+
compare_moving_window_result(moving_avg, params)
|
|
729
|
+
|
|
730
|
+
when "moving_sum"
|
|
731
|
+
# Calculates moving sum over window
|
|
732
|
+
return false unless actual_value.is_a?(Array)
|
|
733
|
+
return false if actual_value.empty?
|
|
734
|
+
|
|
735
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
736
|
+
return false if numeric_array.empty?
|
|
737
|
+
|
|
738
|
+
params = parse_moving_window_params(expected_value)
|
|
739
|
+
return false unless params
|
|
740
|
+
|
|
741
|
+
window = [params[:window], numeric_array.size].min
|
|
742
|
+
return false if window < 1
|
|
743
|
+
|
|
744
|
+
# OPTIMIZE: use slice instead of last
|
|
745
|
+
window_array = numeric_array.slice(-window, window)
|
|
746
|
+
moving_sum = window_array.sum
|
|
747
|
+
compare_moving_window_result(moving_sum, params)
|
|
748
|
+
|
|
749
|
+
when "moving_max"
|
|
750
|
+
# Calculates moving max over window
|
|
751
|
+
return false unless actual_value.is_a?(Array)
|
|
752
|
+
return false if actual_value.empty?
|
|
753
|
+
|
|
754
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
755
|
+
return false if numeric_array.empty?
|
|
756
|
+
|
|
757
|
+
params = parse_moving_window_params(expected_value)
|
|
758
|
+
return false unless params
|
|
759
|
+
|
|
760
|
+
window = [params[:window], numeric_array.size].min
|
|
761
|
+
return false if window < 1
|
|
762
|
+
|
|
763
|
+
# OPTIMIZE: use slice instead of last, iterate directly for max
|
|
764
|
+
window_array = numeric_array.slice(-window, window)
|
|
765
|
+
moving_max = window_array.max
|
|
766
|
+
compare_moving_window_result(moving_max, params)
|
|
767
|
+
|
|
768
|
+
when "moving_min"
|
|
769
|
+
# Calculates moving min over window
|
|
770
|
+
return false unless actual_value.is_a?(Array)
|
|
771
|
+
return false if actual_value.empty?
|
|
772
|
+
|
|
773
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
774
|
+
return false if numeric_array.empty?
|
|
775
|
+
|
|
776
|
+
params = parse_moving_window_params(expected_value)
|
|
777
|
+
return false unless params
|
|
778
|
+
|
|
779
|
+
window = [params[:window], numeric_array.size].min
|
|
780
|
+
return false if window < 1
|
|
781
|
+
|
|
782
|
+
# OPTIMIZE: use slice instead of last
|
|
783
|
+
window_array = numeric_array.slice(-window, window)
|
|
784
|
+
moving_min = window_array.min
|
|
785
|
+
compare_moving_window_result(moving_min, params)
|
|
786
|
+
|
|
787
|
+
# FINANCIAL CALCULATIONS
|
|
788
|
+
when "compound_interest"
|
|
789
|
+
# Calculates compound interest: A = P(1 + r/n)^(nt)
|
|
790
|
+
# expected_value: {rate: 0.05, periods: 12, result: 1050} or {rate: 0.05, periods: 12, compare: "gt", threshold: 1000}
|
|
791
|
+
return false unless actual_value.is_a?(Numeric)
|
|
792
|
+
|
|
793
|
+
params = parse_compound_interest_params(expected_value)
|
|
794
|
+
return false unless params
|
|
795
|
+
|
|
796
|
+
principal = actual_value
|
|
797
|
+
rate = params[:rate]
|
|
798
|
+
periods = params[:periods]
|
|
799
|
+
result = principal * ((1 + (rate / periods))**periods)
|
|
800
|
+
|
|
801
|
+
if params[:result]
|
|
802
|
+
(result.round(2) == params[:result].round(2))
|
|
803
|
+
else
|
|
804
|
+
compare_financial_result(result, params)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
when "present_value"
|
|
808
|
+
# Calculates present value: PV = FV / (1 + r)^n
|
|
809
|
+
# expected_value: {rate: 0.05, periods: 10, result: 613.91}
|
|
810
|
+
return false unless actual_value.is_a?(Numeric)
|
|
811
|
+
|
|
812
|
+
params = parse_present_value_params(expected_value)
|
|
813
|
+
return false unless params
|
|
814
|
+
|
|
815
|
+
future_value = actual_value
|
|
816
|
+
rate = params[:rate]
|
|
817
|
+
periods = params[:periods]
|
|
818
|
+
present_value = future_value / ((1 + rate)**periods)
|
|
819
|
+
|
|
820
|
+
if params[:result]
|
|
821
|
+
(present_value.round(2) == params[:result].round(2))
|
|
822
|
+
else
|
|
823
|
+
compare_financial_result(present_value, params)
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
when "future_value"
|
|
827
|
+
# Calculates future value: FV = PV * (1 + r)^n
|
|
828
|
+
# expected_value: {rate: 0.05, periods: 10, result: 1628.89}
|
|
829
|
+
return false unless actual_value.is_a?(Numeric)
|
|
830
|
+
|
|
831
|
+
params = parse_future_value_params(expected_value)
|
|
832
|
+
return false unless params
|
|
833
|
+
|
|
834
|
+
present_value = actual_value
|
|
835
|
+
rate = params[:rate]
|
|
836
|
+
periods = params[:periods]
|
|
837
|
+
future_value = present_value * ((1 + rate)**periods)
|
|
838
|
+
|
|
839
|
+
if params[:result]
|
|
840
|
+
(future_value.round(2) == params[:result].round(2))
|
|
841
|
+
else
|
|
842
|
+
compare_financial_result(future_value, params)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
when "payment"
|
|
846
|
+
# Calculates loan payment: PMT = P * [r(1+r)^n] / [(1+r)^n - 1]
|
|
847
|
+
# expected_value: {rate: 0.05, periods: 12, result: 100}
|
|
848
|
+
return false unless actual_value.is_a?(Numeric)
|
|
849
|
+
|
|
850
|
+
params = parse_payment_params(expected_value)
|
|
851
|
+
return false unless params
|
|
852
|
+
|
|
853
|
+
principal = actual_value
|
|
854
|
+
rate = params[:rate]
|
|
855
|
+
periods = params[:periods]
|
|
856
|
+
|
|
857
|
+
return false if rate <= 0 || periods <= 0
|
|
858
|
+
|
|
859
|
+
payment = if rate.zero?
|
|
860
|
+
principal / periods
|
|
861
|
+
else
|
|
862
|
+
principal * (rate * ((1 + rate)**periods)) / (((1 + rate)**periods) - 1)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
if params[:result]
|
|
866
|
+
(payment.round(2) == params[:result].round(2))
|
|
867
|
+
else
|
|
868
|
+
compare_financial_result(payment, params)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# STRING AGGREGATIONS
|
|
872
|
+
when "join"
|
|
873
|
+
# Joins array of strings with separator
|
|
874
|
+
# expected_value: {separator: ",", result: "a,b,c"} or {separator: ",", contains: "a"}
|
|
875
|
+
return false unless actual_value.is_a?(Array)
|
|
876
|
+
return false if actual_value.empty?
|
|
877
|
+
|
|
878
|
+
string_array = actual_value.map(&:to_s)
|
|
879
|
+
params = parse_join_params(expected_value)
|
|
880
|
+
return false unless params
|
|
881
|
+
|
|
882
|
+
joined = string_array.join(params[:separator])
|
|
883
|
+
|
|
884
|
+
if params[:result]
|
|
885
|
+
joined == params[:result]
|
|
886
|
+
elsif params[:contains]
|
|
887
|
+
joined.include?(params[:contains])
|
|
888
|
+
else
|
|
889
|
+
false
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
when "length"
|
|
893
|
+
# Gets length of string or array
|
|
894
|
+
# expected_value: {max: 500} or {min: 10, max: 100}
|
|
895
|
+
return false if actual_value.nil?
|
|
896
|
+
|
|
897
|
+
length_value = if actual_value.is_a?(String) || actual_value.is_a?(Array)
|
|
898
|
+
actual_value.length
|
|
899
|
+
else
|
|
900
|
+
return false
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
compare_length_result(length_value, expected_value)
|
|
904
|
+
|
|
199
905
|
# COLLECTION OPERATORS
|
|
200
906
|
when "contains_all"
|
|
201
907
|
# Checks if array contains all specified elements
|
|
202
908
|
# expected_value should be an array
|
|
203
909
|
return false unless actual_value.is_a?(Array)
|
|
204
910
|
return false unless expected_value.is_a?(Array)
|
|
911
|
+
return true if expected_value.empty?
|
|
205
912
|
|
|
206
|
-
|
|
913
|
+
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
914
|
+
# For small arrays, Set overhead is minimal; for large arrays, huge win
|
|
915
|
+
actual_set = actual_value.to_set
|
|
916
|
+
expected_value.all? { |item| actual_set.include?(item) }
|
|
207
917
|
|
|
208
918
|
when "contains_any"
|
|
209
919
|
# Checks if array contains any of the specified elements
|
|
210
920
|
# expected_value should be an array
|
|
211
921
|
return false unless actual_value.is_a?(Array)
|
|
212
922
|
return false unless expected_value.is_a?(Array)
|
|
923
|
+
return false if expected_value.empty?
|
|
213
924
|
|
|
214
|
-
|
|
925
|
+
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
926
|
+
# Early exit on first match for better performance
|
|
927
|
+
actual_set = actual_value.to_set
|
|
928
|
+
expected_value.any? { |item| actual_set.include?(item) }
|
|
215
929
|
|
|
216
930
|
when "intersects"
|
|
217
931
|
# Checks if two arrays have any common elements
|
|
218
932
|
# expected_value should be an array
|
|
219
933
|
return false unless actual_value.is_a?(Array)
|
|
220
934
|
return false unless expected_value.is_a?(Array)
|
|
221
|
-
|
|
222
|
-
|
|
935
|
+
return false if actual_value.empty? || expected_value.empty?
|
|
936
|
+
|
|
937
|
+
# OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
|
|
938
|
+
# Check smaller array against larger set for better performance
|
|
939
|
+
if actual_value.size <= expected_value.size
|
|
940
|
+
expected_set = expected_value.to_set
|
|
941
|
+
actual_value.any? { |item| expected_set.include?(item) }
|
|
942
|
+
else
|
|
943
|
+
actual_set = actual_value.to_set
|
|
944
|
+
expected_value.any? { |item| actual_set.include?(item) }
|
|
945
|
+
end
|
|
223
946
|
|
|
224
947
|
when "subset_of"
|
|
225
948
|
# Checks if array is a subset of another array
|
|
226
949
|
# All elements in actual_value must be in expected_value
|
|
227
950
|
return false unless actual_value.is_a?(Array)
|
|
228
951
|
return false unless expected_value.is_a?(Array)
|
|
952
|
+
return true if actual_value.empty?
|
|
229
953
|
|
|
230
|
-
|
|
954
|
+
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
955
|
+
expected_set = expected_value.to_set
|
|
956
|
+
actual_value.all? { |item| expected_set.include?(item) }
|
|
231
957
|
|
|
232
958
|
# GEOSPATIAL OPERATORS
|
|
233
959
|
when "within_radius"
|
|
@@ -240,7 +966,8 @@ module DecisionAgent
|
|
|
240
966
|
params = parse_radius_params(expected_value)
|
|
241
967
|
return false unless params
|
|
242
968
|
|
|
243
|
-
|
|
969
|
+
# Cache geospatial distance calculations
|
|
970
|
+
distance = get_cached_distance(point, params[:center])
|
|
244
971
|
distance <= params[:radius]
|
|
245
972
|
|
|
246
973
|
when "in_polygon"
|
|
@@ -277,7 +1004,14 @@ module DecisionAgent
|
|
|
277
1004
|
keys.reduce(hash) do |memo, key|
|
|
278
1005
|
return nil unless memo.is_a?(Hash)
|
|
279
1006
|
|
|
280
|
-
|
|
1007
|
+
# OPTIMIZE: try symbol first (most common), then string
|
|
1008
|
+
# Check key existence first to avoid double lookup
|
|
1009
|
+
key_sym = key.to_sym
|
|
1010
|
+
if memo.key?(key_sym)
|
|
1011
|
+
memo[key_sym]
|
|
1012
|
+
elsif memo.key?(key)
|
|
1013
|
+
memo[key]
|
|
1014
|
+
end
|
|
281
1015
|
end
|
|
282
1016
|
end
|
|
283
1017
|
|
|
@@ -299,9 +1033,24 @@ module DecisionAgent
|
|
|
299
1033
|
# Parse range for 'between' operator
|
|
300
1034
|
# Accepts [min, max] or {min: x, max: y}
|
|
301
1035
|
def self.parse_range(value)
|
|
1036
|
+
# Generate cache key from normalized value
|
|
1037
|
+
cache_key = normalize_param_cache_key(value, "range")
|
|
1038
|
+
|
|
1039
|
+
# Fast path: check cache without lock
|
|
1040
|
+
cached = @param_cache[cache_key]
|
|
1041
|
+
return cached if cached
|
|
1042
|
+
|
|
1043
|
+
# Slow path: parse and cache
|
|
1044
|
+
@param_cache_mutex.synchronize do
|
|
1045
|
+
@param_cache[cache_key] ||= parse_range_impl(value)
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def self.parse_range_impl(value)
|
|
302
1050
|
if value.is_a?(Array) && value.size == 2
|
|
303
1051
|
{ min: value[0], max: value[1] }
|
|
304
1052
|
elsif value.is_a?(Hash)
|
|
1053
|
+
# Normalize keys to symbols for consistency
|
|
305
1054
|
min = value["min"] || value[:min]
|
|
306
1055
|
max = value["max"] || value[:max]
|
|
307
1056
|
return nil unless min && max
|
|
@@ -313,9 +1062,24 @@ module DecisionAgent
|
|
|
313
1062
|
# Parse modulo parameters
|
|
314
1063
|
# Accepts [divisor, remainder] or {divisor: x, remainder: y}
|
|
315
1064
|
def self.parse_modulo_params(value)
|
|
1065
|
+
# Generate cache key from normalized value
|
|
1066
|
+
cache_key = normalize_param_cache_key(value, "modulo")
|
|
1067
|
+
|
|
1068
|
+
# Fast path: check cache without lock
|
|
1069
|
+
cached = @param_cache[cache_key]
|
|
1070
|
+
return cached if cached
|
|
1071
|
+
|
|
1072
|
+
# Slow path: parse and cache
|
|
1073
|
+
@param_cache_mutex.synchronize do
|
|
1074
|
+
@param_cache[cache_key] ||= parse_modulo_params_impl(value)
|
|
1075
|
+
end
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
def self.parse_modulo_params_impl(value)
|
|
316
1079
|
if value.is_a?(Array) && value.size == 2
|
|
317
1080
|
{ divisor: value[0], remainder: value[1] }
|
|
318
1081
|
elsif value.is_a?(Hash)
|
|
1082
|
+
# Normalize keys to symbols for consistency
|
|
319
1083
|
divisor = value["divisor"] || value[:divisor]
|
|
320
1084
|
remainder = value["remainder"] || value[:remainder]
|
|
321
1085
|
return nil unless divisor && !remainder.nil?
|
|
@@ -324,6 +1088,20 @@ module DecisionAgent
|
|
|
324
1088
|
end
|
|
325
1089
|
end
|
|
326
1090
|
|
|
1091
|
+
# Parse power parameters
|
|
1092
|
+
# Accepts [exponent, result] or {exponent: x, result: y}
|
|
1093
|
+
def self.parse_power_params(value)
|
|
1094
|
+
if value.is_a?(Array) && value.size == 2
|
|
1095
|
+
{ exponent: value[0], result: value[1] }
|
|
1096
|
+
elsif value.is_a?(Hash)
|
|
1097
|
+
exponent = value["exponent"] || value[:exponent]
|
|
1098
|
+
result = value["result"] || value[:result]
|
|
1099
|
+
return nil unless exponent && !result.nil?
|
|
1100
|
+
|
|
1101
|
+
{ exponent: exponent, result: result }
|
|
1102
|
+
end
|
|
1103
|
+
end
|
|
1104
|
+
|
|
327
1105
|
# Parse date from string, Time, Date, or DateTime (with caching)
|
|
328
1106
|
def self.parse_date(value)
|
|
329
1107
|
case value
|
|
@@ -337,9 +1115,16 @@ module DecisionAgent
|
|
|
337
1115
|
end
|
|
338
1116
|
|
|
339
1117
|
# Compare two dates with given operator
|
|
1118
|
+
# Optimized: Early return if values are already Time/Date objects
|
|
340
1119
|
def self.compare_dates(actual_value, expected_value, operator)
|
|
341
1120
|
return false unless actual_value && expected_value
|
|
342
1121
|
|
|
1122
|
+
# Fast path: Both are already Time/Date objects (no parsing needed)
|
|
1123
|
+
actual_is_date = actual_value.is_a?(Time) || actual_value.is_a?(Date) || actual_value.is_a?(DateTime)
|
|
1124
|
+
expected_is_date = expected_value.is_a?(Time) || expected_value.is_a?(Date) || expected_value.is_a?(DateTime)
|
|
1125
|
+
return actual_value.send(operator, expected_value) if actual_is_date && expected_is_date
|
|
1126
|
+
|
|
1127
|
+
# Slow path: Parse dates (with caching)
|
|
343
1128
|
actual_date = parse_date(actual_value)
|
|
344
1129
|
expected_date = parse_date(expected_value)
|
|
345
1130
|
|
|
@@ -428,6 +1213,27 @@ module DecisionAgent
|
|
|
428
1213
|
earth_radius_km * c
|
|
429
1214
|
end
|
|
430
1215
|
|
|
1216
|
+
# Get cached distance between two points (with precision rounding for cache key)
|
|
1217
|
+
def self.get_cached_distance(point1, point2)
|
|
1218
|
+
# Round coordinates to 4 decimal places (~11m precision) for cache key
|
|
1219
|
+
# This balances cache hit rate with precision
|
|
1220
|
+
key = [
|
|
1221
|
+
point1[:lat].round(4),
|
|
1222
|
+
point1[:lon].round(4),
|
|
1223
|
+
point2[:lat].round(4),
|
|
1224
|
+
point2[:lon].round(4)
|
|
1225
|
+
].join(",")
|
|
1226
|
+
|
|
1227
|
+
# Fast path: check cache without lock
|
|
1228
|
+
cached = @geospatial_cache[key]
|
|
1229
|
+
return cached if cached
|
|
1230
|
+
|
|
1231
|
+
# Slow path: calculate and cache
|
|
1232
|
+
@geospatial_cache_mutex.synchronize do
|
|
1233
|
+
@geospatial_cache[key] ||= haversine_distance(point1, point2)
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
|
|
431
1237
|
# Check if point is inside polygon using ray casting algorithm
|
|
432
1238
|
def self.point_in_polygon?(point, polygon)
|
|
433
1239
|
x = point[:lon]
|
|
@@ -451,6 +1257,262 @@ module DecisionAgent
|
|
|
451
1257
|
inside
|
|
452
1258
|
end
|
|
453
1259
|
|
|
1260
|
+
# Helper methods for new operators
|
|
1261
|
+
|
|
1262
|
+
# Compare aggregation result with expected value (supports hash with comparison operators)
|
|
1263
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
1264
|
+
def self.compare_aggregation_result(actual, expected)
|
|
1265
|
+
if expected.is_a?(Hash)
|
|
1266
|
+
result = true
|
|
1267
|
+
result &&= (actual >= expected[:min]) if expected[:min]
|
|
1268
|
+
result &&= (actual <= expected[:max]) if expected[:max]
|
|
1269
|
+
result &&= (actual > expected[:gt]) if expected[:gt]
|
|
1270
|
+
result &&= (actual < expected[:lt]) if expected[:lt]
|
|
1271
|
+
result &&= (actual >= expected[:gte]) if expected[:gte]
|
|
1272
|
+
result &&= (actual <= expected[:lte]) if expected[:lte]
|
|
1273
|
+
result &&= (actual == expected[:eq]) if expected[:eq]
|
|
1274
|
+
result
|
|
1275
|
+
else
|
|
1276
|
+
actual == expected
|
|
1277
|
+
end
|
|
1278
|
+
end
|
|
1279
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
1280
|
+
|
|
1281
|
+
# Parse percentile parameters
|
|
1282
|
+
def self.parse_percentile_params(value)
|
|
1283
|
+
return nil unless value.is_a?(Hash)
|
|
1284
|
+
|
|
1285
|
+
percentile = value["percentile"] || value[:percentile]
|
|
1286
|
+
return nil unless percentile.is_a?(Numeric) && percentile >= 0 && percentile <= 100
|
|
1287
|
+
|
|
1288
|
+
{
|
|
1289
|
+
percentile: percentile.to_f,
|
|
1290
|
+
threshold: value["threshold"] || value[:threshold],
|
|
1291
|
+
gt: value["gt"] || value[:gt],
|
|
1292
|
+
lt: value["lt"] || value[:lt],
|
|
1293
|
+
gte: value["gte"] || value[:gte],
|
|
1294
|
+
lte: value["lte"] || value[:lte],
|
|
1295
|
+
eq: value["eq"] || value[:eq]
|
|
1296
|
+
}
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
# Compare percentile result
|
|
1300
|
+
def self.compare_percentile_result(actual, params)
|
|
1301
|
+
result = true
|
|
1302
|
+
result &&= (actual >= params[:threshold]) if params[:threshold]
|
|
1303
|
+
result &&= (actual > params[:gt]) if params[:gt]
|
|
1304
|
+
result &&= (actual < params[:lt]) if params[:lt]
|
|
1305
|
+
result &&= (actual >= params[:gte]) if params[:gte]
|
|
1306
|
+
result &&= (actual <= params[:lte]) if params[:lte]
|
|
1307
|
+
result &&= (actual == params[:eq]) if params[:eq]
|
|
1308
|
+
result
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
# Parse duration parameters
|
|
1312
|
+
def self.parse_duration_params(value)
|
|
1313
|
+
return nil unless value.is_a?(Hash)
|
|
1314
|
+
|
|
1315
|
+
end_field = value["end"] || value[:end]
|
|
1316
|
+
return nil unless end_field
|
|
1317
|
+
|
|
1318
|
+
{
|
|
1319
|
+
end: end_field.to_s,
|
|
1320
|
+
min: value["min"] || value[:min],
|
|
1321
|
+
max: value["max"] || value[:max],
|
|
1322
|
+
gt: value["gt"] || value[:gt],
|
|
1323
|
+
lt: value["lt"] || value[:lt],
|
|
1324
|
+
gte: value["gte"] || value[:gte],
|
|
1325
|
+
lte: value["lte"] || value[:lte]
|
|
1326
|
+
}
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
# Compare duration result
|
|
1330
|
+
def self.compare_duration_result(actual, params)
|
|
1331
|
+
result = true
|
|
1332
|
+
result &&= (actual >= params[:min]) if params[:min]
|
|
1333
|
+
result &&= (actual <= params[:max]) if params[:max]
|
|
1334
|
+
result &&= (actual > params[:gt]) if params[:gt]
|
|
1335
|
+
result &&= (actual < params[:lt]) if params[:lt]
|
|
1336
|
+
result &&= (actual >= params[:gte]) if params[:gte]
|
|
1337
|
+
result &&= (actual <= params[:lte]) if params[:lte]
|
|
1338
|
+
result
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
# Parse date arithmetic parameters
|
|
1342
|
+
def self.parse_date_arithmetic_params(value, unit = :days)
|
|
1343
|
+
return nil unless value.is_a?(Hash)
|
|
1344
|
+
|
|
1345
|
+
unit_value = value[unit.to_s] || value[unit]
|
|
1346
|
+
return nil unless unit_value.is_a?(Numeric)
|
|
1347
|
+
|
|
1348
|
+
{
|
|
1349
|
+
unit => unit_value.to_f,
|
|
1350
|
+
target: value["target"] || value[:target] || "now",
|
|
1351
|
+
compare: value["compare"] || value[:compare],
|
|
1352
|
+
eq: value["eq"] || value[:eq],
|
|
1353
|
+
gt: value["gt"] || value[:gt],
|
|
1354
|
+
lt: value["lt"] || value[:lt],
|
|
1355
|
+
gte: value["gte"] || value[:gte],
|
|
1356
|
+
lte: value["lte"] || value[:lte]
|
|
1357
|
+
}
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
# Compare date result
|
|
1361
|
+
def self.compare_date_result?(actual, target, params)
|
|
1362
|
+
if params[:compare]
|
|
1363
|
+
case params[:compare].to_s
|
|
1364
|
+
when "eq", "=="
|
|
1365
|
+
(actual - target).abs < 1
|
|
1366
|
+
when "gt", ">"
|
|
1367
|
+
actual > target
|
|
1368
|
+
when "lt", "<"
|
|
1369
|
+
actual < target
|
|
1370
|
+
when "gte", ">="
|
|
1371
|
+
actual >= target
|
|
1372
|
+
when "lte", "<="
|
|
1373
|
+
actual <= target
|
|
1374
|
+
else
|
|
1375
|
+
false
|
|
1376
|
+
end
|
|
1377
|
+
elsif params[:eq]
|
|
1378
|
+
(actual - target).abs < 1
|
|
1379
|
+
elsif params[:gt]
|
|
1380
|
+
actual > target
|
|
1381
|
+
elsif params[:lt]
|
|
1382
|
+
actual < target
|
|
1383
|
+
elsif params[:gte]
|
|
1384
|
+
actual >= target
|
|
1385
|
+
elsif params[:lte]
|
|
1386
|
+
actual <= target
|
|
1387
|
+
else
|
|
1388
|
+
false
|
|
1389
|
+
end
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
# Compare numeric result (for time component extraction)
|
|
1393
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
1394
|
+
def self.compare_numeric_result(actual, expected)
|
|
1395
|
+
if expected.is_a?(Hash)
|
|
1396
|
+
result = true
|
|
1397
|
+
result &&= (actual >= expected[:min]) if expected[:min]
|
|
1398
|
+
result &&= (actual <= expected[:max]) if expected[:max]
|
|
1399
|
+
result &&= (actual > expected[:gt]) if expected[:gt]
|
|
1400
|
+
result &&= (actual < expected[:lt]) if expected[:lt]
|
|
1401
|
+
result &&= (actual >= expected[:gte]) if expected[:gte]
|
|
1402
|
+
result &&= (actual <= expected[:lte]) if expected[:lte]
|
|
1403
|
+
result &&= (actual == expected[:eq]) if expected[:eq]
|
|
1404
|
+
result
|
|
1405
|
+
else
|
|
1406
|
+
actual == expected
|
|
1407
|
+
end
|
|
1408
|
+
end
|
|
1409
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
1410
|
+
|
|
1411
|
+
# Compare rate result
|
|
1412
|
+
def self.compare_rate_result(actual, expected)
|
|
1413
|
+
compare_aggregation_result(actual, expected)
|
|
1414
|
+
end
|
|
1415
|
+
|
|
1416
|
+
# Parse moving window parameters
|
|
1417
|
+
def self.parse_moving_window_params(value)
|
|
1418
|
+
return nil unless value.is_a?(Hash)
|
|
1419
|
+
|
|
1420
|
+
window = value["window"] || value[:window]
|
|
1421
|
+
return nil unless window.is_a?(Numeric) && window.positive?
|
|
1422
|
+
|
|
1423
|
+
{
|
|
1424
|
+
window: window.to_i,
|
|
1425
|
+
threshold: value["threshold"] || value[:threshold],
|
|
1426
|
+
gt: value["gt"] || value[:gt],
|
|
1427
|
+
lt: value["lt"] || value[:lt],
|
|
1428
|
+
gte: value["gte"] || value[:gte],
|
|
1429
|
+
lte: value["lte"] || value[:lte],
|
|
1430
|
+
eq: value["eq"] || value[:eq]
|
|
1431
|
+
}
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
# Compare moving window result
|
|
1435
|
+
def self.compare_moving_window_result(actual, params)
|
|
1436
|
+
result = true
|
|
1437
|
+
result &&= (actual >= params[:threshold]) if params[:threshold]
|
|
1438
|
+
result &&= (actual > params[:gt]) if params[:gt]
|
|
1439
|
+
result &&= (actual < params[:lt]) if params[:lt]
|
|
1440
|
+
result &&= (actual >= params[:gte]) if params[:gte]
|
|
1441
|
+
result &&= (actual <= params[:lte]) if params[:lte]
|
|
1442
|
+
result &&= (actual == params[:eq]) if params[:eq]
|
|
1443
|
+
result
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
# Parse compound interest parameters
|
|
1447
|
+
def self.parse_compound_interest_params(value)
|
|
1448
|
+
return nil unless value.is_a?(Hash)
|
|
1449
|
+
|
|
1450
|
+
rate = value["rate"] || value[:rate]
|
|
1451
|
+
periods = value["periods"] || value[:periods]
|
|
1452
|
+
return nil unless rate && periods
|
|
1453
|
+
|
|
1454
|
+
{
|
|
1455
|
+
rate: rate.to_f,
|
|
1456
|
+
periods: periods.to_i,
|
|
1457
|
+
result: value["result"] || value[:result],
|
|
1458
|
+
threshold: value["threshold"] || value[:threshold],
|
|
1459
|
+
gt: value["gt"] || value[:gt],
|
|
1460
|
+
lt: value["lt"] || value[:lt]
|
|
1461
|
+
}
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
# Parse present value parameters
|
|
1465
|
+
def self.parse_present_value_params(value)
|
|
1466
|
+
return nil unless value.is_a?(Hash)
|
|
1467
|
+
|
|
1468
|
+
rate = value["rate"] || value[:rate]
|
|
1469
|
+
periods = value["periods"] || value[:periods]
|
|
1470
|
+
return nil unless rate && periods
|
|
1471
|
+
|
|
1472
|
+
{
|
|
1473
|
+
rate: rate.to_f,
|
|
1474
|
+
periods: periods.to_i,
|
|
1475
|
+
result: value["result"] || value[:result],
|
|
1476
|
+
threshold: value["threshold"] || value[:threshold]
|
|
1477
|
+
}
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
# Parse future value parameters
|
|
1481
|
+
def self.parse_future_value_params(value)
|
|
1482
|
+
parse_present_value_params(value)
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
# Parse payment parameters
|
|
1486
|
+
def self.parse_payment_params(value)
|
|
1487
|
+
parse_compound_interest_params(value)
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1490
|
+
# Compare financial result
|
|
1491
|
+
def self.compare_financial_result(actual, params)
|
|
1492
|
+
result = true
|
|
1493
|
+
result &&= (actual >= params[:threshold]) if params[:threshold]
|
|
1494
|
+
result &&= (actual > params[:gt]) if params[:gt]
|
|
1495
|
+
result &&= (actual < params[:lt]) if params[:lt]
|
|
1496
|
+
result
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
# Parse join parameters
|
|
1500
|
+
def self.parse_join_params(value)
|
|
1501
|
+
return nil unless value.is_a?(Hash)
|
|
1502
|
+
|
|
1503
|
+
separator = value["separator"] || value[:separator] || ","
|
|
1504
|
+
{
|
|
1505
|
+
separator: separator.to_s,
|
|
1506
|
+
result: value["result"] || value[:result],
|
|
1507
|
+
contains: value["contains"] || value[:contains]
|
|
1508
|
+
}
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
# Compare length result
|
|
1512
|
+
def self.compare_length_result(actual, expected)
|
|
1513
|
+
compare_aggregation_result(actual, expected)
|
|
1514
|
+
end
|
|
1515
|
+
|
|
454
1516
|
# Cache management methods
|
|
455
1517
|
|
|
456
1518
|
# Get or compile regex with caching
|
|
@@ -479,7 +1541,7 @@ module DecisionAgent
|
|
|
479
1541
|
end
|
|
480
1542
|
end
|
|
481
1543
|
|
|
482
|
-
# Get cached parsed date
|
|
1544
|
+
# Get cached parsed date with fast-path for common formats
|
|
483
1545
|
def self.get_cached_date(date_string)
|
|
484
1546
|
# Fast path: check cache without lock
|
|
485
1547
|
cached = @date_cache[date_string]
|
|
@@ -487,15 +1549,48 @@ module DecisionAgent
|
|
|
487
1549
|
|
|
488
1550
|
# Slow path: parse and cache
|
|
489
1551
|
@date_cache_mutex.synchronize do
|
|
490
|
-
@date_cache[date_string] ||=
|
|
1552
|
+
@date_cache[date_string] ||= parse_date_fast(date_string)
|
|
491
1553
|
end
|
|
492
1554
|
end
|
|
493
1555
|
|
|
1556
|
+
# Fast-path date parsing for common formats (ISO8601, etc.)
|
|
1557
|
+
# Falls back to Time.parse for other formats
|
|
1558
|
+
def self.parse_date_fast(date_string)
|
|
1559
|
+
return nil unless date_string.is_a?(String)
|
|
1560
|
+
|
|
1561
|
+
# Fast-path: ISO8601 date format (YYYY-MM-DD)
|
|
1562
|
+
if date_string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
1563
|
+
year, month, day = date_string.split("-").map(&:to_i)
|
|
1564
|
+
begin
|
|
1565
|
+
return Time.new(year, month, day)
|
|
1566
|
+
rescue StandardError
|
|
1567
|
+
nil
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
# Fast-path: ISO8601 datetime format (YYYY-MM-DDTHH:MM:SS or YYYY-MM-DDTHH:MM:SSZ)
|
|
1572
|
+
if date_string.match?(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
|
1573
|
+
begin
|
|
1574
|
+
# Try ISO8601 parsing first (faster than Time.parse for this format)
|
|
1575
|
+
return Time.iso8601(date_string)
|
|
1576
|
+
rescue ArgumentError
|
|
1577
|
+
# Fall through to Time.parse
|
|
1578
|
+
end
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
# Fallback to Time.parse for other formats
|
|
1582
|
+
Time.parse(date_string)
|
|
1583
|
+
rescue ArgumentError, TypeError
|
|
1584
|
+
nil
|
|
1585
|
+
end
|
|
1586
|
+
|
|
494
1587
|
# Clear all caches (useful for testing or memory management)
|
|
495
1588
|
def self.clear_caches!
|
|
496
1589
|
@regex_cache_mutex.synchronize { @regex_cache.clear }
|
|
497
1590
|
@path_cache_mutex.synchronize { @path_cache.clear }
|
|
498
1591
|
@date_cache_mutex.synchronize { @date_cache.clear }
|
|
1592
|
+
@geospatial_cache_mutex.synchronize { @geospatial_cache.clear }
|
|
1593
|
+
@param_cache_mutex.synchronize { @param_cache.clear }
|
|
499
1594
|
end
|
|
500
1595
|
|
|
501
1596
|
# Get cache statistics
|
|
@@ -503,9 +1598,31 @@ module DecisionAgent
|
|
|
503
1598
|
{
|
|
504
1599
|
regex_cache_size: @regex_cache.size,
|
|
505
1600
|
path_cache_size: @path_cache.size,
|
|
506
|
-
date_cache_size: @date_cache.size
|
|
1601
|
+
date_cache_size: @date_cache.size,
|
|
1602
|
+
geospatial_cache_size: @geospatial_cache.size,
|
|
1603
|
+
param_cache_size: @param_cache.size
|
|
507
1604
|
}
|
|
508
1605
|
end
|
|
1606
|
+
|
|
1607
|
+
# Normalize parameter value for cache key generation
|
|
1608
|
+
# Converts hash keys to symbols for consistency
|
|
1609
|
+
def self.normalize_param_cache_key(value, prefix)
|
|
1610
|
+
case value
|
|
1611
|
+
when Array
|
|
1612
|
+
"#{prefix}:#{value.inspect}"
|
|
1613
|
+
when Hash
|
|
1614
|
+
# Normalize keys to symbols and sort for consistent cache keys
|
|
1615
|
+
normalized = value.each_with_object({}) do |(k, v), h|
|
|
1616
|
+
key = k.is_a?(String) ? k.to_sym : k
|
|
1617
|
+
h[key] = v
|
|
1618
|
+
end
|
|
1619
|
+
sorted_keys = normalized.keys.sort
|
|
1620
|
+
"#{prefix}:#{sorted_keys.map { |k| "#{k}:#{normalized[k]}" }.join(',')}"
|
|
1621
|
+
else
|
|
1622
|
+
"#{prefix}:#{value.inspect}"
|
|
1623
|
+
end
|
|
1624
|
+
end
|
|
1625
|
+
# rubocop:enable Metrics/ClassLength
|
|
509
1626
|
end
|
|
510
1627
|
end
|
|
511
1628
|
end
|