decision_agent 0.2.0 → 1.0.1
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 +313 -8
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- 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 +819 -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 +984 -838
- data/lib/decision_agent/dsl/schema_validator.rb +53 -14
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -0
- 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 +52 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +86 -0
- data/lib/decision_agent/web/server.rb +1059 -23
- data/lib/decision_agent.rb +60 -2
- metadata +105 -61
- 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 -481
- 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 -550
- 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/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 -1633
- 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 -499
- data/spec/monitoring/monitored_agent_spec.rb +0 -222
- 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 -486
- 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 -482
- 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 -1840
|
@@ -26,15 +26,20 @@ module DecisionAgent
|
|
|
26
26
|
attr_reader :regex_cache, :path_cache, :date_cache, :geospatial_cache, :param_cache
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def self.evaluate(condition, context)
|
|
29
|
+
def self.evaluate(condition, context, enriched_context_hash: nil, trace_collector: nil)
|
|
30
30
|
return false unless condition.is_a?(Hash)
|
|
31
31
|
|
|
32
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
33
|
+
# This ensures all conditions in the same evaluation share the same enriched hash
|
|
34
|
+
enriched = enriched_context_hash
|
|
35
|
+
enriched ||= context.to_h.dup
|
|
36
|
+
|
|
32
37
|
if condition.key?("all")
|
|
33
|
-
evaluate_all(condition["all"], context)
|
|
38
|
+
evaluate_all(condition["all"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
|
|
34
39
|
elsif condition.key?("any")
|
|
35
|
-
evaluate_any(condition["any"], context)
|
|
40
|
+
evaluate_any(condition["any"], context, enriched_context_hash: enriched, trace_collector: trace_collector)
|
|
36
41
|
elsif condition.key?("field")
|
|
37
|
-
evaluate_field_condition(condition, context)
|
|
42
|
+
evaluate_field_condition(condition, context, enriched_context_hash: enriched, trace_collector: trace_collector)
|
|
38
43
|
else
|
|
39
44
|
false
|
|
40
45
|
end
|
|
@@ -42,952 +47,1054 @@ module DecisionAgent
|
|
|
42
47
|
|
|
43
48
|
# Evaluates 'all' condition - returns true only if ALL sub-conditions are true
|
|
44
49
|
# Empty array returns true (vacuous truth)
|
|
45
|
-
def self.evaluate_all(conditions, context)
|
|
50
|
+
def self.evaluate_all(conditions, context, enriched_context_hash: nil, trace_collector: nil)
|
|
46
51
|
return true if conditions.is_a?(Array) && conditions.empty?
|
|
47
52
|
return false unless conditions.is_a?(Array)
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
55
|
+
# All conditions share the same enriched hash so data enrichment persists
|
|
56
|
+
enriched = enriched_context_hash
|
|
57
|
+
enriched ||= context.to_h.dup
|
|
58
|
+
|
|
59
|
+
conditions.all? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
# Evaluates 'any' condition - returns true if AT LEAST ONE sub-condition is true
|
|
53
63
|
# Empty array returns false (no options to match)
|
|
54
|
-
def self.evaluate_any(conditions, context)
|
|
64
|
+
def self.evaluate_any(conditions, context, enriched_context_hash: nil, trace_collector: nil)
|
|
55
65
|
return false unless conditions.is_a?(Array)
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
68
|
+
# All conditions share the same enriched hash so data enrichment persists
|
|
69
|
+
enriched = enriched_context_hash
|
|
70
|
+
enriched ||= context.to_h.dup
|
|
71
|
+
|
|
72
|
+
conditions.any? { |cond| evaluate(cond, context, enriched_context_hash: enriched, trace_collector: trace_collector) }
|
|
58
73
|
end
|
|
59
74
|
|
|
60
75
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
61
|
-
def self.evaluate_field_condition(condition, context)
|
|
76
|
+
def self.evaluate_field_condition(condition, context, enriched_context_hash: nil, trace_collector: nil)
|
|
62
77
|
field = condition["field"]
|
|
63
78
|
op = condition["op"]
|
|
64
79
|
expected_value = condition["value"]
|
|
65
80
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
when "gt"
|
|
79
|
-
# Greater than - only for comparable types (numbers, strings)
|
|
80
|
-
comparable?(actual_value, expected_value) && actual_value > expected_value
|
|
81
|
-
|
|
82
|
-
when "gte"
|
|
83
|
-
# Greater than or equal - only for comparable types
|
|
84
|
-
comparable?(actual_value, expected_value) && actual_value >= expected_value
|
|
85
|
-
|
|
86
|
-
when "lt"
|
|
87
|
-
# Less than - only for comparable types
|
|
88
|
-
comparable?(actual_value, expected_value) && actual_value < expected_value
|
|
89
|
-
|
|
90
|
-
when "lte"
|
|
91
|
-
# Less than or equal - only for comparable types
|
|
92
|
-
comparable?(actual_value, expected_value) && actual_value <= expected_value
|
|
93
|
-
|
|
94
|
-
when "in"
|
|
95
|
-
# Array membership - checks if actual_value is in the expected array
|
|
96
|
-
Array(expected_value).include?(actual_value)
|
|
97
|
-
|
|
98
|
-
when "present"
|
|
99
|
-
# PRESENT SEMANTICS:
|
|
100
|
-
# Returns true if value exists AND is not empty
|
|
101
|
-
# - nil: false
|
|
102
|
-
# - Empty string "": false
|
|
103
|
-
# - Empty array []: false
|
|
104
|
-
# - Empty hash {}: false
|
|
105
|
-
# - Zero 0: true (zero is a valid value)
|
|
106
|
-
# - False boolean: true (false is a valid value)
|
|
107
|
-
# - Non-empty values: true
|
|
108
|
-
!actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
|
|
109
|
-
|
|
110
|
-
when "blank"
|
|
111
|
-
# BLANK SEMANTICS:
|
|
112
|
-
# Returns true if value is nil OR empty
|
|
113
|
-
# - nil: true
|
|
114
|
-
# - Empty string "": true
|
|
115
|
-
# - Empty array []: true
|
|
116
|
-
# - Empty hash {}: true
|
|
117
|
-
# - Zero 0: false (zero is a valid value)
|
|
118
|
-
# - False boolean: false (false is a valid value)
|
|
119
|
-
# - Non-empty values: false
|
|
120
|
-
actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
|
|
121
|
-
|
|
122
|
-
# STRING OPERATORS
|
|
123
|
-
when "contains"
|
|
124
|
-
# Checks if string contains substring (case-sensitive)
|
|
125
|
-
string_operator?(actual_value, expected_value) &&
|
|
126
|
-
actual_value.include?(expected_value)
|
|
127
|
-
|
|
128
|
-
when "starts_with"
|
|
129
|
-
# Checks if string starts with prefix (case-sensitive)
|
|
130
|
-
string_operator?(actual_value, expected_value) &&
|
|
131
|
-
actual_value.start_with?(expected_value)
|
|
132
|
-
|
|
133
|
-
when "ends_with"
|
|
134
|
-
# Checks if string ends with suffix (case-sensitive)
|
|
135
|
-
string_operator?(actual_value, expected_value) &&
|
|
136
|
-
actual_value.end_with?(expected_value)
|
|
137
|
-
|
|
138
|
-
when "matches"
|
|
139
|
-
# Matches string against regular expression
|
|
140
|
-
# expected_value can be a string (converted to regex) or Regexp object
|
|
141
|
-
return false unless actual_value.is_a?(String)
|
|
142
|
-
return false if expected_value.nil?
|
|
143
|
-
|
|
144
|
-
begin
|
|
145
|
-
regex = get_cached_regex(expected_value)
|
|
146
|
-
!regex.match(actual_value).nil?
|
|
147
|
-
rescue RegexpError
|
|
148
|
-
false
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# NUMERIC OPERATORS
|
|
152
|
-
when "between"
|
|
153
|
-
# Checks if numeric value is between min and max (inclusive)
|
|
154
|
-
# expected_value should be [min, max] or {min: x, max: y}
|
|
155
|
-
return false unless actual_value.is_a?(Numeric)
|
|
156
|
-
|
|
157
|
-
range = parse_range(expected_value)
|
|
158
|
-
return false unless range
|
|
159
|
-
|
|
160
|
-
actual_value.between?(range[:min], range[:max])
|
|
161
|
-
|
|
162
|
-
when "modulo"
|
|
163
|
-
# Checks if value modulo divisor equals remainder
|
|
164
|
-
# expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
|
|
165
|
-
return false unless actual_value.is_a?(Numeric)
|
|
166
|
-
|
|
167
|
-
params = parse_modulo_params(expected_value)
|
|
168
|
-
return false unless params
|
|
169
|
-
|
|
170
|
-
(actual_value % params[:divisor]) == params[:remainder]
|
|
171
|
-
|
|
172
|
-
# MATHEMATICAL FUNCTIONS
|
|
173
|
-
# Trigonometric functions
|
|
174
|
-
when "sin"
|
|
175
|
-
# Checks if sin(field_value) equals expected_value
|
|
176
|
-
# expected_value is the expected result of sin(actual_value)
|
|
177
|
-
return false unless actual_value.is_a?(Numeric)
|
|
178
|
-
return false unless expected_value.is_a?(Numeric)
|
|
179
|
-
|
|
180
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
181
|
-
result = Math.sin(actual_value)
|
|
182
|
-
(result - expected_value).abs < 1e-10
|
|
183
|
-
|
|
184
|
-
when "cos"
|
|
185
|
-
# Checks if cos(field_value) equals expected_value
|
|
186
|
-
# expected_value is the expected result of cos(actual_value)
|
|
187
|
-
return false unless actual_value.is_a?(Numeric)
|
|
188
|
-
return false unless expected_value.is_a?(Numeric)
|
|
189
|
-
|
|
190
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
191
|
-
result = Math.cos(actual_value)
|
|
192
|
-
(result - expected_value).abs < 1e-10
|
|
193
|
-
|
|
194
|
-
when "tan"
|
|
195
|
-
# Checks if tan(field_value) equals expected_value
|
|
196
|
-
# expected_value is the expected result of tan(actual_value)
|
|
197
|
-
return false unless actual_value.is_a?(Numeric)
|
|
198
|
-
return false unless expected_value.is_a?(Numeric)
|
|
199
|
-
|
|
200
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
201
|
-
result = Math.tan(actual_value)
|
|
202
|
-
(result - expected_value).abs < 1e-10
|
|
203
|
-
|
|
204
|
-
# Exponential and logarithmic functions
|
|
205
|
-
when "sqrt"
|
|
206
|
-
# Checks if sqrt(field_value) equals expected_value
|
|
207
|
-
# expected_value is the expected result of sqrt(actual_value)
|
|
208
|
-
return false unless actual_value.is_a?(Numeric)
|
|
209
|
-
return false unless expected_value.is_a?(Numeric)
|
|
210
|
-
return false if actual_value.negative? # sqrt of negative number is invalid
|
|
211
|
-
|
|
212
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
213
|
-
result = Math.sqrt(actual_value)
|
|
214
|
-
(result - expected_value).abs < 1e-10
|
|
215
|
-
|
|
216
|
-
when "power"
|
|
217
|
-
# Checks if power(field_value, exponent) equals result
|
|
218
|
-
# expected_value should be [exponent, result] or {exponent: x, result: y}
|
|
219
|
-
return false unless actual_value.is_a?(Numeric)
|
|
220
|
-
|
|
221
|
-
params = parse_power_params(expected_value)
|
|
222
|
-
return false unless params
|
|
223
|
-
|
|
224
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
225
|
-
result = actual_value**params[:exponent]
|
|
226
|
-
(result - params[:result]).abs < 1e-10
|
|
227
|
-
|
|
228
|
-
when "exp"
|
|
229
|
-
# Checks if exp(field_value) equals expected_value
|
|
230
|
-
# expected_value is the expected result of exp(actual_value) (e^actual_value)
|
|
231
|
-
return false unless actual_value.is_a?(Numeric)
|
|
232
|
-
return false unless expected_value.is_a?(Numeric)
|
|
233
|
-
|
|
234
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
235
|
-
result = Math.exp(actual_value)
|
|
236
|
-
(result - expected_value).abs < 1e-10
|
|
237
|
-
|
|
238
|
-
when "log"
|
|
239
|
-
# Checks if log(field_value) equals expected_value
|
|
240
|
-
# expected_value is the expected result of log(actual_value) (natural logarithm)
|
|
241
|
-
return false unless actual_value.is_a?(Numeric)
|
|
242
|
-
return false unless expected_value.is_a?(Numeric)
|
|
243
|
-
return false if actual_value <= 0 # log of non-positive number is invalid
|
|
244
|
-
|
|
245
|
-
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
246
|
-
result = Math.log(actual_value)
|
|
247
|
-
(result - expected_value).abs < 1e-10
|
|
248
|
-
|
|
249
|
-
# Rounding and absolute value functions
|
|
250
|
-
when "round"
|
|
251
|
-
# Checks if round(field_value) equals expected_value
|
|
252
|
-
# expected_value is the expected result of round(actual_value)
|
|
253
|
-
return false unless actual_value.is_a?(Numeric)
|
|
254
|
-
return false unless expected_value.is_a?(Numeric)
|
|
255
|
-
|
|
256
|
-
actual_value.round == expected_value
|
|
257
|
-
|
|
258
|
-
when "floor"
|
|
259
|
-
# Checks if floor(field_value) equals expected_value
|
|
260
|
-
# expected_value is the expected result of floor(actual_value)
|
|
261
|
-
return false unless actual_value.is_a?(Numeric)
|
|
262
|
-
return false unless expected_value.is_a?(Numeric)
|
|
263
|
-
|
|
264
|
-
actual_value.floor == expected_value
|
|
265
|
-
|
|
266
|
-
when "ceil"
|
|
267
|
-
# Checks if ceil(field_value) equals expected_value
|
|
268
|
-
# expected_value is the expected result of ceil(actual_value)
|
|
269
|
-
return false unless actual_value.is_a?(Numeric)
|
|
270
|
-
return false unless expected_value.is_a?(Numeric)
|
|
271
|
-
|
|
272
|
-
actual_value.ceil == expected_value
|
|
273
|
-
|
|
274
|
-
when "abs"
|
|
275
|
-
# Checks if abs(field_value) equals expected_value
|
|
276
|
-
# expected_value is the expected result of abs(actual_value)
|
|
277
|
-
return false unless actual_value.is_a?(Numeric)
|
|
278
|
-
return false unless expected_value.is_a?(Numeric)
|
|
279
|
-
|
|
280
|
-
actual_value.abs == expected_value
|
|
281
|
-
|
|
282
|
-
# Aggregation functions
|
|
283
|
-
when "min"
|
|
284
|
-
# Checks if min(field_value) equals expected_value
|
|
285
|
-
# field_value should be an array, expected_value is the minimum value
|
|
286
|
-
return false unless actual_value.is_a?(Array)
|
|
287
|
-
return false if actual_value.empty?
|
|
288
|
-
return false unless expected_value.is_a?(Numeric)
|
|
289
|
-
|
|
290
|
-
actual_value.min == expected_value
|
|
291
|
-
|
|
292
|
-
when "max"
|
|
293
|
-
# Checks if max(field_value) equals expected_value
|
|
294
|
-
# field_value should be an array, expected_value is the maximum value
|
|
295
|
-
return false unless actual_value.is_a?(Array)
|
|
296
|
-
return false if actual_value.empty?
|
|
297
|
-
return false unless expected_value.is_a?(Numeric)
|
|
298
|
-
|
|
299
|
-
actual_value.max == expected_value
|
|
300
|
-
|
|
301
|
-
# STATISTICAL AGGREGATIONS
|
|
302
|
-
when "sum"
|
|
303
|
-
# Checks if sum of numeric array equals expected_value
|
|
304
|
-
# expected_value can be numeric or hash with comparison operators
|
|
305
|
-
return false unless actual_value.is_a?(Array)
|
|
306
|
-
return false if actual_value.empty?
|
|
307
|
-
|
|
308
|
-
# OPTIMIZE: calculate sum in single pass, filtering as we go
|
|
309
|
-
sum_value = 0.0
|
|
310
|
-
found_numeric = false
|
|
311
|
-
actual_value.each do |v|
|
|
312
|
-
if v.is_a?(Numeric)
|
|
313
|
-
sum_value += v
|
|
314
|
-
found_numeric = true
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
|
-
return false unless found_numeric
|
|
318
|
-
|
|
319
|
-
compare_aggregation_result(sum_value, expected_value)
|
|
320
|
-
|
|
321
|
-
when "average", "mean"
|
|
322
|
-
# Checks if average of numeric array equals expected_value
|
|
323
|
-
return false unless actual_value.is_a?(Array)
|
|
324
|
-
return false if actual_value.empty?
|
|
325
|
-
|
|
326
|
-
# OPTIMIZE: calculate sum and count in single pass
|
|
327
|
-
sum_value = 0.0
|
|
328
|
-
count = 0
|
|
329
|
-
actual_value.each do |v|
|
|
330
|
-
if v.is_a?(Numeric)
|
|
331
|
-
sum_value += v
|
|
332
|
-
count += 1
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
return false if count.zero?
|
|
336
|
-
|
|
337
|
-
avg_value = sum_value / count
|
|
338
|
-
compare_aggregation_result(avg_value, expected_value)
|
|
339
|
-
|
|
340
|
-
when "median"
|
|
341
|
-
# Checks if median of numeric array equals expected_value
|
|
342
|
-
return false unless actual_value.is_a?(Array)
|
|
343
|
-
return false if actual_value.empty?
|
|
344
|
-
|
|
345
|
-
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
|
|
346
|
-
return false if numeric_array.empty?
|
|
347
|
-
|
|
348
|
-
median_value = if numeric_array.size.odd?
|
|
349
|
-
numeric_array[numeric_array.size / 2]
|
|
350
|
-
else
|
|
351
|
-
(numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
|
|
352
|
-
end
|
|
353
|
-
compare_aggregation_result(median_value, expected_value)
|
|
354
|
-
|
|
355
|
-
when "stddev", "standard_deviation"
|
|
356
|
-
# Checks if standard deviation of numeric array equals expected_value
|
|
357
|
-
return false unless actual_value.is_a?(Array)
|
|
358
|
-
return false if actual_value.size < 2
|
|
359
|
-
|
|
360
|
-
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
361
|
-
return false if numeric_array.size < 2
|
|
362
|
-
|
|
363
|
-
mean = numeric_array.sum.to_f / numeric_array.size
|
|
364
|
-
variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
|
|
365
|
-
stddev_value = Math.sqrt(variance)
|
|
366
|
-
compare_aggregation_result(stddev_value, expected_value)
|
|
367
|
-
|
|
368
|
-
when "variance"
|
|
369
|
-
# Checks if variance of numeric array equals expected_value
|
|
370
|
-
return false unless actual_value.is_a?(Array)
|
|
371
|
-
return false if actual_value.size < 2
|
|
372
|
-
|
|
373
|
-
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
374
|
-
return false if numeric_array.size < 2
|
|
375
|
-
|
|
376
|
-
mean = numeric_array.sum.to_f / numeric_array.size
|
|
377
|
-
variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
|
|
378
|
-
compare_aggregation_result(variance_value, expected_value)
|
|
379
|
-
|
|
380
|
-
when "percentile"
|
|
381
|
-
# Checks if Nth percentile of numeric array meets threshold
|
|
382
|
-
# expected_value: {percentile: 95, threshold: 200} or {percentile: 95, gt: 200, lt: 500}
|
|
383
|
-
return false unless actual_value.is_a?(Array)
|
|
384
|
-
return false if actual_value.empty?
|
|
385
|
-
|
|
386
|
-
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
|
|
387
|
-
return false if numeric_array.empty?
|
|
388
|
-
|
|
389
|
-
params = parse_percentile_params(expected_value)
|
|
390
|
-
return false unless params
|
|
391
|
-
|
|
392
|
-
percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
|
|
393
|
-
percentile_value = if percentile_index == percentile_index.to_i
|
|
394
|
-
numeric_array[percentile_index.to_i]
|
|
395
|
-
else
|
|
396
|
-
lower = numeric_array[percentile_index.floor]
|
|
397
|
-
upper = numeric_array[percentile_index.ceil]
|
|
398
|
-
lower + ((upper - lower) * (percentile_index - percentile_index.floor))
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
compare_percentile_result(percentile_value, params)
|
|
402
|
-
|
|
403
|
-
when "count"
|
|
404
|
-
# Checks if count of array elements meets threshold
|
|
405
|
-
# expected_value can be numeric or hash with comparison operators
|
|
406
|
-
return false unless actual_value.is_a?(Array)
|
|
407
|
-
|
|
408
|
-
count_value = actual_value.size
|
|
409
|
-
compare_aggregation_result(count_value, expected_value)
|
|
410
|
-
|
|
411
|
-
# DATE/TIME OPERATORS
|
|
412
|
-
when "before_date"
|
|
413
|
-
# Checks if date is before specified date
|
|
414
|
-
compare_dates(actual_value, expected_value, :<)
|
|
415
|
-
|
|
416
|
-
when "after_date"
|
|
417
|
-
# Checks if date is after specified date
|
|
418
|
-
compare_dates(actual_value, expected_value, :>)
|
|
419
|
-
|
|
420
|
-
when "within_days"
|
|
421
|
-
# Checks if date is within N days from now (past or future)
|
|
422
|
-
# expected_value is number of days
|
|
423
|
-
return false unless actual_value
|
|
424
|
-
return false unless expected_value.is_a?(Numeric)
|
|
425
|
-
|
|
426
|
-
date = parse_date(actual_value)
|
|
427
|
-
return false unless date
|
|
428
|
-
|
|
429
|
-
now = Time.now
|
|
430
|
-
diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
|
|
431
|
-
diff_days <= expected_value
|
|
432
|
-
|
|
433
|
-
when "day_of_week"
|
|
434
|
-
# Checks if date falls on specified day of week
|
|
435
|
-
# expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
|
|
436
|
-
return false unless actual_value
|
|
437
|
-
|
|
438
|
-
date = parse_date(actual_value)
|
|
439
|
-
return false unless date
|
|
440
|
-
|
|
441
|
-
expected_day = normalize_day_of_week(expected_value)
|
|
442
|
-
return false unless expected_day
|
|
443
|
-
|
|
444
|
-
date.wday == expected_day
|
|
445
|
-
|
|
446
|
-
# DURATION CALCULATIONS
|
|
447
|
-
when "duration_seconds"
|
|
448
|
-
# Calculates duration between two dates in seconds
|
|
449
|
-
# expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
|
|
450
|
-
return false unless actual_value
|
|
451
|
-
|
|
452
|
-
start_date = parse_date(actual_value)
|
|
453
|
-
return false unless start_date
|
|
454
|
-
|
|
455
|
-
params = parse_duration_params(expected_value)
|
|
456
|
-
return false unless params
|
|
457
|
-
|
|
458
|
-
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
459
|
-
return false unless end_date
|
|
460
|
-
|
|
461
|
-
duration = (end_date - start_date).abs
|
|
462
|
-
compare_duration_result(duration, params)
|
|
463
|
-
|
|
464
|
-
when "duration_minutes"
|
|
465
|
-
# Calculates duration between two dates in minutes
|
|
466
|
-
return false unless actual_value
|
|
467
|
-
|
|
468
|
-
start_date = parse_date(actual_value)
|
|
469
|
-
return false unless start_date
|
|
470
|
-
|
|
471
|
-
params = parse_duration_params(expected_value)
|
|
472
|
-
return false unless params
|
|
473
|
-
|
|
474
|
-
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
475
|
-
return false unless end_date
|
|
476
|
-
|
|
477
|
-
duration = ((end_date - start_date).abs / 60.0)
|
|
478
|
-
compare_duration_result(duration, params)
|
|
479
|
-
|
|
480
|
-
when "duration_hours"
|
|
481
|
-
# Calculates duration between two dates in hours
|
|
482
|
-
return false unless actual_value
|
|
483
|
-
|
|
484
|
-
start_date = parse_date(actual_value)
|
|
485
|
-
return false unless start_date
|
|
486
|
-
|
|
487
|
-
params = parse_duration_params(expected_value)
|
|
488
|
-
return false unless params
|
|
489
|
-
|
|
490
|
-
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
491
|
-
return false unless end_date
|
|
492
|
-
|
|
493
|
-
duration = ((end_date - start_date).abs / 3600.0)
|
|
494
|
-
compare_duration_result(duration, params)
|
|
495
|
-
|
|
496
|
-
when "duration_days"
|
|
497
|
-
# Calculates duration between two dates in days
|
|
498
|
-
return false unless actual_value
|
|
81
|
+
# Special handling for "don't care" conditions (from DMN "-" entries)
|
|
82
|
+
if field == "__always_match__" && op == "eq" && expected_value == true
|
|
83
|
+
trace_collector&.add_trace(Explainability::ConditionTrace.new(
|
|
84
|
+
field: field,
|
|
85
|
+
operator: op,
|
|
86
|
+
expected_value: expected_value,
|
|
87
|
+
actual_value: true,
|
|
88
|
+
result: true
|
|
89
|
+
))
|
|
90
|
+
return true
|
|
91
|
+
end
|
|
499
92
|
|
|
500
|
-
|
|
501
|
-
|
|
93
|
+
# Use enriched context hash if provided, otherwise create mutable copy
|
|
94
|
+
# This ensures all conditions in the same evaluation share the same enriched hash
|
|
95
|
+
context_hash = enriched_context_hash || context.to_h.dup
|
|
96
|
+
actual_value = get_nested_value(context_hash, field)
|
|
502
97
|
|
|
503
|
-
|
|
504
|
-
|
|
98
|
+
result = case op
|
|
99
|
+
when "eq"
|
|
100
|
+
# Equality - uses Ruby's == for comparison
|
|
101
|
+
actual_value == expected_value
|
|
102
|
+
|
|
103
|
+
when "neq"
|
|
104
|
+
# Not equal - inverse of ==
|
|
105
|
+
actual_value != expected_value
|
|
106
|
+
|
|
107
|
+
when "gt"
|
|
108
|
+
# Greater than - only for comparable types (numbers, strings)
|
|
109
|
+
comparable?(actual_value, expected_value) && actual_value > expected_value
|
|
110
|
+
|
|
111
|
+
when "gte"
|
|
112
|
+
# Greater than or equal - only for comparable types
|
|
113
|
+
comparable?(actual_value, expected_value) && actual_value >= expected_value
|
|
114
|
+
|
|
115
|
+
when "lt"
|
|
116
|
+
# Less than - only for comparable types
|
|
117
|
+
comparable?(actual_value, expected_value) && actual_value < expected_value
|
|
118
|
+
|
|
119
|
+
when "lte"
|
|
120
|
+
# Less than or equal - only for comparable types
|
|
121
|
+
comparable?(actual_value, expected_value) && actual_value <= expected_value
|
|
122
|
+
|
|
123
|
+
when "in"
|
|
124
|
+
# Array membership - checks if actual_value is in the expected array
|
|
125
|
+
Array(expected_value).include?(actual_value)
|
|
126
|
+
|
|
127
|
+
when "present"
|
|
128
|
+
# PRESENT SEMANTICS:
|
|
129
|
+
# Returns true if value exists AND is not empty
|
|
130
|
+
# - nil: false
|
|
131
|
+
# - Empty string "": false
|
|
132
|
+
# - Empty array []: false
|
|
133
|
+
# - Empty hash {}: false
|
|
134
|
+
# - Zero 0: true (zero is a valid value)
|
|
135
|
+
# - False boolean: true (false is a valid value)
|
|
136
|
+
# - Non-empty values: true
|
|
137
|
+
!actual_value.nil? && (actual_value.respond_to?(:empty?) ? !actual_value.empty? : true)
|
|
138
|
+
|
|
139
|
+
when "blank"
|
|
140
|
+
# BLANK SEMANTICS:
|
|
141
|
+
# Returns true if value is nil OR empty
|
|
142
|
+
# - nil: true
|
|
143
|
+
# - Empty string "": true
|
|
144
|
+
# - Empty array []: true
|
|
145
|
+
# - Empty hash {}: true
|
|
146
|
+
# - Zero 0: false (zero is a valid value)
|
|
147
|
+
# - False boolean: false (false is a valid value)
|
|
148
|
+
# - Non-empty values: false
|
|
149
|
+
actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
|
|
150
|
+
|
|
151
|
+
# STRING OPERATORS
|
|
152
|
+
when "contains"
|
|
153
|
+
# Checks if string contains substring (case-sensitive)
|
|
154
|
+
string_operator?(actual_value, expected_value) &&
|
|
155
|
+
actual_value.include?(expected_value)
|
|
156
|
+
|
|
157
|
+
when "starts_with"
|
|
158
|
+
# Checks if string starts with prefix (case-sensitive)
|
|
159
|
+
string_operator?(actual_value, expected_value) &&
|
|
160
|
+
actual_value.start_with?(expected_value)
|
|
161
|
+
|
|
162
|
+
when "ends_with"
|
|
163
|
+
# Checks if string ends with suffix (case-sensitive)
|
|
164
|
+
string_operator?(actual_value, expected_value) &&
|
|
165
|
+
actual_value.end_with?(expected_value)
|
|
166
|
+
|
|
167
|
+
when "matches"
|
|
168
|
+
# Matches string against regular expression
|
|
169
|
+
# expected_value can be a string (converted to regex) or Regexp object
|
|
170
|
+
if !actual_value.is_a?(String) || expected_value.nil?
|
|
171
|
+
false
|
|
172
|
+
else
|
|
173
|
+
begin
|
|
174
|
+
regex = get_cached_regex(expected_value)
|
|
175
|
+
!regex.match(actual_value).nil?
|
|
176
|
+
rescue RegexpError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# NUMERIC OPERATORS
|
|
182
|
+
when "between"
|
|
183
|
+
# Checks if numeric value is between min and max (inclusive)
|
|
184
|
+
# expected_value should be [min, max] or {min: x, max: y}
|
|
185
|
+
if actual_value.is_a?(Numeric)
|
|
186
|
+
range = parse_range(expected_value)
|
|
187
|
+
range ? actual_value.between?(range[:min], range[:max]) : false
|
|
188
|
+
else
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
when "modulo"
|
|
193
|
+
# Checks if value modulo divisor equals remainder
|
|
194
|
+
# expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
|
|
195
|
+
if actual_value.is_a?(Numeric)
|
|
196
|
+
params = parse_modulo_params(expected_value)
|
|
197
|
+
params ? (actual_value % params[:divisor]) == params[:remainder] : false
|
|
198
|
+
else
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# MATHEMATICAL FUNCTIONS
|
|
203
|
+
# Trigonometric functions
|
|
204
|
+
when "sin"
|
|
205
|
+
# Checks if sin(field_value) equals expected_value
|
|
206
|
+
# expected_value is the expected result of sin(actual_value)
|
|
207
|
+
return false unless actual_value.is_a?(Numeric)
|
|
208
|
+
return false unless expected_value.is_a?(Numeric)
|
|
209
|
+
|
|
210
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
211
|
+
result = Math.sin(actual_value)
|
|
212
|
+
(result - expected_value).abs < 1e-10
|
|
213
|
+
|
|
214
|
+
when "cos"
|
|
215
|
+
# Checks if cos(field_value) equals expected_value
|
|
216
|
+
# expected_value is the expected result of cos(actual_value)
|
|
217
|
+
return false unless actual_value.is_a?(Numeric)
|
|
218
|
+
return false unless expected_value.is_a?(Numeric)
|
|
219
|
+
|
|
220
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
221
|
+
result = Math.cos(actual_value)
|
|
222
|
+
(result - expected_value).abs < 1e-10
|
|
223
|
+
|
|
224
|
+
when "tan"
|
|
225
|
+
# Checks if tan(field_value) equals expected_value
|
|
226
|
+
# expected_value is the expected result of tan(actual_value)
|
|
227
|
+
return false unless actual_value.is_a?(Numeric)
|
|
228
|
+
return false unless expected_value.is_a?(Numeric)
|
|
229
|
+
|
|
230
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
231
|
+
result = Math.tan(actual_value)
|
|
232
|
+
(result - expected_value).abs < 1e-10
|
|
233
|
+
|
|
234
|
+
# Exponential and logarithmic functions
|
|
235
|
+
when "sqrt"
|
|
236
|
+
# Checks if sqrt(field_value) equals expected_value
|
|
237
|
+
# expected_value is the expected result of sqrt(actual_value)
|
|
238
|
+
return false unless actual_value.is_a?(Numeric)
|
|
239
|
+
return false unless expected_value.is_a?(Numeric)
|
|
240
|
+
return false if actual_value.negative? # sqrt of negative number is invalid
|
|
241
|
+
|
|
242
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
243
|
+
result = Math.sqrt(actual_value)
|
|
244
|
+
(result - expected_value).abs < 1e-10
|
|
245
|
+
|
|
246
|
+
when "power"
|
|
247
|
+
# Checks if power(field_value, exponent) equals result
|
|
248
|
+
# expected_value should be [exponent, result] or {exponent: x, result: y}
|
|
249
|
+
return false unless actual_value.is_a?(Numeric)
|
|
250
|
+
|
|
251
|
+
params = parse_power_params(expected_value)
|
|
252
|
+
return false unless params
|
|
253
|
+
|
|
254
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
255
|
+
result = actual_value**params[:exponent]
|
|
256
|
+
(result - params[:result]).abs < 1e-10
|
|
257
|
+
|
|
258
|
+
when "exp"
|
|
259
|
+
# Checks if exp(field_value) equals expected_value
|
|
260
|
+
# expected_value is the expected result of exp(actual_value) (e^actual_value)
|
|
261
|
+
return false unless actual_value.is_a?(Numeric)
|
|
262
|
+
return false unless expected_value.is_a?(Numeric)
|
|
263
|
+
|
|
264
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
265
|
+
result = Math.exp(actual_value)
|
|
266
|
+
(result - expected_value).abs < 1e-10
|
|
267
|
+
|
|
268
|
+
when "log"
|
|
269
|
+
# Checks if log(field_value) equals expected_value
|
|
270
|
+
# expected_value is the expected result of log(actual_value) (natural logarithm)
|
|
271
|
+
return false unless actual_value.is_a?(Numeric)
|
|
272
|
+
return false unless expected_value.is_a?(Numeric)
|
|
273
|
+
return false if actual_value <= 0 # log of non-positive number is invalid
|
|
274
|
+
|
|
275
|
+
# OPTIMIZE: Use epsilon comparison instead of round for better performance
|
|
276
|
+
result = Math.log(actual_value)
|
|
277
|
+
(result - expected_value).abs < 1e-10
|
|
278
|
+
|
|
279
|
+
# Rounding and absolute value functions
|
|
280
|
+
when "round"
|
|
281
|
+
# Checks if round(field_value) equals expected_value
|
|
282
|
+
# expected_value is the expected result of round(actual_value)
|
|
283
|
+
return false unless actual_value.is_a?(Numeric)
|
|
284
|
+
return false unless expected_value.is_a?(Numeric)
|
|
285
|
+
|
|
286
|
+
actual_value.round == expected_value
|
|
287
|
+
|
|
288
|
+
when "floor"
|
|
289
|
+
# Checks if floor(field_value) equals expected_value
|
|
290
|
+
# expected_value is the expected result of floor(actual_value)
|
|
291
|
+
return false unless actual_value.is_a?(Numeric)
|
|
292
|
+
return false unless expected_value.is_a?(Numeric)
|
|
293
|
+
|
|
294
|
+
actual_value.floor == expected_value
|
|
295
|
+
|
|
296
|
+
when "ceil"
|
|
297
|
+
# Checks if ceil(field_value) equals expected_value
|
|
298
|
+
# expected_value is the expected result of ceil(actual_value)
|
|
299
|
+
return false unless actual_value.is_a?(Numeric)
|
|
300
|
+
return false unless expected_value.is_a?(Numeric)
|
|
301
|
+
|
|
302
|
+
actual_value.ceil == expected_value
|
|
303
|
+
|
|
304
|
+
when "abs"
|
|
305
|
+
# Checks if abs(field_value) equals expected_value
|
|
306
|
+
# expected_value is the expected result of abs(actual_value)
|
|
307
|
+
return false unless actual_value.is_a?(Numeric)
|
|
308
|
+
return false unless expected_value.is_a?(Numeric)
|
|
309
|
+
|
|
310
|
+
actual_value.abs == expected_value
|
|
311
|
+
|
|
312
|
+
# Aggregation functions
|
|
313
|
+
when "min"
|
|
314
|
+
# Checks if min(field_value) equals expected_value
|
|
315
|
+
# field_value should be an array, expected_value is the minimum value
|
|
316
|
+
return false unless actual_value.is_a?(Array)
|
|
317
|
+
return false if actual_value.empty?
|
|
318
|
+
return false unless expected_value.is_a?(Numeric)
|
|
319
|
+
|
|
320
|
+
actual_value.min == expected_value
|
|
321
|
+
|
|
322
|
+
when "max"
|
|
323
|
+
# Checks if max(field_value) equals expected_value
|
|
324
|
+
# field_value should be an array, expected_value is the maximum value
|
|
325
|
+
return false unless actual_value.is_a?(Array)
|
|
326
|
+
return false if actual_value.empty?
|
|
327
|
+
return false unless expected_value.is_a?(Numeric)
|
|
328
|
+
|
|
329
|
+
actual_value.max == expected_value
|
|
330
|
+
|
|
331
|
+
# STATISTICAL AGGREGATIONS
|
|
332
|
+
when "sum"
|
|
333
|
+
# Checks if sum of numeric array equals expected_value
|
|
334
|
+
# expected_value can be numeric or hash with comparison operators
|
|
335
|
+
return false unless actual_value.is_a?(Array)
|
|
336
|
+
return false if actual_value.empty?
|
|
337
|
+
|
|
338
|
+
# OPTIMIZE: calculate sum in single pass, filtering as we go
|
|
339
|
+
sum_value = 0.0
|
|
340
|
+
found_numeric = false
|
|
341
|
+
actual_value.each do |v|
|
|
342
|
+
if v.is_a?(Numeric)
|
|
343
|
+
sum_value += v
|
|
344
|
+
found_numeric = true
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
return false unless found_numeric
|
|
348
|
+
|
|
349
|
+
compare_aggregation_result(sum_value, expected_value)
|
|
350
|
+
|
|
351
|
+
when "average", "mean"
|
|
352
|
+
# Checks if average of numeric array equals expected_value
|
|
353
|
+
return false unless actual_value.is_a?(Array)
|
|
354
|
+
return false if actual_value.empty?
|
|
355
|
+
|
|
356
|
+
# OPTIMIZE: calculate sum and count in single pass
|
|
357
|
+
sum_value = 0.0
|
|
358
|
+
count = 0
|
|
359
|
+
actual_value.each do |v|
|
|
360
|
+
if v.is_a?(Numeric)
|
|
361
|
+
sum_value += v
|
|
362
|
+
count += 1
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
return false if count.zero?
|
|
366
|
+
|
|
367
|
+
avg_value = sum_value / count
|
|
368
|
+
compare_aggregation_result(avg_value, expected_value)
|
|
369
|
+
|
|
370
|
+
when "median"
|
|
371
|
+
# Checks if median of numeric array equals expected_value
|
|
372
|
+
return false unless actual_value.is_a?(Array)
|
|
373
|
+
return false if actual_value.empty?
|
|
374
|
+
|
|
375
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
|
|
376
|
+
return false if numeric_array.empty?
|
|
377
|
+
|
|
378
|
+
median_value = if numeric_array.size.odd?
|
|
379
|
+
numeric_array[numeric_array.size / 2]
|
|
380
|
+
else
|
|
381
|
+
(numeric_array[(numeric_array.size / 2) - 1] + numeric_array[numeric_array.size / 2]) / 2.0
|
|
382
|
+
end
|
|
383
|
+
compare_aggregation_result(median_value, expected_value)
|
|
384
|
+
|
|
385
|
+
when "stddev", "standard_deviation"
|
|
386
|
+
# Checks if standard deviation of numeric array equals expected_value
|
|
387
|
+
return false unless actual_value.is_a?(Array)
|
|
388
|
+
return false if actual_value.size < 2
|
|
389
|
+
|
|
390
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
391
|
+
return false if numeric_array.size < 2
|
|
392
|
+
|
|
393
|
+
mean = numeric_array.sum.to_f / numeric_array.size
|
|
394
|
+
variance = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
|
|
395
|
+
stddev_value = Math.sqrt(variance)
|
|
396
|
+
compare_aggregation_result(stddev_value, expected_value)
|
|
397
|
+
|
|
398
|
+
when "variance"
|
|
399
|
+
# Checks if variance of numeric array equals expected_value
|
|
400
|
+
return false unless actual_value.is_a?(Array)
|
|
401
|
+
return false if actual_value.size < 2
|
|
402
|
+
|
|
403
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
404
|
+
return false if numeric_array.size < 2
|
|
405
|
+
|
|
406
|
+
mean = numeric_array.sum.to_f / numeric_array.size
|
|
407
|
+
variance_value = numeric_array.sum { |v| (v - mean)**2 } / numeric_array.size
|
|
408
|
+
compare_aggregation_result(variance_value, expected_value)
|
|
409
|
+
|
|
410
|
+
when "percentile"
|
|
411
|
+
# Checks if Nth percentile of numeric array meets threshold
|
|
412
|
+
# expected_value: {percentile: 95, threshold: 200} or {percentile: 95, gt: 200, lt: 500}
|
|
413
|
+
return false unless actual_value.is_a?(Array)
|
|
414
|
+
return false if actual_value.empty?
|
|
415
|
+
|
|
416
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }.sort
|
|
417
|
+
return false if numeric_array.empty?
|
|
418
|
+
|
|
419
|
+
params = parse_percentile_params(expected_value)
|
|
420
|
+
return false unless params
|
|
421
|
+
|
|
422
|
+
percentile_index = (params[:percentile] / 100.0) * (numeric_array.size - 1)
|
|
423
|
+
percentile_value = if percentile_index == percentile_index.to_i
|
|
424
|
+
numeric_array[percentile_index.to_i]
|
|
425
|
+
else
|
|
426
|
+
lower = numeric_array[percentile_index.floor]
|
|
427
|
+
upper = numeric_array[percentile_index.ceil]
|
|
428
|
+
lower + ((upper - lower) * (percentile_index - percentile_index.floor))
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
compare_percentile_result(percentile_value, params)
|
|
432
|
+
|
|
433
|
+
when "count"
|
|
434
|
+
# Checks if count of array elements meets threshold
|
|
435
|
+
# expected_value can be numeric or hash with comparison operators
|
|
436
|
+
return false unless actual_value.is_a?(Array)
|
|
437
|
+
|
|
438
|
+
count_value = actual_value.size
|
|
439
|
+
compare_aggregation_result(count_value, expected_value)
|
|
440
|
+
|
|
441
|
+
# DATE/TIME OPERATORS
|
|
442
|
+
when "before_date"
|
|
443
|
+
# Checks if date is before specified date
|
|
444
|
+
compare_dates(actual_value, expected_value, :<)
|
|
505
445
|
|
|
506
|
-
|
|
507
|
-
|
|
446
|
+
when "after_date"
|
|
447
|
+
# Checks if date is after specified date
|
|
448
|
+
compare_dates(actual_value, expected_value, :>)
|
|
508
449
|
|
|
509
|
-
|
|
510
|
-
|
|
450
|
+
when "within_days"
|
|
451
|
+
# Checks if date is within N days from now (past or future)
|
|
452
|
+
# expected_value is number of days
|
|
453
|
+
return false unless actual_value
|
|
454
|
+
return false unless expected_value.is_a?(Numeric)
|
|
511
455
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
# Adds days to a date and compares
|
|
515
|
-
# expected_value: {days: 7, compare: "lt", target: "now"} or {days: 7, eq: target_date}
|
|
516
|
-
return false unless actual_value
|
|
456
|
+
date = parse_date(actual_value)
|
|
457
|
+
return false unless date
|
|
517
458
|
|
|
518
|
-
|
|
519
|
-
|
|
459
|
+
now = Time.now
|
|
460
|
+
diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
|
|
461
|
+
diff_days <= expected_value
|
|
520
462
|
|
|
521
|
-
|
|
522
|
-
|
|
463
|
+
when "day_of_week"
|
|
464
|
+
# Checks if date falls on specified day of week
|
|
465
|
+
# expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
|
|
466
|
+
return false unless actual_value
|
|
523
467
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return false unless target_date
|
|
468
|
+
date = parse_date(actual_value)
|
|
469
|
+
return false unless date
|
|
527
470
|
|
|
528
|
-
|
|
471
|
+
expected_day = normalize_day_of_week(expected_value)
|
|
472
|
+
return false unless expected_day
|
|
529
473
|
|
|
530
|
-
|
|
531
|
-
# Subtracts days from a date and compares
|
|
532
|
-
return false unless actual_value
|
|
474
|
+
date.wday == expected_day
|
|
533
475
|
|
|
534
|
-
|
|
535
|
-
|
|
476
|
+
# DURATION CALCULATIONS
|
|
477
|
+
when "duration_seconds"
|
|
478
|
+
# Calculates duration between two dates in seconds
|
|
479
|
+
# expected_value: {end: "field.path", max: 3600} or {end: "now", min: 60}
|
|
480
|
+
return false unless actual_value
|
|
536
481
|
|
|
537
|
-
|
|
538
|
-
|
|
482
|
+
start_date = parse_date(actual_value)
|
|
483
|
+
return false unless start_date
|
|
539
484
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
return false unless target_date
|
|
485
|
+
params = parse_duration_params(expected_value)
|
|
486
|
+
return false unless params
|
|
543
487
|
|
|
544
|
-
|
|
488
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
489
|
+
return false unless end_date
|
|
545
490
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
return false unless actual_value
|
|
491
|
+
duration = (end_date - start_date).abs
|
|
492
|
+
compare_duration_result(duration, params)
|
|
549
493
|
|
|
550
|
-
|
|
551
|
-
|
|
494
|
+
when "duration_minutes"
|
|
495
|
+
# Calculates duration between two dates in minutes
|
|
496
|
+
return false unless actual_value
|
|
552
497
|
|
|
553
|
-
|
|
554
|
-
|
|
498
|
+
start_date = parse_date(actual_value)
|
|
499
|
+
return false unless start_date
|
|
555
500
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return false unless target_date
|
|
501
|
+
params = parse_duration_params(expected_value)
|
|
502
|
+
return false unless params
|
|
559
503
|
|
|
560
|
-
|
|
504
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
505
|
+
return false unless end_date
|
|
561
506
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return false unless actual_value
|
|
507
|
+
duration = ((end_date - start_date).abs / 60.0)
|
|
508
|
+
compare_duration_result(duration, params)
|
|
565
509
|
|
|
566
|
-
|
|
567
|
-
|
|
510
|
+
when "duration_hours"
|
|
511
|
+
# Calculates duration between two dates in hours
|
|
512
|
+
return false unless actual_value
|
|
568
513
|
|
|
569
|
-
|
|
570
|
-
|
|
514
|
+
start_date = parse_date(actual_value)
|
|
515
|
+
return false unless start_date
|
|
571
516
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
return false unless target_date
|
|
517
|
+
params = parse_duration_params(expected_value)
|
|
518
|
+
return false unless params
|
|
575
519
|
|
|
576
|
-
|
|
520
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
521
|
+
return false unless end_date
|
|
577
522
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
return false unless actual_value
|
|
523
|
+
duration = ((end_date - start_date).abs / 3600.0)
|
|
524
|
+
compare_duration_result(duration, params)
|
|
581
525
|
|
|
582
|
-
|
|
583
|
-
|
|
526
|
+
when "duration_days"
|
|
527
|
+
# Calculates duration between two dates in days
|
|
528
|
+
return false unless actual_value
|
|
584
529
|
|
|
585
|
-
|
|
586
|
-
|
|
530
|
+
start_date = parse_date(actual_value)
|
|
531
|
+
return false unless start_date
|
|
587
532
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
return false unless target_date
|
|
533
|
+
params = parse_duration_params(expected_value)
|
|
534
|
+
return false unless params
|
|
591
535
|
|
|
592
|
-
|
|
536
|
+
end_date = params[:end] == "now" ? Time.now : parse_date(get_nested_value(context_hash, params[:end]))
|
|
537
|
+
return false unless end_date
|
|
593
538
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
return false unless actual_value
|
|
539
|
+
duration = ((end_date - start_date).abs / 86_400.0)
|
|
540
|
+
compare_duration_result(duration, params)
|
|
597
541
|
|
|
598
|
-
|
|
599
|
-
|
|
542
|
+
# DATE ARITHMETIC
|
|
543
|
+
when "add_days"
|
|
544
|
+
# Adds days to a date and compares
|
|
545
|
+
# expected_value: {days: 7, compare: "lt", target: "now"} or {days: 7, eq: target_date}
|
|
546
|
+
return false unless actual_value
|
|
600
547
|
|
|
601
|
-
|
|
602
|
-
|
|
548
|
+
start_date = parse_date(actual_value)
|
|
549
|
+
return false unless start_date
|
|
603
550
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return false unless target_date
|
|
551
|
+
params = parse_date_arithmetic_params(expected_value)
|
|
552
|
+
return false unless params
|
|
607
553
|
|
|
608
|
-
|
|
554
|
+
result_date = start_date + (params[:days] * 86_400)
|
|
555
|
+
target_date = if params[:target] == "now"
|
|
556
|
+
Time.now
|
|
557
|
+
else
|
|
558
|
+
parse_date(get_nested_value(context_hash,
|
|
559
|
+
params[:target]))
|
|
560
|
+
end
|
|
561
|
+
return false unless target_date
|
|
609
562
|
|
|
610
|
-
|
|
611
|
-
when "hour_of_day"
|
|
612
|
-
# Extracts hour of day (0-23) and compares
|
|
613
|
-
return false unless actual_value
|
|
563
|
+
compare_date_result?(result_date, target_date, params)
|
|
614
564
|
|
|
615
|
-
|
|
616
|
-
|
|
565
|
+
when "subtract_days"
|
|
566
|
+
# Subtracts days from a date and compares
|
|
567
|
+
return false unless actual_value
|
|
617
568
|
|
|
618
|
-
|
|
619
|
-
|
|
569
|
+
start_date = parse_date(actual_value)
|
|
570
|
+
return false unless start_date
|
|
620
571
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
return false unless actual_value
|
|
572
|
+
params = parse_date_arithmetic_params(expected_value)
|
|
573
|
+
return false unless params
|
|
624
574
|
|
|
625
|
-
|
|
626
|
-
|
|
575
|
+
result_date = start_date - (params[:days] * 86_400)
|
|
576
|
+
target_date = if params[:target] == "now"
|
|
577
|
+
Time.now
|
|
578
|
+
else
|
|
579
|
+
parse_date(get_nested_value(context_hash,
|
|
580
|
+
params[:target]))
|
|
581
|
+
end
|
|
582
|
+
return false unless target_date
|
|
627
583
|
|
|
628
|
-
|
|
629
|
-
compare_numeric_result(day, expected_value)
|
|
584
|
+
compare_date_result?(result_date, target_date, params)
|
|
630
585
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
586
|
+
when "add_hours"
|
|
587
|
+
# Adds hours to a date and compares
|
|
588
|
+
return false unless actual_value
|
|
634
589
|
|
|
635
|
-
|
|
636
|
-
|
|
590
|
+
start_date = parse_date(actual_value)
|
|
591
|
+
return false unless start_date
|
|
637
592
|
|
|
638
|
-
|
|
639
|
-
|
|
593
|
+
params = parse_date_arithmetic_params(expected_value, :hours)
|
|
594
|
+
return false unless params
|
|
640
595
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
596
|
+
result_date = start_date + (params[:hours] * 3600)
|
|
597
|
+
target_date = if params[:target] == "now"
|
|
598
|
+
Time.now
|
|
599
|
+
else
|
|
600
|
+
parse_date(get_nested_value(context_hash,
|
|
601
|
+
params[:target]))
|
|
602
|
+
end
|
|
603
|
+
return false unless target_date
|
|
644
604
|
|
|
645
|
-
|
|
646
|
-
return false unless date
|
|
605
|
+
compare_date_result?(result_date, target_date, params)
|
|
647
606
|
|
|
648
|
-
|
|
649
|
-
|
|
607
|
+
when "subtract_hours"
|
|
608
|
+
# Subtracts hours from a date and compares
|
|
609
|
+
return false unless actual_value
|
|
650
610
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
return false unless actual_value
|
|
611
|
+
start_date = parse_date(actual_value)
|
|
612
|
+
return false unless start_date
|
|
654
613
|
|
|
655
|
-
|
|
656
|
-
|
|
614
|
+
params = parse_date_arithmetic_params(expected_value, :hours)
|
|
615
|
+
return false unless params
|
|
657
616
|
|
|
658
|
-
|
|
659
|
-
|
|
617
|
+
result_date = start_date - (params[:hours] * 3600)
|
|
618
|
+
target_date = if params[:target] == "now"
|
|
619
|
+
Time.now
|
|
620
|
+
else
|
|
621
|
+
parse_date(get_nested_value(context_hash,
|
|
622
|
+
params[:target]))
|
|
623
|
+
end
|
|
624
|
+
return false unless target_date
|
|
660
625
|
|
|
661
|
-
|
|
662
|
-
when "rate_per_second"
|
|
663
|
-
# Calculates rate per second from array of timestamps
|
|
664
|
-
# expected_value: {max: 10} or {min: 5, max: 100}
|
|
665
|
-
return false unless actual_value.is_a?(Array)
|
|
666
|
-
return false if actual_value.empty?
|
|
626
|
+
compare_date_result?(result_date, target_date, params)
|
|
667
627
|
|
|
668
|
-
|
|
669
|
-
|
|
628
|
+
when "add_minutes"
|
|
629
|
+
# Adds minutes to a date and compares
|
|
630
|
+
return false unless actual_value
|
|
670
631
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
return false if time_span <= 0
|
|
632
|
+
start_date = parse_date(actual_value)
|
|
633
|
+
return false unless start_date
|
|
674
634
|
|
|
675
|
-
|
|
676
|
-
|
|
635
|
+
params = parse_date_arithmetic_params(expected_value, :minutes)
|
|
636
|
+
return false unless params
|
|
677
637
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
638
|
+
result_date = start_date + (params[:minutes] * 60)
|
|
639
|
+
target_date = if params[:target] == "now"
|
|
640
|
+
Time.now
|
|
641
|
+
else
|
|
642
|
+
parse_date(get_nested_value(context_hash,
|
|
643
|
+
params[:target]))
|
|
644
|
+
end
|
|
645
|
+
return false unless target_date
|
|
682
646
|
|
|
683
|
-
|
|
684
|
-
return false if timestamps.size < 2
|
|
647
|
+
compare_date_result?(result_date, target_date, params)
|
|
685
648
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
649
|
+
when "subtract_minutes"
|
|
650
|
+
# Subtracts minutes from a date and compares
|
|
651
|
+
return false unless actual_value
|
|
689
652
|
|
|
690
|
-
|
|
691
|
-
|
|
653
|
+
start_date = parse_date(actual_value)
|
|
654
|
+
return false unless start_date
|
|
692
655
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
return false unless actual_value.is_a?(Array)
|
|
696
|
-
return false if actual_value.empty?
|
|
656
|
+
params = parse_date_arithmetic_params(expected_value, :minutes)
|
|
657
|
+
return false unless params
|
|
697
658
|
|
|
698
|
-
|
|
699
|
-
|
|
659
|
+
result_date = start_date - (params[:minutes] * 60)
|
|
660
|
+
target_date = if params[:target] == "now"
|
|
661
|
+
Time.now
|
|
662
|
+
else
|
|
663
|
+
parse_date(get_nested_value(context_hash,
|
|
664
|
+
params[:target]))
|
|
665
|
+
end
|
|
666
|
+
return false unless target_date
|
|
700
667
|
|
|
701
|
-
|
|
702
|
-
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
703
|
-
return false if time_span <= 0
|
|
668
|
+
compare_date_result?(result_date, target_date, params)
|
|
704
669
|
|
|
705
|
-
|
|
706
|
-
|
|
670
|
+
# TIME COMPONENT EXTRACTION
|
|
671
|
+
when "hour_of_day"
|
|
672
|
+
# Extracts hour of day (0-23) and compares
|
|
673
|
+
return false unless actual_value
|
|
707
674
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
# Calculates moving average over window
|
|
711
|
-
# expected_value: {window: 5, threshold: 100} or {window: 5, gt: 100}
|
|
712
|
-
return false unless actual_value.is_a?(Array)
|
|
713
|
-
return false if actual_value.empty?
|
|
675
|
+
date = parse_date(actual_value)
|
|
676
|
+
return false unless date
|
|
714
677
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
return false if numeric_array.empty?
|
|
678
|
+
hour = date.hour
|
|
679
|
+
compare_numeric_result(hour, expected_value)
|
|
718
680
|
|
|
719
|
-
|
|
720
|
-
|
|
681
|
+
when "day_of_month"
|
|
682
|
+
# Extracts day of month (1-31) and compares
|
|
683
|
+
return false unless actual_value
|
|
721
684
|
|
|
722
|
-
|
|
723
|
-
|
|
685
|
+
date = parse_date(actual_value)
|
|
686
|
+
return false unless date
|
|
724
687
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
moving_avg = window_array.sum.to_f / window
|
|
728
|
-
compare_moving_window_result(moving_avg, params)
|
|
688
|
+
day = date.day
|
|
689
|
+
compare_numeric_result(day, expected_value)
|
|
729
690
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
return false if actual_value.empty?
|
|
691
|
+
when "month"
|
|
692
|
+
# Extracts month (1-12) and compares
|
|
693
|
+
return false unless actual_value
|
|
734
694
|
|
|
735
|
-
|
|
736
|
-
|
|
695
|
+
date = parse_date(actual_value)
|
|
696
|
+
return false unless date
|
|
737
697
|
|
|
738
|
-
|
|
739
|
-
|
|
698
|
+
month = date.month
|
|
699
|
+
compare_numeric_result(month, expected_value)
|
|
740
700
|
|
|
741
|
-
|
|
742
|
-
|
|
701
|
+
when "year"
|
|
702
|
+
# Extracts year and compares
|
|
703
|
+
return false unless actual_value
|
|
743
704
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
moving_sum = window_array.sum
|
|
747
|
-
compare_moving_window_result(moving_sum, params)
|
|
705
|
+
date = parse_date(actual_value)
|
|
706
|
+
return false unless date
|
|
748
707
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
return false unless actual_value.is_a?(Array)
|
|
752
|
-
return false if actual_value.empty?
|
|
708
|
+
year = date.year
|
|
709
|
+
compare_numeric_result(year, expected_value)
|
|
753
710
|
|
|
754
|
-
|
|
755
|
-
|
|
711
|
+
when "week_of_year"
|
|
712
|
+
# Extracts week of year (1-52) and compares
|
|
713
|
+
return false unless actual_value
|
|
756
714
|
|
|
757
|
-
|
|
758
|
-
|
|
715
|
+
date = parse_date(actual_value)
|
|
716
|
+
return false unless date
|
|
759
717
|
|
|
760
|
-
|
|
761
|
-
|
|
718
|
+
week = date.strftime("%U").to_i + 1 # %U returns 0-53, we want 1-53
|
|
719
|
+
compare_numeric_result(week, expected_value)
|
|
762
720
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
721
|
+
# RATE CALCULATIONS
|
|
722
|
+
when "rate_per_second"
|
|
723
|
+
# Calculates rate per second from array of timestamps
|
|
724
|
+
# expected_value: {max: 10} or {min: 5, max: 100}
|
|
725
|
+
return false unless actual_value.is_a?(Array)
|
|
726
|
+
return false if actual_value.empty?
|
|
767
727
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
return false unless actual_value.is_a?(Array)
|
|
771
|
-
return false if actual_value.empty?
|
|
728
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
729
|
+
return false if timestamps.size < 2
|
|
772
730
|
|
|
773
|
-
|
|
774
|
-
|
|
731
|
+
sorted_timestamps = timestamps.sort
|
|
732
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
733
|
+
return false if time_span <= 0
|
|
775
734
|
|
|
776
|
-
|
|
777
|
-
|
|
735
|
+
rate = timestamps.size.to_f / time_span
|
|
736
|
+
compare_rate_result(rate, expected_value)
|
|
778
737
|
|
|
779
|
-
|
|
780
|
-
|
|
738
|
+
when "rate_per_minute"
|
|
739
|
+
# Calculates rate per minute from array of timestamps
|
|
740
|
+
return false unless actual_value.is_a?(Array)
|
|
741
|
+
return false if actual_value.empty?
|
|
781
742
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
moving_min = window_array.min
|
|
785
|
-
compare_moving_window_result(moving_min, params)
|
|
743
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
744
|
+
return false if timestamps.size < 2
|
|
786
745
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
# expected_value: {rate: 0.05, periods: 12, result: 1050} or {rate: 0.05, periods: 12, compare: "gt", threshold: 1000}
|
|
791
|
-
return false unless actual_value.is_a?(Numeric)
|
|
746
|
+
sorted_timestamps = timestamps.sort
|
|
747
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
748
|
+
return false if time_span <= 0
|
|
792
749
|
|
|
793
|
-
|
|
794
|
-
|
|
750
|
+
rate = (timestamps.size.to_f / time_span) * 60.0
|
|
751
|
+
compare_rate_result(rate, expected_value)
|
|
795
752
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
753
|
+
when "rate_per_hour"
|
|
754
|
+
# Calculates rate per hour from array of timestamps
|
|
755
|
+
return false unless actual_value.is_a?(Array)
|
|
756
|
+
return false if actual_value.empty?
|
|
800
757
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
else
|
|
804
|
-
compare_financial_result(result, params)
|
|
805
|
-
end
|
|
758
|
+
timestamps = actual_value.map { |ts| parse_date(ts) }.compact
|
|
759
|
+
return false if timestamps.size < 2
|
|
806
760
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
return false unless actual_value.is_a?(Numeric)
|
|
761
|
+
sorted_timestamps = timestamps.sort
|
|
762
|
+
time_span = sorted_timestamps.last - sorted_timestamps.first
|
|
763
|
+
return false if time_span <= 0
|
|
811
764
|
|
|
812
|
-
|
|
813
|
-
|
|
765
|
+
rate = (timestamps.size.to_f / time_span) * 3600.0
|
|
766
|
+
compare_rate_result(rate, expected_value)
|
|
814
767
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
768
|
+
# MOVING WINDOW CALCULATIONS
|
|
769
|
+
when "moving_average"
|
|
770
|
+
# Calculates moving average over window
|
|
771
|
+
# expected_value: {window: 5, threshold: 100} or {window: 5, gt: 100}
|
|
772
|
+
return false unless actual_value.is_a?(Array)
|
|
773
|
+
return false if actual_value.empty?
|
|
819
774
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
compare_financial_result(present_value, params)
|
|
824
|
-
end
|
|
775
|
+
# OPTIMIZE: filter once and reuse
|
|
776
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
777
|
+
return false if numeric_array.empty?
|
|
825
778
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
# expected_value: {rate: 0.05, periods: 10, result: 1628.89}
|
|
829
|
-
return false unless actual_value.is_a?(Numeric)
|
|
779
|
+
params = parse_moving_window_params(expected_value)
|
|
780
|
+
return false unless params
|
|
830
781
|
|
|
831
|
-
|
|
832
|
-
|
|
782
|
+
window = [params[:window], numeric_array.size].min
|
|
783
|
+
return false if window < 1
|
|
833
784
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
785
|
+
# OPTIMIZE: use slice instead of last for better performance
|
|
786
|
+
window_array = numeric_array.slice(-window, window)
|
|
787
|
+
moving_avg = window_array.sum.to_f / window
|
|
788
|
+
compare_moving_window_result(moving_avg, params)
|
|
838
789
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
end
|
|
844
|
-
|
|
845
|
-
when "payment"
|
|
846
|
-
# Calculates loan payment: PMT = P * [r(1+r)^n] / [(1+r)^n - 1]
|
|
847
|
-
# expected_value: {rate: 0.05, periods: 12, result: 100}
|
|
848
|
-
return false unless actual_value.is_a?(Numeric)
|
|
790
|
+
when "moving_sum"
|
|
791
|
+
# Calculates moving sum over window
|
|
792
|
+
return false unless actual_value.is_a?(Array)
|
|
793
|
+
return false if actual_value.empty?
|
|
849
794
|
|
|
850
|
-
|
|
851
|
-
|
|
795
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
796
|
+
return false if numeric_array.empty?
|
|
852
797
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
periods = params[:periods]
|
|
798
|
+
params = parse_moving_window_params(expected_value)
|
|
799
|
+
return false unless params
|
|
856
800
|
|
|
857
|
-
|
|
801
|
+
window = [params[:window], numeric_array.size].min
|
|
802
|
+
return false if window < 1
|
|
803
|
+
|
|
804
|
+
# OPTIMIZE: use slice instead of last
|
|
805
|
+
window_array = numeric_array.slice(-window, window)
|
|
806
|
+
moving_sum = window_array.sum
|
|
807
|
+
compare_moving_window_result(moving_sum, params)
|
|
808
|
+
|
|
809
|
+
when "moving_max"
|
|
810
|
+
# Calculates moving max over window
|
|
811
|
+
return false unless actual_value.is_a?(Array)
|
|
812
|
+
return false if actual_value.empty?
|
|
813
|
+
|
|
814
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
815
|
+
return false if numeric_array.empty?
|
|
816
|
+
|
|
817
|
+
params = parse_moving_window_params(expected_value)
|
|
818
|
+
return false unless params
|
|
819
|
+
|
|
820
|
+
window = [params[:window], numeric_array.size].min
|
|
821
|
+
return false if window < 1
|
|
822
|
+
|
|
823
|
+
# OPTIMIZE: use slice instead of last, iterate directly for max
|
|
824
|
+
window_array = numeric_array.slice(-window, window)
|
|
825
|
+
moving_max = window_array.max
|
|
826
|
+
compare_moving_window_result(moving_max, params)
|
|
858
827
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
828
|
+
when "moving_min"
|
|
829
|
+
# Calculates moving min over window
|
|
830
|
+
return false unless actual_value.is_a?(Array)
|
|
831
|
+
return false if actual_value.empty?
|
|
832
|
+
|
|
833
|
+
numeric_array = actual_value.select { |v| v.is_a?(Numeric) }
|
|
834
|
+
return false if numeric_array.empty?
|
|
835
|
+
|
|
836
|
+
params = parse_moving_window_params(expected_value)
|
|
837
|
+
return false unless params
|
|
838
|
+
|
|
839
|
+
window = [params[:window], numeric_array.size].min
|
|
840
|
+
return false if window < 1
|
|
841
|
+
|
|
842
|
+
# OPTIMIZE: use slice instead of last
|
|
843
|
+
window_array = numeric_array.slice(-window, window)
|
|
844
|
+
moving_min = window_array.min
|
|
845
|
+
compare_moving_window_result(moving_min, params)
|
|
846
|
+
|
|
847
|
+
# FINANCIAL CALCULATIONS
|
|
848
|
+
when "compound_interest"
|
|
849
|
+
# Calculates compound interest: A = P(1 + r/n)^(nt)
|
|
850
|
+
# expected_value: {rate: 0.05, periods: 12, result: 1050} or {rate: 0.05, periods: 12, compare: "gt", threshold: 1000}
|
|
851
|
+
return false unless actual_value.is_a?(Numeric)
|
|
852
|
+
|
|
853
|
+
params = parse_compound_interest_params(expected_value)
|
|
854
|
+
return false unless params
|
|
855
|
+
|
|
856
|
+
principal = actual_value
|
|
857
|
+
rate = params[:rate]
|
|
858
|
+
periods = params[:periods]
|
|
859
|
+
result = principal * ((1 + (rate / periods))**periods)
|
|
860
|
+
|
|
861
|
+
if params[:result]
|
|
862
|
+
(result.round(2) == params[:result].round(2))
|
|
863
|
+
else
|
|
864
|
+
compare_financial_result(result, params)
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
when "present_value"
|
|
868
|
+
# Calculates present value: PV = FV / (1 + r)^n
|
|
869
|
+
# expected_value: {rate: 0.05, periods: 10, result: 613.91}
|
|
870
|
+
return false unless actual_value.is_a?(Numeric)
|
|
871
|
+
|
|
872
|
+
params = parse_present_value_params(expected_value)
|
|
873
|
+
return false unless params
|
|
864
874
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
# For small arrays, Set overhead is minimal; for large arrays, huge win
|
|
915
|
-
actual_set = actual_value.to_set
|
|
916
|
-
expected_value.all? { |item| actual_set.include?(item) }
|
|
917
|
-
|
|
918
|
-
when "contains_any"
|
|
919
|
-
# Checks if array contains any of the specified elements
|
|
920
|
-
# expected_value should be an array
|
|
921
|
-
return false unless actual_value.is_a?(Array)
|
|
922
|
-
return false unless expected_value.is_a?(Array)
|
|
923
|
-
return false if expected_value.empty?
|
|
924
|
-
|
|
925
|
-
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
926
|
-
# Early exit on first match for better performance
|
|
927
|
-
actual_set = actual_value.to_set
|
|
928
|
-
expected_value.any? { |item| actual_set.include?(item) }
|
|
929
|
-
|
|
930
|
-
when "intersects"
|
|
931
|
-
# Checks if two arrays have any common elements
|
|
932
|
-
# expected_value should be an array
|
|
933
|
-
return false unless actual_value.is_a?(Array)
|
|
934
|
-
return false unless expected_value.is_a?(Array)
|
|
935
|
-
return false if actual_value.empty? || expected_value.empty?
|
|
936
|
-
|
|
937
|
-
# OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
|
|
938
|
-
# Check smaller array against larger set for better performance
|
|
939
|
-
if actual_value.size <= expected_value.size
|
|
940
|
-
expected_set = expected_value.to_set
|
|
941
|
-
actual_value.any? { |item| expected_set.include?(item) }
|
|
942
|
-
else
|
|
943
|
-
actual_set = actual_value.to_set
|
|
944
|
-
expected_value.any? { |item| actual_set.include?(item) }
|
|
945
|
-
end
|
|
875
|
+
future_value = actual_value
|
|
876
|
+
rate = params[:rate]
|
|
877
|
+
periods = params[:periods]
|
|
878
|
+
present_value = future_value / ((1 + rate)**periods)
|
|
879
|
+
|
|
880
|
+
if params[:result]
|
|
881
|
+
(present_value.round(2) == params[:result].round(2))
|
|
882
|
+
else
|
|
883
|
+
compare_financial_result(present_value, params)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
when "future_value"
|
|
887
|
+
# Calculates future value: FV = PV * (1 + r)^n
|
|
888
|
+
# expected_value: {rate: 0.05, periods: 10, result: 1628.89}
|
|
889
|
+
return false unless actual_value.is_a?(Numeric)
|
|
890
|
+
|
|
891
|
+
params = parse_future_value_params(expected_value)
|
|
892
|
+
return false unless params
|
|
893
|
+
|
|
894
|
+
present_value = actual_value
|
|
895
|
+
rate = params[:rate]
|
|
896
|
+
periods = params[:periods]
|
|
897
|
+
future_value = present_value * ((1 + rate)**periods)
|
|
898
|
+
|
|
899
|
+
if params[:result]
|
|
900
|
+
(future_value.round(2) == params[:result].round(2))
|
|
901
|
+
else
|
|
902
|
+
compare_financial_result(future_value, params)
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
when "payment"
|
|
906
|
+
# Calculates loan payment: PMT = P * [r(1+r)^n] / [(1+r)^n - 1]
|
|
907
|
+
# expected_value: {rate: 0.05, periods: 12, result: 100}
|
|
908
|
+
return false unless actual_value.is_a?(Numeric)
|
|
909
|
+
|
|
910
|
+
params = parse_payment_params(expected_value)
|
|
911
|
+
return false unless params
|
|
912
|
+
|
|
913
|
+
principal = actual_value
|
|
914
|
+
rate = params[:rate]
|
|
915
|
+
periods = params[:periods]
|
|
916
|
+
|
|
917
|
+
return false if rate <= 0 || periods <= 0
|
|
918
|
+
|
|
919
|
+
payment = if rate.zero?
|
|
920
|
+
principal / periods
|
|
921
|
+
else
|
|
922
|
+
principal * (rate * ((1 + rate)**periods)) / (((1 + rate)**periods) - 1)
|
|
923
|
+
end
|
|
946
924
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
925
|
+
if params[:result]
|
|
926
|
+
(payment.round(2) == params[:result].round(2))
|
|
927
|
+
else
|
|
928
|
+
compare_financial_result(payment, params)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# STRING AGGREGATIONS
|
|
932
|
+
when "join"
|
|
933
|
+
# Joins array of strings with separator
|
|
934
|
+
# expected_value: {separator: ",", result: "a,b,c"} or {separator: ",", contains: "a"}
|
|
935
|
+
return false unless actual_value.is_a?(Array)
|
|
936
|
+
return false if actual_value.empty?
|
|
937
|
+
|
|
938
|
+
string_array = actual_value.map(&:to_s)
|
|
939
|
+
params = parse_join_params(expected_value)
|
|
940
|
+
return false unless params
|
|
941
|
+
|
|
942
|
+
joined = string_array.join(params[:separator])
|
|
943
|
+
|
|
944
|
+
if params[:result]
|
|
945
|
+
joined == params[:result]
|
|
946
|
+
elsif params[:contains]
|
|
947
|
+
joined.include?(params[:contains])
|
|
948
|
+
else
|
|
949
|
+
false
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
when "length"
|
|
953
|
+
# Gets length of string or array
|
|
954
|
+
# expected_value: {max: 500} or {min: 10, max: 100}
|
|
955
|
+
return false if actual_value.nil?
|
|
956
|
+
|
|
957
|
+
length_value = if actual_value.is_a?(String) || actual_value.is_a?(Array)
|
|
958
|
+
actual_value.length
|
|
959
|
+
else
|
|
960
|
+
return false
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
compare_length_result(length_value, expected_value)
|
|
964
|
+
|
|
965
|
+
# COLLECTION OPERATORS
|
|
966
|
+
when "contains_all"
|
|
967
|
+
# Checks if array contains all specified elements
|
|
968
|
+
# expected_value should be an array
|
|
969
|
+
return false unless actual_value.is_a?(Array)
|
|
970
|
+
return false unless expected_value.is_a?(Array)
|
|
971
|
+
return true if expected_value.empty?
|
|
972
|
+
|
|
973
|
+
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
974
|
+
# For small arrays, Set overhead is minimal; for large arrays, huge win
|
|
975
|
+
actual_set = actual_value.to_set
|
|
976
|
+
expected_value.all? { |item| actual_set.include?(item) }
|
|
977
|
+
|
|
978
|
+
when "contains_any"
|
|
979
|
+
# Checks if array contains any of the specified elements
|
|
980
|
+
# expected_value should be an array
|
|
981
|
+
return false unless actual_value.is_a?(Array)
|
|
982
|
+
return false unless expected_value.is_a?(Array)
|
|
983
|
+
return false if expected_value.empty?
|
|
984
|
+
|
|
985
|
+
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
986
|
+
# Early exit on first match for better performance
|
|
987
|
+
actual_set = actual_value.to_set
|
|
988
|
+
expected_value.any? { |item| actual_set.include?(item) }
|
|
989
|
+
|
|
990
|
+
when "intersects"
|
|
991
|
+
# Checks if two arrays have any common elements
|
|
992
|
+
# expected_value should be an array
|
|
993
|
+
return false unless actual_value.is_a?(Array)
|
|
994
|
+
return false unless expected_value.is_a?(Array)
|
|
995
|
+
return false if actual_value.empty? || expected_value.empty?
|
|
996
|
+
|
|
997
|
+
# OPTIMIZE: Use Set intersection for O(n) instead of array & which creates intermediate array
|
|
998
|
+
# Check smaller array against larger set for better performance
|
|
999
|
+
if actual_value.size <= expected_value.size
|
|
1000
|
+
expected_set = expected_value.to_set
|
|
1001
|
+
actual_value.any? { |item| expected_set.include?(item) }
|
|
1002
|
+
else
|
|
1003
|
+
actual_set = actual_value.to_set
|
|
1004
|
+
expected_value.any? { |item| actual_set.include?(item) }
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
when "subset_of"
|
|
1008
|
+
# Checks if array is a subset of another array
|
|
1009
|
+
# All elements in actual_value must be in expected_value
|
|
1010
|
+
return false unless actual_value.is_a?(Array)
|
|
1011
|
+
return false unless expected_value.is_a?(Array)
|
|
1012
|
+
return true if actual_value.empty?
|
|
1013
|
+
|
|
1014
|
+
# OPTIMIZE: Use Set for O(1) lookups instead of O(n) include?
|
|
1015
|
+
expected_set = expected_value.to_set
|
|
1016
|
+
actual_value.all? { |item| expected_set.include?(item) }
|
|
1017
|
+
|
|
1018
|
+
# GEOSPATIAL OPERATORS
|
|
1019
|
+
when "within_radius"
|
|
1020
|
+
# Checks if point is within radius of center point
|
|
1021
|
+
# actual_value: {lat: y, lon: x} or [lat, lon]
|
|
1022
|
+
# expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
|
|
1023
|
+
point = parse_coordinates(actual_value)
|
|
1024
|
+
return false unless point
|
|
1025
|
+
|
|
1026
|
+
params = parse_radius_params(expected_value)
|
|
1027
|
+
return false unless params
|
|
1028
|
+
|
|
1029
|
+
# Cache geospatial distance calculations
|
|
1030
|
+
distance = get_cached_distance(point, params[:center])
|
|
1031
|
+
distance <= params[:radius]
|
|
1032
|
+
|
|
1033
|
+
when "in_polygon"
|
|
1034
|
+
# Checks if point is inside a polygon using ray casting algorithm
|
|
1035
|
+
# actual_value: {lat: y, lon: x} or [lat, lon]
|
|
1036
|
+
# expected_value: array of vertices [{lat: y, lon: x}, ...] or [[lat, lon], ...]
|
|
1037
|
+
point = parse_coordinates(actual_value)
|
|
1038
|
+
return false unless point
|
|
1039
|
+
|
|
1040
|
+
polygon = parse_polygon(expected_value)
|
|
1041
|
+
return false unless polygon
|
|
1042
|
+
return false if polygon.size < 3 # Need at least 3 vertices
|
|
1043
|
+
|
|
1044
|
+
point_in_polygon?(point, polygon)
|
|
1045
|
+
|
|
1046
|
+
when "fetch_from_api"
|
|
1047
|
+
# Fetches data from external API and enriches context
|
|
1048
|
+
# expected_value: { endpoint: :endpoint_name, params: {...}, mapping: {...} }
|
|
1049
|
+
return false unless expected_value.is_a?(Hash)
|
|
1050
|
+
return false unless expected_value[:endpoint] || expected_value["endpoint"]
|
|
1051
|
+
|
|
1052
|
+
begin
|
|
1053
|
+
endpoint_name = (expected_value[:endpoint] || expected_value["endpoint"]).to_sym
|
|
1054
|
+
params = expand_template_params(expected_value[:params] || expected_value["params"] || {}, context_hash)
|
|
1055
|
+
mapping = expected_value[:mapping] || expected_value["mapping"] || {}
|
|
1056
|
+
|
|
1057
|
+
# Get data enrichment client
|
|
1058
|
+
client = DecisionAgent.data_enrichment_client
|
|
1059
|
+
|
|
1060
|
+
# Fetch data from API
|
|
1061
|
+
response_data = client.fetch(endpoint_name, params: params, use_cache: true)
|
|
1062
|
+
|
|
1063
|
+
# Apply mapping if provided and merge into context_hash
|
|
1064
|
+
if mapping.any?
|
|
1065
|
+
mapped_data = apply_mapping(response_data, mapping)
|
|
1066
|
+
# Merge mapped data into context_hash for subsequent conditions
|
|
1067
|
+
mapped_data.each do |key, value|
|
|
1068
|
+
context_hash[key] = value
|
|
1069
|
+
end
|
|
1070
|
+
# Return true if fetch succeeded and mapping applied
|
|
1071
|
+
mapped_data.any?
|
|
1072
|
+
else
|
|
1073
|
+
# Return true if fetch succeeded
|
|
1074
|
+
!response_data.nil?
|
|
1075
|
+
end
|
|
1076
|
+
rescue StandardError => e
|
|
1077
|
+
# Log error but return false (fail-safe)
|
|
1078
|
+
warn "Data enrichment error: #{e.message}" if ENV["DEBUG"]
|
|
1079
|
+
false
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
else
|
|
1083
|
+
# Unknown operator - returns false (fail-safe)
|
|
1084
|
+
# Note: Validation should catch this earlier
|
|
1085
|
+
false
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
# Add trace if collector is provided
|
|
1089
|
+
trace_collector&.add_trace(Explainability::ConditionTrace.new(
|
|
1090
|
+
field: field,
|
|
1091
|
+
operator: op,
|
|
1092
|
+
expected_value: expected_value,
|
|
1093
|
+
actual_value: actual_value,
|
|
1094
|
+
result: result
|
|
1095
|
+
))
|
|
985
1096
|
|
|
986
|
-
|
|
987
|
-
# Unknown operator - returns false (fail-safe)
|
|
988
|
-
# Note: Validation should catch this earlier
|
|
989
|
-
false
|
|
990
|
-
end
|
|
1097
|
+
result
|
|
991
1098
|
end
|
|
992
1099
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
993
1100
|
|
|
@@ -1016,15 +1123,54 @@ module DecisionAgent
|
|
|
1016
1123
|
end
|
|
1017
1124
|
|
|
1018
1125
|
# Checks if two values can be compared with <, >, <=, >=
|
|
1019
|
-
#
|
|
1126
|
+
# Allows comparison between numeric types (Float, Integer, etc.) or same string types
|
|
1020
1127
|
def self.comparable?(val1, val2)
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1128
|
+
# Both are numeric - allow comparison between different numeric types
|
|
1129
|
+
# (e.g., Integer and Float are comparable in Ruby)
|
|
1130
|
+
return true if val1.is_a?(Numeric) && val2.is_a?(Numeric)
|
|
1131
|
+
|
|
1132
|
+
# Both are strings - require exact same type
|
|
1133
|
+
return val1.instance_of?(val2.class) if val1.is_a?(String) && val2.is_a?(String)
|
|
1134
|
+
|
|
1135
|
+
false
|
|
1024
1136
|
end
|
|
1025
1137
|
|
|
1026
1138
|
# Helper methods for new operators
|
|
1027
1139
|
|
|
1140
|
+
# Expand template parameters (e.g., "{{customer.ssn}}") from context
|
|
1141
|
+
def self.expand_template_params(params, context_hash)
|
|
1142
|
+
return {} unless params.is_a?(Hash)
|
|
1143
|
+
|
|
1144
|
+
params.transform_values do |value|
|
|
1145
|
+
expand_template_value(value, context_hash)
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# Expand a single template value
|
|
1150
|
+
def self.expand_template_value(value, context_hash)
|
|
1151
|
+
return value unless value.is_a?(String)
|
|
1152
|
+
return value unless value.match?(/\{\{.*\}\}/)
|
|
1153
|
+
|
|
1154
|
+
# Extract path from {{path}} syntax
|
|
1155
|
+
value.gsub(/\{\{([^}]+)\}\}/) do |_match|
|
|
1156
|
+
path = Regexp.last_match(1).strip
|
|
1157
|
+
get_nested_value(context_hash, path) || value
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Apply mapping to API response data
|
|
1162
|
+
# Mapping format: { source_key: "target_key" }
|
|
1163
|
+
# Example: { score: "credit_score" } means map response[:score] to context["credit_score"]
|
|
1164
|
+
def self.apply_mapping(response_data, mapping)
|
|
1165
|
+
return {} unless response_data.is_a?(Hash)
|
|
1166
|
+
return {} unless mapping.is_a?(Hash)
|
|
1167
|
+
|
|
1168
|
+
mapping.each_with_object({}) do |(source_key, target_key), result|
|
|
1169
|
+
source_value = get_nested_value(response_data, source_key.to_s)
|
|
1170
|
+
result[target_key.to_s] = source_value unless source_value.nil?
|
|
1171
|
+
end
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1028
1174
|
# String operator validation
|
|
1029
1175
|
def self.string_operator?(actual_value, expected_value)
|
|
1030
1176
|
actual_value.is_a?(String) && expected_value.is_a?(String)
|