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}