Skip to main content

rust_igraph/algorithms/flow/
all_st_cuts.rs

1//! `all_st_cuts` (ALGO-FL-031) — list every (s,t) edge cut of a directed
2//! graph.
3//!
4//! Counterpart of `igraph_all_st_cuts` from
5//! `references/igraph/src/flow/st-cuts.c:1030`.
6//!
7//! Implements the Provan-Shier paradigm for listing (s,t)-cuts
8//! (J. S. Provan and D. R. Shier, *A paradigm for listing (s,t)-cuts in
9//! graphs*, Algorithmica 15:351–372, 1996). Every distinct edge cut
10//! between `source` and `target` is enumerated exactly once.
11//!
12//! The enumeration walks a binary search tree over *closed* vertex sets
13//! `S` (the source side of a cut). At each node a **pivot** element `v`
14//! and its *closure increment* `I(S,v)` are computed using the dominator
15//! tree of the residual `Sbar = V \ S` subgraph (rooted at `target`,
16//! post-dominator orientation). Recursing "right" adds `I(S,v)` to `S`;
17//! recursing "left" forbids `v` (pushes it on `T`). A leaf with a proper,
18//! non-trivial `S` yields one cut.
19//!
20//! Only the OUT-neighbour adjacency of the original graph is needed; the
21//! dominator computation and induced-subgraph mapping are delegated to
22//! the existing [`dominator_tree`] and [`induced_subgraph`] AWUs.
23
24// Signed sentinels from `dominator_tree` (`idom`: -1 root, -2 unreachable)
25// are walked as `i32`; the induced-subgraph maps use `u32::MAX` as the
26// "absent" sentinel. All casts are bounded by `vcount` which the dominator
27// AWU already constrains to `i32::MAX`.
28#![allow(
29    clippy::cast_possible_truncation,
30    clippy::cast_sign_loss,
31    clippy::cast_possible_wrap
32)]
33
34use crate::algorithms::flow::dominator_tree::{DominatorMode, DominatorTree, dominator_tree};
35use crate::algorithms::flow::provan_shier::{EStack, MarkedQueue, Pivot, provan_shier_list};
36use crate::algorithms::operators::induced_subgraph::{InducedSubgraphResult, induced_subgraph};
37use crate::core::graph::EdgeId;
38use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
39
40/// Result of [`all_st_cuts`].
41///
42/// `cuts[i]` and `partition1s[i]` describe the same cut: `partition1s[i]`
43/// is the source-side vertex set `X` (always contains `source`, never
44/// `target`), and `cuts[i]` lists the ids of the edges leaving `X`
45/// (`from ∈ X`, `to ∉ X`). Vertices within each partition and edge ids
46/// within each cut are sorted ascending.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct StCuts {
49    /// Edge-id list of each cut, sorted ascending.
50    pub cuts: Vec<Vec<EdgeId>>,
51    /// Source-side vertex set generating each cut, sorted ascending.
52    pub partition1s: Vec<Vec<VertexId>>,
53}
54
55/// List all (s,t) edge cuts of a directed graph (Provan-Shier).
56///
57/// # Arguments
58///
59/// * `graph`  — directed input graph. Undirected graphs are rejected.
60/// * `source` — source vertex id.
61/// * `target` — target vertex id (must differ from `source`).
62///
63/// # Errors
64///
65/// * [`IgraphError::InvalidArgument`] when `graph` is undirected or when
66///   `source == target`.
67/// * [`IgraphError::VertexOutOfRange`] when `source` or `target` is not a
68///   valid vertex id.
69///
70/// [`IgraphError::InvalidArgument`]: crate::core::IgraphError::InvalidArgument
71/// [`IgraphError::VertexOutOfRange`]: crate::core::IgraphError::VertexOutOfRange
72///
73/// # Examples
74///
75/// ```
76/// use rust_igraph::all_st_cuts;
77/// use rust_igraph::Graph;
78///
79/// // Two parallel routes 0 ⇒ 2: 0→1→2 and 0→2 (here a single path 0→1→2).
80/// let mut g = Graph::new(3, true).unwrap();
81/// g.add_edge(0, 1).unwrap();
82/// g.add_edge(1, 2).unwrap();
83/// let res = all_st_cuts(&g, 0, 2).unwrap();
84/// // Two cuts: {0} (edge 0) and {0,1} (edge 1).
85/// assert_eq!(res.partition1s, vec![vec![0], vec![0, 1]]);
86/// assert_eq!(res.cuts, vec![vec![0], vec![1]]);
87/// ```
88pub fn all_st_cuts(graph: &Graph, source: VertexId, target: VertexId) -> IgraphResult<StCuts> {
89    let n = graph.vcount();
90
91    if !graph.is_directed() {
92        return Err(IgraphError::InvalidArgument(
93            "all_st_cuts: listing all s-t cuts is only implemented for directed graphs".to_string(),
94        ));
95    }
96    if source >= n {
97        return Err(IgraphError::VertexOutOfRange { id: source, n });
98    }
99    if target >= n {
100        return Err(IgraphError::VertexOutOfRange { id: target, n });
101    }
102    if source == target {
103        return Err(IgraphError::InvalidArgument(
104            "all_st_cuts: source and target must differ".to_string(),
105        ));
106    }
107
108    let n_us = n as usize;
109    let mut pivot_fn = AllCutsPivot { graph };
110    let mut partitions = provan_shier_list(n_us, source, target, &mut pivot_fn)?;
111
112    // `provan_shier_list` already applies igraph's post-reversal. We
113    // canonicalise each partition (sorted) and derive the matching edge cut.
114    let mut cuts: Vec<Vec<EdgeId>> = Vec::with_capacity(partitions.len());
115    let mut in_s = vec![false; n_us];
116    for part in &mut partitions {
117        part.sort_unstable();
118        for &v in part.iter() {
119            in_s[v as usize] = true;
120        }
121        let mut cut: Vec<EdgeId> = Vec::new();
122        for e in 0..graph.ecount() {
123            let (from, to) = graph.edge(e as EdgeId)?;
124            if in_s[from as usize] && !in_s[to as usize] {
125                cut.push(e as EdgeId);
126            }
127        }
128        cut.sort_unstable();
129        cuts.push(cut);
130        for &v in part.iter() {
131            in_s[v as usize] = false;
132        }
133    }
134
135    Ok(StCuts {
136        cuts,
137        partition1s: partitions,
138    })
139}
140
141/// Pivot strategy for listing **all** (s,t) cuts: dominator-tree based,
142/// stateless apart from the input graph (`igraph_i_all_st_cuts_pivot`).
143struct AllCutsPivot<'a> {
144    graph: &'a Graph,
145}
146
147impl Pivot for AllCutsPivot<'_> {
148    fn pivot(
149        &mut self,
150        s: &MarkedQueue,
151        t: &EStack,
152        source: VertexId,
153        target: VertexId,
154    ) -> IgraphResult<(VertexId, Vec<VertexId>)> {
155        pivot(self.graph, s, t, source, target)
156    }
157}
158
159/// The pivot function (`igraph_i_all_st_cuts_pivot`).
160///
161/// Returns `(v, I(S,v))`. When `I(S,v)` is empty `v` is meaningless and the
162/// caller treats `S` as a leaf.
163// `s`/`t`/`n`/`m`/`v` mirror the Provan-Shier paper notation (set S, set T,
164// |V|, minimal element set M, pivot v) and the igraph C source one-to-one.
165#[allow(clippy::many_single_char_names)]
166fn pivot(
167    graph: &Graph,
168    s: &MarkedQueue,
169    t: &EStack,
170    source: VertexId,
171    target: VertexId,
172) -> IgraphResult<(VertexId, Vec<VertexId>)> {
173    let n = graph.vcount() as usize;
174
175    // Sbar = V \ S, with old<->new id maps.
176    let mut keep: Vec<VertexId> = Vec::new();
177    for i in 0..n {
178        if !s.iselement(i as VertexId) {
179            keep.push(i as VertexId);
180        }
181    }
182    let sbar: InducedSubgraphResult = induced_subgraph(graph, &keep)?;
183    let root = sbar.map[target as usize];
184
185    // Post-dominator tree of Sbar rooted at target.
186    let dt = dominator_tree(&sbar.graph, root, DominatorMode::In)?;
187
188    // Gamma(S): the OUT-boundary of S (old ids). When S is empty the
189    // induced map is the identity and Gamma(S) = {source}.
190    let mut gamma_s = vec![false; n];
191    if s.size() == 0 {
192        gamma_s[source as usize] = true;
193    } else {
194        for i in 0..n {
195            if s.iselement(i as VertexId) {
196                for nei in graph.out_neighbors_vec(i as VertexId)? {
197                    if !s.iselement(nei) {
198                        gamma_s[nei as usize] = true;
199                    }
200                }
201            }
202        }
203    }
204
205    // K = vertices left out of the dominator tree (cannot reach target).
206    // Relabel to old ids and ensure Gamma(S) ⊆ L (the dominator-tree nodes).
207    let mut leftout_old: Vec<VertexId> = Vec::with_capacity(dt.leftout.len());
208    for &lo in &dt.leftout {
209        let old = sbar.invmap[lo as usize];
210        leftout_old.push(old);
211        gamma_s[old as usize] = false;
212    }
213
214    // M = minimal elements of Gamma(S) under the dominator relation.
215    let m = if dt.tree.ecount() > 0 {
216        minimal_gamma(&dt, &sbar, &gamma_s, n)
217    } else {
218        Vec::new()
219    };
220
221    if m.is_empty() {
222        return Ok((0, Vec::new()));
223    }
224
225    // Children adjacency of the dominator tree (new-id space).
226    let sbar_n = sbar.graph.vcount() as usize;
227    let mut children: Vec<Vec<u32>> = vec![Vec::new(); sbar_n];
228    for v in 0..sbar_n {
229        let p = dt.idom[v];
230        if p >= 0 {
231            children[p as usize].push(v as u32);
232        }
233    }
234
235    let gamma_s_vec: Vec<VertexId> = (0..n)
236        .filter(|&i| gamma_s[i])
237        .map(|i| i as VertexId)
238        .collect();
239
240    for &m_old in &m {
241        let min_new = sbar.map[m_old as usize];
242        // Nu(v) = subtree of the dominator tree rooted at v (old ids).
243        let mut nuv: Vec<VertexId> = dom_subtree(&children, min_new)
244            .into_iter()
245            .map(|x| sbar.invmap[x as usize])
246            .collect();
247
248        // I(S,v) − K: vertices of Nu(v) reachable from Gamma(S) within Nu(v).
249        let isv_min = restricted_bfs(graph, &gamma_s_vec, &nuv, n)?;
250
251        // Accept v if none of those vertices is forbidden (in T) or is the
252        // target.
253        let blocked = isv_min.iter().any(|&u| t.iselement(u) || u == target);
254        if !blocked {
255            // Real I(S,v): BFS from v restricted to Nu(v) ∪ K.
256            nuv.extend_from_slice(&leftout_old);
257            let isv = restricted_bfs(graph, &[m_old], &nuv, n)?;
258            return Ok((m_old, isv));
259        }
260    }
261
262    Ok((0, Vec::new()))
263}
264
265/// Minimal elements of `Gamma(S)` w.r.t. the dominator relation
266/// (`igraph_i_all_st_cuts_minimal`). A Gamma(S) vertex is minimal iff it
267/// has no proper Gamma(S) descendant in the dominator tree.
268fn minimal_gamma(
269    dt: &DominatorTree,
270    sbar: &InducedSubgraphResult,
271    gamma_s: &[bool],
272    n: usize,
273) -> Vec<VertexId> {
274    // nomark[i] = true ⇒ vertex i is not minimal. Start with every non-Γ
275    // vertex marked, then mark any Γ ancestor that dominates another Γ
276    // vertex.
277    let mut nomark: Vec<bool> = (0..n).map(|i| !gamma_s[i]).collect();
278
279    for g_old in 0..n {
280        if !gamma_s[g_old] {
281            continue;
282        }
283        let gn = sbar.map[g_old];
284        if gn == u32::MAX {
285            continue;
286        }
287        let mut cur = dt.idom[gn as usize];
288        while cur >= 0 {
289            let anc_old = sbar.invmap[cur as usize] as usize;
290            if gamma_s[anc_old] {
291                nomark[anc_old] = true;
292            }
293            cur = dt.idom[cur as usize];
294        }
295    }
296
297    (0..n)
298        .filter(|&i| !nomark[i])
299        .map(|i| i as VertexId)
300        .collect()
301}
302
303/// Vertices of the dominator-tree subtree rooted at `root` (new ids),
304/// including `root` itself.
305fn dom_subtree(children: &[Vec<u32>], root: u32) -> Vec<u32> {
306    let mut out = Vec::new();
307    let mut stack = vec![root];
308    while let Some(v) = stack.pop() {
309        out.push(v);
310        for &c in &children[v as usize] {
311            stack.push(c);
312        }
313    }
314    out
315}
316
317/// Multi-root OUT-direction BFS over `graph`, visiting only vertices in the
318/// `restricted` set (`igraph_bfs` with `roots` + `restricted`). Roots
319/// outside the restricted set are skipped. Returns the visit order.
320fn restricted_bfs(
321    graph: &Graph,
322    roots: &[VertexId],
323    restricted: &[VertexId],
324    n: usize,
325) -> IgraphResult<Vec<VertexId>> {
326    let mut allowed = vec![false; n];
327    for &r in restricted {
328        allowed[r as usize] = true;
329    }
330    let mut visited = vec![false; n];
331    let mut order: Vec<VertexId> = Vec::new();
332    let mut queue: std::collections::VecDeque<VertexId> = std::collections::VecDeque::new();
333
334    for &root in roots {
335        if !allowed[root as usize] || visited[root as usize] {
336            continue;
337        }
338        visited[root as usize] = true;
339        order.push(root);
340        queue.push_back(root);
341        while let Some(u) = queue.pop_front() {
342            for nei in graph.out_neighbors_vec(u)? {
343                if allowed[nei as usize] && !visited[nei as usize] {
344                    visited[nei as usize] = true;
345                    order.push(nei);
346                    queue.push_back(nei);
347                }
348            }
349        }
350    }
351
352    Ok(order)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    /// Build a directed graph from an explicit `(from, to)` edge list.
360    /// Edge ids follow insertion order.
361    fn build(n: u32, edges: &[(u32, u32)]) -> Graph {
362        let mut g = Graph::new(n, true).expect("directed graph");
363        for &(u, v) in edges {
364            g.add_edge(u, v).expect("add edge");
365        }
366        g
367    }
368
369    /// Can `target` still be reached from `source` after deleting the edge
370    /// ids in `cut`? Forward (OUT) BFS over the original graph minus the
371    /// cut edges.
372    fn target_reachable_minus_cut(
373        g: &Graph,
374        source: VertexId,
375        target: VertexId,
376        cut: &[EdgeId],
377    ) -> bool {
378        let n = g.vcount() as usize;
379        let removed: std::collections::HashSet<EdgeId> = cut.iter().copied().collect();
380        let mut adj: Vec<Vec<VertexId>> = vec![Vec::new(); n];
381        for e in 0..g.ecount() as EdgeId {
382            if removed.contains(&e) {
383                continue;
384            }
385            let (from, to) = g.edge(e).expect("edge");
386            adj[from as usize].push(to);
387        }
388        let mut visited = vec![false; n];
389        let mut stack = vec![source];
390        visited[source as usize] = true;
391        while let Some(u) = stack.pop() {
392            if u == target {
393                return true;
394            }
395            for &w in &adj[u as usize] {
396                if !visited[w as usize] {
397                    visited[w as usize] = true;
398                    stack.push(w);
399                }
400            }
401        }
402        false
403    }
404
405    /// Assert every structural invariant that `all_st_cuts` must satisfy
406    /// for a given `source`/`target`, independent of the exact cut list.
407    fn assert_cuts_consistent(g: &Graph, source: VertexId, target: VertexId, res: &StCuts) {
408        assert_eq!(
409            res.cuts.len(),
410            res.partition1s.len(),
411            "cuts and partition1s must have equal length"
412        );
413        let n = g.vcount() as usize;
414        let mut seen: std::collections::HashSet<Vec<VertexId>> = std::collections::HashSet::new();
415        for (part, cut) in res.partition1s.iter().zip(res.cuts.iter()) {
416            // Strict ascending order.
417            for w in part.windows(2) {
418                assert!(w[0] < w[1], "partition not strictly ascending: {part:?}");
419            }
420            for w in cut.windows(2) {
421                assert!(w[0] < w[1], "cut not strictly ascending: {cut:?}");
422            }
423            // Source/target membership.
424            assert!(
425                part.contains(&source),
426                "source {source} missing from {part:?}"
427            );
428            assert!(
429                !part.contains(&target),
430                "target {target} present in {part:?}"
431            );
432            // Uniqueness of source-side sets.
433            assert!(seen.insert(part.clone()), "duplicate partition {part:?}");
434
435            let mut in_s = vec![false; n];
436            for &v in part {
437                in_s[v as usize] = true;
438            }
439            // Completeness: cut equals exactly the crossing-out edges.
440            let mut crossing: Vec<EdgeId> = Vec::new();
441            for e in 0..g.ecount() as EdgeId {
442                let (from, to) = g.edge(e).expect("edge");
443                if in_s[from as usize] && !in_s[to as usize] {
444                    crossing.push(e);
445                }
446            }
447            crossing.sort_unstable();
448            assert_eq!(cut, &crossing, "cut != crossing-out edges of {part:?}");
449            // Deleting the cut must disconnect target from source.
450            assert!(
451                !target_reachable_minus_cut(g, source, target, cut),
452                "target still reachable after removing cut {cut:?} (partition {part:?})"
453            );
454        }
455    }
456
457    /// The verified golden fixture from the igraph C reference output.
458    #[test]
459    fn golden_six_node_nine_cuts() {
460        let g = build(6, &[(0, 1), (1, 2), (1, 3), (2, 4), (3, 4), (1, 5), (5, 4)]);
461        let res = all_st_cuts(&g, 0, 4).expect("compute");
462
463        let expected = StCuts {
464            partition1s: vec![
465                vec![0],
466                vec![0, 1],
467                vec![0, 1, 5],
468                vec![0, 1, 3],
469                vec![0, 1, 3, 5],
470                vec![0, 1, 2],
471                vec![0, 1, 2, 5],
472                vec![0, 1, 2, 3],
473                vec![0, 1, 2, 3, 5],
474            ],
475            cuts: vec![
476                vec![0],
477                vec![1, 2, 5],
478                vec![1, 2, 6],
479                vec![1, 4, 5],
480                vec![1, 4, 6],
481                vec![2, 3, 5],
482                vec![2, 3, 6],
483                vec![3, 4, 5],
484                vec![3, 4, 6],
485            ],
486        };
487        assert_eq!(res, expected, "golden fixture mismatch");
488        assert_cuts_consistent(&g, 0, 4, &res);
489    }
490
491    /// Simple path 0 → 1 → 2: exactly two cuts.
492    #[test]
493    fn simple_path_two_cuts() {
494        let g = build(3, &[(0, 1), (1, 2)]);
495        let res = all_st_cuts(&g, 0, 2).expect("compute");
496        assert_eq!(res.partition1s, vec![vec![0], vec![0, 1]]);
497        assert_eq!(res.cuts, vec![vec![0], vec![1]]);
498        assert_cuts_consistent(&g, 0, 2, &res);
499    }
500
501    /// Complete directed graph K5: every subset `X` with `s ∈ X`, `t ∉ X`
502    /// is a valid distinct source side, so there are `2^(n-2) = 8` cuts.
503    #[test]
504    fn complete_digraph_k5_has_eight_cuts() {
505        let mut edges = Vec::new();
506        for u in 0..5 {
507            for v in 0..5 {
508                if u != v {
509                    edges.push((u, v));
510                }
511            }
512        }
513        let g = build(5, &edges);
514        let res = all_st_cuts(&g, 0, 4).expect("compute");
515        assert_eq!(res.cuts.len(), 8, "K5 should yield 2^(5-2) = 8 cuts");
516        assert_cuts_consistent(&g, 0, 4, &res);
517    }
518
519    /// Empty graph (n = 0): any source id is out of range.
520    #[test]
521    fn empty_graph_source_out_of_range() {
522        let g = Graph::new(0, true).expect("empty directed graph");
523        let err = all_st_cuts(&g, 0, 1).expect_err("must reject empty graph");
524        match err {
525            IgraphError::VertexOutOfRange { id, n } => {
526                assert_eq!(id, 0);
527                assert_eq!(n, 0);
528            }
529            other => panic!("unexpected error: {other:?}"),
530        }
531    }
532
533    /// Single vertex (n = 1): the only in-range call has source == target,
534    /// which is rejected; there is no valid (s, t) pair.
535    #[test]
536    fn single_vertex_rejects_equal_endpoints() {
537        let g = Graph::new(1, true).expect("single vertex directed graph");
538        let err = all_st_cuts(&g, 0, 0).expect_err("must reject source == target");
539        assert!(matches!(err, IgraphError::InvalidArgument(_)));
540        // The only other id (target = 1) is out of range.
541        let err2 = all_st_cuts(&g, 0, 1).expect_err("must reject out-of-range target");
542        assert!(matches!(
543            err2,
544            IgraphError::VertexOutOfRange { id: 1, n: 1 }
545        ));
546    }
547
548    /// Undirected graphs are rejected.
549    #[test]
550    fn rejects_undirected_graph() {
551        let g = Graph::with_vertices(4);
552        let err = all_st_cuts(&g, 0, 3).expect_err("must reject undirected");
553        assert!(matches!(err, IgraphError::InvalidArgument(_)));
554    }
555
556    /// `source == target` is rejected.
557    #[test]
558    fn rejects_equal_source_and_target() {
559        let g = build(3, &[(0, 1), (1, 2)]);
560        let err = all_st_cuts(&g, 1, 1).expect_err("must reject source == target");
561        assert!(matches!(err, IgraphError::InvalidArgument(_)));
562    }
563
564    /// Out-of-range source and target are rejected with the right id/n.
565    #[test]
566    fn rejects_out_of_range_endpoints() {
567        let g = build(3, &[(0, 1), (1, 2)]);
568        match all_st_cuts(&g, 5, 1).expect_err("bad source") {
569            IgraphError::VertexOutOfRange { id, n } => {
570                assert_eq!((id, n), (5, 3));
571            }
572            other => panic!("unexpected error: {other:?}"),
573        }
574        match all_st_cuts(&g, 0, 9).expect_err("bad target") {
575            IgraphError::VertexOutOfRange { id, n } => {
576                assert_eq!((id, n), (9, 3));
577            }
578            other => panic!("unexpected error: {other:?}"),
579        }
580    }
581
582    /// Source cannot reach target at all. The algorithm produces no proper
583    /// non-trivial source side here (the recursion bottoms out with an empty
584    /// `S`), so the result is the empty cut list. Whatever it returns must
585    /// be internally consistent.
586    #[test]
587    fn unreachable_target_is_consistent() {
588        // Edge (1, 2) only; vertex 0 has no outgoing edges.
589        let g = build(3, &[(1, 2)]);
590        let res = all_st_cuts(&g, 0, 2).expect("compute");
591        // Observed behaviour: no cuts are enumerated when the target is
592        // unreachable from the source.
593        assert!(res.cuts.is_empty(), "expected empty cut list, got {res:?}");
594        // Consistency holds vacuously, but keep the check as a guard.
595        assert_cuts_consistent(&g, 0, 2, &res);
596    }
597}