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}