decision_agent 0.1.3 → 0.1.6
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 +84 -233
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- data/lib/decision_agent/agent.rb +5 -3
- data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
- data/lib/decision_agent/auth/authenticator.rb +127 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
- data/lib/decision_agent/auth/password_reset_token.rb +33 -0
- data/lib/decision_agent/auth/permission.rb +29 -0
- data/lib/decision_agent/auth/permission_checker.rb +43 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
- data/lib/decision_agent/auth/rbac_config.rb +51 -0
- data/lib/decision_agent/auth/role.rb +56 -0
- data/lib/decision_agent/auth/session.rb +33 -0
- data/lib/decision_agent/auth/session_manager.rb +57 -0
- data/lib/decision_agent/auth/user.rb +70 -0
- data/lib/decision_agent/context.rb +24 -4
- data/lib/decision_agent/decision.rb +10 -3
- data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
- data/lib/decision_agent/dsl/schema_validator.rb +8 -1
- data/lib/decision_agent/errors.rb +38 -0
- data/lib/decision_agent/evaluation.rb +10 -3
- data/lib/decision_agent/evaluation_validator.rb +8 -13
- data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
- data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
- data/lib/decision_agent/testing/test_scenario.rb +42 -0
- data/lib/decision_agent/version.rb +10 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
- data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
- data/lib/decision_agent/web/public/app.js +184 -29
- data/lib/decision_agent/web/public/batch_testing.html +640 -0
- data/lib/decision_agent/web/public/index.html +37 -9
- data/lib/decision_agent/web/public/login.html +298 -0
- data/lib/decision_agent/web/public/users.html +679 -0
- data/lib/decision_agent/web/server.rb +873 -7
- data/lib/decision_agent.rb +59 -0
- data/lib/generators/decision_agent/install/install_generator.rb +37 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
- data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
- data/spec/ab_testing/storage/adapter_spec.rb +64 -0
- data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
- data/spec/advanced_operators_spec.rb +1003 -0
- data/spec/agent_spec.rb +40 -0
- data/spec/audit_adapters_spec.rb +18 -0
- data/spec/auth/access_audit_logger_spec.rb +394 -0
- data/spec/auth/authenticator_spec.rb +112 -0
- data/spec/auth/password_reset_spec.rb +294 -0
- data/spec/auth/permission_checker_spec.rb +207 -0
- data/spec/auth/permission_spec.rb +73 -0
- data/spec/auth/rbac_adapter_spec.rb +550 -0
- data/spec/auth/rbac_config_spec.rb +82 -0
- data/spec/auth/role_spec.rb +51 -0
- data/spec/auth/session_manager_spec.rb +172 -0
- data/spec/auth/session_spec.rb +112 -0
- data/spec/auth/user_spec.rb +130 -0
- data/spec/context_spec.rb +43 -0
- data/spec/decision_agent_spec.rb +96 -0
- data/spec/decision_spec.rb +423 -0
- data/spec/dsl/condition_evaluator_spec.rb +774 -0
- data/spec/evaluation_spec.rb +364 -0
- data/spec/evaluation_validator_spec.rb +165 -0
- data/spec/examples.txt +1542 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +221 -3
- data/spec/monitoring/monitored_agent_spec.rb +1 -1
- data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- data/spec/performance_optimizations_spec.rb +486 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/testing/batch_test_importer_spec.rb +693 -0
- data/spec/testing/batch_test_runner_spec.rb +307 -0
- data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
- data/spec/testing/test_result_comparator_spec.rb +392 -0
- data/spec/testing/test_scenario_spec.rb +113 -0
- data/spec/versioning/adapter_spec.rb +156 -0
- data/spec/versioning_spec.rb +253 -0
- data/spec/web/middleware/auth_middleware_spec.rb +133 -0
- data/spec/web/middleware/permission_middleware_spec.rb +247 -0
- data/spec/web_ui_rack_spec.rb +1705 -0
- metadata +123 -6
|
@@ -7,6 +7,18 @@ module DecisionAgent
|
|
|
7
7
|
# - Nested field access via dot notation (e.g., "user.profile.role")
|
|
8
8
|
# - Logical operators (all/any)
|
|
9
9
|
class ConditionEvaluator
|
|
10
|
+
# Thread-safe caches for performance optimization
|
|
11
|
+
@regex_cache = {}
|
|
12
|
+
@regex_cache_mutex = Mutex.new
|
|
13
|
+
@path_cache = {}
|
|
14
|
+
@path_cache_mutex = Mutex.new
|
|
15
|
+
@date_cache = {}
|
|
16
|
+
@date_cache_mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_reader :regex_cache, :path_cache, :date_cache
|
|
20
|
+
end
|
|
21
|
+
|
|
10
22
|
def self.evaluate(condition, context)
|
|
11
23
|
return false unless condition.is_a?(Hash)
|
|
12
24
|
|
|
@@ -38,6 +50,7 @@ module DecisionAgent
|
|
|
38
50
|
conditions.any? { |cond| evaluate(cond, context) }
|
|
39
51
|
end
|
|
40
52
|
|
|
53
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
54
|
def self.evaluate_field_condition(condition, context)
|
|
42
55
|
field = condition["field"]
|
|
43
56
|
op = condition["op"]
|
|
@@ -98,12 +111,158 @@ module DecisionAgent
|
|
|
98
111
|
# - Non-empty values: false
|
|
99
112
|
actual_value.nil? || (actual_value.respond_to?(:empty?) ? actual_value.empty? : false)
|
|
100
113
|
|
|
114
|
+
# STRING OPERATORS
|
|
115
|
+
when "contains"
|
|
116
|
+
# Checks if string contains substring (case-sensitive)
|
|
117
|
+
string_operator?(actual_value, expected_value) &&
|
|
118
|
+
actual_value.include?(expected_value)
|
|
119
|
+
|
|
120
|
+
when "starts_with"
|
|
121
|
+
# Checks if string starts with prefix (case-sensitive)
|
|
122
|
+
string_operator?(actual_value, expected_value) &&
|
|
123
|
+
actual_value.start_with?(expected_value)
|
|
124
|
+
|
|
125
|
+
when "ends_with"
|
|
126
|
+
# Checks if string ends with suffix (case-sensitive)
|
|
127
|
+
string_operator?(actual_value, expected_value) &&
|
|
128
|
+
actual_value.end_with?(expected_value)
|
|
129
|
+
|
|
130
|
+
when "matches"
|
|
131
|
+
# Matches string against regular expression
|
|
132
|
+
# expected_value can be a string (converted to regex) or Regexp object
|
|
133
|
+
return false unless actual_value.is_a?(String)
|
|
134
|
+
return false if expected_value.nil?
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
regex = get_cached_regex(expected_value)
|
|
138
|
+
!regex.match(actual_value).nil?
|
|
139
|
+
rescue RegexpError
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# NUMERIC OPERATORS
|
|
144
|
+
when "between"
|
|
145
|
+
# Checks if numeric value is between min and max (inclusive)
|
|
146
|
+
# expected_value should be [min, max] or {min: x, max: y}
|
|
147
|
+
return false unless actual_value.is_a?(Numeric)
|
|
148
|
+
|
|
149
|
+
range = parse_range(expected_value)
|
|
150
|
+
return false unless range
|
|
151
|
+
|
|
152
|
+
actual_value.between?(range[:min], range[:max])
|
|
153
|
+
|
|
154
|
+
when "modulo"
|
|
155
|
+
# Checks if value modulo divisor equals remainder
|
|
156
|
+
# expected_value should be [divisor, remainder] or {divisor: x, remainder: y}
|
|
157
|
+
return false unless actual_value.is_a?(Numeric)
|
|
158
|
+
|
|
159
|
+
params = parse_modulo_params(expected_value)
|
|
160
|
+
return false unless params
|
|
161
|
+
|
|
162
|
+
(actual_value % params[:divisor]) == params[:remainder]
|
|
163
|
+
|
|
164
|
+
# DATE/TIME OPERATORS
|
|
165
|
+
when "before_date"
|
|
166
|
+
# Checks if date is before specified date
|
|
167
|
+
compare_dates(actual_value, expected_value, :<)
|
|
168
|
+
|
|
169
|
+
when "after_date"
|
|
170
|
+
# Checks if date is after specified date
|
|
171
|
+
compare_dates(actual_value, expected_value, :>)
|
|
172
|
+
|
|
173
|
+
when "within_days"
|
|
174
|
+
# Checks if date is within N days from now (past or future)
|
|
175
|
+
# expected_value is number of days
|
|
176
|
+
return false unless actual_value
|
|
177
|
+
return false unless expected_value.is_a?(Numeric)
|
|
178
|
+
|
|
179
|
+
date = parse_date(actual_value)
|
|
180
|
+
return false unless date
|
|
181
|
+
|
|
182
|
+
now = Time.now
|
|
183
|
+
diff_days = ((date - now) / 86_400).abs # 86400 seconds in a day
|
|
184
|
+
diff_days <= expected_value
|
|
185
|
+
|
|
186
|
+
when "day_of_week"
|
|
187
|
+
# Checks if date falls on specified day of week
|
|
188
|
+
# expected_value can be: "monday", "tuesday", etc. or 0-6 (Sunday=0)
|
|
189
|
+
return false unless actual_value
|
|
190
|
+
|
|
191
|
+
date = parse_date(actual_value)
|
|
192
|
+
return false unless date
|
|
193
|
+
|
|
194
|
+
expected_day = normalize_day_of_week(expected_value)
|
|
195
|
+
return false unless expected_day
|
|
196
|
+
|
|
197
|
+
date.wday == expected_day
|
|
198
|
+
|
|
199
|
+
# COLLECTION OPERATORS
|
|
200
|
+
when "contains_all"
|
|
201
|
+
# Checks if array contains all specified elements
|
|
202
|
+
# expected_value should be an array
|
|
203
|
+
return false unless actual_value.is_a?(Array)
|
|
204
|
+
return false unless expected_value.is_a?(Array)
|
|
205
|
+
|
|
206
|
+
expected_value.all? { |item| actual_value.include?(item) }
|
|
207
|
+
|
|
208
|
+
when "contains_any"
|
|
209
|
+
# Checks if array contains any of the specified elements
|
|
210
|
+
# expected_value should be an array
|
|
211
|
+
return false unless actual_value.is_a?(Array)
|
|
212
|
+
return false unless expected_value.is_a?(Array)
|
|
213
|
+
|
|
214
|
+
expected_value.any? { |item| actual_value.include?(item) }
|
|
215
|
+
|
|
216
|
+
when "intersects"
|
|
217
|
+
# Checks if two arrays have any common elements
|
|
218
|
+
# expected_value should be an array
|
|
219
|
+
return false unless actual_value.is_a?(Array)
|
|
220
|
+
return false unless expected_value.is_a?(Array)
|
|
221
|
+
|
|
222
|
+
!(actual_value & expected_value).empty?
|
|
223
|
+
|
|
224
|
+
when "subset_of"
|
|
225
|
+
# Checks if array is a subset of another array
|
|
226
|
+
# All elements in actual_value must be in expected_value
|
|
227
|
+
return false unless actual_value.is_a?(Array)
|
|
228
|
+
return false unless expected_value.is_a?(Array)
|
|
229
|
+
|
|
230
|
+
actual_value.all? { |item| expected_value.include?(item) }
|
|
231
|
+
|
|
232
|
+
# GEOSPATIAL OPERATORS
|
|
233
|
+
when "within_radius"
|
|
234
|
+
# Checks if point is within radius of center point
|
|
235
|
+
# actual_value: {lat: y, lon: x} or [lat, lon]
|
|
236
|
+
# expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
|
|
237
|
+
point = parse_coordinates(actual_value)
|
|
238
|
+
return false unless point
|
|
239
|
+
|
|
240
|
+
params = parse_radius_params(expected_value)
|
|
241
|
+
return false unless params
|
|
242
|
+
|
|
243
|
+
distance = haversine_distance(point, params[:center])
|
|
244
|
+
distance <= params[:radius]
|
|
245
|
+
|
|
246
|
+
when "in_polygon"
|
|
247
|
+
# Checks if point is inside a polygon using ray casting algorithm
|
|
248
|
+
# actual_value: {lat: y, lon: x} or [lat, lon]
|
|
249
|
+
# expected_value: array of vertices [{lat: y, lon: x}, ...] or [[lat, lon], ...]
|
|
250
|
+
point = parse_coordinates(actual_value)
|
|
251
|
+
return false unless point
|
|
252
|
+
|
|
253
|
+
polygon = parse_polygon(expected_value)
|
|
254
|
+
return false unless polygon
|
|
255
|
+
return false if polygon.size < 3 # Need at least 3 vertices
|
|
256
|
+
|
|
257
|
+
point_in_polygon?(point, polygon)
|
|
258
|
+
|
|
101
259
|
else
|
|
102
260
|
# Unknown operator - returns false (fail-safe)
|
|
103
261
|
# Note: Validation should catch this earlier
|
|
104
262
|
false
|
|
105
263
|
end
|
|
106
264
|
end
|
|
265
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
107
266
|
|
|
108
267
|
# Retrieves nested values from a hash using dot notation
|
|
109
268
|
#
|
|
@@ -114,7 +273,7 @@ module DecisionAgent
|
|
|
114
273
|
#
|
|
115
274
|
# Supports both string and symbol keys in the hash
|
|
116
275
|
def self.get_nested_value(hash, key_path)
|
|
117
|
-
keys = key_path
|
|
276
|
+
keys = get_cached_path(key_path)
|
|
118
277
|
keys.reduce(hash) do |memo, key|
|
|
119
278
|
return nil unless memo.is_a?(Hash)
|
|
120
279
|
|
|
@@ -129,6 +288,224 @@ module DecisionAgent
|
|
|
129
288
|
(val2.is_a?(Numeric) || val2.is_a?(String)) &&
|
|
130
289
|
val1.instance_of?(val2.class)
|
|
131
290
|
end
|
|
291
|
+
|
|
292
|
+
# Helper methods for new operators
|
|
293
|
+
|
|
294
|
+
# String operator validation
|
|
295
|
+
def self.string_operator?(actual_value, expected_value)
|
|
296
|
+
actual_value.is_a?(String) && expected_value.is_a?(String)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Parse range for 'between' operator
|
|
300
|
+
# Accepts [min, max] or {min: x, max: y}
|
|
301
|
+
def self.parse_range(value)
|
|
302
|
+
if value.is_a?(Array) && value.size == 2
|
|
303
|
+
{ min: value[0], max: value[1] }
|
|
304
|
+
elsif value.is_a?(Hash)
|
|
305
|
+
min = value["min"] || value[:min]
|
|
306
|
+
max = value["max"] || value[:max]
|
|
307
|
+
return nil unless min && max
|
|
308
|
+
|
|
309
|
+
{ min: min, max: max }
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Parse modulo parameters
|
|
314
|
+
# Accepts [divisor, remainder] or {divisor: x, remainder: y}
|
|
315
|
+
def self.parse_modulo_params(value)
|
|
316
|
+
if value.is_a?(Array) && value.size == 2
|
|
317
|
+
{ divisor: value[0], remainder: value[1] }
|
|
318
|
+
elsif value.is_a?(Hash)
|
|
319
|
+
divisor = value["divisor"] || value[:divisor]
|
|
320
|
+
remainder = value["remainder"] || value[:remainder]
|
|
321
|
+
return nil unless divisor && !remainder.nil?
|
|
322
|
+
|
|
323
|
+
{ divisor: divisor, remainder: remainder }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Parse date from string, Time, Date, or DateTime (with caching)
|
|
328
|
+
def self.parse_date(value)
|
|
329
|
+
case value
|
|
330
|
+
when Time, Date, DateTime
|
|
331
|
+
value
|
|
332
|
+
when String
|
|
333
|
+
get_cached_date(value)
|
|
334
|
+
end
|
|
335
|
+
rescue ArgumentError
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Compare two dates with given operator
|
|
340
|
+
def self.compare_dates(actual_value, expected_value, operator)
|
|
341
|
+
return false unless actual_value && expected_value
|
|
342
|
+
|
|
343
|
+
actual_date = parse_date(actual_value)
|
|
344
|
+
expected_date = parse_date(expected_value)
|
|
345
|
+
|
|
346
|
+
return false unless actual_date && expected_date
|
|
347
|
+
|
|
348
|
+
actual_date.send(operator, expected_date)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Normalize day of week to 0-6 (Sunday=0)
|
|
352
|
+
def self.normalize_day_of_week(value)
|
|
353
|
+
case value
|
|
354
|
+
when Numeric
|
|
355
|
+
value.to_i % 7
|
|
356
|
+
when String
|
|
357
|
+
day_map = {
|
|
358
|
+
"sunday" => 0, "sun" => 0,
|
|
359
|
+
"monday" => 1, "mon" => 1,
|
|
360
|
+
"tuesday" => 2, "tue" => 2,
|
|
361
|
+
"wednesday" => 3, "wed" => 3,
|
|
362
|
+
"thursday" => 4, "thu" => 4,
|
|
363
|
+
"friday" => 5, "fri" => 5,
|
|
364
|
+
"saturday" => 6, "sat" => 6
|
|
365
|
+
}
|
|
366
|
+
day_map[value.downcase]
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Parse coordinates from hash or array
|
|
371
|
+
# Accepts {lat: y, lon: x}, {latitude: y, longitude: x}, or [lat, lon]
|
|
372
|
+
def self.parse_coordinates(value)
|
|
373
|
+
case value
|
|
374
|
+
when Hash
|
|
375
|
+
lat = value["lat"] || value[:lat] || value["latitude"] || value[:latitude]
|
|
376
|
+
lon = value["lon"] || value[:lon] || value["lng"] || value[:lng] ||
|
|
377
|
+
value["longitude"] || value[:longitude]
|
|
378
|
+
return nil unless lat && lon
|
|
379
|
+
|
|
380
|
+
{ lat: lat.to_f, lon: lon.to_f }
|
|
381
|
+
when Array
|
|
382
|
+
return nil unless value.size == 2
|
|
383
|
+
|
|
384
|
+
{ lat: value[0].to_f, lon: value[1].to_f }
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Parse radius parameters
|
|
389
|
+
# expected_value: {center: {lat: y, lon: x}, radius: distance_in_km}
|
|
390
|
+
def self.parse_radius_params(value)
|
|
391
|
+
return nil unless value.is_a?(Hash)
|
|
392
|
+
|
|
393
|
+
center_data = value["center"] || value[:center]
|
|
394
|
+
radius = value["radius"] || value[:radius]
|
|
395
|
+
|
|
396
|
+
return nil unless center_data && radius
|
|
397
|
+
|
|
398
|
+
center = parse_coordinates(center_data)
|
|
399
|
+
return nil unless center
|
|
400
|
+
|
|
401
|
+
{ center: center, radius: radius.to_f }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Parse polygon vertices
|
|
405
|
+
# Accepts array of coordinate hashes or arrays
|
|
406
|
+
def self.parse_polygon(value)
|
|
407
|
+
return nil unless value.is_a?(Array)
|
|
408
|
+
|
|
409
|
+
value.map { |vertex| parse_coordinates(vertex) }.compact
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Calculate distance between two points using Haversine formula
|
|
413
|
+
# Returns distance in kilometers
|
|
414
|
+
def self.haversine_distance(point1, point2)
|
|
415
|
+
earth_radius_km = 6371.0
|
|
416
|
+
|
|
417
|
+
lat1_rad = (point1[:lat] * Math::PI) / 180
|
|
418
|
+
lat2_rad = (point2[:lat] * Math::PI) / 180
|
|
419
|
+
delta_lat = ((point2[:lat] - point1[:lat]) * Math::PI) / 180
|
|
420
|
+
delta_lon = ((point2[:lon] - point1[:lon]) * Math::PI) / 180
|
|
421
|
+
|
|
422
|
+
a = (Math.sin(delta_lat / 2)**2) +
|
|
423
|
+
(Math.cos(lat1_rad) * Math.cos(lat2_rad) *
|
|
424
|
+
(Math.sin(delta_lon / 2)**2))
|
|
425
|
+
|
|
426
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
427
|
+
|
|
428
|
+
earth_radius_km * c
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Check if point is inside polygon using ray casting algorithm
|
|
432
|
+
def self.point_in_polygon?(point, polygon)
|
|
433
|
+
x = point[:lon]
|
|
434
|
+
y = point[:lat]
|
|
435
|
+
inside = false
|
|
436
|
+
|
|
437
|
+
j = polygon.size - 1
|
|
438
|
+
polygon.size.times do |i|
|
|
439
|
+
xi = polygon[i][:lon]
|
|
440
|
+
yi = polygon[i][:lat]
|
|
441
|
+
xj = polygon[j][:lon]
|
|
442
|
+
yj = polygon[j][:lat]
|
|
443
|
+
|
|
444
|
+
intersect = ((yi > y) != (yj > y)) &&
|
|
445
|
+
(x < ((((xj - xi) * (y - yi)) / (yj - yi)) + xi))
|
|
446
|
+
inside = !inside if intersect
|
|
447
|
+
|
|
448
|
+
j = i
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
inside
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Cache management methods
|
|
455
|
+
|
|
456
|
+
# Get or compile regex with caching
|
|
457
|
+
def self.get_cached_regex(pattern)
|
|
458
|
+
return pattern if pattern.is_a?(Regexp)
|
|
459
|
+
|
|
460
|
+
# Fast path: check cache without lock
|
|
461
|
+
cached = @regex_cache[pattern]
|
|
462
|
+
return cached if cached
|
|
463
|
+
|
|
464
|
+
# Slow path: compile and cache
|
|
465
|
+
@regex_cache_mutex.synchronize do
|
|
466
|
+
@regex_cache[pattern] ||= Regexp.new(pattern.to_s)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Get cached split path
|
|
471
|
+
def self.get_cached_path(key_path)
|
|
472
|
+
# Fast path: check cache without lock
|
|
473
|
+
cached = @path_cache[key_path]
|
|
474
|
+
return cached if cached
|
|
475
|
+
|
|
476
|
+
# Slow path: split and cache
|
|
477
|
+
@path_cache_mutex.synchronize do
|
|
478
|
+
@path_cache[key_path] ||= key_path.to_s.split(".").freeze
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Get cached parsed date
|
|
483
|
+
def self.get_cached_date(date_string)
|
|
484
|
+
# Fast path: check cache without lock
|
|
485
|
+
cached = @date_cache[date_string]
|
|
486
|
+
return cached if cached
|
|
487
|
+
|
|
488
|
+
# Slow path: parse and cache
|
|
489
|
+
@date_cache_mutex.synchronize do
|
|
490
|
+
@date_cache[date_string] ||= Time.parse(date_string)
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Clear all caches (useful for testing or memory management)
|
|
495
|
+
def self.clear_caches!
|
|
496
|
+
@regex_cache_mutex.synchronize { @regex_cache.clear }
|
|
497
|
+
@path_cache_mutex.synchronize { @path_cache.clear }
|
|
498
|
+
@date_cache_mutex.synchronize { @date_cache.clear }
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Get cache statistics
|
|
502
|
+
def self.cache_stats
|
|
503
|
+
{
|
|
504
|
+
regex_cache_size: @regex_cache.size,
|
|
505
|
+
path_cache_size: @path_cache.size,
|
|
506
|
+
date_cache_size: @date_cache.size
|
|
507
|
+
}
|
|
508
|
+
end
|
|
132
509
|
end
|
|
133
510
|
end
|
|
134
511
|
end
|
|
@@ -3,7 +3,14 @@ module DecisionAgent
|
|
|
3
3
|
# JSON Schema validator for Decision Agent rule DSL
|
|
4
4
|
# Provides comprehensive validation with detailed error messages
|
|
5
5
|
class SchemaValidator
|
|
6
|
-
SUPPORTED_OPERATORS = %w[
|
|
6
|
+
SUPPORTED_OPERATORS = %w[
|
|
7
|
+
eq neq gt gte lt lte in present blank
|
|
8
|
+
contains starts_with ends_with matches
|
|
9
|
+
between modulo
|
|
10
|
+
before_date after_date within_days day_of_week
|
|
11
|
+
contains_all contains_any intersects subset_of
|
|
12
|
+
within_radius in_polygon
|
|
13
|
+
].freeze
|
|
7
14
|
|
|
8
15
|
CONDITION_TYPES = %w[all any field].freeze
|
|
9
16
|
|
|
@@ -74,4 +74,42 @@ module DecisionAgent
|
|
|
74
74
|
|
|
75
75
|
# Alias for backward compatibility and clearer naming
|
|
76
76
|
ConfigurationError = InvalidConfigurationError
|
|
77
|
+
|
|
78
|
+
# Testing-specific errors
|
|
79
|
+
class ImportError < Error
|
|
80
|
+
def initialize(message = "Failed to import test scenarios")
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class InvalidTestDataError < Error
|
|
86
|
+
attr_reader :row_number, :errors
|
|
87
|
+
|
|
88
|
+
def initialize(message = "Invalid test data", row_number: nil, errors: [])
|
|
89
|
+
@row_number = row_number
|
|
90
|
+
@errors = errors
|
|
91
|
+
full_message = message.dup
|
|
92
|
+
full_message += " (row #{row_number})" if row_number
|
|
93
|
+
full_message += ": #{errors.join(', ')}" if errors.any?
|
|
94
|
+
super(full_message)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class BatchTestError < Error
|
|
99
|
+
def initialize(message = "Batch test execution failed")
|
|
100
|
+
super
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class AuthenticationError < Error
|
|
105
|
+
def initialize(message = "Authentication failed")
|
|
106
|
+
super
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class PermissionDeniedError < Error
|
|
111
|
+
def initialize(message = "Permission denied")
|
|
112
|
+
super
|
|
113
|
+
end
|
|
114
|
+
end
|
|
77
115
|
end
|
|
@@ -41,14 +41,21 @@ module DecisionAgent
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def deep_freeze(obj)
|
|
44
|
+
return obj if obj.frozen?
|
|
45
|
+
|
|
44
46
|
case obj
|
|
45
47
|
when Hash
|
|
46
|
-
obj.
|
|
48
|
+
obj.each_value { |v| deep_freeze(v) }
|
|
49
|
+
obj.freeze
|
|
47
50
|
when Array
|
|
48
|
-
obj.
|
|
49
|
-
else
|
|
51
|
+
obj.each { |v| deep_freeze(v) }
|
|
50
52
|
obj.freeze
|
|
53
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
|
|
54
|
+
obj.freeze
|
|
55
|
+
else
|
|
56
|
+
obj.freeze if obj.respond_to?(:freeze)
|
|
51
57
|
end
|
|
58
|
+
obj
|
|
52
59
|
end
|
|
53
60
|
end
|
|
54
61
|
end
|
|
@@ -40,7 +40,8 @@ module DecisionAgent
|
|
|
40
40
|
private_class_method def self.validate_decision!(decision)
|
|
41
41
|
raise ValidationError, "Decision cannot be nil" if decision.nil?
|
|
42
42
|
raise ValidationError, "Decision must be a String" unless decision.is_a?(String)
|
|
43
|
-
|
|
43
|
+
# Fast path: skip strip if string is clearly not empty (length > 0)
|
|
44
|
+
raise ValidationError, "Decision cannot be empty" if decision.empty? || decision.strip.empty?
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
private_class_method def self.validate_weight!(weight)
|
|
@@ -52,7 +53,8 @@ module DecisionAgent
|
|
|
52
53
|
private_class_method def self.validate_reason!(reason)
|
|
53
54
|
raise ValidationError, "Reason cannot be nil" if reason.nil?
|
|
54
55
|
raise ValidationError, "Reason must be a String" unless reason.is_a?(String)
|
|
55
|
-
|
|
56
|
+
# Fast path: skip strip if string is clearly not empty (length > 0)
|
|
57
|
+
raise ValidationError, "Reason cannot be empty" if reason.empty? || reason.strip.empty?
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
private_class_method def self.validate_evaluator_name!(name)
|
|
@@ -61,18 +63,11 @@ module DecisionAgent
|
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
private_class_method def self.validate_frozen!(evaluation)
|
|
64
|
-
|
|
66
|
+
# Fast path: if evaluation is frozen, assume nested structures are also frozen
|
|
67
|
+
# (they are frozen in Evaluation#initialize)
|
|
68
|
+
return true if evaluation.frozen?
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
raise ValidationError, "Evaluation decision must be frozen" unless evaluation.decision.frozen?
|
|
68
|
-
|
|
69
|
-
raise ValidationError, "Evaluation reason must be frozen" unless evaluation.reason.frozen?
|
|
70
|
-
|
|
71
|
-
raise ValidationError, "Evaluation evaluator_name must be frozen" unless evaluation.evaluator_name.frozen?
|
|
72
|
-
|
|
73
|
-
return unless evaluation.metadata && !evaluation.metadata.frozen?
|
|
74
|
-
|
|
75
|
-
raise ValidationError, "Evaluation metadata must be frozen"
|
|
70
|
+
raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)"
|
|
76
71
|
end
|
|
77
72
|
end
|
|
78
73
|
end
|