Skip to main content

mockforge_core/ab_testing/
analytics.rs

1//! Analytics for A/B testing
2//!
3//! This module provides analytics and reporting functionality for A/B tests.
4
5use crate::ab_testing::types::{ABTestConfig, VariantAnalytics};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Summary report for an A/B test
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ABTestReport {
12    /// Test configuration
13    pub test_config: ABTestConfig,
14    /// Analytics for each variant
15    pub variant_analytics: HashMap<String, VariantAnalytics>,
16    /// Total requests across all variants
17    pub total_requests: u64,
18    /// Test start time
19    pub start_time: Option<chrono::DateTime<chrono::Utc>>,
20    /// Test end time (if ended)
21    pub end_time: Option<chrono::DateTime<chrono::Utc>>,
22    /// Whether the test is currently active
23    pub is_active: bool,
24}
25
26impl ABTestReport {
27    /// Create a new A/B test report
28    pub fn new(
29        test_config: ABTestConfig,
30        variant_analytics: HashMap<String, VariantAnalytics>,
31    ) -> Self {
32        let total_requests: u64 = variant_analytics.values().map(|a| a.request_count).sum();
33        let is_active = test_config.enabled
34            && test_config.start_time.is_none_or(|t| t <= chrono::Utc::now())
35            && test_config.end_time.is_none_or(|t| t >= chrono::Utc::now());
36
37        Self {
38            test_config,
39            variant_analytics,
40            total_requests,
41            start_time: None,
42            end_time: None,
43            is_active,
44        }
45    }
46
47    /// Get the best performing variant (highest success rate)
48    pub fn best_variant(&self) -> Option<&VariantAnalytics> {
49        self.variant_analytics.values().max_by(|a, b| {
50            a.success_rate()
51                .partial_cmp(&b.success_rate())
52                .unwrap_or(std::cmp::Ordering::Equal)
53        })
54    }
55
56    /// Get the worst performing variant (lowest success rate)
57    pub fn worst_variant(&self) -> Option<&VariantAnalytics> {
58        self.variant_analytics.values().min_by(|a, b| {
59            a.success_rate()
60                .partial_cmp(&b.success_rate())
61                .unwrap_or(std::cmp::Ordering::Equal)
62        })
63    }
64
65    /// Calculate statistical significance using a two-proportion z-test
66    ///
67    /// Compares the best and worst performing variants using the standard
68    /// z-test for two proportions. Returns a confidence percentage (0-100).
69    /// A value >= 95.0 is conventionally considered statistically significant.
70    pub fn statistical_significance(&self) -> f64 {
71        if self.variant_analytics.len() < 2 {
72            return 0.0;
73        }
74
75        let variants: Vec<&VariantAnalytics> = self.variant_analytics.values().collect();
76        if variants.len() < 2 {
77            return 0.0;
78        }
79
80        // Find the best and worst performing variants
81        let best = variants
82            .iter()
83            .max_by(|a, b| {
84                a.success_rate()
85                    .partial_cmp(&b.success_rate())
86                    .unwrap_or(std::cmp::Ordering::Equal)
87            })
88            .unwrap();
89        let worst = variants
90            .iter()
91            .min_by(|a, b| {
92                a.success_rate()
93                    .partial_cmp(&b.success_rate())
94                    .unwrap_or(std::cmp::Ordering::Equal)
95            })
96            .unwrap();
97
98        let n1 = best.request_count as f64;
99        let n2 = worst.request_count as f64;
100
101        // Need a minimum sample size for meaningful results
102        if n1 < 5.0 || n2 < 5.0 {
103            return 0.0;
104        }
105
106        let p1 = best.success_rate();
107        let p2 = worst.success_rate();
108
109        // Pooled proportion under null hypothesis (H0: p1 == p2)
110        let pooled = (best.success_count as f64 + worst.success_count as f64) / (n1 + n2);
111
112        // Guard against zero variance (all successes or all failures)
113        if pooled <= 0.0 || pooled >= 1.0 {
114            return 0.0;
115        }
116
117        // Standard error of the difference
118        let se = (pooled * (1.0 - pooled) * (1.0 / n1 + 1.0 / n2)).sqrt();
119        if se < f64::EPSILON {
120            return 0.0;
121        }
122
123        // Z-score
124        let z = (p1 - p2).abs() / se;
125
126        // Convert z-score to confidence percentage using the standard normal CDF approximation
127        // Using the Abramowitz and Stegun approximation for the normal CDF
128        let confidence = z_to_confidence(z) * 100.0;
129
130        confidence.min(100.0)
131    }
132}
133
134/// Comparison between two variants
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct VariantComparison {
137    /// First variant ID
138    pub variant_a_id: String,
139    /// Second variant ID
140    pub variant_b_id: String,
141    /// Success rate difference (A - B)
142    pub success_rate_diff: f64,
143    /// Response time difference in milliseconds (A - B)
144    pub response_time_diff_ms: f64,
145    /// Error rate difference (A - B)
146    pub error_rate_diff: f64,
147    /// Request count difference (A - B)
148    pub request_count_diff: i64,
149}
150
151impl VariantComparison {
152    /// Create a comparison between two variants
153    pub fn new(variant_a: &VariantAnalytics, variant_b: &VariantAnalytics) -> Self {
154        Self {
155            variant_a_id: variant_a.variant_id.clone(),
156            variant_b_id: variant_b.variant_id.clone(),
157            success_rate_diff: variant_a.success_rate() - variant_b.success_rate(),
158            response_time_diff_ms: variant_a.avg_response_time_ms - variant_b.avg_response_time_ms,
159            error_rate_diff: variant_a.error_rate() - variant_b.error_rate(),
160            request_count_diff: variant_a.request_count as i64 - variant_b.request_count as i64,
161        }
162    }
163}
164
165/// Convert a z-score to a confidence level (two-tailed) using the standard normal CDF.
166///
167/// Uses the Abramowitz and Stegun approximation (formula 26.2.17) for the
168/// standard normal cumulative distribution function, which is accurate to ~1e-5.
169fn z_to_confidence(z: f64) -> f64 {
170    // For a two-tailed test, confidence = 1 - 2 * (1 - Phi(|z|))
171    let z_abs = z.abs();
172
173    // Abramowitz and Stegun constants
174    let p = 0.2316419;
175    let b1 = 0.319381530;
176    let b2 = -0.356563782;
177    let b3 = 1.781477937;
178    let b4 = -1.821255978;
179    let b5 = 1.330274429;
180
181    let t = 1.0 / (1.0 + p * z_abs);
182    let t2 = t * t;
183    let t3 = t2 * t;
184    let t4 = t3 * t;
185    let t5 = t4 * t;
186
187    let pdf = (-0.5 * z_abs * z_abs).exp() / (2.0 * std::f64::consts::PI).sqrt();
188    let cdf = 1.0 - pdf * (b1 * t + b2 * t2 + b3 * t3 + b4 * t4 + b5 * t5);
189
190    // Two-tailed confidence: probability that the difference is real
191    1.0 - 2.0 * (1.0 - cdf)
192}