peer_scoring.rs
1 //! Peer scoring service and default implementations. 2 //! 3 //! This module provides [`PeerScoringService`], a standalone service that tracks 4 //! per-member reputation scores scoped by group. It lives at the application layer 5 //! because it orchestrates core-defined traits ([`PeerScoreStorage`], [`ScoringProvider`]) 6 //! and is designed to be instantiated once per [`User`](super::User), not per group. 7 //! 8 //! # Architecture 9 //! 10 //! ```text 11 //! ┌───────────────────────────────────────────────────────┐ 12 //! │ PeerScoringService<S, P> │ 13 //! │ (one per User) │ 14 //! │ │ 15 //! │ ┌─────────────────┐ ┌─────────────────────────────┐ │ 16 //! │ │ ScoringProvider │ │ PeerScoreStorage │ │ 17 //! │ │ (event → delta) │ │ key: (group_id, member_id) │ │ 18 //! │ └─────────────────┘ └─────────────────────────────┘ │ 19 //! └───────────────────────────────────────────────────────┘ 20 //! ``` 21 //! 22 //! # Default implementations 23 //! 24 //! - [`InMemoryPeerScoreStorage`] — `HashMap`-backed storage for testing/development 25 //! - [`FixedScoringProvider`]— static delta table 26 27 use std::collections::HashMap; 28 29 use crate::core::{PeerScoreStorage, ScoreEvent, ScoringConfig, ScoringProvider}; 30 31 // ── In-memory storage ─────────────────────────────────────────────── 32 33 /// In-memory score storage backed by a nested `HashMap`. 34 /// 35 /// Scores are keyed by `(group_id, member_id)`. Suitable for testing and 36 /// development; production deployments should implement [`PeerScoreStorage`] 37 /// with a durable backend. 38 #[derive(Debug, Clone, Default)] 39 pub struct InMemoryPeerScoreStorage { 40 /// group_id → (member_id → score) 41 scores: HashMap<String, HashMap<Vec<u8>, i64>>, 42 } 43 44 impl InMemoryPeerScoreStorage { 45 pub fn new() -> Self { 46 Self::default() 47 } 48 } 49 50 impl PeerScoreStorage for InMemoryPeerScoreStorage { 51 fn get(&self, group_id: &str, member_id: &[u8]) -> Option<i64> { 52 self.scores 53 .get(group_id) 54 .and_then(|members| members.get(member_id).copied()) 55 } 56 57 fn set(&mut self, group_id: &str, member_id: &[u8], score: i64) { 58 self.scores 59 .entry(group_id.to_string()) 60 .or_default() 61 .insert(member_id.to_vec(), score); 62 } 63 64 fn remove(&mut self, group_id: &str, member_id: &[u8]) { 65 if let Some(members) = self.scores.get_mut(group_id) { 66 members.remove(member_id); 67 } 68 } 69 70 fn all_scores(&self, group_id: &str) -> Vec<(Vec<u8>, i64)> { 71 self.scores 72 .get(group_id) 73 .map(|members| members.iter().map(|(k, v)| (k.clone(), *v)).collect()) 74 .unwrap_or_default() 75 } 76 } 77 78 // ── Default provider ─────────────────────────────────────────────── 79 80 /// Fixed score deltas backed by a `HashMap`. 81 /// 82 /// Constructed once (typically at startup) and never changes. Events not 83 /// present in the map produce a delta of 0. 84 #[derive(Debug, Clone)] 85 pub struct FixedScoringProvider { 86 deltas: HashMap<ScoreEvent, i64>, 87 } 88 89 impl FixedScoringProvider { 90 pub fn new(deltas: HashMap<ScoreEvent, i64>) -> Self { 91 Self { deltas } 92 } 93 } 94 95 impl ScoringProvider for FixedScoringProvider { 96 fn score_delta(&self, event: ScoreEvent) -> i64 { 97 self.deltas.get(&event).copied().unwrap_or(0) 98 } 99 } 100 101 // ── Service ───────────────────────────────────────────────────────── 102 103 /// Standalone peer scoring service. 104 /// 105 /// Tracks per-member scores scoped by group, applies events using a 106 /// [`ScoringProvider`], and detects members below the removal threshold. 107 /// Designed to be instantiated once per [`User`](super::User) with storage 108 /// keyed by `(group_id, member_id)`. 109 /// 110 /// # Type parameters 111 /// 112 /// - `S`: Storage backend (e.g. [`InMemoryPeerScoreStorage`]) 113 /// - `P`: Scoring provider (e.g. [`FixedScoringProvider`]) 114 pub struct PeerScoringService<S: PeerScoreStorage, P: ScoringProvider> { 115 storage: S, 116 provider: P, 117 config: ScoringConfig, 118 } 119 120 impl<S: PeerScoreStorage, P: ScoringProvider> PeerScoringService<S, P> { 121 pub fn new(storage: S, provider: P, config: ScoringConfig) -> Self { 122 Self { 123 storage, 124 provider, 125 config, 126 } 127 } 128 129 /// Register a new member in a group with the default score. 130 pub fn add_member(&mut self, group_id: &str, member_id: &[u8]) { 131 self.storage 132 .set(group_id, member_id, self.config.default_score); 133 } 134 135 /// Remove a member from score tracking in a group. 136 pub fn remove_member(&mut self, group_id: &str, member_id: &[u8]) { 137 self.storage.remove(group_id, member_id); 138 } 139 140 /// Apply a score event to a member in a group. 141 /// 142 /// Returns the member's new score, or `None` if the member is not tracked. 143 pub fn apply_event( 144 &mut self, 145 group_id: &str, 146 member_id: &[u8], 147 event: ScoreEvent, 148 ) -> Option<i64> { 149 let current = self.storage.get(group_id, member_id)?; 150 let delta = self.provider.score_delta(event); 151 let new_score = current.saturating_add(delta); 152 self.storage.set(group_id, member_id, new_score); 153 Some(new_score) 154 } 155 156 /// Query a member's current score in a group. 157 pub fn score_for(&self, group_id: &str, member_id: &[u8]) -> Option<i64> { 158 self.storage.get(group_id, member_id) 159 } 160 161 /// Returns member IDs whose score is at or below the removal threshold in a group. 162 pub fn members_below_threshold(&self, group_id: &str) -> Vec<Vec<u8>> { 163 self.storage 164 .all_scores(group_id) 165 .into_iter() 166 .filter(|(_, score)| *score <= self.config.removal_threshold) 167 .map(|(id, _)| id) 168 .collect() 169 } 170 171 /// Check whether a specific member is at or below the removal threshold in a group. 172 pub fn is_below_threshold(&self, group_id: &str, member_id: &[u8]) -> bool { 173 self.storage 174 .get(group_id, member_id) 175 .is_some_and(|s| s <= self.config.removal_threshold) 176 } 177 178 /// Returns all members and their scores for a group. 179 pub fn all_members_with_scores(&self, group_id: &str) -> Vec<(Vec<u8>, i64)> { 180 self.storage.all_scores(group_id) 181 } 182 183 /// Returns a reference to the scoring config. 184 pub fn config(&self) -> &ScoringConfig { 185 &self.config 186 } 187 }