Skip to main content

subetha_cxc/
fusion.rs

1//! Sensor fusion: turn loss / burstiness / delay-trend readings into a
2//! coding decision, behind a swappable [`FusionPolicy`] so the
3//! arbitration strategy is chosen empirically rather than hard-coded.
4//!
5//! The hard question - when sensors disagree, who wins, and with what
6//! hysteresis so the level does not oscillate - is settled by scoring
7//! candidate policies over synthetic traces with [`score_policy`] and
8//! picking the best, then confirming on real links. The score rewards
9//! fast escalation (cover loss before it hurts) while penalizing
10//! oscillation (level flapping) and average parity overhead.
11
12use crate::control_table::CodingLevel;
13
14/// A fused snapshot of the channel from all sensors.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct SensorSnapshot {
17    /// Measured loss fraction, 0..=1 (in-band ground truth).
18    pub loss: f32,
19    /// Burstiness, 0..=1: how clustered the loss is (radio / temporal).
20    pub burstiness: f32,
21    /// One-way-delay trend (delay added per unit time). Positive means
22    /// the queue is building - congestion-driven loss is imminent.
23    pub owd_trend: f32,
24    /// Link stress, 0..=1, from the platform link sensor (low signal /
25    /// high interface drop rate). A feed-forward predictor of loss.
26    pub link_stress: f32,
27    /// Path shift, 0..=1, from the peer's echoed TTL: high just after a
28    /// hop-count change (a router-level re-route). A feed-forward predictor
29    /// of the loss a path change often brings.
30    pub path_shift: f32,
31    /// ECN Congestion-Experienced rate, 0..=1, from the peer's echoed TOS.
32    /// An AQM router marks CE before it tail-drops, so this leads loss the
33    /// way a rising delay trend does.
34    pub ecn_ce: f32,
35    /// Share of recent loss the peer classed congestion (0..=1), from its
36    /// `loss_class` report (Biaz + Spike). The congestion share drives parity
37    /// up broadly; the wireless share is left to local FEC and does not inflate
38    /// effective loss.
39    pub congestion_fraction: f32,
40    /// Reverse-path (feedback) loss share (0..=1): the fraction of the peer's
41    /// feedback the sender missed. Lost feedback impairs ARQ, so a lossy reverse
42    /// path nudges FEC to carry more (less reliance on the round trip).
43    pub rev_loss: f32,
44    /// Self-induced queue delay in milliseconds: `RTT_now - RTprop` from the
45    /// BBR path model. A sustained value above [`QUEUE_BLOAT_MS`] is bufferbloat
46    /// WE are causing - the rising delay is our own standing queue, not external
47    /// congestion, so the answer is to pace down (drain the queue), not to add
48    /// FEC parity (which only deepens it).
49    pub queue_delay_ms: f32,
50    /// Estimated Wi-Fi backhaul-hop count (0..=3) behind the first hop. Each
51    /// extra hop is a shared-medium retransmit that raises expected loss, so
52    /// more hops bias parity up.
53    pub backhaul_hops: u8,
54}
55
56/// The coding configuration a policy selects.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct ControlDecision {
59    pub level: CodingLevel,
60    pub parity_r: u8,
61    pub interleave_depth: u8,
62}
63
64/// Trend slope above which the queue is judged to be building.
65const OWD_RISING: f32 = 0.02;
66
67/// Link-stress below this is treated as a clean link (the sensor reports
68/// tiny nonzero values even on a healthy interface).
69const CLEAN_STRESS_EPS: f32 = 0.02;
70
71/// How much the congestion share of measured loss adds to effective loss
72/// (parity up, broadly). A wireless drop stays at the base FEC level; a
73/// congestion drop drives more protection, since under-protecting a congested
74/// path - or over-driving it - is the costlier miss.
75const CONGESTION_PARITY_WEIGHT: f32 = 0.5;
76
77/// Standing-queue delay (ms) above which a rising delay trend is judged self-
78/// induced bufferbloat rather than external congestion. ~25 ms of queue we are
79/// causing means pace down (the flow window drains it); adding FEC parity would
80/// only add wire traffic and deepen the queue.
81pub const QUEUE_BLOAT_MS: f32 = 25.0;
82
83/// Effective-loss bias per estimated Wi-Fi backhaul hop. Each shared-medium
84/// retransmit hop raises expected loss, so parity arms a little higher behind a
85/// mesh repeater even before the loss reaches shard accounting.
86const BACKHAUL_HOP_PARITY: f32 = 0.03;
87
88/// Whether the path is provably clean: no measured loss, no clustered loss,
89/// no rising delay trend, and no feed-forward stress (link / path-shift / ECN /
90/// reverse-loss / backhaul). A clean link calls for zero protection - the
91/// block-RS path ships at Passthrough, the RLC path disables coding - with ARQ
92/// the floor if a rare drop slips through before the controller re-arms. Both
93/// codes share this predicate so "clean" means the same thing to each.
94pub fn is_clean(s: &SensorSnapshot) -> bool {
95    s.loss <= 0.0
96        && s.burstiness <= 0.0
97        && s.owd_trend <= OWD_RISING
98        && s.link_stress < CLEAN_STRESS_EPS
99        && s.path_shift < CLEAN_STRESS_EPS
100        && s.ecn_ce < CLEAN_STRESS_EPS
101        && s.rev_loss < CLEAN_STRESS_EPS
102        && s.backhaul_hops == 0
103}
104
105/// The effective loss the controller protects against: measured loss plus the
106/// feed-forward predictors (congestion share, rising delay trend, path shift,
107/// backhaul hops, link stress, ECN, reverse loss), clamped to `0..=1`. A rising
108/// delay trend that is OUR OWN standing queue (self-induced bufferbloat) does
109/// NOT add protection - the flow-window pacer drains it; adding redundancy would
110/// only deepen the queue - so that bump is suppressed above [`QUEUE_BLOAT_MS`].
111/// Both the block-RS parity map and the RLC rate law consume this single number,
112/// so the two codes assess the channel identically and differ only in how they
113/// translate it into coding parameters.
114pub fn effective_loss(s: &SensorSnapshot) -> f32 {
115    let rising = s.owd_trend > OWD_RISING;
116    let shifting = s.path_shift > 0.5;
117    let self_induced_bloat = s.queue_delay_ms > QUEUE_BLOAT_MS;
118    let rising_bump = if rising && !self_induced_bloat { 0.05 } else { 0.0 };
119    (s.loss
120        + s.loss * s.congestion_fraction * CONGESTION_PARITY_WEIGHT
121        + rising_bump
122        + if shifting { 0.05 } else { 0.0 }
123        + s.backhaul_hops as f32 * BACKHAUL_HOP_PARITY
124        + s.link_stress * 0.1
125        + s.ecn_ce * 0.1
126        + s.rev_loss * 0.1)
127        .min(1.0)
128}
129
130/// The configuration the sensors alone call for, before any policy-level
131/// hysteresis or timing. Feed-forward: a rising delay trend bumps the
132/// level up even while measured loss is still low.
133pub fn raw_target(s: &SensorSnapshot) -> ControlDecision {
134    // A provably-clean link calls for zero protection (Passthrough): the block
135    // ships its data shards with no FEC encode and no parity datagrams; ARQ
136    // stays the floor if a rare drop slips through before the controller re-arms.
137    if is_clean(s) {
138        return ControlDecision {
139            level: CodingLevel::Passthrough,
140            parity_r: 0,
141            interleave_depth: 1,
142        };
143    }
144    // Feed-forward: a rising delay trend, a stressed link, a path shift, and
145    // ECN congestion all pre-emptively add protection, as if measured loss
146    // were higher, before the loss they predict has materialized.
147    let effective_loss = effective_loss(s);
148    let parity_r = ((effective_loss * 8.0).ceil() as u8 + 1).clamp(1, 6);
149    // Interleave to the burst length the burstiness term encodes - the jitter
150    // ratio (heuristic) or the Gilbert-Elliott mean burst / 16 (the burst
151    // model). Linear, no gate: a real fitted mean burst of 3-4 must still
152    // interleave, which the old `> 0.5` gate (depth 8+) silently dropped.
153    let interleave_depth = ((s.burstiness * 16.0).round() as u8).clamp(1, 16);
154    let level = if interleave_depth > 1 {
155        CodingLevel::Interleave
156    } else {
157        CodingLevel::Fec
158    };
159    ControlDecision { level, parity_r, interleave_depth }
160}
161
162/// A strategy that maps a sensor snapshot to a coding decision, carrying
163/// whatever state (hysteresis counters, last level) it needs.
164pub trait FusionPolicy {
165    /// Short identifier for scoring output.
166    fn name(&self) -> &'static str;
167    /// Decide the coding configuration for this snapshot.
168    fn decide(&mut self, s: &SensorSnapshot) -> ControlDecision;
169}
170
171/// Jump straight to the sensors' raw target every tick - maximally
172/// responsive, but flaps when sensors are noisy.
173#[derive(Debug, Default)]
174pub struct MaxOfSensors;
175
176impl FusionPolicy for MaxOfSensors {
177    fn name(&self) -> &'static str {
178        "max-of-sensors"
179    }
180    fn decide(&mut self, s: &SensorSnapshot) -> ControlDecision {
181        raw_target(s)
182    }
183}
184
185/// Raise the level immediately (cheap insurance), but only lower it after
186/// `hold` consecutive ticks that all call for a lower level - so a brief
187/// dip does not drop protection and the level does not oscillate.
188#[derive(Debug)]
189pub struct ImmediateUpConservativeDown {
190    level: CodingLevel,
191    parity_r: u8,
192    interleave_depth: u8,
193    down_streak: u32,
194    hold: u32,
195    clean_hold: u32,
196}
197
198impl ImmediateUpConservativeDown {
199    /// `hold` is the number of consecutive lower-demand ticks required
200    /// before de-escalating between FEC levels. Dropping all the way to
201    /// Passthrough (zero parity) is the riskiest de-escalation, so it
202    /// requires a longer sustained-clean window, `clean_hold`, defaulting
203    /// to `4 * hold`.
204    pub fn new(hold: u32) -> Self {
205        let hold = hold.max(1);
206        Self::with_holds(hold, hold.saturating_mul(4))
207    }
208
209    /// Like [`new`](Self::new) but with an explicit `clean_hold` (the
210    /// sustained-clean streak required before dropping to Passthrough).
211    pub fn with_holds(hold: u32, clean_hold: u32) -> Self {
212        let hold = hold.max(1);
213        Self {
214            level: CodingLevel::Fec,
215            parity_r: 2,
216            interleave_depth: 1,
217            down_streak: 0,
218            hold,
219            clean_hold: clean_hold.max(hold),
220        }
221    }
222}
223
224impl FusionPolicy for ImmediateUpConservativeDown {
225    fn name(&self) -> &'static str {
226        "immediate-up-conservative-down"
227    }
228    fn decide(&mut self, s: &SensorSnapshot) -> ControlDecision {
229        let t = raw_target(s);
230        let up = (t.level as u8) > (self.level as u8)
231            || t.parity_r > self.parity_r
232            || t.interleave_depth > self.interleave_depth;
233        if up {
234            // Escalate immediately and reset the down streak.
235            self.level = t.level.max_level(self.level);
236            self.parity_r = self.parity_r.max(t.parity_r);
237            self.interleave_depth = self.interleave_depth.max(t.interleave_depth);
238            self.down_streak = 0;
239        } else if t == current(self) {
240            self.down_streak = 0;
241        } else {
242            // Lower demand: only step down after a sustained quiet run.
243            // Dropping to Passthrough (zero parity) gives up all protection,
244            // so it needs the longer `clean_hold` confidence window; steps
245            // between FEC levels use the shorter `hold`.
246            self.down_streak += 1;
247            let threshold = if t.level == CodingLevel::Passthrough {
248                self.clean_hold
249            } else {
250                self.hold
251            };
252            if self.down_streak >= threshold {
253                self.level = t.level;
254                self.parity_r = t.parity_r;
255                self.interleave_depth = t.interleave_depth;
256                self.down_streak = 0;
257            }
258        }
259        current(self)
260    }
261}
262
263fn current(p: &ImmediateUpConservativeDown) -> ControlDecision {
264    ControlDecision {
265        level: p.level,
266        parity_r: p.parity_r,
267        interleave_depth: p.interleave_depth,
268    }
269}
270
271impl CodingLevel {
272    /// The higher of two levels.
273    pub fn max_level(self, other: CodingLevel) -> CodingLevel {
274        if (self as u8) >= (other as u8) {
275            self
276        } else {
277            other
278        }
279    }
280}
281
282/// Score of a policy over a trace: lower is better.
283#[derive(Debug, Clone, Copy)]
284pub struct PolicyScore {
285    /// Number of times the level changed (oscillation; lower is better).
286    pub level_changes: u32,
287    /// Mean parity shards (overhead; lower is better).
288    pub mean_parity: f32,
289    /// Ticks from the first high-loss sample until the level first
290    /// reaches `Interleave` (responsiveness; lower is better). `u32::MAX`
291    /// if it never escalated while loss was high.
292    pub escalation_lag: u32,
293}
294
295/// Run `policy` over `trace` and score it. `loss_threshold` defines a
296/// "high loss" sample for the escalation-lag measurement.
297pub fn score_policy(
298    policy: &mut dyn FusionPolicy,
299    trace: &[SensorSnapshot],
300    loss_threshold: f32,
301) -> PolicyScore {
302    let mut level_changes = 0u32;
303    let mut parity_sum = 0u64;
304    let mut prev: Option<CodingLevel> = None;
305    let mut first_high: Option<usize> = None;
306    let mut escalation_lag = u32::MAX;
307    for (i, s) in trace.iter().enumerate() {
308        if first_high.is_none() && s.loss >= loss_threshold {
309            first_high = Some(i);
310        }
311        let d = policy.decide(s);
312        parity_sum += d.parity_r as u64;
313        if let Some(p) = prev
314            && p != d.level
315        {
316            level_changes += 1;
317        }
318        prev = Some(d.level);
319        if escalation_lag == u32::MAX
320            && let Some(fh) = first_high
321            && d.level as u8 >= CodingLevel::Interleave as u8
322        {
323            escalation_lag = (i - fh) as u32;
324        }
325    }
326    PolicyScore {
327        level_changes,
328        mean_parity: parity_sum as f32 / trace.len().max(1) as f32,
329        escalation_lag,
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    /// Clean -> bursty-loss spike -> clean again.
338    fn spike_trace() -> Vec<SensorSnapshot> {
339        let mut t = Vec::new();
340        for _ in 0..40 {
341            t.push(SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
342        }
343        for _ in 0..40 {
344            t.push(SensorSnapshot { loss: 0.2, burstiness: 0.7, owd_trend: 0.05, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
345        }
346        for _ in 0..40 {
347            t.push(SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
348        }
349        t
350    }
351
352    /// Loss flaps on/off every tick - the oscillation stress test.
353    fn flapping_trace() -> Vec<SensorSnapshot> {
354        (0..80)
355            .map(|i| {
356                if i % 2 == 0 {
357                    SensorSnapshot { loss: 0.25, burstiness: 0.6, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 }
358                } else {
359                    SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 }
360                }
361            })
362            .collect()
363    }
364
365    #[test]
366    fn both_escalate_fast_on_a_spike() {
367        let spike = spike_trace();
368        let mut a = MaxOfSensors;
369        let mut b = ImmediateUpConservativeDown::new(8);
370        let sa = score_policy(&mut a, &spike, 0.1);
371        let sb = score_policy(&mut b, &spike, 0.1);
372        assert!(sa.escalation_lag <= 1, "max-of-sensors lag {}", sa.escalation_lag);
373        assert!(sb.escalation_lag <= 1, "immediate-up lag {}", sb.escalation_lag);
374    }
375
376    #[test]
377    fn conservative_down_suppresses_flapping() {
378        let flap = flapping_trace();
379        let mut a = MaxOfSensors;
380        let mut b = ImmediateUpConservativeDown::new(8);
381        let sa = score_policy(&mut a, &flap, 0.1);
382        let sb = score_policy(&mut b, &flap, 0.1);
383        // The conservative-down policy must oscillate far less.
384        assert!(
385            sb.level_changes < sa.level_changes,
386            "immediate-up flapped {} vs max {}",
387            sb.level_changes,
388            sa.level_changes
389        );
390    }
391
392    #[test]
393    fn raw_target_scales_parity_and_interleave_with_loss() {
394        // A provably-clean link calls for Passthrough (zero parity).
395        let clean = raw_target(&SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
396        assert_eq!(clean.parity_r, 0);
397        assert_eq!(clean.level, CodingLevel::Passthrough);
398        assert_eq!(clean.interleave_depth, 1);
399        let lossy = raw_target(&SensorSnapshot { loss: 0.3, burstiness: 0.8, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
400        assert!(lossy.parity_r >= 3, "parity {}", lossy.parity_r);
401        assert!(lossy.interleave_depth >= 2, "depth {}", lossy.interleave_depth);
402        assert_eq!(lossy.level, CodingLevel::Interleave);
403    }
404
405    #[test]
406    fn rising_delay_trend_preempts_before_loss() {
407        // No loss yet, but the queue is visibly building.
408        let d = raw_target(&SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.1, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
409        assert_eq!(d.level, CodingLevel::Fec, "rising trend keeps FEC engaged");
410    }
411
412    #[test]
413    fn link_stress_preempts_parity_before_loss() {
414        // No measured loss, but the link sensor reports a degraded link:
415        // parity must rise pre-emptively over the clean case.
416        let clean =
417            raw_target(&SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
418        let stressed =
419            raw_target(&SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.9, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 });
420        assert!(
421            stressed.parity_r > clean.parity_r,
422            "link stress must raise parity: {} vs {}",
423            stressed.parity_r,
424            clean.parity_r
425        );
426    }
427
428    #[test]
429    fn path_shift_and_ecn_each_pre_arm_parity() {
430        // The clean link is Passthrough.
431        assert_eq!(raw_target(&clean()).parity_r, 0);
432        // A hop-count shift (a router-level re-route) must lift parity off the
433        // clean floor before any loss is measured.
434        let shifted = raw_target(&SensorSnapshot {
435            path_shift: 1.0,
436            ..clean()
437        });
438        assert!(
439            shifted.parity_r >= 1,
440            "path shift arms parity pre-emptively: {}",
441            shifted.parity_r
442        );
443        // ECN Congestion-Experienced, marked by an AQM router before it drops,
444        // does the same.
445        let congested = raw_target(&SensorSnapshot {
446            ecn_ce: 0.5,
447            ..clean()
448        });
449        assert!(
450            congested.parity_r >= 1,
451            "ECN-CE arms parity pre-emptively: {}",
452            congested.parity_r
453        );
454    }
455
456    #[test]
457    fn congestion_share_raises_parity_over_wireless() {
458        // The SAME measured loss, classed wireless vs congestion. The wireless
459        // case stays at the base FEC level (recover locally); the congestion
460        // case must raise parity (broad protection), since over-driving or
461        // under-protecting a congested path is the costlier miss.
462        let wireless = raw_target(&SensorSnapshot {
463            loss: 0.2,
464            congestion_fraction: 0.0,
465            ..clean()
466        });
467        let congestion = raw_target(&SensorSnapshot {
468            loss: 0.2,
469            congestion_fraction: 1.0,
470            ..clean()
471        });
472        assert!(
473            congestion.parity_r > wireless.parity_r,
474            "congestion loss must raise parity over wireless: {} vs {}",
475            congestion.parity_r,
476            wireless.parity_r
477        );
478    }
479
480    #[test]
481    fn reverse_loss_arms_fec_off_the_clean_floor() {
482        // A lossy reverse path (feedback) with no measured forward loss must not
483        // sit at Passthrough: lost feedback impairs ARQ, so FEC carries more
484        // defensively rather than relying on a round trip that is dropping.
485        let d = raw_target(&SensorSnapshot {
486            rev_loss: 0.3,
487            ..clean()
488        });
489        assert_ne!(d.level, CodingLevel::Passthrough, "reverse loss must arm FEC");
490        assert!(d.parity_r >= 1, "reverse loss lifts parity off the clean floor");
491    }
492
493    #[test]
494    fn self_induced_bloat_suppresses_delay_parity_bump() {
495        // A rising delay trend normally pre-arms parity. But when the rising
496        // delay is our OWN standing queue (self-induced bufferbloat), adding FEC
497        // would only add wire traffic and deepen the queue - the flow-window
498        // pacer drains it instead. So the same rising trend must NOT bump parity
499        // once queue_delay crosses the bloat threshold.
500        let rising_external =
501            raw_target(&SensorSnapshot { owd_trend: 0.1, queue_delay_ms: 0.0, ..clean() });
502        let rising_self_induced =
503            raw_target(&SensorSnapshot { owd_trend: 0.1, queue_delay_ms: 50.0, ..clean() });
504        assert!(
505            rising_self_induced.parity_r < rising_external.parity_r,
506            "self-induced bloat suppresses the delay-driven parity bump: {} vs {}",
507            rising_self_induced.parity_r,
508            rising_external.parity_r
509        );
510    }
511
512    #[test]
513    fn backhaul_hops_arm_parity_off_the_clean_floor() {
514        // A detected Wi-Fi backhaul hop is not a clean link - each shared-medium
515        // retransmit hop raises expected loss - so parity arms off the floor and
516        // never falls as hops rise.
517        assert_eq!(raw_target(&clean()).level, CodingLevel::Passthrough);
518        let one_hop = raw_target(&SensorSnapshot { backhaul_hops: 1, ..clean() });
519        assert_ne!(one_hop.level, CodingLevel::Passthrough, "a backhaul hop arms FEC");
520        assert!(one_hop.parity_r >= 1, "a backhaul hop lifts parity off the floor");
521        let three_hop = raw_target(&SensorSnapshot { backhaul_hops: 3, ..clean() });
522        assert!(three_hop.parity_r >= one_hop.parity_r, "more hops never lower parity");
523    }
524
525    fn clean() -> SensorSnapshot {
526        SensorSnapshot { loss: 0.0, burstiness: 0.0, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 }
527    }
528    fn lossy() -> SensorSnapshot {
529        SensorSnapshot { loss: 0.15, burstiness: 0.2, owd_trend: 0.0, link_stress: 0.0, path_shift: 0.0, ecn_ce: 0.0, congestion_fraction: 0.0, rev_loss: 0.0, queue_delay_ms: 0.0, backhaul_hops: 0 }
530    }
531
532    #[test]
533    fn sustained_clean_drops_to_passthrough_after_clean_hold() {
534        let mut p = ImmediateUpConservativeDown::with_holds(2, 10);
535        // The first feedback is clean but the policy starts at Fec; it must
536        // NOT drop to Passthrough until clean_hold consecutive clean ticks.
537        for i in 0..9 {
538            let d = p.decide(&clean());
539            assert_ne!(d.level, CodingLevel::Passthrough, "dropped too early at tick {i}");
540            assert!(d.parity_r >= 1, "lost protection too early at tick {i}");
541        }
542        // The clean_hold-th clean tick crosses the confidence window.
543        let d = p.decide(&clean());
544        assert_eq!(d.level, CodingLevel::Passthrough, "should reach Passthrough");
545        assert_eq!(d.parity_r, 0, "Passthrough is zero parity");
546    }
547
548    #[test]
549    fn passthrough_re_arms_instantly_on_loss() {
550        let mut p = ImmediateUpConservativeDown::with_holds(2, 4);
551        for _ in 0..6 {
552            p.decide(&clean());
553        }
554        assert_eq!(p.decide(&clean()).level, CodingLevel::Passthrough);
555        // First lossy tick must re-arm parity immediately - no hold.
556        let d = p.decide(&lossy());
557        assert!(d.parity_r >= 1, "must re-arm parity on the first loss tick");
558        assert_ne!(d.level, CodingLevel::Passthrough, "must leave Passthrough at once");
559    }
560
561    #[test]
562    fn brief_clean_run_never_drops_protection() {
563        // Clean for fewer than clean_hold ticks, then loss: protection must
564        // never have dropped to Passthrough.
565        let mut p = ImmediateUpConservativeDown::with_holds(2, 20);
566        for _ in 0..10 {
567            let d = p.decide(&clean());
568            assert!(d.parity_r >= 1, "must keep protection during a brief clean run");
569        }
570        let d = p.decide(&lossy());
571        assert!(d.parity_r >= 1);
572    }
573}