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}