Skip to main content

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}