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(°[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}