Skip to main content

fips_core/proto/mmp/
metrics.rs

1//! MMP derived metrics.
2//!
3//! `MmpMetrics` processes incoming ReceiverReports (from our peer) and
4//! maintains derived metrics: SRTT, loss rate, goodput, ETX, and dual
5//! EWMA trend indicators. Updated by the sender side when it receives
6//! a ReceiverReport about its own traffic.
7
8use super::algorithms::{DualEwma, SrttEstimator, compute_etx};
9use super::report::ReceiverReport;
10use std::time::Instant;
11use tracing::trace;
12
13/// Derived MMP metrics, updated from incoming ReceiverReports.
14///
15/// This lives on the sender side: when we receive a ReceiverReport from
16/// our peer describing what they observed about our traffic, we process
17/// it here to compute RTT, loss, goodput, and trend indicators.
18pub struct MmpMetrics {
19    /// Smoothed RTT from timestamp echo.
20    pub srtt: SrttEstimator,
21
22    /// Dual EWMA trend detectors.
23    pub rtt_trend: DualEwma,
24    pub loss_trend: DualEwma,
25    pub goodput_trend: DualEwma,
26    pub jitter_trend: DualEwma,
27    pub etx_trend: DualEwma,
28
29    /// Forward delivery ratio (what fraction of our frames the peer received).
30    pub delivery_ratio_forward: f64,
31    /// Reverse delivery ratio (set when we compute from our own receiver state).
32    pub delivery_ratio_reverse: f64,
33    /// ETX computed from bidirectional delivery ratios.
34    pub etx: f64,
35
36    /// Smoothed goodput in bytes/sec (forward direction: what the peer received from us).
37    pub goodput_bps: f64,
38
39    // --- State for delta computation ---
40    /// Previous ReceiverReport's cumulative counters (for computing interval deltas).
41    prev_rr_cum_packets: u64,
42    prev_rr_cum_bytes: u64,
43    prev_rr_highest_counter: u64,
44    prev_rr_ecn_ce: u32,
45    prev_rr_reorder: u32,
46    /// Time of previous ReceiverReport (for goodput rate computation).
47    prev_rr_time: Option<Instant>,
48    /// Time of the most recent accepted RTT sample.
49    last_srtt_update: Option<Instant>,
50    /// Whether we have a previous ReceiverReport for delta computation.
51    has_prev_rr: bool,
52    /// Counter span in the most recent ReceiverReport delta.
53    last_forward_counter_span: u64,
54    /// Loss rate in the most recent ReceiverReport delta.
55    last_forward_loss_rate: Option<f64>,
56    /// Accumulated low-rate forward loss evidence since the last actionable
57    /// route-quality sample was emitted.
58    forward_loss_window_span: u64,
59    forward_loss_window_lost: u64,
60
61    // --- State for reverse delivery ratio delta computation ---
62    /// Previous reverse-side cumulative packets received (our receiver state).
63    prev_reverse_packets: u64,
64    /// Previous reverse-side highest counter (our receiver state).
65    prev_reverse_highest: u64,
66    /// Whether we have a previous reverse-side snapshot for delta computation.
67    has_prev_reverse: bool,
68}
69
70impl MmpMetrics {
71    /// Reset state derived from ReceiverReport counters for rekey cutover.
72    ///
73    /// The new session starts with counter 0, so the prev_rr deltas must
74    /// be reset to avoid computing bogus loss/goodput from the counter
75    /// discontinuity. RTT (SRTT) is preserved since it remains valid.
76    pub fn reset_for_rekey(&mut self) {
77        self.prev_rr_cum_packets = 0;
78        self.prev_rr_cum_bytes = 0;
79        self.prev_rr_highest_counter = 0;
80        self.prev_rr_ecn_ce = 0;
81        self.prev_rr_reorder = 0;
82        self.prev_rr_time = None;
83        self.has_prev_rr = false;
84        self.last_forward_counter_span = 0;
85        self.last_forward_loss_rate = None;
86        self.forward_loss_window_span = 0;
87        self.forward_loss_window_lost = 0;
88        self.delivery_ratio_forward = 1.0;
89        self.prev_reverse_packets = 0;
90        self.prev_reverse_highest = 0;
91        self.has_prev_reverse = false;
92        // Keep srtt, srtt freshness, etx, trends, goodput_bps — they'll refresh from data.
93    }
94
95    pub fn new() -> Self {
96        Self {
97            srtt: SrttEstimator::new(),
98            rtt_trend: DualEwma::new(),
99            loss_trend: DualEwma::new(),
100            goodput_trend: DualEwma::new(),
101            jitter_trend: DualEwma::new(),
102            etx_trend: DualEwma::new(),
103            delivery_ratio_forward: 1.0,
104            delivery_ratio_reverse: 1.0,
105            etx: 1.0,
106            goodput_bps: 0.0,
107            prev_rr_cum_packets: 0,
108            prev_rr_cum_bytes: 0,
109            prev_rr_highest_counter: 0,
110            prev_rr_ecn_ce: 0,
111            prev_rr_reorder: 0,
112            prev_rr_time: None,
113            last_srtt_update: None,
114            has_prev_rr: false,
115            last_forward_counter_span: 0,
116            last_forward_loss_rate: None,
117            forward_loss_window_span: 0,
118            forward_loss_window_lost: 0,
119            prev_reverse_packets: 0,
120            prev_reverse_highest: 0,
121            has_prev_reverse: false,
122        }
123    }
124
125    /// Process an incoming ReceiverReport (from the peer about our traffic).
126    ///
127    /// `our_timestamp_ms` is the current session-relative time in ms (for RTT).
128    /// `now` is the current monotonic time (for goodput rate computation).
129    ///
130    /// Returns `true` if this report produced the first SRTT measurement
131    /// (transition from uninitialized to initialized).
132    pub fn process_receiver_report(
133        &mut self,
134        rr: &ReceiverReport,
135        our_timestamp_ms: u32,
136        now: Instant,
137    ) -> bool {
138        let had_srtt = self.srtt.initialized();
139
140        if self.has_prev_rr {
141            let counters_regressed = rr.highest_counter < self.prev_rr_highest_counter
142                || rr.cumulative_packets_recv < self.prev_rr_cum_packets
143                || rr.cumulative_bytes_recv < self.prev_rr_cum_bytes
144                || rr.ecn_ce_count < self.prev_rr_ecn_ce
145                || rr.cumulative_reorder_count < self.prev_rr_reorder;
146            let duplicate_counters = rr.highest_counter == self.prev_rr_highest_counter
147                && rr.cumulative_packets_recv == self.prev_rr_cum_packets
148                && rr.cumulative_bytes_recv == self.prev_rr_cum_bytes
149                && rr.ecn_ce_count == self.prev_rr_ecn_ce
150                && rr.cumulative_reorder_count == self.prev_rr_reorder;
151            if counters_regressed || duplicate_counters {
152                trace!(
153                    highest_counter = rr.highest_counter,
154                    prev_highest_counter = self.prev_rr_highest_counter,
155                    cumulative_packets_recv = rr.cumulative_packets_recv,
156                    prev_cumulative_packets_recv = self.prev_rr_cum_packets,
157                    cumulative_bytes_recv = rr.cumulative_bytes_recv,
158                    prev_cumulative_bytes_recv = self.prev_rr_cum_bytes,
159                    "Ignoring stale MMP ReceiverReport"
160                );
161                return false;
162            }
163        }
164
165        // --- RTT from timestamp echo ---
166        // RTT = now - echoed_timestamp - dwell_time
167        if rr.timestamp_echo > 0 {
168            let echo_ms = rr.timestamp_echo;
169            let dwell_ms = u32::from(rr.dwell_time);
170            let rtt_sample_ms = echo_ms
171                .checked_add(dwell_ms)
172                .and_then(|send_done_ms| our_timestamp_ms.checked_sub(send_done_ms));
173
174            match rtt_sample_ms {
175                Some(rtt_ms) if rtt_ms > 0 => {
176                    let rtt_us = (rtt_ms as i64) * 1000;
177                    trace!(
178                        our_ts = our_timestamp_ms,
179                        echo = echo_ms,
180                        dwell = dwell_ms,
181                        rtt_ms = rtt_ms,
182                        srtt_ms = self.srtt.srtt_us() as f64 / 1000.0,
183                        "RTT sample from timestamp echo"
184                    );
185                    self.srtt.update(rtt_us);
186                    self.last_srtt_update = Some(now);
187                    self.rtt_trend.update(rtt_us as f64);
188                }
189                _ => {
190                    trace!(
191                        our_ts = our_timestamp_ms,
192                        echo = echo_ms,
193                        dwell = dwell_ms,
194                        "Ignoring invalid MMP RTT sample"
195                    );
196                }
197            }
198        }
199
200        // --- Loss rate from cumulative counters ---
201        // Delta: frames the peer should have received vs. actually received
202        self.last_forward_counter_span = 0;
203        self.last_forward_loss_rate = None;
204        if self.has_prev_rr {
205            let counter_span = rr
206                .highest_counter
207                .saturating_sub(self.prev_rr_highest_counter);
208            let packets_delta = rr
209                .cumulative_packets_recv
210                .saturating_sub(self.prev_rr_cum_packets);
211
212            if counter_span > 0 {
213                let delivery = (packets_delta as f64) / (counter_span as f64);
214                self.delivery_ratio_forward = delivery.clamp(0.0, 1.0);
215                let loss_rate = 1.0 - self.delivery_ratio_forward;
216                self.last_forward_counter_span = counter_span;
217                self.last_forward_loss_rate = Some(loss_rate);
218                self.forward_loss_window_span =
219                    self.forward_loss_window_span.saturating_add(counter_span);
220                self.forward_loss_window_lost = self
221                    .forward_loss_window_lost
222                    .saturating_add(counter_span.saturating_sub(packets_delta));
223                self.loss_trend.update(loss_rate);
224                self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
225                self.etx_trend.update(self.etx);
226            }
227        }
228
229        // --- Goodput from cumulative bytes + time delta ---
230        if self.has_prev_rr {
231            let bytes_delta = rr
232                .cumulative_bytes_recv
233                .saturating_sub(self.prev_rr_cum_bytes);
234            self.goodput_trend.update(bytes_delta as f64);
235
236            // Compute bytes/sec if we have a time reference
237            if let Some(prev_time) = self.prev_rr_time {
238                let elapsed = now.duration_since(prev_time);
239                let secs = elapsed.as_secs_f64();
240                if secs > 0.0 {
241                    let bps = bytes_delta as f64 / secs;
242                    // EWMA smoothing: α = 1/4
243                    if self.goodput_bps == 0.0 {
244                        self.goodput_bps = bps;
245                    } else {
246                        self.goodput_bps += (bps - self.goodput_bps) * 0.25;
247                    }
248                }
249            }
250        }
251
252        // --- Jitter trend ---
253        self.jitter_trend.update(rr.jitter as f64);
254
255        // --- Save for next delta ---
256        self.prev_rr_cum_packets = rr.cumulative_packets_recv;
257        self.prev_rr_cum_bytes = rr.cumulative_bytes_recv;
258        self.prev_rr_highest_counter = rr.highest_counter;
259        self.prev_rr_ecn_ce = rr.ecn_ce_count;
260        self.prev_rr_reorder = rr.cumulative_reorder_count;
261        self.prev_rr_time = Some(now);
262        self.has_prev_rr = true;
263
264        !had_srtt && self.srtt.initialized()
265    }
266
267    /// Update the reverse delivery ratio from our own receiver state.
268    ///
269    /// Computes a per-interval delta (same as forward ratio) rather than
270    /// a lifetime cumulative ratio, so ETX responds to recent conditions.
271    pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) {
272        if self.has_prev_reverse {
273            let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest);
274            let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets);
275
276            if counter_span > 0 {
277                let delivery = (packets_delta as f64) / (counter_span as f64);
278                self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0);
279                self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
280                self.etx_trend.update(self.etx);
281            }
282        }
283
284        self.prev_reverse_packets = our_recv_packets;
285        self.prev_reverse_highest = peer_highest;
286        self.has_prev_reverse = true;
287    }
288
289    /// Current smoothed RTT in milliseconds, or `None` if not yet measured.
290    pub fn srtt_ms(&self) -> Option<f64> {
291        if self.srtt.initialized() {
292            Some(self.srtt.srtt_us() as f64 / 1000.0)
293        } else {
294            None
295        }
296    }
297
298    /// Age of the current SRTT sample in milliseconds.
299    pub fn srtt_age_ms(&self, now: Instant) -> Option<u64> {
300        self.last_srtt_update.map(|updated_at| {
301            now.saturating_duration_since(updated_at)
302                .as_millis()
303                .min(u128::from(u64::MAX)) as u64
304        })
305    }
306
307    /// Current loss rate (0.0 = no loss, 1.0 = total loss).
308    pub fn loss_rate(&self) -> f64 {
309        1.0 - self.delivery_ratio_forward
310    }
311
312    /// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized.
313    pub fn smoothed_loss(&self) -> Option<f64> {
314        if self.loss_trend.initialized() {
315            Some(self.loss_trend.long())
316        } else {
317            None
318        }
319    }
320
321    /// Most recent forward-loss sample from a ReceiverReport delta.
322    pub fn last_forward_loss_sample(&self) -> Option<(u64, f64)> {
323        self.last_forward_loss_rate
324            .map(|loss| (self.last_forward_counter_span, loss))
325    }
326
327    /// Return route-quality loss evidence once enough packets have been
328    /// observed. High-rate reports are emitted as-is; low-rate reports, such as
329    /// interactive pings, accumulate until they have the same weight.
330    pub fn take_forward_loss_evidence(&mut self, min_span: u64) -> Option<(u64, f64)> {
331        if min_span == 0 {
332            return self.last_forward_loss_sample();
333        }
334
335        if self.last_forward_counter_span >= min_span
336            && let Some(loss) = self.last_forward_loss_rate
337        {
338            self.forward_loss_window_span = 0;
339            self.forward_loss_window_lost = 0;
340            return Some((self.last_forward_counter_span, loss));
341        }
342
343        if self.forward_loss_window_span >= min_span {
344            let span = self.forward_loss_window_span;
345            let loss = (self.forward_loss_window_lost as f64 / span as f64).clamp(0.0, 1.0);
346            self.forward_loss_window_span = 0;
347            self.forward_loss_window_lost = 0;
348            return Some((span, loss));
349        }
350
351        None
352    }
353
354    /// Smoothed ETX (long-term EWMA), or `None` if not yet initialized.
355    pub fn smoothed_etx(&self) -> Option<f64> {
356        if self.etx_trend.initialized() {
357            Some(self.etx_trend.long())
358        } else {
359            None
360        }
361    }
362
363    /// Current smoothed goodput in bytes/sec, or 0 if not yet measured.
364    pub fn goodput_bps(&self) -> f64 {
365        self.goodput_bps
366    }
367
368    /// Cumulative ECN CE count from the most recent ReceiverReport.
369    pub fn last_ecn_ce_count(&self) -> u32 {
370        self.prev_rr_ecn_ce
371    }
372}
373
374impl Default for MmpMetrics {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380// ============================================================================
381// Tests
382// ============================================================================
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::time::Duration;
388
389    fn make_rr(
390        highest_counter: u64,
391        cum_packets: u64,
392        cum_bytes: u64,
393        timestamp_echo: u32,
394        dwell: u16,
395        jitter: u32,
396    ) -> ReceiverReport {
397        ReceiverReport {
398            highest_counter,
399            cumulative_packets_recv: cum_packets,
400            cumulative_bytes_recv: cum_bytes,
401            timestamp_echo,
402            dwell_time: dwell,
403            max_burst_loss: 0,
404            mean_burst_loss: 0,
405            jitter,
406            ecn_ce_count: 0,
407            owd_trend: 0,
408            burst_loss_count: 0,
409            cumulative_reorder_count: 0,
410            interval_packets_recv: 0,
411            interval_bytes_recv: 0,
412        }
413    }
414
415    #[test]
416    fn test_rtt_from_echo() {
417        let mut m = MmpMetrics::new();
418        let now = Instant::now();
419        // Peer echoes timestamp 1000ms, dwell=5ms, our current time=1050ms
420        let rr = make_rr(10, 10, 5000, 1000, 5, 0);
421        m.process_receiver_report(&rr, 1050, now);
422
423        assert!(m.srtt.initialized());
424        // RTT = 1050 - 1000 - 5 = 45ms
425        let srtt_ms = m.srtt_ms().unwrap();
426        assert!((srtt_ms - 45.0).abs() < 1.0, "srtt={srtt_ms}, expected ~45");
427    }
428
429    #[test]
430    fn test_ignores_duplicate_receiver_report_after_valid_sample() {
431        let mut m = MmpMetrics::new();
432        let now = Instant::now();
433
434        let valid_rr = make_rr(10, 10, 5000, 1000, 5, 0);
435        m.process_receiver_report(&valid_rr, 1050, now);
436        let baseline_srtt_ms = m.srtt_ms().unwrap();
437        assert_eq!(m.srtt_age_ms(now), Some(0));
438
439        // A duplicate of the same counters arriving later would be a 5s RTT
440        // sample if accepted. It is stale and must not move SRTT.
441        m.process_receiver_report(&valid_rr, 6000, now + Duration::from_secs(5));
442
443        let srtt_ms = m.srtt_ms().unwrap();
444        assert_eq!(srtt_ms, baseline_srtt_ms);
445        assert_eq!(m.srtt_age_ms(now + Duration::from_secs(5)), Some(5000));
446    }
447
448    #[test]
449    fn test_ignores_out_of_order_receiver_report_after_valid_sample() {
450        let mut m = MmpMetrics::new();
451        let now = Instant::now();
452
453        let valid_rr = make_rr(20, 20, 10000, 1000, 5, 0);
454        m.process_receiver_report(&valid_rr, 1050, now);
455        let baseline_srtt_ms = m.srtt_ms().unwrap();
456
457        let old_rr = make_rr(10, 10, 5000, 1000, 0, 0);
458        m.process_receiver_report(&old_rr, 6000, now + Duration::from_secs(5));
459
460        let srtt_ms = m.srtt_ms().unwrap();
461        assert_eq!(srtt_ms, baseline_srtt_ms);
462    }
463
464    #[test]
465    fn test_ignores_wrapped_rtt_sample() {
466        let mut m = MmpMetrics::new();
467        let now = Instant::now();
468
469        let wrapped_rr = make_rr(10, 10, 5000, u32::MAX - 10, 20, 0);
470        m.process_receiver_report(&wrapped_rr, 15, now);
471
472        assert!(m.srtt_ms().is_none());
473    }
474
475    #[test]
476    fn test_loss_rate_computation() {
477        let mut m = MmpMetrics::new();
478        let t0 = Instant::now();
479
480        // First report: baseline
481        let rr1 = make_rr(100, 100, 50000, 0, 0, 0);
482        m.process_receiver_report(&rr1, 0, t0);
483
484        // Second report: 200 counters sent, 190 received (5% loss)
485        let rr2 = make_rr(300, 290, 145000, 0, 0, 0);
486        m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
487
488        let loss = m.loss_rate();
489        assert!((loss - 0.05).abs() < 0.01, "loss={loss}, expected ~0.05");
490        assert_eq!(m.last_forward_loss_sample(), Some((200, loss)));
491    }
492
493    #[test]
494    fn test_etx_updates() {
495        let mut m = MmpMetrics::new();
496        assert_eq!(m.etx, 1.0); // initial: perfect
497
498        // Simulate some loss via forward ratio
499        m.delivery_ratio_forward = 0.9;
500
501        // First call establishes the baseline (no ETX update yet)
502        m.update_reverse_delivery(100, 100);
503        assert_eq!(m.etx, 1.0); // still perfect — baseline only
504
505        // Second call: 190 of 200 frames received (5% loss)
506        m.update_reverse_delivery(290, 300);
507        assert!(m.etx > 1.0);
508        assert!(m.etx < 2.0);
509    }
510
511    #[test]
512    fn test_no_rtt_without_echo() {
513        let mut m = MmpMetrics::new();
514        let now = Instant::now();
515        let rr = make_rr(10, 10, 5000, 0, 0, 0);
516        m.process_receiver_report(&rr, 1000, now);
517        assert!(m.srtt_ms().is_none());
518    }
519
520    #[test]
521    fn test_jitter_trend() {
522        let mut m = MmpMetrics::new();
523        let t0 = Instant::now();
524        let rr1 = make_rr(10, 10, 5000, 0, 0, 100);
525        m.process_receiver_report(&rr1, 0, t0);
526
527        let rr2 = make_rr(20, 20, 10000, 0, 0, 500);
528        m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
529
530        assert!(m.jitter_trend.initialized());
531        // Short-term should be closer to 500 than long-term
532        assert!(m.jitter_trend.short() > m.jitter_trend.long());
533    }
534
535    #[test]
536    fn test_goodput_bps() {
537        let mut m = MmpMetrics::new();
538        let t0 = Instant::now();
539
540        // First report: baseline (50KB received)
541        let rr1 = make_rr(100, 100, 50_000, 0, 0, 0);
542        m.process_receiver_report(&rr1, 0, t0);
543        assert_eq!(m.goodput_bps(), 0.0); // no rate yet (first report)
544
545        // Second report 1s later: 150KB total (100KB delta in 1s = 100KB/s)
546        let rr2 = make_rr(300, 290, 150_000, 0, 0, 0);
547        m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
548        assert!(
549            m.goodput_bps() > 90_000.0,
550            "goodput={}, expected ~100000",
551            m.goodput_bps()
552        );
553        assert!(
554            m.goodput_bps() < 110_000.0,
555            "goodput={}, expected ~100000",
556            m.goodput_bps()
557        );
558    }
559
560    #[test]
561    fn test_reverse_delivery_delta() {
562        let mut m = MmpMetrics::new();
563
564        // First call: baseline only, no ratio update
565        m.update_reverse_delivery(100, 100);
566        assert_eq!(m.delivery_ratio_reverse, 1.0); // unchanged from default
567
568        // Second call: perfect delivery (200 new frames, all received)
569        m.update_reverse_delivery(300, 300);
570        assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
571
572        // Third call: 50% loss (100 frames sent, 50 received)
573        m.update_reverse_delivery(350, 400);
574        assert!(
575            (m.delivery_ratio_reverse - 0.5).abs() < 0.001,
576            "reverse={}, expected 0.5",
577            m.delivery_ratio_reverse
578        );
579    }
580
581    #[test]
582    fn test_reverse_delivery_rekey_reset() {
583        let mut m = MmpMetrics::new();
584
585        // Establish baseline and one measurement
586        m.update_reverse_delivery(100, 100);
587        m.update_reverse_delivery(300, 300);
588        assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
589
590        // Rekey resets reverse state
591        m.reset_for_rekey();
592
593        // First call after rekey: baseline only
594        m.update_reverse_delivery(50, 50);
595        // delivery_ratio_reverse was reset to 1.0 by reset_for_rekey's
596        // clearing of delivery_ratio_forward; reverse is not explicitly
597        // reset — but the delta state is, so next call computes fresh.
598        assert_eq!(m.delivery_ratio_reverse, 1.0);
599
600        // Second call after rekey: 80% delivery
601        m.update_reverse_delivery(90, 100);
602        assert!(
603            (m.delivery_ratio_reverse - 0.8).abs() < 0.001,
604            "reverse={}, expected 0.8",
605            m.delivery_ratio_reverse
606        );
607    }
608}