Skip to main content

rtc_interceptor/gcc/
kalman.rs

1//! The delay-gradient filter: noisy per-group measurements in, a trend out.
2
3/// Kalman filter over the one-way delay gradient, per draft-ietf-rmcat-gcc-02 §5.3.
4///
5/// # What it is for
6///
7/// A single inter-group delay measurement is mostly noise: scheduling jitter at either end, the
8/// receiver's clock resolution, and TWCC's 250 µs quantisation all move it around by more than the
9/// signal does early on. The filter estimates the *trend* — is the queue growing, and how fast —
10/// while adapting how much it trusts each new measurement to how noisy the measurements have been.
11///
12/// # Pure
13///
14/// No clock, no allocation, no state beyond four numbers. Every input is a parameter, which is what
15/// lets upstream's table-driven vectors port directly.
16#[derive(Debug, Clone, Copy)]
17pub struct Kalman {
18    /// Current estimate of the delay gradient, in milliseconds per group.
19    estimate: f64,
20    /// Estimate error variance.
21    error: f64,
22    /// Running estimate of measurement noise variance.
23    measurement_variance: f64,
24    /// How fast the process itself is believed to drift.
25    process_noise: f64,
26    /// Weight on each new sample when updating the noise estimate.
27    ///
28    /// Deliberately tiny. The residual that feeds this estimate is *signal* as well as noise, so a
29    /// large weight lets a genuine trend inflate the variance, which collapses the gain, which
30    /// stops the trend being tracked — the filter talks itself out of the thing it is measuring.
31    /// The draft derives it from `chi = 0.01` at the frame rate, which lands around 3e-4.
32    noise_gain: f64,
33    /// Floor on the measurement-noise estimate, so the filter never trusts a sample completely.
34    min_measurement_variance: f64,
35}
36
37impl Default for Kalman {
38    fn default() -> Self {
39        Self {
40            estimate: 0.0,
41            // A large initial error means the first few measurements move the estimate freely,
42            // rather than being damped towards an arbitrary zero.
43            error: 0.1,
44            measurement_variance: 0.0,
45            process_noise: 1e-3,
46            noise_gain: 3e-4,
47            min_measurement_variance: 1.0,
48        }
49    }
50}
51
52impl Kalman {
53    /// A filter with the draft's default tuning.
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// How fast the underlying gradient is assumed to drift. Larger tracks faster and is noisier.
59    pub fn with_process_noise(mut self, process_noise: f64) -> Self {
60        self.process_noise = process_noise;
61        self
62    }
63
64    /// The current trend estimate, in milliseconds per group.
65    pub fn estimate(&self) -> f64 {
66        self.estimate
67    }
68
69    /// Fold in one inter-group delay measurement and return the updated estimate.
70    ///
71    /// `measurement` is arrival spread minus departure spread for a pair of groups, in
72    /// milliseconds.
73    pub fn update(&mut self, measurement: f64) -> f64 {
74        // How far this sample is from what was predicted.
75        let residual = measurement - self.estimate;
76
77        // Track the noisiness of the measurements themselves, so a jittery path is trusted less.
78        // Clamped from below: with no floor, a run of identical samples drives the variance to
79        // zero and the filter starts believing each new sample absolutely.
80        self.measurement_variance = ((1.0 - self.noise_gain) * self.measurement_variance
81            + self.noise_gain * residual * residual)
82            .max(self.min_measurement_variance);
83
84        // Predict: uncertainty grows by the process noise before the measurement is folded in.
85        let predicted_error = self.error + self.process_noise;
86
87        // The gain is how much of the residual to believe: high when the estimate is uncertain,
88        // low when the measurements are noisy.
89        let gain = predicted_error / (predicted_error + self.measurement_variance);
90
91        self.estimate += gain * residual;
92        self.error = (1.0 - gain) * predicted_error;
93
94        self.estimate
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    /// A path that is not queueing measures zero, and the filter must stay there rather than
103    /// drifting — a filter that wanders on a quiet path invents congestion.
104    #[test]
105    fn a_zero_signal_keeps_a_zero_estimate() {
106        let mut kalman = Kalman::new();
107        for _ in 0..100 {
108            kalman.update(0.0);
109        }
110        assert!(
111            kalman.estimate().abs() < 1e-9,
112            "estimate drifted to {}",
113            kalman.estimate()
114        );
115    }
116
117    /// A sustained gradient is tracked. Not instantly — that is the point of filtering — but it
118    /// must get there, or overuse is never detected.
119    #[test]
120    fn a_sustained_gradient_is_tracked() {
121        let mut kalman = Kalman::new();
122        for _ in 0..200 {
123            kalman.update(10.0);
124        }
125        assert!(
126            (kalman.estimate() - 10.0).abs() < 1.0,
127            "a steady 10 ms gradient should be tracked, got {}",
128            kalman.estimate()
129        );
130    }
131
132    /// Noise around zero must not be mistaken for a trend. This is the property that stops a
133    /// jittery but uncongested path from being throttled.
134    #[test]
135    fn symmetric_noise_does_not_move_the_estimate() {
136        let mut kalman = Kalman::new();
137        for step in 0..400 {
138            // ±8 ms alternating: far larger than any real gradient early on.
139            kalman.update(if step % 2 == 0 { 8.0 } else { -8.0 });
140        }
141        assert!(
142            kalman.estimate().abs() < 2.0,
143            "alternating noise should average out, got {}",
144            kalman.estimate()
145        );
146    }
147
148    /// The filter is asymmetric in time, not in sign: a negative trend is tracked as readily as a
149    /// positive one, or a draining queue would look like a stable one.
150    #[test]
151    fn a_negative_gradient_is_tracked_too() {
152        let mut kalman = Kalman::new();
153        for _ in 0..200 {
154            kalman.update(-6.0);
155        }
156        assert!(
157            (kalman.estimate() + 6.0).abs() < 1.0,
158            "a draining queue should read negative, got {}",
159            kalman.estimate()
160        );
161    }
162
163    /// Higher process noise tracks a change faster. This is the knob, and it must actually do
164    /// something — otherwise the tuning is decoration.
165    #[test]
166    fn process_noise_controls_how_fast_a_change_is_tracked() {
167        let mut slow = Kalman::new().with_process_noise(1e-5);
168        let mut fast = Kalman::new().with_process_noise(1e-1);
169
170        for _ in 0..30 {
171            slow.update(20.0);
172            fast.update(20.0);
173        }
174
175        assert!(
176            fast.estimate() > slow.estimate(),
177            "more process noise should track faster: slow {}, fast {}",
178            slow.estimate(),
179            fast.estimate()
180        );
181    }
182}