decision_agent 0.1.1 → 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.
- checksums.yaml +4 -4
- data/README.md +234 -919
- data/bin/decision_agent +5 -5
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +21 -6
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
- data/lib/decision_agent/versioning/adapter.rb +100 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
- data/lib/decision_agent/versioning/version_manager.rb +127 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +56 -1
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +169 -9
- data/lib/decision_agent.rb +11 -0
- data/lib/generators/decision_agent/install/install_generator.rb +40 -0
- data/lib/generators/decision_agent/install/templates/README +47 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +777 -0
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +84 -11
|
@@ -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
|