Skip to main content

feral_ordering_core/quotient_graph/
workspace.rs

1//! Quotient-graph workspace and initialization, shared by AMD-family
2//! bottom-up orderings.
3//!
4//! All arrays follow the faer / SuiteSparse naming convention and use
5//! signed `i32` so we can reserve negative values for sentinels via
6//! [`flip`]: `flip(x) = -2 - x`. The input pattern is also `i32`-
7//! indexed (per the ordering-crate contract), so workspace ingestion
8//! converts to `usize` only for Rust slice addressing.
9//!
10//! Builds the workspace arrays from a full-symmetric CSC pattern,
11//! runs the two initialization fast paths (zero-degree
12//! pre-elimination and dense-deferred bucket), and seats the
13//! remaining variables into degree-indexed linked lists ready for
14//! the elimination loop.
15//!
16//! Migrated from `feral-amd` in 2026-04-27 to host the shared
17//! machinery for the planned `feral-amf` crate; the AMD-vs-AMF
18//! delta lives entirely in the elimination metric (see
19//! `dev/research/amf-clean-room.md`).
20//
21// Items are consumed by the elimination loop in subsequent commits
22// (Commit 4 onwards). Until then several fields and helpers are
23// intentionally unused.
24#![allow(dead_code)]
25
26use super::WorkspaceOptions;
27use crate::{CscPattern, OrderingError};
28
29/// Sentinel for "no index" in the `i32` arrays.
30pub const NONE: i32 = -1;
31
32/// Sentinel encoding used by the quotient graph: `flip(x) = -2 - x`.
33/// Used to mark absorbed elements (`pe[e] < 0` ⇒ `flip(parent)`) and
34/// as a tag on `elen` for freshly eliminated zero-degree variables.
35#[inline(always)]
36pub fn flip(x: i32) -> i32 {
37    -2 - x
38}
39
40/// Reset the generation counter `wflg` without visiting `iw`.
41///
42/// Called when `wflg` would overflow the `wbig` ceiling, or when it
43/// drops below 2 (which should not happen in practice, but matches
44/// SuiteSparse AMD's defensive check). All nonzero entries in `w`
45/// are clamped to `1`, and `2` is returned as the fresh counter.
46///
47/// Reference: `amd.rs:130-143`.
48#[inline]
49pub fn clear_flag(wflg: i32, wbig: i32, w: &mut [i32]) -> i32 {
50    if wflg < 2 || wflg >= wbig {
51        for x in w.iter_mut() {
52            if *x != 0 {
53                *x = 1;
54            }
55        }
56        return 2;
57    }
58    wflg
59}
60
61/// In-memory workspace for one AMD run.
62///
63/// The fields mirror faer's `amd_2` locals. Ownership of every buffer
64/// is held here so the elimination loop can borrow them concurrently
65/// through split borrows without reallocation.
66#[derive(Debug)]
67pub struct Workspace {
68    pub n: usize,
69    pub iwlen: usize,
70    pub pfree: usize,
71    pub iw: Vec<i32>,
72
73    pub pe: Vec<i32>,
74    pub len: Vec<i32>,
75    pub nv: Vec<i32>,
76    pub elen: Vec<i32>,
77    pub degree: Vec<i32>,
78    pub w: Vec<i32>,
79    pub head: Vec<i32>,
80    pub next: Vec<i32>,
81    pub last: Vec<i32>,
82    /// Per-supervariable / per-element fill-score scratch used by the
83    /// AMF inner loop only. Length `n`. Ignored by the AMD path
84    /// (`feral-amd` never reads or writes it). For variable indices
85    /// `i`, holds the running quantized RMF score; for element indices
86    /// `e`, holds the lazily-cached `dext * (2*deg(e) - dext - 1)`
87    /// surface contribution (sentinel `0` = "first touch this iter").
88    ///
89    /// `i64` (not `i32`): the un-quantized surface contribution has
90    /// both factors `O(n)`, so it reaches ~`n^2` and overflows `i32`
91    /// for `n` ≳ 46k before being consumed as `f64` in the RMF score
92    /// (O1, `dev/research/repo-review-2026-06-09.md`). MUMPS computes
93    /// the RMF in DBLE for the same reason. The post-quantization RMF
94    /// score stored here later is bounded by `i32::MAX - 1`.
95    pub wf: Vec<i64>,
96
97    /// Generation counter for the mark array `w`.
98    pub wflg: i32,
99    /// Overflow ceiling for `wflg`: `i32::MAX - n`.
100    pub wbig: i32,
101    /// Largest element size encountered so far — used by supervariable
102    /// detection (Slice B) to bump `wflg` safely.
103    pub lemax: i32,
104    /// Lower bound on the next pivot's degree. Monotone non-decreasing.
105    pub mindeg: usize,
106    /// Number of garbage-collection compactions so far.
107    pub ncmpa: u32,
108    /// Supervariables eliminated so far (pivoted OR dense-deferred).
109    pub nel: usize,
110    /// Dense-deferred supervariable count.
111    pub ndense: i32,
112    /// Variables folded into a concurrent pivot by mass elimination.
113    pub n_mass_elim: u32,
114    /// Supervariable merges detected during indistinguishable-variable
115    /// consolidation.
116    pub n_supervar_merge: u32,
117}
118
119impl Workspace {
120    /// Build a workspace from a full-symmetric CSC pattern and run
121    /// initialization. On return, all variables have been classified
122    /// into one of three buckets:
123    ///
124    /// 1. **Zero-degree** (`deg == 0`) — pre-eliminated. `pe[i] = NONE`,
125    ///    `elen[i] = flip(1)`, `w[i] = 0`, `nel` incremented.
126    /// 2. **Dense-deferred** (`deg > dense`) — moved to the dense tail.
127    ///    `pe[i] = NONE`, `nv[i] = 0`, `elen[i] = NONE`, `nel` incremented.
128    /// 3. **Live** — inserted LIFO into the degree-indexed linked list
129    ///    headed by `head[deg]`, threaded through `next`/`last`.
130    ///
131    /// `pattern` must be the full-symmetric graph (both halves). The
132    /// diagonal is ignored if present.
133    pub fn new(
134        pattern: &CscPattern<'_>,
135        opts: &WorkspaceOptions,
136    ) -> Result<Workspace, OrderingError> {
137        Self::new_with_n_buckets(pattern, opts, pattern.n)
138    }
139
140    /// Variant of [`Workspace::new`] that allocates `head` with the
141    /// caller-supplied bucket count. Used by AMF, where the quantized
142    /// fill score can exceed `n` and the head array must extend up to
143    /// `NBBUCK + 1 = 2 * n + 1`. AMD always passes `pattern.n`, which
144    /// makes this byte-equivalent to [`Workspace::new`].
145    ///
146    /// `n_buckets` must be at least `n` so the init insertion at
147    /// `head[deg]` (with `deg ≤ dense ≤ n`) is in range.
148    pub fn new_with_n_buckets(
149        pattern: &CscPattern<'_>,
150        opts: &WorkspaceOptions,
151        n_buckets: usize,
152    ) -> Result<Workspace, OrderingError> {
153        let n = pattern.n;
154        debug_assert!(n_buckets >= n, "n_buckets must cover deg ∈ [0, n)");
155
156        // i32 addressing requires n < i32::MAX. The algorithm also
157        // stores `pfree` as i32 via `pe[i]`, so iwlen must fit.
158        if n >= i32::MAX as usize {
159            return Err(OrderingError::IndexOverflow);
160        }
161
162        // Count off-diagonal entries per column.
163        let mut len: Vec<i32> = vec![0; n];
164        let mut nzaat: usize = 0;
165        #[allow(clippy::needless_range_loop)]
166        for j in 0..n {
167            let j_i32 = j as i32;
168            let start = pattern.col_ptr[j] as usize;
169            let end = pattern.col_ptr[j + 1] as usize;
170            let mut cnt: usize = 0;
171            for &r in &pattern.row_idx[start..end] {
172                if r != j_i32 {
173                    cnt += 1;
174                }
175            }
176            len[j] = cnt as i32;
177            nzaat += cnt;
178        }
179
180        // iwlen = nzaat + nzaat/5 + n  (plan A1 / faer amd.rs:921-924).
181        let iwlen = nzaat
182            .checked_add(nzaat / 5)
183            .and_then(|s| s.checked_add(n))
184            .ok_or(OrderingError::IndexOverflow)?;
185        if iwlen > i32::MAX as usize {
186            return Err(OrderingError::IndexOverflow);
187        }
188        // iw needs at least one slot even when n==0 so `pfree` is
189        // addressable; we allocate exactly iwlen.
190        let mut iw: Vec<i32> = vec![0; iwlen];
191
192        // pe[j] = start of j's adjacency list in iw; fill iw with
193        // off-diagonals, in the order they appear in the CSC pattern.
194        let mut pe: Vec<i32> = vec![0; n];
195        let mut pfree: usize = 0;
196        #[allow(clippy::needless_range_loop)]
197        for j in 0..n {
198            pe[j] = pfree as i32;
199            let j_i32 = j as i32;
200            let start = pattern.col_ptr[j] as usize;
201            let end = pattern.col_ptr[j + 1] as usize;
202            for &r in &pattern.row_idx[start..end] {
203                if r != j_i32 {
204                    iw[pfree] = r;
205                    pfree += 1;
206                }
207            }
208        }
209        debug_assert_eq!(pfree, nzaat);
210
211        // Dense threshold. alpha < 0 disables dense deferral.
212        let dense = if opts.dense_alpha < 0.0 {
213            n.saturating_sub(2)
214        } else {
215            (opts.dense_alpha * (n as f64).sqrt()) as usize
216        };
217        let dense = dense.max(16).min(n);
218
219        // Fixed-value arrays.
220        let mut nv: Vec<i32> = vec![1; n];
221        let mut elen: Vec<i32> = vec![0; n];
222        let mut w: Vec<i32> = vec![1; n];
223        let degree: Vec<i32> = len.clone();
224        let mut head: Vec<i32> = vec![NONE; n_buckets];
225        let mut next: Vec<i32> = vec![NONE; n];
226        let mut last: Vec<i32> = vec![NONE; n];
227        let mut wf: Vec<i64> = vec![0; n];
228
229        let wbig = i32::MAX - n as i32;
230        let wflg = 0; // clear_flag will lift to 2 on first use.
231
232        let mut nel: usize = 0;
233        let mut ndense: i32 = 0;
234
235        // Classify each variable.
236        for i in 0..n {
237            let deg = degree[i] as usize;
238            if deg == 0 {
239                // Zero-degree fast path — pre-eliminated.
240                elen[i] = flip(1);
241                nel += 1;
242                pe[i] = NONE;
243                w[i] = 0;
244            } else if deg > dense {
245                // Dense-deferred fast path.
246                ndense += 1;
247                nv[i] = 0;
248                elen[i] = NONE;
249                pe[i] = NONE;
250                nel += 1;
251            } else {
252                // LIFO head-insert at head[deg]. The AMF metric's
253                // `bucket(deg, n)` is identity for `deg ≤ n`, so the
254                // index `deg` is the right slot for both AMD and AMF
255                // at init time. Seed the AMF fill score so the AMF
256                // path can compute `bucket(wf[i], n)` consistently.
257                let inext = head[deg];
258                if inext != NONE {
259                    last[inext as usize] = i as i32;
260                }
261                next[i] = inext;
262                head[deg] = i as i32;
263                wf[i] = deg as i64;
264            }
265        }
266
267        Ok(Workspace {
268            n,
269            iwlen,
270            pfree,
271            iw,
272            pe,
273            len,
274            nv,
275            elen,
276            degree,
277            w,
278            head,
279            next,
280            last,
281            wf,
282            wflg,
283            wbig,
284            lemax: 0,
285            mindeg: 0,
286            ncmpa: 0,
287            nel,
288            ndense,
289            n_mass_elim: 0,
290            n_supervar_merge: 0,
291        })
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn pat<'a>(n: usize, cp: &'a [i32], ri: &'a [i32]) -> CscPattern<'a> {
300        CscPattern::new(n, cp, ri).expect("valid test pattern")
301    }
302
303    #[test]
304    fn flip_involution() {
305        for x in [-100i32, -1, 0, 1, 17, 1000] {
306            assert_eq!(flip(flip(x)), x);
307        }
308    }
309
310    #[test]
311    fn clear_flag_resets_on_overflow() {
312        let mut w = [0, 3, 5, 7, 0];
313        // wflg >= wbig triggers reset.
314        let wflg = clear_flag(100, 100, &mut w);
315        assert_eq!(wflg, 2);
316        assert_eq!(w, [0, 1, 1, 1, 0]);
317    }
318
319    #[test]
320    fn clear_flag_passthrough() {
321        let mut w = [1, 2, 3];
322        let wflg = clear_flag(5, 100, &mut w);
323        assert_eq!(wflg, 5);
324        assert_eq!(w, [1, 2, 3]);
325    }
326
327    /// Diagonal 4x4: every variable has degree 0. All four are
328    /// pre-eliminated by the zero-degree fast path; none are
329    /// inserted into any degree list.
330    #[test]
331    fn diag_4_zero_degree_fast_path() {
332        let cp = [0, 1, 2, 3, 4];
333        let ri = [0, 1, 2, 3];
334        let p = pat(4, &cp, &ri);
335        let ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
336
337        assert_eq!(ws.n, 4);
338        assert_eq!(ws.nel, 4, "all four pre-eliminated");
339        assert_eq!(ws.ndense, 0, "no dense deferral");
340        for i in 0..4 {
341            assert_eq!(ws.elen[i], flip(1));
342            assert_eq!(ws.pe[i], NONE);
343            assert_eq!(ws.w[i], 0);
344            assert_eq!(ws.degree[i], 0);
345        }
346        for d in 0..4 {
347            assert_eq!(ws.head[d], NONE, "degree {d} bucket empty");
348        }
349    }
350
351    /// Tridiagonal 5x5 full-symmetric: var 0 has deg 1, 4 has deg 1,
352    /// interior vars have deg 2. No dense deferral (max deg 2 < 16).
353    /// All five enter degree lists.
354    #[test]
355    fn tridiag_5_populates_degree_lists() {
356        // Full-symmetric tridiag of size 5.
357        // column j contains diagonal + off-diagonals j-1 and j+1.
358        let cp = [0, 2, 5, 8, 11, 13];
359        let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
360        let p = pat(5, &cp, &ri);
361        let ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
362
363        assert_eq!(ws.n, 5);
364        assert_eq!(ws.nel, 0, "no fast-path eliminations");
365        assert_eq!(ws.ndense, 0);
366        assert_eq!(ws.degree, vec![1, 2, 2, 2, 1]);
367        assert_eq!(ws.len, vec![1, 2, 2, 2, 1]);
368
369        // LIFO insertion: the last variable inserted into deg list d
370        // is at head[d]. For deg=1, vars 0 and 4 hit the bucket;
371        // last in is 4. For deg=2, vars 1,2,3; last in is 3.
372        assert_eq!(ws.head[1], 4);
373        assert_eq!(ws.next[4], 0);
374        assert_eq!(ws.next[0], NONE);
375        assert_eq!(ws.last[0], 4);
376
377        assert_eq!(ws.head[2], 3);
378        assert_eq!(ws.next[3], 2);
379        assert_eq!(ws.next[2], 1);
380        assert_eq!(ws.next[1], NONE);
381        assert_eq!(ws.last[1], 2);
382        assert_eq!(ws.last[2], 3);
383        assert_eq!(ws.last[3], NONE);
384
385        // iwlen = nzaat + nzaat/5 + n.
386        // nzaat = sum(len) = 1+2+2+2+1 = 8.
387        // iwlen = 8 + 1 + 5 = 14.
388        assert_eq!(ws.iwlen, 14);
389        assert_eq!(ws.pfree, 8);
390    }
391
392    /// Arrow(5): hub at 0 has deg 4, spokes have deg 1. Dense
393    /// threshold for n=5 is min(n, max(16, 10*sqrt(5))) = 5; deg 4
394    /// < 5, so nothing is deferred. Hub enters head[4].
395    #[test]
396    fn arrow_5_all_live() {
397        let cp = [0, 5, 7, 9, 11, 13];
398        let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
399        let p = pat(5, &cp, &ri);
400        let ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
401
402        assert_eq!(ws.degree, vec![4, 1, 1, 1, 1]);
403        assert_eq!(ws.nel, 0);
404        assert_eq!(ws.ndense, 0);
405        assert_eq!(ws.head[4], 0, "hub at deg-4 bucket");
406        assert_eq!(ws.head[1], 4, "last spoke inserted");
407    }
408
409    /// Arrow(200): hub has deg 199. Dense threshold = max(16,
410    /// floor(10*sqrt(200))) = max(16, 141) = 141, min(141, 200) =
411    /// 141. 199 > 141 so the hub is deferred. Spokes (deg 1) live.
412    #[test]
413    fn arrow_200_hub_deferred() {
414        let n = 200usize;
415        let mut cp: Vec<i32> = Vec::with_capacity(n + 1);
416        let mut ri: Vec<i32> = Vec::new();
417        cp.push(0);
418        // col 0: diagonal + all spokes 1..n
419        ri.push(0);
420        for r in 1..n {
421            ri.push(r as i32);
422        }
423        cp.push(ri.len() as i32);
424        // cols 1..n: diagonal + hub
425        for j in 1..n {
426            ri.push(0);
427            ri.push(j as i32);
428            cp.push(ri.len() as i32);
429        }
430        let p = pat(n, &cp, &ri);
431        let ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
432
433        assert_eq!(ws.degree[0], (n - 1) as i32);
434        assert_eq!(ws.nel, 1, "hub only");
435        assert_eq!(ws.ndense, 1);
436        assert_eq!(ws.nv[0], 0, "hub marked deferred");
437        assert_eq!(ws.pe[0], NONE);
438        assert_eq!(ws.elen[0], NONE);
439        // Spokes all land in head[1] — LIFO, last in is n-1.
440        assert_eq!(ws.head[1], (n - 1) as i32);
441    }
442
443    /// With `dense_alpha < 0`, the threshold is set to `n - 2`
444    /// (faer amd.rs:173-177). A variable with degree `n - 1` (only
445    /// possible for a true hub) is still deferred; everything else
446    /// stays live. This matches SuiteSparse AMD semantics exactly.
447    #[test]
448    fn dense_alpha_negative_uses_n_minus_2() {
449        // Band(20, 5): max degree = 10, well under n - 2 = 18.
450        let n = 20usize;
451        let b = 5usize;
452        let mut cp: Vec<i32> = vec![0];
453        let mut ri: Vec<i32> = Vec::new();
454        for j in 0..n {
455            let lo = j.saturating_sub(b);
456            let hi = (j + b + 1).min(n);
457            for r in lo..hi {
458                ri.push(r as i32);
459            }
460            cp.push(ri.len() as i32);
461        }
462        let p = pat(n, &cp, &ri);
463        let opts = WorkspaceOptions { dense_alpha: -1.0 };
464        let ws = Workspace::new(&p, &opts).unwrap();
465        assert_eq!(ws.ndense, 0, "nothing deferred below n - 2");
466        assert_eq!(ws.nel, 0);
467    }
468
469    #[test]
470    fn empty_pattern_ok() {
471        let cp = [0i32];
472        let ri: [i32; 0] = [];
473        let p = pat(0, &cp, &ri);
474        let ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
475        assert_eq!(ws.n, 0);
476        assert_eq!(ws.nel, 0);
477        assert_eq!(ws.iwlen, 0);
478    }
479
480    /// Diagonal entries in the input are ignored when computing
481    /// adjacency lists — only off-diagonal neighbors contribute to
482    /// `len`, `degree`, and `iw`.
483    #[test]
484    fn diagonal_entries_skipped() {
485        // 3x3 with diagonal only.
486        let cp = [0, 1, 2, 3];
487        let ri = [0, 1, 2];
488        let p = pat(3, &cp, &ri);
489        let ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
490        assert_eq!(ws.len, vec![0, 0, 0]);
491        assert_eq!(ws.pfree, 0);
492        assert_eq!(ws.nel, 3);
493    }
494}