/ src / app / peer_scoring.rs
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  }