decision_agent 0.1.3 → 0.1.6

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -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,188 @@
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
+ # @param cache_agents [Boolean] Whether to cache agents by version_id (default: true)
14
+ def initialize(
15
+ ab_test_manager:,
16
+ version_manager: nil,
17
+ evaluators: [],
18
+ scoring_strategy: nil,
19
+ audit_adapter: nil,
20
+ cache_agents: true
21
+ )
22
+ @ab_test_manager = ab_test_manager
23
+ @version_manager = version_manager || ab_test_manager.version_manager
24
+ @base_evaluators = evaluators
25
+ @scoring_strategy = scoring_strategy
26
+ @audit_adapter = audit_adapter
27
+ @cache_agents = cache_agents
28
+ @agent_cache = {} # Cache agents by version_id
29
+ @agent_cache_mutex = Mutex.new
30
+ end
31
+
32
+ # Make a decision with A/B testing support
33
+ # @param context [Hash, Context] The decision context
34
+ # @param feedback [Hash] Optional feedback
35
+ # @param ab_test_id [String, Integer, nil] Optional A/B test ID
36
+ # @param user_id [String, nil] Optional user ID for consistent assignment
37
+ # @return [Hash] Decision result with A/B test metadata
38
+ def decide(context:, feedback: {}, ab_test_id: nil, user_id: nil)
39
+ ctx = context.is_a?(Context) ? context : Context.new(context)
40
+
41
+ # If A/B test is specified, use variant assignment
42
+ if ab_test_id
43
+ decide_with_ab_test(ctx, feedback, ab_test_id, user_id)
44
+ else
45
+ # Standard decision without A/B testing
46
+ agent = build_agent(@base_evaluators)
47
+ decision = agent.decide(context: ctx, feedback: feedback)
48
+
49
+ {
50
+ decision: decision.decision,
51
+ confidence: decision.confidence,
52
+ explanations: decision.explanations,
53
+ evaluations: decision.evaluations,
54
+ ab_test: nil
55
+ }
56
+ end
57
+ end
58
+
59
+ # Get A/B test results
60
+ # @param test_id [String, Integer] The test ID
61
+ # @return [Hash] Test results and statistics
62
+ def get_test_results(test_id)
63
+ @ab_test_manager.get_results(test_id)
64
+ end
65
+
66
+ # List active A/B tests
67
+ # @return [Array<ABTest>] Active tests
68
+ def active_tests
69
+ @ab_test_manager.active_tests
70
+ end
71
+
72
+ # Clear the agent cache (useful for testing or when versions are updated)
73
+ def clear_agent_cache!
74
+ @agent_cache_mutex.synchronize { @agent_cache.clear }
75
+ end
76
+
77
+ # Get cache statistics
78
+ def cache_stats
79
+ @agent_cache_mutex.synchronize do
80
+ {
81
+ cached_agents: @agent_cache.size,
82
+ version_ids: @agent_cache.keys
83
+ }
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def decide_with_ab_test(context, feedback, ab_test_id, user_id)
90
+ # Assign variant
91
+ assignment = @ab_test_manager.assign_variant(test_id: ab_test_id, user_id: user_id)
92
+
93
+ # Get or build cached agent for this version
94
+ agent = get_or_build_agent_for_version(assignment[:version_id])
95
+
96
+ # Make decision
97
+ decision = agent.decide(context: context, feedback: feedback)
98
+
99
+ # Record the decision result
100
+ @ab_test_manager.record_decision(
101
+ assignment_id: assignment[:assignment_id],
102
+ decision: decision.decision,
103
+ confidence: decision.confidence
104
+ )
105
+
106
+ # Return decision with A/B test metadata
107
+ {
108
+ decision: decision.decision,
109
+ confidence: decision.confidence,
110
+ explanations: decision.explanations,
111
+ evaluations: decision.evaluations,
112
+ ab_test: {
113
+ test_id: ab_test_id,
114
+ variant: assignment[:variant],
115
+ version_id: assignment[:version_id],
116
+ assignment_id: assignment[:assignment_id]
117
+ }
118
+ }
119
+ end
120
+
121
+ # Get or build agent for a specific version (with caching)
122
+ def get_or_build_agent_for_version(version_id)
123
+ return build_agent_for_version(version_id) unless @cache_agents
124
+
125
+ # Check cache first (fast path without lock for reads)
126
+ cached = @agent_cache[version_id]
127
+ return cached if cached
128
+
129
+ # Cache miss - acquire lock and build
130
+ @agent_cache_mutex.synchronize do
131
+ # Double-check after acquiring lock (another thread may have built it)
132
+ @agent_cache[version_id] ||= build_agent_for_version(version_id)
133
+ end
134
+ end
135
+
136
+ def build_agent_for_version(version_id)
137
+ version = @version_manager.get_version(version_id: version_id)
138
+ raise VersionNotFoundError, "Version not found: #{version_id}" unless version
139
+
140
+ evaluators = build_evaluators_from_version(version)
141
+ build_agent(evaluators)
142
+ end
143
+
144
+ def build_agent(evaluators)
145
+ Agent.new(
146
+ evaluators: evaluators.empty? ? @base_evaluators : evaluators,
147
+ scoring_strategy: @scoring_strategy,
148
+ audit_adapter: @audit_adapter
149
+ )
150
+ end
151
+
152
+ def build_evaluators_from_version(version)
153
+ content = version[:content]
154
+
155
+ # If the version content contains evaluator configurations, build them
156
+ # Otherwise, use base evaluators
157
+ if content.is_a?(Hash) && content[:evaluators]
158
+ content[:evaluators].map do |eval_config|
159
+ build_evaluator_from_config(eval_config)
160
+ end
161
+ elsif content.is_a?(Hash) && content[:rules]
162
+ # Build a JsonRuleEvaluator from the full content (ruleset + rules)
163
+ [Evaluators::JsonRuleEvaluator.new(rules_json: content)]
164
+ else
165
+ # Fallback to base evaluators
166
+ @base_evaluators
167
+ end
168
+ end
169
+
170
+ def build_evaluator_from_config(config)
171
+ case config[:type]
172
+ when "json_rule"
173
+ Evaluators::JsonRuleEvaluator.new(
174
+ rules_json: config[:rules]
175
+ )
176
+ when "static"
177
+ Evaluators::StaticEvaluator.new(
178
+ decision: config[:decision],
179
+ weight: config[:weight] || 1.0,
180
+ reason: config[:reason]
181
+ )
182
+ else
183
+ raise "Unknown evaluator type: #{config[:type]}"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ 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