use crate::fusion::{is_clean, SensorSnapshot};
use crate::rlc_fec::DEFAULT_DT;
const BASE_RATE_MARGIN: f32 = 1.4;
const RTT_MARGIN_SLOPE: f32 = 0.8;
const MAX_RATE_MARGIN: f32 = 2.8;
fn rate_margin_for_rtt(rtt_ms: f32) -> f32 {
(BASE_RATE_MARGIN + RTT_MARGIN_SLOPE * rtt_ms.max(0.0)).min(MAX_RATE_MARGIN)
}
const STEP_MAX: u16 = 16;
const STEP_MIN: u16 = 1;
const WINDOW_MIN: u16 = 8;
const WINDOW_MAX: u16 = 64;
const WINDOW_BURST_SAFETY: f32 = 1.5;
const MIN_EFFECTIVE_LOSS: f32 = 1.0e-3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RlcDecision {
pub coding_on: bool,
pub window: u16,
pub step: u16,
pub dt: u8,
}
pub fn rlc_target(s: &SensorSnapshot) -> RlcDecision {
rlc_target_with_margin(s, BASE_RATE_MARGIN)
}
pub fn rlc_target_with_margin(s: &SensorSnapshot, margin: f32) -> RlcDecision {
if is_clean(s) {
return RlcDecision {
coding_on: false,
window: WINDOW_MIN,
step: STEP_MAX,
dt: DEFAULT_DT,
};
}
let loss_rate = s.loss.max(MIN_EFFECTIVE_LOSS);
let step = ((1.0 / (margin.max(1.0) * loss_rate) - 1.0).floor() as i32)
.clamp(STEP_MIN as i32, STEP_MAX as i32) as u16;
let mean_burst = (s.burstiness * 16.0).max(1.0);
let span = (mean_burst * step as f32 * WINDOW_BURST_SAFETY).ceil() as u32;
let window = span
.max(2 * step as u32)
.clamp(WINDOW_MIN as u32, WINDOW_MAX as u32) as u16;
let density = (0.5 + 0.5 * s.burstiness).clamp(0.0, 1.0);
let dt = ((density * 15.0).round() as u8).clamp(4, 15);
RlcDecision { coding_on: true, window, step, dt }
}
fn more_protective(a: &RlcDecision, b: &RlcDecision) -> bool {
a.coding_on
&& (!b.coding_on || a.step < b.step || a.window > b.window || a.dt > b.dt)
}
#[derive(Debug, Clone)]
pub struct RlcController {
state: RlcDecision,
down_streak: u32,
hold: u32,
clean_hold: u32,
rtt_ms: f32,
floor_step: u16,
floor_window: u16,
}
impl RlcController {
pub fn new(window: u16, step: u16, dt: u8, hold: u32) -> Self {
let hold = hold.max(1);
Self {
state: RlcDecision { coding_on: true, window, step, dt },
down_streak: 0,
hold,
clean_hold: hold.saturating_mul(4).max(hold),
rtt_ms: 0.0,
floor_step: 0,
floor_window: 0,
}
}
pub fn with_holds(window: u16, step: u16, dt: u8, hold: u32, clean_hold: u32) -> Self {
let hold = hold.max(1);
Self {
state: RlcDecision { coding_on: true, window, step, dt },
down_streak: 0,
hold,
clean_hold: clean_hold.max(hold),
rtt_ms: 0.0,
floor_step: 0,
floor_window: 0,
}
}
pub fn set_rtt_ms(&mut self, rtt_ms: f32) {
self.rtt_ms = rtt_ms.max(0.0);
}
pub fn set_latency_floor(&mut self) {
self.floor_step = self.state.step.max(1);
self.floor_window = self.state.window;
}
pub fn current(&self) -> RlcDecision {
self.state
}
pub fn decide(&mut self, s: &SensorSnapshot) -> RlcDecision {
let mut t = rlc_target_with_margin(s, rate_margin_for_rtt(self.rtt_ms));
if self.floor_step > 0 {
t.coding_on = true;
t.step = t.step.min(self.floor_step);
t.window = t.window.max(self.floor_window);
}
if more_protective(&t, &self.state) {
self.state.coding_on |= t.coding_on;
if t.coding_on {
self.state.step = self.state.step.min(t.step);
self.state.window = self.state.window.max(t.window);
self.state.dt = self.state.dt.max(t.dt);
}
self.down_streak = 0;
} else if t == self.state {
self.down_streak = 0;
} else {
self.down_streak += 1;
let threshold = if !t.coding_on { self.clean_hold } else { self.hold };
if self.down_streak >= threshold {
self.state = t;
self.down_streak = 0;
}
}
self.state
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clean() -> SensorSnapshot {
SensorSnapshot::default()
}
fn lossy(loss: f32, burstiness: f32) -> SensorSnapshot {
SensorSnapshot { loss, burstiness, ..SensorSnapshot::default() }
}
#[test]
fn clean_link_disables_coding() {
let d = rlc_target(&clean());
assert!(!d.coding_on, "a provably-clean link must disable RLC coding");
}
#[test]
fn heavier_loss_shrinks_step_toward_more_repairs() {
let light = rlc_target(&lossy(0.02, 0.0));
let heavy = rlc_target(&lossy(0.30, 0.0));
assert!(light.coding_on && heavy.coding_on);
assert!(
heavy.step < light.step,
"heavier loss must lower step (more repairs): heavy {} vs light {}",
heavy.step,
light.step,
);
assert!(heavy.step <= 2, "30% loss should be near the heaviest rate, got step {}", heavy.step);
}
#[test]
fn rate_law_covers_effective_loss() {
for &loss in &[0.05f32, 0.10, 0.20] {
let d = rlc_target(&lossy(loss, 0.0));
let redundancy = 1.0 / (d.step as f32 + 1.0);
assert!(
redundancy >= loss,
"redundancy {redundancy} must cover loss {loss} (step {})",
d.step,
);
}
}
#[test]
fn longer_bursts_grow_the_window() {
let short = rlc_target(&lossy(0.10, 0.1)); let long = rlc_target(&lossy(0.10, 0.6)); assert!(
long.window > short.window,
"longer bursts must widen the window: long {} vs short {}",
long.window,
short.window,
);
}
#[test]
fn density_rises_with_burstiness() {
let mild = rlc_target(&lossy(0.10, 0.0));
let bursty = rlc_target(&lossy(0.10, 0.9));
assert!(bursty.dt > mild.dt, "burstiness must raise density: {} vs {}", bursty.dt, mild.dt);
}
#[test]
fn higher_rtt_provisions_heavier_fec() {
let s = lossy(0.10, 0.0);
let light = rlc_target_with_margin(&s, rate_margin_for_rtt(0.0)); let heavy = rlc_target_with_margin(&s, rate_margin_for_rtt(2.0)); assert!(
heavy.step < light.step,
"a higher round trip must shrink step (heavier FEC): {} vs {}",
heavy.step,
light.step,
);
assert!(rate_margin_for_rtt(5.0) >= rate_margin_for_rtt(1.0));
assert!(rate_margin_for_rtt(1000.0) <= MAX_RATE_MARGIN + 1e-6);
}
#[test]
fn rate_tracks_loss_not_congestion_classification() {
let wireless = rlc_target(&SensorSnapshot {
loss: 0.20,
congestion_fraction: 0.0,
..SensorSnapshot::default()
});
let congested = rlc_target(&SensorSnapshot {
loss: 0.20,
congestion_fraction: 0.9,
..SensorSnapshot::default()
});
assert_eq!(
congested.step, wireless.step,
"the rate must track the loss rate, not the congestion classification",
);
}
#[test]
fn controller_escalates_immediately_on_loss() {
let mut c = RlcController::new(16, STEP_MAX, DEFAULT_DT, 8);
let before = c.current().step;
let d = c.decide(&lossy(0.25, 0.5));
assert!(d.coding_on, "must keep coding on under loss");
assert!(
d.step < before,
"must raise protection (smaller step) at once: {} -> {}",
before,
d.step,
);
}
#[test]
fn controller_holds_protection_through_a_blip() {
let mut c = RlcController::with_holds(16, 4, 15, 4, 16);
c.decide(&lossy(0.25, 0.5)); let escalated = c.current();
let d = c.decide(&clean());
assert_eq!(d.step, escalated.step, "a single clean tick must not relax step");
assert!(d.coding_on, "a single clean tick must not disable coding");
}
#[test]
fn controller_disables_coding_only_after_sustained_clean() {
let mut c = RlcController::with_holds(16, 4, 15, 2, 6);
c.decide(&lossy(0.25, 0.5)); for i in 0..5 {
let d = c.decide(&clean());
assert!(d.coding_on, "coding dropped too early at clean tick {i}");
}
let d = c.decide(&clean());
assert!(!d.coding_on, "sustained clean must finally disable coding");
}
#[test]
fn latency_floor_holds_baseline_through_clean() {
let mut c = RlcController::with_holds(16, 4, 15, 2, 4);
c.set_latency_floor();
c.decide(&lossy(0.25, 0.5)); for i in 0..20 {
let d = c.decide(&clean());
assert!(d.coding_on, "floor must hold FEC on at clean tick {i}");
assert!(d.step <= 4, "floor must not relax lighter than baseline 4 (got {})", d.step);
assert!(d.window >= 16, "floor must not shrink window below baseline 16 (got {})", d.window);
}
let heavy = c.decide(&lossy(0.30, 0.5));
assert!(heavy.step < 4, "floor must still escalate heavier under loss (got {})", heavy.step);
let mut base = RlcController::with_holds(16, 4, 15, 2, 4);
base.decide(&lossy(0.25, 0.5));
let mut disabled = false;
for _ in 0..20 {
if !base.decide(&clean()).coding_on {
disabled = true;
break;
}
}
assert!(disabled, "default controller must disable-on-clean (the baseline)");
}
}