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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5711ffe8ff5b3de7d808292d9cda3a0d8818ff77684b2da9a51a4794546a18b5
4
- data.tar.gz: f0aba632f6da2eed6b0a07ca3c1ce73404652fbb4b57c8f812b4b9c679cbc1b4
3
+ metadata.gz: fac7efbaf0c8afd76053d21e611eca64f5880d5b6434720fd90ae34549f6244d
4
+ data.tar.gz: ecc22d0a9bb19053fd103b06f2c1be768db432b6d9ed715eac8adddcbd6f68dc
5
5
  SHA512:
6
- metadata.gz: 99b980e8fec99a261db15b0042891675903b83cf238f14604a7222c4efa30be3153c9ce5eda2857fcd1167cd30b4b1fd33090e4ff87d770959a5ba8b5fd7013b
7
- data.tar.gz: be51208704d78c7b76b4dc956384d246e30d64ed8edcfe4d162e5ebd1221365a1e26057628634a56d62f0d04ac45c5482efb7556d614c6f28e6792c515276470
6
+ metadata.gz: b7a0be6ad95512697b6e49a8145668cde64256051db57a39cf925edbd93886d7772cb93026a9af7a9eaea2e9968756ec628ca94d0ebc5a0d1aef0c409eeae97d
7
+ data.tar.gz: 06777a3c4155b1adff552f712a05a0d4bd2694c6f8af631e36611528c050cf1ea711268230dba6d1a64522e127c0c312b7221fd5d36bdc56f69009b0a98c17e4
data/README.md CHANGED
@@ -59,7 +59,11 @@ puts result.explanations # => ["High value transaction"]
59
59
 
60
60
  ## Web UI - Visual Rule Builder
61
61
 
62
- Launch the visual rule builder for non-technical users:
62
+ The DecisionAgent Web UI provides a visual interface for building and testing rules.
63
+
64
+ ### Standalone Usage
65
+
66
+ Launch the visual rule builder:
63
67
 
64
68
  ```bash
65
69
  decision_agent web
@@ -67,45 +71,119 @@ decision_agent web
67
71
 
68
72
  Open [http://localhost:4567](http://localhost:4567) in your browser.
69
73
 
70
- <img width="1602" alt="DecisionAgent Web UI" src="https://github.com/user-attachments/assets/6ee6859c-f9f2-4f93-8bff-923986ccb1bc" />
74
+ ### Mount in Rails
71
75
 
72
- ## Documentation
76
+ Add to your `config/routes.rb`:
77
+
78
+ ```ruby
79
+ require 'decision_agent/web/server'
80
+
81
+ Rails.application.routes.draw do
82
+ # Mount DecisionAgent Web UI
83
+ mount DecisionAgent::Web::Server, at: '/decision_agent'
84
+ end
85
+ ```
86
+
87
+ Then visit `http://localhost:3000/decision_agent` in your browser.
88
+
89
+ **With Authentication:**
90
+
91
+ ```ruby
92
+ authenticate :user, ->(user) { user.admin? } do
93
+ mount DecisionAgent::Web::Server, at: '/decision_agent'
94
+ end
95
+ ```
96
+
97
+ ### Mount in Rack/Sinatra Apps
98
+
99
+ ```ruby
100
+ # config.ru
101
+ require 'decision_agent/web/server'
102
+
103
+ map '/decision_agent' do
104
+ run DecisionAgent::Web::Server
105
+ end
106
+ ```
107
+
108
+ <img width="1622" height="820" alt="Screenshot" src="https://github.com/user-attachments/assets/687e9ff6-669a-40f9-be27-085c614392d4" />
109
+
110
+ See [Web UI Rails Integration Guide](wiki/WEB_UI_RAILS_INTEGRATION.md) for detailed setup instructions.
111
+
112
+ ## Monitoring & Analytics
113
+
114
+ Real-time monitoring, metrics, and alerting for production environments.
115
+
116
+ ### Quick Start
117
+
118
+ ```ruby
119
+ require 'decision_agent/monitoring/metrics_collector'
120
+ require 'decision_agent/monitoring/dashboard_server'
121
+
122
+ # Initialize metrics collection
123
+ collector = DecisionAgent::Monitoring::MetricsCollector.new(window_size: 3600)
124
+
125
+ # Start real-time dashboard
126
+ DecisionAgent::Monitoring::DashboardServer.start!(
127
+ port: 4568,
128
+ metrics_collector: collector
129
+ )
130
+
131
+ # Record decisions
132
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
133
+ result = agent.decide(context: { amount: 1500 })
134
+ collector.record_decision(result, context, duration_ms: 25.5)
135
+ ```
136
+
137
+ Open [http://localhost:4568](http://localhost:4568) for the monitoring dashboard.
138
+
139
+ ### Features
140
+
141
+ - **Real-time Dashboard** - Live metrics with WebSocket updates
142
+ - **Prometheus Export** - Industry-standard metrics format
143
+ - **Intelligent Alerting** - Anomaly detection with customizable rules
144
+ - **Grafana Integration** - Pre-built dashboards and alert rules
145
+ - **Custom KPIs** - Track business-specific metrics
146
+ - **Thread-Safe** - Production-ready performance
147
+
148
+ ### Prometheus & Grafana
73
149
 
150
+ ```yaml
151
+ # prometheus.yml
152
+ scrape_configs:
153
+ - job_name: 'decision_agent'
154
+ static_configs:
155
+ - targets: ['localhost:4568']
156
+ metrics_path: '/metrics'
74
157
  ```
75
- 📚 DecisionAgent Documentation
76
-
77
- ├── 🚀 Getting Started
78
- │ ├── Installation (above)
79
- │ ├── Quick Start (above)
80
- │ └── Examples → examples/README.md
81
-
82
- ├── 📖 Core Documentation
83
- │ ├── Core Concepts → wiki/CORE_CONCEPTS.md
84
- │ ├── JSON Rule DSL → wiki/JSON_RULE_DSL.md
85
- │ ├── API Reference → wiki/API_CONTRACT.md
86
- │ └── Error Handling → wiki/ERROR_HANDLING.md
87
-
88
- ├── 🎯 Advanced Features
89
- │ ├── Versioning System → wiki/VERSIONING.md
90
- │ ├── Decision Replay → wiki/REPLAY.md
91
- │ ├── Advanced Usage → wiki/ADVANCED_USAGE.md
92
- │ └── Custom Components → wiki/ADVANCED_USAGE.md#custom-components
93
-
94
- ├── 🔌 Integration Guides
95
- │ ├── Rails Integration → wiki/INTEGRATION.md#rails
96
- │ ├── Redmine Plugin → wiki/INTEGRATION.md#redmine
97
- │ ├── Standalone Service → wiki/INTEGRATION.md#standalone
98
- │ └── Testing Guide → wiki/TESTING.md
99
-
100
- ├── 🎨 Web UI
101
- │ ├── User Guide → wiki/WEB_UI.md
102
- │ └── Setup Guide → wiki/WEB_UI_SETUP.md
103
-
104
- └── 📝 Reference
105
- ├── Changelog → wiki/CHANGELOG.md
106
- └── Full Wiki Index → wiki/README.md
158
+
159
+ Import the pre-built Grafana dashboard from [grafana/decision_agent_dashboard.json](grafana/decision_agent_dashboard.json).
160
+
161
+ ### Alert Management
162
+
163
+ ```ruby
164
+ alert_manager = DecisionAgent::Monitoring::AlertManager.new(
165
+ metrics_collector: collector
166
+ )
167
+
168
+ # Add alert rules
169
+ alert_manager.add_rule(
170
+ name: 'High Error Rate',
171
+ condition: AlertManager.high_error_rate(threshold: 0.1),
172
+ severity: :critical
173
+ )
174
+
175
+ # Register alert handlers
176
+ alert_manager.add_handler do |alert|
177
+ SlackNotifier.notify("🚨 #{alert[:message]}")
178
+ end
179
+
180
+ # Start monitoring
181
+ alert_manager.start_monitoring(interval: 60)
107
182
  ```
108
183
 
184
+ See [Monitoring & Analytics Guide](wiki/MONITORING_AND_ANALYTICS.md) for complete documentation.
185
+
186
+
109
187
  ## Key Features
110
188
 
111
189
  ### Decision Making
@@ -124,11 +202,21 @@ Open [http://localhost:4567](http://localhost:4567) in your browser.
124
202
  - **JSON Rule DSL** - Non-technical users can write rules
125
203
  - **Visual Rule Builder** - Web UI for rule management
126
204
 
205
+ ### Monitoring & Observability
206
+ - **Real-time Metrics** - Live dashboard with WebSocket updates (<1 second latency)
207
+ - **Prometheus Export** - Industry-standard metrics format at `/metrics` endpoint
208
+ - **Intelligent Alerting** - Anomaly detection with customizable rules and severity levels
209
+ - **Grafana Integration** - Pre-built dashboards and alert configurations in `grafana/` directory
210
+ - **Custom KPIs** - Track business-specific metrics with thread-safe operations
211
+ - **MonitoredAgent** - Drop-in replacement that auto-records all metrics
212
+ - **AlertManager** - Built-in anomaly detection (error rates, latency spikes, low confidence)
213
+
127
214
  ### Production Ready
128
215
  - **Comprehensive Testing** - 90%+ code coverage
129
216
  - **Error Handling** - Clear, actionable error messages
130
217
  - **Versioning** - Full rule version control and rollback
131
218
  - **Performance** - Fast, zero external dependencies
219
+ - **Thread-Safe** - Safe for multi-threaded servers and background jobs
132
220
 
133
221
  ## Examples
134
222
 
@@ -162,6 +250,68 @@ rules = {
162
250
 
163
251
  See [examples/](examples/) for complete working examples.
164
252
 
253
+ ## Thread-Safety Guarantees
254
+
255
+ DecisionAgent is designed to be **thread-safe and FAST** for use in multi-threaded environments:
256
+
257
+ ### Performance
258
+ - **10,000+ decisions/second** throughput
259
+ - **~0.1ms average latency** per decision
260
+ - **Zero performance overhead** from thread-safety
261
+ - **Linear scalability** with thread count
262
+
263
+ ### Safe Concurrent Usage
264
+ - **Agent instances** can be shared across threads safely
265
+ - **Evaluators** are immutable after initialization
266
+ - **Decisions and Evaluations** are deeply frozen
267
+ - **File storage** uses mutex-protected operations
268
+
269
+ ### Best Practices
270
+ ```ruby
271
+ # Safe: Reuse agent instance across threads
272
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
273
+
274
+ Thread.new { agent.decide(context: { user_id: 1 }) }
275
+ Thread.new { agent.decide(context: { user_id: 2 }) }
276
+
277
+ # Safe: Share evaluators across agent instances
278
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
279
+ agent1 = DecisionAgent::Agent.new(evaluators: [evaluator])
280
+ agent2 = DecisionAgent::Agent.new(evaluators: [evaluator])
281
+ ```
282
+
283
+ ### What's Frozen
284
+ All data structures are deeply frozen to prevent mutation:
285
+ - Decision objects (decision, confidence, explanations, evaluations)
286
+ - Evaluation objects (decision, weight, reason, metadata)
287
+ - Context data
288
+ - Rule definitions in evaluators
289
+
290
+ This ensures safe concurrent access without race conditions.
291
+
292
+ ### RFC 8785 Canonical JSON
293
+ DecisionAgent uses **RFC 8785 (JSON Canonicalization Scheme)** for deterministic audit hashing:
294
+
295
+ - **Industry Standard** - Official IETF specification for canonical JSON
296
+ - **Cryptographically Sound** - Ensures deterministic hashing of decision payloads
297
+ - **Reproducible** - Same decision always produces same audit hash
298
+ - **Interoperable** - Compatible with other systems using RFC 8785
299
+
300
+ Every decision includes a deterministic SHA-256 hash in the audit payload, enabling:
301
+ - Tamper detection in audit logs
302
+ - Exact replay verification
303
+ - Regulatory compliance documentation
304
+
305
+ Learn more: [RFC 8785 Specification](https://datatracker.ietf.org/doc/html/rfc8785)
306
+
307
+ ### Performance Benchmark
308
+ Run the included benchmark to verify zero overhead:
309
+ ```bash
310
+ ruby examples/thread_safe_performance.rb
311
+ ```
312
+
313
+ See [THREAD_SAFETY.md](wiki/THREAD_SAFETY.md) for detailed implementation guide and [PERFORMANCE_AND_THREAD_SAFETY.md](wiki/PERFORMANCE_AND_THREAD_SAFETY.md) for detailed performance analysis.
314
+
165
315
  ## When to Use DecisionAgent
166
316
 
167
317
  ✅ **Perfect for:**
@@ -176,6 +326,33 @@ See [examples/](examples/) for complete working examples.
176
326
  - Pure AI/ML with no rules
177
327
  - Single-step validations
178
328
 
329
+ ## Documentation
330
+
331
+ **Getting Started**
332
+ - [Installation](#installation)
333
+ - [Quick Start](#quick-start)
334
+ - [Examples](examples/README.md)
335
+
336
+ **Core Features**
337
+ - [Versioning System](wiki/VERSIONING.md) - Version control for rules
338
+ - [Web UI](wiki/WEB_UI.md) - Visual rule builder
339
+ - [Web UI Setup](wiki/WEB_UI_SETUP.md) - Setup guide
340
+ - [Web UI Rails Integration](wiki/WEB_UI_RAILS_INTEGRATION.md) - Mount in Rails/Rack apps
341
+ - [Monitoring & Analytics](wiki/MONITORING_AND_ANALYTICS.md) - Real-time monitoring, metrics, and alerting
342
+ - [Monitoring Architecture](wiki/MONITORING_ARCHITECTURE.md) - System architecture and design
343
+
344
+ **Performance & Thread-Safety**
345
+ - [Performance & Thread-Safety Summary](wiki/PERFORMANCE_AND_THREAD_SAFETY.md) - Benchmarks and production readiness
346
+ - [Thread-Safety Implementation](wiki/THREAD_SAFETY.md) - Technical implementation guide
347
+
348
+ **Reference**
349
+ - [API Contract](wiki/API_CONTRACT.md) - Full API reference
350
+ - [Changelog](wiki/CHANGELOG.md) - Version history
351
+
352
+ **More Resources**
353
+ - [Wiki Home](wiki/README.md) - Documentation index
354
+ - [GitHub Issues](https://github.com/samaswin87/decision_agent/issues) - Report bugs or request features
355
+
179
356
  ## Contributing
180
357
 
181
358
  1. Fork the repository
data/bin/decision_agent CHANGED
@@ -24,15 +24,13 @@ def print_help
24
24
  decision_agent version
25
25
 
26
26
  For more information, visit:
27
- https://github.com/yourusername/decision_agent
27
+ https://github.com/samaswin87/decision_agent
28
28
  HELP
29
29
  end
30
30
 
31
31
  def start_web_ui(port = 4567)
32
32
  # Ruby 4.0 compatibility: Puma expects Bundler::ORIGINAL_ENV which was removed
33
- if defined?(Bundler) && !Bundler.const_defined?(:ORIGINAL_ENV)
34
- Bundler.const_set(:ORIGINAL_ENV, ENV.to_h.dup)
35
- end
33
+ Bundler.const_set(:ORIGINAL_ENV, ENV.to_h.dup) if defined?(Bundler) && !Bundler.const_defined?(:ORIGINAL_ENV)
36
34
 
37
35
  puts "🎯 Starting DecisionAgent Rule Builder..."
38
36
  puts "📍 Server: http://localhost:#{port}"
@@ -60,19 +58,16 @@ def validate_file(filepath)
60
58
  puts " Version: #{data['version'] || data[:version]}"
61
59
  puts " Ruleset: #{data['ruleset'] || data[:ruleset]}"
62
60
  puts " Rules: #{data['rules']&.size || 0}"
63
-
64
61
  rescue JSON::ParserError => e
65
62
  puts "❌ JSON Parsing Error:"
66
63
  puts " #{e.message}"
67
64
  exit 1
68
-
69
65
  rescue DecisionAgent::InvalidRuleDslError => e
70
66
  puts "❌ Validation Failed:"
71
67
  puts ""
72
68
  puts e.message
73
69
  exit 1
74
-
75
- rescue => e
70
+ rescue StandardError => e
76
71
  puts "❌ Unexpected Error:"
77
72
  puts " #{e.message}"
78
73
  exit 1
@@ -0,0 +1,197 @@
1
+ module DecisionAgent
2
+ module ABTesting
3
+ # Represents an A/B test configuration for comparing rule versions
4
+ class ABTest
5
+ attr_reader :id, :name, :champion_version_id, :challenger_version_id,
6
+ :traffic_split, :start_date, :end_date, :status
7
+
8
+ # @param name [String] Name of the A/B test
9
+ # @param champion_version_id [String, Integer] ID of the current/champion version
10
+ # @param challenger_version_id [String, Integer] ID of the new/challenger version
11
+ # @param options [Hash] Optional configuration
12
+ # @option options [Hash] :traffic_split Traffic distribution (default: { champion: 90, challenger: 10 })
13
+ # @option options [Time] :start_date When the test starts (defaults to now)
14
+ # @option options [Time] :end_date When the test ends (optional)
15
+ # @option options [String] :status Test status: running, completed, cancelled, scheduled
16
+ # @option options [String, Integer] :id Optional ID (for persistence)
17
+ def initialize(
18
+ name:,
19
+ champion_version_id:,
20
+ challenger_version_id:,
21
+ **options
22
+ )
23
+ @id = options[:id]
24
+ @name = name
25
+ @champion_version_id = champion_version_id
26
+ @challenger_version_id = challenger_version_id
27
+ @traffic_split = normalize_traffic_split(options[:traffic_split] || { champion: 90, challenger: 10 })
28
+ @start_date = options[:start_date] || Time.now.utc
29
+ @end_date = options[:end_date]
30
+ @status = options[:status] || "scheduled"
31
+
32
+ validate!
33
+ end
34
+
35
+ # Assign a variant based on traffic split
36
+ # Uses consistent hashing to ensure same user gets same variant
37
+ # @param user_id [String, nil] Optional user identifier for consistent assignment
38
+ # @return [Symbol] :champion or :challenger
39
+ def assign_variant(user_id: nil)
40
+ raise TestNotRunningError, "Test '#{@name}' is not running (status: #{@status})" unless running?
41
+
42
+ if user_id
43
+ # Consistent hashing: same user always gets same variant
44
+ hash_value = Digest::SHA256.hexdigest("#{@id}:#{user_id}").to_i(16)
45
+ percentage = hash_value % 100
46
+ else
47
+ # Random assignment
48
+ percentage = rand(100)
49
+ end
50
+
51
+ percentage < @traffic_split[:champion] ? :champion : :challenger
52
+ end
53
+
54
+ # Get the version ID for the assigned variant
55
+ # @param variant [Symbol] :champion or :challenger
56
+ # @return [String, Integer] The version ID
57
+ def version_for_variant(variant)
58
+ case variant
59
+ when :champion
60
+ @champion_version_id
61
+ when :challenger
62
+ @challenger_version_id
63
+ else
64
+ raise ArgumentError, "Invalid variant: #{variant}. Must be :champion or :challenger"
65
+ end
66
+ end
67
+
68
+ # Check if test is currently running
69
+ # @return [Boolean]
70
+ def running?
71
+ return false unless @status == "running"
72
+ return false if @start_date && Time.now.utc < @start_date
73
+ return false if @end_date && Time.now.utc > @end_date
74
+
75
+ true
76
+ end
77
+
78
+ # Check if test is scheduled to start
79
+ # @return [Boolean]
80
+ def scheduled?
81
+ @status == "scheduled" && @start_date && Time.now.utc < @start_date
82
+ end
83
+
84
+ # Check if test is completed
85
+ # @return [Boolean]
86
+ def completed?
87
+ @status == "completed" || (@end_date && Time.now.utc > @end_date)
88
+ end
89
+
90
+ # Start the test
91
+ def start!
92
+ raise InvalidStatusTransitionError, "Cannot start test from status: #{@status}" unless can_start?
93
+
94
+ @status = "running"
95
+ @start_date = Time.now.utc if @start_date.nil? || @start_date > Time.now.utc
96
+ end
97
+
98
+ # Complete the test
99
+ def complete!
100
+ raise InvalidStatusTransitionError, "Cannot complete test from status: #{@status}" unless can_complete?
101
+
102
+ @status = "completed"
103
+ @end_date = Time.now.utc
104
+ end
105
+
106
+ # Cancel the test
107
+ def cancel!
108
+ raise InvalidStatusTransitionError, "Cannot cancel test from status: #{@status}" if @status == "completed"
109
+
110
+ @status = "cancelled"
111
+ end
112
+
113
+ # Convert to hash representation
114
+ # @return [Hash]
115
+ def to_h
116
+ {
117
+ id: @id,
118
+ name: @name,
119
+ champion_version_id: @champion_version_id,
120
+ challenger_version_id: @challenger_version_id,
121
+ traffic_split: @traffic_split,
122
+ start_date: @start_date,
123
+ end_date: @end_date,
124
+ status: @status
125
+ }
126
+ end
127
+
128
+ private
129
+
130
+ def validate!
131
+ raise ValidationError, "Test name is required" if @name.nil? || @name.strip.empty?
132
+ raise ValidationError, "Champion version ID is required" if @champion_version_id.nil?
133
+ raise ValidationError, "Challenger version ID is required" if @challenger_version_id.nil?
134
+ raise ValidationError, "Champion and challenger must be different versions" if @champion_version_id == @challenger_version_id
135
+
136
+ validate_traffic_split!
137
+ validate_dates!
138
+ validate_status!
139
+ end
140
+
141
+ def validate_traffic_split!
142
+ raise ValidationError, "Traffic split must be a Hash" unless @traffic_split.is_a?(Hash)
143
+
144
+ unless @traffic_split.key?(:champion) && @traffic_split.key?(:challenger)
145
+ raise ValidationError,
146
+ "Traffic split must have :champion and :challenger keys"
147
+ end
148
+
149
+ total = @traffic_split[:champion] + @traffic_split[:challenger]
150
+ raise ValidationError, "Traffic split must sum to 100, got #{total}" unless total == 100
151
+
152
+ raise ValidationError, "Traffic percentages must be non-negative" if @traffic_split.values.any?(&:negative?)
153
+ end
154
+
155
+ def validate_dates!
156
+ return unless @start_date && @end_date
157
+
158
+ raise ValidationError, "End date must be after start date" if @end_date <= @start_date
159
+ end
160
+
161
+ def validate_status!
162
+ valid_statuses = %w[scheduled running completed cancelled]
163
+ return if valid_statuses.include?(@status)
164
+
165
+ raise ValidationError, "Invalid status: #{@status}. Must be one of: #{valid_statuses.join(', ')}"
166
+ end
167
+
168
+ def normalize_traffic_split(split)
169
+ case split
170
+ when Hash
171
+ # Handle both string and symbol keys
172
+ {
173
+ champion: (split[:champion] || split["champion"] || 50).to_i,
174
+ challenger: (split[:challenger] || split["challenger"] || 50).to_i
175
+ }
176
+ when Array
177
+ # Handle array format [90, 10]
178
+ { champion: split[0].to_i, challenger: split[1].to_i }
179
+ else
180
+ raise ValidationError, "Traffic split must be a Hash or Array"
181
+ end
182
+ end
183
+
184
+ def can_start?
185
+ %w[scheduled].include?(@status)
186
+ end
187
+
188
+ def can_complete?
189
+ %w[running].include?(@status)
190
+ end
191
+ end
192
+
193
+ # Custom errors
194
+ class TestNotRunningError < StandardError; end
195
+ class InvalidStatusTransitionError < StandardError; end
196
+ end
197
+ end
@@ -0,0 +1,76 @@
1
+ module DecisionAgent
2
+ module ABTesting
3
+ # Tracks individual assignments of users/requests to A/B test variants
4
+ class ABTestAssignment
5
+ attr_reader :id, :ab_test_id, :user_id, :variant, :version_id,
6
+ :timestamp, :decision_result, :confidence, :context
7
+
8
+ # @param ab_test_id [String, Integer] The A/B test ID
9
+ # @param variant [Symbol] :champion or :challenger
10
+ # @param version_id [String, Integer] The rule version ID that was used
11
+ # @param options [Hash] Optional configuration
12
+ # @option options [String] :user_id User identifier (optional)
13
+ # @option options [Time] :timestamp When the assignment occurred
14
+ # @option options [String] :decision_result The decision outcome
15
+ # @option options [Float] :confidence Confidence score of the decision
16
+ # @option options [Hash] :context Additional context for the decision
17
+ # @option options [String, Integer] :id Optional ID (for persistence)
18
+ def initialize(
19
+ ab_test_id:,
20
+ variant:,
21
+ version_id:,
22
+ **options
23
+ )
24
+ @id = options[:id]
25
+ @ab_test_id = ab_test_id
26
+ @user_id = options[:user_id]
27
+ @variant = variant
28
+ @version_id = version_id
29
+ @timestamp = options[:timestamp] || Time.now.utc
30
+ @decision_result = options[:decision_result]
31
+ @confidence = options[:confidence]
32
+ @context = options[:context] || {}
33
+
34
+ validate!
35
+ end
36
+
37
+ # Update the assignment with decision results
38
+ # @param decision [String] The decision result
39
+ # @param confidence [Float] The confidence score
40
+ def record_decision(decision, confidence)
41
+ @decision_result = decision
42
+ @confidence = confidence
43
+ end
44
+
45
+ # Convert to hash representation
46
+ # @return [Hash]
47
+ def to_h
48
+ {
49
+ id: @id,
50
+ ab_test_id: @ab_test_id,
51
+ user_id: @user_id,
52
+ variant: @variant,
53
+ version_id: @version_id,
54
+ timestamp: @timestamp,
55
+ decision_result: @decision_result,
56
+ confidence: @confidence,
57
+ context: @context
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def validate!
64
+ raise ValidationError, "AB test ID is required" if @ab_test_id.nil?
65
+ raise ValidationError, "Variant is required" if @variant.nil?
66
+ raise ValidationError, "Version ID is required" if @version_id.nil?
67
+
68
+ raise ValidationError, "Variant must be :champion or :challenger, got: #{@variant}" unless %i[champion challenger].include?(@variant)
69
+
70
+ return unless @confidence && (@confidence.negative? || @confidence > 1)
71
+
72
+ raise ValidationError, "Confidence must be between 0 and 1, got: #{@confidence}"
73
+ end
74
+ end
75
+ end
76
+ end