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}