Skip to main content

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}