chronix_analytics/lifecycle/
adwin.rs1const MAX_BUCKETS_PER_ROW: usize = 5;
36
37#[derive(Debug, Clone, Copy)]
39struct Bucket {
40 total: f64,
42 variance: f64,
44 count: usize,
46}
47
48#[derive(Debug, Clone)]
62pub struct Adwin {
63 delta: f64,
65 rows: Vec<Vec<Bucket>>,
67 total_count: usize,
69 total_sum: f64,
71 total_variance: f64,
73 width: usize,
75}
76
77impl Adwin {
78 #[must_use]
83 pub fn new(delta: f64) -> Self {
84 Self {
85 delta: delta.max(1e-15),
86 rows: Vec::new(),
87 total_count: 0,
88 total_sum: 0.0,
89 total_variance: 0.0,
90 width: 0,
91 }
92 }
93
94 pub fn add(&mut self, value: f64) -> bool {
102 if !value.is_finite() {
103 return false;
104 }
105
106 self.insert_bucket(value);
108 self.compress();
110 self.detect_drift()
112 }
113
114 #[must_use]
116 pub fn mean(&self) -> f64 {
117 if self.width == 0 {
118 return 0.0;
119 }
120 self.total_sum / self.width as f64
121 }
122
123 #[must_use]
125 pub fn window_size(&self) -> usize {
126 self.width
127 }
128
129 #[must_use]
131 pub fn std_dev(&self) -> f64 {
132 if self.width < 2 {
133 return 0.0;
134 }
135 let var = (self.total_variance / self.width as f64)
136 - (self.total_sum / self.width as f64).powi(2);
137 var.max(0.0).sqrt()
138 }
139
140 pub fn reset(&mut self) {
142 self.rows.clear();
143 self.total_count = 0;
144 self.total_sum = 0.0;
145 self.total_variance = 0.0;
146 self.width = 0;
147 }
148
149 fn insert_bucket(&mut self, value: f64) {
152 if self.rows.is_empty() {
153 self.rows.push(Vec::new());
154 }
155 self.rows[0].push(Bucket {
156 total: value,
157 variance: value * value,
158 count: 1,
159 });
160 self.total_count += 1;
161 self.total_sum += value;
162 self.total_variance += value * value;
163 self.width += 1;
164 }
165
166 fn compress(&mut self) {
167 for i in 0..self.rows.len() {
168 if self.rows[i].len() > MAX_BUCKETS_PER_ROW + 1 {
169 let b2 = self.rows[i].remove(0);
172 let b1 = self.rows[i].remove(0);
173 let merged = Bucket {
174 total: b1.total + b2.total,
175 variance: b1.variance + b2.variance,
176 count: b1.count + b2.count,
177 };
178 if i + 1 >= self.rows.len() {
179 self.rows.push(Vec::new());
180 }
181 self.rows[i + 1].push(merged);
182 }
183 }
184 }
185
186 fn detect_drift(&mut self) -> bool {
187 let mut drift = false;
188 let mut n0: usize = 0;
192 let mut sum0: f64 = 0.0;
193
194 let n_rows = self.rows.len();
196 for i in (0..n_rows).rev() {
197 let row = &self.rows[i];
198 for j in 0..row.len() {
199 let bucket = &row[j];
200 n0 += bucket.count;
201 sum0 += bucket.total;
202
203 let n1 = self.width - n0;
204 if n0 < 5 || n1 < 5 {
205 continue;
206 }
207 let sum1 = self.total_sum - sum0;
208
209 let mean0 = sum0 / n0 as f64;
210 let mean1 = sum1 / n1 as f64;
211
212 let m = 1.0 / (1.0 / n0 as f64 + 1.0 / n1 as f64);
213 let epsilon = ((2.0 / m) * (4.0 * self.width as f64 / self.delta).ln()).sqrt();
214
215 if (mean0 - mean1).abs() >= epsilon {
216 self.remove_oldest(i, j);
219 drift = true;
220 break;
221 }
222 }
223 if drift {
224 break;
225 }
226 }
227 drift
228 }
229
230 fn remove_oldest(&mut self, row_idx: usize, bucket_idx: usize) {
231 let n_rows = self.rows.len();
236 for i in (row_idx + 1..n_rows).rev() {
237 for b in &self.rows[i] {
238 self.total_sum -= b.total;
239 self.total_variance -= b.variance;
240 self.width -= b.count;
241 }
242 self.rows[i].clear();
243 }
244
245 let to_remove = bucket_idx + 1;
247 for _ in 0..to_remove {
248 if self.rows[row_idx].is_empty() {
249 break;
250 }
251 let b = self.rows[row_idx].remove(0);
252 self.total_sum -= b.total;
253 self.total_variance -= b.variance;
254 self.width -= b.count;
255 }
256
257 while self.rows.last().is_some_and(std::vec::Vec::is_empty) {
259 self.rows.pop();
260 }
261 }
262}
263
264#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn no_drift_on_stable_stream() {
272 let mut adwin = Adwin::new(0.002);
273 for i in 0..500 {
274 let v = 10.0 + 0.1 * ((i * 7 + 3) % 11) as f64;
275 assert!(!adwin.add(v), "unexpected drift at step {i}");
276 }
277 assert!(adwin.window_size() > 400);
278 }
279
280 #[test]
281 fn detects_mean_shift() {
282 let mut adwin = Adwin::new(0.002);
283 for _ in 0..300 {
284 adwin.add(5.0);
285 }
286 let mut detected = false;
287 for _ in 0..100 {
288 if adwin.add(50.0) {
289 detected = true;
290 break;
291 }
292 }
293 assert!(detected, "should detect shift from 5 to 50");
294 }
295
296 #[test]
297 fn window_shrinks_after_drift() {
298 let mut adwin = Adwin::new(0.002);
299 for _ in 0..300 {
300 adwin.add(0.0);
301 }
302 let size_before = adwin.window_size();
303
304 for _ in 0..200 {
306 adwin.add(100.0);
307 }
308 assert!(
310 adwin.window_size() < size_before,
311 "window should shrink: was {size_before}, now {}",
312 adwin.window_size()
313 );
314 }
315
316 #[test]
317 fn mean_tracks_current_distribution() {
318 let mut adwin = Adwin::new(0.002);
319 for _ in 0..500 {
320 adwin.add(10.0);
321 }
322 assert!((adwin.mean() - 10.0).abs() < 0.01);
323
324 for _ in 0..500 {
326 adwin.add(20.0);
327 }
328 assert!(
330 adwin.mean() > 15.0,
331 "mean after shift should be near 20, got {}",
332 adwin.mean()
333 );
334 }
335
336 #[test]
337 fn reset_clears_state() {
338 let mut adwin = Adwin::new(0.002);
339 for _ in 0..100 {
340 adwin.add(5.0);
341 }
342 adwin.reset();
343 assert_eq!(adwin.window_size(), 0);
344 assert_eq!(adwin.mean(), 0.0);
345 }
346
347 #[test]
348 fn lower_delta_is_less_sensitive() {
349 let mut adwin_sensitive = Adwin::new(0.5);
351 let mut adwin_conservative = Adwin::new(0.0001);
352
353 let mut sensitive_detected_at = None;
354 let mut conservative_detected_at = None;
355
356 for _ in 0..200 {
357 adwin_sensitive.add(1.0);
358 adwin_conservative.add(1.0);
359 }
360
361 for i in 0..200 {
362 if sensitive_detected_at.is_none() && adwin_sensitive.add(5.0) {
363 sensitive_detected_at = Some(i);
364 } else if sensitive_detected_at.is_some() {
365 adwin_sensitive.add(5.0);
366 }
367 if conservative_detected_at.is_none() && adwin_conservative.add(5.0) {
368 conservative_detected_at = Some(i);
369 } else if conservative_detected_at.is_some() {
370 adwin_conservative.add(5.0);
371 }
372 }
373
374 if let (Some(s), Some(c)) = (sensitive_detected_at, conservative_detected_at) {
375 assert!(
376 s <= c,
377 "sensitive (δ=0.5) detected at {s}, conservative (δ=0.0001) at {c}"
378 );
379 }
380 }
381
382 #[test]
383 fn std_dev_reasonable() {
384 let mut adwin = Adwin::new(0.002);
385 for i in 0..1000 {
386 adwin.add((i % 10) as f64);
387 }
388 let sd = adwin.std_dev();
389 assert!(sd > 0.0 && sd < 5.0, "std_dev = {sd}");
390 }
391
392 #[test]
393 fn gradual_drift_eventually_detected() {
394 let mut adwin = Adwin::new(0.01);
395 for _ in 0..200 {
397 adwin.add(0.0);
398 }
399 let mut detected = false;
401 for i in 0..2000 {
402 let v = i as f64 * 0.1;
403 if adwin.add(v) {
404 detected = true;
405 break;
406 }
407 }
408 assert!(detected, "should detect gradual drift");
409 }
410
411 #[test]
412 fn nan_rejected_without_poisoning() {
413 let mut adwin = Adwin::new(0.002);
414 for _ in 0..50 {
415 adwin.add(1.0);
416 }
417 let mean_before = adwin.mean();
418
419 assert!(!adwin.add(f64::NAN));
421 assert_eq!(adwin.mean(), mean_before);
422 assert_eq!(adwin.window_size(), 50);
423 }
424
425 #[test]
426 fn infinity_rejected() {
427 let mut adwin = Adwin::new(0.002);
428 assert!(!adwin.add(f64::INFINITY));
429 assert!(!adwin.add(f64::NEG_INFINITY));
430 assert_eq!(adwin.window_size(), 0);
431 }
432}