Skip to main content

ftui_runtime/
bocpd.rs

1#![forbid(unsafe_code)]
2
3//! Bayesian Online Change-Point Detection (BOCPD) for Resize Regime Detection.
4//!
5//! This module implements BOCPD to replace heuristic threshold-based regime
6//! detection in the resize coalescer. It provides a principled Bayesian
7//! approach to detecting transitions between Steady and Burst regimes.
8//!
9//! # Mathematical Model
10//!
11//! ## Observation Model
12//!
13//! We observe inter-arrival times `x_t` between consecutive resize events.
14//! The observation model conditions on the current regime:
15//!
16//! ```text
17//! Steady regime: x_t ~ Exponential(λ_steady)
18//!                where λ_steady = 1/μ_steady, μ_steady ≈ 200ms (slow events)
19//!
20//! Burst regime:  x_t ~ Exponential(λ_burst)
21//!                where λ_burst = 1/μ_burst, μ_burst ≈ 20ms (rapid events)
22//! ```
23//!
24//! The likelihood for observation x under each regime:
25//!
26//! ```text
27//! P(x | steady) = λ_steady × exp(-λ_steady × x)
28//! P(x | burst)  = λ_burst × exp(-λ_burst × x)
29//! ```
30//!
31//! ## Run-Length Model
32//!
33//! BOCPD maintains a distribution over run-lengths `r_t`, where r represents
34//! the number of observations since the last changepoint.
35//!
36//! The run-length posterior is recursively updated:
37//!
38//! ```text
39//! P(r_t = 0 | x_1:t) ∝ Σᵣ P(r_{t-1} = r | x_1:t-1) × H(r) × P(x_t | r)
40//! P(r_t = r+1 | x_1:t) ∝ P(r_{t-1} = r | x_1:t-1) × (1 - H(r)) × P(x_t | r)
41//! ```
42//!
43//! where H(r) is the hazard function (probability of changepoint at run-length r).
44//!
45//! ## Hazard Function
46//!
47//! We use a constant hazard model with geometric prior on run-length:
48//!
49//! ```text
50//! H(r) = 1/λ_hazard
51//!
52//! where λ_hazard is the expected run-length between changepoints.
53//! Default: λ_hazard = 50 (expect changepoint every ~50 observations)
54//! ```
55//!
56//! This implies:
57//! - P(changepoint at r) = (1 - 1/λ)^r × (1/λ)
58//! - E[run-length] = λ_hazard
59//!
60//! ## Run-Length Truncation
61//!
62//! To achieve O(K) complexity per update, we truncate the run-length
63//! distribution at maximum K:
64//!
65//! ```text
66//! K = 100 (default)
67//!
68//! For r ≥ K: P(r_t = r | x_1:t) is merged into P(r_t = K | x_1:t)
69//! ```
70//!
71//! This approximation is accurate when K >> λ_hazard, since most mass
72//! concentrates on recent run-lengths.
73//!
74//! ## Regime Posterior
75//!
76//! We maintain a separate regime indicator:
77//!
78//! ```text
79//! Regime detection via likelihood ratio:
80//!
81//! LR_t = P(x_1:t | burst) / P(x_1:t | steady)
82//!
83//! P(burst | x_1:t) = LR_t × P(burst) / (LR_t × P(burst) + P(steady))
84//!
85//! where P(burst) = 0.2 (prior probability of burst regime)
86//! ```
87//!
88//! The regime posterior is integrated over all run-lengths:
89//!
90//! ```text
91//! P(burst | x_1:t) = Σᵣ P(burst | r, x_1:t) × P(r | x_1:t)
92//! ```
93//!
94//! # Decision Rule
95//!
96//! The coalescing delay is selected based on the burst posterior:
97//!
98//! ```text
99//! Let p_burst = P(burst | x_1:t)
100//!
101//! If p_burst < 0.3:  delay = steady_delay_ms  (16ms, responsive)
102//! If p_burst > 0.7:  delay = burst_delay_ms   (40ms, coalescing)
103//! Otherwise:         delay = interpolate(16ms, 40ms, p_burst)
104//!
105//! Always respect hard_deadline_ms (100ms) regardless of regime.
106//! ```
107//!
108//! ## Log-Bayes Factor for Explainability
109//!
110//! We compute the log10 Bayes factor for each decision:
111//!
112//! ```text
113//! LBF = log10(P(x_t | burst) / P(x_t | steady))
114//!
115//! Interpretation:
116//! - LBF > 1:  Strong evidence for burst
117//! - LBF > 2:  Decisive evidence for burst
118//! - LBF < -1: Strong evidence for steady
119//! - LBF < -2: Decisive evidence for steady
120//! ```
121//!
122//! # Invariants
123//!
124//! 1. **Normalized posterior**: Σᵣ P(r_t = r) = 1 (up to numerical precision)
125//! 2. **Deterministic**: Same observation sequence → same posteriors
126//! 3. **Bounded complexity**: O(K) per observation update
127//! 4. **Bounded memory**: O(K) state vector
128//! 5. **Monotonic regime confidence**: p_burst increases with rapid events
129//!
130//! # Failure Modes
131//!
132//! | Condition | Behavior | Rationale |
133//! |-----------|----------|-----------|
134//! | x = 0 (instant event) | x = ε = 1ms | Avoid log(0) in likelihood |
135//! | x > 10s (very slow) | Clamp to 10s | Numerical stability |
136//! | All posterior mass at r=K | Normal operation | Truncation working |
137//! | λ_hazard = 0 | Use default λ=50 | Avoid division by zero |
138//! | K = 0 | Use default K=100 | Ensure valid state |
139//!
140//! # Configuration
141//!
142//! ```text
143//! BocpdConfig {
144//!     // Observation model
145//!     mu_steady_ms: 200.0,    // Expected inter-arrival in steady (ms)
146//!     mu_burst_ms: 20.0,      // Expected inter-arrival in burst (ms)
147//!
148//!     // Hazard function
149//!     hazard_lambda: 50.0,    // Expected run-length between changepoints
150//!
151//!     // Truncation
152//!     max_run_length: 100,    // K for O(K) complexity
153//!
154//!     // Decision thresholds
155//!     steady_threshold: 0.3,  // p_burst below this → steady
156//!     burst_threshold: 0.7,   // p_burst above this → burst
157//!
158//!     // Priors
159//!     burst_prior: 0.2,       // P(burst) a priori
160//! }
161//! ```
162//!
163//! # Performance
164//!
165//! - **Time complexity**: O(K) per observation
166//! - **Space complexity**: O(K) for run-length posterior
167//! - **Default K=100**: ~100 multiplications per resize event
168//! - **Suitable for**: Up to 1000 events/second without concern
169//!
170//! # References
171//!
172//! - Adams & MacKay (2007): "Bayesian Online Changepoint Detection"
173//! - The run-length truncation follows standard BOCPD practice
174//! - Hazard function choice is geometric (constant hazard)
175
176use std::fmt;
177use std::sync::atomic::{AtomicU64, Ordering};
178use web_time::{Duration, Instant};
179
180// =============================================================================
181// Metrics counters
182// =============================================================================
183
184/// Total number of change-points (regime transitions) detected by BOCPD.
185static BOCPD_CHANGE_POINTS_DETECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
186
187/// Read the global count of BOCPD change-points detected.
188pub fn bocpd_change_points_detected_total() -> u64 {
189    BOCPD_CHANGE_POINTS_DETECTED_TOTAL.load(Ordering::Relaxed)
190}
191
192// =============================================================================
193// Configuration
194// =============================================================================
195
196/// Configuration for the BOCPD regime detector.
197#[derive(Debug, Clone)]
198pub struct BocpdConfig {
199    /// Expected inter-arrival time in steady regime (ms).
200    /// Longer values indicate slower, more spaced events.
201    /// Default: 200.0 ms
202    pub mu_steady_ms: f64,
203
204    /// Expected inter-arrival time in burst regime (ms).
205    /// Shorter values indicate rapid, clustered events.
206    /// Default: 20.0 ms
207    pub mu_burst_ms: f64,
208
209    /// Expected run-length between changepoints (hazard parameter).
210    /// Higher values mean changepoints are expected less frequently.
211    /// Default: 50.0
212    pub hazard_lambda: f64,
213
214    /// Maximum run-length for truncation (K).
215    /// Controls complexity: O(K) per update.
216    /// Default: 100
217    pub max_run_length: usize,
218
219    /// Threshold below which we classify as Steady.
220    /// If P(burst) < steady_threshold → Steady regime.
221    /// Default: 0.3
222    pub steady_threshold: f64,
223
224    /// Threshold above which we classify as Burst.
225    /// If P(burst) > burst_threshold → Burst regime.
226    /// Default: 0.7
227    pub burst_threshold: f64,
228
229    /// Prior probability of burst regime.
230    /// Used to initialize the regime posterior.
231    /// Default: 0.2
232    pub burst_prior: f64,
233
234    /// Minimum observation value (ms) to avoid log(0).
235    /// Default: 1.0 ms
236    pub min_observation_ms: f64,
237
238    /// Maximum observation value (ms) for numerical stability.
239    /// Default: 10000.0 ms (10 seconds)
240    pub max_observation_ms: f64,
241
242    /// Enable evidence logging.
243    /// Default: false
244    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    /// Create a configuration tuned for responsive UI.
266    ///
267    /// Lower thresholds for faster regime detection.
268    #[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    /// Create a configuration tuned for aggressive coalescing.
281    ///
282    /// Higher thresholds to stay in burst mode longer.
283    #[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    /// Enable evidence logging.
297    #[must_use]
298    pub fn with_logging(mut self, enabled: bool) -> Self {
299        self.enable_logging = enabled;
300        self
301    }
302}
303
304// =============================================================================
305// Regime Enum
306// =============================================================================
307
308/// Detected regime from BOCPD analysis.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum BocpdRegime {
311    /// Low event rate, prioritize responsiveness.
312    #[default]
313    Steady,
314    /// High event rate, prioritize coalescing.
315    Burst,
316    /// Transitional state (P(burst) between thresholds).
317    Transitional,
318}
319
320impl BocpdRegime {
321    /// Stable string representation for logging.
322    #[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// =============================================================================
339// Evidence for Explainability
340// =============================================================================
341
342/// Evidence from a BOCPD update step.
343///
344/// Provides explainability for regime detection decisions.
345#[derive(Debug, Clone)]
346pub struct BocpdEvidence {
347    /// Posterior probability of burst regime.
348    pub p_burst: f64,
349
350    /// Log10 Bayes factor (positive favors burst).
351    pub log_bayes_factor: f64,
352
353    /// Current observation (inter-arrival time in ms).
354    pub observation_ms: f64,
355
356    /// Classified regime based on thresholds.
357    pub regime: BocpdRegime,
358
359    /// Likelihood under steady model.
360    pub likelihood_steady: f64,
361
362    /// Likelihood under burst model.
363    pub likelihood_burst: f64,
364
365    /// Expected run-length (mean of posterior).
366    pub expected_run_length: f64,
367
368    /// Run-length posterior variance.
369    pub run_length_variance: f64,
370
371    /// Run-length posterior mode (argmax).
372    pub run_length_mode: usize,
373
374    /// 95th percentile of run-length posterior.
375    pub run_length_p95: usize,
376
377    /// Tail mass at the truncation bucket (r = K).
378    pub run_length_tail_mass: f64,
379
380    /// Recommended delay based on current regime (ms), if provided.
381    pub recommended_delay_ms: Option<u64>,
382
383    /// Whether a hard deadline forced the decision, if provided.
384    pub hard_deadline_forced: Option<bool>,
385
386    /// Number of observations processed.
387    pub observation_count: u64,
388
389    /// Timestamp of this evidence.
390    pub timestamp: Instant,
391}
392
393impl BocpdEvidence {
394    /// Generate JSONL representation for logging.
395    #[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// =============================================================================
461// BOCPD Detector
462// =============================================================================
463
464/// Bayesian Online Change-Point Detection for regime classification.
465///
466/// Maintains a truncated run-length posterior and computes the
467/// probability of being in burst vs steady regime.
468#[derive(Debug, Clone)]
469pub struct BocpdDetector {
470    /// Configuration.
471    config: BocpdConfig,
472
473    /// Run-length posterior: P(r_t = r | x_1:t) for r in 0..=K.
474    /// Indexed by run-length, normalized to sum to 1.
475    run_length_posterior: Vec<f64>,
476
477    /// Current burst probability P(burst | x_1:t).
478    p_burst: f64,
479
480    /// Timestamp of last observation.
481    last_event_time: Option<Instant>,
482
483    /// Number of observations processed.
484    observation_count: u64,
485
486    /// Last evidence for inspection.
487    last_evidence: Option<BocpdEvidence>,
488
489    /// Previous regime for transition detection.
490    previous_regime: BocpdRegime,
491
492    /// Pre-computed rate parameters for efficiency.
493    lambda_steady: f64, // 1 / mu_steady_ms
494    lambda_burst: f64, // 1 / mu_burst_ms
495    hazard: f64,       // 1 / hazard_lambda
496}
497
498impl BocpdDetector {
499    /// Create a new BOCPD detector with the given configuration.
500    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        // Initialize uniform run-length posterior
538        let initial_prob = 1.0 / (k + 1) as f64;
539        let run_length_posterior = vec![initial_prob; k + 1];
540
541        // Pre-compute rate parameters
542        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    /// Create with default configuration.
561    pub fn with_defaults() -> Self {
562        Self::new(BocpdConfig::default())
563    }
564
565    /// Get current burst probability.
566    #[inline]
567    pub fn p_burst(&self) -> f64 {
568        self.p_burst
569    }
570
571    /// Get the run-length posterior distribution.
572    ///
573    /// Returns a slice where element `i` is `P(r_t = i | x_1:t)`.
574    /// The length is bounded by `max_run_length + 1`.
575    #[inline]
576    pub fn run_length_posterior(&self) -> &[f64] {
577        &self.run_length_posterior
578    }
579
580    /// Get current classified regime.
581    #[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    /// Get the expected run-length (mean of posterior).
593    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    /// Get the last evidence.
634    pub fn last_evidence(&self) -> Option<&BocpdEvidence> {
635        self.last_evidence.as_ref()
636    }
637
638    /// Update the last evidence with decision context (delay + deadline).
639    ///
640    /// Call this after a decision is made (e.g., in the coalescer) to
641    /// include chosen delay and hard-deadline forcing in JSONL logs.
642    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    /// Return the latest JSONL evidence entry if logging is enabled.
656    #[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    /// Return JSONL evidence with decision context applied.
665    #[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    /// Get observation count.
683    #[inline]
684    pub fn observation_count(&self) -> u64 {
685        self.observation_count
686    }
687
688    /// Get configuration.
689    pub fn config(&self) -> &BocpdConfig {
690        &self.config
691    }
692
693    /// Process a new resize event.
694    ///
695    /// Call this when a resize event occurs. Returns the classified regime.
696    /// Emits tracing span `bocpd.update` and logs regime transitions at INFO.
697    pub fn observe_event(&mut self, now: Instant) -> BocpdRegime {
698        // Compute inter-arrival time
699        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); // Default to steady-like on first event
708
709        // Clamp observation
710        let x = observation_ms
711            .max(self.config.min_observation_ms)
712            .min(self.config.max_observation_ms);
713
714        // Update posterior
715        self.update_posterior(x, now);
716
717        // Update last event time
718        self.last_event_time = Some(now);
719
720        let current_regime = self.regime();
721
722        // Compute span fields
723        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        // TRACING: span 'bocpd.update' with required fields
735        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        // DEBUG log for posterior update
745        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        // METRICS: histogram bocpd_run_length via tracing event
756        tracing::debug!(
757            target: "ftui.bocpd",
758            bocpd_run_length = %self.expected_run_length(),
759            "bocpd run length histogram"
760        );
761
762        // Detect regime transition
763        if current_regime != self.previous_regime {
764            // METRICS: counter bocpd_change_points_detected_total
765            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    /// Update the run-length posterior with a new observation.
783    fn update_posterior(&mut self, x: f64, now: Instant) {
784        self.observation_count += 1;
785
786        // Compute predictive likelihoods for each regime
787        let pred_steady = self.exponential_pdf(x, self.lambda_steady);
788        let pred_burst = self.exponential_pdf(x, self.lambda_burst);
789
790        // Compute log-likelihood ratio exactly to avoid underflow
791        let log_lr = self.lambda_burst.ln()
792            - self.lambda_burst * x
793            - (self.lambda_steady.ln() - self.lambda_steady * x);
794
795        // Compute Bayes factor for the observation (instantaneous)
796        let log_bf = log_lr * std::f64::consts::LOG10_E;
797
798        // ==== Update Run-Length Posteriors ====
799        // We maintain a single run-length distribution that marginalizes over regimes.
800        // P(r_t | x) ∝ Σ_regime P(x | regime) * P(r_t | regime) * P(regime)
801        //
802        // However, standard BOCPD assumes parameters are associated with run-length.
803        // Here, "regime" is a global latent variable, not per-run-segment.
804        // So we update the regime probability first, then weight the RL update.
805
806        // Update regime probability using a Hidden Markov Model (HMM) step
807        // 1. Transition prior: incorporate hazard rate (chance of switching regime)
808        let prior_burst =
809            self.p_burst * (1.0 - self.hazard) + self.config.burst_prior * self.hazard;
810
811        // 2. Bayesian update with new observation
812        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        // Clamp to prevent total float lock-in, but the transition prior does the heavy lifting now
817        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        // Update run-length distribution
828        // The predictive likelihood for RL update is the mixture of regimes:
829        // P(x | r) = P(burst) * P(x|burst) + P(steady) * P(x|steady)
830        // This makes the RL distribution track "time since change in event rate".
831        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        // Growth: P(r_t = r+1) ∝ P(r_{t-1} = r) * (1 - H(r)) * P(x | r)
837        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        // Merge at K (truncation)
843        new_posterior[k] += self.run_length_posterior[k] * (1.0 - self.hazard) * mixture_likelihood;
844
845        // Changepoint: P(r_t = 0) ∝ Σ P(r_{t-1}) * H * P(x | 0)
846        // Note: P(x | 0) is same mixture likelihood since we assume regime continuity across CP for now
847        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        // Normalize
855        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        // Store evidence
868        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    /// Compute exponential PDF: λ × exp(-λx).
889    #[inline]
890    fn exponential_pdf(&self, x: f64, lambda: f64) -> f64 {
891        lambda * (-lambda * x).exp()
892    }
893
894    /// Reset the detector to initial state.
895    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    /// Compute recommended coalesce delay based on current regime.
907    ///
908    /// Interpolates between steady_delay and burst_delay based on p_burst.
909    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            // Linear interpolation in transitional region
916            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// =============================================================================
931// Tests
932// =============================================================================
933
934#[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); // Default prior
951        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        // Simulate slow events (200ms apart) - should stay in steady
961        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        // Simulate rapid events (10ms apart) - should trigger burst
980        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        // Start slow (steady)
1002        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        // Then rapid events (burst)
1009        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        // Process some events
1038        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        // In steady regime (default)
1055        assert_eq!(detector.recommended_delay(16, 40), 16);
1056
1057        // Manually set to burst
1058        detector.p_burst = 0.9;
1059        assert_eq!(detector.recommended_delay(16, 40), 40);
1060
1061        // Transitional (interpolated)
1062        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        // Extreme rapid events
1111        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    // Property test: expected run-length is non-negative
1186    #[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    // ── Config presets ────────────────────────────────────────────
1199
1200    #[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    // ── BocpdRegime traits ────────────────────────────────────────
1230
1231    #[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    // ── BocpdEvidence ─────────────────────────────────────────────
1264
1265    #[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        // Before set_decision_context, these should be null
1318        assert!(jsonl.contains("\"delay_ms\":null"));
1319        assert!(jsonl.contains("\"forced_deadline\":null"));
1320    }
1321
1322    // ── Detector accessors ────────────────────────────────────────
1323
1324    #[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    // ── observe_event edge cases ──────────────────────────────────
1348
1349    #[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        // First event should use mu_steady_ms as observation
1355        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        // First event (baseline)
1367        detector.observe_event(start);
1368        let initial = detector.p_burst();
1369        // Rapid events (5ms apart)
1370        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        // Seed with some rapid events to raise p_burst
1385        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        // Now slow events (500ms apart)
1391        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    // ── burst-to-steady recovery ──────────────────────────────────
1403
1404    #[test]
1405    fn burst_to_steady_recovery() {
1406        let mut detector = BocpdDetector::with_defaults();
1407        let start = Instant::now();
1408        // Drive into burst with rapid events
1409        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        // Recover with slow events
1416        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    // ── set_decision_context ──────────────────────────────────────
1429
1430    #[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    // ── decision_log_jsonl ────────────────────────────────────────
1452
1453    #[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    // ── recommended_delay ─────────────────────────────────────────
1475
1476    #[test]
1477    fn recommended_delay_interpolation_in_transitional() {
1478        let mut detector = BocpdDetector::with_defaults();
1479        // Set p_burst to middle of transitional range
1480        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        // Default p_burst is 0.2, below steady_threshold of 0.3
1492        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    // ── run-length summary ────────────────────────────────────────
1503
1504    #[test]
1505    fn expected_run_length_initial_uniform() {
1506        let detector = BocpdDetector::with_defaults();
1507        let erl = detector.expected_run_length();
1508        // Uniform on 0..=100 → mean = 50
1509        assert!((erl - 50.0).abs() < 1.0);
1510    }
1511
1512    // ── evidence fields accuracy ──────────────────────────────────
1513
1514    #[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    // ── responsive vs default ─────────────────────────────────────
1540
1541    #[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        // Feed identical rapid events
1547        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        // Responsive should have higher burst probability (lower thresholds)
1553        // or at least detect burst regime sooner
1554        let d_regime = default_det.regime();
1555        let r_regime = responsive_det.regime();
1556        // If default is still transitional, responsive should be at least transitional or burst
1557        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    // ── reset behavior ────────────────────────────────────────────
1567
1568    #[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    // ── posterior normalization under stress ───────────────────────
1585
1586    #[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            // Alternate rapid and slow
1592            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    // ── BocpdConfig presets ─────────────────────────────────────────
1604
1605    #[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    // ── BocpdRegime ─────────────────────────────────────────────────
1635
1636    #[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    // ── BocpdDetector constructors ──────────────────────────────────
1663
1664    #[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        // Default max_run_length = 100, so K+1 = 101 elements
1686        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        // Uniform posterior over 0..=100 → mean = 50.0
1696        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    // ── set_decision_context ────────────────────────────────────────
1706
1707    #[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)); // steady default
1715        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        // No observe_event called, so no evidence
1722        det.set_decision_context(16, 40, true);
1723        assert!(det.last_evidence().is_none());
1724    }
1725
1726    // ── evidence_jsonl ──────────────────────────────────────────────
1727
1728    #[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        // No observe_event called
1746        assert!(det.decision_log_jsonl(16, 40, false).is_none());
1747    }
1748
1749    // ── BocpdEvidence Display ───────────────────────────────────────
1750
1751    #[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    // ── BocpdEvidence to_jsonl with optional fields ─────────────────
1768
1769    #[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    // ── recommended_delay edge cases ────────────────────────────────
1791
1792    #[test]
1793    fn recommended_delay_at_exact_thresholds() {
1794        let mut det = BocpdDetector::with_defaults();
1795        // At exactly steady_threshold (0.3) → transitional
1796        det.p_burst = 0.3;
1797        let delay = det.recommended_delay(16, 40);
1798        assert_eq!(delay, 16); // t = (0.3 - 0.3) / (0.7 - 0.3) = 0
1799
1800        // At exactly burst_threshold (0.7) → transitional
1801        det.p_burst = 0.7;
1802        let delay = det.recommended_delay(16, 40);
1803        assert_eq!(delay, 40); // t = (0.7 - 0.3) / (0.7 - 0.3) = 1
1804    }
1805
1806    #[test]
1807    fn recommended_delay_midpoint() {
1808        let mut det = BocpdDetector::with_defaults();
1809        det.p_burst = 0.5; // midpoint of [0.3, 0.7]
1810        let delay = det.recommended_delay(16, 40);
1811        assert_eq!(delay, 28); // 16 * 0.5 + 40 * 0.5 = 28
1812    }
1813
1814    // ── reset clears last_event_time ────────────────────────────────
1815
1816    #[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        // After reset, first event should use default mu_steady_ms
1828        let _ = det.observe_event(start + Duration::from_millis(100));
1829        assert_eq!(det.observation_count(), 1);
1830    }
1831
1832    // ── First event uses default inter-arrival ──────────────────────
1833
1834    #[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        // First event should use mu_steady_ms as default observation
1841        assert!((ev.observation_ms - 200.0).abs() < f64::EPSILON);
1842    }
1843
1844    // ── Observation clamping ────────────────────────────────────────
1845
1846    #[test]
1847    fn observation_clamped_to_bounds() {
1848        let mut det = BocpdDetector::with_defaults();
1849        let start = Instant::now();
1850        // First event (uses default, not clamped)
1851        det.observe_event(start);
1852        // Second event 0ms later → should be clamped to min_observation_ms
1853        det.observe_event(start);
1854        let ev = det.last_evidence().unwrap();
1855        assert!(ev.observation_ms >= det.config().min_observation_ms);
1856    }
1857
1858    // ── BocpdConfig clone/debug ─────────────────────────────────────
1859
1860    #[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    // ── Observability tests (bd-37a.3) ─────────────────────────
1887
1888    #[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        // Start steady
1895        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        // Drive into burst with rapid events
1901        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        // At least one transition should have occurred (steady→transitional or steady→burst).
1908        // The counter may have been incremented by other tests running concurrently,
1909        // so we check relative increments.
1910        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        // Initially steady
1922        assert_eq!(det.previous_regime, BocpdRegime::Steady);
1923
1924        // Steady events should keep previous_regime steady
1925        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        // Drive into burst
1937        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        // Steady events
1951        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}