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