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,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Thread-Safety" do
6
+ describe "Agent with shared evaluators" do
7
+ let(:rules_json) do
8
+ {
9
+ version: "1.0",
10
+ ruleset: "approval_rules",
11
+ rules: [
12
+ {
13
+ id: "approve_high",
14
+ if: { field: "amount", op: "gt", value: 1000 },
15
+ then: { decision: "approve", weight: 0.9, reason: "High value" }
16
+ },
17
+ {
18
+ id: "reject_low",
19
+ if: { field: "amount", op: "lte", value: 1000 },
20
+ then: { decision: "reject", weight: 0.8, reason: "Low value" }
21
+ }
22
+ ]
23
+ }
24
+ end
25
+
26
+ let(:evaluator) { DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json) }
27
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
28
+
29
+ it "handles concurrent decisions from multiple threads safely" do
30
+ threads = []
31
+ results = Array.new(50)
32
+
33
+ # Create 50 threads making concurrent decisions
34
+ 50.times do |i|
35
+ threads << Thread.new do
36
+ context = { amount: i.even? ? 1500 : 500 }
37
+ results[i] = agent.decide(context: context)
38
+ end
39
+ end
40
+
41
+ threads.each(&:join)
42
+
43
+ # Verify all threads completed successfully
44
+ expect(results.compact.size).to eq(50)
45
+
46
+ # Verify results are correct and frozen
47
+ results.each_with_index do |decision, i|
48
+ expect(decision).to be_frozen
49
+ expect(decision.decision).to be_frozen
50
+ expect(decision.explanations).to be_frozen
51
+ expect(decision.evaluations).to be_frozen
52
+ expect(decision.audit_payload).to be_frozen
53
+
54
+ # Verify correctness based on input
55
+ if i.even?
56
+ expect(decision.decision).to eq("approve")
57
+ else
58
+ expect(decision.decision).to eq("reject")
59
+ end
60
+ end
61
+ end
62
+
63
+ it "prevents modification of shared evaluator ruleset" do
64
+ # Verify the ruleset is frozen
65
+ expect(evaluator.instance_variable_get(:@ruleset)).to be_frozen
66
+
67
+ # Attempt to modify should raise error
68
+ expect do
69
+ evaluator.instance_variable_get(:@ruleset)["rules"] << { id: "new_rule" }
70
+ end.to raise_error(FrozenError)
71
+ end
72
+
73
+ it "prevents modification of evaluators array in Agent" do
74
+ expect(agent.evaluators).to be_frozen
75
+
76
+ expect do
77
+ agent.evaluators << DecisionAgent::Evaluators::StaticEvaluator.new(decision: true, weight: 1.0)
78
+ end.to raise_error(FrozenError)
79
+ end
80
+ end
81
+
82
+ describe "Multiple agents sharing evaluators" do
83
+ let(:evaluator) do
84
+ DecisionAgent::Evaluators::JsonRuleEvaluator.new(
85
+ rules_json: {
86
+ version: "1.0",
87
+ ruleset: "shared_rules",
88
+ rules: [
89
+ {
90
+ id: "rule1",
91
+ if: { field: "value", op: "eq", value: "yes" },
92
+ then: { decision: "approve", weight: 1.0, reason: "Match" }
93
+ }
94
+ ]
95
+ }
96
+ )
97
+ end
98
+
99
+ it "allows multiple agents to safely share the same evaluator instance" do
100
+ agent1 = DecisionAgent::Agent.new(evaluators: [evaluator])
101
+ agent2 = DecisionAgent::Agent.new(evaluators: [evaluator])
102
+ agent3 = DecisionAgent::Agent.new(evaluators: [evaluator])
103
+
104
+ results = []
105
+ mutex = Mutex.new
106
+
107
+ # Each agent makes decisions in parallel
108
+ threads = [agent1, agent2, agent3].map do |agent|
109
+ Thread.new do
110
+ 10.times do
111
+ decision = agent.decide(context: { value: "yes" })
112
+ mutex.synchronize { results << decision }
113
+ end
114
+ end
115
+ end
116
+
117
+ threads.each(&:join)
118
+
119
+ # All 30 decisions should succeed
120
+ expect(results.size).to eq(30)
121
+ results.each do |decision|
122
+ expect(decision.decision).to eq("approve")
123
+ expect(decision).to be_frozen
124
+ end
125
+ end
126
+ end
127
+
128
+ describe "Evaluation immutability" do
129
+ it "ensures evaluations are deeply frozen" do
130
+ evaluation = DecisionAgent::Evaluation.new(
131
+ decision: "approve",
132
+ weight: 0.8,
133
+ reason: "Test reason",
134
+ evaluator_name: "TestEvaluator",
135
+ metadata: { key: "value" }
136
+ )
137
+
138
+ expect(evaluation).to be_frozen
139
+ expect(evaluation.decision).to be_frozen
140
+ expect(evaluation.reason).to be_frozen
141
+ expect(evaluation.evaluator_name).to be_frozen
142
+ expect(evaluation.metadata).to be_frozen
143
+ end
144
+ end
145
+
146
+ describe "Decision immutability" do
147
+ it "ensures decisions are deeply frozen" do
148
+ evaluation = DecisionAgent::Evaluation.new(
149
+ decision: "approve",
150
+ weight: 1.0,
151
+ reason: "Test",
152
+ evaluator_name: "Test"
153
+ )
154
+
155
+ decision = DecisionAgent::Decision.new(
156
+ decision: "approve",
157
+ confidence: 0.95,
158
+ explanations: ["Explanation 1"],
159
+ evaluations: [evaluation],
160
+ audit_payload: { timestamp: "2024-01-01" }
161
+ )
162
+
163
+ expect(decision).to be_frozen
164
+ expect(decision.decision).to be_frozen
165
+ expect(decision.explanations).to be_frozen
166
+ expect(decision.evaluations).to be_frozen
167
+ expect(decision.audit_payload).to be_frozen
168
+
169
+ # Nested structures should also be frozen
170
+ expect(decision.explanations.first).to be_frozen
171
+ expect(decision.evaluations.first).to be_frozen
172
+ end
173
+ end
174
+
175
+ describe "Context immutability" do
176
+ it "freezes context data to prevent mutation" do
177
+ context_data = { user: { id: 1, name: "Test" }, amount: 100 }
178
+ context = DecisionAgent::Context.new(context_data)
179
+
180
+ expect(context.to_h).to be_frozen
181
+ expect(context.to_h[:user]).to be_frozen
182
+
183
+ # Original data should not be affected
184
+ expect(context_data).not_to be_frozen
185
+ end
186
+ end
187
+
188
+ describe "Concurrent file storage operations" do
189
+ let(:storage_path) { File.join(__dir__, "../tmp/thread_safety_test") }
190
+ let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: storage_path) }
191
+
192
+ before do
193
+ FileUtils.rm_rf(storage_path)
194
+ end
195
+
196
+ after do
197
+ FileUtils.rm_rf(storage_path)
198
+ end
199
+
200
+ it "handles concurrent version creation safely" do
201
+ threads = []
202
+ results = []
203
+ mutex = Mutex.new
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
+ read_mutex = Mutex.new
246
+ write_mutex = Mutex.new
247
+
248
+ # Mix of read and write operations
249
+ 10.times do |i|
250
+ threads << if i.even?
251
+ # Read operations
252
+ Thread.new do
253
+ versions = adapter.list_versions(rule_id: "read_write_test")
254
+ read_mutex.synchronize { read_results << versions }
255
+ end
256
+ else
257
+ # Write operations
258
+ Thread.new do
259
+ version = adapter.create_version(
260
+ rule_id: "read_write_test",
261
+ content: { rule: "version_#{i}" },
262
+ metadata: { created_by: "thread_#{i}" }
263
+ )
264
+ write_mutex.synchronize { write_results << version }
265
+ end
266
+ end
267
+ end
268
+
269
+ threads.each(&:join)
270
+
271
+ # All operations should complete successfully
272
+ expect(read_results.size).to eq(5)
273
+ expect(write_results.size).to eq(5)
274
+
275
+ # Reads should never return inconsistent data
276
+ read_results.each do |versions|
277
+ expect(versions).to be_an(Array)
278
+ versions.each do |version|
279
+ expect(version).to have_key(:id)
280
+ expect(version).to have_key(:version_number)
281
+ expect(version).to have_key(:status)
282
+ end
283
+ end
284
+ end
285
+ end
286
+
287
+ describe "EvaluationValidator" do
288
+ it "validates frozen evaluations" do
289
+ evaluation = DecisionAgent::Evaluation.new(
290
+ decision: "approve",
291
+ weight: 0.8,
292
+ reason: "Valid",
293
+ evaluator_name: "TestEvaluator"
294
+ )
295
+
296
+ expect do
297
+ DecisionAgent::EvaluationValidator.validate!(evaluation)
298
+ end.not_to raise_error
299
+ end
300
+
301
+ it "raises error for unfrozen evaluations" do
302
+ # Create an evaluation and unfreeze it (for testing purposes)
303
+ evaluation = DecisionAgent::Evaluation.allocate
304
+ evaluation.instance_variable_set(:@decision, "approve")
305
+ evaluation.instance_variable_set(:@weight, 0.8)
306
+ evaluation.instance_variable_set(:@reason, "Test")
307
+ evaluation.instance_variable_set(:@evaluator_name, "Test")
308
+
309
+ expect do
310
+ DecisionAgent::EvaluationValidator.validate!(evaluation)
311
+ end.to raise_error(DecisionAgent::EvaluationValidator::ValidationError, /must be frozen/)
312
+ end
313
+
314
+ it "validates arrays of evaluations" do
315
+ evaluations = [
316
+ DecisionAgent::Evaluation.new(
317
+ decision: "approve",
318
+ weight: 0.8,
319
+ reason: "Valid 1",
320
+ evaluator_name: "Evaluator1"
321
+ ),
322
+ DecisionAgent::Evaluation.new(
323
+ decision: "reject",
324
+ weight: 0.6,
325
+ reason: "Valid 2",
326
+ evaluator_name: "Evaluator2"
327
+ )
328
+ ]
329
+
330
+ expect do
331
+ DecisionAgent::EvaluationValidator.validate_all!(evaluations)
332
+ end.not_to raise_error
333
+ end
334
+ end
335
+
336
+ describe "Stress Testing & Extended Coverage" do
337
+ let(:rules_json) do
338
+ {
339
+ version: "1.0",
340
+ ruleset: "stress_test",
341
+ rules: [
342
+ {
343
+ id: "rule1",
344
+ if: { field: "value", op: "gt", value: 50 },
345
+ then: { decision: "high", weight: 0.9, reason: "High value" }
346
+ },
347
+ {
348
+ id: "rule2",
349
+ if: { field: "value", op: "lte", value: 50 },
350
+ then: { decision: "low", weight: 0.8, reason: "Low value" }
351
+ }
352
+ ]
353
+ }
354
+ end
355
+
356
+ let(:evaluator) { DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json) }
357
+ let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
358
+
359
+ it "handles 100 threads making 100 decisions each (10,000 total)" do
360
+ thread_count = 100
361
+ decisions_per_thread = 100
362
+ total_decisions = thread_count * decisions_per_thread
363
+ results = []
364
+ mutex = Mutex.new
365
+
366
+ threads = thread_count.times.map do |thread_id|
367
+ Thread.new do
368
+ decisions_per_thread.times do |i|
369
+ context = { value: ((thread_id * decisions_per_thread) + i) % 100 }
370
+ decision = agent.decide(context: context)
371
+ mutex.synchronize { results << decision }
372
+ end
373
+ end
374
+ end
375
+
376
+ threads.each(&:join)
377
+
378
+ expect(results.size).to eq(total_decisions)
379
+ expect(results).to all(be_frozen)
380
+ expect(results.map(&:decision).uniq.sort).to eq(%w[high low])
381
+ end
382
+
383
+ it "handles rapid-fire decisions with deterministic results" do
384
+ results = []
385
+
386
+ 1000.times do |i|
387
+ decision = agent.decide(context: { value: i % 100 })
388
+ results << decision
389
+ end
390
+
391
+ expect(results.size).to eq(1000)
392
+ expect(results).to all(be_frozen)
393
+
394
+ # Verify determinism - same input produces same output
395
+ decision1 = agent.decide(context: { value: 75 })
396
+ decision2 = agent.decide(context: { value: 75 })
397
+ expect(decision1.decision).to eq(decision2.decision)
398
+ expect(decision1.confidence).to eq(decision2.confidence)
399
+ end
400
+
401
+ it "handles concurrent decisions with complex nested contexts" do
402
+ complex_contexts = 50.times.map do |i|
403
+ {
404
+ value: i,
405
+ user: {
406
+ id: i,
407
+ profile: {
408
+ age: 20 + (i % 50),
409
+ score: 0.5 + ((i % 10) * 0.05)
410
+ }
411
+ },
412
+ metadata: {
413
+ tags: ["tag#{i % 5}", "tag#{i % 3}"],
414
+ timestamps: [Time.now.to_i - i, Time.now.to_i]
415
+ }
416
+ }
417
+ end
418
+
419
+ results = []
420
+ mutex = Mutex.new
421
+
422
+ threads = complex_contexts.map do |context|
423
+ Thread.new do
424
+ decision = agent.decide(context: context)
425
+ mutex.synchronize { results << decision }
426
+ end
427
+ end
428
+
429
+ threads.each(&:join)
430
+
431
+ expect(results.size).to eq(50)
432
+ expect(results).to all(be_frozen)
433
+ results.each do |decision|
434
+ expect(decision.audit_payload).to be_frozen
435
+ expect(decision.audit_payload[:context]).to be_frozen
436
+ end
437
+ end
438
+
439
+ it "prevents race conditions when reading same frozen decision" do
440
+ results = []
441
+ mutex = Mutex.new
442
+ decision = agent.decide(context: { value: 0 })
443
+
444
+ # Multiple threads reading the same frozen decision
445
+ threads = 100.times.map do
446
+ Thread.new do
447
+ # These reads should be safe because decision is frozen
448
+ data = {
449
+ decision: decision.decision,
450
+ confidence: decision.confidence,
451
+ evaluations_count: decision.evaluations.size
452
+ }
453
+ mutex.synchronize { results << data }
454
+ end
455
+ end
456
+
457
+ threads.each(&:join)
458
+
459
+ expect(results.size).to eq(100)
460
+ # All threads should see the same values
461
+ expect(results.map { |r| r[:decision] }.uniq).to eq(["low"])
462
+ expect(results.map { |r| r[:evaluations_count] }.uniq).to eq([1])
463
+ end
464
+
465
+ it "ensures original context data is not mutated" do
466
+ original_context = { value: 75, count: 0 }
467
+ original_context_copy = original_context.dup
468
+
469
+ threads = 20.times.map do
470
+ Thread.new do
471
+ agent.decide(context: original_context)
472
+ end
473
+ end
474
+
475
+ threads.each(&:join)
476
+
477
+ # Original context should be unchanged
478
+ expect(original_context).to eq(original_context_copy)
479
+ expect(original_context).not_to be_frozen
480
+ end
481
+ end
482
+ end