Skip to main content

subetha_cxc/
path_sensor.rs

1//! Path sensing from the peer's TTL / ECN observations.
2//!
3//! The receiver reads the IP TTL and ECN bits off every datagram (a passive
4//! cmsg, no protocol cost) and echoes them back in a [`PathFrame`]. The
5//! sender feeds that stream here to derive two feed-forward signals the
6//! adaptive controller fuses alongside loss and delay-trend:
7//!
8//!  - **Path shift**: a change in hop count means a router-level path change
9//!    (a re-route, a link failover). It often precedes a throughput change,
10//!    so it pre-arms protection before loss materializes. The signal spikes
11//!    to 1.0 on the change and decays.
12//!  - **ECN-CE rate**: an AQM router marks Congestion-Experienced *before*
13//!    it tail-drops. A rising CE rate is a direct "queue is building" signal
14//!    that, like a rising delay trend, calls for protection ahead of loss.
15//!
16//! The estimator holds no clock and does no I/O - the caller supplies each
17//! `(hop_count, ecn)` observation - so it is deterministic and exhaustively
18//! testable with synthetic traces.
19//!
20//! [`PathFrame`]: crate::control_frame::PathFrame
21
22/// The two-bit ECN codepoint marking Congestion Experienced (RFC 3168). An
23/// AQM router sets this on a packet it would otherwise have to drop.
24pub const ECN_CE: u8 = 0b11;
25
26/// Common initial IP TTL values, smallest first. Hosts start a packet at one
27/// of these and every router decrements by one, so the smallest of these that
28/// is at least the observed TTL is the likely origin, and the difference is
29/// the hop count.
30const INITIAL_TTLS: [u8; 3] = [64, 128, 255];
31
32/// Derive a hop count from an observed TTL: pick the smallest standard
33/// initial TTL not below the observed value, and subtract.
34pub fn hop_count_from_ttl(ttl: u8) -> u8 {
35    for &init in &INITIAL_TTLS {
36        if ttl <= init {
37            return init - ttl;
38        }
39    }
40    0
41}
42
43/// Rolling estimator over the peer's path observations.
44#[derive(Debug, Default)]
45pub struct PathSensor {
46    /// Last hop count seen, to detect a change.
47    last_hop_count: Option<u8>,
48    /// Decaying path-shift signal: 1.0 on a hop-count change, then fading.
49    shift: f32,
50    /// EWMA of the Congestion-Experienced marking rate, 0..=1.
51    ce_rate: f32,
52    /// Last raw `(ttl, ecn, hop_count)` echoed, for diagnostics.
53    last: Option<(u8, u8, u8)>,
54}
55
56impl PathSensor {
57    /// A fresh sensor with no observations.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Record one `(hop_count, ecn)` observation echoed by the peer.
63    pub fn observe(&mut self, ttl: u8, ecn: u8, hop_count: u8) {
64        let changed = self.last_hop_count.is_some_and(|p| p != hop_count);
65        self.last_hop_count = Some(hop_count);
66        // A hop-count change spikes the shift signal; an unchanged path lets
67        // it decay back toward zero.
68        if changed {
69            self.shift = 1.0;
70        } else {
71            self.shift *= 0.85;
72        }
73        let ce = if ecn & 0b11 == ECN_CE { 1.0 } else { 0.0 };
74        self.ce_rate += (ce - self.ce_rate) * 0.2;
75        self.last = Some((ttl, ecn, hop_count));
76    }
77
78    /// Path-shift signal, 0..=1: high just after a router-level path change.
79    pub fn path_shift(&self) -> f32 {
80        self.shift
81    }
82
83    /// Congestion-Experienced marking rate, 0..=1: a queue-building signal.
84    pub fn ecn_ce(&self) -> f32 {
85        self.ce_rate
86    }
87
88    /// Last observed `(ttl, ecn, hop_count)`, for diagnostics / telemetry.
89    pub fn last(&self) -> Option<(u8, u8, u8)> {
90        self.last
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn hop_count_inference_matches_known_initials() {
100        // Linux origin (64) eleven hops away.
101        assert_eq!(hop_count_from_ttl(53), 11);
102        // Windows origin (128) eleven hops away.
103        assert_eq!(hop_count_from_ttl(117), 11);
104        // Router origin (255), one hop.
105        assert_eq!(hop_count_from_ttl(254), 1);
106        // Direct (no decrement) from a 64-init host.
107        assert_eq!(hop_count_from_ttl(64), 0);
108    }
109
110    #[test]
111    fn steady_path_keeps_shift_low() {
112        let mut s = PathSensor::new();
113        for _ in 0..20 {
114            s.observe(53, 0, 11);
115        }
116        assert!(s.path_shift() < 0.05, "steady path -> shift ~0");
117    }
118
119    #[test]
120    fn hop_count_change_spikes_then_decays() {
121        let mut s = PathSensor::new();
122        for _ in 0..10 {
123            s.observe(53, 0, 11);
124        }
125        // A re-route: hop count jumps 11 -> 14.
126        s.observe(50, 0, 14);
127        assert!(s.path_shift() > 0.9, "path shift spikes on hop-count change");
128        // Settling back: the signal decays over subsequent steady samples.
129        for _ in 0..10 {
130            s.observe(50, 0, 14);
131        }
132        assert!(s.path_shift() < 0.2, "shift decays once the path is steady");
133    }
134
135    #[test]
136    fn ce_marking_rate_rises_with_congestion() {
137        let mut s = PathSensor::new();
138        for _ in 0..20 {
139            s.observe(53, 0, 11); // ECT, no congestion
140        }
141        assert!(s.ecn_ce() < 0.05, "no CE -> rate ~0");
142        for _ in 0..20 {
143            s.observe(53, ECN_CE, 11); // congestion experienced
144        }
145        assert!(s.ecn_ce() > 0.8, "sustained CE -> rate climbs toward 1");
146    }
147}