Skip to main content

feral_ordering_core/
lib.rs

1//! Shared contract types for FERAL's fill-reducing ordering crates.
2//!
3//! This crate exists so that `feral-amd`, `feral-metis`, `feral-scotch`,
4//! and `feral-kahip` all accept the same input type and emit the same
5//! stats / error types without a type-conversion layer at their
6//! boundary. The contract itself is documented in
7//! `dev/plans/ordering-crate-contract.md`.
8//!
9//! The public surface is deliberately minimal:
10//!
11//! - [`CscPattern`] — borrowed, full-symmetric, 0-based, `i32`-indexed.
12//! - [`OrderingStats`] — producer-agnostic diagnostic counters.
13//! - [`OrderingError`] — shared error shape.
14//! - [`CONTRACT_VERSION`] — bumped on any breaking change.
15//!
16//! Each ordering crate exposes exactly one contract-conforming
17//! function with the signature:
18//!
19//! ```ignore
20//! pub fn xxx_order(
21//!     pattern: &feral_ordering_core::CscPattern<'_>,
22//!     opts: &XxxOptions,
23//! ) -> Result<
24//!     (Vec<i32>, feral_ordering_core::OrderingStats, XxxStats),
25//!     feral_ordering_core::OrderingError,
26//! >;
27//! ```
28//!
29//! `perm[k] = j` means new index `k` corresponds to old index `j`
30//! (new-to-old). Callers that need the inverse compute it with a
31//! trivial helper.
32
33#![forbid(unsafe_code)]
34#![deny(missing_docs)]
35
36pub mod quotient_graph;
37
38use core::fmt;
39
40/// Version of the shared ordering-crate contract.
41///
42/// Bumped on any breaking change to [`CscPattern`], [`OrderingStats`],
43/// [`OrderingError`], or the per-crate producer-function signature.
44/// Consumers can assert at build time that all linked ordering crates
45/// target the same contract version.
46pub const CONTRACT_VERSION: u32 = 1;
47
48/// Borrowed symmetric sparsity pattern in CSC form.
49///
50/// The pattern must be **full-symmetric** — both the upper and lower
51/// halves are present. Row indices within each column must be sorted
52/// in ascending order. Indices are 0-based.
53///
54/// Invariants (checked by [`CscPattern::new`]):
55///
56/// - `col_ptr.len() == n + 1`
57/// - `col_ptr[0] == 0`, `col_ptr` is non-decreasing
58/// - `row_idx.len() == col_ptr[n]`
59/// - every row index is in `0..n` (non-negative and `< n`)
60/// - row indices within each column are sorted ascending
61///
62/// Structural symmetry is the caller's responsibility and is not
63/// checked here; individual ordering crates may debug-assert it.
64#[derive(Debug, Clone, Copy)]
65pub struct CscPattern<'a> {
66    /// Matrix dimension.
67    pub n: usize,
68    /// Column pointers. Length `n + 1`.
69    pub col_ptr: &'a [i32],
70    /// Row indices. Length `col_ptr[n]`.
71    pub row_idx: &'a [i32],
72}
73
74impl<'a> CscPattern<'a> {
75    /// Construct a validated pattern.
76    ///
77    /// Returns `None` if the structural invariants above are
78    /// violated. Does not check symmetry.
79    pub fn new(n: usize, col_ptr: &'a [i32], row_idx: &'a [i32]) -> Option<Self> {
80        if col_ptr.len() != n + 1 {
81            return None;
82        }
83        if col_ptr[0] != 0 {
84            return None;
85        }
86        let nnz_i32 = *col_ptr.last()?;
87        if nnz_i32 < 0 {
88            return None;
89        }
90        let nnz = nnz_i32 as usize;
91        if row_idx.len() != nnz {
92            return None;
93        }
94        for w in col_ptr.windows(2) {
95            if w[1] < w[0] {
96                return None;
97            }
98        }
99        let n_i32: i32 = match i32::try_from(n) {
100            Ok(v) => v,
101            Err(_) => return None,
102        };
103        for &r in row_idx {
104            if r < 0 || r >= n_i32 {
105                return None;
106            }
107        }
108        // Row indices within each column must be sorted ascending. This is a
109        // documented precondition that downstream consumers silently rely on:
110        // feral-metis's adjacency builder dedups only *adjacent* duplicates
111        // (graph.rs), and feral-scotch's compress step inserts neighbours with
112        // `partition_point` assuming sorted runs. Unsorted rows would let a
113        // non-adjacent duplicate survive as a spurious edge, corrupting the
114        // graph. Enforce it here (O(nnz)) so every consumer can trust it.
115        for w in col_ptr.windows(2) {
116            let lo = w[0] as usize;
117            let hi = w[1] as usize;
118            if row_idx[lo..hi].windows(2).any(|p| p[1] < p[0]) {
119                return None;
120            }
121        }
122        Some(Self {
123            n,
124            col_ptr,
125            row_idx,
126        })
127    }
128
129    /// Number of stored nonzeros.
130    pub fn nnz(&self) -> usize {
131        self.row_idx.len()
132    }
133}
134
135/// Diagnostic counters shared by every ordering producer.
136///
137/// Crate-specific counters live in the crate's own stats struct
138/// (e.g. `AmdStats`, `MetisStats`) returned alongside this one, not
139/// inside it.
140#[derive(Debug, Default, Clone, PartialEq, Eq)]
141pub struct OrderingStats {
142    /// Wall-clock ordering time, microseconds.
143    pub time_us: u64,
144    /// Predicted non-zeros in L (upper bound if known).
145    ///
146    /// `None` when the algorithm does not produce an estimate.
147    /// AMD can populate this; METIS / Scotch / KaHIP typically
148    /// cannot without a follow-up symbolic pass.
149    pub fill_estimate: Option<u64>,
150    /// Predicted factorization flops. `None` when not produced.
151    pub flop_estimate: Option<u64>,
152}
153
154/// Shared error shape for the ordering-crate contract.
155///
156/// Crate-specific failure modes are carried via [`OrderingError::Internal`]
157/// rather than a wrapped crate-specific enum, to avoid an error-type
158/// dependency tree between the sibling crates.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum OrderingError {
161    /// Input failed [`CscPattern::new`] validation (wrong lengths,
162    /// out-of-range row indices, etc.).
163    MalformedInput,
164    /// Input pattern was not structurally symmetric. Debug-only
165    /// detection; release builds trust the caller.
166    NonSymmetric,
167    /// Index overflow in the crate's internal workspace (for
168    /// example, `i32` overflow on a very large matrix).
169    IndexOverflow,
170    /// Graph is disconnected and the crate does not handle
171    /// disconnected components in its current form.
172    DisconnectedGraph,
173    /// Crate-specific failure with a short static message. Keep
174    /// short — this is a status channel, not a rich diagnostic.
175    Internal(&'static str),
176}
177
178impl fmt::Display for OrderingError {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        match self {
181            Self::MalformedInput => f.write_str("ordering input failed structural validation"),
182            Self::NonSymmetric => {
183                f.write_str("ordering input pattern was not structurally symmetric")
184            }
185            Self::IndexOverflow => f.write_str("ordering workspace exceeded i32::MAX"),
186            Self::DisconnectedGraph => f.write_str("ordering input graph is disconnected"),
187            Self::Internal(msg) => write!(f, "ordering internal error: {msg}"),
188        }
189    }
190}
191
192impl std::error::Error for OrderingError {}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn contract_version_is_one() {
200        assert_eq!(CONTRACT_VERSION, 1);
201    }
202
203    #[test]
204    fn empty_pattern_ok() {
205        let cp = [0i32];
206        let ri: [i32; 0] = [];
207        let p = CscPattern::new(0, &cp, &ri).expect("n=0 pattern");
208        assert_eq!(p.n, 0);
209        assert_eq!(p.nnz(), 0);
210    }
211
212    #[test]
213    fn diagonal_2x2_ok() {
214        let cp = [0i32, 1, 2];
215        let ri = [0i32, 1];
216        let p = CscPattern::new(2, &cp, &ri).unwrap();
217        assert_eq!(p.nnz(), 2);
218    }
219
220    #[test]
221    fn rejects_bad_col_ptr_length() {
222        let cp = [0i32, 1];
223        let ri = [0i32];
224        assert!(CscPattern::new(2, &cp, &ri).is_none());
225    }
226
227    #[test]
228    fn rejects_oob_row_index() {
229        let cp = [0i32, 1];
230        let ri = [5i32];
231        assert!(CscPattern::new(1, &cp, &ri).is_none());
232    }
233
234    #[test]
235    fn rejects_negative_row_index() {
236        let cp = [0i32, 1];
237        let ri = [-1i32];
238        assert!(CscPattern::new(1, &cp, &ri).is_none());
239    }
240
241    #[test]
242    fn rejects_nonzero_first_col_ptr() {
243        let cp = [1i32, 2];
244        let ri = [0i32];
245        assert!(CscPattern::new(1, &cp, &ri).is_none());
246    }
247
248    #[test]
249    fn rejects_nonmonotone_col_ptr() {
250        let cp = [0i32, 2, 1];
251        let ri = [0i32, 0];
252        assert!(CscPattern::new(2, &cp, &ri).is_none());
253    }
254
255    #[test]
256    fn rejects_row_idx_length_mismatch() {
257        let cp = [0i32, 1, 2];
258        let ri = [0i32];
259        assert!(CscPattern::new(2, &cp, &ri).is_none());
260    }
261
262    #[test]
263    fn rejects_unsorted_rows_within_column() {
264        // Column 0 has rows [1, 0] — descending, violating the documented
265        // ascending-order invariant. `new` must reject it: feral-metis's
266        // adjacency builder dedups only *adjacent* duplicates, so unsorted
267        // rows would let a non-adjacent duplicate survive as a spurious edge.
268        let cp = [0i32, 2, 2];
269        let ri = [1i32, 0];
270        assert!(CscPattern::new(2, &cp, &ri).is_none());
271    }
272
273    #[test]
274    fn accepts_sorted_rows_with_adjacent_duplicate() {
275        // Ascending order permits adjacent duplicates; feral-metis drops them
276        // in its adjacency builder. `new` enforces sortedness, not
277        // strict-increase, so this must still be accepted.
278        let cp = [0i32, 3, 3];
279        let ri = [0i32, 0, 1];
280        assert!(CscPattern::new(2, &cp, &ri).is_some());
281    }
282
283    #[test]
284    fn rejects_negative_col_ptr_tail() {
285        // col_ptr.last() is negative → nnz would be invalid.
286        let cp = [0i32, -1];
287        let ri: [i32; 0] = [];
288        assert!(CscPattern::new(1, &cp, &ri).is_none());
289    }
290
291    #[test]
292    fn ordering_error_display_is_non_empty() {
293        for e in [
294            OrderingError::MalformedInput,
295            OrderingError::NonSymmetric,
296            OrderingError::IndexOverflow,
297            OrderingError::DisconnectedGraph,
298            OrderingError::Internal("boom"),
299        ] {
300            assert!(!format!("{e}").is_empty());
301        }
302    }
303
304    #[test]
305    fn ordering_stats_default_is_none_fields() {
306        let s = OrderingStats::default();
307        assert_eq!(s.time_us, 0);
308        assert!(s.fill_estimate.is_none());
309        assert!(s.flop_estimate.is_none());
310    }
311}