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