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 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}