network: add mesh connectivity and critical link analysis

This commit is contained in:
ApsisRS contributors 2026-09-26 16:06:59 +08:00
parent 0bc127277f
commit eb8ef3fa3b
6 changed files with 739 additions and 0 deletions

5
Cargo.lock generated
View File

@ -160,6 +160,11 @@ version = "0.1.0"
[[package]] [[package]]
name = "apsis-mesh" name = "apsis-mesh"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-math",
"apsis-topology",
]
[[package]] [[package]]
name = "apsis-noise" name = "apsis-noise"

View File

@ -11,3 +11,6 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-math.workspace = true
apsis-topology.workspace = true

View File

@ -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<usize> {
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<usize> {
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<usize> = 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<Option<usize>> = 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
}

View File

@ -1 +1,51 @@
//! Mesh connectivity abstractions. //! 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};

View File

@ -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<usize>,
}
/// 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<usize> {
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<usize> {
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<usize> {
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<CriticalLink> {
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(),
}
}
}

View File

@ -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));
}