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}