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}