Skip to main content

rust_igraph/algorithms/flow/
st_vertex_connectivity.rs

1//! `st_vertex_connectivity` (ALGO-FL-013) — minimum number of vertices
2//! (other than `source` and `target`) whose removal disconnects
3//! `source` from `target`.
4//!
5//! Counterpart of `igraph_st_vertex_connectivity` in
6//! `references/igraph/src/flow/flow.c` (lines 1922-1940), which
7//! dispatches to two internal helpers,
8//! `igraph_i_st_vertex_connectivity_directed` (flow.c:1790-1849) and
9//! `igraph_i_st_vertex_connectivity_undirected` (flow.c:1851-1879).
10//! The undirected case forwards to the directed one after a
11//! `IGRAPH_TO_DIRECTED_MUTUAL` conversion (each `(u, v)` ↔ two arcs
12//! `u→v` and `v→u`).
13//!
14//! ## Algorithm
15//!
16//! The reduction is the standard vertex-splitting trick (Even,
17//! *Graph Algorithms* §5.5):
18//!
19//! 1. Replace every vertex `v` with two vertices `v_out` and `v_in`
20//!    joined by an internal arc `v_in → v_out` of capacity 1.
21//! 2. Rewrite each original arc `u → v` as `u_out → v_in` with
22//!    capacity 1.
23//! 3. Set the internal arcs of `source` and `target` to capacity 0
24//!    (the C implementation does this implicitly: it sets every arc
25//!    incident on `source_in` and `target_out` to capacity 0 — same
26//!    set of arcs in our layout).
27//! 4. Run a unit-capacity max-flow from `source_out` (= `source` in
28//!    the C indexing) to `target_in` (= `target + n`). By Menger's
29//!    theorem this equals the number of internally vertex-disjoint
30//!    `source → target` paths in the original graph, which is the
31//!    s-t vertex connectivity.
32//!
33//! We mirror igraph C's layout exactly so the implementation can be
34//! verified against the reference's `igraph_i_split_vertices`
35//! (`references/igraph/src/flow/flow_conversion.c:61-92`): the first
36//! `n` vertices of the split graph are the **output** halves
37//! (`v_out = v`) and the last `n` are the **input** halves
38//! (`v_in = v + n`). The internal arc is `v_in → v_out`.
39//!
40//! ## Direct edges between `source` and `target`
41//!
42//! A direct edge `source → target` cannot be cut by removing internal
43//! vertices, so the behaviour when one exists is configurable via
44//! [`VconnNei`]. This mirrors igraph C's `igraph_vconn_nei_t`
45//! verbatim.
46
47use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
48
49use super::max_flow::max_flow_value;
50
51/// Behaviour when `source` and `target` are directly adjacent.
52///
53/// Mirrors `igraph_vconn_nei_t` from
54/// `references/igraph/include/igraph_flow.h`. A direct edge cannot be
55/// broken by removing internal vertices, so this enum picks how the
56/// library should report that case.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum VconnNei {
59    /// Return an [`IgraphError::InvalidArgument`] when a direct edge
60    /// exists. Matches igraph C `IGRAPH_VCONN_NEI_ERROR`.
61    Error,
62    /// Return `-1` when a direct edge exists. Matches igraph C
63    /// `IGRAPH_VCONN_NEI_NEGATIVE`.
64    Negative,
65    /// Return `vcount()` (which is `≥ s-t vertex connectivity`
66    /// trivially) when a direct edge exists. Matches igraph C
67    /// `IGRAPH_VCONN_NEI_NUMBER_OF_NODES`.
68    NumberOfNodes,
69    /// Continue with the max-flow calculation, subtracting one path
70    /// for every parallel direct arc so the result is the number of
71    /// internally vertex-disjoint paths excluding the direct ones.
72    /// Matches igraph C `IGRAPH_VCONN_NEI_IGNORE`.
73    Ignore,
74}
75
76/// Vertex connectivity between two vertices.
77///
78/// Counterpart of `igraph_st_vertex_connectivity` in
79/// `references/igraph/src/flow/flow.c:1922`. Returns the minimum
80/// number of internal vertices whose removal disconnects `source`
81/// from `target`.
82///
83/// # Arguments
84///
85/// * `graph` — input graph (directed or undirected). Undirected input
86///   is treated as if every edge is two anti-parallel directed arcs
87///   (`IGRAPH_TO_DIRECTED_MUTUAL`), matching igraph C.
88/// * `source` — source vertex id (`0 ≤ source < vcount()`).
89/// * `target` — target vertex id (`0 ≤ target < vcount()`,
90///   `target != source`).
91/// * `mode` — how to handle a direct `source → target` edge. See
92///   [`VconnNei`].
93///
94/// # Returns
95///
96/// The s-t vertex connectivity as `i64`. Returns `0` when `source`
97/// and `target` are in disjoint components. When `mode` is
98/// [`VconnNei::Negative`] and a direct edge exists, returns `-1`.
99///
100/// # Errors
101///
102/// * [`IgraphError::InvalidArgument`] if `source == target`,
103///   `vcount() < 2`, or `mode == VconnNei::Error` and a direct edge
104///   `source → target` exists.
105/// * [`IgraphError::VertexOutOfRange`] if `source` or `target` is
106///   outside `[0, vcount())`.
107/// * [`IgraphError::Internal`] if the unit-capacity max-flow value is
108///   not representable as `i64` (unreachable in practice: the value
109///   is bounded above by `vcount()`).
110///
111/// [`IgraphError::VertexOutOfRange`]: crate::core::IgraphError::VertexOutOfRange
112/// [`IgraphError::InvalidArgument`]: crate::core::IgraphError::InvalidArgument
113/// [`IgraphError::Internal`]: crate::core::IgraphError::Internal
114///
115/// # Examples
116///
117/// ```
118/// use rust_igraph::{Graph, VconnNei, st_vertex_connectivity};
119///
120/// // Path 0 — 1 — 2 — 3 — 4 — 5 (undirected). Any single internal
121/// // vertex separates endpoints → s-t vertex connectivity = 1.
122/// let mut g = Graph::new(6, false).unwrap();
123/// for (u, v) in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)] {
124///     g.add_edge(u, v).unwrap();
125/// }
126/// assert_eq!(
127///     st_vertex_connectivity(&g, 0, 5, VconnNei::Error).unwrap(),
128///     1
129/// );
130/// ```
131pub fn st_vertex_connectivity(
132    graph: &Graph,
133    source: VertexId,
134    target: VertexId,
135    mode: VconnNei,
136) -> IgraphResult<i64> {
137    let n = graph.vcount();
138
139    if n < 2 {
140        return Err(IgraphError::InvalidArgument(format!(
141            "st_vertex_connectivity needs at least 2 vertices, graph has {n}"
142        )));
143    }
144    if source >= n {
145        return Err(IgraphError::VertexOutOfRange { id: source, n });
146    }
147    if target >= n {
148        return Err(IgraphError::VertexOutOfRange { id: target, n });
149    }
150    if source == target {
151        return Err(IgraphError::InvalidArgument(format!(
152            "source and target vertices are the same ({source})"
153        )));
154    }
155
156    // Direct s→t edges. For undirected graphs `get_all_eids_between`
157    // returns the union of both directions which matches the
158    // `IGRAPH_TO_DIRECTED_MUTUAL` semantics for this check.
159    let direct_eids = graph.get_all_eids_between(source, target)?;
160    let no_conn = direct_eids.len();
161    let has_direct = no_conn > 0;
162
163    match mode {
164        VconnNei::Error if has_direct => {
165            return Err(IgraphError::InvalidArgument(format!(
166                "source ({source}) and target ({target}) are directly connected"
167            )));
168        }
169        VconnNei::Negative if has_direct => return Ok(-1),
170        VconnNei::NumberOfNodes if has_direct => return Ok(i64::from(n)),
171        _ => {}
172    }
173
174    // Build the split-vertex graph. Layout (mirrors igraph C
175    // `igraph_i_split_vertices`):
176    //   * vertices [0, n)        — output halves (`v_out == v`)
177    //   * vertices [n, 2n)       — input halves (`v_in == v + n`)
178    //   * arcs:
179    //       - first ecount() arcs  : u_out → v_in   (for each input arc u→v)
180    //       - last n arcs          : v_in → v_out   (one per original v)
181    //   * undirected input is exploded into mutual arc pairs first.
182    let split_n = n
183        .checked_mul(2)
184        .ok_or(IgraphError::Internal("split-vertex graph overflowed u32"))?;
185    let mut split = Graph::new(split_n, true)?;
186
187    let mut original_arc_count: usize = 0;
188    if graph.is_directed() {
189        for eid in 0..graph.ecount() {
190            let eid_u32 =
191                u32::try_from(eid).map_err(|_| IgraphError::Internal("edge id overflow u32"))?;
192            let (u, v) = graph.edge(eid_u32)?;
193            split.add_edge(u, v + n)?;
194            original_arc_count += 1;
195        }
196    } else {
197        // Mutual conversion: each undirected edge contributes both
198        // `u→v` and `v→u` directed arcs (matches
199        // `IGRAPH_TO_DIRECTED_MUTUAL`).
200        for eid in 0..graph.ecount() {
201            let eid_u32 =
202                u32::try_from(eid).map_err(|_| IgraphError::Internal("edge id overflow u32"))?;
203            let (u, v) = graph.edge(eid_u32)?;
204            split.add_edge(u, v + n)?;
205            split.add_edge(v, u + n)?;
206            original_arc_count += 2;
207        }
208    }
209    for v in 0..n {
210        split.add_edge(v + n, v)?;
211    }
212
213    // Unit caps everywhere, then zero out arcs incident on
214    // `source_in` (= source + n) and `target_out` (= target). These
215    // are exactly the arcs that "open up" the source's input-side or
216    // the target's output-side and have no role in any genuine
217    // vertex-disjoint path between source and target.
218    let split_ecount = split.ecount();
219    let mut caps = vec![1.0f64; split_ecount];
220    let source_in = source + n;
221    let target_out = target;
222    for eid in split.incident(source_in)? {
223        caps[eid as usize] = 0.0;
224    }
225    for eid in split.incident(target_out)? {
226        caps[eid as usize] = 0.0;
227    }
228
229    // For IGNORE mode the direct arcs source_out → target_in are kept
230    // in the split graph (they're part of `original_arc_count`) and
231    // contribute `no_conn` unit-flow units we need to subtract.
232    let _ = original_arc_count; // documented above, no further use
233
234    let flow = max_flow_value(&split, source, target + n, Some(&caps))?;
235
236    #[allow(
237        clippy::cast_precision_loss,
238        clippy::cast_possible_truncation,
239        clippy::cast_sign_loss
240    )]
241    let raw = {
242        if !flow.is_finite() || flow < 0.0 || flow > i64::MAX as f64 {
243            return Err(IgraphError::Internal(
244                "unit-capacity max-flow value is not representable as i64",
245            ));
246        }
247        flow.round() as i64
248    };
249
250    let no_conn_i64 = i64::try_from(no_conn).map_err(|_| {
251        IgraphError::Internal("number of parallel direct edges does not fit in i64")
252    })?;
253
254    Ok(raw - no_conn_i64)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::core::IgraphError;
261
262    // --- Error cases (mirrors C: igraph_st_vertex_connectivity.c lines 70-83) ---
263
264    #[test]
265    fn rejects_source_equals_target() {
266        let mut g = Graph::new(2, false).expect("graph");
267        g.add_edge(0, 1).expect("edge");
268        let err = st_vertex_connectivity(&g, 0, 0, VconnNei::Error).unwrap_err();
269        assert!(matches!(err, IgraphError::InvalidArgument(_)));
270    }
271
272    #[test]
273    fn rejects_empty_graph() {
274        let g = Graph::new(0, false).expect("graph");
275        let err = st_vertex_connectivity(&g, 0, 0, VconnNei::Error).unwrap_err();
276        assert!(matches!(err, IgraphError::InvalidArgument(_)));
277    }
278
279    #[test]
280    fn rejects_single_vertex_graph() {
281        let g = Graph::new(1, false).expect("graph");
282        let err = st_vertex_connectivity(&g, 0, 0, VconnNei::Error).unwrap_err();
283        assert!(matches!(err, IgraphError::InvalidArgument(_)));
284    }
285
286    #[test]
287    fn rejects_out_of_range_source() {
288        let g = Graph::new(3, false).expect("graph");
289        let err = st_vertex_connectivity(&g, 5, 0, VconnNei::Error).unwrap_err();
290        match err {
291            IgraphError::VertexOutOfRange { id, n } => {
292                assert_eq!(id, 5);
293                assert_eq!(n, 3);
294            }
295            other => panic!("expected VertexOutOfRange, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn rejects_out_of_range_target() {
301        let g = Graph::new(3, false).expect("graph");
302        let err = st_vertex_connectivity(&g, 0, 5, VconnNei::Error).unwrap_err();
303        match err {
304            IgraphError::VertexOutOfRange { id, n } => {
305                assert_eq!(id, 5);
306                assert_eq!(n, 3);
307            }
308            other => panic!("expected VertexOutOfRange, got {other:?}"),
309        }
310    }
311
312    // --- C unit-test fixtures (igraph_st_vertex_connectivity.c lines 32-66) ---
313
314    #[test]
315    fn two_unconnected_vertices_error_mode_returns_zero() {
316        let g = Graph::new(2, false).expect("graph");
317        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Error).expect("vc");
318        assert_eq!(v, 0);
319    }
320
321    #[test]
322    fn two_connected_vertices_negative_mode_returns_negative_one() {
323        let mut g = Graph::new(2, false).expect("graph");
324        g.add_edge(0, 1).expect("edge");
325        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Negative).expect("vc");
326        assert_eq!(v, -1);
327    }
328
329    #[test]
330    fn two_connected_vertices_number_of_nodes_mode_returns_n() {
331        let mut g = Graph::new(2, false).expect("graph");
332        g.add_edge(0, 1).expect("edge");
333        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::NumberOfNodes).expect("vc");
334        assert_eq!(v, 2);
335    }
336
337    #[test]
338    fn three_parallel_undirected_edges_ignore_mode_returns_zero() {
339        let mut g = Graph::new(2, false).expect("graph");
340        for _ in 0..3 {
341            g.add_edge(0, 1).expect("edge");
342        }
343        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Ignore).expect("vc");
344        assert_eq!(v, 0);
345    }
346
347    #[test]
348    fn mixed_parallel_undirected_edges_ignore_mode_returns_zero() {
349        // Same fixture as in `igraph_st_vertex_connectivity.c` line 49
350        // (despite the C printout claiming "directed", the igraph_small
351        // construction is IGRAPH_UNDIRECTED — see the source).
352        let mut g = Graph::new(2, false).expect("graph");
353        for _ in 0..3 {
354            g.add_edge(0, 1).expect("edge");
355        }
356        for _ in 0..2 {
357            g.add_edge(1, 0).expect("edge");
358        }
359        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Ignore).expect("vc");
360        assert_eq!(v, 0);
361    }
362
363    #[test]
364    fn line_graph_6v_error_mode_returns_one() {
365        // 0 — 1 — 2 — 3 — 4 — 5 undirected. Any internal vertex separates
366        // endpoints. C fixture line 53-54.
367        let mut g = Graph::new(6, false).expect("graph");
368        for (u, v) in [(0u32, 1u32), (1, 2), (2, 3), (3, 4), (4, 5)] {
369            g.add_edge(u, v).expect("edge");
370        }
371        let v = st_vertex_connectivity(&g, 0, 5, VconnNei::Error).expect("vc");
372        assert_eq!(v, 1);
373    }
374
375    #[test]
376    fn full_graph_6v_undirected_ignore_mode_returns_four() {
377        // K_6 undirected. Removing all 4 vertices other than s and t
378        // disconnects them; can't go lower. C fixture line 57-58.
379        let mut g = Graph::new(6, false).expect("graph");
380        for i in 0u32..6 {
381            for j in (i + 1)..6 {
382                g.add_edge(i, j).expect("edge");
383            }
384        }
385        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Ignore).expect("vc");
386        assert_eq!(v, 4);
387    }
388
389    #[test]
390    fn full_graph_6v_directed_ignore_mode_returns_four() {
391        // K_6 with both directions for every pair. C fixture line 60-62.
392        let mut g = Graph::new(6, true).expect("graph");
393        for i in 0u32..6 {
394            for j in 0u32..6 {
395                if i != j {
396                    g.add_edge(i, j).expect("edge");
397                }
398            }
399        }
400        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Ignore).expect("vc");
401        assert_eq!(v, 4);
402    }
403
404    #[test]
405    fn three_vertex_bottleneck_error_mode_returns_one() {
406        // Vertices 0,1,2. Edges (0,1)×2 and (1,2)×4 undirected.
407        // Vertex 1 is the only path through. C fixture line 64-66.
408        let mut g = Graph::new(3, false).expect("graph");
409        for _ in 0..2 {
410            g.add_edge(0, 1).expect("edge");
411        }
412        for _ in 0..4 {
413            g.add_edge(1, 2).expect("edge");
414        }
415        let v = st_vertex_connectivity(&g, 0, 2, VconnNei::Error).expect("vc");
416        assert_eq!(v, 1);
417    }
418
419    // --- Additional sanity tests ---
420
421    #[test]
422    fn isolated_endpoints_have_zero_connectivity() {
423        let g = Graph::new(4, true).expect("graph");
424        let v = st_vertex_connectivity(&g, 0, 3, VconnNei::Error).expect("vc");
425        assert_eq!(v, 0);
426    }
427
428    #[test]
429    fn two_internally_vertex_disjoint_paths() {
430        // 0 → 1 → 3 and 0 → 2 → 3 share no internal vertex.
431        let mut g = Graph::new(4, true).expect("graph");
432        for (u, v) in [(0u32, 1u32), (1, 3), (0, 2), (2, 3)] {
433            g.add_edge(u, v).expect("edge");
434        }
435        let v = st_vertex_connectivity(&g, 0, 3, VconnNei::Error).expect("vc");
436        assert_eq!(v, 2);
437    }
438
439    #[test]
440    fn directed_anti_parallel_does_not_count_in_error_mode() {
441        // 0 → 1 and 1 → 0: there *is* a direct arc 0→1, so ERROR mode
442        // must raise.
443        let mut g = Graph::new(2, true).expect("graph");
444        g.add_edge(0, 1).expect("edge");
445        g.add_edge(1, 0).expect("edge");
446        let err = st_vertex_connectivity(&g, 0, 1, VconnNei::Error).unwrap_err();
447        assert!(matches!(err, IgraphError::InvalidArgument(_)));
448    }
449
450    #[test]
451    fn ignore_mode_subtracts_parallel_direct_arcs_directed() {
452        // s → t exists (1 direct arc) plus 0→2→1 detour. With IGNORE
453        // mode the direct arc is subtracted, leaving the single detour
454        // → 1.
455        let mut g = Graph::new(3, true).expect("graph");
456        g.add_edge(0, 1).expect("edge");
457        g.add_edge(0, 2).expect("edge");
458        g.add_edge(2, 1).expect("edge");
459        let v = st_vertex_connectivity(&g, 0, 1, VconnNei::Ignore).expect("vc");
460        assert_eq!(v, 1);
461    }
462}
463
464#[cfg(all(test, feature = "proptest-harness"))]
465mod proptests {
466    //! Proptest invariants: s-t vertex connectivity is bounded above by
467    //! s-t edge connectivity (Whitney 1932), and the function must
468    //! never return a negative value in modes other than Negative.
469
470    use super::*;
471    use crate::algorithms::flow::st_edge_connectivity::st_edge_connectivity;
472    use crate::core::Graph;
473    use proptest::prelude::*;
474
475    fn xorshift(mut r: u64) -> u64 {
476        r ^= r << 13;
477        r ^= r >> 7;
478        r ^= r << 17;
479        r
480    }
481
482    fn build_random(seed: u64, n: u32, m_max: u32, directed: bool) -> Graph {
483        let mut g = Graph::new(n, directed).expect("graph");
484        let mut state = seed | 1;
485        for _ in 0..m_max {
486            state = xorshift(state);
487            let u = u32::try_from(state % u64::from(n)).expect("modulo fits");
488            state = xorshift(state);
489            let v = u32::try_from(state % u64::from(n)).expect("modulo fits");
490            if u == v {
491                continue;
492            }
493            g.add_edge(u, v).expect("edge");
494        }
495        g
496    }
497
498    proptest! {
499        #[test]
500        fn vc_le_ec_when_no_direct_edge(
501            seed in any::<u64>(),
502            n in 3u32..8,
503            m in 1u32..16,
504            directed in any::<bool>(),
505        ) {
506            // Whitney's theorem: kappa(s,t) <= lambda(s,t) when no
507            // direct edge exists.
508            let g = build_random(seed, n, m, directed);
509            let s = u32::try_from(seed % u64::from(n)).expect("modulo fits");
510            let t_raw = u32::try_from(xorshift(seed) % u64::from(n)).expect("modulo fits");
511            let t = if t_raw == s { (s + 1) % n } else { t_raw };
512            prop_assume!(s != t);
513
514            // Skip cases with a direct edge for this invariant. For
515            // undirected graphs `get_all_eids_between` covers both
516            // directions; for directed graphs we want only s→t.
517            let has_direct = if directed {
518                g.find_eid(s, t).expect("find").is_some()
519            } else {
520                !g.get_all_eids_between(s, t).expect("eids").is_empty()
521            };
522            prop_assume!(!has_direct);
523
524            let vc = st_vertex_connectivity(&g, s, t, VconnNei::Error)
525                .expect("vc (no direct edge by prop_assume)");
526            let ec = st_edge_connectivity(&g, s, t).expect("ec");
527
528            prop_assert!(vc >= 0,
529                "vc must be non-negative without direct edge, got {vc}");
530            prop_assert!(vc <= ec,
531                "Whitney violated: vc={vc} > ec={ec} (n={n}, m={m}, directed={directed}, seed={seed})");
532            // And vc is also bounded by n - 2 (must leave s, t).
533            prop_assert!(vc <= i64::from(n) - 2,
534                "vc={vc} exceeds n-2={} (n={n})", i64::from(n) - 2);
535        }
536
537        #[test]
538        fn ignore_mode_is_non_negative(
539            seed in any::<u64>(),
540            n in 2u32..6,
541            m in 0u32..12,
542            directed in any::<bool>(),
543        ) {
544            let g = build_random(seed, n, m, directed);
545            let s = u32::try_from(seed % u64::from(n)).expect("modulo fits");
546            let t_raw = u32::try_from(xorshift(seed) % u64::from(n)).expect("modulo fits");
547            let t = if t_raw == s { (s + 1) % n } else { t_raw };
548            prop_assume!(s != t);
549
550            let vc = st_vertex_connectivity(&g, s, t, VconnNei::Ignore).expect("vc");
551            prop_assert!(vc >= 0,
552                "Ignore mode returned negative: vc={vc} (n={n}, m={m}, directed={directed}, seed={seed})");
553        }
554    }
555}