Skip to content

Commit d746aff

Browse files
authored
Merge pull request #34 from hellas-ai/feat/spiderization
Implement "spiderization", an algorithm to recover the frobenius decomposition of an open hypergraph, and a helper module for *cycle breaking*: identifying a small (but not necessarily minimal) subset of nodes which, when spiderized, will yield a monogamous acyclic diagram. Also bumps version to `0.3.2`
2 parents 0164043 + bfaee85 commit d746aff

7 files changed

Lines changed: 801 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "open-hypergraphs"
3-
version = "0.3.1"
3+
version = "0.3.2"
44
edition = "2021"
55
description = "Data-Parallel Algorithms for Open Hypergraphs"
66
license = "MIT OR Apache-2.0"

src/lax/cycle.rs

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
//! Fast cycle-breaking heuristics for lax open hypergraphs.
2+
3+
use super::{NodeId, OpenHypergraph};
4+
use std::cmp::Reverse;
5+
use 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]
35+
pub 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.
80+
fn 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.
95+
fn 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.
197+
fn 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.
206+
type 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.
212+
fn 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)]
237+
fn 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+
}

src/lax/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,18 @@
7272
//! connected nodes, e.g., x0 and y0. this allows both *checking* (of e.g. equality) and
7373
//! *inference*: inequal types might be *unified* into a single type.
7474
pub mod category;
75+
pub mod cycle;
7576
pub mod functor;
7677
pub mod hypergraph;
7778
pub mod mut_category;
7879
pub mod open_hypergraph;
80+
pub mod spider;
7981

8082
pub use crate::category::*;
83+
pub use cycle::*;
8184
pub use hypergraph::*;
8285
pub use open_hypergraph::*;
86+
pub use spider::*;
8387

8488
pub mod optic;
8589
pub mod var;

0 commit comments

Comments
 (0)