pounce_sens_core/backsolver.rs
1//! `SensBacksolver` trait — abstract backsolver against a converged KKT factor.
2//!
3//! Mirrors upstream
4//! [`SensBacksolver.hpp`](../../../ref/Ipopt/contrib/sIPOPT/src/SensBacksolver.hpp).
5//!
6//! # What this is
7//!
8//! After pounce's IPM converges, the KKT factor `K` is stored on the
9//! algorithm side (`pounce-algorithm::kkt::PdFullSpaceSolver`).
10//! sIPOPT runs a small number of backsolves against that factor to
11//! build the sensitivity matrix `P = K⁻¹ A` (see
12//! [`crate::p_calculator::PCalculator`]). The trait surface is just
13//! "solve `K · lhs = rhs`"; concrete impls plug in either the real
14//! `AugSystemSolver` (Phase B.2) or a synthetic dense-LU backsolver
15//! (Phase B.1, this file, for unit testing the sensitivity math).
16//!
17//! Upstream `SensSimpleBacksolver` is the analogous wrapper around
18//! Ipopt's `AugSystemSolver`
19//! ([`SensSimpleBacksolver.{hpp,cpp}`](../../../ref/Ipopt/contrib/sIPOPT/src/SensSimpleBacksolver.hpp)).
20
21use pounce_common::types::Number;
22
23/// Solve `K · lhs = rhs` against the converged KKT factor. Returns
24/// `false` on failure (e.g. backend reports `Singular`).
25///
26/// Mirrors `Ipopt::SensBacksolver::Solve`
27/// ([`SensBacksolver.hpp:28-31`](../../../ref/Ipopt/contrib/sIPOPT/src/SensBacksolver.hpp)).
28/// Pounce takes flat `&[Number]` / `&mut [Number]` rather than
29/// upstream's block-structured `IteratesVector` because the
30/// sensitivity-side data is naturally flat; if the algorithm-side
31/// wrapper in Phase B.2 needs the block layout it converts before
32/// calling.
33pub trait SensBacksolver {
34 /// Size of the linear system in entries (length of `lhs` and
35 /// `rhs`). The backsolver's notion of "full state" — pounce's
36 /// IPM uses the compound `(x, s, λ_c, λ_d, z_l, z_u, v_l, v_u)`
37 /// concatenation here.
38 fn dim(&self) -> usize;
39
40 /// Solve `K · lhs = rhs`. The implementation may use `rhs` as
41 /// scratch; callers should treat it as moved-from on return.
42 /// `lhs` must have length `self.dim()`.
43 ///
44 /// The solve has to be refined against the matrix, as Ipopt's and
45 /// pounce's are. At a converged barrier iterate a bound held on its
46 /// constraint gives `K` a diagonal entry many orders above the rest, and
47 /// the activity classification's reduced curvature is a difference taken
48 /// against that entry. An unrefined solve loses the digits that entry
49 /// costs. On a model whose diagonal reaches `1e11`, it kept two or three.
50 fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool;
51
52 /// [`Self::solve`] for `n_rhs` right-hand sides at once, each
53 /// `self.dim()` long and stored one after another in `rhs` and
54 /// `lhs`.
55 ///
56 /// The default solves them one at a time. An engine that can
57 /// back-solve several right-hand sides in one pass overrides it, and
58 /// the activity classification's per-coordinate refinement is where
59 /// that pays off.
60 fn solve_many(&self, rhs: &[Number], lhs: &mut [Number], n_rhs: usize) -> bool {
61 let dim = self.dim();
62 if rhs.len() != n_rhs * dim || lhs.len() != n_rhs * dim {
63 return false;
64 }
65 rhs.chunks(dim.max(1))
66 .zip(lhs.chunks_mut(dim.max(1)))
67 .take(n_rhs)
68 .all(|(r, l)| self.solve(r, l))
69 }
70
71 /// Per-row factor carrying a quantity read off the converged
72 /// iterate into the units [`Self::solve`] answers in, indexed by
73 /// compound KKT row. `None` when the solve ran unscaled, which is
74 /// the identity.
75 ///
76 /// This is the same `F` the natural-units back-solve applies to
77 /// its result, so a bound multiplier read raw off `curr.z_l` and
78 /// multiplied by `f[row]` agrees with the `z` rows of a step this
79 /// backsolver returns. The two disagree by `d/df` otherwise, and
80 /// mixing them puts a scaled right-hand side into a Schur
81 /// complement whose other rows are natural, which moves every
82 /// coordinate of the answer rather than one.
83 fn natural_units_factor(&self) -> Option<&[Number]> {
84 None
85 }
86
87 /// The variable behind each bound-multiplier row. `None` when the
88 /// backsolver cannot report it, which leaves
89 /// [`crate::boundcheck::refine_step_onto_bounds`] with no way to
90 /// release and so pinning only.
91 ///
92 /// A release re-factors with that variable's `sigma` removed, so it
93 /// needs to know which variable each row belongs to and which side
94 /// it bounds.
95 fn bound_rows(&self) -> Option<&[BoundRow]> {
96 None
97 }
98
99 /// Solve against the system with the bounds named by `released` --
100 /// compound multiplier rows -- taken out of the active set.
101 ///
102 /// This re-factors, and has to. An active bound puts
103 /// `sigma = z / s` on the x diagonal, and on a tightly converged
104 /// bound that term is large enough to destroy the released system's
105 /// information in the converged factor: recovering it from there
106 /// needs the difference of two quantities agreeing to about
107 /// `eps * sigma`, so the released answer comes out *worse* the
108 /// better the solve converged. Re-factoring with the term removed
109 /// is what buys those digits back, and one factorization still sits
110 /// an order of magnitude under a re-solve.
111 ///
112 /// This solves in the released *system* and does nothing to the
113 /// right-hand side, so it is the right call for every solve the
114 /// refinement makes once a bound is out -- including the unit
115 /// vectors a Schur complement is built from, which carry no
116 /// multiplier to move.
117 ///
118 /// `false` by default, which leaves the refinement pinning only.
119 fn solve_released(&self, _released: &[usize], _rhs: &[Number], _lhs: &mut [Number]) -> bool {
120 false
121 }
122
123 /// [`Self::solve_released`] against a *parametric* right-hand side,
124 /// which additionally needs the released multipliers moved onto
125 /// their variables' x rows.
126 ///
127 /// Dropping `sigma` gives the released matrix; this gives the
128 /// released right-hand side to go with it. Only the step itself
129 /// gets this treatment -- applying it to a Schur complement's unit
130 /// vectors would shift a right-hand side that has no multiplier in
131 /// it to begin with.
132 fn solve_released_step(
133 &self,
134 _released: &[usize],
135 _rhs: &[Number],
136 _lhs: &mut [Number],
137 ) -> bool {
138 false
139 }
140
141 /// [`Self::solve_released`] with each **primal** KKT row in
142 /// `pinned` carrying an extra diagonal stiff enough to hold that
143 /// coordinate in place.
144 ///
145 /// This is not the pin the walk applies -- that one is a Schur row
146 /// on top of the factored system, and it is exact. This is the
147 /// *operator* that pin is applied to, for the case where the
148 /// released system on its own has no inverse for a Schur
149 /// complement to be built from: releasing a bound takes its
150 /// `Sigma` off the diagonal, and on a model with no curvature two
151 /// released variables sharing a constraint are left with linearly
152 /// dependent stationarity rows (gh#930).
153 ///
154 /// Adding the diagonal back regularizes exactly those rows, and
155 /// costs nothing in accuracy, because the Schur pin then holds the
156 /// same coordinates at zero: `Eᵀw = 0` annihilates the term that
157 /// was added, so the pinned system solved is the released one
158 /// after all. The diagonal has to be *reachable* -- large enough
159 /// that the regularized operator is invertible, small enough that
160 /// the row's own couplings survive it, which is the gh#737
161 /// ceiling.
162 ///
163 /// `false` by default, which leaves the caller reporting the
164 /// Schur pin's failure.
165 fn solve_released_pinned(
166 &self,
167 _released: &[usize],
168 _pinned: &[usize],
169 _rhs: &[Number],
170 _lhs: &mut [Number],
171 ) -> bool {
172 false
173 }
174
175 /// Whether [`Self::solve_released`] is implemented.
176 fn supports_release(&self) -> bool {
177 false
178 }
179}
180
181/// A borrowed engine is an engine, so the calculations that hold one by
182/// value, such as [`crate::sens_app::SensApplication`], take a reference to
183/// an engine the caller keeps. Every method forwards, the defaulted ones
184/// included, so a reference never answers with a default its engine
185/// overrides.
186impl<B: SensBacksolver + ?Sized> SensBacksolver for &B {
187 fn dim(&self) -> usize {
188 (**self).dim()
189 }
190 fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
191 (**self).solve(rhs, lhs)
192 }
193 fn solve_many(&self, rhs: &[Number], lhs: &mut [Number], n_rhs: usize) -> bool {
194 (**self).solve_many(rhs, lhs, n_rhs)
195 }
196 fn natural_units_factor(&self) -> Option<&[Number]> {
197 (**self).natural_units_factor()
198 }
199 fn bound_rows(&self) -> Option<&[BoundRow]> {
200 (**self).bound_rows()
201 }
202 fn solve_released(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
203 (**self).solve_released(released, rhs, lhs)
204 }
205 fn solve_released_step(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
206 (**self).solve_released_step(released, rhs, lhs)
207 }
208 fn solve_released_pinned(
209 &self,
210 released: &[usize],
211 pinned: &[usize],
212 rhs: &[Number],
213 lhs: &mut [Number],
214 ) -> bool {
215 (**self).solve_released_pinned(released, pinned, rhs, lhs)
216 }
217 fn supports_release(&self) -> bool {
218 (**self).supports_release()
219 }
220}
221
222/// One bound-multiplier row of the compound KKT vector, resolved to
223/// the primal quantity it constrains.
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub struct BoundRow {
226 /// Row of the compound KKT vector holding the multiplier.
227 pub row: usize,
228 /// **Primal KKT row** of the quantity that bound constrains: a
229 /// row of the `x` block for a variable bound (`z_l` / `z_u`), and
230 /// a row of the `s` block for a constraint's limit (`v_l` /
231 /// `v_u`), which bounds the slack rather than a variable.
232 ///
233 /// The field is named for the case it had when only variable
234 /// bounds were reported, and below `dims[0]` the two spaces
235 /// coincide, so a var-x reader that never sees a slack row is
236 /// still correct. That is not an accident to rely on quietly:
237 /// **a consumer that indexes a var-x-length vector with this must
238 /// first check `var_row < dims[0]`**, because a slack row's value
239 /// is a valid index into nothing. `Solver::weakly_active_bounds`
240 /// and `step_along_path`'s base-activity table both carry that
241 /// check explicitly.
242 pub var_row: usize,
243 /// `true` for a lower bound (`z_l` / `v_l`), `false` for an upper
244 /// (`z_u` / `v_u`). The primal row carries the two with opposite
245 /// signs.
246 pub lower: bool,
247}
248
249/// Synthetic dense-LU backsolver. Used in this crate's tests to
250/// validate the sensitivity math against known-good linear-algebra
251/// answers without standing up the full pounce IPM. Phase B.2 ships
252/// a real `pounce-algorithm`-backed implementation; this stays for
253/// regression tests and as a reference for the trait contract.
254///
255/// Stores a row-major `n × n` matrix and reuses an in-place
256/// Gaussian-elimination LU factor with partial pivoting. Numerical
257/// stability is fine for the small problem sizes the unit tests
258/// exercise (n ≤ 16).
259#[derive(Debug, Clone)]
260pub struct DenseLuBacksolver {
261 n: usize,
262 /// Factored `K` in row-major order: contains `L` (unit lower)
263 /// and `U` (upper) packed in-place per Doolittle.
264 lu: Vec<Number>,
265 /// Row-permutation order: row `piv[i]` of the original `K`
266 /// ended up in row `i` of the factor.
267 piv: Vec<usize>,
268}
269
270impl DenseLuBacksolver {
271 /// Build from a row-major `n × n` matrix. Returns `Err(())` if
272 /// the matrix is exactly singular at LU time (zero pivot after
273 /// pivoting).
274 pub fn from_dense(n: usize, a_row_major: &[Number]) -> Result<Self, ()> {
275 if a_row_major.len() != n * n {
276 return Err(());
277 }
278 let mut lu = a_row_major.to_vec();
279 let mut piv: Vec<usize> = (0..n).collect();
280 for k in 0..n {
281 // Partial pivot: find row with largest |lu[r,k]| for r >= k.
282 let mut best = k;
283 let mut best_mag = lu[k * n + k].abs();
284 for r in (k + 1)..n {
285 let mag = lu[r * n + k].abs();
286 if mag > best_mag {
287 best = r;
288 best_mag = mag;
289 }
290 }
291 if best_mag == 0.0 {
292 return Err(());
293 }
294 if best != k {
295 piv.swap(k, best);
296 for j in 0..n {
297 let tmp = lu[k * n + j];
298 lu[k * n + j] = lu[best * n + j];
299 lu[best * n + j] = tmp;
300 }
301 }
302 // Eliminate below pivot.
303 let inv_p = 1.0 / lu[k * n + k];
304 for r in (k + 1)..n {
305 let m = lu[r * n + k] * inv_p;
306 lu[r * n + k] = m;
307 for j in (k + 1)..n {
308 let upd = lu[k * n + j];
309 lu[r * n + j] -= m * upd;
310 }
311 }
312 }
313 Ok(Self { n, lu, piv })
314 }
315}
316
317impl SensBacksolver for DenseLuBacksolver {
318 fn dim(&self) -> usize {
319 self.n
320 }
321
322 fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
323 if rhs.len() != self.n || lhs.len() != self.n {
324 return false;
325 }
326 // Apply row permutation first.
327 for i in 0..self.n {
328 lhs[i] = rhs[self.piv[i]];
329 }
330 // Forward solve: L · y = P · rhs (L unit lower in `lu`).
331 for i in 0..self.n {
332 let mut s = lhs[i];
333 for j in 0..i {
334 s -= self.lu[i * self.n + j] * lhs[j];
335 }
336 lhs[i] = s;
337 }
338 // Back solve: U · x = y.
339 for i in (0..self.n).rev() {
340 let mut s = lhs[i];
341 for j in (i + 1)..self.n {
342 s -= self.lu[i * self.n + j] * lhs[j];
343 }
344 let p = self.lu[i * self.n + i];
345 if p == 0.0 {
346 return false;
347 }
348 lhs[i] = s / p;
349 }
350 true
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 /// Solve the small symmetric system `A x = b`:
359 /// 2 -1 0 | 1
360 /// -1 2 -1 | 0
361 /// 0 -1 2 | 0
362 /// Closed-form: x = (3/4, 1/2, 1/4).
363 #[test]
364 fn dense_lu_solves_3x3_symmetric() {
365 #[rustfmt::skip]
366 let a = vec![
367 2.0, -1.0, 0.0,
368 -1.0, 2.0, -1.0,
369 0.0, -1.0, 2.0,
370 ];
371 let solver = DenseLuBacksolver::from_dense(3, &a).expect("factor");
372 let b = [1.0, 0.0, 0.0];
373 let mut x = [0.0; 3];
374 assert!(solver.solve(&b, &mut x));
375 assert!((x[0] - 0.75).abs() < 1e-12, "x[0] = {}", x[0]);
376 assert!((x[1] - 0.50).abs() < 1e-12, "x[1] = {}", x[1]);
377 assert!((x[2] - 0.25).abs() < 1e-12, "x[2] = {}", x[2]);
378 }
379
380 /// Pivoting check: a matrix whose first pivot is zero (must swap
381 /// rows). Direct closed-form: A x = b for
382 /// 0 1 | 2 → x = (2, 1) after swap
383 /// 1 0 | 1
384 #[test]
385 fn dense_lu_handles_zero_first_pivot() {
386 let a = vec![0.0, 1.0, 1.0, 0.0];
387 let solver = DenseLuBacksolver::from_dense(2, &a).expect("factor");
388 let b = [2.0, 1.0];
389 let mut x = [0.0; 2];
390 assert!(solver.solve(&b, &mut x));
391 assert!((x[0] - 1.0).abs() < 1e-12, "x[0] = {}", x[0]);
392 assert!((x[1] - 2.0).abs() < 1e-12, "x[1] = {}", x[1]);
393 }
394
395 #[test]
396 fn dense_lu_rejects_singular_matrix() {
397 // Rank-deficient: rows are linearly dependent.
398 let a = vec![1.0, 2.0, 2.0, 4.0];
399 assert!(DenseLuBacksolver::from_dense(2, &a).is_err());
400 }
401
402 #[test]
403 fn solve_rejects_wrong_dim() {
404 let a = vec![1.0, 0.0, 0.0, 1.0];
405 let s = DenseLuBacksolver::from_dense(2, &a).expect("ok");
406 let b = [1.0];
407 let mut x = [0.0; 2];
408 assert!(!s.solve(&b, &mut x));
409 }
410}