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.
@@ -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
- actual_value = get_nested_value(context.to_h, field)
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
- expected_value.all? { |item| actual_value.include?(item) }
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
- expected_value.any? { |item| actual_value.include?(item) }
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
- !(actual_value & expected_value).empty?
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
- actual_value.all? { |item| expected_value.include?(item) }
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
- distance = haversine_distance(point, params[:center])
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
- memo[key] || memo[key.to_sym]
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] ||= Time.parse(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