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.
- checksums.yaml +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +1132 -12
- data/lib/decision_agent/dsl/schema_validator.rb +12 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -1
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +71 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/advanced_operators_spec.rb +2147 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1909 -0
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ||=
|
|
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
|