Skip to main content

ipfrs_network/
gossip_protocol_engine.rs

1//! Gossip Dissemination Protocol Engine
2//!
3//! A production-quality gossip protocol engine implementing epidemic broadcast with
4//! pluggable fanout strategies, message deduplication, hop-count TTL, and per-peer
5//! delivery scoring.  All state is synchronous and `Send + Sync`-compatible (the
6//! public API takes `&mut self` for mutable operations).
7//!
8//! # Design notes
9//! - **No external PRNG**: `xorshift64` is used where randomness is required.
10//! - **No `rand` crate**: coin-flip / shuffle are done inline.
11//! - **FNV-1a 64-bit** is used for deterministic message-ID generation.
12//! - **Dedup cache**: bounded `VecDeque` providing O(1) push / O(n) lookup, sufficient
13//!   for typical gossip workloads where the window is small (≤ 4096 messages).
14
15use std::collections::VecDeque;
16
17// ────────────────────────────────────────────────────────────────────────────
18// Primitive helpers
19// ────────────────────────────────────────────────────────────────────────────
20
21/// FNV-1a 64-bit hash.
22#[inline]
23pub fn fnv1a_64(data: &[u8]) -> u64 {
24    let mut h: u64 = 14_695_981_039_346_656_037;
25    for &b in data {
26        h ^= b as u64;
27        h = h.wrapping_mul(1_099_511_628_211);
28    }
29    h
30}
31
32/// Xorshift64 PRNG – mutates `state` in-place and returns the next value.
33#[inline]
34pub fn xorshift64(state: &mut u64) -> u64 {
35    let mut x = *state;
36    x ^= x << 13;
37    x ^= x >> 7;
38    x ^= x << 17;
39    *state = x;
40    x
41}
42
43// ────────────────────────────────────────────────────────────────────────────
44// Types
45// ────────────────────────────────────────────────────────────────────────────
46
47/// A gossip message travelling through the overlay.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct GossipMessage {
50    /// FNV-1a hex of `payload ++ timestamp`.
51    pub id: String,
52    /// Peer-ID string of the node that originally created the message.
53    pub origin_peer: String,
54    /// Number of hops already traversed (incremented on each forward).
55    pub hop_count: u8,
56    /// Maximum number of hops before the message is dropped.
57    pub max_hops: u8,
58    /// Raw payload bytes.
59    pub payload: Vec<u8>,
60    /// Unix-epoch milliseconds at creation time.
61    pub timestamp: u64,
62    /// Application-level topic string.
63    pub topic: String,
64}
65
66/// An active or recently-seen gossip peer.
67#[derive(Debug, Clone)]
68pub struct GossipPeer {
69    /// Peer-ID string.
70    pub id: String,
71    /// Unix-epoch milliseconds of last activity.
72    pub last_seen: u64,
73    /// Total number of messages received from this peer.
74    pub message_count: u64,
75    /// Whether this peer is considered active.
76    pub is_active: bool,
77    /// Exponential-moving-average delivery efficiency in `[0.0, 1.0]`.
78    pub fanout_score: f64,
79}
80
81impl GossipPeer {
82    fn new(id: String) -> Self {
83        Self {
84            id,
85            last_seen: 0,
86            message_count: 0,
87            is_active: true,
88            fanout_score: 0.5,
89        }
90    }
91}
92
93/// Peer-selection strategy used when forwarding messages.
94#[derive(Debug, Clone)]
95pub enum FanoutStrategy {
96    /// Always forward to the first `n` active peers (excluding origin).
97    Fixed(usize),
98    /// Adaptive count: `min + (active_count / 10).min(max − min)`.
99    Adaptive {
100        /// Minimum number of peers to forward to.
101        min: usize,
102        /// Maximum number of peers to forward to.
103        max: usize,
104    },
105    /// Each active peer is included with probability `p` (0.0–1.0).
106    Epidemic(f64),
107    /// Deterministic shuffle via `xorshift64(seed)`, then take `fanout_size`.
108    Random(u64),
109    /// Sort peers descending by `fanout_score`, take `fanout_size`.
110    PriorityBased,
111}
112
113/// Configuration for a `GossipProtocolEngine`.
114#[derive(Debug, Clone)]
115pub struct GossipConfig {
116    /// Default number of peers to forward to (used by several strategies).
117    pub fanout_size: usize,
118    /// Maximum hop-count before a message is silently dropped.
119    pub max_hops: u8,
120    /// Maximum number of message IDs retained in the dedup cache.
121    pub dedup_cache_size: usize,
122    /// How often the engine should gossip (milliseconds); informational only.
123    pub gossip_interval_ms: u64,
124    /// Minimum number of active peers required before forwarding.
125    pub min_peers_for_gossip: usize,
126    /// Fanout strategy to apply on each forward.
127    pub strategy: FanoutStrategy,
128}
129
130impl Default for GossipConfig {
131    fn default() -> Self {
132        Self {
133            fanout_size: 6,
134            max_hops: 7,
135            dedup_cache_size: 2048,
136            gossip_interval_ms: 100,
137            min_peers_for_gossip: 1,
138            strategy: FanoutStrategy::Fixed(6),
139        }
140    }
141}
142
143/// Aggregate statistics produced by a `GossipProtocolEngine`.
144#[derive(Debug, Clone, Default)]
145pub struct GossipStats {
146    /// Total messages successfully received (not duplicates, not over TTL).
147    pub messages_received: u64,
148    /// Total messages forwarded to downstream peers.
149    pub messages_forwarded: u64,
150    /// Messages dropped because they were already seen.
151    pub messages_dropped_duplicate: u64,
152    /// Messages dropped because `hop_count >= max_hops`.
153    pub messages_dropped_ttl: u64,
154    /// Current number of active peers.
155    pub active_peers: usize,
156    /// Exponential-moving-average hop-count of accepted messages.
157    pub avg_propagation_hops: f64,
158}
159
160/// Events emitted by a `GossipProtocolEngine`.
161#[derive(Debug, Clone)]
162pub enum GossipEvent {
163    /// A new (non-duplicate, within-TTL) message was accepted.
164    MessageReceived(GossipMessage),
165    /// A message was forwarded to a set of peers.
166    MessageForwarded {
167        /// ID of the forwarded message.
168        msg_id: String,
169        /// Peer-IDs that the message was sent to.
170        to_peers: Vec<String>,
171    },
172    /// A new peer was registered.
173    PeerAdded(String),
174    /// A peer was removed.
175    PeerRemoved(String),
176    /// A duplicate message was dropped.
177    DuplicateDropped(String),
178}
179
180/// Errors returned by `GossipProtocolEngine` operations.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum EngineError {
183    /// No peer with the given ID is registered.
184    PeerNotFound(String),
185    /// Payload exceeds the allowed maximum (value is the actual size in bytes).
186    MessageTooLarge(usize),
187    /// There are no active peers to forward to.
188    NoPeersAvailable,
189    /// `hop_count` field carries an invalid value (e.g. exceeds `u8::MAX`).
190    InvalidHopCount,
191    /// Binary decoding of a field failed.
192    DecodingError(String),
193}
194
195impl std::fmt::Display for EngineError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            EngineError::PeerNotFound(id) => write!(f, "peer not found: {id}"),
199            EngineError::MessageTooLarge(n) => write!(f, "message too large: {n} bytes"),
200            EngineError::NoPeersAvailable => write!(f, "no active peers available"),
201            EngineError::InvalidHopCount => write!(f, "invalid hop count"),
202            EngineError::DecodingError(msg) => write!(f, "decoding error: {msg}"),
203        }
204    }
205}
206
207impl std::error::Error for EngineError {}
208
209// ────────────────────────────────────────────────────────────────────────────
210// Engine
211// ────────────────────────────────────────────────────────────────────────────
212
213/// Production-quality gossip dissemination protocol engine.
214///
215/// The engine is **not** async; it exposes a pure, synchronous API suitable for
216/// embedding inside higher-level async event loops.  All state is owned by the
217/// struct and protected via `&mut self`.
218pub struct GossipProtocolEngine {
219    config: GossipConfig,
220    peers: Vec<GossipPeer>,
221    dedup_cache: VecDeque<String>,
222    stats: GossipStats,
223    event_log: Vec<GossipEvent>,
224    /// PRNG state for strategies that need randomness.
225    rng_state: u64,
226    /// EMA accumulator for avg_propagation_hops.
227    hop_ema_n: u64,
228}
229
230impl GossipProtocolEngine {
231    // ── Construction ──────────────────────────────────────────────────────
232
233    /// Create a new engine with the given configuration.
234    ///
235    /// The PRNG seed is derived from `dedup_cache_size` XOR a constant so that
236    /// default-config engines are reproducible in tests.
237    pub fn new(config: GossipConfig) -> Self {
238        let seed = (config.dedup_cache_size as u64)
239            ^ 0xdeadbeef_cafebabe
240            ^ (config.fanout_size as u64).wrapping_mul(6_364_136_223_846_793_005);
241        Self {
242            peers: Vec::new(),
243            dedup_cache: VecDeque::with_capacity(config.dedup_cache_size),
244            stats: GossipStats::default(),
245            event_log: Vec::new(),
246            rng_state: if seed == 0 { 1 } else { seed },
247            hop_ema_n: 0,
248            config,
249        }
250    }
251
252    /// Create an engine with default configuration.
253    pub fn with_defaults() -> Self {
254        Self::new(GossipConfig::default())
255    }
256
257    // ── Peer management ───────────────────────────────────────────────────
258
259    /// Register a new peer.  Returns `Ok(())` if the peer was added, or if it
260    /// was already present (idempotent).
261    pub fn add_peer(&mut self, peer_id: String) -> Result<(), EngineError> {
262        if !self.peers.iter().any(|p| p.id == peer_id) {
263            self.peers.push(GossipPeer::new(peer_id.clone()));
264            self.event_log.push(GossipEvent::PeerAdded(peer_id));
265            self.stats.active_peers = self.active_peer_count();
266        }
267        Ok(())
268    }
269
270    /// Remove a registered peer.  Returns `Err(PeerNotFound)` if absent.
271    pub fn remove_peer(&mut self, peer_id: &str) -> Result<(), EngineError> {
272        let pos = self
273            .peers
274            .iter()
275            .position(|p| p.id == peer_id)
276            .ok_or_else(|| EngineError::PeerNotFound(peer_id.to_owned()))?;
277        self.peers.remove(pos);
278        self.event_log
279            .push(GossipEvent::PeerRemoved(peer_id.to_owned()));
280        self.stats.active_peers = self.active_peer_count();
281        Ok(())
282    }
283
284    // ── Message creation ──────────────────────────────────────────────────
285
286    /// Create a new `GossipMessage` with a computed FNV-1a ID.
287    ///
288    /// `timestamp` should be a monotonically-increasing value (e.g.
289    /// `SystemTime::now()` milliseconds); when running tests without a real
290    /// clock, pass any non-zero value.
291    pub fn create_message(
292        payload: Vec<u8>,
293        topic: String,
294        origin: String,
295        timestamp: u64,
296        max_hops: u8,
297    ) -> Result<GossipMessage, EngineError> {
298        let mut id_input = payload.clone();
299        id_input.extend_from_slice(&timestamp.to_le_bytes());
300        let hash = fnv1a_64(&id_input);
301        let id = format!("{hash:016x}");
302        Ok(GossipMessage {
303            id,
304            origin_peer: origin,
305            hop_count: 0,
306            max_hops,
307            payload,
308            timestamp,
309            topic,
310        })
311    }
312
313    // ── Receive / forward ─────────────────────────────────────────────────
314
315    /// Process an incoming `GossipMessage`.
316    ///
317    /// Returns a `Vec<GossipEvent>` describing what happened:
318    /// - `DuplicateDropped` if the message was already seen.
319    /// - `MessageReceived` + `MessageForwarded` if accepted and forwarded.
320    /// - `MessageReceived` only if there are no eligible forward peers.
321    pub fn receive_message(&mut self, msg: GossipMessage) -> Result<Vec<GossipEvent>, EngineError> {
322        // 1. Dedup check.
323        if self.dedup_cache.contains(&msg.id) {
324            self.stats.messages_dropped_duplicate += 1;
325            let ev = GossipEvent::DuplicateDropped(msg.id.clone());
326            self.event_log.push(ev.clone());
327            return Ok(vec![ev]);
328        }
329
330        // 2. TTL check.
331        if msg.hop_count >= msg.max_hops {
332            self.stats.messages_dropped_ttl += 1;
333            return Ok(vec![]);
334        }
335
336        // 3. Accept: add to dedup cache (bounded).
337        if self.dedup_cache.len() >= self.config.dedup_cache_size {
338            self.dedup_cache.pop_front();
339        }
340        self.dedup_cache.push_back(msg.id.clone());
341
342        // 4. Update stats.
343        self.stats.messages_received += 1;
344        self.hop_ema_n += 1;
345        let hops_f = msg.hop_count as f64;
346        if self.hop_ema_n == 1 {
347            self.stats.avg_propagation_hops = hops_f;
348        } else {
349            self.stats.avg_propagation_hops = self.stats.avg_propagation_hops * 0.9 + hops_f * 0.1;
350        }
351
352        let received_ev = GossipEvent::MessageReceived(msg.clone());
353        self.event_log.push(received_ev.clone());
354        let mut events = vec![received_ev];
355
356        // 5. Select forwarding peers (clone strategy to avoid borrow conflict).
357        let strategy = self.config.strategy.clone();
358        let targets = self.select_peers(&msg.origin_peer, &strategy);
359
360        if !targets.is_empty() {
361            // 6. Forward: bump hop_count for the forwarded copy.
362            let mut forwarded = msg.clone();
363            forwarded.hop_count = msg.hop_count.saturating_add(1);
364            self.stats.messages_forwarded += targets.len() as u64;
365
366            let fwd_ev = GossipEvent::MessageForwarded {
367                msg_id: msg.id.clone(),
368                to_peers: targets.clone(),
369            };
370            self.event_log.push(fwd_ev.clone());
371            events.push(fwd_ev);
372
373            // Update scores: mark each target as "delivered".
374            for peer_id in &targets {
375                // Safe: we have a valid peer list; update_peer_score is infallible.
376                self.update_peer_score(peer_id, true);
377            }
378        }
379
380        self.stats.active_peers = self.active_peer_count();
381        Ok(events)
382    }
383
384    // ── Peer selection ────────────────────────────────────────────────────
385
386    /// Select peers to forward a message to, applying the given strategy.
387    ///
388    /// The returned list excludes `exclude` (the origin peer) and only
389    /// contains active peers.
390    pub fn select_peers(&mut self, exclude: &str, strategy: &FanoutStrategy) -> Vec<String> {
391        // Gather active candidate IDs (excluding `exclude`).
392        let candidates: Vec<(usize, f64)> = self
393            .peers
394            .iter()
395            .enumerate()
396            .filter(|(_, p)| p.is_active && p.id != exclude)
397            .map(|(i, p)| (i, p.fanout_score))
398            .collect();
399
400        if candidates.is_empty() {
401            return Vec::new();
402        }
403
404        let active_count = candidates.len();
405
406        match strategy {
407            FanoutStrategy::Fixed(n) => {
408                // Take the first `n` active peers in insertion order.
409                candidates
410                    .iter()
411                    .take(*n)
412                    .map(|(i, _)| self.peers[*i].id.clone())
413                    .collect()
414            }
415
416            FanoutStrategy::Adaptive { min, max } => {
417                let extra = (active_count / 10).min(max.saturating_sub(*min));
418                let target = (min + extra).min(*max).min(active_count);
419                candidates
420                    .iter()
421                    .take(target)
422                    .map(|(i, _)| self.peers[*i].id.clone())
423                    .collect()
424            }
425
426            FanoutStrategy::Epidemic(p) => {
427                let threshold = (*p * u64::MAX as f64) as u64;
428                candidates
429                    .iter()
430                    .filter(|_| {
431                        let r = xorshift64(&mut self.rng_state);
432                        r <= threshold
433                    })
434                    .map(|(i, _)| self.peers[*i].id.clone())
435                    .collect()
436            }
437
438            FanoutStrategy::Random(seed) => {
439                // Build a local index list and shuffle with xorshift64.
440                let mut indices: Vec<usize> = (0..active_count).collect();
441                let mut state = if *seed == 0 { self.rng_state } else { *seed };
442                // Fisher-Yates shuffle.
443                for i in (1..active_count).rev() {
444                    let j = (xorshift64(&mut state) % (i as u64 + 1)) as usize;
445                    indices.swap(i, j);
446                }
447                // Advance rng_state to avoid repeated deterministic sequences.
448                self.rng_state = state;
449                let take = self.config.fanout_size.min(active_count);
450                indices[..take]
451                    .iter()
452                    .map(|&ci| self.peers[candidates[ci].0].id.clone())
453                    .collect()
454            }
455
456            FanoutStrategy::PriorityBased => {
457                // Sort descending by fanout_score, take `fanout_size`.
458                let mut sorted = candidates.clone();
459                sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
460                let take = self.config.fanout_size.min(active_count);
461                sorted[..take]
462                    .iter()
463                    .map(|(i, _)| self.peers[*i].id.clone())
464                    .collect()
465            }
466        }
467    }
468
469    // ── Scoring ───────────────────────────────────────────────────────────
470
471    /// Update a peer's `fanout_score` using an EMA with α = 0.1.
472    ///
473    /// `delivered = true`  → positive signal (score nudges toward 1.0).
474    /// `delivered = false` → negative signal (score nudges toward 0.0).
475    pub fn update_peer_score(&mut self, peer_id: &str, delivered: bool) {
476        if let Some(peer) = self.peers.iter_mut().find(|p| p.id == peer_id) {
477            let signal = if delivered { 1.0_f64 } else { 0.0_f64 };
478            peer.fanout_score = 0.9 * peer.fanout_score + 0.1 * signal;
479            // Clamp to [0.0, 1.0] for numerical stability.
480            peer.fanout_score = peer.fanout_score.clamp(0.0, 1.0);
481        }
482    }
483
484    // ── Accessors ─────────────────────────────────────────────────────────
485
486    /// Number of message IDs currently in the dedup cache.
487    pub fn pending_messages(&self) -> usize {
488        self.dedup_cache.len()
489    }
490
491    /// Return a snapshot of current statistics.
492    pub fn stats(&self) -> GossipStats {
493        let mut s = self.stats.clone();
494        s.active_peers = self.active_peer_count();
495        s
496    }
497
498    /// Drain and return all buffered `GossipEvent`s.
499    pub fn drain_events(&mut self) -> Vec<GossipEvent> {
500        std::mem::take(&mut self.event_log)
501    }
502
503    /// Borrow the current configuration.
504    pub fn config(&self) -> &GossipConfig {
505        &self.config
506    }
507
508    /// Number of registered peers (active + inactive).
509    pub fn peer_count(&self) -> usize {
510        self.peers.len()
511    }
512
513    /// Get a peer by ID.
514    pub fn peer(&self, peer_id: &str) -> Option<&GossipPeer> {
515        self.peers.iter().find(|p| p.id == peer_id)
516    }
517
518    /// Mark a peer as inactive without removing it.
519    pub fn deactivate_peer(&mut self, peer_id: &str) -> Result<(), EngineError> {
520        let peer = self
521            .peers
522            .iter_mut()
523            .find(|p| p.id == peer_id)
524            .ok_or_else(|| EngineError::PeerNotFound(peer_id.to_owned()))?;
525        peer.is_active = false;
526        self.stats.active_peers = self.active_peer_count();
527        Ok(())
528    }
529
530    /// Reactivate a previously deactivated peer.
531    pub fn reactivate_peer(&mut self, peer_id: &str) -> Result<(), EngineError> {
532        let peer = self
533            .peers
534            .iter_mut()
535            .find(|p| p.id == peer_id)
536            .ok_or_else(|| EngineError::PeerNotFound(peer_id.to_owned()))?;
537        peer.is_active = true;
538        self.stats.active_peers = self.active_peer_count();
539        Ok(())
540    }
541
542    /// Reset the dedup cache without touching any other state.
543    pub fn clear_dedup_cache(&mut self) {
544        self.dedup_cache.clear();
545    }
546
547    // ── Private helpers ───────────────────────────────────────────────────
548
549    fn active_peer_count(&self) -> usize {
550        self.peers.iter().filter(|p| p.is_active).count()
551    }
552}
553
554// ────────────────────────────────────────────────────────────────────────────
555// Tests
556// ────────────────────────────────────────────────────────────────────────────
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    // ── helpers ──────────────────────────────────────────────────────────
563
564    fn make_engine() -> GossipProtocolEngine {
565        GossipProtocolEngine::with_defaults()
566    }
567
568    fn add_peers(engine: &mut GossipProtocolEngine, n: usize) {
569        for i in 0..n {
570            engine.add_peer(format!("peer{i}")).expect("add_peer");
571        }
572    }
573
574    fn make_msg(id: &str, origin: &str, hop: u8, max: u8) -> GossipMessage {
575        GossipMessage {
576            id: id.to_owned(),
577            origin_peer: origin.to_owned(),
578            hop_count: hop,
579            max_hops: max,
580            payload: vec![1, 2, 3],
581            timestamp: 1_000_000,
582            topic: "test".to_owned(),
583        }
584    }
585
586    // ── 1. Peer management ────────────────────────────────────────────────
587
588    #[test]
589    fn add_peer_increases_count() {
590        let mut e = make_engine();
591        e.add_peer("p1".into())
592            .expect("test: add_peer should succeed");
593        assert_eq!(e.peer_count(), 1);
594    }
595
596    #[test]
597    fn add_peer_idempotent() {
598        let mut e = make_engine();
599        e.add_peer("p1".into())
600            .expect("test: add_peer should succeed");
601        e.add_peer("p1".into())
602            .expect("test: add_peer idempotent should succeed");
603        assert_eq!(e.peer_count(), 1);
604    }
605
606    #[test]
607    fn remove_peer_decreases_count() {
608        let mut e = make_engine();
609        e.add_peer("p1".into())
610            .expect("test: add_peer should succeed");
611        e.remove_peer("p1")
612            .expect("test: remove_peer should succeed");
613        assert_eq!(e.peer_count(), 0);
614    }
615
616    #[test]
617    fn remove_nonexistent_peer_errors() {
618        let mut e = make_engine();
619        let res = e.remove_peer("ghost");
620        assert!(matches!(res, Err(EngineError::PeerNotFound(_))));
621    }
622
623    #[test]
624    fn peer_added_event_emitted() {
625        let mut e = make_engine();
626        e.add_peer("p1".into())
627            .expect("test: add_peer should succeed");
628        let events = e.drain_events();
629        assert!(events
630            .iter()
631            .any(|ev| matches!(ev, GossipEvent::PeerAdded(id) if id == "p1")));
632    }
633
634    #[test]
635    fn peer_removed_event_emitted() {
636        let mut e = make_engine();
637        e.add_peer("p1".into())
638            .expect("test: add_peer should succeed");
639        e.drain_events();
640        e.remove_peer("p1")
641            .expect("test: remove_peer should succeed");
642        let events = e.drain_events();
643        assert!(events
644            .iter()
645            .any(|ev| matches!(ev, GossipEvent::PeerRemoved(id) if id == "p1")));
646    }
647
648    #[test]
649    fn deactivate_peer_excludes_from_forwarding() {
650        let mut e = make_engine();
651        e.add_peer("p1".into())
652            .expect("test: add_peer should succeed");
653        e.deactivate_peer("p1")
654            .expect("test: deactivate_peer should succeed");
655        assert_eq!(e.active_peer_count(), 0);
656    }
657
658    #[test]
659    fn reactivate_peer_includes_in_forwarding() {
660        let mut e = make_engine();
661        e.add_peer("p1".into())
662            .expect("test: add_peer should succeed");
663        e.deactivate_peer("p1")
664            .expect("test: deactivate_peer should succeed");
665        e.reactivate_peer("p1")
666            .expect("test: reactivate_peer should succeed");
667        assert_eq!(e.active_peer_count(), 1);
668    }
669
670    #[test]
671    fn deactivate_nonexistent_errors() {
672        let mut e = make_engine();
673        assert!(matches!(
674            e.deactivate_peer("ghost"),
675            Err(EngineError::PeerNotFound(_))
676        ));
677    }
678
679    // ── 2. Message creation ───────────────────────────────────────────────
680
681    #[test]
682    fn create_message_produces_valid_id() {
683        let msg = GossipProtocolEngine::create_message(
684            vec![1, 2, 3],
685            "topic".into(),
686            "origin".into(),
687            12345,
688            7,
689        )
690        .expect("test: create_message should succeed");
691        assert_eq!(msg.id.len(), 16, "FNV-1a 64-bit hex is 16 chars");
692        assert_eq!(msg.hop_count, 0);
693        assert_eq!(msg.max_hops, 7);
694    }
695
696    #[test]
697    fn create_message_different_timestamps_differ() {
698        let m1 = GossipProtocolEngine::create_message(vec![1], "t".into(), "o".into(), 1, 5)
699            .expect("test: create_message with timestamp 1 should succeed");
700        let m2 = GossipProtocolEngine::create_message(vec![1], "t".into(), "o".into(), 2, 5)
701            .expect("test: create_message with timestamp 2 should succeed");
702        assert_ne!(m1.id, m2.id);
703    }
704
705    #[test]
706    fn create_message_same_inputs_same_id() {
707        let m1 =
708            GossipProtocolEngine::create_message(vec![9, 8, 7], "t".into(), "o".into(), 999, 5)
709                .expect("test: create_message m1 should succeed");
710        let m2 =
711            GossipProtocolEngine::create_message(vec![9, 8, 7], "t".into(), "o".into(), 999, 5)
712                .expect("test: create_message m2 should succeed");
713        assert_eq!(m1.id, m2.id);
714    }
715
716    // ── 3. Receive and forward ────────────────────────────────────────────
717
718    #[test]
719    fn receive_message_no_peers_still_accepted() {
720        let mut e = make_engine();
721        let msg = make_msg("msg1", "origin", 0, 7);
722        let events = e
723            .receive_message(msg)
724            .expect("test: receive_message should succeed");
725        assert!(events
726            .iter()
727            .any(|ev| matches!(ev, GossipEvent::MessageReceived(_))));
728        assert_eq!(e.stats().messages_received, 1);
729    }
730
731    #[test]
732    fn receive_message_forwards_to_peers() {
733        let mut e = make_engine();
734        add_peers(&mut e, 3);
735        let msg = make_msg("msg1", "external", 0, 7);
736        let events = e
737            .receive_message(msg)
738            .expect("test: receive_message should succeed");
739        let fwd = events
740            .iter()
741            .find(|ev| matches!(ev, GossipEvent::MessageForwarded { .. }));
742        assert!(fwd.is_some());
743        if let Some(GossipEvent::MessageForwarded { to_peers, .. }) = fwd {
744            assert!(!to_peers.is_empty());
745        }
746    }
747
748    #[test]
749    fn receive_excludes_origin_peer() {
750        let mut e = make_engine();
751        e.add_peer("p1".into())
752            .expect("test: add_peer should succeed");
753        let msg = make_msg("msg1", "p1", 0, 7);
754        let events = e
755            .receive_message(msg)
756            .expect("test: receive_message should succeed");
757        // p1 is the only peer and is excluded → no forward.
758        assert!(!events
759            .iter()
760            .any(|ev| matches!(ev, GossipEvent::MessageForwarded { .. })));
761    }
762
763    #[test]
764    fn receive_hop_count_incremented_on_forward() {
765        let mut e = make_engine();
766        add_peers(&mut e, 2);
767        let msg = make_msg("msg1", "ext", 3, 7);
768        let events = e
769            .receive_message(msg)
770            .expect("test: receive_message should succeed");
771        assert!(events
772            .iter()
773            .any(|ev| matches!(ev, GossipEvent::MessageForwarded { .. })));
774    }
775
776    #[test]
777    fn stats_messages_received_incremented() {
778        let mut e = make_engine();
779        let msg = make_msg("m1", "ext", 0, 7);
780        e.receive_message(msg)
781            .expect("test: receive_message should succeed");
782        assert_eq!(e.stats().messages_received, 1);
783    }
784
785    #[test]
786    fn stats_messages_forwarded_incremented() {
787        let mut e = make_engine();
788        add_peers(&mut e, 4);
789        let msg = make_msg("m1", "ext", 0, 7);
790        e.receive_message(msg)
791            .expect("test: receive_message should succeed");
792        assert!(e.stats().messages_forwarded > 0);
793    }
794
795    // ── 4. Deduplication ─────────────────────────────────────────────────
796
797    #[test]
798    fn duplicate_message_dropped() {
799        let mut e = make_engine();
800        let msg = make_msg("dup", "ext", 0, 7);
801        e.receive_message(msg.clone())
802            .expect("test: first receive_message should succeed");
803        let events2 = e
804            .receive_message(msg)
805            .expect("test: second receive_message should succeed");
806        assert!(events2
807            .iter()
808            .any(|ev| matches!(ev, GossipEvent::DuplicateDropped(_))));
809    }
810
811    #[test]
812    fn duplicate_increments_dropped_counter() {
813        let mut e = make_engine();
814        let msg = make_msg("dup", "ext", 0, 7);
815        e.receive_message(msg.clone())
816            .expect("test: first receive_message should succeed");
817        e.receive_message(msg)
818            .expect("test: duplicate receive_message should succeed");
819        assert_eq!(e.stats().messages_dropped_duplicate, 1);
820    }
821
822    #[test]
823    fn duplicate_not_forwarded() {
824        let mut e = make_engine();
825        add_peers(&mut e, 3);
826        let msg = make_msg("dup", "ext", 0, 7);
827        e.receive_message(msg.clone())
828            .expect("test: first receive_message should succeed");
829        let before = e.stats().messages_forwarded;
830        let events = e
831            .receive_message(msg)
832            .expect("test: duplicate receive_message should succeed");
833        assert!(events
834            .iter()
835            .any(|ev| matches!(ev, GossipEvent::DuplicateDropped(_))));
836        assert_eq!(e.stats().messages_forwarded, before);
837    }
838
839    #[test]
840    fn dedup_cache_bounded_eviction() {
841        let mut e = GossipProtocolEngine::new(GossipConfig {
842            dedup_cache_size: 3,
843            ..GossipConfig::default()
844        });
845        for i in 0_u32..4 {
846            let msg = make_msg(&format!("m{i}"), "ext", 0, 7);
847            e.receive_message(msg)
848                .expect("test: receive_message should succeed");
849        }
850        // Cache should hold exactly 3 entries.
851        assert_eq!(e.pending_messages(), 3);
852    }
853
854    #[test]
855    fn evicted_message_can_be_reaccepted() {
856        let mut e = GossipProtocolEngine::new(GossipConfig {
857            dedup_cache_size: 2,
858            ..GossipConfig::default()
859        });
860        e.receive_message(make_msg("m0", "ext", 0, 7))
861            .expect("test: receive m0 should succeed");
862        e.receive_message(make_msg("m1", "ext", 0, 7))
863            .expect("test: receive m1 should succeed");
864        // Evict m0 by adding m2.
865        e.receive_message(make_msg("m2", "ext", 0, 7))
866            .expect("test: receive m2 should succeed");
867        // m0 should be re-accepted now.
868        let events = e
869            .receive_message(make_msg("m0", "ext", 0, 7))
870            .expect("test: re-receive evicted m0 should succeed");
871        assert!(events
872            .iter()
873            .any(|ev| matches!(ev, GossipEvent::MessageReceived(_))));
874    }
875
876    #[test]
877    fn clear_dedup_cache_allows_reprocessing() {
878        let mut e = make_engine();
879        let msg = make_msg("m1", "ext", 0, 7);
880        e.receive_message(msg.clone())
881            .expect("test: first receive_message should succeed");
882        e.clear_dedup_cache();
883        let events = e
884            .receive_message(msg)
885            .expect("test: receive after cache clear should succeed");
886        assert!(events
887            .iter()
888            .any(|ev| matches!(ev, GossipEvent::MessageReceived(_))));
889    }
890
891    // ── 5. TTL / hop-count enforcement ────────────────────────────────────
892
893    #[test]
894    fn message_at_max_hops_dropped() {
895        let mut e = make_engine();
896        add_peers(&mut e, 3);
897        let msg = make_msg("ttl1", "ext", 7, 7); // hop_count == max_hops
898        let events = e
899            .receive_message(msg)
900            .expect("test: receive_message should succeed even for TTL drop");
901        assert!(events.is_empty());
902        assert_eq!(e.stats().messages_dropped_ttl, 1);
903    }
904
905    #[test]
906    fn message_below_max_hops_accepted() {
907        let mut e = make_engine();
908        let msg = make_msg("ttl2", "ext", 6, 7); // hop_count < max_hops
909        let events = e
910            .receive_message(msg)
911            .expect("test: receive_message below max_hops should succeed");
912        assert!(events
913            .iter()
914            .any(|ev| matches!(ev, GossipEvent::MessageReceived(_))));
915    }
916
917    #[test]
918    fn ttl_drop_not_forwarded() {
919        let mut e = make_engine();
920        add_peers(&mut e, 5);
921        let msg = make_msg("ttl3", "ext", 7, 7);
922        e.receive_message(msg)
923            .expect("test: receive_message should succeed even for TTL drop");
924        assert_eq!(e.stats().messages_forwarded, 0);
925    }
926
927    #[test]
928    fn max_hops_zero_drops_immediately() {
929        let mut e = make_engine();
930        let msg = make_msg("ttl4", "ext", 0, 0);
931        let events = e
932            .receive_message(msg)
933            .expect("test: receive_message with max_hops=0 should succeed");
934        assert!(events.is_empty());
935        assert_eq!(e.stats().messages_dropped_ttl, 1);
936    }
937
938    // ── 6. Fanout strategies ──────────────────────────────────────────────
939
940    fn engine_with_strategy(strategy: FanoutStrategy) -> GossipProtocolEngine {
941        GossipProtocolEngine::new(GossipConfig {
942            fanout_size: 3,
943            strategy,
944            ..GossipConfig::default()
945        })
946    }
947
948    #[test]
949    fn fanout_fixed_respects_n() {
950        let mut e = engine_with_strategy(FanoutStrategy::Fixed(2));
951        add_peers(&mut e, 6);
952        let peers = e.select_peers("ext", &FanoutStrategy::Fixed(2));
953        assert_eq!(peers.len(), 2);
954    }
955
956    #[test]
957    fn fanout_fixed_capped_by_available() {
958        let mut e = engine_with_strategy(FanoutStrategy::Fixed(10));
959        add_peers(&mut e, 3);
960        let peers = e.select_peers("ext", &FanoutStrategy::Fixed(10));
961        assert_eq!(peers.len(), 3);
962    }
963
964    #[test]
965    fn fanout_adaptive_min() {
966        let mut e = engine_with_strategy(FanoutStrategy::Adaptive { min: 2, max: 8 });
967        // 5 peers → extra = 5/10 = 0 → target = 2
968        add_peers(&mut e, 5);
969        let peers = e.select_peers("ext", &FanoutStrategy::Adaptive { min: 2, max: 8 });
970        assert!(peers.len() >= 2);
971    }
972
973    #[test]
974    fn fanout_adaptive_max_clamped() {
975        let mut e = engine_with_strategy(FanoutStrategy::Adaptive { min: 1, max: 3 });
976        add_peers(&mut e, 100);
977        let peers = e.select_peers("ext", &FanoutStrategy::Adaptive { min: 1, max: 3 });
978        assert!(peers.len() <= 3);
979    }
980
981    #[test]
982    fn fanout_epidemic_probability_zero() {
983        let mut e = engine_with_strategy(FanoutStrategy::Epidemic(0.0));
984        add_peers(&mut e, 10);
985        let peers = e.select_peers("ext", &FanoutStrategy::Epidemic(0.0));
986        assert!(peers.is_empty());
987    }
988
989    #[test]
990    fn fanout_epidemic_probability_one() {
991        let mut e = engine_with_strategy(FanoutStrategy::Epidemic(1.0));
992        add_peers(&mut e, 5);
993        let peers = e.select_peers("ext", &FanoutStrategy::Epidemic(1.0));
994        assert_eq!(peers.len(), 5);
995    }
996
997    #[test]
998    fn fanout_random_returns_fanout_size() {
999        let mut e = engine_with_strategy(FanoutStrategy::Random(42));
1000        add_peers(&mut e, 10);
1001        let peers = e.select_peers("ext", &FanoutStrategy::Random(42));
1002        assert_eq!(peers.len(), 3); // fanout_size = 3
1003    }
1004
1005    #[test]
1006    fn fanout_random_no_duplicates() {
1007        let mut e = engine_with_strategy(FanoutStrategy::Random(7));
1008        add_peers(&mut e, 10);
1009        let peers = e.select_peers("ext", &FanoutStrategy::Random(7));
1010        let mut sorted = peers.clone();
1011        sorted.sort();
1012        sorted.dedup();
1013        assert_eq!(peers.len(), sorted.len());
1014    }
1015
1016    #[test]
1017    fn fanout_priority_based_returns_fanout_size() {
1018        let mut e = engine_with_strategy(FanoutStrategy::PriorityBased);
1019        add_peers(&mut e, 8);
1020        // Assign differentiated scores.
1021        for i in 0..8_usize {
1022            e.peers[i].fanout_score = i as f64 / 8.0;
1023        }
1024        let peers = e.select_peers("ext", &FanoutStrategy::PriorityBased);
1025        assert_eq!(peers.len(), 3); // fanout_size = 3
1026    }
1027
1028    #[test]
1029    fn fanout_priority_based_orders_by_score() {
1030        let mut e = engine_with_strategy(FanoutStrategy::PriorityBased);
1031        for id in ["low", "high", "mid"] {
1032            e.add_peer(id.into())
1033                .expect("test: add_peer should succeed");
1034        }
1035        e.peers.iter_mut().for_each(|p| {
1036            p.fanout_score = match p.id.as_str() {
1037                "low" => 0.1,
1038                "high" => 0.9,
1039                "mid" => 0.5,
1040                _ => 0.0,
1041            };
1042        });
1043        let peers = e.select_peers("ext", &FanoutStrategy::PriorityBased);
1044        // First peer selected should be the highest-scored one.
1045        assert_eq!(peers[0], "high");
1046    }
1047
1048    #[test]
1049    fn fanout_no_active_peers_returns_empty() {
1050        let mut e = engine_with_strategy(FanoutStrategy::Fixed(5));
1051        let peers = e.select_peers("ext", &FanoutStrategy::Fixed(5));
1052        assert!(peers.is_empty());
1053    }
1054
1055    #[test]
1056    fn fanout_excludes_origin() {
1057        let mut e = engine_with_strategy(FanoutStrategy::Fixed(10));
1058        e.add_peer("origin".into())
1059            .expect("test: add_peer origin should succeed");
1060        e.add_peer("p2".into())
1061            .expect("test: add_peer p2 should succeed");
1062        let peers = e.select_peers("origin", &FanoutStrategy::Fixed(10));
1063        assert!(!peers.contains(&"origin".to_owned()));
1064    }
1065
1066    // ── 7. Peer scoring ───────────────────────────────────────────────────
1067
1068    #[test]
1069    fn update_score_positive_increases() {
1070        let mut e = make_engine();
1071        e.add_peer("p1".into())
1072            .expect("test: add_peer should succeed");
1073        let before = e
1074            .peer("p1")
1075            .expect("test: peer p1 should exist")
1076            .fanout_score;
1077        e.update_peer_score("p1", true);
1078        let after = e
1079            .peer("p1")
1080            .expect("test: peer p1 should exist after score update")
1081            .fanout_score;
1082        assert!(after > before);
1083    }
1084
1085    #[test]
1086    fn update_score_negative_decreases() {
1087        let mut e = make_engine();
1088        e.add_peer("p1".into())
1089            .expect("test: add_peer should succeed");
1090        // Start at 1.0 so we can observe a decrease.
1091        e.peers[0].fanout_score = 1.0;
1092        e.update_peer_score("p1", false);
1093        assert!(
1094            e.peer("p1")
1095                .expect("test: peer p1 should exist")
1096                .fanout_score
1097                < 1.0
1098        );
1099    }
1100
1101    #[test]
1102    fn update_score_clamps_to_one() {
1103        let mut e = make_engine();
1104        e.add_peer("p1".into())
1105            .expect("test: add_peer should succeed");
1106        e.peers[0].fanout_score = 0.999;
1107        for _ in 0..1000 {
1108            e.update_peer_score("p1", true);
1109        }
1110        assert!(
1111            e.peer("p1")
1112                .expect("test: peer p1 should exist")
1113                .fanout_score
1114                <= 1.0
1115        );
1116    }
1117
1118    #[test]
1119    fn update_score_clamps_to_zero() {
1120        let mut e = make_engine();
1121        e.add_peer("p1".into())
1122            .expect("test: add_peer should succeed");
1123        e.peers[0].fanout_score = 0.001;
1124        for _ in 0..1000 {
1125            e.update_peer_score("p1", false);
1126        }
1127        assert!(
1128            e.peer("p1")
1129                .expect("test: peer p1 should exist")
1130                .fanout_score
1131                >= 0.0
1132        );
1133    }
1134
1135    #[test]
1136    fn update_score_nonexistent_peer_is_noop() {
1137        let mut e = make_engine();
1138        // Should not panic.
1139        e.update_peer_score("ghost", true);
1140    }
1141
1142    // ── 8. Stats ──────────────────────────────────────────────────────────
1143
1144    #[test]
1145    fn stats_active_peers_reflects_add_remove() {
1146        let mut e = make_engine();
1147        e.add_peer("p1".into())
1148            .expect("test: add_peer should succeed");
1149        assert_eq!(e.stats().active_peers, 1);
1150        e.remove_peer("p1")
1151            .expect("test: remove_peer should succeed");
1152        assert_eq!(e.stats().active_peers, 0);
1153    }
1154
1155    #[test]
1156    fn stats_avg_propagation_hops_tracks_messages() {
1157        let mut e = make_engine();
1158        e.receive_message(make_msg("m1", "ext", 2, 7))
1159            .expect("test: receive m1 should succeed");
1160        e.receive_message(make_msg("m2", "ext", 4, 7))
1161            .expect("test: receive m2 should succeed");
1162        // After two messages: first sets avg=2.0, second: 0.9*2+0.1*4 = 2.2
1163        let avg = e.stats().avg_propagation_hops;
1164        assert!((avg - 2.2).abs() < 1e-9, "avg={avg}");
1165    }
1166
1167    #[test]
1168    fn stats_reset_on_clear_cache() {
1169        let mut e = make_engine();
1170        e.receive_message(make_msg("m1", "ext", 0, 7))
1171            .expect("test: receive m1 should succeed");
1172        assert_eq!(e.pending_messages(), 1);
1173        e.clear_dedup_cache();
1174        assert_eq!(e.pending_messages(), 0);
1175    }
1176
1177    // ── 9. Event log ──────────────────────────────────────────────────────
1178
1179    #[test]
1180    fn drain_events_returns_all_events() {
1181        let mut e = make_engine();
1182        e.add_peer("p1".into())
1183            .expect("test: add_peer should succeed");
1184        e.receive_message(make_msg("m1", "ext", 0, 7))
1185            .expect("test: receive_message should succeed");
1186        let events = e.drain_events();
1187        assert!(!events.is_empty());
1188    }
1189
1190    #[test]
1191    fn drain_events_clears_log() {
1192        let mut e = make_engine();
1193        e.add_peer("p1".into())
1194            .expect("test: add_peer should succeed");
1195        e.drain_events();
1196        let events2 = e.drain_events();
1197        assert!(events2.is_empty());
1198    }
1199
1200    #[test]
1201    fn event_log_order_preserved() {
1202        let mut e = make_engine();
1203        e.add_peer("p1".into())
1204            .expect("test: add_peer p1 should succeed");
1205        e.add_peer("p2".into())
1206            .expect("test: add_peer p2 should succeed");
1207        let events = e.drain_events();
1208        assert!(matches!(&events[0], GossipEvent::PeerAdded(id) if id == "p1"));
1209        assert!(matches!(&events[1], GossipEvent::PeerAdded(id) if id == "p2"));
1210    }
1211
1212    // ── 10. Edge cases ────────────────────────────────────────────────────
1213
1214    #[test]
1215    fn empty_payload_message_accepted() {
1216        let mut e = make_engine();
1217        let msg = GossipMessage {
1218            id: "empty".into(),
1219            origin_peer: "ext".into(),
1220            hop_count: 0,
1221            max_hops: 7,
1222            payload: vec![],
1223            timestamp: 1,
1224            topic: "t".into(),
1225        };
1226        let events = e
1227            .receive_message(msg)
1228            .expect("test: receive_message with empty payload should succeed");
1229        assert!(events
1230            .iter()
1231            .any(|ev| matches!(ev, GossipEvent::MessageReceived(_))));
1232    }
1233
1234    #[test]
1235    fn hop_count_u8_max_minus_one_dropped() {
1236        let mut e = make_engine();
1237        // max_hops = 254, hop_count = 254 → drop
1238        let msg = make_msg("big", "ext", 254, 254);
1239        let events = e
1240            .receive_message(msg)
1241            .expect("test: receive_message at u8 max hops should succeed");
1242        assert!(events.is_empty());
1243        assert_eq!(e.stats().messages_dropped_ttl, 1);
1244    }
1245
1246    #[test]
1247    fn many_messages_no_panic() {
1248        let mut e = GossipProtocolEngine::new(GossipConfig {
1249            dedup_cache_size: 128,
1250            ..GossipConfig::default()
1251        });
1252        add_peers(&mut e, 5);
1253        for i in 0_u32..256 {
1254            let msg = make_msg(&format!("{i:08x}"), "ext", 0, 7);
1255            e.receive_message(msg)
1256                .expect("test: receive_message in bulk loop should succeed");
1257        }
1258        assert!(e.stats().messages_received > 0);
1259    }
1260
1261    #[test]
1262    fn config_accessor() {
1263        let e = make_engine();
1264        assert_eq!(e.config().fanout_size, 6);
1265    }
1266
1267    #[test]
1268    fn peer_lookup_by_id() {
1269        let mut e = make_engine();
1270        e.add_peer("p42".into())
1271            .expect("test: add_peer p42 should succeed");
1272        let p = e.peer("p42").expect("test: peer p42 should be found");
1273        assert_eq!(p.id, "p42");
1274    }
1275
1276    #[test]
1277    fn peer_lookup_missing_returns_none() {
1278        let e = make_engine();
1279        assert!(e.peer("ghost").is_none());
1280    }
1281
1282    #[test]
1283    fn fnv1a_known_value() {
1284        // FNV-1a of empty input is the offset basis.
1285        assert_eq!(fnv1a_64(b""), 14_695_981_039_346_656_037);
1286    }
1287
1288    #[test]
1289    fn xorshift64_produces_nonzero() {
1290        let mut state = 12345_u64;
1291        let v = xorshift64(&mut state);
1292        assert_ne!(v, 0);
1293    }
1294
1295    #[test]
1296    fn xorshift64_advances_state() {
1297        let mut state = 12345_u64;
1298        let v1 = xorshift64(&mut state);
1299        let v2 = xorshift64(&mut state);
1300        assert_ne!(v1, v2);
1301    }
1302
1303    #[test]
1304    fn receive_multiple_topics_tracked_independently() {
1305        let mut e = make_engine();
1306        let m1 = GossipMessage {
1307            id: "t1m1".into(),
1308            origin_peer: "ext".into(),
1309            hop_count: 0,
1310            max_hops: 7,
1311            payload: vec![],
1312            timestamp: 1,
1313            topic: "news".into(),
1314        };
1315        let m2 = GossipMessage {
1316            id: "t2m1".into(),
1317            origin_peer: "ext".into(),
1318            hop_count: 0,
1319            max_hops: 7,
1320            payload: vec![],
1321            timestamp: 2,
1322            topic: "alerts".into(),
1323        };
1324        e.receive_message(m1)
1325            .expect("test: receive m1 (news topic) should succeed");
1326        e.receive_message(m2)
1327            .expect("test: receive m2 (alerts topic) should succeed");
1328        assert_eq!(e.stats().messages_received, 2);
1329    }
1330
1331    #[test]
1332    fn adaptive_strategy_scales_with_peer_count() {
1333        let mut e = engine_with_strategy(FanoutStrategy::Adaptive { min: 2, max: 20 });
1334        add_peers(&mut e, 50);
1335        let peers = e.select_peers("ext", &FanoutStrategy::Adaptive { min: 2, max: 20 });
1336        // 50 active peers → extra = 50/10 = 5 → target = min(7, 20) = 7
1337        assert_eq!(peers.len(), 7);
1338    }
1339
1340    #[test]
1341    fn random_strategy_seed_zero_uses_engine_rng() {
1342        let mut e = engine_with_strategy(FanoutStrategy::Random(0));
1343        add_peers(&mut e, 10);
1344        // Should not panic and should return some peers.
1345        let peers = e.select_peers("ext", &FanoutStrategy::Random(0));
1346        assert_eq!(peers.len(), 3); // fanout_size = 3
1347    }
1348
1349    #[test]
1350    fn gossip_event_message_forwarded_contains_correct_id() {
1351        let mut e = make_engine();
1352        add_peers(&mut e, 2);
1353        let msg = make_msg("fwd_id_check", "ext", 0, 7);
1354        let events = e
1355            .receive_message(msg)
1356            .expect("test: receive_message should succeed");
1357        let fwd = events.iter().find_map(|ev| {
1358            if let GossipEvent::MessageForwarded { msg_id, .. } = ev {
1359                Some(msg_id.clone())
1360            } else {
1361                None
1362            }
1363        });
1364        assert_eq!(
1365            fwd.expect("test: MessageForwarded event should be present"),
1366            "fwd_id_check"
1367        );
1368    }
1369
1370    #[test]
1371    fn engine_new_with_zero_fanout() {
1372        // When fanout_size=0 AND strategy=Fixed(0), no peers are selected.
1373        let mut e = GossipProtocolEngine::new(GossipConfig {
1374            fanout_size: 0,
1375            strategy: FanoutStrategy::Fixed(0),
1376            ..GossipConfig::default()
1377        });
1378        add_peers(&mut e, 5);
1379        let msg = make_msg("z", "ext", 0, 7);
1380        let events = e
1381            .receive_message(msg)
1382            .expect("test: receive_message with zero fanout should succeed");
1383        // zero fanout → no forwarding event.
1384        assert!(!events
1385            .iter()
1386            .any(|ev| matches!(ev, GossipEvent::MessageForwarded { .. })));
1387    }
1388
1389    #[test]
1390    fn engine_default_config_fanout_six() {
1391        let e = GossipProtocolEngine::with_defaults();
1392        assert_eq!(e.config().fanout_size, 6);
1393    }
1394
1395    #[test]
1396    fn statistics_snapshot_is_clone() {
1397        let e = make_engine();
1398        let s1 = e.stats();
1399        let s2 = s1.clone();
1400        assert_eq!(s1.messages_received, s2.messages_received);
1401    }
1402}