Skip to main content

ipfrs_network/
behavior_classifier.rs

1//! Peer Behavior Classifier
2//!
3//! Classifies peers into behavioral categories based on observed interaction
4//! patterns, enabling smarter routing and eviction decisions.
5
6use std::collections::HashMap;
7
8/// Behavioral signals observed from peer interactions.
9#[derive(Clone, Debug, PartialEq)]
10pub enum BehaviorSignal {
11    /// Response latency < 50ms
12    FastResponder,
13    /// Response latency > 500ms
14    SlowResponder,
15    /// Transfer rate > 10 MB/s
16    HighBandwidth,
17    /// Transfer rate < 100 KB/s
18    LowBandwidth,
19    /// Disconnects/reconnects frequently
20    FrequentChurner,
21    /// No disconnects in observation window
22    StableConnection,
23    /// Downloads much more than uploads
24    DataHoarder,
25    /// Uploads >= downloads
26    GoodContributor,
27}
28
29impl BehaviorSignal {
30    /// Returns the discriminant index based on declaration order for sorting.
31    fn discriminant_index(&self) -> usize {
32        match self {
33            BehaviorSignal::FastResponder => 0,
34            BehaviorSignal::SlowResponder => 1,
35            BehaviorSignal::HighBandwidth => 2,
36            BehaviorSignal::LowBandwidth => 3,
37            BehaviorSignal::FrequentChurner => 4,
38            BehaviorSignal::StableConnection => 5,
39            BehaviorSignal::DataHoarder => 6,
40            BehaviorSignal::GoodContributor => 7,
41        }
42    }
43}
44
45/// Profile of a peer's observed behavior signals.
46#[derive(Clone, Debug)]
47pub struct BehaviorProfile {
48    /// Peer identifier.
49    pub peer_id: String,
50    /// All observed signals (duplicates allowed — frequency matters).
51    pub signals: Vec<BehaviorSignal>,
52    /// Total number of observations recorded.
53    pub observation_count: u64,
54}
55
56impl BehaviorProfile {
57    /// Returns `true` if the profile contains the given signal at least once.
58    pub fn has_signal(&self, signal: &BehaviorSignal) -> bool {
59        self.signals.contains(signal)
60    }
61
62    /// Returns the total number of signals stored (including duplicates).
63    pub fn signal_count(&self) -> usize {
64        self.signals.len()
65    }
66}
67
68/// Aggregate statistics over all classified peers.
69#[derive(Clone, Debug, Default)]
70pub struct ClassifierStats {
71    /// Total number of tracked peers.
72    pub total_peers: usize,
73    /// Peers whose `classify()` output includes `FastResponder`.
74    pub fast_responders: usize,
75    /// Peers whose `classify()` output includes `HighBandwidth`.
76    pub high_bandwidth_peers: usize,
77    /// Peers whose `classify()` output includes `GoodContributor`.
78    pub good_contributors: usize,
79    /// Peers whose `classify()` output includes `FrequentChurner`.
80    pub churners: usize,
81}
82
83/// Classifies peers into behavioral categories based on recorded signals.
84///
85/// # Example
86///
87/// ```rust
88/// use ipfrs_network::behavior_classifier::{PeerBehaviorClassifier, BehaviorSignal};
89///
90/// let mut classifier = PeerBehaviorClassifier::new();
91/// classifier.record_signal("peer-1", BehaviorSignal::FastResponder);
92/// classifier.record_signal("peer-1", BehaviorSignal::FastResponder);
93/// let classified = classifier.classify("peer-1");
94/// assert!(classified.contains(&BehaviorSignal::FastResponder));
95/// ```
96#[derive(Debug, Default)]
97pub struct PeerBehaviorClassifier {
98    /// Profiles keyed by peer_id.
99    pub profiles: HashMap<String, BehaviorProfile>,
100}
101
102impl PeerBehaviorClassifier {
103    /// Creates a new, empty classifier.
104    pub fn new() -> Self {
105        Self {
106            profiles: HashMap::new(),
107        }
108    }
109
110    /// Records a behavioral signal for a peer.
111    ///
112    /// If no profile exists for `peer_id`, one is created automatically.
113    /// Duplicates are allowed — signal frequency is meaningful.
114    pub fn record_signal(&mut self, peer_id: &str, signal: BehaviorSignal) {
115        let profile = self
116            .profiles
117            .entry(peer_id.to_string())
118            .or_insert_with(|| BehaviorProfile {
119                peer_id: peer_id.to_string(),
120                signals: Vec::new(),
121                observation_count: 0,
122            });
123        profile.signals.push(signal);
124        profile.observation_count += 1;
125    }
126
127    /// Returns the "consistent" signals for a peer — those appearing more than
128    /// once in the recorded history.
129    ///
130    /// The returned `Vec` contains each qualifying signal exactly once, sorted
131    /// by declaration order (discriminant index).
132    ///
133    /// Returns an empty `Vec` for unknown peers.
134    pub fn classify(&self, peer_id: &str) -> Vec<BehaviorSignal> {
135        let profile = match self.profiles.get(peer_id) {
136            Some(p) => p,
137            None => return Vec::new(),
138        };
139
140        // Count occurrences of each variant.
141        let mut counts: HashMap<usize, (usize, BehaviorSignal)> = HashMap::new();
142        for signal in &profile.signals {
143            let idx = signal.discriminant_index();
144            let entry = counts.entry(idx).or_insert_with(|| (0, signal.clone()));
145            entry.0 += 1;
146        }
147
148        // Collect signals that appear more than once.
149        let mut result: Vec<(usize, BehaviorSignal)> = counts
150            .into_iter()
151            .filter_map(
152                |(idx, (count, signal))| {
153                    if count > 1 {
154                        Some((idx, signal))
155                    } else {
156                        None
157                    }
158                },
159            )
160            .collect();
161
162        // Sort by declaration order.
163        result.sort_by_key(|(idx, _)| *idx);
164        result.into_iter().map(|(_, signal)| signal).collect()
165    }
166
167    /// Returns peer IDs where `classify()` includes the given signal, sorted
168    /// alphabetically.
169    pub fn peers_with_signal(&self, signal: &BehaviorSignal) -> Vec<&str> {
170        let mut result: Vec<&str> = self
171            .profiles
172            .keys()
173            .filter(|peer_id| self.classify(peer_id).contains(signal))
174            .map(|s| s.as_str())
175            .collect();
176        result.sort_unstable();
177        result
178    }
179
180    /// Removes a peer profile. Returns `true` if the peer existed.
181    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
182        self.profiles.remove(peer_id).is_some()
183    }
184
185    /// Returns aggregate statistics across all tracked peers.
186    pub fn stats(&self) -> ClassifierStats {
187        let mut stats = ClassifierStats {
188            total_peers: self.profiles.len(),
189            ..Default::default()
190        };
191
192        for peer_id in self.profiles.keys() {
193            let classified = self.classify(peer_id);
194            for signal in &classified {
195                match signal {
196                    BehaviorSignal::FastResponder => stats.fast_responders += 1,
197                    BehaviorSignal::HighBandwidth => stats.high_bandwidth_peers += 1,
198                    BehaviorSignal::GoodContributor => stats.good_contributors += 1,
199                    BehaviorSignal::FrequentChurner => stats.churners += 1,
200                    _ => {}
201                }
202            }
203        }
204
205        stats
206    }
207
208    /// Returns an immutable reference to a peer's profile, or `None` if
209    /// unknown.
210    pub fn profile(&self, peer_id: &str) -> Option<&BehaviorProfile> {
211        self.profiles.get(peer_id)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    // -------------------------------------------------------------------------
220    // Construction
221    // -------------------------------------------------------------------------
222
223    #[test]
224    fn test_new_starts_empty() {
225        let classifier = PeerBehaviorClassifier::new();
226        assert!(classifier.profiles.is_empty());
227    }
228
229    // -------------------------------------------------------------------------
230    // record_signal
231    // -------------------------------------------------------------------------
232
233    #[test]
234    fn test_record_signal_creates_profile() {
235        let mut c = PeerBehaviorClassifier::new();
236        c.record_signal("peer-a", BehaviorSignal::FastResponder);
237        assert!(c.profile("peer-a").is_some());
238    }
239
240    #[test]
241    fn test_record_signal_increments_observation_count() {
242        let mut c = PeerBehaviorClassifier::new();
243        c.record_signal("peer-a", BehaviorSignal::FastResponder);
244        c.record_signal("peer-a", BehaviorSignal::SlowResponder);
245        assert_eq!(
246            c.profile("peer-a")
247                .expect("test: peer-a profile should exist")
248                .observation_count,
249            2
250        );
251    }
252
253    #[test]
254    fn test_record_signal_appends_to_signals() {
255        let mut c = PeerBehaviorClassifier::new();
256        c.record_signal("peer-a", BehaviorSignal::HighBandwidth);
257        c.record_signal("peer-a", BehaviorSignal::HighBandwidth);
258        assert_eq!(
259            c.profile("peer-a")
260                .expect("test: peer-a profile should exist")
261                .signals
262                .len(),
263            2
264        );
265    }
266
267    #[test]
268    fn test_observation_count_accumulates_across_many_signals() {
269        let mut c = PeerBehaviorClassifier::new();
270        for _ in 0..10 {
271            c.record_signal("peer-x", BehaviorSignal::StableConnection);
272        }
273        assert_eq!(
274            c.profile("peer-x")
275                .expect("test: peer-x profile should exist")
276                .observation_count,
277            10
278        );
279    }
280
281    // -------------------------------------------------------------------------
282    // classify
283    // -------------------------------------------------------------------------
284
285    #[test]
286    fn test_classify_single_signal_not_in_result() {
287        let mut c = PeerBehaviorClassifier::new();
288        c.record_signal("peer-a", BehaviorSignal::FastResponder);
289        assert!(c.classify("peer-a").is_empty());
290    }
291
292    #[test]
293    fn test_classify_signal_appearing_twice_is_included() {
294        let mut c = PeerBehaviorClassifier::new();
295        c.record_signal("peer-a", BehaviorSignal::FastResponder);
296        c.record_signal("peer-a", BehaviorSignal::FastResponder);
297        assert!(c
298            .classify("peer-a")
299            .contains(&BehaviorSignal::FastResponder));
300    }
301
302    #[test]
303    fn test_classify_multiple_distinct_signals() {
304        let mut c = PeerBehaviorClassifier::new();
305        for _ in 0..2 {
306            c.record_signal("peer-a", BehaviorSignal::HighBandwidth);
307            c.record_signal("peer-a", BehaviorSignal::GoodContributor);
308        }
309        let classified = c.classify("peer-a");
310        assert!(classified.contains(&BehaviorSignal::HighBandwidth));
311        assert!(classified.contains(&BehaviorSignal::GoodContributor));
312    }
313
314    #[test]
315    fn test_classify_returns_empty_for_unknown_peer() {
316        let c = PeerBehaviorClassifier::new();
317        assert!(c.classify("ghost-peer").is_empty());
318    }
319
320    #[test]
321    fn test_classify_returns_sorted_by_declaration_order() {
322        let mut c = PeerBehaviorClassifier::new();
323        // Add in reverse declaration order to confirm sorting.
324        for _ in 0..2 {
325            c.record_signal("peer-a", BehaviorSignal::GoodContributor); // idx 7
326            c.record_signal("peer-a", BehaviorSignal::DataHoarder); // idx 6
327            c.record_signal("peer-a", BehaviorSignal::FastResponder); // idx 0
328        }
329        let classified = c.classify("peer-a");
330        assert_eq!(classified.len(), 3);
331        assert_eq!(classified[0], BehaviorSignal::FastResponder);
332        assert_eq!(classified[1], BehaviorSignal::DataHoarder);
333        assert_eq!(classified[2], BehaviorSignal::GoodContributor);
334    }
335
336    #[test]
337    fn test_classify_threshold_exactly_two_observations() {
338        let mut c = PeerBehaviorClassifier::new();
339        // Exactly 2 occurrences → should be included (> 1).
340        c.record_signal("peer-a", BehaviorSignal::LowBandwidth);
341        c.record_signal("peer-a", BehaviorSignal::LowBandwidth);
342        assert!(c.classify("peer-a").contains(&BehaviorSignal::LowBandwidth));
343    }
344
345    // -------------------------------------------------------------------------
346    // peers_with_signal
347    // -------------------------------------------------------------------------
348
349    #[test]
350    fn test_peers_with_signal_returns_correct_peers_sorted() {
351        let mut c = PeerBehaviorClassifier::new();
352        for id in &["charlie", "alice", "bob"] {
353            c.record_signal(id, BehaviorSignal::FastResponder);
354            c.record_signal(id, BehaviorSignal::FastResponder);
355        }
356        let peers = c.peers_with_signal(&BehaviorSignal::FastResponder);
357        assert_eq!(peers, vec!["alice", "bob", "charlie"]);
358    }
359
360    #[test]
361    fn test_peers_with_signal_returns_empty_when_none_qualify() {
362        let mut c = PeerBehaviorClassifier::new();
363        // Only one observation — doesn't meet threshold.
364        c.record_signal("peer-a", BehaviorSignal::FastResponder);
365        assert!(c
366            .peers_with_signal(&BehaviorSignal::FastResponder)
367            .is_empty());
368    }
369
370    #[test]
371    fn test_peers_with_signal_excludes_non_qualifying_peers() {
372        let mut c = PeerBehaviorClassifier::new();
373        // peer-a qualifies; peer-b does not.
374        c.record_signal("peer-a", BehaviorSignal::HighBandwidth);
375        c.record_signal("peer-a", BehaviorSignal::HighBandwidth);
376        c.record_signal("peer-b", BehaviorSignal::HighBandwidth); // only once
377        let peers = c.peers_with_signal(&BehaviorSignal::HighBandwidth);
378        assert_eq!(peers, vec!["peer-a"]);
379    }
380
381    // -------------------------------------------------------------------------
382    // remove_peer
383    // -------------------------------------------------------------------------
384
385    #[test]
386    fn test_remove_peer_existing_returns_true() {
387        let mut c = PeerBehaviorClassifier::new();
388        c.record_signal("peer-a", BehaviorSignal::FastResponder);
389        assert!(c.remove_peer("peer-a"));
390    }
391
392    #[test]
393    fn test_remove_peer_nonexistent_returns_false() {
394        let mut c = PeerBehaviorClassifier::new();
395        assert!(!c.remove_peer("ghost"));
396    }
397
398    #[test]
399    fn test_remove_peer_actually_removes_profile() {
400        let mut c = PeerBehaviorClassifier::new();
401        c.record_signal("peer-a", BehaviorSignal::FastResponder);
402        c.remove_peer("peer-a");
403        assert!(c.profile("peer-a").is_none());
404    }
405
406    // -------------------------------------------------------------------------
407    // profile
408    // -------------------------------------------------------------------------
409
410    #[test]
411    fn test_profile_returns_some_for_known_peer() {
412        let mut c = PeerBehaviorClassifier::new();
413        c.record_signal("peer-a", BehaviorSignal::FastResponder);
414        assert!(c.profile("peer-a").is_some());
415    }
416
417    #[test]
418    fn test_profile_returns_none_for_unknown_peer() {
419        let c = PeerBehaviorClassifier::new();
420        assert!(c.profile("ghost").is_none());
421    }
422
423    // -------------------------------------------------------------------------
424    // stats
425    // -------------------------------------------------------------------------
426
427    #[test]
428    fn test_stats_total_peers() {
429        let mut c = PeerBehaviorClassifier::new();
430        c.record_signal("peer-1", BehaviorSignal::FastResponder);
431        c.record_signal("peer-2", BehaviorSignal::SlowResponder);
432        assert_eq!(c.stats().total_peers, 2);
433    }
434
435    #[test]
436    fn test_stats_fast_responders_count() {
437        let mut c = PeerBehaviorClassifier::new();
438        for id in &["p1", "p2"] {
439            c.record_signal(id, BehaviorSignal::FastResponder);
440            c.record_signal(id, BehaviorSignal::FastResponder);
441        }
442        c.record_signal("p3", BehaviorSignal::FastResponder); // only once
443        assert_eq!(c.stats().fast_responders, 2);
444    }
445
446    #[test]
447    fn test_stats_high_bandwidth_peers_count() {
448        let mut c = PeerBehaviorClassifier::new();
449        c.record_signal("p1", BehaviorSignal::HighBandwidth);
450        c.record_signal("p1", BehaviorSignal::HighBandwidth);
451        assert_eq!(c.stats().high_bandwidth_peers, 1);
452    }
453
454    #[test]
455    fn test_stats_good_contributors_count() {
456        let mut c = PeerBehaviorClassifier::new();
457        c.record_signal("p1", BehaviorSignal::GoodContributor);
458        c.record_signal("p1", BehaviorSignal::GoodContributor);
459        c.record_signal("p2", BehaviorSignal::GoodContributor);
460        c.record_signal("p2", BehaviorSignal::GoodContributor);
461        assert_eq!(c.stats().good_contributors, 2);
462    }
463
464    #[test]
465    fn test_stats_churners_count() {
466        let mut c = PeerBehaviorClassifier::new();
467        c.record_signal("p1", BehaviorSignal::FrequentChurner);
468        c.record_signal("p1", BehaviorSignal::FrequentChurner);
469        assert_eq!(c.stats().churners, 1);
470    }
471
472    // -------------------------------------------------------------------------
473    // BehaviorProfile helpers
474    // -------------------------------------------------------------------------
475
476    #[test]
477    fn test_has_signal_true() {
478        let mut c = PeerBehaviorClassifier::new();
479        c.record_signal("peer-a", BehaviorSignal::StableConnection);
480        let profile = c
481            .profile("peer-a")
482            .expect("test: peer-a profile should exist");
483        assert!(profile.has_signal(&BehaviorSignal::StableConnection));
484    }
485
486    #[test]
487    fn test_has_signal_false() {
488        let mut c = PeerBehaviorClassifier::new();
489        c.record_signal("peer-a", BehaviorSignal::StableConnection);
490        let profile = c
491            .profile("peer-a")
492            .expect("test: peer-a profile should exist");
493        assert!(!profile.has_signal(&BehaviorSignal::FastResponder));
494    }
495
496    #[test]
497    fn test_signal_count() {
498        let mut c = PeerBehaviorClassifier::new();
499        c.record_signal("peer-a", BehaviorSignal::DataHoarder);
500        c.record_signal("peer-a", BehaviorSignal::DataHoarder);
501        c.record_signal("peer-a", BehaviorSignal::GoodContributor);
502        let profile = c
503            .profile("peer-a")
504            .expect("test: peer-a profile should exist");
505        assert_eq!(profile.signal_count(), 3);
506    }
507}