Skip to main content

rill_ml/stats/
quantile.rs

1//! Constant-memory P-square (P²) online quantile estimation.
2//!
3//! P² tracks five adaptive markers per quantile and therefore uses `O(1)`
4//! memory for one quantile and `O(k)` for `k` requested quantiles. It is a
5//! heuristic estimator: unlike Greenwald-Khanna it has no deterministic
6//! worst-case rank-error bound. It performs well on continuous stationary
7//! streams, but callers needing an auditable epsilon rank bound, exact tail
8//! quantiles, or abrupt multimodal changes should use a bounded-window exact
9//! statistic or a sketch with a formal error guarantee.
10
11use crate::error::{RillError, checked_increment, ensure_finite};
12use crate::persistence::ValidateState;
13use crate::traits::OnlineStatistic;
14
15const MARKER_COUNT: usize = 5;
16const MAX_TRACKED_QUANTILES: usize = 64;
17
18/// Constant-memory P² estimator for one interior quantile.
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct P2Quantile {
22    quantile: f64,
23    samples_seen: u64,
24    initial: Vec<f64>,
25    marker_heights: [f64; MARKER_COUNT],
26    marker_positions: [u64; MARKER_COUNT],
27    desired_positions: [f64; MARKER_COUNT],
28}
29
30impl P2Quantile {
31    /// Create an estimator for `quantile` in the open interval `(0, 1)`.
32    pub fn new(quantile: f64) -> Result<Self, RillError> {
33        ensure_finite("quantile", quantile)?;
34        if quantile <= 0.0 || quantile >= 1.0 {
35            return Err(RillError::InvalidParameter {
36                name: "quantile",
37                value: quantile,
38            });
39        }
40        Ok(Self {
41            quantile,
42            samples_seen: 0,
43            initial: Vec::with_capacity(MARKER_COUNT),
44            marker_heights: [0.0; MARKER_COUNT],
45            marker_positions: [0; MARKER_COUNT],
46            desired_positions: [0.0; MARKER_COUNT],
47        })
48    }
49
50    /// Requested quantile.
51    pub const fn quantile(&self) -> f64 {
52        self.quantile
53    }
54
55    /// Estimated quantile, or `None` before the first observation.
56    ///
57    /// The first four samples use exact linear interpolation over the retained
58    /// bootstrap values. From the fifth sample onward this returns the central
59    /// P² marker.
60    pub fn value(&self) -> Option<f64> {
61        if self.samples_seen == 0 {
62            return None;
63        }
64        if self.samples_seen < MARKER_COUNT as u64 {
65            let mut sorted = self.initial.clone();
66            sorted.sort_by(f64::total_cmp);
67            return Some(exact_linear_quantile(&sorted, self.quantile));
68        }
69        Some(self.marker_heights[2])
70    }
71
72    /// Reset to the empty state while retaining the requested quantile.
73    pub fn reset(&mut self) {
74        self.samples_seen = 0;
75        self.initial.clear();
76        self.marker_heights = [0.0; MARKER_COUNT];
77        self.marker_positions = [0; MARKER_COUNT];
78        self.desired_positions = [0.0; MARKER_COUNT];
79    }
80
81    fn desired_increments(&self) -> [f64; MARKER_COUNT] {
82        [
83            0.0,
84            self.quantile / 2.0,
85            self.quantile,
86            (1.0 + self.quantile) / 2.0,
87            1.0,
88        ]
89    }
90
91    fn update_inner(&mut self, value: f64) -> Result<(), RillError> {
92        ensure_finite("value", value)?;
93        let next_samples = checked_increment(self.samples_seen, "P2 samples_seen")?;
94        if self.samples_seen < MARKER_COUNT as u64 {
95            self.initial.push(value);
96            self.samples_seen = next_samples;
97            if self.samples_seen == MARKER_COUNT as u64 {
98                self.initial.sort_by(f64::total_cmp);
99                self.marker_heights.copy_from_slice(&self.initial);
100                self.marker_positions = [1, 2, 3, 4, 5];
101                self.desired_positions = [
102                    1.0,
103                    1.0 + 2.0 * self.quantile,
104                    1.0 + 4.0 * self.quantile,
105                    3.0 + 2.0 * self.quantile,
106                    5.0,
107                ];
108            }
109            return Ok(());
110        }
111
112        let cell = if value < self.marker_heights[0] {
113            self.marker_heights[0] = value;
114            0
115        } else if value < self.marker_heights[1] {
116            0
117        } else if value < self.marker_heights[2] {
118            1
119        } else if value < self.marker_heights[3] {
120            2
121        } else if value <= self.marker_heights[4] {
122            3
123        } else {
124            self.marker_heights[4] = value;
125            3
126        };
127
128        for position in self.marker_positions.iter_mut().skip(cell + 1) {
129            *position = position
130                .checked_add(1)
131                .ok_or_else(|| RillError::InvalidState("P2 marker position overflow".to_owned()))?;
132        }
133        let increments = self.desired_increments();
134        for (desired, increment) in self.desired_positions.iter_mut().zip(increments) {
135            *desired += increment;
136            ensure_finite("P2 desired marker position", *desired)?;
137        }
138
139        for index in 1..(MARKER_COUNT - 1) {
140            let difference = self.desired_positions[index] - self.marker_positions[index] as f64;
141            let can_move_up = difference >= 1.0
142                && self.marker_positions[index + 1] - self.marker_positions[index] > 1;
143            let can_move_down = difference <= -1.0
144                && self.marker_positions[index] - self.marker_positions[index - 1] > 1;
145            if !can_move_up && !can_move_down {
146                continue;
147            }
148            let direction = if difference > 0.0 { 1_i8 } else { -1_i8 };
149            let candidate = self.parabolic_candidate(index, direction)?;
150            let next_height = if self.marker_heights[index - 1] < candidate
151                && candidate < self.marker_heights[index + 1]
152            {
153                candidate
154            } else {
155                self.linear_candidate(index, direction)?
156            };
157            ensure_finite("P2 marker height", next_height)?;
158            self.marker_heights[index] = next_height;
159            if direction > 0 {
160                self.marker_positions[index] += 1;
161            } else {
162                self.marker_positions[index] -= 1;
163            }
164        }
165        self.samples_seen = next_samples;
166        Ok(())
167    }
168
169    fn parabolic_candidate(&self, index: usize, direction: i8) -> Result<f64, RillError> {
170        let n_prev = self.marker_positions[index - 1] as f64;
171        let n = self.marker_positions[index] as f64;
172        let n_next = self.marker_positions[index + 1] as f64;
173        let q_prev = self.marker_heights[index - 1];
174        let q = self.marker_heights[index];
175        let q_next = self.marker_heights[index + 1];
176        let d = f64::from(direction);
177        let left = (n - n_prev + d) * (q_next - q) / (n_next - n);
178        let right = (n_next - n - d) * (q - q_prev) / (n - n_prev);
179        let candidate = q + d * (left + right) / (n_next - n_prev);
180        ensure_finite("P2 parabolic marker", candidate)?;
181        Ok(candidate)
182    }
183
184    fn linear_candidate(&self, index: usize, direction: i8) -> Result<f64, RillError> {
185        let adjacent = if direction > 0 { index + 1 } else { index - 1 };
186        let numerator = self.marker_heights[adjacent] - self.marker_heights[index];
187        let denominator =
188            self.marker_positions[adjacent] as f64 - self.marker_positions[index] as f64;
189        let candidate = self.marker_heights[index] + f64::from(direction) * numerator / denominator;
190        ensure_finite("P2 linear marker", candidate)?;
191        Ok(candidate)
192    }
193}
194
195impl OnlineStatistic for P2Quantile {
196    fn update(&mut self, value: f64) -> Result<(), RillError> {
197        // Five markers make cloning constant-cost and preserve failure
198        // atomicity for arithmetic overflow or counter exhaustion.
199        let mut next = self.clone();
200        next.update_inner(value)?;
201        *self = next;
202        Ok(())
203    }
204
205    fn samples_seen(&self) -> u64 {
206        self.samples_seen
207    }
208
209    fn reset(&mut self) {
210        P2Quantile::reset(self);
211    }
212}
213
214impl ValidateState for P2Quantile {
215    fn validate_state(&self) -> Result<(), RillError> {
216        P2Quantile::new(self.quantile)?;
217        if self.samples_seen < MARKER_COUNT as u64 {
218            if self.initial.len() != self.samples_seen as usize {
219                return Err(RillError::InvalidState(
220                    "P2 bootstrap length does not match samples_seen".to_owned(),
221                ));
222            }
223            for &value in &self.initial {
224                ensure_finite("P2 bootstrap value", value)?;
225            }
226            if self.marker_heights != [0.0; MARKER_COUNT]
227                || self.marker_positions != [0; MARKER_COUNT]
228                || self.desired_positions != [0.0; MARKER_COUNT]
229            {
230                return Err(RillError::InvalidState(
231                    "P2 bootstrap state contains initialized markers".to_owned(),
232                ));
233            }
234            return Ok(());
235        }
236        if self.initial.len() != MARKER_COUNT {
237            return Err(RillError::InvalidState(
238                "P2 initialized state must retain exactly five bootstrap values".to_owned(),
239            ));
240        }
241        for &value in self.initial.iter().chain(&self.marker_heights) {
242            ensure_finite("P2 retained value", value)?;
243        }
244        for &value in &self.desired_positions {
245            ensure_finite("P2 desired position", value)?;
246        }
247        let observations_after_bootstrap = (self.samples_seen - MARKER_COUNT as u64) as f64;
248        let increments = self.desired_increments();
249        let initial_desired = [
250            1.0,
251            1.0 + 2.0 * self.quantile,
252            1.0 + 4.0 * self.quantile,
253            3.0 + 2.0 * self.quantile,
254            5.0,
255        ];
256        let desired_matches_count = self
257            .desired_positions
258            .iter()
259            .zip(initial_desired.iter().zip(increments))
260            .all(|(&actual, (&initial, increment))| {
261                let expected = initial + observations_after_bootstrap * increment;
262                (actual - expected).abs() <= 1e-10 * expected.abs().max(1.0)
263            });
264        if self.initial.windows(2).any(|pair| pair[0] > pair[1])
265            || self.marker_positions[0] != 1
266            || self.marker_positions[4] != self.samples_seen
267            || self
268                .marker_positions
269                .windows(2)
270                .any(|pair| pair[0] >= pair[1])
271            || self
272                .marker_positions
273                .iter()
274                .any(|&position| position > self.samples_seen)
275            || self.marker_heights.windows(2).any(|pair| pair[0] > pair[1])
276            || self
277                .desired_positions
278                .windows(2)
279                .any(|pair| pair[0] > pair[1])
280            || !desired_matches_count
281        {
282            return Err(RillError::InvalidState(
283                "P2 marker ordering or boundary positions are inconsistent".to_owned(),
284            ));
285        }
286        Ok(())
287    }
288}
289
290/// A bounded collection of independent P² estimators.
291#[derive(Debug, Clone, PartialEq)]
292#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
293pub struct P2Quantiles {
294    estimators: Vec<P2Quantile>,
295}
296
297impl P2Quantiles {
298    /// Create estimators for up to 64 distinct interior quantiles.
299    pub fn new(quantiles: &[f64]) -> Result<Self, RillError> {
300        if quantiles.is_empty() || quantiles.len() > MAX_TRACKED_QUANTILES {
301            return Err(RillError::InvalidCapacity(quantiles.len()));
302        }
303        let mut estimators = Vec::with_capacity(quantiles.len());
304        for (index, &quantile) in quantiles.iter().enumerate() {
305            if quantiles[..index].contains(&quantile) {
306                return Err(RillError::InvalidState(
307                    "P2Quantiles requires distinct quantiles".to_owned(),
308                ));
309            }
310            estimators.push(P2Quantile::new(quantile)?);
311        }
312        Ok(Self { estimators })
313    }
314
315    /// Update every configured estimator atomically.
316    pub fn update(&mut self, value: f64) -> Result<(), RillError> {
317        let mut next = self.clone();
318        for estimator in &mut next.estimators {
319            estimator.update_inner(value)?;
320        }
321        *self = next;
322        Ok(())
323    }
324
325    /// `(requested quantile, current estimate)` pairs in constructor order.
326    pub fn values(&self) -> Vec<(f64, Option<f64>)> {
327        self.estimators
328            .iter()
329            .map(|estimator| (estimator.quantile(), estimator.value()))
330            .collect()
331    }
332
333    /// Observations incorporated by every estimator.
334    pub fn samples_seen(&self) -> u64 {
335        self.estimators[0].samples_seen()
336    }
337
338    /// Reset every estimator.
339    pub fn reset(&mut self) {
340        for estimator in &mut self.estimators {
341            estimator.reset();
342        }
343    }
344}
345
346impl ValidateState for P2Quantiles {
347    fn validate_state(&self) -> Result<(), RillError> {
348        if self.estimators.is_empty() || self.estimators.len() > MAX_TRACKED_QUANTILES {
349            return Err(RillError::InvalidState(
350                "P2Quantiles estimator count is out of bounds".to_owned(),
351            ));
352        }
353        let samples = self.estimators[0].samples_seen();
354        for (index, estimator) in self.estimators.iter().enumerate() {
355            estimator.validate_state()?;
356            if estimator.samples_seen() != samples
357                || self.estimators[..index]
358                    .iter()
359                    .any(|previous| previous.quantile() == estimator.quantile())
360            {
361                return Err(RillError::InvalidState(
362                    "P2Quantiles estimators are inconsistent".to_owned(),
363                ));
364            }
365        }
366        Ok(())
367    }
368}
369
370fn exact_linear_quantile(sorted: &[f64], quantile: f64) -> f64 {
371    let position = (sorted.len() - 1) as f64 * quantile;
372    let lower = position.floor() as usize;
373    let upper = position.ceil() as usize;
374    if lower == upper {
375        sorted[lower]
376    } else {
377        let weight = position - lower as f64;
378        sorted[lower] + weight * (sorted[upper] - sorted[lower])
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use proptest::prelude::*;
386    use rand::{Rng, SeedableRng};
387
388    #[test]
389    fn bootstrap_values_are_exact() {
390        let mut median = P2Quantile::new(0.5).unwrap();
391        for value in [3.0, 1.0, 2.0, 4.0] {
392            median.update(value).unwrap();
393        }
394        assert_eq!(median.value(), Some(2.5));
395        assert_eq!(median.samples_seen(), 4);
396    }
397
398    #[test]
399    fn monotonic_and_constant_streams() {
400        let mut median = P2Quantile::new(0.5).unwrap();
401        for value in 1..=1000 {
402            median.update(value as f64).unwrap();
403        }
404        assert!((median.value().unwrap() - 500.0).abs() <= 2.0);
405
406        let mut constant = P2Quantile::new(0.99).unwrap();
407        for _ in 0..10_000 {
408            constant.update(7.25).unwrap();
409        }
410        assert_eq!(constant.value(), Some(7.25));
411    }
412
413    #[test]
414    fn seeded_random_stream_matches_offline_rank() {
415        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(42);
416        for quantile in [0.1, 0.5, 0.9, 0.99] {
417            let mut estimator = P2Quantile::new(quantile).unwrap();
418            let mut data = Vec::new();
419            for _ in 0..20_000 {
420                let value = rng.gen_range(-1000.0..1000.0);
421                estimator.update(value).unwrap();
422                data.push(value);
423            }
424            data.sort_by(f64::total_cmp);
425            let estimate = estimator.value().unwrap();
426            let rank = data.partition_point(|value| *value <= estimate) as f64 / data.len() as f64;
427            assert!(
428                (rank - quantile).abs() < 0.02,
429                "q={quantile}, estimated rank={rank}, value={estimate}"
430            );
431        }
432    }
433
434    #[test]
435    fn extreme_finite_values_and_non_finite_rejection_are_atomic() {
436        let mut estimator = P2Quantile::new(0.5).unwrap();
437        for value in [-1e150, -1e100, 0.0, 1e100, 1e150] {
438            estimator.update(value).unwrap();
439        }
440        assert!(estimator.value().unwrap().is_finite());
441        let before = estimator.clone();
442        assert!(estimator.update(f64::NAN).is_err());
443        assert_eq!(estimator, before);
444    }
445
446    #[test]
447    fn multiple_quantiles_are_bounded_resettable_and_validated() {
448        let mut quantiles = P2Quantiles::new(&[0.1, 0.5, 0.9]).unwrap();
449        for value in 0..1000 {
450            quantiles.update(value as f64).unwrap();
451        }
452        let values = quantiles.values();
453        assert!(values[0].1.unwrap() < values[1].1.unwrap());
454        assert!(values[1].1.unwrap() < values[2].1.unwrap());
455        quantiles.validate_state().unwrap();
456        quantiles.reset();
457        assert_eq!(quantiles.samples_seen(), 0);
458        assert!(quantiles.values().iter().all(|(_, value)| value.is_none()));
459    }
460
461    #[test]
462    fn validation_rejects_corrupt_marker_counts_and_positions() {
463        let mut estimator = P2Quantile::new(0.5).unwrap();
464        for value in 0..20 {
465            estimator.update(value as f64).unwrap();
466        }
467        let mut corrupt = estimator.clone();
468        corrupt.desired_positions[2] += 10.0;
469        assert!(corrupt.validate_state().is_err());
470        let mut corrupt = estimator.clone();
471        corrupt.marker_positions[4] -= 1;
472        assert!(corrupt.validate_state().is_err());
473        let mut corrupt = estimator;
474        corrupt.marker_heights[2] = corrupt.marker_heights[1] - 1.0;
475        assert!(corrupt.validate_state().is_err());
476    }
477
478    #[cfg(feature = "serde")]
479    #[test]
480    fn serde_roundtrip_preserves_future_continuity() {
481        let mut original = P2Quantile::new(0.75).unwrap();
482        for value in 0..100 {
483            original.update(value as f64).unwrap();
484        }
485        let json = serde_json::to_string(&original).unwrap();
486        let mut restored: P2Quantile = serde_json::from_str(&json).unwrap();
487        restored.validate_state().unwrap();
488        for value in 100..200 {
489            original.update(value as f64).unwrap();
490            restored.update(value as f64).unwrap();
491            assert_eq!(original, restored);
492        }
493    }
494
495    proptest! {
496        #[test]
497        fn property_estimate_stays_within_observed_range(
498            values in prop::collection::vec(-1e6_f64..1e6_f64, 1..500),
499            quantile in 0.01_f64..0.99_f64,
500        ) {
501            let mut estimator = P2Quantile::new(quantile).unwrap();
502            for &value in &values {
503                estimator.update(value).unwrap();
504            }
505            let estimate = estimator.value().unwrap();
506            let min = values.iter().copied().fold(f64::INFINITY, f64::min);
507            let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
508            prop_assert!(estimate >= min && estimate <= max);
509            prop_assert!(estimate.is_finite());
510            estimator.validate_state().unwrap();
511        }
512    }
513}