Skip to main content

xingo_sparse/
ordering.rs

1//! # Chapter 5.10 — Fill-reducing orderings: [`Natural`], [`Amd`], [`Colamd`]
2//!
3//! *New with task L1.3 (PLAN.md trilha L; D-0080, decision D10: the
4//! orderings are our own from v0.1, no C, no GPL/LGPL).*
5//!
6//! ## Purpose
7//! The cost of a sparse factorization is decided before the first flop, by
8//! the order of elimination. An [`Ordering`] looks at a [`Pattern`]
9//! (Chapter 5.9) and returns a [`Perm`]; it never sees values. Two targets
10//! ([`OrderingTarget`]):
11//!
12//! * [`OrderingTarget::Symmetric`] — a symmetric order `P (S + Sᵀ) Pᵀ` for
13//!   Cholesky or `LDLᵀ` (the KKT systems of the interior-point method,
14//!   L1.5): the permutation of the rows *and* columns;
15//! * [`OrderingTarget::Columns`] — a column order `S Q` for an LU with row
16//!   pivoting (or a QR): it reduces the fill of the Cholesky factor of
17//!   `QᵀSᵀSQ`, which bounds the `U` of every row-pivoted LU of `S Q`
18//!   (George & Ng 1987), without forming `SᵀS`.
19//!
20//! Three orderings, stateless policies:
21//!
22//! * [`Natural`] — the identity (the reference of every table);
23//! * [`Amd`] — approximate minimum degree on the graph of `S + Sᵀ`
24//!   (Amestoy, Davis & Duff 1996), with aggressive absorption, mass
25//!   elimination, supervariables and dense-row detection; for the
26//!   `Columns` target it orders the graph of `SᵀS`, formed explicitly;
27//! * [`Colamd`] — column approximate minimum degree (Davis, Gilbert,
28//!   Larimore & Ng 2004): the same process on the quotient graph of `SᵀS`
29//!   started from the rows of `S`, never forming `SᵀS`; for the `Symmetric`
30//!   target, the SYMAMD construction (every off-diagonal pair `{i, j}` of
31//!   `S + Sᵀ` is a row with two entries, whose `MᵀM` has the pattern of
32//!   `S + Sᵀ`).
33//!
34//! Both run Algorithm Q of Chapter 5.11; this chapter is their setup.
35//!
36//! ## Notation
37//! `S` is `m × n`; `r_i` and `c_j` are its row and column counts.
38//! `κ(α, N) = max(16, ⌊α √N⌋)` is the *dense threshold* (SuiteSparse's
39//! rule); a negative `α` disables dense detection. Excluded (dense or
40//! empty) indices are ordered last, in increasing index order.
41//!
42//! ## Algorithm A (AMD)
43//! A1. [Graph.] `G ← S + Sᵀ` without its diagonal (Algorithm S of Chapter
44//!     5.9; for `Columns`, the graph of `SᵀS` by Algorithm G). An error
45//!     unless `S` is square for `Symmetric`.
46//! A2. [Dense rows.] With `δ = κ(dense, n)` (`dense` default 10), every
47//!     node of degree `> δ` is excluded: ordering it in the middle would
48//!     cost as much as it fills, and its presence would spoil every
49//!     degree it touches.
50//! A3. [Order.] Algorithm Q with `A_i = G`'s adjacency, no initial
51//!     elements, the bound `min(d̄_i, δ_i)`, AMD's conventions (largest
52//!     index first among equal degrees, the postorder Q8 as output);
53//!     aggressive absorption as asked (default on).
54//! A4. Append the excluded nodes. Return the [`Perm`] (`fwd` = pivot order).
55//!
56//! ## Algorithm C (COLAMD)
57//! C1. [Dense columns.] With `κ_c = κ(dense_col, min(m, n))` (default
58//!     `dense_col = 10`), exclude every column with `c_j > κ_c`.
59//! C2. [Dense rows.] With `κ_r = κ(dense_row, n)` (default 10), drop every
60//!     row with more than `κ_r` entries in non-excluded columns: a dense row
61//!     makes `SᵀS` dense and says nothing useful about the order.
62//! C3. [Null columns.] Exclude every column left without a row (they are
63//!     ordered last, before the dense columns, as COLAMD does).
64//! C4. [Order.] Algorithm Q with no `A_i`, the surviving rows as initial
65//!     elements, COLAMD's score `δ_i` (no `min` with the previous score) and
66//!     COLAMD's conventions (smallest index first among ties, initial rows
67//!     before pivot rows in Q3, the pivot order Q8′ as output); aggressive
68//!     absorption on.
69//! C5. Append the null columns, then the dense ones.
70//!
71//! For `Symmetric` (SYMAMD): exclude the nodes of `S + Sᵀ` of degree
72//! `> κ(dense_col, n)`; every edge `{i, j}` (`i < j`) between the others is
73//! an initial element `{i, j}`; then C4–C5 with the excluded nodes.
74//!
75//! ## Invariants
76//! (O1) Every ordering returns a [`Perm`] of `n` (the number of columns)
77//!      satisfying (P1) of Chapter 5.9, and fails only on an input error
78//!      (a non-square pattern for `Symmetric`, (P2) violated).
79//! (O2) [`Natural`] is the identity; the others are deterministic
80//!      functions of the pattern.
81//!
82//! ## Defaults and their source
83//! `Amd { dense: None (= 10), aggressive: true }` and `Colamd { dense_row:
84//! None (= 10), dense_col: None (= 10) }` are SuiteSparse's `AMD_DENSE`,
85//! `AMD_AGGRESSIVE`, `COLAMD_DENSE_ROW` and `COLAMD_DENSE_COL` defaults
86//! (AMD 2.x and COLAMD 2.x user guides).
87//!
88//! ## References
89//! Amestoy, Davis & Duff, SIAM J. Matrix Anal. Appl. 17(4), 1996, and
90//! Algorithm 837 (AMD), ACM TOMS 30(3), 2004; Davis, Gilbert, Larimore &
91//! Ng, "A column approximate minimum degree ordering algorithm" and
92//! Algorithm 836 (COLAMD), ACM TOMS 30(3), 2004; George & Ng (1987); the
93//! contract of the crate (`Ordering`, `OrderingTarget`, `Natural`, `Amd`,
94//! `Colamd`).
95
96use crate::factor::count_f64;
97use crate::perm::idx32;
98use crate::qgraph::{self, Input};
99use crate::real::lt;
100use crate::{Pattern, Perm, SparseError};
101
102/// What an ordering is asked to reduce fill for.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum OrderingTarget {
105    /// A symmetric order of a square pattern (Cholesky, `LDLᵀ`): rows and
106    /// columns are permuted alike.
107    Symmetric,
108    /// A column order for an LU with row pivoting (or a QR): the pattern
109    /// of `SᵀS`, without forming it (COLAMD).
110    Columns,
111}
112
113/// Fill-reducing orderings. Implementations are stateless policies
114/// (invariants (O1)–(O2)).
115pub trait Ordering {
116    /// A permutation of the columns of `pattern` (for `Symmetric`, of its
117    /// rows and columns alike).
118    fn order(&self, pattern: &Pattern, target: OrderingTarget) -> Result<Perm, SparseError>;
119    /// A short name for logs and tables.
120    fn name(&self) -> &'static str;
121}
122
123/// The identity ordering.
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
125pub struct Natural;
126
127/// Approximate minimum degree (Amestoy, Davis & Duff 1996), Algorithm A:
128/// quotient graph with element absorption, approximate external degrees,
129/// mass elimination of indistinguishable nodes and dense-row detection.
130/// Our own implementation (D10); SuiteSparse AMD is the oracle in tests.
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct Amd {
133    /// Nodes of degree above `max(16, dense · √n)` are ordered last
134    /// (`None`: AMD's default `10`; negative: no dense detection).
135    pub dense: Option<f64>,
136    /// Aggressive absorption of elements (AMD's default: on).
137    pub aggressive: bool,
138}
139
140impl Default for Amd {
141    fn default() -> Self {
142        Self {
143            dense: None,
144            aggressive: true,
145        }
146    }
147}
148
149/// Column approximate minimum degree (Davis, Gilbert, Larimore & Ng 2004),
150/// Algorithm C: the AMD idea applied to the pattern of `SᵀS` without
151/// forming it, for an LU with row pivoting. Our own implementation (D10).
152#[derive(Clone, Copy, Debug, Default, PartialEq)]
153pub struct Colamd {
154    /// Rows with more than `max(16, dense_row · √n)` entries are ignored
155    /// (`None`: COLAMD's default `10`; negative: none is).
156    pub dense_row: Option<f64>,
157    /// Columns with more than `max(16, dense_col · √min(m, n))` entries
158    /// are ordered last (`None`: COLAMD's default `10`; negative: none
159    /// is).
160    pub dense_col: Option<f64>,
161}
162
163/// The default dense knob of AMD and COLAMD.
164const DENSE_DEFAULT: f64 = 10.0;
165
166/// The least dense threshold (SuiteSparse's `16`).
167const DENSE_MIN: usize = 16;
168
169/// `κ(α, N) = max(16, ⌊α √N⌋)`; `usize::MAX` (never dense) for `α < 0`.
170fn dense_threshold(alpha: Option<f64>, big_n: usize) -> usize {
171    let a = alpha.unwrap_or(DENSE_DEFAULT);
172    if lt(a, 0.0) {
173        return usize::MAX;
174    }
175    floor_usize(a * count_f64(big_n).sqrt()).max(DENSE_MIN)
176}
177
178/// `⌊x⌋` for a finite `x ≥ 0` (saturating, as `as` is).
179#[inline]
180#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // x ≥ 0, saturating
181fn floor_usize(x: f64) -> usize {
182    x as usize
183}
184
185/// A square check for the `Symmetric` target.
186fn square(pattern: &Pattern) -> Result<usize, SparseError> {
187    let n = pattern.n_cols();
188    if pattern.n_rows == n {
189        Ok(n)
190    } else {
191        Err(SparseError::Dimension {
192            what: "pattern (a symmetric ordering needs a square one)",
193            expected: n,
194            found: pattern.n_rows,
195        })
196    }
197}
198
199/// The off-diagonal adjacency of a symmetric pattern.
200fn adjacency(g: &Pattern) -> Vec<Vec<u32>> {
201    (0..g.n_cols())
202        .map(|j| {
203            g.col(j)
204                .iter()
205                .copied()
206                .filter(|&i| i as usize != j)
207                .collect()
208        })
209        .collect()
210}
211
212/// The off-diagonal adjacency of `S + Sᵀ` for a square `S` (Algorithm S of
213/// Chapter 5.9 straight into the lists the engine takes: one transpose
214/// and one merge per column, no intermediate pattern).
215fn sym_adjacency(s: &Pattern) -> Vec<Vec<u32>> {
216    let t = s.transpose();
217    (0..s.n_cols())
218        .map(|j| {
219            let (a, b) = (s.col(j), t.col(j));
220            let jj = idx32(j);
221            let mut out = Vec::with_capacity(a.len() + b.len());
222            let (mut p, mut q) = (0, 0);
223            while p < a.len() && q < b.len() {
224                let (x, y) = (a[p], b[q]);
225                let next = x.min(y);
226                p += usize::from(x == next);
227                q += usize::from(y == next);
228                if next != jj {
229                    out.push(next);
230                }
231            }
232            out.extend(a[p..].iter().chain(&b[q..]).copied().filter(|&i| i != jj));
233            out
234        })
235        .collect()
236}
237
238/// `fwd` = `ordered`, then the excluded indices of each group in order.
239fn finish(n: usize, mut fwd: Vec<u32>, groups: &[&[bool]]) -> Perm {
240    for g in groups {
241        fwd.extend((0..n).filter(|&j| g[j]).map(idx32));
242    }
243    debug_assert_eq!(fwd.len(), n);
244    let p = Perm::from_fwd(fwd).expect("(O1): the ordering is a permutation");
245    debug_assert_eq!(p.check_invariants(), Ok(()));
246    p
247}
248
249impl Ordering for Natural {
250    fn order(&self, pattern: &Pattern, target: OrderingTarget) -> Result<Perm, SparseError> {
251        pattern.check_invariants()?;
252        let n = match target {
253            OrderingTarget::Symmetric => square(pattern)?,
254            OrderingTarget::Columns => pattern.n_cols(),
255        };
256        Ok(Perm::identity(n))
257    }
258    fn name(&self) -> &'static str {
259        "natural"
260    }
261}
262
263impl Ordering for Amd {
264    fn order(&self, pattern: &Pattern, target: OrderingTarget) -> Result<Perm, SparseError> {
265        pattern.check_invariants()?;
266        // A1.
267        let adj = match target {
268            OrderingTarget::Symmetric => {
269                square(pattern)?;
270                sym_adjacency(pattern)
271            }
272            OrderingTarget::Columns => adjacency(&pattern.ata()),
273        };
274        let n = adj.len();
275        // A2.
276        let delta = dense_threshold(self.dense, n);
277        let excluded: Vec<bool> = adj.iter().map(|a| a.len() > delta).collect();
278        // A3.
279        let fwd = qgraph::order(Input {
280            n,
281            adj,
282            elems: Vec::new(),
283            excluded: excluded.clone(),
284            aggressive: self.aggressive,
285            previous_degree: true,
286            column_order: false,
287        });
288        // A4.
289        Ok(finish(n, fwd, &[excluded.as_slice()]))
290    }
291    fn name(&self) -> &'static str {
292        "amd"
293    }
294}
295
296impl Ordering for Colamd {
297    fn order(&self, pattern: &Pattern, target: OrderingTarget) -> Result<Perm, SparseError> {
298        pattern.check_invariants()?;
299        match target {
300            OrderingTarget::Columns => Ok(self.columns(pattern)),
301            OrderingTarget::Symmetric => {
302                square(pattern)?;
303                Ok(self.symmetric(&pattern.symmetric()?))
304            }
305        }
306    }
307    fn name(&self) -> &'static str {
308        "colamd"
309    }
310}
311
312impl Colamd {
313    /// Algorithm C.
314    fn columns(self, s: &Pattern) -> Perm {
315        let (m, n) = (s.n_rows, s.n_cols());
316        // C1.
317        let kc = dense_threshold(self.dense_col, m.min(n));
318        let dense: Vec<bool> = (0..n).map(|j| s.col(j).len() > kc).collect();
319        // C2.
320        let t = s.transpose();
321        let kr = dense_threshold(self.dense_row, n);
322        let mut elems = Vec::with_capacity(m);
323        let mut has_row = vec![false; n];
324        for i in 0..m {
325            let row: Vec<u32> = t
326                .col(i)
327                .iter()
328                .copied()
329                .filter(|&j| !dense[j as usize])
330                .collect();
331            if row.is_empty() || row.len() > kr {
332                continue;
333            }
334            for &j in &row {
335                has_row[j as usize] = true;
336            }
337            elems.push(row);
338        }
339        // C3.
340        let null: Vec<bool> = (0..n).map(|j| !dense[j] && !has_row[j]).collect();
341        let excluded: Vec<bool> = (0..n).map(|j| dense[j] || null[j]).collect();
342        // C4.
343        let fwd = qgraph::order(Input {
344            n,
345            adj: vec![Vec::new(); n],
346            elems,
347            excluded,
348            aggressive: true,
349            previous_degree: false,
350            column_order: true,
351        });
352        // C5.
353        finish(n, fwd, &[null.as_slice(), dense.as_slice()])
354    }
355
356    /// SYMAMD: Algorithm C on the edge rows of the symmetric pattern `g`.
357    fn symmetric(self, g: &Pattern) -> Perm {
358        let n = g.n_cols();
359        let adj = adjacency(g);
360        let kc = dense_threshold(self.dense_col, n);
361        let dense: Vec<bool> = adj.iter().map(|a| a.len() > kc).collect();
362        let mut elems = Vec::new();
363        for (j, a) in adj.iter().enumerate() {
364            if dense[j] {
365                continue;
366            }
367            for &i in a {
368                if (i as usize) < j && !dense[i as usize] {
369                    elems.push(vec![i, idx32(j)]);
370                }
371            }
372        }
373        let fwd = qgraph::order(Input {
374            n,
375            adj: vec![Vec::new(); n],
376            elems,
377            excluded: dense.clone(),
378            aggressive: true,
379            previous_degree: false,
380            column_order: true,
381        });
382        finish(n, fwd, &[dense.as_slice()])
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use proptest::prelude::*;
390
391    /// xorshift64*.
392    struct Rng(u64);
393    impl Rng {
394        fn new(seed: u64) -> Self {
395            Self(seed | 1)
396        }
397        fn below(&mut self, n: usize) -> usize {
398            self.0 ^= self.0 >> 12;
399            self.0 ^= self.0 << 25;
400            self.0 ^= self.0 >> 27;
401            usize::try_from(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) % (n as u64)).unwrap()
402        }
403        fn shuffle(&mut self, v: &mut [u32]) {
404            for i in (1..v.len()).rev() {
405                let j = self.below(i + 1);
406                v.swap(i, j);
407            }
408        }
409    }
410
411    /// A pattern from `(i, j)` pairs (duplicates collapse).
412    fn pat(m: usize, n: usize, e: &[(usize, usize)]) -> Pattern {
413        let mut cols: Vec<Vec<u32>> = vec![Vec::new(); n];
414        for &(i, j) in e {
415            cols[j].push(idx32(i));
416        }
417        let mut col_ptr = vec![0];
418        let mut row_idx = Vec::new();
419        for mut c in cols {
420            c.sort_unstable();
421            c.dedup();
422            row_idx.extend(c);
423            col_ptr.push(row_idx.len());
424        }
425        Pattern::from_raw_parts(m, col_ptr, row_idx).unwrap()
426    }
427
428    /// The symmetric pattern of an undirected graph given by its edges,
429    /// relabelled by `label`.
430    fn graph(n: usize, edges: &[(usize, usize)], label: &[u32]) -> Pattern {
431        let mut e: Vec<(usize, usize)> = (0..n).map(|i| (i, i)).collect();
432        for &(a, b) in edges {
433            let (a, b) = (label[a] as usize, label[b] as usize);
434            e.push((a, b));
435            e.push((b, a));
436        }
437        pat(n, n, &e)
438    }
439
440    fn random_label(rng: &mut Rng, n: usize) -> Vec<u32> {
441        let mut l: Vec<u32> = (0..n).map(idx32).collect();
442        rng.shuffle(&mut l);
443        l
444    }
445
446    /// A random tree on `n` nodes.
447    fn tree_edges(rng: &mut Rng, n: usize) -> Vec<(usize, usize)> {
448        (1..n).map(|i| (i, rng.below(i))).collect()
449    }
450
451    /// The `k × k` grid graph in its natural (row-major) numbering.
452    fn grid_edges(k: usize) -> Vec<(usize, usize)> {
453        let mut e = Vec::new();
454        for r in 0..k {
455            for c in 0..k {
456                let v = r * k + c;
457                if c + 1 < k {
458                    e.push((v, v + 1));
459                }
460                if r + 1 < k {
461                    e.push((v, v + k));
462                }
463            }
464        }
465        e
466    }
467
468    /// The edge–node incidence pattern (`|E| × n`): its `MᵀM` is the graph.
469    fn incidence(n: usize, edges: &[(usize, usize)], label: &[u32]) -> Pattern {
470        let mut e = Vec::new();
471        for (r, &(a, b)) in edges.iter().enumerate() {
472            e.push((r, label[a] as usize));
473            e.push((r, label[b] as usize));
474        }
475        pat(edges.len().max(1), n, &e)
476    }
477
478    fn fill(s: &Pattern, p: &Perm) -> usize {
479        s.cholesky_nnz(p).unwrap()
480    }
481
482    // -----------------------------------------------------------------
483    // Naive references.
484    // -----------------------------------------------------------------
485
486    /// Exact minimum degree on the explicit elimination graph (ties: the
487    /// smallest index) — the classical algorithm AMD approximates.
488    fn exact_minimum_degree(s: &Pattern) -> Perm {
489        let n = s.n_cols();
490        let g = s.symmetric().unwrap();
491        let mut adj: Vec<Vec<bool>> = vec![vec![false; n]; n];
492        // `g` is symmetric: column j lists the neighbours of j.
493        for (j, row) in adj.iter_mut().enumerate() {
494            for &i in g.col(j) {
495                if i as usize != j {
496                    row[i as usize] = true;
497                }
498            }
499        }
500        let mut alive = vec![true; n];
501        let mut fwd = Vec::with_capacity(n);
502        for _ in 0..n {
503            let degree = |v: usize| (0..n).filter(|&w| alive[w] && adj[v][w]).count();
504            let p = (0..n)
505                .filter(|&v| alive[v])
506                .min_by_key(|&v| (degree(v), v))
507                .unwrap();
508            let nb: Vec<usize> = (0..n).filter(|&w| alive[w] && adj[p][w]).collect();
509            for &a in &nb {
510                for &b in &nb {
511                    if a != b {
512                        adj[a][b] = true;
513                    }
514                }
515            }
516            alive[p] = false;
517            fwd.push(idx32(p));
518        }
519        Perm::from_fwd(fwd).unwrap()
520    }
521
522    /// The least fill over all `n!` orders (brute force, `n ≤ 7`).
523    fn optimal_fill(s: &Pattern) -> usize {
524        fn rec(k: usize, v: &mut Vec<u32>, s: &Pattern, best: &mut usize) {
525            if k == v.len() {
526                let f = s.cholesky_nnz(&Perm::from_fwd(v.clone()).unwrap()).unwrap();
527                *best = (*best).min(f);
528                return;
529            }
530            for i in k..v.len() {
531                v.swap(k, i);
532                rec(k + 1, v, s, best);
533                v.swap(k, i);
534            }
535        }
536        let mut v: Vec<u32> = (0..s.n_cols()).map(idx32).collect();
537        let mut best = usize::MAX;
538        rec(0, &mut v, s, &mut best);
539        best
540    }
541
542    fn random_graph(rng: &mut Rng, n: usize, pct: usize) -> Pattern {
543        let mut e = Vec::new();
544        for a in 0..n {
545            for b in a + 1..n {
546                if rng.below(100) < pct {
547                    e.push((a, b));
548                }
549            }
550        }
551        let id: Vec<u32> = (0..n).map(idx32).collect();
552        graph(n, &e, &id)
553    }
554
555    // -----------------------------------------------------------------
556    // Examples and properties.
557    // -----------------------------------------------------------------
558
559    #[test]
560    fn natural_and_errors() {
561        let s = pat(2, 3, &[(0, 0), (1, 2)]);
562        assert!(
563            Natural
564                .order(&s, OrderingTarget::Columns)
565                .unwrap()
566                .is_identity()
567        );
568        assert!(Natural.order(&s, OrderingTarget::Symmetric).is_err());
569        assert!(Amd::default().order(&s, OrderingTarget::Symmetric).is_err());
570        assert!(
571            Colamd::default()
572                .order(&s, OrderingTarget::Symmetric)
573                .is_err()
574        );
575        assert_eq!(
576            Amd::default()
577                .order(&s, OrderingTarget::Columns)
578                .unwrap()
579                .len(),
580            3
581        );
582        assert_eq!(
583            Colamd::default()
584                .order(&s, OrderingTarget::Columns)
585                .unwrap()
586                .len(),
587            3
588        );
589        let empty = Pattern::empty(0, 0);
590        for o in [
591            &Natural as &dyn Ordering,
592            &Amd::default(),
593            &Colamd::default(),
594        ] {
595            assert!(
596                o.order(&empty, OrderingTarget::Symmetric)
597                    .unwrap()
598                    .is_empty()
599            );
600            assert_ne!(o.name(), "");
601        }
602        assert_eq!(dense_threshold(None, 100), 100);
603        assert_eq!(dense_threshold(Some(1.0), 4), DENSE_MIN);
604        assert_eq!(dense_threshold(Some(-1.0), 4), usize::MAX);
605    }
606
607    /// A path long enough that the flag of step Q4 (`n + 1` per pivot, in
608    /// `u32`) restarts (Chapter 5.11, "Constant factors"): the order is
609    /// still perfect (`nnz(L) = 2n − 1`).
610    #[test]
611    fn long_path_restarts_the_flag_and_stays_perfect() {
612        let n = 100_000;
613        let edges: Vec<(usize, usize)> = (1..n).map(|i| (i, i - 1)).collect();
614        let s = pat(n, n, &edges).symmetric().unwrap();
615        for o in [&Amd::default() as &dyn Ordering, &Colamd::default()] {
616            let p = o.order(&s, OrderingTarget::Symmetric).unwrap();
617            assert_eq!(s.cholesky_nnz(&p).unwrap(), 2 * n - 1, "{}", o.name());
618        }
619    }
620
621    /// Example 5.10.1: the arrowhead with its hub first fills completely
622    /// in the natural order; AMD and COLAMD order the hub last (by degree —
623    /// mass-eliminated with the last leaf — for small `n`, as a dense node
624    /// for large `n`) and fill nothing.
625    #[test]
626    fn ex_5_10_1_arrowhead() {
627        for n in [8_usize, 30, 400] {
628            let edges: Vec<(usize, usize)> = (1..n).map(|i| (0, i)).collect();
629            let id: Vec<u32> = (0..n).map(idx32).collect();
630            let s = graph(n, &edges, &id);
631            let nat = fill(&s, &Natural.order(&s, OrderingTarget::Symmetric).unwrap());
632            assert_eq!(nat, n * (n + 1) / 2);
633            for o in [&Amd::default() as &dyn Ordering, &Colamd::default()] {
634                let p = o.order(&s, OrderingTarget::Symmetric).unwrap();
635                assert_eq!(fill(&s, &p), 2 * n - 1, "{} n = {n}", o.name());
636                // Last, or mass-eliminated with the last leaf (Q5).
637                assert!(
638                    p.inv[0] as usize + 2 >= n,
639                    "{}: hub at {}",
640                    o.name(),
641                    p.inv[0]
642                );
643            }
644            let m = incidence(n, &edges, &id);
645            let p = Colamd::default()
646                .order(&m, OrderingTarget::Columns)
647                .unwrap();
648            assert_eq!(fill(&s, &p), 2 * n - 1);
649        }
650    }
651
652    /// Example 5.10.2: on a `k × k` grid, AMD (on a random relabelling)
653    /// fills less than the natural band order.
654    #[test]
655    fn ex_5_10_2_grids_beat_the_band() {
656        let mut rng = Rng::new(7);
657        for k in [6_usize, 10, 20, 30] {
658            let n = k * k;
659            let e = grid_edges(k);
660            let id: Vec<u32> = (0..n).map(idx32).collect();
661            let band = fill(&graph(n, &e, &id), &Perm::identity(n));
662            let label = random_label(&mut rng, n);
663            let s = graph(n, &e, &label);
664            let amd = fill(
665                &s,
666                &Amd::default().order(&s, OrderingTarget::Symmetric).unwrap(),
667            );
668            let sym = fill(
669                &s,
670                &Colamd::default()
671                    .order(&s, OrderingTarget::Symmetric)
672                    .unwrap(),
673            );
674            let m = incidence(n, &e, &label);
675            let col = fill(
676                &s,
677                &Colamd::default()
678                    .order(&m, OrderingTarget::Columns)
679                    .unwrap(),
680            );
681            assert!(
682                amd <= band && sym <= band && col <= band,
683                "k {k}: band {band}, amd {amd}, symamd {sym}, colamd {col}"
684            );
685        }
686    }
687
688    /// Brute force: on every small random graph the fill of AMD is at
689    /// least the optimum (a check of the counter) and, summed over the
690    /// sample, within 10 % of exact minimum degree and of 1.25× the
691    /// optimum; the same for COLAMD on the incidence pattern.
692    #[test]
693    fn brute_force_against_minimum_degree_and_optimum() {
694        let mut rng = Rng::new(2026);
695        let (mut amd_s, mut col_s, mut md_s, mut opt_s) = (0, 0, 0, 0);
696        for case in 0..300 {
697            let n = 2 + case % 6;
698            let s = random_graph(&mut rng, n, 20 + case % 50);
699            let opt = optimal_fill(&s);
700            let amd = fill(
701                &s,
702                &Amd::default().order(&s, OrderingTarget::Symmetric).unwrap(),
703            );
704            let mut edges = Vec::new();
705            for j in 0..n {
706                for &i in s.col(j) {
707                    if (i as usize) < j {
708                        edges.push((i as usize, j));
709                    }
710                }
711            }
712            let id: Vec<u32> = (0..n).map(idx32).collect();
713            let m = incidence(n, &edges, &id);
714            let col = fill(
715                &s,
716                &Colamd::default()
717                    .order(&m, OrderingTarget::Columns)
718                    .unwrap(),
719            );
720            let md = fill(&s, &exact_minimum_degree(&s));
721            assert!(amd >= opt && col >= opt && md >= opt);
722            amd_s += amd;
723            col_s += col;
724            md_s += md;
725            opt_s += opt;
726        }
727        let r = |a: usize, b: usize| count_f64(a) / count_f64(b);
728        assert!(r(amd_s, md_s) <= 1.10, "AMD/MD {}", r(amd_s, md_s));
729        assert!(r(col_s, md_s) <= 1.10, "COLAMD/MD {}", r(col_s, md_s));
730        assert!(r(amd_s, opt_s) <= 1.25 && r(col_s, opt_s) <= 1.25);
731    }
732
733    /// Larger graphs: AMD within 10 % of exact minimum degree in total.
734    #[test]
735    fn amd_tracks_exact_minimum_degree() {
736        let mut rng = Rng::new(11);
737        let (mut amd_s, mut md_s) = (0, 0);
738        for case in 0..40 {
739            let n = 20 + case;
740            let s = random_graph(&mut rng, n, 3 + case % 8);
741            amd_s += fill(
742                &s,
743                &Amd::default().order(&s, OrderingTarget::Symmetric).unwrap(),
744            );
745            md_s += fill(&s, &exact_minimum_degree(&s));
746        }
747        assert!(
748            count_f64(amd_s) <= 1.10 * count_f64(md_s),
749            "AMD {amd_s} MD {md_s}"
750        );
751    }
752
753    proptest! {
754        /// (O1): every ordering is a bijection, for both targets, on random
755        /// rectangular patterns with empty and dense lines.
756        #[test]
757        fn orderings_are_permutations(m in 1_usize..40, n in 1_usize..40,
758                                      e in prop::collection::vec((0_usize..40, 0_usize..40), 0..300),
759                                      dense_line in 0_usize..3, knob in -1.0_f64..2.0) {
760            let mut e: Vec<(usize, usize)> = e.into_iter().map(|(i, j)| (i % m, j % n)).collect();
761            if dense_line == 1 {
762                e.extend((0..n).map(|j| (0, j)));
763            } else if dense_line == 2 {
764                e.extend((0..m).map(|i| (i, 0)));
765            }
766            let s = pat(m, n, &e);
767            let sq = pat(n, n, &e.iter().map(|&(i, j)| (i % n, j)).collect::<Vec<_>>());
768            let orderings: [&dyn Ordering; 5] = [
769                &Natural,
770                &Amd::default(),
771                &Amd { dense: Some(knob), aggressive: false },
772                &Colamd::default(),
773                &Colamd { dense_row: Some(knob), dense_col: Some(knob) },
774            ];
775            for o in orderings {
776                let p = o.order(&s, OrderingTarget::Columns).unwrap();
777                prop_assert_eq!(p.len(), n);
778                prop_assert!(p.check_invariants().is_ok());
779                let q = o.order(&sq, OrderingTarget::Symmetric).unwrap();
780                prop_assert_eq!(q.len(), n);
781                prop_assert!(q.check_invariants().is_ok());
782                prop_assert_eq!(o.order(&sq, OrderingTarget::Symmetric).unwrap(), q);
783            }
784        }
785
786        /// Trees (relabelled at random) are eliminated without fill by AMD
787        /// and SYMAMD, and COLAMD on their incidence pattern; the natural
788        /// order of the relabelled tree is never better.
789        #[test]
790        fn trees_have_no_fill(n in 1_usize..120, seed in any::<u64>()) {
791            let mut rng = Rng::new(seed);
792            let e = tree_edges(&mut rng, n);
793            let label = random_label(&mut rng, n);
794            let s = graph(n, &e, &label);
795            let none = 2 * n - 1;
796            prop_assert!(fill(&s, &Perm::identity(n)) >= none);
797            prop_assert_eq!(fill(&s, &Amd::default().order(&s, OrderingTarget::Symmetric).unwrap()), none);
798            prop_assert_eq!(fill(&s, &Colamd::default().order(&s, OrderingTarget::Symmetric).unwrap()), none);
799            let m = incidence(n, &e, &label);
800            prop_assert_eq!(fill(&s, &Colamd::default().order(&m, OrderingTarget::Columns).unwrap()), none);
801            prop_assert_eq!(fill(&s, &Amd::default().order(&m, OrderingTarget::Columns).unwrap()), none);
802        }
803
804        /// Structured matrices: a block-arrow (dense border of width `w`
805        /// first, then independent diagonal blocks) relabelled at random —
806        /// the orderings never fill more than the natural order of the
807        /// unrelabelled matrix, which puts the border first and so turns
808        /// all the blocks into one clique. (With one block the natural
809        /// order has no fill at all, and a minimum-degree tie can cost one
810        /// entry: the case is excluded, it is not a property; so are borders
811        /// whose nodes have no more neighbours than the block nodes.)
812        #[test]
813        fn block_arrows_beat_the_natural_order(blocks in 2_usize..8, size in 2_usize..6,
814                                               w in 1_usize..4, seed in any::<u64>()) {
815            // The block nodes must be the ones of least degree
816            // (`size − 1 + w < blocks · size`), else minimum degree may
817            // start on the border as the natural order does.
818            prop_assume!(size + w <= blocks * size);
819            let mut rng = Rng::new(seed);
820            let n = w + blocks * size;
821            let mut e = Vec::new();
822            for b in 0..blocks {
823                let base = w + b * size;
824                for x in 0..size {
825                    for y in x + 1..size {
826                        e.push((base + x, base + y));
827                    }
828                    for c in 0..w {
829                        e.push((c, base + x));
830                    }
831                }
832            }
833            let id: Vec<u32> = (0..n).map(idx32).collect();
834            let nat = fill(&graph(n, &e, &id), &Perm::identity(n));
835            let label = random_label(&mut rng, n);
836            let s = graph(n, &e, &label);
837            for o in [&Amd::default() as &dyn Ordering, &Colamd::default()] {
838                let f = fill(&s, &o.order(&s, OrderingTarget::Symmetric).unwrap());
839                prop_assert!(f <= nat, "{}: {} > natural {}", o.name(), f, nat);
840            }
841        }
842    }
843}