Skip to main content

xingo_sparse/
faer_ldl.rs

1//! # Chapter 5.15 — `FaerLdl`: the v0.1 KKT backend
2//!
3//! *New with task L1.5 (PLAN.md trilha L; the contract's §5.7, decision
4//! D10 "hybrid"). Compiled with the `faer` feature (on by default).*
5//!
6//! ## Purpose
7//! [`FaerLdl`] implements [`KktSolver`] with the sparse symmetric
8//! factorizations of the `faer` crate (pure Rust, MIT): `LDLᵀ`
9//! (simplicial or supernodal, faer's choice by flops per `nnz(L)` as in
10//! CHOLMOD, or forced by [`FaerLdl::supernodal`]) and the intranodal
11//! Bunch–Kaufman `LBLᵀ`. What is ours: the fill-reducing ordering (any
12//! [`Ordering`], by default our [`crate::Amd`] of Chapter 5.10 — faer's own AMD
13//! is never called), the regularization policy and the inertia check
14//! (Algorithm K of Chapter 5.14), and the refinement (Algorithm F). The
15//! simplex method never touches this chapter (D10).
16//!
17//! ## Notation
18//! As in Chapter 5.14: `K` of order `N` by its lower triangle, `L₀` the
19//! analyzed lower pattern with a full diagonal, `π` the ordering
20//! ([`crate::Perm`], `fwd[k]` = the row eliminated `k`-th), `σ_k` the pivot
21//! signs, `(ρ, δ, τ, β)` the regularization.
22//!
23//! ## Algorithm A (Analyze)
24//! A1. `L₀ ←` the lower triangle of the pattern plus a full diagonal
25//!     (invariant (K1) of Chapter 5.14; [`KktError::NotSymmetric`] or
26//!     [`KktError::Structure`] otherwise).
27//! A2. `π ←` `ordering.order(L₀ + L₀ᵀ, Symmetric)`.
28//! A2′. `π ←` the postorder of the elimination tree of `π`, heaviest
29//!     child last (Algorithm P of Chapter 5.9): the same `nnz(L)`, but
30//!     subtrees contiguous and the largest front next to its parent —
31//!     the one child faer's relaxed supernodal amalgamation can merge.
32//!     Whatever the ordering's own output convention (the assembly tree of
33//!     our [`crate::Amd`], the pivot order of [`crate::Colamd`], a caller's
34//!     permutation), the supernodes faer builds are then as good as the
35//!     elimination tree allows (task L1.5b: maros-r7's augmented system
36//!     factorized 11× slower without this step, with the same
37//!     `nnz(L)`).
38//! A3. faer's symbolic Cholesky of `L₀` under the custom permutation `π`:
39//!     elimination tree, column counts, and the simplicial or supernodal
40//!     structure of `L` (threshold from [`FaerLdl::supernodal`]).
41//! A4. The pattern of `P L₀ Pᵀ` in the triangle faer's numeric phase reads
42//!     (upper for the simplicial structure, lower for the supernodal one),
43//!     with the position of every entry of `L₀` in it: each factorization
44//!     then permutes its values by a scatter, and calls faer's
45//!     *structure-level* numeric phase, which reports how many pivots it
46//!     bumped (the top-level one does not; task L1.5b).
47//!
48//! ## Algorithm N (Numeric factorization)
49//! N1. `v ←` the values of `K` on `L₀` (steps K1′–K2 of Chapter 5.14),
50//!     equilibrated to `K̃ = S K S` (step K2′), plus the static shifts
51//!     (step K2″): `v` holds `K̃_reg`.
52//! N2. Choose the kernel:
53//!     * **`LDLᵀ`** (diagonal `Δ`, no pivoting) for the normal equations,
54//!       for an augmented system that is quasi-definite (`ρ, δ > 0`), and
55//!       whenever dynamic regularization is on (`τ = Some`): faer's
56//!       left-looking simplicial or supernodal `LDLᵀ` with the signs `σ_k`
57//!       (permuted by `π` inside faer), threshold `τ` and bump `β`
58//!       (step K3). A zero or non-finite pivot without bumping is
59//!       [`KktError::ZeroPivot`] at the original row `fwd[k]`.
60//!     * **`LBLᵀ`** (Bunch & Kaufman 1977, `1×1` and `2×2` pivots) for an
61//!       augmented system without regularization (`ρ = 0` or `δ = 0`, and
62//!       `τ = None`) when the structure is supernodal: faer's *intranodal*
63//!       Bunch–Kaufman, whose interchanges stay inside each supernode (the
64//!       analyzed structure does not change). With a simplicial structure
65//!       faer has no pivoted kernel, and the `LDLᵀ` path runs instead.
66//! N3. Read the pivots — the diagonal of `Δ` (first entry of each column
67//!     of the simplicial `L`; diagonal of each supernode's dense block),
68//!     plus the subdiagonal of `B` for the `2×2` blocks of `LBLᵀ` — and
69//!     count the inertia (step K4). The bumped pivots of step K3: faer's
70//!     count, exact for the simplicial structure; its supernodal kernel
71//!     counts only the positive-sign pivots it bumps, so a negative-sign
72//!     pivot equal to `−β` exactly is added — the one count by value left
73//!     (it can only overcount, by a pivot that happens to equal `−β` bit
74//!     for bit).
75//! N3′. (S10): if a stored value of the factor (or of the subdiagonal of
76//!     `B`) is not finite, fail with [`KktError::NotFinite`] at the
77//!     original row `fwd[k]` of the first such column `k`.
78//! N4. (S6): fail with [`KktError::WrongInertia`] unless the inertia is the
79//!     one the form requires.
80//!
81//! ## Algorithm S (Solve)
82//! `x ← S x`, faer's permuted triangular solves `x ← Pᵀ L⁻ᵀ Δ⁻¹ L⁻¹ P x`
83//! on `K̃`, then `x ← S x` (the factor is of `K̃ = S K_reg S`; and the
84//! intranodal permutation and `2×2` blocks for `LBLᵀ`), sequentially
85//! (`Par::Seq`; faer is built without its `rayon` feature, so results do
86//! not depend on threads). The solve allocates nothing: faer's stack
87//! memory for it is sized and allocated once, by the factorization, and
88//! kept in the [`FaerNumeric`] behind a mutex (a second thread solving
89//! with the same factor at the same moment gets a temporary buffer
90//! instead of waiting) — with [`KktSolver::solve_refined_with`] and a
91//! reused [`crate::RefineWorkspace`], a refined solve allocates nothing
92//! either. Measured in L1.5b with a counting allocator on the 186 Netlib
93//! systems of the middle regime: 0 allocations in a second solve and in
94//! a second refined solve with the same workspace (the first solve with a
95//! factor may make one, the lazy initialization of the mutex on macOS).
96//!
97//! ## Invariants
98//! (F1) The numeric factor shares the symbolic structure it was computed
99//!      with (an `Arc`): a [`FaerNumeric`] can be solved with after its
100//!      [`FaerSymbolic`] is dropped, and never with another one.
101//! (F2) `nnz_factor` is faer's stored length of `L` (diagonal included;
102//!      for the supernodal structure, the dense supernodal blocks with
103//!      their explicit zeros).
104//! (S6) as in Chapter 5.14, checked in step N4 and asserted in debug
105//!      builds.
106//!
107//! ## What was read
108//! faer 0.24.4, `sparse::linalg::cholesky` (the public API and its
109//! documentation: `factorize_symbolic_cholesky`, `SymmetricOrdering::Custom`,
110//! `factorize_numeric_ldlt` with `LdltRegularization`, the intranodal
111//! `LBLᵀ`, the supernodal storage read by the solves) and
112//! `linalg::cholesky::ldlt::factor` (the dynamic regularization rule, to
113//! match step K3) — read, not copied; CHOLMOD (Chen, Davis, Hager &
114//! Rajamanickam 2008) for the shape.
115//!
116//! ## References
117//! See Chapter 5.14; the faer documentation (docs.rs/faer/0.24.4,
118//! `sparse::linalg::cholesky`); Bunch & Kaufman (1977); Davis, *Direct
119//! Methods for Sparse Linear Systems* (2006), ch. 4 and 9.
120
121use std::sync::{Arc, Mutex};
122
123use faer::dyn_stack::{MemBuffer, MemStack};
124use faer::linalg::cholesky::ldlt::factor::{LdltError, LdltRegularization};
125use faer::perm::PermRef;
126use faer::sparse::linalg::SupernodalThreshold;
127use faer::sparse::linalg::cholesky::{
128    CholeskySymbolicParams, IntranodeLbltRef, LdltRef, SymbolicCholesky, SymbolicCholeskyRaw,
129    SymmetricOrdering, factorize_symbolic_cholesky, simplicial, supernodal,
130};
131use faer::sparse::{SparseColMatRef, SymbolicSparseColMatRef};
132use faer::{Conj, MatMut, Par, Side, Spec};
133
134use crate::kkt::{
135    assemble_regularized, check_inertia, check_regularization, count_inertia, lower_with_diagonal,
136    pivot_signs,
137};
138use crate::real::{Real, approx_eq, is_exact_zero};
139use crate::{
140    CscMatrix, Inertia, KktError, KktForm, KktSolver, Ordering, OrderingTarget, Pattern, Perm,
141    Regularization, SparseError,
142};
143
144/// The v0.1 backend (D10, hybrid): faer's sparse `LDLᵀ` (normal
145/// equations, quasi-definite augmented systems) or intranodal `LBLᵀ` with
146/// Bunch–Kaufman pivots (unregularized augmented systems), with faer's
147/// simplicial/supernodal selection, ordered by our ordering through
148/// [`KktSolver::analyze`]. Regularization policy and refinement are the
149/// trait's (Chapter 5.14).
150///
151/// Normal equations `A D Aᵀ + δI`, analyzed once and factorized for two
152/// diagonals `D` (the pattern does not change between interior-point
153/// iterations):
154/// ```
155/// use xingo_sparse::{Amd, Col, CscMatrix, FaerLdl, KktForm, KktSolver, Refinement, Regularization, Row};
156/// // A = [1 1 0; 0 1 1]; lower triangle of A D Aᵀ.
157/// let normal = |d: [f64; 3]| {
158///     CscMatrix::from_triplets(2, 2, &[
159///         (Row(0), Col(0), d[0] + d[1]), (Row(1), Col(0), d[1]), (Row(1), Col(1), d[1] + d[2]),
160///     ])
161///     .unwrap()
162/// };
163/// let solver = FaerLdl::default();
164/// let form = KktForm::NormalEquations;
165/// let sym = solver.analyze(&normal([1.0; 3]).pattern(), form, &Amd::default()).unwrap();
166/// for d in [[1.0, 2.0, 3.0], [1e-6, 1e4, 1e-3]] {
167///     let k = normal(d);
168///     // δ is taken in the equilibrated matrix (the default).
169///     let (num, inertia) = solver.factorize(&sym, &k, Regularization::default()).unwrap();
170///     assert_eq!(inertia.positive, 2);
171///     let (b, mut x) = ([1.0, -1.0], [0.0; 2]);
172///     // Refining against k itself removes the bias of the shift δ, down to
173///     // the rounding floor ε‖K‖‖x‖ of the computed residual (≈ 10⁻⁸ for
174///     // the second D, whose K has condition ≈ 10⁷).
175///     let report = solver.solve_refined(&num, &k, &b, &mut x, Refinement::default());
176///     assert!(report.residual <= 1e-8);
177/// }
178/// ```
179#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
180pub struct FaerLdl {
181    /// Force the supernodal path (`Some(true)`), the simplicial one
182    /// (`Some(false)`), or let faer choose by flops per `nnz(L)`
183    /// (`None`).
184    pub supernodal: Option<bool>,
185}
186
187/// The symbolic phase of [`FaerLdl`] (opaque): the analyzed lower
188/// pattern, the ordering and faer's symbolic factor.
189#[derive(Debug)]
190pub struct FaerSymbolic {
191    form: KktForm,
192    lower: Pattern,
193    row_idx: Vec<usize>,
194    perm: Perm,
195    faer: Arc<SymbolicCholesky<usize>>,
196    /// `P L₀ Pᵀ` in the triangle faer's numeric phase reads (upper for the
197    /// simplicial structure, lower for the supernodal one), with the
198    /// position of every entry of `L₀` in it (step A4).
199    permuted: Permuted,
200}
201
202/// Step A4: the permuted pattern and the scatter map.
203#[derive(Debug)]
204struct Permuted {
205    col_ptr: Vec<usize>,
206    row_idx: Vec<usize>,
207    /// `map[p]` = the position in `row_idx` of entry `p` of `L₀`.
208    map: Vec<usize>,
209}
210
211/// Step A4 on `L₀` (lower, full diagonal) under `perm`: the upper
212/// (`upper = true`) or lower triangle of `P L₀ Pᵀ`, columns sorted.
213fn permute_lower(lower: &Pattern, perm: &Perm, upper: bool) -> Permuted {
214    let n = lower.n_cols();
215    let nnz = lower.nnz();
216    let mut entries: Vec<(usize, usize, usize)> = Vec::with_capacity(nnz);
217    for j in 0..n {
218        for (q, &i) in lower.col(j).iter().enumerate() {
219            let (a, b) = (perm.inv[i as usize] as usize, perm.inv[j] as usize);
220            let (hi, lo) = (a.max(b), a.min(b));
221            let (col, row) = if upper { (hi, lo) } else { (lo, hi) };
222            entries.push((col, row, lower.col_ptr[j] + q));
223        }
224    }
225    entries.sort_unstable();
226    let mut col_ptr = vec![0_usize; n + 1];
227    let mut row_idx = Vec::with_capacity(nnz);
228    let mut map = vec![0_usize; nnz];
229    for (pos, &(col, row, p)) in entries.iter().enumerate() {
230        col_ptr[col + 1] += 1;
231        row_idx.push(row);
232        map[p] = pos;
233    }
234    for c in 0..n {
235        col_ptr[c + 1] += col_ptr[c];
236    }
237    Permuted {
238        col_ptr,
239        row_idx,
240        map,
241    }
242}
243
244impl FaerSymbolic {
245    /// The fill-reducing permutation used (`fwd[k]` = row eliminated
246    /// `k`-th): the ordering's, postordered (step A2′).
247    pub fn perm(&self) -> &Perm {
248        &self.perm
249    }
250
251    /// `true` if faer chose (or was told to use) the supernodal structure.
252    pub fn is_supernodal(&self) -> bool {
253        matches!(self.faer.raw(), SymbolicCholeskyRaw::Supernodal(_))
254    }
255
256    /// Stored length of the factor `L` (invariant (F2)).
257    pub fn nnz_factor(&self) -> usize {
258        self.faer.len_val()
259    }
260
261    /// The form the pattern was analyzed for.
262    pub fn form(&self) -> KktForm {
263        self.form
264    }
265}
266
267/// The numeric factor of [`FaerLdl`] (opaque; invariant (F1)).
268#[derive(Debug)]
269pub struct FaerNumeric<T: Real> {
270    faer: Arc<SymbolicCholesky<usize>>,
271    values: Vec<T>,
272    /// `Some` for the Bunch–Kaufman path: the subdiagonal of `B` and the
273    /// intranodal permutation.
274    lblt: Option<Lblt<T>>,
275    inertia: Inertia,
276    bumped: usize,
277    /// `s` of step K2′ (powers of two).
278    scale: Vec<T>,
279    /// faer's stack memory for the solves (Algorithm S).
280    scratch: SolveScratch,
281}
282
283/// faer's stack memory for [`KktSolver::solve`], allocated by the
284/// factorization; a mutex so that a shared factor stays `Sync`.
285struct SolveScratch(Mutex<MemBuffer>);
286
287impl std::fmt::Debug for SolveScratch {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        f.write_str("SolveScratch")
290    }
291}
292
293#[derive(Debug)]
294struct Lblt<T> {
295    subdiag: Vec<T>,
296    fwd: Vec<usize>,
297    inv: Vec<usize>,
298}
299
300impl<T: Real> FaerNumeric<T> {
301    /// The inertia of the factor (S6).
302    pub fn inertia(&self) -> Inertia {
303        self.inertia
304    }
305
306    /// Pivots that dynamic regularization bumped (step K3).
307    pub fn bumped(&self) -> usize {
308        self.bumped
309    }
310
311    /// `true` if the factor is a Bunch–Kaufman `LBLᵀ` (`2×2` pivots
312    /// allowed), `false` for a diagonal `LDLᵀ`.
313    pub fn is_pivoted(&self) -> bool {
314        self.lblt.is_some()
315    }
316}
317
318fn faer_error(e: faer::sparse::FaerError) -> KktError {
319    match e {
320        faer::sparse::FaerError::OutOfMemory => KktError::Backend("faer: out of memory"),
321        faer::sparse::FaerError::IndexOverflow => KktError::Backend("faer: index overflow"),
322        _ => KktError::Backend("faer: symbolic factorization failed"),
323    }
324}
325
326/// The diagonal pivots of a faer `LDLᵀ`/`LBLᵀ` value array, in pivot order.
327fn pivots(sym: &SymbolicCholesky<usize>, values: &[f64]) -> Vec<f64> {
328    match sym.raw() {
329        SymbolicCholeskyRaw::Simplicial(s) => {
330            let l = s.factor();
331            let col_ptr = l.col_ptr();
332            let rows = l.row_idx();
333            (0..s.nrows())
334                .map(|k| {
335                    debug_assert_eq!(rows[col_ptr[k]], k, "the diagonal leads each column");
336                    values[col_ptr[k]]
337                })
338                .collect()
339        }
340        SymbolicCholeskyRaw::Supernodal(s) => {
341            let mut d = Vec::with_capacity(s.nrows());
342            for t in 0..s.n_supernodes() {
343                let begin = s.supernode_begin()[t];
344                let ncols = s.supernode_end()[t] - begin;
345                let nrows = s.nnz_per_super()[t] + ncols;
346                let block = &values[s.col_ptr_for_val()[t]..s.col_ptr_for_val()[t + 1]];
347                d.extend((0..ncols).map(|c| block[c * nrows + c]));
348            }
349            d
350        }
351    }
352}
353
354/// Step N3′: the first column (pivot order) of the factor holding a NaN
355/// or an infinity, if any.
356fn first_non_finite(sym: &SymbolicCholesky<usize>, values: &[f64]) -> Option<usize> {
357    let p = values.iter().position(|x| !x.is_finite())?;
358    Some(match sym.raw() {
359        SymbolicCholeskyRaw::Simplicial(s) => {
360            // The column whose range holds p.
361            s.col_ptr().partition_point(|&c| c <= p) - 1
362        }
363        SymbolicCholeskyRaw::Supernodal(s) => {
364            let ptr = s.col_ptr_for_val();
365            let t = ptr.partition_point(|&c| c <= p) - 1;
366            let ncols = s.supernode_end()[t] - s.supernode_begin()[t];
367            let nrows = s.nnz_per_super()[t] + ncols;
368            s.supernode_begin()[t] + (p - ptr[t]) / nrows
369        }
370    })
371}
372
373/// Step N2, Bunch–Kaufman path: faer's intranodal `LBLᵀ`; returns the
374/// pivot data, the `1×1` pivots and the `2×2` blocks (step N3).
375#[allow(clippy::type_complexity)]
376fn factor_lblt(
377    sym: &SymbolicCholesky<usize>,
378    a: SparseColMatRef<'_, usize, f64>,
379    values: &mut [f64],
380) -> Result<(Lblt<f64>, Vec<f64>, Vec<(f64, f64, f64)>), KktError> {
381    let n = sym.nrows();
382    let mut subdiag = vec![0.0_f64; n];
383    let mut fwd = vec![0_usize; n];
384    let mut inv = vec![0_usize; n];
385    let mut buf = MemBuffer::try_new(
386        sym.factorize_numeric_intranode_lblt_scratch::<f64>(Par::Seq, Spec::default()),
387    )
388    .map_err(|_| KktError::Backend("faer: out of memory"))?;
389    sym.factorize_numeric_intranode_lblt(
390        values,
391        &mut subdiag,
392        &mut fwd,
393        &mut inv,
394        a,
395        Side::Lower,
396        Par::Seq,
397        MemStack::new(&mut buf),
398        Spec::default(),
399    );
400    let d = pivots(sym, values);
401    // N3: 2×2 blocks where the subdiagonal is nonzero.
402    let mut ones = Vec::with_capacity(n);
403    let mut twos = Vec::new();
404    let mut p = 0;
405    while p < n {
406        if p + 1 < n && !is_exact_zero(subdiag[p]) {
407            twos.push((d[p], subdiag[p], d[p + 1]));
408            p += 2;
409        } else {
410            ones.push(d[p]);
411            p += 1;
412        }
413    }
414    Ok((Lblt { subdiag, fwd, inv }, ones, twos))
415}
416
417impl KktSolver<f64> for FaerLdl {
418    type Symbolic = FaerSymbolic;
419    type Numeric = FaerNumeric<f64>;
420
421    fn analyze(
422        &self,
423        pattern: &Pattern,
424        form: KktForm,
425        ordering: &dyn Ordering,
426    ) -> Result<FaerSymbolic, KktError> {
427        // A1.
428        let lower = lower_with_diagonal(pattern, form)?;
429        let n = lower.n_cols();
430        // A2.
431        let full = lower.symmetric()?;
432        let perm = ordering.order(&full, OrderingTarget::Symmetric)?;
433        if perm.len() != n {
434            return Err(KktError::Structure(SparseError::Dimension {
435                what: "ordering length",
436                expected: n,
437                found: perm.len(),
438            }));
439        }
440        // A2′.
441        let perm = full.etree_postorder(&perm);
442        // A3.
443        let row_idx: Vec<usize> = lower.row_idx.iter().map(|&i| i as usize).collect();
444        let fwd: Vec<usize> = perm.fwd.iter().map(|&i| i as usize).collect();
445        let inv: Vec<usize> = perm.inv.iter().map(|&i| i as usize).collect();
446        let a = SymbolicSparseColMatRef::new_checked(n, n, &lower.col_ptr, None, &row_idx);
447        let threshold = match self.supernodal {
448            None => SupernodalThreshold::AUTO,
449            Some(true) => SupernodalThreshold::FORCE_SUPERNODAL,
450            Some(false) => SupernodalThreshold::FORCE_SIMPLICIAL,
451        };
452        let params = CholeskySymbolicParams {
453            supernodal_flop_ratio_threshold: threshold,
454            ..Default::default()
455        };
456        let faer = factorize_symbolic_cholesky(
457            a,
458            Side::Lower,
459            SymmetricOrdering::Custom(PermRef::new_checked(&fwd, &inv, n)),
460            params,
461        )
462        .map_err(faer_error)?;
463        // A4.
464        let upper = matches!(faer.raw(), SymbolicCholeskyRaw::Simplicial(_));
465        let permuted = permute_lower(&lower, &perm, upper);
466        Ok(FaerSymbolic {
467            form,
468            lower,
469            row_idx,
470            perm,
471            faer: Arc::new(faer),
472            permuted,
473        })
474    }
475
476    #[allow(clippy::too_many_lines)] // steps N1–N4 in order, as in the chapter
477    fn factorize(
478        &self,
479        symbolic: &FaerSymbolic,
480        k: &CscMatrix<f64>,
481        reg: Regularization<f64>,
482    ) -> Result<(FaerNumeric<f64>, Inertia), KktError> {
483        let form = symbolic.form;
484        let sym = &*symbolic.faer;
485        let n = symbolic.lower.n_cols();
486        check_regularization(&reg)?;
487        // N1.
488        let (v, scale) = assemble_regularized(&symbolic.lower, form, k, &reg)?;
489        let a = SparseColMatRef::new(
490            SymbolicSparseColMatRef::new_checked(
491                n,
492                n,
493                &symbolic.lower.col_ptr,
494                None,
495                &symbolic.row_idx,
496            ),
497            &v,
498        );
499        let mut values = vec![0.0_f64; sym.len_val()];
500        let supernodal = matches!(sym.raw(), SymbolicCholeskyRaw::Supernodal(_));
501        // N2.
502        let use_lblt = matches!(form, KktForm::Augmented { .. })
503            && !reg.is_quasi_definite()
504            && reg.pivot_tol.is_none()
505            && supernodal;
506        let (lblt, d, twos, faer_bumps) = if use_lblt {
507            let (lblt, ones, twos) = factor_lblt(sym, a, &mut values)?;
508            (Some(lblt), ones, twos, 0)
509        } else {
510            // The signs in pivot order, and K̃_reg permuted by the map of
511            // step A4: faer's lower-level numeric phases, which report the
512            // count of bumped pivots (step N3).
513            let signs = pivot_signs(form, n);
514            let fwd = &symbolic.perm.fwd;
515            let signs: Vec<i8> = (0..n).map(|k| signs[fwd[k] as usize]).collect();
516            let pm = &symbolic.permuted;
517            let mut pv = vec![0.0_f64; v.len()];
518            for (&dst, &x) in pm.map.iter().zip(&v) {
519                pv[dst] = x;
520            }
521            let a_perm = SparseColMatRef::new(
522                SymbolicSparseColMatRef::new_checked(n, n, &pm.col_ptr, None, &pm.row_idx),
523                &pv,
524            );
525            let regularization = LdltRegularization {
526                dynamic_regularization_signs: reg.pivot_tol.map(|_| signs.as_slice()),
527                dynamic_regularization_delta: reg.bump,
528                dynamic_regularization_epsilon: reg.pivot_tol.unwrap_or(0.0),
529            };
530            let req = match sym.raw() {
531                SymbolicCholeskyRaw::Simplicial(_) => {
532                    simplicial::factorize_simplicial_numeric_ldlt_scratch::<usize, f64>(n)
533                }
534                SymbolicCholeskyRaw::Supernodal(s) => {
535                    supernodal::factorize_supernodal_numeric_ldlt_scratch::<usize, f64>(
536                        s,
537                        Par::Seq,
538                        Spec::default(),
539                    )
540                }
541            };
542            let mut buf =
543                MemBuffer::try_new(req).map_err(|_| KktError::Backend("faer: out of memory"))?;
544            let stack = MemStack::new(&mut buf);
545            let result = match sym.raw() {
546                SymbolicCholeskyRaw::Simplicial(s) => {
547                    simplicial::factorize_simplicial_numeric_ldlt(
548                        &mut values,
549                        a_perm,
550                        regularization,
551                        s,
552                        stack,
553                    )
554                }
555                SymbolicCholeskyRaw::Supernodal(s) => {
556                    supernodal::factorize_supernodal_numeric_ldlt(
557                        &mut values,
558                        a_perm,
559                        regularization,
560                        s,
561                        Par::Seq,
562                        stack,
563                        Spec::default(),
564                    )
565                }
566            };
567            let bumps = match result {
568                Ok(info) => info.dynamic_regularization_count,
569                Err(LdltError::ZeroPivot { index }) => {
570                    // faer 0.24 reports the simplicial pivot one-based and
571                    // the supernodal one zero-based.
572                    let k = if supernodal { index } else { index - 1 };
573                    return Err(KktError::ZeroPivot {
574                        position: symbolic.perm.fwd[k] as usize,
575                    });
576                }
577            };
578            let d = pivots(sym, &values);
579            // faer's supernodal kernel counts only the bumps of the
580            // positive-sign pivots; a bumped negative-sign pivot is −β
581            // exactly (step N3).
582            let extra = if supernodal && reg.pivot_tol.is_some() {
583                d.iter()
584                    .zip(&signs)
585                    .filter(|&(&x, &sg)| sg < 0 && approx_eq(x, -reg.bump, 0.0))
586                    .count()
587            } else {
588                0
589            };
590            (None, d, Vec::new(), bumps + extra)
591        };
592        // N3′ (step K3′, (S10)).
593        let bad = first_non_finite(sym, &values).or_else(|| {
594            lblt.as_ref()
595                .and_then(|b| b.subdiag.iter().position(|x| !x.is_finite()))
596        });
597        if let Some(k) = bad {
598            return Err(KktError::NotFinite {
599                position: symbolic.perm.fwd[k] as usize,
600            });
601        }
602        // N3.
603        let bumped = faer_bumps;
604        let found = count_inertia(d.into_iter(), twos.into_iter());
605        // N4 (S6).
606        let inertia = check_inertia(found, form.expected_inertia(n))?;
607        debug_assert_eq!(inertia, form.expected_inertia(n), "(S6)");
608        Ok((
609            FaerNumeric {
610                faer: Arc::clone(&symbolic.faer),
611                values,
612                lblt,
613                inertia,
614                bumped,
615                scale,
616                scratch: SolveScratch(Mutex::new(
617                    MemBuffer::try_new(sym.solve_in_place_scratch::<f64>(1, Par::Seq))
618                        .map_err(|_| KktError::Backend("faer: out of memory"))?,
619                )),
620            },
621            inertia,
622        ))
623    }
624
625    fn solve(&self, numeric: &FaerNumeric<f64>, x: &mut [f64]) {
626        let sym = &*numeric.faer;
627        let n = sym.nrows();
628        assert_eq!(x.len(), n, "solve: x has the wrong length");
629        for (xi, &si) in x.iter_mut().zip(&numeric.scale) {
630            *xi *= si;
631        }
632        // The factor's own scratch; a temporary one only if another thread
633        // holds it (or a panic poisoned it).
634        let mut held = numeric.scratch.0.try_lock().ok();
635        let mut temp;
636        let stack = if let Some(buf) = held.as_deref_mut() {
637            MemStack::new(buf)
638        } else {
639            temp = MemBuffer::new(sym.solve_in_place_scratch::<f64>(1, Par::Seq));
640            MemStack::new(&mut temp)
641        };
642        let rhs = MatMut::from_column_major_slice_mut(x, n, 1);
643        match &numeric.lblt {
644            None => LdltRef::new(sym, &numeric.values).solve_in_place_with_conj(
645                Conj::No,
646                rhs,
647                Par::Seq,
648                stack,
649            ),
650            Some(b) => IntranodeLbltRef::new(
651                sym,
652                &numeric.values,
653                &b.subdiag,
654                PermRef::new_checked(&b.fwd, &b.inv, n),
655            )
656            .solve_in_place_with_conj(Conj::No, rhs, Par::Seq, stack),
657        }
658        for (xi, &si) in x.iter_mut().zip(&numeric.scale) {
659            *xi *= si;
660        }
661    }
662
663    fn nnz_factor(&self, numeric: &FaerNumeric<f64>) -> usize {
664        numeric.faer.len_val()
665    }
666
667    fn name(&self) -> &'static str {
668        "faer-ldl"
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::real::le;
676    use crate::{Amd, Col, Natural, Refinement, Row, ShiftScale};
677    use proptest::prelude::*;
678
679    /// xorshift in `[0, 1)`.
680    struct Rng(u64);
681    impl Rng {
682        fn unit(&mut self) -> f64 {
683            self.0 ^= self.0 << 13;
684            self.0 ^= self.0 >> 7;
685            self.0 ^= self.0 << 17;
686            #[allow(clippy::cast_precision_loss)]
687            let u = (self.0 >> 11) as f64 / (1_u64 << 53) as f64;
688            u
689        }
690    }
691
692    /// A random sparse `m × n` matrix with a nonzero in every row (full
693    /// row rank with probability one) and the KKT systems built from it
694    /// with `D = 10^{±spread·u}`.
695    fn kkt(m: usize, n: usize, spread: f64, seed: u64) -> (CscMatrix, CscMatrix, KktForm) {
696        let mut r = Rng(seed | 1);
697        let mut a = Vec::new();
698        for i in 0..m {
699            #[allow(
700                clippy::cast_possible_truncation,
701                clippy::cast_sign_loss,
702                clippy::cast_precision_loss
703            )]
704            let j = (r.unit() * n as f64) as usize % n;
705            a.push((i, j, 1.0 + r.unit()));
706            for jj in 0..n {
707                if jj != j && r.unit() < 0.2 {
708                    a.push((i, jj, 2.0 * r.unit() - 1.0));
709                }
710            }
711        }
712        let d: Vec<f64> = (0..n)
713            .map(|_| 10f64.powf(spread * (2.0 * r.unit() - 1.0)))
714            .collect();
715        // Augmented, lower triangle: −D on the diagonal, A below.
716        let mut t = Vec::new();
717        for (j, &dj) in d.iter().enumerate() {
718            t.push((Row::from_usize(j), Col::from_usize(j), -dj));
719        }
720        for &(i, j, v) in &a {
721            t.push((Row::from_usize(n + i), Col::from_usize(j), v));
722        }
723        let aug = CscMatrix::from_triplets(n + m, n + m, &t).unwrap();
724        // Normal equations A D Aᵀ, lower triangle.
725        let mut dense = vec![vec![0.0; m]; m];
726        for &(i, j, v) in &a {
727            for &(p, q, w) in &a {
728                if q == j && p <= i {
729                    dense[i][p] += v * d[j] * w;
730                }
731            }
732        }
733        let mut tn = Vec::new();
734        for (i, row) in dense.iter().enumerate() {
735            for (p, &v) in row.iter().enumerate() {
736                if !crate::real::is_exact_zero(v) {
737                    tn.push((Row::from_usize(i), Col::from_usize(p), v));
738                }
739            }
740        }
741        let normal = CscMatrix::from_triplets(m, m, &tn).unwrap();
742        (
743            aug,
744            normal,
745            KktForm::Augmented {
746                n_primal: n,
747                n_dual: m,
748            },
749        )
750    }
751
752    fn rhs(n: usize, seed: u64) -> Vec<f64> {
753        let mut r = Rng(seed | 3);
754        (0..n).map(|_| 2.0 * r.unit() - 1.0).collect()
755    }
756
757    /// Factorizes `k` with `reg` and refines against the unregularized
758    /// `K` (the measure of L1.5b), with the right-hand side `K x*` for
759    /// `x* = b`.
760    fn solve_ok(
761        k: &CscMatrix,
762        form: KktForm,
763        reg: Regularization,
764        supernodal: Option<bool>,
765        b: &[f64],
766    ) -> (Inertia, f64, usize) {
767        let s = FaerLdl { supernodal };
768        let sym = s.analyze(&k.pattern(), form, &Amd::default()).unwrap();
769        let (num, inertia) = s.factorize(&sym, k, reg).unwrap();
770        // A manufactured solution: b = K x*, as the right-hand sides of an
771        // interior-point method scale with K.
772        let mut rhs = vec![0.0; b.len()];
773        crate::kkt::sym_lower_matvec(k, b, &mut rhs);
774        let mut x = vec![0.0; b.len()];
775        let rep = s.solve_refined(&num, k, &rhs, &mut x, Refinement::default());
776        (inertia, rep.residual, num.bumped())
777    }
778
779    #[test]
780    fn augmented_and_normal_systems_solve_with_the_expected_inertia() {
781        for (seed, spread) in [(1, 0.0), (2, 4.0), (3, 8.0)] {
782            let (aug, normal, form) = kkt(12, 30, spread, seed);
783            for sn in [None, Some(true), Some(false)] {
784                let (i, res, _) =
785                    solve_ok(&aug, form, Regularization::default(), sn, &rhs(42, seed));
786                assert_eq!(i, form.expected_inertia(42));
787                assert!(le(res, 1e-10, 0.0), "aug seed {seed} sn {sn:?}: {res}");
788                let (i, res, _) = solve_ok(
789                    &normal,
790                    KktForm::NormalEquations,
791                    Regularization::default(),
792                    sn,
793                    &rhs(12, seed),
794                );
795                assert_eq!(i, KktForm::NormalEquations.expected_inertia(12));
796                assert!(le(res, 1e-10, 0.0), "normal seed {seed} sn {sn:?}: {res}");
797            }
798        }
799    }
800
801    #[test]
802    fn unregularized_augmented_system_takes_the_bunch_kaufman_path() {
803        let (aug, _, form) = kkt(10, 25, 2.0, 9);
804        let s = FaerLdl {
805            supernodal: Some(true),
806        };
807        let sym = s.analyze(&aug.pattern(), form, &Natural).unwrap();
808        let (num, inertia) = s.factorize(&sym, &aug, Regularization::none()).unwrap();
809        assert!(num.is_pivoted());
810        assert_eq!(inertia, form.expected_inertia(35));
811        let b = rhs(35, 9);
812        let mut x = vec![0.0; 35];
813        let rep = s.solve_refined(&num, &aug, &b, &mut x, Refinement::default());
814        assert!(rep.converged, "{rep:?}");
815    }
816
817    #[test]
818    fn s6_errors_zero_pivot_and_wrong_inertia() {
819        // K = [−1, 0; 0, 0]: the dual pivot is exactly zero.
820        let k = CscMatrix::from_triplets(2, 2, &[(Row(0), Col(0), -1.0)]).unwrap();
821        let form = KktForm::Augmented {
822            n_primal: 1,
823            n_dual: 1,
824        };
825        let s = FaerLdl {
826            supernodal: Some(false),
827        };
828        let sym = s.analyze(&k.pattern(), form, &Natural).unwrap();
829        assert_eq!(
830            s.factorize(&sym, &k, Regularization::none()).err(),
831            Some(KktError::ZeroPivot { position: 1 })
832        );
833        // With the Bunch–Kaufman path the zero pivot shows in the inertia.
834        let sn = FaerLdl {
835            supernodal: Some(true),
836        };
837        let sym = sn.analyze(&k.pattern(), form, &Natural).unwrap();
838        assert!(matches!(
839            sn.factorize(&sym, &k, Regularization::none()),
840            Err(KktError::WrongInertia { .. })
841        ));
842        // Static regularization makes it quasi-definite.
843        let (_, inertia) = s
844            .factorize(
845                &s.analyze(&k.pattern(), form, &Natural).unwrap(),
846                &k,
847                Regularization::default(),
848            )
849            .unwrap();
850        assert_eq!(inertia, form.expected_inertia(2));
851        // Indefinite "normal equations": wrong inertia without bumping,
852        // the expected one (with bumps) with it.
853        let ind = CscMatrix::from_triplets(
854            2,
855            2,
856            &[
857                (Row(0), Col(0), 1.0),
858                (Row(1), Col(0), 2.0),
859                (Row(1), Col(1), 1.0),
860            ],
861        )
862        .unwrap();
863        let sym = s
864            .analyze(&ind.pattern(), KktForm::NormalEquations, &Natural)
865            .unwrap();
866        assert_eq!(
867            s.factorize(&sym, &ind, Regularization::none()).err(),
868            Some(KktError::WrongInertia {
869                found: Inertia {
870                    positive: 1,
871                    negative: 1,
872                    zero: 0
873                },
874                expected: Inertia {
875                    positive: 2,
876                    negative: 0,
877                    zero: 0
878                }
879            })
880        );
881        let reg = Regularization {
882            primal: 0.0,
883            dual: 0.0,
884            pivot_tol: Some(1e-13),
885            bump: 1e-8,
886            scale: ShiftScale::Equilibrated,
887        };
888        let (num, inertia) = s.factorize(&sym, &ind, reg).unwrap();
889        assert_eq!(inertia.positive, 2);
890        assert_eq!(num.bumped(), 1);
891        // A pivot equal to δ is not a bump.
892        let (aug, _, form) = kkt(3, 5, 0.0, 4);
893        let sym = s.analyze(&aug.pattern(), form, &Natural).unwrap();
894        let (num, _) = s.factorize(&sym, &aug, Regularization::default()).unwrap();
895        assert_eq!(num.bumped(), 0);
896    }
897
898    #[test]
899    fn s10_overflow_is_an_error_and_non_finite_input_is_refused() {
900        // Pivots bumped to a tiny β make L overflow (step K3′).
901        let t = |v: f64| {
902            CscMatrix::from_triplets(
903                3,
904                3,
905                &[
906                    (Row(0), Col(0), v),
907                    (Row(1), Col(0), 1.0),
908                    (Row(2), Col(0), 1.0),
909                    (Row(1), Col(1), 1.0),
910                    (Row(2), Col(1), 1.0),
911                    (Row(2), Col(2), 1.0),
912                ],
913            )
914            .unwrap()
915        };
916        let k = t(1e-310);
917        let reg = Regularization {
918            primal: 0.0,
919            dual: 0.0,
920            pivot_tol: Some(1e-300),
921            bump: 1e-300,
922            scale: ShiftScale::Equilibrated,
923        };
924        for sn in [Some(false), Some(true)] {
925            let s = FaerLdl { supernodal: sn };
926            let sym = s
927                .analyze(&k.pattern(), KktForm::NormalEquations, &Natural)
928                .unwrap();
929            assert!(
930                matches!(
931                    s.factorize(&sym, &k, reg),
932                    Err(KktError::NotFinite { .. } | KktError::ZeroPivot { .. })
933                ),
934                "{sn:?}"
935            );
936            // A NaN in K never reaches the factorization (step K1′).
937            let mut nan = t(1.0);
938            nan.val[0] = f64::NAN;
939            assert!(matches!(
940                s.factorize(&sym, &nan, Regularization::default()),
941                Err(KktError::Structure(_))
942            ));
943        }
944        let s = FaerLdl {
945            supernodal: Some(false),
946        };
947        let sym = s
948            .analyze(&k.pattern(), KktForm::NormalEquations, &Natural)
949            .unwrap();
950        assert!(matches!(
951            s.factorize(&sym, &k, reg),
952            Err(KktError::NotFinite { position: 1 | 2 })
953        ));
954    }
955
956    #[test]
957    fn gmres_refinement_removes_the_shift_on_a_nearly_singular_system() {
958        // A D Aᵀ with two nearly parallel rows of A: λ_min ≈ 10⁻¹² ≪ δ, where
959        // the classical iteration x ← x + F⁻¹ r contracts by δ/(λ + δ) ≈ 1
960        // per step; GMRES (Algorithm F) converges in a few.
961        let a = [
962            [1.0, 2.0, 0.0, 1.0],
963            [1.0, 2.0, 1e-6, 1.0],
964            [0.0, 1.0, 1.0, 3.0],
965        ];
966        let mut t = Vec::new();
967        for i in 0..3 {
968            for p in 0..=i {
969                let v: f64 = (0..4).map(|j| a[i][j] * a[p][j]).sum();
970                t.push((Row::from_usize(i), Col::from_usize(p), v));
971            }
972        }
973        let k = CscMatrix::from_triplets(3, 3, &t).unwrap();
974        let s = FaerLdl::default();
975        let sym = s
976            .analyze(&k.pattern(), KktForm::NormalEquations, &Amd::default())
977            .unwrap();
978        let (num, _) = s.factorize(&sym, &k, Regularization::default()).unwrap();
979        let xs = [1.0, -1.0, 0.5];
980        let mut b = vec![0.0; 3];
981        crate::kkt::sym_lower_matvec(&k, &xs, &mut b);
982        let mut x = vec![0.0; 3];
983        let first = s.solve_refined(
984            &num,
985            &k,
986            &b,
987            &mut x,
988            Refinement {
989                max_iters: 0,
990                target_residual: 0.0,
991            },
992        );
993        let rep = s.solve_refined(&num, &k, &b, &mut x, Refinement::default());
994        assert!(rep.converged && rep.iters <= 4, "{first:?} → {rep:?}");
995        assert!(le(rep.residual, first.residual, 0.0));
996    }
997
998    #[test]
999    fn bumps_are_counted_with_both_signs_and_both_structures() {
1000        // K = [0 1; 1 0], one primal and one dual unknown, no static
1001        // shift: the primal pivot 0 is bumped to −β (a negative-sign bump),
1002        // the dual one is then 1/β > 0.
1003        let k = CscMatrix::from_triplets(2, 2, &[(Row(1), Col(0), 1.0)]).unwrap();
1004        let form = KktForm::Augmented {
1005            n_primal: 1,
1006            n_dual: 1,
1007        };
1008        let reg = Regularization {
1009            primal: 0.0,
1010            dual: 0.0,
1011            pivot_tol: Some(1e-13),
1012            bump: 1e-8,
1013            scale: ShiftScale::Equilibrated,
1014        };
1015        // A normal-equations matrix with a zero pivot: a positive-sign bump.
1016        let z = CscMatrix::from_triplets(
1017            3,
1018            3,
1019            &[
1020                (Row(0), Col(0), 1.0),
1021                (Row(2), Col(0), 1.0),
1022                (Row(2), Col(2), 1.0),
1023            ],
1024        )
1025        .unwrap();
1026        for sn in [Some(false), Some(true)] {
1027            let s = FaerLdl { supernodal: sn };
1028            let sym = s.analyze(&k.pattern(), form, &Natural).unwrap();
1029            let (num, inertia) = s.factorize(&sym, &k, reg).unwrap();
1030            assert_eq!(inertia, form.expected_inertia(2));
1031            assert_eq!(num.bumped(), 1, "{sn:?}");
1032            let sym = s
1033                .analyze(&z.pattern(), KktForm::NormalEquations, &Natural)
1034                .unwrap();
1035            let (num, _) = s.factorize(&sym, &z, reg).unwrap();
1036            assert_eq!(num.bumped(), 2, "{sn:?}"); // pivots 1 (zero row) and 2 (1 − 1 = 0)
1037        }
1038    }
1039
1040    #[test]
1041    fn structural_errors() {
1042        let s = FaerLdl::default();
1043        let skew = CscMatrix::from_triplets(
1044            2,
1045            2,
1046            &[
1047                (Row(1), Col(0), 1.0),
1048                (Row(1), Col(1), 1.0),
1049                (Row(0), Col(0), 1.0),
1050                (Row(0), Col(1), 3.0),
1051            ],
1052        )
1053        .unwrap();
1054        // Symmetric pattern with both triangles: accepted.
1055        assert!(
1056            s.analyze(&skew.pattern(), KktForm::NormalEquations, &Amd::default())
1057                .is_ok()
1058        );
1059        let up = CscMatrix::from_triplets(2, 2, &[(Row(0), Col(1), 1.0)]).unwrap();
1060        assert_eq!(
1061            s.analyze(&up.pattern(), KktForm::NormalEquations, &Amd::default())
1062                .err(),
1063            Some(KktError::NotSymmetric)
1064        );
1065        let sym = s
1066            .analyze(
1067                &CscMatrix::<f64>::zeros(2, 2).pattern(),
1068                KktForm::NormalEquations,
1069                &Amd::default(),
1070            )
1071            .unwrap();
1072        let k = CscMatrix::from_triplets(2, 2, &[(Row(1), Col(0), 1.0), (Row(0), Col(0), 1.0)])
1073            .unwrap();
1074        assert!(matches!(
1075            s.factorize(&sym, &k, Regularization::default()),
1076            Err(KktError::Structure(_))
1077        ));
1078        let k3 = CscMatrix::<f64>::zeros(3, 3);
1079        assert!(matches!(
1080            s.factorize(&sym, &k3, Regularization::default()),
1081            Err(KktError::Structure(_))
1082        ));
1083        let bad = Regularization {
1084            pivot_tol: Some(1e-13),
1085            bump: 0.0,
1086            ..Regularization::default()
1087        };
1088        assert!(matches!(
1089            s.factorize(&sym, &CscMatrix::zeros(2, 2), bad),
1090            Err(KktError::Structure(_))
1091        ));
1092        // A value that vanished (left the CSC pattern) is a zero inside the
1093        // analyzed one.
1094        let (aug, _, form) = kkt(4, 8, 1.0, 5);
1095        let sym = s.analyze(&aug.pattern(), form, &Amd::default()).unwrap();
1096        let mut thin = aug.clone();
1097        thin.row_idx.remove(1);
1098        thin.val.remove(1);
1099        for p in &mut thin.col_ptr[1..] {
1100            *p -= 1;
1101        }
1102        assert!(s.factorize(&sym, &thin, Regularization::default()).is_ok());
1103    }
1104
1105    #[test]
1106    fn s7_refinement_keeps_the_best_iterate() {
1107        let (aug, _, form) = kkt(8, 20, 4.0, 77);
1108        let s = FaerLdl::default();
1109        let sym = s.analyze(&aug.pattern(), form, &Amd::default()).unwrap();
1110        let b = rhs(28, 77);
1111        // A factor of a far-away matrix (δ = ρ = 10): refinement against
1112        // the unregularized K improves or stops, never worsens.
1113        let far = Regularization {
1114            primal: 10.0,
1115            dual: 10.0,
1116            pivot_tol: None,
1117            bump: 0.0,
1118            scale: ShiftScale::Absolute,
1119        };
1120        let (num, _) = s.factorize(&sym, &aug, far).unwrap();
1121        let mut x = vec![0.0; 28];
1122        let none = s.solve_refined(
1123            &num,
1124            &aug,
1125            &b,
1126            &mut x,
1127            Refinement {
1128                max_iters: 0,
1129                target_residual: 0.0,
1130            },
1131        );
1132        let rep = s.solve_refined(
1133            &num,
1134            &aug,
1135            &b,
1136            &mut x,
1137            Refinement {
1138                max_iters: 50,
1139                target_residual: 0.0,
1140            },
1141        );
1142        assert!(le(rep.residual, none.residual, 0.0));
1143        assert_eq!(none.iters, 0);
1144        assert!(!rep.converged);
1145    }
1146
1147    proptest! {
1148        #![proptest_config(ProptestConfig::with_cases(24))]
1149        /// (S6) and the refined residual on random KKT systems in the three
1150        /// IPM regimes, with every structure choice.
1151        #[test]
1152        fn random_kkt_systems(m in 1_usize..15, extra in 0_usize..20, regime in 0_usize..3, sn in 0_usize..3, seed in any::<u64>()) {
1153            let n = m + extra;
1154            let spread = [0.0, 4.0, 9.0][regime];
1155            let supernodal = [None, Some(true), Some(false)][sn];
1156            let (aug, normal, form) = kkt(m, n, spread, seed);
1157            let (i, res, _) = solve_ok(&aug, form, Regularization::default(), supernodal, &rhs(n + m, seed));
1158            prop_assert_eq!(i, form.expected_inertia(n + m));
1159            prop_assert!(le(res, 1e-10, 0.0), "augmented: {}", res);
1160            let (i, _, _) = solve_ok(&normal, KktForm::NormalEquations, Regularization::default(), supernodal, &rhs(m, seed));
1161            prop_assert_eq!(i, KktForm::NormalEquations.expected_inertia(m));
1162        }
1163    }
1164}