Skip to main content

ftui_runtime/
resize_coalescer.rs

1//! Adaptive resize stream coalescer.
2//!
3//! This module implements the resize coalescing behavior specified in
4//! `docs/spec/resize-scheduler.md`. It provides:
5//!
6//! - **Latest-wins semantics**: Only the final size in a burst is rendered
7//! - **Bounded latency**: Hard deadline guarantees render within max wait
8//! - **Regime awareness**: Adapts behavior between steady and burst modes
9//! - **Decision logging**: JSONL-compatible evidence for each decision
10//!
11//! # Usage
12//!
13//! ```ignore
14//! use ftui_runtime::resize_coalescer::{ResizeCoalescer, CoalescerConfig};
15//!
16//! let config = CoalescerConfig::default();
17//! let mut coalescer = ResizeCoalescer::new(config, (80, 24));
18//!
19//! // On resize event
20//! let action = coalescer.handle_resize(100, 40);
21//!
22//! // On tick (called each frame)
23//! let action = coalescer.tick();
24//! ```
25//!
26//! # Regime Detection
27//!
28//! The coalescer uses a simplified regime model with two states:
29//! - **Steady**: Single resize or slow sequence — prioritize responsiveness
30//! - **Burst**: Rapid resize events — prioritize coalescing to reduce work
31//!
32//! Regime transitions are detected via event rate tracking with hysteresis.
33//!
34//! # Invariants
35//!
36//! - **Latest-wins**: the final resize in a burst is never dropped.
37//! - **Bounded latency**: pending resizes apply within `hard_deadline_ms`.
38//! - **Deterministic**: identical event sequences yield identical decisions.
39//!
40//! # Failure Modes
41//!
42//! | Condition | Behavior | Rationale |
43//! |-----------|----------|-----------|
44//! | `hard_deadline_ms = 0` | Apply immediately | Avoids zero-latency stall |
45//! | `rate_window_size < 2` | `event_rate = 0` | No divide-by-zero in rate |
46//! | No pending size | Return `None` | Avoids spurious applies |
47//!
48//! # Decision Rule (Explainable)
49//!
50//! 1) If `time_since_render ≥ hard_deadline_ms`, **apply** (forced).
51//! 2) If `dt ≥ delay_ms`, **apply** when in **Steady** (or when BOCPD is
52//!    enabled). (`delay_ms` = steady/burst delay, or BOCPD posterior-interpolated
53//!    delay when enabled.)
54//! 3) If `event_rate ≥ burst_enter_rate`, switch to **Burst**.
55//! 4) If in **Burst** and `event_rate < burst_exit_rate` for `cooldown_frames`,
56//!    switch to **Steady**.
57//! 5) Otherwise, **coalesce** and optionally show a placeholder.
58
59#![forbid(unsafe_code)]
60
61use std::collections::VecDeque;
62use std::time::{Duration, Instant};
63
64use crate::bocpd::{BocpdConfig, BocpdDetector, BocpdRegime};
65use crate::evidence_sink::{EVIDENCE_SCHEMA_VERSION, EvidenceSink};
66use crate::terminal_writer::ScreenMode;
67
68/// FNV-1a 64-bit offset basis.
69const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
70/// FNV-1a 64-bit prime.
71const FNV_PRIME: u64 = 0x100000001b3;
72
73fn fnv_hash_bytes(hash: &mut u64, bytes: &[u8]) {
74    for byte in bytes {
75        *hash ^= *byte as u64;
76        *hash = hash.wrapping_mul(FNV_PRIME);
77    }
78}
79
80fn default_resize_run_id() -> String {
81    format!("resize-{}", std::process::id())
82}
83
84fn screen_mode_str(mode: ScreenMode) -> &'static str {
85    match mode {
86        ScreenMode::Inline { .. } => "inline",
87        ScreenMode::InlineAuto { .. } => "inline_auto",
88        ScreenMode::AltScreen => "altscreen",
89    }
90}
91
92#[inline]
93fn json_escape(value: &str) -> String {
94    let mut out = String::with_capacity(value.len());
95    for ch in value.chars() {
96        match ch {
97            '"' => out.push_str("\\\""),
98            '\\' => out.push_str("\\\\"),
99            '\n' => out.push_str("\\n"),
100            '\r' => out.push_str("\\r"),
101            '\t' => out.push_str("\\t"),
102            c if c.is_control() => {
103                use std::fmt::Write as _;
104                let _ = write!(out, "\\u{:04X}", c as u32);
105            }
106            _ => out.push(ch),
107        }
108    }
109    out
110}
111
112fn evidence_prefix(
113    run_id: &str,
114    screen_mode: ScreenMode,
115    cols: u16,
116    rows: u16,
117    event_idx: u64,
118) -> String {
119    format!(
120        r#""schema_version":"{}","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{}"#,
121        EVIDENCE_SCHEMA_VERSION,
122        json_escape(run_id),
123        event_idx,
124        screen_mode_str(screen_mode),
125        cols,
126        rows,
127    )
128}
129
130/// Configuration for the resize coalescer.
131#[derive(Debug, Clone)]
132pub struct CoalescerConfig {
133    /// Maximum coalesce delay in steady regime (ms).
134    /// In steady state, we want quick response.
135    pub steady_delay_ms: u64,
136
137    /// Maximum coalesce delay in burst regime (ms).
138    /// During bursts, we coalesce more aggressively.
139    pub burst_delay_ms: u64,
140
141    /// Hard deadline — always render within this time (ms).
142    /// Guarantees bounded worst-case latency.
143    pub hard_deadline_ms: u64,
144
145    /// Event rate threshold to enter burst mode (events/second).
146    pub burst_enter_rate: f64,
147
148    /// Event rate threshold to exit burst mode (events/second).
149    /// Lower than enter_rate for hysteresis.
150    pub burst_exit_rate: f64,
151
152    /// Number of frames to hold in burst mode after rate drops.
153    pub cooldown_frames: u32,
154
155    /// Window size for rate calculation (number of events).
156    pub rate_window_size: usize,
157
158    /// Enable decision logging (JSONL format).
159    pub enable_logging: bool,
160
161    /// Enable BOCPD (Bayesian Online Change-Point Detection) for regime detection.
162    ///
163    /// When enabled, the coalescer uses a Bayesian posterior over run-lengths to
164    /// detect regime changes (steady vs burst), replacing the simple rate threshold
165    /// heuristics. BOCPD provides:
166    /// - Principled uncertainty quantification via P(burst)
167    /// - Automatic adaptation without hand-tuned thresholds
168    /// - Evidence logging for decision explainability
169    ///
170    /// When disabled, falls back to rate threshold heuristics.
171    pub enable_bocpd: bool,
172
173    /// BOCPD configuration (used when `enable_bocpd` is true).
174    pub bocpd_config: Option<BocpdConfig>,
175}
176
177impl Default for CoalescerConfig {
178    fn default() -> Self {
179        Self {
180            steady_delay_ms: 16, // ~60fps responsiveness
181            burst_delay_ms: 40,  // Aggressive coalescing
182            hard_deadline_ms: 100,
183            burst_enter_rate: 10.0, // 10 events/sec to enter burst
184            burst_exit_rate: 5.0,   // 5 events/sec to exit burst
185            cooldown_frames: 3,
186            rate_window_size: 8,
187            enable_logging: false,
188            enable_bocpd: false,
189            bocpd_config: None,
190        }
191    }
192}
193
194impl CoalescerConfig {
195    /// Enable or disable decision logging.
196    #[must_use]
197    pub fn with_logging(mut self, enabled: bool) -> Self {
198        self.enable_logging = enabled;
199        self
200    }
201
202    /// Enable BOCPD-based regime detection with default configuration.
203    #[must_use]
204    pub fn with_bocpd(mut self) -> Self {
205        self.enable_bocpd = true;
206        self.bocpd_config = Some(BocpdConfig::default());
207        self
208    }
209
210    /// Enable BOCPD-based regime detection with custom configuration.
211    #[must_use]
212    pub fn with_bocpd_config(mut self, config: BocpdConfig) -> Self {
213        self.enable_bocpd = true;
214        self.bocpd_config = Some(config);
215        self
216    }
217
218    /// Serialize configuration to JSONL format.
219    #[must_use]
220    pub fn to_jsonl(
221        &self,
222        run_id: &str,
223        screen_mode: ScreenMode,
224        cols: u16,
225        rows: u16,
226        event_idx: u64,
227    ) -> String {
228        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
229        format!(
230            r#"{{{prefix},"event":"config","steady_delay_ms":{},"burst_delay_ms":{},"hard_deadline_ms":{},"burst_enter_rate":{:.3},"burst_exit_rate":{:.3},"cooldown_frames":{},"rate_window_size":{},"logging_enabled":{}}}"#,
231            self.steady_delay_ms,
232            self.burst_delay_ms,
233            self.hard_deadline_ms,
234            self.burst_enter_rate,
235            self.burst_exit_rate,
236            self.cooldown_frames,
237            self.rate_window_size,
238            self.enable_logging
239        )
240    }
241}
242
243/// Action returned by the coalescer.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum CoalesceAction {
246    /// No action needed.
247    None,
248
249    /// Show a placeholder/skeleton while coalescing.
250    ShowPlaceholder,
251
252    /// Apply the resize with the given dimensions.
253    ApplyResize {
254        width: u16,
255        height: u16,
256        /// Time spent coalescing.
257        coalesce_time: Duration,
258        /// Whether this was forced by hard deadline.
259        forced_by_deadline: bool,
260    },
261}
262
263/// Detected regime for resize events.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
265pub enum Regime {
266    /// Single resize or slow sequence.
267    #[default]
268    Steady,
269    /// Rapid resize events (storm).
270    Burst,
271}
272
273impl Regime {
274    /// Get the stable string representation.
275    #[must_use]
276    pub const fn as_str(self) -> &'static str {
277        match self {
278            Self::Steady => "steady",
279            Self::Burst => "burst",
280        }
281    }
282}
283
284/// Event emitted when a resize operation is applied (bd-bksf.6 stub).
285///
286/// Used by [`ResizeSlaMonitor`](crate::resize_sla::ResizeSlaMonitor) for latency tracking.
287#[derive(Debug, Clone)]
288pub struct ResizeAppliedEvent {
289    /// New terminal size after resize.
290    pub new_size: (u16, u16),
291    /// Previous terminal size.
292    pub old_size: (u16, u16),
293    /// Time elapsed from resize request to apply.
294    pub elapsed: Duration,
295    /// Whether the apply was forced (hard deadline).
296    pub forced: bool,
297}
298
299/// Event emitted when regime changes between Steady and Burst (bd-bksf.6 stub).
300///
301/// Used by SLA monitoring for regime-aware alerting.
302#[derive(Debug, Clone)]
303pub struct RegimeChangeEvent {
304    /// Previous regime.
305    pub from: Regime,
306    /// New regime.
307    pub to: Regime,
308    /// Event index when transition occurred.
309    pub event_idx: u64,
310}
311
312// =============================================================================
313// Evidence Ledger for Scheduler Decisions (bd-1rz0.27)
314// =============================================================================
315
316/// Evidence supporting a scheduler decision with Bayes factors.
317///
318/// This captures the mathematical reasoning behind coalesce/apply decisions,
319/// providing explainability for the regime-adaptive scheduler.
320///
321/// # Bayes Factor Interpretation
322///
323/// The `log_bayes_factor` represents log10(P(evidence|apply_now) / P(evidence|coalesce)):
324/// - Positive values favor immediate apply (respond quickly)
325/// - Negative values favor coalescing (wait for more events)
326/// - |LBF| > 1 is "strong" evidence, |LBF| > 2 is "decisive"
327///
328/// # Example
329///
330/// ```ignore
331/// let evidence = DecisionEvidence {
332///     log_bayes_factor: 1.5,  // Strong evidence to apply now
333///     regime_contribution: 0.8,
334///     timing_contribution: 0.5,
335///     rate_contribution: 0.2,
336///     explanation: "Steady regime with long idle interval".to_string(),
337/// };
338/// ```
339#[derive(Debug, Clone)]
340pub struct DecisionEvidence {
341    /// Log10 Bayes factor: positive favors apply, negative favors coalesce.
342    pub log_bayes_factor: f64,
343
344    /// Contribution from regime detection (Steady vs Burst).
345    pub regime_contribution: f64,
346
347    /// Contribution from timing (dt_ms, time since last render).
348    pub timing_contribution: f64,
349
350    /// Contribution from event rate.
351    pub rate_contribution: f64,
352
353    /// Human-readable explanation of the decision.
354    pub explanation: String,
355}
356
357impl DecisionEvidence {
358    /// Evidence strongly favoring immediate apply (Steady regime, long idle).
359    #[must_use]
360    pub fn favor_apply(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
361        let regime_contrib = if regime == Regime::Steady { 1.0 } else { -0.5 };
362        let timing_contrib = (dt_ms / 50.0).min(2.0); // Higher dt -> favor apply
363        let rate_contrib = if event_rate < 5.0 { 0.5 } else { -0.3 };
364
365        let lbf = regime_contrib + timing_contrib + rate_contrib;
366
367        Self {
368            log_bayes_factor: lbf,
369            regime_contribution: regime_contrib,
370            timing_contribution: timing_contrib,
371            rate_contribution: rate_contrib,
372            explanation: format!(
373                "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
374                regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
375            ),
376        }
377    }
378
379    /// Evidence favoring coalescing (Burst regime, high rate).
380    #[must_use]
381    pub fn favor_coalesce(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
382        let regime_contrib = if regime == Regime::Burst { 1.0 } else { -0.5 };
383        let timing_contrib = (20.0 / dt_ms.max(1.0)).min(2.0); // Lower dt -> favor coalesce
384        let rate_contrib = if event_rate > 10.0 { 0.5 } else { -0.3 };
385
386        let lbf = -(regime_contrib + timing_contrib + rate_contrib);
387
388        Self {
389            log_bayes_factor: lbf,
390            regime_contribution: regime_contrib,
391            timing_contribution: timing_contrib,
392            rate_contribution: rate_contrib,
393            explanation: format!(
394                "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
395                regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
396            ),
397        }
398    }
399
400    /// Evidence for a forced deadline decision.
401    #[must_use]
402    pub fn forced_deadline(deadline_ms: f64) -> Self {
403        Self {
404            log_bayes_factor: f64::INFINITY,
405            regime_contribution: 0.0,
406            timing_contribution: deadline_ms,
407            rate_contribution: 0.0,
408            explanation: format!("Forced by hard deadline ({:.1}ms)", deadline_ms),
409        }
410    }
411
412    /// Serialize to JSONL format.
413    #[must_use]
414    pub fn to_jsonl(
415        &self,
416        run_id: &str,
417        screen_mode: ScreenMode,
418        cols: u16,
419        rows: u16,
420        event_idx: u64,
421    ) -> String {
422        let lbf_str = if self.log_bayes_factor.is_infinite() {
423            "\"inf\"".to_string()
424        } else {
425            format!("{:.3}", self.log_bayes_factor)
426        };
427        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
428        format!(
429            r#"{{{prefix},"event":"decision_evidence","log_bayes_factor":{},"regime_contribution":{:.3},"timing_contribution":{:.3},"rate_contribution":{:.3},"explanation":"{}"}}"#,
430            lbf_str,
431            self.regime_contribution,
432            self.timing_contribution,
433            self.rate_contribution,
434            json_escape(&self.explanation)
435        )
436    }
437
438    /// Check if evidence strongly supports the action (|LBF| > 1).
439    #[must_use]
440    pub fn is_strong(&self) -> bool {
441        self.log_bayes_factor.abs() > 1.0
442    }
443
444    /// Check if evidence decisively supports the action (|LBF| > 2).
445    #[must_use]
446    pub fn is_decisive(&self) -> bool {
447        self.log_bayes_factor.abs() > 2.0 || self.log_bayes_factor.is_infinite()
448    }
449}
450
451/// Decision log entry for observability.
452#[derive(Debug, Clone)]
453pub struct DecisionLog {
454    /// Timestamp of the decision.
455    pub timestamp: Instant,
456    /// Elapsed time since logging started (ms).
457    pub elapsed_ms: f64,
458    /// Event index in session.
459    pub event_idx: u64,
460    /// Time since last event (ms).
461    pub dt_ms: f64,
462    /// Current event rate (events/sec).
463    pub event_rate: f64,
464    /// Detected regime.
465    pub regime: Regime,
466    /// Chosen action.
467    pub action: &'static str,
468    /// Pending size (if any).
469    pub pending_size: Option<(u16, u16)>,
470    /// Applied size (for apply decisions).
471    pub applied_size: Option<(u16, u16)>,
472    /// Time since last render (ms).
473    pub time_since_render_ms: f64,
474    /// Time spent coalescing until apply (ms).
475    pub coalesce_ms: Option<f64>,
476    /// Was forced by deadline.
477    pub forced: bool,
478}
479
480impl DecisionLog {
481    /// Serialize decision log to JSONL format.
482    #[must_use]
483    pub fn to_jsonl(&self, run_id: &str, screen_mode: ScreenMode, cols: u16, rows: u16) -> String {
484        let (pending_w, pending_h) = match self.pending_size {
485            Some((w, h)) => (w.to_string(), h.to_string()),
486            None => ("null".to_string(), "null".to_string()),
487        };
488        let (applied_w, applied_h) = match self.applied_size {
489            Some((w, h)) => (w.to_string(), h.to_string()),
490            None => ("null".to_string(), "null".to_string()),
491        };
492        let coalesce_ms = match self.coalesce_ms {
493            Some(ms) => format!("{:.3}", ms),
494            None => "null".to_string(),
495        };
496        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, self.event_idx);
497
498        format!(
499            r#"{{{prefix},"event":"decision","idx":{},"elapsed_ms":{:.3},"dt_ms":{:.3},"event_rate":{:.3},"regime":"{}","action":"{}","pending_w":{},"pending_h":{},"applied_w":{},"applied_h":{},"time_since_render_ms":{:.3},"coalesce_ms":{},"forced":{}}}"#,
500            self.event_idx,
501            self.elapsed_ms,
502            self.dt_ms,
503            self.event_rate,
504            self.regime.as_str(),
505            self.action,
506            pending_w,
507            pending_h,
508            applied_w,
509            applied_h,
510            self.time_since_render_ms,
511            coalesce_ms,
512            self.forced
513        )
514    }
515}
516
517/// Adaptive resize stream coalescer.
518///
519/// Implements latest-wins coalescing with regime-aware behavior.
520#[derive(Debug)]
521pub struct ResizeCoalescer {
522    config: CoalescerConfig,
523
524    /// Currently pending size (latest wins).
525    pending_size: Option<(u16, u16)>,
526
527    /// Last applied size.
528    last_applied: (u16, u16),
529
530    /// Timestamp of first event in current coalesce window.
531    window_start: Option<Instant>,
532
533    /// Timestamp of last resize event.
534    last_event: Option<Instant>,
535
536    /// Timestamp of last render.
537    last_render: Instant,
538
539    /// Current detected regime.
540    regime: Regime,
541
542    /// Frames remaining in cooldown (for burst exit hysteresis).
543    cooldown_remaining: u32,
544
545    /// Recent event timestamps for rate calculation.
546    event_times: VecDeque<Instant>,
547
548    /// Total event count.
549    event_count: u64,
550
551    /// Logging start time for elapsed timestamps.
552    log_start: Option<Instant>,
553
554    /// Decision logs (if logging enabled).
555    logs: Vec<DecisionLog>,
556    /// Evidence sink for JSONL decision logs.
557    evidence_sink: Option<EvidenceSink>,
558    /// Whether config has been logged to the evidence sink.
559    config_logged: bool,
560    /// Run identifier for evidence logs.
561    evidence_run_id: String,
562    /// Screen mode label for evidence logs.
563    evidence_screen_mode: ScreenMode,
564
565    // --- Telemetry integration (bd-1rz0.7) ---
566    /// Telemetry hooks for external observability.
567    telemetry_hooks: Option<TelemetryHooks>,
568
569    /// Count of regime transitions during this session.
570    regime_transitions: u64,
571
572    /// Events coalesced in current window.
573    events_in_window: u64,
574
575    /// History of cycle times (ms) for percentile calculation.
576    cycle_times: Vec<f64>,
577
578    /// BOCPD detector for Bayesian regime detection (when enabled).
579    bocpd: Option<BocpdDetector>,
580}
581
582/// Cycle time percentiles for reflow diagnostics (bd-1rz0.7).
583#[derive(Debug, Clone, Copy)]
584pub struct CycleTimePercentiles {
585    /// 50th percentile (median) cycle time in ms.
586    pub p50_ms: f64,
587    /// 95th percentile cycle time in ms.
588    pub p95_ms: f64,
589    /// 99th percentile cycle time in ms.
590    pub p99_ms: f64,
591    /// Number of samples.
592    pub count: usize,
593    /// Mean cycle time in ms.
594    pub mean_ms: f64,
595}
596
597impl CycleTimePercentiles {
598    /// Serialize to JSONL format.
599    #[must_use]
600    pub fn to_jsonl(&self) -> String {
601        format!(
602            r#"{{"event":"cycle_time_percentiles","p50_ms":{:.3},"p95_ms":{:.3},"p99_ms":{:.3},"mean_ms":{:.3},"count":{}}}"#,
603            self.p50_ms, self.p95_ms, self.p99_ms, self.mean_ms, self.count
604        )
605    }
606}
607
608impl ResizeCoalescer {
609    /// Create a new coalescer with the given configuration and initial size.
610    pub fn new(config: CoalescerConfig, initial_size: (u16, u16)) -> Self {
611        let bocpd = if config.enable_bocpd {
612            let mut bocpd_cfg = config.bocpd_config.clone().unwrap_or_default();
613            if config.enable_logging {
614                bocpd_cfg.enable_logging = true;
615            }
616            Some(BocpdDetector::new(bocpd_cfg))
617        } else {
618            None
619        };
620
621        Self {
622            config,
623            pending_size: None,
624            last_applied: initial_size,
625            window_start: None,
626            last_event: None,
627            last_render: Instant::now(),
628            regime: Regime::Steady,
629            cooldown_remaining: 0,
630            event_times: VecDeque::new(),
631            event_count: 0,
632            log_start: None,
633            logs: Vec::new(),
634            evidence_sink: None,
635            config_logged: false,
636            evidence_run_id: default_resize_run_id(),
637            evidence_screen_mode: ScreenMode::AltScreen,
638            telemetry_hooks: None,
639            regime_transitions: 0,
640            events_in_window: 0,
641            cycle_times: Vec::new(),
642            bocpd,
643        }
644    }
645
646    /// Attach telemetry hooks for external observability.
647    #[must_use]
648    pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
649        self.telemetry_hooks = Some(hooks);
650        self
651    }
652
653    /// Attach an evidence sink for JSONL decision logs.
654    #[must_use]
655    pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
656        self.evidence_sink = Some(sink);
657        self.config_logged = false;
658        self
659    }
660
661    /// Set the run identifier used in evidence logs.
662    #[must_use]
663    pub fn with_evidence_run_id(mut self, run_id: impl Into<String>) -> Self {
664        self.evidence_run_id = run_id.into();
665        self
666    }
667
668    /// Set the screen mode label used in evidence logs.
669    #[must_use]
670    pub fn with_screen_mode(mut self, screen_mode: ScreenMode) -> Self {
671        self.evidence_screen_mode = screen_mode;
672        self
673    }
674
675    /// Set or clear the evidence sink.
676    pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
677        self.evidence_sink = sink;
678        self.config_logged = false;
679    }
680
681    /// Set the last render time (for deterministic testing).
682    #[must_use]
683    pub fn with_last_render(mut self, time: Instant) -> Self {
684        self.last_render = time;
685        self
686    }
687
688    /// Record an externally-applied resize (immediate path).
689    pub fn record_external_apply(&mut self, width: u16, height: u16, now: Instant) {
690        self.event_count += 1;
691        self.event_times.push_back(now);
692        while self.event_times.len() > self.config.rate_window_size {
693            self.event_times.pop_front();
694        }
695        self.update_regime(now);
696
697        self.pending_size = None;
698        self.window_start = None;
699        self.last_event = Some(now);
700        self.last_applied = (width, height);
701        self.last_render = now;
702        self.events_in_window = 0;
703        self.cooldown_remaining = 0;
704
705        self.log_decision(now, "apply_immediate", false, Some(0.0), Some(0.0));
706
707        if let Some(ref hooks) = self.telemetry_hooks
708            && let Some(entry) = self.logs.last()
709        {
710            hooks.fire_resize_applied(entry);
711        }
712    }
713
714    /// Get current regime transition count.
715    #[must_use]
716    pub fn regime_transition_count(&self) -> u64 {
717        self.regime_transitions
718    }
719
720    /// Get cycle time percentiles (p50, p95, p99) in milliseconds.
721    /// Returns None if no cycle times recorded.
722    #[must_use]
723    pub fn cycle_time_percentiles(&self) -> Option<CycleTimePercentiles> {
724        if self.cycle_times.is_empty() {
725            return None;
726        }
727
728        let mut sorted = self.cycle_times.clone();
729        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
730
731        let len = sorted.len();
732        let p50_idx = len / 2;
733        let p95_idx = (len * 95) / 100;
734        let p99_idx = (len * 99) / 100;
735
736        Some(CycleTimePercentiles {
737            p50_ms: sorted[p50_idx],
738            p95_ms: sorted[p95_idx.min(len - 1)],
739            p99_ms: sorted[p99_idx.min(len - 1)],
740            count: len,
741            mean_ms: sorted.iter().sum::<f64>() / len as f64,
742        })
743    }
744
745    /// Handle a resize event.
746    ///
747    /// Returns the action to take immediately.
748    pub fn handle_resize(&mut self, width: u16, height: u16) -> CoalesceAction {
749        self.handle_resize_at(width, height, Instant::now())
750    }
751
752    /// Handle a resize event at a specific time (for testing).
753    pub fn handle_resize_at(&mut self, width: u16, height: u16, now: Instant) -> CoalesceAction {
754        self.event_count += 1;
755
756        // Track event time for rate calculation
757        self.event_times.push_back(now);
758        while self.event_times.len() > self.config.rate_window_size {
759            self.event_times.pop_front();
760        }
761
762        // Update regime based on event rate
763        self.update_regime(now);
764
765        // Calculate dt
766        let dt = self.last_event.map(|t| now.duration_since(t));
767        let dt_ms = dt.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0);
768        self.last_event = Some(now);
769
770        // If no pending, and this matches current size, no action needed
771        if self.pending_size.is_none() && (width, height) == self.last_applied {
772            self.log_decision(now, "skip_same_size", false, Some(dt_ms), None);
773            return CoalesceAction::None;
774        }
775
776        // Update pending size (latest wins)
777        self.pending_size = Some((width, height));
778
779        // Track events in current coalesce window (bd-1rz0.7)
780        self.events_in_window += 1;
781
782        // Mark window start if this is first event
783        if self.window_start.is_none() {
784            self.window_start = Some(now);
785        }
786
787        // Check hard deadline
788        let time_since_render = now.duration_since(self.last_render);
789        if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
790            return self.apply_pending_at(now, true);
791        }
792
793        // If enough time has passed since the last event, apply now.
794        // In heuristic mode, only apply immediately in Steady; burst applies via tick.
795        if let Some(dt) = dt
796            && dt >= Duration::from_millis(self.current_delay_ms())
797            && (self.bocpd.is_some() || self.regime == Regime::Steady)
798        {
799            return self.apply_pending_at(now, false);
800        }
801
802        self.log_decision(now, "coalesce", false, Some(dt_ms), None);
803
804        // Fire decision hook for coalesce events (bd-1rz0.7)
805        if let Some(ref hooks) = self.telemetry_hooks
806            && let Some(entry) = self.logs.last()
807        {
808            hooks.fire_decision(entry);
809        }
810
811        CoalesceAction::ShowPlaceholder
812    }
813
814    /// Tick the coalescer (call each frame).
815    ///
816    /// Returns the action to take.
817    pub fn tick(&mut self) -> CoalesceAction {
818        self.tick_at(Instant::now())
819    }
820
821    /// Tick at a specific time (for testing).
822    pub fn tick_at(&mut self, now: Instant) -> CoalesceAction {
823        if self.pending_size.is_none() {
824            return CoalesceAction::None;
825        }
826
827        if self.window_start.is_none() {
828            return CoalesceAction::None;
829        }
830
831        // Check hard deadline
832        let time_since_render = now.duration_since(self.last_render);
833        if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
834            return self.apply_pending_at(now, true);
835        }
836
837        let delay_ms = self.current_delay_ms();
838
839        // Check if enough time has passed since last event
840        if let Some(last_event) = self.last_event {
841            let since_last_event = now.duration_since(last_event);
842            if since_last_event >= Duration::from_millis(delay_ms) {
843                return self.apply_pending_at(now, false);
844            }
845        }
846
847        // Update cooldown
848        if self.cooldown_remaining > 0 {
849            self.cooldown_remaining -= 1;
850            if self.cooldown_remaining == 0 && self.regime == Regime::Burst {
851                let rate = self.calculate_event_rate(now);
852                if rate < self.config.burst_exit_rate {
853                    self.regime = Regime::Steady;
854                }
855            }
856        }
857
858        CoalesceAction::None
859    }
860
861    /// Time until the pending resize should be applied.
862    pub fn time_until_apply(&self, now: Instant) -> Option<Duration> {
863        let _pending = self.pending_size?;
864        let last_event = self.last_event?;
865
866        let delay_ms = self.current_delay_ms();
867
868        let elapsed = now.duration_since(last_event);
869        let target = Duration::from_millis(delay_ms);
870
871        if elapsed >= target {
872            Some(Duration::ZERO)
873        } else {
874            Some(target - elapsed)
875        }
876    }
877
878    /// Check if there's a pending resize.
879    #[inline]
880    pub fn has_pending(&self) -> bool {
881        self.pending_size.is_some()
882    }
883
884    /// Get the current regime.
885    #[inline]
886    pub fn regime(&self) -> Regime {
887        self.regime
888    }
889
890    /// Check if BOCPD-based regime detection is enabled.
891    #[inline]
892    pub fn bocpd_enabled(&self) -> bool {
893        self.bocpd.is_some()
894    }
895
896    /// Get the BOCPD detector for inspection (if enabled).
897    ///
898    /// Returns `None` if BOCPD is not enabled.
899    #[inline]
900    pub fn bocpd(&self) -> Option<&BocpdDetector> {
901        self.bocpd.as_ref()
902    }
903
904    /// Get the current P(burst) from BOCPD (if enabled).
905    ///
906    /// Returns the posterior probability that the system is in burst regime.
907    /// Returns `None` if BOCPD is not enabled.
908    #[inline]
909    pub fn bocpd_p_burst(&self) -> Option<f64> {
910        self.bocpd.as_ref().map(|b| b.p_burst())
911    }
912
913    /// Get the recommended delay from BOCPD (if enabled).
914    ///
915    /// Returns the recommended coalesce delay in milliseconds based on the
916    /// current posterior distribution. Returns `None` if BOCPD is not enabled.
917    #[inline]
918    pub fn bocpd_recommended_delay(&self) -> Option<u64> {
919        self.bocpd
920            .as_ref()
921            .map(|b| b.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms))
922    }
923
924    /// Get the current event rate (events/second).
925    pub fn event_rate(&self) -> f64 {
926        self.calculate_event_rate(Instant::now())
927    }
928
929    /// Get the last applied size.
930    #[inline]
931    pub fn last_applied(&self) -> (u16, u16) {
932        self.last_applied
933    }
934
935    /// Get decision logs (if logging enabled).
936    pub fn logs(&self) -> &[DecisionLog] {
937        &self.logs
938    }
939
940    /// Clear decision logs.
941    pub fn clear_logs(&mut self) {
942        self.logs.clear();
943        self.log_start = None;
944        self.config_logged = false;
945    }
946
947    /// Get statistics about the coalescer.
948    pub fn stats(&self) -> CoalescerStats {
949        CoalescerStats {
950            event_count: self.event_count,
951            regime: self.regime,
952            event_rate: self.event_rate(),
953            has_pending: self.pending_size.is_some(),
954            last_applied: self.last_applied,
955        }
956    }
957
958    /// Export decision logs as JSONL (one entry per line).
959    #[must_use]
960    pub fn decision_logs_jsonl(&self) -> String {
961        let (cols, rows) = self.last_applied;
962        let run_id = self.evidence_run_id.as_str();
963        let screen_mode = self.evidence_screen_mode;
964        self.logs
965            .iter()
966            .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows))
967            .collect::<Vec<_>>()
968            .join("\n")
969    }
970
971    /// Compute a deterministic checksum of decision logs.
972    #[must_use]
973    pub fn decision_checksum(&self) -> u64 {
974        let mut hash = FNV_OFFSET_BASIS;
975        for entry in &self.logs {
976            fnv_hash_bytes(&mut hash, &entry.event_idx.to_le_bytes());
977            fnv_hash_bytes(&mut hash, &entry.elapsed_ms.to_bits().to_le_bytes());
978            fnv_hash_bytes(&mut hash, &entry.dt_ms.to_bits().to_le_bytes());
979            fnv_hash_bytes(&mut hash, &entry.event_rate.to_bits().to_le_bytes());
980            fnv_hash_bytes(
981                &mut hash,
982                &[match entry.regime {
983                    Regime::Steady => 0u8,
984                    Regime::Burst => 1u8,
985                }],
986            );
987            fnv_hash_bytes(&mut hash, entry.action.as_bytes());
988            fnv_hash_bytes(&mut hash, &[0u8]); // separator
989
990            fnv_hash_bytes(&mut hash, &[entry.pending_size.is_some() as u8]);
991            if let Some((w, h)) = entry.pending_size {
992                fnv_hash_bytes(&mut hash, &w.to_le_bytes());
993                fnv_hash_bytes(&mut hash, &h.to_le_bytes());
994            }
995
996            fnv_hash_bytes(&mut hash, &[entry.applied_size.is_some() as u8]);
997            if let Some((w, h)) = entry.applied_size {
998                fnv_hash_bytes(&mut hash, &w.to_le_bytes());
999                fnv_hash_bytes(&mut hash, &h.to_le_bytes());
1000            }
1001
1002            fnv_hash_bytes(
1003                &mut hash,
1004                &entry.time_since_render_ms.to_bits().to_le_bytes(),
1005            );
1006            fnv_hash_bytes(&mut hash, &[entry.coalesce_ms.is_some() as u8]);
1007            if let Some(ms) = entry.coalesce_ms {
1008                fnv_hash_bytes(&mut hash, &ms.to_bits().to_le_bytes());
1009            }
1010            fnv_hash_bytes(&mut hash, &[entry.forced as u8]);
1011        }
1012        hash
1013    }
1014
1015    /// Compute checksum as hex string.
1016    #[must_use]
1017    pub fn decision_checksum_hex(&self) -> String {
1018        format!("{:016x}", self.decision_checksum())
1019    }
1020
1021    /// Compute a summary of the decision log.
1022    #[must_use]
1023    #[allow(clippy::field_reassign_with_default)]
1024    pub fn decision_summary(&self) -> DecisionSummary {
1025        let mut summary = DecisionSummary::default();
1026        summary.decision_count = self.logs.len();
1027        summary.last_applied = self.last_applied;
1028        summary.regime = self.regime;
1029
1030        for entry in &self.logs {
1031            match entry.action {
1032                "apply" | "apply_forced" | "apply_immediate" => {
1033                    summary.apply_count += 1;
1034                    if entry.forced {
1035                        summary.forced_apply_count += 1;
1036                    }
1037                }
1038                "coalesce" => summary.coalesce_count += 1,
1039                "skip_same_size" => summary.skip_count += 1,
1040                _ => {}
1041            }
1042        }
1043
1044        summary.checksum = self.decision_checksum();
1045        summary
1046    }
1047
1048    /// Export config + decision logs + summary as JSONL.
1049    #[must_use]
1050    pub fn evidence_to_jsonl(&self) -> String {
1051        let mut lines = Vec::with_capacity(self.logs.len() + 2);
1052        let (cols, rows) = self.last_applied;
1053        let run_id = self.evidence_run_id.as_str();
1054        let screen_mode = self.evidence_screen_mode;
1055        let summary_event_idx = self.logs.last().map(|entry| entry.event_idx).unwrap_or(0);
1056        lines.push(self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1057        lines.extend(
1058            self.logs
1059                .iter()
1060                .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows)),
1061        );
1062        lines.push(self.decision_summary().to_jsonl(
1063            run_id,
1064            screen_mode,
1065            cols,
1066            rows,
1067            summary_event_idx,
1068        ));
1069        lines.join("\n")
1070    }
1071
1072    // --- Internal methods ---
1073
1074    fn apply_pending_at(&mut self, now: Instant, forced: bool) -> CoalesceAction {
1075        let Some((width, height)) = self.pending_size.take() else {
1076            return CoalesceAction::None;
1077        };
1078
1079        let coalesce_time = self
1080            .window_start
1081            .map(|s| now.duration_since(s))
1082            .unwrap_or(Duration::ZERO);
1083        let coalesce_ms = coalesce_time.as_secs_f64() * 1000.0;
1084
1085        // Track cycle time for percentile calculation (bd-1rz0.7)
1086        self.cycle_times.push(coalesce_ms);
1087
1088        self.window_start = None;
1089        self.last_applied = (width, height);
1090        self.last_render = now;
1091
1092        // Reset events in window counter
1093        self.events_in_window = 0;
1094
1095        self.log_decision(
1096            now,
1097            if forced { "apply_forced" } else { "apply" },
1098            forced,
1099            None,
1100            Some(coalesce_ms),
1101        );
1102
1103        // Fire telemetry hooks (bd-1rz0.7)
1104        if let Some(ref hooks) = self.telemetry_hooks
1105            && let Some(entry) = self.logs.last()
1106        {
1107            hooks.fire_resize_applied(entry);
1108        }
1109
1110        CoalesceAction::ApplyResize {
1111            width,
1112            height,
1113            coalesce_time,
1114            forced_by_deadline: forced,
1115        }
1116    }
1117
1118    #[inline]
1119    fn current_delay_ms(&self) -> u64 {
1120        if let Some(ref bocpd) = self.bocpd {
1121            bocpd.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms)
1122        } else {
1123            match self.regime {
1124                Regime::Steady => self.config.steady_delay_ms,
1125                Regime::Burst => self.config.burst_delay_ms,
1126            }
1127        }
1128    }
1129
1130    fn update_regime(&mut self, now: Instant) {
1131        let old_regime = self.regime;
1132
1133        // Use BOCPD for regime detection when enabled
1134        if let Some(ref mut bocpd) = self.bocpd {
1135            // Update BOCPD with the event timestamp (it calculates inter-arrival internally)
1136            bocpd.observe_event(now);
1137
1138            // Map BOCPD regime to coalescer regime
1139            self.regime = match bocpd.regime() {
1140                BocpdRegime::Steady => Regime::Steady,
1141                BocpdRegime::Burst => Regime::Burst,
1142                BocpdRegime::Transitional => {
1143                    // During transition, maintain current regime to avoid thrashing
1144                    self.regime
1145                }
1146            };
1147        } else {
1148            // Fall back to heuristic rate-based detection
1149            let rate = self.calculate_event_rate(now);
1150
1151            match self.regime {
1152                Regime::Steady => {
1153                    if rate >= self.config.burst_enter_rate {
1154                        self.regime = Regime::Burst;
1155                        self.cooldown_remaining = self.config.cooldown_frames;
1156                    }
1157                }
1158                Regime::Burst => {
1159                    if rate < self.config.burst_exit_rate {
1160                        // Don't exit immediately — use cooldown
1161                        if self.cooldown_remaining == 0 {
1162                            self.cooldown_remaining = self.config.cooldown_frames;
1163                        }
1164                    } else {
1165                        // Still in burst, reset cooldown
1166                        self.cooldown_remaining = self.config.cooldown_frames;
1167                    }
1168                }
1169            }
1170        }
1171
1172        // Track regime transitions and fire telemetry hooks (bd-1rz0.7)
1173        if old_regime != self.regime {
1174            self.regime_transitions += 1;
1175            if let Some(ref hooks) = self.telemetry_hooks {
1176                hooks.fire_regime_change(old_regime, self.regime);
1177            }
1178        }
1179    }
1180
1181    fn calculate_event_rate(&self, now: Instant) -> f64 {
1182        if self.event_times.len() < 2 {
1183            return 0.0;
1184        }
1185
1186        let first = *self.event_times.front().unwrap();
1187        let window_duration = now.duration_since(first);
1188
1189        // Enforce a minimum duration of 1ms to prevent divide-by-zero or instability
1190        // and to correctly reflect high rates for near-instantaneous bursts.
1191        let duration_secs = window_duration.as_secs_f64().max(0.001);
1192
1193        (self.event_times.len() as f64) / duration_secs
1194    }
1195
1196    fn log_decision(
1197        &mut self,
1198        now: Instant,
1199        action: &'static str,
1200        forced: bool,
1201        dt_ms_override: Option<f64>,
1202        coalesce_ms: Option<f64>,
1203    ) {
1204        if !self.config.enable_logging {
1205            return;
1206        }
1207
1208        if self.log_start.is_none() {
1209            self.log_start = Some(now);
1210        }
1211
1212        let elapsed_ms = self
1213            .log_start
1214            .map(|t| now.duration_since(t).as_secs_f64() * 1000.0)
1215            .unwrap_or(0.0);
1216
1217        let dt_ms = dt_ms_override
1218            .or_else(|| {
1219                self.last_event
1220                    .map(|t| now.duration_since(t).as_secs_f64() * 1000.0)
1221            })
1222            .unwrap_or(0.0);
1223
1224        let time_since_render_ms = now.duration_since(self.last_render).as_secs_f64() * 1000.0;
1225
1226        let applied_size =
1227            if action == "apply" || action == "apply_forced" || action == "apply_immediate" {
1228                Some(self.last_applied)
1229            } else {
1230                None
1231            };
1232
1233        self.logs.push(DecisionLog {
1234            timestamp: now,
1235            elapsed_ms,
1236            event_idx: self.event_count,
1237            dt_ms,
1238            event_rate: self.calculate_event_rate(now),
1239            regime: self.regime,
1240            action,
1241            pending_size: self.pending_size,
1242            applied_size,
1243            time_since_render_ms,
1244            coalesce_ms,
1245            forced,
1246        });
1247
1248        if let Some(ref sink) = self.evidence_sink {
1249            let (cols, rows) = self.last_applied;
1250            let run_id = self.evidence_run_id.as_str();
1251            let screen_mode = self.evidence_screen_mode;
1252            if !self.config_logged {
1253                let _ = sink.write_jsonl(&self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1254                self.config_logged = true;
1255            }
1256            if let Some(entry) = self.logs.last() {
1257                let _ = sink.write_jsonl(&entry.to_jsonl(run_id, screen_mode, cols, rows));
1258            }
1259            if let Some(ref bocpd) = self.bocpd
1260                && let Some(jsonl) = bocpd.decision_log_jsonl(
1261                    self.config.steady_delay_ms,
1262                    self.config.burst_delay_ms,
1263                    forced,
1264                )
1265            {
1266                let _ = sink.write_jsonl(&jsonl);
1267            }
1268        }
1269    }
1270}
1271
1272/// Statistics about the coalescer state.
1273#[derive(Debug, Clone)]
1274pub struct CoalescerStats {
1275    /// Total events processed.
1276    pub event_count: u64,
1277    /// Current regime.
1278    pub regime: Regime,
1279    /// Current event rate (events/sec).
1280    pub event_rate: f64,
1281    /// Whether there's a pending resize.
1282    pub has_pending: bool,
1283    /// Last applied size.
1284    pub last_applied: (u16, u16),
1285}
1286
1287/// Summary of decision logs.
1288#[derive(Debug, Clone, Default)]
1289pub struct DecisionSummary {
1290    /// Total number of decisions logged.
1291    pub decision_count: usize,
1292    /// Total apply decisions.
1293    pub apply_count: usize,
1294    /// Applies forced by deadline.
1295    pub forced_apply_count: usize,
1296    /// Total coalesce decisions.
1297    pub coalesce_count: usize,
1298    /// Total skip decisions.
1299    pub skip_count: usize,
1300    /// Final regime at summary time.
1301    pub regime: Regime,
1302    /// Last applied size.
1303    pub last_applied: (u16, u16),
1304    /// Checksum for the decision log.
1305    pub checksum: u64,
1306}
1307
1308impl DecisionSummary {
1309    /// Checksum as hex string.
1310    #[must_use]
1311    pub fn checksum_hex(&self) -> String {
1312        format!("{:016x}", self.checksum)
1313    }
1314
1315    /// Serialize summary to JSONL format.
1316    #[must_use]
1317    pub fn to_jsonl(
1318        &self,
1319        run_id: &str,
1320        screen_mode: ScreenMode,
1321        cols: u16,
1322        rows: u16,
1323        event_idx: u64,
1324    ) -> String {
1325        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
1326        format!(
1327            r#"{{{prefix},"event":"summary","decisions":{},"applies":{},"forced_applies":{},"coalesces":{},"skips":{},"regime":"{}","last_w":{},"last_h":{},"checksum":"{}"}}"#,
1328            self.decision_count,
1329            self.apply_count,
1330            self.forced_apply_count,
1331            self.coalesce_count,
1332            self.skip_count,
1333            self.regime.as_str(),
1334            self.last_applied.0,
1335            self.last_applied.1,
1336            self.checksum_hex()
1337        )
1338    }
1339}
1340
1341// =============================================================================
1342// Telemetry Hooks (bd-1rz0.7)
1343// =============================================================================
1344
1345/// Callback type for resize applied events.
1346pub type OnResizeApplied = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1347/// Callback type for regime change events.
1348pub type OnRegimeChange = Box<dyn Fn(Regime, Regime) + Send + Sync>;
1349/// Callback type for coalesce decision events.
1350pub type OnCoalesceDecision = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1351
1352/// Telemetry hooks for observing resize coalescer events.
1353///
1354/// # Example
1355///
1356/// ```ignore
1357/// use ftui_runtime::resize_coalescer::{ResizeCoalescer, TelemetryHooks, CoalescerConfig};
1358///
1359/// let hooks = TelemetryHooks::new()
1360///     .on_resize_applied(|entry| println!("Applied: {}x{}", entry.applied_size.unwrap().0, entry.applied_size.unwrap().1))
1361///     .on_regime_change(|from, to| println!("Regime: {:?} -> {:?}", from, to));
1362///
1363/// let mut coalescer = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24))
1364///     .with_telemetry_hooks(hooks);
1365/// ```
1366pub struct TelemetryHooks {
1367    /// Called when a resize is applied.
1368    on_resize_applied: Option<OnResizeApplied>,
1369    /// Called when regime changes (Steady <-> Burst).
1370    on_regime_change: Option<OnRegimeChange>,
1371    /// Called for every decision (coalesce, apply, skip).
1372    on_decision: Option<OnCoalesceDecision>,
1373    /// Enable tracing events (requires `tracing` feature).
1374    emit_tracing: bool,
1375}
1376
1377impl Default for TelemetryHooks {
1378    fn default() -> Self {
1379        Self::new()
1380    }
1381}
1382
1383impl std::fmt::Debug for TelemetryHooks {
1384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1385        f.debug_struct("TelemetryHooks")
1386            .field("on_resize_applied", &self.on_resize_applied.is_some())
1387            .field("on_regime_change", &self.on_regime_change.is_some())
1388            .field("on_decision", &self.on_decision.is_some())
1389            .field("emit_tracing", &self.emit_tracing)
1390            .finish()
1391    }
1392}
1393
1394impl TelemetryHooks {
1395    /// Create a new empty hooks instance.
1396    #[must_use]
1397    pub fn new() -> Self {
1398        Self {
1399            on_resize_applied: None,
1400            on_regime_change: None,
1401            on_decision: None,
1402            emit_tracing: false,
1403        }
1404    }
1405
1406    /// Set callback for resize applied events.
1407    #[must_use]
1408    pub fn on_resize_applied<F>(mut self, callback: F) -> Self
1409    where
1410        F: Fn(&DecisionLog) + Send + Sync + 'static,
1411    {
1412        self.on_resize_applied = Some(Box::new(callback));
1413        self
1414    }
1415
1416    /// Set callback for regime change events.
1417    #[must_use]
1418    pub fn on_regime_change<F>(mut self, callback: F) -> Self
1419    where
1420        F: Fn(Regime, Regime) + Send + Sync + 'static,
1421    {
1422        self.on_regime_change = Some(Box::new(callback));
1423        self
1424    }
1425
1426    /// Set callback for all decision events.
1427    #[must_use]
1428    pub fn on_decision<F>(mut self, callback: F) -> Self
1429    where
1430        F: Fn(&DecisionLog) + Send + Sync + 'static,
1431    {
1432        self.on_decision = Some(Box::new(callback));
1433        self
1434    }
1435
1436    /// Enable tracing event emission for OpenTelemetry integration.
1437    ///
1438    /// When enabled, decision events are emitted as `tracing::event!` calls
1439    /// with target `ftui.decision.resize` and all evidence ledger fields.
1440    #[must_use]
1441    pub fn with_tracing(mut self, enabled: bool) -> Self {
1442        self.emit_tracing = enabled;
1443        self
1444    }
1445
1446    /// Check if a resize-applied hook is registered.
1447    pub fn has_resize_applied(&self) -> bool {
1448        self.on_resize_applied.is_some()
1449    }
1450
1451    /// Check if a regime-change hook is registered.
1452    pub fn has_regime_change(&self) -> bool {
1453        self.on_regime_change.is_some()
1454    }
1455
1456    /// Check if a decision hook is registered.
1457    pub fn has_decision(&self) -> bool {
1458        self.on_decision.is_some()
1459    }
1460
1461    /// Invoke the resize applied callback if set.
1462    fn fire_resize_applied(&self, entry: &DecisionLog) {
1463        if let Some(ref cb) = self.on_resize_applied {
1464            cb(entry);
1465        }
1466        if self.emit_tracing {
1467            Self::emit_resize_tracing(entry);
1468        }
1469    }
1470
1471    /// Invoke the regime change callback if set.
1472    fn fire_regime_change(&self, from: Regime, to: Regime) {
1473        if let Some(ref cb) = self.on_regime_change {
1474            cb(from, to);
1475        }
1476        if self.emit_tracing {
1477            tracing::debug!(
1478                target: "ftui.decision.resize",
1479                from_regime = %from.as_str(),
1480                to_regime = %to.as_str(),
1481                "regime_change"
1482            );
1483        }
1484    }
1485
1486    /// Invoke the decision callback if set.
1487    fn fire_decision(&self, entry: &DecisionLog) {
1488        if let Some(ref cb) = self.on_decision {
1489            cb(entry);
1490        }
1491    }
1492
1493    /// Emit a tracing event for resize decisions.
1494    fn emit_resize_tracing(entry: &DecisionLog) {
1495        let (pending_w, pending_h) = entry.pending_size.unwrap_or((0, 0));
1496        let (applied_w, applied_h) = entry.applied_size.unwrap_or((0, 0));
1497        let coalesce_ms = entry.coalesce_ms.unwrap_or(0.0);
1498
1499        tracing::info!(
1500            target: "ftui.decision.resize",
1501            event_idx = entry.event_idx,
1502            elapsed_ms = entry.elapsed_ms,
1503            dt_ms = entry.dt_ms,
1504            event_rate = entry.event_rate,
1505            regime = %entry.regime.as_str(),
1506            action = entry.action,
1507            pending_w = pending_w,
1508            pending_h = pending_h,
1509            applied_w = applied_w,
1510            applied_h = applied_h,
1511            time_since_render_ms = entry.time_since_render_ms,
1512            coalesce_ms = coalesce_ms,
1513            forced = entry.forced,
1514            "resize_decision"
1515        );
1516    }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522
1523    fn test_config() -> CoalescerConfig {
1524        CoalescerConfig {
1525            steady_delay_ms: 16,
1526            burst_delay_ms: 40,
1527            hard_deadline_ms: 100,
1528            burst_enter_rate: 10.0,
1529            burst_exit_rate: 5.0,
1530            cooldown_frames: 3,
1531            rate_window_size: 8,
1532            enable_logging: true,
1533            enable_bocpd: false,
1534            bocpd_config: None,
1535        }
1536    }
1537
1538    #[derive(Debug, Clone, Copy)]
1539    struct SimulationMetrics {
1540        event_count: u64,
1541        apply_count: u64,
1542        forced_count: u64,
1543        mean_coalesce_ms: f64,
1544        max_coalesce_ms: f64,
1545        decision_checksum: u64,
1546        final_regime: Regime,
1547    }
1548
1549    impl SimulationMetrics {
1550        fn to_jsonl(self, pattern: &str, mode: &str) -> String {
1551            let pattern = json_escape(pattern);
1552            let mode = json_escape(mode);
1553            let apply_ratio = if self.event_count == 0 {
1554                0.0
1555            } else {
1556                self.apply_count as f64 / self.event_count as f64
1557            };
1558
1559            format!(
1560                r#"{{"event":"simulation_summary","pattern":"{pattern}","mode":"{mode}","events":{},"applies":{},"forced":{},"apply_ratio":{:.4},"mean_coalesce_ms":{:.3},"max_coalesce_ms":{:.3},"final_regime":"{}","checksum":"{:016x}"}}"#,
1561                self.event_count,
1562                self.apply_count,
1563                self.forced_count,
1564                apply_ratio,
1565                self.mean_coalesce_ms,
1566                self.max_coalesce_ms,
1567                self.final_regime.as_str(),
1568                self.decision_checksum
1569            )
1570        }
1571    }
1572
1573    #[derive(Debug, Clone, Copy)]
1574    struct SimulationComparison {
1575        apply_delta: i64,
1576        mean_coalesce_delta_ms: f64,
1577    }
1578
1579    impl SimulationComparison {
1580        fn from_metrics(heuristic: SimulationMetrics, bocpd: SimulationMetrics) -> Self {
1581            let heuristic_apply = i64::try_from(heuristic.apply_count).unwrap_or(i64::MAX);
1582            let bocpd_apply = i64::try_from(bocpd.apply_count).unwrap_or(i64::MAX);
1583            let apply_delta = heuristic_apply.saturating_sub(bocpd_apply);
1584            let mean_coalesce_delta_ms = heuristic.mean_coalesce_ms - bocpd.mean_coalesce_ms;
1585            Self {
1586                apply_delta,
1587                mean_coalesce_delta_ms,
1588            }
1589        }
1590
1591        fn to_jsonl(self, pattern: &str) -> String {
1592            let pattern = json_escape(pattern);
1593            format!(
1594                r#"{{"event":"simulation_compare","pattern":"{pattern}","apply_delta":{},"mean_coalesce_delta_ms":{:.3}}}"#,
1595                self.apply_delta, self.mean_coalesce_delta_ms
1596            )
1597        }
1598    }
1599
1600    fn as_u64(value: usize) -> u64 {
1601        u64::try_from(value).unwrap_or(u64::MAX)
1602    }
1603
1604    fn build_schedule(base: Instant, events: &[(u16, u16, u64)]) -> Vec<(Instant, u16, u16)> {
1605        let mut schedule = Vec::with_capacity(events.len());
1606        let mut elapsed_ms = 0u64;
1607        for (w, h, delay_ms) in events {
1608            elapsed_ms = elapsed_ms.saturating_add(*delay_ms);
1609            schedule.push((base + Duration::from_millis(elapsed_ms), *w, *h));
1610        }
1611        schedule
1612    }
1613
1614    fn run_simulation(
1615        events: &[(u16, u16, u64)],
1616        config: CoalescerConfig,
1617        tick_ms: u64,
1618    ) -> SimulationMetrics {
1619        let mut c = ResizeCoalescer::new(config, (80, 24));
1620        let base = Instant::now();
1621        let schedule = build_schedule(base, events);
1622        let last_event_ms = schedule
1623            .last()
1624            .map(|(time, _, _)| {
1625                u64::try_from(time.duration_since(base).as_millis()).unwrap_or(u64::MAX)
1626            })
1627            .unwrap_or(0);
1628        let end_ms = last_event_ms
1629            .saturating_add(c.config.hard_deadline_ms)
1630            .saturating_add(tick_ms);
1631
1632        let mut next_idx = 0usize;
1633        let mut now_ms = 0u64;
1634        while now_ms <= end_ms {
1635            let now = base + Duration::from_millis(now_ms);
1636
1637            while next_idx < schedule.len() && schedule[next_idx].0 <= now {
1638                let (event_time, w, h) = schedule[next_idx];
1639                let _ = c.handle_resize_at(w, h, event_time);
1640                next_idx += 1;
1641            }
1642
1643            let _ = c.tick_at(now);
1644            now_ms = now_ms.saturating_add(tick_ms);
1645        }
1646
1647        let mut coalesce_values = Vec::new();
1648        let mut apply_count = 0usize;
1649        let mut forced_count = 0usize;
1650        for entry in c.logs() {
1651            if matches!(entry.action, "apply" | "apply_forced" | "apply_immediate") {
1652                apply_count += 1;
1653                if entry.forced {
1654                    forced_count += 1;
1655                }
1656                if let Some(ms) = entry.coalesce_ms {
1657                    coalesce_values.push(ms);
1658                }
1659            }
1660        }
1661
1662        let max_coalesce_ms = coalesce_values
1663            .iter()
1664            .copied()
1665            .fold(0.0_f64, |acc, value| acc.max(value));
1666        let mean_coalesce_ms = if coalesce_values.is_empty() {
1667            0.0
1668        } else {
1669            let sum = coalesce_values.iter().sum::<f64>();
1670            sum / as_u64(coalesce_values.len()) as f64
1671        };
1672
1673        SimulationMetrics {
1674            event_count: as_u64(events.len()),
1675            apply_count: as_u64(apply_count),
1676            forced_count: as_u64(forced_count),
1677            mean_coalesce_ms,
1678            max_coalesce_ms,
1679            decision_checksum: c.decision_checksum(),
1680            final_regime: c.regime(),
1681        }
1682    }
1683
1684    fn steady_pattern() -> Vec<(u16, u16, u64)> {
1685        let mut events = Vec::new();
1686        for i in 0..8u16 {
1687            let width = 90 + i;
1688            let height = 30 + (i % 3);
1689            events.push((width, height, 300));
1690        }
1691        events
1692    }
1693
1694    fn burst_pattern() -> Vec<(u16, u16, u64)> {
1695        let mut events = Vec::new();
1696        for i in 0..30u16 {
1697            let width = 100 + i;
1698            let height = 25 + (i % 5);
1699            events.push((width, height, 10));
1700        }
1701        events
1702    }
1703
1704    fn oscillatory_pattern() -> Vec<(u16, u16, u64)> {
1705        let mut events = Vec::new();
1706        let sizes = [(120, 40), (140, 28), (130, 36), (150, 32)];
1707        let delays = [40u64, 200u64, 60u64, 180u64];
1708        for i in 0..16usize {
1709            let (w, h) = sizes[i % sizes.len()];
1710            let delay = delays[i % delays.len()];
1711            events.push((w + (i as u16 % 3), h, delay));
1712        }
1713        events
1714    }
1715
1716    #[test]
1717    fn new_coalescer_starts_in_steady() {
1718        let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
1719        assert_eq!(c.regime(), Regime::Steady);
1720        assert!(!c.has_pending());
1721    }
1722
1723    #[test]
1724    fn same_size_returns_none() {
1725        let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1726        let action = c.handle_resize(80, 24);
1727        assert_eq!(action, CoalesceAction::None);
1728    }
1729
1730    #[test]
1731    fn different_size_shows_placeholder() {
1732        let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1733        let action = c.handle_resize(100, 40);
1734        assert_eq!(action, CoalesceAction::ShowPlaceholder);
1735        assert!(c.has_pending());
1736    }
1737
1738    #[test]
1739    fn latest_wins_semantics() {
1740        let config = test_config();
1741        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1742
1743        let base = Instant::now();
1744
1745        // Rapid sequence of resizes
1746        c.handle_resize_at(90, 30, base);
1747        c.handle_resize_at(100, 40, base + Duration::from_millis(5));
1748        c.handle_resize_at(110, 50, base + Duration::from_millis(10));
1749
1750        // Wait for coalesce delay
1751        let action = c.tick_at(base + Duration::from_millis(60));
1752
1753        let (width, height) = if let CoalesceAction::ApplyResize { width, height, .. } = action {
1754            (width, height)
1755        } else {
1756            assert!(
1757                matches!(action, CoalesceAction::ApplyResize { .. }),
1758                "Expected ApplyResize, got {action:?}"
1759            );
1760            return;
1761        };
1762        assert_eq!((width, height), (110, 50), "Should apply latest size");
1763    }
1764
1765    #[test]
1766    fn hard_deadline_forces_apply() {
1767        let config = test_config();
1768        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1769
1770        let base = Instant::now();
1771
1772        // First resize
1773        c.handle_resize_at(100, 40, base);
1774
1775        // Wait past hard deadline
1776        let action = c.tick_at(base + Duration::from_millis(150));
1777
1778        let forced_by_deadline = if let CoalesceAction::ApplyResize {
1779            forced_by_deadline, ..
1780        } = action
1781        {
1782            forced_by_deadline
1783        } else {
1784            assert!(
1785                matches!(action, CoalesceAction::ApplyResize { .. }),
1786                "Expected ApplyResize, got {action:?}"
1787            );
1788            return;
1789        };
1790        assert!(forced_by_deadline, "Should be forced by deadline");
1791    }
1792
1793    #[test]
1794    fn burst_mode_detection() {
1795        let config = test_config();
1796        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1797
1798        let base = Instant::now();
1799
1800        // Rapid events to trigger burst mode
1801        for i in 0..15 {
1802            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
1803        }
1804
1805        assert_eq!(c.regime(), Regime::Burst);
1806    }
1807
1808    #[test]
1809    fn steady_mode_fast_response() {
1810        let config = test_config();
1811        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1812
1813        let base = Instant::now();
1814
1815        // Single resize
1816        c.handle_resize_at(100, 40, base);
1817
1818        // In steady mode, should apply after steady_delay
1819        let action = c.tick_at(base + Duration::from_millis(20));
1820
1821        assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1822    }
1823
1824    #[test]
1825    fn record_external_apply_updates_state_and_logs() {
1826        let config = test_config();
1827        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1828
1829        let base = Instant::now();
1830        c.handle_resize_at(100, 40, base);
1831        c.record_external_apply(120, 50, base + Duration::from_millis(5));
1832
1833        assert!(!c.has_pending());
1834        assert_eq!(c.last_applied(), (120, 50));
1835
1836        let summary = c.decision_summary();
1837        assert_eq!(summary.apply_count, 1);
1838        assert_eq!(summary.last_applied, (120, 50));
1839        assert!(
1840            c.logs()
1841                .iter()
1842                .any(|entry| entry.action == "apply_immediate"),
1843            "record_external_apply should emit apply_immediate decision"
1844        );
1845    }
1846
1847    #[test]
1848    fn coalesce_time_tracked() {
1849        let config = test_config();
1850        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1851
1852        let base = Instant::now();
1853
1854        c.handle_resize_at(100, 40, base);
1855        let action = c.tick_at(base + Duration::from_millis(50));
1856
1857        let coalesce_time = if let CoalesceAction::ApplyResize { coalesce_time, .. } = action {
1858            coalesce_time
1859        } else {
1860            assert!(
1861                matches!(action, CoalesceAction::ApplyResize { .. }),
1862                "Expected ApplyResize"
1863            );
1864            return;
1865        };
1866        assert!(coalesce_time >= Duration::from_millis(40));
1867        assert!(coalesce_time <= Duration::from_millis(60));
1868    }
1869
1870    #[test]
1871    fn event_rate_calculation() {
1872        let config = test_config();
1873        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1874
1875        let base = Instant::now();
1876
1877        // 10 events over 1 second = 10 events/sec
1878        for i in 0..10 {
1879            c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 100));
1880        }
1881
1882        let rate = c.calculate_event_rate(base + Duration::from_millis(1000));
1883        assert!(rate > 8.0 && rate < 12.0, "Rate should be ~10 events/sec");
1884    }
1885
1886    #[test]
1887    fn rapid_burst_triggers_high_rate() {
1888        let config = test_config();
1889        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1890        let base = Instant::now();
1891
1892        // Simulate 8 events arriving at the EXACT same time (or < 1ms)
1893        for _ in 0..8 {
1894            c.handle_resize_at(80, 24, base);
1895        }
1896
1897        let rate = c.calculate_event_rate(base);
1898        // 8 events / 0.001s = 8000 events/sec
1899        assert!(
1900            rate >= 1000.0,
1901            "Rate should be high for instantaneous burst, got {}",
1902            rate
1903        );
1904    }
1905
1906    #[test]
1907    fn cooldown_prevents_immediate_exit() {
1908        let config = test_config();
1909        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1910
1911        let base = Instant::now();
1912
1913        // Enter burst mode
1914        for i in 0..15 {
1915            c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
1916        }
1917        assert_eq!(c.regime(), Regime::Burst);
1918
1919        // Rate should drop but cooldown prevents immediate exit
1920        c.tick_at(base + Duration::from_millis(500));
1921        c.tick_at(base + Duration::from_millis(600));
1922
1923        // After cooldown frames, should exit
1924        c.tick_at(base + Duration::from_millis(700));
1925        c.tick_at(base + Duration::from_millis(800));
1926        c.tick_at(base + Duration::from_millis(900));
1927
1928        // Should have exited burst
1929        // Note: This depends on rate calculation window
1930    }
1931
1932    #[test]
1933    fn logging_captures_decisions() {
1934        let mut config = test_config();
1935        config.enable_logging = true;
1936        let mut c = ResizeCoalescer::new(config, (80, 24));
1937
1938        c.handle_resize(100, 40);
1939
1940        assert!(!c.logs().is_empty());
1941        assert_eq!(c.logs()[0].action, "coalesce");
1942    }
1943
1944    #[test]
1945    fn logging_jsonl_format() {
1946        let mut config = test_config();
1947        config.enable_logging = true;
1948        let mut c = ResizeCoalescer::new(config, (80, 24));
1949
1950        c.handle_resize(100, 40);
1951        let (cols, rows) = c.last_applied();
1952        let jsonl = c.logs()[0].to_jsonl("resize-test", ScreenMode::AltScreen, cols, rows);
1953
1954        assert!(jsonl.contains("\"event\":\"decision\""));
1955        assert!(jsonl.contains("\"action\":\"coalesce\""));
1956        assert!(jsonl.contains("\"regime\":\"steady\""));
1957        assert!(jsonl.contains("\"pending_w\":100"));
1958        assert!(jsonl.contains("\"pending_h\":40"));
1959    }
1960
1961    #[test]
1962    fn apply_logs_coalesce_ms() {
1963        let mut config = test_config();
1964        config.enable_logging = true;
1965        let mut c = ResizeCoalescer::new(config, (80, 24));
1966
1967        let base = Instant::now();
1968        c.handle_resize_at(100, 40, base);
1969        let action = c.tick_at(base + Duration::from_millis(50));
1970        assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1971
1972        let last = c.logs().last().expect("Expected a decision log entry");
1973        assert!(last.coalesce_ms.is_some());
1974        assert!(last.coalesce_ms.unwrap() >= 0.0);
1975    }
1976
1977    #[test]
1978    fn decision_checksum_is_stable() {
1979        let mut config = test_config();
1980        config.enable_logging = true;
1981
1982        let base = Instant::now();
1983        let mut c1 = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
1984        let mut c2 = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
1985
1986        for c in [&mut c1, &mut c2] {
1987            c.handle_resize_at(90, 30, base);
1988            c.handle_resize_at(100, 40, base + Duration::from_millis(10));
1989            let _ = c.tick_at(base + Duration::from_millis(80));
1990        }
1991
1992        assert_eq!(c1.decision_checksum(), c2.decision_checksum());
1993    }
1994
1995    #[test]
1996    fn evidence_jsonl_includes_summary() {
1997        let mut config = test_config();
1998        config.enable_logging = true;
1999        let mut c = ResizeCoalescer::new(config, (80, 24));
2000
2001        c.handle_resize(100, 40);
2002        let jsonl = c.evidence_to_jsonl();
2003
2004        assert!(jsonl.contains("\"event\":\"config\""));
2005        assert!(jsonl.contains("\"event\":\"summary\""));
2006    }
2007
2008    #[test]
2009    fn evidence_jsonl_parses_and_has_required_fields() {
2010        use serde_json::Value;
2011
2012        let mut config = test_config();
2013        config.enable_logging = true;
2014        let base = Instant::now();
2015        let mut c = ResizeCoalescer::new(config, (80, 24))
2016            .with_last_render(base)
2017            .with_evidence_run_id("resize-test")
2018            .with_screen_mode(ScreenMode::AltScreen);
2019
2020        c.handle_resize_at(90, 30, base);
2021        c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2022        let _ = c.tick_at(base + Duration::from_millis(120));
2023
2024        let jsonl = c.evidence_to_jsonl();
2025        let mut saw_config = false;
2026        let mut saw_decision = false;
2027        let mut saw_summary = false;
2028
2029        for line in jsonl.lines() {
2030            let value: Value = serde_json::from_str(line).expect("valid JSONL evidence");
2031            let event = value
2032                .get("event")
2033                .and_then(Value::as_str)
2034                .expect("event field");
2035            assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
2036            assert_eq!(value["run_id"], "resize-test");
2037            assert!(
2038                value["event_idx"].is_number(),
2039                "event_idx should be numeric"
2040            );
2041            assert_eq!(value["screen_mode"], "altscreen");
2042            assert!(value["cols"].is_number(), "cols should be numeric");
2043            assert!(value["rows"].is_number(), "rows should be numeric");
2044            match event {
2045                "config" => {
2046                    for key in [
2047                        "steady_delay_ms",
2048                        "burst_delay_ms",
2049                        "hard_deadline_ms",
2050                        "burst_enter_rate",
2051                        "burst_exit_rate",
2052                        "cooldown_frames",
2053                        "rate_window_size",
2054                        "logging_enabled",
2055                    ] {
2056                        assert!(value.get(key).is_some(), "missing config field {key}");
2057                    }
2058                    saw_config = true;
2059                }
2060                "decision" => {
2061                    for key in [
2062                        "idx",
2063                        "elapsed_ms",
2064                        "dt_ms",
2065                        "event_rate",
2066                        "regime",
2067                        "action",
2068                        "pending_w",
2069                        "pending_h",
2070                        "applied_w",
2071                        "applied_h",
2072                        "time_since_render_ms",
2073                        "coalesce_ms",
2074                        "forced",
2075                    ] {
2076                        assert!(value.get(key).is_some(), "missing decision field {key}");
2077                    }
2078                    saw_decision = true;
2079                }
2080                "summary" => {
2081                    for key in [
2082                        "decisions",
2083                        "applies",
2084                        "forced_applies",
2085                        "coalesces",
2086                        "skips",
2087                        "regime",
2088                        "last_w",
2089                        "last_h",
2090                        "checksum",
2091                    ] {
2092                        assert!(value.get(key).is_some(), "missing summary field {key}");
2093                    }
2094                    saw_summary = true;
2095                }
2096                _ => {}
2097            }
2098        }
2099
2100        assert!(saw_config, "config evidence missing");
2101        assert!(saw_decision, "decision evidence missing");
2102        assert!(saw_summary, "summary evidence missing");
2103    }
2104
2105    #[test]
2106    fn evidence_jsonl_is_deterministic_for_fixed_schedule() {
2107        let mut config = test_config();
2108        config.enable_logging = true;
2109        let base = Instant::now();
2110
2111        let run = || {
2112            let mut c = ResizeCoalescer::new(config.clone(), (80, 24))
2113                .with_last_render(base)
2114                .with_evidence_run_id("resize-test")
2115                .with_screen_mode(ScreenMode::AltScreen);
2116            c.handle_resize_at(90, 30, base);
2117            c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2118            let _ = c.tick_at(base + Duration::from_millis(120));
2119            c.evidence_to_jsonl()
2120        };
2121
2122        let first = run();
2123        let second = run();
2124        assert_eq!(first, second);
2125    }
2126
2127    #[test]
2128    fn bocpd_logging_inherits_coalescer_logging() {
2129        let mut config = test_config();
2130        config.enable_bocpd = true;
2131        config.bocpd_config = Some(BocpdConfig::default());
2132
2133        let c = ResizeCoalescer::new(config, (80, 24));
2134        let bocpd = c.bocpd().expect("BOCPD should be enabled");
2135        assert!(bocpd.config().enable_logging);
2136    }
2137
2138    #[test]
2139    fn stats_reflect_state() {
2140        let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2141
2142        c.handle_resize(100, 40);
2143
2144        let stats = c.stats();
2145        assert_eq!(stats.event_count, 1);
2146        assert!(stats.has_pending);
2147        assert_eq!(stats.last_applied, (80, 24));
2148    }
2149
2150    #[test]
2151    fn time_until_apply_calculation() {
2152        let config = test_config();
2153        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2154
2155        let base = Instant::now();
2156        c.handle_resize_at(100, 40, base);
2157
2158        let time_left = c.time_until_apply(base + Duration::from_millis(5));
2159        assert!(time_left.is_some());
2160        let time_left = time_left.unwrap();
2161        assert!(time_left.as_millis() > 0);
2162        assert!(time_left.as_millis() < config.steady_delay_ms as u128);
2163    }
2164
2165    #[test]
2166    fn deterministic_behavior() {
2167        let config = test_config();
2168
2169        // Run twice with same inputs
2170        let results: Vec<_> = (0..2)
2171            .map(|_| {
2172                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2173                let base = Instant::now();
2174
2175                for i in 0..5 {
2176                    c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 20));
2177                }
2178
2179                c.tick_at(base + Duration::from_millis(200))
2180            })
2181            .collect();
2182
2183        assert_eq!(results[0], results[1], "Results must be deterministic");
2184    }
2185
2186    #[test]
2187    fn simulation_bocpd_vs_heuristic_metrics() {
2188        let tick_ms = 5;
2189        // Keep heuristic thresholds high so burst classification is conservative,
2190        // while BOCPD uses a responsive posterior to detect bursty streams.
2191        let mut heuristic_config = test_config();
2192        heuristic_config.burst_enter_rate = 60.0;
2193        heuristic_config.burst_exit_rate = 30.0;
2194        let mut bocpd_cfg = BocpdConfig::responsive();
2195        bocpd_cfg.burst_prior = 0.35;
2196        bocpd_cfg.steady_threshold = 0.2;
2197        bocpd_cfg.burst_threshold = 0.6;
2198        let bocpd_config = heuristic_config.clone().with_bocpd_config(bocpd_cfg);
2199        let patterns = vec![
2200            ("steady", steady_pattern()),
2201            ("burst", burst_pattern()),
2202            ("oscillatory", oscillatory_pattern()),
2203        ];
2204
2205        for (pattern, events) in patterns {
2206            let heuristic = run_simulation(&events, heuristic_config.clone(), tick_ms);
2207            let bocpd = run_simulation(&events, bocpd_config.clone(), tick_ms);
2208
2209            let heuristic_jsonl = heuristic.to_jsonl(pattern, "heuristic");
2210            let bocpd_jsonl = bocpd.to_jsonl(pattern, "bocpd");
2211            let comparison = SimulationComparison::from_metrics(heuristic, bocpd);
2212            let comparison_jsonl = comparison.to_jsonl(pattern);
2213
2214            eprintln!("{heuristic_jsonl}");
2215            eprintln!("{bocpd_jsonl}");
2216            eprintln!("{comparison_jsonl}");
2217
2218            assert!(heuristic_jsonl.contains("\"event\":\"simulation_summary\""));
2219            assert!(bocpd_jsonl.contains("\"event\":\"simulation_summary\""));
2220            assert!(comparison_jsonl.contains("\"event\":\"simulation_compare\""));
2221
2222            #[allow(clippy::cast_precision_loss)]
2223            let max_allowed = test_config().hard_deadline_ms as f64 + 1.0;
2224            assert!(
2225                heuristic.max_coalesce_ms <= max_allowed,
2226                "heuristic latency bounded for {pattern}"
2227            );
2228            assert!(
2229                bocpd.max_coalesce_ms <= max_allowed,
2230                "bocpd latency bounded for {pattern}"
2231            );
2232
2233            if pattern == "burst" {
2234                let event_count = as_u64(events.len());
2235                assert!(
2236                    heuristic.apply_count < event_count,
2237                    "heuristic should coalesce under burst pattern"
2238                );
2239                assert!(
2240                    bocpd.apply_count < event_count,
2241                    "bocpd should coalesce under burst pattern"
2242                );
2243                assert!(
2244                    comparison.apply_delta >= 0,
2245                    "BOCPD should not increase renders in burst (apply_delta={})",
2246                    comparison.apply_delta
2247                );
2248            }
2249        }
2250    }
2251
2252    #[test]
2253    fn never_drops_final_size() {
2254        let config = test_config();
2255        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2256
2257        let base = Instant::now();
2258
2259        // Many rapid resizes that may trigger some applies due to hard deadline
2260        let mut intermediate_applies = Vec::new();
2261        for i in 0..100 {
2262            let action = c.handle_resize_at(
2263                80 + (i % 50),
2264                24 + (i % 30),
2265                base + Duration::from_millis(i as u64 * 5),
2266            );
2267            if let CoalesceAction::ApplyResize { width, height, .. } = action {
2268                intermediate_applies.push((width, height));
2269            }
2270        }
2271
2272        // The final size - may apply immediately if deadline is hit
2273        let final_action = c.handle_resize_at(200, 100, base + Duration::from_millis(600));
2274
2275        let applied_size = if let CoalesceAction::ApplyResize { width, height, .. } = final_action {
2276            Some((width, height))
2277        } else {
2278            // If not applied immediately, tick until it is
2279            let mut result = None;
2280            for tick in 0..100 {
2281                let action = c.tick_at(base + Duration::from_millis(700 + tick * 20));
2282                if let CoalesceAction::ApplyResize { width, height, .. } = action {
2283                    result = Some((width, height));
2284                    break;
2285                }
2286            }
2287            result
2288        };
2289
2290        assert_eq!(
2291            applied_size,
2292            Some((200, 100)),
2293            "Must apply final size 200x100"
2294        );
2295    }
2296
2297    #[test]
2298    fn bounded_latency_invariant() {
2299        let config = test_config();
2300        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2301
2302        let base = Instant::now();
2303        c.handle_resize_at(100, 40, base);
2304
2305        // Simulate time passing without any new events
2306        let mut applied_at = None;
2307        for ms in 0..200 {
2308            let now = base + Duration::from_millis(ms);
2309            let action = c.tick_at(now);
2310            if matches!(action, CoalesceAction::ApplyResize { .. }) {
2311                applied_at = Some(ms);
2312                break;
2313            }
2314        }
2315
2316        assert!(applied_at.is_some(), "Must apply within reasonable time");
2317        assert!(
2318            applied_at.unwrap() <= config.hard_deadline_ms,
2319            "Must apply within hard deadline"
2320        );
2321    }
2322
2323    // =========================================================================
2324    // Property tests (bd-1rz0.8)
2325    // =========================================================================
2326
2327    mod property {
2328        use super::*;
2329        use proptest::prelude::*;
2330
2331        /// Strategy for generating resize dimensions.
2332        fn dimension() -> impl Strategy<Value = u16> {
2333            1u16..500
2334        }
2335
2336        /// Strategy for generating resize event sequences.
2337        fn resize_sequence(max_len: usize) -> impl Strategy<Value = Vec<(u16, u16, u64)>> {
2338            proptest::collection::vec((dimension(), dimension(), 0u64..200), 0..max_len)
2339        }
2340
2341        proptest! {
2342            /// Property: identical sequences yield identical final results.
2343            ///
2344            /// The coalescer must be deterministic - same inputs → same outputs.
2345            #[test]
2346            fn determinism_across_sequences(
2347                events in resize_sequence(50),
2348                tick_offset in 100u64..500
2349            ) {
2350                let config = CoalescerConfig::default();
2351
2352                let results: Vec<_> = (0..2)
2353                    .map(|_| {
2354                        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2355                        let base = Instant::now();
2356
2357                        for (i, (w, h, delay)) in events.iter().enumerate() {
2358                            let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2359                            c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2360                        }
2361
2362                        // Tick to trigger apply
2363                        let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2364                        c.tick_at(base + Duration::from_millis(total_time))
2365                    })
2366                    .collect();
2367
2368                prop_assert_eq!(results[0], results[1], "Results must be deterministic");
2369            }
2370
2371            /// Property: the latest resize is never lost (latest-wins semantics).
2372            ///
2373            /// When all coalescing completes, the applied size must match the
2374            /// final requested size.
2375            #[test]
2376            fn latest_wins_never_drops(
2377                events in resize_sequence(20),
2378                final_w in dimension(),
2379                final_h in dimension()
2380            ) {
2381                if events.is_empty() {
2382                    // No events to test
2383                    return Ok(());
2384                }
2385
2386                let config = CoalescerConfig::default();
2387                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2388                let base = Instant::now();
2389
2390                // Feed all events
2391                let mut offset = 0u64;
2392                for (w, h, delay) in &events {
2393                    offset += delay;
2394                    c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2395                }
2396
2397                // Add final event
2398                offset += 50;
2399                c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2400
2401                // Tick until we get an apply
2402                let mut final_applied = None;
2403                for tick in 0..200 {
2404                    let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2405                    if let CoalesceAction::ApplyResize { width, height, .. } = action {
2406                        final_applied = Some((width, height));
2407                    }
2408                    if !c.has_pending() && final_applied.is_some() {
2409                        break;
2410                    }
2411                }
2412
2413                // The final applied size must match the latest requested size
2414                if let Some((applied_w, applied_h)) = final_applied {
2415                    prop_assert_eq!(
2416                        (applied_w, applied_h),
2417                        (final_w, final_h),
2418                        "Must apply the final size {} x {}",
2419                        final_w,
2420                        final_h
2421                    );
2422                }
2423            }
2424
2425            /// Property: bounded latency is always maintained.
2426            ///
2427            /// A pending resize must be applied within hard_deadline_ms.
2428            #[test]
2429            fn bounded_latency_maintained(
2430                w in dimension(),
2431                h in dimension()
2432            ) {
2433                let config = CoalescerConfig::default();
2434                // Use (0,0) so no generated size (1..500) hits the skip_same_size path
2435                let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2436                let base = Instant::now();
2437
2438                c.handle_resize_at(w, h, base);
2439
2440                // Tick forward until applied
2441                let mut applied_at = None;
2442                for ms in 0..=config.hard_deadline_ms + 50 {
2443                    let action = c.tick_at(base + Duration::from_millis(ms));
2444                    if matches!(action, CoalesceAction::ApplyResize { .. }) {
2445                        applied_at = Some(ms);
2446                        break;
2447                    }
2448                }
2449
2450                prop_assert!(applied_at.is_some(), "Resize must be applied");
2451                prop_assert!(
2452                    applied_at.unwrap() <= config.hard_deadline_ms,
2453                    "Must apply within hard deadline ({}ms), took {}ms",
2454                    config.hard_deadline_ms,
2455                    applied_at.unwrap()
2456                );
2457            }
2458
2459            /// Property: applied sizes are never corrupted.
2460            ///
2461            /// When a resize is applied, the dimensions must exactly match
2462            /// what was requested (no off-by-one, no swapped axes).
2463            #[test]
2464            fn no_size_corruption(
2465                w in dimension(),
2466                h in dimension()
2467            ) {
2468                let config = CoalescerConfig::default();
2469                // Use (0,0) so no generated size (1..500) hits the skip_same_size path
2470                let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2471                let base = Instant::now();
2472
2473                c.handle_resize_at(w, h, base);
2474
2475                // Tick until applied
2476                let mut result = None;
2477                for ms in 0..200 {
2478                    let action = c.tick_at(base + Duration::from_millis(ms));
2479                    if let CoalesceAction::ApplyResize { width, height, .. } = action {
2480                        result = Some((width, height));
2481                        break;
2482                    }
2483                }
2484
2485                prop_assert!(result.is_some());
2486                let (applied_w, applied_h) = result.unwrap();
2487                prop_assert_eq!(applied_w, w, "Width must not be corrupted");
2488                prop_assert_eq!(applied_h, h, "Height must not be corrupted");
2489            }
2490
2491            /// Property: regime transitions are monotonic with event rate.
2492            ///
2493            /// Higher event rates should more reliably trigger burst mode.
2494            #[test]
2495            fn regime_follows_event_rate(
2496                event_count in 1usize..30
2497            ) {
2498                let config = CoalescerConfig {
2499                    burst_enter_rate: 10.0,
2500                    burst_exit_rate: 5.0,
2501                    ..CoalescerConfig::default()
2502                };
2503                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2504                let base = Instant::now();
2505
2506                // Very fast events (>10/sec) should trigger burst mode
2507                for i in 0..event_count {
2508                    c.handle_resize_at(
2509                        80 + i as u16,
2510                        24,
2511                        base + Duration::from_millis(i as u64 * 50), // 20 events/sec
2512                    );
2513                }
2514
2515                // With enough fast events, should enter burst
2516                if event_count >= 10 {
2517                    prop_assert_eq!(
2518                        c.regime(),
2519                        Regime::Burst,
2520                        "Many rapid events should trigger burst mode"
2521                    );
2522                }
2523            }
2524
2525            /// Property: event count invariant - coalescer tracks all incoming events.
2526            ///
2527            /// The `event_count` field tracks ALL resize events for rate calculation
2528            /// and telemetry, including same-size events that are skipped.
2529            #[test]
2530            fn event_count_invariant(
2531                events in resize_sequence(100)
2532            ) {
2533                let config = CoalescerConfig::default();
2534                let mut c = ResizeCoalescer::new(config, (80, 24));
2535                let base = Instant::now();
2536
2537                for (w, h, delay) in &events {
2538                    c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2539                }
2540
2541                let stats = c.stats();
2542                // Event count should equal total incoming events (for rate calculation).
2543                prop_assert_eq!(
2544                    stats.event_count,
2545                    events.len() as u64,
2546                    "Event count should match total incoming events"
2547                );
2548            }
2549
2550            // =========================================================================
2551            // BOCPD Property Tests (bd-3e1t.2.5)
2552            // =========================================================================
2553
2554            /// Property: BOCPD determinism - identical sequences yield identical results.
2555            #[test]
2556            fn bocpd_determinism_across_sequences(
2557                events in resize_sequence(30),
2558                tick_offset in 100u64..400
2559            ) {
2560                let config = CoalescerConfig::default().with_bocpd();
2561
2562                let results: Vec<_> = (0..2)
2563                    .map(|_| {
2564                        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2565                        let base = Instant::now();
2566
2567                        for (i, (w, h, delay)) in events.iter().enumerate() {
2568                            let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2569                            c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2570                        }
2571
2572                        let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2573                        let action = c.tick_at(base + Duration::from_millis(total_time));
2574                        (action, c.regime(), c.bocpd_p_burst())
2575                    })
2576                    .collect();
2577
2578                prop_assert_eq!(results[0], results[1], "BOCPD results must be deterministic");
2579            }
2580
2581            /// Property: BOCPD latest-wins - final resize is always applied.
2582            #[test]
2583            fn bocpd_latest_wins_never_drops(
2584                events in resize_sequence(15),
2585                final_w in dimension(),
2586                final_h in dimension()
2587            ) {
2588                if events.is_empty() {
2589                    return Ok(());
2590                }
2591
2592                let config = CoalescerConfig::default().with_bocpd();
2593                let mut c = ResizeCoalescer::new(config, (80, 24));
2594                let base = Instant::now();
2595
2596                let mut offset = 0u64;
2597                for (w, h, delay) in &events {
2598                    offset += delay;
2599                    c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2600                }
2601
2602                offset += 50;
2603                c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2604
2605                let mut final_applied = None;
2606                for tick in 0..200 {
2607                    let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2608                    if let CoalesceAction::ApplyResize { width, height, .. } = action {
2609                        final_applied = Some((width, height));
2610                    }
2611                    if !c.has_pending() && final_applied.is_some() {
2612                        break;
2613                    }
2614                }
2615
2616                if let Some((applied_w, applied_h)) = final_applied {
2617                    prop_assert_eq!(
2618                        (applied_w, applied_h),
2619                        (final_w, final_h),
2620                        "BOCPD must apply the final size"
2621                    );
2622                }
2623            }
2624
2625            /// Property: BOCPD bounded latency - hard deadline is always met.
2626            #[test]
2627            fn bocpd_bounded_latency_maintained(
2628                w in dimension(),
2629                h in dimension()
2630            ) {
2631                let config = CoalescerConfig::default().with_bocpd();
2632                let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2633                let base = Instant::now();
2634
2635                c.handle_resize_at(w, h, base);
2636
2637                let mut applied_at = None;
2638                for ms in 0..=config.hard_deadline_ms + 50 {
2639                    let action = c.tick_at(base + Duration::from_millis(ms));
2640                    if matches!(action, CoalesceAction::ApplyResize { .. }) {
2641                        applied_at = Some(ms);
2642                        break;
2643                    }
2644                }
2645
2646                prop_assert!(applied_at.is_some(), "BOCPD resize must be applied");
2647                prop_assert!(
2648                    applied_at.unwrap() <= config.hard_deadline_ms,
2649                    "BOCPD must apply within hard deadline ({}ms), took {}ms",
2650                    config.hard_deadline_ms,
2651                    applied_at.unwrap()
2652                );
2653            }
2654
2655            /// Property: BOCPD posterior always valid (normalized, bounded).
2656            #[test]
2657            fn bocpd_posterior_always_valid(
2658                events in resize_sequence(50)
2659            ) {
2660                if events.is_empty() {
2661                    return Ok(());
2662                }
2663
2664                let config = CoalescerConfig::default().with_bocpd();
2665                let mut c = ResizeCoalescer::new(config, (80, 24));
2666                let base = Instant::now();
2667
2668                for (w, h, delay) in &events {
2669                    c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2670
2671                    // Check posterior validity after each event
2672                    if let Some(bocpd) = c.bocpd() {
2673                        let sum: f64 = bocpd.run_length_posterior().iter().sum();
2674                        prop_assert!(
2675                            (sum - 1.0).abs() < 1e-8,
2676                            "Posterior must sum to 1, got {}",
2677                            sum
2678                        );
2679                    }
2680
2681                    let p_burst = c.bocpd_p_burst().unwrap();
2682                    prop_assert!(
2683                        (0.0..=1.0).contains(&p_burst),
2684                        "P(burst) must be in [0,1], got {}",
2685                        p_burst
2686                    );
2687                }
2688            }
2689        }
2690    }
2691
2692    // =========================================================================
2693    // Telemetry Hooks Tests (bd-1rz0.7)
2694    // =========================================================================
2695
2696    #[test]
2697    fn telemetry_hooks_fire_on_resize_applied() {
2698        use std::sync::Arc;
2699        use std::sync::atomic::{AtomicU32, Ordering};
2700
2701        let applied_count = Arc::new(AtomicU32::new(0));
2702        let applied_count_clone = applied_count.clone();
2703
2704        let hooks = TelemetryHooks::new().on_resize_applied(move |_entry| {
2705            applied_count_clone.fetch_add(1, Ordering::SeqCst);
2706        });
2707
2708        let mut config = test_config();
2709        config.enable_logging = true;
2710        let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2711
2712        let base = Instant::now();
2713        c.handle_resize_at(100, 40, base);
2714        c.tick_at(base + Duration::from_millis(50));
2715
2716        assert_eq!(applied_count.load(Ordering::SeqCst), 1);
2717    }
2718
2719    #[test]
2720    fn telemetry_hooks_fire_on_regime_change() {
2721        use std::sync::Arc;
2722        use std::sync::atomic::{AtomicU32, Ordering};
2723
2724        let regime_changes = Arc::new(AtomicU32::new(0));
2725        let regime_changes_clone = regime_changes.clone();
2726
2727        let hooks = TelemetryHooks::new().on_regime_change(move |_from, _to| {
2728            regime_changes_clone.fetch_add(1, Ordering::SeqCst);
2729        });
2730
2731        let config = test_config();
2732        let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2733
2734        let base = Instant::now();
2735
2736        // Rapid events to trigger burst mode
2737        for i in 0..15 {
2738            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2739        }
2740
2741        // Should have triggered at least one regime change (Steady -> Burst)
2742        assert!(regime_changes.load(Ordering::SeqCst) >= 1);
2743    }
2744
2745    #[test]
2746    fn regime_transition_count_tracks_changes() {
2747        let config = test_config();
2748        let mut c = ResizeCoalescer::new(config, (80, 24));
2749
2750        assert_eq!(c.regime_transition_count(), 0);
2751
2752        let base = Instant::now();
2753
2754        // Rapid events to trigger burst mode
2755        for i in 0..15 {
2756            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2757        }
2758
2759        // Should have transitioned to Burst at least once
2760        assert!(c.regime_transition_count() >= 1);
2761    }
2762
2763    #[test]
2764    fn cycle_time_percentiles_calculated() {
2765        let mut config = test_config();
2766        config.enable_logging = true;
2767        let mut c = ResizeCoalescer::new(config, (80, 24));
2768
2769        // Initially no percentiles
2770        assert!(c.cycle_time_percentiles().is_none());
2771
2772        let base = Instant::now();
2773
2774        // Generate multiple applies to get cycle times
2775        for i in 0..5 {
2776            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 100));
2777            c.tick_at(base + Duration::from_millis(i as u64 * 100 + 50));
2778        }
2779
2780        // Now should have percentiles
2781        let percentiles = c.cycle_time_percentiles();
2782        assert!(percentiles.is_some());
2783
2784        let p = percentiles.unwrap();
2785        assert!(p.count >= 1);
2786        assert!(p.mean_ms >= 0.0);
2787        assert!(p.p50_ms >= 0.0);
2788        assert!(p.p95_ms >= p.p50_ms);
2789        assert!(p.p99_ms >= p.p95_ms);
2790    }
2791
2792    #[test]
2793    fn cycle_time_percentiles_jsonl_format() {
2794        let percentiles = CycleTimePercentiles {
2795            p50_ms: 10.5,
2796            p95_ms: 25.3,
2797            p99_ms: 42.1,
2798            count: 100,
2799            mean_ms: 15.2,
2800        };
2801
2802        let jsonl = percentiles.to_jsonl();
2803        assert!(jsonl.contains("\"event\":\"cycle_time_percentiles\""));
2804        assert!(jsonl.contains("\"p50_ms\":10.500"));
2805        assert!(jsonl.contains("\"p95_ms\":25.300"));
2806        assert!(jsonl.contains("\"p99_ms\":42.100"));
2807        assert!(jsonl.contains("\"mean_ms\":15.200"));
2808        assert!(jsonl.contains("\"count\":100"));
2809    }
2810
2811    // =========================================================================
2812    // BOCPD Integration Tests (bd-3e1t.2.2)
2813    // =========================================================================
2814
2815    #[test]
2816    fn bocpd_disabled_by_default() {
2817        let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
2818        assert!(!c.bocpd_enabled());
2819        assert!(c.bocpd().is_none());
2820        assert!(c.bocpd_p_burst().is_none());
2821    }
2822
2823    #[test]
2824    fn bocpd_enabled_with_config() {
2825        let config = CoalescerConfig::default().with_bocpd();
2826        let c = ResizeCoalescer::new(config, (80, 24));
2827        assert!(c.bocpd_enabled());
2828        assert!(c.bocpd().is_some());
2829    }
2830
2831    #[test]
2832    fn bocpd_posterior_normalized() {
2833        let config = CoalescerConfig::default().with_bocpd();
2834        let mut c = ResizeCoalescer::new(config, (80, 24));
2835
2836        let base = Instant::now();
2837
2838        // Feed events with various inter-arrival times
2839        for i in 0..20 {
2840            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 50));
2841        }
2842
2843        // Check posterior is valid probability
2844        let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2845        assert!(
2846            (0.0..=1.0).contains(&p_burst),
2847            "P(burst) must be in [0,1], got {}",
2848            p_burst
2849        );
2850
2851        // Check BOCPD internal posterior is normalized
2852        if let Some(bocpd) = c.bocpd() {
2853            let sum: f64 = bocpd.run_length_posterior().iter().sum();
2854            assert!(
2855                (sum - 1.0).abs() < 1e-9,
2856                "Posterior must sum to 1, got {}",
2857                sum
2858            );
2859        }
2860    }
2861
2862    #[test]
2863    fn bocpd_detects_burst_from_rapid_events() {
2864        use crate::bocpd::BocpdConfig;
2865
2866        // Configure BOCPD with clear burst detection
2867        let bocpd_config = BocpdConfig {
2868            mu_steady_ms: 200.0,
2869            mu_burst_ms: 20.0,
2870            burst_threshold: 0.6,
2871            steady_threshold: 0.4,
2872            ..BocpdConfig::default()
2873        };
2874
2875        let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2876        let mut c = ResizeCoalescer::new(config, (80, 24));
2877
2878        let base = Instant::now();
2879
2880        // Feed rapid events (10ms intervals = burst-like)
2881        for i in 0..30 {
2882            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2883        }
2884
2885        // Should have high P(burst) and be in Burst regime
2886        let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2887        assert!(
2888            p_burst > 0.5,
2889            "Rapid events should yield high P(burst), got {}",
2890            p_burst
2891        );
2892        assert_eq!(
2893            c.regime(),
2894            Regime::Burst,
2895            "Regime should be Burst with rapid events"
2896        );
2897    }
2898
2899    #[test]
2900    fn bocpd_detects_steady_from_slow_events() {
2901        use crate::bocpd::BocpdConfig;
2902
2903        // Configure BOCPD with clear steady detection
2904        let bocpd_config = BocpdConfig {
2905            mu_steady_ms: 200.0,
2906            mu_burst_ms: 20.0,
2907            burst_threshold: 0.7,
2908            steady_threshold: 0.3,
2909            ..BocpdConfig::default()
2910        };
2911
2912        let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2913        let mut c = ResizeCoalescer::new(config, (80, 24));
2914
2915        let base = Instant::now();
2916
2917        // Feed slow events (300ms intervals = steady-like)
2918        for i in 0..10 {
2919            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 300));
2920        }
2921
2922        // Should have low P(burst) and be in Steady regime
2923        let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2924        assert!(
2925            p_burst < 0.5,
2926            "Slow events should yield low P(burst), got {}",
2927            p_burst
2928        );
2929        assert_eq!(
2930            c.regime(),
2931            Regime::Steady,
2932            "Regime should be Steady with slow events"
2933        );
2934    }
2935
2936    #[test]
2937    fn bocpd_recommended_delay_varies_with_regime() {
2938        let config = CoalescerConfig::default().with_bocpd();
2939        let mut c = ResizeCoalescer::new(config, (80, 24));
2940
2941        let base = Instant::now();
2942
2943        // Initial delay (before any events)
2944        c.handle_resize_at(85, 30, base);
2945        let delay_initial = c.bocpd_recommended_delay().expect("BOCPD enabled");
2946
2947        // Feed burst-like events
2948        for i in 1..30 {
2949            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2950        }
2951        let delay_burst = c.bocpd_recommended_delay().expect("BOCPD enabled");
2952
2953        // Recommended delay should be positive
2954        assert!(delay_initial > 0, "Initial delay should be positive");
2955        assert!(delay_burst > 0, "Burst delay should be positive");
2956    }
2957
2958    #[test]
2959    fn bocpd_update_is_deterministic() {
2960        let config = CoalescerConfig::default().with_bocpd();
2961
2962        let base = Instant::now();
2963
2964        // Run twice with identical inputs
2965        let results: Vec<_> = (0..2)
2966            .map(|_| {
2967                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2968                for i in 0..20 {
2969                    c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 25));
2970                }
2971                (c.regime(), c.bocpd_p_burst())
2972            })
2973            .collect();
2974
2975        assert_eq!(
2976            results[0], results[1],
2977            "BOCPD results must be deterministic"
2978        );
2979    }
2980
2981    #[test]
2982    fn bocpd_memory_bounded() {
2983        use crate::bocpd::BocpdConfig;
2984
2985        // Use a small max_run_length to test memory bounds
2986        let bocpd_config = BocpdConfig {
2987            max_run_length: 50,
2988            ..BocpdConfig::default()
2989        };
2990
2991        let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2992        let mut c = ResizeCoalescer::new(config, (80, 24));
2993
2994        let base = Instant::now();
2995
2996        // Feed many events
2997        for i in 0u64..200 {
2998            c.handle_resize_at(
2999                80 + (i as u16 % 100),
3000                24 + (i as u16 % 50),
3001                base + Duration::from_millis(i * 20),
3002            );
3003        }
3004
3005        // Check posterior length is bounded
3006        if let Some(bocpd) = c.bocpd() {
3007            let posterior_len = bocpd.run_length_posterior().len();
3008            assert!(
3009                posterior_len <= 51, // max_run_length + 1
3010                "Posterior length should be bounded, got {}",
3011                posterior_len
3012            );
3013        }
3014    }
3015
3016    #[test]
3017    fn bocpd_stable_under_mixed_traffic() {
3018        let config = CoalescerConfig::default().with_bocpd();
3019        let mut c = ResizeCoalescer::new(config, (80, 24));
3020
3021        let base = Instant::now();
3022        let mut offset = 0u64;
3023
3024        // Steady period
3025        for i in 0..5 {
3026            offset += 200;
3027            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(offset));
3028        }
3029
3030        // Burst period
3031        for i in 0..15 {
3032            offset += 15;
3033            c.handle_resize_at(90 + i, 30 + i, base + Duration::from_millis(offset));
3034        }
3035
3036        // Steady period again
3037        for i in 0..5 {
3038            offset += 250;
3039            c.handle_resize_at(100 + i, 40 + i, base + Duration::from_millis(offset));
3040        }
3041
3042        // Posterior should still be valid
3043        let p_burst = c.bocpd_p_burst().expect("BOCPD enabled");
3044        assert!(
3045            (0.0..=1.0).contains(&p_burst),
3046            "P(burst) must remain valid after mixed traffic"
3047        );
3048
3049        if let Some(bocpd) = c.bocpd() {
3050            let sum: f64 = bocpd.run_length_posterior().iter().sum();
3051            assert!((sum - 1.0).abs() < 1e-9, "Posterior must remain normalized");
3052        }
3053    }
3054}