decision_agent 0.1.2 → 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.
- checksums.yaml +4 -4
- data/README.md +212 -35
- data/bin/decision_agent +3 -8
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- 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 +11 -8
- 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 +423 -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/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -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 +69 -33
- data/lib/decision_agent/versioning/adapter.rb +1 -3
- data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
- data/lib/decision_agent/versioning/version_manager.rb +4 -12
- data/lib/decision_agent/web/public/index.html +1 -1
- data/lib/decision_agent/web/server.rb +19 -24
- data/lib/decision_agent.rb +14 -0
- data/lib/generators/decision_agent/install/install_generator.rb +42 -5
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
- data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
- data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -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 +612 -0
- data/spec/issue_verification_spec.rb +759 -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/monitoring/storage/activerecord_adapter_spec.rb +346 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -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 +141 -37
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +93 -6
|
@@ -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
|