Skip to main content

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}