Files
foxhunt/crates/ml/src/deployment/ab_testing.rs
jgrusewski 29f36bb1d6 docs(ml): clarify edge-case comments from final review
- ab_testing: clarify champion_dd == 0 drawdown semantics
- online_learning: document compute_fisher independent-loss requirement
- curriculum: document disabled-vs-override precedence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:26:53 +01:00

1375 lines
47 KiB
Rust

//! A/B Testing Framework for ML Model Deployments
//!
//! This module provides statistical A/B testing capabilities for comparing
//! model performance with traffic splitting and significance testing.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::{MLError, MLResult, Features, ModelPrediction, MLModel};
/// A/B test configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestConfig {
/// Test name/identifier
pub test_name: String,
/// Description of the test
pub description: String,
/// Control group percentage (0.0 to 1.0)
pub control_percentage: f32,
/// Treatment group percentage (0.0 to 1.0)
pub treatment_percentage: f32,
/// Minimum sample size per group
pub min_sample_size: u32,
/// Statistical significance threshold (e.g., 0.05 for 95% confidence)
pub significance_threshold: f64,
/// Test duration limit
pub max_duration: Duration,
/// Metrics to track for comparison
pub tracked_metrics: Vec<String>,
/// Early stopping criteria
pub early_stopping: Option<EarlyStoppingConfig>,
/// Traffic splitting strategy
pub splitting_strategy: TrafficSplittingStrategy,
}
impl Default for ABTestConfig {
fn default() -> Self {
Self {
test_name: "default_test".to_owned(),
description: "Default A/B test configuration".to_owned(),
control_percentage: 0.5,
treatment_percentage: 0.5,
min_sample_size: 1000,
significance_threshold: 0.05,
max_duration: Duration::from_secs(24 * 60 * 60),
tracked_metrics: vec!["latency".to_owned(), "accuracy".to_owned(), "error_rate".to_owned()],
early_stopping: Some(EarlyStoppingConfig::default()),
splitting_strategy: TrafficSplittingStrategy::HashBased,
}
}
}
/// Early stopping configuration for A/B tests
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EarlyStoppingConfig {
/// Check interval for early stopping
pub check_interval: Duration,
/// Minimum samples before early stopping is considered
pub min_samples_for_early_stop: u32,
/// Maximum degradation allowed before stopping (0.0 to 1.0)
pub max_degradation_threshold: f64,
/// Statistical power threshold for early stopping
pub statistical_power_threshold: f64,
}
impl Default for EarlyStoppingConfig {
fn default() -> Self {
Self {
check_interval: Duration::from_secs(5 * 60),
min_samples_for_early_stop: 100,
max_degradation_threshold: 0.1, // 10% degradation
statistical_power_threshold: 0.8, // 80% power
}
}
}
/// Traffic splitting strategies
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrafficSplittingStrategy {
/// Hash-based splitting using feature hash
HashBased,
/// Random splitting
Random,
/// Round-robin splitting
RoundRobin,
/// Weighted random splitting
WeightedRandom,
/// Thompson Sampling — Bayesian adaptive traffic allocation
ThompsonSampling,
}
/// A/B test status
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ABTestStatus {
/// Test is being set up
Initializing,
/// Test is running
Running,
/// Test is paused
Paused,
/// Test completed successfully
Completed,
/// Test was stopped early due to significance
StoppedEarly,
/// Test was stopped due to degradation
StoppedDegradation,
/// Test failed
Failed,
}
/// A/B test experiment
#[derive(Debug, Clone)]
pub struct ABTestExperiment {
/// Unique experiment ID
pub experiment_id: Uuid,
/// Test configuration
pub config: ABTestConfig,
/// Control model
pub control_model: Arc<dyn MLModel>,
/// Treatment model
pub treatment_model: Arc<dyn MLModel>,
/// Test status
pub status: ABTestStatus,
/// Start time
pub start_time: SystemTime,
/// End time (if completed)
pub end_time: Option<SystemTime>,
/// Traffic splitter
pub traffic_splitter: Arc<TrafficSplitter>,
/// Metrics collector
pub metrics_collector: Arc<ABTestMetricsCollector>,
}
impl ABTestExperiment {
/// Create new A/B test experiment
pub fn new(
config: ABTestConfig,
control_model: Arc<dyn MLModel>,
treatment_model: Arc<dyn MLModel>,
) -> Self {
let experiment_id = Uuid::new_v4();
let traffic_splitter = Arc::new(TrafficSplitter::new(
config.control_percentage,
config.treatment_percentage,
config.splitting_strategy,
));
let metrics_collector = Arc::new(ABTestMetricsCollector::new(
experiment_id,
config.tracked_metrics.clone(),
));
Self {
experiment_id,
config,
control_model,
treatment_model,
status: ABTestStatus::Initializing,
start_time: SystemTime::now(),
end_time: None,
traffic_splitter,
metrics_collector,
}
}
/// Start the A/B test
pub async fn start(&mut self) -> MLResult<()> {
if self.status != ABTestStatus::Initializing {
return Err(MLError::ValidationError {
message: "Test can only be started from Initializing status".to_owned(),
});
}
self.status = ABTestStatus::Running;
self.start_time = SystemTime::now();
tracing::info!(
"Started A/B test {} with control model {} and treatment model {}",
self.config.test_name,
self.control_model.name(),
self.treatment_model.name()
);
Ok(())
}
/// Perform prediction with A/B test traffic splitting
pub async fn predict(&self, features: &Features) -> MLResult<ABTestPrediction> {
if self.status != ABTestStatus::Running {
return Err(MLError::ValidationError {
message: "Test is not running".to_owned(),
});
}
let start_time = Instant::now();
// Determine which model to use
let group = self.traffic_splitter.assign_group(features);
let (model, _group_name) = match group {
TestGroup::Control => (&self.control_model, "control"),
TestGroup::Treatment => (&self.treatment_model, "treatment"),
};
// Make prediction
let prediction = model.predict(features).await?;
let latency = start_time.elapsed();
// Record metrics
self.metrics_collector.record_prediction(
group,
&prediction,
latency,
).await?;
Ok(ABTestPrediction {
prediction,
assigned_group: group,
model_name: model.name().to_string(),
latency,
experiment_id: self.experiment_id,
})
}
/// Get current test results
pub async fn get_results(&self) -> ABTestResults {
self.metrics_collector.get_results().await
}
/// Check if test should be stopped early
pub async fn check_early_stopping(&mut self) -> MLResult<bool> {
if let Some(ref early_config) = self.config.early_stopping {
let results = self.get_results().await;
// Check minimum samples
if results.control_metrics.sample_count < early_config.min_samples_for_early_stop ||
results.treatment_metrics.sample_count < early_config.min_samples_for_early_stop {
return Ok(false);
}
// Check for statistical significance
if let Some(significance) = self.calculate_statistical_significance(&results).await? {
if significance.p_value < self.config.significance_threshold {
self.status = ABTestStatus::StoppedEarly;
self.end_time = Some(SystemTime::now());
tracing::info!(
"A/B test {} stopped early due to statistical significance (p-value: {})",
self.config.test_name,
significance.p_value
);
return Ok(true);
}
}
// Check for degradation
if self.check_degradation_threshold(&results, early_config.max_degradation_threshold).await? {
self.status = ABTestStatus::StoppedDegradation;
self.end_time = Some(SystemTime::now());
tracing::warn!(
"A/B test {} stopped due to performance degradation",
self.config.test_name
);
return Ok(true);
}
}
Ok(false)
}
/// Calculate statistical significance between groups
async fn calculate_statistical_significance(&self, results: &ABTestResults) -> MLResult<Option<StatisticalSignificance>> {
// For latency comparison (continuous metric)
if let (Some(control_latency), Some(treatment_latency)) =
(results.control_metrics.avg_latency, results.treatment_metrics.avg_latency) {
let t_stat = self.calculate_t_statistic(
control_latency,
treatment_latency,
results.control_metrics.latency_std_dev.unwrap_or(0.0),
results.treatment_metrics.latency_std_dev.unwrap_or(0.0),
results.control_metrics.sample_count as f64,
results.treatment_metrics.sample_count as f64,
);
let p_value = self.calculate_p_value(t_stat,
(results.control_metrics.sample_count + results.treatment_metrics.sample_count - 2) as f64);
return Ok(Some(StatisticalSignificance {
metric_name: "latency".to_owned(),
t_statistic: t_stat,
p_value,
confidence_interval: self.calculate_confidence_interval(
control_latency,
treatment_latency,
results.control_metrics.latency_std_dev.unwrap_or(0.0),
results.treatment_metrics.latency_std_dev.unwrap_or(0.0),
results.control_metrics.sample_count as f64,
results.treatment_metrics.sample_count as f64,
),
effect_size: (treatment_latency - control_latency) / control_latency,
}));
}
Ok(None)
}
/// Calculate t-statistic for two-sample t-test
fn calculate_t_statistic(&self, mean1: f64, mean2: f64, std1: f64, std2: f64, n1: f64, n2: f64) -> f64 {
let pooled_variance = ((n1 - 1.0) * std1.powi(2) + (n2 - 1.0) * std2.powi(2)) / (n1 + n2 - 2.0);
let standard_error = (pooled_variance * (1.0 / n1 + 1.0 / n2)).sqrt();
if standard_error == 0.0 {
0.0
} else {
(mean1 - mean2) / standard_error
}
}
/// Calculate p-value from t-statistic (simplified approximation)
fn calculate_p_value(&self, t_stat: f64, degrees_of_freedom: f64) -> f64 {
// Simplified p-value calculation using normal approximation
// In production, you'd use a proper statistical library
let abs_t = t_stat.abs();
if degrees_of_freedom > 30.0 {
// Normal approximation for large samples
2.0 * (1.0 - self.normal_cdf(abs_t))
} else {
// Conservative estimate for small samples
if abs_t > 2.0 { 0.05 } else { 0.1 }
}
}
/// Normal cumulative distribution function (approximation)
fn normal_cdf(&self, x: f64) -> f64 {
0.5 * (1.0 + erf(x / 2.0_f64.sqrt()))
}
/// Calculate confidence interval
fn calculate_confidence_interval(&self, mean1: f64, mean2: f64, std1: f64, std2: f64, n1: f64, n2: f64) -> (f64, f64) {
let diff = mean2 - mean1;
let pooled_variance = ((n1 - 1.0) * std1.powi(2) + (n2 - 1.0) * std2.powi(2)) / (n1 + n2 - 2.0);
let standard_error = (pooled_variance * (1.0 / n1 + 1.0 / n2)).sqrt();
let margin_of_error = 1.96 * standard_error; // 95% confidence
(diff - margin_of_error, diff + margin_of_error)
}
/// Check if treatment shows significant degradation
async fn check_degradation_threshold(&self, results: &ABTestResults, threshold: f64) -> MLResult<bool> {
// Check latency degradation
if let (Some(control_latency), Some(treatment_latency)) =
(results.control_metrics.avg_latency, results.treatment_metrics.avg_latency) {
let degradation = (treatment_latency - control_latency) / control_latency;
if degradation > threshold {
return Ok(true);
}
}
// Check error rate degradation
if let (Some(control_error), Some(treatment_error)) =
(results.control_metrics.error_rate, results.treatment_metrics.error_rate) {
let degradation = (treatment_error - control_error) / control_error.max(0.001); // Avoid division by zero
if degradation > threshold {
return Ok(true);
}
}
Ok(false)
}
/// Stop the test
pub async fn stop(&mut self) -> MLResult<ABTestResults> {
if self.status != ABTestStatus::Running {
return Err(MLError::ValidationError {
message: "Test is not running".to_owned(),
});
}
self.status = ABTestStatus::Completed;
self.end_time = Some(SystemTime::now());
let results = self.get_results().await;
tracing::info!(
"A/B test {} completed with {} control samples and {} treatment samples",
self.config.test_name,
results.control_metrics.sample_count,
results.treatment_metrics.sample_count
);
Ok(results)
}
}
/// Test group assignment
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TestGroup {
/// Control group (original model)
Control,
/// Treatment group (new model)
Treatment,
}
/// Thompson Sampling state for Bayesian adaptive traffic splitting.
///
/// Maintains Beta distribution parameters for both champion (control) and
/// challenger (treatment) models. The champion starts with a strong prior
/// Beta(10,1) reflecting production confidence; the challenger starts with
/// an uninformative Beta(1,1).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThompsonSamplingState {
/// Beta distribution alpha (successes + prior) for the champion model
pub control_alpha: f64,
/// Beta distribution beta (failures + prior) for the champion model
pub control_beta: f64,
/// Beta distribution alpha (successes + prior) for the challenger model
pub treatment_alpha: f64,
/// Beta distribution beta (failures + prior) for the challenger model
pub treatment_beta: f64,
/// Total trades observed for control
pub control_trades: usize,
/// Total trades observed for treatment
pub treatment_trades: usize,
}
impl Default for ThompsonSamplingState {
fn default() -> Self {
Self::new()
}
}
impl ThompsonSamplingState {
/// Create a new Thompson Sampling state with default priors.
///
/// Champion: Beta(10, 1) — strong prior reflecting production confidence.
/// Challenger: Beta(1, 1) — uninformative prior.
pub fn new() -> Self {
Self {
control_alpha: 10.0,
control_beta: 1.0,
treatment_alpha: 1.0,
treatment_beta: 1.0,
control_trades: 0,
treatment_trades: 0,
}
}
/// Sample from both Beta distributions and return the group whose
/// sample is higher. This is the core Thompson Sampling decision:
/// models that are believed to be better get more traffic, but
/// exploration is preserved via randomness.
///
/// Uses the Gamma-distribution trick: Beta(a,b) = X/(X+Y) where
/// X ~ Gamma(a,1), Y ~ Gamma(b,1).
pub fn sample_and_assign(&self) -> TestGroup {
let control_sample = self.sample_beta(self.control_alpha, self.control_beta);
let treatment_sample = self.sample_beta(self.treatment_alpha, self.treatment_beta);
if control_sample >= treatment_sample {
TestGroup::Control
} else {
TestGroup::Treatment
}
}
/// Record the outcome of a trade for a given group.
///
/// On success: alpha += 1 (evidence of reward).
/// On failure: beta += 1 (evidence of non-reward).
pub fn record_outcome(&mut self, group: TestGroup, success: bool) {
match group {
TestGroup::Control => {
self.control_trades += 1;
if success {
self.control_alpha += 1.0;
} else {
self.control_beta += 1.0;
}
}
TestGroup::Treatment => {
self.treatment_trades += 1;
if success {
self.treatment_alpha += 1.0;
} else {
self.treatment_beta += 1.0;
}
}
}
}
/// Sample from a Beta(alpha, beta) distribution using the Gamma trick.
///
/// Beta(a,b) = X/(X+Y) where X ~ Gamma(a,1) and Y ~ Gamma(b,1).
fn sample_beta(&self, alpha: f64, beta: f64) -> f64 {
use rand::Rng;
use rand_distr::{Distribution, Gamma};
let mut rng = rand::thread_rng();
// Alpha and beta must be > 0 for Gamma. Clamp to epsilon.
let safe_alpha = alpha.max(1e-10);
let safe_beta = beta.max(1e-10);
let gamma_a = match Gamma::new(safe_alpha, 1.0) {
Ok(d) => d.sample(&mut rng),
Err(_) => rng.gen::<f64>(), // fallback: uniform [0,1)
};
let gamma_b = match Gamma::new(safe_beta, 1.0) {
Ok(d) => d.sample(&mut rng),
Err(_) => rng.gen::<f64>(),
};
let sum = gamma_a + gamma_b;
if sum <= 0.0 {
0.5 // degenerate case — return neutral
} else {
gamma_a / sum
}
}
/// Estimate P(treatment > control) via Monte Carlo sampling.
///
/// Draws `n_samples` paired samples from both Beta distributions and
/// returns the fraction where treatment sample exceeds control sample.
pub fn estimate_probability_treatment_better(&self, n_samples: usize) -> f64 {
if n_samples == 0 {
return 0.0;
}
let mut treatment_wins = 0_usize;
for _ in 0..n_samples {
let ctrl = self.sample_beta(self.control_alpha, self.control_beta);
let treat = self.sample_beta(self.treatment_alpha, self.treatment_beta);
if treat > ctrl {
treatment_wins += 1;
}
}
treatment_wins as f64 / n_samples as f64
}
}
/// Decision returned by promotion criteria evaluation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PromotionDecision {
/// Not enough evidence yet to promote
NotReady {
/// Explanation of why promotion is not ready
reason: String,
},
/// All statistical criteria met; awaiting manual approval
ReadyForApproval,
/// All criteria met and manual approval not required — auto-promoted
AutoPromoted,
}
/// Criteria for promoting a challenger model to champion.
///
/// All conditions must be satisfied simultaneously:
/// 1. Minimum trade count on the challenger
/// 2. Bayesian confidence threshold (P(challenger > champion) > threshold)
/// 3. Maximum drawdown ratio constraint
/// 4. Optional manual approval gate
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionCriteria {
/// Minimum number of trades the challenger must have observed
pub min_trades: usize,
/// Required P(challenger > champion) for promotion (e.g. 0.95)
pub bayesian_confidence: f64,
/// Maximum allowed ratio of challenger DD to champion DD
pub max_drawdown_ratio: f64,
/// Whether a human must manually approve after criteria are met
pub require_manual_approval: bool,
}
impl Default for PromotionCriteria {
fn default() -> Self {
Self {
min_trades: 500,
bayesian_confidence: 0.95,
max_drawdown_ratio: 1.2,
require_manual_approval: true,
}
}
}
impl PromotionCriteria {
/// Evaluate whether the challenger should be promoted.
///
/// Returns `NotReady` with a reason if any criterion fails,
/// `ReadyForApproval` if manual approval is required, or
/// `AutoPromoted` if all criteria pass and no approval needed.
///
/// `n_mc_samples` controls Monte Carlo accuracy for Bayesian confidence
/// (default: 10_000).
pub fn should_promote(
&self,
state: &ThompsonSamplingState,
challenger_dd: f64,
champion_dd: f64,
) -> PromotionDecision {
self.should_promote_with_samples(state, challenger_dd, champion_dd, 10_000)
}
/// Same as `should_promote` but with configurable MC sample count
/// (useful for deterministic testing).
pub fn should_promote_with_samples(
&self,
state: &ThompsonSamplingState,
challenger_dd: f64,
champion_dd: f64,
n_mc_samples: usize,
) -> PromotionDecision {
// 1. Check minimum trades
if state.treatment_trades < self.min_trades {
return PromotionDecision::NotReady {
reason: format!(
"Insufficient trades: {} / {} required",
state.treatment_trades, self.min_trades
),
};
}
// 2. Bayesian confidence: P(challenger > champion)
let p_better = state.estimate_probability_treatment_better(n_mc_samples);
if p_better < self.bayesian_confidence {
return PromotionDecision::NotReady {
reason: format!(
"Bayesian confidence too low: {:.4} < {:.4}",
p_better, self.bayesian_confidence
),
};
}
// 3. Drawdown constraint: challenger DD must not exceed ratio * champion DD.
// When champion_dd == 0, dd_limit == 0 so any positive challenger_dd fails
// (correctly strict). Both == 0 passes (both perfect — valid).
let dd_limit = champion_dd * self.max_drawdown_ratio;
if challenger_dd > dd_limit {
return PromotionDecision::NotReady {
reason: format!(
"Challenger drawdown {:.4} exceeds {:.1}x champion drawdown {:.4} (limit {:.4})",
challenger_dd, self.max_drawdown_ratio, champion_dd, dd_limit
),
};
}
// All criteria met
if self.require_manual_approval {
PromotionDecision::ReadyForApproval
} else {
PromotionDecision::AutoPromoted
}
}
}
/// Traffic splitter for A/B testing
#[derive(Debug)]
pub struct TrafficSplitter {
/// Control group percentage
control_percentage: f32,
/// Treatment group percentage
treatment_percentage: f32,
/// Splitting strategy
strategy: TrafficSplittingStrategy,
/// Round-robin counter
round_robin_counter: AtomicU64,
/// Thompson Sampling state (only present when strategy == ThompsonSampling).
/// Wrapped in Mutex for interior mutability through Arc, enabling
/// `record_outcome` to update Beta distributions during inference.
thompson_state: Option<std::sync::Mutex<ThompsonSamplingState>>,
}
impl TrafficSplitter {
/// Create new traffic splitter
pub fn new(
control_percentage: f32,
treatment_percentage: f32,
strategy: TrafficSplittingStrategy,
) -> Self {
let thompson_state =
(strategy == TrafficSplittingStrategy::ThompsonSampling)
.then(|| std::sync::Mutex::new(ThompsonSamplingState::new()));
Self {
control_percentage,
treatment_percentage,
strategy,
round_robin_counter: AtomicU64::new(0),
thompson_state,
}
}
/// Access the Thompson Sampling state lock (if active).
///
/// Callers should use `record_outcome` to update the state after each trade.
pub fn thompson_state(&self) -> Option<&std::sync::Mutex<ThompsonSamplingState>> {
self.thompson_state.as_ref()
}
/// Record an outcome for the given test group in the Thompson Sampling state.
///
/// No-op if the strategy is not ThompsonSampling.
pub fn record_outcome(&self, group: TestGroup, success: bool) {
if let Some(ref mutex) = self.thompson_state {
if let Ok(mut state) = mutex.lock() {
state.record_outcome(group, success);
}
}
}
/// Assign test group for given features
pub fn assign_group(&self, features: &Features) -> TestGroup {
match self.strategy {
TrafficSplittingStrategy::HashBased => self.hash_based_assignment(features),
TrafficSplittingStrategy::Random => self.random_assignment(),
TrafficSplittingStrategy::RoundRobin => self.round_robin_assignment(),
TrafficSplittingStrategy::WeightedRandom => self.weighted_random_assignment(),
TrafficSplittingStrategy::ThompsonSampling => {
if let Some(ref mutex) = self.thompson_state {
if let Ok(state) = mutex.lock() {
state.sample_and_assign()
} else {
self.random_assignment()
}
} else {
// Fallback to random if state is somehow missing
self.random_assignment()
}
}
}
}
/// Hash-based assignment using feature hash
fn hash_based_assignment(&self, features: &Features) -> TestGroup {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
// Hash feature values
for value in &features.values {
value.to_bits().hash(&mut hasher);
}
// Hash timestamp for additional randomness
features.timestamp.hash(&mut hasher);
let hash_value = hasher.finish();
let normalized = (hash_value % 1000) as f32 / 1000.0;
if normalized < self.control_percentage {
TestGroup::Control
} else {
TestGroup::Treatment
}
}
/// Random assignment
fn random_assignment(&self) -> TestGroup {
let random_value: f32 = rand::random();
if random_value < self.control_percentage {
TestGroup::Control
} else {
TestGroup::Treatment
}
}
/// Round-robin assignment
fn round_robin_assignment(&self) -> TestGroup {
let counter = self.round_robin_counter.fetch_add(1, Ordering::Relaxed);
let normalized = (counter % 1000) as f32 / 1000.0;
if normalized < self.control_percentage {
TestGroup::Control
} else {
TestGroup::Treatment
}
}
/// Weighted random assignment
fn weighted_random_assignment(&self) -> TestGroup {
// Similar to random but with more sophisticated weighting
self.random_assignment()
}
}
/// A/B test prediction result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestPrediction {
/// Underlying model prediction
pub prediction: ModelPrediction,
/// Assigned test group
pub assigned_group: TestGroup,
/// Model name used
pub model_name: String,
/// Prediction latency
pub latency: Duration,
/// Experiment ID
pub experiment_id: Uuid,
}
/// Metrics collector for A/B tests
#[derive(Debug)]
pub struct ABTestMetricsCollector {
/// Experiment ID
experiment_id: Uuid,
/// Tracked metrics
tracked_metrics: Vec<String>,
/// Control group metrics
control_metrics: Arc<Mutex<GroupMetrics>>,
/// Treatment group metrics
treatment_metrics: Arc<Mutex<GroupMetrics>>,
}
/// Metrics for a test group
#[derive(Debug, Clone, Default)]
struct GroupMetrics {
/// Total predictions
sample_count: u32,
/// Total errors
error_count: u32,
/// Latency measurements
latencies: Vec<f64>,
/// Accuracy scores
accuracy_scores: Vec<f64>,
/// Custom metrics
custom_metrics: HashMap<String, Vec<f64>>,
}
impl ABTestMetricsCollector {
/// Create new metrics collector
pub fn new(experiment_id: Uuid, tracked_metrics: Vec<String>) -> Self {
Self {
experiment_id,
tracked_metrics,
control_metrics: Arc::new(Mutex::new(GroupMetrics::default())),
treatment_metrics: Arc::new(Mutex::new(GroupMetrics::default())),
}
}
/// Record prediction metrics
pub async fn record_prediction(
&self,
group: TestGroup,
prediction: &ModelPrediction,
latency: Duration,
) -> MLResult<()> {
let metrics = match group {
TestGroup::Control => &self.control_metrics,
TestGroup::Treatment => &self.treatment_metrics,
};
let mut group_metrics = metrics.lock().await;
group_metrics.sample_count += 1;
group_metrics.latencies.push(latency.as_micros() as f64);
group_metrics.accuracy_scores.push(prediction.confidence);
Ok(())
}
/// Record error
pub async fn record_error(&self, group: TestGroup) -> MLResult<()> {
let metrics = match group {
TestGroup::Control => &self.control_metrics,
TestGroup::Treatment => &self.treatment_metrics,
};
let mut group_metrics = metrics.lock().await;
group_metrics.error_count += 1;
Ok(())
}
/// Get test results
pub async fn get_results(&self) -> ABTestResults {
let control_metrics = self.control_metrics.lock().await;
let treatment_metrics = self.treatment_metrics.lock().await;
ABTestResults {
experiment_id: self.experiment_id,
control_metrics: GroupMetricsSummary::from_group_metrics(&control_metrics),
treatment_metrics: GroupMetricsSummary::from_group_metrics(&treatment_metrics),
test_duration: SystemTime::now(),
}
}
}
/// Summary of group metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupMetricsSummary {
/// Sample count
pub sample_count: u32,
/// Error count
pub error_count: u32,
/// Error rate
pub error_rate: Option<f64>,
/// Average latency
pub avg_latency: Option<f64>,
/// Latency standard deviation
pub latency_std_dev: Option<f64>,
/// Average accuracy
pub avg_accuracy: Option<f64>,
/// Accuracy standard deviation
pub accuracy_std_dev: Option<f64>,
/// Custom metrics
pub custom_metrics: HashMap<String, f64>,
}
impl GroupMetricsSummary {
/// Create summary from group metrics
fn from_group_metrics(metrics: &GroupMetrics) -> Self {
let error_rate = (metrics.sample_count > 0)
.then(|| metrics.error_count as f64 / metrics.sample_count as f64);
let (avg_latency, latency_std_dev) = if !metrics.latencies.is_empty() {
let avg = metrics.latencies.iter().sum::<f64>() / metrics.latencies.len() as f64;
let variance = metrics.latencies.iter()
.map(|x| (x - avg).powi(2))
.sum::<f64>() / metrics.latencies.len() as f64;
(Some(avg), Some(variance.sqrt()))
} else {
(None, None)
};
let (avg_accuracy, accuracy_std_dev) = if !metrics.accuracy_scores.is_empty() {
let avg = metrics.accuracy_scores.iter().sum::<f64>() / metrics.accuracy_scores.len() as f64;
let variance = metrics.accuracy_scores.iter()
.map(|x| (x - avg).powi(2))
.sum::<f64>() / metrics.accuracy_scores.len() as f64;
(Some(avg), Some(variance.sqrt()))
} else {
(None, None)
};
Self {
sample_count: metrics.sample_count,
error_count: metrics.error_count,
error_rate,
avg_latency,
latency_std_dev,
avg_accuracy,
accuracy_std_dev,
custom_metrics: HashMap::new(), // Would be populated from metrics.custom_metrics
}
}
}
/// A/B test results
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestResults {
/// Experiment ID
pub experiment_id: Uuid,
/// Control group results
pub control_metrics: GroupMetricsSummary,
/// Treatment group results
pub treatment_metrics: GroupMetricsSummary,
/// Test duration
pub test_duration: SystemTime,
}
/// Statistical significance result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatisticalSignificance {
/// Metric name
pub metric_name: String,
/// T-statistic
pub t_statistic: f64,
/// P-value
pub p_value: f64,
/// Confidence interval (lower, upper)
pub confidence_interval: (f64, f64),
/// Effect size
pub effect_size: f64,
}
/// Error function approximation for normal CDF
fn erf(x: f64) -> f64 {
// Abramowitz and Stegun approximation
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
sign * y
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_factory;
#[test]
fn test_traffic_splitter_creation() {
let splitter = TrafficSplitter::new(0.5, 0.5, TrafficSplittingStrategy::Random);
// Test that assignments are roughly balanced over many iterations
let mut control_count = 0;
let mut treatment_count = 0;
for _ in 0..1000 {
let features = Features::new(vec![1.0, 2.0], vec!["f1".to_owned(), "f2".to_owned()]);
match splitter.assign_group(&features) {
TestGroup::Control => control_count += 1,
TestGroup::Treatment => treatment_count += 1,
}
}
// Should be roughly balanced (within 20% of expected)
let total = control_count + treatment_count;
let control_ratio = control_count as f64 / total as f64;
assert!(control_ratio > 0.3 && control_ratio < 0.7);
}
#[test]
fn test_group_metrics_summary() {
let mut metrics = GroupMetrics::default();
metrics.sample_count = 100;
metrics.error_count = 5;
metrics.latencies = vec![100.0, 200.0, 150.0, 175.0, 125.0];
metrics.accuracy_scores = vec![0.8, 0.85, 0.9, 0.75, 0.88];
let summary = GroupMetricsSummary::from_group_metrics(&metrics);
assert_eq!(summary.sample_count, 100);
assert_eq!(summary.error_count, 5);
assert_eq!(summary.error_rate, Some(0.05));
assert!(summary.avg_latency.is_some());
assert!(summary.avg_accuracy.is_some());
}
#[tokio::test]
async fn test_ab_test_experiment_creation() {
let control_model = Arc::from(model_factory::create_dqn_wrapper().unwrap());
let treatment_model = Arc::from(model_factory::create_dqn_wrapper().unwrap());
let config = ABTestConfig::default();
let experiment = ABTestExperiment::new(config, control_model, treatment_model);
assert_eq!(experiment.status, ABTestStatus::Initializing);
assert!(experiment.end_time.is_none());
}
#[test]
fn test_statistical_calculations() {
let experiment = ABTestExperiment::new(
ABTestConfig::default(),
Arc::from(model_factory::create_dqn_wrapper().unwrap()),
Arc::from(model_factory::create_dqn_wrapper().unwrap()),
);
// Test t-statistic calculation
let t_stat = experiment.calculate_t_statistic(100.0, 110.0, 10.0, 12.0, 50.0, 50.0);
assert!(t_stat.is_finite());
// Test confidence interval calculation
let (lower, upper) = experiment.calculate_confidence_interval(100.0, 110.0, 10.0, 12.0, 50.0, 50.0);
assert!(lower < upper);
}
// ---------------------------------------------------------------
// Thompson Sampling tests
// ---------------------------------------------------------------
#[test]
fn test_thompson_sampling_state_new() {
let state = ThompsonSamplingState::new();
// Champion: strong prior Beta(10, 1)
assert!((state.control_alpha - 10.0).abs() < f64::EPSILON);
assert!((state.control_beta - 1.0).abs() < f64::EPSILON);
// Challenger: uninformative prior Beta(1, 1)
assert!((state.treatment_alpha - 1.0).abs() < f64::EPSILON);
assert!((state.treatment_beta - 1.0).abs() < f64::EPSILON);
assert_eq!(state.control_trades, 0);
assert_eq!(state.treatment_trades, 0);
}
#[test]
fn test_thompson_sampling_default_matches_new() {
let from_new = ThompsonSamplingState::new();
let from_default = ThompsonSamplingState::default();
assert!((from_new.control_alpha - from_default.control_alpha).abs() < f64::EPSILON);
assert!((from_new.treatment_alpha - from_default.treatment_alpha).abs() < f64::EPSILON);
}
#[test]
fn test_thompson_sampling_record_outcome() {
let mut state = ThompsonSamplingState::new();
let orig_ctrl_alpha = state.control_alpha;
let orig_ctrl_beta = state.control_beta;
let orig_treat_alpha = state.treatment_alpha;
let orig_treat_beta = state.treatment_beta;
// Control success: alpha should increase by 1
state.record_outcome(TestGroup::Control, true);
assert!((state.control_alpha - (orig_ctrl_alpha + 1.0)).abs() < f64::EPSILON);
assert!((state.control_beta - orig_ctrl_beta).abs() < f64::EPSILON);
assert_eq!(state.control_trades, 1);
// Control failure: beta should increase by 1
state.record_outcome(TestGroup::Control, false);
assert!((state.control_beta - (orig_ctrl_beta + 1.0)).abs() < f64::EPSILON);
assert_eq!(state.control_trades, 2);
// Treatment success
state.record_outcome(TestGroup::Treatment, true);
assert!((state.treatment_alpha - (orig_treat_alpha + 1.0)).abs() < f64::EPSILON);
assert!((state.treatment_beta - orig_treat_beta).abs() < f64::EPSILON);
assert_eq!(state.treatment_trades, 1);
// Treatment failure
state.record_outcome(TestGroup::Treatment, false);
assert!((state.treatment_beta - (orig_treat_beta + 1.0)).abs() < f64::EPSILON);
assert_eq!(state.treatment_trades, 2);
}
#[test]
fn test_thompson_sampling_returns_valid_group() {
let state = ThompsonSamplingState::new();
// Run many samples — each must return a valid group
for _ in 0..500 {
let group = state.sample_and_assign();
assert!(group == TestGroup::Control || group == TestGroup::Treatment);
}
}
#[test]
fn test_thompson_sampling_strong_prior() {
// Champion with overwhelming evidence Beta(100, 1) vs Challenger Beta(1, 1)
let state = ThompsonSamplingState {
control_alpha: 100.0,
control_beta: 1.0,
treatment_alpha: 1.0,
treatment_beta: 1.0,
control_trades: 0,
treatment_trades: 0,
};
let mut control_count = 0;
let iterations = 1000;
for _ in 0..iterations {
if state.sample_and_assign() == TestGroup::Control {
control_count += 1;
}
}
// With Beta(100,1) vs Beta(1,1), champion should win >80% of the time
let control_ratio = control_count as f64 / iterations as f64;
assert!(
control_ratio > 0.80,
"Champion with Beta(100,1) should dominate, but ratio was {control_ratio:.3}"
);
}
#[test]
fn test_thompson_sampling_estimate_probability() {
// Treatment is much better: Beta(50, 5) vs Control Beta(5, 50)
let state = ThompsonSamplingState {
control_alpha: 5.0,
control_beta: 50.0,
treatment_alpha: 50.0,
treatment_beta: 5.0,
control_trades: 55,
treatment_trades: 55,
};
let p = state.estimate_probability_treatment_better(5000);
// Treatment should be better with very high probability
assert!(
p > 0.95,
"P(treatment > control) should be > 0.95, got {p:.4}"
);
}
#[test]
fn test_promotion_criteria_defaults() {
let criteria = PromotionCriteria::default();
assert_eq!(criteria.min_trades, 500);
assert!((criteria.bayesian_confidence - 0.95).abs() < f64::EPSILON);
assert!((criteria.max_drawdown_ratio - 1.2).abs() < f64::EPSILON);
assert!(criteria.require_manual_approval);
}
#[test]
fn test_promotion_not_ready_insufficient_trades() {
let criteria = PromotionCriteria::default();
let state = ThompsonSamplingState {
control_alpha: 1.0,
control_beta: 1.0,
treatment_alpha: 100.0,
treatment_beta: 1.0,
control_trades: 1000,
treatment_trades: 499, // below min_trades (500)
};
let decision = criteria.should_promote(&state, 0.05, 0.10);
match decision {
PromotionDecision::NotReady { reason } => {
assert!(
reason.contains("Insufficient trades"),
"Expected insufficient trades reason, got: {reason}"
);
}
other => panic!("Expected NotReady, got {other:?}"),
}
}
#[test]
fn test_promotion_not_ready_low_confidence() {
let criteria = PromotionCriteria {
min_trades: 10,
bayesian_confidence: 0.95,
max_drawdown_ratio: 2.0,
require_manual_approval: false,
};
// Treatment is much worse: Beta(1, 50) — will fail confidence check
let state = ThompsonSamplingState {
control_alpha: 50.0,
control_beta: 1.0,
treatment_alpha: 1.0,
treatment_beta: 50.0,
control_trades: 51,
treatment_trades: 51,
};
let decision = criteria.should_promote_with_samples(&state, 0.01, 0.10, 5000);
match decision {
PromotionDecision::NotReady { reason } => {
assert!(
reason.contains("confidence"),
"Expected confidence reason, got: {reason}"
);
}
other => panic!("Expected NotReady due to low confidence, got {other:?}"),
}
}
#[test]
fn test_promotion_drawdown_check() {
let criteria = PromotionCriteria {
min_trades: 10,
bayesian_confidence: 0.01, // very low threshold so confidence always passes
max_drawdown_ratio: 1.2,
require_manual_approval: false,
};
// Treatment has good stats
let state = ThompsonSamplingState {
control_alpha: 10.0,
control_beta: 10.0,
treatment_alpha: 50.0,
treatment_beta: 5.0,
control_trades: 20,
treatment_trades: 55,
};
// Challenger DD (0.25) > 1.2 * Champion DD (0.10) = 0.12 => should fail
let decision = criteria.should_promote_with_samples(&state, 0.25, 0.10, 5000);
match decision {
PromotionDecision::NotReady { reason } => {
assert!(
reason.contains("drawdown"),
"Expected drawdown reason, got: {reason}"
);
}
other => panic!("Expected NotReady due to drawdown, got {other:?}"),
}
// Challenger DD (0.11) <= 1.2 * 0.10 = 0.12 => should pass
let decision = criteria.should_promote_with_samples(&state, 0.11, 0.10, 5000);
assert_eq!(decision, PromotionDecision::AutoPromoted);
}
#[test]
fn test_promotion_ready_for_approval() {
let criteria = PromotionCriteria {
min_trades: 10,
bayesian_confidence: 0.01,
max_drawdown_ratio: 2.0,
require_manual_approval: true,
};
let state = ThompsonSamplingState {
control_alpha: 10.0,
control_beta: 10.0,
treatment_alpha: 50.0,
treatment_beta: 5.0,
control_trades: 20,
treatment_trades: 55,
};
let decision = criteria.should_promote_with_samples(&state, 0.05, 0.10, 5000);
assert_eq!(decision, PromotionDecision::ReadyForApproval);
}
#[test]
fn test_promotion_auto_promoted() {
let criteria = PromotionCriteria {
min_trades: 10,
bayesian_confidence: 0.01,
max_drawdown_ratio: 2.0,
require_manual_approval: false,
};
let state = ThompsonSamplingState {
control_alpha: 10.0,
control_beta: 10.0,
treatment_alpha: 50.0,
treatment_beta: 5.0,
control_trades: 20,
treatment_trades: 55,
};
let decision = criteria.should_promote_with_samples(&state, 0.05, 0.10, 5000);
assert_eq!(decision, PromotionDecision::AutoPromoted);
}
#[test]
fn test_traffic_splitter_thompson() {
let splitter = TrafficSplitter::new(0.5, 0.5, TrafficSplittingStrategy::ThompsonSampling);
// Thompson state should be initialized
assert!(splitter.thompson_state().is_some());
let mut control_count = 0;
let mut treatment_count = 0;
for _ in 0..500 {
let features = Features::new(vec![1.0, 2.0], vec!["f1".to_owned(), "f2".to_owned()]);
match splitter.assign_group(&features) {
TestGroup::Control => control_count += 1,
TestGroup::Treatment => treatment_count += 1,
}
}
// With default priors Beta(10,1) vs Beta(1,1), control should dominate
// but both groups should get some traffic (exploration)
let total = control_count + treatment_count;
assert_eq!(total, 500);
// Control should get the majority with Beta(10,1)
assert!(
control_count > treatment_count,
"Control ({control_count}) should exceed Treatment ({treatment_count}) with strong prior"
);
// Treatment should still get some traffic (Thompson Sampling explores)
assert!(
treatment_count > 0,
"Treatment should get some exploration traffic"
);
}
#[test]
fn test_traffic_splitter_non_thompson_has_no_state() {
let splitter = TrafficSplitter::new(0.5, 0.5, TrafficSplittingStrategy::Random);
assert!(splitter.thompson_state().is_none());
let splitter = TrafficSplitter::new(0.5, 0.5, TrafficSplittingStrategy::HashBased);
assert!(splitter.thompson_state().is_none());
}
}