Skip to main content

subetha_cxc/
rlc_control.rs

1//! Adaptive control for the sliding-window RLC code: turn the fused channel
2//! assessment ([`crate::fusion::SensorSnapshot`]) into RLC coding parameters -
3//! the window size, the repair cadence (code rate), and the coefficient
4//! density - then hold them steady with immediate-up / conservative-down
5//! hysteresis so the knobs do not flap on a noisy channel.
6//!
7//! This is the RLC counterpart of [`crate::fusion::raw_target`], which maps the
8//! same assessment to block-RS `(parity_r, interleave_depth)`. Both consume
9//! [`crate::fusion::effective_loss`] and [`crate::fusion::is_clean`], so the two
10//! codes judge the channel identically and differ only in the parameter map:
11//!
12//!  - **Rate from loss (`R = T / (T + B)`).** One repair every `step` source
13//!    symbols is `T = step` source to `B = 1` repair, code rate
14//!    `R = step / (step + 1)` and redundancy `1 / (step + 1)`. To cover an
15//!    effective loss `p` with a safety margin `m`, the redundancy must satisfy
16//!    `1 / (step + 1) >= m * p`, i.e. `step <= 1 / (m * p) - 1`. Higher loss
17//!    shrinks `step` (more repairs, lower rate); lighter loss grows it toward
18//!    the lightest-protection cap. This is the QUIC-FEC adaptive-rate result:
19//!    the redundancy tracks the measured loss instead of a fixed code rate.
20//!  - **Window from burst length.** A repair over a window of `w` source
21//!    symbols, emitted every `step`, gives `w / step` repairs covering any one
22//!    symbol. Recovering a burst of `b` consecutive losses needs at least `b`
23//!    independent repairs spanning it, so `w >= b * step`. The mean burst length
24//!    is `burstiness * 16` (the [`crate::burst_model_sensor`] Gilbert-Elliott
25//!    fit, normalized into the snapshot), so the window scales with the real
26//!    fitted burst, not a fixed depth.
27//!  - **Density from burstiness / congestion.** A denser coefficient vector
28//!    (higher `dt`) makes each repair touch more source symbols, raising the
29//!    recovery probability per repair at more compute cost. Bursty or congested
30//!    loss leans dense; light isolated loss can stay sparser.
31//!  - **Disable-on-clean.** A provably-clean link ([`crate::fusion::is_clean`])
32//!    turns coding off entirely: QUIC-FEC found FEC *hurts* a clean / bulk path
33//!    (the redundancy is pure overhead and competes with the data for the
34//!    bottleneck), so the RLC path ships data only and leans on the ARQ floor
35//!    until the controller re-arms on the first sign of loss.
36
37use crate::fusion::{is_clean, SensorSnapshot};
38use crate::rlc_fec::DEFAULT_DT;
39
40/// Safety margin on the rate law: provision redundancy for `RATE_MARGIN` times
41/// the measured effective loss, so a momentary spike above the mean is still
42/// covered rather than NAK'd.
43/// Base redundancy margin on the rate law at near-zero round trip (a loopback /
44/// IPC path where a NAK is nearly free, so a light code that leans on ARQ is
45/// fine).
46const BASE_RATE_MARGIN: f32 = 1.4;
47/// How much the rate margin grows per millisecond of round trip. On a real
48/// network a NAK costs a round trip, so heavier FEC (a smaller step) that
49/// recovers in-window without a retransmit is worth the extra redundancy - the
50/// streaming-codes "provision FEC to the latency budget" result, which only
51/// shows up off-loopback (validated on the LAN: at near-zero RTT a light step=6
52/// beats static, but at LAN RTT it loses to the heavier static step=4).
53const RTT_MARGIN_SLOPE: f32 = 0.8;
54/// Ceiling on the rate margin, so a very high round trip does not drive the code
55/// to its heaviest rate for a modest loss.
56const MAX_RATE_MARGIN: f32 = 2.8;
57
58/// The rate-law redundancy margin for a measured round trip: base at near-zero
59/// RTT, growing with RTT (an expensive NAK is worth avoiding with more FEC).
60fn rate_margin_for_rtt(rtt_ms: f32) -> f32 {
61    (BASE_RATE_MARGIN + RTT_MARGIN_SLOPE * rtt_ms.max(0.0)).min(MAX_RATE_MARGIN)
62}
63
64/// Lightest protection: at most one repair per [`STEP_MAX`] source symbols
65/// (code rate `STEP_MAX / (STEP_MAX + 1)` ~= 0.94). Reached as loss approaches
66/// zero (but not clean, where coding turns off entirely).
67const STEP_MAX: u16 = 16;
68/// Heaviest protection: one repair per source symbol (code rate 1/2). Reached
69/// under very high effective loss.
70const STEP_MIN: u16 = 1;
71
72/// Smallest coding window: enough overlap for isolated-loss recovery.
73const WINDOW_MIN: u16 = 8;
74/// Largest coding window. Bounded so the decoder's Gaussian-elimination cost
75/// per repair stays small and the receiver's recovery horizon comfortably
76/// exceeds it.
77const WINDOW_MAX: u16 = 64;
78/// Window provisioning factor over the bare burst-span estimate `mean_burst *
79/// step`, so a burst slightly longer than the fitted mean is still in scope.
80const WINDOW_BURST_SAFETY: f32 = 1.5;
81
82/// Floor on effective loss for the rate law, so a barely-lossy-but-not-clean
83/// channel does not divide by zero (it clamps to the lightest protection
84/// anyway).
85const MIN_EFFECTIVE_LOSS: f32 = 1.0e-3;
86
87/// The RLC coding configuration the controller selects.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct RlcDecision {
90    /// Whether the RLC code is active at all. `false` is the disable-on-clean
91    /// state: ship source symbols only and lean on the ARQ floor.
92    pub coding_on: bool,
93    /// Sliding-window size: how many recent source symbols a repair spans.
94    pub window: u16,
95    /// Source symbols between repairs. Code rate is `step / (step + 1)`.
96    pub step: u16,
97    /// Coefficient density threshold (0..=15): each coefficient is nonzero with
98    /// probability `(dt + 1) / 16`.
99    pub dt: u8,
100}
101
102/// The RLC parameters the sensors call for at the base (near-zero-RTT) rate
103/// margin, before any hysteresis.
104pub fn rlc_target(s: &SensorSnapshot) -> RlcDecision {
105    rlc_target_with_margin(s, BASE_RATE_MARGIN)
106}
107
108/// The RLC parameters at an explicit rate-law `margin` (the controller scales it
109/// by the measured round trip; see `rate_margin_for_rtt`).
110pub fn rlc_target_with_margin(s: &SensorSnapshot, margin: f32) -> RlcDecision {
111    if is_clean(s) {
112        // Disable-on-clean: coding off, but keep nominal knobs so re-arming
113        // (which copies these) starts from a sane window / cadence.
114        return RlcDecision {
115            coding_on: false,
116            window: WINDOW_MIN,
117            step: STEP_MAX,
118            dt: DEFAULT_DT,
119        };
120    }
121    // The RLC FEC provisions its RATE against the measured LOSS RATE - the one
122    // channel signal that is reliably measured here (the Gilbert-Elliott fit's
123    // marginal loss). In principle congestion loss should bias the rate LIGHTER
124    // (QUIC-FEC: FEC on a congested/bulk path steals bottleneck bandwidth and
125    // deepens the queue), but acting on that needs a TRUSTWORTHY queue signal:
126    // this transport's one-way-trip time folds in the receiver's own decode
127    // backlog (it stamps arrival at process time and decodes synchronously), so
128    // under loss it reads a false congestion, and a false positive that lightened
129    // the rate would collapse recovery. So the congestion share is measured and
130    // reported but does NOT drive the rate; the rate tracks the loss rate, which
131    // is robust. Density and window carry the burst signal, which IS reliable.
132    let loss_rate = s.loss.max(MIN_EFFECTIVE_LOSS);
133    // Rate law R = T/(T+B): redundancy 1/(step+1) >= margin * loss_rate, so
134    // step <= 1/(margin * loss_rate) - 1. Heavier loss -> smaller step; a larger
135    // margin (a more expensive round trip) also shrinks step.
136    let step = ((1.0 / (margin.max(1.0) * loss_rate) - 1.0).floor() as i32)
137        .clamp(STEP_MIN as i32, STEP_MAX as i32) as u16;
138    // Window spans the fitted burst: w >= mean_burst * step, with a margin, and
139    // at least 2*step so consecutive repairs overlap.
140    let mean_burst = (s.burstiness * 16.0).max(1.0);
141    let span = (mean_burst * step as f32 * WINDOW_BURST_SAFETY).ceil() as u32;
142    let window = span
143        .max(2 * step as u32)
144        .clamp(WINDOW_MIN as u32, WINDOW_MAX as u32) as u16;
145    // Denser coefficients under BURSTY loss: a base 0.5 density plus half the
146    // burstiness, mapped onto 0..=15 and floored at 4 (a too-sparse repair over a
147    // small window can miss the lost symbol entirely). Density tracks burstiness
148    // alone - congestion does not make a loss more recoverable, and denser
149    // repairs over a congested path are the wrong response (less FEC, not more).
150    let density = (0.5 + 0.5 * s.burstiness).clamp(0.0, 1.0);
151    let dt = ((density * 15.0).round() as u8).clamp(4, 15);
152    RlcDecision { coding_on: true, window, step, dt }
153}
154
155/// Whether `a` is strictly more protective than `b`: coding turning on, a
156/// smaller step (more repairs), a larger window (longer reach), or denser
157/// coefficients. Escalations in any of these directions are applied at once;
158/// only de-escalations wait out the hold.
159fn more_protective(a: &RlcDecision, b: &RlcDecision) -> bool {
160    // `a` must have coding on to be more protective at all; then it is more
161    // protective if `b`'s coding is off, or any knob in `a` is set more
162    // aggressively (smaller step, larger window, denser coefficients).
163    a.coding_on
164        && (!b.coding_on || a.step < b.step || a.window > b.window || a.dt > b.dt)
165}
166
167/// Immediate-up / conservative-down controller for the RLC parameters. It
168/// raises protection (coding on, smaller step, larger window, denser
169/// coefficients) the instant the sensors call for it - cheap insurance against
170/// loss - but only lowers it after `hold` consecutive ticks that all want less,
171/// so a brief quiet spell does not strip protection. Turning coding OFF
172/// entirely (disable-on-clean) is the riskiest de-escalation, so it needs the
173/// longer `clean_hold` sustained-clean window, exactly as the block-RS policy
174/// guards the drop to Passthrough.
175#[derive(Debug, Clone)]
176pub struct RlcController {
177    state: RlcDecision,
178    down_streak: u32,
179    hold: u32,
180    clean_hold: u32,
181    /// Measured round trip (ms); scales the rate-law margin so an expensive NAK
182    /// buys heavier FEC. Zero (the default) is the near-zero-RTT base margin.
183    rtt_ms: f32,
184    /// Latency-priority floor: never relax FEC below this baseline protection
185    /// (and never disable coding). disable-on-clean and an over-light rate save
186    /// redundancy bandwidth on a quiet assessment but pay an ARQ round trip on
187    /// the next loss, which head-of-line-stalls in-order delivery. For the
188    /// latency-priority code (RLC in the unified transport) the step is clamped at
189    /// `floor_step` (the configured baseline) so an isolated loss always recovers
190    /// in-window; the controller may still escalate HEAVIER under high loss. Zero
191    /// disables the floor (the default bulk behaviour with disable-on-clean).
192    floor_step: u16,
193    /// Latency-priority floor on the coding WINDOW: never shrink the window below
194    /// this baseline span. A clean assessment otherwise collapses the window to
195    /// the minimum, and a short window cannot span a loss CLUSTER even at heavy
196    /// redundancy (too few repairs reach back over the burst), so clustered losses
197    /// fall to ARQ. Keeping the baseline span lets the in-window repairs cover a
198    /// burst. Zero disables the floor.
199    floor_window: u16,
200}
201
202impl RlcController {
203    /// A controller starting from active coding at the given parameters, with
204    /// `hold` lower-demand ticks required before relaxing a knob and
205    /// `4 * hold` sustained-clean ticks before disabling coding entirely.
206    pub fn new(window: u16, step: u16, dt: u8, hold: u32) -> Self {
207        let hold = hold.max(1);
208        Self {
209            state: RlcDecision { coding_on: true, window, step, dt },
210            down_streak: 0,
211            hold,
212            clean_hold: hold.saturating_mul(4).max(hold),
213            rtt_ms: 0.0,
214            floor_step: 0,
215            floor_window: 0,
216        }
217    }
218
219    /// Like [`new`](Self::new) but with an explicit `clean_hold`.
220    pub fn with_holds(window: u16, step: u16, dt: u8, hold: u32, clean_hold: u32) -> Self {
221        let hold = hold.max(1);
222        Self {
223            state: RlcDecision { coding_on: true, window, step, dt },
224            down_streak: 0,
225            hold,
226            clean_hold: clean_hold.max(hold),
227            rtt_ms: 0.0,
228            floor_step: 0,
229            floor_window: 0,
230        }
231    }
232
233    /// Set the measured round trip (ms), which scales the rate-law margin.
234    pub fn set_rtt_ms(&mut self, rtt_ms: f32) {
235        self.rtt_ms = rtt_ms.max(0.0);
236    }
237
238    /// Enable the latency-priority floor at the current baseline step: the
239    /// controller never relaxes FEC lighter than this (nor disables coding), so an
240    /// isolated loss always has an in-window repair and never falls to an ARQ
241    /// round trip that stalls in-order delivery. The controller may still escalate
242    /// HEAVIER under high loss. Call once at construction, before any feedback.
243    pub fn set_latency_floor(&mut self) {
244        self.floor_step = self.state.step.max(1);
245        self.floor_window = self.state.window;
246    }
247
248    /// The current coding configuration.
249    pub fn current(&self) -> RlcDecision {
250        self.state
251    }
252
253    /// Fold one channel assessment and return the configuration to apply.
254    pub fn decide(&mut self, s: &SensorSnapshot) -> RlcDecision {
255        let mut t = rlc_target_with_margin(s, rate_margin_for_rtt(self.rtt_ms));
256        // Latency-priority floor: a clean / low-loss assessment would disable
257        // coding or relax the rate too light, so the next loss falls to an ARQ
258        // round trip that head-of-line-stalls the in-order stream. Hold coding on
259        // and clamp the step at the baseline (never lighter), so an isolated loss
260        // always recovers in-window; escalation to a HEAVIER step under high loss
261        // still applies (the clamp only caps the light side).
262        if self.floor_step > 0 {
263            t.coding_on = true;
264            t.step = t.step.min(self.floor_step);
265            t.window = t.window.max(self.floor_window);
266        }
267        if more_protective(&t, &self.state) {
268            // Escalate every knob that wants more protection, immediately.
269            self.state.coding_on |= t.coding_on;
270            if t.coding_on {
271                self.state.step = self.state.step.min(t.step);
272                self.state.window = self.state.window.max(t.window);
273                self.state.dt = self.state.dt.max(t.dt);
274            }
275            self.down_streak = 0;
276        } else if t == self.state {
277            self.down_streak = 0;
278        } else {
279            // Lower demand: only relax after a sustained quiet run. Disabling
280            // coding (the riskiest step) needs the longer clean_hold window;
281            // relaxing a knob while coding stays on uses the shorter hold.
282            self.down_streak += 1;
283            let threshold = if !t.coding_on { self.clean_hold } else { self.hold };
284            if self.down_streak >= threshold {
285                self.state = t;
286                self.down_streak = 0;
287            }
288        }
289        self.state
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn clean() -> SensorSnapshot {
298        SensorSnapshot::default()
299    }
300
301    fn lossy(loss: f32, burstiness: f32) -> SensorSnapshot {
302        SensorSnapshot { loss, burstiness, ..SensorSnapshot::default() }
303    }
304
305    #[test]
306    fn clean_link_disables_coding() {
307        let d = rlc_target(&clean());
308        assert!(!d.coding_on, "a provably-clean link must disable RLC coding");
309    }
310
311    #[test]
312    fn heavier_loss_shrinks_step_toward_more_repairs() {
313        // The rate law: more loss -> smaller step (more repairs, lower rate).
314        let light = rlc_target(&lossy(0.02, 0.0));
315        let heavy = rlc_target(&lossy(0.30, 0.0));
316        assert!(light.coding_on && heavy.coding_on);
317        assert!(
318            heavy.step < light.step,
319            "heavier loss must lower step (more repairs): heavy {} vs light {}",
320            heavy.step,
321            light.step,
322        );
323        // 30% loss provisioned at 1.4x margin needs redundancy ~0.42 -> step ~1.
324        assert!(heavy.step <= 2, "30% loss should be near the heaviest rate, got step {}", heavy.step);
325    }
326
327    #[test]
328    fn rate_law_covers_effective_loss() {
329        // The provisioned redundancy 1/(step+1) must be at least the loss it is
330        // covering (the whole point of the rate law), at a representative loss.
331        for &loss in &[0.05f32, 0.10, 0.20] {
332            let d = rlc_target(&lossy(loss, 0.0));
333            let redundancy = 1.0 / (d.step as f32 + 1.0);
334            assert!(
335                redundancy >= loss,
336                "redundancy {redundancy} must cover loss {loss} (step {})",
337                d.step,
338            );
339        }
340    }
341
342    #[test]
343    fn longer_bursts_grow_the_window() {
344        // burstiness encodes mean_burst / 16: a longer fitted burst must widen
345        // the window so enough repairs span the burst.
346        let short = rlc_target(&lossy(0.10, 0.1)); // mean burst ~1.6
347        let long = rlc_target(&lossy(0.10, 0.6)); // mean burst ~9.6
348        assert!(
349            long.window > short.window,
350            "longer bursts must widen the window: long {} vs short {}",
351            long.window,
352            short.window,
353        );
354    }
355
356    #[test]
357    fn density_rises_with_burstiness() {
358        // Clustered (bursty) loss calls for denser repairs; congestion does not
359        // touch density (it lightens the rate instead).
360        let mild = rlc_target(&lossy(0.10, 0.0));
361        let bursty = rlc_target(&lossy(0.10, 0.9));
362        assert!(bursty.dt > mild.dt, "burstiness must raise density: {} vs {}", bursty.dt, mild.dt);
363    }
364
365    #[test]
366    fn higher_rtt_provisions_heavier_fec() {
367        // The same loss, at a higher round trip, gets a smaller step (heavier
368        // FEC): an expensive NAK is worth avoiding with more in-window recovery.
369        let s = lossy(0.10, 0.0);
370        let light = rlc_target_with_margin(&s, rate_margin_for_rtt(0.0)); // ~loopback
371        let heavy = rlc_target_with_margin(&s, rate_margin_for_rtt(2.0)); // real network
372        assert!(
373            heavy.step < light.step,
374            "a higher round trip must shrink step (heavier FEC): {} vs {}",
375            heavy.step,
376            light.step,
377        );
378        // And the margin is monotonic in RTT, capped.
379        assert!(rate_margin_for_rtt(5.0) >= rate_margin_for_rtt(1.0));
380        assert!(rate_margin_for_rtt(1000.0) <= MAX_RATE_MARGIN + 1e-6);
381    }
382
383    #[test]
384    fn rate_tracks_loss_not_congestion_classification() {
385        // The rate provisions against the measured loss rate and is robust to the
386        // congestion classification (whose timing signal is unreliable on this
387        // transport): the same measured loss yields the same rate whether classed
388        // wireless or congestion, so a false-high congestion reading cannot
389        // collapse the code.
390        let wireless = rlc_target(&SensorSnapshot {
391            loss: 0.20,
392            congestion_fraction: 0.0,
393            ..SensorSnapshot::default()
394        });
395        let congested = rlc_target(&SensorSnapshot {
396            loss: 0.20,
397            congestion_fraction: 0.9,
398            ..SensorSnapshot::default()
399        });
400        assert_eq!(
401            congested.step, wireless.step,
402            "the rate must track the loss rate, not the congestion classification",
403        );
404    }
405
406    #[test]
407    fn controller_escalates_immediately_on_loss() {
408        let mut c = RlcController::new(16, STEP_MAX, DEFAULT_DT, 8);
409        // A loss spike must shrink step (raise protection) on the very first tick.
410        let before = c.current().step;
411        let d = c.decide(&lossy(0.25, 0.5));
412        assert!(d.coding_on, "must keep coding on under loss");
413        assert!(
414            d.step < before,
415            "must raise protection (smaller step) at once: {} -> {}",
416            before,
417            d.step,
418        );
419    }
420
421    #[test]
422    fn controller_holds_protection_through_a_blip() {
423        let mut c = RlcController::with_holds(16, 4, 15, 4, 16);
424        c.decide(&lossy(0.25, 0.5)); // escalate
425        let escalated = c.current();
426        // One clean tick must NOT immediately relax protection.
427        let d = c.decide(&clean());
428        assert_eq!(d.step, escalated.step, "a single clean tick must not relax step");
429        assert!(d.coding_on, "a single clean tick must not disable coding");
430    }
431
432    #[test]
433    fn controller_disables_coding_only_after_sustained_clean() {
434        let mut c = RlcController::with_holds(16, 4, 15, 2, 6);
435        c.decide(&lossy(0.25, 0.5)); // escalate, coding on
436        // Fewer than clean_hold clean ticks: coding stays on.
437        for i in 0..5 {
438            let d = c.decide(&clean());
439            assert!(d.coding_on, "coding dropped too early at clean tick {i}");
440        }
441        // The clean_hold-th sustained-clean tick disables coding.
442        let d = c.decide(&clean());
443        assert!(!d.coding_on, "sustained clean must finally disable coding");
444    }
445
446    #[test]
447    fn latency_floor_holds_baseline_through_clean() {
448        // The latency-priority floor holds FEC on AND never relaxes the step
449        // lighter than the baseline (4) through a sustained-clean run - the fix
450        // for the ARQ-latency cliff where the controller would otherwise relax to
451        // an over-light rate (or disable coding) and pay an ARQ round trip on the
452        // next loss.
453        let mut c = RlcController::with_holds(16, 4, 15, 2, 4);
454        c.set_latency_floor();
455        c.decide(&lossy(0.25, 0.5)); // escalate under loss (step drops below 4)
456        for i in 0..20 {
457            let d = c.decide(&clean());
458            assert!(d.coding_on, "floor must hold FEC on at clean tick {i}");
459            assert!(d.step <= 4, "floor must not relax lighter than baseline 4 (got {})", d.step);
460            assert!(d.window >= 16, "floor must not shrink window below baseline 16 (got {})", d.window);
461        }
462        // The floor still escalates HEAVIER than the baseline under high loss.
463        let heavy = c.decide(&lossy(0.30, 0.5));
464        assert!(heavy.step < 4, "floor must still escalate heavier under loss (got {})", heavy.step);
465        // The same sustained-clean run disables coding WITHOUT the floor (baseline).
466        let mut base = RlcController::with_holds(16, 4, 15, 2, 4);
467        base.decide(&lossy(0.25, 0.5));
468        let mut disabled = false;
469        for _ in 0..20 {
470            if !base.decide(&clean()).coding_on {
471                disabled = true;
472                break;
473            }
474        }
475        assert!(disabled, "default controller must disable-on-clean (the baseline)");
476    }
477}