Skip to main content

ipfrs_network/
gossip_overlay.rs

1//! Gossip Overlay Manager — application-level gossip for lightweight state distribution
2//!
3//! Manages distribution of peer capabilities, index statistics, and gradient round status
4//! without requiring DHT lookups. Uses a fanout-based epidemic broadcast with sequence-number
5//! deduplication.
6//!
7//! ## Architecture
8//!
9//! Messages flow through three stages:
10//! 1. **Receive** — deduplicate via `GossipState`, enqueue for application processing
11//! 2. **Fanout** — select `fanout` random peers (excluding sender), enqueue outbound copies
12//! 3. **Drain** — application pulls inbound/outbound queues for processing/sending
13//!
14//! ## Example
15//!
16//! ```rust
17//! use ipfrs_network::gossip_overlay::{GossipOverlayManager, GossipMessage};
18//!
19//! let mgr = GossipOverlayManager::default();
20//! mgr.add_peer("peer-A".to_string());
21//! mgr.add_peer("peer-B".to_string());
22//! mgr.add_peer("peer-C".to_string());
23//!
24//! let msg = GossipMessage::Heartbeat {
25//!     peer_id: "peer-X".to_string(),
26//!     uptime_secs: 42,
27//!     sequence: 1,
28//! };
29//!
30//! let is_new = mgr.receive(msg);
31//! assert!(is_new);
32//!
33//! let inbound = mgr.drain_inbound();
34//! assert_eq!(inbound.len(), 1);
35//! ```
36
37use serde::{Deserialize, Serialize};
38use std::collections::{HashMap, VecDeque};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Mutex, RwLock};
41
42// ---------------------------------------------------------------------------
43// GossipMessage
44// ---------------------------------------------------------------------------
45
46/// Lightweight gossip messages distributed across the overlay without DHT lookups.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum GossipMessage {
50    /// Peer advertising its capabilities.
51    PeerAnnounce {
52        peer_id: String,
53        capabilities: Vec<String>,
54        sequence: u64,
55    },
56    /// Peer reporting its current vector index statistics.
57    IndexStats {
58        peer_id: String,
59        vector_count: u64,
60        dimensions: u32,
61        sequence: u64,
62    },
63    /// Gradient-round status update.
64    RoundStatus {
65        round_id: u64,
66        status: String,
67        peer_id: String,
68        sequence: u64,
69    },
70    /// Liveness heartbeat.
71    Heartbeat {
72        peer_id: String,
73        uptime_secs: u64,
74        sequence: u64,
75    },
76}
77
78impl GossipMessage {
79    /// Human-readable message type tag.
80    pub fn message_type(&self) -> &str {
81        match self {
82            GossipMessage::PeerAnnounce { .. } => "peer_announce",
83            GossipMessage::IndexStats { .. } => "index_stats",
84            GossipMessage::RoundStatus { .. } => "round_status",
85            GossipMessage::Heartbeat { .. } => "heartbeat",
86        }
87    }
88
89    /// Monotonically-increasing sequence number carried by this message.
90    pub fn sequence(&self) -> u64 {
91        match self {
92            GossipMessage::PeerAnnounce { sequence, .. } => *sequence,
93            GossipMessage::IndexStats { sequence, .. } => *sequence,
94            GossipMessage::RoundStatus { sequence, .. } => *sequence,
95            GossipMessage::Heartbeat { sequence, .. } => *sequence,
96        }
97    }
98
99    /// Originating peer identifier.
100    pub fn peer_id(&self) -> &str {
101        match self {
102            GossipMessage::PeerAnnounce { peer_id, .. } => peer_id,
103            GossipMessage::IndexStats { peer_id, .. } => peer_id,
104            GossipMessage::RoundStatus { peer_id, .. } => peer_id,
105            GossipMessage::Heartbeat { peer_id, .. } => peer_id,
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// GossipState — per-peer sequence deduplication
112// ---------------------------------------------------------------------------
113
114/// Tracks the highest sequence number seen from each peer to deduplicate messages.
115#[derive(Debug, Default)]
116pub struct GossipState {
117    /// Maps `peer_id` → highest `sequence` observed so far.
118    pub seen: HashMap<String, u64>,
119}
120
121impl GossipState {
122    /// Returns `true` when `sequence` is strictly greater than the last recorded value
123    /// for `peer_id` (or when the peer has never been seen).
124    pub fn is_new(&self, peer_id: &str, sequence: u64) -> bool {
125        match self.seen.get(peer_id) {
126            Some(&last) => sequence > last,
127            None => true,
128        }
129    }
130
131    /// Advance (or initialise) the tracked sequence for `peer_id`.
132    /// Only updates when `sequence` is greater than the currently stored value.
133    pub fn record(&mut self, peer_id: &str, sequence: u64) {
134        let entry = self.seen.entry(peer_id.to_string()).or_insert(0);
135        if sequence > *entry {
136            *entry = sequence;
137        }
138    }
139
140    /// Remove all peers whose highest recorded sequence is ≤ `threshold_seq`.
141    /// Useful for cleaning up stale entries from long-gone peers.
142    pub fn prune_stale(&mut self, threshold_seq: u64) {
143        self.seen.retain(|_, &mut seq| seq > threshold_seq);
144    }
145}
146
147// ---------------------------------------------------------------------------
148// GossipFanout — peer selection for epidemic broadcast
149// ---------------------------------------------------------------------------
150
151/// Maintains the candidate peer set and selects fanout targets for each forwarded message.
152#[derive(Debug)]
153pub struct GossipFanout {
154    /// All known candidate peers.
155    pub peers: Vec<String>,
156    /// Number of peers to forward each message to.
157    pub fanout: usize,
158}
159
160impl Default for GossipFanout {
161    fn default() -> Self {
162        Self {
163            peers: Vec::new(),
164            fanout: 3,
165        }
166    }
167}
168
169impl GossipFanout {
170    /// Create a new fanout structure with the given target fan-out width.
171    pub fn new(fanout: usize) -> Self {
172        Self {
173            peers: Vec::new(),
174            fanout,
175        }
176    }
177
178    /// Select up to `self.fanout` targets from the peer list, excluding `exclude`.
179    ///
180    /// Selection is deterministic (round-robin starting offset derived from the
181    /// FNV-1a hash of `exclude`) so that the same sender always produces the same
182    /// forwarding set given the same peer list, while different senders produce
183    /// different sets — ensuring good epidemic coverage without requiring a PRNG.
184    pub fn select_targets(&self, exclude: &str) -> Vec<String> {
185        let candidates: Vec<&String> = self
186            .peers
187            .iter()
188            .filter(|p| p.as_str() != exclude)
189            .collect();
190        if candidates.is_empty() {
191            return Vec::new();
192        }
193
194        // FNV-1a hash of `exclude` bytes for a deterministic, cheap starting offset.
195        let hash = fnv1a_hash(exclude.as_bytes());
196        let start = (hash as usize) % candidates.len();
197
198        let take = self.fanout.min(candidates.len());
199        let mut result = Vec::with_capacity(take);
200        for i in 0..take {
201            let idx = (start + i) % candidates.len();
202            result.push(candidates[idx].clone());
203        }
204        result
205    }
206
207    /// Register a new candidate peer. Silently ignores duplicates.
208    pub fn add_peer(&mut self, peer_id: String) {
209        if !self.peers.contains(&peer_id) {
210            self.peers.push(peer_id);
211        }
212    }
213
214    /// Remove a peer from the candidate list. No-op if the peer is not present.
215    pub fn remove_peer(&mut self, peer_id: &str) {
216        self.peers.retain(|p| p.as_str() != peer_id);
217    }
218
219    /// Number of registered peers.
220    pub fn len(&self) -> usize {
221        self.peers.len()
222    }
223
224    /// Returns `true` if no peers are registered.
225    pub fn is_empty(&self) -> bool {
226        self.peers.is_empty()
227    }
228}
229
230/// Simple FNV-1a 64-bit hash for deterministic offset computation.
231fn fnv1a_hash(bytes: &[u8]) -> u64 {
232    const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
233    const FNV_PRIME: u64 = 1_099_511_628_211;
234    let mut hash = FNV_OFFSET_BASIS;
235    for &b in bytes {
236        hash ^= u64::from(b);
237        hash = hash.wrapping_mul(FNV_PRIME);
238    }
239    hash
240}
241
242// ---------------------------------------------------------------------------
243// GossipStats — lock-free atomic counters
244// ---------------------------------------------------------------------------
245
246/// Lock-free counters tracking message flow through the overlay.
247#[derive(Debug, Default)]
248pub struct GossipStats {
249    /// Total messages received (new + duplicates).
250    pub total_received: AtomicU64,
251    /// Messages accepted as novel (forwarded to inbound queue).
252    pub total_new: AtomicU64,
253    /// Messages discarded as duplicates.
254    pub total_duplicates: AtomicU64,
255    /// Individual (target, message) pairs enqueued for fanout.
256    pub total_fanned_out: AtomicU64,
257}
258
259/// A point-in-time snapshot of [`GossipStats`].
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct GossipStatsSnapshot {
262    pub total_received: u64,
263    pub total_new: u64,
264    pub total_duplicates: u64,
265    pub total_fanned_out: u64,
266}
267
268impl GossipStats {
269    /// Atomically read all counters into a snapshot.
270    pub fn snapshot(&self) -> GossipStatsSnapshot {
271        GossipStatsSnapshot {
272            total_received: self.total_received.load(Ordering::Relaxed),
273            total_new: self.total_new.load(Ordering::Relaxed),
274            total_duplicates: self.total_duplicates.load(Ordering::Relaxed),
275            total_fanned_out: self.total_fanned_out.load(Ordering::Relaxed),
276        }
277    }
278}
279
280// ---------------------------------------------------------------------------
281// GossipOverlayManager — the top-level coordinator
282// ---------------------------------------------------------------------------
283
284/// Manages the application-level gossip overlay.
285///
286/// All operations are thread-safe; queues and state are protected by `Mutex`/`RwLock`.
287///
288/// ### Typical usage pattern
289///
290/// 1. Register known peers via [`add_peer`](Self::add_peer).
291/// 2. On network receive, call [`receive`](Self::receive); it deduplicates and enqueues.
292/// 3. Periodically call [`drain_outbound`](Self::drain_outbound) to obtain (target, msg) pairs
293///    and forward them over the transport layer.
294/// 4. Call [`drain_inbound`](Self::drain_inbound) to pull novel messages for local processing.
295#[derive(Debug, Default)]
296pub struct GossipOverlayManager {
297    state: Mutex<GossipState>,
298    fanout: RwLock<GossipFanout>,
299    inbound_queue: Mutex<VecDeque<GossipMessage>>,
300    outbound_queue: Mutex<VecDeque<(String, GossipMessage)>>,
301    pub stats: GossipStats,
302}
303
304impl GossipOverlayManager {
305    /// Create a manager with a custom fanout width.
306    pub fn with_fanout(fanout: usize) -> Self {
307        Self {
308            fanout: RwLock::new(GossipFanout::new(fanout)),
309            ..Default::default()
310        }
311    }
312
313    // ------------------------------------------------------------------
314    // Peer management (delegates to GossipFanout)
315    // ------------------------------------------------------------------
316
317    /// Register a peer as a fanout candidate.
318    pub fn add_peer(&self, peer_id: String) {
319        self.fanout
320            .write()
321            .expect("fanout RwLock poisoned")
322            .add_peer(peer_id);
323    }
324
325    /// Deregister a peer from the fanout candidate list.
326    pub fn remove_peer(&self, peer_id: &str) {
327        self.fanout
328            .write()
329            .expect("fanout RwLock poisoned")
330            .remove_peer(peer_id);
331    }
332
333    /// Number of registered fanout candidate peers.
334    pub fn peer_count(&self) -> usize {
335        self.fanout.read().expect("fanout RwLock poisoned").len()
336    }
337
338    // ------------------------------------------------------------------
339    // Core message handling
340    // ------------------------------------------------------------------
341
342    /// Process an incoming message.
343    ///
344    /// Returns `true` when the message is novel (first time this sequence number from this
345    /// peer has been seen).  When novel, the message is:
346    /// - pushed to the **inbound** queue for application processing, and
347    /// - pushed to the **outbound** queue for each fanout target.
348    ///
349    /// Returns `false` and increments the duplicate counter when the message was already seen.
350    pub fn receive(&self, msg: GossipMessage) -> bool {
351        self.stats.total_received.fetch_add(1, Ordering::Relaxed);
352
353        let pid = msg.peer_id().to_string();
354        let seq = msg.sequence();
355
356        let is_new = {
357            let mut state = self.state.lock().expect("state Mutex poisoned");
358            if state.is_new(&pid, seq) {
359                state.record(&pid, seq);
360                true
361            } else {
362                false
363            }
364        };
365
366        if !is_new {
367            self.stats.total_duplicates.fetch_add(1, Ordering::Relaxed);
368            return false;
369        }
370
371        self.stats.total_new.fetch_add(1, Ordering::Relaxed);
372
373        // Enqueue for local application processing.
374        self.inbound_queue
375            .lock()
376            .expect("inbound_queue Mutex poisoned")
377            .push_back(msg.clone());
378
379        // Select fanout targets and enqueue outbound copies.
380        let targets = self
381            .fanout
382            .read()
383            .expect("fanout RwLock poisoned")
384            .select_targets(&pid);
385
386        let fanout_count = targets.len() as u64;
387        {
388            let mut out = self
389                .outbound_queue
390                .lock()
391                .expect("outbound_queue Mutex poisoned");
392            for target in targets {
393                out.push_back((target, msg.clone()));
394            }
395        }
396        self.stats
397            .total_fanned_out
398            .fetch_add(fanout_count, Ordering::Relaxed);
399
400        true
401    }
402
403    /// Drain and return all messages currently in the inbound queue.
404    pub fn drain_inbound(&self) -> Vec<GossipMessage> {
405        let mut q = self
406            .inbound_queue
407            .lock()
408            .expect("inbound_queue Mutex poisoned");
409        q.drain(..).collect()
410    }
411
412    /// Drain and return all `(target_peer_id, message)` pairs from the outbound queue.
413    pub fn drain_outbound(&self) -> Vec<(String, GossipMessage)> {
414        let mut q = self
415            .outbound_queue
416            .lock()
417            .expect("outbound_queue Mutex poisoned");
418        q.drain(..).collect()
419    }
420
421    /// Broadcast `msg` to **all** registered fanout peers (ignoring the message's own
422    /// `peer_id` as a sender — useful for locally-originated messages).
423    pub fn broadcast(&self, msg: GossipMessage) {
424        let peers: Vec<String> = self
425            .fanout
426            .read()
427            .expect("fanout RwLock poisoned")
428            .peers
429            .clone();
430
431        let count = peers.len() as u64;
432        {
433            let mut out = self
434                .outbound_queue
435                .lock()
436                .expect("outbound_queue Mutex poisoned");
437            for peer in peers {
438                out.push_back((peer, msg.clone()));
439            }
440        }
441        self.stats
442            .total_fanned_out
443            .fetch_add(count, Ordering::Relaxed);
444    }
445
446    /// A point-in-time snapshot of all gossip counters.
447    pub fn stats_snapshot(&self) -> GossipStatsSnapshot {
448        self.stats.snapshot()
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Tests
454// ---------------------------------------------------------------------------
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    // Helper constructors ---------------------------------------------------
461
462    fn make_announce(peer_id: &str, seq: u64) -> GossipMessage {
463        GossipMessage::PeerAnnounce {
464            peer_id: peer_id.to_string(),
465            capabilities: vec!["vector-search".to_string()],
466            sequence: seq,
467        }
468    }
469
470    fn make_index_stats(peer_id: &str, seq: u64) -> GossipMessage {
471        GossipMessage::IndexStats {
472            peer_id: peer_id.to_string(),
473            vector_count: 1000,
474            dimensions: 768,
475            sequence: seq,
476        }
477    }
478
479    fn make_round_status(peer_id: &str, seq: u64) -> GossipMessage {
480        GossipMessage::RoundStatus {
481            round_id: 7,
482            status: "running".to_string(),
483            peer_id: peer_id.to_string(),
484            sequence: seq,
485        }
486    }
487
488    fn make_heartbeat(peer_id: &str, seq: u64) -> GossipMessage {
489        GossipMessage::Heartbeat {
490            peer_id: peer_id.to_string(),
491            uptime_secs: 300,
492            sequence: seq,
493        }
494    }
495
496    // 1. message_type for PeerAnnounce
497    #[test]
498    fn test_message_type_peer_announce() {
499        let msg = make_announce("p1", 1);
500        assert_eq!(msg.message_type(), "peer_announce");
501    }
502
503    // 2. message_type for IndexStats
504    #[test]
505    fn test_message_type_index_stats() {
506        let msg = make_index_stats("p1", 1);
507        assert_eq!(msg.message_type(), "index_stats");
508    }
509
510    // 3. message_type for RoundStatus
511    #[test]
512    fn test_message_type_round_status() {
513        let msg = make_round_status("p1", 1);
514        assert_eq!(msg.message_type(), "round_status");
515    }
516
517    // 4. message_type for Heartbeat
518    #[test]
519    fn test_message_type_heartbeat() {
520        let msg = make_heartbeat("p1", 1);
521        assert_eq!(msg.message_type(), "heartbeat");
522    }
523
524    // 5. GossipMessage::sequence and peer_id accessors
525    #[test]
526    fn test_message_accessors() {
527        let msg = make_announce("alice", 42);
528        assert_eq!(msg.sequence(), 42);
529        assert_eq!(msg.peer_id(), "alice");
530    }
531
532    // 6. GossipState::is_new — first message is always new
533    #[test]
534    fn test_gossip_state_is_new_first() {
535        let state = GossipState::default();
536        assert!(state.is_new("peer-A", 1));
537    }
538
539    // 7. GossipState deduplication — same sequence not new after record
540    #[test]
541    fn test_gossip_state_dedup() {
542        let mut state = GossipState::default();
543        state.record("peer-A", 5);
544        assert!(!state.is_new("peer-A", 5));
545        assert!(!state.is_new("peer-A", 4));
546        assert!(state.is_new("peer-A", 6));
547    }
548
549    // 8. GossipState::prune_stale
550    #[test]
551    fn test_gossip_state_prune_stale() {
552        let mut state = GossipState::default();
553        state.record("peer-A", 3);
554        state.record("peer-B", 7);
555        state.record("peer-C", 10);
556        state.prune_stale(7); // removes peer-A (3 ≤ 7) and peer-B (7 ≤ 7)
557        assert!(!state.seen.contains_key("peer-A"));
558        assert!(!state.seen.contains_key("peer-B"));
559        assert!(state.seen.contains_key("peer-C"));
560    }
561
562    // 9. GossipFanout::select_targets excludes sender
563    #[test]
564    fn test_fanout_select_excludes_sender() {
565        let mut fanout = GossipFanout::new(3);
566        fanout.add_peer("A".to_string());
567        fanout.add_peer("B".to_string());
568        fanout.add_peer("C".to_string());
569        fanout.add_peer("D".to_string());
570
571        let targets = fanout.select_targets("A");
572        assert!(
573            !targets.contains(&"A".to_string()),
574            "sender must not appear in targets"
575        );
576        assert!(targets.len() <= 3);
577    }
578
579    // 10. GossipFanout::select_targets respects fanout width
580    #[test]
581    fn test_fanout_select_width() {
582        let mut fanout = GossipFanout::new(2);
583        for i in 0..10u32 {
584            fanout.add_peer(format!("peer-{i}"));
585        }
586        let targets = fanout.select_targets("peer-0");
587        assert_eq!(targets.len(), 2);
588    }
589
590    // 11. receive returns true for new message, false for duplicate
591    #[test]
592    fn test_receive_new_vs_duplicate() {
593        let mgr = GossipOverlayManager::default();
594        let msg = make_heartbeat("peer-X", 1);
595        assert!(mgr.receive(msg.clone()), "first receive must be new");
596        assert!(!mgr.receive(msg), "second receive must be duplicate");
597    }
598
599    // 12. receive enqueues fanout messages
600    #[test]
601    fn test_receive_enqueues_fanout() {
602        let mgr = GossipOverlayManager::default();
603        mgr.add_peer("peer-A".to_string());
604        mgr.add_peer("peer-B".to_string());
605        mgr.add_peer("peer-C".to_string());
606
607        let msg = make_heartbeat("sender", 1);
608        mgr.receive(msg);
609
610        let out = mgr.drain_outbound();
611        // fanout default = 3, all 3 peers should receive it (sender is not in peer list)
612        assert_eq!(out.len(), 3, "all 3 registered peers should receive fanout");
613        for (target, _) in &out {
614            assert_ne!(target, "sender");
615        }
616    }
617
618    // 13. drain_inbound clears queue
619    #[test]
620    fn test_drain_inbound_clears_queue() {
621        let mgr = GossipOverlayManager::default();
622        mgr.receive(make_announce("p1", 1));
623        mgr.receive(make_announce("p2", 1));
624        let first_drain = mgr.drain_inbound();
625        assert_eq!(first_drain.len(), 2);
626        let second_drain = mgr.drain_inbound();
627        assert!(second_drain.is_empty(), "queue should be empty after drain");
628    }
629
630    // 14. drain_outbound clears queue
631    #[test]
632    fn test_drain_outbound_clears_queue() {
633        let mgr = GossipOverlayManager::default();
634        mgr.add_peer("peer-A".to_string());
635        mgr.receive(make_heartbeat("sender", 1));
636        let first = mgr.drain_outbound();
637        assert!(!first.is_empty());
638        let second = mgr.drain_outbound();
639        assert!(
640            second.is_empty(),
641            "outbound queue should be empty after drain"
642        );
643    }
644
645    // 15. broadcast reaches all registered peers
646    #[test]
647    fn test_broadcast_reaches_all_peers() {
648        let mgr = GossipOverlayManager::default();
649        mgr.add_peer("peer-A".to_string());
650        mgr.add_peer("peer-B".to_string());
651        mgr.add_peer("peer-C".to_string());
652
653        let msg = make_index_stats("local-node", 1);
654        mgr.broadcast(msg.clone());
655
656        let out = mgr.drain_outbound();
657        assert_eq!(
658            out.len(),
659            3,
660            "broadcast must enqueue one copy per registered peer"
661        );
662        let targets: Vec<&str> = out.iter().map(|(t, _)| t.as_str()).collect();
663        assert!(targets.contains(&"peer-A"));
664        assert!(targets.contains(&"peer-B"));
665        assert!(targets.contains(&"peer-C"));
666    }
667
668    // 16. stats accumulate correctly
669    #[test]
670    fn test_stats_accumulation() {
671        let mgr = GossipOverlayManager::default();
672        mgr.add_peer("peer-A".to_string());
673        mgr.add_peer("peer-B".to_string());
674
675        let msg = make_round_status("origin", 10);
676        mgr.receive(msg.clone()); // new
677        mgr.receive(msg.clone()); // duplicate
678
679        let snap = mgr.stats_snapshot();
680        assert_eq!(snap.total_received, 2);
681        assert_eq!(snap.total_new, 1);
682        assert_eq!(snap.total_duplicates, 1);
683        // fanout = 3 but only 2 peers registered → 2 outbound
684        assert_eq!(snap.total_fanned_out, 2);
685    }
686
687    // 17. peer_count reflects add/remove operations
688    #[test]
689    fn test_peer_count() {
690        let mgr = GossipOverlayManager::default();
691        assert_eq!(mgr.peer_count(), 0);
692        mgr.add_peer("p1".to_string());
693        mgr.add_peer("p2".to_string());
694        assert_eq!(mgr.peer_count(), 2);
695        mgr.remove_peer("p1");
696        assert_eq!(mgr.peer_count(), 1);
697    }
698
699    // 18. duplicate message does NOT enqueue to inbound
700    #[test]
701    fn test_duplicate_not_enqueued_to_inbound() {
702        let mgr = GossipOverlayManager::default();
703        let msg = make_announce("p1", 5);
704        mgr.receive(msg.clone());
705        mgr.receive(msg.clone()); // duplicate — should not enqueue
706
707        let inbound = mgr.drain_inbound();
708        assert_eq!(
709            inbound.len(),
710            1,
711            "only one copy should reach the inbound queue"
712        );
713    }
714
715    // 19. higher sequence after duplicate is treated as new
716    #[test]
717    fn test_higher_sequence_after_duplicate_is_new() {
718        let mgr = GossipOverlayManager::default();
719        let msg_v1 = make_heartbeat("peer-Z", 1);
720        let msg_v2 = make_heartbeat("peer-Z", 2);
721
722        assert!(mgr.receive(msg_v1));
723        assert!(!mgr.receive(make_heartbeat("peer-Z", 1))); // same seq → dup
724        assert!(mgr.receive(msg_v2)); // higher seq → new
725
726        let snap = mgr.stats_snapshot();
727        assert_eq!(snap.total_new, 2);
728        assert_eq!(snap.total_duplicates, 1);
729    }
730
731    // 20. serialization round-trip for all variants
732    #[test]
733    fn test_serde_round_trip() {
734        let messages = vec![
735            make_announce("p1", 1),
736            make_index_stats("p2", 2),
737            make_round_status("p3", 3),
738            make_heartbeat("p4", 4),
739        ];
740        for msg in messages {
741            let json = serde_json::to_string(&msg).expect("serialization failed");
742            let decoded: GossipMessage =
743                serde_json::from_str(&json).expect("deserialization failed");
744            assert_eq!(msg, decoded);
745        }
746    }
747}