Skip to main content

cuprate_helper/num/
rolling_median.rs

1use std::{
2    cmp::min,
3    collections::VecDeque,
4    ops::{Add, Div, Mul, Sub},
5};
6
7use crate::num::{get_mid, median};
8
9/// A rolling median type.
10///
11/// This keeps track of a window of items and allows calculating the [`RollingMedian::median`] of them.
12///
13/// Example:
14/// ```rust
15/// # use cuprate_helper::num::RollingMedian;
16/// let mut rolling_median = RollingMedian::new(2);
17///
18/// rolling_median.push(1);
19/// assert_eq!(rolling_median.median(), 1);
20/// assert_eq!(rolling_median.window_len(), 1);
21///
22/// rolling_median.push(3);
23/// assert_eq!(rolling_median.median(), 2);
24/// assert_eq!(rolling_median.window_len(), 2);
25///
26/// rolling_median.push(5);
27/// assert_eq!(rolling_median.median(), 4);
28/// assert_eq!(rolling_median.window_len(), 2);
29/// ```
30///
31// TODO: a more efficient structure is probably possible.
32#[derive(Debug, Ord, PartialOrd, Eq, PartialEq, Clone)]
33pub struct RollingMedian<T> {
34    /// The window of items, in order of insertion.
35    window: VecDeque<T>,
36    /// The window of items, sorted.
37    sorted_window: Vec<T>,
38
39    /// The target window length.
40    target_window: usize,
41}
42
43impl<T> RollingMedian<T>
44where
45    T: Ord
46        + PartialOrd
47        + Add<Output = T>
48        + Sub<Output = T>
49        + Div<Output = T>
50        + Mul<Output = T>
51        + Copy
52        + From<u8>,
53{
54    /// Creates a new [`RollingMedian`] with a certain target window length.
55    ///
56    /// `target_window` is the maximum amount of items to keep in the rolling window.
57    pub fn new(target_window: usize) -> Self {
58        Self {
59            window: VecDeque::with_capacity(target_window),
60            sorted_window: Vec::with_capacity(target_window),
61            target_window,
62        }
63    }
64
65    /// Creates a new [`RollingMedian`] from a [`Vec`] with a certain target window length.
66    ///
67    /// `target_window` is the maximum amount of items to keep in the rolling window.
68    ///
69    /// # Panics
70    /// This function panics if `vec.len() > target_window`.
71    pub fn from_vec(vec: Vec<T>, target_window: usize) -> Self {
72        assert!(vec.len() <= target_window);
73
74        let mut sorted_window = vec.clone();
75        sorted_window.sort_unstable();
76
77        Self {
78            window: vec.into(),
79            sorted_window,
80            target_window,
81        }
82    }
83
84    /// Pops the front of the window, i.e. the oldest item.
85    ///
86    /// This is often not needed as [`RollingMedian::push`] will handle popping old values when they fall
87    /// out of the window.
88    pub fn pop_front(&mut self) {
89        if let Some(item) = self.window.pop_front() {
90            match self.sorted_window.binary_search(&item) {
91                Ok(idx) => {
92                    self.sorted_window.remove(idx);
93                }
94                Err(_) => panic!("Value expected to be in sorted_window was not there"),
95            }
96        }
97    }
98
99    /// Pops the back of the window, i.e. the youngest item.
100    pub fn pop_back(&mut self) {
101        if let Some(item) = self.window.pop_back() {
102            match self.sorted_window.binary_search(&item) {
103                Ok(idx) => {
104                    self.sorted_window.remove(idx);
105                }
106                Err(_) => panic!("Value expected to be in sorted_window was not there"),
107            }
108        }
109    }
110
111    /// Push an item to the _back_ of the window.
112    ///
113    /// This will pop the oldest item in the window if the target length has been exceeded.
114    pub fn push(&mut self, item: T) {
115        if self.window.len() >= self.target_window {
116            self.pop_front();
117        }
118
119        self.window.push_back(item);
120        match self.sorted_window.binary_search(&item) {
121            Ok(idx) | Err(idx) => self.sorted_window.insert(idx, item),
122        }
123    }
124
125    /// Append some values to the _front_ of the window.
126    ///
127    /// These new values will be the oldest items in the window. The order of the inputted items will be
128    /// kept, i.e. the first item in the [`Vec`] will be the oldest item in the queue.
129    pub fn append_front(&mut self, items: Vec<T>) {
130        for item in items.into_iter().rev() {
131            self.window.push_front(item);
132            match self.sorted_window.binary_search(&item) {
133                Ok(idx) | Err(idx) => self.sorted_window.insert(idx, item),
134            }
135
136            if self.window.len() > self.target_window {
137                self.pop_back();
138            }
139        }
140    }
141
142    /// Returns the number of items currently in the [`RollingMedian`].
143    pub fn window_len(&self) -> usize {
144        self.window.len()
145    }
146
147    /// Calculates the median of the values currently in the [`RollingMedian`].
148    pub fn median(&self) -> T {
149        median(&self.sorted_window)
150    }
151
152    /// Calculates a median value with a set amount of `grace` values.
153    ///
154    /// `grace` values are minimum values added to the back of the [`RollingMedian`]. The median is then
155    /// got as if these values had been added and replaced any values at the front, if the capacity is
156    /// reached.
157    pub fn median_with_grace(&self, grace: usize) -> T {
158        let zero = T::from(0);
159        let current_len = self.sorted_window.len();
160        let cap = self.target_window;
161
162        // The amount of values that would be dropped if this many grace values were added.
163        let drop = (current_len + grace).saturating_sub(cap);
164        // The new length of the window with the grace values.
165        let new_len = min(current_len + grace, cap);
166
167        if new_len == 0 || new_len / 2 < grace {
168            return zero;
169        }
170
171        // The entries that would be removed if the grace values were added.
172        let mut removed = self.window.iter().take(drop).copied().collect::<Vec<_>>();
173        removed.sort_unstable();
174        // An index into the sorted `removed` list.
175        let mut rem_idx = 0;
176
177        // Conceptual median index for the new window.
178        let conceptual_idx = if new_len.is_multiple_of(2) {
179            new_len / 2 - 1
180        } else {
181            new_len / 2
182        };
183
184        // The index into the real `sorted_window`.
185        // Because the grace entries are not really in the window, we simulate them by shifting the median
186        // search by grace entries. As the grace values are always `0`, it will shift the median down.
187        let mut idx = conceptual_idx.saturating_sub(grace);
188
189        // A closure to get the next live value, starting the search at the given index.
190        // When we add grace values, some values may be removed from the list so we need to make sure
191        // we are not using dead values to calculate the median.
192        let next_live = |idx: &mut usize, rem_idx: &mut usize| -> T {
193            loop {
194                let v = self.sorted_window[*idx];
195                // If the value we are currently looking at has a value more than or equal to a removed value we need to increase the median index.
196                if removed.get(*rem_idx).is_some_and(|r| *r <= v) {
197                    // Consume the removed value, we have now adjusted.
198                    *rem_idx += 1;
199                    // Increase the median index.
200                    *idx += 1;
201                    // Try the next value.
202                    continue;
203                }
204                // We have found a value, increase the index for the next potential search.
205                *idx += 1;
206                return v;
207            }
208        };
209
210        if new_len.is_multiple_of(2) {
211            // This handles an edge case where the grace takes one of our median values as 0 but leaves
212            // the other.
213            let left = if conceptual_idx < grace {
214                zero
215            } else {
216                next_live(&mut idx, &mut rem_idx)
217            };
218
219            get_mid(left, next_live(&mut idx, &mut rem_idx))
220        } else {
221            next_live(&mut idx, &mut rem_idx)
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use proptest::{collection::vec, prelude::*};
229
230    use crate::num::RollingMedian;
231
232    fn assert_median_with_grace(window: Vec<u64>, grace: usize, target_window: usize) {
233        let mut median = RollingMedian::new(target_window);
234
235        for i in window {
236            median.push(i);
237        }
238
239        let median1 = median.median_with_grace(grace);
240
241        for _ in 0..grace {
242            median.push(0);
243        }
244
245        assert_eq!(median1, median.median());
246    }
247
248    proptest! {
249        #[test]
250        fn median_with_grace(window in vec(any::<u64>(), 1..10_000_usize), grace in 0..10_000_usize, target_window in 1..10_000_usize) {
251            assert_median_with_grace(window, grace, target_window);
252        }
253
254        #[test]
255        fn median_with_grace_tight(window in vec(0..50_u64, 1..10_000_usize), grace in 0..10_000_usize, target_window in 1..10_000_usize) {
256            assert_median_with_grace(window, grace, target_window);
257        }
258    }
259}