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}