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.
- checksums.yaml +4 -4
- data/README.md +272 -7
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
- data/lib/decision_agent/dsl/schema_validator.rb +51 -13
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/index.html +49 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/server.rb +594 -23
- data/lib/decision_agent.rb +60 -2
- metadata +53 -73
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -2134
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
RSpec.describe "Performance Optimizations" do
|
|
6
|
-
describe "MetricsCollector cleanup batching" do
|
|
7
|
-
let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new(window_size: 60, cleanup_threshold: 10) }
|
|
8
|
-
let(:evaluator) do
|
|
9
|
-
DecisionAgent::Evaluators::JsonRuleEvaluator.new(
|
|
10
|
-
rules_json: {
|
|
11
|
-
version: "1.0",
|
|
12
|
-
ruleset: "test",
|
|
13
|
-
rules: [
|
|
14
|
-
{
|
|
15
|
-
id: "rule1",
|
|
16
|
-
if: { field: "amount", op: "gte", value: 0 },
|
|
17
|
-
then: { decision: "approve", weight: 1.0, reason: "Test" }
|
|
18
|
-
}
|
|
19
|
-
]
|
|
20
|
-
}
|
|
21
|
-
)
|
|
22
|
-
end
|
|
23
|
-
let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
|
|
24
|
-
|
|
25
|
-
it "does not cleanup on every record" do
|
|
26
|
-
# Record 5 decisions (below threshold of 10)
|
|
27
|
-
5.times do |i|
|
|
28
|
-
decision = agent.decide(context: { amount: i * 100 })
|
|
29
|
-
collector.record_decision(decision, DecisionAgent::Context.new({ amount: i * 100 }))
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Should have 5 decisions
|
|
33
|
-
expect(collector.metrics[:decisions].size).to eq(5)
|
|
34
|
-
|
|
35
|
-
# Record 5 more to cross threshold
|
|
36
|
-
5.times do |i|
|
|
37
|
-
decision = agent.decide(context: { amount: i * 100 })
|
|
38
|
-
collector.record_decision(decision, DecisionAgent::Context.new({ amount: i * 100 }))
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Cleanup should have been triggered at 10
|
|
42
|
-
expect(collector.metrics[:decisions].size).to be <= 10
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
it "allows configurable cleanup threshold" do
|
|
46
|
-
custom_collector = DecisionAgent::Monitoring::MetricsCollector.new(
|
|
47
|
-
window_size: 60,
|
|
48
|
-
cleanup_threshold: 5
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
# Record 4 decisions (below threshold)
|
|
52
|
-
4.times do |i|
|
|
53
|
-
decision = agent.decide(context: { amount: i * 100 })
|
|
54
|
-
custom_collector.record_decision(decision, DecisionAgent::Context.new({ amount: i * 100 }))
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
expect(custom_collector.metrics[:decisions].size).to eq(4)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
it "maintains backward compatibility with default threshold" do
|
|
61
|
-
default_collector = DecisionAgent::Monitoring::MetricsCollector.new(window_size: 60)
|
|
62
|
-
|
|
63
|
-
# Should work without specifying cleanup_threshold
|
|
64
|
-
decision = agent.decide(context: { amount: 100 })
|
|
65
|
-
expect do
|
|
66
|
-
default_collector.record_decision(decision, DecisionAgent::Context.new({ amount: 100 }))
|
|
67
|
-
end.not_to raise_error
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
describe "ABTestingAgent caching" do
|
|
72
|
-
let(:storage_adapter) { DecisionAgent::ABTesting::Storage::MemoryAdapter.new }
|
|
73
|
-
let(:version_manager) do
|
|
74
|
-
DecisionAgent::Versioning::VersionManager.new(
|
|
75
|
-
adapter: DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: "./tmp/test_versions")
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
let(:ab_test_manager) do
|
|
79
|
-
DecisionAgent::ABTesting::ABTestManager.new(
|
|
80
|
-
storage_adapter: storage_adapter,
|
|
81
|
-
version_manager: version_manager
|
|
82
|
-
)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
before do
|
|
86
|
-
FileUtils.mkdir_p("./tmp/test_versions")
|
|
87
|
-
|
|
88
|
-
# Create test versions
|
|
89
|
-
@version1 = version_manager.save_version(
|
|
90
|
-
rule_id: "test_rule",
|
|
91
|
-
rule_content: {
|
|
92
|
-
version: "1.0",
|
|
93
|
-
ruleset: "champion",
|
|
94
|
-
rules: [
|
|
95
|
-
{
|
|
96
|
-
id: "rule1",
|
|
97
|
-
if: { field: "amount", op: "gte", value: 0 },
|
|
98
|
-
then: { decision: "approve", weight: 1.0, reason: "Champion" }
|
|
99
|
-
}
|
|
100
|
-
]
|
|
101
|
-
},
|
|
102
|
-
created_by: "test",
|
|
103
|
-
changelog: "Champion version"
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
@version2 = version_manager.save_version(
|
|
107
|
-
rule_id: "test_rule",
|
|
108
|
-
rule_content: {
|
|
109
|
-
version: "1.0",
|
|
110
|
-
ruleset: "challenger",
|
|
111
|
-
rules: [
|
|
112
|
-
{
|
|
113
|
-
id: "rule2",
|
|
114
|
-
if: { field: "amount", op: "gte", value: 0 },
|
|
115
|
-
then: { decision: "review", weight: 1.0, reason: "Challenger" }
|
|
116
|
-
}
|
|
117
|
-
]
|
|
118
|
-
},
|
|
119
|
-
created_by: "test",
|
|
120
|
-
changelog: "Challenger version"
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
# Create A/B test
|
|
124
|
-
@test = ab_test_manager.create_test(
|
|
125
|
-
name: "Test AB",
|
|
126
|
-
champion_version_id: @version1[:id],
|
|
127
|
-
challenger_version_id: @version2[:id],
|
|
128
|
-
traffic_split: { champion: 50, challenger: 50 }
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
# Start the test
|
|
132
|
-
ab_test_manager.start_test(@test.id) if @test.status != "running"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
after do
|
|
136
|
-
FileUtils.rm_rf("./tmp/test_versions")
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
it "caches agents by version_id" do
|
|
140
|
-
ab_agent = DecisionAgent::ABTesting::ABTestingAgent.new(
|
|
141
|
-
ab_test_manager: ab_test_manager,
|
|
142
|
-
cache_agents: true
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
# Make multiple decisions
|
|
146
|
-
5.times do
|
|
147
|
-
ab_agent.decide(context: { amount: 100 }, ab_test_id: @test.id, user_id: "user1")
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# Check cache stats
|
|
151
|
-
stats = ab_agent.cache_stats
|
|
152
|
-
expect(stats[:cached_agents]).to be > 0
|
|
153
|
-
expect(stats[:version_ids]).not_to be_empty
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
it "can disable caching" do
|
|
157
|
-
ab_agent = DecisionAgent::ABTesting::ABTestingAgent.new(
|
|
158
|
-
ab_test_manager: ab_test_manager,
|
|
159
|
-
cache_agents: false
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
# Make multiple decisions
|
|
163
|
-
3.times do
|
|
164
|
-
ab_agent.decide(context: { amount: 100 }, ab_test_id: @test.id, user_id: "user2")
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Cache should be empty
|
|
168
|
-
stats = ab_agent.cache_stats
|
|
169
|
-
expect(stats[:cached_agents]).to eq(0)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
it "allows clearing the cache" do
|
|
173
|
-
ab_agent = DecisionAgent::ABTesting::ABTestingAgent.new(
|
|
174
|
-
ab_test_manager: ab_test_manager,
|
|
175
|
-
cache_agents: true
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
# Build cache
|
|
179
|
-
ab_agent.decide(context: { amount: 100 }, ab_test_id: @test.id, user_id: "user3")
|
|
180
|
-
expect(ab_agent.cache_stats[:cached_agents]).to be > 0
|
|
181
|
-
|
|
182
|
-
# Clear cache
|
|
183
|
-
ab_agent.clear_agent_cache!
|
|
184
|
-
expect(ab_agent.cache_stats[:cached_agents]).to eq(0)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
it "is thread-safe with concurrent access" do
|
|
188
|
-
ab_agent = DecisionAgent::ABTesting::ABTestingAgent.new(
|
|
189
|
-
ab_test_manager: ab_test_manager,
|
|
190
|
-
cache_agents: true
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
threads = 10.times.map do |i|
|
|
194
|
-
Thread.new do
|
|
195
|
-
ab_agent.decide(context: { amount: 100 }, ab_test_id: @test.id, user_id: "user#{i}")
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
threads.each(&:join)
|
|
200
|
-
|
|
201
|
-
# Should have cached agents without errors
|
|
202
|
-
expect(ab_agent.cache_stats[:cached_agents]).to be > 0
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
describe "ConditionEvaluator caching" do
|
|
207
|
-
before do
|
|
208
|
-
# Clear caches before each test
|
|
209
|
-
DecisionAgent::Dsl::ConditionEvaluator.clear_caches!
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
describe "regex caching" do
|
|
213
|
-
it "caches compiled regexes" do
|
|
214
|
-
context = DecisionAgent::Context.new({ email: "test@example.com" })
|
|
215
|
-
|
|
216
|
-
# First evaluation compiles regex
|
|
217
|
-
result1 = DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
218
|
-
{ "field" => "email", "op" => "matches", "value" => ".*@example\\.com$" },
|
|
219
|
-
context
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
# Check cache
|
|
223
|
-
stats = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
224
|
-
expect(stats[:regex_cache_size]).to eq(1)
|
|
225
|
-
|
|
226
|
-
# Second evaluation uses cached regex
|
|
227
|
-
result2 = DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
228
|
-
{ "field" => "email", "op" => "matches", "value" => ".*@example\\.com$" },
|
|
229
|
-
context
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
expect(result1).to eq(result2)
|
|
233
|
-
expect(stats[:regex_cache_size]).to eq(1) # Still 1
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
it "handles Regexp objects without caching" do
|
|
237
|
-
context = DecisionAgent::Context.new({ email: "test@example.com" })
|
|
238
|
-
regex = /.*@example\.com$/
|
|
239
|
-
|
|
240
|
-
result = DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
241
|
-
{ "field" => "email", "op" => "matches", "value" => regex },
|
|
242
|
-
context
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
expect(result).to be true
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
describe "path caching" do
|
|
250
|
-
it "caches split paths for nested field access" do
|
|
251
|
-
context = DecisionAgent::Context.new({ user: { profile: { role: "admin" } } })
|
|
252
|
-
|
|
253
|
-
# First access splits path
|
|
254
|
-
value1 = DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(
|
|
255
|
-
context.to_h,
|
|
256
|
-
"user.profile.role"
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
# Check cache
|
|
260
|
-
stats = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
261
|
-
expect(stats[:path_cache_size]).to eq(1)
|
|
262
|
-
|
|
263
|
-
# Second access uses cached path
|
|
264
|
-
value2 = DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(
|
|
265
|
-
context.to_h,
|
|
266
|
-
"user.profile.role"
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
expect(value1).to eq(value2)
|
|
270
|
-
expect(value1).to eq("admin")
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
it "caches multiple different paths" do
|
|
274
|
-
context = DecisionAgent::Context.new({
|
|
275
|
-
user: { name: "Alice", age: 30 },
|
|
276
|
-
order: { total: 100 }
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(context.to_h, "user.name")
|
|
280
|
-
DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(context.to_h, "user.age")
|
|
281
|
-
DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(context.to_h, "order.total")
|
|
282
|
-
|
|
283
|
-
stats = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
284
|
-
expect(stats[:path_cache_size]).to eq(3)
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
describe "date caching" do
|
|
289
|
-
it "caches parsed dates" do
|
|
290
|
-
context = DecisionAgent::Context.new({ created_at: "2025-01-01T00:00:00Z" })
|
|
291
|
-
|
|
292
|
-
# First evaluation parses date
|
|
293
|
-
result1 = DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
294
|
-
{ "field" => "created_at", "op" => "after_date", "value" => "2024-12-01T00:00:00Z" },
|
|
295
|
-
context
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
# Check cache
|
|
299
|
-
stats = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
300
|
-
expect(stats[:date_cache_size]).to be > 0
|
|
301
|
-
|
|
302
|
-
# Second evaluation uses cached date
|
|
303
|
-
result2 = DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
304
|
-
{ "field" => "created_at", "op" => "after_date", "value" => "2024-12-01T00:00:00Z" },
|
|
305
|
-
context
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
expect(result1).to eq(result2)
|
|
309
|
-
expect(result1).to be true
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
it "does not cache Time/Date objects" do
|
|
313
|
-
context = DecisionAgent::Context.new({ created_at: Time.now })
|
|
314
|
-
|
|
315
|
-
result = DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
316
|
-
{ "field" => "created_at", "op" => "after_date", "value" => Time.now - 3600 },
|
|
317
|
-
context
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
expect(result).to be true
|
|
321
|
-
end
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
describe "cache management" do
|
|
325
|
-
it "can clear all caches" do
|
|
326
|
-
context = DecisionAgent::Context.new({
|
|
327
|
-
email: "test@example.com",
|
|
328
|
-
user: { role: "admin" },
|
|
329
|
-
created_at: "2025-01-01T00:00:00Z"
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
# Populate caches
|
|
333
|
-
DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
334
|
-
{ "field" => "email", "op" => "matches", "value" => ".*@example\\.com$" },
|
|
335
|
-
context
|
|
336
|
-
)
|
|
337
|
-
DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(context.to_h, "user.role")
|
|
338
|
-
DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
339
|
-
{ "field" => "created_at", "op" => "after_date", "value" => "2024-01-01T00:00:00Z" },
|
|
340
|
-
context
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
stats_before = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
344
|
-
expect(stats_before.values.sum).to be > 0
|
|
345
|
-
|
|
346
|
-
# Clear caches
|
|
347
|
-
DecisionAgent::Dsl::ConditionEvaluator.clear_caches!
|
|
348
|
-
|
|
349
|
-
stats_after = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
350
|
-
expect(stats_after[:regex_cache_size]).to eq(0)
|
|
351
|
-
expect(stats_after[:path_cache_size]).to eq(0)
|
|
352
|
-
expect(stats_after[:date_cache_size]).to eq(0)
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
describe "thread safety" do
|
|
357
|
-
it "handles concurrent cache access safely" do
|
|
358
|
-
context = DecisionAgent::Context.new({
|
|
359
|
-
email: "test@example.com",
|
|
360
|
-
user: { profile: { role: "admin" } }
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
threads = 20.times.map do |_i|
|
|
364
|
-
Thread.new do
|
|
365
|
-
# Regex caching
|
|
366
|
-
DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
367
|
-
{ "field" => "email", "op" => "matches", "value" => ".*@example\\.com$" },
|
|
368
|
-
context
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
# Path caching
|
|
372
|
-
DecisionAgent::Dsl::ConditionEvaluator.get_nested_value(
|
|
373
|
-
context.to_h,
|
|
374
|
-
"user.profile.role"
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
# Date caching
|
|
378
|
-
DecisionAgent::Dsl::ConditionEvaluator.evaluate(
|
|
379
|
-
{ "field" => "created_at", "op" => "after_date", "value" => "2024-01-01T00:00:00Z" },
|
|
380
|
-
DecisionAgent::Context.new({ created_at: "2025-01-01T00:00:00Z" })
|
|
381
|
-
)
|
|
382
|
-
end
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
threads.each(&:join)
|
|
386
|
-
|
|
387
|
-
# Caches should be populated without errors
|
|
388
|
-
stats = DecisionAgent::Dsl::ConditionEvaluator.cache_stats
|
|
389
|
-
expect(stats[:regex_cache_size]).to be > 0
|
|
390
|
-
expect(stats[:path_cache_size]).to be > 0
|
|
391
|
-
end
|
|
392
|
-
end
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
describe "WebSocket broadcasting optimization" do
|
|
396
|
-
it "skips broadcast when no clients are connected" do
|
|
397
|
-
# This is tested indirectly through the dashboard server
|
|
398
|
-
# The optimization is in the broadcast_to_clients method
|
|
399
|
-
# which returns early if @websocket_clients.empty?
|
|
400
|
-
|
|
401
|
-
# We can verify the optimization exists in the code
|
|
402
|
-
server_code = File.read("lib/decision_agent/monitoring/dashboard_server.rb")
|
|
403
|
-
expect(server_code).to include("return if @websocket_clients.empty?")
|
|
404
|
-
end
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
describe "Performance benchmarks" do
|
|
408
|
-
let(:evaluator) do
|
|
409
|
-
DecisionAgent::Evaluators::JsonRuleEvaluator.new(
|
|
410
|
-
rules_json: {
|
|
411
|
-
version: "1.0",
|
|
412
|
-
ruleset: "benchmark",
|
|
413
|
-
rules: [
|
|
414
|
-
{
|
|
415
|
-
id: "rule1",
|
|
416
|
-
if: {
|
|
417
|
-
all: [
|
|
418
|
-
{ field: "amount", op: "gte", value: 100 },
|
|
419
|
-
{ field: "user.verified", op: "eq", value: true },
|
|
420
|
-
{ field: "email", op: "matches", value: ".*@example\\.com$" }
|
|
421
|
-
]
|
|
422
|
-
},
|
|
423
|
-
then: { decision: "approve", weight: 1.0, reason: "Approved" }
|
|
424
|
-
}
|
|
425
|
-
]
|
|
426
|
-
}
|
|
427
|
-
)
|
|
428
|
-
end
|
|
429
|
-
let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator], validate_evaluations: false) }
|
|
430
|
-
|
|
431
|
-
it "maintains high throughput with optimizations" do
|
|
432
|
-
require "benchmark"
|
|
433
|
-
|
|
434
|
-
# Warm up JIT and caches
|
|
435
|
-
100.times { agent.decide(context: { amount: 150, user: { verified: true }, email: "test@example.com" }) }
|
|
436
|
-
|
|
437
|
-
iterations = 2000
|
|
438
|
-
context = { amount: 150, user: { verified: true }, email: "test@example.com" }
|
|
439
|
-
|
|
440
|
-
time = Benchmark.realtime do
|
|
441
|
-
iterations.times do
|
|
442
|
-
agent.decide(context: context)
|
|
443
|
-
end
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
throughput = iterations / time
|
|
447
|
-
puts "\nThroughput: #{throughput.round(2)} decisions/second"
|
|
448
|
-
|
|
449
|
-
# Should maintain at least 2000 decisions/second
|
|
450
|
-
# Note: This test uses regex matching which is more expensive than simple comparisons.
|
|
451
|
-
# The threshold accounts for system variability, complex rules, test environment, and
|
|
452
|
-
# potential interference from other tests when running in the full suite.
|
|
453
|
-
# For simpler rules in production, expect 5,000-8,000+ decisions/second (see PERFORMANCE_AND_THREAD_SAFETY.md)
|
|
454
|
-
expect(throughput).to be > 2000
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
it "benefits from caching on repeated evaluations" do
|
|
458
|
-
require "benchmark"
|
|
459
|
-
|
|
460
|
-
iterations = 1000
|
|
461
|
-
context = { amount: 150, user: { verified: true }, email: "test@example.com" }
|
|
462
|
-
|
|
463
|
-
# Warm up caches
|
|
464
|
-
10.times { agent.decide(context: context) }
|
|
465
|
-
|
|
466
|
-
# Measure with warm cache
|
|
467
|
-
warm_time = Benchmark.realtime do
|
|
468
|
-
iterations.times { agent.decide(context: context) }
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
# Clear caches
|
|
472
|
-
DecisionAgent::Dsl::ConditionEvaluator.clear_caches!
|
|
473
|
-
|
|
474
|
-
# Measure with cold cache
|
|
475
|
-
cold_time = Benchmark.realtime do
|
|
476
|
-
iterations.times { agent.decide(context: context) }
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
warm_throughput = iterations / warm_time
|
|
480
|
-
cold_throughput = iterations / cold_time
|
|
481
|
-
|
|
482
|
-
puts "\nWarm cache throughput: #{warm_throughput.round(2)} decisions/second"
|
|
483
|
-
puts "Cold cache throughput: #{cold_throughput.round(2)} decisions/second"
|
|
484
|
-
puts "Improvement: #{(((warm_throughput / cold_throughput) - 1) * 100).round(2)}%"
|
|
485
|
-
|
|
486
|
-
# NOTE: Cache warming may not always show improvement in microbenchmarks
|
|
487
|
-
# due to Ruby's JIT, GC, and other factors. The important thing is
|
|
488
|
-
# that caching doesn't make things slower.
|
|
489
|
-
expect(warm_throughput).to be > 0
|
|
490
|
-
expect(cold_throughput).to be > 0
|
|
491
|
-
end
|
|
492
|
-
end
|
|
493
|
-
end
|