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,878 @@
1
+ # frozen_string_literal: true
2
+ # encoding: UTF-8
3
+
4
+ require "spec_helper"
5
+
6
+ RSpec.describe "Thread-Safety" do
7
+ describe "Agent with shared evaluators" do
8
+ let(:rules_json) do
9
+ {
10
+ version: "1.0",
11
+ ruleset: "approval_rules",
12
+ rules: [
13
+ {
14
+ id: "approve_high",
15
+ if: { field: "amount", op: "gt", value: 1000 },
16
+ then: { decision: "approve", weight: 0.9, reason: "High value" }
17
+ },
18
+ {
19
+ id: "reject_low",
20
+ if: { field: "amount", op: "lte", value: 1000 },
21
+ then: { decision: "reject", weight: 0.8, reason: "Low value" }
22
+ }
23
+ ]
24
+ }
25
+ end
26
+
27
+ let(:evaluator) { DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json) }
28
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
29
+
30
+ it "handles concurrent decisions from multiple threads safely" do
31
+ threads = []
32
+ results = Array.new(50)
33
+
34
+ # Create 50 threads making concurrent decisions
35
+ 50.times do |i|
36
+ threads << Thread.new do
37
+ context = { amount: (i % 2 == 0) ? 1500 : 500 }
38
+ results[i] = agent.decide(context: context)
39
+ end
40
+ end
41
+
42
+ threads.each(&:join)
43
+
44
+ # Verify all threads completed successfully
45
+ expect(results.compact.size).to eq(50)
46
+
47
+ # Verify results are correct and frozen
48
+ results.each_with_index do |decision, i|
49
+ expect(decision).to be_frozen
50
+ expect(decision.decision).to be_frozen
51
+ expect(decision.explanations).to be_frozen
52
+ expect(decision.evaluations).to be_frozen
53
+ expect(decision.audit_payload).to be_frozen
54
+
55
+ # Verify correctness based on input
56
+ if i % 2 == 0
57
+ expect(decision.decision).to eq("approve")
58
+ else
59
+ expect(decision.decision).to eq("reject")
60
+ end
61
+ end
62
+ end
63
+
64
+ it "prevents modification of shared evaluator ruleset" do
65
+ # Verify the ruleset is frozen
66
+ expect(evaluator.instance_variable_get(:@ruleset)).to be_frozen
67
+
68
+ # Attempt to modify should raise error
69
+ expect {
70
+ evaluator.instance_variable_get(:@ruleset)["rules"] << { id: "new_rule" }
71
+ }.to raise_error(FrozenError)
72
+ end
73
+
74
+ it "prevents modification of evaluators array in Agent" do
75
+ expect(agent.evaluators).to be_frozen
76
+
77
+ expect {
78
+ agent.evaluators << DecisionAgent::Evaluators::StaticEvaluator.new(decision: true, weight: 1.0)
79
+ }.to raise_error(FrozenError)
80
+ end
81
+ end
82
+
83
+ describe "Multiple agents sharing evaluators" do
84
+ let(:evaluator) do
85
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
86
+ rules_json: {
87
+ version: "1.0",
88
+ ruleset: "shared_rules",
89
+ rules: [
90
+ {
91
+ id: "rule1",
92
+ if: { field: "value", op: "eq", value: "yes" },
93
+ then: { decision: "approve", weight: 1.0, reason: "Match" }
94
+ }
95
+ ]
96
+ }
97
+ )
98
+ end
99
+
100
+ it "allows multiple agents to safely share the same evaluator instance" do
101
+ agent1 = DecisionAgent::Agent.new(evaluators: [evaluator])
102
+ agent2 = DecisionAgent::Agent.new(evaluators: [evaluator])
103
+ agent3 = DecisionAgent::Agent.new(evaluators: [evaluator])
104
+
105
+ threads = []
106
+ results = []
107
+
108
+ # Each agent makes decisions in parallel
109
+ [agent1, agent2, agent3].each do |agent|
110
+ threads << Thread.new do
111
+ 10.times do
112
+ decision = agent.decide(context: { value: "yes" })
113
+ mutex.synchronize { results << decision }
114
+ end
115
+ end
116
+ end
117
+
118
+ threads.each(&:join)
119
+
120
+ # All 30 decisions should succeed
121
+ expect(results.size).to eq(30)
122
+ results.each do |decision|
123
+ expect(decision.decision).to eq("approve")
124
+ expect(decision).to be_frozen
125
+ end
126
+ end
127
+ end
128
+
129
+ describe "Evaluation immutability" do
130
+ it "ensures evaluations are deeply frozen" do
131
+ evaluation = DecisionAgent::Evaluation.new(
132
+ decision: "approve",
133
+ weight: 0.8,
134
+ reason: "Test reason",
135
+ evaluator_name: "TestEvaluator",
136
+ metadata: { key: "value" }
137
+ )
138
+
139
+ expect(evaluation).to be_frozen
140
+ expect(evaluation.decision).to be_frozen
141
+ expect(evaluation.reason).to be_frozen
142
+ expect(evaluation.evaluator_name).to be_frozen
143
+ expect(evaluation.metadata).to be_frozen
144
+ end
145
+ end
146
+
147
+ describe "Decision immutability" do
148
+ it "ensures decisions are deeply frozen" do
149
+ evaluation = DecisionAgent::Evaluation.new(
150
+ decision: "approve",
151
+ weight: 1.0,
152
+ reason: "Test",
153
+ evaluator_name: "Test"
154
+ )
155
+
156
+ decision = DecisionAgent::Decision.new(
157
+ decision: "approve",
158
+ confidence: 0.95,
159
+ explanations: ["Explanation 1"],
160
+ evaluations: [evaluation],
161
+ audit_payload: { timestamp: "2024-01-01" }
162
+ )
163
+
164
+ expect(decision).to be_frozen
165
+ expect(decision.decision).to be_frozen
166
+ expect(decision.explanations).to be_frozen
167
+ expect(decision.evaluations).to be_frozen
168
+ expect(decision.audit_payload).to be_frozen
169
+
170
+ # Nested structures should also be frozen
171
+ expect(decision.explanations.first).to be_frozen
172
+ expect(decision.evaluations.first).to be_frozen
173
+ end
174
+ end
175
+
176
+ describe "Context immutability" do
177
+ it "freezes context data to prevent mutation" do
178
+ context_data = { user: { id: 1, name: "Test" }, amount: 100 }
179
+ context = DecisionAgent::Context.new(context_data)
180
+
181
+ expect(context.to_h).to be_frozen
182
+ expect(context.to_h[:user]).to be_frozen
183
+
184
+ # Original data should not be affected
185
+ expect(context_data).not_to be_frozen
186
+ end
187
+ end
188
+
189
+ describe "Concurrent file storage operations" do
190
+ let(:storage_path) { File.join(__dir__, "../tmp/thread_safety_test") }
191
+ let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: storage_path) }
192
+
193
+ before do
194
+ FileUtils.rm_rf(storage_path) if Dir.exist?(storage_path)
195
+ end
196
+
197
+ after do
198
+ FileUtils.rm_rf(storage_path) if Dir.exist?(storage_path)
199
+ end
200
+
201
+ it "handles concurrent version creation safely" do
202
+ threads = []
203
+ results = []
204
+
205
+ # Create 10 versions concurrently
206
+ 10.times do |i|
207
+ threads << Thread.new do
208
+ version = adapter.create_version(
209
+ rule_id: "concurrent_rule",
210
+ content: { rule: "version_#{i}" },
211
+ metadata: { created_by: "thread_#{i}" }
212
+ )
213
+ mutex.synchronize { results << version }
214
+ end
215
+ end
216
+
217
+ threads.each(&:join)
218
+
219
+ # All versions should be created successfully
220
+ expect(results.size).to eq(10)
221
+
222
+ # Version numbers should be unique and sequential
223
+ version_numbers = results.map { |v| v[:version_number] }.sort
224
+ expect(version_numbers).to eq((1..10).to_a)
225
+
226
+ # Each thread created its version as active
227
+ # Due to thread scheduling, all might be created as active initially
228
+ # The last one written should be active in the file system
229
+ final_active = adapter.get_active_version(rule_id: "concurrent_rule")
230
+ expect(final_active).not_to be_nil
231
+ expect(final_active[:status]).to eq("active")
232
+ end
233
+
234
+ it "handles concurrent read and write operations safely" do
235
+ # Create initial version
236
+ adapter.create_version(
237
+ rule_id: "read_write_test",
238
+ content: { rule: "initial" },
239
+ metadata: { created_by: "setup" }
240
+ )
241
+
242
+ threads = []
243
+ read_results = []
244
+ write_results = []
245
+
246
+ # Mix of read and write operations
247
+ 10.times do |i|
248
+ if i % 2 == 0
249
+ # Read operations
250
+ threads << Thread.new do
251
+ versions = adapter.list_versions(rule_id: "read_write_test")
252
+ read_mutex.synchronize { results << versions }
253
+ end
254
+ else
255
+ # Write operations
256
+ threads << Thread.new do
257
+ version = adapter.create_version(
258
+ rule_id: "read_write_test",
259
+ content: { rule: "version_#{i}" },
260
+ metadata: { created_by: "thread_#{i}" }
261
+ )
262
+ write_mutex.synchronize { results << version }
263
+ end
264
+ end
265
+ end
266
+
267
+ threads.each(&:join)
268
+
269
+ # All operations should complete successfully
270
+ expect(read_results.size).to eq(5)
271
+ expect(write_results.size).to eq(5)
272
+
273
+ # Reads should never return inconsistent data
274
+ read_results.each do |versions|
275
+ expect(versions).to be_an(Array)
276
+ versions.each do |version|
277
+ expect(version).to have_key(:id)
278
+ expect(version).to have_key(:version_number)
279
+ expect(version).to have_key(:status)
280
+ end
281
+ end
282
+ end
283
+ end
284
+
285
+ describe "EvaluationValidator" do
286
+ it "validates frozen evaluations" do
287
+ evaluation = DecisionAgent::Evaluation.new(
288
+ decision: "approve",
289
+ weight: 0.8,
290
+ reason: "Valid",
291
+ evaluator_name: "TestEvaluator"
292
+ )
293
+
294
+ expect {
295
+ DecisionAgent::EvaluationValidator.validate!(evaluation)
296
+ }.not_to raise_error
297
+ end
298
+
299
+ it "raises error for unfrozen evaluations" do
300
+ # Create an evaluation and unfreeze it (for testing purposes)
301
+ evaluation = DecisionAgent::Evaluation.allocate
302
+ evaluation.instance_variable_set(:@decision, "approve")
303
+ evaluation.instance_variable_set(:@weight, 0.8)
304
+ evaluation.instance_variable_set(:@reason, "Test")
305
+ evaluation.instance_variable_set(:@evaluator_name, "Test")
306
+
307
+ expect {
308
+ DecisionAgent::EvaluationValidator.validate!(evaluation)
309
+ }.to raise_error(DecisionAgent::EvaluationValidator::ValidationError, /must be frozen/)
310
+ end
311
+
312
+ it "validates arrays of evaluations" do
313
+ evaluations = [
314
+ DecisionAgent::Evaluation.new(
315
+ decision: "approve",
316
+ weight: 0.8,
317
+ reason: "Valid 1",
318
+ evaluator_name: "Evaluator1"
319
+ ),
320
+ DecisionAgent::Evaluation.new(
321
+ decision: "reject",
322
+ weight: 0.6,
323
+ reason: "Valid 2",
324
+ evaluator_name: "Evaluator2"
325
+ )
326
+ ]
327
+
328
+ expect {
329
+ DecisionAgent::EvaluationValidator.validate_all!(evaluations)
330
+ }.not_to raise_error
331
+ end
332
+ end
333
+
334
+ describe "Stress Testing" do
335
+ let(:rules_json) do
336
+ {
337
+ version: "1.0",
338
+ ruleset: "stress_test",
339
+ rules: [
340
+ {
341
+ id: "rule1",
342
+ if: { field: "value", op: "gt", value: 50 },
343
+ then: { decision: "high", weight: 0.9, reason: "High value" }
344
+ },
345
+ {
346
+ id: "rule2",
347
+ if: { field: "value", op: "lte", value: 50 },
348
+ then: { decision: "low", weight: 0.8, reason: "Low value" }
349
+ }
350
+ ]
351
+ }
352
+ end
353
+
354
+ let(:evaluator) { DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json) }
355
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
356
+
357
+ it "handles 100 threads making 100 decisions each (10,000 total)" do
358
+ thread_count = 100
359
+ decisions_per_thread = 100
360
+ total_decisions = thread_count * decisions_per_thread
361
+ results = []
362
+ mutex = Mutex.new
363
+
364
+ threads = thread_count.times.map do |thread_id|
365
+ Thread.new do
366
+ decisions_per_thread.times do |i|
367
+ context = { value: (thread_id * decisions_per_thread + i) % 100 }
368
+ decision = agent.decide(context: context)
369
+ mutex.synchronize { mutex.synchronize { results << decision } }
370
+ end
371
+ end
372
+ end
373
+
374
+ threads.each(&:join)
375
+
376
+ expect(results.size).to eq(total_decisions)
377
+ expect(results).to all(be_frozen)
378
+ expect(results.map(&:decision).uniq.sort).to eq(["high", "low"])
379
+ end
380
+
381
+ it "handles rapid-fire decisions from single thread (no race conditions)" do
382
+ results = []
383
+
384
+ 1000.times do |i|
385
+ decision = agent.decide(context: { value: i % 100 })
386
+ mutex.synchronize { results << decision }
387
+ end
388
+
389
+ expect(results.size).to eq(1000)
390
+ expect(results).to all(be_frozen)
391
+
392
+ # Verify determinism - same input produces same output
393
+ decision1 = agent.decide(context: { value: 75 })
394
+ decision2 = agent.decide(context: { value: 75 })
395
+ expect(decision1.decision).to eq(decision2.decision)
396
+ end
397
+
398
+ it "handles concurrent decisions with complex nested contexts" do
399
+ complex_contexts = 50.times.map do |i|
400
+ {
401
+ value: i,
402
+ user: {
403
+ id: i,
404
+ profile: {
405
+ age: 20 + i % 50,
406
+ score: 0.5 + (i % 10) * 0.05
407
+ }
408
+ },
409
+ metadata: {
410
+ tags: ["tag#{i % 5}", "tag#{i % 3}"],
411
+ timestamps: [Time.now.to_i - i, Time.now.to_i]
412
+ }
413
+ }
414
+ end
415
+
416
+ results = []; mutex = Mutex.new
417
+ threads = complex_contexts.map do |context|
418
+ Thread.new do
419
+ decision = agent.decide(context: context)
420
+ mutex.synchronize { results << decision }
421
+ end
422
+ end
423
+
424
+ threads.each(&:join)
425
+
426
+ expect(results.size).to eq(50)
427
+ expect(results).to all(be_frozen)
428
+ results.each do |decision|
429
+ expect(decision.audit_payload).to be_frozen
430
+ expect(decision.audit_payload[:context]).to be_frozen
431
+ end
432
+ end
433
+ end
434
+
435
+ describe "Edge Cases" do
436
+ let(:evaluator) do
437
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
438
+ rules_json: {
439
+ version: "1.0",
440
+ ruleset: "edge_cases",
441
+ rules: [
442
+ {
443
+ id: "rule1",
444
+ if: { field: "status", op: "eq", value: "active" },
445
+ then: { decision: "proceed", weight: 1.0, reason: "Active status" }
446
+ }
447
+ ]
448
+ }
449
+ )
450
+ end
451
+
452
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
453
+
454
+ it "handles empty context safely across threads" do
455
+ results = []; mutex = Mutex.new
456
+
457
+ 10.times do
458
+ threads = 10.times.map do
459
+ Thread.new do
460
+ # Empty context should not match any rules, causing no evaluations
461
+ begin
462
+ decision = agent.decide(context: {})
463
+ mutex.synchronize { results << decision }
464
+ rescue DecisionAgent::NoEvaluationsError
465
+ # Expected when no rules match
466
+ end
467
+ end
468
+ end
469
+ threads.each(&:join)
470
+ end
471
+
472
+ # Should either have no results (NoEvaluationsError) or all frozen
473
+ if results.any?
474
+ expect(results).to all(be_frozen)
475
+ end
476
+ end
477
+
478
+ it "handles nil values in context safely across threads" do
479
+ contexts = [
480
+ { status: nil },
481
+ { status: "active" },
482
+ { status: "" },
483
+ { status: false }
484
+ ]
485
+
486
+ results = []; mutex = Mutex.new
487
+ threads = contexts.flat_map do |context|
488
+ 10.times.map do
489
+ Thread.new do
490
+ begin
491
+ decision = agent.decide(context: context)
492
+ mutex.synchronize { results << decision }
493
+ rescue DecisionAgent::NoEvaluationsError
494
+ # Expected for non-matching contexts
495
+ end
496
+ end
497
+ end
498
+ end
499
+
500
+ threads.each(&:join)
501
+
502
+ # Only contexts with status: "active" should produce decisions
503
+ matching_results = results.select { |d| d.decision == "proceed" }
504
+ expect(matching_results.size).to be <= 10
505
+ expect(results).to all(be_frozen)
506
+ end
507
+
508
+ it "handles unicode and special characters in context" do
509
+ special_contexts = [
510
+ { status: "active", name: "Test 测试 тест" },
511
+ { status: "active", emoji: "🚀🎉" },
512
+ { status: "active", special: "!@#$%^&*()" },
513
+ { status: "active", json: '{"nested": "value"}' }
514
+ ]
515
+
516
+ results = []; mutex = Mutex.new
517
+ threads = special_contexts.flat_map do |context|
518
+ 5.times.map do
519
+ Thread.new do
520
+ decision = agent.decide(context: context)
521
+ mutex.synchronize { results << decision }
522
+ end
523
+ end
524
+ end
525
+
526
+ threads.each(&:join)
527
+
528
+ expect(results.size).to eq(20)
529
+ expect(results).to all(be_frozen)
530
+ results.each do |decision|
531
+ expect(decision.audit_payload[:context]).to be_frozen
532
+ end
533
+ end
534
+ end
535
+
536
+ describe "Multiple Evaluators" do
537
+ let(:evaluator1) do
538
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
539
+ rules_json: {
540
+ version: "1.0",
541
+ ruleset: "evaluator1",
542
+ rules: [
543
+ {
544
+ id: "rule1",
545
+ if: { field: "score", op: "gt", value: 0.7 },
546
+ then: { decision: "approve", weight: 0.8, reason: "High score" }
547
+ }
548
+ ]
549
+ }
550
+ )
551
+ end
552
+
553
+ let(:evaluator2) do
554
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
555
+ rules_json: {
556
+ version: "1.0",
557
+ ruleset: "evaluator2",
558
+ rules: [
559
+ {
560
+ id: "rule2",
561
+ if: { field: "verified", op: "eq", value: true },
562
+ then: { decision: "approve", weight: 0.9, reason: "Verified user" }
563
+ }
564
+ ]
565
+ }
566
+ )
567
+ end
568
+
569
+ let(:evaluator3) do
570
+ DecisionAgent::Evaluators::StaticEvaluator.new(
571
+ decision: "approve",
572
+ weight: 0.5,
573
+ reason: "Default approval"
574
+ )
575
+ end
576
+
577
+ let(:agent) do
578
+ DecisionAgent::Agent.new(
579
+ evaluators: [evaluator1, evaluator2, evaluator3],
580
+ scoring_strategy: DecisionAgent::Scoring::WeightedAverage.new
581
+ )
582
+ end
583
+
584
+ it "handles multiple evaluators safely across threads" do
585
+ contexts = [
586
+ { score: 0.8, verified: true },
587
+ { score: 0.9, verified: false },
588
+ { score: 0.5, verified: true },
589
+ { score: 0.6, verified: false }
590
+ ]
591
+
592
+ results = []; mutex = Mutex.new
593
+ threads = contexts.flat_map do |context|
594
+ 25.times.map do
595
+ Thread.new do
596
+ decision = agent.decide(context: context)
597
+ mutex.synchronize { results << decision }
598
+ end
599
+ end
600
+ end
601
+
602
+ threads.each(&:join)
603
+
604
+ expect(results.size).to eq(100)
605
+ expect(results).to all(be_frozen)
606
+
607
+ # All should have multiple evaluations from different evaluators
608
+ results.each do |decision|
609
+ expect(decision.evaluations.size).to be >= 1
610
+ expect(decision.evaluations).to all(be_frozen)
611
+ end
612
+ end
613
+
614
+ it "prevents modification of shared evaluators array" do
615
+ expect(agent.evaluators).to be_frozen
616
+ expect(agent.evaluators.size).to eq(3)
617
+
618
+ expect {
619
+ agent.evaluators << DecisionAgent::Evaluators::StaticEvaluator.new(
620
+ decision: "reject",
621
+ weight: 1.0,
622
+ reason: "Test"
623
+ )
624
+ }.to raise_error(FrozenError)
625
+ end
626
+ end
627
+
628
+ describe "Different Scoring Strategies" do
629
+ let(:evaluator) do
630
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
631
+ rules_json: {
632
+ version: "1.0",
633
+ ruleset: "scoring_test",
634
+ rules: [
635
+ {
636
+ id: "rule1",
637
+ if: { field: "value", op: "gt", value: 50 },
638
+ then: { decision: "high", weight: 0.9, reason: "High value" }
639
+ }
640
+ ]
641
+ }
642
+ )
643
+ end
644
+
645
+ it "handles Consensus strategy thread-safely" do
646
+ agent = DecisionAgent::Agent.new(
647
+ evaluators: [evaluator],
648
+ scoring_strategy: DecisionAgent::Scoring::Consensus.new(minimum_agreement: 0.5)
649
+ )
650
+
651
+ results = []; mutex = Mutex.new
652
+ threads = 50.times.map do |i|
653
+ Thread.new do
654
+ decision = agent.decide(context: { value: i + 25 })
655
+ mutex.synchronize { results << decision }
656
+ end
657
+ end
658
+
659
+ threads.each(&:join)
660
+
661
+ expect(results.size).to eq(50)
662
+ expect(results).to all(be_frozen)
663
+ end
664
+
665
+ it "handles MaxWeight strategy thread-safely" do
666
+ agent = DecisionAgent::Agent.new(
667
+ evaluators: [evaluator],
668
+ scoring_strategy: DecisionAgent::Scoring::MaxWeight.new
669
+ )
670
+
671
+ results = []; mutex = Mutex.new
672
+ threads = 50.times.map do |i|
673
+ Thread.new do
674
+ decision = agent.decide(context: { value: i + 25 })
675
+ mutex.synchronize { results << decision }
676
+ end
677
+ end
678
+
679
+ threads.each(&:join)
680
+
681
+ expect(results.size).to eq(50)
682
+ expect(results).to all(be_frozen)
683
+ end
684
+
685
+ it "handles Threshold strategy thread-safely" do
686
+ agent = DecisionAgent::Agent.new(
687
+ evaluators: [evaluator],
688
+ scoring_strategy: DecisionAgent::Scoring::Threshold.new(threshold: 0.75)
689
+ )
690
+
691
+ results = []; mutex = Mutex.new
692
+ threads = 50.times.map do |i|
693
+ Thread.new do
694
+ decision = agent.decide(context: { value: i + 25 })
695
+ mutex.synchronize { results << decision }
696
+ end
697
+ end
698
+
699
+ threads.each(&:join)
700
+
701
+ expect(results.size).to eq(50)
702
+ expect(results).to all(be_frozen)
703
+ end
704
+ end
705
+
706
+ describe "Race Condition Prevention" do
707
+ let(:evaluator) do
708
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
709
+ rules_json: {
710
+ version: "1.0",
711
+ ruleset: "race_test",
712
+ rules: [
713
+ {
714
+ id: "rule1",
715
+ if: { field: "counter", op: "eq", value: 0 },
716
+ then: { decision: "zero", weight: 1.0, reason: "Counter is zero" }
717
+ }
718
+ ]
719
+ }
720
+ )
721
+ end
722
+
723
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
724
+
725
+ it "prevents race conditions when reading same frozen objects" do
726
+ results = []; mutex = Mutex.new
727
+ decision = agent.decide(context: { counter: 0 })
728
+
729
+ # Multiple threads reading the same frozen decision
730
+ threads = 100.times.map do
731
+ Thread.new do
732
+ # These reads should be safe because decision is frozen
733
+ mutex.synchronize { results << { }
734
+ decision: decision.decision,
735
+ confidence: decision.confidence,
736
+ evaluations_count: decision.evaluations.size
737
+ }
738
+ end
739
+ end
740
+
741
+ threads.each(&:join)
742
+
743
+ expect(results.size).to eq(100)
744
+ # All threads should see the same values
745
+ expect(results.map { |r| r[:decision] }.uniq).to eq(["zero"])
746
+ expect(results.map { |r| r[:evaluations_count] }.uniq).to eq([1])
747
+ end
748
+
749
+ it "ensures deterministic hashes are consistent across threads" do
750
+ hashes = []; mutex = Mutex.new
751
+ context = { value: 42, user: { id: 123 } }
752
+
753
+ threads = 50.times.map do
754
+ Thread.new do
755
+ decision = agent.decide(context: context.dup)
756
+ hashes << decision.audit_payload[:deterministic_hash]
757
+ end
758
+ end
759
+
760
+ threads.each(&:join)
761
+
762
+ # All decisions with same context should have same hash
763
+ expect(hashes.uniq.size).to be <= 2 # May differ if rule matches/doesn't match
764
+ end
765
+ end
766
+
767
+ describe "Memory Safety" do
768
+ let(:evaluator) do
769
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
770
+ rules_json: {
771
+ version: "1.0",
772
+ ruleset: "memory_test",
773
+ rules: [
774
+ {
775
+ id: "rule1",
776
+ if: { field: "active", op: "eq", value: true },
777
+ then: { decision: "proceed", weight: 1.0, reason: "Active" }
778
+ }
779
+ ]
780
+ }
781
+ )
782
+ end
783
+
784
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
785
+
786
+ it "prevents memory leaks from unfrozen nested structures" do
787
+ results = []
788
+
789
+ 100.times do |i|
790
+ decision = agent.decide(
791
+ context: {
792
+ active: true,
793
+ metadata: {
794
+ level1: {
795
+ level2: {
796
+ level3: {
797
+ data: "value_#{i}"
798
+ }
799
+ }
800
+ }
801
+ }
802
+ }
803
+ )
804
+ mutex.synchronize { results << decision }
805
+ end
806
+
807
+ # Verify all nested structures are frozen
808
+ results.each do |decision|
809
+ expect(decision.audit_payload).to be_frozen
810
+ expect(decision.audit_payload[:context]).to be_frozen
811
+
812
+ # Check deep nesting
813
+ if decision.audit_payload[:context][:metadata]
814
+ expect(decision.audit_payload[:context][:metadata]).to be_frozen
815
+
816
+ if decision.audit_payload[:context][:metadata][:level1]
817
+ expect(decision.audit_payload[:context][:metadata][:level1]).to be_frozen
818
+ end
819
+ end
820
+ end
821
+ end
822
+
823
+ it "does not mutate original context data" do
824
+ original_context = { active: true, count: 0 }
825
+ original_context_copy = original_context.dup
826
+
827
+ threads = 10.times.map do
828
+ Thread.new do
829
+ agent.decide(context: original_context)
830
+ end
831
+ end
832
+
833
+ threads.each(&:join)
834
+
835
+ # Original context should be unchanged
836
+ expect(original_context).to eq(original_context_copy)
837
+ expect(original_context).not_to be_frozen
838
+ end
839
+ end
840
+
841
+ describe "Error Handling in Concurrent Context" do
842
+ it "handles evaluator errors gracefully in multi-threaded context" do
843
+ failing_evaluator = Class.new(DecisionAgent::Evaluators::Base) do
844
+ def evaluate(context, feedback: {})
845
+ raise StandardError, "Intentional failure" if context[:fail]
846
+ DecisionAgent::Evaluation.new(
847
+ decision: "success",
848
+ weight: 1.0,
849
+ reason: "Success",
850
+ evaluator_name: "FailingEvaluator"
851
+ )
852
+ end
853
+ end.new
854
+
855
+ agent = DecisionAgent::Agent.new(evaluators: [failing_evaluator])
856
+ results = []; mutex = Mutex.new
857
+ errors = []; mutex = Mutex.new
858
+
859
+ threads = 50.times.map do |i|
860
+ Thread.new do
861
+ begin
862
+ decision = agent.decide(context: { fail: i.even? })
863
+ mutex.synchronize { results << decision }
864
+ rescue DecisionAgent::NoEvaluationsError => e
865
+ errors << e
866
+ end
867
+ end
868
+ end
869
+
870
+ threads.each(&:join)
871
+
872
+ # Half should succeed (odd i), half should raise NoEvaluationsError (even i)
873
+ expect(results.size).to be > 0
874
+ expect(errors.size).to be > 0
875
+ expect(results).to all(be_frozen)
876
+ end
877
+ end
878
+ end