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}