1use std::collections::{HashMap, VecDeque};
35
36#[derive(Debug, Clone, PartialEq, thiserror::Error)]
42pub enum ScoringError {
43 #[error("scoring weights must sum to 1.0, got {got:.6}")]
45 WeightsMustSumToOne {
46 got: f64,
48 },
49
50 #[error("peer not found in scoring history")]
52 PeerNotFound,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum PsScoringDimension {
62 Latency,
64 Bandwidth,
66 Availability,
68 Reliability,
70 GossipQuality,
72 RoutingQuality,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub enum ScoreTier {
85 Excellent,
87 Good,
89 Fair,
91 Poor,
93 Unacceptable,
95}
96
97impl ScoreTier {
98 #[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 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#[derive(Debug, Clone)]
146pub struct ScoringWeights {
147 pub latency: f64,
149 pub bandwidth: f64,
151 pub availability: f64,
153 pub reliability: f64,
155 pub gossip_quality: f64,
157 pub routing_quality: f64,
159}
160
161impl ScoringWeights {
162 #[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 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 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#[derive(Debug, Clone)]
205pub struct PeerMetrics {
206 pub peer_id: String,
208 pub rtt_ms: f64,
210 pub bandwidth_bps: f64,
212 pub uptime_fraction: f64,
214 pub success_rate: f64,
216 pub gossip_delivery_rate: f64,
218 pub routing_success_rate: f64,
220 pub last_updated: u64,
222}
223
224#[derive(Debug, Clone)]
230pub struct PsPeerScore {
231 pub peer_id: String,
233 pub composite: f64,
235 pub components: HashMap<PsScoringDimension, f64>,
237 pub tier: ScoreTier,
239 pub computed_at: u64,
241}
242
243#[derive(Debug, Clone)]
249pub struct ScoringStats {
250 pub total_peers: usize,
252 pub avg_composite: f64,
254 pub tier_distribution: HashMap<ScoreTier, usize>,
256 pub stale_peers: usize,
258}
259
260#[derive(Debug)]
269pub struct PeerScoringSystem {
270 pub weights: ScoringWeights,
272 pub history: HashMap<String, VecDeque<PsPeerScore>>,
274 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 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 pub fn with_max_history(mut self, max_history: usize) -> Self {
308 self.max_history = max_history.max(1);
309 self
310 }
311
312 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 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 pub fn history_for(&self, peer_id: &str) -> &[PsPeerScore] {
382 self.history
383 .get(peer_id)
384 .map(|dq| dq.as_slices().0) .unwrap_or(&[])
386 }
387
388 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 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 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; 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 pub fn top_peers(&self, n: usize, _now: u64) -> Vec<&PsPeerScore> {
455 let mut latest: Vec<&PsPeerScore> =
457 self.history.values().filter_map(|dq| dq.back()).collect();
458
459 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 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 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#[cfg(test)]
547mod tests {
548 use crate::peer_scoring::{
549 PeerMetrics, PeerScoringSystem, PsScoringDimension, ScoreTier, ScoringError, ScoringWeights,
550 };
551
552 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 #[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 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 #[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 #[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, 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 #[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, bandwidth_bps: 1_000_000.0, uptime_fraction: 0.5, success_rate: 0.75, gossip_delivery_rate: 0.25, routing_success_rate: 0.0, last_updated: 0,
822 };
823 let s = sys.score_peer(&m, 0);
824 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 #[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 #[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 assert_eq!(sys.history_vec("p1")[0].computed_at, 7);
922 }
923
924 #[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 sys.score_and_record(&excellent_metrics("p1"), 10);
947 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 #[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 let w = ScoringWeights::default();
980 let mut low_m = poor_metrics("p1");
981 let high_m = excellent_metrics("p1");
982
983 for i in 0..5usize {
987 let frac = 0.1 + i as f64 * 0.2;
988 let _ = &w;
990 low_m.bandwidth_bps = frac * 1_000_000.0;
991 low_m.rtt_ms = (-frac.ln()) * 500.0; 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 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 #[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 sys.score_and_record(&excellent_metrics("p1"), 0);
1067 sys.score_and_record(&poor_metrics("p1"), 1);
1068 let top = sys.top_peers(1, 1);
1070 assert_eq!(top[0].peer_id, "p1");
1071 let expected = sys.score_peer(&poor_metrics("p1"), 1).composite;
1073 assert!((top[0].composite - expected).abs() < 1e-9);
1074 }
1075
1076 #[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 #[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 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); assert!(sys.history.contains_key("p1"));
1153 }
1154
1155 #[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 #[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, 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}