Skip to main content

rust_igraph/algorithms/
matching.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_sign_loss,
4    clippy::cast_possible_wrap,
5    clippy::cast_precision_loss,
6    clippy::many_single_char_names,
7    clippy::too_many_lines,
8    clippy::similar_names,
9    clippy::module_name_repetitions
10)]
11
12//! Bipartite matching algorithms.
13//!
14//! Provides:
15//! - [`is_matching`] — validate a matching vector against a graph
16//! - [`is_maximal_matching`] — check whether a valid matching is maximal
17//! - [`maximum_bipartite_matching`] — maximum cardinality bipartite matching
18//!   (push-relabel, unweighted)
19//! - [`maximum_bipartite_matching_weighted`] — maximum weight bipartite
20//!   matching (Hungarian / Kuhn-Munkres)
21//!
22//! Reference: `igraph/src/misc/matching.c` (1013 lines).
23
24use std::collections::VecDeque;
25
26use crate::core::error::{IgraphError, IgraphResult};
27use crate::core::graph::Graph;
28
29/// Result of [`maximum_bipartite_matching`] or
30/// [`maximum_bipartite_matching_weighted`].
31///
32/// # Fields
33///
34/// * `matching_size` — number of matched vertex pairs.
35/// * `matching_weight` — total weight of matched edges (equals `matching_size`
36///   for unweighted).
37/// * `matching` — per-vertex match: `matching[v]` is the partner of `v`, or
38///   `None` if `v` is unmatched.
39///
40/// ```
41/// use rust_igraph::{create, maximum_bipartite_matching, MatchingResult};
42///
43/// // K_{2,2}: 0-2, 0-3, 1-2, 1-3
44/// let g = create(&[(0, 2), (0, 3), (1, 2), (1, 3)], 4, false).unwrap();
45/// let types = vec![false, false, true, true];
46/// let r = maximum_bipartite_matching(&g, &types).unwrap();
47/// assert_eq!(r.matching_size, 2);
48/// ```
49#[derive(Debug, Clone)]
50pub struct MatchingResult {
51    /// Number of matched vertex pairs.
52    pub matching_size: usize,
53    /// Total weight of matched edges (1.0 per edge if unweighted).
54    pub matching_weight: f64,
55    /// Per-vertex matching partner: `Some(j)` if matched to `j`, `None` if unmatched.
56    pub matching: Vec<Option<u32>>,
57}
58
59/// Check whether `matching` is a valid matching for `graph`.
60///
61/// A matching vector has length `vcount`; entry `i` is `Some(j)` when vertex
62/// `i` is matched to `j`, or `None` if unmatched. The function verifies:
63/// 1. Length equals `vcount`.
64/// 2. Matched pairs are mutual (`matching[i] == Some(j)` ⟹ `matching[j] == Some(i)`).
65/// 3. Every matched pair is connected by an edge (ignoring direction).
66/// 4. If `types` is provided, matched vertices have different types.
67///
68/// ```
69/// use rust_igraph::{create, is_matching};
70///
71/// let g = create(&[(0, 1), (1, 2)], 3, false).unwrap();
72/// let m = vec![Some(1), Some(0), None];
73/// assert!(is_matching(&g, None, &m).unwrap());
74/// ```
75pub fn is_matching(
76    graph: &Graph,
77    types: Option<&[bool]>,
78    matching: &[Option<u32>],
79) -> IgraphResult<bool> {
80    let n = graph.vcount() as usize;
81    if matching.len() != n {
82        return Ok(false);
83    }
84
85    let adj = build_undirected_adj(graph);
86
87    for (i, &mi) in matching.iter().enumerate() {
88        let Some(j) = mi else { continue };
89        let j_usize = j as usize;
90        if j_usize >= n {
91            return Ok(false);
92        }
93        if matching[j_usize] != Some(i as u32) {
94            return Ok(false);
95        }
96        if !adj[i].contains(&j) {
97            return Ok(false);
98        }
99    }
100
101    if let Some(t) = types {
102        if t.len() < n {
103            return Err(IgraphError::InvalidArgument(
104                "types vector too short".into(),
105            ));
106        }
107        for (i, &mi) in matching.iter().enumerate() {
108            let Some(j) = mi else { continue };
109            if t[i] == t[j as usize] {
110                return Ok(false);
111            }
112        }
113    }
114
115    Ok(true)
116}
117
118/// Check whether `matching` is a *maximal* matching for `graph`.
119///
120/// A matching is maximal if no unmatched vertex has an unmatched neighbor
121/// (respecting bipartite types if given).
122///
123/// ```
124/// use rust_igraph::{create, is_maximal_matching};
125///
126/// let g = create(&[(0, 1), (1, 2)], 3, false).unwrap();
127/// // Only 0-1 matched; vertex 2 is unmatched but has no unmatched neighbor → maximal
128/// let m = vec![Some(1), Some(0), None];
129/// assert!(is_maximal_matching(&g, None, &m).unwrap());
130/// ```
131pub fn is_maximal_matching(
132    graph: &Graph,
133    types: Option<&[bool]>,
134    matching: &[Option<u32>],
135) -> IgraphResult<bool> {
136    if !is_matching(graph, types, matching)? {
137        return Ok(false);
138    }
139
140    let n = graph.vcount() as usize;
141    let adj = build_undirected_adj(graph);
142
143    for i in 0..n {
144        if matching[i].is_some() {
145            continue;
146        }
147        for &nb in &adj[i] {
148            if matching[nb as usize].is_none() {
149                if let Some(t) = types {
150                    if t[i] == t[nb as usize] {
151                        continue;
152                    }
153                }
154                return Ok(false);
155            }
156        }
157    }
158
159    Ok(true)
160}
161
162/// Compute a maximum cardinality matching in an unweighted bipartite graph.
163///
164/// Uses a push-relabel algorithm with greedy initialization and global
165/// relabeling every `n/2` steps. Returns a [`MatchingResult`] where
166/// `matching_weight == matching_size` (since all edges have unit weight).
167///
168/// `types` must be a bipartite partition: `types[v]` is `false` for one side
169/// and `true` for the other. The function validates that every edge connects
170/// vertices of different types.
171///
172/// ```
173/// use rust_igraph::{create, maximum_bipartite_matching};
174///
175/// let g = create(&[(0, 2), (0, 3), (1, 2), (1, 3)], 4, false).unwrap();
176/// let types = vec![false, false, true, true];
177/// let r = maximum_bipartite_matching(&g, &types).unwrap();
178/// assert_eq!(r.matching_size, 2);
179/// ```
180pub fn maximum_bipartite_matching(graph: &Graph, types: &[bool]) -> IgraphResult<MatchingResult> {
181    let n = graph.vcount() as usize;
182    if types.len() < n {
183        return Err(IgraphError::InvalidArgument(
184            "types vector too short".into(),
185        ));
186    }
187
188    let adj = build_undirected_adj(graph);
189    let (matching, num_matched) = push_relabel_unweighted(graph, &adj, types, n)?;
190
191    Ok(MatchingResult {
192        matching_size: num_matched,
193        matching_weight: num_matched as f64,
194        matching,
195    })
196}
197
198/// Compute a maximum weight matching in a weighted bipartite graph.
199///
200/// Uses the Hungarian algorithm (Kuhn-Munkres) with push-relabel
201/// initialization on tight edges. `weights[e]` gives the weight of edge `e`
202/// (by edge id). `eps` controls floating-point tolerance for "tight" edge
203/// detection; pass `0.0` for integer weights.
204///
205/// ```
206/// use rust_igraph::{create, maximum_bipartite_matching_weighted};
207///
208/// let g = create(&[(0, 2), (0, 3), (1, 2), (1, 3)], 4, false).unwrap();
209/// let types = vec![false, false, true, true];
210/// let weights = vec![1.0, 10.0, 10.0, 1.0];
211/// let r = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).unwrap();
212/// assert_eq!(r.matching_size, 2);
213/// assert!((r.matching_weight - 20.0).abs() < 1e-9);
214/// ```
215pub fn maximum_bipartite_matching_weighted(
216    graph: &Graph,
217    types: &[bool],
218    weights: &[f64],
219    eps: f64,
220) -> IgraphResult<MatchingResult> {
221    let n = graph.vcount() as usize;
222    let ne = graph.ecount();
223    if types.len() < n {
224        return Err(IgraphError::InvalidArgument(
225            "types vector too short".into(),
226        ));
227    }
228    if weights.len() < ne {
229        return Err(IgraphError::InvalidArgument(
230            "weights vector too short".into(),
231        ));
232    }
233    let eps = if eps < 0.0 { 0.0 } else { eps };
234
235    hungarian(graph, types, weights, eps, n, ne)
236}
237
238// ── helpers ──────────────────────────────────────────────────────────
239
240fn build_undirected_adj(graph: &Graph) -> Vec<Vec<u32>> {
241    let n = graph.vcount() as usize;
242    let mut adj: Vec<Vec<u32>> = vec![Vec::new(); n];
243    for eid in 0..graph.ecount() {
244        if let Ok((u, v)) = graph.edge(eid as u32) {
245            adj[u as usize].push(v);
246            if u != v {
247                adj[v as usize].push(u);
248            }
249        }
250    }
251    adj
252}
253
254// ── push-relabel unweighted ─────────────────────────────────────────
255
256fn push_relabel_unweighted(
257    _graph: &Graph,
258    adj: &[Vec<u32>],
259    types: &[bool],
260    n: usize,
261) -> IgraphResult<(Vec<Option<u32>>, usize)> {
262    let mut matching: Vec<i64> = vec![-1; n];
263    let mut labels: Vec<i64> = vec![0; n];
264
265    // Determine smaller set and greedy init
266    let count_true = types[..n].iter().filter(|&&t| t).count();
267    let smaller_set = count_true <= n / 2;
268
269    let mut num_matched: usize = 0;
270    for i in 0..n {
271        if matching[i] != -1 {
272            continue;
273        }
274        for &nb in &adj[i] {
275            let nb_usize = nb as usize;
276            if types[nb_usize] == types[i] {
277                return Err(IgraphError::InvalidArgument(
278                    "Graph is not bipartite with supplied types vector".into(),
279                ));
280            }
281            if matching[nb_usize] == -1 {
282                matching[nb_usize] = i64::from(i as u32);
283                matching[i] = i64::from(nb);
284                num_matched += 1;
285                break;
286            }
287        }
288    }
289
290    // Global relabeling
291    global_relabel(adj, &mut labels, &matching, types, smaller_set, n);
292
293    // Fill push queue with unmatched vertices from smaller set
294    let mut q: VecDeque<usize> = VecDeque::new();
295    for i in 0..n {
296        if matching[i] == -1 && types[i] == smaller_set {
297            q.push_back(i);
298        }
299    }
300
301    let relabeling_freq = (n / 2).max(1);
302    let mut label_changed: usize = 0;
303
304    while let Some(v) = q.pop_front() {
305        if label_changed >= relabeling_freq {
306            global_relabel(adj, &mut labels, &matching, types, smaller_set, n);
307            label_changed = 0;
308        }
309
310        let mut best_u: i64 = -1;
311        let mut best_label: i64 = 2 * n as i64;
312
313        for &nb in &adj[v] {
314            let nb_usize = nb as usize;
315            if labels[nb_usize] < best_label {
316                best_u = i64::from(nb);
317                best_label = labels[nb_usize];
318                label_changed += 1;
319            }
320        }
321
322        if best_label < n as i64 {
323            let u = best_u as usize;
324            labels[v] = labels[u] + 1;
325            if matching[u] != -1 {
326                let w = matching[u] as usize;
327                if w != v {
328                    matching[u] = -1;
329                    matching[w] = -1;
330                    q.push_back(w);
331                    num_matched -= 1;
332                }
333            }
334            matching[u] = v as i64;
335            matching[v] = u as i64;
336            num_matched += 1;
337            labels[u] += 2;
338            label_changed += 1;
339        }
340    }
341
342    let result: Vec<Option<u32>> = matching
343        .iter()
344        .map(|&m| if m < 0 { None } else { Some(m as u32) })
345        .collect();
346
347    Ok((result, num_matched))
348}
349
350fn global_relabel(
351    adj: &[Vec<u32>],
352    labels: &mut [i64],
353    matching: &[i64],
354    types: &[bool],
355    smaller_set: bool,
356    n: usize,
357) {
358    labels.fill(n as i64);
359
360    let mut q: VecDeque<usize> = VecDeque::new();
361    for i in 0..n {
362        if types[i] != smaller_set && matching[i] == -1 {
363            q.push_back(i);
364            labels[i] = 0;
365        }
366    }
367
368    while let Some(v) = q.pop_front() {
369        for &nb in &adj[v] {
370            let w = nb as usize;
371            if labels[w] == n as i64 {
372                labels[w] = labels[v] + 1;
373                let matched_to = matching[w];
374                if matched_to != -1 {
375                    let mt = matched_to as usize;
376                    if labels[mt] == n as i64 {
377                        q.push_back(mt);
378                        labels[mt] = labels[w] + 1;
379                    }
380                }
381            }
382        }
383    }
384}
385
386// ── Hungarian (weighted) ────────────────────────────────────────────
387
388fn hungarian(
389    graph: &Graph,
390    types: &[bool],
391    weights: &[f64],
392    eps: f64,
393    n: usize,
394    ne: usize,
395) -> IgraphResult<MatchingResult> {
396    // Build incidence list: for each vertex, list of (edge_id, other_vertex)
397    let mut incidence: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
398    let mut edges: Vec<(u32, u32)> = Vec::with_capacity(ne);
399    for eid in 0..ne {
400        let (u, v) = graph.edge(eid as u32)?;
401        edges.push((u, v));
402        incidence[u as usize].push((eid as u32, v));
403        if u != v {
404            incidence[v as usize].push((eid as u32, u));
405        }
406    }
407
408    // Find smaller and larger sets
409    let count_false = types[..n].iter().filter(|&&t| !t).count();
410    let smaller_set_type = count_false > n / 2;
411    let smaller_set_size = if smaller_set_type {
412        n - count_false
413    } else {
414        count_false
415    };
416
417    let mut smaller_set: Vec<usize> = Vec::with_capacity(smaller_set_size);
418    let mut larger_set: Vec<usize> = Vec::with_capacity(n - smaller_set_size);
419    for (i, &tp) in types[..n].iter().enumerate() {
420        if tp == smaller_set_type {
421            smaller_set.push(i);
422        } else {
423            larger_set.push(i);
424        }
425    }
426
427    // Initial labeling: for each vertex in smaller set, label = max incident weight
428    let mut labels: Vec<f64> = vec![0.0; n];
429    for (i, &tp) in types[..n].iter().enumerate() {
430        if tp != smaller_set_type {
431            continue;
432        }
433        let mut max_w: f64 = 0.0;
434        for &(eid, other) in &incidence[i] {
435            if types[other as usize] == types[i] {
436                return Err(IgraphError::InvalidArgument(
437                    "Graph is not bipartite with supplied types vector".into(),
438                ));
439            }
440            if weights[eid as usize] > max_w {
441                max_w = weights[eid as usize];
442            }
443        }
444        labels[i] = max_w;
445    }
446
447    // Compute initial slack and tight edges
448    let mut slack: Vec<f64> = vec![0.0; ne];
449    let mut tight_edges: Vec<(u32, u32)> = Vec::new();
450    for eid in 0..ne {
451        let (u, v) = edges[eid];
452        slack[eid] = labels[u as usize] + labels[v as usize] - weights[eid];
453        if slack[eid] <= eps {
454            tight_edges.push((u, v));
455        }
456    }
457
458    // Build initial matching on tight edges using push-relabel
459    let tight_graph = crate::algorithms::constructors::create::create(
460        &tight_edges.iter().map(|&(a, b)| (a, b)).collect::<Vec<_>>(),
461        n as u32,
462        false,
463    )?;
464    let tight_adj = build_undirected_adj(&tight_graph);
465    let (init_match_opt, mut msize) = push_relabel_unweighted(&tight_graph, &tight_adj, types, n)?;
466    let mut matching: Vec<i64> = init_match_opt
467        .iter()
468        .map(|o| match o {
469            Some(v) => i64::from(*v),
470            None => -1,
471        })
472        .collect();
473
474    // Tight phantom edges adjacency (sorted for binary search)
475    let mut tight_phantom: Vec<Vec<usize>> = vec![Vec::new(); n];
476
477    // Main Hungarian loop
478    while msize < smaller_set_size {
479        let mut parent: Vec<i64> = vec![-1; n];
480        let mut reachable_smaller: Vec<usize> = Vec::new();
481        let mut reachable_larger: Vec<usize> = Vec::new();
482
483        // Fill queue with unmatched vertices from smaller set
484        let mut q: VecDeque<usize> = VecDeque::new();
485        for &s in &smaller_set {
486            if matching[s] == -1 {
487                q.push_back(s);
488                parent[s] = s as i64;
489                reachable_smaller.push(s);
490            }
491        }
492
493        // BFS along tight edges
494        let mut alternating_path_endpoint: i64 = -1;
495        'bfs: while let Some(v) = q.pop_front() {
496            // Real tight edges
497            for &(eid, other) in &incidence[v] {
498                let u = other as usize;
499                if slack[eid as usize] > eps {
500                    continue;
501                }
502                if parent[u] >= 0 {
503                    continue;
504                }
505                parent[u] = v as i64;
506                reachable_larger.push(u);
507                let w = matching[u];
508                if w == -1 {
509                    alternating_path_endpoint = u as i64;
510                    break 'bfs;
511                }
512                let w_usize = w as usize;
513                q.push_back(w_usize);
514                parent[w_usize] = u as i64;
515                reachable_smaller.push(w_usize);
516            }
517
518            // Tight phantom edges
519            for &u in &tight_phantom[v] {
520                if parent[u] >= 0 {
521                    continue;
522                }
523                if (labels[v] + labels[u]).abs() > eps {
524                    continue;
525                }
526                parent[u] = v as i64;
527                reachable_larger.push(u);
528                let w = matching[u];
529                if w == -1 {
530                    alternating_path_endpoint = u as i64;
531                    break 'bfs;
532                }
533                let w_usize = w as usize;
534                q.push_back(w_usize);
535                parent[w_usize] = u as i64;
536                reachable_smaller.push(w_usize);
537            }
538        }
539
540        if alternating_path_endpoint != -1 {
541            // Augment along alternating path
542            let mut v = alternating_path_endpoint as usize;
543            let mut u = parent[v] as usize;
544            while u != v {
545                let w = matching[v];
546                if w != -1 {
547                    matching[w as usize] = -1;
548                }
549                matching[v] = u as i64;
550                let w2 = matching[u];
551                if w2 != -1 {
552                    matching[w2 as usize] = -1;
553                }
554                matching[u] = v as i64;
555
556                v = parent[u] as usize;
557                u = parent[v] as usize;
558            }
559            msize += 1;
560            continue;
561        }
562
563        // No augmenting path found — update labels
564        // Find minimum slack between reachable smaller-set and unreachable larger-set
565
566        // Upper bound from phantom edges
567        let mut min_label_larger = f64::INFINITY;
568        for &l in &larger_set {
569            if labels[l] < min_label_larger {
570                min_label_larger = labels[l];
571            }
572        }
573        let mut min_label_reachable_smaller = f64::INFINITY;
574        for &s in &reachable_smaller {
575            if parent[s] >= 0 && labels[s] < min_label_reachable_smaller {
576                min_label_reachable_smaller = labels[s];
577            }
578        }
579        let mut min_slack = min_label_larger + min_label_reachable_smaller;
580
581        // Check real edges
582        for &u in &reachable_smaller {
583            for &(eid, other) in &incidence[u] {
584                let v_node = other as usize;
585                if parent[v_node] >= 0 {
586                    continue;
587                }
588                if slack[eid as usize] < min_slack {
589                    min_slack = slack[eid as usize];
590                }
591            }
592        }
593
594        if min_slack > 0.0 {
595            // Update labels and slack
596            for &u in &reachable_smaller {
597                labels[u] -= min_slack;
598                for &(eid, _) in &incidence[u] {
599                    slack[eid as usize] -= min_slack;
600                }
601            }
602            for &u in &reachable_larger {
603                labels[u] += min_slack;
604                for &(eid, _) in &incidence[u] {
605                    slack[eid as usize] += min_slack;
606                }
607            }
608        }
609
610        // Update tight phantom edges
611        for &u in &smaller_set {
612            for &v in &larger_set {
613                if (labels[u] + labels[v]).abs() <= eps {
614                    let phantoms = &mut tight_phantom[u];
615                    match phantoms.binary_search(&v) {
616                        Ok(_) => {} // already present
617                        Err(pos) => phantoms.insert(pos, v),
618                    }
619                }
620            }
621        }
622    }
623
624    // Remove phantom matches
625    for &u in &smaller_set {
626        let v = matching[u];
627        if v != -1 {
628            let v_usize = v as usize;
629            if tight_phantom[u].binary_search(&v_usize).is_ok() {
630                // Check if this is a real edge or phantom
631                let is_real = incidence[u]
632                    .iter()
633                    .any(|&(_, other)| other as usize == v_usize);
634                if !is_real {
635                    matching[u] = -1;
636                    matching[v_usize] = -1;
637                    msize -= 1;
638                }
639            }
640        }
641    }
642
643    // Compute matching weight
644    let mut total_weight: f64 = 0.0;
645    for eid in 0..ne {
646        if slack[eid] <= eps {
647            let (u, v) = edges[eid];
648            if matching[u as usize] == i64::from(v) {
649                total_weight += weights[eid];
650            }
651        }
652    }
653
654    let result_matching: Vec<Option<u32>> = matching
655        .iter()
656        .map(|&m| if m < 0 { None } else { Some(m as u32) })
657        .collect();
658
659    Ok(MatchingResult {
660        matching_size: msize,
661        matching_weight: total_weight,
662        matching: result_matching,
663    })
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use crate::algorithms::constructors::create::create;
670
671    fn make_k22() -> (Graph, Vec<bool>) {
672        let g = create(&[(0, 2), (0, 3), (1, 2), (1, 3)], 4, false).expect("K22");
673        let types = vec![false, false, true, true];
674        (g, types)
675    }
676
677    // ── is_matching ─────────────────────────────────────────────
678
679    #[test]
680    fn is_matching_valid() {
681        let (g, types) = make_k22();
682        let m = vec![Some(2), Some(3), Some(0), Some(1)];
683        assert!(is_matching(&g, Some(&types), &m).expect("ok"));
684    }
685
686    #[test]
687    fn is_matching_wrong_length() {
688        let (g, _) = make_k22();
689        let m = vec![Some(1), Some(0)];
690        assert!(!is_matching(&g, None, &m).expect("ok"));
691    }
692
693    #[test]
694    fn is_matching_non_mutual() {
695        let (g, _) = make_k22();
696        let m = vec![Some(2), None, None, None];
697        assert!(!is_matching(&g, None, &m).expect("ok"));
698    }
699
700    #[test]
701    fn is_matching_no_edge() {
702        let g = create(&[(0, 1)], 3, false).expect("ok");
703        // vertices 0 and 2 are not connected
704        let m = vec![Some(2), None, Some(0)];
705        assert!(!is_matching(&g, None, &m).expect("ok"));
706    }
707
708    #[test]
709    fn is_matching_all_unmatched_with_types() {
710        let g = create(&[(0, 1), (1, 2), (2, 3)], 4, false).expect("ok");
711        let types = vec![false, true, false, true];
712        let m = vec![None, None, None, None];
713        assert!(is_matching(&g, Some(&types), &m).expect("ok"));
714    }
715
716    #[test]
717    fn is_matching_types_same_partition() {
718        let g = create(&[(0, 1), (1, 2)], 3, false).expect("ok");
719        let types = vec![false, false, true]; // 0 and 1 same type but matched
720        let m = vec![Some(1), Some(0), None];
721        assert!(!is_matching(&g, Some(&types), &m).expect("ok"));
722    }
723
724    // ── is_maximal_matching ─────────────────────────────────────
725
726    #[test]
727    fn is_maximal_matching_true() {
728        let (g, types) = make_k22();
729        let m = vec![Some(2), Some(3), Some(0), Some(1)];
730        assert!(is_maximal_matching(&g, Some(&types), &m).expect("ok"));
731    }
732
733    #[test]
734    fn is_maximal_matching_false() {
735        let (g, types) = make_k22();
736        // Only 0-2 matched, but 1 and 3 are both unmatched and connected → not maximal
737        let m = vec![Some(2), None, Some(0), None];
738        assert!(!is_maximal_matching(&g, Some(&types), &m).expect("ok"));
739    }
740
741    #[test]
742    fn is_maximal_all_unmatched_no_edges() {
743        let g = Graph::new(3, false).expect("ok");
744        let m = vec![None, None, None];
745        assert!(is_maximal_matching(&g, None, &m).expect("ok"));
746    }
747
748    // ── maximum_bipartite_matching ──────────────────────────────
749
750    #[test]
751    fn max_matching_k22() {
752        let (g, types) = make_k22();
753        let r = maximum_bipartite_matching(&g, &types).expect("ok");
754        assert_eq!(r.matching_size, 2);
755        assert!(is_maximal_matching(&g, Some(&types), &r.matching).expect("ok"));
756    }
757
758    #[test]
759    fn max_matching_empty() {
760        let g = Graph::new(0, false).expect("ok");
761        let types: Vec<bool> = vec![];
762        let r = maximum_bipartite_matching(&g, &types).expect("ok");
763        assert_eq!(r.matching_size, 0);
764    }
765
766    #[test]
767    fn max_matching_singleton() {
768        let g = Graph::new(1, false).expect("ok");
769        let types = vec![false];
770        let r = maximum_bipartite_matching(&g, &types).expect("ok");
771        assert_eq!(r.matching_size, 0);
772    }
773
774    #[test]
775    fn max_matching_path_4() {
776        // 0-1-2-3, bipartite with types [F,T,F,T]
777        let g = create(&[(0, 1), (1, 2), (2, 3)], 4, false).expect("ok");
778        let types = vec![false, true, false, true];
779        let r = maximum_bipartite_matching(&g, &types).expect("ok");
780        assert_eq!(r.matching_size, 2);
781        assert!(is_maximal_matching(&g, Some(&types), &r.matching).expect("ok"));
782    }
783
784    #[test]
785    fn max_matching_star() {
786        // Star: 0 connected to 1,2,3,4
787        let g = create(&[(0, 1), (0, 2), (0, 3), (0, 4)], 5, false).expect("ok");
788        let types = vec![false, true, true, true, true];
789        let r = maximum_bipartite_matching(&g, &types).expect("ok");
790        assert_eq!(r.matching_size, 1);
791        assert!(is_maximal_matching(&g, Some(&types), &r.matching).expect("ok"));
792    }
793
794    #[test]
795    fn max_matching_complete_bipartite_k33() {
796        let g = create(
797            &[
798                (0, 3),
799                (0, 4),
800                (0, 5),
801                (1, 3),
802                (1, 4),
803                (1, 5),
804                (2, 3),
805                (2, 4),
806                (2, 5),
807            ],
808            6,
809            false,
810        )
811        .expect("ok");
812        let types = vec![false, false, false, true, true, true];
813        let r = maximum_bipartite_matching(&g, &types).expect("ok");
814        assert_eq!(r.matching_size, 3);
815        assert!(is_maximal_matching(&g, Some(&types), &r.matching).expect("ok"));
816    }
817
818    #[test]
819    fn max_matching_not_bipartite_error() {
820        // Triangle: not bipartite
821        let g = create(&[(0, 1), (1, 2), (2, 0)], 3, false).expect("ok");
822        let types = vec![false, true, false]; // 0 and 2 connected but same type
823        let r = maximum_bipartite_matching(&g, &types);
824        assert!(r.is_err());
825    }
826
827    #[test]
828    fn max_matching_disconnected() {
829        // Two disconnected edges
830        let g = create(&[(0, 1), (2, 3)], 4, false).expect("ok");
831        let types = vec![false, true, false, true];
832        let r = maximum_bipartite_matching(&g, &types).expect("ok");
833        assert_eq!(r.matching_size, 2);
834    }
835
836    #[test]
837    fn max_matching_types_too_short() {
838        let g = create(&[(0, 1)], 2, false).expect("ok");
839        let types = vec![false];
840        let r = maximum_bipartite_matching(&g, &types);
841        assert!(r.is_err());
842    }
843
844    // ── maximum_bipartite_matching_weighted ──────────────────────
845
846    #[test]
847    fn weighted_matching_simple() {
848        // K2,2 with weights: prefer 0-3 and 1-2
849        let g = create(&[(0, 2), (0, 3), (1, 2), (1, 3)], 4, false).expect("ok");
850        let types = vec![false, false, true, true];
851        let weights = vec![1.0, 10.0, 10.0, 1.0];
852        let r = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).expect("ok");
853        assert_eq!(r.matching_size, 2);
854        assert!((r.matching_weight - 20.0).abs() < 1e-9);
855    }
856
857    #[test]
858    fn weighted_matching_mit_notes() {
859        // Test graph from MIT lecture notes on matching
860        // 10 vertices: 0-4 in set A, 5-9 in set B
861        let g = create(
862            &[
863                (0, 6),
864                (0, 7),
865                (0, 8),
866                (0, 9),
867                (1, 5),
868                (1, 6),
869                (1, 7),
870                (1, 8),
871                (1, 9),
872                (2, 5),
873                (2, 6),
874                (2, 7),
875                (2, 8),
876                (2, 9),
877                (3, 5),
878                (3, 7),
879                (3, 9),
880                (4, 7),
881            ],
882            10,
883            false,
884        )
885        .expect("ok");
886        let types: Vec<bool> = (0..10).map(|i| i >= 5).collect();
887        let weights = vec![
888            2.0, 7.0, 2.0, 3.0, // edges from 0
889            1.0, 3.0, 9.0, 3.0, 3.0, // edges from 1
890            1.0, 3.0, 3.0, 1.0, 2.0, // edges from 2
891            4.0, 1.0, 2.0, // edges from 3
892            3.0, // edge from 4
893        ];
894        let r = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).expect("ok");
895        assert_eq!(r.matching_size, 4);
896        assert!((r.matching_weight - 19.0).abs() < 1e-9);
897        assert!(is_maximal_matching(&g, Some(&types), &r.matching).expect("ok"));
898    }
899
900    #[test]
901    fn weighted_matching_generated_case1() {
902        let g = create(&[(0, 8), (2, 7), (3, 7), (3, 8), (4, 5), (4, 9)], 10, false).expect("ok");
903        let types: Vec<bool> = (0..10).map(|i| i >= 5).collect();
904        let weights = vec![8.0, 5.0, 9.0, 18.0, 20.0, 13.0];
905        let r = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).expect("ok");
906        assert!((r.matching_weight - 43.0).abs() < 1e-9);
907    }
908
909    #[test]
910    fn weighted_matching_generated_case2() {
911        let g = create(&[(0, 5), (0, 6), (1, 7), (2, 5), (3, 5), (3, 9)], 10, false).expect("ok");
912        let types: Vec<bool> = (0..10).map(|i| i >= 5).collect();
913        let weights = vec![20.0, 4.0, 20.0, 3.0, 13.0, 1.0];
914        let r = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).expect("ok");
915        assert!((r.matching_weight - 41.0).abs() < 1e-9);
916    }
917
918    #[test]
919    fn weighted_matching_empty() {
920        let g = Graph::new(0, false).expect("ok");
921        let r = maximum_bipartite_matching_weighted(&g, &[], &[], 0.0).expect("ok");
922        assert_eq!(r.matching_size, 0);
923    }
924
925    #[test]
926    fn weighted_matching_no_edges() {
927        let g = Graph::new(4, false).expect("ok");
928        let types = vec![false, false, true, true];
929        let r = maximum_bipartite_matching_weighted(&g, &types, &[], 0.0).expect("ok");
930        assert_eq!(r.matching_size, 0);
931    }
932
933    // ── proptest ────────────────────────────────────────────────
934
935    #[cfg(all(test, feature = "proptest-harness"))]
936    mod proptests {
937        use super::*;
938        use proptest::prelude::*;
939
940        fn arb_bipartite_graph(
941            max_a: u32,
942            max_b: u32,
943        ) -> impl Strategy<Value = (Graph, Vec<bool>)> {
944            (1..=max_a, 1..=max_b).prop_flat_map(move |(a, b)| {
945                let pool = (a as usize) * (b as usize);
946                let mask_len = pool.min(20);
947                proptest::collection::vec(proptest::bool::ANY, mask_len).prop_map(move |mask| {
948                    let n = a + b;
949                    let mut edges = Vec::new();
950                    for (idx, &present) in mask.iter().enumerate() {
951                        if present {
952                            let u = (idx as u32) / b;
953                            let v = a + (idx as u32) % b;
954                            edges.push((u, v));
955                        }
956                    }
957                    let g = create(&edges, n, false).expect("bipartite graph");
958                    let types: Vec<bool> = (0..n).map(|i| i >= a).collect();
959                    (g, types)
960                })
961            })
962        }
963
964        proptest! {
965            #[test]
966            fn matching_is_valid((g, types) in arb_bipartite_graph(6, 6)) {
967                let r = maximum_bipartite_matching(&g, &types).expect("ok");
968                prop_assert!(is_matching(&g, Some(&types), &r.matching).expect("ok"));
969                prop_assert!(is_maximal_matching(&g, Some(&types), &r.matching).expect("ok"));
970            }
971
972            #[test]
973            fn matching_size_leq_min_partition(
974                (g, types) in arb_bipartite_graph(6, 6)
975            ) {
976                let r = maximum_bipartite_matching(&g, &types).expect("ok");
977                let a_size = types.iter().filter(|&&t| !t).count();
978                let b_size = types.iter().filter(|&&t| t).count();
979                prop_assert!(r.matching_size <= a_size.min(b_size));
980            }
981
982            #[test]
983            fn matching_size_leq_ecount(
984                (g, types) in arb_bipartite_graph(6, 6)
985            ) {
986                let r = maximum_bipartite_matching(&g, &types).expect("ok");
987                prop_assert!(r.matching_size <= g.ecount());
988            }
989
990            #[test]
991            fn weighted_matching_is_valid((g, types) in arb_bipartite_graph(5, 5)) {
992                let ne = g.ecount();
993                let weights: Vec<f64> = (0..ne).map(|i| (i as f64) + 1.0).collect();
994                if ne > 0 {
995                    let r = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).expect("ok");
996                    prop_assert!(is_matching(&g, Some(&types), &r.matching).expect("ok"));
997                }
998            }
999
1000            #[test]
1001            fn weighted_geq_unweighted_unit(
1002                (g, types) in arb_bipartite_graph(5, 5)
1003            ) {
1004                let unw = maximum_bipartite_matching(&g, &types).expect("ok");
1005                let ne = g.ecount();
1006                let weights: Vec<f64> = vec![1.0; ne];
1007                if ne > 0 {
1008                    let w = maximum_bipartite_matching_weighted(&g, &types, &weights, 0.0).expect("ok");
1009                    // With unit weights, weighted should find same size or better
1010                    prop_assert!(w.matching_size >= unw.matching_size.saturating_sub(1),
1011                        "weighted: {}, unweighted: {}", w.matching_size, unw.matching_size);
1012                }
1013            }
1014        }
1015    }
1016}