subetha_cxc/temporal_sensor.rs
1//! Temporal sensing: a purely in-band channel estimator built from
2//! send / receive timing alone.
3//!
4//! Given a stream of `(send_ts, recv_ts)` observations (microseconds) it
5//! produces three signals the adaptive controller fuses with loss and
6//! radio sensors:
7//!
8//! - **Inter-arrival jitter** (RFC 3550 style): variability of arrival
9//! spacing, a proxy for queueing noise and burstiness.
10//! - **One-way-delay (OWD) trend**: the slope of OWD over a recent
11//! window. The absolute OWD carries the (unknown) clock offset between
12//! the two hosts, but the *slope* cancels it, so a rising trend means
13//! "the queue is building" - congestion-driven loss is imminent -
14//! regardless of unsynchronized clocks. This is the WebRTC Google
15//! Congestion Control mechanism (trendline over one-way delay).
16//! - **Mean inter-arrival**: the baseline spacing the jitter is measured
17//! against.
18//!
19//! The estimator holds no clock and does no I/O; the caller supplies
20//! timestamps, so it is deterministic and exhaustively testable with
21//! synthetic traces.
22
23use std::collections::VecDeque;
24
25/// Rolling estimator over send/receive timing.
26#[derive(Debug)]
27pub struct TemporalSensor {
28 /// `(send_ts, recv_ts)` of the previous observation.
29 prev: Option<(u64, u64)>,
30 /// EWMA mean inter-arrival (microseconds).
31 mean_interarrival: f64,
32 /// RFC 3550 interarrival jitter estimate (microseconds).
33 jitter: f64,
34 /// Recent `(recv_ts, owd)` samples for the trend slope, bounded to
35 /// `window_cap`.
36 owd_window: VecDeque<(u64, f64)>,
37 window_cap: usize,
38 /// A LONGER `(recv_ts, owd)` window for the clock-skew estimate. Skew is a
39 /// slow, stable quantity (a fixed crystal-frequency difference), so it is
40 /// measured over many round trips - long enough that the linear drift rises
41 /// above the per-packet jitter that swamps it on a short window.
42 skew_window: VecDeque<(u64, f64)>,
43 skew_cap: usize,
44}
45
46impl Default for TemporalSensor {
47 fn default() -> Self {
48 Self::new(64)
49 }
50}
51
52impl TemporalSensor {
53 /// Create a sensor whose OWD trend is computed over the last
54 /// `window` samples (clamped to at least 2).
55 pub fn new(window: usize) -> Self {
56 Self {
57 prev: None,
58 mean_interarrival: 0.0,
59 jitter: 0.0,
60 owd_window: VecDeque::new(),
61 window_cap: window.max(2),
62 skew_window: VecDeque::new(),
63 // ~16x the trend window: enough round trips that the clock drift
64 // accumulates above the jitter, while still bounded.
65 skew_cap: (window.max(2)).saturating_mul(16).max(256),
66 }
67 }
68
69 /// Record one observation. `send_ts` and `recv_ts` are microseconds;
70 /// `recv_ts` uses the receiver's clock, `send_ts` the sender's. Only
71 /// their *differences* are used, so a constant clock offset between
72 /// the two is harmless.
73 pub fn observe(&mut self, send_ts: u64, recv_ts: u64) {
74 // OWD carries the clock offset; the trend slope removes it.
75 let owd = recv_ts as f64 - send_ts as f64;
76 if let Some((psend, precv)) = self.prev {
77 // Inter-arrival on the receive side.
78 let interarrival = recv_ts.wrapping_sub(precv) as f64;
79 self.mean_interarrival += (interarrival - self.mean_interarrival) / 16.0;
80 // RFC 3550 jitter: D is the change in transit time between
81 // consecutive packets; J tracks |D| with a 1/16 gain.
82 let d = (recv_ts as f64 - precv as f64) - (send_ts as f64 - psend as f64);
83 self.jitter += (d.abs() - self.jitter) / 16.0;
84 }
85 self.prev = Some((send_ts, recv_ts));
86 self.owd_window.push_back((recv_ts, owd));
87 while self.owd_window.len() > self.window_cap {
88 self.owd_window.pop_front();
89 }
90 self.skew_window.push_back((recv_ts, owd));
91 while self.skew_window.len() > self.skew_cap {
92 self.skew_window.pop_front();
93 }
94 }
95
96 /// Current interarrival jitter (microseconds).
97 pub fn jitter_micros(&self) -> f64 {
98 self.jitter
99 }
100
101 /// Mean interarrival spacing (microseconds).
102 pub fn interarrival_micros(&self) -> f64 {
103 self.mean_interarrival
104 }
105
106 /// Slope of OWD over the window: microseconds of delay added per
107 /// microsecond of wall time. Positive means the queue is building
108 /// (congestion-driven loss is coming); near zero is a steady link;
109 /// negative means the queue is draining. Clock-offset-invariant.
110 pub fn owd_trend(&self) -> f64 {
111 let n = self.owd_window.len();
112 if n < 2 {
113 return 0.0;
114 }
115 // Least-squares slope of owd (y) vs recv_ts (x). Shift x by the
116 // first sample so the magnitudes stay small and well-conditioned.
117 let x0 = self.owd_window.front().unwrap().0;
118 let (mut sx, mut sy, mut sxx, mut sxy) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
119 for &(rx, owd) in &self.owd_window {
120 let x = (rx - x0) as f64;
121 sx += x;
122 sy += owd;
123 sxx += x * x;
124 sxy += x * owd;
125 }
126 let nf = n as f64;
127 let denom = nf * sxx - sx * sx;
128 if denom.abs() < f64::EPSILON {
129 0.0
130 } else {
131 (nf * sxy - sx * sy) / denom
132 }
133 }
134
135 /// Estimated clock skew: the slope of the line lying BELOW all
136 /// `(recv_ts, owd)` samples (Moon-Skelly-Towsley). The minimum OWD for each
137 /// time is the queue-free path, whose drift is purely the relative clock
138 /// rate; queueing only ever adds delay ABOVE that line. Computed from the
139 /// lower convex hull of the window (the queue-free minimum points), whose
140 /// least-squares slope is the skew. Same units as `owd_trend`.
141 pub fn skew(&self) -> f64 {
142 let n = self.skew_window.len();
143 if n < 3 {
144 return 0.0;
145 }
146 let x0 = self.skew_window.front().unwrap().0;
147 // Lower convex hull (monotone chain) over (t, owd): the queue-free
148 // minimum boundary. Samples arrive in recv_ts order, so they are
149 // already sorted by t.
150 let mut hull: Vec<(f64, f64)> = Vec::new();
151 for &(rx, owd) in &self.skew_window {
152 let p = ((rx - x0) as f64, owd);
153 while hull.len() >= 2 {
154 let a = hull[hull.len() - 2];
155 let b = hull[hull.len() - 1];
156 // Pop on a clockwise / collinear turn so the chain hugs the
157 // lower boundary; a queue spike above it is popped, leaving the
158 // minimum-delay points.
159 let cross = (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0);
160 if cross <= 0.0 {
161 hull.pop();
162 } else {
163 break;
164 }
165 }
166 hull.push(p);
167 }
168 let m = hull.len();
169 if m < 2 {
170 return 0.0;
171 }
172 // Moon's LP optimum (the below-all line highest in sum) is the lower
173 // hull's supporting line at the mean time - i.e. the slope of the hull
174 // edge spanning the centroid. This ignores a high queue spike at a
175 // window endpoint (which is on the hull boundary but not the skew
176 // line), where a least-squares fit over all hull vertices would be
177 // tilted by it.
178 let t_mean = self.skew_window.iter().map(|&(rx, _)| (rx - x0) as f64).sum::<f64>()
179 / n as f64;
180 let mut k = 0;
181 while k + 2 < m && hull[k + 1].0 < t_mean {
182 k += 1;
183 }
184 let (a, b) = (hull[k], hull[k + 1]);
185 let dt = b.0 - a.0;
186 if dt.abs() < f64::EPSILON {
187 0.0
188 } else {
189 (b.1 - a.1) / dt
190 }
191 }
192
193 /// OWD trend with the clock skew removed: `owd_trend - skew`. On a steady
194 /// link a relative clock drift makes `owd_trend` read a false rising (or
195 /// falling) trend; subtracting the skew leaves only genuine queue
196 /// variation, so a steady-but-skewed link reads ~0.
197 pub fn owd_trend_debiased(&self) -> f64 {
198 self.owd_trend() - self.skew()
199 }
200
201 /// Number of OWD samples currently in the window.
202 pub fn samples(&self) -> usize {
203 self.owd_window.len()
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn steady_link_has_low_jitter_and_flat_trend() {
213 let mut s = TemporalSensor::new(64);
214 // Constant 1000us spacing, constant 5000us OWD (any clock offset).
215 let offset = 1_000_000u64;
216 for i in 0..100u64 {
217 let send = i * 1000;
218 let recv = send + 5000 + offset;
219 s.observe(send, recv);
220 }
221 assert!(s.jitter_micros() < 1.0, "jitter {}", s.jitter_micros());
222 assert!(s.owd_trend().abs() < 1e-6, "trend {}", s.owd_trend());
223 assert!((s.interarrival_micros() - 1000.0).abs() < 50.0);
224 }
225
226 #[test]
227 fn rising_owd_yields_positive_trend() {
228 let mut s = TemporalSensor::new(64);
229 // OWD grows 50us per packet (queue building).
230 for i in 0..100u64 {
231 let send = i * 1000;
232 let recv = send + 5000 + i * 50;
233 s.observe(send, recv);
234 }
235 assert!(s.owd_trend() > 0.0, "expected positive trend, got {}", s.owd_trend());
236 }
237
238 #[test]
239 fn draining_owd_yields_negative_trend() {
240 let mut s = TemporalSensor::new(64);
241 for i in 0..100u64 {
242 let send = i * 1000;
243 // Start high, drain 40us per packet.
244 let recv = send + 5000 + (100 - i) * 40;
245 s.observe(send, recv);
246 }
247 assert!(s.owd_trend() < 0.0, "expected negative trend, got {}", s.owd_trend());
248 }
249
250 #[test]
251 fn jittery_arrivals_raise_jitter() {
252 let mut s = TemporalSensor::new(64);
253 // Alternating transit time -> nonzero D each step.
254 for i in 0..100u64 {
255 let send = i * 1000;
256 let wobble = if i % 2 == 0 { 0 } else { 800 };
257 let recv = send + 5000 + wobble;
258 s.observe(send, recv);
259 }
260 assert!(s.jitter_micros() > 100.0, "jitter {}", s.jitter_micros());
261 }
262
263 #[test]
264 fn clock_skew_de_biases_the_trend() {
265 let mut s = TemporalSensor::new(64);
266 let offset = 1_000_000u64;
267 // Steady link (constant true OWD), but the receive clock runs fast:
268 // OWD drifts +10 us per 1000 us of send time (a 1% relative skew).
269 for i in 0..100u64 {
270 let send = i * 1000;
271 let recv = send + 5000 + send / 100 + offset;
272 s.observe(send, recv);
273 }
274 // The raw trend reads the skew as a (false) rising queue.
275 assert!(s.owd_trend() > 1e-4, "raw trend sees the skew: {}", s.owd_trend());
276 // The skew estimate recovers it, so the de-biased trend is ~flat.
277 assert!(s.skew() > 1e-4, "skew recovered: {}", s.skew());
278 assert!(
279 s.owd_trend_debiased().abs() < 1e-4,
280 "de-biased trend flat: {}",
281 s.owd_trend_debiased()
282 );
283 }
284
285 #[test]
286 fn no_skew_leaves_trend_flat() {
287 let mut s = TemporalSensor::new(64);
288 for i in 0..100u64 {
289 // Constant OWD, no skew.
290 s.observe(i * 1000, i * 1000 + 5000 + 1_000_000);
291 }
292 assert!(s.skew().abs() < 1e-5, "no skew: {}", s.skew());
293 assert!(s.owd_trend_debiased().abs() < 1e-5, "flat: {}", s.owd_trend_debiased());
294 }
295
296 #[test]
297 fn real_queue_trend_survives_de_biasing() {
298 let mut s = TemporalSensor::new(64);
299 // No clock skew. The queue genuinely builds, but dips to a flat
300 // baseline every fourth packet - so the lower-hull (minimum) line is
301 // flat (skew ~0) while the mean trend rises. The de-biased trend must
302 // keep the real queue signal.
303 for i in 0..100u64 {
304 let send = i * 1000;
305 let queue = if i % 4 == 0 { 0 } else { i * 30 };
306 s.observe(send, send + 5000 + queue + 1_000_000);
307 }
308 assert!(s.skew().abs() < 0.01, "flat baseline -> low skew: {}", s.skew());
309 assert!(
310 s.owd_trend_debiased() > 0.0,
311 "real queue trend survives: {}",
312 s.owd_trend_debiased()
313 );
314 }
315
316 #[test]
317 fn window_is_bounded() {
318 let mut s = TemporalSensor::new(8);
319 for i in 0..100u64 {
320 s.observe(i * 1000, i * 1000 + 5000);
321 }
322 assert_eq!(s.samples(), 8);
323 }
324}