Skip to main content

clock_kit/
ring.rs

1//! Fixed-capacity latency statistics — REQ-CLK-006.
2//!
3//! [`LatencyRing`] is a sliding window of `u64` nanos: [`record`] is O(1)
4//! and evicts the oldest sample when capacity is exceeded; [`stats`] is
5//! **exact over the current window** with zero heap allocation — it sorts
6//! a stack-resident copy (CAP × 8 B scratch; default 4096 → 32 KiB, chosen
7//! for ≥ 64 KiB stacks) and reports min/max/mean/p50/p99/p99.9.
8//!
9//! Percentiles use the **nearest-rank** algorithm, re-implemented lean
10//! from the percentile-kit pattern: percentile-kit is L2, and an L1 crate
11//! must not depend upward (estate layers.md rule 1) — so the algorithm is
12//! copied, not linked. No recursion anywhere (guaranteed for constrained
13//! stacks): the sort is an iterative heapsort.
14//!
15//! [`record`]: LatencyRing::record
16//! [`stats`]: LatencyRing::stats
17
18/// Exact interval statistics over the current window (nanosecond
19/// resolution). All zeros for an empty ring. REQ-CLK-006.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub struct IntervalStats {
22    /// Smallest sample in the window.
23    pub min: u64,
24    /// Largest sample in the window.
25    pub max: u64,
26    /// Arithmetic mean, integer floor.
27    pub mean: u64,
28    /// 50th percentile (nearest rank).
29    pub p50: u64,
30    /// 99th percentile (nearest rank).
31    pub p99: u64,
32    /// 99.9th percentile (nearest rank).
33    pub p999: u64,
34}
35
36/// Fixed-capacity (const-generic) ring of `u64` nanos — a sliding window,
37/// not an accumulator. Allocation-free on every path: `stats()` works from
38/// a stack-resident scratch copy sized by `CAP` (default 4096 → 32 KiB —
39/// document your stack when embedding with larger capacities). REQ-CLK-006.
40#[derive(Debug)]
41pub struct LatencyRing<const CAP: usize = 4096> {
42    buf: [u64; CAP],
43    /// Index of the oldest live sample (next write slot).
44    head: usize,
45    /// Live samples, ≤ CAP.
46    len: usize,
47}
48
49impl<const CAP: usize> LatencyRing<CAP> {
50    /// An empty ring.
51    #[must_use]
52    pub const fn new() -> Self {
53        // Const-eval guard: zero capacity would deadlock the ring math.
54        const {
55            assert!(CAP > 0, "LatencyRing capacity must be non-zero");
56        }
57        Self {
58            buf: [0; CAP],
59            head: 0,
60            len: 0,
61        }
62    }
63
64    /// Record one interval. O(1); when the window is full the oldest
65    /// sample is evicted. REQ-CLK-006.
66    pub fn record(&mut self, interval_ns: u64) {
67        // Invariant: head < CAP (wraps below), len ≤ CAP — the index below
68        // is always in bounds.
69        #[allow(clippy::indexing_slicing)]
70        {
71            self.buf[self.head] = interval_ns;
72        }
73        self.head += 1;
74        if self.head == CAP {
75            self.head = 0;
76        }
77        if self.len < CAP {
78            self.len += 1;
79        }
80    }
81
82    /// Exact statistics over the current window. Zero heap allocation;
83    /// scratch is a stack-resident `CAP`-sized copy (heapsorted in place —
84    /// iterative, no recursion). REQ-CLK-006.
85    // Indexing below is guarded by two invariants, documented inline:
86    // (a) ring indices wrap below CAP; (b) only `0..written` is read, and
87    // `written == self.len ≤ CAP == scratch.len()`.
88    #[allow(clippy::indexing_slicing)]
89    #[must_use]
90    pub fn stats(&self) -> IntervalStats {
91        if self.len == 0 {
92            return IntervalStats::default();
93        }
94        // Live samples in recording order (oldest first), then sorted.
95        // Oldest index: `head` when the window is full (it wrapped onto
96        // the oldest slot); while still filling, data starts at 0.
97        let mut start = self.head + CAP - self.len;
98        if start >= CAP {
99            start -= CAP;
100        }
101        let mut scratch = [0_u64; CAP];
102        let mut written = 0;
103        for i in 0..self.len {
104            // (a) (start + i) mod CAP, branch instead of %.
105            let idx = start + i;
106            let idx = if idx >= CAP { idx - CAP } else { idx };
107            scratch[written] = self.buf[idx];
108            written += 1;
109        }
110        let live = &mut scratch[..written];
111        heapsort(live);
112
113        let n = self.len;
114        let mut sum = 0_u128;
115        let mut min = u64::MAX;
116        let mut max = 0_u64;
117        for &v in live.iter() {
118            sum += u128::from(v);
119            if v < min {
120                min = v;
121            }
122            if v > max {
123                max = v;
124            }
125        }
126        IntervalStats {
127            min,
128            max,
129            // Integer floor mean; sum of ≤ CAP u64s cannot exceed u128.
130            #[allow(clippy::cast_possible_truncation)] // mean < 2^64
131            mean: (sum / n as u128) as u64,
132            p50: nearest_rank(live, n, 50, 100),
133            p99: nearest_rank(live, n, 99, 100),
134            p999: nearest_rank(live, n, 999, 1_000),
135        }
136    }
137
138    /// Live samples in the window (≤ CAP).
139    #[must_use]
140    pub const fn len(&self) -> usize {
141        self.len
142    }
143
144    /// True when nothing has been recorded.
145    #[must_use]
146    pub const fn is_empty(&self) -> bool {
147        self.len == 0
148    }
149
150    /// Clear the window (reuses the allocation-free buffer).
151    pub fn reset(&mut self) {
152        self.head = 0;
153        self.len = 0;
154    }
155}
156
157impl<const CAP: usize> Default for LatencyRing<CAP> {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163/// Nearest-rank percentile: rank `ceil(num/den · n)`, 1-based, on a
164/// sorted window. Percentile-kit's algorithm, re-implemented lean —
165/// percentile-kit is L2 and cannot be a dependency of this L1 crate
166/// (estate layers.md rule 1). REQ-CLK-006.
167fn nearest_rank(sorted: &[u64], n: usize, num: usize, den: usize) -> u64 {
168    // rank = ceil(n·num/den); n·num overflows usize only far past any
169    // legal CAP — saturating regardless.
170    let rank = (n.saturating_mul(num).saturating_add(den - 1)) / den;
171    let idx = rank.saturating_sub(1).min(n - 1);
172    // Invariant: 1 ≤ rank ≤ n, so idx < n == sorted.len().
173    #[allow(clippy::indexing_slicing)]
174    sorted[idx]
175}
176
177/// Iterative in-place heapsort (no recursion — the risk register requires
178/// it for constrained stacks), no allocation. Sorts ascending.
179// Indexing invariants: sift_down only touches indices proven < end (the
180// child bounds are checked immediately above each access).
181#[allow(clippy::indexing_slicing)]
182fn heapsort(v: &mut [u64]) {
183    let n = v.len();
184    if n < 2 {
185        return;
186    }
187    // Build max-heap: sift down from the last parent.
188    let mut start = n / 2;
189    while start > 0 {
190        start -= 1;
191        sift_down(v, start, n);
192    }
193    // Extract: swap root with end, shrink, re-heapify.
194    let mut end = n;
195    while end > 1 {
196        end -= 1;
197        v.swap(0, end);
198        sift_down(v, 0, end);
199    }
200}
201
202// Indexing invariants: `left`, `left + 1`, `child`, `r` are each guarded by
203// `left < end` / `left + 1 < end` checks; `child ≤ left + 1 < end ≤ len`.
204#[allow(clippy::indexing_slicing)]
205fn sift_down(v: &mut [u64], root: usize, end: usize) {
206    let mut r = root;
207    loop {
208        let left = 2 * r + 1;
209        if left >= end {
210            return;
211        }
212        // Invariant: left and left+1 (when < end) are valid children.
213        let child = if left + 1 < end && v[left + 1] > v[left] {
214            left + 1
215        } else {
216            left
217        };
218        if v[child] <= v[r] {
219            return;
220        }
221        v.swap(r, child);
222        r = child;
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::{IntervalStats, LatencyRing};
229
230    /// Small-cap exactness against a plain reference implementation —
231    /// pure logic, mandated under miri (spec §Gate plan).
232    #[test]
233    fn ring_stats_exact_small_cap() {
234        let mut ring = LatencyRing::<8>::new();
235        ring.record(500);
236        ring.record(100);
237        ring.record(300);
238        let s = ring.stats();
239        assert_eq!(
240            s,
241            IntervalStats {
242                min: 100,
243                max: 500,
244                mean: 300,
245                p50: 300,
246                p99: 500, // rank ceil(99·3/100) = 3 → 3rd of {100,300,500}
247                p999: 500,
248            }
249        );
250    }
251
252    /// Sliding-window eviction: recording past CAP evicts the oldest; a
253    /// reset empties without reallocation.
254    #[test]
255    fn ring_eviction_order_and_reset() {
256        let mut ring = LatencyRing::<4>::new();
257        for v in 1..=6_u64 {
258            ring.record(v);
259        }
260        assert_eq!(ring.len(), 4);
261        let s = ring.stats();
262        // Window is {3, 4, 5, 6} — samples 1, 2 were evicted in order.
263        assert_eq!(s.min, 3);
264        assert_eq!(s.max, 6);
265        assert_eq!(s.mean, 4);
266        assert_eq!(s.p50, 4); // rank ceil(50·4/100)=2 → 2nd of sorted {3,4,5,6}
267
268        ring.reset();
269        assert!(ring.is_empty());
270        assert_eq!(ring.stats(), IntervalStats::default());
271    }
272
273    /// Empty-ring and single-sample edges.
274    #[test]
275    fn ring_edges() {
276        let mut ring = LatencyRing::<2>::new();
277        assert!(ring.is_empty());
278        assert_eq!(ring.stats(), IntervalStats::default());
279        ring.record(u64::MAX);
280        let s = ring.stats();
281        assert_eq!(
282            (s.min, s.max, s.mean, s.p50),
283            (u64::MAX, u64::MAX, u64::MAX, u64::MAX)
284        );
285    }
286
287    /// Mean floors (integer mean over u128 accumulation — no overflow at
288    /// plausible sums).
289    #[test]
290    fn ring_mean_floors() {
291        let mut ring = LatencyRing::<3>::new();
292        ring.record(1);
293        ring.record(1);
294        ring.record(2);
295        assert_eq!(ring.stats().mean, 1); // 4/3 floors to 1
296    }
297}