Skip to main content

chronix_analytics/lifecycle/
ab_test.rs

1//! Champion/Challenger A/B testing for model evaluation.
2//!
3//! During prediction, traffic is probabilistically routed between the
4//! champion and a challenger model.  Both predictions are logged against
5//! actuals, and an evaluator determines when to promote the challenger.
6
7use std::collections::VecDeque;
8
9use parking_lot::Mutex;
10
11use crate::lifecycle::registry::VersionAccuracy;
12
13/// Relative+absolute tolerance comparison for float metrics.
14///
15/// Uses `1e-9` relative tolerance with a `1e-12` absolute floor,
16/// correctly handling values across all magnitudes (MAPE 0.01–100+,
17/// RMSE 0.001–1000+).
18#[inline]
19fn approx_eq(a: f64, b: f64) -> bool {
20    let diff = (a - b).abs();
21    let magnitude = a.abs().max(b.abs());
22    diff <= 1e-9 * magnitude.max(1e-3)
23}
24
25/// Criterion for promoting a challenger to champion.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub enum PromotionCriteria {
28    /// Promote when challenger's MAPE is lower.
29    LowerMape,
30    /// Promote when challenger's RMSE is lower.
31    LowerRmse,
32    /// Promote when both MAPE and RMSE are lower.
33    LowerBoth,
34}
35
36/// Configuration for an A/B test.
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct ABTestConfig {
39    /// Fraction of traffic routed to challenger `[0.0, 1.0]`. Default: `0.10`.
40    pub challenger_traffic_pct: f64,
41    /// Number of evaluation windows before a decision is made. Default: 10.
42    pub evaluation_windows: usize,
43    /// Promotion criterion.
44    pub criteria: PromotionCriteria,
45}
46
47impl Default for ABTestConfig {
48    fn default() -> Self {
49        Self {
50            challenger_traffic_pct: 0.10,
51            evaluation_windows: 10,
52            criteria: PromotionCriteria::LowerMape,
53        }
54    }
55}
56
57/// Result of an A/B test evaluation round.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ABTestResult {
60    /// Promote the challenger to champion.
61    Promote,
62    /// Keep the current champion; retire the challenger.
63    Keep,
64    /// Not enough data for a decision yet.
65    Inconclusive,
66}
67
68/// Logged prediction record.
69#[derive(Debug, Clone)]
70struct PredictionRecord {
71    actual: f64,
72    champion_prediction: f64,
73    challenger_prediction: f64,
74}
75
76/// Evaluator that collects prediction pairs and judges the test.
77///
78/// Thread-safe — mutable state is wrapped in `Mutex`.
79#[derive(Debug)]
80pub struct ABTestEvaluator {
81    config: ABTestConfig,
82    inner: Mutex<ABTestInner>,
83}
84
85#[derive(Debug)]
86struct ABTestInner {
87    records: VecDeque<PredictionRecord>,
88    consecutive_wins: usize,
89    last_eval_count: usize,
90}
91
92impl ABTestEvaluator {
93    /// Creates a new A/B test evaluator with the given configuration.
94    pub fn new(config: ABTestConfig) -> Self {
95        Self {
96            config,
97            inner: Mutex::new(ABTestInner {
98                records: VecDeque::new(),
99                consecutive_wins: 0,
100                last_eval_count: 0,
101            }),
102        }
103    }
104
105    /// Decide whether a specific request should be routed to the challenger.
106    ///
107    /// Uses a simple deterministic hash on the `request_id` for consistent
108    /// routing (no randomness in tight loops).
109    pub fn should_use_challenger(&self, request_id: u64) -> bool {
110        let frac = (request_id % 10000) as f64 / 10000.0;
111        frac < self.config.challenger_traffic_pct
112    }
113
114    /// Log a prediction pair (both champion and challenger predictions along
115    /// with the actual observed value).
116    pub fn log_prediction(&self, actual: f64, champion_pred: f64, challenger_pred: f64) {
117        let mut inner = self.inner.lock();
118        inner.records.push_back(PredictionRecord {
119            actual,
120            champion_prediction: champion_pred,
121            challenger_prediction: challenger_pred,
122        });
123
124        // Keep bounded to 10× evaluation_windows
125        let max_records = self.config.evaluation_windows * 100;
126        while inner.records.len() > max_records {
127            inner.records.pop_front();
128        }
129    }
130
131    /// Evaluate the A/B test based on logged records.
132    ///
133    /// Computes metrics over the most recent `evaluation_windows` records
134    /// to avoid stale early predictions diluting accuracy.
135    pub fn evaluate(&self) -> ABTestResult {
136        let mut inner = self.inner.lock();
137        if inner.records.len() < self.config.evaluation_windows {
138            return ABTestResult::Inconclusive;
139        }
140
141        // Only count as a new evaluation window when fresh data has arrived.
142        let current_count = inner.records.len();
143        if current_count == inner.last_eval_count {
144            // No new data — return current state without inflating counters.
145            return if inner.consecutive_wins >= 3 {
146                ABTestResult::Promote
147            } else if current_count >= self.config.evaluation_windows * 5
148                && inner.consecutive_wins == 0
149            {
150                ABTestResult::Keep
151            } else {
152                ABTestResult::Inconclusive
153            };
154        }
155        inner.last_eval_count = current_count;
156
157        // Use only the most recent window of records for metric computation
158        let window = self.config.evaluation_windows;
159        let recent = inner.records.iter().rev().take(window);
160        let actuals: Vec<f64> = recent.clone().map(|r| r.actual).collect();
161        let champ_preds: Vec<f64> = recent.clone().map(|r| r.champion_prediction).collect();
162        let chal_preds: Vec<f64> = recent.map(|r| r.challenger_prediction).collect();
163
164        let champ_metrics = VersionAccuracy::compute(&actuals, &champ_preds);
165        let chal_metrics = VersionAccuracy::compute(&actuals, &chal_preds);
166
167        metrics::gauge!("chronix_model_ab_test_champion_mape").set(champ_metrics.mape);
168        metrics::gauge!("chronix_model_ab_test_challenger_mape").set(chal_metrics.mape);
169
170        let challenger_wins = match self.config.criteria {
171            PromotionCriteria::LowerMape => {
172                !chal_metrics.mape.is_nan()
173                    && !champ_metrics.mape.is_nan()
174                    && chal_metrics.mape < champ_metrics.mape
175            }
176            PromotionCriteria::LowerRmse => {
177                !chal_metrics.rmse.is_nan()
178                    && !champ_metrics.rmse.is_nan()
179                    && chal_metrics.rmse < champ_metrics.rmse
180            }
181            PromotionCriteria::LowerBoth => {
182                chal_metrics.rmse < champ_metrics.rmse
183                    && !chal_metrics.mape.is_nan()
184                    && !champ_metrics.mape.is_nan()
185                    && chal_metrics.mape < champ_metrics.mape
186            }
187        };
188
189        // Tie detection: when challenger and champion produce exactly
190        // equal metrics under the active criterion. On a tie the
191        // `consecutive_wins` counter is left unchanged — the streak is
192        // neither extended nor broken.  This prevents a lucky sequence
193        // of ties from inflating the counter, while also avoiding
194        // penalising the challenger for matching the champion exactly.
195        // Use relative tolerance (1e-9) for tie detection
196        // instead of f64::EPSILON (~2.2e-16) which is meaningless for
197        // MAPE/RMSE values in the range 0.01-100+.
198        let is_tie = match self.config.criteria {
199            PromotionCriteria::LowerMape => {
200                !chal_metrics.mape.is_nan()
201                    && !champ_metrics.mape.is_nan()
202                    && approx_eq(chal_metrics.mape, champ_metrics.mape)
203            }
204            PromotionCriteria::LowerRmse => {
205                !chal_metrics.rmse.is_nan()
206                    && !champ_metrics.rmse.is_nan()
207                    && approx_eq(chal_metrics.rmse, champ_metrics.rmse)
208            }
209            PromotionCriteria::LowerBoth => {
210                !chal_metrics.mape.is_nan()
211                    && !champ_metrics.mape.is_nan()
212                    && approx_eq(chal_metrics.mape, champ_metrics.mape)
213                    && !chal_metrics.rmse.is_nan()
214                    && !champ_metrics.rmse.is_nan()
215                    && approx_eq(chal_metrics.rmse, champ_metrics.rmse)
216            }
217        };
218
219        if challenger_wins {
220            inner.consecutive_wins += 1;
221        } else if is_tie {
222            // Tie: preserve existing streak — the challenger matched
223            // the champion exactly, so no reason to penalise it.
224        } else {
225            inner.consecutive_wins = 0;
226        }
227
228        // Require sustained wins across multiple evaluations
229        if inner.consecutive_wins >= 3 {
230            ABTestResult::Promote
231        } else if inner.records.len() >= self.config.evaluation_windows * 5
232            && inner.consecutive_wins == 0
233        {
234            ABTestResult::Keep
235        } else {
236            ABTestResult::Inconclusive
237        }
238    }
239
240    /// Champion accuracy from logged data.
241    pub fn champion_metrics(&self) -> Option<VersionAccuracy> {
242        let inner = self.inner.lock();
243        if inner.records.is_empty() {
244            return None;
245        }
246        let actuals: Vec<f64> = inner.records.iter().map(|r| r.actual).collect();
247        let preds: Vec<f64> = inner
248            .records
249            .iter()
250            .map(|r| r.champion_prediction)
251            .collect();
252        Some(VersionAccuracy::compute(&actuals, &preds))
253    }
254
255    /// Challenger accuracy from logged data.
256    pub fn challenger_metrics(&self) -> Option<VersionAccuracy> {
257        let inner = self.inner.lock();
258        if inner.records.is_empty() {
259            return None;
260        }
261        let actuals: Vec<f64> = inner.records.iter().map(|r| r.actual).collect();
262        let preds: Vec<f64> = inner
263            .records
264            .iter()
265            .map(|r| r.challenger_prediction)
266            .collect();
267        Some(VersionAccuracy::compute(&actuals, &preds))
268    }
269
270    /// Number of logged records.
271    pub fn record_count(&self) -> usize {
272        self.inner.lock().records.len()
273    }
274}
275
276// -----------------------------------------------------------------------
277// Tests
278// -----------------------------------------------------------------------
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_routing_fraction() {
285        let eval = ABTestEvaluator::new(ABTestConfig {
286            challenger_traffic_pct: 0.10,
287            ..Default::default()
288        });
289
290        let mut challenger_count = 0;
291        for id in 0..10000 {
292            if eval.should_use_challenger(id) {
293                challenger_count += 1;
294            }
295        }
296        // ~10% of 10000 = ~1000
297        assert!((900..=1100).contains(&challenger_count));
298    }
299
300    #[test]
301    fn test_inconclusive_with_few_records() {
302        let eval = ABTestEvaluator::new(ABTestConfig {
303            evaluation_windows: 10,
304            ..Default::default()
305        });
306
307        for i in 0..5 {
308            eval.log_prediction(100.0, 110.0 + i as f64, 105.0 + i as f64);
309        }
310
311        assert_eq!(eval.evaluate(), ABTestResult::Inconclusive);
312    }
313
314    #[test]
315    fn test_promote_when_challenger_consistently_better() {
316        let eval = ABTestEvaluator::new(ABTestConfig {
317            evaluation_windows: 5,
318            criteria: PromotionCriteria::LowerMape,
319            ..Default::default()
320        });
321
322        // Challenger is consistently better
323        for i in 0..50 {
324            let actual = 100.0 + (i as f64 * 0.1).sin();
325            let champ_pred = actual + 10.0; // champion off by 10
326            let chal_pred = actual + 1.0; // challenger off by 1
327            eval.log_prediction(actual, champ_pred, chal_pred);
328        }
329
330        // Need 3 consecutive evaluations with challenger winning.
331        // Each evaluation requires new data to avoid no-op re-evaluation.
332        for batch in 0..3 {
333            for i in 0..5 {
334                let actual = 100.0 + ((50 + batch * 5 + i) as f64 * 0.1).sin();
335                let champ_pred = actual + 10.0;
336                let chal_pred = actual + 1.0;
337                eval.log_prediction(actual, champ_pred, chal_pred);
338            }
339            eval.evaluate();
340        }
341        let result = eval.evaluate();
342        assert_eq!(result, ABTestResult::Promote);
343    }
344
345    #[test]
346    fn test_keep_when_champion_wins() {
347        let eval = ABTestEvaluator::new(ABTestConfig {
348            evaluation_windows: 5,
349            criteria: PromotionCriteria::LowerRmse,
350            ..Default::default()
351        });
352
353        // Champion is better
354        for i in 0..50 {
355            let actual = 100.0 + (i as f64 * 0.1).sin();
356            let champ_pred = actual + 0.5;
357            let chal_pred = actual + 20.0;
358            eval.log_prediction(actual, champ_pred, chal_pred);
359        }
360
361        let result = eval.evaluate();
362        assert_eq!(result, ABTestResult::Keep);
363    }
364
365    #[test]
366    fn test_metrics_available() {
367        let eval = ABTestEvaluator::new(Default::default());
368        eval.log_prediction(100.0, 110.0, 105.0);
369        eval.log_prediction(200.0, 190.0, 195.0);
370
371        let cm = eval.champion_metrics().unwrap();
372        let clm = eval.challenger_metrics().unwrap();
373        assert!(cm.mae > 0.0);
374        assert!(clm.mae > 0.0);
375        assert!(clm.mae < cm.mae); // challenger closer
376    }
377
378    #[test]
379    fn test_record_count() {
380        let eval = ABTestEvaluator::new(Default::default());
381        assert_eq!(eval.record_count(), 0);
382        eval.log_prediction(1.0, 1.0, 1.0);
383        assert_eq!(eval.record_count(), 1);
384    }
385
386    /// When challenger MAPE exactly equals champion MAPE (tie),
387    /// `consecutive_wins` must be preserved — not reset to zero.
388    #[test]
389    fn test_ab_test_tie_preserves_streak() {
390        let eval = ABTestEvaluator::new(ABTestConfig {
391            evaluation_windows: 5,
392            criteria: PromotionCriteria::LowerMape,
393            ..Default::default()
394        });
395
396        // ── Round 1: challenger wins (lower MAPE) ──────────────
397        for _ in 0..10 {
398            let actual = 100.0;
399            let champ_pred = 110.0; // 10% error
400            let chal_pred = 101.0; // 1% error
401            eval.log_prediction(actual, champ_pred, chal_pred);
402        }
403        let r1 = eval.evaluate();
404        assert_eq!(r1, ABTestResult::Inconclusive); // 1 consecutive win
405
406        // ── Round 2: challenger wins again ─────────────────────
407        for _ in 0..5 {
408            eval.log_prediction(100.0, 110.0, 101.0);
409        }
410        let r2 = eval.evaluate();
411        assert_eq!(r2, ABTestResult::Inconclusive); // 2 consecutive wins
412
413        // ── Round 3: TIE — identical predictions ──────────────
414        // Both champion and challenger predict with the same error.
415        for _ in 0..5 {
416            eval.log_prediction(100.0, 105.0, 105.0);
417        }
418        let r3 = eval.evaluate();
419        // Streak should NOT be reset; still at 2 consecutive wins
420        assert_eq!(r3, ABTestResult::Inconclusive);
421
422        // ── Round 4: challenger wins again ─────────────────────
423        for _ in 0..5 {
424            eval.log_prediction(100.0, 110.0, 101.0);
425        }
426        let r4 = eval.evaluate();
427        // This should be the 3rd win (2 before tie + 1 now) → Promote
428        assert_eq!(r4, ABTestResult::Promote);
429    }
430}