Skip to main content

scirs2_graph/algorithms/
matching.rs

1//! Graph matching algorithms
2//!
3//! This module contains algorithms for finding matchings in graphs,
4//! particularly bipartite matchings.
5
6use crate::algorithms::connectivity::is_bipartite;
7use crate::base::{EdgeWeight, Graph, IndexType, Node};
8use crate::error::{GraphError, Result};
9use std::collections::{HashMap, HashSet};
10use std::hash::Hash;
11
12/// Maximum bipartite matching result
13#[derive(Debug, Clone)]
14pub struct BipartiteMatching<N: Node> {
15    /// The matching as a map from left nodes to right nodes
16    pub matching: HashMap<N, N>,
17    /// The size of the matching
18    pub size: usize,
19}
20
21/// Finds a maximum bipartite matching using the Hungarian algorithm
22///
23/// Assumes the graph is bipartite with nodes already colored.
24///
25/// # Arguments
26/// * `graph` - The bipartite graph
27/// * `coloring` - The bipartite coloring (0 or 1 for each node)
28///
29/// # Returns
30/// * A maximum bipartite matching
31#[allow(dead_code)]
32pub fn maximum_bipartite_matching<N, E, Ix>(
33    graph: &Graph<N, E, Ix>,
34    coloring: &HashMap<N, u8>,
35) -> BipartiteMatching<N>
36where
37    N: Node + std::fmt::Debug,
38    E: EdgeWeight,
39    Ix: petgraph::graph::IndexType,
40{
41    // Create a mapping from nodes to indices
42    let mut node_to_idx: HashMap<N, petgraph::graph::NodeIndex<Ix>> = HashMap::new();
43    for node_idx in graph.inner().node_indices() {
44        node_to_idx.insert(graph.inner()[node_idx].clone(), node_idx);
45    }
46
47    // Separate nodes into left and right sets based on coloring
48    let mut left_nodes = Vec::new();
49    let mut right_nodes = Vec::new();
50
51    for (node, &color) in coloring {
52        if color == 0 {
53            left_nodes.push(node.clone());
54        } else {
55            right_nodes.push(node.clone());
56        }
57    }
58
59    // Build matching using augmenting paths
60    let mut matching: HashMap<N, N> = HashMap::new();
61    let mut reverse_matching: HashMap<N, N> = HashMap::new();
62
63    // For each unmatched left node, try to find an augmenting path
64    for left_node in &left_nodes {
65        if !matching.contains_key(left_node) {
66            let mut visited = HashSet::new();
67            augment_path(
68                graph,
69                left_node,
70                &mut matching,
71                &mut reverse_matching,
72                &mut visited,
73                coloring,
74            );
75        }
76    }
77
78    BipartiteMatching {
79        size: matching.len(),
80        matching,
81    }
82}
83
84/// Try to find an augmenting path from an unmatched left node
85#[allow(dead_code)]
86fn augment_path<N, E, Ix>(
87    graph: &Graph<N, E, Ix>,
88    node: &N,
89    matching: &mut HashMap<N, N>,
90    reverse_matching: &mut HashMap<N, N>,
91    visited: &mut HashSet<N>,
92    coloring: &HashMap<N, u8>,
93) -> bool
94where
95    N: Node + std::fmt::Debug,
96    E: EdgeWeight,
97    Ix: petgraph::graph::IndexType,
98{
99    // Mark as visited
100    visited.insert(node.clone());
101
102    // Try all neighbors
103    if let Ok(neighbors) = graph.neighbors(node) {
104        for neighbor in neighbors {
105            // Skip if same color (not bipartite edge)
106            if coloring.get(node) == coloring.get(&neighbor) {
107                continue;
108            }
109
110            // If neighbor is unmatched, we found an augmenting path
111            if let std::collections::hash_map::Entry::Vacant(e) =
112                reverse_matching.entry(neighbor.clone())
113            {
114                matching.insert(node.clone(), neighbor.clone());
115                e.insert(node.clone());
116                return true;
117            }
118
119            // Otherwise, try to augment through the matched node
120            let matched_node = reverse_matching[&neighbor].clone();
121            if !visited.contains(&matched_node)
122                && augment_path(
123                    graph,
124                    &matched_node,
125                    matching,
126                    reverse_matching,
127                    visited,
128                    coloring,
129                )
130            {
131                matching.insert(node.clone(), neighbor.clone());
132                reverse_matching.insert(neighbor, node.clone());
133                return true;
134            }
135        }
136    }
137
138    false
139}
140
141/// Minimum weight bipartite matching using the exact O(n^3) Hungarian algorithm
142///
143/// Finds the minimum weight perfect matching in a bipartite graph, exactly,
144/// for graphs of any size (there is no approximate fallback: every left node
145/// must have an edge into a distinct right node, or this returns an error
146/// rather than a non-optimal or partial matching).
147/// Returns the total weight and the matching as a vector of (left_node, right_node) pairs.
148#[allow(dead_code)]
149pub fn minimum_weight_bipartite_matching<N, E, Ix>(
150    graph: &Graph<N, E, Ix>,
151) -> Result<(f64, Vec<(N, N)>)>
152where
153    N: Node + Clone + Hash + Eq + std::fmt::Debug,
154    E: EdgeWeight + Into<f64> + Clone,
155    Ix: IndexType,
156{
157    // First check if the graph is bipartite
158    let bipartite_result = is_bipartite(graph);
159
160    if !bipartite_result.is_bipartite {
161        return Err(GraphError::InvalidGraph(
162            "Graph is not bipartite".to_string(),
163        ));
164    }
165
166    let coloring = bipartite_result.coloring;
167
168    // Separate nodes by color
169    let mut left_nodes = Vec::new();
170    let mut right_nodes = Vec::new();
171
172    for (node, &color) in &coloring {
173        if color == 0 {
174            left_nodes.push(node.clone());
175        } else {
176            right_nodes.push(node.clone());
177        }
178    }
179
180    let n_left = left_nodes.len();
181    let n_right = right_nodes.len();
182
183    if n_left != n_right {
184        return Err(GraphError::InvalidGraph(
185            "Bipartite graph must have equal number of nodes in each partition for perfect matching".to_string()
186        ));
187    }
188
189    if n_left == 0 {
190        return Ok((0.0, vec![]));
191    }
192
193    // Create cost matrix
194    let mut cost_matrix = vec![vec![f64::INFINITY; n_right]; n_left];
195
196    for (i, left_node) in left_nodes.iter().enumerate() {
197        for (j, right_node) in right_nodes.iter().enumerate() {
198            if let Ok(weight) = graph.edge_weight(left_node, right_node) {
199                cost_matrix[i][j] = weight.into();
200            }
201        }
202    }
203
204    // Solve the assignment problem exactly with the O(n^3) Hungarian
205    // algorithm for every size (previously this silently switched to a
206    // non-optimal greedy heuristic for n_left > 6, with no indication in the
207    // return type that the answer was only approximate).
208    let assignment = hungarian_algorithm(&cost_matrix).map_err(|e| {
209        GraphError::InvalidGraph(format!(
210            "minimum_weight_bipartite_matching: {e} (graph may be missing edges needed for a perfect matching)"
211        ))
212    })?;
213
214    let mut total_cost = 0.0;
215    let mut matching = Vec::with_capacity(n_left);
216    for (j, &row_1indexed) in assignment.iter().enumerate().skip(1) {
217        let i = row_1indexed - 1;
218        let cost = cost_matrix[i][j - 1];
219        if !cost.is_finite() {
220            return Err(GraphError::InvalidGraph(
221                "minimum_weight_bipartite_matching: no perfect matching exists using only real edges".to_string(),
222            ));
223        }
224        total_cost += cost;
225        matching.push((left_nodes[i].clone(), right_nodes[j - 1].clone()));
226    }
227
228    Ok((total_cost, matching))
229}
230
231/// Solves the square assignment problem (minimum weight perfect matching)
232/// via the O(n^3) Hungarian algorithm (Kuhn-Munkres, using row/column
233/// potentials and shortest augmenting paths). `cost[i][j]` is the cost of
234/// assigning row `i` to column `j`; use `f64::INFINITY` for a forbidden
235/// assignment (no edge).
236///
237/// Returns `p` where `p[j]` (for `j` in `1..=n`; `p[0]` is unused scratch
238/// space) is the 1-indexed row assigned to column `j`.
239///
240/// This closely follows the standard reference algorithm for the assignment
241/// problem (e.g. <https://cp-algorithms.com/graph/hungarian-algorithm.html>),
242/// adapted to Rust and to gracefully reject infeasible instances (rather
243/// than assuming a complete cost matrix with no forbidden assignments).
244#[allow(clippy::needless_range_loop)]
245fn hungarian_algorithm(cost: &[Vec<f64>]) -> std::result::Result<Vec<usize>, String> {
246    let n = cost.len();
247    if n == 0 {
248        return Ok(vec![0]);
249    }
250
251    // 1-indexed throughout (index 0 is the algorithm's sentinel/dummy row
252    // and column), matching the reference implementation closely to
253    // minimize transcription risk in this notoriously fiddly algorithm.
254    let mut u = vec![0.0_f64; n + 1];
255    let mut v = vec![0.0_f64; n + 1];
256    let mut p = vec![0usize; n + 1]; // p[j] = row assigned to column j, 0 = none yet
257    let mut way = vec![0usize; n + 1];
258
259    for i in 1..=n {
260        p[0] = i;
261        let mut j0 = 0usize;
262        let mut minv = vec![f64::INFINITY; n + 1];
263        let mut used = vec![false; n + 1];
264
265        loop {
266            used[j0] = true;
267            let i0 = p[j0];
268            let mut delta = f64::INFINITY;
269            let mut j1 = 0usize;
270
271            for j in 1..=n {
272                if !used[j] {
273                    let cur = cost[i0 - 1][j - 1] - u[i0] - v[j];
274                    if cur < minv[j] {
275                        minv[j] = cur;
276                        way[j] = j0;
277                    }
278                    if minv[j] < delta {
279                        delta = minv[j];
280                        j1 = j;
281                    }
282                }
283            }
284
285            if !delta.is_finite() {
286                // Every unused column is unreachable through a finite-cost
287                // edge from the rows visited so far: no perfect matching
288                // exists using only real edges.
289                return Err("no feasible perfect matching exists".to_string());
290            }
291
292            for j in 0..=n {
293                if used[j] {
294                    u[p[j]] += delta;
295                    v[j] -= delta;
296                } else {
297                    minv[j] -= delta;
298                }
299            }
300
301            j0 = j1;
302            if p[j0] == 0 {
303                break;
304            }
305        }
306
307        loop {
308            let j1 = way[j0];
309            p[j0] = p[j1];
310            j0 = j1;
311            if j0 == 0 {
312                break;
313            }
314        }
315    }
316
317    Ok(p)
318}
319
320#[allow(dead_code)]
321fn minimum_weight_matching_bruteforce<N>(
322    left_nodes: &[N],
323    right_nodes: &[N],
324    cost_matrix: &[Vec<f64>],
325) -> Result<(f64, Vec<(N, N)>)>
326where
327    N: Node + Clone + std::fmt::Debug,
328{
329    let n = left_nodes.len();
330    let mut best_cost = f64::INFINITY;
331    let mut best_matching = Vec::new();
332
333    // Generate all permutations
334    let mut perm: Vec<usize> = (0..n).collect();
335
336    loop {
337        // Calculate cost for this permutation
338        let mut cost = 0.0;
339        for i in 0..n {
340            cost += cost_matrix[i][perm[i]];
341        }
342
343        if cost < best_cost {
344            best_cost = cost;
345            best_matching = (0..n)
346                .map(|i| (left_nodes[i].clone(), right_nodes[perm[i]].clone()))
347                .collect();
348        }
349
350        // Next permutation
351        if !next_permutation(&mut perm) {
352            break;
353        }
354    }
355
356    Ok((best_cost, best_matching))
357}
358
359#[allow(dead_code)]
360fn next_permutation(perm: &mut [usize]) -> bool {
361    let n = perm.len();
362
363    // Find the largest index k such that perm[k] < perm[k + 1]
364    let mut k = None;
365    for i in 0..n - 1 {
366        if perm[i] < perm[i + 1] {
367            k = Some(i);
368        }
369    }
370
371    let k = match k {
372        Some(k) => k,
373        None => return false, // Last permutation
374    };
375
376    // Find the largest index l greater than k such that perm[k] < perm[l]
377    let mut l = k + 1;
378    for i in k + 1..n {
379        if perm[k] < perm[i] {
380            l = i;
381        }
382    }
383
384    // Swap perm[k] and perm[l]
385    perm.swap(k, l);
386
387    // Reverse the sequence from perm[k + 1] to the end
388    perm[k + 1..].reverse();
389
390    true
391}
392
393/// Maximum cardinality matching result
394#[derive(Debug, Clone)]
395pub struct MaximumMatching<N: Node> {
396    /// The matching as a vector of edge pairs
397    pub matching: Vec<(N, N)>,
398    /// The size of the matching
399    pub size: usize,
400}
401
402/// Finds a maximum cardinality matching in a general graph using Edmonds' blossom algorithm
403///
404/// This is a simplified implementation of the blossom algorithm for general graphs.
405/// For better performance on bipartite graphs, use `maximum_bipartite_matching`.
406///
407/// # Arguments
408/// * `graph` - The input graph
409///
410/// # Returns
411/// * A maximum cardinality matching
412#[allow(dead_code)]
413pub fn maximum_cardinality_matching<N, E, Ix>(graph: &Graph<N, E, Ix>) -> MaximumMatching<N>
414where
415    N: Node + Clone + std::fmt::Debug,
416    E: EdgeWeight,
417    Ix: IndexType,
418{
419    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
420    let n = nodes.len();
421
422    if n == 0 {
423        return MaximumMatching {
424            matching: Vec::new(),
425            size: 0,
426        };
427    }
428
429    // Use a greedy approach for simplicity
430    // A full implementation would use Edmonds' blossom algorithm
431    let mut matching = Vec::new();
432    let mut matched = vec![false; n];
433    let node_to_idx: HashMap<N, usize> = nodes
434        .iter()
435        .enumerate()
436        .map(|(i, n)| (n.clone(), i))
437        .collect();
438
439    // Greedy matching: find augmenting paths
440    for (i, node) in nodes.iter().enumerate() {
441        if matched[i] {
442            continue;
443        }
444
445        if let Ok(neighbors) = graph.neighbors(node) {
446            for neighbor in neighbors {
447                if let Some(&j) = node_to_idx.get(&neighbor) {
448                    if !matched[j] {
449                        // Found an augmenting path of length 1
450                        matching.push((node.clone(), neighbor));
451                        matched[i] = true;
452                        matched[j] = true;
453                        break;
454                    }
455                }
456            }
457        }
458    }
459
460    MaximumMatching {
461        size: matching.len(),
462        matching,
463    }
464}
465
466/// Finds a maximal matching using a greedy algorithm
467///
468/// A maximal matching is one where no more edges can be added.
469/// This is simpler than maximum matching but provides a 2-approximation.
470///
471/// # Arguments
472/// * `graph` - The input graph
473///
474/// # Returns
475/// * A maximal matching
476#[allow(dead_code)]
477pub fn maximal_matching<N, E, Ix>(graph: &Graph<N, E, Ix>) -> MaximumMatching<N>
478where
479    N: Node + Clone + std::fmt::Debug,
480    E: EdgeWeight,
481    Ix: IndexType,
482{
483    let mut matching = Vec::new();
484    let mut matched_nodes = HashSet::new();
485
486    // Get all edges
487    let edges = graph.edges();
488
489    // Greedily add edges that don't conflict with existing matching
490    for edge in edges {
491        if !matched_nodes.contains(&edge.source) && !matched_nodes.contains(&edge.target) {
492            matching.push((edge.source.clone(), edge.target.clone()));
493            matched_nodes.insert(edge.source);
494            matched_nodes.insert(edge.target);
495        }
496    }
497
498    MaximumMatching {
499        size: matching.len(),
500        matching,
501    }
502}
503
504/// Stable marriage problem solver using the Gale-Shapley algorithm
505///
506/// Finds a stable matching between two sets of equal size where each element
507/// has a preference order over the other set.
508///
509/// # Arguments
510/// * `left_prefs` - Preference lists for left set (each list is ordered from most to least preferred)
511/// * `right_prefs` - Preference lists for right set
512///
513/// # Returns
514/// * A stable matching as pairs (left_index, right_index)
515#[allow(dead_code)]
516pub fn stable_marriage(
517    left_prefs: &[Vec<usize>],
518    right_prefs: &[Vec<usize>],
519) -> Result<Vec<(usize, usize)>> {
520    let n = left_prefs.len();
521
522    if n != right_prefs.len() {
523        return Err(GraphError::InvalidGraph(
524            "Left and right sets must have equal size".to_string(),
525        ));
526    }
527
528    if n == 0 {
529        return Ok(Vec::new());
530    }
531
532    // Validate preference lists
533    for (i, prefs) in left_prefs.iter().enumerate() {
534        if prefs.len() != n {
535            return Err(GraphError::InvalidGraph(format!(
536                "Left preference list {i} has wrong length"
537            )));
538        }
539        let mut sorted_prefs = prefs.clone();
540        sorted_prefs.sort_unstable();
541        if sorted_prefs != (0..n).collect::<Vec<_>>() {
542            return Err(GraphError::InvalidGraph(format!(
543                "Left preference list {i} is not a valid permutation"
544            )));
545        }
546    }
547
548    for (i, prefs) in right_prefs.iter().enumerate() {
549        if prefs.len() != n {
550            return Err(GraphError::InvalidGraph(format!(
551                "Right preference list {i} has wrong length"
552            )));
553        }
554        let mut sorted_prefs = prefs.clone();
555        sorted_prefs.sort_unstable();
556        if sorted_prefs != (0..n).collect::<Vec<_>>() {
557            return Err(GraphError::InvalidGraph(format!(
558                "Right preference list {i} is not a valid permutation"
559            )));
560        }
561    }
562
563    // Create inverse preference mappings for right set for efficiency
564    let mut right_inv_prefs = vec![vec![0; n]; n];
565    for (i, prefs) in right_prefs.iter().enumerate() {
566        for (rank, &person) in prefs.iter().enumerate() {
567            right_inv_prefs[i][person] = rank;
568        }
569    }
570
571    // Gale-Shapley algorithm
572    let mut left_partner = vec![None; n];
573    let mut right_partner = vec![None; n];
574    let mut left_next_proposal = vec![0; n];
575    let mut free_left: std::collections::VecDeque<usize> = (0..n).collect();
576
577    while let Some(left) = free_left.pop_front() {
578        if left_next_proposal[left] >= n {
579            continue; // This left person has proposed to everyone
580        }
581
582        let right = left_prefs[left][left_next_proposal[left]];
583        left_next_proposal[left] += 1;
584
585        match right_partner[right] {
586            None => {
587                // Right person is free, form engagement
588                left_partner[left] = Some(right);
589                right_partner[right] = Some(left);
590            }
591            Some(current_left) => {
592                // Right person is engaged, check if they prefer the new proposal
593                if right_inv_prefs[right][left] < right_inv_prefs[right][current_left] {
594                    // Right person prefers the new proposal
595                    left_partner[left] = Some(right);
596                    right_partner[right] = Some(left);
597                    left_partner[current_left] = None;
598                    free_left.push_back(current_left);
599                } else {
600                    // Right person prefers their current partner
601                    free_left.push_back(left);
602                }
603            }
604        }
605    }
606
607    // Convert to result format
608    let mut result = Vec::new();
609    for (left, partner) in left_partner.iter().enumerate() {
610        if let Some(right) = partner {
611            result.push((left, *right));
612        }
613    }
614
615    Ok(result)
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::error::Result as GraphResult;
622    use crate::generators::create_graph;
623
624    #[test]
625    fn test_maximum_bipartite_matching() -> GraphResult<()> {
626        let mut graph = create_graph::<&str, ()>();
627
628        // Create a bipartite graph
629        graph.add_edge("A", "1", ())?;
630        graph.add_edge("A", "2", ())?;
631        graph.add_edge("B", "2", ())?;
632        graph.add_edge("B", "3", ())?;
633        graph.add_edge("C", "3", ())?;
634
635        // Create coloring
636        let mut coloring = HashMap::new();
637        coloring.insert("A", 0);
638        coloring.insert("B", 0);
639        coloring.insert("C", 0);
640        coloring.insert("1", 1);
641        coloring.insert("2", 1);
642        coloring.insert("3", 1);
643
644        let matching = maximum_bipartite_matching(&graph, &coloring);
645
646        // Should find a perfect matching of size 3
647        assert_eq!(matching.size, 3);
648
649        // Verify it's a valid matching
650        let mut used_right = HashSet::new();
651        for right in matching.matching.values() {
652            assert!(!used_right.contains(right));
653            used_right.insert(right);
654        }
655
656        Ok(())
657    }
658
659    #[test]
660    fn test_minimum_weight_bipartite_matching() -> GraphResult<()> {
661        let mut graph = create_graph::<&str, f64>();
662
663        // Create a complete bipartite graph K2,2
664        graph.add_edge("A", "1", 1.0)?;
665        graph.add_edge("A", "2", 3.0)?;
666        graph.add_edge("B", "1", 2.0)?;
667        graph.add_edge("B", "2", 1.0)?;
668
669        let (total_weight, matching) = minimum_weight_bipartite_matching(&graph)?;
670
671        // Optimal matching: A-1 (1.0) and B-2 (1.0)
672        assert_eq!(total_weight, 2.0);
673        assert_eq!(matching.len(), 2);
674
675        Ok(())
676    }
677
678    #[test]
679    fn test_hungarian_matches_bruteforce_on_random_instances() {
680        // Cross-check the real Hungarian algorithm against the brute-force
681        // exact solver (feasible up to n=6) on many random, non-constant
682        // cost matrices. A deterministic LCG (no external RNG dependency)
683        // keeps this reproducible while still exercising genuine variety.
684        let mut state: u64 = 0x1234_5678_9abc_def0;
685
686        for n in 1..=6usize {
687            for trial in 0..20u32 {
688                let mut cost_matrix = vec![vec![0.0_f64; n]; n];
689                for row in cost_matrix.iter_mut() {
690                    for cell in row.iter_mut() {
691                        state = state
692                            .wrapping_mul(6364136223846793005)
693                            .wrapping_add(1442695040888963407);
694                        *cell = ((state >> 11) as f64 / (1u64 << 53) as f64) * 100.0;
695                    }
696                }
697
698                let left_nodes: Vec<usize> = (0..n).collect();
699                let right_nodes: Vec<usize> = (0..n).collect();
700
701                let (bruteforce_cost, _) =
702                    minimum_weight_matching_bruteforce(&left_nodes, &right_nodes, &cost_matrix)
703                        .expect("bruteforce failed");
704                let assignment = hungarian_algorithm(&cost_matrix).expect("hungarian failed");
705                let hungarian_cost: f64 =
706                    (1..=n).map(|j| cost_matrix[assignment[j] - 1][j - 1]).sum();
707
708                assert!(
709                    (bruteforce_cost - hungarian_cost).abs() < 1e-6,
710                    "n={n} trial={trial}: hungarian cost {hungarian_cost} should match bruteforce {bruteforce_cost}"
711                );
712            }
713        }
714    }
715
716    #[test]
717    fn test_minimum_weight_bipartite_matching_large_finds_true_optimum() {
718        // n_left = 7 (> 6): the OLD implementation silently substituted a
719        // non-optimal greedy heuristic for any instance past this size.
720        // This is a classic "greedy fails" case: greedily grabbing each left
721        // node's locally-cheapest still-free right node gives (0,10)=1 +
722        // (1,11)=3 = 4 for the first pair, whereas the TRUE optimum swaps
723        // them: (0,11)=2 + (1,10)=1 = 3. The remaining five pairs are
724        // free (cost 0) padding that just keeps n_left > 6 while leaving the
725        // graph a clean disjoint union of small bipartite pieces.
726        let mut graph = create_graph::<i32, f64>();
727        graph.add_edge(0, 10, 1.0).expect("Operation failed");
728        graph.add_edge(0, 11, 2.0).expect("Operation failed");
729        graph.add_edge(1, 10, 1.0).expect("Operation failed");
730        graph.add_edge(1, 11, 3.0).expect("Operation failed");
731        graph.add_edge(2, 12, 0.0).expect("Operation failed");
732        graph.add_edge(3, 13, 0.0).expect("Operation failed");
733        graph.add_edge(4, 14, 0.0).expect("Operation failed");
734        graph.add_edge(5, 15, 0.0).expect("Operation failed");
735        graph.add_edge(6, 16, 0.0).expect("Operation failed");
736
737        let (total_weight, matching) =
738            minimum_weight_bipartite_matching(&graph).expect("matching failed");
739
740        assert_eq!(matching.len(), 7);
741        assert!(
742            (total_weight - 3.0).abs() < 1e-9,
743            "expected the true optimum 3.0 (not the greedy-suboptimal 4.0), got {total_weight}"
744        );
745    }
746
747    #[test]
748    fn test_minimum_weight_bipartite_matching_infeasible_returns_error() {
749        // Node 0 has no edge at all into the right partition, so no perfect
750        // matching can exist -- this must be reported as an error, never a
751        // silently wrong/partial matching.
752        let mut graph = create_graph::<i32, f64>();
753        graph.add_edge(0, 10, 1.0).expect("Operation failed");
754        graph.add_node(1);
755        graph.add_edge(2, 11, 1.0).expect("Operation failed");
756
757        assert!(minimum_weight_bipartite_matching(&graph).is_err());
758    }
759
760    #[test]
761    fn test_hungarian_algorithm_detects_infeasible_instance() {
762        // Rows 0 and 1 can BOTH only be assigned to column 0 (Hall's
763        // marriage condition is violated for the subset {0, 1}), so no
764        // perfect assignment exists even though every row has at least one
765        // finite-cost entry. The solver must reject this outright rather
766        // than corrupt its potentials or silently return a partial/invalid
767        // assignment.
768        let inf = f64::INFINITY;
769        let cost_matrix = vec![
770            vec![1.0, inf, inf],
771            vec![1.0, inf, inf],
772            vec![inf, 1.0, 1.0],
773        ];
774
775        assert!(hungarian_algorithm(&cost_matrix).is_err());
776    }
777
778    #[test]
779    fn test_maximum_cardinality_matching() {
780        let mut graph = create_graph::<&str, ()>();
781
782        // Create a simple graph
783        graph.add_edge("A", "B", ()).expect("Operation failed");
784        graph.add_edge("C", "D", ()).expect("Operation failed");
785        graph.add_edge("E", "F", ()).expect("Operation failed");
786
787        let matching = maximum_cardinality_matching(&graph);
788
789        // Should find a matching of size 3
790        assert_eq!(matching.size, 3);
791        assert_eq!(matching.matching.len(), 3);
792
793        // Verify no node is matched twice
794        let mut matched_nodes = HashSet::new();
795        for (u, v) in &matching.matching {
796            assert!(!matched_nodes.contains(u));
797            assert!(!matched_nodes.contains(v));
798            matched_nodes.insert(u);
799            matched_nodes.insert(v);
800        }
801    }
802
803    #[test]
804    fn test_maximal_matching() {
805        let mut graph = create_graph::<i32, ()>();
806
807        // Create a triangle
808        graph.add_edge(1, 2, ()).expect("Operation failed");
809        graph.add_edge(2, 3, ()).expect("Operation failed");
810        graph.add_edge(3, 1, ()).expect("Operation failed");
811
812        let matching = maximal_matching(&graph);
813
814        // Should find at least one edge (maximal for triangle is 1)
815        assert_eq!(matching.size, 1);
816        assert_eq!(matching.matching.len(), 1);
817
818        // Verify it's a valid matching
819        let mut matched_nodes = HashSet::new();
820        for (u, v) in &matching.matching {
821            assert!(!matched_nodes.contains(u));
822            assert!(!matched_nodes.contains(v));
823            matched_nodes.insert(u);
824            matched_nodes.insert(v);
825        }
826    }
827
828    #[test]
829    fn test_stable_marriage() -> GraphResult<()> {
830        // Example: 3 people on each side
831        let left_prefs = vec![
832            vec![0, 1, 2], // Person 0 prefers 0, then 1, then 2
833            vec![1, 0, 2], // Person 1 prefers 1, then 0, then 2
834            vec![0, 1, 2], // Person 2 prefers 0, then 1, then 2
835        ];
836
837        let right_prefs = vec![
838            vec![2, 1, 0], // Person 0 prefers 2, then 1, then 0
839            vec![0, 2, 1], // Person 1 prefers 0, then 2, then 1
840            vec![0, 1, 2], // Person 2 prefers 0, then 1, then 2
841        ];
842
843        let matching = stable_marriage(&left_prefs, &right_prefs)?;
844
845        // Should have 3 pairs
846        assert_eq!(matching.len(), 3);
847
848        // Verify it's a complete matching
849        let mut matched_left = HashSet::new();
850        let mut matched_right = HashSet::new();
851        for (left, right) in &matching {
852            assert!(!matched_left.contains(left));
853            assert!(!matched_right.contains(right));
854            matched_left.insert(*left);
855            matched_right.insert(*right);
856        }
857
858        Ok(())
859    }
860
861    #[test]
862    fn test_stable_marriage_empty() -> GraphResult<()> {
863        let left_prefs: Vec<Vec<usize>> = vec![];
864        let right_prefs: Vec<Vec<usize>> = vec![];
865
866        let matching = stable_marriage(&left_prefs, &right_prefs)?;
867        assert_eq!(matching.len(), 0);
868
869        Ok(())
870    }
871
872    #[test]
873    fn test_stable_marriage_invalid_input() {
874        // Mismatched sizes
875        let left_prefs = vec![vec![0]];
876        let right_prefs = vec![vec![0], vec![1]];
877
878        assert!(stable_marriage(&left_prefs, &right_prefs).is_err());
879
880        // Invalid preference list
881        let left_prefs = vec![vec![0, 0]]; // Duplicate
882        let right_prefs = vec![vec![0, 1]];
883
884        assert!(stable_marriage(&left_prefs, &right_prefs).is_err());
885    }
886}