1use std::collections::VecDeque;
16
17#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct GossipMessage {
50 pub id: String,
52 pub origin_peer: String,
54 pub hop_count: u8,
56 pub max_hops: u8,
58 pub payload: Vec<u8>,
60 pub timestamp: u64,
62 pub topic: String,
64}
65
66#[derive(Debug, Clone)]
68pub struct GossipPeer {
69 pub id: String,
71 pub last_seen: u64,
73 pub message_count: u64,
75 pub is_active: bool,
77 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#[derive(Debug, Clone)]
95pub enum FanoutStrategy {
96 Fixed(usize),
98 Adaptive {
100 min: usize,
102 max: usize,
104 },
105 Epidemic(f64),
107 Random(u64),
109 PriorityBased,
111}
112
113#[derive(Debug, Clone)]
115pub struct GossipConfig {
116 pub fanout_size: usize,
118 pub max_hops: u8,
120 pub dedup_cache_size: usize,
122 pub gossip_interval_ms: u64,
124 pub min_peers_for_gossip: usize,
126 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#[derive(Debug, Clone, Default)]
145pub struct GossipStats {
146 pub messages_received: u64,
148 pub messages_forwarded: u64,
150 pub messages_dropped_duplicate: u64,
152 pub messages_dropped_ttl: u64,
154 pub active_peers: usize,
156 pub avg_propagation_hops: f64,
158}
159
160#[derive(Debug, Clone)]
162pub enum GossipEvent {
163 MessageReceived(GossipMessage),
165 MessageForwarded {
167 msg_id: String,
169 to_peers: Vec<String>,
171 },
172 PeerAdded(String),
174 PeerRemoved(String),
176 DuplicateDropped(String),
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum EngineError {
183 PeerNotFound(String),
185 MessageTooLarge(usize),
187 NoPeersAvailable,
189 InvalidHopCount,
191 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
209pub struct GossipProtocolEngine {
219 config: GossipConfig,
220 peers: Vec<GossipPeer>,
221 dedup_cache: VecDeque<String>,
222 stats: GossipStats,
223 event_log: Vec<GossipEvent>,
224 rng_state: u64,
226 hop_ema_n: u64,
228}
229
230impl GossipProtocolEngine {
231 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 pub fn with_defaults() -> Self {
254 Self::new(GossipConfig::default())
255 }
256
257 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 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 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(×tamp.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 pub fn receive_message(&mut self, msg: GossipMessage) -> Result<Vec<GossipEvent>, EngineError> {
322 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 if msg.hop_count >= msg.max_hops {
332 self.stats.messages_dropped_ttl += 1;
333 return Ok(vec![]);
334 }
335
336 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 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 let strategy = self.config.strategy.clone();
358 let targets = self.select_peers(&msg.origin_peer, &strategy);
359
360 if !targets.is_empty() {
361 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 for peer_id in &targets {
375 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 pub fn select_peers(&mut self, exclude: &str, strategy: &FanoutStrategy) -> Vec<String> {
391 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 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 let mut indices: Vec<usize> = (0..active_count).collect();
441 let mut state = if *seed == 0 { self.rng_state } else { *seed };
442 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 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 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 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 peer.fanout_score = peer.fanout_score.clamp(0.0, 1.0);
481 }
482 }
483
484 pub fn pending_messages(&self) -> usize {
488 self.dedup_cache.len()
489 }
490
491 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 pub fn drain_events(&mut self) -> Vec<GossipEvent> {
500 std::mem::take(&mut self.event_log)
501 }
502
503 pub fn config(&self) -> &GossipConfig {
505 &self.config
506 }
507
508 pub fn peer_count(&self) -> usize {
510 self.peers.len()
511 }
512
513 pub fn peer(&self, peer_id: &str) -> Option<&GossipPeer> {
515 self.peers.iter().find(|p| p.id == peer_id)
516 }
517
518 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 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 pub fn clear_dedup_cache(&mut self) {
544 self.dedup_cache.clear();
545 }
546
547 fn active_peer_count(&self) -> usize {
550 self.peers.iter().filter(|p| p.is_active).count()
551 }
552}
553
554#[cfg(test)]
559mod tests {
560 use super::*;
561
562 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 #[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 #[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 #[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 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 #[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 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 e.receive_message(make_msg("m2", "ext", 0, 7))
866 .expect("test: receive m2 should succeed");
867 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 #[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); 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); 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 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 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); }
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 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); }
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 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 #[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 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 e.update_peer_score("ghost", true);
1140 }
1141
1142 #[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 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 #[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 #[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 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 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 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 let peers = e.select_peers("ext", &FanoutStrategy::Random(0));
1346 assert_eq!(peers.len(), 3); }
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 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 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}