Skip to main content

ipfrs_network/
peer_reputation.rs

1//! Peer Reputation Manager — long-term behavioral scoring for trust-weighted routing.
2//!
3//! Tracks per-peer reputation scores derived from a stream of [`PrReputationEvent`]s.
4//! Scores are clamped to `[0.0, 1.0]`, decay toward the neutral value of `0.5` each tick,
5//! and drive tier classification used by connection prioritisation and routing.
6//!
7//! # Quick start
8//!
9//! ```rust
10//! use ipfrs_network::peer_reputation::{
11//!     PeerReputationManager, PrReputationConfig, PrReputationEvent,
12//! };
13//!
14//! let cfg = PrReputationConfig::default();
15//! let mut mgr = PeerReputationManager::new(cfg);
16//!
17//! mgr.record_event("peer-A", PrReputationEvent::BlockDelivered { bytes: 4096 });
18//! mgr.record_event("peer-A", PrReputationEvent::GoodProof);
19//!
20//! let score = mgr.score("peer-A").expect("peer exists");
21//! assert!(score.is_trusted());
22//! ```
23
24use std::collections::HashMap;
25
26// ---------------------------------------------------------------------------
27// ReputationEvent
28// ---------------------------------------------------------------------------
29
30/// A discrete behavioural observation for a remote peer.
31#[derive(Clone, Debug, PartialEq)]
32pub enum PrReputationEvent {
33    /// A block was successfully transferred from the peer.
34    BlockDelivered {
35        /// Number of bytes in the delivered block.
36        bytes: u64,
37    },
38    /// A block request to the peer timed out.
39    BlockTimeout,
40    /// The peer sent an invalid message or otherwise violated the protocol.
41    ProtocolViolation,
42    /// The peer supplied a cryptographically valid proof.
43    GoodProof,
44    /// The peer supplied an invalid proof (major penalty).
45    BadProof,
46    /// The peer disconnected unexpectedly.
47    ConnectionDropped,
48}
49
50// ---------------------------------------------------------------------------
51// ReputationScore
52// ---------------------------------------------------------------------------
53
54/// Long-term reputation score for a single peer.
55#[derive(Clone, Debug)]
56pub struct PrReputationScore {
57    /// Current score, always clamped to `[0.0, 1.0]`.
58    pub score: f64,
59    /// Total number of events recorded for this peer.
60    pub total_events: u64,
61    /// Number of violations (ProtocolViolation + BadProof) observed.
62    pub violations: u32,
63}
64
65impl PrReputationScore {
66    fn new() -> Self {
67        Self {
68            score: 0.5,
69            total_events: 0,
70            violations: 0,
71        }
72    }
73
74    /// Returns `true` when the peer's score is at or above the trust threshold (≥ 0.7).
75    pub fn is_trusted(&self) -> bool {
76        self.score >= 0.7
77    }
78
79    /// Returns `true` when the peer's score is below the ban threshold (< 0.1)
80    /// or the peer has accumulated 10 or more violations.
81    pub fn is_banned(&self) -> bool {
82        self.score < 0.1 || self.violations >= 10
83    }
84
85    /// Human-readable tier label based on the current score.
86    ///
87    /// | Range          | Label      |
88    /// |---------------|------------|
89    /// | `[0.0, 0.1)`  | `"banned"` |
90    /// | `[0.1, 0.4)`  | `"poor"`   |
91    /// | `[0.4, 0.7)`  | `"neutral"`|
92    /// | `[0.7, 1.0]`  | `"trusted"`|
93    pub fn tier(&self) -> &'static str {
94        if self.score < 0.1 {
95            "banned"
96        } else if self.score < 0.4 {
97            "poor"
98        } else if self.score < 0.7 {
99            "neutral"
100        } else {
101            "trusted"
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// ReputationConfig
108// ---------------------------------------------------------------------------
109
110/// Tuning parameters for score deltas and temporal decay.
111#[derive(Clone, Debug)]
112pub struct PrReputationConfig {
113    /// Score delta for a successful block delivery (positive).
114    pub block_delivered_delta: f64,
115    /// Score delta when a block request times out (negative).
116    pub block_timeout_delta: f64,
117    /// Score delta for a protocol violation (negative).
118    pub protocol_violation_delta: f64,
119    /// Score delta for a valid proof (positive).
120    pub good_proof_delta: f64,
121    /// Score delta for an invalid proof (large negative).
122    pub bad_proof_delta: f64,
123    /// Score delta for an unexpected disconnect (mild negative).
124    pub connection_dropped_delta: f64,
125    /// Per-tick decay magnitude toward the neutral score of `0.5`.
126    pub decay_rate: f64,
127}
128
129impl Default for PrReputationConfig {
130    fn default() -> Self {
131        Self {
132            block_delivered_delta: 0.02,
133            block_timeout_delta: -0.05,
134            protocol_violation_delta: -0.15,
135            good_proof_delta: 0.05,
136            bad_proof_delta: -0.25,
137            connection_dropped_delta: -0.03,
138            decay_rate: 0.001,
139        }
140    }
141}
142
143impl PrReputationConfig {
144    fn delta_for(&self, event: &PrReputationEvent) -> f64 {
145        match event {
146            PrReputationEvent::BlockDelivered { .. } => self.block_delivered_delta,
147            PrReputationEvent::BlockTimeout => self.block_timeout_delta,
148            PrReputationEvent::ProtocolViolation => self.protocol_violation_delta,
149            PrReputationEvent::GoodProof => self.good_proof_delta,
150            PrReputationEvent::BadProof => self.bad_proof_delta,
151            PrReputationEvent::ConnectionDropped => self.connection_dropped_delta,
152        }
153    }
154
155    fn is_violation(event: &PrReputationEvent) -> bool {
156        matches!(
157            event,
158            PrReputationEvent::ProtocolViolation | PrReputationEvent::BadProof
159        )
160    }
161}
162
163// ---------------------------------------------------------------------------
164// ReputationStats
165// ---------------------------------------------------------------------------
166
167/// Aggregate statistics across all tracked peers.
168#[derive(Clone, Debug)]
169pub struct PrReputationStats {
170    /// Total number of peers currently tracked.
171    pub total_peers: usize,
172    /// Number of peers classified as trusted.
173    pub trusted_count: usize,
174    /// Number of peers classified as banned.
175    pub banned_count: usize,
176    /// Mean score across all tracked peers; `0.0` when no peers are tracked.
177    pub avg_score: f64,
178}
179
180// ---------------------------------------------------------------------------
181// PeerReputationManager
182// ---------------------------------------------------------------------------
183
184/// Manages long-term reputation scores for a set of remote peers.
185///
186/// Scores are keyed by an opaque peer-ID string and are automatically
187/// created at the neutral value of `0.5` on the first event.
188pub struct PeerReputationManager {
189    scores: HashMap<String, PrReputationScore>,
190    config: PrReputationConfig,
191}
192
193impl PeerReputationManager {
194    /// Creates a new manager with the supplied configuration.
195    pub fn new(config: PrReputationConfig) -> Self {
196        Self {
197            scores: HashMap::new(),
198            config,
199        }
200    }
201
202    /// Records a behavioural event for `peer_id`.
203    ///
204    /// If the peer is not yet tracked it is inserted with the neutral score of
205    /// `0.5` before the event is applied.  The resulting score is always clamped
206    /// to `[0.0, 1.0]`.
207    pub fn record_event(&mut self, peer_id: &str, event: PrReputationEvent) {
208        let entry = self
209            .scores
210            .entry(peer_id.to_owned())
211            .or_insert_with(PrReputationScore::new);
212
213        let delta = self.config.delta_for(&event);
214        entry.score = (entry.score + delta).clamp(0.0, 1.0);
215
216        if PrReputationConfig::is_violation(&event) {
217            entry.violations = entry.violations.saturating_add(1);
218        }
219
220        entry.total_events = entry.total_events.saturating_add(1);
221    }
222
223    /// Nudges every tracked peer's score one step toward `0.5` by `decay_rate`.
224    pub fn decay_all(&mut self) {
225        let rate = self.config.decay_rate;
226        for entry in self.scores.values_mut() {
227            if entry.score > 0.5 {
228                entry.score = (entry.score - rate).max(0.5);
229            } else if entry.score < 0.5 {
230                entry.score = (entry.score + rate).min(0.5);
231            }
232        }
233    }
234
235    /// Returns the current score for `peer_id`, or `None` if unknown.
236    pub fn score(&self, peer_id: &str) -> Option<&PrReputationScore> {
237        self.scores.get(peer_id)
238    }
239
240    /// Returns the IDs of all trusted peers, sorted by score descending.
241    pub fn trusted_peers(&self) -> Vec<&str> {
242        let mut trusted: Vec<(&str, f64)> = self
243            .scores
244            .iter()
245            .filter(|(_, s)| s.is_trusted())
246            .map(|(id, s)| (id.as_str(), s.score))
247            .collect();
248
249        trusted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
250        trusted.into_iter().map(|(id, _)| id).collect()
251    }
252
253    /// Returns the IDs of all banned peers (order is unspecified).
254    pub fn banned_peers(&self) -> Vec<&str> {
255        self.scores
256            .iter()
257            .filter(|(_, s)| s.is_banned())
258            .map(|(id, _)| id.as_str())
259            .collect()
260    }
261
262    /// Removes a peer from the tracker.
263    ///
264    /// Returns `true` if the peer was present, `false` otherwise.
265    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
266        self.scores.remove(peer_id).is_some()
267    }
268
269    /// Computes aggregate statistics over all currently tracked peers.
270    pub fn stats(&self) -> PrReputationStats {
271        let total_peers = self.scores.len();
272        if total_peers == 0 {
273            return PrReputationStats {
274                total_peers: 0,
275                trusted_count: 0,
276                banned_count: 0,
277                avg_score: 0.0,
278            };
279        }
280
281        let mut trusted_count = 0usize;
282        let mut banned_count = 0usize;
283        let mut score_sum = 0.0f64;
284
285        for s in self.scores.values() {
286            if s.is_trusted() {
287                trusted_count += 1;
288            }
289            if s.is_banned() {
290                banned_count += 1;
291            }
292            score_sum += s.score;
293        }
294
295        PrReputationStats {
296            total_peers,
297            trusted_count,
298            banned_count,
299            avg_score: score_sum / total_peers as f64,
300        }
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Tests
306// ---------------------------------------------------------------------------
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn default_mgr() -> PeerReputationManager {
313        PeerReputationManager::new(PrReputationConfig::default())
314    }
315
316    // 1. New manager starts empty
317    #[test]
318    fn test_new_manager_is_empty() {
319        let mgr = default_mgr();
320        assert!(mgr.scores.is_empty());
321        assert_eq!(mgr.stats().total_peers, 0);
322    }
323
324    // 2. Auto-create peer on first event at 0.5
325    #[test]
326    fn test_auto_create_peer_at_neutral_score() {
327        let mut mgr = default_mgr();
328        mgr.record_event("peer-X", PrReputationEvent::BlockDelivered { bytes: 100 });
329        let s = mgr.score("peer-X").expect("peer must exist");
330        // Started at 0.5, BlockDelivered adds 0.02 → 0.52
331        assert!((s.score - 0.52).abs() < 1e-9);
332    }
333
334    // 3. BlockDelivered increases score
335    #[test]
336    fn test_block_delivered_increases_score() {
337        let mut mgr = default_mgr();
338        mgr.record_event("p", PrReputationEvent::BlockDelivered { bytes: 512 });
339        let s = mgr
340            .score("p")
341            .expect("test: peer should exist after recording event");
342        assert!(s.score > 0.5);
343    }
344
345    // 4. BlockTimeout decreases score
346    #[test]
347    fn test_block_timeout_decreases_score() {
348        let mut mgr = default_mgr();
349        mgr.record_event("p", PrReputationEvent::BlockTimeout);
350        let s = mgr
351            .score("p")
352            .expect("test: peer should exist after recording event");
353        assert!(s.score < 0.5);
354    }
355
356    // 5. ProtocolViolation increments violations
357    #[test]
358    fn test_protocol_violation_increments_violations() {
359        let mut mgr = default_mgr();
360        mgr.record_event("p", PrReputationEvent::ProtocolViolation);
361        let s = mgr
362            .score("p")
363            .expect("test: peer 'p' should exist after recording ProtocolViolation event");
364        assert_eq!(s.violations, 1);
365    }
366
367    // 6. BadProof increments violations
368    #[test]
369    fn test_bad_proof_increments_violations() {
370        let mut mgr = default_mgr();
371        mgr.record_event("p", PrReputationEvent::BadProof);
372        let s = mgr
373            .score("p")
374            .expect("test: peer 'p' should exist after recording BadProof event");
375        assert_eq!(s.violations, 1);
376    }
377
378    // 7. Score is clamped to [0.0, 1.0] — upper bound
379    #[test]
380    fn test_score_clamped_at_upper_bound() {
381        let mut mgr = default_mgr();
382        for _ in 0..100 {
383            mgr.record_event("p", PrReputationEvent::BlockDelivered { bytes: 1 });
384        }
385        let s = mgr
386            .score("p")
387            .expect("test: peer 'p' should exist after recording BlockDelivered events");
388        assert!(s.score <= 1.0);
389    }
390
391    // 8. Score is clamped to [0.0, 1.0] — lower bound
392    #[test]
393    fn test_score_clamped_at_lower_bound() {
394        let mut mgr = default_mgr();
395        for _ in 0..100 {
396            mgr.record_event("p", PrReputationEvent::BadProof);
397        }
398        let s = mgr
399            .score("p")
400            .expect("test: peer 'p' should exist after recording BadProof events");
401        assert!(s.score >= 0.0);
402    }
403
404    // 9. is_trusted returns true when score >= 0.7
405    #[test]
406    fn test_is_trusted_at_threshold() {
407        let s = PrReputationScore {
408            score: 0.7,
409            total_events: 1,
410            violations: 0,
411        };
412        assert!(s.is_trusted());
413    }
414
415    // 10. is_trusted returns false below threshold
416    #[test]
417    fn test_is_trusted_below_threshold() {
418        let s = PrReputationScore {
419            score: 0.699,
420            total_events: 1,
421            violations: 0,
422        };
423        assert!(!s.is_trusted());
424    }
425
426    // 11. is_banned when score < 0.1
427    #[test]
428    fn test_is_banned_low_score() {
429        let s = PrReputationScore {
430            score: 0.05,
431            total_events: 5,
432            violations: 0,
433        };
434        assert!(s.is_banned());
435    }
436
437    // 12. is_banned when violations >= 10
438    #[test]
439    fn test_is_banned_high_violations() {
440        let s = PrReputationScore {
441            score: 0.5,
442            total_events: 10,
443            violations: 10,
444        };
445        assert!(s.is_banned());
446    }
447
448    // 13. tier() returns correct labels
449    #[test]
450    fn test_tier_labels() {
451        let cases = [
452            (0.05, "banned"),
453            (0.1, "poor"),
454            (0.39, "poor"),
455            (0.4, "neutral"),
456            (0.69, "neutral"),
457            (0.7, "trusted"),
458            (1.0, "trusted"),
459        ];
460        for (score, expected) in cases {
461            let s = PrReputationScore {
462                score,
463                total_events: 0,
464                violations: 0,
465            };
466            assert_eq!(s.tier(), expected, "score={score}");
467        }
468    }
469
470    // 14. decay_all moves high score toward 0.5
471    #[test]
472    fn test_decay_all_moves_high_score_toward_neutral() {
473        let mut mgr = default_mgr();
474        // Manually set a high score
475        mgr.scores.insert(
476            "p".to_owned(),
477            PrReputationScore {
478                score: 0.9,
479                total_events: 0,
480                violations: 0,
481            },
482        );
483        mgr.decay_all();
484        let s = mgr
485            .score("p")
486            .expect("test: peer 'p' should exist after decay_all");
487        assert!(s.score < 0.9, "score should have decreased toward 0.5");
488        assert!(s.score >= 0.5, "score should not go below 0.5 in one tick");
489    }
490
491    // 15. decay_all moves low score toward 0.5
492    #[test]
493    fn test_decay_all_moves_low_score_toward_neutral() {
494        let mut mgr = default_mgr();
495        mgr.scores.insert(
496            "p".to_owned(),
497            PrReputationScore {
498                score: 0.2,
499                total_events: 0,
500                violations: 0,
501            },
502        );
503        mgr.decay_all();
504        let s = mgr
505            .score("p")
506            .expect("test: peer 'p' should exist after decay_all");
507        assert!(s.score > 0.2, "score should have increased toward 0.5");
508        assert!(s.score <= 0.5, "score should not exceed 0.5 in one tick");
509    }
510
511    // 16. trusted_peers returns peers sorted by score descending
512    #[test]
513    fn test_trusted_peers_sorted_desc() {
514        let mut mgr = default_mgr();
515        for (id, score) in [("a", 0.8_f64), ("b", 0.95), ("c", 0.72)] {
516            mgr.scores.insert(
517                id.to_owned(),
518                PrReputationScore {
519                    score,
520                    total_events: 1,
521                    violations: 0,
522                },
523            );
524        }
525        let trusted = mgr.trusted_peers();
526        assert_eq!(trusted.len(), 3);
527        // b (0.95) > a (0.80) > c (0.72)
528        assert_eq!(trusted[0], "b");
529        assert_eq!(trusted[1], "a");
530        assert_eq!(trusted[2], "c");
531    }
532
533    // 17. banned_peers returns the correct set
534    #[test]
535    fn test_banned_peers_correct() {
536        let mut mgr = default_mgr();
537        mgr.scores.insert(
538            "good".to_owned(),
539            PrReputationScore {
540                score: 0.8,
541                total_events: 1,
542                violations: 0,
543            },
544        );
545        mgr.scores.insert(
546            "bad".to_owned(),
547            PrReputationScore {
548                score: 0.05,
549                total_events: 5,
550                violations: 0,
551            },
552        );
553        let banned = mgr.banned_peers();
554        assert_eq!(banned.len(), 1);
555        assert_eq!(banned[0], "bad");
556    }
557
558    // 18. remove_peer returns true when present, false when absent
559    #[test]
560    fn test_remove_peer_returns_correct_bool() {
561        let mut mgr = default_mgr();
562        mgr.record_event("p", PrReputationEvent::GoodProof);
563        assert!(mgr.remove_peer("p"));
564        assert!(!mgr.remove_peer("p")); // already gone
565        assert!(!mgr.remove_peer("never-existed"));
566    }
567
568    // 19. stats counts are correct
569    #[test]
570    fn test_stats_counts_correct() {
571        let mut mgr = default_mgr();
572        mgr.scores.insert(
573            "trusted".to_owned(),
574            PrReputationScore {
575                score: 0.8,
576                total_events: 1,
577                violations: 0,
578            },
579        );
580        mgr.scores.insert(
581            "neutral".to_owned(),
582            PrReputationScore {
583                score: 0.5,
584                total_events: 1,
585                violations: 0,
586            },
587        );
588        mgr.scores.insert(
589            "banned".to_owned(),
590            PrReputationScore {
591                score: 0.05,
592                total_events: 1,
593                violations: 0,
594            },
595        );
596        let stats = mgr.stats();
597        assert_eq!(stats.total_peers, 3);
598        assert_eq!(stats.trusted_count, 1);
599        assert_eq!(stats.banned_count, 1);
600    }
601
602    // 20. avg_score is computed correctly
603    #[test]
604    fn test_avg_score_computed_correctly() {
605        let mut mgr = default_mgr();
606        for (id, score) in [("a", 0.6_f64), ("b", 0.8)] {
607            mgr.scores.insert(
608                id.to_owned(),
609                PrReputationScore {
610                    score,
611                    total_events: 0,
612                    violations: 0,
613                },
614            );
615        }
616        let stats = mgr.stats();
617        let expected = (0.6 + 0.8) / 2.0;
618        assert!((stats.avg_score - expected).abs() < 1e-9);
619    }
620
621    // 21. avg_score is 0.0 when no peers
622    #[test]
623    fn test_avg_score_zero_when_empty() {
624        let mgr = default_mgr();
625        assert_eq!(mgr.stats().avg_score, 0.0);
626    }
627
628    // 22. total_events increments per event
629    #[test]
630    fn test_total_events_increments() {
631        let mut mgr = default_mgr();
632        mgr.record_event("p", PrReputationEvent::GoodProof);
633        mgr.record_event("p", PrReputationEvent::GoodProof);
634        mgr.record_event("p", PrReputationEvent::BlockTimeout);
635        let s = mgr
636            .score("p")
637            .expect("test: peer 'p' should exist after recording 3 events");
638        assert_eq!(s.total_events, 3);
639    }
640
641    // 23. GoodProof does not increment violations
642    #[test]
643    fn test_good_proof_no_violation() {
644        let mut mgr = default_mgr();
645        mgr.record_event("p", PrReputationEvent::GoodProof);
646        let s = mgr
647            .score("p")
648            .expect("test: peer 'p' should exist after GoodProof event");
649        assert_eq!(s.violations, 0);
650    }
651
652    // 24. ConnectionDropped does not increment violations
653    #[test]
654    fn test_connection_dropped_no_violation() {
655        let mut mgr = default_mgr();
656        mgr.record_event("p", PrReputationEvent::ConnectionDropped);
657        let s = mgr
658            .score("p")
659            .expect("test: peer 'p' should exist after ConnectionDropped event");
660        assert_eq!(s.violations, 0);
661    }
662
663    // 25. Decay does not push neutral scores away from 0.5
664    #[test]
665    fn test_decay_leaves_neutral_unchanged() {
666        let mut mgr = default_mgr();
667        mgr.scores.insert(
668            "p".to_owned(),
669            PrReputationScore {
670                score: 0.5,
671                total_events: 0,
672                violations: 0,
673            },
674        );
675        mgr.decay_all();
676        let s = mgr
677            .score("p")
678            .expect("test: peer 'p' should exist after decay_all on neutral score");
679        assert_eq!(s.score, 0.5);
680    }
681}