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,317 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module ABTesting
|
|
5
|
+
# Manages A/B tests and provides high-level orchestration
|
|
6
|
+
class ABTestManager
|
|
7
|
+
include MonitorMixin
|
|
8
|
+
|
|
9
|
+
attr_reader :storage_adapter, :version_manager
|
|
10
|
+
|
|
11
|
+
# @param storage_adapter [Storage::Adapter] Storage adapter for persistence
|
|
12
|
+
# @param version_manager [Versioning::VersionManager] Version manager for rule versions
|
|
13
|
+
def initialize(storage_adapter: nil, version_manager: nil)
|
|
14
|
+
super()
|
|
15
|
+
@storage_adapter = storage_adapter || default_storage_adapter
|
|
16
|
+
@version_manager = version_manager || Versioning::VersionManager.new
|
|
17
|
+
@active_tests_cache = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Create a new A/B test
|
|
21
|
+
# @param name [String] Name of the test
|
|
22
|
+
# @param champion_version_id [String, Integer] ID of the champion version
|
|
23
|
+
# @param challenger_version_id [String, Integer] ID of the challenger version
|
|
24
|
+
# @param traffic_split [Hash] Traffic distribution
|
|
25
|
+
# @param start_date [Time, nil] When to start the test
|
|
26
|
+
# @param end_date [Time, nil] When to end the test
|
|
27
|
+
# @return [ABTest] The created test
|
|
28
|
+
def create_test(name:, champion_version_id:, challenger_version_id:, traffic_split: { champion: 90, challenger: 10 }, start_date: nil,
|
|
29
|
+
end_date: nil)
|
|
30
|
+
synchronize do
|
|
31
|
+
# Validate that both versions exist
|
|
32
|
+
validate_version_exists!(champion_version_id, "champion")
|
|
33
|
+
validate_version_exists!(challenger_version_id, "challenger")
|
|
34
|
+
|
|
35
|
+
test = ABTest.new(
|
|
36
|
+
name: name,
|
|
37
|
+
champion_version_id: champion_version_id,
|
|
38
|
+
challenger_version_id: challenger_version_id,
|
|
39
|
+
traffic_split: traffic_split,
|
|
40
|
+
start_date: start_date || Time.now.utc,
|
|
41
|
+
end_date: end_date,
|
|
42
|
+
status: start_date && start_date > Time.now.utc ? "scheduled" : "running"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
saved_test = @storage_adapter.save_test(test)
|
|
46
|
+
invalidate_cache!
|
|
47
|
+
saved_test
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get an A/B test by ID
|
|
52
|
+
# @param test_id [String, Integer] The test ID
|
|
53
|
+
# @return [ABTest, nil] The test or nil if not found
|
|
54
|
+
def get_test(test_id)
|
|
55
|
+
@storage_adapter.get_test(test_id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get all active A/B tests
|
|
59
|
+
# @return [Array<ABTest>] Array of active tests
|
|
60
|
+
def active_tests
|
|
61
|
+
synchronize do
|
|
62
|
+
return @active_tests_cache[:tests] if cache_valid?
|
|
63
|
+
|
|
64
|
+
tests = @storage_adapter.list_tests(status: "running")
|
|
65
|
+
@active_tests_cache = { tests: tests, timestamp: Time.now.utc }
|
|
66
|
+
tests
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Assign a variant for a request
|
|
71
|
+
# @param test_id [String, Integer] The A/B test ID
|
|
72
|
+
# @param user_id [String, nil] Optional user identifier for consistent assignment
|
|
73
|
+
# @return [Hash] Assignment details { test_id:, variant:, version_id: }
|
|
74
|
+
def assign_variant(test_id:, user_id: nil)
|
|
75
|
+
test = get_test(test_id)
|
|
76
|
+
raise TestNotFoundError, "Test not found: #{test_id}" unless test
|
|
77
|
+
|
|
78
|
+
variant = test.assign_variant(user_id: user_id)
|
|
79
|
+
version_id = test.version_for_variant(variant)
|
|
80
|
+
|
|
81
|
+
assignment = ABTestAssignment.new(
|
|
82
|
+
ab_test_id: test_id,
|
|
83
|
+
user_id: user_id,
|
|
84
|
+
variant: variant,
|
|
85
|
+
version_id: version_id
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
saved_assignment = @storage_adapter.save_assignment(assignment)
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
test_id: test_id,
|
|
92
|
+
variant: variant,
|
|
93
|
+
version_id: version_id,
|
|
94
|
+
assignment_id: saved_assignment.id
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Record the decision result for an assignment
|
|
99
|
+
# @param assignment_id [String, Integer] The assignment ID
|
|
100
|
+
# @param decision [String] The decision result
|
|
101
|
+
# @param confidence [Float] The confidence score
|
|
102
|
+
def record_decision(assignment_id:, decision:, confidence:)
|
|
103
|
+
@storage_adapter.update_assignment(assignment_id, decision_result: decision, confidence: confidence)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get results comparison for an A/B test
|
|
107
|
+
# @param test_id [String, Integer] The test ID
|
|
108
|
+
# @return [Hash] Comparison statistics
|
|
109
|
+
def get_results(test_id)
|
|
110
|
+
test = get_test(test_id)
|
|
111
|
+
raise TestNotFoundError, "Test not found: #{test_id}" unless test
|
|
112
|
+
|
|
113
|
+
assignments = @storage_adapter.get_assignments(test_id)
|
|
114
|
+
|
|
115
|
+
champion_assignments = assignments.select { |a| a.variant == :champion }
|
|
116
|
+
challenger_assignments = assignments.select { |a| a.variant == :challenger }
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
test: test.to_h,
|
|
120
|
+
champion: calculate_variant_stats(champion_assignments, "Champion"),
|
|
121
|
+
challenger: calculate_variant_stats(challenger_assignments, "Challenger"),
|
|
122
|
+
comparison: compare_variants(champion_assignments, challenger_assignments),
|
|
123
|
+
total_assignments: assignments.size,
|
|
124
|
+
timestamp: Time.now.utc
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Start a scheduled test
|
|
129
|
+
# @param test_id [String, Integer] The test ID
|
|
130
|
+
def start_test(test_id)
|
|
131
|
+
synchronize do
|
|
132
|
+
test = get_test(test_id)
|
|
133
|
+
raise TestNotFoundError, "Test not found: #{test_id}" unless test
|
|
134
|
+
|
|
135
|
+
test.start!
|
|
136
|
+
@storage_adapter.update_test(test_id, status: "running", start_date: test.start_date)
|
|
137
|
+
invalidate_cache!
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Complete a running test
|
|
142
|
+
# @param test_id [String, Integer] The test ID
|
|
143
|
+
def complete_test(test_id)
|
|
144
|
+
synchronize do
|
|
145
|
+
test = get_test(test_id)
|
|
146
|
+
raise TestNotFoundError, "Test not found: #{test_id}" unless test
|
|
147
|
+
|
|
148
|
+
test.complete!
|
|
149
|
+
@storage_adapter.update_test(test_id, status: "completed", end_date: test.end_date)
|
|
150
|
+
invalidate_cache!
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Cancel a test
|
|
155
|
+
# @param test_id [String, Integer] The test ID
|
|
156
|
+
def cancel_test(test_id)
|
|
157
|
+
synchronize do
|
|
158
|
+
test = get_test(test_id)
|
|
159
|
+
raise TestNotFoundError, "Test not found: #{test_id}" unless test
|
|
160
|
+
|
|
161
|
+
test.cancel!
|
|
162
|
+
@storage_adapter.update_test(test_id, status: "cancelled")
|
|
163
|
+
invalidate_cache!
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# List all tests with optional filtering
|
|
168
|
+
# @param status [String, nil] Filter by status
|
|
169
|
+
# @param limit [Integer, nil] Limit results
|
|
170
|
+
# @return [Array<ABTest>] Array of tests
|
|
171
|
+
def list_tests(status: nil, limit: nil)
|
|
172
|
+
@storage_adapter.list_tests(status: status, limit: limit)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def default_storage_adapter
|
|
178
|
+
# Use in-memory adapter by default
|
|
179
|
+
require_relative "storage/memory_adapter"
|
|
180
|
+
Storage::MemoryAdapter.new
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_version_exists!(version_id, label)
|
|
184
|
+
version = @version_manager.get_version(version_id: version_id)
|
|
185
|
+
return if version
|
|
186
|
+
|
|
187
|
+
raise VersionNotFoundError, "#{label.capitalize} version not found: #{version_id}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def cache_valid?
|
|
191
|
+
return false unless @active_tests_cache[:timestamp]
|
|
192
|
+
|
|
193
|
+
# Cache is valid for 60 seconds
|
|
194
|
+
Time.now.utc - @active_tests_cache[:timestamp] < 60
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def invalidate_cache!
|
|
198
|
+
@active_tests_cache = {}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def calculate_variant_stats(assignments, label)
|
|
202
|
+
with_decisions = assignments.select(&:decision_result)
|
|
203
|
+
|
|
204
|
+
if with_decisions.empty?
|
|
205
|
+
return {
|
|
206
|
+
label: label,
|
|
207
|
+
total_assignments: assignments.size,
|
|
208
|
+
decisions_recorded: 0,
|
|
209
|
+
avg_confidence: nil,
|
|
210
|
+
decision_distribution: {}
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
confidences = with_decisions.map(&:confidence)
|
|
215
|
+
decision_counts = with_decisions.group_by(&:decision_result).transform_values(&:size)
|
|
216
|
+
|
|
217
|
+
{
|
|
218
|
+
label: label,
|
|
219
|
+
total_assignments: assignments.size,
|
|
220
|
+
decisions_recorded: with_decisions.size,
|
|
221
|
+
avg_confidence: (confidences.sum / confidences.size.to_f).round(4),
|
|
222
|
+
min_confidence: confidences.min&.round(4),
|
|
223
|
+
max_confidence: confidences.max&.round(4),
|
|
224
|
+
decision_distribution: decision_counts
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def compare_variants(champion_assignments, challenger_assignments)
|
|
229
|
+
champion_with_decisions = champion_assignments.select(&:decision_result)
|
|
230
|
+
challenger_with_decisions = challenger_assignments.select(&:decision_result)
|
|
231
|
+
|
|
232
|
+
return { statistical_significance: "insufficient_data" } if champion_with_decisions.empty? || challenger_with_decisions.empty?
|
|
233
|
+
|
|
234
|
+
champion_confidences = champion_with_decisions.map(&:confidence)
|
|
235
|
+
challenger_confidences = challenger_with_decisions.map(&:confidence)
|
|
236
|
+
|
|
237
|
+
champion_avg = champion_confidences.sum / champion_confidences.size.to_f
|
|
238
|
+
challenger_avg = challenger_confidences.sum / challenger_confidences.size.to_f
|
|
239
|
+
|
|
240
|
+
improvement = ((challenger_avg - champion_avg) / champion_avg * 100).round(2)
|
|
241
|
+
|
|
242
|
+
# Calculate statistical significance using Welch's t-test approximation
|
|
243
|
+
sig_result = calculate_statistical_significance(champion_confidences, challenger_confidences)
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
champion_avg_confidence: champion_avg.round(4),
|
|
247
|
+
challenger_avg_confidence: challenger_avg.round(4),
|
|
248
|
+
improvement_percentage: improvement,
|
|
249
|
+
winner: determine_winner(champion_avg, challenger_avg, sig_result[:significant]),
|
|
250
|
+
statistical_significance: sig_result[:significant] ? "significant" : "not_significant",
|
|
251
|
+
confidence_level: sig_result[:confidence_level],
|
|
252
|
+
recommendation: generate_recommendation(improvement, sig_result[:significant])
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def calculate_statistical_significance(sample1, sample2)
|
|
257
|
+
n1 = sample1.size
|
|
258
|
+
n2 = sample2.size
|
|
259
|
+
|
|
260
|
+
return { significant: false, confidence_level: 0 } if n1 < 30 || n2 < 30
|
|
261
|
+
|
|
262
|
+
mean1 = sample1.sum / n1.to_f
|
|
263
|
+
mean2 = sample2.sum / n2.to_f
|
|
264
|
+
|
|
265
|
+
var1 = sample1.map { |x| (x - mean1)**2 }.sum / (n1 - 1).to_f
|
|
266
|
+
var2 = sample2.map { |x| (x - mean2)**2 }.sum / (n2 - 1).to_f
|
|
267
|
+
|
|
268
|
+
# Welch's t-statistic
|
|
269
|
+
t_stat = (mean1 - mean2) / Math.sqrt((var1 / n1) + (var2 / n2))
|
|
270
|
+
|
|
271
|
+
# Simplified p-value approximation (for demonstration)
|
|
272
|
+
# In production, use a proper statistical library
|
|
273
|
+
t_stat_abs = t_stat.abs
|
|
274
|
+
|
|
275
|
+
confidence_level = if t_stat_abs > 2.576
|
|
276
|
+
0.99 # 99% confidence
|
|
277
|
+
elsif t_stat_abs > 1.96
|
|
278
|
+
0.95 # 95% confidence
|
|
279
|
+
elsif t_stat_abs > 1.645
|
|
280
|
+
0.90 # 90% confidence
|
|
281
|
+
else
|
|
282
|
+
0.0
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
{
|
|
286
|
+
significant: confidence_level >= 0.95,
|
|
287
|
+
confidence_level: confidence_level,
|
|
288
|
+
t_statistic: t_stat.round(4)
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def determine_winner(champion_avg, challenger_avg, significant)
|
|
293
|
+
return "inconclusive" unless significant
|
|
294
|
+
|
|
295
|
+
challenger_avg > champion_avg ? "challenger" : "champion"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def generate_recommendation(improvement, significant)
|
|
299
|
+
if !significant
|
|
300
|
+
"Continue testing - not enough data for statistical significance"
|
|
301
|
+
elsif improvement > 5
|
|
302
|
+
"Strong evidence to promote challenger"
|
|
303
|
+
elsif improvement.positive?
|
|
304
|
+
"Moderate evidence to promote challenger"
|
|
305
|
+
elsif improvement > -5
|
|
306
|
+
"Results are similar - consider other factors"
|
|
307
|
+
else
|
|
308
|
+
"Keep champion - challenger performs worse"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Custom errors
|
|
314
|
+
class TestNotFoundError < StandardError; end
|
|
315
|
+
class VersionNotFoundError < StandardError; end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module ABTesting
|
|
3
|
+
# Agent wrapper that adds A/B testing capabilities to the standard Agent
|
|
4
|
+
# Automatically handles variant assignment and decision tracking
|
|
5
|
+
class ABTestingAgent
|
|
6
|
+
attr_reader :ab_test_manager, :version_manager
|
|
7
|
+
|
|
8
|
+
# @param ab_test_manager [ABTestManager] The A/B test manager
|
|
9
|
+
# @param version_manager [Versioning::VersionManager] Version manager for rules
|
|
10
|
+
# @param evaluators [Array] Base evaluators (can be overridden by versioned rules)
|
|
11
|
+
# @param scoring_strategy [Scoring::Base] Scoring strategy
|
|
12
|
+
# @param audit_adapter [Audit::Adapter] Audit adapter
|
|
13
|
+
def initialize(
|
|
14
|
+
ab_test_manager:,
|
|
15
|
+
version_manager: nil,
|
|
16
|
+
evaluators: [],
|
|
17
|
+
scoring_strategy: nil,
|
|
18
|
+
audit_adapter: nil
|
|
19
|
+
)
|
|
20
|
+
@ab_test_manager = ab_test_manager
|
|
21
|
+
@version_manager = version_manager || ab_test_manager.version_manager
|
|
22
|
+
@base_evaluators = evaluators
|
|
23
|
+
@scoring_strategy = scoring_strategy
|
|
24
|
+
@audit_adapter = audit_adapter
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Make a decision with A/B testing support
|
|
28
|
+
# @param context [Hash, Context] The decision context
|
|
29
|
+
# @param feedback [Hash] Optional feedback
|
|
30
|
+
# @param ab_test_id [String, Integer, nil] Optional A/B test ID
|
|
31
|
+
# @param user_id [String, nil] Optional user ID for consistent assignment
|
|
32
|
+
# @return [Hash] Decision result with A/B test metadata
|
|
33
|
+
def decide(context:, feedback: {}, ab_test_id: nil, user_id: nil)
|
|
34
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
35
|
+
|
|
36
|
+
# If A/B test is specified, use variant assignment
|
|
37
|
+
if ab_test_id
|
|
38
|
+
decide_with_ab_test(ctx, feedback, ab_test_id, user_id)
|
|
39
|
+
else
|
|
40
|
+
# Standard decision without A/B testing
|
|
41
|
+
agent = build_agent(@base_evaluators)
|
|
42
|
+
decision = agent.decide(context: ctx, feedback: feedback)
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
decision: decision.decision,
|
|
46
|
+
confidence: decision.confidence,
|
|
47
|
+
explanations: decision.explanations,
|
|
48
|
+
evaluations: decision.evaluations,
|
|
49
|
+
ab_test: nil
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get A/B test results
|
|
55
|
+
# @param test_id [String, Integer] The test ID
|
|
56
|
+
# @return [Hash] Test results and statistics
|
|
57
|
+
def get_test_results(test_id)
|
|
58
|
+
@ab_test_manager.get_results(test_id)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# List active A/B tests
|
|
62
|
+
# @return [Array<ABTest>] Active tests
|
|
63
|
+
def active_tests
|
|
64
|
+
@ab_test_manager.active_tests
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def decide_with_ab_test(context, feedback, ab_test_id, user_id)
|
|
70
|
+
# Assign variant
|
|
71
|
+
assignment = @ab_test_manager.assign_variant(test_id: ab_test_id, user_id: user_id)
|
|
72
|
+
|
|
73
|
+
# Get the version for the assigned variant
|
|
74
|
+
version = @version_manager.get_version(version_id: assignment[:version_id])
|
|
75
|
+
raise VersionNotFoundError, "Version not found: #{assignment[:version_id]}" unless version
|
|
76
|
+
|
|
77
|
+
# Build evaluators from the versioned rule content
|
|
78
|
+
evaluators = build_evaluators_from_version(version)
|
|
79
|
+
|
|
80
|
+
# Create agent with version-specific evaluators
|
|
81
|
+
agent = build_agent(evaluators)
|
|
82
|
+
|
|
83
|
+
# Make decision
|
|
84
|
+
decision = agent.decide(context: context, feedback: feedback)
|
|
85
|
+
|
|
86
|
+
# Record the decision result
|
|
87
|
+
@ab_test_manager.record_decision(
|
|
88
|
+
assignment_id: assignment[:assignment_id],
|
|
89
|
+
decision: decision.decision,
|
|
90
|
+
confidence: decision.confidence
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Return decision with A/B test metadata
|
|
94
|
+
{
|
|
95
|
+
decision: decision.decision,
|
|
96
|
+
confidence: decision.confidence,
|
|
97
|
+
explanations: decision.explanations,
|
|
98
|
+
evaluations: decision.evaluations,
|
|
99
|
+
ab_test: {
|
|
100
|
+
test_id: ab_test_id,
|
|
101
|
+
variant: assignment[:variant],
|
|
102
|
+
version_id: assignment[:version_id],
|
|
103
|
+
assignment_id: assignment[:assignment_id]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_agent(evaluators)
|
|
109
|
+
Agent.new(
|
|
110
|
+
evaluators: evaluators.empty? ? @base_evaluators : evaluators,
|
|
111
|
+
scoring_strategy: @scoring_strategy,
|
|
112
|
+
audit_adapter: @audit_adapter
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_evaluators_from_version(version)
|
|
117
|
+
content = version[:content]
|
|
118
|
+
|
|
119
|
+
# If the version content contains evaluator configurations, build them
|
|
120
|
+
# Otherwise, use base evaluators
|
|
121
|
+
if content.is_a?(Hash) && content[:evaluators]
|
|
122
|
+
content[:evaluators].map do |eval_config|
|
|
123
|
+
build_evaluator_from_config(eval_config)
|
|
124
|
+
end
|
|
125
|
+
elsif content.is_a?(Hash) && content[:rules]
|
|
126
|
+
# Build a JsonRuleEvaluator from the full content (ruleset + rules)
|
|
127
|
+
[Evaluators::JsonRuleEvaluator.new(rules_json: content)]
|
|
128
|
+
else
|
|
129
|
+
# Fallback to base evaluators
|
|
130
|
+
@base_evaluators
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_evaluator_from_config(config)
|
|
135
|
+
case config[:type]
|
|
136
|
+
when "json_rule"
|
|
137
|
+
Evaluators::JsonRuleEvaluator.new(
|
|
138
|
+
rules_json: config[:rules]
|
|
139
|
+
)
|
|
140
|
+
when "static"
|
|
141
|
+
Evaluators::StaticEvaluator.new(
|
|
142
|
+
decision: config[:decision],
|
|
143
|
+
weight: config[:weight] || 1.0,
|
|
144
|
+
reason: config[:reason]
|
|
145
|
+
)
|
|
146
|
+
else
|
|
147
|
+
raise "Unknown evaluator type: #{config[:type]}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module ABTesting
|
|
5
|
+
module Storage
|
|
6
|
+
# ActiveRecord storage adapter for A/B tests
|
|
7
|
+
# Requires Rails models: ABTestModel, ABTestAssignmentModel
|
|
8
|
+
class ActiveRecordAdapter < Adapter
|
|
9
|
+
# Check if ActiveRecord models are available
|
|
10
|
+
def self.available?
|
|
11
|
+
defined?(::ABTestModel) && defined?(::ABTestAssignmentModel)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
super
|
|
16
|
+
raise "ActiveRecord models not available. Run the generator to create them." unless self.class.available?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save_test(test)
|
|
20
|
+
record = ::ABTestModel.create!(
|
|
21
|
+
name: test.name,
|
|
22
|
+
champion_version_id: test.champion_version_id,
|
|
23
|
+
challenger_version_id: test.challenger_version_id,
|
|
24
|
+
traffic_split: test.traffic_split,
|
|
25
|
+
start_date: test.start_date,
|
|
26
|
+
end_date: test.end_date,
|
|
27
|
+
status: test.status
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
to_ab_test(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get_test(test_id)
|
|
34
|
+
record = ::ABTestModel.find_by(id: test_id)
|
|
35
|
+
record ? to_ab_test(record) : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update_test(test_id, attributes)
|
|
39
|
+
record = ::ABTestModel.find(test_id)
|
|
40
|
+
record.update!(attributes)
|
|
41
|
+
to_ab_test(record)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list_tests(status: nil, limit: nil)
|
|
45
|
+
query = ::ABTestModel.order(created_at: :desc)
|
|
46
|
+
query = query.where(status: status) if status
|
|
47
|
+
query = query.limit(limit) if limit
|
|
48
|
+
|
|
49
|
+
query.map { |record| to_ab_test(record) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def save_assignment(assignment)
|
|
53
|
+
record = ::ABTestAssignmentModel.create!(
|
|
54
|
+
ab_test_id: assignment.ab_test_id,
|
|
55
|
+
user_id: assignment.user_id,
|
|
56
|
+
variant: assignment.variant.to_s,
|
|
57
|
+
version_id: assignment.version_id,
|
|
58
|
+
decision_result: assignment.decision_result,
|
|
59
|
+
confidence: assignment.confidence,
|
|
60
|
+
context: assignment.context,
|
|
61
|
+
timestamp: assignment.timestamp
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
to_assignment(record)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update_assignment(assignment_id, attributes)
|
|
68
|
+
record = ::ABTestAssignmentModel.find(assignment_id)
|
|
69
|
+
record.update!(attributes)
|
|
70
|
+
to_assignment(record)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_assignments(test_id)
|
|
74
|
+
::ABTestAssignmentModel
|
|
75
|
+
.where(ab_test_id: test_id)
|
|
76
|
+
.order(timestamp: :desc)
|
|
77
|
+
.map { |record| to_assignment(record) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# rubocop:disable Naming/PredicateMethod
|
|
81
|
+
def delete_test(test_id)
|
|
82
|
+
record = ::ABTestModel.find(test_id)
|
|
83
|
+
::ABTestAssignmentModel.where(ab_test_id: test_id).delete_all
|
|
84
|
+
record.destroy
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
# rubocop:enable Naming/PredicateMethod
|
|
88
|
+
|
|
89
|
+
# Get statistics from database
|
|
90
|
+
def get_test_statistics(test_id)
|
|
91
|
+
assignments = ::ABTestAssignmentModel.where(ab_test_id: test_id)
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
total_assignments: assignments.count,
|
|
95
|
+
champion_count: assignments.where(variant: "champion").count,
|
|
96
|
+
challenger_count: assignments.where(variant: "challenger").count,
|
|
97
|
+
with_decisions: assignments.where.not(decision_result: nil).count,
|
|
98
|
+
avg_confidence: assignments.where.not(confidence: nil).average(:confidence)&.to_f
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def to_ab_test(record)
|
|
105
|
+
ABTest.new(
|
|
106
|
+
id: record.id,
|
|
107
|
+
name: record.name,
|
|
108
|
+
champion_version_id: record.champion_version_id,
|
|
109
|
+
challenger_version_id: record.challenger_version_id,
|
|
110
|
+
traffic_split: parse_traffic_split(record.traffic_split),
|
|
111
|
+
start_date: record.start_date,
|
|
112
|
+
end_date: record.end_date,
|
|
113
|
+
status: record.status
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_assignment(record)
|
|
118
|
+
ABTestAssignment.new(
|
|
119
|
+
id: record.id,
|
|
120
|
+
ab_test_id: record.ab_test_id,
|
|
121
|
+
user_id: record.user_id,
|
|
122
|
+
variant: record.variant.to_sym,
|
|
123
|
+
version_id: record.version_id,
|
|
124
|
+
timestamp: record.timestamp,
|
|
125
|
+
decision_result: record.decision_result,
|
|
126
|
+
confidence: record.confidence,
|
|
127
|
+
context: parse_context(record.context)
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_traffic_split(value)
|
|
132
|
+
case value
|
|
133
|
+
when Hash
|
|
134
|
+
value.symbolize_keys
|
|
135
|
+
when String
|
|
136
|
+
JSON.parse(value).symbolize_keys
|
|
137
|
+
else
|
|
138
|
+
value
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_context(value)
|
|
143
|
+
case value
|
|
144
|
+
when Hash
|
|
145
|
+
value
|
|
146
|
+
when String
|
|
147
|
+
JSON.parse(value)
|
|
148
|
+
else
|
|
149
|
+
{}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module ABTesting
|
|
3
|
+
module Storage
|
|
4
|
+
# Base adapter interface for A/B test persistence
|
|
5
|
+
class Adapter
|
|
6
|
+
# Save an A/B test
|
|
7
|
+
# @param test [ABTest] The test to save
|
|
8
|
+
# @return [ABTest] The saved test with ID
|
|
9
|
+
def save_test(test)
|
|
10
|
+
raise NotImplementedError, "#{self.class} must implement #save_test"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Get an A/B test by ID
|
|
14
|
+
# @param test_id [String, Integer] The test ID
|
|
15
|
+
# @return [ABTest, nil] The test or nil
|
|
16
|
+
def get_test(test_id)
|
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #get_test"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Update an A/B test
|
|
21
|
+
# @param test_id [String, Integer] The test ID
|
|
22
|
+
# @param attributes [Hash] Attributes to update
|
|
23
|
+
# @return [ABTest] The updated test
|
|
24
|
+
def update_test(test_id, attributes)
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #update_test"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# List A/B tests
|
|
29
|
+
# @param status [String, nil] Filter by status
|
|
30
|
+
# @param limit [Integer, nil] Limit results
|
|
31
|
+
# @return [Array<ABTest>] Array of tests
|
|
32
|
+
def list_tests(status: nil, limit: nil)
|
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #list_tests"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Save an assignment
|
|
37
|
+
# @param assignment [ABTestAssignment] The assignment to save
|
|
38
|
+
# @return [ABTestAssignment] The saved assignment with ID
|
|
39
|
+
def save_assignment(assignment)
|
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #save_assignment"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Update an assignment
|
|
44
|
+
# @param assignment_id [String, Integer] The assignment ID
|
|
45
|
+
# @param attributes [Hash] Attributes to update
|
|
46
|
+
# @return [ABTestAssignment] The updated assignment
|
|
47
|
+
def update_assignment(assignment_id, attributes)
|
|
48
|
+
raise NotImplementedError, "#{self.class} must implement #update_assignment"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get assignments for a test
|
|
52
|
+
# @param test_id [String, Integer] The test ID
|
|
53
|
+
# @return [Array<ABTestAssignment>] Array of assignments
|
|
54
|
+
def get_assignments(test_id)
|
|
55
|
+
raise NotImplementedError, "#{self.class} must implement #get_assignments"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delete a test and its assignments
|
|
59
|
+
# @param test_id [String, Integer] The test ID
|
|
60
|
+
# @return [Boolean] True if deleted
|
|
61
|
+
def delete_test(test_id)
|
|
62
|
+
raise NotImplementedError, "#{self.class} must implement #delete_test"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|