Skip to main content

sim_lib_numbers_stats/
quantile.rs

1//! Bounded, mergeable streaming quantiles with an exact small-data path.
2
3use std::{error::Error, fmt, mem};
4
5/// Error and memory policy for a [`QuantileSketch`].
6#[derive(Clone, Debug, PartialEq)]
7pub struct QuantilePolicy {
8    /// Maximum target rank error, as a fraction of the observation count.
9    pub rank_error: f64,
10    /// Number of observations retained exactly before summarization begins.
11    pub exact_threshold: usize,
12    /// Hard maximum number of retained summary entries.
13    /// Insertion fails instead of silently exceeding this limit or weakening
14    /// `rank_error`.
15    pub max_summary_entries: usize,
16}
17
18impl QuantilePolicy {
19    /// Builds a checked policy.
20    pub fn new(
21        rank_error: f64,
22        exact_threshold: usize,
23        max_summary_entries: usize,
24    ) -> Result<Self, QuantileError> {
25        let policy = Self {
26            rank_error,
27            exact_threshold,
28            max_summary_entries,
29        };
30        policy.validate()?;
31        Ok(policy)
32    }
33
34    fn validate(&self) -> Result<(), QuantileError> {
35        if !self.rank_error.is_finite() || !(0.0..0.5).contains(&self.rank_error) {
36            return Err(QuantileError::InvalidPolicy {
37                field: "rank_error",
38                reason: "must be finite and in the half-open interval [0, 0.5)",
39            });
40        }
41        if self.exact_threshold == 0 {
42            return Err(QuantileError::InvalidPolicy {
43                field: "exact_threshold",
44                reason: "must be greater than zero",
45            });
46        }
47        if self.max_summary_entries < self.exact_threshold {
48            return Err(QuantileError::InvalidPolicy {
49                field: "max_summary_entries",
50                reason: "must be at least exact_threshold",
51            });
52        }
53        Ok(())
54    }
55}
56
57/// One quantile estimate together with its retained rank evidence.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct QuantileEstimate {
60    /// Requested quantile in `0.0..=1.0`.
61    pub quantile: f64,
62    /// Estimated value.
63    pub value: f64,
64    /// Inclusive lower bound on the value's zero-based normalized rank.
65    pub rank_lower: f64,
66    /// Inclusive upper bound on the value's zero-based normalized rank.
67    pub rank_upper: f64,
68    /// Number of observations represented by the sketch.
69    pub observations: u64,
70    /// Number of retained exact values or summary entries.
71    pub retained_entries: usize,
72    /// Whether this estimate used the exact small-data reference path.
73    pub exact: bool,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq)]
77struct SummaryEntry {
78    value: f64,
79    gap: u64,
80    slack: u64,
81}
82
83/// A deterministic Greenwald-Khanna streaming quantile summary.
84/// The sketch stores observations exactly through
85/// [`QuantilePolicy::exact_threshold`]. Larger streams use rank intervals and
86/// deterministic compression. Compatible sketches can be merged without
87/// replaying their source streams. The hard entry limit makes memory admission
88/// explicit: an operation that cannot retain the requested rank error is
89/// rejected transactionally.
90#[derive(Clone, Debug, PartialEq)]
91pub struct QuantileSketch {
92    policy: QuantilePolicy,
93    observations: u64,
94    exact_values: Option<Vec<f64>>,
95    summary: Vec<SummaryEntry>,
96}
97
98impl QuantileSketch {
99    /// Creates an empty sketch governed by `policy`.
100    pub fn new(policy: QuantilePolicy) -> Result<Self, QuantileError> {
101        policy.validate()?;
102        Ok(Self {
103            policy,
104            observations: 0,
105            exact_values: Some(Vec::new()),
106            summary: Vec::new(),
107        })
108    }
109
110    /// Returns the immutable error and memory policy.
111    pub fn policy(&self) -> &QuantilePolicy {
112        &self.policy
113    }
114
115    /// Returns the number of observations represented by the sketch.
116    pub fn len(&self) -> u64 {
117        self.observations
118    }
119
120    /// Returns whether the sketch is empty.
121    pub fn is_empty(&self) -> bool {
122        self.observations == 0
123    }
124
125    /// Returns the number of retained exact values or summary entries.
126    pub fn retained_entries(&self) -> usize {
127        self.exact_values
128            .as_ref()
129            .map_or(self.summary.len(), Vec::len)
130    }
131
132    /// Returns an upper bound on bytes occupied by retained numeric entries.
133    /// This excludes allocator metadata and the fixed-size sketch value.
134    pub fn retained_entry_bytes(&self) -> usize {
135        if let Some(values) = &self.exact_values {
136            values.len() * mem::size_of::<f64>()
137        } else {
138            self.summary.len() * mem::size_of::<SummaryEntry>()
139        }
140    }
141
142    /// Inserts one finite observation.
143    /// The operation is transactional when the configured entry limit is too
144    /// small for the requested error.
145    pub fn insert(&mut self, value: f64) -> Result<(), QuantileError> {
146        if !value.is_finite() {
147            return Err(QuantileError::NonFinite { index: None, value });
148        }
149        let mut candidate = self.clone();
150        candidate.insert_unchecked(value)?;
151        *self = candidate;
152        Ok(())
153    }
154
155    /// Merges another compatible sketch transactionally.
156    pub fn merge(&mut self, other: &Self) -> Result<(), QuantileError> {
157        if self.policy != other.policy {
158            return Err(QuantileError::IncompatiblePolicy);
159        }
160        if other.is_empty() {
161            return Ok(());
162        }
163        if self.is_empty() {
164            *self = other.clone();
165            return Ok(());
166        }
167        let mut candidate = self.clone();
168        candidate.merge_unchecked(other)?;
169        *self = candidate;
170        Ok(())
171    }
172
173    /// Estimates a quantile and returns its retained rank interval.
174    pub fn estimate(&self, quantile: f64) -> Result<QuantileEstimate, QuantileError> {
175        validate_quantile(quantile)?;
176        if self.is_empty() {
177            return Err(QuantileError::EmptyInput);
178        }
179        if let Some(values) = &self.exact_values {
180            let value = exact_quantile(values, quantile)?;
181            let normalized_rank = if values.len() == 1 { 0.0 } else { quantile };
182            return Ok(QuantileEstimate {
183                quantile,
184                value,
185                rank_lower: normalized_rank,
186                rank_upper: normalized_rank,
187                observations: self.observations,
188                retained_entries: values.len(),
189                exact: true,
190            });
191        }
192
193        let last = self.summary.len() - 1;
194        let selected = if quantile == 0.0 {
195            0
196        } else if quantile == 1.0 {
197            last
198        } else {
199            self.summary_index(quantile)
200        };
201        let (rank_lower, rank_upper) = self.rank_interval(selected);
202        Ok(QuantileEstimate {
203            quantile,
204            value: self.summary[selected].value,
205            rank_lower,
206            rank_upper,
207            observations: self.observations,
208            retained_entries: self.summary.len(),
209            exact: false,
210        })
211    }
212
213    fn insert_unchecked(&mut self, value: f64) -> Result<(), QuantileError> {
214        if let Some(values) = &mut self.exact_values {
215            values.push(value);
216            self.observations = self
217                .observations
218                .checked_add(1)
219                .ok_or(QuantileError::CountOverflow)?;
220            if values.len() <= self.policy.exact_threshold {
221                return Ok(());
222            }
223            let exact = self.exact_values.take().unwrap_or_default();
224            self.observations = 0;
225            for exact_value in exact {
226                self.insert_summary_value(exact_value)?;
227            }
228            return Ok(());
229        }
230        self.insert_summary_value(value)
231    }
232
233    fn insert_summary_value(&mut self, value: f64) -> Result<(), QuantileError> {
234        self.observations = self
235            .observations
236            .checked_add(1)
237            .ok_or(QuantileError::CountOverflow)?;
238        let position = self.summary.partition_point(|entry| entry.value <= value);
239        let slack = if position == 0 || position == self.summary.len() {
240            0
241        } else {
242            self.allowable_error().saturating_sub(1)
243        };
244        self.summary.insert(
245            position,
246            SummaryEntry {
247                value,
248                gap: 1,
249                slack,
250            },
251        );
252        self.compress()?;
253        self.check_memory()
254    }
255
256    fn merge_unchecked(&mut self, other: &Self) -> Result<(), QuantileError> {
257        let combined = self
258            .observations
259            .checked_add(other.observations)
260            .ok_or(QuantileError::CountOverflow)?;
261        if combined <= self.policy.exact_threshold as u64 {
262            let mut values = self.exact_values.take().unwrap_or_default();
263            values.extend(other.exact_values.as_deref().unwrap_or_default());
264            self.observations = combined;
265            self.exact_values = Some(values);
266            self.summary.clear();
267            return Ok(());
268        }
269
270        let own_entries = self.entries_for_merge();
271        let other_entries = other.entries_for_merge();
272        let mut merged = Vec::with_capacity(own_entries.len() + other_entries.len());
273        for (entries, other_entries) in [
274            (own_entries.as_slice(), other_entries.as_slice()),
275            (other_entries.as_slice(), own_entries.as_slice()),
276        ] {
277            for entry in entries.iter().copied() {
278                merged.push(SummaryEntry {
279                    slack: entry
280                        .slack
281                        .saturating_add(cross_rank_uncertainty(entry.value, other_entries)),
282                    ..entry
283                });
284            }
285        }
286        merged.sort_by(|left, right| left.value.total_cmp(&right.value));
287        if let Some(first) = merged.first_mut() {
288            first.slack = 0;
289        }
290        if let Some(last) = merged.last_mut() {
291            last.slack = 0;
292        }
293        self.observations = combined;
294        self.exact_values = None;
295        self.summary = merged;
296        self.compress()?;
297        self.check_memory()
298    }
299
300    fn entries_for_merge(&self) -> Vec<SummaryEntry> {
301        self.exact_values.as_ref().map_or_else(
302            || self.summary.clone(),
303            |values| {
304                let mut values = values.clone();
305                values.sort_by(f64::total_cmp);
306                values
307                    .into_iter()
308                    .map(|value| SummaryEntry {
309                        value,
310                        gap: 1,
311                        slack: 0,
312                    })
313                    .collect()
314            },
315        )
316    }
317
318    fn compress(&mut self) -> Result<(), QuantileError> {
319        if self.summary.len() < 3 {
320            return Ok(());
321        }
322        let allowable = self.allowable_error();
323        let mut index = self.summary.len() - 2;
324        while index > 0 {
325            let combined = self.summary[index]
326                .gap
327                .checked_add(self.summary[index + 1].gap)
328                .and_then(|value| value.checked_add(self.summary[index + 1].slack))
329                .ok_or(QuantileError::CountOverflow)?;
330            if combined <= allowable {
331                let removed = self.summary.remove(index);
332                self.summary[index].gap = self.summary[index]
333                    .gap
334                    .checked_add(removed.gap)
335                    .ok_or(QuantileError::CountOverflow)?;
336            }
337            index -= 1;
338        }
339        Ok(())
340    }
341
342    fn summary_index(&self, quantile: f64) -> usize {
343        let target = quantile.mul_add(self.observations.saturating_sub(1) as f64, 1.0);
344        let permitted = self.policy.rank_error * self.observations as f64;
345        let mut minimum_rank = 0_u64;
346        let mut previous = 0;
347        for (index, entry) in self.summary.iter().enumerate() {
348            minimum_rank = minimum_rank.saturating_add(entry.gap);
349            let maximum_rank = minimum_rank.saturating_add(entry.slack);
350            if maximum_rank as f64 > target + permitted {
351                return previous;
352            }
353            previous = index;
354        }
355        self.summary.len() - 1
356    }
357
358    fn rank_interval(&self, selected: usize) -> (f64, f64) {
359        if self.observations <= 1 {
360            return (0.0, 0.0);
361        }
362        let minimum = self.summary[..=selected]
363            .iter()
364            .map(|entry| entry.gap)
365            .sum::<u64>();
366        let maximum = minimum.saturating_add(self.summary[selected].slack);
367        let denominator = (self.observations - 1) as f64;
368        (
369            minimum.saturating_sub(1) as f64 / denominator,
370            maximum.saturating_sub(1) as f64 / denominator,
371        )
372    }
373
374    fn allowable_error(&self) -> u64 {
375        allowance(self.policy.rank_error, self.observations)
376    }
377
378    fn check_memory(&self) -> Result<(), QuantileError> {
379        if self.summary.len() > self.policy.max_summary_entries {
380            return Err(QuantileError::MemoryLimit {
381                required_entries: self.summary.len(),
382                max_entries: self.policy.max_summary_entries,
383            });
384        }
385        Ok(())
386    }
387}
388
389/// Computes the exact linearly interpolated quantile of finite small data.
390pub fn exact_quantile(values: &[f64], quantile: f64) -> Result<f64, QuantileError> {
391    validate_quantile(quantile)?;
392    if values.is_empty() {
393        return Err(QuantileError::EmptyInput);
394    }
395    let mut ordered = values.to_vec();
396    for (index, value) in ordered.iter().copied().enumerate() {
397        if !value.is_finite() {
398            return Err(QuantileError::NonFinite {
399                index: Some(index),
400                value,
401            });
402        }
403    }
404    ordered.sort_by(f64::total_cmp);
405    let position = quantile * (ordered.len() - 1) as f64;
406    let lower = position.floor() as usize;
407    let upper = position.ceil() as usize;
408    let fraction = position - lower as f64;
409    Ok((ordered[upper] - ordered[lower]).mul_add(fraction, ordered[lower]))
410}
411
412/// Failure while configuring, updating, merging, or querying a quantile sketch.
413#[derive(Clone, Debug, PartialEq)]
414pub enum QuantileError {
415    /// No observations were supplied.
416    EmptyInput,
417    /// A policy field was invalid.
418    InvalidPolicy {
419        /// Invalid field name.
420        field: &'static str,
421        /// Concrete policy requirement.
422        reason: &'static str,
423    },
424    /// A quantile was outside `0.0..=1.0` or not finite.
425    QuantileOutOfRange {
426        /// Rejected quantile.
427        quantile: f64,
428    },
429    /// An observation was not finite.
430    NonFinite {
431        /// Position in an exact input slice, when available.
432        index: Option<usize>,
433        /// Rejected value.
434        value: f64,
435    },
436    /// Two sketches had different error or memory policies.
437    IncompatiblePolicy,
438    /// The configured entry bound could not preserve the requested rank error.
439    MemoryLimit {
440        /// Entries required after compression.
441        required_entries: usize,
442        /// Configured hard entry bound.
443        max_entries: usize,
444    },
445    /// The represented observation count exceeded `u64`.
446    CountOverflow,
447}
448
449impl fmt::Display for QuantileError {
450    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
451        match self {
452            Self::EmptyInput => write!(formatter, "quantile input must not be empty"),
453            Self::InvalidPolicy { field, reason } => {
454                write!(formatter, "invalid quantile policy {field}: {reason}")
455            }
456            Self::QuantileOutOfRange { quantile } => {
457                write!(formatter, "quantile outside finite 0..=1 range: {quantile}")
458            }
459            Self::NonFinite { index, value } => match index {
460                Some(index) => write!(formatter, "quantile value {index} is not finite: {value}"),
461                None => write!(formatter, "quantile observation is not finite: {value}"),
462            },
463            Self::IncompatiblePolicy => write!(formatter, "quantile policies do not match"),
464            Self::MemoryLimit {
465                required_entries,
466                max_entries,
467            } => write!(
468                formatter,
469                "quantile summary requires {required_entries} entries, limit is {max_entries}"
470            ),
471            Self::CountOverflow => write!(formatter, "quantile observation count overflow"),
472        }
473    }
474}
475
476impl Error for QuantileError {}
477
478fn validate_quantile(quantile: f64) -> Result<(), QuantileError> {
479    if quantile.is_finite() && (0.0..=1.0).contains(&quantile) {
480        Ok(())
481    } else {
482        Err(QuantileError::QuantileOutOfRange { quantile })
483    }
484}
485
486fn allowance(rank_error: f64, observations: u64) -> u64 {
487    (2.0 * rank_error * observations as f64).floor() as u64
488}
489
490fn cross_rank_uncertainty(value: f64, entries: &[SummaryEntry]) -> u64 {
491    let upper = entries.partition_point(|entry| entry.value <= value);
492    if upper == 0 || upper == entries.len() {
493        0
494    } else {
495        entries[upper]
496            .gap
497            .saturating_add(entries[upper].slack)
498            .saturating_sub(1)
499    }
500}