chronix_analytics/lifecycle/
ab_test.rs1use std::collections::VecDeque;
8
9use parking_lot::Mutex;
10
11use crate::lifecycle::registry::AccuracyMetrics;
12
13#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub enum PromotionCriteria {
28 LowerMape,
30 LowerRmse,
32 LowerBoth,
34}
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct ABTestConfig {
39 pub challenger_traffic_pct: f64,
41 pub evaluation_windows: usize,
43 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ABTestResult {
60 Promote,
62 Keep,
64 Inconclusive,
66}
67
68#[derive(Debug, Clone)]
70struct PredictionRecord {
71 actual: f64,
72 champion_prediction: f64,
73 challenger_prediction: f64,
74}
75
76#[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 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 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 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 let max_records = self.config.evaluation_windows * 100;
126 while inner.records.len() > max_records {
127 inner.records.pop_front();
128 }
129 }
130
131 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 let current_count = inner.records.len();
143 if current_count == inner.last_eval_count {
144 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 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 = AccuracyMetrics::compute(&actuals, &champ_preds);
165 let chal_metrics = AccuracyMetrics::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 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 } else {
225 inner.consecutive_wins = 0;
226 }
227
228 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 pub fn champion_metrics(&self) -> Option<AccuracyMetrics> {
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(AccuracyMetrics::compute(&actuals, &preds))
253 }
254
255 pub fn challenger_metrics(&self) -> Option<AccuracyMetrics> {
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(AccuracyMetrics::compute(&actuals, &preds))
268 }
269
270 pub fn record_count(&self) -> usize {
272 self.inner.lock().records.len()
273 }
274}
275
276#[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 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 for i in 0..50 {
324 let actual = 100.0 + (i as f64 * 0.1).sin();
325 let champ_pred = actual + 10.0; let chal_pred = actual + 1.0; eval.log_prediction(actual, champ_pred, chal_pred);
328 }
329
330 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 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); }
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 #[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 for _ in 0..10 {
398 let actual = 100.0;
399 let champ_pred = 110.0; let chal_pred = 101.0; eval.log_prediction(actual, champ_pred, chal_pred);
402 }
403 let r1 = eval.evaluate();
404 assert_eq!(r1, ABTestResult::Inconclusive); 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); for _ in 0..5 {
416 eval.log_prediction(100.0, 105.0, 105.0);
417 }
418 let r3 = eval.evaluate();
419 assert_eq!(r3, ABTestResult::Inconclusive);
421
422 for _ in 0..5 {
424 eval.log_prediction(100.0, 110.0, 101.0);
425 }
426 let r4 = eval.evaluate();
427 assert_eq!(r4, ABTestResult::Promote);
429 }
430}