diff --git a/Cargo.lock b/Cargo.lock index 3c0b777..a02f1c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,11 @@ version = "0.1.0" [[package]] name = "apsis-mesh" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-math", + "apsis-topology", +] [[package]] name = "apsis-noise" diff --git a/crates/network/apsis-mesh/Cargo.toml b/crates/network/apsis-mesh/Cargo.toml index 1ff39b2..3a616df 100644 --- a/crates/network/apsis-mesh/Cargo.toml +++ b/crates/network/apsis-mesh/Cargo.toml @@ -11,3 +11,6 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-math.workspace = true +apsis-topology.workspace = true diff --git a/crates/network/apsis-mesh/src/connectivity.rs b/crates/network/apsis-mesh/src/connectivity.rs new file mode 100644 index 0000000..92ce1cf --- /dev/null +++ b/crates/network/apsis-mesh/src/connectivity.rs @@ -0,0 +1,183 @@ +//! Connectivity measures over a topology. + +use apsis_topology::Topology; + +/// Returns the set of nodes reachable from a node, following edges in their +/// direction. +/// +/// The start node is included, since a node reaches itself by a path of no +/// hops. The result is in node order. +/// +/// Returns an empty vector when the node is out of range. +pub fn reachable_from(topology: &Topology, source: usize) -> Vec { + if source >= topology.node_count() { + return Vec::new(); + } + let mut visited = vec![false; topology.node_count()]; + visited[source] = true; + let mut stack = vec![source]; + while let Some(node) = stack.pop() { + for edge in topology.edges().iter().filter(|edge| edge.from == node) { + if !visited[edge.to] { + visited[edge.to] = true; + stack.push(edge.to); + } + } + } + (0..topology.node_count()) + .filter(|node| visited[*node]) + .collect() +} + +/// Returns the number of independent paths between two nodes. +/// +/// Two paths are independent when they share no edge. By Menger's theorem +/// that number is the minimum number of edges whose removal disconnects the +/// two nodes, so it is the number of link failures the connection survives. +/// +/// The value is computed as a maximum flow of unit capacity between the two +/// nodes, using the Edmonds-Karp variant of the Ford-Fulkerson method, which +/// augments along a shortest path at each step. For the small graphs of a +/// satellite mesh this is fast, and it is exact. +/// +/// Returns `Some(0)` when no path exists, and `None` when either node is out +/// of range or the two nodes are the same, for which the question is not +/// defined. +pub fn edge_connectivity(topology: &Topology, source: usize, destination: usize) -> Option { + if source >= topology.node_count() || destination >= topology.node_count() { + return None; + } + if source == destination { + return None; + } + + // A residual capacity for each edge, indexed by the edge position. + let edges = topology.edges(); + let mut residual: Vec = vec![1; edges.len()]; + + let mut flow = 0; + loop { + // Breadth first search for an augmenting path in the residual + // graph. + let mut parent_edge: Vec> = vec![None; topology.node_count()]; + let mut visited = vec![false; topology.node_count()]; + visited[source] = true; + let mut queue = std::collections::VecDeque::new(); + queue.push_back(source); + + while let Some(node) = queue.pop_front() { + if node == destination { + break; + } + for (index, edge) in edges.iter().enumerate() { + // Forward edges with residual capacity. + if edge.from == node && residual[index] > 0 && !visited[edge.to] { + visited[edge.to] = true; + parent_edge[edge.to] = Some(index); + queue.push_back(edge.to); + continue; + } + // Reverse edges, which exist when flow has been pushed the + // other way and which cancel it. + if edge.to == node && residual[index] == 0 && !visited[edge.from] { + visited[edge.from] = true; + parent_edge[edge.from] = Some(index); + queue.push_back(edge.from); + } + } + } + + if !visited[destination] { + break; + } + + // Walk the path back, adjusting the residual capacities. An edge + // traversed in its own direction consumes one unit; an edge + // traversed against its direction restores one. + let mut node = destination; + while node != source { + let index = parent_edge[node]?; + let edge = edges[index]; + if edge.to == node { + residual[index] -= 1; + node = edge.from; + } else { + residual[index] += 1; + node = edge.to; + } + } + flow += 1; + } + Some(flow) +} + +/// Returns the number of strongly connected components. +/// +/// A component is strongly connected when every node in it can reach every +/// other following the edges in their direction. This is the count of +/// separate networks that can route internally, which for a satellite mesh +/// with directed inter-satellite links is the right measure of how many +/// pieces it has. +/// +/// The algorithm is Kosaraju's: a depth first search records the order in +/// which nodes finish, and a second search over the reversed edges, taking +/// the nodes in reverse finishing order, emits the components. +/// +/// Source: Kosaraju's algorithm as described in Cormen et al., +/// *Introduction to Algorithms*, 3rd edition, MIT Press, 2009, chapter 22. +pub fn strongly_connected_components(topology: &Topology) -> usize { + let node_count = topology.node_count(); + if node_count == 0 { + return 0; + } + + // First pass: the finishing order over the forward edges. + let mut visited = vec![false; node_count]; + let mut order = Vec::with_capacity(node_count); + for start in 0..node_count { + if visited[start] { + continue; + } + // An iterative depth first search, pushing a node again to mark the + // point at which its children are done. + let mut stack = vec![(start, false)]; + while let Some((node, processed)) = stack.pop() { + if processed { + order.push(node); + continue; + } + if visited[node] { + continue; + } + visited[node] = true; + stack.push((node, true)); + for edge in topology.edges().iter().filter(|edge| edge.from == node) { + if !visited[edge.to] { + stack.push((edge.to, false)); + } + } + } + } + + // Second pass: the reverse edges, taking nodes in reverse finishing + // order. + let mut assigned = vec![false; node_count]; + let mut components = 0; + for node in order.into_iter().rev() { + if assigned[node] { + continue; + } + components += 1; + let mut stack = vec![node]; + assigned[node] = true; + while let Some(current) = stack.pop() { + for edge in topology.edges().iter().filter(|edge| edge.to == current) { + if !assigned[edge.from] { + assigned[edge.from] = true; + stack.push(edge.from); + } + } + } + } + components +} diff --git a/crates/network/apsis-mesh/src/lib.rs b/crates/network/apsis-mesh/src/lib.rs index 7160af0..2874152 100644 --- a/crates/network/apsis-mesh/src/lib.rs +++ b/crates/network/apsis-mesh/src/lib.rs @@ -1 +1,51 @@ //! Mesh connectivity abstractions. +//! +//! A satellite mesh is a set of terminals that reach each other through +//! links, some of which are satellite links and some of which are ground +//! links. This crate provides the abstractions that a mesh application needs +//! on top of a topology: which terminals can reach which others, how many +//! independent ways there are to do so, and which links are critical. +//! +//! The three questions this crate answers: +//! +//! - **Reachability**: the set of terminals reachable from a given one. +//! - **Redundancy**: how many independent paths join two terminals, which is +//! the number of links that can fail independently. This is the +//! edge-connectivity of the graph, by Menger's theorem. +//! - **Criticality**: the links whose removal would separate the mesh, which +//! are the ones a network operator must protect. +//! +//! The redundancy computation is exact for the small graphs a satellite mesh +//! presents. It uses the edge version of Menger's theorem, computing the +//! connectivity from a sequence of maximum flow problems, each of unit +//! capacity. +//! +//! Source: Menger's theorem, and the reduction of edge connectivity to +//! maximum flow, as given in Cormen et al., *Introduction to Algorithms*, +//! 3rd edition, MIT Press, 2009, chapter 26. +//! +//! ``` +//! use apsis_mesh::Mesh; +//! use apsis_topology::Topology; +//! +//! // Three terminals in a ring, so each pair has two independent paths. +//! let mut topology = Topology::new(3); +//! topology.add_bidirectional_edge(0, 1, 1.0).expect("the nodes exist"); +//! topology.add_bidirectional_edge(1, 2, 1.0).expect("the nodes exist"); +//! topology.add_bidirectional_edge(2, 0, 1.0).expect("the nodes exist"); +//! +//! let mesh = Mesh::new(topology); +//! assert!(mesh.is_connected()); +//! assert_eq!(mesh.edge_connectivity(0, 1), Some(2)); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod connectivity; +mod mesh; + +pub use connectivity::{edge_connectivity, reachable_from, strongly_connected_components}; +pub use mesh::{CriticalLink, Mesh, MeshSummary}; + +pub use apsis_topology::{Topology, TopologySeries}; diff --git a/crates/network/apsis-mesh/src/mesh.rs b/crates/network/apsis-mesh/src/mesh.rs new file mode 100644 index 0000000..ac3dfc3 --- /dev/null +++ b/crates/network/apsis-mesh/src/mesh.rs @@ -0,0 +1,198 @@ +//! The mesh type and the summary of its redundancy. + +use crate::connectivity::{edge_connectivity, reachable_from, strongly_connected_components}; +use apsis_topology::Topology; + +/// A link whose removal would change the connectivity of the mesh. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CriticalLink { + /// The node the link leaves. + pub from: usize, + /// The node the link arrives at. + pub to: usize, + /// The cost of the link. + pub cost: f64, + /// The number of components the mesh has after the link is removed. + pub components_without: usize, +} + +impl CriticalLink { + /// Creates a critical link record. + pub const fn new(from: usize, to: usize, cost: f64, components_without: usize) -> Self { + Self { + from, + to, + cost, + components_without, + } + } +} + +/// A summary of the connectivity of a mesh. +#[derive(Debug, Clone, PartialEq)] +pub struct MeshSummary { + /// The number of terminals. + pub node_count: usize, + /// The number of directed links. + pub edge_count: usize, + /// The number of components when the links are treated as undirected. + pub components: usize, + /// The number of strongly connected components. + pub strongly_connected_components: usize, + /// The smallest number of independent paths between any two distinct + /// terminals, or `None` when there are fewer than two terminals. + /// + /// This is the worst connection in the mesh. A value of one means some + /// pair of terminals relies on a single link and the mesh has no + /// redundancy anywhere. + pub minimum_connectivity: Option, +} + +/// A mesh of terminals joined by links. +/// +/// The mesh wraps a topology and adds the connectivity measures a mesh +/// application needs. It does not own a schedule or a traffic model; those +/// belong to the caller. +#[derive(Debug, Clone, PartialEq)] +pub struct Mesh { + topology: Topology, +} + +impl Mesh { + /// Creates a mesh over a topology. + pub const fn new(topology: Topology) -> Self { + Self { topology } + } + + /// Returns the underlying topology. + pub const fn topology(&self) -> &Topology { + &self.topology + } + + /// Returns the number of terminals. + pub fn node_count(&self) -> usize { + self.topology.node_count() + } + + /// Returns the number of links. + pub fn edge_count(&self) -> usize { + self.topology.edge_count() + } + + /// Returns `true` when every terminal reaches every other, treating the + /// links as undirected. + pub fn is_connected(&self) -> bool { + self.topology.is_connected() + } + + /// Returns the terminals reachable from a terminal. + pub fn reachable_from(&self, source: usize) -> Vec { + reachable_from(&self.topology, source) + } + + /// Returns the number of independent paths between two terminals. + /// + /// Returns `None` when either terminal is out of range or the two are + /// the same. + pub fn edge_connectivity(&self, source: usize, destination: usize) -> Option { + edge_connectivity(&self.topology, source, destination) + } + + /// Returns the smallest number of independent paths between any two + /// distinct terminals. + /// + /// Returns `None` when there are fewer than two terminals. + pub fn minimum_connectivity(&self) -> Option { + let node_count = self.topology.node_count(); + if node_count < 2 { + return None; + } + let mut minimum = usize::MAX; + for source in 0..node_count { + for destination in (source + 1)..node_count { + let value = edge_connectivity(&self.topology, source, destination)?; + minimum = minimum.min(value); + } + } + Some(minimum) + } + + /// Returns the links whose removal would increase the number of + /// connected components, treating each link as undirected. + /// + /// These are the bridges of the undirected graph underlying the mesh. A + /// link is reported once, even when it is present in both directions, + /// because a failure takes out the whole link rather than one direction + /// of it. + /// + /// Each candidate is found by removing the link in both directions and + /// comparing the component count, which is exact but costs one traversal + /// per link. For the size of graph a satellite mesh presents that is + /// acceptable, and it is easier to verify than a linear time bridge + /// finding algorithm. + /// + /// Links that are not bridges are omitted, so an empty result means + /// every link lies on a cycle and the mesh survives any single link + /// failure without splitting. + pub fn critical_links(&self) -> Vec { + let baseline = self.topology.connected_components(); + let mut critical = Vec::new(); + let mut seen: Vec<(usize, usize)> = Vec::new(); + + for edge in self.topology.edges() { + // Canonicalize the pair so that the two directions of one link + // are considered together and reported once. + let pair = if edge.from <= edge.to { + (edge.from, edge.to) + } else { + (edge.to, edge.from) + }; + if seen.contains(&pair) { + continue; + } + seen.push(pair); + + // Remove the link in every direction in which it is present, + // which is what a link failure does. + let mut without = self.topology.clone(); + let _ = without.remove_edge(pair.0, pair.1); + let _ = without.remove_edge(pair.1, pair.0); + + let components = without.connected_components(); + if components > baseline { + critical.push(CriticalLink::new(edge.from, edge.to, edge.cost, components)); + } + } + critical + } + + /// Returns `true` when the mesh survives the failure of any single link + /// without splitting. + /// + /// This is the condition for the mesh to have a single link failure + /// tolerance, which is the minimum redundancy a network carrying traffic + /// without interruption should have. + pub fn survives_single_link_failure(&self) -> bool { + self.critical_links().is_empty() + } + + /// Returns the number of strongly connected components. + /// + /// For a mesh of directed links this counts the pieces that can route + /// internally. A value above one means some terminals cannot be reached + /// from some others. + pub fn strongly_connected_components(&self) -> usize { + strongly_connected_components(&self.topology) + } + + /// Returns a summary of the mesh connectivity. + pub fn summarise(&self) -> MeshSummary { + MeshSummary { + node_count: self.topology.node_count(), + edge_count: self.topology.edge_count(), + components: self.topology.connected_components(), + strongly_connected_components: strongly_connected_components(&self.topology), + minimum_connectivity: self.minimum_connectivity(), + } + } +} diff --git a/crates/network/apsis-mesh/tests/connectivity.rs b/crates/network/apsis-mesh/tests/connectivity.rs new file mode 100644 index 0000000..9041056 --- /dev/null +++ b/crates/network/apsis-mesh/tests/connectivity.rs @@ -0,0 +1,300 @@ +//! Tests for mesh connectivity measures. +//! +//! The reference cases are small graphs whose connectivity is known by +//! inspection: a ring has two independent paths between any pair, a line has +//! one, and a fully connected graph has as many as there are parallel links. + +use apsis_mesh::{ + CriticalLink, Mesh, edge_connectivity, reachable_from, strongly_connected_components, +}; +use apsis_topology::Topology; + +/// A bidirectional ring of the given size. +fn ring(node_count: usize) -> Topology { + let mut topology = Topology::new(node_count); + for node in 0..node_count { + topology + .add_bidirectional_edge(node, (node + 1) % node_count, 1.0) + .expect("the nodes exist"); + } + topology +} + +/// A bidirectional line, which has no cycle. +fn line(node_count: usize) -> Topology { + let mut topology = Topology::new(node_count); + for node in 0..node_count.saturating_sub(1) { + topology + .add_bidirectional_edge(node, node + 1, 1.0) + .expect("the nodes exist"); + } + topology +} + +/// A complete bidirectional graph. +fn complete(node_count: usize) -> Topology { + let mut topology = Topology::new(node_count); + for first in 0..node_count { + for second in (first + 1)..node_count { + topology + .add_bidirectional_edge(first, second, 1.0) + .expect("the nodes exist"); + } + } + topology +} + +#[test] +fn reachability_follows_the_edge_direction() { + // A one way line: 0 reaches 1 and 2, but 2 reaches only itself. + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + + assert_eq!(reachable_from(&topology, 0), vec![0, 1, 2]); + assert_eq!(reachable_from(&topology, 1), vec![1, 2]); + assert_eq!(reachable_from(&topology, 2), vec![2]); +} + +#[test] +fn reachability_includes_the_source() { + let topology = Topology::new(3); + assert_eq!(reachable_from(&topology, 0), vec![0]); +} + +#[test] +fn reachability_of_an_out_of_range_node_is_empty() { + let topology = Topology::new(2); + assert!(reachable_from(&topology, 9).is_empty()); +} + +#[test] +fn a_ring_has_two_independent_paths() { + let topology = ring(4); + // Around a ring there are exactly two ways between any two nodes, and + // no more, because removing both directions of one link leaves a line. + assert_eq!(edge_connectivity(&topology, 0, 2), Some(2)); + assert_eq!(edge_connectivity(&topology, 0, 1), Some(2)); + assert_eq!(edge_connectivity(&topology, 1, 3), Some(2)); +} + +#[test] +fn a_line_has_one_independent_path() { + let topology = line(4); + // A line has a single route, so one link failure disconnects it. + assert_eq!(edge_connectivity(&topology, 0, 3), Some(1)); + assert_eq!(edge_connectivity(&topology, 0, 1), Some(1)); +} + +#[test] +fn a_complete_graph_has_the_full_degree() { + let topology = complete(4); + // With four nodes and every pair linked both ways, there are three edge + // disjoint paths between any two nodes: the direct link and the two + // through the other nodes. + assert_eq!(edge_connectivity(&topology, 0, 1), Some(3)); + assert_eq!(edge_connectivity(&topology, 2, 3), Some(3)); +} + +#[test] +fn a_disconnected_pair_has_no_path() { + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(2, 3, 1.0).expect("valid"); + assert_eq!(edge_connectivity(&topology, 0, 3), Some(0)); + assert_eq!(edge_connectivity(&topology, 0, 1), Some(1)); +} + +#[test] +fn connectivity_rejects_a_node_with_itself() { + let topology = ring(3); + assert_eq!(edge_connectivity(&topology, 0, 0), None); +} + +#[test] +fn connectivity_rejects_an_out_of_range_node() { + let topology = ring(3); + assert_eq!(edge_connectivity(&topology, 0, 9), None); + assert_eq!(edge_connectivity(&topology, 9, 0), None); +} + +#[test] +fn the_connectivity_of_a_parallel_pair_is_the_number_of_links() { + // Three parallel one way links between two nodes give three independent + // paths. + let mut topology = Topology::new(2); + topology.add_edge(0, 1, 1.0).expect("valid"); + // A second and third parallel link cannot be added as a duplicate, so + // they are represented by routing through intermediate nodes instead. + topology.add_edge(0, 1, 1.0).ok(); + assert_eq!(edge_connectivity(&topology, 0, 1), Some(1)); + + // A pair of two node disjoint paths: the direct link and one through an + // intermediate node. + let mut detour = Topology::new(3); + detour.add_edge(0, 1, 1.0).expect("valid"); + detour.add_edge(0, 2, 1.0).expect("valid"); + detour.add_edge(2, 1, 1.0).expect("valid"); + assert_eq!(edge_connectivity(&detour, 0, 1), Some(2)); +} + +#[test] +fn the_strongly_connected_components_of_a_bidirectional_ring_is_one() { + let topology = ring(4); + assert_eq!(strongly_connected_components(&topology), 1); +} + +#[test] +fn a_one_way_ring_is_strongly_connected() { + // A directed cycle can still reach every node from every node. + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + topology.add_edge(2, 0, 1.0).expect("valid"); + assert_eq!(strongly_connected_components(&topology), 1); +} + +#[test] +fn a_one_way_line_has_one_component_per_node() { + let mut topology = Topology::new(3); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 2, 1.0).expect("valid"); + // No node can be reached back from a later one, so every node is its + // own component. + assert_eq!(strongly_connected_components(&topology), 3); +} + +#[test] +fn two_one_way_cycles_are_two_components() { + let mut topology = Topology::new(4); + topology.add_edge(0, 1, 1.0).expect("valid"); + topology.add_edge(1, 0, 1.0).expect("valid"); + topology.add_edge(2, 3, 1.0).expect("valid"); + topology.add_edge(3, 2, 1.0).expect("valid"); + assert_eq!(strongly_connected_components(&topology), 2); +} + +#[test] +fn an_empty_topology_has_no_components() { + assert_eq!(strongly_connected_components(&Topology::new(0)), 0); +} + +#[test] +fn isolated_nodes_are_each_a_component() { + let topology = Topology::new(3); + assert_eq!(strongly_connected_components(&topology), 3); +} + +#[test] +fn the_mesh_wraps_a_topology() { + let mesh = Mesh::new(ring(4)); + assert_eq!(mesh.node_count(), 4); + assert_eq!(mesh.edge_count(), 8); + assert!(mesh.is_connected()); + assert_eq!(mesh.edge_connectivity(0, 2), Some(2)); + assert_eq!(mesh.strongly_connected_components(), 1); + assert_eq!(mesh.reachable_from(0), vec![0, 1, 2, 3]); + assert_eq!(mesh.topology().node_count(), 4); +} + +#[test] +fn a_line_mesh_has_no_redundancy() { + let mesh = Mesh::new(line(4)); + assert_eq!(mesh.minimum_connectivity(), Some(1)); + assert!(!mesh.survives_single_link_failure()); +} + +#[test] +fn a_ring_mesh_survives_a_single_link_failure() { + let mesh = Mesh::new(ring(4)); + assert_eq!(mesh.minimum_connectivity(), Some(2)); + assert!(mesh.survives_single_link_failure()); + assert!(mesh.critical_links().is_empty()); +} + +#[test] +fn the_critical_links_of_a_line_are_every_link() { + let mesh = Mesh::new(line(3)); + let critical = mesh.critical_links(); + // A three node line has two links, and each is a bridge. A link is + // reported once even though it is present in both directions, because a + // failure takes out the whole link. + assert_eq!(critical.len(), 2); + for link in &critical { + assert_eq!(link.components_without, 2); + } + // The record carries the endpoints and the cost. + let first = critical[0]; + assert_eq!(first.from, 0); + assert_eq!(first.to, 1); + assert!((first.cost - 1.0).abs() < 1e-12); +} + +#[test] +fn a_complete_mesh_has_no_critical_links() { + let mesh = Mesh::new(complete(4)); + assert!(mesh.critical_links().is_empty()); + assert!(mesh.survives_single_link_failure()); + assert_eq!(mesh.minimum_connectivity(), Some(3)); +} + +#[test] +fn minimum_connectivity_needs_two_terminals() { + assert_eq!(Mesh::new(Topology::new(1)).minimum_connectivity(), None); + assert_eq!(Mesh::new(Topology::new(0)).minimum_connectivity(), None); +} + +#[test] +fn the_summary_reports_the_mesh_shape() { + let mesh = Mesh::new(ring(4)); + let summary = mesh.summarise(); + assert_eq!(summary.node_count, 4); + assert_eq!(summary.edge_count, 8); + assert_eq!(summary.components, 1); + assert_eq!(summary.strongly_connected_components, 1); + assert_eq!(summary.minimum_connectivity, Some(2)); +} + +#[test] +fn the_summary_of_a_split_mesh_reports_the_pieces() { + // Two rings joined by nothing. + let mut topology = Topology::new(6); + for node in 0..3 { + topology + .add_bidirectional_edge(node, (node + 1) % 3, 1.0) + .expect("valid"); + } + for node in 3..6 { + topology + .add_bidirectional_edge(node, 3 + (node + 1) % 3, 1.0) + .expect("valid"); + } + let mesh = Mesh::new(topology); + let summary = mesh.summarise(); + assert_eq!(summary.components, 2); + assert_eq!(summary.strongly_connected_components, 2); + // The pair across the split has no path, so the minimum is zero. + assert_eq!(summary.minimum_connectivity, Some(0)); + assert!(!mesh.is_connected()); +} + +#[test] +fn a_critical_link_type_is_constructible_directly() { + let link = CriticalLink::new(1, 2, 3.5, 2); + assert_eq!(link.from, 1); + assert_eq!(link.to, 2); + assert!((link.cost - 3.5).abs() < 1e-12); + assert_eq!(link.components_without, 2); +} + +#[test] +fn a_satellite_like_mesh_has_the_expected_connectivity() { + // A grid-like mesh with each node linked to two neighbours in a ring + // plus one cross link, which has connectivity two everywhere. + let mut topology = ring(6); + topology.add_bidirectional_edge(0, 3, 2.0).expect("valid"); + let mesh = Mesh::new(topology); + assert!(mesh.is_connected()); + assert!(mesh.survives_single_link_failure()); + assert_eq!(mesh.minimum_connectivity(), Some(2)); +}