1#![forbid(unsafe_code)]
49
50use std::collections::VecDeque;
51
52const SIGMA_MIN: f64 = 1e-9;
54
55const E_MIN: f64 = 1e-100;
57
58const E_MAX: f64 = 1e100;
60
61const DEFAULT_ALPHA: f64 = 0.05;
63
64const DEFAULT_LAMBDA: f64 = 0.5;
66
67#[derive(Debug, Clone)]
73pub struct FlakeConfig {
74 pub alpha: f64,
77
78 pub lambda: f64,
81
82 pub sigma: f64,
85
86 pub variance_window: usize,
89
90 pub min_observations: usize,
93
94 pub enable_logging: bool,
96
97 pub threshold: Option<f64>,
100}
101
102impl Default for FlakeConfig {
103 fn default() -> Self {
104 Self {
105 alpha: DEFAULT_ALPHA,
106 lambda: DEFAULT_LAMBDA,
107 sigma: 1.0,
108 variance_window: 50,
109 min_observations: 3,
110 enable_logging: false,
111 threshold: None,
112 }
113 }
114}
115
116impl FlakeConfig {
117 #[must_use]
119 pub fn new(alpha: f64) -> Self {
120 Self {
121 alpha: alpha.clamp(1e-10, 0.5),
122 ..Default::default()
123 }
124 }
125
126 #[must_use]
128 pub fn with_lambda(mut self, lambda: f64) -> Self {
129 self.lambda = lambda.clamp(0.01, 2.0);
130 self
131 }
132
133 #[must_use]
135 pub fn with_sigma(mut self, sigma: f64) -> Self {
136 self.sigma = sigma.max(SIGMA_MIN);
137 self
138 }
139
140 #[must_use]
142 pub fn with_variance_window(mut self, window: usize) -> Self {
143 self.variance_window = window;
144 self
145 }
146
147 #[must_use]
149 pub fn with_min_observations(mut self, min: usize) -> Self {
150 self.min_observations = min.max(1);
151 self
152 }
153
154 #[must_use]
156 pub fn with_logging(mut self, enabled: bool) -> Self {
157 self.enable_logging = enabled;
158 self
159 }
160
161 #[must_use]
163 pub fn threshold(&self) -> f64 {
164 self.threshold.unwrap_or(1.0 / self.alpha)
165 }
166}
167
168#[derive(Debug, Clone, PartialEq)]
174pub struct FlakeDecision {
175 pub is_flaky: bool,
177 pub e_value: f64,
179 pub threshold: f64,
181 pub observation_count: usize,
183 pub variance_estimate: f64,
185 pub warmed_up: bool,
187}
188
189impl FlakeDecision {
190 #[must_use]
192 pub fn should_fail(&self) -> bool {
193 self.is_flaky && self.warmed_up
194 }
195}
196
197#[derive(Debug, Clone)]
199pub struct EvidenceLog {
200 pub observation_idx: usize,
202 pub residual: f64,
204 pub e_increment: f64,
206 pub e_cumulative: f64,
208 pub variance: f64,
210 pub decision: bool,
212}
213
214impl EvidenceLog {
215 #[must_use]
217 pub fn to_jsonl(&self) -> String {
218 format!(
219 r#"{{"idx":{},"residual":{:.6},"e_inc":{:.6},"e_cum":{:.6},"var":{:.6},"decision":{}}}"#,
220 self.observation_idx,
221 self.residual,
222 self.e_increment,
223 self.e_cumulative,
224 self.variance,
225 self.decision
226 )
227 }
228}
229
230#[derive(Debug, Clone)]
236pub struct FlakeDetector {
237 config: FlakeConfig,
239 e_cumulative: f64,
241 observation_count: usize,
243 variance_window: VecDeque<f64>,
245 online_mean: f64,
247 online_m2: f64,
249 evidence_log: Vec<EvidenceLog>,
251}
252
253impl FlakeDetector {
254 #[must_use]
256 pub fn new(config: FlakeConfig) -> Self {
257 let capacity = if config.variance_window > 0 {
258 config.variance_window
259 } else {
260 1
261 };
262 Self {
263 config,
264 e_cumulative: 1.0, observation_count: 0,
266 variance_window: VecDeque::with_capacity(capacity),
267 online_mean: 0.0,
268 online_m2: 0.0,
269 evidence_log: Vec::new(),
270 }
271 }
272
273 pub fn observe(&mut self, residual: f64) -> FlakeDecision {
278 self.observation_count += 1;
279
280 self.update_variance(residual);
282 let sigma = self.current_sigma();
283
284 let lambda = self.config.lambda;
286 let exponent = lambda * residual - (lambda * lambda * sigma * sigma) / 2.0;
287 let e_increment = exponent.exp().clamp(E_MIN, E_MAX);
288
289 self.e_cumulative = (self.e_cumulative * e_increment).clamp(E_MIN, E_MAX);
291
292 let threshold = self.config.threshold();
294 let is_flaky = self.e_cumulative > threshold;
295 let warmed_up = self.observation_count >= self.config.min_observations;
296
297 if self.config.enable_logging {
299 self.evidence_log.push(EvidenceLog {
300 observation_idx: self.observation_count,
301 residual,
302 e_increment,
303 e_cumulative: self.e_cumulative,
304 variance: sigma * sigma,
305 decision: is_flaky && warmed_up,
306 });
307 }
308
309 FlakeDecision {
310 is_flaky,
311 e_value: self.e_cumulative,
312 threshold,
313 observation_count: self.observation_count,
314 variance_estimate: sigma * sigma,
315 warmed_up,
316 }
317 }
318
319 pub fn observe_batch(&mut self, residuals: &[f64]) -> FlakeDecision {
321 let mut decision = FlakeDecision {
322 is_flaky: false,
323 e_value: self.e_cumulative,
324 threshold: self.config.threshold(),
325 observation_count: self.observation_count,
326 variance_estimate: self.current_sigma().powi(2),
327 warmed_up: false,
328 };
329
330 for &r in residuals {
331 decision = self.observe(r);
332 if decision.should_fail() {
333 break; }
335 }
336
337 decision
338 }
339
340 pub fn reset(&mut self) {
342 self.e_cumulative = 1.0;
343 self.observation_count = 0;
344 self.variance_window.clear();
345 self.online_mean = 0.0;
346 self.online_m2 = 0.0;
347 self.evidence_log.clear();
348 }
349
350 #[must_use]
352 pub fn e_value(&self) -> f64 {
353 self.e_cumulative
354 }
355
356 #[must_use]
358 pub fn observation_count(&self) -> usize {
359 self.observation_count
360 }
361
362 #[must_use]
364 pub fn is_warmed_up(&self) -> bool {
365 self.observation_count >= self.config.min_observations
366 }
367
368 #[must_use]
370 pub fn evidence_log(&self) -> &[EvidenceLog] {
371 &self.evidence_log
372 }
373
374 #[must_use]
376 pub fn evidence_to_jsonl(&self) -> String {
377 self.evidence_log
378 .iter()
379 .map(|e| e.to_jsonl())
380 .collect::<Vec<_>>()
381 .join("\n")
382 }
383
384 #[must_use]
386 pub fn current_sigma(&self) -> f64 {
387 if self.config.variance_window == 0 || self.observation_count < 2 {
388 return self.config.sigma.max(SIGMA_MIN);
389 }
390
391 let variance = if self.observation_count > 1 {
393 self.online_m2 / (self.observation_count - 1) as f64
394 } else {
395 self.config.sigma * self.config.sigma
396 };
397
398 variance.sqrt().max(SIGMA_MIN)
399 }
400
401 fn update_variance(&mut self, residual: f64) {
403 if self.config.variance_window == 0 {
404 return;
405 }
406
407 let n = self.observation_count as f64;
409 let delta = residual - self.online_mean;
410 self.online_mean += delta / n;
411 let delta2 = residual - self.online_mean;
412 self.online_m2 += delta * delta2;
413
414 if self.variance_window.len() >= self.config.variance_window {
416 self.variance_window.pop_front();
417 }
418 self.variance_window.push_back(residual);
419 }
420
421 #[must_use]
423 pub fn config(&self) -> &FlakeConfig {
424 &self.config
425 }
426}
427
428impl Default for FlakeDetector {
429 fn default() -> Self {
430 Self::new(FlakeConfig::default())
431 }
432}
433
434#[derive(Debug, Clone)]
440pub struct FlakeSummary {
441 pub total_observations: usize,
443 pub final_e_value: f64,
445 pub is_flaky: bool,
447 pub first_flaky_at: Option<usize>,
449 pub max_e_value: f64,
451 pub threshold: f64,
453}
454
455impl FlakeDetector {
456 #[must_use]
458 pub fn summary(&self) -> FlakeSummary {
459 let first_flaky_at = self
460 .evidence_log
461 .iter()
462 .find(|e| e.decision)
463 .map(|e| e.observation_idx);
464
465 let max_e_value = self
466 .evidence_log
467 .iter()
468 .map(|e| e.e_cumulative)
469 .fold(1.0_f64, f64::max);
470
471 FlakeSummary {
472 total_observations: self.observation_count,
473 final_e_value: self.e_cumulative,
474 is_flaky: self.e_cumulative > self.config.threshold(),
475 first_flaky_at,
476 max_e_value,
477 threshold: self.config.threshold(),
478 }
479 }
480}
481
482#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn unit_eprocess_threshold() {
492 let config = FlakeConfig::new(0.05).with_min_observations(1);
494 let mut detector = FlakeDetector::new(config);
495
496 for _ in 0..20 {
498 let decision = detector.observe(3.0); if decision.should_fail() {
500 assert!(decision.e_value > decision.threshold);
502 return;
503 }
504 }
505
506 let decision = detector.observe(0.0);
508 assert!(
509 decision.e_value > decision.threshold || !decision.is_flaky,
510 "Should either have triggered or not be flaky"
511 );
512 }
513
514 #[test]
515 fn unit_eprocess_nonnegative() {
516 let mut detector = FlakeDetector::default();
518
519 let residuals = [-5.0, -2.0, 0.0, 2.0, 5.0, -10.0, 10.0];
521 for r in residuals {
522 let decision = detector.observe(r);
523 assert!(
524 decision.e_value > 0.0,
525 "E-value must be positive, got {}",
526 decision.e_value
527 );
528 }
529 }
530
531 #[test]
532 fn unit_optional_stopping() {
533 let config = FlakeConfig::new(0.05)
535 .with_lambda(0.3)
536 .with_min_observations(1)
537 .with_logging(true);
538 let mut detector = FlakeDetector::new(config);
539
540 let stable_residuals: Vec<f64> = (0..100).map(|i| (i as f64 * 0.1).sin() * 0.1).collect();
542
543 let decision = detector.observe_batch(&stable_residuals);
544
545 assert!(
548 decision.e_value <= decision.threshold * 2.0 || !decision.should_fail(),
549 "Stable run should rarely trigger flakiness"
550 );
551 }
552
553 #[test]
554 fn unit_stable_run_no_false_positives() {
555 let config = FlakeConfig::new(0.05)
557 .with_sigma(1.0)
558 .with_lambda(0.5)
559 .with_min_observations(3);
560 let mut detector = FlakeDetector::new(config);
561
562 for _ in 0..50 {
564 let decision = detector.observe(0.0);
565 assert!(
568 !decision.should_fail(),
569 "Zero residuals should never trigger flakiness"
570 );
571 }
572 }
573
574 #[test]
575 fn unit_spike_detection() {
576 let config = FlakeConfig::new(0.05)
578 .with_sigma(1.0)
579 .with_lambda(0.5)
580 .with_min_observations(3)
581 .with_logging(true);
582 let mut detector = FlakeDetector::new(config);
583
584 for _ in 0..5 {
586 detector.observe(0.1);
587 }
588
589 let mut detected = false;
591 for _ in 0..20 {
592 let decision = detector.observe(5.0); if decision.should_fail() {
594 detected = true;
595 break;
596 }
597 }
598
599 assert!(detected, "Should detect sustained spike");
600 }
601
602 #[test]
603 fn unit_reset() {
604 let mut detector = FlakeDetector::default();
605 detector.observe(1.0);
606 detector.observe(2.0);
607
608 assert_eq!(detector.observation_count(), 2);
609
610 detector.reset();
611
612 assert_eq!(detector.observation_count(), 0);
613 assert!((detector.e_value() - 1.0).abs() < 1e-10);
614 }
615
616 #[test]
617 fn unit_variance_estimation() {
618 let config = FlakeConfig::default().with_variance_window(10);
619 let mut detector = FlakeDetector::new(config);
620
621 for _ in 0..20 {
623 detector.observe(1.0);
624 }
625
626 let sigma = detector.current_sigma();
628 assert!(
629 sigma < 0.1 || (sigma - 1.0).abs() < 0.5,
630 "Variance should converge"
631 );
632 }
633
634 #[test]
635 fn unit_evidence_log() {
636 let config = FlakeConfig::default()
637 .with_logging(true)
638 .with_min_observations(1);
639 let mut detector = FlakeDetector::new(config);
640
641 detector.observe(0.5);
642 detector.observe(1.0);
643 detector.observe(-0.5);
644
645 assert_eq!(detector.evidence_log().len(), 3);
646
647 let jsonl = detector.evidence_to_jsonl();
648 assert!(jsonl.contains("\"idx\":1"));
649 assert!(jsonl.contains("\"idx\":2"));
650 assert!(jsonl.contains("\"idx\":3"));
651 }
652
653 #[test]
654 fn unit_summary() {
655 let config = FlakeConfig::default()
656 .with_logging(true)
657 .with_min_observations(1);
658 let mut detector = FlakeDetector::new(config);
659
660 for _ in 0..10 {
661 detector.observe(0.1);
662 }
663
664 let summary = detector.summary();
665 assert_eq!(summary.total_observations, 10);
666 assert!(summary.final_e_value > 0.0);
667 assert!(summary.threshold > 0.0);
668 }
669
670 #[test]
671 fn unit_batch_observe() {
672 let config = FlakeConfig::default().with_min_observations(1);
673 let mut detector = FlakeDetector::new(config);
674
675 let residuals = vec![0.1, 0.2, 0.3, 0.4, 0.5];
676 let decision = detector.observe_batch(&residuals);
677
678 assert_eq!(decision.observation_count, 5);
679 }
680
681 #[test]
682 fn unit_config_builder() {
683 let config = FlakeConfig::new(0.01)
684 .with_lambda(0.3)
685 .with_sigma(2.0)
686 .with_variance_window(100)
687 .with_min_observations(5)
688 .with_logging(true);
689
690 assert!((config.alpha - 0.01).abs() < 1e-10);
691 assert!((config.lambda - 0.3).abs() < 1e-10);
692 assert!((config.sigma - 2.0).abs() < 1e-10);
693 assert_eq!(config.variance_window, 100);
694 assert_eq!(config.min_observations, 5);
695 assert!(config.enable_logging);
696 assert!((config.threshold() - 100.0).abs() < 1e-10);
697 }
698
699 #[test]
700 fn unit_numerical_stability() {
701 let mut detector = FlakeDetector::default();
702
703 for _ in 0..10 {
705 let decision = detector.observe(1000.0);
706 assert!(decision.e_value.is_finite());
707 assert!(decision.e_value > 0.0);
708 }
709
710 detector.reset();
711
712 for _ in 0..10 {
714 let decision = detector.observe(-1000.0);
715 assert!(decision.e_value.is_finite());
716 assert!(decision.e_value > 0.0);
717 }
718 }
719}