Skip to main content

subetha_cxc/
rlc_fec.rs

1//! Sliding-window Random Linear Code (RLC) forward erasure correction: a
2//! convolutional erasure code that interleaves repair symbols with the source
3//! symbols over a sliding window, so an isolated loss is recovered from the
4//! next repair without waiting for a block boundary.
5//!
6//! This is the convolutional counterpart of the block Cauchy Reed-Solomon code
7//! in [`crate::fec`]. A block code sends `k` source shards then `r` parity
8//! shards; to recover a loss the decoder must wait for the rest of the block,
9//! by which time the loss detector has often already fired a wasted retransmit.
10//! A sliding-window RLC, by contrast, emits one repair symbol every few source
11//! symbols, each a random linear combination of the source symbols currently in
12//! the window, so a single loss is recovered as soon as the next repair
13//! arrives - an RTT-independent, near-instant recovery that suits low-latency
14//! streams.
15//!
16//! The repair symbol over a window of source symbols `s_i` is
17//! `sum_i coef_i * s_i` over GF(2^8) with the field's `0x11D` polynomial - the
18//! *same* field as the block RS code, so the linear combination rides the
19//! GF(2^8) SIMD ladder ([`crate::fec::gf_mul_add_auto`]) with no new kernel.
20//! Each coefficient is drawn from a deterministic, seedable generator and is
21//! nonzero with probability `(DT + 1) / 16` (the density threshold `DT`), so
22//! the decoder reconstructs the exact coefficients from the repair's metadata
23//! (the repair key, the first source id, the window size, and `DT`).
24//!
25//! The decoder maintains a linear system over GF(2^8) of the received source
26//! and repair symbols and solves it by Gaussian elimination; a lost symbol is
27//! recovered the moment the received equations determine it. The heavy work -
28//! combining symbol-vectors during elimination - is `gf_mul_add` over the
29//! payload bytes (SIMD-accelerated); the small coefficient matrix (at most one
30//! column per source symbol in the window) uses scalar GF(2^8) arithmetic.
31
32use crate::fec::{gf, gf_mul_add_auto};
33use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
34
35/// Default density threshold: every coefficient nonzero (maximum density),
36/// which maximizes the recovery probability for small windows.
37pub const DEFAULT_DT: u8 = 15;
38
39/// Deterministic GF(2^8) coefficient for `source_id` under repair `repair_key`
40/// at density threshold `dt`. Nonzero with probability `(dt + 1) / 16`; the two
41/// independent draws (a density nibble and a value byte) come from a splitmix
42/// hash of `(repair_key, source_id)`, so encoder and decoder agree exactly.
43fn coef(repair_key: u32, source_id: u32, dt: u8) -> u8 {
44    // splitmix64 finaliser over the (key, id) pair.
45    let mut x = ((repair_key as u64) << 32) | source_id as u64;
46    x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
47    x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
48    x ^= x >> 31;
49    // Low nibble decides presence (uniform 0..15); next byte is the value.
50    if (x as u8 & 0x0f) <= dt {
51        let v = (x >> 8) as u8;
52        if v == 0 { 1 } else { v }
53    } else {
54        0
55    }
56}
57
58/// A repair symbol: a random linear combination of the source symbols in the
59/// sliding window at the moment it was generated. The metadata lets the decoder
60/// reconstruct the exact coefficients.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct RepairSymbol {
63    /// Seed for the coefficient generator.
64    pub repair_key: u32,
65    /// Lowest source id in the covered window.
66    pub first_source_id: u32,
67    /// Number of source symbols in the covered window.
68    pub window_size: u16,
69    /// Density threshold (0..=15).
70    pub dt: u8,
71    /// `sum_i coef_i * source_i` over the window.
72    pub payload: Vec<u8>,
73}
74
75/// Sliding-window RLC encoder: holds the last `window_max` source symbols and
76/// emits one repair symbol every `step` source symbols.
77#[derive(Debug)]
78pub struct RlcEncoder {
79    window: VecDeque<(u32, Vec<u8>)>,
80    window_max: usize,
81    step: usize,
82    dt: u8,
83    symbol_len: usize,
84    next_source_id: u32,
85    since_last_repair: usize,
86    next_repair_key: u32,
87    /// Whether repairs are emitted at all. `false` is the disable-on-clean
88    /// state: source symbols still flow and the window is still maintained (so
89    /// re-arming is instant), but no repair rides the wire.
90    coding_on: bool,
91}
92
93impl RlcEncoder {
94    /// Build an encoder over `symbol_len`-byte symbols with a window of up to
95    /// `window_max` source symbols, emitting one repair every `step` source
96    /// symbols at density threshold `dt`. The code rate is `step / (step + 1)`.
97    pub fn new(window_max: usize, step: usize, dt: u8, symbol_len: usize) -> Self {
98        Self {
99            window: VecDeque::new(),
100            window_max: window_max.max(1),
101            step: step.max(1),
102            dt: dt.min(15),
103            symbol_len,
104            next_source_id: 0,
105            since_last_repair: 0,
106            next_repair_key: 0,
107            coding_on: true,
108        }
109    }
110
111    /// Retune the coding parameters at runtime (the adaptive control path): the
112    /// window size, the repair cadence `step` (code rate `step / (step + 1)`),
113    /// and the coefficient density `dt`. Shrinking the window trims the oldest
114    /// source symbols immediately so the next repair spans only the new window.
115    pub fn set_params(&mut self, window_max: usize, step: usize, dt: u8) {
116        self.window_max = window_max.max(1);
117        self.step = step.max(1);
118        self.dt = dt.min(15);
119        while self.window.len() > self.window_max {
120            self.window.pop_front();
121        }
122    }
123
124    /// Turn repair emission on or off (disable-on-clean). The window keeps
125    /// filling either way, so re-enabling protects the in-flight symbols at once.
126    pub fn set_coding(&mut self, on: bool) {
127        self.coding_on = on;
128    }
129
130    /// The live `(window_max, step, dt)` parameters (telemetry).
131    pub fn params(&self) -> (usize, usize, u8) {
132        (self.window_max, self.step, self.dt)
133    }
134
135    /// Whether repair emission is currently active.
136    pub fn coding_on(&self) -> bool {
137        self.coding_on
138    }
139
140    /// Add one source symbol. Returns its assigned source id and, every `step`
141    /// symbols (while coding is on), a repair symbol to interleave onto the wire
142    /// after it.
143    pub fn push_source(&mut self, payload: &[u8]) -> (u32, Option<RepairSymbol>) {
144        debug_assert_eq!(payload.len(), self.symbol_len);
145        let sid = self.next_source_id;
146        self.next_source_id = self.next_source_id.wrapping_add(1);
147        self.window.push_back((sid, payload.to_vec()));
148        while self.window.len() > self.window_max {
149            self.window.pop_front();
150        }
151        self.since_last_repair += 1;
152        let repair = if self.coding_on && self.since_last_repair >= self.step {
153            self.since_last_repair = 0;
154            Some(self.emit_repair())
155        } else {
156            None
157        };
158        (sid, repair)
159    }
160
161    /// Drop acknowledged-or-recovered source symbols below `floor` from the
162    /// window (the elastic-window feedback path); the window never protects
163    /// data the peer already has.
164    pub fn forget_below(&mut self, floor: u32) {
165        while let Some((sid, _)) = self.window.front() {
166            if *sid < floor {
167                self.window.pop_front();
168            } else {
169                break;
170            }
171        }
172    }
173
174    /// Re-base the source-id stream to `base` for a cross-code resync: the next
175    /// source symbol is assigned id `base` and the coding window starts empty, so
176    /// repairs reference only post-rebase symbols. Used when another code carried
177    /// the ids between this code's old running id and `base`, so it must resume at
178    /// `base` rather than its own (now-diverged) counter. The repair-key counter
179    /// keeps running (keys are matched to ids by coefficient, not by equality).
180    pub fn rebase_to(&mut self, base: u32) {
181        self.next_source_id = base;
182        self.window.clear();
183        self.since_last_repair = 0;
184    }
185
186    fn emit_repair(&mut self) -> RepairSymbol {
187        let repair_key = self.next_repair_key;
188        self.next_repair_key = self.next_repair_key.wrapping_add(1);
189        let first = self.window.front().map(|(id, _)| *id).unwrap_or(0);
190        let mut payload = vec![0u8; self.symbol_len];
191        for (sid, sym) in &self.window {
192            let c = coef(repair_key, *sid, self.dt);
193            if c != 0 {
194                gf_mul_add_auto(&mut payload, sym, c);
195            }
196        }
197        RepairSymbol {
198            repair_key,
199            first_source_id: first,
200            window_size: self.window.len() as u16,
201            dt: self.dt,
202            payload,
203        }
204    }
205
206    /// The next source id that will be assigned.
207    pub fn next_source_id(&self) -> u32 {
208        self.next_source_id
209    }
210}
211
212/// Sliding-window RLC decoder: stores received source and repair symbols and
213/// recovers lost source symbols by Gaussian elimination over GF(2^8).
214#[derive(Debug)]
215pub struct RlcDecoder {
216    symbol_len: usize,
217    source: BTreeMap<u32, Vec<u8>>,
218    repairs: Vec<RepairSymbol>,
219    /// Highest source id seen on any source or repair, for the recovery horizon.
220    highest: u32,
221    /// RLC solving is bounded to source ids within `horizon` of `highest`: a
222    /// sliding-window code can only recover within its window, so a gap older
223    /// than this is the ARQ floor's job, not RLC's. Bounding the solve keeps
224    /// the per-packet cost constant instead of growing with history.
225    horizon: u32,
226}
227
228impl RlcDecoder {
229    /// Build a decoder over `symbol_len`-byte symbols.
230    pub fn new(symbol_len: usize) -> Self {
231        Self {
232            symbol_len,
233            source: BTreeMap::new(),
234            repairs: Vec::new(),
235            highest: 0,
236            horizon: 1024,
237        }
238    }
239
240    /// Set the RLC recovery horizon (source ids back from the newest that the
241    /// solver considers). Should comfortably exceed the encoder's window so a
242    /// repair's whole window is in scope; gaps older than this fall to ARQ.
243    pub fn with_horizon(mut self, horizon: u32) -> Self {
244        self.horizon = horizon.max(1);
245        self
246    }
247
248    /// Record a received source symbol. A source arrival fills its own slot
249    /// directly; recovery of OTHER symbols is driven by repair arrivals
250    /// ([`on_repair`](Self::on_repair)), so this does NOT run the (potentially
251    /// expensive) Gaussian solve - that would otherwise fire on every single
252    /// packet under loss, re-solving the whole in-horizon system each time, when
253    /// a new source adds no equation. A late source that completes a pending
254    /// system is recovered on the next repair (one arrives every `step`
255    /// symbols), and anything that slips through is caught by the ARQ floor.
256    pub fn on_source(&mut self, source_id: u32, payload: &[u8]) -> Vec<u32> {
257        debug_assert_eq!(payload.len(), self.symbol_len);
258        self.highest = self.highest.max(source_id);
259        self.source
260            .entry(source_id)
261            .or_insert_with(|| payload.to_vec());
262        Vec::new()
263    }
264
265    /// Record a received repair symbol. Returns any source ids newly recovered.
266    pub fn on_repair(&mut self, r: RepairSymbol) -> Vec<u32> {
267        self.add_repair(r);
268        self.try_recover()
269    }
270
271    /// Store a repair WITHOUT solving, for a caller that drains a batch of
272    /// datagrams first (stamping their arrival before any decode) and then runs
273    /// one [`recover`](Self::recover) over the whole batch - keeping the
274    /// expensive Gaussian solve out of the receive/timing path.
275    pub fn add_repair(&mut self, r: RepairSymbol) {
276        self.highest = self
277            .highest
278            .max(r.first_source_id.wrapping_add(r.window_size as u32).saturating_sub(1));
279        self.repairs.push(r);
280    }
281
282    /// Run one recovery pass over the currently-stored source and repair symbols,
283    /// returning any source ids newly recovered. Pairs with [`add_repair`](Self::add_repair).
284    pub fn recover(&mut self) -> Vec<u32> {
285        self.try_recover()
286    }
287
288    /// The bytes of source symbol `source_id`, if received or recovered.
289    pub fn get(&self, source_id: u32) -> Option<&[u8]> {
290        self.source.get(&source_id).map(|v| v.as_slice())
291    }
292
293    /// Whether source symbol `source_id` is present (received or recovered).
294    pub fn has(&self, source_id: u32) -> bool {
295        self.source.contains_key(&source_id)
296    }
297
298    /// Drop delivered source symbols and spent repairs below `floor`, to bound
299    /// memory on a long-lived flow. A source symbol a remaining repair still
300    /// references is kept regardless (the decoder must subtract its known
301    /// contribution when solving), so the source floor is capped at the oldest
302    /// remaining repair's window start - forgetting it otherwise would make a
303    /// received symbol look unknown and corrupt the linear system.
304    pub fn forget_below(&mut self, floor: u32) {
305        self.repairs
306            .retain(|r| r.first_source_id.wrapping_add(r.window_size as u32) > floor);
307        let safe = match self.repairs.iter().map(|r| r.first_source_id).min() {
308            Some(oldest) => floor.min(oldest),
309            None => floor,
310        };
311        self.source.retain(|&sid, _| sid >= safe);
312    }
313
314    /// Re-base the decoder to deliver from `base`: drop all stored source and
315    /// repair symbols (they belong to the pre-rebase id range another code now
316    /// owns) and anchor the recovery horizon at `base`. The receiver moves its
317    /// delivery frontier to `base` in lockstep, so nothing below `base` is ever
318    /// looked up again.
319    pub fn rebase_to(&mut self, base: u32) {
320        self.source.clear();
321        self.repairs.clear();
322        self.highest = base;
323    }
324
325    fn try_recover(&mut self) -> Vec<u32> {
326        // Recovery is scoped to the horizon: a sliding-window code cannot use a
327        // repair whose window has aged out, so drop those and only treat
328        // in-horizon gaps as RLC unknowns (older gaps fall to the ARQ floor).
329        // This keeps the solve bounded instead of growing with history.
330        let lo = self.highest.saturating_sub(self.horizon);
331        self.repairs
332            .retain(|r| r.first_source_id.wrapping_add(r.window_size as u32) > lo);
333        let mut unknown_set: BTreeSet<u32> = BTreeSet::new();
334        for r in &self.repairs {
335            for off in 0..r.window_size as u32 {
336                let sid = r.first_source_id.wrapping_add(off);
337                if sid >= lo && !self.source.contains_key(&sid) {
338                    unknown_set.insert(sid);
339                }
340            }
341        }
342        if unknown_set.is_empty() {
343            self.prune();
344            return Vec::new();
345        }
346        let unknowns: Vec<u32> = unknown_set.into_iter().collect();
347        let idx: HashMap<u32, usize> = unknowns.iter().enumerate().map(|(i, &s)| (s, i)).collect();
348        let ncols = unknowns.len();
349
350        // One row per repair covering at least one unknown: a coefficient
351        // vector over the unknowns and an rhs symbol-vector with the known
352        // source contributions already moved across (rhs ^= c * known_source).
353        struct Row {
354            coefs: Vec<u8>,
355            rhs: Vec<u8>,
356        }
357        let mut rows: Vec<Row> = Vec::new();
358        for r in &self.repairs {
359            let mut coefs = vec![0u8; ncols];
360            let mut rhs = r.payload.clone();
361            let mut covers_unknown = false;
362            let mut usable = true;
363            for off in 0..r.window_size as u32 {
364                let sid = r.first_source_id.wrapping_add(off);
365                let c = coef(r.repair_key, sid, r.dt);
366                if c == 0 {
367                    continue;
368                }
369                if let Some(sym) = self.source.get(&sid) {
370                    gf_mul_add_auto(&mut rhs, sym, c);
371                } else if sid >= lo {
372                    coefs[idx[&sid]] = c;
373                    covers_unknown = true;
374                } else {
375                    // An unknown below the horizon is out of RLC scope; this
376                    // repair cannot be used here (the ARQ floor recovers that
377                    // older gap).
378                    usable = false;
379                    break;
380                }
381            }
382            if usable && covers_unknown {
383                rows.push(Row { coefs, rhs });
384            }
385        }
386
387        // Reduced row echelon over GF(2^8).
388        let mut pivot = 0usize;
389        for col in 0..ncols {
390            let sel = (pivot..rows.len()).find(|&r| rows[r].coefs[col] != 0);
391            let Some(sel) = sel else { continue };
392            rows.swap(pivot, sel);
393            let inv = gf::inv(rows[pivot].coefs[col]);
394            for cf in rows[pivot].coefs.iter_mut() {
395                *cf = gf::mul(*cf, inv);
396            }
397            gf_scale(&mut rows[pivot].rhs, inv);
398            // Snapshot the pivot row so the elimination loop can borrow `rows`
399            // mutably for every other row without aliasing.
400            let pivot_coefs = rows[pivot].coefs.clone();
401            let pivot_rhs = rows[pivot].rhs.clone();
402            for (r, row) in rows.iter_mut().enumerate() {
403                if r == pivot {
404                    continue;
405                }
406                let f = row.coefs[col];
407                if f == 0 {
408                    continue;
409                }
410                for (rc, &pc) in row.coefs.iter_mut().zip(&pivot_coefs) {
411                    *rc ^= gf::mul(f, pc);
412                }
413                gf_mul_add_auto(&mut row.rhs, &pivot_rhs, f);
414            }
415            pivot += 1;
416        }
417
418        // A row that reduced to a single unit coefficient determines that
419        // unknown: x = rhs.
420        let mut recovered = Vec::new();
421        for row in &rows {
422            let nz: Vec<usize> = (0..ncols).filter(|&c| row.coefs[c] != 0).collect();
423            if nz.len() == 1 && row.coefs[nz[0]] == 1 {
424                let sid = unknowns[nz[0]];
425                if let std::collections::btree_map::Entry::Vacant(e) = self.source.entry(sid) {
426                    e.insert(row.rhs.clone());
427                    recovered.push(sid);
428                }
429            }
430        }
431        self.prune();
432        recovered
433    }
434
435    /// Drop repairs whose covered source symbols are all known: they carry no
436    /// further information and keeping them only grows the linear system.
437    fn prune(&mut self) {
438        let source = &self.source;
439        self.repairs.retain(|r| {
440            (0..r.window_size as u32)
441                .any(|off| !source.contains_key(&r.first_source_id.wrapping_add(off)))
442        });
443    }
444}
445
446/// `v[i] = gf::mul(v[i], coef)` in place - a scalar GF(2^8) scale of one
447/// symbol-vector (used once per pivot to normalize the pivot row's rhs).
448fn gf_scale(v: &mut [u8], coef: u8) {
449    if coef == 1 {
450        return;
451    }
452    for b in v.iter_mut() {
453        *b = gf::mul(*b, coef);
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    /// A deterministic source symbol of `len` bytes for source id `sid`.
462    fn make_symbol(sid: u32, len: usize) -> Vec<u8> {
463        (0..len)
464            .map(|b| ((sid as usize * 131 + b * 17 + 7) & 0xff) as u8)
465            .collect()
466    }
467
468    /// After a cross-code resync, the encoder re-bases to the new id and its
469    /// repairs reference only post-rebase symbols, and a re-based decoder recovers
470    /// a post-rebase loss without ever touching the abandoned pre-rebase ids.
471    #[test]
472    fn rebase_resumes_a_clean_recoverable_stream() {
473        let len = 32;
474        let mut enc = RlcEncoder::new(8, 4, 15, len);
475        for sid in 0..10u32 {
476            enc.push_source(&make_symbol(sid, len));
477        }
478        // Resync: another code carried [10, 5000); RLC resumes at 5000.
479        enc.rebase_to(5000);
480        assert_eq!(enc.next_source_id(), 5000);
481
482        let mut dec = RlcDecoder::new(len).with_horizon(64);
483        dec.rebase_to(5000);
484        // Push four post-rebase symbols, dropping the first (5000), keeping its
485        // repair, and verify RLC recovers it - proving the re-based window is a
486        // self-contained linear system anchored at the new base.
487        let mut repair = None;
488        for sid in 5000..5004u32 {
489            let (id, rep) = enc.push_source(&make_symbol(sid, len));
490            assert_eq!(id, sid);
491            if sid != 5000 {
492                dec.on_source(sid, &make_symbol(sid, len));
493            }
494            if let Some(r) = rep {
495                repair = Some(r);
496            }
497        }
498        let recovered = dec.on_repair(repair.expect("a repair fires every step=4"));
499        assert!(recovered.contains(&5000), "re-based loss recovers: {recovered:?}");
500        assert_eq!(dec.get(5000), Some(make_symbol(5000, len).as_slice()));
501        // Nothing below the rebase base is present (the old range was abandoned).
502        assert!(!dec.has(9), "pre-rebase ids must not linger in the decoder");
503    }
504
505    #[test]
506    fn coefficient_generator_is_deterministic_and_honors_density() {
507        // Same inputs -> same coefficient.
508        for key in 0..8u32 {
509            for sid in 0..8u32 {
510                assert_eq!(coef(key, sid, 15), coef(key, sid, 15));
511            }
512        }
513        // DT = 15 -> every coefficient nonzero.
514        for sid in 0..256u32 {
515            assert_ne!(coef(7, sid, 15), 0, "DT=15 must be fully dense at sid {sid}");
516        }
517        // DT = 0 -> roughly 1/16 nonzero (sample a population).
518        let nz = (0..4096u32).filter(|&s| coef(3, s, 0) != 0).count();
519        assert!(
520            (150..420).contains(&nz),
521            "DT=0 density ~1/16 of 4096 (~256); got {nz}"
522        );
523    }
524
525    /// An isolated loss is recovered from the very next repair that covers it,
526    /// without waiting for a block to complete.
527    #[test]
528    fn isolated_loss_recovers_immediately() {
529        let len = 64;
530        let mut enc = RlcEncoder::new(8, 2, DEFAULT_DT, len);
531        let mut dec = RlcDecoder::new(len);
532        let drop_sid = 5u32;
533        let n = 12u32;
534        let mut recovered_at: Option<u32> = None;
535        let mut emitted = 0u32; // count of wire symbols fed after the drop
536        for i in 0..n {
537            let sym = make_symbol(i, len);
538            let (sid, repair) = enc.push_source(&sym);
539            if sid != drop_sid {
540                dec.on_source(sid, &sym);
541            }
542            if sid > drop_sid {
543                emitted += 1;
544            }
545            if let Some(r) = repair {
546                let rec = dec.on_repair(r);
547                if rec.contains(&drop_sid) && recovered_at.is_none() {
548                    recovered_at = Some(emitted);
549                }
550            }
551        }
552        assert!(dec.has(drop_sid), "isolated loss must recover");
553        assert_eq!(
554            dec.get(drop_sid),
555            Some(make_symbol(drop_sid, len).as_slice()),
556            "recovered bytes must match the original"
557        );
558        // Recovered within a couple of symbols of the loss - not after a whole
559        // block (a block of this rate would need ~the full window first).
560        assert!(
561            recovered_at.is_some_and(|e| e <= 2),
562            "must recover within ~2 wire symbols of the loss, got {recovered_at:?}"
563        );
564    }
565
566    /// A burst of consecutive losses recovers once enough repairs span them.
567    #[test]
568    fn burst_within_capability_recovers() {
569        let len = 48;
570        let mut enc = RlcEncoder::new(16, 2, DEFAULT_DT, len);
571        let mut dec = RlcDecoder::new(len);
572        let drops: BTreeSet<u32> = [4, 5].into_iter().collect();
573        let originals: Vec<Vec<u8>> = (0..16).map(|i| make_symbol(i, len)).collect();
574        for i in 0..16u32 {
575            let (sid, repair) = enc.push_source(&originals[i as usize]);
576            if !drops.contains(&sid) {
577                dec.on_source(sid, &originals[i as usize]);
578            }
579            if let Some(r) = repair {
580                dec.on_repair(r);
581            }
582        }
583        for &sid in &drops {
584            assert!(dec.has(sid), "burst symbol {sid} must recover");
585            assert_eq!(dec.get(sid), Some(originals[sid as usize].as_slice()));
586        }
587    }
588
589    /// A loss whose repairs are also lost is reported missing, never recovered
590    /// as wrong data.
591    #[test]
592    fn unrecoverable_loss_is_not_misrecovered() {
593        let len = 32;
594        let mut enc = RlcEncoder::new(8, 2, DEFAULT_DT, len);
595        let mut dec = RlcDecoder::new(len);
596        let drop_sid = 5u32;
597        for i in 0..12u32 {
598            let sym = make_symbol(i, len);
599            let (sid, repair) = enc.push_source(&sym);
600            if sid != drop_sid {
601                dec.on_source(sid, &sym);
602            }
603            // Drop every repair whose window still contains the lost symbol, so
604            // it can never be recovered.
605            if let Some(r) = repair {
606                let covers = (0..r.window_size as u32)
607                    .any(|off| r.first_source_id.wrapping_add(off) == drop_sid);
608                if !covers {
609                    dec.on_repair(r);
610                }
611            }
612        }
613        assert!(!dec.has(drop_sid), "no repair covered it -> must stay missing");
614        assert_eq!(dec.get(drop_sid), None, "must not fabricate wrong data");
615    }
616
617    /// A long stream under scattered isolated losses delivers every symbol
618    /// exactly (received or recovered).
619    #[test]
620    fn long_stream_scattered_losses_all_recover() {
621        let len = 40;
622        let n = 300u32;
623        let mut enc = RlcEncoder::new(16, 2, DEFAULT_DT, len);
624        let mut dec = RlcDecoder::new(len);
625        let originals: Vec<Vec<u8>> = (0..n).map(|i| make_symbol(i, len)).collect();
626        // Deterministic ~8% isolated drops (never two in a row, so each is
627        // within the repair capability at this rate).
628        let mut rng = 0x1234_5678u32;
629        let mut prev_dropped = false;
630        for i in 0..n {
631            rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
632            let drop = !prev_dropped && (rng >> 24) % 100 < 8;
633            prev_dropped = drop;
634            let (sid, repair) = enc.push_source(&originals[i as usize]);
635            if !drop {
636                dec.on_source(sid, &originals[i as usize]);
637            }
638            if let Some(r) = repair {
639                dec.on_repair(r);
640            }
641        }
642        for i in 0..n {
643            assert!(dec.has(i), "symbol {i} must be delivered");
644            assert_eq!(dec.get(i), Some(originals[i as usize].as_slice()));
645        }
646    }
647}