Skip to main content

sliding_quantile/lib_actual/
mod.rs

1// Based on the C# reference implementation by Andrey Akinshin.
2// https://aakinshin.net/posts/partitioning-heaps-quantile-estimator3/
3//
4// Copyright (c) 2020–2025 Andrey Akinshin
5//
6// Permission is hereby granted, free of charge, to any person obtaining
7// a copy of this software and associated documentation files (the
8// "Software"), to deal in the Software without restriction, including
9// without limitation the rights to use, copy, modify, merge, publish,
10// distribute, sublicense, and/or sell copies of the Software, and to
11// permit persons to whom the Software is furnished to do so, subject to
12// the following conditions:
13//
14// The above copyright notice and this permission notice shall be
15// included in all copies or substantial portions of the Software.
16//
17// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
21// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
22// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
25mod aliases;
26
27#[cfg(test)]
28mod tests;
29
30use core::ops::{ControlFlow, Index, IndexMut};
31
32pub use aliases::*;
33
34/// Moving quantile over a sliding window based on partitioning heaps.
35pub struct Quantile<Num, NumArr, UsizeArr>
36where
37    Num: num_traits::Float,
38{
39    /// The number of most recent observations we calculate the desired quantile for.
40    window_size: usize, // const
41
42    /// The target quantile in `0.0..=1.0`.
43    ///
44    /// For example, `0.5` (median) or `0.95` (95th percentile).
45    ///
46    /// Formally, given a random variable `X` and a probability `p`, the quantile function
47    /// finds a value `x` so that `P(X <= x) = p`. `X` represents the values we encounter.
48    /// Our threshold value `x` is at the root that connects the two heaps.
49    probability: Num, // const
50
51    /// Heap index of the root element that represents the target quantile position.
52    ///
53    /// This is the index that connects the lower heap (elements less than or equal to the root)
54    /// and upper heap (elements larger than or equal to the root).
55    ///
56    /// Let's say `window_size = 10` and `p = 0.5` (median). Remember that `p` percent of
57    /// observations are supposed to be less than or equal to the value at this index.
58    /// Then it makes sense to choose `root_heap_idx = 4`, because root and lower heap
59    /// make up exactly half of the window.
60    ///
61    /// For the correct proportion of elements on each side, we calculate
62    /// `root_heap_idx = floor((window_size - 1) * probability)`.
63    root_heap_idx: usize, // const
64
65    /// Stores the `n=window_size` most recent observations.
66    ///
67    /// Must be ordered to uphold the heap-partitioning property and to place the
68    /// root that approximates the targe quantile at `root_heap_idx`.
69    heap: NumArr,
70
71    /// Total number of observations.
72    ///
73    /// When the window fills up, `total_elem_count % window_size` is the index of the
74    /// oldest observation. New observations are always inserted at
75    /// `heap[total_elem_count % window_size]`.
76    total_elem_count: usize,
77
78    /// Ring buffer that maps sliding window elements to heap indices.
79    ///
80    /// The heap index of new observations is always inserted at `total_elem_count % window_size`.
81    /// If the window is full, this is also the index of the oldest observation. Therefore,
82    /// this buffer allows us to efficiently replace the oldest observation with a new one.
83    elem_to_heap_idx: UsizeArr,
84
85    /// Maps heap indices to sliding window elements.
86    ///
87    /// When inserting new observations, we usually need to swap heap elements to uphold
88    /// its properties. Swapping elements means they swap heap indices. We need this mapping
89    /// to know where to swap them in `elem_to_heap_idx`.
90    heap_to_elem_idx: UsizeArr,
91
92    /// Number of elements in the lower heap (elements <= than the quantile).
93    ///
94    /// Once the initial window is filled, `lower_heap_size == root_heap_idx`.
95    lower_heap_size: usize,
96
97    /// Number of elements in the upper heap (elements >= than the quantile).
98    ///
99    /// Once the initial window is filled, `upper_heap_size == window_size - lower_heap_size - 1`.
100    upper_heap_size: usize,
101}
102
103#[derive(Clone, Copy)]
104enum Heap {
105    /// The lower heap is a max-heap containing elements smaller than the root.
106    ///
107    /// It occupies the positions `0..root_heap_idx`. The parent is always greater
108    /// than or equal to its children, so larger values bubble up toward the root boundary.
109    Lower,
110
111    /// The upper heap is a min-heap containing elements larger than the root.
112    ///
113    /// It occupies the positions `root_heap_idx + 1..window_size`. The parent is always less
114    /// than or equal to its children, so smaller values bubble up toward the root boundary.
115    Upper,
116}
117
118#[cfg(any(feature = "std", feature = "alloc"))]
119impl<Num> Quantile<Num, alloc::vec::Vec<Num>, alloc::vec::Vec<usize>>
120where
121    Num: num_traits::Float + num_traits::FromPrimitive + num_traits::ToPrimitive,
122{
123    /// Create a new quantile tracker with a specific window size.
124    ///
125    /// # Arguments
126    /// * `probability` -- Probability of the quantile to compute. For example, `0.5` for the
127    ///   median, or `0.75` for the 75th percentile.
128    /// * `window_size` -- Sliding window size. The target quantile is tracked for
129    ///   this many most recent observations. The algorithm is intended for moderate window sizes
130    ///   of roughly 10 to 10,000 elements.
131    ///
132    /// # Panics
133    /// * when `window_size` is zero
134    /// * when `probability` is outside `0..=1`
135    #[must_use]
136    pub fn new(probability: Num, window_size: usize) -> Self {
137        assert!(window_size > 0_usize, "zero window size");
138        assert!(probability.is_finite(), "probability must be finite");
139        assert!(probability >= Num::zero(), "probability must be >= 0");
140        assert!(probability <= Num::one(), "probability must be <= 1");
141
142        let root_heap_idx =
143            Num::from_usize(window_size - 1).unwrap_or(Num::max_value()) * probability;
144        let root_heap_idx = root_heap_idx.floor().to_usize().unwrap();
145
146        Self {
147            window_size,
148            probability,
149            heap: alloc::vec![Num::nan(); window_size],
150            heap_to_elem_idx: alloc::vec![0_usize; window_size],
151            elem_to_heap_idx: alloc::vec![0_usize; window_size],
152            root_heap_idx,
153            lower_heap_size: 0_usize,
154            upper_heap_size: 0_usize,
155            total_elem_count: 0_usize,
156        }
157    }
158}
159
160impl<Num, const WIN_SIZE: usize> Quantile<Num, [Num; WIN_SIZE], [usize; WIN_SIZE]>
161where
162    Num: num_traits::Float + num_traits::FromPrimitive + num_traits::ToPrimitive,
163{
164    /// Create a new quantile tracker with a specific window size.
165    ///
166    /// # Arguments
167    /// * `window_size` -- Sliding window size. The target quantile is tracked for
168    ///   this many most recent observations. The algorithm is intended for moderate window sizes
169    ///   of roughly 10 to 10,000 elements.
170    /// * `probability` -- Probability of the quantile to compute. For example, `0.5` for the
171    ///   median, or `0.75` for the 75th percentile.
172    ///
173    /// # Panics
174    /// * when `window_size` is zero
175    /// * when `probability` is outside `0..=1`
176    #[must_use]
177    pub fn new(probability: Num) -> Self {
178        assert!(WIN_SIZE > 0_usize, "zero window size");
179        assert!(probability.is_finite(), "probability must be finite");
180        assert!(probability >= Num::zero(), "probability must be >= 0");
181        assert!(probability <= Num::one(), "probability must be <= 1");
182
183        let root_heap_idx = Num::from_usize(WIN_SIZE - 1).unwrap_or(Num::max_value()) * probability;
184        let root_heap_idx = root_heap_idx.floor().to_usize().unwrap();
185
186        Self {
187            window_size: WIN_SIZE,
188            probability,
189            heap: [Num::nan(); WIN_SIZE],
190            heap_to_elem_idx: [0_usize; WIN_SIZE],
191            elem_to_heap_idx: [0_usize; WIN_SIZE],
192            root_heap_idx,
193            lower_heap_size: 0_usize,
194            upper_heap_size: 0_usize,
195            total_elem_count: 0_usize,
196        }
197    }
198}
199
200impl<Num, NumArr, UsizeArr> Quantile<Num, NumArr, UsizeArr>
201where
202    Num: num_traits::Float + num_traits::FromPrimitive + num_traits::ToPrimitive,
203    NumArr: Index<usize, Output = Num> + IndexMut<usize, Output = Num>,
204    UsizeArr: Index<usize, Output = usize> + IndexMut<usize, Output = usize>,
205{
206    /// Add an observation and update the quantile estimate.
207    ///
208    /// If the window is full, the oldest observation will be removed.
209    ///
210    /// # Arguments
211    /// * `value` -- the next observation in your data stream
212    #[inline]
213    pub fn add(&mut self, value: Num) {
214        let elem_idx = self.total_elem_count % self.window_size;
215        self.total_elem_count += 1;
216
217        // window is full - replace the oldest element and sift for the heap property
218        if self.total_elem_count > self.window_size {
219            let heap_idx = self.elem_to_heap_idx[elem_idx];
220            self.insert(heap_idx, elem_idx, value);
221            self.sift(heap_idx);
222            return;
223        }
224
225        // first element - just insert at the root, no sifting needed
226        if self.total_elem_count == 1 {
227            core::hint::cold_path();
228            self.insert(self.root_heap_idx, elem_idx, value);
229            return;
230        }
231
232        // window is filling - pick a heap to extend and sift
233        let desired_lower_heap_size =
234            Num::from_usize(self.total_elem_count - 1).unwrap() * self.probability;
235        let desired_lower_heap_size = desired_lower_heap_size.floor().to_usize().unwrap();
236
237        let heap_idx = if self.lower_heap_size < desired_lower_heap_size {
238            self.lower_heap_size += 1;
239            self.root_heap_idx - self.lower_heap_size
240        } else {
241            self.upper_heap_size += 1;
242            self.root_heap_idx + self.upper_heap_size
243        };
244        self.insert(heap_idx, elem_idx, value);
245        self.sift(heap_idx);
246    }
247
248    #[inline(always)]
249    fn insert(&mut self, heap_idx: usize, elem_idx: usize, value: Num) {
250        self.heap[heap_idx] = value;
251        self.heap_to_elem_idx[heap_idx] = elem_idx;
252        self.elem_to_heap_idx[elem_idx] = heap_idx;
253    }
254
255    /// After inserting a new value, heap properties may be violated.
256    /// The sift operation restores these properties.
257    ///
258    /// > The sift operation is convergent. The sift process eventually finds the correct
259    /// > element position regardless of insertion location or initial heap property violations.
260    /// > This robustness keeps quantile relationships intact as the window slides.
261    fn sift(&mut self, mut heap_idx: usize) {
262        while let ControlFlow::Continue(new_heap_idx) = self.sift_once(heap_idx) {
263            heap_idx = new_heap_idx;
264        }
265    }
266
267    #[inline(always)]
268    fn sift_once(&mut self, heap_idx: usize) -> ControlFlow<(), usize> {
269        // the root can be displaced by elements from either side
270        if heap_idx == self.root_heap_idx {
271            if self.lower_heap_size > 0 {
272                let new_heap_idx =
273                    self.swap_with_children(Heap::Lower, heap_idx, Some(heap_idx - 1), None);
274                if new_heap_idx != heap_idx {
275                    return ControlFlow::Continue(new_heap_idx);
276                }
277            }
278
279            if self.upper_heap_size > 0 {
280                let new_heap_idx =
281                    self.swap_with_children(Heap::Upper, heap_idx, Some(heap_idx + 1), None);
282                if new_heap_idx != heap_idx {
283                    return ControlFlow::Continue(new_heap_idx);
284                }
285            }
286
287            core::hint::cold_path();
288            return ControlFlow::Break(());
289        }
290
291        let heap = if heap_idx > self.root_heap_idx {
292            Heap::Upper
293        } else {
294            Heap::Lower
295        };
296        let heap_parent_idx = match heap {
297            Heap::Upper => self.root_heap_idx + (heap_idx - self.root_heap_idx) / 2,
298            Heap::Lower => self.root_heap_idx - (self.root_heap_idx - heap_idx) / 2,
299        };
300
301        // sift upward: if the newly inserted value violates the heap property with respect to
302        // its parent, swap their positions and continue. This handles cases where a large value
303        // is inserted into the lower heap or a small value into the upper heap.
304        if self.should_swap(heap, heap_parent_idx, heap_idx) {
305            self.swap(heap_idx, heap_parent_idx);
306            return ControlFlow::Continue(heap_parent_idx);
307        }
308
309        let heap_child_idx1 = (2 * heap_idx).checked_sub(self.root_heap_idx);
310        let heap_child_idx2 = if heap_idx > self.root_heap_idx {
311            heap_child_idx1.map(|i| i + 1)
312        } else {
313            heap_child_idx1.and_then(|i| i.checked_sub(1))
314        };
315
316        // sift downward: compare the current element with its children and swap with the
317        // child that best satisfies the heap property. For the lower heap (max-heap),
318        // this means swapping with the larger child. For the upper heap (min-heap),
319        // it means swapping with the smaller child.
320        let new_heap_idx =
321            self.swap_with_children(heap, heap_idx, heap_child_idx1, heap_child_idx2);
322
323        if new_heap_idx != heap_idx {
324            return ControlFlow::Continue(new_heap_idx);
325        }
326
327        ControlFlow::Break(())
328    }
329
330    #[inline(always)]
331    fn should_swap(&self, heap: Heap, parent_idx: usize, child_idx: usize) -> bool {
332        match heap {
333            // check if the current node violates the max-heap property (lower heap)
334            Heap::Lower => self.heap[parent_idx] < self.heap[child_idx],
335            // check if the current node violates the min-heap property (upper heap)
336            Heap::Upper => self.heap[parent_idx] > self.heap[child_idx],
337        }
338    }
339
340    #[inline(always)]
341    fn swap(&mut self, heap_idx1: usize, heap_idx2: usize) {
342        let elem_idx1 = self.heap_to_elem_idx[heap_idx1];
343        let elem_idx2 = self.heap_to_elem_idx[heap_idx2];
344        let value1 = self.heap[heap_idx1];
345        let value2 = self.heap[heap_idx2];
346
347        // swap the actual values in the heap
348        self.heap[heap_idx1] = value2;
349        self.heap[heap_idx2] = value1;
350
351        // keep the heap-to-element mapping correct
352        self.heap_to_elem_idx[heap_idx1] = elem_idx2;
353        self.heap_to_elem_idx[heap_idx2] = elem_idx1;
354
355        // keep the element-to-heap mapping correct
356        self.elem_to_heap_idx[elem_idx1] = heap_idx2;
357        self.elem_to_heap_idx[elem_idx2] = heap_idx1;
358    }
359
360    #[inline(always)]
361    fn is_heap_idx(&self, heap_idx: usize) -> bool {
362        // check if above or at lower heap limit & below or at upper heap limit
363        let min_lower_heap_idx = self.root_heap_idx - self.lower_heap_size;
364        let max_upper_heap_idx = self.root_heap_idx + self.upper_heap_size;
365        heap_idx >= min_lower_heap_idx && heap_idx <= max_upper_heap_idx
366    }
367
368    /// Check if swapping the element with a child is necessary to maintain heap properties.
369    /// Return the new index of the element after swaps.
370    fn swap_with_children(
371        &mut self,
372        heap: Heap,
373        heap_idx: usize,
374        maybe_left_child_idx: Option<usize>,
375        maybe_right_child_idx: Option<usize>,
376    ) -> usize {
377        let maybe_left_child_idx = maybe_left_child_idx.filter(|i| self.is_heap_idx(*i));
378        let maybe_right_child_idx = maybe_right_child_idx.filter(|i| self.is_heap_idx(*i));
379
380        match (maybe_left_child_idx, maybe_right_child_idx) {
381            (None, None) => {
382                // no children, no swap
383                heap_idx
384            }
385            (Some(left_child_idx), None) if self.should_swap(heap, heap_idx, left_child_idx) => {
386                // single child and swap is necessary
387                self.swap(heap_idx, left_child_idx);
388                left_child_idx
389            }
390            (Some(_), None) => {
391                // single child but must not swap
392                heap_idx
393            }
394            (Some(left_child_idx), Some(right_child_idx))
395                if self.should_swap(heap, heap_idx, left_child_idx)
396                    || self.should_swap(heap, heap_idx, right_child_idx) =>
397            {
398                // two children and swap is necessary - pick the right one for swapping
399                let swap_child_idx = if self.should_swap(heap, left_child_idx, right_child_idx) {
400                    // right child should become the parent of left child
401                    right_child_idx
402                } else {
403                    // left child should become the parent of right child
404                    left_child_idx
405                };
406                self.swap(heap_idx, swap_child_idx);
407                swap_child_idx
408            }
409            (Some(_), Some(_)) => {
410                // two children but must not swap
411                heap_idx
412            }
413            (None, Some(_)) => {
414                // SAFETY: in no case should there be a right child but no left child
415                unsafe { core::hint::unreachable_unchecked() }
416            }
417        }
418    }
419
420    /// The target quantile for the last `n ≤ window_size` observations.
421    ///
422    /// For example, when initialized with `probability = 0.2`, this returns a
423    /// value ≥ 20% of the observations in the sliding window, and ≤ 80% of them.
424    ///
425    /// This returns `NAN` if zero values have been observed so far.
426    #[inline]
427    #[must_use]
428    pub fn get(&self) -> Num {
429        if self.total_elem_count == 0 {
430            core::hint::cold_path();
431            return Num::nan();
432        }
433
434        // single observation or no observation > the target quantile
435        if self.total_elem_count == 1 || self.upper_heap_size == 0 {
436            core::hint::cold_path();
437            return self.heap[self.root_heap_idx];
438        }
439
440        // Hyndman-Fan Type 7
441        // root element represents one boundary of the quantile estimate
442        let a = self.heap[self.root_heap_idx];
443
444        // the first element of the upper heap provides the other boundary
445        let b = self.heap[self.root_heap_idx + 1];
446
447        // these two elements provide the order statistics needed for linear interpolation
448
449        // for a window of size n and probability p, the theoretical position is:
450        //      h = (n - 1) * p
451        let n = self.total_elem_count.min(self.window_size);
452        let h = Num::from_usize(n - 1).unwrap() * self.probability;
453
454        // if this position falls between two elements at positions floor(h) and ceil(h),
455        // the quantile becomes:
456        //      q = element[floor(h)] + (h - floor(h)) * (element[ceil(h)] - element[floor(h)])
457        a + (h - h.floor()) * (b - a)
458    }
459}