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}