Skip to main content

ipfrs_network/
gossip_metrics.rs

1//! Gossip protocol efficiency metrics.
2//!
3//! Tracks message propagation rates, duplicate detection, and coverage estimation
4//! across the peer gossip overlay network.
5
6use std::collections::HashMap;
7
8/// Events emitted by the gossip protocol layer.
9#[derive(Clone, Debug, PartialEq)]
10pub enum GossipEvent {
11    /// A message was received from a peer.
12    Received {
13        /// Unique message identifier.
14        msg_id: u64,
15        /// Peer that sent this message.
16        from_peer: String,
17        /// Number of hops this message has traversed.
18        hop_count: u32,
19    },
20    /// A message was forwarded to one or more peers.
21    Forwarded {
22        /// Unique message identifier.
23        msg_id: u64,
24        /// Peers the message was forwarded to.
25        to_peers: Vec<String>,
26    },
27    /// A duplicate message was received (already seen).
28    Duplicate {
29        /// Unique message identifier.
30        msg_id: u64,
31        /// Peer that sent the duplicate.
32        from_peer: String,
33    },
34    /// A message has expired from the gossip cache.
35    Expired {
36        /// Unique message identifier.
37        msg_id: u64,
38    },
39}
40
41/// Per-message trace accumulating all observations of a given message.
42#[derive(Clone, Debug, PartialEq)]
43pub struct MessageTrace {
44    /// Unique message identifier.
45    pub msg_id: u64,
46    /// Logical tick at which this message was first seen.
47    pub first_seen_tick: u64,
48    /// Total times the message was received (including duplicates).
49    pub received_count: u32,
50    /// Number of times this message was forwarded.
51    pub forward_count: u32,
52    /// Number of duplicate receptions detected.
53    pub duplicate_count: u32,
54    /// Minimum hop count observed across all receptions.
55    pub min_hop_count: u32,
56}
57
58impl MessageTrace {
59    /// Fraction of receptions that were duplicates.
60    ///
61    /// Returns `duplicate_count / received_count.max(1)` as an `f64`.
62    pub fn redundancy_ratio(&self) -> f64 {
63        self.duplicate_count as f64 / self.received_count.max(1) as f64
64    }
65}
66
67/// Point-in-time snapshot of aggregated gossip metrics.
68#[derive(Clone, Debug, PartialEq)]
69pub struct GossipMetricsSnapshot {
70    /// Total distinct messages seen.
71    pub total_messages: u64,
72    /// Total duplicate receptions across all messages.
73    pub total_duplicates: u64,
74    /// Total forward operations across all messages.
75    pub total_forwarded: u64,
76    /// Average minimum hop count across all message traces.
77    pub avg_hop_count: f64,
78    /// Ratio of duplicates to total messages (`total_duplicates / total_messages.max(1)`).
79    pub duplicate_rate: f64,
80}
81
82impl GossipMetricsSnapshot {
83    /// Simple coverage approximation: `1.0 - duplicate_rate`.
84    ///
85    /// A higher duplicate rate implies more redundant paths, suggesting lower
86    /// marginal coverage gain per message.
87    pub fn estimated_coverage(&self) -> f64 {
88        1.0 - self.duplicate_rate
89    }
90}
91
92/// Tracks gossip protocol efficiency across all observed messages.
93#[derive(Debug)]
94pub struct PeerGossipMetrics {
95    /// Per-message traces, keyed by `msg_id`.
96    pub traces: HashMap<u64, MessageTrace>,
97    /// Current logical tick (monotonically increasing).
98    pub current_tick: u64,
99}
100
101impl PeerGossipMetrics {
102    /// Create a new, empty `PeerGossipMetrics` instance.
103    pub fn new() -> Self {
104        Self {
105            traces: HashMap::new(),
106            current_tick: 0,
107        }
108    }
109
110    /// Record a gossip event, updating the relevant message trace.
111    ///
112    /// # Behaviour by variant
113    ///
114    /// - `Received`: Upsert the trace.  Increment `received_count` and update
115    ///   `min_hop_count`.  If this is **not** the first reception (i.e.
116    ///   `received_count` was already ≥ 1 before this event), also increment
117    ///   `duplicate_count`.
118    /// - `Forwarded`: Increment `forward_count` for the trace (no-op if the
119    ///   trace does not yet exist).
120    /// - `Duplicate`: Increment `duplicate_count` for the trace (no-op if the
121    ///   trace does not yet exist).
122    /// - `Expired`: No-op; the trace is retained for historical queries.
123    pub fn record_event(&mut self, event: GossipEvent) {
124        match event {
125            GossipEvent::Received {
126                msg_id,
127                from_peer: _,
128                hop_count,
129            } => {
130                let tick = self.current_tick;
131                let trace = self.traces.entry(msg_id).or_insert_with(|| MessageTrace {
132                    msg_id,
133                    first_seen_tick: tick,
134                    received_count: 0,
135                    forward_count: 0,
136                    duplicate_count: 0,
137                    min_hop_count: hop_count,
138                });
139
140                // If already received at least once this is a duplicate reception.
141                if trace.received_count >= 1 {
142                    trace.duplicate_count += 1;
143                }
144
145                trace.received_count += 1;
146
147                if hop_count < trace.min_hop_count {
148                    trace.min_hop_count = hop_count;
149                }
150            }
151
152            GossipEvent::Forwarded { msg_id, to_peers } => {
153                if let Some(trace) = self.traces.get_mut(&msg_id) {
154                    trace.forward_count += to_peers.len() as u32;
155                }
156            }
157
158            GossipEvent::Duplicate {
159                msg_id,
160                from_peer: _,
161            } => {
162                if let Some(trace) = self.traces.get_mut(&msg_id) {
163                    trace.duplicate_count += 1;
164                }
165            }
166
167            GossipEvent::Expired { msg_id: _ } => {
168                // Intentionally a no-op; traces are kept for historical access.
169            }
170        }
171    }
172
173    /// Advance the logical tick by one.
174    pub fn advance_tick(&mut self) {
175        self.current_tick += 1;
176    }
177
178    /// Build an aggregated snapshot of current metrics.
179    pub fn snapshot(&self) -> GossipMetricsSnapshot {
180        let total_messages = self.traces.len() as u64;
181        let total_duplicates: u64 = self.traces.values().map(|t| t.duplicate_count as u64).sum();
182        let total_forwarded: u64 = self.traces.values().map(|t| t.forward_count as u64).sum();
183
184        let avg_hop_count = if self.traces.is_empty() {
185            0.0
186        } else {
187            let sum: u64 = self.traces.values().map(|t| t.min_hop_count as u64).sum();
188            sum as f64 / total_messages as f64
189        };
190
191        let duplicate_rate = total_duplicates as f64 / total_messages.max(1) as f64;
192
193        GossipMetricsSnapshot {
194            total_messages,
195            total_duplicates,
196            total_forwarded,
197            avg_hop_count,
198            duplicate_rate,
199        }
200    }
201
202    /// Return up to `n` message traces sorted descending by `redundancy_ratio`.
203    pub fn top_messages_by_redundancy(&self, n: usize) -> Vec<&MessageTrace> {
204        let mut traces: Vec<&MessageTrace> = self.traces.values().collect();
205        traces.sort_by(|a, b| {
206            b.redundancy_ratio()
207                .partial_cmp(&a.redundancy_ratio())
208                .unwrap_or(std::cmp::Ordering::Equal)
209        });
210        traces.truncate(n);
211        traces
212    }
213
214    /// Look up the trace for a specific message, if one exists.
215    pub fn message_trace(&self, msg_id: u64) -> Option<&MessageTrace> {
216        self.traces.get(&msg_id)
217    }
218}
219
220impl Default for PeerGossipMetrics {
221    fn default() -> Self {
222        Self::new()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // ── helpers ───────────────────────────────────────────────────────────────
231
232    fn recv(msg_id: u64, from: &str, hop: u32) -> GossipEvent {
233        GossipEvent::Received {
234            msg_id,
235            from_peer: from.to_string(),
236            hop_count: hop,
237        }
238    }
239
240    fn fwd(msg_id: u64, peers: &[&str]) -> GossipEvent {
241        GossipEvent::Forwarded {
242            msg_id,
243            to_peers: peers.iter().map(|s| s.to_string()).collect(),
244        }
245    }
246
247    fn dup(msg_id: u64, from: &str) -> GossipEvent {
248        GossipEvent::Duplicate {
249            msg_id,
250            from_peer: from.to_string(),
251        }
252    }
253
254    fn exp(msg_id: u64) -> GossipEvent {
255        GossipEvent::Expired { msg_id }
256    }
257
258    // ── test 1: Received creates a trace ─────────────────────────────────────
259
260    #[test]
261    fn received_creates_trace() {
262        let mut m = PeerGossipMetrics::new();
263        m.record_event(recv(1, "peer-A", 2));
264
265        let trace = m.message_trace(1).expect("trace should exist");
266        assert_eq!(trace.msg_id, 1);
267        assert_eq!(trace.received_count, 1);
268        assert_eq!(trace.duplicate_count, 0);
269        assert_eq!(trace.min_hop_count, 2);
270        assert_eq!(trace.first_seen_tick, 0);
271    }
272
273    // ── test 2: second Received increments duplicate_count ───────────────────
274
275    #[test]
276    fn second_received_increments_duplicate_count() {
277        let mut m = PeerGossipMetrics::new();
278        m.record_event(recv(1, "peer-A", 2));
279        m.record_event(recv(1, "peer-B", 3));
280
281        let trace = m.message_trace(1).expect("trace should exist");
282        assert_eq!(trace.received_count, 2);
283        assert_eq!(trace.duplicate_count, 1);
284    }
285
286    // ── test 3: third Received further increments duplicate_count ────────────
287
288    #[test]
289    fn third_received_increments_duplicate_count_again() {
290        let mut m = PeerGossipMetrics::new();
291        m.record_event(recv(1, "peer-A", 2));
292        m.record_event(recv(1, "peer-B", 3));
293        m.record_event(recv(1, "peer-C", 1));
294
295        let trace = m.message_trace(1).expect("trace should exist");
296        assert_eq!(trace.received_count, 3);
297        assert_eq!(trace.duplicate_count, 2);
298    }
299
300    // ── test 4: Forwarded updates forward_count ───────────────────────────────
301
302    #[test]
303    fn forwarded_updates_forward_count() {
304        let mut m = PeerGossipMetrics::new();
305        m.record_event(recv(2, "peer-A", 1));
306        m.record_event(fwd(2, &["peer-B", "peer-C"]));
307
308        let trace = m.message_trace(2).expect("trace should exist");
309        assert_eq!(trace.forward_count, 2);
310    }
311
312    // ── test 5: Forwarded to single peer ─────────────────────────────────────
313
314    #[test]
315    fn forwarded_single_peer() {
316        let mut m = PeerGossipMetrics::new();
317        m.record_event(recv(3, "peer-A", 0));
318        m.record_event(fwd(3, &["peer-B"]));
319
320        let trace = m.message_trace(3).expect("trace should exist");
321        assert_eq!(trace.forward_count, 1);
322    }
323
324    // ── test 6: explicit Duplicate increments duplicate_count ─────────────────
325
326    #[test]
327    fn explicit_duplicate_increments_count() {
328        let mut m = PeerGossipMetrics::new();
329        m.record_event(recv(4, "peer-A", 1));
330        m.record_event(dup(4, "peer-B"));
331
332        let trace = m.message_trace(4).expect("trace should exist");
333        assert_eq!(trace.duplicate_count, 1);
334    }
335
336    // ── test 7: multiple explicit Duplicates ──────────────────────────────────
337
338    #[test]
339    fn multiple_explicit_duplicates() {
340        let mut m = PeerGossipMetrics::new();
341        m.record_event(recv(5, "peer-A", 2));
342        m.record_event(dup(5, "peer-B"));
343        m.record_event(dup(5, "peer-C"));
344
345        let trace = m.message_trace(5).expect("trace should exist");
346        assert_eq!(trace.duplicate_count, 2);
347    }
348
349    // ── test 8: min_hop_count tracks smallest value ───────────────────────────
350
351    #[test]
352    fn min_hop_count_tracks_minimum() {
353        let mut m = PeerGossipMetrics::new();
354        m.record_event(recv(6, "peer-A", 5));
355        m.record_event(recv(6, "peer-B", 3));
356        m.record_event(recv(6, "peer-C", 7));
357
358        let trace = m.message_trace(6).expect("trace should exist");
359        assert_eq!(trace.min_hop_count, 3);
360    }
361
362    // ── test 9: min_hop_count set on first reception ──────────────────────────
363
364    #[test]
365    fn min_hop_count_initial_value() {
366        let mut m = PeerGossipMetrics::new();
367        m.record_event(recv(7, "peer-A", 4));
368
369        let trace = m.message_trace(7).expect("trace should exist");
370        assert_eq!(trace.min_hop_count, 4);
371    }
372
373    // ── test 10: redundancy_ratio ─────────────────────────────────────────────
374
375    #[test]
376    fn redundancy_ratio_correct() {
377        let mut m = PeerGossipMetrics::new();
378        m.record_event(recv(8, "peer-A", 1));
379        m.record_event(recv(8, "peer-B", 1));
380        m.record_event(recv(8, "peer-C", 1));
381        // received_count = 3, duplicate_count = 2
382        let trace = m.message_trace(8).expect("trace should exist");
383        let expected = 2.0 / 3.0;
384        assert!((trace.redundancy_ratio() - expected).abs() < 1e-10);
385    }
386
387    // ── test 11: redundancy_ratio zero when no duplicates ────────────────────
388
389    #[test]
390    fn redundancy_ratio_zero_no_duplicates() {
391        let mut m = PeerGossipMetrics::new();
392        m.record_event(recv(9, "peer-A", 1));
393
394        let trace = m.message_trace(9).expect("trace should exist");
395        assert_eq!(trace.redundancy_ratio(), 0.0);
396    }
397
398    // ── test 12: snapshot totals ──────────────────────────────────────────────
399
400    #[test]
401    fn snapshot_totals() {
402        let mut m = PeerGossipMetrics::new();
403        // msg 10: received twice (1 dup), forwarded to 3
404        m.record_event(recv(10, "peer-A", 1));
405        m.record_event(recv(10, "peer-B", 2));
406        m.record_event(fwd(10, &["peer-C", "peer-D", "peer-E"]));
407        // msg 11: received once, forwarded to 1
408        m.record_event(recv(11, "peer-A", 2));
409        m.record_event(fwd(11, &["peer-F"]));
410
411        let snap = m.snapshot();
412        assert_eq!(snap.total_messages, 2);
413        assert_eq!(snap.total_duplicates, 1);
414        assert_eq!(snap.total_forwarded, 4);
415    }
416
417    // ── test 13: snapshot duplicate_rate ─────────────────────────────────────
418
419    #[test]
420    fn snapshot_duplicate_rate() {
421        let mut m = PeerGossipMetrics::new();
422        m.record_event(recv(12, "peer-A", 1));
423        m.record_event(recv(12, "peer-B", 1));
424        m.record_event(recv(13, "peer-A", 1));
425
426        let snap = m.snapshot();
427        // total_messages = 2, total_duplicates = 1
428        let expected = 1.0 / 2.0;
429        assert!((snap.duplicate_rate - expected).abs() < 1e-10);
430    }
431
432    // ── test 14: estimated_coverage ──────────────────────────────────────────
433
434    #[test]
435    fn estimated_coverage() {
436        let mut m = PeerGossipMetrics::new();
437        m.record_event(recv(14, "peer-A", 1));
438        m.record_event(recv(14, "peer-B", 1)); // 1 dup in 1 msg → dup_rate = 1.0
439
440        let snap = m.snapshot();
441        // coverage = 1.0 - 1.0 = 0.0
442        assert!((snap.estimated_coverage() - 0.0).abs() < 1e-10);
443    }
444
445    // ── test 15: estimated_coverage no duplicates ─────────────────────────────
446
447    #[test]
448    fn estimated_coverage_no_duplicates() {
449        let mut m = PeerGossipMetrics::new();
450        m.record_event(recv(15, "peer-A", 1));
451        m.record_event(recv(16, "peer-B", 1));
452
453        let snap = m.snapshot();
454        assert!((snap.estimated_coverage() - 1.0).abs() < 1e-10);
455    }
456
457    // ── test 16: top_messages_by_redundancy ordering ──────────────────────────
458
459    #[test]
460    fn top_messages_by_redundancy_ordered() {
461        let mut m = PeerGossipMetrics::new();
462        // msg 20: received once  → redundancy 0/1 = 0.0
463        m.record_event(recv(20, "peer-A", 1));
464        // msg 21: received twice → redundancy 1/2 = 0.5
465        m.record_event(recv(21, "peer-A", 1));
466        m.record_event(recv(21, "peer-B", 1));
467        // msg 22: received three times → redundancy 2/3 ≈ 0.667
468        m.record_event(recv(22, "peer-A", 1));
469        m.record_event(recv(22, "peer-B", 1));
470        m.record_event(recv(22, "peer-C", 1));
471
472        let top = m.top_messages_by_redundancy(3);
473        assert_eq!(top.len(), 3);
474        assert_eq!(top[0].msg_id, 22);
475        assert_eq!(top[1].msg_id, 21);
476        assert_eq!(top[2].msg_id, 20);
477    }
478
479    // ── test 17: top_messages_by_redundancy respects n ────────────────────────
480
481    #[test]
482    fn top_messages_by_redundancy_truncates() {
483        let mut m = PeerGossipMetrics::new();
484        for id in 30..36_u64 {
485            m.record_event(recv(id, "peer-A", 1));
486        }
487
488        let top = m.top_messages_by_redundancy(3);
489        assert_eq!(top.len(), 3);
490    }
491
492    // ── test 18: message_trace Some ───────────────────────────────────────────
493
494    #[test]
495    fn message_trace_some() {
496        let mut m = PeerGossipMetrics::new();
497        m.record_event(recv(40, "peer-A", 1));
498        assert!(m.message_trace(40).is_some());
499    }
500
501    // ── test 19: message_trace None ───────────────────────────────────────────
502
503    #[test]
504    fn message_trace_none() {
505        let m = PeerGossipMetrics::new();
506        assert!(m.message_trace(999).is_none());
507    }
508
509    // ── test 20: Expired is a no-op but trace survives ───────────────────────
510
511    #[test]
512    fn expired_is_noop_trace_survives() {
513        let mut m = PeerGossipMetrics::new();
514        m.record_event(recv(50, "peer-A", 1));
515        m.record_event(exp(50));
516
517        let trace = m.message_trace(50).expect("trace should survive expiry");
518        assert_eq!(trace.received_count, 1);
519        assert_eq!(trace.duplicate_count, 0);
520    }
521
522    // ── test 21: advance_tick increments current_tick ────────────────────────
523
524    #[test]
525    fn advance_tick_increments() {
526        let mut m = PeerGossipMetrics::new();
527        assert_eq!(m.current_tick, 0);
528        m.advance_tick();
529        assert_eq!(m.current_tick, 1);
530        m.advance_tick();
531        assert_eq!(m.current_tick, 2);
532    }
533
534    // ── test 22: first_seen_tick reflects tick at insertion ──────────────────
535
536    #[test]
537    fn first_seen_tick_correct() {
538        let mut m = PeerGossipMetrics::new();
539        m.advance_tick(); // tick = 1
540        m.advance_tick(); // tick = 2
541        m.record_event(recv(60, "peer-A", 1));
542
543        let trace = m.message_trace(60).expect("trace should exist");
544        assert_eq!(trace.first_seen_tick, 2);
545    }
546
547    // ── test 23: snapshot avg_hop_count ──────────────────────────────────────
548
549    #[test]
550    fn snapshot_avg_hop_count() {
551        let mut m = PeerGossipMetrics::new();
552        m.record_event(recv(70, "peer-A", 2));
553        m.record_event(recv(71, "peer-A", 4));
554
555        let snap = m.snapshot();
556        // avg of min_hop_counts 2 and 4 = 3.0
557        assert!((snap.avg_hop_count - 3.0).abs() < 1e-10);
558    }
559
560    // ── test 24: empty snapshot ───────────────────────────────────────────────
561
562    #[test]
563    fn empty_snapshot() {
564        let m = PeerGossipMetrics::new();
565        let snap = m.snapshot();
566        assert_eq!(snap.total_messages, 0);
567        assert_eq!(snap.total_duplicates, 0);
568        assert_eq!(snap.total_forwarded, 0);
569        assert_eq!(snap.avg_hop_count, 0.0);
570        assert_eq!(snap.duplicate_rate, 0.0);
571        assert_eq!(snap.estimated_coverage(), 1.0);
572    }
573}