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}