wickra_embed_core/ring.rs
1//! A fixed-capacity ring buffer of `f64`, allocation-free.
2//!
3//! This is the no-alloc replacement for the `Box<[f64]>` window `wickra-core`'s
4//! SMA uses. It preserves the two properties byte-parity depends on: [`push`]
5//! returns the evicted oldest value in the same order the std code subtracts it
6//! from the rolling sum, and [`chrono_sum`] folds oldest-to-newest exactly as
7//! the std reseed does.
8//!
9//! [`push`]: Ring::push
10//! [`chrono_sum`]: Ring::chrono_sum
11
12/// A ring buffer holding up to `N` `f64` values.
13#[derive(Debug, Clone)]
14pub struct Ring<const N: usize> {
15 buf: [f64; N],
16 /// Index of the next slot to write — also the oldest element once full.
17 head: usize,
18 /// Number of slots filled, saturating at `N`.
19 count: usize,
20}
21
22impl<const N: usize> Ring<N> {
23 /// A new, empty ring.
24 #[must_use]
25 pub const fn new() -> Self {
26 Self {
27 buf: [0.0; N],
28 head: 0,
29 count: 0,
30 }
31 }
32
33 /// Whether the ring holds `N` values.
34 #[must_use]
35 pub const fn is_full(&self) -> bool {
36 self.count == N
37 }
38
39 /// Number of values currently held.
40 #[must_use]
41 pub const fn len(&self) -> usize {
42 self.count
43 }
44
45 /// Whether the ring holds no values.
46 #[must_use]
47 pub const fn is_empty(&self) -> bool {
48 self.count == 0
49 }
50
51 /// Push `x`, overwriting the oldest slot once full.
52 ///
53 /// Returns the evicted oldest value if the ring was already full, or `None`
54 /// while still filling. The caller (SMA) subtracts the returned value from
55 /// its rolling sum *before* adding `x`, matching the std subtract-then-add
56 /// order.
57 pub fn push(&mut self, x: f64) -> Option<f64> {
58 let evicted = if self.count == N {
59 Some(self.buf[self.head])
60 } else {
61 self.count += 1;
62 None
63 };
64 self.buf[self.head] = x;
65 self.head += 1;
66 if self.head == N {
67 self.head = 0;
68 }
69 evicted
70 }
71
72 /// Sum the held values oldest-to-newest.
73 ///
74 /// When full this walks `buf[head..]` then `buf[..head]` — the exact
75 /// chronological order (and thus the exact fold, starting from `0.0`) that
76 /// `wickra-core` reseeds its rolling sum with, so the reseed is bit-for-bit
77 /// identical.
78 #[must_use]
79 pub fn chrono_sum(&self) -> f64 {
80 let mut sum = 0.0;
81 let mut idx = self.head;
82 let mut seen = 0;
83 while seen < self.count {
84 sum += self.buf[idx];
85 idx += 1;
86 if idx == N {
87 idx = 0;
88 }
89 seen += 1;
90 }
91 sum
92 }
93}
94
95impl<const N: usize> Default for Ring<N> {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::Ring;
104
105 #[test]
106 fn fills_then_evicts_oldest() {
107 let mut ring = Ring::<3>::new();
108 assert_eq!(ring.push(1.0), None);
109 assert_eq!(ring.push(2.0), None);
110 assert!(!ring.is_full());
111 assert_eq!(ring.push(3.0), None);
112 assert!(ring.is_full());
113 // Now full: pushing evicts the oldest (1.0, then 2.0, ...).
114 assert_eq!(ring.push(4.0), Some(1.0));
115 assert_eq!(ring.push(5.0), Some(2.0));
116 }
117
118 #[test]
119 fn chrono_sum_is_oldest_to_newest_after_wraparound() {
120 let mut ring = Ring::<3>::new();
121 for x in [1.0, 2.0, 3.0, 4.0, 5.0] {
122 ring.push(x);
123 }
124 // Holds 3,4,5; chronological fold 0.0 + 3 + 4 + 5.
125 assert_eq!(ring.chrono_sum().to_bits(), (3.0_f64 + 4.0 + 5.0).to_bits());
126 assert_eq!(ring.len(), 3);
127 }
128}