Skip to main content

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}