Skip to main content

ftui_render/
diff_strategy.rs

1#![forbid(unsafe_code)]
2
3//! Bayesian Diff Strategy Selection.
4//!
5//! This module provides an adaptive strategy selector for buffer diffing,
6//! choosing between full diff, dirty-row diff, or full redraw based on
7//! expected cost using a Bayesian change-rate model.
8//!
9//! # Cost Model
10//!
11//! We model the cost of each strategy as:
12//!
13//! ```text
14//! Cost = c_scan × cells_scanned + c_emit × cells_emitted + c_overhead
15//! ```
16//!
17//! Where:
18//! - `c_scan` = cost per cell comparison (memory load + compare)
19//! - `c_emit` = cost per changed cell emitted (ANSI escape + write)
20//! - `c_overhead` = fixed overhead per frame
21//!
22//! ## Strategy Costs
23//!
24//! Let:
25//! - `N = width × height` (total cells)
26//! - `D` = number of dirty rows
27//! - `W` = width (cells per row)
28//! - `p` = change rate (fraction of cells changed)
29//!
30//! ### Full Diff (`compute`)
31//!
32//! Scans all rows with row-skip fast path for unchanged rows:
33//!
34//! ```text
35//! Cost_full = c_row × H + c_scan × D × W + c_emit × (p × N)
36//! ```
37//!
38//! where `c_row` is the cost of the row-equality fast path check.
39//!
40//! ### Dirty-Row Diff (`compute_dirty`)
41//!
42//! Scans only rows marked dirty. When available, use a scan-cell estimate
43//! (e.g., dirty-span coverage) to refine the scan cost:
44//!
45//! ```text
46//! Cost_dirty = c_scan × ScanCells + c_emit × (p × N)
47//! ```
48//!
49//! Where `ScanCells` defaults to `D × W` when no estimate is provided.
50//!
51//! ### Full Redraw
52//!
53//! No diff computation; emit all cells:
54//!
55//! ```text
56//! Cost_redraw = c_emit × N
57//! ```
58//!
59//! # Bayesian Change-Rate Posterior
60//!
61//! We maintain a Beta prior/posterior over the change rate `p`:
62//!
63//! ```text
64//! p ~ Beta(α, β)
65//!
66//! Prior: α₀ = 1, β₀ = 19  (E[p] = 0.05, expecting ~5% change rate)
67//!
68//! Update per frame:
69//!   α ← α + N_changed
70//!   β ← β + (N_scanned - N_changed)
71//!
72//! Posterior mean: E[p] = α / (α + β)
73//! Posterior variance: Var[p] = αβ / ((α+β)² × (α+β+1))
74//! ```
75//!
76//! # Decision Rule
77//!
78//! Select strategy with minimum expected cost:
79//!
80//! ```text
81//! strategy = argmin { E[Cost_full], E[Cost_dirty], E[Cost_redraw] }
82//! ```
83//!
84//! Using `E[p]` from the posterior to compute expected costs.
85//!
86//! ## Conservative Mode
87//!
88//! For worst-case scenarios, use the upper 95th percentile of `p`:
89//!
90//! ```text
91//! p_95 = quantile(Beta(α, β), 0.95)
92//! ```
93//!
94//! This provides a more conservative estimate when the posterior variance
95//! is high (early frames, unstable UI).
96//!
97//! # Decay / Forgetting
98//!
99//! To adapt to changing workloads, we apply exponential decay:
100//!
101//! ```text
102//! α ← α × decay + N_changed
103//! β ← β × decay + (N_scanned - N_changed)
104//! ```
105//!
106//! where `decay ∈ (0, 1)` (default 0.95). This weights recent frames more
107//! heavily, allowing the posterior to track non-stationary change patterns.
108//!
109//! # Invariants
110//!
111//! 1. **Deterministic**: Same inputs → same strategy selection
112//! 2. **O(1) update**: Posterior update is constant time per frame
113//! 3. **Bounded posterior**: α, β ∈ [ε, MAX] to avoid numerical issues
114//! 4. **Monotonic dirty tracking**: Dirty rows are a superset of changed rows
115//!
116//! # Failure Modes
117//!
118//! | Condition | Behavior | Rationale |
119//! |-----------|----------|-----------|
120//! | α, β → 0 | Clamp to ε = 1e-6 | Avoid degenerate Beta |
121//! | α, β → ∞ | Cap at MAX = 1e6 | Prevent overflow |
122//! | D = 0 (no dirty) | Use dirty-row diff | O(height) check, optimal |
123//! | D = H (all dirty) | Full diff if p low, redraw if p high | Cost-based decision |
124//! | Dimension mismatch | Full redraw | Buffer resize scenario |
125
126use std::fmt;
127
128// =============================================================================
129// Configuration
130// =============================================================================
131
132/// Configuration for the diff strategy selector.
133#[derive(Debug, Clone)]
134pub struct DiffStrategyConfig {
135    /// Cost weight for cell scanning (relative units).
136    /// Default: 1.0
137    pub c_scan: f64,
138
139    /// Cost weight for cell emission (relative units).
140    /// Typically higher than c_scan since it involves I/O.
141    /// Default: 6.0
142    pub c_emit: f64,
143
144    /// Cost weight for row-equality fast path check.
145    /// Lower than full scan since it uses SIMD.
146    /// Default: 0.1
147    pub c_row: f64,
148
149    /// Prior α for Beta distribution (pseudo-count for "changed").
150    /// Default: 1.0 (uninformative prior weighted toward low change)
151    pub prior_alpha: f64,
152
153    /// Prior β for Beta distribution (pseudo-count for "unchanged").
154    /// Default: 19.0 (prior E[p] = 0.05)
155    pub prior_beta: f64,
156
157    /// Decay factor for exponential forgetting.
158    /// Range: (0, 1], where 1.0 means no decay.
159    /// Default: 0.95
160    pub decay: f64,
161
162    /// Whether to use conservative (upper quantile) estimates.
163    /// Default: false
164    pub conservative: bool,
165
166    /// Quantile for conservative mode (0.0 to 1.0).
167    /// Default: 0.95
168    pub conservative_quantile: f64,
169
170    /// Minimum cells changed to update posterior.
171    /// Prevents noise from near-zero observations.
172    /// Default: 0
173    pub min_observation_cells: usize,
174
175    /// Hysteresis ratio required to switch strategies.
176    ///
177    /// A value of 0.05 means the new strategy must be at least 5% cheaper
178    /// than the previous strategy to trigger a switch.
179    ///
180    /// Default: 0.05
181    pub hysteresis_ratio: f64,
182
183    /// Variance threshold for uncertainty guard.
184    ///
185    /// When posterior variance exceeds this threshold, the selector
186    /// uses conservative quantiles and avoids FullRedraw.
187    ///
188    /// Default: 0.002
189    pub uncertainty_guard_variance: f64,
190}
191
192impl Default for DiffStrategyConfig {
193    fn default() -> Self {
194        Self {
195            // Calibrated 2026-02-03 from `perf_diff_microbench`:
196            // scan cost ~0.008us/cell, emit cost ~0.05us/change -> ~6x ratio.
197            // Reproduce: `cargo test -p ftui-render diff::tests::perf_diff_microbench -- --nocapture`.
198            c_scan: 1.0,
199            c_emit: 6.0,
200            c_row: 0.1,
201            prior_alpha: 1.0,
202            prior_beta: 19.0,
203            decay: 0.95,
204            conservative: false,
205            conservative_quantile: 0.95,
206            min_observation_cells: 1,
207            hysteresis_ratio: 0.05,
208            uncertainty_guard_variance: 0.002,
209        }
210    }
211}
212
213impl DiffStrategyConfig {
214    fn sanitized(&self) -> Self {
215        const EPS: f64 = 1e-6;
216        let mut config = self.clone();
217        config.c_scan = normalize_cost(config.c_scan, 1.0);
218        config.c_emit = normalize_cost(config.c_emit, 6.0);
219        config.c_row = normalize_cost(config.c_row, 0.1);
220        config.prior_alpha = normalize_positive(config.prior_alpha, 1.0);
221        config.prior_beta = normalize_positive(config.prior_beta, 19.0);
222        config.decay = normalize_decay(config.decay);
223        config.conservative_quantile = if config.conservative_quantile.is_nan() {
224            EPS
225        } else {
226            config.conservative_quantile.clamp(EPS, 1.0 - EPS)
227        };
228        config.hysteresis_ratio = normalize_ratio(config.hysteresis_ratio, 0.05);
229        config.uncertainty_guard_variance =
230            normalize_cost(config.uncertainty_guard_variance, 0.002);
231        config
232    }
233}
234
235fn normalize_positive(value: f64, fallback: f64) -> f64 {
236    if value.is_finite() && value > 0.0 {
237        value
238    } else {
239        fallback
240    }
241}
242
243fn normalize_cost(value: f64, fallback: f64) -> f64 {
244    if value.is_finite() && value >= 0.0 {
245        value
246    } else {
247        fallback
248    }
249}
250
251fn normalize_decay(value: f64) -> f64 {
252    if value.is_finite() && value > 0.0 {
253        value.min(1.0)
254    } else {
255        1.0
256    }
257}
258
259fn normalize_ratio(value: f64, fallback: f64) -> f64 {
260    if value.is_finite() {
261        value.clamp(0.0, 1.0)
262    } else {
263        fallback
264    }
265}
266
267// =============================================================================
268// Change-Rate Estimator (Beta-Binomial)
269// =============================================================================
270
271/// Beta-Binomial estimator for change-rate `p`.
272///
273/// Maintains a Beta posterior with exponential decay and deterministic updates.
274#[derive(Debug, Clone)]
275pub struct ChangeRateEstimator {
276    prior_alpha: f64,
277    prior_beta: f64,
278    alpha: f64,
279    beta: f64,
280    decay: f64,
281    min_observation_cells: usize,
282}
283
284impl ChangeRateEstimator {
285    /// Create a new estimator with the given priors and decay.
286    pub fn new(
287        prior_alpha: f64,
288        prior_beta: f64,
289        decay: f64,
290        min_observation_cells: usize,
291    ) -> Self {
292        Self {
293            prior_alpha,
294            prior_beta,
295            alpha: prior_alpha,
296            beta: prior_beta,
297            decay,
298            min_observation_cells,
299        }
300    }
301
302    /// Reset the posterior to the prior.
303    pub fn reset(&mut self) {
304        self.alpha = self.prior_alpha;
305        self.beta = self.prior_beta;
306    }
307
308    /// Posterior parameters (α, β).
309    pub fn posterior_params(&self) -> (f64, f64) {
310        (self.alpha, self.beta)
311    }
312
313    /// Posterior mean E[p].
314    pub fn mean(&self) -> f64 {
315        self.alpha / (self.alpha + self.beta)
316    }
317
318    /// Posterior variance Var[p].
319    pub fn variance(&self) -> f64 {
320        let sum = self.alpha + self.beta;
321        (self.alpha * self.beta) / (sum * sum * (sum + 1.0))
322    }
323
324    /// Observe an update with scanned and changed cells.
325    pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
326        if cells_scanned < self.min_observation_cells {
327            return;
328        }
329
330        let cells_changed = cells_changed.min(cells_scanned);
331        self.alpha *= self.decay;
332        self.beta *= self.decay;
333
334        self.alpha += cells_changed as f64;
335        self.beta += (cells_scanned.saturating_sub(cells_changed)) as f64;
336
337        const EPS: f64 = 1e-6;
338        const MAX: f64 = 1e6;
339        self.alpha = self.alpha.clamp(EPS, MAX);
340        self.beta = self.beta.clamp(EPS, MAX);
341    }
342
343    /// Upper quantile of the Beta distribution using normal approximation.
344    pub fn upper_quantile(&self, q: f64) -> f64 {
345        let q = q.clamp(1e-6, 1.0 - 1e-6);
346        let mean = self.mean();
347        let var = self.variance();
348        let std = var.sqrt();
349
350        // Standard normal quantile approximation (Abramowitz & Stegun 26.2.23)
351        let z = if q >= 0.5 {
352            let t = (-2.0 * (1.0 - q).ln()).sqrt();
353            t - (2.515517 + 0.802853 * t + 0.010328 * t * t)
354                / (1.0 + 1.432788 * t + 0.189269 * t * t + 0.001308 * t * t * t)
355        } else {
356            let t = (-2.0 * q.ln()).sqrt();
357            -(t - (2.515517 + 0.802853 * t + 0.010328 * t * t)
358                / (1.0 + 1.432788 * t + 0.189269 * t * t + 0.001308 * t * t * t))
359        };
360
361        (mean + z * std).clamp(0.0, 1.0)
362    }
363}
364
365// =============================================================================
366// Strategy Enum
367// =============================================================================
368
369/// The diff strategy to use for the current frame.
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum DiffStrategy {
372    /// Use `BufferDiff::compute` (full row-major scan with row-skip).
373    Full,
374    /// Use `BufferDiff::compute_dirty` (scan only dirty rows).
375    DirtyRows,
376    /// Skip diff entirely; emit all cells.
377    FullRedraw,
378}
379
380impl fmt::Display for DiffStrategy {
381    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382        match self {
383            Self::Full => write!(f, "Full"),
384            Self::DirtyRows => write!(f, "DirtyRows"),
385            Self::FullRedraw => write!(f, "FullRedraw"),
386        }
387    }
388}
389
390// =============================================================================
391// Decision Evidence (Explainability)
392// =============================================================================
393
394/// Evidence supporting a strategy decision.
395///
396/// Provides explainability for the selection, showing expected costs
397/// and the posterior state that led to the decision.
398#[derive(Debug, Clone)]
399pub struct StrategyEvidence {
400    /// The selected strategy.
401    pub strategy: DiffStrategy,
402
403    /// Expected cost of Full strategy.
404    pub cost_full: f64,
405
406    /// Expected cost of DirtyRows strategy.
407    pub cost_dirty: f64,
408
409    /// Expected cost of FullRedraw strategy.
410    pub cost_redraw: f64,
411
412    /// Posterior mean of change rate p.
413    pub posterior_mean: f64,
414
415    /// Posterior variance of change rate p.
416    pub posterior_variance: f64,
417
418    /// Current posterior α.
419    pub alpha: f64,
420
421    /// Current posterior β.
422    pub beta: f64,
423
424    /// Number of dirty rows observed.
425    pub dirty_rows: usize,
426
427    /// Total rows (height).
428    pub total_rows: usize,
429
430    /// Total cells (width × height).
431    pub total_cells: usize,
432
433    /// Guard reason, if any.
434    pub guard_reason: &'static str,
435
436    /// Whether hysteresis prevented a switch.
437    pub hysteresis_applied: bool,
438
439    /// Hysteresis ratio used for the decision.
440    pub hysteresis_ratio: f64,
441}
442
443impl StrategyEvidence {
444    /// Format this evidence entry as a JSONL line for structured logging.
445    #[must_use]
446    pub fn to_jsonl(&self) -> String {
447        format!(
448            r#"{{"schema":"diff-strategy-v1","strategy":"{}","cost_full":{:.2},"cost_dirty":{:.2},"cost_redraw":{:.2},"posterior_mean":{:.6},"posterior_var":{:.8},"alpha":{:.4},"beta":{:.4},"dirty_rows":{},"total_rows":{},"total_cells":{},"guard":"{}","hysteresis":{},"hysteresis_ratio":{:.4}}}"#,
449            self.strategy,
450            self.cost_full,
451            self.cost_dirty,
452            self.cost_redraw,
453            self.posterior_mean,
454            self.posterior_variance,
455            self.alpha,
456            self.beta,
457            self.dirty_rows,
458            self.total_rows,
459            self.total_cells,
460            self.guard_reason,
461            self.hysteresis_applied,
462            self.hysteresis_ratio,
463        )
464    }
465}
466
467impl fmt::Display for StrategyEvidence {
468    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469        writeln!(f, "Strategy: {}", self.strategy)?;
470        writeln!(
471            f,
472            "Costs: Full={:.2}, Dirty={:.2}, Redraw={:.2}",
473            self.cost_full, self.cost_dirty, self.cost_redraw
474        )?;
475        writeln!(
476            f,
477            "Posterior: p~Beta({:.2},{:.2}), E[p]={:.4}, Var[p]={:.6}",
478            self.alpha, self.beta, self.posterior_mean, self.posterior_variance
479        )?;
480        writeln!(
481            f,
482            "Dirty: {}/{} rows, {} total cells",
483            self.dirty_rows, self.total_rows, self.total_cells
484        )?;
485        writeln!(
486            f,
487            "Guard: {}, Hysteresis: {} (ratio {:.3})",
488            self.guard_reason, self.hysteresis_applied, self.hysteresis_ratio
489        )
490    }
491}
492
493// =============================================================================
494// Strategy Selector
495// =============================================================================
496
497/// Bayesian diff strategy selector.
498///
499/// Maintains a Beta posterior over the change rate and selects the
500/// strategy with minimum expected cost each frame.
501#[derive(Debug, Clone)]
502pub struct DiffStrategySelector {
503    config: DiffStrategyConfig,
504    estimator: ChangeRateEstimator,
505
506    /// Frame counter for diagnostics.
507    frame_count: u64,
508
509    /// Last decision evidence (for logging/debugging).
510    last_evidence: Option<StrategyEvidence>,
511}
512
513impl DiffStrategySelector {
514    /// Create a new selector with the given configuration.
515    pub fn new(config: DiffStrategyConfig) -> Self {
516        let config = config.sanitized();
517        let estimator = ChangeRateEstimator::new(
518            config.prior_alpha,
519            config.prior_beta,
520            config.decay,
521            config.min_observation_cells,
522        );
523        Self {
524            config,
525            estimator,
526            frame_count: 0,
527            last_evidence: None,
528        }
529    }
530
531    /// Create a selector with default configuration.
532    pub fn with_defaults() -> Self {
533        Self::new(DiffStrategyConfig::default())
534    }
535
536    /// Get the current configuration.
537    #[must_use]
538    pub fn config(&self) -> &DiffStrategyConfig {
539        &self.config
540    }
541
542    /// Get the current posterior parameters.
543    #[must_use]
544    pub fn posterior_params(&self) -> (f64, f64) {
545        self.estimator.posterior_params()
546    }
547
548    /// Get the posterior mean E[p].
549    #[must_use]
550    pub fn posterior_mean(&self) -> f64 {
551        self.estimator.mean()
552    }
553
554    /// Get the posterior variance Var[p].
555    #[must_use]
556    pub fn posterior_variance(&self) -> f64 {
557        self.estimator.variance()
558    }
559
560    /// Get the last decision evidence.
561    #[must_use]
562    pub fn last_evidence(&self) -> Option<&StrategyEvidence> {
563        self.last_evidence.as_ref()
564    }
565
566    /// Get frame count.
567    pub fn frame_count(&self) -> u64 {
568        self.frame_count
569    }
570
571    /// Override the last decision's selected strategy and guard reason.
572    ///
573    /// Used when higher-level feature flags or probes force a different strategy
574    /// than the Bayesian selector chose.
575    pub fn override_last_strategy(&mut self, strategy: DiffStrategy, reason: &'static str) {
576        if let Some(evidence) = self.last_evidence.as_mut() {
577            evidence.strategy = strategy;
578            evidence.guard_reason = reason;
579            evidence.hysteresis_applied = false;
580        }
581    }
582
583    /// Select the optimal strategy for the current frame.
584    ///
585    /// # Arguments
586    ///
587    /// * `width` - Buffer width in cells
588    /// * `height` - Buffer height in rows
589    /// * `dirty_rows` - Number of rows marked dirty
590    ///
591    /// # Returns
592    ///
593    /// The optimal `DiffStrategy` and stores evidence for later inspection.
594    pub fn select(&mut self, width: u16, height: u16, dirty_rows: usize) -> DiffStrategy {
595        let scan_cells = dirty_rows.saturating_mul(width as usize);
596        self.select_with_scan_estimate(width, height, dirty_rows, scan_cells)
597    }
598
599    /// Select the optimal strategy using a scan-cell estimate for DirtyRows.
600    ///
601    /// `dirty_scan_cells` should approximate the number of cells scanned when
602    /// using DirtyRows (e.g., dirty-span coverage). If unknown, pass
603    /// `dirty_rows × width`.
604    pub fn select_with_scan_estimate(
605        &mut self,
606        width: u16,
607        height: u16,
608        dirty_rows: usize,
609        dirty_scan_cells: usize,
610    ) -> DiffStrategy {
611        self.frame_count += 1;
612
613        let w = width as f64;
614        let h = height as f64;
615        let d = dirty_rows as f64;
616        let n = w * h;
617        let scan_cells =
618            dirty_scan_cells.min((width as usize).saturating_mul(height as usize)) as f64;
619
620        // Get expected change rate
621        let uncertainty_guard = self.config.uncertainty_guard_variance > 0.0
622            && self.posterior_variance() > self.config.uncertainty_guard_variance;
623        let mut guard_reason = if dirty_rows == 0 {
624            "zero_dirty_rows"
625        } else {
626            "none"
627        };
628        let mut p = if self.config.conservative || uncertainty_guard {
629            self.upper_quantile(self.config.conservative_quantile)
630        } else {
631            self.posterior_mean()
632        };
633        if dirty_rows == 0 {
634            p = 0.0;
635        }
636
637        // Compute expected costs
638        let cost_full =
639            self.config.c_row * h + self.config.c_scan * d * w + self.config.c_emit * p * n;
640
641        let cost_dirty = self.config.c_scan * scan_cells + self.config.c_emit * p * n;
642
643        let cost_redraw = self.config.c_emit * n;
644
645        // Select argmin
646        let mut strategy = if cost_dirty <= cost_full && cost_dirty <= cost_redraw {
647            DiffStrategy::DirtyRows
648        } else if cost_full <= cost_redraw {
649            DiffStrategy::Full
650        } else {
651            DiffStrategy::FullRedraw
652        };
653
654        if uncertainty_guard {
655            if guard_reason == "none" {
656                guard_reason = "uncertainty_variance";
657            }
658            if strategy == DiffStrategy::FullRedraw {
659                strategy = if cost_dirty <= cost_full {
660                    DiffStrategy::DirtyRows
661                } else {
662                    DiffStrategy::Full
663                };
664            }
665        }
666
667        let mut hysteresis_applied = false;
668        if let Some(prev) = self.last_evidence.as_ref().map(|e| e.strategy)
669            && prev != strategy
670        {
671            let prev_cost = cost_for_strategy(prev, cost_full, cost_dirty, cost_redraw);
672            let new_cost = cost_for_strategy(strategy, cost_full, cost_dirty, cost_redraw);
673            let ratio = self.config.hysteresis_ratio;
674            if ratio > 0.0
675                && prev_cost.is_finite()
676                && prev_cost > 0.0
677                && new_cost >= prev_cost * (1.0 - ratio)
678                && !(uncertainty_guard && prev == DiffStrategy::FullRedraw)
679            {
680                strategy = prev;
681                hysteresis_applied = true;
682            }
683        }
684
685        // Store evidence
686        let (alpha, beta) = self.estimator.posterior_params();
687        self.last_evidence = Some(StrategyEvidence {
688            strategy,
689            cost_full,
690            cost_dirty,
691            cost_redraw,
692            posterior_mean: self.posterior_mean(),
693            posterior_variance: self.posterior_variance(),
694            alpha,
695            beta,
696            dirty_rows,
697            total_rows: height as usize,
698            total_cells: (width as usize) * (height as usize),
699            guard_reason,
700            hysteresis_applied,
701            hysteresis_ratio: self.config.hysteresis_ratio,
702        });
703
704        strategy
705    }
706
707    /// Update the posterior with observed change rate.
708    ///
709    /// # Arguments
710    ///
711    /// * `cells_scanned` - Number of cells that were scanned for differences
712    /// * `cells_changed` - Number of cells that actually changed
713    pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
714        self.estimator.observe(cells_scanned, cells_changed);
715    }
716
717    /// Reset the posterior to priors.
718    pub fn reset(&mut self) {
719        self.estimator.reset();
720        self.frame_count = 0;
721        self.last_evidence = None;
722    }
723
724    /// Compute the upper quantile of the Beta distribution.
725    ///
726    /// Uses the normal approximation for computational efficiency:
727    /// `p_q ≈ μ + z_q × σ` where z_q is the standard normal quantile.
728    fn upper_quantile(&self, q: f64) -> f64 {
729        self.estimator.upper_quantile(q)
730    }
731}
732
733#[inline]
734fn cost_for_strategy(
735    strategy: DiffStrategy,
736    cost_full: f64,
737    cost_dirty: f64,
738    cost_redraw: f64,
739) -> f64 {
740    match strategy {
741        DiffStrategy::Full => cost_full,
742        DiffStrategy::DirtyRows => cost_dirty,
743        DiffStrategy::FullRedraw => cost_redraw,
744    }
745}
746
747impl Default for DiffStrategySelector {
748    fn default() -> Self {
749        Self::with_defaults()
750    }
751}
752
753// =============================================================================
754// Tests
755// =============================================================================
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    fn strategy_costs(
762        config: &DiffStrategyConfig,
763        width: u16,
764        height: u16,
765        dirty_rows: usize,
766        p_actual: f64,
767    ) -> (f64, f64, f64) {
768        let w = width as f64;
769        let h = height as f64;
770        let d = dirty_rows as f64;
771        let n = w * h;
772        let p = p_actual.clamp(0.0, 1.0);
773
774        let cost_full = config.c_row * h + config.c_scan * d * w + config.c_emit * p * n;
775        let cost_dirty = config.c_scan * d * w + config.c_emit * p * n;
776        let cost_redraw = config.c_emit * n;
777
778        (cost_full, cost_dirty, cost_redraw)
779    }
780
781    #[test]
782    fn test_default_config() {
783        let config = DiffStrategyConfig::default();
784        assert!((config.c_scan - 1.0).abs() < 1e-9);
785        assert!((config.c_emit - 6.0).abs() < 1e-9);
786        assert!((config.prior_alpha - 1.0).abs() < 1e-9);
787        assert!((config.prior_beta - 19.0).abs() < 1e-9);
788        assert!((config.hysteresis_ratio - 0.05).abs() < 1e-9);
789        assert!((config.uncertainty_guard_variance - 0.002).abs() < 1e-9);
790        assert_eq!(config.min_observation_cells, 1);
791    }
792
793    #[test]
794    fn test_decay_paused_on_empty_observation() {
795        let mut selector = DiffStrategySelector::with_defaults();
796        let initial_mean = selector.posterior_mean();
797
798        // Observe empty frames (e.g. idle)
799        for _ in 0..100 {
800            selector.observe(0, 0);
801        }
802
803        // Mean should not change (decay shouldn't happen)
804        assert!((selector.posterior_mean() - initial_mean).abs() < 1e-9);
805    }
806
807    #[test]
808    fn estimator_initializes_from_priors() {
809        let estimator = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
810        let (alpha, beta) = estimator.posterior_params();
811        assert!((alpha - 2.0).abs() < 1e-9);
812        assert!((beta - 8.0).abs() < 1e-9);
813        assert!((estimator.mean() - 0.2).abs() < 1e-9);
814    }
815
816    #[test]
817    fn estimator_updates_with_decay() {
818        let mut estimator = ChangeRateEstimator::new(1.0, 9.0, 0.5, 0);
819        estimator.observe(100, 10);
820        let (alpha, beta) = estimator.posterior_params();
821        assert!((alpha - (0.5 + 10.0)).abs() < 1e-9);
822        assert!((beta - (4.5 + 90.0)).abs() < 1e-9);
823    }
824
825    #[test]
826    fn estimator_clamps_bounds() {
827        let mut estimator = ChangeRateEstimator::new(1.0, 1.0, 1.0, 0);
828        for _ in 0..1000 {
829            estimator.observe(1_000_000, 1_000_000);
830        }
831        let (alpha, beta) = estimator.posterior_params();
832        assert!(alpha <= 1e6);
833        assert!(beta >= 1e-6);
834    }
835
836    #[test]
837    fn test_posterior_mean_initial() {
838        let selector = DiffStrategySelector::with_defaults();
839        // E[p] = α / (α + β) = 1 / 20 = 0.05
840        assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
841    }
842
843    #[test]
844    fn test_posterior_update() {
845        let mut selector = DiffStrategySelector::with_defaults();
846
847        // Observe 10% change rate (10 changed out of 100)
848        selector.observe(100, 10);
849
850        // After update (with decay=0.95):
851        // α = 0.95 * 1 + 10 = 10.95
852        // β = 0.95 * 19 + 90 = 108.05
853        // E[p] = 10.95 / 119.0 ≈ 0.092
854        let mean = selector.posterior_mean();
855        assert!(
856            mean > 0.05,
857            "Mean should increase after observing 10% change"
858        );
859        assert!(mean < 0.15, "Mean should not be too high");
860    }
861
862    #[test]
863    fn test_select_dirty_rows_when_few_dirty() {
864        let mut selector = DiffStrategySelector::with_defaults();
865
866        // With default config and low expected p, dirty rows should win
867        // when few rows are dirty
868        let strategy = selector.select(80, 24, 2); // Only 2 dirty rows
869        assert_eq!(strategy, DiffStrategy::DirtyRows);
870    }
871
872    #[test]
873    fn test_select_dirty_rows_when_no_dirty() {
874        let mut selector = DiffStrategySelector::with_defaults();
875
876        let strategy = selector.select(80, 24, 0);
877        assert_eq!(strategy, DiffStrategy::DirtyRows);
878
879        let evidence = selector.last_evidence().expect("evidence stored");
880        assert_eq!(evidence.guard_reason, "zero_dirty_rows");
881    }
882
883    #[test]
884    fn test_select_dirty_rows_with_single_dirty_row_large_screen() {
885        let mut selector = DiffStrategySelector::with_defaults();
886
887        // Single-row changes on large screens should still favor DirtyRows.
888        let strategy = selector.select(200, 60, 1);
889        assert_eq!(strategy, DiffStrategy::DirtyRows);
890    }
891
892    #[test]
893    fn test_select_full_redraw_when_high_change() {
894        let config = DiffStrategyConfig {
895            prior_alpha: 9.0, // High prior change rate
896            prior_beta: 1.0,  // E[p] = 0.9
897            ..Default::default()
898        };
899
900        let mut selector = DiffStrategySelector::new(config);
901        let strategy = selector.select(80, 24, 24); // All rows dirty
902
903        // With 90% expected change rate and all rows dirty,
904        // full redraw might win depending on cost ratios
905        // This test just verifies the selection doesn't panic
906        assert!(matches!(
907            strategy,
908            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
909        ));
910    }
911
912    #[test]
913    fn test_evidence_stored() {
914        let mut selector = DiffStrategySelector::with_defaults();
915        selector.select(80, 24, 5);
916
917        let evidence = selector.last_evidence().expect("Evidence should be stored");
918        assert_eq!(evidence.total_rows, 24);
919        assert_eq!(evidence.total_cells, 80 * 24);
920        assert_eq!(evidence.dirty_rows, 5);
921    }
922
923    #[test]
924    fn test_posterior_clamping() {
925        let mut selector = DiffStrategySelector::with_defaults();
926
927        // Extreme observation
928        for _ in 0..1000 {
929            selector.observe(1_000_000, 1_000_000);
930        }
931
932        let (alpha, beta) = selector.posterior_params();
933        assert!(alpha <= 1e6, "Alpha should be clamped");
934        assert!(beta >= 1e-6, "Beta should be clamped");
935    }
936
937    #[test]
938    fn conservative_quantile_extremes_are_safe() {
939        let config = DiffStrategyConfig {
940            conservative: true,
941            conservative_quantile: 1.0,
942            ..Default::default()
943        };
944        let mut selector = DiffStrategySelector::new(config);
945
946        let strategy = selector.select(80, 24, 0);
947        let evidence = selector.last_evidence().expect("evidence should exist");
948
949        assert_eq!(strategy, evidence.strategy);
950        assert!(evidence.cost_full.is_finite());
951        assert!(evidence.cost_dirty.is_finite());
952        assert!(evidence.cost_redraw.is_finite());
953    }
954
955    #[test]
956    fn sanitize_config_clamps_invalid_values() {
957        let config = DiffStrategyConfig {
958            c_scan: -1.0,
959            c_emit: f64::NAN,
960            c_row: f64::INFINITY,
961            prior_alpha: 0.0,
962            prior_beta: -3.0,
963            decay: -1.0,
964            conservative: true,
965            conservative_quantile: 2.0,
966            min_observation_cells: 0,
967            hysteresis_ratio: -1.0,
968            uncertainty_guard_variance: -1.0,
969        };
970        let selector = DiffStrategySelector::new(config);
971        let sanitized = selector.config();
972
973        assert!(sanitized.c_scan >= 0.0);
974        assert!(sanitized.c_emit.is_finite());
975        assert!(sanitized.c_row.is_finite());
976        assert!(sanitized.prior_alpha > 0.0);
977        assert!(sanitized.prior_beta > 0.0);
978        assert!((0.0..=1.0).contains(&sanitized.decay));
979        assert!((0.0..=1.0).contains(&sanitized.conservative_quantile));
980        assert!((0.0..=1.0).contains(&sanitized.hysteresis_ratio));
981        assert!(sanitized.uncertainty_guard_variance >= 0.0);
982    }
983
984    #[test]
985    fn hysteresis_can_freeze_strategy_switching() {
986        let config = DiffStrategyConfig {
987            hysteresis_ratio: 1.0,
988            uncertainty_guard_variance: 0.0,
989            ..Default::default()
990        };
991        let mut selector = DiffStrategySelector::new(config);
992
993        let first = selector.select(80, 24, 1);
994        let second = selector.select(80, 24, 24);
995
996        assert_eq!(
997            first, second,
998            "With hysteresis_ratio=1.0, selector should keep prior strategy"
999        );
1000    }
1001
1002    #[test]
1003    fn uncertainty_guard_avoids_full_redraw() {
1004        let config = DiffStrategyConfig {
1005            c_scan: 10.0,
1006            c_emit: 1.0,
1007            uncertainty_guard_variance: 1e-6,
1008            ..Default::default()
1009        };
1010        let mut selector = DiffStrategySelector::new(config);
1011
1012        let strategy = selector.select(80, 24, 24);
1013        assert_ne!(
1014            strategy,
1015            DiffStrategy::FullRedraw,
1016            "Uncertainty guard should avoid FullRedraw under high variance"
1017        );
1018    }
1019
1020    #[test]
1021    fn selector_regret_bounded_across_regimes() {
1022        let mut selector = DiffStrategySelector::with_defaults();
1023        let config = selector.config().clone();
1024        let width = 200u16;
1025        let height = 60u16;
1026        let total_cells = width as usize * height as usize;
1027
1028        let regimes = [
1029            (100usize, 2usize, 0.02f64),
1030            (100usize, 12usize, 0.12f64),
1031            (100usize, height as usize, 0.6f64),
1032        ];
1033
1034        let mut selector_total = 0.0f64;
1035        let mut fixed_full_total = 0.0f64;
1036        let mut fixed_dirty_total = 0.0f64;
1037        let mut fixed_redraw_total = 0.0f64;
1038
1039        for (frames, dirty_rows, p_actual) in regimes {
1040            for _ in 0..frames {
1041                let strategy = selector.select(width, height, dirty_rows);
1042                let (cost_full, cost_dirty, cost_redraw) =
1043                    strategy_costs(&config, width, height, dirty_rows, p_actual);
1044                fixed_full_total += cost_full;
1045                fixed_dirty_total += cost_dirty;
1046                fixed_redraw_total += cost_redraw;
1047
1048                let chosen_cost = match strategy {
1049                    DiffStrategy::Full => cost_full,
1050                    DiffStrategy::DirtyRows => cost_dirty,
1051                    DiffStrategy::FullRedraw => cost_redraw,
1052                };
1053                selector_total += chosen_cost;
1054
1055                let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1056                let scanned = match strategy {
1057                    DiffStrategy::Full => total_cells,
1058                    DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1059                    DiffStrategy::FullRedraw => 0,
1060                };
1061                if strategy != DiffStrategy::FullRedraw {
1062                    selector.observe(scanned, changed);
1063                }
1064            }
1065        }
1066
1067        let best_fixed = fixed_full_total
1068            .min(fixed_dirty_total)
1069            .min(fixed_redraw_total);
1070        let regret = if best_fixed > 0.0 {
1071            (selector_total - best_fixed) / best_fixed
1072        } else {
1073            0.0
1074        };
1075        let evidence = selector
1076            .last_evidence()
1077            .map(ToString::to_string)
1078            .unwrap_or_else(|| "no evidence".to_string());
1079
1080        assert!(
1081            regret <= 0.05,
1082            "Selector regret too high: {:.4} (selector {:.2}, best_fixed {:.2})\n{}",
1083            regret,
1084            selector_total,
1085            best_fixed,
1086            evidence
1087        );
1088    }
1089
1090    #[test]
1091    fn selector_switching_is_stable_under_constant_load() {
1092        let mut selector = DiffStrategySelector::with_defaults();
1093        let config = selector.config().clone();
1094        let width = 200u16;
1095        let height = 60u16;
1096        let dirty_rows = 2usize;
1097        let p_actual = 0.02f64;
1098        let total_cells = width as usize * height as usize;
1099
1100        let mut switches = 0usize;
1101        let mut last = None;
1102
1103        for _ in 0..200 {
1104            let strategy = selector.select(width, height, dirty_rows);
1105            if let Some(prev) = last
1106                && prev != strategy
1107            {
1108                switches = switches.saturating_add(1);
1109            }
1110            last = Some(strategy);
1111
1112            let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1113            let scanned = match strategy {
1114                DiffStrategy::Full => total_cells,
1115                DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1116                DiffStrategy::FullRedraw => 0,
1117            };
1118            if strategy != DiffStrategy::FullRedraw {
1119                selector.observe(scanned, changed);
1120            }
1121
1122            let _ = strategy_costs(&config, width, height, dirty_rows, p_actual);
1123        }
1124
1125        let evidence = selector
1126            .last_evidence()
1127            .map(ToString::to_string)
1128            .unwrap_or_else(|| "no evidence".to_string());
1129        assert!(
1130            switches <= 40,
1131            "Selector switched too often under stable regime: {switches}\n{evidence}"
1132        );
1133    }
1134
1135    #[test]
1136    fn test_reset() {
1137        let mut selector = DiffStrategySelector::with_defaults();
1138        selector.observe(100, 50);
1139        selector.select(80, 24, 10);
1140
1141        selector.reset();
1142
1143        assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
1144        assert_eq!(selector.frame_count(), 0);
1145        assert!(selector.last_evidence().is_none());
1146    }
1147
1148    #[test]
1149    fn test_deterministic() {
1150        let mut sel1 = DiffStrategySelector::with_defaults();
1151        let mut sel2 = DiffStrategySelector::with_defaults();
1152
1153        // Same inputs should produce same outputs
1154        sel1.observe(100, 10);
1155        sel2.observe(100, 10);
1156
1157        let s1 = sel1.select(80, 24, 5);
1158        let s2 = sel2.select(80, 24, 5);
1159
1160        assert_eq!(s1, s2);
1161        assert!((sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12);
1162    }
1163
1164    #[test]
1165    fn test_upper_quantile_reasonable() {
1166        let selector = DiffStrategySelector::with_defaults();
1167        let mean = selector.posterior_mean();
1168        let q95 = selector.upper_quantile(0.95);
1169
1170        assert!(q95 > mean, "95th percentile should be above mean");
1171        assert!(q95 <= 1.0, "Quantile should be bounded by 1.0");
1172    }
1173
1174    // Property test: posterior mean is always in [0, 1]
1175    #[test]
1176    fn prop_posterior_mean_bounded() {
1177        let mut selector = DiffStrategySelector::with_defaults();
1178
1179        for scanned in [1, 10, 100, 1000, 10000] {
1180            for changed in [0, 1, scanned / 10, scanned / 2, scanned] {
1181                selector.observe(scanned, changed);
1182                let mean = selector.posterior_mean();
1183                assert!((0.0..=1.0).contains(&mean), "Mean out of bounds: {mean}");
1184            }
1185        }
1186    }
1187
1188    // Property test: variance is always non-negative
1189    #[test]
1190    fn prop_variance_non_negative() {
1191        let mut selector = DiffStrategySelector::with_defaults();
1192
1193        for _ in 0..100 {
1194            selector.observe(100, 5);
1195            assert!(selector.posterior_variance() >= 0.0);
1196        }
1197    }
1198
1199    // --- DiffStrategy enum ---
1200
1201    #[test]
1202    fn diff_strategy_display() {
1203        assert_eq!(format!("{}", DiffStrategy::Full), "Full");
1204        assert_eq!(format!("{}", DiffStrategy::DirtyRows), "DirtyRows");
1205        assert_eq!(format!("{}", DiffStrategy::FullRedraw), "FullRedraw");
1206    }
1207
1208    #[test]
1209    fn diff_strategy_debug() {
1210        let dbg = format!("{:?}", DiffStrategy::Full);
1211        assert!(dbg.contains("Full"));
1212    }
1213
1214    #[test]
1215    fn diff_strategy_clone_and_eq() {
1216        let a = DiffStrategy::DirtyRows;
1217        let b = a;
1218        assert_eq!(a, b);
1219        assert_ne!(a, DiffStrategy::Full);
1220    }
1221
1222    // --- StrategyEvidence ---
1223
1224    #[test]
1225    fn strategy_evidence_display_contains_all_sections() {
1226        let mut selector = DiffStrategySelector::with_defaults();
1227        selector.select(80, 24, 5);
1228        let ev = selector.last_evidence().unwrap();
1229        let display = format!("{ev}");
1230        assert!(display.contains("Strategy:"));
1231        assert!(display.contains("Costs:"));
1232        assert!(display.contains("Posterior:"));
1233        assert!(display.contains("Dirty:"));
1234        assert!(display.contains("Guard:"));
1235        assert!(display.contains("Hysteresis:"));
1236    }
1237
1238    #[test]
1239    fn strategy_evidence_clone() {
1240        let mut selector = DiffStrategySelector::with_defaults();
1241        selector.select(80, 24, 3);
1242        let ev = selector.last_evidence().unwrap().clone();
1243        assert_eq!(ev.dirty_rows, 3);
1244        assert_eq!(ev.total_rows, 24);
1245        assert_eq!(ev.total_cells, 80 * 24);
1246    }
1247
1248    #[test]
1249    fn strategy_evidence_debug() {
1250        let mut selector = DiffStrategySelector::with_defaults();
1251        selector.select(80, 24, 2);
1252        let ev = selector.last_evidence().unwrap();
1253        let dbg = format!("{ev:?}");
1254        assert!(dbg.contains("StrategyEvidence"));
1255        assert!(dbg.contains("cost_full"));
1256    }
1257
1258    // --- Config ---
1259
1260    #[test]
1261    fn config_default_all_fields() {
1262        let c = DiffStrategyConfig::default();
1263        assert!((c.c_row - 0.1).abs() < 1e-9);
1264        assert!((c.decay - 0.95).abs() < 1e-9);
1265        assert!(!c.conservative);
1266        assert!((c.conservative_quantile - 0.95).abs() < 1e-9);
1267    }
1268
1269    #[test]
1270    fn config_clone_and_debug() {
1271        let c = DiffStrategyConfig::default();
1272        let c2 = c.clone();
1273        assert!((c2.c_scan - c.c_scan).abs() < 1e-9);
1274        let dbg = format!("{c:?}");
1275        assert!(dbg.contains("DiffStrategyConfig"));
1276        assert!(dbg.contains("c_scan"));
1277    }
1278
1279    // --- Selector construction ---
1280
1281    #[test]
1282    fn selector_default_equals_with_defaults() {
1283        let s1 = DiffStrategySelector::default();
1284        let s2 = DiffStrategySelector::with_defaults();
1285        assert!((s1.posterior_mean() - s2.posterior_mean()).abs() < 1e-12);
1286        assert_eq!(s1.frame_count(), s2.frame_count());
1287    }
1288
1289    #[test]
1290    fn selector_config_accessor() {
1291        let config = DiffStrategyConfig {
1292            c_scan: 2.0,
1293            ..Default::default()
1294        };
1295        let selector = DiffStrategySelector::new(config);
1296        assert!((selector.config().c_scan - 2.0).abs() < 1e-9);
1297    }
1298
1299    // --- frame_count ---
1300
1301    #[test]
1302    fn frame_count_increments_per_select() {
1303        let mut selector = DiffStrategySelector::with_defaults();
1304        assert_eq!(selector.frame_count(), 0);
1305        selector.select(80, 24, 1);
1306        assert_eq!(selector.frame_count(), 1);
1307        selector.select(80, 24, 1);
1308        assert_eq!(selector.frame_count(), 2);
1309        for _ in 0..10 {
1310            selector.select(80, 24, 1);
1311        }
1312        assert_eq!(selector.frame_count(), 12);
1313    }
1314
1315    #[test]
1316    fn frame_count_not_affected_by_observe() {
1317        let mut selector = DiffStrategySelector::with_defaults();
1318        selector.observe(100, 10);
1319        assert_eq!(selector.frame_count(), 0);
1320    }
1321
1322    // --- override_last_strategy ---
1323
1324    #[test]
1325    fn override_last_strategy_changes_evidence() {
1326        let mut selector = DiffStrategySelector::with_defaults();
1327        selector.select(80, 24, 2);
1328        let original = selector.last_evidence().unwrap().strategy;
1329
1330        let override_to = if original == DiffStrategy::Full {
1331            DiffStrategy::FullRedraw
1332        } else {
1333            DiffStrategy::Full
1334        };
1335        selector.override_last_strategy(override_to, "test_override");
1336
1337        let ev = selector.last_evidence().unwrap();
1338        assert_eq!(ev.strategy, override_to);
1339        assert_eq!(ev.guard_reason, "test_override");
1340        assert!(!ev.hysteresis_applied);
1341    }
1342
1343    #[test]
1344    fn override_last_strategy_noop_when_no_evidence() {
1345        let mut selector = DiffStrategySelector::with_defaults();
1346        // No select() called yet, so no evidence.
1347        selector.override_last_strategy(DiffStrategy::Full, "noop");
1348        assert!(selector.last_evidence().is_none());
1349    }
1350
1351    // --- select_with_scan_estimate ---
1352
1353    #[test]
1354    fn select_with_scan_estimate_custom_cells() {
1355        let mut selector = DiffStrategySelector::with_defaults();
1356        // Provide a very small scan estimate to make DirtyRows cheaper.
1357        let strategy = selector.select_with_scan_estimate(80, 24, 10, 10);
1358        assert_eq!(strategy, DiffStrategy::DirtyRows);
1359    }
1360
1361    #[test]
1362    fn select_with_scan_estimate_clamped_to_total() {
1363        let mut selector = DiffStrategySelector::with_defaults();
1364        // Provide scan_cells > total cells — should be clamped.
1365        let strategy = selector.select_with_scan_estimate(80, 24, 5, 1_000_000);
1366        // Should not panic, strategy is valid.
1367        assert!(matches!(
1368            strategy,
1369            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1370        ));
1371    }
1372
1373    // --- Estimator ---
1374
1375    #[test]
1376    fn estimator_reset_restores_priors() {
1377        let mut est = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
1378        est.observe(100, 50);
1379        assert!((est.mean() - 0.2).abs() > 0.01, "Mean should have changed");
1380
1381        est.reset();
1382        let (alpha, beta) = est.posterior_params();
1383        assert!((alpha - 2.0).abs() < 1e-9);
1384        assert!((beta - 8.0).abs() < 1e-9);
1385        assert!((est.mean() - 0.2).abs() < 1e-9);
1386    }
1387
1388    #[test]
1389    fn estimator_clone() {
1390        let est1 = ChangeRateEstimator::new(1.0, 9.0, 0.95, 0);
1391        let est2 = est1.clone();
1392        assert!((est2.mean() - est1.mean()).abs() < 1e-12);
1393    }
1394
1395    #[test]
1396    fn estimator_debug() {
1397        let est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1398        let dbg = format!("{est:?}");
1399        assert!(dbg.contains("ChangeRateEstimator"));
1400    }
1401
1402    #[test]
1403    fn estimator_min_observation_cells_filters() {
1404        let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 50);
1405        let initial_mean = est.mean();
1406        // Observe fewer than min_observation_cells — should be no-op.
1407        est.observe(49, 25);
1408        assert!(
1409            (est.mean() - initial_mean).abs() < 1e-12,
1410            "Observation below min should be ignored"
1411        );
1412        // Observe exactly min_observation_cells — should update.
1413        est.observe(50, 25);
1414        assert!(
1415            (est.mean() - initial_mean).abs() > 0.01,
1416            "Observation at min should be accepted"
1417        );
1418    }
1419
1420    #[test]
1421    fn estimator_changed_exceeds_scanned_is_clamped() {
1422        let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1423        // changed > scanned should be clamped to scanned.
1424        est.observe(10, 100);
1425        let mean = est.mean();
1426        // With all cells changed, mean should be high.
1427        assert!(mean > 0.3, "Mean should be high when all cells changed");
1428    }
1429
1430    #[test]
1431    fn estimator_variance_decreases_with_data() {
1432        let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1433        let v0 = est.variance();
1434        for _ in 0..50 {
1435            est.observe(100, 5);
1436        }
1437        let v1 = est.variance();
1438        assert!(
1439            v1 < v0,
1440            "Variance should decrease with more data: before={v0:.6}, after={v1:.6}"
1441        );
1442    }
1443
1444    #[test]
1445    fn estimator_upper_quantile_at_50_pct_near_mean() {
1446        let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1447        let mean = est.mean();
1448        let q50 = est.upper_quantile(0.5);
1449        assert!(
1450            (q50 - mean).abs() < 0.05,
1451            "50th percentile should be near mean: q50={q50:.4}, mean={mean:.4}"
1452        );
1453    }
1454
1455    #[test]
1456    fn estimator_upper_quantile_monotonic() {
1457        let est = ChangeRateEstimator::new(5.0, 15.0, 1.0, 0);
1458        let q25 = est.upper_quantile(0.25);
1459        let q50 = est.upper_quantile(0.5);
1460        let q75 = est.upper_quantile(0.75);
1461        let q95 = est.upper_quantile(0.95);
1462        assert!(q25 <= q50, "q25={q25:.4} should <= q50={q50:.4}");
1463        assert!(q50 <= q75, "q50={q50:.4} should <= q75={q75:.4}");
1464        assert!(q75 <= q95, "q75={q75:.4} should <= q95={q95:.4}");
1465    }
1466
1467    // --- Normalization helpers ---
1468
1469    #[test]
1470    fn normalize_positive_rejects_zero_and_negative() {
1471        assert!((normalize_positive(0.0, 5.0) - 5.0).abs() < 1e-9);
1472        assert!((normalize_positive(-1.0, 5.0) - 5.0).abs() < 1e-9);
1473        assert!((normalize_positive(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1474        assert!((normalize_positive(3.0, 5.0) - 3.0).abs() < 1e-9);
1475    }
1476
1477    #[test]
1478    fn normalize_cost_accepts_zero() {
1479        assert!((normalize_cost(0.0, 5.0) - 0.0).abs() < 1e-9);
1480        assert!((normalize_cost(-1.0, 5.0) - 5.0).abs() < 1e-9);
1481        assert!((normalize_cost(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1482    }
1483
1484    #[test]
1485    fn normalize_decay_clamps_to_one() {
1486        assert!((normalize_decay(1.5) - 1.0).abs() < 1e-9);
1487        assert!((normalize_decay(0.5) - 0.5).abs() < 1e-9);
1488        assert!((normalize_decay(-1.0) - 1.0).abs() < 1e-9);
1489        assert!((normalize_decay(0.0) - 1.0).abs() < 1e-9);
1490        assert!((normalize_decay(f64::NAN) - 1.0).abs() < 1e-9);
1491    }
1492
1493    #[test]
1494    fn normalize_ratio_clamps_to_unit() {
1495        assert!((normalize_ratio(0.5, 0.1) - 0.5).abs() < 1e-9);
1496        assert!((normalize_ratio(-1.0, 0.1) - 0.0).abs() < 1e-9);
1497        assert!((normalize_ratio(2.0, 0.1) - 1.0).abs() < 1e-9);
1498        assert!((normalize_ratio(f64::NAN, 0.1) - 0.1).abs() < 1e-9);
1499    }
1500
1501    // --- cost_for_strategy helper ---
1502
1503    #[test]
1504    fn cost_for_strategy_returns_correct_values() {
1505        assert!((cost_for_strategy(DiffStrategy::Full, 1.0, 2.0, 3.0) - 1.0).abs() < 1e-9);
1506        assert!((cost_for_strategy(DiffStrategy::DirtyRows, 1.0, 2.0, 3.0) - 2.0).abs() < 1e-9);
1507        assert!((cost_for_strategy(DiffStrategy::FullRedraw, 1.0, 2.0, 3.0) - 3.0).abs() < 1e-9);
1508    }
1509
1510    // --- Small / edge-case buffers ---
1511
1512    #[test]
1513    fn select_1x1_buffer() {
1514        let mut selector = DiffStrategySelector::with_defaults();
1515        let strategy = selector.select(1, 1, 1);
1516        assert!(matches!(
1517            strategy,
1518            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1519        ));
1520    }
1521
1522    #[test]
1523    fn select_zero_width() {
1524        let mut selector = DiffStrategySelector::with_defaults();
1525        let strategy = selector.select(0, 24, 0);
1526        // Zero-width buffer → zero cells → DirtyRows should be cheapest.
1527        assert!(matches!(
1528            strategy,
1529            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1530        ));
1531    }
1532
1533    #[test]
1534    fn select_zero_height() {
1535        let mut selector = DiffStrategySelector::with_defaults();
1536        let strategy = selector.select(80, 0, 0);
1537        assert!(matches!(
1538            strategy,
1539            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1540        ));
1541    }
1542
1543    // --- All-dirty vs no-dirty ---
1544
1545    #[test]
1546    fn all_dirty_vs_no_dirty_different_evidence() {
1547        let mut sel1 = DiffStrategySelector::with_defaults();
1548        let mut sel2 = DiffStrategySelector::with_defaults();
1549
1550        sel1.select(80, 24, 0);
1551        sel2.select(80, 24, 24);
1552
1553        let ev1 = sel1.last_evidence().unwrap();
1554        let ev2 = sel2.last_evidence().unwrap();
1555
1556        assert_eq!(ev1.dirty_rows, 0);
1557        assert_eq!(ev2.dirty_rows, 24);
1558        // No-dirty should have zero p estimate.
1559        assert!(
1560            ev1.cost_dirty <= ev1.cost_full,
1561            "DirtyRows should be cheap with no dirty rows"
1562        );
1563    }
1564
1565    // --- Decay = 1.0 (no decay) ---
1566
1567    #[test]
1568    fn no_decay_accumulates_all_evidence() {
1569        let config = DiffStrategyConfig {
1570            decay: 1.0,
1571            ..Default::default()
1572        };
1573        let mut selector = DiffStrategySelector::new(config);
1574
1575        // Observe 100% change rate many times.
1576        for _ in 0..100 {
1577            selector.observe(100, 100);
1578        }
1579        let mean = selector.posterior_mean();
1580        // With no decay and all-changed, mean should be near 1.0.
1581        assert!(
1582            mean > 0.9,
1583            "No-decay all-changed mean should be near 1.0: {mean:.4}"
1584        );
1585    }
1586
1587    // --- Evidence costs are finite ---
1588
1589    #[test]
1590    fn evidence_costs_always_finite() {
1591        let mut selector = DiffStrategySelector::with_defaults();
1592        for dirty in [0, 1, 12, 24] {
1593            selector.select(80, 24, dirty);
1594            let ev = selector.last_evidence().unwrap();
1595            assert!(ev.cost_full.is_finite(), "cost_full should be finite");
1596            assert!(ev.cost_dirty.is_finite(), "cost_dirty should be finite");
1597            assert!(ev.cost_redraw.is_finite(), "cost_redraw should be finite");
1598        }
1599    }
1600
1601    // --- Evidence posterior matches selector ---
1602
1603    #[test]
1604    fn evidence_posterior_matches_selector() {
1605        let mut selector = DiffStrategySelector::with_defaults();
1606        selector.observe(100, 10);
1607        selector.select(80, 24, 5);
1608        let ev = selector.last_evidence().unwrap();
1609        assert!((ev.posterior_mean - selector.posterior_mean()).abs() < 1e-12);
1610        assert!((ev.posterior_variance - selector.posterior_variance()).abs() < 1e-12);
1611        let (alpha, beta) = selector.posterior_params();
1612        assert!((ev.alpha - alpha).abs() < 1e-12);
1613        assert!((ev.beta - beta).abs() < 1e-12);
1614    }
1615
1616    // --- Selector clone ---
1617
1618    #[test]
1619    fn selector_clone() {
1620        let mut selector = DiffStrategySelector::with_defaults();
1621        selector.observe(100, 10);
1622        selector.select(80, 24, 5);
1623        let clone = selector.clone();
1624        assert!((clone.posterior_mean() - selector.posterior_mean()).abs() < 1e-12);
1625        assert_eq!(clone.frame_count(), selector.frame_count());
1626    }
1627
1628    // --- Selector debug ---
1629
1630    #[test]
1631    fn selector_debug() {
1632        let selector = DiffStrategySelector::with_defaults();
1633        let dbg = format!("{selector:?}");
1634        assert!(dbg.contains("DiffStrategySelector"));
1635        assert!(dbg.contains("frame_count"));
1636    }
1637
1638    // --- Hysteresis not applied on first select ---
1639
1640    #[test]
1641    fn hysteresis_not_applied_on_first_select() {
1642        let config = DiffStrategyConfig {
1643            hysteresis_ratio: 1.0,
1644            ..Default::default()
1645        };
1646        let mut selector = DiffStrategySelector::new(config);
1647        selector.select(80, 24, 5);
1648        let ev = selector.last_evidence().unwrap();
1649        assert!(
1650            !ev.hysteresis_applied,
1651            "First select should not apply hysteresis"
1652        );
1653    }
1654
1655    // --- Conservative mode uses upper quantile ---
1656
1657    #[test]
1658    fn conservative_mode_higher_p_estimate() {
1659        let mut conservative = DiffStrategySelector::new(DiffStrategyConfig {
1660            conservative: true,
1661            ..Default::default()
1662        });
1663        let mut normal = DiffStrategySelector::with_defaults();
1664
1665        // Same observations.
1666        for _ in 0..20 {
1667            conservative.observe(100, 5);
1668            normal.observe(100, 5);
1669        }
1670
1671        conservative.select(80, 24, 12);
1672        normal.select(80, 24, 12);
1673
1674        let ev_cons = conservative.last_evidence().unwrap();
1675        let ev_norm = normal.last_evidence().unwrap();
1676
1677        // Conservative mode uses upper quantile of p, so cost_dirty (which
1678        // includes p * N * c_emit) should be >= normal mode's cost_dirty.
1679        assert!(
1680            ev_cons.cost_dirty >= ev_norm.cost_dirty - 1e-6,
1681            "Conservative costs should be >= normal costs"
1682        );
1683    }
1684
1685    // -----------------------------------------------------------------------
1686    // Edge-case tests (bd-2az7m)
1687    // -----------------------------------------------------------------------
1688
1689    mod edge_case_tests {
1690        use super::super::*;
1691        use super::strategy_costs;
1692
1693        // --- ChangeRateEstimator edge cases ---
1694
1695        #[test]
1696        fn estimator_observe_zero_scanned_with_min_one() {
1697            // Default min_observation_cells=1, so scanned=0 is filtered out
1698            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 1);
1699            let initial = est.mean();
1700            est.observe(0, 0);
1701            assert!(
1702                (est.mean() - initial).abs() < 1e-12,
1703                "Zero scanned should be filtered: mean changed"
1704            );
1705        }
1706
1707        #[test]
1708        fn estimator_observe_all_unchanged() {
1709            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1710            for _ in 0..100 {
1711                est.observe(1000, 0);
1712            }
1713            // With 100% unchanged observations, mean should be very low
1714            assert!(
1715                est.mean() < 0.01,
1716                "All-unchanged observations should drive mean near zero: {}",
1717                est.mean()
1718            );
1719        }
1720
1721        #[test]
1722        fn estimator_observe_all_changed() {
1723            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1724            for _ in 0..100 {
1725                est.observe(1000, 1000);
1726            }
1727            // With 100% changed observations, mean should be very high
1728            assert!(
1729                est.mean() > 0.99,
1730                "All-changed observations should drive mean near 1.0: {}",
1731                est.mean()
1732            );
1733        }
1734
1735        #[test]
1736        fn estimator_rapid_decay_forgets_quickly() {
1737            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.1, 0);
1738            // Observe high change rate
1739            for _ in 0..50 {
1740                est.observe(100, 90);
1741            }
1742            let high_mean = est.mean();
1743
1744            // Now observe low change rate — with decay=0.1, should adapt fast
1745            for _ in 0..10 {
1746                est.observe(100, 1);
1747            }
1748            let low_mean = est.mean();
1749
1750            assert!(
1751                low_mean < high_mean * 0.5,
1752                "Rapid decay should forget quickly: high={high_mean:.4}, low={low_mean:.4}"
1753            );
1754        }
1755
1756        #[test]
1757        fn estimator_alternating_observations() {
1758            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1759            for i in 0..100 {
1760                if i % 2 == 0 {
1761                    est.observe(100, 100); // All changed
1762                } else {
1763                    est.observe(100, 0); // Nothing changed
1764                }
1765            }
1766            // Mean should settle around 0.5
1767            let mean = est.mean();
1768            assert!(
1769                mean > 0.3 && mean < 0.7,
1770                "Alternating observations should settle near 0.5: {mean:.4}"
1771            );
1772        }
1773
1774        #[test]
1775        fn estimator_upper_quantile_at_extreme_low() {
1776            let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1777            let q01 = est.upper_quantile(0.01);
1778            assert!(
1779                q01 >= 0.0,
1780                "Lower quantile should be non-negative: {q01:.4}"
1781            );
1782            assert!(
1783                q01 < est.mean(),
1784                "1st percentile should be below mean: q01={q01:.4}, mean={:.4}",
1785                est.mean()
1786            );
1787        }
1788
1789        #[test]
1790        fn estimator_upper_quantile_at_extreme_high() {
1791            let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1792            let q99 = est.upper_quantile(0.99);
1793            assert!(q99 <= 1.0, "Upper quantile should be <= 1.0: {q99:.4}");
1794            assert!(
1795                q99 > est.mean(),
1796                "99th percentile should be above mean: q99={q99:.4}, mean={:.4}",
1797                est.mean()
1798            );
1799        }
1800
1801        #[test]
1802        fn estimator_upper_quantile_tight_posterior() {
1803            // With lots of data, posterior is tight → quantiles near mean
1804            let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1805            for _ in 0..10000 {
1806                est.observe(100, 5);
1807            }
1808            let mean = est.mean();
1809            let q95 = est.upper_quantile(0.95);
1810            assert!(
1811                (q95 - mean).abs() < 0.01,
1812                "Tight posterior should have quantile near mean: q95={q95:.4}, mean={mean:.4}"
1813            );
1814        }
1815
1816        #[test]
1817        fn estimator_upper_quantile_clamped_output() {
1818            // Even with extreme inputs, output should be in [0, 1]
1819            let est = ChangeRateEstimator::new(1e-6, 1e-6, 1.0, 0);
1820            for q in [0.01, 0.1, 0.5, 0.9, 0.99] {
1821                let val = est.upper_quantile(q);
1822                assert!(
1823                    (0.0..=1.0).contains(&val),
1824                    "Quantile({q}) = {val} should be in [0,1]"
1825                );
1826            }
1827        }
1828
1829        #[test]
1830        fn estimator_variance_formula_correct() {
1831            let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1832            let (a, b) = est.posterior_params();
1833            let expected_var = (a * b) / ((a + b).powi(2) * (a + b + 1.0));
1834            assert!(
1835                (est.variance() - expected_var).abs() < 1e-12,
1836                "Variance formula: got {}, expected {}",
1837                est.variance(),
1838                expected_var
1839            );
1840        }
1841
1842        #[test]
1843        fn estimator_mean_formula_correct() {
1844            let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1845            let (a, b) = est.posterior_params();
1846            let expected_mean = a / (a + b);
1847            assert!(
1848                (est.mean() - expected_mean).abs() < 1e-12,
1849                "Mean formula: got {}, expected {}",
1850                est.mean(),
1851                expected_mean
1852            );
1853        }
1854
1855        // --- DiffStrategySelector edge cases ---
1856
1857        #[test]
1858        fn select_dirty_rows_exceeds_height() {
1859            let mut selector = DiffStrategySelector::with_defaults();
1860            // dirty_rows > height shouldn't panic
1861            let strategy = selector.select(80, 24, 100);
1862            assert!(matches!(
1863                strategy,
1864                DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1865            ));
1866        }
1867
1868        #[test]
1869        fn select_large_dimensions() {
1870            let mut selector = DiffStrategySelector::with_defaults();
1871            // u16::MAX width and height
1872            let strategy = selector.select(u16::MAX, u16::MAX, 1);
1873            assert!(matches!(
1874                strategy,
1875                DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1876            ));
1877            let ev = selector.last_evidence().unwrap();
1878            assert!(ev.cost_full.is_finite());
1879            assert!(ev.cost_dirty.is_finite());
1880            assert!(ev.cost_redraw.is_finite());
1881        }
1882
1883        #[test]
1884        fn multiple_selects_without_observe() {
1885            let mut selector = DiffStrategySelector::with_defaults();
1886            let initial_mean = selector.posterior_mean();
1887
1888            for _ in 0..50 {
1889                selector.select(80, 24, 5);
1890            }
1891
1892            // Posterior should not change without observations
1893            assert!(
1894                (selector.posterior_mean() - initial_mean).abs() < 1e-12,
1895                "Mean should not change without observations"
1896            );
1897            assert_eq!(selector.frame_count(), 50);
1898        }
1899
1900        #[test]
1901        fn conservative_with_zero_dirty_rows() {
1902            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1903                conservative: true,
1904                ..Default::default()
1905            });
1906            let strategy = selector.select(80, 24, 0);
1907            assert_eq!(strategy, DiffStrategy::DirtyRows);
1908            let ev = selector.last_evidence().unwrap();
1909            assert_eq!(ev.guard_reason, "zero_dirty_rows");
1910        }
1911
1912        #[test]
1913        fn uncertainty_guard_with_fullredraw_hysteresis() {
1914            // When uncertainty guard is active and previous strategy was FullRedraw,
1915            // hysteresis should NOT prevent switching away from FullRedraw
1916            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1917                c_scan: 10.0,
1918                c_emit: 1.0,
1919                uncertainty_guard_variance: 1e-6,
1920                hysteresis_ratio: 0.99, // Very high hysteresis
1921                ..Default::default()
1922            });
1923
1924            // First select — likely FullRedraw since c_scan is high
1925            selector.select(80, 24, 24);
1926
1927            // Second select — uncertainty guard should override hysteresis for FullRedraw
1928            let strategy = selector.select(80, 24, 24);
1929            // Under uncertainty guard, FullRedraw is avoided
1930            assert_ne!(
1931                strategy,
1932                DiffStrategy::FullRedraw,
1933                "Uncertainty guard should override hysteresis for FullRedraw"
1934            );
1935        }
1936
1937        #[test]
1938        fn select_with_scan_estimate_zero_cells() {
1939            let mut selector = DiffStrategySelector::with_defaults();
1940            // Zero scan cells makes DirtyRows very cheap
1941            let strategy = selector.select_with_scan_estimate(80, 24, 5, 0);
1942            assert_eq!(strategy, DiffStrategy::DirtyRows);
1943        }
1944
1945        #[test]
1946        fn hysteresis_prevents_switch_near_boundary() {
1947            let config = DiffStrategyConfig {
1948                hysteresis_ratio: 0.5, // 50% — very sticky
1949                uncertainty_guard_variance: 0.0,
1950                ..Default::default()
1951            };
1952            let mut selector = DiffStrategySelector::new(config);
1953
1954            // First select establishes a strategy
1955            let first = selector.select(80, 24, 5);
1956
1957            // Second select with slightly different params — hysteresis should hold
1958            let second = selector.select(80, 24, 6);
1959            assert_eq!(
1960                first, second,
1961                "High hysteresis should prevent switching on small changes"
1962            );
1963        }
1964
1965        #[test]
1966        fn reset_clears_frame_count_and_evidence() {
1967            let mut selector = DiffStrategySelector::with_defaults();
1968            selector.observe(100, 10);
1969            selector.select(80, 24, 5);
1970            selector.select(80, 24, 5);
1971
1972            assert_eq!(selector.frame_count(), 2);
1973            assert!(selector.last_evidence().is_some());
1974
1975            selector.reset();
1976
1977            assert_eq!(selector.frame_count(), 0);
1978            assert!(selector.last_evidence().is_none());
1979            assert!(
1980                (selector.posterior_mean() - 0.05).abs() < 1e-9,
1981                "Reset should restore prior mean"
1982            );
1983        }
1984
1985        #[test]
1986        fn posterior_variance_after_reset() {
1987            let mut selector = DiffStrategySelector::with_defaults();
1988            let initial_var = selector.posterior_variance();
1989
1990            selector.observe(100, 10);
1991            assert!(selector.posterior_variance() != initial_var);
1992
1993            selector.reset();
1994            assert!(
1995                (selector.posterior_variance() - initial_var).abs() < 1e-12,
1996                "Reset should restore prior variance"
1997            );
1998        }
1999
2000        // --- Normalization edge cases ---
2001
2002        #[test]
2003        fn normalize_positive_rejects_infinity() {
2004            assert!(
2005                (normalize_positive(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2006                "Infinity should be rejected"
2007            );
2008            assert!(
2009                (normalize_positive(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2010                "Negative infinity should be rejected"
2011            );
2012        }
2013
2014        #[test]
2015        fn normalize_cost_rejects_neg_infinity() {
2016            assert!(
2017                (normalize_cost(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2018                "Negative infinity should be rejected"
2019            );
2020        }
2021
2022        #[test]
2023        fn normalize_cost_accepts_positive_infinity() {
2024            // Positive infinity is not finite, so should be rejected
2025            assert!(
2026                (normalize_cost(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2027                "Positive infinity should be rejected"
2028            );
2029        }
2030
2031        #[test]
2032        fn normalize_ratio_rejects_infinity() {
2033            assert!(
2034                (normalize_ratio(f64::INFINITY, 0.1) - 0.1).abs() < 1e-9,
2035                "Infinity should use fallback"
2036            );
2037            assert!(
2038                (normalize_ratio(f64::NEG_INFINITY, 0.1) - 0.1).abs() < 1e-9,
2039                "Negative infinity should use fallback"
2040            );
2041        }
2042
2043        #[test]
2044        fn normalize_decay_rejects_neg_infinity() {
2045            assert!(
2046                (normalize_decay(f64::NEG_INFINITY) - 1.0).abs() < 1e-9,
2047                "Negative infinity should use fallback"
2048            );
2049        }
2050
2051        // --- Strategy cost ordering edge cases ---
2052
2053        #[test]
2054        fn cost_redraw_independent_of_dirty_rows() {
2055            let mut sel1 = DiffStrategySelector::with_defaults();
2056            let mut sel2 = DiffStrategySelector::with_defaults();
2057
2058            sel1.select(80, 24, 0);
2059            sel2.select(80, 24, 24);
2060
2061            let ev1 = sel1.last_evidence().unwrap();
2062            let ev2 = sel2.last_evidence().unwrap();
2063
2064            // FullRedraw cost = c_emit * N, independent of dirty rows
2065            assert!(
2066                (ev1.cost_redraw - ev2.cost_redraw).abs() < 1e-6,
2067                "FullRedraw cost should not depend on dirty rows"
2068            );
2069        }
2070
2071        #[test]
2072        fn cost_full_increases_with_dirty_rows() {
2073            let config = DiffStrategyConfig::default();
2074            // With more dirty rows, Full cost increases (more scan cost)
2075            let (cost_full_2, _, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2076            let (cost_full_20, _, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2077            assert!(
2078                cost_full_20 > cost_full_2,
2079                "More dirty rows should increase Full cost: 2={cost_full_2:.2}, 20={cost_full_20:.2}"
2080            );
2081        }
2082
2083        #[test]
2084        fn cost_dirty_increases_with_dirty_rows() {
2085            let config = DiffStrategyConfig::default();
2086            let (_, cost_dirty_2, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2087            let (_, cost_dirty_20, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2088            assert!(
2089                cost_dirty_20 > cost_dirty_2,
2090                "More dirty rows should increase DirtyRows cost"
2091            );
2092        }
2093
2094        // --- Strategy evidence field completeness ---
2095
2096        #[test]
2097        fn evidence_all_fields_populated() {
2098            let mut selector = DiffStrategySelector::with_defaults();
2099            selector.observe(100, 10);
2100            selector.select(200, 60, 15);
2101
2102            let ev = selector.last_evidence().unwrap();
2103            assert_eq!(ev.total_rows, 60);
2104            assert_eq!(ev.total_cells, 200 * 60);
2105            assert_eq!(ev.dirty_rows, 15);
2106            assert!(ev.cost_full >= 0.0);
2107            assert!(ev.cost_dirty >= 0.0);
2108            assert!(ev.cost_redraw >= 0.0);
2109            assert!((0.0..=1.0).contains(&ev.posterior_mean));
2110            assert!(ev.posterior_variance >= 0.0);
2111            assert!(ev.alpha > 0.0);
2112            assert!(ev.beta > 0.0);
2113            assert!(!ev.guard_reason.is_empty());
2114            assert!(ev.hysteresis_ratio >= 0.0);
2115        }
2116
2117        #[test]
2118        fn evidence_display_format() {
2119            let mut selector = DiffStrategySelector::with_defaults();
2120            selector.select(80, 24, 5);
2121            let ev = selector.last_evidence().unwrap();
2122            let display = format!("{ev}");
2123
2124            // Should contain key sections
2125            assert!(display.contains("Strategy:"));
2126            assert!(display.contains("Costs:"));
2127            assert!(display.contains("Posterior:"));
2128            assert!(display.contains("Dirty:"));
2129            assert!(display.contains("Guard:"));
2130        }
2131
2132        // --- DiffStrategy enum completeness ---
2133
2134        #[test]
2135        fn diff_strategy_all_variants_distinct() {
2136            let variants = [
2137                DiffStrategy::Full,
2138                DiffStrategy::DirtyRows,
2139                DiffStrategy::FullRedraw,
2140            ];
2141            for (i, a) in variants.iter().enumerate() {
2142                for (j, b) in variants.iter().enumerate() {
2143                    if i == j {
2144                        assert_eq!(a, b);
2145                    } else {
2146                        assert_ne!(a, b);
2147                    }
2148                }
2149            }
2150        }
2151
2152        #[test]
2153        fn diff_strategy_copy() {
2154            let a = DiffStrategy::DirtyRows;
2155            let b = a; // Copy
2156            let _c = a; // Still valid — a was copied, not moved
2157            assert_eq!(a, b);
2158        }
2159
2160        // --- Selector with custom priors ---
2161
2162        #[test]
2163        fn custom_prior_high_alpha_favors_dirty_rows_less() {
2164            // High prior alpha → expect more changes → Full/FullRedraw more likely
2165            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2166                prior_alpha: 50.0,
2167                prior_beta: 1.0, // E[p] ≈ 0.98
2168                ..Default::default()
2169            });
2170            selector.select(80, 24, 24);
2171            let ev = selector.last_evidence().unwrap();
2172            // With p≈0.98 and all dirty, FullRedraw should be competitive
2173            assert!(
2174                ev.cost_redraw <= ev.cost_full * 1.5,
2175                "High change rate should make redraw competitive"
2176            );
2177        }
2178
2179        #[test]
2180        fn custom_prior_high_beta_favors_dirty_rows() {
2181            // High prior beta → expect few changes → DirtyRows efficient
2182            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2183                prior_alpha: 1.0,
2184                prior_beta: 1000.0, // E[p] ≈ 0.001
2185                ..Default::default()
2186            });
2187            let strategy = selector.select(80, 24, 5);
2188            assert_eq!(
2189                strategy,
2190                DiffStrategy::DirtyRows,
2191                "Very low expected change rate should favor DirtyRows"
2192            );
2193        }
2194
2195        // --- Decay boundary behavior ---
2196
2197        #[test]
2198        fn decay_zero_sanitizes_to_one() {
2199            // decay=0 is invalid (non-positive), sanitized() should set it to 1.0
2200            let config = DiffStrategyConfig {
2201                decay: 0.0,
2202                ..Default::default()
2203            };
2204            let selector = DiffStrategySelector::new(config);
2205            // With decay=1.0 (sanitized from 0.0), accumulation should work
2206            assert!(
2207                (selector.config().decay - 1.0).abs() < 1e-9,
2208                "Decay=0.0 should be sanitized to 1.0"
2209            );
2210        }
2211
2212        #[test]
2213        fn decay_one_no_forgetting() {
2214            let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
2215            est.observe(100, 10);
2216            let (a1, b1) = est.posterior_params();
2217            // With decay=1.0: alpha = 1.0*1.0 + 10 = 11.0, beta = 1.0*19.0 + 90 = 109.0
2218            assert!(
2219                (a1 - 11.0).abs() < 1e-9,
2220                "No-decay alpha: expected 11.0, got {a1}"
2221            );
2222            assert!(
2223                (b1 - 109.0).abs() < 1e-9,
2224                "No-decay beta: expected 109.0, got {b1}"
2225            );
2226        }
2227
2228        // --- Selector determinism across multiple frames ---
2229
2230        #[test]
2231        fn determinism_across_long_trace() {
2232            let trace: Vec<(u16, u16, usize, usize, usize)> = (0..200)
2233                .map(|i| {
2234                    let dirty = (i * 3 % 24) + 1;
2235                    let scanned = 80 * dirty;
2236                    let changed = (i * 7 % scanned.max(1)).max(1);
2237                    (80u16, 24u16, dirty, scanned, changed)
2238                })
2239                .collect();
2240
2241            let mut sel1 = DiffStrategySelector::with_defaults();
2242            let mut sel2 = DiffStrategySelector::with_defaults();
2243
2244            for (w, h, dirty, scanned, changed) in &trace {
2245                let s1 = sel1.select(*w, *h, *dirty);
2246                let s2 = sel2.select(*w, *h, *dirty);
2247                assert_eq!(s1, s2, "Determinism violated");
2248
2249                sel1.observe(*scanned, *changed);
2250                sel2.observe(*scanned, *changed);
2251
2252                assert!(
2253                    (sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12,
2254                    "Posterior diverged"
2255                );
2256            }
2257        }
2258
2259        // --- Override edge cases ---
2260
2261        #[test]
2262        fn override_changes_strategy_and_clears_hysteresis() {
2263            let mut selector = DiffStrategySelector::with_defaults();
2264            selector.select(80, 24, 5);
2265
2266            let original = selector.last_evidence().unwrap().strategy;
2267            let target = if original == DiffStrategy::FullRedraw {
2268                DiffStrategy::Full
2269            } else {
2270                DiffStrategy::FullRedraw
2271            };
2272
2273            selector.override_last_strategy(target, "forced_override");
2274            let ev = selector.last_evidence().unwrap();
2275
2276            assert_eq!(ev.strategy, target, "Override should change strategy");
2277            assert_eq!(ev.guard_reason, "forced_override");
2278            assert!(!ev.hysteresis_applied, "Override should clear hysteresis");
2279        }
2280
2281        // --- Config sanitization edge cases ---
2282
2283        #[test]
2284        fn sanitize_preserves_valid_config() {
2285            let config = DiffStrategyConfig {
2286                c_scan: 2.0,
2287                c_emit: 8.0,
2288                c_row: 0.5,
2289                prior_alpha: 3.0,
2290                prior_beta: 17.0,
2291                decay: 0.9,
2292                conservative: true,
2293                conservative_quantile: 0.9,
2294                min_observation_cells: 5,
2295                hysteresis_ratio: 0.1,
2296                uncertainty_guard_variance: 0.005,
2297            };
2298            let selector = DiffStrategySelector::new(config);
2299            let c = selector.config();
2300            assert!((c.c_scan - 2.0).abs() < 1e-9);
2301            assert!((c.c_emit - 8.0).abs() < 1e-9);
2302            assert!((c.c_row - 0.5).abs() < 1e-9);
2303            assert!((c.prior_alpha - 3.0).abs() < 1e-9);
2304            assert!((c.prior_beta - 17.0).abs() < 1e-9);
2305            assert!((c.decay - 0.9).abs() < 1e-9);
2306            assert!(c.conservative);
2307            assert!((c.conservative_quantile - 0.9).abs() < 1e-9);
2308            assert_eq!(c.min_observation_cells, 5);
2309            assert!((c.hysteresis_ratio - 0.1).abs() < 1e-9);
2310        }
2311
2312        #[test]
2313        fn sanitize_all_nan_uses_defaults() {
2314            let config = DiffStrategyConfig {
2315                c_scan: f64::NAN,
2316                c_emit: f64::NAN,
2317                c_row: f64::NAN,
2318                prior_alpha: f64::NAN,
2319                prior_beta: f64::NAN,
2320                decay: f64::NAN,
2321                conservative: false,
2322                conservative_quantile: f64::NAN,
2323                min_observation_cells: 0,
2324                hysteresis_ratio: f64::NAN,
2325                uncertainty_guard_variance: f64::NAN,
2326            };
2327            let selector = DiffStrategySelector::new(config);
2328            let c = selector.config();
2329            // All should be sanitized to defaults
2330            assert!((c.c_scan - 1.0).abs() < 1e-9);
2331            assert!((c.c_emit - 6.0).abs() < 1e-9);
2332            assert!((c.c_row - 0.1).abs() < 1e-9);
2333            assert!((c.prior_alpha - 1.0).abs() < 1e-9);
2334            assert!((c.prior_beta - 19.0).abs() < 1e-9);
2335            assert!((c.decay - 1.0).abs() < 1e-9);
2336            assert!((c.hysteresis_ratio - 0.05).abs() < 1e-9);
2337            assert!((c.uncertainty_guard_variance - 0.002).abs() < 1e-9);
2338        }
2339
2340        // --- Cost model correctness ---
2341
2342        #[test]
2343        fn zero_change_rate_costs() {
2344            let config = DiffStrategyConfig::default();
2345            let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 5, 0.0);
2346            // With p=0: Full = c_row*H + c_scan*D*W, Dirty = c_scan*D*W, Redraw = c_emit*N
2347            let expected_full = config.c_row * 24.0 + config.c_scan * 5.0 * 80.0;
2348            let expected_dirty = config.c_scan * 5.0 * 80.0;
2349            let expected_redraw = config.c_emit * 80.0 * 24.0;
2350
2351            assert!((cost_full - expected_full).abs() < 1e-6);
2352            assert!((cost_dirty - expected_dirty).abs() < 1e-6);
2353            assert!((cost_redraw - expected_redraw).abs() < 1e-6);
2354        }
2355
2356        #[test]
2357        fn full_change_rate_costs() {
2358            let config = DiffStrategyConfig::default();
2359            let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 24, 1.0);
2360            // With p=1.0 and all dirty: Full and Dirty both include c_emit*N
2361            // Redraw = c_emit*N, which should be cheapest since no scan cost
2362            assert!(
2363                cost_redraw <= cost_full,
2364                "At p=1.0, redraw should be <= full"
2365            );
2366            assert!(
2367                cost_redraw <= cost_dirty,
2368                "At p=1.0, redraw should be <= dirty"
2369            );
2370        }
2371    }
2372}