Skip to main content

pounce_sensitivity/
algorithm_backsolver.rs

1//! `PdSensBacksolver` — `SensBacksolver` adapter over the converged
2//! `PdFullSpaceSolver` from `pounce-algorithm`.
3//!
4//! This is the Phase B.2 piece tracked in
5//! [pounce#16](https://github.com/jkitchin/pounce/issues/16): it lets
6//! `pounce-sensitivity` drive backsolves against the real converged
7//! KKT factor, replacing the synthetic [`crate::DenseLuBacksolver`]
8//! used by Phase B.1 unit tests.
9//!
10//! # Use
11//!
12//! 1. Register an `on_converged` callback on `IpoptApplication` via
13//!    [`pounce_algorithm::application::IpoptApplication::set_on_converged`].
14//! 2. Inside the callback, build a `PdSensBacksolver` from the four
15//!    handles passed in (`data`, `cq`, `nlp`, `&mut pd_solver`).
16//! 3. Hand it to [`crate::SensApplication`] / a `SensStepCalc` /
17//!    [`crate::compute_reduced_hessian`] like any other
18//!    [`SensBacksolver`].
19//!
20//! Upstream `SensSimpleBacksolver`
21//! ([`ref/Ipopt/contrib/sIPOPT/src/SensSimpleBacksolver.cpp`](../../../ref/Ipopt/contrib/sIPOPT/src/SensSimpleBacksolver.cpp))
22//! is the analogous wrapper around `IpoptCalculatedQuantities` +
23//! `PDSystemSolver` upstream.
24//!
25//! # Flat-slice ↔ `IteratesVector` mapping
26//!
27//! The full primal-dual state of pounce's IPM is the eight-block
28//! compound `(x, s, λ_c, λ_d, z_l, z_u, v_l, v_u)` (see
29//! [`pounce_algorithm::iterates_vector::IteratesVector`]). This
30//! adapter packs / unpacks the flat slices that
31//! [`crate::SensBacksolver`] takes as the concatenation
32//! `x || s || λ_c || λ_d || z_l || z_u || v_l || v_u`, mirroring
33//! upstream's `CompoundVector` layout (`IpCompoundVector.hpp`).
34//!
35//! # Reference
36//!
37//! Pirnay, H.; López-Negrete, R.; Biegler, L. T. (2012). *Optimal
38//! sensitivity based on IPOPT*. Mathematical Programming Computation,
39//! **4**(4), 307–331. DOI:
40//! [10.1007/s12532-012-0043-2](https://doi.org/10.1007/s12532-012-0043-2).
41//! Verified via Crossref on 2026-05-13.
42
43use std::cell::RefCell;
44use std::rc::Rc;
45
46use pounce_algorithm::ipopt_cq::IpoptCqHandle;
47use pounce_algorithm::ipopt_data::IpoptDataHandle;
48use pounce_algorithm::iterates_vector::{IteratesVector, IteratesVectorMut};
49use pounce_algorithm::kkt::pd_full_space_solver::PdFullSpaceSolver;
50use pounce_common::types::{Index, Number};
51use pounce_linalg::dense_vector::DenseVector;
52use pounce_nlp::ipopt_nlp::IpoptNlp;
53
54use crate::backsolver::SensBacksolver;
55
56/// Adapter from `PdFullSpaceSolver` to [`SensBacksolver`]. Holds
57/// owning clones of the four pieces of the algorithm's converged
58/// state, plus the 8-block iterate template used to allocate fresh
59/// RHS / LHS vectors.
60///
61/// The PD solver lives behind an `Rc<RefCell<…>>` because
62/// [`SensBacksolver::solve`] is `&self` but the upstream signature
63/// for `PdFullSpaceSolver::solve` is `&mut self` (it caches the
64/// last-solve dependency tags and the augsys-improved flag). The
65/// `RefCell` is single-thread-only, single-borrow, exactly matching
66/// the call pattern from `pounce-sensitivity`'s pipeline.
67///
68/// Owning (rather than borrowing) the four handles is what lets a
69/// `PdSensBacksolver` outlive the `on_converged` callback frame —
70/// required by the public `Solver` session API in `pounce-algorithm`,
71/// which retains the backsolver for repeated `parametric_step` /
72/// `kkt_solve` / `compute_reduced_hessian` calls after the IPM has
73/// returned. The data, cq, and nlp handles are already
74/// `Rc<RefCell<…>>` cheap-clone handles upstream, so this carries no
75/// allocation overhead.
76#[derive(Clone)]
77pub struct PdSensBacksolver {
78    /// Shared, interior-mutable handle to the converged PD solver.
79    /// Cloned from `PdSearchDirCalc::pd_solver_rc()` at construction.
80    pd: Rc<RefCell<PdFullSpaceSolver>>,
81    data: IpoptDataHandle,
82    cq: IpoptCqHandle,
83    nlp: Rc<RefCell<dyn IpoptNlp>>,
84    /// Block dimensions in `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order.
85    dims: [usize; 8],
86    /// 8-block prototype used to mint fresh vectors with the same
87    /// `VectorSpace`s as the converged iterate; cloned from
88    /// `data.borrow().curr`.
89    template: IteratesVector,
90    /// Natural-units row/column scaling pair (pounce#128). The IPM's
91    /// KKT factor is held in the NLP's internally **scaled** space
92    /// (objective factor `df`, per-row constraint factors `dc` / `dd`
93    /// from `nlp_scaling_method`; scaled multipliers `ỹ = (df/dc)·y`,
94    /// `z̃ = df·z`, `ṽ = (df/dd)·v`, scaled slack `s̃ = dd·s`). The
95    /// scaled 8-block primal-dual system is the two-sided diagonal
96    /// scaling `K̃ = E K F` of the natural-units system, with
97    /// per-block entries
98    ///
99    /// ```text
100    ///        x      s        y_c      y_d     z_l/z_u   v_l/v_u
101    /// E  =   df     df/dd_i  dc_i     dd_i    df        df
102    /// F  =   1      1/dd_i   dc_i/df  dd_i/df 1/df      dd_r(j)/df
103    /// ```
104    ///
105    /// (`dd_r(j)` = the d-row scaling of the j-th finite d-bound,
106    /// through the `pd_l` / `pd_u` expansion). Hence
107    /// `K⁻¹ = F K̃⁻¹ E`: scale the RHS by `E`, back-solve against the
108    /// held factor, scale the result by `F`. Unlike a symmetric
109    /// congruence this needs no square root, so it covers a negative
110    /// `obj_scaling_factor` (maximization) and covers the z/v
111    /// bound-multiplier rows exactly (those rows admit no symmetric
112    /// diagonal: `K̃_{z,x} = df·Z·Pᵀ` but `K̃_{z,z} = X − x_L` is
113    /// unscaled). `None` ⇔ scaling inactive, identity.
114    ///
115    /// **Variable scaling** (gh#486 stage 3) multiplies into the same
116    /// pair. A change of variables `x̃ = d ⊙ x` contributes
117    ///
118    /// ```text
119    ///        x      z_l/z_u          everything else
120    /// E  =   1/d    1                1
121    /// F  =   1/d    d_{px(j)}        1
122    /// ```
123    ///
124    /// (`d_{px(j)}` = the factor of the variable carrying the j-th
125    /// finite bound, through the `px_l` / `px_u` expansion). The `s`,
126    /// `y_c`, `y_d`, `v_l` and `v_u` blocks are untouched because the
127    /// substitution leaves `c`, `d` and their multipliers alone. The
128    /// two contributions compose by elementwise product, in either
129    /// order, because both are diagonal.
130    conj: Option<Rc<ConjPair>>,
131    /// Per-variable factors `d` the solve ran under (gh#486), in the
132    /// algorithm's **var-x** space — i.e. already projected through
133    /// the fixed-variable map, so entry `i` matches KKT row `i`.
134    /// `None` ⇔ no variable scaling. Folded into [`Self::conj`] for
135    /// the back-solves; kept here for the consumers that read the
136    /// converged iterate and the model's matrices directly rather than
137    /// through the factor (see [`crate::activity`]).
138    d_var: Option<Rc<Vec<Number>>>,
139    /// The same factors in the user TNLP's **full-x** space, the shape
140    /// `finalize_solution_z_l` / `n_full_x`-length reports come in.
141    /// `None` alongside [`Self::d_var`].
142    d_full: Option<Rc<Vec<Number>>>,
143}
144
145/// Left/right diagonal pair for the natural-units back-solve; see the
146/// `conj` field doc on [`PdSensBacksolver`]. Both vectors are
147/// flat-KKT-length, in the `x‖s‖y_c‖y_d‖z_l‖z_u‖v_l‖v_u` packing.
148struct ConjPair {
149    /// `E`: multiplied into the RHS before the scaled-space solve.
150    e: Vec<Number>,
151    /// `F`: multiplied into the solution after the scaled-space solve.
152    f: Vec<Number>,
153}
154
155impl PdSensBacksolver {
156    /// The retained handles the activity classifier reads
157    /// (crate-internal; see [`crate::activity`]).
158    pub(crate) fn activity_handles(
159        &self,
160    ) -> (&IpoptDataHandle, &IpoptCqHandle, &Rc<RefCell<dyn IpoptNlp>>) {
161        (&self.data, &self.cq, &self.nlp)
162    }
163
164    /// Construct from the four handles handed in by the `on_converged`
165    /// callback. Errors if `data` has no `curr` (i.e. the algorithm
166    /// never reached an iterate — should not happen on
167    /// `SolveSucceeded`) or the NLP reports scaling data inconsistent
168    /// with the converged iterate (see [`Self::natural_units_conj`]).
169    pub fn new(
170        data: &IpoptDataHandle,
171        cq: &IpoptCqHandle,
172        nlp: &Rc<RefCell<dyn IpoptNlp>>,
173        pd: Rc<RefCell<PdFullSpaceSolver>>,
174    ) -> Result<Self, String> {
175        let curr = data
176            .borrow()
177            .curr
178            .clone()
179            .ok_or_else(|| "no current iterate at convergence".to_string())?;
180        let dims = [
181            curr.x.dim() as usize,
182            curr.s.dim() as usize,
183            curr.y_c.dim() as usize,
184            curr.y_d.dim() as usize,
185            curr.z_l.dim() as usize,
186            curr.z_u.dim() as usize,
187            curr.v_l.dim() as usize,
188            curr.v_u.dim() as usize,
189        ];
190        let (d_var, d_full) = Self::variable_factors(nlp, &dims)?;
191        let conj = Self::natural_units_conj(nlp, &dims, d_var.as_ref().map(|v| v.as_slice()))?;
192        Ok(Self {
193            pd,
194            data: Rc::clone(data),
195            cq: Rc::clone(cq),
196            nlp: Rc::clone(nlp),
197            dims,
198            template: curr,
199            conj,
200            d_var,
201            d_full,
202        })
203    }
204
205    /// Read the variable factors the solve ran under off the NLP and
206    /// project them into the algorithm's var-x space (gh#486 stage 3).
207    ///
208    /// Returns `(None, None)` when no variable scaling is active.
209    /// Errors when the reported vector does not match the NLP's own
210    /// full-x width, or when the projection does not fill the `x`
211    /// block: either would silently mis-pair a factor with a variable,
212    /// which is the whole failure mode this plumbing exists to avoid.
213    #[allow(clippy::type_complexity)]
214    fn variable_factors(
215        nlp: &Rc<RefCell<dyn IpoptNlp>>,
216        dims: &[usize; 8],
217    ) -> Result<(Option<Rc<Vec<Number>>>, Option<Rc<Vec<Number>>>), String> {
218        let nlp_ref = nlp.borrow();
219        let Some(d_full) = nlp_ref.variable_scaling() else {
220            return Ok((None, None));
221        };
222        let n_full = nlp_ref.n_full_x() as usize;
223        if d_full.len() != n_full {
224            return Err(format!(
225                "variable scaling length {} != n_full_x {}",
226                d_full.len(),
227                n_full
228            ));
229        }
230        // NaN is not a "no-op" factor and neither is zero; the wrapper
231        // refuses both at setup, so seeing one here means the vector
232        // did not come from the wrapper that ran.
233        if let Some(bad) = d_full.iter().find(|v| !v.is_finite() || **v <= 0.0) {
234            return Err(format!(
235                "variable scaling factor {bad} is not finite and positive"
236            ));
237        }
238        let mut d_var = vec![Number::NAN; dims[0]];
239        for (full, &factor) in d_full.iter().enumerate() {
240            if let Some(var) = nlp_ref.full_x_to_var_x(full as Index) {
241                let slot = d_var.get_mut(var as usize).ok_or_else(|| {
242                    format!("var-x index {var} outside x block of width {}", dims[0])
243                })?;
244                *slot = factor;
245            }
246        }
247        if let Some(pos) = d_var.iter().position(|v| v.is_nan()) {
248            return Err(format!(
249                "variable scaling left var-x column {pos} of {} unmapped",
250                dims[0]
251            ));
252        }
253        Ok((Some(Rc::new(d_var)), Some(Rc::new(d_full))))
254    }
255
256    /// The per-variable factors the held solve ran under, in the
257    /// algorithm's **var-x** space (one entry per `x`-block KKT row),
258    /// or `None` when no variable scaling was active (gh#486).
259    pub fn variable_scaling(&self) -> Option<&[Number]> {
260        self.d_var.as_deref().map(|v| v.as_slice())
261    }
262
263    /// [`Self::variable_scaling`] in the user TNLP's **full-x** space:
264    /// the shape of an `n_full_x`-length report, with the columns the
265    /// solve dropped as fixed still present.
266    pub fn variable_scaling_full(&self) -> Option<&[Number]> {
267        self.d_full.as_deref().map(|v| v.as_slice())
268    }
269
270    /// Build the natural-units scaling pair `(E, F)` from the NLP's
271    /// effective scaling and the variable factors `d_var` the solve
272    /// ran under (see the field doc on [`Self::conj`]).
273    /// Returns `Ok(None)` when no scaling is active. Errors when the
274    /// NLP reports scaling data inconsistent with the converged
275    /// iterate's block dimensions (would silently corrupt every
276    /// back-solve) or a zero/non-finite `df`.
277    fn natural_units_conj(
278        nlp: &Rc<RefCell<dyn IpoptNlp>>,
279        dims: &[usize; 8],
280        d_var: Option<&[Number]>,
281    ) -> Result<Option<Rc<ConjPair>>, String> {
282        let nlp_ref = nlp.borrow();
283        let df = nlp_ref.obj_scaling_factor();
284        let dc = nlp_ref.c_scale_vec();
285        let dd = nlp_ref.d_scale_vec();
286        // `d_var` counts as active scaling on its own: a solve with
287        // unit objective and row factors but a change of variables
288        // still holds its factor in scaled coordinates.
289        if df == 1.0 && dc.is_none() && dd.is_none() && d_var.is_none() {
290            return Ok(None);
291        }
292        // df may be negative (obj_scaling_factor < 0 means maximize);
293        // the two-sided scaling needs no square root, only df ≠ 0.
294        if !df.is_finite() || df == 0.0 {
295            return Err(format!("invalid obj_scaling_factor {df}"));
296        }
297        if let Some(v) = &dc {
298            if v.len() != dims[2] {
299                return Err(format!("c_scale length {} != y_c dim {}", v.len(), dims[2]));
300            }
301        }
302        if let Some(v) = &dd {
303            if v.len() != dims[3] || dims[1] != dims[3] {
304                return Err(format!(
305                    "d_scale length {} != y_d dim {} (s dim {})",
306                    v.len(),
307                    dims[3],
308                    dims[1]
309                ));
310            }
311        }
312        if let Some(d) = d_var {
313            if d.len() != dims[0] {
314                return Err(format!(
315                    "variable scaling length {} != x dim {}",
316                    d.len(),
317                    dims[0]
318                ));
319            }
320        }
321        // Per-entry source scale for a compressed bound-multiplier
322        // block: entry j of z_l / v_l covers the row
323        // `px_l.expanded_pos[j]` / `pd_l.expanded_pos[j]` of `src`.
324        // Used for the v blocks (source `d_scale`, indexed by
325        // inequality row) and the z blocks (source `d_var`, indexed by
326        // var-x column).
327        let bound_row_scale = |pm: Rc<dyn pounce_linalg::matrix::Matrix>,
328                               src: Option<&[Number]>,
329                               n_v: usize,
330                               which: &str|
331         -> Result<Vec<Number>, String> {
332            let Some(vals) = src else {
333                return Ok(vec![1.0; n_v]);
334            };
335            if n_v == 0 {
336                return Ok(Vec::new());
337            }
338            let Some(em) = pm
339                .as_any()
340                .downcast_ref::<pounce_linalg::expansion_matrix::ExpansionMatrix>()
341            else {
342                return Err(format!("{which} is not an ExpansionMatrix"));
343            };
344            let pos = em.expanded_pos_indices();
345            if pos.len() != n_v {
346                return Err(format!(
347                    "{which} expansion length {} != {} block dim {}",
348                    pos.len(),
349                    which,
350                    n_v
351                ));
352            }
353            pos.iter()
354                .map(|&r| {
355                    vals.get(r as usize).copied().ok_or_else(|| {
356                        format!(
357                            "{which} expansion row {r} out of scale-vector range {}",
358                            vals.len()
359                        )
360                    })
361                })
362                .collect()
363        };
364        let vl_dd = bound_row_scale(nlp_ref.pd_l(), dd.as_deref(), dims[6], "pd_l")?;
365        let vu_dd = bound_row_scale(nlp_ref.pd_u(), dd.as_deref(), dims[7], "pd_u")?;
366        // The variable factor carried by each finite x-bound, through
367        // the same expansion (gh#486). `d_var` indexes var-x columns,
368        // and `px_l` / `px_u` say which column each z entry belongs to.
369        let zl_dx = bound_row_scale(nlp_ref.px_l(), d_var, dims[4], "px_l")?;
370        let zu_dx = bound_row_scale(nlp_ref.px_u(), d_var, dims[5], "px_u")?;
371        drop(nlp_ref);
372
373        let total: usize = dims.iter().sum();
374        let mut e = Vec::with_capacity(total);
375        let mut f = Vec::with_capacity(total);
376        // x block: E = df/d_i, F = 1/d_i. `df` is the objective scale;
377        // the `1/d_i` on both sides is the change of variables —
378        // `∇f̃ = ∇f ⊘ d` puts the RHS in scaled units and `x = x̃ ⊘ d`
379        // brings the solution back.
380        match d_var {
381            Some(d) => {
382                e.extend(d.iter().map(|&di| df / di));
383                f.extend(d.iter().map(|&di| 1.0 / di));
384            }
385            None => {
386                e.extend(std::iter::repeat_n(df, dims[0]));
387                f.extend(std::iter::repeat_n(1.0, dims[0]));
388            }
389        }
390        // s block: E = df/dd_i, F = 1/dd_i (slacks live in scaled d-space).
391        match &dd {
392            Some(v) => {
393                e.extend(v.iter().map(|&ddi| df / ddi));
394                f.extend(v.iter().map(|&ddi| 1.0 / ddi));
395            }
396            None => {
397                e.extend(std::iter::repeat_n(df, dims[1]));
398                f.extend(std::iter::repeat_n(1.0, dims[1]));
399            }
400        }
401        // y_c block: E = dc_i, F = dc_i/df.
402        match &dc {
403            Some(v) => {
404                e.extend(v.iter().copied());
405                f.extend(v.iter().map(|&dci| dci / df));
406            }
407            None => {
408                e.extend(std::iter::repeat_n(1.0, dims[2]));
409                f.extend(std::iter::repeat_n(1.0 / df, dims[2]));
410            }
411        }
412        // y_d block: E = dd_i, F = dd_i/df.
413        match &dd {
414            Some(v) => {
415                e.extend(v.iter().copied());
416                f.extend(v.iter().map(|&ddi| ddi / df));
417            }
418            None => {
419                e.extend(std::iter::repeat_n(1.0, dims[3]));
420                f.extend(std::iter::repeat_n(1.0 / df, dims[3]));
421            }
422        }
423        // z_l / z_u blocks: E = df, F = d_{px(j)}/df (z̃ = (df/d)·z,
424        // and the slack diagonal x̃ − x̃_L = d·(x − x_L) carries the
425        // variable factor, so the two cancel in the row and leave it
426        // identical to the natural one — E takes no `d` at all).
427        // Without variable scaling this is the pre-#486 `F = 1/df`:
428        // bounds on x are unscaled and the slack diagonal is shared.
429        e.extend(std::iter::repeat_n(df, dims[4] + dims[5]));
430        f.extend(zl_dx.iter().map(|&dx| dx / df));
431        f.extend(zu_dx.iter().map(|&dx| dx / df));
432        // v_l / v_u blocks: E = df, F = dd_r/df (ṽ = (df/dd)·v and the
433        // slack diagonal s̃ − d̃_l = dd·(s − d_l) carries the d-row
434        // scale).
435        e.extend(std::iter::repeat_n(df, dims[6] + dims[7]));
436        f.extend(vl_dd.iter().map(|&ddr| ddr / df));
437        f.extend(vu_dd.iter().map(|&ddr| ddr / df));
438        Ok(Some(Rc::new(ConjPair { e, f })))
439    }
440
441    /// Effective objective scaling factor `df` of the converged NLP
442    /// (1.0 when no scaling is active).
443    pub fn obj_scaling_factor(&self) -> Number {
444        self.nlp.borrow().obj_scaling_factor()
445    }
446
447    /// Effective NLP scaling at convergence:
448    /// `(obj_scaling_factor, c_scale, d_scale)`. The vectors are
449    /// `None` when the corresponding block carries no row scaling.
450    pub fn nlp_scaling(&self) -> (Number, Option<Vec<Number>>, Option<Vec<Number>>) {
451        let n = self.nlp.borrow();
452        (n.obj_scaling_factor(), n.c_scale_vec(), n.d_scale_vec())
453    }
454
455    /// Inertia-correction perturbations `(δ_x, δ_s, δ_c, δ_d)` baked
456    /// into the held KKT factor (the IPM's `current_perturbation`
457    /// state at convergence). All zero ⇔ the final factorization was
458    /// unregularized and the natural-units back-solves invert the
459    /// exact KKT matrix. Nonzero ⇔ the factor carries a (scaled-space)
460    /// regularization, so sensitivity outputs — covariance in
461    /// particular — are perturbed and no longer exactly
462    /// scaling-invariant; consumers should check this before trusting
463    /// `-inv(reduced_hessian)` on ill-conditioned problems
464    /// (pounce#128 follow-up).
465    pub fn kkt_perturbations(&self) -> [Number; 4] {
466        let p = self.data.borrow().perturbations;
467        [p.delta_x, p.delta_s, p.delta_c, p.delta_d]
468    }
469
470    /// Map user-facing 0-based `g(x)` indices of parameter-pin
471    /// equality constraints to flat KKT rows **and** the pin rows'
472    /// `dc_i` scaling factors, in one pass. The KKT row of pin `i` is
473    /// `n_x + n_s + c_block_idx`, i.e. the matching `y_c` slot, found
474    /// through `IpoptNlp::full_g_to_c_block` so the c/d split's row
475    /// permutation is honored (pounce#128 follow-up: the previous
476    /// direct `n_x + n_s + g_idx` mapping silently picked wrong rows
477    /// when inequalities preceded the pins). The scales are 1.0 when
478    /// no constraint scaling is active; they relate the natural and
479    /// solver-space reduced Hessians via
480    /// `H̃_ij = (df / (dc_i·dc_j)) · H_ij`. Errors when a pin index
481    /// is out of range or refers to an inequality row.
482    pub fn pin_rows_and_c_scales(
483        &self,
484        pin_g_indices: &[Index],
485    ) -> Result<(Vec<Index>, Vec<Number>), String> {
486        let y_c_offset = (self.dims[0] + self.dims[1]) as Index;
487        let nlp = self.nlp.borrow();
488        let dc = nlp.c_scale_vec();
489        let n_full_g = nlp.n_full_g();
490        let mut rows = Vec::with_capacity(pin_g_indices.len());
491        let mut scales = Vec::with_capacity(pin_g_indices.len());
492        for &gi in pin_g_indices {
493            // n_full_g() defaults to 0 for IpoptNlp impls that don't
494            // report it; only range-check when it's meaningful.
495            if gi < 0 || (n_full_g > 0 && gi >= n_full_g) {
496                return Err(format!(
497                    "pin constraint index {gi} out of range [0, m={n_full_g})"
498                ));
499            }
500            let Some(ci) = nlp.full_g_to_c_block(gi) else {
501                return Err(format!(
502                    "pin constraint index {gi} is an inequality (not an equality row); \
503                     parameter pins must be exact equalities"
504                ));
505            };
506            rows.push(y_c_offset + ci);
507            scales.push(dc.as_ref().map(|v| v[ci as usize]).unwrap_or(1.0));
508        }
509        Ok((rows, scales))
510    }
511
512    /// KKT-row half of [`Self::pin_rows_and_c_scales`].
513    pub fn map_pin_g_to_kkt_rows(&self, pin_g_indices: &[Index]) -> Result<Vec<Index>, String> {
514        Ok(self.pin_rows_and_c_scales(pin_g_indices)?.0)
515    }
516
517    /// Scaling half of [`Self::pin_rows_and_c_scales`].
518    pub fn pin_c_scales(&self, pin_g_indices: &[Index]) -> Result<Vec<Number>, String> {
519        Ok(self.pin_rows_and_c_scales(pin_g_indices)?.1)
520    }
521
522    /// Block dimensions of the compound KKT vector at convergence, in
523    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order. Sum equals
524    /// [`SensBacksolver::dim`]. Useful when a caller needs to compute
525    /// the flat offset of a non-x block (e.g. `n_x + n_s` for the
526    /// start of the equality-multiplier `y_c` block).
527    pub fn block_dims(&self) -> [usize; 8] {
528        self.dims
529    }
530
531    /// Map a 0-based **full-g** index (user-TNLP `g(x)` order) to its
532    /// 0-based position in the equality-multiplier `y_c` block, or
533    /// `None` when the constraint is an inequality (it lives in the `d`
534    /// block, not `y_c`). Delegates to the held NLP's c/d-split map.
535    ///
536    /// Pin-row construction must route through this: the flat KKT row of
537    /// a pinned equality is `n_x + n_s + full_g_to_c_block(g)`, NOT
538    /// `n_x + n_s + g` — those differ whenever any inequality precedes
539    /// the pinned equality in `g(x)`.
540    pub fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
541        self.nlp.borrow().full_g_to_c_block(full_idx)
542    }
543
544    /// Map a 0-based **full-x** index (user-TNLP variable order) to its
545    /// 0-based position in the algorithm-side `x` block, or `None` when
546    /// the solve removed the column because `x_l == x_u` under
547    /// `fixed_variable_treatment = make_parameter`. Delegates to the
548    /// held NLP's fixed-variable map.
549    ///
550    /// The `x` counterpart of [`Self::full_g_to_c_block`], and it must
551    /// be routed through for the same reason: the flat KKT row of a
552    /// user variable is `full_x_to_var_x(i)`, NOT `i` — those differ
553    /// whenever any fixed variable precedes it in the user's `x`.
554    /// Reports and iterates are in full-x, the factor is in var-x, and
555    /// nothing about the two spaces is distinguishable by length alone
556    /// on a model that happens to have no fixed variables.
557    pub fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
558        self.nlp.borrow().full_x_to_var_x(full_idx)
559    }
560
561    /// The user TNLP's variable count, the domain of
562    /// [`Self::full_x_to_var_x`]. Distinct from the `x` block width
563    /// whenever the solve removed a fixed variable.
564    pub fn n_full_x(&self) -> Index {
565        self.nlp.borrow().n_full_x()
566    }
567
568    /// Cumulative block offsets: `offset(i)` is the start index of
569    /// block `i` in the flat slice.
570    fn offsets(&self) -> [usize; 9] {
571        let mut o = [0usize; 9];
572        for i in 0..8 {
573            o[i + 1] = o[i] + self.dims[i];
574        }
575        o
576    }
577
578    /// Pack a flat slice into a freshly-allocated `IteratesVectorMut`
579    /// shaped like the converged iterate.
580    fn pack(&self, flat: &[Number]) -> Result<IteratesVectorMut, ()> {
581        let mut out = self.template.make_new_zeroed();
582        let off = self.offsets();
583        let blocks: [&mut Box<dyn pounce_linalg::vector::Vector>; 8] = [
584            &mut out.x,
585            &mut out.s,
586            &mut out.y_c,
587            &mut out.y_d,
588            &mut out.z_l,
589            &mut out.z_u,
590            &mut out.v_l,
591            &mut out.v_u,
592        ];
593        for (i, blk) in blocks.into_iter().enumerate() {
594            let slice = &flat[off[i]..off[i + 1]];
595            let dv = blk.as_any_mut().downcast_mut::<DenseVector>().ok_or(())?;
596            dv.set_values(slice);
597        }
598        Ok(out)
599    }
600
601    /// Read an `IteratesVectorMut` into a flat slice. Uses
602    /// [`DenseVector::expanded_values`] rather than `values()` so
603    /// blocks that the IPM left in homogeneous-scalar form (typical
604    /// for empty z_l/z_u/v_l/v_u when the TNLP has no bounds) are
605    /// materialized rather than panicking.
606    fn unpack(&self, iv: &IteratesVectorMut, out: &mut [Number]) -> Result<(), ()> {
607        let off = self.offsets();
608        let blocks: [&Box<dyn pounce_linalg::vector::Vector>; 8] = [
609            &iv.x, &iv.s, &iv.y_c, &iv.y_d, &iv.z_l, &iv.z_u, &iv.v_l, &iv.v_u,
610        ];
611        for (i, blk) in blocks.into_iter().enumerate() {
612            let dst = &mut out[off[i]..off[i + 1]];
613            if dst.is_empty() {
614                continue;
615            }
616            let dv = (**blk).as_any().downcast_ref::<DenseVector>().ok_or(())?;
617            let ev = dv.expanded_values();
618            dst.copy_from_slice(&ev);
619        }
620        Ok(())
621    }
622}
623
624impl PdSensBacksolver {
625    /// Batched-RHS back-solve over the held factor. `rhs_flat` and
626    /// `lhs_flat` are row-major `(n_rhs, dim)` buffers. Equivalent to
627    /// looping [`SensBacksolver::solve`] over each row but reuses one
628    /// frozen `IteratesVector` for the RHS and one `IteratesVectorMut`
629    /// for the result across all `n_rhs` calls into
630    /// [`PdFullSpaceSolver::solve`]. The pack step writes into the
631    /// existing `DenseVector` storage via `Rc::get_mut` +
632    /// `set_values`, and the unpack step reads it back via `values()`
633    /// /`scalar()` — skipping the per-call 8-block `make_new_zeroed`
634    /// (Box alloc) in `pack` and the per-block `expanded_values()` Vec
635    /// alloc in `unpack` that otherwise dominate the held-factor
636    /// back-solve cost under `jax.jacrev` over a JaxProblem solve
637    /// (pounce#77 follow-up).
638    ///
639    /// The matrix and perturbation state inside `PdFullSpaceSolver`
640    /// are unchanged across calls, so each iteration hits the cached
641    /// fast path in `solve_once` (`uptodate && !pretend_singular`).
642    ///
643    /// Like [`SensBacksolver::solve`], results are in **natural
644    /// (unscaled) units** — see [`Self::solve_many_scaled_space`] for
645    /// the raw solver-space back-solve.
646    pub fn solve_many(&self, rhs_flat: &[Number], lhs_flat: &mut [Number], n_rhs: usize) -> bool {
647        match &self.conj {
648            None => self.solve_many_scaled_space(rhs_flat, lhs_flat, n_rhs),
649            Some(c) => {
650                let total = self.dim();
651                if rhs_flat.len() != n_rhs * total || lhs_flat.len() != n_rhs * total {
652                    return false;
653                }
654                let mut rhs_scaled = rhs_flat.to_vec();
655                for row in rhs_scaled.chunks_mut(total) {
656                    for (r, &ei) in row.iter_mut().zip(c.e.iter()) {
657                        *r *= ei;
658                    }
659                }
660                if !self.solve_many_scaled_space(&rhs_scaled, lhs_flat, n_rhs) {
661                    return false;
662                }
663                for row in lhs_flat.chunks_mut(total) {
664                    for (l, &fi) in row.iter_mut().zip(c.f.iter()) {
665                        *l *= fi;
666                    }
667                }
668                true
669            }
670        }
671    }
672
673    /// Batched-RHS back-solve against the held factor in the solver's
674    /// internal **scaled** space (no natural-units conjugation). Same
675    /// buffer contract as [`Self::solve_many`].
676    pub fn solve_many_scaled_space(
677        &self,
678        rhs_flat: &[Number],
679        lhs_flat: &mut [Number],
680        n_rhs: usize,
681    ) -> bool {
682        let total = self.dim();
683        if rhs_flat.len() != n_rhs * total || lhs_flat.len() != n_rhs * total {
684            return false;
685        }
686        if n_rhs == 0 {
687            return true;
688        }
689        let off = self.offsets();
690
691        // Tier 1: fully-inline flat-slice path. `PdFullSpaceSolver::
692        // solve_many_cached_flat` downcasts the slack / z / v vectors to
693        // `DenseVector` and the bound-expansion matrices to
694        // `ExpansionMatrix` once at the top, then runs Phase 1 / Phase 3
695        // as raw scatter-add / divide loops on flat slices with no dyn
696        // dispatch in the per-RHS inner loops. Returns `None` if a
697        // downcast fails (homogeneous-on-non-empty block, unusual matrix
698        // type) — we fall to Tier 2.
699        {
700            let mut pd_ref = self.pd.borrow_mut();
701            let fast_flat = pd_ref.solve_many_cached_flat(
702                &self.data, &self.cq, &self.nlp, n_rhs, rhs_flat, lhs_flat, self.dims,
703            );
704            match fast_flat {
705                Some(true) => return true,
706                Some(false) => return false,
707                None => { /* fall through to Tier 2 */ }
708            }
709        }
710
711        // Tier 2: closure-based cached-factor path. Same single
712        // back-substitution through the linsol, but Phase 1 / Phase 3
713        // go through `dyn Vector` / `dyn Matrix` ops on a per-RHS
714        // `IteratesVectorMut`. Slower than Tier 1 but correct for
715        // homogeneous DenseVectors and non-`ExpansionMatrix` bound
716        // expansions.
717        {
718            let mut pd_ref = self.pd.borrow_mut();
719            let fast = pd_ref.solve_many_cached(
720                &self.data,
721                &self.cq,
722                &self.nlp,
723                n_rhs,
724                |k, iv| {
725                    let row = &rhs_flat[k * total..(k + 1) * total];
726                    let _ = write_rhs_box(&mut iv.x, &row[off[0]..off[1]])
727                        && write_rhs_box(&mut iv.s, &row[off[1]..off[2]])
728                        && write_rhs_box(&mut iv.y_c, &row[off[2]..off[3]])
729                        && write_rhs_box(&mut iv.y_d, &row[off[3]..off[4]])
730                        && write_rhs_box(&mut iv.z_l, &row[off[4]..off[5]])
731                        && write_rhs_box(&mut iv.z_u, &row[off[5]..off[6]])
732                        && write_rhs_box(&mut iv.v_l, &row[off[6]..off[7]])
733                        && write_rhs_box(&mut iv.v_u, &row[off[7]..off[8]]);
734                },
735                |k, iv| {
736                    let row = &mut lhs_flat[k * total..(k + 1) * total];
737                    let _ = read_res_block(&*iv.x, &mut row[off[0]..off[1]])
738                        && read_res_block(&*iv.s, &mut row[off[1]..off[2]])
739                        && read_res_block(&*iv.y_c, &mut row[off[2]..off[3]])
740                        && read_res_block(&*iv.y_d, &mut row[off[3]..off[4]])
741                        && read_res_block(&*iv.z_l, &mut row[off[4]..off[5]])
742                        && read_res_block(&*iv.z_u, &mut row[off[5]..off[6]])
743                        && read_res_block(&*iv.v_l, &mut row[off[6]..off[7]])
744                        && read_res_block(&*iv.v_u, &mut row[off[7]..off[8]]);
745                },
746            );
747            match fast {
748                Some(true) => return true,
749                Some(false) => return false,
750                None => { /* fall through to per-RHS loop */ }
751            }
752        }
753
754        // Per-RHS fallback: reuse one frozen rhs and one mut sol across
755        // all n_rhs `solve` calls.
756        let rhs_mut0 = self.template.make_new_zeroed();
757        let mut rhs_iv = rhs_mut0.freeze();
758        let mut res_iv = self.template.make_new_zeroed();
759
760        let mut pd_ref = self.pd.borrow_mut();
761        for k in 0..n_rhs {
762            let rhs_row = &rhs_flat[k * total..(k + 1) * total];
763            let lhs_row = &mut lhs_flat[k * total..(k + 1) * total];
764
765            if !write_rhs_block(&mut rhs_iv.x, &rhs_row[off[0]..off[1]])
766                || !write_rhs_block(&mut rhs_iv.s, &rhs_row[off[1]..off[2]])
767                || !write_rhs_block(&mut rhs_iv.y_c, &rhs_row[off[2]..off[3]])
768                || !write_rhs_block(&mut rhs_iv.y_d, &rhs_row[off[3]..off[4]])
769                || !write_rhs_block(&mut rhs_iv.z_l, &rhs_row[off[4]..off[5]])
770                || !write_rhs_block(&mut rhs_iv.z_u, &rhs_row[off[5]..off[6]])
771                || !write_rhs_block(&mut rhs_iv.v_l, &rhs_row[off[6]..off[7]])
772                || !write_rhs_block(&mut rhs_iv.v_u, &rhs_row[off[7]..off[8]])
773            {
774                return false;
775            }
776
777            let ok = pd_ref.solve(
778                &self.data,
779                &self.cq,
780                &self.nlp,
781                1.0,
782                0.0,
783                &rhs_iv,
784                &mut res_iv,
785                /* allow_inexact = */ true,
786                /* improve_solution = */ false,
787            );
788            if !ok {
789                return false;
790            }
791
792            if !read_res_block(&*res_iv.x, &mut lhs_row[off[0]..off[1]])
793                || !read_res_block(&*res_iv.s, &mut lhs_row[off[1]..off[2]])
794                || !read_res_block(&*res_iv.y_c, &mut lhs_row[off[2]..off[3]])
795                || !read_res_block(&*res_iv.y_d, &mut lhs_row[off[3]..off[4]])
796                || !read_res_block(&*res_iv.z_l, &mut lhs_row[off[4]..off[5]])
797                || !read_res_block(&*res_iv.z_u, &mut lhs_row[off[5]..off[6]])
798                || !read_res_block(&*res_iv.v_l, &mut lhs_row[off[6]..off[7]])
799                || !read_res_block(&*res_iv.v_u, &mut lhs_row[off[7]..off[8]])
800            {
801                return false;
802            }
803        }
804        true
805    }
806}
807
808/// Write `slice` into the `DenseVector` behind `b` in place. Used by
809/// the fast path's `write_rhs` closure, where the new
810/// `PdFullSpaceSolver::solve_many_cached` API hands back an
811/// `IteratesVectorMut` (Box-backed blocks).
812fn write_rhs_box(b: &mut Box<dyn pounce_linalg::vector::Vector>, slice: &[Number]) -> bool {
813    if slice.is_empty() {
814        return true;
815    }
816    let Some(dv) = b.as_any_mut().downcast_mut::<DenseVector>() else {
817        return false;
818    };
819    dv.set_values(slice);
820    true
821}
822
823/// Write `slice` into the `DenseVector` behind `rc` in place. Returns
824/// `false` if the Rc is unexpectedly shared (would indicate a bug in
825/// `PdFullSpaceSolver::solve`'s borrow discipline — it should never
826/// `Rc::clone` from the rhs vector) or if the block is not a
827/// `DenseVector`.
828fn write_rhs_block(rc: &mut Rc<dyn pounce_linalg::vector::Vector>, slice: &[Number]) -> bool {
829    if slice.is_empty() {
830        return true;
831    }
832    let Some(v) = Rc::get_mut(rc) else {
833        return false;
834    };
835    let Some(dv) = v.as_any_mut().downcast_mut::<DenseVector>() else {
836        return false;
837    };
838    dv.set_values(slice);
839    true
840}
841
842/// Read the `DenseVector` behind `blk` into `dst`. Handles the
843/// homogeneous case (empty z/v blocks for a TNLP with no bounds) by
844/// broadcasting the scalar rather than calling `expanded_values()`,
845/// which would allocate a fresh `Vec<Number>` every call.
846fn read_res_block(blk: &dyn pounce_linalg::vector::Vector, dst: &mut [Number]) -> bool {
847    if dst.is_empty() {
848        return true;
849    }
850    let Some(dv) = blk.as_any().downcast_ref::<DenseVector>() else {
851        return false;
852    };
853    if dv.is_homogeneous() {
854        let s = dv.scalar();
855        for x in dst.iter_mut() {
856            *x = s;
857        }
858    } else {
859        dst.copy_from_slice(dv.values());
860    }
861    true
862}
863
864impl PdSensBacksolver {
865    /// Single-RHS back-solve against the held factor in the solver's
866    /// internal **scaled** space (no natural-units conjugation). This
867    /// is the value [`SensBacksolver::solve`] returned before
868    /// pounce#128; kept for callers that want the raw factor.
869    pub fn solve_scaled_space(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
870        let total = self.dim();
871        if rhs.len() != total || lhs.len() != total {
872            return false;
873        }
874        // Pack rhs into block form.
875        let rhs_mut = match self.pack(rhs) {
876            Ok(v) => v,
877            Err(()) => return false,
878        };
879        let rhs_iv = rhs_mut.freeze();
880        // Fresh result slot, zeroed.
881        let mut res_iv = self.template.make_new_zeroed();
882
883        // K · lhs = rhs   ⇒   solve(α=1, β=0, rhs, res) writes
884        // res = K⁻¹ · rhs.
885        //
886        // `allow_inexact=true` mirrors upstream sIPOPT's
887        // `SensSimpleBacksolver`: skip `PdFullSpaceSolver`'s iterative-
888        // refinement loop and accept the first back-solve against the
889        // held factor. The IPM-level refinement (`min_refinement_steps
890        // = 1`, residual_ratio_max = 1e-10`) is there to clean up
891        // numerical noise during forward IPM steps; for the held-factor
892        // back-solve used by sens / JaxProblem bwd, it ~doubles the
893        // per-call cost and produces gains that are below `tol`. Under
894        // `jax.jacrev` over a JaxProblem solve this dominates the wall
895        // time at moderate `n+m` (pounce#77 follow-up).
896        let ok = {
897            let mut pd_ref = self.pd.borrow_mut();
898            pd_ref.solve(
899                &self.data,
900                &self.cq,
901                &self.nlp,
902                1.0,
903                0.0,
904                &rhs_iv,
905                &mut res_iv,
906                /* allow_inexact = */ true,
907                /* improve_solution = */ false,
908            )
909        };
910        if !ok {
911            return false;
912        }
913        self.unpack(&res_iv, lhs).is_ok()
914    }
915}
916
917impl SensBacksolver for PdSensBacksolver {
918    fn dim(&self) -> usize {
919        self.dims.iter().sum()
920    }
921
922    /// Solve `K · lhs = rhs` against the converged factor, in
923    /// **natural (unscaled) units** (pounce#128): when the NLP carries
924    /// active scaling (`nlp_scaling_method`, `obj_scaling_factor`,
925    /// user scaling) the RHS is pre-multiplied by `E` and the result
926    /// post-multiplied by `F` (see the `conj` field doc), so
927    /// `lhs = K_natural⁻¹ rhs` for **all eight blocks** — including
928    /// the z/v bound-multiplier rows. Use
929    /// [`Self::solve_scaled_space`] for the raw factor.
930    fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
931        match &self.conj {
932            None => self.solve_scaled_space(rhs, lhs),
933            Some(c) => {
934                let total = self.dim();
935                if rhs.len() != total || lhs.len() != total {
936                    return false;
937                }
938                let rhs_scaled: Vec<Number> =
939                    rhs.iter().zip(c.e.iter()).map(|(&r, &ei)| r * ei).collect();
940                if !self.solve_scaled_space(&rhs_scaled, lhs) {
941                    return false;
942                }
943                for (l, &fi) in lhs.iter_mut().zip(c.f.iter()) {
944                    *l *= fi;
945                }
946                true
947            }
948        }
949    }
950}