pounce_algorithm/sqp/sqp_alg.rs
1//! `SqpAlgorithm` — active-set SQP outer loop. Consumes an
2//! `SqpProblemSpec` for evaluation; delegates the QP subproblem
3//! solve to `pounce_qp::ParametricActiveSetSolver`.
4//!
5//! Outer loop (Nocedal-Wright §18 standard SQP):
6//! 1. Evaluate `f, ∇f, c, ∇c, ∇²L` at `x_k`.
7//! 2. Build the QP via `SqpQpData::build`.
8//! 3. Solve the QP via `pounce-qp` (warm-started by the previous
9//! `WorkingSet` when available).
10//! 4. KKT-error check on `x_k` (before stepping) — if all
11//! component tolerances are met, declare optimal.
12//! 5. Globalization step acceptance via either the Fletcher-
13//! Leyffer 2002 filter (`SqpGlobalization::Filter`, default)
14//! or the Han-Powell l1-merit (`SqpGlobalization::L1Elastic`),
15//! both backtracking on α.
16//! 6. Take `α·p`; promote `(x_k + α p, λ_g, λ_x)` to the next
17//! iterate and carry the QP's `WorkingSet` for the next solve.
18
19use crate::sqp::bfgs::DampedBfgs;
20use crate::sqp::filter::{SqpFilter, filter_line_search};
21use crate::sqp::iterates::SqpIterates;
22use crate::sqp::lbfgs::LBfgs;
23use crate::sqp::line_search::l1_merit_line_search;
24use crate::sqp::options::{SqpGlobalization, SqpHessianSource, SqpOptions};
25use crate::sqp::problem::SqpProblemSpec;
26use crate::sqp::qp_assembly::{SqpQpData, Triplet};
27use crate::sqp::result::{SqpError, SqpResult, SqpStatus};
28use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, Number};
29use pounce_linalg::triplet::GenTMatrix;
30use pounce_qp::{
31 HessianInertia, ParametricActiveSetSolver, QpOptions, QpProblem, QpSolver, QpStatus, WorkingSet,
32};
33
34/// SQP-side algorithm driver.
35pub struct SqpAlgorithm {
36 qp_solver: ParametricActiveSetSolver,
37 qp_opts: QpOptions,
38 opts: SqpOptions,
39 iterates: Option<SqpIterates>,
40 /// Filter for Fletcher-Leyffer globalization; reset at the
41 /// top of each `optimize` call. Unused when
42 /// `opts.globalization = L1Elastic`.
43 filter: SqpFilter,
44}
45
46impl SqpAlgorithm {
47 pub fn new(qp_solver: ParametricActiveSetSolver, opts: SqpOptions) -> Self {
48 Self {
49 qp_solver,
50 qp_opts: QpOptions::default(),
51 opts,
52 iterates: None,
53 filter: SqpFilter::new(),
54 }
55 }
56
57 /// Override the per-call QP-solver options. Defaults are the
58 /// `pounce_qp::QpOptions::default()` (which include the
59 /// `use_schur_updates = false` and `anti_cycling = Expand`
60 /// from Phase 5a.2). Callers can pin tighter tolerances or
61 /// flip `use_schur_updates = true` for warm-started workloads.
62 pub fn with_qp_options(mut self, qp_opts: QpOptions) -> Self {
63 self.qp_opts = qp_opts;
64 self
65 }
66
67 pub fn options(&self) -> &SqpOptions {
68 &self.opts
69 }
70
71 pub fn iterates(&self) -> Option<&SqpIterates> {
72 self.iterates.as_ref()
73 }
74
75 /// Run the SQP loop to convergence (or `max_iter`). Cold-starts
76 /// the iterate from `nlp.x_init()` and an empty working set.
77 pub fn optimize<N: SqpProblemSpec>(&mut self, nlp: &mut N) -> Result<SqpResult, SqpError> {
78 self.optimize_with_warm_start(nlp, None)
79 }
80
81 /// Warm-start variant. `warm = Some(prev)` seeds the iterate
82 /// from `prev.{x, lambda_g, lambda_x, working}` instead of the
83 /// NLP's cold defaults. Dimensions are validated against the
84 /// problem; any mismatch is fatal. The QP solver consumes
85 /// `warm.working` (when present) via `solve_with_working_set`.
86 ///
87 /// `warm = None` is equivalent to [`Self::optimize`].
88 ///
89 /// Implements the §6 design-note warm-start contract: the
90 /// tuple `(x, λ_g, λ_x, 𝒲)`. The Hessian carry-forward
91 /// (damped-BFGS / L-BFGS state) is *not* part of the warm-start
92 /// payload — each `optimize` call rebuilds its own Hessian
93 /// approximation from scratch.
94 pub fn optimize_with_warm_start<N: SqpProblemSpec>(
95 &mut self,
96 nlp: &mut N,
97 warm: Option<SqpIterates>,
98 ) -> Result<SqpResult, SqpError> {
99 let n = nlp.n();
100 let m = nlp.m();
101 let (xl, xu) = nlp.variable_bounds();
102 let (bl_c, bu_c) = nlp.constraint_bounds();
103 if xl.len() != n || xu.len() != n {
104 return Err(SqpError::DimensionMismatch(format!(
105 "variable_bounds length must be n = {n}"
106 )));
107 }
108 if bl_c.len() != m || bu_c.len() != m {
109 return Err(SqpError::DimensionMismatch(format!(
110 "constraint_bounds length must be m = {m}"
111 )));
112 }
113
114 let mut iter = match warm {
115 Some(w) => {
116 if w.x.len() != n {
117 return Err(SqpError::DimensionMismatch(format!(
118 "warm.x length {} must equal n = {n}",
119 w.x.len()
120 )));
121 }
122 if w.lambda_g.len() != m {
123 return Err(SqpError::DimensionMismatch(format!(
124 "warm.lambda_g length {} must equal m = {m}",
125 w.lambda_g.len()
126 )));
127 }
128 if w.lambda_x.len() != n {
129 return Err(SqpError::DimensionMismatch(format!(
130 "warm.lambda_x length {} must equal n = {n}",
131 w.lambda_x.len()
132 )));
133 }
134 if let Some(ws) = w.working.as_ref() {
135 ws.validate_dims(n, m).map_err(SqpError::QpFailure)?;
136 }
137 w
138 }
139 None => {
140 let mut cold = SqpIterates::cold(n, m);
141 let x_init = nlp.x_init();
142 if x_init.len() != n {
143 return Err(SqpError::DimensionMismatch(format!(
144 "x_init length must be n = {n}"
145 )));
146 }
147 cold.x = x_init;
148 cold
149 }
150 };
151
152 let mut n_qp_solves: u32 = 0;
153 // Inner active-set work: adds + drops summed over every step QP
154 // solved in this call. This — not the outer iteration count — is
155 // what a warm start is trying to reduce, and on a QP-shaped NLP
156 // (one outer iteration by construction) it is the *only* thing
157 // that moves. Second-order-correction QPs are not counted: they
158 // are solved inside the line search, which does not surface its
159 // subproblem stats.
160 let mut n_qp_working_set_changes: u32 = 0;
161 let mut final_stationarity = 0.0;
162 let mut final_constr_viol = 0.0;
163 // l1-merit penalty parameter ν, adapted across iterations
164 // by `l1_merit_line_search`. Initialized from
165 // `SqpOptions::l1_penalty`.
166 let mut nu = self.opts.l1_penalty;
167 // Reset filter state at the top of each optimize call.
168 self.filter = SqpFilter::new();
169 // Cache the most recent f(x) and c(x) so we don't
170 // re-evaluate them after a successful line search (the
171 // LS already computed them at the new iterate).
172 let mut f_cached: Option<Number> = None;
173 let mut c_cached: Option<Vec<Number>> = None;
174 // Previous iterate's `(x, ∇f, ∇c)`, kept so the quasi-Newton
175 // curvature pair can difference `∇L` at a single fixed multiplier
176 // (see [`curvature_pair`]). Storing `∇L` directly — as the older
177 // `DampedBfgs::update(x, ∇L)` form did — bakes in the multiplier
178 // that was current at the time, which is precisely the bug.
179 let mut prev_point: Option<(Vec<Number>, Vec<Number>, Triplet)> = None;
180
181 // Damped-BFGS state, allocated only if needed. The
182 // matrix is updated at the END of each iteration (after
183 // we have x_new and the next ∇L), then queried at the
184 // TOP of the next iteration to populate the QP Hessian.
185 let mut bfgs: Option<DampedBfgs> =
186 if matches!(self.opts.hessian, SqpHessianSource::DampedBfgs) {
187 Some(DampedBfgs::new(n))
188 } else {
189 None
190 };
191 let mut lbfgs: Option<LBfgs> = if matches!(self.opts.hessian, SqpHessianSource::Lbfgs) {
192 Some(LBfgs::new(n, self.opts.lbfgs_max_history.max(1) as usize))
193 } else {
194 None
195 };
196
197 // Iteration-0 curvature probe (issue #358 tail).
198 //
199 // `DampedBfgs::update` sizes the identity seed from the first
200 // `(s, y)` pair — but that pair only exists at iteration 1, and
201 // iteration **0** already solves a QP against `B`. With `B = I`
202 // on a problem where `‖∇²L‖ ≫ 1`, that first step overshoots the
203 // Newton step by `~cond(∇²L)`; the filter (empty, and `θ` tiny at
204 // a near-feasible start) accepts the objective-blowing step, the
205 // iterate is flung to `‖x‖ ~ 1e3`, and the huge `(s, y)` pairs
206 // that follow drive `B` so ill-conditioned that the QP subproblem
207 // itself fails a few iterations later (`QpStepFailed`, surfacing
208 // as `Search_Direction_Becomes_Too_Small`).
209 //
210 // Fix the scale *before* that first QP with one extra gradient
211 // evaluation: step a short distance along the steepest-descent
212 // direction, difference the gradients, and seed `B = γI` with the
213 // resulting Rayleigh quotient `γ = sᵀy / sᵀs`. For a quadratic
214 // this is exactly the curvature along the probe direction, and it
215 // lies in `[λ_min(∇²L), λ_max(∇²L)]`.
216 //
217 // The probe differences the *objective* gradient, so it estimates
218 // `∇²f` — which equals the Lagrangian Hessian `∇²L = ∇²f + Σλᵢ∇²cᵢ`
219 // only when the constraint-curvature term vanishes, i.e. when every
220 // constraint is linear (or there are none). That condition is
221 // exactly the #358 family, and it is *not* cosmetic: on the Maratos
222 // problem (`min 2(x₁²+x₂²−1) − x₁ s.t. x₁²+x₂²=1`) `∇²f = 4I` while
223 // `∇²L ≈ I` at the solution, so seeding the objective curvature
224 // would over-scale `B` fourfold and cost that solve its convergence.
225 //
226 // Detect linearity directly rather than trusting a declaration:
227 // compare the constraint Jacobian at the probe point with the one
228 // at `x`. Identical ⇒ `∇c` is constant ⇒ constraints are linear ⇒
229 // the objective Hessian *is* the Lagrangian Hessian and the probe
230 // is exact. Otherwise leave the identity seed alone and let the
231 // rank-2 updates (which see the true `∇L`) do the work.
232 if let Some(b) = bfgs.as_mut() {
233 let g0 = nlp.eval_grad_f(&iter.x);
234 let g_norm = g0.iter().map(|v| v * v).sum::<Number>().sqrt();
235 if g_norm.is_finite() && g_norm > 0.0 {
236 // Absolute probe length, scaled by the iterate so the step
237 // is meaningful in the problem's own units but always tiny
238 // relative to it. `1e-7` keeps the gradient difference well
239 // above f64 roundoff without leaving the local model.
240 let x_scale = iter.x.iter().map(|v| v.abs()).fold(1.0, f64::max);
241 let eps = 1e-7 * x_scale;
242 let step: Vec<Number> = g0.iter().map(|gi| -eps * gi / g_norm).collect();
243 let x_probe: Vec<Number> =
244 iter.x.iter().zip(step.iter()).map(|(a, d)| a + d).collect();
245 // Constant-Jacobian (linear-constraint) check, per above.
246 let linear_constraints = m == 0 || {
247 let j0 = nlp.eval_jac_c(&iter.x);
248 let j1 = nlp.eval_jac_c(&x_probe);
249 j0.vals.len() == j1.vals.len()
250 && j0.vals.iter().zip(j1.vals.iter()).all(|(a, c)| {
251 // Relative comparison: a linear constraint
252 // reproduces its Jacobian bit-for-bit, so this
253 // only tolerates evaluation noise.
254 let scale = a.abs().max(c.abs()).max(1.0);
255 (a - c).abs() <= 1e-12 * scale
256 })
257 };
258 if linear_constraints {
259 let g1 = nlp.eval_grad_f(&x_probe);
260 let s_y: Number = step
261 .iter()
262 .zip(g1.iter().zip(g0.iter()))
263 .map(|(si, (a, bg))| si * (a - bg))
264 .sum();
265 let s_s: Number = step.iter().map(|v| v * v).sum();
266 if s_s > 0.0 && s_y.is_finite() {
267 // A non-positive quotient means the probe direction
268 // has non-positive curvature (nonconvex or
269 // numerically flat); `seed_scale` ignores it, leaving
270 // the identity seed rather than a meaningless or
271 // negative scale.
272 b.seed_scale(s_y / s_s);
273 }
274 }
275 }
276 }
277
278 for outer in 0..self.opts.max_iter {
279 let grad_f = nlp.eval_grad_f(&iter.x);
280 let c_vals = c_cached.take().unwrap_or_else(|| nlp.eval_c(&iter.x));
281 let f_curr = f_cached.take().unwrap_or_else(|| nlp.eval_f(&iter.x));
282 let jac_c = nlp.eval_jac_c(&iter.x);
283 let hess_lag = match self.opts.hessian {
284 SqpHessianSource::Exact => nlp.eval_hess_lag(&iter.x, &iter.lambda_g),
285 SqpHessianSource::DampedBfgs => {
286 let bfgs = bfgs.as_mut().expect("DampedBfgs state initialized above");
287 if let Some((s, y)) =
288 curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
289 {
290 bfgs.update_sy(&s, &y);
291 }
292 bfgs.as_triplet()
293 }
294 SqpHessianSource::Lbfgs => {
295 let lb = lbfgs.as_mut().expect("LBfgs state initialized above");
296 if let Some((s, y)) =
297 curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
298 {
299 lb.update_sy(&s, &y);
300 }
301 lb.as_triplet()
302 }
303 };
304
305 // Remember this iterate's `(x, ∇f, ∇c)` so the next
306 // iteration can build its curvature pair at a fixed
307 // multiplier. See `curvature_pair`.
308 prev_point = Some((iter.x.clone(), grad_f.clone(), jac_c.clone()));
309
310 // KKT check uses the current iterate's evaluations.
311 let kkt = check_kkt(
312 n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
313 );
314 final_stationarity = kkt.stationarity;
315 final_constr_viol = kkt.constr_viol;
316
317 #[cfg(test)]
318 if self.opts.print_level >= 1 {
319 tracing::debug!(target: "pounce::sqp",
320 "[sqp k={outer:3}] x={:?} f={:.4e} ‖c‖={:.2e} stat={:.2e} ν={:.2e}",
321 iter.x.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>(),
322 f_curr,
323 kkt.constr_viol,
324 kkt.stationarity,
325 nu,
326 );
327 }
328
329 // `sqp_tol` and `sqp_dual_inf_tol` are both registered and both
330 // documented as a max-norm tolerance on the stationarity
331 // residual, but only `dual_inf_tol` was ever read — `opts.tol`
332 // (default 1e-8) was dead, so the loose 1e-4 governed alone and
333 // silently capped attainable accuracy (max x-error `7e-5` on the
334 // #358 sweep). Honor both by requiring the tighter, which is the
335 // only reading under which neither option is a no-op. Restores
336 // `~5e-9` worst-case accuracy for ~10% more iterations. Same
337 // registered-but-inert defect family as gh #360.
338 let stationarity_tol = self.opts.tol.min(self.opts.dual_inf_tol);
339 if kkt.stationarity <= stationarity_tol && kkt.constr_viol <= self.opts.constr_viol_tol
340 {
341 self.iterates = Some(iter.clone());
342 return Ok(SqpResult {
343 x: iter.x,
344 lambda_g: iter.lambda_g,
345 lambda_x: iter.lambda_x,
346 obj: f_curr,
347 status: SqpStatus::Optimal,
348 n_iter: outer,
349 n_qp_solves,
350 n_qp_working_set_changes,
351 final_stationarity,
352 final_constr_viol,
353 working_set: iter.working,
354 });
355 }
356
357 let qp_data = SqpQpData::build(
358 &iter.x,
359 &grad_f,
360 &c_vals,
361 &bl_c,
362 &bu_c,
363 &xl,
364 &xu,
365 jac_c,
366 hess_lag,
367 self.hessian_inertia(),
368 );
369 let qp = qp_data.as_qp();
370
371 // Scale-relative inner-QP tolerances (issue #358 tail).
372 //
373 // `QpOptions::{feas_tol, opt_tol}` are **absolute** (1e-9 each).
374 // That is a sane default for a standalone `solve_qp` on
375 // well-scaled data, but this QP is an *inner* subproblem whose
376 // data inherits the NLP's scale: with `‖∇f‖ ~ 1e3` and
377 // `‖B‖ ~ 1e3`, an absolute 1e-9 is ~1e-12 *relative* — at the
378 // f64 noise floor. The active-set solver then cannot certify
379 // its own optimality, burns its whole iteration budget, and
380 // returns `MaxIter`; the driver reports `QpStepFailed`, which
381 // surfaces to the user as `Search_Direction_Becomes_Too_Small`
382 // on a QP that is trivially solvable. This is what stalled the
383 // ill-conditioned tail of #358 even once the Hessian scale was
384 // fixed by the probe above.
385 //
386 // Scale both tolerances by the QP data magnitude, so the inner
387 // solve is asked for a *relative* accuracy it can actually
388 // reach. Nothing is lost in the answer: the SQP outer loop
389 // still gates optimality on the true, unscaled NLP KKT
390 // residuals (`dual_inf_tol` / `constr_viol_tol`) at the top of
391 // each iteration, so a sloppier inner step can only cost an
392 // extra outer iteration — never a false `Optimal`. Measured on
393 // a 500-instance convex-QP sweep this converts 34 failures into
394 // successes with a *bit-for-bit identical* error distribution
395 // (median 6e-11, max true constraint violation 4e-11).
396 //
397 // The `SCALE_MAX` clamp bounds the relaxation on pathological
398 // data (a quasi-Newton `B` that has blown up); it does not bind
399 // on any problem in the sweep.
400 const SCALE_MAX: Number = 1e6;
401 let g_inf = grad_f.iter().map(|v| v.abs()).fold(0.0, f64::max);
402 let b_inf = qp_data
403 .h
404 .values()
405 .iter()
406 .map(|v| v.abs())
407 .fold(0.0, f64::max);
408 let base = self.qp_opts.clone();
409 let scale = g_inf.max(b_inf).clamp(1.0, SCALE_MAX);
410 if scale > 1.0 {
411 self.qp_opts.opt_tol = base.opt_tol * scale;
412 self.qp_opts.feas_tol = base.feas_tol * scale;
413 }
414
415 // Warm-start from the previous QP's working set when
416 // available. Pounce-qp's `solve_with_working_set`
417 // internally computes a feasible primal compatible
418 // with the supplied set (it satisfies every active
419 // row exactly) — necessary because each SQP
420 // linearization shifts the QP's constraint RHS by
421 // `-c(x_k)`, so the previous QP's *primal* doesn't
422 // carry over even when the active set does.
423 let warm_started = iter.working.is_some();
424 let mut sol = if let Some(prev_w) = iter.working.as_ref() {
425 self.qp_solver
426 .solve_with_working_set(&qp, prev_w, &self.qp_opts)?
427 } else {
428 self.qp_solver.solve(&qp, None, &self.qp_opts)?
429 };
430 n_qp_solves += 1;
431 n_qp_working_set_changes += sol.stats.n_working_set_changes;
432
433 // Cold-start fallback: a warm start seeds the QP with the
434 // previous iterate's working set, which is usually a big
435 // win but can occasionally strand the active-set solver at
436 // its iteration limit (or a numerical breakdown) on a QP
437 // that is perfectly solvable from a clean start — e.g.
438 // when a quasi-Newton Hessian has drifted enough that the
439 // carried-over active set is a poor guess. Rather than
440 // give up with `QpStepFailed`, re-solve once from cold;
441 // this is what rescues the curved-constraint SQP runs of
442 // issue #349 that previously reported
443 // `Search_Direction_Becomes_Too_Small`.
444 if warm_started && matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError) {
445 let cold = self.qp_solver.solve(&qp, None, &self.qp_opts)?;
446 n_qp_solves += 1;
447 n_qp_working_set_changes += cold.stats.n_working_set_changes;
448 if cold.status == QpStatus::Optimal {
449 sol = cold;
450 }
451 }
452
453 // Quasi-Newton reset fallback (issue #358 tail). If the QP
454 // still cannot be solved, the usual culprit is not the
455 // linearization but the *approximated* Hessian: a damped-BFGS
456 // matrix that has accumulated enough drift (typically after a
457 // large early step on an ill-conditioned problem) to make the
458 // step subproblem numerically unsolvable. That is recoverable
459 // — throwing away the accumulated curvature and retrying from
460 // a scaled identity almost always yields a usable step —
461 // whereas the alternative is aborting an otherwise healthy
462 // solve with `QpStepFailed`, which the user sees as
463 // `Search_Direction_Becomes_Too_Small` on a trivially solvable
464 // problem. Rebuild the subproblem around the reset Hessian and
465 // re-solve once, from cold (the carried working set belongs to
466 // the discarded model).
467 let mut qp_data = qp_data;
468 if matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError)
469 && let Some(b) = bfgs.as_mut()
470 {
471 b.reset_to_scale();
472 qp_data = SqpQpData::build(
473 &iter.x,
474 &grad_f,
475 &c_vals,
476 &bl_c,
477 &bu_c,
478 &xl,
479 &xu,
480 nlp.eval_jac_c(&iter.x),
481 b.as_triplet(),
482 self.hessian_inertia(),
483 );
484 let retry = self
485 .qp_solver
486 .solve(&qp_data.as_qp(), None, &self.qp_opts)?;
487 n_qp_solves += 1;
488 n_qp_working_set_changes += retry.stats.n_working_set_changes;
489 if retry.status == QpStatus::Optimal {
490 sol = retry;
491 }
492 }
493
494 // Unbounded-model fallback (gh #423). The step QP being
495 // unbounded below is a statement about the *linearization*, so
496 // re-test the ray against the true NLP (gh #388) — and when it
497 // does not survive, do not stop there. An unbounded model on a
498 // bounded NLP is not a dead end; it is the textbook signal that
499 // the model needs regularizing (Nocedal-Wright §18.4), and δ
500 // from §4.5 inertia control already *is* that regularization.
501 // So re-solve declining the certificate: the same subproblem,
502 // the same shift, but the unblocked direction takes the
503 // δ-shifted proximal step instead of certifying recession.
504 //
505 // This is not a corner case. A nonconvex NLP with `m = 0` and
506 // no finite bounds has *nothing that can ever block* a
507 // negative-curvature direction, so every indefinite iterate
508 // produces this certificate. gh #419 gave those iterates a real
509 // step where a bound exists and left them with none where one
510 // does not: a chain of coupled double wells (`n = 12`, `m = 0`)
511 // that converged to f = 0.027424 in 24 iterations died at
512 // iteration 1 with `QpStepFailed` at f = 26.03. The proximal
513 // step is slow — that slowness is what #416 was about — but it
514 // is a step, and it is only reached here where the alternative
515 // is no step at all.
516 let mut ray_certified = false;
517 if sol.status == QpStatus::Unbounded {
518 ray_certified = sol.unbounded_ray.as_ref().is_some_and(|d| {
519 ray_certifies_unbounded(
520 nlp,
521 &iter.x,
522 d,
523 f_curr,
524 &grad_f,
525 &bl_c,
526 &bu_c,
527 &xl,
528 &xu,
529 self.opts.constr_viol_tol,
530 )
531 });
532 if !ray_certified {
533 tracing::debug!(target: "pounce::sqp",
534 "unbounded step QP whose recession ray does not survive \
535 re-testing against the NLP — re-solving for the δ-shifted \
536 proximal step (gh #423)");
537 let prox_opts = QpOptions {
538 certify_recession_ray: false,
539 ..self.qp_opts.clone()
540 };
541 let prox = self.qp_solver.solve(&qp_data.as_qp(), None, &prox_opts)?;
542 n_qp_solves += 1;
543 if prox.status == QpStatus::Optimal {
544 sol = prox;
545 }
546 }
547 }
548 self.qp_opts = base;
549
550 match sol.status {
551 QpStatus::Optimal => {}
552 QpStatus::Infeasible => {
553 let obj = nlp.eval_f(&iter.x);
554 self.iterates = Some(iter.clone());
555 return Ok(SqpResult {
556 x: iter.x,
557 lambda_g: iter.lambda_g,
558 lambda_x: iter.lambda_x,
559 obj,
560 status: SqpStatus::InfeasibleSubproblem,
561 n_iter: outer,
562 n_qp_solves,
563 n_qp_working_set_changes,
564 final_stationarity,
565 final_constr_viol,
566 working_set: iter.working,
567 });
568 }
569 // The QP subproblem neither solved nor certified
570 // infeasibility. `MaxIter` / `NumericalError` mean the
571 // active-set QP could not resolve the (typically extremely
572 // degenerate) step subproblem — the m/n ≫ 1 collapsed-cone
573 // geometry of #282. Terminate the SQP with an HONEST
574 // non-committal status rather than a hard error, and — the
575 // point of #282 — WITHOUT ever asserting infeasibility on a
576 // problem we have not certified infeasible.
577 QpStatus::MaxIter | QpStatus::NumericalError => {
578 let obj = nlp.eval_f(&iter.x);
579 self.iterates = Some(iter.clone());
580 // Report *which* of the two it was. Both are honest
581 // non-committal failures making no infeasibility claim
582 // (#282), but only the budget one is actionable by the
583 // user, and merging them hid the dominant Maros-Mészáros
584 // failure mode behind a step-size verdict. See
585 // `SqpStatus::QpIterationLimit`.
586 let status = if sol.status == QpStatus::MaxIter {
587 SqpStatus::QpIterationLimit
588 } else {
589 SqpStatus::QpStepFailed
590 };
591 return Ok(SqpResult {
592 x: iter.x,
593 lambda_g: iter.lambda_g,
594 lambda_x: iter.lambda_x,
595 obj,
596 status,
597 n_iter: outer,
598 n_qp_solves,
599 n_qp_working_set_changes,
600 final_stationarity,
601 final_constr_viol,
602 working_set: iter.working,
603 });
604 }
605 // The step QP is unbounded below along a certified
606 // recession ray of the LOCAL model (zero curvature,
607 // feasible for every step length, strict descent). That
608 // is a statement about the linearization, not yet about
609 // the NLP — so it was re-tested against the true
610 // objective and constraints above. If it survived, the
611 // NLP itself is unbounded and we say so with the same
612 // `Diverging_Iterates` verdict every other POUNCE path
613 // returns. If it did not, the proximal re-solve above
614 // already had its chance to produce a step, and reaching
615 // here means even that failed: the QP simply could not
616 // produce a usable step, which is `QpStepFailed`.
617 //
618 // Neither outcome is a hard error. This used to return
619 // `QpFailure(LinearSolverFailure("QP subproblem returned
620 // status unbounded"))`, which surfaced to AMPL / Pyomo /
621 // GAMS consumers as `Internal_Error` /
622 // `solve_result_num=500` — "the solver broke" — on a
623 // model that is merely unbounded (`300`), and named a
624 // linear-solver failure when no linear solver had failed
625 // (gh #388).
626 QpStatus::Unbounded => {
627 let certified = ray_certified;
628 let obj = nlp.eval_f(&iter.x);
629 self.iterates = Some(iter.clone());
630 return Ok(SqpResult {
631 x: iter.x,
632 lambda_g: iter.lambda_g,
633 lambda_x: iter.lambda_x,
634 obj,
635 status: if certified {
636 SqpStatus::Unbounded
637 } else {
638 SqpStatus::QpStepFailed
639 },
640 n_iter: outer,
641 n_qp_solves,
642 n_qp_working_set_changes,
643 final_stationarity,
644 final_constr_viol,
645 working_set: iter.working,
646 });
647 }
648 }
649
650 #[cfg(test)]
651 if self.opts.print_level >= 1 {
652 let p_inf = sol.x.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
653 tracing::debug!(target: "pounce::sqp",
654 " qp: ‖p‖_inf={:.3e} ‖λ_g_qp‖_inf={:.3e}",
655 p_inf,
656 sol.lambda_g.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
657 );
658 }
659 // Globalization: l1-merit backtracking (Han-Powell)
660 // or filter (Fletcher-Leyffer 2002). The two share
661 // the same backtracking shell + acceptance API; the
662 // filter keeps state across iterations on
663 // `self.filter`.
664 //
665 // Both are handed a second-order-correction (SOC)
666 // provider (the Maratos remedy). When the full step
667 // (α = 1) is rejected because it increased the
668 // constraint violation, the line search calls this
669 // closure with `c(x_k + p)` to obtain a corrected full
670 // step. We build that step by re-solving the SAME QP
671 // with the general-constraint RHS re-centered on the
672 // trial-point constraint values: the original QP models
673 // `c(x_k) + A p`, and the SOC replaces `c(x_k)` by
674 // `c(x_k + p) − A p`, so the correction subproblem
675 // targets the true (curved) violation at the trial
676 // point (Nocedal-Wright §18.11). The just-solved working
677 // set warm-starts the correction. Only meaningful with
678 // general constraints (`m > 0`).
679 //
680 // Pre-computed `A p` for the RHS re-centering:
681 let a_p = if m > 0 {
682 mat_vec_gen(&qp_data.a, &sol.x, m)
683 } else {
684 Vec::new()
685 };
686 let mut n_soc_solves: u32 = 0;
687 // Working set from the SOC subproblem, kept so that a
688 // taken SOC step warm-starts the next iteration from the
689 // active set that actually describes `x + p_soc` (not the
690 // original QP's set, which belongs to the rejected step).
691 let mut soc_working: Option<WorkingSet> = None;
692 let ls = {
693 let qp_solver = &mut self.qp_solver;
694 let qp_opts = &self.qp_opts;
695 let qp_data_ref = &qp_data;
696 let c_curr_ref = &c_vals;
697 let a_p_ref = &a_p;
698 let sol_working = &sol.working;
699 let n_soc = &mut n_soc_solves;
700 let soc_working_slot = &mut soc_working;
701 let mut soc = |c_trial: &[Number]| -> Option<crate::sqp::line_search::SocStep> {
702 let mm = qp_data_ref.m;
703 // Re-center the general-constraint RHS on the
704 // trial-point violation, preserving ±∞ sentinels.
705 let mut bl_soc = qp_data_ref.bl.clone();
706 let mut bu_soc = qp_data_ref.bu.clone();
707 for i in 0..mm {
708 let delta = c_curr_ref[i] - c_trial[i] + a_p_ref[i];
709 if qp_data_ref.bl[i] > NLP_LOWER_BOUND_INF {
710 bl_soc[i] = qp_data_ref.bl[i] + delta;
711 }
712 if qp_data_ref.bu[i] < NLP_UPPER_BOUND_INF {
713 bu_soc[i] = qp_data_ref.bu[i] + delta;
714 }
715 }
716 let qp_soc = QpProblem {
717 n: qp_data_ref.n,
718 m: qp_data_ref.m,
719 h: &qp_data_ref.h,
720 g: &qp_data_ref.g,
721 a: &qp_data_ref.a,
722 bl: &bl_soc,
723 bu: &bu_soc,
724 xl: &qp_data_ref.xl,
725 xu: &qp_data_ref.xu,
726 hessian_inertia: qp_data_ref.hessian_inertia,
727 };
728 let sol_soc = qp_solver
729 .solve_with_working_set(&qp_soc, sol_working, qp_opts)
730 .ok()?;
731 *n_soc += 1;
732 if sol_soc.status == QpStatus::Optimal {
733 *soc_working_slot = Some(sol_soc.working);
734 Some(crate::sqp::line_search::SocStep {
735 p: sol_soc.x,
736 lambda_g: sol_soc.lambda_g,
737 lambda_x: sol_soc.lambda_x,
738 })
739 } else {
740 None
741 }
742 };
743 let soc_ref: Option<crate::sqp::line_search::SocProvider<'_>> =
744 if m > 0 { Some(&mut soc) } else { None };
745 match self.opts.globalization {
746 SqpGlobalization::L1Elastic => l1_merit_line_search(
747 nlp,
748 &iter.x,
749 &sol.x,
750 &sol.lambda_g,
751 &grad_f,
752 f_curr,
753 &c_vals,
754 &bl_c,
755 &bu_c,
756 &xl,
757 &xu,
758 nu,
759 &self.opts,
760 soc_ref,
761 ),
762 SqpGlobalization::Filter => filter_line_search(
763 nlp,
764 &mut self.filter,
765 &iter.x,
766 &sol.x,
767 f_curr,
768 &c_vals,
769 &bl_c,
770 &bu_c,
771 &xl,
772 &xu,
773 nu,
774 &self.opts,
775 soc_ref,
776 ),
777 }
778 };
779 n_qp_solves += n_soc_solves;
780 #[cfg(test)]
781 if self.opts.print_level >= 1 {
782 tracing::debug!(target: "pounce::sqp",
783 " ls: α={:.3e} ν={:.3e} ok={} f_new={:.3e}",
784 ls.alpha, ls.nu, ls.success, ls.f_new
785 );
786 }
787 if !ls.success {
788 self.iterates = Some(iter.clone());
789 return Ok(SqpResult {
790 x: iter.x,
791 lambda_g: iter.lambda_g,
792 lambda_x: iter.lambda_x,
793 obj: f_curr,
794 status: SqpStatus::LineSearchFailed,
795 n_iter: outer,
796 n_qp_solves,
797 n_qp_working_set_changes,
798 final_stationarity,
799 final_constr_viol,
800 working_set: Some(sol.working),
801 });
802 }
803 iter.x = ls.x_new;
804 match ls.soc_duals {
805 Some((soc_lg, soc_lx)) => {
806 // A second-order-correction step was taken (α = 1
807 // on the SOC subproblem). Adopt the SOC
808 // subproblem's own multipliers and working set so
809 // `(step, multipliers, active set)` stay a
810 // consistent triple — required for the quasi-
811 // Newton Hessian update to stay well-conditioned
812 // and for the next QP to warm-start correctly.
813 iter.lambda_g = soc_lg;
814 iter.lambda_x = soc_lx;
815 iter.working = soc_working.take().or(Some(sol.working));
816 }
817 None => {
818 for (l, &lq) in iter.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
819 *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
820 }
821 for (l, &lq) in iter.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
822 *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
823 }
824 iter.working = Some(sol.working);
825 }
826 }
827 nu = ls.nu;
828 f_cached = Some(ls.f_new);
829 c_cached = Some(ls.c_new);
830 }
831
832 let obj = nlp.eval_f(&iter.x);
833 self.iterates = Some(iter.clone());
834 Ok(SqpResult {
835 x: iter.x,
836 lambda_g: iter.lambda_g,
837 lambda_x: iter.lambda_x,
838 obj,
839 status: SqpStatus::MaxIter,
840 n_iter: self.opts.max_iter,
841 n_qp_solves,
842 n_qp_working_set_changes,
843 final_stationarity,
844 final_constr_viol,
845 working_set: iter.working,
846 })
847 }
848
849 fn hessian_inertia(&self) -> HessianInertia {
850 match self.opts.hessian {
851 // Exact ∇²L is indefinite on nonconvex NLPs; let the
852 // QP solver's §4.5 inertia control handle it.
853 crate::sqp::SqpHessianSource::Exact => HessianInertia::Indefinite,
854 // Damped BFGS and L-BFGS are PSD by construction.
855 crate::sqp::SqpHessianSource::DampedBfgs => HessianInertia::Psd,
856 crate::sqp::SqpHessianSource::Lbfgs => HessianInertia::Psd,
857 }
858 }
859}
860
861/// gh #388: does the step QP's certified recession ray certify the **NLP**
862/// unbounded below?
863///
864/// The inner QP hands back a direction `d` that is a recession ray *of the
865/// linearization at `x`*: `∇²L d ≈ 0`, `d` feasible for the linearized
866/// constraints at every step length, `∇q(x)ᵀd < 0`. On an LP or a QP that
867/// linearization is exact and `d` is a recession ray of the original
868/// problem; on a general NLP it need not be — the constraints curve back
869/// and the objective can turn around. The two cases must not share a
870/// status, so we settle it by evaluation rather than by faith: walk the
871/// ray and check, at the **true** `f` and `c`, that
872///
873/// 1. every probe point is *feasible* (variable bounds and constraint
874/// bounds, the latter with a roundoff allowance that grows with the
875/// row scale so a linear row evaluated at `‖x‖ ~ 1e12` is not failed
876/// on cancellation noise), and
877/// 2. the objective keeps falling at **at least half** the initial linear
878/// rate `∇f(x)ᵀd` — not merely falling. A ray that decelerates is
879/// settling onto a finite optimum, the same distinction the IPM's
880/// divergence guard draws (#248/#252/#285).
881///
882/// Probes span twelve decades of step length, so a "pass" is a family of
883/// genuinely feasible points whose objective marches to `−∞` at a linear
884/// rate over `1e12`. Anything short of that — one infeasible probe, one
885/// decelerating decade, a NaN — returns `false` and the caller reports the
886/// non-committal `QpStepFailed` instead. False negatives cost an honest
887/// "no step" status; a false positive would tell a modeler their bounded
888/// model is unbounded, so the asymmetry is deliberate.
889///
890/// `dir` need not be normalized (it is rescaled to unit max-norm here, so
891/// the probe lengths are in the iterate's own units).
892#[allow(clippy::too_many_arguments)]
893fn ray_certifies_unbounded<N: SqpProblemSpec>(
894 nlp: &mut N,
895 x: &[Number],
896 dir: &[Number],
897 f_x: Number,
898 grad_f: &[Number],
899 bl_c: &[Number],
900 bu_c: &[Number],
901 xl: &[Number],
902 xu: &[Number],
903 constr_viol_tol: Number,
904) -> bool {
905 /// Step lengths along the unit-max-norm ray, spanning twelve decades.
906 const PROBES: [Number; 7] = [1e0, 1e2, 1e4, 1e6, 1e8, 1e10, 1e12];
907 /// Roundoff allowance per unit of `row_scale · ‖x‖∞` when checking a
908 /// constraint at a far-out probe: comfortably above f64 epsilon
909 /// (`2.2e-16`) to absorb accumulation over a row, far below anything
910 /// a real violation would produce.
911 const ROUNDOFF_REL: Number = 1e-12;
912
913 let n = x.len();
914 if dir.len() != n || grad_f.len() != n || !f_x.is_finite() {
915 return false;
916 }
917 let scale = dir.iter().map(|v| v.abs()).fold(0.0, f64::max);
918 if !scale.is_finite() || scale <= 0.0 {
919 return false;
920 }
921 let d: Vec<Number> = dir.iter().map(|v| v / scale).collect();
922
923 // Descent of the TRUE objective along the ray. The QP certified this
924 // for its own (possibly quasi-Newton) model gradient; re-derive it
925 // from `∇f(x)` so the rate we hold the probes to is the real one.
926 let slope: Number = grad_f.iter().zip(d.iter()).map(|(g, di)| g * di).sum();
927 let g_norm = grad_f.iter().map(|v| v * v).sum::<Number>().sqrt();
928 // Numerically meaningful (not roundoff-scale) descent; a NaN slope
929 // fails the `is_finite` guard rather than sneaking past the comparison.
930 let descent_bar = -1e-9 * g_norm.max(1.0);
931 if !slope.is_finite() || slope >= descent_bar {
932 return false;
933 }
934
935 // Per-row `max_j |∂c_i/∂x_j|`, the scale a linear row's value grows
936 // with along the ray — the basis for the roundoff allowance in (1).
937 let m = bl_c.len();
938 let mut row_scale: Vec<Number> = vec![0.0; m];
939 {
940 let jac = nlp.eval_jac_c(x);
941 for k in 0..jac.vals.len() {
942 let i = (jac.irow[k] - 1) as usize;
943 row_scale[i] = row_scale[i].max(jac.vals[k].abs());
944 }
945 }
946
947 for &t in PROBES.iter() {
948 let xt: Vec<Number> = x.iter().zip(d.iter()).map(|(xi, di)| xi + t * di).collect();
949 if xt.iter().any(|v| !v.is_finite()) {
950 return false;
951 }
952
953 // (1a) Variable bounds. These are exact linear rows in the probe's
954 // own arithmetic, so the tolerance stays tight.
955 for i in 0..n {
956 let tol = 1e-9 * (1.0 + xt[i].abs());
957 if xl[i] > NLP_LOWER_BOUND_INF && xt[i] < xl[i] - tol {
958 return false;
959 }
960 if xu[i] < NLP_UPPER_BOUND_INF && xt[i] > xu[i] + tol {
961 return false;
962 }
963 }
964
965 // (1b) Constraint bounds, at the true (possibly nonlinear) `c`.
966 let x_inf = xt.iter().map(|v| v.abs()).fold(0.0, f64::max);
967 let c = nlp.eval_c(&xt);
968 if c.len() != m {
969 return false;
970 }
971 for i in 0..m {
972 if c[i].is_nan() {
973 return false;
974 }
975 let tol =
976 constr_viol_tol.max(0.0) * (1.0 + c[i].abs()) + ROUNDOFF_REL * row_scale[i] * x_inf;
977 if bl_c[i] > NLP_LOWER_BOUND_INF && c[i] < bl_c[i] - tol {
978 return false;
979 }
980 if bu_c[i] < NLP_UPPER_BOUND_INF && c[i] > bu_c[i] + tol {
981 return false;
982 }
983 }
984
985 // (2) Sustained (non-decelerating) descent. `-inf` passes: an
986 // objective that has already overflowed downward is not evidence
987 // against unboundedness.
988 let f_t = nlp.eval_f(&xt);
989 if f_t.is_nan() || f_t > f_x + 0.5 * slope * t {
990 return false;
991 }
992 }
993 true
994}
995
996#[derive(Debug, Clone, Copy)]
997struct KktError {
998 pub stationarity: Number,
999 pub constr_viol: Number,
1000}
1001
1002/// Sparse `A · p` for an `m × n` general-constraint Jacobian stored
1003/// as a `GenTMatrix` (1-based triplet indices). Used to re-center
1004/// the second-order-correction QP's RHS on the trial point.
1005fn mat_vec_gen(a: &GenTMatrix, p: &[Number], m: usize) -> Vec<Number> {
1006 let mut out = vec![0.0; m];
1007 let irows = a.irows();
1008 let jcols = a.jcols();
1009 let vals = a.values();
1010 for k in 0..vals.len() {
1011 let i = (irows[k] - 1) as usize;
1012 let j = (jcols[k] - 1) as usize;
1013 out[i] += vals[k] * p[j];
1014 }
1015 out
1016}
1017
1018/// Build the quasi-Newton curvature pair `(s, y)` for the step from the
1019/// previous iterate to the current one, differencing `∇L` at a **single,
1020/// fixed multiplier** (Nocedal-Wright §18.3):
1021///
1022/// ```text
1023/// s = x_k − x_{k−1}
1024/// y = ∇L(x_k, λ_k) − ∇L(x_{k−1}, λ_k) ← the SAME λ_k twice
1025/// ```
1026///
1027/// Returns `None` on the first iteration (no previous point yet).
1028///
1029/// **Why the fixed multiplier matters (gh #361).** The previous code held
1030/// `∇L(x_{k−1}, λ_{k−1})` inside the Hessian object and differenced against
1031/// `∇L(x_k, λ_k)`, giving
1032///
1033/// ```text
1034/// y = (∇f_k − ∇f_{k−1}) + (J_kᵀλ_k − J_{k−1}ᵀλ_{k−1})
1035/// ```
1036///
1037/// For **linear** constraints `J` is constant, so that second group collapses
1038/// to `Aᵀ(λ_k − λ_{k−1})` — pure *multiplier* difference, carrying no
1039/// curvature information at all. Since the true `∇²L` equals `∇²f` there, the
1040/// whole term is spurious, and it feeds a divergent loop: a perturbed `B`
1041/// yields a worse QP multiplier, which injects a larger error into the next
1042/// `y`, which corrupts `B` further. On equality-constrained QPs (where `λ` is
1043/// sign-free and can swing hard) the multiplier was observed oscillating and
1044/// growing exponentially — `−13, 19, −69, 104, −145, 581, −1320, 3176, …` —
1045/// while `x` itself sat on the exact optimum. The solve then burned its whole
1046/// iteration budget and exited `Maximum_Iterations_Exceeded` *at the right
1047/// answer*, because the stationarity residual is computed from that garbage
1048/// multiplier.
1049///
1050/// Using one multiplier at both points makes the term telescope to
1051/// `Σλᵏᵢ(∇cᵢ(x_k) − ∇cᵢ(x_{k−1}))`, which is the genuine constraint-curvature
1052/// contribution: it vanishes identically for linear constraints (as it must)
1053/// and is retained for nonlinear ones.
1054fn curvature_pair(
1055 prev: Option<&(Vec<Number>, Vec<Number>, Triplet)>,
1056 iter: &SqpIterates,
1057 grad_f: &[Number],
1058 jac_c: &Triplet,
1059 n: usize,
1060) -> Option<(Vec<Number>, Vec<Number>)> {
1061 let (prev_x, prev_grad_f, prev_jac) = prev?;
1062 let s: Vec<Number> = iter
1063 .x
1064 .iter()
1065 .zip(prev_x.iter())
1066 .map(|(a, b)| a - b)
1067 .collect();
1068 // Both evaluated at the *current* multiplier `iter.lambda_g`.
1069 let lag_curr = compute_grad_lag(grad_f, jac_c, &iter.lambda_g, n);
1070 let lag_prev = compute_grad_lag(prev_grad_f, prev_jac, &iter.lambda_g, n);
1071 let y: Vec<Number> = lag_curr
1072 .iter()
1073 .zip(lag_prev.iter())
1074 .map(|(a, b)| a - b)
1075 .collect();
1076 Some((s, y))
1077}
1078
1079/// Lagrangian gradient `∇L(x, λ_g) = ∇f(x) + J_c(x)ᵀ λ_g` at the
1080/// current iterate. Used by the damped-BFGS update.
1081fn compute_grad_lag(
1082 grad_f: &[Number],
1083 jac_c: &Triplet,
1084 lambda_g: &[Number],
1085 n: usize,
1086) -> Vec<Number> {
1087 let mut out = grad_f.to_vec();
1088 debug_assert_eq!(out.len(), n);
1089 for k in 0..jac_c.irow.len() {
1090 let row_i = (jac_c.irow[k] - 1) as usize;
1091 let col_j = (jac_c.jcol[k] - 1) as usize;
1092 out[col_j] += jac_c.vals[k] * lambda_g[row_i];
1093 }
1094 out
1095}
1096
1097fn check_kkt(
1098 n: usize,
1099 m: usize,
1100 iter: &SqpIterates,
1101 grad_f: &[Number],
1102 c_vals: &[Number],
1103 bl_c: &[Number],
1104 bu_c: &[Number],
1105 xl: &[Number],
1106 xu: &[Number],
1107 jac_c: &crate::sqp::qp_assembly::Triplet,
1108) -> KktError {
1109 // Constraint violation: max(0, bl - c, c - bu) on every row,
1110 // plus bound violation on every variable.
1111 let mut viol = 0.0_f64;
1112 for i in 0..m {
1113 let lo = if bl_c[i] > NLP_LOWER_BOUND_INF {
1114 (bl_c[i] - c_vals[i]).max(0.0)
1115 } else {
1116 0.0
1117 };
1118 let hi = if bu_c[i] < NLP_UPPER_BOUND_INF {
1119 (c_vals[i] - bu_c[i]).max(0.0)
1120 } else {
1121 0.0
1122 };
1123 viol = viol.max(lo).max(hi);
1124 }
1125 for i in 0..n {
1126 let lo = if xl[i] > NLP_LOWER_BOUND_INF {
1127 (xl[i] - iter.x[i]).max(0.0)
1128 } else {
1129 0.0
1130 };
1131 let hi = if xu[i] < NLP_UPPER_BOUND_INF {
1132 (iter.x[i] - xu[i]).max(0.0)
1133 } else {
1134 0.0
1135 };
1136 viol = viol.max(lo).max(hi);
1137 }
1138
1139 // Stationarity: ∇f + Jᵀ λ_g − λ_x. pounce-qp's KKT is
1140 // `Hx + Aᵀλ_qp + (lower-bound multiplier) e_i − (upper-bound
1141 // multiplier) e_i = -g`. Since `λ_x = z_l − z_u` packs the
1142 // bound-multiplier sign, the variable-bound term enters the
1143 // stationarity check with a negative sign — i.e. at the
1144 // optimum `∇f + Jᵀ λ_g = λ_x`.
1145 let mut stat = vec![0.0; n];
1146 for (s, &g) in stat.iter_mut().zip(grad_f.iter()) {
1147 *s = g;
1148 }
1149 // Add Jᵀ λ_g
1150 for k in 0..jac_c.irow.len() {
1151 let i = (jac_c.irow[k] - 1) as usize; // 0-based row in c
1152 let j = (jac_c.jcol[k] - 1) as usize; // 0-based col in x
1153 stat[j] += jac_c.vals[k] * iter.lambda_g[i];
1154 }
1155 // Subtract λ_x
1156 for (s, &lx) in stat.iter_mut().zip(iter.lambda_x.iter()) {
1157 *s -= lx;
1158 }
1159 let stat_max = stat.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
1160
1161 KktError {
1162 stationarity: stat_max,
1163 constr_viol: viol,
1164 }
1165}