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}