Skip to main content

sparse_ldlt/
lib.rs

1//! Pure-Rust, dependency-free sparse **symmetric-indefinite** LDLᵀ factorization.
2//!
3//! Factors a symmetric sparse matrix `A = L D Lᵀ`, where `L` is unit-lower-triangular
4//! and `D` is a **signed** diagonal, then solves `A x = b`. Because `D` may carry
5//! negative entries, this handles symmetric **indefinite** systems (KKT / saddle-point
6//! problems, shifted eigenvalue matrices `K - σM`, quasi-definite systems) - not just
7//! positive-definite ones - and it exposes `D` so you can read the matrix **inertia**
8//! (the number of negative eigenvalues, by Sylvester's law) for Sturm eigenvalue counts.
9//!
10//! Most pure-Rust sparse factorizations only offer positive-definite Cholesky and do not
11//! expose the signed pivots; this crate fills that gap with a small, self-contained
12//! implementation of the standard up-looking sparse LDLᵀ (elimination-tree) method
13//! described in T. A. Davis, *Direct Methods for Sparse Linear Systems* (SIAM, 2006).
14//!
15//! It has **no dependencies** and works on stable Rust. The matrix is supplied in
16//! compressed-sparse-column (CSC) form; only the upper triangle (entries with row ≤ col
17//! in each column) is read, so a fully-populated symmetric matrix is also accepted.
18//!
19//! No pivoting is performed: like every un-pivoted LDLᵀ it breaks down if a diagonal entry
20//! of `D` reaches zero ([`LdltError::ZeroPivot`]) - and, just as importantly, if a pivot's
21//! magnitude has been destroyed by cancellation ([`LdltError::NearZeroPivot`]). The second
22//! case is the dangerous one: such a pivot still carries a sign, but that sign is rounding
23//! noise, and the sign pattern of `D` IS the matrix inertia, so a silently-returned
24//! near-zero pivot is a silently wrong eigenvalue count. Both are reported, never
25//! swallowed. [`SparseLdlt::factor_shifted`] retries the breakdown with a diagonal shift
26//! and tells you, via [`SparseLdlt::shift`], exactly how far it moved the matrix.
27//! Non-finite input values (NaN / ±inf) are rejected up front rather than silently
28//! propagating through the factors.
29//!
30//! # Example
31//! ```
32//! use sparse_ldlt::SparseLdlt;
33//! // Symmetric indefinite 3x3 matrix (full storage), CSC:
34//! //   [ 2  1  0 ]
35//! //   [ 1 -3  1 ]
36//! //   [ 0  1  2 ]
37//! let col_ptr = vec![0, 2, 5, 7];
38//! let row_idx = vec![0, 1,  0, 1, 2,  1, 2];
39//! let values  = vec![2.0, 1.0,  1.0, -3.0, 1.0,  1.0, 2.0];
40//! let f = SparseLdlt::factor(3, &col_ptr, &row_idx, &values).unwrap();
41//! let x = f.solve(&[1.0, 2.0, 3.0]).unwrap();
42//! // one negative pivot => one negative eigenvalue (inertia)
43//! assert_eq!(f.d().iter().filter(|&&v| v < 0.0).count(), 1);
44//! # assert!(x.len() == 3);
45//! ```
46
47#![forbid(unsafe_code)]
48// Sparse CSC factorization is inherently index-driven (column ranges index parallel
49// indices/values arrays); range loops are clearer here than iterator gymnastics.
50#![allow(clippy::needless_range_loop)]
51
52/// Relative tolerance below which a pivot counts as destroyed rather than merely small.
53///
54/// `1e-13` is about 1000x `f64::EPSILON`. Below it a pivot has lost essentially all of its
55/// significant digits to cancellation, so its magnitude is meaningless and - the reason this
56/// matters here - its SIGN is rounding noise. Since the sign pattern of `D` is the matrix
57/// inertia, accepting such a pivot means returning an inertia that is noise, silently.
58///
59/// The threshold is deliberately not configurable: a caller who wants a different one should
60/// scale their matrix so that the tolerance means what they want it to mean, or use
61/// [`SparseLdlt::factor_shifted`], which moves the matrix off the near-singular point instead
62/// of arguing about where the cliff edge is.
63pub const NEAR_ZERO_PIVOT_REL: f64 = 1e-13;
64
65/// Failure modes of the factorization and solves.
66///
67/// `Eq` is deliberately not derived: [`LdltError::NearZeroPivot`] carries `f64` payloads.
68#[derive(Debug, Clone, PartialEq)]
69pub enum LdltError {
70    /// A zero pivot (`D[k] == 0`) was hit at this column: the matrix is singular or the
71    /// un-pivoted factorization broke down there.
72    ZeroPivot(usize),
73    /// A pivot that is not exactly zero but has lost every significant digit to
74    /// cancellation: `|D[k]| < ` [`NEAR_ZERO_PIVOT_REL`] `* scale`.
75    ///
76    /// This is the honest report of the case that used to be returned silently, and it
77    /// matters because the sign pattern of `D` is the matrix inertia (Sylvester's law).
78    /// A pivot at this magnitude still has a sign, but that sign is rounding noise, so the
79    /// inertia read from the factorization would be noise too - and downstream that inertia
80    /// is a Sturm eigenvalue count, i.e. an eigenvalue or buckling load. Returning it is
81    /// strictly better than returning a number nobody can tell is wrong.
82    ///
83    /// Recover by moving off the near-singular point: either shift the matrix yourself, or
84    /// call [`SparseLdlt::factor_shifted`], which does exactly that and reports the shift it
85    /// used through [`SparseLdlt::shift`].
86    NearZeroPivot {
87        /// The column at which the pivot collapsed.
88        column: usize,
89        /// The computed pivot value. Its sign is not trustworthy at this magnitude.
90        pivot: f64,
91        /// The largest absolute diagonal entry of the input matrix - the reference the
92        /// tolerance is relative to.
93        scale: f64,
94        /// A diagonal shift large enough to clear the breakdown: `sqrt(`
95        /// [`NEAR_ZERO_PIVOT_REL`] `) * scale`, i.e. comfortably outside the tolerance band
96        /// rather than on its edge. Factoring `A + suggested_shift * I` is an exact
97        /// factorization of a NEARBY matrix, not of `A`.
98        suggested_shift: f64,
99    },
100    /// The CSC arrays were inconsistent (bad length, `col_ptr` not monotonic, an index
101    /// out of range, or a non-finite value).
102    InvalidInput(&'static str),
103    /// A right-hand side (or multi-RHS row) did not match the factored matrix's order.
104    SizeMismatch {
105        /// The order of the factored matrix.
106        expected: usize,
107        /// The length that was supplied.
108        got: usize,
109    },
110}
111
112/// An `L D Lᵀ` factorization of a symmetric matrix.
113///
114/// `L` is stored in CSC by column with an **implicit** unit diagonal (only the strictly
115/// lower entries are kept); `d` is the signed diagonal of `D`.
116#[derive(Debug, Clone)]
117pub struct SparseLdlt {
118    n: usize,
119    lp: Vec<usize>, // column pointers of L, length n+1
120    li: Vec<usize>, // row indices of the strictly-lower entries of L
121    lx: Vec<f64>,   // values matching li
122    d: Vec<f64>,    // signed diagonal of D, length n
123    // Elimination order: `order[k]` = original index of the node sitting at permuted
124    // position k. Identity for [`SparseLdlt::factor`]; the AMD ordering for
125    // [`SparseLdlt::factor_perm`], which `solve` uses to map right-hand sides in and
126    // solutions back out.
127    order: Vec<usize>,
128    // The diagonal shift that was actually applied, if any. See [`SparseLdlt::shift`].
129    shift: f64,
130}
131
132/// The largest absolute diagonal entry of a CSC matrix, summing duplicate entries the same
133/// way the factorization's scatter does. `0.0` if the matrix stores no diagonal at all -
134/// callers must guard against that, or a relative tolerance test would pass vacuously.
135fn diagonal_scale(n: usize, col_ptr: &[usize], row_idx: &[usize], values: &[f64]) -> f64 {
136    let mut scale = 0.0f64;
137    for k in 0..n {
138        let mut dk = 0.0f64;
139        for p in col_ptr[k]..col_ptr[k + 1] {
140            if row_idx[p] == k {
141                dk += values[p];
142            }
143        }
144        scale = scale.max(dk.abs());
145    }
146    scale
147}
148
149/// `(col_ptr, row_idx, values)` for `A + shift * I`. One extra diagonal entry is appended per
150/// column; the factorization sums duplicates in its scatter, so this is correct whether or not
151/// the column already stored a diagonal, and it is correct under a symmetric permutation too
152/// (a diagonal entry stays diagonal).
153#[allow(clippy::type_complexity)]
154fn with_diagonal_shift(
155    n: usize,
156    col_ptr: &[usize],
157    row_idx: &[usize],
158    values: &[f64],
159    shift: f64,
160) -> (Vec<usize>, Vec<usize>, Vec<f64>) {
161    let mut cp = Vec::with_capacity(n + 1);
162    let mut ri = Vec::with_capacity(row_idx.len() + n);
163    let mut vx = Vec::with_capacity(values.len() + n);
164    cp.push(0usize);
165    for k in 0..n {
166        for p in col_ptr[k]..col_ptr[k + 1] {
167            ri.push(row_idx[p]);
168            vx.push(values[p]);
169        }
170        ri.push(k);
171        vx.push(shift);
172        cp.push(ri.len());
173    }
174    (cp, ri, vx)
175}
176
177impl SparseLdlt {
178    /// Factor a symmetric `n x n` matrix supplied in CSC form.
179    ///
180    /// - `col_ptr` has length `n + 1`; column `k` occupies `col_ptr[k]..col_ptr[k+1]`.
181    /// - `row_idx` and `values` are parallel arrays of the nonzeros (any row order).
182    ///
183    /// Only the upper triangle (entries with row ≤ col) is read; a fully symmetric
184    /// matrix works too. No fill-reducing reordering is applied - permute the matrix
185    /// first if you want one (RCM, AMD, nested dissection, ...).
186    pub fn factor(
187        n: usize,
188        col_ptr: &[usize],
189        row_idx: &[usize],
190        values: &[f64],
191    ) -> Result<Self, LdltError> {
192        Self::factor_inner(n, col_ptr, row_idx, values, None)
193    }
194
195    /// Like [`SparseLdlt::factor`], but a near-zero pivot is RECORDED and the factorization
196    /// continues, instead of aborting at the first one.
197    ///
198    /// This exists for RANK CHECKS, not for solves. A caller asking "which directions of this
199    /// matrix are null" needs the elimination to run to the end and name every column that
200    /// collapsed - a structure with fifteen mechanisms has fifteen of them, and stopping at the
201    /// first would report one. The returned factor is NOT fit to solve or to sign-count with:
202    /// every collapsed column's pivot is rounding noise, exactly the value [`SparseLdlt::factor`]
203    /// refuses to return. Use the column list; discard `d()` for anything but structure.
204    ///
205    /// An exact zero pivot still aborts, as it must: the elimination cannot proceed through it.
206    pub fn factor_reporting_collapse(
207        n: usize,
208        col_ptr: &[usize],
209        row_idx: &[usize],
210        values: &[f64],
211    ) -> Result<(Self, Vec<usize>), LdltError> {
212        let mut collapsed = Vec::new();
213        let f = Self::factor_inner(n, col_ptr, row_idx, values, Some(&mut collapsed))?;
214        Ok((f, collapsed))
215    }
216
217    fn factor_inner(
218        n: usize,
219        col_ptr: &[usize],
220        row_idx: &[usize],
221        values: &[f64],
222        mut collapsed: Option<&mut Vec<usize>>,
223    ) -> Result<Self, LdltError> {
224        if col_ptr.len() != n + 1 {
225            return Err(LdltError::InvalidInput("col_ptr length must be n + 1"));
226        }
227        if row_idx.len() != values.len() {
228            return Err(LdltError::InvalidInput("row_idx and values length mismatch"));
229        }
230        if col_ptr[n] != row_idx.len() {
231            return Err(LdltError::InvalidInput("col_ptr[n] must equal the nonzero count"));
232        }
233        for k in 0..n {
234            if col_ptr[k] > col_ptr[k + 1] {
235                return Err(LdltError::InvalidInput("col_ptr must be non-decreasing"));
236            }
237        }
238        for &r in row_idx {
239            if r >= n {
240                return Err(LdltError::InvalidInput("row index out of range"));
241            }
242        }
243        // A NaN or infinite entry would not hit the `d[k] == 0.0` check (NaN compares
244        // false against zero) and would propagate silently into every factor entry - so
245        // reject it here, where the error can still name the cause.
246        for &v in values {
247            if !v.is_finite() {
248                return Err(LdltError::InvalidInput(
249                    "values contain a non-finite entry (NaN or infinity)",
250                ));
251            }
252        }
253        let ap = col_ptr;
254        let ai = row_idx;
255        let ax = values;
256        // The reference magnitude for the near-zero pivot test, computed once. Guarded
257        // against 0.0 below: a matrix with no diagonal at all would otherwise make the
258        // relative test pass vacuously for every pivot.
259        let scale = diagonal_scale(n, ap, ai, ax);
260
261        // ---- symbolic: elimination tree `parent` and per-column counts `lnz` ----
262        let mut parent = vec![usize::MAX; n];
263        let mut flag = vec![usize::MAX; n];
264        let mut lnz = vec![0usize; n];
265        for k in 0..n {
266            flag[k] = k;
267            for p in ap[k]..ap[k + 1] {
268                let mut i = ai[p];
269                if i < k {
270                    while flag[i] != k {
271                        if parent[i] == usize::MAX {
272                            parent[i] = k;
273                        }
274                        lnz[i] += 1;
275                        flag[i] = k;
276                        i = parent[i];
277                    }
278                }
279            }
280        }
281        let mut lp = vec![0usize; n + 1];
282        for k in 0..n {
283            lp[k + 1] = lp[k] + lnz[k];
284        }
285
286        // ---- numeric: compute L (below diagonal) and the signed D ----
287        let mut li = vec![0usize; lp[n]];
288        let mut lx = vec![0.0f64; lp[n]];
289        let mut d = vec![0.0f64; n];
290        let mut y = vec![0.0f64; n]; // dense workspace, zero between columns
291        let mut pattern = vec![0usize; n];
292        let mut fill = vec![0usize; n]; // running count of entries placed per L column
293        for f in flag.iter_mut() {
294            *f = usize::MAX;
295        }
296
297        for k in 0..n {
298            // Gather column k of A (upper triangle, rows i <= k): scatter into Y and collect
299            // the nonzero pattern of row k of L (the etree path) into pattern[top..n].
300            let mut top = n;
301            flag[k] = k;
302            y[k] = 0.0;
303            for p in ap[k]..ap[k + 1] {
304                let i = ai[p];
305                if i <= k {
306                    y[i] += ax[p];
307                    let mut len = 0usize;
308                    let mut ii = i;
309                    while flag[ii] != k {
310                        pattern[len] = ii;
311                        len += 1;
312                        flag[ii] = k;
313                        ii = parent[ii];
314                    }
315                    while len > 0 {
316                        len -= 1;
317                        top -= 1;
318                        pattern[top] = pattern[len];
319                    }
320                }
321            }
322
323            d[k] = y[k];
324            y[k] = 0.0;
325            for idx in top..n {
326                let i = pattern[idx];
327                let yi = y[i];
328                y[i] = 0.0;
329                let start = lp[i];
330                let used = fill[i];
331                for p in start..start + used {
332                    y[li[p]] -= lx[p] * yi;
333                }
334                let l_ki = yi / d[i];
335                d[k] -= l_ki * yi;
336                let slot = start + used;
337                li[slot] = k;
338                lx[slot] = l_ki;
339                fill[i] = used + 1;
340            }
341
342            if d[k] == 0.0 {
343                return Err(LdltError::ZeroPivot(k));
344            }
345            // A pivot that is merely SMALL used to be returned silently. It cannot be: at this
346            // magnitude the pivot's sign is rounding noise, and the sign pattern of D is the
347            // matrix inertia, so a silent return here is a silently wrong eigenvalue count.
348            if scale > 0.0 && d[k].abs() < NEAR_ZERO_PIVOT_REL * scale {
349                if let Some(list) = collapsed.as_deref_mut() {
350                    list.push(k);
351                    continue;
352                }
353                return Err(LdltError::NearZeroPivot {
354                    column: k,
355                    pivot: d[k],
356                    scale,
357                    // sqrt(tol) * scale, not tol * scale: a shift right at the tolerance would
358                    // land back on the edge of the band it is supposed to escape. The square
359                    // root puts it several orders of magnitude clear while still being a tiny
360                    // perturbation of the matrix.
361                    suggested_shift: NEAR_ZERO_PIVOT_REL.sqrt() * scale,
362                });
363            }
364        }
365
366        Ok(SparseLdlt { n, lp, li, lx, d, order: (0..n).collect(), shift: 0.0 })
367    }
368
369    /// Factor `P A Pᵀ` for a symmetric permutation `P` given as `order`, where
370    /// `order[k]` is the original index eliminated k-th (e.g. the output of [`amd`]).
371    ///
372    /// The returned factorization solves `A x = b` DIRECTLY - the permutation is stored and
373    /// `solve` maps the right-hand side in and the solution back out, so callers that just
374    /// want answers use it exactly like [`SparseLdlt::factor`]. Fill-in drops because the
375    /// elimination order follows the ordering: on a random 2%-dense 1024 matrix the plain
376    /// factor carries ~9x the nonzeros of the AMD-ordered one.
377    ///
378    /// Inertia is untouched by a symmetric permutation (Sylvester's law: `P A Pᵀ` is a
379    /// congruence of `A`), so Sturm counts are identical with or without ordering.
380    ///
381    /// # Errors
382    ///
383    /// [`LdltError::InvalidInput`] if `order` is not a permutation of `0..n`, plus
384    /// everything [`SparseLdlt::factor`] can return.
385    pub fn factor_perm(
386        n: usize,
387        col_ptr: &[usize],
388        row_idx: &[usize],
389        values: &[f64],
390        order: &[usize],
391    ) -> Result<Self, LdltError> {
392        if order.len() != n {
393            return Err(LdltError::InvalidInput("order length must be n"));
394        }
395        let mut pos = vec![usize::MAX; n]; // pos[orig] = permuted index
396        for (new, &old) in order.iter().enumerate() {
397            if old >= n || pos[old] != usize::MAX {
398                return Err(LdltError::InvalidInput(
399                    "order must be a permutation of 0..n",
400                ));
401            }
402            pos[old] = new;
403        }
404        // Permute the CSC: new column k holds old column order[k], rows remapped by pos,
405        // sorted within each column, duplicates summed (the same semantics `factor` gives
406        // duplicate entries via its scatter).
407        let mut entries: Vec<(usize, f64)> = Vec::with_capacity(values.len());
408        let mut pcp = vec![0usize; n + 1];
409        for k in 0..n {
410            let old_k = order[k];
411            for p in col_ptr[old_k]..col_ptr[old_k + 1] {
412                entries.push((pos[row_idx[p]], values[p]));
413            }
414            entries[pcp[k]..].sort_unstable_by_key(|e| e.0);
415            // Sum duplicate rows within the column (they are now adjacent).
416            let mut w = pcp[k];
417            let mut r = pcp[k];
418            while r < entries.len() {
419                let (row, mut val) = entries[r];
420                r += 1;
421                while r < entries.len() && entries[r].0 == row {
422                    val += entries[r].1;
423                    r += 1;
424                }
425                entries[w] = (row, val);
426                w += 1;
427            }
428            entries.truncate(w);
429            pcp[k + 1] = entries.len();
430        }
431        let pri: Vec<usize> = entries.iter().map(|e| e.0).collect();
432        let pv: Vec<f64> = entries.iter().map(|e| e.1).collect();
433        let mut f = Self::factor(n, &pcp, &pri, &pv)?;
434        f.order = order.to_vec();
435        Ok(f)
436    }
437
438    /// Like [`SparseLdlt::factor`], but on a breakdown it retries with a positive diagonal
439    /// shift instead of giving up.
440    ///
441    /// The unshifted factorization is tried first, so a well-conditioned matrix costs nothing
442    /// extra and comes back with [`SparseLdlt::shift`] `== 0.0`. On [`LdltError::ZeroPivot`]
443    /// or [`LdltError::NearZeroPivot`] the matrix is refactored as `A + shift * I`, starting
444    /// from the suggested shift and multiplying by 8 each attempt, at most 8 attempts; if none
445    /// succeeds the last error is returned.
446    ///
447    /// THE RESULT IS AN EXACT FACTORIZATION OF A NEARBY MATRIX, NOT OF `A`. Its pivots are the
448    /// pivots of `A + shift * I`, so its inertia is that matrix's inertia and a Sturm count
449    /// taken from it is a count at a sigma moved by `shift`. A solve against it is a solve of
450    /// the shifted system. Ignoring [`SparseLdlt::shift`] is a bug in the caller.
451    pub fn factor_shifted(
452        n: usize,
453        col_ptr: &[usize],
454        row_idx: &[usize],
455        values: &[f64],
456    ) -> Result<Self, LdltError> {
457        Self::shifted_retry(n, col_ptr, row_idx, values, None)
458    }
459
460    /// [`SparseLdlt::factor_perm`] with the shifted-retry behaviour of
461    /// [`SparseLdlt::factor_shifted`]. The same warning applies: a non-zero
462    /// [`SparseLdlt::shift`] means this factored `A + shift * I`, not `A`.
463    pub fn factor_perm_shifted(
464        n: usize,
465        col_ptr: &[usize],
466        row_idx: &[usize],
467        values: &[f64],
468        order: &[usize],
469    ) -> Result<Self, LdltError> {
470        Self::shifted_retry(n, col_ptr, row_idx, values, Some(order))
471    }
472
473    /// Shared body of the two shifted entry points. `order` selects the permuted path.
474    fn shifted_retry(
475        n: usize,
476        col_ptr: &[usize],
477        row_idx: &[usize],
478        values: &[f64],
479        order: Option<&[usize]>,
480    ) -> Result<Self, LdltError> {
481        let attempt = |cp: &[usize], ri: &[usize], vx: &[f64]| match order {
482            Some(o) => Self::factor_perm(n, cp, ri, vx, o),
483            None => Self::factor(n, cp, ri, vx),
484        };
485        let mut last = match attempt(col_ptr, row_idx, values) {
486            Ok(f) => return Ok(f),
487            Err(e) => e,
488        };
489        // Only a pivot breakdown is worth retrying: malformed input or a size mismatch will
490        // fail identically no matter how the diagonal is nudged.
491        let mut shift = match last {
492            LdltError::NearZeroPivot {
493                suggested_shift, ..
494            } => suggested_shift,
495            LdltError::ZeroPivot(_) => {
496                // ZeroPivot carries no suggestion, so derive the same starting point it would
497                // have carried.
498                NEAR_ZERO_PIVOT_REL.sqrt() * diagonal_scale(n, col_ptr, row_idx, values)
499            }
500            other => return Err(other),
501        };
502        if shift <= 0.0 {
503            // A matrix with no diagonal at all gives no scale to shift by; there is nothing
504            // honest to do but report the original breakdown.
505            return Err(last);
506        }
507        for _ in 0..8 {
508            let (cp, ri, vx) = with_diagonal_shift(n, col_ptr, row_idx, values, shift);
509            match attempt(&cp, &ri, &vx) {
510                Ok(mut f) => {
511                    f.shift = shift;
512                    return Ok(f);
513                }
514                Err(e) => last = e,
515            }
516            shift *= 8.0;
517        }
518        Err(last)
519    }
520
521    /// The diagonal shift actually applied. 0.0 for [`SparseLdlt::factor`] /
522    /// [`SparseLdlt::factor_perm`], which never shift. Non-zero means this is an exact
523    /// factorization of `A + shift * I`, NOT of `A`: its inertia is the inertia of the shifted
524    /// matrix, so a Sturm count taken from it is a count for the caller's sigma moved by this
525    /// much, and the caller must correct for it.
526    pub fn shift(&self) -> f64 {
527        self.shift
528    }
529
530    /// The order of the factored matrix.
531    pub fn dim(&self) -> usize {
532        self.n
533    }
534
535    /// The signed diagonal `D`. The count of negative entries is the matrix inertia
536    /// (number of negative eigenvalues), e.g. for a Sturm eigenvalue count.
537    pub fn d(&self) -> &[f64] {
538        &self.d
539    }
540
541    /// Number of stored off-diagonal nonzeros in `L` (the fill-in).
542    pub fn nnz(&self) -> usize {
543        self.lp[self.n]
544    }
545
546    /// Floating-point operation count of the factorization: for each column of `L` with
547    /// `c` stored entries, `c*c + 3*c` (the column-update arithmetic). Deterministic, so
548    /// two factorizations of the same sparsity pattern report identical counts - callers
549    /// (e.g. the supernodal equivalence gates in FEM Studio) assert on exactly that.
550    pub fn flops(&self) -> u64 {
551        let mut f = 0u64;
552        for j in 0..self.n {
553            let c = (self.lp[j + 1] - self.lp[j]) as u64;
554            f += c * c + 3 * c;
555        }
556        f
557    }
558
559    /// Solve `A x = b` for a single right-hand side, returning `x`.
560    ///
561    /// Works for both [`SparseLdlt::factor`] and [`SparseLdlt::factor_perm`] - the stored
562    /// elimination order is applied to the right-hand side and inverted on the solution,
563    /// so the caller never sees the permutation.
564    ///
565    /// # Errors
566    ///
567    /// Returns [`LdltError::SizeMismatch`] if `b.len() != self.dim()`.
568    pub fn solve(&self, b: &[f64]) -> Result<Vec<f64>, LdltError> {
569        if b.len() != self.n {
570            return Err(LdltError::SizeMismatch { expected: self.n, got: b.len() });
571        }
572        let identity = self.order.len() == self.n && self.order.iter().enumerate().all(|(k, &o)| o == k);
573        let mut x = if identity {
574            b.to_vec()
575        } else {
576            self.order.iter().map(|&o| b[o]).collect()
577        };
578        // L y = b  (forward, unit lower)
579        for j in 0..self.n {
580            let xj = x[j];
581            for p in self.lp[j]..self.lp[j + 1] {
582                x[self.li[p]] -= self.lx[p] * xj;
583            }
584        }
585        // D z = y
586        for j in 0..self.n {
587            x[j] /= self.d[j];
588        }
589        // Lᵀ x = z  (backward)
590        for j in (0..self.n).rev() {
591            let mut acc = x[j];
592            for p in self.lp[j]..self.lp[j + 1] {
593                acc -= self.lx[p] * x[self.li[p]];
594            }
595            x[j] = acc;
596        }
597        if identity {
598            Ok(x)
599        } else {
600            // Un-permute: x_orig[order[k]] = x_perm[k].
601            let mut out = vec![0.0f64; self.n];
602            for (k, &o) in self.order.iter().enumerate() {
603                out[o] = x[k];
604            }
605            Ok(out)
606        }
607    }
608}
609/// Approximate minimum degree ordering (Amestoy, Davis & Duff 1996) - the fill-reducing
610/// elimination order for a symmetric sparse matrix.
611///
612/// Returns `order` where `order[k]` is the original node eliminated k-th, ready for
613/// [`SparseLdlt::factor_perm`]. Graph-symmetric input: only the upper triangle
614/// (row <= col) is read, exactly like [`SparseLdlt::factor`].
615///
616/// THE ALGORITHM: quotient-graph AMD, faithfully. Eliminated nodes become *elements*
617/// (their neighbour list, attached to surviving neighbours in O(1) - the structure that
618/// keeps the total update work proportional to the factor's nonzero count instead of the
619/// filled graph's). Degrees are AMD's *external degrees*: the count of distinct live
620/// variables reachable through a node's own adjacency plus its attached elements,
621/// recomputed only for the neighbours of each elimination (the only nodes whose degree
622/// changes). Aggressive absorption (AMD's later refinement) is not implemented; on
623/// FE-sized problems the fill difference is small and the code stays auditable.
624///
625/// Inertia is INVARIANT under the resulting symmetric permutation (Sylvester's law), so
626/// ordering changes cost, never eigenvalue counts.
627pub fn amd(n: usize, col_ptr: &[usize], row_idx: &[usize]) -> Vec<usize> {
628    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
629    for k in 0..n {
630        for p in col_ptr[k]..col_ptr[k + 1] {
631            let i = row_idx[p];
632            if i < n && i != k {
633                adj[k].push(i);
634                adj[i].push(k);
635            }
636        }
637    }
638    for a in adj.iter_mut() {
639        a.sort_unstable();
640        a.dedup();
641    }
642    // ELEMENT ABSORPTION, which the first version did not do and which is the whole cost model.
643    // When node i is eliminated its new element E_i is the union of its live neighbours and the
644    // live variables of every element already attached to it; those older elements are then
645    // ABSORBED - every variable's element list drops them and keeps E_i. Without absorption each
646    // variable accumulated every element it had ever touched and every degree update rescanned
647    // all of them, dead variables included: on a 5.9k-node shell mesh the ordering took 1.7 s
648    // against a 0.2 s factorization (measured 2026-09-03). With it, each variable holds a handful
649    // of live elements and the update work stays proportional to the factor's size.
650    let mut elem_vars: Vec<Vec<usize>> = Vec::new();
651    let mut elem_alive: Vec<bool> = Vec::new();
652    let mut elems_of: Vec<Vec<usize>> = vec![Vec::new(); n];
653    let mut alive = vec![true; n];
654    let mut deg: Vec<usize> = adj.iter().map(Vec::len).collect();
655    let mut flag = vec![usize::MAX; n]; // distinct-variable scratch, stamped per use
656    let mut next_stamp = 0usize; // monotonic: every distinct-variable scan gets a fresh stamp
657    let mut order = Vec::with_capacity(n);
658    // MINIMUM DEGREE BY HEAP with lazy invalidation: an entry is stale when the node is gone or
659    // its degree has since changed; stale entries are popped and skipped. A full scan per step
660    // is O(n^2), which is invisible at a few thousand nodes and a second at tens of thousands.
661    let mut heap: std::collections::BinaryHeap<std::cmp::Reverse<(usize, usize)>> =
662        (0..n).map(|u| std::cmp::Reverse((deg[u], u))).collect();
663
664    for _step in 0..n {
665        let i = loop {
666            match heap.pop() {
667                Some(std::cmp::Reverse((d, u))) => {
668                    if alive[u] && deg[u] == d {
669                        break u;
670                    }
671                }
672                None => break usize::MAX,
673            }
674        };
675        if i == usize::MAX {
676            break;
677        }
678        alive[i] = false;
679        order.push(i);
680
681        next_stamp += 1;
682        let stamp = next_stamp;
683        let mut nb: Vec<usize> = Vec::with_capacity(deg[i] + 1);
684        for &a in &adj[i] {
685            if a < n && alive[a] && flag[a] != stamp {
686                flag[a] = stamp;
687                nb.push(a);
688            }
689        }
690        for &e in &elems_of[i] {
691            if !elem_alive[e] {
692                continue;
693            }
694            for &x in &elem_vars[e] {
695                if x < n && alive[x] && flag[x] != stamp {
696                    flag[x] = stamp;
697                    nb.push(x);
698                }
699            }
700        }
701        // The elements i belonged to are absorbed into E_i: dead from here on.
702        for &e in &elems_of[i] {
703            elem_alive[e] = false;
704        }
705        if nb.is_empty() {
706            continue;
707        }
708        let elem_id = elem_vars.len();
709        elem_vars.push(nb.clone());
710        elem_alive.push(true);
711        for &j in &nb {
712            // Drop the absorbed elements from j's list and attach E_i.
713            elems_of[j].retain(|&e| elem_alive[e]);
714            elems_of[j].push(elem_id);
715        }
716        for &j in &nb {
717            next_stamp += 1;
718            let estamp = next_stamp;
719            let mut count = 0usize;
720            flag[j] = estamp;
721            let scan = |xs: &[usize], flag: &mut Vec<usize>, count: &mut usize| {
722                for &x in xs {
723                    if x < n && alive[x] && flag[x] != estamp {
724                        flag[x] = estamp;
725                        *count += 1;
726                    }
727                }
728            };
729            scan(&adj[j], &mut flag, &mut count);
730            for &e in &elems_of[j] {
731                scan(&elem_vars[e], &mut flag, &mut count);
732            }
733            deg[j] = count;
734            heap.push(std::cmp::Reverse((count, j)));
735        }
736    }
737    // A node that never entered `nb` of anything and had a stale heap entry could be missed only
738    // if the heap emptied early; append any survivor so the permutation is complete.
739    if order.len() < n {
740        for u in 0..n {
741            if alive[u] {
742                order.push(u);
743            }
744        }
745    }
746    order
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    // Deterministic LCG in [-1, 1).
754    struct Rng(u64);
755    impl Rng {
756        fn next_f64(&mut self) -> f64 {
757            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
758            ((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
759        }
760    }
761
762    /// Build a random symmetric matrix in CSC (full storage). `diag_shift` added to
763    /// every diagonal: large positive => SPD, small => indefinite. Returns (col_ptr,
764    /// row_idx, values) and a dense copy for reference.
765    #[allow(clippy::type_complexity)]
766    fn random_symmetric(
767        n: usize,
768        density: f64,
769        diag_shift: f64,
770        seed: u64,
771    ) -> (Vec<usize>, Vec<usize>, Vec<f64>, Vec<Vec<f64>>) {
772        let mut rng = Rng(seed);
773        let mut dense = vec![vec![0.0f64; n]; n];
774        for i in 0..n {
775            for j in (i + 1)..n {
776                if (rng.next_f64() + 1.0) / 2.0 < density {
777                    let v = rng.next_f64();
778                    dense[i][j] = v;
779                    dense[j][i] = v;
780                }
781            }
782            dense[i][i] = rng.next_f64() + diag_shift;
783        }
784        // to CSC (columns)
785        let mut col_ptr = vec![0usize];
786        let mut row_idx = Vec::new();
787        let mut values = Vec::new();
788        for j in 0..n {
789            for i in 0..n {
790                if dense[i][j] != 0.0 {
791                    row_idx.push(i);
792                    values.push(dense[i][j]);
793                }
794            }
795            col_ptr.push(row_idx.len());
796        }
797        (col_ptr, row_idx, values, dense)
798    }
799
800    fn residual_inf(dense: &[Vec<f64>], x: &[f64], b: &[f64]) -> f64 {
801        let n = b.len();
802        (0..n)
803            .map(|i| {
804                let ax: f64 = (0..n).map(|j| dense[i][j] * x[j]).sum();
805                (ax - b[i]).abs()
806            })
807            .fold(0.0, f64::max)
808    }
809
810    // Number of negative eigenvalues of a small dense symmetric matrix via the cyclic
811    // Jacobi eigenvalue algorithm - the reference inertia (Sylvester's law).
812    fn negative_eigs(mat: &[Vec<f64>]) -> usize {
813        let n = mat.len();
814        let mut a = mat.to_vec();
815        for _sweep in 0..100 {
816            let mut off = 0.0;
817            for p in 0..n {
818                for q in (p + 1)..n {
819                    off += a[p][q] * a[p][q];
820                }
821            }
822            if off < 1e-20 {
823                break;
824            }
825            for p in 0..n {
826                for q in (p + 1)..n {
827                    if a[p][q].abs() < 1e-18 {
828                        continue;
829                    }
830                    let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
831                    let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
832                    let c = 1.0 / (t * t + 1.0).sqrt();
833                    let s = t * c;
834                    for k in 0..n {
835                        let akp = a[k][p];
836                        let akq = a[k][q];
837                        a[k][p] = c * akp - s * akq;
838                        a[k][q] = s * akp + c * akq;
839                    }
840                    for k in 0..n {
841                        let apk = a[p][k];
842                        let aqk = a[q][k];
843                        a[p][k] = c * apk - s * aqk;
844                        a[q][k] = s * apk + c * aqk;
845                    }
846                }
847            }
848        }
849        (0..n).filter(|&i| a[i][i] < -1e-9).count()
850    }
851
852    #[test]
853    fn spd_solves_accurately_with_no_negative_pivots() {
854        for seed in 0..25u64 {
855            let n = 6 + (seed as usize % 18);
856            let (cp, ri, v, dense) = random_symmetric(n, 0.4, n as f64 + 2.0, seed * 7 + 1);
857            let mut rng = Rng(seed * 13 + 3);
858            let b: Vec<f64> = (0..n).map(|_| rng.next_f64()).collect();
859            let f = SparseLdlt::factor(n, &cp, &ri, &v).expect("SPD factor");
860            let x = f.solve(&b).unwrap();
861            assert!(residual_inf(&dense, &x, &b) < 1e-9, "seed {seed}: residual too large");
862            assert_eq!(f.d().iter().filter(|&&d| d < 0.0).count(), 0);
863        }
864    }
865
866    #[test]
867    fn indefinite_solves_and_inertia_is_correct() {
868        let mut indefinite = 0;
869        for seed in 0..60u64 {
870            let n = 4 + (seed as usize % 10);
871            let (cp, ri, v, dense) = random_symmetric(n, 0.35, 0.5, seed * 5 + 9);
872            let mut rng = Rng(seed * 17 + 2);
873            let b: Vec<f64> = (0..n).map(|_| rng.next_f64()).collect();
874            let f = match SparseLdlt::factor(n, &cp, &ri, &v) {
875                Ok(f) => f,
876                Err(_) => continue, // zero pivot; un-pivoted LDLT breaks down, skip
877            };
878            let x = f.solve(&b).unwrap();
879            assert!(residual_inf(&dense, &x, &b) < 1e-7, "seed {seed}: residual too large");
880            let neg = f.d().iter().filter(|&&d| d < 0.0).count();
881            assert_eq!(neg, negative_eigs(&dense), "seed {seed}: inertia mismatch");
882            if neg > 0 {
883                indefinite += 1;
884            }
885        }
886        assert!(indefinite >= 5, "expected several indefinite cases, got {indefinite}");
887    }
888
889    #[test]
890    fn rejects_malformed_input() {
891        assert!(matches!(SparseLdlt::factor(2, &[0, 1], &[0], &[1.0]), Err(LdltError::InvalidInput(_))));
892    }
893
894    #[test]
895    fn rejects_non_finite_values() {
896        // NaN compares false against every pivot check, so a non-finite entry would silently
897        // poison every factor value - it must be rejected at the door.
898        let cp: &[usize] = &[0, 1, 2];
899        let ri: &[usize] = &[0, 1];
900        assert!(matches!(
901            SparseLdlt::factor(2, cp, ri, &[f64::NAN, 1.0]),
902            Err(LdltError::InvalidInput(_))
903        ));
904        assert!(matches!(
905            SparseLdlt::factor(2, cp, ri, &[1.0, f64::INFINITY]),
906            Err(LdltError::InvalidInput(_))
907        ));
908    }
909
910    #[test]
911    fn solve_rejects_wrong_rhs_length() {
912        let f = SparseLdlt::factor(3, &[0, 2, 5, 7], &[0, 1, 0, 1, 2, 1, 2],
913            &[2.0, 1.0, 1.0, -3.0, 1.0, 1.0, 2.0]).unwrap();
914        assert_eq!(
915            f.solve(&[1.0, 2.0]),
916            Err(LdltError::SizeMismatch { expected: 3, got: 2 })
917        );
918    }
919
920    /// A tiny leading pivot is a DESTROYED pivot, not a small one: it used to be returned
921    /// silently, carrying a sign that is rounding noise into the caller's inertia.
922    #[test]
923    fn near_zero_pivot_is_reported_not_returned() {
924        // [[1e-18, 1], [1, 1]]: scale 1, so the first pivot is 1e-18 relative - far below the
925        // threshold. The old code factored this and handed back a sign nobody could check.
926        let cp: &[usize] = &[0, 2, 4];
927        let ri: &[usize] = &[0, 1, 0, 1];
928        let v: &[f64] = &[1e-18, 1.0, 1.0, 1.0];
929        match SparseLdlt::factor(2, cp, ri, v) {
930            Err(LdltError::NearZeroPivot {
931                column,
932                pivot,
933                scale,
934                suggested_shift,
935            }) => {
936                assert_eq!(column, 0);
937                assert_eq!(pivot, 1e-18);
938                assert_eq!(scale, 1.0);
939                assert!(suggested_shift > NEAR_ZERO_PIVOT_REL * scale);
940            }
941            other => panic!("expected NearZeroPivot, got {other:?}"),
942        }
943        // An EXACT zero is still the plain ZeroPivot it always was.
944        assert!(matches!(
945            SparseLdlt::factor(1, &[0, 1], &[0], &[0.0]),
946            Err(LdltError::ZeroPivot(0))
947        ));
948        // factor_perm delegates to the same numeric loop, so it reports it too. (A different
949        // elimination order can legitimately dodge this particular breakdown, so the identity
950        // order is what proves the shared path is covered.)
951        match SparseLdlt::factor_perm(2, cp, ri, v, &[0, 1]) {
952            Err(LdltError::NearZeroPivot { column, .. }) => assert_eq!(column, 0),
953            other => panic!("expected NearZeroPivot from factor_perm, got {other:?}"),
954        }
955    }
956
957    /// The shifted entry points recover, and they say by how much - a caller reading the
958    /// inertia without reading `shift()` would be reading it for the wrong matrix.
959    #[test]
960    fn factor_shifted_recovers_and_reports_the_shift() {
961        let cp: &[usize] = &[0, 2, 4];
962        let ri: &[usize] = &[0, 1, 0, 1];
963        let v: &[f64] = &[1e-18, 1.0, 1.0, 1.0];
964        let f = SparseLdlt::factor_shifted(2, cp, ri, v).expect("shifted factor");
965        let sh = f.shift();
966        assert!(sh > 0.0, "shift was {sh}");
967        // It factored A + sh*I, so THAT is the system it solves.
968        let b = [1.0, 2.0];
969        let x = f.solve(&b).unwrap();
970        let a = [[1e-18 + sh, 1.0], [1.0, 1.0 + sh]];
971        for i in 0..2 {
972            let ax = a[i][0] * x[0] + a[i][1] * x[1];
973            assert!((ax - b[i]).abs() < 1e-9, "row {i}: {ax} vs {}", b[i]);
974        }
975        let g = SparseLdlt::factor_perm_shifted(2, cp, ri, v, &[0, 1]).expect("shifted perm");
976        assert!(g.shift() > 0.0);
977        // A healthy matrix is never shifted: the unshifted attempt comes first.
978        let h = SparseLdlt::factor_shifted(2, cp, ri, &[3.0, 1.0, 1.0, 2.0]).unwrap();
979        assert_eq!(h.shift(), 0.0);
980        let plain = SparseLdlt::factor(2, cp, ri, &[3.0, 1.0, 1.0, 2.0]).unwrap();
981        assert_eq!(plain.shift(), 0.0);
982    }
983
984    /// KNOWN-ANSWER GOLDEN, hand-computed. For A = [[2,1,0],[1,-3,1],[0,1,2]]:
985    ///   col 0: d0 = 2, l10 = 1/2
986    ///   col 1: y = (1, -3); d1 = -3 - (1/2)(1) = -7/2, l21 = 1/(-7/2) = -2/7
987    ///   col 2: y = (0, 1, 2); the etree path of row 1 is {1} only (A[0][2] = 0, so node 0
988    ///          is a structural zero in L), so d2 = 2 - (-2/7)(1) = 16/7
989    /// Pins the fill pattern (nnz(L) = 2: the (2,0) slot is NOT filled), the signed pivots,
990    /// and the solve: L y = b, D z = y, L^T x = z gives x = (1/2, 0, 3/2).
991    #[test]
992    fn golden_known_answer() {
993        let f = SparseLdlt::factor(3, &[0, 2, 5, 7], &[0, 1, 0, 1, 2, 1, 2],
994            &[2.0, 1.0, 1.0, -3.0, 1.0, 1.0, 2.0]).unwrap();
995        assert_eq!(f.nnz(), 2);
996        assert_eq!(f.dim(), 3);
997        let d = f.d();
998        assert_eq!(d[0], 2.0);
999        assert_eq!(d[1], -3.5);
1000        assert!((d[2] - 16.0 / 7.0).abs() < 1e-15, "d2 = {} (want 16/7)", d[2]);
1001        // The factors themselves are private; the solve exercises every stored value.
1002        let x = f.solve(&[1.0, 2.0, 3.0]).unwrap();
1003        let want = [0.5, 0.0, 1.5];
1004        for i in 0..3 {
1005            assert!((x[i] - want[i]).abs() < 1e-14, "x[{i}] = {} (want {})", x[i], want[i]);
1006        }
1007    }
1008}