Skip to main content

ipfrs_network/
topology_mapper.rs

1//! Network Topology Mapper
2//!
3//! Discovers and maps the P2P network topology, computing graph metrics and
4//! detecting structural properties.  The mapper maintains an in-memory directed
5//! graph of overlay nodes and edges, and exposes algorithms for path finding,
6//! reachability, clustering, and component analysis.
7//!
8//! # Design
9//!
10//! * `NetworkTopologyMapper` owns the graph state.
11//! * Nodes are keyed by opaque `String` identifiers (peer IDs or any label).
12//! * Edges are directed (from → to) with latency and bandwidth annotations.
13//! * All algorithms operate on the in-memory graph without I/O.
14
15use std::cmp::Reverse;
16use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
17
18// ─── Core graph types ─────────────────────────────────────────────────────────
19
20/// A node in the topology graph, representing a single peer.
21#[derive(Debug, Clone)]
22pub struct TopoNode {
23    /// Unique identifier of this peer.
24    pub node_id: String,
25    /// Network address (multiaddr or socket string).
26    pub address: String,
27    /// Unix epoch milliseconds when this node was first observed.
28    pub discovered_at: u64,
29    /// BFS hop distance from the local node (0 = local node).
30    pub hop_distance: u32,
31}
32
33/// A directed edge between two peers.
34#[derive(Debug, Clone)]
35pub struct TopoEdge {
36    /// Source peer identifier.
37    pub from: String,
38    /// Destination peer identifier.
39    pub to: String,
40    /// One-way (or round-trip) latency estimate in milliseconds.
41    pub latency_ms: f64,
42    /// Measured bandwidth in bits per second.
43    pub bandwidth_bps: u64,
44    /// Unix epoch milliseconds when this edge was observed.
45    pub discovered_at: u64,
46}
47
48/// A point-in-time snapshot of the full topology.
49#[derive(Debug, Clone)]
50pub struct TopologySnapshot {
51    /// All nodes in the graph at snapshot time.
52    pub nodes: Vec<TopoNode>,
53    /// All directed edges in the graph at snapshot time.
54    pub edges: Vec<TopoEdge>,
55    /// Unix epoch milliseconds when the snapshot was taken.
56    pub snapshot_at: u64,
57    /// Identifier of the local node.
58    pub local_node_id: String,
59}
60
61/// Result of a path query.
62#[derive(Debug, Clone, PartialEq)]
63pub struct PathResult {
64    /// Ordered list of node IDs from source to destination (inclusive).
65    pub path: Vec<String>,
66    /// Sum of latency_ms across all traversed edges.
67    pub total_latency_ms: f64,
68    /// Number of hops (= `path.len() - 1`).
69    pub hop_count: usize,
70}
71
72/// Aggregate statistics for the current topology graph.
73#[derive(Debug, Clone, PartialEq)]
74pub struct TopologyStats {
75    /// Total number of nodes.
76    pub node_count: usize,
77    /// Total number of directed edges.
78    pub edge_count: usize,
79    /// Mean latency across all edges (0.0 if no edges).
80    pub avg_latency_ms: f64,
81    /// Maximum latency across all edges (0.0 if no edges).
82    pub max_latency_ms: f64,
83    /// Mean out-degree across all nodes (0.0 if no nodes).
84    pub avg_out_degree: f64,
85    /// Number of weakly connected components.
86    pub component_count: usize,
87    /// Graph diameter (max shortest-path latency), or `None` if < 2 nodes.
88    pub diameter: Option<f64>,
89}
90
91// ─── Legacy compatibility aliases ────────────────────────────────────────────
92
93/// Legacy: directed edge (alias for `TopoEdge`).
94///
95/// Retained so existing re-exports from `lib.rs` continue to compile.
96pub type PeerEdge = TopoEdge;
97
98/// Legacy: topology node (alias for `TopoNode`).
99///
100/// Retained so existing re-exports from `lib.rs` continue to compile.
101pub type TopologyNode = TopoNode;
102
103// ─── Main mapper ──────────────────────────────────────────────────────────────
104
105/// Discovers and maps the P2P network topology.
106///
107/// # Example
108///
109/// ```rust
110/// use ipfrs_network::topology_mapper::{NetworkTopologyMapper, TopoNode, TopoEdge};
111///
112/// let mut mapper = NetworkTopologyMapper::new("local".to_string());
113/// mapper.add_node(TopoNode {
114///     node_id: "peer-1".to_string(),
115///     address: "/ip4/127.0.0.1/tcp/4001".to_string(),
116///     discovered_at: 0,
117///     hop_distance: 1,
118/// });
119/// mapper.add_edge(TopoEdge {
120///     from: "local".to_string(),
121///     to: "peer-1".to_string(),
122///     latency_ms: 5.0,
123///     bandwidth_bps: 10_000_000,
124///     discovered_at: 0,
125/// });
126/// let path = mapper.shortest_path("local", "peer-1");
127/// assert!(path.is_some());
128/// ```
129#[derive(Debug, Clone)]
130pub struct NetworkTopologyMapper {
131    /// Identifier of the local node (the observer).
132    pub local_id: String,
133    /// All known nodes keyed by `node_id`.
134    pub nodes: HashMap<String, TopoNode>,
135    /// All directed edges (flat list; may contain duplicate `from→to` pairs
136    /// if the caller adds them; the last-added is preferred for path finding).
137    pub edges: Vec<TopoEdge>,
138    /// Adjacency list: `node_id → [neighbour_ids]` (outgoing only).
139    pub adjacency: HashMap<String, Vec<String>>,
140}
141
142impl NetworkTopologyMapper {
143    // ── Construction ──────────────────────────────────────────────────────────
144
145    /// Create a new mapper with the given local node ID.
146    pub fn new(local_id: String) -> Self {
147        Self {
148            local_id,
149            nodes: HashMap::new(),
150            edges: Vec::new(),
151            adjacency: HashMap::new(),
152        }
153    }
154
155    // ── Node management ───────────────────────────────────────────────────────
156
157    /// Insert or update a node.  When a node is updated its hop_distance and
158    /// address are refreshed; `discovered_at` is kept if the new value is 0.
159    pub fn add_node(&mut self, node: TopoNode) {
160        let id = node.node_id.clone();
161        self.nodes.insert(id.clone(), node);
162        // Ensure an adjacency entry exists (even if empty).
163        self.adjacency.entry(id).or_default();
164        // Rebuild adjacency from stored edges to keep it consistent.
165        self.rebuild_adjacency_for_edges();
166    }
167
168    /// Remove a node and all edges that reference it (as source or destination).
169    ///
170    /// Returns `true` if the node existed, `false` otherwise.
171    pub fn remove_node(&mut self, node_id: &str) -> bool {
172        if self.nodes.remove(node_id).is_none() {
173            return false;
174        }
175        self.edges.retain(|e| e.from != node_id && e.to != node_id);
176        self.adjacency.remove(node_id);
177        // Remove node_id from all adjacency lists where it appears as a neighbour.
178        for neighbours in self.adjacency.values_mut() {
179            neighbours.retain(|n| n != node_id);
180        }
181        true
182    }
183
184    // ── Edge management ───────────────────────────────────────────────────────
185
186    /// Add a directed edge and update the adjacency list for `from`.
187    ///
188    /// Duplicate `from→to` edges are allowed; path queries always use the edge
189    /// with the **lowest latency** between a pair.
190    pub fn add_edge(&mut self, edge: TopoEdge) {
191        let from = edge.from.clone();
192        let to = edge.to.clone();
193        self.edges.push(edge);
194        // Update adjacency: add `to` to `from`'s neighbour list if not present.
195        let neighbours = self.adjacency.entry(from).or_default();
196        if !neighbours.contains(&to) {
197            neighbours.push(to.clone());
198        }
199        // Ensure the destination has an adjacency entry.
200        self.adjacency.entry(to).or_default();
201    }
202
203    /// Remove all edges from `from` to `to`.
204    ///
205    /// Returns `true` if at least one edge was removed.
206    pub fn remove_edge(&mut self, from: &str, to: &str) -> bool {
207        let before = self.edges.len();
208        self.edges.retain(|e| !(e.from == from && e.to == to));
209        let removed = self.edges.len() < before;
210        if removed {
211            // Rebuild adjacency for `from`.
212            if let Some(nbrs) = self.adjacency.get_mut(from) {
213                // Only remove `to` if no remaining edge from→to exists.
214                let still_connected = self.edges.iter().any(|e| e.from == from && e.to == to);
215                if !still_connected {
216                    nbrs.retain(|n| n != to);
217                }
218            }
219        }
220        removed
221    }
222
223    // ── Path finding ──────────────────────────────────────────────────────────
224
225    /// Dijkstra shortest path weighted by `latency_ms`.
226    ///
227    /// Returns `None` if either node is unknown or no path exists.
228    pub fn shortest_path(&self, from: &str, to: &str) -> Option<PathResult> {
229        if from == to {
230            return None;
231        }
232
233        // Build a per-pair minimum-latency map from edges.
234        let min_lat = self.min_latency_map();
235
236        // dist[node] = current best (total_latency, prev_node)
237        let mut dist: HashMap<&str, f64> = HashMap::new();
238        let mut prev: HashMap<&str, &str> = HashMap::new();
239        // BinaryHeap with Reverse for min-heap: (Reverse(latency_bits), node_id)
240        let mut heap: BinaryHeap<(Reverse<u64>, &str)> = BinaryHeap::new();
241
242        dist.insert(from, 0.0);
243        heap.push((Reverse(0), from));
244
245        while let Some((Reverse(cost_bits), node)) = heap.pop() {
246            let cost = f64::from_bits(cost_bits);
247
248            if node == to {
249                // Reconstruct path.
250                let mut path: Vec<String> = Vec::new();
251                let mut cur = to;
252                loop {
253                    path.push(cur.to_string());
254                    if let Some(&p) = prev.get(cur) {
255                        cur = p;
256                    } else {
257                        break;
258                    }
259                }
260                path.reverse();
261                let hop_count = path.len().saturating_sub(1);
262                return Some(PathResult {
263                    path,
264                    total_latency_ms: cost,
265                    hop_count,
266                });
267            }
268
269            // Skip stale heap entries.
270            if let Some(&best) = dist.get(node) {
271                if cost > best {
272                    continue;
273                }
274            }
275
276            // Explore neighbours.
277            if let Some(neighbours) = self.adjacency.get(node) {
278                for nb in neighbours {
279                    let edge_lat = min_lat
280                        .get(&(node, nb.as_str()))
281                        .copied()
282                        .unwrap_or(f64::INFINITY);
283                    let new_cost = cost + edge_lat;
284                    let better = dist.get(nb.as_str()).map(|&d| new_cost < d).unwrap_or(true);
285                    if better {
286                        dist.insert(nb.as_str(), new_cost);
287                        prev.insert(nb.as_str(), node);
288                        heap.push((Reverse(new_cost.to_bits()), nb.as_str()));
289                    }
290                }
291            }
292        }
293
294        None
295    }
296
297    /// BFS shortest path (unweighted; minimises hop count).
298    ///
299    /// Returns `None` if either node is unknown or no path exists.
300    pub fn hop_shortest_path(&self, from: &str, to: &str) -> Option<PathResult> {
301        if from == to {
302            return None;
303        }
304
305        let mut visited: HashSet<&str> = HashSet::new();
306        let mut queue: VecDeque<(&str, Vec<&str>)> = VecDeque::new();
307
308        visited.insert(from);
309        queue.push_back((from, vec![from]));
310
311        while let Some((node, path)) = queue.pop_front() {
312            if let Some(neighbours) = self.adjacency.get(node) {
313                for nb in neighbours {
314                    let nb_str = nb.as_str();
315                    if nb_str == to {
316                        let mut full: Vec<String> = path.iter().map(|s| s.to_string()).collect();
317                        full.push(to.to_string());
318                        let hop_count = full.len().saturating_sub(1);
319                        // Compute total latency along the hop path.
320                        let min_lat = self.min_latency_map();
321                        let total = full
322                            .windows(2)
323                            .map(|w| {
324                                min_lat
325                                    .get(&(w[0].as_str(), w[1].as_str()))
326                                    .copied()
327                                    .unwrap_or(0.0)
328                            })
329                            .sum();
330                        return Some(PathResult {
331                            path: full,
332                            total_latency_ms: total,
333                            hop_count,
334                        });
335                    }
336                    if !visited.contains(nb_str) {
337                        visited.insert(nb_str);
338                        let mut new_path = path.clone();
339                        new_path.push(nb_str);
340                        queue.push_back((nb_str, new_path));
341                    }
342                }
343            }
344        }
345
346        None
347    }
348
349    // ── Graph queries ─────────────────────────────────────────────────────────
350
351    /// Return the outgoing neighbour IDs for `node_id`.
352    pub fn neighbors(&self, node_id: &str) -> Vec<&str> {
353        self.adjacency
354            .get(node_id)
355            .map(|v| v.iter().map(String::as_str).collect())
356            .unwrap_or_default()
357    }
358
359    /// Number of edges pointing **into** `node_id`.
360    pub fn in_degree(&self, node_id: &str) -> usize {
361        self.edges.iter().filter(|e| e.to == node_id).count()
362    }
363
364    /// Number of outgoing edges from `node_id`.
365    pub fn out_degree(&self, node_id: &str) -> usize {
366        self.edges.iter().filter(|e| e.from == node_id).count()
367    }
368
369    /// BFS reachability check.
370    pub fn is_reachable(&self, from: &str, to: &str) -> bool {
371        if from == to {
372            return true;
373        }
374        let mut visited: HashSet<&str> = HashSet::new();
375        let mut queue: VecDeque<&str> = VecDeque::new();
376        visited.insert(from);
377        queue.push_back(from);
378        while let Some(node) = queue.pop_front() {
379            if let Some(nbrs) = self.adjacency.get(node) {
380                for nb in nbrs {
381                    let nb_str = nb.as_str();
382                    if nb_str == to {
383                        return true;
384                    }
385                    if visited.insert(nb_str) {
386                        queue.push_back(nb_str);
387                    }
388                }
389            }
390        }
391        false
392    }
393
394    /// Compute weakly connected components (treat directed edges as undirected).
395    ///
396    /// Each component is a sorted list of node IDs.  The returned list of
397    /// components is sorted by the first element of each component.
398    pub fn connected_components(&self) -> Vec<Vec<String>> {
399        // Build an undirected adjacency set from all edges + node entries.
400        let mut undirected: HashMap<&str, HashSet<&str>> = HashMap::new();
401
402        // Seed every known node (even isolated ones).
403        for id in self.nodes.keys() {
404            undirected.entry(id.as_str()).or_default();
405        }
406        for id in self.adjacency.keys() {
407            undirected.entry(id.as_str()).or_default();
408        }
409
410        for edge in &self.edges {
411            undirected
412                .entry(edge.from.as_str())
413                .or_default()
414                .insert(edge.to.as_str());
415            undirected
416                .entry(edge.to.as_str())
417                .or_default()
418                .insert(edge.from.as_str());
419        }
420
421        let mut visited: HashSet<&str> = HashSet::new();
422        let mut components: Vec<Vec<String>> = Vec::new();
423
424        for &start in undirected.keys() {
425            if visited.contains(start) {
426                continue;
427            }
428            // BFS over undirected graph.
429            let mut component: Vec<String> = Vec::new();
430            let mut queue: VecDeque<&str> = VecDeque::new();
431            visited.insert(start);
432            queue.push_back(start);
433            while let Some(node) = queue.pop_front() {
434                component.push(node.to_string());
435                if let Some(nbrs) = undirected.get(node) {
436                    for &nb in nbrs {
437                        if visited.insert(nb) {
438                            queue.push_back(nb);
439                        }
440                    }
441                }
442            }
443            component.sort();
444            components.push(component);
445        }
446
447        components.sort_by(|a, b| {
448            a.first()
449                .unwrap_or(&String::new())
450                .cmp(b.first().unwrap_or(&String::new()))
451        });
452        components
453    }
454
455    /// Graph diameter: the maximum shortest-path latency over all reachable pairs.
456    ///
457    /// Returns `None` when there are fewer than two nodes.
458    /// This is O(V × Dijkstra) and is expensive for large graphs.
459    pub fn diameter(&self) -> Option<f64> {
460        let ids: Vec<&str> = self
461            .nodes
462            .keys()
463            .map(String::as_str)
464            .chain(self.adjacency.keys().map(String::as_str))
465            .collect::<HashSet<_>>()
466            .into_iter()
467            .collect();
468
469        if ids.len() < 2 {
470            return None;
471        }
472
473        let mut max_lat: f64 = 0.0;
474        let mut found_any = false;
475
476        for &src in &ids {
477            for &dst in &ids {
478                if src == dst {
479                    continue;
480                }
481                if let Some(res) = self.shortest_path(src, dst) {
482                    found_any = true;
483                    if res.total_latency_ms > max_lat {
484                        max_lat = res.total_latency_ms;
485                    }
486                }
487            }
488        }
489
490        if found_any {
491            Some(max_lat)
492        } else {
493            None
494        }
495    }
496
497    /// Mean clustering coefficient (undirected interpretation).
498    ///
499    /// For each node whose combined (in+out) neighbour set has size ≥ 2, the
500    /// local clustering coefficient is:
501    ///
502    ///   C(v) = (actual triangles through v) / (k * (k-1) / 2)
503    ///
504    /// where `k` is the size of the undirected neighbourhood, and a "triangle"
505    /// exists when two neighbours are themselves connected (in either direction).
506    ///
507    /// Returns 0.0 if no node has k ≥ 2.
508    pub fn average_clustering_coefficient(&self) -> f64 {
509        let mut total_cc = 0.0_f64;
510        let mut count = 0_usize;
511
512        // Build undirected neighbour sets for all nodes.
513        let undirected_nbrs = self.undirected_neighbour_sets();
514        // Build a fast edge-existence lookup (directed).
515        let edge_set: HashSet<(&str, &str)> = self
516            .edges
517            .iter()
518            .map(|e| (e.from.as_str(), e.to.as_str()))
519            .collect();
520
521        for (node, nbrs) in &undirected_nbrs {
522            let k = nbrs.len();
523            if k < 2 {
524                continue;
525            }
526            let nbr_vec: Vec<&str> = nbrs.iter().copied().collect();
527            let mut triangles = 0_usize;
528            for i in 0..nbr_vec.len() {
529                for j in (i + 1)..nbr_vec.len() {
530                    let u = nbr_vec[i];
531                    let v = nbr_vec[j];
532                    // Check undirected connection between u and v.
533                    if edge_set.contains(&(u, v)) || edge_set.contains(&(v, u)) {
534                        triangles += 1;
535                    }
536                }
537            }
538            let possible = (k * (k - 1)) / 2;
539            let cc = triangles as f64 / possible as f64;
540            total_cc += cc;
541            count += 1;
542            let _ = node; // suppress unused-variable lint
543        }
544
545        if count == 0 {
546            0.0
547        } else {
548            total_cc / count as f64
549        }
550    }
551
552    // ── Snapshot / stats ──────────────────────────────────────────────────────
553
554    /// Take a snapshot of the current topology.
555    pub fn snapshot(&self) -> TopologySnapshot {
556        use std::time::{SystemTime, UNIX_EPOCH};
557        let now = SystemTime::now()
558            .duration_since(UNIX_EPOCH)
559            .map(|d| d.as_millis() as u64)
560            .unwrap_or(0);
561
562        TopologySnapshot {
563            nodes: self.nodes.values().cloned().collect(),
564            edges: self.edges.clone(),
565            snapshot_at: now,
566            local_node_id: self.local_id.clone(),
567        }
568    }
569
570    /// Total number of known nodes.
571    pub fn node_count(&self) -> usize {
572        self.nodes.len()
573    }
574
575    /// Total number of edges (including duplicates for the same pair).
576    pub fn edge_count(&self) -> usize {
577        self.edges.len()
578    }
579
580    /// Remove nodes whose `discovered_at` is older than `max_age_ms` relative
581    /// to `now`.  All edges referencing removed nodes are also cleaned up.
582    ///
583    /// Returns the number of nodes removed.
584    pub fn evict_stale(&mut self, max_age_ms: u64, now: u64) -> usize {
585        let stale_ids: Vec<String> = self
586            .nodes
587            .iter()
588            .filter(|(_, n)| now.saturating_sub(n.discovered_at) > max_age_ms)
589            .map(|(id, _)| id.clone())
590            .collect();
591
592        let removed = stale_ids.len();
593        for id in &stale_ids {
594            self.nodes.remove(id);
595            self.edges.retain(|e| &e.from != id && &e.to != id);
596            self.adjacency.remove(id);
597            for nbrs in self.adjacency.values_mut() {
598                nbrs.retain(|n| n != id);
599            }
600        }
601        removed
602    }
603
604    /// Compute aggregate statistics for the current topology.
605    pub fn stats(&self) -> TopologyStats {
606        let node_count = self.node_count();
607        let edge_count = self.edge_count();
608
609        let (avg_latency_ms, max_latency_ms) = if edge_count == 0 {
610            (0.0, 0.0)
611        } else {
612            let sum: f64 = self.edges.iter().map(|e| e.latency_ms).sum();
613            let max = self
614                .edges
615                .iter()
616                .map(|e| e.latency_ms)
617                .fold(0.0_f64, f64::max);
618            (sum / edge_count as f64, max)
619        };
620
621        let avg_out_degree = if node_count == 0 {
622            0.0
623        } else {
624            edge_count as f64 / node_count as f64
625        };
626
627        let component_count = self.connected_components().len();
628        let diameter = self.diameter();
629
630        TopologyStats {
631            node_count,
632            edge_count,
633            avg_latency_ms,
634            max_latency_ms,
635            avg_out_degree,
636            component_count,
637            diameter,
638        }
639    }
640
641    // ── Internal helpers ──────────────────────────────────────────────────────
642
643    /// Build a map `(from, to) → minimum latency` from the edge list.
644    fn min_latency_map(&self) -> HashMap<(&str, &str), f64> {
645        let mut map: HashMap<(&str, &str), f64> = HashMap::new();
646        for edge in &self.edges {
647            let key = (edge.from.as_str(), edge.to.as_str());
648            let entry = map.entry(key).or_insert(f64::INFINITY);
649            if edge.latency_ms < *entry {
650                *entry = edge.latency_ms;
651            }
652        }
653        map
654    }
655
656    /// Rebuild the full adjacency map from the current edge list.
657    fn rebuild_adjacency_for_edges(&mut self) {
658        // Keep existing entries but re-derive neighbour lists from edges.
659        // Existing nodes that have no edges still get an empty entry.
660        for id in self.nodes.keys() {
661            self.adjacency.entry(id.clone()).or_default();
662        }
663        for edge in &self.edges {
664            let from = edge.from.clone();
665            let to = edge.to.clone();
666            let nbrs = self.adjacency.entry(from).or_default();
667            if !nbrs.contains(&to) {
668                nbrs.push(to);
669            }
670            self.adjacency.entry(edge.to.clone()).or_default();
671        }
672    }
673
674    /// Build per-node undirected neighbour sets (used for clustering coefficient).
675    fn undirected_neighbour_sets(&self) -> HashMap<&str, HashSet<&str>> {
676        let mut map: HashMap<&str, HashSet<&str>> = HashMap::new();
677        for id in self.nodes.keys() {
678            map.entry(id.as_str()).or_default();
679        }
680        for edge in &self.edges {
681            map.entry(edge.from.as_str())
682                .or_default()
683                .insert(edge.to.as_str());
684            map.entry(edge.to.as_str())
685                .or_default()
686                .insert(edge.from.as_str());
687        }
688        map
689    }
690}
691
692// ─── Tests ────────────────────────────────────────────────────────────────────
693
694#[cfg(test)]
695mod tests {
696    use crate::topology_mapper::{
697        NetworkTopologyMapper, PathResult, TopoEdge, TopoNode, TopologyStats,
698    };
699
700    // ── Helpers ───────────────────────────────────────────────────────────────
701
702    fn node(id: &str, hop: u32) -> TopoNode {
703        TopoNode {
704            node_id: id.to_string(),
705            address: format!("/ip4/127.0.0.1/tcp/{}", 4000 + hop),
706            discovered_at: 1_000,
707            hop_distance: hop,
708        }
709    }
710
711    fn edge(from: &str, to: &str, lat: f64) -> TopoEdge {
712        TopoEdge {
713            from: from.to_string(),
714            to: to.to_string(),
715            latency_ms: lat,
716            bandwidth_bps: 1_000_000,
717            discovered_at: 1_000,
718        }
719    }
720
721    fn edge_ts(from: &str, to: &str, lat: f64, ts: u64) -> TopoEdge {
722        TopoEdge {
723            from: from.to_string(),
724            to: to.to_string(),
725            latency_ms: lat,
726            bandwidth_bps: 1_000_000,
727            discovered_at: ts,
728        }
729    }
730
731    // Build a simple linear graph: local → A → B → C
732    fn linear_mapper() -> NetworkTopologyMapper {
733        let mut m = NetworkTopologyMapper::new("local".to_string());
734        for (id, hop) in [("local", 0), ("A", 1), ("B", 2), ("C", 3)] {
735            m.add_node(node(id, hop));
736        }
737        m.add_edge(edge("local", "A", 10.0));
738        m.add_edge(edge("A", "B", 20.0));
739        m.add_edge(edge("B", "C", 30.0));
740        m
741    }
742
743    // ── 1. Constructor ────────────────────────────────────────────────────────
744
745    #[test]
746    fn test_new_empty() {
747        let m = NetworkTopologyMapper::new("local".to_string());
748        assert_eq!(m.local_id, "local");
749        assert!(m.nodes.is_empty());
750        assert!(m.edges.is_empty());
751        assert!(m.adjacency.is_empty());
752    }
753
754    // ── 2. add_node ───────────────────────────────────────────────────────────
755
756    #[test]
757    fn test_add_node_creates_entry() {
758        let mut m = NetworkTopologyMapper::new("local".to_string());
759        m.add_node(node("A", 1));
760        assert!(m.nodes.contains_key("A"));
761        assert!(m.adjacency.contains_key("A"));
762    }
763
764    #[test]
765    fn test_add_node_updates_existing() {
766        let mut m = NetworkTopologyMapper::new("local".to_string());
767        m.add_node(node("A", 1));
768        m.add_node(TopoNode {
769            node_id: "A".to_string(),
770            address: "/ip4/1.2.3.4/tcp/9999".to_string(),
771            discovered_at: 9999,
772            hop_distance: 3,
773        });
774        let n = m.nodes.get("A").expect("A should exist");
775        assert_eq!(n.address, "/ip4/1.2.3.4/tcp/9999");
776        assert_eq!(n.hop_distance, 3);
777    }
778
779    // ── 3. remove_node ────────────────────────────────────────────────────────
780
781    #[test]
782    fn test_remove_node_returns_true_when_exists() {
783        let mut m = NetworkTopologyMapper::new("local".to_string());
784        m.add_node(node("A", 1));
785        assert!(m.remove_node("A"));
786    }
787
788    #[test]
789    fn test_remove_node_returns_false_when_missing() {
790        let mut m = NetworkTopologyMapper::new("local".to_string());
791        assert!(!m.remove_node("Z"));
792    }
793
794    #[test]
795    fn test_remove_node_cleans_edges() {
796        let mut m = linear_mapper();
797        m.remove_node("A");
798        assert!(!m.edges.iter().any(|e| e.from == "A" || e.to == "A"));
799    }
800
801    #[test]
802    fn test_remove_node_cleans_adjacency() {
803        let mut m = linear_mapper();
804        m.remove_node("A");
805        assert!(!m.adjacency.contains_key("A"));
806        // "local" should no longer list "A" as a neighbour.
807        let local_nbrs = m.adjacency.get("local").cloned().unwrap_or_default();
808        assert!(!local_nbrs.contains(&"A".to_string()));
809    }
810
811    // ── 4. add_edge ───────────────────────────────────────────────────────────
812
813    #[test]
814    fn test_add_edge_updates_adjacency() {
815        let mut m = NetworkTopologyMapper::new("local".to_string());
816        m.add_edge(edge("X", "Y", 5.0));
817        let nbrs = m.adjacency.get("X").expect("X should have adjacency");
818        assert!(nbrs.contains(&"Y".to_string()));
819    }
820
821    #[test]
822    fn test_add_edge_no_duplicate_adjacency() {
823        let mut m = NetworkTopologyMapper::new("local".to_string());
824        m.add_edge(edge("X", "Y", 5.0));
825        m.add_edge(edge("X", "Y", 10.0));
826        let nbrs = m.adjacency.get("X").expect("X should exist");
827        assert_eq!(nbrs.iter().filter(|n| n.as_str() == "Y").count(), 1);
828    }
829
830    // ── 5. remove_edge ────────────────────────────────────────────────────────
831
832    #[test]
833    fn test_remove_edge_returns_true() {
834        let mut m = linear_mapper();
835        assert!(m.remove_edge("local", "A"));
836    }
837
838    #[test]
839    fn test_remove_edge_returns_false_when_missing() {
840        let mut m = linear_mapper();
841        assert!(!m.remove_edge("local", "C"));
842    }
843
844    #[test]
845    fn test_remove_edge_removes_from_edge_list() {
846        let mut m = linear_mapper();
847        m.remove_edge("local", "A");
848        assert!(!m.edges.iter().any(|e| e.from == "local" && e.to == "A"));
849    }
850
851    // ── 6. shortest_path (Dijkstra) ───────────────────────────────────────────
852
853    #[test]
854    fn test_shortest_path_direct() {
855        let m = linear_mapper();
856        let r = m.shortest_path("local", "A").expect("path should exist");
857        assert_eq!(r.path, vec!["local", "A"]);
858        assert_eq!(r.hop_count, 1);
859        assert!((r.total_latency_ms - 10.0).abs() < 1e-9);
860    }
861
862    #[test]
863    fn test_shortest_path_multi_hop() {
864        let m = linear_mapper();
865        let r = m.shortest_path("local", "C").expect("path should exist");
866        assert_eq!(r.path, vec!["local", "A", "B", "C"]);
867        assert_eq!(r.hop_count, 3);
868        assert!((r.total_latency_ms - 60.0).abs() < 1e-9);
869    }
870
871    #[test]
872    fn test_shortest_path_prefers_low_latency() {
873        let mut m = NetworkTopologyMapper::new("s".to_string());
874        for id in ["s", "fast", "slow", "t"] {
875            m.add_node(node(id, 0));
876        }
877        // s → fast → t (total 5)  vs  s → slow → t (total 1000)
878        m.add_edge(edge("s", "fast", 2.0));
879        m.add_edge(edge("fast", "t", 3.0));
880        m.add_edge(edge("s", "slow", 500.0));
881        m.add_edge(edge("slow", "t", 500.0));
882        let r = m.shortest_path("s", "t").expect("path should exist");
883        assert_eq!(r.path, vec!["s", "fast", "t"]);
884        assert!((r.total_latency_ms - 5.0).abs() < 1e-9);
885    }
886
887    #[test]
888    fn test_shortest_path_same_node_returns_none() {
889        let m = linear_mapper();
890        assert!(m.shortest_path("local", "local").is_none());
891    }
892
893    #[test]
894    fn test_shortest_path_disconnected_returns_none() {
895        let mut m = linear_mapper();
896        m.add_node(node("island", 99));
897        assert!(m.shortest_path("local", "island").is_none());
898    }
899
900    // ── 7. hop_shortest_path (BFS) ────────────────────────────────────────────
901
902    #[test]
903    fn test_hop_shortest_path_direct() {
904        let m = linear_mapper();
905        let r = m
906            .hop_shortest_path("local", "A")
907            .expect("path should exist");
908        assert_eq!(r.hop_count, 1);
909        assert_eq!(r.path, vec!["local", "A"]);
910    }
911
912    #[test]
913    fn test_hop_shortest_path_prefers_fewer_hops() {
914        let mut m = NetworkTopologyMapper::new("s".to_string());
915        for id in ["s", "x", "y", "z", "t"] {
916            m.add_node(node(id, 0));
917        }
918        // s → t (1 hop, high latency)  vs  s → x → y → z → t (4 hops, low latency)
919        m.add_edge(edge("s", "t", 9999.0));
920        m.add_edge(edge("s", "x", 1.0));
921        m.add_edge(edge("x", "y", 1.0));
922        m.add_edge(edge("y", "z", 1.0));
923        m.add_edge(edge("z", "t", 1.0));
924        let r = m.hop_shortest_path("s", "t").expect("path should exist");
925        assert_eq!(r.hop_count, 1);
926        assert_eq!(r.path, vec!["s", "t"]);
927    }
928
929    #[test]
930    fn test_hop_shortest_path_same_node_none() {
931        let m = linear_mapper();
932        assert!(m.hop_shortest_path("A", "A").is_none());
933    }
934
935    // ── 8. neighbors ──────────────────────────────────────────────────────────
936
937    #[test]
938    fn test_neighbors_correct() {
939        let m = linear_mapper();
940        let mut nbrs = m.neighbors("local");
941        nbrs.sort_unstable();
942        assert_eq!(nbrs, vec!["A"]);
943    }
944
945    #[test]
946    fn test_neighbors_unknown_node_empty() {
947        let m = linear_mapper();
948        assert!(m.neighbors("zzz").is_empty());
949    }
950
951    // ── 9. in_degree / out_degree ─────────────────────────────────────────────
952
953    #[test]
954    fn test_in_degree() {
955        let m = linear_mapper();
956        // Only "local → A" points to A.
957        assert_eq!(m.in_degree("A"), 1);
958        // "C" has one in-edge (B→C).
959        assert_eq!(m.in_degree("C"), 1);
960        // "local" has no in-edges.
961        assert_eq!(m.in_degree("local"), 0);
962    }
963
964    #[test]
965    fn test_out_degree() {
966        let m = linear_mapper();
967        assert_eq!(m.out_degree("local"), 1);
968        assert_eq!(m.out_degree("A"), 1);
969        assert_eq!(m.out_degree("C"), 0);
970    }
971
972    // ── 10. is_reachable ──────────────────────────────────────────────────────
973
974    #[test]
975    fn test_is_reachable_direct() {
976        let m = linear_mapper();
977        assert!(m.is_reachable("local", "A"));
978    }
979
980    #[test]
981    fn test_is_reachable_transitive() {
982        let m = linear_mapper();
983        assert!(m.is_reachable("local", "C"));
984    }
985
986    #[test]
987    fn test_is_reachable_same_node() {
988        let m = linear_mapper();
989        assert!(m.is_reachable("local", "local"));
990    }
991
992    #[test]
993    fn test_is_reachable_unreachable() {
994        let mut m = linear_mapper();
995        m.add_node(node("island", 99));
996        assert!(!m.is_reachable("local", "island"));
997    }
998
999    #[test]
1000    fn test_is_reachable_reverse_not_reachable() {
1001        // The graph is directed, so C cannot reach local.
1002        let m = linear_mapper();
1003        assert!(!m.is_reachable("C", "local"));
1004    }
1005
1006    // ── 11. connected_components ──────────────────────────────────────────────
1007
1008    #[test]
1009    fn test_connected_components_single() {
1010        let m = linear_mapper();
1011        let comps = m.connected_components();
1012        // All four nodes are weakly connected.
1013        assert_eq!(comps.len(), 1);
1014        let mut comp = comps[0].clone();
1015        comp.sort();
1016        assert_eq!(comp, vec!["A", "B", "C", "local"]);
1017    }
1018
1019    #[test]
1020    fn test_connected_components_two_islands() {
1021        let mut m = NetworkTopologyMapper::new("L".to_string());
1022        for id in ["L", "M", "X", "Y"] {
1023            m.add_node(node(id, 0));
1024        }
1025        m.add_edge(edge("L", "M", 1.0));
1026        m.add_edge(edge("X", "Y", 1.0));
1027        let comps = m.connected_components();
1028        assert_eq!(comps.len(), 2);
1029    }
1030
1031    #[test]
1032    fn test_connected_components_sorted() {
1033        let mut m = NetworkTopologyMapper::new("Z".to_string());
1034        for id in ["Z", "A", "M", "N"] {
1035            m.add_node(node(id, 0));
1036        }
1037        m.add_edge(edge("Z", "A", 1.0));
1038        m.add_edge(edge("M", "N", 1.0));
1039        let comps = m.connected_components();
1040        // Each component is sorted internally; components sorted by first element.
1041        for comp in &comps {
1042            let mut sorted = comp.clone();
1043            sorted.sort();
1044            assert_eq!(comp, &sorted, "component should be sorted");
1045        }
1046        // Components themselves are sorted by first element.
1047        for i in 1..comps.len() {
1048            assert!(comps[i - 1][0] <= comps[i][0]);
1049        }
1050    }
1051
1052    // ── 12. diameter ──────────────────────────────────────────────────────────
1053
1054    #[test]
1055    fn test_diameter_linear() {
1056        let m = linear_mapper();
1057        // local → C = 10+20+30 = 60, which should be the max.
1058        let d = m.diameter().expect("diameter should exist");
1059        assert!((d - 60.0).abs() < 1e-9);
1060    }
1061
1062    #[test]
1063    fn test_diameter_none_single_node() {
1064        let mut m = NetworkTopologyMapper::new("x".to_string());
1065        m.add_node(node("x", 0));
1066        assert!(m.diameter().is_none());
1067    }
1068
1069    #[test]
1070    fn test_diameter_none_disconnected_graph() {
1071        // Two fully isolated nodes — no reachable pairs → None.
1072        let mut m = NetworkTopologyMapper::new("a".to_string());
1073        m.add_node(node("a", 0));
1074        m.add_node(node("b", 0));
1075        // No edges; diameter is None because no reachable pair exists.
1076        assert!(m.diameter().is_none());
1077    }
1078
1079    // ── 13. average_clustering_coefficient ────────────────────────────────────
1080
1081    #[test]
1082    fn test_clustering_coefficient_triangle() {
1083        let mut m = NetworkTopologyMapper::new("A".to_string());
1084        for id in ["A", "B", "C"] {
1085            m.add_node(node(id, 0));
1086        }
1087        // Full triangle (directed, treated as undirected).
1088        m.add_edge(edge("A", "B", 1.0));
1089        m.add_edge(edge("B", "C", 1.0));
1090        m.add_edge(edge("A", "C", 1.0));
1091        let cc = m.average_clustering_coefficient();
1092        // Every node has k=2, one triangle → C = 1/1 = 1.0 each → mean = 1.0.
1093        assert!((cc - 1.0).abs() < 1e-9);
1094    }
1095
1096    #[test]
1097    fn test_clustering_coefficient_no_triangles() {
1098        let m = linear_mapper();
1099        // Linear graph — no triangles → local CC = 0 for A (only neighbour).
1100        // Actually A has neighbours {local, B} (undirected); are local and B connected? No.
1101        let cc = m.average_clustering_coefficient();
1102        assert!((cc - 0.0).abs() < 1e-9);
1103    }
1104
1105    #[test]
1106    fn test_clustering_coefficient_empty() {
1107        let m = NetworkTopologyMapper::new("x".to_string());
1108        assert_eq!(m.average_clustering_coefficient(), 0.0);
1109    }
1110
1111    // ── 14. snapshot ──────────────────────────────────────────────────────────
1112
1113    #[test]
1114    fn test_snapshot_counts() {
1115        let m = linear_mapper();
1116        let snap = m.snapshot();
1117        assert_eq!(snap.local_node_id, "local");
1118        assert_eq!(snap.nodes.len(), 4);
1119        assert_eq!(snap.edges.len(), 3);
1120    }
1121
1122    // ── 15. node_count / edge_count ───────────────────────────────────────────
1123
1124    #[test]
1125    fn test_node_count() {
1126        let m = linear_mapper();
1127        assert_eq!(m.node_count(), 4);
1128    }
1129
1130    #[test]
1131    fn test_edge_count() {
1132        let m = linear_mapper();
1133        assert_eq!(m.edge_count(), 3);
1134    }
1135
1136    // ── 16. evict_stale ───────────────────────────────────────────────────────
1137
1138    #[test]
1139    fn test_evict_stale_removes_old_nodes() {
1140        let mut m = NetworkTopologyMapper::new("local".to_string());
1141        m.add_node(TopoNode {
1142            node_id: "old".to_string(),
1143            address: "".to_string(),
1144            discovered_at: 0,
1145            hop_distance: 1,
1146        });
1147        m.add_node(TopoNode {
1148            node_id: "fresh".to_string(),
1149            address: "".to_string(),
1150            discovered_at: 900,
1151            hop_distance: 1,
1152        });
1153        // now=1000, max_age=500 → age_of_old=1000>500 stale; age_of_fresh=100 fresh.
1154        let removed = m.evict_stale(500, 1000);
1155        assert_eq!(removed, 1);
1156        assert!(!m.nodes.contains_key("old"));
1157        assert!(m.nodes.contains_key("fresh"));
1158    }
1159
1160    #[test]
1161    fn test_evict_stale_removes_associated_edges() {
1162        let mut m = NetworkTopologyMapper::new("local".to_string());
1163        m.add_node(TopoNode {
1164            node_id: "A".to_string(),
1165            address: "".to_string(),
1166            discovered_at: 0,
1167            hop_distance: 1,
1168        });
1169        m.add_node(TopoNode {
1170            node_id: "B".to_string(),
1171            address: "".to_string(),
1172            discovered_at: 999,
1173            hop_distance: 2,
1174        });
1175        m.add_edge(edge("A", "B", 5.0));
1176        m.evict_stale(500, 1000);
1177        // Edge A→B should be gone because A was evicted.
1178        assert!(!m.edges.iter().any(|e| e.from == "A" || e.to == "A"));
1179    }
1180
1181    #[test]
1182    fn test_evict_stale_returns_zero_when_all_fresh() {
1183        let m = linear_mapper(); // all nodes at discovered_at=1000
1184        let mut m2 = m.clone();
1185        let removed = m2.evict_stale(5000, 2000); // max_age=5000, now=2000
1186        assert_eq!(removed, 0);
1187    }
1188
1189    // ── 17. stats ─────────────────────────────────────────────────────────────
1190
1191    #[test]
1192    fn test_stats_empty() {
1193        let m = NetworkTopologyMapper::new("local".to_string());
1194        let s = m.stats();
1195        assert_eq!(
1196            s,
1197            TopologyStats {
1198                node_count: 0,
1199                edge_count: 0,
1200                avg_latency_ms: 0.0,
1201                max_latency_ms: 0.0,
1202                avg_out_degree: 0.0,
1203                component_count: 0,
1204                diameter: None,
1205            }
1206        );
1207    }
1208
1209    #[test]
1210    fn test_stats_linear_graph() {
1211        let m = linear_mapper();
1212        let s = m.stats();
1213        assert_eq!(s.node_count, 4);
1214        assert_eq!(s.edge_count, 3);
1215        // avg latency = (10+20+30)/3 = 20
1216        assert!((s.avg_latency_ms - 20.0).abs() < 1e-9);
1217        // max latency = 30
1218        assert!((s.max_latency_ms - 30.0).abs() < 1e-9);
1219        assert_eq!(s.component_count, 1);
1220    }
1221
1222    // ── 18. PathResult equality ───────────────────────────────────────────────
1223
1224    #[test]
1225    fn test_path_result_equality() {
1226        let p1 = PathResult {
1227            path: vec!["A".to_string(), "B".to_string()],
1228            total_latency_ms: 5.0,
1229            hop_count: 1,
1230        };
1231        let p2 = p1.clone();
1232        assert_eq!(p1, p2);
1233    }
1234
1235    // ── 19. Multiple edges same pair ──────────────────────────────────────────
1236
1237    #[test]
1238    fn test_shortest_path_uses_minimum_latency_edge() {
1239        let mut m = NetworkTopologyMapper::new("s".to_string());
1240        m.add_node(node("s", 0));
1241        m.add_node(node("t", 0));
1242        m.add_edge(edge("s", "t", 100.0));
1243        m.add_edge(edge("s", "t", 5.0));
1244        let r = m.shortest_path("s", "t").expect("path should exist");
1245        assert!((r.total_latency_ms - 5.0).abs() < 1e-9);
1246    }
1247
1248    // ── 20. Edge timestamp field ──────────────────────────────────────────────
1249
1250    #[test]
1251    fn test_edge_has_discovered_at() {
1252        let e = edge_ts("X", "Y", 1.0, 42_000);
1253        assert_eq!(e.discovered_at, 42_000);
1254    }
1255
1256    // ── 21. Rebuild adjacency after add_node ──────────────────────────────────
1257
1258    #[test]
1259    fn test_add_node_rebuilds_adjacency() {
1260        let mut m = NetworkTopologyMapper::new("local".to_string());
1261        m.add_edge(edge("local", "peer", 1.0));
1262        m.add_node(node("peer", 1));
1263        let nbrs = m.neighbors("local");
1264        assert!(nbrs.contains(&"peer"));
1265    }
1266
1267    // ── 22. is_reachable after remove_edge ────────────────────────────────────
1268
1269    #[test]
1270    fn test_is_reachable_after_remove_edge() {
1271        let mut m = linear_mapper();
1272        m.remove_edge("A", "B");
1273        assert!(!m.is_reachable("local", "B"));
1274    }
1275
1276    // ── 23. evict_stale boundary ──────────────────────────────────────────────
1277
1278    #[test]
1279    fn test_evict_stale_boundary_exact_age() {
1280        // discovered_at=500, now=1000, max_age=500 → age=500, which is NOT > 500 → not stale.
1281        let mut m = NetworkTopologyMapper::new("local".to_string());
1282        m.add_node(TopoNode {
1283            node_id: "A".to_string(),
1284            address: "".to_string(),
1285            discovered_at: 500,
1286            hop_distance: 1,
1287        });
1288        let removed = m.evict_stale(500, 1000);
1289        assert_eq!(removed, 0);
1290        assert!(m.nodes.contains_key("A"));
1291    }
1292
1293    // ── 24. hop_shortest_path returns none for disconnected ───────────────────
1294
1295    #[test]
1296    fn test_hop_shortest_path_disconnected_none() {
1297        let mut m = linear_mapper();
1298        m.add_node(node("isolated", 99));
1299        assert!(m.hop_shortest_path("local", "isolated").is_none());
1300    }
1301
1302    // ── 25. in_degree multiple ────────────────────────────────────────────────
1303
1304    #[test]
1305    fn test_in_degree_multiple_sources() {
1306        let mut m = NetworkTopologyMapper::new("hub".to_string());
1307        for id in ["hub", "s1", "s2", "s3"] {
1308            m.add_node(node(id, 0));
1309        }
1310        m.add_edge(edge("s1", "hub", 1.0));
1311        m.add_edge(edge("s2", "hub", 2.0));
1312        m.add_edge(edge("s3", "hub", 3.0));
1313        assert_eq!(m.in_degree("hub"), 3);
1314    }
1315
1316    // ── 26. snapshot local_node_id ────────────────────────────────────────────
1317
1318    #[test]
1319    fn test_snapshot_local_id() {
1320        let m = NetworkTopologyMapper::new("my-peer".to_string());
1321        assert_eq!(m.snapshot().local_node_id, "my-peer");
1322    }
1323
1324    // ── 27. remove_node reduces node_count ────────────────────────────────────
1325
1326    #[test]
1327    fn test_remove_node_reduces_count() {
1328        let mut m = linear_mapper();
1329        let before = m.node_count();
1330        m.remove_node("C");
1331        assert_eq!(m.node_count(), before - 1);
1332    }
1333
1334    // ── 28. add_edge creates destination adjacency entry ─────────────────────
1335
1336    #[test]
1337    fn test_add_edge_creates_destination_adjacency() {
1338        let mut m = NetworkTopologyMapper::new("s".to_string());
1339        m.add_edge(edge("s", "d", 1.0));
1340        assert!(m.adjacency.contains_key("d"));
1341    }
1342
1343    // ── 29. clustering coefficient partial triangle ───────────────────────────
1344
1345    #[test]
1346    fn test_clustering_partial_triangle() {
1347        let mut m = NetworkTopologyMapper::new("A".to_string());
1348        for id in ["A", "B", "C", "D"] {
1349            m.add_node(node(id, 0));
1350        }
1351        // Star: A → B, A → C, A → D (no edges between B, C, D).
1352        m.add_edge(edge("A", "B", 1.0));
1353        m.add_edge(edge("A", "C", 1.0));
1354        m.add_edge(edge("A", "D", 1.0));
1355        let cc = m.average_clustering_coefficient();
1356        // Only A has k ≥ 2 (k=3). No edges between its neighbours → cc(A)=0.
1357        assert!((cc - 0.0).abs() < 1e-9);
1358    }
1359
1360    // ── 30. diameter with two separate reachable pairs ────────────────────────
1361
1362    #[test]
1363    fn test_diameter_picks_max() {
1364        let mut m = NetworkTopologyMapper::new("A".to_string());
1365        for id in ["A", "B", "C"] {
1366            m.add_node(node(id, 0));
1367        }
1368        m.add_edge(edge("A", "B", 1.0));
1369        m.add_edge(edge("B", "C", 999.0));
1370        // A→B = 1, A→C = 1000, B→C = 999.  Max = 1000.
1371        let d = m.diameter().expect("diameter should exist");
1372        assert!((d - 1000.0).abs() < 1e-9);
1373    }
1374
1375    // ── 31. stats component_count ─────────────────────────────────────────────
1376
1377    #[test]
1378    fn test_stats_component_count() {
1379        let mut m = NetworkTopologyMapper::new("A".to_string());
1380        for id in ["A", "B", "C", "D"] {
1381            m.add_node(node(id, 0));
1382        }
1383        m.add_edge(edge("A", "B", 1.0));
1384        m.add_edge(edge("C", "D", 1.0));
1385        let s = m.stats();
1386        assert_eq!(s.component_count, 2);
1387    }
1388
1389    // ── 32. TopoNode hop_distance field accessible ────────────────────────────
1390
1391    #[test]
1392    fn test_topo_node_hop_distance() {
1393        let n = TopoNode {
1394            node_id: "peer".to_string(),
1395            address: "/ip4/1.2.3.4/tcp/1234".to_string(),
1396            discovered_at: 500,
1397            hop_distance: 7,
1398        };
1399        assert_eq!(n.hop_distance, 7);
1400    }
1401}