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, ReducedActivityReport, ReducedRowActivityReport};
60use crate::backsolver::SensBacksolver;
61use crate::index::{FullXSlice, VarToFull, VarX};
62use crate::schur_data::IndexSchurData;
63use crate::sens_app::{SensApplication, SensOptions};
64use crate::vec_util::dense_to_vec;
65
66/// Sign of the barrier correction term, set from a comparison
67/// against sIPOPT rather than derived.
68pub const BARRIER_SIGN: Number = -1.0;
69
70/// The bound geometry the bound-aware parametric steps share. See
71/// [`Solver::bound_context`].
72struct BoundContext {
73    /// Length of the primal block.
74    n_x: usize,
75    /// Lower bounds over the primal block, in the model's own units.
76    lo: Vec<Number>,
77    /// Upper bounds, likewise.
78    hi: Vec<Number>,
79    /// The converged primal point, truncated to the primal block.
80    x_curr: Vec<Number>,
81    /// How far outside a bound still counts as on it.
82    eps: Number,
83    /// How far negative a bound multiplier has to go before its bound
84    /// is released. Always the solve's own margin, whatever `eps` is.
85    release_eps: Number,
86    /// Bound multipliers at the base point, in the solve's own
87    /// coordinates, with the compound row each occupies.
88    mults: Vec<crate::boundcheck::BoundMultiplier>,
89}
90
91impl BoundContext {
92    /// Distance from the base point to each bound, for one var-x row.
93    ///
94    /// Typed because the callers read a full-x `ActivityReport` in the
95    /// same scope: `lo`, `hi` and `x_curr` are all primal-block length,
96    /// and indexing them with a full-x value is the swap `crate::index`
97    /// exists to prevent.
98    fn slacks_at(&self, row: VarX) -> (Number, Number) {
99        let i = row.get();
100        (self.x_curr[i] - self.lo[i], self.hi[i] - self.x_curr[i])
101    }
102}
103
104/// Errors returned by post-convergence operations on [`Solver`].
105#[derive(Debug, Clone)]
106#[non_exhaustive]
107pub enum SolverError {
108    /// The solver has not yet converged, or the last solve failed
109    /// before producing a usable KKT factor.
110    NotConverged,
111    /// An input slice's length did not match the KKT dimension or the
112    /// parameter count.
113    BadShape {
114        /// Human description of the mismatched buffer.
115        what: &'static str,
116        /// Length the caller passed.
117        got: usize,
118        /// Length expected.
119        expected: usize,
120    },
121    /// The underlying back-solve failed (singular factor, numerical
122    /// breakdown).
123    BacksolveFailed,
124    /// The underlying [`SensApplication`] step failed (e.g. row mapping
125    /// invalid for the current problem).
126    SensComputationFailed(String),
127    /// An option the requested computation depends on holds an
128    /// incompatible value; the message names the option and the value
129    /// required.
130    BadOptions(String),
131}
132
133/// State captured at convergence: the user-visible iterate plus the
134/// `PdSensBacksolver` that wraps the converged KKT factor.
135///
136/// Read this via [`Solver::converged`].
137pub struct ConvergedState {
138    /// IPM return status of the most recent solve.
139    pub status: ApplicationReturnStatus,
140    /// Final primal iterate `x*` (length `n_x`), in the user's own
141    /// units: a `user-scaling` change of variables is undone here, so
142    /// this is `x`, never the algorithm's `x̃ = d ⊙ x` (gh#486).
143    pub x: Vec<Number>,
144    /// Final objective value `f(x*)`.
145    pub obj_val: Number,
146    /// `bound_relax_factor` **as the solve that produced this state
147    /// ran with it**, not as the application's options read today.
148    /// The bounds were relaxed (or not) once, during this solve; a
149    /// later `set_numeric_value` cannot change what the held slacks
150    /// were measured against, so post-solve calls whose validity
151    /// depends on unrelaxed bounds must guard on this value. See
152    /// [`Solver::classify_activity`].
153    pub bound_relax_factor: Number,
154    /// Whether the solve computed exact Hessians, **as it ran**. A
155    /// `limited-memory` solve's `IpoptData::w` is the quasi-Newton
156    /// matrix, and there is no exact Hessian to evaluate at another
157    /// point, so the corrector keeps that matrix instead of
158    /// refreshing it at the predicted iterate.
159    pub exact_hessian: bool,
160    /// Converged KKT-factor wrapper. Owns `Rc` handles to the
161    /// `PdFullSpaceSolver`, the IpoptData / Cq, and the NLP, so it
162    /// outlives the IPM call frame.
163    backsolver: PdSensBacksolver,
164}
165
166impl ConvergedState {
167    /// Block dimensions of the compound KKT vector in
168    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order.
169    pub fn block_dims(&self) -> [usize; 8] {
170        self.backsolver.block_dims()
171    }
172
173    /// Total dimension of the compound KKT vector (sum of `block_dims`).
174    pub fn kkt_dim(&self) -> usize {
175        self.backsolver.dim()
176    }
177}
178
179/// Session-style solver: holds an [`IpoptApplication`], its TNLP, and
180/// the converged factor between calls.
181pub struct Solver {
182    app: IpoptApplication,
183    tnlp: Rc<RefCell<dyn TNLP>>,
184    /// Side channel populated by the `on_converged` callback installed
185    /// in [`Self::solve`]. The `RefCell<Option<…>>` shape mirrors the
186    /// pattern in [`crate::convenience`] (the callback closure needs
187    /// shared mutable access; the `Option` is `None` before the first
188    /// solve and gets overwritten on each call).
189    state: Rc<RefCell<Option<ConvergedState>>>,
190}
191
192impl Solver {
193    /// Build a new session. The `app` should already have its options
194    /// configured and `initialize()` called.
195    pub fn new(app: IpoptApplication, tnlp: Rc<RefCell<dyn TNLP>>) -> Self {
196        Self {
197            app,
198            tnlp,
199            state: Rc::new(RefCell::new(None)),
200        }
201    }
202
203    /// Borrow the underlying `IpoptApplication` (e.g. to read its
204    /// options table after a solve). Mutation between `solve` calls is
205    /// supported via [`Self::app_mut`].
206    pub fn app(&self) -> &IpoptApplication {
207        &self.app
208    }
209
210    /// Mutable borrow of the underlying `IpoptApplication`. Useful for
211    /// reconfiguring options before a follow-up `solve()`. Note that
212    /// changing options that affect the KKT linear system between
213    /// calls will invalidate the cached factor; the next `solve()`
214    /// rebuilds it.
215    pub fn app_mut(&mut self) -> &mut IpoptApplication {
216        &mut self.app
217    }
218
219    /// Run the IPM to convergence. On a successful solve the
220    /// [`ConvergedState`] (including the KKT backsolver) is stashed
221    /// inside the `Solver` and accessible via [`Self::converged`].
222    ///
223    /// Each call to `solve()` overwrites the previous converged
224    /// state; the previously held factor is dropped.
225    pub fn solve(&mut self) -> ApplicationReturnStatus {
226        // Clear any previous state so a failed re-solve doesn't leave
227        // a stale factor visible.
228        self.state.borrow_mut().take();
229
230        // Snapshot the options this solve will run under, before it
231        // runs. `bound_relax_factor` is consumed once, when the NLP
232        // relaxes its bounds; reading it back at query time would
233        // describe the application's options rather than the state
234        // being queried. The registry supplies its own default when
235        // the option is unset, so no second copy of the default lives
236        // here.
237        let brf = self
238            .app
239            .options()
240            .get_numeric_value("bound_relax_factor", "")
241            .map(|(v, _)| v)
242            .expect("bound_relax_factor is a registered core option");
243        let exact_hessian = self
244            .app
245            .options()
246            .get_string_value("hessian_approximation", "")
247            .map(|(v, _)| v == "exact")
248            .expect("hessian_approximation is a registered core option");
249
250        let state_cb = Rc::clone(&self.state);
251        // NOTE (gh#884 follow-up): `set_on_converged` fires once per
252        // *attempt*. When a later attempt loses and an earlier one's answer
253        // is replayed through the three-sink floor -- the mu fallback
254        // (pounce#870, on by DEFAULT) or the gh#884 dual-divergence retry --
255        // the converged KKT state this closure reads belongs to the
256        // DISCARDED attempt, while the status, objective and statistics
257        // reported alongside it are the winner's.
258        // `IpoptApplication::answer_restored_from_floor()` reports that this
259        // happened; the CLI's main path consults it and re-reads the point
260        // from the `finalize_solution` payload. This site does not, because
261        // what it needs is the factorization and the KKT state, which the
262        // payload does not carry and which cannot be rewound. Pre-existing
263        // and unfixed: a sensitivity result taken across a floored solve
264        // describes the attempt that lost.
265        self.app
266            .set_on_converged(Box::new(move |data, cq, nlp, pd| {
267                let curr = match data.borrow().curr.clone() {
268                    Some(c) => c,
269                    None => return,
270                };
271                let backsolver = match PdSensBacksolver::new(data, cq, nlp, Rc::clone(&pd)) {
272                    Ok(b) => b,
273                    Err(e) => {
274                        // No session state is stored, so post-solve
275                        // calls will report NotConverged; at least say
276                        // why on stderr rather than failing silently.
277                        eprintln!("pounce: Solver could not capture the KKT factor: {e}");
278                        return;
279                    }
280                };
281                // The algorithm's iterate is `x̃ = d ⊙ x` when the
282                // solve ran under a change of variables (gh#486): this
283                // capture reads the iterate, not the
284                // `finalize_solution` payload, so it undoes the
285                // substitution itself. The backsolver already read the
286                // factors off the NLP, in this same var-x space.
287                let mut x = dense_to_vec(&*curr.x);
288                if let Some(d) = backsolver.variable_scaling() {
289                    debug_assert_eq!(x.len(), d.len());
290                    for (xi, &di) in x.iter_mut().zip(d.iter()) {
291                        *xi /= di;
292                    }
293                }
294                let obj_val = cq.borrow_mut().curr_f();
295                // Status is overwritten with the real value after
296                // optimize_tnlp returns.
297                *state_cb.borrow_mut() = Some(ConvergedState {
298                    status: ApplicationReturnStatus::InternalError,
299                    x,
300                    obj_val,
301                    bound_relax_factor: brf,
302                    exact_hessian,
303                    backsolver,
304                });
305            }));
306
307        let status = crate::optimize_tnlp_for_sensitivity(&mut self.app, Rc::clone(&self.tnlp));
308        if let Some(s) = self.state.borrow_mut().as_mut() {
309            s.status = status;
310        }
311        status
312    }
313
314    /// Borrow the converged state, if a successful solve has been
315    /// run. Returns `None` if no solve has run or if the most recent
316    /// solve failed before reaching convergence.
317    pub fn converged(&self) -> Option<Ref<'_, ConvergedState>> {
318        let r = self.state.borrow();
319        r.as_ref()?;
320        Some(Ref::map(r, |o| {
321            o.as_ref()
322                .unwrap_or_else(|| unreachable!("checked is_some above"))
323        }))
324    }
325
326    /// Total dimension of the compound KKT vector (sum of
327    /// `block_dims`). Returns `None` if no converged factor is held.
328    pub fn kkt_dim(&self) -> Option<usize> {
329        self.converged().map(|c| c.kkt_dim())
330    }
331
332    /// Block dimensions of the compound KKT vector in
333    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order. Returns `None` if
334    /// no converged factor is held.
335    pub fn block_dims(&self) -> Option<[usize; 8]> {
336        self.converged().map(|c| c.block_dims())
337    }
338
339    /// Classify every bounded variable and every finite-bounded
340    /// inequality row of the converged solve by activity: see
341    /// [`crate::activity`] and
342    /// `dev-notes/covariance-information-roadmap.md` item 0 (gh #362).
343    ///
344    /// Requires the held solve to have run with `bound_relax_factor=0`
345    /// (the Ipopt default is `1e-8`): with relaxed bounds the solver's
346    /// slacks are measured against perturbed bounds, and the
347    /// complementarity products the classifier reads no longer track
348    /// `μ`.
349    ///
350    /// The guard reads
351    /// [`ConvergedState::bound_relax_factor`] — the value that solve
352    /// ran under — not the application's current options. Setting the
353    /// option after the fact neither unlocks a state whose bounds were
354    /// relaxed nor invalidates one whose bounds were not; re-solve to
355    /// change the answer.
356    ///
357    /// # Neither classes' `q` is a reduced curvature
358    ///
359    /// A variable's ratio is `Σ_i/|H_ii|`, and at a kink the
360    /// multiplier is generated by the curvature **reduced** along that
361    /// coordinate, not by the diagonal. The two agree only where the
362    /// coordinate is decoupled, so a genuine kink coupled to a
363    /// neighbour reads [`AMBIGUOUS`](crate::activity::AMBIGUOUS) here
364    /// at any tolerance (gh#763). Do not read that class as "probably
365    /// not a kink": use [`Self::reduced_activity`], which normalizes
366    /// by the reduced curvature at one back-solve per coordinate.
367    ///
368    /// A row's ratio divides by the curvature along the row's own
369    /// gradient instead, which is a genuine directional curvature but
370    /// still not a reduced one, so the same warning and the same
371    /// remedy apply there: its ratio is `reduced/directional` and
372    /// [`Self::reduced_row_activity`] answers the kink question
373    /// (gh#804).
374    pub fn classify_activity(&self) -> Result<ActivityReport, SolverError> {
375        let state = self.state.borrow();
376        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
377        let brf = state.bound_relax_factor;
378        if brf != 0.0 {
379            return Err(SolverError::BadOptions(format!(
380                "classify_activity requires bound_relax_factor=0, but the \
381                 held solve ran with {brf:e}: relaxed bounds shift the \
382                 slacks the classifier reads. Set the option and solve() \
383                 again — changing it now does not re-measure the slacks."
384            )));
385        }
386        Ok(crate::activity::compute(&state.backsolver))
387    }
388
389    /// [`Self::classify_activity`]'s per-variable verdict for
390    /// `user_vars`, re-measured against the **reduced** curvature
391    /// along each coordinate instead of the Hessian diagonal — one
392    /// back-solve against the held factor per variable (gh#763).
393    ///
394    /// `classify_activity` normalizes a variable's `Σ` by `H_ii`, but
395    /// the multiplier at a kink is generated by the curvature left
396    /// after the other free variables re-optimize. The two agree only
397    /// where the coordinate is decoupled, so a genuine kink coupled to
398    /// a neighbour reads [`AMBIGUOUS`](crate::activity::AMBIGUOUS)
399    /// there at any tolerance — the ratio is `μ`-independent. Ask here
400    /// and the same kink reads
401    /// [`WEAKLY_ACTIVE`](crate::activity::WEAKLY_ACTIVE).
402    ///
403    /// Indices are **user space** (full-x), as the report's are. The
404    /// intended call is over a report's ambiguous entries:
405    ///
406    /// ```ignore
407    /// let report = solver.classify_activity()?;
408    /// let ask: Vec<usize> = (0..report.var_status.len())
409    ///     .filter(|&i| report.var_status[i] == AMBIGUOUS)
410    ///     .collect();
411    /// let refined = solver.reduced_activity(&ask)?;
412    /// ```
413    ///
414    /// The cost is one back-solve per index, so it is a refinement to
415    /// call over the entries in question, not over every bounded
416    /// variable of a large model. See
417    /// [`crate::activity::reduced_activity`] for the algebra and the
418    /// edge cases.
419    ///
420    /// Requires the held solve to have run with `bound_relax_factor=0`
421    /// for the same reason [`Self::classify_activity`] does: both read
422    /// the slacks relaxed bounds shift.
423    pub fn reduced_activity(
424        &self,
425        user_vars: &[usize],
426    ) -> Result<ReducedActivityReport, SolverError> {
427        let state = self.state.borrow();
428        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
429        let brf = state.bound_relax_factor;
430        if brf != 0.0 {
431            return Err(SolverError::BadOptions(format!(
432                "reduced_activity requires bound_relax_factor=0, but the \
433                 held solve ran with {brf:e}: relaxed bounds shift the \
434                 slacks the classifier reads. Set the option and solve() \
435                 again — changing it now does not re-measure the slacks."
436            )));
437        }
438        crate::activity::reduced_activity(&state.backsolver, user_vars).map_err(|e| match e {
439            crate::activity::ReducedActivityError::OutOfRange { got, n_full_x } => {
440                SolverError::BadShape {
441                    what: "reduced_activity variable index",
442                    got,
443                    expected: n_full_x,
444                }
445            }
446            crate::activity::ReducedActivityError::Backsolve => SolverError::BacksolveFailed,
447        })
448    }
449
450    /// [`Self::classify_activity`]'s per-ROW verdict for `user_rows`,
451    /// re-measured against the **reduced** curvature along each row's
452    /// gradient instead of the directional curvature `∇dᵀH∇d/‖∇d‖²` —
453    /// one back-solve against the held factor per row (gh#804).
454    ///
455    /// The row counterpart of [`Self::reduced_activity`], and the same
456    /// defect one block over. A row's directional denominator is a
457    /// genuine curvature along the row's own gradient — strictly
458    /// better than the variable path's bare `H_ii`, which is why
459    /// gh#763 fixed the variables first — but it is still not
460    /// *reduced*: it does not account for the other free coordinates
461    /// re-optimizing, and the multiplier is generated by what is left
462    /// after they do. So a row's ratio there is
463    /// `reduced/directional`, equal to `1` only where the row's
464    /// direction is decoupled from the remaining free space, and a
465    /// genuine row kink that is coupled reads
466    /// [`AMBIGUOUS`](crate::activity::AMBIGUOUS) at any tolerance —
467    /// the ratio is `μ`-independent. Ask here and the same kink reads
468    /// [`WEAKLY_ACTIVE`](crate::activity::WEAKLY_ACTIVE).
469    ///
470    /// Indices are **user space** (full-g), as the report's are —
471    /// equality rows included, which report
472    /// [`EQUALITY`](crate::activity::EQUALITY) rather than being an
473    /// error. The intended call is over a report's ambiguous rows:
474    ///
475    /// ```ignore
476    /// let report = solver.classify_activity()?;
477    /// let ask: Vec<usize> = (0..report.row_status.len())
478    ///     .filter(|&j| report.row_status[j] == AMBIGUOUS)
479    ///     .collect();
480    /// let refined = solver.reduced_row_activity(&ask)?;
481    /// ```
482    ///
483    /// The cost is one back-solve per index, so it is a refinement to
484    /// call over the rows in question, not over every bounded row of a
485    /// large model. See [`crate::activity::reduced_row_activity`] for
486    /// the algebra and the edge cases.
487    ///
488    /// Requires the held solve to have run with `bound_relax_factor=0`
489    /// for the same reason [`Self::classify_activity`] does: both read
490    /// the slacks relaxed bounds shift.
491    pub fn reduced_row_activity(
492        &self,
493        user_rows: &[usize],
494    ) -> Result<ReducedRowActivityReport, SolverError> {
495        let state = self.state.borrow();
496        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
497        let brf = state.bound_relax_factor;
498        if brf != 0.0 {
499            return Err(SolverError::BadOptions(format!(
500                "reduced_row_activity requires bound_relax_factor=0, but the \
501                 held solve ran with {brf:e}: relaxed bounds shift the \
502                 slacks the classifier reads. Set the option and solve() \
503                 again — changing it now does not re-measure the slacks."
504            )));
505        }
506        crate::activity::reduced_row_activity(&state.backsolver, user_rows).map_err(|e| match e {
507            crate::activity::ReducedRowActivityError::OutOfRange { got, n_full_g } => {
508                SolverError::BadShape {
509                    what: "reduced_row_activity constraint index",
510                    got,
511                    expected: n_full_g,
512                }
513            }
514            crate::activity::ReducedRowActivityError::Backsolve => SolverError::BacksolveFailed,
515        })
516    }
517
518    /// The gradient of user constraint row `user_row` at the converged
519    /// iterate, in user variable order (length `n_full_x`) and in
520    /// **natural (unscaled) units**: the internal Jacobian row carries
521    /// the solver's per-row `c_scale`/`d_scale`, which is divided out
522    /// here, so this is the gradient of the row as the user wrote it.
523    /// Equality and inequality rows alike; entries for fixed
524    /// (`make_parameter`-removed) variables are 0 because the solve
525    /// dropped their columns. Errors on an out-of-range row.
526    ///
527    /// Serves the covariance roadmap's item 1: a binding row's normal
528    /// restricted to the fitted block is the projection direction.
529    pub fn row_normal(&self, user_row: usize) -> Result<Vec<Number>, SolverError> {
530        let state = self.state.borrow();
531        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
532        crate::activity::row_normal(&state.backsolver, user_row).map_err(|m| {
533            SolverError::BadShape {
534                what: "row_normal constraint index",
535                got: user_row,
536                expected: m,
537            }
538        })
539    }
540
541    /// The exact Lagrangian Hessian times a user-space vector, in
542    /// user variable order and natural units (see
543    /// [`crate::activity::hessian_vec`]). Errors on a length mismatch.
544    pub fn hessian_vec(&self, v: &[Number]) -> Result<Vec<Number>, SolverError> {
545        let state = self.state.borrow();
546        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
547        crate::activity::hessian_vec(&state.backsolver, v).map_err(|n| SolverError::BadShape {
548            what: "hessian_vec vector length",
549            got: v.len(),
550            expected: n,
551        })
552    }
553
554    /// Solve `K · lhs = rhs` against the converged KKT factor. Both
555    /// slices must have length `kkt_dim()`; the layout is the flat
556    /// `x || s || y_c || y_d || z_l || z_u || v_l || v_u` packing.
557    ///
558    /// `K` here is the **natural-units** (unscaled) KKT matrix: when
559    /// the IPM solved with active NLP scaling, the backsolver scales
560    /// the RHS/solution (all eight blocks, including the z/v
561    /// bound-multiplier rows) so callers pass and receive data in the
562    /// user's own units (pounce#128) — see
563    /// [`crate::PdSensBacksolver::solve`]. For the raw scaled-space
564    /// back-solve use [`Self::kkt_solve_scaled`].
565    pub fn kkt_solve(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
566        self.kkt_solve_impl(rhs, lhs, false)
567    }
568
569    /// [`Self::kkt_solve`] without the natural-units conjugation: the
570    /// back-solve runs against the factor exactly as the IPM holds it
571    /// (the solver's internal scaled space). Identical to `kkt_solve`
572    /// when no NLP scaling is active. "Scaled space" includes a
573    /// `user-scaling` change of variables (gh#486), so on such a solve
574    /// the `x` and `z` blocks here are in the substituted coordinates
575    /// `x̃ = d ⊙ x`, not the model's.
576    pub fn kkt_solve_scaled(&self, rhs: &[Number], lhs: &mut [Number]) -> Result<(), SolverError> {
577        self.kkt_solve_impl(rhs, lhs, true)
578    }
579
580    fn kkt_solve_impl(
581        &self,
582        rhs: &[Number],
583        lhs: &mut [Number],
584        scaled: bool,
585    ) -> Result<(), SolverError> {
586        let state = self.state.borrow();
587        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
588        let total = state.backsolver.dim();
589        if rhs.len() != total {
590            return Err(SolverError::BadShape {
591                what: "rhs",
592                got: rhs.len(),
593                expected: total,
594            });
595        }
596        if lhs.len() != total {
597            return Err(SolverError::BadShape {
598                what: "lhs",
599                got: lhs.len(),
600                expected: total,
601            });
602        }
603        let ok = if scaled {
604            state.backsolver.solve_scaled_space(rhs, lhs)
605        } else {
606            state.backsolver.solve(rhs, lhs)
607        };
608        if ok {
609            Ok(())
610        } else {
611            Err(SolverError::BacksolveFailed)
612        }
613    }
614
615    /// Batched-RHS back-solve. `rhs_flat` and `lhs_flat` are row-major
616    /// `(n_rhs, kkt_dim)` buffers; each row is solved against the
617    /// same converged factor. Equivalent in result to looping
618    /// [`Self::kkt_solve`] but reuses one `IteratesVector` for the
619    /// RHS and one for the result across all `n_rhs` calls — see
620    /// [`crate::algorithm_backsolver::PdSensBacksolver::solve_many`].
621    pub fn kkt_solve_many(
622        &self,
623        rhs_flat: &[Number],
624        lhs_flat: &mut [Number],
625        n_rhs: usize,
626    ) -> Result<(), SolverError> {
627        self.kkt_solve_many_impl(rhs_flat, lhs_flat, n_rhs, false)
628    }
629
630    /// [`Self::kkt_solve_many`] without the natural-units
631    /// conjugation (the batched sibling of [`Self::kkt_solve_scaled`]).
632    pub fn kkt_solve_many_scaled(
633        &self,
634        rhs_flat: &[Number],
635        lhs_flat: &mut [Number],
636        n_rhs: usize,
637    ) -> Result<(), SolverError> {
638        self.kkt_solve_many_impl(rhs_flat, lhs_flat, n_rhs, true)
639    }
640
641    fn kkt_solve_many_impl(
642        &self,
643        rhs_flat: &[Number],
644        lhs_flat: &mut [Number],
645        n_rhs: usize,
646        scaled: bool,
647    ) -> Result<(), SolverError> {
648        let state = self.state.borrow();
649        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
650        let total = state.backsolver.dim();
651        let expected = n_rhs * total;
652        if rhs_flat.len() != expected {
653            return Err(SolverError::BadShape {
654                what: "rhs",
655                got: rhs_flat.len(),
656                expected,
657            });
658        }
659        if lhs_flat.len() != expected {
660            return Err(SolverError::BadShape {
661                what: "lhs",
662                got: lhs_flat.len(),
663                expected,
664            });
665        }
666        let ok = if scaled {
667            state
668                .backsolver
669                .solve_many_scaled_space(rhs_flat, lhs_flat, n_rhs)
670        } else {
671            state.backsolver.solve_many(rhs_flat, lhs_flat, n_rhs)
672        };
673        if ok {
674            Ok(())
675        } else {
676            Err(SolverError::BacksolveFailed)
677        }
678    }
679
680    /// First-order parametric step `Δx ≈ ∂x*/∂p · Δp` for a set of
681    /// pinned equality constraints. `pin_constraint_indices` are
682    /// 0-based indices into the user's `g(x)`; `deltas` is the
683    /// perturbation `Δp` (same length).
684    ///
685    /// Returns the `n_x`-long primal step. For the full KKT-space
686    /// step, use [`Self::kkt_solve`] directly.
687    pub fn parametric_step(
688        &self,
689        pin_constraint_indices: &[Index],
690        deltas: &[Number],
691    ) -> Result<Vec<Number>, SolverError> {
692        if pin_constraint_indices.len() != deltas.len() {
693            return Err(SolverError::BadShape {
694                what: "deltas",
695                got: deltas.len(),
696                expected: pin_constraint_indices.len(),
697            });
698        }
699        let state = self.state.borrow();
700        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
701
702        // Map user g-indices to y_c rows through the NLP's c/d-split
703        // permutation (pounce#128; matches `convenience.rs`).
704        let dims = state.backsolver.block_dims();
705        let n_x = dims[0];
706        let param_rows = state
707            .backsolver
708            .map_pin_g_to_kkt_rows(pin_constraint_indices)
709            .map_err(SolverError::SensComputationFailed)?;
710        let signs = vec![1; pin_constraint_indices.len()];
711        let a_data = IndexSchurData::from_parts(param_rows, signs)
712            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
713
714        let opts = SensOptions {
715            run_sens: true,
716            ..SensOptions::default()
717        };
718        let sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
719        let n_full = state.backsolver.dim();
720        let mut dx_full = vec![0.0; n_full];
721        if !sens_app.parametric_step(deltas, &mut dx_full) {
722            return Err(SolverError::SensComputationFailed(
723                "SensApplication::parametric_step failed".into(),
724            ));
725        }
726        // carry the step from the barrier problem's solution toward the
727        // original problem's (the paper's equation 11)
728        let corr = self.barrier_correction(state)?;
729        for (d, c) in dx_full.iter_mut().zip(corr.iter()) {
730            *d += *c * BARRIER_SIGN;
731        }
732        dx_full.truncate(n_x);
733        Ok(dx_full)
734        // NOTE: parametric_step_full below applies the same correction,
735        // so the two agree on their shared block.
736    }
737
738    /// The right-hand side [`Self::parametric_step_full`] answers,
739    /// barrier term included. That method adds the term as a correction
740    /// to the solution rather than to the right-hand side, which is the
741    /// same thing by linearity.
742    ///
743    /// The parameter rows go through `map_pin_g_to_kkt_rows` exactly as
744    /// they do there. Passing the constraint indices raw instead puts
745    /// the perturbation on the x rows, where it contributes nothing --
746    /// a release then sees only its own multiplier shift and lands on
747    /// the wrong answer without failing.
748    fn parametric_rhs_full(
749        &self,
750        pin_constraint_indices: &[Index],
751        deltas: &[Number],
752    ) -> Result<Vec<Number>, SolverError> {
753        let state = self.converged().ok_or(SolverError::NotConverged)?;
754        let state = &*state;
755        let dims = state.backsolver.block_dims();
756        let n_full = state.backsolver.dim();
757        let param_rows = state
758            .backsolver
759            .map_pin_g_to_kkt_rows(pin_constraint_indices)
760            .map_err(SolverError::SensComputationFailed)?;
761        let signs = vec![1; pin_constraint_indices.len()];
762        let a_data = IndexSchurData::from_parts(param_rows, signs)
763            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
764        let opts = SensOptions {
765            run_sens: true,
766            ..SensOptions::default()
767        };
768        let sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
769        let mut rhs = vec![0.0; n_full];
770        if !sens_app.parametric_rhs(deltas, &mut rhs) {
771            return Err(SolverError::SensComputationFailed(
772                "SensApplication::parametric_rhs failed".into(),
773            ));
774        }
775        let mu = state.backsolver.barrier_mu();
776        let start = dims[0] + dims[1] + dims[2] + dims[3];
777        let end = start + dims[4] + dims[5] + dims[6] + dims[7];
778        for r in rhs.iter_mut().take(end).skip(start) {
779            *r += mu * BARRIER_SIGN;
780        }
781        Ok(rhs)
782    }
783
784    /// The barrier correction of the parametric step: the paper's
785    /// equation 11 term, which carries the step from the solution of
786    /// the barrier problem at `mu > 0` toward the one at `mu = 0`.
787    ///
788    /// [`Self::parametric_step`] is taken against a factorization held
789    /// at the final `mu`, so it estimates where the BARRIER problem's
790    /// solution moves, not where the original problem's does. The two
791    /// differ by `O(mu)`, which is negligible at a tight tolerance and
792    /// is not at a loose one. Measured against sIPOPT on a nonlinear
793    /// model, the uncorrected step agrees to 2e-9 at `tol = 1e-8` and
794    /// differs by 9e-6 at `tol = 1e-3`.
795    ///
796    /// The term is one more backsolve against the same factor, with
797    /// `mu` in the complementarity rows, which are the bound multiplier
798    /// blocks of the compound vector.
799    ///
800    /// Returns the correction over the whole compound vector, to be
801    /// added to the step.
802    fn barrier_correction(&self, state: &ConvergedState) -> Result<Vec<Number>, SolverError> {
803        let dims = state.backsolver.block_dims();
804        let n_full = state.backsolver.dim();
805        let mu = state.backsolver.barrier_mu();
806        // z_l, z_u, v_l, v_u: the rows carrying the complementarity
807        // conditions, which are the ones the barrier perturbs
808        let start = dims[0] + dims[1] + dims[2] + dims[3];
809        let end = start + dims[4] + dims[5] + dims[6] + dims[7];
810        let mut rhs = vec![0.0; n_full];
811        for r in rhs.iter_mut().take(end).skip(start) {
812            *r = mu;
813        }
814        let mut corr = vec![0.0; n_full];
815        if !state.backsolver.solve(&rhs, &mut corr) {
816            return Err(SolverError::BacksolveFailed);
817        }
818        Ok(corr)
819    }
820
821    /// Parametric step with the bounds respected by pinning, not by
822    /// clamping. Returns the `n_x`-long primal step, the rows it
823    /// constrained to reach it, and why the refinement stopped.
824    ///
825    /// [`Self::parametric_step`] answers where the linear predictor
826    /// points, which can be outside the box. Clamping a coordinate
827    /// back to its bound leaves every other coordinate at its
828    /// predictor value, so the answer is feasible but no longer
829    /// consistent with the KKT relations. This instead adds a row
830    /// pinning each offending coordinate at its bound and re-solves, so
831    /// the others move to stay consistent under the pins, which is the
832    /// refinement upstream runs under `sens_boundcheck`.
833    ///
834    /// A pass takes every crossing it can see, pins them together, and
835    /// re-solves, so the loop ends when nothing is left outside rather
836    /// than when the passes run out. Each pass rebuilds the Schur
837    /// complement over the pins so far, so a pass carrying `k` of them
838    /// costs one dense `k × k` solve and `k + 1` back-solves; the
839    /// factorization itself is never rebuilt for a pin.
840    ///
841    /// What counts as outside a bound is the `eps` argument when the
842    /// caller passes one, and the solve's own margin when it passes
843    /// `None`: the solve was willing to leave a converged point
844    /// `bound_relax_factor` outside its bound, so anything within that
845    /// is on the bound. An unrelaxed solve gets a roundoff floor.
846    ///
847    /// Passes stop when nothing is outside its bound by that much, when
848    /// a pin cannot be achieved because the pins have exhausted the
849    /// problem's degrees of freedom, or at `max_iter`, which is a
850    /// safety limit rather than a budget: it took one pin per pass
851    /// until gh#732, where a model with more crossings than passes had
852    /// its answer picked by the limit. None of those is an error, and
853    /// the returned [`crate::boundcheck::RefineStop`] says which
854    /// happened.
855    pub fn parametric_step_bounded(
856        &self,
857        pin_constraint_indices: &[Index],
858        deltas: &[Number],
859        max_iter: usize,
860        bound_eps: Option<Number>,
861    ) -> Result<(Vec<Number>, Vec<Index>, crate::boundcheck::RefineStop), SolverError> {
862        let dx_full = self.parametric_step_full(pin_constraint_indices, deltas)?;
863        let rhs_plain = self.parametric_rhs_full(pin_constraint_indices, deltas)?;
864        let ctx = self.bound_context(bound_eps)?;
865        let state = self.state.borrow();
866        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
867        let (dx, pinned, stop) = crate::boundcheck::refine_step_onto_bounds(
868            &state.backsolver,
869            &dx_full,
870            &ctx.x_curr,
871            &ctx.lo,
872            &ctx.hi,
873            &ctx.mults,
874            &rhs_plain,
875            ctx.eps,
876            ctx.release_eps,
877            max_iter,
878        )
879        .map_err(SolverError::SensComputationFailed)?;
880        Ok((
881            dx[..ctx.n_x].to_vec(),
882            pinned.into_iter().map(|p| p as Index).collect(),
883            stop,
884        ))
885    }
886
887    /// Parametric step applied a little at a time instead of taken
888    /// whole, stopping wherever the active set changes and continuing
889    /// from there under the new one. Returns the primal step and the
890    /// breakpoints crossed.
891    ///
892    /// [`Self::parametric_step_bounded`] decides every condition at the
893    /// base point, which is upstream's fix-relax. This is past it: the
894    /// result is piecewise linear in the parameter, exact for a QP
895    /// because a QP's solution is piecewise affine in the parameter,
896    /// and still a predictor for an NLP because nothing is
897    /// re-linearized between breakpoints.
898    ///
899    /// `max_iter` caps the breakpoints crossed. It is in practice a
900    /// budget on factorizations, since a pin is a back-solve against
901    /// the held factor while a release re-factors.
902    pub fn parametric_step_path(
903        &self,
904        pin_constraint_indices: &[Index],
905        deltas: &[Number],
906        max_iter: usize,
907    ) -> Result<(Vec<Number>, Vec<crate::boundcheck::PathSegment>), SolverError> {
908        let rhs_plain = self.parametric_rhs_full(pin_constraint_indices, deltas)?;
909        // Nothing here decides the weak rows -- that is what the
910        // "decided" variant below is for -- but the walk still has to
911        // be told which they are, or it reads their order-one sigma as
912        // a bound the factorization enforces and lets the variable
913        // walk out of its box (gh#852). A relaxed solve shifts the
914        // slacks the classifier reads, so this comes back empty there
915        // and the walk behaves as it did before -- the same silence
916        // `weakly_active_bounds` hands every other caller.
917        let weak_rows: Vec<usize> = self.weakly_active_bounds()?.iter().map(|w| w.row).collect();
918        let ctx = self.bound_context(None)?;
919        let state = self.state.borrow();
920        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
921        let (dx, segments) = crate::boundcheck::step_along_path(
922            &state.backsolver,
923            &rhs_plain,
924            &ctx.x_curr,
925            &ctx.lo,
926            &ctx.hi,
927            &ctx.mults,
928            max_iter,
929            &[],
930            &[],
931            &weak_rows,
932        )
933        .map_err(SolverError::SensComputationFailed)?;
934        Ok((dx[..ctx.n_x].to_vec(), segments))
935    }
936
937    /// [`Self::parametric_step_path`] with the weak-row
938    /// decision supplied by the caller instead of searched for.
939    /// `held_var_rows` names the var-x rows of the weakly active
940    /// bounds the direction holds; every other weakly active bound is
941    /// forced into the walk's base-activity table as a leaving row.
942    /// A row left there is still reachable, so a caller that hands in
943    /// an empty held list — every weak row declared a leaver, which is
944    /// what an undecided study of the all-released step does — gets
945    /// the bound back at the fraction the walk finds the direction
946    /// pressing into it, rather than an answer outside the box
947    /// (gh#852). Study surface for an externally solved eq. 14 QP.
948    pub fn parametric_step_path_decided(
949        &self,
950        pin_constraint_indices: &[Index],
951        deltas: &[Number],
952        max_iter: usize,
953        held_var_rows: &[Index],
954    ) -> Result<(Vec<Number>, Vec<crate::boundcheck::PathSegment>), SolverError> {
955        let weak = self.weakly_active_bounds()?;
956        if weak.is_empty() {
957            return self.parametric_step_path(pin_constraint_indices, deltas, max_iter);
958        }
959        let mut rhs_plain = self.parametric_rhs_full(pin_constraint_indices, deltas)?;
960        for w in &weak {
961            rhs_plain[w.row] = 0.0;
962        }
963        let held: std::collections::HashSet<usize> =
964            held_var_rows.iter().map(|&r| r as usize).collect();
965        let ctx = self.bound_context(None)?;
966        let state = self.state.borrow();
967        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
968        let holds: Vec<(usize, bool)> = weak
969            .iter()
970            .filter(|w| held.contains(&w.var_row))
971            .map(|w| (w.var_row, w.lower))
972            .collect();
973        let forced_active: Vec<usize> = weak
974            .iter()
975            .filter(|w| !held.contains(&w.var_row))
976            .map(|w| w.row)
977            .collect();
978        // Every weak row, held or leaving. For a held one the flag is
979        // inert -- it arrives released and pinned already -- and for a
980        // leaving one it is what lets the walk take the bound back
981        // when the direction turns out to press into it, which is the
982        // whole of an empty held list on a holding perturbation
983        // (gh#852).
984        let weak_rows: Vec<usize> = weak.iter().map(|w| w.row).collect();
985        let (dx, segments) = crate::boundcheck::step_along_path(
986            &state.backsolver,
987            &rhs_plain,
988            &ctx.x_curr,
989            &ctx.lo,
990            &ctx.hi,
991            &ctx.mults,
992            max_iter,
993            &forced_active,
994            &holds,
995            &weak_rows,
996        )
997        .map_err(SolverError::SensComputationFailed)?;
998        Ok((dx[..ctx.n_x].to_vec(), segments))
999    }
1000
1001    /// Newton iterations on the barrier system, refining a step that
1002    /// some mode already produced.
1003    ///
1004    /// `step` is a full compound step, the shape
1005    /// [`Self::parametric_step_full`] returns, so any mode's result
1006    /// can be handed in. Every correction pays one derivative
1007    /// evaluation and one factorization at the predicted point, and
1008    /// each iteration after that costs one back-solve. Returns the
1009    /// refined step and a [`CorrectorReport`] saying what the
1010    /// iterations bought.
1011    ///
1012    /// The corrector aims at the barrier solution at the μ the solve
1013    /// finished on, not at a re-solve, so the accuracy it can reach is
1014    /// bounded by that offset. Its operator is assembled at the
1015    /// PREDICTED point, every block: the Hessian, the constraint
1016    /// Jacobians, and the barrier diagonal all evaluated at the
1017    /// stepped iterate with the step's own multipliers, and the
1018    /// predictor's active set applied to the diagonal in that frame.
1019    /// A base solve the sigma ceiling (gh#737) touched, or one that
1020    /// crossed over into the declared frame (gh#654), is no
1021    /// exception: both rules are re-derived at the predicted point
1022    /// rather than read from the base-point diagonals stored for the
1023    /// held factor's own back-solves.
1024    /// A chord iteration contracts at the rate the distance between
1025    /// its operator and the true Jacobian sets, and the predicted
1026    /// point is where the truth is. Under a `limited-memory` solve
1027    /// the quasi-Newton matrix is kept as is, since no exact Hessian
1028    /// exists to evaluate elsewhere. Where the perturbation needs a
1029    /// bound to leave the active set that the step's endpoint does
1030    /// not show, no released row is applied: the step's clamped
1031    /// multiplier leaves a weak diagonal entry there, the iterations
1032    /// can move the coordinate partway off the bound, and the answer
1033    /// is not the re-solve. The release-deciding modes are the ones
1034    /// that cross exactly. `CorrectorReport::improved` reports
1035    /// whether the residual fell; when it did not, the step handed
1036    /// back is the caller's own.
1037    ///
1038    /// The returned point always satisfies the variable bounds, since
1039    /// the barrier residual is undefined outside them and the
1040    /// fraction-to-boundary rule keeps every iterate inside. A step
1041    /// that arrives pointing out of the box is therefore put back in
1042    /// before the first iteration, which means `max_iter = 0` is not a
1043    /// no-op: it costs the derivative evaluation and the residual
1044    /// evaluation, no back-solve, and reports the residual the
1045    /// caller's step leaves.
1046    ///
1047    /// Errors with [`SolverError::SensComputationFailed`] when the
1048    /// barrier residual at that starting point is not finite, which is
1049    /// what a predicted point outside the domain of one of the model's
1050    /// functions gives (gh#845). A *declared bound* is protection here,
1051    /// since the clamp above puts the coordinate back inside it; a
1052    /// variable held in a function's domain by a **constraint** has no
1053    /// bound to be put back inside, and an ordinary `log`, `sqrt` or
1054    /// reciprocal is then reachable by a large enough perturbation.
1055    /// There is no correction to make from such a point, so it is an
1056    /// error rather than a report -- and never a step full of NaN
1057    /// carrying `residual = 0.0` and `converged = true`.
1058    pub fn correct_step(
1059        &self,
1060        pin_constraint_indices: &[Index],
1061        deltas: &[Number],
1062        step: &[Number],
1063        max_iter: usize,
1064    ) -> Result<(Vec<Number>, crate::corrector::CorrectorReport), SolverError> {
1065        let ctx = self.bound_context(None)?;
1066        let state = self.state.borrow();
1067        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1068        let bs = &state.backsolver;
1069        let dim = bs.dim();
1070        if step.len() != dim {
1071            return Err(SolverError::BadShape {
1072                what: "step",
1073                got: step.len(),
1074                expected: dim,
1075            });
1076        }
1077        // `>= 0`, not `> 0`: `barrier_mu` reports exactly zero for a
1078        // point whose bound multipliers were zeroed on the way out (see
1079        // its doc comment), and that is a barrier level, not a missing
1080        // one. The complementarity rows are then already satisfied
1081        // where they stand, which is what the corrector should measure.
1082        let mu = {
1083            let m = bs.barrier_mu();
1084            if m >= 0.0 && m.is_finite() {
1085                m
1086            } else {
1087                return Err(SolverError::SensComputationFailed(
1088                    "corrector: the solve reported no barrier parameter".into(),
1089                ));
1090            }
1091        };
1092        let base = {
1093            let mut flat = vec![0.0; dim];
1094            bs.curr_flat(&mut flat).map_err(|_| {
1095                SolverError::SensComputationFailed(
1096                    "corrector: converged iterate unavailable".into(),
1097                )
1098            })?;
1099            flat
1100        };
1101        // The pinned equalities' KKT rows and the row scales the
1102        // algorithm applied to them. A user `g` index is not the KKT
1103        // row: the two differ once an inequality precedes the pin in
1104        // `g(x)` (pounce#128), and the residual the corrector measures
1105        // sits in the algorithm's scaled equality block, so the deltas
1106        // have to carry the same factors.
1107        let (pin_rows, pin_scales) = bs
1108            .pin_rows_and_c_scales(pin_constraint_indices)
1109            .map_err(SolverError::SensComputationFailed)?;
1110        let pin_rows: Vec<usize> = pin_rows.iter().map(|&r| r as usize).collect();
1111        let scaled_deltas: Vec<Number> = deltas
1112            .iter()
1113            .zip(&pin_scales)
1114            .map(|(&d, &c)| d * c)
1115            .collect();
1116        crate::corrector::run(
1117            bs,
1118            &base,
1119            step,
1120            &pin_rows,
1121            &scaled_deltas,
1122            &ctx.lo,
1123            &ctx.hi,
1124            mu,
1125            max_iter,
1126            state.exact_hessian,
1127        )
1128    }
1129
1130    /// [`Self::parametric_step_bounded`] with the weak-row
1131    /// decision supplied by the caller instead of searched for. The
1132    /// direction is computed for the given working set (all weak rows
1133    /// released, the held variables pinned through Schur rows), then
1134    /// refined onto the bounds exactly as the searched variant does.
1135    /// Study surface for an externally solved eq. 14 QP.
1136    pub fn parametric_step_bounded_decided(
1137        &self,
1138        pin_constraint_indices: &[Index],
1139        deltas: &[Number],
1140        max_iter: usize,
1141        held_var_rows: &[Index],
1142        bound_eps: Option<Number>,
1143    ) -> Result<(Vec<Number>, Vec<Index>, crate::boundcheck::RefineStop), SolverError> {
1144        let weak = self.weakly_active_bounds()?;
1145        if weak.is_empty() {
1146            return self.parametric_step_bounded(
1147                pin_constraint_indices,
1148                deltas,
1149                max_iter,
1150                bound_eps,
1151            );
1152        }
1153        let rhs_plain = self.parametric_rhs_full(pin_constraint_indices, deltas)?;
1154        let ctx = self.bound_context(bound_eps)?;
1155        let state = self.state.borrow();
1156        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1157        let released: Vec<usize> = weak.iter().map(|w| w.row).collect();
1158        let pinned_rows: Vec<usize> = held_var_rows.iter().map(|&r| r as usize).collect();
1159        let (d, _) = crate::boundcheck::path_direction(
1160            &state.backsolver,
1161            &rhs_plain,
1162            &released,
1163            &pinned_rows,
1164        )
1165        .map_err(SolverError::SensComputationFailed)?;
1166        let (dx, pinned, stop) = crate::boundcheck::refine_step_onto_bounds(
1167            &state.backsolver,
1168            &d,
1169            &ctx.x_curr,
1170            &ctx.lo,
1171            &ctx.hi,
1172            &ctx.mults,
1173            &rhs_plain,
1174            ctx.eps,
1175            ctx.release_eps,
1176            max_iter,
1177        )
1178        .map_err(SolverError::SensComputationFailed)?;
1179        Ok((
1180            dx[..ctx.n_x].to_vec(),
1181            pinned.into_iter().map(|p| p as Index).collect(),
1182            stop,
1183        ))
1184    }
1185
1186    /// The all-released step: the plain parametric step solved with
1187    /// every weakly active bound's row released, and nothing decided.
1188    ///
1189    /// This is [`Self::parametric_step_directional`]'s first
1190    /// back-solve returned as the answer instead of refined. The
1191    /// caller trades the engagement's budget for whatever violations
1192    /// the released direction carries at weak bounds the perturbation
1193    /// actually holds, which come back as crossings for the mode's
1194    /// clamp, pins, or path segments, or for a correction, to handle.
1195    /// A clean base point takes the plain step. Returns the direction
1196    /// over the model's variables and the number of rows released.
1197    pub fn parametric_step_release_all(
1198        &self,
1199        pin_constraint_indices: &[Index],
1200        deltas: &[Number],
1201    ) -> Result<(Vec<Number>, usize), SolverError> {
1202        let rhs_plain = self.parametric_rhs_full(pin_constraint_indices, deltas)?;
1203        let weak = self.weakly_active_bounds()?;
1204        let ctx = self.bound_context(None)?;
1205        let state = self.state.borrow();
1206        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1207        let released: Vec<usize> = weak.iter().map(|w| w.row).collect();
1208        let mut d = vec![0.0; state.backsolver.dim()];
1209        // solve_released is the whole mechanism: an empty released set
1210        // is the plain solve, and shift = false matches the
1211        // directional path's all-released solve, whose rationale lives
1212        // on `solve_released_inner`.
1213        if !state
1214            .backsolver
1215            .solve_released(&released, &rhs_plain, &mut d)
1216        {
1217            return Err(SolverError::BacksolveFailed);
1218        }
1219        Ok((d[..ctx.n_x].to_vec(), released.len()))
1220    }
1221
1222    /// The eq. 14 directional derivative, decided by pounce-qp over
1223    /// the weak rows the direction engages.
1224    ///
1225    /// One released factorization serves the whole decision: the
1226    /// released `Σ` is built once and every solve passes the same
1227    /// object, so the factorization cache reuses the factor across the
1228    /// all-released direction and the basis columns. The decision
1229    /// itself is the dual of eq. 14 restricted to the weak rows the
1230    /// direction engages: with `a_k` the signed unit vector of weak
1231    /// row `k` (positive for a lower bound), `X_k = K_rel^{-1} a_k`,
1232    /// `S = aᵀX` and `m = aᵀd0`, the pin forces `λ` solve
1233    ///
1234    /// ```text
1235    ///     min  ½ λᵀ S λ + mᵀ λ    s.t.  λ ≥ 0
1236    /// ```
1237    ///
1238    /// whose KKT conditions are eq. 14's complementarity: a released
1239    /// row moves to its feasible side (the QP gradient `Sλ + m ≥ 0`)
1240    /// and a held row's pin force is nonnegative. Rows outside the
1241    /// engaged set are verified against the decided direction and the
1242    /// set expands until no new row violates. Nothing reads the
1243    /// perturbation's size, so the decision is linear in the step.
1244    ///
1245    /// An engaged row is decided only when its bound is at a kink,
1246    /// read off `kappa = sigma * S_kk`, the barrier weight times the
1247    /// row's own diagonal of the reduced matrix. `sigma` equals the
1248    /// curvature reduced along the coordinate at an exact kink, and
1249    /// `S_kk` is that reduced curvature's inverse, so `kappa` is 1
1250    /// there at any curvature, coupling, or scaling, and it falls as
1251    /// the squared ratio of kink width to slack away from one. A row
1252    /// below `KAPPA_MIN` is dropped from the engaged set and its
1253    /// plain movement stands: its bound is too far from a kink for a
1254    /// pin force to decide, and the error of leaving it undecided is
1255    /// bounded by its own slack, order `sqrt(mu)` at the threshold.
1256    /// A coordinate an equality pins is the limiting case, `S_kk`
1257    /// exactly zero, dropped by the same test.
1258    ///
1259    /// `max_iter` is the total back-solve budget: the all-released
1260    /// solve, every basis column, and the combined solve that recovers
1261    /// the direction all count against it. A budget of zero errs
1262    /// before any work. Any budget above that pays the all-released
1263    /// factorization first, because which rows engage is only known
1264    /// once that solve has run, and the shortfall is reported when the
1265    /// basis columns cannot fit. Either way the caller falls back to
1266    /// the one-sided step. Returns the direction, the var-x rows held,
1267    /// and the back-solves spent.
1268    pub fn parametric_step_directional(
1269        &self,
1270        pin_constraint_indices: &[Index],
1271        deltas: &[Number],
1272        max_iter: usize,
1273    ) -> Result<(Vec<Number>, Vec<usize>, usize), SolverError> {
1274        use pounce_common::types::NLP_UPPER_BOUND_INF;
1275        use pounce_linalg::triplet::{GenTMatrix, GenTMatrixSpace, SymTMatrix, SymTMatrixSpace};
1276        use pounce_qp::QpStatus;
1277        use pounce_qp::options::QpOptions;
1278        use pounce_qp::problem::{HessianInertia, QpProblem};
1279        use pounce_qp::solver::{ParametricActiveSetSolver, QpSolver};
1280
1281        const EPS_REL: Number = 1e-9;
1282        /// A row whose `kappa = sigma * S_kk` is below this is not at
1283        /// a kink and is dropped from the QP. `kappa` is 1 at an
1284        /// exact kink and equals the squared ratio of kink width to
1285        /// slack, so a row at the threshold sits about 30 widths from
1286        /// its bound and the cost of deciding it either way is
1287        /// bounded by that slack. Measured populations: exact fixture
1288        /// kinks 1.0, held solves near a release 4e-2 and 6e-3,
1289        /// genuinely interior rows 3e-8 and below, pin-owned rows at
1290        /// or below zero.
1291        const KAPPA_MIN: Number = 1e-3;
1292
1293        let rhs_plain = self.parametric_rhs_full(pin_constraint_indices, deltas)?;
1294        let weak = self.weakly_active_bounds()?;
1295        let ctx = self.bound_context(None)?;
1296        let state = self.state.borrow();
1297        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1298        let bs = &state.backsolver;
1299        let dim = bs.dim();
1300        let n_x = ctx.n_x;
1301        let nw = weak.len();
1302        let mut work = 0usize;
1303        // A weak bound's slack and multiplier are both of order
1304        // sqrt(mu) and their uncertainty equals their magnitude, so a
1305        // movement below sqrt(mu) of the direction's scale cannot be
1306        // resolved against the bound and does not warrant an exact
1307        // complementarity decision. The engagement and expansion
1308        // tests use this band; acceptance-level roundoff tests keep
1309        // EPS_REL.
1310        let band = bs.barrier_mu().max(0.0).sqrt().max(EPS_REL);
1311
1312        if weak.is_empty() {
1313            // a clean base point takes the plain step and no decision
1314            // happens, so the reported decision work is zero
1315            let mut d = vec![0.0; dim];
1316            if !bs.solve(&rhs_plain, &mut d) {
1317                return Err(SolverError::BacksolveFailed);
1318            }
1319            return Ok((d[..n_x].to_vec(), Vec::new(), 0));
1320        }
1321
1322        // What the caller needs is the number to raise
1323        // `degeneracy_iter` to, so the message reports the engaged
1324        // count rather than the weak-set size: engagement is the retry
1325        // price, and on a model with hundreds of weak bounds the two
1326        // differ by enough that raising one at a time is dozens of
1327        // retries. The engaged set can still grow on a later pass, so
1328        // the figure is a floor and says so.
1329        //
1330        // `engaged_now + 2` prices a decision that finishes on one
1331        // pass. Each expansion round pays another combined solve, so
1332        // on a multi-pass decision that total is short, and once the
1333        // engaged set stops growing it stops moving at all: the
1334        // combined solve of the last round would otherwise be told to
1335        // raise the budget to the number already spent, which is a
1336        // retry that buys nothing and reads as self-contradictory.
1337        // Flooring at `spent + 1` keeps the advice strictly larger
1338        // than what is gone, so every retry makes progress.
1339        let budget = |engaged_now: usize, spent: usize| {
1340            let need = (engaged_now + 2).max(spent + 1);
1341            SolverError::SensComputationFailed(format!(
1342                "directional derivative: {spent} of {max_iter} back-solve(s) \
1343                 spent, and {engaged_now} of {nw} weakly active bound(s) are \
1344                 engaged so far. Raise degeneracy_iter to at least {need}; \
1345                 the engaged set can still grow, so that is a floor."
1346            ))
1347        };
1348        let fail = |what: &str| {
1349            SolverError::SensComputationFailed(format!("directional derivative: {what}"))
1350        };
1351
1352        let released: Vec<usize> = weak.iter().map(|w| w.row).collect();
1353        let sigma = bs
1354            .released_sigma_x(&released)
1355            .ok_or_else(|| fail("released sigma unavailable"))?;
1356        if work + 1 > max_iter {
1357            // Nothing is engaged before the all-released solve, so
1358            // this fires only at a budget of zero, and the floor is
1359            // the one solve the decision cannot start without.
1360            return Err(SolverError::SensComputationFailed(format!(
1361                "directional derivative: degeneracy_iter is {max_iter}, and the \
1362                 decision cannot start without one back-solve over the {nw} \
1363                 weakly active bound(s). Raise degeneracy_iter to at least 2."
1364            )));
1365        }
1366        let mut d0 = vec![0.0; dim];
1367        // shift = false, matching `path_direction`'s all-released
1368        // solve: a weak bound's multiplier is order sqrt(mu) and the
1369        // released convention holds it at exactly zero, so the step
1370        // shift's multiplier injection is deliberately omitted.
1371        if !bs.solve_released_prebuilt(
1372            &released,
1373            Rc::clone(&sigma),
1374            None,
1375            None,
1376            &rhs_plain,
1377            &mut d0,
1378            false,
1379        ) {
1380            return Err(SolverError::BacksolveFailed);
1381        }
1382        work += 1;
1383
1384        // movement of weak row k under a direction: positive is the
1385        // feasible side for that row's bound
1386        let sign = |k: usize| if weak[k].lower { 1.0 } else { -1.0 };
1387        let movement = |k: usize, d: &[Number]| -> Number { sign(k) * d[weak[k].var_row] };
1388        // The barrier weight of each weak row's variable, in natural
1389        // units to match the natural-units response the back-solves
1390        // return, so `kappa` below is frame-invariant. The classifier
1391        // succeeded inside `weakly_active_bounds`, so a nonempty weak
1392        // set implies this call succeeds too.
1393        // `var_sigma` is a FULL-x array read from a VAR-x row, and the
1394        // `unwrap_or(0.0)` below turns a miss into a zero that silently
1395        // drops the row from the engaged set rather than raising. That
1396        // is the shape gh#672 finding 1 shipped, so the index is typed:
1397        // `sigma.at` takes a `FullX` and a bare `w.var_row` will not
1398        // compile. See `crate::index`.
1399        let nat_sigma: Vec<Number> = {
1400            let report = self.classify_activity()?;
1401            let (_, _, nlp) = state.backsolver.activity_handles();
1402            let nl = nlp.borrow();
1403            let sigma = FullXSlice::new(&report.var_sigma);
1404            let map = VarToFull::build(ctx.n_x, |r| nl.var_x_to_full_x(r.as_index()) as usize);
1405            weak.iter()
1406                .map(|w| {
1407                    map.full_of(VarX::new(w.var_row))
1408                        .and_then(|full| sigma.at(full))
1409                        .unwrap_or(0.0)
1410                })
1411                .collect()
1412        };
1413        let scale_of = |d: &[Number]| -> Number {
1414            d[..n_x]
1415                .iter()
1416                .fold(0.0_f64, |a, &b| a.max(b.abs()))
1417                .max(1e-300)
1418        };
1419
1420        let tol0 = band * scale_of(&d0);
1421        let mut engaged: Vec<usize> = (0..nw).filter(|&k| movement(k, &d0) < -tol0).collect();
1422        if engaged.is_empty() {
1423            return Ok((d0[..n_x].to_vec(), Vec::new(), work));
1424        }
1425
1426        // Each basis column is only ever read at the weak rows' own
1427        // variables, once to build `S` and never again: the direction
1428        // it contributes is recovered below in a single solve. So the
1429        // column is projected onto those `nw` entries and the
1430        // full-length vector dropped, which bounds this by the weak
1431        // set rather than by `dim` times the budget. Holding the full
1432        // columns costs about 114 MB on a 62k model at 230 engaged
1433        // rows, and grows with `degeneracy_iter`.
1434        let mut proj: Vec<Option<Vec<Number>>> = vec![None; nw];
1435        let mut d = d0.clone();
1436        let held: Vec<usize>;
1437        // A weak row is decided only when its bound is at a kink,
1438        // and `kappa = sigma * S_kk` measures exactly that: 1 at an
1439        // exact kink, falling as the squared ratio of kink width to
1440        // slack away from one. A row below the threshold is dropped
1441        // and its plain movement stands, since a pin force there
1442        // holds the coordinate a full slack from where the bound
1443        // actually is, and the error of not deciding is bounded by
1444        // that same slack. The limiting cases fall out of the one
1445        // test: a coordinate an equality owns has `S_kk` exactly
1446        // zero (its pin absorbs any bound force, and admitting it
1447        // puts a zero diagonal beside a nonzero gradient, an
1448        // unbounded QP), and a negative diagonal, which the QP could
1449        // not bound either, is likewise below the threshold.
1450        let mut inert: Vec<usize> = Vec::new();
1451        loop {
1452            for &k in &engaged {
1453                if proj[k].is_some() {
1454                    continue;
1455                }
1456                if work + 1 > max_iter {
1457                    return Err(budget(engaged.len(), work));
1458                }
1459                let mut unit = vec![0.0; dim];
1460                unit[weak[k].var_row] = sign(k);
1461                let mut xk = vec![0.0; dim];
1462                if !bs.solve_released_prebuilt(
1463                    &released,
1464                    Rc::clone(&sigma),
1465                    None,
1466                    None,
1467                    &unit,
1468                    &mut xk,
1469                    false,
1470                ) {
1471                    return Err(SolverError::BacksolveFailed);
1472                }
1473                work += 1;
1474                let col: Vec<Number> = weak.iter().map(|w| xk[w.var_row]).collect();
1475                let own = sign(k) * col[k];
1476                if nat_sigma[k] * own < KAPPA_MIN {
1477                    inert.push(k);
1478                }
1479                proj[k] = Some(col);
1480            }
1481            engaged.retain(|k| !inert.contains(k));
1482            if engaged.is_empty() {
1483                return Ok((d[..n_x].to_vec(), Vec::new(), work));
1484            }
1485
1486            // dense reduced data over the engaged rows, upper triangle
1487            let ke = engaged.len();
1488            let mut irows = Vec::new();
1489            let mut jcols = Vec::new();
1490            let mut vals = Vec::new();
1491            for i in 0..ke {
1492                for j in i..ke {
1493                    let col_j = proj[engaged[j]].as_ref().expect("column built");
1494                    let col_i = proj[engaged[i]].as_ref().expect("column built");
1495                    // S_ij = a_i^T X_j; symmetrize, since S is
1496                    // symmetric in exact arithmetic. The projection
1497                    // holds one entry per weak row, so a weak row's
1498                    // own index is where its `a` picks the column out.
1499                    let s_ij = 0.5
1500                        * (sign(engaged[i]) * col_j[engaged[i]]
1501                            + sign(engaged[j]) * col_i[engaged[j]]);
1502                    // pounce-linalg triplets are one-based
1503                    irows.push((i + 1) as Index);
1504                    jcols.push((j + 1) as Index);
1505                    vals.push(s_ij);
1506                }
1507            }
1508            // The engine's feasibility and optimality tolerances are
1509            // absolute and act on the QP's variables, which are the
1510            // pin forces, so both sides of the problem are scaled to
1511            // order one: the gradient against the direction's scale
1512            // (a 1e-10 perturbation must decide the same way a 1e-2
1513            // one does) and S against its largest entry, which is a
1514            // compliance in the model's units. The joint scaling maps
1515            // the solution by g_scale / s_scale exactly, so the
1516            // scaled solve loses nothing.
1517            let g_raw: Vec<Number> = engaged.iter().map(|&k| movement(k, &d0)).collect();
1518            let g_scale = g_raw
1519                .iter()
1520                .fold(0.0_f64, |a, &b| a.max(b.abs()))
1521                .max(1e-300);
1522            let g: Vec<Number> = g_raw.iter().map(|&v| v / g_scale).collect();
1523            let s_scale = vals
1524                .iter()
1525                .fold(0.0_f64, |a, &b| a.max(b.abs()))
1526                .max(1e-300);
1527            let vals_scaled: Vec<Number> = vals.iter().map(|&v| v / s_scale).collect();
1528            let space = SymTMatrixSpace::new(ke as Index, irows, jcols);
1529            let mut h = SymTMatrix::new(space);
1530            h.set_values(&vals_scaled);
1531            let a_space = GenTMatrixSpace::new(0, ke as Index, Vec::new(), Vec::new());
1532            let a = GenTMatrix::new(a_space);
1533            let xl = vec![0.0; ke];
1534            let xu = vec![NLP_UPPER_BOUND_INF; ke];
1535            let qp = QpProblem {
1536                n: ke,
1537                m: 0,
1538                h: &h,
1539                g: &g,
1540                a: &a,
1541                bl: &[],
1542                bu: &[],
1543                xl: &xl,
1544                xu: &xu,
1545                hessian_inertia: HessianInertia::Unknown,
1546            };
1547            let opts = QpOptions {
1548                max_iter: (10 * ke as u32).max(200),
1549                // the engine's Schur-update path (use_schur_updates)
1550                // hits MaxIter on a dense reduced problem of hundreds
1551                // of rows where the refactorizing path terminates
1552                // Optimal, so the default stays; the heavy-direction
1553                // exact decision pays engine refactorizations and is
1554                // priced accordingly in the docs
1555                ..QpOptions::default()
1556            };
1557            let mut engine =
1558                ParametricActiveSetSolver::new(Box::new(pounce_feral::FeralSolverInterface::new()));
1559            let sol = engine
1560                .solve(&qp, None, &opts)
1561                .map_err(|e| fail(&format!("reduced QP failed: {e:?}")))?;
1562            if sol.status != QpStatus::Optimal {
1563                return Err(fail(&format!(
1564                    "reduced QP terminated {:?} over {ke} engaged row(s)",
1565                    sol.status
1566                )));
1567            }
1568            let lambda: Vec<Number> = sol.x.iter().map(|&v| v * (g_scale / s_scale)).collect();
1569
1570            // plus, not minus: the QP's optimality gradient is
1571            // S lambda + m, so the direction's movement must be
1572            // m + lambda S, which is d0 + Σ λ_k X_k here.
1573            //
1574            // Each `X_k` is `K_rel⁻¹ a_k`, so that sum is
1575            // `K_rel⁻¹ (Σ λ_k a_k)` and one solve on the combined
1576            // right-hand side gives it. That is why the columns above
1577            // need not be kept: the only thing they were held for is
1578            // recovered here, in a single back-solve, at the price of
1579            // one more against the budget per expansion round.
1580            d.copy_from_slice(&d0);
1581            if lambda.iter().any(|&l| l != 0.0) {
1582                if work + 1 > max_iter {
1583                    return Err(budget(engaged.len(), work));
1584                }
1585                let mut comb = vec![0.0; dim];
1586                for (i, &k) in engaged.iter().enumerate() {
1587                    comb[weak[k].var_row] += lambda[i] * sign(k);
1588                }
1589                let mut corr = vec![0.0; dim];
1590                if !bs.solve_released_prebuilt(
1591                    &released,
1592                    Rc::clone(&sigma),
1593                    None,
1594                    None,
1595                    &comb,
1596                    &mut corr,
1597                    false,
1598                ) {
1599                    return Err(SolverError::BacksolveFailed);
1600                }
1601                work += 1;
1602                for (dv, &cv) in d.iter_mut().zip(corr.iter()) {
1603                    *dv += cv;
1604                }
1605            }
1606
1607            let tol = band * scale_of(&d);
1608            let mut grew = false;
1609            for k in 0..nw {
1610                if engaged.contains(&k) || inert.contains(&k) {
1611                    continue;
1612                }
1613                if movement(k, &d) < -tol {
1614                    engaged.push(k);
1615                    grew = true;
1616                }
1617            }
1618            if !grew {
1619                // relative to the largest pin force, with no absolute
1620                // floor: a 1e-10-scale perturbation's pins are as real
1621                // as a 1e-2 one's, and a floor here silently unlabels
1622                // them while the direction still carries the pin
1623                let lam_scale = lambda
1624                    .iter()
1625                    .fold(0.0_f64, |a, &b| a.max(b.abs()))
1626                    .max(1e-300);
1627                held = engaged
1628                    .iter()
1629                    .enumerate()
1630                    .filter(|(i, _)| lambda[*i] > EPS_REL * lam_scale)
1631                    .map(|(_, &k)| weak[k].var_row)
1632                    .collect();
1633                break;
1634            }
1635        }
1636
1637        Ok((d[..n_x].to_vec(), held, work))
1638    }
1639
1640    /// The bounds the activity classifier could not call at the base
1641    /// point: on the bound with a multiplier of the same order as the
1642    /// slack. Each entry is a bound row present in the held
1643    /// factorization, with the side taken from the smaller slack,
1644    /// which is the only side an ambiguous label can come from.
1645    ///
1646    /// Both [`WEAKLY_ACTIVE`](crate::activity::WEAKLY_ACTIVE) and
1647    /// [`AMBIGUOUS`](crate::activity::AMBIGUOUS) count as weak here,
1648    /// deliberately: the ambiguous class contains genuine kinks whose
1649    /// coordinate is coupled to a neighbour (gh#763), so treating it
1650    /// as "not a kink" would drop real weak rows. That is why the
1651    /// mislabeling is not a wrong answer in the step path — see
1652    /// [`Self::reduced_activity`] for the class itself.
1653    ///
1654    /// The classifier reports per user variable, in full-x, while the
1655    /// bound context and the factor's rows are var-x, and the two
1656    /// index spaces diverge from the first fixed variable on. Each
1657    /// var-x row's status is read through the same map the classifier
1658    /// scattered through, so a fixed variable shifts nothing. Using
1659    /// the full-x index as a factor row instead returns a NEIGHBORING
1660    /// variable's answer, plausible and wrong, which is the gh#450
1661    /// hazard the `primal_row` discipline exists to prevent.
1662    pub fn weakly_active_bounds(&self) -> Result<Vec<crate::boundcheck::WeakBound>, SolverError> {
1663        use crate::activity::{AMBIGUOUS, WEAKLY_ACTIVE};
1664
1665        // A relaxed solve shifts the slacks the classifier reads, so
1666        // degeneracy is undetectable there: the callers take the plain
1667        // step, the same choice `estimate_report` makes when it fills
1668        // `bounds_relaxed` instead of raising.
1669        let report = match self.classify_activity() {
1670            Ok(r) => r,
1671            Err(SolverError::BadOptions(_)) => return Ok(Vec::new()),
1672            Err(e) => return Err(e),
1673        };
1674        let ctx = self.bound_context(None)?;
1675        let state = self.state.borrow();
1676        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1677        let Some(rows) = state.backsolver.bound_rows() else {
1678            return Ok(Vec::new());
1679        };
1680        // Three index spaces are live in the loop below: `report` is
1681        // FULL-x, `ctx` is VAR-x, and `br.row` is a bound row. The
1682        // first two coincide until the first `make_parameter`-removed
1683        // variable and diverge after it, so a swap reads a NEIGHBOURING
1684        // variable's status -- in range, plausible, wrong (gh#450, then
1685        // gh#672 finding 1). The typed indices make that a compile
1686        // error; `sens_invariance_legs.rs` leg 3 is what covers the
1687        // site already written. See `crate::index`.
1688        let map = {
1689            let (_, _, nlp) = state.backsolver.activity_handles();
1690            let nl = nlp.borrow();
1691            VarToFull::build(ctx.n_x, |r| nl.var_x_to_full_x(r.as_index()) as usize)
1692        };
1693        let status = FullXSlice::new(&report.var_status);
1694        let mut out = Vec::new();
1695        for row in map.rows() {
1696            let Some(full) = map.full_of(row) else {
1697                continue;
1698            };
1699            let Some(st) = status.at(full) else {
1700                continue;
1701            };
1702            if st != WEAKLY_ACTIVE && st != AMBIGUOUS {
1703                continue;
1704            }
1705            let (s_lo, s_hi) = ctx.slacks_at(row);
1706            let lower = s_lo <= s_hi;
1707            // a table lookup, so a space-swap here would fail loudly
1708            let var_row = row.get();
1709            if let Some(br) = rows
1710                .iter()
1711                .find(|b| b.var_row == var_row && b.lower == lower)
1712            {
1713                out.push(crate::boundcheck::WeakBound {
1714                    row: br.row,
1715                    var_row,
1716                    lower,
1717                });
1718            }
1719        }
1720        Ok(out)
1721    }
1722
1723    /// The bound geometry both bound-aware steps read: the primal
1724    /// block's size and base point, its bounds in the model's own
1725    /// units, the tolerance that decides what counts as on a bound, and
1726    /// the bound multipliers at the base point.
1727    ///
1728    /// Shared rather than assembled twice. The two callers have to
1729    /// agree on all of it, and the unit and index-space conversions
1730    /// below are exactly what went wrong when a second caller wrote its
1731    /// own.
1732    ///
1733    /// `bound_eps` overrides the margin. `None` keeps how far outside
1734    /// the solve itself was willing to settle, floored so an unrelaxed
1735    /// solve does not pin on roundoff.
1736    fn bound_context(&self, bound_eps: Option<Number>) -> Result<BoundContext, SolverError> {
1737        let state = self.state.borrow();
1738        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1739        let n_x = state.backsolver.block_dims()[0];
1740
1741        // Expanded once, before any re-solve: reading the compressed
1742        // form means borrowing the NLP, and the solves below re-borrow
1743        // it.
1744        let (mut lo, mut hi) = {
1745            let (_, _, nlp) = state.backsolver.activity_handles();
1746            let nl = nlp.borrow();
1747            crate::boundcheck::expand_bounds(n_x, &nl.px_l(), &nl.px_u(), nl.x_l(), nl.x_u())
1748        };
1749        // Those bounds bound the algorithm's `x̃ = d ⊙ x`, while
1750        // `state.x` and the step are both in the model's own units
1751        // (gh#486 stage 3). Undo the change of variables on the bounds
1752        // so all three agree, rather than projecting onto the wrong box.
1753        // A negative factor reflects the interval, so the sides swap.
1754        // `variable_scaling`, not `variable_scaling_full`: `lo` / `hi`
1755        // are var-x length, and the two index spaces diverge from the
1756        // first fixed variable on.
1757        if let Some(d) = state.backsolver.variable_scaling() {
1758            for i in 0..n_x {
1759                let di = d[i];
1760                if di == 0.0 || di == 1.0 {
1761                    continue;
1762                }
1763                let (a, b) = (lo[i] / di, hi[i] / di);
1764                lo[i] = a.min(b);
1765                hi[i] = a.max(b);
1766            }
1767        }
1768
1769        // What counts as outside a bound is the solve's own answer: it
1770        // was willing to leave a converged point `bound_relax_factor`
1771        // outside, so anything within that is on the bound, not past
1772        // it. A floor keeps an unrelaxed solve from pinning on
1773        // roundoff.
1774        let floor = crate::boundcheck::release_floor(state.bound_relax_factor);
1775        // Rejected here rather than at each entry point, so the pyo3
1776        // binding and every Rust caller get the check the CLI's
1777        // `sens_bound_eps` gets from its strict lower bound. Zero
1778        // reinstates the roundoff pinning the floor prevents, and NaN
1779        // makes `over > eps` false everywhere, so the refinement pins
1780        // nothing and still reports settled — both return a plausible
1781        // vector rather than failing, which is the worse outcome.
1782        // `> 0.0` is false for NaN, as `pyomo_pounce`'s own check is.
1783        let eps = match bound_eps {
1784            None => floor,
1785            Some(e) if e > 0.0 => e,
1786            Some(e) => {
1787                return Err(SolverError::BadOptions(format!(
1788                    "bound_eps must be a positive number, got {e}"
1789                )));
1790            }
1791        };
1792        // A caller's `bound_eps` is a primal margin and says nothing
1793        // about when a multiplier has changed sign, so the release test
1794        // keeps the solve's own margin.
1795        let release_eps = floor;
1796        // The bound multipliers at the base point, with the compound
1797        // row each one occupies, so a step that drives one negative can
1798        // release that bound.
1799        let mults = {
1800            let dims = state.backsolver.block_dims();
1801            let (z_l_off, z_u_off) = (
1802                dims[0] + dims[1] + dims[2] + dims[3],
1803                dims[0] + dims[1] + dims[2] + dims[3] + dims[4],
1804            );
1805            let (data, _, _) = state.backsolver.activity_handles();
1806            let d = data.borrow();
1807            let curr = d.curr.as_ref().ok_or(SolverError::NotConverged)?;
1808            let mut out = Vec::new();
1809            for (off, v) in [(z_l_off, &curr.z_l), (z_u_off, &curr.z_u)] {
1810                for (k, &base) in crate::vec_util::dense_to_vec(&**v).iter().enumerate() {
1811                    out.push(crate::boundcheck::BoundMultiplier { row: off + k, base });
1812                }
1813            }
1814            out
1815        };
1816        Ok(BoundContext {
1817            n_x,
1818            lo,
1819            hi,
1820            x_curr: state.x[..n_x].to_vec(),
1821            eps,
1822            release_eps,
1823            mults,
1824        })
1825    }
1826
1827    /// Full KKT-space parametric step for a set of pinned equality
1828    /// constraints: the same computation as [`Self::parametric_step`],
1829    /// returned WITHOUT truncating to the primal block. The layout is
1830    /// the compound KKT vector `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)`;
1831    /// use [`Self::block_dims`] for the block sizes and
1832    /// [`Self::g_multiplier_rows`] to locate a constraint's multiplier
1833    /// row. This exposes the multiplier sensitivities `∂λ*/∂p`
1834    /// alongside the primal step.
1835    pub fn parametric_step_full(
1836        &self,
1837        pin_constraint_indices: &[Index],
1838        deltas: &[Number],
1839    ) -> Result<Vec<Number>, SolverError> {
1840        if pin_constraint_indices.len() != deltas.len() {
1841            return Err(SolverError::BadShape {
1842                what: "deltas",
1843                got: deltas.len(),
1844                expected: pin_constraint_indices.len(),
1845            });
1846        }
1847        let state = self.state.borrow();
1848        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1849
1850        let param_rows = state
1851            .backsolver
1852            .map_pin_g_to_kkt_rows(pin_constraint_indices)
1853            .map_err(SolverError::SensComputationFailed)?;
1854        let signs = vec![1; pin_constraint_indices.len()];
1855        let a_data = IndexSchurData::from_parts(param_rows, signs)
1856            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
1857
1858        let opts = SensOptions {
1859            run_sens: true,
1860            ..SensOptions::default()
1861        };
1862        let sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
1863        let n_full = state.backsolver.dim();
1864        let mut dx_full = vec![0.0; n_full];
1865        if !sens_app.parametric_step(deltas, &mut dx_full) {
1866            return Err(SolverError::SensComputationFailed(
1867                "SensApplication::parametric_step failed".into(),
1868            ));
1869        }
1870        let corr = self.barrier_correction(state)?;
1871        for (d, c) in dx_full.iter_mut().zip(corr.iter()) {
1872            *d += *c * BARRIER_SIGN;
1873        }
1874        Ok(dx_full)
1875    }
1876
1877    /// Flat rows of the compound KKT vector holding the equality
1878    /// multipliers `y_c` for the given 0-based **full-g** constraint
1879    /// indices. `None` for inequalities (their multipliers live in the
1880    /// `y_d` block; mapping those is not exposed here). Row `r` of a
1881    /// [`Self::parametric_step_full`] result is then `∂λ_g/∂p · Δp`.
1882    pub fn g_multiplier_rows(
1883        &self,
1884        g_indices: &[Index],
1885    ) -> Result<Vec<Option<Index>>, SolverError> {
1886        let state = self.state.borrow();
1887        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1888        let dims = state.backsolver.block_dims();
1889        let y_c_offset = (dims[0] + dims[1]) as Index;
1890        Ok(g_indices
1891            .iter()
1892            .map(|&g| {
1893                state
1894                    .backsolver
1895                    .full_g_to_c_block(g)
1896                    .map(|pos| y_c_offset + pos)
1897            })
1898            .collect())
1899    }
1900
1901    /// Flat rows of the compound KKT vector holding the primal values
1902    /// `x` for the given 0-based **full-x** variable indices. `None`
1903    /// where the solve removed the column (`x_l == x_u` under
1904    /// `fixed_variable_treatment = make_parameter`), which has no row
1905    /// in the factor at all.
1906    ///
1907    /// The `x` counterpart of [`Self::g_multiplier_rows`], and needed
1908    /// for the same reason: a caller holding user-space indices — from
1909    /// the `.col` file, from [`Self::classify_activity`], from
1910    /// [`Self::row_normal`] — cannot index the factor with them
1911    /// directly. Row `r` of a [`Self::parametric_step_full`] result is
1912    /// then `∂x/∂p · Δp` for that variable, and `e_r` is the unit
1913    /// vector selecting its column in a [`Self::kkt_solve`].
1914    pub fn x_primal_rows(&self, x_indices: &[Index]) -> Result<Vec<Option<Index>>, SolverError> {
1915        let state = self.state.borrow();
1916        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1917        let n_full = state.backsolver.n_full_x();
1918        // out of range must not masquerade as "removed as fixed": the
1919        // NLP map returns None for both, and the caller's whole reason
1920        // for asking is that it cannot tell the spaces apart itself
1921        if let Some(&bad) = x_indices.iter().find(|&&i| i < 0 || i >= n_full) {
1922            return Err(SolverError::BadShape {
1923                what: "x_primal_rows variable index",
1924                got: bad as usize,
1925                expected: n_full as usize,
1926            });
1927        }
1928        // the x block starts at flat index 0, so the var-x position IS
1929        // the KKT row; the offset stays explicit for the day it is not
1930        Ok(x_indices
1931            .iter()
1932            .map(|&i| state.backsolver.full_x_to_var_x(i))
1933            .collect())
1934    }
1935
1936    /// The user TNLP's variable count: the length of a full-x report
1937    /// and the domain of [`Self::x_primal_rows`].
1938    pub fn n_full_x(&self) -> Result<usize, SolverError> {
1939        let state = self.state.borrow();
1940        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1941        Ok(state.backsolver.n_full_x() as usize)
1942    }
1943
1944    /// The user TNLP's constraint count: the length of a full-g
1945    /// report and the domain of [`Self::reduced_row_activity`].
1946    pub fn n_full_g(&self) -> Result<usize, SolverError> {
1947        let state = self.state.borrow();
1948        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1949        Ok(state.backsolver.n_full_g() as usize)
1950    }
1951
1952    /// Reduced Hessian `H_R = obj_scal · B K⁻¹ Bᵀ` over the pinned
1953    /// equality-constraint rows, where `B` selects the
1954    /// `pin_constraint_indices` rows of the y_c block and `K` is the
1955    /// **natural-units** (unscaled) KKT matrix — active NLP scaling
1956    /// is undone by the backsolver, so `−inv(H_R)` is directly the
1957    /// parameter covariance regardless of `nlp_scaling_method`
1958    /// (pounce#128). `obj_scal` survives as a plain extra multiplier
1959    /// (default 1.0); it is no longer needed to recover natural units.
1960    /// Returns the `n²`-long column-major dense matrix
1961    /// (`n = pin_constraint_indices.len()`).
1962    ///
1963    /// Equivalent to [`crate::SensSolve::with_reduced_hessian`] but
1964    /// usable post-hoc on a held `Solver`. For the solver-space
1965    /// (pre-#128) value use [`Self::compute_reduced_hessian_scaled`];
1966    /// the factors themselves are exposed via [`Self::nlp_scaling`] /
1967    /// [`Self::pin_g_scaling`].
1968    pub fn compute_reduced_hessian(
1969        &self,
1970        pin_constraint_indices: &[Index],
1971        obj_scal: Number,
1972    ) -> Result<Vec<Number>, SolverError> {
1973        let state = self.state.borrow();
1974        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
1975        let n = pin_constraint_indices.len();
1976        let param_rows = state
1977            .backsolver
1978            .map_pin_g_to_kkt_rows(pin_constraint_indices)
1979            .map_err(SolverError::SensComputationFailed)?;
1980        let signs = vec![1; n];
1981        let a_data = IndexSchurData::from_parts(param_rows, signs)
1982            .map_err(|e| SolverError::SensComputationFailed(format!("{e:?}")))?;
1983        let opts = SensOptions {
1984            compute_red_hessian: true,
1985            obj_scal,
1986            ..SensOptions::default()
1987        };
1988        let mut sens_app = SensApplication::new(a_data, state.backsolver.clone(), opts);
1989        let mut hr = vec![0.0; n * n];
1990        if !sens_app.compute_reduced_hessian(&mut hr) {
1991            return Err(SolverError::SensComputationFailed(
1992                "SensApplication::compute_reduced_hessian failed".into(),
1993            ));
1994        }
1995        Ok(hr)
1996    }
1997
1998    /// [`Self::compute_reduced_hessian`] plus its eigendecomposition —
1999    /// `(H_R, eigenvalues, eigenvectors)`.
2000    ///
2001    /// The curvature on the null space of the active constraints is the
2002    /// question; its **spectrum** is what answers "is this parameter
2003    /// identifiable, and along which direction". `SensSolve` has offered that
2004    /// since gh#561 ([`crate::SensSolve::with_reduced_hessian_eigen`]), and
2005    /// the session API did not — so a caller holding a `Solver` had to
2006    /// re-solve the whole NLP through the one-shot builder to get a
2007    /// decomposition of a matrix it already had. That is the gap this closes;
2008    /// the numbers are the one-shot path's, from the same
2009    /// [`pounce_linalg::symmetric_eigen`].
2010    ///
2011    /// Eigenvectors are column-major, length `n²`, column `j` belonging to
2012    /// eigenvalue `j`, and sign-pinned by `symmetric_eigen` so a column read
2013    /// as a direction reproduces across builds.
2014    pub fn compute_reduced_hessian_eigen(
2015        &self,
2016        pin_constraint_indices: &[Index],
2017        obj_scal: Number,
2018    ) -> Result<(Vec<Number>, Vec<Number>, Vec<Number>), SolverError> {
2019        let hr = self.compute_reduced_hessian(pin_constraint_indices, obj_scal)?;
2020        let n = pin_constraint_indices.len();
2021        let mut vals = vec![0.0; n];
2022        let mut vecs = vec![0.0; n * n];
2023        if !pounce_linalg::symmetric_eigen(&hr, n, &mut vals, &mut vecs) {
2024            return Err(SolverError::SensComputationFailed(
2025                "the reduced Hessian's eigendecomposition did not converge".into(),
2026            ));
2027        }
2028        Ok((hr, vals, vecs))
2029    }
2030
2031    /// The reduced Hessian as the solver's internal **scaled** space
2032    /// sees it — the value [`Self::compute_reduced_hessian`] returned
2033    /// before pounce#128: `H̃_ij = (df / (dc_i·dc_j)) · H_ij`.
2034    /// Identical to `compute_reduced_hessian` when no NLP scaling is
2035    /// active.
2036    pub fn compute_reduced_hessian_scaled(
2037        &self,
2038        pin_constraint_indices: &[Index],
2039        obj_scal: Number,
2040    ) -> Result<Vec<Number>, SolverError> {
2041        let mut hr = self.compute_reduced_hessian(pin_constraint_indices, obj_scal)?;
2042        let state = self.state.borrow();
2043        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
2044        let df = state.backsolver.obj_scaling_factor();
2045        let dc = state
2046            .backsolver
2047            .pin_c_scales(pin_constraint_indices)
2048            .map_err(SolverError::SensComputationFailed)?;
2049        crate::reduced_hessian::scale_to_solver_space(&mut hr, df, &dc);
2050        Ok(hr)
2051    }
2052
2053    /// Effective NLP scaling the IPM applied on the most recent
2054    /// converged solve: `(obj_scaling_factor, c_scale, d_scale)`.
2055    /// `(1.0, None, None)` ⇔ no scaling was active. The vectors are
2056    /// per-row factors over the algorithm's equality (`c`) and
2057    /// inequality (`d`) blocks.
2058    pub fn nlp_scaling(
2059        &self,
2060    ) -> Result<(Number, Option<Vec<Number>>, Option<Vec<Number>>), SolverError> {
2061        let state = self.state.borrow();
2062        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
2063        Ok(state.backsolver.nlp_scaling())
2064    }
2065
2066    /// The per-variable `user-scaling` factors `d` the held solve ran
2067    /// under (gh#486), in the user TNLP's **full-x** space, or `None`
2068    /// when the solve applied no change of variables.
2069    ///
2070    /// Every accessor on this type already reports natural units, so
2071    /// this is diagnostic rather than a correction a caller has to
2072    /// apply — it answers "was this solve conditioned, and by how
2073    /// much", the x-axis counterpart of [`Self::nlp_scaling`].
2074    pub fn variable_scaling(&self) -> Result<Option<Vec<Number>>, SolverError> {
2075        let state = self.state.borrow();
2076        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
2077        Ok(state.backsolver.variable_scaling_full().map(|d| d.to_vec()))
2078    }
2079
2080    /// Inertia-correction perturbations `(δ_x, δ_s, δ_c, δ_d)` baked
2081    /// into the held KKT factor. All zero ⇔ the final factorization
2082    /// was unregularized and the natural-units back-solves invert the
2083    /// exact KKT matrix — see
2084    /// [`crate::PdSensBacksolver::kkt_perturbations`].
2085    pub fn kkt_perturbations(&self) -> Result<[Number; 4], SolverError> {
2086        let state = self.state.borrow();
2087        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
2088        Ok(state.backsolver.kkt_perturbations())
2089    }
2090
2091    /// Per-pin equality-row scaling factors `dc_i` (1.0 entries when
2092    /// no constraint scaling is active), ordered like
2093    /// `pin_constraint_indices`.
2094    pub fn pin_g_scaling(
2095        &self,
2096        pin_constraint_indices: &[Index],
2097    ) -> Result<Vec<Number>, SolverError> {
2098        let state = self.state.borrow();
2099        let state = state.as_ref().ok_or(SolverError::NotConverged)?;
2100        state
2101            .backsolver
2102            .pin_c_scales(pin_constraint_indices)
2103            .map_err(SolverError::SensComputationFailed)
2104    }
2105}