Skip to main content

ipfrs_network/
gossipsub.rs

1//! GossipSub - Topic-based pub/sub messaging
2//!
3//! This module provides efficient topic-based publish/subscribe messaging
4//! using the GossipSub protocol from libp2p.
5//!
6//! ## Features
7//!
8//! - **Topic Subscription**: Subscribe to topics of interest
9//! - **Message Publishing**: Publish messages to topics
10//! - **Mesh Formation**: Automatic peer mesh formation for topic propagation
11//! - **Message Deduplication**: Seen message tracking to prevent duplicates
12//! - **Peer Scoring**: Score-based peer selection for mesh quality
13//! - **Content Announcements**: Broadcast new content availability
14//!
15//! ## Design
16//!
17//! GossipSub maintains a mesh of peers for each topic, ensuring:
18//! - Low latency message delivery
19//! - High reliability through redundancy
20//! - Efficient bandwidth usage through mesh optimization
21//! - Resistance to spam and malicious peers through scoring
22
23use dashmap::DashMap;
24use libp2p::PeerId;
25use parking_lot::RwLock;
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28use std::sync::Arc;
29use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
30use thiserror::Error;
31
32/// Errors that can occur in GossipSub operations
33#[derive(Error, Debug)]
34pub enum GossipSubError {
35    #[error("Topic not found: {0}")]
36    TopicNotFound(String),
37
38    #[error("Already subscribed to topic: {0}")]
39    AlreadySubscribed(String),
40
41    #[error("Not subscribed to topic: {0}")]
42    NotSubscribed(String),
43
44    #[error("Message too large: {size} bytes (max: {max})")]
45    MessageTooLarge { size: usize, max: usize },
46
47    #[error("Invalid topic name: {0}")]
48    InvalidTopicName(String),
49
50    #[error("Peer scoring error: {0}")]
51    ScoringError(String),
52}
53
54/// GossipSub configuration
55#[derive(Debug, Clone)]
56pub struct GossipSubConfig {
57    /// Minimum number of peers in mesh (D_low)
58    pub mesh_n_low: usize,
59
60    /// Target number of peers in mesh (D)
61    pub mesh_n: usize,
62
63    /// Maximum number of peers in mesh (D_high)
64    pub mesh_n_high: usize,
65
66    /// Number of peers to send gossip to (D_lazy)
67    pub gossip_n: usize,
68
69    /// Heartbeat interval for mesh maintenance
70    pub heartbeat_interval: Duration,
71
72    /// Maximum message size
73    pub max_message_size: usize,
74
75    /// Enable peer scoring
76    pub enable_scoring: bool,
77
78    /// Time window for message deduplication
79    pub duplicate_cache_time: Duration,
80
81    /// Maximum number of messages in duplicate cache
82    pub max_duplicate_cache_size: usize,
83
84    /// Enable message validation
85    pub enable_validation: bool,
86}
87
88impl Default for GossipSubConfig {
89    fn default() -> Self {
90        Self {
91            mesh_n_low: 4,
92            mesh_n: 6,
93            mesh_n_high: 12,
94            gossip_n: 3,
95            heartbeat_interval: Duration::from_secs(1),
96            max_message_size: 1024 * 1024, // 1 MB
97            enable_scoring: true,
98            duplicate_cache_time: Duration::from_secs(120),
99            max_duplicate_cache_size: 10000,
100            enable_validation: true,
101        }
102    }
103}
104
105/// Topic identifier
106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
107pub struct TopicId(pub String);
108
109impl TopicId {
110    pub fn new(name: impl Into<String>) -> Self {
111        Self(name.into())
112    }
113
114    /// Topic for content announcements
115    pub fn content_announce() -> Self {
116        Self("/ipfrs/content/announce/1.0.0".to_string())
117    }
118
119    /// Topic for peer announcements
120    pub fn peer_announce() -> Self {
121        Self("/ipfrs/peer/announce/1.0.0".to_string())
122    }
123
124    /// Topic for DHT events
125    pub fn dht_events() -> Self {
126        Self("/ipfrs/dht/events/1.0.0".to_string())
127    }
128}
129
130/// GossipSub message
131#[derive(Debug, Clone)]
132pub struct GossipSubMessage {
133    /// Message ID
134    pub id: MessageId,
135
136    /// Source peer
137    pub source: PeerId,
138
139    /// Topic this message belongs to
140    pub topic: TopicId,
141
142    /// Message payload
143    pub data: Vec<u8>,
144
145    /// Sequence number
146    pub sequence: u64,
147
148    /// Timestamp
149    pub timestamp: Instant,
150}
151
152/// Message identifier
153#[derive(Debug, Clone, PartialEq, Eq, Hash)]
154pub struct MessageId(pub Vec<u8>);
155
156impl MessageId {
157    /// Create a message ID from source peer and sequence number
158    pub fn new(source: &PeerId, sequence: u64) -> Self {
159        let mut data = source.to_bytes();
160        data.extend_from_slice(&sequence.to_le_bytes());
161        Self(data)
162    }
163}
164
165/// Peer score for mesh quality
166#[derive(Debug, Clone, Default)]
167pub struct PeerScore {
168    /// Topic-specific scores
169    pub topic_scores: HashMap<TopicId, f64>,
170
171    /// Overall score
172    pub total_score: f64,
173
174    /// Number of invalid messages
175    pub invalid_messages: u64,
176
177    /// Number of valid messages
178    pub valid_messages: u64,
179
180    /// Last update time
181    pub last_update: Option<Instant>,
182}
183
184impl PeerScore {
185    /// Calculate overall score
186    pub fn calculate_total(&mut self) {
187        if self.topic_scores.is_empty() {
188            self.total_score = 0.0;
189            return;
190        }
191
192        // Average of topic scores
193        let sum: f64 = self.topic_scores.values().sum();
194        self.total_score = sum / self.topic_scores.len() as f64;
195
196        // Penalize for invalid messages
197        let total_messages = self.invalid_messages + self.valid_messages;
198        if total_messages > 0 {
199            let invalid_ratio = self.invalid_messages as f64 / total_messages as f64;
200            self.total_score *= 1.0 - invalid_ratio;
201        }
202
203        self.last_update = Some(Instant::now());
204    }
205
206    /// Update topic score
207    pub fn update_topic_score(&mut self, topic: TopicId, score: f64) {
208        self.topic_scores.insert(topic, score);
209        self.calculate_total();
210    }
211
212    /// Record message validation result
213    pub fn record_message(&mut self, valid: bool) {
214        if valid {
215            self.valid_messages += 1;
216        } else {
217            self.invalid_messages += 1;
218        }
219        self.calculate_total();
220    }
221}
222
223/// Topic subscription information
224#[derive(Debug, Clone)]
225pub struct TopicSubscription {
226    /// Topic ID
227    pub topic: TopicId,
228
229    /// Subscribed since
230    pub subscribed_at: Instant,
231
232    /// Mesh peers for this topic
233    pub mesh_peers: HashSet<PeerId>,
234
235    /// Number of messages received
236    pub messages_received: u64,
237
238    /// Number of messages published
239    pub messages_published: u64,
240}
241
242/// GossipSub statistics
243#[derive(Debug, Clone, Default, Serialize, Deserialize)]
244pub struct GossipSubStats {
245    /// Total topics subscribed
246    pub subscribed_topics: usize,
247
248    /// Total messages published
249    pub messages_published: u64,
250
251    /// Total messages received
252    pub messages_received: u64,
253
254    /// Total duplicate messages seen
255    pub duplicate_messages: u64,
256
257    /// Total invalid messages
258    pub invalid_messages: u64,
259
260    /// Active mesh peers
261    pub active_mesh_peers: usize,
262
263    /// Mesh prune events
264    pub mesh_prune_count: u64,
265
266    /// Mesh graft events
267    pub mesh_graft_count: u64,
268
269    /// Messages per topic
270    pub messages_per_topic: HashMap<String, u64>,
271}
272
273/// Message seen cache entry
274#[derive(Debug, Clone)]
275struct SeenCacheEntry {
276    timestamp: Instant,
277}
278
279/// GossipSub manager
280pub struct GossipSubManager {
281    /// Configuration
282    config: GossipSubConfig,
283
284    /// Subscribed topics
285    subscriptions: Arc<DashMap<TopicId, TopicSubscription>>,
286
287    /// Peer scores
288    peer_scores: Arc<DashMap<PeerId, PeerScore>>,
289
290    /// Seen message cache (deduplication)
291    seen_messages: Arc<DashMap<MessageId, SeenCacheEntry>>,
292
293    /// Message sequence number counter
294    sequence_counter: Arc<RwLock<u64>>,
295
296    /// Statistics
297    stats: Arc<RwLock<GossipSubStats>>,
298}
299
300impl GossipSubManager {
301    /// Create a new GossipSub manager
302    pub fn new(config: GossipSubConfig) -> Self {
303        Self {
304            config,
305            subscriptions: Arc::new(DashMap::new()),
306            peer_scores: Arc::new(DashMap::new()),
307            seen_messages: Arc::new(DashMap::new()),
308            sequence_counter: Arc::new(RwLock::new(0)),
309            stats: Arc::new(RwLock::new(GossipSubStats::default())),
310        }
311    }
312
313    /// Subscribe to a topic
314    pub fn subscribe(&self, topic: TopicId) -> Result<(), GossipSubError> {
315        if self.subscriptions.contains_key(&topic) {
316            return Err(GossipSubError::AlreadySubscribed(topic.0.clone()));
317        }
318
319        let subscription = TopicSubscription {
320            topic: topic.clone(),
321            subscribed_at: Instant::now(),
322            mesh_peers: HashSet::new(),
323            messages_received: 0,
324            messages_published: 0,
325        };
326
327        self.subscriptions.insert(topic.clone(), subscription);
328
329        let mut stats = self.stats.write();
330        stats.subscribed_topics = self.subscriptions.len();
331
332        Ok(())
333    }
334
335    /// Unsubscribe from a topic
336    pub fn unsubscribe(&self, topic: &TopicId) -> Result<(), GossipSubError> {
337        self.subscriptions
338            .remove(topic)
339            .ok_or_else(|| GossipSubError::NotSubscribed(topic.0.clone()))?;
340
341        let mut stats = self.stats.write();
342        stats.subscribed_topics = self.subscriptions.len();
343
344        Ok(())
345    }
346
347    /// Publish a message to a topic
348    pub fn publish(
349        &self,
350        topic: TopicId,
351        data: Vec<u8>,
352        source: PeerId,
353    ) -> Result<MessageId, GossipSubError> {
354        // Check if subscribed
355        if !self.subscriptions.contains_key(&topic) {
356            return Err(GossipSubError::NotSubscribed(topic.0.clone()));
357        }
358
359        // Check message size
360        if data.len() > self.config.max_message_size {
361            return Err(GossipSubError::MessageTooLarge {
362                size: data.len(),
363                max: self.config.max_message_size,
364            });
365        }
366
367        // Generate sequence number
368        let sequence = {
369            let mut counter = self.sequence_counter.write();
370            *counter += 1;
371            *counter
372        };
373
374        // Create message ID
375        let message_id = MessageId::new(&source, sequence);
376
377        // Update statistics
378        if let Some(mut subscription) = self.subscriptions.get_mut(&topic) {
379            subscription.messages_published += 1;
380        }
381
382        let mut stats = self.stats.write();
383        stats.messages_published += 1;
384        *stats.messages_per_topic.entry(topic.0.clone()).or_insert(0) += 1;
385
386        Ok(message_id)
387    }
388
389    /// Handle received message
390    pub fn handle_message(&self, message: GossipSubMessage) -> Result<bool, GossipSubError> {
391        // Check for duplicate
392        if self.is_duplicate(&message.id) {
393            let mut stats = self.stats.write();
394            stats.duplicate_messages += 1;
395            return Ok(false); // Message already seen
396        }
397
398        // Add to seen cache
399        self.add_to_seen_cache(message.id.clone());
400
401        // Validate message if enabled
402        if self.config.enable_validation && !self.validate_message(&message) {
403            let mut stats = self.stats.write();
404            stats.invalid_messages += 1;
405
406            // Update peer score
407            if self.config.enable_scoring {
408                if let Some(mut score) = self.peer_scores.get_mut(&message.source) {
409                    score.record_message(false);
410                }
411            }
412
413            return Ok(false);
414        }
415
416        // Update statistics
417        if let Some(mut subscription) = self.subscriptions.get_mut(&message.topic) {
418            subscription.messages_received += 1;
419        }
420
421        let mut stats = self.stats.write();
422        stats.messages_received += 1;
423
424        // Update peer score
425        if self.config.enable_scoring {
426            self.peer_scores
427                .entry(message.source)
428                .or_default()
429                .record_message(true);
430        }
431
432        Ok(true) // Message is new and valid
433    }
434
435    /// Check if message is a duplicate
436    fn is_duplicate(&self, message_id: &MessageId) -> bool {
437        if let Some(entry) = self.seen_messages.get(message_id) {
438            let age = Instant::now().duration_since(entry.timestamp);
439            return age < self.config.duplicate_cache_time;
440        }
441        false
442    }
443
444    /// Add message to seen cache
445    fn add_to_seen_cache(&self, message_id: MessageId) {
446        let entry = SeenCacheEntry {
447            timestamp: Instant::now(),
448        };
449
450        self.seen_messages.insert(message_id, entry);
451
452        // Cleanup old entries if cache is too large
453        if self.seen_messages.len() > self.config.max_duplicate_cache_size {
454            self.cleanup_seen_cache();
455        }
456    }
457
458    /// Cleanup old entries from seen cache
459    fn cleanup_seen_cache(&self) {
460        let now = Instant::now();
461        let ttl = self.config.duplicate_cache_time;
462
463        self.seen_messages
464            .retain(|_, entry| now.duration_since(entry.timestamp) < ttl);
465    }
466
467    /// Validate message
468    fn validate_message(&self, _message: &GossipSubMessage) -> bool {
469        // Basic validation - can be extended
470        // Check if source peer is not banned, message format is correct, etc.
471        true
472    }
473
474    /// Add peer to topic mesh
475    pub fn add_peer_to_mesh(&self, topic: &TopicId, peer: PeerId) -> Result<(), GossipSubError> {
476        let inserted = {
477            let mut subscription = self
478                .subscriptions
479                .get_mut(topic)
480                .ok_or_else(|| GossipSubError::NotSubscribed(topic.0.clone()))?;
481            subscription.mesh_peers.insert(peer)
482        }; // Guard dropped here before count_mesh_peers()
483
484        if inserted {
485            let mut stats = self.stats.write();
486            stats.mesh_graft_count += 1;
487            stats.active_mesh_peers = self.count_mesh_peers();
488        }
489
490        Ok(())
491    }
492
493    /// Remove peer from topic mesh
494    pub fn remove_peer_from_mesh(
495        &self,
496        topic: &TopicId,
497        peer: &PeerId,
498    ) -> Result<(), GossipSubError> {
499        let removed = {
500            let mut subscription = self
501                .subscriptions
502                .get_mut(topic)
503                .ok_or_else(|| GossipSubError::NotSubscribed(topic.0.clone()))?;
504            subscription.mesh_peers.remove(peer)
505        }; // Guard dropped here before count_mesh_peers()
506
507        if removed {
508            let mut stats = self.stats.write();
509            stats.mesh_prune_count += 1;
510            stats.active_mesh_peers = self.count_mesh_peers();
511        }
512
513        Ok(())
514    }
515
516    /// Get peers in topic mesh
517    pub fn get_mesh_peers(&self, topic: &TopicId) -> Result<Vec<PeerId>, GossipSubError> {
518        let subscription = self
519            .subscriptions
520            .get(topic)
521            .ok_or_else(|| GossipSubError::NotSubscribed(topic.0.clone()))?;
522
523        Ok(subscription.mesh_peers.iter().cloned().collect())
524    }
525
526    /// Count total mesh peers across all topics
527    fn count_mesh_peers(&self) -> usize {
528        self.subscriptions
529            .iter()
530            .map(|entry| entry.mesh_peers.len())
531            .sum()
532    }
533
534    /// Get peer score
535    pub fn get_peer_score(&self, peer: &PeerId) -> Option<PeerScore> {
536        self.peer_scores.get(peer).map(|s| s.clone())
537    }
538
539    /// Update peer score for a topic
540    pub fn update_peer_score(&self, peer: &PeerId, topic: TopicId, score: f64) {
541        self.peer_scores
542            .entry(*peer)
543            .or_default()
544            .update_topic_score(topic, score);
545    }
546
547    /// Get low-scoring peers that should be pruned
548    pub fn get_peers_to_prune(&self, topic: &TopicId, threshold: f64) -> Vec<PeerId> {
549        let subscription = match self.subscriptions.get(topic) {
550            Some(sub) => sub,
551            None => return Vec::new(),
552        };
553
554        subscription
555            .mesh_peers
556            .iter()
557            .filter(|peer| {
558                if let Some(score) = self.peer_scores.get(peer) {
559                    score.total_score < threshold
560                } else {
561                    false
562                }
563            })
564            .cloned()
565            .collect()
566    }
567
568    /// Get statistics
569    pub fn stats(&self) -> GossipSubStats {
570        self.stats.read().clone()
571    }
572
573    /// List subscribed topics
574    pub fn list_topics(&self) -> Vec<TopicId> {
575        self.subscriptions
576            .iter()
577            .map(|entry| entry.key().clone())
578            .collect()
579    }
580
581    /// Check if subscribed to a topic
582    pub fn is_subscribed(&self, topic: &TopicId) -> bool {
583        self.subscriptions.contains_key(topic)
584    }
585}
586
587/// Standard GossipSub topics for IPFRS protocol messages
588pub mod topics {
589    /// Inference requests broadcast to all peers
590    pub const INFERENCE_REQUEST: &str = "/ipfrs/inference/request/1.0.0";
591    /// Inference results streamed back to requester
592    pub const INFERENCE_RESULT: &str = "/ipfrs/inference/result/1.0.0";
593    /// DHT provider announcements
594    pub const PROVIDER_ANNOUNCE: &str = "/ipfrs/provider/announce/1.0.0";
595    /// Block availability announcements
596    pub const BLOCK_ANNOUNCE: &str = "/ipfrs/block/announce/1.0.0";
597    /// Gradient synchronization
598    pub const GRADIENT_SYNC: &str = "/ipfrs/gradient/sync/1.0.0";
599    /// Knowledge base delta updates
600    pub const KB_DELTA: &str = "/ipfrs/kb/delta/1.0.0";
601}
602
603/// Message envelope for all IPFRS GossipSub messages
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct TopicMessage {
606    pub topic: String,
607    pub sender_peer_id: String,
608    pub payload: Vec<u8>,
609    pub timestamp: u64,
610    pub sequence: u64,
611}
612
613impl TopicMessage {
614    /// Create a new topic message with the current Unix timestamp
615    pub fn new(topic: &str, sender: &str, payload: Vec<u8>) -> Self {
616        let timestamp = SystemTime::now()
617            .duration_since(UNIX_EPOCH)
618            .unwrap_or_default()
619            .as_secs();
620        Self {
621            topic: topic.to_string(),
622            sender_peer_id: sender.to_string(),
623            payload,
624            timestamp,
625            sequence: 0,
626        }
627    }
628
629    /// Encode the message to JSON bytes
630    pub fn encode(&self) -> Result<Vec<u8>, serde_json::Error> {
631        serde_json::to_vec(self)
632    }
633
634    /// Decode a message from JSON bytes
635    pub fn decode(bytes: &[u8]) -> Result<Self, serde_json::Error> {
636        serde_json::from_slice(bytes)
637    }
638}
639
640/// Type-safe topic handle
641pub struct IpfrsTopic {
642    pub name: String,
643    pub hash: libp2p::gossipsub::TopicHash,
644}
645
646impl IpfrsTopic {
647    pub fn new(name: &str) -> Self {
648        use libp2p::gossipsub::IdentTopic;
649        let topic = IdentTopic::new(name);
650        IpfrsTopic {
651            name: name.to_string(),
652            hash: topic.hash(),
653        }
654    }
655}
656
657impl GossipSubManager {
658    /// Subscribe to all standard IPFRS inference topics
659    pub fn subscribe_inference_topics(&self) -> Result<(), GossipSubError> {
660        use topics::{
661            BLOCK_ANNOUNCE, GRADIENT_SYNC, INFERENCE_REQUEST, INFERENCE_RESULT, KB_DELTA,
662            PROVIDER_ANNOUNCE,
663        };
664        let all_topics = [
665            INFERENCE_REQUEST,
666            INFERENCE_RESULT,
667            PROVIDER_ANNOUNCE,
668            BLOCK_ANNOUNCE,
669            GRADIENT_SYNC,
670            KB_DELTA,
671        ];
672        for t in &all_topics {
673            let tid = TopicId::new(*t);
674            // Subscribing when already subscribed is acceptable during bulk init
675            match self.subscribe(tid) {
676                Ok(()) | Err(GossipSubError::AlreadySubscribed(_)) => {}
677                Err(e) => return Err(e),
678            }
679        }
680        Ok(())
681    }
682
683    /// Publish an inference request to all subscribers
684    pub fn publish_inference_request(
685        &self,
686        request_bytes: &[u8],
687        sender_peer_id: &str,
688    ) -> Result<(), GossipSubError> {
689        let msg = TopicMessage::new(
690            topics::INFERENCE_REQUEST,
691            sender_peer_id,
692            request_bytes.to_vec(),
693        );
694        let encoded = msg
695            .encode()
696            .map_err(|e| GossipSubError::InvalidTopicName(format!("encode error: {}", e)))?;
697        let topic = TopicId::new(topics::INFERENCE_REQUEST);
698        // We need a source PeerId; use the zero peer as a stand-in for the local node
699        // (real integration would pass in the local PeerId)
700        let source = self.dummy_local_peer();
701        self.publish(topic, encoded, source)?;
702        Ok(())
703    }
704
705    /// Publish an inference result to subscribers
706    pub fn publish_inference_result(
707        &self,
708        result_bytes: &[u8],
709        sender_peer_id: &str,
710    ) -> Result<(), GossipSubError> {
711        let msg = TopicMessage::new(
712            topics::INFERENCE_RESULT,
713            sender_peer_id,
714            result_bytes.to_vec(),
715        );
716        let encoded = msg
717            .encode()
718            .map_err(|e| GossipSubError::InvalidTopicName(format!("encode error: {}", e)))?;
719        let topic = TopicId::new(topics::INFERENCE_RESULT);
720        let source = self.dummy_local_peer();
721        self.publish(topic, encoded, source)?;
722        Ok(())
723    }
724
725    /// Publish a block availability announcement
726    pub fn announce_block(&self, cid: &str, sender_peer_id: &str) -> Result<(), GossipSubError> {
727        let msg = TopicMessage::new(
728            topics::BLOCK_ANNOUNCE,
729            sender_peer_id,
730            cid.as_bytes().to_vec(),
731        );
732        let encoded = msg
733            .encode()
734            .map_err(|e| GossipSubError::InvalidTopicName(format!("encode error: {}", e)))?;
735        let topic = TopicId::new(topics::BLOCK_ANNOUNCE);
736        let source = self.dummy_local_peer();
737        self.publish(topic, encoded, source)?;
738        Ok(())
739    }
740
741    /// Publish a gradient CID to the `GRADIENT_SYNC` topic so that peers can fetch it.
742    ///
743    /// The `cid` is the content-address of the Arrow IPC block holding the gradient tensor.
744    /// `sender_peer_id` is the local node's peer-id string used as the message source.
745    pub fn publish_gradient_cid(
746        &self,
747        cid: &str,
748        sender_peer_id: &str,
749    ) -> Result<(), GossipSubError> {
750        let msg = TopicMessage::new(
751            topics::GRADIENT_SYNC,
752            sender_peer_id,
753            cid.as_bytes().to_vec(),
754        );
755        let encoded = msg
756            .encode()
757            .map_err(|e| GossipSubError::InvalidTopicName(format!("encode error: {e}")))?;
758        let topic = TopicId::new(topics::GRADIENT_SYNC);
759        let source = self.dummy_local_peer();
760        self.publish(topic, encoded, source)?;
761        Ok(())
762    }
763
764    /// Publish a provider record announcement
765    pub fn announce_provider(&self, cid: &str, peer_id: &str) -> Result<(), GossipSubError> {
766        let msg = TopicMessage::new(topics::PROVIDER_ANNOUNCE, peer_id, cid.as_bytes().to_vec());
767        let encoded = msg
768            .encode()
769            .map_err(|e| GossipSubError::InvalidTopicName(format!("encode error: {}", e)))?;
770        let topic = TopicId::new(topics::PROVIDER_ANNOUNCE);
771        let source = self.dummy_local_peer();
772        self.publish(topic, encoded, source)?;
773        Ok(())
774    }
775
776    /// Return a deterministic placeholder PeerId used when no local peer id is configured.
777    /// This is intentionally not random so tests are reproducible and the manager remains
778    /// Send + Sync without needing to store a PeerId field.
779    fn dummy_local_peer(&self) -> PeerId {
780        use libp2p::identity::Keypair;
781        let seed = [0u8; 32];
782        // Safe: valid fixed seed, only used as a stand-in source identifier
783        Keypair::ed25519_from_bytes(seed.to_vec())
784            .map(|kp| kp.public().to_peer_id())
785            .unwrap_or_else(|_| PeerId::random())
786    }
787}
788
789#[cfg(test)]
790mod topic_tests {
791    use super::*;
792    use topics::{
793        BLOCK_ANNOUNCE, GRADIENT_SYNC, INFERENCE_REQUEST, INFERENCE_RESULT, KB_DELTA,
794        PROVIDER_ANNOUNCE,
795    };
796
797    #[test]
798    fn test_topic_message_roundtrip() {
799        let original =
800            TopicMessage::new(INFERENCE_REQUEST, "peer-abc", b"hello inference".to_vec());
801        let encoded = original.encode().expect("encode should succeed");
802        let decoded = TopicMessage::decode(&encoded).expect("decode should succeed");
803        assert_eq!(decoded.topic, INFERENCE_REQUEST);
804        assert_eq!(decoded.sender_peer_id, "peer-abc");
805        assert_eq!(decoded.payload, b"hello inference");
806    }
807
808    #[test]
809    fn test_all_topic_constants_unique() {
810        let topics = [
811            INFERENCE_REQUEST,
812            INFERENCE_RESULT,
813            PROVIDER_ANNOUNCE,
814            BLOCK_ANNOUNCE,
815            GRADIENT_SYNC,
816            KB_DELTA,
817        ];
818        let unique: std::collections::HashSet<_> = topics.iter().collect();
819        assert_eq!(
820            unique.len(),
821            topics.len(),
822            "all topic constants must be unique"
823        );
824    }
825
826    #[test]
827    fn test_ipfrs_topic_hash() {
828        let t1 = IpfrsTopic::new(INFERENCE_REQUEST);
829        let t2 = IpfrsTopic::new(INFERENCE_REQUEST);
830        let t3 = IpfrsTopic::new(INFERENCE_RESULT);
831
832        assert_eq!(t1.name, INFERENCE_REQUEST);
833        assert_eq!(t1.hash, t2.hash, "same name must produce same hash");
834        assert_ne!(
835            t1.hash, t3.hash,
836            "different names must produce different hashes"
837        );
838    }
839
840    #[test]
841    fn test_subscribe_inference_topics_subscribes_all() {
842        let manager = GossipSubManager::new(GossipSubConfig::default());
843        manager
844            .subscribe_inference_topics()
845            .expect("should subscribe without error");
846
847        let all_topics = [
848            INFERENCE_REQUEST,
849            INFERENCE_RESULT,
850            PROVIDER_ANNOUNCE,
851            BLOCK_ANNOUNCE,
852            GRADIENT_SYNC,
853            KB_DELTA,
854        ];
855        for t in &all_topics {
856            assert!(
857                manager.is_subscribed(&TopicId::new(*t)),
858                "should be subscribed to {}",
859                t
860            );
861        }
862    }
863
864    #[test]
865    fn test_subscribe_inference_topics_idempotent() {
866        let manager = GossipSubManager::new(GossipSubConfig::default());
867        manager.subscribe_inference_topics().expect("first call ok");
868        // Second call should not error even though already subscribed
869        manager
870            .subscribe_inference_topics()
871            .expect("second call should also be ok");
872    }
873
874    #[test]
875    fn test_publish_inference_request() {
876        let manager = GossipSubManager::new(GossipSubConfig::default());
877        manager.subscribe_inference_topics().expect("subscribe");
878        manager
879            .publish_inference_request(b"req_data", "peer-1")
880            .expect("publish inference request");
881        let stats = manager.stats();
882        assert!(stats.messages_published >= 1);
883    }
884
885    #[test]
886    fn test_publish_inference_result() {
887        let manager = GossipSubManager::new(GossipSubConfig::default());
888        manager.subscribe_inference_topics().expect("subscribe");
889        manager
890            .publish_inference_result(b"result_data", "peer-2")
891            .expect("publish inference result");
892        let stats = manager.stats();
893        assert!(stats.messages_published >= 1);
894    }
895
896    #[test]
897    fn test_announce_block() {
898        let manager = GossipSubManager::new(GossipSubConfig::default());
899        manager.subscribe_inference_topics().expect("subscribe");
900        manager
901            .announce_block("bafyreiabc123", "peer-3")
902            .expect("announce block");
903        let stats = manager.stats();
904        assert!(stats.messages_published >= 1);
905    }
906
907    #[test]
908    fn test_announce_provider() {
909        let manager = GossipSubManager::new(GossipSubConfig::default());
910        manager.subscribe_inference_topics().expect("subscribe");
911        manager
912            .announce_provider("bafyreiabc123", "peer-4")
913            .expect("announce provider");
914        let stats = manager.stats();
915        assert!(stats.messages_published >= 1);
916    }
917
918    #[test]
919    fn test_topic_message_timestamp_nonnegative() {
920        let msg = TopicMessage::new(KB_DELTA, "p", vec![1, 2, 3]);
921        // timestamp should be a reasonable Unix epoch value (after year 2020)
922        assert!(
923            msg.timestamp > 1_577_836_800,
924            "timestamp should be after 2020"
925        );
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use libp2p::identity::Keypair;
933
934    /// Create a deterministic PeerId from an index (avoids slow random key generation)
935    fn test_peer_id(index: u8) -> PeerId {
936        // Use a deterministic seed based on index
937        let mut seed = [0u8; 32];
938        seed[0] = index;
939        let keypair = Keypair::ed25519_from_bytes(seed).expect("valid seed");
940        keypair.public().to_peer_id()
941    }
942
943    #[test]
944    fn test_gossipsub_manager_creation() {
945        let config = GossipSubConfig::default();
946        let manager = GossipSubManager::new(config);
947        assert_eq!(manager.list_topics().len(), 0);
948    }
949
950    #[test]
951    fn test_topic_subscription() {
952        let manager = GossipSubManager::new(GossipSubConfig::default());
953        let topic = TopicId::content_announce();
954
955        manager
956            .subscribe(topic.clone())
957            .expect("test: subscribe to content_announce topic");
958        assert!(manager.is_subscribed(&topic));
959        assert_eq!(manager.list_topics().len(), 1);
960    }
961
962    #[test]
963    fn test_duplicate_subscription() {
964        let manager = GossipSubManager::new(GossipSubConfig::default());
965        let topic = TopicId::content_announce();
966
967        manager
968            .subscribe(topic.clone())
969            .expect("test: first subscribe should succeed");
970        let result = manager.subscribe(topic);
971        assert!(matches!(result, Err(GossipSubError::AlreadySubscribed(_))));
972    }
973
974    #[test]
975    fn test_unsubscribe() {
976        let manager = GossipSubManager::new(GossipSubConfig::default());
977        let topic = TopicId::content_announce();
978
979        manager
980            .subscribe(topic.clone())
981            .expect("test: subscribe should succeed");
982        manager
983            .unsubscribe(&topic)
984            .expect("test: unsubscribe should succeed");
985        assert!(!manager.is_subscribed(&topic));
986        assert_eq!(manager.list_topics().len(), 0);
987    }
988
989    #[test]
990    fn test_publish_message() {
991        let manager = GossipSubManager::new(GossipSubConfig::default());
992        let topic = TopicId::content_announce();
993        let peer = test_peer_id(1);
994
995        manager
996            .subscribe(topic.clone())
997            .expect("test: subscribe should succeed");
998
999        let data = b"Hello, GossipSub!".to_vec();
1000        let message_id = manager
1001            .publish(topic, data, peer)
1002            .expect("test: publish should succeed");
1003
1004        let stats = manager.stats();
1005        assert_eq!(stats.messages_published, 1);
1006        assert!(!message_id.0.is_empty());
1007    }
1008
1009    #[test]
1010    fn test_publish_without_subscription() {
1011        let manager = GossipSubManager::new(GossipSubConfig::default());
1012        let topic = TopicId::content_announce();
1013        let peer = test_peer_id(1);
1014
1015        let data = b"Hello".to_vec();
1016        let result = manager.publish(topic, data, peer);
1017        assert!(matches!(result, Err(GossipSubError::NotSubscribed(_))));
1018    }
1019
1020    #[test]
1021    fn test_message_too_large() {
1022        let config = GossipSubConfig {
1023            max_message_size: 100,
1024            ..Default::default()
1025        };
1026        let manager = GossipSubManager::new(config);
1027        let topic = TopicId::content_announce();
1028        let peer = test_peer_id(1);
1029
1030        manager
1031            .subscribe(topic.clone())
1032            .expect("test: subscribe should succeed");
1033
1034        let data = vec![0u8; 200]; // Larger than max
1035        let result = manager.publish(topic, data, peer);
1036        assert!(matches!(
1037            result,
1038            Err(GossipSubError::MessageTooLarge { .. })
1039        ));
1040    }
1041
1042    #[test]
1043    fn test_message_deduplication() {
1044        let manager = GossipSubManager::new(GossipSubConfig::default());
1045        let topic = TopicId::content_announce();
1046        let peer = test_peer_id(1);
1047
1048        manager
1049            .subscribe(topic.clone())
1050            .expect("test: subscribe should succeed");
1051
1052        let message = GossipSubMessage {
1053            id: MessageId::new(&peer, 1),
1054            source: peer,
1055            topic: topic.clone(),
1056            data: b"Test".to_vec(),
1057            sequence: 1,
1058            timestamp: Instant::now(),
1059        };
1060
1061        // First message should be accepted
1062        let result1 = manager
1063            .handle_message(message.clone())
1064            .expect("test: handle first message should succeed");
1065        assert!(result1);
1066
1067        // Duplicate should be rejected
1068        let result2 = manager
1069            .handle_message(message)
1070            .expect("test: handle duplicate message should succeed");
1071        assert!(!result2);
1072
1073        let stats = manager.stats();
1074        assert_eq!(stats.duplicate_messages, 1);
1075    }
1076
1077    #[test]
1078    fn test_peer_scoring() {
1079        let manager = GossipSubManager::new(GossipSubConfig::default());
1080        let peer = test_peer_id(1);
1081        let topic = TopicId::content_announce();
1082
1083        manager.update_peer_score(&peer, topic.clone(), 0.8);
1084
1085        let score = manager
1086            .get_peer_score(&peer)
1087            .expect("test: peer score should exist after update");
1088        assert_eq!(score.topic_scores.get(&topic), Some(&0.8));
1089        assert!(score.total_score > 0.0);
1090    }
1091
1092    #[test]
1093    fn test_mesh_management() {
1094        let manager = GossipSubManager::new(GossipSubConfig::default());
1095        let topic = TopicId::content_announce();
1096        let peer1 = test_peer_id(1);
1097        let peer2 = test_peer_id(2);
1098
1099        manager
1100            .subscribe(topic.clone())
1101            .expect("test: subscribe should succeed");
1102
1103        // Add peers to mesh
1104        manager
1105            .add_peer_to_mesh(&topic, peer1)
1106            .expect("test: add peer1 to mesh should succeed");
1107        manager
1108            .add_peer_to_mesh(&topic, peer2)
1109            .expect("test: add peer2 to mesh should succeed");
1110
1111        let mesh_peers = manager
1112            .get_mesh_peers(&topic)
1113            .expect("test: get mesh peers should succeed");
1114        assert_eq!(mesh_peers.len(), 2);
1115        assert!(mesh_peers.contains(&peer1));
1116        assert!(mesh_peers.contains(&peer2));
1117
1118        // Remove peer from mesh
1119        manager
1120            .remove_peer_from_mesh(&topic, &peer1)
1121            .expect("test: remove peer1 from mesh should succeed");
1122        let mesh_peers = manager
1123            .get_mesh_peers(&topic)
1124            .expect("test: get mesh peers after removal should succeed");
1125        assert_eq!(mesh_peers.len(), 1);
1126        assert!(!mesh_peers.contains(&peer1));
1127    }
1128
1129    #[test]
1130    fn test_peers_to_prune() {
1131        let manager = GossipSubManager::new(GossipSubConfig::default());
1132        let topic = TopicId::content_announce();
1133        let peer1 = test_peer_id(1);
1134        let peer2 = test_peer_id(2);
1135
1136        manager
1137            .subscribe(topic.clone())
1138            .expect("test: subscribe should succeed");
1139        manager
1140            .add_peer_to_mesh(&topic, peer1)
1141            .expect("test: add peer1 to mesh should succeed");
1142        manager
1143            .add_peer_to_mesh(&topic, peer2)
1144            .expect("test: add peer2 to mesh should succeed");
1145
1146        // Set scores
1147        manager.update_peer_score(&peer1, topic.clone(), 0.9);
1148        manager.update_peer_score(&peer2, topic.clone(), 0.3);
1149
1150        // Get peers below threshold
1151        let to_prune = manager.get_peers_to_prune(&topic, 0.5);
1152        assert_eq!(to_prune.len(), 1);
1153        assert!(to_prune.contains(&peer2));
1154    }
1155
1156    #[test]
1157    fn test_topic_ids() {
1158        assert_eq!(
1159            TopicId::content_announce().0,
1160            "/ipfrs/content/announce/1.0.0"
1161        );
1162        assert_eq!(TopicId::peer_announce().0, "/ipfrs/peer/announce/1.0.0");
1163        assert_eq!(TopicId::dht_events().0, "/ipfrs/dht/events/1.0.0");
1164    }
1165
1166    #[test]
1167    fn test_message_id_generation() {
1168        let peer = test_peer_id(1);
1169        let id1 = MessageId::new(&peer, 1);
1170        let id2 = MessageId::new(&peer, 1);
1171        let id3 = MessageId::new(&peer, 2);
1172
1173        assert_eq!(id1, id2); // Same peer and sequence
1174        assert_ne!(id1, id3); // Different sequence
1175    }
1176
1177    #[test]
1178    fn test_peer_score_calculation() {
1179        let mut score = PeerScore::default();
1180
1181        score.update_topic_score(TopicId::content_announce(), 0.8);
1182        score.update_topic_score(TopicId::peer_announce(), 0.6);
1183
1184        assert_eq!(score.topic_scores.len(), 2);
1185        assert_eq!(score.total_score, 0.7); // Average: (0.8 + 0.6) / 2
1186
1187        // Record invalid message
1188        score.record_message(false);
1189        assert!(score.total_score < 0.7); // Score should decrease
1190    }
1191}
1192
1193// ============================================================================
1194// Mesh Health Monitor
1195// ============================================================================
1196
1197/// Health status of a GossipSub topic mesh.
1198#[derive(Debug, Clone, PartialEq, Eq)]
1199pub enum MeshHealthStatus {
1200    /// Mesh peer count is within the healthy range [D_low, D_high].
1201    Healthy,
1202    /// Mesh peer count is below D_low — grafting is needed.
1203    Underpeered {
1204        /// Current number of mesh peers.
1205        current: usize,
1206        /// Minimum required number of mesh peers (D_low).
1207        minimum: usize,
1208    },
1209    /// Mesh peer count exceeds D_high — pruning is needed.
1210    Overloaded {
1211        /// Current number of mesh peers.
1212        current: usize,
1213        /// Maximum allowed number of mesh peers (D_high).
1214        maximum: usize,
1215    },
1216}
1217
1218/// Monitors GossipSub mesh health and manages graft/prune decisions.
1219///
1220/// The monitor tracks a per-topic peer count relative to the D_low / D_high
1221/// thresholds defined in the GossipSub specification and implements a
1222/// backoff-aware heal scheduler so that repeated failed graft attempts do not
1223/// flood the network.
1224pub struct MeshHealthMonitor {
1225    /// Minimum mesh peers threshold (D_low).
1226    d_low: usize,
1227    /// Maximum mesh peers threshold (D_high).
1228    d_high: usize,
1229    /// Minimum seconds between consecutive heal attempts.
1230    heal_interval_secs: u64,
1231    /// Timestamp of the last heal attempt (if any).
1232    last_heal_attempt: Option<Instant>,
1233    /// Total number of heal attempts recorded.
1234    heal_attempts: u64,
1235}
1236
1237impl MeshHealthMonitor {
1238    /// Create a new monitor with the given D_low and D_high thresholds.
1239    ///
1240    /// The heal interval defaults to 30 seconds.
1241    pub fn new(d_low: usize, d_high: usize) -> Self {
1242        Self {
1243            d_low,
1244            d_high,
1245            heal_interval_secs: 30,
1246            last_heal_attempt: None,
1247            heal_attempts: 0,
1248        }
1249    }
1250
1251    /// Create a monitor with an explicit heal interval.
1252    pub fn with_heal_interval(mut self, secs: u64) -> Self {
1253        self.heal_interval_secs = secs;
1254        self
1255    }
1256
1257    /// Return the D_low threshold.
1258    pub fn d_low(&self) -> usize {
1259        self.d_low
1260    }
1261
1262    /// Return the D_high threshold.
1263    pub fn d_high(&self) -> usize {
1264        self.d_high
1265    }
1266
1267    /// Return the heal interval in seconds.
1268    pub fn heal_interval_secs(&self) -> u64 {
1269        self.heal_interval_secs
1270    }
1271
1272    /// Return the number of heal attempts recorded so far.
1273    pub fn heal_attempts(&self) -> u64 {
1274        self.heal_attempts
1275    }
1276
1277    /// Return `true` when the given peer count is below D_low.
1278    pub fn is_mesh_underpeered(&self, topic_peer_count: usize) -> bool {
1279        topic_peer_count < self.d_low
1280    }
1281
1282    /// Return `true` when the given peer count exceeds D_high.
1283    pub fn is_mesh_overloaded(&self, topic_peer_count: usize) -> bool {
1284        topic_peer_count > self.d_high
1285    }
1286
1287    /// Summarise mesh health given the current peer count.
1288    pub fn health_status(&self, mesh_size: usize) -> MeshHealthStatus {
1289        if mesh_size < self.d_low {
1290            MeshHealthStatus::Underpeered {
1291                current: mesh_size,
1292                minimum: self.d_low,
1293            }
1294        } else if mesh_size > self.d_high {
1295            MeshHealthStatus::Overloaded {
1296                current: mesh_size,
1297                maximum: self.d_high,
1298            }
1299        } else {
1300            MeshHealthStatus::Healthy
1301        }
1302    }
1303
1304    /// Suggest peers to graft from the gossip peer cache when the mesh is
1305    /// sparse.
1306    ///
1307    /// Returns peer IDs that are in `gossip_peers` but **not** in
1308    /// `mesh_peers`.  The result is bounded to at most `D_high - mesh_size`
1309    /// candidates so callers never receive more grafts than needed.
1310    pub fn suggest_grafts(&self, gossip_peers: &[String], mesh_peers: &[String]) -> Vec<String> {
1311        let mesh_set: std::collections::HashSet<&str> =
1312            mesh_peers.iter().map(|s| s.as_str()).collect();
1313
1314        gossip_peers
1315            .iter()
1316            .filter(|p| !mesh_set.contains(p.as_str()))
1317            .cloned()
1318            .collect()
1319    }
1320
1321    /// Record that a heal attempt was made right now.
1322    ///
1323    /// After calling this `should_attempt_heal` will return `false` until
1324    /// the configured heal interval has elapsed.
1325    pub fn record_heal_attempt(&mut self) {
1326        self.last_heal_attempt = Some(Instant::now());
1327        self.heal_attempts += 1;
1328    }
1329
1330    /// Return `true` when sufficient time has passed since the last heal
1331    /// attempt (or no attempt has ever been made) so that a new heal should
1332    /// be tried.
1333    pub fn should_attempt_heal(&self) -> bool {
1334        match self.last_heal_attempt {
1335            None => true,
1336            Some(last) => last.elapsed() >= Duration::from_secs(self.heal_interval_secs),
1337        }
1338    }
1339}
1340
1341// ============================================================================
1342// GossipSubManager — mesh health methods
1343// ============================================================================
1344
1345impl GossipSubManager {
1346    /// Return the aggregate mesh health status for a given topic.
1347    ///
1348    /// Uses the manager's configured `mesh_n_low` and `mesh_n_high` thresholds.
1349    pub fn mesh_health(&self, topic: &TopicId) -> MeshHealthStatus {
1350        let monitor = MeshHealthMonitor::new(self.config.mesh_n_low, self.config.mesh_n_high);
1351        let mesh_size = self
1352            .subscriptions
1353            .get(topic)
1354            .map(|s| s.mesh_peers.len())
1355            .unwrap_or(0);
1356        monitor.health_status(mesh_size)
1357    }
1358
1359    /// Attempt to heal the mesh for `topic` if it is underpeered.
1360    ///
1361    /// Selects peers from the gossip peer score table that are not already in
1362    /// the mesh and grafts them in until D_low is satisfied.  Returns the list
1363    /// of peer IDs grafted.
1364    pub fn heal_mesh_if_needed(&self, topic: &TopicId) -> Vec<PeerId> {
1365        let d_low = self.config.mesh_n_low;
1366        let d_high = self.config.mesh_n_high;
1367
1368        let current_mesh: Vec<PeerId> = self
1369            .subscriptions
1370            .get(topic)
1371            .map(|s| s.mesh_peers.iter().cloned().collect())
1372            .unwrap_or_default();
1373
1374        if current_mesh.len() >= d_low {
1375            return Vec::new();
1376        }
1377
1378        let slots_needed = d_low.saturating_sub(current_mesh.len());
1379        let mesh_set: std::collections::HashSet<PeerId> = current_mesh.into_iter().collect();
1380
1381        // Collect candidate peers from the score table, ordered by score
1382        // (descending) so that the best-quality peers are grafted first.
1383        let mut candidates: Vec<(PeerId, f64)> = self
1384            .peer_scores
1385            .iter()
1386            .filter(|entry| !mesh_set.contains(entry.key()))
1387            .map(|entry| (*entry.key(), entry.value().total_score))
1388            .collect();
1389
1390        candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1391
1392        let to_graft: Vec<PeerId> = candidates
1393            .into_iter()
1394            .take(slots_needed.min(d_high - mesh_set.len()))
1395            .map(|(peer, _)| peer)
1396            .collect();
1397
1398        for peer in &to_graft {
1399            // Best-effort: ignore errors (e.g. not subscribed)
1400            let _ = self.add_peer_to_mesh(topic, *peer);
1401        }
1402
1403        to_graft
1404    }
1405
1406    /// Prune the mesh for `topic` if it exceeds D_high.
1407    ///
1408    /// Removes the lowest-scoring peers until the mesh size equals D_high.
1409    /// Returns the list of peer IDs pruned.
1410    pub fn prune_mesh_if_needed(&self, topic: &TopicId) -> Vec<PeerId> {
1411        let d_high = self.config.mesh_n_high;
1412
1413        let current_mesh: Vec<PeerId> = self
1414            .subscriptions
1415            .get(topic)
1416            .map(|s| s.mesh_peers.iter().cloned().collect())
1417            .unwrap_or_default();
1418
1419        if current_mesh.len() <= d_high {
1420            return Vec::new();
1421        }
1422
1423        let excess = current_mesh.len() - d_high;
1424
1425        // Score all mesh peers; peers without a score record get score 0.
1426        let mut scored: Vec<(PeerId, f64)> = current_mesh
1427            .into_iter()
1428            .map(|peer| {
1429                let score = self
1430                    .peer_scores
1431                    .get(&peer)
1432                    .map(|s| s.total_score)
1433                    .unwrap_or(0.0);
1434                (peer, score)
1435            })
1436            .collect();
1437
1438        // Sort ascending (lowest score first) so we prune the worst peers.
1439        scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1440
1441        let to_prune: Vec<PeerId> = scored.into_iter().take(excess).map(|(p, _)| p).collect();
1442
1443        for peer in &to_prune {
1444            let _ = self.remove_peer_from_mesh(topic, peer);
1445        }
1446
1447        to_prune
1448    }
1449}
1450
1451// ============================================================================
1452// Mesh health tests
1453// ============================================================================
1454
1455#[cfg(test)]
1456mod mesh_health_tests {
1457    use super::*;
1458    use libp2p::identity::Keypair;
1459
1460    fn test_peer_str(index: u8) -> String {
1461        let mut seed = [0u8; 32];
1462        seed[0] = index;
1463        let kp = Keypair::ed25519_from_bytes(seed).expect("valid seed");
1464        kp.public().to_peer_id().to_string()
1465    }
1466
1467    #[test]
1468    fn test_mesh_health_underpeered() {
1469        let monitor = MeshHealthMonitor::new(6, 12);
1470        let status = monitor.health_status(3);
1471        assert_eq!(
1472            status,
1473            MeshHealthStatus::Underpeered {
1474                current: 3,
1475                minimum: 6
1476            }
1477        );
1478        assert!(monitor.is_mesh_underpeered(3));
1479    }
1480
1481    #[test]
1482    fn test_mesh_health_healthy() {
1483        let monitor = MeshHealthMonitor::new(6, 12);
1484        let status = monitor.health_status(8);
1485        assert_eq!(status, MeshHealthStatus::Healthy);
1486        assert!(!monitor.is_mesh_underpeered(8));
1487        assert!(!monitor.is_mesh_overloaded(8));
1488    }
1489
1490    #[test]
1491    fn test_mesh_health_overloaded() {
1492        let monitor = MeshHealthMonitor::new(6, 12);
1493        let status = monitor.health_status(15);
1494        assert_eq!(
1495            status,
1496            MeshHealthStatus::Overloaded {
1497                current: 15,
1498                maximum: 12,
1499            }
1500        );
1501        assert!(monitor.is_mesh_overloaded(15));
1502    }
1503
1504    #[test]
1505    fn test_mesh_suggest_grafts() {
1506        let monitor = MeshHealthMonitor::new(6, 12);
1507        let a = test_peer_str(1);
1508        let b = test_peer_str(2);
1509        let c = test_peer_str(3);
1510        let d = test_peer_str(4);
1511
1512        let gossip_peers = vec![a.clone(), b.clone(), c.clone(), d.clone()];
1513        let mesh_peers = vec![a.clone()];
1514
1515        let suggestions = monitor.suggest_grafts(&gossip_peers, &mesh_peers);
1516
1517        // A is already in mesh; B, C, D should be suggested.
1518        assert_eq!(suggestions.len(), 3);
1519        assert!(!suggestions.contains(&a));
1520        assert!(suggestions.contains(&b));
1521        assert!(suggestions.contains(&c));
1522        assert!(suggestions.contains(&d));
1523    }
1524
1525    #[test]
1526    fn test_heal_backoff() {
1527        let mut monitor = MeshHealthMonitor::new(6, 12).with_heal_interval(60);
1528
1529        // Initially should attempt.
1530        assert!(monitor.should_attempt_heal());
1531
1532        // After recording an attempt it should not attempt immediately.
1533        monitor.record_heal_attempt();
1534        assert!(
1535            !monitor.should_attempt_heal(),
1536            "should not heal immediately after attempt"
1537        );
1538
1539        assert_eq!(monitor.heal_attempts(), 1);
1540    }
1541
1542    #[test]
1543    fn test_gossipsub_manager_mesh_health_method() {
1544        let mgr = GossipSubManager::new(GossipSubConfig::default());
1545        let topic = TopicId::content_announce();
1546        mgr.subscribe(topic.clone()).expect("subscribe");
1547
1548        // Empty mesh → underpeered (default d_low = 4)
1549        let status = mgr.mesh_health(&topic);
1550        assert!(
1551            matches!(status, MeshHealthStatus::Underpeered { .. }),
1552            "empty mesh should be underpeered"
1553        );
1554    }
1555
1556    #[test]
1557    fn test_gossipsub_manager_heal_mesh_if_needed() {
1558        let mgr = GossipSubManager::new(GossipSubConfig::default());
1559        let topic = TopicId::content_announce();
1560        mgr.subscribe(topic.clone()).expect("subscribe");
1561
1562        // With an empty peer score table, heal_mesh_if_needed returns nothing.
1563        let grafted = mgr.heal_mesh_if_needed(&topic);
1564        assert!(grafted.is_empty(), "no scored peers to graft from");
1565    }
1566
1567    #[test]
1568    fn test_gossipsub_manager_prune_mesh_if_needed() {
1569        let config = GossipSubConfig {
1570            mesh_n_high: 2,
1571            ..GossipSubConfig::default()
1572        };
1573        let mgr = GossipSubManager::new(config);
1574        let topic = TopicId::content_announce();
1575        mgr.subscribe(topic.clone()).expect("subscribe");
1576
1577        // Add 4 peers to the mesh, exceeding d_high = 2.
1578        for i in 0u8..4 {
1579            let mut seed = [0u8; 32];
1580            seed[0] = i + 10;
1581            let kp = Keypair::ed25519_from_bytes(seed).expect("seed");
1582            let peer = kp.public().to_peer_id();
1583            mgr.add_peer_to_mesh(&topic, peer).expect("add to mesh");
1584        }
1585
1586        let before = mgr.get_mesh_peers(&topic).expect("get peers").len();
1587        assert_eq!(before, 4);
1588
1589        let pruned = mgr.prune_mesh_if_needed(&topic);
1590        assert_eq!(pruned.len(), 2, "should prune 2 peers to reach d_high=2");
1591
1592        let after = mgr.get_mesh_peers(&topic).expect("get peers").len();
1593        assert_eq!(after, 2);
1594    }
1595}