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 web_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
80#[inline]
81fn duration_since_or_zero(now: Instant, earlier: Instant) -> Duration {
82    now.checked_duration_since(earlier)
83        .unwrap_or(Duration::ZERO)
84}
85
86fn default_resize_run_id() -> String {
87    format!("resize-{}", std::process::id())
88}
89
90fn screen_mode_str(mode: ScreenMode) -> &'static str {
91    match mode {
92        ScreenMode::Inline { .. } => "inline",
93        ScreenMode::InlineAuto { .. } => "inline_auto",
94        ScreenMode::AltScreen => "altscreen",
95    }
96}
97
98#[inline]
99fn json_escape(value: &str) -> String {
100    let mut out = String::with_capacity(value.len());
101    for ch in value.chars() {
102        match ch {
103            '"' => out.push_str("\\\""),
104            '\\' => out.push_str("\\\\"),
105            '\n' => out.push_str("\\n"),
106            '\r' => out.push_str("\\r"),
107            '\t' => out.push_str("\\t"),
108            c if c.is_control() => {
109                use std::fmt::Write as _;
110                let _ = write!(out, "\\u{:04X}", c as u32);
111            }
112            _ => out.push(ch),
113        }
114    }
115    out
116}
117
118fn evidence_prefix(
119    run_id: &str,
120    screen_mode: ScreenMode,
121    cols: u16,
122    rows: u16,
123    event_idx: u64,
124) -> String {
125    format!(
126        r#""schema_version":"{}","run_id":"{}","event_idx":{},"screen_mode":"{}","cols":{},"rows":{}"#,
127        EVIDENCE_SCHEMA_VERSION,
128        json_escape(run_id),
129        event_idx,
130        screen_mode_str(screen_mode),
131        cols,
132        rows,
133    )
134}
135
136/// Configuration for the resize coalescer.
137#[derive(Debug, Clone)]
138pub struct CoalescerConfig {
139    /// Maximum coalesce delay in steady regime (ms).
140    /// In steady state, we want quick response.
141    pub steady_delay_ms: u64,
142
143    /// Maximum coalesce delay in burst regime (ms).
144    /// During bursts, we coalesce more aggressively.
145    pub burst_delay_ms: u64,
146
147    /// Hard deadline — always render within this time (ms).
148    /// Guarantees bounded worst-case latency.
149    pub hard_deadline_ms: u64,
150
151    /// Event rate threshold to enter burst mode (events/second).
152    pub burst_enter_rate: f64,
153
154    /// Event rate threshold to exit burst mode (events/second).
155    /// Lower than enter_rate for hysteresis.
156    pub burst_exit_rate: f64,
157
158    /// Number of frames to hold in burst mode after rate drops.
159    pub cooldown_frames: u32,
160
161    /// Window size for rate calculation (number of events).
162    pub rate_window_size: usize,
163
164    /// Enable decision logging (JSONL format).
165    pub enable_logging: bool,
166
167    /// Enable BOCPD (Bayesian Online Change-Point Detection) for regime detection.
168    ///
169    /// When enabled, the coalescer uses a Bayesian posterior over run-lengths to
170    /// detect regime changes (steady vs burst), replacing the simple rate threshold
171    /// heuristics. BOCPD provides:
172    /// - Principled uncertainty quantification via P(burst)
173    /// - Automatic adaptation without hand-tuned thresholds
174    /// - Evidence logging for decision explainability
175    ///
176    /// When disabled, falls back to rate threshold heuristics.
177    pub enable_bocpd: bool,
178
179    /// BOCPD configuration (used when `enable_bocpd` is true).
180    pub bocpd_config: Option<BocpdConfig>,
181}
182
183impl Default for CoalescerConfig {
184    fn default() -> Self {
185        Self {
186            steady_delay_ms: 16, // ~60fps responsiveness
187            burst_delay_ms: 40,  // Aggressive coalescing
188            hard_deadline_ms: 100,
189            burst_enter_rate: 10.0, // 10 events/sec to enter burst
190            burst_exit_rate: 5.0,   // 5 events/sec to exit burst
191            cooldown_frames: 3,
192            rate_window_size: 8,
193            enable_logging: false,
194            enable_bocpd: false,
195            bocpd_config: None,
196        }
197    }
198}
199
200impl CoalescerConfig {
201    /// Enable or disable decision logging.
202    #[must_use]
203    pub fn with_logging(mut self, enabled: bool) -> Self {
204        self.enable_logging = enabled;
205        self
206    }
207
208    /// Enable BOCPD-based regime detection with default configuration.
209    #[must_use]
210    pub fn with_bocpd(mut self) -> Self {
211        self.enable_bocpd = true;
212        self.bocpd_config = Some(BocpdConfig::default());
213        self
214    }
215
216    /// Enable BOCPD-based regime detection with custom configuration.
217    #[must_use]
218    pub fn with_bocpd_config(mut self, config: BocpdConfig) -> Self {
219        self.enable_bocpd = true;
220        self.bocpd_config = Some(config);
221        self
222    }
223
224    /// Serialize configuration to JSONL format.
225    #[must_use]
226    pub fn to_jsonl(
227        &self,
228        run_id: &str,
229        screen_mode: ScreenMode,
230        cols: u16,
231        rows: u16,
232        event_idx: u64,
233    ) -> String {
234        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
235        format!(
236            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":{}}}"#,
237            self.steady_delay_ms,
238            self.burst_delay_ms,
239            self.hard_deadline_ms,
240            self.burst_enter_rate,
241            self.burst_exit_rate,
242            self.cooldown_frames,
243            self.rate_window_size,
244            self.enable_logging
245        )
246    }
247}
248
249/// Action returned by the coalescer.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum CoalesceAction {
252    /// No action needed.
253    None,
254
255    /// Show a placeholder/skeleton while coalescing.
256    ShowPlaceholder,
257
258    /// Apply the resize with the given dimensions.
259    ApplyResize {
260        width: u16,
261        height: u16,
262        /// Time spent coalescing.
263        coalesce_time: Duration,
264        /// Whether this was forced by hard deadline.
265        forced_by_deadline: bool,
266    },
267}
268
269/// Detected regime for resize events.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271pub enum Regime {
272    /// Single resize or slow sequence.
273    #[default]
274    Steady,
275    /// Rapid resize events (storm).
276    Burst,
277}
278
279impl Regime {
280    /// Get the stable string representation.
281    #[must_use]
282    pub const fn as_str(self) -> &'static str {
283        match self {
284            Self::Steady => "steady",
285            Self::Burst => "burst",
286        }
287    }
288}
289
290/// Event emitted when a resize operation is applied (bd-bksf.6 stub).
291///
292/// Used by [`ResizeSlaMonitor`](crate::resize_sla::ResizeSlaMonitor) for latency tracking.
293#[derive(Debug, Clone)]
294pub struct ResizeAppliedEvent {
295    /// New terminal size after resize.
296    pub new_size: (u16, u16),
297    /// Previous terminal size.
298    pub old_size: (u16, u16),
299    /// Time elapsed from resize request to apply.
300    pub elapsed: Duration,
301    /// Whether the apply was forced (hard deadline).
302    pub forced: bool,
303}
304
305/// Event emitted when regime changes between Steady and Burst (bd-bksf.6 stub).
306///
307/// Used by SLA monitoring for regime-aware alerting.
308#[derive(Debug, Clone)]
309pub struct RegimeChangeEvent {
310    /// Previous regime.
311    pub from: Regime,
312    /// New regime.
313    pub to: Regime,
314    /// Event index when transition occurred.
315    pub event_idx: u64,
316}
317
318// =============================================================================
319// Evidence Ledger for Scheduler Decisions (bd-1rz0.27)
320// =============================================================================
321
322/// Evidence supporting a scheduler decision with Bayes factors.
323///
324/// This captures the mathematical reasoning behind coalesce/apply decisions,
325/// providing explainability for the regime-adaptive scheduler.
326///
327/// # Bayes Factor Interpretation
328///
329/// The `log_bayes_factor` represents log10(P(evidence|apply_now) / P(evidence|coalesce)):
330/// - Positive values favor immediate apply (respond quickly)
331/// - Negative values favor coalescing (wait for more events)
332/// - |LBF| > 1 is "strong" evidence, |LBF| > 2 is "decisive"
333///
334/// # Example
335///
336/// ```ignore
337/// let evidence = DecisionEvidence {
338///     log_bayes_factor: 1.5,  // Strong evidence to apply now
339///     regime_contribution: 0.8,
340///     timing_contribution: 0.5,
341///     rate_contribution: 0.2,
342///     explanation: "Steady regime with long idle interval".to_string(),
343/// };
344/// ```
345#[derive(Debug, Clone)]
346pub struct DecisionEvidence {
347    /// Log10 Bayes factor: positive favors apply, negative favors coalesce.
348    pub log_bayes_factor: f64,
349
350    /// Contribution from regime detection (Steady vs Burst).
351    pub regime_contribution: f64,
352
353    /// Contribution from timing (dt_ms, time since last render).
354    pub timing_contribution: f64,
355
356    /// Contribution from event rate.
357    pub rate_contribution: f64,
358
359    /// Human-readable explanation of the decision.
360    pub explanation: String,
361}
362
363impl DecisionEvidence {
364    /// Evidence strongly favoring immediate apply (Steady regime, long idle).
365    #[must_use]
366    pub fn favor_apply(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
367        let regime_contrib = if regime == Regime::Steady { 1.0 } else { -0.5 };
368        let timing_contrib = (dt_ms / 50.0).min(2.0); // Higher dt -> favor apply
369        let rate_contrib = if event_rate < 5.0 { 0.5 } else { -0.3 };
370
371        let lbf = regime_contrib + timing_contrib + rate_contrib;
372
373        Self {
374            log_bayes_factor: lbf,
375            regime_contribution: regime_contrib,
376            timing_contribution: timing_contrib,
377            rate_contribution: rate_contrib,
378            explanation: format!(
379                "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
380                regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
381            ),
382        }
383    }
384
385    /// Evidence favoring coalescing (Burst regime, high rate).
386    #[must_use]
387    pub fn favor_coalesce(regime: Regime, dt_ms: f64, event_rate: f64) -> Self {
388        let regime_contrib = if regime == Regime::Burst { 1.0 } else { -0.5 };
389        let timing_contrib = (20.0 / dt_ms.max(1.0)).min(2.0); // Lower dt -> favor coalesce
390        let rate_contrib = if event_rate > 10.0 { 0.5 } else { -0.3 };
391
392        let lbf = -(regime_contrib + timing_contrib + rate_contrib);
393
394        Self {
395            log_bayes_factor: lbf,
396            regime_contribution: regime_contrib,
397            timing_contribution: timing_contrib,
398            rate_contribution: rate_contrib,
399            explanation: format!(
400                "Regime={:?} (contrib={:.2}), dt={:.1}ms (contrib={:.2}), rate={:.1}/s (contrib={:.2})",
401                regime, regime_contrib, dt_ms, timing_contrib, event_rate, rate_contrib
402            ),
403        }
404    }
405
406    /// Evidence for a forced deadline decision.
407    #[must_use]
408    pub fn forced_deadline(deadline_ms: f64) -> Self {
409        Self {
410            log_bayes_factor: f64::INFINITY,
411            regime_contribution: 0.0,
412            timing_contribution: deadline_ms,
413            rate_contribution: 0.0,
414            explanation: format!("Forced by hard deadline ({:.1}ms)", deadline_ms),
415        }
416    }
417
418    /// Serialize to JSONL format.
419    #[must_use]
420    pub fn to_jsonl(
421        &self,
422        run_id: &str,
423        screen_mode: ScreenMode,
424        cols: u16,
425        rows: u16,
426        event_idx: u64,
427    ) -> String {
428        let lbf_str = if self.log_bayes_factor.is_infinite() {
429            "\"inf\"".to_string()
430        } else {
431            format!("{:.3}", self.log_bayes_factor)
432        };
433        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
434        format!(
435            r#"{{{prefix},"event":"decision_evidence","log_bayes_factor":{},"regime_contribution":{:.3},"timing_contribution":{:.3},"rate_contribution":{:.3},"explanation":"{}"}}"#,
436            lbf_str,
437            self.regime_contribution,
438            self.timing_contribution,
439            self.rate_contribution,
440            json_escape(&self.explanation)
441        )
442    }
443
444    /// Check if evidence strongly supports the action (|LBF| > 1).
445    #[must_use]
446    pub fn is_strong(&self) -> bool {
447        self.log_bayes_factor.abs() > 1.0
448    }
449
450    /// Check if evidence decisively supports the action (|LBF| > 2).
451    #[must_use]
452    pub fn is_decisive(&self) -> bool {
453        self.log_bayes_factor.abs() > 2.0 || self.log_bayes_factor.is_infinite()
454    }
455}
456
457/// Decision log entry for observability.
458#[derive(Debug, Clone)]
459pub struct DecisionLog {
460    /// Timestamp of the decision.
461    pub timestamp: Instant,
462    /// Elapsed time since logging started (ms).
463    pub elapsed_ms: f64,
464    /// Event index in session.
465    pub event_idx: u64,
466    /// Time since last event (ms).
467    pub dt_ms: f64,
468    /// Current event rate (events/sec).
469    pub event_rate: f64,
470    /// Detected regime.
471    pub regime: Regime,
472    /// Chosen action.
473    pub action: &'static str,
474    /// Pending size (if any).
475    pub pending_size: Option<(u16, u16)>,
476    /// Applied size (for apply decisions).
477    pub applied_size: Option<(u16, u16)>,
478    /// Time since last render (ms).
479    pub time_since_render_ms: f64,
480    /// Time spent coalescing until apply (ms).
481    pub coalesce_ms: Option<f64>,
482    /// Was forced by deadline.
483    pub forced: bool,
484}
485
486impl DecisionLog {
487    /// Serialize decision log to JSONL format.
488    #[must_use]
489    pub fn to_jsonl(&self, run_id: &str, screen_mode: ScreenMode, cols: u16, rows: u16) -> String {
490        let (pending_w, pending_h) = match self.pending_size {
491            Some((w, h)) => (w.to_string(), h.to_string()),
492            None => ("null".to_string(), "null".to_string()),
493        };
494        let (applied_w, applied_h) = match self.applied_size {
495            Some((w, h)) => (w.to_string(), h.to_string()),
496            None => ("null".to_string(), "null".to_string()),
497        };
498        let coalesce_ms = match self.coalesce_ms {
499            Some(ms) => format!("{:.3}", ms),
500            None => "null".to_string(),
501        };
502        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, self.event_idx);
503
504        format!(
505            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":{}}}"#,
506            self.event_idx,
507            self.elapsed_ms,
508            self.dt_ms,
509            self.event_rate,
510            self.regime.as_str(),
511            self.action,
512            pending_w,
513            pending_h,
514            applied_w,
515            applied_h,
516            self.time_since_render_ms,
517            coalesce_ms,
518            self.forced
519        )
520    }
521}
522
523/// Adaptive resize stream coalescer.
524///
525/// Implements latest-wins coalescing with regime-aware behavior.
526#[derive(Debug)]
527pub struct ResizeCoalescer {
528    config: CoalescerConfig,
529
530    /// Currently pending size (latest wins).
531    pending_size: Option<(u16, u16)>,
532
533    /// Last applied size.
534    last_applied: (u16, u16),
535
536    /// Timestamp of first event in current coalesce window.
537    window_start: Option<Instant>,
538
539    /// Timestamp of last resize event.
540    last_event: Option<Instant>,
541
542    /// Timestamp of last render.
543    last_render: Instant,
544
545    /// Current detected regime.
546    regime: Regime,
547
548    /// Frames remaining in cooldown (for burst exit hysteresis).
549    cooldown_remaining: u32,
550
551    /// Recent event timestamps for rate calculation.
552    event_times: VecDeque<Instant>,
553
554    /// Total event count.
555    event_count: u64,
556
557    /// Logging start time for elapsed timestamps.
558    log_start: Option<Instant>,
559
560    /// Decision logs (if logging enabled).
561    logs: Vec<DecisionLog>,
562    /// Evidence sink for JSONL decision logs.
563    evidence_sink: Option<EvidenceSink>,
564    /// Whether config has been logged to the evidence sink.
565    config_logged: bool,
566    /// Run identifier for evidence logs.
567    evidence_run_id: String,
568    /// Screen mode label for evidence logs.
569    evidence_screen_mode: ScreenMode,
570
571    // --- Telemetry integration (bd-1rz0.7) ---
572    /// Telemetry hooks for external observability.
573    telemetry_hooks: Option<TelemetryHooks>,
574
575    /// Count of regime transitions during this session.
576    regime_transitions: u64,
577
578    /// Events coalesced in current window.
579    events_in_window: u64,
580
581    /// History of cycle times (ms) for percentile calculation.
582    cycle_times: Vec<f64>,
583
584    /// BOCPD detector for Bayesian regime detection (when enabled).
585    bocpd: Option<BocpdDetector>,
586}
587
588/// Cycle time percentiles for reflow diagnostics (bd-1rz0.7).
589#[derive(Debug, Clone, Copy)]
590pub struct CycleTimePercentiles {
591    /// 50th percentile (median) cycle time in ms.
592    pub p50_ms: f64,
593    /// 95th percentile cycle time in ms.
594    pub p95_ms: f64,
595    /// 99th percentile cycle time in ms.
596    pub p99_ms: f64,
597    /// Number of samples.
598    pub count: usize,
599    /// Mean cycle time in ms.
600    pub mean_ms: f64,
601}
602
603impl CycleTimePercentiles {
604    /// Serialize to JSONL format.
605    #[must_use]
606    pub fn to_jsonl(&self) -> String {
607        format!(
608            r#"{{"event":"cycle_time_percentiles","p50_ms":{:.3},"p95_ms":{:.3},"p99_ms":{:.3},"mean_ms":{:.3},"count":{}}}"#,
609            self.p50_ms, self.p95_ms, self.p99_ms, self.mean_ms, self.count
610        )
611    }
612}
613
614impl ResizeCoalescer {
615    /// Create a new coalescer with the given configuration and initial size.
616    pub fn new(config: CoalescerConfig, initial_size: (u16, u16)) -> Self {
617        let bocpd = if config.enable_bocpd {
618            let mut bocpd_cfg = config.bocpd_config.clone().unwrap_or_default();
619            if config.enable_logging {
620                bocpd_cfg.enable_logging = true;
621            }
622            Some(BocpdDetector::new(bocpd_cfg))
623        } else {
624            None
625        };
626
627        Self {
628            config,
629            pending_size: None,
630            last_applied: initial_size,
631            window_start: None,
632            last_event: None,
633            last_render: Instant::now(),
634            regime: Regime::Steady,
635            cooldown_remaining: 0,
636            event_times: VecDeque::new(),
637            event_count: 0,
638            log_start: None,
639            logs: Vec::new(),
640            evidence_sink: None,
641            config_logged: false,
642            evidence_run_id: default_resize_run_id(),
643            evidence_screen_mode: ScreenMode::AltScreen,
644            telemetry_hooks: None,
645            regime_transitions: 0,
646            events_in_window: 0,
647            cycle_times: Vec::new(),
648            bocpd,
649        }
650    }
651
652    /// Attach telemetry hooks for external observability.
653    #[must_use]
654    pub fn with_telemetry_hooks(mut self, hooks: TelemetryHooks) -> Self {
655        self.telemetry_hooks = Some(hooks);
656        self
657    }
658
659    /// Attach an evidence sink for JSONL decision logs.
660    #[must_use]
661    pub fn with_evidence_sink(mut self, sink: EvidenceSink) -> Self {
662        self.evidence_sink = Some(sink);
663        self.config_logged = false;
664        self
665    }
666
667    /// Set the run identifier used in evidence logs.
668    #[must_use]
669    pub fn with_evidence_run_id(mut self, run_id: impl Into<String>) -> Self {
670        self.evidence_run_id = run_id.into();
671        self
672    }
673
674    /// Set the screen mode label used in evidence logs.
675    #[must_use]
676    pub fn with_screen_mode(mut self, screen_mode: ScreenMode) -> Self {
677        self.evidence_screen_mode = screen_mode;
678        self
679    }
680
681    /// Set or clear the evidence sink.
682    pub fn set_evidence_sink(&mut self, sink: Option<EvidenceSink>) {
683        self.evidence_sink = sink;
684        self.config_logged = false;
685    }
686
687    /// Set the last render time (for deterministic testing).
688    #[must_use]
689    pub fn with_last_render(mut self, time: Instant) -> Self {
690        self.last_render = time;
691        self
692    }
693
694    /// Record an externally-applied resize (immediate path).
695    pub fn record_external_apply(&mut self, width: u16, height: u16, now: Instant) {
696        self.event_count += 1;
697        self.event_times.push_back(now);
698        while self.event_times.len() > self.config.rate_window_size {
699            self.event_times.pop_front();
700        }
701        self.update_regime(now);
702
703        self.pending_size = None;
704        self.window_start = None;
705        self.last_event = Some(now);
706        self.last_applied = (width, height);
707        self.last_render = now;
708        self.events_in_window = 0;
709        self.cooldown_remaining = 0;
710
711        self.log_decision(now, "apply_immediate", false, Some(0.0), Some(0.0));
712
713        if let Some(ref hooks) = self.telemetry_hooks
714            && let Some(entry) = self.logs.last()
715        {
716            hooks.fire_resize_applied(entry);
717        }
718    }
719
720    /// Get current regime transition count.
721    #[must_use]
722    pub fn regime_transition_count(&self) -> u64 {
723        self.regime_transitions
724    }
725
726    /// Get cycle time percentiles (p50, p95, p99) in milliseconds.
727    /// Returns None if no cycle times recorded.
728    #[must_use]
729    pub fn cycle_time_percentiles(&self) -> Option<CycleTimePercentiles> {
730        if self.cycle_times.is_empty() {
731            return None;
732        }
733
734        let mut sorted = self.cycle_times.clone();
735        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
736
737        let len = sorted.len();
738        let p50_idx = len / 2;
739        let p95_idx = (len * 95) / 100;
740        let p99_idx = (len * 99) / 100;
741
742        Some(CycleTimePercentiles {
743            p50_ms: sorted[p50_idx],
744            p95_ms: sorted[p95_idx.min(len - 1)],
745            p99_ms: sorted[p99_idx.min(len - 1)],
746            count: len,
747            mean_ms: sorted.iter().sum::<f64>() / len as f64,
748        })
749    }
750
751    /// Handle a resize event.
752    ///
753    /// Returns the action to take immediately.
754    pub fn handle_resize(&mut self, width: u16, height: u16) -> CoalesceAction {
755        self.handle_resize_at(width, height, Instant::now())
756    }
757
758    /// Handle a resize event at a specific time (for testing).
759    pub fn handle_resize_at(&mut self, width: u16, height: u16, now: Instant) -> CoalesceAction {
760        self.event_count += 1;
761
762        // Track event time for rate calculation
763        self.event_times.push_back(now);
764        while self.event_times.len() > self.config.rate_window_size {
765            self.event_times.pop_front();
766        }
767
768        // Update regime based on event rate
769        self.update_regime(now);
770
771        // Calculate dt
772        let dt = self.last_event.map(|t| duration_since_or_zero(now, t));
773        let dt_ms = dt.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0);
774        self.last_event = Some(now);
775
776        // If no pending, and this matches current size, no action needed
777        if self.pending_size.is_none() && (width, height) == self.last_applied {
778            self.log_decision(now, "skip_same_size", false, Some(dt_ms), None);
779            return CoalesceAction::None;
780        }
781
782        // Update pending size (latest wins)
783        self.pending_size = Some((width, height));
784
785        // Track events in current coalesce window (bd-1rz0.7)
786        self.events_in_window += 1;
787
788        // Mark window start if this is first event
789        if self.window_start.is_none() {
790            self.window_start = Some(now);
791        }
792
793        // Check hard deadline
794        let time_since_render = duration_since_or_zero(now, self.last_render);
795        if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
796            return self.apply_pending_at(now, true);
797        }
798
799        // If enough time has passed since the last event, apply now.
800        // In heuristic mode, only apply immediately in Steady; burst applies via tick.
801        if let Some(dt) = dt
802            && dt >= Duration::from_millis(self.current_delay_ms())
803            && (self.bocpd.is_some() || self.regime == Regime::Steady)
804        {
805            return self.apply_pending_at(now, false);
806        }
807
808        self.log_decision(now, "coalesce", false, Some(dt_ms), None);
809
810        // Fire decision hook for coalesce events (bd-1rz0.7)
811        if let Some(ref hooks) = self.telemetry_hooks
812            && let Some(entry) = self.logs.last()
813        {
814            hooks.fire_decision(entry);
815        }
816
817        CoalesceAction::ShowPlaceholder
818    }
819
820    /// Tick the coalescer (call each frame).
821    ///
822    /// Returns the action to take.
823    pub fn tick(&mut self) -> CoalesceAction {
824        self.tick_at(Instant::now())
825    }
826
827    /// Tick at a specific time (for testing).
828    pub fn tick_at(&mut self, now: Instant) -> CoalesceAction {
829        if self.pending_size.is_none() {
830            return CoalesceAction::None;
831        }
832
833        if self.window_start.is_none() {
834            return CoalesceAction::None;
835        }
836
837        // Check hard deadline
838        let time_since_render = duration_since_or_zero(now, self.last_render);
839        if time_since_render >= Duration::from_millis(self.config.hard_deadline_ms) {
840            return self.apply_pending_at(now, true);
841        }
842
843        let delay_ms = self.current_delay_ms();
844
845        // Check if enough time has passed since last event
846        if let Some(last_event) = self.last_event {
847            let since_last_event = duration_since_or_zero(now, last_event);
848            if since_last_event >= Duration::from_millis(delay_ms) {
849                return self.apply_pending_at(now, false);
850            }
851        }
852
853        // Update cooldown
854        if self.cooldown_remaining > 0 {
855            self.cooldown_remaining -= 1;
856            if self.cooldown_remaining == 0 && self.regime == Regime::Burst {
857                let rate = self.calculate_event_rate(now);
858                if rate < self.config.burst_exit_rate {
859                    self.regime = Regime::Steady;
860                }
861            }
862        }
863
864        CoalesceAction::None
865    }
866
867    /// Time until the pending resize should be applied.
868    pub fn time_until_apply(&self, now: Instant) -> Option<Duration> {
869        let _pending = self.pending_size?;
870        let last_event = self.last_event?;
871
872        let delay_ms = self.current_delay_ms();
873
874        let elapsed = duration_since_or_zero(now, last_event);
875        let target = Duration::from_millis(delay_ms);
876
877        if elapsed >= target {
878            Some(Duration::ZERO)
879        } else {
880            Some(target - elapsed)
881        }
882    }
883
884    /// Check if there's a pending resize.
885    #[inline]
886    pub fn has_pending(&self) -> bool {
887        self.pending_size.is_some()
888    }
889
890    /// Get the current regime.
891    #[inline]
892    pub fn regime(&self) -> Regime {
893        self.regime
894    }
895
896    /// Check if BOCPD-based regime detection is enabled.
897    #[inline]
898    pub fn bocpd_enabled(&self) -> bool {
899        self.bocpd.is_some()
900    }
901
902    /// Get the BOCPD detector for inspection (if enabled).
903    ///
904    /// Returns `None` if BOCPD is not enabled.
905    #[inline]
906    pub fn bocpd(&self) -> Option<&BocpdDetector> {
907        self.bocpd.as_ref()
908    }
909
910    /// Get the current P(burst) from BOCPD (if enabled).
911    ///
912    /// Returns the posterior probability that the system is in burst regime.
913    /// Returns `None` if BOCPD is not enabled.
914    #[inline]
915    pub fn bocpd_p_burst(&self) -> Option<f64> {
916        self.bocpd.as_ref().map(|b| b.p_burst())
917    }
918
919    /// Get the recommended delay from BOCPD (if enabled).
920    ///
921    /// Returns the recommended coalesce delay in milliseconds based on the
922    /// current posterior distribution. Returns `None` if BOCPD is not enabled.
923    #[inline]
924    pub fn bocpd_recommended_delay(&self) -> Option<u64> {
925        self.bocpd
926            .as_ref()
927            .map(|b| b.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms))
928    }
929
930    /// Get the current event rate (events/second).
931    pub fn event_rate(&self) -> f64 {
932        self.calculate_event_rate(Instant::now())
933    }
934
935    /// Get the last applied size.
936    #[inline]
937    pub fn last_applied(&self) -> (u16, u16) {
938        self.last_applied
939    }
940
941    /// Get decision logs (if logging enabled).
942    pub fn logs(&self) -> &[DecisionLog] {
943        &self.logs
944    }
945
946    /// Clear decision logs.
947    pub fn clear_logs(&mut self) {
948        self.logs.clear();
949        self.log_start = None;
950        self.config_logged = false;
951    }
952
953    /// Get statistics about the coalescer.
954    pub fn stats(&self) -> CoalescerStats {
955        CoalescerStats {
956            event_count: self.event_count,
957            regime: self.regime,
958            event_rate: self.event_rate(),
959            has_pending: self.pending_size.is_some(),
960            last_applied: self.last_applied,
961        }
962    }
963
964    /// Export decision logs as JSONL (one entry per line).
965    #[must_use]
966    pub fn decision_logs_jsonl(&self) -> String {
967        let (cols, rows) = self.last_applied;
968        let run_id = self.evidence_run_id.as_str();
969        let screen_mode = self.evidence_screen_mode;
970        self.logs
971            .iter()
972            .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows))
973            .collect::<Vec<_>>()
974            .join("\n")
975    }
976
977    /// Compute a deterministic checksum of decision logs.
978    #[must_use]
979    pub fn decision_checksum(&self) -> u64 {
980        let mut hash = FNV_OFFSET_BASIS;
981        for entry in &self.logs {
982            fnv_hash_bytes(&mut hash, &entry.event_idx.to_le_bytes());
983            fnv_hash_bytes(&mut hash, &entry.elapsed_ms.to_bits().to_le_bytes());
984            fnv_hash_bytes(&mut hash, &entry.dt_ms.to_bits().to_le_bytes());
985            fnv_hash_bytes(&mut hash, &entry.event_rate.to_bits().to_le_bytes());
986            fnv_hash_bytes(
987                &mut hash,
988                &[match entry.regime {
989                    Regime::Steady => 0u8,
990                    Regime::Burst => 1u8,
991                }],
992            );
993            fnv_hash_bytes(&mut hash, entry.action.as_bytes());
994            fnv_hash_bytes(&mut hash, &[0u8]); // separator
995
996            fnv_hash_bytes(&mut hash, &[entry.pending_size.is_some() as u8]);
997            if let Some((w, h)) = entry.pending_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(&mut hash, &[entry.applied_size.is_some() as u8]);
1003            if let Some((w, h)) = entry.applied_size {
1004                fnv_hash_bytes(&mut hash, &w.to_le_bytes());
1005                fnv_hash_bytes(&mut hash, &h.to_le_bytes());
1006            }
1007
1008            fnv_hash_bytes(
1009                &mut hash,
1010                &entry.time_since_render_ms.to_bits().to_le_bytes(),
1011            );
1012            fnv_hash_bytes(&mut hash, &[entry.coalesce_ms.is_some() as u8]);
1013            if let Some(ms) = entry.coalesce_ms {
1014                fnv_hash_bytes(&mut hash, &ms.to_bits().to_le_bytes());
1015            }
1016            fnv_hash_bytes(&mut hash, &[entry.forced as u8]);
1017        }
1018        hash
1019    }
1020
1021    /// Compute checksum as hex string.
1022    #[must_use]
1023    pub fn decision_checksum_hex(&self) -> String {
1024        format!("{:016x}", self.decision_checksum())
1025    }
1026
1027    /// Compute a summary of the decision log.
1028    #[must_use]
1029    #[allow(clippy::field_reassign_with_default)]
1030    pub fn decision_summary(&self) -> DecisionSummary {
1031        let mut summary = DecisionSummary::default();
1032        summary.decision_count = self.logs.len();
1033        summary.last_applied = self.last_applied;
1034        summary.regime = self.regime;
1035
1036        for entry in &self.logs {
1037            match entry.action {
1038                "apply" | "apply_forced" | "apply_immediate" => {
1039                    summary.apply_count += 1;
1040                    if entry.forced {
1041                        summary.forced_apply_count += 1;
1042                    }
1043                }
1044                "coalesce" => summary.coalesce_count += 1,
1045                "skip_same_size" => summary.skip_count += 1,
1046                _ => {}
1047            }
1048        }
1049
1050        summary.checksum = self.decision_checksum();
1051        summary
1052    }
1053
1054    /// Export config + decision logs + summary as JSONL.
1055    #[must_use]
1056    pub fn evidence_to_jsonl(&self) -> String {
1057        let mut lines = Vec::with_capacity(self.logs.len() + 2);
1058        let (cols, rows) = self.last_applied;
1059        let run_id = self.evidence_run_id.as_str();
1060        let screen_mode = self.evidence_screen_mode;
1061        let summary_event_idx = self.logs.last().map(|entry| entry.event_idx).unwrap_or(0);
1062        lines.push(self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1063        lines.extend(
1064            self.logs
1065                .iter()
1066                .map(|entry| entry.to_jsonl(run_id, screen_mode, cols, rows)),
1067        );
1068        lines.push(self.decision_summary().to_jsonl(
1069            run_id,
1070            screen_mode,
1071            cols,
1072            rows,
1073            summary_event_idx,
1074        ));
1075        lines.join("\n")
1076    }
1077
1078    // --- Internal methods ---
1079
1080    fn apply_pending_at(&mut self, now: Instant, forced: bool) -> CoalesceAction {
1081        let Some((width, height)) = self.pending_size.take() else {
1082            return CoalesceAction::None;
1083        };
1084
1085        let coalesce_time = self
1086            .window_start
1087            .map(|s| duration_since_or_zero(now, s))
1088            .unwrap_or(Duration::ZERO);
1089        let coalesce_ms = coalesce_time.as_secs_f64() * 1000.0;
1090
1091        // Track cycle time for percentile calculation (bd-1rz0.7)
1092        self.cycle_times.push(coalesce_ms);
1093
1094        self.window_start = None;
1095        self.last_applied = (width, height);
1096        self.last_render = now;
1097
1098        // Reset events in window counter
1099        self.events_in_window = 0;
1100
1101        self.log_decision(
1102            now,
1103            if forced { "apply_forced" } else { "apply" },
1104            forced,
1105            None,
1106            Some(coalesce_ms),
1107        );
1108
1109        // Fire telemetry hooks (bd-1rz0.7)
1110        if let Some(ref hooks) = self.telemetry_hooks
1111            && let Some(entry) = self.logs.last()
1112        {
1113            hooks.fire_resize_applied(entry);
1114        }
1115
1116        CoalesceAction::ApplyResize {
1117            width,
1118            height,
1119            coalesce_time,
1120            forced_by_deadline: forced,
1121        }
1122    }
1123
1124    #[inline]
1125    fn current_delay_ms(&self) -> u64 {
1126        if let Some(ref bocpd) = self.bocpd {
1127            bocpd.recommended_delay(self.config.steady_delay_ms, self.config.burst_delay_ms)
1128        } else {
1129            match self.regime {
1130                Regime::Steady => self.config.steady_delay_ms,
1131                Regime::Burst => self.config.burst_delay_ms,
1132            }
1133        }
1134    }
1135
1136    fn update_regime(&mut self, now: Instant) {
1137        let old_regime = self.regime;
1138
1139        // Use BOCPD for regime detection when enabled
1140        if let Some(ref mut bocpd) = self.bocpd {
1141            // Update BOCPD with the event timestamp (it calculates inter-arrival internally)
1142            bocpd.observe_event(now);
1143
1144            // Map BOCPD regime to coalescer regime
1145            self.regime = match bocpd.regime() {
1146                BocpdRegime::Steady => Regime::Steady,
1147                BocpdRegime::Burst => Regime::Burst,
1148                BocpdRegime::Transitional => {
1149                    // During transition, maintain current regime to avoid thrashing
1150                    self.regime
1151                }
1152            };
1153        } else {
1154            // Fall back to heuristic rate-based detection
1155            let rate = self.calculate_event_rate(now);
1156
1157            match self.regime {
1158                Regime::Steady => {
1159                    if rate >= self.config.burst_enter_rate {
1160                        self.regime = Regime::Burst;
1161                        self.cooldown_remaining = self.config.cooldown_frames;
1162                    }
1163                }
1164                Regime::Burst => {
1165                    if rate < self.config.burst_exit_rate {
1166                        // Don't exit immediately — use cooldown
1167                        if self.cooldown_remaining == 0 {
1168                            self.cooldown_remaining = self.config.cooldown_frames;
1169                        }
1170                    } else {
1171                        // Still in burst, reset cooldown
1172                        self.cooldown_remaining = self.config.cooldown_frames;
1173                    }
1174                }
1175            }
1176        }
1177
1178        // Track regime transitions and fire telemetry hooks (bd-1rz0.7)
1179        if old_regime != self.regime {
1180            self.regime_transitions += 1;
1181            if let Some(ref hooks) = self.telemetry_hooks {
1182                hooks.fire_regime_change(old_regime, self.regime);
1183            }
1184        }
1185    }
1186
1187    fn calculate_event_rate(&self, now: Instant) -> f64 {
1188        if self.event_times.len() < 2 {
1189            return 0.0;
1190        }
1191
1192        let first = *self
1193            .event_times
1194            .front()
1195            .expect("event_times has >=2 elements per length guard");
1196        let window_duration = match now.checked_duration_since(first) {
1197            Some(duration) => duration,
1198            None => return 0.0,
1199        };
1200
1201        // Enforce a minimum duration of 1ms to prevent divide-by-zero or instability
1202        // and to correctly reflect high rates for near-instantaneous bursts.
1203        let duration_secs = window_duration.as_secs_f64().max(0.001);
1204
1205        (self.event_times.len() as f64) / duration_secs
1206    }
1207
1208    fn log_decision(
1209        &mut self,
1210        now: Instant,
1211        action: &'static str,
1212        forced: bool,
1213        dt_ms_override: Option<f64>,
1214        coalesce_ms: Option<f64>,
1215    ) {
1216        if !self.config.enable_logging {
1217            return;
1218        }
1219
1220        if self.log_start.is_none() {
1221            self.log_start = Some(now);
1222        }
1223
1224        let elapsed_ms = self
1225            .log_start
1226            .map(|t| duration_since_or_zero(now, t).as_secs_f64() * 1000.0)
1227            .unwrap_or(0.0);
1228
1229        let dt_ms = dt_ms_override
1230            .or_else(|| {
1231                self.last_event
1232                    .map(|t| duration_since_or_zero(now, t).as_secs_f64() * 1000.0)
1233            })
1234            .unwrap_or(0.0);
1235
1236        let time_since_render_ms =
1237            duration_since_or_zero(now, self.last_render).as_secs_f64() * 1000.0;
1238
1239        let applied_size =
1240            if action == "apply" || action == "apply_forced" || action == "apply_immediate" {
1241                Some(self.last_applied)
1242            } else {
1243                None
1244            };
1245
1246        self.logs.push(DecisionLog {
1247            timestamp: now,
1248            elapsed_ms,
1249            event_idx: self.event_count,
1250            dt_ms,
1251            event_rate: self.calculate_event_rate(now),
1252            regime: self.regime,
1253            action,
1254            pending_size: self.pending_size,
1255            applied_size,
1256            time_since_render_ms,
1257            coalesce_ms,
1258            forced,
1259        });
1260
1261        if let Some(ref sink) = self.evidence_sink {
1262            let (cols, rows) = self.last_applied;
1263            let run_id = self.evidence_run_id.as_str();
1264            let screen_mode = self.evidence_screen_mode;
1265            if !self.config_logged {
1266                let _ = sink.write_jsonl(&self.config.to_jsonl(run_id, screen_mode, cols, rows, 0));
1267                self.config_logged = true;
1268            }
1269            if let Some(entry) = self.logs.last() {
1270                let _ = sink.write_jsonl(&entry.to_jsonl(run_id, screen_mode, cols, rows));
1271            }
1272            if let Some(ref bocpd) = self.bocpd
1273                && let Some(jsonl) = bocpd.decision_log_jsonl(
1274                    self.config.steady_delay_ms,
1275                    self.config.burst_delay_ms,
1276                    forced,
1277                )
1278            {
1279                let _ = sink.write_jsonl(&jsonl);
1280            }
1281        }
1282    }
1283}
1284
1285/// Statistics about the coalescer state.
1286#[derive(Debug, Clone)]
1287pub struct CoalescerStats {
1288    /// Total events processed.
1289    pub event_count: u64,
1290    /// Current regime.
1291    pub regime: Regime,
1292    /// Current event rate (events/sec).
1293    pub event_rate: f64,
1294    /// Whether there's a pending resize.
1295    pub has_pending: bool,
1296    /// Last applied size.
1297    pub last_applied: (u16, u16),
1298}
1299
1300/// Summary of decision logs.
1301#[derive(Debug, Clone, Default)]
1302pub struct DecisionSummary {
1303    /// Total number of decisions logged.
1304    pub decision_count: usize,
1305    /// Total apply decisions.
1306    pub apply_count: usize,
1307    /// Applies forced by deadline.
1308    pub forced_apply_count: usize,
1309    /// Total coalesce decisions.
1310    pub coalesce_count: usize,
1311    /// Total skip decisions.
1312    pub skip_count: usize,
1313    /// Final regime at summary time.
1314    pub regime: Regime,
1315    /// Last applied size.
1316    pub last_applied: (u16, u16),
1317    /// Checksum for the decision log.
1318    pub checksum: u64,
1319}
1320
1321impl DecisionSummary {
1322    /// Checksum as hex string.
1323    #[must_use]
1324    pub fn checksum_hex(&self) -> String {
1325        format!("{:016x}", self.checksum)
1326    }
1327
1328    /// Serialize summary to JSONL format.
1329    #[must_use]
1330    pub fn to_jsonl(
1331        &self,
1332        run_id: &str,
1333        screen_mode: ScreenMode,
1334        cols: u16,
1335        rows: u16,
1336        event_idx: u64,
1337    ) -> String {
1338        let prefix = evidence_prefix(run_id, screen_mode, cols, rows, event_idx);
1339        format!(
1340            r#"{{{prefix},"event":"summary","decisions":{},"applies":{},"forced_applies":{},"coalesces":{},"skips":{},"regime":"{}","last_w":{},"last_h":{},"checksum":"{}"}}"#,
1341            self.decision_count,
1342            self.apply_count,
1343            self.forced_apply_count,
1344            self.coalesce_count,
1345            self.skip_count,
1346            self.regime.as_str(),
1347            self.last_applied.0,
1348            self.last_applied.1,
1349            self.checksum_hex()
1350        )
1351    }
1352}
1353
1354// =============================================================================
1355// Telemetry Hooks (bd-1rz0.7)
1356// =============================================================================
1357
1358/// Callback type for resize applied events.
1359pub type OnResizeApplied = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1360/// Callback type for regime change events.
1361pub type OnRegimeChange = Box<dyn Fn(Regime, Regime) + Send + Sync>;
1362/// Callback type for coalesce decision events.
1363pub type OnCoalesceDecision = Box<dyn Fn(&DecisionLog) + Send + Sync>;
1364
1365/// Telemetry hooks for observing resize coalescer events.
1366///
1367/// # Example
1368///
1369/// ```ignore
1370/// use ftui_runtime::resize_coalescer::{ResizeCoalescer, TelemetryHooks, CoalescerConfig};
1371///
1372/// let hooks = TelemetryHooks::new()
1373///     .on_resize_applied(|entry| println!("Applied: {}x{}", entry.applied_size.unwrap().0, entry.applied_size.unwrap().1))
1374///     .on_regime_change(|from, to| println!("Regime: {:?} -> {:?}", from, to));
1375///
1376/// let mut coalescer = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24))
1377///     .with_telemetry_hooks(hooks);
1378/// ```
1379pub struct TelemetryHooks {
1380    /// Called when a resize is applied.
1381    on_resize_applied: Option<OnResizeApplied>,
1382    /// Called when regime changes (Steady <-> Burst).
1383    on_regime_change: Option<OnRegimeChange>,
1384    /// Called for every decision (coalesce, apply, skip).
1385    on_decision: Option<OnCoalesceDecision>,
1386    /// Enable tracing events (requires `tracing` feature).
1387    emit_tracing: bool,
1388}
1389
1390impl Default for TelemetryHooks {
1391    fn default() -> Self {
1392        Self::new()
1393    }
1394}
1395
1396impl std::fmt::Debug for TelemetryHooks {
1397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1398        f.debug_struct("TelemetryHooks")
1399            .field("on_resize_applied", &self.on_resize_applied.is_some())
1400            .field("on_regime_change", &self.on_regime_change.is_some())
1401            .field("on_decision", &self.on_decision.is_some())
1402            .field("emit_tracing", &self.emit_tracing)
1403            .finish()
1404    }
1405}
1406
1407impl TelemetryHooks {
1408    /// Create a new empty hooks instance.
1409    #[must_use]
1410    pub fn new() -> Self {
1411        Self {
1412            on_resize_applied: None,
1413            on_regime_change: None,
1414            on_decision: None,
1415            emit_tracing: false,
1416        }
1417    }
1418
1419    /// Set callback for resize applied events.
1420    #[must_use]
1421    pub fn on_resize_applied<F>(mut self, callback: F) -> Self
1422    where
1423        F: Fn(&DecisionLog) + Send + Sync + 'static,
1424    {
1425        self.on_resize_applied = Some(Box::new(callback));
1426        self
1427    }
1428
1429    /// Set callback for regime change events.
1430    #[must_use]
1431    pub fn on_regime_change<F>(mut self, callback: F) -> Self
1432    where
1433        F: Fn(Regime, Regime) + Send + Sync + 'static,
1434    {
1435        self.on_regime_change = Some(Box::new(callback));
1436        self
1437    }
1438
1439    /// Set callback for all decision events.
1440    #[must_use]
1441    pub fn on_decision<F>(mut self, callback: F) -> Self
1442    where
1443        F: Fn(&DecisionLog) + Send + Sync + 'static,
1444    {
1445        self.on_decision = Some(Box::new(callback));
1446        self
1447    }
1448
1449    /// Enable tracing event emission for OpenTelemetry integration.
1450    ///
1451    /// When enabled, decision events are emitted as `tracing::event!` calls
1452    /// with target `ftui.decision.resize` and all evidence ledger fields.
1453    #[must_use]
1454    pub fn with_tracing(mut self, enabled: bool) -> Self {
1455        self.emit_tracing = enabled;
1456        self
1457    }
1458
1459    /// Check if a resize-applied hook is registered.
1460    pub fn has_resize_applied(&self) -> bool {
1461        self.on_resize_applied.is_some()
1462    }
1463
1464    /// Check if a regime-change hook is registered.
1465    pub fn has_regime_change(&self) -> bool {
1466        self.on_regime_change.is_some()
1467    }
1468
1469    /// Check if a decision hook is registered.
1470    pub fn has_decision(&self) -> bool {
1471        self.on_decision.is_some()
1472    }
1473
1474    /// Invoke the resize applied callback if set.
1475    fn fire_resize_applied(&self, entry: &DecisionLog) {
1476        if let Some(ref cb) = self.on_resize_applied {
1477            cb(entry);
1478        }
1479        if self.emit_tracing {
1480            Self::emit_resize_tracing(entry);
1481        }
1482    }
1483
1484    /// Invoke the regime change callback if set.
1485    fn fire_regime_change(&self, from: Regime, to: Regime) {
1486        if let Some(ref cb) = self.on_regime_change {
1487            cb(from, to);
1488        }
1489        if self.emit_tracing {
1490            tracing::debug!(
1491                target: "ftui.decision.resize",
1492                from_regime = %from.as_str(),
1493                to_regime = %to.as_str(),
1494                "regime_change"
1495            );
1496        }
1497    }
1498
1499    /// Invoke the decision callback if set.
1500    fn fire_decision(&self, entry: &DecisionLog) {
1501        if let Some(ref cb) = self.on_decision {
1502            cb(entry);
1503        }
1504    }
1505
1506    /// Emit a tracing event for resize decisions.
1507    fn emit_resize_tracing(entry: &DecisionLog) {
1508        let (pending_w, pending_h) = entry.pending_size.unwrap_or((0, 0));
1509        let (applied_w, applied_h) = entry.applied_size.unwrap_or((0, 0));
1510        let coalesce_ms = entry.coalesce_ms.unwrap_or(0.0);
1511
1512        tracing::info!(
1513            target: "ftui.decision.resize",
1514            event_idx = entry.event_idx,
1515            elapsed_ms = entry.elapsed_ms,
1516            dt_ms = entry.dt_ms,
1517            event_rate = entry.event_rate,
1518            regime = %entry.regime.as_str(),
1519            action = entry.action,
1520            pending_w = pending_w,
1521            pending_h = pending_h,
1522            applied_w = applied_w,
1523            applied_h = applied_h,
1524            time_since_render_ms = entry.time_since_render_ms,
1525            coalesce_ms = coalesce_ms,
1526            forced = entry.forced,
1527            "resize_decision"
1528        );
1529    }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535
1536    fn test_config() -> CoalescerConfig {
1537        CoalescerConfig {
1538            steady_delay_ms: 16,
1539            burst_delay_ms: 40,
1540            hard_deadline_ms: 100,
1541            burst_enter_rate: 10.0,
1542            burst_exit_rate: 5.0,
1543            cooldown_frames: 3,
1544            rate_window_size: 8,
1545            enable_logging: true,
1546            enable_bocpd: false,
1547            bocpd_config: None,
1548        }
1549    }
1550
1551    #[derive(Debug, Clone, Copy)]
1552    struct SimulationMetrics {
1553        event_count: u64,
1554        apply_count: u64,
1555        forced_count: u64,
1556        mean_coalesce_ms: f64,
1557        max_coalesce_ms: f64,
1558        decision_checksum: u64,
1559        final_regime: Regime,
1560    }
1561
1562    impl SimulationMetrics {
1563        fn to_jsonl(self, pattern: &str, mode: &str) -> String {
1564            let pattern = json_escape(pattern);
1565            let mode = json_escape(mode);
1566            let apply_ratio = if self.event_count == 0 {
1567                0.0
1568            } else {
1569                self.apply_count as f64 / self.event_count as f64
1570            };
1571
1572            format!(
1573                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}"}}"#,
1574                self.event_count,
1575                self.apply_count,
1576                self.forced_count,
1577                apply_ratio,
1578                self.mean_coalesce_ms,
1579                self.max_coalesce_ms,
1580                self.final_regime.as_str(),
1581                self.decision_checksum
1582            )
1583        }
1584    }
1585
1586    #[derive(Debug, Clone, Copy)]
1587    struct SimulationComparison {
1588        apply_delta: i64,
1589        mean_coalesce_delta_ms: f64,
1590    }
1591
1592    impl SimulationComparison {
1593        fn from_metrics(heuristic: SimulationMetrics, bocpd: SimulationMetrics) -> Self {
1594            let heuristic_apply = i64::try_from(heuristic.apply_count).unwrap_or(i64::MAX);
1595            let bocpd_apply = i64::try_from(bocpd.apply_count).unwrap_or(i64::MAX);
1596            let apply_delta = heuristic_apply.saturating_sub(bocpd_apply);
1597            let mean_coalesce_delta_ms = heuristic.mean_coalesce_ms - bocpd.mean_coalesce_ms;
1598            Self {
1599                apply_delta,
1600                mean_coalesce_delta_ms,
1601            }
1602        }
1603
1604        fn to_jsonl(self, pattern: &str) -> String {
1605            let pattern = json_escape(pattern);
1606            format!(
1607                r#"{{"event":"simulation_compare","pattern":"{pattern}","apply_delta":{},"mean_coalesce_delta_ms":{:.3}}}"#,
1608                self.apply_delta, self.mean_coalesce_delta_ms
1609            )
1610        }
1611    }
1612
1613    fn as_u64(value: usize) -> u64 {
1614        u64::try_from(value).unwrap_or(u64::MAX)
1615    }
1616
1617    fn build_schedule(base: Instant, events: &[(u16, u16, u64)]) -> Vec<(Instant, u16, u16)> {
1618        let mut schedule = Vec::with_capacity(events.len());
1619        let mut elapsed_ms = 0u64;
1620        for (w, h, delay_ms) in events {
1621            elapsed_ms = elapsed_ms.saturating_add(*delay_ms);
1622            schedule.push((base + Duration::from_millis(elapsed_ms), *w, *h));
1623        }
1624        schedule
1625    }
1626
1627    fn run_simulation(
1628        events: &[(u16, u16, u64)],
1629        config: CoalescerConfig,
1630        tick_ms: u64,
1631    ) -> SimulationMetrics {
1632        let mut c = ResizeCoalescer::new(config, (80, 24));
1633        let base = Instant::now();
1634        let schedule = build_schedule(base, events);
1635        let last_event_ms = schedule
1636            .last()
1637            .map(|(time, _, _)| {
1638                u64::try_from(duration_since_or_zero(*time, base).as_millis()).unwrap_or(u64::MAX)
1639            })
1640            .unwrap_or(0);
1641        let end_ms = last_event_ms
1642            .saturating_add(c.config.hard_deadline_ms)
1643            .saturating_add(tick_ms);
1644
1645        let mut next_idx = 0usize;
1646        let mut now_ms = 0u64;
1647        while now_ms <= end_ms {
1648            let now = base + Duration::from_millis(now_ms);
1649
1650            while next_idx < schedule.len() && schedule[next_idx].0 <= now {
1651                let (event_time, w, h) = schedule[next_idx];
1652                let _ = c.handle_resize_at(w, h, event_time);
1653                next_idx += 1;
1654            }
1655
1656            let _ = c.tick_at(now);
1657            now_ms = now_ms.saturating_add(tick_ms);
1658        }
1659
1660        let mut coalesce_values = Vec::new();
1661        let mut apply_count = 0usize;
1662        let mut forced_count = 0usize;
1663        for entry in c.logs() {
1664            if matches!(entry.action, "apply" | "apply_forced" | "apply_immediate") {
1665                apply_count += 1;
1666                if entry.forced {
1667                    forced_count += 1;
1668                }
1669                if let Some(ms) = entry.coalesce_ms {
1670                    coalesce_values.push(ms);
1671                }
1672            }
1673        }
1674
1675        let max_coalesce_ms = coalesce_values
1676            .iter()
1677            .copied()
1678            .fold(0.0_f64, |acc, value| acc.max(value));
1679        let mean_coalesce_ms = if coalesce_values.is_empty() {
1680            0.0
1681        } else {
1682            let sum = coalesce_values.iter().sum::<f64>();
1683            sum / as_u64(coalesce_values.len()) as f64
1684        };
1685
1686        SimulationMetrics {
1687            event_count: as_u64(events.len()),
1688            apply_count: as_u64(apply_count),
1689            forced_count: as_u64(forced_count),
1690            mean_coalesce_ms,
1691            max_coalesce_ms,
1692            decision_checksum: c.decision_checksum(),
1693            final_regime: c.regime(),
1694        }
1695    }
1696
1697    fn steady_pattern() -> Vec<(u16, u16, u64)> {
1698        let mut events = Vec::new();
1699        for i in 0..8u16 {
1700            let width = 90 + i;
1701            let height = 30 + (i % 3);
1702            events.push((width, height, 300));
1703        }
1704        events
1705    }
1706
1707    fn burst_pattern() -> Vec<(u16, u16, u64)> {
1708        let mut events = Vec::new();
1709        for i in 0..30u16 {
1710            let width = 100 + i;
1711            let height = 25 + (i % 5);
1712            events.push((width, height, 10));
1713        }
1714        events
1715    }
1716
1717    fn oscillatory_pattern() -> Vec<(u16, u16, u64)> {
1718        let mut events = Vec::new();
1719        let sizes = [(120, 40), (140, 28), (130, 36), (150, 32)];
1720        let delays = [40u64, 200u64, 60u64, 180u64];
1721        for i in 0..16usize {
1722            let (w, h) = sizes[i % sizes.len()];
1723            let delay = delays[i % delays.len()];
1724            events.push((w + (i as u16 % 3), h, delay));
1725        }
1726        events
1727    }
1728
1729    #[test]
1730    fn new_coalescer_starts_in_steady() {
1731        let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
1732        assert_eq!(c.regime(), Regime::Steady);
1733        assert!(!c.has_pending());
1734    }
1735
1736    #[test]
1737    fn same_size_returns_none() {
1738        let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1739        let action = c.handle_resize(80, 24);
1740        assert_eq!(action, CoalesceAction::None);
1741    }
1742
1743    #[test]
1744    fn different_size_shows_placeholder() {
1745        let mut c = ResizeCoalescer::new(test_config(), (80, 24));
1746        let action = c.handle_resize(100, 40);
1747        assert_eq!(action, CoalesceAction::ShowPlaceholder);
1748        assert!(c.has_pending());
1749    }
1750
1751    #[test]
1752    fn latest_wins_semantics() {
1753        let config = test_config();
1754        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1755
1756        let base = Instant::now();
1757
1758        // Rapid sequence of resizes
1759        c.handle_resize_at(90, 30, base);
1760        c.handle_resize_at(100, 40, base + Duration::from_millis(5));
1761        c.handle_resize_at(110, 50, base + Duration::from_millis(10));
1762
1763        // Wait for coalesce delay
1764        let action = c.tick_at(base + Duration::from_millis(60));
1765
1766        let (width, height) = if let CoalesceAction::ApplyResize { width, height, .. } = action {
1767            (width, height)
1768        } else {
1769            assert!(
1770                matches!(action, CoalesceAction::ApplyResize { .. }),
1771                "Expected ApplyResize, got {action:?}"
1772            );
1773            return;
1774        };
1775        assert_eq!((width, height), (110, 50), "Should apply latest size");
1776    }
1777
1778    #[test]
1779    fn hard_deadline_forces_apply() {
1780        let config = test_config();
1781        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1782
1783        let base = Instant::now();
1784
1785        // First resize
1786        c.handle_resize_at(100, 40, base);
1787
1788        // Wait past hard deadline
1789        let action = c.tick_at(base + Duration::from_millis(150));
1790
1791        let forced_by_deadline = if let CoalesceAction::ApplyResize {
1792            forced_by_deadline, ..
1793        } = action
1794        {
1795            forced_by_deadline
1796        } else {
1797            assert!(
1798                matches!(action, CoalesceAction::ApplyResize { .. }),
1799                "Expected ApplyResize, got {action:?}"
1800            );
1801            return;
1802        };
1803        assert!(forced_by_deadline, "Should be forced by deadline");
1804    }
1805
1806    #[test]
1807    fn burst_mode_detection() {
1808        let config = test_config();
1809        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1810
1811        let base = Instant::now();
1812
1813        // Rapid events to trigger burst mode
1814        for i in 0..15 {
1815            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
1816        }
1817
1818        assert_eq!(c.regime(), Regime::Burst);
1819    }
1820
1821    #[test]
1822    fn steady_mode_fast_response() {
1823        let config = test_config();
1824        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1825
1826        let base = Instant::now();
1827
1828        // Single resize
1829        c.handle_resize_at(100, 40, base);
1830
1831        // In steady mode, should apply after steady_delay
1832        let action = c.tick_at(base + Duration::from_millis(20));
1833
1834        assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1835    }
1836
1837    #[test]
1838    fn record_external_apply_updates_state_and_logs() {
1839        let config = test_config();
1840        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1841
1842        let base = Instant::now();
1843        c.handle_resize_at(100, 40, base);
1844        c.record_external_apply(120, 50, base + Duration::from_millis(5));
1845
1846        assert!(!c.has_pending());
1847        assert_eq!(c.last_applied(), (120, 50));
1848
1849        let summary = c.decision_summary();
1850        assert_eq!(summary.apply_count, 1);
1851        assert_eq!(summary.last_applied, (120, 50));
1852        assert!(
1853            c.logs()
1854                .iter()
1855                .any(|entry| entry.action == "apply_immediate"),
1856            "record_external_apply should emit apply_immediate decision"
1857        );
1858    }
1859
1860    #[test]
1861    fn coalesce_time_tracked() {
1862        let config = test_config();
1863        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1864
1865        let base = Instant::now();
1866
1867        c.handle_resize_at(100, 40, base);
1868        let action = c.tick_at(base + Duration::from_millis(50));
1869
1870        let coalesce_time = if let CoalesceAction::ApplyResize { coalesce_time, .. } = action {
1871            coalesce_time
1872        } else {
1873            assert!(
1874                matches!(action, CoalesceAction::ApplyResize { .. }),
1875                "Expected ApplyResize"
1876            );
1877            return;
1878        };
1879        assert!(coalesce_time >= Duration::from_millis(40));
1880        assert!(coalesce_time <= Duration::from_millis(60));
1881    }
1882
1883    #[test]
1884    fn event_rate_calculation() {
1885        let config = test_config();
1886        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1887
1888        let base = Instant::now();
1889
1890        // 10 events over 1 second = 10 events/sec
1891        for i in 0..10 {
1892            c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 100));
1893        }
1894
1895        let rate = c.calculate_event_rate(base + Duration::from_millis(1000));
1896        assert!(rate > 8.0 && rate < 12.0, "Rate should be ~10 events/sec");
1897    }
1898
1899    #[test]
1900    fn rapid_burst_triggers_high_rate() {
1901        let config = test_config();
1902        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1903        let base = Instant::now();
1904
1905        // Simulate 8 events arriving at the EXACT same time (or < 1ms)
1906        for _ in 0..8 {
1907            c.handle_resize_at(80, 24, base);
1908        }
1909
1910        let rate = c.calculate_event_rate(base);
1911        // 8 events / 0.001s = 8000 events/sec
1912        assert!(
1913            rate >= 1000.0,
1914            "Rate should be high for instantaneous burst, got {}",
1915            rate
1916        );
1917    }
1918
1919    #[test]
1920    fn cooldown_prevents_immediate_exit() {
1921        let config = test_config();
1922        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
1923
1924        let base = Instant::now();
1925
1926        // Enter burst mode
1927        for i in 0..15 {
1928            c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
1929        }
1930        assert_eq!(c.regime(), Regime::Burst);
1931
1932        // Rate should drop but cooldown prevents immediate exit
1933        c.tick_at(base + Duration::from_millis(500));
1934        c.tick_at(base + Duration::from_millis(600));
1935
1936        // After cooldown frames, should exit
1937        c.tick_at(base + Duration::from_millis(700));
1938        c.tick_at(base + Duration::from_millis(800));
1939        c.tick_at(base + Duration::from_millis(900));
1940
1941        // Should have exited burst
1942        // Note: This depends on rate calculation window
1943    }
1944
1945    #[test]
1946    fn logging_captures_decisions() {
1947        let mut config = test_config();
1948        config.enable_logging = true;
1949        let mut c = ResizeCoalescer::new(config, (80, 24));
1950
1951        c.handle_resize(100, 40);
1952
1953        assert!(!c.logs().is_empty());
1954        assert_eq!(c.logs()[0].action, "coalesce");
1955    }
1956
1957    #[test]
1958    fn logging_jsonl_format() {
1959        let mut config = test_config();
1960        config.enable_logging = true;
1961        let mut c = ResizeCoalescer::new(config, (80, 24));
1962
1963        c.handle_resize(100, 40);
1964        let (cols, rows) = c.last_applied();
1965        let jsonl = c.logs()[0].to_jsonl("resize-test", ScreenMode::AltScreen, cols, rows);
1966
1967        assert!(jsonl.contains("\"event\":\"decision\""));
1968        assert!(jsonl.contains("\"action\":\"coalesce\""));
1969        assert!(jsonl.contains("\"regime\":\"steady\""));
1970        assert!(jsonl.contains("\"pending_w\":100"));
1971        assert!(jsonl.contains("\"pending_h\":40"));
1972    }
1973
1974    #[test]
1975    fn apply_logs_coalesce_ms() {
1976        let mut config = test_config();
1977        config.enable_logging = true;
1978        let mut c = ResizeCoalescer::new(config, (80, 24));
1979
1980        let base = Instant::now();
1981        c.handle_resize_at(100, 40, base);
1982        let action = c.tick_at(base + Duration::from_millis(50));
1983        assert!(matches!(action, CoalesceAction::ApplyResize { .. }));
1984
1985        let last = c.logs().last().expect("Expected a decision log entry");
1986        assert!(last.coalesce_ms.is_some());
1987        assert!(last.coalesce_ms.unwrap() >= 0.0);
1988    }
1989
1990    #[test]
1991    fn decision_checksum_is_stable() {
1992        let mut config = test_config();
1993        config.enable_logging = true;
1994
1995        let base = Instant::now();
1996        let mut c1 = ResizeCoalescer::new(config.clone(), (80, 24)).with_last_render(base);
1997        let mut c2 = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
1998
1999        for c in [&mut c1, &mut c2] {
2000            c.handle_resize_at(90, 30, base);
2001            c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2002            let _ = c.tick_at(base + Duration::from_millis(80));
2003        }
2004
2005        assert_eq!(c1.decision_checksum(), c2.decision_checksum());
2006    }
2007
2008    #[test]
2009    fn evidence_jsonl_includes_summary() {
2010        let mut config = test_config();
2011        config.enable_logging = true;
2012        let mut c = ResizeCoalescer::new(config, (80, 24));
2013
2014        c.handle_resize(100, 40);
2015        let jsonl = c.evidence_to_jsonl();
2016
2017        assert!(jsonl.contains("\"event\":\"config\""));
2018        assert!(jsonl.contains("\"event\":\"summary\""));
2019    }
2020
2021    #[test]
2022    fn evidence_jsonl_parses_and_has_required_fields() {
2023        use serde_json::Value;
2024
2025        let mut config = test_config();
2026        config.enable_logging = true;
2027        let base = Instant::now();
2028        let mut c = ResizeCoalescer::new(config, (80, 24))
2029            .with_last_render(base)
2030            .with_evidence_run_id("resize-test")
2031            .with_screen_mode(ScreenMode::AltScreen);
2032
2033        c.handle_resize_at(90, 30, base);
2034        c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2035        let _ = c.tick_at(base + Duration::from_millis(120));
2036
2037        let jsonl = c.evidence_to_jsonl();
2038        let mut saw_config = false;
2039        let mut saw_decision = false;
2040        let mut saw_summary = false;
2041
2042        for line in jsonl.lines() {
2043            let value: Value = serde_json::from_str(line).expect("valid JSONL evidence");
2044            let event = value
2045                .get("event")
2046                .and_then(Value::as_str)
2047                .expect("event field");
2048            assert_eq!(value["schema_version"], EVIDENCE_SCHEMA_VERSION);
2049            assert_eq!(value["run_id"], "resize-test");
2050            assert!(
2051                value["event_idx"].is_number(),
2052                "event_idx should be numeric"
2053            );
2054            assert_eq!(value["screen_mode"], "altscreen");
2055            assert!(value["cols"].is_number(), "cols should be numeric");
2056            assert!(value["rows"].is_number(), "rows should be numeric");
2057            match event {
2058                "config" => {
2059                    for key in [
2060                        "steady_delay_ms",
2061                        "burst_delay_ms",
2062                        "hard_deadline_ms",
2063                        "burst_enter_rate",
2064                        "burst_exit_rate",
2065                        "cooldown_frames",
2066                        "rate_window_size",
2067                        "logging_enabled",
2068                    ] {
2069                        assert!(value.get(key).is_some(), "missing config field {key}");
2070                    }
2071                    saw_config = true;
2072                }
2073                "decision" => {
2074                    for key in [
2075                        "idx",
2076                        "elapsed_ms",
2077                        "dt_ms",
2078                        "event_rate",
2079                        "regime",
2080                        "action",
2081                        "pending_w",
2082                        "pending_h",
2083                        "applied_w",
2084                        "applied_h",
2085                        "time_since_render_ms",
2086                        "coalesce_ms",
2087                        "forced",
2088                    ] {
2089                        assert!(value.get(key).is_some(), "missing decision field {key}");
2090                    }
2091                    saw_decision = true;
2092                }
2093                "summary" => {
2094                    for key in [
2095                        "decisions",
2096                        "applies",
2097                        "forced_applies",
2098                        "coalesces",
2099                        "skips",
2100                        "regime",
2101                        "last_w",
2102                        "last_h",
2103                        "checksum",
2104                    ] {
2105                        assert!(value.get(key).is_some(), "missing summary field {key}");
2106                    }
2107                    saw_summary = true;
2108                }
2109                _ => {}
2110            }
2111        }
2112
2113        assert!(saw_config, "config evidence missing");
2114        assert!(saw_decision, "decision evidence missing");
2115        assert!(saw_summary, "summary evidence missing");
2116    }
2117
2118    #[test]
2119    fn evidence_jsonl_is_deterministic_for_fixed_schedule() {
2120        let mut config = test_config();
2121        config.enable_logging = true;
2122        let base = Instant::now();
2123
2124        let run = || {
2125            let mut c = ResizeCoalescer::new(config.clone(), (80, 24))
2126                .with_last_render(base)
2127                .with_evidence_run_id("resize-test")
2128                .with_screen_mode(ScreenMode::AltScreen);
2129            c.handle_resize_at(90, 30, base);
2130            c.handle_resize_at(100, 40, base + Duration::from_millis(10));
2131            let _ = c.tick_at(base + Duration::from_millis(120));
2132            c.evidence_to_jsonl()
2133        };
2134
2135        let first = run();
2136        let second = run();
2137        assert_eq!(first, second);
2138    }
2139
2140    #[test]
2141    fn bocpd_logging_inherits_coalescer_logging() {
2142        let mut config = test_config();
2143        config.enable_bocpd = true;
2144        config.bocpd_config = Some(BocpdConfig::default());
2145
2146        let c = ResizeCoalescer::new(config, (80, 24));
2147        let bocpd = c.bocpd().expect("BOCPD should be enabled");
2148        assert!(bocpd.config().enable_logging);
2149    }
2150
2151    #[test]
2152    fn stats_reflect_state() {
2153        let mut c = ResizeCoalescer::new(test_config(), (80, 24));
2154
2155        c.handle_resize(100, 40);
2156
2157        let stats = c.stats();
2158        assert_eq!(stats.event_count, 1);
2159        assert!(stats.has_pending);
2160        assert_eq!(stats.last_applied, (80, 24));
2161    }
2162
2163    #[test]
2164    fn time_until_apply_calculation() {
2165        let config = test_config();
2166        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2167
2168        let base = Instant::now();
2169        c.handle_resize_at(100, 40, base);
2170
2171        let time_left = c.time_until_apply(base + Duration::from_millis(5));
2172        assert!(time_left.is_some());
2173        let time_left = time_left.unwrap();
2174        assert!(time_left.as_millis() > 0);
2175        assert!(time_left.as_millis() < config.steady_delay_ms as u128);
2176    }
2177
2178    #[test]
2179    fn deterministic_behavior() {
2180        let config = test_config();
2181
2182        // Run twice with same inputs
2183        let results: Vec<_> = (0..2)
2184            .map(|_| {
2185                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2186                let base = Instant::now();
2187
2188                for i in 0..5 {
2189                    c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 20));
2190                }
2191
2192                c.tick_at(base + Duration::from_millis(200))
2193            })
2194            .collect();
2195
2196        assert_eq!(results[0], results[1], "Results must be deterministic");
2197    }
2198
2199    #[test]
2200    fn simulation_bocpd_vs_heuristic_metrics() {
2201        let tick_ms = 5;
2202        // Keep heuristic thresholds high so burst classification is conservative,
2203        // while BOCPD uses a responsive posterior to detect bursty streams.
2204        let mut heuristic_config = test_config();
2205        heuristic_config.burst_enter_rate = 60.0;
2206        heuristic_config.burst_exit_rate = 30.0;
2207        let mut bocpd_cfg = BocpdConfig::responsive();
2208        bocpd_cfg.burst_prior = 0.35;
2209        bocpd_cfg.steady_threshold = 0.2;
2210        bocpd_cfg.burst_threshold = 0.6;
2211        let bocpd_config = heuristic_config.clone().with_bocpd_config(bocpd_cfg);
2212        let patterns = vec![
2213            ("steady", steady_pattern()),
2214            ("burst", burst_pattern()),
2215            ("oscillatory", oscillatory_pattern()),
2216        ];
2217
2218        for (pattern, events) in patterns {
2219            let heuristic = run_simulation(&events, heuristic_config.clone(), tick_ms);
2220            let bocpd = run_simulation(&events, bocpd_config.clone(), tick_ms);
2221
2222            let heuristic_jsonl = heuristic.to_jsonl(pattern, "heuristic");
2223            let bocpd_jsonl = bocpd.to_jsonl(pattern, "bocpd");
2224            let comparison = SimulationComparison::from_metrics(heuristic, bocpd);
2225            let comparison_jsonl = comparison.to_jsonl(pattern);
2226
2227            eprintln!("{heuristic_jsonl}");
2228            eprintln!("{bocpd_jsonl}");
2229            eprintln!("{comparison_jsonl}");
2230
2231            assert!(heuristic_jsonl.contains("\"event\":\"simulation_summary\""));
2232            assert!(bocpd_jsonl.contains("\"event\":\"simulation_summary\""));
2233            assert!(comparison_jsonl.contains("\"event\":\"simulation_compare\""));
2234
2235            #[allow(clippy::cast_precision_loss)]
2236            let max_allowed = test_config().hard_deadline_ms as f64 + 1.0;
2237            assert!(
2238                heuristic.max_coalesce_ms <= max_allowed,
2239                "heuristic latency bounded for {pattern}"
2240            );
2241            assert!(
2242                bocpd.max_coalesce_ms <= max_allowed,
2243                "bocpd latency bounded for {pattern}"
2244            );
2245
2246            if pattern == "burst" {
2247                let event_count = as_u64(events.len());
2248                assert!(
2249                    heuristic.apply_count < event_count,
2250                    "heuristic should coalesce under burst pattern"
2251                );
2252                assert!(
2253                    bocpd.apply_count < event_count,
2254                    "bocpd should coalesce under burst pattern"
2255                );
2256                assert!(
2257                    comparison.apply_delta >= 0,
2258                    "BOCPD should not increase renders in burst (apply_delta={})",
2259                    comparison.apply_delta
2260                );
2261            }
2262        }
2263    }
2264
2265    #[test]
2266    fn never_drops_final_size() {
2267        let config = test_config();
2268        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2269
2270        let base = Instant::now();
2271
2272        // Many rapid resizes that may trigger some applies due to hard deadline
2273        let mut intermediate_applies = Vec::new();
2274        for i in 0..100 {
2275            let action = c.handle_resize_at(
2276                80 + (i % 50),
2277                24 + (i % 30),
2278                base + Duration::from_millis(i as u64 * 5),
2279            );
2280            if let CoalesceAction::ApplyResize { width, height, .. } = action {
2281                intermediate_applies.push((width, height));
2282            }
2283        }
2284
2285        // The final size - may apply immediately if deadline is hit
2286        let final_action = c.handle_resize_at(200, 100, base + Duration::from_millis(600));
2287
2288        let applied_size = if let CoalesceAction::ApplyResize { width, height, .. } = final_action {
2289            Some((width, height))
2290        } else {
2291            // If not applied immediately, tick until it is
2292            let mut result = None;
2293            for tick in 0..100 {
2294                let action = c.tick_at(base + Duration::from_millis(700 + tick * 20));
2295                if let CoalesceAction::ApplyResize { width, height, .. } = action {
2296                    result = Some((width, height));
2297                    break;
2298                }
2299            }
2300            result
2301        };
2302
2303        assert_eq!(
2304            applied_size,
2305            Some((200, 100)),
2306            "Must apply final size 200x100"
2307        );
2308    }
2309
2310    #[test]
2311    fn bounded_latency_invariant() {
2312        let config = test_config();
2313        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2314
2315        let base = Instant::now();
2316        c.handle_resize_at(100, 40, base);
2317
2318        // Simulate time passing without any new events
2319        let mut applied_at = None;
2320        for ms in 0..200 {
2321            let now = base + Duration::from_millis(ms);
2322            let action = c.tick_at(now);
2323            if matches!(action, CoalesceAction::ApplyResize { .. }) {
2324                applied_at = Some(ms);
2325                break;
2326            }
2327        }
2328
2329        assert!(applied_at.is_some(), "Must apply within reasonable time");
2330        assert!(
2331            applied_at.unwrap() <= config.hard_deadline_ms,
2332            "Must apply within hard deadline"
2333        );
2334    }
2335
2336    // =========================================================================
2337    // Property tests (bd-1rz0.8)
2338    // =========================================================================
2339
2340    mod property {
2341        use super::*;
2342        use proptest::prelude::*;
2343
2344        /// Strategy for generating resize dimensions.
2345        fn dimension() -> impl Strategy<Value = u16> {
2346            1u16..500
2347        }
2348
2349        /// Strategy for generating resize event sequences.
2350        fn resize_sequence(max_len: usize) -> impl Strategy<Value = Vec<(u16, u16, u64)>> {
2351            proptest::collection::vec((dimension(), dimension(), 0u64..200), 0..max_len)
2352        }
2353
2354        proptest! {
2355            /// Property: identical sequences yield identical final results.
2356            ///
2357            /// The coalescer must be deterministic - same inputs → same outputs.
2358            #[test]
2359            fn determinism_across_sequences(
2360                events in resize_sequence(50),
2361                tick_offset in 100u64..500
2362            ) {
2363                let config = CoalescerConfig::default();
2364
2365                let results: Vec<_> = (0..2)
2366                    .map(|_| {
2367                        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2368                        let base = Instant::now();
2369
2370                        for (i, (w, h, delay)) in events.iter().enumerate() {
2371                            let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2372                            c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2373                        }
2374
2375                        // Tick to trigger apply
2376                        let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2377                        c.tick_at(base + Duration::from_millis(total_time))
2378                    })
2379                    .collect();
2380
2381                prop_assert_eq!(results[0], results[1], "Results must be deterministic");
2382            }
2383
2384            /// Property: the latest resize is never lost (latest-wins semantics).
2385            ///
2386            /// When all coalescing completes, the applied size must match the
2387            /// final requested size.
2388            #[test]
2389            fn latest_wins_never_drops(
2390                events in resize_sequence(20),
2391                final_w in dimension(),
2392                final_h in dimension()
2393            ) {
2394                if events.is_empty() {
2395                    // No events to test
2396                    return Ok(());
2397                }
2398
2399                let config = CoalescerConfig::default();
2400                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2401                let base = Instant::now();
2402
2403                // Feed all events
2404                let mut offset = 0u64;
2405                for (w, h, delay) in &events {
2406                    offset += delay;
2407                    c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2408                }
2409
2410                // Add final event
2411                offset += 50;
2412                c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2413
2414                // Tick until we get an apply
2415                let mut final_applied = None;
2416                for tick in 0..200 {
2417                    let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2418                    if let CoalesceAction::ApplyResize { width, height, .. } = action {
2419                        final_applied = Some((width, height));
2420                    }
2421                    if !c.has_pending() && final_applied.is_some() {
2422                        break;
2423                    }
2424                }
2425
2426                // The final applied size must match the latest requested size
2427                if let Some((applied_w, applied_h)) = final_applied {
2428                    prop_assert_eq!(
2429                        (applied_w, applied_h),
2430                        (final_w, final_h),
2431                        "Must apply the final size {} x {}",
2432                        final_w,
2433                        final_h
2434                    );
2435                }
2436            }
2437
2438            /// Property: bounded latency is always maintained.
2439            ///
2440            /// A pending resize must be applied within hard_deadline_ms.
2441            #[test]
2442            fn bounded_latency_maintained(
2443                w in dimension(),
2444                h in dimension()
2445            ) {
2446                let config = CoalescerConfig::default();
2447                // Use (0,0) so no generated size (1..500) hits the skip_same_size path
2448                let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2449                let base = Instant::now();
2450
2451                c.handle_resize_at(w, h, base);
2452
2453                // Tick forward until applied
2454                let mut applied_at = None;
2455                for ms in 0..=config.hard_deadline_ms + 50 {
2456                    let action = c.tick_at(base + Duration::from_millis(ms));
2457                    if matches!(action, CoalesceAction::ApplyResize { .. }) {
2458                        applied_at = Some(ms);
2459                        break;
2460                    }
2461                }
2462
2463                prop_assert!(applied_at.is_some(), "Resize must be applied");
2464                prop_assert!(
2465                    applied_at.unwrap() <= config.hard_deadline_ms,
2466                    "Must apply within hard deadline ({}ms), took {}ms",
2467                    config.hard_deadline_ms,
2468                    applied_at.unwrap()
2469                );
2470            }
2471
2472            /// Property: applied sizes are never corrupted.
2473            ///
2474            /// When a resize is applied, the dimensions must exactly match
2475            /// what was requested (no off-by-one, no swapped axes).
2476            #[test]
2477            fn no_size_corruption(
2478                w in dimension(),
2479                h in dimension()
2480            ) {
2481                let config = CoalescerConfig::default();
2482                // Use (0,0) so no generated size (1..500) hits the skip_same_size path
2483                let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2484                let base = Instant::now();
2485
2486                c.handle_resize_at(w, h, base);
2487
2488                // Tick until applied
2489                let mut result = None;
2490                for ms in 0..200 {
2491                    let action = c.tick_at(base + Duration::from_millis(ms));
2492                    if let CoalesceAction::ApplyResize { width, height, .. } = action {
2493                        result = Some((width, height));
2494                        break;
2495                    }
2496                }
2497
2498                prop_assert!(result.is_some());
2499                let (applied_w, applied_h) = result.unwrap();
2500                prop_assert_eq!(applied_w, w, "Width must not be corrupted");
2501                prop_assert_eq!(applied_h, h, "Height must not be corrupted");
2502            }
2503
2504            /// Property: regime transitions are monotonic with event rate.
2505            ///
2506            /// Higher event rates should more reliably trigger burst mode.
2507            #[test]
2508            fn regime_follows_event_rate(
2509                event_count in 1usize..30
2510            ) {
2511                let config = CoalescerConfig {
2512                    burst_enter_rate: 10.0,
2513                    burst_exit_rate: 5.0,
2514                    ..CoalescerConfig::default()
2515                };
2516                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2517                let base = Instant::now();
2518
2519                // Very fast events (>10/sec) should trigger burst mode
2520                for i in 0..event_count {
2521                    c.handle_resize_at(
2522                        80 + i as u16,
2523                        24,
2524                        base + Duration::from_millis(i as u64 * 50), // 20 events/sec
2525                    );
2526                }
2527
2528                // With enough fast events, should enter burst
2529                if event_count >= 10 {
2530                    prop_assert_eq!(
2531                        c.regime(),
2532                        Regime::Burst,
2533                        "Many rapid events should trigger burst mode"
2534                    );
2535                }
2536            }
2537
2538            /// Property: event count invariant - coalescer tracks all incoming events.
2539            ///
2540            /// The `event_count` field tracks ALL resize events for rate calculation
2541            /// and telemetry, including same-size events that are skipped.
2542            #[test]
2543            fn event_count_invariant(
2544                events in resize_sequence(100)
2545            ) {
2546                let config = CoalescerConfig::default();
2547                let mut c = ResizeCoalescer::new(config, (80, 24));
2548                let base = Instant::now();
2549
2550                for (w, h, delay) in &events {
2551                    c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2552                }
2553
2554                let stats = c.stats();
2555                // Event count should equal total incoming events (for rate calculation).
2556                prop_assert_eq!(
2557                    stats.event_count,
2558                    events.len() as u64,
2559                    "Event count should match total incoming events"
2560                );
2561            }
2562
2563            // =========================================================================
2564            // BOCPD Property Tests (bd-3e1t.2.5)
2565            // =========================================================================
2566
2567            /// Property: BOCPD determinism - identical sequences yield identical results.
2568            #[test]
2569            fn bocpd_determinism_across_sequences(
2570                events in resize_sequence(30),
2571                tick_offset in 100u64..400
2572            ) {
2573                let config = CoalescerConfig::default().with_bocpd();
2574
2575                let results: Vec<_> = (0..2)
2576                    .map(|_| {
2577                        let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2578                        let base = Instant::now();
2579
2580                        for (i, (w, h, delay)) in events.iter().enumerate() {
2581                            let offset = events[..i].iter().map(|(_, _, d)| *d).sum::<u64>() + delay;
2582                            c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2583                        }
2584
2585                        let total_time = events.iter().map(|(_, _, d)| d).sum::<u64>() + tick_offset;
2586                        let action = c.tick_at(base + Duration::from_millis(total_time));
2587                        (action, c.regime(), c.bocpd_p_burst())
2588                    })
2589                    .collect();
2590
2591                prop_assert_eq!(results[0], results[1], "BOCPD results must be deterministic");
2592            }
2593
2594            /// Property: BOCPD latest-wins - final resize is always applied.
2595            #[test]
2596            fn bocpd_latest_wins_never_drops(
2597                events in resize_sequence(15),
2598                final_w in dimension(),
2599                final_h in dimension()
2600            ) {
2601                if events.is_empty() {
2602                    return Ok(());
2603                }
2604
2605                let config = CoalescerConfig::default().with_bocpd();
2606                let mut c = ResizeCoalescer::new(config, (80, 24));
2607                let base = Instant::now();
2608
2609                let mut offset = 0u64;
2610                for (w, h, delay) in &events {
2611                    offset += delay;
2612                    c.handle_resize_at(*w, *h, base + Duration::from_millis(offset));
2613                }
2614
2615                offset += 50;
2616                c.handle_resize_at(final_w, final_h, base + Duration::from_millis(offset));
2617
2618                let mut final_applied = None;
2619                for tick in 0..200 {
2620                    let action = c.tick_at(base + Duration::from_millis(offset + 10 + tick * 20));
2621                    if let CoalesceAction::ApplyResize { width, height, .. } = action {
2622                        final_applied = Some((width, height));
2623                    }
2624                    if !c.has_pending() && final_applied.is_some() {
2625                        break;
2626                    }
2627                }
2628
2629                if let Some((applied_w, applied_h)) = final_applied {
2630                    prop_assert_eq!(
2631                        (applied_w, applied_h),
2632                        (final_w, final_h),
2633                        "BOCPD must apply the final size"
2634                    );
2635                }
2636            }
2637
2638            /// Property: BOCPD bounded latency - hard deadline is always met.
2639            #[test]
2640            fn bocpd_bounded_latency_maintained(
2641                w in dimension(),
2642                h in dimension()
2643            ) {
2644                let config = CoalescerConfig::default().with_bocpd();
2645                let mut c = ResizeCoalescer::new(config.clone(), (0, 0));
2646                let base = Instant::now();
2647
2648                c.handle_resize_at(w, h, base);
2649
2650                let mut applied_at = None;
2651                for ms in 0..=config.hard_deadline_ms + 50 {
2652                    let action = c.tick_at(base + Duration::from_millis(ms));
2653                    if matches!(action, CoalesceAction::ApplyResize { .. }) {
2654                        applied_at = Some(ms);
2655                        break;
2656                    }
2657                }
2658
2659                prop_assert!(applied_at.is_some(), "BOCPD resize must be applied");
2660                prop_assert!(
2661                    applied_at.unwrap() <= config.hard_deadline_ms,
2662                    "BOCPD must apply within hard deadline ({}ms), took {}ms",
2663                    config.hard_deadline_ms,
2664                    applied_at.unwrap()
2665                );
2666            }
2667
2668            /// Property: BOCPD posterior always valid (normalized, bounded).
2669            #[test]
2670            fn bocpd_posterior_always_valid(
2671                events in resize_sequence(50)
2672            ) {
2673                if events.is_empty() {
2674                    return Ok(());
2675                }
2676
2677                let config = CoalescerConfig::default().with_bocpd();
2678                let mut c = ResizeCoalescer::new(config, (80, 24));
2679                let base = Instant::now();
2680
2681                for (w, h, delay) in &events {
2682                    c.handle_resize_at(*w, *h, base + Duration::from_millis(*delay));
2683
2684                    // Check posterior validity after each event
2685                    if let Some(bocpd) = c.bocpd() {
2686                        let sum: f64 = bocpd.run_length_posterior().iter().sum();
2687                        prop_assert!(
2688                            (sum - 1.0).abs() < 1e-8,
2689                            "Posterior must sum to 1, got {}",
2690                            sum
2691                        );
2692                    }
2693
2694                    let p_burst = c.bocpd_p_burst().unwrap();
2695                    prop_assert!(
2696                        (0.0..=1.0).contains(&p_burst),
2697                        "P(burst) must be in [0,1], got {}",
2698                        p_burst
2699                    );
2700                }
2701            }
2702        }
2703    }
2704
2705    // =========================================================================
2706    // Telemetry Hooks Tests (bd-1rz0.7)
2707    // =========================================================================
2708
2709    #[test]
2710    fn telemetry_hooks_fire_on_resize_applied() {
2711        use std::sync::Arc;
2712        use std::sync::atomic::{AtomicU32, Ordering};
2713
2714        let applied_count = Arc::new(AtomicU32::new(0));
2715        let applied_count_clone = applied_count.clone();
2716
2717        let hooks = TelemetryHooks::new().on_resize_applied(move |_entry| {
2718            applied_count_clone.fetch_add(1, Ordering::SeqCst);
2719        });
2720
2721        let mut config = test_config();
2722        config.enable_logging = true;
2723        let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2724
2725        let base = Instant::now();
2726        c.handle_resize_at(100, 40, base);
2727        c.tick_at(base + Duration::from_millis(50));
2728
2729        assert_eq!(applied_count.load(Ordering::SeqCst), 1);
2730    }
2731
2732    #[test]
2733    fn telemetry_hooks_fire_on_regime_change() {
2734        use std::sync::Arc;
2735        use std::sync::atomic::{AtomicU32, Ordering};
2736
2737        let regime_changes = Arc::new(AtomicU32::new(0));
2738        let regime_changes_clone = regime_changes.clone();
2739
2740        let hooks = TelemetryHooks::new().on_regime_change(move |_from, _to| {
2741            regime_changes_clone.fetch_add(1, Ordering::SeqCst);
2742        });
2743
2744        let config = test_config();
2745        let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
2746
2747        let base = Instant::now();
2748
2749        // Rapid events to trigger burst mode
2750        for i in 0..15 {
2751            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2752        }
2753
2754        // Should have triggered at least one regime change (Steady -> Burst)
2755        assert!(regime_changes.load(Ordering::SeqCst) >= 1);
2756    }
2757
2758    #[test]
2759    fn regime_transition_count_tracks_changes() {
2760        let config = test_config();
2761        let mut c = ResizeCoalescer::new(config, (80, 24));
2762
2763        assert_eq!(c.regime_transition_count(), 0);
2764
2765        let base = Instant::now();
2766
2767        // Rapid events to trigger burst mode
2768        for i in 0..15 {
2769            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2770        }
2771
2772        // Should have transitioned to Burst at least once
2773        assert!(c.regime_transition_count() >= 1);
2774    }
2775
2776    #[test]
2777    fn cycle_time_percentiles_calculated() {
2778        let mut config = test_config();
2779        config.enable_logging = true;
2780        let mut c = ResizeCoalescer::new(config, (80, 24));
2781
2782        // Initially no percentiles
2783        assert!(c.cycle_time_percentiles().is_none());
2784
2785        let base = Instant::now();
2786
2787        // Generate multiple applies to get cycle times
2788        for i in 0..5 {
2789            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 100));
2790            c.tick_at(base + Duration::from_millis(i as u64 * 100 + 50));
2791        }
2792
2793        // Now should have percentiles
2794        let percentiles = c.cycle_time_percentiles();
2795        assert!(percentiles.is_some());
2796
2797        let p = percentiles.unwrap();
2798        assert!(p.count >= 1);
2799        assert!(p.mean_ms >= 0.0);
2800        assert!(p.p50_ms >= 0.0);
2801        assert!(p.p95_ms >= p.p50_ms);
2802        assert!(p.p99_ms >= p.p95_ms);
2803    }
2804
2805    #[test]
2806    fn cycle_time_percentiles_jsonl_format() {
2807        let percentiles = CycleTimePercentiles {
2808            p50_ms: 10.5,
2809            p95_ms: 25.3,
2810            p99_ms: 42.1,
2811            count: 100,
2812            mean_ms: 15.2,
2813        };
2814
2815        let jsonl = percentiles.to_jsonl();
2816        assert!(jsonl.contains("\"event\":\"cycle_time_percentiles\""));
2817        assert!(jsonl.contains("\"p50_ms\":10.500"));
2818        assert!(jsonl.contains("\"p95_ms\":25.300"));
2819        assert!(jsonl.contains("\"p99_ms\":42.100"));
2820        assert!(jsonl.contains("\"mean_ms\":15.200"));
2821        assert!(jsonl.contains("\"count\":100"));
2822    }
2823
2824    // =========================================================================
2825    // BOCPD Integration Tests (bd-3e1t.2.2)
2826    // =========================================================================
2827
2828    #[test]
2829    fn bocpd_disabled_by_default() {
2830        let c = ResizeCoalescer::new(CoalescerConfig::default(), (80, 24));
2831        assert!(!c.bocpd_enabled());
2832        assert!(c.bocpd().is_none());
2833        assert!(c.bocpd_p_burst().is_none());
2834    }
2835
2836    #[test]
2837    fn bocpd_enabled_with_config() {
2838        let config = CoalescerConfig::default().with_bocpd();
2839        let c = ResizeCoalescer::new(config, (80, 24));
2840        assert!(c.bocpd_enabled());
2841        assert!(c.bocpd().is_some());
2842    }
2843
2844    #[test]
2845    fn bocpd_posterior_normalized() {
2846        let config = CoalescerConfig::default().with_bocpd();
2847        let mut c = ResizeCoalescer::new(config, (80, 24));
2848
2849        let base = Instant::now();
2850
2851        // Feed events with various inter-arrival times
2852        for i in 0..20 {
2853            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 50));
2854        }
2855
2856        // Check posterior is valid probability
2857        let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2858        assert!(
2859            (0.0..=1.0).contains(&p_burst),
2860            "P(burst) must be in [0,1], got {}",
2861            p_burst
2862        );
2863
2864        // Check BOCPD internal posterior is normalized
2865        if let Some(bocpd) = c.bocpd() {
2866            let sum: f64 = bocpd.run_length_posterior().iter().sum();
2867            assert!(
2868                (sum - 1.0).abs() < 1e-9,
2869                "Posterior must sum to 1, got {}",
2870                sum
2871            );
2872        }
2873    }
2874
2875    #[test]
2876    fn bocpd_detects_burst_from_rapid_events() {
2877        use crate::bocpd::BocpdConfig;
2878
2879        // Configure BOCPD with clear burst detection
2880        let bocpd_config = BocpdConfig {
2881            mu_steady_ms: 200.0,
2882            mu_burst_ms: 20.0,
2883            burst_threshold: 0.6,
2884            steady_threshold: 0.4,
2885            ..BocpdConfig::default()
2886        };
2887
2888        let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2889        let mut c = ResizeCoalescer::new(config, (80, 24));
2890
2891        let base = Instant::now();
2892
2893        // Feed rapid events (10ms intervals = burst-like)
2894        for i in 0..30 {
2895            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2896        }
2897
2898        // Should have high P(burst) and be in Burst regime
2899        let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2900        assert!(
2901            p_burst > 0.5,
2902            "Rapid events should yield high P(burst), got {}",
2903            p_burst
2904        );
2905        assert_eq!(
2906            c.regime(),
2907            Regime::Burst,
2908            "Regime should be Burst with rapid events"
2909        );
2910    }
2911
2912    #[test]
2913    fn bocpd_detects_steady_from_slow_events() {
2914        use crate::bocpd::BocpdConfig;
2915
2916        // Configure BOCPD with clear steady detection
2917        let bocpd_config = BocpdConfig {
2918            mu_steady_ms: 200.0,
2919            mu_burst_ms: 20.0,
2920            burst_threshold: 0.7,
2921            steady_threshold: 0.3,
2922            ..BocpdConfig::default()
2923        };
2924
2925        let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
2926        let mut c = ResizeCoalescer::new(config, (80, 24));
2927
2928        let base = Instant::now();
2929
2930        // Feed slow events (300ms intervals = steady-like)
2931        for i in 0..10 {
2932            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 300));
2933        }
2934
2935        // Should have low P(burst) and be in Steady regime
2936        let p_burst = c.bocpd_p_burst().expect("BOCPD should be enabled");
2937        assert!(
2938            p_burst < 0.5,
2939            "Slow events should yield low P(burst), got {}",
2940            p_burst
2941        );
2942        assert_eq!(
2943            c.regime(),
2944            Regime::Steady,
2945            "Regime should be Steady with slow events"
2946        );
2947    }
2948
2949    #[test]
2950    fn bocpd_recommended_delay_varies_with_regime() {
2951        let config = CoalescerConfig::default().with_bocpd();
2952        let mut c = ResizeCoalescer::new(config, (80, 24));
2953
2954        let base = Instant::now();
2955
2956        // Initial delay (before any events)
2957        c.handle_resize_at(85, 30, base);
2958        let delay_initial = c.bocpd_recommended_delay().expect("BOCPD enabled");
2959
2960        // Feed burst-like events
2961        for i in 1..30 {
2962            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 10));
2963        }
2964        let delay_burst = c.bocpd_recommended_delay().expect("BOCPD enabled");
2965
2966        // Recommended delay should be positive
2967        assert!(delay_initial > 0, "Initial delay should be positive");
2968        assert!(delay_burst > 0, "Burst delay should be positive");
2969    }
2970
2971    #[test]
2972    fn bocpd_update_is_deterministic() {
2973        let config = CoalescerConfig::default().with_bocpd();
2974
2975        let base = Instant::now();
2976
2977        // Run twice with identical inputs
2978        let results: Vec<_> = (0..2)
2979            .map(|_| {
2980                let mut c = ResizeCoalescer::new(config.clone(), (80, 24));
2981                for i in 0..20 {
2982                    c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(i as u64 * 25));
2983                }
2984                (c.regime(), c.bocpd_p_burst())
2985            })
2986            .collect();
2987
2988        assert_eq!(
2989            results[0], results[1],
2990            "BOCPD results must be deterministic"
2991        );
2992    }
2993
2994    #[test]
2995    fn bocpd_memory_bounded() {
2996        use crate::bocpd::BocpdConfig;
2997
2998        // Use a small max_run_length to test memory bounds
2999        let bocpd_config = BocpdConfig {
3000            max_run_length: 50,
3001            ..BocpdConfig::default()
3002        };
3003
3004        let config = CoalescerConfig::default().with_bocpd_config(bocpd_config);
3005        let mut c = ResizeCoalescer::new(config, (80, 24));
3006
3007        let base = Instant::now();
3008
3009        // Feed many events
3010        for i in 0u64..200 {
3011            c.handle_resize_at(
3012                80 + (i as u16 % 100),
3013                24 + (i as u16 % 50),
3014                base + Duration::from_millis(i * 20),
3015            );
3016        }
3017
3018        // Check posterior length is bounded
3019        if let Some(bocpd) = c.bocpd() {
3020            let posterior_len = bocpd.run_length_posterior().len();
3021            assert!(
3022                posterior_len <= 51, // max_run_length + 1
3023                "Posterior length should be bounded, got {}",
3024                posterior_len
3025            );
3026        }
3027    }
3028
3029    #[test]
3030    fn bocpd_stable_under_mixed_traffic() {
3031        let config = CoalescerConfig::default().with_bocpd();
3032        let mut c = ResizeCoalescer::new(config, (80, 24));
3033
3034        let base = Instant::now();
3035        let mut offset = 0u64;
3036
3037        // Steady period
3038        for i in 0..5 {
3039            offset += 200;
3040            c.handle_resize_at(80 + i, 24 + i, base + Duration::from_millis(offset));
3041        }
3042
3043        // Burst period
3044        for i in 0..15 {
3045            offset += 15;
3046            c.handle_resize_at(90 + i, 30 + i, base + Duration::from_millis(offset));
3047        }
3048
3049        // Steady period again
3050        for i in 0..5 {
3051            offset += 250;
3052            c.handle_resize_at(100 + i, 40 + i, base + Duration::from_millis(offset));
3053        }
3054
3055        // Posterior should still be valid
3056        let p_burst = c.bocpd_p_burst().expect("BOCPD enabled");
3057        assert!(
3058            (0.0..=1.0).contains(&p_burst),
3059            "P(burst) must remain valid after mixed traffic"
3060        );
3061
3062        if let Some(bocpd) = c.bocpd() {
3063            let sum: f64 = bocpd.run_length_posterior().iter().sum();
3064            assert!((sum - 1.0).abs() < 1e-9, "Posterior must remain normalized");
3065        }
3066    }
3067
3068    // =========================================================================
3069    // Evidence JSONL field validation tests (bd-plwf)
3070    // =========================================================================
3071
3072    #[test]
3073    fn evidence_decision_jsonl_contains_all_required_fields() {
3074        let log = DecisionLog {
3075            timestamp: Instant::now(),
3076            elapsed_ms: 16.5,
3077            event_idx: 1,
3078            dt_ms: 16.0,
3079            event_rate: 62.5,
3080            regime: Regime::Steady,
3081            action: "apply",
3082            pending_size: Some((100, 40)),
3083            applied_size: Some((100, 40)),
3084            time_since_render_ms: 16.2,
3085            coalesce_ms: Some(16.0),
3086            forced: false,
3087        };
3088
3089        let jsonl = log.to_jsonl("test-run-1", ScreenMode::AltScreen, 100, 40);
3090        let parsed: serde_json::Value =
3091            serde_json::from_str(&jsonl).expect("Decision JSONL must be valid JSON");
3092
3093        // Schema fields
3094        assert_eq!(
3095            parsed["schema_version"].as_str().unwrap(),
3096            EVIDENCE_SCHEMA_VERSION
3097        );
3098        assert_eq!(parsed["run_id"].as_str().unwrap(), "test-run-1");
3099        assert_eq!(parsed["event_idx"].as_u64().unwrap(), 1);
3100        assert_eq!(parsed["screen_mode"].as_str().unwrap(), "altscreen");
3101        assert_eq!(parsed["cols"].as_u64().unwrap(), 100);
3102        assert_eq!(parsed["rows"].as_u64().unwrap(), 40);
3103
3104        // Event-specific fields
3105        assert_eq!(parsed["event"].as_str().unwrap(), "decision");
3106        assert!(parsed["elapsed_ms"].as_f64().is_some());
3107        assert!(parsed["dt_ms"].as_f64().is_some());
3108        assert!(parsed["event_rate"].as_f64().is_some());
3109        assert_eq!(parsed["regime"].as_str().unwrap(), "steady");
3110        assert_eq!(parsed["action"].as_str().unwrap(), "apply");
3111        assert_eq!(parsed["pending_w"].as_u64().unwrap(), 100);
3112        assert_eq!(parsed["pending_h"].as_u64().unwrap(), 40);
3113        assert_eq!(parsed["applied_w"].as_u64().unwrap(), 100);
3114        assert_eq!(parsed["applied_h"].as_u64().unwrap(), 40);
3115        assert!(parsed["time_since_render_ms"].as_f64().is_some());
3116        assert!(parsed["coalesce_ms"].as_f64().is_some());
3117        assert!(!parsed["forced"].as_bool().unwrap());
3118    }
3119
3120    #[test]
3121    fn evidence_decision_jsonl_null_fields_when_no_pending() {
3122        let log = DecisionLog {
3123            timestamp: Instant::now(),
3124            elapsed_ms: 0.0,
3125            event_idx: 0,
3126            dt_ms: 0.0,
3127            event_rate: 0.0,
3128            regime: Regime::Steady,
3129            action: "skip_same_size",
3130            pending_size: None,
3131            applied_size: None,
3132            time_since_render_ms: 0.0,
3133            coalesce_ms: None,
3134            forced: false,
3135        };
3136
3137        let jsonl = log.to_jsonl("test-run-2", ScreenMode::AltScreen, 80, 24);
3138        let parsed: serde_json::Value =
3139            serde_json::from_str(&jsonl).expect("Decision JSONL must be valid JSON");
3140
3141        assert!(parsed["pending_w"].is_null());
3142        assert!(parsed["pending_h"].is_null());
3143        assert!(parsed["applied_w"].is_null());
3144        assert!(parsed["applied_h"].is_null());
3145        assert!(parsed["coalesce_ms"].is_null());
3146    }
3147
3148    #[test]
3149    fn evidence_config_jsonl_contains_all_fields() {
3150        let config = test_config();
3151        let jsonl = config.to_jsonl("cfg-run", ScreenMode::AltScreen, 80, 24, 0);
3152        let parsed: serde_json::Value =
3153            serde_json::from_str(&jsonl).expect("Config JSONL must be valid JSON");
3154
3155        assert_eq!(parsed["event"].as_str().unwrap(), "config");
3156        assert_eq!(
3157            parsed["schema_version"].as_str().unwrap(),
3158            EVIDENCE_SCHEMA_VERSION
3159        );
3160        assert_eq!(parsed["steady_delay_ms"].as_u64().unwrap(), 16);
3161        assert_eq!(parsed["burst_delay_ms"].as_u64().unwrap(), 40);
3162        assert_eq!(parsed["hard_deadline_ms"].as_u64().unwrap(), 100);
3163        assert!(parsed["burst_enter_rate"].as_f64().is_some());
3164        assert!(parsed["burst_exit_rate"].as_f64().is_some());
3165        assert_eq!(parsed["cooldown_frames"].as_u64().unwrap(), 3);
3166        assert_eq!(parsed["rate_window_size"].as_u64().unwrap(), 8);
3167    }
3168
3169    #[test]
3170    fn evidence_inline_screen_mode_string() {
3171        let log = DecisionLog {
3172            timestamp: Instant::now(),
3173            elapsed_ms: 0.0,
3174            event_idx: 0,
3175            dt_ms: 0.0,
3176            event_rate: 0.0,
3177            regime: Regime::Burst,
3178            action: "coalesce",
3179            pending_size: Some((120, 40)),
3180            applied_size: None,
3181            time_since_render_ms: 5.0,
3182            coalesce_ms: None,
3183            forced: false,
3184        };
3185
3186        let jsonl = log.to_jsonl("inline-run", ScreenMode::Inline { ui_height: 12 }, 120, 40);
3187        let parsed: serde_json::Value =
3188            serde_json::from_str(&jsonl).expect("JSONL must be valid JSON");
3189
3190        assert_eq!(parsed["screen_mode"].as_str().unwrap(), "inline");
3191        assert_eq!(parsed["regime"].as_str().unwrap(), "burst");
3192    }
3193
3194    #[test]
3195    fn resize_scheduling_steady_applies_within_steady_delay() {
3196        let config = CoalescerConfig {
3197            steady_delay_ms: 20,
3198            burst_delay_ms: 50,
3199            hard_deadline_ms: 200,
3200            enable_logging: true,
3201            ..test_config()
3202        };
3203        let base = Instant::now();
3204        let mut c = ResizeCoalescer::new(config, (80, 24));
3205
3206        // Single resize in steady mode
3207        let action = c.handle_resize_at(100, 40, base);
3208        // First resize may or may not apply immediately depending on implementation
3209        match action {
3210            CoalesceAction::ApplyResize { width, height, .. } => {
3211                assert_eq!(width, 100);
3212                assert_eq!(height, 40);
3213            }
3214            CoalesceAction::None | CoalesceAction::ShowPlaceholder => {
3215                // Tick past steady delay to get the apply
3216                let later = base + Duration::from_millis(25);
3217                let action = c.tick_at(later);
3218                if let CoalesceAction::ApplyResize { width, height, .. } = action {
3219                    assert_eq!(width, 100);
3220                    assert_eq!(height, 40);
3221                }
3222            }
3223        }
3224
3225        // Verify final applied size
3226        assert_eq!(c.last_applied(), (100, 40));
3227    }
3228
3229    #[test]
3230    fn resize_scheduling_burst_regime_coalesces_rapid_events() {
3231        let config = CoalescerConfig {
3232            steady_delay_ms: 16,
3233            burst_delay_ms: 40,
3234            hard_deadline_ms: 100,
3235            burst_enter_rate: 10.0,
3236            enable_logging: true,
3237            ..test_config()
3238        };
3239        let base = Instant::now();
3240        let mut c = ResizeCoalescer::new(config, (80, 24));
3241        let mut apply_count = 0u32;
3242
3243        // Rapid resize events (20 events at 50ms intervals = 20 Hz)
3244        for i in 0..20 {
3245            let t = base + Duration::from_millis(i * 50);
3246            let action = c.handle_resize_at(80 + (i as u16), 24, t);
3247            if matches!(action, CoalesceAction::ApplyResize { .. }) {
3248                apply_count += 1;
3249            }
3250            // Tick between events
3251            let tick_t = t + Duration::from_millis(10);
3252            let tick_action = c.tick_at(tick_t);
3253            if matches!(tick_action, CoalesceAction::ApplyResize { .. }) {
3254                apply_count += 1;
3255            }
3256        }
3257
3258        // Should have coalesced: fewer applies than events
3259        assert!(
3260            apply_count < 20,
3261            "Expected coalescing: {apply_count} applies for 20 events"
3262        );
3263        // But should still have rendered at least once
3264        assert!(apply_count > 0, "Should have at least one apply");
3265    }
3266
3267    #[test]
3268    fn evidence_summary_jsonl_includes_checksum() {
3269        let config = CoalescerConfig {
3270            enable_logging: true,
3271            ..test_config()
3272        };
3273        let base = Instant::now();
3274        let mut c = ResizeCoalescer::new(config, (80, 24));
3275
3276        // Generate some events
3277        c.handle_resize_at(100, 40, base + Duration::from_millis(10));
3278        c.tick_at(base + Duration::from_millis(30));
3279
3280        let all_lines = c.evidence_to_jsonl();
3281        let summary_line = all_lines.lines().last().expect("Should have summary line");
3282        let parsed: serde_json::Value =
3283            serde_json::from_str(summary_line).expect("Summary JSONL line must be valid JSON");
3284
3285        assert_eq!(parsed["event"].as_str().unwrap(), "summary");
3286        assert!(parsed["decisions"].as_u64().is_some());
3287        assert!(parsed["applies"].as_u64().is_some());
3288        assert!(parsed["forced_applies"].as_u64().is_some());
3289        assert!(parsed["coalesces"].as_u64().is_some());
3290        assert!(parsed["skips"].as_u64().is_some());
3291        assert!(parsed["regime"].as_str().is_some());
3292        assert!(parsed["checksum"].as_str().is_some());
3293    }
3294
3295    // =========================================================================
3296    // DecisionEvidence tests (bd-dionl)
3297    // =========================================================================
3298
3299    #[test]
3300    fn decision_evidence_favor_apply_steady() {
3301        let ev = DecisionEvidence::favor_apply(Regime::Steady, 80.0, 2.0);
3302        // Steady regime contrib = 1.0, timing = min(80/50, 2) = 1.6, rate<5 = 0.5
3303        assert!(ev.log_bayes_factor > 0.0, "Should favor apply");
3304        assert_eq!(ev.regime_contribution, 1.0);
3305        assert!((ev.timing_contribution - 1.6).abs() < 0.01);
3306        assert_eq!(ev.rate_contribution, 0.5);
3307        assert!(ev.is_strong());
3308        assert!(ev.is_decisive());
3309    }
3310
3311    #[test]
3312    fn decision_evidence_favor_apply_burst_regime() {
3313        let ev = DecisionEvidence::favor_apply(Regime::Burst, 10.0, 20.0);
3314        // Burst regime contrib = -0.5, timing = min(10/50, 2) = 0.2, rate>=5 = -0.3
3315        assert_eq!(ev.regime_contribution, -0.5);
3316        assert!((ev.timing_contribution - 0.2).abs() < 0.01);
3317        assert_eq!(ev.rate_contribution, -0.3);
3318        // LBF = -0.5 + 0.2 + (-0.3) = -0.6, so not strong
3319        assert!(!ev.is_strong());
3320    }
3321
3322    #[test]
3323    fn decision_evidence_favor_coalesce_burst() {
3324        let ev = DecisionEvidence::favor_coalesce(Regime::Burst, 5.0, 15.0);
3325        // Burst regime contrib = 1.0, timing = min(20/5, 2) = 2.0, rate>10 = 0.5
3326        // LBF = -(1.0 + 2.0 + 0.5) = -3.5
3327        assert!(ev.log_bayes_factor < 0.0, "Should favor coalesce");
3328        assert_eq!(ev.regime_contribution, 1.0);
3329        assert!((ev.timing_contribution - 2.0).abs() < 0.01);
3330        assert_eq!(ev.rate_contribution, 0.5);
3331        assert!(ev.is_strong());
3332        assert!(ev.is_decisive());
3333    }
3334
3335    #[test]
3336    fn decision_evidence_favor_coalesce_steady_regime() {
3337        let ev = DecisionEvidence::favor_coalesce(Regime::Steady, 100.0, 3.0);
3338        // Steady regime contrib = -0.5, timing = min(20/100, 2) = 0.2, rate<=10 = -0.3
3339        assert_eq!(ev.regime_contribution, -0.5);
3340        assert!((ev.timing_contribution - 0.2).abs() < 0.01);
3341        assert_eq!(ev.rate_contribution, -0.3);
3342    }
3343
3344    #[test]
3345    fn decision_evidence_forced_deadline() {
3346        let ev = DecisionEvidence::forced_deadline(100.0);
3347        assert!(ev.log_bayes_factor.is_infinite());
3348        assert_eq!(ev.regime_contribution, 0.0);
3349        assert!((ev.timing_contribution - 100.0).abs() < 0.01);
3350        assert_eq!(ev.rate_contribution, 0.0);
3351        assert!(ev.is_strong());
3352        assert!(ev.is_decisive());
3353        assert!(ev.explanation.contains("100.0ms"));
3354    }
3355
3356    #[test]
3357    fn decision_evidence_is_strong_boundary() {
3358        // Exactly 1.0 is NOT strong (need >1.0)
3359        let ev = DecisionEvidence {
3360            log_bayes_factor: 1.0,
3361            regime_contribution: 0.0,
3362            timing_contribution: 0.0,
3363            rate_contribution: 0.0,
3364            explanation: String::new(),
3365        };
3366        assert!(!ev.is_strong());
3367
3368        let ev2 = DecisionEvidence {
3369            log_bayes_factor: 1.001,
3370            ..ev.clone()
3371        };
3372        assert!(ev2.is_strong());
3373
3374        // Negative values also count
3375        let ev3 = DecisionEvidence {
3376            log_bayes_factor: -1.5,
3377            ..ev
3378        };
3379        assert!(ev3.is_strong());
3380    }
3381
3382    #[test]
3383    fn decision_evidence_is_decisive_boundary() {
3384        let ev = DecisionEvidence {
3385            log_bayes_factor: 2.0,
3386            regime_contribution: 0.0,
3387            timing_contribution: 0.0,
3388            rate_contribution: 0.0,
3389            explanation: String::new(),
3390        };
3391        assert!(!ev.is_decisive()); // need >2.0
3392
3393        let ev2 = DecisionEvidence {
3394            log_bayes_factor: 2.001,
3395            ..ev
3396        };
3397        assert!(ev2.is_decisive());
3398    }
3399
3400    #[test]
3401    fn decision_evidence_to_jsonl_valid() {
3402        let ev = DecisionEvidence::favor_apply(Regime::Steady, 50.0, 3.0);
3403        let jsonl = ev.to_jsonl("test-run", ScreenMode::AltScreen, 80, 24, 5);
3404        let parsed: serde_json::Value =
3405            serde_json::from_str(&jsonl).expect("DecisionEvidence JSONL must be valid JSON");
3406
3407        assert_eq!(parsed["event"].as_str().unwrap(), "decision_evidence");
3408        assert!(parsed["log_bayes_factor"].as_f64().is_some());
3409        assert!(parsed["regime_contribution"].as_f64().is_some());
3410        assert!(parsed["timing_contribution"].as_f64().is_some());
3411        assert!(parsed["rate_contribution"].as_f64().is_some());
3412        assert!(parsed["explanation"].as_str().is_some());
3413    }
3414
3415    #[test]
3416    fn decision_evidence_to_jsonl_infinity() {
3417        let ev = DecisionEvidence::forced_deadline(100.0);
3418        let jsonl = ev.to_jsonl("test-run", ScreenMode::AltScreen, 80, 24, 0);
3419        // Infinity serialized as "inf" string
3420        assert!(jsonl.contains("\"inf\""));
3421    }
3422
3423    // =========================================================================
3424    // Edge case tests (bd-dionl)
3425    // =========================================================================
3426
3427    #[test]
3428    fn hard_deadline_zero_applies_immediately() {
3429        let config = CoalescerConfig {
3430            hard_deadline_ms: 0,
3431            enable_logging: true,
3432            ..test_config()
3433        };
3434        let mut c = ResizeCoalescer::new(config, (80, 24));
3435        let base = Instant::now();
3436
3437        // Any resize should apply immediately since deadline is 0
3438        let action = c.handle_resize_at(100, 40, base);
3439        assert!(
3440            matches!(action, CoalesceAction::ApplyResize { .. }),
3441            "hard_deadline_ms=0 should force immediate apply, got {action:?}"
3442        );
3443    }
3444
3445    #[test]
3446    fn rate_window_size_zero_returns_zero_rate() {
3447        let config = CoalescerConfig {
3448            rate_window_size: 0,
3449            enable_logging: true,
3450            ..test_config()
3451        };
3452        let mut c = ResizeCoalescer::new(config, (80, 24));
3453        let base = Instant::now();
3454
3455        // Feed events — rate should stay 0 since window can hold 0 events
3456        for i in 0..5 {
3457            c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
3458        }
3459        let rate = c.calculate_event_rate(base + Duration::from_millis(50));
3460        assert_eq!(rate, 0.0, "rate_window_size=0 should yield 0 rate");
3461    }
3462
3463    #[test]
3464    fn rate_window_size_one_returns_zero_rate() {
3465        let config = CoalescerConfig {
3466            rate_window_size: 1,
3467            enable_logging: true,
3468            ..test_config()
3469        };
3470        let mut c = ResizeCoalescer::new(config, (80, 24));
3471        let base = Instant::now();
3472
3473        for i in 0..5 {
3474            c.handle_resize_at(80 + i, 24, base + Duration::from_millis(i as u64 * 10));
3475        }
3476        // With window=1, only 1 event kept, so < 2 elements → rate=0
3477        let rate = c.calculate_event_rate(base + Duration::from_millis(50));
3478        assert_eq!(rate, 0.0, "rate_window_size=1 should yield 0 rate");
3479    }
3480
3481    #[test]
3482    fn tick_no_pending_returns_none() {
3483        let config = test_config();
3484        let mut c = ResizeCoalescer::new(config, (80, 24));
3485        let base = Instant::now();
3486
3487        // No resize events, tick should return None
3488        let action = c.tick_at(base);
3489        assert_eq!(action, CoalesceAction::None);
3490        let action = c.tick_at(base + Duration::from_millis(500));
3491        assert_eq!(action, CoalesceAction::None);
3492    }
3493
3494    #[test]
3495    fn time_until_apply_none_when_no_pending() {
3496        let c = ResizeCoalescer::new(test_config(), (80, 24));
3497        assert!(c.time_until_apply(Instant::now()).is_none());
3498    }
3499
3500    #[test]
3501    fn time_until_apply_zero_when_past_delay() {
3502        let config = test_config();
3503        let mut c = ResizeCoalescer::new(config, (80, 24));
3504        let base = Instant::now();
3505
3506        c.handle_resize_at(100, 40, base);
3507        // Well past the steady_delay_ms of 16ms
3508        let result = c.time_until_apply(base + Duration::from_millis(500));
3509        assert_eq!(result, Some(Duration::ZERO));
3510    }
3511
3512    // =========================================================================
3513    // json_escape tests (bd-dionl)
3514    // =========================================================================
3515
3516    #[test]
3517    fn json_escape_special_characters() {
3518        assert_eq!(json_escape("hello"), "hello");
3519        assert_eq!(json_escape("a\"b"), "a\\\"b");
3520        assert_eq!(json_escape("a\\b"), "a\\\\b");
3521        assert_eq!(json_escape("a\nb"), "a\\nb");
3522        assert_eq!(json_escape("a\rb"), "a\\rb");
3523        assert_eq!(json_escape("a\tb"), "a\\tb");
3524    }
3525
3526    #[test]
3527    fn json_escape_control_characters() {
3528        // Control char \x01 should be escaped as \u0001
3529        let input = "a\x01b";
3530        let escaped = json_escape(input);
3531        assert_eq!(escaped, "a\\u0001b");
3532    }
3533
3534    #[test]
3535    fn json_escape_empty_string() {
3536        assert_eq!(json_escape(""), "");
3537    }
3538
3539    // =========================================================================
3540    // clear_logs and decision_logs_jsonl tests (bd-dionl)
3541    // =========================================================================
3542
3543    #[test]
3544    fn clear_logs_resets_state() {
3545        let mut config = test_config();
3546        config.enable_logging = true;
3547        let mut c = ResizeCoalescer::new(config, (80, 24));
3548
3549        c.handle_resize(100, 40);
3550        assert!(!c.logs().is_empty());
3551
3552        c.clear_logs();
3553        assert!(c.logs().is_empty());
3554
3555        // After clearing, new logs should work
3556        c.handle_resize(120, 50);
3557        assert!(!c.logs().is_empty());
3558    }
3559
3560    #[test]
3561    fn decision_logs_jsonl_each_line_valid() {
3562        let mut config = test_config();
3563        config.enable_logging = true;
3564        let base = Instant::now();
3565        let mut c = ResizeCoalescer::new(config, (80, 24))
3566            .with_evidence_run_id("jsonl-test")
3567            .with_screen_mode(ScreenMode::AltScreen);
3568
3569        c.handle_resize_at(100, 40, base);
3570        c.handle_resize_at(110, 50, base + Duration::from_millis(5));
3571        c.tick_at(base + Duration::from_millis(50));
3572
3573        let jsonl = c.decision_logs_jsonl();
3574        assert!(!jsonl.is_empty());
3575        for line in jsonl.lines() {
3576            let _: serde_json::Value =
3577                serde_json::from_str(line).expect("Each JSONL line must be valid JSON");
3578        }
3579    }
3580
3581    // =========================================================================
3582    // TelemetryHooks tests (bd-dionl)
3583    // =========================================================================
3584
3585    #[test]
3586    fn telemetry_hooks_has_methods() {
3587        let hooks = TelemetryHooks::new();
3588        assert!(!hooks.has_resize_applied());
3589        assert!(!hooks.has_regime_change());
3590        assert!(!hooks.has_decision());
3591
3592        let hooks = hooks.on_resize_applied(|_| {});
3593        assert!(hooks.has_resize_applied());
3594        assert!(!hooks.has_regime_change());
3595        assert!(!hooks.has_decision());
3596    }
3597
3598    #[test]
3599    fn telemetry_hooks_with_tracing() {
3600        let hooks = TelemetryHooks::new().with_tracing(true);
3601        let debug_str = format!("{:?}", hooks);
3602        assert!(debug_str.contains("emit_tracing: true"));
3603    }
3604
3605    #[test]
3606    fn telemetry_hooks_default_equals_new() {
3607        let h1 = TelemetryHooks::default();
3608        let h2 = TelemetryHooks::new();
3609        assert!(!h1.has_resize_applied());
3610        assert!(!h2.has_resize_applied());
3611    }
3612
3613    #[test]
3614    fn telemetry_hooks_on_decision_fires() {
3615        use std::sync::Arc;
3616        use std::sync::atomic::{AtomicU32, Ordering};
3617
3618        let count = Arc::new(AtomicU32::new(0));
3619        let count_clone = count.clone();
3620
3621        let hooks = TelemetryHooks::new().on_decision(move |_entry| {
3622            count_clone.fetch_add(1, Ordering::SeqCst);
3623        });
3624
3625        let mut config = test_config();
3626        config.enable_logging = true;
3627        let mut c = ResizeCoalescer::new(config, (80, 24)).with_telemetry_hooks(hooks);
3628
3629        let base = Instant::now();
3630        c.handle_resize_at(100, 40, base);
3631
3632        // The coalesce decision should fire the on_decision hook
3633        assert!(count.load(Ordering::SeqCst) >= 1);
3634    }
3635
3636    // =========================================================================
3637    // Regime::as_str tests (bd-dionl)
3638    // =========================================================================
3639
3640    #[test]
3641    fn regime_as_str_values() {
3642        assert_eq!(Regime::Steady.as_str(), "steady");
3643        assert_eq!(Regime::Burst.as_str(), "burst");
3644    }
3645
3646    #[test]
3647    fn regime_default_is_steady() {
3648        assert_eq!(Regime::default(), Regime::Steady);
3649    }
3650
3651    // =========================================================================
3652    // DecisionSummary tests (bd-dionl)
3653    // =========================================================================
3654
3655    #[test]
3656    fn decision_summary_checksum_hex_format() {
3657        let summary = DecisionSummary {
3658            checksum: 0x0123456789ABCDEF,
3659            ..DecisionSummary::default()
3660        };
3661        assert_eq!(summary.checksum_hex(), "0123456789abcdef");
3662    }
3663
3664    #[test]
3665    fn decision_summary_default_values() {
3666        let summary = DecisionSummary::default();
3667        assert_eq!(summary.decision_count, 0);
3668        assert_eq!(summary.apply_count, 0);
3669        assert_eq!(summary.forced_apply_count, 0);
3670        assert_eq!(summary.coalesce_count, 0);
3671        assert_eq!(summary.skip_count, 0);
3672        assert_eq!(summary.regime, Regime::Steady);
3673        assert_eq!(summary.last_applied, (0, 0));
3674        assert_eq!(summary.checksum, 0);
3675    }
3676
3677    #[test]
3678    fn decision_summary_to_jsonl_valid() {
3679        let summary = DecisionSummary {
3680            decision_count: 5,
3681            apply_count: 2,
3682            forced_apply_count: 1,
3683            coalesce_count: 2,
3684            skip_count: 1,
3685            regime: Regime::Burst,
3686            last_applied: (120, 40),
3687            checksum: 0xDEADBEEF,
3688        };
3689        let jsonl = summary.to_jsonl("run-1", ScreenMode::AltScreen, 120, 40, 5);
3690        let parsed: serde_json::Value =
3691            serde_json::from_str(&jsonl).expect("Summary JSONL must be valid JSON");
3692
3693        assert_eq!(parsed["event"].as_str().unwrap(), "summary");
3694        assert_eq!(parsed["decisions"].as_u64().unwrap(), 5);
3695        assert_eq!(parsed["applies"].as_u64().unwrap(), 2);
3696        assert_eq!(parsed["forced_applies"].as_u64().unwrap(), 1);
3697        assert_eq!(parsed["coalesces"].as_u64().unwrap(), 2);
3698        assert_eq!(parsed["skips"].as_u64().unwrap(), 1);
3699        assert_eq!(parsed["regime"].as_str().unwrap(), "burst");
3700        assert_eq!(parsed["last_w"].as_u64().unwrap(), 120);
3701        assert_eq!(parsed["last_h"].as_u64().unwrap(), 40);
3702        assert!(parsed["checksum"].as_str().unwrap().contains("deadbeef"));
3703    }
3704
3705    // =========================================================================
3706    // screen_mode_str tests (bd-dionl)
3707    // =========================================================================
3708
3709    #[test]
3710    fn screen_mode_str_all_variants() {
3711        assert_eq!(screen_mode_str(ScreenMode::AltScreen), "altscreen");
3712        assert_eq!(
3713            screen_mode_str(ScreenMode::Inline { ui_height: 10 }),
3714            "inline"
3715        );
3716        assert_eq!(
3717            screen_mode_str(ScreenMode::InlineAuto {
3718                min_height: 5,
3719                max_height: 20,
3720            }),
3721            "inline_auto"
3722        );
3723    }
3724
3725    // =========================================================================
3726    // Config builder chaining tests (bd-dionl)
3727    // =========================================================================
3728
3729    #[test]
3730    fn config_with_logging_chaining() {
3731        let config = CoalescerConfig::default().with_logging(true);
3732        assert!(config.enable_logging);
3733
3734        let config2 = config.with_logging(false);
3735        assert!(!config2.enable_logging);
3736    }
3737
3738    #[test]
3739    fn config_with_bocpd_chaining() {
3740        let config = CoalescerConfig::default().with_bocpd();
3741        assert!(config.enable_bocpd);
3742        assert!(config.bocpd_config.is_some());
3743    }
3744
3745    #[test]
3746    fn config_with_bocpd_config_chaining() {
3747        let bocpd_cfg = BocpdConfig {
3748            max_run_length: 42,
3749            ..BocpdConfig::default()
3750        };
3751        let config = CoalescerConfig::default().with_bocpd_config(bocpd_cfg);
3752        assert!(config.enable_bocpd);
3753        assert_eq!(config.bocpd_config.as_ref().unwrap().max_run_length, 42);
3754    }
3755
3756    // =========================================================================
3757    // Coalescer builder chaining tests (bd-dionl)
3758    // =========================================================================
3759
3760    #[test]
3761    fn coalescer_with_evidence_run_id() {
3762        let c = ResizeCoalescer::new(test_config(), (80, 24)).with_evidence_run_id("custom-run-id");
3763        // Verify via evidence JSONL output
3764        let jsonl = c.evidence_to_jsonl();
3765        assert!(jsonl.contains("custom-run-id"));
3766    }
3767
3768    #[test]
3769    fn coalescer_with_screen_mode() {
3770        let mut config = test_config();
3771        config.enable_logging = true;
3772        let mut c = ResizeCoalescer::new(config, (80, 24))
3773            .with_screen_mode(ScreenMode::Inline { ui_height: 8 });
3774
3775        c.handle_resize(100, 40);
3776        let jsonl = c.evidence_to_jsonl();
3777        assert!(jsonl.contains("\"screen_mode\":\"inline\""));
3778    }
3779
3780    #[test]
3781    fn coalescer_set_evidence_sink_clears_config_logged() {
3782        let mut config = test_config();
3783        config.enable_logging = true;
3784        let mut c = ResizeCoalescer::new(config, (80, 24));
3785
3786        // Generate some events so config is logged
3787        c.handle_resize(100, 40);
3788
3789        // Setting evidence sink to None should reset config_logged
3790        c.set_evidence_sink(None);
3791
3792        // Evidence JSONL should still have config line since it rebuilds from config
3793        let jsonl = c.evidence_to_jsonl();
3794        assert!(jsonl.contains("\"event\":\"config\""));
3795    }
3796
3797    // =========================================================================
3798    // duration_since_or_zero tests (bd-dionl)
3799    // =========================================================================
3800
3801    #[test]
3802    fn duration_since_or_zero_normal() {
3803        let earlier = Instant::now();
3804        std::thread::sleep(Duration::from_millis(1));
3805        let now = Instant::now();
3806        let result = duration_since_or_zero(now, earlier);
3807        assert!(result >= Duration::from_millis(1));
3808    }
3809
3810    #[test]
3811    fn duration_since_or_zero_same_instant() {
3812        let now = Instant::now();
3813        let result = duration_since_or_zero(now, now);
3814        assert_eq!(result, Duration::ZERO);
3815    }
3816
3817    // =========================================================================
3818    // ResizeAppliedEvent and RegimeChangeEvent struct tests (bd-dionl)
3819    // =========================================================================
3820
3821    #[test]
3822    fn resize_applied_event_fields() {
3823        let event = ResizeAppliedEvent {
3824            new_size: (100, 40),
3825            old_size: (80, 24),
3826            elapsed: Duration::from_millis(42),
3827            forced: true,
3828        };
3829        assert_eq!(event.new_size, (100, 40));
3830        assert_eq!(event.old_size, (80, 24));
3831        assert_eq!(event.elapsed, Duration::from_millis(42));
3832        assert!(event.forced);
3833    }
3834
3835    #[test]
3836    fn regime_change_event_fields() {
3837        let event = RegimeChangeEvent {
3838            from: Regime::Steady,
3839            to: Regime::Burst,
3840            event_idx: 42,
3841        };
3842        assert_eq!(event.from, Regime::Steady);
3843        assert_eq!(event.to, Regime::Burst);
3844        assert_eq!(event.event_idx, 42);
3845    }
3846
3847    // =========================================================================
3848    // CoalesceAction equality edge cases (bd-dionl)
3849    // =========================================================================
3850
3851    #[test]
3852    fn coalesce_action_show_placeholder_eq() {
3853        assert_eq!(
3854            CoalesceAction::ShowPlaceholder,
3855            CoalesceAction::ShowPlaceholder
3856        );
3857        assert_ne!(CoalesceAction::ShowPlaceholder, CoalesceAction::None);
3858    }
3859
3860    #[test]
3861    fn coalesce_action_apply_resize_eq() {
3862        let a = CoalesceAction::ApplyResize {
3863            width: 100,
3864            height: 40,
3865            coalesce_time: Duration::from_millis(16),
3866            forced_by_deadline: false,
3867        };
3868        let b = CoalesceAction::ApplyResize {
3869            width: 100,
3870            height: 40,
3871            coalesce_time: Duration::from_millis(16),
3872            forced_by_deadline: false,
3873        };
3874        assert_eq!(a, b);
3875
3876        let c = CoalesceAction::ApplyResize {
3877            width: 100,
3878            height: 40,
3879            coalesce_time: Duration::from_millis(16),
3880            forced_by_deadline: true,
3881        };
3882        assert_ne!(a, c);
3883    }
3884
3885    // =========================================================================
3886    // FNV hash consistency (bd-dionl)
3887    // =========================================================================
3888
3889    #[test]
3890    fn fnv_hash_deterministic() {
3891        let mut h1 = FNV_OFFSET_BASIS;
3892        fnv_hash_bytes(&mut h1, b"hello world");
3893
3894        let mut h2 = FNV_OFFSET_BASIS;
3895        fnv_hash_bytes(&mut h2, b"hello world");
3896
3897        assert_eq!(h1, h2);
3898    }
3899
3900    #[test]
3901    fn fnv_hash_different_inputs_different_hashes() {
3902        let mut h1 = FNV_OFFSET_BASIS;
3903        fnv_hash_bytes(&mut h1, b"hello");
3904
3905        let mut h2 = FNV_OFFSET_BASIS;
3906        fnv_hash_bytes(&mut h2, b"world");
3907
3908        assert_ne!(h1, h2);
3909    }
3910
3911    #[test]
3912    fn fnv_hash_empty_input_returns_basis() {
3913        let mut hash = FNV_OFFSET_BASIS;
3914        fnv_hash_bytes(&mut hash, b"");
3915        assert_eq!(hash, FNV_OFFSET_BASIS);
3916    }
3917}