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}