decision_agent 0.1.4 → 0.1.7
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 +83 -232
- data/bin/decision_agent +1 -1
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
- data/lib/decision_agent/agent.rb +5 -3
- data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
- data/lib/decision_agent/auth/authenticator.rb +127 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
- data/lib/decision_agent/auth/password_reset_token.rb +33 -0
- data/lib/decision_agent/auth/permission.rb +29 -0
- data/lib/decision_agent/auth/permission_checker.rb +43 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
- data/lib/decision_agent/auth/rbac_config.rb +51 -0
- data/lib/decision_agent/auth/role.rb +56 -0
- data/lib/decision_agent/auth/session.rb +33 -0
- data/lib/decision_agent/auth/session_manager.rb +57 -0
- data/lib/decision_agent/auth/user.rb +70 -0
- data/lib/decision_agent/context.rb +24 -4
- data/lib/decision_agent/decision.rb +10 -3
- data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
- data/lib/decision_agent/dsl/schema_validator.rb +8 -1
- data/lib/decision_agent/errors.rb +38 -0
- data/lib/decision_agent/evaluation.rb +10 -3
- data/lib/decision_agent/evaluation_validator.rb +8 -13
- data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
- data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
- data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
- data/lib/decision_agent/testing/test_scenario.rb +42 -0
- data/lib/decision_agent/version.rb +10 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
- data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
- data/lib/decision_agent/web/public/app.js +184 -29
- data/lib/decision_agent/web/public/batch_testing.html +640 -0
- data/lib/decision_agent/web/public/index.html +38 -10
- data/lib/decision_agent/web/public/login.html +298 -0
- data/lib/decision_agent/web/public/users.html +679 -0
- data/lib/decision_agent/web/server.rb +873 -7
- data/lib/decision_agent.rb +52 -0
- data/lib/generators/decision_agent/install/templates/README +1 -1
- data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
- data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
- data/spec/ab_testing/storage/adapter_spec.rb +64 -0
- data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
- data/spec/advanced_operators_spec.rb +1003 -0
- data/spec/agent_spec.rb +40 -0
- data/spec/audit_adapters_spec.rb +18 -0
- data/spec/auth/access_audit_logger_spec.rb +394 -0
- data/spec/auth/authenticator_spec.rb +112 -0
- data/spec/auth/password_reset_spec.rb +294 -0
- data/spec/auth/permission_checker_spec.rb +207 -0
- data/spec/auth/permission_spec.rb +73 -0
- data/spec/auth/rbac_adapter_spec.rb +550 -0
- data/spec/auth/rbac_config_spec.rb +82 -0
- data/spec/auth/role_spec.rb +51 -0
- data/spec/auth/session_manager_spec.rb +172 -0
- data/spec/auth/session_spec.rb +112 -0
- data/spec/auth/user_spec.rb +130 -0
- data/spec/context_spec.rb +43 -0
- data/spec/decision_agent_spec.rb +96 -0
- data/spec/decision_spec.rb +423 -0
- data/spec/dsl/condition_evaluator_spec.rb +774 -0
- data/spec/evaluation_spec.rb +364 -0
- data/spec/evaluation_validator_spec.rb +165 -0
- data/spec/monitoring/metrics_collector_spec.rb +220 -2
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/performance_optimizations_spec.rb +486 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/testing/batch_test_importer_spec.rb +693 -0
- data/spec/testing/batch_test_runner_spec.rb +307 -0
- data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
- data/spec/testing/test_result_comparator_spec.rb +392 -0
- data/spec/testing/test_scenario_spec.rb +113 -0
- data/spec/versioning/adapter_spec.rb +156 -0
- data/spec/versioning_spec.rb +253 -0
- data/spec/web/middleware/auth_middleware_spec.rb +133 -0
- data/spec/web/middleware/permission_middleware_spec.rb +247 -0
- data/spec/web_ui_rack_spec.rb +1705 -0
- metadata +103 -11
- data/spec/examples.txt +0 -612
data/lib/decision_agent.rb
CHANGED
|
@@ -43,5 +43,57 @@ require_relative "decision_agent/ab_testing/ab_testing_agent"
|
|
|
43
43
|
require_relative "decision_agent/ab_testing/storage/adapter"
|
|
44
44
|
require_relative "decision_agent/ab_testing/storage/memory_adapter"
|
|
45
45
|
|
|
46
|
+
require_relative "decision_agent/testing/test_scenario"
|
|
47
|
+
require_relative "decision_agent/testing/batch_test_importer"
|
|
48
|
+
require_relative "decision_agent/testing/batch_test_runner"
|
|
49
|
+
require_relative "decision_agent/testing/test_result_comparator"
|
|
50
|
+
require_relative "decision_agent/testing/test_coverage_analyzer"
|
|
51
|
+
|
|
52
|
+
require_relative "decision_agent/auth/user"
|
|
53
|
+
require_relative "decision_agent/auth/role"
|
|
54
|
+
require_relative "decision_agent/auth/permission"
|
|
55
|
+
require_relative "decision_agent/auth/session"
|
|
56
|
+
require_relative "decision_agent/auth/session_manager"
|
|
57
|
+
require_relative "decision_agent/auth/password_reset_token"
|
|
58
|
+
require_relative "decision_agent/auth/password_reset_manager"
|
|
59
|
+
require_relative "decision_agent/auth/authenticator"
|
|
60
|
+
require_relative "decision_agent/auth/rbac_adapter"
|
|
61
|
+
require_relative "decision_agent/auth/rbac_config"
|
|
62
|
+
require_relative "decision_agent/auth/permission_checker"
|
|
63
|
+
require_relative "decision_agent/auth/access_audit_logger"
|
|
64
|
+
|
|
46
65
|
module DecisionAgent
|
|
66
|
+
# Global RBAC configuration
|
|
67
|
+
@rbac_config = Auth::RbacConfig.new
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
attr_reader :rbac_config
|
|
71
|
+
|
|
72
|
+
# Configure RBAC adapter
|
|
73
|
+
# @param adapter_type [Symbol] :default, :devise_cancan, :pundit, or :custom
|
|
74
|
+
# @param options [Hash] Options for the adapter
|
|
75
|
+
# @yield [RbacConfig] Configuration block
|
|
76
|
+
# @example
|
|
77
|
+
# DecisionAgent.configure_rbac(:devise_cancan, ability_class: Ability)
|
|
78
|
+
# @example
|
|
79
|
+
# DecisionAgent.configure_rbac(:custom) do |config|
|
|
80
|
+
# config.adapter = MyCustomAdapter.new
|
|
81
|
+
# end
|
|
82
|
+
def configure_rbac(adapter_type = nil, **options)
|
|
83
|
+
if block_given?
|
|
84
|
+
yield @rbac_config
|
|
85
|
+
elsif adapter_type
|
|
86
|
+
@rbac_config.use(adapter_type, **options)
|
|
87
|
+
end
|
|
88
|
+
@rbac_config
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get the configured permission checker
|
|
92
|
+
def permission_checker
|
|
93
|
+
@permission_checker ||= Auth::PermissionChecker.new(adapter: @rbac_config.adapter)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Set a custom permission checker
|
|
97
|
+
attr_writer :permission_checker
|
|
98
|
+
end
|
|
47
99
|
end
|
|
@@ -42,6 +42,6 @@ Next steps:
|
|
|
42
42
|
# mount DecisionAgent::Engine => '/decision_agent'
|
|
43
43
|
|
|
44
44
|
For more information, visit:
|
|
45
|
-
https://github.com/
|
|
45
|
+
https://github.com/samaswin/decision_agent
|
|
46
46
|
|
|
47
47
|
===============================================================================
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent/ab_testing/ab_test_assignment"
|
|
3
|
+
|
|
4
|
+
RSpec.describe DecisionAgent::ABTesting::ABTestAssignment do
|
|
5
|
+
describe "#initialize" do
|
|
6
|
+
it "creates an assignment with required fields" do
|
|
7
|
+
assignment = described_class.new(
|
|
8
|
+
ab_test_id: "test_1",
|
|
9
|
+
variant: :champion,
|
|
10
|
+
version_id: "v1"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
expect(assignment.ab_test_id).to eq("test_1")
|
|
14
|
+
expect(assignment.variant).to eq(:champion)
|
|
15
|
+
expect(assignment.version_id).to eq("v1")
|
|
16
|
+
expect(assignment.timestamp).to be_a(Time)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "accepts optional user_id" do
|
|
20
|
+
assignment = described_class.new(
|
|
21
|
+
ab_test_id: "test_1",
|
|
22
|
+
variant: :champion,
|
|
23
|
+
version_id: "v1",
|
|
24
|
+
user_id: "user_123"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(assignment.user_id).to eq("user_123")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "accepts optional timestamp" do
|
|
31
|
+
custom_time = Time.new(2024, 1, 1, 12, 0, 0, "+00:00")
|
|
32
|
+
assignment = described_class.new(
|
|
33
|
+
ab_test_id: "test_1",
|
|
34
|
+
variant: :champion,
|
|
35
|
+
version_id: "v1",
|
|
36
|
+
timestamp: custom_time
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(assignment.timestamp).to eq(custom_time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "accepts optional decision_result and confidence" do
|
|
43
|
+
assignment = described_class.new(
|
|
44
|
+
ab_test_id: "test_1",
|
|
45
|
+
variant: :champion,
|
|
46
|
+
version_id: "v1",
|
|
47
|
+
decision_result: "approve",
|
|
48
|
+
confidence: 0.95
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(assignment.decision_result).to eq("approve")
|
|
52
|
+
expect(assignment.confidence).to eq(0.95)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "accepts optional context" do
|
|
56
|
+
context = { user_type: "premium", region: "us" }
|
|
57
|
+
assignment = described_class.new(
|
|
58
|
+
ab_test_id: "test_1",
|
|
59
|
+
variant: :champion,
|
|
60
|
+
version_id: "v1",
|
|
61
|
+
context: context
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(assignment.context).to eq(context)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "defaults context to empty hash" do
|
|
68
|
+
assignment = described_class.new(
|
|
69
|
+
ab_test_id: "test_1",
|
|
70
|
+
variant: :champion,
|
|
71
|
+
version_id: "v1"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(assignment.context).to eq({})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "accepts optional id" do
|
|
78
|
+
assignment = described_class.new(
|
|
79
|
+
ab_test_id: "test_1",
|
|
80
|
+
variant: :champion,
|
|
81
|
+
version_id: "v1",
|
|
82
|
+
id: "assign_123"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(assignment.id).to eq("assign_123")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "raises error if ab_test_id is nil" do
|
|
89
|
+
expect do
|
|
90
|
+
described_class.new(
|
|
91
|
+
ab_test_id: nil,
|
|
92
|
+
variant: :champion,
|
|
93
|
+
version_id: "v1"
|
|
94
|
+
)
|
|
95
|
+
end.to raise_error(DecisionAgent::ValidationError, /AB test ID is required/)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises error if variant is nil" do
|
|
99
|
+
expect do
|
|
100
|
+
described_class.new(
|
|
101
|
+
ab_test_id: "test_1",
|
|
102
|
+
variant: nil,
|
|
103
|
+
version_id: "v1"
|
|
104
|
+
)
|
|
105
|
+
end.to raise_error(DecisionAgent::ValidationError, /Variant is required/)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "raises error if version_id is nil" do
|
|
109
|
+
expect do
|
|
110
|
+
described_class.new(
|
|
111
|
+
ab_test_id: "test_1",
|
|
112
|
+
variant: :champion,
|
|
113
|
+
version_id: nil
|
|
114
|
+
)
|
|
115
|
+
end.to raise_error(DecisionAgent::ValidationError, /Version ID is required/)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "raises error if variant is not :champion or :challenger" do
|
|
119
|
+
expect do
|
|
120
|
+
described_class.new(
|
|
121
|
+
ab_test_id: "test_1",
|
|
122
|
+
variant: :invalid,
|
|
123
|
+
version_id: "v1"
|
|
124
|
+
)
|
|
125
|
+
end.to raise_error(DecisionAgent::ValidationError, /Variant must be :champion or :challenger/)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "raises error if confidence is negative" do
|
|
129
|
+
expect do
|
|
130
|
+
described_class.new(
|
|
131
|
+
ab_test_id: "test_1",
|
|
132
|
+
variant: :champion,
|
|
133
|
+
version_id: "v1",
|
|
134
|
+
confidence: -0.1
|
|
135
|
+
)
|
|
136
|
+
end.to raise_error(DecisionAgent::ValidationError, /Confidence must be between 0 and 1/)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "raises error if confidence is greater than 1" do
|
|
140
|
+
expect do
|
|
141
|
+
described_class.new(
|
|
142
|
+
ab_test_id: "test_1",
|
|
143
|
+
variant: :champion,
|
|
144
|
+
version_id: "v1",
|
|
145
|
+
confidence: 1.5
|
|
146
|
+
)
|
|
147
|
+
end.to raise_error(DecisionAgent::ValidationError, /Confidence must be between 0 and 1/)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "accepts confidence of 0" do
|
|
151
|
+
assignment = described_class.new(
|
|
152
|
+
ab_test_id: "test_1",
|
|
153
|
+
variant: :champion,
|
|
154
|
+
version_id: "v1",
|
|
155
|
+
confidence: 0.0
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
expect(assignment.confidence).to eq(0.0)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "accepts confidence of 1" do
|
|
162
|
+
assignment = described_class.new(
|
|
163
|
+
ab_test_id: "test_1",
|
|
164
|
+
variant: :champion,
|
|
165
|
+
version_id: "v1",
|
|
166
|
+
confidence: 1.0
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(assignment.confidence).to eq(1.0)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "accepts challenger variant" do
|
|
173
|
+
assignment = described_class.new(
|
|
174
|
+
ab_test_id: "test_1",
|
|
175
|
+
variant: :challenger,
|
|
176
|
+
version_id: "v2"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(assignment.variant).to eq(:challenger)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "#record_decision" do
|
|
184
|
+
let(:assignment) do
|
|
185
|
+
described_class.new(
|
|
186
|
+
ab_test_id: "test_1",
|
|
187
|
+
variant: :champion,
|
|
188
|
+
version_id: "v1"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "updates decision_result and confidence" do
|
|
193
|
+
assignment.record_decision("approve", 0.95)
|
|
194
|
+
|
|
195
|
+
expect(assignment.decision_result).to eq("approve")
|
|
196
|
+
expect(assignment.confidence).to eq(0.95)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "can update multiple times" do
|
|
200
|
+
assignment.record_decision("approve", 0.95)
|
|
201
|
+
assignment.record_decision("reject", 0.85)
|
|
202
|
+
|
|
203
|
+
expect(assignment.decision_result).to eq("reject")
|
|
204
|
+
expect(assignment.confidence).to eq(0.85)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
describe "#to_h" do
|
|
209
|
+
it "converts assignment to hash with all fields" do
|
|
210
|
+
assignment = described_class.new(
|
|
211
|
+
ab_test_id: "test_1",
|
|
212
|
+
variant: :champion,
|
|
213
|
+
version_id: "v1",
|
|
214
|
+
id: "assign_123",
|
|
215
|
+
user_id: "user_456",
|
|
216
|
+
decision_result: "approve",
|
|
217
|
+
confidence: 0.95,
|
|
218
|
+
context: { region: "us" },
|
|
219
|
+
timestamp: Time.new(2024, 1, 1, 12, 0, 0, "+00:00")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
hash = assignment.to_h
|
|
223
|
+
|
|
224
|
+
expect(hash).to eq({
|
|
225
|
+
id: "assign_123",
|
|
226
|
+
ab_test_id: "test_1",
|
|
227
|
+
user_id: "user_456",
|
|
228
|
+
variant: :champion,
|
|
229
|
+
version_id: "v1",
|
|
230
|
+
timestamp: Time.new(2024, 1, 1, 12, 0, 0, "+00:00"),
|
|
231
|
+
decision_result: "approve",
|
|
232
|
+
confidence: 0.95,
|
|
233
|
+
context: { region: "us" }
|
|
234
|
+
})
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it "includes nil values in hash" do
|
|
238
|
+
assignment = described_class.new(
|
|
239
|
+
ab_test_id: "test_1",
|
|
240
|
+
variant: :champion,
|
|
241
|
+
version_id: "v1"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
hash = assignment.to_h
|
|
245
|
+
|
|
246
|
+
expect(hash[:id]).to be_nil
|
|
247
|
+
expect(hash[:user_id]).to be_nil
|
|
248
|
+
expect(hash[:decision_result]).to be_nil
|
|
249
|
+
expect(hash[:confidence]).to be_nil
|
|
250
|
+
expect(hash[:context]).to eq({})
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -75,6 +75,32 @@ RSpec.describe DecisionAgent::ABTesting::ABTestManager do
|
|
|
75
75
|
|
|
76
76
|
expect(test.traffic_split).to eq({ champion: 70, challenger: 30 })
|
|
77
77
|
end
|
|
78
|
+
|
|
79
|
+
it "accepts start_date and end_date" do
|
|
80
|
+
start_date = Time.now.utc + 3600
|
|
81
|
+
end_date = Time.now.utc + 7200
|
|
82
|
+
test = manager.create_test(
|
|
83
|
+
name: "Scheduled Test",
|
|
84
|
+
champion_version_id: @champion[:id],
|
|
85
|
+
challenger_version_id: @challenger[:id],
|
|
86
|
+
start_date: start_date,
|
|
87
|
+
end_date: end_date
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(test.start_date).to eq(start_date)
|
|
91
|
+
expect(test.end_date).to eq(end_date)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "sets status to running if start_date is in the past" do
|
|
95
|
+
test = manager.create_test(
|
|
96
|
+
name: "Immediate Test",
|
|
97
|
+
champion_version_id: @champion[:id],
|
|
98
|
+
challenger_version_id: @challenger[:id],
|
|
99
|
+
start_date: Time.now.utc - 3600
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(test.status).to eq("running")
|
|
103
|
+
end
|
|
78
104
|
end
|
|
79
105
|
|
|
80
106
|
describe "#get_test" do
|
|
@@ -326,5 +352,261 @@ RSpec.describe DecisionAgent::ABTesting::ABTestManager do
|
|
|
326
352
|
|
|
327
353
|
expect(results[:comparison][:statistical_significance]).to eq("not_significant")
|
|
328
354
|
end
|
|
355
|
+
|
|
356
|
+
it "handles tests with assignments but no decisions" do
|
|
357
|
+
# Create assignments without recording decisions
|
|
358
|
+
10.times do |i|
|
|
359
|
+
manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
results = manager.get_results(test.id)
|
|
363
|
+
|
|
364
|
+
expect(results[:champion][:decisions_recorded]).to eq(0)
|
|
365
|
+
expect(results[:challenger][:decisions_recorded]).to eq(0)
|
|
366
|
+
expect(results[:comparison][:statistical_significance]).to eq("insufficient_data")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "calculates statistical significance with sufficient data" do
|
|
370
|
+
# Create 30+ assignments for each variant to trigger statistical significance
|
|
371
|
+
champion_count = 0
|
|
372
|
+
challenger_count = 0
|
|
373
|
+
|
|
374
|
+
100.times do |i|
|
|
375
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
376
|
+
if assignment[:variant] == :champion
|
|
377
|
+
champion_count += 1
|
|
378
|
+
confidence = 0.7
|
|
379
|
+
else
|
|
380
|
+
challenger_count += 1
|
|
381
|
+
confidence = 0.9
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
manager.record_decision(
|
|
385
|
+
assignment_id: assignment[:assignment_id],
|
|
386
|
+
decision: "approve",
|
|
387
|
+
confidence: confidence
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
results = manager.get_results(test.id)
|
|
392
|
+
|
|
393
|
+
# Should have enough data for statistical significance
|
|
394
|
+
expect(results[:champion][:decisions_recorded]).to be >= 30
|
|
395
|
+
expect(results[:challenger][:decisions_recorded]).to be >= 30
|
|
396
|
+
expect(%w[significant not_significant]).to include(results[:comparison][:statistical_significance])
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it "calculates different confidence levels based on t-statistic" do
|
|
400
|
+
# Create data that will result in different t-statistic values
|
|
401
|
+
# High t-statistic (> 2.576) should give 99% confidence
|
|
402
|
+
50.times do |i|
|
|
403
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
404
|
+
# Large difference to get high t-statistic
|
|
405
|
+
confidence = assignment[:variant] == :champion ? 0.5 : 0.95
|
|
406
|
+
manager.record_decision(
|
|
407
|
+
assignment_id: assignment[:assignment_id],
|
|
408
|
+
decision: "approve",
|
|
409
|
+
confidence: confidence
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
results = manager.get_results(test.id)
|
|
414
|
+
expect(results[:comparison][:confidence_level]).to be_a(Numeric)
|
|
415
|
+
expect(results[:comparison][:confidence_level]).to be >= 0.0
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it "determines winner correctly when challenger is better" do
|
|
419
|
+
50.times do |i|
|
|
420
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
421
|
+
confidence = assignment[:variant] == :champion ? 0.6 : 0.9
|
|
422
|
+
manager.record_decision(
|
|
423
|
+
assignment_id: assignment[:assignment_id],
|
|
424
|
+
decision: "approve",
|
|
425
|
+
confidence: confidence
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
results = manager.get_results(test.id)
|
|
430
|
+
expect(%w[champion challenger inconclusive]).to include(results[:comparison][:winner])
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it "generates appropriate recommendations" do
|
|
434
|
+
# Test different improvement scenarios
|
|
435
|
+
50.times do |i|
|
|
436
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
437
|
+
# Challenger significantly better (>5% improvement)
|
|
438
|
+
confidence = assignment[:variant] == :champion ? 0.7 : 0.8
|
|
439
|
+
manager.record_decision(
|
|
440
|
+
assignment_id: assignment[:assignment_id],
|
|
441
|
+
decision: "approve",
|
|
442
|
+
confidence: confidence
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
results = manager.get_results(test.id)
|
|
447
|
+
expect(results[:comparison][:recommendation]).to be_a(String)
|
|
448
|
+
expect(results[:comparison][:recommendation]).not_to be_empty
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it "handles champion better than challenger scenario" do
|
|
452
|
+
50.times do |i|
|
|
453
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
454
|
+
# Champion better
|
|
455
|
+
confidence = assignment[:variant] == :champion ? 0.9 : 0.7
|
|
456
|
+
manager.record_decision(
|
|
457
|
+
assignment_id: assignment[:assignment_id],
|
|
458
|
+
decision: "approve",
|
|
459
|
+
confidence: confidence
|
|
460
|
+
)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
results = manager.get_results(test.id)
|
|
464
|
+
expect(results[:comparison][:improvement_percentage]).to be < 0
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
it "handles similar performance scenario" do
|
|
468
|
+
50.times do |i|
|
|
469
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
470
|
+
# Similar performance
|
|
471
|
+
confidence = assignment[:variant] == :champion ? 0.75 : 0.76
|
|
472
|
+
manager.record_decision(
|
|
473
|
+
assignment_id: assignment[:assignment_id],
|
|
474
|
+
decision: "approve",
|
|
475
|
+
confidence: confidence
|
|
476
|
+
)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
results = manager.get_results(test.id)
|
|
480
|
+
expect(results[:comparison][:improvement_percentage]).to be_between(-5, 5)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
describe "#list_tests" do
|
|
485
|
+
before do
|
|
486
|
+
manager.create_test(
|
|
487
|
+
name: "Test 1",
|
|
488
|
+
champion_version_id: @champion[:id],
|
|
489
|
+
challenger_version_id: @challenger[:id]
|
|
490
|
+
)
|
|
491
|
+
manager.create_test(
|
|
492
|
+
name: "Test 2",
|
|
493
|
+
champion_version_id: @champion[:id],
|
|
494
|
+
challenger_version_id: @challenger[:id]
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it "lists all tests when no filters" do
|
|
499
|
+
tests = manager.list_tests
|
|
500
|
+
expect(tests.size).to be >= 2
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
it "filters by status" do
|
|
504
|
+
tests = manager.list_tests(status: "scheduled")
|
|
505
|
+
expect(tests).to all(have_attributes(status: "scheduled"))
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
it "respects limit parameter" do
|
|
509
|
+
tests = manager.list_tests(limit: 1)
|
|
510
|
+
expect(tests.size).to eq(1)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
describe "#initialize" do
|
|
515
|
+
it "uses default storage adapter when not provided" do
|
|
516
|
+
manager = described_class.new(version_manager: version_manager)
|
|
517
|
+
expect(manager.storage_adapter).to be_a(DecisionAgent::ABTesting::Storage::MemoryAdapter)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
it "uses default version manager when not provided" do
|
|
521
|
+
manager = described_class.new
|
|
522
|
+
expect(manager.version_manager).to be_a(DecisionAgent::Versioning::VersionManager)
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
describe "cache behavior" do
|
|
527
|
+
let(:test) do
|
|
528
|
+
manager.create_test(
|
|
529
|
+
name: "Test",
|
|
530
|
+
champion_version_id: @champion[:id],
|
|
531
|
+
challenger_version_id: @challenger[:id],
|
|
532
|
+
start_date: Time.now.utc + 3600
|
|
533
|
+
)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
it "invalidates cache when test is started" do
|
|
537
|
+
manager.active_tests # Populate cache
|
|
538
|
+
manager.start_test(test.id)
|
|
539
|
+
# Cache should be invalidated, so next call should hit storage
|
|
540
|
+
expect(storage_adapter).to receive(:list_tests).and_call_original
|
|
541
|
+
manager.active_tests
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
it "invalidates cache when test is completed" do
|
|
545
|
+
manager.active_tests # Populate cache
|
|
546
|
+
manager.start_test(test.id) # Start the test first so it can be completed
|
|
547
|
+
manager.complete_test(test.id)
|
|
548
|
+
expect(storage_adapter).to receive(:list_tests).and_call_original
|
|
549
|
+
manager.active_tests
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it "invalidates cache when test is cancelled" do
|
|
553
|
+
manager.active_tests # Populate cache
|
|
554
|
+
manager.cancel_test(test.id)
|
|
555
|
+
expect(storage_adapter).to receive(:list_tests).and_call_original
|
|
556
|
+
manager.active_tests
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
it "cache expires after 60 seconds" do
|
|
560
|
+
manager.active_tests # Populate cache
|
|
561
|
+
# Simulate time passing
|
|
562
|
+
allow(Time).to receive(:now).and_return(Time.now.utc + 61)
|
|
563
|
+
expect(storage_adapter).to receive(:list_tests).and_call_original
|
|
564
|
+
manager.active_tests
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
describe "#get_results edge cases" do
|
|
569
|
+
let(:test) do
|
|
570
|
+
test = manager.create_test(
|
|
571
|
+
name: "Test",
|
|
572
|
+
champion_version_id: @champion[:id],
|
|
573
|
+
challenger_version_id: @challenger[:id],
|
|
574
|
+
start_date: Time.now.utc + 3600
|
|
575
|
+
)
|
|
576
|
+
manager.start_test(test.id)
|
|
577
|
+
test
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
it "handles assignments with different decision results" do
|
|
581
|
+
20.times do |i|
|
|
582
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
583
|
+
decision = i.even? ? "approve" : "reject"
|
|
584
|
+
manager.record_decision(
|
|
585
|
+
assignment_id: assignment[:assignment_id],
|
|
586
|
+
decision: decision,
|
|
587
|
+
confidence: 0.8
|
|
588
|
+
)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
results = manager.get_results(test.id)
|
|
592
|
+
expect(results[:champion][:decision_distribution]).to be_a(Hash)
|
|
593
|
+
expect(results[:challenger][:decision_distribution]).to be_a(Hash)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
it "calculates min and max confidence correctly" do
|
|
597
|
+
20.times do |i|
|
|
598
|
+
assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
|
|
599
|
+
confidence = 0.5 + (i * 0.02) # Range from 0.5 to 0.88
|
|
600
|
+
manager.record_decision(
|
|
601
|
+
assignment_id: assignment[:assignment_id],
|
|
602
|
+
decision: "approve",
|
|
603
|
+
confidence: confidence
|
|
604
|
+
)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
results = manager.get_results(test.id)
|
|
608
|
+
expect(results[:champion][:min_confidence]).to be_a(Numeric).or be_nil
|
|
609
|
+
expect(results[:champion][:max_confidence]).to be_a(Numeric).or be_nil
|
|
610
|
+
end
|
|
329
611
|
end
|
|
330
612
|
end
|