Skip to main content

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}