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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. 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.to_s.split(".")
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[eq neq gt gte lt lte in present blank].freeze
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.transform_values { |v| deep_freeze(v) }.freeze
48
+ obj.each_value { |v| deep_freeze(v) }
49
+ obj.freeze
47
50
  when Array
48
- obj.map { |v| deep_freeze(v) }.freeze
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
- raise ValidationError, "Decision cannot be empty" if decision.strip.empty?
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
- raise ValidationError, "Reason cannot be empty" if reason.strip.empty?
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
- raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)" unless evaluation.frozen?
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
- # Verify nested structures are also frozen
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
@@ -69,6 +69,7 @@ module DecisionAgent
69
69
 
70
70
  def broadcast_to_clients(message)
71
71
  return unless WEBSOCKET_AVAILABLE
72
+ return if @websocket_clients.empty? # Skip if no clients connected
72
73
 
73
74
  json_message = message.to_json
74
75
  @websocket_clients.each do |client|