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, SigmaOverride};
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    /// Var-x row of the variable each bound multiplier constrains,
144    /// `z_l` entries then `z_u` entries, read off the `px_l` / `px_u`
145    /// expansions. `None` when either expansion is not an
146    /// `ExpansionMatrix` and the map cannot be recovered.
147    bound_vars: Option<Rc<Vec<crate::backsolver::BoundRow>>>,
148    /// The barrier geometry re-measured against the bounds the model
149    /// declares, for a held iterate that came from crossover. `None` —
150    /// meaning "read the calculated quantities as they stand" — on
151    /// every solve that ended on an interior point. See
152    /// [`DeclaredFrameBarrier`].
153    declared: Option<Rc<DeclaredFrameBarrier>>,
154    /// The barrier diagonals every sensitivity solve actually factors
155    /// with: [`Self::declared`]'s pair when there is one, the
156    /// calculated quantities otherwise, with [`sigma_pin_caps`]
157    /// applied to both (gh#737). See [`EffectiveSigma`].
158    sigma: EffectiveSigma,
159}
160
161/// Which of the four bound-multiplier blocks a compound KKT row falls
162/// in. Produced by [`PdSensBacksolver::bound_block_of`], which is the
163/// only place the block offsets are computed.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165enum BoundBlock {
166    /// `z_l`: lower bound on a variable.
167    ZL,
168    /// `z_u`: upper bound on a variable.
169    ZU,
170    /// `v_l`: lower limit on an inequality row, carried by its slack.
171    VL,
172    /// `v_u`: upper limit on an inequality row, likewise.
173    VU,
174}
175
176/// The barrier diagonals the sensitivity path factors with, after both
177/// corrections that stand between the calculated quantities and the
178/// matrix: gh#654's choice of *frame*, and gh#737's ceiling on how
179/// stiff a pin the frame is allowed to report.
180///
181/// `None` in a block means "the calculated quantity as it stands" —
182/// no crossover frame to substitute and nothing over the ceiling — so
183/// an ordinary solve still factors against the cached `Σ` object and
184/// keeps the tag-keyed factorization cache warm.
185#[derive(Clone)]
186struct EffectiveSigma {
187    /// `x`-block diagonal, or `None` for `cq.curr_sigma_x()`.
188    x: Option<Rc<dyn pounce_linalg::Vector>>,
189    /// `s`-block diagonal, or `None` for `cq.curr_sigma_s()`.
190    s: Option<Rc<dyn pounce_linalg::Vector>>,
191    /// The gh#737 ceiling per var-x row, `INFINITY` where none applies.
192    /// Kept because a *released* solve rebuilds one variable's `Σ`
193    /// entry from the bounds that stay active, and a rebuilt entry has
194    /// to land under the same ceiling the rest of the diagonal did.
195    cap_x: Rc<Vec<Number>>,
196    /// `Σ_used / Σ_base` per var-x row — how much of each variable's
197    /// barrier stiffness survived the ceiling. `1.0` wherever the
198    /// ceiling did not bind, which is every entry of an uncapped
199    /// solve. Read by
200    /// [`PdSensBacksolver::rescale_bound_multipliers`] (gh#828); see
201    /// there for why the multiplier block has to know.
202    ratio_x: Rc<Vec<Number>>,
203    /// [`Self::ratio_x`] for the `s` block.
204    ratio_s: Rc<Vec<Number>>,
205}
206
207/// The operator a correction factors: both barrier diagonals built at
208/// the predicted point, plus how much of each entry survived the
209/// gh#737 ceiling.
210///
211/// The two ratios travel with the diagonals because they answer the
212/// same question about the same operator — a solve that eliminates a
213/// bound row into a *capped* diagonal has to read that row back
214/// through the same cap, and separating the two is exactly the gh#828
215/// defect. See [`PdSensBacksolver::rescale_bound_multipliers`].
216pub(crate) struct CorrectorOperator {
217    /// `x`-block diagonal at the predicted point.
218    pub(crate) sigma_x: Rc<dyn pounce_linalg::Vector>,
219    /// `s`-block diagonal at the predicted point.
220    pub(crate) sigma_s: Rc<dyn pounce_linalg::Vector>,
221    /// Per var-x row, the fraction of the uncapped diagonal the
222    /// ceiling left standing; `1.0` where it did not bind.
223    pub(crate) ratio_x: Vec<Number>,
224    /// [`Self::ratio_x`] for the `s` block.
225    pub(crate) ratio_s: Vec<Number>,
226}
227
228/// `Σ` and the active-bound slacks of a **crossed-over** iterate,
229/// measured against the bounds the user declared rather than the ones
230/// the barrier ran against (gh#654).
231///
232/// `bound_relax_factor` (default `1e-8`) widens every bound by `δ`
233/// before the solve, and crossover (gh#612) then parks the iterate
234/// exactly on the *declared* bound — a full `δ` inside the live relaxed
235/// one. So the calculated quantities report a slack of exactly `δ` at
236/// every active bound, where an interior iterate would have carried
237/// `μ/z`, and the barrier diagonal `Σ = z/s` comes out as `z/δ` instead
238/// of `z²/μ`. Since `δ` is capped at `constr_viol_tol` and `μ` ends near
239/// `tol/(barrier_tol_factor+1)`, that is *looser* whenever `z·δ/μ > 1`,
240/// which is the ordinary case.
241///
242/// `Σ` is the stiffness with which the barrier holds a bounded variable,
243/// and a reduced Hessian read off the held factor carries a residual
244/// error of exactly `O(1/Σ)` — the leftover of that pin being finite. So
245/// the looser reading is a measurably less accurate covariance: on the
246/// gh#654 fixture, `18x` at a bound multiplier of `4.5` and `396x` at
247/// `994.5`, tracking `z·δ/μ` exactly.
248///
249/// The correction is the same one gh#646 applied to the reported
250/// residuals: measure the crossed-over point in the frame it was solved
251/// in. Nothing on the live iterate is touched — the relaxed bounds are
252/// still what the algorithm ran against, and un-relaxing them after the
253/// fact would mean replacing the NLP's bound `Rc`s and invalidating
254/// every tag-keyed cache built on them. This is the consumer boundary
255/// instead, where the question "how stiffly is this point held" is
256/// actually being asked.
257struct DeclaredFrameBarrier {
258    /// Variable-bound contribution to the `x` diagonal.
259    sigma_x: Rc<dyn pounce_linalg::Vector>,
260    /// Inequality-row-bound contribution to the `s` diagonal. Relaxation
261    /// widens `d_L` / `d_U` too, and crossover puts `s = d(x)` on the
262    /// declared row bounds, so the row half of the defect is identical
263    /// to the variable half.
264    sigma_s: Rc<dyn pounce_linalg::Vector>,
265    /// The declared-frame `x` slacks behind `sigma_x`, compressed in the
266    /// `px_l` / `px_u` spaces. Kept because a *released* solve rebuilds
267    /// one variable's `Σ` entry from the sides that stay active and has
268    /// to rebuild it in the same frame.
269    slack_x_l: Vec<Number>,
270    slack_x_u: Vec<Number>,
271    /// The same two, for the `s` block and the `pd_l` / `pd_u` spaces.
272    /// A released **constraint** limit rebuilds its slack's `Σ` entry
273    /// exactly as a released variable bound does, and a rebuild that
274    /// reached for the live relaxed slacks while the diagonal it was
275    /// patching came from the declared frame would mix the two frames
276    /// in one vector -- the gh#654 defect, one block over.
277    slack_s_l: Vec<Number>,
278    slack_s_u: Vec<Number>,
279}
280
281/// Left/right diagonal pair for the natural-units back-solve; see the
282/// `conj` field doc on [`PdSensBacksolver`]. Both vectors are
283/// flat-KKT-length, in the `x‖s‖y_c‖y_d‖z_l‖z_u‖v_l‖v_u` packing.
284struct ConjPair {
285    /// `E`: multiplied into the RHS before the scaled-space solve.
286    e: Vec<Number>,
287    /// `F`: multiplied into the solution after the scaled-space solve.
288    f: Vec<Number>,
289}
290
291impl PdSensBacksolver {
292    /// The retained handles the activity classifier reads
293    /// (crate-internal; see [`crate::activity`]).
294    pub(crate) fn activity_handles(
295        &self,
296    ) -> (&IpoptDataHandle, &IpoptCqHandle, &Rc<RefCell<dyn IpoptNlp>>) {
297        (&self.data, &self.cq, &self.nlp)
298    }
299
300    /// The barrier level the **reported point** sits on.
301    ///
302    /// Normally that is `IpoptData::curr_mu`: the solve stops on the
303    /// `mu = 0` error with `mu` already driven to the floor, so the
304    /// driver's last barrier parameter still describes the iterate it
305    /// stopped at, and every complementarity product is within
306    /// tolerance of it.
307    ///
308    /// It stops describing the iterate when a terminating path
309    /// installs multipliers of its own. `ComputeFeasibilityMultipliers`
310    /// (`IpIpoptAlg.cpp:893`, ported in gh#508) is the one that bites:
311    /// on a square NLP -- `dim(x) == dim(y_c)`, so the objective is
312    /// decorative and the answer is just the feasible point -- it zeroes
313    /// all four bound-multiplier blocks, solves for the feasibility
314    /// multipliers, and converges the check outright. A square problem
315    /// can therefore be *reported solved at `mu = mu_init`* with every
316    /// complementarity product identically zero, on iteration 1, having
317    /// never reduced the barrier at all.
318    ///
319    /// Everything downstream that reads `curr_mu` then measures the
320    /// point against a barrier it is not on. It is not cosmetic: the
321    /// equation-11 barrier correction injects `mu` into the
322    /// complementarity rows, and on such a point that term is pure
323    /// error -- on the `cd_split_pin_mapping` fixture it flips the sign
324    /// of the returned bound-multiplier step (`-1.004e-4` against a
325    /// true `+1.004e-4`).
326    ///
327    /// Detected from the point, never from the problem's dimensions:
328    /// bound rows present with every bound multiplier exactly zero is a
329    /// state no barrier iterate can be in -- the algorithm holds
330    /// `z > 0` strictly -- and is exactly what that path leaves behind.
331    /// The barrier level there is `0`: the equation-11 correction has
332    /// nothing to carry the step off, and the complementarity rows are
333    /// already satisfied where they stand.
334    ///
335    /// The test is exact equality rather than a threshold on purpose.
336    /// `curr_avrg_compl` was measured as the alternative and rejected:
337    /// across the `pounce-sensitivity` suite it disagrees with
338    /// `curr_mu` by up to 4.7x on ordinary terminations, so reading the
339    /// barrier level off it would move every sensitivity result to fix
340    /// one. The zeroing, by contrast, is assignment, not arithmetic.
341    pub(crate) fn barrier_mu(&self) -> Number {
342        let d = self.data.borrow();
343        let mu = d.curr_mu;
344        let Some(curr) = d.curr.as_ref() else {
345            return mu;
346        };
347        let blocks = [&curr.z_l, &curr.z_u, &curr.v_l, &curr.v_u];
348        let n_bound: Index = blocks.iter().map(|v| v.dim()).sum();
349        if n_bound == 0 {
350            // No bounds at all: there is no barrier either way, and the
351            // complementarity blocks the caller would shift are empty.
352            return mu;
353        }
354        if blocks.iter().all(|v| v.amax() == 0.0) {
355            return 0.0;
356        }
357        mu
358    }
359
360    /// Construct from the four handles handed in by the `on_converged`
361    /// callback. Errors if `data` has no `curr` (i.e. the algorithm
362    /// never reached an iterate — should not happen on
363    /// `SolveSucceeded`) or the NLP reports scaling data inconsistent
364    /// with the converged iterate (see [`Self::natural_units_conj`]).
365    pub fn new(
366        data: &IpoptDataHandle,
367        cq: &IpoptCqHandle,
368        nlp: &Rc<RefCell<dyn IpoptNlp>>,
369        pd: Rc<RefCell<PdFullSpaceSolver>>,
370    ) -> Result<Self, String> {
371        let curr = data
372            .borrow()
373            .curr
374            .clone()
375            .ok_or_else(|| "no current iterate at convergence".to_string())?;
376        let dims = [
377            curr.x.dim() as usize,
378            curr.s.dim() as usize,
379            curr.y_c.dim() as usize,
380            curr.y_d.dim() as usize,
381            curr.z_l.dim() as usize,
382            curr.z_u.dim() as usize,
383            curr.v_l.dim() as usize,
384            curr.v_u.dim() as usize,
385        ];
386        let (d_var, d_full) = Self::variable_factors(nlp, &dims)?;
387        let conj = Self::natural_units_conj(nlp, &dims, d_var.as_ref().map(|v| v.as_slice()))?;
388        let bound_vars = Self::bound_variable_rows(nlp, &dims);
389        let declared = Self::declared_frame_barrier(data, nlp, &dims);
390        let sigma = Self::effective_sigma(cq, declared.as_ref(), &dims);
391        Ok(Self {
392            pd,
393            data: Rc::clone(data),
394            cq: Rc::clone(cq),
395            nlp: Rc::clone(nlp),
396            dims,
397            template: curr,
398            conj,
399            d_var,
400            d_full,
401            bound_vars,
402            declared,
403            sigma,
404        })
405    }
406
407    /// Pick the frame (gh#654) and apply the ceiling (gh#737), once,
408    /// at construction: both diagonals are functions of the converged
409    /// state alone, and every solve has to factor against the same
410    /// object for the factorization cache to hold.
411    fn effective_sigma(
412        cq: &IpoptCqHandle,
413        declared: Option<&Rc<DeclaredFrameBarrier>>,
414        dims: &[usize; 8],
415    ) -> EffectiveSigma {
416        let cap_x = Rc::new(sigma_pin_caps(cq, dims[0]));
417        let base_x = match declared {
418            Some(d) => Rc::clone(&d.sigma_x),
419            None => cq.borrow().curr_sigma_x(),
420        };
421        let base_s = match declared {
422            Some(d) => Rc::clone(&d.sigma_s),
423            None => cq.borrow().curr_sigma_s(),
424        };
425        // The `s` block's single model coefficient is the `−I` that ties
426        // each row's slack to its `d(x)` row, exactly `1` in the scaled
427        // space this is measured in, so its ceiling is one scalar.
428        let cap_s = sigma_pin_cap(1.0);
429        let cap_of_x = |i: usize| cap_x.get(i).copied().unwrap_or(Number::INFINITY);
430        let ratio_x = Rc::new(cap_ratio(&base_x, &cap_of_x));
431        let ratio_s = Rc::new(cap_ratio(&base_s, &|_| cap_s));
432        let x = cap_sigma(&base_x, &cap_of_x).or_else(|| declared.map(|d| Rc::clone(&d.sigma_x)));
433        let s = cap_sigma(&base_s, &|_| cap_s).or_else(|| declared.map(|d| Rc::clone(&d.sigma_s)));
434        EffectiveSigma {
435            x,
436            s,
437            cap_x,
438            ratio_x,
439            ratio_s,
440        }
441    }
442
443    /// Re-measure `Σ` against the declared bounds when the held iterate
444    /// came from crossover; `None` otherwise, and `None` whenever the
445    /// NLP does not report its declared box (the fallback is then the
446    /// calculated quantities, i.e. the pre-gh#654 behaviour).
447    ///
448    /// Deliberately gated on crossover rather than applied everywhere:
449    /// an *interior* iterate is not near the declared bounds in any
450    /// useful sense — it can sit up to `δ` **outside** one — and its
451    /// `μ/z` standoff is the barrier's own geometry, which is the right
452    /// thing to read. Only a purified point has the declared frame as
453    /// its own.
454    fn declared_frame_barrier(
455        data: &IpoptDataHandle,
456        nlp: &Rc<RefCell<dyn IpoptNlp>>,
457        dims: &[usize; 8],
458    ) -> Option<Rc<DeclaredFrameBarrier>> {
459        let (curr, from_crossover) = {
460            let d = data.borrow();
461            (d.curr.clone()?, d.curr_from_crossover)
462        };
463        if !from_crossover {
464            return None;
465        }
466        let nlp_ref = nlp.borrow();
467        let (x_l, x_u) = nlp_ref.declared_x_bounds()?;
468        let (d_l, d_u) = nlp_ref.declared_d_bounds()?;
469        if x_l.len() != dims[4]
470            || x_u.len() != dims[5]
471            || d_l.len() != dims[6]
472            || d_u.len() != dims[7]
473        {
474            return None;
475        }
476        let (sigma_x, slack_x_l, slack_x_u) = declared_frame_sigma(
477            &*nlp_ref.px_l(),
478            &*nlp_ref.px_u(),
479            &*curr.x,
480            &x_l,
481            &x_u,
482            &*curr.z_l,
483            &*curr.z_u,
484            dims[0],
485        );
486        let (sigma_s, slack_s_l, slack_s_u) = declared_frame_sigma(
487            &*nlp_ref.pd_l(),
488            &*nlp_ref.pd_u(),
489            &*curr.s,
490            &d_l,
491            &d_u,
492            &*curr.v_l,
493            &*curr.v_u,
494            dims[1],
495        );
496        Some(Rc::new(DeclaredFrameBarrier {
497            sigma_x,
498            sigma_s,
499            slack_x_l,
500            slack_x_u,
501            slack_s_l,
502            slack_s_u,
503        }))
504    }
505
506    /// The barrier diagonals to factor with — [`Self::sigma`], which
507    /// is the declared-frame pair when the held iterate came from
508    /// crossover and the calculated quantities otherwise, either way
509    /// under gh#737's ceiling. A block that needed neither correction
510    /// stays `None`, which is what [`SigmaOverride::default`] carries
511    /// and what leaves the cached diagonal in place.
512    fn sigma_override(&self) -> SigmaOverride {
513        SigmaOverride {
514            x: self.sigma.x.clone(),
515            s: self.sigma.s.clone(),
516        }
517    }
518
519    /// Put the four bound-multiplier blocks of a scaled-space solution
520    /// into the same frame the operator was factored in (gh#828).
521    ///
522    /// `PdFullSpaceSolver` eliminates the bound rows into the `x` / `s`
523    /// diagonal and recovers them afterwards as
524    /// `dz = r_z/s − (z/s)·dx`. That recovery re-derives `z/s` straight
525    /// off the iterate, which is right exactly when the diagonal it
526    /// eliminated into *was* `Σ = Σ z/s` — and under the gh#737 ceiling
527    /// it is not. The solve then holds the capped bound softly and
528    /// reads its multiplier back stiffly, and the two disagree by the
529    /// cap ratio.
530    ///
531    /// The measured cost is not subtle. On gh#828's fixture a strongly
532    /// active bound with `Σ = 2.5e12` capped to `7.0e5` came back with
533    /// `dz = 1.78e7` against a true `0`, growing as `A⁻²` as the row
534    /// coefficient shrank — enough that `correct_step` opened on a
535    /// stationarity residual of `1.78e7`, failed to improve it in one
536    /// chord step, and handed the caller back its own uncorrected
537    /// input at every budget.
538    ///
539    /// Consistency asks for the capped bound's complementarity row to
540    /// read `s·dz + r·z·dx = r_z` — the same `r` the diagonal was
541    /// scaled by — so the fix adds `(1 − r)·(z/s)·dx` back onto each
542    /// affected row. `r == 1` wherever the ceiling did not bind, which
543    /// is every row of an uncapped solve, so this is exactly a no-op
544    /// off the capped path.
545    ///
546    /// Scoped to the solves that carry [`Self::sigma_override`]. A
547    /// *released* or *pinned* diagonal is not a rescaling of the base
548    /// one — a released entry is rebuilt from the bounds that stay
549    /// active and a pinned entry is raised by an addend — so no single
550    /// per-variable ratio describes it, and those paths are left in the
551    /// frame they already had.
552    ///
553    /// Returns `false` when a ratio is in force and the correction
554    /// could **not** be applied — an unexpected vector or matrix type
555    /// behind a block, a missing iterate — and every caller fails the
556    /// solve on it. Skipping quietly would put back exactly the defect
557    /// this exists to fix, with no signal: `Σ` capped, the multiplier
558    /// rows read back uncapped, and an answer that looks like every
559    /// other. `true` whenever every ratio is `1.0`, which is the whole
560    /// of an uncapped solve and costs one pass over two slices.
561    #[must_use]
562    pub(crate) fn rescale_bound_multipliers(
563        &self,
564        lhs: &mut [Number],
565        ratio_x: &[Number],
566        ratio_s: &[Number],
567    ) -> bool {
568        let unit = |r: &[Number]| r.iter().all(|&v| v == 1.0);
569        if unit(ratio_x) && unit(ratio_s) {
570            return true;
571        }
572        let off = self.offsets();
573        let d = self.data.borrow();
574        let Some(curr) = d.curr.as_ref() else {
575            return false;
576        };
577        let cq_ref = self.cq.borrow();
578        let nlp_ref = self.nlp.borrow();
579        let dense = |v: &dyn pounce_linalg::Vector| -> Option<Vec<Number>> {
580            v.as_any()
581                .downcast_ref::<DenseVector>()
582                .map(|d| d.expanded_values())
583        };
584        let rows = |m: &dyn pounce_linalg::Matrix| -> Option<Vec<usize>> {
585            m.as_any()
586                .downcast_ref::<pounce_linalg::expansion_matrix::ExpansionMatrix>()
587                .map(|e| {
588                    e.expanded_pos_indices()
589                        .iter()
590                        .map(|&i| i as usize)
591                        .collect()
592                })
593        };
594        // `sign` is the one the expansion carries: a lower bound folds
595        // in as `−z·dx` and an upper one as `+z·dx`.
596        let mut fix = |p: &dyn pounce_linalg::Matrix,
597                       z: &dyn pounce_linalg::Vector,
598                       slack: &dyn pounce_linalg::Vector,
599                       ratio: &[Number],
600                       primal_lo: usize,
601                       block_lo: usize,
602                       block_hi: usize,
603                       sign: Number|
604         -> bool {
605            if block_hi == block_lo || ratio.iter().all(|&v| v == 1.0) {
606                return true;
607            }
608            let (Some(pos), Some(zv), Some(sv)) = (rows(p), dense(z), dense(slack)) else {
609                return false;
610            };
611            // The expansion's rows ARE the block, one per bound; a
612            // mismatch means the index arithmetic below is against the
613            // wrong layout, so say so rather than scatter into it.
614            if pos.len() != block_hi - block_lo {
615                return false;
616            }
617            for (k, &i) in pos.iter().enumerate() {
618                let (Some(&r), Some(&zk), Some(&sk)) = (ratio.get(i), zv.get(k), sv.get(k)) else {
619                    return false;
620                };
621                if r == 1.0 || sk == 0.0 || !sk.is_finite() {
622                    continue;
623                }
624                let dx = lhs[primal_lo + i];
625                lhs[block_lo + k] += sign * (1.0 - r) * (zk / sk) * dx;
626            }
627            true
628        };
629        fix(
630            &*nlp_ref.px_l(),
631            &*curr.z_l,
632            &*cq_ref.curr_slack_x_l(),
633            ratio_x,
634            off[0],
635            off[4],
636            off[5],
637            1.0,
638        ) && fix(
639            &*nlp_ref.px_u(),
640            &*curr.z_u,
641            &*cq_ref.curr_slack_x_u(),
642            ratio_x,
643            off[0],
644            off[5],
645            off[6],
646            -1.0,
647        ) && fix(
648            &*nlp_ref.pd_l(),
649            &*curr.v_l,
650            &*cq_ref.curr_slack_s_l(),
651            ratio_s,
652            off[1],
653            off[6],
654            off[7],
655            1.0,
656        ) && fix(
657            &*nlp_ref.pd_u(),
658            &*curr.v_u,
659            &*cq_ref.curr_slack_s_u(),
660            ratio_s,
661            off[1],
662            off[7],
663            off[8],
664            -1.0,
665        )
666    }
667
668    /// Whether the held-factor back-solve may run
669    /// `PdFullSpaceSolver`'s iterative refinement.
670    ///
671    /// Refinement iterates `x += K^-1 r` and measures `r` against the
672    /// system it thinks it is solving. That is only the system this
673    /// factor factors when no `SigmaOverride` is in play: after
674    /// crossover (gh#654) the barrier diagonal is replaced with the
675    /// declared-frame one, so the residual is taken against a matrix
676    /// the held factor does not decompose, the loop cannot converge,
677    /// and it escalates instead of improving anything. Same reason the
678    /// release path (`solve_released_inner`) never refines.
679    ///
680    /// So the test is on the override itself and not on where it came
681    /// from. It read `declared.is_none()` while crossover was the only
682    /// thing that could produce one; gh#737's ceiling is a second, and
683    /// it fires on an ordinary solve with `declared` empty. On the
684    /// gh#737 fixture that combination returned `7.97e22` for a step of
685    /// `-0.21` -- refinement escalating against the uncapped matrix,
686    /// exactly the failure this predicate exists to prevent. Declared
687    /// still implies an override, so this stays equivalent wherever it
688    /// was already right.
689    fn may_refine(&self) -> bool {
690        self.sigma.x.is_none() && self.sigma.s.is_none()
691    }
692
693    /// The `x`-block barrier diagonal in the frame the held iterate
694    /// belongs to, under the [`sigma_pin_caps`] ceiling. Crate-internal
695    /// because [`crate::activity`] reads `Σ` straight off the iterate
696    /// rather than through the factor, and the two must not disagree
697    /// about which bounds the point is measured against or about how
698    /// stiffly it is held there.
699    pub(crate) fn barrier_sigma_x(&self) -> Rc<dyn pounce_linalg::Vector> {
700        match self.sigma.x.as_ref() {
701            Some(v) => Rc::clone(v),
702            None => self.cq.borrow().curr_sigma_x(),
703        }
704    }
705
706    /// [`Self::barrier_sigma_x`] for the `s` block.
707    pub(crate) fn barrier_sigma_s(&self) -> Rc<dyn pounce_linalg::Vector> {
708        match self.sigma.s.as_ref() {
709            Some(v) => Rc::clone(v),
710            None => self.cq.borrow().curr_sigma_s(),
711        }
712    }
713
714    /// Shared body of the two released solves. `shift` moves the
715    /// released multipliers onto their x rows, which the step needs and
716    /// a Schur complement's unit vectors must not get.
717    fn solve_released_inner(
718        &self,
719        released: &[usize],
720        rhs: &[Number],
721        lhs: &mut [Number],
722        shift: bool,
723    ) -> bool {
724        if rhs.len() != self.dim() || lhs.len() != self.dim() {
725            return false;
726        }
727        // Nothing released is an ordinary solve. Taking this early lets
728        // callers route every solve through here without paying a
729        // re-factorization for a step that releases nothing.
730        if released.is_empty() {
731            return self.solve(rhs, lhs);
732        }
733        // Both diagonals: a released row can bound a slack rather than
734        // a variable, and taking its `z / s` off the `x` block would
735        // leave the constraint limit still enforced (gh#928). `None`
736        // in the `s` slot is the ordinary case and keeps the cached
737        // factorization key.
738        let Some((sigma, sigma_s)) = self.released_sigmas(released) else {
739            return false;
740        };
741        self.solve_released_prebuilt(released, sigma, sigma_s, None, rhs, lhs, shift)
742    }
743
744    /// [`Self::solve_released_inner`] with the released `Σ` supplied by
745    /// the caller. Repeated solves against ONE released operator must
746    /// pass the same `Rc` every time: the factorization cache keys on
747    /// the sigma object's tag, so a sigma rebuilt per call forces a
748    /// re-factorization per call, while a held one factorizes once and
749    /// back-solves thereafter.
750    #[allow(clippy::too_many_arguments)]
751    pub(crate) fn solve_released_prebuilt(
752        &self,
753        released: &[usize],
754        sigma: Rc<dyn pounce_linalg::Vector>,
755        sigma_s: Option<Rc<dyn pounce_linalg::Vector>>,
756        ratios: Option<(&[Number], &[Number])>,
757        rhs: &[Number],
758        lhs: &mut [Number],
759        shift: bool,
760    ) -> bool {
761        if rhs.len() != self.dim() || lhs.len() != self.dim() {
762            return false;
763        }
764        let mut scaled: Vec<Number> = match self.conj.as_ref() {
765            Some(c) => rhs.iter().zip(c.e.iter()).map(|(&r, &e)| r * e).collect(),
766            None => rhs.to_vec(),
767        };
768        // A released bound's multiplier is fixed at zero, so its row
769        // has no equation and the right-hand side there is meaningless.
770        // It is also dangerous: the elimination folds a multiplier
771        // row's entry in as r_z / s, and at a tightly active bound the
772        // barrier-correction term every parametric right-hand side
773        // carries, -mu on each bound row, folds to -mu / s = -z by
774        // complementarity, an order-one injection into the released
775        // variable's equation. Measured on a two-variable QP that bent
776        // the released direction from the analytic [1.227, 0.454] to
777        // [1.154, 0.194].
778        for &r in released {
779            if r < scaled.len() {
780                scaled[r] = 0.0;
781            }
782        }
783        if shift && !self.shift_released_rhs(released, &mut scaled) {
784            return false;
785        }
786        if !self.solve_released_scaled(sigma, sigma_s, &scaled, lhs) {
787            return false;
788        }
789        // In scaled space, before the natural-units conjugation: the
790        // ceiling ratio is a property of the operator that was
791        // factored, and `z / s` is read off the iterate in the frame
792        // that operator lives in (gh#828).
793        if let Some((rx, rs)) = ratios {
794            if !self.rescale_bound_multipliers(lhs, rx, rs) {
795                return false;
796            }
797        }
798        if let Some(c) = self.conj.as_ref() {
799            for (l, &f) in lhs.iter_mut().zip(c.f.iter()) {
800                *l *= f;
801            }
802        }
803        true
804    }
805
806    /// The diagonal a *path* pin puts on one primal KKT row: the
807    /// stiffest entry the row's own couplings still survive, which is
808    /// [`sigma_pin_cap`] of the largest constraint coefficient the row
809    /// carries.
810    ///
811    /// A variable in no constraint row reports no ceiling at all
812    /// (gh#653), and `INFINITY` here is not a stiffer pin but a `NaN`
813    /// in the factorization. Those get the scalar the `s` diagonal is
814    /// built under, `sigma_pin_cap(1.0)` -- unit coupling, and a pin
815    /// the coordinate moves under by `eps * SIGMA_PIN_HEADROOM` per
816    /// unit of force, which is roundoff.
817    ///
818    /// The addend is the ceiling itself rather than something under
819    /// it, so [`pinned_entry`] returns the ceiling exactly and the
820    /// capped diagonal differs from the uncapped one by the factor
821    /// `cap / (had + cap)`. On a bound the walk *reaches* -- the only
822    /// kind it pins -- `had` is the base point's barrier term for a
823    /// bound that was inactive there, order one against a ceiling of
824    /// order `1e13`, so that factor is `1 - 1e-13` and the multiplier
825    /// rows need no [`Self::rescale_bound_multipliers`] pass.
826    fn path_pin_add(&self, row: usize) -> Number {
827        let cap = if row < self.dims[0] {
828            self.sigma
829                .cap_x
830                .get(row)
831                .copied()
832                .unwrap_or(Number::INFINITY)
833        } else {
834            sigma_pin_cap(1.0)
835        };
836        if cap.is_finite() {
837            cap
838        } else {
839            sigma_pin_cap(1.0)
840        }
841    }
842
843    /// Body of [`SensBacksolver::solve_released_pinned`]: the
844    /// released solve, with each pinned primal row's diagonal raised
845    /// to [`Self::path_pin_add`].
846    ///
847    /// # Why raising the diagonal costs nothing in accuracy
848    ///
849    /// This is the *operator*, not the pin. The caller applies the
850    /// same exact Schur row on top of it -- `K w - E du = r`,
851    /// `Eᵀ w = 0` -- and `Eᵀ w = 0` says every pinned coordinate of
852    /// `w` is zero, so the term this function added,
853    /// `Σ_pin E Eᵀ w`, is identically zero at the solution. The
854    /// system actually solved is the released one, and `du` comes back
855    /// in the very same frame and units the unregularized pin reports
856    /// it in. Nothing has to be converted, and a walk that takes the
857    /// plain operator on one segment and this one on the next keeps
858    /// accumulating a single `h.mult`.
859    ///
860    /// What the added diagonal buys is that `K⁻¹` exists at all.
861    /// Releasing a bound takes its `Sigma` off the diagonal; on a
862    /// model with no curvature two released variables sharing a
863    /// constraint row are left with linearly dependent stationarity
864    /// rows, and a Schur complement `-Eᵀ K⁻¹ E` cannot be built from a
865    /// singular `K` (gh#930). The diagonal has to be reachable, not
866    /// merely large -- [`Self::path_pin_add`] is the gh#737 ceiling,
867    /// the stiffest entry the row's own couplings survive.
868    ///
869    /// `issue_930_two_curvature_free_releases.rs` measures the two
870    /// operators against each other on a fixture where both run, and
871    /// the answer against a re-solve at the perturbed parameter.
872    fn solve_released_pinned_inner(
873        &self,
874        released: &[usize],
875        pinned: &[usize],
876        rhs: &[Number],
877        lhs: &mut [Number],
878    ) -> bool {
879        if pinned.is_empty() {
880            return false;
881        }
882        let n_p = self.dims[0] + self.dims[1];
883        // A pin is a primal row by contract; anything past the `s`
884        // block is a multiplier row and has no diagonal to raise.
885        if pinned.iter().any(|&r| r >= n_p) {
886            return false;
887        }
888        let pins: Vec<(usize, Number)> =
889            pinned.iter().map(|&r| (r, self.path_pin_add(r))).collect();
890        let Some((sigma_x, sigma_s)) = self.active_set_sigmas(released, &pins) else {
891            return false;
892        };
893        self.solve_released_prebuilt(released, sigma_x, sigma_s, None, rhs, lhs, false)
894    }
895
896    /// The barrier's **primal** diagonals with each released bound's
897    /// own `z / s` taken off the quantity it constrains -- rebuilt
898    /// rather than subtracted, since a quantity bounded on both sides
899    /// contributes twice and only one side is being released.
900    ///
901    /// The second slot is the `s`-block diagonal a released
902    /// **constraint** limit needs, and is `None` when nothing in
903    /// `released` bounds a slack.
904    ///
905    /// Both the starting diagonal and the slacks the surviving sides are
906    /// rebuilt from come from the frame the held iterate belongs to
907    /// (gh#654): mixing a declared-frame `Σ` with relaxed-frame slacks
908    /// would leave the released variable pinned in one frame and its
909    /// neighbours in the other.
910    ///
911    /// `None` is load-bearing rather than a shortcut: the
912    /// factorization cache keys on the diagonal object's tag, so
913    /// handing back a freshly built `s` vector that is numerically
914    /// identical to the cached one would re-factorize every solve.
915    pub(crate) fn released_sigmas(
916        &self,
917        released: &[usize],
918    ) -> Option<(
919        Rc<dyn pounce_linalg::Vector>,
920        Option<Rc<dyn pounce_linalg::Vector>>,
921    )> {
922        self.active_set_sigmas(released, &[])
923    }
924
925    /// Which bound block a multiplier row falls in, and its index
926    /// inside that block's compressed vector.
927    ///
928    /// `bound_variable_rows` emits the four groups in block order and
929    /// records only the compound row, so this is where a row is turned
930    /// back into "the `k`th entry of `z_u`". Doing that arithmetic at
931    /// the use site is what the `z`-only version did -- `row -
932    /// base_row - dims[4]` -- and that expression silently returns a
933    /// `z_u` index for a `v_l` row, which is a real number that
934    /// indexes a real vector and is wrong.
935    fn bound_block_of(&self, row: usize) -> Option<(BoundBlock, usize)> {
936        let z_l = self.dims[0] + self.dims[1] + self.dims[2] + self.dims[3];
937        let z_u = z_l + self.dims[4];
938        let v_l = z_u + self.dims[5];
939        let v_u = v_l + self.dims[6];
940        let end = v_u + self.dims[7];
941        if row >= z_l && row < z_u {
942            Some((BoundBlock::ZL, row - z_l))
943        } else if row >= z_u && row < v_l {
944            Some((BoundBlock::ZU, row - z_u))
945        } else if row >= v_l && row < v_u {
946            Some((BoundBlock::VL, row - v_l))
947        } else if row >= v_u && row < end {
948            Some((BoundBlock::VU, row - v_u))
949        } else {
950            None
951        }
952    }
953
954    /// [`Self::released_sigma_x`], also raising the diagonal on
955    /// variables a step brings onto a bound.
956    ///
957    /// The two directions an active set can move are the same
958    /// modification to one diagonal. A bound that leaves has its
959    /// `z / s` taken off the quantity it constrains, and a bound that
960    /// becomes active has one put on, so a single vector describes
961    /// both and a single factorization serves the whole correction.
962    /// The two arguments are in different index spaces and both are
963    /// `usize`: `released` holds compound KKT rows of bound
964    /// multipliers, `pinned` holds var-x rows. Passing one where the
965    /// other belongs is not a type error and will not be caught here.
966    ///
967    /// Returns both primal diagonals, because a released row can bound
968    /// a slack rather than a variable (gh#928): `g(x) <= cap` puts its
969    /// multiplier in `v_u` and its `z / s` on the `s` diagonal, and
970    /// releasing it has to come off the block it was added to. The `s`
971    /// half is `None` whenever `released` touches no slack bound,
972    /// which is every call this had before constraint rows were
973    /// reported and every call on a model with no inequality limits.
974    ///
975    /// `pinned` is a **primal** KKT row, not a var-x row, and the two
976    /// spaces coincide only below `dims[0]`. A constraint's own limit
977    /// is a bound on its slack (gh#928), so its pin belongs on the `s`
978    /// diagonal; writing it into the `x` diagonal at whatever index it
979    /// happens to equal is the gh#450 neighbouring-variable hazard one
980    /// block over. The loop below decides the block rather than
981    /// assuming it.
982    fn active_set_sigmas(
983        &self,
984        released: &[usize],
985        pinned: &[(usize, Number)],
986    ) -> Option<(
987        Rc<dyn pounce_linalg::Vector>,
988        Option<Rc<dyn pounce_linalg::Vector>>,
989    )> {
990        use pounce_linalg::dense_vector::DenseVectorSpace;
991        let rows = self.bound_vars.as_deref()?;
992        let dense = |v: Rc<dyn pounce_linalg::Vector>| -> Option<Vec<Number>> {
993            v.as_any()
994                .downcast_ref::<DenseVector>()
995                .map(|d| d.expanded_values())
996        };
997        let n_x = self.dims[0];
998        let mut sigma_x = dense(self.barrier_sigma_x())?;
999        // Built only if a slack bound is actually released; see
1000        // `released_sigmas` on why an untouched `None` matters.
1001        let mut sigma_s: Option<Vec<Number>> = None;
1002        let (slack_x_l, slack_x_u, slack_s_l, slack_s_u) = match self.declared.as_ref() {
1003            Some(d) => (
1004                d.slack_x_l.clone(),
1005                d.slack_x_u.clone(),
1006                d.slack_s_l.clone(),
1007                d.slack_s_u.clone(),
1008            ),
1009            None => {
1010                let cq_ref = self.cq.borrow();
1011                (
1012                    dense(cq_ref.curr_slack_x_l())?,
1013                    dense(cq_ref.curr_slack_x_u())?,
1014                    dense(cq_ref.curr_slack_s_l())?,
1015                    dense(cq_ref.curr_slack_s_u())?,
1016                )
1017            }
1018        };
1019        let (z_l, z_u, v_l, v_u) = {
1020            let d = self.data.borrow();
1021            let curr = d.curr.as_ref()?;
1022            (
1023                dense(Rc::clone(&curr.z_l))?,
1024                dense(Rc::clone(&curr.z_u))?,
1025                dense(Rc::clone(&curr.v_l))?,
1026                dense(Rc::clone(&curr.v_u))?,
1027            )
1028        };
1029        // Rebuild each released quantity's entry from its bounds that
1030        // stay active, rather than subtracting the released bound's
1031        // `z / s` from the cached total. The subtraction differences
1032        // two numbers of order `z / s`, 1e7 and up at a tightly
1033        // active bound, and its correctness rests on the cache having
1034        // built its total from bitwise-identical products. Rebuilding
1035        // makes the released side an exact zero by construction and
1036        // depends on nothing about the cache.
1037        for &r in released {
1038            let br = rows.iter().find(|b| b.row == r)?;
1039            let mut fresh = 0.0;
1040            for other in rows.iter().filter(|b| b.var_row == br.var_row) {
1041                if released.contains(&other.row) {
1042                    continue;
1043                }
1044                let (blk, k) = self.bound_block_of(other.row)?;
1045                let (z, s) = match blk {
1046                    BoundBlock::ZL => (*z_l.get(k)?, *slack_x_l.get(k)?),
1047                    BoundBlock::ZU => (*z_u.get(k)?, *slack_x_u.get(k)?),
1048                    BoundBlock::VL => (*v_l.get(k)?, *slack_s_l.get(k)?),
1049                    BoundBlock::VU => (*v_u.get(k)?, *slack_s_u.get(k)?),
1050                };
1051                if s == 0.0 || !s.is_finite() {
1052                    return None;
1053                }
1054                fresh += z / s;
1055            }
1056            // Under the same ceiling the rest of the diagonal is held
1057            // to (gh#737): a rebuilt entry is `z/s` off the iterate
1058            // like any other, and a released variable is the one most
1059            // likely to be reached through a constraint row. The `s`
1060            // block's ceiling is the scalar its diagonal was built
1061            // under, `sigma_pin_cap(1.0)`, exactly as at construction.
1062            if br.var_row < n_x {
1063                let cap = self
1064                    .sigma
1065                    .cap_x
1066                    .get(br.var_row)
1067                    .copied()
1068                    .unwrap_or(Number::INFINITY);
1069                *sigma_x.get_mut(br.var_row)? = fresh.min(cap);
1070            } else {
1071                let ss = match sigma_s.as_mut() {
1072                    Some(v) => v,
1073                    None => sigma_s.insert(dense(self.barrier_sigma_s())?),
1074                };
1075                *ss.get_mut(br.var_row - n_x)? = fresh.min(sigma_pin_cap(1.0));
1076            }
1077        }
1078        for &(var_row, add) in pinned {
1079            // `var_row` is a *primal* KKT row, so it reaches past the
1080            // `x` block into `s` for a constraint's own limit. Writing
1081            // one into the `x` diagonal at whatever index it happens
1082            // to equal is the gh#450 neighbouring-variable hazard, so
1083            // the block is decided here rather than assumed.
1084            if var_row < n_x {
1085                let cap = self
1086                    .sigma
1087                    .cap_x
1088                    .get(var_row)
1089                    .copied()
1090                    .unwrap_or(Number::INFINITY);
1091                let slot = sigma_x.get_mut(var_row)?;
1092                *slot = pinned_entry(*slot, add, cap);
1093            } else {
1094                // Same ceiling the `s` diagonal was built under.
1095                let ss = match sigma_s.as_mut() {
1096                    Some(v) => v,
1097                    None => sigma_s.insert(dense(self.barrier_sigma_s())?),
1098                };
1099                let slot = ss.get_mut(var_row - n_x)?;
1100                *slot = pinned_entry(*slot, add, sigma_pin_cap(1.0));
1101            }
1102        }
1103        let pack = |vals: &[Number]| -> Rc<dyn pounce_linalg::Vector> {
1104            let space = DenseVectorSpace::new(vals.len() as Index);
1105            let mut out = DenseVector::new(space);
1106            out.values_mut().copy_from_slice(vals);
1107            Rc::new(out) as Rc<dyn pounce_linalg::Vector>
1108        };
1109        Some((pack(&sigma_x), sigma_s.as_deref().map(pack)))
1110    }
1111
1112    /// Zero the released multipliers' own rows of a scaled-space
1113    /// right-hand side and move each multiplier onto its variable's x
1114    /// row.
1115    ///
1116    /// Dropping `sigma` gives the released *matrix*; the released
1117    /// right-hand side still wants the multiplier moved across. The
1118    /// elimination folds a multiplier row in as `r_z / s`, so zeroing
1119    /// that row and adding the multiplier to the x row directly reaches
1120    /// the same place without `s` appearing at all -- which is the
1121    /// whole point of re-factoring rather than downdating.
1122    fn shift_released_rhs(&self, released: &[usize], rhs: &mut [Number]) -> bool {
1123        let Some(rows) = self.bound_vars.as_deref() else {
1124            return false;
1125        };
1126        if rows.is_empty() {
1127            return false;
1128        }
1129        let d = self.data.borrow();
1130        let Some(curr) = d.curr.as_ref() else {
1131            return false;
1132        };
1133        let dense = |v: &Rc<dyn pounce_linalg::Vector>| -> Option<Vec<Number>> {
1134            v.as_any()
1135                .downcast_ref::<DenseVector>()
1136                .map(|x| x.expanded_values())
1137        };
1138        let (Some(z_l), Some(z_u), Some(v_l), Some(v_u)) = (
1139            dense(&curr.z_l),
1140            dense(&curr.z_u),
1141            dense(&curr.v_l),
1142            dense(&curr.v_u),
1143        ) else {
1144            return false;
1145        };
1146        for &r in released {
1147            let Some(br) = rows.iter().find(|b| b.row == r) else {
1148                return false;
1149            };
1150            // Which block the row lives in decides which multiplier
1151            // vector to read; `br.var_row` is already the compound row
1152            // of the primal quantity that carries it, `x` block or `s`
1153            // block, so the shift below needs no second case.
1154            let Some((blk, k)) = self.bound_block_of(r) else {
1155                return false;
1156            };
1157            let Some(&z) = (match blk {
1158                BoundBlock::ZL => z_l.get(k),
1159                BoundBlock::ZU => z_u.get(k),
1160                BoundBlock::VL => v_l.get(k),
1161                BoundBlock::VU => v_u.get(k),
1162            }) else {
1163                return false;
1164            };
1165            if r >= rhs.len() || br.var_row >= rhs.len() {
1166                return false;
1167            }
1168            rhs[r] = 0.0;
1169            // the sign the primal row carries this side's multiplier with
1170            rhs[br.var_row] += if br.lower { -z } else { z };
1171        }
1172        true
1173    }
1174
1175    /// One back-solve against the re-factored system, in the solver's
1176    /// internal scaled space.
1177    fn solve_released_scaled(
1178        &self,
1179        sigma: Rc<dyn pounce_linalg::Vector>,
1180        sigma_s: Option<Rc<dyn pounce_linalg::Vector>>,
1181        rhs: &[Number],
1182        lhs: &mut [Number],
1183    ) -> bool {
1184        let off = self.offsets();
1185        let rhs_mut0 = self.template.make_new_zeroed();
1186        let mut rhs_iv = rhs_mut0.freeze();
1187        let mut res_iv = self.template.make_new_zeroed();
1188        if !(write_rhs_block(&mut rhs_iv.x, &rhs[off[0]..off[1]])
1189            && write_rhs_block(&mut rhs_iv.s, &rhs[off[1]..off[2]])
1190            && write_rhs_block(&mut rhs_iv.y_c, &rhs[off[2]..off[3]])
1191            && write_rhs_block(&mut rhs_iv.y_d, &rhs[off[3]..off[4]])
1192            && write_rhs_block(&mut rhs_iv.z_l, &rhs[off[4]..off[5]])
1193            && write_rhs_block(&mut rhs_iv.z_u, &rhs[off[5]..off[6]])
1194            && write_rhs_block(&mut rhs_iv.v_l, &rhs[off[6]..off[7]])
1195            && write_rhs_block(&mut rhs_iv.v_u, &rhs[off[7]..off[8]]))
1196        {
1197            return false;
1198        }
1199        if !self.pd.borrow_mut().solve_with_sigma(
1200            &self.data,
1201            &self.cq,
1202            &self.nlp,
1203            1.0,
1204            0.0,
1205            &rhs_iv,
1206            &mut res_iv,
1207            // NOT refined: the release path asks the held factor
1208            // for the solution of a *different* system (one bound
1209            // released), so the refinement loop would measure a
1210            // residual against a matrix this factor does not factor,
1211            // stagnate, and escalate. See `solve_scaled_space` for
1212            // why the ordinary back-solves do refine.
1213            /* allow_inexact = */
1214            true,
1215            /* improve_solution = */ false,
1216            SigmaOverride {
1217                x: Some(sigma),
1218                // The release is an x-block operation; the row block
1219                // keeps whichever frame the held iterate belongs to,
1220                // unless the caller built its own (the corrector's
1221                // predicted-point pair).
1222                s: sigma_s.or_else(|| self.sigma_override().s),
1223            },
1224        ) {
1225            return false;
1226        }
1227        read_res_block(&*res_iv.x, &mut lhs[off[0]..off[1]])
1228            && read_res_block(&*res_iv.s, &mut lhs[off[1]..off[2]])
1229            && read_res_block(&*res_iv.y_c, &mut lhs[off[2]..off[3]])
1230            && read_res_block(&*res_iv.y_d, &mut lhs[off[3]..off[4]])
1231            && read_res_block(&*res_iv.z_l, &mut lhs[off[4]..off[5]])
1232            && read_res_block(&*res_iv.z_u, &mut lhs[off[5]..off[6]])
1233            && read_res_block(&*res_iv.v_l, &mut lhs[off[6]..off[7]])
1234            && read_res_block(&*res_iv.v_u, &mut lhs[off[7]..off[8]])
1235    }
1236
1237    /// The corrector's barrier diagonals, both blocks, built at the
1238    /// CURRENT iterate, which during a correction is the predicted
1239    /// point. The stored [`Self::sigma`] pair is deliberately not
1240    /// consulted: it is frozen at the base point for the back-solves
1241    /// through the held factor, and a correction factors its own
1242    /// operator, so the frame rule and the ceiling are re-derived
1243    /// where that operator lives. Declared-frame slacks when the held
1244    /// iterate came from crossover (gh#654; the declared bounds are
1245    /// constants, so the frame follows the iterate), the calculated
1246    /// quantities otherwise, and the gh#737 ceiling applied from the
1247    /// Jacobians at the same point. Released rows need no handling:
1248    /// the correction zeroes their multipliers in the packed iterate,
1249    /// so their sides contribute nothing. Pinned rows are raised
1250    /// under the same ceiling. Always fresh objects, because the
1251    /// factorization cache keys on their tags and a cached vector
1252    /// could resolve to a factor built at another iterate. How much of
1253    /// each entry the ceiling left standing comes back with them, in
1254    /// the same [`CorrectorOperator`], because the solve that folds a
1255    /// bound row into one of these diagonals has to read that row back
1256    /// through the same cap (gh#828).
1257    pub(crate) fn corrector_sigma(&self, pinned: &[(usize, Number)]) -> Option<CorrectorOperator> {
1258        use pounce_linalg::dense_vector::DenseVectorSpace;
1259        let dense = |v: Rc<dyn pounce_linalg::Vector>| -> Option<Vec<Number>> {
1260            v.as_any()
1261                .downcast_ref::<DenseVector>()
1262                .map(|d| d.expanded_values())
1263        };
1264        let (live_x, live_s) = if self.declared.is_some() {
1265            let nlp_ref = self.nlp.borrow();
1266            let (x_l, x_u) = nlp_ref.declared_x_bounds()?;
1267            let (d_l, d_u) = nlp_ref.declared_d_bounds()?;
1268            let d = self.data.borrow();
1269            let curr = d.curr.as_ref()?;
1270            let (sx, _, _) = declared_frame_sigma(
1271                &*nlp_ref.px_l(),
1272                &*nlp_ref.px_u(),
1273                &*curr.x,
1274                &x_l,
1275                &x_u,
1276                &*curr.z_l,
1277                &*curr.z_u,
1278                self.dims[0],
1279            );
1280            let (ss, _, _) = declared_frame_sigma(
1281                &*nlp_ref.pd_l(),
1282                &*nlp_ref.pd_u(),
1283                &*curr.s,
1284                &d_l,
1285                &d_u,
1286                &*curr.v_l,
1287                &*curr.v_u,
1288                self.dims[1],
1289            );
1290            (sx, ss)
1291        } else {
1292            let c = self.cq.borrow();
1293            (c.curr_sigma_x(), c.curr_sigma_s())
1294        };
1295        let caps = sigma_pin_caps(&self.cq, self.dims[0]);
1296        let cap_s = sigma_pin_cap(1.0);
1297        let mut x = dense(live_x)?;
1298        // What the diagonal would have been with no ceiling, kept
1299        // entry by entry so the multiplier rows can be read back in
1300        // the frame the operator is actually built in (gh#828).
1301        let mut base_x = x.clone();
1302        for (i, v) in x.iter_mut().enumerate() {
1303            let c = caps.get(i).copied().unwrap_or(Number::INFINITY);
1304            if *v > c {
1305                *v = c;
1306            }
1307        }
1308        let mut s = dense(live_s)?;
1309        let mut base_s = s.clone();
1310        for v in s.iter_mut() {
1311            if *v > cap_s {
1312                *v = cap_s;
1313            }
1314        }
1315        // `var_row` is a *primal* KKT row and the primal blocks are
1316        // `x` then `s`, so a pin on a constraint's own limit lands in
1317        // the second diagonal. Indexing `x` with it would be the
1318        // gh#450 neighbouring-entry hazard one block over.
1319        for &(var_row, add) in pinned {
1320            if var_row < self.dims[0] {
1321                let c = caps.get(var_row).copied().unwrap_or(Number::INFINITY);
1322                let slot = x.get_mut(var_row)?;
1323                *slot = pinned_entry(*slot, add, c);
1324                *base_x.get_mut(var_row)? += add;
1325            } else {
1326                let k = var_row - self.dims[0];
1327                let slot = s.get_mut(k)?;
1328                *slot = pinned_entry(*slot, add, cap_s);
1329                *base_s.get_mut(k)? += add;
1330            }
1331        }
1332        let pack = |vals: Vec<Number>| -> Rc<dyn pounce_linalg::Vector> {
1333            let mut out = DenseVector::new(DenseVectorSpace::new(vals.len() as Index));
1334            out.values_mut().copy_from_slice(&vals);
1335            Rc::new(out) as Rc<dyn pounce_linalg::Vector>
1336        };
1337        Some(CorrectorOperator {
1338            ratio_x: surviving_fraction(&base_x, &x),
1339            ratio_s: surviving_fraction(&base_s, &s),
1340            sigma_x: pack(x),
1341            sigma_s: pack(s),
1342        })
1343    }
1344
1345    /// A natural-units compound vector, packed as a frozen
1346    /// `IteratesVector` in the algorithm's scaled frame, so a caller
1347    /// can install it as the current iterate. The corrector uses this
1348    /// to assemble its operator at the predicted point.
1349    pub(crate) fn pack_natural(
1350        &self,
1351        flat: &[Number],
1352    ) -> Option<pounce_algorithm::iterates_vector::IteratesVector> {
1353        let scaled: Vec<Number> = match self.natural_units_factor() {
1354            None => flat.to_vec(),
1355            Some(f) => flat
1356                .iter()
1357                .zip(f)
1358                .map(|(&v, &s)| if s == 0.0 { v } else { v / s })
1359                .collect(),
1360        };
1361        self.pack_public(&scaled).ok().map(|iv| iv.freeze())
1362    }
1363
1364    /// The primal KKT row behind every bound multiplier: `z_l`, `z_u`
1365    /// through the `px_l` / `px_u` expansions into the `x` block, then
1366    /// `v_l`, `v_u` through `pd_l` / `pd_u` into the `s` block.
1367    /// `None` when any of the four is not an `ExpansionMatrix` or
1368    /// reports the wrong length -- the release half then stays off
1369    /// rather than guessing a mapping.
1370    ///
1371    /// The `v` half is here because a limit written as a **constraint
1372    /// row** -- `g(x) <= cap` -- is a bound like any other, on the
1373    /// slack rather than on a variable, and reporting only the `z`
1374    /// half left every such limit watched by nothing: no breakpoint,
1375    /// no release, and a step straight through the cap with an empty
1376    /// record (gh#928). The `s` block sits immediately after `x` in
1377    /// the compound vector, so the (x, s) prefix is one contiguous box
1378    /// and the walk needs no second index space to cover it -- which
1379    /// is exactly why `var_row` is documented as a primal KKT row
1380    /// rather than a var-x one.
1381    ///
1382    /// The four groups are emitted in block order, so
1383    /// [`Self::bound_row_offsets`] can recover which block a row
1384    /// belongs to and its position within that block without this
1385    /// function storing either.
1386    fn bound_variable_rows(
1387        nlp: &Rc<RefCell<dyn IpoptNlp>>,
1388        dims: &[usize; 8],
1389    ) -> Option<Rc<Vec<crate::backsolver::BoundRow>>> {
1390        let nlp_ref = nlp.borrow();
1391        let z_l_off = dims[0] + dims[1] + dims[2] + dims[3];
1392        let v_l_off = z_l_off + dims[4] + dims[5];
1393        let mut out = Vec::with_capacity(dims[4] + dims[5] + dims[6] + dims[7]);
1394        // (expansion, block width, multiplier-row offset, lower?,
1395        //  primal-block base, primal-block width)
1396        for (pm, n_v, off, lower, base, width) in [
1397            (nlp_ref.px_l(), dims[4], z_l_off, true, 0, dims[0]),
1398            (
1399                nlp_ref.px_u(),
1400                dims[5],
1401                z_l_off + dims[4],
1402                false,
1403                0,
1404                dims[0],
1405            ),
1406            (nlp_ref.pd_l(), dims[6], v_l_off, true, dims[0], dims[1]),
1407            (
1408                nlp_ref.pd_u(),
1409                dims[7],
1410                v_l_off + dims[6],
1411                false,
1412                dims[0],
1413                dims[1],
1414            ),
1415        ] {
1416            if n_v == 0 {
1417                continue;
1418            }
1419            let em = pm
1420                .as_any()
1421                .downcast_ref::<pounce_linalg::expansion_matrix::ExpansionMatrix>()?;
1422            let pos = em.expanded_pos_indices();
1423            if pos.len() != n_v {
1424                return None;
1425            }
1426            for (k, &p) in pos.iter().enumerate() {
1427                let p = p as usize;
1428                if p >= width {
1429                    return None;
1430                }
1431                out.push(crate::backsolver::BoundRow {
1432                    row: off + k,
1433                    var_row: base + p,
1434                    lower,
1435                });
1436            }
1437        }
1438        Some(Rc::new(out))
1439    }
1440
1441    /// Read the variable factors the solve ran under off the NLP and
1442    /// project them into the algorithm's var-x space (gh#486 stage 3).
1443    ///
1444    /// Returns `(None, None)` when no variable scaling is active.
1445    /// Errors when the reported vector does not match the NLP's own
1446    /// full-x width, or when the projection does not fill the `x`
1447    /// block: either would silently mis-pair a factor with a variable,
1448    /// which is the whole failure mode this plumbing exists to avoid.
1449    #[allow(clippy::type_complexity)]
1450    fn variable_factors(
1451        nlp: &Rc<RefCell<dyn IpoptNlp>>,
1452        dims: &[usize; 8],
1453    ) -> Result<(Option<Rc<Vec<Number>>>, Option<Rc<Vec<Number>>>), String> {
1454        let nlp_ref = nlp.borrow();
1455        let Some(d_full) = nlp_ref.variable_scaling() else {
1456            return Ok((None, None));
1457        };
1458        let n_full = nlp_ref.n_full_x() as usize;
1459        if d_full.len() != n_full {
1460            return Err(format!(
1461                "variable scaling length {} != n_full_x {}",
1462                d_full.len(),
1463                n_full
1464            ));
1465        }
1466        // NaN is not a "no-op" factor and neither is zero; the wrapper
1467        // refuses both at setup, so seeing one here means the vector
1468        // did not come from the wrapper that ran.
1469        if let Some(bad) = d_full.iter().find(|v| !v.is_finite() || **v <= 0.0) {
1470            return Err(format!(
1471                "variable scaling factor {bad} is not finite and positive"
1472            ));
1473        }
1474        let mut d_var = vec![Number::NAN; dims[0]];
1475        for (full, &factor) in d_full.iter().enumerate() {
1476            if let Some(var) = nlp_ref.full_x_to_var_x(full as Index) {
1477                let slot = d_var.get_mut(var as usize).ok_or_else(|| {
1478                    format!("var-x index {var} outside x block of width {}", dims[0])
1479                })?;
1480                *slot = factor;
1481            }
1482        }
1483        if let Some(pos) = d_var.iter().position(|v| v.is_nan()) {
1484            return Err(format!(
1485                "variable scaling left var-x column {pos} of {} unmapped",
1486                dims[0]
1487            ));
1488        }
1489        Ok((Some(Rc::new(d_var)), Some(Rc::new(d_full))))
1490    }
1491
1492    /// The per-variable factors the held solve ran under, in the
1493    /// algorithm's **var-x** space (one entry per `x`-block KKT row),
1494    /// or `None` when no variable scaling was active (gh#486).
1495    pub fn variable_scaling(&self) -> Option<&[Number]> {
1496        self.d_var.as_deref().map(|v| v.as_slice())
1497    }
1498
1499    /// [`Self::variable_scaling`] in the user TNLP's **full-x** space:
1500    /// the shape of an `n_full_x`-length report, with the columns the
1501    /// solve dropped as fixed still present.
1502    pub fn variable_scaling_full(&self) -> Option<&[Number]> {
1503        self.d_full.as_deref().map(|v| v.as_slice())
1504    }
1505
1506    /// Build the natural-units scaling pair `(E, F)` from the NLP's
1507    /// effective scaling and the variable factors `d_var` the solve
1508    /// ran under (see the field doc on [`Self::conj`]).
1509    /// Returns `Ok(None)` when no scaling is active. Errors when the
1510    /// NLP reports scaling data inconsistent with the converged
1511    /// iterate's block dimensions (would silently corrupt every
1512    /// back-solve) or a zero/non-finite `df`.
1513    fn natural_units_conj(
1514        nlp: &Rc<RefCell<dyn IpoptNlp>>,
1515        dims: &[usize; 8],
1516        d_var: Option<&[Number]>,
1517    ) -> Result<Option<Rc<ConjPair>>, String> {
1518        let nlp_ref = nlp.borrow();
1519        let df = nlp_ref.obj_scaling_factor();
1520        let dc = nlp_ref.c_scale_vec();
1521        let dd = nlp_ref.d_scale_vec();
1522        // `d_var` counts as active scaling on its own: a solve with
1523        // unit objective and row factors but a change of variables
1524        // still holds its factor in scaled coordinates.
1525        if df == 1.0 && dc.is_none() && dd.is_none() && d_var.is_none() {
1526            return Ok(None);
1527        }
1528        // df may be negative (obj_scaling_factor < 0 means maximize);
1529        // the two-sided scaling needs no square root, only df ≠ 0.
1530        if !df.is_finite() || df == 0.0 {
1531            return Err(format!("invalid obj_scaling_factor {df}"));
1532        }
1533        if let Some(v) = &dc {
1534            if v.len() != dims[2] {
1535                return Err(format!("c_scale length {} != y_c dim {}", v.len(), dims[2]));
1536            }
1537        }
1538        if let Some(v) = &dd {
1539            if v.len() != dims[3] || dims[1] != dims[3] {
1540                return Err(format!(
1541                    "d_scale length {} != y_d dim {} (s dim {})",
1542                    v.len(),
1543                    dims[3],
1544                    dims[1]
1545                ));
1546            }
1547        }
1548        if let Some(d) = d_var {
1549            if d.len() != dims[0] {
1550                return Err(format!(
1551                    "variable scaling length {} != x dim {}",
1552                    d.len(),
1553                    dims[0]
1554                ));
1555            }
1556        }
1557        // Per-entry source scale for a compressed bound-multiplier
1558        // block: entry j of z_l / v_l covers the row
1559        // `px_l.expanded_pos[j]` / `pd_l.expanded_pos[j]` of `src`.
1560        // Used for the v blocks (source `d_scale`, indexed by
1561        // inequality row) and the z blocks (source `d_var`, indexed by
1562        // var-x column).
1563        let bound_row_scale = |pm: Rc<dyn pounce_linalg::matrix::Matrix>,
1564                               src: Option<&[Number]>,
1565                               n_v: usize,
1566                               which: &str|
1567         -> Result<Vec<Number>, String> {
1568            let Some(vals) = src else {
1569                return Ok(vec![1.0; n_v]);
1570            };
1571            if n_v == 0 {
1572                return Ok(Vec::new());
1573            }
1574            let Some(em) = pm
1575                .as_any()
1576                .downcast_ref::<pounce_linalg::expansion_matrix::ExpansionMatrix>()
1577            else {
1578                return Err(format!("{which} is not an ExpansionMatrix"));
1579            };
1580            let pos = em.expanded_pos_indices();
1581            if pos.len() != n_v {
1582                return Err(format!(
1583                    "{which} expansion length {} != {} block dim {}",
1584                    pos.len(),
1585                    which,
1586                    n_v
1587                ));
1588            }
1589            pos.iter()
1590                .map(|&r| {
1591                    vals.get(r as usize).copied().ok_or_else(|| {
1592                        format!(
1593                            "{which} expansion row {r} out of scale-vector range {}",
1594                            vals.len()
1595                        )
1596                    })
1597                })
1598                .collect()
1599        };
1600        let vl_dd = bound_row_scale(nlp_ref.pd_l(), dd.as_deref(), dims[6], "pd_l")?;
1601        let vu_dd = bound_row_scale(nlp_ref.pd_u(), dd.as_deref(), dims[7], "pd_u")?;
1602        // The variable factor carried by each finite x-bound, through
1603        // the same expansion (gh#486). `d_var` indexes var-x columns,
1604        // and `px_l` / `px_u` say which column each z entry belongs to.
1605        let zl_dx = bound_row_scale(nlp_ref.px_l(), d_var, dims[4], "px_l")?;
1606        let zu_dx = bound_row_scale(nlp_ref.px_u(), d_var, dims[5], "px_u")?;
1607        drop(nlp_ref);
1608
1609        let total: usize = dims.iter().sum();
1610        let mut e = Vec::with_capacity(total);
1611        let mut f = Vec::with_capacity(total);
1612        // x block: E = df/d_i, F = 1/d_i. `df` is the objective scale;
1613        // the `1/d_i` on both sides is the change of variables —
1614        // `∇f̃ = ∇f ⊘ d` puts the RHS in scaled units and `x = x̃ ⊘ d`
1615        // brings the solution back.
1616        match d_var {
1617            Some(d) => {
1618                e.extend(d.iter().map(|&di| df / di));
1619                f.extend(d.iter().map(|&di| 1.0 / di));
1620            }
1621            None => {
1622                e.extend(std::iter::repeat_n(df, dims[0]));
1623                f.extend(std::iter::repeat_n(1.0, dims[0]));
1624            }
1625        }
1626        // s block: E = df/dd_i, F = 1/dd_i (slacks live in scaled d-space).
1627        match &dd {
1628            Some(v) => {
1629                e.extend(v.iter().map(|&ddi| df / ddi));
1630                f.extend(v.iter().map(|&ddi| 1.0 / ddi));
1631            }
1632            None => {
1633                e.extend(std::iter::repeat_n(df, dims[1]));
1634                f.extend(std::iter::repeat_n(1.0, dims[1]));
1635            }
1636        }
1637        // y_c block: E = dc_i, F = dc_i/df.
1638        match &dc {
1639            Some(v) => {
1640                e.extend(v.iter().copied());
1641                f.extend(v.iter().map(|&dci| dci / df));
1642            }
1643            None => {
1644                e.extend(std::iter::repeat_n(1.0, dims[2]));
1645                f.extend(std::iter::repeat_n(1.0 / df, dims[2]));
1646            }
1647        }
1648        // y_d block: E = dd_i, F = dd_i/df.
1649        match &dd {
1650            Some(v) => {
1651                e.extend(v.iter().copied());
1652                f.extend(v.iter().map(|&ddi| ddi / df));
1653            }
1654            None => {
1655                e.extend(std::iter::repeat_n(1.0, dims[3]));
1656                f.extend(std::iter::repeat_n(1.0 / df, dims[3]));
1657            }
1658        }
1659        // z_l / z_u blocks: E = df, F = d_{px(j)}/df (z̃ = (df/d)·z,
1660        // and the slack diagonal x̃ − x̃_L = d·(x − x_L) carries the
1661        // variable factor, so the two cancel in the row and leave it
1662        // identical to the natural one — E takes no `d` at all).
1663        // Without variable scaling this is the pre-#486 `F = 1/df`:
1664        // bounds on x are unscaled and the slack diagonal is shared.
1665        e.extend(std::iter::repeat_n(df, dims[4] + dims[5]));
1666        f.extend(zl_dx.iter().map(|&dx| dx / df));
1667        f.extend(zu_dx.iter().map(|&dx| dx / df));
1668        // v_l / v_u blocks: E = df, F = dd_r/df (ṽ = (df/dd)·v and the
1669        // slack diagonal s̃ − d̃_l = dd·(s − d_l) carries the d-row
1670        // scale).
1671        e.extend(std::iter::repeat_n(df, dims[6] + dims[7]));
1672        f.extend(vl_dd.iter().map(|&ddr| ddr / df));
1673        f.extend(vu_dd.iter().map(|&ddr| ddr / df));
1674        Ok(Some(Rc::new(ConjPair { e, f })))
1675    }
1676
1677    /// Effective objective scaling factor `df` of the converged NLP
1678    /// (1.0 when no scaling is active).
1679    pub fn obj_scaling_factor(&self) -> Number {
1680        self.nlp.borrow().obj_scaling_factor()
1681    }
1682
1683    /// Effective NLP scaling at convergence:
1684    /// `(obj_scaling_factor, c_scale, d_scale)`. The vectors are
1685    /// `None` when the corresponding block carries no row scaling.
1686    pub fn nlp_scaling(&self) -> (Number, Option<Vec<Number>>, Option<Vec<Number>>) {
1687        let n = self.nlp.borrow();
1688        (n.obj_scaling_factor(), n.c_scale_vec(), n.d_scale_vec())
1689    }
1690
1691    /// Inertia-correction perturbations `(δ_x, δ_s, δ_c, δ_d)` baked
1692    /// into the held KKT factor (the IPM's `current_perturbation`
1693    /// state at convergence). All zero ⇔ the final factorization was
1694    /// unregularized and the natural-units back-solves invert the
1695    /// exact KKT matrix. Nonzero ⇔ the factor carries a (scaled-space)
1696    /// regularization, so sensitivity outputs — covariance in
1697    /// particular — are perturbed and no longer exactly
1698    /// scaling-invariant; consumers should check this before trusting
1699    /// `-inv(reduced_hessian)` on ill-conditioned problems
1700    /// (pounce#128 follow-up).
1701    pub fn kkt_perturbations(&self) -> [Number; 4] {
1702        let p = self.data.borrow().perturbations;
1703        [p.delta_x, p.delta_s, p.delta_c, p.delta_d]
1704    }
1705
1706    /// Map user-facing 0-based `g(x)` indices of parameter-pin
1707    /// equality constraints to flat KKT rows **and** the pin rows'
1708    /// `dc_i` scaling factors, in one pass. The KKT row of pin `i` is
1709    /// `n_x + n_s + c_block_idx`, i.e. the matching `y_c` slot, found
1710    /// through `IpoptNlp::full_g_to_c_block` so the c/d split's row
1711    /// permutation is honored (pounce#128 follow-up: the previous
1712    /// direct `n_x + n_s + g_idx` mapping silently picked wrong rows
1713    /// when inequalities preceded the pins). The scales are 1.0 when
1714    /// no constraint scaling is active; they relate the natural and
1715    /// solver-space reduced Hessians via
1716    /// `H̃_ij = (df / (dc_i·dc_j)) · H_ij`. Errors when a pin index
1717    /// is out of range or refers to an inequality row.
1718    pub fn pin_rows_and_c_scales(
1719        &self,
1720        pin_g_indices: &[Index],
1721    ) -> Result<(Vec<Index>, Vec<Number>), String> {
1722        let y_c_offset = (self.dims[0] + self.dims[1]) as Index;
1723        let nlp = self.nlp.borrow();
1724        let dc = nlp.c_scale_vec();
1725        let n_full_g = nlp.n_full_g();
1726        let mut rows = Vec::with_capacity(pin_g_indices.len());
1727        let mut scales = Vec::with_capacity(pin_g_indices.len());
1728        for &gi in pin_g_indices {
1729            // n_full_g() defaults to 0 for IpoptNlp impls that don't
1730            // report it; only range-check when it's meaningful.
1731            if gi < 0 || (n_full_g > 0 && gi >= n_full_g) {
1732                return Err(format!(
1733                    "pin constraint index {gi} out of range [0, m={n_full_g})"
1734                ));
1735            }
1736            let Some(ci) = nlp.full_g_to_c_block(gi) else {
1737                return Err(format!(
1738                    "pin constraint index {gi} is an inequality (not an equality row); \
1739                     parameter pins must be exact equalities"
1740                ));
1741            };
1742            rows.push(y_c_offset + ci);
1743            scales.push(dc.as_ref().map(|v| v[ci as usize]).unwrap_or(1.0));
1744        }
1745        Ok((rows, scales))
1746    }
1747
1748    /// KKT-row half of [`Self::pin_rows_and_c_scales`].
1749    pub fn map_pin_g_to_kkt_rows(&self, pin_g_indices: &[Index]) -> Result<Vec<Index>, String> {
1750        Ok(self.pin_rows_and_c_scales(pin_g_indices)?.0)
1751    }
1752
1753    /// Scaling half of [`Self::pin_rows_and_c_scales`].
1754    pub fn pin_c_scales(&self, pin_g_indices: &[Index]) -> Result<Vec<Number>, String> {
1755        Ok(self.pin_rows_and_c_scales(pin_g_indices)?.1)
1756    }
1757
1758    /// Block dimensions of the compound KKT vector at convergence, in
1759    /// `(x, s, y_c, y_d, z_l, z_u, v_l, v_u)` order. Sum equals
1760    /// [`SensBacksolver::dim`]. Useful when a caller needs to compute
1761    /// the flat offset of a non-x block (e.g. `n_x + n_s` for the
1762    /// start of the equality-multiplier `y_c` block).
1763    pub fn block_dims(&self) -> [usize; 8] {
1764        self.dims
1765    }
1766
1767    /// Map a 0-based **full-g** index (user-TNLP `g(x)` order) to its
1768    /// 0-based position in the equality-multiplier `y_c` block, or
1769    /// `None` when the constraint is an inequality (it lives in the `d`
1770    /// block, not `y_c`). Delegates to the held NLP's c/d-split map.
1771    ///
1772    /// Pin-row construction must route through this: the flat KKT row of
1773    /// a pinned equality is `n_x + n_s + full_g_to_c_block(g)`, NOT
1774    /// `n_x + n_s + g` — those differ whenever any inequality precedes
1775    /// the pinned equality in `g(x)`.
1776    pub fn full_g_to_c_block(&self, full_idx: Index) -> Option<Index> {
1777        self.nlp.borrow().full_g_to_c_block(full_idx)
1778    }
1779
1780    /// Map a 0-based **full-g** index to its 0-based position in the
1781    /// inequality-multiplier `y_d` block, or `None` when the
1782    /// constraint is an equality (it lives in `y_c`). The exact
1783    /// complement of [`Self::full_g_to_c_block`], and the map gh#910
1784    /// added so an inequality's multiplier row can be addressed.
1785    ///
1786    /// The flat KKT row is `n_x + n_s + n_y_c + full_g_to_d_block(g)`,
1787    /// and the caveat is the same one `full_g_to_c_block` carries: it
1788    /// is NOT `g`, and it is not `g` offset by a constant either —
1789    /// each equality ahead of the row shifts it by one.
1790    pub fn full_g_to_d_block(&self, full_idx: Index) -> Option<Index> {
1791        self.nlp.borrow().full_g_to_d_block(full_idx)
1792    }
1793
1794    /// Map a 0-based **full-x** index (user-TNLP variable order) to its
1795    /// 0-based position in the algorithm-side `x` block, or `None` when
1796    /// the solve removed the column because `x_l == x_u` under
1797    /// `fixed_variable_treatment = make_parameter`. Delegates to the
1798    /// held NLP's fixed-variable map.
1799    ///
1800    /// The `x` counterpart of [`Self::full_g_to_c_block`], and it must
1801    /// be routed through for the same reason: the flat KKT row of a
1802    /// user variable is `full_x_to_var_x(i)`, NOT `i` — those differ
1803    /// whenever any fixed variable precedes it in the user's `x`.
1804    /// Reports and iterates are in full-x, the factor is in var-x, and
1805    /// nothing about the two spaces is distinguishable by length alone
1806    /// on a model that happens to have no fixed variables.
1807    pub fn full_x_to_var_x(&self, full_idx: Index) -> Option<Index> {
1808        self.nlp.borrow().full_x_to_var_x(full_idx)
1809    }
1810
1811    /// The user TNLP's variable count, the domain of
1812    /// [`Self::full_x_to_var_x`]. Distinct from the `x` block width
1813    /// whenever the solve removed a fixed variable.
1814    pub fn n_full_x(&self) -> Index {
1815        self.nlp.borrow().n_full_x()
1816    }
1817
1818    /// The user TNLP's constraint count, the `g` counterpart of
1819    /// [`Self::n_full_x`]: the length of a full-g report and the
1820    /// domain of [`Self::full_g_to_c_block`]. Distinct from either
1821    /// KKT row block's width, since the c/d split sends equalities to
1822    /// `y_c` and inequalities to `s`/`y_d`.
1823    pub fn n_full_g(&self) -> Index {
1824        self.nlp.borrow().n_full_g()
1825    }
1826
1827    /// `E` itself, the vector [`Self::solve`] pre-multiplies its
1828    /// right-hand side by.
1829    ///
1830    /// The counterpart of [`SensBacksolver::natural_units_factor`],
1831    /// which reports `F`. A caller holding a residual it assembled from
1832    /// the algorithm's own calculated quantities holds it in the scaled
1833    /// frame, and `solve` wants its right-hand side in natural units:
1834    /// `K̃ = E K F` with `v_scaled = F⁻¹ v_nat` gives
1835    /// `r_scaled = E r_nat`, so that caller divides by this before
1836    /// handing the residual over. Passing `r_scaled` straight in applies
1837    /// `E` twice and leaves a diagonally mis-scaled Newton direction.
1838    pub(crate) fn scaled_rhs_factor(&self) -> Option<&[Number]> {
1839        self.conj.as_ref().map(|c| c.e.as_slice())
1840    }
1841
1842    /// [`Self::offsets`], for the corrector's residual assembly, which
1843    /// writes one calculated-quantity block at a time.
1844    pub(crate) fn offsets_public(&self) -> [usize; 9] {
1845        self.offsets()
1846    }
1847
1848    /// [`Self::pack`], for the corrector, which builds a trial iterate
1849    /// from the flat point it is stepping.
1850    pub(crate) fn pack_public(&self, flat: &[Number]) -> Result<IteratesVectorMut, ()> {
1851        self.pack(flat)
1852    }
1853
1854    /// The converged iterate, flattened into the compound layout.
1855    ///
1856    /// The corrector steps a point rather than a step, so it needs the
1857    /// iterate the step is measured from, in the same layout the step
1858    /// arrives in.
1859    pub(crate) fn curr_flat(&self, out: &mut [Number]) -> Result<(), ()> {
1860        if out.len() != self.dim() {
1861            return Err(());
1862        }
1863        let curr = {
1864            let d = self.data.borrow();
1865            d.curr.clone().ok_or(())?
1866        };
1867        let off = self.offsets();
1868        let blocks: [&Rc<dyn pounce_linalg::vector::Vector>; 8] = [
1869            &curr.x, &curr.s, &curr.y_c, &curr.y_d, &curr.z_l, &curr.z_u, &curr.v_l, &curr.v_u,
1870        ];
1871        for (i, b) in blocks.iter().enumerate() {
1872            let vals = crate::vec_util::dense_to_vec(&***b);
1873            let (a, e) = (off[i], off[i + 1]);
1874            if vals.len() != e - a {
1875                return Err(());
1876            }
1877            out[a..e].copy_from_slice(&vals);
1878        }
1879        Ok(())
1880    }
1881
1882    /// Cumulative block offsets: `offset(i)` is the start index of
1883    /// block `i` in the flat slice.
1884    fn offsets(&self) -> [usize; 9] {
1885        let mut o = [0usize; 9];
1886        for i in 0..8 {
1887            o[i + 1] = o[i] + self.dims[i];
1888        }
1889        o
1890    }
1891
1892    /// Pack a flat slice into a freshly-allocated `IteratesVectorMut`
1893    /// shaped like the converged iterate.
1894    fn pack(&self, flat: &[Number]) -> Result<IteratesVectorMut, ()> {
1895        let mut out = self.template.make_new_zeroed();
1896        let off = self.offsets();
1897        let blocks: [&mut Box<dyn pounce_linalg::vector::Vector>; 8] = [
1898            &mut out.x,
1899            &mut out.s,
1900            &mut out.y_c,
1901            &mut out.y_d,
1902            &mut out.z_l,
1903            &mut out.z_u,
1904            &mut out.v_l,
1905            &mut out.v_u,
1906        ];
1907        for (i, blk) in blocks.into_iter().enumerate() {
1908            let slice = &flat[off[i]..off[i + 1]];
1909            let dv = blk.as_any_mut().downcast_mut::<DenseVector>().ok_or(())?;
1910            dv.set_values(slice);
1911        }
1912        Ok(out)
1913    }
1914
1915    /// Read an `IteratesVectorMut` into a flat slice. Uses
1916    /// [`DenseVector::expanded_values`] rather than `values()` so
1917    /// blocks that the IPM left in homogeneous-scalar form (typical
1918    /// for empty z_l/z_u/v_l/v_u when the TNLP has no bounds) are
1919    /// materialized rather than panicking.
1920    fn unpack(&self, iv: &IteratesVectorMut, out: &mut [Number]) -> Result<(), ()> {
1921        let off = self.offsets();
1922        let blocks: [&Box<dyn pounce_linalg::vector::Vector>; 8] = [
1923            &iv.x, &iv.s, &iv.y_c, &iv.y_d, &iv.z_l, &iv.z_u, &iv.v_l, &iv.v_u,
1924        ];
1925        for (i, blk) in blocks.into_iter().enumerate() {
1926            let dst = &mut out[off[i]..off[i + 1]];
1927            if dst.is_empty() {
1928                continue;
1929            }
1930            let dv = (**blk).as_any().downcast_ref::<DenseVector>().ok_or(())?;
1931            let ev = dv.expanded_values();
1932            dst.copy_from_slice(&ev);
1933        }
1934        Ok(())
1935    }
1936}
1937
1938impl PdSensBacksolver {
1939    /// Batched-RHS back-solve over the held factor. `rhs_flat` and
1940    /// `lhs_flat` are row-major `(n_rhs, dim)` buffers. Equivalent to
1941    /// looping [`SensBacksolver::solve`] over each row but reuses one
1942    /// frozen `IteratesVector` for the RHS and one `IteratesVectorMut`
1943    /// for the result across all `n_rhs` calls into
1944    /// [`PdFullSpaceSolver::solve`]. The pack step writes into the
1945    /// existing `DenseVector` storage via `Rc::get_mut` +
1946    /// `set_values`, and the unpack step reads it back via `values()`
1947    /// /`scalar()` — skipping the per-call 8-block `make_new_zeroed`
1948    /// (Box alloc) in `pack` and the per-block `expanded_values()` Vec
1949    /// alloc in `unpack` that otherwise dominate the held-factor
1950    /// back-solve cost under `jax.jacrev` over a JaxProblem solve
1951    /// (pounce#77 follow-up).
1952    ///
1953    /// The matrix and perturbation state inside `PdFullSpaceSolver`
1954    /// are unchanged across calls, so each iteration hits the cached
1955    /// fast path in `solve_once` (`uptodate && !pretend_singular`).
1956    ///
1957    /// Like [`SensBacksolver::solve`], results are in **natural
1958    /// (unscaled) units** — see [`Self::solve_many_scaled_space`] for
1959    /// the raw solver-space back-solve.
1960    pub fn solve_many(&self, rhs_flat: &[Number], lhs_flat: &mut [Number], n_rhs: usize) -> bool {
1961        match &self.conj {
1962            None => self.solve_many_scaled_space(rhs_flat, lhs_flat, n_rhs),
1963            Some(c) => {
1964                let total = self.dim();
1965                if rhs_flat.len() != n_rhs * total || lhs_flat.len() != n_rhs * total {
1966                    return false;
1967                }
1968                let mut rhs_scaled = rhs_flat.to_vec();
1969                for row in rhs_scaled.chunks_mut(total) {
1970                    for (r, &ei) in row.iter_mut().zip(c.e.iter()) {
1971                        *r *= ei;
1972                    }
1973                }
1974                if !self.solve_many_scaled_space(&rhs_scaled, lhs_flat, n_rhs) {
1975                    return false;
1976                }
1977                for row in lhs_flat.chunks_mut(total) {
1978                    for (l, &fi) in row.iter_mut().zip(c.f.iter()) {
1979                        *l *= fi;
1980                    }
1981                }
1982                true
1983            }
1984        }
1985    }
1986
1987    /// Batched-RHS back-solve against the held factor in the solver's
1988    /// internal **scaled** space (no natural-units conjugation). Same
1989    /// buffer contract as [`Self::solve_many`].
1990    pub fn solve_many_scaled_space(
1991        &self,
1992        rhs_flat: &[Number],
1993        lhs_flat: &mut [Number],
1994        n_rhs: usize,
1995    ) -> bool {
1996        let total = self.dim();
1997        if rhs_flat.len() != n_rhs * total || lhs_flat.len() != n_rhs * total {
1998            return false;
1999        }
2000        if n_rhs == 0 {
2001            return true;
2002        }
2003        let off = self.offsets();
2004
2005        // Both cached tiers below assemble their elimination from the
2006        // *calculated* `Σ` and slacks, and fire against whatever factor
2007        // the last solve left behind. Neither is the right system when
2008        // the held iterate came from crossover and the declared-frame
2009        // diagonal is in force (gh#654), and the tag check cannot be
2010        // relied on to decline: on the first call after convergence the
2011        // cached tags are the algorithm's own final solve, which used
2012        // exactly the diagonal being corrected. So skip them outright
2013        // and take the per-RHS path, which carries the override. That
2014        // costs the batched path its inlining on a crossed-over solve —
2015        // one factorization, then a back-substitution per RHS, against
2016        // the tag cache that the shared override vector keeps warm.
2017        let sigma = self.sigma_override();
2018        // Refined, for the reason spelled out in `solve_scaled_space`:
2019        // one shot, no outer loop.
2020        let allow_inexact = !self.may_refine();
2021        // Any override at all, not just crossover's: gh#737's ceiling
2022        // is a second one, it fires with `declared` empty, and these
2023        // tiers fold the bound rows in from the *calculated* `z / s`
2024        // against whatever factor was left behind — which on the first
2025        // call after convergence is the algorithm's own uncapped one.
2026        // Same predicate, same reason, as `may_refine` (gh#828).
2027        let corrected = !self.may_refine();
2028
2029        // Tier 1: fully-inline flat-slice path. `PdFullSpaceSolver::
2030        // solve_many_cached_flat` downcasts the slack / z / v vectors to
2031        // `DenseVector` and the bound-expansion matrices to
2032        // `ExpansionMatrix` once at the top, then runs Phase 1 / Phase 3
2033        // as raw scatter-add / divide loops on flat slices with no dyn
2034        // dispatch in the per-RHS inner loops. Returns `None` if a
2035        // downcast fails (homogeneous-on-non-empty block, unusual matrix
2036        // type) — we fall to Tier 2.
2037        if !corrected {
2038            let mut pd_ref = self.pd.borrow_mut();
2039            let fast_flat = pd_ref.solve_many_cached_flat(
2040                &self.data, &self.cq, &self.nlp, n_rhs, rhs_flat, lhs_flat, self.dims,
2041            );
2042            match fast_flat {
2043                Some(true) => return true,
2044                Some(false) => return false,
2045                None => { /* fall through to Tier 2 */ }
2046            }
2047        }
2048
2049        // Tier 2: closure-based cached-factor path. Same single
2050        // back-substitution through the linsol, but Phase 1 / Phase 3
2051        // go through `dyn Vector` / `dyn Matrix` ops on a per-RHS
2052        // `IteratesVectorMut`. Slower than Tier 1 but correct for
2053        // homogeneous DenseVectors and non-`ExpansionMatrix` bound
2054        // expansions.
2055        if !corrected {
2056            let mut pd_ref = self.pd.borrow_mut();
2057            let fast = pd_ref.solve_many_cached(
2058                &self.data,
2059                &self.cq,
2060                &self.nlp,
2061                n_rhs,
2062                |k, iv| {
2063                    let row = &rhs_flat[k * total..(k + 1) * total];
2064                    let _ = write_rhs_box(&mut iv.x, &row[off[0]..off[1]])
2065                        && write_rhs_box(&mut iv.s, &row[off[1]..off[2]])
2066                        && write_rhs_box(&mut iv.y_c, &row[off[2]..off[3]])
2067                        && write_rhs_box(&mut iv.y_d, &row[off[3]..off[4]])
2068                        && write_rhs_box(&mut iv.z_l, &row[off[4]..off[5]])
2069                        && write_rhs_box(&mut iv.z_u, &row[off[5]..off[6]])
2070                        && write_rhs_box(&mut iv.v_l, &row[off[6]..off[7]])
2071                        && write_rhs_box(&mut iv.v_u, &row[off[7]..off[8]]);
2072                },
2073                |k, iv| {
2074                    let row = &mut lhs_flat[k * total..(k + 1) * total];
2075                    let _ = read_res_block(&*iv.x, &mut row[off[0]..off[1]])
2076                        && read_res_block(&*iv.s, &mut row[off[1]..off[2]])
2077                        && read_res_block(&*iv.y_c, &mut row[off[2]..off[3]])
2078                        && read_res_block(&*iv.y_d, &mut row[off[3]..off[4]])
2079                        && read_res_block(&*iv.z_l, &mut row[off[4]..off[5]])
2080                        && read_res_block(&*iv.z_u, &mut row[off[5]..off[6]])
2081                        && read_res_block(&*iv.v_l, &mut row[off[6]..off[7]])
2082                        && read_res_block(&*iv.v_u, &mut row[off[7]..off[8]]);
2083                },
2084            );
2085            match fast {
2086                Some(true) => return true,
2087                Some(false) => return false,
2088                None => { /* fall through to per-RHS loop */ }
2089            }
2090        }
2091
2092        // Per-RHS fallback: reuse one frozen rhs and one mut sol across
2093        // all n_rhs `solve` calls.
2094        let rhs_mut0 = self.template.make_new_zeroed();
2095        let mut rhs_iv = rhs_mut0.freeze();
2096        let mut res_iv = self.template.make_new_zeroed();
2097
2098        let mut pd_ref = self.pd.borrow_mut();
2099        for k in 0..n_rhs {
2100            let rhs_row = &rhs_flat[k * total..(k + 1) * total];
2101            let lhs_row = &mut lhs_flat[k * total..(k + 1) * total];
2102
2103            if !write_rhs_block(&mut rhs_iv.x, &rhs_row[off[0]..off[1]])
2104                || !write_rhs_block(&mut rhs_iv.s, &rhs_row[off[1]..off[2]])
2105                || !write_rhs_block(&mut rhs_iv.y_c, &rhs_row[off[2]..off[3]])
2106                || !write_rhs_block(&mut rhs_iv.y_d, &rhs_row[off[3]..off[4]])
2107                || !write_rhs_block(&mut rhs_iv.z_l, &rhs_row[off[4]..off[5]])
2108                || !write_rhs_block(&mut rhs_iv.z_u, &rhs_row[off[5]..off[6]])
2109                || !write_rhs_block(&mut rhs_iv.v_l, &rhs_row[off[6]..off[7]])
2110                || !write_rhs_block(&mut rhs_iv.v_u, &rhs_row[off[7]..off[8]])
2111            {
2112                return false;
2113            }
2114
2115            let ok = pd_ref.solve_with_sigma(
2116                &self.data,
2117                &self.cq,
2118                &self.nlp,
2119                1.0,
2120                0.0,
2121                &rhs_iv,
2122                &mut res_iv,
2123                allow_inexact,
2124                /* improve_solution = */ false,
2125                sigma.clone(),
2126            );
2127            if !ok {
2128                return false;
2129            }
2130
2131            if !read_res_block(&*res_iv.x, &mut lhs_row[off[0]..off[1]])
2132                || !read_res_block(&*res_iv.s, &mut lhs_row[off[1]..off[2]])
2133                || !read_res_block(&*res_iv.y_c, &mut lhs_row[off[2]..off[3]])
2134                || !read_res_block(&*res_iv.y_d, &mut lhs_row[off[3]..off[4]])
2135                || !read_res_block(&*res_iv.z_l, &mut lhs_row[off[4]..off[5]])
2136                || !read_res_block(&*res_iv.z_u, &mut lhs_row[off[5]..off[6]])
2137                || !read_res_block(&*res_iv.v_l, &mut lhs_row[off[6]..off[7]])
2138                || !read_res_block(&*res_iv.v_u, &mut lhs_row[off[7]..off[8]])
2139            {
2140                return false;
2141            }
2142
2143            if !self.rescale_bound_multipliers(lhs_row, &self.sigma.ratio_x, &self.sigma.ratio_s) {
2144                return false;
2145            }
2146        }
2147        true
2148    }
2149}
2150
2151/// Headroom on the representability half of [`declared_slack_floor`],
2152/// matching `ipopt_cq`'s `SIGMA_OVERFLOW_HEADROOM` (gh#655) because it
2153/// bounds the same quantity for the same reason: `Σ_x` sums a lower and
2154/// an upper ratio into one diagonal entry, so bounding each by `MAX/4`
2155/// bounds the sum by `MAX/2` with room for the rounding in the divide.
2156const SIGMA_OVERFLOW_HEADROOM: Number = 4.0;
2157
2158/// The smallest offset from a bound that the declared-frame slack is
2159/// allowed to be: the larger of what double precision tells apart from
2160/// the bound itself, and what keeps `Σ = z/s` inside the double range.
2161///
2162/// A crossed-over point is *on* its active bounds, so its declared-frame
2163/// slack is the residual of the QP step and the line search — measured
2164/// around `1.8e-12` (gh#653), comfortably above both, so this does
2165/// nothing on the ordinary path. Each half covers a different corner:
2166///
2167/// * **`eps·max(1,|bound|)`** — a pivot landing on the bound exactly.
2168///   `Σ = z/0` is not a stiffer pin, it is a `NaN` in the KKT matrix.
2169///   Flooring says the honest thing instead: below this distance the
2170///   point *is* the bound, and the slack carries no further information
2171///   about how far inside it sits.
2172/// * **`z_max/(f64::MAX/4)`** — a multiplier large enough that `z/s`
2173///   leaves the double range even at a slack this frame considers
2174///   resolvable. This is gh#655's floor, which the live path gained in
2175///   `CalculateSafeSlack`; the declared frame does not go through that
2176///   function, so without carrying the bound here explicitly the
2177///   guarantee would stop at the frame boundary. It takes `max_i z_i`
2178///   over the block rather than each bound's own `z`, matching gh#655
2179///   and conservative in the harmless direction — raising a slack only
2180///   lowers `Σ`. A non-finite `z` leaves the floor alone; the iterate
2181///   finiteness checks own that case.
2182///
2183/// What is deliberately *not* borrowed is the rest of
2184/// `CalculateSafeSlack`: it raises a below-floor slack to
2185/// `max(μ/z, s_min)`, i.e. straight back to the `μ/z` standoff crossover
2186/// exists to remove (the same reason gh#646 declined it for the residual
2187/// report). Only the representability bound crosses over, because that
2188/// one is about what a double can hold, not about where the barrier
2189/// would have put the point.
2190fn declared_slack_floor(bound: Number, z_max: Number) -> Number {
2191    let resolvable = Number::EPSILON * bound.abs().max(1.0);
2192    if z_max.is_finite() && z_max > 0.0 {
2193        // Divide before multiplying so a `z_max` near `f64::MAX` cannot
2194        // overflow the floor itself.
2195        resolvable.max(z_max / (Number::MAX / SIGMA_OVERFLOW_HEADROOM))
2196    } else {
2197        resolvable
2198    }
2199}
2200
2201/// Headroom on [`sigma_pin_caps`]' representability bound: how far
2202/// above one ulp the surviving Schur contribution `a²/Σ` is required to
2203/// stay.
2204///
2205/// The bound itself is where that contribution reaches exactly one ulp
2206/// of unity, which is the edge rather than a safe distance from it, and
2207/// the edge is where measurement puts the failure: on the gh#737
2208/// fixture `Σ = 3.6e22` still returns the exact step and `6.9e27`
2209/// returns none of it, while the issue's own bracket runs from `7.1e14`
2210/// correct to `3.4e23` zero. Backing off is nearly free — the ceiling
2211/// only ever binds on an entry that would have been capped anyway, and
2212/// dropping it from `1/eps` to `1/(64·eps)` moves the pin's residual
2213/// leak from `2e-16` to `1.4e-14`, still an order under the roundoff a
2214/// back-solve on any real model carries. The cost of the other
2215/// direction is the defect surviving between the ceiling and the
2216/// failure.
2217const SIGMA_PIN_HEADROOM: Number = 64.0;
2218
2219/// The largest barrier diagonal each variable may carry and still be
2220/// reachable through the constraint rows it appears in, one entry per
2221/// var-x row; `INFINITY` for a variable in no constraint row, and an
2222/// all-`INFINITY` vector when the Jacobians are not triplet matrices
2223/// and the column magnitudes cannot be read.
2224///
2225/// # What the ceiling is
2226///
2227/// `Σ_i` sits on the diagonal of KKT row `i`, alongside that variable's
2228/// Jacobian entries `a_ji` in the constraint columns. Eliminating the
2229/// variable through its own diagonal leaves each constraint row `j`
2230/// holding `a_ji²/Σ_i` — the whole of what row `j` still knows about
2231/// variable `i`. Once that quantity falls below the roundoff of the
2232/// row it lands in, the constraint is no longer represented: the
2233/// factorization sees a row it cannot pivot on, and what comes back is
2234/// whatever the singularity handling substitutes.
2235///
2236/// Requiring `a²/Σ` to stay at or above one ulp is therefore the
2237/// ceiling `Σ_i ≤ a_i²/eps`, with `a_i` the largest of the variable's
2238/// constraint coefficients, and [`SIGMA_PIN_HEADROOM`] backing off from
2239/// the edge. The quadratic form is the scale-invariant one: a change of
2240/// variables `x_i → c·x_i` sends `Σ_i → Σ_i/c²` and `a_ji → a_ji/c`, so
2241/// the ceiling tracks the diagonal it bounds. Both quantities are read
2242/// in the solver's own scaled space, which is the space the factor
2243/// lives in.
2244///
2245/// # What it is not
2246///
2247/// It is not a release. A capped bound is still a bound, and still
2248/// pinned about as hard as double precision expresses: at the ceiling
2249/// the variable moves by `eps·SIGMA_PIN_HEADROOM/a²` per unit of force,
2250/// which for a constraint coefficient of order one is roundoff.
2251/// Zeroing the entry
2252/// instead would let a genuinely held variable off its bound entirely
2253/// and answer a different question. The rule is only that a pin the
2254/// matrix cannot represent is not a stiffer pin — the same argument
2255/// [`declared_slack_floor`] makes one step further down, where `z/0` is
2256/// not an infinitely stiff pin but a `NaN`.
2257///
2258/// A variable in no constraint row is left alone: there is no row for
2259/// its diagonal to swamp, and that is the gh#653 / gh#654 case, where a
2260/// bound-pinned variable coupled to the rest of the model only through
2261/// the Hessian wants every digit of stiffness it has.
2262fn sigma_pin_caps(cq: &IpoptCqHandle, n_x: usize) -> Vec<Number> {
2263    use pounce_linalg::triplet::GenTMatrix;
2264
2265    let mut a_max: Vec<Number> = vec![0.0; n_x];
2266    let (jac_c, jac_d) = {
2267        let c = cq.borrow();
2268        (c.curr_jac_c(), c.curr_jac_d())
2269    };
2270    for jac in [jac_c, jac_d] {
2271        let Some(t) = jac.as_any().downcast_ref::<GenTMatrix>() else {
2272            return vec![Number::INFINITY; n_x];
2273        };
2274        for (&col, &v) in t.jcols().iter().zip(t.values().iter()) {
2275            let j = (col - 1) as usize;
2276            if let Some(slot) = a_max.get_mut(j) {
2277                *slot = slot.max(v.abs());
2278            }
2279        }
2280    }
2281    a_max
2282        .into_iter()
2283        .map(|a| {
2284            if a > 0.0 && a.is_finite() {
2285                sigma_pin_cap(a)
2286            } else {
2287                Number::INFINITY
2288            }
2289        })
2290        .collect()
2291}
2292
2293/// [`sigma_pin_caps`]' ceiling for one coefficient magnitude, `a²/eps`
2294/// backed off by [`SIGMA_PIN_HEADROOM`].
2295///
2296/// Grouped as two divides so a large `a` overflows to `INFINITY` — no
2297/// ceiling, which is right, since no representable `Σ` reaches it — in
2298/// place of a spurious finite product. The floor at the other end is
2299/// the one that matters: for a coefficient small enough that the
2300/// ceiling underflows, *no* positive `Σ` keeps that coefficient
2301/// representable, and a ceiling at or below `1` would not be a looser
2302/// pin but a released bound. There is nothing to buy there, so those
2303/// report no ceiling too and leave the diagonal as it stands.
2304fn sigma_pin_cap(a: Number) -> Number {
2305    let cap = (a / Number::EPSILON) * (a / SIGMA_PIN_HEADROOM);
2306    if cap > 1.0 { cap } else { Number::INFINITY }
2307}
2308
2309/// One diagonal entry after a step brings a bound onto its variable:
2310/// the entry it had, plus the newly active bound's contribution, held
2311/// under that variable's ceiling.
2312///
2313/// The corrector's pinned contribution (gh#733) is `mu / s²` off the
2314/// slack the *endpoint* has, landing on a variable the corrector has
2315/// just decided sits on a bound -- gh#737's own case, reached through
2316/// a second door. The ceiling is a property of the entry rather than
2317/// of where the entry came from, so it applies to the sum and not to
2318/// the addend: two contributions that are individually representable
2319/// can still swamp the row together.
2320///
2321/// # How far the addend can actually reach
2322///
2323/// `pinned_rows` itself bounds that slack from below by nothing beyond
2324/// `> 0`, but the caller does: `correct_step` clamps the iterate to
2325/// `margin = 1e-10 * (1 + |base_i|)` inside each bound *before*
2326/// measuring it. So the addend is bounded after all, at
2327/// `mu / margin²`, and at a converged `mu` of `1e-9` on a variable of
2328/// order one that is `2.5e10` -- three orders under the `7.0e13` a
2329/// unit Jacobian coefficient allows.
2330///
2331/// The ceiling therefore binds on this path only where the coefficient
2332/// is small enough to bring it down to meet the addend, below
2333/// `sqrt(mu · eps · SIGMA_PIN_HEADROOM) / margin`, about `2e-2` at
2334/// that `mu`. That is measured, not deduced: on PR #738 the corrector
2335/// was driven with pinned coefficients of `1e-3` and `1e-4` and two of
2336/// twelve cases moved -- an iteration count of 1 against 12 at
2337/// identical residuals, and a residual differing in the fifth digit.
2338/// Live, and far too small to assert on.
2339///
2340/// So this clamp is prophylaxis rather than a fix for a reachable
2341/// failure: what makes the corrector's door narrow is a `1e-10` margin
2342/// in a different file, which no rule keeps in step with this ceiling.
2343/// The ceiling costs nothing to hold here and does not depend on that
2344/// margin staying where it is.
2345fn pinned_entry(had: Number, add: Number, cap: Number) -> Number {
2346    (had + add).min(cap)
2347}
2348
2349/// How much of each entry of `sigma` survives `cap` — `min(Σ, cap)/Σ`,
2350/// and exactly `1.0` wherever the ceiling does not bind (gh#828).
2351///
2352/// [`cap_sigma`] builds the *operator's* diagonal; this builds the
2353/// factor the bound-multiplier rows have to be read back through, so
2354/// the two describe the same system. Non-positive and non-finite
2355/// entries get `1.0`: there is no ratio to take, and the capping loop
2356/// leaves them alone too.
2357fn cap_ratio(sigma: &Rc<dyn pounce_linalg::Vector>, cap: &dyn Fn(usize) -> Number) -> Vec<Number> {
2358    let Some(dv) = sigma.as_any().downcast_ref::<DenseVector>() else {
2359        return Vec::new();
2360    };
2361    let base = dv.expanded_values();
2362    let used: Vec<Number> = base
2363        .iter()
2364        .enumerate()
2365        .map(|(i, &v)| {
2366            let c = cap(i);
2367            if v > c { c } else { v }
2368        })
2369        .collect();
2370    surviving_fraction(&base, &used)
2371}
2372
2373/// `used / base` entrywise, and `1.0` wherever the two agree or the
2374/// base carries no ratio to take (gh#828). The shared kernel behind
2375/// [`cap_ratio`] and [`CorrectorOperator::ratio_x`].
2376fn surviving_fraction(base: &[Number], used: &[Number]) -> Vec<Number> {
2377    base.iter()
2378        .zip(used)
2379        .map(|(&b, &u)| {
2380            if u < b && b > 0.0 && b.is_finite() {
2381                u / b
2382            } else {
2383                1.0
2384            }
2385        })
2386        .collect()
2387}
2388
2389/// `min(Σ, cap)` entrywise, or the input unchanged (and `None`) when no
2390/// entry is over its ceiling.
2391///
2392/// Returning `None` rather than an equal copy is what keeps an ordinary
2393/// solve factoring against the object the calculated quantities already
2394/// cache: the factorization cache keys on the `Σ` object's tag, and a
2395/// fresh vector per construction would cost a re-factorization for a
2396/// diagonal that is bit-identical.
2397fn cap_sigma(
2398    sigma: &Rc<dyn pounce_linalg::Vector>,
2399    cap: &dyn Fn(usize) -> Number,
2400) -> Option<Rc<dyn pounce_linalg::Vector>> {
2401    use pounce_linalg::dense_vector::DenseVectorSpace;
2402
2403    let dv = sigma.as_any().downcast_ref::<DenseVector>()?;
2404    let mut vals = dv.expanded_values();
2405    let mut hit = false;
2406    for (i, v) in vals.iter_mut().enumerate() {
2407        let c = cap(i);
2408        if *v > c {
2409            *v = c;
2410            hit = true;
2411        }
2412    }
2413    if !hit {
2414        return None;
2415    }
2416    let mut out = DenseVector::new(DenseVectorSpace::new(vals.len() as Index));
2417    out.values_mut().copy_from_slice(&vals);
2418    Some(Rc::new(out) as Rc<dyn pounce_linalg::Vector>)
2419}
2420
2421/// `Σ = Σ_l z_l/s_l + Σ_u z_u/s_u` for one primal block, with the slacks
2422/// taken against `b_l` / `b_u` instead of the NLP's live (relaxed)
2423/// bounds. Returns the diagonal plus the two slack vectors it was built
2424/// from, compressed in the `p_l` / `p_u` spaces.
2425///
2426/// Mirrors `IpoptCalculatedQuantities`' own `curr_sigma_x` /
2427/// `curr_sigma_s` — the same `Pᵀx − b` slack and the same
2428/// `add_m_sinv_z` accumulation — so the only difference between this and
2429/// the cached value is which bounds it measured against, and the floor
2430/// above in place of the safe-slack correction.
2431#[allow(clippy::too_many_arguments)]
2432fn declared_frame_sigma(
2433    p_l: &dyn pounce_linalg::Matrix,
2434    p_u: &dyn pounce_linalg::Matrix,
2435    primal: &dyn pounce_linalg::Vector,
2436    b_l: &[Number],
2437    b_u: &[Number],
2438    z_l: &dyn pounce_linalg::Vector,
2439    z_u: &dyn pounce_linalg::Vector,
2440    n: usize,
2441) -> (Rc<dyn pounce_linalg::Vector>, Vec<Number>, Vec<Number>) {
2442    use pounce_linalg::Vector;
2443    use pounce_linalg::dense_vector::DenseVectorSpace;
2444
2445    // One scalar over both sides of the block, as gh#655 does: the two
2446    // ratios land in the same `Σ` entry, so the bound that keeps their
2447    // sum representable has to be taken over both.
2448    let z_max = z_l.amax().max(z_u.amax());
2449
2450    // `lower`: s = Pᵀx − b_l. Otherwise: s = b_u − Pᵀx.
2451    let slack = |p: &dyn pounce_linalg::Matrix, b: &[Number], lower: bool| -> DenseVector {
2452        let mut v = DenseVector::new(DenseVectorSpace::new(b.len() as Index));
2453        if !b.is_empty() {
2454            v.values_mut().copy_from_slice(b);
2455        }
2456        let (alpha, beta) = if lower { (1.0, -1.0) } else { (-1.0, 1.0) };
2457        p.trans_mult_vector(alpha, primal, beta, &mut v);
2458        for (s, &bi) in v.values_mut().iter_mut().zip(b.iter()) {
2459            *s = s.max(declared_slack_floor(bi, z_max));
2460        }
2461        v
2462    };
2463    let s_l = slack(p_l, b_l, true);
2464    let s_u = slack(p_u, b_u, false);
2465
2466    let mut sigma = DenseVector::new(DenseVectorSpace::new(n as Index));
2467    sigma.set(0.0);
2468    p_l.add_m_sinv_z(1.0, &s_l, z_l, &mut sigma);
2469    p_u.add_m_sinv_z(1.0, &s_u, z_u, &mut sigma);
2470    (
2471        Rc::new(sigma) as Rc<dyn pounce_linalg::Vector>,
2472        s_l.expanded_values(),
2473        s_u.expanded_values(),
2474    )
2475}
2476
2477/// Write `slice` into the `DenseVector` behind `b` in place. Used by
2478/// the fast path's `write_rhs` closure, where the new
2479/// `PdFullSpaceSolver::solve_many_cached` API hands back an
2480/// `IteratesVectorMut` (Box-backed blocks).
2481fn write_rhs_box(b: &mut Box<dyn pounce_linalg::vector::Vector>, slice: &[Number]) -> bool {
2482    if slice.is_empty() {
2483        return true;
2484    }
2485    let Some(dv) = b.as_any_mut().downcast_mut::<DenseVector>() else {
2486        return false;
2487    };
2488    dv.set_values(slice);
2489    true
2490}
2491
2492/// Write `slice` into the `DenseVector` behind `rc` in place. Returns
2493/// `false` if the Rc is unexpectedly shared (would indicate a bug in
2494/// `PdFullSpaceSolver::solve`'s borrow discipline — it should never
2495/// `Rc::clone` from the rhs vector) or if the block is not a
2496/// `DenseVector`.
2497fn write_rhs_block(rc: &mut Rc<dyn pounce_linalg::vector::Vector>, slice: &[Number]) -> bool {
2498    if slice.is_empty() {
2499        return true;
2500    }
2501    let Some(v) = Rc::get_mut(rc) else {
2502        return false;
2503    };
2504    let Some(dv) = v.as_any_mut().downcast_mut::<DenseVector>() else {
2505        return false;
2506    };
2507    dv.set_values(slice);
2508    true
2509}
2510
2511/// Read the `DenseVector` behind `blk` into `dst`. Handles the
2512/// homogeneous case (empty z/v blocks for a TNLP with no bounds) by
2513/// broadcasting the scalar rather than calling `expanded_values()`,
2514/// which would allocate a fresh `Vec<Number>` every call.
2515fn read_res_block(blk: &dyn pounce_linalg::vector::Vector, dst: &mut [Number]) -> bool {
2516    if dst.is_empty() {
2517        return true;
2518    }
2519    let Some(dv) = blk.as_any().downcast_ref::<DenseVector>() else {
2520        return false;
2521    };
2522    if dv.is_homogeneous() {
2523        let s = dv.scalar();
2524        for x in dst.iter_mut() {
2525            *x = s;
2526        }
2527    } else {
2528        dst.copy_from_slice(dv.values());
2529    }
2530    true
2531}
2532
2533impl PdSensBacksolver {
2534    /// Single-RHS back-solve against the held factor in the solver's
2535    /// internal **scaled** space (no natural-units conjugation). This
2536    /// is the value [`SensBacksolver::solve`] returned before
2537    /// pounce#128; kept for callers that want the raw factor.
2538    pub fn solve_scaled_space(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
2539        let total = self.dim();
2540        if rhs.len() != total || lhs.len() != total {
2541            return false;
2542        }
2543        // Pack rhs into block form.
2544        let rhs_mut = match self.pack(rhs) {
2545            Ok(v) => v,
2546            Err(()) => return false,
2547        };
2548        let rhs_iv = rhs_mut.freeze();
2549        // Fresh result slot, zeroed.
2550        let mut res_iv = self.template.make_new_zeroed();
2551
2552        // K · lhs = rhs   ⇒   solve(α=1, β=0, rhs, res) writes
2553        // res = K⁻¹ · rhs.
2554        //
2555        // `allow_inexact = false`: run `PdFullSpaceSolver`'s
2556        // iterative-refinement loop (`min_refinement_steps = 1`,
2557        // `residual_ratio_max = 1e-10`) on the held-factor back-solve
2558        // too, rather than accepting the first substitution the way
2559        // upstream sIPOPT's `SensSimpleBacksolver` does.
2560        //
2561        // It used to be `true`, on the argument that refinement is
2562        // there to clean up noise during *forward* IPM steps and that
2563        // the residual it removes here is below `tol` (pounce#77
2564        // follow-up, where the per-call cost showed up under
2565        // `jax.jacrev` over a batched `JaxProblem` solve). Two things
2566        // are wrong with that argument.
2567        //
2568        // The first is that "below `tol`" is not the property the
2569        // callers need. A sens back-solve has no outer loop to
2570        // self-correct — it is one shot, and its answer is read as a
2571        // *derivative*, not as a step. `pyomo-pounce`'s covariance /
2572        // information machinery then asks rank questions about blocks
2573        // of `K⁻¹`, and `np.linalg.matrix_rank` thresholds the
2574        // correlation-scaled block at `n · eps` — around `1e-15` for a
2575        // 2x2. Two coordinates that are exactly dependent (a
2576        // duplicated design point) produce two rows that agree to
2577        // whatever accuracy the back-solve has: at `1e-16` they read
2578        // as dependent and the caller gets its refusal, at `1e-12`
2579        // they read as independent and it silently gets a covariance
2580        // for a block that has none. Refinement is what buys those
2581        // digits.
2582        //
2583        // The second is that the premise had a hidden dependency. The
2584        // unrefined substitution was accurate enough only because the
2585        // *backend* was refining underneath (`feral_refine` defaulted
2586        // on); with MA57 — whose `icntl[9] = 0` disables its own
2587        // refinement — the hole was already open, and turning the feral
2588        // default off opened it for everyone on that path. "That path"
2589        // is the qualifier gh#909 added: the turn-off is scoped to
2590        // `hessian_approximation=limited-memory`, so on the exact
2591        // Hessian path the backend is still refining underneath. This
2592        // layer refines regardless, which is the point — it must not
2593        // depend on which path the base solve took.
2594        //
2595        // The cost the original comment was avoiding is not there to
2596        // avoid: measured on `kkt_solve_many` with 32 RHS against
2597        // held factors of dimension 120k/150k/300k (poisson,
2598        // optcontrol, sparseqp), refined vs unrefined is 0.136/0.120/
2599        // 0.217 s against 0.138/0.123/0.215 s — inside the noise. The
2600        // extra substitution runs against the cached matrix, so it
2601        // never refactorizes, and the pack/unpack around it dominates.
2602        let allow_inexact = !self.may_refine();
2603        let ok = {
2604            let mut pd_ref = self.pd.borrow_mut();
2605            pd_ref.solve_with_sigma(
2606                &self.data,
2607                &self.cq,
2608                &self.nlp,
2609                1.0,
2610                0.0,
2611                &rhs_iv,
2612                &mut res_iv,
2613                allow_inexact,
2614                /* improve_solution = */ false,
2615                // Identity on every ordinary solve; the declared-frame
2616                // diagonal when the held iterate came from crossover
2617                // (gh#654).
2618                self.sigma_override(),
2619            )
2620        };
2621        if !ok {
2622            return false;
2623        }
2624        if self.unpack(&res_iv, lhs).is_err() {
2625            return false;
2626        }
2627        self.rescale_bound_multipliers(lhs, &self.sigma.ratio_x, &self.sigma.ratio_s)
2628    }
2629}
2630
2631impl SensBacksolver for PdSensBacksolver {
2632    fn dim(&self) -> usize {
2633        self.dims.iter().sum()
2634    }
2635
2636    /// `F` itself, the vector [`Self::solve`] post-multiplies its
2637    /// result by, so a caller converting an iterate quantity uses the
2638    /// same numbers the back-solve used.
2639    fn natural_units_factor(&self) -> Option<&[Number]> {
2640        self.conj.as_ref().map(|c| c.f.as_slice())
2641    }
2642
2643    fn bound_rows(&self) -> Option<&[crate::backsolver::BoundRow]> {
2644        self.bound_vars.as_deref().map(|v| v.as_slice())
2645    }
2646
2647    fn supports_release(&self) -> bool {
2648        self.bound_vars.is_some()
2649    }
2650
2651    fn solve_released(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
2652        self.solve_released_inner(released, rhs, lhs, false)
2653    }
2654
2655    fn solve_released_step(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
2656        self.solve_released_inner(released, rhs, lhs, true)
2657    }
2658
2659    fn solve_released_pinned(
2660        &self,
2661        released: &[usize],
2662        pinned: &[usize],
2663        rhs: &[Number],
2664        lhs: &mut [Number],
2665    ) -> bool {
2666        self.bound_vars.is_some() && self.solve_released_pinned_inner(released, pinned, rhs, lhs)
2667    }
2668
2669    /// Solve `K · lhs = rhs` against the converged factor, in
2670    /// **natural (unscaled) units** (pounce#128): when the NLP carries
2671    /// active scaling (`nlp_scaling_method`, `obj_scaling_factor`,
2672    /// user scaling) the RHS is pre-multiplied by `E` and the result
2673    /// post-multiplied by `F` (see the `conj` field doc), so
2674    /// `lhs = K_natural⁻¹ rhs` for **all eight blocks** — including
2675    /// the z/v bound-multiplier rows. Use
2676    /// [`Self::solve_scaled_space`] for the raw factor.
2677    fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
2678        match &self.conj {
2679            None => self.solve_scaled_space(rhs, lhs),
2680            Some(c) => {
2681                let total = self.dim();
2682                if rhs.len() != total || lhs.len() != total {
2683                    return false;
2684                }
2685                let rhs_scaled: Vec<Number> =
2686                    rhs.iter().zip(c.e.iter()).map(|(&r, &ei)| r * ei).collect();
2687                if !self.solve_scaled_space(&rhs_scaled, lhs) {
2688                    return false;
2689                }
2690                for (l, &fi) in lhs.iter_mut().zip(c.f.iter()) {
2691                    *l *= fi;
2692                }
2693                true
2694            }
2695        }
2696    }
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701    use super::*;
2702    use pounce_linalg::dense_vector::DenseVectorSpace;
2703
2704    fn dense(vals: &[Number]) -> Rc<dyn pounce_linalg::Vector> {
2705        let mut v = DenseVector::new(DenseVectorSpace::new(vals.len() as Index));
2706        v.values_mut().copy_from_slice(vals);
2707        Rc::new(v) as Rc<dyn pounce_linalg::Vector>
2708    }
2709
2710    /// The ceiling is where the surviving Schur contribution `a²/Σ`
2711    /// reaches [`SIGMA_PIN_HEADROOM`] ulps, which is what the whole
2712    /// rule says it is.
2713    #[test]
2714    fn the_ceiling_leaves_the_schur_contribution_representable() {
2715        for a in [1.0, 3.5, 1e-3, 1e6] {
2716            let ratio = a * a / sigma_pin_cap(a);
2717            assert!(
2718                (ratio / (Number::EPSILON * SIGMA_PIN_HEADROOM) - 1.0).abs() < 1e-12,
2719                "a={a:e}: a²/cap = {ratio:e}, want {:e}",
2720                Number::EPSILON * SIGMA_PIN_HEADROOM,
2721            );
2722        }
2723    }
2724
2725    /// A change of variables `x → c·x` sends `Σ → Σ/c²` and `a → a/c`,
2726    /// so a diagonal that was over its ceiling has to still be over it
2727    /// afterwards, and one under it has to stay under. Anything keyed
2728    /// on `a` linearly instead of quadratically fails this.
2729    ///
2730    /// The range stops where the ceiling itself saturates — past
2731    /// `c = 1e3` here the rescaled coefficient is small enough that
2732    /// [`sigma_pin_cap`] reports no ceiling at all, which is the
2733    /// deliberate refusal documented there rather than a break in the
2734    /// invariance.
2735    #[test]
2736    fn the_ceiling_is_invariant_to_rescaling_the_variable() {
2737        for c in [1e-3, 1e-1, 1.0, 1e1, 1e3] {
2738            let over = 1e27 / (c * c) > sigma_pin_cap(1.0 / c);
2739            assert!(over, "c={c:e}: rescaling moved the entry under its ceiling");
2740            let under = 1e6 / (c * c) > sigma_pin_cap(1.0 / c);
2741            assert!(
2742                !under,
2743                "c={c:e}: rescaling moved the entry over its ceiling"
2744            );
2745        }
2746    }
2747
2748    /// Neither end of the coefficient range may produce a ceiling that
2749    /// caps something it should not. Huge: no representable `Σ` reaches
2750    /// the ceiling, so it is no ceiling. Tiny: the ceiling underflows,
2751    /// and a `Σ` capped at or below `1` is a released bound rather than
2752    /// a looser pin, so that is no ceiling either.
2753    #[test]
2754    fn a_ceiling_that_cannot_help_is_no_ceiling() {
2755        for a in [Number::MAX, 1e200, 1e-9, 1e-200, Number::MIN_POSITIVE] {
2756            let cap = sigma_pin_cap(a);
2757            assert!(
2758                cap.is_infinite() || cap > 1.0,
2759                "a={a:e} produced a ceiling of {cap:e}, which would release the bound",
2760            );
2761        }
2762    }
2763
2764    /// Nothing over its ceiling returns `None`, so an ordinary solve
2765    /// keeps factoring against the object the calculated quantities
2766    /// cache rather than an equal copy with a fresh tag.
2767    #[test]
2768    fn a_newly_pinned_bound_lands_under_the_ceiling_too() {
2769        // An addend over the ceiling is held at it, wherever it came
2770        // from.
2771        let cap = sigma_pin_cap(1.0);
2772        let add = 1e-9 / (1e-14 * 1e-14);
2773        assert!(add > cap, "the fixture needs an addend over the ceiling");
2774        assert_eq!(pinned_entry(1e6, add, cap), cap);
2775    }
2776
2777    /// Where the corrector's addend stands against the ceiling once
2778    /// `correct_step`'s own clamp is accounted for -- the reason this
2779    /// path is narrow, in the two numbers that make it narrow. Both
2780    /// live elsewhere (`corrector.rs` sets the margin, `pinned_rows`
2781    /// the form of the addend), so this reads as documentation until
2782    /// one of them moves, which is the point.
2783    #[test]
2784    fn the_correctors_clamp_is_what_keeps_its_addend_under_a_unit_ceiling() {
2785        let mu = 1e-9;
2786        // `correct_step`: margin = 1e-10 * (1 + |base|), base ~ 1.
2787        let margin = 1e-10 * 2.0;
2788        let most = mu / (margin * margin);
2789
2790        // At a unit coefficient the clamp already does it, three
2791        // orders clear, and `pinned_entry` is a pass-through.
2792        let unit = sigma_pin_cap(1.0);
2793        assert!(most < unit, "{most:e} should sit under {unit:e}");
2794        assert_eq!(pinned_entry(0.0, most, unit), most);
2795
2796        // The ceiling binds only once the coefficient brings it down
2797        // to meet the addend. #738 measured the corrector moving at
2798        // 1e-3 and 1e-4, and not at 1.
2799        assert!(sigma_pin_cap(1e-3) < most);
2800        assert_eq!(
2801            pinned_entry(0.0, most, sigma_pin_cap(1e-3)),
2802            sigma_pin_cap(1e-3)
2803        );
2804
2805        // The crossover between the two, which is what would move if
2806        // either the margin or the headroom were retuned.
2807        let crossover = (mu * Number::EPSILON * SIGMA_PIN_HEADROOM).sqrt() / margin;
2808        assert!(
2809            (1e-2..1e-1).contains(&crossover),
2810            "the corrector's door is this wide: {crossover:e}",
2811        );
2812    }
2813
2814    #[test]
2815    fn two_representable_contributions_can_swamp_a_row_together() {
2816        // Neither half is over the ceiling; their sum is. Capping the
2817        // addend alone would let this through, which is why the
2818        // ceiling is applied to the entry.
2819        let cap = sigma_pin_cap(1.0);
2820        let (had, add) = (0.7 * cap, 0.7 * cap);
2821        assert!(had < cap && add < cap && had + add > cap);
2822        assert_eq!(pinned_entry(had, add, cap), cap);
2823    }
2824
2825    #[test]
2826    fn a_pinned_entry_under_the_ceiling_is_just_the_sum() {
2827        let cap = sigma_pin_cap(1.0);
2828        assert_eq!(pinned_entry(2.0, 3.0, cap), 5.0);
2829        // A variable in no constraint row has no ceiling, so the
2830        // corrector's pin reaches the diagonal whole.
2831        assert_eq!(pinned_entry(2.0, 1e30, Number::INFINITY), 1e30 + 2.0);
2832    }
2833
2834    #[test]
2835    fn a_diagonal_under_its_ceiling_is_left_alone() {
2836        let sigma = dense(&[1.0, 1e6, 0.0, 1e12]);
2837        assert!(cap_sigma(&sigma, &|_| sigma_pin_cap(1.0)).is_none());
2838    }
2839
2840    /// Over the ceiling, only the offending entries move.
2841    #[test]
2842    fn only_the_entries_over_their_ceiling_move() {
2843        let cap = sigma_pin_cap(1.0);
2844        let sigma = dense(&[1.0, 1e27, 1e6, Number::INFINITY]);
2845        let capped = cap_sigma(&sigma, &|i| if i == 2 { Number::INFINITY } else { cap })
2846            .expect("two entries are over their ceiling");
2847        let got = capped
2848            .as_any()
2849            .downcast_ref::<DenseVector>()
2850            .expect("dense")
2851            .expanded_values();
2852        assert_eq!(got, vec![1.0, cap, 1e6, cap]);
2853    }
2854}