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}