Skip to main content

seyd_qos/
abr.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Closed-loop rate control inside the QoS ceiling (PLAN.md §1.3).
4//!
5//! A pure, deterministic controller stepped once per second with what the
6//! transport and the pilot measured. It never exceeds the profile ceiling and
7//! never goes below 25 % of it; it moves the *request* to the publisher
8//! (`video-config`) and Seyd's own FEC rates.
9//!
10//! Rules, in priority order:
11//!
12//! 1. **FEC follows measured loss, burst-first.** Cellular loss arrives in
13//!    bursts, and a block of 8 data chunks with `k = 1` (the 15 % default)
14//!    dies to any burst of two. So the first response to *any* measurable
15//!    loss (≥ 0.2 % over the last 5 s) is `k = 2` (25 %), then `k = 3` at
16//!    ≥ 2 % (38 %) and `k = 4` at ≥ 5 % (50 %). Keyframe parity is at least
17//!    delta + 10 (they are larger and losing one costs a GOP). FEC steps up
18//!    immediately and steps down one level only after 5 clean seconds.
19//! 2. **Raising FEC is paid for by the video rate.** The link constrains
20//!    total bytes, so the bitrate request is scaled by
21//!    `(1 + profile_fec) / (1 + fec)` — the on-wire budget stays what the
22//!    profile promised.
23//! 3. **Residual loss (≥ 2 frames/s genuinely lost *after* FEC — timed-out
24//!    frames whose chunks arrived late are jitter and are not counted) without any latency
25//!    signal is a loss problem, not a congestion problem:** step FEC up one
26//!    level instead of cutting the rate (measured: cutting on residual loss
27//!    alone sawtooths 25 % down / 10 % up forever on a lossy-but-uncongested
28//!    link). Only when FEC is already at its top level does residual loss
29//!    cut the bitrate.
30//! 4. **Congestion: AIMD with latency gating.** A backlog drop is the primary
31//!    signal and cuts 25 % at once. Latency is secondary: the median of the
32//!    last 3 RTT samples must exceed `min_rtt + budget/2` for 2 consecutive
33//!    seconds, and the sample right after a keyframe went out is ignored
34//!    (on a ~1.5 Mbps uplink a 45 KB IDR is 240 ms of queue by itself —
35//!    measured as 80–94 ms RTT spikes that used to trigger cuts). Raise 10 %
36//!    after 5 consecutive clean seconds. Cuts are at most one per 5 s.
37//! 5. **Emission hysteresis.** A bitrate decision is emitted at most every
38//!    5 s and only for ≥ 10 % changes (encoders hate churn); FEC changes are
39//!    emitted as soon as they happen; FEC steps down one level only after
40//!    10 clean seconds. A `steady` decision never moves the bitrate: the
41//!    reason that moved the target is carried until it is emitted.
42//! 6. **At the floor and still congested for 5 s → `suggested_fps = 15`.**
43
44use crate::Profile;
45use std::collections::VecDeque;
46
47#[derive(Debug, Clone, Copy, Default, PartialEq)]
48pub struct Sample {
49    pub rtt_ms: f64,
50    pub min_rtt_ms: f64,
51    /// Transport transmit rate over the last second.
52    pub delivery_kbps: u64,
53    pub lost_packets_delta: u64,
54    pub frames_dropped_backlog_delta: u64,
55    /// Chunk loss measured by pairing the agent's `chunks_sent` with the
56    /// pilot's `chunksRx` over the last window, in percent. `None` until the
57    /// pilot has reported.
58    pub pilot_chunk_loss_pct: Option<f64>,
59    /// Frames the pilot could not reconstruct even with parity — genuinely
60    /// incomplete ones only (timed-out frames excluded by the caller).
61    pub pilot_frames_incomplete_delta: u64,
62    pub send_kbps: u64,
63    /// A keyframe left the sender within the last second (RTT sample is
64    /// polluted by our own burst).
65    pub keyframe_just_sent: bool,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Decision {
70    pub max_bitrate_kbps: u32,
71    pub fec_delta_pct: u32,
72    pub fec_key_pct: u32,
73    pub suggested_fps: u32,
74    /// Why this decision was taken: `loss`, `latency`, `backlog`, `residual`,
75    /// `recover`, `fec-down`, `steady`.
76    pub reason: &'static str,
77    /// The bitrate moved (the publisher should be told).
78    pub bitrate_changed: bool,
79    /// The FEC rates moved (the sender applies them at the next frame).
80    pub fec_changed: bool,
81}
82
83const FEC_LEVELS: [u32; 4] = [0, 25, 38, 50]; // index 0 = the profile's own value
84const EMIT_INTERVAL_S: u64 = 5;
85const CLEAN_SECONDS_TO_RAISE: u32 = 5;
86const CLEAN_SECONDS_TO_LOWER_FEC: u32 = 10;
87const LATENCY_SECONDS_TO_CUT: u32 = 2;
88const FLOOR_FRACTION: f64 = 0.25;
89
90/// The profile's ceiling, lowered to the publisher's own maximum when the
91/// host declared one (0 = none).
92pub fn effective_ceiling_kbps(profile: &Profile, publisher_max_kbps: u32) -> u32 {
93    if publisher_max_kbps == 0 {
94        profile.max_bitrate_kbps
95    } else {
96        profile.max_bitrate_kbps.min(publisher_max_kbps)
97    }
98}
99
100#[derive(Debug, Clone)]
101pub struct AbrController {
102    profile: &'static Profile,
103    /// The top of the range: the profile's ceiling, lowered to what the
104    /// publisher can actually produce when the host declared that.
105    ceiling_kbps: u32,
106    tick: u64,
107    /// Current bitrate target before the FEC budget scaling.
108    target_kbps: f64,
109    fec_level: usize,
110    clean_seconds: u32,
111    clean_seconds_fec: u32,
112    congested_at_floor_seconds: u32,
113    last_cut_tick: Option<u64>,
114    last_emit_tick: Option<u64>,
115    last_emitted_kbps: u32,
116    loss_window: VecDeque<f64>,
117    rtt_window: VecDeque<f64>,
118    latency_seconds: u32,
119    /// Reason of the last target move not yet emitted.
120    pending_reason: Option<&'static str>,
121    current: Decision,
122}
123
124impl AbrController {
125    pub fn new(profile: &'static Profile) -> Self {
126        Self::with_ceiling(profile, 0)
127    }
128
129    /// A controller whose range tops out at `publisher_max_kbps` when that is
130    /// below the profile's ceiling (0 = no limit of the publisher's own).
131    ///
132    /// A publisher that cannot encode faster than some rate makes every
133    /// request above it a no-op: the controller would "raise" a bitrate that
134    /// never moves, and its floor — a fraction of the ceiling — would sit
135    /// higher than it should. Stating the publisher's limit keeps the whole
136    /// range real.
137    pub fn with_ceiling(profile: &'static Profile, publisher_max_kbps: u32) -> Self {
138        let ceiling_kbps = effective_ceiling_kbps(profile, publisher_max_kbps);
139        let d = Decision {
140            max_bitrate_kbps: ceiling_kbps,
141            fec_delta_pct: profile.fec_delta_pct,
142            fec_key_pct: profile.fec_key_pct,
143            suggested_fps: 0,
144            reason: "steady",
145            bitrate_changed: false,
146            fec_changed: false,
147        };
148        Self {
149            profile,
150            ceiling_kbps,
151            tick: 0,
152            target_kbps: ceiling_kbps as f64,
153            fec_level: 0,
154            clean_seconds: 0,
155            clean_seconds_fec: 0,
156            congested_at_floor_seconds: 0,
157            last_cut_tick: None,
158            last_emit_tick: None,
159            last_emitted_kbps: ceiling_kbps,
160            loss_window: VecDeque::new(),
161            rtt_window: VecDeque::new(),
162            latency_seconds: 0,
163            pending_reason: None,
164            current: d,
165        }
166    }
167
168    pub fn profile(&self) -> &'static Profile {
169        self.profile
170    }
171
172    /// The decision in force (whatever was last emitted, plus live FEC).
173    pub fn current(&self) -> Decision {
174        self.current
175    }
176
177    fn fec_for_level(&self, level: usize) -> (u32, u32) {
178        let delta = if level == 0 {
179            self.profile.fec_delta_pct
180        } else {
181            FEC_LEVELS[level].max(self.profile.fec_delta_pct)
182        };
183        let key = self.profile.fec_key_pct.max(delta + 10).min(50);
184        (delta, key)
185    }
186
187    fn floor_kbps(&self) -> f64 {
188        (self.ceiling_kbps as f64 * FLOOR_FRACTION).round()
189    }
190
191    /// Step once per second. Returns `Some` when something should be applied.
192    pub fn step(&mut self, s: &Sample) -> Option<Decision> {
193        self.tick += 1;
194        let ceiling = self.ceiling_kbps as f64;
195
196        // ── loss over the last 5 s ─────────────────────────────────────────
197        let loss_now = s.pilot_chunk_loss_pct.unwrap_or(0.0).max(0.0);
198        self.loss_window.push_back(loss_now);
199        if self.loss_window.len() > 5 {
200            self.loss_window.pop_front();
201        }
202        // Windowed average only: a single-sample spike (a keyframe burst at
203        // the measurement boundary) must not move the FEC level by itself.
204        let loss_signal = self.loss_window.iter().sum::<f64>() / self.loss_window.len() as f64;
205
206        // ── FEC level from loss (up immediately, down after clean seconds) ──
207        let wanted_level = if loss_signal >= 5.0 {
208            3
209        } else if loss_signal >= 2.0 {
210            2
211        } else if loss_signal >= 0.2 {
212            1
213        } else {
214            0
215        };
216        let mut fec_changed = false;
217        let mut reason: &'static str = "steady";
218        if wanted_level > self.fec_level {
219            self.fec_level = wanted_level;
220            self.clean_seconds_fec = 0;
221            fec_changed = true;
222            reason = "loss";
223            // The budget move that pays for the parity is emitted at the
224            // next emission slot even if it is under 10 %.
225            self.pending_reason = Some("loss");
226        } else if wanted_level < self.fec_level {
227            self.clean_seconds_fec += 1;
228            if self.clean_seconds_fec >= CLEAN_SECONDS_TO_LOWER_FEC {
229                self.fec_level -= 1;
230                self.clean_seconds_fec = 0;
231                fec_changed = true;
232                reason = "fec-down";
233            }
234        } else {
235            self.clean_seconds_fec = 0;
236        }
237
238        // ── congestion signals ─────────────────────────────────────────────
239        // Smoothed RTT: median of the last 3 samples, skipping the sample
240        // right after our own keyframe burst.
241        if !s.keyframe_just_sent {
242            self.rtt_window.push_back(s.rtt_ms);
243            if self.rtt_window.len() > 3 {
244                self.rtt_window.pop_front();
245            }
246        }
247        let rtt_smoothed = {
248            let mut v: Vec<f64> = self.rtt_window.iter().cloned().collect();
249            v.sort_by(|a, b| a.partial_cmp(b).unwrap());
250            v.get(v.len() / 2).cloned().unwrap_or(s.rtt_ms)
251        };
252        let inflated_now = s.min_rtt_ms > 0.0
253            && !s.keyframe_just_sent
254            && (rtt_smoothed - s.min_rtt_ms) > self.profile.latency_budget_ms as f64 / 2.0;
255        if inflated_now {
256            self.latency_seconds += 1;
257        } else {
258            self.latency_seconds = 0;
259        }
260        let latency_inflated = self.latency_seconds >= LATENCY_SECONDS_TO_CUT;
261        let backlog = s.frames_dropped_backlog_delta > 0;
262        // ≥ 2 genuinely incomplete frames in one second: a single lost frame is
263        // what the loss-driven FEC level already prices in; reacting to it
264        // sawtooths the bitrate (measured on the sim at a steady 3 % loss).
265        let residual = s.pilot_frames_incomplete_delta >= 2;
266        // Residual loss with a healthy queue: more parity, not less video.
267        if residual && !latency_inflated && !backlog && self.fec_level < FEC_LEVELS.len() - 1 {
268            self.fec_level += 1;
269            self.clean_seconds_fec = 0;
270            fec_changed = true;
271            reason = "residual";
272            self.pending_reason = Some("residual");
273        }
274        let residual_cuts = residual && self.fec_level == FEC_LEVELS.len() - 1;
275        let congested = latency_inflated || backlog || residual_cuts;
276
277        if congested {
278            self.clean_seconds = 0;
279            let can_cut = self
280                .last_cut_tick
281                .map_or(true, |t| self.tick - t >= EMIT_INTERVAL_S);
282            if can_cut && self.target_kbps > self.floor_kbps() {
283                self.target_kbps = (self.target_kbps * 0.75).max(self.floor_kbps());
284                self.last_cut_tick = Some(self.tick);
285                self.latency_seconds = 0;
286                reason = if backlog {
287                    "backlog"
288                } else if latency_inflated {
289                    "latency"
290                } else {
291                    "residual"
292                };
293                self.pending_reason = Some(reason);
294            }
295            if self.target_kbps <= self.floor_kbps() {
296                self.congested_at_floor_seconds += 1;
297            }
298        } else {
299            self.congested_at_floor_seconds = 0;
300            self.clean_seconds += 1;
301            if self.clean_seconds >= CLEAN_SECONDS_TO_RAISE && self.target_kbps < ceiling {
302                self.target_kbps = (self.target_kbps * 1.10).min(ceiling);
303                self.clean_seconds = 0;
304                if reason == "steady" {
305                    reason = "recover";
306                }
307                if self.pending_reason.is_none() {
308                    self.pending_reason = Some("recover");
309                }
310            }
311        }
312
313        // ── budget: pay for parity out of the video rate ───────────────────
314        let (fec_delta, fec_key) = self.fec_for_level(self.fec_level);
315        let budget_scale = (100.0 + self.profile.fec_delta_pct as f64) / (100.0 + fec_delta as f64);
316        let bitrate = (self.target_kbps * budget_scale)
317            .clamp(self.floor_kbps(), ceiling)
318            .round() as u32;
319        let suggested_fps = if self.congested_at_floor_seconds >= 5 {
320            15
321        } else {
322            0
323        };
324
325        // ── emission hysteresis ────────────────────────────────────────────
326        let delta_pct = (bitrate as f64 - self.last_emitted_kbps as f64).abs()
327            / self.last_emitted_kbps.max(1) as f64;
328        let emit_ok = self
329            .last_emit_tick
330            .map_or(true, |t| self.tick - t >= EMIT_INTERVAL_S);
331        // ≥10 % moves, plus the exact ceiling/floor and any budget move caused
332        // by a FEC change (that one is small by construction, ~8 %, but it is
333        // what keeps the on-wire total inside the profile).
334        let bitrate_changed = emit_ok
335            && bitrate != self.last_emitted_kbps
336            && (delta_pct >= 0.10
337                || fec_changed
338                || self.pending_reason.is_some()
339                || bitrate == ceiling as u32
340                || bitrate == self.floor_kbps() as u32);
341        if bitrate_changed {
342            self.last_emitted_kbps = bitrate;
343            self.last_emit_tick = Some(self.tick);
344            // A bitrate move always carries the reason that caused it.
345            if reason == "steady" {
346                reason = self.pending_reason.unwrap_or("recover");
347            }
348            self.pending_reason = None;
349        }
350        let fps_changed = suggested_fps != self.current.suggested_fps;
351
352        if bitrate_changed || fec_changed || fps_changed {
353            self.current = Decision {
354                max_bitrate_kbps: self.last_emitted_kbps,
355                fec_delta_pct: fec_delta,
356                fec_key_pct: fec_key,
357                suggested_fps,
358                reason,
359                bitrate_changed: bitrate_changed || fps_changed,
360                fec_changed,
361            };
362            Some(self.current)
363        } else {
364            self.current.reason = reason;
365            self.current.bitrate_changed = false;
366            self.current.fec_changed = false;
367            None
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::BALANCED;
376
377    fn clean() -> Sample {
378        Sample {
379            rtt_ms: 25.0,
380            min_rtt_ms: 24.0,
381            delivery_kbps: 3000,
382            send_kbps: 3000,
383            pilot_chunk_loss_pct: Some(0.0),
384            ..Default::default()
385        }
386    }
387
388    fn run(c: &mut AbrController, samples: impl IntoIterator<Item = Sample>) -> Vec<Decision> {
389        samples.into_iter().filter_map(|s| c.step(&s)).collect()
390    }
391
392    #[test]
393    fn a_publisher_limit_lowers_the_ceiling_and_the_floor() {
394        // quality asks for 6000; this publisher tops out at 4000.
395        let c = AbrController::with_ceiling(&crate::QUALITY, 4000);
396        assert_eq!(c.current().max_bitrate_kbps, 4000);
397        assert_eq!(c.floor_kbps(), 1000.0);
398        // A limit above the profile changes nothing, and 0 means none.
399        assert_eq!(
400            AbrController::with_ceiling(&crate::LATENCY, 4000)
401                .current()
402                .max_bitrate_kbps,
403            1500
404        );
405        assert_eq!(
406            AbrController::with_ceiling(&crate::QUALITY, 0)
407                .current()
408                .max_bitrate_kbps,
409            6000
410        );
411    }
412
413    #[test]
414    fn clean_link_stays_at_ceiling() {
415        let mut c = AbrController::new(&BALANCED);
416        let out = run(&mut c, std::iter::repeat(clean()).take(60));
417        assert!(out.is_empty(), "no decisions on a clean link: {out:?}");
418        assert_eq!(c.current().max_bitrate_kbps, 3000);
419        assert_eq!(c.current().fec_delta_pct, 15);
420    }
421
422    #[test]
423    fn sustained_loss_raises_fec_and_lowers_bitrate() {
424        let mut c = AbrController::new(&BALANCED);
425        let lossy = Sample {
426            pilot_chunk_loss_pct: Some(1.0),
427            ..clean()
428        };
429        let out = run(&mut c, std::iter::repeat(lossy).take(10));
430        let first = out.first().expect("a decision");
431        assert_eq!(first.fec_delta_pct, 25, "k=2 at any measurable loss");
432        assert!(first.fec_key_pct >= 35);
433        assert_eq!(first.reason, "loss");
434        // 3000 × 1.15 / 1.25 = 2760
435        assert_eq!(c.current().max_bitrate_kbps, 2760);
436        // and it recovers after clean seconds
437        let out = run(&mut c, std::iter::repeat(clean()).take(40));
438        assert!(out.iter().any(|d| d.reason == "fec-down"));
439        assert_eq!(c.current().fec_delta_pct, 15);
440        assert_eq!(c.current().max_bitrate_kbps, 3000);
441    }
442
443    #[test]
444    fn heavy_loss_reaches_top_fec_level() {
445        let mut c = AbrController::new(&BALANCED);
446        let bad = Sample {
447            pilot_chunk_loss_pct: Some(6.0),
448            ..clean()
449        };
450        run(&mut c, std::iter::repeat(bad).take(3));
451        assert_eq!(c.current().fec_delta_pct, 50);
452        assert_eq!(c.current().fec_key_pct, 50);
453    }
454
455    #[test]
456    fn rtt_inflation_cuts_then_recovers() {
457        let mut c = AbrController::new(&BALANCED);
458        let inflated = Sample {
459            rtt_ms: 90.0,
460            min_rtt_ms: 24.0,
461            ..clean()
462        };
463        let out = run(&mut c, std::iter::repeat(inflated).take(12));
464        assert!(out
465            .iter()
466            .any(|d| d.reason == "latency" && d.bitrate_changed));
467        let low = c.current().max_bitrate_kbps;
468        assert!(
469            (750..=2250).contains(&low),
470            "one cut per 5 s, never below the floor: {low}"
471        );
472        // a single inflated sample (keyframe burst) must not cut
473        let mut c2 = AbrController::new(&BALANCED);
474        let mut seq = vec![clean(); 5];
475        seq.push(Sample {
476            rtt_ms: 90.0,
477            min_rtt_ms: 24.0,
478            ..clean()
479        });
480        seq.extend(vec![clean(); 5]);
481        assert!(
482            run(&mut c2, seq).is_empty(),
483            "one RTT spike is not congestion"
484        );
485        let mut c3 = AbrController::new(&BALANCED);
486        let kf = Sample {
487            rtt_ms: 90.0,
488            min_rtt_ms: 24.0,
489            keyframe_just_sent: true,
490            ..clean()
491        };
492        assert!(
493            run(&mut c3, vec![kf; 10]).is_empty(),
494            "keyframe-polluted samples are ignored"
495        );
496        let out = run(&mut c, std::iter::repeat(clean()).take(120));
497        assert!(out.iter().any(|d| d.reason == "recover"));
498        assert!(
499            out.iter()
500                .all(|d| d.reason != "steady" || !d.bitrate_changed),
501            "steady never moves bitrate"
502        );
503        assert_eq!(c.current().max_bitrate_kbps, 3000);
504    }
505
506    #[test]
507    fn floor_and_suggested_fps() {
508        let mut c = AbrController::new(&BALANCED);
509        let backlog = Sample {
510            frames_dropped_backlog_delta: 2,
511            ..clean()
512        };
513        run(&mut c, std::iter::repeat(backlog).take(30));
514        assert_eq!(c.current().max_bitrate_kbps, 750);
515        assert_eq!(c.current().suggested_fps, 15);
516    }
517
518    #[test]
519    fn noisy_but_fine_link_does_not_oscillate() {
520        let mut c = AbrController::new(&BALANCED);
521        // rtt jitters ±15 ms around a 24 ms floor (budget/2 = 50 ms), loss flickers at 0.1 %
522        let samples = (0..120).map(|i| Sample {
523            rtt_ms: 24.0 + ((i * 7) % 30) as f64,
524            pilot_chunk_loss_pct: Some(if i % 9 == 0 { 0.1 } else { 0.0 }),
525            ..clean()
526        });
527        let out = run(&mut c, samples);
528        assert!(out.is_empty(), "spurious decisions: {out:?}");
529        assert_eq!(c.current().max_bitrate_kbps, 3000);
530    }
531
532    #[test]
533    fn residual_loss_raises_fec_before_cutting_bitrate() {
534        let mut c = AbrController::new(&BALANCED);
535        let residual = Sample {
536            pilot_frames_incomplete_delta: 2,
537            ..clean()
538        };
539        let mut out = run(&mut c, [residual]);
540        let d = out.pop().unwrap();
541        assert_eq!((d.reason, d.fec_delta_pct), ("residual", 25));
542        assert_eq!(d.max_bitrate_kbps, 2760, "budget scaling only, no 25 % cut");
543        // keeps stepping FEC up to the top, then cuts
544        run(&mut c, std::iter::repeat(residual).take(2));
545        assert_eq!(c.current().fec_delta_pct, 50);
546        run(&mut c, std::iter::repeat(residual).take(3));
547        assert!(
548            c.current().max_bitrate_kbps < 2300,
549            "cut once FEC is maxed: {}",
550            c.current().max_bitrate_kbps
551        );
552    }
553
554    #[test]
555    fn emission_needs_ten_percent() {
556        let mut c = AbrController::new(&BALANCED);
557        // one backlog second: cut 25 % → 2250 emitted; then clean: +10 % steps
558        let mut out = run(
559            &mut c,
560            [Sample {
561                frames_dropped_backlog_delta: 1,
562                ..clean()
563            }],
564        );
565        assert_eq!(out.pop().unwrap().max_bitrate_kbps, 2250);
566        let out = run(&mut c, std::iter::repeat(clean()).take(20));
567        assert!(out
568            .iter()
569            .all(|d| d.max_bitrate_kbps >= 2475 && d.reason == "recover"));
570    }
571}