alpine/stream/
network.rs

1//! Network condition detection helpers for ALPINE streaming.
2//!
3//! Phase 3.1 introduces deterministic metrics for packet loss, late frames, and
4//! jitter so we can reason about what the network is doing without changing
5//! runtime behavior yet. Each session gets its own `NetworkConditions` tracker,
6//! and the metrics snapshot exposes `loss_ratio`, `late_frame_rate`, and
7//! `jitter_ms` derived from observed arrival timelines.
8
9/// Snapshot of the observed network metrics for a single session.
10#[derive(Debug, Clone, Copy)]
11pub struct NetworkMetrics {
12    /// Fraction of expected frames that never arrived, in `[0, 1]`.
13    pub loss_ratio: f64,
14    /// Fraction of observed frames that missed their delivery deadline.
15    pub late_frame_rate: f64,
16    /// Average jitter in milliseconds between consecutive arrivals.
17    pub jitter_ms: Option<f64>,
18}
19
20/// Determines the network conditions for an ALPINE streaming session.
21pub struct NetworkConditions {
22    last_sequence: Option<u64>,
23    total_expected: u64,
24    observed_frames: u64,
25    lost_frames: u64,
26    late_frames: u64,
27    last_arrival: Option<u64>,
28    last_interval: Option<u64>,
29    total_jitter_ns: u128,
30    jitter_samples: u64,
31    max_loss_gap: u64,
32}
33
34impl NetworkConditions {
35    /// Creates a fresh tracker.
36    pub fn new() -> Self {
37        Self {
38            last_sequence: None,
39            total_expected: 0,
40            observed_frames: 0,
41            lost_frames: 0,
42            late_frames: 0,
43            last_arrival: None,
44            last_interval: None,
45            total_jitter_ns: 0,
46            jitter_samples: 0,
47            max_loss_gap: 0,
48        }
49    }
50
51    /// Records an observed frame arrival.
52    ///
53    /// The stream encodes `sequence`, `arrival_us`, and the caller-supplied
54    /// `deadline_us` so we can independently reason about lateness, loss, and
55    /// jitter. All calculations are deterministic and rely solely on these
56    /// inputs.
57    pub fn record_frame(&mut self, sequence: u64, arrival_us: u64, deadline_us: u64) {
58        if let Some(last_seq) = self.last_sequence {
59            if sequence <= last_seq {
60                // Out-of-order or duplicate frames do not affect the metrics.
61                return;
62            }
63            let delta = sequence - last_seq;
64            self.total_expected = self.total_expected.saturating_add(delta);
65            if delta > 1 {
66                self.lost_frames = self.lost_frames.saturating_add(delta - 1);
67                self.max_loss_gap = self.max_loss_gap.max(delta - 1);
68            }
69        } else {
70            self.total_expected = self.total_expected.saturating_add(1);
71        }
72
73        self.last_sequence = Some(sequence);
74        self.observed_frames = self.observed_frames.saturating_add(1);
75
76        if arrival_us > deadline_us {
77            self.late_frames = self.late_frames.saturating_add(1);
78        }
79
80        if let Some(last) = self.last_arrival {
81            let interval = arrival_us.saturating_sub(last);
82            if let Some(prev_interval) = self.last_interval {
83                let jitter = if interval > prev_interval {
84                    interval - prev_interval
85                } else {
86                    prev_interval - interval
87                };
88                self.total_jitter_ns = self.total_jitter_ns.saturating_add(jitter as u128);
89                self.jitter_samples = self.jitter_samples.saturating_add(1);
90            }
91            self.last_interval = Some(interval);
92        }
93        self.last_arrival = Some(arrival_us);
94    }
95
96    /// Returns the latest metrics snapshot.
97    pub fn metrics(&self) -> NetworkMetrics {
98        let total_expected = self.total_expected.max(self.observed_frames);
99        let loss_ratio = if total_expected == 0 {
100            0.0
101        } else {
102            self.lost_frames as f64 / total_expected as f64
103        };
104
105        let late_frame_rate = if self.observed_frames == 0 {
106            0.0
107        } else {
108            self.late_frames as f64 / self.observed_frames as f64
109        };
110
111        let jitter_ms = if self.jitter_samples == 0 {
112            None
113        } else {
114            Some(self.total_jitter_ns as f64 / self.jitter_samples as f64 / 1000.0)
115        };
116
117        NetworkMetrics {
118            loss_ratio,
119            late_frame_rate,
120            jitter_ms,
121        }
122    }
123
124    /// Returns the largest sequence gap observed for burst detection.
125    pub fn max_loss_gap(&self) -> u64 {
126        self.max_loss_gap
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn loss_ratio_accounts_for_missing_sequences() {
136        let mut net = NetworkConditions::new();
137        net.record_frame(1, 0, 1);
138        net.record_frame(2, 1_000, 2_000);
139        net.record_frame(4, 3_000, 4_000);
140        let metrics = net.metrics();
141        assert!((metrics.loss_ratio - (1.0 / 4.0)).abs() < f64::EPSILON);
142    }
143
144    #[test]
145    fn late_frame_rate_counts_deadlines() {
146        let mut net = NetworkConditions::new();
147        net.record_frame(1, 0, 0);
148        net.record_frame(2, 5_000, 3_000);
149        net.record_frame(3, 6_000, 6_000);
150        let metrics = net.metrics();
151        assert!((metrics.late_frame_rate - (1.0 / 3.0)).abs() < f64::EPSILON);
152    }
153
154    #[test]
155    fn jitter_ms_average() {
156        let mut net = NetworkConditions::new();
157        net.record_frame(1, 0, 0);
158        net.record_frame(2, 1_000, 2_000);
159        net.record_frame(3, 2_500, 4_000);
160        net.record_frame(4, 3_900, 5_000);
161        let metrics = net.metrics();
162        // intervals: 1000, 1500, 1400 -> diffs: 500, 100 -> avg = 300 µs => 0.3 ms
163        assert_eq!(metrics.jitter_ms, Some(0.3));
164    }
165}