av_state/velocity.rs
1//! Token-velocity tracking: sliding-window tokens-per-second intended for
2//! rate limiting. (The loop-breaker's `N+ tokens` arm uses its own
3//! cumulative per-session counter, not this window.)
4//!
5//! # Round-31 F4 — NOT WIRED INTO ENFORCEMENT
6//!
7//! There are currently zero production callers of [`TokenVelocity`]; the
8//! only users outside this crate are the integration tests in
9//! `crates/av-state/tests/e2e_race_velocity.rs`. The type predates the
10//! current admission / loop-breaker plumbing and was never connected to
11//! either the breaker's `min_tokens` gate (in `av_loopdetect`) or any
12//! harness rate-limit path.
13//!
14//! **Do not wire this into the breaker without a shared per-session
15//! lock.** The velocity `record_at` mutation and the breaker's `observe`
16//! mutation live under different [`parking_lot::Mutex`] instances, so a
17//! blind wire-up races the two counters. If you need per-session
18//! velocity in the breaker, extend `av_loopdetect::Breaker` with its own
19//! velocity window guarded by the same mutex the breaker already holds.
20//!
21//! The type is kept public for now (rather than deleted) because the
22//! sliding-window arithmetic is exercised by the
23//! `e2e_race_velocity` adversarial tests that lock in the
24//! round-26 F5 `saturating_add` discipline — reusable ground for the
25//! future breaker-integrated window when someone builds it.
26
27use parking_lot::Mutex;
28use std::collections::VecDeque;
29
30/// Sliding-window token counter.
31#[derive(Debug)]
32pub struct TokenVelocity {
33 window_ms: u64,
34 samples: Mutex<VecDeque<(u64, u64)>>, // (timestamp_ms, tokens)
35}
36
37impl TokenVelocity {
38 /// Create a tracker with the given window size in milliseconds.
39 pub fn new(window_ms: u64) -> Self {
40 Self {
41 window_ms: window_ms.max(1),
42 samples: Mutex::new(VecDeque::new()),
43 }
44 }
45
46 /// Record `tokens` at time `now_ms` and return the windowed total.
47 pub fn record_at(&self, now_ms: u64, tokens: u64) -> u64 {
48 let mut samples = self.samples.lock();
49 samples.push_back((now_ms, tokens));
50 let cutoff = now_ms.saturating_sub(self.window_ms);
51 while samples.front().is_some_and(|(t, _)| *t < cutoff) {
52 samples.pop_front();
53 }
54 // Round-26 F5: `Iterator::sum` on `u64` panics in debug and
55 // silently wraps in release on overflow. Every other counter
56 // and spend site in av_state uses `checked_add` or
57 // `saturating_add` — velocity was the last inconsistent
58 // site. Realistically requires ~2^64 windowed tokens, but
59 // the discipline gap means a future refactor that turns
60 // `window_ms` into a `Duration::MAX` sentinel could reach
61 // it. Cheap to fix.
62 samples.iter().fold(0u64, |acc, (_, n)| acc.saturating_add(*n))
63 }
64
65 /// Record at the current wall clock.
66 pub fn record(&self, tokens: u64) -> u64 {
67 self.record_at(av_core::time::now_ms(), tokens)
68 }
69
70 /// Windowed total without recording.
71 pub fn current_at(&self, now_ms: u64) -> u64 {
72 let samples = self.samples.lock();
73 let cutoff = now_ms.saturating_sub(self.window_ms);
74 // Round-26 F5: mirror record_at's saturating_add discipline.
75 samples
76 .iter()
77 .filter(|(t, _)| *t >= cutoff)
78 .fold(0u64, |acc, (_, n)| acc.saturating_add(*n))
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
85
86 use super::*;
87
88 #[test]
89 fn accumulates_within_window() {
90 let v = TokenVelocity::new(1000);
91 assert_eq!(v.record_at(0, 100), 100);
92 assert_eq!(v.record_at(500, 50), 150);
93 assert_eq!(v.record_at(999, 25), 175);
94 }
95
96 #[test]
97 fn old_samples_expire() {
98 let v = TokenVelocity::new(1000);
99 v.record_at(0, 100);
100 v.record_at(500, 50);
101 // t=1400 → cutoff 400: only the t=0 sample expires.
102 assert_eq!(v.record_at(1400, 10), 60);
103 // t=1600 → cutoff 600: the t=500 sample expires too.
104 assert_eq!(v.current_at(1600), 10);
105 assert_eq!(v.current_at(3000), 0, "everything expires eventually");
106 }
107
108 #[test]
109 fn clock_going_backwards_does_not_panic() {
110 let v = TokenVelocity::new(1000);
111 v.record_at(5000, 10);
112 // Skewed clock: earlier timestamp after a later one.
113 let total = v.record_at(4000, 5);
114 assert!(total >= 5, "must count at least the new sample, got {total}");
115 }
116
117 #[test]
118 fn record_returns_the_windowed_total_including_the_new_sample() {
119 // The wall-clock `record` wrapper must not be reducible to a
120 // constant (mutant misses on `record -> 0 / 1`).
121 let v = TokenVelocity::new(60_000);
122 let first = v.record(7);
123 assert_eq!(first, 7, "first record must return the value just written");
124 let second = v.record(3);
125 assert_eq!(
126 second, 10,
127 "second record must reflect both samples inside the window"
128 );
129 }
130
131 /// Round-26 F5: `Iterator::sum` on `u64` panics in debug and
132 /// wraps in release on overflow. Every other counter/spend
133 /// site in av_state uses checked/saturating arithmetic;
134 /// velocity now does too. Two u64::MAX samples inside the
135 /// window must return u64::MAX (saturated), not panic and not
136 /// wrap to a small number.
137 #[test]
138 fn windowed_sum_saturates_instead_of_wrapping_or_panicking() {
139 let v = TokenVelocity::new(60_000);
140 v.record_at(1_000, u64::MAX);
141 let total = v.record_at(2_000, u64::MAX);
142 assert_eq!(
143 total,
144 u64::MAX,
145 "windowed sum must saturate at u64::MAX, got {total}"
146 );
147 // current_at path also.
148 let peek = v.current_at(3_000);
149 assert_eq!(peek, u64::MAX, "current_at must saturate at u64::MAX, got {peek}");
150 }
151}