decision_agent 0.1.2 → 0.1.3

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +11 -8
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  29. data/lib/decision_agent/versioning/adapter.rb +1 -3
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  31. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  32. data/lib/decision_agent/web/public/index.html +1 -1
  33. data/lib/decision_agent/web/server.rb +19 -24
  34. data/lib/decision_agent.rb +7 -0
  35. data/lib/generators/decision_agent/install/install_generator.rb +5 -5
  36. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  37. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  38. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  39. data/spec/activerecord_thread_safety_spec.rb +553 -0
  40. data/spec/agent_spec.rb +13 -13
  41. data/spec/api_contract_spec.rb +16 -16
  42. data/spec/audit_adapters_spec.rb +3 -3
  43. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  44. data/spec/dsl_validation_spec.rb +83 -83
  45. data/spec/edge_cases_spec.rb +23 -23
  46. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  47. data/spec/examples.txt +548 -0
  48. data/spec/issue_verification_spec.rb +685 -0
  49. data/spec/json_rule_evaluator_spec.rb +15 -15
  50. data/spec/monitoring/alert_manager_spec.rb +378 -0
  51. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  52. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  53. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  54. data/spec/replay_edge_cases_spec.rb +58 -58
  55. data/spec/replay_spec.rb +11 -11
  56. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  57. data/spec/scoring_spec.rb +1 -1
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/thread_safety_spec.rb +482 -0
  60. data/spec/thread_safety_spec.rb.broken +878 -0
  61. data/spec/versioning_spec.rb +141 -37
  62. data/spec/web_ui_rack_spec.rb +135 -0
  63. metadata +69 -6
@@ -0,0 +1,553 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Only run these tests if ActiveRecord is available
6
+ if defined?(ActiveRecord)
7
+ RSpec.describe "ActiveRecordAdapter Thread-Safety" do
8
+ # Setup in-memory SQLite database for testing
9
+ before(:all) do
10
+ ActiveRecord::Base.establish_connection(
11
+ adapter: "sqlite3",
12
+ database: "file::memory:?cache=shared",
13
+ timeout: 10_000,
14
+ pool: 110 # Support 100 thread test + some overhead
15
+ )
16
+
17
+ # Enable WAL mode and busy timeout for better concurrency
18
+ ActiveRecord::Base.connection.execute("PRAGMA journal_mode=WAL")
19
+ ActiveRecord::Base.connection.execute("PRAGMA busy_timeout=10000")
20
+
21
+ # Create the schema
22
+ ActiveRecord::Schema.define do
23
+ create_table :rule_versions, force: true do |t|
24
+ t.string :rule_id, null: false
25
+ t.integer :version_number, null: false
26
+ t.text :content, null: false
27
+ t.string :created_by, null: false, default: "system"
28
+ t.text :changelog
29
+ t.string :status, null: false, default: "draft"
30
+ t.timestamps
31
+ end
32
+
33
+ add_index :rule_versions, %i[rule_id version_number], unique: true
34
+ add_index :rule_versions, %i[rule_id status]
35
+ end
36
+
37
+ # Define RuleVersion model if not already defined
38
+ unless defined?(RuleVersion)
39
+ class ::RuleVersion < ActiveRecord::Base
40
+ validates :rule_id, presence: true
41
+ validates :version_number, presence: true, uniqueness: { scope: :rule_id }
42
+ validates :content, presence: true
43
+ validates :status, inclusion: { in: %w[draft active archived] }
44
+ validates :created_by, presence: true
45
+
46
+ scope :active, -> { where(status: "active") }
47
+ scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
48
+ scope :latest, -> { order(version_number: :desc).limit(1) }
49
+
50
+ before_create :set_next_version_number
51
+
52
+ def parsed_content
53
+ JSON.parse(content, symbolize_names: true)
54
+ rescue JSON::ParserError
55
+ {}
56
+ end
57
+
58
+ def content_hash=(hash)
59
+ self.content = hash.to_json
60
+ end
61
+
62
+ def activate!
63
+ transaction do
64
+ self.class.where(rule_id: rule_id, status: "active")
65
+ .where.not(id: id)
66
+ .update_all(status: "archived")
67
+ update!(status: "active")
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def set_next_version_number
74
+ return if version_number.present?
75
+
76
+ # Use pessimistic locking to prevent race conditions
77
+ last_version = self.class.where(rule_id: rule_id)
78
+ .order(version_number: :desc)
79
+ .lock
80
+ .first
81
+
82
+ self.version_number = last_version ? last_version.version_number + 1 : 1
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ before(:each) do
89
+ RuleVersion.delete_all
90
+ end
91
+
92
+ let(:adapter) { DecisionAgent::Versioning::ActiveRecordAdapter.new }
93
+ let(:rule_id) { "concurrent_test_rule" }
94
+ let(:rule_content) do
95
+ {
96
+ version: "1.0",
97
+ ruleset: "test_rules",
98
+ rules: [
99
+ {
100
+ id: "test_rule",
101
+ if: { field: "amount", op: "gt", value: 100 },
102
+ then: { decision: "approve", weight: 0.8, reason: "Test" }
103
+ }
104
+ ]
105
+ }
106
+ end
107
+
108
+ # Helper method to retry on SQLite busy exceptions (concurrency limitation)
109
+ def with_retry(max_retries: 3, &block)
110
+ retries = 0
111
+ begin
112
+ block.call
113
+ rescue ActiveRecord::StatementInvalid => e
114
+ raise unless e.message.include?("database is locked") && retries < max_retries
115
+
116
+ retries += 1
117
+ sleep(0.01 * retries) # Exponential backoff
118
+ retry
119
+ end
120
+ end
121
+
122
+ describe "concurrent version creation" do
123
+ it "prevents duplicate version numbers with pessimistic locking" do
124
+ thread_count = 20
125
+ threads = []
126
+ results = []
127
+ mutex = Mutex.new
128
+
129
+ # Spawn multiple threads creating versions concurrently
130
+ thread_count.times do |i|
131
+ threads << Thread.new do
132
+ version = with_retry do
133
+ adapter.create_version(
134
+ rule_id: rule_id,
135
+ content: rule_content.merge(thread_id: i),
136
+ metadata: { created_by: "thread_#{i}" }
137
+ )
138
+ end
139
+ mutex.synchronize { results << version }
140
+ end
141
+ end
142
+
143
+ threads.each(&:join)
144
+
145
+ # All versions should be created successfully
146
+ expect(results.size).to eq(thread_count)
147
+
148
+ # Version numbers must be unique and sequential
149
+ version_numbers = results.map { |v| v[:version_number] }.sort
150
+ expect(version_numbers).to eq((1..thread_count).to_a)
151
+
152
+ # Verify in database
153
+ db_versions = RuleVersion.where(rule_id: rule_id).order(:version_number)
154
+ expect(db_versions.count).to eq(thread_count)
155
+ expect(db_versions.pluck(:version_number)).to eq((1..thread_count).to_a)
156
+ end
157
+
158
+ it "handles high concurrency (100 threads)" do
159
+ thread_count = 100
160
+ threads = []
161
+ errors = []
162
+ mutex = Mutex.new
163
+
164
+ thread_count.times do |i|
165
+ threads << Thread.new do
166
+ with_retry(max_retries: 10) do # Increased retry count for high concurrency
167
+ adapter.create_version(
168
+ rule_id: rule_id,
169
+ content: rule_content,
170
+ metadata: { created_by: "thread_#{i}" }
171
+ )
172
+ end
173
+ rescue StandardError => e
174
+ mutex.synchronize { errors << e }
175
+ end
176
+ end
177
+
178
+ threads.each(&:join)
179
+
180
+ # Should have no errors
181
+ expect(errors).to be_empty
182
+
183
+ # All versions created with unique version numbers
184
+ versions = RuleVersion.where(rule_id: rule_id).order(:version_number)
185
+ expect(versions.count).to eq(thread_count)
186
+ expect(versions.pluck(:version_number)).to eq((1..thread_count).to_a)
187
+ end
188
+
189
+ it "maintains unique constraint even under extreme concurrency" do
190
+ # This test verifies the database-level unique constraint catches any edge cases
191
+ thread_count = 50
192
+ threads = []
193
+ successes = []
194
+ failures = []
195
+ mutex = Mutex.new
196
+
197
+ thread_count.times do |i|
198
+ threads << Thread.new do
199
+ version = with_retry do
200
+ adapter.create_version(
201
+ rule_id: rule_id,
202
+ content: rule_content,
203
+ metadata: { created_by: "thread_#{i}" }
204
+ )
205
+ end
206
+ mutex.synchronize { successes << version }
207
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
208
+ # These errors are acceptable - they mean the unique constraint caught duplicates
209
+ mutex.synchronize { failures << e }
210
+ end
211
+ end
212
+
213
+ threads.each(&:join)
214
+
215
+ # Either all succeed with unique versions, or some fail due to constraint
216
+ total_attempts = successes.size + failures.size
217
+ expect(total_attempts).to eq(thread_count)
218
+
219
+ # All successful versions must have unique version numbers
220
+ version_numbers = successes.map { |v| v[:version_number] }
221
+ expect(version_numbers.uniq.size).to eq(version_numbers.size)
222
+
223
+ # Database should have only unique versions
224
+ db_versions = RuleVersion.where(rule_id: rule_id)
225
+ db_version_numbers = db_versions.pluck(:version_number).sort
226
+ expect(db_version_numbers).to eq(db_version_numbers.uniq)
227
+ end
228
+ end
229
+
230
+ describe "concurrent read and write operations" do
231
+ it "allows safe concurrent reads during writes" do
232
+ # Create initial version
233
+ adapter.create_version(
234
+ rule_id: rule_id,
235
+ content: rule_content,
236
+ metadata: { created_by: "setup" }
237
+ )
238
+
239
+ threads = []
240
+ read_results = []
241
+ write_results = []
242
+ read_mutex = Mutex.new
243
+ write_mutex = Mutex.new
244
+
245
+ # Mix of readers and writers
246
+ 20.times do |i|
247
+ threads << if i % 3 == 0
248
+ # Write thread
249
+ Thread.new do
250
+ version = with_retry do
251
+ adapter.create_version(
252
+ rule_id: rule_id,
253
+ content: rule_content,
254
+ metadata: { created_by: "writer_#{i}" }
255
+ )
256
+ end
257
+ write_mutex.synchronize { write_results << version }
258
+ end
259
+ else
260
+ # Read thread
261
+ Thread.new do
262
+ versions = adapter.list_versions(rule_id: rule_id)
263
+ read_mutex.synchronize { read_results << versions }
264
+ end
265
+ end
266
+ end
267
+
268
+ threads.each(&:join)
269
+
270
+ # Readers should never see corrupted data
271
+ read_results.each do |versions|
272
+ expect(versions).to be_an(Array)
273
+ versions.each do |v|
274
+ expect(v[:version_number]).to be > 0
275
+ expect(v[:rule_id]).to eq(rule_id)
276
+ end
277
+ end
278
+
279
+ # Writers should create valid sequential versions
280
+ write_version_numbers = write_results.map { |v| v[:version_number] }.sort
281
+ expect(write_version_numbers.first).to eq(2) # First write creates version 2
282
+ end
283
+ end
284
+
285
+ describe "status updates during concurrent creation" do
286
+ it "ensures only one active version at a time" do
287
+ thread_count = 10
288
+ threads = []
289
+
290
+ thread_count.times do |i|
291
+ threads << Thread.new do
292
+ with_retry do
293
+ adapter.create_version(
294
+ rule_id: rule_id,
295
+ content: rule_content,
296
+ metadata: { created_by: "thread_#{i}", status: "active" }
297
+ )
298
+ end
299
+ end
300
+ end
301
+
302
+ threads.each(&:join)
303
+
304
+ # Only the last created version should be active
305
+ active_versions = RuleVersion.where(rule_id: rule_id, status: "active")
306
+ expect(active_versions.count).to eq(1)
307
+
308
+ # The active version should be the last one
309
+ expect(active_versions.first.version_number).to eq(thread_count)
310
+
311
+ # All others should be archived
312
+ archived_versions = RuleVersion.where(rule_id: rule_id, status: "archived")
313
+ expect(archived_versions.count).to eq(thread_count - 1)
314
+ end
315
+ end
316
+
317
+ describe "multiple rules concurrently" do
318
+ it "handles version creation for different rules in parallel" do
319
+ rule_ids = (1..10).map { |i| "rule_#{i}" }
320
+ threads = []
321
+ results = {}
322
+ mutex = Mutex.new
323
+
324
+ rule_ids.each do |rid|
325
+ 5.times do |version_index|
326
+ threads << Thread.new do
327
+ version = with_retry do
328
+ adapter.create_version(
329
+ rule_id: rid,
330
+ content: rule_content,
331
+ metadata: { created_by: "creator_#{version_index}" }
332
+ )
333
+ end
334
+ mutex.synchronize do
335
+ results[rid] ||= []
336
+ results[rid] << version
337
+ end
338
+ end
339
+ end
340
+ end
341
+
342
+ threads.each(&:join)
343
+
344
+ # Each rule should have 5 versions
345
+ rule_ids.each do |rid|
346
+ expect(results[rid].size).to eq(5)
347
+ version_numbers = results[rid].map { |v| v[:version_number] }.sort
348
+ expect(version_numbers).to eq([1, 2, 3, 4, 5])
349
+ end
350
+ end
351
+ end
352
+
353
+ describe "transaction rollback on errors" do
354
+ it "rolls back version creation if there's an error" do
355
+ # Create a scenario where create might fail
356
+ allow(RuleVersion).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(RuleVersion.new))
357
+
358
+ expect do
359
+ adapter.create_version(
360
+ rule_id: rule_id,
361
+ content: rule_content
362
+ )
363
+ end.to raise_error(ActiveRecord::RecordInvalid)
364
+
365
+ # No versions should be created
366
+ expect(RuleVersion.where(rule_id: rule_id).count).to eq(0)
367
+ end
368
+ end
369
+
370
+ describe "RuleVersion model callback thread safety" do
371
+ it "safely calculates version numbers when using model directly" do
372
+ thread_count = 30
373
+ threads = []
374
+ errors = []
375
+ mutex = Mutex.new
376
+
377
+ thread_count.times do |i|
378
+ threads << Thread.new do
379
+ RuleVersion.create!(
380
+ rule_id: rule_id,
381
+ content: rule_content.to_json,
382
+ created_by: "thread_#{i}",
383
+ status: "draft"
384
+ )
385
+ rescue StandardError => e
386
+ mutex.synchronize { errors << e }
387
+ end
388
+ end
389
+
390
+ threads.each(&:join)
391
+
392
+ # Should have minimal or no errors (unique constraint might catch some)
393
+ # The key is version numbers should be unique
394
+ versions = RuleVersion.where(rule_id: rule_id).order(:version_number)
395
+ version_numbers = versions.pluck(:version_number)
396
+
397
+ # All version numbers should be unique
398
+ expect(version_numbers.uniq.size).to eq(version_numbers.size)
399
+ end
400
+ end
401
+
402
+ describe "concurrent activate_version" do
403
+ it "prevents multiple active versions with pessimistic locking" do
404
+ # Create 5 versions
405
+ versions = 5.times.map do |i|
406
+ with_retry do
407
+ adapter.create_version(
408
+ rule_id: rule_id,
409
+ content: rule_content.merge(version: "#{i + 1}.0"),
410
+ metadata: { created_by: "setup_#{i}" }
411
+ )
412
+ end
413
+ end
414
+
415
+ # Try to activate different versions concurrently
416
+ thread_count = 10
417
+ threads = []
418
+ activated_versions = []
419
+ mutex = Mutex.new
420
+
421
+ thread_count.times do |i|
422
+ threads << Thread.new do
423
+ # Each thread tries to activate a different version (cycling through versions)
424
+ version_to_activate = versions[i % versions.size]
425
+ activated = with_retry do
426
+ adapter.activate_version(version_id: version_to_activate[:id])
427
+ end
428
+ mutex.synchronize { activated_versions << activated }
429
+ end
430
+ end
431
+
432
+ threads.each(&:join)
433
+
434
+ # CRITICAL: Only ONE version should be active at the end
435
+ active_versions = RuleVersion.where(rule_id: rule_id, status: "active")
436
+ expect(active_versions.count).to eq(1),
437
+ "Expected exactly 1 active version, but found #{active_versions.count}: #{active_versions.pluck(:version_number)}"
438
+
439
+ # All other versions should be archived
440
+ archived_versions = RuleVersion.where(rule_id: rule_id, status: "archived")
441
+ expect(archived_versions.count).to eq(versions.size - 1)
442
+
443
+ # The active version should be one of the versions we tried to activate
444
+ active_version_id = active_versions.first.id
445
+ expect(versions.map { |v| v[:id] }).to include(active_version_id)
446
+ end
447
+
448
+ it "handles race condition when two threads activate different versions simultaneously" do
449
+ # Create 3 versions
450
+ v1 = with_retry do
451
+ adapter.create_version(
452
+ rule_id: rule_id,
453
+ content: rule_content.merge(version: "1.0"),
454
+ metadata: { created_by: "setup" }
455
+ )
456
+ end
457
+ v2 = with_retry do
458
+ adapter.create_version(
459
+ rule_id: rule_id,
460
+ content: rule_content.merge(version: "2.0"),
461
+ metadata: { created_by: "setup" }
462
+ )
463
+ end
464
+ with_retry do
465
+ adapter.create_version(
466
+ rule_id: rule_id,
467
+ content: rule_content.merge(version: "3.0"),
468
+ metadata: { created_by: "setup" }
469
+ )
470
+ end
471
+
472
+ # At this point v3 is active, v1 and v2 are archived
473
+
474
+ # Spawn two threads trying to activate v1 and v2 at the same time
475
+ begin
476
+ barrier = begin
477
+ Concurrent::CyclicBarrier.new(2)
478
+ rescue StandardError
479
+ Thread::Barrier.new(2)
480
+ end
481
+ rescue StandardError
482
+ nil
483
+ end
484
+ threads = []
485
+
486
+ if barrier
487
+ threads << Thread.new do
488
+ barrier.wait
489
+ with_retry { adapter.activate_version(version_id: v1[:id]) }
490
+ end
491
+
492
+ threads << Thread.new do
493
+ barrier.wait
494
+ with_retry { adapter.activate_version(version_id: v2[:id]) }
495
+ end
496
+
497
+ threads.each(&:join)
498
+ else
499
+ # Fallback without barrier - still tests thread safety
500
+ t1 = Thread.new { with_retry { adapter.activate_version(version_id: v1[:id]) } }
501
+ t2 = Thread.new { with_retry { adapter.activate_version(version_id: v2[:id]) } }
502
+ t1.join
503
+ t2.join
504
+ end
505
+
506
+ # CRITICAL: Only ONE version should be active
507
+ active_count = RuleVersion.where(rule_id: rule_id, status: "active").count
508
+ expect(active_count).to eq(1),
509
+ "Race condition detected: #{active_count} active versions found instead of 1"
510
+ end
511
+
512
+ it "maintains consistency across 100 concurrent activation attempts" do
513
+ # Create 10 versions
514
+ versions = 10.times.map do |i|
515
+ with_retry do
516
+ adapter.create_version(
517
+ rule_id: rule_id,
518
+ content: rule_content.merge(version: "#{i + 1}.0"),
519
+ metadata: { created_by: "setup_#{i}" }
520
+ )
521
+ end
522
+ end
523
+
524
+ # 100 threads each randomly activating versions
525
+ threads = 100.times.map do
526
+ Thread.new do
527
+ random_version = versions.sample
528
+ with_retry do
529
+ adapter.activate_version(version_id: random_version[:id])
530
+ end
531
+ sleep(rand * 0.01) # Small random delay to increase race condition likelihood
532
+ end
533
+ end
534
+
535
+ threads.each(&:join)
536
+
537
+ # Check consistency
538
+ active_versions = RuleVersion.where(rule_id: rule_id, status: "active")
539
+ expect(active_versions.count).to eq(1),
540
+ "Consistency violation: #{active_versions.count} active versions after concurrent activations"
541
+
542
+ # All versions should still exist
543
+ expect(RuleVersion.where(rule_id: rule_id).count).to eq(10)
544
+ end
545
+ end
546
+ end
547
+ else
548
+ RSpec.describe "ActiveRecordAdapter Thread-Safety" do
549
+ it "skips tests when ActiveRecord is not available" do
550
+ skip "ActiveRecord is not loaded"
551
+ end
552
+ end
553
+ end
data/spec/agent_spec.rb CHANGED
@@ -3,41 +3,41 @@ require "spec_helper"
3
3
  RSpec.describe DecisionAgent::Agent do
4
4
  describe "#initialize" do
5
5
  it "requires at least one evaluator" do
6
- expect {
6
+ expect do
7
7
  DecisionAgent::Agent.new(evaluators: [])
8
- }.to raise_error(DecisionAgent::InvalidConfigurationError, /at least one evaluator/i)
8
+ end.to raise_error(DecisionAgent::InvalidConfigurationError, /at least one evaluator/i)
9
9
  end
10
10
 
11
11
  it "validates evaluators respond to #evaluate" do
12
12
  invalid_evaluator = Object.new
13
13
 
14
- expect {
14
+ expect do
15
15
  DecisionAgent::Agent.new(evaluators: [invalid_evaluator])
16
- }.to raise_error(DecisionAgent::InvalidEvaluatorError)
16
+ end.to raise_error(DecisionAgent::InvalidEvaluatorError)
17
17
  end
18
18
 
19
19
  it "validates scoring strategy responds to #score" do
20
20
  evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
21
21
  invalid_strategy = Object.new
22
22
 
23
- expect {
23
+ expect do
24
24
  DecisionAgent::Agent.new(
25
25
  evaluators: [evaluator],
26
26
  scoring_strategy: invalid_strategy
27
27
  )
28
- }.to raise_error(DecisionAgent::InvalidScoringStrategyError)
28
+ end.to raise_error(DecisionAgent::InvalidScoringStrategyError)
29
29
  end
30
30
 
31
31
  it "validates audit adapter responds to #record" do
32
32
  evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
33
33
  invalid_adapter = Object.new
34
34
 
35
- expect {
35
+ expect do
36
36
  DecisionAgent::Agent.new(
37
37
  evaluators: [evaluator],
38
38
  audit_adapter: invalid_adapter
39
39
  )
40
- }.to raise_error(DecisionAgent::InvalidAuditAdapterError)
40
+ end.to raise_error(DecisionAgent::InvalidAuditAdapterError)
41
41
  end
42
42
 
43
43
  it "uses defaults when optional parameters are omitted" do
@@ -82,21 +82,21 @@ RSpec.describe DecisionAgent::Agent do
82
82
 
83
83
  it "raises NoEvaluationsError when no evaluators return decisions" do
84
84
  failing_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
85
- def evaluate(context, feedback: {})
85
+ def evaluate(_context, feedback: {})
86
86
  nil
87
87
  end
88
88
  end
89
89
 
90
90
  agent = DecisionAgent::Agent.new(evaluators: [failing_evaluator.new])
91
91
 
92
- expect {
92
+ expect do
93
93
  agent.decide(context: {})
94
- }.to raise_error(DecisionAgent::NoEvaluationsError)
94
+ end.to raise_error(DecisionAgent::NoEvaluationsError)
95
95
  end
96
96
 
97
97
  it "includes feedback in evaluation" do
98
98
  feedback_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
99
- def evaluate(context, feedback: {})
99
+ def evaluate(_context, feedback: {})
100
100
  decision = feedback[:override] ? "reject" : "approve"
101
101
  DecisionAgent::Evaluation.new(
102
102
  decision: decision,
@@ -234,7 +234,7 @@ RSpec.describe DecisionAgent::Agent do
234
234
  good_evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
235
235
 
236
236
  bad_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
237
- def evaluate(context, feedback: {})
237
+ def evaluate(_context, feedback: {})
238
238
  raise StandardError, "Intentional error"
239
239
  end
240
240
  end