1#![forbid(unsafe_code)]
2
3use std::fmt;
177use std::sync::atomic::{AtomicU64, Ordering};
178use web_time::{Duration, Instant};
179
180static BOCPD_CHANGE_POINTS_DETECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
186
187pub fn bocpd_change_points_detected_total() -> u64 {
189 BOCPD_CHANGE_POINTS_DETECTED_TOTAL.load(Ordering::Relaxed)
190}
191
192#[derive(Debug, Clone)]
198pub struct BocpdConfig {
199 pub mu_steady_ms: f64,
203
204 pub mu_burst_ms: f64,
208
209 pub hazard_lambda: f64,
213
214 pub max_run_length: usize,
218
219 pub steady_threshold: f64,
223
224 pub burst_threshold: f64,
228
229 pub burst_prior: f64,
233
234 pub min_observation_ms: f64,
237
238 pub max_observation_ms: f64,
241
242 pub enable_logging: bool,
245}
246
247impl Default for BocpdConfig {
248 fn default() -> Self {
249 Self {
250 mu_steady_ms: 200.0,
251 mu_burst_ms: 20.0,
252 hazard_lambda: 50.0,
253 max_run_length: 100,
254 steady_threshold: 0.3,
255 burst_threshold: 0.7,
256 burst_prior: 0.2,
257 min_observation_ms: 1.0,
258 max_observation_ms: 10000.0,
259 enable_logging: false,
260 }
261 }
262}
263
264impl BocpdConfig {
265 #[must_use]
269 pub fn responsive() -> Self {
270 Self {
271 mu_steady_ms: 150.0,
272 mu_burst_ms: 15.0,
273 hazard_lambda: 30.0,
274 steady_threshold: 0.25,
275 burst_threshold: 0.6,
276 ..Default::default()
277 }
278 }
279
280 #[must_use]
284 pub fn aggressive_coalesce() -> Self {
285 Self {
286 mu_steady_ms: 250.0,
287 mu_burst_ms: 25.0,
288 hazard_lambda: 80.0,
289 steady_threshold: 0.4,
290 burst_threshold: 0.8,
291 burst_prior: 0.3,
292 ..Default::default()
293 }
294 }
295
296 #[must_use]
298 pub fn with_logging(mut self, enabled: bool) -> Self {
299 self.enable_logging = enabled;
300 self
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum BocpdRegime {
311 #[default]
313 Steady,
314 Burst,
316 Transitional,
318}
319
320impl BocpdRegime {
321 #[must_use]
323 pub const fn as_str(self) -> &'static str {
324 match self {
325 Self::Steady => "steady",
326 Self::Burst => "burst",
327 Self::Transitional => "transitional",
328 }
329 }
330}
331
332impl fmt::Display for BocpdRegime {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 write!(f, "{}", self.as_str())
335 }
336}
337
338#[derive(Debug, Clone)]
346pub struct BocpdEvidence {
347 pub p_burst: f64,
349
350 pub log_bayes_factor: f64,
352
353 pub observation_ms: f64,
355
356 pub regime: BocpdRegime,
358
359 pub likelihood_steady: f64,
361
362 pub likelihood_burst: f64,
364
365 pub expected_run_length: f64,
367
368 pub run_length_variance: f64,
370
371 pub run_length_mode: usize,
373
374 pub run_length_p95: usize,
376
377 pub run_length_tail_mass: f64,
379
380 pub recommended_delay_ms: Option<u64>,
382
383 pub hard_deadline_forced: Option<bool>,
385
386 pub observation_count: u64,
388
389 pub timestamp: Instant,
391}
392
393impl BocpdEvidence {
394 #[must_use]
396 pub fn to_jsonl(&self) -> String {
397 const SCHEMA_VERSION: &str = "bocpd-v1";
398 let delay_ms = self
399 .recommended_delay_ms
400 .map(|v| v.to_string())
401 .unwrap_or_else(|| "null".to_string());
402 let forced = self
403 .hard_deadline_forced
404 .map(|v| v.to_string())
405 .unwrap_or_else(|| "null".to_string());
406 format!(
407 r#"{{"schema_version":"{}","event":"bocpd","p_burst":{:.4},"log_bf":{:.3},"obs_ms":{:.1},"regime":"{}","ll_steady":{:.6},"ll_burst":{:.6},"runlen_mean":{:.1},"runlen_var":{:.3},"runlen_mode":{},"runlen_p95":{},"runlen_tail":{:.4},"delay_ms":{},"forced_deadline":{},"n_obs":{}}}"#,
408 SCHEMA_VERSION,
409 self.p_burst,
410 self.log_bayes_factor,
411 self.observation_ms,
412 self.regime.as_str(),
413 self.likelihood_steady,
414 self.likelihood_burst,
415 self.expected_run_length,
416 self.run_length_variance,
417 self.run_length_mode,
418 self.run_length_p95,
419 self.run_length_tail_mass,
420 delay_ms,
421 forced,
422 self.observation_count,
423 )
424 }
425}
426
427impl fmt::Display for BocpdEvidence {
428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429 writeln!(f, "BOCPD Evidence:")?;
430 writeln!(
431 f,
432 " Regime: {} (P(burst) = {:.3})",
433 self.regime, self.p_burst
434 )?;
435 writeln!(
436 f,
437 " Log BF: {:+.3} (positive favors burst)",
438 self.log_bayes_factor
439 )?;
440 writeln!(f, " Observation: {:.1} ms", self.observation_ms)?;
441 writeln!(
442 f,
443 " Likelihoods: steady={:.6}, burst={:.6}",
444 self.likelihood_steady, self.likelihood_burst
445 )?;
446 writeln!(f, " E[run-length]: {:.1}", self.expected_run_length)?;
447 write!(f, " Observations: {}", self.observation_count)
448 }
449}
450
451#[derive(Debug, Clone, Copy)]
452struct RunLengthSummary {
453 mean: f64,
454 variance: f64,
455 mode: usize,
456 p95: usize,
457 tail_mass: f64,
458}
459
460#[derive(Debug, Clone)]
469pub struct BocpdDetector {
470 config: BocpdConfig,
472
473 run_length_posterior: Vec<f64>,
476
477 p_burst: f64,
479
480 last_event_time: Option<Instant>,
482
483 observation_count: u64,
485
486 last_evidence: Option<BocpdEvidence>,
488
489 previous_regime: BocpdRegime,
491
492 lambda_steady: f64, lambda_burst: f64, hazard: f64, }
497
498impl BocpdDetector {
499 pub fn new(config: BocpdConfig) -> Self {
501 let mut config = config;
502 config.max_run_length = config.max_run_length.max(1);
503 config.mu_steady_ms = config.mu_steady_ms.max(1.0);
504 config.mu_burst_ms = config.mu_burst_ms.max(1.0);
505 config.hazard_lambda = config.hazard_lambda.max(1.0);
506 config.min_observation_ms = if config.min_observation_ms.is_nan() {
507 0.1
508 } else {
509 config.min_observation_ms.max(0.1)
510 };
511 config.max_observation_ms = if config.max_observation_ms.is_nan() {
512 config.min_observation_ms
513 } else {
514 config.max_observation_ms.max(config.min_observation_ms)
515 };
516 config.steady_threshold = if config.steady_threshold.is_nan() {
517 0.3
518 } else {
519 config.steady_threshold.clamp(0.0, 1.0)
520 };
521 config.burst_threshold = if config.burst_threshold.is_nan() {
522 0.7
523 } else {
524 config.burst_threshold.clamp(0.0, 1.0)
525 };
526 if config.burst_threshold < config.steady_threshold {
527 std::mem::swap(&mut config.steady_threshold, &mut config.burst_threshold);
528 }
529 config.burst_prior = if config.burst_prior.is_nan() {
530 0.1
531 } else {
532 config.burst_prior.clamp(0.001, 0.999)
533 };
534
535 let k = config.max_run_length;
536
537 let initial_prob = 1.0 / (k + 1) as f64;
539 let run_length_posterior = vec![initial_prob; k + 1];
540
541 let lambda_steady = 1.0 / config.mu_steady_ms;
543 let lambda_burst = 1.0 / config.mu_burst_ms;
544 let hazard = 1.0 / config.hazard_lambda;
545
546 Self {
547 p_burst: config.burst_prior,
548 run_length_posterior,
549 last_event_time: None,
550 observation_count: 0,
551 last_evidence: None,
552 previous_regime: BocpdRegime::Steady,
553 lambda_steady,
554 lambda_burst,
555 hazard,
556 config,
557 }
558 }
559
560 pub fn with_defaults() -> Self {
562 Self::new(BocpdConfig::default())
563 }
564
565 #[inline]
567 pub fn p_burst(&self) -> f64 {
568 self.p_burst
569 }
570
571 #[inline]
576 pub fn run_length_posterior(&self) -> &[f64] {
577 &self.run_length_posterior
578 }
579
580 #[inline]
582 pub fn regime(&self) -> BocpdRegime {
583 if self.p_burst < self.config.steady_threshold {
584 BocpdRegime::Steady
585 } else if self.p_burst > self.config.burst_threshold {
586 BocpdRegime::Burst
587 } else {
588 BocpdRegime::Transitional
589 }
590 }
591
592 pub fn expected_run_length(&self) -> f64 {
594 self.run_length_posterior
595 .iter()
596 .enumerate()
597 .map(|(r, p)| r as f64 * p)
598 .sum()
599 }
600
601 fn run_length_summary(&self) -> RunLengthSummary {
602 let mean = self.expected_run_length();
603 let mut variance = 0.0;
604 let mut mode = 0;
605 let mut mode_p = -1.0;
606 let mut cumulative = 0.0;
607 let mut p95 = self.config.max_run_length;
608
609 for (r, p) in self.run_length_posterior.iter().enumerate() {
610 if *p > mode_p {
611 mode_p = *p;
612 mode = r;
613 }
614 let diff = r as f64 - mean;
615 variance += p * diff * diff;
616 if cumulative < 0.95 {
617 cumulative += p;
618 if cumulative >= 0.95 {
619 p95 = r;
620 }
621 }
622 }
623
624 RunLengthSummary {
625 mean,
626 variance,
627 mode,
628 p95,
629 tail_mass: self.run_length_posterior[self.config.max_run_length],
630 }
631 }
632
633 pub fn last_evidence(&self) -> Option<&BocpdEvidence> {
635 self.last_evidence.as_ref()
636 }
637
638 pub fn set_decision_context(
643 &mut self,
644 steady_delay_ms: u64,
645 burst_delay_ms: u64,
646 hard_deadline_forced: bool,
647 ) {
648 let recommended_delay = self.recommended_delay(steady_delay_ms, burst_delay_ms);
649 if let Some(ref mut evidence) = self.last_evidence {
650 evidence.recommended_delay_ms = Some(recommended_delay);
651 evidence.hard_deadline_forced = Some(hard_deadline_forced);
652 }
653 }
654
655 #[must_use]
657 pub fn evidence_jsonl(&self) -> Option<String> {
658 if !self.config.enable_logging {
659 return None;
660 }
661 self.last_evidence.as_ref().map(BocpdEvidence::to_jsonl)
662 }
663
664 #[must_use]
666 pub fn decision_log_jsonl(
667 &self,
668 steady_delay_ms: u64,
669 burst_delay_ms: u64,
670 hard_deadline_forced: bool,
671 ) -> Option<String> {
672 if !self.config.enable_logging {
673 return None;
674 }
675 let mut evidence = self.last_evidence.clone()?;
676 evidence.recommended_delay_ms =
677 Some(self.recommended_delay(steady_delay_ms, burst_delay_ms));
678 evidence.hard_deadline_forced = Some(hard_deadline_forced);
679 Some(evidence.to_jsonl())
680 }
681
682 #[inline]
684 pub fn observation_count(&self) -> u64 {
685 self.observation_count
686 }
687
688 pub fn config(&self) -> &BocpdConfig {
690 &self.config
691 }
692
693 pub fn observe_event(&mut self, now: Instant) -> BocpdRegime {
698 let observation_ms = self
700 .last_event_time
701 .map(|last| {
702 now.checked_duration_since(last)
703 .unwrap_or(Duration::ZERO)
704 .as_secs_f64()
705 * 1000.0
706 })
707 .unwrap_or(self.config.mu_steady_ms); let x = observation_ms
711 .max(self.config.min_observation_ms)
712 .min(self.config.max_observation_ms);
713
714 self.update_posterior(x, now);
716
717 self.last_event_time = Some(now);
719
720 let current_regime = self.regime();
721
722 let posterior_max = self
724 .run_length_posterior
725 .iter()
726 .copied()
727 .fold(0.0_f64, f64::max);
728 let change_point_probability = self.run_length_posterior[0];
729 let coalescing_active = matches!(
730 current_regime,
731 BocpdRegime::Burst | BocpdRegime::Transitional
732 );
733
734 let _span = tracing::debug_span!(
736 "bocpd.update",
737 run_length_posterior_max = %posterior_max,
738 change_point_probability = %change_point_probability,
739 coalescing_active = coalescing_active,
740 resize_count_in_window = self.observation_count,
741 )
742 .entered();
743
744 tracing::debug!(
746 target: "ftui.bocpd",
747 p_burst = %self.p_burst,
748 observation_ms = %x,
749 posterior_max = %posterior_max,
750 change_point_prob = %change_point_probability,
751 observation_count = self.observation_count,
752 "posterior update"
753 );
754
755 tracing::debug!(
757 target: "ftui.bocpd",
758 bocpd_run_length = %self.expected_run_length(),
759 "bocpd run length histogram"
760 );
761
762 if current_regime != self.previous_regime {
764 BOCPD_CHANGE_POINTS_DETECTED_TOTAL.fetch_add(1, Ordering::Relaxed);
766
767 tracing::info!(
768 target: "ftui.bocpd",
769 from_regime = %self.previous_regime.as_str(),
770 to_regime = %current_regime.as_str(),
771 p_burst = %self.p_burst,
772 observation_count = self.observation_count,
773 "regime transition detected"
774 );
775
776 self.previous_regime = current_regime;
777 }
778
779 current_regime
780 }
781
782 fn update_posterior(&mut self, x: f64, now: Instant) {
784 self.observation_count += 1;
785
786 let pred_steady = self.exponential_pdf(x, self.lambda_steady);
788 let pred_burst = self.exponential_pdf(x, self.lambda_burst);
789
790 let log_lr = self.lambda_burst.ln()
792 - self.lambda_burst * x
793 - (self.lambda_steady.ln() - self.lambda_steady * x);
794
795 let log_bf = log_lr * std::f64::consts::LOG10_E;
797
798 let prior_burst =
809 self.p_burst * (1.0 - self.hazard) + self.config.burst_prior * self.hazard;
810
811 let prior_odds = prior_burst / (1.0 - prior_burst).max(1e-10);
813 let likelihood_ratio = log_lr.exp();
814 let posterior_odds = prior_odds * likelihood_ratio;
815
816 let mut p_burst_raw = posterior_odds / (1.0 + posterior_odds);
818 if p_burst_raw.is_nan() {
819 p_burst_raw = if posterior_odds.is_infinite() {
820 1.0
821 } else {
822 0.5
823 };
824 }
825 self.p_burst = p_burst_raw.clamp(0.001, 0.999);
826
827 let mixture_likelihood = self.p_burst * pred_burst + (1.0 - self.p_burst) * pred_steady;
832
833 let k = self.config.max_run_length;
834 let mut new_posterior = vec![0.0; k + 1];
835
836 for r in 0..k {
838 let growth_prob = self.run_length_posterior[r] * (1.0 - self.hazard);
839 new_posterior[r + 1] += growth_prob * mixture_likelihood;
840 }
841
842 new_posterior[k] += self.run_length_posterior[k] * (1.0 - self.hazard) * mixture_likelihood;
844
845 let cp_prob: f64 = self
848 .run_length_posterior
849 .iter()
850 .map(|&p| p * self.hazard * mixture_likelihood)
851 .sum();
852 new_posterior[0] = cp_prob;
853
854 let total: f64 = new_posterior.iter().sum();
856 if total > 0.0 {
857 for p in &mut new_posterior {
858 *p /= total;
859 }
860 } else {
861 let uniform = 1.0 / (k + 1) as f64;
862 new_posterior.fill(uniform);
863 }
864
865 self.run_length_posterior = new_posterior;
866
867 let summary = self.run_length_summary();
869 self.last_evidence = Some(BocpdEvidence {
870 p_burst: self.p_burst,
871 log_bayes_factor: log_bf,
872 observation_ms: x,
873 regime: self.regime(),
874 likelihood_steady: pred_steady,
875 likelihood_burst: pred_burst,
876 expected_run_length: summary.mean,
877 run_length_variance: summary.variance,
878 run_length_mode: summary.mode,
879 run_length_p95: summary.p95,
880 run_length_tail_mass: summary.tail_mass,
881 recommended_delay_ms: None,
882 hard_deadline_forced: None,
883 observation_count: self.observation_count,
884 timestamp: now,
885 });
886 }
887
888 #[inline]
890 fn exponential_pdf(&self, x: f64, lambda: f64) -> f64 {
891 lambda * (-lambda * x).exp()
892 }
893
894 pub fn reset(&mut self) {
896 let k = self.config.max_run_length;
897 let initial_prob = 1.0 / (k + 1) as f64;
898 self.run_length_posterior = vec![initial_prob; k + 1];
899 self.p_burst = self.config.burst_prior;
900 self.last_event_time = None;
901 self.observation_count = 0;
902 self.last_evidence = None;
903 self.previous_regime = BocpdRegime::Steady;
904 }
905
906 pub fn recommended_delay(&self, steady_delay_ms: u64, burst_delay_ms: u64) -> u64 {
910 if self.p_burst < self.config.steady_threshold {
911 steady_delay_ms
912 } else if self.p_burst > self.config.burst_threshold {
913 burst_delay_ms
914 } else {
915 let denom = (self.config.burst_threshold - self.config.steady_threshold).max(1e-6);
917 let t = ((self.p_burst - self.config.steady_threshold) / denom).clamp(0.0, 1.0);
918 let delay = steady_delay_ms as f64 * (1.0 - t) + burst_delay_ms as f64 * t;
919 delay.round() as u64
920 }
921 }
922}
923
924impl Default for BocpdDetector {
925 fn default() -> Self {
926 Self::with_defaults()
927 }
928}
929
930#[cfg(test)]
935mod tests {
936 use super::*;
937 use std::time::Duration;
938
939 #[test]
940 fn test_default_config() {
941 let config = BocpdConfig::default();
942 assert!((config.mu_steady_ms - 200.0).abs() < 0.01);
943 assert!((config.mu_burst_ms - 20.0).abs() < 0.01);
944 assert_eq!(config.max_run_length, 100);
945 }
946
947 #[test]
948 fn test_initial_state() {
949 let detector = BocpdDetector::with_defaults();
950 assert!((detector.p_burst() - 0.2).abs() < 0.01); assert_eq!(detector.regime(), BocpdRegime::Steady);
952 assert_eq!(detector.observation_count(), 0);
953 }
954
955 #[test]
956 fn test_steady_detection() {
957 let mut detector = BocpdDetector::with_defaults();
958 let start = Instant::now();
959
960 for i in 0..10 {
962 let t = start + Duration::from_millis(200 * (i + 1));
963 detector.observe_event(t);
964 }
965
966 assert!(
967 detector.p_burst() < 0.5,
968 "p_burst={} should be low",
969 detector.p_burst()
970 );
971 assert_eq!(detector.regime(), BocpdRegime::Steady);
972 }
973
974 #[test]
975 fn test_burst_detection() {
976 let mut detector = BocpdDetector::with_defaults();
977 let start = Instant::now();
978
979 for i in 0..20 {
981 let t = start + Duration::from_millis(10 * (i + 1));
982 detector.observe_event(t);
983 }
984
985 assert!(
986 detector.p_burst() > 0.5,
987 "p_burst={} should be high",
988 detector.p_burst()
989 );
990 assert!(matches!(
991 detector.regime(),
992 BocpdRegime::Burst | BocpdRegime::Transitional
993 ));
994 }
995
996 #[test]
997 fn test_regime_transition() {
998 let mut detector = BocpdDetector::with_defaults();
999 let start = Instant::now();
1000
1001 for i in 0..5 {
1003 let t = start + Duration::from_millis(200 * (i + 1));
1004 detector.observe_event(t);
1005 }
1006 let initial_p_burst = detector.p_burst();
1007
1008 let burst_start = start + Duration::from_millis(1000);
1010 for i in 0..20 {
1011 let t = burst_start + Duration::from_millis(10 * (i + 1));
1012 detector.observe_event(t);
1013 }
1014
1015 assert!(
1016 detector.p_burst() > initial_p_burst,
1017 "p_burst should increase during burst"
1018 );
1019 }
1020
1021 #[test]
1022 fn test_evidence_stored() {
1023 let mut detector = BocpdDetector::with_defaults();
1024 let t = Instant::now();
1025 detector.observe_event(t);
1026
1027 let evidence = detector.last_evidence().expect("Evidence should be stored");
1028 assert_eq!(evidence.observation_count, 1);
1029 assert!(evidence.log_bayes_factor.is_finite());
1030 }
1031
1032 #[test]
1033 fn test_reset() {
1034 let mut detector = BocpdDetector::with_defaults();
1035 let start = Instant::now();
1036
1037 for i in 0..10 {
1039 let t = start + Duration::from_millis(10 * (i + 1));
1040 detector.observe_event(t);
1041 }
1042
1043 detector.reset();
1044
1045 assert!((detector.p_burst() - 0.2).abs() < 0.01);
1046 assert_eq!(detector.observation_count(), 0);
1047 assert!(detector.last_evidence().is_none());
1048 }
1049
1050 #[test]
1051 fn test_recommended_delay() {
1052 let mut detector = BocpdDetector::with_defaults();
1053
1054 assert_eq!(detector.recommended_delay(16, 40), 16);
1056
1057 detector.p_burst = 0.9;
1059 assert_eq!(detector.recommended_delay(16, 40), 40);
1060
1061 detector.p_burst = 0.5;
1063 let delay = detector.recommended_delay(16, 40);
1064 assert!(
1065 delay > 16 && delay < 40,
1066 "delay={} should be interpolated",
1067 delay
1068 );
1069 }
1070
1071 #[test]
1072 fn test_deterministic() {
1073 let mut det1 = BocpdDetector::with_defaults();
1074 let mut det2 = BocpdDetector::with_defaults();
1075 let start = Instant::now();
1076
1077 for i in 0..10 {
1078 let t = start + Duration::from_millis(15 * (i + 1));
1079 det1.observe_event(t);
1080 det2.observe_event(t);
1081 }
1082
1083 assert!((det1.p_burst() - det2.p_burst()).abs() < 1e-10);
1084 assert_eq!(det1.regime(), det2.regime());
1085 }
1086
1087 #[test]
1088 fn test_posterior_normalized() {
1089 let mut detector = BocpdDetector::with_defaults();
1090 let start = Instant::now();
1091
1092 for i in 0..20 {
1093 let t = start + Duration::from_millis(25 * (i + 1));
1094 detector.observe_event(t);
1095
1096 let sum: f64 = detector.run_length_posterior.iter().sum();
1097 assert!(
1098 (sum - 1.0).abs() < 1e-6,
1099 "Posterior not normalized: sum={}",
1100 sum
1101 );
1102 }
1103 }
1104
1105 #[test]
1106 fn test_p_burst_bounded() {
1107 let mut detector = BocpdDetector::with_defaults();
1108 let start = Instant::now();
1109
1110 for i in 0..100 {
1112 let t = start + Duration::from_millis(i + 1);
1113 detector.observe_event(t);
1114 assert!(detector.p_burst() >= 0.0 && detector.p_burst() <= 1.0);
1115 }
1116 }
1117
1118 #[test]
1119 fn config_sanitization_clamps_thresholds_and_priors() {
1120 let config = BocpdConfig {
1121 steady_threshold: 0.9,
1122 burst_threshold: 0.1,
1123 burst_prior: 2.0,
1124 max_run_length: 0,
1125 mu_steady_ms: 0.0,
1126 mu_burst_ms: 0.0,
1127 hazard_lambda: 0.0,
1128 min_observation_ms: 0.0,
1129 max_observation_ms: 0.0,
1130 ..Default::default()
1131 };
1132
1133 let detector = BocpdDetector::new(config);
1134 let cfg = detector.config();
1135
1136 assert!(
1137 cfg.steady_threshold <= cfg.burst_threshold,
1138 "thresholds should be ordered after sanitization"
1139 );
1140 assert_eq!(cfg.max_run_length, 1);
1141 assert!(cfg.mu_steady_ms >= 1.0);
1142 assert!(cfg.mu_burst_ms >= 1.0);
1143 assert!(cfg.hazard_lambda >= 1.0);
1144 assert!(cfg.min_observation_ms >= 0.1);
1145 assert!(cfg.max_observation_ms >= cfg.min_observation_ms);
1146 assert!(
1147 (0.0..=1.0).contains(&detector.p_burst()),
1148 "p_burst should be clamped into [0,1]"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_jsonl_output() {
1154 let mut detector = BocpdDetector::with_defaults();
1155 let t = Instant::now();
1156 detector.observe_event(t);
1157 detector.config.enable_logging = true;
1158
1159 let jsonl = detector
1160 .decision_log_jsonl(16, 40, false)
1161 .expect("jsonl should be emitted when enabled");
1162
1163 assert!(jsonl.contains("bocpd-v1"));
1164 assert!(jsonl.contains("p_burst"));
1165 assert!(jsonl.contains("regime"));
1166 assert!(jsonl.contains("runlen_mean"));
1167 assert!(jsonl.contains("runlen_mode"));
1168 assert!(jsonl.contains("runlen_p95"));
1169 assert!(jsonl.contains("delay_ms"));
1170 assert!(jsonl.contains("forced_deadline"));
1171 }
1172
1173 #[test]
1174 fn evidence_jsonl_respects_config() {
1175 let mut detector = BocpdDetector::with_defaults();
1176 let t = Instant::now();
1177 detector.observe_event(t);
1178
1179 assert!(detector.evidence_jsonl().is_none());
1180
1181 detector.config.enable_logging = true;
1182 assert!(detector.evidence_jsonl().is_some());
1183 }
1184
1185 #[test]
1187 fn prop_expected_runlen_non_negative() {
1188 let mut detector = BocpdDetector::with_defaults();
1189 let start = Instant::now();
1190
1191 for i in 0..50 {
1192 let t = start + Duration::from_millis((i % 30 + 5) * (i + 1));
1193 detector.observe_event(t);
1194 assert!(detector.expected_run_length() >= 0.0);
1195 }
1196 }
1197
1198 #[test]
1201 fn responsive_config_values() {
1202 let cfg = BocpdConfig::responsive();
1203 assert!((cfg.mu_steady_ms - 150.0).abs() < f64::EPSILON);
1204 assert!((cfg.mu_burst_ms - 15.0).abs() < f64::EPSILON);
1205 assert!((cfg.hazard_lambda - 30.0).abs() < f64::EPSILON);
1206 assert!((cfg.steady_threshold - 0.25).abs() < f64::EPSILON);
1207 assert!((cfg.burst_threshold - 0.6).abs() < f64::EPSILON);
1208 }
1209
1210 #[test]
1211 fn aggressive_coalesce_config_values() {
1212 let cfg = BocpdConfig::aggressive_coalesce();
1213 assert!((cfg.mu_steady_ms - 250.0).abs() < f64::EPSILON);
1214 assert!((cfg.mu_burst_ms - 25.0).abs() < f64::EPSILON);
1215 assert!((cfg.hazard_lambda - 80.0).abs() < f64::EPSILON);
1216 assert!((cfg.steady_threshold - 0.4).abs() < f64::EPSILON);
1217 assert!((cfg.burst_threshold - 0.8).abs() < f64::EPSILON);
1218 assert!((cfg.burst_prior - 0.3).abs() < f64::EPSILON);
1219 }
1220
1221 #[test]
1222 fn with_logging_builder() {
1223 let cfg = BocpdConfig::default().with_logging(true);
1224 assert!(cfg.enable_logging);
1225 let cfg2 = cfg.with_logging(false);
1226 assert!(!cfg2.enable_logging);
1227 }
1228
1229 #[test]
1232 fn regime_as_str_values() {
1233 assert_eq!(BocpdRegime::Steady.as_str(), "steady");
1234 assert_eq!(BocpdRegime::Burst.as_str(), "burst");
1235 assert_eq!(BocpdRegime::Transitional.as_str(), "transitional");
1236 }
1237
1238 #[test]
1239 fn regime_display_matches_as_str() {
1240 for regime in [
1241 BocpdRegime::Steady,
1242 BocpdRegime::Burst,
1243 BocpdRegime::Transitional,
1244 ] {
1245 assert_eq!(format!("{regime}"), regime.as_str());
1246 }
1247 }
1248
1249 #[test]
1250 fn regime_default_is_steady() {
1251 assert_eq!(BocpdRegime::default(), BocpdRegime::Steady);
1252 }
1253
1254 #[test]
1255 fn regime_copy() {
1256 let r = BocpdRegime::Burst;
1257 let r2 = r;
1258 assert_eq!(r, r2);
1259 let r3 = r;
1260 assert_eq!(r, r3);
1261 }
1262
1263 #[test]
1266 fn evidence_to_jsonl_has_all_fields() {
1267 let mut detector = BocpdDetector::with_defaults();
1268 let t = Instant::now();
1269 detector.observe_event(t);
1270 let evidence = detector.last_evidence().unwrap();
1271 let jsonl = evidence.to_jsonl();
1272
1273 for key in [
1274 "schema_version",
1275 "bocpd-v1",
1276 "p_burst",
1277 "log_bf",
1278 "obs_ms",
1279 "regime",
1280 "ll_steady",
1281 "ll_burst",
1282 "runlen_mean",
1283 "runlen_var",
1284 "runlen_mode",
1285 "runlen_p95",
1286 "runlen_tail",
1287 "delay_ms",
1288 "forced_deadline",
1289 "n_obs",
1290 ] {
1291 assert!(jsonl.contains(key), "missing field {key} in {jsonl}");
1292 }
1293 }
1294
1295 #[test]
1296 fn evidence_display_contains_regime_and_pburst() {
1297 let mut detector = BocpdDetector::with_defaults();
1298 let t = Instant::now();
1299 detector.observe_event(t);
1300 let evidence = detector.last_evidence().unwrap();
1301 let display = format!("{evidence}");
1302 assert!(display.contains("BOCPD Evidence:"));
1303 assert!(display.contains("Regime:"));
1304 assert!(display.contains("P(burst)"));
1305 assert!(display.contains("Log BF:"));
1306 assert!(display.contains("Observation:"));
1307 assert!(display.contains("Observations:"));
1308 }
1309
1310 #[test]
1311 fn evidence_null_optionals_in_jsonl() {
1312 let mut detector = BocpdDetector::with_defaults();
1313 let t = Instant::now();
1314 detector.observe_event(t);
1315 let evidence = detector.last_evidence().unwrap();
1316 let jsonl = evidence.to_jsonl();
1317 assert!(jsonl.contains("\"delay_ms\":null"));
1319 assert!(jsonl.contains("\"forced_deadline\":null"));
1320 }
1321
1322 #[test]
1325 fn initial_detector_state() {
1326 let detector = BocpdDetector::with_defaults();
1327 assert!((detector.p_burst() - 0.2).abs() < 0.01);
1328 assert_eq!(detector.observation_count(), 0);
1329 assert!(detector.last_evidence().is_none());
1330 assert_eq!(detector.regime(), BocpdRegime::Steady);
1331 }
1332
1333 #[test]
1334 fn run_length_posterior_sums_to_one() {
1335 let detector = BocpdDetector::with_defaults();
1336 let sum: f64 = detector.run_length_posterior().iter().sum();
1337 assert!((sum - 1.0).abs() < 1e-10);
1338 }
1339
1340 #[test]
1341 fn config_accessor_returns_config() {
1342 let cfg = BocpdConfig::responsive();
1343 let detector = BocpdDetector::new(cfg);
1344 assert!((detector.config().mu_steady_ms - 150.0).abs() < f64::EPSILON);
1345 }
1346
1347 #[test]
1350 fn first_event_uses_steady_default() {
1351 let mut detector = BocpdDetector::with_defaults();
1352 let t = Instant::now();
1353 detector.observe_event(t);
1354 let evidence = detector.last_evidence().unwrap();
1356 assert!(
1357 (evidence.observation_ms - 200.0).abs() < 1.0,
1358 "first observation should be ~mu_steady_ms"
1359 );
1360 }
1361
1362 #[test]
1363 fn rapid_events_increase_pburst() {
1364 let mut detector = BocpdDetector::with_defaults();
1365 let start = Instant::now();
1366 detector.observe_event(start);
1368 let initial = detector.p_burst();
1369 for i in 1..20 {
1371 let t = start + Duration::from_millis(5 * i);
1372 detector.observe_event(t);
1373 }
1374 assert!(
1375 detector.p_burst() > initial,
1376 "p_burst should increase with rapid events"
1377 );
1378 }
1379
1380 #[test]
1381 fn slow_events_decrease_pburst() {
1382 let mut detector = BocpdDetector::with_defaults();
1383 let start = Instant::now();
1384 for i in 0..10 {
1386 let t = start + Duration::from_millis(5 * (i + 1));
1387 detector.observe_event(t);
1388 }
1389 let after_burst = detector.p_burst();
1390 let slow_start = start + Duration::from_millis(50);
1392 for i in 0..20 {
1393 let t = slow_start + Duration::from_millis(500 * (i + 1));
1394 detector.observe_event(t);
1395 }
1396 assert!(
1397 detector.p_burst() < after_burst,
1398 "p_burst should decrease with slow events"
1399 );
1400 }
1401
1402 #[test]
1405 fn burst_to_steady_recovery() {
1406 let mut detector = BocpdDetector::with_defaults();
1407 let start = Instant::now();
1408 for i in 0..30 {
1410 let t = start + Duration::from_millis(5 * (i + 1));
1411 detector.observe_event(t);
1412 }
1413 let burst_p = detector.p_burst();
1414 assert!(burst_p > 0.5, "should be in burst, got p={burst_p}");
1415 let slow_start = start + Duration::from_millis(150);
1417 for i in 0..30 {
1418 let t = slow_start + Duration::from_millis(200 * (i + 1));
1419 detector.observe_event(t);
1420 }
1421 let steady_p = detector.p_burst();
1422 assert!(
1423 steady_p < burst_p,
1424 "p_burst should decrease during recovery"
1425 );
1426 }
1427
1428 #[test]
1431 fn set_decision_context_populates_evidence() {
1432 let mut detector = BocpdDetector::with_defaults();
1433 let t = Instant::now();
1434 detector.observe_event(t);
1435 detector.set_decision_context(16, 40, false);
1436 let evidence = detector.last_evidence().unwrap();
1437 assert!(evidence.recommended_delay_ms.is_some());
1438 assert_eq!(evidence.hard_deadline_forced, Some(false));
1439 }
1440
1441 #[test]
1442 fn set_decision_context_forced_deadline() {
1443 let mut detector = BocpdDetector::with_defaults();
1444 let t = Instant::now();
1445 detector.observe_event(t);
1446 detector.set_decision_context(16, 40, true);
1447 let evidence = detector.last_evidence().unwrap();
1448 assert_eq!(evidence.hard_deadline_forced, Some(true));
1449 }
1450
1451 #[test]
1454 fn decision_log_jsonl_none_when_logging_disabled() {
1455 let mut detector = BocpdDetector::with_defaults();
1456 let t = Instant::now();
1457 detector.observe_event(t);
1458 assert!(detector.decision_log_jsonl(16, 40, false).is_none());
1459 }
1460
1461 #[test]
1462 fn decision_log_jsonl_has_delay_when_logging_enabled() {
1463 let mut detector = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1464 let t = Instant::now();
1465 detector.observe_event(t);
1466 let jsonl = detector
1467 .decision_log_jsonl(16, 40, true)
1468 .expect("should emit when logging enabled");
1469 assert!(jsonl.contains("\"delay_ms\":"));
1470 assert!(!jsonl.contains("\"delay_ms\":null"));
1471 assert!(jsonl.contains("\"forced_deadline\":true"));
1472 }
1473
1474 #[test]
1477 fn recommended_delay_interpolation_in_transitional() {
1478 let mut detector = BocpdDetector::with_defaults();
1479 detector.p_burst = 0.5;
1481 let delay = detector.recommended_delay(16, 40);
1482 assert!(
1483 delay > 16 && delay < 40,
1484 "transitional delay={delay} should be interpolated"
1485 );
1486 }
1487
1488 #[test]
1489 fn recommended_delay_steady_when_low_pburst() {
1490 let detector = BocpdDetector::with_defaults();
1491 assert_eq!(detector.recommended_delay(16, 40), 16);
1493 }
1494
1495 #[test]
1496 fn recommended_delay_burst_when_high_pburst() {
1497 let mut detector = BocpdDetector::with_defaults();
1498 detector.p_burst = 0.9;
1499 assert_eq!(detector.recommended_delay(16, 40), 40);
1500 }
1501
1502 #[test]
1505 fn expected_run_length_initial_uniform() {
1506 let detector = BocpdDetector::with_defaults();
1507 let erl = detector.expected_run_length();
1508 assert!((erl - 50.0).abs() < 1.0);
1510 }
1511
1512 #[test]
1515 fn evidence_observation_count_matches_events() {
1516 let mut detector = BocpdDetector::with_defaults();
1517 let start = Instant::now();
1518 for i in 0..7 {
1519 let t = start + Duration::from_millis(20 * (i + 1));
1520 detector.observe_event(t);
1521 }
1522 let evidence = detector.last_evidence().unwrap();
1523 assert_eq!(evidence.observation_count, 7);
1524 }
1525
1526 #[test]
1527 fn evidence_likelihoods_are_positive() {
1528 let mut detector = BocpdDetector::with_defaults();
1529 let start = Instant::now();
1530 for i in 0..5 {
1531 let t = start + Duration::from_millis(50 * (i + 1));
1532 detector.observe_event(t);
1533 }
1534 let evidence = detector.last_evidence().unwrap();
1535 assert!(evidence.likelihood_steady > 0.0);
1536 assert!(evidence.likelihood_burst > 0.0);
1537 }
1538
1539 #[test]
1542 fn responsive_detects_burst_faster() {
1543 let start = Instant::now();
1544 let mut default_det = BocpdDetector::with_defaults();
1545 let mut responsive_det = BocpdDetector::new(BocpdConfig::responsive());
1546 for i in 0..15 {
1548 let t = start + Duration::from_millis(5 * (i + 1));
1549 default_det.observe_event(t);
1550 responsive_det.observe_event(t);
1551 }
1552 let d_regime = default_det.regime();
1555 let r_regime = responsive_det.regime();
1556 if d_regime == BocpdRegime::Steady {
1558 assert_ne!(
1559 r_regime,
1560 BocpdRegime::Steady,
1561 "responsive should not be steady when default is"
1562 );
1563 }
1564 }
1565
1566 #[test]
1569 fn reset_restores_initial_state() {
1570 let mut detector = BocpdDetector::with_defaults();
1571 let start = Instant::now();
1572 for i in 0..20 {
1573 let t = start + Duration::from_millis(5 * (i + 1));
1574 detector.observe_event(t);
1575 }
1576 assert!(detector.p_burst() > 0.5);
1577 detector.reset();
1578 assert!((detector.p_burst() - 0.2).abs() < 0.01);
1579 assert_eq!(detector.observation_count(), 0);
1580 assert!(detector.last_evidence().is_none());
1581 assert!(detector.last_event_time.is_none());
1582 }
1583
1584 #[test]
1587 fn posterior_stays_normalized_under_alternating_traffic() {
1588 let mut detector = BocpdDetector::with_defaults();
1589 let start = Instant::now();
1590 for i in 0..100 {
1591 let gap = if i % 2 == 0 { 5 } else { 300 };
1593 let t = start + Duration::from_millis(gap * (i + 1));
1594 detector.observe_event(t);
1595 let sum: f64 = detector.run_length_posterior().iter().sum();
1596 assert!(
1597 (sum - 1.0).abs() < 1e-6,
1598 "posterior not normalized at step {i}: sum={sum}"
1599 );
1600 }
1601 }
1602
1603 #[test]
1606 fn responsive_config_values_dup() {
1607 let config = BocpdConfig::responsive();
1608 assert!((config.mu_steady_ms - 150.0).abs() < f64::EPSILON);
1609 assert!((config.mu_burst_ms - 15.0).abs() < f64::EPSILON);
1610 assert!((config.hazard_lambda - 30.0).abs() < f64::EPSILON);
1611 assert!((config.steady_threshold - 0.25).abs() < f64::EPSILON);
1612 assert!((config.burst_threshold - 0.6).abs() < f64::EPSILON);
1613 }
1614
1615 #[test]
1616 fn aggressive_coalesce_config_values_dup() {
1617 let config = BocpdConfig::aggressive_coalesce();
1618 assert!((config.mu_steady_ms - 250.0).abs() < f64::EPSILON);
1619 assert!((config.mu_burst_ms - 25.0).abs() < f64::EPSILON);
1620 assert!((config.hazard_lambda - 80.0).abs() < f64::EPSILON);
1621 assert!((config.steady_threshold - 0.4).abs() < f64::EPSILON);
1622 assert!((config.burst_threshold - 0.8).abs() < f64::EPSILON);
1623 assert!((config.burst_prior - 0.3).abs() < f64::EPSILON);
1624 }
1625
1626 #[test]
1627 fn with_logging_builder_dup() {
1628 let config = BocpdConfig::default().with_logging(true);
1629 assert!(config.enable_logging);
1630 let config2 = config.with_logging(false);
1631 assert!(!config2.enable_logging);
1632 }
1633
1634 #[test]
1637 fn regime_as_str() {
1638 assert_eq!(BocpdRegime::Steady.as_str(), "steady");
1639 assert_eq!(BocpdRegime::Burst.as_str(), "burst");
1640 assert_eq!(BocpdRegime::Transitional.as_str(), "transitional");
1641 }
1642
1643 #[test]
1644 fn regime_display() {
1645 assert_eq!(format!("{}", BocpdRegime::Steady), "steady");
1646 assert_eq!(format!("{}", BocpdRegime::Burst), "burst");
1647 assert_eq!(format!("{}", BocpdRegime::Transitional), "transitional");
1648 }
1649
1650 #[test]
1651 fn regime_default_is_steady_dup() {
1652 assert_eq!(BocpdRegime::default(), BocpdRegime::Steady);
1653 }
1654
1655 #[test]
1656 fn regime_clone_eq() {
1657 let r = BocpdRegime::Burst;
1658 assert_eq!(r, r.clone());
1659 assert_ne!(BocpdRegime::Steady, BocpdRegime::Burst);
1660 }
1661
1662 #[test]
1665 fn detector_default_impl() {
1666 let det = BocpdDetector::default();
1667 assert_eq!(det.regime(), BocpdRegime::Steady);
1668 assert_eq!(det.observation_count(), 0);
1669 }
1670
1671 #[test]
1672 fn detector_config_accessor() {
1673 let config = BocpdConfig {
1674 mu_steady_ms: 300.0,
1675 ..Default::default()
1676 };
1677 let det = BocpdDetector::new(config);
1678 assert!((det.config().mu_steady_ms - 300.0).abs() < f64::EPSILON);
1679 }
1680
1681 #[test]
1682 fn detector_run_length_posterior_accessor() {
1683 let det = BocpdDetector::with_defaults();
1684 let posterior = det.run_length_posterior();
1685 assert_eq!(posterior.len(), 101);
1687 let sum: f64 = posterior.iter().sum();
1688 assert!((sum - 1.0).abs() < 1e-10);
1689 }
1690
1691 #[test]
1692 fn detector_expected_run_length_initial() {
1693 let det = BocpdDetector::with_defaults();
1694 let erl = det.expected_run_length();
1695 assert!((erl - 50.0).abs() < 1e-10);
1697 }
1698
1699 #[test]
1700 fn detector_last_evidence_initially_none() {
1701 let det = BocpdDetector::with_defaults();
1702 assert!(det.last_evidence().is_none());
1703 }
1704
1705 #[test]
1708 fn set_decision_context_updates_evidence() {
1709 let mut det = BocpdDetector::with_defaults();
1710 det.observe_event(Instant::now());
1711 det.set_decision_context(16, 40, false);
1712
1713 let ev = det.last_evidence().unwrap();
1714 assert_eq!(ev.recommended_delay_ms, Some(16)); assert_eq!(ev.hard_deadline_forced, Some(false));
1716 }
1717
1718 #[test]
1719 fn set_decision_context_noop_without_evidence() {
1720 let mut det = BocpdDetector::with_defaults();
1721 det.set_decision_context(16, 40, true);
1723 assert!(det.last_evidence().is_none());
1724 }
1725
1726 #[test]
1729 fn evidence_jsonl_none_when_disabled() {
1730 let mut det = BocpdDetector::with_defaults();
1731 det.observe_event(Instant::now());
1732 assert!(det.evidence_jsonl().is_none());
1733 }
1734
1735 #[test]
1736 fn decision_log_jsonl_none_when_disabled() {
1737 let mut det = BocpdDetector::with_defaults();
1738 det.observe_event(Instant::now());
1739 assert!(det.decision_log_jsonl(16, 40, false).is_none());
1740 }
1741
1742 #[test]
1743 fn decision_log_jsonl_none_without_evidence() {
1744 let det = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1745 assert!(det.decision_log_jsonl(16, 40, false).is_none());
1747 }
1748
1749 #[test]
1752 fn evidence_display_format() {
1753 let mut det = BocpdDetector::with_defaults();
1754 det.observe_event(Instant::now());
1755 let ev = det.last_evidence().unwrap();
1756 let display = format!("{}", ev);
1757 assert!(display.contains("BOCPD Evidence:"));
1758 assert!(display.contains("Regime:"));
1759 assert!(display.contains("P(burst)"));
1760 assert!(display.contains("Log BF:"));
1761 assert!(display.contains("Observation:"));
1762 assert!(display.contains("Likelihoods:"));
1763 assert!(display.contains("E[run-length]:"));
1764 assert!(display.contains("Observations:"));
1765 }
1766
1767 #[test]
1770 fn evidence_jsonl_with_decision_context() {
1771 let mut det = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1772 det.observe_event(Instant::now());
1773 det.set_decision_context(16, 40, true);
1774
1775 let jsonl = det.evidence_jsonl().unwrap();
1776 assert!(jsonl.contains("\"delay_ms\":16"));
1777 assert!(jsonl.contains("\"forced_deadline\":true"));
1778 }
1779
1780 #[test]
1781 fn evidence_jsonl_null_optional_fields() {
1782 let mut det = BocpdDetector::new(BocpdConfig::default().with_logging(true));
1783 det.observe_event(Instant::now());
1784
1785 let jsonl = det.evidence_jsonl().unwrap();
1786 assert!(jsonl.contains("\"delay_ms\":null"));
1787 assert!(jsonl.contains("\"forced_deadline\":null"));
1788 }
1789
1790 #[test]
1793 fn recommended_delay_at_exact_thresholds() {
1794 let mut det = BocpdDetector::with_defaults();
1795 det.p_burst = 0.3;
1797 let delay = det.recommended_delay(16, 40);
1798 assert_eq!(delay, 16); det.p_burst = 0.7;
1802 let delay = det.recommended_delay(16, 40);
1803 assert_eq!(delay, 40); }
1805
1806 #[test]
1807 fn recommended_delay_midpoint() {
1808 let mut det = BocpdDetector::with_defaults();
1809 det.p_burst = 0.5; let delay = det.recommended_delay(16, 40);
1811 assert_eq!(delay, 28); }
1813
1814 #[test]
1817 fn reset_clears_last_event_time() {
1818 let mut det = BocpdDetector::with_defaults();
1819 let start = Instant::now();
1820 det.observe_event(start);
1821 det.observe_event(start + Duration::from_millis(10));
1822 assert_eq!(det.observation_count(), 2);
1823
1824 det.reset();
1825 assert_eq!(det.observation_count(), 0);
1826 assert!(det.last_evidence().is_none());
1827 let _ = det.observe_event(start + Duration::from_millis(100));
1829 assert_eq!(det.observation_count(), 1);
1830 }
1831
1832 #[test]
1835 fn first_event_uses_steady_default_dup() {
1836 let mut det = BocpdDetector::with_defaults();
1837 let t = Instant::now();
1838 det.observe_event(t);
1839 let ev = det.last_evidence().unwrap();
1840 assert!((ev.observation_ms - 200.0).abs() < f64::EPSILON);
1842 }
1843
1844 #[test]
1847 fn observation_clamped_to_bounds() {
1848 let mut det = BocpdDetector::with_defaults();
1849 let start = Instant::now();
1850 det.observe_event(start);
1852 det.observe_event(start);
1854 let ev = det.last_evidence().unwrap();
1855 assert!(ev.observation_ms >= det.config().min_observation_ms);
1856 }
1857
1858 #[test]
1861 fn config_clone_debug() {
1862 let config = BocpdConfig::default();
1863 let cloned = config.clone();
1864 assert!((cloned.mu_steady_ms - 200.0).abs() < f64::EPSILON);
1865 let dbg = format!("{:?}", config);
1866 assert!(dbg.contains("BocpdConfig"));
1867 }
1868
1869 #[test]
1870 fn detector_clone_debug() {
1871 let det = BocpdDetector::with_defaults();
1872 let cloned = det.clone();
1873 assert!((cloned.p_burst() - det.p_burst()).abs() < f64::EPSILON);
1874 let dbg = format!("{:?}", det);
1875 assert!(dbg.contains("BocpdDetector"));
1876 }
1877
1878 #[test]
1879 fn evidence_clone() {
1880 let mut det = BocpdDetector::with_defaults();
1881 det.observe_event(Instant::now());
1882 let ev = det.last_evidence().unwrap().clone();
1883 assert_eq!(ev.observation_count, 1);
1884 }
1885
1886 #[test]
1889 fn change_points_counter_increments_on_regime_transition() {
1890 let before = bocpd_change_points_detected_total();
1891 let mut det = BocpdDetector::with_defaults();
1892 let start = Instant::now();
1893
1894 for i in 0..5 {
1896 det.observe_event(start + Duration::from_millis(200 * (i + 1)));
1897 }
1898 let after_steady = bocpd_change_points_detected_total();
1899
1900 let burst_start = start + Duration::from_millis(1100);
1902 for i in 0..30 {
1903 det.observe_event(burst_start + Duration::from_millis(5 * (i + 1)));
1904 }
1905 let after_burst = bocpd_change_points_detected_total();
1906
1907 assert!(
1911 after_burst > before || after_burst > after_steady,
1912 "Expected change-point counter to increment: before={before}, after_steady={after_steady}, after_burst={after_burst}"
1913 );
1914 }
1915
1916 #[test]
1917 fn previous_regime_tracks_last_state() {
1918 let mut det = BocpdDetector::with_defaults();
1919 let start = Instant::now();
1920
1921 assert_eq!(det.previous_regime, BocpdRegime::Steady);
1923
1924 for i in 0..5 {
1926 det.observe_event(start + Duration::from_millis(200 * (i + 1)));
1927 }
1928 assert_eq!(det.previous_regime, BocpdRegime::Steady);
1929 }
1930
1931 #[test]
1932 fn reset_clears_previous_regime() {
1933 let mut det = BocpdDetector::with_defaults();
1934 let start = Instant::now();
1935
1936 for i in 0..30 {
1938 det.observe_event(start + Duration::from_millis(5 * (i + 1)));
1939 }
1940
1941 det.reset();
1942 assert_eq!(det.previous_regime, BocpdRegime::Steady);
1943 }
1944
1945 #[test]
1946 fn observe_event_returns_correct_regime() {
1947 let mut det = BocpdDetector::with_defaults();
1948 let start = Instant::now();
1949
1950 for i in 0..10 {
1952 let regime = det.observe_event(start + Duration::from_millis(200 * (i + 1)));
1953 assert_eq!(regime, det.regime());
1954 }
1955 }
1956}