Skip to main content

feral_scotch/
lib.rs

1//! SCOTCH-style nested-dissection fill-reducing ordering.
2//!
3//! Clean-room pure-Rust implementation of the algorithms described in
4//! Pellegrini, "SCOTCH: A software package for static mapping by dual
5//! recursive bipartitioning of process and architecture graphs"
6//! (HPCN Europe, 1996), §3. This crate provides an alternative to
7//! [`feral_metis`](../feral_metis) with:
8//!
9//! - Graph compression (supervariable merging before partitioning).
10//! - Direct vertex-separator FM (tighter separators than edge-cut
11//!   conversion on structured meshes).
12//! - Adaptive refinement (boundary / halo FM). A band-FM refiner
13//!   (`band_fm`) is implemented and unit-tested but is not yet wired
14//!   into the default ND driver.
15//!
16//! The public surface conforms to the FERAL ordering-crate contract
17//! (`dev/plans/ordering-crate-contract.md`): [`CscPattern`],
18//! [`OrderingStats`], [`OrderingError`], and [`CONTRACT_VERSION`] are
19//! re-exported from `feral-ordering-core`.
20//!
21//! **Status: S1–S5 complete.** [`scotch_order`] and
22//! [`scotch_order_full`] run the full pipeline — graph compression,
23//! connected-component split, multilevel coarsening, best-of-
24//! `n_sep_trials` initial bisection, halo-FM uncoarsening, direct
25//! vertex separator, and recursive ND with an AMD leaf fallback.
26//!
27//! ## Clean-room provenance
28//!
29//! No code is copied or paraphrased from SCOTCH's C source
30//! (`libscotch/`, CeCILL-C). Algorithms are reconstructed from
31//! Pellegrini 1996 §3 and the research notes under `dev/research/`.
32//! Constants, data layouts, and hash choices are independently
33//! justified and documented per-module.
34
35#![forbid(unsafe_code)]
36#![deny(missing_docs)]
37
38#[allow(dead_code)]
39mod band_fm;
40#[allow(dead_code)]
41mod compress;
42#[allow(dead_code)]
43mod graph;
44#[allow(dead_code)]
45mod halo_fm;
46mod node_nd;
47#[allow(dead_code)]
48mod vertex_separator;
49
50#[cfg(test)]
51mod test_util;
52
53pub use feral_ordering_core::{CscPattern, OrderingError, OrderingStats, CONTRACT_VERSION};
54
55/// Tunable parameters for SCOTCH nested-dissection ordering.
56///
57/// Defaults mirror SCOTCH 7.0's vertex-separation ordering defaults as
58/// documented in the audit of `dev/plans/ordering-scotch.md`:
59/// `cmin = 100` (coarsening floor), `amd_switch = 120`,
60/// `n_sep_trials = 5`, `fm_move_cap = 200`, `bal = 0.05`,
61/// `compress_ratio = 0.7`, `seed = 0xDEAD_BEEF`.
62///
63/// **S1 status:** only [`compress`](Self::compress) and
64/// [`compress_ratio`](Self::compress_ratio) are consumed by shipped
65/// code. The remaining fields are defined at the S1 boundary so that
66/// the public type does not drift between milestones.
67#[derive(Debug, Clone)]
68pub struct ScotchOptions {
69    /// Apply graph compression before partitioning.
70    pub compress: bool,
71    /// Compress only if `n_compressed / n < compress_ratio` (i.e.,
72    /// compress only when compression saves at least
73    /// `(1 - compress_ratio) * 100` % of vertices). SCOTCH uses 0.75;
74    /// we follow the plan's slightly more aggressive 0.7.
75    pub compress_ratio: f64,
76    /// Switch from recursive ND to AMD on subproblems with at most
77    /// this many vertices (SCOTCH default: 120).
78    pub amd_switch: u32,
79    /// Stop coarsening when the graph has fewer than this many
80    /// vertices (SCOTCH `cmin` = 100 for vertex-separation contexts).
81    pub coarsen_floor: u32,
82    /// Number of separator trials at each recursion level (SCOTCH
83    /// default: 5).
84    pub n_sep_trials: u32,
85    /// FM refinement: per-pass move cap (SCOTCH default: 200).
86    pub fm_move_cap: u32,
87    /// FM refinement: per-call pass cap. SCOTCH's default is "passes
88    /// until no improvement"; we clamp at 32 for bounded runtime.
89    pub fm_pass_cap: u32,
90    /// Imbalance tolerance (SCOTCH default: 0.05).
91    pub max_imbalance: f64,
92    /// Carry the **node separator** through uncoarsening and refine it
93    /// at every level, instead of refining the 2-way bisection with
94    /// halo FM and building the separator once at the finest level.
95    ///
96    /// The old path optimised the wrong objective at every level: halo
97    /// FM minimises an edge cut, and minimum edge cut and minimum
98    /// vertex separator are different problems. `feral-metis` had the
99    /// identical defect and fixing it there was worth 3.1x on a
100    /// collocation KKT; the same measurement for this crate is in
101    /// `dev/research/scotch-kahip-node-separator-2026-09-18.md`.
102    pub node_refine: bool,
103    /// Deterministic RNG seed for coarsening matching.
104    pub seed: u64,
105}
106
107impl Default for ScotchOptions {
108    fn default() -> Self {
109        Self {
110            compress: true,
111            compress_ratio: 0.7,
112            amd_switch: 120,
113            coarsen_floor: 100,
114            n_sep_trials: 5,
115            fm_move_cap: 200,
116            fm_pass_cap: 32,
117            max_imbalance: 0.05,
118            node_refine: true,
119            seed: 0xDEAD_BEEF,
120        }
121    }
122}
123
124/// Crate-specific diagnostic counters for SCOTCH nested dissection.
125///
126/// Zero-initialized at the start of each ordering call and filled in
127/// as the pipeline runs. S1 only touches the compression counters.
128#[derive(Debug, Default, Clone, PartialEq, Eq)]
129pub struct ScotchStats {
130    /// Number of vertices dropped by compression at the top level
131    /// (`n_original - n_compressed`). Zero if compression was
132    /// attempted but returned `None`, or if compression is disabled.
133    pub n_compressed_out: u32,
134    /// Number of coarsening levels built. Unused until S2.
135    pub n_levels: u32,
136    /// Number of top-level connected components encountered. Unused
137    /// until S5.
138    pub n_components: u32,
139    /// Number of vertices assigned to a separator at any level.
140    /// Unused until S5.
141    pub n_separator_vertices: u32,
142    /// Number of FM passes executed across all levels. Unused until
143    /// S3/S4.
144    pub n_fm_passes: u32,
145    /// Number of times the recursion bottomed out in the AMD leaf
146    /// fallback. Unused until S5.
147    pub n_amd_leaf_calls: u32,
148}
149
150/// Compute a fill-reducing SCOTCH nested-dissection ordering.
151///
152/// Thin wrapper over [`scotch_order_full`] that discards the
153/// diagnostic stats. Returns a permutation `perm` (new-to-old).
154pub fn scotch_order(pattern: &CscPattern<'_>) -> Result<Vec<i32>, OrderingError> {
155    scotch_order_full(pattern, &ScotchOptions::default()).map(|(perm, _, _)| perm)
156}
157
158/// Contract-conforming ordering producer.
159///
160/// Signature matches the shape every FERAL ordering crate must expose
161/// per `dev/plans/ordering-crate-contract.md`: input is a
162/// full-symmetric [`CscPattern`] and options; output is a three-tuple
163/// of `(perm, OrderingStats, crate-stats)`, with errors in
164/// [`OrderingError`].
165///
166/// `OrderingStats.time_us` is the wall-clock time of this call.
167/// `fill_estimate` and `flop_estimate` stay `None` — SCOTCH does not
168/// produce them at the ordering boundary; they belong to a downstream
169/// symbolic analysis.
170///
171/// Runs the S1–S5 pipeline: optional graph compression, connected-
172/// component split, multilevel coarsening, best-of-`n_sep_trials`
173/// initial bisection, halo-FM uncoarsening refinement, direct
174/// vertex-separator via two-sided FM, recursion on each side with an
175/// AMD leaf fallback at `amd_switch`.
176pub fn scotch_order_full(
177    pattern: &CscPattern<'_>,
178    opts: &ScotchOptions,
179) -> Result<(Vec<i32>, OrderingStats, ScotchStats), OrderingError> {
180    if pattern.col_ptr.len() != pattern.n + 1 {
181        return Err(OrderingError::MalformedInput);
182    }
183    let t0 = std::time::Instant::now();
184    let mut stats = ScotchStats::default();
185    let perm = node_nd::scotch_nd_order(pattern, opts, &mut stats)?;
186    let ordering_stats = OrderingStats {
187        time_us: t0.elapsed().as_micros() as u64,
188        fill_estimate: None,
189        flop_estimate: None,
190    };
191    Ok((perm, ordering_stats, stats))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn options_defaults_match_plan() {
200        let o = ScotchOptions::default();
201        assert!(o.compress);
202        assert_eq!(o.compress_ratio, 0.7);
203        assert_eq!(o.amd_switch, 120);
204        assert_eq!(o.coarsen_floor, 100);
205        assert_eq!(o.n_sep_trials, 5);
206        assert_eq!(o.fm_move_cap, 200);
207        assert_eq!(o.fm_pass_cap, 32);
208        assert_eq!(o.max_imbalance, 0.05);
209        assert_eq!(o.seed, 0xDEAD_BEEF);
210    }
211
212    #[test]
213    fn stats_default_is_zeros() {
214        let s = ScotchStats::default();
215        assert_eq!(s.n_compressed_out, 0);
216        assert_eq!(s.n_levels, 0);
217        assert_eq!(s.n_components, 0);
218        assert_eq!(s.n_separator_vertices, 0);
219        assert_eq!(s.n_fm_passes, 0);
220        assert_eq!(s.n_amd_leaf_calls, 0);
221    }
222
223    #[test]
224    fn contract_version_matches_core() {
225        assert_eq!(CONTRACT_VERSION, feral_ordering_core::CONTRACT_VERSION);
226    }
227
228    #[test]
229    fn scotch_order_trivial_diagonal() {
230        let cp = vec![0i32, 1, 2, 3];
231        let ri = vec![0i32, 1, 2];
232        let pat = CscPattern::new(3, &cp, &ri).unwrap();
233        let perm = scotch_order(&pat).expect("trivial 3-vertex diagonal orders");
234        assert_eq!(perm.len(), 3);
235        let mut seen = [false; 3];
236        for &p in &perm {
237            assert!((0..3).contains(&p));
238            seen[p as usize] = true;
239        }
240        assert!(seen.iter().all(|&s| s));
241    }
242
243    #[test]
244    fn scotch_order_full_populates_time_us() {
245        let cp = vec![0i32, 1, 2, 3];
246        let ri = vec![0i32, 1, 2];
247        let pat = CscPattern::new(3, &cp, &ri).unwrap();
248        let (_perm, ostats, _sstats) =
249            scotch_order_full(&pat, &ScotchOptions::default()).expect("ok");
250        // time_us is populated; fill/flop remain None.
251        assert!(ostats.fill_estimate.is_none());
252        assert!(ostats.flop_estimate.is_none());
253    }
254
255    #[test]
256    fn scotch_order_rejects_malformed_col_ptr() {
257        // col_ptr length must equal n + 1. Construct a well-formed
258        // CscPattern for n = 3, then pass it to scotch_order_full
259        // with an options struct and a lying n via a fresh pattern
260        // that fails the upstream validator.
261        let cp = vec![0i32, 1];
262        let ri: Vec<i32> = Vec::new();
263        // CscPattern::new with n=2 and col_ptr of length 2 rejects at
264        // construction; go through the public API by constructing a
265        // valid n=0 pattern and mutating — since we don't have public
266        // mutation, simply verify that CscPattern::new rejects the
267        // malformed case (the scotch_order_full guard is a defence-
268        // in-depth and is covered by tests in node_nd).
269        assert!(CscPattern::new(2, &cp, &ri).is_none());
270    }
271}