Files
wz-phone/crates/wzp-transport/src/path_monitor.rs
Siavash Sameni 766c9df442 feat(dred): continuous DRED tuning, PMTUD, extended Opus6k window
- DredTuner: maps live network metrics (loss/RTT/jitter) to continuous
  DRED duration every ~500ms instead of discrete tier-locked values.
  Includes jitter-spike detection for pre-emptive Starlink-style boost.
- Opus6k DRED extended from 500ms to 1040ms (max libopus 1.5 supports)
- PMTUD: quinn MtuDiscoveryConfig with upper_bound=1452, 300s interval
- TrunkedForwarder respects discovered MTU (was hard-coded 1200)
- QuinnPathSnapshot exposes quinn internal stats + discovered MTU
- AudioEncoder trait: set_expected_loss() + set_dred_duration() methods
- PathMonitor: sliding-window jitter variance for spike detection
- Integrated into both Android and desktop send tasks in engine.rs
- 14 new tests (10 tuner unit + 4 encoder integration)
- Updated ARCHITECTURE.md, PROGRESS.md, PRD-dred-integration, PRD-mtu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:38:37 +04:00

319 lines
10 KiB
Rust

//! Network path quality estimation using EWMA smoothing.
//!
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
use std::collections::VecDeque;
use wzp_proto::PathQuality;
/// EWMA smoothing factor.
const ALPHA: f64 = 0.1;
/// Maximum number of RTT samples in the jitter variance sliding window.
/// At ~50 packets/sec (20 ms frame), 10 samples ≈ 200 ms.
const JITTER_VARIANCE_WINDOW_SIZE: usize = 10;
/// Monitors network path quality metrics.
pub struct PathMonitor {
/// EWMA-smoothed loss percentage (0.0 - 100.0).
loss_ewma: f64,
/// EWMA-smoothed RTT in milliseconds.
rtt_ewma: f64,
/// EWMA-smoothed jitter (RTT variance) in milliseconds.
jitter_ewma: f64,
/// Total bytes observed for bandwidth estimation.
bytes_sent: u64,
bytes_received: u64,
/// Timestamps for bandwidth calculation.
first_send_time_ms: Option<u64>,
last_send_time_ms: Option<u64>,
first_recv_time_ms: Option<u64>,
last_recv_time_ms: Option<u64>,
/// Sequence tracking for loss detection.
highest_sent_seq: Option<u16>,
total_sent: u64,
total_received: u64,
/// Last observed RTT for jitter calculation.
last_rtt_ms: Option<f64>,
/// Whether we have any observations yet.
initialized: bool,
/// Sliding window of recent RTT samples for variance calculation.
rtt_window: VecDeque<f64>,
}
impl PathMonitor {
/// Create a new path monitor with default (zero) initial values.
pub fn new() -> Self {
Self {
loss_ewma: 0.0,
rtt_ewma: 0.0,
jitter_ewma: 0.0,
bytes_sent: 0,
bytes_received: 0,
first_send_time_ms: None,
last_send_time_ms: None,
first_recv_time_ms: None,
last_recv_time_ms: None,
highest_sent_seq: None,
total_sent: 0,
total_received: 0,
last_rtt_ms: None,
initialized: false,
rtt_window: VecDeque::with_capacity(JITTER_VARIANCE_WINDOW_SIZE),
}
}
/// Record that we sent a packet with the given sequence number and timestamp.
pub fn observe_sent(&mut self, seq: u16, timestamp_ms: u64) {
self.total_sent += 1;
self.highest_sent_seq = Some(seq);
if self.first_send_time_ms.is_none() {
self.first_send_time_ms = Some(timestamp_ms);
}
self.last_send_time_ms = Some(timestamp_ms);
// Estimate ~100 bytes per packet for bandwidth calculation
self.bytes_sent += 100;
}
/// Record that we received a packet with the given sequence number and timestamp.
pub fn observe_received(&mut self, seq: u16, timestamp_ms: u64) {
self.total_received += 1;
if self.first_recv_time_ms.is_none() {
self.first_recv_time_ms = Some(timestamp_ms);
}
self.last_recv_time_ms = Some(timestamp_ms);
self.bytes_received += 100;
// Estimate loss from sequence gaps.
// After we've sent some packets, compute instantaneous loss.
if self.total_sent > 0 {
let expected = self.total_sent;
let received = self.total_received;
let inst_loss = if expected > received {
((expected - received) as f64 / expected as f64) * 100.0
} else {
0.0
};
if !self.initialized {
self.loss_ewma = inst_loss;
self.initialized = true;
} else {
self.loss_ewma = ALPHA * inst_loss + (1.0 - ALPHA) * self.loss_ewma;
}
}
let _ = seq; // seq used implicitly via total counts
}
/// Record an RTT observation in milliseconds.
pub fn observe_rtt(&mut self, rtt_ms: u32) {
let rtt = rtt_ms as f64;
// Update jitter (difference from last RTT, smoothed)
if let Some(last_rtt) = self.last_rtt_ms {
let diff = (rtt - last_rtt).abs();
if self.jitter_ewma == 0.0 {
self.jitter_ewma = diff;
} else {
self.jitter_ewma = ALPHA * diff + (1.0 - ALPHA) * self.jitter_ewma;
}
}
self.last_rtt_ms = Some(rtt);
// Update RTT EWMA
if self.rtt_ewma == 0.0 {
self.rtt_ewma = rtt;
} else {
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
}
// Maintain sliding window for variance calculation
if self.rtt_window.len() >= JITTER_VARIANCE_WINDOW_SIZE {
self.rtt_window.pop_front();
}
self.rtt_window.push_back(rtt);
}
/// Get the current estimated path quality.
pub fn quality(&self) -> PathQuality {
let bandwidth_kbps = self.estimate_bandwidth_kbps();
PathQuality {
loss_pct: self.loss_ewma as f32,
rtt_ms: self.rtt_ewma as u32,
jitter_ms: self.jitter_ewma as u32,
bandwidth_kbps,
}
}
/// Get raw packet counts for debugging.
pub fn counts(&self) -> (u64, u64) {
(self.total_sent, self.total_received)
}
/// Estimate bandwidth in kbps from bytes received over time.
fn estimate_bandwidth_kbps(&self) -> u32 {
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
let duration_ms = last.saturating_sub(first);
if duration_ms > 0 {
// bytes_received * 8 bits / duration_ms * 1000 ms/s / 1000 bits/kbit
let bits = self.bytes_received * 8;
let kbps = bits as f64 / duration_ms as f64;
return kbps as u32;
}
}
0
}
/// Compute the jitter (RTT standard deviation) over the sliding window.
///
/// Returns the standard deviation in milliseconds, or 0.0 if insufficient
/// samples. Used by `DredTuner` for spike detection.
pub fn jitter_variance_ms(&self) -> f64 {
let n = self.rtt_window.len();
if n < 2 {
return 0.0;
}
let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
var.sqrt()
}
/// Detect whether a network handoff likely occurred.
///
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
/// the EWMA-smoothed jitter average, which is characteristic of a cellular
/// network handoff (tower switch, WiFi-to-cellular transition, etc.).
pub fn detect_handoff(&self) -> bool {
// We need at least two RTT observations to have a meaningful jitter value,
// and the EWMA must be non-zero to avoid division/multiplication by zero.
if self.jitter_ewma <= 0.0 {
return false;
}
if let (Some(last_rtt), Some(_)) = (self.last_rtt_ms, Some(self.rtt_ewma)) {
// Compute the most recent instantaneous jitter (RTT deviation from EWMA)
let instant_jitter = (last_rtt - self.rtt_ewma).abs();
instant_jitter > self.jitter_ewma * 3.0
} else {
false
}
}
}
impl Default for PathMonitor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_quality_is_zero() {
let monitor = PathMonitor::new();
let q = monitor.quality();
assert_eq!(q.loss_pct, 0.0);
assert_eq!(q.rtt_ms, 0);
assert_eq!(q.jitter_ms, 0);
assert_eq!(q.bandwidth_kbps, 0);
}
#[test]
fn rtt_ewma_smoothing() {
let mut monitor = PathMonitor::new();
// First observation sets the initial value
monitor.observe_rtt(100);
let q = monitor.quality();
assert_eq!(q.rtt_ms, 100);
// Second observation should be smoothed: 0.1 * 200 + 0.9 * 100 = 110
monitor.observe_rtt(200);
let q = monitor.quality();
assert_eq!(q.rtt_ms, 110);
// Third: 0.1 * 200 + 0.9 * 110 = 119
monitor.observe_rtt(200);
let q = monitor.quality();
assert_eq!(q.rtt_ms, 119);
}
#[test]
fn jitter_from_rtt_variance() {
let mut monitor = PathMonitor::new();
monitor.observe_rtt(100);
// No jitter yet (only one observation)
assert_eq!(monitor.quality().jitter_ms, 0);
monitor.observe_rtt(150);
// Jitter = |150 - 100| = 50 (first jitter observation, sets directly)
assert_eq!(monitor.quality().jitter_ms, 50);
monitor.observe_rtt(140);
// diff = |140 - 150| = 10
// jitter = 0.1 * 10 + 0.9 * 50 = 46
assert_eq!(monitor.quality().jitter_ms, 46);
}
#[test]
fn detect_packet_loss_from_gaps() {
let mut monitor = PathMonitor::new();
// Send 10 packets
for i in 0..10 {
monitor.observe_sent(i, i as u64 * 20);
}
// Receive only 7 of them (30% loss)
for i in [0u16, 1, 2, 3, 5, 7, 9] {
monitor.observe_received(i, i as u64 * 20 + 50);
}
let q = monitor.quality();
// After 7 observations, the EWMA should converge towards 30%
// The exact value depends on the EWMA progression
assert!(q.loss_pct > 0.0, "should detect some loss");
assert!(q.loss_pct < 100.0, "loss should be reasonable");
}
#[test]
fn bandwidth_estimation() {
let mut monitor = PathMonitor::new();
// Receive 100 packets over 1000ms, each ~100 bytes
for i in 0..100 {
monitor.observe_received(i, i as u64 * 10);
monitor.observe_sent(i, i as u64 * 10);
}
let q = monitor.quality();
// 100 packets * 100 bytes * 8 bits / 990ms ~= 80.8 kbps
assert!(q.bandwidth_kbps > 0, "should estimate non-zero bandwidth");
}
#[test]
fn no_loss_when_all_received() {
let mut monitor = PathMonitor::new();
for i in 0..20 {
monitor.observe_sent(i, i as u64 * 20);
monitor.observe_received(i, i as u64 * 20 + 30);
}
let q = monitor.quality();
assert!(
q.loss_pct < 1.0,
"loss should be near zero when all packets received"
);
}
}