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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fac7efbaf0c8afd76053d21e611eca64f5880d5b6434720fd90ae34549f6244d
|
|
4
|
+
data.tar.gz: ecc22d0a9bb19053fd103b06f2c1be768db432b6d9ed715eac8adddcbd6f68dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
### Mount in Rails
|
|
71
75
|
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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/
|
|
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
|