xingo_sparse/kkt.rs
1//! # Chapter 5.14 — KKT systems: analyze once, factorize many
2//!
3//! *New with task L1.5 (PLAN.md trilha L; the contract's §5.7).*
4//!
5//! ## Purpose
6//! At every iteration a primal-dual interior-point method solves a linear
7//! system whose matrix has a *fixed pattern* and *changing values*: the
8//! normal equations `A D Aᵀ` or the augmented (KKT) system
9//! `[−D, Aᵀ; A, 0]`, where the diagonal `D > 0` spreads over many orders
10//! of magnitude as the iterates approach the boundary (Wright 1997, ch.
11//! 11). The interface below is the shape of CHOLMOD (Chen, Davis, Hager &
12//! Rajamanickam 2008): a *symbolic* phase that depends on the pattern only
13//! (ordering, elimination tree, structure of the factor) is done once, a
14//! *numeric* phase is repeated for every new set of values, and a *solve*
15//! phase uses the factor. On top of it sit two things that every backend
16//! shares and that therefore live here, not in a backend:
17//!
18//! * the **regularization policy** (Altman & Gondzio 1999): which static
19//! shifts `ρ`, `δ` enter which diagonal block, which sign each pivot must
20//! have, which pivots a backend may bump, and the inertia check (S6);
21//! * **iterative refinement** against the matrix the caller really wants
22//! solved ([`KktSolver::solve_refined`], a provided method), which keeps
23//! its best iterate (S7).
24//!
25//! ## Notation
26//! `K ∈ R^{N×N}` symmetric, given by its **lower triangle** in a
27//! [`CscMatrix`] (entries `i ≥ j`; CHOLMOD's `stype < 0`). A matrix that
28//! stores both triangles is accepted when its pattern is symmetric: the
29//! entries above the diagonal are then ignored. Two forms ([`KktForm`]):
30//!
31//! * `NormalEquations`: `K = A D Aᵀ` (`N = m`), positive (semi)definite;
32//! * `Augmented { n_primal: n, n_dual: m }`: `K = [−D, Aᵀ; A, 0]`
33//! (`N = n + m`), the `n` primal unknowns first.
34//!
35//! A [`Regularization`] `(ρ, δ, τ, β, scale)` = (`primal`, `dual`,
36//! `pivot_tol`, `bump`, `scale`) defines the matrix the backend
37//! factorizes. With `S = diag(s)` the symmetric equilibration of `K`
38//! (step K2′ below; powers of two) and `Σ = diag(−ρ I_n, δ I_m)` for the
39//! augmented system (`Σ = δ I_m` for the normal equations),
40//! ```text
41//! K_reg = K + S⁻¹ Σ S⁻¹ (ShiftScale::Equilibrated, the default),
42//! K_reg = K + Σ (ShiftScale::Absolute),
43//! ```
44//! so the default shifts are taken *in the equilibrated matrix*
45//! `K̃ = S K S`, whose rows have their largest entry near 1: `δ = 10⁻⁸` is
46//! then relative to the magnitude of each row, whatever the spread of
47//! `D`. The *pivot signs* are `σ_k = −1` for the primal rows, `+1` for the
48//! dual rows (all `+1` for the normal equations). The *inertia* of a symmetric
49//! matrix is the triple ([`Inertia`]) of the numbers of positive, negative
50//! and zero eigenvalues; by Sylvester's law it equals the count of
51//! positive, negative and zero pivots of any `P K Pᵀ = L Δ Lᵀ` with `Δ`
52//! diagonal, and, for `Δ` block diagonal with `1×1` and `2×2` blocks,
53//! the sum of the inertias of the blocks. The *relative residual* of `x`
54//! is `res(x) = ‖K x − b‖_∞ / (1 + ‖b‖_∞)`.
55//!
56//! ## Why the regularized augmented system needs no pivoting
57//! With `D > 0`, `ρ > 0` and `δ > 0`, `K_reg` is *quasi-definite*: its
58//! `(1,1)` block is negative definite and its `(2,2)` block positive
59//! definite. Vanderbei (1995) proved that every symmetric permutation of a
60//! quasi-definite matrix has an `LDLᵀ` factorization with diagonal `Δ`,
61//! the first block's pivots negative and the second's positive; Gill,
62//! Saunders & Shinnerl (1996) bound its stability by the size of the
63//! regularization. So the ordering can be chosen for sparsity alone, at
64//! analysis time — the property that makes "analyze once" possible for the
65//! augmented system — and the inertia is known in advance: `(m, n, 0)`.
66//! Without regularization (`ρ = 0` or `δ = 0`) this guarantee is lost, and
67//! a backend needs `2×2` pivots (Bunch & Kaufman 1977) or dynamic
68//! regularization to factorize.
69//!
70//! *On the sign convention.* The contract's (S6) writes the expected
71//! inertia of the augmented system as `(n, m, 0)` for the matrix
72//! `[−D − ρI, Aᵀ; A, δI]`; for that matrix the `n` primal pivots are
73//! *negative* and the `m` dual pivots *positive*, so with the field order
74//! `(positive, negative, zero)` of [`Inertia`] the triple is
75//! `{ positive: m, negative: n, zero: 0 }` — which is what this chapter
76//! checks (`n` primal and `m` dual eigenvalues, each block with its own
77//! sign; listed for ratification in L1.5).
78//!
79//! ## Algorithm K (the regularization policy shared by every backend)
80//! K1. [Validate.] `K` satisfies (S1); it is square, of order `N` equal to
81//! the form's (`n + m` for the augmented system); its lower triangle
82//! lies inside the pattern given to the analysis (missing entries are
83//! zeros: an IPM value that became exactly zero leaves the CSC
84//! pattern, (C3) of Chapter 5.8).
85//! K1′. [Finite input.] Every stored value of the lower triangle of `K`
86//! is finite ([`KktError::Structure`] otherwise): a NaN or an infinity
87//! in `K` would otherwise surface as a NaN solution.
88//! K2. [Assemble.] The lower triangle of `K` on the analyzed pattern, which
89//! has a full diagonal (a dual diagonal entry absent from `K` is `0`).
90//! K2′. [Symmetric equilibration (Ruiz 2001).] Scale `K` to
91//! `K̃ = S K S`, `S = diag(s)` with powers of two: start from
92//! `s = 1`; in each pass compute, for every row `i` of the *scaled*
93//! matrix (both triangles), `r_i = max_j |k̃_ij|` and multiply `s_i` by
94//! `⌊1/√r_i⌉₂` (rows without entries keep `s_i`); stop after a pass
95//! that changes no `s_i` — exact, the factors being powers of two — or
96//! after [`EQUILIBRATION_PASSES`] passes. Every row of `K̃` then has
97//! its largest entry in `[2^{-1}, 2^{1}]` roughly, whatever the spread
98//! of `D`. The scaling is a congruence, so the inertia of `K̃` is that
99//! of `K` (Sylvester). Being by powers of two it is exact, and an
100//! `LDLᵀ` without pivoting *commutes* with it: the factor of `K̃` is
101//! `(S L S⁻¹)(S Δ S)(S L S⁻¹)ᵀ` bit for bit, barring overflow and
102//! underflow. So K2′ by itself changes exactly two things: the
103//! thresholds `τ`, `β` of K3 become *relative* to the magnitude of
104//! each row, and the intermediate values stay in range.
105//! K2″. [Static regularization.] Add the shifts on the diagonal of `K̃`:
106//! `Σ` itself with [`ShiftScale::Equilibrated`] (`K̃_reg = K̃ + Σ`),
107//! `S Σ S` with [`ShiftScale::Absolute`] (`K̃_reg = S (K + Σ) S`): `−ρ`
108//! on the primal diagonal and `+δ` on the dual one (normal equations:
109//! `+δ` everywhere; `ρ` does not apply, the caller folds it into
110//! `D`). The backend factorizes `K̃_reg` and solves with `S F̃⁻¹ S`;
111//! `S⁻¹ K̃_reg S⁻¹` is the `K_reg` of the notation above.
112//! K3. [Dynamic regularization.] If `τ = pivot_tol` is `Some`, the backend
113//! replaces every pivot `d_k` with `σ_k d_k ≤ τ` (too small, or of the
114//! wrong sign) by `σ_k β` while it eliminates — the dynamic part of
115//! Altman & Gondzio's (1999) primal-dual regularization, in the form
116//! faer's `LdltRegularization` applies — and counts the bumped pivots. Bumping
117//! needs the sign `σ_k`, which only the form knows — the policy is
118//! here; the elimination loop that applies it is the backend's.
119//! K3′. [Finite factor (S10).] Every stored value of the factor is
120//! finite; otherwise the call fails with [`KktError::NotFinite`] at
121//! the first column (original numbering) holding a NaN or an infinity
122//! — an overflow of the elimination is reported, never handed to the
123//! solves (where it would become a NaN solution).
124//! K4. [Inertia (S6).] From the pivots of the factor, count the inertia.
125//! If it is not `(N, 0, 0)` (normal) or `(m, n, 0)` (augmented),
126//! return [`KktError::WrongInertia`]. With `τ = Some`, every pivot has
127//! the sign `σ_k` by K3, so the check cannot fail: the factor is that
128//! of a perturbation of `K_reg` with the right inertia, and refinement
129//! (Algorithm F) measures how far it is from `K`.
130//!
131//! ## Choosing the regularization
132//! Three facts, measured on the 558 Netlib systems of L1.5/L1.5b (93 LPs ×
133//! normal and augmented × the start, middle and end regimes of `D`),
134//! decide the default.
135//!
136//! 1. *A shift must be above the rounding level of its row.* A shift
137//! `δ < ε_T · max|k_ij|` changes nothing in floating point. At the end
138//! of an interior-point method `D` spreads to `10^{±10}` and
139//! `max|K| ≈ 10^{10}`, so the absolute `ρ = δ = 10⁻⁸` of L1.5 failed on
140//! 32 of the 558 systems (zero pivots after overflow, or NaN
141//! solutions). One shift relative to the whole matrix
142//! ([`Regularization::relative_to`]`(k, 10⁻⁸)`, `δ ≈ 10²` there)
143//! factorizes all 558, but perturbs the rows of small magnitude so much
144//! that refinement against the unregularized `K` stalls (418 of 558 at
145//! or below `10⁻¹⁰` after 50 classical steps). Shifts taken in the
146//! equilibrated matrix (step K2″, [`ShiftScale::Equilibrated`]) are
147//! relative *row by row*.
148//! 2. *The product `ρδ` must be well above the unit roundoff.* Eliminating
149//! a primal pivot of size `~ρ` adds terms of size `~1/ρ` to the dual
150//! block, whose pivots must stay above `δ`: with `ρ = δ = 10⁻⁸` in the
151//! equilibrated matrix (`ρδ = 10⁻¹⁶ < ε`) nine augmented systems of the
152//! end regime still got pivots of the wrong sign; with `ρδ ≥ 10⁻¹⁴` none
153//! did (the stability analysis of quasi-definite factorizations of Gill,
154//! Saunders & Shinnerl 1996 bounds the growth by the shifts).
155//! 3. *The normal equations see only `δ`*, and every unit of `δ` slows the
156//! refinement on their small eigenvalues: `δ = 10⁻⁶` left 46 systems
157//! above `10⁻¹⁰` after ten steps, `δ = 10⁻⁸` none (with Algorithm F's
158//! GMRES).
159//!
160//! Hence the default `ρ = 10⁻⁶`, `δ = 10⁻⁸`, equilibrated: all 558 systems
161//! with the right inertia, no bump, and a residual against the
162//! unregularized `K` at or below `10⁻¹⁰` after the ten steps of
163//! [`Refinement::default`] (L1.5b, D1–D3; the bumping of K3 is the safety
164//! net, not the mechanism). An interior-point method that wants other
165//! values passes them; [`ShiftScale::Absolute`] keeps shifts in the
166//! caller's units.
167//!
168//! ## Algorithm F (Refinement by preconditioned GMRES, with the best iterate; (S7))
169//! Given a factor `F ≈ K_reg`, the matrix `K` to be solved (the
170//! *unregularized* one, or `K_reg` itself — whichever the caller passes),
171//! `b` and a [`Refinement`] `(t_max, target)` = (`max_iters`,
172//! `target_residual`), where `t_max` bounds the solves with `F` after the
173//! first one:
174//! F1. `x ← F⁻¹ b`; `r ← b − K x`; `ρ ← res(x)`; `x* ← x`, `ρ* ← ρ`;
175//! `t ← 0`.
176//! F2. [Done?] If `ρ* ≤ target`, `ρ` is not finite, or `t = t_max`, go to
177//! F6.
178//! F3. [Arnoldi cycle, right preconditioned (Saad 2003, §9.3.2).] `x ← x*`,
179//! `r ← b − K x` (the residual of the best iterate), `β ← ‖r‖₂`,
180//! `v₁ ← r/β`, `g ← β e₁`, `m ← t_max − t`. For `j = 1, 2, …, m`:
181//! `z_j ← F⁻¹ v_j`, `t ← t + 1`; `w ← K z_j`; modified Gram–Schmidt
182//! `h_ij ← ⟨w, v_i⟩`, `w ← w − h_ij v_i` for `i ≤ j`;
183//! `h_{j+1,j} ← ‖w‖₂`; apply the previous Givens rotations to column
184//! `j` of `H`, compute the rotation that annihilates `h_{j+1,j}` and
185//! apply it to `g`. Leave the loop with `k = j` when `h_{j+1,j} = 0`
186//! (the Krylov space is invariant: the solution is in it), when
187//! `|g_{j+1}| ≤ target·(1 + ‖b‖_∞)` (in exact arithmetic `|g_{j+1}|`
188//! is the 2-norm of the residual, which bounds its ∞-norm), or when
189//! `j = m`.
190//! F4. [Update.] Solve the `k × k` upper triangular system `R y = g_{1:k}`
191//! (the rotated `H`); `x ← x* + Σ_j y_j z_j`; `r ← b − K x`;
192//! `ρ ← res(x)`. If `ρ < ρ*`, set `x* ← x`, `ρ* ← ρ`.
193//! F5. Go to F2 (a new cycle restarts from `x*`).
194//! F6. Return `x*` with `iters = t`, `residual = ρ*` and
195//! `converged = (ρ* ≤ target)`.
196//!
197//! *Why GMRES and not the classical iteration.* The classical refinement
198//! `x ← x + F⁻¹ r` of L1.5 contracts the error by the factor
199//! `‖I − F⁻¹ K‖ = ‖F⁻¹ (K_reg − K)‖` per step; on the eigenvectors of `K`
200//! whose eigenvalues are not much larger than the shifts — the nearly
201//! rank-deficient `A D Aᵀ` of the middle of an interior-point method, or
202//! an augmented system with `D` spread to `10^{±4}` — that factor is close
203//! to 1, and ten steps left 15 of the 558 Netlib systems of L1.5b above
204//! `10⁻¹⁰` against the unregularized `K`. Right-preconditioned GMRES
205//! minimizes `‖b − K x‖₂` over the same affine Krylov space
206//! `x₀ + span{F⁻¹r₀, (F⁻¹K) F⁻¹r₀, …}` that holds the classical
207//! iterates, so in exact arithmetic it is never worse, and the few
208//! eigenvalues of `F⁻¹ K` far from 1 — one per small eigenvalue of `K`,
209//! the regularization being a small perturbation elsewhere — cost one
210//! step each. This is GMRES preconditioned by a perturbed (statically
211//! pivoted, here statically regularized) `LDLᵀ`, analyzed by Arioli,
212//! Duff, Gratton & Pralet (2007), and GMRES-based iterative refinement
213//! (Carson & Higham 2017). One cycle with all the budget is the normal
214//! case; F5 restarts only when the estimate `|g_{k+1}|` and the computed
215//! residual disagree (rounding) or the budget allows another cycle after
216//! a breakdown.
217//!
218//! *Memory.* A cycle of `m` steps keeps `m + 1` Arnoldi vectors `v_j` and
219//! `m` preconditioned vectors `z_j` (so F4 needs no further solve), plus
220//! `H` and the rotations: `(2m + 3) N + O(m²)` scalars, held by a
221//! [`RefineWorkspace`] that [`KktSolver::solve_refined_with`] reuses
222//! across calls — after the first call with a given order and budget,
223//! refinement allocates nothing (Chapter 5.15 for the solves themselves).
224//!
225//! The residual is computed with the stored lower triangle of `K`
226//! (`y_i += k_ij x_j` and, for `i ≠ j`, `y_j += k_ij x_i`), in the working
227//! precision of `T` — fixed-precision refinement, which brings the
228//! componentwise backward error of a backward-stable factorization down to
229//! the order of the unit roundoff (Skeel 1980; Higham 2002, ch. 12); with
230//! a regularized factor it also removes the bias of the shifts (Altman &
231//! Gondzio 1999, §6). When `K` is singular but `b` is consistent the
232//! iteration still converges in the residual.
233//!
234//! ## Invariants
235//! (K1) The pattern given to [`KktSolver::analyze`] is square and either
236//! lower triangular or structurally symmetric
237//! ([`KktError::NotSymmetric`] otherwise).
238//! (S6) (the contract's) [`KktSolver::factorize`] returns the inertia of
239//! the factor it computed; it is `(N, 0, 0)` for the normal equations
240//! and `(m, n, 0)` = `{positive: m, negative: n, zero: 0}` for the
241//! augmented system, or the call fails with
242//! [`KktError::WrongInertia`]. Asserted on every successful
243//! factorization in debug builds.
244//! (S7) (the contract's) [`KktSolver::solve_refined`] never returns a
245//! residual larger than that of its first solve: it keeps the best
246//! iterate (step F4). Asserted in debug builds.
247//! (S10) A factor returned by [`KktSolver::factorize`] holds only finite
248//! values (step K3′); [`KktSolver::solve_refined`] reports a
249//! non-finite solution explicitly: its `residual` is NaN or infinite
250//! exactly when the returned `x` has a non-finite entry, and then
251//! `converged = false` (the input `K`, `b` being finite).
252//!
253//! ## References
254//! Chen, Davis, Hager & Rajamanickam, "Algorithm 887: CHOLMOD, supernodal
255//! sparse Cholesky factorization and update/downdate", ACM TOMS 35(3),
256//! 2008 (the analyze/factorize/solve shape, the lower-triangle input, the
257//! supernodal/simplicial selection by flops per `nnz(L)` — read, not
258//! copied); Altman & Gondzio, "Regularized symmetric indefinite systems in
259//! interior point methods for linear and quadratic optimization", Optim.
260//! Methods Softw. 11–12, 1999; Vanderbei, "Symmetric quasidefinite
261//! matrices", SIAM J. Optim. 5(1), 1995; Gill, Saunders & Shinnerl, "On
262//! the stability of Cholesky factorization for symmetric quasidefinite
263//! systems", SIAM J. Matrix Anal. Appl. 17(1), 1996; Wright,
264//! *Primal-Dual Interior-Point Methods*, SIAM 1997, ch. 11; Bunch &
265//! Kaufman, "Some stable methods for calculating inertia and solving
266//! symmetric linear systems", Math. Comp. 31, 1977; Skeel, "Iterative
267//! refinement implies numerical stability for Gaussian elimination", Math.
268//! Comp. 35, 1980; Higham, *Accuracy and Stability of Numerical
269//! Algorithms*, 2nd ed. 2002, ch. 11–12; Ruiz, "A scaling algorithm to
270//! equilibrate both rows and columns norms in matrices", RAL-TR-2001-034,
271//! 2001 (symmetric variant, rounded to powers of two here); Stellato et al., "OSQP: an
272//! operator splitting solver for quadratic programs", Math. Prog. Comp.
273//! 12, 2020 (quasi-definite `LDLᵀ` without pivoting in an optimization
274//! solver); Saad, *Iterative Methods for Sparse Linear Systems*, 2nd ed.,
275//! SIAM 2003, §6.5 and §9.3 (GMRES, right preconditioning, Givens
276//! rotations); Arioli, Duff, Gratton & Pralet, "A note on GMRES
277//! preconditioned by a perturbed `LDLᵀ` decomposition with static
278//! pivoting", SIAM J. Sci. Comput. 29(5), 2007; Carson & Higham, "A new
279//! analysis of iterative refinement and its application to accurate
280//! solution of ill-conditioned sparse linear systems", SIAM J. Sci.
281//! Comput. 39(6), 2017; Saunders & Tomlin, "Solving regularized linear
282//! programs using barrier methods and KKT systems", SOL 96-4, 1996 (the
283//! product `ρδ` above the rounding level for a stable quasi-definite
284//! factorization).
285
286use crate::real::{Real, is_exact_zero, is_nan, le, lt};
287use crate::{CscMatrix, KktError, Ordering, Pattern, SparseError};
288
289/// Inertia `(positive, negative, zero)` of a symmetric factorization: the
290/// numbers of positive, negative and zero pivots (eigenvalues, by
291/// Sylvester's law).
292#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
293pub struct Inertia {
294 /// Positive pivots.
295 pub positive: usize,
296 /// Negative pivots.
297 pub negative: usize,
298 /// Zero pivots.
299 pub zero: usize,
300}
301
302/// Where the static shifts `ρ`, `δ` of a [`Regularization`] are applied
303/// (step K2″ of Algorithm K).
304#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
305pub enum ShiftScale {
306 /// On the diagonal of the equilibrated matrix `K̃ = S K S` (step K2′),
307 /// whose rows have their largest entry near 1: the shifts are
308 /// relative to the magnitude of each row, `K_reg = K + S⁻¹ Σ S⁻¹`.
309 /// The default.
310 #[default]
311 Equilibrated,
312 /// On the diagonal of `K` itself, in the caller's units:
313 /// `K_reg = K + Σ`.
314 Absolute,
315}
316
317/// Regularization of a quasi-definite KKT matrix (Altman & Gondzio 1999;
318/// Algorithm K).
319#[derive(Clone, Copy, Debug, PartialEq)]
320pub struct Regularization<T: Real = f64> {
321 /// `ρ ≥ 0`, added to the primal block (which becomes `−D − ρI`, in
322 /// the units [`Regularization::scale`] says); ignored for the normal
323 /// equations.
324 pub primal: T,
325 /// `δ ≥ 0`, added to the dual block (`δI`), or to the whole diagonal
326 /// of the normal equations (`A D Aᵀ + δI`), in the units
327 /// [`Regularization::scale`] says.
328 pub dual: T,
329 /// Below this magnitude (or with the wrong sign) a pivot of the
330 /// equilibrated matrix is bumped to `±bump` with the sign the inertia
331 /// expects (dynamic regularization, step K3); `None` disables it.
332 pub pivot_tol: Option<T>,
333 /// The magnitude `β > 0` of a bumped pivot (of the equilibrated
334 /// matrix).
335 pub bump: T,
336 /// Whether `ρ`, `δ` are taken in the equilibrated matrix (the default)
337 /// or in `K` itself (step K2″).
338 pub scale: ShiftScale,
339}
340
341impl<T: Real> Default for Regularization<T> {
342 /// `ρ = 10⁻⁶`, `δ = 10⁻⁸` *in the equilibrated matrix*
343 /// ([`ShiftScale::Equilibrated`]: relative to each row), and pivots of
344 /// the wrong sign or of magnitude at most `τ = 10⁻¹³` bumped to
345 /// `±β = ±2·10⁻⁷`. `ρδ = 10⁻¹⁴`, about 50 units of roundoff, keeps the
346 /// regularized augmented matrix numerically quasi-definite; `δ`
347 /// alone, small, is what the normal equations see (Chapter 5.14,
348 /// "Choosing the regularization", measured in L1.5b). `τ`, `β` are of
349 /// the order of the defaults of current interior-point codes
350 /// (Clarabel's documented dynamic regularization; not tuned here).
351 fn default() -> Self {
352 Self {
353 primal: T::from_f64(1e-6),
354 dual: T::from_f64(1e-8),
355 pivot_tol: Some(T::from_f64(1e-13)),
356 bump: T::from_f64(2e-7),
357 scale: ShiftScale::Equilibrated,
358 }
359 }
360}
361
362impl<T: Real> Regularization<T> {
363 /// No regularization at all: `ρ = δ = 0`, no bumping (the factorization
364 /// then fails on a zero pivot, or with the wrong inertia).
365 #[must_use]
366 pub fn none() -> Self {
367 Self {
368 primal: T::ZERO,
369 dual: T::ZERO,
370 pivot_tol: None,
371 bump: T::ZERO,
372 scale: ShiftScale::Equilibrated,
373 }
374 }
375
376 /// Absolute static shifts relative to the largest entry of `k`:
377 /// `ρ = δ = factor · max|k_ij|` with [`ShiftScale::Absolute`], and the
378 /// dynamic part of [`Regularization::default`] (the L1.5 variant).
379 ///
380 /// One shift for the whole matrix: at the end of an interior-point
381 /// method, where `max|k_ij| ≈ 10^{10}`, `factor = 10⁻⁸` gives
382 /// `δ ≈ 10²`, a large perturbation of the rows of small magnitude —
383 /// refinement against the unregularized `K` then converges slowly
384 /// (Chapter 5.14, "Choosing the regularization"). The default
385 /// ([`ShiftScale::Equilibrated`]) is relative row by row instead.
386 #[must_use]
387 pub fn relative_to(k: &CscMatrix<T>, factor: T) -> Self {
388 let big = inf_norm(&k.val);
389 Self {
390 primal: factor * big,
391 dual: factor * big,
392 scale: ShiftScale::Absolute,
393 ..Self::default()
394 }
395 }
396
397 /// `ρ > 0` and `δ > 0`: the regularized augmented matrix is
398 /// quasi-definite (it factorizes without pivoting, in any order).
399 pub fn is_quasi_definite(&self) -> bool {
400 lt(T::ZERO, self.primal) && lt(T::ZERO, self.dual)
401 }
402}
403
404/// Iterative refinement parameters (Algorithm F).
405#[derive(Clone, Copy, Debug, PartialEq)]
406pub struct Refinement<T: Real = f64> {
407 /// Refinement steps after the first solve.
408 pub max_iters: usize,
409 /// Stop when `‖K x − b‖_∞ / (1 + ‖b‖_∞)` is at or below this.
410 pub target_residual: T,
411}
412
413impl<T: Real> Default for Refinement<T> {
414 /// Ten steps, target `10⁻¹²`.
415 fn default() -> Self {
416 Self {
417 max_iters: 10,
418 target_residual: T::from_f64(1e-12),
419 }
420 }
421}
422
423/// What refinement achieved (S7).
424#[derive(Clone, Copy, Debug, PartialEq)]
425pub struct RefineReport<T: Real = f64> {
426 /// Refinement steps taken (solves after the first).
427 pub iters: usize,
428 /// `‖K x − b‖_∞ / (1 + ‖b‖_∞)` of the returned `x`.
429 pub residual: T,
430 /// `residual ≤ target_residual`.
431 pub converged: bool,
432}
433
434/// Which system the interior-point method hands over.
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436pub enum KktForm {
437 /// `A D Aᵀ + δI`, positive definite.
438 NormalEquations,
439 /// `[−D − ρI, Aᵀ; A, δI]`, quasi-definite; the `n_primal` primal rows
440 /// come first.
441 Augmented {
442 /// `n`, the order of the `(1,1)` block.
443 n_primal: usize,
444 /// `m`, the order of the `(2,2)` block.
445 n_dual: usize,
446 },
447}
448
449impl KktForm {
450 /// The inertia (S6) requires of a factor of order `n`.
451 pub fn expected_inertia(self, n: usize) -> Inertia {
452 match self {
453 Self::NormalEquations => Inertia {
454 positive: n,
455 negative: 0,
456 zero: 0,
457 },
458 Self::Augmented { n_primal, n_dual } => Inertia {
459 positive: n_dual,
460 negative: n_primal,
461 zero: 0,
462 },
463 }
464 }
465}
466
467/// Analyze/factorize/solve of symmetric systems with a fixed pattern (the
468/// CHOLMOD shape, read, not copied). Regularization (its policy,
469/// Algorithm K) and refinement (Algorithm F) are shared by every backend.
470///
471/// ```
472/// # #[cfg(feature = "faer")] {
473/// use xingo_sparse::{
474/// Amd, Col, CscMatrix, FaerLdl, KktForm, KktSolver, Refinement, Regularization, Row,
475/// };
476/// // K = [−D, Aᵀ; A, 0] with D = diag(1, 2), A = [1 1]: lower triangle.
477/// let k = CscMatrix::from_triplets(3, 3, &[
478/// (Row(0), Col(0), -1.0), (Row(2), Col(0), 1.0),
479/// (Row(1), Col(1), -2.0), (Row(2), Col(1), 1.0),
480/// ])
481/// .unwrap();
482/// let form = KktForm::Augmented { n_primal: 2, n_dual: 1 };
483/// let solver = FaerLdl::default();
484/// let sym = solver.analyze(&k.pattern(), form, &Amd::default()).unwrap();
485/// let (num, inertia) = solver.factorize(&sym, &k, Regularization::default()).unwrap();
486/// assert_eq!(inertia, form.expected_inertia(3)); // (S6): 1 positive, 2 negative
487/// let b = [1.0, 0.0, 3.0];
488/// let mut x = [0.0; 3];
489/// let report = solver.solve_refined(&num, &k, &b, &mut x, Refinement::default());
490/// assert!(report.converged && report.residual <= 1e-12);
491/// # }
492/// ```
493pub trait KktSolver<T: Real> {
494 /// The result of the symbolic phase.
495 type Symbolic;
496 /// The result of the numeric phase.
497 type Numeric;
498
499 /// Symbolic phase on the lower triangle of `pattern` (plus a full
500 /// diagonal) with the given ordering; done once per pattern.
501 fn analyze(
502 &self,
503 pattern: &Pattern,
504 form: KktForm,
505 ordering: &dyn Ordering,
506 ) -> Result<Self::Symbolic, KktError>;
507
508 /// Numeric phase on the lower triangle of `k` (inside the analyzed
509 /// pattern) with regularization (Algorithm K); returns the factor and
510 /// its inertia (S6).
511 fn factorize(
512 &self,
513 symbolic: &Self::Symbolic,
514 k: &CscMatrix<T>,
515 reg: Regularization<T>,
516 ) -> Result<(Self::Numeric, Inertia), KktError>;
517
518 /// `x ← F⁻¹ x` in place, dense, with `F` the regularized factor.
519 ///
520 /// # Panics
521 /// If `x.len()` is not the order of the factor.
522 fn solve(&self, numeric: &Self::Numeric, x: &mut [T]);
523
524 /// Solve followed by iterative refinement against `k` (Algorithm F);
525 /// keeps the best iterate (S7). `k` is the matrix to be solved — the
526 /// unregularized one the factor approximates, or any other with the
527 /// same order; only its lower triangle is read. `x` is overwritten.
528 /// Allocates its work vectors; [`KktSolver::solve_refined_with`]
529 /// reuses them.
530 ///
531 /// # Panics
532 /// If `b`, `x` and `k` do not have the order of the factor.
533 fn solve_refined(
534 &self,
535 numeric: &Self::Numeric,
536 k: &CscMatrix<T>,
537 b: &[T],
538 x: &mut [T],
539 refinement: Refinement<T>,
540 ) -> RefineReport<T> {
541 let mut work = RefineWorkspace::new();
542 self.solve_refined_with(numeric, k, b, x, refinement, &mut work)
543 }
544
545 /// [`KktSolver::solve_refined`] with caller-owned work vectors: once
546 /// `work` has served a call with the same order and budget, the
547 /// refinement allocates nothing.
548 ///
549 /// # Panics
550 /// If `b`, `x` and `k` do not have the order of the factor.
551 #[allow(clippy::too_many_lines)] // one step of Algorithm F per block, as in the chapter
552 fn solve_refined_with(
553 &self,
554 numeric: &Self::Numeric,
555 k: &CscMatrix<T>,
556 b: &[T],
557 x: &mut [T],
558 refinement: Refinement<T>,
559 work: &mut RefineWorkspace<T>,
560 ) -> RefineReport<T> {
561 let n = b.len();
562 assert_eq!(x.len(), n, "solve_refined: x has the wrong length");
563 assert!(
564 k.n_rows == n && k.n_cols() == n,
565 "solve_refined: k has the wrong order"
566 );
567 let budget = refinement.max_iters;
568 work.reserve(n, budget);
569 let scale = T::ONE + inf_norm(b);
570 let tol = refinement.target_residual * scale;
571 // F1.
572 x.copy_from_slice(b);
573 self.solve(numeric, x);
574 let RefineWorkspace {
575 r,
576 cur,
577 v,
578 z,
579 h,
580 g,
581 cs,
582 sn,
583 y,
584 } = work;
585 residual(k, x, b, r);
586 let first = inf_norm(r) / scale;
587 let mut best = first;
588 let mut t = 0;
589 // `r` holds b − K x* at the top of every cycle.
590 loop {
591 // F2.
592 if !best.is_finite() || le(best, refinement.target_residual, T::ZERO) || t >= budget {
593 break;
594 }
595 // F3.
596 let m = budget - t;
597 let beta = norm2(r);
598 if !beta.is_finite() || is_exact_zero(beta) {
599 break;
600 }
601 for (vi, &ri) in v[..n].iter_mut().zip(r.iter()) {
602 *vi = ri / beta;
603 }
604 g.fill(T::ZERO);
605 g[0] = beta;
606 let mut steps = 0;
607 for j in 0..m {
608 let (zj, vj) = (&mut z[j * n..(j + 1) * n], &v[j * n..(j + 1) * n]);
609 zj.copy_from_slice(vj);
610 self.solve(numeric, zj);
611 t += 1;
612 steps = j + 1;
613 let (done, rest) = v.split_at_mut((j + 1) * n);
614 let w = &mut rest[..n];
615 sym_lower_matvec(k, &z[j * n..(j + 1) * n], w);
616 // Column j of H, (m + 1) rows, column-major.
617 let col = &mut h[j * (m + 1)..(j + 1) * (m + 1)];
618 for i in 0..=j {
619 let vi = &done[i * n..(i + 1) * n];
620 let hij = dot(w, vi);
621 col[i] = hij;
622 for (wk, &vk) in w.iter_mut().zip(vi) {
623 *wk -= hij * vk;
624 }
625 }
626 let hnext = norm2(w);
627 col[j + 1] = hnext;
628 if !hnext.is_finite() {
629 steps = j;
630 break;
631 }
632 // Previous rotations on column j.
633 for i in 0..j {
634 let (a, c) = (col[i], col[i + 1]);
635 col[i] = cs[i] * a + sn[i] * c;
636 col[i + 1] = cs[i] * c - sn[i] * a;
637 }
638 // New rotation annihilating h_{j+1,j}.
639 let (a, c) = (col[j], col[j + 1]);
640 let rr = norm2(&[a, c]);
641 if is_exact_zero(rr) {
642 // R singular in this column: drop the step.
643 steps = j;
644 break;
645 }
646 cs[j] = a / rr;
647 sn[j] = c / rr;
648 col[j] = rr;
649 col[j + 1] = T::ZERO;
650 g[j + 1] = -sn[j] * g[j];
651 g[j] *= cs[j];
652 if is_exact_zero(hnext) || le(g[j + 1].abs(), tol, T::ZERO) {
653 break;
654 }
655 for wk in w.iter_mut() {
656 *wk = *wk / hnext;
657 }
658 }
659 if steps == 0 {
660 break;
661 }
662 // F4: back substitution R y = g.
663 for i in (0..steps).rev() {
664 let mut acc = g[i];
665 for l in i + 1..steps {
666 acc -= h[l * (m + 1) + i] * y[l];
667 }
668 y[i] = acc / h[i * (m + 1) + i];
669 }
670 cur.copy_from_slice(x);
671 for (l, &yl) in y[..steps].iter().enumerate() {
672 for (c, &zl) in cur.iter_mut().zip(&z[l * n..(l + 1) * n]) {
673 *c += yl * zl;
674 }
675 }
676 residual(k, cur, b, r);
677 let res = inf_norm(r) / scale;
678 if res.is_finite() && lt(res, best) {
679 // `r` is the residual of the new x*.
680 x.copy_from_slice(cur);
681 best = res;
682 } else {
683 // F5 would restart from the same x*, whose cycle is the
684 // one just done (deterministic): stop.
685 break;
686 }
687 }
688 // F6.
689 debug_assert!(
690 !best.is_finite() || !first.is_finite() || le(best, first, T::ZERO),
691 "(S7): the returned residual exceeds the first one"
692 );
693 RefineReport {
694 iters: t,
695 residual: best,
696 converged: best.is_finite() && le(best, refinement.target_residual, T::ZERO),
697 }
698 }
699
700 /// Nonzeros of the factor (for the IPM log).
701 fn nnz_factor(&self, numeric: &Self::Numeric) -> usize;
702
703 /// A short name for logs and tables.
704 fn name(&self) -> &'static str;
705}
706
707/// `‖v‖_∞` (`0` for an empty vector; NaN if an entry is NaN — `max`
708/// alone would hide it).
709pub(crate) fn inf_norm<T: Real>(v: &[T]) -> T {
710 let mut m = T::ZERO;
711 for &x in v {
712 if is_nan(x) {
713 return x;
714 }
715 m = m.max(x.abs());
716 }
717 m
718}
719
720/// `‖v‖₂`, scaled against overflow (NaN propagates).
721fn norm2<T: Real>(v: &[T]) -> T {
722 let big = inf_norm(v);
723 if !big.is_finite() || is_exact_zero(big) {
724 return big;
725 }
726 let mut s = T::ZERO;
727 for &x in v {
728 let q = x / big;
729 s += q * q;
730 }
731 big * s.sqrt()
732}
733
734/// `⟨u, v⟩`.
735fn dot<T: Real>(u: &[T], v: &[T]) -> T {
736 let mut s = T::ZERO;
737 for (&a, &b) in u.iter().zip(v) {
738 s += a * b;
739 }
740 s
741}
742
743/// Work vectors of Algorithm F, reusable across calls of
744/// [`KktSolver::solve_refined_with`]: `(2m + 3) N + O(m²)` scalars for a
745/// budget of `m = max_iters` solves and order `N`. Grows on demand, never
746/// shrinks.
747#[derive(Clone, Debug, Default)]
748pub struct RefineWorkspace<T: Real = f64> {
749 r: Vec<T>,
750 cur: Vec<T>,
751 v: Vec<T>,
752 z: Vec<T>,
753 h: Vec<T>,
754 g: Vec<T>,
755 cs: Vec<T>,
756 sn: Vec<T>,
757 y: Vec<T>,
758}
759
760impl<T: Real> RefineWorkspace<T> {
761 /// An empty workspace (it grows at the first call).
762 #[must_use]
763 pub fn new() -> Self {
764 Self {
765 r: Vec::new(),
766 cur: Vec::new(),
767 v: Vec::new(),
768 z: Vec::new(),
769 h: Vec::new(),
770 g: Vec::new(),
771 cs: Vec::new(),
772 sn: Vec::new(),
773 y: Vec::new(),
774 }
775 }
776
777 /// Sizes the vectors for order `n` and budget `m` (allocates only when
778 /// they grow).
779 fn reserve(&mut self, n: usize, m: usize) {
780 fn fit<T: Real>(v: &mut Vec<T>, len: usize) {
781 if v.len() != len {
782 v.resize(len, T::ZERO);
783 }
784 }
785 fit(&mut self.r, n);
786 fit(&mut self.cur, n);
787 fit(&mut self.v, (m + 1) * n);
788 fit(&mut self.z, m * n);
789 fit(&mut self.h, (m + 1) * m);
790 fit(&mut self.g, m + 1);
791 fit(&mut self.cs, m);
792 fit(&mut self.sn, m);
793 fit(&mut self.y, m);
794 }
795}
796
797/// `y ← K x` with `K` given by the lower triangle of `k` (entries above
798/// the diagonal ignored).
799pub(crate) fn sym_lower_matvec<T: Real>(k: &CscMatrix<T>, x: &[T], y: &mut [T]) {
800 y.fill(T::ZERO);
801 for (j, &xj) in x.iter().enumerate() {
802 for p in k.col_ptr[j]..k.col_ptr[j + 1] {
803 let i = k.row_idx[p] as usize;
804 if i < j {
805 continue;
806 }
807 let v = k.val[p];
808 y[i] += v * xj;
809 if i != j {
810 y[j] += v * x[i];
811 }
812 }
813 }
814}
815
816/// `r ← b − K x` (lower triangle of `k`).
817fn residual<T: Real>(k: &CscMatrix<T>, x: &[T], b: &[T], r: &mut [T]) {
818 sym_lower_matvec(k, x, r);
819 for (ri, &bi) in r.iter_mut().zip(b) {
820 *ri = bi - *ri;
821 }
822}
823
824/// The lower triangle of a symmetric pattern plus a full diagonal, as the
825/// symbolic phase of every backend consumes it (step K1 for the pattern,
826/// invariant (K1)). The order `N` is checked against the form.
827#[cfg_attr(not(feature = "faer"), allow(dead_code))] // used by every backend
828pub(crate) fn lower_with_diagonal(pattern: &Pattern, form: KktForm) -> Result<Pattern, KktError> {
829 pattern.check_invariants()?;
830 let n = pattern.n_cols();
831 if pattern.n_rows != n {
832 return Err(KktError::NotSymmetric);
833 }
834 if let KktForm::Augmented { n_primal, n_dual } = form
835 && n_primal + n_dual != n
836 {
837 return Err(KktError::Structure(SparseError::Dimension {
838 what: "KKT order (n_primal + n_dual)",
839 expected: n_primal + n_dual,
840 found: n,
841 }));
842 }
843 let has_upper = (0..n).any(|j| pattern.col(j).first().is_some_and(|&i| (i as usize) < j));
844 if has_upper && pattern.transpose() != *pattern {
845 return Err(KktError::NotSymmetric);
846 }
847 let mut col_ptr = Vec::with_capacity(n + 1);
848 let mut row_idx = Vec::with_capacity(pattern.nnz() / 2 + n);
849 col_ptr.push(0);
850 for j in 0..n {
851 let rows = pattern.col(j);
852 let start = rows.partition_point(|&i| (i as usize) < j);
853 let below = &rows[start..];
854 if below.first() != Some(&crate::perm::idx32(j)) {
855 row_idx.push(crate::perm::idx32(j));
856 }
857 row_idx.extend_from_slice(below);
858 col_ptr.push(row_idx.len());
859 }
860 let l = Pattern {
861 n_rows: n,
862 col_ptr,
863 row_idx,
864 };
865 debug_assert_eq!(l.check_invariants(), Ok(()));
866 Ok(l)
867}
868
869/// The regularization is usable: `ρ, δ, β ≥ 0` finite, and with dynamic
870/// regularization `τ > 0` and `β > 0`.
871#[cfg_attr(not(feature = "faer"), allow(dead_code))] // used by every backend
872pub(crate) fn check_regularization<T: Real>(reg: &Regularization<T>) -> Result<(), KktError> {
873 for (what, x) in [
874 ("primal regularization", reg.primal),
875 ("dual regularization", reg.dual),
876 ("bump", reg.bump),
877 ] {
878 if !x.is_finite() || lt(x, T::ZERO) {
879 return Err(KktError::Structure(SparseError::InvalidValue {
880 what,
881 index: 0,
882 value: x.to_f64(),
883 }));
884 }
885 }
886 if let Some(tau) = reg.pivot_tol
887 && (!tau.is_finite() || !lt(T::ZERO, tau) || !lt(T::ZERO, reg.bump))
888 {
889 return Err(KktError::Structure(SparseError::InvalidValue {
890 what: "pivot_tol and bump (both must be positive)",
891 index: 0,
892 value: tau.to_f64(),
893 }));
894 }
895 Ok(())
896}
897
898/// The pivot signs `σ_k` of step K3: `−1` on the primal rows, `+1` on the
899/// dual rows (all `+1` for the normal equations).
900#[cfg_attr(not(feature = "faer"), allow(dead_code))] // used by every backend
901pub(crate) fn pivot_signs(form: KktForm, n: usize) -> Vec<i8> {
902 match form {
903 KktForm::NormalEquations => vec![1; n],
904 KktForm::Augmented { n_primal, .. } => {
905 (0..n).map(|k| if k < n_primal { -1 } else { 1 }).collect()
906 }
907 }
908}
909
910/// Steps K1′–K2″: scatters the lower triangle of `k` into the values of
911/// the analyzed lower pattern `l` (which has a full diagonal), equilibrates
912/// it (K2′) and adds the static shifts (K2″); returns the values of
913/// `K̃_reg` and the scaling `s`. Fails (K1, K1′) if `k` does not satisfy
914/// (S1), has the wrong order, has a lower entry outside `l`, or a
915/// non-finite lower entry.
916#[cfg_attr(not(feature = "faer"), allow(dead_code))] // used by every backend
917pub(crate) fn assemble_regularized<T: Real>(
918 l: &Pattern,
919 form: KktForm,
920 k: &CscMatrix<T>,
921 reg: &Regularization<T>,
922) -> Result<(Vec<T>, Vec<T>), KktError> {
923 let mut val = assemble(l, k)?;
924 let s = equilibrate_symmetric(l, &mut val);
925 for (j, &sj) in s.iter().enumerate() {
926 let shift = match form {
927 KktForm::NormalEquations => reg.dual,
928 KktForm::Augmented { n_primal, .. } => {
929 if j < n_primal {
930 -reg.primal
931 } else {
932 reg.dual
933 }
934 }
935 };
936 if !is_exact_zero(shift) {
937 // The diagonal is the first entry of column j of l.
938 let p = l.col_ptr[j];
939 val[p] += match reg.scale {
940 ShiftScale::Equilibrated => shift,
941 ShiftScale::Absolute => shift * sj * sj,
942 };
943 }
944 }
945 Ok((val, s))
946}
947
948/// Steps K1′–K2: the lower triangle of `k` on the analyzed pattern `l`.
949fn assemble<T: Real>(l: &Pattern, k: &CscMatrix<T>) -> Result<Vec<T>, KktError> {
950 k.check_invariants()?;
951 let n = l.n_cols();
952 if k.n_rows != n || k.n_cols() != n {
953 return Err(KktError::Structure(SparseError::Dimension {
954 what: "KKT matrix order (as analyzed)",
955 expected: n,
956 found: k.n_rows.max(k.n_cols()),
957 }));
958 }
959 let mut val = vec![T::ZERO; l.nnz()];
960 for j in 0..n {
961 let lrows = l.col(j);
962 let base = l.col_ptr[j];
963 let mut q = 0;
964 for p in k.col_ptr[j]..k.col_ptr[j + 1] {
965 let i = k.row_idx[p];
966 if (i as usize) < j {
967 continue;
968 }
969 // Both lists are increasing: a merge.
970 while q < lrows.len() && lrows[q] < i {
971 q += 1;
972 }
973 if q == lrows.len() || lrows[q] != i {
974 return Err(KktError::Structure(SparseError::Malformed(format!(
975 "KKT entry ({i}, {j}) is outside the analyzed pattern"
976 ))));
977 }
978 // K1′.
979 if !k.val[p].is_finite() {
980 return Err(KktError::Structure(SparseError::InvalidValue {
981 what: "KKT matrix entry (not finite)",
982 index: p,
983 value: k.val[p].to_f64(),
984 }));
985 }
986 val[base + q] = k.val[p];
987 }
988 // The diagonal is the first entry of column j of l.
989 debug_assert_eq!(lrows[0] as usize, j);
990 }
991 Ok(val)
992}
993
994/// Step K4 over a list of `1×1` pivots and `2×2` blocks `(a, b, c)` =
995/// `[a b; b c]`: the inertia by Sylvester's law.
996pub(crate) fn count_inertia<T: Real>(
997 ones: impl Iterator<Item = T>,
998 twos: impl Iterator<Item = (T, T, T)>,
999) -> Inertia {
1000 let mut s = Inertia::default();
1001 let mut one = |d: T| {
1002 if lt(T::ZERO, d) {
1003 s.positive += 1;
1004 } else if lt(d, T::ZERO) {
1005 s.negative += 1;
1006 } else {
1007 s.zero += 1;
1008 }
1009 };
1010 for d in ones {
1011 one(d);
1012 }
1013 for (a, b, c) in twos {
1014 // det < 0: one eigenvalue of each sign; det > 0: both of the sign
1015 // of the trace; det = 0: one zero, the other of the sign of the
1016 // trace.
1017 let det = a * c - b * b;
1018 if lt(det, T::ZERO) {
1019 one(T::ONE);
1020 one(-T::ONE);
1021 } else if lt(T::ZERO, det) {
1022 one(a + c);
1023 one(a + c);
1024 } else {
1025 one(T::ZERO);
1026 one(a + c);
1027 }
1028 }
1029 s
1030}
1031
1032/// Most passes of the symmetric equilibration (step K2′). A cap, not a
1033/// threshold: with power-of-two factors the iteration stops by itself
1034/// when no factor changes, which happens after a few passes in practice
1035/// (Ruiz 2001 reports convergence of the infinity-norm iteration to
1036/// within a factor 2 in a handful of steps).
1037pub const EQUILIBRATION_PASSES: usize = 20;
1038
1039/// Step K2′: symmetric equilibration of the lower pattern `l` with values
1040/// `val` (full diagonal present), in place; returns `s` (powers of two).
1041#[cfg_attr(not(feature = "faer"), allow(dead_code))] // used by every backend
1042pub(crate) fn equilibrate_symmetric<T: Real>(l: &Pattern, val: &mut [T]) -> Vec<T> {
1043 let n = l.n_cols();
1044 let mut s = vec![T::ONE; n];
1045 let mut rmax = vec![T::ZERO; n];
1046 let mut step = vec![T::ONE; n];
1047 for _ in 0..EQUILIBRATION_PASSES {
1048 rmax.fill(T::ZERO);
1049 for j in 0..n {
1050 let range = l.col_ptr[j]..l.col_ptr[j + 1];
1051 for (&i, &x) in l.row_idx[range.clone()].iter().zip(&val[range]) {
1052 let i = i as usize;
1053 let v = x.abs();
1054 rmax[i] = rmax[i].max(v);
1055 rmax[j] = rmax[j].max(v);
1056 }
1057 }
1058 let mut changed = false;
1059 for i in 0..n {
1060 step[i] = if rmax[i].is_finite() && lt(T::ZERO, rmax[i]) {
1061 pow2_round(rmax[i].sqrt().recip())
1062 } else {
1063 T::ONE
1064 };
1065 changed |= !is_exact_zero(step[i] - T::ONE);
1066 }
1067 if !changed {
1068 break;
1069 }
1070 for j in 0..n {
1071 let range = l.col_ptr[j]..l.col_ptr[j + 1];
1072 for (&i, x) in l.row_idx[range.clone()].iter().zip(&mut val[range]) {
1073 *x = *x * step[i as usize] * step[j];
1074 }
1075 }
1076 for (si, &t) in s.iter_mut().zip(&step) {
1077 *si *= t;
1078 }
1079 }
1080 s
1081}
1082
1083/// `⌊x⌉₂ = 2^{round(log₂ x)}` for a finite `x > 0`, computed in `f64`
1084/// (a power of two is exact in every `Real`).
1085fn pow2_round<T: Real>(x: T) -> T {
1086 T::from_f64(x.to_f64().log2().round().exp2())
1087}
1088
1089/// Step K4: the (S6) check.
1090#[cfg_attr(not(feature = "faer"), allow(dead_code))] // used by every backend
1091pub(crate) fn check_inertia(found: Inertia, expected: Inertia) -> Result<Inertia, KktError> {
1092 if found == expected {
1093 Ok(found)
1094 } else {
1095 Err(KktError::WrongInertia { found, expected })
1096 }
1097}
1098
1099#[cfg(test)]
1100// Exact equality is the property under test here (the assembly and the
1101// scaling are products and sums of powers of two, bit for bit); clippy 1.90
1102// flags it, 1.99 does not.
1103#[allow(clippy::float_cmp)]
1104mod tests {
1105 use super::*;
1106 use crate::{Col, Row};
1107
1108 #[test]
1109 fn lower_with_diagonal_accepts_lower_and_symmetric_patterns() {
1110 let lower = CscMatrix::from_triplets(3, 3, &[(Row(2), Col(0), 1.0), (Row(1), Col(1), 2.0)])
1111 .unwrap();
1112 let l = lower_with_diagonal(&lower.pattern(), KktForm::NormalEquations).unwrap();
1113 assert_eq!(l.col_ptr, vec![0, 2, 3, 4]);
1114 assert_eq!(l.row_idx, vec![0, 2, 1, 2]);
1115 let full = CscMatrix::from_triplets(3, 3, &[(Row(2), Col(0), 1.0), (Row(0), Col(2), 1.0)])
1116 .unwrap();
1117 assert_eq!(
1118 lower_with_diagonal(&full.pattern(), KktForm::NormalEquations).unwrap(),
1119 l.clone()
1120 );
1121 let skew = CscMatrix::from_triplets(3, 3, &[(Row(2), Col(0), 1.0), (Row(0), Col(1), 1.0)])
1122 .unwrap();
1123 assert_eq!(
1124 lower_with_diagonal(&skew.pattern(), KktForm::NormalEquations),
1125 Err(KktError::NotSymmetric)
1126 );
1127 let rect = CscMatrix::<f64>::zeros(2, 3);
1128 assert_eq!(
1129 lower_with_diagonal(&rect.pattern(), KktForm::NormalEquations),
1130 Err(KktError::NotSymmetric)
1131 );
1132 assert!(matches!(
1133 lower_with_diagonal(
1134 &lower.pattern(),
1135 KktForm::Augmented {
1136 n_primal: 1,
1137 n_dual: 1
1138 }
1139 ),
1140 Err(KktError::Structure(_))
1141 ));
1142 }
1143
1144 #[test]
1145 fn inertia_counts_follow_sylvester() {
1146 let s = count_inertia(
1147 [1.0, -2.0, 0.0].into_iter(),
1148 [(0.0, 1.0, 0.0), (2.0, 1.0, 3.0), (-1.0, 0.5, -1.0)].into_iter(),
1149 );
1150 assert_eq!(
1151 s,
1152 Inertia {
1153 positive: 4,
1154 negative: 4,
1155 zero: 1
1156 }
1157 );
1158 let expected = KktForm::Augmented {
1159 n_primal: 2,
1160 n_dual: 3,
1161 }
1162 .expected_inertia(5);
1163 assert_eq!(expected.positive, 3);
1164 assert_eq!(expected.negative, 2);
1165 assert!(check_inertia(s, expected).is_err());
1166 }
1167
1168 #[test]
1169 fn assembly_adds_the_shifts_and_rejects_foreign_entries() {
1170 let k = CscMatrix::from_triplets(
1171 3,
1172 3,
1173 &[
1174 (Row(0), Col(0), -1.0),
1175 (Row(2), Col(0), 1.0),
1176 (Row(1), Col(1), -2.0),
1177 ],
1178 )
1179 .unwrap();
1180 let form = KktForm::Augmented {
1181 n_primal: 2,
1182 n_dual: 1,
1183 };
1184 let l = lower_with_diagonal(&k.pattern(), form).unwrap();
1185 let mut reg = Regularization {
1186 primal: 0.5,
1187 dual: 0.25,
1188 pivot_tol: None,
1189 bump: 0.0,
1190 scale: ShiftScale::Absolute,
1191 };
1192 // S⁻¹ K̃_reg S⁻¹ = K + Σ exactly (powers of two), whatever S.
1193 let unscale = |(v, s): (Vec<f64>, Vec<f64>)| -> Vec<f64> {
1194 let mut u = Vec::new();
1195 for j in 0..l.n_cols() {
1196 for p in l.col_ptr[j]..l.col_ptr[j + 1] {
1197 u.push(v[p] / (s[l.row_idx[p] as usize] * s[j]));
1198 }
1199 }
1200 u
1201 };
1202 let v = assemble_regularized(&l, form, &k, ®).unwrap();
1203 assert!(v.1.iter().all(|&x| x > 0.0 && x.log2().fract() == 0.0));
1204 assert_eq!(unscale(v), vec![-1.5, 1.0, -2.5, 0.25]);
1205 // Equilibrated: K + S⁻¹ Σ S⁻¹.
1206 reg.scale = ShiftScale::Equilibrated;
1207 let (v, s) = assemble_regularized(&l, form, &k, ®).unwrap();
1208 let u = unscale((v, s.clone()));
1209 assert_eq!(u[0], -1.0 - 0.5 / (s[0] * s[0]));
1210 assert_eq!(u[1], 1.0);
1211 assert_eq!(u[2], -2.0 - 0.5 / (s[1] * s[1]));
1212 assert_eq!(u[3], 0.25 / (s[2] * s[2]));
1213 // A non-finite entry is refused (K1′).
1214 let mut nan = k.clone();
1215 nan.val[1] = f64::NAN;
1216 assert!(matches!(
1217 assemble_regularized(&l, form, &nan, ®),
1218 Err(KktError::Structure(_))
1219 ));
1220 let other = CscMatrix::from_triplets(3, 3, &[(Row(2), Col(1), 1.0)]).unwrap();
1221 assert!(matches!(
1222 assemble_regularized(&l, form, &other, ®),
1223 Err(KktError::Structure(_))
1224 ));
1225 }
1226
1227 #[test]
1228 fn symmetric_matvec_reads_the_lower_triangle_only() {
1229 let k = CscMatrix::from_triplets(
1230 2,
1231 2,
1232 &[
1233 (Row(0), Col(0), 2.0),
1234 (Row(1), Col(0), 3.0),
1235 (Row(0), Col(1), 99.0),
1236 (Row(1), Col(1), 4.0),
1237 ],
1238 )
1239 .unwrap();
1240 let mut y = [0.0; 2];
1241 sym_lower_matvec(&k, &[1.0, 1.0], &mut y);
1242 assert_eq!(y, [5.0, 7.0]);
1243 }
1244}