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