Skip to main content

ipfrs_network/
peer_trust_scorer.rs

1//! Composite trust scoring system for network peers.
2//!
3//! [`PeerTrustScorer`] combines five orthogonal trust dimensions —
4//! uptime, content validity, protocol compliance, response latency, and data
5//! availability — into a single weighted composite score.  Scores are bounded
6//! to `[min_score, max_score]`, subject to time-based decay, and mapped to a
7//! human-readable [`TrustBand`].
8//!
9//! # Example
10//!
11//! ```rust
12//! use ipfrs_network::peer_trust_scorer::{
13//!     PeerTrustScorer, TrustConfig, TrustDimension, TrustEvent, TrustBand,
14//! };
15//!
16//! let config = TrustConfig::default();
17//! let mut scorer = PeerTrustScorer::new(config, 1000);
18//!
19//! let event = TrustEvent {
20//!     peer_id: "peer-A".to_string(),
21//!     dimension: TrustDimension::ContentValidity,
22//!     delta: 0.1,
23//!     timestamp: 0,
24//!     description: "Valid content received".to_string(),
25//! };
26//! scorer.record_event(event);
27//!
28//! let profile = scorer.get_profile("peer-A").expect("profile must exist");
29//! assert!(profile.composite_score >= 0.5);
30//! ```
31
32use std::collections::{HashMap, VecDeque};
33
34// ─────────────────────────────────────────────────────────────────────────────
35// Trust dimension
36// ─────────────────────────────────────────────────────────────────────────────
37
38/// The five orthogonal dimensions along which a peer is evaluated.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum TrustDimension {
41    /// Fraction of time the peer is reachable.
42    Uptime,
43    /// How reliably the peer sends well-formed, valid content.
44    ContentValidity,
45    /// Adherence to the IPFRS protocol specification.
46    ProtocolCompliance,
47    /// Response latency relative to network baseline.
48    ResponseLatency,
49    /// Probability that announced data is actually retrievable.
50    DataAvailability,
51}
52
53impl TrustDimension {
54    /// Return the canonical string key used as a `HashMap` key.
55    pub fn as_key(&self) -> &'static str {
56        match self {
57            TrustDimension::Uptime => "Uptime",
58            TrustDimension::ContentValidity => "ContentValidity",
59            TrustDimension::ProtocolCompliance => "ProtocolCompliance",
60            TrustDimension::ResponseLatency => "ResponseLatency",
61            TrustDimension::DataAvailability => "DataAvailability",
62        }
63    }
64
65    /// All dimension variants in a stable order.
66    pub fn all() -> &'static [TrustDimension] {
67        &[
68            TrustDimension::Uptime,
69            TrustDimension::ContentValidity,
70            TrustDimension::ProtocolCompliance,
71            TrustDimension::ResponseLatency,
72            TrustDimension::DataAvailability,
73        ]
74    }
75}
76
77// ─────────────────────────────────────────────────────────────────────────────
78// Trust event
79// ─────────────────────────────────────────────────────────────────────────────
80
81/// A single observed behavioural signal for a peer.
82///
83/// `delta > 0` rewards good behaviour; `delta < 0` penalises bad behaviour.
84#[derive(Debug, Clone)]
85pub struct TrustEvent {
86    /// Peer identifier (opaque string, not a `libp2p::PeerId` to keep the
87    /// module dependency-free).
88    pub peer_id: String,
89    /// Which trust dimension this event affects.
90    pub dimension: TrustDimension,
91    /// Score change in `[min_score - max_score, max_score - min_score]`.
92    pub delta: f64,
93    /// Unix-epoch milliseconds at which the event was observed.
94    pub timestamp: u64,
95    /// Human-readable description for audit logs.
96    pub description: String,
97}
98
99// ─────────────────────────────────────────────────────────────────────────────
100// Trust band
101// ─────────────────────────────────────────────────────────────────────────────
102
103/// Coarse-grained label derived from the composite trust score.
104#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
105pub enum TrustBand {
106    /// Score < 0.10 — peer should be rejected.
107    Blocked = 0,
108    /// Score in [0.10, 0.30) — proceed with extreme caution.
109    Untrusted = 1,
110    /// Score in [0.30, 0.60) — acceptable but unverified.
111    Neutral = 2,
112    /// Score in [0.60, 0.85) — reliably well-behaved.
113    Trusted = 3,
114    /// Score ≥ 0.85 — exemplary peer.
115    HighlyTrusted = 4,
116}
117
118impl TrustBand {
119    /// Derive the appropriate band from a composite score.
120    pub fn from_score(score: f64) -> TrustBand {
121        if score < 0.10 {
122            TrustBand::Blocked
123        } else if score < 0.30 {
124            TrustBand::Untrusted
125        } else if score < 0.60 {
126            TrustBand::Neutral
127        } else if score < 0.85 {
128            TrustBand::Trusted
129        } else {
130            TrustBand::HighlyTrusted
131        }
132    }
133
134    /// Numeric ordinal (same as the discriminant value).
135    pub fn ordinal(&self) -> u8 {
136        match self {
137            TrustBand::Blocked => 0,
138            TrustBand::Untrusted => 1,
139            TrustBand::Neutral => 2,
140            TrustBand::Trusted => 3,
141            TrustBand::HighlyTrusted => 4,
142        }
143    }
144}
145
146// ─────────────────────────────────────────────────────────────────────────────
147// Configuration
148// ─────────────────────────────────────────────────────────────────────────────
149
150/// Configuration for [`PeerTrustScorer`].
151#[derive(Debug, Clone)]
152pub struct TrustConfig {
153    /// Per-dimension weight used when computing the composite score.
154    /// Keys must match [`TrustDimension::as_key()`].
155    pub dimension_weights: HashMap<String, f64>,
156    /// Score decay per hour (multiplicative, applied to each dimension score).
157    pub decay_rate: f64,
158    /// Lower bound for any dimension score.
159    pub min_score: f64,
160    /// Upper bound for any dimension score.
161    pub max_score: f64,
162    /// Number of events returned per page by [`PeerTrustScorer::peer_events`].
163    pub events_per_page: usize,
164}
165
166impl Default for TrustConfig {
167    fn default() -> Self {
168        let mut weights = HashMap::new();
169        weights.insert(TrustDimension::Uptime.as_key().to_string(), 0.20);
170        weights.insert(TrustDimension::ContentValidity.as_key().to_string(), 0.30);
171        weights.insert(
172            TrustDimension::ProtocolCompliance.as_key().to_string(),
173            0.20,
174        );
175        weights.insert(TrustDimension::ResponseLatency.as_key().to_string(), 0.15);
176        weights.insert(TrustDimension::DataAvailability.as_key().to_string(), 0.15);
177        TrustConfig {
178            dimension_weights: weights,
179            decay_rate: 0.01,
180            min_score: 0.0,
181            max_score: 1.0,
182            events_per_page: 50,
183        }
184    }
185}
186
187impl TrustConfig {
188    /// Validate that all five dimensions are present and weights sum to ≈ 1.0.
189    pub fn is_valid(&self) -> bool {
190        let total: f64 = self.dimension_weights.values().sum();
191        (total - 1.0).abs() < 1e-9
192            && TrustDimension::all()
193                .iter()
194                .all(|d| self.dimension_weights.contains_key(d.as_key()))
195    }
196
197    /// Retrieve the weight for `dimension`, falling back to `0.0`.
198    pub fn weight_for(&self, dimension: &TrustDimension) -> f64 {
199        *self
200            .dimension_weights
201            .get(dimension.as_key())
202            .unwrap_or(&0.0)
203    }
204}
205
206// ─────────────────────────────────────────────────────────────────────────────
207// Peer trust profile
208// ─────────────────────────────────────────────────────────────────────────────
209
210/// The current trust state of a single peer.
211#[derive(Debug, Clone)]
212pub struct PeerTrustProfile {
213    /// Opaque peer identifier.
214    pub peer_id: String,
215    /// Per-dimension scores, keyed by [`TrustDimension::as_key()`].
216    pub scores: HashMap<String, f64>,
217    /// Weighted composite of `scores`.
218    pub composite_score: f64,
219    /// Total number of events recorded for this peer.
220    pub event_count: u64,
221    /// Timestamp (ms) of the last update.
222    pub last_updated: u64,
223    /// Band derived from `composite_score`.
224    pub band: TrustBand,
225}
226
227impl PeerTrustProfile {
228    /// Construct a fresh profile with all dimensions initialised to `0.5`.
229    fn new(peer_id: &str, now: u64) -> Self {
230        let mut scores = HashMap::new();
231        for d in TrustDimension::all() {
232            scores.insert(d.as_key().to_string(), 0.5);
233        }
234        PeerTrustProfile {
235            peer_id: peer_id.to_string(),
236            scores,
237            composite_score: 0.5,
238            event_count: 0,
239            last_updated: now,
240            band: TrustBand::Neutral,
241        }
242    }
243
244    /// Recompute `composite_score` and `band` from the current per-dimension
245    /// scores using `config`'s weights.
246    fn recompute_composite(&mut self, config: &TrustConfig) {
247        let mut total_weight = 0.0_f64;
248        let mut weighted_sum = 0.0_f64;
249        for d in TrustDimension::all() {
250            let weight = config.weight_for(d);
251            let score = self.scores.get(d.as_key()).copied().unwrap_or(0.5);
252            weighted_sum += weight * score;
253            total_weight += weight;
254        }
255        self.composite_score = if total_weight > 0.0 {
256            weighted_sum / total_weight
257        } else {
258            0.5
259        };
260        self.band = TrustBand::from_score(self.composite_score);
261    }
262}
263
264// ─────────────────────────────────────────────────────────────────────────────
265// Scorer statistics
266// ─────────────────────────────────────────────────────────────────────────────
267
268/// Aggregate statistics for the entire scorer.
269#[derive(Debug, Clone)]
270pub struct TrustScorerStats {
271    /// Number of distinct peers tracked.
272    pub total_peers: usize,
273    /// Peers whose band is ≥ [`TrustBand::Trusted`].
274    pub trusted_count: usize,
275    /// Peers whose band is [`TrustBand::Blocked`].
276    pub blocked_count: usize,
277    /// Cumulative number of events ever recorded.
278    pub total_events: u64,
279    /// Mean composite score across all profiles (`0.5` when no peers).
280    pub avg_composite_score: f64,
281}
282
283// ─────────────────────────────────────────────────────────────────────────────
284// PeerTrustScorer
285// ─────────────────────────────────────────────────────────────────────────────
286
287/// Composite trust scoring engine for a set of peers.
288///
289/// Maintains one [`PeerTrustProfile`] per peer and a bounded event log.
290/// All operations are synchronous and operate in O(1) or O(p) where p is the
291/// number of distinct peers.
292pub struct PeerTrustScorer {
293    /// Scorer configuration (weights, decay, bounds, pagination).
294    pub config: TrustConfig,
295    /// One profile per peer identifier.
296    profiles: HashMap<String, PeerTrustProfile>,
297    /// Bounded ring-buffer of recent events (oldest evicted when full).
298    event_log: VecDeque<TrustEvent>,
299    /// Maximum number of events retained in `event_log`.
300    max_events: usize,
301    /// Monotonically increasing counter of all events ever recorded.
302    total_events: u64,
303}
304
305impl PeerTrustScorer {
306    // ── Construction ─────────────────────────────────────────────────────────
307
308    /// Create a new scorer with the given configuration and event-log capacity.
309    ///
310    /// `max_events` controls how many recent [`TrustEvent`]s are kept in
311    /// memory; older events are evicted in FIFO order.
312    pub fn new(config: TrustConfig, max_events: usize) -> Self {
313        PeerTrustScorer {
314            config,
315            profiles: HashMap::new(),
316            event_log: VecDeque::with_capacity(max_events.min(4096)),
317            max_events: max_events.max(1),
318            total_events: 0,
319        }
320    }
321
322    // ── Profile access ────────────────────────────────────────────────────────
323
324    /// Return the canonical string key for `dimension`.
325    pub fn dimension_key(dimension: &TrustDimension) -> String {
326        dimension.as_key().to_string()
327    }
328
329    /// Return a mutable reference to the profile for `peer_id`, creating one
330    /// initialised to `0.5` on all dimensions if it does not yet exist.
331    pub fn get_or_create_profile(&mut self, peer_id: &str, now: u64) -> &mut PeerTrustProfile {
332        self.profiles
333            .entry(peer_id.to_string())
334            .or_insert_with(|| PeerTrustProfile::new(peer_id, now))
335    }
336
337    /// Return a shared reference to the profile for `peer_id`, or `None`.
338    pub fn get_profile(&self, peer_id: &str) -> Option<&PeerTrustProfile> {
339        self.profiles.get(peer_id)
340    }
341
342    // ── Event recording ───────────────────────────────────────────────────────
343
344    /// Record a behavioural event for a peer.
345    ///
346    /// The event is:
347    /// 1. Applied as a clamped score delta to the affected dimension.
348    /// 2. Used to recompute the composite score and trust band.
349    /// 3. Appended to the bounded event log (oldest entry evicted if full).
350    pub fn record_event(&mut self, event: TrustEvent) {
351        let peer_id = event.peer_id.clone();
352        let timestamp = event.timestamp;
353        let dimension_key = event.dimension.as_key().to_string();
354        let delta = event.delta;
355        let min = self.config.min_score;
356        let max = self.config.max_score;
357
358        // Get or create profile and mutate it.
359        let profile = self.get_or_create_profile(&peer_id, timestamp);
360        let current = *profile.scores.get(&dimension_key).unwrap_or(&0.5);
361        let updated = (current + delta).clamp(min, max);
362        profile.scores.insert(dimension_key, updated);
363        profile.event_count += 1;
364        profile.last_updated = timestamp;
365
366        // Recompute composite — borrow config separately.
367        let config = self.config.clone();
368        let profile = self.profiles.get_mut(&peer_id).unwrap_or_else(|| {
369            // This branch is unreachable because we just inserted the profile,
370            // but the compiler cannot verify that through the borrow above.
371            panic!("profile vanished unexpectedly")
372        });
373        profile.recompute_composite(&config);
374
375        // Append event to bounded log.
376        if self.event_log.len() >= self.max_events {
377            self.event_log.pop_front();
378        }
379        self.event_log.push_back(event);
380        self.total_events += 1;
381    }
382
383    // ── Decay ─────────────────────────────────────────────────────────────────
384
385    /// Apply exponential decay to all profiles.
386    ///
387    /// For each profile the elapsed time since `last_updated` is converted to
388    /// fractional hours and used to scale each dimension score:
389    ///
390    /// ```text
391    /// score_new = score_old × max(0, 1 − decay_rate × elapsed_hours)
392    /// ```
393    ///
394    /// After scaling, the composite and band are recomputed and `last_updated`
395    /// is set to `now`.
396    pub fn apply_decay(&mut self, now: u64) {
397        let decay_rate = self.config.decay_rate;
398        let min = self.config.min_score;
399        let max = self.config.max_score;
400        let config = self.config.clone();
401
402        for profile in self.profiles.values_mut() {
403            let elapsed_ms = now.saturating_sub(profile.last_updated);
404            let elapsed_hours = elapsed_ms as f64 / 3_600_000.0;
405            let factor = (1.0 - decay_rate * elapsed_hours).max(0.0);
406
407            for score in profile.scores.values_mut() {
408                *score = (*score * factor).clamp(min, max);
409            }
410            profile.recompute_composite(&config);
411            profile.last_updated = now;
412        }
413    }
414
415    // ── Queries ───────────────────────────────────────────────────────────────
416
417    /// Return all profiles whose [`TrustBand`] is ≥ `min_band`, sorted by
418    /// `composite_score` descending.
419    pub fn trusted_peers(&self, min_band: TrustBand) -> Vec<&PeerTrustProfile> {
420        let mut result: Vec<&PeerTrustProfile> = self
421            .profiles
422            .values()
423            .filter(|p| p.band >= min_band)
424            .collect();
425        result.sort_by(|a, b| {
426            b.composite_score
427                .partial_cmp(&a.composite_score)
428                .unwrap_or(std::cmp::Ordering::Equal)
429        });
430        result
431    }
432
433    /// Return the peer IDs of all peers whose band is [`TrustBand::Blocked`].
434    pub fn blocked_peers(&self) -> Vec<&str> {
435        self.profiles
436            .values()
437            .filter(|p| p.band == TrustBand::Blocked)
438            .map(|p| p.peer_id.as_str())
439            .collect()
440    }
441
442    /// Return a paginated slice of events for `peer_id` from the event log.
443    ///
444    /// Page 0 is the oldest matching events; pages are `config.events_per_page`
445    /// entries wide.
446    pub fn peer_events(&self, peer_id: &str, page: usize) -> Vec<&TrustEvent> {
447        let per_page = self.config.events_per_page;
448        let skip = page * per_page;
449        self.event_log
450            .iter()
451            .filter(|e| e.peer_id == peer_id)
452            .skip(skip)
453            .take(per_page)
454            .collect()
455    }
456
457    // ── Administrative actions ────────────────────────────────────────────────
458
459    /// Set all dimension scores for `peer_id` to `0.0` (effectively blocks the
460    /// peer regardless of subsequent events until explicitly rehabilitated).
461    pub fn ban_peer(&mut self, peer_id: &str, now: u64) {
462        let config = self.config.clone();
463        let profile = self.get_or_create_profile(peer_id, now);
464        for score in profile.scores.values_mut() {
465            *score = 0.0;
466        }
467        profile.composite_score = 0.0;
468        profile.band = TrustBand::Blocked;
469        profile.last_updated = now;
470        // Trigger full recompute to be consistent with config weights.
471        let profile = self
472            .profiles
473            .get_mut(peer_id)
474            .expect("profile was just created");
475        profile.recompute_composite(&config);
476        // Composite might round above 0; force band after.
477        profile.band = TrustBand::Blocked;
478    }
479
480    /// Reset all dimension scores for `peer_id` to `0.3` (Untrusted band),
481    /// giving the peer a fresh but cautious start.
482    pub fn rehabilitate_peer(&mut self, peer_id: &str, now: u64) {
483        let config = self.config.clone();
484        let profile = self.get_or_create_profile(peer_id, now);
485        for score in profile.scores.values_mut() {
486            *score = 0.3;
487        }
488        profile.last_updated = now;
489        let profile = self
490            .profiles
491            .get_mut(peer_id)
492            .expect("profile was just created");
493        profile.recompute_composite(&config);
494    }
495
496    // ── Statistics ────────────────────────────────────────────────────────────
497
498    /// Compute aggregate statistics over all tracked profiles.
499    pub fn scorer_stats(&self) -> TrustScorerStats {
500        let total_peers = self.profiles.len();
501        let mut trusted_count = 0_usize;
502        let mut blocked_count = 0_usize;
503        let mut score_sum = 0.0_f64;
504
505        for profile in self.profiles.values() {
506            if profile.band >= TrustBand::Trusted {
507                trusted_count += 1;
508            }
509            if profile.band == TrustBand::Blocked {
510                blocked_count += 1;
511            }
512            score_sum += profile.composite_score;
513        }
514
515        let avg_composite_score = if total_peers > 0 {
516            score_sum / total_peers as f64
517        } else {
518            0.5
519        };
520
521        TrustScorerStats {
522            total_peers,
523            trusted_count,
524            blocked_count,
525            total_events: self.total_events,
526            avg_composite_score,
527        }
528    }
529
530    // ── Accessors (read-only) ─────────────────────────────────────────────────
531
532    /// Number of distinct peers currently tracked.
533    pub fn peer_count(&self) -> usize {
534        self.profiles.len()
535    }
536
537    /// Number of events currently retained in the log.
538    pub fn event_log_len(&self) -> usize {
539        self.event_log.len()
540    }
541
542    /// Cumulative event count since creation.
543    pub fn total_events(&self) -> u64 {
544        self.total_events
545    }
546}
547
548// ─────────────────────────────────────────────────────────────────────────────
549// Tests
550// ─────────────────────────────────────────────────────────────────────────────
551
552#[cfg(test)]
553mod tests {
554    use crate::peer_trust_scorer::{
555        PeerTrustScorer, TrustBand, TrustConfig, TrustDimension, TrustEvent, TrustScorerStats,
556    };
557    use std::collections::HashMap;
558
559    // ── helpers ───────────────────────────────────────────────────────────────
560
561    fn default_scorer() -> PeerTrustScorer {
562        PeerTrustScorer::new(TrustConfig::default(), 200)
563    }
564
565    fn make_event(peer: &str, dim: TrustDimension, delta: f64, ts: u64) -> TrustEvent {
566        TrustEvent {
567            peer_id: peer.to_string(),
568            dimension: dim,
569            delta,
570            timestamp: ts,
571            description: "test event".to_string(),
572        }
573    }
574
575    // ── TrustBand ─────────────────────────────────────────────────────────────
576
577    #[test]
578    fn band_from_score_blocked() {
579        assert_eq!(TrustBand::from_score(0.0), TrustBand::Blocked);
580        assert_eq!(TrustBand::from_score(0.09), TrustBand::Blocked);
581    }
582
583    #[test]
584    fn band_from_score_untrusted() {
585        assert_eq!(TrustBand::from_score(0.10), TrustBand::Untrusted);
586        assert_eq!(TrustBand::from_score(0.29), TrustBand::Untrusted);
587    }
588
589    #[test]
590    fn band_from_score_neutral() {
591        assert_eq!(TrustBand::from_score(0.30), TrustBand::Neutral);
592        assert_eq!(TrustBand::from_score(0.59), TrustBand::Neutral);
593    }
594
595    #[test]
596    fn band_from_score_trusted() {
597        assert_eq!(TrustBand::from_score(0.60), TrustBand::Trusted);
598        assert_eq!(TrustBand::from_score(0.84), TrustBand::Trusted);
599    }
600
601    #[test]
602    fn band_from_score_highly_trusted() {
603        assert_eq!(TrustBand::from_score(0.85), TrustBand::HighlyTrusted);
604        assert_eq!(TrustBand::from_score(1.0), TrustBand::HighlyTrusted);
605    }
606
607    #[test]
608    fn band_ordinal_ordering() {
609        assert!(TrustBand::Blocked < TrustBand::Untrusted);
610        assert!(TrustBand::Untrusted < TrustBand::Neutral);
611        assert!(TrustBand::Neutral < TrustBand::Trusted);
612        assert!(TrustBand::Trusted < TrustBand::HighlyTrusted);
613    }
614
615    #[test]
616    fn band_ordinal_values() {
617        assert_eq!(TrustBand::Blocked.ordinal(), 0);
618        assert_eq!(TrustBand::Untrusted.ordinal(), 1);
619        assert_eq!(TrustBand::Neutral.ordinal(), 2);
620        assert_eq!(TrustBand::Trusted.ordinal(), 3);
621        assert_eq!(TrustBand::HighlyTrusted.ordinal(), 4);
622    }
623
624    // ── TrustConfig ───────────────────────────────────────────────────────────
625
626    #[test]
627    fn default_config_is_valid() {
628        assert!(TrustConfig::default().is_valid());
629    }
630
631    #[test]
632    fn default_config_weights_sum_to_one() {
633        let total: f64 = TrustConfig::default().dimension_weights.values().sum();
634        assert!((total - 1.0).abs() < 1e-9);
635    }
636
637    #[test]
638    fn config_weight_for_returns_correct_values() {
639        let cfg = TrustConfig::default();
640        assert!((cfg.weight_for(&TrustDimension::ContentValidity) - 0.30).abs() < 1e-9);
641        assert!((cfg.weight_for(&TrustDimension::Uptime) - 0.20).abs() < 1e-9);
642    }
643
644    #[test]
645    fn config_weight_for_missing_dimension_returns_zero() {
646        let cfg = TrustConfig {
647            dimension_weights: HashMap::new(),
648            ..TrustConfig::default()
649        };
650        assert_eq!(cfg.weight_for(&TrustDimension::Uptime), 0.0);
651    }
652
653    // ── Profile initialisation ────────────────────────────────────────────────
654
655    #[test]
656    fn new_profile_initialised_to_neutral() {
657        let mut scorer = default_scorer();
658        let profile = scorer.get_or_create_profile("peer-init", 0);
659        // All dimensions should start at 0.5 → composite ≈ 0.5 → Neutral band.
660        assert_eq!(profile.band, TrustBand::Neutral);
661        assert!((profile.composite_score - 0.5).abs() < 1e-9);
662    }
663
664    #[test]
665    fn new_profile_all_dimensions_present() {
666        let mut scorer = default_scorer();
667        let profile = scorer.get_or_create_profile("peer-dims", 0);
668        for d in TrustDimension::all() {
669            assert!(
670                profile.scores.contains_key(d.as_key()),
671                "missing dimension {:?}",
672                d
673            );
674        }
675    }
676
677    #[test]
678    fn get_profile_returns_none_for_unknown_peer() {
679        let scorer = default_scorer();
680        assert!(scorer.get_profile("ghost").is_none());
681    }
682
683    // ── Event recording ───────────────────────────────────────────────────────
684
685    #[test]
686    fn record_positive_event_increases_score() {
687        let mut scorer = default_scorer();
688        let before = scorer
689            .get_or_create_profile("peer-pos", 0)
690            .scores
691            .get(TrustDimension::ContentValidity.as_key())
692            .copied()
693            .unwrap_or(0.5);
694        scorer.record_event(make_event(
695            "peer-pos",
696            TrustDimension::ContentValidity,
697            0.1,
698            1,
699        ));
700        let after = scorer
701            .get_profile("peer-pos")
702            .expect("profile")
703            .scores
704            .get(TrustDimension::ContentValidity.as_key())
705            .copied()
706            .unwrap_or(0.0);
707        assert!(after > before);
708    }
709
710    #[test]
711    fn record_negative_event_decreases_score() {
712        let mut scorer = default_scorer();
713        scorer.record_event(make_event(
714            "peer-neg",
715            TrustDimension::ProtocolCompliance,
716            -0.2,
717            1,
718        ));
719        let score = scorer
720            .get_profile("peer-neg")
721            .expect("profile")
722            .scores
723            .get(TrustDimension::ProtocolCompliance.as_key())
724            .copied()
725            .unwrap_or(1.0);
726        assert!(score < 0.5);
727    }
728
729    #[test]
730    fn score_clamped_at_max() {
731        let mut scorer = default_scorer();
732        for _ in 0..20 {
733            scorer.record_event(make_event("peer-max", TrustDimension::Uptime, 0.3, 1));
734        }
735        let score = scorer
736            .get_profile("peer-max")
737            .expect("profile")
738            .scores
739            .get(TrustDimension::Uptime.as_key())
740            .copied()
741            .unwrap_or(0.0);
742        assert!(score <= 1.0);
743    }
744
745    #[test]
746    fn score_clamped_at_min() {
747        let mut scorer = default_scorer();
748        for _ in 0..20 {
749            scorer.record_event(make_event(
750                "peer-min",
751                TrustDimension::ResponseLatency,
752                -0.3,
753                1,
754            ));
755        }
756        let score = scorer
757            .get_profile("peer-min")
758            .expect("profile")
759            .scores
760            .get(TrustDimension::ResponseLatency.as_key())
761            .copied()
762            .unwrap_or(1.0);
763        assert!(score >= 0.0);
764    }
765
766    #[test]
767    fn event_count_increments_per_event() {
768        let mut scorer = default_scorer();
769        for i in 0..5_u64 {
770            scorer.record_event(make_event(
771                "peer-cnt",
772                TrustDimension::DataAvailability,
773                0.01,
774                i,
775            ));
776        }
777        assert_eq!(
778            scorer.get_profile("peer-cnt").expect("profile").event_count,
779            5
780        );
781    }
782
783    #[test]
784    fn total_events_counts_across_peers() {
785        let mut scorer = default_scorer();
786        scorer.record_event(make_event("A", TrustDimension::Uptime, 0.1, 0));
787        scorer.record_event(make_event("B", TrustDimension::Uptime, 0.1, 0));
788        scorer.record_event(make_event("A", TrustDimension::Uptime, 0.1, 0));
789        assert_eq!(scorer.total_events(), 3);
790    }
791
792    #[test]
793    fn event_log_evicts_oldest_when_full() {
794        let mut scorer = PeerTrustScorer::new(TrustConfig::default(), 3);
795        for i in 0..5_u64 {
796            scorer.record_event(make_event("peer-evict", TrustDimension::Uptime, 0.01, i));
797        }
798        // Log capped at 3.
799        assert_eq!(scorer.event_log_len(), 3);
800    }
801
802    #[test]
803    fn composite_score_is_weighted_average() {
804        // Build a scorer where we can control scores manually.
805        let mut scorer = default_scorer();
806        // Force all dimensions to known values via events.
807        let peer = "peer-weighted";
808        // Start at 0.5; push ContentValidity (w=0.30) up by 0.1 → 0.6.
809        scorer.record_event(make_event(peer, TrustDimension::ContentValidity, 0.1, 0));
810        let profile = scorer.get_profile(peer).expect("profile");
811        // Composite must be between 0.5 and 0.6 (only one dimension changed).
812        assert!(profile.composite_score > 0.5);
813        assert!(profile.composite_score <= 0.6);
814    }
815
816    #[test]
817    fn band_updates_after_positive_events() {
818        let mut scorer = default_scorer();
819        let peer = "peer-band-up";
820        // Push all dimensions to near-max.
821        for d in TrustDimension::all() {
822            for _ in 0..4 {
823                scorer.record_event(make_event(peer, d.clone(), 0.1, 1));
824            }
825        }
826        let profile = scorer.get_profile(peer).expect("profile");
827        assert!(profile.band >= TrustBand::Trusted);
828    }
829
830    #[test]
831    fn band_updates_after_negative_events() {
832        let mut scorer = default_scorer();
833        let peer = "peer-band-down";
834        for d in TrustDimension::all() {
835            for _ in 0..6 {
836                scorer.record_event(make_event(peer, d.clone(), -0.1, 1));
837            }
838        }
839        let profile = scorer.get_profile(peer).expect("profile");
840        assert!(profile.band <= TrustBand::Untrusted);
841    }
842
843    // ── Decay ─────────────────────────────────────────────────────────────────
844
845    #[test]
846    fn apply_decay_reduces_scores() {
847        let mut scorer = default_scorer();
848        scorer.record_event(make_event("peer-decay", TrustDimension::Uptime, 0.2, 0));
849        let before = scorer
850            .get_profile("peer-decay")
851            .expect("profile")
852            .scores
853            .get(TrustDimension::Uptime.as_key())
854            .copied()
855            .unwrap_or(0.5);
856        // Advance by 1 hour = 3_600_000 ms.
857        scorer.apply_decay(3_600_000);
858        let after = scorer
859            .get_profile("peer-decay")
860            .expect("profile")
861            .scores
862            .get(TrustDimension::Uptime.as_key())
863            .copied()
864            .unwrap_or(1.0);
865        assert!(after < before);
866    }
867
868    #[test]
869    fn apply_decay_updates_last_updated() {
870        let mut scorer = default_scorer();
871        scorer.record_event(make_event("peer-decay2", TrustDimension::Uptime, 0.0, 100));
872        scorer.apply_decay(9_000_000);
873        assert_eq!(
874            scorer
875                .get_profile("peer-decay2")
876                .expect("profile")
877                .last_updated,
878            9_000_000
879        );
880    }
881
882    #[test]
883    fn apply_decay_with_zero_elapsed_is_identity() {
884        let mut scorer = default_scorer();
885        let ts = 5_000;
886        scorer.record_event(make_event("peer-nodecay", TrustDimension::Uptime, 0.1, ts));
887        let before = scorer
888            .get_profile("peer-nodecay")
889            .expect("profile")
890            .composite_score;
891        scorer.apply_decay(ts); // 0 ms elapsed
892        let after = scorer
893            .get_profile("peer-nodecay")
894            .expect("profile")
895            .composite_score;
896        assert!((before - after).abs() < 1e-12);
897    }
898
899    #[test]
900    fn apply_decay_does_not_go_below_min() {
901        let cfg = TrustConfig {
902            decay_rate: 100.0, // extreme decay
903            ..TrustConfig::default()
904        };
905        let mut scorer = PeerTrustScorer::new(cfg, 100);
906        scorer.record_event(make_event("peer-floor", TrustDimension::Uptime, 0.0, 0));
907        scorer.apply_decay(3_600_000);
908        let score = scorer
909            .get_profile("peer-floor")
910            .expect("profile")
911            .scores
912            .get(TrustDimension::Uptime.as_key())
913            .copied()
914            .unwrap_or(1.0);
915        assert!(score >= 0.0);
916    }
917
918    // ── trusted_peers ─────────────────────────────────────────────────────────
919
920    #[test]
921    fn trusted_peers_filters_by_min_band() {
922        let mut scorer = default_scorer();
923        // Create a blocked peer.
924        scorer.ban_peer("peer-blocked", 0);
925        // Create a neutral peer (default 0.5).
926        scorer.get_or_create_profile("peer-neutral", 0);
927        // Trusted peers must exclude Blocked.
928        let trusted = scorer.trusted_peers(TrustBand::Neutral);
929        let ids: Vec<&str> = trusted.iter().map(|p| p.peer_id.as_str()).collect();
930        assert!(!ids.contains(&"peer-blocked"));
931        assert!(ids.contains(&"peer-neutral"));
932    }
933
934    #[test]
935    fn trusted_peers_sorted_descending_by_composite() {
936        let mut scorer = default_scorer();
937        scorer.record_event(make_event("peer-high", TrustDimension::Uptime, 0.3, 0));
938        scorer.record_event(make_event("peer-low", TrustDimension::Uptime, -0.1, 0));
939        let peers = scorer.trusted_peers(TrustBand::Blocked); // include all
940        if peers.len() >= 2 {
941            let first = peers[0].composite_score;
942            let last = peers[peers.len() - 1].composite_score;
943            assert!(first >= last);
944        }
945    }
946
947    // ── blocked_peers ─────────────────────────────────────────────────────────
948
949    #[test]
950    fn blocked_peers_returns_only_blocked() {
951        let mut scorer = default_scorer();
952        scorer.ban_peer("peer-b1", 0);
953        scorer.ban_peer("peer-b2", 0);
954        scorer.get_or_create_profile("peer-ok", 0);
955        let blocked = scorer.blocked_peers();
956        assert!(blocked.contains(&"peer-b1"));
957        assert!(blocked.contains(&"peer-b2"));
958        assert!(!blocked.contains(&"peer-ok"));
959    }
960
961    #[test]
962    fn blocked_peers_empty_when_none_banned() {
963        let scorer = default_scorer();
964        assert!(scorer.blocked_peers().is_empty());
965    }
966
967    // ── peer_events pagination ─────────────────────────────────────────────────
968
969    #[test]
970    fn peer_events_page_zero_returns_first_n() {
971        let mut scorer = PeerTrustScorer::new(TrustConfig::default(), 1000);
972        for i in 0..10_u64 {
973            scorer.record_event(make_event("peer-pg", TrustDimension::Uptime, 0.01, i));
974        }
975        let page = scorer.peer_events("peer-pg", 0);
976        // Default events_per_page = 50; all 10 fit on page 0.
977        assert_eq!(page.len(), 10);
978    }
979
980    #[test]
981    fn peer_events_page_one_skips_first_page() {
982        let cfg = TrustConfig {
983            events_per_page: 3,
984            ..TrustConfig::default()
985        };
986        let mut scorer = PeerTrustScorer::new(cfg, 1000);
987        for i in 0..7_u64 {
988            scorer.record_event(make_event("peer-pages", TrustDimension::Uptime, 0.0, i));
989        }
990        let page1 = scorer.peer_events("peer-pages", 1);
991        // Page 1 should have events 3-5 (indices 3..6).
992        assert_eq!(page1.len(), 3);
993    }
994
995    #[test]
996    fn peer_events_filters_by_peer_id() {
997        let mut scorer = default_scorer();
998        scorer.record_event(make_event("A", TrustDimension::Uptime, 0.01, 0));
999        scorer.record_event(make_event("B", TrustDimension::Uptime, 0.01, 1));
1000        scorer.record_event(make_event("A", TrustDimension::Uptime, 0.01, 2));
1001        let events = scorer.peer_events("A", 0);
1002        assert!(events.iter().all(|e| e.peer_id == "A"));
1003        assert_eq!(events.len(), 2);
1004    }
1005
1006    #[test]
1007    fn peer_events_empty_page_beyond_end() {
1008        let mut scorer = default_scorer();
1009        scorer.record_event(make_event("peer-eof", TrustDimension::Uptime, 0.0, 0));
1010        let page99 = scorer.peer_events("peer-eof", 99);
1011        assert!(page99.is_empty());
1012    }
1013
1014    // ── ban / rehabilitate ────────────────────────────────────────────────────
1015
1016    #[test]
1017    fn ban_peer_sets_band_to_blocked() {
1018        let mut scorer = default_scorer();
1019        scorer.ban_peer("peer-ban", 0);
1020        assert_eq!(
1021            scorer.get_profile("peer-ban").expect("profile").band,
1022            TrustBand::Blocked
1023        );
1024    }
1025
1026    #[test]
1027    fn ban_peer_sets_all_scores_to_zero() {
1028        let mut scorer = default_scorer();
1029        scorer.ban_peer("peer-zeroed", 0);
1030        let profile = scorer.get_profile("peer-zeroed").expect("profile");
1031        for score in profile.scores.values() {
1032            assert!(*score <= 0.0);
1033        }
1034    }
1035
1036    #[test]
1037    fn ban_creates_profile_if_absent() {
1038        let mut scorer = default_scorer();
1039        scorer.ban_peer("brand-new", 42);
1040        assert!(scorer.get_profile("brand-new").is_some());
1041    }
1042
1043    #[test]
1044    fn rehabilitate_peer_resets_scores_to_0_3() {
1045        let mut scorer = default_scorer();
1046        scorer.ban_peer("peer-rehab", 0);
1047        scorer.rehabilitate_peer("peer-rehab", 100);
1048        let profile = scorer.get_profile("peer-rehab").expect("profile");
1049        for score in profile.scores.values() {
1050            assert!((*score - 0.3).abs() < 1e-12);
1051        }
1052    }
1053
1054    #[test]
1055    fn rehabilitate_updates_band_above_blocked() {
1056        let mut scorer = default_scorer();
1057        scorer.ban_peer("peer-reh2", 0);
1058        scorer.rehabilitate_peer("peer-reh2", 100);
1059        let band = &scorer.get_profile("peer-reh2").expect("profile").band;
1060        assert!(*band > TrustBand::Blocked);
1061    }
1062
1063    #[test]
1064    fn rehabilitate_creates_profile_if_absent() {
1065        let mut scorer = default_scorer();
1066        scorer.rehabilitate_peer("ghost-rehab", 0);
1067        assert!(scorer.get_profile("ghost-rehab").is_some());
1068    }
1069
1070    // ── scorer_stats ──────────────────────────────────────────────────────────
1071
1072    #[test]
1073    fn scorer_stats_no_peers() {
1074        let scorer = default_scorer();
1075        let stats = scorer.scorer_stats();
1076        assert_eq!(stats.total_peers, 0);
1077        assert_eq!(stats.total_events, 0);
1078        assert!((stats.avg_composite_score - 0.5).abs() < 1e-9);
1079    }
1080
1081    #[test]
1082    fn scorer_stats_counts_blocked_correctly() {
1083        let mut scorer = default_scorer();
1084        scorer.ban_peer("b1", 0);
1085        scorer.ban_peer("b2", 0);
1086        let stats = scorer.scorer_stats();
1087        assert_eq!(stats.blocked_count, 2);
1088    }
1089
1090    #[test]
1091    fn scorer_stats_counts_trusted_correctly() {
1092        let mut scorer = default_scorer();
1093        // Push two peers into Trusted band.
1094        for peer in &["t1", "t2"] {
1095            for d in TrustDimension::all() {
1096                for _ in 0..3 {
1097                    scorer.record_event(make_event(peer, d.clone(), 0.1, 0));
1098                }
1099            }
1100        }
1101        let stats = scorer.scorer_stats();
1102        assert!(stats.trusted_count >= 2);
1103    }
1104
1105    #[test]
1106    fn scorer_stats_total_events_matches_recorded() {
1107        let mut scorer = default_scorer();
1108        let n = 7_u64;
1109        for i in 0..n {
1110            scorer.record_event(make_event("ev-peer", TrustDimension::Uptime, 0.0, i));
1111        }
1112        assert_eq!(scorer.scorer_stats().total_events, n);
1113    }
1114
1115    #[test]
1116    fn scorer_stats_avg_composite_is_mean() {
1117        let mut scorer = default_scorer();
1118        // Two peers: one with composite ≈ 0.5 (default), one with extra boost.
1119        scorer.get_or_create_profile("p-avg1", 0);
1120        scorer.record_event(make_event("p-avg2", TrustDimension::Uptime, 0.2, 0));
1121        let stats = scorer.scorer_stats();
1122        assert!(stats.avg_composite_score > 0.5); // boosted peer pulls average up
1123    }
1124
1125    // ── dimension key helper ──────────────────────────────────────────────────
1126
1127    #[test]
1128    fn dimension_key_returns_stable_strings() {
1129        assert_eq!(
1130            PeerTrustScorer::dimension_key(&TrustDimension::Uptime),
1131            "Uptime"
1132        );
1133        assert_eq!(
1134            PeerTrustScorer::dimension_key(&TrustDimension::ContentValidity),
1135            "ContentValidity"
1136        );
1137        assert_eq!(
1138            PeerTrustScorer::dimension_key(&TrustDimension::ProtocolCompliance),
1139            "ProtocolCompliance"
1140        );
1141        assert_eq!(
1142            PeerTrustScorer::dimension_key(&TrustDimension::ResponseLatency),
1143            "ResponseLatency"
1144        );
1145        assert_eq!(
1146            PeerTrustScorer::dimension_key(&TrustDimension::DataAvailability),
1147            "DataAvailability"
1148        );
1149    }
1150
1151    // ── Multi-peer isolation ──────────────────────────────────────────────────
1152
1153    #[test]
1154    fn events_for_different_peers_do_not_interfere() {
1155        let mut scorer = default_scorer();
1156        scorer.record_event(make_event("peer-iso-A", TrustDimension::Uptime, 0.3, 0));
1157        scorer.record_event(make_event("peer-iso-B", TrustDimension::Uptime, -0.3, 0));
1158        let a = scorer
1159            .get_profile("peer-iso-A")
1160            .expect("profile A")
1161            .composite_score;
1162        let b = scorer
1163            .get_profile("peer-iso-B")
1164            .expect("profile B")
1165            .composite_score;
1166        assert!(a > b);
1167    }
1168
1169    #[test]
1170    fn peer_count_tracks_distinct_peers() {
1171        let mut scorer = default_scorer();
1172        // Three distinct peers; recording multiple events for the same peer
1173        // must not inflate the count.
1174        for _ in 0..5 {
1175            scorer.record_event(make_event("P1", TrustDimension::Uptime, 0.0, 0));
1176        }
1177        scorer.record_event(make_event("P2", TrustDimension::Uptime, 0.0, 0));
1178        scorer.record_event(make_event("P3", TrustDimension::Uptime, 0.0, 0));
1179        assert_eq!(scorer.peer_count(), 3);
1180    }
1181
1182    // ── Edge / boundary cases ─────────────────────────────────────────────────
1183
1184    #[test]
1185    fn max_events_of_one_retains_only_latest() {
1186        let mut scorer = PeerTrustScorer::new(TrustConfig::default(), 1);
1187        scorer.record_event(make_event("edge", TrustDimension::Uptime, 0.01, 0));
1188        scorer.record_event(make_event("edge", TrustDimension::Uptime, 0.01, 1));
1189        assert_eq!(scorer.event_log_len(), 1);
1190    }
1191
1192    #[test]
1193    fn rehabilitate_then_ban_leaves_peer_blocked() {
1194        let mut scorer = default_scorer();
1195        scorer.rehabilitate_peer("cycle", 0);
1196        scorer.ban_peer("cycle", 100);
1197        assert_eq!(
1198            scorer.get_profile("cycle").expect("profile").band,
1199            TrustBand::Blocked
1200        );
1201    }
1202
1203    #[test]
1204    fn trusted_peers_with_highly_trusted_min_band() {
1205        let mut scorer = default_scorer();
1206        // Only one peer achieves HighlyTrusted.
1207        for d in TrustDimension::all() {
1208            for _ in 0..5 {
1209                scorer.record_event(make_event("elite", d.clone(), 0.1, 0));
1210            }
1211        }
1212        scorer.get_or_create_profile("average", 0); // stays Neutral (0.5)
1213        let ht = scorer.trusted_peers(TrustBand::HighlyTrusted);
1214        assert!(ht.iter().all(|p| p.band == TrustBand::HighlyTrusted));
1215    }
1216
1217    #[test]
1218    fn profile_implements_debug() {
1219        let mut scorer = default_scorer();
1220        let profile = scorer.get_or_create_profile("debug-peer", 0);
1221        let s = format!("{:?}", profile);
1222        assert!(s.contains("PeerTrustProfile"));
1223    }
1224
1225    #[test]
1226    fn trust_scorer_stats_implements_debug() {
1227        let stats = TrustScorerStats {
1228            total_peers: 0,
1229            trusted_count: 0,
1230            blocked_count: 0,
1231            total_events: 0,
1232            avg_composite_score: 0.5,
1233        };
1234        let s = format!("{:?}", stats);
1235        assert!(s.contains("TrustScorerStats"));
1236    }
1237
1238    #[test]
1239    fn trust_event_implements_clone() {
1240        let ev = make_event("clone-peer", TrustDimension::Uptime, 0.1, 42);
1241        let ev2 = ev.clone();
1242        assert_eq!(ev2.peer_id, ev.peer_id);
1243        assert_eq!(ev2.delta, ev.delta);
1244    }
1245
1246    #[test]
1247    fn trust_config_implements_clone() {
1248        let cfg = TrustConfig::default();
1249        let cfg2 = cfg.clone();
1250        assert!((cfg2.decay_rate - cfg.decay_rate).abs() < 1e-12);
1251    }
1252
1253    #[test]
1254    fn trust_dimension_all_has_five_variants() {
1255        assert_eq!(TrustDimension::all().len(), 5);
1256    }
1257}