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