1use crate::control_table::CodingLevel;
13
14#[derive(Debug, Clone, Copy, Default)]
16pub struct SensorSnapshot {
17 pub loss: f32,
19 pub burstiness: f32,
21 pub owd_trend: f32,
24 pub link_stress: f32,
27 pub path_shift: f32,
31 pub ecn_ce: f32,
35 pub congestion_fraction: f32,
40 pub rev_loss: f32,
44 pub queue_delay_ms: f32,
50 pub backhaul_hops: u8,
54}
55
56#[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
64const OWD_RISING: f32 = 0.02;
66
67const CLEAN_STRESS_EPS: f32 = 0.02;
70
71const CONGESTION_PARITY_WEIGHT: f32 = 0.5;
76
77pub const QUEUE_BLOAT_MS: f32 = 25.0;
82
83const BACKHAUL_HOP_PARITY: f32 = 0.03;
87
88pub 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
105pub 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
130pub fn raw_target(s: &SensorSnapshot) -> ControlDecision {
134 if is_clean(s) {
138 return ControlDecision {
139 level: CodingLevel::Passthrough,
140 parity_r: 0,
141 interleave_depth: 1,
142 };
143 }
144 let effective_loss = effective_loss(s);
148 let parity_r = ((effective_loss * 8.0).ceil() as u8 + 1).clamp(1, 6);
149 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
162pub trait FusionPolicy {
165 fn name(&self) -> &'static str;
167 fn decide(&mut self, s: &SensorSnapshot) -> ControlDecision;
169}
170
171#[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#[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 pub fn new(hold: u32) -> Self {
205 let hold = hold.max(1);
206 Self::with_holds(hold, hold.saturating_mul(4))
207 }
208
209 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 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 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 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#[derive(Debug, Clone, Copy)]
284pub struct PolicyScore {
285 pub level_changes: u32,
287 pub mean_parity: f32,
289 pub escalation_lag: u32,
293}
294
295pub 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 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 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 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 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 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 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 assert_eq!(raw_target(&clean()).parity_r, 0);
432 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 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 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 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 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 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 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 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 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 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}