high_roller/rolling_sum.rs
1//! # Rolling Sum
2//!
3//! [`RollingSum`] tracks the sum of values in a fixed-size window.
4//! When the window is full, pushing a new value evicts the oldest.
5//!
6//! API guarantees:
7//! - Add is O(1).
8//! - Total is O(1).
9//! - Overflow and underflow are detected and recoverable.
10//! - Zero heap allocations.
11//!
12//! The value of this implementation is error recovery.
13//! [`RollingSum`] returns [`None`] as long as the inner sum
14//! overflows `T::MAX` or underflows `T::MIN`. And it resumes
15//! yielding `Some(&T)` as soon as the inner sum recovers.
16//!
17//! ```
18//! use high_roller::rolling_sum::RollingSum;
19//!
20//! let mut rsum: RollingSum<i8, 3> = RollingSum::default();
21//!
22//! // Add an initial value.
23//! rsum.add(5);
24//! assert_eq!(rsum.total().copied(), Some(5));
25//!
26//! // Stream of -1s takes over the window.
27//! for _ in 0..100 {
28//! rsum.add(-1);
29//! }
30//! assert_eq!(rsum.total().copied(), Some(-3));
31//!
32//! // Underflow forces total to None.
33//! rsum.add(i8::MIN);
34//! assert_eq!(rsum.total(), None);
35//!
36//! // Balanced back to zero.
37//! rsum.add(i8::MAX);
38//! rsum.add(1);
39//! assert_eq!(rsum.total().copied(), Some(0));
40//!
41//! // Evicting i8::MIN causes overflow.
42//! rsum.add(0);
43//! assert_eq!(rsum.total(), None);
44//!
45//! // Cause multiple overflows.
46//! for _ in 0..100 {
47//! rsum.add(i8::MAX);
48//! }
49//! assert_eq!(rsum.total(), None);
50//!
51//! // And recover back into an expected range.
52//! for _ in 0..100 {
53//! rsum.add(1);
54//! }
55//! assert_eq!(rsum.total().copied(), Some(3));
56//! ```
57
58use arraydeque::ArrayDeque;
59use num_traits::CheckedAdd;
60use num_traits::CheckedSub;
61use num_traits::WrappingAdd;
62use num_traits::WrappingSub;
63
64// PERF: using a bitvec or double bitvec for RollingSum on
65// applicable types could be a pretty nice space optimization.
66// Future project :)
67
68/// Tracks a rolling sum of at most `WINDOW` values.
69///
70/// This type is stack allocated. However a large window
71/// might warrant boxing. Clustering multiple instances
72/// in the same allocation might offer better cache
73/// access patterns.
74///
75/// ```
76/// use high_roller::rolling_sum::RollingSum;
77///
78/// struct Average {
79/// total: RollingSum<u32, 6000>,
80/// samples: RollingSum<u8, 6000>
81/// }
82///
83/// // Probably want to box this.
84/// const _: () = assert!(core::mem::size_of::<Average>() == 30064);
85/// ```
86#[derive(Debug)]
87pub struct RollingSum<T, const WINDOW: usize> {
88 deq: ArrayDeque<T, WINDOW>,
89 total: T,
90 zero: T,
91 balance: isize,
92}
93
94impl<T, const W: usize> Default for RollingSum<T, W>
95where
96 T: Default,
97{
98 /// Constructs a new [`RollingSum`] with sum and zero
99 /// values equal to `T::default()`.
100 fn default() -> Self {
101 Self::new(T::default(), T::default())
102 }
103}
104
105impl<T, const WINDOW: usize> RollingSum<T, WINDOW>
106where
107 T: Default,
108{
109 /// Constructs a new [`RollingSum`].
110 ///
111 /// Prefer using [`RollingSum::default`] for a cleaner API.
112 /// `init` is the sum's initial value. `zero` is value separating
113 /// positive and negative for this type. While identifying zero is strange,
114 /// passing as a parameter avoids requiring a `Zero` trait impl, which isn't
115 /// fun for anybody. Zero is required for differentiating overflow
116 /// and underflow.
117 ///
118 /// ```
119 /// use high_roller::rolling_sum::RollingSum;
120 ///
121 /// let _sum: RollingSum::<usize, 10> = RollingSum::new(0, 0);
122 /// ```
123 ///
124 /// # Compile errors
125 ///
126 /// Using `WINDOW == 0` is a compile-time error.
127 ///
128 /// ```compile_fail
129 /// # use high_roller::rolling_sum::RollingSum;
130 /// // This will fail to compile. A rolling sum of capacity 0 is nonsensical.
131 /// let _sum: RollingSum::<usize, 0> = RollingSum::default();
132 /// ```
133 #[must_use]
134 pub const fn new(init: T, zero: T) -> Self {
135 const { assert!(WINDOW != 0, "RollingSum with WINDOW == 0 is not permitted") };
136 Self {
137 deq: ArrayDeque::new(),
138 total: init,
139 balance: 0,
140 zero,
141 }
142 }
143}
144
145impl<T, const WINDOW: usize> RollingSum<T, WINDOW>
146where
147 T: WrappingAdd + WrappingSub + CheckedAdd + CheckedSub + PartialOrd + Copy + Default,
148{
149 /// Adds `T` to the rolling sum, displacing the oldest
150 /// member if the window is full to capacity.
151 ///
152 /// If adding `T` causes numerical overflow, subsequent
153 /// calls to [`RollingSum::total`] will return [`None`] until window
154 /// expirations cause underflow commensurate to the overflow.
155 ///
156 /// ```
157 /// use high_roller::rolling_sum::RollingSum;
158 ///
159 /// let mut rsum: RollingSum<i32, 1000> = RollingSum::default();
160 ///
161 /// rsum.add(100);
162 /// rsum.add(1);
163 /// rsum.add(-2);
164 ///
165 /// assert_eq!(rsum.total().copied(), Some(99));
166 /// ```
167 ///
168 /// # Panics
169 ///
170 /// This function panics if the [`isize`] signed overflow balance
171 /// counter itself overflows. Such a panic is impossible as long
172 /// as `WINDOW <= isize::MAX`. And an array of size `isize::MAX`
173 /// bytes is fantasy on all current hardware anyway.
174 //
175 // Clippy allow:
176 // Explained inline. This is easily provable, will never occur,
177 // and should not be exposed to the user.
178 #[allow(clippy::expect_used)]
179 #[allow(clippy::missing_panics_doc)]
180 pub fn add(&mut self, val: T) {
181 if self.deq.is_full() {
182 // Construction has a const assertion that WINDOW is not zero.
183 // So `is_full` guarantees there's something to pop.
184 let popped = self.deq.pop_front().expect(
185 "len is equal to capacity, and capacity is nonzero. So an element must exist.",
186 );
187
188 let changed = self.total.checked_sub(&popped).is_none();
189 self.total = self.total.wrapping_sub(&popped);
190
191 if changed {
192 self.balance = self
193 .balance
194 .checked_add(if popped >= self.zero { -1 } else { 1 })
195 .expect("overflow count itself overflowed");
196 }
197 }
198
199 let changed = self.total.checked_add(&val).is_none();
200 self.total = self.total.wrapping_add(&val);
201
202 if changed {
203 self.balance = self
204 .balance
205 .checked_add(if val >= self.zero { 1 } else { -1 })
206 .expect("overflow count itself overflowed");
207 }
208
209 // The `if` condition above guarantees the deque
210 // is not full. So there's space to push a value.
211 self.deq.push_back(val).expect("deq is not full");
212 }
213
214 /// Returns the accumulated total of all added values that
215 /// fit within the rolling window's capacity.
216 ///
217 /// Returns [`None`] if the window has overflowed. [`RollingSum`]
218 /// will resume returning `Some(&T)` when the last element
219 /// causing overflow is pushed out of the window.
220 ///
221 /// ```
222 /// use high_roller::rolling_sum::RollingSum;
223 ///
224 /// let mut rsum: RollingSum<i32, 100> = RollingSum::default();
225 ///
226 /// rsum.add(i32::MIN);
227 /// assert_eq!(rsum.total().copied(), Some(i32::MIN));
228 ///
229 /// for _ in 0..1000 {
230 /// rsum.add(i32::MIN);
231 /// assert_eq!(rsum.total(), None);
232 /// }
233 ///
234 /// for _ in 0..100 {
235 /// rsum.add(-1);
236 /// }
237 /// assert_eq!(rsum.total().copied(), Some(-100));
238 /// ```
239 #[must_use]
240 pub fn total(&self) -> Option<&T> {
241 (self.balance == 0).then_some(&self.total)
242 }
243}
244
245#[cfg(test)]
246pub mod for_tests {
247 use arraydeque::ArrayDeque;
248 use arraydeque::Wrapping;
249 use num_bigint::BigInt;
250
251 /// A simple implementation satisfying the same API as
252 /// this crate's `RollingSum` type. This is used for both
253 /// correctness and performance testing.
254 ///
255 /// See the note in total. This is not as robust against
256 /// underflow as RollingSum.
257 #[derive(Debug, Default)]
258 pub struct NaiveRollingSum<T, const WINDOW: usize> {
259 deq: ArrayDeque<T, WINDOW, Wrapping>,
260 sum: BigInt,
261 }
262
263 impl<T, const WINDOW: usize> NaiveRollingSum<T, WINDOW>
264 where
265 T: Clone + Default + Into<BigInt> + for<'a> TryFrom<&'a BigInt>,
266 {
267 #[must_use]
268 pub fn new(init: T) -> Self {
269 const { assert!(WINDOW != 0, "RollingSum with WINDOW == 0 is not permitted") };
270 Self {
271 deq: ArrayDeque::new(),
272 sum: init.into(),
273 }
274 }
275
276 // Rationalizing `allow`
277 // - This is test code. Expect is used as an assertion. We want
278 // assertions in test code.
279 // - Internal correctness assertions shouldn't be part of the public
280 // API. They just make my problem someone else's problem.
281 #[allow(clippy::expect_used)]
282 #[allow(clippy::missing_panics_doc)]
283 pub fn add(&mut self, val: T) {
284 self.sum = self
285 .sum
286 .checked_add(&Into::<BigInt>::into(val.clone()))
287 .expect("no bigint overflow");
288 if let Some(replaced) = self.deq.push_back(val) {
289 self.sum = self
290 .sum
291 .checked_sub(&Into::<BigInt>::into(replaced))
292 .expect("no bigint underflow");
293 }
294 }
295
296 #[must_use]
297 pub fn total(&self) -> Option<T> {
298 (&self.sum).try_into().ok()
299 }
300 }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used)]
305mod tests {
306 use super::*;
307 use crate::decimal::D1;
308 use crate::decimal::D5;
309 use crate::rolling_sum::for_tests::NaiveRollingSum;
310 use core::fmt::Debug;
311 use rand::distr::Uniform;
312 use rand::rngs::SmallRng;
313 use rand::RngExt;
314 use rand::SeedableRng;
315
316 /// Smoke test for RollingSum correctness.
317 ///
318 /// Accumulates a representative RollingMax and NaiveRollingMax
319 /// to verify their outputs are identical.
320 #[test]
321 fn rng_with_naive() {
322 const QLEN: usize = 1000;
323 const STREAM_LEN: usize = 100_000;
324
325 let sample = SmallRng::seed_from_u64(57).sample_iter(Uniform::new(-800f32, 800.).unwrap());
326 let mut roller = RollingSum::<D5, QLEN>::default();
327 let mut naive = NaiveRollingSum::<D5, QLEN>::default();
328
329 for val in sample.take(STREAM_LEN) {
330 let d4 = D5::cast(val.into());
331 roller.add(d4);
332 naive.add(d4);
333 assert_eq!(roller.total(), naive.total().as_ref());
334 }
335 }
336
337 /// If this doesn't work, nothing will.
338 #[test]
339 fn basic_math() {
340 let mut rs: RollingSum<u32, 3> = RollingSum::default();
341 assert_eq!(rs.total(), Some(&0u32));
342
343 rs.add(10);
344 assert_eq!(rs.total(), Some(&10));
345 }
346
347 /// Various behaviors when pushing and popping from the window.
348 #[test]
349 fn basic_rolling() {
350 // Basic accumulation.
351 self::expect_total::<u32, 3>([1, 2, 3].into_iter().zip([1, 3, 6]));
352
353 // Old elements are properly evicted from the sum.
354 self::expect_total::<u32, 3>([1, 2, 3, 4].into_iter().zip([1, 3, 6, 9]));
355
356 // Test above but with a longer window.
357 self::expect_total::<u32, 3>([5, 3, 8, 2, 6].into_iter().zip([5, 8, 16, 13, 16]));
358
359 // Capacity equals one is just a complicated variable.
360 self::expect_total::<u32, 1>([5, 3, 9, 1].into_iter().zip([5, 3, 9, 1]));
361
362 // Giant window never has eviction.
363 self::expect_total::<u32, 100>([1, 2, 3, 4, 5].into_iter().zip([1, 3, 6, 10, 15]));
364
365 // Negatives work too.
366 self::expect_total::<i32, 2>([-3, 5, -2, 4].into_iter().zip([-3, 2, 3, 2]));
367 }
368
369 /// Hits the u8 limit exactly and then recovers.
370 #[test]
371 fn limit_boundary() {
372 let mut rs = RollingSum::<u8, 3>::default();
373 rs.add(100);
374 rs.add(100);
375 rs.add(55);
376 assert_eq!(rs.total(), Some(&255));
377 rs.add(0);
378 assert_eq!(rs.total(), Some(&155));
379 }
380
381 /// Sum overflows and then recovers.
382 #[test]
383 fn limit_overflow() {
384 let mut rs = RollingSum::<u8, 2>::default();
385 rs.add(200);
386 assert_eq!(rs.total(), Some(&200)); // single element, no overflow
387 rs.add(100); // 200+100=300 > u8::MAX → wrap_ct=1
388 assert_eq!(rs.total(), None); // overflow detected
389 rs.add(50); // evicts 200; window=[100,50], sum=150
390 assert_eq!(rs.total(), Some(&150)); // overflow healed
391 }
392
393 /// Sum really overflows and then recovers.
394 #[test]
395 fn limit_overflow_2x() {
396 let mut rs = RollingSum::<u8, 3>::default();
397
398 rs.add(200);
399 assert_eq!(rs.total(), Some(&200));
400 rs.add(200);
401 assert_eq!(rs.total(), None);
402 rs.add(200);
403 assert_eq!(rs.total(), None);
404
405 rs.add(10);
406 assert_eq!(rs.total(), None);
407 rs.add(10);
408 assert_eq!(rs.total(), Some(&220));
409 rs.add(10);
410 assert_eq!(rs.total(), Some(&30));
411 }
412
413 /// The other tests use u8. Maybe I left a weird type expectation
414 /// somewhere. This uses large u64s.
415 #[test]
416 fn sanity_check_big() {
417 const HALF: u64 = (u64::MAX as f64 / 2.) as u64 - 1;
418 let mut rs = RollingSum::<_, 2>::default();
419 rs.add(HALF);
420 rs.add(HALF);
421 assert_eq!(rs.total(), Some(&(HALF * 2)));
422 rs.add(1);
423 assert_eq!(rs.total(), Some(&(HALF + 1)));
424 }
425
426 /// Brief overflow and recovery using only min, max, and 0.
427 #[test]
428 fn limit_extremes() {
429 let mut rs = RollingSum::<i32, 3>::default();
430
431 rs.add(i32::MAX);
432 assert!(rs.total().is_some());
433
434 rs.add(i32::MIN);
435 assert!(rs.total().is_some());
436
437 rs.add(i32::MAX);
438 assert!(rs.total().is_some());
439
440 rs.add(i32::MAX);
441 assert!(rs.total().is_some());
442
443 rs.add(0);
444 assert!(rs.total().is_none());
445
446 rs.add(0);
447 assert_eq!(rs.total(), Some(&i32::MAX));
448 }
449
450 /// Same as overflow test but negative.
451 #[test]
452 fn limit_underflow() {
453 let mut rs = RollingSum::<i32, 3>::default();
454
455 rs.add(i32::MIN);
456 assert!(rs.total().is_some());
457
458 rs.add(-1);
459 assert!(rs.total().is_none());
460
461 rs.add(1);
462 assert_eq!(rs.total(), Some(&i32::MIN));
463 }
464
465 /// Using `Decimal32` for its intended purpose.
466 #[test]
467 fn decimal_overflow() {
468 let mut rs = RollingSum::<D1, 4>::default();
469
470 rs.add(D1::MAX);
471 assert!(rs.total().is_some());
472
473 for _ in 0..100 {
474 rs.add(D1::MAX);
475 assert!(rs.total().is_none());
476 }
477
478 for _ in 0..3 {
479 rs.add(D1::ZERO);
480 }
481
482 rs.add(D1::MIN_UNIT);
483 assert!(matches!(rs.total(), Some(&D1::MIN_UNIT)));
484 }
485
486 /// Feeds inputs from an `(input, expected)` iterator into
487 /// a RollingSum. Compares each total to `expected` and panics
488 /// if they're not equal.
489 fn expect_total<T, const WINDOW: usize>(input_and_expected: impl Iterator<Item = (T, T)>)
490 where
491 T: WrappingAdd
492 + WrappingSub
493 + CheckedAdd
494 + CheckedSub
495 + PartialOrd
496 + Copy
497 + Default
498 + Debug,
499 {
500 let mut roll: RollingSum<T, WINDOW> = RollingSum::default();
501 for (input, expected) in input_and_expected {
502 roll.add(input);
503 assert_eq!(*roll.total().unwrap(), expected);
504 }
505 }
506}