Skip to main content

pounce_sens_core/
converged.rs

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