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 fmt::Display for StrategyEvidence {
440    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441        writeln!(f, "Strategy: {}", self.strategy)?;
442        writeln!(
443            f,
444            "Costs: Full={:.2}, Dirty={:.2}, Redraw={:.2}",
445            self.cost_full, self.cost_dirty, self.cost_redraw
446        )?;
447        writeln!(
448            f,
449            "Posterior: p~Beta({:.2},{:.2}), E[p]={:.4}, Var[p]={:.6}",
450            self.alpha, self.beta, self.posterior_mean, self.posterior_variance
451        )?;
452        writeln!(
453            f,
454            "Dirty: {}/{} rows, {} total cells",
455            self.dirty_rows, self.total_rows, self.total_cells
456        )?;
457        writeln!(
458            f,
459            "Guard: {}, Hysteresis: {} (ratio {:.3})",
460            self.guard_reason, self.hysteresis_applied, self.hysteresis_ratio
461        )
462    }
463}
464
465// =============================================================================
466// Strategy Selector
467// =============================================================================
468
469/// Bayesian diff strategy selector.
470///
471/// Maintains a Beta posterior over the change rate and selects the
472/// strategy with minimum expected cost each frame.
473#[derive(Debug, Clone)]
474pub struct DiffStrategySelector {
475    config: DiffStrategyConfig,
476    estimator: ChangeRateEstimator,
477
478    /// Frame counter for diagnostics.
479    frame_count: u64,
480
481    /// Last decision evidence (for logging/debugging).
482    last_evidence: Option<StrategyEvidence>,
483}
484
485impl DiffStrategySelector {
486    /// Create a new selector with the given configuration.
487    pub fn new(config: DiffStrategyConfig) -> Self {
488        let config = config.sanitized();
489        let estimator = ChangeRateEstimator::new(
490            config.prior_alpha,
491            config.prior_beta,
492            config.decay,
493            config.min_observation_cells,
494        );
495        Self {
496            config,
497            estimator,
498            frame_count: 0,
499            last_evidence: None,
500        }
501    }
502
503    /// Create a selector with default configuration.
504    pub fn with_defaults() -> Self {
505        Self::new(DiffStrategyConfig::default())
506    }
507
508    /// Get the current configuration.
509    pub fn config(&self) -> &DiffStrategyConfig {
510        &self.config
511    }
512
513    /// Get the current posterior parameters.
514    pub fn posterior_params(&self) -> (f64, f64) {
515        self.estimator.posterior_params()
516    }
517
518    /// Get the posterior mean E[p].
519    pub fn posterior_mean(&self) -> f64 {
520        self.estimator.mean()
521    }
522
523    /// Get the posterior variance Var[p].
524    pub fn posterior_variance(&self) -> f64 {
525        self.estimator.variance()
526    }
527
528    /// Get the last decision evidence.
529    pub fn last_evidence(&self) -> Option<&StrategyEvidence> {
530        self.last_evidence.as_ref()
531    }
532
533    /// Get frame count.
534    pub fn frame_count(&self) -> u64 {
535        self.frame_count
536    }
537
538    /// Override the last decision's selected strategy and guard reason.
539    ///
540    /// Used when higher-level feature flags or probes force a different strategy
541    /// than the Bayesian selector chose.
542    pub fn override_last_strategy(&mut self, strategy: DiffStrategy, reason: &'static str) {
543        if let Some(evidence) = self.last_evidence.as_mut() {
544            evidence.strategy = strategy;
545            evidence.guard_reason = reason;
546            evidence.hysteresis_applied = false;
547        }
548    }
549
550    /// Select the optimal strategy for the current frame.
551    ///
552    /// # Arguments
553    ///
554    /// * `width` - Buffer width in cells
555    /// * `height` - Buffer height in rows
556    /// * `dirty_rows` - Number of rows marked dirty
557    ///
558    /// # Returns
559    ///
560    /// The optimal `DiffStrategy` and stores evidence for later inspection.
561    pub fn select(&mut self, width: u16, height: u16, dirty_rows: usize) -> DiffStrategy {
562        let scan_cells = dirty_rows.saturating_mul(width as usize);
563        self.select_with_scan_estimate(width, height, dirty_rows, scan_cells)
564    }
565
566    /// Select the optimal strategy using a scan-cell estimate for DirtyRows.
567    ///
568    /// `dirty_scan_cells` should approximate the number of cells scanned when
569    /// using DirtyRows (e.g., dirty-span coverage). If unknown, pass
570    /// `dirty_rows × width`.
571    pub fn select_with_scan_estimate(
572        &mut self,
573        width: u16,
574        height: u16,
575        dirty_rows: usize,
576        dirty_scan_cells: usize,
577    ) -> DiffStrategy {
578        self.frame_count += 1;
579
580        let w = width as f64;
581        let h = height as f64;
582        let d = dirty_rows as f64;
583        let n = w * h;
584        let scan_cells =
585            dirty_scan_cells.min((width as usize).saturating_mul(height as usize)) as f64;
586
587        // Get expected change rate
588        let uncertainty_guard = self.config.uncertainty_guard_variance > 0.0
589            && self.posterior_variance() > self.config.uncertainty_guard_variance;
590        let mut guard_reason = if dirty_rows == 0 {
591            "zero_dirty_rows"
592        } else {
593            "none"
594        };
595        let mut p = if self.config.conservative || uncertainty_guard {
596            self.upper_quantile(self.config.conservative_quantile)
597        } else {
598            self.posterior_mean()
599        };
600        if dirty_rows == 0 {
601            p = 0.0;
602        }
603
604        // Compute expected costs
605        let cost_full =
606            self.config.c_row * h + self.config.c_scan * d * w + self.config.c_emit * p * n;
607
608        let cost_dirty = self.config.c_scan * scan_cells + self.config.c_emit * p * n;
609
610        let cost_redraw = self.config.c_emit * n;
611
612        // Select argmin
613        let mut strategy = if cost_dirty <= cost_full && cost_dirty <= cost_redraw {
614            DiffStrategy::DirtyRows
615        } else if cost_full <= cost_redraw {
616            DiffStrategy::Full
617        } else {
618            DiffStrategy::FullRedraw
619        };
620
621        if uncertainty_guard {
622            if guard_reason == "none" {
623                guard_reason = "uncertainty_variance";
624            }
625            if strategy == DiffStrategy::FullRedraw {
626                strategy = if cost_dirty <= cost_full {
627                    DiffStrategy::DirtyRows
628                } else {
629                    DiffStrategy::Full
630                };
631            }
632        }
633
634        let mut hysteresis_applied = false;
635        if let Some(prev) = self.last_evidence.as_ref().map(|e| e.strategy)
636            && prev != strategy
637        {
638            let prev_cost = cost_for_strategy(prev, cost_full, cost_dirty, cost_redraw);
639            let new_cost = cost_for_strategy(strategy, cost_full, cost_dirty, cost_redraw);
640            let ratio = self.config.hysteresis_ratio;
641            if ratio > 0.0
642                && prev_cost.is_finite()
643                && prev_cost > 0.0
644                && new_cost >= prev_cost * (1.0 - ratio)
645                && !(uncertainty_guard && prev == DiffStrategy::FullRedraw)
646            {
647                strategy = prev;
648                hysteresis_applied = true;
649            }
650        }
651
652        // Store evidence
653        let (alpha, beta) = self.estimator.posterior_params();
654        self.last_evidence = Some(StrategyEvidence {
655            strategy,
656            cost_full,
657            cost_dirty,
658            cost_redraw,
659            posterior_mean: self.posterior_mean(),
660            posterior_variance: self.posterior_variance(),
661            alpha,
662            beta,
663            dirty_rows,
664            total_rows: height as usize,
665            total_cells: (width as usize) * (height as usize),
666            guard_reason,
667            hysteresis_applied,
668            hysteresis_ratio: self.config.hysteresis_ratio,
669        });
670
671        strategy
672    }
673
674    /// Update the posterior with observed change rate.
675    ///
676    /// # Arguments
677    ///
678    /// * `cells_scanned` - Number of cells that were scanned for differences
679    /// * `cells_changed` - Number of cells that actually changed
680    pub fn observe(&mut self, cells_scanned: usize, cells_changed: usize) {
681        self.estimator.observe(cells_scanned, cells_changed);
682    }
683
684    /// Reset the posterior to priors.
685    pub fn reset(&mut self) {
686        self.estimator.reset();
687        self.frame_count = 0;
688        self.last_evidence = None;
689    }
690
691    /// Compute the upper quantile of the Beta distribution.
692    ///
693    /// Uses the normal approximation for computational efficiency:
694    /// `p_q ≈ μ + z_q × σ` where z_q is the standard normal quantile.
695    fn upper_quantile(&self, q: f64) -> f64 {
696        self.estimator.upper_quantile(q)
697    }
698}
699
700#[inline]
701fn cost_for_strategy(
702    strategy: DiffStrategy,
703    cost_full: f64,
704    cost_dirty: f64,
705    cost_redraw: f64,
706) -> f64 {
707    match strategy {
708        DiffStrategy::Full => cost_full,
709        DiffStrategy::DirtyRows => cost_dirty,
710        DiffStrategy::FullRedraw => cost_redraw,
711    }
712}
713
714impl Default for DiffStrategySelector {
715    fn default() -> Self {
716        Self::with_defaults()
717    }
718}
719
720// =============================================================================
721// Tests
722// =============================================================================
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    fn strategy_costs(
729        config: &DiffStrategyConfig,
730        width: u16,
731        height: u16,
732        dirty_rows: usize,
733        p_actual: f64,
734    ) -> (f64, f64, f64) {
735        let w = width as f64;
736        let h = height as f64;
737        let d = dirty_rows as f64;
738        let n = w * h;
739        let p = p_actual.clamp(0.0, 1.0);
740
741        let cost_full = config.c_row * h + config.c_scan * d * w + config.c_emit * p * n;
742        let cost_dirty = config.c_scan * d * w + config.c_emit * p * n;
743        let cost_redraw = config.c_emit * n;
744
745        (cost_full, cost_dirty, cost_redraw)
746    }
747
748    #[test]
749    fn test_default_config() {
750        let config = DiffStrategyConfig::default();
751        assert!((config.c_scan - 1.0).abs() < 1e-9);
752        assert!((config.c_emit - 6.0).abs() < 1e-9);
753        assert!((config.prior_alpha - 1.0).abs() < 1e-9);
754        assert!((config.prior_beta - 19.0).abs() < 1e-9);
755        assert!((config.hysteresis_ratio - 0.05).abs() < 1e-9);
756        assert!((config.uncertainty_guard_variance - 0.002).abs() < 1e-9);
757        assert_eq!(config.min_observation_cells, 1);
758    }
759
760    #[test]
761    fn test_decay_paused_on_empty_observation() {
762        let mut selector = DiffStrategySelector::with_defaults();
763        let initial_mean = selector.posterior_mean();
764
765        // Observe empty frames (e.g. idle)
766        for _ in 0..100 {
767            selector.observe(0, 0);
768        }
769
770        // Mean should not change (decay shouldn't happen)
771        assert!((selector.posterior_mean() - initial_mean).abs() < 1e-9);
772    }
773
774    #[test]
775    fn estimator_initializes_from_priors() {
776        let estimator = ChangeRateEstimator::new(2.0, 8.0, 0.9, 0);
777        let (alpha, beta) = estimator.posterior_params();
778        assert!((alpha - 2.0).abs() < 1e-9);
779        assert!((beta - 8.0).abs() < 1e-9);
780        assert!((estimator.mean() - 0.2).abs() < 1e-9);
781    }
782
783    #[test]
784    fn estimator_updates_with_decay() {
785        let mut estimator = ChangeRateEstimator::new(1.0, 9.0, 0.5, 0);
786        estimator.observe(100, 10);
787        let (alpha, beta) = estimator.posterior_params();
788        assert!((alpha - (0.5 + 10.0)).abs() < 1e-9);
789        assert!((beta - (4.5 + 90.0)).abs() < 1e-9);
790    }
791
792    #[test]
793    fn estimator_clamps_bounds() {
794        let mut estimator = ChangeRateEstimator::new(1.0, 1.0, 1.0, 0);
795        for _ in 0..1000 {
796            estimator.observe(1_000_000, 1_000_000);
797        }
798        let (alpha, beta) = estimator.posterior_params();
799        assert!(alpha <= 1e6);
800        assert!(beta >= 1e-6);
801    }
802
803    #[test]
804    fn test_posterior_mean_initial() {
805        let selector = DiffStrategySelector::with_defaults();
806        // E[p] = α / (α + β) = 1 / 20 = 0.05
807        assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
808    }
809
810    #[test]
811    fn test_posterior_update() {
812        let mut selector = DiffStrategySelector::with_defaults();
813
814        // Observe 10% change rate (10 changed out of 100)
815        selector.observe(100, 10);
816
817        // After update (with decay=0.95):
818        // α = 0.95 * 1 + 10 = 10.95
819        // β = 0.95 * 19 + 90 = 108.05
820        // E[p] = 10.95 / 119.0 ≈ 0.092
821        let mean = selector.posterior_mean();
822        assert!(
823            mean > 0.05,
824            "Mean should increase after observing 10% change"
825        );
826        assert!(mean < 0.15, "Mean should not be too high");
827    }
828
829    #[test]
830    fn test_select_dirty_rows_when_few_dirty() {
831        let mut selector = DiffStrategySelector::with_defaults();
832
833        // With default config and low expected p, dirty rows should win
834        // when few rows are dirty
835        let strategy = selector.select(80, 24, 2); // Only 2 dirty rows
836        assert_eq!(strategy, DiffStrategy::DirtyRows);
837    }
838
839    #[test]
840    fn test_select_dirty_rows_when_no_dirty() {
841        let mut selector = DiffStrategySelector::with_defaults();
842
843        let strategy = selector.select(80, 24, 0);
844        assert_eq!(strategy, DiffStrategy::DirtyRows);
845
846        let evidence = selector.last_evidence().expect("evidence stored");
847        assert_eq!(evidence.guard_reason, "zero_dirty_rows");
848    }
849
850    #[test]
851    fn test_select_dirty_rows_with_single_dirty_row_large_screen() {
852        let mut selector = DiffStrategySelector::with_defaults();
853
854        // Single-row changes on large screens should still favor DirtyRows.
855        let strategy = selector.select(200, 60, 1);
856        assert_eq!(strategy, DiffStrategy::DirtyRows);
857    }
858
859    #[test]
860    fn test_select_full_redraw_when_high_change() {
861        let config = DiffStrategyConfig {
862            prior_alpha: 9.0, // High prior change rate
863            prior_beta: 1.0,  // E[p] = 0.9
864            ..Default::default()
865        };
866
867        let mut selector = DiffStrategySelector::new(config);
868        let strategy = selector.select(80, 24, 24); // All rows dirty
869
870        // With 90% expected change rate and all rows dirty,
871        // full redraw might win depending on cost ratios
872        // This test just verifies the selection doesn't panic
873        assert!(matches!(
874            strategy,
875            DiffStrategy::Full | DiffStrategy::DirtyRows | DiffStrategy::FullRedraw
876        ));
877    }
878
879    #[test]
880    fn test_evidence_stored() {
881        let mut selector = DiffStrategySelector::with_defaults();
882        selector.select(80, 24, 5);
883
884        let evidence = selector.last_evidence().expect("Evidence should be stored");
885        assert_eq!(evidence.total_rows, 24);
886        assert_eq!(evidence.total_cells, 80 * 24);
887        assert_eq!(evidence.dirty_rows, 5);
888    }
889
890    #[test]
891    fn test_posterior_clamping() {
892        let mut selector = DiffStrategySelector::with_defaults();
893
894        // Extreme observation
895        for _ in 0..1000 {
896            selector.observe(1_000_000, 1_000_000);
897        }
898
899        let (alpha, beta) = selector.posterior_params();
900        assert!(alpha <= 1e6, "Alpha should be clamped");
901        assert!(beta >= 1e-6, "Beta should be clamped");
902    }
903
904    #[test]
905    fn conservative_quantile_extremes_are_safe() {
906        let config = DiffStrategyConfig {
907            conservative: true,
908            conservative_quantile: 1.0,
909            ..Default::default()
910        };
911        let mut selector = DiffStrategySelector::new(config);
912
913        let strategy = selector.select(80, 24, 0);
914        let evidence = selector.last_evidence().expect("evidence should exist");
915
916        assert_eq!(strategy, evidence.strategy);
917        assert!(evidence.cost_full.is_finite());
918        assert!(evidence.cost_dirty.is_finite());
919        assert!(evidence.cost_redraw.is_finite());
920    }
921
922    #[test]
923    fn sanitize_config_clamps_invalid_values() {
924        let config = DiffStrategyConfig {
925            c_scan: -1.0,
926            c_emit: f64::NAN,
927            c_row: f64::INFINITY,
928            prior_alpha: 0.0,
929            prior_beta: -3.0,
930            decay: -1.0,
931            conservative: true,
932            conservative_quantile: 2.0,
933            min_observation_cells: 0,
934            hysteresis_ratio: -1.0,
935            uncertainty_guard_variance: -1.0,
936        };
937        let selector = DiffStrategySelector::new(config);
938        let sanitized = selector.config();
939
940        assert!(sanitized.c_scan >= 0.0);
941        assert!(sanitized.c_emit.is_finite());
942        assert!(sanitized.c_row.is_finite());
943        assert!(sanitized.prior_alpha > 0.0);
944        assert!(sanitized.prior_beta > 0.0);
945        assert!((0.0..=1.0).contains(&sanitized.decay));
946        assert!((0.0..=1.0).contains(&sanitized.conservative_quantile));
947        assert!((0.0..=1.0).contains(&sanitized.hysteresis_ratio));
948        assert!(sanitized.uncertainty_guard_variance >= 0.0);
949    }
950
951    #[test]
952    fn hysteresis_can_freeze_strategy_switching() {
953        let config = DiffStrategyConfig {
954            hysteresis_ratio: 1.0,
955            uncertainty_guard_variance: 0.0,
956            ..Default::default()
957        };
958        let mut selector = DiffStrategySelector::new(config);
959
960        let first = selector.select(80, 24, 1);
961        let second = selector.select(80, 24, 24);
962
963        assert_eq!(
964            first, second,
965            "With hysteresis_ratio=1.0, selector should keep prior strategy"
966        );
967    }
968
969    #[test]
970    fn uncertainty_guard_avoids_full_redraw() {
971        let config = DiffStrategyConfig {
972            c_scan: 10.0,
973            c_emit: 1.0,
974            uncertainty_guard_variance: 1e-6,
975            ..Default::default()
976        };
977        let mut selector = DiffStrategySelector::new(config);
978
979        let strategy = selector.select(80, 24, 24);
980        assert_ne!(
981            strategy,
982            DiffStrategy::FullRedraw,
983            "Uncertainty guard should avoid FullRedraw under high variance"
984        );
985    }
986
987    #[test]
988    fn selector_regret_bounded_across_regimes() {
989        let mut selector = DiffStrategySelector::with_defaults();
990        let config = selector.config().clone();
991        let width = 200u16;
992        let height = 60u16;
993        let total_cells = width as usize * height as usize;
994
995        let regimes = [
996            (100usize, 2usize, 0.02f64),
997            (100usize, 12usize, 0.12f64),
998            (100usize, height as usize, 0.6f64),
999        ];
1000
1001        let mut selector_total = 0.0f64;
1002        let mut fixed_full_total = 0.0f64;
1003        let mut fixed_dirty_total = 0.0f64;
1004        let mut fixed_redraw_total = 0.0f64;
1005
1006        for (frames, dirty_rows, p_actual) in regimes {
1007            for _ in 0..frames {
1008                let strategy = selector.select(width, height, dirty_rows);
1009                let (cost_full, cost_dirty, cost_redraw) =
1010                    strategy_costs(&config, width, height, dirty_rows, p_actual);
1011                fixed_full_total += cost_full;
1012                fixed_dirty_total += cost_dirty;
1013                fixed_redraw_total += cost_redraw;
1014
1015                let chosen_cost = match strategy {
1016                    DiffStrategy::Full => cost_full,
1017                    DiffStrategy::DirtyRows => cost_dirty,
1018                    DiffStrategy::FullRedraw => cost_redraw,
1019                };
1020                selector_total += chosen_cost;
1021
1022                let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1023                let scanned = match strategy {
1024                    DiffStrategy::Full => total_cells,
1025                    DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1026                    DiffStrategy::FullRedraw => 0,
1027                };
1028                if strategy != DiffStrategy::FullRedraw {
1029                    selector.observe(scanned, changed);
1030                }
1031            }
1032        }
1033
1034        let best_fixed = fixed_full_total
1035            .min(fixed_dirty_total)
1036            .min(fixed_redraw_total);
1037        let regret = if best_fixed > 0.0 {
1038            (selector_total - best_fixed) / best_fixed
1039        } else {
1040            0.0
1041        };
1042        let evidence = selector
1043            .last_evidence()
1044            .map(ToString::to_string)
1045            .unwrap_or_else(|| "no evidence".to_string());
1046
1047        assert!(
1048            regret <= 0.05,
1049            "Selector regret too high: {:.4} (selector {:.2}, best_fixed {:.2})\n{}",
1050            regret,
1051            selector_total,
1052            best_fixed,
1053            evidence
1054        );
1055    }
1056
1057    #[test]
1058    fn selector_switching_is_stable_under_constant_load() {
1059        let mut selector = DiffStrategySelector::with_defaults();
1060        let config = selector.config().clone();
1061        let width = 200u16;
1062        let height = 60u16;
1063        let dirty_rows = 2usize;
1064        let p_actual = 0.02f64;
1065        let total_cells = width as usize * height as usize;
1066
1067        let mut switches = 0usize;
1068        let mut last = None;
1069
1070        for _ in 0..200 {
1071            let strategy = selector.select(width, height, dirty_rows);
1072            if let Some(prev) = last
1073                && prev != strategy
1074            {
1075                switches = switches.saturating_add(1);
1076            }
1077            last = Some(strategy);
1078
1079            let changed = ((p_actual * total_cells as f64).round() as usize).min(total_cells);
1080            let scanned = match strategy {
1081                DiffStrategy::Full => total_cells,
1082                DiffStrategy::DirtyRows => dirty_rows.saturating_mul(width as usize),
1083                DiffStrategy::FullRedraw => 0,
1084            };
1085            if strategy != DiffStrategy::FullRedraw {
1086                selector.observe(scanned, changed);
1087            }
1088
1089            let _ = strategy_costs(&config, width, height, dirty_rows, p_actual);
1090        }
1091
1092        let evidence = selector
1093            .last_evidence()
1094            .map(ToString::to_string)
1095            .unwrap_or_else(|| "no evidence".to_string());
1096        assert!(
1097            switches <= 40,
1098            "Selector switched too often under stable regime: {switches}\n{evidence}"
1099        );
1100    }
1101
1102    #[test]
1103    fn test_reset() {
1104        let mut selector = DiffStrategySelector::with_defaults();
1105        selector.observe(100, 50);
1106        selector.select(80, 24, 10);
1107
1108        selector.reset();
1109
1110        assert!((selector.posterior_mean() - 0.05).abs() < 1e-9);
1111        assert_eq!(selector.frame_count(), 0);
1112        assert!(selector.last_evidence().is_none());
1113    }
1114
1115    #[test]
1116    fn test_deterministic() {
1117        let mut sel1 = DiffStrategySelector::with_defaults();
1118        let mut sel2 = DiffStrategySelector::with_defaults();
1119
1120        // Same inputs should produce same outputs
1121        sel1.observe(100, 10);
1122        sel2.observe(100, 10);
1123
1124        let s1 = sel1.select(80, 24, 5);
1125        let s2 = sel2.select(80, 24, 5);
1126
1127        assert_eq!(s1, s2);
1128        assert!((sel1.posterior_mean() - sel2.posterior_mean()).abs() < 1e-12);
1129    }
1130
1131    #[test]
1132    fn test_upper_quantile_reasonable() {
1133        let selector = DiffStrategySelector::with_defaults();
1134        let mean = selector.posterior_mean();
1135        let q95 = selector.upper_quantile(0.95);
1136
1137        assert!(q95 > mean, "95th percentile should be above mean");
1138        assert!(q95 <= 1.0, "Quantile should be bounded by 1.0");
1139    }
1140
1141    // Property test: posterior mean is always in [0, 1]
1142    #[test]
1143    fn prop_posterior_mean_bounded() {
1144        let mut selector = DiffStrategySelector::with_defaults();
1145
1146        for scanned in [1, 10, 100, 1000, 10000] {
1147            for changed in [0, 1, scanned / 10, scanned / 2, scanned] {
1148                selector.observe(scanned, changed);
1149                let mean = selector.posterior_mean();
1150                assert!((0.0..=1.0).contains(&mean), "Mean out of bounds: {mean}");
1151            }
1152        }
1153    }
1154
1155    // Property test: variance is always non-negative
1156    #[test]
1157    fn prop_variance_non_negative() {
1158        let mut selector = DiffStrategySelector::with_defaults();
1159
1160        for _ in 0..100 {
1161            selector.observe(100, 5);
1162            assert!(selector.posterior_variance() >= 0.0);
1163        }
1164    }
1165}