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