1use std::collections::HashMap;
36
37#[derive(Debug, Clone)]
43pub struct LatencySample {
44 pub peer_id: String,
46 pub rtt_ms: f64,
48 pub timestamp_secs: u64,
50}
51
52#[derive(Clone, Copy, Debug, PartialEq)]
54pub enum TrendDirection {
55 Improving,
57 Stable,
59 Degrading,
61}
62
63#[derive(Debug, Clone)]
65pub struct PeerLatencyState {
66 pub peer_id: String,
68 pub ewma_ms: f64,
70 pub ewma_variance: f64,
72 pub sample_count: u64,
74 pub recent_ewmas: Vec<f64>,
76 pub last_updated_secs: u64,
78}
79
80impl PeerLatencyState {
81 pub fn jitter_ms(&self) -> f64 {
83 self.ewma_variance.sqrt()
84 }
85
86 pub fn predicted_rtt_ms(&self) -> f64 {
88 self.ewma_ms + 0.5 * self.jitter_ms()
89 }
90
91 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#[derive(Debug, Clone)]
127pub struct PredictorConfig {
128 pub alpha: f64,
132
133 pub beta: f64,
135
136 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#[derive(Debug, Clone)]
157pub struct PredictorStats {
158 pub active_peers: usize,
160 pub total_samples: u64,
162}
163
164impl PredictorStats {
165 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
177const RECENT_EWMA_WINDOW: usize = 5;
183
184#[derive(Debug)]
186pub struct PeerLatencyPredictor {
187 pub states: HashMap<String, PeerLatencyState>,
189 pub config: PredictorConfig,
191 pub total_samples: u64,
193}
194
195impl PeerLatencyPredictor {
196 pub fn new(config: PredictorConfig) -> Self {
198 Self {
199 states: HashMap::new(),
200 config,
201 total_samples: 0,
202 }
203 }
204
205 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 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 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 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 pub fn predict(&self, peer_id: &str) -> Option<f64> {
262 self.states.get(peer_id).map(|s| s.predicted_rtt_ms())
263 }
264
265 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 now_secs.saturating_sub(state.last_updated_secs) < threshold
274 });
275 before - self.states.len()
276 }
277
278 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 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 pub fn stats(&self) -> PredictorStats {
301 PredictorStats {
302 active_peers: self.states.len(),
303 total_samples: self.total_samples,
304 }
305 }
306}
307
308#[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 #[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 #[test]
370 fn test_ewma_moves_toward_recent_higher_value() {
371 let mut p = default_predictor();
372 p.record_sample(make_sample("peer-a", 50.0, 1000));
374 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 assert!(
417 state.ewma_ms < 15.0,
418 "EWMA ({}) should converge toward 10 ms",
419 state.ewma_ms
420 );
421 }
422
423 #[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 #[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, sample_count: 3,
464 recent_ewmas: vec![40.0],
465 last_updated_secs: 1000,
466 };
467 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 #[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 let state = PeerLatencyState {
510 peer_id: "x".to_string(),
511 ewma_ms: 100.0,
512 ewma_variance: 0.0,
513 sample_count: 5,
514 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 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 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 #[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 p.record_sample(make_sample("old-peer", 50.0, 1000));
561 p.record_sample(make_sample("fresh-peer", 30.0, 2000));
562
563 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); 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 #[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 #[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 #[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 #[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}