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