feral_ordering_core/quotient_graph/algo.rs
1//! AMD elimination-loop primitives: pivot selection and element
2//! construction (plus standard absorption).
3//!
4//! This module lands Commit 4 of the Slice A plan. It ports faer's
5//! `amd.rs:220-365` line-by-line:
6//!
7//! - [`select_pivot`]: linear scan from `mindeg`, LIFO unlink.
8//! - [`create_element`]: both the in-place (`elenme == 0`) and
9//! out-of-place (`elenme > 0`) branches, plus standard absorption
10//! fired at the end of each `knt1` iter (faer `amd.rs:355-358`),
11//! and the final bookkeeping write-back to `pe[me]/len[me]/elen[me]`
12//! with the post-step `clear_flag` call.
13//!
14//! Inline garbage collection (faer `amd.rs:289-338`) fires inside
15//! the out-of-place branch when `pfree >= iwlen`. See
16//! [`create_element`] for the full save → mark → compact → restore
17//! dance.
18//!
19//! Pass-1 `w[e]` seeding (`amd.rs:366-385`), Pass-2 approximate
20//! degree (`amd.rs:386-465`), aggressive absorption, monotone
21//! degree cap, and re-insertion into degree lists (`amd.rs:516-546`)
22//! live in [`finalize_step`]. Mass elimination and supervariable
23//! detection are Slice B (Commits 9-10).
24
25#![allow(dead_code)]
26
27use super::workspace::{clear_flag, flip, Workspace, NONE};
28use crate::OrderingError;
29
30/// Flop-counter deltas produced by a single elimination step.
31/// Matches faer's `amd.rs:547-557` accounting so `AmdStats` can
32/// accumulate consistent `ndiv` / `nms_ldl` / `nms_lu` totals.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct StepFlops {
35 pub ndiv: f64,
36 pub nms_lu: f64,
37 pub nms_ldl: f64,
38}
39
40impl StepFlops {
41 fn accumulate(&mut self, other: StepFlops) {
42 self.ndiv += other.ndiv;
43 self.nms_lu += other.nms_lu;
44 self.nms_ldl += other.nms_ldl;
45 }
46}
47
48/// Scan `head` from `ws.mindeg` upward and return the first
49/// non-empty degree-list head. Unlink the chosen variable. Returns
50/// `None` if no bucket in `[ws.mindeg, ws.n)` is non-empty (i.e.
51/// all remaining supervariables have been dense-deferred and the
52/// main loop should stop).
53///
54/// Side effects: `ws.mindeg` advances to the degree of the chosen
55/// pivot. `head[deg]` is advanced to the next element. `last[next]`
56/// is cleared if a successor exists.
57///
58/// Reference: faer `amd.rs:220-235`.
59pub fn select_pivot(ws: &mut Workspace) -> Option<usize> {
60 let n = ws.n;
61 let mut deg = ws.mindeg;
62 let mut me_signed: i32 = NONE;
63 while deg < n {
64 let h = ws.head[deg];
65 if h != NONE {
66 me_signed = h;
67 break;
68 }
69 deg += 1;
70 }
71 if me_signed == NONE {
72 return None;
73 }
74 ws.mindeg = deg;
75 let me = me_signed as usize;
76 let inext = ws.next[me];
77 if inext != NONE {
78 ws.last[inext as usize] = NONE;
79 }
80 ws.head[deg] = inext;
81 Some(me)
82}
83
84/// Build the new element `me` by merging the (variable) tail of
85/// `me`'s list with every element `e` already in `me`'s list.
86///
87/// On success returns `(pme1, pme2_excl, nvpiv, degme)`:
88/// - `pme1..pme2_excl` is the contiguous region in `ws.iw` holding
89/// the new element's **variable** members (supervariables, listed
90/// once each). Exclusive end so the empty case (`pme2_excl ==
91/// pme1`) is representable in `usize` without underflow.
92/// - `nvpiv` is the supervariable count of the pivot.
93/// - `degme` is the tentative new element's external degree (sum of
94/// `nv[i]` over the assembled variables, before any absorption
95/// correction made by Pass-2).
96///
97/// Post-conditions also persisted on the workspace:
98/// - `nv[me] = -nvpiv` (marker — Pass-2 will flip sign back via
99/// `-nv[i]`).
100/// - `nv[i] = -nv[i]` for every `i` assembled into the new element
101/// (ditto — marker for Pass-2's w-seed walk).
102/// - `pe[me] = pme1`, `len[me] = pme2 - pme1 + 1`, `elen[me] =
103/// flip(nvpiv + degme)` (dead-variable sentinel carrying the
104/// pivot-front size for the postorder phase).
105/// - `degree[me] = degme` (temporary; Pass-2 overwrites).
106/// - For every absorbed element `e != me` in the elenme>0 branch:
107/// `pe[e] = flip(me)`, `w[e] = 0`. This is **standard absorption**
108/// (faer `amd.rs:355-358`) — it fires unconditionally at each
109/// `knt1` iter's end. Aggressive absorption (Pass-2 only) lands
110/// in Commit 5.
111/// - `ws.wflg` bumped via `clear_flag`.
112/// - `ws.nel` incremented by `nvpiv`.
113///
114/// Reference: faer `amd.rs:236-366` (incl. inline GC at 289-338).
115pub fn create_element(
116 ws: &mut Workspace,
117 me: usize,
118) -> Result<(usize, usize, i32, usize), OrderingError> {
119 let elenme = ws.elen[me];
120 let nvpiv = ws.nv[me];
121 ws.nel += nvpiv as usize;
122 ws.nv[me] = -nvpiv;
123 let mut degme: usize = 0;
124 let pme1: usize;
125 let pme2: i32;
126
127 if elenme == 0 {
128 // In-place: me has no elements in its list — just variables.
129 // Compact them at pe[me]: advance pme2 to the final position,
130 // overwriting absorbed entries in-place.
131 let pme1_s = ws.pe[me];
132 pme1 = pme1_s as usize;
133 let list_start = pme1;
134 let list_end = list_start + ws.len[me] as usize;
135 let mut pme2_s = pme1_s - 1;
136 for p in list_start..list_end {
137 let i = ws.iw[p] as usize;
138 let nvi = ws.nv[i];
139 if nvi > 0 {
140 degme += nvi as usize;
141 ws.nv[i] = -nvi;
142 pme2_s += 1;
143 ws.iw[pme2_s as usize] = i as i32;
144 // Unlink i from its degree list.
145 let ilast = ws.last[i];
146 let inext = ws.next[i];
147 if inext != NONE {
148 ws.last[inext as usize] = ilast;
149 }
150 if ilast != NONE {
151 ws.next[ilast as usize] = inext;
152 } else {
153 ws.head[ws.degree[i] as usize] = inext;
154 }
155 }
156 }
157 pme2 = pme2_s;
158 } else {
159 // Out-of-place: start a new region at pfree. Walk every
160 // element e in me's list (first `elenme` entries of me's
161 // adjacency), then walk the variable tail (remaining
162 // `slenme` entries) with `knt1 = elenme + 1` as the flag.
163 let mut p = ws.pe[me] as usize;
164 let mut pme1_rw: usize = ws.pfree;
165 let slenme = (ws.len[me] - elenme) as usize;
166 let elenme_u = elenme as usize;
167 for knt1 in 1..=elenme_u + 1 {
168 let e: usize;
169 let mut pj: usize;
170 let ln: usize;
171 if knt1 > elenme_u {
172 // Variable tail of me's own list.
173 e = me;
174 pj = p;
175 ln = slenme;
176 } else {
177 e = ws.iw[p] as usize;
178 p += 1;
179 pj = ws.pe[e] as usize;
180 ln = ws.len[e] as usize;
181 }
182 for knt2 in 1..=ln {
183 let i = ws.iw[pj] as usize;
184 pj += 1;
185 let nvi = ws.nv[i];
186 if nvi > 0 {
187 if ws.pfree >= ws.iwlen {
188 // Inline garbage collection (faer
189 // amd.rs:289-338). Save partial state so
190 // the surviving elements can be compacted
191 // down, then restore local cursors.
192 ws.pe[me] = p as i32;
193 ws.len[me] -= knt1 as i32;
194 if ws.len[me] == 0 {
195 ws.pe[me] = NONE;
196 }
197 ws.pe[e] = pj as i32;
198 ws.len[e] = (ln - knt2) as i32;
199 if ws.len[e] == 0 {
200 ws.pe[e] = NONE;
201 }
202 ws.ncmpa += 1;
203 // Mark each live list's head: save iw[pe[j]]
204 // into pe[j] and write flip(j) at the old
205 // head position so the compact sweep can
206 // recognise list starts.
207 for j in 0..ws.n {
208 let pn = ws.pe[j];
209 if pn >= 0 {
210 let pn_u = pn as usize;
211 ws.pe[j] = ws.iw[pn_u];
212 ws.iw[pn_u] = flip(j as i32);
213 }
214 }
215 // Sweep [0, pme1_rw), reconstructing every
216 // marked list contiguously at pdst.
217 let mut psrc = 0usize;
218 let mut pdst = 0usize;
219 let pend = pme1_rw;
220 while psrc < pend {
221 let j_marker = flip(ws.iw[psrc]);
222 psrc += 1;
223 if j_marker >= 0 {
224 let j = j_marker as usize;
225 ws.iw[pdst] = ws.pe[j];
226 ws.pe[j] = pdst as i32;
227 pdst += 1;
228 let lenj = ws.len[j] as usize;
229 if lenj > 0 {
230 ws.iw.copy_within(psrc..psrc + lenj - 1, pdst);
231 psrc += lenj - 1;
232 pdst += lenj - 1;
233 }
234 }
235 }
236 // Slide the new element's accumulated prefix
237 // [pme1_rw, pfree) down to the new pdst.
238 let p1 = pdst;
239 ws.iw.copy_within(pme1_rw..ws.pfree, pdst);
240 pdst += ws.pfree - pme1_rw;
241 pme1_rw = p1;
242 ws.pfree = pdst;
243 // Restore local cursors from the relocated
244 // heads of e's and me's lists.
245 pj = ws.pe[e] as usize;
246 p = ws.pe[me] as usize;
247 }
248 degme += nvi as usize;
249 ws.nv[i] = -nvi;
250 ws.iw[ws.pfree] = i as i32;
251 ws.pfree += 1;
252 // Unlink i from its degree list.
253 let ilast = ws.last[i];
254 let inext = ws.next[i];
255 if inext != NONE {
256 ws.last[inext as usize] = ilast;
257 }
258 if ilast != NONE {
259 ws.next[ilast as usize] = inext;
260 } else {
261 ws.head[ws.degree[i] as usize] = inext;
262 }
263 }
264 }
265 // Standard absorption (amd.rs:355-358): every element e
266 // that was in me's list is now absorbed by me.
267 if e != me {
268 ws.pe[e] = flip(me as i32);
269 ws.w[e] = 0;
270 }
271 }
272 pme1 = pme1_rw;
273 pme2 = (ws.pfree - 1) as i32;
274 }
275
276 ws.degree[me] = degme as i32;
277 ws.pe[me] = pme1 as i32;
278 ws.len[me] = pme2 - pme1 as i32 + 1;
279 ws.elen[me] = flip(nvpiv + degme as i32);
280 ws.wflg = clear_flag(ws.wflg, ws.wbig, &mut ws.w);
281
282 // Convert the inclusive `pme2` (which is `pme1 - 1` when the
283 // pivot's variable list ended up empty — every neighbour was
284 // already absorbed) to an exclusive end so downstream loops can
285 // use a `usize` half-open range without the wrap-around bug
286 // `(-1i32) as usize == usize::MAX`.
287 let pme2_excl: usize = if pme2 < pme1 as i32 {
288 pme1
289 } else {
290 (pme2 + 1) as usize
291 };
292 Ok((pme1, pme2_excl, nvpiv, degme))
293}
294
295/// Finish the elimination step whose create-element phase produced
296/// `(pme1, pme2_excl, nvpiv, degme)` and left `nv[me] = -nvpiv` and
297/// `nv[i] = -nv[i]` for every variable `i ∈ iw[pme1..pme2_excl]`.
298///
299/// Does, in order:
300/// 1. **Pass-1 w-seeding** (faer `amd.rs:366-385`). For each
301/// variable `i` in the new element, walk its element list and
302/// lazily seed `w[e]`: first touch sets `w[e] = degree[e] +
303/// (wflg - nvi)`, subsequent touches do `w[e] -= nvi`.
304/// 2. **Pass-2 approximate external degree** (`amd.rs:386-462`).
305/// For each variable `i` in the new element: walk its element
306/// list computing `dext = w[e] - wflg`, then its variable list
307/// accumulating `nv[j]` for live neighbours. Under `aggressive`,
308/// dead elements (`dext == 0`) are absorbed on the spot. The
309/// updated degree is clamped by `min(degree[i], deg)`
310/// ("monotone cap"). The element list is re-ordered so `me` sits
311/// at position `p1`.
312/// 3. **Mass elimination** (`amd.rs:436-444`). A member `i` whose
313/// only remaining element is `me` (`elen[i] == 1`) and whose
314/// surviving variable neighbourhood is empty (`p3 == pn`) will
315/// pivot concurrently with `me`. Fold its supervariable count
316/// into `nvpiv` / `nel` and deduct from `degme`.
317/// 4. **Hash-bucket insertion** (`amd.rs:452-460`): each still-
318/// marked member is placed into a hash bucket threaded through
319/// `head`/`next`/`last` via sign-bit encoding.
320/// 5. Bump `degree[me] = degme`, `lemax = max(lemax, degme)`,
321/// `wflg += lemax`, `wflg = clear_flag(...)`.
322/// 6. **Supervariable detection** (`amd.rs:467-515`): for each
323/// hash chain whose anchor is still marked, walk the chain and
324/// merge indistinguishable followers into the head.
325/// 7. **Re-insert** (`amd.rs:516-537`): each surviving variable's
326/// updated degree is pushed back onto `head[deg]` LIFO and
327/// `mindeg` is lowered if needed.
328/// 8. **Me bookkeeping** (`amd.rs:538-546`): restore `nv[me] =
329/// nvpiv`, compact `me`'s var list to `[pme1, p)`, trim `pfree`.
330/// 9. **Flop counters** (`amd.rs:547-557`).
331#[allow(clippy::too_many_arguments)]
332pub fn finalize_step(
333 ws: &mut Workspace,
334 me: usize,
335 pme1: usize,
336 pme2_excl: usize,
337 nvpiv: i32,
338 degme: usize,
339 elenme: i32,
340 aggressive: bool,
341) -> StepFlops {
342 let mut degme = degme;
343 let mut nvpiv = nvpiv;
344
345 // Pass 1: seed w[e] for every element in each member's list.
346 for pme in pme1..pme2_excl {
347 let i = ws.iw[pme] as usize;
348 let eln = ws.elen[i];
349 if eln > 0 {
350 let nvi = -ws.nv[i];
351 let wnvi = ws.wflg - nvi;
352 let pi = ws.pe[i] as usize;
353 for k in 0..eln as usize {
354 let e = ws.iw[pi + k] as usize;
355 let mut we = ws.w[e];
356 if we >= ws.wflg {
357 we -= nvi;
358 } else if we != 0 {
359 we = ws.degree[e] + wnvi;
360 }
361 ws.w[e] = we;
362 }
363 }
364 }
365
366 // Pass 2: approximate degree, (optionally) aggressive absorption,
367 // mass elimination (faer amd.rs:436-444), and hash-bucket
368 // insertion for supervariable detection (amd.rs:451-460). `degme`
369 // and `nvpiv` are mutated when mass-elim fires; the post-loop
370 // degree/flop bookkeeping uses the updated values.
371 for pme in pme1..pme2_excl {
372 let i = ws.iw[pme] as usize;
373 let p1 = ws.pe[i] as usize;
374 let p2 = p1 + ws.elen[i] as usize;
375 let mut pn = p1;
376 let mut deg: usize = 0;
377 // Hash accumulator for supervariable detection (faer
378 // amd.rs:419,433). Both elements AND variables in the kept
379 // neighbourhood contribute.
380 let mut hash: usize = 0;
381
382 // Element sub-pass.
383 if aggressive {
384 for p in p1..p2 {
385 let e = ws.iw[p] as usize;
386 let we = ws.w[e];
387 if we != 0 {
388 let dext = we - ws.wflg;
389 if dext > 0 {
390 deg += dext as usize;
391 ws.iw[pn] = e as i32;
392 pn += 1;
393 hash = hash.wrapping_add(e);
394 } else {
395 // Aggressive absorption: dead element folded
396 // into me right here (faer amd.rs:404-407).
397 ws.pe[e] = flip(me as i32);
398 ws.w[e] = 0;
399 }
400 }
401 }
402 } else {
403 for p in p1..p2 {
404 let e = ws.iw[p] as usize;
405 let we = ws.w[e];
406 if we != 0 {
407 // Invariant (O4): a live element in the non-aggressive pass
408 // always has `we >= ws.wflg`, so the difference is
409 // non-negative. Guard the unchecked `as usize` cast — a
410 // future regression that broke the invariant would
411 // sign-extend a negative difference to ~2^64 here.
412 debug_assert!(
413 we >= ws.wflg,
414 "stale mark: w[e]={we} < wflg={} would wrap as usize",
415 ws.wflg
416 );
417 let dext = (we - ws.wflg) as usize;
418 deg += dext;
419 ws.iw[pn] = e as i32;
420 pn += 1;
421 hash = hash.wrapping_add(e);
422 }
423 }
424 }
425
426 // Record number-of-elements + 1 (the +1 reserves the slot
427 // for `me` which we insert at p1 below).
428 ws.elen[i] = (pn - p1 + 1) as i32;
429 let p3 = pn;
430 let p4 = p1 + ws.len[i] as usize;
431 // Variable sub-pass.
432 for p in p2..p4 {
433 let j = ws.iw[p] as usize;
434 let nvj = ws.nv[j];
435 if nvj > 0 {
436 deg += nvj as usize;
437 ws.iw[pn] = j as i32;
438 pn += 1;
439 hash = hash.wrapping_add(j);
440 }
441 }
442
443 if ws.elen[i] == 1 && p3 == pn {
444 // Mass elimination: i's only element is `me` and it has
445 // no surviving outside variables, so it will pivot
446 // concurrently with me. Fold its supervariable count
447 // into nvpiv / nel and drop it from degme.
448 ws.pe[i] = flip(me as i32);
449 let nvi = -ws.nv[i];
450 debug_assert!(nvi >= 0);
451 degme -= nvi as usize;
452 nvpiv += nvi;
453 ws.nel += nvi as usize;
454 ws.nv[i] = 0;
455 ws.elen[i] = NONE;
456 ws.n_mass_elim += 1;
457 } else {
458 ws.degree[i] = ws.degree[i].min(deg as i32);
459 // Swap-dance to put `me` at the head of i's element list.
460 if p1 != pn {
461 ws.iw[pn] = ws.iw[p3];
462 }
463 if p3 != p1 {
464 ws.iw[p3] = ws.iw[p1];
465 }
466 ws.iw[p1] = me as i32;
467 ws.len[i] = (pn - p1 + 1) as i32;
468
469 // Insert i into a hash bucket threaded through
470 // head/next/last via sign-bit encoding (faer
471 // amd.rs:452-460). `head[hash] <= NONE` distinguishes
472 // two cases:
473 // * NONE (-1) or flip(prev_head)<=-2 mean the bucket
474 // is either empty or already a flip-encoded bucket
475 // chain: next[i]=flip(old), head[hash]=flip(i).
476 // * j>=0 means head[hash] still holds an unrelated
477 // degree-list head. Hijack last[j] to chain
478 // bucket members (last[j] is NONE for a list head,
479 // so no information is destroyed).
480 let h = hash % ws.n;
481 let j = ws.head[h];
482 if j <= NONE {
483 ws.next[i] = flip(j);
484 ws.head[h] = flip(i as i32);
485 } else {
486 ws.next[i] = ws.last[j as usize];
487 ws.last[j as usize] = i as i32;
488 }
489 ws.last[i] = h as i32;
490 }
491 }
492
493 // Step bookkeeping (amd.rs:463-466). degme may have been reduced
494 // by mass elimination above.
495 let degme_i32 = degme as i32;
496 ws.degree[me] = degme_i32;
497 if degme_i32 > ws.lemax {
498 ws.lemax = degme_i32;
499 }
500 ws.wflg += ws.lemax;
501 ws.wflg = clear_flag(ws.wflg, ws.wbig, &mut ws.w);
502
503 // Supervariable detection (faer amd.rs:467-515). For each hash
504 // chain anchored at a still-marked member (nv[i] < 0), walk the
505 // chain marking i's variable neighbourhood with `wflg`, then
506 // compare each follower j to i: if len/elen agree AND every
507 // variable neighbour of j is also marked, merge j into i.
508 //
509 // head / next / last are being reused here as hash-bucket
510 // storage; they are restored to NONE at the heads involved so
511 // the subsequent degree-list re-insertion pass can rebuild the
512 // degree buckets cleanly.
513 for pme in pme1..pme2_excl {
514 let i_anchor = ws.iw[pme] as usize;
515 if ws.nv[i_anchor] >= 0 {
516 continue; // already restored / mass-elim'd
517 }
518 let h = ws.last[i_anchor] as usize;
519 let j_head = ws.head[h];
520 let mut i: i32 = if j_head == NONE {
521 NONE
522 } else if j_head < NONE {
523 // Bucket was flip-encoded: flip(j_head) is the head.
524 ws.head[h] = NONE;
525 flip(j_head)
526 } else {
527 // Bucket chained via last[j_head]; restore last[j_head].
528 let chain_start = ws.last[j_head as usize];
529 ws.last[j_head as usize] = NONE;
530 chain_start
531 };
532 while i != NONE && ws.next[i as usize] != NONE {
533 let i_u = i as usize;
534 let ln = ws.len[i_u];
535 let eln = ws.elen[i_u];
536 let pi = ws.pe[i_u];
537 // Mark i's neighbourhood (everything past slot pe[i]=me).
538 for p in (pi + 1) as usize..(pi + ln) as usize {
539 ws.w[ws.iw[p] as usize] = ws.wflg;
540 }
541 let mut jlast = i_u;
542 let mut jp = ws.next[i_u];
543 while jp != NONE {
544 let jj = jp as usize;
545 let mut ok = ws.len[jj] == ln && ws.elen[jj] == eln;
546 if ok {
547 let pj = ws.pe[jj];
548 for p in (pj + 1) as usize..(pj + ln) as usize {
549 if ws.w[ws.iw[p] as usize] != ws.wflg {
550 ok = false;
551 break;
552 }
553 }
554 }
555 if ok {
556 // Merge j into i: j becomes a degree-0 ghost
557 // pointing at i via pe[j] = flip(i).
558 ws.pe[jj] = flip(i);
559 ws.nv[i_u] += ws.nv[jj];
560 ws.nv[jj] = 0;
561 ws.elen[jj] = NONE;
562 jp = ws.next[jj];
563 ws.next[jlast] = jp;
564 ws.n_supervar_merge += 1;
565 } else {
566 jlast = jj;
567 jp = ws.next[jj];
568 }
569 }
570 // Bump wflg to reset marks for the next chain head.
571 ws.wflg += 1;
572 i = ws.next[i_u];
573 }
574 }
575
576 // Re-insertion (amd.rs:516-537): every surviving var in the new
577 // element list gets its new degree, is pushed onto head[deg]
578 // LIFO, and me's own list is compacted down to just the
579 // survivors.
580 let mut p_write = pme1;
581 let nleft = ws.n - ws.nel;
582 for pme in pme1..pme2_excl {
583 let i = ws.iw[pme] as usize;
584 let nvi = -ws.nv[i];
585 if nvi > 0 {
586 ws.nv[i] = nvi;
587 let mut d = ws.degree[i] as usize + degme_i32 as usize - nvi as usize;
588 let cap = nleft - nvi as usize;
589 if d > cap {
590 d = cap;
591 }
592 let inext = ws.head[d];
593 if inext != NONE {
594 ws.last[inext as usize] = i as i32;
595 }
596 ws.next[i] = inext;
597 ws.last[i] = NONE;
598 ws.head[d] = i as i32;
599 if d < ws.mindeg {
600 ws.mindeg = d;
601 }
602 ws.degree[i] = d as i32;
603 ws.iw[p_write] = i as i32;
604 p_write += 1;
605 }
606 }
607
608 // Me bookkeeping (amd.rs:538-546).
609 ws.nv[me] = nvpiv;
610 ws.len[me] = (p_write as i32) - pme1 as i32;
611 if ws.len[me] == 0 {
612 ws.pe[me] = NONE;
613 ws.w[me] = 0;
614 }
615 if elenme != 0 {
616 ws.pfree = p_write;
617 }
618
619 // Flop counters (amd.rs:547-557).
620 let f = nvpiv as f64;
621 let r = degme_i32 as f64 + ws.ndense as f64;
622 let lnzme = f * r + (f - 1.0) * f / 2.0;
623 let s = f * r * r + r * (f - 1.0) * f + (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
624
625 StepFlops {
626 ndiv: lnzme,
627 nms_lu: s,
628 nms_ldl: (s + lnzme) / 2.0,
629 }
630}
631
632/// AMF bucket index for a quantized fill score (`MinFill::bucket`).
633///
634/// Mirrors the metric trait's `MinFill::bucket` but is duplicated as a
635/// free function here so `algo.rs` does not need a back-edge to
636/// `metric.rs` (which itself imports from `algo`). Inlined.
637#[inline(always)]
638fn amf_bucket_of(score: i64, n: usize) -> usize {
639 if score <= 0 {
640 return 0;
641 }
642 let s = score as usize;
643 if s <= n {
644 return s;
645 }
646 let pas = (n / 8).max(1);
647 let nbbuck = 2 * n;
648 ((s - n) / pas + n).min(nbbuck)
649}
650
651/// AMF working-fill *surface contribution* of an element with current
652/// external degree `dext` and total degree `degree`:
653/// `dext * (2*degree - dext - 1)` (Amestoy 1999 thesis; MUMPS
654/// `ana_orderings.F:4810`).
655///
656/// Computed in `i64`: both factors are `O(n)`, so the product reaches
657/// ~`n^2` and overflows `i32` for `n` ≳ 46k (`i32::MAX` is
658/// 2_147_483_647 and `46342 * 46341 = 2_147_534_622` already exceeds
659/// it). In release the old `i32` form wrapped silently, feeding garbage
660/// into the RMF pivot score; in debug it panicked. The value is later
661/// consumed as `f64`, so widening loses no precision (O1,
662/// `dev/research/repo-review-2026-06-09.md`).
663#[inline(always)]
664fn amf_wf_surface(dext: i64, degree: i64) -> i64 {
665 dext * (2 * degree - dext - 1)
666}
667
668/// AMF per-supervariable working-fill accumulation
669/// `wf4 + 2 * nvi * wf3` (Amestoy 1999 eq. for the B3 contribution;
670/// MUMPS `ana_orderings.F:4810`). Computed in `i64` for the same
671/// `O(n^2)` overflow reason as [`amf_wf_surface`]: `nvi` (supervariable
672/// size) and `wf3` (sum of neighbour supervariable sizes) are each
673/// `O(n)` (O1, `dev/research/repo-review-2026-06-09.md`).
674#[inline(always)]
675fn amf_wf_combine(wf4: i64, nvi: i64, wf3: i64) -> i64 {
676 wf4 + 2 * nvi * wf3
677}
678
679/// Saturation cap used when quantizing the AMF RMF score into `i32`.
680/// `i32::MAX - 1` matches MUMPS `idummy = huge(idummy) - 1`
681/// (`ana_orderings.F:4230`).
682const AMF_DUMMY_I32: i32 = i32::MAX - 1;
683
684/// AMF analogue of [`select_pivot`]. Linear-scans coarse buckets
685/// (`idx > n`) for the entry with the smallest exact score; takes the
686/// head for fine buckets.
687///
688/// Side effects: `ws.mindeg` advances to the chosen bucket index. The
689/// chosen `me` is unlinked from its degree-list chain (head update for
690/// fine buckets, doubly-linked unlink for coarse buckets).
691///
692/// Reference: `ana_orderings.F:4392-4427`.
693pub fn select_pivot_amf(ws: &mut Workspace) -> Option<usize> {
694 let n = ws.n;
695 let nbuck = ws.head.len();
696 let mut deg = ws.mindeg;
697 while deg < nbuck && ws.head[deg] == NONE {
698 deg += 1;
699 }
700 if deg >= nbuck {
701 return None;
702 }
703 ws.mindeg = deg;
704 let head_me = ws.head[deg] as usize;
705
706 let me;
707 if deg > n {
708 // Coarse bucket: linear scan for the minimum-score entry.
709 let mut best = head_me;
710 let mut best_score = ws.wf[best];
711 let mut j = ws.next[best];
712 while j != NONE {
713 let ju = j as usize;
714 if ws.wf[ju] < best_score {
715 best_score = ws.wf[ju];
716 best = ju;
717 }
718 j = ws.next[ju];
719 }
720 me = best;
721 // Doubly-linked unlink (best may be mid-chain).
722 let ilast = ws.last[me];
723 let inext = ws.next[me];
724 if inext != NONE {
725 ws.last[inext as usize] = ilast;
726 }
727 if ilast != NONE {
728 ws.next[ilast as usize] = inext;
729 } else {
730 ws.head[deg] = inext;
731 }
732 } else {
733 me = head_me;
734 let inext = ws.next[me];
735 if inext != NONE {
736 ws.last[inext as usize] = NONE;
737 }
738 ws.head[deg] = inext;
739 }
740 Some(me)
741}
742
743/// AMF analogue of [`create_element`]. Identical structure; differs
744/// only in the bucket-index used when unlinking absorbed neighbours
745/// from their degree lists. AMD reads `degree[i]` (which doubles as
746/// the bucket index because AMD's bucket is identity); AMF computes
747/// `amf_bucket_of(wf[i], n)` because the AMF score and running degree
748/// are stored in distinct fields (`wf` vs `degree`).
749pub fn create_element_amf(
750 ws: &mut Workspace,
751 me: usize,
752) -> Result<(usize, usize, i32, usize), OrderingError> {
753 let n = ws.n;
754 let elenme = ws.elen[me];
755 let nvpiv = ws.nv[me];
756 ws.nel += nvpiv as usize;
757 ws.nv[me] = -nvpiv;
758 let mut degme: usize = 0;
759 let pme1: usize;
760 let pme2: i32;
761
762 if elenme == 0 {
763 let pme1_s = ws.pe[me];
764 pme1 = pme1_s as usize;
765 let list_start = pme1;
766 let list_end = list_start + ws.len[me] as usize;
767 let mut pme2_s = pme1_s - 1;
768 for p in list_start..list_end {
769 let i = ws.iw[p] as usize;
770 let nvi = ws.nv[i];
771 if nvi > 0 {
772 degme += nvi as usize;
773 ws.nv[i] = -nvi;
774 pme2_s += 1;
775 ws.iw[pme2_s as usize] = i as i32;
776 let ilast = ws.last[i];
777 let inext = ws.next[i];
778 if inext != NONE {
779 ws.last[inext as usize] = ilast;
780 }
781 if ilast != NONE {
782 ws.next[ilast as usize] = inext;
783 } else {
784 let h_idx = amf_bucket_of(ws.wf[i], n);
785 ws.head[h_idx] = inext;
786 }
787 }
788 }
789 pme2 = pme2_s;
790 } else {
791 let mut p = ws.pe[me] as usize;
792 let mut pme1_rw: usize = ws.pfree;
793 let slenme = (ws.len[me] - elenme) as usize;
794 let elenme_u = elenme as usize;
795 for knt1 in 1..=elenme_u + 1 {
796 let e: usize;
797 let mut pj: usize;
798 let ln: usize;
799 if knt1 > elenme_u {
800 e = me;
801 pj = p;
802 ln = slenme;
803 } else {
804 e = ws.iw[p] as usize;
805 p += 1;
806 pj = ws.pe[e] as usize;
807 ln = ws.len[e] as usize;
808 }
809 for knt2 in 1..=ln {
810 let i = ws.iw[pj] as usize;
811 pj += 1;
812 let nvi = ws.nv[i];
813 if nvi > 0 {
814 if ws.pfree >= ws.iwlen {
815 ws.pe[me] = p as i32;
816 ws.len[me] -= knt1 as i32;
817 if ws.len[me] == 0 {
818 ws.pe[me] = NONE;
819 }
820 ws.pe[e] = pj as i32;
821 ws.len[e] = (ln - knt2) as i32;
822 if ws.len[e] == 0 {
823 ws.pe[e] = NONE;
824 }
825 ws.ncmpa += 1;
826 for j in 0..ws.n {
827 let pn = ws.pe[j];
828 if pn >= 0 {
829 let pn_u = pn as usize;
830 ws.pe[j] = ws.iw[pn_u];
831 ws.iw[pn_u] = flip(j as i32);
832 }
833 }
834 let mut psrc = 0usize;
835 let mut pdst = 0usize;
836 let pend = pme1_rw;
837 while psrc < pend {
838 let j_marker = flip(ws.iw[psrc]);
839 psrc += 1;
840 if j_marker >= 0 {
841 let j = j_marker as usize;
842 ws.iw[pdst] = ws.pe[j];
843 ws.pe[j] = pdst as i32;
844 pdst += 1;
845 let lenj = ws.len[j] as usize;
846 if lenj > 0 {
847 ws.iw.copy_within(psrc..psrc + lenj - 1, pdst);
848 psrc += lenj - 1;
849 pdst += lenj - 1;
850 }
851 }
852 }
853 let p1 = pdst;
854 ws.iw.copy_within(pme1_rw..ws.pfree, pdst);
855 pdst += ws.pfree - pme1_rw;
856 pme1_rw = p1;
857 ws.pfree = pdst;
858 pj = ws.pe[e] as usize;
859 p = ws.pe[me] as usize;
860 }
861 degme += nvi as usize;
862 ws.nv[i] = -nvi;
863 ws.iw[ws.pfree] = i as i32;
864 ws.pfree += 1;
865 let ilast = ws.last[i];
866 let inext = ws.next[i];
867 if inext != NONE {
868 ws.last[inext as usize] = ilast;
869 }
870 if ilast != NONE {
871 ws.next[ilast as usize] = inext;
872 } else {
873 let h_idx = amf_bucket_of(ws.wf[i], n);
874 ws.head[h_idx] = inext;
875 }
876 }
877 }
878 if e != me {
879 ws.pe[e] = flip(me as i32);
880 ws.w[e] = 0;
881 }
882 }
883 pme1 = pme1_rw;
884 pme2 = (ws.pfree - 1) as i32;
885 }
886
887 ws.degree[me] = degme as i32;
888 ws.pe[me] = pme1 as i32;
889 ws.len[me] = pme2 - pme1 as i32 + 1;
890 ws.elen[me] = flip(nvpiv + degme as i32);
891 ws.wflg = clear_flag(ws.wflg, ws.wbig, &mut ws.w);
892
893 let pme2_excl: usize = if pme2 < pme1 as i32 {
894 pme1
895 } else {
896 (pme2 + 1) as usize
897 };
898 Ok((pme1, pme2_excl, nvpiv, degme))
899}
900
901/// AMF analogue of [`finalize_step`]. The Pass-1 element seeding,
902/// hash-bucket detection, and supervariable-merge structure mirror
903/// AMD; the per-iteration accumulator carries the AMF triple
904/// `(deg, wf3, wf4)` (Amestoy 1999 thesis), and the re-insertion
905/// computes the quantized RMF score and inserts at
906/// `head[amf_bucket_of(wf[i], n)]`.
907///
908/// Six metric-specific sites compared to AMD (numbered per
909/// `dev/research/amf-clean-room.md` Section 6):
910/// 1. Pass-1 also resets `wf[e] = 0` on the first touch of each
911/// element (lazy cache sentinel).
912/// 2. Pass-2 element walk caches `wf[e] = dext * (2*deg(e) - dext - 1)`
913/// on first encounter and accumulates `wf4 += wf[e]`.
914/// 3. Pass-2 variable walk accumulates `wf3 += nv[j]`.
915/// 4. Loose-degree special case zeroes `wf3 = wf4 = 0`; the kept
916/// `degree[i]` cannot have a meaningful WF-subtraction so the
917/// AMF score is reset.
918/// 5. Supervariable merge takes `wf[i] = max(wf[i], wf[j])`.
919/// 6. Re-insertion uses the saturated/regular RMF formula with
920/// `dummy = i32::MAX - 1`, quantizes via `bucket(wf[i], n)`, and
921/// threads through `head` of length `2 * n + 2`.
922///
923/// Reference: `ana_orderings.F:4660-5025`.
924#[allow(clippy::too_many_arguments)]
925pub fn finalize_step_amf(
926 ws: &mut Workspace,
927 me: usize,
928 pme1: usize,
929 pme2_excl: usize,
930 nvpiv: i32,
931 degme: usize,
932 elenme: i32,
933 aggressive: bool,
934) -> StepFlops {
935 let n = ws.n;
936 let mut degme = degme;
937 let mut nvpiv = nvpiv;
938
939 // Pass 1: seed w[e] for every element in each member's list, and
940 // reset wf[e] = 0 on the first touch (lazy cache for Pass-2).
941 for pme in pme1..pme2_excl {
942 let i = ws.iw[pme] as usize;
943 let eln = ws.elen[i];
944 if eln > 0 {
945 let nvi = -ws.nv[i];
946 let wnvi = ws.wflg - nvi;
947 let pi = ws.pe[i] as usize;
948 for k in 0..eln as usize {
949 let e = ws.iw[pi + k] as usize;
950 let mut we = ws.w[e];
951 if we >= ws.wflg {
952 we -= nvi;
953 } else if we != 0 {
954 we = ws.degree[e] + wnvi;
955 // O21 (repo-review-2026-06-09): `wf[e] = 0` is the
956 // lazy-cache "surface not yet computed this iteration"
957 // sentinel for Pass-2 below. It is intentionally NOT
958 // distinct from a genuine surface contribution of 0:
959 // `amf_wf_surface(dext, deg) = dext*(2*deg - dext - 1)`
960 // is 0 for a live element whenever `dext == 2*deg(e)-1`
961 // (e.g. dext=1, deg=1). When that happens `wf[e]` stays
962 // 0, so the Pass-2 `if wf[e] == 0` check re-treats it as
963 // uncached and recomputes the surface for every member
964 // that touches `e`. This is benign: `amf_wf_surface` is
965 // pure in (dext, degree[e]) — both stable across one
966 // Pass-2 — so the recompute yields the same 0 and the
967 // accumulated `wf4` (hence the RMF score and the
968 // permutation) is unchanged; only a few integer multiplies
969 // are redundant. A distinguishing sentinel (e.g. -1) was
970 // rejected: `wf` is reused for variable scores
971 // (supervariable-merge `max`, re-insertion bucket
972 // quantization), so -1 would have to be proven never to
973 // leak into either across the AMD and AMF paths — added
974 // correctness risk for a handful of saved ops.
975 // "Correctness before performance." See
976 // dev/tried-and-rejected.md (O21).
977 ws.wf[e] = 0;
978 }
979 ws.w[e] = we;
980 }
981 }
982 }
983
984 // Pass 2: AMF triple-accumulator (deg, wf3, wf4), aggressive
985 // absorption on dext == 0, mass elimination, hash-bucket insert.
986 for pme in pme1..pme2_excl {
987 let i = ws.iw[pme] as usize;
988 let p1 = ws.pe[i] as usize;
989 let p2 = p1 + ws.elen[i] as usize;
990 let mut pn = p1;
991 let mut deg: usize = 0;
992 let mut hash: usize = 0;
993 let mut wf3: i64 = 0;
994 let mut wf4: i64 = 0;
995 let nvi = -ws.nv[i];
996
997 // Element sub-pass.
998 if aggressive {
999 for p in p1..p2 {
1000 let e = ws.iw[p] as usize;
1001 let we = ws.w[e];
1002 if we != 0 {
1003 let dext = we - ws.wflg;
1004 if dext > 0 {
1005 // `wf[e] == 0` means "uncached this iter" OR a genuine
1006 // 0 surface (O21) — recompute on the latter is benign
1007 // (same value). See the Pass-1 reset comment above.
1008 if ws.wf[e] == 0 {
1009 // First touch this iter: cache the surface
1010 // contribution dext*(2*deg(e) - dext - 1).
1011 ws.wf[e] = amf_wf_surface(dext as i64, ws.degree[e] as i64);
1012 }
1013 wf4 += ws.wf[e];
1014 deg += dext as usize;
1015 ws.iw[pn] = e as i32;
1016 pn += 1;
1017 hash = hash.wrapping_add(e);
1018 } else {
1019 // Aggressive absorption.
1020 ws.pe[e] = flip(me as i32);
1021 ws.w[e] = 0;
1022 }
1023 }
1024 }
1025 } else {
1026 for p in p1..p2 {
1027 let e = ws.iw[p] as usize;
1028 let we = ws.w[e];
1029 if we != 0 {
1030 let dext = we - ws.wflg;
1031 // Invariant (O4): non-aggressive pass keeps `we >= ws.wflg`,
1032 // so `dext >= 0`. Guard the `dext as usize` cast below
1033 // against a future regression wrapping a negative dext to
1034 // ~2^64.
1035 debug_assert!(
1036 dext >= 0,
1037 "stale mark: w[e]={we} < wflg={} would wrap as usize",
1038 ws.wflg
1039 );
1040 // `wf[e] == 0` means "uncached this iter" OR a genuine 0
1041 // surface (O21) — recompute on the latter is benign (same
1042 // value). See the Pass-1 reset comment above.
1043 if ws.wf[e] == 0 {
1044 ws.wf[e] = amf_wf_surface(dext as i64, ws.degree[e] as i64);
1045 }
1046 wf4 += ws.wf[e];
1047 deg += dext as usize;
1048 ws.iw[pn] = e as i32;
1049 pn += 1;
1050 hash = hash.wrapping_add(e);
1051 }
1052 }
1053 }
1054
1055 ws.elen[i] = (pn - p1 + 1) as i32;
1056 let p3 = pn;
1057 let p4 = p1 + ws.len[i] as usize;
1058 // Variable sub-pass.
1059 for p in p2..p4 {
1060 let j = ws.iw[p] as usize;
1061 let nvj = ws.nv[j];
1062 if nvj > 0 {
1063 deg += nvj as usize;
1064 wf3 += nvj as i64;
1065 ws.iw[pn] = j as i32;
1066 pn += 1;
1067 hash = hash.wrapping_add(j);
1068 }
1069 }
1070
1071 if ws.elen[i] == 1 && p3 == pn {
1072 // Mass elimination (equivalent to MUMPS DEG==0 in
1073 // aggressive / non-halo mode).
1074 ws.pe[i] = flip(me as i32);
1075 let nvi_sv = -ws.nv[i];
1076 debug_assert!(nvi_sv >= 0);
1077 degme -= nvi_sv as usize;
1078 nvpiv += nvi_sv;
1079 ws.nel += nvi_sv as usize;
1080 ws.nv[i] = 0;
1081 ws.elen[i] = NONE;
1082 ws.n_mass_elim += 1;
1083 } else {
1084 // Loose-degree special case: if the prior degree estimate
1085 // is already tighter, keep it but the WF accumulator is
1086 // not subtraction-safe — zero it.
1087 if ws.degree[i] < deg as i32 {
1088 wf3 = 0;
1089 wf4 = 0;
1090 } else {
1091 ws.degree[i] = deg as i32;
1092 }
1093 // wf[i] = wf4 + 2 * nvi * wf3 (Amestoy 1999 eq. for B3
1094 // contribution; see ana_orderings.F:4810).
1095 ws.wf[i] = amf_wf_combine(wf4, nvi as i64, wf3);
1096
1097 // Swap-dance to put `me` at the head of i's element list.
1098 if p1 != pn {
1099 ws.iw[pn] = ws.iw[p3];
1100 }
1101 if p3 != p1 {
1102 ws.iw[p3] = ws.iw[p1];
1103 }
1104 ws.iw[p1] = me as i32;
1105 ws.len[i] = (pn - p1 + 1) as i32;
1106
1107 // Hash-bucket insertion (sign-bit encoding identical to
1108 // AMD; head reuse is safe because hash mod n falls in
1109 // the fine bucket region [0, n)).
1110 let h = hash % n;
1111 let j = ws.head[h];
1112 if j <= NONE {
1113 ws.next[i] = flip(j);
1114 ws.head[h] = flip(i as i32);
1115 } else {
1116 ws.next[i] = ws.last[j as usize];
1117 ws.last[j as usize] = i as i32;
1118 }
1119 ws.last[i] = h as i32;
1120 }
1121 }
1122
1123 let degme_i32 = degme as i32;
1124 ws.degree[me] = degme_i32;
1125 if degme_i32 > ws.lemax {
1126 ws.lemax = degme_i32;
1127 }
1128 ws.wflg += ws.lemax;
1129 ws.wflg = clear_flag(ws.wflg, ws.wbig, &mut ws.w);
1130
1131 // Supervariable detection. Identical to AMD except merge updates
1132 // wf[i] = max(wf[i], wf[j]).
1133 for pme in pme1..pme2_excl {
1134 let i_anchor = ws.iw[pme] as usize;
1135 if ws.nv[i_anchor] >= 0 {
1136 continue;
1137 }
1138 let h = ws.last[i_anchor] as usize;
1139 let j_head = ws.head[h];
1140 let mut i: i32 = if j_head == NONE {
1141 NONE
1142 } else if j_head < NONE {
1143 ws.head[h] = NONE;
1144 flip(j_head)
1145 } else {
1146 let chain_start = ws.last[j_head as usize];
1147 ws.last[j_head as usize] = NONE;
1148 chain_start
1149 };
1150 while i != NONE && ws.next[i as usize] != NONE {
1151 let i_u = i as usize;
1152 let ln = ws.len[i_u];
1153 let eln = ws.elen[i_u];
1154 let pi = ws.pe[i_u];
1155 for p in (pi + 1) as usize..(pi + ln) as usize {
1156 ws.w[ws.iw[p] as usize] = ws.wflg;
1157 }
1158 let mut jlast = i_u;
1159 let mut jp = ws.next[i_u];
1160 while jp != NONE {
1161 let jj = jp as usize;
1162 let mut ok = ws.len[jj] == ln && ws.elen[jj] == eln;
1163 if ok {
1164 let pj = ws.pe[jj];
1165 for p in (pj + 1) as usize..(pj + ln) as usize {
1166 if ws.w[ws.iw[p] as usize] != ws.wflg {
1167 ok = false;
1168 break;
1169 }
1170 }
1171 }
1172 if ok {
1173 ws.pe[jj] = flip(i);
1174 // AMF merge: wf takes the max of the two scores.
1175 let wf_j = ws.wf[jj];
1176 if wf_j > ws.wf[i_u] {
1177 ws.wf[i_u] = wf_j;
1178 }
1179 ws.nv[i_u] += ws.nv[jj];
1180 ws.nv[jj] = 0;
1181 ws.elen[jj] = NONE;
1182 jp = ws.next[jj];
1183 ws.next[jlast] = jp;
1184 ws.n_supervar_merge += 1;
1185 } else {
1186 jlast = jj;
1187 jp = ws.next[jj];
1188 }
1189 }
1190 ws.wflg += 1;
1191 i = ws.next[i_u];
1192 }
1193 }
1194
1195 // Re-insertion: AMF saturated/regular RMF, quantize, bucket.
1196 let dummy_f = AMF_DUMMY_I32 as f64;
1197 let n_f = if n == 0 { 1.0 } else { n as f64 };
1198 let mut p_write = pme1;
1199 let nleft = ws.n - ws.nel;
1200 for pme in pme1..pme2_excl {
1201 let i = ws.iw[pme] as usize;
1202 let nvi = -ws.nv[i];
1203 if nvi > 0 {
1204 ws.nv[i] = nvi;
1205 let degme_i = degme_i32;
1206 let nvi_i = nvi;
1207 let deg_i = ws.degree[i];
1208 let rmf: f64 = if (deg_i as usize) + (degme_i as usize) > nleft {
1209 // Saturated branch. RMF1 uses original DEG.
1210 let deg_f = deg_i as f64;
1211 let rmf1 = deg_f * (deg_f - 1.0 + 2.0 * degme_i as f64) - ws.wf[i] as f64;
1212 let new_deg = (nleft as i32) - nvi_i;
1213 ws.degree[i] = new_deg;
1214 let nd = new_deg as f64;
1215 let rmf_new =
1216 nd * (nd - 1.0) - (degme_i - nvi_i) as f64 * (degme_i - nvi_i - 1) as f64;
1217 rmf_new.min(rmf1)
1218 } else {
1219 let deg_f = deg_i as f64;
1220 ws.degree[i] = deg_i + degme_i - nvi_i;
1221 deg_f * (deg_f - 1.0 + 2.0 * degme_i as f64) - ws.wf[i] as f64
1222 };
1223 let rmf = rmf / (nvi_i as f64 + 1.0);
1224 let qscore: i32 = if rmf < dummy_f {
1225 rmf.round() as i32
1226 } else if rmf / n_f < dummy_f {
1227 (rmf / n_f).round() as i32
1228 } else {
1229 AMF_DUMMY_I32
1230 };
1231 ws.wf[i] = qscore.max(1) as i64;
1232
1233 let d = amf_bucket_of(ws.wf[i], n);
1234 let inext = ws.head[d];
1235 if inext != NONE {
1236 ws.last[inext as usize] = i as i32;
1237 }
1238 ws.next[i] = inext;
1239 ws.last[i] = NONE;
1240 ws.head[d] = i as i32;
1241 if d < ws.mindeg {
1242 ws.mindeg = d;
1243 }
1244 ws.iw[p_write] = i as i32;
1245 p_write += 1;
1246 }
1247 }
1248
1249 // Me bookkeeping (same as AMD).
1250 ws.nv[me] = nvpiv;
1251 ws.len[me] = (p_write as i32) - pme1 as i32;
1252 if ws.len[me] == 0 {
1253 ws.pe[me] = NONE;
1254 ws.w[me] = 0;
1255 }
1256 if elenme != 0 {
1257 ws.pfree = p_write;
1258 }
1259
1260 // Flop counters (identical to AMD).
1261 let f = nvpiv as f64;
1262 let r = degme_i32 as f64 + ws.ndense as f64;
1263 let lnzme = f * r + (f - 1.0) * f / 2.0;
1264 let s = f * r * r + r * (f - 1.0) * f + (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
1265
1266 StepFlops {
1267 ndiv: lnzme,
1268 nms_lu: s,
1269 nms_ldl: (s + lnzme) / 2.0,
1270 }
1271}
1272
1273/// AMF analogue of [`run_elimination`].
1274pub fn run_elimination_amf(
1275 ws: &mut Workspace,
1276 aggressive: bool,
1277) -> Result<StepFlops, OrderingError> {
1278 let mut flops = StepFlops::default();
1279 while ws.nel < ws.n {
1280 let me = match select_pivot_amf(ws) {
1281 Some(m) => m,
1282 None => break,
1283 };
1284 let elenme = ws.elen[me];
1285 let (pme1, pme2, nvpiv, degme) = create_element_amf(ws, me)?;
1286 flops.accumulate(finalize_step_amf(
1287 ws, me, pme1, pme2, nvpiv, degme, elenme, aggressive,
1288 ));
1289 }
1290 let f = ws.ndense as f64;
1291 let lnzme = (f - 1.0) * f / 2.0;
1292 let s = (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
1293 flops.ndiv += lnzme;
1294 flops.nms_lu += s;
1295 flops.nms_ldl += (s + lnzme) / 2.0;
1296 Ok(flops)
1297}
1298
1299/// Run the main AMD elimination loop until every live supervariable
1300/// has been either pivoted or dense-deferred. Returns the
1301/// accumulated flop counts.
1302///
1303/// Mass elimination and supervariable detection are absent (Slice
1304/// B). Inline garbage collection is live; fixtures whose working
1305/// set transiently exceeds `iwlen` recover via in-place compaction
1306/// and bump `ws.ncmpa`.
1307///
1308/// At exit: `ws.nel == ws.n`, every `pe[i]` either points to a
1309/// live parent (to be path-compressed by the postorder phase) or
1310/// is `NONE` / `flip(parent)`.
1311pub fn run_elimination(ws: &mut Workspace, aggressive: bool) -> Result<StepFlops, OrderingError> {
1312 let mut flops = StepFlops::default();
1313 while ws.nel < ws.n {
1314 let me = match select_pivot(ws) {
1315 Some(m) => m,
1316 None => break, // only dense-deferred survivors remain
1317 };
1318 let elenme = ws.elen[me];
1319 let (pme1, pme2, nvpiv, degme) = create_element(ws, me)?;
1320 flops.accumulate(finalize_step(
1321 ws, me, pme1, pme2, nvpiv, degme, elenme, aggressive,
1322 ));
1323 }
1324 // Dense-phase flop contribution (amd.rs:559-566).
1325 let f = ws.ndense as f64;
1326 let lnzme = (f - 1.0) * f / 2.0;
1327 let s = (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
1328 flops.ndiv += lnzme;
1329 flops.nms_lu += s;
1330 flops.nms_ldl += (s + lnzme) / 2.0;
1331 Ok(flops)
1332}
1333
1334/// Consume the post-elimination state and produce the final
1335/// permutation.
1336///
1337/// On entry `ws` must have completed [`run_elimination`]. Performs,
1338/// in order:
1339/// 1. Un-flip `pe` and `elen` (faer `amd.rs:567-572`) so `pe[i]`
1340/// holds the parent pivot and `elen[i]` holds the frontal size.
1341/// 2. Path compression (`amd.rs:573-590`): each absorbed
1342/// supervariable `i` (`nv[i] == 0`) has its `pe` chain walked
1343/// until a pivot is found, then all intermediates are rewritten
1344/// to point at that pivot directly. Inert in Slice A — becomes
1345/// active once supervariable detection (Slice B) lands.
1346/// 3. Assembly-tree postorder with big-child-last heuristic
1347/// (`amd.rs:5-49`, `amd.rs:51-124`, `amd.rs:593-599`). Reuses
1348/// `head`/`next`/`last` as child/sibling/stack scratch; writes
1349/// the postorder index into `w`.
1350/// 4. Invert `w` into `head[k] = pivot at postorder k`
1351/// (`amd.rs:600-606`).
1352/// 5. Assign starting positions to each pivot's block
1353/// (`amd.rs:607-615`): `next[e] = nel`, then `nel += nv[e]`.
1354/// 6. Expand absorbed supervariables + place dense-deferred variables
1355/// at the tail (`amd.rs:617-629`).
1356/// 7. Emit `perm`: `perm[next[i]] = i` for every `i`
1357/// (`amd.rs:631-633`).
1358///
1359/// Returns a permutation `perm` of length `n` where `perm[k]` is the
1360/// column of the original matrix to be eliminated at step `k`.
1361pub fn finalize_permutation(ws: &mut Workspace) -> Vec<i32> {
1362 let n = ws.n;
1363 if n == 0 {
1364 return Vec::new();
1365 }
1366
1367 // Step 1: un-flip.
1368 for x in ws.pe.iter_mut() {
1369 *x = flip(*x);
1370 }
1371 for x in ws.elen.iter_mut() {
1372 *x = flip(*x);
1373 }
1374
1375 // Step 2: path-compress absorbed supervariables.
1376 for i in 0..n {
1377 if ws.nv[i] == 0 {
1378 let head_i = ws.pe[i];
1379 if head_i == NONE {
1380 continue;
1381 }
1382 let mut j = head_i as usize;
1383 while ws.nv[j] == 0 {
1384 j = ws.pe[j] as usize;
1385 }
1386 let e = j as i32;
1387 let mut j = i;
1388 while ws.nv[j] == 0 {
1389 let jnext = ws.pe[j];
1390 ws.pe[j] = e;
1391 j = jnext as usize;
1392 }
1393 }
1394 }
1395
1396 // Step 3: assembly-tree postorder. Writes postorder index into w.
1397 assembly_tree_postorder(ws);
1398
1399 // Step 4: invert w into head.
1400 for x in ws.head.iter_mut() {
1401 *x = NONE;
1402 }
1403 for e in 0..n {
1404 let k = ws.w[e];
1405 if k != NONE {
1406 ws.head[k as usize] = e as i32;
1407 }
1408 }
1409
1410 // Step 5: pivot-block starting positions.
1411 for x in ws.next.iter_mut() {
1412 *x = NONE;
1413 }
1414 let mut nel: i32 = 0;
1415 for &e in ws.head.iter() {
1416 if e == NONE {
1417 break;
1418 }
1419 let eu = e as usize;
1420 ws.next[eu] = nel;
1421 nel += ws.nv[eu];
1422 }
1423
1424 // Step 6: expand absorbed supervars + place dense-deferred at tail.
1425 for i in 0..n {
1426 if ws.nv[i] == 0 {
1427 let e = ws.pe[i];
1428 if e != NONE {
1429 let eu = e as usize;
1430 ws.next[i] = ws.next[eu];
1431 ws.next[eu] += 1;
1432 } else {
1433 ws.next[i] = nel;
1434 nel += 1;
1435 }
1436 }
1437 }
1438
1439 // Step 7: emit perm.
1440 let mut perm = vec![0i32; n];
1441 for i in 0..n {
1442 perm[ws.next[i] as usize] = i as i32;
1443 }
1444 perm
1445}
1446
1447/// Build the assembly tree from `pe` (parent pointers) + `nv`
1448/// (`> 0` selects pivots), apply the big-child-last heuristic, and
1449/// run an iterative DFS postorder. Result indexes go into `ws.w`
1450/// (NONE for non-pivot nodes).
1451fn assembly_tree_postorder(ws: &mut Workspace) {
1452 let n = ws.n;
1453 // Repurpose head/next/last as child/sibling/stack scratch.
1454 for x in ws.head.iter_mut() {
1455 *x = NONE;
1456 }
1457 for x in ws.next.iter_mut() {
1458 *x = NONE;
1459 }
1460 // Link each pivot as a child of its parent. Reverse order so that
1461 // after building, child lists are in ascending index order.
1462 for j in (0..n).rev() {
1463 if ws.nv[j] > 0 {
1464 let parent = ws.pe[j];
1465 if parent >= 0 && (parent as usize) < n {
1466 let pu = parent as usize;
1467 ws.next[j] = ws.head[pu];
1468 ws.head[pu] = j as i32;
1469 }
1470 }
1471 }
1472 // Big-child-last heuristic: move the child with the largest
1473 // `elen` (frontal size) to the end of the sibling list so the
1474 // deepest-recursion subtree is visited last.
1475 for i in 0..n {
1476 if ws.nv[i] > 0 && ws.head[i] != NONE {
1477 let child0 = ws.head[i];
1478 let mut fprev: i32 = NONE;
1479 let mut bigfprev: i32 = NONE;
1480 let mut bigf: i32 = NONE;
1481 let mut maxfrsize: i32 = NONE;
1482 let mut f = child0;
1483 while f != NONE {
1484 let fu = f as usize;
1485 let frsize = ws.elen[fu];
1486 if frsize >= maxfrsize {
1487 maxfrsize = frsize;
1488 bigfprev = fprev;
1489 bigf = f;
1490 }
1491 fprev = f;
1492 f = ws.next[fu];
1493 }
1494 let bigfu = bigf as usize;
1495 let fnext = ws.next[bigfu];
1496 if fnext != NONE {
1497 if bigfprev != NONE {
1498 ws.next[bigfprev as usize] = fnext;
1499 } else {
1500 ws.head[i] = fnext;
1501 }
1502 ws.next[bigfu] = NONE;
1503 ws.next[fprev as usize] = bigf;
1504 }
1505 }
1506 }
1507 // Iterative DFS postorder from each pivot root.
1508 for x in ws.w.iter_mut() {
1509 *x = NONE;
1510 }
1511 let mut k: usize = 0;
1512 for i in 0..n {
1513 if ws.pe[i] == NONE && ws.nv[i] > 0 {
1514 k = post_tree_dfs(ws, i, k);
1515 }
1516 }
1517}
1518
1519/// Iterative DFS of one assembly-tree root. Stack lives in `last`,
1520/// child list in `head`, sibling links in `next`. Writes postorder
1521/// indices into `w`. Returns the next postorder index to assign.
1522fn post_tree_dfs(ws: &mut Workspace, root: usize, k_start: usize) -> usize {
1523 let mut k = k_start;
1524 let mut top: usize = 1;
1525 ws.last[0] = root as i32;
1526 while top > 0 {
1527 let i = ws.last[top - 1] as usize;
1528 let child0 = ws.head[i];
1529 if child0 != NONE {
1530 // Count children.
1531 let mut count = 0usize;
1532 let mut f = child0;
1533 while f != NONE {
1534 count += 1;
1535 f = ws.next[f as usize];
1536 }
1537 // Push children into stack slots [top, top+count) with the
1538 // first child at the highest position (popped first).
1539 let new_top = top + count;
1540 let mut t = new_top;
1541 let mut f = child0;
1542 loop {
1543 t -= 1;
1544 ws.last[t] = f;
1545 let nf = ws.next[f as usize];
1546 if nf == NONE {
1547 break;
1548 }
1549 f = nf;
1550 }
1551 top = new_top;
1552 ws.head[i] = NONE; // mark visited
1553 } else {
1554 top -= 1;
1555 ws.w[i] = k as i32;
1556 k += 1;
1557 }
1558 }
1559 k
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564 use super::*;
1565 use crate::quotient_graph::WorkspaceOptions;
1566 use crate::CscPattern;
1567
1568 fn ws_for<'a>(n: usize, cp: &'a [i32], ri: &'a [i32]) -> Workspace {
1569 let p = CscPattern::new(n, cp, ri).unwrap();
1570 Workspace::new(&p, &WorkspaceOptions::default()).unwrap()
1571 }
1572
1573 /// O1 (repo-review-2026-06-09.md): the AMF working-fill kernels must
1574 /// be computed in `i64`. Both factors are `O(n)`, so for `n` ≳ 46k
1575 /// the products exceed `i32::MAX` (2_147_483_647) and wrap silently
1576 /// in release / panic in debug, feeding garbage into the RMF pivot
1577 /// score. Oracle: the exact hand-computed `i64` value of the
1578 /// Amestoy 1999 formulas (`ana_orderings.F:4810`).
1579 #[test]
1580 fn amf_wf_kernels_do_not_overflow_i32() {
1581 // Surface contribution dext*(2*degree - dext - 1).
1582 // dext = degree = 46342 -> 46342 * 46341 = 2_147_534_622,
1583 // which is 50_975 above i32::MAX. Hand-computed external oracle.
1584 assert!(2_147_534_622_i64 > i32::MAX as i64);
1585 assert_eq!(amf_wf_surface(46342, 46342), 2_147_534_622_i64);
1586 // Small-value sanity: dext=3, degree=4 -> 3*(8-3-1) = 12.
1587 assert_eq!(amf_wf_surface(3, 4), 12);
1588
1589 // Combine wf4 + 2*nvi*wf3.
1590 // nvi = wf3 = 46342, wf4 = 0 -> 2 * 46342 * 46342
1591 // = 4_295_161_928, which exceeds both i32::MAX and u32::MAX.
1592 assert!(4_295_161_928_i64 > u32::MAX as i64);
1593 assert_eq!(amf_wf_combine(0, 46342, 46342), 4_295_161_928_i64);
1594 // Small-value sanity: 5 + 2*3*4 = 29.
1595 assert_eq!(amf_wf_combine(5, 3, 4), 29);
1596 }
1597
1598 #[test]
1599 fn select_pivot_empty() {
1600 // diag_4: every var pre-eliminated, no degree bucket populated.
1601 let cp = [0, 1, 2, 3, 4];
1602 let ri = [0, 1, 2, 3];
1603 let mut ws = ws_for(4, &cp, &ri);
1604 assert_eq!(select_pivot(&mut ws), None);
1605 }
1606
1607 #[test]
1608 fn select_pivot_lifo_on_tridiag() {
1609 // Tridiag 5: head[1] contains 4 -> 0 (LIFO).
1610 let cp = [0, 2, 5, 8, 11, 13];
1611 let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
1612 let mut ws = ws_for(5, &cp, &ri);
1613 assert_eq!(select_pivot(&mut ws), Some(4));
1614 assert_eq!(ws.mindeg, 1);
1615 // Head of deg-1 list now points to the remaining spoke (0).
1616 assert_eq!(ws.head[1], 0);
1617 assert_eq!(ws.last[0], NONE, "new head has no predecessor");
1618
1619 assert_eq!(select_pivot(&mut ws), Some(0));
1620 assert_eq!(ws.head[1], NONE, "deg-1 bucket drained");
1621
1622 // Next call scans from mindeg=1 upward; only deg-2 non-empty.
1623 assert_eq!(select_pivot(&mut ws), Some(3));
1624 assert_eq!(ws.mindeg, 2);
1625 }
1626
1627 #[test]
1628 fn create_element_elenme_zero_on_arrow_5_hub() {
1629 // Arrow 5: hub has deg 4, but it's dense-deferred? Let's check.
1630 // For n=5 default, dense = max(16, min(5, 10*sqrt(5))) = 5.
1631 // deg 4 < 5, so hub is LIVE and sits in head[4].
1632 // Spokes (deg 1) all share head[1]. The min-degree pivot is a
1633 // spoke. Let's pick spoke 4 (LIFO head of deg-1).
1634 let cp = [0, 5, 7, 9, 11, 13];
1635 let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
1636 let mut ws = ws_for(5, &cp, &ri);
1637 let me = select_pivot(&mut ws).unwrap();
1638 assert_eq!(me, 4, "first pivot is the LIFO head of deg-1");
1639 // elen[4] == 0 (no elements yet).
1640 assert_eq!(ws.elen[4], 0);
1641 let (pme1, pme2_excl, nvpiv, degme) = create_element(&mut ws, me).unwrap();
1642 assert_eq!(nvpiv, 1, "singleton supervariable");
1643 assert_eq!(degme, 1, "only neighbor is the hub (nv=1)");
1644 // The new element's var list contains {hub} = {0}.
1645 assert_eq!(pme2_excl - pme1, 1);
1646 assert_eq!(ws.iw[pme1], 0);
1647 assert_eq!(ws.pe[4], pme1 as i32);
1648 assert_eq!(ws.len[4], 1);
1649 assert_eq!(ws.elen[4], flip(1 + 1), "flip(nvpiv + degme)");
1650 assert_eq!(ws.nv[4], -1, "pivot marker");
1651 assert_eq!(ws.nv[0], -1, "hub marked");
1652 // Hub was in head[4] with no siblings; it was removed.
1653 assert_eq!(ws.head[4], NONE);
1654 // nel advanced by nvpiv.
1655 assert_eq!(ws.nel, 1);
1656 }
1657
1658 #[test]
1659 fn create_element_elenme_zero_unlinks_from_degree_list() {
1660 // Tridiag 5: pivot var 4 (deg 1), neighbor var 3 (deg 2).
1661 // var 3 should be unlinked from head[2], which currently
1662 // threads 3 -> 2 -> 1.
1663 let cp = [0, 2, 5, 8, 11, 13];
1664 let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
1665 let mut ws = ws_for(5, &cp, &ri);
1666 let me = select_pivot(&mut ws).unwrap();
1667 assert_eq!(me, 4);
1668 let (_, _, _, _) = create_element(&mut ws, me).unwrap();
1669 // After unlinking 3 (head of deg-2), head[2] -> 2.
1670 assert_eq!(ws.head[2], 2);
1671 assert_eq!(ws.last[2], NONE);
1672 // Unlinked var's last/next are stale but the list is valid.
1673 assert_eq!(ws.nv[3], -1);
1674 }
1675
1676 #[test]
1677 fn create_element_skips_absorbed_neighbors() {
1678 // Construct a workspace where one neighbor is already absorbed
1679 // (nv == 0 or nv < 0). That neighbor must NOT contribute.
1680 let cp = [0, 2, 5, 8, 11, 13];
1681 let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
1682 let mut ws = ws_for(5, &cp, &ri);
1683 // Mark var 0 as already-absorbed (nv <= 0).
1684 ws.nv[0] = 0;
1685 let me = select_pivot(&mut ws).unwrap();
1686 assert_eq!(me, 4);
1687 let (_, _, nvpiv, degme) = create_element(&mut ws, me).unwrap();
1688 // Only neighbor 3 counts; not 0 (absorbed) and not the diagonal
1689 // (skipped by init).
1690 assert_eq!(nvpiv, 1);
1691 assert_eq!(degme, 1);
1692 }
1693
1694 /// Drive the full loop on diag_4 — every var pre-eliminated,
1695 /// loop terminates immediately.
1696 #[test]
1697 fn run_elimination_diag_4() {
1698 let cp = [0, 1, 2, 3, 4];
1699 let ri = [0, 1, 2, 3];
1700 let mut ws = ws_for(4, &cp, &ri);
1701 let flops = run_elimination(&mut ws, true).unwrap();
1702 assert_eq!(ws.nel, 4);
1703 assert_eq!(flops.ndiv, 0.0);
1704 }
1705
1706 /// Arrow 5: no dense deferral. Loop eliminates all 5 vars.
1707 /// Verify `nel == n` and pivot supervariable count matches nv[me]
1708 /// after the step (restored to positive).
1709 #[test]
1710 fn run_elimination_arrow_5() {
1711 let cp = [0, 5, 7, 9, 11, 13];
1712 let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
1713 let mut ws = ws_for(5, &cp, &ri);
1714 run_elimination(&mut ws, true).unwrap();
1715 assert_eq!(ws.nel, 5);
1716 // Every var was pivoted exactly once ⇒ nv[i] > 0 everywhere
1717 // (the pivot restores nv to +nvpiv).
1718 for i in 0..5 {
1719 assert!(ws.nv[i] >= 0, "nv[{}] = {}", i, ws.nv[i]);
1720 }
1721 }
1722
1723 /// Tridiag 10 full-symmetric: loop should terminate cleanly.
1724 /// Oracle lnz = 9 — verified indirectly by the flop counter.
1725 #[test]
1726 fn run_elimination_tridiag_10() {
1727 let n = 10usize;
1728 let mut cp: Vec<i32> = vec![0];
1729 let mut ri: Vec<i32> = Vec::new();
1730 for j in 0..n {
1731 if j > 0 {
1732 ri.push((j - 1) as i32);
1733 }
1734 ri.push(j as i32);
1735 if j + 1 < n {
1736 ri.push((j + 1) as i32);
1737 }
1738 cp.push(ri.len() as i32);
1739 }
1740 let p = CscPattern::new(n, &cp, &ri).unwrap();
1741 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1742 run_elimination(&mut ws, true).unwrap();
1743 assert_eq!(ws.nel, n);
1744 }
1745
1746 /// Grid 7x7: five-point stencil. Faer's oracle reports
1747 /// `ncmpa == 0`, but Slice A lacks mass elimination and
1748 /// supervariable detection, so it consumes more `iw` space and
1749 /// may trip the inline GC. With Commit 6 the loop terminates
1750 /// cleanly regardless of how many compactions are needed.
1751 #[test]
1752 fn run_elimination_grid_7x7() {
1753 let m = 7usize;
1754 let n = 7usize;
1755 let total = m * n;
1756 let mut cp: Vec<i32> = vec![0];
1757 let mut ri: Vec<i32> = Vec::new();
1758 use std::collections::BTreeSet;
1759 let idx = |r: usize, c: usize| r * n + c;
1760 for c in 0..total {
1761 let r0 = c / n;
1762 let c0 = c % n;
1763 let mut neigh: BTreeSet<usize> = BTreeSet::new();
1764 neigh.insert(c);
1765 if r0 > 0 {
1766 neigh.insert(idx(r0 - 1, c0));
1767 }
1768 if r0 + 1 < m {
1769 neigh.insert(idx(r0 + 1, c0));
1770 }
1771 if c0 > 0 {
1772 neigh.insert(idx(r0, c0 - 1));
1773 }
1774 if c0 + 1 < n {
1775 neigh.insert(idx(r0, c0 + 1));
1776 }
1777 for &r in &neigh {
1778 ri.push(r as i32);
1779 }
1780 cp.push(ri.len() as i32);
1781 }
1782 let p = CscPattern::new(total, &cp, &ri).unwrap();
1783 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1784 run_elimination(&mut ws, true).unwrap();
1785 assert_eq!(ws.nel, total);
1786 }
1787
1788 /// Band(20,3) triggers garbage collection in faer (oracle
1789 /// ncmpa=1). Commit 6 must run the compaction and finish the
1790 /// elimination; verify both.
1791 #[test]
1792 fn run_elimination_band_20_3_triggers_gc() {
1793 let n = 20usize;
1794 let b = 3usize;
1795 let mut cp: Vec<i32> = vec![0];
1796 let mut ri: Vec<i32> = Vec::new();
1797 for j in 0..n {
1798 let lo = j.saturating_sub(b);
1799 let hi = (j + b + 1).min(n);
1800 for r in lo..hi {
1801 ri.push(r as i32);
1802 }
1803 cp.push(ri.len() as i32);
1804 }
1805 let p = CscPattern::new(n, &cp, &ri).unwrap();
1806 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1807 run_elimination(&mut ws, true).unwrap();
1808 assert_eq!(ws.nel, n);
1809 assert!(
1810 ws.ncmpa >= 1,
1811 "expected at least one compaction on band(20,3), got ncmpa={}",
1812 ws.ncmpa
1813 );
1814 }
1815
1816 /// Arrow 200: hub dense-deferred in init; spokes get pivoted
1817 /// one by one; loop terminates with ndense=1 survivor.
1818 #[test]
1819 fn run_elimination_arrow_200() {
1820 let n = 200usize;
1821 let mut cp: Vec<i32> = vec![0];
1822 let mut ri: Vec<i32> = Vec::new();
1823 ri.push(0);
1824 for r in 1..n {
1825 ri.push(r as i32);
1826 }
1827 cp.push(ri.len() as i32);
1828 for j in 1..n {
1829 ri.push(0);
1830 ri.push(j as i32);
1831 cp.push(ri.len() as i32);
1832 }
1833 let p = CscPattern::new(n, &cp, &ri).unwrap();
1834 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1835 run_elimination(&mut ws, true).unwrap();
1836 assert_eq!(ws.ndense, 1);
1837 assert_eq!(ws.nel, n);
1838 }
1839
1840 /// Arrow 200 hub is dense-deferred; first pivot is a spoke with
1841 /// no elements in its list, so the elenme==0 path is exercised
1842 /// on a larger graph. Smoke test that the path completes without
1843 /// indexing out of bounds.
1844 #[test]
1845 fn arrow_200_first_pivot_smoke() {
1846 let n = 200usize;
1847 let mut cp: Vec<i32> = vec![0];
1848 let mut ri: Vec<i32> = Vec::new();
1849 ri.push(0);
1850 for r in 1..n {
1851 ri.push(r as i32);
1852 }
1853 cp.push(ri.len() as i32);
1854 for j in 1..n {
1855 ri.push(0);
1856 ri.push(j as i32);
1857 cp.push(ri.len() as i32);
1858 }
1859 let p = CscPattern::new(n, &cp, &ri).unwrap();
1860 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1861 // Hub (var 0) is dense-deferred; its nv was set to 0 during
1862 // init. So when a spoke's list references 0, it's skipped.
1863 let me = select_pivot(&mut ws).unwrap();
1864 let (_, _, nvpiv, degme) = create_element(&mut ws, me).unwrap();
1865 assert_eq!(nvpiv, 1);
1866 assert_eq!(degme, 0, "spoke's only neighbor (hub) is deferred");
1867 // Exactly one var (the pivot itself) has been eliminated plus
1868 // the deferred hub that init already counted.
1869 assert_eq!(ws.nel, 2, "1 deferred hub + 1 pivot");
1870 }
1871
1872 fn is_permutation(perm: &[i32]) -> bool {
1873 let n = perm.len();
1874 let mut seen = vec![false; n];
1875 for &p in perm {
1876 if p < 0 {
1877 return false;
1878 }
1879 let pu = p as usize;
1880 if pu >= n || seen[pu] {
1881 return false;
1882 }
1883 seen[pu] = true;
1884 }
1885 true
1886 }
1887
1888 /// diag_4: every variable is pre-eliminated at init as a
1889 /// zero-degree singleton. Each is a tree root; postorder visits
1890 /// them in ascending index order, giving perm = [0,1,2,3].
1891 #[test]
1892 fn permutation_diag_4() {
1893 let cp = [0, 1, 2, 3, 4];
1894 let ri = [0, 1, 2, 3];
1895 let mut ws = ws_for(4, &cp, &ri);
1896 run_elimination(&mut ws, true).unwrap();
1897 let perm = finalize_permutation(&mut ws);
1898 assert_eq!(perm.len(), 4);
1899 assert!(is_permutation(&perm));
1900 assert_eq!(perm, vec![0, 1, 2, 3]);
1901 }
1902
1903 /// Arrow 5 with hub live: LIFO spoke pivots first, then hub and
1904 /// remaining spoke chain through aggressive absorption. The exact
1905 /// root depends on pivot order and absorption choices; just
1906 /// verify a valid permutation.
1907 #[test]
1908 fn permutation_arrow_5_valid() {
1909 let cp = [0, 5, 7, 9, 11, 13];
1910 let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
1911 let mut ws = ws_for(5, &cp, &ri);
1912 run_elimination(&mut ws, true).unwrap();
1913 let perm = finalize_permutation(&mut ws);
1914 assert_eq!(perm.len(), 5);
1915 assert!(is_permutation(&perm));
1916 }
1917
1918 /// Tridiag 10: valid permutation of 0..10. No structural
1919 /// oracle here — just bijection + length checks.
1920 #[test]
1921 fn permutation_tridiag_10() {
1922 let n = 10usize;
1923 let mut cp: Vec<i32> = vec![0];
1924 let mut ri: Vec<i32> = Vec::new();
1925 for j in 0..n {
1926 if j > 0 {
1927 ri.push((j - 1) as i32);
1928 }
1929 ri.push(j as i32);
1930 if j + 1 < n {
1931 ri.push((j + 1) as i32);
1932 }
1933 cp.push(ri.len() as i32);
1934 }
1935 let p = CscPattern::new(n, &cp, &ri).unwrap();
1936 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1937 run_elimination(&mut ws, true).unwrap();
1938 let perm = finalize_permutation(&mut ws);
1939 assert_eq!(perm.len(), n);
1940 assert!(is_permutation(&perm));
1941 }
1942
1943 /// Arrow 200 with a dense-deferred hub. The hub (var 0, nv=0,
1944 /// pe=NONE) is placed at the tail by the expand phase. All 199
1945 /// spokes are pivots; permutation is still a bijection of 0..200.
1946 #[test]
1947 fn permutation_arrow_200_hub_deferred() {
1948 let n = 200usize;
1949 let mut cp: Vec<i32> = vec![0];
1950 let mut ri: Vec<i32> = Vec::new();
1951 ri.push(0);
1952 for r in 1..n {
1953 ri.push(r as i32);
1954 }
1955 cp.push(ri.len() as i32);
1956 for j in 1..n {
1957 ri.push(0);
1958 ri.push(j as i32);
1959 cp.push(ri.len() as i32);
1960 }
1961 let p = CscPattern::new(n, &cp, &ri).unwrap();
1962 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1963 run_elimination(&mut ws, true).unwrap();
1964 let perm = finalize_permutation(&mut ws);
1965 assert_eq!(perm.len(), n);
1966 assert!(is_permutation(&perm));
1967 assert_eq!(
1968 perm[n - 1],
1969 0,
1970 "dense-deferred hub lands at the tail of the permutation"
1971 );
1972 }
1973
1974 /// Grid 7x7: ensure the permutation survives GC-triggered runs.
1975 #[test]
1976 fn permutation_grid_7x7() {
1977 let m = 7usize;
1978 let n = 7usize;
1979 let total = m * n;
1980 let mut cp: Vec<i32> = vec![0];
1981 let mut ri: Vec<i32> = Vec::new();
1982 use std::collections::BTreeSet;
1983 let idx = |r: usize, c: usize| r * n + c;
1984 for c in 0..total {
1985 let r0 = c / n;
1986 let c0 = c % n;
1987 let mut neigh: BTreeSet<usize> = BTreeSet::new();
1988 neigh.insert(c);
1989 if r0 > 0 {
1990 neigh.insert(idx(r0 - 1, c0));
1991 }
1992 if r0 + 1 < m {
1993 neigh.insert(idx(r0 + 1, c0));
1994 }
1995 if c0 > 0 {
1996 neigh.insert(idx(r0, c0 - 1));
1997 }
1998 if c0 + 1 < n {
1999 neigh.insert(idx(r0, c0 + 1));
2000 }
2001 for &r in &neigh {
2002 ri.push(r as i32);
2003 }
2004 cp.push(ri.len() as i32);
2005 }
2006 let p = CscPattern::new(total, &cp, &ri).unwrap();
2007 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
2008 run_elimination(&mut ws, true).unwrap();
2009 let perm = finalize_permutation(&mut ws);
2010 assert_eq!(perm.len(), total);
2011 assert!(is_permutation(&perm));
2012 }
2013
2014 /// Band(20, 3) with GC: permutation remains a valid bijection
2015 /// even after ncmpa >= 1 compactions.
2016 #[test]
2017 fn permutation_band_20_3() {
2018 let n = 20usize;
2019 let b = 3usize;
2020 let mut cp: Vec<i32> = vec![0];
2021 let mut ri: Vec<i32> = Vec::new();
2022 for j in 0..n {
2023 let lo = j.saturating_sub(b);
2024 let hi = (j + b + 1).min(n);
2025 for r in lo..hi {
2026 ri.push(r as i32);
2027 }
2028 cp.push(ri.len() as i32);
2029 }
2030 let p = CscPattern::new(n, &cp, &ri).unwrap();
2031 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
2032 run_elimination(&mut ws, true).unwrap();
2033 let perm = finalize_permutation(&mut ws);
2034 assert_eq!(perm.len(), n);
2035 assert!(is_permutation(&perm));
2036 }
2037
2038 /// Empty pattern (n == 0) round-trips to an empty permutation.
2039 #[test]
2040 fn permutation_empty() {
2041 let cp = [0i32];
2042 let ri: [i32; 0] = [];
2043 let p = CscPattern::new(0, &cp, &ri).unwrap();
2044 let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
2045 run_elimination(&mut ws, true).unwrap();
2046 let perm = finalize_permutation(&mut ws);
2047 assert!(perm.is_empty());
2048 }
2049}