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,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
|