From 0232790b4e0c0349d47596c964b577baff7991b6 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Wed, 30 Sep 2026 18:29:18 +0800 Subject: [PATCH] topology: index the outgoing edges Asking whether an edge already exists scanned the whole edge list, so building a graph of E edges cost O(E^2). A visibility sweep adds a quadratic number of edges, so the whole construction was cubic: 600 satellites took twelve seconds. An index of outgoing edge positions makes the membership test constant time, and the same index answers neighbours and out-degree without a scan. Removal rebuilds the index in one pass, since it renumbers every later position. Six hundred satellites now take 130 ms, a factor of ninety five. --- crates/network/apsis-topology/src/graph.rs | 76 ++++++++++++++++------ 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/crates/network/apsis-topology/src/graph.rs b/crates/network/apsis-topology/src/graph.rs index cda1014..343e922 100644 --- a/crates/network/apsis-topology/src/graph.rs +++ b/crates/network/apsis-topology/src/graph.rs @@ -48,14 +48,30 @@ impl Edge { pub struct Topology { node_count: usize, edges: Vec, + /// The positions in `edges` of the edges leaving each node. + /// + /// The index is what makes membership a constant time test. Without it, + /// asking whether an edge already exists scans the whole edge list, and + /// building a graph of `E` edges costs `O(E^2)`, which turns the + /// visibility sweep of a constellation, quadratic in the number of + /// satellites, into cubic work. The index holds positions rather than + /// destinations so that `remove_edge`, which invalidates positions, can + /// repair it in one pass. + outgoing: Vec>, } impl Topology { /// Creates an empty topology with the given number of nodes. - pub const fn new(node_count: usize) -> Self { + /// + /// This is not a `const fn` because it sizes the adjacency index, which + /// allocates. + pub fn new(node_count: usize) -> Self { + let mut outgoing = Vec::new(); + outgoing.resize_with(node_count, Vec::new); Self { node_count, edges: Vec::new(), + outgoing, } } @@ -98,6 +114,7 @@ impl Topology { return Err(TopologyError::new(TopologyErrorKind::DuplicateEdge) .with_detail(format!("edge {from} to {to}"))); } + self.outgoing[from].push(self.edges.len()); self.edges.push(Edge::new(from, to, cost)); Ok(()) } @@ -116,8 +133,7 @@ impl Topology { self.add_edge(first, second, cost)?; if let Err(error) = self.add_edge(second, first, cost) { // Remove the first direction so the topology is unchanged. - self.edges - .retain(|edge| !(edge.from == first && edge.to == second)); + self.remove_edge_unchecked(first, second); return Err(error); } Ok(()) @@ -125,50 +141,72 @@ impl Topology { /// Returns `true` when an edge exists from one node to another. pub fn has_edge(&self, from: usize, to: usize) -> bool { - self.edges - .iter() - .any(|edge| edge.from == from && edge.to == to) + self.outgoing.get(from).is_some_and(|positions| { + positions + .iter() + .any(|&position| self.edges[position].to == to) + }) } /// Returns the cost of the edge between two nodes. /// /// Returns `None` when there is no such edge. pub fn cost(&self, from: usize, to: usize) -> Option { - self.edges + let positions = self.outgoing.get(from)?; + positions .iter() - .find(|edge| edge.from == from && edge.to == to) - .map(|edge| edge.cost) + .find(|&&position| self.edges[position].to == to) + .map(|&position| self.edges[position].cost) } /// Removes the edge between two nodes. /// /// Returns an error when there is no such edge. pub fn remove_edge(&mut self, from: usize, to: usize) -> Result<(), TopologyError> { - let before = self.edges.len(); - self.edges - .retain(|edge| !(edge.from == from && edge.to == to)); - if self.edges.len() == before { + if !self.has_edge(from, to) { return Err(TopologyError::new(TopologyErrorKind::NoSuchEdge) .with_detail(format!("edge {from} to {to}"))); } + self.remove_edge_unchecked(from, to); Ok(()) } + /// Removes an edge the caller has already established exists. + /// + /// Removing an edge renumbers every position after it, so the index is + /// rebuilt from the surviving edges in one pass. Removal is rarer than + /// insertion, so paying for a rebuild here is cheaper than keeping the + /// index correct incrementally. + fn remove_edge_unchecked(&mut self, from: usize, to: usize) { + self.edges + .retain(|edge| !(edge.from == from && edge.to == to)); + self.rebuild_index(); + } + + /// Rebuilds the adjacency index from the current edge list. + fn rebuild_index(&mut self) { + for positions in &mut self.outgoing { + positions.clear(); + } + for (position, edge) in self.edges.iter().enumerate() { + self.outgoing[edge.from].push(position); + } + } + /// Returns the indices of the nodes reachable from a node in one hop. /// /// The result is in the order the edges were added, so it is /// deterministic for a given construction sequence. pub fn neighbours(&self, node: usize) -> Vec { - self.edges - .iter() - .filter(|edge| edge.from == node) - .map(|edge| edge.to) - .collect() + self.outgoing + .get(node) + .map(|positions| positions.iter().map(|&p| self.edges[p].to).collect()) + .unwrap_or_default() } /// Returns the number of edges leaving a node. pub fn out_degree(&self, node: usize) -> usize { - self.edges.iter().filter(|edge| edge.from == node).count() + self.outgoing.get(node).map_or(0, Vec::len) } /// Returns the number of edges arriving at a node.