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: 1 (so `observe(0, 0)` is a no-op, pausing decay on empty frames)
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        //
639        // The posterior p is trained by observe() as changes-per-SCANNED-cell
640        // (DirtyRows observations scan the dirty region, not the screen), so
641        // the expected-emission term must multiply p by each path's own
642        // scanned-cell count, never by total cells. Using p·N here inflated
643        // the emit estimate by N/scanned for concentrated workloads (e.g. a
644        // half-screen log pane rewriting its rows had density ≈ 1.0 within
645        // its 6000 scanned cells; p·N priced 12000 emitted cells) and locked
646        // the selector into FullRedraw at 2x wire bytes, with the periodic
647        // probe re-observing density ≈ 1.0 and flipping straight back.
648        // Changes can only occur inside the scanned/dirty region (dirty rows
649        // ⊇ changed rows), so p·scanned is both consistent with training and
650        // a sound upper bound on emitted cells.
651        let cost_full = self.config.c_row * h
652            + self.config.c_scan * d * w
653            + self.config.c_emit * p * (d * w).min(n);
654
655        let cost_dirty = self.config.c_scan * scan_cells + self.config.c_emit * p * scan_cells;
656
657        let cost_redraw = self.config.c_emit * n;
658
659        // Select argmin
660        let mut strategy = if cost_dirty <= cost_full && cost_dirty <= cost_redraw {
661            DiffStrategy::DirtyRows
662        } else if cost_full <= cost_redraw {
663            DiffStrategy::Full
664        } else {
665            DiffStrategy::FullRedraw
666        };
667
668        if uncertainty_guard {
669            if guard_reason == "none" {
670                guard_reason = "uncertainty_variance";
671            }
672            if strategy == DiffStrategy::FullRedraw {
673                strategy = if cost_dirty <= cost_full {
674                    DiffStrategy::DirtyRows
675                } else {
676                    DiffStrategy::Full
677                };
678            }
679        }
680
681        let mut hysteresis_applied = false;
682        if let Some(prev) = self.last_evidence.as_ref().map(|e| e.strategy)
683            && prev != strategy
684        {
685            let prev_cost = cost_for_strategy(prev, cost_full, cost_dirty, cost_redraw);
686            let new_cost = cost_for_strategy(strategy, cost_full, cost_dirty, cost_redraw);
687            let ratio = self.config.hysteresis_ratio;
688            if ratio > 0.0
689                && prev_cost.is_finite()
690                && prev_cost > 0.0
691                && new_cost >= prev_cost * (1.0 - ratio)
692                && !(uncertainty_guard && prev == DiffStrategy::FullRedraw)
693            {
694                strategy = prev;
695                hysteresis_applied = true;
696            }
697        }
698
699        // Store evidence
700        let (alpha, beta) = self.estimator.posterior_params();
701        self.last_evidence = Some(StrategyEvidence {
702            strategy,
703            cost_full,
704            cost_dirty,
705            cost_redraw,
706            posterior_mean: self.posterior_mean(),
707            posterior_variance: self.posterior_variance(),
708            alpha,
709            beta,
710            dirty_rows,
711            total_rows: height as usize,
712            total_cells: (width as usize) * (height as usize),
713            guard_reason,
714            hysteresis_applied,
715            hysteresis_ratio: self.config.hysteresis_ratio,
716        });
717
718        strategy
719    }
720
721    /// Update the posterior with observed change rate.
722    ///
723    /// # Arguments
724    ///
725    /// * `cells_scanned` - Number of cells that were scanned for differences
726    /// * `cells_changed` - Number of cells that actually changed
727    pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
728        self.estimator.observe(cells_scanned, cells_changed);
729    }
730
731    /// Reset the posterior to priors.
732    pub fn reset(&mut self) {
733        self.estimator.reset();
734        self.frame_count = 0;
735        self.last_evidence = None;
736    }
737
738    /// Compute the upper quantile of the Beta distribution.
739    ///
740    /// Uses the normal approximation for computational efficiency:
741    /// `p_q ≈ μ + z_q × σ` where z_q is the standard normal quantile.
742    fn upper_quantile(&self, q: f64) -> f64 {
743        self.estimator.upper_quantile(q)
744    }
745}
746
747#[inline]
748fn cost_for_strategy(
749    strategy: DiffStrategy,
750    cost_full: f64,
751    cost_dirty: f64,
752    cost_redraw: f64,
753) -> f64 {
754    match strategy {
755        DiffStrategy::Full => cost_full,
756        DiffStrategy::DirtyRows => cost_dirty,
757        DiffStrategy::FullRedraw => cost_redraw,
758    }
759}
760
761impl Default for DiffStrategySelector {
762    fn default() -> Self {
763        Self::with_defaults()
764    }
765}
766
767// =============================================================================
768// Tests
769// =============================================================================
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    fn strategy_costs(
776        config: &DiffStrategyConfig,
777        width: u16,
778        height: u16,
779        dirty_rows: usize,
780        p_actual: f64,
781    ) -> (f64, f64, f64) {
782        let w = width as f64;
783        let h = height as f64;
784        let d = dirty_rows as f64;
785        let n = w * h;
786        let p = p_actual.clamp(0.0, 1.0);
787
788        let cost_full = config.c_row * h + config.c_scan * d * w + config.c_emit * p * n;
789        let cost_dirty = config.c_scan * d * w + config.c_emit * p * n;
790        let cost_redraw = config.c_emit * n;
791
792        (cost_full, cost_dirty, cost_redraw)
793    }
794
795    #[test]
796    fn test_default_config() {
797        let config = DiffStrategyConfig::default();
798        assert!((config.c_scan - 1.0).abs() < 1e-9);
799        assert!((config.c_emit - 6.0).abs() < 1e-9);
800        assert!((config.prior_alpha - 1.0).abs() < 1e-9);
801        assert!((config.prior_beta - 19.0).abs() < 1e-9);
802        assert!((config.hysteresis_ratio - 0.05).abs() < 1e-9);
803        assert!((config.uncertainty_guard_variance - 0.002).abs() < 1e-9);
804        assert_eq!(config.min_observation_cells, 1);
805    }
806
807    #[test]
808    fn test_decay_paused_on_empty_observation() {
809        let mut selector = DiffStrategySelector::with_defaults();
810        let initial_mean = selector.posterior_mean();
811
812        // Observe empty frames (e.g. idle)
813        for _ in 0..100 {
814            selector.observe(0, 0);
815        }
816
817        // Mean should not change (decay shouldn't happen)
818        assert!((selector.posterior_mean() - initial_mean).abs() < 1e-9);
819    }
820
821    #[test]
822    fn estimator_initializes_from_priors() {
823        let estimator = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
824        let (alpha, beta) = estimator.posterior_params();
825        assert!((alpha - 2.0).abs() < 1e-9);
826        assert!((beta - 8.0).abs() < 1e-9);
827        assert!((estimator.mean() - 0.2).abs() < 1e-9);
828    }
829
830    #[test]
831    fn estimator_updates_with_decay() {
832        let mut estimator = ChangeRateEstimator::new(1.0, 9.0, 0.5, 0);
833        estimator.observe(100, 10);
834        let (alpha, beta) = estimator.posterior_params();
835        assert!((alpha - (0.5 + 10.0)).abs() < 1e-9);
836        assert!((beta - (4.5 + 90.0)).abs() < 1e-9);
837    }
838
839    #[test]
840    fn estimator_clamps_bounds() {
841        let mut estimator = ChangeRateEstimator::new(1.0, 1.0, 1.0, 0);
842        for _ in 0..1000 {
843            estimator.observe(1_000_000, 1_000_000);
844        }
845        let (alpha, beta) = estimator.posterior_params();
846        assert!(alpha <= 1e6);
847        assert!(beta >= 1e-6);
848    }
849
850    #[test]
851    fn test_posterior_mean_initial() {
852        let selector = DiffStrategySelector::with_defaults();
853        // `E[p]` = α / (α + β) = 1 / 20 = 0.05
854        assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
855    }
856
857    #[test]
858    fn test_posterior_update() {
859        let mut selector = DiffStrategySelector::with_defaults();
860
861        // Observe 10% change rate (10 changed out of 100)
862        selector.observe(100, 10);
863
864        // After update (with decay=0.95):
865        // α = 0.95 * 1 + 10 = 10.95
866        // β = 0.95 * 19 + 90 = 108.05
867        // `E[p]` = 10.95 / 119.0 ≈ 0.092
868        let mean = selector.posterior_mean();
869        assert!(
870            mean > 0.05,
871            "Mean should increase after observing 10% change"
872        );
873        assert!(mean < 0.15, "Mean should not be too high");
874    }
875
876    #[test]
877    fn test_select_dirty_rows_when_few_dirty() {
878        let mut selector = DiffStrategySelector::with_defaults();
879
880        // With default config and low expected p, dirty rows should win
881        // when few rows are dirty
882        let strategy = selector.select(80, 24, 2); // Only 2 dirty rows
883        assert_eq!(strategy, DiffStrategy::DirtyRows);
884    }
885
886    #[test]
887    fn test_select_dirty_rows_when_no_dirty() {
888        let mut selector = DiffStrategySelector::with_defaults();
889
890        let strategy = selector.select(80, 24, 0);
891        assert_eq!(strategy, DiffStrategy::DirtyRows);
892
893        let evidence = selector.last_evidence().expect("evidence stored");
894        assert_eq!(evidence.guard_reason, "zero_dirty_rows");
895    }
896
897    #[test]
898    fn test_select_dirty_rows_with_single_dirty_row_large_screen() {
899        let mut selector = DiffStrategySelector::with_defaults();
900
901        // Single-row changes on large screens should still favor DirtyRows.
902        let strategy = selector.select(200, 60, 1);
903        assert_eq!(strategy, DiffStrategy::DirtyRows);
904    }
905
906    #[test]
907    fn test_select_full_redraw_when_high_change() {
908        let config = DiffStrategyConfig {
909            prior_alpha: 9.0, // High prior change rate
910            prior_beta: 1.0,  // `E[p]` = 0.9
911            ..Default::default()
912        };
913
914        let mut selector = DiffStrategySelector::new(config);
915        let strategy = selector.select(80, 24, 24); // All rows dirty
916
917        // With 90% expected change rate and all rows dirty,
918        // full redraw might win depending on cost ratios
919        // This test just verifies the selection doesn't panic
920        assert!(matches!(
921            strategy,
922            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
923        ));
924    }
925
926    #[test]
927    fn test_evidence_stored() {
928        let mut selector = DiffStrategySelector::with_defaults();
929        selector.select(80, 24, 5);
930
931        let evidence = selector.last_evidence().expect("Evidence should be stored");
932        assert_eq!(evidence.total_rows, 24);
933        assert_eq!(evidence.total_cells, 80 * 24);
934        assert_eq!(evidence.dirty_rows, 5);
935    }
936
937    #[test]
938    fn test_posterior_clamping() {
939        let mut selector = DiffStrategySelector::with_defaults();
940
941        // Extreme observation
942        for _ in 0..1000 {
943            selector.observe(1_000_000, 1_000_000);
944        }
945
946        let (alpha, beta) = selector.posterior_params();
947        assert!(alpha <= 1e6, "Alpha should be clamped");
948        assert!(beta >= 1e-6, "Beta should be clamped");
949    }
950
951    #[test]
952    fn conservative_quantile_extremes_are_safe() {
953        let config = DiffStrategyConfig {
954            conservative: true,
955            conservative_quantile: 1.0,
956            ..Default::default()
957        };
958        let mut selector = DiffStrategySelector::new(config);
959
960        let strategy = selector.select(80, 24, 0);
961        let evidence = selector.last_evidence().expect("evidence should exist");
962
963        assert_eq!(strategy, evidence.strategy);
964        assert!(evidence.cost_full.is_finite());
965        assert!(evidence.cost_dirty.is_finite());
966        assert!(evidence.cost_redraw.is_finite());
967    }
968
969    #[test]
970    fn sanitize_config_clamps_invalid_values() {
971        let config = DiffStrategyConfig {
972            c_scan: -1.0,
973            c_emit: f64::NAN,
974            c_row: f64::INFINITY,
975            prior_alpha: 0.0,
976            prior_beta: -3.0,
977            decay: -1.0,
978            conservative: true,
979            conservative_quantile: 2.0,
980            min_observation_cells: 0,
981            hysteresis_ratio: -1.0,
982            uncertainty_guard_variance: -1.0,
983        };
984        let selector = DiffStrategySelector::new(config);
985        let sanitized = selector.config();
986
987        assert!(sanitized.c_scan >= 0.0);
988        assert!(sanitized.c_emit.is_finite());
989        assert!(sanitized.c_row.is_finite());
990        assert!(sanitized.prior_alpha > 0.0);
991        assert!(sanitized.prior_beta > 0.0);
992        assert!((0.0..=1.0).contains(&sanitized.decay));
993        assert!((0.0..=1.0).contains(&sanitized.conservative_quantile));
994        assert!((0.0..=1.0).contains(&sanitized.hysteresis_ratio));
995        assert!(sanitized.uncertainty_guard_variance >= 0.0);
996    }
997
998    #[test]
999    fn hysteresis_can_freeze_strategy_switching() {
1000        let config = DiffStrategyConfig {
1001            hysteresis_ratio: 1.0,
1002            uncertainty_guard_variance: 0.0,
1003            ..Default::default()
1004        };
1005        let mut selector = DiffStrategySelector::new(config);
1006
1007        let first = selector.select(80, 24, 1);
1008        let second = selector.select(80, 24, 24);
1009
1010        assert_eq!(
1011            first, second,
1012            "With hysteresis_ratio=1.0, selector should keep prior strategy"
1013        );
1014    }
1015
1016    #[test]
1017    fn uncertainty_guard_avoids_full_redraw() {
1018        let config = DiffStrategyConfig {
1019            c_scan: 10.0,
1020            c_emit: 1.0,
1021            uncertainty_guard_variance: 1e-6,
1022            ..Default::default()
1023        };
1024        let mut selector = DiffStrategySelector::new(config);
1025
1026        let strategy = selector.select(80, 24, 24);
1027        assert_ne!(
1028            strategy,
1029            DiffStrategy::FullRedraw,
1030            "Uncertainty guard should avoid FullRedraw under high variance"
1031        );
1032    }
1033
1034    #[test]
1035    fn concentrated_half_screen_workload_stays_dirty_rows() {
1036        // Regression: p is trained as changes-per-SCANNED-cell, but the emit
1037        // term used p * total_cells. A half-screen pane rewriting all its
1038        // rows (density within the dirty region ~= 1.0) priced 2x the real
1039        // emission and locked the selector into FullRedraw at double the
1040        // wire bytes — sticky, because the periodic probe re-observed
1041        // density ~= 1.0 and flipped straight back.
1042        let mut sel = DiffStrategySelector::new(DiffStrategyConfig::default());
1043
1044        // Steady workload: 200x60 terminal, 30 fully-changing rows/frame.
1045        for _ in 0..10 {
1046            let strategy = sel.select_with_scan_estimate(200, 60, 30, 6000);
1047            assert_ne!(
1048                strategy,
1049                DiffStrategy::FullRedraw,
1050                "half-screen concentrated workload must not full-redraw                  (6000 scanned + 6000 emitted < 12000 emitted)"
1051            );
1052            sel.observe(6000, 6000);
1053        }
1054    }
1055
1056    #[test]
1057    fn selector_regret_bounded_across_regimes() {
1058        let mut selector = DiffStrategySelector::with_defaults();
1059        let config = selector.config().clone();
1060        let width = 200u16;
1061        let height = 60u16;
1062        let total_cells = width as usize * height as usize;
1063
1064        let regimes = [
1065            (100usize, 2usize, 0.02f64),
1066            (100usize, 12usize, 0.12f64),
1067            (100usize, height as usize, 0.6f64),
1068        ];
1069
1070        let mut selector_total = 0.0f64;
1071        let mut fixed_full_total = 0.0f64;
1072        let mut fixed_dirty_total = 0.0f64;
1073        let mut fixed_redraw_total = 0.0f64;
1074
1075        for (frames, dirty_rows, p_actual) in regimes {
1076            for _ in 0..frames {
1077                let strategy = selector.select(width, height, dirty_rows);
1078                let (cost_full, cost_dirty, cost_redraw) =
1079                    strategy_costs(&config, width, height, dirty_rows, p_actual);
1080                fixed_full_total += cost_full;
1081                fixed_dirty_total += cost_dirty;
1082                fixed_redraw_total += cost_redraw;
1083
1084                let chosen_cost = match strategy {
1085                    DiffStrategy::Full => cost_full,
1086                    DiffStrategy::DirtyRows => cost_dirty,
1087                    DiffStrategy::FullRedraw => cost_redraw,
1088                };
1089                selector_total += chosen_cost;
1090
1091                let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1092                let scanned = match strategy {
1093                    DiffStrategy::Full => total_cells,
1094                    DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1095                    DiffStrategy::FullRedraw => 0,
1096                };
1097                if strategy != DiffStrategy::FullRedraw {
1098                    selector.observe(scanned, changed);
1099                }
1100            }
1101        }
1102
1103        let best_fixed = fixed_full_total
1104            .min(fixed_dirty_total)
1105            .min(fixed_redraw_total);
1106        let regret = if best_fixed > 0.0 {
1107            (selector_total - best_fixed) / best_fixed
1108        } else {
1109            0.0
1110        };
1111        let evidence = selector
1112            .last_evidence()
1113            .map(ToString::to_string)
1114            .unwrap_or_else(|| "no evidence".to_string());
1115
1116        assert!(
1117            regret <= 0.05,
1118            "Selector regret too high: {:.4} (selector {:.2}, best_fixed {:.2})\n{}",
1119            regret,
1120            selector_total,
1121            best_fixed,
1122            evidence
1123        );
1124    }
1125
1126    #[test]
1127    fn selector_switching_is_stable_under_constant_load() {
1128        let mut selector = DiffStrategySelector::with_defaults();
1129        let config = selector.config().clone();
1130        let width = 200u16;
1131        let height = 60u16;
1132        let dirty_rows = 2usize;
1133        let p_actual = 0.02f64;
1134        let total_cells = width as usize * height as usize;
1135
1136        let mut switches = 0usize;
1137        let mut last = None;
1138
1139        for _ in 0..200 {
1140            let strategy = selector.select(width, height, dirty_rows);
1141            if let Some(prev) = last
1142                && prev != strategy
1143            {
1144                switches = switches.saturating_add(1);
1145            }
1146            last = Some(strategy);
1147
1148            let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1149            let scanned = match strategy {
1150                DiffStrategy::Full => total_cells,
1151                DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1152                DiffStrategy::FullRedraw => 0,
1153            };
1154            if strategy != DiffStrategy::FullRedraw {
1155                selector.observe(scanned, changed);
1156            }
1157
1158            let _ = strategy_costs(&config, width, height, dirty_rows, p_actual);
1159        }
1160
1161        let evidence = selector
1162            .last_evidence()
1163            .map(ToString::to_string)
1164            .unwrap_or_else(|| "no evidence".to_string());
1165        assert!(
1166            switches <= 40,
1167            "Selector switched too often under stable regime: {switches}\n{evidence}"
1168        );
1169    }
1170
1171    #[test]
1172    fn test_reset() {
1173        let mut selector = DiffStrategySelector::with_defaults();
1174        selector.observe(100, 50);
1175        selector.select(80, 24, 10);
1176
1177        selector.reset();
1178
1179        assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
1180        assert_eq!(selector.frame_count(), 0);
1181        assert!(selector.last_evidence().is_none());
1182    }
1183
1184    #[test]
1185    fn test_deterministic() {
1186        let mut sel1 = DiffStrategySelector::with_defaults();
1187        let mut sel2 = DiffStrategySelector::with_defaults();
1188
1189        // Same inputs should produce same outputs
1190        sel1.observe(100, 10);
1191        sel2.observe(100, 10);
1192
1193        let s1 = sel1.select(80, 24, 5);
1194        let s2 = sel2.select(80, 24, 5);
1195
1196        assert_eq!(s1, s2);
1197        assert!((sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12);
1198    }
1199
1200    #[test]
1201    fn test_upper_quantile_reasonable() {
1202        let selector = DiffStrategySelector::with_defaults();
1203        let mean = selector.posterior_mean();
1204        let q95 = selector.upper_quantile(0.95);
1205
1206        assert!(q95 > mean, "95th percentile should be above mean");
1207        assert!(q95 <= 1.0, "Quantile should be bounded by 1.0");
1208    }
1209
1210    // Property test: posterior mean is always in [0, 1]
1211    #[test]
1212    fn prop_posterior_mean_bounded() {
1213        let mut selector = DiffStrategySelector::with_defaults();
1214
1215        for scanned in [1, 10, 100, 1000, 10000] {
1216            for changed in [0, 1, scanned / 10, scanned / 2, scanned] {
1217                selector.observe(scanned, changed);
1218                let mean = selector.posterior_mean();
1219                assert!((0.0..=1.0).contains(&mean), "Mean out of bounds: {mean}");
1220            }
1221        }
1222    }
1223
1224    // Property test: variance is always non-negative
1225    #[test]
1226    fn prop_variance_non_negative() {
1227        let mut selector = DiffStrategySelector::with_defaults();
1228
1229        for _ in 0..100 {
1230            selector.observe(100, 5);
1231            assert!(selector.posterior_variance() >= 0.0);
1232        }
1233    }
1234
1235    // --- DiffStrategy enum ---
1236
1237    #[test]
1238    fn diff_strategy_display() {
1239        assert_eq!(format!("{}", DiffStrategy::Full), "Full");
1240        assert_eq!(format!("{}", DiffStrategy::DirtyRows), "DirtyRows");
1241        assert_eq!(format!("{}", DiffStrategy::FullRedraw), "FullRedraw");
1242    }
1243
1244    #[test]
1245    fn diff_strategy_debug() {
1246        let dbg = format!("{:?}", DiffStrategy::Full);
1247        assert!(dbg.contains("Full"));
1248    }
1249
1250    #[test]
1251    fn diff_strategy_clone_and_eq() {
1252        let a = DiffStrategy::DirtyRows;
1253        let b = a;
1254        assert_eq!(a, b);
1255        assert_ne!(a, DiffStrategy::Full);
1256    }
1257
1258    // --- StrategyEvidence ---
1259
1260    #[test]
1261    fn strategy_evidence_display_contains_all_sections() {
1262        let mut selector = DiffStrategySelector::with_defaults();
1263        selector.select(80, 24, 5);
1264        let ev = selector.last_evidence().unwrap();
1265        let display = format!("{ev}");
1266        assert!(display.contains("Strategy:"));
1267        assert!(display.contains("Costs:"));
1268        assert!(display.contains("Posterior:"));
1269        assert!(display.contains("Dirty:"));
1270        assert!(display.contains("Guard:"));
1271        assert!(display.contains("Hysteresis:"));
1272    }
1273
1274    #[test]
1275    fn strategy_evidence_clone() {
1276        let mut selector = DiffStrategySelector::with_defaults();
1277        selector.select(80, 24, 3);
1278        let ev = selector.last_evidence().unwrap().clone();
1279        assert_eq!(ev.dirty_rows, 3);
1280        assert_eq!(ev.total_rows, 24);
1281        assert_eq!(ev.total_cells, 80 * 24);
1282    }
1283
1284    #[test]
1285    fn strategy_evidence_debug() {
1286        let mut selector = DiffStrategySelector::with_defaults();
1287        selector.select(80, 24, 2);
1288        let ev = selector.last_evidence().unwrap();
1289        let dbg = format!("{ev:?}");
1290        assert!(dbg.contains("StrategyEvidence"));
1291        assert!(dbg.contains("cost_full"));
1292    }
1293
1294    // --- Config ---
1295
1296    #[test]
1297    fn config_default_all_fields() {
1298        let c = DiffStrategyConfig::default();
1299        assert!((c.c_row - 0.1).abs() < 1e-9);
1300        assert!((c.decay - 0.95).abs() < 1e-9);
1301        assert!(!c.conservative);
1302        assert!((c.conservative_quantile - 0.95).abs() < 1e-9);
1303    }
1304
1305    #[test]
1306    fn config_clone_and_debug() {
1307        let c = DiffStrategyConfig::default();
1308        let c2 = c.clone();
1309        assert!((c2.c_scan - c.c_scan).abs() < 1e-9);
1310        let dbg = format!("{c:?}");
1311        assert!(dbg.contains("DiffStrategyConfig"));
1312        assert!(dbg.contains("c_scan"));
1313    }
1314
1315    // --- Selector construction ---
1316
1317    #[test]
1318    fn selector_default_equals_with_defaults() {
1319        let s1 = DiffStrategySelector::default();
1320        let s2 = DiffStrategySelector::with_defaults();
1321        assert!((s1.posterior_mean() - s2.posterior_mean()).abs() < 1e-12);
1322        assert_eq!(s1.frame_count(), s2.frame_count());
1323    }
1324
1325    #[test]
1326    fn selector_config_accessor() {
1327        let config = DiffStrategyConfig {
1328            c_scan: 2.0,
1329            ..Default::default()
1330        };
1331        let selector = DiffStrategySelector::new(config);
1332        assert!((selector.config().c_scan - 2.0).abs() < 1e-9);
1333    }
1334
1335    // --- frame_count ---
1336
1337    #[test]
1338    fn frame_count_increments_per_select() {
1339        let mut selector = DiffStrategySelector::with_defaults();
1340        assert_eq!(selector.frame_count(), 0);
1341        selector.select(80, 24, 1);
1342        assert_eq!(selector.frame_count(), 1);
1343        selector.select(80, 24, 1);
1344        assert_eq!(selector.frame_count(), 2);
1345        for _ in 0..10 {
1346            selector.select(80, 24, 1);
1347        }
1348        assert_eq!(selector.frame_count(), 12);
1349    }
1350
1351    #[test]
1352    fn frame_count_not_affected_by_observe() {
1353        let mut selector = DiffStrategySelector::with_defaults();
1354        selector.observe(100, 10);
1355        assert_eq!(selector.frame_count(), 0);
1356    }
1357
1358    // --- override_last_strategy ---
1359
1360    #[test]
1361    fn override_last_strategy_changes_evidence() {
1362        let mut selector = DiffStrategySelector::with_defaults();
1363        selector.select(80, 24, 2);
1364        let original = selector.last_evidence().unwrap().strategy;
1365
1366        let override_to = if original == DiffStrategy::Full {
1367            DiffStrategy::FullRedraw
1368        } else {
1369            DiffStrategy::Full
1370        };
1371        selector.override_last_strategy(override_to, "test_override");
1372
1373        let ev = selector.last_evidence().unwrap();
1374        assert_eq!(ev.strategy, override_to);
1375        assert_eq!(ev.guard_reason, "test_override");
1376        assert!(!ev.hysteresis_applied);
1377    }
1378
1379    #[test]
1380    fn override_last_strategy_noop_when_no_evidence() {
1381        let mut selector = DiffStrategySelector::with_defaults();
1382        // No select() called yet, so no evidence.
1383        selector.override_last_strategy(DiffStrategy::Full, "noop");
1384        assert!(selector.last_evidence().is_none());
1385    }
1386
1387    // --- select_with_scan_estimate ---
1388
1389    #[test]
1390    fn select_with_scan_estimate_custom_cells() {
1391        let mut selector = DiffStrategySelector::with_defaults();
1392        // Provide a very small scan estimate to make DirtyRows cheaper.
1393        let strategy = selector.select_with_scan_estimate(80, 24, 10, 10);
1394        assert_eq!(strategy, DiffStrategy::DirtyRows);
1395    }
1396
1397    #[test]
1398    fn select_with_scan_estimate_clamped_to_total() {
1399        let mut selector = DiffStrategySelector::with_defaults();
1400        // Provide scan_cells > total cells — should be clamped.
1401        let strategy = selector.select_with_scan_estimate(80, 24, 5, 1_000_000);
1402        // Should not panic, strategy is valid.
1403        assert!(matches!(
1404            strategy,
1405            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1406        ));
1407    }
1408
1409    // --- Estimator ---
1410
1411    #[test]
1412    fn estimator_reset_restores_priors() {
1413        let mut est = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
1414        est.observe(100, 50);
1415        assert!((est.mean() - 0.2).abs() > 0.01, "Mean should have changed");
1416
1417        est.reset();
1418        let (alpha, beta) = est.posterior_params();
1419        assert!((alpha - 2.0).abs() < 1e-9);
1420        assert!((beta - 8.0).abs() < 1e-9);
1421        assert!((est.mean() - 0.2).abs() < 1e-9);
1422    }
1423
1424    #[test]
1425    fn estimator_clone() {
1426        let est1 = ChangeRateEstimator::new(1.0, 9.0, 0.95, 0);
1427        let est2 = est1.clone();
1428        assert!((est2.mean() - est1.mean()).abs() < 1e-12);
1429    }
1430
1431    #[test]
1432    fn estimator_debug() {
1433        let est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1434        let dbg = format!("{est:?}");
1435        assert!(dbg.contains("ChangeRateEstimator"));
1436    }
1437
1438    #[test]
1439    fn estimator_min_observation_cells_filters() {
1440        let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 50);
1441        let initial_mean = est.mean();
1442        // Observe fewer than min_observation_cells — should be no-op.
1443        est.observe(49, 25);
1444        assert!(
1445            (est.mean() - initial_mean).abs() < 1e-12,
1446            "Observation below min should be ignored"
1447        );
1448        // Observe exactly min_observation_cells — should update.
1449        est.observe(50, 25);
1450        assert!(
1451            (est.mean() - initial_mean).abs() > 0.01,
1452            "Observation at min should be accepted"
1453        );
1454    }
1455
1456    #[test]
1457    fn estimator_changed_exceeds_scanned_is_clamped() {
1458        let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1459        // changed > scanned should be clamped to scanned.
1460        est.observe(10, 100);
1461        let mean = est.mean();
1462        // With all cells changed, mean should be high.
1463        assert!(mean > 0.3, "Mean should be high when all cells changed");
1464    }
1465
1466    #[test]
1467    fn estimator_variance_decreases_with_data() {
1468        let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1469        let v0 = est.variance();
1470        for _ in 0..50 {
1471            est.observe(100, 5);
1472        }
1473        let v1 = est.variance();
1474        assert!(
1475            v1 < v0,
1476            "Variance should decrease with more data: before={v0:.6}, after={v1:.6}"
1477        );
1478    }
1479
1480    #[test]
1481    fn estimator_upper_quantile_at_50_pct_near_mean() {
1482        let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1483        let mean = est.mean();
1484        let q50 = est.upper_quantile(0.5);
1485        assert!(
1486            (q50 - mean).abs() < 0.05,
1487            "50th percentile should be near mean: q50={q50:.4}, mean={mean:.4}"
1488        );
1489    }
1490
1491    #[test]
1492    fn estimator_upper_quantile_monotonic() {
1493        let est = ChangeRateEstimator::new(5.0, 15.0, 1.0, 0);
1494        let q25 = est.upper_quantile(0.25);
1495        let q50 = est.upper_quantile(0.5);
1496        let q75 = est.upper_quantile(0.75);
1497        let q95 = est.upper_quantile(0.95);
1498        assert!(q25 <= q50, "q25={q25:.4} should <= q50={q50:.4}");
1499        assert!(q50 <= q75, "q50={q50:.4} should <= q75={q75:.4}");
1500        assert!(q75 <= q95, "q75={q75:.4} should <= q95={q95:.4}");
1501    }
1502
1503    // --- Normalization helpers ---
1504
1505    #[test]
1506    fn normalize_positive_rejects_zero_and_negative() {
1507        assert!((normalize_positive(0.0, 5.0) - 5.0).abs() < 1e-9);
1508        assert!((normalize_positive(-1.0, 5.0) - 5.0).abs() < 1e-9);
1509        assert!((normalize_positive(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1510        assert!((normalize_positive(3.0, 5.0) - 3.0).abs() < 1e-9);
1511    }
1512
1513    #[test]
1514    fn normalize_cost_accepts_zero() {
1515        assert!((normalize_cost(0.0, 5.0) - 0.0).abs() < 1e-9);
1516        assert!((normalize_cost(-1.0, 5.0) - 5.0).abs() < 1e-9);
1517        assert!((normalize_cost(f64::NAN, 5.0) - 5.0).abs() < 1e-9);
1518    }
1519
1520    #[test]
1521    fn normalize_decay_clamps_to_one() {
1522        assert!((normalize_decay(1.5) - 1.0).abs() < 1e-9);
1523        assert!((normalize_decay(0.5) - 0.5).abs() < 1e-9);
1524        assert!((normalize_decay(-1.0) - 1.0).abs() < 1e-9);
1525        assert!((normalize_decay(0.0) - 1.0).abs() < 1e-9);
1526        assert!((normalize_decay(f64::NAN) - 1.0).abs() < 1e-9);
1527    }
1528
1529    #[test]
1530    fn normalize_ratio_clamps_to_unit() {
1531        assert!((normalize_ratio(0.5, 0.1) - 0.5).abs() < 1e-9);
1532        assert!((normalize_ratio(-1.0, 0.1) - 0.0).abs() < 1e-9);
1533        assert!((normalize_ratio(2.0, 0.1) - 1.0).abs() < 1e-9);
1534        assert!((normalize_ratio(f64::NAN, 0.1) - 0.1).abs() < 1e-9);
1535    }
1536
1537    // --- cost_for_strategy helper ---
1538
1539    #[test]
1540    fn cost_for_strategy_returns_correct_values() {
1541        assert!((cost_for_strategy(DiffStrategy::Full, 1.0, 2.0, 3.0) - 1.0).abs() < 1e-9);
1542        assert!((cost_for_strategy(DiffStrategy::DirtyRows, 1.0, 2.0, 3.0) - 2.0).abs() < 1e-9);
1543        assert!((cost_for_strategy(DiffStrategy::FullRedraw, 1.0, 2.0, 3.0) - 3.0).abs() < 1e-9);
1544    }
1545
1546    // --- Small / edge-case buffers ---
1547
1548    #[test]
1549    fn select_1x1_buffer() {
1550        let mut selector = DiffStrategySelector::with_defaults();
1551        let strategy = selector.select(1, 1, 1);
1552        assert!(matches!(
1553            strategy,
1554            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1555        ));
1556    }
1557
1558    #[test]
1559    fn select_zero_width() {
1560        let mut selector = DiffStrategySelector::with_defaults();
1561        let strategy = selector.select(0, 24, 0);
1562        // Zero-width buffer → zero cells → DirtyRows should be cheapest.
1563        assert!(matches!(
1564            strategy,
1565            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1566        ));
1567    }
1568
1569    #[test]
1570    fn select_zero_height() {
1571        let mut selector = DiffStrategySelector::with_defaults();
1572        let strategy = selector.select(80, 0, 0);
1573        assert!(matches!(
1574            strategy,
1575            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1576        ));
1577    }
1578
1579    // --- All-dirty vs no-dirty ---
1580
1581    #[test]
1582    fn all_dirty_vs_no_dirty_different_evidence() {
1583        let mut sel1 = DiffStrategySelector::with_defaults();
1584        let mut sel2 = DiffStrategySelector::with_defaults();
1585
1586        sel1.select(80, 24, 0);
1587        sel2.select(80, 24, 24);
1588
1589        let ev1 = sel1.last_evidence().unwrap();
1590        let ev2 = sel2.last_evidence().unwrap();
1591
1592        assert_eq!(ev1.dirty_rows, 0);
1593        assert_eq!(ev2.dirty_rows, 24);
1594        // No-dirty should have zero p estimate.
1595        assert!(
1596            ev1.cost_dirty <= ev1.cost_full,
1597            "DirtyRows should be cheap with no dirty rows"
1598        );
1599    }
1600
1601    // --- Decay = 1.0 (no decay) ---
1602
1603    #[test]
1604    fn no_decay_accumulates_all_evidence() {
1605        let config = DiffStrategyConfig {
1606            decay: 1.0,
1607            ..Default::default()
1608        };
1609        let mut selector = DiffStrategySelector::new(config);
1610
1611        // Observe 100% change rate many times.
1612        for _ in 0..100 {
1613            selector.observe(100, 100);
1614        }
1615        let mean = selector.posterior_mean();
1616        // With no decay and all-changed, mean should be near 1.0.
1617        assert!(
1618            mean > 0.9,
1619            "No-decay all-changed mean should be near 1.0: {mean:.4}"
1620        );
1621    }
1622
1623    // --- Evidence costs are finite ---
1624
1625    #[test]
1626    fn evidence_costs_always_finite() {
1627        let mut selector = DiffStrategySelector::with_defaults();
1628        for dirty in [0, 1, 12, 24] {
1629            selector.select(80, 24, dirty);
1630            let ev = selector.last_evidence().unwrap();
1631            assert!(ev.cost_full.is_finite(), "cost_full should be finite");
1632            assert!(ev.cost_dirty.is_finite(), "cost_dirty should be finite");
1633            assert!(ev.cost_redraw.is_finite(), "cost_redraw should be finite");
1634        }
1635    }
1636
1637    // --- Evidence posterior matches selector ---
1638
1639    #[test]
1640    fn evidence_posterior_matches_selector() {
1641        let mut selector = DiffStrategySelector::with_defaults();
1642        selector.observe(100, 10);
1643        selector.select(80, 24, 5);
1644        let ev = selector.last_evidence().unwrap();
1645        assert!((ev.posterior_mean - selector.posterior_mean()).abs() < 1e-12);
1646        assert!((ev.posterior_variance - selector.posterior_variance()).abs() < 1e-12);
1647        let (alpha, beta) = selector.posterior_params();
1648        assert!((ev.alpha - alpha).abs() < 1e-12);
1649        assert!((ev.beta - beta).abs() < 1e-12);
1650    }
1651
1652    // --- Selector clone ---
1653
1654    #[test]
1655    fn selector_clone() {
1656        let mut selector = DiffStrategySelector::with_defaults();
1657        selector.observe(100, 10);
1658        selector.select(80, 24, 5);
1659        let clone = selector.clone();
1660        assert!((clone.posterior_mean() - selector.posterior_mean()).abs() < 1e-12);
1661        assert_eq!(clone.frame_count(), selector.frame_count());
1662    }
1663
1664    // --- Selector debug ---
1665
1666    #[test]
1667    fn selector_debug() {
1668        let selector = DiffStrategySelector::with_defaults();
1669        let dbg = format!("{selector:?}");
1670        assert!(dbg.contains("DiffStrategySelector"));
1671        assert!(dbg.contains("frame_count"));
1672    }
1673
1674    // --- Hysteresis not applied on first select ---
1675
1676    #[test]
1677    fn hysteresis_not_applied_on_first_select() {
1678        let config = DiffStrategyConfig {
1679            hysteresis_ratio: 1.0,
1680            ..Default::default()
1681        };
1682        let mut selector = DiffStrategySelector::new(config);
1683        selector.select(80, 24, 5);
1684        let ev = selector.last_evidence().unwrap();
1685        assert!(
1686            !ev.hysteresis_applied,
1687            "First select should not apply hysteresis"
1688        );
1689    }
1690
1691    // --- Conservative mode uses upper quantile ---
1692
1693    #[test]
1694    fn conservative_mode_higher_p_estimate() {
1695        let mut conservative = DiffStrategySelector::new(DiffStrategyConfig {
1696            conservative: true,
1697            ..Default::default()
1698        });
1699        let mut normal = DiffStrategySelector::with_defaults();
1700
1701        // Same observations.
1702        for _ in 0..20 {
1703            conservative.observe(100, 5);
1704            normal.observe(100, 5);
1705        }
1706
1707        conservative.select(80, 24, 12);
1708        normal.select(80, 24, 12);
1709
1710        let ev_cons = conservative.last_evidence().unwrap();
1711        let ev_norm = normal.last_evidence().unwrap();
1712
1713        // Conservative mode uses upper quantile of p, so cost_dirty (which
1714        // includes p * N * c_emit) should be >= normal mode's cost_dirty.
1715        assert!(
1716            ev_cons.cost_dirty >= ev_norm.cost_dirty - 1e-6,
1717            "Conservative costs should be >= normal costs"
1718        );
1719    }
1720
1721    // -----------------------------------------------------------------------
1722    // Edge-case tests (bd-2az7m)
1723    // -----------------------------------------------------------------------
1724
1725    mod edge_case_tests {
1726        use super::super::*;
1727        use super::strategy_costs;
1728
1729        // --- ChangeRateEstimator edge cases ---
1730
1731        #[test]
1732        fn estimator_observe_zero_scanned_with_min_one() {
1733            // Default min_observation_cells=1, so scanned=0 is filtered out
1734            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 1);
1735            let initial = est.mean();
1736            est.observe(0, 0);
1737            assert!(
1738                (est.mean() - initial).abs() < 1e-12,
1739                "Zero scanned should be filtered: mean changed"
1740            );
1741        }
1742
1743        #[test]
1744        fn estimator_observe_all_unchanged() {
1745            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1746            for _ in 0..100 {
1747                est.observe(1000, 0);
1748            }
1749            // With 100% unchanged observations, mean should be very low
1750            assert!(
1751                est.mean() < 0.01,
1752                "All-unchanged observations should drive mean near zero: {}",
1753                est.mean()
1754            );
1755        }
1756
1757        #[test]
1758        fn estimator_observe_all_changed() {
1759            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1760            for _ in 0..100 {
1761                est.observe(1000, 1000);
1762            }
1763            // With 100% changed observations, mean should be very high
1764            assert!(
1765                est.mean() > 0.99,
1766                "All-changed observations should drive mean near 1.0: {}",
1767                est.mean()
1768            );
1769        }
1770
1771        #[test]
1772        fn estimator_rapid_decay_forgets_quickly() {
1773            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.1, 0);
1774            // Observe high change rate
1775            for _ in 0..50 {
1776                est.observe(100, 90);
1777            }
1778            let high_mean = est.mean();
1779
1780            // Now observe low change rate — with decay=0.1, should adapt fast
1781            for _ in 0..10 {
1782                est.observe(100, 1);
1783            }
1784            let low_mean = est.mean();
1785
1786            assert!(
1787                low_mean < high_mean * 0.5,
1788                "Rapid decay should forget quickly: high={high_mean:.4}, low={low_mean:.4}"
1789            );
1790        }
1791
1792        #[test]
1793        fn estimator_alternating_observations() {
1794            let mut est = ChangeRateEstimator::new(1.0, 19.0, 0.95, 0);
1795            for i in 0..100 {
1796                if i % 2 == 0 {
1797                    est.observe(100, 100); // All changed
1798                } else {
1799                    est.observe(100, 0); // Nothing changed
1800                }
1801            }
1802            // Mean should settle around 0.5
1803            let mean = est.mean();
1804            assert!(
1805                mean > 0.3 && mean < 0.7,
1806                "Alternating observations should settle near 0.5: {mean:.4}"
1807            );
1808        }
1809
1810        #[test]
1811        fn estimator_upper_quantile_at_extreme_low() {
1812            let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1813            let q01 = est.upper_quantile(0.01);
1814            assert!(
1815                q01 >= 0.0,
1816                "Lower quantile should be non-negative: {q01:.4}"
1817            );
1818            assert!(
1819                q01 < est.mean(),
1820                "1st percentile should be below mean: q01={q01:.4}, mean={:.4}",
1821                est.mean()
1822            );
1823        }
1824
1825        #[test]
1826        fn estimator_upper_quantile_at_extreme_high() {
1827            let est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1828            let q99 = est.upper_quantile(0.99);
1829            assert!(q99 <= 1.0, "Upper quantile should be <= 1.0: {q99:.4}");
1830            assert!(
1831                q99 > est.mean(),
1832                "99th percentile should be above mean: q99={q99:.4}, mean={:.4}",
1833                est.mean()
1834            );
1835        }
1836
1837        #[test]
1838        fn estimator_upper_quantile_tight_posterior() {
1839            // With lots of data, posterior is tight → quantiles near mean
1840            let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
1841            for _ in 0..10000 {
1842                est.observe(100, 5);
1843            }
1844            let mean = est.mean();
1845            let q95 = est.upper_quantile(0.95);
1846            assert!(
1847                (q95 - mean).abs() < 0.01,
1848                "Tight posterior should have quantile near mean: q95={q95:.4}, mean={mean:.4}"
1849            );
1850        }
1851
1852        #[test]
1853        fn estimator_upper_quantile_clamped_output() {
1854            // Even with extreme inputs, output should be in [0, 1]
1855            let est = ChangeRateEstimator::new(1e-6, 1e-6, 1.0, 0);
1856            for q in [0.01, 0.1, 0.5, 0.9, 0.99] {
1857                let val = est.upper_quantile(q);
1858                assert!(
1859                    (0.0..=1.0).contains(&val),
1860                    "Quantile({q}) = {val} should be in [0,1]"
1861                );
1862            }
1863        }
1864
1865        #[test]
1866        fn estimator_variance_formula_correct() {
1867            let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1868            let (a, b) = est.posterior_params();
1869            let expected_var = (a * b) / ((a + b).powi(2) * (a + b + 1.0));
1870            assert!(
1871                (est.variance() - expected_var).abs() < 1e-12,
1872                "Variance formula: got {}, expected {}",
1873                est.variance(),
1874                expected_var
1875            );
1876        }
1877
1878        #[test]
1879        fn estimator_mean_formula_correct() {
1880            let est = ChangeRateEstimator::new(3.0, 7.0, 1.0, 0);
1881            let (a, b) = est.posterior_params();
1882            let expected_mean = a / (a + b);
1883            assert!(
1884                (est.mean() - expected_mean).abs() < 1e-12,
1885                "Mean formula: got {}, expected {}",
1886                est.mean(),
1887                expected_mean
1888            );
1889        }
1890
1891        // --- DiffStrategySelector edge cases ---
1892
1893        #[test]
1894        fn select_dirty_rows_exceeds_height() {
1895            let mut selector = DiffStrategySelector::with_defaults();
1896            // dirty_rows > height shouldn't panic
1897            let strategy = selector.select(80, 24, 100);
1898            assert!(matches!(
1899                strategy,
1900                DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1901            ));
1902        }
1903
1904        #[test]
1905        fn select_large_dimensions() {
1906            let mut selector = DiffStrategySelector::with_defaults();
1907            // u16::MAX width and height
1908            let strategy = selector.select(u16::MAX, u16::MAX, 1);
1909            assert!(matches!(
1910                strategy,
1911                DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
1912            ));
1913            let ev = selector.last_evidence().unwrap();
1914            assert!(ev.cost_full.is_finite());
1915            assert!(ev.cost_dirty.is_finite());
1916            assert!(ev.cost_redraw.is_finite());
1917        }
1918
1919        #[test]
1920        fn multiple_selects_without_observe() {
1921            let mut selector = DiffStrategySelector::with_defaults();
1922            let initial_mean = selector.posterior_mean();
1923
1924            for _ in 0..50 {
1925                selector.select(80, 24, 5);
1926            }
1927
1928            // Posterior should not change without observations
1929            assert!(
1930                (selector.posterior_mean() - initial_mean).abs() < 1e-12,
1931                "Mean should not change without observations"
1932            );
1933            assert_eq!(selector.frame_count(), 50);
1934        }
1935
1936        #[test]
1937        fn conservative_with_zero_dirty_rows() {
1938            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1939                conservative: true,
1940                ..Default::default()
1941            });
1942            let strategy = selector.select(80, 24, 0);
1943            assert_eq!(strategy, DiffStrategy::DirtyRows);
1944            let ev = selector.last_evidence().unwrap();
1945            assert_eq!(ev.guard_reason, "zero_dirty_rows");
1946        }
1947
1948        #[test]
1949        fn uncertainty_guard_with_fullredraw_hysteresis() {
1950            // When uncertainty guard is active and previous strategy was FullRedraw,
1951            // hysteresis should NOT prevent switching away from FullRedraw
1952            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
1953                c_scan: 10.0,
1954                c_emit: 1.0,
1955                uncertainty_guard_variance: 1e-6,
1956                hysteresis_ratio: 0.99, // Very high hysteresis
1957                ..Default::default()
1958            });
1959
1960            // First select — likely FullRedraw since c_scan is high
1961            selector.select(80, 24, 24);
1962
1963            // Second select — uncertainty guard should override hysteresis for FullRedraw
1964            let strategy = selector.select(80, 24, 24);
1965            // Under uncertainty guard, FullRedraw is avoided
1966            assert_ne!(
1967                strategy,
1968                DiffStrategy::FullRedraw,
1969                "Uncertainty guard should override hysteresis for FullRedraw"
1970            );
1971        }
1972
1973        #[test]
1974        fn select_with_scan_estimate_zero_cells() {
1975            let mut selector = DiffStrategySelector::with_defaults();
1976            // Zero scan cells makes DirtyRows very cheap
1977            let strategy = selector.select_with_scan_estimate(80, 24, 5, 0);
1978            assert_eq!(strategy, DiffStrategy::DirtyRows);
1979        }
1980
1981        #[test]
1982        fn hysteresis_prevents_switch_near_boundary() {
1983            let config = DiffStrategyConfig {
1984                hysteresis_ratio: 0.5, // 50% — very sticky
1985                uncertainty_guard_variance: 0.0,
1986                ..Default::default()
1987            };
1988            let mut selector = DiffStrategySelector::new(config);
1989
1990            // First select establishes a strategy
1991            let first = selector.select(80, 24, 5);
1992
1993            // Second select with slightly different params — hysteresis should hold
1994            let second = selector.select(80, 24, 6);
1995            assert_eq!(
1996                first, second,
1997                "High hysteresis should prevent switching on small changes"
1998            );
1999        }
2000
2001        #[test]
2002        fn reset_clears_frame_count_and_evidence() {
2003            let mut selector = DiffStrategySelector::with_defaults();
2004            selector.observe(100, 10);
2005            selector.select(80, 24, 5);
2006            selector.select(80, 24, 5);
2007
2008            assert_eq!(selector.frame_count(), 2);
2009            assert!(selector.last_evidence().is_some());
2010
2011            selector.reset();
2012
2013            assert_eq!(selector.frame_count(), 0);
2014            assert!(selector.last_evidence().is_none());
2015            assert!(
2016                (selector.posterior_mean() - 0.05).abs() < 1e-9,
2017                "Reset should restore prior mean"
2018            );
2019        }
2020
2021        #[test]
2022        fn posterior_variance_after_reset() {
2023            let mut selector = DiffStrategySelector::with_defaults();
2024            let initial_var = selector.posterior_variance();
2025
2026            selector.observe(100, 10);
2027            assert!(selector.posterior_variance() != initial_var);
2028
2029            selector.reset();
2030            assert!(
2031                (selector.posterior_variance() - initial_var).abs() < 1e-12,
2032                "Reset should restore prior variance"
2033            );
2034        }
2035
2036        // --- Normalization edge cases ---
2037
2038        #[test]
2039        fn normalize_positive_rejects_infinity() {
2040            assert!(
2041                (normalize_positive(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2042                "Infinity should be rejected"
2043            );
2044            assert!(
2045                (normalize_positive(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2046                "Negative infinity should be rejected"
2047            );
2048        }
2049
2050        #[test]
2051        fn normalize_cost_rejects_neg_infinity() {
2052            assert!(
2053                (normalize_cost(f64::NEG_INFINITY, 5.0) - 5.0).abs() < 1e-9,
2054                "Negative infinity should be rejected"
2055            );
2056        }
2057
2058        #[test]
2059        fn normalize_cost_accepts_positive_infinity() {
2060            // Positive infinity is not finite, so should be rejected
2061            assert!(
2062                (normalize_cost(f64::INFINITY, 5.0) - 5.0).abs() < 1e-9,
2063                "Positive infinity should be rejected"
2064            );
2065        }
2066
2067        #[test]
2068        fn normalize_ratio_rejects_infinity() {
2069            assert!(
2070                (normalize_ratio(f64::INFINITY, 0.1) - 0.1).abs() < 1e-9,
2071                "Infinity should use fallback"
2072            );
2073            assert!(
2074                (normalize_ratio(f64::NEG_INFINITY, 0.1) - 0.1).abs() < 1e-9,
2075                "Negative infinity should use fallback"
2076            );
2077        }
2078
2079        #[test]
2080        fn normalize_decay_rejects_neg_infinity() {
2081            assert!(
2082                (normalize_decay(f64::NEG_INFINITY) - 1.0).abs() < 1e-9,
2083                "Negative infinity should use fallback"
2084            );
2085        }
2086
2087        // --- Strategy cost ordering edge cases ---
2088
2089        #[test]
2090        fn cost_redraw_independent_of_dirty_rows() {
2091            let mut sel1 = DiffStrategySelector::with_defaults();
2092            let mut sel2 = DiffStrategySelector::with_defaults();
2093
2094            sel1.select(80, 24, 0);
2095            sel2.select(80, 24, 24);
2096
2097            let ev1 = sel1.last_evidence().unwrap();
2098            let ev2 = sel2.last_evidence().unwrap();
2099
2100            // FullRedraw cost = c_emit * N, independent of dirty rows
2101            assert!(
2102                (ev1.cost_redraw - ev2.cost_redraw).abs() < 1e-6,
2103                "FullRedraw cost should not depend on dirty rows"
2104            );
2105        }
2106
2107        #[test]
2108        fn cost_full_increases_with_dirty_rows() {
2109            let config = DiffStrategyConfig::default();
2110            // With more dirty rows, Full cost increases (more scan cost)
2111            let (cost_full_2, _, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2112            let (cost_full_20, _, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2113            assert!(
2114                cost_full_20 > cost_full_2,
2115                "More dirty rows should increase Full cost: 2={cost_full_2:.2}, 20={cost_full_20:.2}"
2116            );
2117        }
2118
2119        #[test]
2120        fn cost_dirty_increases_with_dirty_rows() {
2121            let config = DiffStrategyConfig::default();
2122            let (_, cost_dirty_2, _) = strategy_costs(&config, 80, 24, 2, 0.05);
2123            let (_, cost_dirty_20, _) = strategy_costs(&config, 80, 24, 20, 0.05);
2124            assert!(
2125                cost_dirty_20 > cost_dirty_2,
2126                "More dirty rows should increase DirtyRows cost"
2127            );
2128        }
2129
2130        // --- Strategy evidence field completeness ---
2131
2132        #[test]
2133        fn evidence_all_fields_populated() {
2134            let mut selector = DiffStrategySelector::with_defaults();
2135            selector.observe(100, 10);
2136            selector.select(200, 60, 15);
2137
2138            let ev = selector.last_evidence().unwrap();
2139            assert_eq!(ev.total_rows, 60);
2140            assert_eq!(ev.total_cells, 200 * 60);
2141            assert_eq!(ev.dirty_rows, 15);
2142            assert!(ev.cost_full >= 0.0);
2143            assert!(ev.cost_dirty >= 0.0);
2144            assert!(ev.cost_redraw >= 0.0);
2145            assert!((0.0..=1.0).contains(&ev.posterior_mean));
2146            assert!(ev.posterior_variance >= 0.0);
2147            assert!(ev.alpha > 0.0);
2148            assert!(ev.beta > 0.0);
2149            assert!(!ev.guard_reason.is_empty());
2150            assert!(ev.hysteresis_ratio >= 0.0);
2151        }
2152
2153        #[test]
2154        fn evidence_display_format() {
2155            let mut selector = DiffStrategySelector::with_defaults();
2156            selector.select(80, 24, 5);
2157            let ev = selector.last_evidence().unwrap();
2158            let display = format!("{ev}");
2159
2160            // Should contain key sections
2161            assert!(display.contains("Strategy:"));
2162            assert!(display.contains("Costs:"));
2163            assert!(display.contains("Posterior:"));
2164            assert!(display.contains("Dirty:"));
2165            assert!(display.contains("Guard:"));
2166        }
2167
2168        // --- DiffStrategy enum completeness ---
2169
2170        #[test]
2171        fn diff_strategy_all_variants_distinct() {
2172            let variants = [
2173                DiffStrategy::Full,
2174                DiffStrategy::DirtyRows,
2175                DiffStrategy::FullRedraw,
2176            ];
2177            for (i, a) in variants.iter().enumerate() {
2178                for (j, b) in variants.iter().enumerate() {
2179                    if i == j {
2180                        assert_eq!(a, b);
2181                    } else {
2182                        assert_ne!(a, b);
2183                    }
2184                }
2185            }
2186        }
2187
2188        #[test]
2189        fn diff_strategy_copy() {
2190            let a = DiffStrategy::DirtyRows;
2191            let b = a; // Copy
2192            let _c = a; // Still valid — a was copied, not moved
2193            assert_eq!(a, b);
2194        }
2195
2196        // --- Selector with custom priors ---
2197
2198        #[test]
2199        fn custom_prior_high_alpha_favors_dirty_rows_less() {
2200            // High prior alpha → expect more changes → Full/FullRedraw more likely
2201            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2202                prior_alpha: 50.0,
2203                prior_beta: 1.0, // `E[p]` ≈ 0.98
2204                ..Default::default()
2205            });
2206            selector.select(80, 24, 24);
2207            let ev = selector.last_evidence().unwrap();
2208            // With p≈0.98 and all dirty, FullRedraw should be competitive
2209            assert!(
2210                ev.cost_redraw <= ev.cost_full * 1.5,
2211                "High change rate should make redraw competitive"
2212            );
2213        }
2214
2215        #[test]
2216        fn custom_prior_high_beta_favors_dirty_rows() {
2217            // High prior beta → expect few changes → DirtyRows efficient
2218            let mut selector = DiffStrategySelector::new(DiffStrategyConfig {
2219                prior_alpha: 1.0,
2220                prior_beta: 1000.0, // `E[p]` ≈ 0.001
2221                ..Default::default()
2222            });
2223            let strategy = selector.select(80, 24, 5);
2224            assert_eq!(
2225                strategy,
2226                DiffStrategy::DirtyRows,
2227                "Very low expected change rate should favor DirtyRows"
2228            );
2229        }
2230
2231        // --- Decay boundary behavior ---
2232
2233        #[test]
2234        fn decay_zero_sanitizes_to_one() {
2235            // decay=0 is invalid (non-positive), sanitized() should set it to 1.0
2236            let config = DiffStrategyConfig {
2237                decay: 0.0,
2238                ..Default::default()
2239            };
2240            let selector = DiffStrategySelector::new(config);
2241            // With decay=1.0 (sanitized from 0.0), accumulation should work
2242            assert!(
2243                (selector.config().decay - 1.0).abs() < 1e-9,
2244                "Decay=0.0 should be sanitized to 1.0"
2245            );
2246        }
2247
2248        #[test]
2249        fn decay_one_no_forgetting() {
2250            let mut est = ChangeRateEstimator::new(1.0, 19.0, 1.0, 0);
2251            est.observe(100, 10);
2252            let (a1, b1) = est.posterior_params();
2253            // With decay=1.0: alpha = 1.0*1.0 + 10 = 11.0, beta = 1.0*19.0 + 90 = 109.0
2254            assert!(
2255                (a1 - 11.0).abs() < 1e-9,
2256                "No-decay alpha: expected 11.0, got {a1}"
2257            );
2258            assert!(
2259                (b1 - 109.0).abs() < 1e-9,
2260                "No-decay beta: expected 109.0, got {b1}"
2261            );
2262        }
2263
2264        // --- Selector determinism across multiple frames ---
2265
2266        #[test]
2267        fn determinism_across_long_trace() {
2268            let trace: Vec<(u16, u16, usize, usize, usize)> = (0..200)
2269                .map(|i| {
2270                    let dirty = (i * 3 % 24) + 1;
2271                    let scanned = 80 * dirty;
2272                    let changed = (i * 7 % scanned.max(1)).max(1);
2273                    (80u16, 24u16, dirty, scanned, changed)
2274                })
2275                .collect();
2276
2277            let mut sel1 = DiffStrategySelector::with_defaults();
2278            let mut sel2 = DiffStrategySelector::with_defaults();
2279
2280            for (w, h, dirty, scanned, changed) in &trace {
2281                let s1 = sel1.select(*w, *h, *dirty);
2282                let s2 = sel2.select(*w, *h, *dirty);
2283                assert_eq!(s1, s2, "Determinism violated");
2284
2285                sel1.observe(*scanned, *changed);
2286                sel2.observe(*scanned, *changed);
2287
2288                assert!(
2289                    (sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12,
2290                    "Posterior diverged"
2291                );
2292            }
2293        }
2294
2295        // --- Override edge cases ---
2296
2297        #[test]
2298        fn override_changes_strategy_and_clears_hysteresis() {
2299            let mut selector = DiffStrategySelector::with_defaults();
2300            selector.select(80, 24, 5);
2301
2302            let original = selector.last_evidence().unwrap().strategy;
2303            let target = if original == DiffStrategy::FullRedraw {
2304                DiffStrategy::Full
2305            } else {
2306                DiffStrategy::FullRedraw
2307            };
2308
2309            selector.override_last_strategy(target, "forced_override");
2310            let ev = selector.last_evidence().unwrap();
2311
2312            assert_eq!(ev.strategy, target, "Override should change strategy");
2313            assert_eq!(ev.guard_reason, "forced_override");
2314            assert!(!ev.hysteresis_applied, "Override should clear hysteresis");
2315        }
2316
2317        // --- Config sanitization edge cases ---
2318
2319        #[test]
2320        fn sanitize_preserves_valid_config() {
2321            let config = DiffStrategyConfig {
2322                c_scan: 2.0,
2323                c_emit: 8.0,
2324                c_row: 0.5,
2325                prior_alpha: 3.0,
2326                prior_beta: 17.0,
2327                decay: 0.9,
2328                conservative: true,
2329                conservative_quantile: 0.9,
2330                min_observation_cells: 5,
2331                hysteresis_ratio: 0.1,
2332                uncertainty_guard_variance: 0.005,
2333            };
2334            let selector = DiffStrategySelector::new(config);
2335            let c = selector.config();
2336            assert!((c.c_scan - 2.0).abs() < 1e-9);
2337            assert!((c.c_emit - 8.0).abs() < 1e-9);
2338            assert!((c.c_row - 0.5).abs() < 1e-9);
2339            assert!((c.prior_alpha - 3.0).abs() < 1e-9);
2340            assert!((c.prior_beta - 17.0).abs() < 1e-9);
2341            assert!((c.decay - 0.9).abs() < 1e-9);
2342            assert!(c.conservative);
2343            assert!((c.conservative_quantile - 0.9).abs() < 1e-9);
2344            assert_eq!(c.min_observation_cells, 5);
2345            assert!((c.hysteresis_ratio - 0.1).abs() < 1e-9);
2346        }
2347
2348        #[test]
2349        fn sanitize_all_nan_uses_defaults() {
2350            let config = DiffStrategyConfig {
2351                c_scan: f64::NAN,
2352                c_emit: f64::NAN,
2353                c_row: f64::NAN,
2354                prior_alpha: f64::NAN,
2355                prior_beta: f64::NAN,
2356                decay: f64::NAN,
2357                conservative: false,
2358                conservative_quantile: f64::NAN,
2359                min_observation_cells: 0,
2360                hysteresis_ratio: f64::NAN,
2361                uncertainty_guard_variance: f64::NAN,
2362            };
2363            let selector = DiffStrategySelector::new(config);
2364            let c = selector.config();
2365            // All should be sanitized to defaults
2366            assert!((c.c_scan - 1.0).abs() < 1e-9);
2367            assert!((c.c_emit - 6.0).abs() < 1e-9);
2368            assert!((c.c_row - 0.1).abs() < 1e-9);
2369            assert!((c.prior_alpha - 1.0).abs() < 1e-9);
2370            assert!((c.prior_beta - 19.0).abs() < 1e-9);
2371            assert!((c.decay - 1.0).abs() < 1e-9);
2372            assert!((c.hysteresis_ratio - 0.05).abs() < 1e-9);
2373            assert!((c.uncertainty_guard_variance - 0.002).abs() < 1e-9);
2374        }
2375
2376        // --- Cost model correctness ---
2377
2378        #[test]
2379        fn zero_change_rate_costs() {
2380            let config = DiffStrategyConfig::default();
2381            let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 5, 0.0);
2382            // With p=0: Full = c_row*H + c_scan*D*W, Dirty = c_scan*D*W, Redraw = c_emit*N
2383            let expected_full = config.c_row * 24.0 + config.c_scan * 5.0 * 80.0;
2384            let expected_dirty = config.c_scan * 5.0 * 80.0;
2385            let expected_redraw = config.c_emit * 80.0 * 24.0;
2386
2387            assert!((cost_full - expected_full).abs() < 1e-6);
2388            assert!((cost_dirty - expected_dirty).abs() < 1e-6);
2389            assert!((cost_redraw - expected_redraw).abs() < 1e-6);
2390        }
2391
2392        #[test]
2393        fn full_change_rate_costs() {
2394            let config = DiffStrategyConfig::default();
2395            let (cost_full, cost_dirty, cost_redraw) = strategy_costs(&config, 80, 24, 24, 1.0);
2396            // With p=1.0 and all dirty: Full and Dirty both include c_emit*N
2397            // Redraw = c_emit*N, which should be cheapest since no scan cost
2398            assert!(
2399                cost_redraw <= cost_full,
2400                "At p=1.0, redraw should be <= full"
2401            );
2402            assert!(
2403                cost_redraw <= cost_dirty,
2404                "At p=1.0, redraw should be <= dirty"
2405            );
2406        }
2407    }
2408}