Skip to main content

ipfrs_network/
capability_registry.rs

1//! Peer Capability Registry
2//!
3//! Tracks what capabilities each peer advertises so that queries can be routed
4//! to nodes that are able to serve them (vector search, TensorLogic,
5//! gradient sync, block storage, content routing, etc.).
6//!
7//! # Two Capability Systems
8//!
9//! This module contains two related but distinct capability tracking systems:
10//!
11//! 1. **[`NodeCapability`] / [`NodeCapabilityRegistry`]** — legacy node-level capabilities
12//!    for vector search, tensor logic, gradient sync, block storage, and content
13//!    routing.  These are keyed by a string name and carry structured payloads.
14//!
15//! 2. **[`Capability`] / [`PeerCapabilityRegistry`]** — protocol-level capabilities for
16//!    Bitswap, TensorSwap, Kademlia DHT, GossipSub, content addressing, and
17//!    extensible custom capabilities.  Advertisements are tick-based and support
18//!    TTL-based expiry.
19
20use std::collections::HashMap;
21use std::sync::RwLock;
22
23use serde::{Deserialize, Serialize};
24
25// ── NodeCapability (legacy) ───────────────────────────────────────────────────
26
27/// A single node-level capability that a peer may advertise.
28///
29/// This is the original capability type tracking resource capacities like
30/// vector-search index sizes, rule-engine rule counts, model sizes, etc.
31/// For protocol-level capabilities (Bitswap, Kademlia, GossipSub, …) see
32/// [`Capability`].
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub enum NodeCapability {
35    /// HNSW / ANN vector index with declared size and dimensionality.
36    VectorSearch {
37        /// Number of vectors stored in the index.
38        index_size: u64,
39        /// Embedding dimensionality.
40        dimensions: u32,
41    },
42    /// TensorLogic rule engine with a declared rule count.
43    TensorLogic {
44        /// Number of compiled rules in the engine.
45        rule_count: u64,
46    },
47    /// Gradient synchronisation participant with declared model footprint.
48    GradientSync {
49        /// Serialised model size in bytes.
50        model_size_bytes: u64,
51    },
52    /// Raw block / object storage with declared occupancy.
53    BlockStorage {
54        /// Bytes currently stored by this peer.
55        stored_bytes: u64,
56    },
57    /// Peer participates in content routing (DHT provider announcements).
58    ContentRouting,
59}
60
61impl NodeCapability {
62    /// Returns a stable, lowercase ASCII identifier for the capability.
63    ///
64    /// These names are used as keys in the capability histogram and when
65    /// looking up capabilities by name.
66    pub fn name(&self) -> &str {
67        match self {
68            NodeCapability::VectorSearch { .. } => "vector_search",
69            NodeCapability::TensorLogic { .. } => "tensor_logic",
70            NodeCapability::GradientSync { .. } => "gradient_sync",
71            NodeCapability::BlockStorage { .. } => "block_storage",
72            NodeCapability::ContentRouting => "content_routing",
73        }
74    }
75}
76
77// ── NodeCapabilities ──────────────────────────────────────────────────────────
78
79/// The full node-level capability advertisement sent by a single peer.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct NodeCapabilities {
82    /// Unique peer identifier (string form of the libp2p `PeerId`).
83    pub peer_id: String,
84    /// Ordered list of capabilities the peer supports.
85    pub capabilities: Vec<NodeCapability>,
86    /// IPFRS protocol version string, e.g. `"0.2.0"`.
87    pub protocol_version: String,
88    /// Unix epoch timestamp in milliseconds at which the advertisement was
89    /// created / last refreshed.
90    pub announced_at: u64,
91    /// Time-to-live in seconds after which this record is considered stale.
92    pub ttl_secs: u64,
93}
94
95impl NodeCapabilities {
96    /// Default TTL used when none is provided explicitly.
97    pub const DEFAULT_TTL_SECS: u64 = 300;
98
99    /// Create a fresh `NodeCapabilities` record with the current system time.
100    ///
101    /// `announced_at` is set to the current Unix time in milliseconds.
102    /// `ttl_secs` defaults to [`Self::DEFAULT_TTL_SECS`] (300 s).
103    pub fn new(peer_id: &str, capabilities: Vec<NodeCapability>, protocol_version: &str) -> Self {
104        let announced_at = std::time::SystemTime::now()
105            .duration_since(std::time::UNIX_EPOCH)
106            .map(|d| d.as_millis() as u64)
107            .unwrap_or(0);
108
109        Self {
110            peer_id: peer_id.to_owned(),
111            capabilities,
112            protocol_version: protocol_version.to_owned(),
113            announced_at,
114            ttl_secs: Self::DEFAULT_TTL_SECS,
115        }
116    }
117
118    /// Returns `true` if the peer supports the capability identified by `name`.
119    pub fn has_capability(&self, name: &str) -> bool {
120        self.capabilities.iter().any(|c| c.name() == name)
121    }
122
123    /// Returns `true` if the record has expired relative to `now_ms`.
124    ///
125    /// A record is expired when `now_ms > announced_at + ttl_secs * 1000`.
126    pub fn is_expired(&self, now_ms: u64) -> bool {
127        now_ms
128            > self
129                .announced_at
130                .saturating_add(self.ttl_secs.saturating_mul(1_000))
131    }
132
133    /// Returns a reference to the first `NodeCapability` whose name matches `name`,
134    /// or `None` if no such capability is present.
135    pub fn get_capability(&self, name: &str) -> Option<&NodeCapability> {
136        self.capabilities.iter().find(|c| c.name() == name)
137    }
138}
139
140// ── NodeCapabilityRegistry ────────────────────────────────────────────────────
141
142/// Thread-safe registry that maps peer IDs to their node-level capability
143/// advertisements.
144///
145/// # Example
146///
147/// ```rust
148/// use ipfrs_network::capability_registry::{
149///     NodeCapability, NodeCapabilities, NodeCapabilityRegistry,
150/// };
151///
152/// let registry = NodeCapabilityRegistry::default();
153///
154/// let caps = NodeCapabilities::new(
155///     "peer-1",
156///     vec![NodeCapability::ContentRouting],
157///     "0.2.0",
158/// );
159/// registry.register(caps);
160///
161/// let found = registry.find_by_capability("content_routing");
162/// assert_eq!(found.len(), 1);
163/// ```
164#[derive(Debug, Default)]
165pub struct NodeCapabilityRegistry {
166    entries: RwLock<HashMap<String, NodeCapabilities>>,
167}
168
169impl NodeCapabilityRegistry {
170    /// Create an empty registry.
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// Register or replace the capability advertisement for the peer described
176    /// by `caps.peer_id`.
177    pub fn register(&self, caps: NodeCapabilities) {
178        match self.entries.write() {
179            Ok(mut guard) => {
180                guard.insert(caps.peer_id.clone(), caps);
181            }
182            Err(poisoned) => {
183                // Recover the lock and continue — a panicking writer should not
184                // permanently block the registry.
185                let mut guard = poisoned.into_inner();
186                guard.insert(caps.peer_id.clone(), caps);
187            }
188        }
189    }
190
191    /// Remove the advertisement for `peer_id` from the registry.
192    ///
193    /// This is a no-op when the peer is not registered.
194    pub fn unregister(&self, peer_id: &str) {
195        match self.entries.write() {
196            Ok(mut guard) => {
197                guard.remove(peer_id);
198            }
199            Err(poisoned) => {
200                let mut guard = poisoned.into_inner();
201                guard.remove(peer_id);
202            }
203        }
204    }
205
206    /// Return a clone of the `NodeCapabilities` for `peer_id`, or `None` if the
207    /// peer is not registered.
208    pub fn get(&self, peer_id: &str) -> Option<NodeCapabilities> {
209        match self.entries.read() {
210            Ok(guard) => guard.get(peer_id).cloned(),
211            Err(poisoned) => poisoned.into_inner().get(peer_id).cloned(),
212        }
213    }
214
215    /// Return clones of all non-expired peer records that advertise the
216    /// capability identified by `name`.
217    ///
218    /// Expiry is evaluated relative to `now_ms` obtained from the system clock
219    /// at the time of the call.
220    pub fn find_by_capability(&self, name: &str) -> Vec<NodeCapabilities> {
221        let now_ms = current_time_ms();
222        match self.entries.read() {
223            Ok(guard) => guard
224                .values()
225                .filter(|nc| !nc.is_expired(now_ms) && nc.has_capability(name))
226                .cloned()
227                .collect(),
228            Err(poisoned) => poisoned
229                .into_inner()
230                .values()
231                .filter(|nc| !nc.is_expired(now_ms) && nc.has_capability(name))
232                .cloned()
233                .collect(),
234        }
235    }
236
237    /// Remove all expired entries from the registry and return the number of
238    /// entries that were evicted.
239    pub fn evict_expired(&self, now_ms: u64) -> usize {
240        match self.entries.write() {
241            Ok(mut guard) => {
242                let before = guard.len();
243                guard.retain(|_, nc| !nc.is_expired(now_ms));
244                before - guard.len()
245            }
246            Err(poisoned) => {
247                let mut guard = poisoned.into_inner();
248                let before = guard.len();
249                guard.retain(|_, nc| !nc.is_expired(now_ms));
250                before - guard.len()
251            }
252        }
253    }
254
255    /// Return the number of peers currently registered (including expired ones
256    /// that have not yet been evicted).
257    pub fn peer_count(&self) -> usize {
258        match self.entries.read() {
259            Ok(guard) => guard.len(),
260            Err(poisoned) => poisoned.into_inner().len(),
261        }
262    }
263
264    /// Return a histogram mapping each capability name to the number of
265    /// currently-registered peers (including stale ones) that advertise it.
266    pub fn capability_histogram(&self) -> HashMap<String, usize> {
267        match self.entries.read() {
268            Ok(guard) => build_histogram(guard.values()),
269            Err(poisoned) => build_histogram(poisoned.into_inner().values()),
270        }
271    }
272}
273
274// ── Capability (protocol-level) ───────────────────────────────────────────────
275
276/// Protocol-level capability that a peer may advertise.
277///
278/// These represent the networking protocols and features a peer supports,
279/// enabling capability-based peer selection for routing and protocol
280/// negotiation.  For resource-level capabilities (vector search, block storage,
281/// etc.) see [`NodeCapability`].
282#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
283pub enum Capability {
284    /// Bitswap block-exchange protocol.
285    Bitswap {
286        /// Bitswap protocol version.
287        version: u8,
288    },
289    /// TensorSwap tensor-exchange protocol.
290    TensorSwap {
291        /// TensorSwap protocol version.
292        version: u8,
293    },
294    /// Kademlia DHT participation.
295    Kademlia,
296    /// GossipSub pub/sub with a list of subscribed topic names.
297    GossipSub {
298        /// Topics the peer has subscribed to.
299        topics: Vec<String>,
300    },
301    /// CID-based content addressing (IPFS-compatible object storage).
302    ContentAddressing,
303    /// Extensible custom capability for application-defined features.
304    Custom {
305        /// Capability identifier (ASCII, lowercase recommended).
306        name: String,
307        /// Capability version.
308        version: u8,
309    },
310}
311
312// ── CapabilityAdvertisement ───────────────────────────────────────────────────
313
314/// A time-bounded advertisement of the [`Capability`] set that a peer supports.
315///
316/// Advertisements are valid while `current_tick < expires_at_tick`.  Once
317/// expired they should be ignored for routing decisions, and can be removed by
318/// calling [`PeerCapabilityRegistry::evict_expired`].
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct CapabilityAdvertisement {
321    /// Unique peer identifier.
322    pub peer_id: String,
323    /// Capabilities the peer currently advertises.
324    pub capabilities: Vec<Capability>,
325    /// Tick at which this advertisement was created.
326    pub advertised_at_tick: u64,
327    /// Tick after which this advertisement is considered expired.
328    pub expires_at_tick: u64,
329}
330
331impl CapabilityAdvertisement {
332    /// Returns `true` if this advertisement is still valid at `current_tick`.
333    ///
334    /// An advertisement is valid while `current_tick < expires_at_tick`.
335    #[inline]
336    pub fn is_valid(&self, current_tick: u64) -> bool {
337        current_tick < self.expires_at_tick
338    }
339
340    /// Returns `true` if this advertisement contains `cap` (exact match via
341    /// [`PartialEq`]).
342    #[inline]
343    pub fn has_capability(&self, cap: &Capability) -> bool {
344        self.capabilities.contains(cap)
345    }
346}
347
348// ── CapabilityRegistryStats ───────────────────────────────────────────────────
349
350/// Statistics snapshot for a [`PeerCapabilityRegistry`].
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct CapabilityRegistryStats {
353    /// Total number of peers in the registry (including expired ones not yet
354    /// evicted).
355    pub total_peers: usize,
356    /// Number of peers whose advertisement is still valid at the query tick.
357    pub active_peers: usize,
358    /// Sum of the capability-list lengths across all advertisements (active
359    /// and expired).
360    pub total_capabilities_registered: usize,
361    /// Number of advertisements that have already expired at the query tick.
362    pub expired_count: usize,
363}
364
365// ── PeerCapabilityRegistry ────────────────────────────────────────────────────
366
367/// Tick-based registry tracking which protocol-level [`Capability`] set each
368/// peer advertises.
369///
370/// This is the primary data structure for capability-based peer selection.
371/// Advertisements carry a TTL expressed in ticks; callers must supply the
372/// current tick to all query operations so that expired entries are correctly
373/// filtered out without requiring a background eviction task.
374///
375/// # Example
376///
377/// ```rust
378/// use ipfrs_network::capability_registry::{
379///     Capability, PeerCapabilityRegistry,
380/// };
381///
382/// let mut registry = PeerCapabilityRegistry::new();
383/// registry.advertise(
384///     "peer-a".to_string(),
385///     vec![Capability::Kademlia, Capability::Bitswap { version: 1 }],
386///     /*current_tick=*/ 0,
387///     /*ttl_ticks=*/ 100,
388/// );
389///
390/// let peers = registry.peers_with_capability(&Capability::Kademlia, 0);
391/// assert_eq!(peers, vec!["peer-a"]);
392/// ```
393#[derive(Debug, Default)]
394pub struct PeerCapabilityRegistry {
395    /// Peer-ID → advertisement map.
396    advertisements: HashMap<String, CapabilityAdvertisement>,
397}
398
399impl PeerCapabilityRegistry {
400    /// Create an empty registry.
401    pub fn new() -> Self {
402        Self::default()
403    }
404
405    /// Insert or replace the advertisement for `peer_id`.
406    ///
407    /// The advertisement expires at tick `current_tick + ttl_ticks`.
408    pub fn advertise(
409        &mut self,
410        peer_id: String,
411        capabilities: Vec<Capability>,
412        current_tick: u64,
413        ttl_ticks: u64,
414    ) {
415        let advert = CapabilityAdvertisement {
416            peer_id: peer_id.clone(),
417            capabilities,
418            advertised_at_tick: current_tick,
419            expires_at_tick: current_tick.saturating_add(ttl_ticks),
420        };
421        self.advertisements.insert(peer_id, advert);
422    }
423
424    /// Return a sorted list of peer IDs whose valid advertisements include `cap`.
425    ///
426    /// Only advertisements that pass [`CapabilityAdvertisement::is_valid`] at
427    /// `current_tick` are considered.  The result is sorted alphabetically.
428    pub fn peers_with_capability(&self, cap: &Capability, current_tick: u64) -> Vec<&str> {
429        let mut peers: Vec<&str> = self
430            .advertisements
431            .values()
432            .filter(|advert| advert.is_valid(current_tick) && advert.has_capability(cap))
433            .map(|advert| advert.peer_id.as_str())
434            .collect();
435        peers.sort_unstable();
436        peers
437    }
438
439    /// Return the capability slice for `peer_id` if its advertisement is valid
440    /// at `current_tick`, or `None` otherwise.
441    ///
442    /// Returns `None` when:
443    /// - the peer is not in the registry, or
444    /// - the peer's advertisement has expired.
445    pub fn peer_capabilities(&self, peer_id: &str, current_tick: u64) -> Option<&[Capability]> {
446        self.advertisements
447            .get(peer_id)
448            .filter(|advert| advert.is_valid(current_tick))
449            .map(|advert| advert.capabilities.as_slice())
450    }
451
452    /// Remove the advertisement for `peer_id`.
453    ///
454    /// Returns `true` if the peer was found and removed, `false` if the peer
455    /// was not registered.
456    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
457        self.advertisements.remove(peer_id).is_some()
458    }
459
460    /// Remove all expired advertisements from the registry.
461    ///
462    /// Returns the number of advertisements that were removed.
463    pub fn evict_expired(&mut self, current_tick: u64) -> usize {
464        let before = self.advertisements.len();
465        self.advertisements
466            .retain(|_, advert| advert.is_valid(current_tick));
467        before - self.advertisements.len()
468    }
469
470    /// Return a [`CapabilityRegistryStats`] snapshot evaluated at `current_tick`.
471    pub fn stats(&self, current_tick: u64) -> CapabilityRegistryStats {
472        let total_peers = self.advertisements.len();
473        let mut active_peers = 0usize;
474        let mut expired_count = 0usize;
475        let mut total_capabilities_registered = 0usize;
476
477        for advert in self.advertisements.values() {
478            total_capabilities_registered =
479                total_capabilities_registered.saturating_add(advert.capabilities.len());
480            if advert.is_valid(current_tick) {
481                active_peers = active_peers.saturating_add(1);
482            } else {
483                expired_count = expired_count.saturating_add(1);
484            }
485        }
486
487        CapabilityRegistryStats {
488            total_peers,
489            active_peers,
490            total_capabilities_registered,
491            expired_count,
492        }
493    }
494}
495
496// ── Helpers ───────────────────────────────────────────────────────────────────
497
498fn current_time_ms() -> u64 {
499    std::time::SystemTime::now()
500        .duration_since(std::time::UNIX_EPOCH)
501        .map(|d| d.as_millis() as u64)
502        .unwrap_or(0)
503}
504
505fn build_histogram<'a, I>(iter: I) -> HashMap<String, usize>
506where
507    I: Iterator<Item = &'a NodeCapabilities>,
508{
509    let mut map: HashMap<String, usize> = HashMap::new();
510    for nc in iter {
511        for cap in &nc.capabilities {
512            *map.entry(cap.name().to_owned()).or_insert(0) += 1;
513        }
514    }
515    map
516}
517
518// ── Tests ─────────────────────────────────────────────────────────────────────
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    // ─────────────────────────────────────────────────────────────────────────
525    // NodeCapabilityRegistry legacy tests
526    // ─────────────────────────────────────────────────────────────────────────
527
528    fn make_node_caps(peer_id: &str, caps: Vec<NodeCapability>) -> NodeCapabilities {
529        NodeCapabilities::new(peer_id, caps, "0.2.0")
530    }
531
532    /// Build a NodeCapabilities whose `announced_at` is in the past so it is
533    /// already expired.
534    fn make_expired_node_caps(peer_id: &str, caps: Vec<NodeCapability>) -> NodeCapabilities {
535        let mut nc = make_node_caps(peer_id, caps);
536        nc.announced_at = 0;
537        nc.ttl_secs = 1;
538        nc
539    }
540
541    // ── test 1: register and retrieve ────────────────────────────────────────
542
543    #[test]
544    fn test_register_and_get() {
545        let registry = NodeCapabilityRegistry::new();
546        let caps = make_node_caps("peer-a", vec![NodeCapability::ContentRouting]);
547        registry.register(caps.clone());
548
549        let retrieved = registry.get("peer-a").expect("should be present");
550        assert_eq!(retrieved.peer_id, "peer-a");
551        assert_eq!(retrieved.protocol_version, "0.2.0");
552    }
553
554    // ── test 2: get returns None for unknown peer ─────────────────────────────
555
556    #[test]
557    fn test_get_unknown_peer_returns_none() {
558        let registry = NodeCapabilityRegistry::new();
559        assert!(registry.get("nobody").is_none());
560    }
561
562    // ── test 3: has_capability true ───────────────────────────────────────────
563
564    #[test]
565    fn test_has_capability_true() {
566        let caps = make_node_caps(
567            "peer-b",
568            vec![NodeCapability::VectorSearch {
569                index_size: 1000,
570                dimensions: 128,
571            }],
572        );
573        assert!(caps.has_capability("vector_search"));
574    }
575
576    // ── test 4: has_capability false ──────────────────────────────────────────
577
578    #[test]
579    fn test_has_capability_false() {
580        let caps = make_node_caps("peer-c", vec![NodeCapability::ContentRouting]);
581        assert!(!caps.has_capability("vector_search"));
582    }
583
584    // ── test 5: find_by_capability returns correct peers ─────────────────────
585
586    #[test]
587    fn test_find_by_capability_correct_peers() {
588        let registry = NodeCapabilityRegistry::new();
589
590        registry.register(make_node_caps(
591            "peer-1",
592            vec![NodeCapability::BlockStorage { stored_bytes: 512 }],
593        ));
594        registry.register(make_node_caps(
595            "peer-2",
596            vec![
597                NodeCapability::BlockStorage { stored_bytes: 1024 },
598                NodeCapability::ContentRouting,
599            ],
600        ));
601        registry.register(make_node_caps(
602            "peer-3",
603            vec![NodeCapability::ContentRouting],
604        ));
605
606        let found = registry.find_by_capability("block_storage");
607        let ids: Vec<&str> = found.iter().map(|nc| nc.peer_id.as_str()).collect();
608        assert_eq!(ids.len(), 2);
609        assert!(ids.contains(&"peer-1"));
610        assert!(ids.contains(&"peer-2"));
611    }
612
613    // ── test 6: find_by_capability excludes expired peers ────────────────────
614
615    #[test]
616    fn test_find_by_capability_excludes_expired() {
617        let registry = NodeCapabilityRegistry::new();
618
619        registry.register(make_node_caps(
620            "fresh",
621            vec![NodeCapability::ContentRouting],
622        ));
623        registry.register(make_expired_node_caps(
624            "stale",
625            vec![NodeCapability::ContentRouting],
626        ));
627
628        let found = registry.find_by_capability("content_routing");
629        assert_eq!(found.len(), 1);
630        assert_eq!(found[0].peer_id, "fresh");
631    }
632
633    // ── test 7: is_expired true ───────────────────────────────────────────────
634
635    #[test]
636    fn test_is_expired_true() {
637        let mut nc = make_node_caps("x", vec![]);
638        nc.announced_at = 1_000_000; // 1 000 seconds in ms
639        nc.ttl_secs = 60;
640        let now_ms = 1_000_000 + 70_000;
641        assert!(nc.is_expired(now_ms));
642    }
643
644    // ── test 8: is_expired false ──────────────────────────────────────────────
645
646    #[test]
647    fn test_is_expired_false() {
648        let mut nc = make_node_caps("y", vec![]);
649        nc.announced_at = 1_000_000;
650        nc.ttl_secs = 300;
651        let now_ms = 1_000_000 + 10_000;
652        assert!(!nc.is_expired(now_ms));
653    }
654
655    // ── test 9: evict_expired removes stale entries ───────────────────────────
656
657    #[test]
658    fn test_evict_expired_removes_stale() {
659        let registry = NodeCapabilityRegistry::new();
660
661        registry.register(make_node_caps(
662            "alive",
663            vec![NodeCapability::ContentRouting],
664        ));
665        registry.register(make_expired_node_caps(
666            "dead-1",
667            vec![NodeCapability::ContentRouting],
668        ));
669        registry.register(make_expired_node_caps(
670            "dead-2",
671            vec![NodeCapability::ContentRouting],
672        ));
673
674        let evicted = registry.evict_expired(current_time_ms());
675        assert_eq!(evicted, 2);
676        assert_eq!(registry.peer_count(), 1);
677    }
678
679    // ── test 10: evict_expired keeps fresh entries ────────────────────────────
680
681    #[test]
682    fn test_evict_expired_keeps_fresh() {
683        let registry = NodeCapabilityRegistry::new();
684
685        registry.register(make_node_caps(
686            "fresh-a",
687            vec![NodeCapability::TensorLogic { rule_count: 10 }],
688        ));
689        registry.register(make_node_caps(
690            "fresh-b",
691            vec![NodeCapability::TensorLogic { rule_count: 20 }],
692        ));
693
694        let evicted = registry.evict_expired(current_time_ms());
695        assert_eq!(evicted, 0);
696        assert_eq!(registry.peer_count(), 2);
697    }
698
699    // ── test 11: capability_histogram counts correctly ────────────────────────
700
701    #[test]
702    fn test_capability_histogram() {
703        let registry = NodeCapabilityRegistry::new();
704
705        registry.register(make_node_caps(
706            "h1",
707            vec![
708                NodeCapability::ContentRouting,
709                NodeCapability::BlockStorage { stored_bytes: 0 },
710            ],
711        ));
712        registry.register(make_node_caps("h2", vec![NodeCapability::ContentRouting]));
713        registry.register(make_node_caps(
714            "h3",
715            vec![NodeCapability::VectorSearch {
716                index_size: 50,
717                dimensions: 64,
718            }],
719        ));
720
721        let hist = registry.capability_histogram();
722        assert_eq!(*hist.get("content_routing").unwrap_or(&0), 2);
723        assert_eq!(*hist.get("block_storage").unwrap_or(&0), 1);
724        assert_eq!(*hist.get("vector_search").unwrap_or(&0), 1);
725        assert_eq!(*hist.get("tensor_logic").unwrap_or(&0), 0);
726    }
727
728    // ── test 12: get_capability returns correct variant ───────────────────────
729
730    #[test]
731    fn test_get_capability_returns_correct_variant() {
732        let caps = make_node_caps(
733            "peer-v",
734            vec![
735                NodeCapability::VectorSearch {
736                    index_size: 2048,
737                    dimensions: 256,
738                },
739                NodeCapability::ContentRouting,
740            ],
741        );
742
743        let found = caps
744            .get_capability("vector_search")
745            .expect("should be found");
746        match found {
747            NodeCapability::VectorSearch {
748                index_size,
749                dimensions,
750            } => {
751                assert_eq!(*index_size, 2048);
752                assert_eq!(*dimensions, 256);
753            }
754            other => panic!("wrong variant: {other:?}"),
755        }
756    }
757
758    // ── test 13: get_capability returns None for absent name ──────────────────
759
760    #[test]
761    fn test_get_capability_absent() {
762        let caps = make_node_caps("peer-z", vec![NodeCapability::ContentRouting]);
763        assert!(caps.get_capability("gradient_sync").is_none());
764    }
765
766    // ── test 14: unregister removes peer ──────────────────────────────────────
767
768    #[test]
769    fn test_unregister_removes_peer() {
770        let registry = NodeCapabilityRegistry::new();
771        registry.register(make_node_caps(
772            "to-remove",
773            vec![NodeCapability::ContentRouting],
774        ));
775        assert_eq!(registry.peer_count(), 1);
776
777        registry.unregister("to-remove");
778        assert_eq!(registry.peer_count(), 0);
779        assert!(registry.get("to-remove").is_none());
780    }
781
782    // ── test 15: unregister on unknown peer is no-op ──────────────────────────
783
784    #[test]
785    fn test_unregister_unknown_is_noop() {
786        let registry = NodeCapabilityRegistry::new();
787        registry.unregister("ghost"); // must not panic
788        assert_eq!(registry.peer_count(), 0);
789    }
790
791    // ── test 16: re-register replaces existing entry ──────────────────────────
792
793    #[test]
794    fn test_register_replaces_existing() {
795        let registry = NodeCapabilityRegistry::new();
796
797        registry.register(make_node_caps(
798            "peer-r",
799            vec![NodeCapability::ContentRouting],
800        ));
801        registry.register(make_node_caps(
802            "peer-r",
803            vec![NodeCapability::BlockStorage { stored_bytes: 9999 }],
804        ));
805
806        assert_eq!(registry.peer_count(), 1);
807        let nc = registry.get("peer-r").expect("peer-r should be present");
808        assert!(!nc.has_capability("content_routing"));
809        assert!(nc.has_capability("block_storage"));
810    }
811
812    // ── test 17: gradient_sync capability name ────────────────────────────────
813
814    #[test]
815    fn test_gradient_sync_name() {
816        let cap = NodeCapability::GradientSync {
817            model_size_bytes: 1_000_000,
818        };
819        assert_eq!(cap.name(), "gradient_sync");
820    }
821
822    // ── test 18: serde round-trip ─────────────────────────────────────────────
823
824    #[test]
825    fn test_serde_round_trip() {
826        let original = make_node_caps(
827            "peer-serde",
828            vec![
829                NodeCapability::TensorLogic { rule_count: 42 },
830                NodeCapability::GradientSync {
831                    model_size_bytes: 1024,
832                },
833            ],
834        );
835
836        let json = serde_json::to_string(&original).expect("serialise");
837        let decoded: NodeCapabilities = serde_json::from_str(&json).expect("deserialise");
838        assert_eq!(original, decoded);
839    }
840
841    // ─────────────────────────────────────────────────────────────────────────
842    // PeerCapabilityRegistry tests
843    // ─────────────────────────────────────────────────────────────────────────
844
845    // ── helper ────────────────────────────────────────────────────────────────
846
847    fn make_registry() -> PeerCapabilityRegistry {
848        PeerCapabilityRegistry::new()
849    }
850
851    // ── test 19: advertise creates entry ─────────────────────────────────────
852
853    #[test]
854    fn test_pcr_advertise_creates_entry() {
855        let mut reg = make_registry();
856        reg.advertise("peer-a".to_string(), vec![Capability::Kademlia], 0, 100);
857        let caps = reg
858            .peer_capabilities("peer-a", 0)
859            .expect("should have capabilities");
860        assert_eq!(caps, &[Capability::Kademlia]);
861    }
862
863    // ── test 20: advertise overwrites existing ────────────────────────────────
864
865    #[test]
866    fn test_pcr_advertise_overwrites_existing() {
867        let mut reg = make_registry();
868        reg.advertise("peer-b".to_string(), vec![Capability::Kademlia], 0, 100);
869        reg.advertise(
870            "peer-b".to_string(),
871            vec![Capability::Bitswap { version: 2 }],
872            10,
873            200,
874        );
875        let caps = reg
876            .peer_capabilities("peer-b", 10)
877            .expect("should have capabilities");
878        assert_eq!(caps.len(), 1);
879        assert!(caps.contains(&Capability::Bitswap { version: 2 }));
880        assert!(!caps.contains(&Capability::Kademlia));
881    }
882
883    // ── test 21: peers_with_capability returns correct sorted peers ───────────
884
885    #[test]
886    fn test_pcr_peers_with_capability_sorted() {
887        let mut reg = make_registry();
888        reg.advertise("peer-c".to_string(), vec![Capability::Kademlia], 0, 100);
889        reg.advertise("peer-a".to_string(), vec![Capability::Kademlia], 0, 100);
890        reg.advertise("peer-b".to_string(), vec![Capability::Kademlia], 0, 100);
891        reg.advertise(
892            "peer-d".to_string(),
893            vec![Capability::ContentAddressing],
894            0,
895            100,
896        );
897
898        let peers = reg.peers_with_capability(&Capability::Kademlia, 0);
899        assert_eq!(peers, vec!["peer-a", "peer-b", "peer-c"]);
900    }
901
902    // ── test 22: peers_with_capability ignores expired ────────────────────────
903
904    #[test]
905    fn test_pcr_peers_with_capability_ignores_expired() {
906        let mut reg = make_registry();
907        reg.advertise("fresh".to_string(), vec![Capability::Kademlia], 0, 100);
908        // Expires at tick 10, query at tick 20 → expired
909        reg.advertise("stale".to_string(), vec![Capability::Kademlia], 0, 10);
910
911        let peers = reg.peers_with_capability(&Capability::Kademlia, 20);
912        assert_eq!(peers, vec!["fresh"]);
913    }
914
915    // ── test 23: peer_capabilities returns None for expired ───────────────────
916
917    #[test]
918    fn test_pcr_peer_capabilities_none_for_expired() {
919        let mut reg = make_registry();
920        // expires_at_tick = 0 + 5 = 5; query at tick 10 → expired
921        reg.advertise("peer-exp".to_string(), vec![Capability::Kademlia], 0, 5);
922        assert!(reg.peer_capabilities("peer-exp", 10).is_none());
923    }
924
925    // ── test 24: peer_capabilities returns Some for valid ─────────────────────
926
927    #[test]
928    fn test_pcr_peer_capabilities_some_for_valid() {
929        let mut reg = make_registry();
930        reg.advertise(
931            "peer-ok".to_string(),
932            vec![Capability::ContentAddressing],
933            0,
934            100,
935        );
936        let caps = reg
937            .peer_capabilities("peer-ok", 50)
938            .expect("should be valid");
939        assert_eq!(caps, &[Capability::ContentAddressing]);
940    }
941
942    // ── test 25: peer_capabilities returns None for unknown peer ─────────────
943
944    #[test]
945    fn test_pcr_peer_capabilities_none_for_unknown() {
946        let reg = make_registry();
947        assert!(reg.peer_capabilities("ghost", 0).is_none());
948    }
949
950    // ── test 26: remove_peer returns true when peer exists ───────────────────
951
952    #[test]
953    fn test_pcr_remove_peer_returns_true() {
954        let mut reg = make_registry();
955        reg.advertise("peer-del".to_string(), vec![Capability::Kademlia], 0, 100);
956        assert!(reg.remove_peer("peer-del"));
957        assert!(reg.peer_capabilities("peer-del", 0).is_none());
958    }
959
960    // ── test 27: remove_peer returns false when peer absent ──────────────────
961
962    #[test]
963    fn test_pcr_remove_peer_returns_false() {
964        let mut reg = make_registry();
965        assert!(!reg.remove_peer("nobody"));
966    }
967
968    // ── test 28: evict_expired removes expired entries ────────────────────────
969
970    #[test]
971    fn test_pcr_evict_expired_removes_expired() {
972        let mut reg = make_registry();
973        reg.advertise("alive".to_string(), vec![Capability::Kademlia], 0, 100);
974        reg.advertise("dead-1".to_string(), vec![Capability::Kademlia], 0, 5);
975        reg.advertise("dead-2".to_string(), vec![Capability::Kademlia], 0, 3);
976
977        let evicted = reg.evict_expired(10);
978        assert_eq!(evicted, 2);
979        // "alive" is still valid at tick 10 (expires at 100)
980        assert!(reg.peer_capabilities("alive", 10).is_some());
981        assert!(reg.peer_capabilities("dead-1", 10).is_none());
982        assert!(reg.peer_capabilities("dead-2", 10).is_none());
983    }
984
985    // ── test 29: evict_expired returns 0 when nothing expired ────────────────
986
987    #[test]
988    fn test_pcr_evict_expired_zero_when_none_expired() {
989        let mut reg = make_registry();
990        reg.advertise("p1".to_string(), vec![Capability::Kademlia], 0, 100);
991        reg.advertise("p2".to_string(), vec![Capability::Kademlia], 0, 200);
992        assert_eq!(reg.evict_expired(0), 0);
993    }
994
995    // ── test 30: stats active_peers vs total_peers ────────────────────────────
996
997    #[test]
998    fn test_pcr_stats_active_vs_total() {
999        let mut reg = make_registry();
1000        reg.advertise("active-1".to_string(), vec![Capability::Kademlia], 0, 100);
1001        reg.advertise(
1002            "active-2".to_string(),
1003            vec![Capability::ContentAddressing],
1004            0,
1005            100,
1006        );
1007        // expires at tick 5, queried at tick 50 → expired
1008        reg.advertise("expired-1".to_string(), vec![Capability::Kademlia], 0, 5);
1009
1010        let stats = reg.stats(50);
1011        assert_eq!(stats.total_peers, 3);
1012        assert_eq!(stats.active_peers, 2);
1013        assert_eq!(stats.expired_count, 1);
1014        assert_eq!(stats.total_capabilities_registered, 3); // 1 + 1 + 1
1015    }
1016
1017    // ── test 31: GossipSub topic matching ────────────────────────────────────
1018
1019    #[test]
1020    fn test_pcr_gossipsub_topic_matching_exact() {
1021        let mut reg = make_registry();
1022        let topics_a = vec!["news".to_string(), "sports".to_string()];
1023        let topics_b = vec!["weather".to_string()];
1024
1025        reg.advertise(
1026            "peer-gs-a".to_string(),
1027            vec![Capability::GossipSub {
1028                topics: topics_a.clone(),
1029            }],
1030            0,
1031            100,
1032        );
1033        reg.advertise(
1034            "peer-gs-b".to_string(),
1035            vec![Capability::GossipSub {
1036                topics: topics_b.clone(),
1037            }],
1038            0,
1039            100,
1040        );
1041
1042        // Exact match: must have identical topic list
1043        let peers_news = reg.peers_with_capability(
1044            &Capability::GossipSub {
1045                topics: topics_a.clone(),
1046            },
1047            0,
1048        );
1049        assert_eq!(peers_news, vec!["peer-gs-a"]);
1050
1051        let peers_weather = reg.peers_with_capability(
1052            &Capability::GossipSub {
1053                topics: topics_b.clone(),
1054            },
1055            0,
1056        );
1057        assert_eq!(peers_weather, vec!["peer-gs-b"]);
1058    }
1059
1060    // ── test 32: Custom capability matching ───────────────────────────────────
1061
1062    #[test]
1063    fn test_pcr_custom_capability_matching() {
1064        let mut reg = make_registry();
1065        reg.advertise(
1066            "peer-custom".to_string(),
1067            vec![Capability::Custom {
1068                name: "my-proto".to_string(),
1069                version: 3,
1070            }],
1071            0,
1072            100,
1073        );
1074
1075        // Exact match succeeds
1076        let peers = reg.peers_with_capability(
1077            &Capability::Custom {
1078                name: "my-proto".to_string(),
1079                version: 3,
1080            },
1081            0,
1082        );
1083        assert_eq!(peers, vec!["peer-custom"]);
1084
1085        // Different version — no match
1086        let peers_v2 = reg.peers_with_capability(
1087            &Capability::Custom {
1088                name: "my-proto".to_string(),
1089                version: 2,
1090            },
1091            0,
1092        );
1093        assert!(peers_v2.is_empty());
1094
1095        // Different name — no match
1096        let peers_other = reg.peers_with_capability(
1097            &Capability::Custom {
1098                name: "other-proto".to_string(),
1099                version: 3,
1100            },
1101            0,
1102        );
1103        assert!(peers_other.is_empty());
1104    }
1105
1106    // ── test 33: TensorSwap version matching ──────────────────────────────────
1107
1108    #[test]
1109    fn test_pcr_tensorswap_version_matching() {
1110        let mut reg = make_registry();
1111        reg.advertise(
1112            "peer-ts-v1".to_string(),
1113            vec![Capability::TensorSwap { version: 1 }],
1114            0,
1115            100,
1116        );
1117        reg.advertise(
1118            "peer-ts-v2".to_string(),
1119            vec![Capability::TensorSwap { version: 2 }],
1120            0,
1121            100,
1122        );
1123
1124        let v1_peers = reg.peers_with_capability(&Capability::TensorSwap { version: 1 }, 0);
1125        assert_eq!(v1_peers, vec!["peer-ts-v1"]);
1126
1127        let v2_peers = reg.peers_with_capability(&Capability::TensorSwap { version: 2 }, 0);
1128        assert_eq!(v2_peers, vec!["peer-ts-v2"]);
1129    }
1130
1131    // ── test 34: Bitswap version exact match ─────────────────────────────────
1132
1133    #[test]
1134    fn test_pcr_bitswap_exact_version() {
1135        let mut reg = make_registry();
1136        reg.advertise(
1137            "peer-bs".to_string(),
1138            vec![Capability::Bitswap { version: 1 }],
1139            0,
1140            100,
1141        );
1142
1143        // Same version → match
1144        assert_eq!(
1145            reg.peers_with_capability(&Capability::Bitswap { version: 1 }, 0),
1146            vec!["peer-bs"]
1147        );
1148
1149        // Different version → no match
1150        assert!(reg
1151            .peers_with_capability(&Capability::Bitswap { version: 2 }, 0)
1152            .is_empty());
1153    }
1154
1155    // ── test 35: stats total_capabilities_registered counts all (incl. expired) ──
1156
1157    #[test]
1158    fn test_pcr_stats_total_caps_includes_expired() {
1159        let mut reg = make_registry();
1160        // 2 capabilities
1161        reg.advertise(
1162            "active".to_string(),
1163            vec![Capability::Kademlia, Capability::ContentAddressing],
1164            0,
1165            100,
1166        );
1167        // 1 capability, expired at tick 50
1168        reg.advertise(
1169            "expired".to_string(),
1170            vec![Capability::Bitswap { version: 1 }],
1171            0,
1172            5,
1173        );
1174
1175        let stats = reg.stats(50);
1176        assert_eq!(stats.total_capabilities_registered, 3); // 2 + 1
1177        assert_eq!(stats.active_peers, 1);
1178        assert_eq!(stats.expired_count, 1);
1179    }
1180
1181    // ── test 36: multiple capabilities per peer ───────────────────────────────
1182
1183    #[test]
1184    fn test_pcr_multiple_capabilities_per_peer() {
1185        let mut reg = make_registry();
1186        reg.advertise(
1187            "multi".to_string(),
1188            vec![
1189                Capability::Kademlia,
1190                Capability::Bitswap { version: 1 },
1191                Capability::ContentAddressing,
1192            ],
1193            0,
1194            100,
1195        );
1196
1197        assert_eq!(
1198            reg.peers_with_capability(&Capability::Kademlia, 0),
1199            vec!["multi"]
1200        );
1201        assert_eq!(
1202            reg.peers_with_capability(&Capability::Bitswap { version: 1 }, 0),
1203            vec!["multi"]
1204        );
1205        assert_eq!(
1206            reg.peers_with_capability(&Capability::ContentAddressing, 0),
1207            vec!["multi"]
1208        );
1209    }
1210
1211    // ── test 37: advertisement valid at boundary tick ─────────────────────────
1212
1213    #[test]
1214    fn test_pcr_advert_valid_at_boundary() {
1215        let mut reg = make_registry();
1216        // expires_at_tick = 0 + 10 = 10
1217        reg.advertise("peer-bnd".to_string(), vec![Capability::Kademlia], 0, 10);
1218
1219        // current_tick < expires_at_tick (9 < 10) → valid
1220        assert!(reg.peer_capabilities("peer-bnd", 9).is_some());
1221        // current_tick == expires_at_tick (10 == 10) → is_valid returns false (not strictly less)
1222        assert!(reg.peer_capabilities("peer-bnd", 10).is_none());
1223    }
1224
1225    // ── test 38: empty registry stats ────────────────────────────────────────
1226
1227    #[test]
1228    fn test_pcr_empty_registry_stats() {
1229        let reg = make_registry();
1230        let stats = reg.stats(0);
1231        assert_eq!(stats.total_peers, 0);
1232        assert_eq!(stats.active_peers, 0);
1233        assert_eq!(stats.expired_count, 0);
1234        assert_eq!(stats.total_capabilities_registered, 0);
1235    }
1236
1237    // ── test 39: peers_with_capability empty when no peers ───────────────────
1238
1239    #[test]
1240    fn test_pcr_peers_with_capability_empty_registry() {
1241        let reg = make_registry();
1242        assert!(reg
1243            .peers_with_capability(&Capability::Kademlia, 0)
1244            .is_empty());
1245    }
1246
1247    // ── test 40: CapabilityAdvertisement is_valid boundary ───────────────────
1248
1249    #[test]
1250    fn test_advert_is_valid_boundary() {
1251        let advert = CapabilityAdvertisement {
1252            peer_id: "p".to_string(),
1253            capabilities: vec![],
1254            advertised_at_tick: 0,
1255            expires_at_tick: 50,
1256        };
1257        assert!(advert.is_valid(49));
1258        assert!(!advert.is_valid(50));
1259        assert!(!advert.is_valid(51));
1260    }
1261
1262    // ── test 41: CapabilityAdvertisement has_capability ──────────────────────
1263
1264    #[test]
1265    fn test_advert_has_capability() {
1266        let advert = CapabilityAdvertisement {
1267            peer_id: "p".to_string(),
1268            capabilities: vec![Capability::Kademlia, Capability::ContentAddressing],
1269            advertised_at_tick: 0,
1270            expires_at_tick: 100,
1271        };
1272        assert!(advert.has_capability(&Capability::Kademlia));
1273        assert!(advert.has_capability(&Capability::ContentAddressing));
1274        assert!(!advert.has_capability(&Capability::Bitswap { version: 1 }));
1275    }
1276
1277    // ── test 42: evict_expired then re-advertise ──────────────────────────────
1278
1279    #[test]
1280    fn test_pcr_evict_then_re_advertise() {
1281        let mut reg = make_registry();
1282        reg.advertise("peer-evict".to_string(), vec![Capability::Kademlia], 0, 5);
1283
1284        // Evict at tick 10
1285        let evicted = reg.evict_expired(10);
1286        assert_eq!(evicted, 1);
1287
1288        // Re-advertise
1289        reg.advertise(
1290            "peer-evict".to_string(),
1291            vec![Capability::ContentAddressing],
1292            10,
1293            50,
1294        );
1295        let caps = reg
1296            .peer_capabilities("peer-evict", 10)
1297            .expect("should be present after re-advertise");
1298        assert!(caps.contains(&Capability::ContentAddressing));
1299    }
1300
1301    // ── test 43: peers_with_capability when all peers expired ─────────────────
1302
1303    #[test]
1304    fn test_pcr_peers_with_capability_all_expired() {
1305        let mut reg = make_registry();
1306        reg.advertise("peer-x".to_string(), vec![Capability::Kademlia], 0, 1);
1307        reg.advertise("peer-y".to_string(), vec![Capability::Kademlia], 0, 2);
1308
1309        let peers = reg.peers_with_capability(&Capability::Kademlia, 100);
1310        assert!(peers.is_empty());
1311    }
1312}