Skip to main content

ipfrs_network/
peer_reputation_graph.rs

1//! Peer Reputation Graph with trust propagation and reputation scoring.
2//!
3//! This module provides a graph-based peer reputation system that models trust
4//! relationships between peers as weighted directed edges. Trust propagates
5//! transitively through the graph (up to a configurable depth), enabling
6//! reputation to be inferred even for peers with limited direct interaction.
7//!
8//! # Design
9//!
10//! The graph stores:
11//! - **Direct scores** – updated by explicit positive/negative interactions via
12//!   an exponential moving average (EMA).
13//! - **Propagated scores** – computed by multi-hop BFS, damping trust at each
14//!   hop, so peers trusted by highly-reputed peers inherit some of that trust.
15//! - **Combined scores** – a configurable linear blend of the two.
16//!
17//! All floating-point arithmetic avoids `unwrap()` and clamps values to
18//! `[0.0, 1.0]` to maintain invariants.
19//!
20//! # Examples
21//!
22//! ```rust
23//! use ipfrs_network::peer_reputation_graph::{PeerReputationGraph, GraphConfig};
24//!
25//! let config = GraphConfig::default();
26//! let mut graph = PeerReputationGraph::new(config);
27//!
28//! graph.add_peer("alice".to_string()).unwrap();
29//! graph.add_peer("bob".to_string()).unwrap();
30//! graph.add_edge("alice", "bob", 0.8).unwrap();
31//!
32//! let event = graph.record_interaction("alice", true, 0.5).unwrap();
33//! let _updated = graph.propagate_trust();
34//!
35//! let score = graph.reputation("bob").unwrap();
36//! println!("Bob combined score: {:.3}", score.combined_score);
37//! ```
38
39use std::collections::{HashMap, HashSet, VecDeque};
40
41// ---------------------------------------------------------------------------
42// PRNG (no rand crate)
43// ---------------------------------------------------------------------------
44
45/// Xorshift64 pseudo-random number generator.
46///
47/// Used internally for tie-breaking and jitter; state must be non-zero.
48fn xorshift64(state: &mut u64) -> u64 {
49    let mut x = *state;
50    x ^= x << 13;
51    x ^= x >> 7;
52    x ^= x << 17;
53    *state = x;
54    x
55}
56
57// ---------------------------------------------------------------------------
58// Error type
59// ---------------------------------------------------------------------------
60
61/// Errors that can arise when working with the [`PeerReputationGraph`].
62#[derive(Debug, Clone, PartialEq)]
63pub enum GraphError {
64    /// The peer ID was not found in the graph.
65    PeerNotFound(String),
66    /// The directed edge between two peers was not found.
67    EdgeNotFound { from: String, to: String },
68    /// Attempted to add a self-loop edge.
69    SelfLoop(String),
70    /// A weight value was outside `[0.0, 1.0]`.
71    InvalidWeight(f64),
72    /// Adding the peer would exceed `GraphConfig::max_peers`.
73    MaxPeersExceeded,
74}
75
76impl std::fmt::Display for GraphError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            GraphError::PeerNotFound(id) => write!(f, "peer not found: {id}"),
80            GraphError::EdgeNotFound { from, to } => {
81                write!(f, "edge not found: {from} → {to}")
82            }
83            GraphError::SelfLoop(id) => write!(f, "self-loop not allowed for peer {id}"),
84            GraphError::InvalidWeight(w) => {
85                write!(f, "invalid edge weight {w}; must be in [0.0, 1.0]")
86            }
87            GraphError::MaxPeersExceeded => write!(f, "maximum peer count exceeded"),
88        }
89    }
90}
91
92impl std::error::Error for GraphError {}
93
94// ---------------------------------------------------------------------------
95// Core data types
96// ---------------------------------------------------------------------------
97
98/// A directed weighted trust edge between two peers.
99#[derive(Debug, Clone)]
100pub struct TrustEdge {
101    /// Source peer identifier.
102    pub from_peer: String,
103    /// Destination peer identifier.
104    pub to_peer: String,
105    /// Edge weight in `[0.0, 1.0]`; higher means stronger trust.
106    pub weight: f64,
107    /// Unix timestamp (seconds) when the edge was first created.
108    pub created_at: u64,
109    /// Unix timestamp (seconds) of the last weight update.
110    pub updated_at: u64,
111    /// Number of times this edge has been updated via `add_edge`.
112    pub interaction_count: u64,
113}
114
115/// A full reputation snapshot for a single peer.
116#[derive(Debug, Clone)]
117pub struct ReputationScore {
118    /// Peer identifier.
119    pub peer_id: String,
120    /// Score derived solely from direct interactions.
121    pub direct_score: f64,
122    /// Score computed by trust propagation through the graph.
123    pub propagated_score: f64,
124    /// Weighted blend of direct and propagated scores.
125    pub combined_score: f64,
126    /// Confidence in the score in `[0.0, 1.0]`; based on observation count.
127    pub confidence: f64,
128    /// Percentile rank among all peers (0 = lowest, 1 = highest).
129    pub percentile: f64,
130}
131
132/// Events emitted by the reputation graph for observability.
133#[derive(Debug, Clone)]
134pub enum ReputationEvent {
135    /// A positive interaction was recorded for the given peer.
136    PositiveInteraction { peer_id: String, magnitude: f64 },
137    /// A negative interaction was recorded for the given peer.
138    NegativeInteraction { peer_id: String, magnitude: f64 },
139    /// A new trust edge was added (or an existing one was updated).
140    EdgeAdded {
141        from: String,
142        to: String,
143        weight: f64,
144    },
145    /// A trust edge was removed.
146    EdgeRemoved { from: String, to: String },
147    /// A peer's score decayed during a decay tick.
148    ScoreDecayed {
149        peer_id: String,
150        old_score: f64,
151        new_score: f64,
152    },
153}
154
155/// Configuration parameters for [`PeerReputationGraph`].
156#[derive(Debug, Clone)]
157pub struct GraphConfig {
158    /// Multiplicative decay applied to all direct scores per `decay_scores` call.
159    /// Default: 0.99 (≈ 1 % loss per tick).
160    pub trust_decay_factor: f64,
161    /// Maximum BFS depth for trust propagation. Default: 3.
162    pub propagation_depth: u8,
163    /// Damping factor applied at each additional hop. Default: 0.5.
164    pub propagation_damping: f64,
165    /// Edges whose weight drops below this are pruned during `decay_scores`.
166    pub min_edge_weight: f64,
167    /// Maximum number of peers the graph may hold.
168    pub max_peers: usize,
169    /// Weight of direct score in the combined score formula. Default: 0.6.
170    pub combination_weight_direct: f64,
171    /// Weight of propagated score in the combined score formula. Default: 0.4.
172    pub combination_weight_propagated: f64,
173}
174
175impl Default for GraphConfig {
176    fn default() -> Self {
177        Self {
178            trust_decay_factor: 0.99,
179            propagation_depth: 3,
180            propagation_damping: 0.5,
181            min_edge_weight: 0.01,
182            max_peers: 10_000,
183            combination_weight_direct: 0.6,
184            combination_weight_propagated: 0.4,
185        }
186    }
187}
188
189/// Aggregate statistics for the entire reputation graph.
190#[derive(Debug, Clone)]
191pub struct GraphStats {
192    /// Number of peers currently in the graph.
193    pub peer_count: usize,
194    /// Number of directed trust edges.
195    pub edge_count: usize,
196    /// Average out-degree (edges per source peer).
197    pub avg_out_degree: f64,
198    /// Mean combined reputation score across all peers.
199    pub avg_reputation: f64,
200    /// Top 5 peers by combined score.
201    pub top_peers: Vec<String>,
202    /// Peers that have no incoming or outgoing edges.
203    pub isolated_peers: usize,
204}
205
206// ---------------------------------------------------------------------------
207// Internal peer state
208// ---------------------------------------------------------------------------
209
210#[derive(Debug, Clone)]
211struct PeerState {
212    peer_id: String,
213    direct_score: f64,
214    propagated_score: f64,
215    /// Total number of positive + negative interactions recorded.
216    interaction_count: u64,
217}
218
219impl PeerState {
220    fn new(peer_id: String) -> Self {
221        Self {
222            peer_id,
223            direct_score: 0.5, // neutral starting point
224            propagated_score: 0.0,
225            interaction_count: 0,
226        }
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Main graph struct
232// ---------------------------------------------------------------------------
233
234/// Graph-based peer reputation system with trust propagation.
235///
236/// The graph maintains directed weighted edges between peer identifiers and
237/// computes reputation scores via both direct interaction history and
238/// multi-hop trust propagation.
239pub struct PeerReputationGraph {
240    config: GraphConfig,
241    /// Peer ID → internal state.
242    peers: HashMap<String, PeerState>,
243    /// (from, to) → edge.
244    edges: HashMap<(String, String), TrustEdge>,
245    /// Adjacency index: from_peer → set of to_peers (outgoing).
246    out_adj: HashMap<String, HashSet<String>>,
247    /// Adjacency index: to_peer → set of from_peers (incoming).
248    in_adj: HashMap<String, HashSet<String>>,
249    /// Simple monotonic timestamp counter (incremented per mutation).
250    clock: u64,
251    /// PRNG state for tie-breaking.
252    rng_state: u64,
253}
254
255impl PeerReputationGraph {
256    /// Create a new graph with the given configuration.
257    pub fn new(config: GraphConfig) -> Self {
258        Self {
259            config,
260            peers: HashMap::new(),
261            edges: HashMap::new(),
262            out_adj: HashMap::new(),
263            in_adj: HashMap::new(),
264            clock: 1,
265            rng_state: 0xdeadbeef_cafebabe,
266        }
267    }
268
269    // -----------------------------------------------------------------------
270    // Peer management
271    // -----------------------------------------------------------------------
272
273    /// Add a peer to the graph.
274    ///
275    /// Returns `GraphError::MaxPeersExceeded` if the configured limit would be
276    /// breached.  Adding an already-existing peer is a no-op (returns `Ok`).
277    pub fn add_peer(&mut self, peer_id: String) -> Result<(), GraphError> {
278        if self.peers.contains_key(&peer_id) {
279            return Ok(());
280        }
281        if self.peers.len() >= self.config.max_peers {
282            return Err(GraphError::MaxPeersExceeded);
283        }
284        self.out_adj.entry(peer_id.clone()).or_default();
285        self.in_adj.entry(peer_id.clone()).or_default();
286        self.peers.insert(peer_id.clone(), PeerState::new(peer_id));
287        self.tick();
288        Ok(())
289    }
290
291    /// Remove a peer and all edges incident to it.
292    pub fn remove_peer(&mut self, peer_id: &str) -> Result<(), GraphError> {
293        if !self.peers.contains_key(peer_id) {
294            return Err(GraphError::PeerNotFound(peer_id.to_string()));
295        }
296
297        // Collect edges to remove to avoid borrow conflicts.
298        let out_peers: Vec<String> = self
299            .out_adj
300            .get(peer_id)
301            .map(|s| s.iter().cloned().collect())
302            .unwrap_or_default();
303        let in_peers: Vec<String> = self
304            .in_adj
305            .get(peer_id)
306            .map(|s| s.iter().cloned().collect())
307            .unwrap_or_default();
308
309        for to in &out_peers {
310            self.edges.remove(&(peer_id.to_string(), to.clone()));
311            if let Some(set) = self.in_adj.get_mut(to) {
312                set.remove(peer_id);
313            }
314        }
315        for from in &in_peers {
316            self.edges.remove(&(from.clone(), peer_id.to_string()));
317            if let Some(set) = self.out_adj.get_mut(from) {
318                set.remove(peer_id);
319            }
320        }
321
322        self.out_adj.remove(peer_id);
323        self.in_adj.remove(peer_id);
324        self.peers.remove(peer_id);
325        self.tick();
326        Ok(())
327    }
328
329    // -----------------------------------------------------------------------
330    // Edge management
331    // -----------------------------------------------------------------------
332
333    /// Add or update a directed trust edge.
334    ///
335    /// # Errors
336    ///
337    /// - `GraphError::PeerNotFound` if either peer is absent.
338    /// - `GraphError::SelfLoop` if `from == to`.
339    /// - `GraphError::InvalidWeight` if `weight` is outside `[0.0, 1.0]`.
340    pub fn add_edge(&mut self, from: &str, to: &str, weight: f64) -> Result<(), GraphError> {
341        if from == to {
342            return Err(GraphError::SelfLoop(from.to_string()));
343        }
344        if !(0.0..=1.0).contains(&weight) {
345            return Err(GraphError::InvalidWeight(weight));
346        }
347        if !self.peers.contains_key(from) {
348            return Err(GraphError::PeerNotFound(from.to_string()));
349        }
350        if !self.peers.contains_key(to) {
351            return Err(GraphError::PeerNotFound(to.to_string()));
352        }
353
354        let now = self.tick();
355        let key = (from.to_string(), to.to_string());
356
357        if let Some(edge) = self.edges.get_mut(&key) {
358            edge.weight = weight;
359            edge.updated_at = now;
360            edge.interaction_count += 1;
361        } else {
362            self.edges.insert(
363                key.clone(),
364                TrustEdge {
365                    from_peer: from.to_string(),
366                    to_peer: to.to_string(),
367                    weight,
368                    created_at: now,
369                    updated_at: now,
370                    interaction_count: 1,
371                },
372            );
373            self.out_adj
374                .entry(from.to_string())
375                .or_default()
376                .insert(to.to_string());
377            self.in_adj
378                .entry(to.to_string())
379                .or_default()
380                .insert(from.to_string());
381        }
382        Ok(())
383    }
384
385    /// Remove a directed trust edge.
386    pub fn remove_edge(&mut self, from: &str, to: &str) -> Result<(), GraphError> {
387        let key = (from.to_string(), to.to_string());
388        if self.edges.remove(&key).is_none() {
389            return Err(GraphError::EdgeNotFound {
390                from: from.to_string(),
391                to: to.to_string(),
392            });
393        }
394        if let Some(set) = self.out_adj.get_mut(from) {
395            set.remove(to);
396        }
397        if let Some(set) = self.in_adj.get_mut(to) {
398            set.remove(from);
399        }
400        self.tick();
401        Ok(())
402    }
403
404    // -----------------------------------------------------------------------
405    // Interaction recording
406    // -----------------------------------------------------------------------
407
408    /// Record an interaction with a peer, updating its direct score via EMA.
409    ///
410    /// Alpha = 0.1.  Positive interactions add `magnitude`; negative ones
411    /// subtract it.  The result is clamped to `[0.0, 1.0]`.
412    ///
413    /// Returns the corresponding [`ReputationEvent`].
414    pub fn record_interaction(
415        &mut self,
416        peer_id: &str,
417        positive: bool,
418        magnitude: f64,
419    ) -> Result<ReputationEvent, GraphError> {
420        const ALPHA: f64 = 0.1;
421
422        let state = self
423            .peers
424            .get_mut(peer_id)
425            .ok_or_else(|| GraphError::PeerNotFound(peer_id.to_string()))?;
426
427        let delta = if positive { magnitude } else { -magnitude };
428
429        // EMA formula: new = old + alpha * (target - old)
430        // where target = clamp(old + delta, 0, 1)
431        let target = (state.direct_score + delta).clamp(0.0, 1.0);
432        let ema = state.direct_score + ALPHA * (target - state.direct_score);
433
434        state.direct_score = ema.clamp(0.0, 1.0);
435        state.interaction_count += 1;
436
437        let event = if positive {
438            ReputationEvent::PositiveInteraction {
439                peer_id: peer_id.to_string(),
440                magnitude,
441            }
442        } else {
443            ReputationEvent::NegativeInteraction {
444                peer_id: peer_id.to_string(),
445                magnitude,
446            }
447        };
448        self.tick();
449        Ok(event)
450    }
451
452    // -----------------------------------------------------------------------
453    // Trust propagation
454    // -----------------------------------------------------------------------
455
456    /// Propagate trust through the graph via BFS.
457    ///
458    /// For each peer `p`, performs BFS up to `propagation_depth` hops.
459    /// The propagated score contribution of a path
460    /// `p → n1 → n2 → … → nk` is:
461    ///
462    /// ```text
463    /// w(p→n1) × direct_score(p) × damping^1
464    /// + w(n1→n2) × direct_score(n1) × damping^2  (if n2 is the target)
465    /// …
466    /// ```
467    ///
468    /// Each peer's `propagated_score` is set to the sum of all such
469    /// contributions clamped to `[0.0, 1.0]`.
470    ///
471    /// Returns the number of peers whose propagated score was updated.
472    pub fn propagate_trust(&mut self) -> usize {
473        // Collect current direct scores to avoid mutable borrow conflicts.
474        let direct_scores: HashMap<String, f64> = self
475            .peers
476            .iter()
477            .map(|(id, s)| (id.clone(), s.direct_score))
478            .collect();
479
480        // New propagated scores accumulator.
481        let mut propagated: HashMap<String, f64> =
482            self.peers.keys().map(|id| (id.clone(), 0.0_f64)).collect();
483
484        let depth = self.config.propagation_depth as usize;
485        let damping = self.config.propagation_damping;
486
487        // For every source peer, BFS outward and accumulate contributions.
488        for (src_id, &src_direct) in &direct_scores {
489            if src_direct == 0.0 {
490                continue;
491            }
492
493            // BFS queue: (current_node, accumulated_trust, current_hop)
494            let mut queue: VecDeque<(String, f64, usize)> = VecDeque::new();
495            queue.push_back((src_id.clone(), src_direct, 0));
496
497            let mut visited: HashSet<String> = HashSet::new();
498            visited.insert(src_id.clone());
499
500            while let Some((node, trust, hop)) = queue.pop_front() {
501                if hop >= depth {
502                    continue;
503                }
504
505                let neighbors: Vec<(String, f64)> = self
506                    .out_adj
507                    .get(&node)
508                    .map(|set| {
509                        set.iter()
510                            .filter_map(|nb| {
511                                let key = (node.clone(), nb.clone());
512                                self.edges.get(&key).map(|e| (nb.clone(), e.weight))
513                            })
514                            .collect()
515                    })
516                    .unwrap_or_default();
517
518                for (nb, edge_weight) in neighbors {
519                    if visited.contains(&nb) {
520                        continue;
521                    }
522                    visited.insert(nb.clone());
523
524                    let contribution = edge_weight * trust * damping.powi((hop + 1) as i32);
525                    if let Some(acc) = propagated.get_mut(&nb) {
526                        *acc += contribution;
527                    }
528
529                    queue.push_back((nb, trust * edge_weight, hop + 1));
530                }
531            }
532        }
533
534        // Write back and count updates.
535        let mut updated = 0_usize;
536        for (id, new_prop) in propagated {
537            if let Some(state) = self.peers.get_mut(&id) {
538                let clamped = new_prop.clamp(0.0, 1.0);
539                if (clamped - state.propagated_score).abs() > f64::EPSILON {
540                    state.propagated_score = clamped;
541                    updated += 1;
542                }
543            }
544        }
545        updated
546    }
547
548    // -----------------------------------------------------------------------
549    // Score decay
550    // -----------------------------------------------------------------------
551
552    /// Apply decay to all direct scores and prune weak edges.
553    ///
554    /// Each `direct_score` is multiplied by `trust_decay_factor`.  A
555    /// [`ReputationEvent::ScoreDecayed`] is emitted for any peer whose score
556    /// changes by more than 0.001.  Edges whose weight drops below
557    /// `min_edge_weight` are also pruned.
558    pub fn decay_scores(&mut self) -> Vec<ReputationEvent> {
559        let factor = self.config.trust_decay_factor;
560        let min_weight = self.config.min_edge_weight;
561
562        let mut events = Vec::new();
563
564        for state in self.peers.values_mut() {
565            let old = state.direct_score;
566            let new = (old * factor).clamp(0.0, 1.0);
567            if (new - old).abs() > 0.001 {
568                events.push(ReputationEvent::ScoreDecayed {
569                    peer_id: state.peer_id.clone(),
570                    old_score: old,
571                    new_score: new,
572                });
573            }
574            state.direct_score = new;
575        }
576
577        // Prune edges below min_edge_weight.
578        let to_prune: Vec<(String, String)> = self
579            .edges
580            .iter()
581            .filter(|(_, e)| e.weight < min_weight)
582            .map(|(k, _)| (k.0.clone(), k.1.clone()))
583            .collect();
584
585        for (from, to) in to_prune {
586            // Best-effort removal; ignore errors.
587            let _ = self.remove_edge(&from, &to);
588        }
589
590        self.tick();
591        events
592    }
593
594    // -----------------------------------------------------------------------
595    // Score query
596    // -----------------------------------------------------------------------
597
598    /// Retrieve the full [`ReputationScore`] for a peer.
599    ///
600    /// `combined_score` is a weighted sum of `direct_score` and
601    /// `propagated_score` normalized so the total weight sums to 1.
602    /// `confidence` is `min(1.0, interaction_count / 10.0)`.
603    pub fn reputation(&self, peer_id: &str) -> Result<ReputationScore, GraphError> {
604        let state = self
605            .peers
606            .get(peer_id)
607            .ok_or_else(|| GraphError::PeerNotFound(peer_id.to_string()))?;
608
609        let wd = self.config.combination_weight_direct;
610        let wp = self.config.combination_weight_propagated;
611        let total_weight = wd + wp;
612
613        let combined = if total_weight > 0.0 {
614            (wd * state.direct_score + wp * state.propagated_score) / total_weight
615        } else {
616            state.direct_score
617        };
618
619        let confidence = (state.interaction_count as f64 / 10.0).min(1.0);
620
621        // Compute percentile rank.
622        let combined_clamped = combined.clamp(0.0, 1.0);
623        let percentile = self.percentile_of(peer_id, combined_clamped);
624
625        Ok(ReputationScore {
626            peer_id: peer_id.to_string(),
627            direct_score: state.direct_score,
628            propagated_score: state.propagated_score,
629            combined_score: combined_clamped,
630            confidence,
631            percentile,
632        })
633    }
634
635    /// Return the top `n` peers by combined score, sorted descending.
636    pub fn top_peers(&self, n: usize) -> Vec<ReputationScore> {
637        let mut scores: Vec<ReputationScore> = self
638            .peers
639            .keys()
640            .filter_map(|id| self.reputation(id).ok())
641            .collect();
642
643        scores.sort_by(|a, b| {
644            b.combined_score
645                .partial_cmp(&a.combined_score)
646                .unwrap_or(std::cmp::Ordering::Equal)
647        });
648
649        scores.truncate(n);
650        scores
651    }
652
653    // -----------------------------------------------------------------------
654    // Graph queries
655    // -----------------------------------------------------------------------
656
657    /// Peers that have a trust edge pointing **to** `peer_id` (in-neighbours).
658    pub fn peers_trusting(&self, peer_id: &str) -> Vec<String> {
659        self.in_adj
660            .get(peer_id)
661            .map(|set| set.iter().cloned().collect())
662            .unwrap_or_default()
663    }
664
665    /// Peers that `peer_id` trusts — i.e. out-neighbours of `peer_id`.
666    pub fn peers_trusted_by(&self, peer_id: &str) -> Vec<String> {
667        self.out_adj
668            .get(peer_id)
669            .map(|set| set.iter().cloned().collect())
670            .unwrap_or_default()
671    }
672
673    /// Compute aggregate statistics for the graph.
674    pub fn stats(&self) -> GraphStats {
675        let peer_count = self.peers.len();
676        let edge_count = self.edges.len();
677
678        let avg_out_degree = if peer_count > 0 {
679            edge_count as f64 / peer_count as f64
680        } else {
681            0.0
682        };
683
684        let avg_reputation = if peer_count > 0 {
685            let sum: f64 = self
686                .peers
687                .keys()
688                .filter_map(|id| self.reputation(id).ok())
689                .map(|r| r.combined_score)
690                .sum();
691            sum / peer_count as f64
692        } else {
693            0.0
694        };
695
696        let mut top = self.top_peers(5);
697        // Ensure deterministic order by peer_id for equal scores.
698        top.sort_by(|a, b| {
699            b.combined_score
700                .partial_cmp(&a.combined_score)
701                .unwrap_or(std::cmp::Ordering::Equal)
702                .then_with(|| a.peer_id.cmp(&b.peer_id))
703        });
704        let top_peers = top.into_iter().map(|r| r.peer_id).collect();
705
706        let isolated_peers = self
707            .peers
708            .keys()
709            .filter(|id| {
710                self.out_adj.get(*id).map(|s| s.is_empty()).unwrap_or(true)
711                    && self.in_adj.get(*id).map(|s| s.is_empty()).unwrap_or(true)
712            })
713            .count();
714
715        GraphStats {
716            peer_count,
717            edge_count,
718            avg_out_degree,
719            avg_reputation,
720            top_peers,
721            isolated_peers,
722        }
723    }
724
725    /// Remove peers that are isolated (no edges) **and** have a combined score
726    /// below 0.1.
727    ///
728    /// Returns the number of peers removed.
729    pub fn prune_isolated(&mut self) -> usize {
730        let to_remove: Vec<String> = self
731            .peers
732            .iter()
733            .filter(|(id, state)| {
734                let out_empty = self.out_adj.get(*id).map(|s| s.is_empty()).unwrap_or(true);
735                let in_empty = self.in_adj.get(*id).map(|s| s.is_empty()).unwrap_or(true);
736                if !out_empty || !in_empty {
737                    return false;
738                }
739                let wd = self.config.combination_weight_direct;
740                let wp = self.config.combination_weight_propagated;
741                let total = wd + wp;
742                let combined = if total > 0.0 {
743                    (wd * state.direct_score + wp * state.propagated_score) / total
744                } else {
745                    state.direct_score
746                };
747                combined < 0.1
748            })
749            .map(|(id, _)| id.clone())
750            .collect();
751
752        let count = to_remove.len();
753        for id in to_remove {
754            // Errors ignored; peer may already have been removed concurrently.
755            let _ = self.remove_peer(&id);
756        }
757        count
758    }
759
760    // -----------------------------------------------------------------------
761    // Direct edge / score accessors
762    // -----------------------------------------------------------------------
763
764    /// Return all edges in the graph (cloned).
765    pub fn all_edges(&self) -> Vec<TrustEdge> {
766        self.edges.values().cloned().collect()
767    }
768
769    /// Return the edge between two peers, if present.
770    pub fn edge(&self, from: &str, to: &str) -> Option<&TrustEdge> {
771        self.edges.get(&(from.to_string(), to.to_string()))
772    }
773
774    /// Return whether a peer is present in the graph.
775    pub fn contains_peer(&self, peer_id: &str) -> bool {
776        self.peers.contains_key(peer_id)
777    }
778
779    /// Return the current number of peers.
780    pub fn peer_count(&self) -> usize {
781        self.peers.len()
782    }
783
784    /// Return the current number of edges.
785    pub fn edge_count(&self) -> usize {
786        self.edges.len()
787    }
788
789    // -----------------------------------------------------------------------
790    // Internal helpers
791    // -----------------------------------------------------------------------
792
793    /// Advance the logical clock and return the new timestamp.
794    fn tick(&mut self) -> u64 {
795        self.clock += 1;
796        // Use xorshift64 to add entropy that prevents degenerate patterns.
797        let _ = xorshift64(&mut self.rng_state);
798        self.clock
799    }
800
801    /// Compute the percentile rank of `combined` among all peers.
802    fn percentile_of(&self, peer_id: &str, combined: f64) -> f64 {
803        if self.peers.len() <= 1 {
804            return 0.5;
805        }
806        let below = self
807            .peers
808            .iter()
809            .filter(|(id, state)| {
810                if id.as_str() == peer_id {
811                    return false;
812                }
813                let wd = self.config.combination_weight_direct;
814                let wp = self.config.combination_weight_propagated;
815                let total = wd + wp;
816                let other_combined = if total > 0.0 {
817                    (wd * state.direct_score + wp * state.propagated_score) / total
818                } else {
819                    state.direct_score
820                };
821                other_combined < combined
822            })
823            .count();
824        let n = self.peers.len() - 1; // exclude self
825        if n == 0 {
826            0.5
827        } else {
828            below as f64 / n as f64
829        }
830    }
831}
832
833// ---------------------------------------------------------------------------
834// Tests
835// ---------------------------------------------------------------------------
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    fn make_graph() -> PeerReputationGraph {
842        PeerReputationGraph::new(GraphConfig::default())
843    }
844
845    // ------- add/remove peers -------
846
847    #[test]
848    fn test_add_peer_basic() {
849        let mut g = make_graph();
850        g.add_peer("alice".to_string())
851            .expect("test: add_peer alice should succeed");
852        assert!(g.contains_peer("alice"));
853    }
854
855    #[test]
856    fn test_add_peer_idempotent() {
857        let mut g = make_graph();
858        g.add_peer("alice".to_string())
859            .expect("test: add_peer alice should succeed");
860        // Adding again must succeed silently.
861        g.add_peer("alice".to_string())
862            .expect("test: re-adding alice should succeed");
863        assert_eq!(g.peer_count(), 1);
864    }
865
866    #[test]
867    fn test_add_peer_max_exceeded() {
868        let mut g = PeerReputationGraph::new(GraphConfig {
869            max_peers: 2,
870            ..Default::default()
871        });
872        g.add_peer("a".to_string())
873            .expect("test: add_peer a should succeed");
874        g.add_peer("b".to_string())
875            .expect("test: add_peer b should succeed");
876        let err = g
877            .add_peer("c".to_string())
878            .expect_err("test: expected error when exceeding max peers");
879        assert_eq!(err, GraphError::MaxPeersExceeded);
880    }
881
882    #[test]
883    fn test_remove_peer_basic() {
884        let mut g = make_graph();
885        g.add_peer("alice".to_string())
886            .expect("test: add_peer alice should succeed");
887        g.remove_peer("alice")
888            .expect("test: remove_peer alice should succeed");
889        assert!(!g.contains_peer("alice"));
890    }
891
892    #[test]
893    fn test_remove_peer_not_found() {
894        let mut g = make_graph();
895        let err = g
896            .remove_peer("ghost")
897            .expect_err("test: expected error removing nonexistent peer");
898        assert!(matches!(err, GraphError::PeerNotFound(_)));
899    }
900
901    #[test]
902    fn test_remove_peer_removes_edges() {
903        let mut g = make_graph();
904        g.add_peer("a".to_string())
905            .expect("test: add_peer a should succeed");
906        g.add_peer("b".to_string())
907            .expect("test: add_peer b should succeed");
908        g.add_peer("c".to_string())
909            .expect("test: add_peer c should succeed");
910        g.add_edge("a", "b", 0.8)
911            .expect("test: add_edge a->b should succeed");
912        g.add_edge("c", "a", 0.6)
913            .expect("test: add_edge c->a should succeed");
914        g.remove_peer("a")
915            .expect("test: remove_peer a should succeed");
916        assert_eq!(g.edge_count(), 0);
917        assert!(g.peers_trusting("b").is_empty());
918        assert!(g.peers_trusted_by("c").is_empty());
919    }
920
921    // ------- add/remove edges -------
922
923    #[test]
924    fn test_add_edge_basic() {
925        let mut g = make_graph();
926        g.add_peer("a".to_string())
927            .expect("test: add_peer a should succeed");
928        g.add_peer("b".to_string())
929            .expect("test: add_peer b should succeed");
930        g.add_edge("a", "b", 0.7)
931            .expect("test: add_edge a->b should succeed");
932        assert_eq!(g.edge_count(), 1);
933        assert_eq!(
934            g.edge("a", "b")
935                .expect("test: edge a->b should exist")
936                .weight,
937            0.7
938        );
939    }
940
941    #[test]
942    fn test_add_edge_update_existing() {
943        let mut g = make_graph();
944        g.add_peer("a".to_string())
945            .expect("test: add_peer a should succeed");
946        g.add_peer("b".to_string())
947            .expect("test: add_peer b should succeed");
948        g.add_edge("a", "b", 0.5)
949            .expect("test: add_edge a->b 0.5 should succeed");
950        g.add_edge("a", "b", 0.9)
951            .expect("test: update edge a->b to 0.9 should succeed");
952        assert_eq!(g.edge_count(), 1);
953        assert_eq!(
954            g.edge("a", "b")
955                .expect("test: edge a->b should exist")
956                .weight,
957            0.9
958        );
959        assert_eq!(
960            g.edge("a", "b")
961                .expect("test: edge a->b should exist")
962                .interaction_count,
963            2
964        );
965    }
966
967    #[test]
968    fn test_add_edge_self_loop() {
969        let mut g = make_graph();
970        g.add_peer("a".to_string())
971            .expect("test: add_peer a should succeed");
972        let err = g
973            .add_edge("a", "a", 0.5)
974            .expect_err("test: expected error for self-loop edge");
975        assert!(matches!(err, GraphError::SelfLoop(_)));
976    }
977
978    #[test]
979    fn test_add_edge_invalid_weight_negative() {
980        let mut g = make_graph();
981        g.add_peer("a".to_string())
982            .expect("test: add_peer a should succeed");
983        g.add_peer("b".to_string())
984            .expect("test: add_peer b should succeed");
985        let err = g
986            .add_edge("a", "b", -0.1)
987            .expect_err("test: expected error for negative weight");
988        assert!(matches!(err, GraphError::InvalidWeight(_)));
989    }
990
991    #[test]
992    fn test_add_edge_invalid_weight_above_one() {
993        let mut g = make_graph();
994        g.add_peer("a".to_string())
995            .expect("test: add_peer a should succeed");
996        g.add_peer("b".to_string())
997            .expect("test: add_peer b should succeed");
998        let err = g
999            .add_edge("a", "b", 1.5)
1000            .expect_err("test: expected error for weight above 1.0");
1001        assert!(matches!(err, GraphError::InvalidWeight(_)));
1002    }
1003
1004    #[test]
1005    fn test_add_edge_peer_not_found() {
1006        let mut g = make_graph();
1007        g.add_peer("a".to_string())
1008            .expect("test: add_peer a should succeed");
1009        let err = g
1010            .add_edge("a", "ghost", 0.5)
1011            .expect_err("test: expected error for nonexistent target peer");
1012        assert!(matches!(err, GraphError::PeerNotFound(_)));
1013    }
1014
1015    #[test]
1016    fn test_remove_edge_basic() {
1017        let mut g = make_graph();
1018        g.add_peer("a".to_string())
1019            .expect("test: add_peer a should succeed");
1020        g.add_peer("b".to_string())
1021            .expect("test: add_peer b should succeed");
1022        g.add_edge("a", "b", 0.5)
1023            .expect("test: add_edge a->b should succeed");
1024        g.remove_edge("a", "b")
1025            .expect("test: remove_edge a->b should succeed");
1026        assert_eq!(g.edge_count(), 0);
1027    }
1028
1029    #[test]
1030    fn test_remove_edge_not_found() {
1031        let mut g = make_graph();
1032        g.add_peer("a".to_string())
1033            .expect("test: add_peer a should succeed");
1034        g.add_peer("b".to_string())
1035            .expect("test: add_peer b should succeed");
1036        let err = g
1037            .remove_edge("a", "b")
1038            .expect_err("test: expected error removing nonexistent edge");
1039        assert!(matches!(err, GraphError::EdgeNotFound { .. }));
1040    }
1041
1042    // ------- interactions -------
1043
1044    #[test]
1045    fn test_positive_interaction_increases_score() {
1046        let mut g = make_graph();
1047        g.add_peer("a".to_string())
1048            .expect("test: add_peer a should succeed");
1049        let initial = g
1050            .reputation("a")
1051            .expect("test: reputation for a should exist")
1052            .direct_score;
1053        let event = g
1054            .record_interaction("a", true, 1.0)
1055            .expect("test: record positive interaction for a should succeed");
1056        let after = g
1057            .reputation("a")
1058            .expect("test: reputation for a should exist")
1059            .direct_score;
1060        assert!(after >= initial);
1061        assert!(matches!(event, ReputationEvent::PositiveInteraction { .. }));
1062    }
1063
1064    #[test]
1065    fn test_negative_interaction_decreases_score() {
1066        let mut g = make_graph();
1067        g.add_peer("a".to_string())
1068            .expect("test: add_peer a should succeed");
1069        let initial = g
1070            .reputation("a")
1071            .expect("test: reputation for a should exist")
1072            .direct_score;
1073        let event = g
1074            .record_interaction("a", false, 1.0)
1075            .expect("test: record negative interaction for a should succeed");
1076        let after = g
1077            .reputation("a")
1078            .expect("test: reputation for a should exist")
1079            .direct_score;
1080        assert!(after <= initial);
1081        assert!(matches!(event, ReputationEvent::NegativeInteraction { .. }));
1082    }
1083
1084    #[test]
1085    fn test_interaction_clamps_score() {
1086        let mut g = make_graph();
1087        g.add_peer("a".to_string())
1088            .expect("test: add_peer a should succeed");
1089        // Many large positive interactions should clamp to 1.0.
1090        for _ in 0..100 {
1091            g.record_interaction("a", true, 10.0)
1092                .expect("test: record large positive interaction should succeed");
1093        }
1094        let score = g
1095            .reputation("a")
1096            .expect("test: reputation for a should exist")
1097            .direct_score;
1098        assert!(score <= 1.0);
1099        // Many large negative interactions should clamp to 0.0.
1100        for _ in 0..100 {
1101            g.record_interaction("a", false, 10.0)
1102                .expect("test: record large negative interaction should succeed");
1103        }
1104        let score = g
1105            .reputation("a")
1106            .expect("test: reputation for a should exist")
1107            .direct_score;
1108        assert!(score >= 0.0);
1109    }
1110
1111    #[test]
1112    fn test_interaction_peer_not_found() {
1113        let mut g = make_graph();
1114        let err = g
1115            .record_interaction("ghost", true, 0.5)
1116            .expect_err("test: expected error for nonexistent peer interaction");
1117        assert!(matches!(err, GraphError::PeerNotFound(_)));
1118    }
1119
1120    #[test]
1121    fn test_interaction_count_increments() {
1122        let mut g = make_graph();
1123        g.add_peer("a".to_string())
1124            .expect("test: add_peer a should succeed");
1125        g.record_interaction("a", true, 0.5)
1126            .expect("test: record positive interaction should succeed");
1127        g.record_interaction("a", false, 0.2)
1128            .expect("test: record negative interaction should succeed");
1129        let score = g
1130            .reputation("a")
1131            .expect("test: reputation for a should exist");
1132        assert!(score.confidence > 0.0);
1133    }
1134
1135    // ------- propagation -------
1136
1137    #[test]
1138    fn test_propagate_trust_basic() {
1139        let mut g = make_graph();
1140        g.add_peer("a".to_string())
1141            .expect("test: add_peer a should succeed");
1142        g.add_peer("b".to_string())
1143            .expect("test: add_peer b should succeed");
1144        g.add_edge("a", "b", 1.0)
1145            .expect("test: add_edge a->b should succeed");
1146        // Give 'a' a high direct score.
1147        for _ in 0..50 {
1148            g.record_interaction("a", true, 1.0)
1149                .expect("test: record positive interaction for a should succeed");
1150        }
1151        let updated = g.propagate_trust();
1152        assert!(updated > 0);
1153        let b_score = g
1154            .reputation("b")
1155            .expect("test: reputation for b should exist");
1156        assert!(b_score.propagated_score > 0.0);
1157    }
1158
1159    #[test]
1160    fn test_propagate_trust_no_edges() {
1161        let mut g = make_graph();
1162        g.add_peer("a".to_string())
1163            .expect("test: add_peer a should succeed");
1164        g.add_peer("b".to_string())
1165            .expect("test: add_peer b should succeed");
1166        // No edges — propagated scores remain 0.
1167        g.propagate_trust();
1168        assert_eq!(
1169            g.reputation("b")
1170                .expect("test: reputation for b should exist")
1171                .propagated_score,
1172            0.0
1173        );
1174    }
1175
1176    #[test]
1177    fn test_propagate_trust_depth_limit() {
1178        // With depth=1, a→b→c: 'c' can still receive propagation FROM 'b'
1179        // (b has its own direct score), but 'c' should NOT receive propagation
1180        // from 'a' through 'b'.  We verify by giving only 'a' a high score and
1181        // keeping 'b' at neutral — 'c' must get some propagated score (from b)
1182        // but less than 'b' itself.
1183        let mut g = PeerReputationGraph::new(GraphConfig {
1184            propagation_depth: 1,
1185            propagation_damping: 0.5,
1186            ..Default::default()
1187        });
1188        g.add_peer("a".to_string())
1189            .expect("test: add_peer a should succeed");
1190        g.add_peer("b".to_string())
1191            .expect("test: add_peer b should succeed");
1192        g.add_peer("c".to_string())
1193            .expect("test: add_peer c should succeed");
1194        g.add_edge("a", "b", 1.0)
1195            .expect("test: add_edge a->b should succeed");
1196        g.add_edge("b", "c", 1.0)
1197            .expect("test: add_edge b->c should succeed");
1198        for _ in 0..50 {
1199            g.record_interaction("a", true, 1.0)
1200                .expect("test: record positive interaction for a should succeed");
1201        }
1202        g.propagate_trust();
1203        // 'b' gets propagated score from 'a'.
1204        assert!(
1205            g.reputation("b")
1206                .expect("test: reputation for b should exist")
1207                .propagated_score
1208                > 0.0
1209        );
1210        // 'c' gets propagated score from 'b' (b has a non-zero direct score).
1211        // Both get something; depth=1 limits but does not eliminate propagation.
1212        // Key check: 'b' receives propagation from high-score 'a',
1213        // while 'c' only receives propagation from neutral-score 'b'.
1214        let b_prop = g
1215            .reputation("b")
1216            .expect("test: reputation for b should exist")
1217            .propagated_score;
1218        let c_prop = g
1219            .reputation("c")
1220            .expect("test: reputation for c should exist")
1221            .propagated_score;
1222        // 'a' has a very high score, so b's propagated score > c's propagated score.
1223        assert!(b_prop >= c_prop);
1224    }
1225
1226    #[test]
1227    fn test_propagate_trust_multi_hop() {
1228        // With depth ≥ 2, a→b→c should give c a propagated score.
1229        let mut g = PeerReputationGraph::new(GraphConfig {
1230            propagation_depth: 2,
1231            propagation_damping: 0.5,
1232            ..Default::default()
1233        });
1234        g.add_peer("a".to_string())
1235            .expect("test: add_peer a should succeed");
1236        g.add_peer("b".to_string())
1237            .expect("test: add_peer b should succeed");
1238        g.add_peer("c".to_string())
1239            .expect("test: add_peer c should succeed");
1240        g.add_edge("a", "b", 1.0)
1241            .expect("test: add_edge a->b should succeed");
1242        g.add_edge("b", "c", 1.0)
1243            .expect("test: add_edge b->c should succeed");
1244        for _ in 0..50 {
1245            g.record_interaction("a", true, 1.0)
1246                .expect("test: record positive interaction for a should succeed");
1247            g.record_interaction("b", true, 1.0)
1248                .expect("test: record positive interaction for b should succeed");
1249        }
1250        g.propagate_trust();
1251        assert!(
1252            g.reputation("c")
1253                .expect("test: reputation for c should exist")
1254                .propagated_score
1255                > 0.0
1256        );
1257    }
1258
1259    #[test]
1260    fn test_propagate_returns_count() {
1261        let mut g = make_graph();
1262        g.add_peer("a".to_string())
1263            .expect("test: add_peer a should succeed");
1264        g.add_peer("b".to_string())
1265            .expect("test: add_peer b should succeed");
1266        g.add_edge("a", "b", 1.0)
1267            .expect("test: add_edge a->b should succeed");
1268        for _ in 0..20 {
1269            g.record_interaction("a", true, 1.0)
1270                .expect("test: record positive interaction for a should succeed");
1271        }
1272        let count = g.propagate_trust();
1273        assert!(count >= 1);
1274    }
1275
1276    // ------- decay -------
1277
1278    #[test]
1279    fn test_decay_scores_reduces_score() {
1280        let mut g = make_graph();
1281        g.add_peer("a".to_string())
1282            .expect("test: add_peer a should succeed");
1283        for _ in 0..50 {
1284            g.record_interaction("a", true, 1.0)
1285                .expect("test: record positive interaction for a should succeed");
1286        }
1287        let before = g
1288            .reputation("a")
1289            .expect("test: reputation for a should exist")
1290            .direct_score;
1291        g.decay_scores();
1292        let after = g
1293            .reputation("a")
1294            .expect("test: reputation for a after decay should exist")
1295            .direct_score;
1296        assert!(after <= before);
1297    }
1298
1299    #[test]
1300    fn test_decay_emits_events() {
1301        let mut g = PeerReputationGraph::new(GraphConfig {
1302            trust_decay_factor: 0.5, // aggressive decay to trigger events
1303            ..Default::default()
1304        });
1305        g.add_peer("a".to_string())
1306            .expect("test: add_peer a should succeed");
1307        for _ in 0..50 {
1308            g.record_interaction("a", true, 1.0)
1309                .expect("test: record positive interaction for a should succeed");
1310        }
1311        let events = g.decay_scores();
1312        assert!(!events.is_empty());
1313        assert!(events
1314            .iter()
1315            .any(|e| matches!(e, ReputationEvent::ScoreDecayed { .. })));
1316    }
1317
1318    #[test]
1319    fn test_decay_prunes_weak_edges() {
1320        let mut g = PeerReputationGraph::new(GraphConfig {
1321            min_edge_weight: 0.5,
1322            ..Default::default()
1323        });
1324        g.add_peer("a".to_string())
1325            .expect("test: add_peer a should succeed");
1326        g.add_peer("b".to_string())
1327            .expect("test: add_peer b should succeed");
1328        g.add_edge("a", "b", 0.3)
1329            .expect("test: add weak edge a->b should succeed"); // below threshold
1330        g.decay_scores();
1331        assert_eq!(g.edge_count(), 0, "weak edge should be pruned");
1332    }
1333
1334    #[test]
1335    fn test_decay_keeps_strong_edges() {
1336        let mut g = PeerReputationGraph::new(GraphConfig {
1337            min_edge_weight: 0.1,
1338            ..Default::default()
1339        });
1340        g.add_peer("a".to_string())
1341            .expect("test: add_peer a should succeed");
1342        g.add_peer("b".to_string())
1343            .expect("test: add_peer b should succeed");
1344        g.add_edge("a", "b", 0.9)
1345            .expect("test: add strong edge a->b should succeed"); // above threshold
1346        g.decay_scores();
1347        assert_eq!(g.edge_count(), 1, "strong edge should remain");
1348    }
1349
1350    // ------- reputation query -------
1351
1352    #[test]
1353    fn test_reputation_not_found() {
1354        let g = make_graph();
1355        let err = g
1356            .reputation("ghost")
1357            .expect_err("test: expected error for nonexistent peer reputation");
1358        assert!(matches!(err, GraphError::PeerNotFound(_)));
1359    }
1360
1361    #[test]
1362    fn test_reputation_combined_score_range() {
1363        let mut g = make_graph();
1364        g.add_peer("a".to_string())
1365            .expect("test: add_peer a should succeed");
1366        for _ in 0..20 {
1367            g.record_interaction("a", true, 0.5)
1368                .expect("test: record positive interaction for a should succeed");
1369        }
1370        let r = g
1371            .reputation("a")
1372            .expect("test: reputation for a should exist");
1373        assert!((0.0..=1.0).contains(&r.combined_score));
1374    }
1375
1376    #[test]
1377    fn test_reputation_confidence_grows() {
1378        let mut g = make_graph();
1379        g.add_peer("a".to_string())
1380            .expect("test: add_peer a should succeed");
1381        let c0 = g
1382            .reputation("a")
1383            .expect("test: reputation for a should exist")
1384            .confidence;
1385        for _ in 0..10 {
1386            g.record_interaction("a", true, 0.5)
1387                .expect("test: record positive interaction should succeed");
1388        }
1389        let c10 = g
1390            .reputation("a")
1391            .expect("test: reputation for a should exist")
1392            .confidence;
1393        assert!(c10 > c0);
1394    }
1395
1396    #[test]
1397    fn test_reputation_confidence_caps_at_one() {
1398        let mut g = make_graph();
1399        g.add_peer("a".to_string())
1400            .expect("test: add_peer a should succeed");
1401        for _ in 0..200 {
1402            g.record_interaction("a", true, 0.3)
1403                .expect("test: record positive interaction should succeed");
1404        }
1405        let c = g
1406            .reputation("a")
1407            .expect("test: reputation for a should exist")
1408            .confidence;
1409        assert!(c <= 1.0);
1410    }
1411
1412    // ------- top_peers -------
1413
1414    #[test]
1415    fn test_top_peers_empty() {
1416        let g = make_graph();
1417        assert!(g.top_peers(5).is_empty());
1418    }
1419
1420    #[test]
1421    fn test_top_peers_order() {
1422        let mut g = make_graph();
1423        for name in &["a", "b", "c"] {
1424            g.add_peer(name.to_string())
1425                .expect("test: add_peer should succeed");
1426        }
1427        for _ in 0..20 {
1428            g.record_interaction("a", true, 1.0)
1429                .expect("test: record positive interaction for a should succeed");
1430        }
1431        let top = g.top_peers(3);
1432        assert!(!top.is_empty());
1433        assert_eq!(top[0].peer_id, "a");
1434    }
1435
1436    #[test]
1437    fn test_top_peers_limit() {
1438        let mut g = make_graph();
1439        for i in 0..10 {
1440            g.add_peer(format!("peer{i}"))
1441                .expect("test: add_peer should succeed");
1442        }
1443        assert!(g.top_peers(5).len() <= 5);
1444    }
1445
1446    #[test]
1447    fn test_top_peers_fewer_than_n() {
1448        let mut g = make_graph();
1449        g.add_peer("a".to_string())
1450            .expect("test: add_peer a should succeed");
1451        let top = g.top_peers(10);
1452        assert_eq!(top.len(), 1);
1453    }
1454
1455    // ------- peers_trusting / peers_trusted_by -------
1456
1457    #[test]
1458    fn test_peers_trusting() {
1459        let mut g = make_graph();
1460        g.add_peer("a".to_string())
1461            .expect("test: add_peer a should succeed");
1462        g.add_peer("b".to_string())
1463            .expect("test: add_peer b should succeed");
1464        g.add_peer("c".to_string())
1465            .expect("test: add_peer c should succeed");
1466        g.add_edge("b", "a", 0.5)
1467            .expect("test: add_edge b->a should succeed");
1468        g.add_edge("c", "a", 0.7)
1469            .expect("test: add_edge c->a should succeed");
1470        let trusting = g.peers_trusting("a");
1471        assert!(trusting.contains(&"b".to_string()));
1472        assert!(trusting.contains(&"c".to_string()));
1473    }
1474
1475    #[test]
1476    fn test_peers_trusted_by() {
1477        let mut g = make_graph();
1478        g.add_peer("a".to_string())
1479            .expect("test: add_peer a should succeed");
1480        g.add_peer("b".to_string())
1481            .expect("test: add_peer b should succeed");
1482        g.add_peer("c".to_string())
1483            .expect("test: add_peer c should succeed");
1484        g.add_edge("a", "b", 0.5)
1485            .expect("test: add_edge a->b should succeed");
1486        g.add_edge("a", "c", 0.7)
1487            .expect("test: add_edge a->c should succeed");
1488        let trusted = g.peers_trusted_by("a");
1489        assert!(trusted.contains(&"b".to_string()));
1490        assert!(trusted.contains(&"c".to_string()));
1491    }
1492
1493    #[test]
1494    fn test_peers_trusting_empty() {
1495        let mut g = make_graph();
1496        g.add_peer("loner".to_string())
1497            .expect("test: add_peer loner should succeed");
1498        assert!(g.peers_trusting("loner").is_empty());
1499    }
1500
1501    // ------- stats -------
1502
1503    #[test]
1504    fn test_stats_empty() {
1505        let g = make_graph();
1506        let s = g.stats();
1507        assert_eq!(s.peer_count, 0);
1508        assert_eq!(s.edge_count, 0);
1509        assert_eq!(s.avg_out_degree, 0.0);
1510        assert_eq!(s.isolated_peers, 0);
1511    }
1512
1513    #[test]
1514    fn test_stats_peer_and_edge_count() {
1515        let mut g = make_graph();
1516        g.add_peer("a".to_string())
1517            .expect("test: add_peer a should succeed");
1518        g.add_peer("b".to_string())
1519            .expect("test: add_peer b should succeed");
1520        g.add_edge("a", "b", 0.5)
1521            .expect("test: add_edge a->b should succeed");
1522        let s = g.stats();
1523        assert_eq!(s.peer_count, 2);
1524        assert_eq!(s.edge_count, 1);
1525    }
1526
1527    #[test]
1528    fn test_stats_isolated_peers() {
1529        let mut g = make_graph();
1530        g.add_peer("a".to_string())
1531            .expect("test: add_peer a should succeed");
1532        g.add_peer("b".to_string())
1533            .expect("test: add_peer b should succeed");
1534        // Both start with direct_score = 0.5, so combined ≥ 0.1.
1535        // Set b's score very low to make it prunable.
1536        for _ in 0..50 {
1537            g.record_interaction("b", false, 1.0)
1538                .expect("test: record negative interaction for b should succeed");
1539        }
1540        let s = g.stats();
1541        // Both peers have no edges.
1542        assert_eq!(s.isolated_peers, 2);
1543    }
1544
1545    #[test]
1546    fn test_stats_top_peers_max_five() {
1547        let mut g = make_graph();
1548        for i in 0..10 {
1549            g.add_peer(format!("p{i}"))
1550                .expect("test: add_peer should succeed");
1551        }
1552        let s = g.stats();
1553        assert!(s.top_peers.len() <= 5);
1554    }
1555
1556    // ------- prune_isolated -------
1557
1558    #[test]
1559    fn test_prune_isolated_removes_low_score_no_edge() {
1560        let mut g = PeerReputationGraph::new(GraphConfig {
1561            trust_decay_factor: 0.5,
1562            ..Default::default()
1563        });
1564        g.add_peer("a".to_string())
1565            .expect("test: add_peer a should succeed");
1566        g.add_peer("b".to_string())
1567            .expect("test: add_peer b should succeed");
1568        // Give 'b' a very low score.
1569        for _ in 0..50 {
1570            g.record_interaction("b", false, 1.0)
1571                .expect("test: record negative interaction for b should succeed");
1572        }
1573        // 'a' stays at neutral 0.5 — above the 0.1 threshold → NOT pruned.
1574        let pruned = g.prune_isolated();
1575        assert!(pruned >= 1);
1576        assert!(!g.contains_peer("b"));
1577    }
1578
1579    #[test]
1580    fn test_prune_isolated_keeps_high_score() {
1581        let mut g = make_graph();
1582        g.add_peer("a".to_string())
1583            .expect("test: add_peer a should succeed");
1584        for _ in 0..50 {
1585            g.record_interaction("a", true, 1.0)
1586                .expect("test: record positive interaction for a should succeed");
1587        }
1588        let pruned = g.prune_isolated();
1589        // 'a' has a high score, should not be pruned.
1590        assert_eq!(pruned, 0);
1591        assert!(g.contains_peer("a"));
1592    }
1593
1594    #[test]
1595    fn test_prune_isolated_keeps_connected_peers() {
1596        let mut g = make_graph();
1597        g.add_peer("a".to_string())
1598            .expect("test: add_peer a should succeed");
1599        g.add_peer("b".to_string())
1600            .expect("test: add_peer b should succeed");
1601        g.add_edge("a", "b", 0.8)
1602            .expect("test: add_edge a->b should succeed");
1603        // Even if 'a' has a low score, it has an edge so it should NOT be pruned.
1604        for _ in 0..100 {
1605            g.record_interaction("a", false, 1.0)
1606                .expect("test: record negative interaction for a should succeed");
1607        }
1608        let pruned = g.prune_isolated();
1609        assert_eq!(pruned, 0);
1610        assert!(g.contains_peer("a"));
1611    }
1612
1613    // ------- error types -------
1614
1615    #[test]
1616    fn test_graph_error_display_peer_not_found() {
1617        let err = GraphError::PeerNotFound("x".to_string());
1618        assert!(err.to_string().contains("x"));
1619    }
1620
1621    #[test]
1622    fn test_graph_error_display_edge_not_found() {
1623        let err = GraphError::EdgeNotFound {
1624            from: "a".to_string(),
1625            to: "b".to_string(),
1626        };
1627        let s = err.to_string();
1628        assert!(s.contains("a"));
1629        assert!(s.contains("b"));
1630    }
1631
1632    #[test]
1633    fn test_graph_error_display_self_loop() {
1634        let err = GraphError::SelfLoop("a".to_string());
1635        assert!(err.to_string().contains("a"));
1636    }
1637
1638    #[test]
1639    fn test_graph_error_display_invalid_weight() {
1640        let err = GraphError::InvalidWeight(2.5);
1641        assert!(err.to_string().contains("2.5"));
1642    }
1643
1644    #[test]
1645    fn test_graph_error_max_peers() {
1646        assert_eq!(
1647            GraphError::MaxPeersExceeded.to_string(),
1648            "maximum peer count exceeded"
1649        );
1650    }
1651
1652    // ------- edge interaction count -------
1653
1654    #[test]
1655    fn test_edge_interaction_count() {
1656        let mut g = make_graph();
1657        g.add_peer("a".to_string())
1658            .expect("test: add_peer a should succeed");
1659        g.add_peer("b".to_string())
1660            .expect("test: add_peer b should succeed");
1661        g.add_edge("a", "b", 0.5)
1662            .expect("test: add_edge a->b 0.5 should succeed");
1663        g.add_edge("a", "b", 0.6)
1664            .expect("test: update edge a->b to 0.6 should succeed");
1665        g.add_edge("a", "b", 0.7)
1666            .expect("test: update edge a->b to 0.7 should succeed");
1667        assert_eq!(
1668            g.edge("a", "b")
1669                .expect("test: edge a->b should exist")
1670                .interaction_count,
1671            3
1672        );
1673    }
1674
1675    // ------- graph config defaults -------
1676
1677    #[test]
1678    fn test_graph_config_defaults() {
1679        let cfg = GraphConfig::default();
1680        assert!((cfg.trust_decay_factor - 0.99).abs() < 1e-9);
1681        assert_eq!(cfg.propagation_depth, 3);
1682        assert!((cfg.propagation_damping - 0.5).abs() < 1e-9);
1683        assert_eq!(cfg.max_peers, 10_000);
1684    }
1685
1686    // ------- xorshift64 -------
1687
1688    #[test]
1689    fn test_xorshift64_produces_nonzero() {
1690        let mut state = 12345u64;
1691        let v = xorshift64(&mut state);
1692        assert_ne!(v, 0);
1693        assert_ne!(state, 12345);
1694    }
1695
1696    #[test]
1697    fn test_xorshift64_sequence_unique() {
1698        let mut state = 0xdeadbeef_u64;
1699        let a = xorshift64(&mut state);
1700        let b = xorshift64(&mut state);
1701        let c = xorshift64(&mut state);
1702        // Very unlikely to repeat with a good PRNG.
1703        assert_ne!(a, b);
1704        assert_ne!(b, c);
1705    }
1706
1707    // ------- combined score formula -------
1708
1709    #[test]
1710    fn test_combined_score_weights() {
1711        let cfg = GraphConfig {
1712            combination_weight_direct: 1.0,
1713            combination_weight_propagated: 0.0,
1714            ..Default::default()
1715        };
1716        let mut g = PeerReputationGraph::new(cfg);
1717        g.add_peer("a".to_string())
1718            .expect("test: add_peer a should succeed");
1719        for _ in 0..50 {
1720            g.record_interaction("a", true, 1.0)
1721                .expect("test: record positive interaction for a should succeed");
1722        }
1723        g.propagate_trust();
1724        let r = g
1725            .reputation("a")
1726            .expect("test: reputation for a should exist");
1727        // combined should equal direct when propagated weight = 0.
1728        assert!((r.combined_score - r.direct_score).abs() < 1e-9);
1729    }
1730
1731    // ------- percentile -------
1732
1733    #[test]
1734    fn test_percentile_single_peer() {
1735        let mut g = make_graph();
1736        g.add_peer("a".to_string())
1737            .expect("test: add_peer a should succeed");
1738        let r = g
1739            .reputation("a")
1740            .expect("test: reputation for a should exist");
1741        assert_eq!(r.percentile, 0.5);
1742    }
1743
1744    #[test]
1745    fn test_percentile_ordering() {
1746        let mut g = make_graph();
1747        g.add_peer("low".to_string())
1748            .expect("test: add_peer low should succeed");
1749        g.add_peer("high".to_string())
1750            .expect("test: add_peer high should succeed");
1751        // Make 'high' have a much better score.
1752        for _ in 0..50 {
1753            g.record_interaction("high", true, 1.0)
1754                .expect("test: record positive interaction for high should succeed");
1755            g.record_interaction("low", false, 1.0)
1756                .expect("test: record negative interaction for low should succeed");
1757        }
1758        let low_pct = g
1759            .reputation("low")
1760            .expect("test: reputation for low should exist")
1761            .percentile;
1762        let high_pct = g
1763            .reputation("high")
1764            .expect("test: reputation for high should exist")
1765            .percentile;
1766        assert!(high_pct > low_pct);
1767    }
1768
1769    // ------- all_edges accessor -------
1770
1771    #[test]
1772    fn test_all_edges() {
1773        let mut g = make_graph();
1774        g.add_peer("a".to_string())
1775            .expect("test: add_peer a should succeed");
1776        g.add_peer("b".to_string())
1777            .expect("test: add_peer b should succeed");
1778        g.add_peer("c".to_string())
1779            .expect("test: add_peer c should succeed");
1780        g.add_edge("a", "b", 0.5)
1781            .expect("test: add_edge a->b should succeed");
1782        g.add_edge("b", "c", 0.3)
1783            .expect("test: add_edge b->c should succeed");
1784        let edges = g.all_edges();
1785        assert_eq!(edges.len(), 2);
1786    }
1787
1788    // ------- propagation damping -------
1789
1790    #[test]
1791    fn test_propagation_damping_reduces_with_hops() {
1792        // Verify damping: a SINGLE high-score source 'a' propagates to 'b' (1 hop)
1793        // more than it alone contributes to 'd' (3 hops) when b/c/d have near-zero
1794        // direct scores.  We give b/c/d many negative interactions to suppress their
1795        // own propagation contributions.
1796        let cfg = GraphConfig {
1797            propagation_depth: 3,
1798            propagation_damping: 0.5,
1799            ..Default::default()
1800        };
1801        let mut g = PeerReputationGraph::new(cfg);
1802        g.add_peer("a".to_string())
1803            .expect("test: add_peer a should succeed");
1804        g.add_peer("b".to_string())
1805            .expect("test: add_peer b should succeed");
1806        g.add_peer("c".to_string())
1807            .expect("test: add_peer c should succeed");
1808        g.add_peer("d".to_string())
1809            .expect("test: add_peer d should succeed");
1810        g.add_edge("a", "b", 1.0)
1811            .expect("test: add_edge a->b should succeed");
1812        g.add_edge("b", "c", 1.0)
1813            .expect("test: add_edge b->c should succeed");
1814        g.add_edge("c", "d", 1.0)
1815            .expect("test: add_edge c->d should succeed");
1816        // Boost 'a' to near 1.0.
1817        for _ in 0..100 {
1818            g.record_interaction("a", true, 1.0)
1819                .expect("test: record positive interaction for a should succeed");
1820        }
1821        // Drive b/c/d to near 0.0 so they contribute almost nothing.
1822        for _ in 0..100 {
1823            g.record_interaction("b", false, 1.0)
1824                .expect("test: record negative interaction for b should succeed");
1825            g.record_interaction("c", false, 1.0)
1826                .expect("test: record negative interaction for c should succeed");
1827            g.record_interaction("d", false, 1.0)
1828                .expect("test: record negative interaction for d should succeed");
1829        }
1830        g.propagate_trust();
1831        // From 'a' alone: b_contribution = a_direct * 0.5^1, d_contribution = a_direct * 0.5^3
1832        // (b/c/d near-zero means their BFS contributes negligibly).
1833        let b_prop = g
1834            .reputation("b")
1835            .expect("test: reputation for b should exist")
1836            .propagated_score;
1837        let d_prop = g
1838            .reputation("d")
1839            .expect("test: reputation for d should exist")
1840            .propagated_score;
1841        assert!(b_prop > d_prop, "b_prop={b_prop}, d_prop={d_prop}");
1842    }
1843}