Skip to main content

oximedia_transcode/
running_stats.rs

1//! Online running statistics for bitrate estimation optimisation.
2//!
3//! This module provides zero-allocation, single-pass statistical accumulators
4//! that can be used instead of buffering the entire sample set.  The primary
5//! use-case is per-frame bitrate analysis inside the `bitrate_estimator`
6//! pipeline: rather than storing every frame's QP/bits and computing
7//! statistics at the end of a full pass, callers update an accumulator for
8//! each encoded frame and query the current statistics at any time.
9//!
10//! # Algorithms
11//!
12//! | Type | Algorithm | Notes |
13//! |------|-----------|-------|
14//! | [`RunningStats`] | Welford online algorithm | Numerically stable mean + variance |
15//! | [`Ewma`] | Exponential weighted moving average | Configurable α |
16//! | [`RollingWindow`] | Fixed-size circular buffer | Exact sliding-window mean / stddev |
17//! | [`PercentileEstimator`] | P² algorithm (Jain & Chlamtac 1985) | O(1) memory per quantile |
18//!
19//! # Example
20//!
21//! ```rust
22//! use oximedia_transcode::running_stats::{RunningStats, Ewma};
23//!
24//! let mut stats = RunningStats::new();
25//! for bits in [4_000u64, 5_000, 6_000, 4_500, 5_500] {
26//!     stats.push(bits as f64);
27//! }
28//! assert!((stats.mean() - 5_000.0).abs() < 1.0);
29//!
30//! let mut ewma = Ewma::new(0.1);
31//! ewma.update(100.0);
32//! ewma.update(200.0);
33//! assert!(ewma.value() > 100.0);
34//! ```
35
36#![allow(clippy::cast_precision_loss)]
37#![allow(clippy::cast_possible_truncation)]
38
39use std::collections::VecDeque;
40
41// ─── RunningStats (Welford) ───────────────────────────────────────────────────
42
43/// Numerically-stable online mean and variance using Welford's algorithm.
44///
45/// Each call to [`push`](RunningStats::push) updates the running mean and the
46/// running sum-of-squared-deviations in O(1) time with no stored samples.
47/// The result is numerically more stable than the naive two-pass formula when
48/// values span several orders of magnitude.
49///
50/// # References
51///
52/// Welford, B. P. (1962).  *Note on a method for calculating corrected sums
53/// of squares and products*.  Technometrics, 4(3), 419–420.
54#[derive(Debug, Clone, Default)]
55pub struct RunningStats {
56    /// Number of samples pushed so far.
57    count: u64,
58    /// Current running mean (μ_n).
59    mean: f64,
60    /// Running sum of squared deviations from the mean (M_n = Σ(xᵢ − μ_n)²).
61    m2: f64,
62    /// Minimum value seen.
63    min: f64,
64    /// Maximum value seen.
65    max: f64,
66}
67
68impl RunningStats {
69    /// Creates an empty accumulator.
70    #[must_use]
71    pub fn new() -> Self {
72        Self {
73            count: 0,
74            mean: 0.0,
75            m2: 0.0,
76            min: f64::INFINITY,
77            max: f64::NEG_INFINITY,
78        }
79    }
80
81    /// Pushes a new sample and updates all running statistics.
82    ///
83    /// Silently ignores `NaN` values to avoid poisoning the accumulator.
84    pub fn push(&mut self, value: f64) {
85        if value.is_nan() {
86            return;
87        }
88        self.count += 1;
89        let delta = value - self.mean;
90        self.mean += delta / self.count as f64;
91        let delta2 = value - self.mean;
92        self.m2 += delta * delta2;
93
94        if value < self.min {
95            self.min = value;
96        }
97        if value > self.max {
98            self.max = value;
99        }
100    }
101
102    /// Returns the number of samples pushed.
103    #[must_use]
104    pub fn count(&self) -> u64 {
105        self.count
106    }
107
108    /// Returns `true` if no samples have been pushed.
109    #[must_use]
110    pub fn is_empty(&self) -> bool {
111        self.count == 0
112    }
113
114    /// Returns the current running mean, or `0.0` if empty.
115    #[must_use]
116    pub fn mean(&self) -> f64 {
117        if self.count == 0 {
118            0.0
119        } else {
120            self.mean
121        }
122    }
123
124    /// Returns the sample variance (divides by n − 1), or `0.0` for < 2 samples.
125    #[must_use]
126    pub fn variance(&self) -> f64 {
127        if self.count < 2 {
128            0.0
129        } else {
130            self.m2 / (self.count - 1) as f64
131        }
132    }
133
134    /// Returns the population variance (divides by n), or `0.0` if empty.
135    #[must_use]
136    pub fn population_variance(&self) -> f64 {
137        if self.count == 0 {
138            0.0
139        } else {
140            self.m2 / self.count as f64
141        }
142    }
143
144    /// Returns the sample standard deviation, or `0.0` for < 2 samples.
145    #[must_use]
146    pub fn stddev(&self) -> f64 {
147        self.variance().sqrt()
148    }
149
150    /// Returns the population standard deviation, or `0.0` if empty.
151    #[must_use]
152    pub fn population_stddev(&self) -> f64 {
153        self.population_variance().sqrt()
154    }
155
156    /// Returns the minimum value seen, or `f64::INFINITY` if empty.
157    #[must_use]
158    pub fn min(&self) -> f64 {
159        self.min
160    }
161
162    /// Returns the maximum value seen, or `f64::NEG_INFINITY` if empty.
163    #[must_use]
164    pub fn max(&self) -> f64 {
165        self.max
166    }
167
168    /// Returns the range (max − min), or `0.0` if fewer than two samples.
169    #[must_use]
170    pub fn range(&self) -> f64 {
171        if self.count < 2 {
172            0.0
173        } else {
174            self.max - self.min
175        }
176    }
177
178    /// Returns the coefficient of variation (stddev / |mean|) as a fraction.
179    ///
180    /// Returns `f64::NAN` when the mean is zero.
181    #[must_use]
182    pub fn cv(&self) -> f64 {
183        let m = self.mean();
184        if m == 0.0 {
185            f64::NAN
186        } else {
187            self.stddev() / m.abs()
188        }
189    }
190
191    /// Merges another `RunningStats` into `self` using Chan's parallel algorithm.
192    ///
193    /// After merging, `self` contains the combined statistics as though all
194    /// samples from both accumulators had been pushed into a single one.
195    ///
196    /// # References
197    ///
198    /// Chan, T. F., Golub, G. H., & LeVeque, R. J. (1979). *Updating Formulae
199    /// and a Pairwise Algorithm for Computing Sample Variances*.
200    pub fn merge(&mut self, other: &Self) {
201        if other.count == 0 {
202            return;
203        }
204        if self.count == 0 {
205            *self = other.clone();
206            return;
207        }
208        let combined = self.count + other.count;
209        let delta = other.mean - self.mean;
210        let new_mean =
211            (self.mean * self.count as f64 + other.mean * other.count as f64) / combined as f64;
212        let new_m2 = self.m2
213            + other.m2
214            + delta * delta * (self.count as f64 * other.count as f64) / combined as f64;
215
216        self.count = combined;
217        self.mean = new_mean;
218        self.m2 = new_m2;
219        if other.min < self.min {
220            self.min = other.min;
221        }
222        if other.max > self.max {
223            self.max = other.max;
224        }
225    }
226
227    /// Resets the accumulator to its initial empty state.
228    pub fn reset(&mut self) {
229        *self = Self::new();
230    }
231}
232
233// ─── EWMA ────────────────────────────────────────────────────────────────────
234
235/// Exponentially weighted moving average (EWMA).
236///
237/// `α ∈ (0, 1]` is the smoothing factor.  Higher α gives more weight to
238/// recent observations.  The first call to [`update`](Ewma::update) sets the
239/// initial value to the first sample (no warm-up bias).
240///
241/// # Usage
242///
243/// ```rust
244/// use oximedia_transcode::running_stats::Ewma;
245///
246/// let mut ewma = Ewma::new(0.2);
247/// for v in [100.0_f64, 110.0, 90.0, 105.0] {
248///     ewma.update(v);
249/// }
250/// // value is close to the true mean but smoothed
251/// assert!((ewma.value() - 100.0).abs() < 20.0);
252/// ```
253#[derive(Debug, Clone)]
254pub struct Ewma {
255    alpha: f64,
256    value: Option<f64>,
257    count: u64,
258    /// Running variance using EWMA-based Welford variant.
259    variance: f64,
260}
261
262impl Ewma {
263    /// Creates an EWMA with smoothing factor `alpha`.
264    ///
265    /// # Panics
266    ///
267    /// Panics in debug builds when `alpha` is outside `(0.0, 1.0]`.
268    #[must_use]
269    pub fn new(alpha: f64) -> Self {
270        debug_assert!(alpha > 0.0 && alpha <= 1.0, "alpha must be in (0, 1]");
271        let alpha = alpha.clamp(1e-9, 1.0);
272        Self {
273            alpha,
274            value: None,
275            count: 0,
276            variance: 0.0,
277        }
278    }
279
280    /// Updates the EWMA with a new observation.
281    ///
282    /// Silently ignores `NaN` values.
283    pub fn update(&mut self, sample: f64) {
284        if sample.is_nan() {
285            return;
286        }
287        self.count += 1;
288        match self.value {
289            None => {
290                self.value = Some(sample);
291                self.variance = 0.0;
292            }
293            Some(prev) => {
294                let new_val = self.alpha * sample + (1.0 - self.alpha) * prev;
295                let diff = sample - prev;
296                self.variance = (1.0 - self.alpha) * (self.variance + self.alpha * diff * diff);
297                self.value = Some(new_val);
298            }
299        }
300    }
301
302    /// Returns the current EWMA value, or `None` before any update.
303    #[must_use]
304    pub fn value_opt(&self) -> Option<f64> {
305        self.value
306    }
307
308    /// Returns the current EWMA value, or `0.0` if not yet initialised.
309    #[must_use]
310    pub fn value(&self) -> f64 {
311        self.value.unwrap_or(0.0)
312    }
313
314    /// Returns the EWMA-based variance estimate.
315    #[must_use]
316    pub fn variance(&self) -> f64 {
317        self.variance
318    }
319
320    /// Returns the EWMA-based standard-deviation estimate.
321    #[must_use]
322    pub fn stddev(&self) -> f64 {
323        self.variance.sqrt()
324    }
325
326    /// Returns the number of observations processed.
327    #[must_use]
328    pub fn count(&self) -> u64 {
329        self.count
330    }
331
332    /// Returns the smoothing factor α.
333    #[must_use]
334    pub fn alpha(&self) -> f64 {
335        self.alpha
336    }
337
338    /// Resets the EWMA to its initial state (no observations).
339    pub fn reset(&mut self) {
340        self.value = None;
341        self.count = 0;
342        self.variance = 0.0;
343    }
344}
345
346// ─── RollingWindow ───────────────────────────────────────────────────────────
347
348/// Exact sliding-window mean and variance over the last `capacity` samples.
349///
350/// Uses a circular buffer (VecDeque) plus Welford incremental updates on
351/// the window boundary (remove oldest, add newest) so each push is O(1)
352/// in both time and space.
353#[derive(Debug, Clone)]
354pub struct RollingWindow {
355    capacity: usize,
356    buffer: VecDeque<f64>,
357    /// Welford running stats over the current window (not the whole history).
358    sum: f64,
359    sum_sq: f64,
360}
361
362impl RollingWindow {
363    /// Creates a rolling window with the given `capacity` (must be ≥ 1).
364    ///
365    /// # Panics
366    ///
367    /// Panics when `capacity` is zero.
368    #[must_use]
369    pub fn new(capacity: usize) -> Self {
370        assert!(capacity >= 1, "RollingWindow capacity must be ≥ 1");
371        Self {
372            capacity,
373            buffer: VecDeque::with_capacity(capacity),
374            sum: 0.0,
375            sum_sq: 0.0,
376        }
377    }
378
379    /// Pushes a new sample into the window, evicting the oldest if full.
380    pub fn push(&mut self, value: f64) {
381        if self.buffer.len() == self.capacity {
382            // Evict oldest.
383            if let Some(old) = self.buffer.pop_front() {
384                self.sum -= old;
385                self.sum_sq -= old * old;
386            }
387        }
388        self.buffer.push_back(value);
389        self.sum += value;
390        self.sum_sq += value * value;
391    }
392
393    /// Returns the current number of samples in the window (≤ capacity).
394    #[must_use]
395    pub fn len(&self) -> usize {
396        self.buffer.len()
397    }
398
399    /// Returns `true` if the window contains no samples.
400    #[must_use]
401    pub fn is_empty(&self) -> bool {
402        self.buffer.is_empty()
403    }
404
405    /// Returns `true` when the window has been filled to capacity.
406    #[must_use]
407    pub fn is_full(&self) -> bool {
408        self.buffer.len() == self.capacity
409    }
410
411    /// Returns the window capacity.
412    #[must_use]
413    pub fn capacity(&self) -> usize {
414        self.capacity
415    }
416
417    /// Returns the mean of the current window, or `0.0` if empty.
418    #[must_use]
419    pub fn mean(&self) -> f64 {
420        if self.buffer.is_empty() {
421            0.0
422        } else {
423            self.sum / self.buffer.len() as f64
424        }
425    }
426
427    /// Returns the population variance of the current window.
428    ///
429    /// Uses the computational formula: `σ² = E[X²] − (E[X])²`.
430    #[must_use]
431    pub fn variance(&self) -> f64 {
432        let n = self.buffer.len() as f64;
433        if n < 1.0 {
434            return 0.0;
435        }
436        let mean = self.sum / n;
437        let mean_sq = self.sum_sq / n;
438        // Clamp to avoid negative variance from floating-point cancellation.
439        (mean_sq - mean * mean).max(0.0)
440    }
441
442    /// Returns the population standard deviation of the current window.
443    #[must_use]
444    pub fn stddev(&self) -> f64 {
445        self.variance().sqrt()
446    }
447
448    /// Returns the minimum value in the current window, or `f64::INFINITY` if empty.
449    #[must_use]
450    pub fn min(&self) -> f64 {
451        self.buffer.iter().copied().fold(f64::INFINITY, f64::min)
452    }
453
454    /// Returns the maximum value in the current window, or `f64::NEG_INFINITY` if empty.
455    #[must_use]
456    pub fn max(&self) -> f64 {
457        self.buffer
458            .iter()
459            .copied()
460            .fold(f64::NEG_INFINITY, f64::max)
461    }
462
463    /// Returns a snapshot of all current window samples, oldest first.
464    #[must_use]
465    pub fn samples(&self) -> Vec<f64> {
466        self.buffer.iter().copied().collect()
467    }
468}
469
470// ─── PercentileEstimator (P² algorithm) ──────────────────────────────────────
471
472/// P² algorithm for online quantile estimation with O(1) memory per quantile.
473///
474/// Estimates a specific percentile `p` (0 < p < 1) without storing
475/// individual samples.  After at least 5 observations the estimate is
476/// maintained via the P² marker update rule.
477///
478/// Accuracy is typically within a few percent for stationary distributions
479/// with thousands of samples.
480///
481/// # References
482///
483/// Jain, R., & Chlamtac, I. (1985). *The P² algorithm for dynamic calculation
484/// of quantiles and histograms without storing observations.*
485/// Communications of the ACM, 28(10), 1076–1085.
486#[derive(Debug, Clone)]
487pub struct PercentileEstimator {
488    /// Target quantile in (0, 1).
489    p: f64,
490    /// Five marker heights q[0..5].
491    q: [f64; 5],
492    /// Five desired marker positions (non-integer).
493    dn: [f64; 5],
494    /// Five actual integer marker positions n[0..5].
495    n: [i64; 5],
496    /// Total number of observations.
497    count: u64,
498    /// Bootstrap buffer (first 5 samples before algorithm begins).
499    bootstrap: Vec<f64>,
500}
501
502impl PercentileEstimator {
503    /// Creates an estimator for the given quantile `p` (e.g. 0.95 for p95).
504    ///
505    /// # Panics
506    ///
507    /// Panics in debug builds if `p` is not in (0, 1).
508    #[must_use]
509    pub fn new(p: f64) -> Self {
510        debug_assert!(p > 0.0 && p < 1.0, "p must be in (0, 1)");
511        let p = p.clamp(1e-9, 1.0 - 1e-9);
512        Self {
513            p,
514            q: [0.0; 5],
515            dn: [0.0, p / 2.0, p, (1.0 + p) / 2.0, 1.0],
516            n: [1, 2, 3, 4, 5],
517            count: 0,
518            bootstrap: Vec::with_capacity(5),
519        }
520    }
521
522    /// Updates the estimator with a new observation.
523    pub fn update(&mut self, x: f64) {
524        if x.is_nan() {
525            return;
526        }
527        self.count += 1;
528
529        // Bootstrap phase: collect first 5 samples.
530        if self.bootstrap.len() < 5 {
531            self.bootstrap.push(x);
532            if self.bootstrap.len() == 5 {
533                // Sort and initialise markers.
534                self.bootstrap
535                    .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
536                for i in 0..5 {
537                    self.q[i] = self.bootstrap[i];
538                }
539                self.n = [1, 2, 3, 4, 5];
540            }
541            return;
542        }
543
544        // P² update phase.
545        // 1. Find cell k where x falls.
546        let k = if x < self.q[0] {
547            self.q[0] = x;
548            0
549        } else if x < self.q[1] {
550            0
551        } else if x < self.q[2] {
552            1
553        } else if x < self.q[3] {
554            2
555        } else if x < self.q[4] {
556            3
557        } else {
558            self.q[4] = x;
559            3
560        };
561
562        // 2. Increment positions n[k+1..5].
563        for i in (k + 1)..5 {
564            self.n[i] += 1;
565        }
566
567        // 3. Update desired positions.
568        let obs_count = (self.count - 5) as f64 + 1.0; // 1-indexed after bootstrap
569        self.dn = [
570            0.0,
571            self.p / 2.0 * obs_count,
572            self.p * obs_count,
573            (1.0 + self.p) / 2.0 * obs_count,
574            obs_count,
575        ];
576
577        // 4. Adjust marker heights.
578        for i in 1..=3 {
579            let d = self.dn[i] - self.n[i] as f64;
580            if (d >= 1.0 && (self.n[i + 1] - self.n[i]) > 1)
581                || (d <= -1.0 && (self.n[i - 1] - self.n[i]) < -1)
582            {
583                let sign = if d > 0.0 { 1 } else { -1 };
584                let q_new = self.parabolic(i, sign as f64);
585                if q_new > self.q[i - 1] && q_new < self.q[i + 1] {
586                    self.q[i] = q_new;
587                } else {
588                    self.q[i] = self.linear(i, sign as f64);
589                }
590                self.n[i] += sign;
591            }
592        }
593    }
594
595    /// Parabolic interpolation (P² formula).
596    fn parabolic(&self, i: usize, d: f64) -> f64 {
597        let qi = self.q[i];
598        let qm = self.q[i - 1];
599        let qp = self.q[i + 1];
600        let ni = self.n[i] as f64;
601        let nm = self.n[i - 1] as f64;
602        let np = self.n[i + 1] as f64;
603        qi + d / (np - nm)
604            * ((ni - nm + d) * (qp - qi) / (np - ni) + (np - ni - d) * (qi - qm) / (ni - nm))
605    }
606
607    /// Linear interpolation fallback.
608    fn linear(&self, i: usize, d: f64) -> f64 {
609        let qi = self.q[i];
610        let idx = if d > 0.0 { i + 1 } else { i - 1 };
611        let qother = self.q[idx];
612        let ni = self.n[i] as f64;
613        let nother = self.n[idx] as f64;
614        qi + d * (qother - qi) / (nother - ni)
615    }
616
617    /// Returns the current percentile estimate, or `None` before 5 observations.
618    #[must_use]
619    pub fn estimate(&self) -> Option<f64> {
620        if self.count < 5 {
621            None
622        } else {
623            Some(self.q[2])
624        }
625    }
626
627    /// Returns the number of observations processed.
628    #[must_use]
629    pub fn count(&self) -> u64 {
630        self.count
631    }
632
633    /// Returns the target quantile.
634    #[must_use]
635    pub fn quantile(&self) -> f64 {
636        self.p
637    }
638}
639
640// ─── BitrateRunningAnalyzer ───────────────────────────────────────────────────
641
642/// A composite analyzer that tracks per-frame bitrate statistics across a
643/// single encode pass using all available online estimators.
644///
645/// This is the primary integration point between `running_stats` and the
646/// `bitrate_estimator` module.  Feed it the bit count for each encoded frame;
647/// query aggregate statistics at any time without buffering frame data.
648///
649/// # Example
650///
651/// ```rust
652/// use oximedia_transcode::running_stats::BitrateRunningAnalyzer;
653///
654/// let mut analyzer = BitrateRunningAnalyzer::new(30.0, 60);
655/// for bits in [40_000u64, 50_000, 60_000, 45_000, 55_000] {
656///     analyzer.push_frame(bits);
657/// }
658/// let summary = analyzer.summary();
659/// assert!(summary.mean_bps > 0.0);
660/// assert!(summary.peak_bps >= summary.mean_bps);
661/// ```
662#[derive(Debug)]
663pub struct BitrateRunningAnalyzer {
664    /// Frame rate (fps) used to convert per-frame bits to bps.
665    fps: f64,
666    /// Welford stats over all frames (bits per frame).
667    global: RunningStats,
668    /// EWMA for bitrate trend.
669    trend: Ewma,
670    /// Rolling window for recent-peak detection.
671    window: RollingWindow,
672    /// P95 estimator over per-frame bits.
673    p95: PercentileEstimator,
674    /// Total bits accumulated.
675    total_bits: u64,
676    /// Total frames pushed.
677    frame_count: u64,
678}
679
680impl BitrateRunningAnalyzer {
681    /// Creates a new analyzer.
682    ///
683    /// * `fps` – The frame rate of the encoded stream.
684    /// * `window_frames` – Size of the rolling window for recent-peak analysis.
685    #[must_use]
686    pub fn new(fps: f64, window_frames: usize) -> Self {
687        let window_frames = window_frames.max(1);
688        Self {
689            fps,
690            global: RunningStats::new(),
691            trend: Ewma::new(0.1),
692            window: RollingWindow::new(window_frames),
693            p95: PercentileEstimator::new(0.95),
694            total_bits: 0,
695            frame_count: 0,
696        }
697    }
698
699    /// Feeds the bit count for one encoded frame into all estimators.
700    pub fn push_frame(&mut self, bits_per_frame: u64) {
701        let bits_f = bits_per_frame as f64;
702        self.global.push(bits_f);
703        self.trend.update(bits_f);
704        self.window.push(bits_f);
705        self.p95.update(bits_f);
706        self.total_bits += bits_per_frame;
707        self.frame_count += 1;
708    }
709
710    /// Returns a point-in-time [`BitrateSummary`] from all running estimators.
711    #[must_use]
712    pub fn summary(&self) -> BitrateSummary {
713        let scale = self.fps; // bits/frame → bits/s
714        BitrateSummary {
715            frame_count: self.frame_count,
716            total_bits: self.total_bits,
717            mean_bps: self.global.mean() * scale,
718            stddev_bps: self.global.stddev() * scale,
719            peak_bps: self.global.max() * scale,
720            min_bps: if self.global.is_empty() {
721                0.0
722            } else {
723                self.global.min() * scale
724            },
725            trend_bps: self.trend.value() * scale,
726            window_mean_bps: self.window.mean() * scale,
727            window_stddev_bps: self.window.stddev() * scale,
728            p95_bps: self.p95.estimate().map(|v| v * scale),
729            cv: self.global.cv(),
730        }
731    }
732
733    /// Resets all accumulators.
734    pub fn reset(&mut self) {
735        self.global.reset();
736        self.trend.reset();
737        self.window = RollingWindow::new(self.window.capacity());
738        self.p95 = PercentileEstimator::new(0.95);
739        self.total_bits = 0;
740        self.frame_count = 0;
741    }
742}
743
744/// Point-in-time bitrate statistics produced by [`BitrateRunningAnalyzer`].
745#[derive(Debug, Clone)]
746pub struct BitrateSummary {
747    /// Number of frames analysed so far.
748    pub frame_count: u64,
749    /// Total bits counted across all frames.
750    pub total_bits: u64,
751    /// Mean bits-per-second (over all frames).
752    pub mean_bps: f64,
753    /// Standard deviation of bits-per-second.
754    pub stddev_bps: f64,
755    /// Maximum per-frame bitrate seen.
756    pub peak_bps: f64,
757    /// Minimum per-frame bitrate seen.
758    pub min_bps: f64,
759    /// EWMA-smoothed bitrate trend (recent emphasis).
760    pub trend_bps: f64,
761    /// Mean bitrate in the recent rolling window.
762    pub window_mean_bps: f64,
763    /// Stddev of bitrate in the recent rolling window.
764    pub window_stddev_bps: f64,
765    /// P95 per-frame bitrate estimate (None before 5 frames).
766    pub p95_bps: Option<f64>,
767    /// Coefficient of variation (stddev / mean); low = stable bitrate.
768    pub cv: f64,
769}
770
771// ─── Tests ────────────────────────────────────────────────────────────────────
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    // ── RunningStats ─────────────────────────────────────────────────────────
778
779    #[test]
780    fn test_running_stats_empty() {
781        let stats = RunningStats::new();
782        assert!(stats.is_empty());
783        assert_eq!(stats.count(), 0);
784        assert_eq!(stats.mean(), 0.0);
785        assert_eq!(stats.variance(), 0.0);
786        assert_eq!(stats.stddev(), 0.0);
787    }
788
789    #[test]
790    fn test_running_stats_single_sample() {
791        let mut stats = RunningStats::new();
792        stats.push(42.0);
793        assert_eq!(stats.count(), 1);
794        assert!((stats.mean() - 42.0).abs() < 1e-10);
795        // Sample variance undefined with 1 sample.
796        assert_eq!(stats.variance(), 0.0);
797        assert_eq!(stats.min(), 42.0);
798        assert_eq!(stats.max(), 42.0);
799    }
800
801    #[test]
802    fn test_running_stats_known_values() {
803        let mut stats = RunningStats::new();
804        for v in [2.0_f64, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
805            stats.push(v);
806        }
807        // Mean = 5.0, population std = 2.0
808        assert!((stats.mean() - 5.0).abs() < 1e-10, "mean {}", stats.mean());
809        assert!((stats.population_stddev() - 2.0).abs() < 1e-10);
810        assert_eq!(stats.min(), 2.0);
811        assert_eq!(stats.max(), 9.0);
812        assert_eq!(stats.range(), 7.0);
813    }
814
815    #[test]
816    fn test_running_stats_nan_ignored() {
817        let mut stats = RunningStats::new();
818        stats.push(10.0);
819        stats.push(f64::NAN);
820        stats.push(20.0);
821        assert_eq!(stats.count(), 2);
822        assert!((stats.mean() - 15.0).abs() < 1e-10);
823    }
824
825    #[test]
826    fn test_running_stats_merge() {
827        let mut a = RunningStats::new();
828        let mut b = RunningStats::new();
829        for v in [1.0_f64, 2.0, 3.0] {
830            a.push(v);
831        }
832        for v in [4.0_f64, 5.0, 6.0] {
833            b.push(v);
834        }
835        a.merge(&b);
836        assert_eq!(a.count(), 6);
837        assert!((a.mean() - 3.5).abs() < 1e-10, "merged mean {}", a.mean());
838        assert_eq!(a.min(), 1.0);
839        assert_eq!(a.max(), 6.0);
840    }
841
842    #[test]
843    fn test_running_stats_merge_empty_rhs() {
844        let mut a = RunningStats::new();
845        a.push(5.0);
846        let empty = RunningStats::new();
847        a.merge(&empty);
848        assert_eq!(a.count(), 1);
849        assert!((a.mean() - 5.0).abs() < 1e-10);
850    }
851
852    #[test]
853    fn test_running_stats_reset() {
854        let mut stats = RunningStats::new();
855        stats.push(100.0);
856        stats.reset();
857        assert!(stats.is_empty());
858        assert_eq!(stats.mean(), 0.0);
859    }
860
861    #[test]
862    fn test_running_stats_cv() {
863        let mut stats = RunningStats::new();
864        // All identical → stddev = 0 → cv = 0.
865        for _ in 0..5 {
866            stats.push(10.0);
867        }
868        let cv = stats.cv();
869        assert!(cv.is_finite() && cv < 1e-10, "cv {cv}");
870    }
871
872    // ── EWMA ─────────────────────────────────────────────────────────────────
873
874    #[test]
875    fn test_ewma_initial_value() {
876        let mut ewma = Ewma::new(0.5);
877        assert!(ewma.value_opt().is_none());
878        ewma.update(100.0);
879        assert!((ewma.value() - 100.0).abs() < 1e-10);
880    }
881
882    #[test]
883    fn test_ewma_convergence() {
884        let mut ewma = Ewma::new(0.3);
885        for _ in 0..200 {
886            ewma.update(50.0);
887        }
888        // Should converge to 50.0 after enough updates.
889        assert!((ewma.value() - 50.0).abs() < 0.1, "value {}", ewma.value());
890    }
891
892    #[test]
893    fn test_ewma_nan_ignored() {
894        let mut ewma = Ewma::new(0.5);
895        ewma.update(10.0);
896        let before = ewma.value();
897        ewma.update(f64::NAN);
898        assert!((ewma.value() - before).abs() < 1e-12);
899        assert_eq!(ewma.count(), 1);
900    }
901
902    #[test]
903    fn test_ewma_reset() {
904        let mut ewma = Ewma::new(0.2);
905        ewma.update(42.0);
906        ewma.reset();
907        assert!(ewma.value_opt().is_none());
908        assert_eq!(ewma.count(), 0);
909    }
910
911    // ── RollingWindow ─────────────────────────────────────────────────────────
912
913    #[test]
914    fn test_rolling_window_basic() {
915        let mut w = RollingWindow::new(3);
916        assert!(w.is_empty());
917        w.push(1.0);
918        w.push(2.0);
919        w.push(3.0);
920        assert!(w.is_full());
921        assert!((w.mean() - 2.0).abs() < 1e-10);
922    }
923
924    #[test]
925    fn test_rolling_window_eviction() {
926        let mut w = RollingWindow::new(3);
927        w.push(10.0);
928        w.push(20.0);
929        w.push(30.0);
930        // Evict 10.0, add 40.0 → window = [20, 30, 40]
931        w.push(40.0);
932        assert_eq!(w.len(), 3);
933        assert!((w.mean() - 30.0).abs() < 1e-10, "mean {}", w.mean());
934    }
935
936    #[test]
937    fn test_rolling_window_variance() {
938        let mut w = RollingWindow::new(4);
939        for v in [2.0_f64, 4.0, 4.0, 4.0] {
940            w.push(v);
941        }
942        // population var = 0.75
943        assert!((w.variance() - 0.75).abs() < 1e-10, "var {}", w.variance());
944    }
945
946    #[test]
947    fn test_rolling_window_min_max() {
948        let mut w = RollingWindow::new(5);
949        for v in [5.0_f64, 3.0, 8.0, 1.0, 6.0] {
950            w.push(v);
951        }
952        assert_eq!(w.min(), 1.0);
953        assert_eq!(w.max(), 8.0);
954    }
955
956    // ── PercentileEstimator ───────────────────────────────────────────────────
957
958    #[test]
959    fn test_percentile_estimator_bootstrap() {
960        let mut est = PercentileEstimator::new(0.5);
961        for v in 1..=4 {
962            est.update(v as f64);
963            // Fewer than 5 samples → no estimate yet.
964            assert!(est.estimate().is_none());
965        }
966        est.update(5.0);
967        assert!(est.estimate().is_some());
968    }
969
970    #[test]
971    fn test_percentile_estimator_median_uniform() {
972        let mut est = PercentileEstimator::new(0.5);
973        for v in 1..=1000 {
974            est.update(v as f64);
975        }
976        // Median of 1..=1000 is ~500.5.
977        let estimated = est.estimate().expect("should have estimate");
978        assert!(
979            (estimated - 500.5).abs() < 30.0,
980            "median estimate {estimated}"
981        );
982    }
983
984    #[test]
985    fn test_percentile_estimator_p95() {
986        let mut est = PercentileEstimator::new(0.95);
987        // Insert 0..=99; p95 ≈ 94.05 (0-indexed).
988        for v in 0..=99 {
989            est.update(v as f64);
990        }
991        let estimated = est.estimate().expect("should have estimate");
992        // Allow ±10 tolerance for small samples.
993        assert!((estimated - 94.05).abs() < 15.0, "p95 estimate {estimated}");
994    }
995
996    // ── BitrateRunningAnalyzer ────────────────────────────────────────────────
997
998    #[test]
999    fn test_bitrate_analyzer_basic() {
1000        let mut analyzer = BitrateRunningAnalyzer::new(30.0, 30);
1001        for bits in [40_000u64, 50_000, 60_000, 45_000, 55_000] {
1002            analyzer.push_frame(bits);
1003        }
1004        let s = analyzer.summary();
1005        assert_eq!(s.frame_count, 5);
1006        assert!(s.mean_bps > 0.0);
1007        assert!(s.peak_bps >= s.mean_bps);
1008        assert!(s.min_bps <= s.mean_bps);
1009        assert_eq!(s.total_bits, 250_000);
1010    }
1011
1012    #[test]
1013    fn test_bitrate_analyzer_reset() {
1014        let mut analyzer = BitrateRunningAnalyzer::new(25.0, 10);
1015        analyzer.push_frame(100_000);
1016        analyzer.reset();
1017        let s = analyzer.summary();
1018        assert_eq!(s.frame_count, 0);
1019        assert_eq!(s.total_bits, 0);
1020    }
1021
1022    #[test]
1023    fn test_bitrate_analyzer_trend_smoother_than_peak() {
1024        let mut analyzer = BitrateRunningAnalyzer::new(30.0, 10);
1025        // Alternate between 10 000 and 200 000 bits/frame.
1026        for i in 0..100 {
1027            analyzer.push_frame(if i % 2 == 0 { 10_000 } else { 200_000 });
1028        }
1029        let s = analyzer.summary();
1030        // Trend (EWMA α=0.1) should be less volatile than peak.
1031        assert!(s.peak_bps > s.trend_bps);
1032    }
1033}