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