rust_igraph/algorithms/flow/st_mincut.rs
1//! `st_mincut_value` (ALGO-FL-010) — scalar s-t minimum-cut value.
2//! `st_mincut` (ALGO-FL-018) — full s-t minimum-cut partition.
3//!
4//! Counterpart of `igraph_st_mincut_value` / `igraph_st_mincut` in
5//! `references/igraph/src/flow/flow.c` (lines 1127-1138 and
6//! 1140-1186 respectively). Both C entries are thin wrappers around
7//! `igraph_maxflow` — the value variant requests only the scalar flow,
8//! while the partition variant additionally requests the cut edge list
9//! and the source-side / sink-side partitions. Correctness rests on
10//! the max-flow / min-cut theorem (Ford-Fulkerson, 1956): after the
11//! flow saturates, the set of vertices reachable from the source in
12//! the residual network is *exactly* the source-side of a minimum
13//! `s-t` cut, and the edges crossing that frontier are saturated in
14//! the original flow.
15//!
16//! We follow the same delegation pattern. [`st_mincut_value`] is a
17//! thin wrapper over [`super::max_flow::max_flow_value`].
18//! [`st_mincut`] uses the crate-private
19//! [`super::max_flow::max_flow_with_residual`] entry point (a sibling
20//! of `max_flow_value` that also returns the post-augmentation
21//! residual network) and then does one BFS from the source in the
22//! residual to materialise the partition + cut edge list. All input
23//! validation (vertex-id bounds, `source != target`, capacity length,
24//! capacity finiteness / non-negativity) is delegated to the
25//! max-flow layer, so the error contract of both functions matches
26//! that of `max_flow_value`.
27
28// Vertex-id casts in the proptest helper compute `state % u64::from(n)`
29// before truncating to `u32` — the result is always `< n <= u32::MAX`,
30// so no truncation occurs at runtime. Same dispensation as
31// `max_flow.rs`; keep both modules in sync.
32#![allow(clippy::cast_possible_truncation)]
33
34use crate::core::{Graph, IgraphResult, VertexId};
35
36use super::max_flow::{max_flow_value, max_flow_with_residual};
37
38/// Scalar s-t minimum-cut value (capacity of the minimum edge set
39/// whose removal disconnects `source` from `target`).
40///
41/// Counterpart of `igraph_st_mincut_value` in
42/// `references/igraph/src/flow/flow.c:1127`. By the max-flow /
43/// min-cut theorem (Ford-Fulkerson, 1956) the value returned equals
44/// [`max_flow_value`](super::max_flow::max_flow_value); this function
45/// is a thin wrapper that exists for naming parity with igraph C and
46/// to make call sites intent-revealing when the caller wants the
47/// cut interpretation rather than the flow one.
48///
49/// # Arguments
50///
51/// * `graph` — input graph (directed or undirected).
52/// * `source` — source vertex id (`0 ≤ source < vcount()`).
53/// * `target` — sink vertex id (`0 ≤ target < vcount()`,
54/// `target != source`).
55/// * `capacity` — optional per-edge capacity in the graph's edge-id
56/// order. When `None`, each edge contributes unit capacity. When
57/// `Some(c)`, `c.len()` must equal `graph.ecount()`, and every entry
58/// must be finite and `≥ 0`.
59///
60/// # Returns
61///
62/// The minimum s-t cut capacity as `f64`. Returns `0.0` when no
63/// `source → target` path exists in the residual network (the empty
64/// cut already disconnects them).
65///
66/// # Errors
67///
68/// Same as [`max_flow_value`](super::max_flow::max_flow_value):
69///
70/// * [`IgraphError::VertexOutOfRange`] if `source` or `target` is
71/// outside `[0, vcount())`.
72/// * [`IgraphError::InvalidArgument`] if `source == target`, the
73/// capacity slice length differs from `ecount()`, or any capacity
74/// is negative / non-finite.
75///
76/// [`IgraphError::VertexOutOfRange`]: crate::core::IgraphError::VertexOutOfRange
77/// [`IgraphError::InvalidArgument`]: crate::core::IgraphError::InvalidArgument
78///
79/// # Examples
80///
81/// ```
82/// use rust_igraph::{Graph, st_mincut_value};
83///
84/// // Two parallel paths of capacity 1 each → min s-t cut = 2
85/// // (must cut both bottleneck edges to disconnect 0 from 3).
86/// let mut g = Graph::new(4, true).unwrap();
87/// g.add_edge(0, 1).unwrap();
88/// g.add_edge(1, 3).unwrap();
89/// g.add_edge(0, 2).unwrap();
90/// g.add_edge(2, 3).unwrap();
91/// let cap = vec![1.0, 1.0, 1.0, 1.0];
92/// let cut = st_mincut_value(&g, 0, 3, Some(&cap)).unwrap();
93/// assert!((cut - 2.0).abs() < 1e-12);
94/// ```
95pub fn st_mincut_value(
96 graph: &Graph,
97 source: VertexId,
98 target: VertexId,
99 capacity: Option<&[f64]>,
100) -> IgraphResult<f64> {
101 max_flow_value(graph, source, target, capacity)
102}
103
104/// Full s-t minimum-cut: scalar value, the cut edge list, and the
105/// source-side / sink-side vertex partitions.
106///
107/// Counterpart of `igraph_st_mincut` in
108/// `references/igraph/src/flow/flow.c:1140`. The C entry is a
109/// 47-line wrapper around `igraph_maxflow` that requests the optional
110/// `cut`, `partition`, and `partition2` outputs alongside the flow
111/// value. Our implementation:
112///
113/// 1. Calls the crate-private `max_flow_with_residual` backend (the
114/// shared Dinic entry point that also powers [`max_flow_value`]) to
115/// obtain both the flow value and the final residual network.
116/// 2. Runs one BFS from `source` in the residual graph (following only
117/// arcs with strictly positive residual capacity). The set of
118/// reachable vertices is precisely the source-side `S` of a
119/// minimum `s-t` cut (max-flow / min-cut duality, Ford-Fulkerson
120/// 1956). The complement is `partition2`.
121/// 3. Walks the original edge list once: an original edge `(u, v)` is
122/// in the cut iff it crosses the partition boundary — for directed
123/// input the cut edge points `S → V \ S`; for undirected input,
124/// either endpoint side suffices.
125///
126/// # Arguments
127///
128/// * `graph` — input graph (directed or undirected).
129/// * `source` — source vertex id (`0 ≤ source < vcount()`).
130/// * `target` — sink vertex id (`0 ≤ target < vcount()`,
131/// `target != source`).
132/// * `capacity` — optional per-edge capacity in the graph's edge-id
133/// order. When `None`, each edge contributes unit capacity. When
134/// `Some(c)`, `c.len()` must equal `graph.ecount()`, and every entry
135/// must be finite and `≥ 0`.
136///
137/// # Returns
138///
139/// An [`StMincut`] whose
140///
141/// * `value` equals the maximum `source → target` flow (matches
142/// [`max_flow_value`] / [`st_mincut_value`] within `1e-12`).
143/// * `cut` lists original edge ids whose removal disconnects `source`
144/// from `target`. Sum of `capacity[cut[i]]` (or `cut.len()` when
145/// `capacity` is `None`) equals `value` within tolerance.
146/// * `partition` is the source-side `S` (always contains `source`,
147/// never contains `target` unless the graph is so degenerate that
148/// they're already separated by the empty cut).
149/// * `partition2` is `V \ S` (always contains `target`).
150///
151/// Both partitions list vertex ids in ascending order. `partition` and
152/// `partition2` together cover every vertex exactly once.
153///
154/// # Errors
155///
156/// Same as [`max_flow_value`]:
157///
158/// * [`IgraphError::VertexOutOfRange`] if `source` or `target` is
159/// outside `[0, vcount())`.
160/// * [`IgraphError::InvalidArgument`] if `source == target`, the
161/// capacity slice length differs from `ecount()`, or any capacity
162/// is negative / non-finite.
163///
164/// [`IgraphError::VertexOutOfRange`]: crate::core::IgraphError::VertexOutOfRange
165/// [`IgraphError::InvalidArgument`]: crate::core::IgraphError::InvalidArgument
166///
167/// # Examples
168///
169/// ```
170/// use rust_igraph::{Graph, st_mincut};
171///
172/// // 0 → 1 (cap 5) → 2 (cap 2) → 3 (cap 7).
173/// // Unique bottleneck arc (1, 2): cut = [1], partition = [0, 1].
174/// let mut g = Graph::new(4, true).unwrap();
175/// g.add_edge(0, 1).unwrap();
176/// g.add_edge(1, 2).unwrap();
177/// g.add_edge(2, 3).unwrap();
178/// let cut = st_mincut(&g, 0, 3, Some(&[5.0, 2.0, 7.0])).unwrap();
179/// assert!((cut.value - 2.0).abs() < 1e-12);
180/// assert_eq!(cut.cut, vec![1]);
181/// assert_eq!(cut.partition, vec![0, 1]);
182/// assert_eq!(cut.partition2, vec![2, 3]);
183/// ```
184pub fn st_mincut(
185 graph: &Graph,
186 source: VertexId,
187 target: VertexId,
188 capacity: Option<&[f64]>,
189) -> IgraphResult<StMincut> {
190 let (value, net) = max_flow_with_residual(graph, source, target, capacity)?;
191
192 // BFS in the residual graph (only arcs with strictly positive
193 // residual capacity). The reachable set is exactly the source-side
194 // of a minimum s-t cut by max-flow / min-cut duality. We compare
195 // against zero (not against a tolerance) because Dinic only ever
196 // subtracts pushed flow from a residual that already had at least
197 // that much capacity, so a saturated arc lands at *exactly* 0.0 and
198 // any positive value means a legitimate residual path; using a
199 // tolerance here would incorrectly include arcs that were just
200 // saturated to within fp noise. See `references/igraph/src/flow/
201 // flow.c:1015` for the equivalent C check.
202 let n = net.n;
203 let mut in_source = vec![false; n];
204 in_source[source as usize] = true;
205 let mut queue: Vec<u32> = Vec::with_capacity(n);
206 queue.push(source);
207 let mut head_ptr = 0_usize;
208 while head_ptr < queue.len() {
209 let v = queue[head_ptr] as usize;
210 head_ptr += 1;
211 for &arc in &net.arcs_out[v] {
212 let arc_us = arc as usize;
213 if net.cap[arc_us] <= 0.0 {
214 continue;
215 }
216 let w = net.head[arc_us] as usize;
217 if !in_source[w] {
218 in_source[w] = true;
219 queue.push(w as u32);
220 }
221 }
222 }
223
224 // Materialise the two partitions in ascending vertex-id order.
225 let mut partition: Vec<u32> = Vec::with_capacity(queue.len());
226 let mut partition2: Vec<u32> = Vec::with_capacity(n.saturating_sub(queue.len()));
227 for (v, &is_src) in in_source.iter().enumerate() {
228 if is_src {
229 partition.push(v as u32);
230 } else {
231 partition2.push(v as u32);
232 }
233 }
234
235 // Walk original edges once. An edge belongs to the cut iff it
236 // crosses the partition boundary. For directed input the cut must
237 // point S → V\S (the reverse direction lies in the opposite cut and
238 // is not saturated by this flow). For undirected input either
239 // crossing is legitimate — the underlying residual carries flow in
240 // whichever direction the network demanded.
241 let m = graph.ecount();
242 let directed = graph.is_directed();
243 let edge_count =
244 u32::try_from(m).map_err(|_| crate::core::IgraphError::Internal("ecount overflows u32"))?;
245 let mut cut: Vec<u32> = Vec::new();
246 for eid in 0..edge_count {
247 let (u, v) = graph.edge(eid)?;
248 let u_in = in_source[u as usize];
249 let v_in = in_source[v as usize];
250 let crosses = if directed {
251 u_in && !v_in
252 } else {
253 u_in != v_in
254 };
255 if crosses {
256 cut.push(eid);
257 }
258 }
259
260 Ok(StMincut {
261 value,
262 cut,
263 partition,
264 partition2,
265 })
266}
267
268/// Output of [`st_mincut`]: scalar value, cut edge ids, and the
269/// source-side / sink-side vertex partitions.
270///
271/// Mirrors the four output parameters of `igraph_st_mincut` in
272/// `references/igraph/src/flow/flow.c:1140` (`value`, `cut`,
273/// `partition`, `partition2`) — bundled into one return type for
274/// ergonomic Rust call sites.
275#[derive(Debug, Clone)]
276pub struct StMincut {
277 /// Capacity of the minimum `source → target` cut. Equals the
278 /// scalar value returned by [`st_mincut_value`] /
279 /// [`max_flow_value`] within `1e-12`.
280 pub value: f64,
281 /// Edge ids (in `graph`'s ecount-order) whose removal disconnects
282 /// `source` from `target`. Sum of capacities equals `value`.
283 pub cut: Vec<u32>,
284 /// Source-side partition `S` (vertices reachable from `source` in
285 /// the residual network after saturation). Always contains
286 /// `source`. Sorted ascending.
287 pub partition: Vec<u32>,
288 /// Sink-side partition `V \ S`. Always contains `target` (unless
289 /// `target` is itself unreachable from `source` even before any
290 /// flow is pushed, in which case the empty cut suffices). Sorted
291 /// ascending.
292 pub partition2: Vec<u32>,
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::core::IgraphError;
299
300 fn approx(a: f64, b: f64) -> bool {
301 (a - b).abs() <= 1e-12_f64 * a.abs().max(b.abs()).max(1.0)
302 }
303
304 #[test]
305 fn rejects_source_equals_target() {
306 let mut g = Graph::new(2, true).expect("graph");
307 g.add_edge(0, 1).expect("edge");
308 let err = st_mincut_value(&g, 0, 0, None).unwrap_err();
309 match err {
310 IgraphError::InvalidArgument(_) => {}
311 other => panic!("expected InvalidArgument, got {other:?}"),
312 }
313 }
314
315 #[test]
316 fn rejects_out_of_range_source() {
317 let g = Graph::new(2, true).expect("graph");
318 let err = st_mincut_value(&g, 5, 0, None).unwrap_err();
319 match err {
320 IgraphError::VertexOutOfRange { id, n } => {
321 assert_eq!(id, 5);
322 assert_eq!(n, 2);
323 }
324 other => panic!("expected VertexOutOfRange, got {other:?}"),
325 }
326 }
327
328 #[test]
329 fn rejects_out_of_range_target() {
330 let g = Graph::new(2, true).expect("graph");
331 let err = st_mincut_value(&g, 0, 5, None).unwrap_err();
332 match err {
333 IgraphError::VertexOutOfRange { id, n } => {
334 assert_eq!(id, 5);
335 assert_eq!(n, 2);
336 }
337 other => panic!("expected VertexOutOfRange, got {other:?}"),
338 }
339 }
340
341 #[test]
342 fn isolated_endpoints_have_zero_cut() {
343 // Disconnected source and target — the empty cut already
344 // separates them.
345 let g = Graph::new(4, true).expect("graph");
346 let cut = st_mincut_value(&g, 0, 3, None).expect("cut");
347 assert!(approx(cut, 0.0));
348 }
349
350 #[test]
351 fn single_edge_unit_cut() {
352 let mut g = Graph::new(2, true).expect("graph");
353 g.add_edge(0, 1).expect("edge");
354 let cut = st_mincut_value(&g, 0, 1, None).expect("cut");
355 assert!(approx(cut, 1.0));
356 }
357
358 #[test]
359 fn two_parallel_paths_cut_equals_two() {
360 // 0→1→3 and 0→2→3, unit caps. Min cut = 2.
361 let mut g = Graph::new(4, true).expect("graph");
362 g.add_edge(0, 1).expect("edge");
363 g.add_edge(1, 3).expect("edge");
364 g.add_edge(0, 2).expect("edge");
365 g.add_edge(2, 3).expect("edge");
366 let cut = st_mincut_value(&g, 0, 3, Some(&[1.0, 1.0, 1.0, 1.0])).expect("cut");
367 assert!(approx(cut, 2.0));
368 }
369
370 #[test]
371 fn bottleneck_directed() {
372 // 0 → 1 (cap 5) → 2 (cap 2) → 3 (cap 7).
373 // Min cut = 2 (the (1,2) edge is the unique bottleneck).
374 let mut g = Graph::new(4, true).expect("graph");
375 g.add_edge(0, 1).expect("edge");
376 g.add_edge(1, 2).expect("edge");
377 g.add_edge(2, 3).expect("edge");
378 let cut = st_mincut_value(&g, 0, 3, Some(&[5.0, 2.0, 7.0])).expect("cut");
379 assert!(approx(cut, 2.0));
380 }
381
382 #[test]
383 fn classic_max_flow_textbook_cut() {
384 // CLRS 26.1-1 — max flow = 23, so min s-t cut = 23 by duality.
385 let mut g = Graph::new(6, true).expect("graph");
386 let arcs = [
387 (0u32, 1u32),
388 (0, 2),
389 (1, 2),
390 (1, 3),
391 (2, 1),
392 (2, 4),
393 (3, 2),
394 (3, 5),
395 (4, 3),
396 (4, 5),
397 ];
398 let caps = [16.0, 13.0, 10.0, 12.0, 4.0, 14.0, 9.0, 20.0, 7.0, 4.0];
399 for (u, v) in arcs {
400 g.add_edge(u, v).expect("edge");
401 }
402 let cut = st_mincut_value(&g, 0, 5, Some(&caps)).expect("cut");
403 assert!(approx(cut, 23.0));
404 }
405
406 #[test]
407 fn undirected_cut_matches_max_flow() {
408 // igraph_maxflow.c:213 4-vertex undirected reference: max flow = 4.
409 let mut g = Graph::new(4, false).expect("graph");
410 for (a, b) in [(0u32, 1u32), (0, 2), (1, 2), (1, 3), (2, 3)] {
411 g.add_edge(a, b).expect("edge");
412 }
413 let cut = st_mincut_value(&g, 0, 3, Some(&[4.0, 2.0, 10.0, 2.0, 2.0])).expect("cut");
414 assert!(approx(cut, 4.0));
415 }
416
417 #[test]
418 fn equals_max_flow_value() {
419 // Belt-and-suspenders: assert wrapper agrees with the
420 // delegate on a non-trivial fixture.
421 let mut g = Graph::new(5, true).expect("graph");
422 for (s, t) in [(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (1, 4)] {
423 g.add_edge(s, t).expect("edge");
424 }
425 let caps = [3.0, 5.0, 2.0, 4.0, 6.0, 1.0];
426 let flow = max_flow_value(&g, 0, 4, Some(&caps)).expect("flow");
427 let cut = st_mincut_value(&g, 0, 4, Some(&caps)).expect("cut");
428 assert!(approx(flow, cut));
429 }
430
431 // ----- FL-018 (st_mincut, the partition variant) -----------------
432
433 fn sum_cut_caps(cut: &[u32], caps: Option<&[f64]>) -> f64 {
434 if let Some(c) = caps {
435 cut.iter().map(|&e| c[e as usize]).sum()
436 } else {
437 // cut.len() is bounded by ecount(); test fixtures keep it
438 // well under 2^53 so the f64 round-trip is exact.
439 #[allow(clippy::cast_precision_loss)]
440 let n = cut.len() as f64;
441 n
442 }
443 }
444
445 fn assert_partition_well_formed(n: u32, part: &[u32], part2: &[u32], src: u32, tgt: u32) {
446 // Two partitions cover every vertex exactly once; sorted ascending.
447 assert!(part.windows(2).all(|w| w[0] < w[1]), "partition not sorted");
448 assert!(
449 part2.windows(2).all(|w| w[0] < w[1]),
450 "partition2 not sorted"
451 );
452 assert_eq!(
453 part.len() + part2.len(),
454 n as usize,
455 "partitions must cover V"
456 );
457 let mut seen = vec![false; n as usize];
458 for &v in part.iter().chain(part2.iter()) {
459 assert!(!seen[v as usize], "vertex {v} appears in both partitions");
460 seen[v as usize] = true;
461 }
462 assert!(seen.iter().all(|&b| b), "some vertex missing");
463 assert!(
464 part.contains(&src),
465 "source {src} not on source side {part:?}"
466 );
467 // The sink is on the opposite side as long as the flow is non-trivial.
468 // For the empty / disconnected case the sink may also live in
469 // partition2 trivially (the BFS never reached it).
470 assert!(
471 part2.contains(&tgt),
472 "target {tgt} not on sink side {part2:?}"
473 );
474 }
475
476 #[test]
477 fn st_mincut_bottleneck_directed_partition() {
478 // 0 → 1 (cap 5) → 2 (cap 2) → 3 (cap 7).
479 // Unique bottleneck arc id 1; partition = {0,1}, partition2 = {2,3}.
480 let mut g = Graph::new(4, true).expect("graph");
481 g.add_edge(0, 1).expect("edge");
482 g.add_edge(1, 2).expect("edge");
483 g.add_edge(2, 3).expect("edge");
484 let caps = [5.0, 2.0, 7.0];
485 let r = st_mincut(&g, 0, 3, Some(&caps)).expect("st_mincut");
486 assert!(approx(r.value, 2.0));
487 assert_eq!(r.cut, vec![1]);
488 assert_eq!(r.partition, vec![0, 1]);
489 assert_eq!(r.partition2, vec![2, 3]);
490 assert!(approx(sum_cut_caps(&r.cut, Some(&caps)), r.value));
491 assert_partition_well_formed(4, &r.partition, &r.partition2, 0, 3);
492 }
493
494 #[test]
495 fn st_mincut_two_parallel_paths_partition() {
496 // 0→1→3 and 0→2→3 unit caps. The Dinic BFS saturates both
497 // outgoing arcs from 0 first, so the only residual frontier
498 // sits at the source — partition = {0}, cut = first arcs of
499 // each path.
500 let mut g = Graph::new(4, true).expect("graph");
501 g.add_edge(0, 1).expect("edge");
502 g.add_edge(1, 3).expect("edge");
503 g.add_edge(0, 2).expect("edge");
504 g.add_edge(2, 3).expect("edge");
505 let r = st_mincut(&g, 0, 3, None).expect("st_mincut");
506 assert!(approx(r.value, 2.0));
507 assert!(approx(sum_cut_caps(&r.cut, None), r.value));
508 assert_partition_well_formed(4, &r.partition, &r.partition2, 0, 3);
509 // value equals the value-only oracle.
510 let v = st_mincut_value(&g, 0, 3, None).expect("value");
511 assert!(approx(v, r.value));
512 }
513
514 #[test]
515 fn st_mincut_isolated_endpoints() {
516 // No path source→target → empty cut, partition = {source}.
517 let g = Graph::new(4, true).expect("graph");
518 let r = st_mincut(&g, 0, 3, None).expect("st_mincut");
519 assert!(approx(r.value, 0.0));
520 assert!(r.cut.is_empty(), "cut must be empty when value = 0");
521 assert_eq!(r.partition, vec![0]);
522 assert_eq!(r.partition2, vec![1, 2, 3]);
523 }
524
525 #[test]
526 fn st_mincut_single_edge() {
527 let mut g = Graph::new(2, true).expect("graph");
528 g.add_edge(0, 1).expect("edge");
529 let r = st_mincut(&g, 0, 1, None).expect("st_mincut");
530 assert!(approx(r.value, 1.0));
531 assert_eq!(r.cut, vec![0]);
532 assert_eq!(r.partition, vec![0]);
533 assert_eq!(r.partition2, vec![1]);
534 }
535
536 #[test]
537 fn st_mincut_undirected_partition_crossings() {
538 // 4v undirected: edges (0,1), (0,2), (1,3), (2,3); caps 1,1,1,1.
539 // Two unit-capacity paths from 0 to 3, so cut value = 2.
540 let mut g = Graph::new(4, false).expect("graph");
541 g.add_edge(0, 1).expect("edge");
542 g.add_edge(0, 2).expect("edge");
543 g.add_edge(1, 3).expect("edge");
544 g.add_edge(2, 3).expect("edge");
545 let caps = [1.0, 1.0, 1.0, 1.0];
546 let r = st_mincut(&g, 0, 3, Some(&caps)).expect("st_mincut");
547 assert!(approx(r.value, 2.0));
548 assert!(approx(sum_cut_caps(&r.cut, Some(&caps)), 2.0));
549 assert_partition_well_formed(4, &r.partition, &r.partition2, 0, 3);
550 // Each cut edge has one endpoint on each side.
551 for &eid in &r.cut {
552 let (u, v) = g.edge(eid).expect("edge");
553 let u_in = r.partition.contains(&u);
554 let v_in = r.partition.contains(&v);
555 assert_ne!(
556 u_in, v_in,
557 "cut edge {eid} ({u}-{v}) does not cross the partition"
558 );
559 }
560 }
561
562 #[test]
563 fn st_mincut_multigraph_parallel_arcs() {
564 // Two parallel arcs 0→1: cut must list both edge ids.
565 let mut g = Graph::new(2, true).expect("graph");
566 g.add_edge(0, 1).expect("edge");
567 g.add_edge(0, 1).expect("edge");
568 let r = st_mincut(&g, 0, 1, None).expect("st_mincut");
569 assert!(approx(r.value, 2.0));
570 let mut cut_sorted = r.cut.clone();
571 cut_sorted.sort_unstable();
572 assert_eq!(cut_sorted, vec![0, 1]);
573 assert_eq!(r.partition, vec![0]);
574 assert_eq!(r.partition2, vec![1]);
575 }
576
577 #[test]
578 fn st_mincut_classic_textbook_partition() {
579 // CLRS 26.1-1: max flow = min cut = 23. The unique min cut
580 // here isolates source 0 with cap 16 + 13 = 29 on its outgoing
581 // arcs — that's NOT the min cut. The min cut sits at the
582 // {0,1,2,4} | {3,5} boundary (12 + 7 + 4 = 23). We don't pin
583 // the exact edge id list (multiple min cuts may exist with the
584 // same value), only invariants.
585 let mut g = Graph::new(6, true).expect("graph");
586 let arcs = [
587 (0u32, 1u32),
588 (0, 2),
589 (1, 2),
590 (1, 3),
591 (2, 1),
592 (2, 4),
593 (3, 2),
594 (3, 5),
595 (4, 3),
596 (4, 5),
597 ];
598 let caps = [16.0, 13.0, 10.0, 12.0, 4.0, 14.0, 9.0, 20.0, 7.0, 4.0];
599 for (u, v) in arcs {
600 g.add_edge(u, v).expect("edge");
601 }
602 let r = st_mincut(&g, 0, 5, Some(&caps)).expect("st_mincut");
603 assert!(approx(r.value, 23.0));
604 assert!(approx(sum_cut_caps(&r.cut, Some(&caps)), 23.0));
605 assert_partition_well_formed(6, &r.partition, &r.partition2, 0, 5);
606 // All cut arcs go S → V\S (directed).
607 for &eid in &r.cut {
608 let (u, v) = g.edge(eid).expect("edge");
609 assert!(
610 r.partition.contains(&u) && r.partition2.contains(&v),
611 "directed cut arc {eid} ({u}→{v}) must point S→V\\S"
612 );
613 }
614 }
615
616 #[test]
617 fn st_mincut_capacity_validation_propagates() {
618 let mut g = Graph::new(2, true).expect("graph");
619 g.add_edge(0, 1).expect("edge");
620 // Mismatched capacity length → InvalidArgument.
621 let err = st_mincut(&g, 0, 1, Some(&[1.0, 2.0])).unwrap_err();
622 match err {
623 IgraphError::InvalidArgument(_) => {}
624 other => panic!("expected InvalidArgument, got {other:?}"),
625 }
626 // Negative capacity → InvalidArgument.
627 let err = st_mincut(&g, 0, 1, Some(&[-1.0])).unwrap_err();
628 assert!(matches!(err, IgraphError::InvalidArgument(_)));
629 // source == target → InvalidArgument (delegated).
630 let err = st_mincut(&g, 0, 0, None).unwrap_err();
631 assert!(matches!(err, IgraphError::InvalidArgument(_)));
632 // source out of range.
633 let err = st_mincut(&g, 5, 1, None).unwrap_err();
634 assert!(matches!(err, IgraphError::VertexOutOfRange { id: 5, n: 2 }));
635 // target out of range.
636 let err = st_mincut(&g, 0, 5, None).unwrap_err();
637 assert!(matches!(err, IgraphError::VertexOutOfRange { id: 5, n: 2 }));
638 }
639
640 #[test]
641 fn st_mincut_value_matches_max_flow_value() {
642 // Triangulate against both peers on the same non-trivial graph.
643 let mut g = Graph::new(5, true).expect("graph");
644 for (s, t) in [(0u32, 1u32), (0, 2), (1, 3), (2, 3), (3, 4), (1, 4)] {
645 g.add_edge(s, t).expect("edge");
646 }
647 let caps = [3.0, 5.0, 2.0, 4.0, 6.0, 1.0];
648 let flow = max_flow_value(&g, 0, 4, Some(&caps)).expect("flow");
649 let val = st_mincut_value(&g, 0, 4, Some(&caps)).expect("value");
650 let part = st_mincut(&g, 0, 4, Some(&caps)).expect("partition");
651 assert!(approx(flow, val));
652 assert!(approx(flow, part.value));
653 assert!(approx(sum_cut_caps(&part.cut, Some(&caps)), flow));
654 }
655}
656
657#[cfg(all(test, feature = "proptest-harness"))]
658mod proptests {
659 //! Proptest cross-validates the wrapper invariant: for every legal
660 //! input, `st_mincut_value(g, s, t, c) == max_flow_value(g, s, t, c)`.
661 //! This is the duality theorem at the value level — and since
662 //! `max_flow_value` is itself proptest-cross-validated against an
663 //! independent Edmonds-Karp reference (see `max_flow.rs`), the
664 //! mincut value transitively inherits that cross-validation.
665
666 use super::*;
667 use crate::core::Graph;
668 use proptest::prelude::*;
669
670 fn xorshift(mut r: u64) -> u64 {
671 r ^= r << 13;
672 r ^= r >> 7;
673 r ^= r << 17;
674 r
675 }
676
677 fn build_random(seed: u64, n: u32, m_max: u32, directed: bool) -> (Graph, Vec<f64>) {
678 let mut g = Graph::new(n, directed).expect("graph");
679 let mut state = seed | 1;
680 let mut caps: Vec<f64> = Vec::new();
681 for _ in 0..m_max {
682 state = xorshift(state);
683 let u = (state % u64::from(n)) as u32;
684 state = xorshift(state);
685 let v = (state % u64::from(n)) as u32;
686 if u == v {
687 continue;
688 }
689 state = xorshift(state);
690 let cap = f64::from((state % 16) as u32) + 1.0;
691 g.add_edge(u, v).expect("edge");
692 caps.push(cap);
693 }
694 (g, caps)
695 }
696
697 proptest! {
698 #[test]
699 fn mincut_equals_maxflow(
700 seed in any::<u64>(),
701 n in 2u32..8,
702 m in 1u32..16,
703 directed in any::<bool>(),
704 ) {
705 let (g, caps) = build_random(seed, n, m, directed);
706 let s = (seed % u64::from(n)) as u32;
707 let t_raw = xorshift(seed) % u64::from(n);
708 let t = if t_raw as u32 == s { (s + 1) % n } else { t_raw as u32 };
709 prop_assume!(s != t);
710
711 let flow = max_flow_value(&g, s, t, Some(&caps)).expect("flow");
712 let cut = st_mincut_value(&g, s, t, Some(&caps)).expect("cut");
713 let scale = flow.abs().max(cut.abs()).max(1.0);
714 prop_assert!(
715 (flow - cut).abs() <= 1e-12_f64 * scale,
716 "duality violated: flow {flow} cut {cut} (n={n}, m={m}, directed={directed}, seed={seed})"
717 );
718 }
719
720 // FL-018: st_mincut partition invariants.
721 // 1. value equals st_mincut_value;
722 // 2. partition ∪ partition2 = V, disjoint, source ∈ partition,
723 // target ∈ partition2;
724 // 3. sum of cut capacities equals the flow value;
725 // 4. removing the cut edges disconnects source from target in
726 // the underlying (directed) reachability — verified by a
727 // plain BFS that respects the directed-vs-undirected arc
728 // orientation.
729 #[test]
730 fn st_mincut_partition_invariants(
731 seed in any::<u64>(),
732 n in 2u32..8,
733 m in 1u32..16,
734 directed in any::<bool>(),
735 ) {
736 let (g, caps) = build_random(seed, n, m, directed);
737 let s = (seed % u64::from(n)) as u32;
738 let t_raw = xorshift(seed) % u64::from(n);
739 let t = if t_raw as u32 == s { (s + 1) % n } else { t_raw as u32 };
740 prop_assume!(s != t);
741
742 let result = st_mincut(&g, s, t, Some(&caps)).expect("st_mincut");
743 let value = st_mincut_value(&g, s, t, Some(&caps)).expect("value");
744
745 // Invariant 1: value matches the scalar oracle.
746 let scale = value.abs().max(result.value.abs()).max(1.0);
747 prop_assert!(
748 (value - result.value).abs() <= 1e-12_f64 * scale,
749 "values disagree: scalar={value} partition={}", result.value
750 );
751
752 // Invariant 2: partition / partition2 well-formedness.
753 prop_assert_eq!(
754 result.partition.len() + result.partition2.len(),
755 n as usize,
756 "partitions do not cover V"
757 );
758 let mut seen = vec![false; n as usize];
759 for &v in result.partition.iter().chain(result.partition2.iter()) {
760 prop_assert!(!seen[v as usize], "vertex {} duplicated", v);
761 seen[v as usize] = true;
762 }
763 prop_assert!(seen.iter().all(|&b| b), "some vertex unaccounted for");
764 prop_assert!(result.partition.contains(&s), "source missing from partition");
765 prop_assert!(result.partition2.contains(&t), "target missing from partition2");
766 prop_assert!(
767 result.partition.windows(2).all(|w| w[0] < w[1]),
768 "partition not sorted"
769 );
770 prop_assert!(
771 result.partition2.windows(2).all(|w| w[0] < w[1]),
772 "partition2 not sorted"
773 );
774
775 // Invariant 3: cut capacity sum = value.
776 let mut sum_caps = 0.0_f64;
777 for &eid in &result.cut {
778 sum_caps += caps[eid as usize];
779 }
780 let scale = value.abs().max(sum_caps.abs()).max(1.0);
781 prop_assert!(
782 (sum_caps - value).abs() <= 1e-9_f64 * scale,
783 "cut capacity {sum_caps} does not match value {value}"
784 );
785
786 // Invariant 4: removing cut edges disconnects s from t.
787 // Build adjacency over the surviving edges and BFS.
788 let cut_set: std::collections::HashSet<u32> = result.cut.iter().copied().collect();
789 let mut adj: Vec<Vec<u32>> = vec![Vec::new(); n as usize];
790 for eid in 0..g.ecount() as u32 {
791 if cut_set.contains(&eid) {
792 continue;
793 }
794 let (u, v) = g.edge(eid).expect("edge");
795 adj[u as usize].push(v);
796 if !directed {
797 adj[v as usize].push(u);
798 }
799 }
800 let mut visited = vec![false; n as usize];
801 visited[s as usize] = true;
802 let mut q = vec![s];
803 let mut hp = 0;
804 while hp < q.len() {
805 let v = q[hp] as usize;
806 hp += 1;
807 for &w in &adj[v] {
808 if !visited[w as usize] {
809 visited[w as usize] = true;
810 q.push(w);
811 }
812 }
813 }
814 prop_assert!(
815 !visited[t as usize],
816 "removing cut did not disconnect {} from {} (graph: n={}, m={}, directed={}, seed={}, cut={:?})",
817 s, t, n, m, directed, seed, result.cut
818 );
819 }
820 }
821}