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//! # Where the calculations live
42//!
43//! Every calculation over the converged factor is
44//! [`pounce_sens_core::converged::ConvergedSolve`]'s, written against the
45//! `BarrierIterate` trait so it runs over any interior-point engine.
46//! `Solver` runs pounce's own solve, holds its factor, and builds a
47//! `ConvergedSolve` over it for each call. The two methods that read
48//! pounce's own scaled space, [`Solver::kkt_solve_scaled`] and
49//! [`Solver::variable_scaling`], stay here.
50
51use std::cell::{Ref, RefCell};
52use std::rc::Rc;
53
54use pounce_algorithm::application::IpoptApplication;
55use pounce_common::types::{Index, Number};
56use pounce_nlp::TNLP;
57use pounce_nlp::return_codes::ApplicationReturnStatus;
58use pounce_sens_core::converged::ConvergedSolve;
59
60use crate::PdSensBacksolver;
61use crate::activity::{ActivityReport, ReducedActivityReport, ReducedRowActivityReport};
62use crate::backsolver::SensBacksolver;
63use crate::boundcheck::{PathOperator, PathSegment, RefineStop, WeakBound};
64use crate::corrector::CorrectorReport;
65use crate::vec_util::dense_to_vec;
66
67pub use pounce_sens_core::converged::{BARRIER_SIGN, SolverError};
68
69/// State captured at convergence: the user-visible iterate plus the
70/// `PdSensBacksolver` that wraps the converged KKT factor.
71///
72/// Read this via [`Solver::converged`].
73pub struct ConvergedState {
74    /// IPM return status of the most recent solve.
75    pub status: ApplicationReturnStatus,
76    /// Final primal iterate `x*` (length `n_x`), in the user's own
77    /// units: a `user-scaling` change of variables is undone here, so
78    /// this is `x`, never the algorithm's `x̃ = d ⊙ x` (gh#486).
79    pub x: Vec<Number>,
80    /// Final objective value `f(x*)`.
81    pub obj_val: Number,
82    /// `bound_relax_factor` **as the solve that produced this state
83    /// ran with it**, not as the application's options read today.
84    /// The bounds were relaxed (or not) once, during this solve; a
85    /// later `set_numeric_value` cannot change what the held slacks
86    /// were measured against, so post-solve calls whose validity
87    /// depends on unrelaxed bounds must guard on this value. See
88    /// [`Solver::classify_activity`].
89    pub bound_relax_factor: Number,
90    /// Whether the solve computed exact Hessians, **as it ran**. A
91    /// `limited-memory` solve's `IpoptData::w` is the quasi-Newton
92    /// matrix, and there is no exact Hessian to evaluate at another
93    /// point, so the corrector keeps that matrix instead of
94    /// refreshing it at the predicted iterate.
95    pub exact_hessian: bool,
96    /// Converged KKT-factor wrapper. Owns `Rc` handles to the
97    /// `PdFullSpaceSolver`, the IpoptData / Cq, and the NLP, so it
98    /// outlives the IPM call frame.
99    backsolver: PdSensBacksolver,
100}
101
102impl ConvergedState {
103    /// Block dimensions of the compound KKT vector in
104    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order.
105    pub fn block_dims(&self) -> [usize; 8] {
106        self.backsolver.block_dims()
107    }
108
109    /// Total dimension of the compound KKT vector (sum of `block_dims`).
110    pub fn kkt_dim(&self) -> usize {
111        self.backsolver.dim()
112    }
113}
114
115/// Session-style solver: holds an [`IpoptApplication`], its TNLP, and
116/// the converged factor between calls.
117pub struct Solver {
118    app: IpoptApplication,
119    tnlp: Rc<RefCell<dyn TNLP>>,
120    /// Side channel populated by the `on_converged` callback installed
121    /// in [`Self::solve`]. The `RefCell<Option<…>>` shape mirrors the
122    /// pattern in [`crate::convenience`] (the callback closure needs
123    /// shared mutable access; the `Option` is `None` before the first
124    /// solve and gets overwritten on each call).
125    state: Rc<RefCell<Option<ConvergedState>>>,
126}
127
128impl Solver {
129    /// Build a new session. The `app` should already have its options
130    /// configured and `initialize()` called.
131    pub fn new(app: IpoptApplication, tnlp: Rc<RefCell<dyn TNLP>>) -> Self {
132        Self {
133            app,
134            tnlp,
135            state: Rc::new(RefCell::new(None)),
136        }
137    }
138
139    /// Borrow the underlying `IpoptApplication` (e.g. to read its
140    /// options table after a solve). Mutation between `solve` calls is
141    /// supported via [`Self::app_mut`].
142    pub fn app(&self) -> &IpoptApplication {
143        &self.app
144    }
145
146    /// Mutable borrow of the underlying `IpoptApplication`. Useful for
147    /// reconfiguring options before a follow-up `solve()`. Note that
148    /// changing options that affect the KKT linear system between
149    /// calls will invalidate the cached factor; the next `solve()`
150    /// rebuilds it.
151    pub fn app_mut(&mut self) -> &mut IpoptApplication {
152        &mut self.app
153    }
154
155    /// Run the IPM to convergence. On a successful solve the
156    /// [`ConvergedState`] (including the KKT backsolver) is stashed
157    /// inside the `Solver` and accessible via [`Self::converged`].
158    ///
159    /// Each call to `solve()` overwrites the previous converged
160    /// state; the previously held factor is dropped.
161    pub fn solve(&mut self) -> ApplicationReturnStatus {
162        // Clear any previous state so a failed re-solve doesn't leave
163        // a stale factor visible.
164        self.state.borrow_mut().take();
165
166        // Snapshot the options this solve will run under, before it
167        // runs. `bound_relax_factor` is consumed once, when the NLP
168        // relaxes its bounds; reading it back at query time would
169        // describe the application's options rather than the state
170        // being queried. The registry supplies its own default when
171        // the option is unset, so no second copy of the default lives
172        // here.
173        let brf = self
174            .app
175            .options()
176            .get_numeric_value("bound_relax_factor", "")
177            .map(|(v, _)| v)
178            .expect("bound_relax_factor is a registered core option");
179        let exact_hessian = self
180            .app
181            .options()
182            .get_string_value("hessian_approximation", "")
183            .map(|(v, _)| v == "exact")
184            .expect("hessian_approximation is a registered core option");
185
186        // `set_on_converged` fires once per converged *attempt*, and when a
187        // later attempt loses (the mu fallback, the gh#884 retry, the gh#983
188        // scale audit) the reported answer is replayed from an earlier one.
189        // Keeping only the last firing therefore held the DISCARDED attempt's
190        // point and factor beside the winner's status. Every firing is kept
191        // under its id until the solve says which one it reported; the KKT
192        // state cannot be rewound, but each attempt's own handles keep it
193        // alive.
194        let attempts: Rc<RefCell<Vec<(u64, ConvergedState)>>> = Rc::new(RefCell::new(Vec::new()));
195        let state_cb = Rc::clone(&attempts);
196        self.app
197            .set_on_converged(Box::new(move |data, cq, nlp, pd| {
198                let (curr, id) = {
199                    let d = data.borrow();
200                    match (d.curr.clone(), d.on_converged_id) {
201                        (Some(c), Some(id)) => (c, id),
202                        _ => return,
203                    }
204                };
205                let backsolver = match PdSensBacksolver::new(data, cq, nlp, Rc::clone(&pd)) {
206                    Ok(b) => b,
207                    Err(e) => {
208                        // No session state is stored, so post-solve
209                        // calls will report NotConverged; at least say
210                        // why on stderr rather than failing silently.
211                        eprintln!("pounce: Solver could not capture the KKT factor: {e}");
212                        return;
213                    }
214                };
215                // The algorithm's iterate is `x̃ = d ⊙ x` when the
216                // solve ran under a change of variables (gh#486): this
217                // capture reads the iterate, not the
218                // `finalize_solution` payload, so it undoes the
219                // substitution itself. The backsolver already read the
220                // factors off the NLP, in this same var-x space.
221                let mut x = dense_to_vec(&*curr.x);
222                if let Some(d) = backsolver.variable_scaling() {
223                    debug_assert_eq!(x.len(), d.len());
224                    for (xi, &di) in x.iter_mut().zip(d.iter()) {
225                        *xi /= di;
226                    }
227                }
228                let obj_val = cq.borrow_mut().curr_f();
229                // Status is overwritten with the real value after
230                // optimize_tnlp returns.
231                state_cb.borrow_mut().push((
232                    id,
233                    ConvergedState {
234                        status: ApplicationReturnStatus::InternalError,
235                        x,
236                        obj_val,
237                        bound_relax_factor: brf,
238                        exact_hessian,
239                        backsolver,
240                    },
241                ));
242            }));
243
244        let status = crate::optimize_tnlp_for_sensitivity(&mut self.app, Rc::clone(&self.tnlp));
245        // Hold the firing whose attempt produced the reported answer; drop the
246        // rest, which describe attempts the solver discarded.
247        let reported = self.app.reported_on_converged_id();
248        let held = attempts
249            .borrow_mut()
250            .drain(..)
251            .find(|(id, _)| Some(*id) == reported)
252            .map(|(_, mut s)| {
253                s.status = status;
254                s
255            });
256        *self.state.borrow_mut() = held;
257        status
258    }
259
260    /// Borrow the converged state, if a successful solve has been
261    /// run. Returns `None` if no solve has run or if the most recent
262    /// solve failed before reaching convergence.
263    pub fn converged(&self) -> Option<Ref<'_, ConvergedState>> {
264        let r = self.state.borrow();
265        r.as_ref()?;
266        Some(Ref::map(r, |o| {
267            o.as_ref()
268                .unwrap_or_else(|| unreachable!("checked is_some above"))
269        }))
270    }
271
272    /// Total dimension of the compound KKT vector (sum of
273    /// `block_dims`). Returns `None` if no converged factor is held.
274    pub fn kkt_dim(&self) -> Option<usize> {
275        self.converged().map(|c| c.kkt_dim())
276    }
277
278    /// Block dimensions of the compound KKT vector in
279    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order. Returns `None` if
280    /// no converged factor is held.
281    pub fn block_dims(&self) -> Option<[usize; 8]> {
282        self.converged().map(|c| c.block_dims())
283    }
284
285    /// The calculations over the held factor, or
286    /// [`SolverError::NotConverged`] when there is none.
287    pub fn with_converged<R>(
288        &self,
289        f: impl FnOnce(&ConvergedSolve<'_, PdSensBacksolver>) -> Result<R, SolverError>,
290    ) -> Result<R, SolverError> {
291        let state = self.state.borrow();
292        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
293        f(&ConvergedSolve::new(
294            &state.backsolver,
295            state.bound_relax_factor,
296            state.exact_hessian,
297        ))
298    }
299
300    /// Classify every bounded variable and every finite-bounded
301    /// inequality row of the converged solve by activity. See
302    /// [`ConvergedSolve::classify_activity`].
303    ///
304    /// Requires the held solve to have run with `bound_relax_factor=0`.
305    /// The guard reads [`ConvergedState::bound_relax_factor`], the value
306    /// that solve ran under, not the application's current options.
307    /// Setting the option after the fact neither unlocks a state whose
308    /// bounds were relaxed nor invalidates one whose bounds were not.
309    /// Re-solve to change the answer.
310    pub fn classify_activity(&self) -> Result<ActivityReport, SolverError> {
311        self.with_converged(|c| c.classify_activity())
312    }
313
314    /// The per-variable verdict for `user_vars`, re-measured against the
315    /// reduced curvature along each coordinate. See
316    /// [`ConvergedSolve::reduced_activity`].
317    pub fn reduced_activity(
318        &self,
319        user_vars: &[usize],
320    ) -> Result<ReducedActivityReport, SolverError> {
321        self.with_converged(|c| c.reduced_activity(user_vars))
322    }
323
324    /// The per-row verdict for `user_rows`, re-measured against the
325    /// reduced curvature along each row's gradient. See
326    /// [`ConvergedSolve::reduced_row_activity`].
327    pub fn reduced_row_activity(
328        &self,
329        user_rows: &[usize],
330    ) -> Result<ReducedRowActivityReport, SolverError> {
331        self.with_converged(|c| c.reduced_row_activity(user_rows))
332    }
333
334    /// The gradient of user constraint row `user_row`, in user variable
335    /// order and natural units. See [`ConvergedSolve::row_normal`].
336    pub fn row_normal(&self, user_row: usize) -> Result<Vec<Number>, SolverError> {
337        self.with_converged(|c| c.row_normal(user_row))
338    }
339
340    /// The exact Lagrangian Hessian times a user-space vector, in natural
341    /// units. See [`ConvergedSolve::hessian_vec`].
342    pub fn hessian_vec(&self, v: &[Number]) -> Result<Vec<Number>, SolverError> {
343        self.with_converged(|c| c.hessian_vec(v))
344    }
345
346    /// Solve `K · lhs = rhs` against the converged KKT factor. Both
347    /// slices must have length `kkt_dim()`, and the layout is the flat
348    /// `x || s || y_c || y_d || z_l || z_u || v_l || v_u` packing.
349    ///
350    /// `K` here is the **natural-units** (unscaled) KKT matrix: when
351    /// the IPM solved with active NLP scaling, the backsolver scales
352    /// the RHS/solution (all eight blocks, including the z/v
353    /// bound-multiplier rows) so callers pass and receive data in the
354    /// user's own units (pounce#128). See
355    /// [`crate::PdSensBacksolver::solve`]. For the raw scaled-space
356    /// back-solve use [`Self::kkt_solve_scaled`].
357    pub fn kkt_solve(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
358        self.with_converged(|c| c.kkt_solve(rhs, lhs))
359    }
360
361    /// [`Self::kkt_solve`] without the natural-units conjugation: the
362    /// back-solve runs against the factor exactly as the IPM holds it
363    /// (the solver's internal scaled space). Identical to `kkt_solve`
364    /// when no NLP scaling is active. "Scaled space" includes a
365    /// `user-scaling` change of variables (gh#486), so on such a solve
366    /// the `x` and `z` blocks here are in the substituted coordinates
367    /// `x̃ = d ⊙ x`, not the model's.
368    pub fn kkt_solve_scaled(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
369        let state = self.state.borrow();
370        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
371        let total = state.backsolver.dim();
372        if rhs.len() != total {
373            return Err(SolverError::BadShape {
374                what: "rhs",
375                got: rhs.len(),
376                expected: total,
377            });
378        }
379        if lhs.len() != total {
380            return Err(SolverError::BadShape {
381                what: "lhs",
382                got: lhs.len(),
383                expected: total,
384            });
385        }
386        if state.backsolver.solve_scaled_space(rhs, lhs) {
387            Ok(())
388        } else {
389            Err(SolverError::BacksolveFailed)
390        }
391    }
392
393    /// Batched-RHS back-solve. `rhs_flat` and `lhs_flat` are row-major
394    /// `(n_rhs, kkt_dim)` buffers, each row solved against the same
395    /// converged factor. Equivalent in result to looping
396    /// [`Self::kkt_solve`] but reuses one `IteratesVector` for the RHS
397    /// and one for the result across all `n_rhs` calls. See
398    /// [`crate::algorithm_backsolver::PdSensBacksolver::solve_many`].
399    pub fn kkt_solve_many(
400        &self,
401        rhs_flat: &[Number],
402        lhs_flat: &mut [Number],
403        n_rhs: usize,
404    ) -> Result<(), SolverError> {
405        self.with_converged(|c| c.kkt_solve_many(rhs_flat, lhs_flat, n_rhs))
406    }
407
408    /// [`Self::kkt_solve_many`] without the natural-units conjugation
409    /// (the batched sibling of [`Self::kkt_solve_scaled`]).
410    pub fn kkt_solve_many_scaled(
411        &self,
412        rhs_flat: &[Number],
413        lhs_flat: &mut [Number],
414        n_rhs: usize,
415    ) -> Result<(), SolverError> {
416        let state = self.state.borrow();
417        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
418        let total = state.backsolver.dim();
419        let expected = n_rhs * total;
420        if rhs_flat.len() != expected {
421            return Err(SolverError::BadShape {
422                what: "rhs",
423                got: rhs_flat.len(),
424                expected,
425            });
426        }
427        if lhs_flat.len() != expected {
428            return Err(SolverError::BadShape {
429                what: "lhs",
430                got: lhs_flat.len(),
431                expected,
432            });
433        }
434        if state
435            .backsolver
436            .solve_many_scaled_space(rhs_flat, lhs_flat, n_rhs)
437        {
438            Ok(())
439        } else {
440            Err(SolverError::BacksolveFailed)
441        }
442    }
443
444    /// First-order parametric step `Δx ≈ ∂x*/∂p · Δp` for a set of
445    /// pinned equality constraints. See
446    /// [`ConvergedSolve::parametric_step`].
447    pub fn parametric_step(
448        &self,
449        pin_constraint_indices: &[Index],
450        deltas: &[Number],
451    ) -> Result<Vec<Number>, SolverError> {
452        check_deltas(pin_constraint_indices, deltas)?;
453        self.with_converged(|c| c.parametric_step(pin_constraint_indices, deltas))
454    }
455
456    /// Parametric step with the bounds respected by pinning, not by
457    /// clamping. See [`ConvergedSolve::parametric_step_bounded`].
458    pub fn parametric_step_bounded(
459        &self,
460        pin_constraint_indices: &[Index],
461        deltas: &[Number],
462        max_iter: usize,
463        bound_eps: Option<Number>,
464    ) -> Result<(Vec<Number>, Vec<Index>, RefineStop), SolverError> {
465        self.with_converged(|c| {
466            c.parametric_step_bounded(pin_constraint_indices, deltas, max_iter, bound_eps)
467        })
468    }
469
470    /// Parametric step taken along the path of active-set changes. See
471    /// [`ConvergedSolve::parametric_step_path`].
472    pub fn parametric_step_path(
473        &self,
474        pin_constraint_indices: &[Index],
475        deltas: &[Number],
476        max_iter: usize,
477    ) -> Result<(Vec<Number>, Vec<PathSegment>), SolverError> {
478        self.with_converged(|c| c.parametric_step_path(pin_constraint_indices, deltas, max_iter))
479    }
480
481    /// [`Self::parametric_step_path`] with the weak-row decision supplied
482    /// by the caller. See [`ConvergedSolve::parametric_step_path_decided`].
483    pub fn parametric_step_path_decided(
484        &self,
485        pin_constraint_indices: &[Index],
486        deltas: &[Number],
487        max_iter: usize,
488        held_var_rows: &[Index],
489    ) -> Result<(Vec<Number>, Vec<PathSegment>), SolverError> {
490        self.with_converged(|c| {
491            c.parametric_step_path_decided(pin_constraint_indices, deltas, max_iter, held_var_rows)
492        })
493    }
494
495    /// Newton iterations on the barrier system, refining a step that some
496    /// mode already produced. See [`ConvergedSolve::correct_step`].
497    pub fn correct_step(
498        &self,
499        pin_constraint_indices: &[Index],
500        deltas: &[Number],
501        step: &[Number],
502        max_iter: usize,
503    ) -> Result<(Vec<Number>, CorrectorReport), SolverError> {
504        self.with_converged(|c| c.correct_step(pin_constraint_indices, deltas, step, max_iter))
505    }
506
507    /// [`Self::parametric_step_bounded`] with the weak-row decision
508    /// supplied by the caller. See
509    /// [`ConvergedSolve::parametric_step_bounded_decided`].
510    pub fn parametric_step_bounded_decided(
511        &self,
512        pin_constraint_indices: &[Index],
513        deltas: &[Number],
514        max_iter: usize,
515        held_var_rows: &[Index],
516        bound_eps: Option<Number>,
517    ) -> Result<(Vec<Number>, Vec<Index>, RefineStop), SolverError> {
518        self.with_converged(|c| {
519            c.parametric_step_bounded_decided(
520                pin_constraint_indices,
521                deltas,
522                max_iter,
523                held_var_rows,
524                bound_eps,
525            )
526        })
527    }
528
529    /// [`crate::boundcheck::path_direction`] for a working set the caller
530    /// names. See [`ConvergedSolve::path_direction_decided`].
531    pub fn path_direction_decided(
532        &self,
533        pin_constraint_indices: &[Index],
534        deltas: &[Number],
535        released_bound_rows: &[Index],
536        held_primal_rows: &[Index],
537        operator: PathOperator,
538    ) -> Result<(Vec<Number>, Vec<Number>), SolverError> {
539        self.with_converged(|c| {
540            c.path_direction_decided(
541                pin_constraint_indices,
542                deltas,
543                released_bound_rows,
544                held_primal_rows,
545                operator,
546            )
547        })
548    }
549
550    /// The all-released step. See
551    /// [`ConvergedSolve::parametric_step_release_all`].
552    pub fn parametric_step_release_all(
553        &self,
554        pin_constraint_indices: &[Index],
555        deltas: &[Number],
556    ) -> Result<(Vec<Number>, usize), SolverError> {
557        self.with_converged(|c| c.parametric_step_release_all(pin_constraint_indices, deltas))
558    }
559
560    /// The eq. 14 directional derivative. See
561    /// [`ConvergedSolve::parametric_step_directional`].
562    pub fn parametric_step_directional(
563        &self,
564        pin_constraint_indices: &[Index],
565        deltas: &[Number],
566        max_iter: usize,
567    ) -> Result<(Vec<Number>, Vec<usize>, usize), SolverError> {
568        self.with_converged(|c| {
569            c.parametric_step_directional(pin_constraint_indices, deltas, max_iter)
570        })
571    }
572
573    /// The bounds the activity classifier could not call at the base
574    /// point. See [`ConvergedSolve::weakly_active_bounds`].
575    pub fn weakly_active_bounds(&self) -> Result<Vec<WeakBound>, SolverError> {
576        self.with_converged(|c| c.weakly_active_bounds())
577    }
578
579    /// Full KKT-space parametric step. See
580    /// [`ConvergedSolve::parametric_step_full`].
581    pub fn parametric_step_full(
582        &self,
583        pin_constraint_indices: &[Index],
584        deltas: &[Number],
585    ) -> Result<Vec<Number>, SolverError> {
586        check_deltas(pin_constraint_indices, deltas)?;
587        self.with_converged(|c| c.parametric_step_full(pin_constraint_indices, deltas))
588    }
589
590    /// The right-hand side [`Self::parametric_step_full`] answers,
591    /// barrier term included. See [`ConvergedSolve::parametric_rhs_full`].
592    pub fn parametric_rhs_full(
593        &self,
594        pin_constraint_indices: &[Index],
595        deltas: &[Number],
596    ) -> Result<Vec<Number>, SolverError> {
597        self.with_converged(|c| c.parametric_rhs_full(pin_constraint_indices, deltas))
598    }
599
600    /// Flat rows of the equality multipliers `y_c` for the given full-g
601    /// constraint indices. See [`ConvergedSolve::g_multiplier_rows`].
602    pub fn g_multiplier_rows(
603        &self,
604        g_indices: &[Index],
605    ) -> Result<Vec<Option<Index>>, SolverError> {
606        self.with_converged(|c| c.g_multiplier_rows(g_indices))
607    }
608
609    /// Flat rows of the inequality multipliers `y_d` for the given full-g
610    /// constraint indices. See [`ConvergedSolve::d_multiplier_rows`],
611    /// which says when such a row is a derivative.
612    pub fn d_multiplier_rows(
613        &self,
614        g_indices: &[Index],
615    ) -> Result<Vec<Option<Index>>, SolverError> {
616        self.with_converged(|c| c.d_multiplier_rows(g_indices))
617    }
618
619    /// Flat rows of an inequality's slack `s`. See
620    /// [`ConvergedSolve::d_slack_rows`].
621    pub fn d_slack_rows(&self, g_indices: &[Index]) -> Result<Vec<Option<Index>>, SolverError> {
622        self.with_converged(|c| c.d_slack_rows(g_indices))
623    }
624
625    /// Flat rows of the primal values `x` for the given full-x variable
626    /// indices. See [`ConvergedSolve::x_primal_rows`].
627    pub fn x_primal_rows(&self, x_indices: &[Index]) -> Result<Vec<Option<Index>>, SolverError> {
628        self.with_converged(|c| c.x_primal_rows(x_indices))
629    }
630
631    /// Flat rows of the bound multipliers `(z_l, z_u)` for the given
632    /// full-x variable indices. See
633    /// [`ConvergedSolve::bound_multiplier_rows`].
634    pub fn bound_multiplier_rows(
635        &self,
636        x_indices: &[Index],
637    ) -> Result<Vec<(Option<Index>, Option<Index>)>, SolverError> {
638        self.with_converged(|c| c.bound_multiplier_rows(x_indices))
639    }
640
641    /// The user TNLP's variable count: the length of a full-x report
642    /// and the domain of [`Self::x_primal_rows`].
643    pub fn n_full_x(&self) -> Result<usize, SolverError> {
644        self.with_converged(|c| c.n_full_x())
645    }
646
647    /// The user TNLP's constraint count: the length of a full-g
648    /// report and the domain of [`Self::reduced_row_activity`].
649    pub fn n_full_g(&self) -> Result<usize, SolverError> {
650        self.with_converged(|c| c.n_full_g())
651    }
652
653    /// The negated reduced Hessian `−H_R` over the pinned equality rows,
654    /// in natural units, so `−inv` of it is the parameter covariance.
655    /// See [`ConvergedSolve::compute_reduced_hessian`] for the sign
656    /// convention (gh#937), pinned by
657    /// `tests/issue_937_reduced_hessian_sign.rs`.
658    ///
659    /// Equivalent to [`crate::SensSolve::with_reduced_hessian`] but
660    /// usable post-hoc on a held `Solver`. For the solver-space value use
661    /// [`Self::compute_reduced_hessian_scaled`].
662    pub fn compute_reduced_hessian(
663        &self,
664        pin_constraint_indices: &[Index],
665        obj_scal: Number,
666    ) -> Result<Vec<Number>, SolverError> {
667        self.with_converged(|c| c.compute_reduced_hessian(pin_constraint_indices, obj_scal))
668    }
669
670    /// [`Self::compute_reduced_hessian`] plus its eigendecomposition,
671    /// `(H_R, eigenvalues, eigenvectors)`. The eigenvalues are those of
672    /// `−H_R` in ascending order, so they run stiffest mode first. See
673    /// [`ConvergedSolve::compute_reduced_hessian_eigen`].
674    pub fn compute_reduced_hessian_eigen(
675        &self,
676        pin_constraint_indices: &[Index],
677        obj_scal: Number,
678    ) -> Result<(Vec<Number>, Vec<Number>, Vec<Number>), SolverError> {
679        self.with_converged(|c| c.compute_reduced_hessian_eigen(pin_constraint_indices, obj_scal))
680    }
681
682    /// The reduced Hessian as the solver's internal scaled space sees it.
683    /// See [`ConvergedSolve::compute_reduced_hessian_scaled`].
684    pub fn compute_reduced_hessian_scaled(
685        &self,
686        pin_constraint_indices: &[Index],
687        obj_scal: Number,
688    ) -> Result<Vec<Number>, SolverError> {
689        self.with_converged(|c| c.compute_reduced_hessian_scaled(pin_constraint_indices, obj_scal))
690    }
691
692    /// Effective NLP scaling the IPM applied on the most recent
693    /// converged solve: `(obj_scaling_factor, c_scale, d_scale)`.
694    /// `(1.0, None, None)` means no scaling was active. The vectors are
695    /// per-row factors over the algorithm's equality (`c`) and
696    /// inequality (`d`) blocks.
697    pub fn nlp_scaling(
698        &self,
699    ) -> Result<(Number, Option<Vec<Number>>, Option<Vec<Number>>), SolverError> {
700        self.with_converged(|c| c.nlp_scaling())
701    }
702
703    /// The per-variable `user-scaling` factors `d` the held solve ran
704    /// under (gh#486), in the user TNLP's **full-x** space, or `None`
705    /// when the solve applied no change of variables.
706    ///
707    /// Every accessor on this type already reports natural units, so
708    /// this is diagnostic rather than a correction a caller has to
709    /// apply: it answers "was this solve conditioned, and by how much",
710    /// the x-axis counterpart of [`Self::nlp_scaling`].
711    pub fn variable_scaling(&self) -> Result<Option<Vec<Number>>, SolverError> {
712        let state = self.state.borrow();
713        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
714        Ok(state.backsolver.variable_scaling_full().map(|d| d.to_vec()))
715    }
716
717    /// Inertia-correction perturbations `(δ_x, δ_s, δ_c, δ_d)` baked
718    /// into the held KKT factor. All zero means the final factorization
719    /// was unregularized and the natural-units back-solves invert the
720    /// exact KKT matrix. See
721    /// [`crate::PdSensBacksolver::kkt_perturbations`].
722    pub fn kkt_perturbations(&self) -> Result<[Number; 4], SolverError> {
723        let state = self.state.borrow();
724        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
725        Ok(state.backsolver.kkt_perturbations())
726    }
727
728    /// Per-pin equality-row scaling factors `dc_i` (1.0 entries when
729    /// no constraint scaling is active), ordered like
730    /// `pin_constraint_indices`.
731    pub fn pin_g_scaling(
732        &self,
733        pin_constraint_indices: &[Index],
734    ) -> Result<Vec<Number>, SolverError> {
735        self.with_converged(|c| c.pin_g_scaling(pin_constraint_indices))
736    }
737}
738
739/// The parametric steps take one perturbation per pinned row, and that is
740/// checked before anything reads the held state, as it always was.
741fn check_deltas(pins: &[Index], deltas: &[Number]) -> Result<(), SolverError> {
742    if pins.len() != deltas.len() {
743        return Err(SolverError::BadShape {
744            what: "deltas",
745            got: deltas.len(),
746            expected: pins.len(),
747        });
748    }
749    Ok(())
750}