#![allow(clippy::excessive_precision)]
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::relevance::LearnedWeights;
pub const DEFAULT_SIGNIFICANCE_LEVEL: f64 = 0.05;
pub const MIN_SAMPLE_SIZE: u64 = 100;
pub const DEFAULT_TRAFFIC_SPLIT: f32 = 0.5;
const CHI_SQUARED_CRITICAL_005: f64 = 3.841;
const CHI_SQUARED_CRITICAL_001: f64 = 6.635;
const CHI_SQUARED_CRITICAL_0001: f64 = 10.828;
const SRM_THRESHOLD: f64 = 0.05;
const MIN_PRACTICAL_EFFECT_SIZE: f64 = 0.1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BayesianAnalysis {
pub prob_treatment_better: f64,
pub prob_control_better: f64,
pub expected_lift: f64,
pub credible_interval: (f64, f64),
pub risk_treatment: f64,
pub risk_control: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EffectSize {
pub cohens_h: f64,
pub interpretation: EffectSizeInterpretation,
pub relative_risk: f64,
pub odds_ratio: f64,
pub nnt: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EffectSizeInterpretation {
Negligible,
Small,
Medium,
Large,
}
impl std::fmt::Display for EffectSizeInterpretation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Negligible => write!(f, "negligible"),
Self::Small => write!(f, "small"),
Self::Medium => write!(f, "medium"),
Self::Large => write!(f, "large"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SRMCheck {
pub srm_detected: bool,
pub expected_ratio: f64,
pub observed_ratio: f64,
pub chi_squared: f64,
pub p_value: f64,
pub severity: SRMSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SRMSeverity {
None,
Warning,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequentialTest {
pub analysis_number: u32,
pub planned_analyses: u32,
pub alpha_spent: f64,
pub current_alpha: f64,
pub can_stop_early: bool,
pub stop_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardrailMetric {
pub name: String,
pub baseline: f64,
pub current: f64,
pub max_degradation: f64,
pub is_breached: bool,
pub degradation_p_value: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BanditState {
pub algorithm: BanditAlgorithm,
pub alphas: Vec<f64>,
pub betas: Vec<f64>,
pub allocation_probs: Vec<f64>,
pub total_reward: f64,
pub estimated_regret: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BanditAlgorithm {
ThompsonSampling,
UCB1,
EpsilonGreedy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ABTestVariant {
Control,
Treatment,
}
impl ABTestVariant {
pub fn as_str(&self) -> &'static str {
match self {
ABTestVariant::Control => "control",
ABTestVariant::Treatment => "treatment",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ABTestStatus {
Draft,
Running,
Paused,
Completed,
Archived,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VariantMetrics {
pub impressions: u64,
pub clicks: u64,
pub positive_feedback: u64,
pub negative_feedback: u64,
pub total_relevance_score: f64,
pub total_latency_us: u64,
pub latency_samples: u64,
pub unique_users: u64,
pub clicked_memory_ids: Vec<Uuid>,
}
impl VariantMetrics {
pub fn ctr(&self) -> f64 {
if self.impressions == 0 {
0.0
} else {
self.clicks as f64 / self.impressions as f64
}
}
pub fn success_rate(&self) -> f64 {
let total = self.positive_feedback + self.negative_feedback;
if total == 0 {
0.0
} else {
self.positive_feedback as f64 / total as f64
}
}
pub fn avg_relevance_score(&self) -> f64 {
if self.impressions == 0 {
0.0
} else {
self.total_relevance_score / self.impressions as f64
}
}
pub fn avg_latency_ms(&self) -> f64 {
if self.latency_samples == 0 {
0.0
} else {
(self.total_latency_us as f64 / self.latency_samples as f64) / 1000.0
}
}
pub fn conversion_rate(&self) -> f64 {
if self.unique_users == 0 {
0.0
} else {
self.clicks as f64 / self.unique_users as f64
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestConfig {
pub id: String,
pub name: String,
pub description: String,
pub control_weights: LearnedWeights,
pub treatment_weights: LearnedWeights,
pub traffic_split: f32,
pub significance_level: f64,
pub min_impressions: u64,
pub max_duration_hours: Option<u64>,
pub tags: Vec<String>,
}
impl Default for ABTestConfig {
fn default() -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: String::new(),
description: String::new(),
control_weights: LearnedWeights::default(),
treatment_weights: LearnedWeights::default(),
traffic_split: DEFAULT_TRAFFIC_SPLIT,
significance_level: DEFAULT_SIGNIFICANCE_LEVEL,
min_impressions: MIN_SAMPLE_SIZE,
max_duration_hours: Some(168), tags: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTest {
#[serde(skip)]
pub id: String,
pub config: ABTestConfig,
pub status: ABTestStatus,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub control_metrics: VariantMetrics,
pub treatment_metrics: VariantMetrics,
#[serde(skip)]
user_assignments: HashMap<String, ABTestVariant>,
}
impl ABTest {
pub fn builder(name: &str) -> ABTestBuilder {
ABTestBuilder::new(name)
}
pub fn from_config(config: ABTestConfig) -> Self {
let id = config.id.clone();
Self {
id,
config,
status: ABTestStatus::Draft,
created_at: Utc::now(),
started_at: None,
completed_at: None,
control_metrics: VariantMetrics::default(),
treatment_metrics: VariantMetrics::default(),
user_assignments: HashMap::new(),
}
}
pub fn get_variant(&mut self, user_id: &str) -> ABTestVariant {
if let Some(&variant) = self.user_assignments.get(user_id) {
return variant;
}
let variant = self.assign_variant(user_id);
match variant {
ABTestVariant::Control => self.control_metrics.unique_users += 1,
ABTestVariant::Treatment => self.treatment_metrics.unique_users += 1,
}
self.user_assignments.insert(user_id.to_string(), variant);
variant
}
fn assign_variant(&self, user_id: &str) -> ABTestVariant {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
user_id.hash(&mut hasher);
self.config.id.hash(&mut hasher);
let hash = hasher.finish();
let bucket = (hash % 10000) as f32 / 10000.0;
if bucket < self.config.traffic_split {
ABTestVariant::Treatment
} else {
ABTestVariant::Control
}
}
pub fn get_weights(&self, variant: ABTestVariant) -> &LearnedWeights {
match variant {
ABTestVariant::Control => &self.config.control_weights,
ABTestVariant::Treatment => &self.config.treatment_weights,
}
}
pub fn get_metrics(&self, variant: ABTestVariant) -> &VariantMetrics {
match variant {
ABTestVariant::Control => &self.control_metrics,
ABTestVariant::Treatment => &self.treatment_metrics,
}
}
fn get_metrics_mut(&mut self, variant: ABTestVariant) -> &mut VariantMetrics {
match variant {
ABTestVariant::Control => &mut self.control_metrics,
ABTestVariant::Treatment => &mut self.treatment_metrics,
}
}
pub fn record_impression(&mut self, user_id: &str, relevance_score: f64, latency_us: u64) {
let variant = self.get_variant(user_id);
let metrics = self.get_metrics_mut(variant);
metrics.impressions += 1;
metrics.total_relevance_score += relevance_score;
metrics.total_latency_us += latency_us;
metrics.latency_samples += 1;
}
pub fn record_click(&mut self, user_id: &str, memory_id: Uuid) {
let variant = self.get_variant(user_id);
let metrics = self.get_metrics_mut(variant);
metrics.clicks += 1;
metrics.clicked_memory_ids.push(memory_id);
}
pub fn record_feedback(&mut self, user_id: &str, positive: bool) {
let variant = self.get_variant(user_id);
let metrics = self.get_metrics_mut(variant);
if positive {
metrics.positive_feedback += 1;
} else {
metrics.negative_feedback += 1;
}
}
pub fn has_sufficient_data(&self) -> bool {
self.control_metrics.impressions >= self.config.min_impressions
&& self.treatment_metrics.impressions >= self.config.min_impressions
}
pub fn is_expired(&self) -> bool {
if let (Some(started), Some(max_hours)) = (self.started_at, self.config.max_duration_hours)
{
let elapsed = Utc::now().signed_duration_since(started);
elapsed > Duration::hours(max_hours as i64)
} else {
false
}
}
pub fn start(&mut self) {
if self.status == ABTestStatus::Draft {
self.status = ABTestStatus::Running;
self.started_at = Some(Utc::now());
}
}
pub fn pause(&mut self) {
if self.status == ABTestStatus::Running {
self.status = ABTestStatus::Paused;
}
}
pub fn resume(&mut self) {
if self.status == ABTestStatus::Paused {
self.status = ABTestStatus::Running;
}
}
pub fn complete(&mut self) {
if self.status == ABTestStatus::Running || self.status == ABTestStatus::Paused {
self.status = ABTestStatus::Completed;
self.completed_at = Some(Utc::now());
}
}
pub fn archive(&mut self) {
self.status = ABTestStatus::Archived;
}
}
pub struct ABTestBuilder {
config: ABTestConfig,
}
impl ABTestBuilder {
pub fn new(name: &str) -> Self {
Self {
config: ABTestConfig {
name: name.to_string(),
..Default::default()
},
}
}
pub fn with_id(mut self, id: &str) -> Self {
self.config.id = id.to_string();
self
}
pub fn with_description(mut self, description: &str) -> Self {
self.config.description = description.to_string();
self
}
pub fn with_control(mut self, weights: LearnedWeights) -> Self {
self.config.control_weights = weights;
self
}
pub fn with_treatment(mut self, weights: LearnedWeights) -> Self {
self.config.treatment_weights = weights;
self
}
pub fn with_traffic_split(mut self, split: f32) -> Self {
self.config.traffic_split = split.clamp(0.0, 1.0);
self
}
pub fn with_significance_level(mut self, level: f64) -> Self {
self.config.significance_level = level.clamp(0.001, 0.1);
self
}
pub fn with_min_impressions(mut self, min: u64) -> Self {
self.config.min_impressions = min.max(MIN_SAMPLE_SIZE);
self
}
pub fn with_max_duration_hours(mut self, hours: u64) -> Self {
self.config.max_duration_hours = Some(hours);
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.config.tags = tags;
self
}
pub fn build(self) -> ABTest {
ABTest::from_config(self.config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestResults {
pub test_id: String,
pub is_significant: bool,
pub confidence_level: f64,
pub chi_squared: f64,
pub p_value: f64,
pub winner: Option<ABTestVariant>,
pub relative_improvement: f64,
pub control_ctr: f64,
pub treatment_ctr: f64,
pub control_success_rate: f64,
pub treatment_success_rate: f64,
pub confidence_interval: (f64, f64),
pub recommendations: Vec<String>,
pub analyzed_at: DateTime<Utc>,
}
pub struct ABTestAnalyzer;
impl ABTestAnalyzer {
pub fn analyze(test: &ABTest) -> ABTestResults {
let control = &test.control_metrics;
let treatment = &test.treatment_metrics;
let control_ctr = control.ctr();
let treatment_ctr = treatment.ctr();
let control_success = control.success_rate();
let treatment_success = treatment.success_rate();
let (chi_squared, p_value) = Self::chi_squared_test(
control.impressions,
control.clicks,
treatment.impressions,
treatment.clicks,
);
let is_significant = p_value < test.config.significance_level
&& control.impressions >= test.config.min_impressions
&& treatment.impressions >= test.config.min_impressions;
let winner = if is_significant {
if treatment_ctr > control_ctr {
Some(ABTestVariant::Treatment)
} else {
Some(ABTestVariant::Control)
}
} else {
None
};
let relative_improvement = if control_ctr > 0.0 {
(treatment_ctr - control_ctr) / control_ctr * 100.0
} else {
0.0
};
let confidence_interval = Self::calculate_confidence_interval(
control.impressions,
control.clicks,
treatment.impressions,
treatment.clicks,
);
let recommendations = Self::generate_recommendations(
test,
is_significant,
winner,
relative_improvement,
&confidence_interval,
);
ABTestResults {
test_id: test.config.id.clone(),
is_significant,
confidence_level: 1.0 - p_value,
chi_squared,
p_value,
winner,
relative_improvement,
control_ctr,
treatment_ctr,
control_success_rate: control_success,
treatment_success_rate: treatment_success,
confidence_interval,
recommendations,
analyzed_at: Utc::now(),
}
}
fn chi_squared_test(n1: u64, x1: u64, n2: u64, x2: u64) -> (f64, f64) {
if n1 == 0 || n2 == 0 {
return (0.0, 1.0);
}
let n1 = n1 as f64;
let x1 = x1 as f64;
let n2 = n2 as f64;
let x2 = x2 as f64;
let p_pooled = (x1 + x2) / (n1 + n2);
let e1_success = n1 * p_pooled;
let e1_failure = n1 * (1.0 - p_pooled);
let e2_success = n2 * p_pooled;
let e2_failure = n2 * (1.0 - p_pooled);
if e1_success < 5.0 || e1_failure < 5.0 || e2_success < 5.0 || e2_failure < 5.0 {
return (0.0, 1.0);
}
let chi_squared = (x1 - e1_success).powi(2) / e1_success
+ ((n1 - x1) - e1_failure).powi(2) / e1_failure
+ (x2 - e2_success).powi(2) / e2_success
+ ((n2 - x2) - e2_failure).powi(2) / e2_failure;
let p_value = Self::chi_squared_p_value(chi_squared);
(chi_squared, p_value)
}
fn chi_squared_p_value(chi_squared: f64) -> f64 {
if chi_squared <= 0.0 {
return 1.0;
}
if chi_squared >= CHI_SQUARED_CRITICAL_0001 {
0.0001
} else if chi_squared >= CHI_SQUARED_CRITICAL_001 {
let ratio = (chi_squared - CHI_SQUARED_CRITICAL_001)
/ (CHI_SQUARED_CRITICAL_0001 - CHI_SQUARED_CRITICAL_001);
(0.001 - ratio * 0.0009).max(0.0)
} else if chi_squared >= CHI_SQUARED_CRITICAL_005 {
let ratio = (chi_squared - CHI_SQUARED_CRITICAL_005)
/ (CHI_SQUARED_CRITICAL_001 - CHI_SQUARED_CRITICAL_005);
(0.05 - ratio * 0.049).max(0.0)
} else {
0.05 + (1.0 - chi_squared / CHI_SQUARED_CRITICAL_005) * 0.95
}
}
fn calculate_confidence_interval(n1: u64, x1: u64, n2: u64, x2: u64) -> (f64, f64) {
if n1 == 0 || n2 == 0 {
return (0.0, 0.0);
}
let p1 = x1 as f64 / n1 as f64;
let p2 = x2 as f64 / n2 as f64;
let diff = p2 - p1;
let se = ((p1 * (1.0 - p1) / n1 as f64) + (p2 * (1.0 - p2) / n2 as f64)).sqrt();
let margin = 1.96 * se;
(diff - margin, diff + margin)
}
fn generate_recommendations(
test: &ABTest,
is_significant: bool,
winner: Option<ABTestVariant>,
relative_improvement: f64,
confidence_interval: &(f64, f64),
) -> Vec<String> {
let mut recommendations = Vec::new();
let total_impressions =
test.control_metrics.impressions + test.treatment_metrics.impressions;
if total_impressions < MIN_SAMPLE_SIZE * 2 {
recommendations.push(format!(
"Insufficient data: {} impressions collected, need at least {} for reliable analysis",
total_impressions,
MIN_SAMPLE_SIZE * 2
));
return recommendations;
}
if is_significant {
match winner {
Some(ABTestVariant::Treatment) => {
recommendations.push(format!(
"Treatment variant wins with {relative_improvement:.1}% relative improvement"
));
recommendations
.push("Recommendation: Deploy treatment weights to production".to_string());
if relative_improvement > 20.0 {
recommendations.push(
"Strong effect detected - consider investigating what drove the improvement".to_string()
);
}
}
Some(ABTestVariant::Control) => {
recommendations.push(format!(
"Control variant wins - treatment performed {:.1}% worse",
-relative_improvement
));
recommendations.push(
"Recommendation: Keep current weights, do not deploy treatment".to_string(),
);
}
None => {}
}
} else {
recommendations.push("No statistically significant difference detected".to_string());
let (ci_low, ci_high) = *confidence_interval;
if ci_low < 0.0 && ci_high > 0.0 {
recommendations.push(
"Confidence interval includes zero - effect may be negligible".to_string(),
);
}
let current_power = Self::estimate_power(test);
if current_power < 0.8 {
let needed = Self::estimate_needed_sample_size(test, 0.8);
recommendations.push(format!(
"Current statistical power: {:.1}%. Need ~{} more impressions per variant for 80% power",
current_power * 100.0,
needed
));
}
}
if test.control_metrics.latency_samples > 0 && test.treatment_metrics.latency_samples > 0 {
let control_latency = test.control_metrics.avg_latency_ms();
let treatment_latency = test.treatment_metrics.avg_latency_ms();
let latency_diff = (treatment_latency - control_latency) / control_latency * 100.0;
if latency_diff.abs() > 20.0 {
recommendations.push(format!(
"Warning: Latency differs by {latency_diff:.1}% between variants - may affect user behavior"
));
}
}
recommendations
}
fn estimate_power(test: &ABTest) -> f64 {
let n1 = test.control_metrics.impressions as f64;
let n2 = test.treatment_metrics.impressions as f64;
let p1 = test.control_metrics.ctr();
let p2 = test.treatment_metrics.ctr();
if n1 == 0.0 || n2 == 0.0 || p1 == 0.0 {
return 0.0;
}
let h = 2.0 * ((p2.sqrt()).asin() - (p1.sqrt()).asin());
let n_eff = 2.0 / (1.0 / n1 + 1.0 / n2);
let z = h * (n_eff / 2.0).sqrt();
let power = 0.5 * (1.0 + Self::erf(z / 2.0_f64.sqrt()));
power.clamp(0.0, 1.0)
}
fn erf(x: f64) -> f64 {
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
}
fn estimate_needed_sample_size(test: &ABTest, target_power: f64) -> u64 {
let p1 = test.control_metrics.ctr();
let p2 = test.treatment_metrics.ctr();
if p1 == 0.0 || p2 == 0.0 || (p2 - p1).abs() < 0.001 {
return 10000; }
let effect = (p2 - p1).abs();
let pooled_p = (p1 + p2) / 2.0;
let pooled_var = pooled_p * (1.0 - pooled_p);
let z_alpha = 1.96;
let z_beta = Self::inverse_normal_cdf(target_power);
let n = 2.0 * pooled_var * (z_alpha + z_beta).powi(2) / effect.powi(2);
n.ceil() as u64
}
fn inverse_normal_cdf(p: f64) -> f64 {
let a = [
-3.969683028665376e+01,
2.209460984245205e+02,
-2.759285104469687e+02,
1.383577518672690e+02,
-3.066479806614716e+01,
2.506628277459239e+00,
];
let b = [
-5.447609879822406e+01,
1.615858368580409e+02,
-1.556989798598866e+02,
6.680131188771972e+01,
-1.328068155288572e+01,
];
let c = [
-7.784894002430293e-03,
-3.223964580411365e-01,
-2.400758277161838e+00,
-2.549732539343734e+00,
4.374664141464968e+00,
2.938163982698783e+00,
];
let d = [
7.784695709041462e-03,
3.224671290700398e-01,
2.445134137142996e+00,
3.754408661907416e+00,
];
let p_low = 0.02425;
let p_high = 1.0 - p_low;
if p < p_low {
let q = (-2.0 * p.ln()).sqrt();
(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
/ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
} else if p <= p_high {
let q = p - 0.5;
let r = q * q;
(((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
/ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
} else {
let q = (-2.0 * (1.0 - p).ln()).sqrt();
-(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
/ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
}
}
pub fn bayesian_analysis(test: &ABTest) -> BayesianAnalysis {
let control = &test.control_metrics;
let treatment = &test.treatment_metrics;
let alpha_c = control.clicks as f64 + 0.5;
let beta_c = (control.impressions as f64 - control.clicks as f64) + 0.5;
let alpha_t = treatment.clicks as f64 + 0.5;
let beta_t = (treatment.impressions as f64 - treatment.clicks as f64) + 0.5;
let n_samples = 10000;
let mut treatment_wins = 0;
let mut lift_sum = 0.0;
let mut lifts = Vec::with_capacity(n_samples);
let mut seed = 12345u64;
let lcg = |s: &mut u64| -> f64 {
*s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
(*s as f64) / (u64::MAX as f64)
};
for _ in 0..n_samples {
let p_c = Self::beta_sample(alpha_c, beta_c, &mut seed, &lcg);
let p_t = Self::beta_sample(alpha_t, beta_t, &mut seed, &lcg);
if p_t > p_c {
treatment_wins += 1;
}
let lift = if p_c > 0.0 { (p_t - p_c) / p_c } else { 0.0 };
lift_sum += lift;
lifts.push(lift);
}
lifts.sort_by(|a, b| a.total_cmp(b));
let prob_treatment_better = treatment_wins as f64 / n_samples as f64;
let expected_lift = lift_sum / n_samples as f64;
let ci_low = lifts[(n_samples as f64 * 0.025) as usize];
let ci_high = lifts[(n_samples as f64 * 0.975) as usize];
let risk_treatment =
lifts.iter().filter(|&&l| l < 0.0).map(|l| -l).sum::<f64>() / n_samples as f64;
let risk_control = lifts.iter().filter(|&&l| l > 0.0).sum::<f64>() / n_samples as f64;
BayesianAnalysis {
prob_treatment_better,
prob_control_better: 1.0 - prob_treatment_better,
expected_lift,
credible_interval: (ci_low, ci_high),
risk_treatment,
risk_control,
}
}
fn beta_sample(alpha: f64, beta: f64, seed: &mut u64, lcg: &impl Fn(&mut u64) -> f64) -> f64 {
let gamma_a = Self::gamma_sample(alpha, seed, lcg);
let gamma_b = Self::gamma_sample(beta, seed, lcg);
let denom = gamma_a + gamma_b;
if denom == 0.0 {
return 0.5; }
gamma_a / denom
}
fn gamma_sample(alpha: f64, seed: &mut u64, lcg: &impl Fn(&mut u64) -> f64) -> f64 {
if alpha < 1.0 {
return Self::gamma_sample(alpha + 1.0, seed, lcg) * lcg(seed).powf(1.0 / alpha);
}
let d = alpha - 1.0 / 3.0;
let c = 1.0 / (9.0 * d).sqrt();
loop {
let x = Self::normal_sample(seed, lcg);
let v = (1.0 + c * x).powi(3);
if v > 0.0 {
let u = lcg(seed);
if u < 1.0 - 0.0331 * x.powi(4) || u.ln() < 0.5 * x.powi(2) + d * (1.0 - v + v.ln())
{
return d * v;
}
}
}
}
fn normal_sample(seed: &mut u64, lcg: &impl Fn(&mut u64) -> f64) -> f64 {
let u1 = lcg(seed).max(f64::MIN_POSITIVE);
let u2 = lcg(seed);
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
pub fn calculate_effect_size(test: &ABTest) -> EffectSize {
let p1 = test.control_metrics.ctr();
let p2 = test.treatment_metrics.ctr();
let phi1 = 2.0 * p1.sqrt().asin();
let phi2 = 2.0 * p2.sqrt().asin();
let cohens_h = (phi2 - phi1).abs();
let interpretation = if cohens_h < 0.2 {
EffectSizeInterpretation::Negligible
} else if cohens_h < 0.5 {
EffectSizeInterpretation::Small
} else if cohens_h < 0.8 {
EffectSizeInterpretation::Medium
} else {
EffectSizeInterpretation::Large
};
let relative_risk = if p1 > 0.0 { p2 / p1 } else { 0.0 };
let odds_c = if p1 < 1.0 {
p1 / (1.0 - p1)
} else {
f64::INFINITY
};
let odds_t = if p2 < 1.0 {
p2 / (1.0 - p2)
} else {
f64::INFINITY
};
let odds_ratio = if odds_c > 0.0 && odds_c.is_finite() {
odds_t / odds_c
} else {
0.0
};
let ard = (p2 - p1).abs(); let nnt = if ard > 0.0 { 1.0 / ard } else { f64::INFINITY };
EffectSize {
cohens_h,
interpretation,
relative_risk,
odds_ratio,
nnt,
}
}
pub fn check_srm(test: &ABTest) -> SRMCheck {
let expected_ratio = test.config.traffic_split as f64;
let total = test.control_metrics.impressions + test.treatment_metrics.impressions;
if total == 0 {
return SRMCheck {
srm_detected: false,
expected_ratio,
observed_ratio: 0.5,
chi_squared: 0.0,
p_value: 1.0,
severity: SRMSeverity::None,
};
}
let observed_ratio = test.treatment_metrics.impressions as f64 / total as f64;
let expected_control = total as f64 * (1.0 - expected_ratio);
let expected_treatment = total as f64 * expected_ratio;
let chi_sq = (test.control_metrics.impressions as f64 - expected_control).powi(2)
/ expected_control
+ (test.treatment_metrics.impressions as f64 - expected_treatment).powi(2)
/ expected_treatment;
let p_value = Self::chi_squared_p_value(chi_sq);
let deviation = (observed_ratio - expected_ratio).abs();
let severity = if p_value > 0.01 {
SRMSeverity::None
} else if deviation < SRM_THRESHOLD {
SRMSeverity::Warning
} else {
SRMSeverity::Critical
};
SRMCheck {
srm_detected: p_value < 0.01,
expected_ratio,
observed_ratio,
chi_squared: chi_sq,
p_value,
severity,
}
}
pub fn sequential_analysis(
test: &ABTest,
analysis_number: u32,
planned_analyses: u32,
) -> SequentialTest {
if planned_analyses == 0 || analysis_number == 0 {
return SequentialTest {
analysis_number,
planned_analyses,
alpha_spent: 0.0,
current_alpha: test.config.significance_level,
can_stop_early: false,
stop_reason: None,
};
}
let fraction = analysis_number as f64 / planned_analyses as f64;
let alpha = test.config.significance_level;
let alpha_spent = 2.0
* (1.0
- Self::normal_cdf(Self::inverse_normal_cdf(1.0 - alpha / 2.0) / fraction.sqrt()));
let current_alpha = alpha_spent / analysis_number as f64;
let (_, p_value) = Self::chi_squared_test(
test.control_metrics.impressions,
test.control_metrics.clicks,
test.treatment_metrics.impressions,
test.treatment_metrics.clicks,
);
let can_stop_early = p_value < current_alpha
&& test.control_metrics.impressions >= test.config.min_impressions / 2
&& test.treatment_metrics.impressions >= test.config.min_impressions / 2;
let stop_reason = if can_stop_early {
let effect = Self::calculate_effect_size(test);
if effect.interpretation == EffectSizeInterpretation::Negligible {
Some("Futility: Effect size too small to be practically significant".to_string())
} else {
Some(format!(
"Efficacy: Significant result with {} effect",
effect.interpretation
))
}
} else {
None
};
SequentialTest {
analysis_number,
planned_analyses,
alpha_spent,
current_alpha,
can_stop_early,
stop_reason,
}
}
fn normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + Self::erf(x / 2.0_f64.sqrt()))
}
pub fn comprehensive_analysis(test: &ABTest) -> ComprehensiveAnalysis {
let frequentist = Self::analyze(test);
let bayesian = Self::bayesian_analysis(test);
let effect_size = Self::calculate_effect_size(test);
let srm = Self::check_srm(test);
let sequential = Self::sequential_analysis(test, 1, 5);
let is_practically_significant = effect_size.cohens_h >= MIN_PRACTICAL_EFFECT_SIZE;
let has_data_quality_issues = srm.srm_detected;
let high_confidence =
bayesian.prob_treatment_better > 0.95 || bayesian.prob_control_better > 0.95;
let low_risk = bayesian.risk_treatment < 0.01 || bayesian.risk_control < 0.01;
let should_ship = frequentist.is_significant
&& is_practically_significant
&& !has_data_quality_issues
&& high_confidence
&& low_risk
&& frequentist.winner == Some(ABTestVariant::Treatment);
let mut insights = Vec::new();
if should_ship {
insights.push(format!(
"✅ SHIP IT: Treatment is {:.1}% better with {:.1}% confidence and {} effect size",
bayesian.expected_lift * 100.0,
bayesian.prob_treatment_better * 100.0,
effect_size.interpretation
));
} else if frequentist.winner == Some(ABTestVariant::Control) && frequentist.is_significant {
insights.push(format!(
"❌ DO NOT SHIP: Control is {:.1}% better. Treatment would hurt users.",
-bayesian.expected_lift * 100.0
));
} else {
insights.push("⏳ KEEP TESTING: Not enough evidence to make a decision".to_string());
}
if !frequentist.is_significant {
insights.push(format!(
"📊 p-value = {:.4} (need < {:.2})",
frequentist.p_value, test.config.significance_level
));
}
if !is_practically_significant {
insights.push(format!(
"📏 Effect is {} (Cohen's h = {:.3}) - may not matter to users",
effect_size.interpretation, effect_size.cohens_h
));
}
if has_data_quality_issues {
insights.push(format!(
"⚠️ DATA QUALITY: Sample ratio mismatch detected ({:.1}% vs expected {:.1}%)",
srm.observed_ratio * 100.0,
srm.expected_ratio * 100.0
));
}
if bayesian.risk_treatment > 0.01 {
insights.push(format!(
"🎲 Risk if shipping treatment: {:.2}% expected loss",
bayesian.risk_treatment * 100.0
));
}
if effect_size.nnt.is_finite() && effect_size.nnt < 1000.0 {
insights.push(format!(
"👥 Impact: 1 in {:.0} users will benefit from this change",
effect_size.nnt
));
}
ComprehensiveAnalysis {
frequentist,
bayesian,
effect_size,
srm,
sequential,
should_ship,
is_practically_significant,
insights,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComprehensiveAnalysis {
pub frequentist: ABTestResults,
pub bayesian: BayesianAnalysis,
pub effect_size: EffectSize,
pub srm: SRMCheck,
pub sequential: SequentialTest,
pub should_ship: bool,
pub is_practically_significant: bool,
pub insights: Vec<String>,
}
pub struct ABTestManager {
tests: Arc<RwLock<HashMap<String, ABTest>>>,
archived: Arc<RwLock<Vec<ABTest>>>,
}
impl Default for ABTestManager {
fn default() -> Self {
Self::new()
}
}
impl ABTestManager {
pub fn new() -> Self {
Self {
tests: Arc::new(RwLock::new(HashMap::new())),
archived: Arc::new(RwLock::new(Vec::new())),
}
}
pub fn create_test(&self, test: ABTest) -> Result<String, ABTestError> {
let id = test.config.id.clone();
let mut tests = self.tests.write();
if tests.contains_key(&id) {
return Err(ABTestError::TestAlreadyExists(id));
}
tests.insert(id.clone(), test);
Ok(id)
}
pub fn get_test(&self, test_id: &str) -> Option<ABTest> {
self.tests.read().get(test_id).cloned()
}
pub fn list_tests(&self) -> Vec<ABTest> {
self.tests.read().values().cloned().collect()
}
pub fn list_tests_by_status(&self, status: ABTestStatus) -> Vec<ABTest> {
self.tests
.read()
.values()
.filter(|t| t.status == status)
.cloned()
.collect()
}
pub fn start_test(&self, test_id: &str) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
if test.status != ABTestStatus::Draft {
return Err(ABTestError::InvalidState(format!(
"Cannot start test in {:?} state",
test.status
)));
}
test.start();
Ok(())
}
pub fn pause_test(&self, test_id: &str) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
test.pause();
Ok(())
}
pub fn resume_test(&self, test_id: &str) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
test.resume();
Ok(())
}
pub fn complete_test(&self, test_id: &str) -> Result<ABTestResults, ABTestError> {
let results = {
let tests = self.tests.read();
let test = tests
.get(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
ABTestAnalyzer::analyze(test)
};
let mut tests = self.tests.write();
if let Some(test) = tests.get_mut(test_id) {
test.complete();
}
Ok(results)
}
pub fn archive_test(&self, test_id: &str) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let mut test = tests
.remove(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
test.archive();
self.archived.write().push(test);
Ok(())
}
pub fn delete_test(&self, test_id: &str) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
tests
.remove(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
Ok(())
}
pub fn get_variant(&self, test_id: &str, user_id: &str) -> Result<ABTestVariant, ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
if test.status != ABTestStatus::Running {
return Err(ABTestError::TestNotRunning(test_id.to_string()));
}
Ok(test.get_variant(user_id))
}
pub fn get_weights_for_user(
&self,
test_id: &str,
user_id: &str,
) -> Result<LearnedWeights, ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
if test.status != ABTestStatus::Running {
return Err(ABTestError::TestNotRunning(test_id.to_string()));
}
let variant = test.get_variant(user_id);
Ok(test.get_weights(variant).clone())
}
pub fn record_impression(
&self,
test_id: &str,
user_id: &str,
relevance_score: f64,
latency_us: u64,
) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
if test.status != ABTestStatus::Running {
return Err(ABTestError::TestNotRunning(test_id.to_string()));
}
test.record_impression(user_id, relevance_score, latency_us);
Ok(())
}
pub fn record_click(
&self,
test_id: &str,
user_id: &str,
memory_id: Uuid,
) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
if test.status != ABTestStatus::Running {
return Err(ABTestError::TestNotRunning(test_id.to_string()));
}
test.record_click(user_id, memory_id);
Ok(())
}
pub fn record_feedback(
&self,
test_id: &str,
user_id: &str,
positive: bool,
) -> Result<(), ABTestError> {
let mut tests = self.tests.write();
let test = tests
.get_mut(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
if test.status != ABTestStatus::Running {
return Err(ABTestError::TestNotRunning(test_id.to_string()));
}
test.record_feedback(user_id, positive);
Ok(())
}
pub fn analyze_test(&self, test_id: &str) -> Result<ABTestResults, ABTestError> {
let tests = self.tests.read();
let test = tests
.get(test_id)
.ok_or_else(|| ABTestError::TestNotFound(test_id.to_string()))?;
Ok(ABTestAnalyzer::analyze(test))
}
pub fn list_archived(&self) -> Vec<ABTest> {
self.archived.read().clone()
}
pub fn check_expired_tests(&self) -> Vec<String> {
let mut expired = Vec::new();
let mut tests = self.tests.write();
for (id, test) in tests.iter_mut() {
if test.status == ABTestStatus::Running && test.is_expired() {
test.complete();
expired.push(id.clone());
}
}
expired
}
pub fn summary(&self) -> ABTestManagerSummary {
let tests = self.tests.read();
let archived = self.archived.read();
let mut draft = 0;
let mut running = 0;
let mut paused = 0;
let mut completed = 0;
for test in tests.values() {
match test.status {
ABTestStatus::Draft => draft += 1,
ABTestStatus::Running => running += 1,
ABTestStatus::Paused => paused += 1,
ABTestStatus::Completed => completed += 1,
ABTestStatus::Archived => {}
}
}
ABTestManagerSummary {
total_active: tests.len(),
draft,
running,
paused,
completed,
archived: archived.len(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABTestManagerSummary {
pub total_active: usize,
pub draft: usize,
pub running: usize,
pub paused: usize,
pub completed: usize,
pub archived: usize,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ABTestError {
#[error("Test not found: {0}")]
TestNotFound(String),
#[error("Test already exists: {0}")]
TestAlreadyExists(String),
#[error("Test is not running: {0}")]
TestNotRunning(String),
#[error("Invalid state: {0}")]
InvalidState(String),
#[error("Insufficient data for analysis")]
InsufficientData,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_variant_assignment_consistency() {
let mut test = ABTest::builder("test").with_traffic_split(0.5).build();
let user = "user_123";
let variant1 = test.get_variant(user);
let variant2 = test.get_variant(user);
let variant3 = test.get_variant(user);
assert_eq!(variant1, variant2);
assert_eq!(variant2, variant3);
}
#[test]
fn test_traffic_split() {
let mut test = ABTest::builder("test").with_traffic_split(0.5).build();
let mut _control_count = 0;
let mut treatment_count = 0;
for i in 0..1000 {
let user = format!("user_{i}");
match test.get_variant(&user) {
ABTestVariant::Control => _control_count += 1,
ABTestVariant::Treatment => treatment_count += 1,
}
}
let ratio = treatment_count as f64 / 1000.0;
assert!(ratio > 0.4 && ratio < 0.6, "Ratio was {ratio}");
}
#[test]
fn test_metrics_tracking() {
let mut test = ABTest::builder("test").build();
test.start();
test.record_impression("user_1", 0.8, 5000);
test.record_impression("user_1", 0.7, 4000);
test.record_click("user_1", Uuid::new_v4());
test.record_feedback("user_1", true);
let variant = test.get_variant("user_1");
let metrics = test.get_metrics(variant);
assert_eq!(metrics.impressions, 2);
assert_eq!(metrics.clicks, 1);
assert_eq!(metrics.positive_feedback, 1);
assert_eq!(metrics.unique_users, 1);
assert!((metrics.ctr() - 0.5).abs() < 0.001);
}
#[test]
fn test_chi_squared_significant() {
let (chi_sq, p_value) = ABTestAnalyzer::chi_squared_test(
1000, 100, 1000, 200, );
assert!(chi_sq > CHI_SQUARED_CRITICAL_005);
assert!(p_value < 0.05);
}
#[test]
fn test_chi_squared_not_significant() {
let (chi_sq, p_value) = ABTestAnalyzer::chi_squared_test(
50, 5, 50, 6, );
assert!(p_value > 0.05 || chi_sq < CHI_SQUARED_CRITICAL_005);
}
#[test]
fn test_confidence_interval() {
let (low, high) = ABTestAnalyzer::calculate_confidence_interval(
1000, 100, 1000, 150, );
assert!(low < 0.05);
assert!(high > 0.05);
assert!(low > 0.0 || high < 0.0 || (low < 0.0 && high > 0.0));
}
#[test]
fn test_manager_lifecycle() {
let manager = ABTestManager::new();
let test = ABTest::builder("test_lifecycle")
.with_description("Test lifecycle management")
.build();
let id = manager.create_test(test).unwrap();
manager.start_test(&id).unwrap();
let test = manager.get_test(&id).unwrap();
assert_eq!(test.status, ABTestStatus::Running);
manager.record_impression(&id, "user_1", 0.8, 5000).unwrap();
manager.record_click(&id, "user_1", Uuid::new_v4()).unwrap();
let results = manager.analyze_test(&id).unwrap();
assert!(!results.is_significant);
manager.complete_test(&id).unwrap();
let test = manager.get_test(&id).unwrap();
assert_eq!(test.status, ABTestStatus::Completed);
manager.archive_test(&id).unwrap();
assert!(manager.get_test(&id).is_none());
assert_eq!(manager.list_archived().len(), 1);
}
#[test]
fn test_learned_weights_integration() {
let control = LearnedWeights::default();
let mut treatment = LearnedWeights {
semantic: 0.6,
entity: 0.2,
..Default::default()
};
treatment.normalize();
let test = ABTest::builder("weights_test")
.with_control(control.clone())
.with_treatment(treatment.clone())
.build();
assert_eq!(
test.get_weights(ABTestVariant::Control).semantic,
control.semantic
);
assert_eq!(
test.get_weights(ABTestVariant::Treatment).semantic,
treatment.semantic
);
}
#[test]
fn test_ctr_calculation() {
let mut metrics = VariantMetrics::default();
assert_eq!(metrics.ctr(), 0.0);
metrics.impressions = 100;
metrics.clicks = 10;
assert!((metrics.ctr() - 0.1).abs() < 0.001);
}
#[test]
fn test_success_rate_calculation() {
let mut metrics = VariantMetrics::default();
assert_eq!(metrics.success_rate(), 0.0);
metrics.positive_feedback = 8;
metrics.negative_feedback = 2;
assert!((metrics.success_rate() - 0.8).abs() < 0.001);
}
#[test]
fn test_power_estimation() {
let mut test = ABTest::builder("power_test").build();
for i in 0..500 {
let user = format!("control_{i}");
test.user_assignments
.insert(user.clone(), ABTestVariant::Control);
test.control_metrics.impressions += 1;
test.control_metrics.unique_users += 1;
if i % 10 == 0 {
test.control_metrics.clicks += 1;
}
}
for i in 0..500 {
let user = format!("treatment_{i}");
test.user_assignments
.insert(user.clone(), ABTestVariant::Treatment);
test.treatment_metrics.impressions += 1;
test.treatment_metrics.unique_users += 1;
if i % 5 == 0 {
test.treatment_metrics.clicks += 1;
}
}
let power = ABTestAnalyzer::estimate_power(&test);
assert!(power > 0.5, "Power was {power}"); }
#[test]
fn test_manager_summary() {
let manager = ABTestManager::new();
let test1 = ABTest::builder("draft_test").build();
manager.create_test(test1).unwrap();
let test2 = ABTest::builder("running_test").build();
let id2 = manager.create_test(test2).unwrap();
manager.start_test(&id2).unwrap();
let summary = manager.summary();
assert_eq!(summary.total_active, 2);
assert_eq!(summary.draft, 1);
assert_eq!(summary.running, 1);
}
#[test]
fn test_recommendations_generation() {
let mut test = ABTest::builder("recommendations_test")
.with_min_impressions(100)
.build();
test.control_metrics.impressions = 1000;
test.control_metrics.clicks = 100; test.treatment_metrics.impressions = 1000;
test.treatment_metrics.clicks = 200;
let results = ABTestAnalyzer::analyze(&test);
assert!(results.is_significant);
assert_eq!(results.winner, Some(ABTestVariant::Treatment));
assert!(!results.recommendations.is_empty());
assert!(results
.recommendations
.iter()
.any(|r| r.contains("Treatment")));
}
#[test]
fn test_ab_demo_with_numbers() {
println!("\n========================================");
println!(" A/B TESTING DEMO WITH NUMBERS");
println!("========================================\n");
println!("📊 SCENARIO 1: Clear Winner (Treatment significantly better)");
println!(" Control: 1000 impressions, 100 clicks (10.0% CTR)");
println!(" Treatment: 1000 impressions, 200 clicks (20.0% CTR)");
let (chi_sq, p_value) = ABTestAnalyzer::chi_squared_test(1000, 100, 1000, 200);
let (ci_low, ci_high) = ABTestAnalyzer::calculate_confidence_interval(1000, 100, 1000, 200);
println!("\n RESULTS:");
println!(" ├─ Chi-squared statistic: {chi_sq:.4}");
println!(" ├─ P-value: {p_value:.6}");
let significant = if p_value < 0.05 { "YES ✓" } else { "NO ✗" };
println!(" ├─ Significant (p < 0.05): {significant}");
println!(" ├─ 95% Confidence Interval: ({ci_low:.4}, {ci_high:.4})");
let improvement = ((0.20 - 0.10) / 0.10) * 100.0;
println!(" └─ Relative improvement: {improvement:.1}%");
println!("\n📊 SCENARIO 2: No Significant Difference (Sample too small)");
println!(" Control: 50 impressions, 5 clicks (10.0% CTR)");
println!(" Treatment: 50 impressions, 6 clicks (12.0% CTR)");
let (chi_sq2, p_value2) = ABTestAnalyzer::chi_squared_test(50, 5, 50, 6);
let (ci_low2, ci_high2) = ABTestAnalyzer::calculate_confidence_interval(50, 5, 50, 6);
println!("\n RESULTS:");
println!(" ├─ Chi-squared statistic: {chi_sq2:.4}");
println!(" ├─ P-value: {p_value2:.6}");
let significant2 = if p_value2 < 0.05 { "YES ✓" } else { "NO ✗" };
println!(" ├─ Significant (p < 0.05): {significant2}");
println!(" ├─ 95% Confidence Interval: ({ci_low2:.4}, {ci_high2:.4})");
let ci_includes_zero = if ci_low2 < 0.0 && ci_high2 > 0.0 {
"YES"
} else {
"NO"
};
println!(" └─ CI includes 0: {ci_includes_zero} (effect may be due to chance)");
println!("\n📊 SCENARIO 3: Full Analysis with Recommendations");
let mut test = ABTest::builder("semantic_weight_test")
.with_min_impressions(100)
.build();
test.control_metrics.impressions = 5000;
test.control_metrics.clicks = 500; test.control_metrics.positive_feedback = 400;
test.control_metrics.negative_feedback = 50;
test.treatment_metrics.impressions = 5000;
test.treatment_metrics.clicks = 750; test.treatment_metrics.positive_feedback = 600;
test.treatment_metrics.negative_feedback = 30;
let results = ABTestAnalyzer::analyze(&test);
println!(" Test: Comparing semantic weight emphasis");
println!(" Control: 5000 impressions, 500 clicks (10.0% CTR)");
println!(" Treatment: 5000 impressions, 750 clicks (15.0% CTR)");
println!("\n STATISTICAL RESULTS:");
println!(" ├─ Chi-squared: {:.4}", results.chi_squared);
println!(" ├─ P-value: {:.8}", results.p_value);
println!(
" ├─ Confidence Level: {:.2}%",
results.confidence_level * 100.0
);
println!(
" ├─ Significant: {}",
if results.is_significant {
"YES ✓"
} else {
"NO ✗"
}
);
println!(" ├─ Winner: {:?}", results.winner);
println!(
" ├─ Relative Improvement: {:.2}%",
results.relative_improvement
);
println!(" ├─ Control CTR: {:.2}%", results.control_ctr * 100.0);
println!(" ├─ Treatment CTR: {:.2}%", results.treatment_ctr * 100.0);
println!(
" └─ 95% CI: ({:.4}, {:.4})",
results.confidence_interval.0, results.confidence_interval.1
);
println!("\n RECOMMENDATIONS:");
for (i, rec) in results.recommendations.iter().enumerate() {
println!(" {}. {}", i + 1, rec);
}
println!("\n========================================");
println!(" END OF A/B TESTING DEMO");
println!("========================================\n");
assert!(results.is_significant);
assert_eq!(results.winner, Some(ABTestVariant::Treatment));
}
#[test]
fn test_comprehensive_analysis_demo() {
println!("\n========================================");
println!(" COMPREHENSIVE A/B ANALYSIS DEMO");
println!(" (Dynamic Weight-Based Simulation)");
println!("========================================\n");
let control_weights = LearnedWeights {
semantic: 0.35,
entity: 0.30,
tag: 0.10,
importance: 0.10,
momentum: 0.15, access_count: 0.0, graph_strength: 0.0, update_count: 0,
last_updated: None,
};
let treatment_weights = LearnedWeights::default();
println!("📊 WEIGHT COMPARISON:");
println!(" Control (old): semantic={:.2}, entity={:.2}, momentum={:.2}, access={:.2}, graph={:.2}",
control_weights.semantic, control_weights.entity, control_weights.momentum,
control_weights.access_count, control_weights.graph_strength);
println!(" Treatment (new): semantic={:.2}, entity={:.2}, momentum={:.2}, access={:.2}, graph={:.2}\n",
treatment_weights.semantic, treatment_weights.entity, treatment_weights.momentum,
treatment_weights.access_count, treatment_weights.graph_strength);
#[allow(clippy::type_complexity)]
let memory_corpus: Vec<(f32, f32, f32, f32, f32, u32, f32, bool)> = vec![
(0.8, 0.7, 0.5, 0.8, 0.9, 15, 0.9, true), (0.7, 0.8, 0.6, 0.7, 0.8, 12, 0.85, true), (0.9, 0.6, 0.4, 0.9, 0.7, 10, 0.8, true), (0.6, 0.9, 0.7, 0.6, 0.85, 8, 0.75, true), (0.95, 0.9, 0.8, 0.9, -0.6, 1, 0.15, false), (0.9, 0.85, 0.7, 0.85, -0.4, 0, 0.1, false), (0.88, 0.82, 0.6, 0.8, -0.5, 1, 0.2, false), (0.85, 0.88, 0.75, 0.82, -0.3, 2, 0.25, false), (0.7, 0.5, 0.3, 0.6, 0.4, 4, 0.5, true), (0.5, 0.6, 0.4, 0.5, 0.3, 3, 0.45, true), (0.6, 0.55, 0.35, 0.55, 0.35, 3, 0.4, true), (0.4, 0.3, 0.2, 0.4, 0.1, 1, 0.2, false), (0.35, 0.4, 0.25, 0.35, -0.1, 1, 0.15, false), ];
let mut control_ranked: Vec<(usize, f32, bool)> = memory_corpus
.iter()
.enumerate()
.map(|(idx, &(sem, ent, tag, imp, mom, acc, graph, relevant))| {
let score = control_weights.fuse_scores_full(sem, ent, tag, imp, mom, acc, graph);
(idx, score, relevant)
})
.collect();
let mut treatment_ranked: Vec<(usize, f32, bool)> = memory_corpus
.iter()
.enumerate()
.map(|(idx, &(sem, ent, tag, imp, mom, acc, graph, relevant))| {
let score = treatment_weights.fuse_scores_full(sem, ent, tag, imp, mom, acc, graph);
(idx, score, relevant)
})
.collect();
control_ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
treatment_ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
println!("🔍 RANKING COMPARISON (top 8):");
println!(" Control ranking:");
for (rank, (idx, score, relevant)) in control_ranked.iter().take(8).enumerate() {
let status = if *relevant {
"✓ relevant"
} else {
"✗ TRAP"
};
println!(
" #{}: memory[{}] score={:.3} {}",
rank + 1,
idx,
score,
status
);
}
println!(" Treatment ranking:");
for (rank, (idx, score, relevant)) in treatment_ranked.iter().take(8).enumerate() {
let status = if *relevant {
"✓ relevant"
} else {
"✗ TRAP"
};
println!(
" #{}: memory[{}] score={:.3} {}",
rank + 1,
idx,
score,
status
);
}
println!();
let num_sessions = 1000;
let memories_surfaced = 5;
let control_top_k: Vec<bool> = control_ranked
.iter()
.take(memories_surfaced)
.map(|x| x.2)
.collect();
let treatment_top_k: Vec<bool> = treatment_ranked
.iter()
.take(memories_surfaced)
.map(|x| x.2)
.collect();
let control_relevant_count = control_top_k.iter().filter(|&&r| r).count();
let treatment_relevant_count = treatment_top_k.iter().filter(|&&r| r).count();
let control_trap_count = memories_surfaced - control_relevant_count;
let treatment_trap_count = memories_surfaced - treatment_relevant_count;
let control_trap_ratio = control_trap_count as f32 / memories_surfaced as f32;
let treatment_trap_ratio = treatment_trap_count as f32 / memories_surfaced as f32;
println!("📈 CONTEXT QUALITY (top {memories_surfaced}):");
let control_trap_pct = control_trap_ratio * 100.0;
let treatment_trap_pct = treatment_trap_ratio * 100.0;
println!(
" Control: {control_relevant_count} relevant, {control_trap_count} traps ({control_trap_pct:.0}% trap ratio)"
);
println!(
" Treatment: {treatment_relevant_count} relevant, {treatment_trap_count} traps ({treatment_trap_pct:.0}% trap ratio)\n"
);
let mut rng_state: u64 = 42;
let next_rand = |state: &mut u64| -> f32 {
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
((*state >> 32) as f32) / (0x1_0000_0000_u64 as f32)
};
let mut control_positive = 0u64;
let mut control_negative = 0u64;
let mut treatment_positive = 0u64;
let mut treatment_negative = 0u64;
for _session in 0..num_sessions {
if next_rand(&mut rng_state) < control_trap_ratio {
control_negative += 1; } else {
control_positive += 1; }
if next_rand(&mut rng_state) < treatment_trap_ratio {
treatment_negative += 1;
} else {
treatment_positive += 1;
}
}
let num_impressions = num_sessions as u64;
let control_clicks = control_positive; let treatment_clicks = treatment_positive;
let mut test = ABTest::builder("relevance_weights_experiment")
.with_description(
"CTX-3: Quality over quantity - momentum, access_count, graph_strength",
)
.with_control(control_weights)
.with_treatment(treatment_weights)
.with_min_impressions(100)
.with_traffic_split(0.5)
.build();
test.control_metrics.impressions = num_impressions;
test.control_metrics.clicks = control_clicks;
test.control_metrics.unique_users = (num_impressions as f64 * 0.85) as u64;
test.control_metrics.positive_feedback = control_positive;
test.control_metrics.negative_feedback = control_negative;
test.treatment_metrics.impressions = num_impressions;
test.treatment_metrics.clicks = treatment_clicks;
test.treatment_metrics.unique_users = (num_impressions as f64 * 0.85) as u64;
test.treatment_metrics.positive_feedback = treatment_positive;
test.treatment_metrics.negative_feedback = treatment_negative;
let control_ctr = (control_clicks as f64 / num_impressions as f64) * 100.0;
let treatment_ctr = (treatment_clicks as f64 / num_impressions as f64) * 100.0;
let analysis = ABTestAnalyzer::comprehensive_analysis(&test);
println!("📊 DYNAMIC SIMULATION RESULTS:");
println!(
" ├─ Control: {num_impressions} impressions, {control_clicks} clicks ({control_ctr:.1}% CTR)"
);
println!(" │ positive={control_positive}, negative={control_negative}");
println!(
" └─ Treatment: {num_impressions} impressions, {treatment_clicks} clicks ({treatment_ctr:.1}% CTR)"
);
println!(" positive={treatment_positive}, negative={treatment_negative}\n");
println!("🔬 FREQUENTIST ANALYSIS:");
let chi_sq = analysis.frequentist.chi_squared;
let p_val = analysis.frequentist.p_value;
println!(" ├─ Chi-squared: {chi_sq:.4}");
println!(" ├─ P-value: {p_val:.6}");
let significant = if analysis.frequentist.is_significant {
"YES ✓"
} else {
"NO ✗"
};
println!(" ├─ Significant: {significant}");
let winner = &analysis.frequentist.winner;
println!(" └─ Winner: {winner:?}\n");
println!("🎲 BAYESIAN ANALYSIS:");
let prob_treat = analysis.bayesian.prob_treatment_better * 100.0;
let exp_lift = analysis.bayesian.expected_lift * 100.0;
let ci_lo = analysis.bayesian.credible_interval.0 * 100.0;
let ci_hi = analysis.bayesian.credible_interval.1 * 100.0;
let risk_treat = analysis.bayesian.risk_treatment * 100.0;
let risk_ctrl = analysis.bayesian.risk_control * 100.0;
println!(" ├─ P(Treatment better): {prob_treat:.2}%");
println!(" ├─ Expected lift: {exp_lift:.2}%");
println!(" ├─ 95% Credible Interval: ({ci_lo:.2}%, {ci_hi:.2}%)");
println!(" ├─ Risk if shipping treatment: {risk_treat:.3}%");
println!(" └─ Risk if keeping control: {risk_ctrl:.3}%\n");
println!("📏 EFFECT SIZE:");
let cohens_h = analysis.effect_size.cohens_h;
let interpretation = &analysis.effect_size.interpretation;
let rel_risk = analysis.effect_size.relative_risk;
let odds_ratio = analysis.effect_size.odds_ratio;
let nnt = analysis.effect_size.nnt;
println!(" ├─ Cohen's h: {cohens_h:.4}");
println!(" ├─ Interpretation: {interpretation}");
println!(" ├─ Relative Risk: {rel_risk:.2}x");
println!(" ├─ Odds Ratio: {odds_ratio:.2}");
if nnt.is_finite() {
println!(" └─ NNT (Number Needed to Treat): {nnt:.0}\n");
} else {
println!(" └─ NNT: N/A (no effect)\n");
}
println!("⚖️ DATA QUALITY (SRM Check):");
let expected_ratio = analysis.srm.expected_ratio * 100.0;
let observed_ratio = analysis.srm.observed_ratio * 100.0;
println!(" ├─ Expected ratio: {expected_ratio:.1}%");
println!(" ├─ Observed ratio: {observed_ratio:.1}%");
let srm_detected = if analysis.srm.srm_detected {
"YES ⚠️"
} else {
"NO ✓"
};
println!(" ├─ SRM Detected: {srm_detected}");
let severity = &analysis.srm.severity;
println!(" └─ Severity: {severity:?}\n");
println!("📈 SEQUENTIAL TESTING:");
let analysis_num = analysis.sequential.analysis_number;
let planned = analysis.sequential.planned_analyses;
let alpha_spent = analysis.sequential.alpha_spent;
println!(" ├─ Analysis #{analysis_num} of {planned}");
println!(" ├─ Alpha spent: {alpha_spent:.4}");
let current_alpha = analysis.sequential.current_alpha;
println!(" ├─ Current threshold: {current_alpha:.4}");
let can_stop = if analysis.sequential.can_stop_early {
"YES ✓"
} else {
"NO - Continue testing"
};
println!(" └─ Can stop early: {can_stop}\n");
println!("═══════════════════════════════════════");
println!("🎯 FINAL DECISION:");
println!("═══════════════════════════════════════");
let should_ship = if analysis.should_ship {
"YES ✅"
} else {
"NO ❌"
};
println!(" Should ship: {should_ship}");
let practically_sig = if analysis.is_practically_significant {
"YES"
} else {
"NO"
};
println!(" Practically significant: {practically_sig}");
println!("\n📋 USER-FOCUSED INSIGHTS:");
for insight in &analysis.insights {
println!(" • {insight}");
}
let ard = (treatment_ctr - control_ctr) / 100.0; let nnt = if ard > 0.0 { 1.0 / ard } else { f64::INFINITY };
let ctr_diff = treatment_ctr - control_ctr;
println!("\n🎯 KEY METRIC:");
println!(
" ├─ CTR Improvement: {control_ctr:.1}% → {treatment_ctr:.1}% (+{ctr_diff:.1}%)"
);
let ard_pct = ard * 100.0;
println!(" ├─ ARD (Absolute Risk Difference): {ard_pct:.2}%");
if nnt.is_finite() && nnt < 100.0 {
println!(" └─ NNT (Number Needed to Treat): {nnt:.0}");
println!(" (1 in {nnt:.0} users benefit from treatment)");
} else {
println!(" └─ NNT: N/A (no significant improvement)");
}
println!("\n========================================");
println!(" END OF COMPREHENSIVE ANALYSIS");
println!("========================================\n");
assert!(
treatment_clicks >= control_clicks,
"Treatment ({treatment_clicks} clicks) should outperform Control ({control_clicks} clicks)"
);
assert!(
treatment_ctr >= control_ctr,
"Treatment CTR ({treatment_ctr:.1}%) should be >= Control CTR ({control_ctr:.1}%)"
);
let control_quality = if control_negative > 0 {
control_positive as f64 / control_negative as f64
} else {
control_positive as f64
};
let treatment_quality = if treatment_negative > 0 {
treatment_positive as f64 / treatment_negative as f64
} else {
treatment_positive as f64
};
assert!(
treatment_quality >= control_quality * 0.9, "Treatment quality ratio ({treatment_quality:.2}) should be >= Control ({control_quality:.2})"
);
assert!(!analysis.insights.is_empty());
}
#[test]
fn test_bayesian_analysis() {
let mut test = ABTest::builder("bayesian_test").build();
test.control_metrics.impressions = 1000;
test.control_metrics.clicks = 100; test.treatment_metrics.impressions = 1000;
test.treatment_metrics.clicks = 150;
let bayesian = ABTestAnalyzer::bayesian_analysis(&test);
assert!(bayesian.prob_treatment_better > 0.9);
assert!(bayesian.expected_lift > 0.0);
assert!(bayesian.credible_interval.0 > -0.5);
}
#[test]
fn test_effect_size_calculation() {
let mut test = ABTest::builder("effect_test").build();
test.control_metrics.impressions = 1000;
test.control_metrics.clicks = 100; test.treatment_metrics.impressions = 1000;
test.treatment_metrics.clicks = 200;
let effect = ABTestAnalyzer::calculate_effect_size(&test);
assert!(effect.cohens_h > 0.2);
assert!(effect.relative_risk > 1.5);
assert!((effect.nnt - 10.0).abs() < 0.5);
}
#[test]
fn test_srm_detection() {
let mut test = ABTest::builder("srm_test").with_traffic_split(0.5).build();
test.control_metrics.impressions = 700;
test.treatment_metrics.impressions = 300;
let srm = ABTestAnalyzer::check_srm(&test);
assert!(srm.srm_detected);
assert_eq!(srm.severity, SRMSeverity::Critical);
}
#[test]
fn test_sequential_analysis() {
let mut test = ABTest::builder("sequential_test")
.with_min_impressions(100)
.build();
test.control_metrics.impressions = 500;
test.control_metrics.clicks = 25; test.treatment_metrics.impressions = 500;
test.treatment_metrics.clicks = 75;
let seq = ABTestAnalyzer::sequential_analysis(&test, 1, 5);
assert_eq!(seq.analysis_number, 1);
assert_eq!(seq.planned_analyses, 5);
assert!(seq.alpha_spent < 0.01);
}
}