Skip to main content

rtc_interceptor/gcc/
threshold.rs

1//! The adaptive threshold the delay trend is compared against.
2
3use std::time::{Duration, Instant};
4
5/// Starting threshold, in milliseconds — draft-ietf-rmcat-gcc-02 §5.4.
6pub const DEFAULT_INITIAL_MS: f64 = 12.5;
7
8/// A threshold that follows the trend it is judging.
9///
10/// # Why it adapts
11///
12/// A fixed threshold cannot work on both a datacentre path and a mobile one: set low enough to
13/// detect congestion on a link with microseconds of jitter, it fires constantly on a link with
14/// tens of milliseconds of it. Worse, a fixed threshold is what lets a GCC flow be starved by a
15/// concurrent loss-based flow — the queue grows past the fixed point, GCC backs off, the other flow
16/// takes the space, and GCC never comes back.
17///
18/// So the threshold rises when the trend is outside it and falls when the trend is inside, slowly,
19/// and at different rates: `K_u` (moving away) is larger than `K_d` (moving back), so it yields
20/// quickly to a genuine overuse and returns to sensitivity only gradually.
21#[derive(Debug, Clone, Copy)]
22pub struct AdaptiveThreshold {
23    /// Current threshold, in milliseconds. Compared against `|estimate|`.
24    value_ms: f64,
25    /// Gain when the estimate is outside the threshold.
26    increase_gain: f64,
27    /// Gain when the estimate is inside it.
28    decrease_gain: f64,
29    /// When it was last updated, for the time-scaled adaptation.
30    last_update: Option<Instant>,
31}
32
33impl Default for AdaptiveThreshold {
34    fn default() -> Self {
35        Self {
36            value_ms: DEFAULT_INITIAL_MS,
37            increase_gain: 0.01,
38            decrease_gain: 0.00018,
39            last_update: None,
40        }
41    }
42}
43
44impl AdaptiveThreshold {
45    /// A threshold at the draft's starting value.
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// The current threshold, in milliseconds.
51    pub fn value_ms(&self) -> f64 {
52        self.value_ms
53    }
54
55    /// Move the threshold towards `estimate_ms`, given how long since the last update.
56    ///
57    /// Returns the updated threshold. `now` is a parameter rather than read from a clock: upstream
58    /// reads `time.Now()` here, which is why its own threshold tests cannot pin a trajectory.
59    pub fn update(&mut self, now: Instant, estimate_ms: f64) -> f64 {
60        let elapsed = match self.last_update {
61            Some(last) => now.saturating_duration_since(last),
62            None => {
63                self.last_update = Some(now);
64                return self.value_ms;
65            }
66        };
67        self.last_update = Some(now);
68
69        let magnitude = estimate_ms.abs();
70
71        // A wild sample is not evidence about where the threshold belongs; it is evidence that
72        // something transient happened. Letting it drag the threshold up would blind the detector
73        // to the sustained growth that follows.
74        if magnitude > self.value_ms + 15.0 {
75            return self.value_ms;
76        }
77
78        let gain = if magnitude > self.value_ms {
79            self.increase_gain
80        } else {
81            self.decrease_gain
82        };
83
84        // Time-scaled, and capped: a long gap between reports must not move the threshold by an
85        // unbounded amount.
86        let elapsed_ms = elapsed.as_secs_f64() * 1_000.0;
87        let step = gain * (magnitude - self.value_ms) * elapsed_ms.min(100.0);
88        self.value_ms = (self.value_ms + step).clamp(6.0, 600.0);
89
90        self.value_ms
91    }
92
93    /// How long since this threshold was last updated, if ever.
94    pub fn since_update(&self, now: Instant) -> Option<Duration> {
95        self.last_update
96            .map(|last| now.saturating_duration_since(last))
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn it_starts_at_the_drafts_value() {
106        assert_eq!(DEFAULT_INITIAL_MS, AdaptiveThreshold::new().value_ms());
107    }
108
109    /// A trend that stays outside pushes the threshold **up**, so a persistently jittery path stops
110    /// being read as persistently congested.
111    #[test]
112    fn a_trend_outside_the_threshold_raises_it() {
113        let epoch = Instant::now();
114        let mut threshold = AdaptiveThreshold::new();
115        threshold.update(epoch, 20.0);
116
117        for step in 1..=50u64 {
118            threshold.update(epoch + Duration::from_millis(step * 20), 20.0);
119        }
120
121        assert!(
122            threshold.value_ms() > DEFAULT_INITIAL_MS,
123            "threshold should have risen, got {}",
124            threshold.value_ms()
125        );
126    }
127
128    /// A trend inside brings it back **down**, so sensitivity returns once the path settles.
129    #[test]
130    fn a_trend_inside_the_threshold_lowers_it() {
131        let epoch = Instant::now();
132        let mut threshold = AdaptiveThreshold::new();
133        threshold.update(epoch, 0.0);
134
135        for step in 1..=500u64 {
136            threshold.update(epoch + Duration::from_millis(step * 20), 0.0);
137        }
138
139        assert!(
140            threshold.value_ms() < DEFAULT_INITIAL_MS,
141            "threshold should have fallen, got {}",
142            threshold.value_ms()
143        );
144    }
145
146    /// Up faster than down: the threshold yields quickly to overuse and returns to sensitivity
147    /// slowly. Symmetric gains would make it oscillate with the very signal it is judging.
148    #[test]
149    fn it_rises_faster_than_it_falls() {
150        let epoch = Instant::now();
151
152        let mut rising = AdaptiveThreshold::new();
153        rising.update(epoch, 25.0);
154        let mut falling = AdaptiveThreshold::new();
155        falling.update(epoch, 0.0);
156
157        for step in 1..=25u64 {
158            let at = epoch + Duration::from_millis(step * 20);
159            rising.update(at, 25.0);
160            falling.update(at, 0.0);
161        }
162
163        let rose = rising.value_ms() - DEFAULT_INITIAL_MS;
164        let fell = DEFAULT_INITIAL_MS - falling.value_ms();
165        assert!(
166            rose > fell,
167            "K_u must exceed K_d: rose by {rose}, fell by {fell}"
168        );
169    }
170
171    /// A single wild sample is transient, not evidence. Letting it drag the threshold up would
172    /// blind the detector to sustained growth immediately afterwards.
173    #[test]
174    fn an_outlier_does_not_move_it() {
175        let epoch = Instant::now();
176        let mut threshold = AdaptiveThreshold::new();
177        threshold.update(epoch, 0.0);
178
179        let before = threshold.value_ms();
180        threshold.update(epoch + Duration::from_millis(20), 5_000.0);
181
182        assert_eq!(
183            before,
184            threshold.value_ms(),
185            "an absurd sample must be ignored, not absorbed"
186        );
187    }
188
189    /// Bounded at both ends, so neither a long quiet spell nor a long storm drives it somewhere it
190    /// cannot come back from.
191    #[test]
192    fn it_stays_within_bounds() {
193        let epoch = Instant::now();
194
195        let mut low = AdaptiveThreshold::new();
196        low.update(epoch, 0.0);
197        for step in 1..=100_000u64 {
198            low.update(epoch + Duration::from_millis(step * 20), 0.0);
199        }
200        assert!(low.value_ms() >= 6.0, "floor breached: {}", low.value_ms());
201
202        let mut high = AdaptiveThreshold::new();
203        high.update(epoch, 0.0);
204        for step in 1..=100_000u64 {
205            // Just outside the current threshold each time, so it is never treated as an outlier.
206            let target = high.value_ms() + 1.0;
207            high.update(epoch + Duration::from_millis(step * 20), target);
208        }
209        assert!(
210            high.value_ms() <= 600.0,
211            "ceiling breached: {}",
212            high.value_ms()
213        );
214    }
215}