Skip to main content

feral_metis/
lib.rs

1//! Multilevel nested-dissection fill-reducing ordering.
2//!
3//! Clean-room Rust implementation of the algorithm described in
4//! Karypis & Kumar, "A Fast and High Quality Multilevel Scheme for
5//! Partitioning Irregular Graphs" (SIAM J. Sci. Comput., 1998), and
6//! George, "Nested Dissection of a Regular Finite Element Mesh"
7//! (SIAM J. Numer. Anal., 1973).
8//!
9//! The public surface conforms to the FERAL ordering-crate contract
10//! (`dev/plans/ordering-crate-contract.md`): `CscPattern`,
11//! `OrderingStats`, `OrderingError`, and `CONTRACT_VERSION` are
12//! re-exported from `feral-ordering-core`.
13//!
14//! **Status: M1–M7 complete.** `metis_order_full` coarsens the graph
15//! (SHEM + 2-hop), picks the best of `niparts` initial bisections
16//! scored on their post-FM cut, uncoarsens with FM refinement, turns
17//! the final edge bisection into a node separator via min vertex
18//! cover (König's theorem), and recursively orders the two sides —
19//! handing off to AMD on subgraphs no larger than
20//! `nd_to_amd_switch`. M8 (integration into the main solver) is
21//! tracked separately in `dev/plans/ordering-metis.md`.
22
23#![forbid(unsafe_code)]
24#![deny(missing_docs)]
25
26// Modules are exercised only by `metis_order_full` once all
27// milestones land; until then, dead-code lint is suppressed at the
28// module root for internal helpers.
29#[doc(hidden)]
30#[allow(dead_code, missing_docs)]
31pub mod coarsen;
32#[doc(hidden)]
33#[allow(dead_code, missing_docs)]
34pub mod fm_refine;
35#[doc(hidden)]
36#[allow(dead_code, missing_docs)]
37pub mod graph;
38#[doc(hidden)]
39#[allow(dead_code, missing_docs)]
40pub mod initial_partition;
41mod node_nd;
42#[doc(hidden)]
43#[allow(dead_code, missing_docs)]
44pub mod rng;
45#[doc(hidden)]
46#[allow(dead_code, missing_docs)]
47pub mod separator;
48
49/// Crate-internal infrastructure exposed for sibling ordering
50/// crates (notably `feral-scotch`) that share the multilevel
51/// coarsening, initial-bisection, and FM-refinement plumbing.
52///
53/// **Not part of the stable public API.** No semver guarantees on
54/// signatures inside `internals`; consumers re-export it at their
55/// own risk. This module exists solely so feral-scotch does not
56/// have to clone the multilevel framework.
57#[doc(hidden)]
58pub mod internals {
59    pub use crate::coarsen;
60    pub use crate::fm_refine;
61    pub use crate::graph;
62    pub use crate::initial_partition;
63    pub use crate::rng;
64    pub use crate::separator;
65}
66
67pub use feral_ordering_core::{CscPattern, OrderingError, OrderingStats, CONTRACT_VERSION};
68
69/// Tunable parameters for METIS nested-dissection ordering.
70///
71/// Defaults mirror METIS 5.2.0's `METIS_NodeND` defaults as documented
72/// in `dev/plans/ordering-metis.md` audit (MUMPS uses stock METIS
73/// defaults for KKT problems: `METIS_OPTION_NUMBERING = 1`, all other
74/// options at library default).
75#[derive(Debug, Clone)]
76pub struct MetisOptions {
77    /// Deterministic RNG seed. Defaults to 1. Two runs with the same
78    /// seed on the same input must produce the same permutation.
79    pub seed: u64,
80    /// Number of initial-bisection trials at the coarsest level
81    /// (METIS 5.2.0 default: 7). Each trial alternates GGP and random
82    /// BFS and is scored on its post-FM cut.
83    pub niparts: u32,
84    /// Stop coarsening when the graph has fewer than this many
85    /// vertices (METIS 5.2.0 default: 120).
86    pub coarsen_floor: u32,
87    /// Switch from recursive ND to AMD on uncoarsened subproblems of
88    /// at most this many vertices (METIS 5.2.0 default: 200).
89    pub nd_to_amd_switch: u32,
90    /// Reduction-ratio threshold below which SHEM falls back to
91    /// 2-hop matching (METIS 5.2.0 default: 0.85).
92    pub two_hop_ratio_threshold: f64,
93    /// Maximum partition imbalance factor (`ufactor` in METIS terms,
94    /// encoded as a fraction here). METIS 5.2.0 uses 200, which
95    /// corresponds to 1.20 load balance tolerance; expressed as the
96    /// fractional deviation 0.20.
97    pub max_imbalance: f64,
98    /// Number of FM passes at each uncoarsening level (METIS 5.2.0
99    /// default: 10).
100    pub fm_passes: u32,
101    /// Pull near-dense columns out of the ND graph before recursive
102    /// bisection and append them at the *end* of the returned
103    /// permutation.
104    ///
105    /// **Default: `false`.** The technique was implemented to mimic
106    /// what we believed MUMPS's `ICNTL(6)` and SSIDS did, but expert
107    /// review of the MUMPS and SPRAL sources (2026-04-27) found:
108    /// (a) `ICNTL(6)` is MC64 matching, not dense-row removal;
109    /// (b) MUMPS handles dense rows *inside* its AMD/AMF
110    /// (`MUMPS_QAMD` in `ana_orderings.F:5226+` with the `THRESM`
111    /// parameter and `HEAD(N)` quasi-dense list); and
112    /// (c) SSIDS does not special-case dense rows at all — it relies
113    /// on METIS placing them in the top separator and supernodal
114    /// amalgamation collapsing the resulting chain into one dense
115    /// BLAS-3 root frontal. Neither solver pre-strips the graph.
116    /// Empirically, on ORBIT2_0000 (n=4795, one column of off-degree
117    /// 1794) Fix A *increased* `nnz_L` from 1.54M to 2.25M because
118    /// removing the dense column destroys the structural signal that
119    /// makes it the natural top separator. The opt-in path is kept
120    /// for diagnostic experimentation; the correct fix lives in
121    /// `feral-amd` (a QAMD-style deferral, future work).
122    ///
123    /// References (kept for the opt-in code path):
124    /// - Davis & Hager, "Dynamic supernodes in sparse Cholesky
125    ///   update/downdate and triangular solves" (2009), §3.2.
126    /// - Davis (1996) AMD paper, §5 ("dense rows / `Alpha` parameter").
127    /// - MUMPS source: `ana_orderings.F:5226-5650` (QAMD).
128    pub dense_quotient_enabled: bool,
129    /// Override the off-diagonal-degree threshold above which a column
130    /// is treated as quasi-dense.
131    ///
132    /// When `None` (the default) the threshold is computed as
133    /// `max(40, ceil(10 * sqrt(n)))` per Davis & Hager / AMD §5. Set
134    /// to `Some(usize::MAX)` to effectively disable the quotient
135    /// without flipping `dense_quotient_enabled` (useful for
136    /// regression sweeps).
137    pub dense_quotient_threshold: Option<usize>,
138}
139
140impl Default for MetisOptions {
141    fn default() -> Self {
142        Self {
143            seed: 1,
144            niparts: 7,
145            coarsen_floor: 120,
146            nd_to_amd_switch: 200,
147            two_hop_ratio_threshold: 0.85,
148            max_imbalance: 0.20,
149            fm_passes: 10,
150            dense_quotient_enabled: false,
151            dense_quotient_threshold: None,
152        }
153    }
154}
155
156/// Crate-specific diagnostic counters for METIS nested dissection.
157///
158/// Populated per call to [`metis_order_full`]. Callers that only need
159/// the permutation should use [`metis_order`]; callers that need the
160/// shared [`OrderingStats`] (wall time) should use
161/// [`metis_order_full`].
162#[derive(Debug, Default, Clone, PartialEq, Eq)]
163pub struct MetisStats {
164    /// Number of coarsening levels built.
165    pub n_levels: u32,
166    /// Number of top-level connected components encountered.
167    pub n_components: u32,
168    /// Number of vertices assigned to a separator at any level.
169    pub n_separator_vertices: u32,
170    /// Number of FM passes executed across all levels.
171    pub n_fm_passes: u32,
172    /// Number of times SHEM fell through to the 2-hop matching path.
173    pub n_two_hop_fallbacks: u32,
174    /// Number of subgraphs handed off to the AMD leaf solver (when
175    /// `nd_to_amd_switch` triggers).
176    pub n_amd_leaf_calls: u32,
177}
178
179/// Compute a fill-reducing METIS nested-dissection ordering.
180///
181/// Thin wrapper over [`metis_order_full`] that discards the
182/// diagnostic stats. Returns a permutation `perm` (new-to-old).
183pub fn metis_order(pattern: &CscPattern<'_>) -> Result<Vec<i32>, OrderingError> {
184    metis_order_full(pattern, &MetisOptions::default()).map(|(perm, _, _)| perm)
185}
186
187/// Contract-conforming ordering producer.
188///
189/// Signature matches the shape every FERAL ordering crate must expose
190/// per `dev/plans/ordering-crate-contract.md`: input is a
191/// full-symmetric [`CscPattern`] and options; output is a three-tuple
192/// of `(perm, OrderingStats, crate-stats)`, with errors in
193/// [`OrderingError`].
194///
195/// `OrderingStats.time_us` is the wall-clock time of this call.
196/// `fill_estimate` and `flop_estimate` stay `None` — METIS does not
197/// produce them at the ordering boundary; they belong to a downstream
198/// symbolic analysis.
199///
200/// Runs the M1–M7 pipeline: coarsen, initial bisection, FM, separator
201/// construction, and recursive nested dissection with an AMD leaf
202/// fallback for subgraphs of at most `nd_to_amd_switch` vertices.
203pub fn metis_order_full(
204    pattern: &CscPattern<'_>,
205    opts: &MetisOptions,
206) -> Result<(Vec<i32>, OrderingStats, MetisStats), OrderingError> {
207    if pattern.col_ptr.len() != pattern.n + 1 {
208        return Err(OrderingError::MalformedInput);
209    }
210    let t0 = std::time::Instant::now();
211    let mut stats = MetisStats::default();
212
213    // Fix A — quasi-dense column quotient.
214    //
215    // Pull columns with off-diagonal degree above the
216    // `dense_quotient_threshold` (default `max(40, 10*sqrt(n))`) out
217    // of the ND input graph, run M1–M7 ND on the *sparse-induced*
218    // subgraph, and append the dense columns at the end of the
219    // returned permutation. This was originally modelled on a belief
220    // that HSL_MC68 / MUMPS ICNTL(6) / SSIDS pre-strip dense rows, but
221    // a 2026-04-27 audit of the MUMPS and SPRAL sources found that
222    // belief wrong: ICNTL(6) is MC64 matching, MUMPS defers dense rows
223    // inside QAMD, and SSIDS does not special-case them — neither
224    // pre-strips the graph. See `MetisOptions::dense_quotient_enabled`
225    // for the full finding. The path is kept opt-in (default off) for
226    // diagnostic use only.
227    let (sparse_pat_storage, dense_cols, sparse_to_orig) =
228        if opts.dense_quotient_enabled && pattern.n > 0 {
229            split_dense_columns(pattern, opts)?
230        } else {
231            (None, Vec::new(), Vec::new())
232        };
233
234    let perm = if let Some((cp, ri, sub_n)) = sparse_pat_storage.as_ref().map(|s| {
235        let (cp, ri, sub_n) = s;
236        (cp.as_slice(), ri.as_slice(), *sub_n)
237    }) {
238        // Run ND on the sparse-induced subgraph.
239        let sub_pat = CscPattern::new(sub_n, cp, ri).ok_or(OrderingError::MalformedInput)?;
240        let sub_perm = node_nd::nd_order(&sub_pat, opts, &mut stats)?;
241        // Lift sub-perm back to original indices and append dense
242        // columns at the end (in descending degree order — Davis &
243        // Hager 2009 §3.2 ordering choice; ties broken by ascending
244        // original index).
245        let mut perm: Vec<i32> = Vec::with_capacity(pattern.n);
246        for &local in &sub_perm {
247            let idx = local as usize;
248            if idx >= sparse_to_orig.len() {
249                return Err(OrderingError::Internal(
250                    "dense-quotient: subgraph perm index out of range",
251                ));
252            }
253            perm.push(sparse_to_orig[idx]);
254        }
255        for &c in &dense_cols {
256            perm.push(c);
257        }
258        if perm.len() != pattern.n {
259            return Err(OrderingError::Internal(
260                "dense-quotient: assembled perm has wrong length",
261            ));
262        }
263        perm
264    } else {
265        node_nd::nd_order(pattern, opts, &mut stats)?
266    };
267
268    let ordering_stats = OrderingStats {
269        time_us: t0.elapsed().as_micros() as u64,
270        fill_estimate: None,
271        flop_estimate: None,
272    };
273    Ok((perm, ordering_stats, stats))
274}
275
276/// Resolve the dense-column threshold for an `n`-vertex graph.
277///
278/// `max(40, ceil(10 * sqrt(n)))` per Davis & Hager 2009 §3.2 and
279/// MUMPS `ICNTL(6)` defaults. Honours the caller's override when
280/// `opts.dense_quotient_threshold` is `Some(_)`.
281fn resolve_dense_threshold(n: usize, opts: &MetisOptions) -> usize {
282    if let Some(t) = opts.dense_quotient_threshold {
283        return t;
284    }
285    let computed = (10.0 * (n as f64).sqrt()).ceil() as usize;
286    computed.max(40)
287}
288
289/// Partition `pattern`'s columns into "dense" and "sparse" sets using
290/// off-diagonal degree, and produce the CSC pattern of the
291/// sparse-induced subgraph.
292///
293/// Returns:
294/// - `Some((col_ptr, row_idx, sub_n))` carrying the induced
295///   sub-pattern, plus the dense column list (in descending degree
296///   order) and the `sparse_local → original` mapping. When the
297///   dense set is empty, returns `(None, Vec::new(), Vec::new())` so
298///   the caller can fast-path to the original pattern.
299type DenseSplit = (Option<(Vec<i32>, Vec<i32>, usize)>, Vec<i32>, Vec<i32>);
300fn split_dense_columns(
301    pattern: &CscPattern<'_>,
302    opts: &MetisOptions,
303) -> Result<DenseSplit, OrderingError> {
304    let n = pattern.n;
305    let thresh = resolve_dense_threshold(n, opts);
306
307    // Off-diagonal degree per column. The pattern is full-symmetric
308    // with the diagonal optionally present; we count entries `r != c`.
309    let mut deg: Vec<usize> = vec![0; n];
310    for (c, d) in deg.iter_mut().enumerate() {
311        let lo = pattern.col_ptr[c] as usize;
312        let hi = pattern.col_ptr[c + 1] as usize;
313        if hi < lo || hi > pattern.row_idx.len() {
314            return Err(OrderingError::MalformedInput);
315        }
316        let mut acc: usize = 0;
317        for k in lo..hi {
318            let r = pattern.row_idx[k] as usize;
319            if r != c {
320                acc += 1;
321            }
322        }
323        *d = acc;
324    }
325
326    // Collect dense columns.
327    let mut dense: Vec<i32> = (0..n)
328        .filter(|&c| deg[c] > thresh)
329        .map(|c| c as i32)
330        .collect();
331
332    // No-op fast path: dense set empty.
333    if dense.is_empty() {
334        return Ok((None, Vec::new(), Vec::new()));
335    }
336
337    // Sort dense columns by *descending* degree, ties by ascending
338    // original index — Davis & Hager 2009 §3.2: "eliminate the densest
339    // last".
340    dense.sort_by(|&a, &b| {
341        deg[b as usize]
342            .cmp(&deg[a as usize])
343            .then_with(|| a.cmp(&b))
344    });
345
346    // Build the local-id maps for the sparse subgraph.
347    //
348    // `sparse_to_orig[local] = original`
349    // `orig_to_local[original] = sparse local id, or -1 if dense`.
350    let mut is_dense = vec![false; n];
351    for &c in &dense {
352        is_dense[c as usize] = true;
353    }
354    let mut sparse_to_orig: Vec<i32> = Vec::with_capacity(n - dense.len());
355    let mut orig_to_local: Vec<i32> = vec![-1; n];
356    for c in 0..n {
357        if !is_dense[c] {
358            orig_to_local[c] = sparse_to_orig.len() as i32;
359            sparse_to_orig.push(c as i32);
360        }
361    }
362    let sub_n = sparse_to_orig.len();
363
364    // Build the induced CSC pattern. Re-include the diagonal entry so
365    // downstream consumers (Graph::from_csc_pattern, AMD leaf) see a
366    // well-formed pattern. Row indices stay sorted because we walk
367    // each original column in ascending row order.
368    let mut col_ptr: Vec<i32> = Vec::with_capacity(sub_n + 1);
369    let mut row_idx: Vec<i32> = Vec::new();
370    col_ptr.push(0);
371    for &orig in &sparse_to_orig {
372        let c = orig as usize;
373        let lo = pattern.col_ptr[c] as usize;
374        let hi = pattern.col_ptr[c + 1] as usize;
375        let mut diag_inserted = false;
376        let local_c = orig_to_local[c];
377        for k in lo..hi {
378            let r = pattern.row_idx[k] as usize;
379            if r == c {
380                // Diagonal handled below; skip here so we control its
381                // placement (input may or may not carry the diagonal).
382                continue;
383            }
384            let lr = orig_to_local[r];
385            if lr < 0 {
386                // Edge crosses into the dense set — drop it from the
387                // sparse-induced subgraph; the dense column carries
388                // that coupling and is eliminated at the end.
389                continue;
390            }
391            if !diag_inserted && lr > local_c {
392                row_idx.push(local_c);
393                diag_inserted = true;
394            }
395            row_idx.push(lr);
396        }
397        if !diag_inserted {
398            row_idx.push(local_c);
399        }
400        col_ptr.push(row_idx.len() as i32);
401    }
402
403    Ok((Some((col_ptr, row_idx, sub_n)), dense, sparse_to_orig))
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn trivial_pattern() -> (Vec<i32>, Vec<i32>) {
411        // Diagonal n=3: col_ptr=[0,1,2,3], row_idx=[0,1,2]
412        (vec![0, 1, 2, 3], vec![0, 1, 2])
413    }
414
415    #[test]
416    fn options_defaults_match_metis_5_2_0() {
417        let o = MetisOptions::default();
418        assert_eq!(o.niparts, 7);
419        assert_eq!(o.coarsen_floor, 120);
420        assert_eq!(o.nd_to_amd_switch, 200);
421        assert_eq!(o.seed, 1);
422    }
423
424    #[test]
425    fn stats_default_is_zeros() {
426        let s = MetisStats::default();
427        assert_eq!(s.n_levels, 0);
428        assert_eq!(s.n_components, 0);
429        assert_eq!(s.n_separator_vertices, 0);
430        assert_eq!(s.n_fm_passes, 0);
431        assert_eq!(s.n_two_hop_fallbacks, 0);
432        assert_eq!(s.n_amd_leaf_calls, 0);
433    }
434
435    #[test]
436    fn diagonal_pattern_yields_permutation() {
437        let (cp, ri) = trivial_pattern();
438        let pat = CscPattern::new(3, &cp, &ri).unwrap();
439        let (perm, ostats, _mstats) = metis_order_full(&pat, &MetisOptions::default()).expect("ok");
440        assert_eq!(perm.len(), 3);
441        let mut seen = [false; 3];
442        for &p in &perm {
443            assert!((0..3).contains(&p));
444            seen[p as usize] = true;
445        }
446        assert!(seen.iter().all(|&s| s));
447        // time_us is populated; fill/flop remain None.
448        assert!(ostats.fill_estimate.is_none());
449        assert!(ostats.flop_estimate.is_none());
450    }
451
452    #[test]
453    fn convenience_wrapper_returns_permutation() {
454        let (cp, ri) = trivial_pattern();
455        let pat = CscPattern::new(3, &cp, &ri).unwrap();
456        let perm = metis_order(&pat).expect("ok");
457        assert_eq!(perm.len(), 3);
458    }
459
460    #[test]
461    fn contract_version_matches_core() {
462        assert_eq!(CONTRACT_VERSION, feral_ordering_core::CONTRACT_VERSION);
463    }
464}