Skip to main content

rust_igraph/algorithms/flow/
st_edge_connectivity.rs

1//! `st_edge_connectivity` (ALGO-FL-011) — minimum number of edges
2//! whose removal disconnects `source` from `target`.
3//!
4//! Counterpart of `igraph_st_edge_connectivity` in
5//! `references/igraph/src/flow/flow.c` (lines 2219-2234). The C source
6//! is a 15-line wrapper that rejects `source == target` and then
7//! delegates to `igraph_maxflow_value` with `NULL` capacity (unit
8//! capacities per edge), casting the resulting `igraph_real_t` to
9//! `igraph_int_t` (int64). Unit-capacity max-flow equals the
10//! cardinality of the minimum edge cut by the max-flow / min-cut
11//! theorem (Ford-Fulkerson, 1956), so the truncation is lossless on
12//! integer outputs.
13//!
14//! We follow the same pattern: this is a thin wrapper over
15//! [`super::max_flow::max_flow_value`] with `capacity = None`.
16//! Validation of vertex-id bounds and `source != target` is delegated
17//! to `max_flow_value`, so the error contract is identical.
18
19use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
20
21use super::max_flow::max_flow_value;
22
23/// Edge connectivity between two vertices: the minimum number of edges
24/// whose removal disconnects `source` from `target`.
25///
26/// Counterpart of `igraph_st_edge_connectivity` in
27/// `references/igraph/src/flow/flow.c:2219`. By the max-flow / min-cut
28/// theorem with unit capacities this equals
29/// [`max_flow_value`](super::max_flow::max_flow_value)`(g, s, t, None)`
30/// (cast to an integer). This function exists for naming parity with
31/// igraph C and to give call sites a typed integer answer when the
32/// caller wants the connectivity interpretation rather than the flow
33/// one.
34///
35/// # Arguments
36///
37/// * `graph` — input graph (directed or undirected). For undirected
38///   graphs both arc directions are open, matching igraph C.
39/// * `source` — source vertex id (`0 ≤ source < vcount()`).
40/// * `target` — sink vertex id (`0 ≤ target < vcount()`,
41///   `target != source`).
42///
43/// # Returns
44///
45/// The number of edges in a minimum `source → target` edge cut as
46/// `i64`. Returns `0` when no `source → target` path exists (the empty
47/// cut already disconnects them).
48///
49/// # Errors
50///
51/// Same as [`max_flow_value`](super::max_flow::max_flow_value):
52///
53/// * [`IgraphError::VertexOutOfRange`] if `source` or `target` is
54///   outside `[0, vcount())`.
55/// * [`IgraphError::InvalidArgument`] if `source == target`.
56/// * [`IgraphError::Internal`] if the unit-capacity max-flow value is
57///   not representable as `i64` (in practice unreachable: it is
58///   bounded by `ecount()`, which fits in `u32`).
59///
60/// [`IgraphError::VertexOutOfRange`]: crate::core::IgraphError::VertexOutOfRange
61/// [`IgraphError::InvalidArgument`]: crate::core::IgraphError::InvalidArgument
62/// [`IgraphError::Internal`]: crate::core::IgraphError::Internal
63///
64/// # Examples
65///
66/// ```
67/// use rust_igraph::{Graph, st_edge_connectivity};
68///
69/// // Two parallel unit-cap paths 0→1→3 and 0→2→3 → connectivity = 2.
70/// let mut g = Graph::new(4, true).unwrap();
71/// g.add_edge(0, 1).unwrap();
72/// g.add_edge(1, 3).unwrap();
73/// g.add_edge(0, 2).unwrap();
74/// g.add_edge(2, 3).unwrap();
75/// assert_eq!(st_edge_connectivity(&g, 0, 3).unwrap(), 2);
76/// ```
77pub fn st_edge_connectivity(
78    graph: &Graph,
79    source: VertexId,
80    target: VertexId,
81) -> IgraphResult<i64> {
82    let flow = max_flow_value(graph, source, target, None)?;
83    // Unit-capacity max-flow is integral and bounded above by `ecount()`
84    // (≤ u32::MAX), so the f64 → i64 round + cast is lossless in every
85    // reachable case. The two lints below are *known* properties of
86    // that conversion, not bugs: we want the truncation (rounded) and
87    // the precision-loss bound (compared against `i64::MAX`) precisely
88    // to defend against numerical drift.
89    #[allow(
90        clippy::cast_precision_loss,
91        clippy::cast_possible_truncation,
92        clippy::cast_sign_loss
93    )]
94    {
95        if !flow.is_finite() || flow < 0.0 || flow > i64::MAX as f64 {
96            return Err(IgraphError::Internal(
97                "unit-capacity max-flow value is not representable as i64",
98            ));
99        }
100        Ok(flow.round() as i64)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::core::IgraphError;
108
109    #[test]
110    fn rejects_source_equals_target() {
111        let mut g = Graph::new(2, true).expect("graph");
112        g.add_edge(0, 1).expect("edge");
113        let err = st_edge_connectivity(&g, 0, 0).unwrap_err();
114        match err {
115            IgraphError::InvalidArgument(_) => {}
116            other => panic!("expected InvalidArgument, got {other:?}"),
117        }
118    }
119
120    #[test]
121    fn rejects_out_of_range_source() {
122        let g = Graph::new(2, true).expect("graph");
123        let err = st_edge_connectivity(&g, 5, 0).unwrap_err();
124        match err {
125            IgraphError::VertexOutOfRange { id, n } => {
126                assert_eq!(id, 5);
127                assert_eq!(n, 2);
128            }
129            other => panic!("expected VertexOutOfRange, got {other:?}"),
130        }
131    }
132
133    #[test]
134    fn rejects_out_of_range_target() {
135        let g = Graph::new(2, true).expect("graph");
136        let err = st_edge_connectivity(&g, 0, 5).unwrap_err();
137        match err {
138            IgraphError::VertexOutOfRange { id, n } => {
139                assert_eq!(id, 5);
140                assert_eq!(n, 2);
141            }
142            other => panic!("expected VertexOutOfRange, got {other:?}"),
143        }
144    }
145
146    #[test]
147    fn isolated_endpoints_have_zero_connectivity() {
148        let g = Graph::new(4, true).expect("graph");
149        assert_eq!(st_edge_connectivity(&g, 0, 3).expect("ec"), 0);
150    }
151
152    #[test]
153    fn single_edge_unit() {
154        let mut g = Graph::new(2, true).expect("graph");
155        g.add_edge(0, 1).expect("edge");
156        assert_eq!(st_edge_connectivity(&g, 0, 1).expect("ec"), 1);
157    }
158
159    #[test]
160    fn two_parallel_paths() {
161        // 0→1→3 and 0→2→3 → ec(0,3) = 2.
162        let mut g = Graph::new(4, true).expect("graph");
163        for (s, t) in [(0u32, 1u32), (1, 3), (0, 2), (2, 3)] {
164            g.add_edge(s, t).expect("edge");
165        }
166        assert_eq!(st_edge_connectivity(&g, 0, 3).expect("ec"), 2);
167    }
168
169    #[test]
170    fn three_parallel_arcs_directed() {
171        let mut g = Graph::new(2, true).expect("graph");
172        for _ in 0..3 {
173            g.add_edge(0, 1).expect("edge");
174        }
175        assert_eq!(st_edge_connectivity(&g, 0, 1).expect("ec"), 3);
176    }
177
178    #[test]
179    fn directed_anti_parallel_arc_does_not_count() {
180        // 0 → 1 → 0 (anti-parallel). ec(0,1) only counts arcs going
181        // s → t direction = 1.
182        let mut g = Graph::new(2, true).expect("graph");
183        g.add_edge(0, 1).expect("edge");
184        g.add_edge(1, 0).expect("edge");
185        assert_eq!(st_edge_connectivity(&g, 0, 1).expect("ec"), 1);
186    }
187
188    #[test]
189    fn undirected_bottleneck() {
190        // Path 0 — 1 — 2 — 3 (undirected). Any single edge separates
191        // endpoints → ec(0, 3) = 1.
192        let mut g = Graph::new(4, false).expect("graph");
193        for (s, t) in [(0u32, 1u32), (1, 2), (2, 3)] {
194            g.add_edge(s, t).expect("edge");
195        }
196        assert_eq!(st_edge_connectivity(&g, 0, 3).expect("ec"), 1);
197    }
198
199    #[test]
200    fn c_unit_test_fixture() {
201        // Mirrors references/igraph/tests/unit/igraph_st_edge_connectivity.c
202        // 6 vertices, 8 directed arcs, ec(0, 5) = 2.
203        let mut g = Graph::new(6, true).expect("graph");
204        let arcs = [
205            (0u32, 1u32),
206            (0, 2),
207            (1, 2),
208            (1, 3),
209            (2, 4),
210            (3, 4),
211            (3, 5),
212            (4, 5),
213        ];
214        for (s, t) in arcs {
215            g.add_edge(s, t).expect("edge");
216        }
217        assert_eq!(st_edge_connectivity(&g, 0, 5).expect("ec"), 2);
218    }
219
220    #[test]
221    fn full_graph_5v_undirected() {
222        // K_5 undirected. ec(any-pair) = 4 (each vertex has degree 4).
223        let mut g = Graph::new(5, false).expect("graph");
224        for i in 0u32..5 {
225            for j in (i + 1)..5 {
226                g.add_edge(i, j).expect("edge");
227            }
228        }
229        assert_eq!(st_edge_connectivity(&g, 0, 1).expect("ec"), 4);
230        assert_eq!(st_edge_connectivity(&g, 2, 4).expect("ec"), 4);
231    }
232}
233
234#[cfg(all(test, feature = "proptest-harness"))]
235mod proptests {
236    //! Proptest cross-validates the wrapper invariant: for every legal
237    //! random unit-capacity input,
238    //! `st_edge_connectivity(g, s, t) ==
239    //!  round(max_flow_value(g, s, t, None))`.
240    //! Since `max_flow_value` is itself proptest-cross-validated
241    //! against an independent Edmonds-Karp reference (see
242    //! `max_flow.rs`), this transitively inherits that cross-check.
243
244    use super::*;
245    use crate::core::Graph;
246    use proptest::prelude::*;
247
248    fn xorshift(mut r: u64) -> u64 {
249        r ^= r << 13;
250        r ^= r >> 7;
251        r ^= r << 17;
252        r
253    }
254
255    fn build_random(seed: u64, n: u32, m_max: u32, directed: bool) -> Graph {
256        let mut g = Graph::new(n, directed).expect("graph");
257        let mut state = seed | 1;
258        for _ in 0..m_max {
259            state = xorshift(state);
260            let u = u32::try_from(state % u64::from(n)).expect("modulo fits");
261            state = xorshift(state);
262            let v = u32::try_from(state % u64::from(n)).expect("modulo fits");
263            if u == v {
264                continue;
265            }
266            g.add_edge(u, v).expect("edge");
267        }
268        g
269    }
270
271    proptest! {
272        #[test]
273        fn ec_equals_unit_maxflow(
274            seed in any::<u64>(),
275            n in 2u32..8,
276            m in 1u32..16,
277            directed in any::<bool>(),
278        ) {
279            let g = build_random(seed, n, m, directed);
280            let s = u32::try_from(seed % u64::from(n)).expect("modulo fits");
281            let t_raw = u32::try_from(xorshift(seed) % u64::from(n)).expect("modulo fits");
282            let t = if t_raw == s { (s + 1) % n } else { t_raw };
283            prop_assume!(s != t);
284
285            let flow = max_flow_value(&g, s, t, None).expect("flow");
286            let ec = st_edge_connectivity(&g, s, t).expect("ec");
287            let want = flow.round() as i64;
288            prop_assert_eq!(
289                ec,
290                want,
291                "ec/flow mismatch: ec={} flow={} (n={}, m={}, directed={}, seed={})",
292                ec, flow, n, m, directed, seed
293            );
294            // Sanity: connectivity is bounded by min(out-deg(s), in-deg(t))
295            // when directed, and degrees when undirected. We can't easily
296            // cheap-check this here, but assert it's >= 0 and <= m.
297            prop_assert!(ec >= 0);
298            prop_assert!(ec as u64 <= u64::from(m));
299        }
300    }
301}