Skip to main content

subetha_cxc/
forecast_sensor.rs

1//! Item 16: Sprout-style stochastic forecast of the deliverable rate.
2//!
3//! Sprout (Winstein, Sivaraman & Balakrishnan, NSDI 2013) treats a cellular /
4//! variable bottleneck as a rate process with uncertainty and forecasts a
5//! CONSERVATIVE lower bound on what it will deliver over the next tick, so a
6//! sender can pre-size its window ahead of a dip rather than react after the loss
7//! a dip causes. This is the receiver-side estimator: it is fed the delivered
8//! bytes per measurement interval and tracks the rate with a one-dimensional
9//! Kalman filter (the rate is a random walk under process noise `q`; each
10//! observation is the rate plus measurement noise `r`), then forecasts the
11//! 5th-percentile deliverable rate for the next tick as
12//! `mean - 1.645 * sqrt(predicted variance)`, floored at zero.
13//!
14//! Unlike the passive BtlBw (item 6), which is a windowed-MAX of the PAST
15//! delivery rate, this is a forward-looking, conservative LOWER bound: when the
16//! observed rate jumps around, the filter's variance widens and the forecast
17//! drops at once - leading the dip - so the controller arms protection before the
18//! loss materialises. The noises scale with the current rate estimate, so one
19//! filter spans a bottleneck that varies by an order of magnitude.
20
21/// The one-sided z-score for a 5th-percentile (95 % one-sided) lower bound.
22const Z_5TH: f64 = 1.645;
23/// Process-noise fraction: the rate may drift this much per tick (a random
24/// walk), so the forecast variance grows by `(Q_FRAC * rate)^2` each tick.
25const Q_FRAC: f64 = 0.25;
26/// Measurement-noise fraction: a single interval's observed rate is this noisy
27/// relative to the rate, so the filter does not chase every sample.
28const R_FRAC: f64 = 0.15;
29
30/// Receiver-side Sprout-style rate forecaster.
31#[derive(Debug, Clone)]
32pub struct ArrivalForecast {
33    /// Current rate estimate (bytes/s).
34    rate: f64,
35    /// Estimate variance ((bytes/s)^2).
36    var: f64,
37    initialized: bool,
38}
39
40impl Default for ArrivalForecast {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl ArrivalForecast {
47    pub fn new() -> Self {
48        Self {
49            rate: 0.0,
50            var: 0.0,
51            initialized: false,
52        }
53    }
54
55    /// Feed one measurement: `bytes` delivered over `interval_s` seconds. The
56    /// first observation seeds the filter; later ones run the Kalman
57    /// predict / update with rate-scaled process and measurement noise.
58    pub fn observe(&mut self, bytes: u64, interval_s: f64) {
59        if interval_s <= 0.0 {
60            return;
61        }
62        let z = bytes as f64 / interval_s;
63        if !self.initialized {
64            self.rate = z;
65            // Seed the variance from the measurement-noise scale so the first
66            // forecast is already a sensible lower bound, not zero.
67            self.var = (R_FRAC * z).powi(2);
68            self.initialized = true;
69            return;
70        }
71        // Predict: the rate is a random walk, so the variance grows by the
72        // process noise (scaled to the current estimate).
73        let q = (Q_FRAC * self.rate).powi(2);
74        let var_pred = self.var + q;
75        // Update against the observation, whose noise scales with its own size.
76        let r = (R_FRAC * z).powi(2).max(1.0);
77        let k = var_pred / (var_pred + r);
78        self.rate += k * (z - self.rate);
79        self.var = (1.0 - k) * var_pred;
80    }
81
82    /// The smoothed mean rate estimate (bytes/s).
83    pub fn mean_bps(&self) -> f64 {
84        self.rate
85    }
86
87    /// The forecast: the 5th-percentile deliverable rate over the next tick
88    /// (bytes/s), `mean - 1.645 * sqrt(var + process noise)`, floored at zero.
89    /// A conservative lower bound the sender can size to without overshooting.
90    pub fn forecast_bps(&self) -> f64 {
91        if !self.initialized {
92            return 0.0;
93        }
94        let q = (Q_FRAC * self.rate).powi(2);
95        let var_next = self.var + q;
96        (self.rate - Z_5TH * var_next.sqrt()).max(0.0)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// A steady rate: the forecast converges to a conservative bound just below
105    /// the rate, never above it.
106    #[test]
107    fn steady_rate_forecasts_a_conservative_lower_bound() {
108        let mut f = ArrivalForecast::new();
109        // 100 kB every 0.1 s = 1 MB/s, repeated.
110        for _ in 0..50 {
111            f.observe(100_000, 0.1);
112        }
113        let mean = f.mean_bps();
114        let fc = f.forecast_bps();
115        assert!((mean - 1e6).abs() < 5e4, "mean ~ 1 MB/s, got {}", mean);
116        assert!(fc < mean, "the forecast is a conservative lower bound");
117        assert!(fc > 0.5e6, "but not absurdly low on a steady rate, got {}", fc);
118    }
119
120    /// The forecast is always at or below the mean (never optimistic).
121    #[test]
122    fn forecast_never_exceeds_the_mean() {
123        let mut f = ArrivalForecast::new();
124        for i in 0..40 {
125            // A noisy rate around 2 MB/s.
126            let b = if i % 2 == 0 { 180_000 } else { 220_000 };
127            f.observe(b, 0.1);
128            assert!(f.forecast_bps() <= f.mean_bps() + 1.0, "never optimistic");
129        }
130    }
131
132    /// A rate that collapses (a cellular dip): the forecast drops toward the new
133    /// low rate, and the variance spike makes it lead the mean down.
134    #[test]
135    fn a_rate_dip_pulls_the_forecast_down() {
136        let mut f = ArrivalForecast::new();
137        for _ in 0..30 {
138            f.observe(100_000, 0.1); // 1 MB/s
139        }
140        let fc_before = f.forecast_bps();
141        // The bottleneck collapses to 0.2 MB/s.
142        for _ in 0..15 {
143            f.observe(20_000, 0.1);
144        }
145        let fc_after = f.forecast_bps();
146        assert!(
147            fc_after < fc_before * 0.6,
148            "the forecast dropped after the dip: {fc_before} -> {fc_after}"
149        );
150        assert!(fc_after < 0.4e6, "toward the new low rate, got {fc_after}");
151    }
152
153    /// Uninitialised: no forecast yet.
154    #[test]
155    fn no_forecast_before_any_observation() {
156        let f = ArrivalForecast::new();
157        assert_eq!(f.forecast_bps(), 0.0);
158    }
159}