decision_agent 0.3.0 → 1.1.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 +234 -14
- data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -13
- data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
- data/lib/decision_agent/agent.rb +78 -9
- data/lib/decision_agent/audit/adapter.rb +2 -0
- data/lib/decision_agent/audit/logger_adapter.rb +2 -0
- data/lib/decision_agent/audit/null_adapter.rb +2 -0
- data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
- data/lib/decision_agent/auth/authenticator.rb +2 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
- data/lib/decision_agent/auth/password_reset_token.rb +2 -0
- data/lib/decision_agent/auth/permission.rb +2 -0
- data/lib/decision_agent/auth/permission_checker.rb +2 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
- data/lib/decision_agent/auth/rbac_config.rb +2 -0
- data/lib/decision_agent/auth/role.rb +2 -0
- data/lib/decision_agent/auth/session.rb +2 -0
- data/lib/decision_agent/auth/session_manager.rb +2 -0
- data/lib/decision_agent/auth/user.rb +2 -0
- data/lib/decision_agent/context.rb +14 -0
- data/lib/decision_agent/decision.rb +113 -4
- data/lib/decision_agent/dmn/adapter.rb +2 -0
- data/lib/decision_agent/dmn/cache.rb +2 -2
- data/lib/decision_agent/dmn/decision_graph.rb +7 -7
- data/lib/decision_agent/dmn/decision_tree.rb +16 -8
- data/lib/decision_agent/dmn/errors.rb +2 -0
- data/lib/decision_agent/dmn/exporter.rb +2 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +130 -114
- data/lib/decision_agent/dmn/feel/functions.rb +2 -0
- data/lib/decision_agent/dmn/feel/parser.rb +2 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
- data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
- data/lib/decision_agent/dmn/feel/types.rb +2 -0
- data/lib/decision_agent/dmn/importer.rb +2 -0
- data/lib/decision_agent/dmn/model.rb +2 -4
- data/lib/decision_agent/dmn/parser.rb +2 -0
- data/lib/decision_agent/dmn/testing.rb +3 -2
- data/lib/decision_agent/dmn/validator.rb +5 -3
- data/lib/decision_agent/dmn/visualizer.rb +7 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +242 -1375
- data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
- data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
- data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
- data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
- data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
- data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
- data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
- data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
- data/lib/decision_agent/dsl/operators/base.rb +70 -0
- data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
- data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
- data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
- data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
- data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
- data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
- data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
- data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
- data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
- data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
- data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
- data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
- data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
- data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
- data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
- data/lib/decision_agent/dsl/rule_parser.rb +2 -0
- data/lib/decision_agent/dsl/schema_validator.rb +37 -14
- data/lib/decision_agent/errors.rb +2 -0
- data/lib/decision_agent/evaluation.rb +14 -2
- data/lib/decision_agent/evaluators/base.rb +2 -0
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +108 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +56 -11
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
- data/lib/decision_agent/explainability/condition_trace.rb +85 -0
- data/lib/decision_agent/explainability/explainability_result.rb +50 -0
- data/lib/decision_agent/explainability/rule_trace.rb +41 -0
- data/lib/decision_agent/explainability/trace_collector.rb +26 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +7 -16
- data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
- data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
- data/lib/decision_agent/replay/replay.rb +4 -1
- data/lib/decision_agent/scoring/base.rb +2 -0
- data/lib/decision_agent/scoring/consensus.rb +2 -0
- data/lib/decision_agent/scoring/max_weight.rb +2 -0
- data/lib/decision_agent/scoring/threshold.rb +2 -0
- data/lib/decision_agent/scoring/weighted_average.rb +2 -0
- data/lib/decision_agent/simulation/errors.rb +20 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +500 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +638 -0
- data/lib/decision_agent/simulation/replay_engine.rb +488 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +320 -0
- data/lib/decision_agent/simulation/scenario_library.rb +165 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +274 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1008 -0
- data/lib/decision_agent/simulation.rb +19 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +6 -2
- data/lib/decision_agent/testing/batch_test_runner.rb +5 -2
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +2 -0
- data/lib/decision_agent/testing/test_scenario.rb +2 -0
- data/lib/decision_agent/version.rb +3 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +108 -43
- data/lib/decision_agent/versioning/adapter.rb +9 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +19 -6
- data/lib/decision_agent/versioning/version_manager.rb +9 -0
- data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
- data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
- data/lib/decision_agent/web/dmn_editor.rb +8 -67
- data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
- data/lib/decision_agent/web/public/app.js +186 -26
- data/lib/decision_agent/web/public/batch_testing.html +80 -6
- data/lib/decision_agent/web/public/dmn-editor.html +2 -2
- data/lib/decision_agent/web/public/dmn-editor.js +74 -8
- data/lib/decision_agent/web/public/index.html +69 -3
- data/lib/decision_agent/web/public/login.html +1 -1
- data/lib/decision_agent/web/public/sample_batch.csv +11 -0
- data/lib/decision_agent/web/public/sample_impact.csv +11 -0
- data/lib/decision_agent/web/public/sample_replay.csv +11 -0
- data/lib/decision_agent/web/public/sample_rules.json +118 -0
- data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
- data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
- data/lib/decision_agent/web/public/simulation.html +146 -0
- data/lib/decision_agent/web/public/simulation_impact.html +495 -0
- data/lib/decision_agent/web/public/simulation_replay.html +547 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +561 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +549 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/public/users.html +1 -1
- data/lib/decision_agent/web/rack_helpers.rb +106 -0
- data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
- data/lib/decision_agent/web/server.rb +2126 -1374
- data/lib/decision_agent.rb +19 -1
- data/lib/generators/decision_agent/install/install_generator.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
- metadata +103 -89
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -2134
|
@@ -1,5 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "set"
|
|
2
4
|
|
|
5
|
+
# Operator mixins
|
|
6
|
+
require_relative "operators/base"
|
|
7
|
+
require_relative "operators/basic_comparison_operators"
|
|
8
|
+
require_relative "operators/string_operators"
|
|
9
|
+
require_relative "operators/numeric_operators"
|
|
10
|
+
require_relative "operators/mathematical_operators"
|
|
11
|
+
require_relative "operators/statistical_aggregations"
|
|
12
|
+
require_relative "operators/date_time_operators"
|
|
13
|
+
require_relative "operators/duration_operators"
|
|
14
|
+
require_relative "operators/date_arithmetic_operators"
|
|
15
|
+
require_relative "operators/time_component_operators"
|
|
16
|
+
require_relative "operators/rate_operators"
|
|
17
|
+
require_relative "operators/moving_window_operators"
|
|
18
|
+
require_relative "operators/financial_operators"
|
|
19
|
+
require_relative "operators/string_aggregations"
|
|
20
|
+
require_relative "operators/collection_operators"
|
|
21
|
+
require_relative "operators/geospatial_operators"
|
|
22
|
+
|
|
23
|
+
# Helper modules
|
|
24
|
+
require_relative "helpers/cache_helpers"
|
|
25
|
+
require_relative "helpers/date_helpers"
|
|
26
|
+
require_relative "helpers/geospatial_helpers"
|
|
27
|
+
require_relative "helpers/template_helpers"
|
|
28
|
+
require_relative "helpers/parameter_parsing_helpers"
|
|
29
|
+
require_relative "helpers/comparison_helpers"
|
|
30
|
+
require_relative "helpers/operator_evaluation_helpers"
|
|
31
|
+
require_relative "helpers/utility_helpers"
|
|
32
|
+
|
|
3
33
|
module DecisionAgent
|
|
4
34
|
module Dsl
|
|
5
35
|
# Evaluates conditions in the rule DSL against a context
|
|
@@ -8,7 +38,6 @@ module DecisionAgent
|
|
|
8
38
|
# - Field conditions with various operators
|
|
9
39
|
# - Nested field access via dot notation (e.g., "user.profile.role")
|
|
10
40
|
# - Logical operators (all/any)
|
|
11
|
-
# rubocop:disable Metrics/ClassLength
|
|
12
41
|
class ConditionEvaluator
|
|
13
42
|
# Thread-safe caches for performance optimization
|
|
14
43
|
@regex_cache = {}
|
|
@@ -26,15 +55,20 @@ module DecisionAgent
|
|
|
26
55
|
attr_reader :regex_cache, :path_cache, :date_cache, :geospatial_cache, :param_cache
|
|
27
56
|
end
|
|
28
57
|
|
|
29
|
-
def self.evaluate(condition, context)
|
|
58
|
+
def self.evaluate(condition, context, enriched_context_hash: nil, trace_collector: nil)
|
|
30
59
|
return false unless condition.is_a?(Hash)
|
|
31
60
|
|
|
61
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
62
|
+
# This ensures all conditions in the same evaluation share the same enriched hash
|
|
63
|
+
enriched = enriched_context_hash
|
|
64
|
+
enriched ||= context.to_h.dup
|
|
65
|
+
|
|
32
66
|
if condition.key?("all")
|
|
33
|
-
evaluate_all(condition["all"], context)
|
|
67
|
+
evaluate_all(condition["all"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
|
|
34
68
|
elsif condition.key?("any")
|
|
35
|
-
evaluate_any(condition["any"], context)
|
|
69
|
+
evaluate_any(condition["any"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
|
|
36
70
|
elsif condition.key?("field")
|
|
37
|
-
evaluate_field_condition(condition, context)
|
|
71
|
+
evaluate_field_condition(condition, context, enriched_context_hash: enriched, trace_collector: trace_collector)
|
|
38
72
|
else
|
|
39
73
|
false
|
|
40
74
|
end
|
|
@@ -42,957 +76,90 @@ module DecisionAgent
|
|
|
42
76
|
|
|
43
77
|
# Evaluates 'all' condition - returns true only if ALL sub-conditions are true
|
|
44
78
|
# Empty array returns true (vacuous truth)
|
|
45
|
-
def self.evaluate_all(conditions, context)
|
|
79
|
+
def self.evaluate_all(conditions, context, enriched_context_hash: nil, trace_collector: nil)
|
|
46
80
|
return true if conditions.is_a?(Array) && conditions.empty?
|
|
47
81
|
return false unless conditions.is_a?(Array)
|
|
48
82
|
|
|
49
|
-
|
|
83
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
84
|
+
# All conditions share the same enriched hash so data enrichment persists
|
|
85
|
+
enriched = enriched_context_hash
|
|
86
|
+
enriched ||= context.to_h.dup
|
|
87
|
+
|
|
88
|
+
conditions.all? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
|
|
50
89
|
end
|
|
51
90
|
|
|
52
91
|
# Evaluates 'any' condition - returns true if AT LEAST ONE sub-condition is true
|
|
53
92
|
# Empty array returns false (no options to match)
|
|
54
|
-
def self.evaluate_any(conditions, context)
|
|
93
|
+
def self.evaluate_any(conditions, context, enriched_context_hash: nil, trace_collector: nil)
|
|
55
94
|
return false unless conditions.is_a?(Array)
|
|
56
95
|
|
|
57
|
-
|
|
96
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
97
|
+
# All conditions share the same enriched hash so data enrichment persists
|
|
98
|
+
enriched = enriched_context_hash
|
|
99
|
+
enriched ||= context.to_h.dup
|
|
100
|
+
|
|
101
|
+
conditions.any? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
|
|
58
102
|
end
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
def self.evaluate_field_condition(condition, context)
|
|
104
|
+
def self.evaluate_field_condition(condition, context, enriched_context_hash: nil, trace_collector: nil)
|
|
62
105
|
field = condition["field"]
|
|
63
106
|
op = condition["op"]
|
|
64
107
|
expected_value = condition["value"]
|
|
65
108
|
|
|
66
109
|
# Special handling for "don't care" conditions (from DMN "-" entries)
|
|
67
|
-
|
|
110
|
+
result = handle_dont_care_condition(field, op, expected_value, trace_collector)
|
|
111
|
+
return result if result == true
|
|
68
112
|
|
|
69
|
-
|
|
113
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
114
|
+
# This ensures all conditions in the same evaluation share the same enriched hash
|
|
115
|
+
context_hash = enriched_context_hash || context.to_h.dup
|
|
70
116
|
actual_value = get_nested_value(context_hash, field)
|
|
71
117
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# Equality - uses Ruby's == for comparison
|
|
75
|
-
actual_value == expected_value
|
|
76
|
-
|
|
77
|
-
when "neq"
|
|
78
|
-
# Not equal - inverse of ==
|
|
79
|
-
actual_value != expected_value
|
|
80
|
-
|
|
81
|
-
when "gt"
|
|
82
|
-
# Greater than - only for comparable types (numbers, strings)
|
|
83
|
-
comparable?(actual_value, expected_value) && actual_value > expected_value
|
|
84
|
-
|
|
85
|
-
when "gte"
|
|
86
|
-
# Greater than or equal - only for comparable types
|
|
87
|
-
comparable?(actual_value, expected_value) && actual_value >= expected_value
|
|
88
|
-
|
|
89
|
-
when "lt"
|
|
90
|
-
# Less than - only for comparable types
|
|
91
|
-
comparable?(actual_value, expected_value) && actual_value < expected_value
|
|
92
|
-
|
|
93
|
-
when "lte"
|
|
94
|
-
# Less than or equal - only for comparable types
|
|
95
|
-
comparable?(actual_value, expected_value) && actual_value <= expected_value
|
|
96
|
-
|
|
97
|
-
when "in"
|
|
98
|
-
# Array membership - checks if actual_value is in the expected array
|
|
99
|
-
Array(expected_value).include?(actual_value)
|
|
100
|
-
|
|
101
|
-
when "present"
|
|
102
|
-
# PRESENT SEMANTICS:
|
|
103
|
-
# Returns true if value exists AND is not empty
|
|
104
|
-
# - nil: false
|
|
105
|
-
# - Empty string "": false
|
|
106
|
-
# - Empty array []: false
|
|
107
|
-
# - Empty hash {}: false
|
|
108
|
-
# - Zero 0: true (zero is a valid value)
|
|
109
|
-
# - False boolean: true (false is a valid value)
|
|
110
|
-
# - Non-empty values: true
|
|
111
|
-
!actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
|
|
112
|
-
|
|
113
|
-
when "blank"
|
|
114
|
-
# BLANK SEMANTICS:
|
|
115
|
-
# Returns true if value is nil OR empty
|
|
116
|
-
# - nil: true
|
|
117
|
-
# - Empty string "": true
|
|
118
|
-
# - Empty array []: true
|
|
119
|
-
# - Empty hash {}: true
|
|
120
|
-
# - Zero 0: false (zero is a valid value)
|
|
121
|
-
# - False boolean: false (false is a valid value)
|
|
122
|
-
# - Non-empty values: false
|
|
123
|
-
actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
|
|
124
|
-
|
|
125
|
-
# STRING OPERATORS
|
|
126
|
-
when "contains"
|
|
127
|
-
# Checks if string contains substring (case-sensitive)
|
|
128
|
-
string_operator?(actual_value, expected_value) &&
|
|
129
|
-
actual_value.include?(expected_value)
|
|
130
|
-
|
|
131
|
-
when "starts_with"
|
|
132
|
-
# Checks if string starts with prefix (case-sensitive)
|
|
133
|
-
string_operator?(actual_value, expected_value) &&
|
|
134
|
-
actual_value.start_with?(expected_value)
|
|
135
|
-
|
|
136
|
-
when "ends_with"
|
|
137
|
-
# Checks if string ends with suffix (case-sensitive)
|
|
138
|
-
string_operator?(actual_value, expected_value) &&
|
|
139
|
-
actual_value.end_with?(expected_value)
|
|
140
|
-
|
|
141
|
-
when "matches"
|
|
142
|
-
# Matches string against regular expression
|
|
143
|
-
# expected_value can be a string (converted to regex) or Regexp object
|
|
144
|
-
return false unless actual_value.is_a?(String)
|
|
145
|
-
return false if expected_value.nil?
|
|
146
|
-
|
|
147
|
-
begin
|
|
148
|
-
regex = get_cached_regex(expected_value)
|
|
149
|
-
!regex.match(actual_value).nil?
|
|
150
|
-
rescue RegexpError
|
|
151
|
-
false
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# NUMERIC OPERATORS
|
|
155
|
-
when "between"
|
|
156
|
-
# Checks if numeric value is between min and max (inclusive)
|
|
157
|
-
# expected_value should be [min, max] or {min: x, max: y}
|
|
158
|
-
return false unless actual_value.is_a?(Numeric)
|
|
159
|
-
|
|
160
|
-
range = parse_range(expected_value)
|
|
161
|
-
return false unless range
|
|
162
|
-
|
|
163
|
-
actual_value.between?(range[:min], range[:max])
|
|
164
|
-
|
|
165
|
-
when "modulo"
|
|
166
|
-
# Checks if value modulo divisor equals remainder
|
|
167
|
-
# expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
|
|
168
|
-
return false unless actual_value.is_a?(Numeric)
|
|
169
|
-
|
|
170
|
-
params = parse_modulo_params(expected_value)
|
|
171
|
-
return false unless params
|
|
172
|
-
|
|
173
|
-
(actual_value % params[:divisor]) == params[:remainder]
|
|
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
|
-
|
|
414
|
-
# DATE/TIME OPERATORS
|
|
415
|
-
when "before_date"
|
|
416
|
-
# Checks if date is before specified date
|
|
417
|
-
compare_dates(actual_value, expected_value, :<)
|
|
418
|
-
|
|
419
|
-
when "after_date"
|
|
420
|
-
# Checks if date is after specified date
|
|
421
|
-
compare_dates(actual_value, expected_value, :>)
|
|
422
|
-
|
|
423
|
-
when "within_days"
|
|
424
|
-
# Checks if date is within N days from now (past or future)
|
|
425
|
-
# expected_value is number of days
|
|
426
|
-
return false unless actual_value
|
|
427
|
-
return false unless expected_value.is_a?(Numeric)
|
|
428
|
-
|
|
429
|
-
date = parse_date(actual_value)
|
|
430
|
-
return false unless date
|
|
431
|
-
|
|
432
|
-
now = Time.now
|
|
433
|
-
diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
|
|
434
|
-
diff_days <= expected_value
|
|
435
|
-
|
|
436
|
-
when "day_of_week"
|
|
437
|
-
# Checks if date falls on specified day of week
|
|
438
|
-
# expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
|
|
439
|
-
return false unless actual_value
|
|
440
|
-
|
|
441
|
-
date = parse_date(actual_value)
|
|
442
|
-
return false unless date
|
|
443
|
-
|
|
444
|
-
expected_day = normalize_day_of_week(expected_value)
|
|
445
|
-
return false unless expected_day
|
|
446
|
-
|
|
447
|
-
date.wday == expected_day
|
|
448
|
-
|
|
449
|
-
# DURATION CALCULATIONS
|
|
450
|
-
when "duration_seconds"
|
|
451
|
-
# Calculates duration between two dates in seconds
|
|
452
|
-
# expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
|
|
453
|
-
return false unless actual_value
|
|
454
|
-
|
|
455
|
-
start_date = parse_date(actual_value)
|
|
456
|
-
return false unless start_date
|
|
457
|
-
|
|
458
|
-
params = parse_duration_params(expected_value)
|
|
459
|
-
return false unless params
|
|
460
|
-
|
|
461
|
-
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
462
|
-
return false unless end_date
|
|
463
|
-
|
|
464
|
-
duration = (end_date - start_date).abs
|
|
465
|
-
compare_duration_result(duration, params)
|
|
466
|
-
|
|
467
|
-
when "duration_minutes"
|
|
468
|
-
# Calculates duration between two dates in minutes
|
|
469
|
-
return false unless actual_value
|
|
470
|
-
|
|
471
|
-
start_date = parse_date(actual_value)
|
|
472
|
-
return false unless start_date
|
|
473
|
-
|
|
474
|
-
params = parse_duration_params(expected_value)
|
|
475
|
-
return false unless params
|
|
476
|
-
|
|
477
|
-
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
478
|
-
return false unless end_date
|
|
479
|
-
|
|
480
|
-
duration = ((end_date - start_date).abs / 60.0)
|
|
481
|
-
compare_duration_result(duration, params)
|
|
482
|
-
|
|
483
|
-
when "duration_hours"
|
|
484
|
-
# Calculates duration between two dates in hours
|
|
485
|
-
return false unless actual_value
|
|
486
|
-
|
|
487
|
-
start_date = parse_date(actual_value)
|
|
488
|
-
return false unless start_date
|
|
489
|
-
|
|
490
|
-
params = parse_duration_params(expected_value)
|
|
491
|
-
return false unless params
|
|
492
|
-
|
|
493
|
-
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
494
|
-
return false unless end_date
|
|
495
|
-
|
|
496
|
-
duration = ((end_date - start_date).abs / 3600.0)
|
|
497
|
-
compare_duration_result(duration, params)
|
|
498
|
-
|
|
499
|
-
when "duration_days"
|
|
500
|
-
# Calculates duration between two dates in days
|
|
501
|
-
return false unless actual_value
|
|
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?
|
|
118
|
+
# Try operator mixins to handle the operator
|
|
119
|
+
result = evaluate_operator(op, actual_value, expected_value, context_hash)
|
|
740
120
|
|
|
741
|
-
|
|
742
|
-
|
|
121
|
+
# Add trace if collector is provided
|
|
122
|
+
trace_collector&.add_trace(Explainability::ConditionTrace.new(
|
|
123
|
+
field: field,
|
|
124
|
+
operator: op,
|
|
125
|
+
expected_value: expected_value,
|
|
126
|
+
actual_value: actual_value,
|
|
127
|
+
result: result
|
|
128
|
+
))
|
|
743
129
|
|
|
744
|
-
|
|
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
|
-
|
|
908
|
-
# COLLECTION OPERATORS
|
|
909
|
-
when "contains_all"
|
|
910
|
-
# Checks if array contains all specified elements
|
|
911
|
-
# expected_value should be an array
|
|
912
|
-
return false unless actual_value.is_a?(Array)
|
|
913
|
-
return false unless expected_value.is_a?(Array)
|
|
914
|
-
return true if expected_value.empty?
|
|
915
|
-
|
|
916
|
-
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
917
|
-
# For small arrays, Set overhead is minimal; for large arrays, huge win
|
|
918
|
-
actual_set = actual_value.to_set
|
|
919
|
-
expected_value.all? { |item| actual_set.include?(item) }
|
|
920
|
-
|
|
921
|
-
when "contains_any"
|
|
922
|
-
# Checks if array contains any of the specified elements
|
|
923
|
-
# expected_value should be an array
|
|
924
|
-
return false unless actual_value.is_a?(Array)
|
|
925
|
-
return false unless expected_value.is_a?(Array)
|
|
926
|
-
return false if expected_value.empty?
|
|
927
|
-
|
|
928
|
-
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
929
|
-
# Early exit on first match for better performance
|
|
930
|
-
actual_set = actual_value.to_set
|
|
931
|
-
expected_value.any? { |item| actual_set.include?(item) }
|
|
932
|
-
|
|
933
|
-
when "intersects"
|
|
934
|
-
# Checks if two arrays have any common elements
|
|
935
|
-
# expected_value should be an array
|
|
936
|
-
return false unless actual_value.is_a?(Array)
|
|
937
|
-
return false unless expected_value.is_a?(Array)
|
|
938
|
-
return false if actual_value.empty? || expected_value.empty?
|
|
939
|
-
|
|
940
|
-
# OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
|
|
941
|
-
# Check smaller array against larger set for better performance
|
|
942
|
-
if actual_value.size <= expected_value.size
|
|
943
|
-
expected_set = expected_value.to_set
|
|
944
|
-
actual_value.any? { |item| expected_set.include?(item) }
|
|
945
|
-
else
|
|
946
|
-
actual_set = actual_value.to_set
|
|
947
|
-
expected_value.any? { |item| actual_set.include?(item) }
|
|
948
|
-
end
|
|
949
|
-
|
|
950
|
-
when "subset_of"
|
|
951
|
-
# Checks if array is a subset of another array
|
|
952
|
-
# All elements in actual_value must be in expected_value
|
|
953
|
-
return false unless actual_value.is_a?(Array)
|
|
954
|
-
return false unless expected_value.is_a?(Array)
|
|
955
|
-
return true if actual_value.empty?
|
|
956
|
-
|
|
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) }
|
|
960
|
-
|
|
961
|
-
# GEOSPATIAL OPERATORS
|
|
962
|
-
when "within_radius"
|
|
963
|
-
# Checks if point is within radius of center point
|
|
964
|
-
# actual_value: {lat: y, lon: x} or [lat, lon]
|
|
965
|
-
# expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
|
|
966
|
-
point = parse_coordinates(actual_value)
|
|
967
|
-
return false unless point
|
|
968
|
-
|
|
969
|
-
params = parse_radius_params(expected_value)
|
|
970
|
-
return false unless params
|
|
971
|
-
|
|
972
|
-
# Cache geospatial distance calculations
|
|
973
|
-
distance = get_cached_distance(point, params[:center])
|
|
974
|
-
distance <= params[:radius]
|
|
975
|
-
|
|
976
|
-
when "in_polygon"
|
|
977
|
-
# Checks if point is inside a polygon using ray casting algorithm
|
|
978
|
-
# actual_value: {lat: y, lon: x} or [lat, lon]
|
|
979
|
-
# expected_value: array of vertices [{lat: y, lon: x}, ...] or [[lat, lon], ...]
|
|
980
|
-
point = parse_coordinates(actual_value)
|
|
981
|
-
return false unless point
|
|
982
|
-
|
|
983
|
-
polygon = parse_polygon(expected_value)
|
|
984
|
-
return false unless polygon
|
|
985
|
-
return false if polygon.size < 3 # Need at least 3 vertices
|
|
986
|
-
|
|
987
|
-
point_in_polygon?(point, polygon)
|
|
988
|
-
|
|
989
|
-
else
|
|
990
|
-
# Unknown operator - returns false (fail-safe)
|
|
991
|
-
# Note: Validation should catch this earlier
|
|
992
|
-
false
|
|
993
|
-
end
|
|
130
|
+
result
|
|
994
131
|
end
|
|
995
|
-
|
|
132
|
+
|
|
133
|
+
# Evaluates operator using mixins (in order of most common to least common)
|
|
134
|
+
# Returns the result from the first mixin that handles the operator, or false if unknown
|
|
135
|
+
def self.evaluate_operator(op, actual_value, expected_value, context_hash)
|
|
136
|
+
Helpers::OperatorEvaluationHelpers.evaluate_operator(
|
|
137
|
+
op, actual_value, expected_value, context_hash,
|
|
138
|
+
regex_cache: @regex_cache,
|
|
139
|
+
regex_cache_mutex: @regex_cache_mutex,
|
|
140
|
+
param_cache: @param_cache,
|
|
141
|
+
param_cache_mutex: @param_cache_mutex,
|
|
142
|
+
geospatial_cache: @geospatial_cache,
|
|
143
|
+
geospatial_cache_mutex: @geospatial_cache_mutex
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
private_class_method :evaluate_operator
|
|
147
|
+
|
|
148
|
+
# Handles "don't care" conditions from DMN "-" entries
|
|
149
|
+
# Returns true if this is a "don't care" condition, nil otherwise
|
|
150
|
+
def self.handle_dont_care_condition(field, op, expected_value, trace_collector)
|
|
151
|
+
return nil unless field == "__always_match__" && op == "eq" && expected_value == true
|
|
152
|
+
|
|
153
|
+
trace_collector&.add_trace(Explainability::ConditionTrace.new(
|
|
154
|
+
field: field,
|
|
155
|
+
operator: op,
|
|
156
|
+
expected_value: expected_value,
|
|
157
|
+
actual_value: true,
|
|
158
|
+
result: true
|
|
159
|
+
))
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
private_class_method :handle_dont_care_condition
|
|
996
163
|
|
|
997
164
|
# Retrieves nested values from a hash using dot notation
|
|
998
165
|
#
|
|
@@ -1003,413 +170,207 @@ module DecisionAgent
|
|
|
1003
170
|
#
|
|
1004
171
|
# Supports both string and symbol keys in the hash
|
|
1005
172
|
def self.get_nested_value(hash, key_path)
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
|
1018
|
-
end
|
|
173
|
+
Helpers::UtilityHelpers.get_nested_value(
|
|
174
|
+
hash, key_path,
|
|
175
|
+
get_cached_path: method(:get_cached_path)
|
|
176
|
+
)
|
|
1019
177
|
end
|
|
1020
178
|
|
|
1021
179
|
# Checks if two values can be compared with <, >, <=, >=
|
|
1022
|
-
#
|
|
180
|
+
# Allows comparison between numeric types (Float, Integer, etc.) or same string types
|
|
1023
181
|
def self.comparable?(val1, val2)
|
|
1024
|
-
|
|
1025
|
-
(val2.is_a?(Numeric) || val2.is_a?(String)) &&
|
|
1026
|
-
val1.instance_of?(val2.class)
|
|
182
|
+
Helpers::UtilityHelpers.comparable?(val1, val2)
|
|
1027
183
|
end
|
|
1028
184
|
|
|
1029
|
-
#
|
|
185
|
+
# Floating point comparison with epsilon threshold
|
|
186
|
+
def self.epsilon_equal?(value1, value2, epsilon = 1e-10)
|
|
187
|
+
Helpers::UtilityHelpers.epsilon_equal?(value1, value2, epsilon)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Expand template parameters (e.g., "{{customer.ssn}}") from context
|
|
191
|
+
def self.expand_template_params(params, context_hash)
|
|
192
|
+
Helpers::TemplateHelpers.expand_template_params(
|
|
193
|
+
params, context_hash,
|
|
194
|
+
get_nested_value: method(:get_nested_value)
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Expand a single template value
|
|
199
|
+
def self.expand_template_value(value, context_hash)
|
|
200
|
+
Helpers::TemplateHelpers.expand_template_value(
|
|
201
|
+
value, context_hash,
|
|
202
|
+
get_nested_value: method(:get_nested_value)
|
|
203
|
+
)
|
|
204
|
+
end
|
|
1030
205
|
|
|
1031
206
|
# String operator validation
|
|
1032
207
|
def self.string_operator?(actual_value, expected_value)
|
|
1033
|
-
|
|
208
|
+
Helpers::UtilityHelpers.string_operator?(actual_value, expected_value)
|
|
1034
209
|
end
|
|
1035
210
|
|
|
1036
211
|
# Parse range for 'between' operator
|
|
1037
212
|
# Accepts [min, max] or {min: x, max: y}
|
|
213
|
+
# Normalizes arrays to hash for better performance with large params
|
|
1038
214
|
def self.parse_range(value)
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
215
|
+
Helpers::ParameterParsingHelpers.parse_range(
|
|
216
|
+
value,
|
|
217
|
+
param_cache: @param_cache,
|
|
218
|
+
param_cache_mutex: @param_cache_mutex
|
|
219
|
+
)
|
|
1050
220
|
end
|
|
1051
221
|
|
|
1052
222
|
def self.parse_range_impl(value)
|
|
1053
|
-
|
|
1054
|
-
{ min: value[0], max: value[1] }
|
|
1055
|
-
elsif value.is_a?(Hash)
|
|
1056
|
-
# Normalize keys to symbols for consistency
|
|
1057
|
-
min = value["min"] || value[:min]
|
|
1058
|
-
max = value["max"] || value[:max]
|
|
1059
|
-
return nil unless min && max
|
|
1060
|
-
|
|
1061
|
-
{ min: min, max: max }
|
|
1062
|
-
end
|
|
223
|
+
Helpers::ParameterParsingHelpers.parse_range_impl(value)
|
|
1063
224
|
end
|
|
1064
225
|
|
|
1065
226
|
# Parse modulo parameters
|
|
1066
227
|
# Accepts [divisor, remainder] or {divisor: x, remainder: y}
|
|
228
|
+
# Normalizes arrays to hash for better performance with large params
|
|
1067
229
|
def self.parse_modulo_params(value)
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
|
230
|
+
Helpers::ParameterParsingHelpers.parse_modulo_params(
|
|
231
|
+
value,
|
|
232
|
+
param_cache: @param_cache,
|
|
233
|
+
param_cache_mutex: @param_cache_mutex
|
|
234
|
+
)
|
|
1079
235
|
end
|
|
1080
236
|
|
|
1081
237
|
def self.parse_modulo_params_impl(value)
|
|
1082
|
-
|
|
1083
|
-
{ divisor: value[0], remainder: value[1] }
|
|
1084
|
-
elsif value.is_a?(Hash)
|
|
1085
|
-
# Normalize keys to symbols for consistency
|
|
1086
|
-
divisor = value["divisor"] || value[:divisor]
|
|
1087
|
-
remainder = value["remainder"] || value[:remainder]
|
|
1088
|
-
return nil unless divisor && !remainder.nil?
|
|
1089
|
-
|
|
1090
|
-
{ divisor: divisor, remainder: remainder }
|
|
1091
|
-
end
|
|
238
|
+
Helpers::ParameterParsingHelpers.parse_modulo_params_impl(value)
|
|
1092
239
|
end
|
|
1093
240
|
|
|
1094
241
|
# Parse power parameters
|
|
1095
242
|
# Accepts [exponent, result] or {exponent: x, result: y}
|
|
243
|
+
# Normalizes arrays to hash for better performance with large params
|
|
1096
244
|
def self.parse_power_params(value)
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
245
|
+
Helpers::ParameterParsingHelpers.parse_power_params(value)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Parse atan2 parameters
|
|
249
|
+
# Accepts [y, result] or {y: x, result: y}
|
|
250
|
+
# Normalizes arrays to hash for better performance with large params
|
|
251
|
+
def self.parse_atan2_params(value)
|
|
252
|
+
Helpers::ParameterParsingHelpers.parse_atan2_params(value)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Parse gcd/lcm parameters
|
|
256
|
+
# Accepts [other, result] or {other: x, result: y}
|
|
257
|
+
# Normalizes arrays to hash for better performance with large params
|
|
258
|
+
def self.parse_gcd_lcm_params(value)
|
|
259
|
+
Helpers::ParameterParsingHelpers.parse_gcd_lcm_params(value)
|
|
1106
260
|
end
|
|
1107
261
|
|
|
1108
262
|
# Parse date from string, Time, Date, or DateTime (with caching)
|
|
1109
263
|
def self.parse_date(value)
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
get_cached_date(value)
|
|
1115
|
-
end
|
|
1116
|
-
rescue ArgumentError
|
|
1117
|
-
nil
|
|
264
|
+
Helpers::DateHelpers.parse_date(
|
|
265
|
+
value,
|
|
266
|
+
get_cached_date: ->(date_string) { get_cached_date(date_string) }
|
|
267
|
+
)
|
|
1118
268
|
end
|
|
1119
269
|
|
|
1120
270
|
# Compare two dates with given operator
|
|
1121
271
|
# Optimized: Early return if values are already Time/Date objects
|
|
1122
272
|
def self.compare_dates(actual_value, expected_value, operator)
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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)
|
|
1131
|
-
actual_date = parse_date(actual_value)
|
|
1132
|
-
expected_date = parse_date(expected_value)
|
|
1133
|
-
|
|
1134
|
-
return false unless actual_date && expected_date
|
|
1135
|
-
|
|
1136
|
-
actual_date.send(operator, expected_date)
|
|
273
|
+
Helpers::DateHelpers.compare_dates(
|
|
274
|
+
actual_value, expected_value, operator,
|
|
275
|
+
parse_date: method(:parse_date)
|
|
276
|
+
)
|
|
1137
277
|
end
|
|
1138
278
|
|
|
1139
279
|
# Normalize day of week to 0-6 (Sunday=0)
|
|
1140
280
|
def self.normalize_day_of_week(value)
|
|
1141
|
-
|
|
1142
|
-
when Numeric
|
|
1143
|
-
value.to_i % 7
|
|
1144
|
-
when String
|
|
1145
|
-
day_map = {
|
|
1146
|
-
"sunday" => 0, "sun" => 0,
|
|
1147
|
-
"monday" => 1, "mon" => 1,
|
|
1148
|
-
"tuesday" => 2, "tue" => 2,
|
|
1149
|
-
"wednesday" => 3, "wed" => 3,
|
|
1150
|
-
"thursday" => 4, "thu" => 4,
|
|
1151
|
-
"friday" => 5, "fri" => 5,
|
|
1152
|
-
"saturday" => 6, "sat" => 6
|
|
1153
|
-
}
|
|
1154
|
-
day_map[value.downcase]
|
|
1155
|
-
end
|
|
281
|
+
Helpers::DateHelpers.normalize_day_of_week(value)
|
|
1156
282
|
end
|
|
1157
283
|
|
|
1158
284
|
# Parse coordinates from hash or array
|
|
1159
285
|
# Accepts {lat: y, lon: x}, {latitude: y, longitude: x}, or [lat, lon]
|
|
1160
286
|
def self.parse_coordinates(value)
|
|
1161
|
-
|
|
1162
|
-
when Hash
|
|
1163
|
-
lat = value["lat"] || value[:lat] || value["latitude"] || value[:latitude]
|
|
1164
|
-
lon = value["lon"] || value[:lon] || value["lng"] || value[:lng] ||
|
|
1165
|
-
value["longitude"] || value[:longitude]
|
|
1166
|
-
return nil unless lat && lon
|
|
1167
|
-
|
|
1168
|
-
{ lat: lat.to_f, lon: lon.to_f }
|
|
1169
|
-
when Array
|
|
1170
|
-
return nil unless value.size == 2
|
|
1171
|
-
|
|
1172
|
-
{ lat: value[0].to_f, lon: value[1].to_f }
|
|
1173
|
-
end
|
|
287
|
+
Helpers::GeospatialHelpers.parse_coordinates(value)
|
|
1174
288
|
end
|
|
1175
289
|
|
|
1176
290
|
# Parse radius parameters
|
|
1177
291
|
# expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
|
|
1178
292
|
def self.parse_radius_params(value)
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
return nil unless center_data && radius
|
|
1185
|
-
|
|
1186
|
-
center = parse_coordinates(center_data)
|
|
1187
|
-
return nil unless center
|
|
1188
|
-
|
|
1189
|
-
{ center: center, radius: radius.to_f }
|
|
293
|
+
Helpers::GeospatialHelpers.parse_radius_params(
|
|
294
|
+
value,
|
|
295
|
+
parse_coordinates: method(:parse_coordinates)
|
|
296
|
+
)
|
|
1190
297
|
end
|
|
1191
298
|
|
|
1192
299
|
# Parse polygon vertices
|
|
1193
300
|
# Accepts array of coordinate hashes or arrays
|
|
1194
301
|
def self.parse_polygon(value)
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
302
|
+
Helpers::GeospatialHelpers.parse_polygon(
|
|
303
|
+
value,
|
|
304
|
+
parse_coordinates: method(:parse_coordinates)
|
|
305
|
+
)
|
|
1198
306
|
end
|
|
1199
307
|
|
|
1200
308
|
# Calculate distance between two points using Haversine formula
|
|
1201
309
|
# Returns distance in kilometers
|
|
1202
310
|
def self.haversine_distance(point1, point2)
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
lat1_rad = (point1[:lat] * Math::PI) / 180
|
|
1206
|
-
lat2_rad = (point2[:lat] * Math::PI) / 180
|
|
1207
|
-
delta_lat = ((point2[:lat] - point1[:lat]) * Math::PI) / 180
|
|
1208
|
-
delta_lon = ((point2[:lon] - point1[:lon]) * Math::PI) / 180
|
|
1209
|
-
|
|
1210
|
-
a = (Math.sin(delta_lat / 2)**2) +
|
|
1211
|
-
(Math.cos(lat1_rad) * Math.cos(lat2_rad) *
|
|
1212
|
-
(Math.sin(delta_lon / 2)**2))
|
|
1213
|
-
|
|
1214
|
-
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
1215
|
-
|
|
1216
|
-
earth_radius_km * c
|
|
311
|
+
Helpers::GeospatialHelpers.haversine_distance(point1, point2)
|
|
1217
312
|
end
|
|
1218
313
|
|
|
1219
314
|
# Get cached distance between two points (with precision rounding for cache key)
|
|
1220
315
|
def self.get_cached_distance(point1, point2)
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
316
|
+
Helpers::CacheHelpers.get_cached_distance(
|
|
317
|
+
point1, point2,
|
|
318
|
+
geospatial_cache: @geospatial_cache,
|
|
319
|
+
geospatial_cache_mutex: @geospatial_cache_mutex,
|
|
320
|
+
haversine_distance: method(:haversine_distance)
|
|
321
|
+
)
|
|
1238
322
|
end
|
|
1239
323
|
|
|
1240
324
|
# Check if point is inside polygon using ray casting algorithm
|
|
1241
325
|
def self.point_in_polygon?(point, polygon)
|
|
1242
|
-
|
|
1243
|
-
y = point[:lat]
|
|
1244
|
-
inside = false
|
|
1245
|
-
|
|
1246
|
-
j = polygon.size - 1
|
|
1247
|
-
polygon.size.times do |i|
|
|
1248
|
-
xi = polygon[i][:lon]
|
|
1249
|
-
yi = polygon[i][:lat]
|
|
1250
|
-
xj = polygon[j][:lon]
|
|
1251
|
-
yj = polygon[j][:lat]
|
|
1252
|
-
|
|
1253
|
-
intersect = ((yi > y) != (yj > y)) &&
|
|
1254
|
-
(x < ((((xj - xi) * (y - yi)) / (yj - yi)) + xi))
|
|
1255
|
-
inside = !inside if intersect
|
|
1256
|
-
|
|
1257
|
-
j = i
|
|
1258
|
-
end
|
|
1259
|
-
|
|
1260
|
-
inside
|
|
326
|
+
Helpers::GeospatialHelpers.point_in_polygon?(point, polygon)
|
|
1261
327
|
end
|
|
1262
328
|
|
|
1263
329
|
# Helper methods for new operators
|
|
1264
330
|
|
|
1265
331
|
# Compare aggregation result with expected value (supports hash with comparison operators)
|
|
1266
|
-
#
|
|
332
|
+
# Delegates to Base utilities for consistency
|
|
1267
333
|
def self.compare_aggregation_result(actual, expected)
|
|
1268
|
-
|
|
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
|
|
334
|
+
Operators::Base.compare_aggregation_result(actual, expected)
|
|
1281
335
|
end
|
|
1282
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
1283
336
|
|
|
1284
337
|
# Parse percentile parameters
|
|
1285
338
|
def self.parse_percentile_params(value)
|
|
1286
|
-
|
|
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
|
-
}
|
|
339
|
+
Helpers::ParameterParsingHelpers.parse_percentile_params(value)
|
|
1300
340
|
end
|
|
1301
341
|
|
|
1302
342
|
# Compare percentile result
|
|
1303
343
|
def self.compare_percentile_result(actual, params)
|
|
1304
|
-
|
|
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
|
|
344
|
+
Helpers::ComparisonHelpers.compare_percentile_result(actual, params)
|
|
1312
345
|
end
|
|
1313
346
|
|
|
1314
347
|
# Parse duration parameters
|
|
1315
348
|
def self.parse_duration_params(value)
|
|
1316
|
-
|
|
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
|
-
}
|
|
349
|
+
Helpers::ParameterParsingHelpers.parse_duration_params(value)
|
|
1330
350
|
end
|
|
1331
351
|
|
|
1332
352
|
# Compare duration result
|
|
1333
353
|
def self.compare_duration_result(actual, params)
|
|
1334
|
-
|
|
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
|
|
354
|
+
Helpers::ComparisonHelpers.compare_duration_result(actual, params)
|
|
1342
355
|
end
|
|
1343
356
|
|
|
1344
357
|
# Parse date arithmetic parameters
|
|
1345
358
|
def self.parse_date_arithmetic_params(value, unit = :days)
|
|
1346
|
-
|
|
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
|
-
}
|
|
359
|
+
Helpers::ParameterParsingHelpers.parse_date_arithmetic_params(value, unit)
|
|
1361
360
|
end
|
|
1362
361
|
|
|
1363
362
|
# Compare date result
|
|
1364
363
|
def self.compare_date_result?(actual, target, params)
|
|
1365
|
-
|
|
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
|
|
364
|
+
Helpers::ComparisonHelpers.compare_date_result?(actual, target, params)
|
|
1393
365
|
end
|
|
1394
366
|
|
|
1395
367
|
# Compare numeric result (for time component extraction)
|
|
1396
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
1397
368
|
def self.compare_numeric_result(actual, expected)
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
|
369
|
+
return actual == expected unless expected.is_a?(Hash)
|
|
370
|
+
|
|
371
|
+
Helpers::ComparisonHelpers.compare_numeric_with_hash(actual, expected)
|
|
1411
372
|
end
|
|
1412
|
-
|
|
373
|
+
private_class_method :compare_numeric_result
|
|
1413
374
|
|
|
1414
375
|
# Compare rate result
|
|
1415
376
|
def self.compare_rate_result(actual, expected)
|
|
@@ -1418,97 +379,42 @@ module DecisionAgent
|
|
|
1418
379
|
|
|
1419
380
|
# Parse moving window parameters
|
|
1420
381
|
def self.parse_moving_window_params(value)
|
|
1421
|
-
|
|
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
|
-
}
|
|
382
|
+
Helpers::ParameterParsingHelpers.parse_moving_window_params(value)
|
|
1435
383
|
end
|
|
1436
384
|
|
|
1437
385
|
# Compare moving window result
|
|
1438
386
|
def self.compare_moving_window_result(actual, params)
|
|
1439
|
-
|
|
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
|
|
387
|
+
Helpers::ComparisonHelpers.compare_moving_window_result(actual, params)
|
|
1447
388
|
end
|
|
1448
389
|
|
|
1449
390
|
# Parse compound interest parameters
|
|
1450
391
|
def self.parse_compound_interest_params(value)
|
|
1451
|
-
|
|
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
|
-
}
|
|
392
|
+
Helpers::ParameterParsingHelpers.parse_compound_interest_params(value)
|
|
1465
393
|
end
|
|
1466
394
|
|
|
1467
395
|
# Parse present value parameters
|
|
1468
396
|
def self.parse_present_value_params(value)
|
|
1469
|
-
|
|
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
|
-
}
|
|
397
|
+
Helpers::ParameterParsingHelpers.parse_present_value_params(value)
|
|
1481
398
|
end
|
|
1482
399
|
|
|
1483
400
|
# Parse future value parameters
|
|
1484
401
|
def self.parse_future_value_params(value)
|
|
1485
|
-
|
|
402
|
+
Helpers::ParameterParsingHelpers.parse_future_value_params(value)
|
|
1486
403
|
end
|
|
1487
404
|
|
|
1488
405
|
# Parse payment parameters
|
|
1489
406
|
def self.parse_payment_params(value)
|
|
1490
|
-
|
|
407
|
+
Helpers::ParameterParsingHelpers.parse_payment_params(value)
|
|
1491
408
|
end
|
|
1492
409
|
|
|
1493
410
|
# Compare financial result
|
|
1494
411
|
def self.compare_financial_result(actual, params)
|
|
1495
|
-
|
|
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
|
|
412
|
+
Helpers::ComparisonHelpers.compare_financial_result(actual, params)
|
|
1500
413
|
end
|
|
1501
414
|
|
|
1502
415
|
# Parse join parameters
|
|
1503
416
|
def self.parse_join_params(value)
|
|
1504
|
-
|
|
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
|
-
}
|
|
417
|
+
Helpers::ParameterParsingHelpers.parse_join_params(value)
|
|
1512
418
|
end
|
|
1513
419
|
|
|
1514
420
|
# Compare length result
|
|
@@ -1520,112 +426,73 @@ module DecisionAgent
|
|
|
1520
426
|
|
|
1521
427
|
# Get or compile regex with caching
|
|
1522
428
|
def self.get_cached_regex(pattern)
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
# Slow path: compile and cache
|
|
1530
|
-
@regex_cache_mutex.synchronize do
|
|
1531
|
-
@regex_cache[pattern] ||= Regexp.new(pattern.to_s)
|
|
1532
|
-
end
|
|
429
|
+
Helpers::CacheHelpers.get_cached_regex(
|
|
430
|
+
pattern,
|
|
431
|
+
regex_cache: @regex_cache,
|
|
432
|
+
regex_cache_mutex: @regex_cache_mutex
|
|
433
|
+
)
|
|
1533
434
|
end
|
|
1534
435
|
|
|
1535
436
|
# Get cached split path
|
|
1536
437
|
def self.get_cached_path(key_path)
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
@path_cache_mutex.synchronize do
|
|
1543
|
-
@path_cache[key_path] ||= key_path.to_s.split(".").freeze
|
|
1544
|
-
end
|
|
438
|
+
Helpers::CacheHelpers.get_cached_path(
|
|
439
|
+
key_path,
|
|
440
|
+
path_cache: @path_cache,
|
|
441
|
+
path_cache_mutex: @path_cache_mutex
|
|
442
|
+
)
|
|
1545
443
|
end
|
|
1546
444
|
|
|
1547
445
|
# Get cached parsed date with fast-path for common formats
|
|
1548
446
|
def self.get_cached_date(date_string)
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
@date_cache[date_string] ||= parse_date_fast(date_string)
|
|
1556
|
-
end
|
|
447
|
+
Helpers::CacheHelpers.get_cached_date(
|
|
448
|
+
date_string,
|
|
449
|
+
date_cache: @date_cache,
|
|
450
|
+
date_cache_mutex: @date_cache_mutex,
|
|
451
|
+
parse_date_fast: ->(str) { Helpers::DateHelpers.parse_date_fast(str) }
|
|
452
|
+
)
|
|
1557
453
|
end
|
|
1558
454
|
|
|
1559
455
|
# Fast-path date parsing for common formats (ISO8601, etc.)
|
|
1560
456
|
# Falls back to Time.parse for other formats
|
|
1561
457
|
def self.parse_date_fast(date_string)
|
|
1562
|
-
|
|
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
|
|
458
|
+
Helpers::DateHelpers.parse_date_fast(date_string)
|
|
1588
459
|
end
|
|
1589
460
|
|
|
1590
461
|
# Clear all caches (useful for testing or memory management)
|
|
1591
462
|
def self.clear_caches!
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
463
|
+
Helpers::CacheHelpers.clear_caches!(
|
|
464
|
+
regex_cache: @regex_cache,
|
|
465
|
+
path_cache: @path_cache,
|
|
466
|
+
date_cache: @date_cache,
|
|
467
|
+
geospatial_cache: @geospatial_cache,
|
|
468
|
+
param_cache: @param_cache
|
|
469
|
+
)
|
|
1597
470
|
end
|
|
1598
471
|
|
|
1599
472
|
# Get cache statistics
|
|
1600
473
|
def self.cache_stats
|
|
474
|
+
stats = Helpers::CacheHelpers.cache_stats(
|
|
475
|
+
regex_cache: @regex_cache,
|
|
476
|
+
path_cache: @path_cache,
|
|
477
|
+
date_cache: @date_cache,
|
|
478
|
+
geospatial_cache: @geospatial_cache,
|
|
479
|
+
param_cache: @param_cache
|
|
480
|
+
)
|
|
1601
481
|
{
|
|
1602
|
-
regex_cache_size:
|
|
1603
|
-
path_cache_size:
|
|
1604
|
-
date_cache_size:
|
|
1605
|
-
geospatial_cache_size:
|
|
1606
|
-
param_cache_size:
|
|
482
|
+
regex_cache_size: stats[:regex],
|
|
483
|
+
path_cache_size: stats[:path],
|
|
484
|
+
date_cache_size: stats[:date],
|
|
485
|
+
geospatial_cache_size: stats[:geospatial],
|
|
486
|
+
param_cache_size: stats[:param]
|
|
1607
487
|
}
|
|
1608
488
|
end
|
|
1609
489
|
|
|
1610
490
|
# Normalize parameter value for cache key generation
|
|
1611
491
|
# Converts hash keys to symbols for consistency
|
|
492
|
+
# Delegates to Base utilities for consistency
|
|
1612
493
|
def self.normalize_param_cache_key(value, prefix)
|
|
1613
|
-
|
|
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
|
|
494
|
+
Operators::Base.normalize_param_cache_key(value, prefix)
|
|
1627
495
|
end
|
|
1628
|
-
# rubocop:enable Metrics/ClassLength
|
|
1629
496
|
end
|
|
1630
497
|
end
|
|
1631
498
|
end
|