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}