Skip to main content

rivrs_sparse/ordering/
metis.rs

1//! Ordering algorithms for symmetric sparse matrices.
2//!
3//! This module provides fill-reducing permutations for symmetric sparse matrices:
4//!
5//! - [`metis_ordering()`] — METIS nested dissection on the raw sparsity pattern
6//! - [`match_order_metis()`] — Combined MC64 matching + METIS ordering with cycle
7//!   condensation, guaranteeing matched 2-cycle pairs are adjacent in the
8//!   elimination order (matching+METIS mode)
9//!
10//! The resulting permutations integrate with
11//! [`AptpSymbolic::analyze()`](crate::symmetric::AptpSymbolic::analyze) via
12//! [`SymmetricOrdering::Custom`](faer::sparse::linalg::cholesky::SymmetricOrdering::Custom).
13//!
14//! # Algorithm References
15//!
16//! - Karypis & Kumar (1998), "A Fast and High Quality Multilevel Scheme for
17//!   Partitioning Irregular Graphs", SIAM J. Sci. Comput. 20(1)
18//! - George (1973), "Nested Dissection of a Regular Finite Element Mesh",
19//!   SIAM J. Numer. Anal. 10(2)
20//! - Hogg & Scott (2013), HSL_MC80 — cycle condensation approach
21//! - Duff & Koster (2001), Algorithm MPD — MC64 matching
22//! - Duff & Pralet (2005) — MC64SYM symmetric scaling
23//! - SPRAL `match_order.f90` (BSD-3) — reference implementation
24//!
25//! # Implementation Notes
26//!
27//! Uses `metis-sys` for raw FFI bindings to METIS 5.x (vendored C source,
28//! compiled via `cc`). The `METIS_NodeND` function computes nested dissection
29//! orderings on undirected graphs extracted from the matrix sparsity pattern.
30
31use faer::perm::Perm;
32use faer::sparse::{SparseColMat, SymbolicSparseColMatRef};
33
34use super::matching::{Mc64Job, mc64_matching};
35use crate::error::SparseError;
36
37/// Result of the combined matching-ordering pipeline.
38///
39/// Contains both the fill-reducing ordering (for symbolic analysis) and
40/// the MC64 scaling factors (for numeric factorization), plus diagnostics
41/// about the condensation process.
42///
43/// # Usage
44///
45/// ```
46/// use rivrs_sparse::ordering::match_order_metis;
47/// use faer::sparse::{SparseColMat, Triplet};
48///
49/// let triplets = vec![
50///     Triplet::new(0, 0, 4.0),
51///     Triplet::new(0, 1, 1.0),
52///     Triplet::new(1, 0, 1.0),
53///     Triplet::new(1, 1, 3.0),
54/// ];
55/// let matrix = SparseColMat::try_new_from_triplets(2, 2, &triplets).unwrap();
56///
57/// let result = match_order_metis(&matrix).unwrap();
58/// assert_eq!(result.scaling.len(), 2);
59/// // Use ordering for symbolic analysis:
60/// // SymmetricOrdering::Custom(result.ordering.as_ref())
61/// ```
62pub struct MatchOrderResult {
63    /// Fill-reducing permutation with matched pair adjacency guarantee.
64    /// Use with `SymmetricOrdering::Custom(result.ordering.as_ref())`.
65    pub ordering: Perm<usize>,
66
67    /// MC64 symmetric scaling factors (linear domain).
68    /// Apply as `A_scaled[i,j] = scaling[i] * A[i,j] * scaling[j]`.
69    pub scaling: Vec<f64>,
70
71    /// Number of matched entries from MC64.
72    /// Equals n for structurally nonsingular matrices.
73    pub matched: usize,
74
75    /// Dimension of the condensed graph passed to METIS.
76    /// Strictly less than n when 2-cycles exist.
77    pub condensed_dim: usize,
78
79    /// Number of singleton nodes (self-matched).
80    pub singletons: usize,
81
82    /// Number of 2-cycle pairs in the decomposed matching.
83    pub two_cycles: usize,
84}
85
86/// Compute a combined MC64 matching + METIS ordering with cycle condensation.
87///
88/// Combined matching and ordering pipeline:
89/// 1. MC64 matching → scaling + matching permutation
90/// 2. Cycle splitting → decompose matching into singletons and 2-cycles
91/// 3. Condensed graph → fuse 2-cycle pairs into single super-nodes
92/// 4. METIS ordering → fill-reducing ordering on condensed graph
93/// 5. Expansion → map back to original indices with pair adjacency
94///
95/// Matched 2-cycle pairs are guaranteed to occupy consecutive positions
96/// in the output ordering, making them natural candidates for 2x2 pivots
97/// in APTP factorization.
98///
99/// # Arguments
100///
101/// * `matrix` — Sparse symmetric matrix in upper-triangular CSC format.
102///   Must be square. Numeric values required (used by MC64).
103///
104/// # Returns
105///
106/// * `Ok(MatchOrderResult)` — Ordering, scaling, and diagnostics.
107/// * `Err(SparseError::NotSquare)` — Matrix is not square.
108/// * `Err(SparseError::InvalidInput)` — Zero dimension or non-finite entries.
109/// * `Err(SparseError::AnalysisFailure)` — METIS or MC64 internal failure.
110///
111/// # Algorithm References
112///
113/// - Hogg & Scott (2013), HSL_MC80 — cycle condensation approach
114/// - Duff & Koster (2001), Algorithm MPD — MC64 matching
115/// - Duff & Pralet (2005) — MC64SYM symmetric scaling
116/// - SPRAL `match_order.f90` (BSD-3) — reference implementation
117// SPRAL Equivalent: `spral_match_order::match_order_metis` with `options%ordering = 2` in SSIDS.
118pub fn match_order_metis(
119    matrix: &SparseColMat<usize, f64>,
120) -> Result<MatchOrderResult, SparseError> {
121    let n = matrix.nrows();
122
123    // Trivial case: dim 0 or 1 — return identity ordering with empty matching info
124    if n <= 1 {
125        let scaling = if n == 1 { vec![1.0] } else { vec![] };
126        return Ok(MatchOrderResult {
127            ordering: identity_perm(n),
128            scaling,
129            matched: n,
130            condensed_dim: n,
131            singletons: n,
132            two_cycles: 0,
133        });
134    }
135
136    // Step 1: MC64 matching
137    let mc64_result = mc64_matching(matrix, Mc64Job::MaximumProduct)?;
138
139    // Step 2: Cycle decomposition
140    let matching_fwd = mc64_result.matching.as_ref().arrays().0;
141    let decomp = split_matching_cycles(matching_fwd, &mc64_result.is_matched, n);
142
143    // Step 3: Build condensed adjacency graph
144    let (mut xadj, mut adjncy) = build_condensed_adjacency(matrix.symbolic(), &decomp)?;
145
146    // Step 4: METIS on condensed graph
147    let cdim = decomp.condensed_dim;
148    let condensed_order = if cdim <= 1 || adjncy.is_empty() {
149        // Trivial condensed graph — identity ordering
150        (0..cdim as i32).collect::<Vec<_>>()
151    } else {
152        if cdim > i32::MAX as usize {
153            return Err(SparseError::InvalidInput {
154                reason: format!(
155                    "Condensed dimension {} exceeds i32::MAX; METIS requires 32-bit indices",
156                    cdim
157                ),
158            });
159        }
160
161        // METIS iperm: iperm[old_idx] = new_pos (this is the "order" we need)
162        let (_perm, iperm) = call_metis_node_nd(cdim, &mut xadj, &mut adjncy)?;
163        iperm
164    };
165
166    // Step 5: Expand ordering
167    let ordering = expand_ordering(&condensed_order, &decomp, n);
168
169    Ok(MatchOrderResult {
170        ordering,
171        scaling: mc64_result.scaling,
172        matched: mc64_result.matched,
173        condensed_dim: decomp.condensed_dim,
174        singletons: decomp.singletons,
175        two_cycles: decomp.two_cycles,
176    })
177}
178
179/// Compute a METIS nested dissection fill-reducing ordering for a symmetric sparse matrix.
180///
181/// Extracts the adjacency graph from the matrix's sparsity pattern and calls METIS
182/// to compute a fill-reducing permutation via multilevel nested dissection.
183///
184/// The returned permutation is suitable for use with
185/// [`AptpSymbolic::analyze()`](crate::symmetric::AptpSymbolic::analyze) via
186/// [`SymmetricOrdering::Custom`](faer::sparse::linalg::cholesky::SymmetricOrdering::Custom).
187///
188/// # Arguments
189///
190/// * `matrix` — Symbolic sparsity pattern of a symmetric sparse matrix.
191///   Only the structural pattern is used; numerical values are ignored.
192///   May contain upper triangle, lower triangle, or full symmetric structure.
193///
194/// # Returns
195///
196/// A fill-reducing permutation as `Perm<usize>`.
197///
198/// # Errors
199///
200/// Returns [`SparseError::InvalidInput`] if the matrix dimension exceeds `i32::MAX`.
201/// Returns [`SparseError::AnalysisFailure`] if METIS reports an internal error.
202///
203/// # Algorithm References
204///
205/// - Karypis & Kumar (1998), "A Fast and High Quality Multilevel Scheme for
206///   Partitioning Irregular Graphs", SIAM J. Sci. Comput.
207/// - George (1973), "Nested Dissection of a Regular Finite Element Mesh"
208///
209/// # Examples
210///
211/// ```
212/// use rivrs_sparse::ordering::metis_ordering;
213/// use rivrs_sparse::symmetric::AptpSymbolic;
214/// use faer::sparse::linalg::cholesky::SymmetricOrdering;
215/// use faer::sparse::{SparseColMat, Triplet};
216///
217/// let triplets = vec![
218///     Triplet::new(0, 0, 4.0),
219///     Triplet::new(0, 1, 1.0),
220///     Triplet::new(1, 0, 1.0),
221///     Triplet::new(1, 1, 4.0),
222/// ];
223/// let matrix = SparseColMat::try_new_from_triplets(2, 2, &triplets).unwrap();
224///
225/// let perm = metis_ordering(matrix.symbolic()).expect("METIS ordering failed");
226/// let symbolic = AptpSymbolic::analyze(
227///     matrix.symbolic(),
228///     SymmetricOrdering::Custom(perm.as_ref()),
229/// ).expect("symbolic analysis failed");
230/// ```
231pub fn metis_ordering(
232    matrix: SymbolicSparseColMatRef<'_, usize>,
233) -> Result<Perm<usize>, SparseError> {
234    let n = matrix.nrows();
235
236    // Validate dimension fits in i32 (METIS uses idx_t = i32).
237    if n > i32::MAX as usize {
238        return Err(SparseError::InvalidInput {
239            reason: format!(
240                "Matrix dimension {} exceeds i32::MAX ({}); METIS requires 32-bit indices",
241                n,
242                i32::MAX
243            ),
244        });
245    }
246
247    // Trivial cases: dim 0 or 1 — return identity permutation without calling METIS.
248    if n <= 1 {
249        return Ok(identity_perm(n));
250    }
251
252    // Extract adjacency structure.
253    let (mut xadj, mut adjncy) = extract_adjacency(matrix)?;
254
255    // If no edges (diagonal matrix), return identity permutation.
256    if adjncy.is_empty() {
257        return Ok(identity_perm(n));
258    }
259
260    // Call METIS_NodeND via shared helper.
261    // METIS perm[new_pos] = old_idx  → faer forward array
262    // METIS iperm[old_idx] = new_pos → faer inverse array
263    let (perm_i32, iperm_i32) = call_metis_node_nd(n, &mut xadj, &mut adjncy)?;
264
265    let fwd: Box<[usize]> = perm_i32.iter().map(|&v| v as usize).collect();
266    let inv: Box<[usize]> = iperm_i32.iter().map(|&v| v as usize).collect();
267
268    Ok(Perm::new_checked(fwd, inv, n))
269}
270
271/// Result of decomposing an MC64 matching permutation into singletons and 2-cycles.
272///
273/// Longer cycles in the matching are split into 2-cycles plus at most one singleton
274/// (for odd-length cycles) by pairing consecutive cycle members.
275///
276/// Each matched index is mapped to a condensed super-node index, where 2-cycle pairs
277/// share the same super-node. Unmatched indices are excluded from the condensed graph.
278struct CycleDecomposition {
279    /// Per-node classification:
280    /// - `-2`: unmatched by MC64
281    /// - `-1`: singleton (matched to self)
282    /// - `>= 0`: partner index (forms a 2-cycle pair)
283    partner: Vec<isize>,
284
285    /// Original index → condensed super-node index.
286    /// Both members of a 2-cycle pair map to the same condensed index.
287    /// Unmatched indices have undefined values (not used).
288    old_to_new: Vec<usize>,
289
290    /// Condensed super-node index → first original index.
291    /// For singletons, this is the singleton index.
292    /// For 2-cycle pairs, this is one member (the other is found via `partner`).
293    new_to_old: Vec<usize>,
294
295    /// Number of condensed super-nodes (singletons + 2-cycle pairs, matched only).
296    condensed_dim: usize,
297
298    /// Count of singleton nodes (matched to self).
299    singletons: usize,
300
301    /// Count of 2-cycle pairs.
302    two_cycles: usize,
303}
304
305/// Decompose an MC64 matching into singletons, 2-cycles, and unmatched indices.
306///
307/// Walk each cycle in the matching permutation, pairing consecutive members into
308/// 2-cycles. Odd-length cycles produce one extra
309/// singleton. The `is_matched` slice distinguishes true singletons (matched, `fwd[i]==i`)
310/// from unmatched indices (assigned to arbitrary free columns by `build_singular_permutation`).
311///
312/// # Arguments
313///
314/// * `matching_fwd` — Forward permutation from MC64 matching (`Mc64Result.matching`)
315/// * `is_matched` — Per-index matched status from `Mc64Result.is_matched`
316/// * `n` — Matrix dimension
317fn split_matching_cycles(
318    matching_fwd: &[usize],
319    is_matched: &[bool],
320    n: usize,
321) -> CycleDecomposition {
322    let mut partner = vec![isize::MIN; n];
323    let mut old_to_new = vec![0usize; n];
324    let mut new_to_old = Vec::new();
325    let mut visited = vec![false; n];
326    let mut singletons = 0usize;
327    let mut two_cycles = 0usize;
328
329    // First pass: mark unmatched indices (visited, so cycle walker skips them)
330    for i in 0..n {
331        if !is_matched[i] {
332            partner[i] = -2;
333            visited[i] = true;
334        }
335    }
336
337    // Second pass: walk cycles, split into singletons + 2-cycles.
338    // All nodes get condensed indices (including unmatched, which become
339    // singleton condensed nodes for METIS).
340    let mut condensed_idx = 0usize;
341    for i in 0..n {
342        if visited[i] && partner[i] != -2 {
343            // Already processed as part of a matched cycle
344            continue;
345        }
346
347        // Unmatched node: assign singleton condensed index
348        if partner[i] == -2 {
349            old_to_new[i] = condensed_idx;
350            new_to_old.push(i);
351            condensed_idx += 1;
352            singletons += 1;
353            continue;
354        }
355
356        // Check for singleton (self-matched)
357        if matching_fwd[i] == i {
358            partner[i] = -1;
359            old_to_new[i] = condensed_idx;
360            new_to_old.push(i);
361            condensed_idx += 1;
362            singletons += 1;
363            visited[i] = true;
364            continue;
365        }
366
367        // Walk the cycle starting at i, pairing consecutive members
368        let mut j = i;
369        loop {
370            let k = matching_fwd[j];
371            if visited[k] || k == i {
372                // Odd cycle: j is the leftover — becomes singleton
373                if !visited[j] {
374                    partner[j] = -1;
375                    old_to_new[j] = condensed_idx;
376                    new_to_old.push(j);
377                    condensed_idx += 1;
378                    singletons += 1;
379                    visited[j] = true;
380                }
381                break;
382            }
383
384            // Pair j and k as a 2-cycle
385            partner[j] = k as isize;
386            partner[k] = j as isize;
387            old_to_new[j] = condensed_idx;
388            old_to_new[k] = condensed_idx;
389            new_to_old.push(j);
390            condensed_idx += 1;
391            two_cycles += 1;
392            visited[j] = true;
393            visited[k] = true;
394
395            // Advance past k
396            let next = matching_fwd[k];
397            if next == i {
398                break;
399            }
400            j = next;
401            // If j was already visited (e.g. an unmatched node from the first pass),
402            // the permutation cycle passes through a "hole" — stop walking here.
403            // The remaining matched indices beyond the hole will be picked up when
404            // the outer loop reaches them.
405            if visited[j] {
406                break;
407            }
408        }
409    }
410
411    CycleDecomposition {
412        partner,
413        old_to_new,
414        new_to_old,
415        condensed_dim: condensed_idx,
416        singletons,
417        two_cycles,
418    }
419}
420
421/// Build condensed CSR adjacency arrays (xadj, adjncy) for METIS from the
422/// original matrix sparsity pattern and cycle decomposition.
423///
424/// Each condensed super-node absorbs all edges from its constituent original
425/// indices. Duplicate edges and self-loops are removed using a marker array.
426/// The output is a full symmetric graph in METIS CSR format.
427///
428/// # Arguments
429///
430/// * `matrix` — Symbolic sparsity pattern of the original matrix
431/// * `decomp` — Cycle decomposition from `split_matching_cycles`
432fn build_condensed_adjacency(
433    matrix: SymbolicSparseColMatRef<'_, usize>,
434    decomp: &CycleDecomposition,
435) -> Result<(Vec<i32>, Vec<i32>), SparseError> {
436    let n = matrix.nrows();
437    let col_ptrs = matrix.col_ptr();
438    let row_indices = matrix.row_idx();
439    let cdim = decomp.condensed_dim;
440
441    // Build symmetric neighbor lists for condensed nodes using marker dedup
442    let mut neighbors: Vec<Vec<i32>> = vec![Vec::new(); cdim];
443    let mut marker = vec![usize::MAX; cdim]; // marker[cnode] = current source cnode
444
445    for col in 0..n {
446        let c_col = decomp.old_to_new[col];
447
448        // For 2-cycle second members: old_to_new maps to same condensed index
449        // as the first member. The marker dedup handles this — we process both
450        // columns of a pair, and all edges merge into the same condensed node.
451
452        // Mark self to avoid self-loops
453        marker[c_col] = c_col;
454
455        let start = col_ptrs[col];
456        let end = col_ptrs[col + 1];
457        for &row in &row_indices[start..end] {
458            let c_row = decomp.old_to_new[row];
459            if marker[c_row] != c_col {
460                marker[c_row] = c_col;
461                // Add edge in both directions (symmetric)
462                neighbors[c_col].push(c_row as i32);
463                neighbors[c_row].push(c_col as i32);
464            }
465        }
466    }
467
468    // Deduplicate (the bidirectional insertion can create duplicates when
469    // both col→row and row→col are visited from the original CSC)
470    for nbrs in &mut neighbors {
471        nbrs.sort_unstable();
472        nbrs.dedup();
473    }
474
475    // Validate total edges fit in i32
476    let total_edges: usize = neighbors.iter().map(|v| v.len()).sum();
477    if total_edges > i32::MAX as usize {
478        return Err(SparseError::InvalidInput {
479            reason: format!(
480                "Condensed adjacency entries {} exceeds i32::MAX",
481                total_edges
482            ),
483        });
484    }
485
486    // Build CSR arrays
487    let mut xadj = Vec::with_capacity(cdim + 1);
488    xadj.push(0i32);
489    let mut adjncy = Vec::with_capacity(total_edges);
490
491    for nbrs in &neighbors {
492        adjncy.extend_from_slice(nbrs);
493        xadj.push(adjncy.len() as i32);
494    }
495
496    Ok((xadj, adjncy))
497}
498
499/// Expand a condensed METIS ordering back to full-size permutation.
500///
501/// Maps each condensed super-node position to its original index(es).
502/// 2-cycle pairs get consecutive positions, preserving pair adjacency.
503/// Unmatched indices are appended at the end.
504///
505/// # Arguments
506///
507/// * `condensed_order` — METIS output: `condensed_order[i]` = position of condensed node `i`
508/// * `decomp` — Cycle decomposition from `split_matching_cycles`
509/// * `n` — Original matrix dimension
510fn expand_ordering(condensed_order: &[i32], decomp: &CycleDecomposition, n: usize) -> Perm<usize> {
511    let cdim = decomp.condensed_dim;
512
513    // Build inverse of METIS output: inv_order[position] = condensed_node
514    let mut inv_order = vec![0usize; cdim];
515    for (cnode, &pos) in condensed_order.iter().enumerate() {
516        inv_order[pos as usize] = cnode;
517    }
518
519    // Walk positions in order, emitting original indices
520    let mut fwd = Vec::with_capacity(n); // fwd[new_pos] = old_idx
521    for &cnode in &inv_order {
522        let orig = decomp.new_to_old[cnode];
523        fwd.push(orig);
524
525        // If this condensed node is a 2-cycle pair, emit partner next
526        if decomp.partner[orig] >= 0 {
527            fwd.push(decomp.partner[orig] as usize);
528        }
529    }
530
531    // All nodes (matched + unmatched) have condensed indices and are emitted
532    // by the loop above. Unmatched nodes are singleton condensed nodes placed
533    // at METIS-chosen positions, not appended at the end.
534    debug_assert_eq!(fwd.len(), n);
535
536    // Build inverse: inv[old_idx] = new_pos
537    let mut inv = vec![0usize; n];
538    for (new_pos, &old_idx) in fwd.iter().enumerate() {
539        inv[old_idx] = new_pos;
540    }
541
542    Perm::new_checked(fwd.into_boxed_slice(), inv.into_boxed_slice(), n)
543}
544
545/// Construct an identity permutation of dimension `n`.
546fn identity_perm(n: usize) -> Perm<usize> {
547    let id: Vec<usize> = (0..n).collect();
548    Perm::new_checked(id.clone().into_boxed_slice(), id.into_boxed_slice(), n)
549}
550
551/// Call METIS_NodeND on a CSR adjacency graph and return (perm, iperm) as i32 vectors.
552///
553/// Handles options setup, FFI call, and error mapping. The caller is responsible
554/// for validating that `n > 1`, `adjncy` is non-empty, and `n <= i32::MAX`.
555///
556/// Returns `(perm, iperm)` where:
557/// - `perm[new_pos] = old_idx` (forward permutation)
558/// - `iperm[old_idx] = new_pos` (inverse permutation)
559fn call_metis_node_nd(
560    n: usize,
561    xadj: &mut [i32],
562    adjncy: &mut [i32],
563) -> Result<(Vec<i32>, Vec<i32>), SparseError> {
564    let mut options = [0i32; metis_sys::METIS_NOPTIONS as usize];
565    unsafe {
566        metis_sys::METIS_SetDefaultOptions(options.as_mut_ptr());
567    }
568    options[metis_sys::moptions_et_METIS_OPTION_NUMBERING as usize] = 0;
569
570    let mut nvtxs = n as i32;
571    let mut perm_i32 = vec![0i32; n];
572    let mut iperm_i32 = vec![0i32; n];
573
574    let ret = unsafe {
575        metis_sys::METIS_NodeND(
576            &mut nvtxs,
577            xadj.as_mut_ptr(),
578            adjncy.as_mut_ptr(),
579            std::ptr::null_mut(),
580            options.as_mut_ptr(),
581            perm_i32.as_mut_ptr(),
582            iperm_i32.as_mut_ptr(),
583        )
584    };
585
586    if ret != metis_sys::rstatus_et_METIS_OK {
587        let msg = match ret {
588            x if x == metis_sys::rstatus_et_METIS_ERROR_INPUT => "METIS_ERROR_INPUT: invalid input",
589            x if x == metis_sys::rstatus_et_METIS_ERROR_MEMORY => {
590                "METIS_ERROR_MEMORY: allocation failure"
591            }
592            x if x == metis_sys::rstatus_et_METIS_ERROR => "METIS_ERROR: general error",
593            _ => "METIS: unknown error code",
594        };
595        return Err(SparseError::AnalysisFailure {
596            reason: format!("{} (return code: {}, dim: {})", msg, ret, n),
597        });
598    }
599
600    Ok((perm_i32, iperm_i32))
601}
602
603/// Extract CSR adjacency arrays (xadj, adjncy) from a symmetric sparse matrix.
604///
605/// Converts the CSC symbolic sparsity pattern into METIS's CSR graph format.
606/// Diagonal entries are excluded (no self-loops). The structural pattern is
607/// symmetrized: if entry (i, j) exists, entry (j, i) is also included.
608///
609/// # Arguments
610///
611/// * `matrix` — Symbolic CSC sparsity pattern of a symmetric sparse matrix.
612///
613/// # Returns
614///
615/// A tuple `(xadj, adjncy)` where:
616/// - `xadj` has length `n + 1` (row pointers)
617/// - `adjncy` has length `xadj[n]` (column indices of neighbors)
618///
619/// Both arrays use `i32` indices as required by METIS (`idx_t`).
620///
621/// # Errors
622///
623/// Returns [`SparseError::InvalidInput`] if the total number of adjacency entries
624/// exceeds `i32::MAX`.
625fn extract_adjacency(
626    matrix: SymbolicSparseColMatRef<'_, usize>,
627) -> Result<(Vec<i32>, Vec<i32>), SparseError> {
628    let n = matrix.nrows();
629    let col_ptrs = matrix.col_ptr();
630    let row_indices = matrix.row_idx();
631
632    // Step 1: Build a symmetric neighbor set for each vertex, excluding diagonal.
633    // Use Vec<Vec<usize>> rather than HashSet; sorted and deduped in step 2.
634    let mut neighbors: Vec<Vec<usize>> = vec![Vec::new(); n];
635
636    // The CSC structure gives us column j's row indices.
637    // For symmetric adjacency, entry (i, j) with i != j means both i→j and j→i.
638    for j in 0..n {
639        let start = col_ptrs[j];
640        let end = col_ptrs[j + 1];
641        for &i in &row_indices[start..end] {
642            if i != j {
643                neighbors[j].push(i);
644                neighbors[i].push(j);
645            }
646        }
647    }
648
649    // Step 2: Sort and deduplicate each neighbor list.
650    for nbrs in &mut neighbors {
651        nbrs.sort_unstable();
652        nbrs.dedup();
653    }
654
655    // Step 3: Validate total edge count fits in i32.
656    let total_edges: usize = neighbors.iter().map(|v| v.len()).sum();
657    if total_edges > i32::MAX as usize {
658        return Err(SparseError::InvalidInput {
659            reason: format!(
660                "Total adjacency entries {} exceeds i32::MAX ({}); METIS requires 32-bit indices",
661                total_edges,
662                i32::MAX
663            ),
664        });
665    }
666
667    // Step 4: Build CSR arrays (xadj, adjncy) with i32 indices.
668    let mut xadj = Vec::with_capacity(n + 1);
669    xadj.push(0i32);
670
671    let mut adjncy = Vec::with_capacity(total_edges);
672
673    for nbrs in &neighbors {
674        for &neighbor in nbrs {
675            adjncy.push(neighbor as i32);
676        }
677        xadj.push(adjncy.len() as i32);
678    }
679
680    Ok((xadj, adjncy))
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    use faer::sparse::{SparseColMat, Triplet};
688
689    /// Helper: create an n×n symmetric tridiagonal sparse matrix.
690    fn make_tridiagonal(n: usize) -> SparseColMat<usize, f64> {
691        let mut triplets = Vec::new();
692        for i in 0..n {
693            triplets.push(Triplet::new(i, i, 4.0));
694        }
695        for i in 0..n - 1 {
696            triplets.push(Triplet::new(i, i + 1, 1.0));
697            triplets.push(Triplet::new(i + 1, i, 1.0));
698        }
699        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
700    }
701
702    /// Helper: create an n×n diagonal-only sparse matrix.
703    fn make_diagonal(n: usize) -> SparseColMat<usize, f64> {
704        let triplets: Vec<_> = (0..n).map(|i| Triplet::new(i, i, (i + 1) as f64)).collect();
705        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
706    }
707
708    /// Helper: create an n×n arrow matrix (dense first row/column + diagonal).
709    fn make_arrow(n: usize) -> SparseColMat<usize, f64> {
710        let mut triplets = Vec::new();
711        for i in 0..n {
712            triplets.push(Triplet::new(i, i, 10.0));
713        }
714        for i in 1..n {
715            triplets.push(Triplet::new(0, i, 1.0));
716            triplets.push(Triplet::new(i, 0, 1.0));
717        }
718        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
719    }
720
721    /// Helper: create a matrix stored as upper triangle only.
722    fn make_upper_triangle_only(n: usize) -> SparseColMat<usize, f64> {
723        let mut triplets = Vec::new();
724        for i in 0..n {
725            triplets.push(Triplet::new(i, i, 4.0));
726        }
727        for i in 0..n - 1 {
728            // Only upper triangle: row < col
729            triplets.push(Triplet::new(i, i + 1, 1.0));
730        }
731        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
732    }
733
734    // ---- metis_ordering unit tests ----
735
736    #[test]
737    fn test_metis_ordering_tridiagonal_5() {
738        let matrix = make_tridiagonal(5);
739        let perm = metis_ordering(matrix.symbolic()).expect("METIS ordering should succeed");
740
741        // Verify valid permutation: every index 0..4 appears exactly once
742        let (fwd, inv) = perm.as_ref().arrays();
743        assert_eq!(fwd.len(), 5);
744        assert_eq!(inv.len(), 5);
745
746        let mut seen_fwd = [false; 5];
747        let mut seen_inv = [false; 5];
748        for i in 0..5 {
749            assert!(fwd[i] < 5, "fwd[{}] = {} out of range", i, fwd[i]);
750            assert!(inv[i] < 5, "inv[{}] = {} out of range", i, inv[i]);
751            seen_fwd[fwd[i]] = true;
752            seen_inv[inv[i]] = true;
753        }
754        assert!(
755            seen_fwd.iter().all(|&s| s),
756            "fwd is not a valid permutation"
757        );
758        assert!(
759            seen_inv.iter().all(|&s| s),
760            "inv is not a valid permutation"
761        );
762    }
763
764    #[test]
765    fn test_metis_ordering_fwd_inv_consistency() {
766        let matrix = make_tridiagonal(10);
767        let perm = metis_ordering(matrix.symbolic()).expect("METIS ordering should succeed");
768
769        let (fwd, inv) = perm.as_ref().arrays();
770        for i in 0..10 {
771            assert_eq!(fwd[inv[i]], i, "fwd[inv[{}]] = {} != {}", i, fwd[inv[i]], i);
772            assert_eq!(inv[fwd[i]], i, "inv[fwd[{}]] = {} != {}", i, inv[fwd[i]], i);
773        }
774    }
775
776    #[test]
777    fn test_metis_ordering_dim_0() {
778        let triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
779        let matrix = SparseColMat::try_new_from_triplets(0, 0, &triplets).unwrap();
780        let perm = metis_ordering(matrix.symbolic()).expect("dim-0 should succeed");
781        let (fwd, inv) = perm.as_ref().arrays();
782        assert_eq!(fwd.len(), 0);
783        assert_eq!(inv.len(), 0);
784    }
785
786    #[test]
787    fn test_metis_ordering_dim_1() {
788        let matrix = make_diagonal(1);
789        let perm = metis_ordering(matrix.symbolic()).expect("dim-1 should succeed");
790        let (fwd, inv) = perm.as_ref().arrays();
791        assert_eq!(fwd, &[0]);
792        assert_eq!(inv, &[0]);
793    }
794
795    #[test]
796    fn test_metis_ordering_diagonal_identity() {
797        // Diagonal matrix has no off-diagonal entries — should return identity perm
798        let matrix = make_diagonal(5);
799        let perm = metis_ordering(matrix.symbolic()).expect("diagonal should succeed");
800        let (fwd, inv) = perm.as_ref().arrays();
801        for i in 0..5 {
802            assert_eq!(
803                fwd[i], i,
804                "diagonal perm should be identity: fwd[{}] = {}",
805                i, fwd[i]
806            );
807            assert_eq!(
808                inv[i], i,
809                "diagonal perm should be identity: inv[{}] = {}",
810                i, inv[i]
811            );
812        }
813    }
814
815    // ---- extract_adjacency tests ----
816
817    #[test]
818    fn test_adjacency_tridiagonal_3x3() {
819        // 3x3 tridiagonal: edges 0-1, 1-2
820        let matrix = make_tridiagonal(3);
821        let (xadj, adjncy) = extract_adjacency(matrix.symbolic()).unwrap();
822
823        // Expected CSR adjacency (no diagonal):
824        // vertex 0: neighbors [1]
825        // vertex 1: neighbors [0, 2]
826        // vertex 2: neighbors [1]
827        assert_eq!(xadj, vec![0, 1, 3, 4]);
828        assert_eq!(adjncy.len(), 4);
829
830        // Check neighbors for each vertex
831        let n0: Vec<i32> = adjncy[xadj[0] as usize..xadj[1] as usize].to_vec();
832        let n1: Vec<i32> = adjncy[xadj[1] as usize..xadj[2] as usize].to_vec();
833        let n2: Vec<i32> = adjncy[xadj[2] as usize..xadj[3] as usize].to_vec();
834        assert_eq!(n0, vec![1]);
835        assert_eq!(n1, vec![0, 2]);
836        assert_eq!(n2, vec![1]);
837    }
838
839    #[test]
840    fn test_adjacency_arrow_5x5() {
841        // 5x5 arrow: vertex 0 connected to all others (star graph)
842        let matrix = make_arrow(5);
843        let (xadj, adjncy) = extract_adjacency(matrix.symbolic()).unwrap();
844
845        // vertex 0: neighbors [1, 2, 3, 4]
846        // vertex 1: neighbors [0]
847        // vertex 2: neighbors [0]
848        // vertex 3: neighbors [0]
849        // vertex 4: neighbors [0]
850        assert_eq!(xadj, vec![0, 4, 5, 6, 7, 8]);
851        assert_eq!(adjncy.len(), 8);
852
853        let n0: Vec<i32> = adjncy[xadj[0] as usize..xadj[1] as usize].to_vec();
854        assert_eq!(n0, vec![1, 2, 3, 4]);
855        for v in 1..5i32 {
856            let start = xadj[v as usize] as usize;
857            let end = xadj[v as usize + 1] as usize;
858            assert_eq!(
859                &adjncy[start..end],
860                &[0],
861                "vertex {} should only neighbor 0",
862                v
863            );
864        }
865    }
866
867    #[test]
868    fn test_adjacency_diagonal_only() {
869        // Diagonal matrix: no off-diagonal entries, empty adjacency
870        let matrix = make_diagonal(4);
871        let (xadj, adjncy) = extract_adjacency(matrix.symbolic()).unwrap();
872
873        assert_eq!(xadj, vec![0, 0, 0, 0, 0]);
874        assert!(adjncy.is_empty());
875    }
876
877    #[test]
878    fn test_adjacency_upper_triangle_only() {
879        // Upper-triangle-only input should produce symmetric adjacency output
880        let matrix = make_upper_triangle_only(4);
881        let (xadj, adjncy) = extract_adjacency(matrix.symbolic()).unwrap();
882
883        // Tridiag structure (edges 0-1, 1-2, 2-3) stored as upper-only
884        // Result should still be symmetric:
885        // vertex 0: [1]
886        // vertex 1: [0, 2]
887        // vertex 2: [1, 3]
888        // vertex 3: [2]
889        assert_eq!(xadj, vec![0, 1, 3, 5, 6]);
890        assert_eq!(adjncy.len(), 6);
891
892        // Verify symmetry: for each (i, j) in adjncy, also (j, i)
893        let n = 4;
894        for i in 0..n {
895            let start = xadj[i] as usize;
896            let end = xadj[i + 1] as usize;
897            for &j in &adjncy[start..end] {
898                let j_start = xadj[j as usize] as usize;
899                let j_end = xadj[j as usize + 1] as usize;
900                assert!(
901                    adjncy[j_start..j_end].contains(&(i as i32)),
902                    "symmetry violated: ({}, {}) exists but ({}, {}) does not",
903                    i,
904                    j,
905                    j,
906                    i
907                );
908            }
909        }
910    }
911
912    #[test]
913    fn test_adjacency_invariants() {
914        // Test structural invariants on various matrices
915        for (name, matrix) in [
916            ("tridiag3", make_tridiagonal(3)),
917            ("arrow5", make_arrow(5)),
918            ("diag4", make_diagonal(4)),
919            ("upper4", make_upper_triangle_only(4)),
920        ] {
921            let n = matrix.nrows();
922            let (xadj, adjncy) = extract_adjacency(matrix.symbolic()).unwrap();
923
924            // xadj[0] == 0
925            assert_eq!(xadj[0], 0, "{}: xadj[0] should be 0", name);
926
927            // xadj has length n+1
928            assert_eq!(xadj.len(), n + 1, "{}: xadj length should be n+1", name);
929
930            // xadj is monotonically non-decreasing
931            for i in 0..n {
932                assert!(
933                    xadj[i + 1] >= xadj[i],
934                    "{}: xadj not monotonic at {}: {} > {}",
935                    name,
936                    i,
937                    xadj[i],
938                    xadj[i + 1]
939                );
940            }
941
942            // No self-loops
943            for i in 0..n {
944                let start = xadj[i] as usize;
945                let end = xadj[i + 1] as usize;
946                for &j in &adjncy[start..end] {
947                    assert_ne!(j, i as i32, "{}: self-loop at vertex {}", name, i);
948                }
949            }
950
951            // All indices in [0, n)
952            for &j in &adjncy {
953                assert!(
954                    j >= 0 && (j as usize) < n,
955                    "{}: adjncy index {} out of range [0, {})",
956                    name,
957                    j,
958                    n
959                );
960            }
961
962            // Symmetry
963            for i in 0..n {
964                let start = xadj[i] as usize;
965                let end = xadj[i + 1] as usize;
966                for &j in &adjncy[start..end] {
967                    let j_start = xadj[j as usize] as usize;
968                    let j_end = xadj[j as usize + 1] as usize;
969                    assert!(
970                        adjncy[j_start..j_end].contains(&(i as i32)),
971                        "{}: symmetry violated: ({}, {}) without ({}, {})",
972                        name,
973                        i,
974                        j,
975                        j,
976                        i
977                    );
978                }
979            }
980        }
981    }
982
983    // ---- split_matching_cycles unit tests ----
984
985    #[test]
986    fn test_split_all_singletons() {
987        // Identity matching: fwd[i] = i, all matched
988        let fwd = vec![0, 1, 2, 3];
989        let is_matched = vec![true, true, true, true];
990        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
991
992        assert_eq!(decomp.singletons, 4);
993        assert_eq!(decomp.two_cycles, 0);
994        assert_eq!(decomp.condensed_dim, 4);
995        for i in 0..4 {
996            assert_eq!(decomp.partner[i], -1, "index {} should be singleton", i);
997        }
998    }
999
1000    #[test]
1001    fn test_split_pure_2_cycles() {
1002        // Two 2-cycles: 0↔1, 2↔3
1003        let fwd = vec![1, 0, 3, 2];
1004        let is_matched = vec![true, true, true, true];
1005        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1006
1007        assert_eq!(decomp.singletons, 0);
1008        assert_eq!(decomp.two_cycles, 2);
1009        assert_eq!(decomp.condensed_dim, 2);
1010        assert_eq!(decomp.partner[0], 1);
1011        assert_eq!(decomp.partner[1], 0);
1012        assert_eq!(decomp.partner[2], 3);
1013        assert_eq!(decomp.partner[3], 2);
1014        // Partners share condensed index
1015        assert_eq!(decomp.old_to_new[0], decomp.old_to_new[1]);
1016        assert_eq!(decomp.old_to_new[2], decomp.old_to_new[3]);
1017        assert_ne!(decomp.old_to_new[0], decomp.old_to_new[2]);
1018    }
1019
1020    #[test]
1021    fn test_split_3_cycle() {
1022        // 3-cycle: 0→1→2→0
1023        let fwd = vec![1, 2, 0];
1024        let is_matched = vec![true, true, true];
1025        let decomp = split_matching_cycles(&fwd, &is_matched, 3);
1026
1027        // Should split into 1 two-cycle + 1 singleton
1028        assert_eq!(decomp.two_cycles, 1);
1029        assert_eq!(decomp.singletons, 1);
1030        assert_eq!(decomp.condensed_dim, 2);
1031
1032        // Count partner types
1033        let pairs: Vec<_> = (0..3).filter(|&i| decomp.partner[i] >= 0).collect();
1034        let sings: Vec<_> = (0..3).filter(|&i| decomp.partner[i] == -1).collect();
1035        assert_eq!(pairs.len(), 2);
1036        assert_eq!(sings.len(), 1);
1037    }
1038
1039    #[test]
1040    fn test_split_4_cycle() {
1041        // 4-cycle: 0→1→2→3→0
1042        let fwd = vec![1, 2, 3, 0];
1043        let is_matched = vec![true, true, true, true];
1044        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1045
1046        // Should split into 2 two-cycles
1047        assert_eq!(decomp.two_cycles, 2);
1048        assert_eq!(decomp.singletons, 0);
1049        assert_eq!(decomp.condensed_dim, 2);
1050    }
1051
1052    #[test]
1053    fn test_split_5_cycle() {
1054        // 5-cycle: 0→1→2→3→4→0
1055        let fwd = vec![1, 2, 3, 4, 0];
1056        let is_matched = vec![true, true, true, true, true];
1057        let decomp = split_matching_cycles(&fwd, &is_matched, 5);
1058
1059        // Should split into 2 two-cycles + 1 singleton
1060        assert_eq!(decomp.two_cycles, 2);
1061        assert_eq!(decomp.singletons, 1);
1062        assert_eq!(decomp.condensed_dim, 3);
1063    }
1064
1065    #[test]
1066    fn test_split_mixed_with_unmatched() {
1067        // 6 nodes: 0↔1 (2-cycle), 2 singleton, 3↔4 (2-cycle), 5 unmatched
1068        let fwd = vec![1, 0, 2, 4, 3, 5]; // 5 maps to self but unmatched
1069        let is_matched = vec![true, true, true, true, true, false];
1070        let decomp = split_matching_cycles(&fwd, &is_matched, 6);
1071
1072        assert_eq!(decomp.two_cycles, 2);
1073        // Singletons include both matched singletons and unmatched-as-singletons
1074        assert_eq!(decomp.singletons, 2); // {2} matched singleton + {5} unmatched singleton
1075        assert_eq!(decomp.condensed_dim, 4); // All nodes: 2 pairs + 2 singletons
1076        assert_eq!(decomp.partner[5], -2); // Still tagged as unmatched
1077        assert_eq!(decomp.partner[2], -1); // Matched singleton
1078        // Unmatched node 5 has a condensed index
1079        assert!(
1080            decomp.old_to_new[5] < decomp.condensed_dim,
1081            "unmatched node should have valid condensed index"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_split_trivial_n0() {
1087        let decomp = split_matching_cycles(&[], &[], 0);
1088        assert_eq!(decomp.condensed_dim, 0);
1089        assert_eq!(decomp.singletons, 0);
1090        assert_eq!(decomp.two_cycles, 0);
1091    }
1092
1093    #[test]
1094    fn test_split_trivial_n1() {
1095        let decomp = split_matching_cycles(&[0], &[true], 1);
1096        assert_eq!(decomp.condensed_dim, 1);
1097        assert_eq!(decomp.singletons, 1);
1098        assert_eq!(decomp.two_cycles, 0);
1099        assert_eq!(decomp.partner[0], -1);
1100    }
1101
1102    // ---- build_condensed_adjacency unit tests ----
1103
1104    /// Helper: create a symmetric matrix from upper-triangular entries.
1105    fn make_upper_tri(n: usize, entries: &[(usize, usize, f64)]) -> SparseColMat<usize, f64> {
1106        let triplets: Vec<_> = entries
1107            .iter()
1108            .map(|&(i, j, v)| Triplet::new(i, j, v))
1109            .collect();
1110        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
1111    }
1112
1113    #[test]
1114    fn test_condensed_adjacency_arrow_with_2cycle() {
1115        // 4x4 arrow: 0 connected to all, plus 2-cycle {0,1}
1116        // After condensation with pair {0,1}: condensed has 3 nodes
1117        //   cnode 0 = {0,1} (pair), cnode 1 = {2}, cnode 2 = {3}
1118        // Edges: {0,1} connects to {2}, {3}; {2} connects to {0,1}; {3} connects to {0,1}
1119        let matrix = make_arrow(4);
1120
1121        // Simulate matching: 0↔1, 2=singleton, 3=singleton
1122        let fwd = vec![1, 0, 2, 3];
1123        let is_matched = vec![true, true, true, true];
1124        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1125
1126        assert_eq!(decomp.condensed_dim, 3);
1127
1128        let (xadj, adjncy) = build_condensed_adjacency(matrix.symbolic(), &decomp).unwrap();
1129
1130        // Condensed should have 3 nodes
1131        assert_eq!(xadj.len(), 4); // 3+1
1132
1133        // No self-loops
1134        for i in 0..3 {
1135            let start = xadj[i] as usize;
1136            let end = xadj[i + 1] as usize;
1137            for &j in &adjncy[start..end] {
1138                assert_ne!(j, i as i32, "self-loop at condensed vertex {}", i);
1139            }
1140        }
1141
1142        // Total edges should be deduplicated
1143        // The pair node {0,1} absorbs edges from both 0 and 1
1144        // No duplicates should remain
1145        let total = adjncy.len();
1146        assert!(total > 0, "condensed graph should have edges");
1147    }
1148
1149    #[test]
1150    fn test_condensed_adjacency_diagonal() {
1151        // Diagonal matrix: no off-diagonal edges
1152        let matrix = make_diagonal(4);
1153        let fwd = vec![0, 1, 2, 3];
1154        let is_matched = vec![true, true, true, true];
1155        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1156
1157        let (xadj, adjncy) = build_condensed_adjacency(matrix.symbolic(), &decomp).unwrap();
1158        assert!(
1159            adjncy.is_empty(),
1160            "diagonal should have zero condensed edges"
1161        );
1162        assert_eq!(xadj, vec![0, 0, 0, 0, 0]);
1163    }
1164
1165    #[test]
1166    fn test_condensed_adjacency_full_4x4_two_pairs() {
1167        // 4x4 fully connected (upper tri), 2 pairs: {0,1}, {2,3}
1168        let matrix = make_upper_tri(
1169            4,
1170            &[
1171                (0, 0, 1.0),
1172                (0, 1, 1.0),
1173                (0, 2, 1.0),
1174                (0, 3, 1.0),
1175                (1, 1, 1.0),
1176                (1, 2, 1.0),
1177                (1, 3, 1.0),
1178                (2, 2, 1.0),
1179                (2, 3, 1.0),
1180                (3, 3, 1.0),
1181            ],
1182        );
1183        let fwd = vec![1, 0, 3, 2];
1184        let is_matched = vec![true, true, true, true];
1185        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1186
1187        assert_eq!(decomp.condensed_dim, 2);
1188
1189        let (xadj, adjncy) = build_condensed_adjacency(matrix.symbolic(), &decomp).unwrap();
1190
1191        // 2 condensed nodes, connected to each other
1192        assert_eq!(xadj.len(), 3);
1193        // Each should neighbor the other (symmetric)
1194        assert_eq!(adjncy.len(), 2);
1195
1196        // Verify symmetry
1197        let n0_start = xadj[0] as usize;
1198        let n0_end = xadj[1] as usize;
1199        let n1_start = xadj[1] as usize;
1200        let n1_end = xadj[2] as usize;
1201        assert!(adjncy[n0_start..n0_end].contains(&1));
1202        assert!(adjncy[n1_start..n1_end].contains(&0));
1203    }
1204
1205    // ---- expand_ordering unit tests ----
1206
1207    #[test]
1208    fn test_expand_ordering_with_pairs() {
1209        // 4 nodes, 2 pairs: {0,1} and {2,3}
1210        // condensed_dim = 2, condensed_order = [1, 0] (swap order)
1211        let fwd = vec![1, 0, 3, 2];
1212        let is_matched = vec![true, true, true, true];
1213        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1214
1215        // condensed_order[cnode] = position
1216        // If cnode 0 ({0,1}) gets position 1 and cnode 1 ({2,3}) gets position 0:
1217        let condensed_order = vec![1i32, 0i32];
1218        let perm = expand_ordering(&condensed_order, &decomp, 4);
1219
1220        let (expanded_fwd, expanded_inv) = perm.as_ref().arrays();
1221        assert_eq!(expanded_fwd.len(), 4);
1222
1223        // Verify valid permutation
1224        let mut seen = [false; 4];
1225        for &v in expanded_fwd {
1226            assert!(v < 4);
1227            seen[v] = true;
1228        }
1229        assert!(seen.iter().all(|&s| s), "not a valid permutation");
1230
1231        // Verify pair adjacency: partners must be consecutive
1232        let pos_0 = expanded_inv[0];
1233        let pos_1 = expanded_inv[1];
1234        assert_eq!(pos_0.abs_diff(pos_1), 1, "pair (0,1) not consecutive");
1235
1236        let pos_2 = expanded_inv[2];
1237        let pos_3 = expanded_inv[3];
1238        assert_eq!(pos_2.abs_diff(pos_3), 1, "pair (2,3) not consecutive");
1239    }
1240
1241    #[test]
1242    fn test_expand_ordering_mixed_singletons_pairs() {
1243        // 5 nodes: pair {0,1}, singleton {2}, pair {3,4}
1244        let fwd = vec![1, 0, 2, 4, 3];
1245        let is_matched = vec![true, true, true, true, true];
1246        let decomp = split_matching_cycles(&fwd, &is_matched, 5);
1247
1248        assert_eq!(decomp.condensed_dim, 3);
1249
1250        // Identity condensed ordering
1251        let condensed_order = vec![0i32, 1i32, 2i32];
1252        let perm = expand_ordering(&condensed_order, &decomp, 5);
1253
1254        let (expanded_fwd, expanded_inv) = perm.as_ref().arrays();
1255        assert_eq!(expanded_fwd.len(), 5);
1256
1257        // Valid permutation
1258        let mut seen = [false; 5];
1259        for &v in expanded_fwd {
1260            seen[v] = true;
1261        }
1262        assert!(seen.iter().all(|&s| s));
1263
1264        // Pair {0,1} consecutive
1265        let diff = (expanded_inv[0] as isize - expanded_inv[1] as isize).unsigned_abs();
1266        assert_eq!(diff, 1, "pair (0,1) not consecutive");
1267
1268        // Pair {3,4} consecutive
1269        let diff = (expanded_inv[3] as isize - expanded_inv[4] as isize).unsigned_abs();
1270        assert_eq!(diff, 1, "pair (3,4) not consecutive");
1271    }
1272
1273    #[test]
1274    fn test_expand_ordering_with_unmatched() {
1275        // 5 nodes: pair {0,1}, singleton {2}, unmatched {3}, unmatched {4}
1276        let fwd = vec![1, 0, 2, 3, 4];
1277        let is_matched = vec![true, true, true, false, false];
1278        let decomp = split_matching_cycles(&fwd, &is_matched, 5);
1279
1280        // Now includes unmatched as singleton condensed nodes
1281        assert_eq!(decomp.condensed_dim, 4); // pair + singleton + 2 unmatched
1282
1283        let condensed_order = vec![0i32, 1i32, 2i32, 3i32];
1284        let perm = expand_ordering(&condensed_order, &decomp, 5);
1285
1286        let (expanded_fwd, expanded_inv) = perm.as_ref().arrays();
1287        assert_eq!(expanded_fwd.len(), 5);
1288
1289        // All nodes should have valid positions
1290        let mut seen = [false; 5];
1291        for &v in expanded_fwd {
1292            assert!(v < 5);
1293            seen[v] = true;
1294        }
1295        assert!(seen.iter().all(|&s| s), "not a valid permutation");
1296
1297        // Pair {0,1} still consecutive
1298        let diff = (expanded_inv[0] as isize - expanded_inv[1] as isize).unsigned_abs();
1299        assert_eq!(diff, 1, "pair (0,1) not consecutive");
1300
1301        // Unmatched nodes are now at METIS-determined positions (not forced to end)
1302        // With identity condensed ordering, they get positions based on their
1303        // condensed index, not appended
1304    }
1305
1306    // ---- Unmatched node condensation fix tests ----
1307
1308    #[test]
1309    fn test_split_unmatched_get_condensed_indices() {
1310        // 4 nodes: pair {0,1}, unmatched {2}, unmatched {3}
1311        let fwd = vec![1, 0, 2, 3]; // 2,3 map to self but unmatched
1312        let is_matched = vec![true, true, false, false];
1313        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1314
1315        // Both unmatched nodes should have condensed indices
1316        assert_eq!(decomp.condensed_dim, 3); // 1 pair + 2 unmatched singletons
1317        assert!(decomp.old_to_new[2] < decomp.condensed_dim);
1318        assert!(decomp.old_to_new[3] < decomp.condensed_dim);
1319
1320        // Unmatched nodes have distinct condensed indices
1321        assert_ne!(decomp.old_to_new[2], decomp.old_to_new[3]);
1322
1323        // Pair members share condensed index
1324        assert_eq!(decomp.old_to_new[0], decomp.old_to_new[1]);
1325    }
1326
1327    #[test]
1328    fn test_condensed_adjacency_includes_unmatched_edges() {
1329        // 4x4: pair {0,1} connected to unmatched {2}. {3} isolated unmatched.
1330        // [1  1  1  0]
1331        // [1  1  0  0]
1332        // [1  0  1  0]
1333        // [0  0  0  1]
1334        let matrix = make_upper_tri(
1335            4,
1336            &[
1337                (0, 0, 1.0),
1338                (0, 1, 1.0),
1339                (0, 2, 1.0),
1340                (1, 1, 1.0),
1341                (2, 2, 1.0),
1342                (3, 3, 1.0),
1343            ],
1344        );
1345
1346        let fwd = vec![1, 0, 2, 3];
1347        let is_matched = vec![true, true, false, false];
1348        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1349
1350        let (xadj, adjncy) = build_condensed_adjacency(matrix.symbolic(), &decomp).unwrap();
1351
1352        // Should have 3 condensed nodes: pair{0,1}, singleton{2}, singleton{3}
1353        assert_eq!(xadj.len(), 4); // 3 + 1
1354
1355        // The pair {0,1} should have edge to unmatched singleton {2}
1356        // (since original node 0 connects to node 2)
1357        let c_pair = decomp.old_to_new[0];
1358        let c_unmatched_2 = decomp.old_to_new[2];
1359
1360        let pair_start = xadj[c_pair] as usize;
1361        let pair_end = xadj[c_pair + 1] as usize;
1362        let pair_neighbors: Vec<i32> = adjncy[pair_start..pair_end].to_vec();
1363        assert!(
1364            pair_neighbors.contains(&(c_unmatched_2 as i32)),
1365            "pair should neighbor unmatched node 2; neighbors = {:?}",
1366            pair_neighbors
1367        );
1368
1369        // The unmatched singleton {3} should be isolated (no edges except diagonal)
1370        let c_unmatched_3 = decomp.old_to_new[3];
1371        let n3_start = xadj[c_unmatched_3] as usize;
1372        let n3_end = xadj[c_unmatched_3 + 1] as usize;
1373        assert_eq!(
1374            n3_end - n3_start,
1375            0,
1376            "isolated unmatched node 3 should have no neighbors"
1377        );
1378    }
1379
1380    #[test]
1381    fn test_expand_unmatched_not_at_end() {
1382        // 6 nodes: pair {0,1}, unmatched {2}, singleton {3}, pair {4,5}
1383        // With condensed_order that places unmatched first, it should appear first
1384        let fwd = vec![1, 0, 2, 3, 5, 4];
1385        let is_matched = vec![true, true, false, true, true, true];
1386        let decomp = split_matching_cycles(&fwd, &is_matched, 6);
1387
1388        // condensed_dim should include unmatched
1389        let cdim = decomp.condensed_dim;
1390
1391        // Create a condensed ordering that puts the unmatched node first
1392        let c_unmatched = decomp.old_to_new[2];
1393        let mut condensed_order = vec![0i32; cdim];
1394        // Put unmatched at position 0
1395        condensed_order[c_unmatched] = 0;
1396        // Fill remaining positions
1397        let mut pos = 1i32;
1398        for (cnode, order) in condensed_order.iter_mut().enumerate().take(cdim) {
1399            if cnode == c_unmatched {
1400                continue;
1401            }
1402            *order = pos;
1403            pos += 1;
1404        }
1405
1406        let perm = expand_ordering(&condensed_order, &decomp, 6);
1407        let (expanded_fwd, expanded_inv) = perm.as_ref().arrays();
1408
1409        // Unmatched node 2 should be at position 0 (not at the end)
1410        assert_eq!(
1411            expanded_inv[2], 0,
1412            "unmatched node should be at position 0, got {}",
1413            expanded_inv[2]
1414        );
1415
1416        // Valid permutation
1417        let mut seen = [false; 6];
1418        for &v in expanded_fwd {
1419            assert!(v < 6);
1420            seen[v] = true;
1421        }
1422        assert!(seen.iter().all(|&s| s));
1423    }
1424
1425    #[test]
1426    fn test_all_unmatched_gets_ordering() {
1427        // Edge case: all nodes unmatched (empty matching)
1428        let fwd = vec![0, 1, 2, 3];
1429        let is_matched = vec![false, false, false, false];
1430        let decomp = split_matching_cycles(&fwd, &is_matched, 4);
1431
1432        // All should be singleton condensed nodes
1433        assert_eq!(decomp.condensed_dim, 4);
1434        assert_eq!(decomp.singletons, 4);
1435        assert_eq!(decomp.two_cycles, 0);
1436
1437        // All have valid condensed indices
1438        for i in 0..4 {
1439            assert!(decomp.old_to_new[i] < 4);
1440        }
1441
1442        // Identity condensed ordering should produce valid expansion
1443        let condensed_order = vec![0i32, 1, 2, 3];
1444        let perm = expand_ordering(&condensed_order, &decomp, 4);
1445        let (fwd_expanded, _) = perm.as_ref().arrays();
1446        assert_eq!(fwd_expanded.len(), 4);
1447
1448        let mut seen = [false; 4];
1449        for &v in fwd_expanded {
1450            seen[v] = true;
1451        }
1452        assert!(seen.iter().all(|&s| s));
1453    }
1454}