Skip to main content

ipfrs_network/
latency_tracker.rs

1//! Per-peer latency histogram tracking with percentile reporting.
2//!
3//! This module provides fine-grained latency monitoring for each connected peer,
4//! including:
5//! - Recording RTT measurements per peer with ring-buffer sample management
6//! - Percentile computation with linear interpolation (p50, p95, p99, arbitrary)
7//! - Histogram generation with configurable bucket counts
8//! - Fastest/slowest peer ranking by mean latency
9//! - Global aggregate statistics across all tracked peers
10//!
11//! # Example
12//!
13//! ```rust
14//! use ipfrs_network::latency_tracker::{PeerLatencyTracker};
15//!
16//! let mut tracker = PeerLatencyTracker::new(1000);
17//!
18//! tracker.record("peer-1", 1_500);
19//! tracker.record("peer-1", 2_000);
20//! tracker.record("peer-1", 1_800);
21//!
22//! if let Some(p99) = tracker.percentile("peer-1", 0.99) {
23//!     println!("P99 = {} us", p99);
24//! }
25//!
26//! if let Some(m) = tracker.mean("peer-1") {
27//!     println!("Mean = {:.1} us", m);
28//! }
29//! ```
30
31use std::collections::HashMap;
32
33// ---------------------------------------------------------------------------
34// LatencyBucket
35// ---------------------------------------------------------------------------
36
37/// A single bucket in a latency histogram.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct LatencyBucket {
40    /// Lower bound of the bucket (inclusive), in microseconds.
41    pub lower_bound_us: u64,
42    /// Upper bound of the bucket (exclusive for all but the last bucket), in microseconds.
43    pub upper_bound_us: u64,
44    /// Number of samples falling into this bucket.
45    pub count: u64,
46}
47
48// ---------------------------------------------------------------------------
49// PeerLatency
50// ---------------------------------------------------------------------------
51
52/// Per-peer latency state with ring-buffer sample storage.
53#[derive(Debug, Clone)]
54pub struct PeerLatency {
55    /// Stable identifier for the remote peer.
56    pub peer_id: String,
57    /// Raw latency samples in microseconds (ring buffer).
58    pub samples: Vec<u64>,
59    /// Maximum number of samples retained (ring buffer capacity).
60    pub max_samples: usize,
61    /// Minimum observed latency in microseconds.
62    pub min_us: u64,
63    /// Maximum observed latency in microseconds.
64    pub max_us: u64,
65    /// Cumulative sum of all recorded latencies in microseconds.
66    pub sum_us: u64,
67    /// Total number of latency samples ever recorded (not just in the buffer).
68    pub count: u64,
69    /// Write position for ring buffer (next index to overwrite).
70    write_pos: usize,
71    /// Whether the ring buffer has wrapped around at least once.
72    wrapped: bool,
73}
74
75impl PeerLatency {
76    /// Create a new, empty peer latency record.
77    fn new(peer_id: impl Into<String>, max_samples: usize) -> Self {
78        Self {
79            peer_id: peer_id.into(),
80            samples: Vec::with_capacity(max_samples.min(1024)),
81            max_samples,
82            min_us: u64::MAX,
83            max_us: 0,
84            sum_us: 0,
85            count: 0,
86            write_pos: 0,
87            wrapped: false,
88        }
89    }
90
91    /// Add a latency sample, maintaining the ring buffer invariant.
92    fn add(&mut self, latency_us: u64) {
93        if self.max_samples == 0 {
94            // Degenerate case: no storage allowed, still track stats.
95            self.count += 1;
96            self.sum_us = self.sum_us.saturating_add(latency_us);
97            if latency_us < self.min_us {
98                self.min_us = latency_us;
99            }
100            if latency_us > self.max_us {
101                self.max_us = latency_us;
102            }
103            return;
104        }
105
106        if self.samples.len() < self.max_samples {
107            // Still filling the buffer.
108            self.samples.push(latency_us);
109        } else {
110            // Overwrite oldest entry.
111            self.samples[self.write_pos] = latency_us;
112            self.wrapped = true;
113        }
114        self.write_pos = (self.write_pos + 1) % self.max_samples;
115
116        self.count += 1;
117        self.sum_us = self.sum_us.saturating_add(latency_us);
118        if latency_us < self.min_us {
119            self.min_us = latency_us;
120        }
121        if latency_us > self.max_us {
122            self.max_us = latency_us;
123        }
124    }
125
126    /// Return a sorted copy of the current samples.
127    fn sorted_samples(&self) -> Vec<u64> {
128        let mut sorted = self.samples.clone();
129        sorted.sort_unstable();
130        sorted
131    }
132}
133
134// ---------------------------------------------------------------------------
135// LatencyTrackerStats
136// ---------------------------------------------------------------------------
137
138/// Aggregate statistics produced by [`PeerLatencyTracker::stats`].
139#[derive(Debug, Clone, PartialEq)]
140pub struct LatencyTrackerStats {
141    /// Number of distinct peers currently being tracked.
142    pub tracked_peers: usize,
143    /// Total number of samples recorded across all peers.
144    pub global_samples: u64,
145    /// Global mean latency in microseconds, or `None` if no samples.
146    pub global_mean_us: Option<f64>,
147}
148
149// ---------------------------------------------------------------------------
150// PeerLatencyTracker
151// ---------------------------------------------------------------------------
152
153/// Tracks per-peer RTT measurements with histogram, percentile, and ranking support.
154///
155/// Each peer maintains a fixed-size ring buffer of raw latency samples.
156/// Statistics (min, max, sum, count) are tracked cumulatively so that
157/// mean computation remains accurate even after samples are evicted.
158#[derive(Debug)]
159pub struct PeerLatencyTracker {
160    /// Per-peer latency state.
161    peers: HashMap<String, PeerLatency>,
162    /// Maximum number of samples to retain per peer.
163    max_samples_per_peer: usize,
164    /// Global cumulative sample count.
165    global_count: u64,
166    /// Global cumulative sum of all latency values.
167    global_sum_us: u64,
168}
169
170impl PeerLatencyTracker {
171    /// Create a new tracker with the specified ring buffer size per peer.
172    pub fn new(max_samples_per_peer: usize) -> Self {
173        Self {
174            peers: HashMap::new(),
175            max_samples_per_peer,
176            global_count: 0,
177            global_sum_us: 0,
178        }
179    }
180
181    /// Record a latency sample for the given peer.
182    ///
183    /// A [`PeerLatency`] entry is automatically created if one does not already
184    /// exist. The ring buffer evicts the oldest sample when full.
185    pub fn record(&mut self, peer_id: &str, latency_us: u64) {
186        let max = self.max_samples_per_peer;
187        let entry = self
188            .peers
189            .entry(peer_id.to_string())
190            .or_insert_with(|| PeerLatency::new(peer_id, max));
191        entry.add(latency_us);
192
193        self.global_count += 1;
194        self.global_sum_us = self.global_sum_us.saturating_add(latency_us);
195    }
196
197    /// Compute a percentile value for a peer using linear interpolation.
198    ///
199    /// `p` must be in `[0.0, 1.0]` (e.g., 0.99 for p99).
200    /// Returns `None` if the peer is unknown or has no samples.
201    pub fn percentile(&self, peer_id: &str, p: f64) -> Option<u64> {
202        let entry = self.peers.get(peer_id)?;
203        if entry.samples.is_empty() {
204            return None;
205        }
206
207        let p = p.clamp(0.0, 1.0);
208        let sorted = entry.sorted_samples();
209        let n = sorted.len();
210
211        if n == 1 {
212            return Some(sorted[0]);
213        }
214
215        // Linear interpolation using the "C = 0" variant (R-7 in R terminology).
216        let rank = p * (n - 1) as f64;
217        let lower_idx = rank.floor() as usize;
218        let upper_idx = rank.ceil() as usize;
219
220        if lower_idx == upper_idx {
221            return Some(sorted[lower_idx]);
222        }
223
224        let frac = rank - lower_idx as f64;
225        let lower_val = sorted[lower_idx] as f64;
226        let upper_val = sorted[upper_idx] as f64;
227        let interpolated = lower_val + frac * (upper_val - lower_val);
228
229        Some(interpolated.round() as u64)
230    }
231
232    /// Compute the mean latency for a peer.
233    ///
234    /// Uses cumulative sum/count for accuracy, not just buffered samples.
235    /// Returns `None` if the peer is unknown or has no samples.
236    pub fn mean(&self, peer_id: &str) -> Option<f64> {
237        let entry = self.peers.get(peer_id)?;
238        if entry.count == 0 {
239            return None;
240        }
241        Some(entry.sum_us as f64 / entry.count as f64)
242    }
243
244    /// Compute the median (p50) latency for a peer.
245    ///
246    /// Returns `None` if the peer is unknown or has no samples.
247    pub fn median(&self, peer_id: &str) -> Option<u64> {
248        self.percentile(peer_id, 0.5)
249    }
250
251    /// Generate a histogram of latency samples for a peer.
252    ///
253    /// The range `[min, max]` is divided into `bucket_count` equal-width buckets.
254    /// Returns `None` if the peer is unknown, has no samples, or `bucket_count` is 0.
255    pub fn histogram(&self, peer_id: &str, bucket_count: usize) -> Option<Vec<LatencyBucket>> {
256        if bucket_count == 0 {
257            return None;
258        }
259
260        let entry = self.peers.get(peer_id)?;
261        if entry.samples.is_empty() {
262            return None;
263        }
264
265        let sorted = entry.sorted_samples();
266        let min_val = sorted[0];
267        let max_val = sorted[sorted.len() - 1];
268
269        // When all samples are equal, put everything in one bucket.
270        if min_val == max_val {
271            let mut buckets = Vec::with_capacity(bucket_count);
272            for i in 0..bucket_count {
273                let count = if i == 0 { sorted.len() as u64 } else { 0 };
274                buckets.push(LatencyBucket {
275                    lower_bound_us: min_val,
276                    upper_bound_us: max_val,
277                    count,
278                });
279            }
280            return Some(buckets);
281        }
282
283        let range = max_val - min_val;
284        let bucket_width = range as f64 / bucket_count as f64;
285        let mut buckets = Vec::with_capacity(bucket_count);
286
287        for i in 0..bucket_count {
288            let lower = min_val as f64 + i as f64 * bucket_width;
289            let upper = if i == bucket_count - 1 {
290                max_val as f64 + 1.0 // inclusive last bucket
291            } else {
292                min_val as f64 + (i + 1) as f64 * bucket_width
293            };
294
295            let count = sorted
296                .iter()
297                .filter(|&&v| {
298                    let vf = v as f64;
299                    if i == bucket_count - 1 {
300                        vf >= lower && vf <= max_val as f64
301                    } else {
302                        vf >= lower && vf < upper
303                    }
304                })
305                .count() as u64;
306
307            buckets.push(LatencyBucket {
308                lower_bound_us: lower.floor() as u64,
309                upper_bound_us: if i == bucket_count - 1 {
310                    max_val
311                } else {
312                    upper.ceil() as u64
313                },
314                count,
315            });
316        }
317
318        Some(buckets)
319    }
320
321    /// Return the `n` peers with the lowest mean latency.
322    ///
323    /// Results are sorted ascending by mean latency.
324    pub fn fastest_peers(&self, n: usize) -> Vec<(String, f64)> {
325        let mut means: Vec<(String, f64)> = self
326            .peers
327            .iter()
328            .filter(|(_, v)| v.count > 0)
329            .map(|(k, v)| (k.clone(), v.sum_us as f64 / v.count as f64))
330            .collect();
331
332        means.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
333        means.truncate(n);
334        means
335    }
336
337    /// Return the `n` peers with the highest mean latency.
338    ///
339    /// Results are sorted descending by mean latency.
340    pub fn slowest_peers(&self, n: usize) -> Vec<(String, f64)> {
341        let mut means: Vec<(String, f64)> = self
342            .peers
343            .iter()
344            .filter(|(_, v)| v.count > 0)
345            .map(|(k, v)| (k.clone(), v.sum_us as f64 / v.count as f64))
346            .collect();
347
348        means.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
349        means.truncate(n);
350        means
351    }
352
353    /// Remove all tracking state for `peer_id`.
354    ///
355    /// Returns `true` if the peer existed and was removed.
356    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
357        self.peers.remove(peer_id).is_some()
358    }
359
360    /// Return the number of peers currently being tracked.
361    pub fn peer_count(&self) -> usize {
362        self.peers.len()
363    }
364
365    /// Compute the global mean latency across all peers.
366    ///
367    /// Returns `None` when no samples have been recorded.
368    pub fn global_mean(&self) -> Option<f64> {
369        if self.global_count == 0 {
370            return None;
371        }
372        Some(self.global_sum_us as f64 / self.global_count as f64)
373    }
374
375    /// Return aggregate statistics for the tracker.
376    pub fn stats(&self) -> LatencyTrackerStats {
377        LatencyTrackerStats {
378            tracked_peers: self.peers.len(),
379            global_samples: self.global_count,
380            global_mean_us: self.global_mean(),
381        }
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn make_tracker() -> PeerLatencyTracker {
394        PeerLatencyTracker::new(1000)
395    }
396
397    // -------------------------------------------------------------------
398    // Basic record tests
399    // -------------------------------------------------------------------
400
401    #[test]
402    fn record_creates_peer_entry() {
403        let mut t = make_tracker();
404        t.record("p1", 200);
405        assert_eq!(t.peer_count(), 1);
406    }
407
408    #[test]
409    fn record_multiple_samples_same_peer() {
410        let mut t = make_tracker();
411        t.record("p1", 100);
412        t.record("p1", 200);
413        t.record("p1", 300);
414        let entry = t.peers.get("p1").expect("peer should exist");
415        assert_eq!(entry.samples.len(), 3);
416        assert_eq!(entry.count, 3);
417        assert_eq!(entry.sum_us, 600);
418    }
419
420    #[test]
421    fn record_multiple_peers() {
422        let mut t = make_tracker();
423        t.record("p1", 100);
424        t.record("p2", 200);
425        t.record("p3", 300);
426        assert_eq!(t.peer_count(), 3);
427    }
428
429    #[test]
430    fn record_updates_min_max() {
431        let mut t = make_tracker();
432        t.record("p1", 500);
433        t.record("p1", 100);
434        t.record("p1", 900);
435        let entry = t.peers.get("p1").expect("peer should exist");
436        assert_eq!(entry.min_us, 100);
437        assert_eq!(entry.max_us, 900);
438    }
439
440    // -------------------------------------------------------------------
441    // Ring buffer eviction
442    // -------------------------------------------------------------------
443
444    #[test]
445    fn ring_buffer_eviction() {
446        let mut t = PeerLatencyTracker::new(3);
447        t.record("p1", 10);
448        t.record("p1", 20);
449        t.record("p1", 30);
450        t.record("p1", 40); // evicts 10
451
452        let entry = t.peers.get("p1").expect("peer should exist");
453        assert_eq!(entry.samples.len(), 3);
454        assert_eq!(entry.count, 4);
455        // Ring buffer overwrites index 0 with 40.
456        let mut sorted = entry.samples.clone();
457        sorted.sort_unstable();
458        assert_eq!(sorted, vec![20, 30, 40]);
459    }
460
461    #[test]
462    fn ring_buffer_size_one() {
463        let mut t = PeerLatencyTracker::new(1);
464        t.record("p1", 100);
465        t.record("p1", 200);
466        let entry = t.peers.get("p1").expect("peer should exist");
467        assert_eq!(entry.samples.len(), 1);
468        assert_eq!(entry.samples[0], 200);
469        assert_eq!(entry.count, 2);
470    }
471
472    #[test]
473    fn ring_buffer_full_cycle() {
474        let mut t = PeerLatencyTracker::new(5);
475        for i in 1..=10u64 {
476            t.record("p1", i * 100);
477        }
478        let entry = t.peers.get("p1").expect("peer should exist");
479        assert_eq!(entry.samples.len(), 5);
480        assert_eq!(entry.count, 10);
481        // Last 5 values: 600, 700, 800, 900, 1000
482        let mut sorted = entry.samples.clone();
483        sorted.sort_unstable();
484        assert_eq!(sorted, vec![600, 700, 800, 900, 1000]);
485    }
486
487    // -------------------------------------------------------------------
488    // Percentile tests
489    // -------------------------------------------------------------------
490
491    #[test]
492    fn percentile_none_for_unknown_peer() {
493        let t = make_tracker();
494        assert!(t.percentile("nobody", 0.5).is_none());
495    }
496
497    #[test]
498    fn percentile_single_sample() {
499        let mut t = make_tracker();
500        t.record("p1", 500);
501        assert_eq!(t.percentile("p1", 0.0), Some(500));
502        assert_eq!(t.percentile("p1", 0.5), Some(500));
503        assert_eq!(t.percentile("p1", 1.0), Some(500));
504    }
505
506    #[test]
507    fn percentile_p50_even_count() {
508        let mut t = make_tracker();
509        // 10 samples: 100, 200, ..., 1000
510        for i in 1..=10u64 {
511            t.record("p1", i * 100);
512        }
513        // p50: rank = 0.5 * 9 = 4.5 -> interpolate between sorted[4]=500 and sorted[5]=600
514        // result = 500 + 0.5 * 100 = 550
515        let p50 = t.percentile("p1", 0.5).expect("should have data");
516        assert_eq!(p50, 550);
517    }
518
519    #[test]
520    fn percentile_p95() {
521        let mut t = make_tracker();
522        for i in 1..=100u64 {
523            t.record("p1", i);
524        }
525        // p95: rank = 0.95 * 99 = 94.05
526        // sorted[94]=95, sorted[95]=96
527        // result = 95 + 0.05 * 1 = 95.05 -> rounds to 95
528        let p95 = t.percentile("p1", 0.95).expect("should have data");
529        assert_eq!(p95, 95);
530    }
531
532    #[test]
533    fn percentile_p99() {
534        let mut t = make_tracker();
535        for i in 1..=100u64 {
536            t.record("p1", i);
537        }
538        // p99: rank = 0.99 * 99 = 98.01
539        // sorted[98]=99, sorted[99]=100
540        // result = 99 + 0.01 * 1 = 99.01 -> rounds to 99
541        let p99 = t.percentile("p1", 0.99).expect("should have data");
542        assert_eq!(p99, 99);
543    }
544
545    #[test]
546    fn percentile_clamped() {
547        let mut t = make_tracker();
548        t.record("p1", 100);
549        t.record("p1", 200);
550        // p < 0 should clamp to 0 -> returns min
551        assert_eq!(t.percentile("p1", -1.0), Some(100));
552        // p > 1 should clamp to 1 -> returns max
553        assert_eq!(t.percentile("p1", 2.0), Some(200));
554    }
555
556    #[test]
557    fn percentile_p0_returns_min() {
558        let mut t = make_tracker();
559        t.record("p1", 100);
560        t.record("p1", 200);
561        t.record("p1", 300);
562        assert_eq!(t.percentile("p1", 0.0), Some(100));
563    }
564
565    #[test]
566    fn percentile_p100_returns_max() {
567        let mut t = make_tracker();
568        t.record("p1", 100);
569        t.record("p1", 200);
570        t.record("p1", 300);
571        assert_eq!(t.percentile("p1", 1.0), Some(300));
572    }
573
574    // -------------------------------------------------------------------
575    // Mean / Median tests
576    // -------------------------------------------------------------------
577
578    #[test]
579    fn mean_none_for_unknown_peer() {
580        let t = make_tracker();
581        assert!(t.mean("nobody").is_none());
582    }
583
584    #[test]
585    fn mean_single_sample() {
586        let mut t = make_tracker();
587        t.record("p1", 500);
588        assert!((t.mean("p1").expect("should have data") - 500.0).abs() < f64::EPSILON);
589    }
590
591    #[test]
592    fn mean_multiple_samples() {
593        let mut t = make_tracker();
594        t.record("p1", 100);
595        t.record("p1", 200);
596        t.record("p1", 300);
597        let m = t.mean("p1").expect("should have data");
598        assert!((m - 200.0).abs() < f64::EPSILON);
599    }
600
601    #[test]
602    fn median_none_for_unknown_peer() {
603        let t = make_tracker();
604        assert!(t.median("nobody").is_none());
605    }
606
607    #[test]
608    fn median_returns_p50() {
609        let mut t = make_tracker();
610        t.record("p1", 100);
611        t.record("p1", 200);
612        t.record("p1", 300);
613        // p50: rank = 0.5 * 2 = 1.0 -> sorted[1] = 200
614        assert_eq!(t.median("p1"), Some(200));
615    }
616
617    // -------------------------------------------------------------------
618    // Histogram tests
619    // -------------------------------------------------------------------
620
621    #[test]
622    fn histogram_none_for_unknown_peer() {
623        let t = make_tracker();
624        assert!(t.histogram("nobody", 5).is_none());
625    }
626
627    #[test]
628    fn histogram_none_for_zero_buckets() {
629        let mut t = make_tracker();
630        t.record("p1", 100);
631        assert!(t.histogram("p1", 0).is_none());
632    }
633
634    #[test]
635    fn histogram_single_bucket() {
636        let mut t = make_tracker();
637        t.record("p1", 100);
638        t.record("p1", 200);
639        t.record("p1", 300);
640        let hist = t.histogram("p1", 1).expect("should have histogram");
641        assert_eq!(hist.len(), 1);
642        assert_eq!(hist[0].count, 3);
643    }
644
645    #[test]
646    fn histogram_bucket_count_matches() {
647        let mut t = make_tracker();
648        for i in 1..=100u64 {
649            t.record("p1", i);
650        }
651        let hist = t.histogram("p1", 10).expect("should have histogram");
652        assert_eq!(hist.len(), 10);
653        // All samples should be accounted for.
654        let total: u64 = hist.iter().map(|b| b.count).sum();
655        assert_eq!(total, 100);
656    }
657
658    #[test]
659    fn histogram_all_same_values() {
660        let mut t = make_tracker();
661        for _ in 0..5 {
662            t.record("p1", 42);
663        }
664        let hist = t.histogram("p1", 3).expect("should have histogram");
665        assert_eq!(hist.len(), 3);
666        // All samples in first bucket.
667        assert_eq!(hist[0].count, 5);
668        assert_eq!(hist[1].count, 0);
669        assert_eq!(hist[2].count, 0);
670    }
671
672    // -------------------------------------------------------------------
673    // Fastest / Slowest peers
674    // -------------------------------------------------------------------
675
676    #[test]
677    fn fastest_peers_ordering() {
678        let mut t = make_tracker();
679        t.record("fast", 100);
680        t.record("medium", 500);
681        t.record("slow", 1000);
682        let fastest = t.fastest_peers(3);
683        assert_eq!(fastest.len(), 3);
684        assert_eq!(fastest[0].0, "fast");
685        assert_eq!(fastest[1].0, "medium");
686        assert_eq!(fastest[2].0, "slow");
687    }
688
689    #[test]
690    fn fastest_peers_truncates() {
691        let mut t = make_tracker();
692        t.record("a", 100);
693        t.record("b", 200);
694        t.record("c", 300);
695        let fastest = t.fastest_peers(2);
696        assert_eq!(fastest.len(), 2);
697    }
698
699    #[test]
700    fn fastest_peers_empty() {
701        let t = make_tracker();
702        let fastest = t.fastest_peers(5);
703        assert!(fastest.is_empty());
704    }
705
706    #[test]
707    fn slowest_peers_ordering() {
708        let mut t = make_tracker();
709        t.record("fast", 100);
710        t.record("medium", 500);
711        t.record("slow", 1000);
712        let slowest = t.slowest_peers(3);
713        assert_eq!(slowest.len(), 3);
714        assert_eq!(slowest[0].0, "slow");
715        assert_eq!(slowest[1].0, "medium");
716        assert_eq!(slowest[2].0, "fast");
717    }
718
719    #[test]
720    fn slowest_peers_truncates() {
721        let mut t = make_tracker();
722        t.record("a", 100);
723        t.record("b", 200);
724        t.record("c", 300);
725        let slowest = t.slowest_peers(1);
726        assert_eq!(slowest.len(), 1);
727        assert_eq!(slowest[0].0, "c");
728    }
729
730    // -------------------------------------------------------------------
731    // Remove peer
732    // -------------------------------------------------------------------
733
734    #[test]
735    fn remove_peer_returns_true_when_present() {
736        let mut t = make_tracker();
737        t.record("p1", 100);
738        assert!(t.remove_peer("p1"));
739        assert_eq!(t.peer_count(), 0);
740    }
741
742    #[test]
743    fn remove_peer_returns_false_when_absent() {
744        let mut t = make_tracker();
745        assert!(!t.remove_peer("nobody"));
746    }
747
748    #[test]
749    fn remove_peer_then_reinsert() {
750        let mut t = make_tracker();
751        t.record("p1", 100);
752        t.remove_peer("p1");
753        t.record("p1", 500);
754        let entry = t.peers.get("p1").expect("peer should exist");
755        assert_eq!(entry.count, 1);
756        assert_eq!(entry.samples, vec![500]);
757    }
758
759    // -------------------------------------------------------------------
760    // Global stats
761    // -------------------------------------------------------------------
762
763    #[test]
764    fn global_mean_none_when_empty() {
765        let t = make_tracker();
766        assert!(t.global_mean().is_none());
767    }
768
769    #[test]
770    fn global_mean_single_peer() {
771        let mut t = make_tracker();
772        t.record("p1", 100);
773        t.record("p1", 200);
774        assert!((t.global_mean().expect("should exist") - 150.0).abs() < f64::EPSILON);
775    }
776
777    #[test]
778    fn global_mean_multiple_peers() {
779        let mut t = make_tracker();
780        t.record("p1", 100);
781        t.record("p2", 300);
782        // global mean = (100 + 300) / 2 = 200
783        assert!((t.global_mean().expect("should exist") - 200.0).abs() < f64::EPSILON);
784    }
785
786    #[test]
787    fn stats_empty_tracker() {
788        let t = make_tracker();
789        let s = t.stats();
790        assert_eq!(s.tracked_peers, 0);
791        assert_eq!(s.global_samples, 0);
792        assert!(s.global_mean_us.is_none());
793    }
794
795    #[test]
796    fn stats_populated_tracker() {
797        let mut t = make_tracker();
798        t.record("p1", 100);
799        t.record("p1", 200);
800        t.record("p2", 300);
801        let s = t.stats();
802        assert_eq!(s.tracked_peers, 2);
803        assert_eq!(s.global_samples, 3);
804        let mean = s.global_mean_us.expect("should have mean");
805        assert!((mean - 200.0).abs() < f64::EPSILON);
806    }
807
808    #[test]
809    fn stats_after_remove_peer() {
810        let mut t = make_tracker();
811        t.record("p1", 100);
812        t.record("p2", 200);
813        t.remove_peer("p1");
814        let s = t.stats();
815        assert_eq!(s.tracked_peers, 1);
816        // global_count is cumulative (not decremented on remove)
817        assert_eq!(s.global_samples, 2);
818    }
819
820    // -------------------------------------------------------------------
821    // Edge cases
822    // -------------------------------------------------------------------
823
824    #[test]
825    fn single_sample_edge_case() {
826        let mut t = make_tracker();
827        t.record("p1", 42);
828        assert_eq!(t.percentile("p1", 0.0), Some(42));
829        assert_eq!(t.percentile("p1", 0.5), Some(42));
830        assert_eq!(t.percentile("p1", 1.0), Some(42));
831        assert_eq!(t.median("p1"), Some(42));
832        assert!((t.mean("p1").expect("mean") - 42.0).abs() < f64::EPSILON);
833    }
834
835    #[test]
836    fn empty_peer_returns_none() {
837        let t = make_tracker();
838        assert!(t.percentile("ghost", 0.5).is_none());
839        assert!(t.mean("ghost").is_none());
840        assert!(t.median("ghost").is_none());
841        assert!(t.histogram("ghost", 5).is_none());
842    }
843
844    #[test]
845    fn peer_count_after_operations() {
846        let mut t = make_tracker();
847        assert_eq!(t.peer_count(), 0);
848        t.record("p1", 100);
849        assert_eq!(t.peer_count(), 1);
850        t.record("p2", 200);
851        assert_eq!(t.peer_count(), 2);
852        t.remove_peer("p1");
853        assert_eq!(t.peer_count(), 1);
854    }
855
856    #[test]
857    fn large_sample_count() {
858        let mut t = PeerLatencyTracker::new(100);
859        for i in 0..500u64 {
860            t.record("p1", i + 1);
861        }
862        let entry = t.peers.get("p1").expect("peer should exist");
863        assert_eq!(entry.samples.len(), 100);
864        assert_eq!(entry.count, 500);
865        // Mean is cumulative: (1+2+...+500)/500 = 250.5
866        let m = t.mean("p1").expect("mean");
867        assert!((m - 250.5).abs() < f64::EPSILON);
868    }
869
870    #[test]
871    fn histogram_two_samples_two_buckets() {
872        let mut t = make_tracker();
873        t.record("p1", 0);
874        t.record("p1", 100);
875        let hist = t.histogram("p1", 2).expect("should have histogram");
876        assert_eq!(hist.len(), 2);
877        let total: u64 = hist.iter().map(|b| b.count).sum();
878        assert_eq!(total, 2);
879    }
880
881    #[test]
882    fn fastest_slowest_with_varied_means() {
883        let mut t = make_tracker();
884        // Give each peer multiple samples with different means.
885        t.record("alpha", 100);
886        t.record("alpha", 200); // mean 150
887        t.record("beta", 400);
888        t.record("beta", 600); // mean 500
889        t.record("gamma", 50);
890        t.record("gamma", 50); // mean 50
891
892        let fastest = t.fastest_peers(2);
893        assert_eq!(fastest[0].0, "gamma");
894        assert_eq!(fastest[1].0, "alpha");
895
896        let slowest = t.slowest_peers(2);
897        assert_eq!(slowest[0].0, "beta");
898        assert_eq!(slowest[1].0, "alpha");
899    }
900}