pounce_algorithm/kkt/aug_system_solver.rs
1//! Augmented-system solver trait — port of `IpAugSystemSolver.hpp`.
2//!
3//! Solves the symmetric saddle-point system
4//!
5//! ```text
6//! [ W·factor + Σ_x + δ_x I 0 J_c^T J_d^T ] [ dx ] [ rx ]
7//! [ 0 Σ_s + δ_s I 0 -I ] [ ds ] = [ rs ]
8//! [ J_c 0 -Σ_c-δ_c 0 ] [ dyc] [ rc ]
9//! [ J_d -I 0 -Σ_d-δ_d] [ dyd] [ rd ]
10//! ```
11//!
12//! See `KKT_SYSTEM.md` §3 for the sign convention. `Σ_x = D_x`, `Σ_s
13//! = D_s`, `Σ_c = D_c`, `Σ_d = D_d` are the diagonal weights pulled
14//! from `IpoptCalculatedQuantities`. Any of the `D_*` may be `None`,
15//! interpreted as zero. `delta_*` are the perturbations driven by the
16//! `PerturbationHandler`.
17
18use pounce_common::timing::TimingStatistics;
19use pounce_common::types::{Index, Number};
20use pounce_linalg::{Matrix, SymMatrix, Vector};
21use pounce_linsol::{ESymSolverStatus, FactorPattern};
22use std::rc::Rc;
23
24/// Bundle of the matrices/vectors that define one augmented-system
25/// instance. Lives only for the duration of the call. Mirrors the
26/// long argument list of upstream `AugSystemSolver::Solve`.
27pub struct AugSysCoeffs<'a> {
28 /// Hessian-of-Lagrangian block. `None` means W = 0 (used by
29 /// `LeastSquareMults` and the resto-NLP equality multiplier
30 /// estimate).
31 pub w: Option<&'a dyn SymMatrix>,
32 /// Multiplier on `W` (typically 1.0; restoration uses ζ).
33 pub w_factor: Number,
34 /// `D_x`, the (1,1) primal weight diagonal. `None` means zero.
35 pub d_x: Option<&'a dyn Vector>,
36 pub delta_x: Number,
37 /// `D_s`, the (2,2) slack weight diagonal. `None` means zero.
38 pub d_s: Option<&'a dyn Vector>,
39 pub delta_s: Number,
40 /// Equality-constraint Jacobian, `m_c × n_x`.
41 pub j_c: &'a dyn Matrix,
42 /// `D_c`, the (3,3) diagonal weight. `None` means zero. Goes in
43 /// with a *negative* sign, matching upstream.
44 pub d_c: Option<&'a dyn Vector>,
45 pub delta_c: Number,
46 /// Inequality-constraint Jacobian, `m_d × n_x`.
47 pub j_d: &'a dyn Matrix,
48 /// `D_d`, the (4,4) diagonal weight. `None` means zero. Goes in
49 /// with a *negative* sign, matching upstream.
50 pub d_d: Option<&'a dyn Vector>,
51 pub delta_d: Number,
52}
53
54/// Right-hand sides for one solve. All four slices are required;
55/// upstream always provides all four (even if some are zero).
56pub struct AugSysRhs<'a> {
57 pub rhs_x: &'a dyn Vector,
58 pub rhs_s: &'a dyn Vector,
59 pub rhs_c: &'a dyn Vector,
60 pub rhs_d: &'a dyn Vector,
61}
62
63/// Solution slots, written in place. Must already be sized to match
64/// the corresponding RHS dim.
65pub struct AugSysSol<'a> {
66 pub sol_x: &'a mut dyn Vector,
67 pub sol_s: &'a mut dyn Vector,
68 pub sol_c: &'a mut dyn Vector,
69 pub sol_d: &'a mut dyn Vector,
70}
71
72/// Trait surface mirroring `Ipopt::AugSystemSolver`.
73pub trait AugSystemSolver {
74 /// Whether the underlying linear solver reports inertia.
75 fn provides_inertia(&self) -> bool;
76
77 /// gh#981: whether the most recent `Singular` from `solve` is a rank
78 /// deficiency the linear solver measured (a zero pivot) rather than an
79 /// inertia count it declined to trust. Read right after a `Singular`
80 /// return; meaningless otherwise. Defaults to `false`.
81 fn singularity_certified(&self) -> bool {
82 false
83 }
84
85 /// Number of negative eigenvalues observed in the most recent
86 /// factorization. Caller checks `provides_inertia()` first.
87 fn number_of_neg_evals(&self) -> Index;
88
89 /// Dimension of the assembled augmented (KKT) system. Used by the
90 /// interactive debugger to report inertia; default 0 for backends
91 /// that don't track it.
92 fn system_dim(&self) -> Index {
93 0
94 }
95
96 /// Triplets of the assembled KKT matrix `(dim, irn, jcn, vals)`
97 /// (1-based lower triangle), for the debugger's `viz kkt`. Default
98 /// `None` for backends that don't expose them.
99 fn kkt_triplets(&self) -> Option<(Index, Vec<Index>, Vec<Index>, Vec<Number>)> {
100 None
101 }
102
103 /// The `LDLᵀ` factor pattern (and optionally values) of the most
104 /// recent factorization, for the debugger's `viz L`. Default `None`.
105 fn l_factor(&self, _want_values: bool) -> Option<FactorPattern> {
106 None
107 }
108
109 /// Ask the underlying solver for higher-quality pivoting.
110 fn increase_quality(&mut self) -> bool;
111
112 /// Status of the most recent `solve` call.
113 fn last_solve_status(&self) -> ESymSolverStatus;
114
115 /// Install the shared per-solve `TimingStatistics` so the
116 /// linear-system factor/back-solve calls are attributed to
117 /// `linear_system_factorization` / `linear_system_back_solve`.
118 /// Default impl is a no-op (timing disabled); the standard
119 /// solver overrides to record both fields, and composite solvers
120 /// (LowRank) forward to their inner solver.
121 fn set_timing_stats(&mut self, _timing: Rc<TimingStatistics>) {}
122
123 /// Install the shared per-solve diagnostics state so KKT-dump
124 /// sites can consult per-iter gating. Default impl is a no-op
125 /// (diagnostics disabled); the standard solver overrides to wire
126 /// in the dump path.
127 fn set_diagnostics(&mut self, _diag: Rc<pounce_common::diagnostics::DiagnosticsState>) {}
128
129 /// Push the per-iterate part of `linear_system_scaling=slack-based`
130 /// down to the linear solver's scaling method. Default no-op;
131 /// `StdAugSystemSolver` forwards to its `TSymLinearSolver`, and
132 /// composite solvers forward to their inner solver.
133 ///
134 /// Called once per iteration rather than once per solve: the
135 /// quantity is a function of the iterate and several augmented
136 /// solves share one. See `IpoptCq::curr_slack_based_s_scaling`.
137 fn set_slack_scaling(&mut self, _nx: Index, _s_scale: &[Number]) {}
138
139 /// Whether this solver can consume a `LowRankUpdateSymMatrix` as
140 /// `coeffs.w` directly, applying it by Sherman-Morrison-Woodbury
141 /// rather than needing an assembled triplet matrix.
142 ///
143 /// Default `false`: a triplet-based backend must be handed triplets.
144 /// `LowRankAugSystemSolver` overrides it, and composite solvers
145 /// forward their inner solver's answer.
146 ///
147 /// Exists so restoration can ask before choosing between handing its
148 /// orig block over in factored form and densifying it — the dense
149 /// form is `O(n²)` and aborts the process at 60k variables (#684).
150 fn handles_low_rank_w(&self) -> bool {
151 false
152 }
153
154 /// One factor + back-substitution for the full 4×4 block system.
155 /// `check_neg_evals=true` asks the linsol to verify that the
156 /// observed inertia equals `num_neg_evals`; on mismatch the
157 /// status is `WrongInertia` and the solution is left untouched.
158 fn solve(
159 &mut self,
160 coeffs: &AugSysCoeffs<'_>,
161 rhs: &AugSysRhs<'_>,
162 sol: &mut AugSysSol<'_>,
163 check_neg_evals: bool,
164 num_neg_evals: Index,
165 ) -> ESymSolverStatus;
166
167 /// Back-substitution only, reusing the factorization from the most
168 /// recent successful `solve`. Caller must guarantee the augmented
169 /// matrix is byte-identical to that solve (same W, J_c, J_d, all
170 /// diagonals, all perturbations, same pivot tolerance). Used by
171 /// `PdFullSpaceSolver`'s iterative-refinement loop and same-matrix
172 /// fast path to avoid the per-iter MA57BD refactor that dominates
173 /// pounce-ma57 wall time on long-iter problems (e.g. cont5_2_4_l
174 /// drops from 97s → ~30s once refactor-per-refinement is gone).
175 ///
176 /// Default impl falls through to `solve` (correct but slow);
177 /// `StdAugSystemSolver` overrides to skip `refill_values` and pass
178 /// `new_matrix=false` to the linear solver.
179 fn resolve(
180 &mut self,
181 coeffs: &AugSysCoeffs<'_>,
182 rhs: &AugSysRhs<'_>,
183 sol: &mut AugSysSol<'_>,
184 ) -> ESymSolverStatus {
185 self.solve(coeffs, rhs, sol, false, 0)
186 }
187
188 /// Solve the same KKT system for `nrhs` right-hand sides. Default
189 /// impl loops [`solve`]; concrete backends override only when they
190 /// can amortize factorization across calls. Mirrors upstream's
191 /// `AugSystemSolver::MultiSolve` (`IpAugSystemSolver.hpp:113-150`).
192 ///
193 /// `rhs_list` and `sol_list` must have the same length; each pair
194 /// describes one independent solve. The same `coeffs` are used for
195 /// every column.
196 fn multi_solve(
197 &mut self,
198 coeffs: &AugSysCoeffs<'_>,
199 rhs_list: &[&AugSysRhs<'_>],
200 sol_list: &mut [&mut AugSysSol<'_>],
201 check_neg_evals: bool,
202 num_neg_evals: Index,
203 ) -> ESymSolverStatus {
204 debug_assert_eq!(rhs_list.len(), sol_list.len());
205 for (rhs, sol) in rhs_list.iter().zip(sol_list.iter_mut()) {
206 let status = self.solve(coeffs, rhs, *sol, check_neg_evals, num_neg_evals);
207 if status != ESymSolverStatus::Success {
208 return status;
209 }
210 }
211 ESymSolverStatus::Success
212 }
213
214 /// Back-substitution only against the cached factor for
215 /// `nrhs` right-hand sides, packed in **column-major** layout in
216 /// `packed_rhs`. Each column has length `dim = n_x + n_s + n_y_c +
217 /// n_y_d` (the aug-system dim — z/v blocks are not part of this
218 /// path; callers expand them via `expand_bound_multipliers` after
219 /// the fact). Solutions overwrite `packed_rhs` in place.
220 ///
221 /// Returns `None` when the backend does not support this fast
222 /// path; the caller should then fall back to a per-RHS loop over
223 /// [`resolve`]. The contract on `coeffs` and `have_factor` matches
224 /// [`resolve`]'s.
225 ///
226 /// `StdAugSystemSolver` overrides this to forward to
227 /// `pounce_linsol::TSymLinearSolver::multi_solve` with `nrhs > 1`,
228 /// which lets the underlying backend (FERAL / MA57 / LAPACK)
229 /// amortize per-call setup and, where supported, block the
230 /// triangular solves. Used by `pounce-sensitivity` for the JaxProblem
231 /// `jacrev` backward, where every cotangent re-solves against the
232 /// same converged factor (pounce#77 follow-up).
233 fn try_resolve_many_flat(
234 &mut self,
235 _coeffs: &AugSysCoeffs<'_>,
236 _packed_rhs: &mut [Number],
237 _nrhs: usize,
238 ) -> Option<ESymSolverStatus> {
239 None
240 }
241
242 /// Factorize **and** solve `nrhs` right-hand sides in a single
243 /// backend call, with `packed_rhs` in the same column-major layout
244 /// [`Self::try_resolve_many_flat`] uses. Solutions overwrite
245 /// `packed_rhs` in place.
246 ///
247 /// This is the factorizing counterpart of `try_resolve_many_flat`,
248 /// and it exists to reproduce upstream's single
249 /// `AugSystemSolver::MultiSolve` (`IpLowRankAugSystemSolver.cpp:487`)
250 /// in one step rather than two. Splitting that call into a
251 /// single-RHS `solve` followed by a batched `try_resolve_many_flat`
252 /// costs one **extra full traversal of the factor**: a sparse
253 /// triangular solve streams the whole factor once per call and then
254 /// applies it to every column, so its cost is `F + nrhs·W` with `F`
255 /// several times `W` on a large KKT. Merging the two calls removes
256 /// one `F` per Sherman-Morrison-Woodbury update.
257 ///
258 /// Returns `None` when the backend does not support the path, in
259 /// which case the caller keeps the split. Inertia bookkeeping
260 /// (`check_neg_evals` / `num_neg_evals`) matches [`Self::solve`].
261 fn try_solve_many_flat(
262 &mut self,
263 _coeffs: &AugSysCoeffs<'_>,
264 _packed_rhs: &mut [Number],
265 _nrhs: usize,
266 _check_neg_evals: bool,
267 _num_neg_evals: Index,
268 ) -> Option<ESymSolverStatus> {
269 None
270 }
271
272 /// Whether [`Self::try_resolve_many_flat`] with `nrhs` columns returns
273 /// **bit-identical** results to `nrhs` separate [`Self::resolve`] calls.
274 ///
275 /// Consulted only by callers that batch as a pure optimization inside an
276 /// iteration whose trajectory must not move — today that is
277 /// `LowRankAugSystemSolver`'s SMW correction block (gh#729). A caller
278 /// batching independent questions (`pounce-sensitivity`'s `jacrev`
279 /// backward) has no such constraint and does not ask.
280 ///
281 /// Defaults to `false`, the conservative answer. `StdAugSystemSolver`
282 /// forwards it to the linear-solver backend; see
283 /// `SparseSymLinearSolverInterface::multi_solve_matches_single_solve`
284 /// for why the answer depends on `nrhs`.
285 fn multi_solve_matches_single_solve(&self, _nrhs: usize) -> bool {
286 false
287 }
288}