Skip to main content

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 rmf: f64;
1208            let deg_i = ws.degree[i];
1209            if (deg_i as usize) + (degme_i as usize) > nleft {
1210                // Saturated branch. RMF1 uses original DEG.
1211                let deg_f = deg_i as f64;
1212                let rmf1 = deg_f * (deg_f - 1.0 + 2.0 * degme_i as f64) - ws.wf[i] as f64;
1213                let new_deg = (nleft as i32) - nvi_i;
1214                ws.degree[i] = new_deg;
1215                let nd = new_deg as f64;
1216                let rmf_new =
1217                    nd * (nd - 1.0) - (degme_i - nvi_i) as f64 * (degme_i - nvi_i - 1) as f64;
1218                rmf = rmf_new.min(rmf1);
1219            } else {
1220                let deg_f = deg_i as f64;
1221                ws.degree[i] = deg_i + degme_i - nvi_i;
1222                rmf = deg_f * (deg_f - 1.0 + 2.0 * degme_i as f64) - ws.wf[i] as f64;
1223            }
1224            let rmf = rmf / (nvi_i as f64 + 1.0);
1225            let qscore: i32 = if rmf < dummy_f {
1226                rmf.round() as i32
1227            } else if rmf / n_f < dummy_f {
1228                (rmf / n_f).round() as i32
1229            } else {
1230                AMF_DUMMY_I32
1231            };
1232            ws.wf[i] = qscore.max(1) as i64;
1233
1234            let d = amf_bucket_of(ws.wf[i], n);
1235            let inext = ws.head[d];
1236            if inext != NONE {
1237                ws.last[inext as usize] = i as i32;
1238            }
1239            ws.next[i] = inext;
1240            ws.last[i] = NONE;
1241            ws.head[d] = i as i32;
1242            if d < ws.mindeg {
1243                ws.mindeg = d;
1244            }
1245            ws.iw[p_write] = i as i32;
1246            p_write += 1;
1247        }
1248    }
1249
1250    // Me bookkeeping (same as AMD).
1251    ws.nv[me] = nvpiv;
1252    ws.len[me] = (p_write as i32) - pme1 as i32;
1253    if ws.len[me] == 0 {
1254        ws.pe[me] = NONE;
1255        ws.w[me] = 0;
1256    }
1257    if elenme != 0 {
1258        ws.pfree = p_write;
1259    }
1260
1261    // Flop counters (identical to AMD).
1262    let f = nvpiv as f64;
1263    let r = degme_i32 as f64 + ws.ndense as f64;
1264    let lnzme = f * r + (f - 1.0) * f / 2.0;
1265    let s = f * r * r + r * (f - 1.0) * f + (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
1266
1267    StepFlops {
1268        ndiv: lnzme,
1269        nms_lu: s,
1270        nms_ldl: (s + lnzme) / 2.0,
1271    }
1272}
1273
1274/// AMF analogue of [`run_elimination`].
1275pub fn run_elimination_amf(
1276    ws: &mut Workspace,
1277    aggressive: bool,
1278) -> Result<StepFlops, OrderingError> {
1279    let mut flops = StepFlops::default();
1280    while ws.nel < ws.n {
1281        let me = match select_pivot_amf(ws) {
1282            Some(m) => m,
1283            None => break,
1284        };
1285        let elenme = ws.elen[me];
1286        let (pme1, pme2, nvpiv, degme) = create_element_amf(ws, me)?;
1287        flops.accumulate(finalize_step_amf(
1288            ws, me, pme1, pme2, nvpiv, degme, elenme, aggressive,
1289        ));
1290    }
1291    let f = ws.ndense as f64;
1292    let lnzme = (f - 1.0) * f / 2.0;
1293    let s = (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
1294    flops.ndiv += lnzme;
1295    flops.nms_lu += s;
1296    flops.nms_ldl += (s + lnzme) / 2.0;
1297    Ok(flops)
1298}
1299
1300/// Run the main AMD elimination loop until every live supervariable
1301/// has been either pivoted or dense-deferred. Returns the
1302/// accumulated flop counts.
1303///
1304/// Mass elimination and supervariable detection are absent (Slice
1305/// B). Inline garbage collection is live; fixtures whose working
1306/// set transiently exceeds `iwlen` recover via in-place compaction
1307/// and bump `ws.ncmpa`.
1308///
1309/// At exit: `ws.nel == ws.n`, every `pe[i]` either points to a
1310/// live parent (to be path-compressed by the postorder phase) or
1311/// is `NONE` / `flip(parent)`.
1312pub fn run_elimination(ws: &mut Workspace, aggressive: bool) -> Result<StepFlops, OrderingError> {
1313    let mut flops = StepFlops::default();
1314    while ws.nel < ws.n {
1315        let me = match select_pivot(ws) {
1316            Some(m) => m,
1317            None => break, // only dense-deferred survivors remain
1318        };
1319        let elenme = ws.elen[me];
1320        let (pme1, pme2, nvpiv, degme) = create_element(ws, me)?;
1321        flops.accumulate(finalize_step(
1322            ws, me, pme1, pme2, nvpiv, degme, elenme, aggressive,
1323        ));
1324    }
1325    // Dense-phase flop contribution (amd.rs:559-566).
1326    let f = ws.ndense as f64;
1327    let lnzme = (f - 1.0) * f / 2.0;
1328    let s = (f - 1.0) * f * (2.0 * f - 1.0) / 6.0;
1329    flops.ndiv += lnzme;
1330    flops.nms_lu += s;
1331    flops.nms_ldl += (s + lnzme) / 2.0;
1332    Ok(flops)
1333}
1334
1335/// Consume the post-elimination state and produce the final
1336/// permutation.
1337///
1338/// On entry `ws` must have completed [`run_elimination`]. Performs,
1339/// in order:
1340/// 1. Un-flip `pe` and `elen` (faer `amd.rs:567-572`) so `pe[i]`
1341///    holds the parent pivot and `elen[i]` holds the frontal size.
1342/// 2. Path compression (`amd.rs:573-590`): each absorbed
1343///    supervariable `i` (`nv[i] == 0`) has its `pe` chain walked
1344///    until a pivot is found, then all intermediates are rewritten
1345///    to point at that pivot directly. Inert in Slice A — becomes
1346///    active once supervariable detection (Slice B) lands.
1347/// 3. Assembly-tree postorder with big-child-last heuristic
1348///    (`amd.rs:5-49`, `amd.rs:51-124`, `amd.rs:593-599`). Reuses
1349///    `head`/`next`/`last` as child/sibling/stack scratch; writes
1350///    the postorder index into `w`.
1351/// 4. Invert `w` into `head[k] = pivot at postorder k`
1352///    (`amd.rs:600-606`).
1353/// 5. Assign starting positions to each pivot's block
1354///    (`amd.rs:607-615`): `next[e] = nel`, then `nel += nv[e]`.
1355/// 6. Expand absorbed supervariables + place dense-deferred variables
1356///    at the tail (`amd.rs:617-629`).
1357/// 7. Emit `perm`: `perm[next[i]] = i` for every `i`
1358///    (`amd.rs:631-633`).
1359///
1360/// Returns a permutation `perm` of length `n` where `perm[k]` is the
1361/// column of the original matrix to be eliminated at step `k`.
1362pub fn finalize_permutation(ws: &mut Workspace) -> Vec<i32> {
1363    let n = ws.n;
1364    if n == 0 {
1365        return Vec::new();
1366    }
1367
1368    // Step 1: un-flip.
1369    for x in ws.pe.iter_mut() {
1370        *x = flip(*x);
1371    }
1372    for x in ws.elen.iter_mut() {
1373        *x = flip(*x);
1374    }
1375
1376    // Step 2: path-compress absorbed supervariables.
1377    for i in 0..n {
1378        if ws.nv[i] == 0 {
1379            let head_i = ws.pe[i];
1380            if head_i == NONE {
1381                continue;
1382            }
1383            let mut j = head_i as usize;
1384            while ws.nv[j] == 0 {
1385                j = ws.pe[j] as usize;
1386            }
1387            let e = j as i32;
1388            let mut j = i;
1389            while ws.nv[j] == 0 {
1390                let jnext = ws.pe[j];
1391                ws.pe[j] = e;
1392                j = jnext as usize;
1393            }
1394        }
1395    }
1396
1397    // Step 3: assembly-tree postorder. Writes postorder index into w.
1398    assembly_tree_postorder(ws);
1399
1400    // Step 4: invert w into head.
1401    for x in ws.head.iter_mut() {
1402        *x = NONE;
1403    }
1404    for e in 0..n {
1405        let k = ws.w[e];
1406        if k != NONE {
1407            ws.head[k as usize] = e as i32;
1408        }
1409    }
1410
1411    // Step 5: pivot-block starting positions.
1412    for x in ws.next.iter_mut() {
1413        *x = NONE;
1414    }
1415    let mut nel: i32 = 0;
1416    for &e in ws.head.iter() {
1417        if e == NONE {
1418            break;
1419        }
1420        let eu = e as usize;
1421        ws.next[eu] = nel;
1422        nel += ws.nv[eu];
1423    }
1424
1425    // Step 6: expand absorbed supervars + place dense-deferred at tail.
1426    for i in 0..n {
1427        if ws.nv[i] == 0 {
1428            let e = ws.pe[i];
1429            if e != NONE {
1430                let eu = e as usize;
1431                ws.next[i] = ws.next[eu];
1432                ws.next[eu] += 1;
1433            } else {
1434                ws.next[i] = nel;
1435                nel += 1;
1436            }
1437        }
1438    }
1439
1440    // Step 7: emit perm.
1441    let mut perm = vec![0i32; n];
1442    for i in 0..n {
1443        perm[ws.next[i] as usize] = i as i32;
1444    }
1445    perm
1446}
1447
1448/// Build the assembly tree from `pe` (parent pointers) + `nv`
1449/// (`> 0` selects pivots), apply the big-child-last heuristic, and
1450/// run an iterative DFS postorder. Result indexes go into `ws.w`
1451/// (NONE for non-pivot nodes).
1452fn assembly_tree_postorder(ws: &mut Workspace) {
1453    let n = ws.n;
1454    // Repurpose head/next/last as child/sibling/stack scratch.
1455    for x in ws.head.iter_mut() {
1456        *x = NONE;
1457    }
1458    for x in ws.next.iter_mut() {
1459        *x = NONE;
1460    }
1461    // Link each pivot as a child of its parent. Reverse order so that
1462    // after building, child lists are in ascending index order.
1463    for j in (0..n).rev() {
1464        if ws.nv[j] > 0 {
1465            let parent = ws.pe[j];
1466            if parent >= 0 && (parent as usize) < n {
1467                let pu = parent as usize;
1468                ws.next[j] = ws.head[pu];
1469                ws.head[pu] = j as i32;
1470            }
1471        }
1472    }
1473    // Big-child-last heuristic: move the child with the largest
1474    // `elen` (frontal size) to the end of the sibling list so the
1475    // deepest-recursion subtree is visited last.
1476    for i in 0..n {
1477        if ws.nv[i] > 0 && ws.head[i] != NONE {
1478            let child0 = ws.head[i];
1479            let mut fprev: i32 = NONE;
1480            let mut bigfprev: i32 = NONE;
1481            let mut bigf: i32 = NONE;
1482            let mut maxfrsize: i32 = NONE;
1483            let mut f = child0;
1484            while f != NONE {
1485                let fu = f as usize;
1486                let frsize = ws.elen[fu];
1487                if frsize >= maxfrsize {
1488                    maxfrsize = frsize;
1489                    bigfprev = fprev;
1490                    bigf = f;
1491                }
1492                fprev = f;
1493                f = ws.next[fu];
1494            }
1495            let bigfu = bigf as usize;
1496            let fnext = ws.next[bigfu];
1497            if fnext != NONE {
1498                if bigfprev != NONE {
1499                    ws.next[bigfprev as usize] = fnext;
1500                } else {
1501                    ws.head[i] = fnext;
1502                }
1503                ws.next[bigfu] = NONE;
1504                ws.next[fprev as usize] = bigf;
1505            }
1506        }
1507    }
1508    // Iterative DFS postorder from each pivot root.
1509    for x in ws.w.iter_mut() {
1510        *x = NONE;
1511    }
1512    let mut k: usize = 0;
1513    for i in 0..n {
1514        if ws.pe[i] == NONE && ws.nv[i] > 0 {
1515            k = post_tree_dfs(ws, i, k);
1516        }
1517    }
1518}
1519
1520/// Iterative DFS of one assembly-tree root. Stack lives in `last`,
1521/// child list in `head`, sibling links in `next`. Writes postorder
1522/// indices into `w`. Returns the next postorder index to assign.
1523fn post_tree_dfs(ws: &mut Workspace, root: usize, k_start: usize) -> usize {
1524    let mut k = k_start;
1525    let mut top: usize = 1;
1526    ws.last[0] = root as i32;
1527    while top > 0 {
1528        let i = ws.last[top - 1] as usize;
1529        let child0 = ws.head[i];
1530        if child0 != NONE {
1531            // Count children.
1532            let mut count = 0usize;
1533            let mut f = child0;
1534            while f != NONE {
1535                count += 1;
1536                f = ws.next[f as usize];
1537            }
1538            // Push children into stack slots [top, top+count) with the
1539            // first child at the highest position (popped first).
1540            let new_top = top + count;
1541            let mut t = new_top;
1542            let mut f = child0;
1543            loop {
1544                t -= 1;
1545                ws.last[t] = f;
1546                let nf = ws.next[f as usize];
1547                if nf == NONE {
1548                    break;
1549                }
1550                f = nf;
1551            }
1552            top = new_top;
1553            ws.head[i] = NONE; // mark visited
1554        } else {
1555            top -= 1;
1556            ws.w[i] = k as i32;
1557            k += 1;
1558        }
1559    }
1560    k
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566    use crate::quotient_graph::WorkspaceOptions;
1567    use crate::CscPattern;
1568
1569    fn ws_for<'a>(n: usize, cp: &'a [i32], ri: &'a [i32]) -> Workspace {
1570        let p = CscPattern::new(n, cp, ri).unwrap();
1571        Workspace::new(&p, &WorkspaceOptions::default()).unwrap()
1572    }
1573
1574    /// O1 (repo-review-2026-06-09.md): the AMF working-fill kernels must
1575    /// be computed in `i64`. Both factors are `O(n)`, so for `n` ≳ 46k
1576    /// the products exceed `i32::MAX` (2_147_483_647) and wrap silently
1577    /// in release / panic in debug, feeding garbage into the RMF pivot
1578    /// score. Oracle: the exact hand-computed `i64` value of the
1579    /// Amestoy 1999 formulas (`ana_orderings.F:4810`).
1580    #[test]
1581    fn amf_wf_kernels_do_not_overflow_i32() {
1582        // Surface contribution dext*(2*degree - dext - 1).
1583        // dext = degree = 46342  ->  46342 * 46341 = 2_147_534_622,
1584        // which is 50_975 above i32::MAX. Hand-computed external oracle.
1585        assert!(2_147_534_622_i64 > i32::MAX as i64);
1586        assert_eq!(amf_wf_surface(46342, 46342), 2_147_534_622_i64);
1587        // Small-value sanity: dext=3, degree=4 -> 3*(8-3-1) = 12.
1588        assert_eq!(amf_wf_surface(3, 4), 12);
1589
1590        // Combine wf4 + 2*nvi*wf3.
1591        // nvi = wf3 = 46342, wf4 = 0  ->  2 * 46342 * 46342
1592        // = 4_295_161_928, which exceeds both i32::MAX and u32::MAX.
1593        assert!(4_295_161_928_i64 > u32::MAX as i64);
1594        assert_eq!(amf_wf_combine(0, 46342, 46342), 4_295_161_928_i64);
1595        // Small-value sanity: 5 + 2*3*4 = 29.
1596        assert_eq!(amf_wf_combine(5, 3, 4), 29);
1597    }
1598
1599    #[test]
1600    fn select_pivot_empty() {
1601        // diag_4: every var pre-eliminated, no degree bucket populated.
1602        let cp = [0, 1, 2, 3, 4];
1603        let ri = [0, 1, 2, 3];
1604        let mut ws = ws_for(4, &cp, &ri);
1605        assert_eq!(select_pivot(&mut ws), None);
1606    }
1607
1608    #[test]
1609    fn select_pivot_lifo_on_tridiag() {
1610        // Tridiag 5: head[1] contains 4 -> 0 (LIFO).
1611        let cp = [0, 2, 5, 8, 11, 13];
1612        let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
1613        let mut ws = ws_for(5, &cp, &ri);
1614        assert_eq!(select_pivot(&mut ws), Some(4));
1615        assert_eq!(ws.mindeg, 1);
1616        // Head of deg-1 list now points to the remaining spoke (0).
1617        assert_eq!(ws.head[1], 0);
1618        assert_eq!(ws.last[0], NONE, "new head has no predecessor");
1619
1620        assert_eq!(select_pivot(&mut ws), Some(0));
1621        assert_eq!(ws.head[1], NONE, "deg-1 bucket drained");
1622
1623        // Next call scans from mindeg=1 upward; only deg-2 non-empty.
1624        assert_eq!(select_pivot(&mut ws), Some(3));
1625        assert_eq!(ws.mindeg, 2);
1626    }
1627
1628    #[test]
1629    fn create_element_elenme_zero_on_arrow_5_hub() {
1630        // Arrow 5: hub has deg 4, but it's dense-deferred? Let's check.
1631        // For n=5 default, dense = max(16, min(5, 10*sqrt(5))) = 5.
1632        // deg 4 < 5, so hub is LIVE and sits in head[4].
1633        // Spokes (deg 1) all share head[1]. The min-degree pivot is a
1634        // spoke. Let's pick spoke 4 (LIFO head of deg-1).
1635        let cp = [0, 5, 7, 9, 11, 13];
1636        let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
1637        let mut ws = ws_for(5, &cp, &ri);
1638        let me = select_pivot(&mut ws).unwrap();
1639        assert_eq!(me, 4, "first pivot is the LIFO head of deg-1");
1640        // elen[4] == 0 (no elements yet).
1641        assert_eq!(ws.elen[4], 0);
1642        let (pme1, pme2_excl, nvpiv, degme) = create_element(&mut ws, me).unwrap();
1643        assert_eq!(nvpiv, 1, "singleton supervariable");
1644        assert_eq!(degme, 1, "only neighbor is the hub (nv=1)");
1645        // The new element's var list contains {hub} = {0}.
1646        assert_eq!(pme2_excl - pme1, 1);
1647        assert_eq!(ws.iw[pme1], 0);
1648        assert_eq!(ws.pe[4], pme1 as i32);
1649        assert_eq!(ws.len[4], 1);
1650        assert_eq!(ws.elen[4], flip(1 + 1), "flip(nvpiv + degme)");
1651        assert_eq!(ws.nv[4], -1, "pivot marker");
1652        assert_eq!(ws.nv[0], -1, "hub marked");
1653        // Hub was in head[4] with no siblings; it was removed.
1654        assert_eq!(ws.head[4], NONE);
1655        // nel advanced by nvpiv.
1656        assert_eq!(ws.nel, 1);
1657    }
1658
1659    #[test]
1660    fn create_element_elenme_zero_unlinks_from_degree_list() {
1661        // Tridiag 5: pivot var 4 (deg 1), neighbor var 3 (deg 2).
1662        // var 3 should be unlinked from head[2], which currently
1663        // threads 3 -> 2 -> 1.
1664        let cp = [0, 2, 5, 8, 11, 13];
1665        let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
1666        let mut ws = ws_for(5, &cp, &ri);
1667        let me = select_pivot(&mut ws).unwrap();
1668        assert_eq!(me, 4);
1669        let (_, _, _, _) = create_element(&mut ws, me).unwrap();
1670        // After unlinking 3 (head of deg-2), head[2] -> 2.
1671        assert_eq!(ws.head[2], 2);
1672        assert_eq!(ws.last[2], NONE);
1673        // Unlinked var's last/next are stale but the list is valid.
1674        assert_eq!(ws.nv[3], -1);
1675    }
1676
1677    #[test]
1678    fn create_element_skips_absorbed_neighbors() {
1679        // Construct a workspace where one neighbor is already absorbed
1680        // (nv == 0 or nv < 0). That neighbor must NOT contribute.
1681        let cp = [0, 2, 5, 8, 11, 13];
1682        let ri = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4];
1683        let mut ws = ws_for(5, &cp, &ri);
1684        // Mark var 0 as already-absorbed (nv <= 0).
1685        ws.nv[0] = 0;
1686        let me = select_pivot(&mut ws).unwrap();
1687        assert_eq!(me, 4);
1688        let (_, _, nvpiv, degme) = create_element(&mut ws, me).unwrap();
1689        // Only neighbor 3 counts; not 0 (absorbed) and not the diagonal
1690        // (skipped by init).
1691        assert_eq!(nvpiv, 1);
1692        assert_eq!(degme, 1);
1693    }
1694
1695    /// Drive the full loop on diag_4 — every var pre-eliminated,
1696    /// loop terminates immediately.
1697    #[test]
1698    fn run_elimination_diag_4() {
1699        let cp = [0, 1, 2, 3, 4];
1700        let ri = [0, 1, 2, 3];
1701        let mut ws = ws_for(4, &cp, &ri);
1702        let flops = run_elimination(&mut ws, true).unwrap();
1703        assert_eq!(ws.nel, 4);
1704        assert_eq!(flops.ndiv, 0.0);
1705    }
1706
1707    /// Arrow 5: no dense deferral. Loop eliminates all 5 vars.
1708    /// Verify `nel == n` and pivot supervariable count matches nv[me]
1709    /// after the step (restored to positive).
1710    #[test]
1711    fn run_elimination_arrow_5() {
1712        let cp = [0, 5, 7, 9, 11, 13];
1713        let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
1714        let mut ws = ws_for(5, &cp, &ri);
1715        run_elimination(&mut ws, true).unwrap();
1716        assert_eq!(ws.nel, 5);
1717        // Every var was pivoted exactly once ⇒ nv[i] > 0 everywhere
1718        // (the pivot restores nv to +nvpiv).
1719        for i in 0..5 {
1720            assert!(ws.nv[i] >= 0, "nv[{}] = {}", i, ws.nv[i]);
1721        }
1722    }
1723
1724    /// Tridiag 10 full-symmetric: loop should terminate cleanly.
1725    /// Oracle lnz = 9 — verified indirectly by the flop counter.
1726    #[test]
1727    fn run_elimination_tridiag_10() {
1728        let n = 10usize;
1729        let mut cp: Vec<i32> = vec![0];
1730        let mut ri: Vec<i32> = Vec::new();
1731        for j in 0..n {
1732            if j > 0 {
1733                ri.push((j - 1) as i32);
1734            }
1735            ri.push(j as i32);
1736            if j + 1 < n {
1737                ri.push((j + 1) as i32);
1738            }
1739            cp.push(ri.len() as i32);
1740        }
1741        let p = CscPattern::new(n, &cp, &ri).unwrap();
1742        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1743        run_elimination(&mut ws, true).unwrap();
1744        assert_eq!(ws.nel, n);
1745    }
1746
1747    /// Grid 7x7: five-point stencil. Faer's oracle reports
1748    /// `ncmpa == 0`, but Slice A lacks mass elimination and
1749    /// supervariable detection, so it consumes more `iw` space and
1750    /// may trip the inline GC. With Commit 6 the loop terminates
1751    /// cleanly regardless of how many compactions are needed.
1752    #[test]
1753    fn run_elimination_grid_7x7() {
1754        let m = 7usize;
1755        let n = 7usize;
1756        let total = m * n;
1757        let mut cp: Vec<i32> = vec![0];
1758        let mut ri: Vec<i32> = Vec::new();
1759        use std::collections::BTreeSet;
1760        let idx = |r: usize, c: usize| r * n + c;
1761        for c in 0..total {
1762            let r0 = c / n;
1763            let c0 = c % n;
1764            let mut neigh: BTreeSet<usize> = BTreeSet::new();
1765            neigh.insert(c);
1766            if r0 > 0 {
1767                neigh.insert(idx(r0 - 1, c0));
1768            }
1769            if r0 + 1 < m {
1770                neigh.insert(idx(r0 + 1, c0));
1771            }
1772            if c0 > 0 {
1773                neigh.insert(idx(r0, c0 - 1));
1774            }
1775            if c0 + 1 < n {
1776                neigh.insert(idx(r0, c0 + 1));
1777            }
1778            for &r in &neigh {
1779                ri.push(r as i32);
1780            }
1781            cp.push(ri.len() as i32);
1782        }
1783        let p = CscPattern::new(total, &cp, &ri).unwrap();
1784        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1785        run_elimination(&mut ws, true).unwrap();
1786        assert_eq!(ws.nel, total);
1787    }
1788
1789    /// Band(20,3) triggers garbage collection in faer (oracle
1790    /// ncmpa=1). Commit 6 must run the compaction and finish the
1791    /// elimination; verify both.
1792    #[test]
1793    fn run_elimination_band_20_3_triggers_gc() {
1794        let n = 20usize;
1795        let b = 3usize;
1796        let mut cp: Vec<i32> = vec![0];
1797        let mut ri: Vec<i32> = Vec::new();
1798        for j in 0..n {
1799            let lo = j.saturating_sub(b);
1800            let hi = (j + b + 1).min(n);
1801            for r in lo..hi {
1802                ri.push(r as i32);
1803            }
1804            cp.push(ri.len() as i32);
1805        }
1806        let p = CscPattern::new(n, &cp, &ri).unwrap();
1807        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1808        run_elimination(&mut ws, true).unwrap();
1809        assert_eq!(ws.nel, n);
1810        assert!(
1811            ws.ncmpa >= 1,
1812            "expected at least one compaction on band(20,3), got ncmpa={}",
1813            ws.ncmpa
1814        );
1815    }
1816
1817    /// Arrow 200: hub dense-deferred in init; spokes get pivoted
1818    /// one by one; loop terminates with ndense=1 survivor.
1819    #[test]
1820    fn run_elimination_arrow_200() {
1821        let n = 200usize;
1822        let mut cp: Vec<i32> = vec![0];
1823        let mut ri: Vec<i32> = Vec::new();
1824        ri.push(0);
1825        for r in 1..n {
1826            ri.push(r as i32);
1827        }
1828        cp.push(ri.len() as i32);
1829        for j in 1..n {
1830            ri.push(0);
1831            ri.push(j as i32);
1832            cp.push(ri.len() as i32);
1833        }
1834        let p = CscPattern::new(n, &cp, &ri).unwrap();
1835        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1836        run_elimination(&mut ws, true).unwrap();
1837        assert_eq!(ws.ndense, 1);
1838        assert_eq!(ws.nel, n);
1839    }
1840
1841    /// Arrow 200 hub is dense-deferred; first pivot is a spoke with
1842    /// no elements in its list, so the elenme==0 path is exercised
1843    /// on a larger graph. Smoke test that the path completes without
1844    /// indexing out of bounds.
1845    #[test]
1846    fn arrow_200_first_pivot_smoke() {
1847        let n = 200usize;
1848        let mut cp: Vec<i32> = vec![0];
1849        let mut ri: Vec<i32> = Vec::new();
1850        ri.push(0);
1851        for r in 1..n {
1852            ri.push(r as i32);
1853        }
1854        cp.push(ri.len() as i32);
1855        for j in 1..n {
1856            ri.push(0);
1857            ri.push(j as i32);
1858            cp.push(ri.len() as i32);
1859        }
1860        let p = CscPattern::new(n, &cp, &ri).unwrap();
1861        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1862        // Hub (var 0) is dense-deferred; its nv was set to 0 during
1863        // init. So when a spoke's list references 0, it's skipped.
1864        let me = select_pivot(&mut ws).unwrap();
1865        let (_, _, nvpiv, degme) = create_element(&mut ws, me).unwrap();
1866        assert_eq!(nvpiv, 1);
1867        assert_eq!(degme, 0, "spoke's only neighbor (hub) is deferred");
1868        // Exactly one var (the pivot itself) has been eliminated plus
1869        // the deferred hub that init already counted.
1870        assert_eq!(ws.nel, 2, "1 deferred hub + 1 pivot");
1871    }
1872
1873    fn is_permutation(perm: &[i32]) -> bool {
1874        let n = perm.len();
1875        let mut seen = vec![false; n];
1876        for &p in perm {
1877            if p < 0 {
1878                return false;
1879            }
1880            let pu = p as usize;
1881            if pu >= n || seen[pu] {
1882                return false;
1883            }
1884            seen[pu] = true;
1885        }
1886        true
1887    }
1888
1889    /// diag_4: every variable is pre-eliminated at init as a
1890    /// zero-degree singleton. Each is a tree root; postorder visits
1891    /// them in ascending index order, giving perm = [0,1,2,3].
1892    #[test]
1893    fn permutation_diag_4() {
1894        let cp = [0, 1, 2, 3, 4];
1895        let ri = [0, 1, 2, 3];
1896        let mut ws = ws_for(4, &cp, &ri);
1897        run_elimination(&mut ws, true).unwrap();
1898        let perm = finalize_permutation(&mut ws);
1899        assert_eq!(perm.len(), 4);
1900        assert!(is_permutation(&perm));
1901        assert_eq!(perm, vec![0, 1, 2, 3]);
1902    }
1903
1904    /// Arrow 5 with hub live: LIFO spoke pivots first, then hub and
1905    /// remaining spoke chain through aggressive absorption. The exact
1906    /// root depends on pivot order and absorption choices; just
1907    /// verify a valid permutation.
1908    #[test]
1909    fn permutation_arrow_5_valid() {
1910        let cp = [0, 5, 7, 9, 11, 13];
1911        let ri = [0, 1, 2, 3, 4, 0, 1, 0, 2, 0, 3, 0, 4];
1912        let mut ws = ws_for(5, &cp, &ri);
1913        run_elimination(&mut ws, true).unwrap();
1914        let perm = finalize_permutation(&mut ws);
1915        assert_eq!(perm.len(), 5);
1916        assert!(is_permutation(&perm));
1917    }
1918
1919    /// Tridiag 10: valid permutation of 0..10. No structural
1920    /// oracle here — just bijection + length checks.
1921    #[test]
1922    fn permutation_tridiag_10() {
1923        let n = 10usize;
1924        let mut cp: Vec<i32> = vec![0];
1925        let mut ri: Vec<i32> = Vec::new();
1926        for j in 0..n {
1927            if j > 0 {
1928                ri.push((j - 1) as i32);
1929            }
1930            ri.push(j as i32);
1931            if j + 1 < n {
1932                ri.push((j + 1) as i32);
1933            }
1934            cp.push(ri.len() as i32);
1935        }
1936        let p = CscPattern::new(n, &cp, &ri).unwrap();
1937        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1938        run_elimination(&mut ws, true).unwrap();
1939        let perm = finalize_permutation(&mut ws);
1940        assert_eq!(perm.len(), n);
1941        assert!(is_permutation(&perm));
1942    }
1943
1944    /// Arrow 200 with a dense-deferred hub. The hub (var 0, nv=0,
1945    /// pe=NONE) is placed at the tail by the expand phase. All 199
1946    /// spokes are pivots; permutation is still a bijection of 0..200.
1947    #[test]
1948    fn permutation_arrow_200_hub_deferred() {
1949        let n = 200usize;
1950        let mut cp: Vec<i32> = vec![0];
1951        let mut ri: Vec<i32> = Vec::new();
1952        ri.push(0);
1953        for r in 1..n {
1954            ri.push(r as i32);
1955        }
1956        cp.push(ri.len() as i32);
1957        for j in 1..n {
1958            ri.push(0);
1959            ri.push(j as i32);
1960            cp.push(ri.len() as i32);
1961        }
1962        let p = CscPattern::new(n, &cp, &ri).unwrap();
1963        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
1964        run_elimination(&mut ws, true).unwrap();
1965        let perm = finalize_permutation(&mut ws);
1966        assert_eq!(perm.len(), n);
1967        assert!(is_permutation(&perm));
1968        assert_eq!(
1969            perm[n - 1],
1970            0,
1971            "dense-deferred hub lands at the tail of the permutation"
1972        );
1973    }
1974
1975    /// Grid 7x7: ensure the permutation survives GC-triggered runs.
1976    #[test]
1977    fn permutation_grid_7x7() {
1978        let m = 7usize;
1979        let n = 7usize;
1980        let total = m * n;
1981        let mut cp: Vec<i32> = vec![0];
1982        let mut ri: Vec<i32> = Vec::new();
1983        use std::collections::BTreeSet;
1984        let idx = |r: usize, c: usize| r * n + c;
1985        for c in 0..total {
1986            let r0 = c / n;
1987            let c0 = c % n;
1988            let mut neigh: BTreeSet<usize> = BTreeSet::new();
1989            neigh.insert(c);
1990            if r0 > 0 {
1991                neigh.insert(idx(r0 - 1, c0));
1992            }
1993            if r0 + 1 < m {
1994                neigh.insert(idx(r0 + 1, c0));
1995            }
1996            if c0 > 0 {
1997                neigh.insert(idx(r0, c0 - 1));
1998            }
1999            if c0 + 1 < n {
2000                neigh.insert(idx(r0, c0 + 1));
2001            }
2002            for &r in &neigh {
2003                ri.push(r as i32);
2004            }
2005            cp.push(ri.len() as i32);
2006        }
2007        let p = CscPattern::new(total, &cp, &ri).unwrap();
2008        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
2009        run_elimination(&mut ws, true).unwrap();
2010        let perm = finalize_permutation(&mut ws);
2011        assert_eq!(perm.len(), total);
2012        assert!(is_permutation(&perm));
2013    }
2014
2015    /// Band(20, 3) with GC: permutation remains a valid bijection
2016    /// even after ncmpa >= 1 compactions.
2017    #[test]
2018    fn permutation_band_20_3() {
2019        let n = 20usize;
2020        let b = 3usize;
2021        let mut cp: Vec<i32> = vec![0];
2022        let mut ri: Vec<i32> = Vec::new();
2023        for j in 0..n {
2024            let lo = j.saturating_sub(b);
2025            let hi = (j + b + 1).min(n);
2026            for r in lo..hi {
2027                ri.push(r as i32);
2028            }
2029            cp.push(ri.len() as i32);
2030        }
2031        let p = CscPattern::new(n, &cp, &ri).unwrap();
2032        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
2033        run_elimination(&mut ws, true).unwrap();
2034        let perm = finalize_permutation(&mut ws);
2035        assert_eq!(perm.len(), n);
2036        assert!(is_permutation(&perm));
2037    }
2038
2039    /// Empty pattern (n == 0) round-trips to an empty permutation.
2040    #[test]
2041    fn permutation_empty() {
2042        let cp = [0i32];
2043        let ri: [i32; 0] = [];
2044        let p = CscPattern::new(0, &cp, &ri).unwrap();
2045        let mut ws = Workspace::new(&p, &WorkspaceOptions::default()).unwrap();
2046        run_elimination(&mut ws, true).unwrap();
2047        let perm = finalize_permutation(&mut ws);
2048        assert!(perm.is_empty());
2049    }
2050}