Skip to main content

ipfrs_network/
peer_score.rs

1//! Peer Score Tracker for GossipSub mesh management
2//!
3//! Tracks composite peer quality scores used by GossipSub mesh management.
4//! Combines message delivery rate, topic mesh contribution, and
5//! application-level quality signals.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use ipfrs_network::peer_score::{PeerScoreTracker, ScoreParameter, BehaviourPenalty};
11//!
12//! let mut tracker = PeerScoreTracker::new(ScoreParameter::default());
13//! tracker.get_or_create("peer-1");
14//! let score = tracker.composite_score("peer-1");
15//! println!("Peer score: {score}");
16//! ```
17
18use std::collections::HashMap;
19use std::time::Instant;
20
21/// Parameters controlling how peer scores are weighted and decayed.
22#[derive(Debug, Clone)]
23pub struct ScoreParameter {
24    /// Weight for topic-specific score component.
25    pub topic_weight: f64,
26    /// Weight for delivery rate score component.
27    pub delivery_weight: f64,
28    /// Weight for application behaviour component.
29    pub behaviour_weight: f64,
30    /// How often (in seconds) scores are decayed.
31    pub decay_interval_secs: u64,
32    /// Score below which a peer is banned.
33    pub score_threshold_ban: f64,
34    /// Score below which a peer is greylisted (but above ban).
35    pub score_threshold_graylist: f64,
36}
37
38impl Default for ScoreParameter {
39    fn default() -> Self {
40        Self {
41            topic_weight: 0.5,
42            delivery_weight: 0.3,
43            behaviour_weight: 0.2,
44            decay_interval_secs: 10,
45            score_threshold_ban: -100.0,
46            score_threshold_graylist: -10.0,
47        }
48    }
49}
50
51/// Per-peer, per-topic scoring data.
52#[derive(Debug, Clone, Default)]
53pub struct TopicScore {
54    /// Increases when the peer is first to deliver a message on this topic.
55    pub first_message_deliveries: f64,
56    /// Deliveries counted while the peer is in the mesh for this topic.
57    pub mesh_message_deliveries: f64,
58    /// Penalty accumulated when a peer was in the mesh but had poor delivery.
59    pub mesh_failure_penalty: f64,
60    /// Counts of invalid messages; penalises the composite score heavily.
61    pub invalid_message_deliveries: f64,
62    /// Whether this peer is currently in the mesh for this topic.
63    pub in_mesh: bool,
64    /// Seconds the peer has been in the mesh for this topic.
65    pub mesh_time_secs: f64,
66}
67
68impl TopicScore {
69    /// Compute the weighted topic score.
70    ///
71    /// Formula:
72    /// - P1: first-message-delivery bonus (capped implicitly by values)
73    /// - P2: mesh-message-delivery bonus
74    /// - P3: mesh-failure penalty (negative)
75    /// - P4: invalid-message penalty (negative, weight ×10)
76    ///
77    /// The result is scaled by `p.topic_weight`.
78    pub fn score(&self, p: &ScoreParameter) -> f64 {
79        let p1 = self.first_message_deliveries;
80        let p2 = self.mesh_message_deliveries;
81        let p3 = -self.mesh_failure_penalty;
82        let p4 = -self.invalid_message_deliveries * 10.0;
83        (p1 + p2 + p3 + p4) * p.topic_weight
84    }
85}
86
87/// Application-level behaviour penalties that can be applied to a peer.
88#[derive(Debug, Clone, PartialEq)]
89pub enum BehaviourPenalty {
90    /// Peer sent an invalid message. Score delta: -10.0
91    InvalidMessage,
92    /// Peer was grafted but immediately triggered a backoff. Score delta: -5.0
93    GraftBackoff,
94    /// Peer exhibited promiscuous PX behaviour. Score delta: -3.0
95    PromiscuousPX,
96    /// Custom application-specific score delta.
97    AppSpecific(f64),
98}
99
100impl BehaviourPenalty {
101    /// Returns the score delta associated with this penalty variant.
102    pub fn delta(&self) -> f64 {
103        match self {
104            BehaviourPenalty::InvalidMessage => -10.0,
105            BehaviourPenalty::GraftBackoff => -5.0,
106            BehaviourPenalty::PromiscuousPX => -3.0,
107            BehaviourPenalty::AppSpecific(v) => *v,
108        }
109    }
110}
111
112/// Scoring state for a single peer.
113#[derive(Debug, Clone)]
114pub struct PeerScore {
115    /// Identifier of the peer.
116    pub peer_id: String,
117    /// Per-topic scoring data.
118    pub topic_scores: HashMap<String, TopicScore>,
119    /// Accumulated behaviour penalty sum.
120    pub behaviour_penalties: f64,
121    /// Application-layer score modifier.
122    pub app_specific_score: f64,
123    /// Time of the last decay pass.
124    pub last_decay: Instant,
125}
126
127impl PeerScore {
128    /// Create a new `PeerScore` for the given peer.
129    pub fn new(peer_id: impl Into<String>) -> Self {
130        Self {
131            peer_id: peer_id.into(),
132            topic_scores: HashMap::new(),
133            behaviour_penalties: 0.0,
134            app_specific_score: 0.0,
135            last_decay: Instant::now(),
136        }
137    }
138
139    /// Compute the composite score combining topic, delivery, and behaviour
140    /// components, weighted by `params`.
141    ///
142    /// ```text
143    /// composite = Σ(topic_score_i) * topic_weight
144    ///           + behaviour_penalties * behaviour_weight
145    ///           + app_specific_score * delivery_weight
146    /// ```
147    pub fn composite_score(&self, params: &ScoreParameter) -> f64 {
148        let topic_sum: f64 = self.topic_scores.values().map(|ts| ts.score(params)).sum();
149        let behaviour_part = self.behaviour_penalties * params.behaviour_weight;
150        let app_part = self.app_specific_score * params.delivery_weight;
151        topic_sum + behaviour_part + app_part
152    }
153
154    /// Apply a behaviour penalty to this peer.
155    pub fn apply_penalty(&mut self, p: BehaviourPenalty) {
156        self.behaviour_penalties += p.delta();
157    }
158
159    /// Record that this peer was the first to deliver a message on `topic`.
160    pub fn record_first_delivery(&mut self, topic: &str) {
161        let ts = self.topic_scores.entry(topic.to_string()).or_default();
162        ts.first_message_deliveries += 1.0;
163    }
164
165    /// Record a mesh message delivery for `topic`.
166    pub fn record_mesh_delivery(&mut self, topic: &str) {
167        let ts = self.topic_scores.entry(topic.to_string()).or_default();
168        ts.mesh_message_deliveries += 1.0;
169    }
170
171    /// Record an invalid message on `topic` and increase the
172    /// `invalid_message_deliveries` counter.
173    pub fn record_invalid_message(&mut self, topic: &str) {
174        let ts = self.topic_scores.entry(topic.to_string()).or_default();
175        ts.invalid_message_deliveries += 1.0;
176    }
177
178    /// Update the in-mesh flag for `topic`.
179    pub fn set_in_mesh(&mut self, topic: &str, in_mesh: bool) {
180        let ts = self.topic_scores.entry(topic.to_string()).or_default();
181        ts.in_mesh = in_mesh;
182    }
183
184    /// Decay all numeric score fields based on time elapsed since the last decay.
185    ///
186    /// Decay factor: `0.9 ^ (elapsed_secs / decay_interval_secs)`
187    pub fn decay(&mut self, params: &ScoreParameter) {
188        let elapsed = self.last_decay.elapsed();
189        let factor = 0.9_f64.powf(elapsed.as_secs_f64() / params.decay_interval_secs as f64);
190
191        for ts in self.topic_scores.values_mut() {
192            ts.first_message_deliveries *= factor;
193            ts.mesh_message_deliveries *= factor;
194            ts.mesh_failure_penalty *= factor;
195            ts.invalid_message_deliveries *= factor;
196            ts.mesh_time_secs *= factor;
197        }
198
199        self.behaviour_penalties *= factor;
200        self.app_specific_score *= factor;
201        self.last_decay = Instant::now();
202    }
203}
204
205/// Central tracker that maintains `PeerScore` entries for every known peer
206/// and exposes mesh-management queries.
207#[derive(Debug)]
208pub struct PeerScoreTracker {
209    /// Map from peer ID string to per-peer score state.
210    pub scores: HashMap<String, PeerScore>,
211    /// Global scoring parameters.
212    pub params: ScoreParameter,
213}
214
215impl PeerScoreTracker {
216    /// Create a new tracker with the given scoring parameters.
217    pub fn new(params: ScoreParameter) -> Self {
218        Self {
219            scores: HashMap::new(),
220            params,
221        }
222    }
223
224    /// Return a mutable reference to the `PeerScore` for `peer_id`,
225    /// inserting a default entry if it does not yet exist.
226    pub fn get_or_create(&mut self, peer_id: &str) -> &mut PeerScore {
227        self.scores
228            .entry(peer_id.to_string())
229            .or_insert_with(|| PeerScore::new(peer_id))
230    }
231
232    /// Return the composite score for `peer_id`, or `0.0` if unknown.
233    pub fn composite_score(&self, peer_id: &str) -> f64 {
234        self.scores
235            .get(peer_id)
236            .map(|ps| ps.composite_score(&self.params))
237            .unwrap_or(0.0)
238    }
239
240    /// Return all peer IDs whose composite score is below the ban threshold.
241    pub fn banned_peers(&self) -> Vec<String> {
242        self.scores
243            .iter()
244            .filter(|(_, ps)| ps.composite_score(&self.params) < self.params.score_threshold_ban)
245            .map(|(id, _)| id.clone())
246            .collect()
247    }
248
249    /// Return all peer IDs whose composite score is below the graylist threshold
250    /// but at or above the ban threshold.
251    pub fn greylisted_peers(&self) -> Vec<String> {
252        self.scores
253            .iter()
254            .filter(|(_, ps)| {
255                let s = ps.composite_score(&self.params);
256                s < self.params.score_threshold_graylist && s >= self.params.score_threshold_ban
257            })
258            .map(|(id, _)| id.clone())
259            .collect()
260    }
261
262    /// Return the top-`n` peer IDs sorted by composite score (descending).
263    pub fn best_peers(&self, n: usize) -> Vec<String> {
264        let mut scored: Vec<(String, f64)> = self
265            .scores
266            .iter()
267            .map(|(id, ps)| (id.clone(), ps.composite_score(&self.params)))
268            .collect();
269        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
270        scored.into_iter().take(n).map(|(id, _)| id).collect()
271    }
272
273    /// Trigger a decay pass on every tracked peer.
274    pub fn apply_decay_all(&mut self) {
275        let params = self.params.clone();
276        for ps in self.scores.values_mut() {
277            ps.decay(&params);
278        }
279    }
280
281    /// Remove the listed peer IDs from the tracker.
282    pub fn prune_peers(&mut self, ids: &[&str]) {
283        for id in ids {
284            self.scores.remove(*id);
285        }
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Tests
291// ---------------------------------------------------------------------------
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use std::time::Duration;
297
298    // 1. Default params construction
299    #[test]
300    fn test_default_params() {
301        let p = ScoreParameter::default();
302        assert!((p.topic_weight - 0.5).abs() < f64::EPSILON);
303        assert!((p.delivery_weight - 0.3).abs() < f64::EPSILON);
304        assert!((p.behaviour_weight - 0.2).abs() < f64::EPSILON);
305        assert_eq!(p.decay_interval_secs, 10);
306        assert!((p.score_threshold_ban - (-100.0)).abs() < f64::EPSILON);
307        assert!((p.score_threshold_graylist - (-10.0)).abs() < f64::EPSILON);
308    }
309
310    // 2. TopicScore::score formula
311    #[test]
312    fn test_topic_score_formula() {
313        let p = ScoreParameter::default();
314        let ts = TopicScore {
315            first_message_deliveries: 4.0,
316            mesh_message_deliveries: 6.0,
317            mesh_failure_penalty: 1.0,
318            invalid_message_deliveries: 0.5,
319            in_mesh: true,
320            mesh_time_secs: 30.0,
321        };
322        // (4 + 6 - 1 - 5) * 0.5 = 4 * 0.5 = 2.0
323        let expected = (4.0 + 6.0 - 1.0 - 5.0) * 0.5;
324        assert!((ts.score(&p) - expected).abs() < 1e-9);
325    }
326
327    // 3. BehaviourPenalty delta values
328    #[test]
329    fn test_behaviour_penalty_deltas() {
330        assert!((BehaviourPenalty::InvalidMessage.delta() - (-10.0)).abs() < f64::EPSILON);
331        assert!((BehaviourPenalty::GraftBackoff.delta() - (-5.0)).abs() < f64::EPSILON);
332        assert!((BehaviourPenalty::PromiscuousPX.delta() - (-3.0)).abs() < f64::EPSILON);
333        assert!((BehaviourPenalty::AppSpecific(7.5).delta() - 7.5).abs() < f64::EPSILON);
334        assert!((BehaviourPenalty::AppSpecific(-2.0).delta() - (-2.0)).abs() < f64::EPSILON);
335    }
336
337    // 4. composite_score weighted sum
338    #[test]
339    fn test_composite_score_weighted_sum() {
340        let p = ScoreParameter::default();
341        let mut ps = PeerScore::new("peer-a");
342        ps.app_specific_score = 10.0;
343        ps.behaviour_penalties = -5.0;
344        // No topic scores: composite = 0 + (-5 * 0.2) + (10 * 0.3) = -1.0 + 3.0 = 2.0
345        let c = ps.composite_score(&p);
346        assert!((c - 2.0).abs() < 1e-9, "got {c}");
347    }
348
349    // 5. record_first_delivery increments correct field
350    #[test]
351    fn test_record_first_delivery() {
352        let mut ps = PeerScore::new("peer-b");
353        ps.record_first_delivery("topic-1");
354        ps.record_first_delivery("topic-1");
355        let ts = ps.topic_scores.get("topic-1").expect("topic must exist");
356        assert!((ts.first_message_deliveries - 2.0).abs() < f64::EPSILON);
357        assert!((ts.mesh_message_deliveries).abs() < f64::EPSILON);
358    }
359
360    // 6. record_mesh_delivery increments mesh field
361    #[test]
362    fn test_record_mesh_delivery() {
363        let mut ps = PeerScore::new("peer-c");
364        ps.record_mesh_delivery("topic-2");
365        ps.record_mesh_delivery("topic-2");
366        ps.record_mesh_delivery("topic-2");
367        let ts = ps.topic_scores.get("topic-2").expect("topic must exist");
368        assert!((ts.mesh_message_deliveries - 3.0).abs() < f64::EPSILON);
369        assert!((ts.first_message_deliveries).abs() < f64::EPSILON);
370    }
371
372    // 7. record_invalid_message increments invalid + penalises composite
373    #[test]
374    fn test_record_invalid_message_penalises() {
375        let p = ScoreParameter::default();
376        let mut ps = PeerScore::new("peer-d");
377        let before = ps.composite_score(&p);
378        ps.record_invalid_message("topic-3");
379        let after = ps.composite_score(&p);
380        assert!(after < before, "invalid message must lower composite score");
381        let ts = ps.topic_scores.get("topic-3").expect("topic must exist");
382        assert!((ts.invalid_message_deliveries - 1.0).abs() < f64::EPSILON);
383    }
384
385    // 8. set_in_mesh updates in_mesh flag
386    #[test]
387    fn test_set_in_mesh() {
388        let mut ps = PeerScore::new("peer-e");
389        ps.set_in_mesh("t", true);
390        assert!(ps.topic_scores["t"].in_mesh);
391        ps.set_in_mesh("t", false);
392        assert!(!ps.topic_scores["t"].in_mesh);
393    }
394
395    // 9. apply_penalty BehaviourPenalty variants
396    #[test]
397    fn test_apply_penalty_variants() {
398        let mut ps = PeerScore::new("peer-f");
399        assert!((ps.behaviour_penalties).abs() < f64::EPSILON);
400        ps.apply_penalty(BehaviourPenalty::InvalidMessage);
401        assert!((ps.behaviour_penalties - (-10.0)).abs() < f64::EPSILON);
402        ps.apply_penalty(BehaviourPenalty::GraftBackoff);
403        assert!((ps.behaviour_penalties - (-15.0)).abs() < f64::EPSILON);
404        ps.apply_penalty(BehaviourPenalty::PromiscuousPX);
405        assert!((ps.behaviour_penalties - (-18.0)).abs() < f64::EPSILON);
406        ps.apply_penalty(BehaviourPenalty::AppSpecific(3.0));
407        assert!((ps.behaviour_penalties - (-15.0)).abs() < f64::EPSILON);
408    }
409
410    // 10. decay reduces all numeric fields
411    #[test]
412    fn test_decay_reduces_fields() {
413        let p = ScoreParameter {
414            decay_interval_secs: 1,
415            ..ScoreParameter::default()
416        };
417        let mut ps = PeerScore::new("peer-g");
418        ps.record_first_delivery("t");
419        ps.record_mesh_delivery("t");
420        ps.record_invalid_message("t");
421        ps.behaviour_penalties = -20.0;
422        ps.app_specific_score = 5.0;
423
424        // Force last_decay to be far enough in the past for a visible decay
425        ps.last_decay = Instant::now() - Duration::from_secs(10);
426        ps.decay(&p);
427
428        let ts = &ps.topic_scores["t"];
429        assert!(ts.first_message_deliveries < 1.0);
430        assert!(ts.mesh_message_deliveries < 1.0);
431        assert!(ts.invalid_message_deliveries < 1.0);
432        assert!(ps.behaviour_penalties > -20.0);
433        assert!(ps.app_specific_score < 5.0);
434    }
435
436    // 11. banned_peers filters by threshold
437    #[test]
438    fn test_banned_peers() {
439        let p = ScoreParameter::default();
440        let mut tracker = PeerScoreTracker::new(p);
441        {
442            let ps = tracker.get_or_create("good");
443            ps.app_specific_score = 10.0;
444        }
445        {
446            let ps = tracker.get_or_create("bad");
447            // behaviour_penalties * 0.2 must be < -100
448            ps.behaviour_penalties = -600.0;
449        }
450        let banned = tracker.banned_peers();
451        assert!(banned.contains(&"bad".to_string()));
452        assert!(!banned.contains(&"good".to_string()));
453    }
454
455    // 12. greylisted_peers range filter
456    #[test]
457    fn test_greylisted_peers() {
458        let p = ScoreParameter::default();
459        let mut tracker = PeerScoreTracker::new(p);
460        {
461            let ps = tracker.get_or_create("ok");
462            ps.app_specific_score = 5.0;
463        }
464        {
465            let ps = tracker.get_or_create("grey");
466            // -30 * 0.2 = -6 < -10? No; need > -50 to stay above ban
467            // behaviour * 0.2 = -30: that is -6.0, not grey enough
468            // Let's target: composite = -15 (grey) => behaviour * 0.2 = -15 => behaviour = -75
469            ps.behaviour_penalties = -75.0;
470        }
471        {
472            let ps = tracker.get_or_create("banned");
473            ps.behaviour_penalties = -600.0;
474        }
475        let grey = tracker.greylisted_peers();
476        assert!(grey.contains(&"grey".to_string()));
477        assert!(!grey.contains(&"ok".to_string()));
478        assert!(!grey.contains(&"banned".to_string()));
479    }
480
481    // 13. best_peers sorted descending
482    #[test]
483    fn test_best_peers_sorted() {
484        let p = ScoreParameter::default();
485        let mut tracker = PeerScoreTracker::new(p);
486        for (id, score) in [("p1", 5.0), ("p2", 20.0), ("p3", 1.0), ("p4", 10.0)] {
487            let ps = tracker.get_or_create(id);
488            ps.app_specific_score = score;
489        }
490        let best = tracker.best_peers(3);
491        assert_eq!(best.len(), 3);
492        assert_eq!(best[0], "p2");
493        assert_eq!(best[1], "p4");
494        assert_eq!(best[2], "p1");
495    }
496
497    // 14. prune_peers removes entries
498    #[test]
499    fn test_prune_peers() {
500        let p = ScoreParameter::default();
501        let mut tracker = PeerScoreTracker::new(p);
502        tracker.get_or_create("a");
503        tracker.get_or_create("b");
504        tracker.get_or_create("c");
505        assert_eq!(tracker.scores.len(), 3);
506        tracker.prune_peers(&["a", "c"]);
507        assert_eq!(tracker.scores.len(), 1);
508        assert!(tracker.scores.contains_key("b"));
509        assert!(!tracker.scores.contains_key("a"));
510        assert!(!tracker.scores.contains_key("c"));
511    }
512
513    // 15. get_or_create creates missing entry
514    #[test]
515    fn test_get_or_create() {
516        let p = ScoreParameter::default();
517        let mut tracker = PeerScoreTracker::new(p);
518        assert!(!tracker.scores.contains_key("new-peer"));
519        tracker.get_or_create("new-peer");
520        assert!(tracker.scores.contains_key("new-peer"));
521        // Calling again should not duplicate
522        tracker.get_or_create("new-peer");
523        assert_eq!(tracker.scores.len(), 1);
524    }
525
526    // 16. apply_decay_all touches all peers
527    #[test]
528    fn test_apply_decay_all() {
529        let p = ScoreParameter {
530            decay_interval_secs: 1,
531            ..ScoreParameter::default()
532        };
533        let mut tracker = PeerScoreTracker::new(p);
534        for id in ["x", "y", "z"] {
535            let ps = tracker.get_or_create(id);
536            ps.behaviour_penalties = -50.0;
537            ps.last_decay = Instant::now() - Duration::from_secs(10);
538        }
539        tracker.apply_decay_all();
540        for id in ["x", "y", "z"] {
541            let ps = &tracker.scores[id];
542            // After decay over 10 intervals: 0.9^10 ≈ 0.349 => -50 * 0.349 ≈ -17.4
543            assert!(ps.behaviour_penalties > -50.0, "decay must reduce {id}");
544            assert!(ps.behaviour_penalties < 0.0, "{id} still negative");
545        }
546    }
547
548    // Extra: composite_score returns 0.0 for unknown peers
549    #[test]
550    fn test_composite_score_unknown_peer() {
551        let tracker = PeerScoreTracker::new(ScoreParameter::default());
552        assert!((tracker.composite_score("ghost")).abs() < f64::EPSILON);
553    }
554}