Skip to main content

ipfrs_network/
scoreboard.rs

1//! Composite peer scoring system (PeerScoreboard)
2//!
3//! Combines multiple weighted scoring components into a single composite score
4//! per peer, with configurable decay and tick-based aging.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ipfrs_network::scoreboard::{PeerScoreboard, ScoreboardStats};
10//!
11//! let mut board = PeerScoreboard::new(0.99);
12//! board.register_component("latency", 2.0);
13//! board.register_component("uptime", 1.0);
14//! board.update_score("peer-a", "latency", 0.8);
15//! board.update_score("peer-a", "uptime", 1.0);
16//! assert!(board.get_score("peer-a").is_some());
17//! ```
18
19use std::collections::HashMap;
20
21// ---------------------------------------------------------------------------
22// Public types
23// ---------------------------------------------------------------------------
24
25/// A single scoring dimension for a peer.
26#[derive(Debug, Clone)]
27pub struct ScoreComponent {
28    /// Human-readable name of this component.
29    pub name: String,
30    /// Weight applied when computing the composite score.
31    pub weight: f64,
32    /// Normalised value in `[0.0, 1.0]`.
33    pub value: f64,
34    /// Tick at which this component was last updated.
35    pub last_updated_tick: u64,
36}
37
38/// Aggregated score entry for one peer.
39#[derive(Debug, Clone)]
40pub struct SbPeerScore {
41    /// Peer identifier.
42    pub peer_id: String,
43    /// Per-component scores keyed by component name.
44    pub components: HashMap<String, ScoreComponent>,
45    /// Weighted-sum composite score (recomputed on every mutation).
46    pub composite_score: f64,
47}
48
49/// Summary statistics for the entire scoreboard.
50#[derive(Debug, Clone)]
51pub struct ScoreboardStats {
52    /// Number of tracked peers.
53    pub peer_count: usize,
54    /// Number of registered component types.
55    pub component_count: usize,
56    /// Mean composite score across all peers.
57    pub avg_composite: f64,
58    /// Maximum composite score across all peers.
59    pub max_composite: f64,
60}
61
62/// Composite peer scoreboard.
63///
64/// Maintains per-peer scores across multiple weighted components and provides
65/// ranking, decay, and statistics.
66#[derive(Debug, Clone)]
67pub struct PeerScoreboard {
68    peers: HashMap<String, SbPeerScore>,
69    component_weights: HashMap<String, f64>,
70    current_tick: u64,
71    decay_rate: f64,
72}
73
74// ---------------------------------------------------------------------------
75// Implementation
76// ---------------------------------------------------------------------------
77
78impl PeerScoreboard {
79    /// Create a new scoreboard with the given per-tick decay rate.
80    ///
81    /// `decay_rate` is clamped to `[0.0, 1.0]`.
82    pub fn new(decay_rate: f64) -> Self {
83        Self {
84            peers: HashMap::new(),
85            component_weights: HashMap::new(),
86            current_tick: 0,
87            decay_rate: decay_rate.clamp(0.0, 1.0),
88        }
89    }
90
91    /// Register (or update the default weight of) a scoring component.
92    pub fn register_component(&mut self, name: &str, default_weight: f64) {
93        self.component_weights
94            .insert(name.to_string(), default_weight);
95    }
96
97    /// Update a single component score for `peer_id`.
98    ///
99    /// `value` is clamped to `[0.0, 1.0]`. If the component has not been
100    /// registered the call is silently ignored.
101    pub fn update_score(&mut self, peer_id: &str, component: &str, value: f64) {
102        let weight = match self.component_weights.get(component) {
103            Some(&w) => w,
104            None => return,
105        };
106
107        let tick = self.current_tick;
108        let entry = self
109            .peers
110            .entry(peer_id.to_string())
111            .or_insert_with(|| SbPeerScore {
112                peer_id: peer_id.to_string(),
113                components: HashMap::new(),
114                composite_score: 0.0,
115            });
116
117        let comp = entry
118            .components
119            .entry(component.to_string())
120            .or_insert_with(|| ScoreComponent {
121                name: component.to_string(),
122                weight,
123                value: 0.0,
124                last_updated_tick: tick,
125            });
126
127        comp.value = value.clamp(0.0, 1.0);
128        comp.weight = weight;
129        comp.last_updated_tick = tick;
130
131        Self::recompute_composite(entry);
132    }
133
134    /// Return the composite score for `peer_id`, if tracked.
135    pub fn get_score(&self, peer_id: &str) -> Option<f64> {
136        self.peers.get(peer_id).map(|p| p.composite_score)
137    }
138
139    /// Return the value of a single component for `peer_id`.
140    pub fn get_component(&self, peer_id: &str, component: &str) -> Option<f64> {
141        self.peers
142            .get(peer_id)
143            .and_then(|p| p.components.get(component))
144            .map(|c| c.value)
145    }
146
147    /// Return all peers ranked by composite score (descending).
148    pub fn rank_peers(&self) -> Vec<(String, f64)> {
149        let mut ranked: Vec<(String, f64)> = self
150            .peers
151            .values()
152            .map(|p| (p.peer_id.clone(), p.composite_score))
153            .collect();
154        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
155        ranked
156    }
157
158    /// Return the top `n` peers by composite score (descending).
159    pub fn top_peers(&self, n: usize) -> Vec<(String, f64)> {
160        let ranked = self.rank_peers();
161        ranked.into_iter().take(n).collect()
162    }
163
164    /// Multiply all component values by `decay_rate` and recompute composites.
165    pub fn apply_decay(&mut self) {
166        let rate = self.decay_rate;
167        for entry in self.peers.values_mut() {
168            for comp in entry.components.values_mut() {
169                comp.value *= rate;
170                // Re-clamp just in case of floating-point drift.
171                comp.value = comp.value.clamp(0.0, 1.0);
172            }
173            Self::recompute_composite(entry);
174        }
175    }
176
177    /// Remove a peer from the scoreboard. Returns `true` if the peer existed.
178    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
179        self.peers.remove(peer_id).is_some()
180    }
181
182    /// Number of tracked peers.
183    pub fn peer_count(&self) -> usize {
184        self.peers.len()
185    }
186
187    /// Advance the internal tick counter by one and apply decay.
188    pub fn tick(&mut self) {
189        self.current_tick = self.current_tick.saturating_add(1);
190        self.apply_decay();
191    }
192
193    /// Snapshot statistics for the entire scoreboard.
194    pub fn stats(&self) -> ScoreboardStats {
195        let peer_count = self.peers.len();
196        let component_count = self.component_weights.len();
197
198        let (sum, max) = self.peers.values().fold((0.0_f64, 0.0_f64), |(s, m), p| {
199            (s + p.composite_score, m.max(p.composite_score))
200        });
201
202        let avg_composite = if peer_count > 0 {
203            sum / peer_count as f64
204        } else {
205            0.0
206        };
207
208        ScoreboardStats {
209            peer_count,
210            component_count,
211            avg_composite,
212            max_composite: max,
213        }
214    }
215
216    /// Return the current tick value.
217    pub fn current_tick(&self) -> u64 {
218        self.current_tick
219    }
220
221    // -----------------------------------------------------------------------
222    // Private helpers
223    // -----------------------------------------------------------------------
224
225    /// Recompute the composite score for a single peer entry.
226    fn recompute_composite(entry: &mut SbPeerScore) {
227        let total_weight: f64 = entry.components.values().map(|c| c.weight.abs()).sum();
228        if total_weight == 0.0 {
229            entry.composite_score = 0.0;
230            return;
231        }
232        let weighted_sum: f64 = entry.components.values().map(|c| c.value * c.weight).sum();
233        entry.composite_score = weighted_sum / total_weight;
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Tests
239// ---------------------------------------------------------------------------
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // -- basics --------------------------------------------------------------
246
247    #[test]
248    fn test_new_scoreboard_empty() {
249        let sb = PeerScoreboard::new(0.999);
250        assert_eq!(sb.peer_count(), 0);
251        assert_eq!(sb.current_tick(), 0);
252    }
253
254    #[test]
255    fn test_register_component() {
256        let mut sb = PeerScoreboard::new(0.999);
257        sb.register_component("latency", 2.0);
258        sb.register_component("uptime", 1.0);
259        let stats = sb.stats();
260        assert_eq!(stats.component_count, 2);
261    }
262
263    #[test]
264    fn test_update_score_creates_peer() {
265        let mut sb = PeerScoreboard::new(0.999);
266        sb.register_component("latency", 1.0);
267        sb.update_score("p1", "latency", 0.5);
268        assert_eq!(sb.peer_count(), 1);
269    }
270
271    #[test]
272    fn test_get_score_known_peer() {
273        let mut sb = PeerScoreboard::new(0.999);
274        sb.register_component("x", 1.0);
275        sb.update_score("p1", "x", 0.7);
276        let score = sb.get_score("p1");
277        assert!(score.is_some());
278        assert!((score.expect("just checked") - 0.7).abs() < 1e-9);
279    }
280
281    #[test]
282    fn test_get_score_unknown_peer() {
283        let sb = PeerScoreboard::new(0.999);
284        assert!(sb.get_score("missing").is_none());
285    }
286
287    #[test]
288    fn test_get_component() {
289        let mut sb = PeerScoreboard::new(0.999);
290        sb.register_component("a", 1.0);
291        sb.update_score("p1", "a", 0.42);
292        let v = sb.get_component("p1", "a");
293        assert!(v.is_some());
294        assert!((v.expect("just checked") - 0.42).abs() < 1e-9);
295    }
296
297    #[test]
298    fn test_get_component_missing_component() {
299        let mut sb = PeerScoreboard::new(0.999);
300        sb.register_component("a", 1.0);
301        sb.update_score("p1", "a", 0.5);
302        assert!(sb.get_component("p1", "b").is_none());
303    }
304
305    #[test]
306    fn test_get_component_missing_peer() {
307        let sb = PeerScoreboard::new(0.999);
308        assert!(sb.get_component("nope", "x").is_none());
309    }
310
311    // -- clamping ------------------------------------------------------------
312
313    #[test]
314    fn test_value_clamped_above_one() {
315        let mut sb = PeerScoreboard::new(0.999);
316        sb.register_component("c", 1.0);
317        sb.update_score("p1", "c", 5.0);
318        assert!((sb.get_component("p1", "c").expect("exists") - 1.0).abs() < 1e-9);
319    }
320
321    #[test]
322    fn test_value_clamped_below_zero() {
323        let mut sb = PeerScoreboard::new(0.999);
324        sb.register_component("c", 1.0);
325        sb.update_score("p1", "c", -3.0);
326        assert!((sb.get_component("p1", "c").expect("exists")).abs() < 1e-9);
327    }
328
329    #[test]
330    fn test_decay_rate_clamped() {
331        let sb = PeerScoreboard::new(2.0);
332        assert!((sb.decay_rate - 1.0).abs() < 1e-9);
333        let sb2 = PeerScoreboard::new(-0.5);
334        assert!(sb2.decay_rate.abs() < 1e-9);
335    }
336
337    // -- composite calculation -----------------------------------------------
338
339    #[test]
340    fn test_composite_single_component() {
341        let mut sb = PeerScoreboard::new(0.999);
342        sb.register_component("a", 1.0);
343        sb.update_score("p1", "a", 0.8);
344        assert!((sb.get_score("p1").expect("exists") - 0.8).abs() < 1e-9);
345    }
346
347    #[test]
348    fn test_composite_equal_weights() {
349        let mut sb = PeerScoreboard::new(0.999);
350        sb.register_component("a", 1.0);
351        sb.register_component("b", 1.0);
352        sb.update_score("p1", "a", 0.6);
353        sb.update_score("p1", "b", 0.4);
354        // (0.6*1 + 0.4*1) / (1+1) = 0.5
355        assert!((sb.get_score("p1").expect("exists") - 0.5).abs() < 1e-9);
356    }
357
358    #[test]
359    fn test_composite_unequal_weights() {
360        let mut sb = PeerScoreboard::new(0.999);
361        sb.register_component("a", 3.0);
362        sb.register_component("b", 1.0);
363        sb.update_score("p1", "a", 1.0);
364        sb.update_score("p1", "b", 0.0);
365        // (1.0*3 + 0.0*1) / (3+1) = 0.75
366        assert!((sb.get_score("p1").expect("exists") - 0.75).abs() < 1e-9);
367    }
368
369    #[test]
370    fn test_composite_updates_on_change() {
371        let mut sb = PeerScoreboard::new(0.999);
372        sb.register_component("a", 1.0);
373        sb.update_score("p1", "a", 0.2);
374        assert!((sb.get_score("p1").expect("exists") - 0.2).abs() < 1e-9);
375        sb.update_score("p1", "a", 0.9);
376        assert!((sb.get_score("p1").expect("exists") - 0.9).abs() < 1e-9);
377    }
378
379    // -- ranking -------------------------------------------------------------
380
381    #[test]
382    fn test_rank_peers_descending() {
383        let mut sb = PeerScoreboard::new(0.999);
384        sb.register_component("a", 1.0);
385        sb.update_score("low", "a", 0.1);
386        sb.update_score("mid", "a", 0.5);
387        sb.update_score("high", "a", 0.9);
388        let ranked = sb.rank_peers();
389        assert_eq!(ranked.len(), 3);
390        assert_eq!(ranked[0].0, "high");
391        assert_eq!(ranked[1].0, "mid");
392        assert_eq!(ranked[2].0, "low");
393    }
394
395    #[test]
396    fn test_rank_peers_empty() {
397        let sb = PeerScoreboard::new(0.999);
398        assert!(sb.rank_peers().is_empty());
399    }
400
401    #[test]
402    fn test_top_peers() {
403        let mut sb = PeerScoreboard::new(0.999);
404        sb.register_component("a", 1.0);
405        sb.update_score("p1", "a", 0.1);
406        sb.update_score("p2", "a", 0.9);
407        sb.update_score("p3", "a", 0.5);
408        let top = sb.top_peers(2);
409        assert_eq!(top.len(), 2);
410        assert_eq!(top[0].0, "p2");
411        assert_eq!(top[1].0, "p3");
412    }
413
414    #[test]
415    fn test_top_peers_n_exceeds_count() {
416        let mut sb = PeerScoreboard::new(0.999);
417        sb.register_component("a", 1.0);
418        sb.update_score("p1", "a", 0.5);
419        let top = sb.top_peers(100);
420        assert_eq!(top.len(), 1);
421    }
422
423    // -- decay ---------------------------------------------------------------
424
425    #[test]
426    fn test_apply_decay_reduces_scores() {
427        let mut sb = PeerScoreboard::new(0.5);
428        sb.register_component("a", 1.0);
429        sb.update_score("p1", "a", 1.0);
430        sb.apply_decay();
431        let v = sb.get_component("p1", "a").expect("exists");
432        assert!((v - 0.5).abs() < 1e-9);
433    }
434
435    #[test]
436    fn test_apply_decay_multiple_times() {
437        let mut sb = PeerScoreboard::new(0.5);
438        sb.register_component("a", 1.0);
439        sb.update_score("p1", "a", 1.0);
440        sb.apply_decay();
441        sb.apply_decay();
442        let v = sb.get_component("p1", "a").expect("exists");
443        assert!((v - 0.25).abs() < 1e-9);
444    }
445
446    #[test]
447    fn test_tick_advances_and_decays() {
448        let mut sb = PeerScoreboard::new(0.5);
449        sb.register_component("a", 1.0);
450        sb.update_score("p1", "a", 1.0);
451        sb.tick();
452        assert_eq!(sb.current_tick(), 1);
453        let v = sb.get_component("p1", "a").expect("exists");
454        assert!((v - 0.5).abs() < 1e-9);
455    }
456
457    #[test]
458    fn test_tick_multiple() {
459        let mut sb = PeerScoreboard::new(0.9);
460        sb.register_component("a", 1.0);
461        sb.update_score("p1", "a", 1.0);
462        for _ in 0..10 {
463            sb.tick();
464        }
465        assert_eq!(sb.current_tick(), 10);
466        let v = sb.get_component("p1", "a").expect("exists");
467        let expected = 0.9_f64.powi(10);
468        assert!((v - expected).abs() < 1e-9);
469    }
470
471    // -- remove peer ---------------------------------------------------------
472
473    #[test]
474    fn test_remove_peer_existing() {
475        let mut sb = PeerScoreboard::new(0.999);
476        sb.register_component("a", 1.0);
477        sb.update_score("p1", "a", 0.5);
478        assert!(sb.remove_peer("p1"));
479        assert_eq!(sb.peer_count(), 0);
480        assert!(sb.get_score("p1").is_none());
481    }
482
483    #[test]
484    fn test_remove_peer_nonexistent() {
485        let mut sb = PeerScoreboard::new(0.999);
486        assert!(!sb.remove_peer("ghost"));
487    }
488
489    // -- stats ---------------------------------------------------------------
490
491    #[test]
492    fn test_stats_empty_board() {
493        let sb = PeerScoreboard::new(0.999);
494        let s = sb.stats();
495        assert_eq!(s.peer_count, 0);
496        assert_eq!(s.component_count, 0);
497        assert!((s.avg_composite).abs() < 1e-9);
498        assert!((s.max_composite).abs() < 1e-9);
499    }
500
501    #[test]
502    fn test_stats_with_peers() {
503        let mut sb = PeerScoreboard::new(0.999);
504        sb.register_component("a", 1.0);
505        sb.update_score("p1", "a", 0.4);
506        sb.update_score("p2", "a", 0.8);
507        let s = sb.stats();
508        assert_eq!(s.peer_count, 2);
509        assert_eq!(s.component_count, 1);
510        assert!((s.avg_composite - 0.6).abs() < 1e-9);
511        assert!((s.max_composite - 0.8).abs() < 1e-9);
512    }
513
514    // -- unregistered component is ignored -----------------------------------
515
516    #[test]
517    fn test_update_unregistered_component_ignored() {
518        let mut sb = PeerScoreboard::new(0.999);
519        sb.update_score("p1", "nope", 1.0);
520        assert_eq!(sb.peer_count(), 0);
521    }
522
523    // -- multiple components with different weights --------------------------
524
525    #[test]
526    fn test_multiple_components_weighted() {
527        let mut sb = PeerScoreboard::new(0.999);
528        sb.register_component("speed", 2.0);
529        sb.register_component("reliability", 3.0);
530        sb.register_component("age", 1.0);
531        sb.update_score("p1", "speed", 0.8);
532        sb.update_score("p1", "reliability", 0.6);
533        sb.update_score("p1", "age", 1.0);
534        // composite = (0.8*2 + 0.6*3 + 1.0*1) / (2+3+1) = (1.6+1.8+1.0)/6 = 4.4/6
535        let expected = 4.4 / 6.0;
536        assert!((sb.get_score("p1").expect("exists") - expected).abs() < 1e-9);
537    }
538
539    // -- register new component after peers exist ----------------------------
540
541    #[test]
542    fn test_register_component_after_peers_added() {
543        let mut sb = PeerScoreboard::new(0.999);
544        sb.register_component("a", 1.0);
545        sb.update_score("p1", "a", 0.5);
546        // register new component and update existing peer
547        sb.register_component("b", 1.0);
548        sb.update_score("p1", "b", 1.0);
549        // composite = (0.5+1.0)/2 = 0.75
550        assert!((sb.get_score("p1").expect("exists") - 0.75).abs() < 1e-9);
551    }
552
553    // -- decay with zero rate ------------------------------------------------
554
555    #[test]
556    fn test_decay_with_zero_rate() {
557        let mut sb = PeerScoreboard::new(0.0);
558        sb.register_component("a", 1.0);
559        sb.update_score("p1", "a", 1.0);
560        sb.apply_decay();
561        assert!((sb.get_component("p1", "a").expect("exists")).abs() < 1e-9);
562    }
563
564    // -- decay with rate 1.0 (no decay) --------------------------------------
565
566    #[test]
567    fn test_decay_with_rate_one() {
568        let mut sb = PeerScoreboard::new(1.0);
569        sb.register_component("a", 1.0);
570        sb.update_score("p1", "a", 0.7);
571        sb.apply_decay();
572        assert!((sb.get_component("p1", "a").expect("exists") - 0.7).abs() < 1e-9);
573    }
574
575    // -- peer_count ----------------------------------------------------------
576
577    #[test]
578    fn test_peer_count_multiple() {
579        let mut sb = PeerScoreboard::new(0.999);
580        sb.register_component("a", 1.0);
581        sb.update_score("p1", "a", 0.1);
582        sb.update_score("p2", "a", 0.2);
583        sb.update_score("p3", "a", 0.3);
584        assert_eq!(sb.peer_count(), 3);
585    }
586
587    // -- composite with partial components -----------------------------------
588
589    #[test]
590    fn test_composite_partial_components() {
591        let mut sb = PeerScoreboard::new(0.999);
592        sb.register_component("a", 2.0);
593        sb.register_component("b", 2.0);
594        // Only set component "a"
595        sb.update_score("p1", "a", 1.0);
596        // composite = 1.0*2 / 2 = 1.0 (only "a" present)
597        assert!((sb.get_score("p1").expect("exists") - 1.0).abs() < 1e-9);
598    }
599
600    // -- last_updated_tick set correctly -------------------------------------
601
602    #[test]
603    fn test_last_updated_tick() {
604        let mut sb = PeerScoreboard::new(0.999);
605        sb.register_component("a", 1.0);
606        sb.tick(); // tick = 1
607        sb.tick(); // tick = 2
608        sb.update_score("p1", "a", 0.5);
609        let entry = sb.peers.get("p1").expect("exists");
610        let comp = entry.components.get("a").expect("exists");
611        assert_eq!(comp.last_updated_tick, 2);
612    }
613
614    // -- decay recomputes composite ------------------------------------------
615
616    #[test]
617    fn test_decay_recomputes_composite() {
618        let mut sb = PeerScoreboard::new(0.5);
619        sb.register_component("a", 1.0);
620        sb.register_component("b", 1.0);
621        sb.update_score("p1", "a", 1.0);
622        sb.update_score("p1", "b", 1.0);
623        // composite = 1.0
624        assert!((sb.get_score("p1").expect("exists") - 1.0).abs() < 1e-9);
625        sb.apply_decay();
626        // both halved => composite = 0.5
627        assert!((sb.get_score("p1").expect("exists") - 0.5).abs() < 1e-9);
628    }
629
630    // -- ranking stability with equal scores ---------------------------------
631
632    #[test]
633    fn test_rank_peers_equal_scores() {
634        let mut sb = PeerScoreboard::new(0.999);
635        sb.register_component("a", 1.0);
636        sb.update_score("p1", "a", 0.5);
637        sb.update_score("p2", "a", 0.5);
638        let ranked = sb.rank_peers();
639        assert_eq!(ranked.len(), 2);
640        // Both should be 0.5
641        assert!((ranked[0].1 - 0.5).abs() < 1e-9);
642        assert!((ranked[1].1 - 0.5).abs() < 1e-9);
643    }
644
645    // -- weight update via re-register ---------------------------------------
646
647    #[test]
648    fn test_reregister_component_updates_weight() {
649        let mut sb = PeerScoreboard::new(0.999);
650        sb.register_component("a", 1.0);
651        sb.update_score("p1", "a", 1.0);
652        assert!((sb.get_score("p1").expect("exists") - 1.0).abs() < 1e-9);
653        // Change weight and re-update
654        sb.register_component("a", 3.0);
655        sb.update_score("p1", "a", 1.0);
656        // Still 1.0 because only one component
657        assert!((sb.get_score("p1").expect("exists") - 1.0).abs() < 1e-9);
658    }
659}