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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  4. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  11. data/lib/decision_agent/agent.rb +19 -26
  12. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  13. data/lib/decision_agent/decision.rb +3 -1
  14. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  15. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  16. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  17. data/lib/decision_agent/errors.rb +11 -8
  18. data/lib/decision_agent/evaluation.rb +3 -1
  19. data/lib/decision_agent/evaluation_validator.rb +78 -0
  20. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  21. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  22. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  23. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  24. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  25. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  26. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  27. data/lib/decision_agent/monitoring/metrics_collector.rb +423 -0
  28. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  29. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  30. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  31. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  32. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  33. data/lib/decision_agent/replay/replay.rb +12 -22
  34. data/lib/decision_agent/scoring/base.rb +1 -1
  35. data/lib/decision_agent/scoring/consensus.rb +5 -5
  36. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  37. data/lib/decision_agent/version.rb +1 -1
  38. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  39. data/lib/decision_agent/versioning/adapter.rb +1 -3
  40. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  41. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  42. data/lib/decision_agent/web/public/index.html +1 -1
  43. data/lib/decision_agent/web/server.rb +19 -24
  44. data/lib/decision_agent.rb +14 -0
  45. data/lib/generators/decision_agent/install/install_generator.rb +42 -5
  46. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  47. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  48. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  49. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  50. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  51. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  52. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  53. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  54. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  55. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  56. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  57. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  58. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  59. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  60. data/spec/ab_testing/ab_test_spec.rb +270 -0
  61. data/spec/activerecord_thread_safety_spec.rb +553 -0
  62. data/spec/agent_spec.rb +13 -13
  63. data/spec/api_contract_spec.rb +16 -16
  64. data/spec/audit_adapters_spec.rb +3 -3
  65. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  66. data/spec/dsl_validation_spec.rb +83 -83
  67. data/spec/edge_cases_spec.rb +23 -23
  68. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  69. data/spec/examples.txt +612 -0
  70. data/spec/issue_verification_spec.rb +759 -0
  71. data/spec/json_rule_evaluator_spec.rb +15 -15
  72. data/spec/monitoring/alert_manager_spec.rb +378 -0
  73. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  74. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  75. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  76. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  77. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  78. data/spec/replay_edge_cases_spec.rb +58 -58
  79. data/spec/replay_spec.rb +11 -11
  80. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  81. data/spec/scoring_spec.rb +1 -1
  82. data/spec/spec_helper.rb +9 -0
  83. data/spec/thread_safety_spec.rb +482 -0
  84. data/spec/thread_safety_spec.rb.broken +878 -0
  85. data/spec/versioning_spec.rb +141 -37
  86. data/spec/web_ui_rack_spec.rb +135 -0
  87. 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