decision_agent 0.1.3 → 0.1.6

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -6
@@ -0,0 +1,486 @@
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
+ iterations = 1000
435
+ context = { amount: 150, user: { verified: true }, email: "test@example.com" }
436
+
437
+ time = Benchmark.realtime do
438
+ iterations.times do
439
+ agent.decide(context: context)
440
+ end
441
+ end
442
+
443
+ throughput = iterations / time
444
+ puts "\nThroughput: #{throughput.round(2)} decisions/second"
445
+
446
+ # Should maintain at least 5000 decisions/second (conservative estimate)
447
+ expect(throughput).to be > 5000
448
+ end
449
+
450
+ it "benefits from caching on repeated evaluations" do
451
+ require "benchmark"
452
+
453
+ iterations = 1000
454
+ context = { amount: 150, user: { verified: true }, email: "test@example.com" }
455
+
456
+ # Warm up caches
457
+ 10.times { agent.decide(context: context) }
458
+
459
+ # Measure with warm cache
460
+ warm_time = Benchmark.realtime do
461
+ iterations.times { agent.decide(context: context) }
462
+ end
463
+
464
+ # Clear caches
465
+ DecisionAgent::Dsl::ConditionEvaluator.clear_caches!
466
+
467
+ # Measure with cold cache
468
+ cold_time = Benchmark.realtime do
469
+ iterations.times { agent.decide(context: context) }
470
+ end
471
+
472
+ warm_throughput = iterations / warm_time
473
+ cold_throughput = iterations / cold_time
474
+
475
+ puts "\nWarm cache throughput: #{warm_throughput.round(2)} decisions/second"
476
+ puts "Cold cache throughput: #{cold_throughput.round(2)} decisions/second"
477
+ puts "Improvement: #{(((warm_throughput / cold_throughput) - 1) * 100).round(2)}%"
478
+
479
+ # NOTE: Cache warming may not always show improvement in microbenchmarks
480
+ # due to Ruby's JIT, GC, and other factors. The important thing is
481
+ # that caching doesn't make things slower.
482
+ expect(warm_throughput).to be > 0
483
+ expect(cold_throughput).to be > 0
484
+ end
485
+ end
486
+ end
data/spec/spec_helper.rb CHANGED
@@ -15,6 +15,11 @@ rescue LoadError
15
15
  # ActiveRecord is optional - tests will be skipped if not available
16
16
  end
17
17
 
18
+ # Store original value for cleanup
19
+ # rubocop:disable Style/GlobalVars
20
+ $original_disable_webui_permissions = nil
21
+ # rubocop:enable Style/GlobalVars
22
+
18
23
  RSpec.configure do |config|
19
24
  config.expect_with :rspec do |expectations|
20
25
  expectations.include_chain_clauses_in_custom_matcher_descriptions = true
@@ -34,4 +39,22 @@ RSpec.configure do |config|
34
39
 
35
40
  config.order = :random
36
41
  Kernel.srand config.seed
42
+
43
+ # Ensure permissions are enabled for tests
44
+ config.before(:suite) do
45
+ # rubocop:disable Style/GlobalVars
46
+ $original_disable_webui_permissions = ENV.fetch("DISABLE_WEBUI_PERMISSIONS", nil)
47
+ # rubocop:enable Style/GlobalVars
48
+ ENV["DISABLE_WEBUI_PERMISSIONS"] = "false"
49
+ end
50
+
51
+ config.after(:suite) do
52
+ # rubocop:disable Style/GlobalVars
53
+ if $original_disable_webui_permissions
54
+ ENV["DISABLE_WEBUI_PERMISSIONS"] = $original_disable_webui_permissions
55
+ else
56
+ ENV.delete("DISABLE_WEBUI_PERMISSIONS")
57
+ end
58
+ # rubocop:enable Style/GlobalVars
59
+ end
37
60
  end