Skip to main content

seyd_qos/
simulcast.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Which encoding to relay, given how many bits the link will carry.
4//!
5//! The `AbrController` decides *how many bits*; this decides *which of the
6//! publisher's encodings fits them* (ADR 0008). The two are deliberately
7//! separate: a robot that publishes one layer runs the controller unchanged and
8//! never consults this module.
9//!
10//! Pure and allocation-light: no I/O, no clock. The caller steps it once per
11//! second with the controller's current target, exactly as it steps the ABR.
12
13use serde::{Deserialize, Serialize};
14
15/// How far above a layer's activation point the target must sit before we climb
16/// onto it. A layer change costs the pilot a decoder reconfigure, so buying the
17/// rung outright beats oscillating around its edge.
18const UP_MARGIN_PCT: u32 = 25;
19
20/// Seconds the target must stay *continuously* affordable before climbing.
21///
22/// A dwell timer alone is not enough. Measured against the real ABR under
23/// sustained loss, the controller sawtooths — cutting on congestion, recovering
24/// 10 % per 5 clean seconds — so the target crosses a rung's threshold every
25/// cycle. Sampling "is it affordable now?" once a dwell expires climbs on that
26/// transient peak and drops again seconds later, costing the pilot a decoder
27/// reconfigure each way.
28///
29/// Downward moves are immediate: sitting a rung too high under congestion is
30/// what tears frames.
31const UP_HOLD_S: u32 = 10;
32
33/// How far the target must beat a rung's *proven insufficient* level to be worth
34/// trying again.
35const RETRY_MARGIN_PCT: u32 = 10;
36
37/// Seconds without a demotion after which what we learned is forgotten and the
38/// ladder probes upward again.
39///
40/// Probing has to happen: a link that genuinely recovers must get its picture
41/// back, and the only way to discover that is to try. But a failed probe costs
42/// two switches, so it is deliberately rare.
43const FORGIVE_S: u32 = 600;
44
45/// One independently encoded stream of a video channel's picture.
46///
47/// `activate_above_kbps` is the ABR target at or above which this layer is the
48/// right choice. The lowest layer is the base and is always eligible, whatever
49/// its value, so a link below every activation point still gets video.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct VideoLayer {
52    pub id: u8,
53    pub name: String,
54    #[serde(default)]
55    pub activate_above_kbps: u32,
56}
57
58/// A change of layer, and why. Returned only when the layer actually moves.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct Selection {
61    pub layer: u8,
62    pub reason: &'static str,
63}
64
65/// Stateful chooser over a fixed set of layers.
66///
67/// Selection and the ABR form a closed loop: relaying a higher rung puts more
68/// bytes on the wire, so on a constrained link the controller cuts, which
69/// demotes the rung, which frees the link and lets the target recover — and the
70/// cycle repeats. Measured on the demo camera under 12 % loss it ran every
71/// 10-40 s indefinitely. Neither a hold nor a backoff damps it on its own,
72/// because the cycle is *caused by the switch*, so the selector remembers the
73/// target at which each rung failed and refuses to climb back for less.
74#[derive(Debug, Clone)]
75pub struct LayerSelector {
76    /// Sorted by `activate_above_kbps` ascending; index 0 is the base layer.
77    layers: Vec<VideoLayer>,
78    current: usize,
79    /// Consecutive seconds the next rung up has been affordable.
80    above_s: u32,
81    /// Per rung: the highest target that turned out not to sustain it. 0 until
82    /// a climb onto that rung has actually failed.
83    insufficient: Vec<u32>,
84    /// Per rung: the target at which we last climbed onto it, so a demotion
85    /// knows what to write into `insufficient`. 0 means "never climbed onto",
86    /// which includes the rung the selector was constructed on — the ABR simply
87    /// starts optimistic there, so failing to hold it proves nothing about the
88    /// link and must not be learned from.
89    climbed_at: Vec<u32>,
90    /// Seconds since the last demotion, for forgiving what we learned.
91    settled_s: u32,
92}
93
94impl LayerSelector {
95    /// Build a selector over `layers`, settled at whichever layer `target_kbps`
96    /// affords. Starting where the link already is avoids a gratuitous switch in
97    /// the first seconds of a session.
98    ///
99    /// Returns `None` if no layer was declared; a single layer is fine and makes
100    /// `step` a no-op forever.
101    pub fn new(mut layers: Vec<VideoLayer>, target_kbps: u32) -> Option<Self> {
102        if layers.is_empty() {
103            return None;
104        }
105        layers.sort_by_key(|l| l.activate_above_kbps);
106        let current = Self::affordable(&layers, target_kbps);
107        let n = layers.len();
108        Some(Self {
109            layers,
110            current,
111            above_s: 0,
112            insufficient: vec![0; n],
113            climbed_at: vec![0; n],
114            settled_s: 0,
115        })
116    }
117
118    /// Highest layer whose activation point the target meets. Index 0 always
119    /// qualifies, so this never fails to pick something.
120    fn affordable(layers: &[VideoLayer], target_kbps: u32) -> usize {
121        layers
122            .iter()
123            .rposition(|l| target_kbps >= l.activate_above_kbps)
124            .unwrap_or(0)
125    }
126
127    pub fn current(&self) -> u8 {
128        self.layers[self.current].id
129    }
130
131    pub fn current_name(&self) -> &str {
132        &self.layers[self.current].name
133    }
134
135    pub fn layers(&self) -> &[VideoLayer] {
136        &self.layers
137    }
138
139    /// What the target must reach to climb onto `rung`: its declared activation
140    /// point plus a margin, and never less than a level already proven not to
141    /// hold it.
142    fn threshold(&self, rung: usize) -> u32 {
143        let declared = self.layers[rung]
144            .activate_above_kbps
145            .saturating_mul(100 + UP_MARGIN_PCT)
146            / 100;
147        let learned = self.insufficient[rung].saturating_mul(100 + RETRY_MARGIN_PCT) / 100;
148        declared.max(learned)
149    }
150
151    /// Step once per second with the ABR's current target. `Some` when the layer
152    /// changed and the host should switch.
153    pub fn step(&mut self, target_kbps: u32) -> Option<Selection> {
154        if self.layers.len() < 2 {
155            return None;
156        }
157        self.settled_s = self.settled_s.saturating_add(1);
158        if self.settled_s >= FORGIVE_S {
159            self.insufficient.iter_mut().for_each(|v| *v = 0);
160            self.settled_s = 0;
161        }
162
163        // Down: immediate. The current layer is unaffordable the moment the
164        // target falls below what it was declared to need.
165        if target_kbps < self.layers[self.current].activate_above_kbps {
166            self.above_s = 0;
167            let to = Self::affordable(&self.layers, target_kbps);
168            if to != self.current {
169                return Some(self.switch(to, "down"));
170            }
171            return None;
172        }
173
174        // Up: onto the next rung only, once the target has cleared its threshold
175        // for a whole window.
176        let next = self.current + 1;
177        if next >= self.layers.len() {
178            self.above_s = 0;
179            return None;
180        }
181        if target_kbps < self.threshold(next) {
182            self.above_s = 0;
183            return None;
184        }
185        self.above_s = self.above_s.saturating_add(1);
186        if self.above_s >= UP_HOLD_S {
187            self.climbed_at[next] = target_kbps;
188            return Some(self.switch(next, "up"));
189        }
190        None
191    }
192
193    fn switch(&mut self, to: usize, reason: &'static str) -> Selection {
194        if to < self.current {
195            // Coming back down means the target we climbed at was not enough to
196            // sustain this rung. Remember it, so the next climb needs better.
197            // Only if we actually climbed: a demotion off the starting rung says
198            // nothing, and treating the ABR's opening ceiling as "proven
199            // insufficient" would bar that rung for good.
200            let failed_at = self.climbed_at[self.current];
201            if failed_at > 0 {
202                self.insufficient[self.current] = self.insufficient[self.current].max(failed_at);
203            }
204            self.settled_s = 0;
205        }
206        self.current = to;
207        self.above_s = 0;
208        Selection {
209            layer: self.layers[to].id,
210            reason,
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn ladder() -> Vec<VideoLayer> {
220        vec![
221            VideoLayer {
222                id: 0,
223                name: "low".into(),
224                activate_above_kbps: 0,
225            },
226            VideoLayer {
227                id: 1,
228                name: "high".into(),
229                activate_above_kbps: 1800,
230            },
231        ]
232    }
233
234    #[test]
235    fn a_single_layer_never_switches() {
236        let mut s = LayerSelector::new(
237            vec![VideoLayer {
238                id: 0,
239                name: "only".into(),
240                activate_above_kbps: 0,
241            }],
242            3000,
243        )
244        .unwrap();
245        for kbps in [3000, 100, 5000, 0] {
246            assert_eq!(s.step(kbps), None);
247        }
248        assert_eq!(s.current(), 0);
249    }
250
251    #[test]
252    fn no_layers_is_not_a_selector() {
253        assert!(LayerSelector::new(vec![], 3000).is_none());
254    }
255
256    #[test]
257    fn starts_on_the_layer_the_link_already_affords() {
258        // A session opening on a good link should not begin low and climb.
259        assert_eq!(LayerSelector::new(ladder(), 3000).unwrap().current(), 1);
260        assert_eq!(LayerSelector::new(ladder(), 900).unwrap().current(), 0);
261    }
262
263    #[test]
264    fn drops_immediately_when_the_target_falls_below_the_rung() {
265        let mut s = LayerSelector::new(ladder(), 3000).unwrap();
266        let d = s.step(1200).expect("must drop at once, not after a dwell");
267        assert_eq!((d.layer, d.reason), (0, "down"));
268    }
269
270    #[test]
271    fn climbs_only_past_the_margin() {
272        let mut s = LayerSelector::new(ladder(), 500).unwrap();
273        // Exactly at the activation point is not enough: 1800 * 1.25 = 2250.
274        for _ in 0..UP_HOLD_S * 2 {
275            assert_eq!(s.step(2249), None);
276        }
277        for _ in 0..UP_HOLD_S - 1 {
278            assert_eq!(s.step(2250), None, "must hold the whole window first");
279        }
280        let u = s
281            .step(2250)
282            .expect("clears the margin for the whole window");
283        assert_eq!((u.layer, u.reason), (1, "up"));
284    }
285
286    #[test]
287    fn one_second_below_the_margin_restarts_the_window() {
288        let mut s = LayerSelector::new(ladder(), 500).unwrap();
289        for _ in 0..UP_HOLD_S - 1 {
290            s.step(3000);
291        }
292        assert_eq!(s.step(2000), None, "a dip resets the hold");
293        for _ in 0..UP_HOLD_S - 1 {
294            assert_eq!(s.step(3000), None);
295        }
296        assert!(s.step(3000).is_some());
297    }
298
299    #[test]
300    fn will_not_climb_straight_back_after_dropping() {
301        let mut s = LayerSelector::new(ladder(), 3000).unwrap();
302        s.step(1000).expect("drops to base");
303        // Plenty affordable straight away, but the window starts now.
304        assert_eq!(s.step(4000), None);
305    }
306
307    #[test]
308    fn the_abr_sawtooth_does_not_flap_the_ladder() {
309        // Measured on the demo camera at 12 % injected loss: the controller cuts
310        // on congestion and recovers 10 % per 5 clean seconds, so the target
311        // cycles roughly 1725 -> 2296 -> 1725 about every 15 s. Sampling
312        // affordability at one instant climbed on each peak and dropped again
313        // seconds later; the pilot paid a decoder reconfigure every time.
314        let cycle: Vec<u32> = [1725, 1897, 2087, 2296, 1722, 1894, 2083, 2291]
315            .iter()
316            .flat_map(|&k| std::iter::repeat(k).take(4))
317            .collect();
318        let mut s = LayerSelector::new(ladder(), 3000).unwrap();
319        let mut switches = 0;
320        for _ in 0..6 {
321            for &k in &cycle {
322                if s.step(k).is_some() {
323                    switches += 1;
324                }
325            }
326        }
327        // One drop onto the base rung, and then it stays there: no peak of the
328        // sawtooth is sustained long enough to buy the high rung back.
329        assert_eq!(switches, 1, "layer flapped on the ABR sawtooth");
330        assert_eq!(s.current(), 0);
331    }
332
333    #[test]
334    fn a_link_hovering_at_the_edge_does_not_flap() {
335        // The failure this guards against: one switch per second across the
336        // activation point, each costing the pilot a decoder reconfigure.
337        let mut s = LayerSelector::new(ladder(), 3000).unwrap();
338        let mut switches = 0;
339        for i in 0..60 {
340            let kbps = if i % 2 == 0 { 1750 } else { 1900 };
341            if s.step(kbps).is_some() {
342                switches += 1;
343            }
344        }
345        assert!(
346            switches <= 1,
347            "{switches} switches while hovering at the rung edge"
348        );
349    }
350
351    #[test]
352    fn the_switch_driven_feedback_loop_converges() {
353        // The loop measured on the demo camera under 12 % loss: while on the low
354        // rung the link looks healthy and the target climbs, but relaying the
355        // high rung puts enough extra on the wire that the controller cuts and
356        // demotes again. A fixed hold cannot damp this, because the cycle is
357        // caused by the switch itself. Backoff must make it settle.
358        let mut s = LayerSelector::new(ladder(), 3000).unwrap();
359        let mut switches = 0;
360        let (mut on_high_s, mut t) = (0u32, 0u32);
361        for _ in 0..2000 {
362            // Target recovers while low, collapses a few seconds after climbing.
363            let target = if s.current() == 1 {
364                on_high_s += 1;
365                if on_high_s > 4 {
366                    1725
367                } else {
368                    2300
369                }
370            } else {
371                on_high_s = 0;
372                2300
373            };
374            if s.step(target).is_some() {
375                switches += 1;
376            }
377            t += 1;
378        }
379        // Without backoff this cycled forever, roughly once every 15 s — about
380        // 130 switches over this run.
381        assert!(
382            switches <= 10,
383            "{switches} switches in {t}s: still flapping"
384        );
385        assert_eq!(
386            s.current(),
387            0,
388            "should settle on the rung the link sustains"
389        );
390    }
391
392    #[test]
393    fn a_demotion_off_the_starting_rung_teaches_nothing() {
394        // The selector opens on whatever the ABR's starting ceiling affords,
395        // without having proven the link carries it. Treating that as evidence
396        // barred the rung permanently: the learned threshold landed above the
397        // profile ceiling, so a fully recovered link stayed on the low rung.
398        // Measured on the demo camera — clean link, target back at 2300, still
399        // 640x480.
400        let mut s = LayerSelector::new(ladder(), 3000).unwrap();
401        assert_eq!(s.current(), 1);
402        s.step(0).expect("demote off the rung we started on");
403        for _ in 0..UP_HOLD_S - 1 {
404            assert_eq!(s.step(2300), None);
405        }
406        assert!(
407            s.step(2300).is_some(),
408            "a recovered link must climb back; nothing was proven by the opening demotion"
409        );
410    }
411
412    #[test]
413    fn a_link_that_recovers_is_eventually_probed_again() {
414        // Learning what failed must not be permanent: a link that genuinely
415        // improves has to get its picture back, and trying is the only way to
416        // find out.
417        let mut s = LayerSelector::new(ladder(), 500).unwrap();
418        for _ in 0..UP_HOLD_S {
419            s.step(3000);
420        }
421        assert_eq!(s.current(), 1, "a genuine climb, so the demotion teaches");
422        s.step(0).expect("demote, learning that 3000 did not hold");
423        // Straight away, even the same healthy target will not buy the rung back.
424        for _ in 0..UP_HOLD_S * 3 {
425            assert_eq!(s.step(3000), None);
426        }
427        // After the forgiveness window it probes upward again.
428        let mut climbed = false;
429        for _ in 0..FORGIVE_S + UP_HOLD_S + 2 {
430            if s.step(3000).is_some() {
431                climbed = true;
432            }
433        }
434        assert!(climbed, "a recovered link must eventually be probed");
435    }
436
437    #[test]
438    fn a_sustained_recovery_does_climb() {
439        let mut s = LayerSelector::new(ladder(), 500).unwrap();
440        let mut ended_high = false;
441        for _ in 0..30 {
442            if s.step(3000).is_some() {
443                ended_high = true;
444            }
445        }
446        assert!(ended_high && s.current() == 1);
447    }
448
449    #[test]
450    fn three_rungs_step_down_to_what_the_target_affords() {
451        let layers = vec![
452            VideoLayer {
453                id: 0,
454                name: "low".into(),
455                activate_above_kbps: 0,
456            },
457            VideoLayer {
458                id: 1,
459                name: "mid".into(),
460                activate_above_kbps: 1200,
461            },
462            VideoLayer {
463                id: 2,
464                name: "high".into(),
465                activate_above_kbps: 3000,
466            },
467        ];
468        let mut s = LayerSelector::new(layers, 4000).unwrap();
469        assert_eq!(s.current(), 2);
470        // A collapse to 1300 lands on mid, not all the way at the base.
471        assert_eq!(s.step(1300).unwrap().layer, 1);
472        assert_eq!(s.step(200).unwrap().layer, 0);
473    }
474}