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    ///
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}