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 fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool;
44
45 /// Per-row factor carrying a quantity read off the converged
46 /// iterate into the units [`Self::solve`] answers in, indexed by
47 /// compound KKT row. `None` when the solve ran unscaled, which is
48 /// the identity.
49 ///
50 /// This is the same `F` the natural-units back-solve applies to
51 /// its result, so a bound multiplier read raw off `curr.z_l` and
52 /// multiplied by `f[row]` agrees with the `z` rows of a step this
53 /// backsolver returns. The two disagree by `d/df` otherwise, and
54 /// mixing them puts a scaled right-hand side into a Schur
55 /// complement whose other rows are natural, which moves every
56 /// coordinate of the answer rather than one.
57 fn natural_units_factor(&self) -> Option<&[Number]> {
58 None
59 }
60
61 /// The variable behind each bound-multiplier row. `None` when the
62 /// backsolver cannot report it, which leaves
63 /// [`crate::boundcheck::refine_step_onto_bounds`] with no way to
64 /// release and so pinning only.
65 ///
66 /// A release re-factors with that variable's `sigma` removed, so it
67 /// needs to know which variable each row belongs to and which side
68 /// it bounds.
69 fn bound_rows(&self) -> Option<&[BoundRow]> {
70 None
71 }
72
73 /// Solve against the system with the bounds named by `released` --
74 /// compound multiplier rows -- taken out of the active set.
75 ///
76 /// This re-factors, and has to. An active bound puts
77 /// `sigma = z / s` on the x diagonal, and on a tightly converged
78 /// bound that term is large enough to destroy the released system's
79 /// information in the converged factor: recovering it from there
80 /// needs the difference of two quantities agreeing to about
81 /// `eps * sigma`, so the released answer comes out *worse* the
82 /// better the solve converged. Re-factoring with the term removed
83 /// is what buys those digits back, and one factorization still sits
84 /// an order of magnitude under a re-solve.
85 ///
86 /// This solves in the released *system* and does nothing to the
87 /// right-hand side, so it is the right call for every solve the
88 /// refinement makes once a bound is out -- including the unit
89 /// vectors a Schur complement is built from, which carry no
90 /// multiplier to move.
91 ///
92 /// `false` by default, which leaves the refinement pinning only.
93 fn solve_released(&self, _released: &[usize], _rhs: &[Number], _lhs: &mut [Number]) -> bool {
94 false
95 }
96
97 /// [`Self::solve_released`] against a *parametric* right-hand side,
98 /// which additionally needs the released multipliers moved onto
99 /// their variables' x rows.
100 ///
101 /// Dropping `sigma` gives the released matrix; this gives the
102 /// released right-hand side to go with it. Only the step itself
103 /// gets this treatment -- applying it to a Schur complement's unit
104 /// vectors would shift a right-hand side that has no multiplier in
105 /// it to begin with.
106 fn solve_released_step(
107 &self,
108 _released: &[usize],
109 _rhs: &[Number],
110 _lhs: &mut [Number],
111 ) -> bool {
112 false
113 }
114
115 /// [`Self::solve_released`] with each **primal** KKT row in
116 /// `pinned` carrying an extra diagonal stiff enough to hold that
117 /// coordinate in place.
118 ///
119 /// This is not the pin the walk applies -- that one is a Schur row
120 /// on top of the factored system, and it is exact. This is the
121 /// *operator* that pin is applied to, for the case where the
122 /// released system on its own has no inverse for a Schur
123 /// complement to be built from: releasing a bound takes its
124 /// `Sigma` off the diagonal, and on a model with no curvature two
125 /// released variables sharing a constraint are left with linearly
126 /// dependent stationarity rows (gh#930).
127 ///
128 /// Adding the diagonal back regularizes exactly those rows, and
129 /// costs nothing in accuracy, because the Schur pin then holds the
130 /// same coordinates at zero: `Eᵀw = 0` annihilates the term that
131 /// was added, so the pinned system solved is the released one
132 /// after all. The diagonal has to be *reachable* -- large enough
133 /// that the regularized operator is invertible, small enough that
134 /// the row's own couplings survive it, which is the gh#737
135 /// ceiling.
136 ///
137 /// `false` by default, which leaves the caller reporting the
138 /// Schur pin's failure.
139 fn solve_released_pinned(
140 &self,
141 _released: &[usize],
142 _pinned: &[usize],
143 _rhs: &[Number],
144 _lhs: &mut [Number],
145 ) -> bool {
146 false
147 }
148
149 /// Whether [`Self::solve_released`] is implemented.
150 fn supports_release(&self) -> bool {
151 false
152 }
153}
154
155/// One bound-multiplier row of the compound KKT vector, resolved to
156/// the primal quantity it constrains.
157#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub struct BoundRow {
159 /// Row of the compound KKT vector holding the multiplier.
160 pub row: usize,
161 /// **Primal KKT row** of the quantity that bound constrains: a
162 /// row of the `x` block for a variable bound (`z_l` / `z_u`), and
163 /// a row of the `s` block for a constraint's limit (`v_l` /
164 /// `v_u`), which bounds the slack rather than a variable.
165 ///
166 /// The field is named for the case it had when only variable
167 /// bounds were reported, and below `dims[0]` the two spaces
168 /// coincide, so a var-x reader that never sees a slack row is
169 /// still correct. That is not an accident to rely on quietly:
170 /// **a consumer that indexes a var-x-length vector with this must
171 /// first check `var_row < dims[0]`**, because a slack row's value
172 /// is a valid index into nothing. `Solver::weakly_active_bounds`
173 /// and `step_along_path`'s base-activity table both carry that
174 /// check explicitly.
175 pub var_row: usize,
176 /// `true` for a lower bound (`z_l` / `v_l`), `false` for an upper
177 /// (`z_u` / `v_u`). The primal row carries the two with opposite
178 /// signs.
179 pub lower: bool,
180}
181
182/// Synthetic dense-LU backsolver. Used in this crate's tests to
183/// validate the sensitivity math against known-good linear-algebra
184/// answers without standing up the full pounce IPM. Phase B.2 ships
185/// a real `pounce-algorithm`-backed implementation; this stays for
186/// regression tests and as a reference for the trait contract.
187///
188/// Stores a row-major `n × n` matrix and reuses an in-place
189/// Gaussian-elimination LU factor with partial pivoting. Numerical
190/// stability is fine for the small problem sizes the unit tests
191/// exercise (n ≤ 16).
192#[derive(Debug, Clone)]
193pub struct DenseLuBacksolver {
194 n: usize,
195 /// Factored `K` in row-major order: contains `L` (unit lower)
196 /// and `U` (upper) packed in-place per Doolittle.
197 lu: Vec<Number>,
198 /// Row-permutation order: row `piv[i]` of the original `K`
199 /// ended up in row `i` of the factor.
200 piv: Vec<usize>,
201}
202
203impl DenseLuBacksolver {
204 /// Build from a row-major `n × n` matrix. Returns `Err(())` if
205 /// the matrix is exactly singular at LU time (zero pivot after
206 /// pivoting).
207 pub fn from_dense(n: usize, a_row_major: &[Number]) -> Result<Self, ()> {
208 if a_row_major.len() != n * n {
209 return Err(());
210 }
211 let mut lu = a_row_major.to_vec();
212 let mut piv: Vec<usize> = (0..n).collect();
213 for k in 0..n {
214 // Partial pivot: find row with largest |lu[r,k]| for r >= k.
215 let mut best = k;
216 let mut best_mag = lu[k * n + k].abs();
217 for r in (k + 1)..n {
218 let mag = lu[r * n + k].abs();
219 if mag > best_mag {
220 best = r;
221 best_mag = mag;
222 }
223 }
224 if best_mag == 0.0 {
225 return Err(());
226 }
227 if best != k {
228 piv.swap(k, best);
229 for j in 0..n {
230 let tmp = lu[k * n + j];
231 lu[k * n + j] = lu[best * n + j];
232 lu[best * n + j] = tmp;
233 }
234 }
235 // Eliminate below pivot.
236 let inv_p = 1.0 / lu[k * n + k];
237 for r in (k + 1)..n {
238 let m = lu[r * n + k] * inv_p;
239 lu[r * n + k] = m;
240 for j in (k + 1)..n {
241 let upd = lu[k * n + j];
242 lu[r * n + j] -= m * upd;
243 }
244 }
245 }
246 Ok(Self { n, lu, piv })
247 }
248}
249
250impl SensBacksolver for DenseLuBacksolver {
251 fn dim(&self) -> usize {
252 self.n
253 }
254
255 fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
256 if rhs.len() != self.n || lhs.len() != self.n {
257 return false;
258 }
259 // Apply row permutation first.
260 for i in 0..self.n {
261 lhs[i] = rhs[self.piv[i]];
262 }
263 // Forward solve: L · y = P · rhs (L unit lower in `lu`).
264 for i in 0..self.n {
265 let mut s = lhs[i];
266 for j in 0..i {
267 s -= self.lu[i * self.n + j] * lhs[j];
268 }
269 lhs[i] = s;
270 }
271 // Back solve: U · x = y.
272 for i in (0..self.n).rev() {
273 let mut s = lhs[i];
274 for j in (i + 1)..self.n {
275 s -= self.lu[i * self.n + j] * lhs[j];
276 }
277 let p = self.lu[i * self.n + i];
278 if p == 0.0 {
279 return false;
280 }
281 lhs[i] = s / p;
282 }
283 true
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 /// Solve the small symmetric system `A x = b`:
292 /// 2 -1 0 | 1
293 /// -1 2 -1 | 0
294 /// 0 -1 2 | 0
295 /// Closed-form: x = (3/4, 1/2, 1/4).
296 #[test]
297 fn dense_lu_solves_3x3_symmetric() {
298 #[rustfmt::skip]
299 let a = vec![
300 2.0, -1.0, 0.0,
301 -1.0, 2.0, -1.0,
302 0.0, -1.0, 2.0,
303 ];
304 let solver = DenseLuBacksolver::from_dense(3, &a).expect("factor");
305 let b = [1.0, 0.0, 0.0];
306 let mut x = [0.0; 3];
307 assert!(solver.solve(&b, &mut x));
308 assert!((x[0] - 0.75).abs() < 1e-12, "x[0] = {}", x[0]);
309 assert!((x[1] - 0.50).abs() < 1e-12, "x[1] = {}", x[1]);
310 assert!((x[2] - 0.25).abs() < 1e-12, "x[2] = {}", x[2]);
311 }
312
313 /// Pivoting check: a matrix whose first pivot is zero (must swap
314 /// rows). Direct closed-form: A x = b for
315 /// 0 1 | 2 → x = (2, 1) after swap
316 /// 1 0 | 1
317 #[test]
318 fn dense_lu_handles_zero_first_pivot() {
319 let a = vec![0.0, 1.0, 1.0, 0.0];
320 let solver = DenseLuBacksolver::from_dense(2, &a).expect("factor");
321 let b = [2.0, 1.0];
322 let mut x = [0.0; 2];
323 assert!(solver.solve(&b, &mut x));
324 assert!((x[0] - 1.0).abs() < 1e-12, "x[0] = {}", x[0]);
325 assert!((x[1] - 2.0).abs() < 1e-12, "x[1] = {}", x[1]);
326 }
327
328 #[test]
329 fn dense_lu_rejects_singular_matrix() {
330 // Rank-deficient: rows are linearly dependent.
331 let a = vec![1.0, 2.0, 2.0, 4.0];
332 assert!(DenseLuBacksolver::from_dense(2, &a).is_err());
333 }
334
335 #[test]
336 fn solve_rejects_wrong_dim() {
337 let a = vec![1.0, 0.0, 0.0, 1.0];
338 let s = DenseLuBacksolver::from_dense(2, &a).expect("ok");
339 let b = [1.0];
340 let mut x = [0.0; 2];
341 assert!(!s.solve(&b, &mut x));
342 }
343}