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::TNLP;
56use pounce_nlp::return_codes::ApplicationReturnStatus;
57
58use crate::PdSensBacksolver;
59use crate::activity::ActivityReport;
60use crate::backsolver::SensBacksolver;
61use crate::schur_data::IndexSchurData;
62use crate::sens_app::{SensApplication, SensOptions};
63use crate::vec_util::dense_to_vec;
64
65/// Errors returned by post-convergence operations on [`Solver`].
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub enum SolverError {
69    /// The solver has not yet converged, or the last solve failed
70    /// before producing a usable KKT factor.
71    NotConverged,
72    /// An input slice's length did not match the KKT dimension or the
73    /// parameter count.
74    BadShape {
75        /// Human description of the mismatched buffer.
76        what: &'static str,
77        /// Length the caller passed.
78        got: usize,
79        /// Length expected.
80        expected: usize,
81    },
82    /// The underlying back-solve failed (singular factor, numerical
83    /// breakdown).
84    BacksolveFailed,
85    /// The underlying [`SensApplication`] step failed (e.g. row mapping
86    /// invalid for the current problem).
87    SensComputationFailed(String),
88    /// An option the requested computation depends on holds an
89    /// incompatible value; the message names the option and the value
90    /// required.
91    BadOptions(String),
92}
93
94/// State captured at convergence: the user-visible iterate plus the
95/// `PdSensBacksolver` that wraps the converged KKT factor.
96///
97/// Read this via [`Solver::converged`].
98pub struct ConvergedState {
99    /// IPM return status of the most recent solve.
100    pub status: ApplicationReturnStatus,
101    /// Final primal iterate `x*` (length `n_x`), in the user's own
102    /// units: a `user-scaling` change of variables is undone here, so
103    /// this is `x`, never the algorithm's `x̃ = d ⊙ x` (gh#486).
104    pub x: Vec<Number>,
105    /// Final objective value `f(x*)`.
106    pub obj_val: Number,
107    /// `bound_relax_factor` **as the solve that produced this state
108    /// ran with it**, not as the application's options read today.
109    /// The bounds were relaxed (or not) once, during this solve; a
110    /// later `set_numeric_value` cannot change what the held slacks
111    /// were measured against, so post-solve calls whose validity
112    /// depends on unrelaxed bounds must guard on this value. See
113    /// [`Solver::classify_activity`].
114    pub bound_relax_factor: Number,
115    /// Converged KKT-factor wrapper. Owns `Rc` handles to the
116    /// `PdFullSpaceSolver`, the IpoptData / Cq, and the NLP, so it
117    /// outlives the IPM call frame.
118    backsolver: PdSensBacksolver,
119}
120
121impl ConvergedState {
122    /// Block dimensions of the compound KKT vector in
123    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order.
124    pub fn block_dims(&self) -> [usize; 8] {
125        self.backsolver.block_dims()
126    }
127
128    /// Total dimension of the compound KKT vector (sum of `block_dims`).
129    pub fn kkt_dim(&self) -> usize {
130        self.backsolver.dim()
131    }
132}
133
134/// Session-style solver: holds an [`IpoptApplication`], its TNLP, and
135/// the converged factor between calls.
136pub struct Solver {
137    app: IpoptApplication,
138    tnlp: Rc<RefCell<dyn TNLP>>,
139    /// Side channel populated by the `on_converged` callback installed
140    /// in [`Self::solve`]. The `RefCell<Option<…>>` shape mirrors the
141    /// pattern in [`crate::convenience`] (the callback closure needs
142    /// shared mutable access; the `Option` is `None` before the first
143    /// solve and gets overwritten on each call).
144    state: Rc<RefCell<Option<ConvergedState>>>,
145}
146
147impl Solver {
148    /// Build a new session. The `app` should already have its options
149    /// configured and `initialize()` called.
150    pub fn new(app: IpoptApplication, tnlp: Rc<RefCell<dyn TNLP>>) -> Self {
151        Self {
152            app,
153            tnlp,
154            state: Rc::new(RefCell::new(None)),
155        }
156    }
157
158    /// Borrow the underlying `IpoptApplication` (e.g. to read its
159    /// options table after a solve). Mutation between `solve` calls is
160    /// supported via [`Self::app_mut`].
161    pub fn app(&self) -> &IpoptApplication {
162        &self.app
163    }
164
165    /// Mutable borrow of the underlying `IpoptApplication`. Useful for
166    /// reconfiguring options before a follow-up `solve()`. Note that
167    /// changing options that affect the KKT linear system between
168    /// calls will invalidate the cached factor; the next `solve()`
169    /// rebuilds it.
170    pub fn app_mut(&mut self) -> &mut IpoptApplication {
171        &mut self.app
172    }
173
174    /// Run the IPM to convergence. On a successful solve the
175    /// [`ConvergedState`] (including the KKT backsolver) is stashed
176    /// inside the `Solver` and accessible via [`Self::converged`].
177    ///
178    /// Each call to `solve()` overwrites the previous converged
179    /// state; the previously held factor is dropped.
180    pub fn solve(&mut self) -> ApplicationReturnStatus {
181        // Clear any previous state so a failed re-solve doesn't leave
182        // a stale factor visible.
183        self.state.borrow_mut().take();
184
185        // Snapshot the options this solve will run under, before it
186        // runs. `bound_relax_factor` is consumed once, when the NLP
187        // relaxes its bounds; reading it back at query time would
188        // describe the application's options rather than the state
189        // being queried. The registry supplies its own default when
190        // the option is unset, so no second copy of the default lives
191        // here.
192        let brf = self
193            .app
194            .options()
195            .get_numeric_value("bound_relax_factor", "")
196            .map(|(v, _)| v)
197            .expect("bound_relax_factor is a registered core option");
198
199        let state_cb = Rc::clone(&self.state);
200        self.app
201            .set_on_converged(Box::new(move |data, cq, nlp, pd| {
202                let curr = match data.borrow().curr.clone() {
203                    Some(c) => c,
204                    None => return,
205                };
206                let backsolver = match PdSensBacksolver::new(data, cq, nlp, Rc::clone(&pd)) {
207                    Ok(b) => b,
208                    Err(e) => {
209                        // No session state is stored, so post-solve
210                        // calls will report NotConverged; at least say
211                        // why on stderr rather than failing silently.
212                        eprintln!("pounce: Solver could not capture the KKT factor: {e}");
213                        return;
214                    }
215                };
216                // The algorithm's iterate is `x̃ = d ⊙ x` when the
217                // solve ran under a change of variables (gh#486): this
218                // capture reads the iterate, not the
219                // `finalize_solution` payload, so it undoes the
220                // substitution itself. The backsolver already read the
221                // factors off the NLP, in this same var-x space.
222                let mut x = dense_to_vec(&*curr.x);
223                if let Some(d) = backsolver.variable_scaling() {
224                    debug_assert_eq!(x.len(), d.len());
225                    for (xi, &di) in x.iter_mut().zip(d.iter()) {
226                        *xi /= di;
227                    }
228                }
229                let obj_val = cq.borrow_mut().curr_f();
230                // Status is overwritten with the real value after
231                // optimize_tnlp returns.
232                *state_cb.borrow_mut() = Some(ConvergedState {
233                    status: ApplicationReturnStatus::InternalError,
234                    x,
235                    obj_val,
236                    bound_relax_factor: brf,
237                    backsolver,
238                });
239            }));
240
241        let status = crate::optimize_tnlp_for_sensitivity(&mut self.app, Rc::clone(&self.tnlp));
242        if let Some(s) = self.state.borrow_mut().as_mut() {
243            s.status = status;
244        }
245        status
246    }
247
248    /// Borrow the converged state, if a successful solve has been
249    /// run. Returns `None` if no solve has run or if the most recent
250    /// solve failed before reaching convergence.
251    pub fn converged(&self) -> Option<Ref<'_, ConvergedState>> {
252        let r = self.state.borrow();
253        r.as_ref()?;
254        Some(Ref::map(r, |o| {
255            o.as_ref()
256                .unwrap_or_else(|| unreachable!("checked is_some above"))
257        }))
258    }
259
260    /// Total dimension of the compound KKT vector (sum of
261    /// `block_dims`). Returns `None` if no converged factor is held.
262    pub fn kkt_dim(&self) -> Option<usize> {
263        self.converged().map(|c| c.kkt_dim())
264    }
265
266    /// Block dimensions of the compound KKT vector in
267    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order. Returns `None` if
268    /// no converged factor is held.
269    pub fn block_dims(&self) -> Option<[usize; 8]> {
270        self.converged().map(|c| c.block_dims())
271    }
272
273    /// Classify every bounded variable and every finite-bounded
274    /// inequality row of the converged solve by activity: see
275    /// [`crate::activity`] and
276    /// `dev-notes/covariance-information-roadmap.md` item 0 (gh #362).
277    ///
278    /// Requires the held solve to have run with `bound_relax_factor=0`
279    /// (the Ipopt default is `1e-8`): with relaxed bounds the solver's
280    /// slacks are measured against perturbed bounds, and the
281    /// complementarity products the classifier reads no longer track
282    /// `μ`.
283    ///
284    /// The guard reads
285    /// [`ConvergedState::bound_relax_factor`] — the value that solve
286    /// ran under — not the application's current options. Setting the
287    /// option after the fact neither unlocks a state whose bounds were
288    /// relaxed nor invalidates one whose bounds were not; re-solve to
289    /// change the answer.
290    pub fn classify_activity(&self) -> Result<ActivityReport, SolverError> {
291        let state = self.state.borrow();
292        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
293        let brf = state.bound_relax_factor;
294        if brf != 0.0 {
295            return Err(SolverError::BadOptions(format!(
296                "classify_activity requires bound_relax_factor=0, but the \
297                 held solve ran with {brf:e}: relaxed bounds shift the \
298                 slacks the classifier reads. Set the option and solve() \
299                 again — changing it now does not re-measure the slacks."
300            )));
301        }
302        Ok(crate::activity::compute(&state.backsolver))
303    }
304
305    /// The gradient of user constraint row `user_row` at the converged
306    /// iterate, in user variable order (length `n_full_x`) and in
307    /// **natural (unscaled) units**: the internal Jacobian row carries
308    /// the solver's per-row `c_scale`/`d_scale`, which is divided out
309    /// here, so this is the gradient of the row as the user wrote it.
310    /// Equality and inequality rows alike; entries for fixed
311    /// (`make_parameter`-removed) variables are 0 because the solve
312    /// dropped their columns. Errors on an out-of-range row.
313    ///
314    /// Serves the covariance roadmap's item 1: a binding row's normal
315    /// restricted to the fitted block is the projection direction.
316    pub fn row_normal(&self, user_row: usize) -> Result<Vec<Number>, SolverError> {
317        let state = self.state.borrow();
318        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
319        crate::activity::row_normal(&state.backsolver, user_row).map_err(|m| {
320            SolverError::BadShape {
321                what: "row_normal constraint index",
322                got: user_row,
323                expected: m,
324            }
325        })
326    }
327
328    /// The exact Lagrangian Hessian times a user-space vector, in
329    /// user variable order and natural units (see
330    /// [`crate::activity::hessian_vec`]). Errors on a length mismatch.
331    pub fn hessian_vec(&self, v: &[Number]) -> Result<Vec<Number>, SolverError> {
332        let state = self.state.borrow();
333        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
334        crate::activity::hessian_vec(&state.backsolver, v).map_err(|n| SolverError::BadShape {
335            what: "hessian_vec vector length",
336            got: v.len(),
337            expected: n,
338        })
339    }
340
341    /// Solve `K · lhs = rhs` against the converged KKT factor. Both
342    /// slices must have length `kkt_dim()`; the layout is the flat
343    /// `x || s || y_c || y_d || z_l || z_u || v_l || v_u` packing.
344    ///
345    /// `K` here is the **natural-units** (unscaled) KKT matrix: when
346    /// the IPM solved with active NLP scaling, the backsolver scales
347    /// the RHS/solution (all eight blocks, including the z/v
348    /// bound-multiplier rows) so callers pass and receive data in the
349    /// user's own units (pounce#128) — see
350    /// [`crate::PdSensBacksolver::solve`]. For the raw scaled-space
351    /// back-solve use [`Self::kkt_solve_scaled`].
352    pub fn kkt_solve(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
353        self.kkt_solve_impl(rhs, lhs, false)
354    }
355
356    /// [`Self::kkt_solve`] without the natural-units conjugation: the
357    /// back-solve runs against the factor exactly as the IPM holds it
358    /// (the solver's internal scaled space). Identical to `kkt_solve`
359    /// when no NLP scaling is active. "Scaled space" includes a
360    /// `user-scaling` change of variables (gh#486), so on such a solve
361    /// the `x` and `z` blocks here are in the substituted coordinates
362    /// `x̃ = d ⊙ x`, not the model's.
363    pub fn kkt_solve_scaled(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
364        self.kkt_solve_impl(rhs, lhs, true)
365    }
366
367    fn kkt_solve_impl(
368        &self,
369        rhs: &[Number],
370        lhs: &mut [Number],
371        scaled: bool,
372    ) -> Result<(), SolverError> {
373        let state = self.state.borrow();
374        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
375        let total = state.backsolver.dim();
376        if rhs.len() != total {
377            return Err(SolverError::BadShape {
378                what: "rhs",
379                got: rhs.len(),
380                expected: total,
381            });
382        }
383        if lhs.len() != total {
384            return Err(SolverError::BadShape {
385                what: "lhs",
386                got: lhs.len(),
387                expected: total,
388            });
389        }
390        let ok = if scaled {
391            state.backsolver.solve_scaled_space(rhs, lhs)
392        } else {
393            state.backsolver.solve(rhs, lhs)
394        };
395        if ok {
396            Ok(())
397        } else {
398            Err(SolverError::BacksolveFailed)
399        }
400    }
401
402    /// Batched-RHS back-solve. `rhs_flat` and `lhs_flat` are row-major
403    /// `(n_rhs, kkt_dim)` buffers; each row is solved against the
404    /// same converged factor. Equivalent in result to looping
405    /// [`Self::kkt_solve`] but reuses one `IteratesVector` for the
406    /// RHS and one for the result across all `n_rhs` calls — see
407    /// [`crate::algorithm_backsolver::PdSensBacksolver::solve_many`].
408    pub fn kkt_solve_many(
409        &self,
410        rhs_flat: &[Number],
411        lhs_flat: &mut [Number],
412        n_rhs: usize,
413    ) -> Result<(), SolverError> {
414        self.kkt_solve_many_impl(rhs_flat, lhs_flat, n_rhs, false)
415    }
416
417    /// [`Self::kkt_solve_many`] without the natural-units
418    /// conjugation (the batched sibling of [`Self::kkt_solve_scaled`]).
419    pub fn kkt_solve_many_scaled(
420        &self,
421        rhs_flat: &[Number],
422        lhs_flat: &mut [Number],
423        n_rhs: usize,
424    ) -> Result<(), SolverError> {
425        self.kkt_solve_many_impl(rhs_flat, lhs_flat, n_rhs, true)
426    }
427
428    fn kkt_solve_many_impl(
429        &self,
430        rhs_flat: &[Number],
431        lhs_flat: &mut [Number],
432        n_rhs: usize,
433        scaled: bool,
434    ) -> Result<(), SolverError> {
435        let state = self.state.borrow();
436        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
437        let total = state.backsolver.dim();
438        let expected = n_rhs * total;
439        if rhs_flat.len() != expected {
440            return Err(SolverError::BadShape {
441                what: "rhs",
442                got: rhs_flat.len(),
443                expected,
444            });
445        }
446        if lhs_flat.len() != expected {
447            return Err(SolverError::BadShape {
448                what: "lhs",
449                got: lhs_flat.len(),
450                expected,
451            });
452        }
453        let ok = if scaled {
454            state
455                .backsolver
456                .solve_many_scaled_space(rhs_flat, lhs_flat, n_rhs)
457        } else {
458            state.backsolver.solve_many(rhs_flat, lhs_flat, n_rhs)
459        };
460        if ok {
461            Ok(())
462        } else {
463            Err(SolverError::BacksolveFailed)
464        }
465    }
466
467    /// First-order parametric step `Δx ≈ ∂x*/∂p · Δp` for a set of
468    /// pinned equality constraints. `pin_constraint_indices` are
469    /// 0-based indices into the user's `g(x)`; `deltas` is the
470    /// perturbation `Δp` (same length).
471    ///
472    /// Returns the `n_x`-long primal step. For the full KKT-space
473    /// step, use [`Self::kkt_solve`] directly.
474    pub fn parametric_step(
475        &self,
476        pin_constraint_indices: &[Index],
477        deltas: &[Number],
478    ) -> Result<Vec<Number>, SolverError> {
479        if pin_constraint_indices.len() != deltas.len() {
480            return Err(SolverError::BadShape {
481                what: "deltas",
482                got: deltas.len(),
483                expected: pin_constraint_indices.len(),
484            });
485        }
486        let state = self.state.borrow();
487        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
488
489        // Map user g-indices to y_c rows through the NLP's c/d-split
490        // permutation (pounce#128; matches `convenience.rs`).
491        let dims = state.backsolver.block_dims();
492        let n_x = dims[0];
493        let param_rows = state
494            .backsolver
495            .map_pin_g_to_kkt_rows(pin_constraint_indices)
496            .map_err(SolverError::SensComputationFailed)?;
497        let signs = vec![1; pin_constraint_indices.len()];
498        let a_data = IndexSchurData::from_parts(param_rows, signs)
499            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
500
501        let opts = SensOptions {
502            run_sens: true,
503            ..SensOptions::default()
504        };
505        let sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
506        let n_full = state.backsolver.dim();
507        let mut dx_full = vec![0.0; n_full];
508        if !sens_app.parametric_step(deltas, &mut dx_full) {
509            return Err(SolverError::SensComputationFailed(
510                "SensApplication::parametric_step failed".into(),
511            ));
512        }
513        dx_full.truncate(n_x);
514        Ok(dx_full)
515    }
516
517    /// Full KKT-space parametric step for a set of pinned equality
518    /// constraints: the same computation as [`Self::parametric_step`],
519    /// returned WITHOUT truncating to the primal block. The layout is
520    /// the compound KKT vector `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)`;
521    /// use [`Self::block_dims`] for the block sizes and
522    /// [`Self::g_multiplier_rows`] to locate a constraint's multiplier
523    /// row. This exposes the multiplier sensitivities `∂λ*/∂p`
524    /// alongside the primal step.
525    pub fn parametric_step_full(
526        &self,
527        pin_constraint_indices: &[Index],
528        deltas: &[Number],
529    ) -> Result<Vec<Number>, SolverError> {
530        if pin_constraint_indices.len() != deltas.len() {
531            return Err(SolverError::BadShape {
532                what: "deltas",
533                got: deltas.len(),
534                expected: pin_constraint_indices.len(),
535            });
536        }
537        let state = self.state.borrow();
538        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
539
540        let param_rows = state
541            .backsolver
542            .map_pin_g_to_kkt_rows(pin_constraint_indices)
543            .map_err(SolverError::SensComputationFailed)?;
544        let signs = vec![1; pin_constraint_indices.len()];
545        let a_data = IndexSchurData::from_parts(param_rows, signs)
546            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
547
548        let opts = SensOptions {
549            run_sens: true,
550            ..SensOptions::default()
551        };
552        let sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
553        let n_full = state.backsolver.dim();
554        let mut dx_full = vec![0.0; n_full];
555        if !sens_app.parametric_step(deltas, &mut dx_full) {
556            return Err(SolverError::SensComputationFailed(
557                "SensApplication::parametric_step failed".into(),
558            ));
559        }
560        Ok(dx_full)
561    }
562
563    /// Flat rows of the compound KKT vector holding the equality
564    /// multipliers `y_c` for the given 0-based **full-g** constraint
565    /// indices. `None` for inequalities (their multipliers live in the
566    /// `y_d` block; mapping those is not exposed here). Row `r` of a
567    /// [`Self::parametric_step_full`] result is then `∂λ_g/∂p · Δp`.
568    pub fn g_multiplier_rows(
569        &self,
570        g_indices: &[Index],
571    ) -> Result<Vec<Option<Index>>, SolverError> {
572        let state = self.state.borrow();
573        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
574        let dims = state.backsolver.block_dims();
575        let y_c_offset = (dims[0] + dims[1]) as Index;
576        Ok(g_indices
577            .iter()
578            .map(|&g| {
579                state
580                    .backsolver
581                    .full_g_to_c_block(g)
582                    .map(|pos| y_c_offset + pos)
583            })
584            .collect())
585    }
586
587    /// Flat rows of the compound KKT vector holding the primal values
588    /// `x` for the given 0-based **full-x** variable indices. `None`
589    /// where the solve removed the column (`x_l == x_u` under
590    /// `fixed_variable_treatment = make_parameter`), which has no row
591    /// in the factor at all.
592    ///
593    /// The `x` counterpart of [`Self::g_multiplier_rows`], and needed
594    /// for the same reason: a caller holding user-space indices — from
595    /// the `.col` file, from [`Self::classify_activity`], from
596    /// [`Self::row_normal`] — cannot index the factor with them
597    /// directly. Row `r` of a [`Self::parametric_step_full`] result is
598    /// then `∂x/∂p · Δp` for that variable, and `e_r` is the unit
599    /// vector selecting its column in a [`Self::kkt_solve`].
600    pub fn x_primal_rows(&self, x_indices: &[Index]) -> Result<Vec<Option<Index>>, SolverError> {
601        let state = self.state.borrow();
602        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
603        let n_full = state.backsolver.n_full_x();
604        // out of range must not masquerade as "removed as fixed": the
605        // NLP map returns None for both, and the caller's whole reason
606        // for asking is that it cannot tell the spaces apart itself
607        if let Some(&bad) = x_indices.iter().find(|&&i| i < 0 || i >= n_full) {
608            return Err(SolverError::BadShape {
609                what: "x_primal_rows variable index",
610                got: bad as usize,
611                expected: n_full as usize,
612            });
613        }
614        // the x block starts at flat index 0, so the var-x position IS
615        // the KKT row; the offset stays explicit for the day it is not
616        Ok(x_indices
617            .iter()
618            .map(|&i| state.backsolver.full_x_to_var_x(i))
619            .collect())
620    }
621
622    /// The user TNLP's variable count: the length of a full-x report
623    /// and the domain of [`Self::x_primal_rows`].
624    pub fn n_full_x(&self) -> Result<usize, SolverError> {
625        let state = self.state.borrow();
626        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
627        Ok(state.backsolver.n_full_x() as usize)
628    }
629
630    /// Reduced Hessian `H_R = obj_scal · B K⁻¹ Bᵀ` over the pinned
631    /// equality-constraint rows, where `B` selects the
632    /// `pin_constraint_indices` rows of the y_c block and `K` is the
633    /// **natural-units** (unscaled) KKT matrix — active NLP scaling
634    /// is undone by the backsolver, so `−inv(H_R)` is directly the
635    /// parameter covariance regardless of `nlp_scaling_method`
636    /// (pounce#128). `obj_scal` survives as a plain extra multiplier
637    /// (default 1.0); it is no longer needed to recover natural units.
638    /// Returns the `n²`-long column-major dense matrix
639    /// (`n = pin_constraint_indices.len()`).
640    ///
641    /// Equivalent to [`crate::SensSolve::with_reduced_hessian`] but
642    /// usable post-hoc on a held `Solver`. For the solver-space
643    /// (pre-#128) value use [`Self::compute_reduced_hessian_scaled`];
644    /// the factors themselves are exposed via [`Self::nlp_scaling`] /
645    /// [`Self::pin_g_scaling`].
646    pub fn compute_reduced_hessian(
647        &self,
648        pin_constraint_indices: &[Index],
649        obj_scal: Number,
650    ) -> Result<Vec<Number>, SolverError> {
651        let state = self.state.borrow();
652        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
653        let n = pin_constraint_indices.len();
654        let param_rows = state
655            .backsolver
656            .map_pin_g_to_kkt_rows(pin_constraint_indices)
657            .map_err(SolverError::SensComputationFailed)?;
658        let signs = vec![1; n];
659        let a_data = IndexSchurData::from_parts(param_rows, signs)
660            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
661        let opts = SensOptions {
662            compute_red_hessian: true,
663            obj_scal,
664            ..SensOptions::default()
665        };
666        let mut sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
667        let mut hr = vec![0.0; n * n];
668        if !sens_app.compute_reduced_hessian(&mut hr) {
669            return Err(SolverError::SensComputationFailed(
670                "SensApplication::compute_reduced_hessian failed".into(),
671            ));
672        }
673        Ok(hr)
674    }
675
676    /// The reduced Hessian as the solver's internal **scaled** space
677    /// sees it — the value [`Self::compute_reduced_hessian`] returned
678    /// before pounce#128: `H̃_ij = (df / (dc_i·dc_j)) · H_ij`.
679    /// Identical to `compute_reduced_hessian` when no NLP scaling is
680    /// active.
681    pub fn compute_reduced_hessian_scaled(
682        &self,
683        pin_constraint_indices: &[Index],
684        obj_scal: Number,
685    ) -> Result<Vec<Number>, SolverError> {
686        let mut hr = self.compute_reduced_hessian(pin_constraint_indices, obj_scal)?;
687        let state = self.state.borrow();
688        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
689        let df = state.backsolver.obj_scaling_factor();
690        let dc = state
691            .backsolver
692            .pin_c_scales(pin_constraint_indices)
693            .map_err(SolverError::SensComputationFailed)?;
694        crate::reduced_hessian::scale_to_solver_space(&mut hr, df, &dc);
695        Ok(hr)
696    }
697
698    /// Effective NLP scaling the IPM applied on the most recent
699    /// converged solve: `(obj_scaling_factor, c_scale, d_scale)`.
700    /// `(1.0, None, None)` ⇔ no scaling was active. The vectors are
701    /// per-row factors over the algorithm's equality (`c`) and
702    /// inequality (`d`) blocks.
703    pub fn nlp_scaling(
704        &self,
705    ) -> Result<(Number, Option<Vec<Number>>, Option<Vec<Number>>), SolverError> {
706        let state = self.state.borrow();
707        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
708        Ok(state.backsolver.nlp_scaling())
709    }
710
711    /// The per-variable `user-scaling` factors `d` the held solve ran
712    /// under (gh#486), in the user TNLP's **full-x** space, or `None`
713    /// when the solve applied no change of variables.
714    ///
715    /// Every accessor on this type already reports natural units, so
716    /// this is diagnostic rather than a correction a caller has to
717    /// apply — it answers "was this solve conditioned, and by how
718    /// much", the x-axis counterpart of [`Self::nlp_scaling`].
719    pub fn variable_scaling(&self) -> Result<Option<Vec<Number>>, SolverError> {
720        let state = self.state.borrow();
721        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
722        Ok(state.backsolver.variable_scaling_full().map(|d| d.to_vec()))
723    }
724
725    /// Inertia-correction perturbations `(δ_x, δ_s, δ_c, δ_d)` baked
726    /// into the held KKT factor. All zero ⇔ the final factorization
727    /// was unregularized and the natural-units back-solves invert the
728    /// exact KKT matrix — see
729    /// [`crate::PdSensBacksolver::kkt_perturbations`].
730    pub fn kkt_perturbations(&self) -> Result<[Number; 4], SolverError> {
731        let state = self.state.borrow();
732        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
733        Ok(state.backsolver.kkt_perturbations())
734    }
735
736    /// Per-pin equality-row scaling factors `dc_i` (1.0 entries when
737    /// no constraint scaling is active), ordered like
738    /// `pin_constraint_indices`.
739    pub fn pin_g_scaling(
740        &self,
741        pin_constraint_indices: &[Index],
742    ) -> Result<Vec<Number>, SolverError> {
743        let state = self.state.borrow();
744        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
745        state
746            .backsolver
747            .pin_c_scales(pin_constraint_indices)
748            .map_err(SolverError::SensComputationFailed)
749    }
750}