pub mod router;
pub mod experiment;
pub mod metrics;
pub use router::{ABRouter, Assignment, UserContext};
pub use experiment::{Experiment, ExperimentConfig, ExperimentState};
pub use metrics::{ABMetrics, ExperimentMetrics, BranchMetrics};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum ABTestingError {
#[error("Experiment not found: {0}")]
ExperimentNotFound(String),
#[error("Branch not found: {0}")]
BranchNotFound(String),
#[error("Experiment already exists: {0}")]
ExperimentExists(String),
#[error("Invalid assignment rule: {0}")]
InvalidAssignment(String),
#[error("Experiment not active: {0}")]
ExperimentNotActive(String),
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, ABTestingError>;
pub struct ABTesting {
router: ABRouter,
metrics: ABMetrics,
}
impl ABTesting {
pub fn new() -> Self {
Self {
router: ABRouter::new(),
metrics: ABMetrics::new(),
}
}
pub async fn create_experiment(&self, experiment: Experiment) -> Result<Uuid> {
self.router.add_experiment(experiment).await
}
pub async fn get_experiment(&self, name: &str) -> Option<Experiment> {
self.router.get_experiment(name).await
}
pub async fn list_experiments(&self) -> Vec<Experiment> {
self.router.list_experiments().await
}
pub async fn start_experiment(&self, name: &str) -> Result<()> {
self.router.set_experiment_state(name, ExperimentState::Active).await
}
pub async fn pause_experiment(&self, name: &str) -> Result<()> {
self.router.set_experiment_state(name, ExperimentState::Paused).await
}
pub async fn complete_experiment(&self, name: &str, winner: Option<&str>) -> Result<()> {
self.router.complete_experiment(name, winner).await
}
pub async fn delete_experiment(&self, name: &str) -> Result<()> {
self.router.remove_experiment(name).await
}
pub async fn get_branch(&self, experiment: &str, user_context: &UserContext) -> Result<String> {
self.router.route_user(experiment, user_context).await
}
pub async fn get_default_branch(&self, user_context: &UserContext) -> Option<String> {
self.router.route_default(user_context).await
}
pub async fn record_query(
&self,
experiment: &str,
branch: &str,
latency_ms: f64,
success: bool,
) {
self.metrics.record_query(experiment, branch, latency_ms, success).await;
}
pub async fn record_event(
&self,
experiment: &str,
branch: &str,
event_name: &str,
value: f64,
) {
self.metrics.record_event(experiment, branch, event_name, value).await;
}
pub async fn get_metrics(&self, experiment: &str) -> Option<ExperimentMetrics> {
self.metrics.get_experiment_metrics(experiment).await
}
pub async fn stats(&self) -> ABStats {
let experiments = self.router.list_experiments().await;
let active = experiments.iter().filter(|e| e.state == ExperimentState::Active).count();
ABStats {
total_experiments: experiments.len(),
active_experiments: active,
total_branches: experiments.iter().map(|e| e.branches.len()).sum(),
}
}
}
impl Default for ABTesting {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ABStats {
pub total_experiments: usize,
pub active_experiments: usize,
pub total_branches: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
let err = ABTestingError::ExperimentNotFound("test".to_string());
assert!(err.to_string().contains("test"));
}
#[tokio::test]
async fn test_ab_testing_lifecycle() {
let ab = ABTesting::new();
let exp = Experiment::new(
"test_exp",
vec!["control".to_string(), "treatment".to_string()],
);
let id = ab.create_experiment(exp).await.unwrap();
assert!(!id.is_nil());
let exp = ab.get_experiment("test_exp").await.unwrap();
assert_eq!(exp.name, "test_exp");
assert_eq!(exp.state, ExperimentState::Draft);
ab.start_experiment("test_exp").await.unwrap();
let exp = ab.get_experiment("test_exp").await.unwrap();
assert_eq!(exp.state, ExperimentState::Active);
let ctx = UserContext::new("user_123");
let branch = ab.get_branch("test_exp", &ctx).await.unwrap();
assert!(exp.branches.contains(&branch));
ab.record_query("test_exp", &branch, 10.0, true).await;
ab.complete_experiment("test_exp", Some(&branch)).await.unwrap();
let exp = ab.get_experiment("test_exp").await.unwrap();
assert_eq!(exp.state, ExperimentState::Completed);
}
}