Skip to main content

ipfrs_network/
peer_scoring.rs

1//! Composite peer quality scoring engine for P2P overlay networks.
2//!
3//! [`PeerScoringSystem`] computes a weighted composite score for each peer
4//! based on multiple observable quality dimensions (latency, bandwidth,
5//! availability, reliability, gossip delivery, and routing success).
6//!
7//! Scores are accumulated in a bounded history per peer, enabling trend
8//! analysis, stale-peer eviction, and tier-based classification.
9//!
10//! # Example
11//!
12//! ```rust
13//! use ipfrs_network::peer_scoring::{
14//!     PeerScoringSystem, PeerMetrics, ScoringWeights,
15//! };
16//!
17//! let system = PeerScoringSystem::default();
18//!
19//! let metrics = PeerMetrics {
20//!     peer_id: "peer-1".to_string(),
21//!     rtt_ms: 50.0,
22//!     bandwidth_bps: 800_000.0,
23//!     uptime_fraction: 0.99,
24//!     success_rate: 0.97,
25//!     gossip_delivery_rate: 0.95,
26//!     routing_success_rate: 0.93,
27//!     last_updated: 1_000,
28//! };
29//!
30//! let score = system.score_peer(&metrics, 1_000);
31//! println!("Composite: {:.3}", score.composite);
32//! ```
33
34use std::collections::{HashMap, VecDeque};
35
36// ---------------------------------------------------------------------------
37// Error types
38// ---------------------------------------------------------------------------
39
40/// Errors that can occur when constructing or using a [`PeerScoringSystem`].
41#[derive(Debug, Clone, PartialEq, thiserror::Error)]
42pub enum ScoringError {
43    /// The provided weights do not sum to approximately 1.0.
44    #[error("scoring weights must sum to 1.0, got {got:.6}")]
45    WeightsMustSumToOne {
46        /// The actual sum of the provided weights.
47        got: f64,
48    },
49
50    /// A query was attempted for a peer that has no recorded history.
51    #[error("peer not found in scoring history")]
52    PeerNotFound,
53}
54
55// ---------------------------------------------------------------------------
56// Score component dimension
57// ---------------------------------------------------------------------------
58
59/// The observable quality dimension contributing to a peer's composite score.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum PsScoringDimension {
62    /// Round-trip latency quality (lower RTT → higher score).
63    Latency,
64    /// Available upload/download bandwidth.
65    Bandwidth,
66    /// Historical fraction of time the peer has been reachable.
67    Availability,
68    /// Fraction of protocol operations that succeeded.
69    Reliability,
70    /// Quality of gossip message delivery.
71    GossipQuality,
72    /// Quality of routing operations.
73    RoutingQuality,
74}
75
76// ---------------------------------------------------------------------------
77// Score tier
78// ---------------------------------------------------------------------------
79
80/// Qualitative tier derived from the composite score.
81///
82/// Ordered so that `Excellent > Good > Fair > Poor > Unacceptable`.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub enum ScoreTier {
85    /// Composite ≥ 0.8
86    Excellent,
87    /// Composite ≥ 0.6
88    Good,
89    /// Composite ≥ 0.4
90    Fair,
91    /// Composite ≥ 0.2
92    Poor,
93    /// Composite < 0.2
94    Unacceptable,
95}
96
97impl ScoreTier {
98    /// Numeric rank used for ordering (higher = better).
99    #[inline]
100    fn rank(self) -> u8 {
101        match self {
102            ScoreTier::Unacceptable => 0,
103            ScoreTier::Poor => 1,
104            ScoreTier::Fair => 2,
105            ScoreTier::Good => 3,
106            ScoreTier::Excellent => 4,
107        }
108    }
109
110    /// Derive the tier from a composite score value.
111    pub fn from_score(composite: f64) -> Self {
112        if composite >= 0.8 {
113            ScoreTier::Excellent
114        } else if composite >= 0.6 {
115            ScoreTier::Good
116        } else if composite >= 0.4 {
117            ScoreTier::Fair
118        } else if composite >= 0.2 {
119            ScoreTier::Poor
120        } else {
121            ScoreTier::Unacceptable
122        }
123    }
124}
125
126impl PartialOrd for ScoreTier {
127    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
128        Some(self.cmp(other))
129    }
130}
131
132impl Ord for ScoreTier {
133    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
134        self.rank().cmp(&other.rank())
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Scoring weights
140// ---------------------------------------------------------------------------
141
142/// Weights applied to each scoring dimension when computing the composite score.
143///
144/// All six weights must sum to 1.0 (validated within 0.001 tolerance).
145#[derive(Debug, Clone)]
146pub struct ScoringWeights {
147    /// Weight for the latency component.
148    pub latency: f64,
149    /// Weight for the bandwidth component.
150    pub bandwidth: f64,
151    /// Weight for the availability component.
152    pub availability: f64,
153    /// Weight for the reliability component.
154    pub reliability: f64,
155    /// Weight for the gossip quality component.
156    pub gossip_quality: f64,
157    /// Weight for the routing quality component.
158    pub routing_quality: f64,
159}
160
161impl ScoringWeights {
162    /// Compute the sum of all six weights.
163    #[inline]
164    pub fn sum(&self) -> f64 {
165        self.latency
166            + self.bandwidth
167            + self.availability
168            + self.reliability
169            + self.gossip_quality
170            + self.routing_quality
171    }
172
173    /// Validate that the weights sum to approximately 1.0 (within 0.001).
174    pub fn validate(&self) -> Result<(), ScoringError> {
175        let s = self.sum();
176        if (s - 1.0).abs() > 0.001 {
177            Err(ScoringError::WeightsMustSumToOne { got: s })
178        } else {
179            Ok(())
180        }
181    }
182}
183
184impl Default for ScoringWeights {
185    /// Even weights: each dimension receives 1/6 ≈ 0.16667.
186    fn default() -> Self {
187        let w = 1.0 / 6.0;
188        Self {
189            latency: w,
190            bandwidth: w,
191            availability: w,
192            reliability: w,
193            gossip_quality: w,
194            routing_quality: w,
195        }
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Peer metrics (raw observations)
201// ---------------------------------------------------------------------------
202
203/// Raw observations for a single peer at a point in time.
204#[derive(Debug, Clone)]
205pub struct PeerMetrics {
206    /// Stable identifier for the peer.
207    pub peer_id: String,
208    /// Round-trip time in milliseconds.
209    pub rtt_ms: f64,
210    /// Measured bandwidth in bits per second.
211    pub bandwidth_bps: f64,
212    /// Historical uptime as a fraction in \[0, 1\].
213    pub uptime_fraction: f64,
214    /// Fraction of protocol operations that succeeded, in \[0, 1\].
215    pub success_rate: f64,
216    /// Fraction of gossip messages successfully delivered, in \[0, 1\].
217    pub gossip_delivery_rate: f64,
218    /// Fraction of routing operations that succeeded, in \[0, 1\].
219    pub routing_success_rate: f64,
220    /// Epoch millisecond timestamp of the last observation.
221    pub last_updated: u64,
222}
223
224// ---------------------------------------------------------------------------
225// Peer score (computed result)
226// ---------------------------------------------------------------------------
227
228/// Computed composite score and per-dimension breakdown for a single peer.
229#[derive(Debug, Clone)]
230pub struct PsPeerScore {
231    /// Peer identifier.
232    pub peer_id: String,
233    /// Weighted composite score in \[0, 1\].
234    pub composite: f64,
235    /// Individual normalised scores for each dimension.
236    pub components: HashMap<PsScoringDimension, f64>,
237    /// Qualitative tier derived from the composite score.
238    pub tier: ScoreTier,
239    /// Epoch millisecond timestamp at which this score was computed.
240    pub computed_at: u64,
241}
242
243// ---------------------------------------------------------------------------
244// Scoring statistics
245// ---------------------------------------------------------------------------
246
247/// Aggregate statistics for the entire scoring system.
248#[derive(Debug, Clone)]
249pub struct ScoringStats {
250    /// Total number of peers with recorded history.
251    pub total_peers: usize,
252    /// Mean composite score across the most recent score of every tracked peer.
253    pub avg_composite: f64,
254    /// Distribution of peers across tier classifications (most-recent score).
255    pub tier_distribution: HashMap<ScoreTier, usize>,
256    /// Number of peers whose latest score is older than `max_age_ms`.
257    pub stale_peers: usize,
258}
259
260// ---------------------------------------------------------------------------
261// PeerScoringSystem
262// ---------------------------------------------------------------------------
263
264/// Composite peer quality scoring engine.
265///
266/// Maintains a rolling score history per peer and exposes scoring, ranking,
267/// trend analysis, and stale-peer eviction.
268#[derive(Debug)]
269pub struct PeerScoringSystem {
270    /// Dimension weights used when computing composite scores.
271    pub weights: ScoringWeights,
272    /// Rolling score history per peer, keyed by peer ID.
273    pub history: HashMap<String, VecDeque<PsPeerScore>>,
274    /// Maximum number of recent scores retained per peer.
275    pub max_history: usize,
276}
277
278impl Default for PeerScoringSystem {
279    fn default() -> Self {
280        Self {
281            weights: ScoringWeights::default(),
282            history: HashMap::new(),
283            max_history: 100,
284        }
285    }
286}
287
288impl PeerScoringSystem {
289    // ------------------------------------------------------------------
290    // Construction
291    // ------------------------------------------------------------------
292
293    /// Construct a new system with the given scoring weights.
294    ///
295    /// Returns [`ScoringError::WeightsMustSumToOne`] if the weights do not
296    /// sum to approximately 1.0.
297    pub fn new(weights: ScoringWeights) -> Result<Self, ScoringError> {
298        weights.validate()?;
299        Ok(Self {
300            weights,
301            history: HashMap::new(),
302            max_history: 100,
303        })
304    }
305
306    /// Construct with a custom `max_history` capacity.
307    pub fn with_max_history(mut self, max_history: usize) -> Self {
308        self.max_history = max_history.max(1);
309        self
310    }
311
312    // ------------------------------------------------------------------
313    // Core scoring
314    // ------------------------------------------------------------------
315
316    /// Compute a [`PsPeerScore`] from raw [`PeerMetrics`] without storing it.
317    ///
318    /// # Component formulas (all output in \[0, 1\]):
319    ///
320    /// - **Latency** : `exp(-rtt_ms / 500.0)` — 0 ms → 1.0, 500 ms → 0.368, 1 000 ms → 0.135
321    /// - **Bandwidth** : `(bandwidth_bps / 1_000_000).min(1.0)` — caps at 1 Mbps
322    /// - **Availability** : `uptime_fraction.clamp(0, 1)`
323    /// - **Reliability** : `success_rate.clamp(0, 1)`
324    /// - **GossipQuality** : `gossip_delivery_rate.clamp(0, 1)`
325    /// - **RoutingQuality** : `routing_success_rate.clamp(0, 1)`
326    pub fn score_peer(&self, metrics: &PeerMetrics, now: u64) -> PsPeerScore {
327        let latency_score = (-metrics.rtt_ms / 500.0).exp();
328        let bandwidth_score = (metrics.bandwidth_bps / 1_000_000.0).min(1.0);
329        let availability_score = metrics.uptime_fraction.clamp(0.0, 1.0);
330        let reliability_score = metrics.success_rate.clamp(0.0, 1.0);
331        let gossip_score = metrics.gossip_delivery_rate.clamp(0.0, 1.0);
332        let routing_score = metrics.routing_success_rate.clamp(0.0, 1.0);
333
334        let composite = self.weights.latency * latency_score
335            + self.weights.bandwidth * bandwidth_score
336            + self.weights.availability * availability_score
337            + self.weights.reliability * reliability_score
338            + self.weights.gossip_quality * gossip_score
339            + self.weights.routing_quality * routing_score;
340
341        let mut components = HashMap::with_capacity(6);
342        components.insert(PsScoringDimension::Latency, latency_score);
343        components.insert(PsScoringDimension::Bandwidth, bandwidth_score);
344        components.insert(PsScoringDimension::Availability, availability_score);
345        components.insert(PsScoringDimension::Reliability, reliability_score);
346        components.insert(PsScoringDimension::GossipQuality, gossip_score);
347        components.insert(PsScoringDimension::RoutingQuality, routing_score);
348
349        let tier = ScoreTier::from_score(composite);
350
351        PsPeerScore {
352            peer_id: metrics.peer_id.clone(),
353            composite,
354            components,
355            tier,
356            computed_at: now,
357        }
358    }
359
360    /// Compute a score and push it into the peer's history ring-buffer.
361    ///
362    /// If the buffer is at `max_history` capacity, the oldest entry is evicted.
363    pub fn score_and_record(&mut self, metrics: &PeerMetrics, now: u64) -> PsPeerScore {
364        let score = self.score_peer(metrics, now);
365        let deque = self.history.entry(metrics.peer_id.clone()).or_default();
366
367        if deque.len() >= self.max_history {
368            deque.pop_front();
369        }
370        deque.push_back(score.clone());
371        score
372    }
373
374    // ------------------------------------------------------------------
375    // History queries
376    // ------------------------------------------------------------------
377
378    /// Return the history slice for `peer_id` (oldest first, newest last).
379    ///
380    /// Returns an empty slice if the peer has no recorded history.
381    pub fn history_for(&self, peer_id: &str) -> &[PsPeerScore] {
382        self.history
383            .get(peer_id)
384            .map(|dq| dq.as_slices().0) // only the contiguous front slice
385            .unwrap_or(&[])
386    }
387
388    /// Return a `Vec` of all history entries for `peer_id` in order
389    /// (oldest first, newest last).
390    ///
391    /// Unlike [`history_for`][Self::history_for], this always returns the
392    /// full sequence even when the internal deque is split across two slices.
393    pub fn history_vec(&self, peer_id: &str) -> Vec<&PsPeerScore> {
394        match self.history.get(peer_id) {
395            None => Vec::new(),
396            Some(dq) => dq.iter().collect(),
397        }
398    }
399
400    /// Return the mean composite score for `peer_id` across all recorded
401    /// history entries, or `None` if there is no history.
402    pub fn average_score(&self, peer_id: &str) -> Option<f64> {
403        let dq = self.history.get(peer_id)?;
404        if dq.is_empty() {
405            return None;
406        }
407        let sum: f64 = dq.iter().map(|s| s.composite).sum();
408        Some(sum / dq.len() as f64)
409    }
410
411    /// Compute the slope of a linear regression over the composite scores in
412    /// the peer's history (positive slope = improving quality over time).
413    ///
414    /// Returns `None` if fewer than 2 data points are available.
415    ///
416    /// The independent variable is the index in the history sequence
417    /// (0, 1, 2, …), so the slope represents change per observation step.
418    pub fn trend(&self, peer_id: &str) -> Option<f64> {
419        let dq = self.history.get(peer_id)?;
420        let n = dq.len();
421        if n < 2 {
422            return None;
423        }
424
425        let n_f = n as f64;
426        let x_mean = (n_f - 1.0) / 2.0; // mean of 0..n-1
427        let y_mean = dq.iter().map(|s| s.composite).sum::<f64>() / n_f;
428
429        let mut numerator = 0.0_f64;
430        let mut denominator = 0.0_f64;
431
432        for (i, s) in dq.iter().enumerate() {
433            let dx = i as f64 - x_mean;
434            let dy = s.composite - y_mean;
435            numerator += dx * dy;
436            denominator += dx * dx;
437        }
438
439        if denominator == 0.0 {
440            return Some(0.0);
441        }
442
443        Some(numerator / denominator)
444    }
445
446    // ------------------------------------------------------------------
447    // Ranking
448    // ------------------------------------------------------------------
449
450    /// Return references to the top `n` peers ordered by their most-recent
451    /// composite score (descending).
452    ///
453    /// Peers with no history are excluded.
454    pub fn top_peers(&self, n: usize, _now: u64) -> Vec<&PsPeerScore> {
455        // Collect the most-recent score for each peer.
456        let mut latest: Vec<&PsPeerScore> =
457            self.history.values().filter_map(|dq| dq.back()).collect();
458
459        // Sort descending by composite.
460        latest.sort_by(|a, b| {
461            b.composite
462                .partial_cmp(&a.composite)
463                .unwrap_or(std::cmp::Ordering::Equal)
464        });
465
466        latest.into_iter().take(n).collect()
467    }
468
469    // ------------------------------------------------------------------
470    // Maintenance
471    // ------------------------------------------------------------------
472
473    /// Remove peers whose latest recorded score is older than `max_age_ms`
474    /// milliseconds relative to `now`.
475    ///
476    /// Returns the number of peers evicted.
477    pub fn evict_stale(&mut self, max_age_ms: u64, now: u64) -> usize {
478        let stale: Vec<String> = self
479            .history
480            .iter()
481            .filter_map(|(peer_id, dq)| {
482                dq.back().and_then(|latest| {
483                    let age = now.saturating_sub(latest.computed_at);
484                    if age > max_age_ms {
485                        Some(peer_id.clone())
486                    } else {
487                        None
488                    }
489                })
490            })
491            .collect();
492
493        let count = stale.len();
494        for id in stale {
495            self.history.remove(&id);
496        }
497        count
498    }
499
500    // ------------------------------------------------------------------
501    // Statistics
502    // ------------------------------------------------------------------
503
504    /// Compute aggregate statistics for the scoring system.
505    ///
506    /// `max_age_ms` and `now` are used solely to determine which peers are
507    /// considered "stale" for the [`ScoringStats::stale_peers`] field;
508    /// stale peers are **not** evicted by this call.
509    pub fn stats(&self, max_age_ms: u64, now: u64) -> ScoringStats {
510        let total_peers = self.history.len();
511        let mut composite_sum = 0.0_f64;
512        let mut tier_distribution: HashMap<ScoreTier, usize> = HashMap::new();
513        let mut stale_peers = 0usize;
514
515        for dq in self.history.values() {
516            if let Some(latest) = dq.back() {
517                composite_sum += latest.composite;
518                *tier_distribution.entry(latest.tier).or_insert(0) += 1;
519
520                let age = now.saturating_sub(latest.computed_at);
521                if age > max_age_ms {
522                    stale_peers += 1;
523                }
524            }
525        }
526
527        let avg_composite = if total_peers > 0 {
528            composite_sum / total_peers as f64
529        } else {
530            0.0
531        };
532
533        ScoringStats {
534            total_peers,
535            avg_composite,
536            tier_distribution,
537            stale_peers,
538        }
539    }
540}
541
542// ---------------------------------------------------------------------------
543// Tests
544// ---------------------------------------------------------------------------
545
546#[cfg(test)]
547mod tests {
548    use crate::peer_scoring::{
549        PeerMetrics, PeerScoringSystem, PsScoringDimension, ScoreTier, ScoringError, ScoringWeights,
550    };
551
552    // -----------------------------------------------------------------------
553    // Helpers
554    // -----------------------------------------------------------------------
555
556    fn excellent_metrics(peer_id: &str) -> PeerMetrics {
557        PeerMetrics {
558            peer_id: peer_id.to_string(),
559            rtt_ms: 1.0,
560            bandwidth_bps: 2_000_000.0,
561            uptime_fraction: 1.0,
562            success_rate: 1.0,
563            gossip_delivery_rate: 1.0,
564            routing_success_rate: 1.0,
565            last_updated: 0,
566        }
567    }
568
569    fn poor_metrics(peer_id: &str) -> PeerMetrics {
570        PeerMetrics {
571            peer_id: peer_id.to_string(),
572            rtt_ms: 2000.0,
573            bandwidth_bps: 0.0,
574            uptime_fraction: 0.05,
575            success_rate: 0.05,
576            gossip_delivery_rate: 0.05,
577            routing_success_rate: 0.05,
578            last_updated: 0,
579        }
580    }
581
582    // -----------------------------------------------------------------------
583    // ScoringWeights
584    // -----------------------------------------------------------------------
585
586    #[test]
587    fn test_default_weights_sum_to_one() {
588        let w = ScoringWeights::default();
589        assert!((w.sum() - 1.0).abs() < 0.001, "sum = {}", w.sum());
590    }
591
592    #[test]
593    fn test_default_weights_equal() {
594        let w = ScoringWeights::default();
595        let expected = 1.0 / 6.0;
596        for v in [
597            w.latency,
598            w.bandwidth,
599            w.availability,
600            w.reliability,
601            w.gossip_quality,
602            w.routing_quality,
603        ] {
604            assert!((v - expected).abs() < 1e-10, "weight = {v}");
605        }
606    }
607
608    #[test]
609    fn test_weights_validation_passes_for_one() {
610        let w = ScoringWeights {
611            latency: 0.2,
612            bandwidth: 0.2,
613            availability: 0.2,
614            reliability: 0.2,
615            gossip_quality: 0.1,
616            routing_quality: 0.1,
617        };
618        assert!(w.validate().is_ok());
619    }
620
621    #[test]
622    fn test_weights_validation_fails_for_wrong_sum() {
623        let w = ScoringWeights {
624            latency: 0.5,
625            bandwidth: 0.5,
626            availability: 0.5,
627            reliability: 0.0,
628            gossip_quality: 0.0,
629            routing_quality: 0.0,
630        };
631        let err = w.validate();
632        assert!(matches!(err, Err(ScoringError::WeightsMustSumToOne { .. })));
633    }
634
635    #[test]
636    fn test_weights_within_tolerance_passes() {
637        // 0.0009 deviation should be accepted.
638        let tiny = 0.0009 / 6.0;
639        let w = ScoringWeights {
640            latency: 1.0 / 6.0 + tiny,
641            bandwidth: 1.0 / 6.0,
642            availability: 1.0 / 6.0,
643            reliability: 1.0 / 6.0,
644            gossip_quality: 1.0 / 6.0,
645            routing_quality: 1.0 / 6.0,
646        };
647        assert!(w.validate().is_ok());
648    }
649
650    // -----------------------------------------------------------------------
651    // PeerScoringSystem construction
652    // -----------------------------------------------------------------------
653
654    #[test]
655    fn test_new_with_valid_weights() {
656        let w = ScoringWeights::default();
657        let sys = PeerScoringSystem::new(w);
658        assert!(sys.is_ok());
659    }
660
661    #[test]
662    fn test_new_with_invalid_weights_returns_error() {
663        let w = ScoringWeights {
664            latency: 1.0,
665            bandwidth: 1.0,
666            availability: 0.0,
667            reliability: 0.0,
668            gossip_quality: 0.0,
669            routing_quality: 0.0,
670        };
671        let err = PeerScoringSystem::new(w);
672        assert!(matches!(err, Err(ScoringError::WeightsMustSumToOne { .. })));
673    }
674
675    #[test]
676    fn test_default_system_creates_successfully() {
677        let sys = PeerScoringSystem::default();
678        assert_eq!(sys.max_history, 100);
679        assert!(sys.history.is_empty());
680    }
681
682    #[test]
683    fn test_with_max_history() {
684        let sys = PeerScoringSystem::default().with_max_history(50);
685        assert_eq!(sys.max_history, 50);
686    }
687
688    #[test]
689    fn test_with_max_history_min_one() {
690        let sys = PeerScoringSystem::default().with_max_history(0);
691        assert_eq!(sys.max_history, 1);
692    }
693
694    // -----------------------------------------------------------------------
695    // score_peer component formulas
696    // -----------------------------------------------------------------------
697
698    #[test]
699    fn test_latency_zero_gives_one() {
700        let sys = PeerScoringSystem::default();
701        let m = PeerMetrics {
702            peer_id: "p".into(),
703            rtt_ms: 0.0,
704            bandwidth_bps: 0.0,
705            uptime_fraction: 0.0,
706            success_rate: 0.0,
707            gossip_delivery_rate: 0.0,
708            routing_success_rate: 0.0,
709            last_updated: 0,
710        };
711        let s = sys.score_peer(&m, 0);
712        let lat = s.components[&PsScoringDimension::Latency];
713        assert!((lat - 1.0).abs() < 1e-10, "got {lat}");
714    }
715
716    #[test]
717    fn test_latency_500ms_gives_approx_0_37() {
718        let sys = PeerScoringSystem::default();
719        let m = PeerMetrics {
720            peer_id: "p".into(),
721            rtt_ms: 500.0,
722            bandwidth_bps: 0.0,
723            uptime_fraction: 0.0,
724            success_rate: 0.0,
725            gossip_delivery_rate: 0.0,
726            routing_success_rate: 0.0,
727            last_updated: 0,
728        };
729        let s = sys.score_peer(&m, 0);
730        let lat = s.components[&PsScoringDimension::Latency];
731        let expected = (-1.0_f64).exp();
732        assert!((lat - expected).abs() < 1e-10, "got {lat}");
733    }
734
735    #[test]
736    fn test_bandwidth_capped_at_1mbps() {
737        let sys = PeerScoringSystem::default();
738        let m = PeerMetrics {
739            peer_id: "p".into(),
740            rtt_ms: 0.0,
741            bandwidth_bps: 5_000_000.0,
742            uptime_fraction: 0.0,
743            success_rate: 0.0,
744            gossip_delivery_rate: 0.0,
745            routing_success_rate: 0.0,
746            last_updated: 0,
747        };
748        let s = sys.score_peer(&m, 0);
749        let bw = s.components[&PsScoringDimension::Bandwidth];
750        assert!((bw - 1.0).abs() < 1e-10, "got {bw}");
751    }
752
753    #[test]
754    fn test_bandwidth_500kbps_gives_0_5() {
755        let sys = PeerScoringSystem::default();
756        let m = PeerMetrics {
757            peer_id: "p".into(),
758            rtt_ms: 0.0,
759            bandwidth_bps: 500_000.0,
760            uptime_fraction: 0.0,
761            success_rate: 0.0,
762            gossip_delivery_rate: 0.0,
763            routing_success_rate: 0.0,
764            last_updated: 0,
765        };
766        let s = sys.score_peer(&m, 0);
767        let bw = s.components[&PsScoringDimension::Bandwidth];
768        assert!((bw - 0.5).abs() < 1e-10, "got {bw}");
769    }
770
771    #[test]
772    fn test_availability_clamped() {
773        let sys = PeerScoringSystem::default();
774        let m = PeerMetrics {
775            peer_id: "p".into(),
776            rtt_ms: 0.0,
777            bandwidth_bps: 0.0,
778            uptime_fraction: 1.5, // above 1 → clamped to 1
779            success_rate: 0.0,
780            gossip_delivery_rate: 0.0,
781            routing_success_rate: 0.0,
782            last_updated: 0,
783        };
784        let s = sys.score_peer(&m, 0);
785        let av = s.components[&PsScoringDimension::Availability];
786        assert!((av - 1.0).abs() < 1e-10, "got {av}");
787    }
788
789    #[test]
790    fn test_all_components_present_in_score() {
791        let sys = PeerScoringSystem::default();
792        let s = sys.score_peer(&excellent_metrics("p"), 0);
793        assert_eq!(s.components.len(), 6);
794        for dim in [
795            PsScoringDimension::Latency,
796            PsScoringDimension::Bandwidth,
797            PsScoringDimension::Availability,
798            PsScoringDimension::Reliability,
799            PsScoringDimension::GossipQuality,
800            PsScoringDimension::RoutingQuality,
801        ] {
802            assert!(s.components.contains_key(&dim), "missing {dim:?}");
803        }
804    }
805
806    // -----------------------------------------------------------------------
807    // Composite score correctness
808    // -----------------------------------------------------------------------
809
810    #[test]
811    fn test_composite_with_equal_weights_and_known_values() {
812        let sys = PeerScoringSystem::default();
813        let m = PeerMetrics {
814            peer_id: "p".into(),
815            rtt_ms: 0.0,                // latency = 1.0
816            bandwidth_bps: 1_000_000.0, // bandwidth = 1.0
817            uptime_fraction: 0.5,       // availability = 0.5
818            success_rate: 0.75,         // reliability = 0.75
819            gossip_delivery_rate: 0.25, // gossip = 0.25
820            routing_success_rate: 0.0,  // routing = 0.0
821            last_updated: 0,
822        };
823        let s = sys.score_peer(&m, 0);
824        // composite = (1.0 + 1.0 + 0.5 + 0.75 + 0.25 + 0.0) / 6
825        let expected = 3.5 / 6.0;
826        assert!(
827            (s.composite - expected).abs() < 1e-10,
828            "got {}, expected {}",
829            s.composite,
830            expected
831        );
832    }
833
834    #[test]
835    fn test_composite_excellent_peer_high_score() {
836        let sys = PeerScoringSystem::default();
837        let s = sys.score_peer(&excellent_metrics("p"), 0);
838        assert!(s.composite > 0.8, "got {}", s.composite);
839        assert_eq!(s.tier, ScoreTier::Excellent);
840    }
841
842    #[test]
843    fn test_composite_poor_peer_low_score() {
844        let sys = PeerScoringSystem::default();
845        let s = sys.score_peer(&poor_metrics("p"), 0);
846        assert!(s.composite < 0.3, "got {}", s.composite);
847    }
848
849    // -----------------------------------------------------------------------
850    // ScoreTier classification
851    // -----------------------------------------------------------------------
852
853    #[test]
854    fn test_score_tier_from_score() {
855        assert_eq!(ScoreTier::from_score(0.95), ScoreTier::Excellent);
856        assert_eq!(ScoreTier::from_score(0.80), ScoreTier::Excellent);
857        assert_eq!(ScoreTier::from_score(0.79), ScoreTier::Good);
858        assert_eq!(ScoreTier::from_score(0.60), ScoreTier::Good);
859        assert_eq!(ScoreTier::from_score(0.59), ScoreTier::Fair);
860        assert_eq!(ScoreTier::from_score(0.40), ScoreTier::Fair);
861        assert_eq!(ScoreTier::from_score(0.39), ScoreTier::Poor);
862        assert_eq!(ScoreTier::from_score(0.20), ScoreTier::Poor);
863        assert_eq!(ScoreTier::from_score(0.19), ScoreTier::Unacceptable);
864        assert_eq!(ScoreTier::from_score(0.00), ScoreTier::Unacceptable);
865    }
866
867    #[test]
868    fn test_score_tier_ordering() {
869        assert!(ScoreTier::Excellent > ScoreTier::Good);
870        assert!(ScoreTier::Good > ScoreTier::Fair);
871        assert!(ScoreTier::Fair > ScoreTier::Poor);
872        assert!(ScoreTier::Poor > ScoreTier::Unacceptable);
873    }
874
875    #[test]
876    fn test_score_tier_equal() {
877        assert_eq!(ScoreTier::Fair, ScoreTier::Fair);
878    }
879
880    // -----------------------------------------------------------------------
881    // score_and_record + history
882    // -----------------------------------------------------------------------
883
884    #[test]
885    fn test_score_and_record_creates_history() {
886        let mut sys = PeerScoringSystem::default();
887        sys.score_and_record(&excellent_metrics("p1"), 100);
888        assert_eq!(sys.history_vec("p1").len(), 1);
889    }
890
891    #[test]
892    fn test_history_for_empty_peer() {
893        let sys = PeerScoringSystem::default();
894        assert!(sys.history_for("ghost").is_empty());
895    }
896
897    #[test]
898    fn test_history_vec_order_oldest_first() {
899        let mut sys = PeerScoringSystem::default();
900        let mut m = excellent_metrics("p1");
901        for t in [10u64, 20, 30] {
902            m.last_updated = t;
903            sys.score_and_record(&m, t);
904        }
905        let history = sys.history_vec("p1");
906        assert_eq!(history.len(), 3);
907        assert_eq!(history[0].computed_at, 10);
908        assert_eq!(history[2].computed_at, 30);
909    }
910
911    #[test]
912    fn test_max_history_ring_buffer() {
913        let mut sys = PeerScoringSystem::default().with_max_history(3);
914        let mut m = excellent_metrics("p1");
915        for t in 0..10u64 {
916            m.last_updated = t;
917            sys.score_and_record(&m, t);
918        }
919        assert_eq!(sys.history_vec("p1").len(), 3);
920        // The oldest retained should be t=7 (last 3 of 0..10)
921        assert_eq!(sys.history_vec("p1")[0].computed_at, 7);
922    }
923
924    // -----------------------------------------------------------------------
925    // average_score
926    // -----------------------------------------------------------------------
927
928    #[test]
929    fn test_average_score_none_for_unknown_peer() {
930        let sys = PeerScoringSystem::default();
931        assert!(sys.average_score("nobody").is_none());
932    }
933
934    #[test]
935    fn test_average_score_single_entry() {
936        let mut sys = PeerScoringSystem::default();
937        let s = sys.score_and_record(&excellent_metrics("p1"), 0);
938        let avg = sys.average_score("p1").expect("should have avg");
939        assert!((avg - s.composite).abs() < 1e-10);
940    }
941
942    #[test]
943    fn test_average_score_multiple_entries() {
944        let mut sys = PeerScoringSystem::default();
945        // First observation: excellent
946        sys.score_and_record(&excellent_metrics("p1"), 10);
947        // Second observation: poor
948        sys.score_and_record(&poor_metrics("p1"), 20);
949        let avg = sys.average_score("p1").expect("should exist");
950        let s_exc = sys.score_peer(&excellent_metrics("p1"), 0).composite;
951        let s_poor = sys.score_peer(&poor_metrics("p1"), 0).composite;
952        let expected = (s_exc + s_poor) / 2.0;
953        assert!((avg - expected).abs() < 1e-10);
954    }
955
956    // -----------------------------------------------------------------------
957    // trend
958    // -----------------------------------------------------------------------
959
960    #[test]
961    fn test_trend_none_for_single_entry() {
962        let mut sys = PeerScoringSystem::default();
963        sys.score_and_record(&excellent_metrics("p1"), 0);
964        assert!(sys.trend("p1").is_none());
965    }
966
967    #[test]
968    fn test_trend_none_for_unknown_peer() {
969        let sys = PeerScoringSystem::default();
970        assert!(sys.trend("nobody").is_none());
971    }
972
973    #[test]
974    fn test_trend_positive_improving() {
975        let mut sys = PeerScoringSystem::default();
976        // Manually insert a sequence of increasing composite scores by
977        // alternating good (high) and poor (low) metrics with increasing
978        // quality. Use score_peer and push directly.
979        let w = ScoringWeights::default();
980        let mut low_m = poor_metrics("p1");
981        let high_m = excellent_metrics("p1");
982
983        // Produce an obviously increasing sequence: 0.1, 0.3, 0.5, 0.7, 0.9
984        // by injecting custom scores via score_and_record with tuned metrics.
985        // We'll construct metrics that yield predictable scores.
986        for i in 0..5usize {
987            let frac = 0.1 + i as f64 * 0.2;
988            // Use a bandwidth-only system for predictability.
989            let _ = &w;
990            low_m.bandwidth_bps = frac * 1_000_000.0;
991            low_m.rtt_ms = (-frac.ln()) * 500.0; // maps to latency = frac
992            low_m.uptime_fraction = frac;
993            low_m.success_rate = frac;
994            low_m.gossip_delivery_rate = frac;
995            low_m.routing_success_rate = frac;
996            sys.score_and_record(&low_m, i as u64);
997        }
998        let _ = &high_m;
999        let slope = sys.trend("p1").expect("should have trend");
1000        assert!(slope > 0.0, "expected positive slope, got {slope}");
1001    }
1002
1003    #[test]
1004    fn test_trend_negative_declining() {
1005        let mut sys = PeerScoringSystem::default();
1006        let mut m = excellent_metrics("p1");
1007        // Decreasing: uptime 1.0 → 0.2
1008        for i in 0..5usize {
1009            let frac = 1.0 - i as f64 * 0.2;
1010            m.uptime_fraction = frac;
1011            m.success_rate = frac;
1012            m.gossip_delivery_rate = frac;
1013            m.routing_success_rate = frac;
1014            m.bandwidth_bps = frac * 1_000_000.0;
1015            m.rtt_ms = (1.0 - frac) * 1000.0;
1016            sys.score_and_record(&m, i as u64);
1017        }
1018        let slope = sys.trend("p1").expect("should have trend");
1019        assert!(slope < 0.0, "expected negative slope, got {slope}");
1020    }
1021
1022    #[test]
1023    fn test_trend_flat_gives_zero_slope() {
1024        let mut sys = PeerScoringSystem::default();
1025        let m = excellent_metrics("p1");
1026        for t in 0..5u64 {
1027            sys.score_and_record(&m, t);
1028        }
1029        let slope = sys.trend("p1").expect("should have trend");
1030        assert!(slope.abs() < 1e-10, "expected ~0, got {slope}");
1031    }
1032
1033    // -----------------------------------------------------------------------
1034    // top_peers
1035    // -----------------------------------------------------------------------
1036
1037    #[test]
1038    fn test_top_peers_empty_system() {
1039        let sys = PeerScoringSystem::default();
1040        assert!(sys.top_peers(5, 0).is_empty());
1041    }
1042
1043    #[test]
1044    fn test_top_peers_ordering() {
1045        let mut sys = PeerScoringSystem::default();
1046        sys.score_and_record(&excellent_metrics("good"), 0);
1047        sys.score_and_record(&poor_metrics("bad"), 0);
1048        let top = sys.top_peers(2, 0);
1049        assert_eq!(top.len(), 2);
1050        assert_eq!(top[0].peer_id, "good");
1051        assert_eq!(top[1].peer_id, "bad");
1052    }
1053
1054    #[test]
1055    fn test_top_peers_n_larger_than_count() {
1056        let mut sys = PeerScoringSystem::default();
1057        sys.score_and_record(&excellent_metrics("p1"), 0);
1058        let top = sys.top_peers(100, 0);
1059        assert_eq!(top.len(), 1);
1060    }
1061
1062    #[test]
1063    fn test_top_peers_takes_latest_score() {
1064        let mut sys = PeerScoringSystem::default();
1065        // First score is excellent, then update to poor
1066        sys.score_and_record(&excellent_metrics("p1"), 0);
1067        sys.score_and_record(&poor_metrics("p1"), 1);
1068        // The latest score should be poor
1069        let top = sys.top_peers(1, 1);
1070        assert_eq!(top[0].peer_id, "p1");
1071        // Its composite should be based on the poor metrics
1072        let expected = sys.score_peer(&poor_metrics("p1"), 1).composite;
1073        assert!((top[0].composite - expected).abs() < 1e-9);
1074    }
1075
1076    // -----------------------------------------------------------------------
1077    // evict_stale
1078    // -----------------------------------------------------------------------
1079
1080    #[test]
1081    fn test_evict_stale_removes_old_peers() {
1082        let mut sys = PeerScoringSystem::default();
1083        sys.score_and_record(&excellent_metrics("old"), 0);
1084        sys.score_and_record(&excellent_metrics("fresh"), 1000);
1085        let evicted = sys.evict_stale(500, 1000);
1086        assert_eq!(evicted, 1);
1087        assert!(sys.history.contains_key("fresh"));
1088        assert!(!sys.history.contains_key("old"));
1089    }
1090
1091    #[test]
1092    fn test_evict_stale_no_peers_removed() {
1093        let mut sys = PeerScoringSystem::default();
1094        sys.score_and_record(&excellent_metrics("p1"), 900);
1095        sys.score_and_record(&excellent_metrics("p2"), 950);
1096        let evicted = sys.evict_stale(500, 1000);
1097        assert_eq!(evicted, 0);
1098    }
1099
1100    #[test]
1101    fn test_evict_stale_empty_system() {
1102        let mut sys = PeerScoringSystem::default();
1103        assert_eq!(sys.evict_stale(100, 200), 0);
1104    }
1105
1106    // -----------------------------------------------------------------------
1107    // stats
1108    // -----------------------------------------------------------------------
1109
1110    #[test]
1111    fn test_stats_empty() {
1112        let sys = PeerScoringSystem::default();
1113        let s = sys.stats(500, 1000);
1114        assert_eq!(s.total_peers, 0);
1115        assert!((s.avg_composite).abs() < 1e-10);
1116        assert_eq!(s.stale_peers, 0);
1117    }
1118
1119    #[test]
1120    fn test_stats_total_peers() {
1121        let mut sys = PeerScoringSystem::default();
1122        sys.score_and_record(&excellent_metrics("p1"), 1000);
1123        sys.score_and_record(&excellent_metrics("p2"), 1000);
1124        let s = sys.stats(500, 1000);
1125        assert_eq!(s.total_peers, 2);
1126    }
1127
1128    #[test]
1129    fn test_stats_stale_count() {
1130        let mut sys = PeerScoringSystem::default();
1131        sys.score_and_record(&excellent_metrics("stale"), 0);
1132        sys.score_and_record(&excellent_metrics("fresh"), 900);
1133        let s = sys.stats(500, 1000);
1134        assert_eq!(s.stale_peers, 1);
1135    }
1136
1137    #[test]
1138    fn test_stats_tier_distribution() {
1139        let mut sys = PeerScoringSystem::default();
1140        sys.score_and_record(&excellent_metrics("good"), 1000);
1141        sys.score_and_record(&poor_metrics("bad"), 1000);
1142        let s = sys.stats(500, 1000);
1143        // excellent peer → Excellent tier; poor peer → some low tier
1144        assert!(s.tier_distribution.contains_key(&ScoreTier::Excellent));
1145    }
1146
1147    #[test]
1148    fn test_stats_does_not_evict() {
1149        let mut sys = PeerScoringSystem::default();
1150        sys.score_and_record(&excellent_metrics("p1"), 0);
1151        let _s = sys.stats(100, 1000); // stale but not evicted
1152        assert!(sys.history.contains_key("p1"));
1153    }
1154
1155    // -----------------------------------------------------------------------
1156    // ScoringError display
1157    // -----------------------------------------------------------------------
1158
1159    #[test]
1160    fn test_scoring_error_display_weights() {
1161        let err = ScoringError::WeightsMustSumToOne { got: 1.5 };
1162        let msg = err.to_string();
1163        assert!(msg.contains("1.5") || msg.contains("1.500000"), "{msg}");
1164    }
1165
1166    #[test]
1167    fn test_scoring_error_display_peer_not_found() {
1168        let err = ScoringError::PeerNotFound;
1169        let msg = err.to_string();
1170        assert!(!msg.is_empty());
1171    }
1172
1173    // -----------------------------------------------------------------------
1174    // Edge cases
1175    // -----------------------------------------------------------------------
1176
1177    #[test]
1178    fn test_negative_rtt_does_not_panic() {
1179        let sys = PeerScoringSystem::default();
1180        let m = PeerMetrics {
1181            peer_id: "p".into(),
1182            rtt_ms: -10.0, // negative RTT: exp(10/500) > 1, but formula is fine
1183            bandwidth_bps: 0.0,
1184            uptime_fraction: 0.0,
1185            success_rate: 0.0,
1186            gossip_delivery_rate: 0.0,
1187            routing_success_rate: 0.0,
1188            last_updated: 0,
1189        };
1190        let s = sys.score_peer(&m, 0);
1191        let lat = s.components[&PsScoringDimension::Latency];
1192        assert!(lat > 1.0, "negative RTT should give score > 1.0: got {lat}");
1193    }
1194
1195    #[test]
1196    fn test_multiple_peers_independent_history() {
1197        let mut sys = PeerScoringSystem::default();
1198        sys.score_and_record(&excellent_metrics("a"), 10);
1199        sys.score_and_record(&excellent_metrics("a"), 20);
1200        sys.score_and_record(&poor_metrics("b"), 30);
1201        assert_eq!(sys.history_vec("a").len(), 2);
1202        assert_eq!(sys.history_vec("b").len(), 1);
1203    }
1204
1205    #[test]
1206    fn test_peer_id_stored_in_score() {
1207        let sys = PeerScoringSystem::default();
1208        let s = sys.score_peer(&excellent_metrics("my-peer"), 42);
1209        assert_eq!(s.peer_id, "my-peer");
1210        assert_eq!(s.computed_at, 42);
1211    }
1212}