decision_agent 0.1.3 → 0.1.4

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  3. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  4. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  5. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  6. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  7. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  8. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  9. data/lib/decision_agent/monitoring/metrics_collector.rb +148 -3
  10. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  11. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  12. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  13. data/lib/decision_agent/version.rb +1 -1
  14. data/lib/decision_agent.rb +7 -0
  15. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  16. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  17. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  18. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  19. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  20. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  21. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  22. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  23. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  24. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  25. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  26. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  27. data/spec/ab_testing/ab_test_spec.rb +270 -0
  28. data/spec/examples.txt +612 -548
  29. data/spec/issue_verification_spec.rb +95 -21
  30. data/spec/monitoring/metrics_collector_spec.rb +2 -2
  31. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  32. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  33. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  34. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  35. metadata +26 -2
@@ -0,0 +1,330 @@
1
+ require "spec_helper"
2
+ require "decision_agent/ab_testing/ab_test_manager"
3
+ require "decision_agent/ab_testing/storage/memory_adapter"
4
+ require "decision_agent/versioning/file_storage_adapter"
5
+
6
+ RSpec.describe DecisionAgent::ABTesting::ABTestManager do
7
+ let(:version_manager) do
8
+ DecisionAgent::Versioning::VersionManager.new(
9
+ adapter: DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: "/tmp/spec_ab_test_versions")
10
+ )
11
+ end
12
+
13
+ let(:storage_adapter) { DecisionAgent::ABTesting::Storage::MemoryAdapter.new }
14
+ let(:manager) { described_class.new(storage_adapter: storage_adapter, version_manager: version_manager) }
15
+
16
+ before do
17
+ # Create test versions
18
+ @champion = version_manager.save_version(
19
+ rule_id: "test_rule",
20
+ rule_content: { rules: [{ decision: "approve", weight: 1.0 }] },
21
+ created_by: "spec"
22
+ )
23
+
24
+ @challenger = version_manager.save_version(
25
+ rule_id: "test_rule",
26
+ rule_content: { rules: [{ decision: "reject", weight: 1.0 }] },
27
+ created_by: "spec"
28
+ )
29
+ end
30
+
31
+ after do
32
+ FileUtils.rm_rf("/tmp/spec_ab_test_versions")
33
+ end
34
+
35
+ describe "#create_test" do
36
+ it "creates a new A/B test" do
37
+ test = manager.create_test(
38
+ name: "Test A vs B",
39
+ champion_version_id: @champion[:id],
40
+ challenger_version_id: @challenger[:id]
41
+ )
42
+
43
+ expect(test).to be_a(DecisionAgent::ABTesting::ABTest)
44
+ expect(test.name).to eq("Test A vs B")
45
+ expect(test.id).not_to be_nil
46
+ end
47
+
48
+ it "validates that champion version exists" do
49
+ expect do
50
+ manager.create_test(
51
+ name: "Test",
52
+ champion_version_id: "nonexistent",
53
+ challenger_version_id: @challenger[:id]
54
+ )
55
+ end.to raise_error(DecisionAgent::ABTesting::VersionNotFoundError, /Champion/)
56
+ end
57
+
58
+ it "validates that challenger version exists" do
59
+ expect do
60
+ manager.create_test(
61
+ name: "Test",
62
+ champion_version_id: @champion[:id],
63
+ challenger_version_id: "nonexistent"
64
+ )
65
+ end.to raise_error(DecisionAgent::ABTesting::VersionNotFoundError, /Challenger/)
66
+ end
67
+
68
+ it "accepts custom traffic split" do
69
+ test = manager.create_test(
70
+ name: "Custom Split",
71
+ champion_version_id: @champion[:id],
72
+ challenger_version_id: @challenger[:id],
73
+ traffic_split: { champion: 70, challenger: 30 }
74
+ )
75
+
76
+ expect(test.traffic_split).to eq({ champion: 70, challenger: 30 })
77
+ end
78
+ end
79
+
80
+ describe "#get_test" do
81
+ it "retrieves a test by ID" do
82
+ created_test = manager.create_test(
83
+ name: "Test",
84
+ champion_version_id: @champion[:id],
85
+ challenger_version_id: @challenger[:id]
86
+ )
87
+
88
+ retrieved_test = manager.get_test(created_test.id)
89
+
90
+ expect(retrieved_test).not_to be_nil
91
+ expect(retrieved_test.id).to eq(created_test.id)
92
+ expect(retrieved_test.name).to eq("Test")
93
+ end
94
+
95
+ it "returns nil for nonexistent test" do
96
+ test = manager.get_test(99_999)
97
+ expect(test).to be_nil
98
+ end
99
+ end
100
+
101
+ describe "#assign_variant" do
102
+ let(:test) do
103
+ manager.create_test(
104
+ name: "Test",
105
+ champion_version_id: @champion[:id],
106
+ challenger_version_id: @challenger[:id],
107
+ start_date: Time.now.utc + 3600
108
+ )
109
+ end
110
+
111
+ before do
112
+ manager.start_test(test.id)
113
+ end
114
+
115
+ it "assigns a variant and returns assignment details" do
116
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_123")
117
+
118
+ expect(assignment[:test_id]).to eq(test.id)
119
+ expect(%i[champion challenger]).to include(assignment[:variant])
120
+ expect([@champion[:id], @challenger[:id]]).to include(assignment[:version_id])
121
+ expect(assignment[:assignment_id]).not_to be_nil
122
+ end
123
+
124
+ it "assigns same variant to same user" do
125
+ user_id = "consistent_user"
126
+ assignment1 = manager.assign_variant(test_id: test.id, user_id: user_id)
127
+ assignment2 = manager.assign_variant(test_id: test.id, user_id: user_id)
128
+
129
+ expect(assignment1[:variant]).to eq(assignment2[:variant])
130
+ end
131
+
132
+ it "raises error for nonexistent test" do
133
+ expect do
134
+ manager.assign_variant(test_id: 99_999)
135
+ end.to raise_error(DecisionAgent::ABTesting::TestNotFoundError)
136
+ end
137
+ end
138
+
139
+ describe "#record_decision" do
140
+ let(:test) do
141
+ test = manager.create_test(
142
+ name: "Test",
143
+ champion_version_id: @champion[:id],
144
+ challenger_version_id: @challenger[:id],
145
+ start_date: Time.now.utc + 3600
146
+ )
147
+ manager.start_test(test.id)
148
+ test
149
+ end
150
+
151
+ it "records decision result for an assignment" do
152
+ assignment = manager.assign_variant(test_id: test.id)
153
+
154
+ expect do
155
+ manager.record_decision(
156
+ assignment_id: assignment[:assignment_id],
157
+ decision: "approve",
158
+ confidence: 0.95
159
+ )
160
+ end.not_to raise_error
161
+ end
162
+ end
163
+
164
+ describe "#get_results" do
165
+ let(:test) do
166
+ test = manager.create_test(
167
+ name: "Test",
168
+ champion_version_id: @champion[:id],
169
+ challenger_version_id: @challenger[:id],
170
+ start_date: Time.now.utc + 3600
171
+ )
172
+ manager.start_test(test.id)
173
+ test
174
+ end
175
+
176
+ it "returns results with statistics" do
177
+ # Create some assignments and record decisions
178
+ 10.times do |i|
179
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
180
+ manager.record_decision(
181
+ assignment_id: assignment[:assignment_id],
182
+ decision: "approve",
183
+ confidence: 0.8 + (rand * 0.2)
184
+ )
185
+ end
186
+
187
+ results = manager.get_results(test.id)
188
+
189
+ expect(results[:test]).to be_a(Hash)
190
+ expect(results[:champion]).to be_a(Hash)
191
+ expect(results[:challenger]).to be_a(Hash)
192
+ expect(results[:comparison]).to be_a(Hash)
193
+ expect(results[:total_assignments]).to eq(10)
194
+ end
195
+
196
+ it "handles tests with no assignments" do
197
+ results = manager.get_results(test.id)
198
+
199
+ expect(results[:total_assignments]).to eq(0)
200
+ expect(results[:champion][:decisions_recorded]).to eq(0)
201
+ expect(results[:challenger][:decisions_recorded]).to eq(0)
202
+ end
203
+ end
204
+
205
+ describe "#active_tests" do
206
+ it "returns only running tests" do
207
+ test1 = manager.create_test(
208
+ name: "Running Test",
209
+ champion_version_id: @champion[:id],
210
+ challenger_version_id: @challenger[:id],
211
+ start_date: Time.now.utc + 3600
212
+ )
213
+ manager.start_test(test1.id)
214
+
215
+ manager.create_test(
216
+ name: "Scheduled Test",
217
+ champion_version_id: @champion[:id],
218
+ challenger_version_id: @challenger[:id],
219
+ start_date: Time.now.utc + 3600
220
+ )
221
+
222
+ active = manager.active_tests
223
+
224
+ expect(active.size).to eq(1)
225
+ expect(active.first.id).to eq(test1.id)
226
+ end
227
+
228
+ it "caches active tests for performance" do
229
+ test = manager.create_test(
230
+ name: "Test",
231
+ champion_version_id: @champion[:id],
232
+ challenger_version_id: @challenger[:id],
233
+ start_date: Time.now.utc + 3600
234
+ )
235
+ manager.start_test(test.id)
236
+
237
+ # First call
238
+ manager.active_tests
239
+
240
+ # Expect storage adapter not to be called again (cached)
241
+ expect(storage_adapter).not_to receive(:list_tests)
242
+ manager.active_tests
243
+ end
244
+ end
245
+
246
+ describe "test lifecycle" do
247
+ let(:test) do
248
+ manager.create_test(
249
+ name: "Test",
250
+ champion_version_id: @champion[:id],
251
+ challenger_version_id: @challenger[:id],
252
+ start_date: Time.now.utc + 3600
253
+ )
254
+ end
255
+
256
+ it "starts a scheduled test" do
257
+ manager.start_test(test.id)
258
+ updated_test = manager.get_test(test.id)
259
+
260
+ expect(updated_test.status).to eq("running")
261
+ end
262
+
263
+ it "completes a running test" do
264
+ manager.start_test(test.id)
265
+ manager.complete_test(test.id)
266
+ updated_test = manager.get_test(test.id)
267
+
268
+ expect(updated_test.status).to eq("completed")
269
+ end
270
+
271
+ it "cancels a test" do
272
+ manager.cancel_test(test.id)
273
+ updated_test = manager.get_test(test.id)
274
+
275
+ expect(updated_test.status).to eq("cancelled")
276
+ end
277
+ end
278
+
279
+ describe "statistical analysis" do
280
+ let(:test) do
281
+ test = manager.create_test(
282
+ name: "Statistical Test",
283
+ champion_version_id: @champion[:id],
284
+ challenger_version_id: @challenger[:id],
285
+ traffic_split: { champion: 50, challenger: 50 },
286
+ start_date: Time.now.utc + 3600
287
+ )
288
+ manager.start_test(test.id)
289
+ test
290
+ end
291
+
292
+ it "calculates improvement percentage" do
293
+ # Create assignments with different confidence levels
294
+ 50.times do |i|
295
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
296
+
297
+ # Champion: avg 0.7, Challenger: avg 0.9
298
+ confidence = assignment[:variant] == :champion ? 0.7 : 0.9
299
+
300
+ manager.record_decision(
301
+ assignment_id: assignment[:assignment_id],
302
+ decision: "approve",
303
+ confidence: confidence
304
+ )
305
+ end
306
+
307
+ results = manager.get_results(test.id)
308
+
309
+ # Challenger should have higher avg confidence (0.9 vs 0.7)
310
+ expect(results[:comparison][:improvement_percentage]).to be > 0
311
+ expect(%w[champion challenger inconclusive]).to include(results[:comparison][:winner])
312
+ end
313
+
314
+ it "indicates insufficient data when sample is too small" do
315
+ # Create only a few assignments
316
+ 5.times do |i|
317
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
318
+ manager.record_decision(
319
+ assignment_id: assignment[:assignment_id],
320
+ decision: "approve",
321
+ confidence: 0.8
322
+ )
323
+ end
324
+
325
+ results = manager.get_results(test.id)
326
+
327
+ expect(results[:comparison][:statistical_significance]).to eq("not_significant")
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,270 @@
1
+ require "spec_helper"
2
+ require "decision_agent/ab_testing/ab_test"
3
+
4
+ RSpec.describe DecisionAgent::ABTesting::ABTest do
5
+ describe "#initialize" do
6
+ it "creates a valid A/B test with default values" do
7
+ test = described_class.new(
8
+ name: "Test A vs B",
9
+ champion_version_id: "v1",
10
+ challenger_version_id: "v2"
11
+ )
12
+
13
+ expect(test.name).to eq("Test A vs B")
14
+ expect(test.champion_version_id).to eq("v1")
15
+ expect(test.challenger_version_id).to eq("v2")
16
+ expect(test.traffic_split).to eq({ champion: 90, challenger: 10 })
17
+ expect(test.status).to eq("scheduled")
18
+ end
19
+
20
+ it "accepts custom traffic split as hash" do
21
+ test = described_class.new(
22
+ name: "Custom Split",
23
+ champion_version_id: "v1",
24
+ challenger_version_id: "v2",
25
+ traffic_split: { champion: 70, challenger: 30 }
26
+ )
27
+
28
+ expect(test.traffic_split).to eq({ champion: 70, challenger: 30 })
29
+ end
30
+
31
+ it "accepts custom traffic split as array" do
32
+ test = described_class.new(
33
+ name: "Array Split",
34
+ champion_version_id: "v1",
35
+ challenger_version_id: "v2",
36
+ traffic_split: [80, 20]
37
+ )
38
+
39
+ expect(test.traffic_split).to eq({ champion: 80, challenger: 20 })
40
+ end
41
+
42
+ it "raises error if traffic split doesn't sum to 100" do
43
+ expect do
44
+ described_class.new(
45
+ name: "Bad Split",
46
+ champion_version_id: "v1",
47
+ challenger_version_id: "v2",
48
+ traffic_split: { champion: 60, challenger: 30 }
49
+ )
50
+ end.to raise_error(DecisionAgent::ValidationError, /must sum to 100/)
51
+ end
52
+
53
+ it "raises error if champion and challenger are the same" do
54
+ expect do
55
+ described_class.new(
56
+ name: "Same Versions",
57
+ champion_version_id: "v1",
58
+ challenger_version_id: "v1"
59
+ )
60
+ end.to raise_error(DecisionAgent::ValidationError, /must be different/)
61
+ end
62
+
63
+ it "raises error if name is empty" do
64
+ expect do
65
+ described_class.new(
66
+ name: "",
67
+ champion_version_id: "v1",
68
+ challenger_version_id: "v2"
69
+ )
70
+ end.to raise_error(DecisionAgent::ValidationError, /name is required/)
71
+ end
72
+ end
73
+
74
+ describe "#assign_variant" do
75
+ let(:test) do
76
+ described_class.new(
77
+ name: "Test",
78
+ champion_version_id: "v1",
79
+ challenger_version_id: "v2",
80
+ traffic_split: { champion: 90, challenger: 10 },
81
+ status: "running",
82
+ id: 123
83
+ )
84
+ end
85
+
86
+ it "assigns champion or challenger based on traffic split" do
87
+ assignments = 1000.times.map { test.assign_variant }
88
+ champion_count = assignments.count { |v| v == :champion }
89
+ challenger_count = assignments.count { |v| v == :challenger }
90
+
91
+ # With 90/10 split, expect roughly 900/100
92
+ expect(champion_count).to be_between(850, 950)
93
+ expect(challenger_count).to be_between(50, 150)
94
+ end
95
+
96
+ it "assigns same variant to same user consistently" do
97
+ user_id = "user_123"
98
+ variants = 10.times.map { test.assign_variant(user_id: user_id) }
99
+
100
+ expect(variants.uniq.size).to eq(1)
101
+ end
102
+
103
+ it "assigns different users to different variants based on split" do
104
+ assignments = 1000.times.map { |i| test.assign_variant(user_id: "user_#{i}") }
105
+ champion_count = assignments.count { |v| v == :champion }
106
+ challenger_count = assignments.count { |v| v == :challenger }
107
+
108
+ expect(champion_count).to be_between(850, 950)
109
+ expect(challenger_count).to be_between(50, 150)
110
+ end
111
+
112
+ it "raises error if test is not running" do
113
+ test = described_class.new(
114
+ name: "Not Running",
115
+ champion_version_id: "v1",
116
+ challenger_version_id: "v2",
117
+ status: "completed"
118
+ )
119
+
120
+ expect do
121
+ test.assign_variant
122
+ end.to raise_error(DecisionAgent::ABTesting::TestNotRunningError)
123
+ end
124
+ end
125
+
126
+ describe "#version_for_variant" do
127
+ let(:test) do
128
+ described_class.new(
129
+ name: "Test",
130
+ champion_version_id: "champion_v1",
131
+ challenger_version_id: "challenger_v2"
132
+ )
133
+ end
134
+
135
+ it "returns champion version ID for :champion variant" do
136
+ expect(test.version_for_variant(:champion)).to eq("champion_v1")
137
+ end
138
+
139
+ it "returns challenger version ID for :challenger variant" do
140
+ expect(test.version_for_variant(:challenger)).to eq("challenger_v2")
141
+ end
142
+
143
+ it "raises error for invalid variant" do
144
+ expect do
145
+ test.version_for_variant(:invalid)
146
+ end.to raise_error(ArgumentError, /Invalid variant/)
147
+ end
148
+ end
149
+
150
+ describe "#running?" do
151
+ it "returns true when status is running and within date range" do
152
+ test = described_class.new(
153
+ name: "Test",
154
+ champion_version_id: "v1",
155
+ challenger_version_id: "v2",
156
+ status: "running",
157
+ start_date: Time.now.utc - 3600,
158
+ end_date: Time.now.utc + 3600
159
+ )
160
+
161
+ expect(test.running?).to be true
162
+ end
163
+
164
+ it "returns false when status is not running" do
165
+ test = described_class.new(
166
+ name: "Test",
167
+ champion_version_id: "v1",
168
+ challenger_version_id: "v2",
169
+ status: "completed"
170
+ )
171
+
172
+ expect(test.running?).to be false
173
+ end
174
+
175
+ it "returns false when start date is in future" do
176
+ test = described_class.new(
177
+ name: "Test",
178
+ champion_version_id: "v1",
179
+ challenger_version_id: "v2",
180
+ status: "running",
181
+ start_date: Time.now.utc + 3600
182
+ )
183
+
184
+ expect(test.running?).to be false
185
+ end
186
+
187
+ it "returns false when end date has passed" do
188
+ test = described_class.new(
189
+ name: "Test",
190
+ champion_version_id: "v1",
191
+ challenger_version_id: "v2",
192
+ status: "running",
193
+ start_date: Time.now.utc - 7200,
194
+ end_date: Time.now.utc - 3600
195
+ )
196
+
197
+ expect(test.running?).to be false
198
+ end
199
+ end
200
+
201
+ describe "status transitions" do
202
+ it "can start a scheduled test" do
203
+ test = described_class.new(
204
+ name: "Test",
205
+ champion_version_id: "v1",
206
+ challenger_version_id: "v2",
207
+ status: "scheduled"
208
+ )
209
+
210
+ expect { test.start! }.not_to raise_error
211
+ expect(test.status).to eq("running")
212
+ end
213
+
214
+ it "can complete a running test" do
215
+ test = described_class.new(
216
+ name: "Test",
217
+ champion_version_id: "v1",
218
+ challenger_version_id: "v2",
219
+ status: "running"
220
+ )
221
+
222
+ expect { test.complete! }.not_to raise_error
223
+ expect(test.status).to eq("completed")
224
+ end
225
+
226
+ it "can cancel a test" do
227
+ test = described_class.new(
228
+ name: "Test",
229
+ champion_version_id: "v1",
230
+ challenger_version_id: "v2",
231
+ status: "running"
232
+ )
233
+
234
+ expect { test.cancel! }.not_to raise_error
235
+ expect(test.status).to eq("cancelled")
236
+ end
237
+
238
+ it "raises error when trying invalid status transition" do
239
+ test = described_class.new(
240
+ name: "Test",
241
+ champion_version_id: "v1",
242
+ challenger_version_id: "v2",
243
+ status: "completed"
244
+ )
245
+
246
+ expect do
247
+ test.start!
248
+ end.to raise_error(DecisionAgent::ABTesting::InvalidStatusTransitionError)
249
+ end
250
+ end
251
+
252
+ describe "#to_h" do
253
+ it "returns hash representation" do
254
+ test = described_class.new(
255
+ name: "Test",
256
+ champion_version_id: "v1",
257
+ challenger_version_id: "v2",
258
+ id: 123
259
+ )
260
+
261
+ hash = test.to_h
262
+
263
+ expect(hash[:id]).to eq(123)
264
+ expect(hash[:name]).to eq("Test")
265
+ expect(hash[:champion_version_id]).to eq("v1")
266
+ expect(hash[:challenger_version_id]).to eq("v2")
267
+ expect(hash[:status]).to eq("scheduled")
268
+ end
269
+ end
270
+ end