open_hypergraphs/lax/cycle.rs
1//! Fast cycle-breaking heuristics for lax open hypergraphs.
2
3use super::{NodeId, OpenHypergraph};
4use std::cmp::Reverse;
5use std::collections::{BinaryHeap, VecDeque};
6
7/// Choose nodes whose spiderization is guaranteed to break every directed
8/// cycle.
9///
10/// This computes a greedy directed feedback vertex set on the bipartite
11/// incidence graph
12///
13/// ```text
14/// node -> operation -> node.
15/// ```
16///
17/// Basic idea:
18///
19/// - Construct a bipartite node-operation incidence graph.
20/// - Vertices with indegree zero or outdegree zero cannot participate in a cycle.
21/// - Repeatedly peel those vertices until no more acyclic fringe can be removed.
22/// - Select the remaining wire node with the largest `indegree * outdegree`
23/// score, remove it, and repeat.
24///
25/// Selecting high-degree nodes in this way tends to break several cycles at once.
26///
27/// The result is deterministic and is guaranteed to make
28/// `f.clone().spiderize_nodes(&result)` acyclic. It is a heuristic: the
29/// returned set need not have minimum size.
30///
31/// Returns [`None`] if `f` has pending node identifications. Quotienting can
32/// create cycles, so cycle breaking must operate on the semantic, quotiented
33/// graph. When successful, the returned IDs refer directly to `f`.
34#[must_use]
35pub fn cycle_breaking_nodes<O, A>(f: &OpenHypergraph<O, A>) -> Option<Vec<NodeId>> {
36 if !f.hypergraph.is_strict() {
37 return None;
38 }
39
40 assert_eq!(
41 f.hypergraph.edges.len(),
42 f.hypergraph.adjacency.len(),
43 "malformed hypergraph: edges and adjacency lengths differ"
44 );
45
46 let node_count = f.hypergraph.nodes.len();
47
48 // Allocate a bipartite incidence graph.
49 // Nodes occupy 0..node_count, followed by one vertex for each operation.
50 // Keeping operation vertices avoids expanding an m -> n hyperedge into m*n arcs.
51 let vertex_count = node_count + f.hypergraph.adjacency.len();
52 let mut outgoing = vec![Vec::new(); vertex_count];
53 let mut incoming = vec![Vec::new(); vertex_count];
54
55 // Populate the incidence graph.
56 // Preserve the hypergraph's direction: every source node points to its
57 // operation, and that operation points to each target node.
58 for (operation, adjacency) in f.hypergraph.adjacency.iter().enumerate() {
59 let operation = node_count + operation;
60
61 for source in &adjacency.sources {
62 add_arc(source.0, operation, &mut outgoing, &mut incoming);
63 }
64 for target in &adjacency.targets {
65 add_arc(operation, target.0, &mut outgoing, &mut incoming);
66 }
67 }
68
69 // Only node vertices are selectable; operation vertices may be peeled but
70 // never returned.
71 let selected = greedy_feedback_nodes(node_count, &outgoing, &incoming);
72
73 Some(selected.into_iter().map(NodeId).collect())
74}
75
76/// Add one directed arc to both forward and reverse adjacency lists.
77///
78/// Keeping both directions lets vertex removal update neighboring in- and
79/// out-degrees without searching the whole graph.
80fn add_arc(source: usize, target: usize, outgoing: &mut [Vec<usize>], incoming: &mut [Vec<usize>]) {
81 outgoing[source].push(target);
82 incoming[target].push(source);
83}
84
85////////////////////////////////////////////////////////////////////////////////
86// Cycle breaking logic
87
88/// Greedily select wire vertices that hit every directed cycle.
89///
90/// Vertices `0..selectable_count` are wire nodes and may be selected; the
91/// remaining vertices are operations. Sources and sinks are peeled, then the
92/// highest-scoring wire vertex is selected whenever cyclic structure remains.
93///
94/// Runs in roughly `O((vertices + arcs) log(selectable vertices))` time.
95fn greedy_feedback_nodes(
96 selectable_count: usize,
97 outgoing: &[Vec<usize>],
98 incoming: &[Vec<usize>],
99) -> Vec<usize> {
100 debug_assert_eq!(outgoing.len(), incoming.len());
101
102 // Degrees and `active` describe the current residual graph. Every vertex
103 // is removed exactly once, either freely or as a selected cycle breaker.
104 let vertex_count = outgoing.len();
105 let mut active = vec![true; vertex_count];
106 let mut active_count = vertex_count;
107 let mut indegree: Vec<usize> = incoming.iter().map(Vec::len).collect();
108 let mut outdegree: Vec<usize> = outgoing.iter().map(Vec::len).collect();
109 let mut peel = VecDeque::new();
110 let mut candidates = BinaryHeap::new();
111 let mut selected = Vec::new();
112
113 // Seed both worklists: immediately peelable vertices go in the FIFO queue,
114 // while selectable vertices with two-sided connectivity go in the heap.
115 for vertex in 0..vertex_count {
116 if indegree[vertex] == 0 || outdegree[vertex] == 0 {
117 peel.push_back(vertex);
118 }
119 push_candidate(
120 vertex,
121 selectable_count,
122 &active,
123 &indegree,
124 &outdegree,
125 &mut candidates,
126 );
127 }
128
129 while active_count > 0 {
130 // Exhaust all consequences of previous removals before choosing
131 // another feedback node. This prevents selecting vertices already
132 // proven not to participate in the residual cycles.
133 while let Some(vertex) = peel.pop_front() {
134 if !active[vertex] || (indegree[vertex] > 0 && outdegree[vertex] > 0) {
135 continue;
136 }
137 remove_vertex(
138 vertex,
139 selectable_count,
140 outgoing,
141 incoming,
142 &mut active,
143 &mut indegree,
144 &mut outdegree,
145 &mut peel,
146 &mut candidates,
147 );
148 active_count -= 1;
149 }
150
151 if active_count == 0 {
152 break;
153 }
154
155 // Peeling got stuck, so the residual graph is cyclic. Pop until the
156 // degree snapshot agrees with current state, skipping lazy stale
157 // entries, then choose the best-scoring node.
158 let vertex = loop {
159 let (_, Reverse(vertex), candidate_indegree, candidate_outdegree) = candidates
160 .pop()
161 .expect("cyclic residual incidence graph must contain a selectable node");
162 if active[vertex]
163 && indegree[vertex] == candidate_indegree
164 && outdegree[vertex] == candidate_outdegree
165 {
166 break vertex;
167 }
168 };
169
170 // This is the only non-free removal: record it for spiderization.
171 // Removing it may expose a large acyclic fringe for the next peel.
172 selected.push(vertex);
173 remove_vertex(
174 vertex,
175 selectable_count,
176 outgoing,
177 incoming,
178 &mut active,
179 &mut indegree,
180 &mut outdegree,
181 &mut peel,
182 &mut candidates,
183 );
184 active_count -= 1;
185 }
186
187 // Heap choice order is an implementation detail; node order is a more
188 // stable and convenient public result.
189 selected.sort_unstable();
190 selected
191}
192
193/// Estimate how much cyclic connectivity removing a node will disrupt.
194///
195/// A node with many incoming and outgoing arcs joins many possible paths. The
196/// saturating product avoids overflow for unusually large incidence graphs.
197fn score(indegree: usize, outdegree: usize) -> usize {
198 indegree.saturating_mul(outdegree)
199}
200
201/// A lazily validated heap entry.
202///
203/// Entries contain `(score, node, indegree, outdegree)`. [`Reverse`] makes the
204/// lower node index win deterministic ties in the max-heap. The degree
205/// snapshots let us recognize entries made stale by later removals.
206type Candidate = (usize, Reverse<usize>, usize, usize);
207
208/// Add a selectable vertex's current state to the candidate heap.
209///
210/// The heap is deliberately lazy: degree changes push new entries rather than
211/// locating and updating old ones. Stale entries are discarded when popped.
212fn push_candidate(
213 node: usize,
214 selectable_count: usize,
215 active: &[bool],
216 indegree: &[usize],
217 outdegree: &[usize],
218 candidates: &mut BinaryHeap<Candidate>,
219) {
220 if node < selectable_count && active[node] && indegree[node] > 0 && outdegree[node] > 0 {
221 candidates.push((
222 score(indegree[node], outdegree[node]),
223 Reverse(node),
224 indegree[node],
225 outdegree[node],
226 ));
227 }
228}
229
230/// Remove a vertex from the active graph and update its neighbors.
231///
232/// Removing outgoing arcs lowers target in-degrees; removing incoming arcs
233/// lowers source out-degrees. Neighbors that become sources or sinks enter the
234/// free peeling queue, while still-cyclic node vertices receive refreshed heap
235/// entries.
236#[allow(clippy::too_many_arguments)]
237fn remove_vertex(
238 vertex: usize,
239 selectable_count: usize,
240 outgoing: &[Vec<usize>],
241 incoming: &[Vec<usize>],
242 active: &mut [bool],
243 indegree: &mut [usize],
244 outdegree: &mut [usize],
245 peel: &mut VecDeque<usize>,
246 candidates: &mut BinaryHeap<Candidate>,
247) {
248 // Mark first so a self-arc, if one is ever supplied, cannot update the
249 // removed vertex's own degree.
250 active[vertex] = false;
251
252 // Delete vertex -> target arcs.
253 for &target in &outgoing[vertex] {
254 if active[target] {
255 indegree[target] -= 1;
256 if indegree[target] == 0 || outdegree[target] == 0 {
257 peel.push_back(target);
258 }
259 push_candidate(
260 target,
261 selectable_count,
262 active,
263 indegree,
264 outdegree,
265 candidates,
266 );
267 }
268 }
269
270 // Delete source -> vertex arcs.
271 for &source in &incoming[vertex] {
272 if active[source] {
273 outdegree[source] -= 1;
274 if indegree[source] == 0 || outdegree[source] == 0 {
275 peel.push_back(source);
276 }
277 push_candidate(
278 source,
279 selectable_count,
280 active,
281 indegree,
282 outdegree,
283 candidates,
284 );
285 }
286 }
287}