decision_agent 0.3.0 → 1.0.1

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -1,490 +0,0 @@
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
- # NOTE: Evaluation objects are always frozen in their initializer.
303
- # To test the validator's frozen check, we need to create an unfrozen instance.
304
- # Using allocate allows us to bypass the initializer (which would freeze the object)
305
- # and manually set instance variables to create a valid but unfrozen evaluation.
306
- # This tests the edge case where an evaluation might not be frozen (though
307
- # this should never happen in practice with real Evaluation instances).
308
- evaluation = DecisionAgent::Evaluation.allocate
309
- evaluation.instance_variable_set(:@decision, "approve")
310
- evaluation.instance_variable_set(:@weight, 0.8)
311
- evaluation.instance_variable_set(:@reason, "Test")
312
- evaluation.instance_variable_set(:@evaluator_name, "TestEvaluator")
313
-
314
- # Verify it's not frozen (this is the condition we're testing)
315
- expect(evaluation).not_to be_frozen
316
-
317
- expect do
318
- DecisionAgent::EvaluationValidator.validate!(evaluation)
319
- end.to raise_error(DecisionAgent::EvaluationValidator::ValidationError, /must be frozen/)
320
- end
321
-
322
- it "validates arrays of evaluations" do
323
- evaluations = [
324
- DecisionAgent::Evaluation.new(
325
- decision: "approve",
326
- weight: 0.8,
327
- reason: "Valid 1",
328
- evaluator_name: "Evaluator1"
329
- ),
330
- DecisionAgent::Evaluation.new(
331
- decision: "reject",
332
- weight: 0.6,
333
- reason: "Valid 2",
334
- evaluator_name: "Evaluator2"
335
- )
336
- ]
337
-
338
- expect do
339
- DecisionAgent::EvaluationValidator.validate_all!(evaluations)
340
- end.not_to raise_error
341
- end
342
- end
343
-
344
- describe "Stress Testing & Extended Coverage" do
345
- let(:rules_json) do
346
- {
347
- version: "1.0",
348
- ruleset: "stress_test",
349
- rules: [
350
- {
351
- id: "rule1",
352
- if: { field: "value", op: "gt", value: 50 },
353
- then: { decision: "high", weight: 0.9, reason: "High value" }
354
- },
355
- {
356
- id: "rule2",
357
- if: { field: "value", op: "lte", value: 50 },
358
- then: { decision: "low", weight: 0.8, reason: "Low value" }
359
- }
360
- ]
361
- }
362
- end
363
-
364
- let(:evaluator) { DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json) }
365
- let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
366
-
367
- it "handles 100 threads making 100 decisions each (10,000 total)" do
368
- thread_count = 100
369
- decisions_per_thread = 100
370
- total_decisions = thread_count * decisions_per_thread
371
- results = []
372
- mutex = Mutex.new
373
-
374
- threads = thread_count.times.map do |thread_id|
375
- Thread.new do
376
- decisions_per_thread.times do |i|
377
- context = { value: ((thread_id * decisions_per_thread) + i) % 100 }
378
- decision = agent.decide(context: context)
379
- mutex.synchronize { results << decision }
380
- end
381
- end
382
- end
383
-
384
- threads.each(&:join)
385
-
386
- expect(results.size).to eq(total_decisions)
387
- expect(results).to all(be_frozen)
388
- expect(results.map(&:decision).uniq.sort).to eq(%w[high low])
389
- end
390
-
391
- it "handles rapid-fire decisions with deterministic results" do
392
- results = []
393
-
394
- 1000.times do |i|
395
- decision = agent.decide(context: { value: i % 100 })
396
- results << decision
397
- end
398
-
399
- expect(results.size).to eq(1000)
400
- expect(results).to all(be_frozen)
401
-
402
- # Verify determinism - same input produces same output
403
- decision1 = agent.decide(context: { value: 75 })
404
- decision2 = agent.decide(context: { value: 75 })
405
- expect(decision1.decision).to eq(decision2.decision)
406
- expect(decision1.confidence).to eq(decision2.confidence)
407
- end
408
-
409
- it "handles concurrent decisions with complex nested contexts" do
410
- complex_contexts = 50.times.map do |i|
411
- {
412
- value: i,
413
- user: {
414
- id: i,
415
- profile: {
416
- age: 20 + (i % 50),
417
- score: 0.5 + ((i % 10) * 0.05)
418
- }
419
- },
420
- metadata: {
421
- tags: ["tag#{i % 5}", "tag#{i % 3}"],
422
- timestamps: [Time.now.to_i - i, Time.now.to_i]
423
- }
424
- }
425
- end
426
-
427
- results = []
428
- mutex = Mutex.new
429
-
430
- threads = complex_contexts.map do |context|
431
- Thread.new do
432
- decision = agent.decide(context: context)
433
- mutex.synchronize { results << decision }
434
- end
435
- end
436
-
437
- threads.each(&:join)
438
-
439
- expect(results.size).to eq(50)
440
- expect(results).to all(be_frozen)
441
- results.each do |decision|
442
- expect(decision.audit_payload).to be_frozen
443
- expect(decision.audit_payload[:context]).to be_frozen
444
- end
445
- end
446
-
447
- it "prevents race conditions when reading same frozen decision" do
448
- results = []
449
- mutex = Mutex.new
450
- decision = agent.decide(context: { value: 0 })
451
-
452
- # Multiple threads reading the same frozen decision
453
- threads = 100.times.map do
454
- Thread.new do
455
- # These reads should be safe because decision is frozen
456
- data = {
457
- decision: decision.decision,
458
- confidence: decision.confidence,
459
- evaluations_count: decision.evaluations.size
460
- }
461
- mutex.synchronize { results << data }
462
- end
463
- end
464
-
465
- threads.each(&:join)
466
-
467
- expect(results.size).to eq(100)
468
- # All threads should see the same values
469
- expect(results.map { |r| r[:decision] }.uniq).to eq(["low"])
470
- expect(results.map { |r| r[:evaluations_count] }.uniq).to eq([1])
471
- end
472
-
473
- it "ensures original context data is not mutated" do
474
- original_context = { value: 75, count: 0 }
475
- original_context_copy = original_context.dup
476
-
477
- threads = 20.times.map do
478
- Thread.new do
479
- agent.decide(context: original_context)
480
- end
481
- end
482
-
483
- threads.each(&:join)
484
-
485
- # Original context should be unchanged
486
- expect(original_context).to eq(original_context_copy)
487
- expect(original_context).not_to be_frozen
488
- end
489
- end
490
- end