Skip to main content

ipfrs_network/
latency_predictor.rs

1//! Peer Latency Predictor
2//!
3//! Predicts future peer latency using exponentially-weighted moving average (EWMA)
4//! and trend detection. Tracks per-peer RTT samples, computes jitter via EWMA of
5//! squared deviations, and provides conservative predicted RTT estimates.
6//!
7//! # Features
8//!
9//! - EWMA-based latency smoothing with configurable alpha factor
10//! - EWMA-based variance (jitter) tracking with configurable beta factor
11//! - Trend detection (Improving / Stable / Degrading) from rolling EWMA history
12//! - Stale peer eviction based on configurable timeout
13//! - Best-peer ranking by predicted RTT
14//!
15//! # Example
16//!
17//! ```rust
18//! use ipfrs_network::latency_predictor::{
19//!     LatencySample, PeerLatencyPredictor, PredictorConfig,
20//! };
21//!
22//! let config = PredictorConfig::default();
23//! let mut predictor = PeerLatencyPredictor::new(config);
24//!
25//! predictor.record_sample(LatencySample {
26//!     peer_id: "peer-a".to_string(),
27//!     rtt_ms: 42.0,
28//!     timestamp_secs: 1_000,
29//! });
30//!
31//! let predicted = predictor.predict("peer-a");
32//! assert!(predicted.is_some());
33//! ```
34
35use std::collections::HashMap;
36
37// ---------------------------------------------------------------------------
38// Public types
39// ---------------------------------------------------------------------------
40
41/// A single RTT measurement for a peer.
42#[derive(Debug, Clone)]
43pub struct LatencySample {
44    /// Identifier of the remote peer.
45    pub peer_id: String,
46    /// Round-trip time in milliseconds.
47    pub rtt_ms: f64,
48    /// Unix timestamp (seconds) when the sample was taken.
49    pub timestamp_secs: u64,
50}
51
52/// Direction of latency trend inferred from recent EWMA history.
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub enum TrendDirection {
55    /// Recent EWMAs are decreasing — connection is getting faster.
56    Improving,
57    /// Recent EWMAs are neither clearly improving nor degrading.
58    Stable,
59    /// Recent EWMAs are increasing — connection is getting slower.
60    Degrading,
61}
62
63/// Per-peer latency tracking state.
64#[derive(Debug, Clone)]
65pub struct PeerLatencyState {
66    /// Identifier of the tracked peer.
67    pub peer_id: String,
68    /// Current EWMA of RTT (milliseconds).
69    pub ewma_ms: f64,
70    /// EWMA of squared deviation from the EWMA (variance proxy for jitter).
71    pub ewma_variance: f64,
72    /// Number of samples recorded so far.
73    pub sample_count: u64,
74    /// Rolling window of the last 5 EWMA values (oldest → newest).
75    pub recent_ewmas: Vec<f64>,
76    /// Unix timestamp (seconds) of the most recent sample.
77    pub last_updated_secs: u64,
78}
79
80impl PeerLatencyState {
81    /// Returns the estimated jitter in milliseconds (sqrt of EWMA variance).
82    pub fn jitter_ms(&self) -> f64 {
83        self.ewma_variance.sqrt()
84    }
85
86    /// Returns a conservative predicted RTT: EWMA + 0.5 * jitter.
87    pub fn predicted_rtt_ms(&self) -> f64 {
88        self.ewma_ms + 0.5 * self.jitter_ms()
89    }
90
91    /// Infers the latency trend from the rolling EWMA window.
92    ///
93    /// Requires at least 2 values. Splits `recent_ewmas` into first-half and
94    /// second-half, comparing their means:
95    /// - second > first × 1.05  → `Degrading`
96    /// - second < first × 0.95  → `Improving`
97    /// - otherwise               → `Stable`
98    pub fn trend(&self) -> TrendDirection {
99        let n = self.recent_ewmas.len();
100        if n < 2 {
101            return TrendDirection::Stable;
102        }
103
104        let mid = n / 2;
105        let first_half = &self.recent_ewmas[..mid];
106        let second_half = &self.recent_ewmas[mid..];
107
108        let first_avg = first_half.iter().copied().sum::<f64>() / first_half.len() as f64;
109        let second_avg = second_half.iter().copied().sum::<f64>() / second_half.len() as f64;
110
111        if second_avg > first_avg * 1.05 {
112            TrendDirection::Degrading
113        } else if second_avg < first_avg * 0.95 {
114            TrendDirection::Improving
115        } else {
116            TrendDirection::Stable
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Configuration
123// ---------------------------------------------------------------------------
124
125/// Configuration for [`PeerLatencyPredictor`].
126#[derive(Debug, Clone)]
127pub struct PredictorConfig {
128    /// EWMA smoothing factor for the mean RTT (0 < alpha ≤ 1).
129    ///
130    /// Higher values give more weight to recent samples.
131    pub alpha: f64,
132
133    /// EWMA smoothing factor for the variance (0 < beta ≤ 1).
134    pub beta: f64,
135
136    /// Age threshold (seconds) after which a peer is considered stale and
137    /// eligible for eviction via [`PeerLatencyPredictor::evict_stale`].
138    pub stale_threshold_secs: u64,
139}
140
141impl Default for PredictorConfig {
142    fn default() -> Self {
143        Self {
144            alpha: 0.2,
145            beta: 0.1,
146            stale_threshold_secs: 300,
147        }
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Stats
153// ---------------------------------------------------------------------------
154
155/// Aggregated statistics snapshot for a [`PeerLatencyPredictor`].
156#[derive(Debug, Clone)]
157pub struct PredictorStats {
158    /// Number of peers currently tracked.
159    pub active_peers: usize,
160    /// Cumulative number of samples recorded across all peers.
161    pub total_samples: u64,
162}
163
164impl PredictorStats {
165    /// Mean predicted RTT across the provided peer states.
166    ///
167    /// Returns `0.0` if `states` is empty.
168    pub fn avg_predicted_rtt(&self, states: &[PeerLatencyState]) -> f64 {
169        if states.is_empty() {
170            return 0.0;
171        }
172        let sum: f64 = states.iter().map(|s| s.predicted_rtt_ms()).sum();
173        sum / states.len() as f64
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Predictor
179// ---------------------------------------------------------------------------
180
181/// Maximum number of recent EWMA values to retain per peer.
182const RECENT_EWMA_WINDOW: usize = 5;
183
184/// Tracks per-peer RTT samples and provides latency predictions.
185#[derive(Debug)]
186pub struct PeerLatencyPredictor {
187    /// Per-peer state keyed by peer ID string.
188    pub states: HashMap<String, PeerLatencyState>,
189    /// Predictor configuration.
190    pub config: PredictorConfig,
191    /// Cumulative sample count across all peers.
192    pub total_samples: u64,
193}
194
195impl PeerLatencyPredictor {
196    /// Creates a new predictor with the given configuration.
197    pub fn new(config: PredictorConfig) -> Self {
198        Self {
199            states: HashMap::new(),
200            config,
201            total_samples: 0,
202        }
203    }
204
205    /// Records a latency sample for a peer, updating EWMA and variance.
206    ///
207    /// On the **first** sample for a peer the EWMA is initialised to the
208    /// observed RTT and the variance to `0.0`. Subsequent samples update via:
209    ///
210    /// ```text
211    /// ewma      = alpha * rtt + (1 - alpha) * ewma
212    /// deviation = rtt - ewma          (post-update deviation)
213    /// variance  = beta * deviation^2 + (1 - beta) * variance
214    /// ```
215    pub fn record_sample(&mut self, sample: LatencySample) {
216        let alpha = self.config.alpha;
217        let beta = self.config.beta;
218        let rtt = sample.rtt_ms;
219        let ts = sample.timestamp_secs;
220
221        if !self.states.contains_key(&sample.peer_id) {
222            // First observation: initialise directly.
223            self.states.insert(
224                sample.peer_id.clone(),
225                PeerLatencyState {
226                    peer_id: sample.peer_id,
227                    ewma_ms: rtt,
228                    ewma_variance: 0.0,
229                    sample_count: 1,
230                    recent_ewmas: vec![rtt],
231                    last_updated_secs: ts,
232                },
233            );
234            self.total_samples += 1;
235            return;
236        }
237
238        // Subsequent observation: update EWMA and variance.
239        let state = self
240            .states
241            .get_mut(&sample.peer_id)
242            .expect("key confirmed above");
243
244        state.ewma_ms = alpha * rtt + (1.0 - alpha) * state.ewma_ms;
245
246        let deviation = rtt - state.ewma_ms;
247        state.ewma_variance = beta * deviation * deviation + (1.0 - beta) * state.ewma_variance;
248
249        // Maintain rolling window of recent EWMA values (max RECENT_EWMA_WINDOW).
250        state.recent_ewmas.push(state.ewma_ms);
251        if state.recent_ewmas.len() > RECENT_EWMA_WINDOW {
252            state.recent_ewmas.remove(0);
253        }
254
255        state.sample_count += 1;
256        state.last_updated_secs = ts;
257        self.total_samples += 1;
258    }
259
260    /// Returns the predicted RTT for `peer_id`, or `None` if unknown.
261    pub fn predict(&self, peer_id: &str) -> Option<f64> {
262        self.states.get(peer_id).map(|s| s.predicted_rtt_ms())
263    }
264
265    /// Removes peers whose last sample is older than `now_secs - stale_threshold_secs`.
266    ///
267    /// Returns the number of peers evicted.
268    pub fn evict_stale(&mut self, now_secs: u64) -> usize {
269        let threshold = self.config.stale_threshold_secs;
270        let before = self.states.len();
271        self.states.retain(|_, state| {
272            // Keep peers that are recent enough.
273            now_secs.saturating_sub(state.last_updated_secs) < threshold
274        });
275        before - self.states.len()
276    }
277
278    /// Returns peer IDs of the `n` peers with the lowest predicted RTT, in
279    /// ascending order (best first).
280    ///
281    /// If `n` exceeds the number of tracked peers, all are returned.
282    pub fn best_peers(&self, n: usize) -> Vec<&str> {
283        let mut entries: Vec<(&str, f64)> = self
284            .states
285            .iter()
286            .map(|(id, state)| (id.as_str(), state.predicted_rtt_ms()))
287            .collect();
288
289        // Sort ascending by predicted RTT; use peer_id as tiebreaker for determinism.
290        entries.sort_by(|a, b| {
291            a.1.partial_cmp(&b.1)
292                .unwrap_or(std::cmp::Ordering::Equal)
293                .then_with(|| a.0.cmp(b.0))
294        });
295
296        entries.into_iter().take(n).map(|(id, _)| id).collect()
297    }
298
299    /// Returns a snapshot of predictor statistics.
300    pub fn stats(&self) -> PredictorStats {
301        PredictorStats {
302            active_peers: self.states.len(),
303            total_samples: self.total_samples,
304        }
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Tests
310// ---------------------------------------------------------------------------
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn make_sample(peer_id: &str, rtt_ms: f64, timestamp_secs: u64) -> LatencySample {
317        LatencySample {
318            peer_id: peer_id.to_string(),
319            rtt_ms,
320            timestamp_secs,
321        }
322    }
323
324    fn default_predictor() -> PeerLatencyPredictor {
325        PeerLatencyPredictor::new(PredictorConfig::default())
326    }
327
328    // ------------------------------------------------------------------
329    // 1. New peer is initialised correctly
330    // ------------------------------------------------------------------
331
332    #[test]
333    fn test_new_peer_ewma_equals_first_rtt() {
334        let mut p = default_predictor();
335        p.record_sample(make_sample("peer-a", 50.0, 1000));
336        let state = p.states.get("peer-a").expect("peer should exist");
337        assert_eq!(state.ewma_ms, 50.0);
338    }
339
340    #[test]
341    fn test_new_peer_variance_is_zero() {
342        let mut p = default_predictor();
343        p.record_sample(make_sample("peer-b", 30.0, 1000));
344        let state = p.states.get("peer-b").expect("peer should exist");
345        assert_eq!(state.ewma_variance, 0.0);
346    }
347
348    #[test]
349    fn test_new_peer_sample_count_is_one() {
350        let mut p = default_predictor();
351        p.record_sample(make_sample("peer-c", 20.0, 1000));
352        let state = p.states.get("peer-c").expect("peer should exist");
353        assert_eq!(state.sample_count, 1);
354    }
355
356    #[test]
357    fn test_new_peer_recent_ewmas_has_one_entry() {
358        let mut p = default_predictor();
359        p.record_sample(make_sample("peer-d", 20.0, 1000));
360        let state = p.states.get("peer-d").expect("peer should exist");
361        assert_eq!(state.recent_ewmas.len(), 1);
362        assert_eq!(state.recent_ewmas[0], 20.0);
363    }
364
365    // ------------------------------------------------------------------
366    // 2. EWMA moves toward recent samples
367    // ------------------------------------------------------------------
368
369    #[test]
370    fn test_ewma_moves_toward_recent_higher_value() {
371        let mut p = default_predictor();
372        // Initialize at 50 ms
373        p.record_sample(make_sample("peer-a", 50.0, 1000));
374        // Feed a much higher sample — EWMA should increase
375        p.record_sample(make_sample("peer-a", 200.0, 1001));
376        let state = p.states.get("peer-a").expect("peer should exist");
377        assert!(
378            state.ewma_ms > 50.0,
379            "EWMA ({}) should be above initial 50 ms",
380            state.ewma_ms
381        );
382        assert!(
383            state.ewma_ms < 200.0,
384            "EWMA ({}) should be below raw sample 200 ms",
385            state.ewma_ms
386        );
387    }
388
389    #[test]
390    fn test_ewma_moves_toward_recent_lower_value() {
391        let mut p = default_predictor();
392        p.record_sample(make_sample("peer-a", 200.0, 1000));
393        p.record_sample(make_sample("peer-a", 20.0, 1001));
394        let state = p.states.get("peer-a").expect("peer should exist");
395        assert!(
396            state.ewma_ms < 200.0,
397            "EWMA ({}) should be below initial 200 ms",
398            state.ewma_ms
399        );
400        assert!(
401            state.ewma_ms > 20.0,
402            "EWMA ({}) should be above raw sample 20 ms",
403            state.ewma_ms
404        );
405    }
406
407    #[test]
408    fn test_ewma_converges_on_constant_input() {
409        let mut p = default_predictor();
410        p.record_sample(make_sample("peer-a", 100.0, 1000));
411        for t in 1..=30_u64 {
412            p.record_sample(make_sample("peer-a", 10.0, 1000 + t));
413        }
414        let state = p.states.get("peer-a").expect("peer should exist");
415        // After many samples of 10 ms the EWMA should be close to 10.
416        assert!(
417            state.ewma_ms < 15.0,
418            "EWMA ({}) should converge toward 10 ms",
419            state.ewma_ms
420        );
421    }
422
423    // ------------------------------------------------------------------
424    // 3. jitter_ms = sqrt(ewma_variance)
425    // ------------------------------------------------------------------
426
427    #[test]
428    fn test_jitter_ms_is_sqrt_of_variance() {
429        let state = PeerLatencyState {
430            peer_id: "x".to_string(),
431            ewma_ms: 50.0,
432            ewma_variance: 25.0,
433            sample_count: 5,
434            recent_ewmas: vec![50.0],
435            last_updated_secs: 1000,
436        };
437        let jitter = state.jitter_ms();
438        assert!(
439            (jitter - 5.0).abs() < 1e-9,
440            "jitter_ms should be sqrt(25) = 5.0, got {}",
441            jitter
442        );
443    }
444
445    #[test]
446    fn test_jitter_zero_for_new_peer() {
447        let mut p = default_predictor();
448        p.record_sample(make_sample("peer-a", 50.0, 1000));
449        let state = p.states.get("peer-a").expect("peer should exist");
450        assert_eq!(state.jitter_ms(), 0.0);
451    }
452
453    // ------------------------------------------------------------------
454    // 4. predicted_rtt_ms includes jitter
455    // ------------------------------------------------------------------
456
457    #[test]
458    fn test_predicted_rtt_includes_half_jitter() {
459        let state = PeerLatencyState {
460            peer_id: "x".to_string(),
461            ewma_ms: 40.0,
462            ewma_variance: 16.0, // jitter = 4.0
463            sample_count: 3,
464            recent_ewmas: vec![40.0],
465            last_updated_secs: 1000,
466        };
467        // expected = 40.0 + 0.5 * 4.0 = 42.0
468        let predicted = state.predicted_rtt_ms();
469        assert!(
470            (predicted - 42.0).abs() < 1e-9,
471            "predicted_rtt_ms should be 42.0, got {}",
472            predicted
473        );
474    }
475
476    #[test]
477    fn test_predicted_rtt_equals_ewma_when_no_jitter() {
478        let state = PeerLatencyState {
479            peer_id: "x".to_string(),
480            ewma_ms: 55.0,
481            ewma_variance: 0.0,
482            sample_count: 1,
483            recent_ewmas: vec![55.0],
484            last_updated_secs: 1000,
485        };
486        assert_eq!(state.predicted_rtt_ms(), 55.0);
487    }
488
489    // ------------------------------------------------------------------
490    // 5. Trend detection
491    // ------------------------------------------------------------------
492
493    #[test]
494    fn test_trend_stable_with_fewer_than_two_ewmas() {
495        let state = PeerLatencyState {
496            peer_id: "x".to_string(),
497            ewma_ms: 50.0,
498            ewma_variance: 0.0,
499            sample_count: 1,
500            recent_ewmas: vec![50.0],
501            last_updated_secs: 1000,
502        };
503        assert_eq!(state.trend(), TrendDirection::Stable);
504    }
505
506    #[test]
507    fn test_trend_degrading() {
508        // recent_ewmas ascending — second half average >> first half
509        let state = PeerLatencyState {
510            peer_id: "x".to_string(),
511            ewma_ms: 100.0,
512            ewma_variance: 0.0,
513            sample_count: 5,
514            // first half: [10, 12] avg=11; second half: [80, 90, 100] avg=90
515            recent_ewmas: vec![10.0, 12.0, 80.0, 90.0, 100.0],
516            last_updated_secs: 1000,
517        };
518        assert_eq!(state.trend(), TrendDirection::Degrading);
519    }
520
521    #[test]
522    fn test_trend_improving() {
523        let state = PeerLatencyState {
524            peer_id: "x".to_string(),
525            ewma_ms: 10.0,
526            ewma_variance: 0.0,
527            sample_count: 5,
528            // first half: [100, 90] avg=95; second half: [20, 15, 10] avg=15
529            recent_ewmas: vec![100.0, 90.0, 20.0, 15.0, 10.0],
530            last_updated_secs: 1000,
531        };
532        assert_eq!(state.trend(), TrendDirection::Improving);
533    }
534
535    #[test]
536    fn test_trend_stable_flat() {
537        let state = PeerLatencyState {
538            peer_id: "x".to_string(),
539            ewma_ms: 50.0,
540            ewma_variance: 0.0,
541            sample_count: 4,
542            // first half: [50, 51] avg≈50.5; second half: [50, 50] avg=50 — within 5%
543            recent_ewmas: vec![50.0, 51.0, 50.0, 50.0],
544            last_updated_secs: 1000,
545        };
546        assert_eq!(state.trend(), TrendDirection::Stable);
547    }
548
549    // ------------------------------------------------------------------
550    // 6. evict_stale
551    // ------------------------------------------------------------------
552
553    #[test]
554    fn test_evict_stale_removes_old_peers() {
555        let mut p = PeerLatencyPredictor::new(PredictorConfig {
556            stale_threshold_secs: 60,
557            ..Default::default()
558        });
559        // old-peer last updated at t=1000, fresh-peer at t=2000
560        p.record_sample(make_sample("old-peer", 50.0, 1000));
561        p.record_sample(make_sample("fresh-peer", 30.0, 2000));
562
563        // now_secs = 2050 → old-peer age=1050 >= 60 → evicted
564        //                  → fresh-peer age=50 < 60  → kept
565        let evicted = p.evict_stale(2050);
566        assert_eq!(evicted, 1, "one stale peer should be evicted");
567        assert!(!p.states.contains_key("old-peer"));
568        assert!(p.states.contains_key("fresh-peer"));
569    }
570
571    #[test]
572    fn test_evict_stale_keeps_fresh_peers() {
573        let mut p = PeerLatencyPredictor::new(PredictorConfig {
574            stale_threshold_secs: 300,
575            ..Default::default()
576        });
577        p.record_sample(make_sample("peer-a", 40.0, 1000));
578        let evicted = p.evict_stale(1200); // age = 200 < 300
579        assert_eq!(evicted, 0);
580        assert!(p.states.contains_key("peer-a"));
581    }
582
583    #[test]
584    fn test_evict_stale_returns_zero_when_nothing_to_evict() {
585        let mut p = default_predictor();
586        assert_eq!(p.evict_stale(99999), 0);
587    }
588
589    // ------------------------------------------------------------------
590    // 7. best_peers
591    // ------------------------------------------------------------------
592
593    #[test]
594    fn test_best_peers_sorted_ascending() {
595        let mut p = default_predictor();
596        p.record_sample(make_sample("slow", 200.0, 1000));
597        p.record_sample(make_sample("fast", 10.0, 1000));
598        p.record_sample(make_sample("medium", 50.0, 1000));
599
600        let best = p.best_peers(3);
601        assert_eq!(best[0], "fast");
602        assert_eq!(best[1], "medium");
603        assert_eq!(best[2], "slow");
604    }
605
606    #[test]
607    fn test_best_peers_respects_n_limit() {
608        let mut p = default_predictor();
609        for i in 0..10_u64 {
610            p.record_sample(make_sample(
611                &format!("peer-{}", i),
612                i as f64 * 10.0 + 5.0,
613                1000,
614            ));
615        }
616        let best = p.best_peers(3);
617        assert_eq!(best.len(), 3);
618    }
619
620    #[test]
621    fn test_best_peers_all_when_n_exceeds_peer_count() {
622        let mut p = default_predictor();
623        p.record_sample(make_sample("a", 10.0, 1000));
624        p.record_sample(make_sample("b", 20.0, 1000));
625        let best = p.best_peers(100);
626        assert_eq!(best.len(), 2);
627    }
628
629    // ------------------------------------------------------------------
630    // 8. stats
631    // ------------------------------------------------------------------
632
633    #[test]
634    fn test_stats_active_peers() {
635        let mut p = default_predictor();
636        p.record_sample(make_sample("p1", 10.0, 1000));
637        p.record_sample(make_sample("p2", 20.0, 1000));
638        assert_eq!(p.stats().active_peers, 2);
639    }
640
641    #[test]
642    fn test_stats_total_samples() {
643        let mut p = default_predictor();
644        p.record_sample(make_sample("p1", 10.0, 1000));
645        p.record_sample(make_sample("p1", 15.0, 1001));
646        p.record_sample(make_sample("p2", 20.0, 1002));
647        assert_eq!(p.stats().total_samples, 3);
648    }
649
650    #[test]
651    fn test_stats_avg_predicted_rtt() {
652        let states = vec![
653            PeerLatencyState {
654                peer_id: "a".to_string(),
655                ewma_ms: 40.0,
656                ewma_variance: 0.0,
657                sample_count: 1,
658                recent_ewmas: vec![40.0],
659                last_updated_secs: 1000,
660            },
661            PeerLatencyState {
662                peer_id: "b".to_string(),
663                ewma_ms: 60.0,
664                ewma_variance: 0.0,
665                sample_count: 1,
666                recent_ewmas: vec![60.0],
667                last_updated_secs: 1000,
668            },
669        ];
670        let stats = PredictorStats {
671            active_peers: 2,
672            total_samples: 2,
673        };
674        let avg = stats.avg_predicted_rtt(&states);
675        assert!(
676            (avg - 50.0).abs() < 1e-9,
677            "avg should be 50 ms, got {}",
678            avg
679        );
680    }
681
682    // ------------------------------------------------------------------
683    // 9. predict returns None for unknown peer
684    // ------------------------------------------------------------------
685
686    #[test]
687    fn test_predict_returns_none_for_unknown_peer() {
688        let p = default_predictor();
689        assert!(p.predict("no-such-peer").is_none());
690    }
691
692    #[test]
693    fn test_predict_returns_some_for_known_peer() {
694        let mut p = default_predictor();
695        p.record_sample(make_sample("known", 100.0, 1000));
696        assert!(p.predict("known").is_some());
697    }
698
699    // ------------------------------------------------------------------
700    // 10. recent_ewmas window capped at 5
701    // ------------------------------------------------------------------
702
703    #[test]
704    fn test_recent_ewmas_capped_at_five() {
705        let mut p = default_predictor();
706        for t in 0..20_u64 {
707            p.record_sample(make_sample("peer-a", 50.0, 1000 + t));
708        }
709        let state = p.states.get("peer-a").expect("peer should exist");
710        assert!(
711            state.recent_ewmas.len() <= 5,
712            "recent_ewmas should be capped at 5, got {}",
713            state.recent_ewmas.len()
714        );
715    }
716}