Skip to main content

pounce_sensitivity/
solver.rs

1//! `Solver` — value-typed session API that holds an `IpoptApplication`,
2//! its TNLP, and the converged KKT factor between calls.
3//!
4//! This is Phase 3a of the factor-reuse work tracked in
5//! [pounce#16](https://github.com/jkitchin/pounce/issues/16). It is
6//! the public surface for callers who want to:
7//!
8//! 1. Run a normal IPM solve, then
9//! 2. Issue many cheap operations against the converged factor
10//!    (`kkt_solve`, `parametric_step`) without going through the
11//!    [`set_on_converged`] callback shape that [`crate::SensSolve`]
12//!    requires.
13//!
14//! [`set_on_converged`]: pounce_algorithm::IpoptApplication::set_on_converged
15//!
16//! # Usage
17//!
18//! ```ignore
19//! use pounce_sensitivity::Solver;
20//! use std::cell::RefCell;
21//! use std::rc::Rc;
22//!
23//! let app = make_configured_app();
24//! let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(MyTnlp));
25//! let mut solver = Solver::new(app, tnlp);
26//!
27//! let status = solver.solve();
28//! assert!(solver.converged().is_some());
29//!
30//! // Issue any number of back-solves against the same factor:
31//! let dim = solver.kkt_dim().unwrap();
32//! let mut lhs = vec![0.0; dim];
33//! let rhs = vec![1.0; dim];
34//! solver.kkt_solve(&rhs, &mut lhs).unwrap();
35//!
36//! // Parametric step with respect to a set of pinned equality
37//! // constraints (same interpretation as [`crate::SensSolve`]):
38//! let dx = solver.parametric_step(&[2, 3], &[-0.5, 0.0]).unwrap();
39//! ```
40//!
41//! # Scope of Phase 3a
42//!
43//! - **In**: `solve()`, `converged()`, `kkt_solve()`, `parametric_step()`,
44//!   `block_dims()` / `kkt_dim()`.
45//! - **Deferred to Phase 3b**: `resolve()` (warm-start that reuses the
46//!   linear backend pool), `compute_reduced_hessian()` on the Solver
47//!   (currently only available through [`crate::SensSolve`]), and the
48//!   `parametric_mpc` / `sensitivity_session` example binaries.
49
50use std::cell::{Ref, RefCell};
51use std::rc::Rc;
52
53use pounce_algorithm::application::IpoptApplication;
54use pounce_common::types::{Index, Number};
55use pounce_nlp::return_codes::ApplicationReturnStatus;
56use pounce_nlp::TNLP;
57
58use crate::backsolver::SensBacksolver;
59use crate::schur_data::IndexSchurData;
60use crate::sens_app::{SensApplication, SensOptions};
61use crate::vec_util::dense_to_vec;
62use crate::PdSensBacksolver;
63
64/// Errors returned by post-convergence operations on [`Solver`].
65#[derive(Debug, Clone)]
66pub enum SolverError {
67    /// The solver has not yet converged, or the last solve failed
68    /// before producing a usable KKT factor.
69    NotConverged,
70    /// An input slice's length did not match the KKT dimension or the
71    /// parameter count.
72    BadShape {
73        /// Human description of the mismatched buffer.
74        what: &'static str,
75        /// Length the caller passed.
76        got: usize,
77        /// Length expected.
78        expected: usize,
79    },
80    /// The underlying back-solve failed (singular factor, numerical
81    /// breakdown).
82    BacksolveFailed,
83    /// The underlying [`SensApplication`] step failed (e.g. row mapping
84    /// invalid for the current problem).
85    SensComputationFailed(String),
86}
87
88/// State captured at convergence: the user-visible iterate plus the
89/// `PdSensBacksolver` that wraps the converged KKT factor.
90///
91/// Read this via [`Solver::converged`].
92pub struct ConvergedState {
93    /// IPM return status of the most recent solve.
94    pub status: ApplicationReturnStatus,
95    /// Final primal iterate `x*` (length `n_x`).
96    pub x: Vec<Number>,
97    /// Final objective value `f(x*)`.
98    pub obj_val: Number,
99    /// Converged KKT-factor wrapper. Owns `Rc` handles to the
100    /// `PdFullSpaceSolver`, the IpoptData / Cq, and the NLP, so it
101    /// outlives the IPM call frame.
102    backsolver: PdSensBacksolver,
103}
104
105impl ConvergedState {
106    /// Block dimensions of the compound KKT vector in
107    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order.
108    pub fn block_dims(&self) -> [usize; 8] {
109        self.backsolver.block_dims()
110    }
111
112    /// Total dimension of the compound KKT vector (sum of `block_dims`).
113    pub fn kkt_dim(&self) -> usize {
114        self.backsolver.dim()
115    }
116}
117
118/// Session-style solver: holds an [`IpoptApplication`], its TNLP, and
119/// the converged factor between calls.
120pub struct Solver {
121    app: IpoptApplication,
122    tnlp: Rc<RefCell<dyn TNLP>>,
123    /// Side channel populated by the `on_converged` callback installed
124    /// in [`Self::solve`]. The `RefCell<Option<…>>` shape mirrors the
125    /// pattern in [`crate::convenience`] (the callback closure needs
126    /// shared mutable access; the `Option` is `None` before the first
127    /// solve and gets overwritten on each call).
128    state: Rc<RefCell<Option<ConvergedState>>>,
129}
130
131impl Solver {
132    /// Build a new session. The `app` should already have its options
133    /// configured and `initialize()` called.
134    pub fn new(app: IpoptApplication, tnlp: Rc<RefCell<dyn TNLP>>) -> Self {
135        Self {
136            app,
137            tnlp,
138            state: Rc::new(RefCell::new(None)),
139        }
140    }
141
142    /// Borrow the underlying `IpoptApplication` (e.g. to read its
143    /// options table after a solve). Mutation between `solve` calls is
144    /// supported via [`Self::app_mut`].
145    pub fn app(&self) -> &IpoptApplication {
146        &self.app
147    }
148
149    /// Mutable borrow of the underlying `IpoptApplication`. Useful for
150    /// reconfiguring options before a follow-up `solve()`. Note that
151    /// changing options that affect the KKT linear system between
152    /// calls will invalidate the cached factor; the next `solve()`
153    /// rebuilds it.
154    pub fn app_mut(&mut self) -> &mut IpoptApplication {
155        &mut self.app
156    }
157
158    /// Run the IPM to convergence. On a successful solve the
159    /// [`ConvergedState`] (including the KKT backsolver) is stashed
160    /// inside the `Solver` and accessible via [`Self::converged`].
161    ///
162    /// Each call to `solve()` overwrites the previous converged
163    /// state; the previously held factor is dropped.
164    pub fn solve(&mut self) -> ApplicationReturnStatus {
165        // Clear any previous state so a failed re-solve doesn't leave
166        // a stale factor visible.
167        self.state.borrow_mut().take();
168
169        let state_cb = Rc::clone(&self.state);
170        self.app
171            .set_on_converged(Box::new(move |data, cq, nlp, pd| {
172                let curr = match data.borrow().curr.clone() {
173                    Some(c) => c,
174                    None => return,
175                };
176                let backsolver = match PdSensBacksolver::new(data, cq, nlp, Rc::clone(&pd)) {
177                    Ok(b) => b,
178                    Err(e) => {
179                        // No session state is stored, so post-solve
180                        // calls will report NotConverged; at least say
181                        // why on stderr rather than failing silently.
182                        eprintln!("pounce: Solver could not capture the KKT factor: {e}");
183                        return;
184                    }
185                };
186                let x = dense_to_vec(&*curr.x);
187                let obj_val = cq.borrow_mut().curr_f();
188                // Status is overwritten with the real value after
189                // optimize_tnlp returns.
190                *state_cb.borrow_mut() = Some(ConvergedState {
191                    status: ApplicationReturnStatus::InternalError,
192                    x,
193                    obj_val,
194                    backsolver,
195                });
196            }));
197
198        let status = self.app.optimize_tnlp(Rc::clone(&self.tnlp));
199        if let Some(s) = self.state.borrow_mut().as_mut() {
200            s.status = status;
201        }
202        status
203    }
204
205    /// Borrow the converged state, if a successful solve has been
206    /// run. Returns `None` if no solve has run or if the most recent
207    /// solve failed before reaching convergence.
208    pub fn converged(&self) -> Option<Ref<'_, ConvergedState>> {
209        let r = self.state.borrow();
210        r.as_ref()?;
211        Some(Ref::map(r, |o| {
212            o.as_ref()
213                .unwrap_or_else(|| unreachable!("checked is_some above"))
214        }))
215    }
216
217    /// Total dimension of the compound KKT vector (sum of
218    /// `block_dims`). Returns `None` if no converged factor is held.
219    pub fn kkt_dim(&self) -> Option<usize> {
220        self.converged().map(|c| c.kkt_dim())
221    }
222
223    /// Block dimensions of the compound KKT vector in
224    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order. Returns `None` if
225    /// no converged factor is held.
226    pub fn block_dims(&self) -> Option<[usize; 8]> {
227        self.converged().map(|c| c.block_dims())
228    }
229
230    /// Solve `K · lhs = rhs` against the converged KKT factor. Both
231    /// slices must have length `kkt_dim()`; the layout is the flat
232    /// `x || s || y_c || y_d || z_l || z_u || v_l || v_u` packing.
233    ///
234    /// `K` here is the **natural-units** (unscaled) KKT matrix: when
235    /// the IPM solved with active NLP scaling, the backsolver scales
236    /// the RHS/solution (all eight blocks, including the z/v
237    /// bound-multiplier rows) so callers pass and receive data in the
238    /// user's own units (pounce#128) — see
239    /// [`crate::PdSensBacksolver::solve`]. For the raw scaled-space
240    /// back-solve use [`Self::kkt_solve_scaled`].
241    pub fn kkt_solve(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
242        self.kkt_solve_impl(rhs, lhs, false)
243    }
244
245    /// [`Self::kkt_solve`] without the natural-units conjugation: the
246    /// back-solve runs against the factor exactly as the IPM holds it
247    /// (the solver's internal scaled space). Identical to `kkt_solve`
248    /// when no NLP scaling is active.
249    pub fn kkt_solve_scaled(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
250        self.kkt_solve_impl(rhs, lhs, true)
251    }
252
253    fn kkt_solve_impl(
254        &self,
255        rhs: &[Number],
256        lhs: &mut [Number],
257        scaled: bool,
258    ) -> Result<(), SolverError> {
259        let state = self.state.borrow();
260        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
261        let total = state.backsolver.dim();
262        if rhs.len() != total {
263            return Err(SolverError::BadShape {
264                what: "rhs",
265                got: rhs.len(),
266                expected: total,
267            });
268        }
269        if lhs.len() != total {
270            return Err(SolverError::BadShape {
271                what: "lhs",
272                got: lhs.len(),
273                expected: total,
274            });
275        }
276        let ok = if scaled {
277            state.backsolver.solve_scaled_space(rhs, lhs)
278        } else {
279            state.backsolver.solve(rhs, lhs)
280        };
281        if ok {
282            Ok(())
283        } else {
284            Err(SolverError::BacksolveFailed)
285        }
286    }
287
288    /// Batched-RHS back-solve. `rhs_flat` and `lhs_flat` are row-major
289    /// `(n_rhs, kkt_dim)` buffers; each row is solved against the
290    /// same converged factor. Equivalent in result to looping
291    /// [`Self::kkt_solve`] but reuses one `IteratesVector` for the
292    /// RHS and one for the result across all `n_rhs` calls — see
293    /// [`crate::algorithm_backsolver::PdSensBacksolver::solve_many`].
294    pub fn kkt_solve_many(
295        &self,
296        rhs_flat: &[Number],
297        lhs_flat: &mut [Number],
298        n_rhs: usize,
299    ) -> Result<(), SolverError> {
300        self.kkt_solve_many_impl(rhs_flat, lhs_flat, n_rhs, false)
301    }
302
303    /// [`Self::kkt_solve_many`] without the natural-units
304    /// conjugation (the batched sibling of [`Self::kkt_solve_scaled`]).
305    pub fn kkt_solve_many_scaled(
306        &self,
307        rhs_flat: &[Number],
308        lhs_flat: &mut [Number],
309        n_rhs: usize,
310    ) -> Result<(), SolverError> {
311        self.kkt_solve_many_impl(rhs_flat, lhs_flat, n_rhs, true)
312    }
313
314    fn kkt_solve_many_impl(
315        &self,
316        rhs_flat: &[Number],
317        lhs_flat: &mut [Number],
318        n_rhs: usize,
319        scaled: bool,
320    ) -> Result<(), SolverError> {
321        let state = self.state.borrow();
322        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
323        let total = state.backsolver.dim();
324        let expected = n_rhs * total;
325        if rhs_flat.len() != expected {
326            return Err(SolverError::BadShape {
327                what: "rhs",
328                got: rhs_flat.len(),
329                expected,
330            });
331        }
332        if lhs_flat.len() != expected {
333            return Err(SolverError::BadShape {
334                what: "lhs",
335                got: lhs_flat.len(),
336                expected,
337            });
338        }
339        let ok = if scaled {
340            state
341                .backsolver
342                .solve_many_scaled_space(rhs_flat, lhs_flat, n_rhs)
343        } else {
344            state.backsolver.solve_many(rhs_flat, lhs_flat, n_rhs)
345        };
346        if ok {
347            Ok(())
348        } else {
349            Err(SolverError::BacksolveFailed)
350        }
351    }
352
353    /// First-order parametric step `Δx ≈ ∂x*/∂p · Δp` for a set of
354    /// pinned equality constraints. `pin_constraint_indices` are
355    /// 0-based indices into the user's `g(x)`; `deltas` is the
356    /// perturbation `Δp` (same length).
357    ///
358    /// Returns the `n_x`-long primal step. For the full KKT-space
359    /// step, use [`Self::kkt_solve`] directly.
360    pub fn parametric_step(
361        &self,
362        pin_constraint_indices: &[Index],
363        deltas: &[Number],
364    ) -> Result<Vec<Number>, SolverError> {
365        if pin_constraint_indices.len() != deltas.len() {
366            return Err(SolverError::BadShape {
367                what: "deltas",
368                got: deltas.len(),
369                expected: pin_constraint_indices.len(),
370            });
371        }
372        let state = self.state.borrow();
373        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
374
375        // Map user g-indices to y_c rows through the NLP's c/d-split
376        // permutation (pounce#128; matches `convenience.rs`).
377        let dims = state.backsolver.block_dims();
378        let n_x = dims[0];
379        let param_rows = state
380            .backsolver
381            .map_pin_g_to_kkt_rows(pin_constraint_indices)
382            .map_err(SolverError::SensComputationFailed)?;
383        let signs = vec![1; pin_constraint_indices.len()];
384        let a_data = IndexSchurData::from_parts(param_rows, signs)
385            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
386
387        let opts = SensOptions {
388            run_sens: true,
389            ..SensOptions::default()
390        };
391        let sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
392        let n_full = state.backsolver.dim();
393        let mut dx_full = vec![0.0; n_full];
394        if !sens_app.parametric_step(deltas, &mut dx_full) {
395            return Err(SolverError::SensComputationFailed(
396                "SensApplication::parametric_step failed".into(),
397            ));
398        }
399        dx_full.truncate(n_x);
400        Ok(dx_full)
401    }
402
403    /// Reduced Hessian `H_R = obj_scal · B K⁻¹ Bᵀ` over the pinned
404    /// equality-constraint rows, where `B` selects the
405    /// `pin_constraint_indices` rows of the y_c block and `K` is the
406    /// **natural-units** (unscaled) KKT matrix — active NLP scaling
407    /// is undone by the backsolver, so `−inv(H_R)` is directly the
408    /// parameter covariance regardless of `nlp_scaling_method`
409    /// (pounce#128). `obj_scal` survives as a plain extra multiplier
410    /// (default 1.0); it is no longer needed to recover natural units.
411    /// Returns the `n²`-long column-major dense matrix
412    /// (`n = pin_constraint_indices.len()`).
413    ///
414    /// Equivalent to [`crate::SensSolve::with_reduced_hessian`] but
415    /// usable post-hoc on a held `Solver`. For the solver-space
416    /// (pre-#128) value use [`Self::compute_reduced_hessian_scaled`];
417    /// the factors themselves are exposed via [`Self::nlp_scaling`] /
418    /// [`Self::pin_g_scaling`].
419    pub fn compute_reduced_hessian(
420        &self,
421        pin_constraint_indices: &[Index],
422        obj_scal: Number,
423    ) -> Result<Vec<Number>, SolverError> {
424        let state = self.state.borrow();
425        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
426        let n = pin_constraint_indices.len();
427        let param_rows = state
428            .backsolver
429            .map_pin_g_to_kkt_rows(pin_constraint_indices)
430            .map_err(SolverError::SensComputationFailed)?;
431        let signs = vec![1; n];
432        let a_data = IndexSchurData::from_parts(param_rows, signs)
433            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
434        let opts = SensOptions {
435            compute_red_hessian: true,
436            obj_scal,
437            ..SensOptions::default()
438        };
439        let mut sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
440        let mut hr = vec![0.0; n * n];
441        if !sens_app.compute_reduced_hessian(&mut hr) {
442            return Err(SolverError::SensComputationFailed(
443                "SensApplication::compute_reduced_hessian failed".into(),
444            ));
445        }
446        Ok(hr)
447    }
448
449    /// The reduced Hessian as the solver's internal **scaled** space
450    /// sees it — the value [`Self::compute_reduced_hessian`] returned
451    /// before pounce#128: `H̃_ij = (df / (dc_i·dc_j)) · H_ij`.
452    /// Identical to `compute_reduced_hessian` when no NLP scaling is
453    /// active.
454    pub fn compute_reduced_hessian_scaled(
455        &self,
456        pin_constraint_indices: &[Index],
457        obj_scal: Number,
458    ) -> Result<Vec<Number>, SolverError> {
459        let mut hr = self.compute_reduced_hessian(pin_constraint_indices, obj_scal)?;
460        let state = self.state.borrow();
461        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
462        let df = state.backsolver.obj_scaling_factor();
463        let dc = state
464            .backsolver
465            .pin_c_scales(pin_constraint_indices)
466            .map_err(SolverError::SensComputationFailed)?;
467        crate::reduced_hessian::scale_to_solver_space(&mut hr, df, &dc);
468        Ok(hr)
469    }
470
471    /// Effective NLP scaling the IPM applied on the most recent
472    /// converged solve: `(obj_scaling_factor, c_scale, d_scale)`.
473    /// `(1.0, None, None)` ⇔ no scaling was active. The vectors are
474    /// per-row factors over the algorithm's equality (`c`) and
475    /// inequality (`d`) blocks.
476    pub fn nlp_scaling(
477        &self,
478    ) -> Result<(Number, Option<Vec<Number>>, Option<Vec<Number>>), SolverError> {
479        let state = self.state.borrow();
480        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
481        Ok(state.backsolver.nlp_scaling())
482    }
483
484    /// Inertia-correction perturbations `(δ_x, δ_s, δ_c, δ_d)` baked
485    /// into the held KKT factor. All zero ⇔ the final factorization
486    /// was unregularized and the natural-units back-solves invert the
487    /// exact KKT matrix — see
488    /// [`crate::PdSensBacksolver::kkt_perturbations`].
489    pub fn kkt_perturbations(&self) -> Result<[Number; 4], SolverError> {
490        let state = self.state.borrow();
491        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
492        Ok(state.backsolver.kkt_perturbations())
493    }
494
495    /// Per-pin equality-row scaling factors `dc_i` (1.0 entries when
496    /// no constraint scaling is active), ordered like
497    /// `pin_constraint_indices`.
498    pub fn pin_g_scaling(
499        &self,
500        pin_constraint_indices: &[Index],
501    ) -> Result<Vec<Number>, SolverError> {
502        let state = self.state.borrow();
503        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
504        state
505            .backsolver
506            .pin_c_scales(pin_constraint_indices)
507            .map_err(SolverError::SensComputationFailed)
508    }
509}