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}