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::sqp_subproblem(),
51            opts,
52            iterates: None,
53            filter: SqpFilter::new(),
54        }
55    }
56
57    /// Override the per-call QP-solver options. Defaults are
58    /// `pounce_qp::QpOptions::sqp_subproblem()` — `QpOptions::default()`
59    /// with second-order certification off, for the reason given there
60    /// (which include the
61    /// `use_schur_updates = false` and `anti_cycling = Expand`
62    /// from Phase 5a.2). Callers can pin tighter tolerances or
63    /// flip `use_schur_updates = true` for warm-started workloads.
64    pub fn with_qp_options(mut self, qp_opts: QpOptions) -> Self {
65        self.qp_opts = qp_opts;
66        self
67    }
68
69    pub fn options(&self) -> &SqpOptions {
70        &self.opts
71    }
72
73    pub fn iterates(&self) -> Option<&SqpIterates> {
74        self.iterates.as_ref()
75    }
76
77    /// Run the SQP loop to convergence (or `max_iter`). Cold-starts
78    /// the iterate from `nlp.x_init()` and an empty working set.
79    pub fn optimize<N: SqpProblemSpec>(&mut self, nlp: &mut N) -> Result<SqpResult, SqpError> {
80        self.optimize_with_warm_start(nlp, None)
81    }
82
83    /// Warm-start variant. `warm = Some(prev)` seeds the iterate
84    /// from `prev.{x, lambda_g, lambda_x, working}` instead of the
85    /// NLP's cold defaults. Dimensions are validated against the
86    /// problem; any mismatch is fatal. The QP solver consumes
87    /// `warm.working` (when present) via `solve_with_working_set`.
88    ///
89    /// `warm = None` is equivalent to [`Self::optimize`].
90    ///
91    /// Implements the §6 design-note warm-start contract: the
92    /// tuple `(x, λ_g, λ_x, 𝒲)`. The Hessian carry-forward
93    /// (damped-BFGS / L-BFGS state) is *not* part of the warm-start
94    /// payload — each `optimize` call rebuilds its own Hessian
95    /// approximation from scratch.
96    pub fn optimize_with_warm_start<N: SqpProblemSpec>(
97        &mut self,
98        nlp: &mut N,
99        warm: Option<SqpIterates>,
100    ) -> Result<SqpResult, SqpError> {
101        let n = nlp.n();
102        let m = nlp.m();
103        let (xl, xu) = nlp.variable_bounds();
104        let (bl_c, bu_c) = nlp.constraint_bounds();
105        if xl.len() != n || xu.len() != n {
106            return Err(SqpError::DimensionMismatch(format!(
107                "variable_bounds length must be n = {n}"
108            )));
109        }
110        if bl_c.len() != m || bu_c.len() != m {
111            return Err(SqpError::DimensionMismatch(format!(
112                "constraint_bounds length must be m = {m}"
113            )));
114        }
115
116        let mut iter = match warm {
117            Some(w) => {
118                if w.x.len() != n {
119                    return Err(SqpError::DimensionMismatch(format!(
120                        "warm.x length {} must equal n = {n}",
121                        w.x.len()
122                    )));
123                }
124                if w.lambda_g.len() != m {
125                    return Err(SqpError::DimensionMismatch(format!(
126                        "warm.lambda_g length {} must equal m = {m}",
127                        w.lambda_g.len()
128                    )));
129                }
130                if w.lambda_x.len() != n {
131                    return Err(SqpError::DimensionMismatch(format!(
132                        "warm.lambda_x length {} must equal n = {n}",
133                        w.lambda_x.len()
134                    )));
135                }
136                if let Some(ws) = w.working.as_ref() {
137                    ws.validate_dims(n, m).map_err(SqpError::QpFailure)?;
138                }
139                w
140            }
141            None => {
142                let mut cold = SqpIterates::cold(n, m);
143                let x_init = nlp.x_init();
144                if x_init.len() != n {
145                    return Err(SqpError::DimensionMismatch(format!(
146                        "x_init length must be n = {n}"
147                    )));
148                }
149                cold.x = x_init;
150                cold
151            }
152        };
153
154        let mut n_qp_solves: u32 = 0;
155        // Inner active-set work: adds + drops summed over every step QP
156        // solved in this call. This — not the outer iteration count — is
157        // what a warm start is trying to reduce, and on a QP-shaped NLP
158        // (one outer iteration by construction) it is the *only* thing
159        // that moves. Second-order-correction QPs are not counted: they
160        // are solved inside the line search, which does not surface its
161        // subproblem stats.
162        let mut n_qp_working_set_changes: u32 = 0;
163        let mut final_stationarity = 0.0;
164        let mut final_constr_viol = 0.0;
165        // l1-merit penalty parameter ν, adapted across iterations
166        // by `l1_merit_line_search`. Initialized from
167        // `SqpOptions::l1_penalty`.
168        let mut nu = self.opts.l1_penalty;
169        // Reset filter state at the top of each optimize call.
170        self.filter = SqpFilter::new();
171        // Cache the most recent f(x) and c(x) so we don't
172        // re-evaluate them after a successful line search (the
173        // LS already computed them at the new iterate).
174        let mut f_cached: Option<Number> = None;
175        let mut c_cached: Option<Vec<Number>> = None;
176        // Previous iterate's `(x, ∇f, ∇c)`, kept so the quasi-Newton
177        // curvature pair can difference `∇L` at a single fixed multiplier
178        // (see [`curvature_pair`]). Storing `∇L` directly — as the older
179        // `DampedBfgs::update(x, ∇L)` form did — bakes in the multiplier
180        // that was current at the time, which is precisely the bug.
181        let mut prev_point: Option<(Vec<Number>, Vec<Number>, Triplet)> = None;
182
183        // Damped-BFGS state, allocated only if needed. The
184        // matrix is updated at the END of each iteration (after
185        // we have x_new and the next ∇L), then queried at the
186        // TOP of the next iteration to populate the QP Hessian.
187        let mut bfgs: Option<DampedBfgs> =
188            if matches!(self.opts.hessian, SqpHessianSource::DampedBfgs) {
189                Some(DampedBfgs::new(n))
190            } else {
191                None
192            };
193        let mut lbfgs: Option<LBfgs> = if matches!(self.opts.hessian, SqpHessianSource::Lbfgs) {
194            Some(LBfgs::new(n, self.opts.lbfgs_max_history.max(1) as usize))
195        } else {
196            None
197        };
198
199        // Iteration-0 curvature probe (issue #358 tail).
200        //
201        // `DampedBfgs::update` sizes the identity seed from the first
202        // `(s, y)` pair — but that pair only exists at iteration 1, and
203        // iteration **0** already solves a QP against `B`. With `B = I`
204        // on a problem where `‖∇²L‖ ≫ 1`, that first step overshoots the
205        // Newton step by `~cond(∇²L)`; the filter (empty, and `θ` tiny at
206        // a near-feasible start) accepts the objective-blowing step, the
207        // iterate is flung to `‖x‖ ~ 1e3`, and the huge `(s, y)` pairs
208        // that follow drive `B` so ill-conditioned that the QP subproblem
209        // itself fails a few iterations later (`QpStepFailed`, surfacing
210        // as `Search_Direction_Becomes_Too_Small`).
211        //
212        // Fix the scale *before* that first QP with one extra gradient
213        // evaluation: step a short distance along the steepest-descent
214        // direction, difference the gradients, and seed `B = γI` with the
215        // resulting Rayleigh quotient `γ = sᵀy / sᵀs`. For a quadratic
216        // this is exactly the curvature along the probe direction, and it
217        // lies in `[λ_min(∇²L), λ_max(∇²L)]`.
218        //
219        // The probe differences the *objective* gradient, so it estimates
220        // `∇²f` — which equals the Lagrangian Hessian `∇²L = ∇²f + Σλᵢ∇²cᵢ`
221        // only when the constraint-curvature term vanishes, i.e. when every
222        // constraint is linear (or there are none). That condition is
223        // exactly the #358 family, and it is *not* cosmetic: on the Maratos
224        // problem (`min 2(x₁²+x₂²−1) − x₁ s.t. x₁²+x₂²=1`) `∇²f = 4I` while
225        // `∇²L ≈ I` at the solution, so seeding the objective curvature
226        // would over-scale `B` fourfold and cost that solve its convergence.
227        //
228        // Detect linearity directly rather than trusting a declaration:
229        // compare the constraint Jacobian at the probe point with the one
230        // at `x`. Identical ⇒ `∇c` is constant ⇒ constraints are linear ⇒
231        // the objective Hessian *is* the Lagrangian Hessian and the probe
232        // is exact. Otherwise leave the identity seed alone and let the
233        // rank-2 updates (which see the true `∇L`) do the work.
234        if let Some(b) = bfgs.as_mut() {
235            let g0 = nlp.eval_grad_f(&iter.x);
236            let g_norm = g0.iter().map(|v| v * v).sum::<Number>().sqrt();
237            if g_norm.is_finite() && g_norm > 0.0 {
238                // Absolute probe length, scaled by the iterate so the step
239                // is meaningful in the problem's own units but always tiny
240                // relative to it. `1e-7` keeps the gradient difference well
241                // above f64 roundoff without leaving the local model.
242                let x_scale = iter.x.iter().map(|v| v.abs()).fold(1.0, f64::max);
243                let eps = 1e-7 * x_scale;
244                let step: Vec<Number> = g0.iter().map(|gi| -eps * gi / g_norm).collect();
245                let x_probe: Vec<Number> =
246                    iter.x.iter().zip(step.iter()).map(|(a, d)| a + d).collect();
247                // Constant-Jacobian (linear-constraint) check, per above.
248                let linear_constraints = m == 0 || {
249                    let j0 = nlp.eval_jac_c(&iter.x);
250                    let j1 = nlp.eval_jac_c(&x_probe);
251                    j0.vals.len() == j1.vals.len()
252                        && j0.vals.iter().zip(j1.vals.iter()).all(|(a, c)| {
253                            // Relative comparison: a linear constraint
254                            // reproduces its Jacobian bit-for-bit, so this
255                            // only tolerates evaluation noise.
256                            let scale = a.abs().max(c.abs()).max(1.0);
257                            (a - c).abs() <= 1e-12 * scale
258                        })
259                };
260                if linear_constraints {
261                    let g1 = nlp.eval_grad_f(&x_probe);
262                    let s_y: Number = step
263                        .iter()
264                        .zip(g1.iter().zip(g0.iter()))
265                        .map(|(si, (a, bg))| si * (a - bg))
266                        .sum();
267                    let s_s: Number = step.iter().map(|v| v * v).sum();
268                    if s_s > 0.0 && s_y.is_finite() {
269                        // A non-positive quotient means the probe direction
270                        // has non-positive curvature (nonconvex or
271                        // numerically flat); `seed_scale` ignores it, leaving
272                        // the identity seed rather than a meaningless or
273                        // negative scale.
274                        b.seed_scale(s_y / s_s);
275                    }
276                }
277            }
278        }
279
280        // Bounded so a curvature escape that keeps returning to the same
281        // neighbourhood cannot spin: each one costs a fresh descent, and a
282        // handful is far more than any of the measured models needs (one).
283        const MAX_SECOND_ORDER_ESCAPES: u32 = 8;
284        let mut escapes: u32 = 0;
285
286        for outer in 0..self.opts.max_iter {
287            let grad_f = nlp.eval_grad_f(&iter.x);
288            let c_vals = c_cached.take().unwrap_or_else(|| nlp.eval_c(&iter.x));
289            let f_curr = f_cached.take().unwrap_or_else(|| nlp.eval_f(&iter.x));
290            let jac_c = nlp.eval_jac_c(&iter.x);
291            let hess_lag = match self.opts.hessian {
292                SqpHessianSource::Exact => nlp.eval_hess_lag(&iter.x, &iter.lambda_g),
293                SqpHessianSource::DampedBfgs => {
294                    let bfgs = bfgs.as_mut().expect("DampedBfgs state initialized above");
295                    if let Some((s, y)) =
296                        curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
297                    {
298                        bfgs.update_sy(&s, &y);
299                    }
300                    bfgs.as_triplet()
301                }
302                SqpHessianSource::Lbfgs => {
303                    let lb = lbfgs.as_mut().expect("LBfgs state initialized above");
304                    if let Some((s, y)) =
305                        curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
306                    {
307                        lb.update_sy(&s, &y);
308                    }
309                    lb.as_triplet()
310                }
311            };
312
313            // Remember this iterate's `(x, ∇f, ∇c)` so the next
314            // iteration can build its curvature pair at a fixed
315            // multiplier. See `curvature_pair`.
316            prev_point = Some((iter.x.clone(), grad_f.clone(), jac_c.clone()));
317
318            // KKT check uses the current iterate's evaluations.
319            let kkt = check_kkt(
320                n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
321            );
322            final_stationarity = kkt.stationarity;
323            final_constr_viol = kkt.constr_viol;
324
325            // Non-finite residuals mean the iterate itself is garbage, and
326            // every gate below this point — the convergence test, the
327            // gh #856 negative-curvature escape installed on it, the filter,
328            // the merit function — is a comparison against a tolerance that
329            // a `NaN` cannot inform. Stop here and say so, mirroring the
330            // interior-point arm's `if !nlp_err.is_finite()` screen
331            // (`ipopt_alg.rs`) so the two arms give the same verdict on the
332            // same condition (gh #876).
333            if !kkt.stationarity.is_finite() || !kkt.constr_viol.is_finite() {
334                let obj = nlp.eval_f(&iter.x);
335                self.iterates = Some(iter.clone());
336                return Ok(SqpResult {
337                    x: iter.x,
338                    lambda_g: iter.lambda_g,
339                    lambda_x: iter.lambda_x,
340                    obj,
341                    status: SqpStatus::InvalidNumber,
342                    n_iter: outer,
343                    n_qp_solves,
344                    n_qp_working_set_changes,
345                    final_stationarity,
346                    final_constr_viol,
347                    working_set: iter.working,
348                });
349            }
350
351            #[cfg(test)]
352            if self.opts.print_level >= 1 {
353                tracing::debug!(target: "pounce::sqp",
354                    "[sqp k={outer:3}] x={:?} f={:.4e} ‖c‖={:.2e} stat={:.2e} ν={:.2e}",
355                    iter.x.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>(),
356                    f_curr,
357                    kkt.constr_viol,
358                    kkt.stationarity,
359                    nu,
360                );
361            }
362
363            // `sqp_tol` and `sqp_dual_inf_tol` are both registered and both
364            // documented as a max-norm tolerance on the stationarity
365            // residual, but only `dual_inf_tol` was ever read — `opts.tol`
366            // (default 1e-8) was dead, so the loose 1e-4 governed alone and
367            // silently capped attainable accuracy (max x-error `7e-5` on the
368            // #358 sweep). Honor both by requiring the tighter, which is the
369            // only reading under which neither option is a no-op. Restores
370            // `~5e-9` worst-case accuracy for ~10% more iterations. Same
371            // registered-but-inert defect family as gh #360.
372            let stationarity_tol = self.opts.tol.min(self.opts.dual_inf_tol);
373            if kkt.stationarity <= stationarity_tol && kkt.constr_viol <= self.opts.constr_viol_tol
374            {
375                // First-order KKT is necessary and not sufficient. Before
376                // reporting success on an indefinite Lagrangian, look for a
377                // feasible direction of negative curvature *at the converged
378                // multipliers* and, where one is found, step along it and keep
379                // going (gh #856).
380                //
381                // `nonconvex_qp.nl` under `algorithm=active-set-sqp` is the
382                // case: `min x₀x₁ s.t. x₀+x₁ = 2, 0 ≤ x ≤ 4`, on whose
383                // feasible segment `f(x₀) = x₀(2−x₀)` is concave, so the
384                // `(1, 1)` this converges to at `f = 1` is the constrained
385                // **maximum** and the minimum is `0` at either endpoint. It was
386                // reported `Solve_Succeeded`. The escape below moves to
387                // `(2, 0)`, where a bound joins the active set, the null space
388                // closes and `f = 0` certifies.
389                //
390                // Refuted **by exhibition**, exactly as gh #848 does one layer
391                // down: the direction is only acted on after stepping along it
392                // and finding the true objective lower, so the curvature search
393                // is free to be approximate and a direction it gets wrong
394                // costs an evaluation rather than a wrong answer.
395                //
396                // The Hessian is the **exact** `∇²L`, taken here even when the
397                // steps were driven by a quasi-Newton one. That is not an
398                // optimization detail, it is what makes the check exist at all
399                // under `limited-memory`: a damped-BFGS or L-BFGS matrix is
400                // positive definite by construction, so searching it for
401                // negative curvature can only ever find none, and gating the
402                // check on `SqpHessianSource::Exact` left the L-BFGS leg
403                // certifying the same constrained maximum. `eval_hess_lag` is
404                // a required method of `SqpProblemSpec`, so it is always
405                // callable; this costs one Hessian evaluation per converged
406                // solve, and an implementation that has none to give returns
407                // an empty triplet, which finds no curvature and changes
408                // nothing.
409                //
410                // The L-BFGS leg is not exotic coverage: the Python frontend
411                // and the CasADi plugin both select `limited-memory` on their
412                // own whenever no exact Lagrangian Hessian is available.
413                let exact_hess = if matches!(self.opts.hessian, SqpHessianSource::Exact) {
414                    None
415                } else {
416                    Some(nlp.eval_hess_lag(&iter.x, &iter.lambda_g))
417                };
418                if escapes < MAX_SECOND_ORDER_ESCAPES
419                    && let Some(d) = negative_curvature_at_kkt_point(
420                        n,
421                        m,
422                        &iter.x,
423                        exact_hess.as_ref().unwrap_or(&hess_lag),
424                        &jac_c,
425                        &c_vals,
426                        &bl_c,
427                        &bu_c,
428                        &xl,
429                        &xu,
430                        self.opts.constr_viol_tol,
431                    )
432                    && let Some(next) = exhibit_better_point(
433                        nlp,
434                        &iter.x,
435                        &d,
436                        f_curr,
437                        &xl,
438                        &xu,
439                        &bl_c,
440                        &bu_c,
441                        self.opts.constr_viol_tol,
442                    )
443                {
444                    tracing::debug!(target: "pounce::sqp",
445                        "first-order KKT point refuted at second order; stepping \
446                         along negative curvature to a strictly better feasible \
447                         point (gh #856)");
448                    escapes += 1;
449                    iter.x = next;
450                    iter.working = None;
451                    f_cached = None;
452                    c_cached = None;
453                    prev_point = None;
454                    continue;
455                }
456                self.iterates = Some(iter.clone());
457                return Ok(SqpResult {
458                    x: iter.x,
459                    lambda_g: iter.lambda_g,
460                    lambda_x: iter.lambda_x,
461                    obj: f_curr,
462                    status: SqpStatus::Optimal,
463                    n_iter: outer,
464                    n_qp_solves,
465                    n_qp_working_set_changes,
466                    final_stationarity,
467                    final_constr_viol,
468                    working_set: iter.working,
469                });
470            }
471
472            let qp_data = SqpQpData::build(
473                &iter.x,
474                &grad_f,
475                &c_vals,
476                &bl_c,
477                &bu_c,
478                &xl,
479                &xu,
480                jac_c,
481                hess_lag,
482                self.hessian_inertia(),
483            );
484            let qp = qp_data.as_qp();
485
486            // Scale-relative inner-QP tolerances (issue #358 tail).
487            //
488            // `QpOptions::{feas_tol, opt_tol}` are **absolute** (1e-9 each).
489            // That is a sane default for a standalone `solve_qp` on
490            // well-scaled data, but this QP is an *inner* subproblem whose
491            // data inherits the NLP's scale: with `‖∇f‖ ~ 1e3` and
492            // `‖B‖ ~ 1e3`, an absolute 1e-9 is ~1e-12 *relative* — at the
493            // f64 noise floor. The active-set solver then cannot certify
494            // its own optimality, burns its whole iteration budget, and
495            // returns `MaxIter`; the driver reports `QpStepFailed`, which
496            // surfaces to the user as `Search_Direction_Becomes_Too_Small`
497            // on a QP that is trivially solvable. This is what stalled the
498            // ill-conditioned tail of #358 even once the Hessian scale was
499            // fixed by the probe above.
500            //
501            // Scale both tolerances by the QP data magnitude, so the inner
502            // solve is asked for a *relative* accuracy it can actually
503            // reach. Nothing is lost in the answer: the SQP outer loop
504            // still gates optimality on the true, unscaled NLP KKT
505            // residuals (`dual_inf_tol` / `constr_viol_tol`) at the top of
506            // each iteration, so a sloppier inner step can only cost an
507            // extra outer iteration — never a false `Optimal`. Measured on
508            // a 500-instance convex-QP sweep this converts 34 failures into
509            // successes with a *bit-for-bit identical* error distribution
510            // (median 6e-11, max true constraint violation 4e-11).
511            //
512            // The `SCALE_MAX` clamp bounds the relaxation on pathological
513            // data (a quasi-Newton `B` that has blown up); it does not bind
514            // on any problem in the sweep.
515            const SCALE_MAX: Number = 1e6;
516            let g_inf = grad_f.iter().map(|v| v.abs()).fold(0.0, f64::max);
517            let b_inf = qp_data
518                .h
519                .values()
520                .iter()
521                .map(|v| v.abs())
522                .fold(0.0, f64::max);
523            let base = self.qp_opts.clone();
524            let scale = g_inf.max(b_inf).clamp(1.0, SCALE_MAX);
525            if scale > 1.0 {
526                self.qp_opts.opt_tol = base.opt_tol * scale;
527                self.qp_opts.feas_tol = base.feas_tol * scale;
528            }
529
530            // Warm-start from the previous QP's working set when
531            // available. Pounce-qp's `solve_with_working_set`
532            // internally computes a feasible primal compatible
533            // with the supplied set (it satisfies every active
534            // row exactly) — necessary because each SQP
535            // linearization shifts the QP's constraint RHS by
536            // `-c(x_k)`, so the previous QP's *primal* doesn't
537            // carry over even when the active set does.
538            let warm_started = iter.working.is_some();
539            let mut sol = if let Some(prev_w) = iter.working.as_ref() {
540                // A warm solve that *errors* falls back to cold rather than
541                // aborting the SQP (gh #855). The cold-start fallback below
542                // already exists for a warm solve that comes back `MaxIter` /
543                // `NumericalError`, on the reasoning that the carried-over
544                // working set can be a poor guess; a hard `Err` from the same
545                // call is the **stronger** form of that signal and was the one
546                // case the fallback could not see, because `?` propagated out
547                // of the whole algorithm first.
548                //
549                // The error that motivates this says so itself: `eigena2`
550                // under `algorithm=active-set-sqp` reaches outer iteration 17
551                // and the warm solve fails with "pinned KKT constraint block
552                // is rank-deficient (inertia shift masked a singular
553                // constraint block); prune to a linearly-independent subset".
554                // That is a statement about the *pinned set*, which is exactly
555                // what a warm start supplies and a cold start rebuilds. The
556                // SQP exited `Internal_Error` / `solve_result_num=500` -- "the
557                // solver broke, retry" -- on a model whose objective it can
558                // report; with the cold re-solve it ends
559                // `Maximum_Iterations_Exceeded` at `obj = 82.5177`, against
560                // the NLP arm's 82.5 on the same file.
561                //
562                // The cold error is still propagated: if a clean start fails
563                // too, the failure is not about the working set and there is
564                // nothing further to try here.
565                match self
566                    .qp_solver
567                    .solve_with_working_set(&qp, prev_w, &self.qp_opts)
568                {
569                    Ok(v) => v,
570                    Err(e) => {
571                        tracing::debug!(target: "pounce::sqp",
572                            "warm-started step QP failed hard ({e:?}); re-solving \
573                             from cold, since the carried working set is what a \
574                             cold start rebuilds (gh #855)");
575                        n_qp_solves += 1;
576                        self.qp_solver.solve(&qp, None, &self.qp_opts)?
577                    }
578                }
579            } else {
580                self.qp_solver.solve(&qp, None, &self.qp_opts)?
581            };
582            n_qp_solves += 1;
583            n_qp_working_set_changes += sol.stats.n_working_set_changes;
584
585            // Cold-start fallback: a warm start seeds the QP with the
586            // previous iterate's working set, which is usually a big
587            // win but can occasionally strand the active-set solver at
588            // its iteration limit (or a numerical breakdown) on a QP
589            // that is perfectly solvable from a clean start — e.g.
590            // when a quasi-Newton Hessian has drifted enough that the
591            // carried-over active set is a poor guess. Rather than
592            // give up with `QpStepFailed`, re-solve once from cold;
593            // this is what rescues the curved-constraint SQP runs of
594            // issue #349 that previously reported
595            // `Search_Direction_Becomes_Too_Small`.
596            if warm_started && matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError) {
597                let cold = self.qp_solver.solve(&qp, None, &self.qp_opts)?;
598                n_qp_solves += 1;
599                n_qp_working_set_changes += cold.stats.n_working_set_changes;
600                // `Unbounded` is accepted as well as `Optimal`, and it is
601                // the point of gh #855. A cold re-solve that comes back
602                // `Unbounded` has *found something* — an unblocked direction
603                // of negative curvature in the null space of its working set
604                // — and taking only `Optimal` threw that away, leaving `sol`
605                // on the original `MaxIter`. The unbounded-model fallback
606                // below is gated on `sol.status == Unbounded`, so the
607                // δ-shifted proximal step written for exactly this situation
608                // was unreachable from a retry.
609                //
610                // Accepting it is safe because the fallback does not trust it
611                // either: it re-tests the ray against the true NLP (gh #388)
612                // and only takes the proximal branch when the certificate
613                // does *not* survive. A spurious `Unbounded` therefore costs
614                // one re-test, not a wrong verdict.
615                //
616                // Nothing else can be accepted here: `Infeasible` from a cold
617                // solve would contradict the warm one on the same subproblem
618                // without a tie-breaker, and `MaxIter` / `NumericalError` are
619                // what we already have.
620                //
621                // COVERAGE, stated because it matters: no fixture in the CLI
622                // corpus reaches this branch. Swept under
623                // `algorithm=active-set-sqp`, the retries fire on three
624                // fixtures (`cresc4`, `eigena2`, `jit1_boxed`) and return only
625                // `MaxIter` or `Optimal`, including under `sqp_qp_max_iter`
626                // forced down to 2. gh #855 observed the `Unbounded` return on
627                // `eigena2` in a build carrying second-order certification for
628                // the step subproblem, which is gh #856's subject and does not
629                // exist here yet. It is kept rather than dropped because it is
630                // not redundant -- nothing else makes the unbounded-model
631                // fallback reachable from a retry -- which is the opposite of
632                // the gh #846 case, where a second arm already rejected
633                // everything the removed one would have.
634                if matches!(cold.status, QpStatus::Optimal | QpStatus::Unbounded) {
635                    sol = cold;
636                }
637            }
638
639            // Quasi-Newton reset fallback (issue #358 tail). If the QP
640            // still cannot be solved, the usual culprit is not the
641            // linearization but the *approximated* Hessian: a damped-BFGS
642            // matrix that has accumulated enough drift (typically after a
643            // large early step on an ill-conditioned problem) to make the
644            // step subproblem numerically unsolvable. That is recoverable
645            // — throwing away the accumulated curvature and retrying from
646            // a scaled identity almost always yields a usable step —
647            // whereas the alternative is aborting an otherwise healthy
648            // solve with `QpStepFailed`, which the user sees as
649            // `Search_Direction_Becomes_Too_Small` on a trivially solvable
650            // problem. Rebuild the subproblem around the reset Hessian and
651            // re-solve once, from cold (the carried working set belongs to
652            // the discarded model).
653            let mut qp_data = qp_data;
654            if matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError)
655                && let Some(b) = bfgs.as_mut()
656            {
657                b.reset_to_scale();
658                qp_data = SqpQpData::build(
659                    &iter.x,
660                    &grad_f,
661                    &c_vals,
662                    &bl_c,
663                    &bu_c,
664                    &xl,
665                    &xu,
666                    nlp.eval_jac_c(&iter.x),
667                    b.as_triplet(),
668                    self.hessian_inertia(),
669                );
670                let retry = self
671                    .qp_solver
672                    .solve(&qp_data.as_qp(), None, &self.qp_opts)?;
673                n_qp_solves += 1;
674                n_qp_working_set_changes += retry.stats.n_working_set_changes;
675                // Same as the cold retry above (gh #855): an `Unbounded`
676                // verdict is a finding, not a failure, and discarding it hid
677                // the proximal fallback from this branch too.
678                //
679                // The subproblem stays consistent: this branch runs only
680                // while `sol.status` is `MaxIter`/`NumericalError`, so it
681                // cannot fire after the cold retry has been accepted, and
682                // `qp_data` is rebound above — so the fallback below re-solves
683                // the *reset-Hessian* subproblem that produced this verdict,
684                // not the discarded one.
685                if matches!(retry.status, QpStatus::Optimal | QpStatus::Unbounded) {
686                    sol = retry;
687                }
688            }
689
690            // Unbounded-model fallback (gh #423). The step QP being
691            // unbounded below is a statement about the *linearization*, so
692            // re-test the ray against the true NLP (gh #388) — and when it
693            // does not survive, do not stop there. An unbounded model on a
694            // bounded NLP is not a dead end; it is the textbook signal that
695            // the model needs regularizing (Nocedal-Wright §18.4), and δ
696            // from §4.5 inertia control already *is* that regularization.
697            // So re-solve declining the certificate: the same subproblem,
698            // the same shift, but the unblocked direction takes the
699            // δ-shifted proximal step instead of certifying recession.
700            //
701            // This is not a corner case. A nonconvex NLP with `m = 0` and
702            // no finite bounds has *nothing that can ever block* a
703            // negative-curvature direction, so every indefinite iterate
704            // produces this certificate. gh #419 gave those iterates a real
705            // step where a bound exists and left them with none where one
706            // does not: a chain of coupled double wells (`n = 12`, `m = 0`)
707            // that converged to f = 0.027424 in 24 iterations died at
708            // iteration 1 with `QpStepFailed` at f = 26.03. The proximal
709            // step is slow — that slowness is what #416 was about — but it
710            // is a step, and it is only reached here where the alternative
711            // is no step at all.
712            let mut ray_certified = false;
713            if sol.status == QpStatus::Unbounded {
714                ray_certified = sol.unbounded_ray.as_ref().is_some_and(|d| {
715                    ray_certifies_unbounded(
716                        nlp,
717                        &iter.x,
718                        d,
719                        f_curr,
720                        &grad_f,
721                        &bl_c,
722                        &bu_c,
723                        &xl,
724                        &xu,
725                        self.opts.constr_viol_tol,
726                    )
727                });
728                if !ray_certified {
729                    tracing::debug!(target: "pounce::sqp",
730                        "unbounded step QP whose recession ray does not survive \
731                         re-testing against the NLP — re-solving for the δ-shifted \
732                         proximal step (gh #423)");
733                    let prox_opts = QpOptions {
734                        certify_recession_ray: false,
735                        ..self.qp_opts.clone()
736                    };
737                    let prox = self.qp_solver.solve(&qp_data.as_qp(), None, &prox_opts)?;
738                    n_qp_solves += 1;
739                    if prox.status == QpStatus::Optimal {
740                        sol = prox;
741                    }
742                }
743            }
744            self.qp_opts = base;
745
746            match sol.status {
747                QpStatus::Optimal => {}
748                QpStatus::Infeasible => {
749                    let obj = nlp.eval_f(&iter.x);
750                    self.iterates = Some(iter.clone());
751                    return Ok(SqpResult {
752                        x: iter.x,
753                        lambda_g: iter.lambda_g,
754                        lambda_x: iter.lambda_x,
755                        obj,
756                        status: SqpStatus::InfeasibleSubproblem,
757                        n_iter: outer,
758                        n_qp_solves,
759                        n_qp_working_set_changes,
760                        final_stationarity,
761                        final_constr_viol,
762                        working_set: iter.working,
763                    });
764                }
765                // The QP subproblem neither solved nor certified
766                // infeasibility. `MaxIter` / `NumericalError` mean the
767                // active-set QP could not resolve the (typically extremely
768                // degenerate) step subproblem — the m/n ≫ 1 collapsed-cone
769                // geometry of #282. Terminate the SQP with an HONEST
770                // non-committal status rather than a hard error, and — the
771                // point of #282 — WITHOUT ever asserting infeasibility on a
772                // problem we have not certified infeasible.
773                QpStatus::MaxIter | QpStatus::TimeLimit | QpStatus::NumericalError => {
774                    let obj = nlp.eval_f(&iter.x);
775                    self.iterates = Some(iter.clone());
776                    // Report *which* of the two it was. Both are honest
777                    // non-committal failures making no infeasibility claim
778                    // (#282), but only the budget one is actionable by the
779                    // user, and merging them hid the dominant Maros-Mészáros
780                    // failure mode behind a step-size verdict. See
781                    // `SqpStatus::QpIterationLimit`.
782                    // `TimeLimit` rides with `MaxIter`: it is the same kind of
783                    // outcome (a budget ran out, no claim about the problem),
784                    // and it is the closest honest report `SqpStatus` can make
785                    // today. Nothing here reaches it yet — the SQP never sets
786                    // `QpOptions::time_limit` on a step subproblem, so the
787                    // active-set solver has no deadline to cross — but leaving
788                    // the arm unhandled would make the first caller that does
789                    // set one land in `QpStepFailed`, i.e. "the step broke
790                    // down", which would be false.
791                    let status = if matches!(sol.status, QpStatus::MaxIter | QpStatus::TimeLimit) {
792                        SqpStatus::QpIterationLimit
793                    } else {
794                        SqpStatus::QpStepFailed
795                    };
796                    return Ok(SqpResult {
797                        x: iter.x,
798                        lambda_g: iter.lambda_g,
799                        lambda_x: iter.lambda_x,
800                        obj,
801                        status,
802                        n_iter: outer,
803                        n_qp_solves,
804                        n_qp_working_set_changes,
805                        final_stationarity,
806                        final_constr_viol,
807                        working_set: iter.working,
808                    });
809                }
810                // The step QP is unbounded below along a certified
811                // recession ray of the LOCAL model (zero curvature,
812                // feasible for every step length, strict descent). That
813                // is a statement about the linearization, not yet about
814                // the NLP — so it was re-tested against the true
815                // objective and constraints above. If it survived, the
816                // NLP itself is unbounded and we say so with the same
817                // `Diverging_Iterates` verdict every other POUNCE path
818                // returns. If it did not, the proximal re-solve above
819                // already had its chance to produce a step, and reaching
820                // here means even that failed: the QP simply could not
821                // produce a usable step, which is `QpStepFailed`.
822                //
823                // Neither outcome is a hard error. This used to return
824                // `QpFailure(LinearSolverFailure("QP subproblem returned
825                // status unbounded"))`, which surfaced to AMPL / Pyomo /
826                // GAMS consumers as `Internal_Error` /
827                // `solve_result_num=500` — "the solver broke" — on a
828                // model that is merely unbounded (`300`), and named a
829                // linear-solver failure when no linear solver had failed
830                // (gh #388).
831                QpStatus::Unbounded => {
832                    let certified = ray_certified;
833                    let obj = nlp.eval_f(&iter.x);
834                    self.iterates = Some(iter.clone());
835                    return Ok(SqpResult {
836                        x: iter.x,
837                        lambda_g: iter.lambda_g,
838                        lambda_x: iter.lambda_x,
839                        obj,
840                        status: if certified {
841                            SqpStatus::Unbounded
842                        } else {
843                            SqpStatus::QpStepFailed
844                        },
845                        n_iter: outer,
846                        n_qp_solves,
847                        n_qp_working_set_changes,
848                        final_stationarity,
849                        final_constr_viol,
850                        working_set: iter.working,
851                    });
852                }
853            }
854
855            #[cfg(test)]
856            if self.opts.print_level >= 1 {
857                let p_inf = sol.x.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
858                tracing::debug!(target: "pounce::sqp",
859                    "         qp: ‖p‖_inf={:.3e} ‖λ_g_qp‖_inf={:.3e}",
860                    p_inf,
861                    sol.lambda_g.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
862                );
863            }
864            // Globalization: l1-merit backtracking (Han-Powell)
865            // or filter (Fletcher-Leyffer 2002). The two share
866            // the same backtracking shell + acceptance API; the
867            // filter keeps state across iterations on
868            // `self.filter`.
869            //
870            // Both are handed a second-order-correction (SOC)
871            // provider (the Maratos remedy). When the full step
872            // (α = 1) is rejected because it increased the
873            // constraint violation, the line search calls this
874            // closure with `c(x_k + p)` to obtain a corrected full
875            // step. We build that step by re-solving the SAME QP
876            // with the general-constraint RHS re-centered on the
877            // trial-point constraint values: the original QP models
878            // `c(x_k) + A p`, and the SOC replaces `c(x_k)` by
879            // `c(x_k + p) − A p`, so the correction subproblem
880            // targets the true (curved) violation at the trial
881            // point (Nocedal-Wright §18.11). The just-solved working
882            // set warm-starts the correction. Only meaningful with
883            // general constraints (`m > 0`).
884            //
885            // Pre-computed `A p` for the RHS re-centering:
886            let a_p = if m > 0 {
887                mat_vec_gen(&qp_data.a, &sol.x, m)
888            } else {
889                Vec::new()
890            };
891            let mut n_soc_solves: u32 = 0;
892            // Working set from the SOC subproblem, kept so that a
893            // taken SOC step warm-starts the next iteration from the
894            // active set that actually describes `x + p_soc` (not the
895            // original QP's set, which belongs to the rejected step).
896            let mut soc_working: Option<WorkingSet> = None;
897            let ls = {
898                let qp_solver = &mut self.qp_solver;
899                let qp_opts = &self.qp_opts;
900                let qp_data_ref = &qp_data;
901                let c_curr_ref = &c_vals;
902                let a_p_ref = &a_p;
903                let sol_working = &sol.working;
904                let n_soc = &mut n_soc_solves;
905                let soc_working_slot = &mut soc_working;
906                let mut soc = |c_trial: &[Number]| -> Option<crate::sqp::line_search::SocStep> {
907                    let mm = qp_data_ref.m;
908                    // Re-center the general-constraint RHS on the
909                    // trial-point violation, preserving ±∞ sentinels.
910                    let mut bl_soc = qp_data_ref.bl.clone();
911                    let mut bu_soc = qp_data_ref.bu.clone();
912                    for i in 0..mm {
913                        let delta = c_curr_ref[i] - c_trial[i] + a_p_ref[i];
914                        if qp_data_ref.bl[i] > NLP_LOWER_BOUND_INF {
915                            bl_soc[i] = qp_data_ref.bl[i] + delta;
916                        }
917                        if qp_data_ref.bu[i] < NLP_UPPER_BOUND_INF {
918                            bu_soc[i] = qp_data_ref.bu[i] + delta;
919                        }
920                    }
921                    let qp_soc = QpProblem {
922                        n: qp_data_ref.n,
923                        m: qp_data_ref.m,
924                        h: &qp_data_ref.h,
925                        g: &qp_data_ref.g,
926                        a: &qp_data_ref.a,
927                        bl: &bl_soc,
928                        bu: &bu_soc,
929                        xl: &qp_data_ref.xl,
930                        xu: &qp_data_ref.xu,
931                        hessian_inertia: qp_data_ref.hessian_inertia,
932                    };
933                    let sol_soc = qp_solver
934                        .solve_with_working_set(&qp_soc, sol_working, qp_opts)
935                        .ok()?;
936                    *n_soc += 1;
937                    if sol_soc.status == QpStatus::Optimal {
938                        *soc_working_slot = Some(sol_soc.working);
939                        Some(crate::sqp::line_search::SocStep {
940                            p: sol_soc.x,
941                            lambda_g: sol_soc.lambda_g,
942                            lambda_x: sol_soc.lambda_x,
943                        })
944                    } else {
945                        None
946                    }
947                };
948                let soc_ref: Option<crate::sqp::line_search::SocProvider<'_>> =
949                    if m > 0 { Some(&mut soc) } else { None };
950                match self.opts.globalization {
951                    SqpGlobalization::L1Elastic => l1_merit_line_search(
952                        nlp,
953                        &iter.x,
954                        &sol.x,
955                        &sol.lambda_g,
956                        &grad_f,
957                        f_curr,
958                        &c_vals,
959                        &bl_c,
960                        &bu_c,
961                        &xl,
962                        &xu,
963                        nu,
964                        &self.opts,
965                        soc_ref,
966                    ),
967                    SqpGlobalization::Filter => filter_line_search(
968                        nlp,
969                        &mut self.filter,
970                        &iter.x,
971                        &sol.x,
972                        f_curr,
973                        &c_vals,
974                        &bl_c,
975                        &bu_c,
976                        &xl,
977                        &xu,
978                        nu,
979                        &self.opts,
980                        soc_ref,
981                    ),
982                }
983            };
984            n_qp_solves += n_soc_solves;
985            #[cfg(test)]
986            if self.opts.print_level >= 1 {
987                tracing::debug!(target: "pounce::sqp",
988                    "         ls: α={:.3e} ν={:.3e} ok={} f_new={:.3e}",
989                    ls.alpha, ls.nu, ls.success, ls.f_new
990                );
991            }
992            if !ls.success {
993                self.iterates = Some(iter.clone());
994                return Ok(SqpResult {
995                    x: iter.x,
996                    lambda_g: iter.lambda_g,
997                    lambda_x: iter.lambda_x,
998                    obj: f_curr,
999                    status: SqpStatus::LineSearchFailed,
1000                    n_iter: outer,
1001                    n_qp_solves,
1002                    n_qp_working_set_changes,
1003                    final_stationarity,
1004                    final_constr_viol,
1005                    working_set: Some(sol.working),
1006                });
1007            }
1008            iter.x = ls.x_new;
1009            match ls.soc_duals {
1010                Some((soc_lg, soc_lx)) => {
1011                    // A second-order-correction step was taken (α = 1
1012                    // on the SOC subproblem). Adopt the SOC
1013                    // subproblem's own multipliers and working set so
1014                    // `(step, multipliers, active set)` stay a
1015                    // consistent triple — required for the quasi-
1016                    // Newton Hessian update to stay well-conditioned
1017                    // and for the next QP to warm-start correctly.
1018                    iter.lambda_g = soc_lg;
1019                    iter.lambda_x = soc_lx;
1020                    iter.working = soc_working.take().or(Some(sol.working));
1021                }
1022                None => {
1023                    for (l, &lq) in iter.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
1024                        *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
1025                    }
1026                    for (l, &lq) in iter.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
1027                        *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
1028                    }
1029                    iter.working = Some(sol.working);
1030                }
1031            }
1032            nu = ls.nu;
1033            f_cached = Some(ls.f_new);
1034            c_cached = Some(ls.c_new);
1035        }
1036
1037        let obj = nlp.eval_f(&iter.x);
1038        self.iterates = Some(iter.clone());
1039        Ok(SqpResult {
1040            x: iter.x,
1041            lambda_g: iter.lambda_g,
1042            lambda_x: iter.lambda_x,
1043            obj,
1044            status: SqpStatus::MaxIter,
1045            n_iter: self.opts.max_iter,
1046            n_qp_solves,
1047            n_qp_working_set_changes,
1048            final_stationarity,
1049            final_constr_viol,
1050            working_set: iter.working,
1051        })
1052    }
1053
1054    fn hessian_inertia(&self) -> HessianInertia {
1055        match self.opts.hessian {
1056            // Exact ∇²L is indefinite on nonconvex NLPs; let the
1057            // QP solver's §4.5 inertia control handle it.
1058            crate::sqp::SqpHessianSource::Exact => HessianInertia::Indefinite,
1059            // Damped BFGS and L-BFGS are PSD by construction.
1060            crate::sqp::SqpHessianSource::DampedBfgs => HessianInertia::Psd,
1061            crate::sqp::SqpHessianSource::Lbfgs => HessianInertia::Psd,
1062        }
1063    }
1064}
1065
1066/// gh #388: does the step QP's certified recession ray certify the **NLP**
1067/// unbounded below?
1068///
1069/// The inner QP hands back a direction `d` that is a recession ray *of the
1070/// linearization at `x`*: `∇²L d ≈ 0`, `d` feasible for the linearized
1071/// constraints at every step length, `∇q(x)ᵀd < 0`. On an LP or a QP that
1072/// linearization is exact and `d` is a recession ray of the original
1073/// problem; on a general NLP it need not be — the constraints curve back
1074/// and the objective can turn around. The two cases must not share a
1075/// status, so we settle it by evaluation rather than by faith: walk the
1076/// ray and check, at the **true** `f` and `c`, that
1077///
1078///  1. every probe point is *feasible* (variable bounds and constraint
1079///     bounds, the latter with a roundoff allowance that grows with the
1080///     row scale so a linear row evaluated at `‖x‖ ~ 1e12` is not failed
1081///     on cancellation noise), and
1082///  2. the objective keeps falling at **at least half** the initial linear
1083///     rate `∇f(x)ᵀd` — not merely falling. A ray that decelerates is
1084///     settling onto a finite optimum, the same distinction the IPM's
1085///     divergence guard draws (#248/#252/#285).
1086///
1087/// Probes span twelve decades of step length, so a "pass" is a family of
1088/// genuinely feasible points whose objective marches to `−∞` at a linear
1089/// rate over `1e12`. Anything short of that — one infeasible probe, one
1090/// decelerating decade, a NaN — returns `false` and the caller reports the
1091/// non-committal `QpStepFailed` instead. False negatives cost an honest
1092/// "no step" status; a false positive would tell a modeler their bounded
1093/// model is unbounded, so the asymmetry is deliberate.
1094///
1095/// `dir` need not be normalized (it is rescaled to unit max-norm here, so
1096/// the probe lengths are in the iterate's own units).
1097#[allow(clippy::too_many_arguments)]
1098fn ray_certifies_unbounded<N: SqpProblemSpec>(
1099    nlp: &mut N,
1100    x: &[Number],
1101    dir: &[Number],
1102    f_x: Number,
1103    grad_f: &[Number],
1104    bl_c: &[Number],
1105    bu_c: &[Number],
1106    xl: &[Number],
1107    xu: &[Number],
1108    constr_viol_tol: Number,
1109) -> bool {
1110    /// Step lengths along the unit-max-norm ray, spanning twelve decades.
1111    const PROBES: [Number; 7] = [1e0, 1e2, 1e4, 1e6, 1e8, 1e10, 1e12];
1112    /// Roundoff allowance per unit of `row_scale · ‖x‖∞` when checking a
1113    /// constraint at a far-out probe: comfortably above f64 epsilon
1114    /// (`2.2e-16`) to absorb accumulation over a row, far below anything
1115    /// a real violation would produce.
1116    const ROUNDOFF_REL: Number = 1e-12;
1117
1118    let n = x.len();
1119    if dir.len() != n || grad_f.len() != n || !f_x.is_finite() {
1120        return false;
1121    }
1122    let scale = dir.iter().map(|v| v.abs()).fold(0.0, f64::max);
1123    if !scale.is_finite() || scale <= 0.0 {
1124        return false;
1125    }
1126    let d: Vec<Number> = dir.iter().map(|v| v / scale).collect();
1127
1128    // Descent of the TRUE objective along the ray. The QP certified this
1129    // for its own (possibly quasi-Newton) model gradient; re-derive it
1130    // from `∇f(x)` so the rate we hold the probes to is the real one.
1131    let slope: Number = grad_f.iter().zip(d.iter()).map(|(g, di)| g * di).sum();
1132    let g_norm = grad_f.iter().map(|v| v * v).sum::<Number>().sqrt();
1133    // Numerically meaningful (not roundoff-scale) descent; a NaN slope
1134    // fails the `is_finite` guard rather than sneaking past the comparison.
1135    let descent_bar = -1e-9 * g_norm.max(1.0);
1136    if !slope.is_finite() || slope >= descent_bar {
1137        return false;
1138    }
1139
1140    // Per-row `max_j |∂c_i/∂x_j|`, the scale a linear row's value grows
1141    // with along the ray — the basis for the roundoff allowance in (1).
1142    let m = bl_c.len();
1143    let mut row_scale: Vec<Number> = vec![0.0; m];
1144    {
1145        let jac = nlp.eval_jac_c(x);
1146        for k in 0..jac.vals.len() {
1147            let i = (jac.irow[k] - 1) as usize;
1148            row_scale[i] = row_scale[i].max(jac.vals[k].abs());
1149        }
1150    }
1151
1152    for &t in PROBES.iter() {
1153        let xt: Vec<Number> = x.iter().zip(d.iter()).map(|(xi, di)| xi + t * di).collect();
1154        if xt.iter().any(|v| !v.is_finite()) {
1155            return false;
1156        }
1157
1158        // (1a) Variable bounds. These are exact linear rows in the probe's
1159        // own arithmetic, so the tolerance stays tight.
1160        for i in 0..n {
1161            let tol = 1e-9 * (1.0 + xt[i].abs());
1162            if xl[i] > NLP_LOWER_BOUND_INF && xt[i] < xl[i] - tol {
1163                return false;
1164            }
1165            if xu[i] < NLP_UPPER_BOUND_INF && xt[i] > xu[i] + tol {
1166                return false;
1167            }
1168        }
1169
1170        // (1b) Constraint bounds, at the true (possibly nonlinear) `c`.
1171        let x_inf = xt.iter().map(|v| v.abs()).fold(0.0, f64::max);
1172        let c = nlp.eval_c(&xt);
1173        if c.len() != m {
1174            return false;
1175        }
1176        for i in 0..m {
1177            if c[i].is_nan() {
1178                return false;
1179            }
1180            let tol =
1181                constr_viol_tol.max(0.0) * (1.0 + c[i].abs()) + ROUNDOFF_REL * row_scale[i] * x_inf;
1182            if bl_c[i] > NLP_LOWER_BOUND_INF && c[i] < bl_c[i] - tol {
1183                return false;
1184            }
1185            if bu_c[i] < NLP_UPPER_BOUND_INF && c[i] > bu_c[i] + tol {
1186                return false;
1187            }
1188        }
1189
1190        // (2) Sustained (non-decelerating) descent. `-inf` passes: an
1191        // objective that has already overflowed downward is not evidence
1192        // against unboundedness.
1193        let f_t = nlp.eval_f(&xt);
1194        if f_t.is_nan() || f_t > f_x + 0.5 * slope * t {
1195            return false;
1196        }
1197    }
1198    true
1199}
1200
1201#[derive(Debug, Clone, Copy)]
1202pub(crate) struct KktError {
1203    pub stationarity: Number,
1204    pub constr_viol: Number,
1205}
1206
1207/// Sparse `A · p` for an `m × n` general-constraint Jacobian stored
1208/// as a `GenTMatrix` (1-based triplet indices). Used to re-center
1209/// the second-order-correction QP's RHS on the trial point.
1210fn mat_vec_gen(a: &GenTMatrix, p: &[Number], m: usize) -> Vec<Number> {
1211    let mut out = vec![0.0; m];
1212    let irows = a.irows();
1213    let jcols = a.jcols();
1214    let vals = a.values();
1215    for k in 0..vals.len() {
1216        let i = (irows[k] - 1) as usize;
1217        let j = (jcols[k] - 1) as usize;
1218        out[i] += vals[k] * p[j];
1219    }
1220    out
1221}
1222
1223/// Build the quasi-Newton curvature pair `(s, y)` for the step from the
1224/// previous iterate to the current one, differencing `∇L` at a **single,
1225/// fixed multiplier** (Nocedal-Wright §18.3):
1226///
1227/// ```text
1228///     s = x_k − x_{k−1}
1229///     y = ∇L(x_k, λ_k) − ∇L(x_{k−1}, λ_k)        ← the SAME λ_k twice
1230/// ```
1231///
1232/// Returns `None` on the first iteration (no previous point yet).
1233///
1234/// **Why the fixed multiplier matters (gh #361).** The previous code held
1235/// `∇L(x_{k−1}, λ_{k−1})` inside the Hessian object and differenced against
1236/// `∇L(x_k, λ_k)`, giving
1237///
1238/// ```text
1239///     y = (∇f_k − ∇f_{k−1}) + (J_kᵀλ_k − J_{k−1}ᵀλ_{k−1})
1240/// ```
1241///
1242/// For **linear** constraints `J` is constant, so that second group collapses
1243/// to `Aᵀ(λ_k − λ_{k−1})` — pure *multiplier* difference, carrying no
1244/// curvature information at all. Since the true `∇²L` equals `∇²f` there, the
1245/// whole term is spurious, and it feeds a divergent loop: a perturbed `B`
1246/// yields a worse QP multiplier, which injects a larger error into the next
1247/// `y`, which corrupts `B` further. On equality-constrained QPs (where `λ` is
1248/// sign-free and can swing hard) the multiplier was observed oscillating and
1249/// growing exponentially — `−13, 19, −69, 104, −145, 581, −1320, 3176, …` —
1250/// while `x` itself sat on the exact optimum. The solve then burned its whole
1251/// iteration budget and exited `Maximum_Iterations_Exceeded` *at the right
1252/// answer*, because the stationarity residual is computed from that garbage
1253/// multiplier.
1254///
1255/// Using one multiplier at both points makes the term telescope to
1256/// `Σλᵏᵢ(∇cᵢ(x_k) − ∇cᵢ(x_{k−1}))`, which is the genuine constraint-curvature
1257/// contribution: it vanishes identically for linear constraints (as it must)
1258/// and is retained for nonlinear ones.
1259fn curvature_pair(
1260    prev: Option<&(Vec<Number>, Vec<Number>, Triplet)>,
1261    iter: &SqpIterates,
1262    grad_f: &[Number],
1263    jac_c: &Triplet,
1264    n: usize,
1265) -> Option<(Vec<Number>, Vec<Number>)> {
1266    let (prev_x, prev_grad_f, prev_jac) = prev?;
1267    let s: Vec<Number> = iter
1268        .x
1269        .iter()
1270        .zip(prev_x.iter())
1271        .map(|(a, b)| a - b)
1272        .collect();
1273    // Both evaluated at the *current* multiplier `iter.lambda_g`.
1274    let lag_curr = compute_grad_lag(grad_f, jac_c, &iter.lambda_g, n);
1275    let lag_prev = compute_grad_lag(prev_grad_f, prev_jac, &iter.lambda_g, n);
1276    let y: Vec<Number> = lag_curr
1277        .iter()
1278        .zip(lag_prev.iter())
1279        .map(|(a, b)| a - b)
1280        .collect();
1281    Some((s, y))
1282}
1283
1284/// Lagrangian gradient `∇L(x, λ_g) = ∇f(x) + J_c(x)ᵀ λ_g` at the
1285/// current iterate. Used by the damped-BFGS update.
1286fn compute_grad_lag(
1287    grad_f: &[Number],
1288    jac_c: &Triplet,
1289    lambda_g: &[Number],
1290    n: usize,
1291) -> Vec<Number> {
1292    let mut out = grad_f.to_vec();
1293    debug_assert_eq!(out.len(), n);
1294    for k in 0..jac_c.irow.len() {
1295        let row_i = (jac_c.irow[k] - 1) as usize;
1296        let col_j = (jac_c.jcol[k] - 1) as usize;
1297        out[col_j] += jac_c.vals[k] * lambda_g[row_i];
1298    }
1299    out
1300}
1301/// Walk `d` from `x` and return a strictly better feasible point, or `None`.
1302///
1303/// This is what turns a curvature *direction* into a refutation. gh #848
1304/// established the shape one layer down: a feasible point with a strictly
1305/// lower objective is proof needing no theory, so the search that produced
1306/// `d` may be as approximate as it likes — a direction it gets wrong costs
1307/// the evaluations below and nothing else.
1308///
1309/// The step length is the distance to the first blocking variable bound,
1310/// halved back until the *nonlinear* constraints are satisfied to
1311/// `constr_viol_tol`. That backtrack is the difference between this and the
1312/// QP-level version: `d` lies in the null space of the *linearized* active
1313/// constraints, which holds them exactly only where they are linear.
1314/// `nonconvex_qp`'s equality is linear, so the first trial is accepted there;
1315/// a curved constraint gives up its step rather than trading feasibility for
1316/// objective, which the SQP's own merit function would then have to undo.
1317#[allow(clippy::too_many_arguments)]
1318fn exhibit_better_point<N: SqpProblemSpec>(
1319    nlp: &mut N,
1320    x: &[Number],
1321    d: &[Number],
1322    f_curr: Number,
1323    xl: &[Number],
1324    xu: &[Number],
1325    bl_c: &[Number],
1326    bu_c: &[Number],
1327    constr_viol_tol: Number,
1328) -> Option<Vec<Number>> {
1329    let n = x.len();
1330    let dn = d.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
1331    if !(dn > 0.0) || !dn.is_finite() {
1332        return None;
1333    }
1334    // Both signs: curvature is even, so `-d` descends wherever `d` does, and
1335    // only one of them may have room before a bound.
1336    //
1337    // Every feasible trial is *scored* and the best one is returned, rather
1338    // than the first that clears the bar (gh#873). Two reasons, and the second
1339    // is a defect the first exposed:
1340    //
1341    // 1. The profile along `d` is not monotone. `f(x + αd) ≈ f(x) + ½α²·dᵀ∇²f d`
1342    //    only near `x`; on `nonconvex_two_escapes` the quartic term takes `f`
1343    //    back to `+1.8` at the wall while the interior of the same ray reaches
1344    //    `−0.225`. So the step length has to be searched, not guessed —
1345    //    which is why the halving below now runs on *either* rejection and not
1346    //    only on infeasibility, the mechanism that had the SQP arm certifying
1347    //    that fixture's documented maximum at every `neg_curv_escapes`.
1348    // 2. `sign` is scanned in a fixed order, so "first acceptable" made the
1349    //    answer depend on it. On `min −x₀² + x₁²` with `x₀ ∈ [−2, g]` the `+d`
1350    //    wall is worth `−g²` and the `−d` wall is worth `−4`; returning the
1351    //    first meant a `g` small enough to clear the bar handed back `−g²` and
1352    //    threw away the global minimum sitting in the other sign.
1353    //
1354    // Scoring costs nothing extra — the evaluations already happened — and the
1355    // bound stays the 24 halvings per sign the loop always carried, spent once
1356    // at convergence on a point already known to have negative curvature.
1357    // The feasibility a refutation is held to is the *incumbent's*, not the
1358    // convergence tolerance (gh#873, found by the fixture sweep).
1359    //
1360    // `constr_viol_tol` is the bar for calling a solve converged; using it here
1361    // let the exhibition buy objective with infeasibility. Measured on
1362    // `cresc4.nl` (lbfgs leg): the KKT point is feasible to 2.2e-16, and the
1363    // trials this accepted violated the rows by 6e-7 — nine orders worse, but
1364    // legal under `constr_viol_tol = 1e-6` — to gain 4.4e-7 of objective. The
1365    // arm then restored feasibility and returned to the same point, eight
1366    // times over, until `MAX_SECOND_ORDER_ESCAPES` capped it: 15 iterations
1367    // became 45 for an answer identical to 15 significant figures. That is
1368    // gh#544's shape exactly — the right answer, slowly — and it is the reason
1369    // CLAUDE.md requires the sweep on a trajectory change.
1370    //
1371    // A point that proves the incumbent is not a local minimum has to be at
1372    // least as feasible as the incumbent is. The slack is `1e-12` *relative to
1373    // the trial's own row magnitudes*, so it is roundoff and not a distance in
1374    // the units of `c`; and it is clamped at `constr_viol_tol`, so this bar is
1375    // never looser than the one it replaces.
1376    //
1377    // The cost is honest and worth naming: against a *curved* active
1378    // constraint a straight tangent probe leaves the feasible set at order
1379    // `α²`, so on such a model the exhibition now declines rather than
1380    // accepting a point that is only tolerance-feasible. That is the same
1381    // structural limit as gh#873 D3 — this walks a straight line and tests the
1382    // objective — made visible instead of paid for in iterations.
1383    let viol_at = |nlp: &mut N, v: &[Number]| -> (Number, Number) {
1384        let c = nlp.eval_c(v);
1385        let mut viol = 0.0_f64;
1386        let mut scale = 0.0_f64;
1387        for (j, &cj) in c.iter().enumerate() {
1388            viol = viol
1389                .max((bl_c[j] - cj).max(0.0))
1390                .max((cj - bu_c[j]).max(0.0));
1391            scale = scale.max(cj.abs());
1392        }
1393        (viol, scale)
1394    };
1395    let (viol_curr, _) = viol_at(nlp, x);
1396
1397    let mut best: Option<(Number, Vec<Number>)> = None;
1398    for sign in [1.0_f64, -1.0] {
1399        let mut alpha = f64::INFINITY;
1400        for i in 0..n {
1401            let di = sign * d[i];
1402            if di > 1e-12 * dn && xu[i] < f64::INFINITY {
1403                alpha = alpha.min((xu[i] - x[i]) / di);
1404            }
1405            if di < -1e-12 * dn && xl[i] > f64::NEG_INFINITY {
1406                alpha = alpha.min((x[i] - xl[i]) / -di);
1407            }
1408        }
1409        // Unbounded in this direction is not this function's business -- the
1410        // unbounded-model fallback owns that -- so cap and keep going.
1411        if !alpha.is_finite() {
1412            alpha = 1.0 / dn;
1413        }
1414        for _ in 0..24 {
1415            if !(alpha > 0.0) {
1416                break;
1417            }
1418            let trial: Vec<Number> = (0..n)
1419                .map(|i| (x[i] + sign * alpha * d[i]).clamp(xl[i], xu[i]))
1420                .collect();
1421            let (viol, c_scale) = viol_at(nlp, &trial);
1422            let feas_bar = viol_curr.max(1e-12 * c_scale).min(constr_viol_tol);
1423            if viol <= feas_bar {
1424                let f_trial = nlp.eval_f(&trial);
1425                if f_trial.is_finite() && best.as_ref().is_none_or(|(b, _)| f_trial < *b) {
1426                    best = Some((f_trial, trial));
1427                }
1428            }
1429            alpha *= 0.5;
1430        }
1431    }
1432
1433    // Strictly better by more than the objective's own scale can round, so
1434    // this cannot fire on noise at a genuine optimum.
1435    //
1436    // The `1.0 +` is what made that "the objective's own scale" rather than an
1437    // absolute `1e-10`, and it is itself scaled here (gh#873 D2, the same class
1438    // as gh#872). On `min k·x₀x₁ s.t. x₀ + x₁ = 2` the true improvement is
1439    // `64·k` (the corner `(9, −7)` against the stationary `(1, 1)`), so from
1440    // `k ≈ 1e-12` down the whole model lived below the additive
1441    // `1e-10` and every genuine refutation was rejected as noise — while the
1442    // reduced Hessian is `−k`, as indefinite at `k = 1e-30` as at `k = 1`.
1443    //
1444    // Lowered only: `.min(1.0)` leaves the bar exactly as it was for any
1445    // objective at or above unit scale, so this cannot make an existing solve
1446    // newly refutable.
1447    let (f_best, x_best) = best?;
1448    let f_scale = f_curr.abs().max(f_best.abs());
1449    let bar = 1e-10 * (f_scale.min(1.0) + f_curr.abs());
1450    (f_best < f_curr - bar).then_some(x_best)
1451}
1452
1453/// The activity tolerance for one bound or row, in that quantity's own units.
1454///
1455/// A raw `constr_viol_tol` is a distance in the units of `x` (for a bound) or
1456/// of `c` (for a row), so using it directly is an absolute threshold on a
1457/// scale-dependent quantity — gh#873 D2, the same class as gh#872. Confirmed
1458/// as pure scaling by an exact change of variables `u = S·x` with the gap held
1459/// fixed in `x` units: at `S = 1e-2` a `5e-7` gap crosses `tol`, and a bound
1460/// with multiplier zero is frozen into the working set, closing the null space
1461/// and the second-order verdict with it.
1462///
1463/// `scale` is the quantity and its own bounds; infinite entries are skipped,
1464/// and everything left scales together under a change of units, so the ratio
1465/// the test really wants is invariant.
1466///
1467/// Scaled **only downwards**, as in gh#872's `psd_band`: widening the test
1468/// above unit scale would freeze bounds that are free today, which loses
1469/// refutations rather than gaining them, and every refutation still has to
1470/// exhibit a strictly better feasible point before it changes any answer.
1471fn active_tol(tol: Number, scale: &[Number]) -> Number {
1472    let s = scale
1473        .iter()
1474        .filter(|v| v.is_finite())
1475        .fold(0.0_f64, |a, v| a.max(v.abs()));
1476    tol * s.min(1.0)
1477}
1478
1479/// A feasible direction of negative curvature at a *converged* first-order
1480/// point, or `None` when the point survives the second-order test (gh #856).
1481///
1482/// # Why this runs at convergence and not at each step
1483///
1484/// gh #848 gave standalone QP solves a second-order screen. Applying the same
1485/// screen to the SQP's **step** subproblem is wrong, and gh #856 has the
1486/// counterexample: that QP is a local model built from the *current*
1487/// multiplier estimates, and its second-order verdict is not the NLP's. At
1488/// SQP iteration 0 the multipliers are still zero, so the exact Lagrangian
1489/// Hessian is `∇²f`; started at HS071's own `x*` the step QP's working set
1490/// leaves a one-dimensional null space on which `dᵀHd = -4.05e-2`, and the
1491/// point is refuted — correctly for that model, wrongly for the NLP, whose
1492/// reduced Hessian at `x*` is positive once the multipliers have converged.
1493///
1494/// At *convergence* that objection disappears: the multipliers are the
1495/// converged ones, so `∇²L` is the Hessian the second-order condition is
1496/// actually about. This is the same distinction gh #856 draws when it says
1497/// "with the converged multipliers the reduced Hessian is positive" — the
1498/// check is meaningful exactly where it is run.
1499///
1500/// # What it computes
1501///
1502/// `Z` is an orthonormal basis for the null space of the active constraint
1503/// normals — equality rows, active inequality rows and active bounds — taken
1504/// from the eigenvectors of `BᵀB` whose eigenvalue is negligible against the
1505/// largest. The reduced Hessian `ZᵀHZ` is then eigen-decomposed, and a
1506/// sufficiently negative eigenvalue yields `d = Z v`: a direction that holds
1507/// every active constraint to first order and along which the objective
1508/// curves down.
1509///
1510/// Returns `None` when there is no negative curvature, when the active set
1511/// leaves no degrees of freedom, or when either eigensolve fails to converge
1512/// — never a direction it is unsure of, since the caller acts on it.
1513#[allow(clippy::too_many_arguments)]
1514fn negative_curvature_at_kkt_point(
1515    n: usize,
1516    m: usize,
1517    x: &[Number],
1518    hess_lag: &Triplet,
1519    jac_c: &Triplet,
1520    c_vals: &[Number],
1521    bl_c: &[Number],
1522    bu_c: &[Number],
1523    xl: &[Number],
1524    xu: &[Number],
1525    tol: Number,
1526) -> Option<Vec<Number>> {
1527    // Dense and `O(n³)`, so it is bounded rather than let loose on a large
1528    // model. It runs once, at convergence, on the way to reporting success —
1529    // and skipping it returns exactly today's answer, so the ceiling costs
1530    // coverage and never correctness. (Contrast gh #849, where the analogous
1531    // ceiling silently withdrew a *guarantee*.)
1532    const MAX_N: usize = 512;
1533    if n == 0 || n > MAX_N {
1534        return None;
1535    }
1536
1537    // The active set. A bound or row counts as active when the iterate sits
1538    // on it to within the same tolerance the convergence test just used, so
1539    // this is the set the verdict was issued about.
1540    let mut rows: Vec<Vec<Number>> = Vec::new();
1541    for j in 0..m {
1542        let t = active_tol(tol, &[c_vals[j], bl_c[j], bu_c[j]]);
1543        let lo_active = bl_c[j] > f64::NEG_INFINITY && (c_vals[j] - bl_c[j]).abs() <= t;
1544        let hi_active = bu_c[j] < f64::INFINITY && (bu_c[j] - c_vals[j]).abs() <= t;
1545        if lo_active || hi_active {
1546            let mut r = vec![0.0; n];
1547            // `pounce_linalg` triplets are **1-based** (see `triplet.rs`).
1548            for k in 0..jac_c.vals.len() {
1549                if jac_c.irow[k] as usize == j + 1 {
1550                    r[jac_c.jcol[k] as usize - 1] += jac_c.vals[k];
1551                }
1552            }
1553            rows.push(r);
1554        }
1555    }
1556    for i in 0..n {
1557        let t = active_tol(tol, &[x[i], xl[i], xu[i]]);
1558        let on_lo = xl[i] > f64::NEG_INFINITY && (x[i] - xl[i]).abs() <= t;
1559        let on_hi = xu[i] < f64::INFINITY && (xu[i] - x[i]).abs() <= t;
1560        if on_lo || on_hi {
1561            let mut r = vec![0.0; n];
1562            r[i] = 1.0;
1563            rows.push(r);
1564        }
1565    }
1566
1567    // Null space of the active normals, from the eigenvectors of `BᵀB`.
1568    let mut z: Vec<Number> = Vec::new();
1569    let n_dof;
1570    if rows.is_empty() {
1571        n_dof = n;
1572        z = vec![0.0; n * n];
1573        for i in 0..n {
1574            z[i * n + i] = 1.0;
1575        }
1576    } else {
1577        let mut btb = vec![0.0; n * n];
1578        for r in &rows {
1579            for a in 0..n {
1580                if r[a] == 0.0 {
1581                    continue;
1582                }
1583                for c in 0..n {
1584                    btb[c * n + a] += r[a] * r[c];
1585                }
1586            }
1587        }
1588        let (mut ev, mut evec) = (vec![0.0; n], vec![0.0; n * n]);
1589        if !pounce_linalg::symmetric_eigen(&btb, n, &mut ev, &mut evec) {
1590            return None;
1591        }
1592        let lam_max = ev.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
1593        // Relative to `BᵀB`'s own spectral scale, with no absolute floor under
1594        // it (gh#873 D2). `lam_max` is already that scale, so the `.max(1.0)`
1595        // this carried made the rank cut absolute for small constraint
1596        // normals: a row scaled down by a change of units then read as part of
1597        // the null space, and the direction built from it does not respect the
1598        // constraint it came from.
1599        //
1600        // `lam_max == 0` means every active row has a zero gradient, so none
1601        // of them restricts anything and the whole space is free. The old
1602        // `.max(1.0)` produced that answer as a side effect of the floor;
1603        // stated here so removing the floor does not quietly change it into
1604        // "no degrees of freedom", which would decline the refutation.
1605        let cut = if lam_max > 0.0 { 1e-9 * lam_max } else { 0.0 };
1606        if lam_max == 0.0 {
1607            z.extend_from_slice(&evec[..n * n]);
1608            n_dof = n;
1609        } else {
1610            for (j, &lam) in ev.iter().enumerate() {
1611                if lam.abs() <= cut {
1612                    z.extend_from_slice(&evec[j * n..(j + 1) * n]);
1613                }
1614            }
1615            n_dof = z.len() / n;
1616        }
1617    }
1618    if n_dof == 0 {
1619        return None;
1620    }
1621
1622    // `H Z`, then `Zᵀ (H Z)`.
1623    let mut hz = vec![0.0; n * n_dof];
1624    for k in 0..n_dof {
1625        let (zc, out) = (&z[k * n..(k + 1) * n], &mut hz[k * n..(k + 1) * n]);
1626        // Stored as one triangle, so an off-diagonal entry contributes to
1627        // both of its rows.
1628        for e in 0..hess_lag.vals.len() {
1629            let (r, c, v) = (
1630                hess_lag.irow[e] as usize - 1,
1631                hess_lag.jcol[e] as usize - 1,
1632                hess_lag.vals[e],
1633            );
1634            out[r] += v * zc[c];
1635            if r != c {
1636                out[c] += v * zc[r];
1637            }
1638        }
1639    }
1640    let mut rh = vec![0.0; n_dof * n_dof];
1641    for a in 0..n_dof {
1642        for b in 0..n_dof {
1643            rh[b * n_dof + a] = (0..n).map(|i| z[a * n + i] * hz[b * n + i]).sum();
1644        }
1645    }
1646
1647    let (mut ev, mut evec) = (vec![0.0; n_dof], vec![0.0; n_dof * n_dof]);
1648    if !pounce_linalg::symmetric_eigen(&rh, n_dof, &mut ev, &mut evec) {
1649        return None;
1650    }
1651    // Relative to the Hessian's own scale, so this cannot fire on the
1652    // rounding noise of a genuinely positive-semidefinite reduced Hessian --
1653    // and with no absolute floor under it (gh#873 D2, gh#872's sibling). The
1654    // `.max(1.0)` this carried made the test absolute below unit Hessian
1655    // scale, and `min k·x₀x₁ s.t. x₀ + x₁ = 2` then returned the constrained
1656    // *maximum* `f = k` for every `k` from `1e-8` down -- a family whose
1657    // reduced Hessian is `−k`, i.e. as indefinite at `k = 1e-30` as at `k = 1`.
1658    let h_scale = hess_lag.vals.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
1659    if ev[0] >= -1e-8 * h_scale {
1660        return None;
1661    }
1662    // `d = Z v` with `v` the eigenvector of the most negative eigenvalue --
1663    // column 0 of the column-major `evec`, since the eigensolver returns them
1664    // in ascending order.
1665    let mut d = vec![0.0; n];
1666    for (i, di) in d.iter_mut().enumerate() {
1667        *di = (0..n_dof).map(|k| z[k * n + i] * evec[k]).sum();
1668    }
1669    Some(d)
1670}
1671
1672pub(crate) fn check_kkt(
1673    n: usize,
1674    m: usize,
1675    iter: &SqpIterates,
1676    grad_f: &[Number],
1677    c_vals: &[Number],
1678    bl_c: &[Number],
1679    bu_c: &[Number],
1680    xl: &[Number],
1681    xu: &[Number],
1682    jac_c: &crate::sqp::qp_assembly::Triplet,
1683) -> KktError {
1684    // Constraint violation: max(0, bl - c, c - bu) on every row,
1685    // plus bound violation on every variable.
1686    //
1687    // `NaN` propagates rather than reducing away (gh #876). `f64::max`
1688    // ignores `NaN`, so `(bl - c).max(0.0)` on a non-finite `c` is `0.0` and
1689    // a diverged iterate scores as perfectly feasible. The same reduction on
1690    // the stationarity rows below is the site the issue reports; both are
1691    // fixed, because either one alone still lets `check_kkt` return a clean
1692    // `KktError` from garbage.
1693    let mut viol = 0.0_f64;
1694    let mut nonfinite = false;
1695    for i in 0..m {
1696        let lo = if bl_c[i] > NLP_LOWER_BOUND_INF {
1697            (bl_c[i] - c_vals[i]).max(0.0)
1698        } else {
1699            0.0
1700        };
1701        let hi = if bu_c[i] < NLP_UPPER_BOUND_INF {
1702            (c_vals[i] - bu_c[i]).max(0.0)
1703        } else {
1704            0.0
1705        };
1706        nonfinite |= !c_vals[i].is_finite();
1707        viol = viol.max(lo).max(hi);
1708    }
1709    for i in 0..n {
1710        nonfinite |= !iter.x[i].is_finite();
1711        let lo = if xl[i] > NLP_LOWER_BOUND_INF {
1712            (xl[i] - iter.x[i]).max(0.0)
1713        } else {
1714            0.0
1715        };
1716        let hi = if xu[i] < NLP_UPPER_BOUND_INF {
1717            (iter.x[i] - xu[i]).max(0.0)
1718        } else {
1719            0.0
1720        };
1721        viol = viol.max(lo).max(hi);
1722    }
1723
1724    // Stationarity: ∇f + Jᵀ λ_g − λ_x. pounce-qp's KKT is
1725    // `Hx + Aᵀλ_qp + (lower-bound multiplier) e_i − (upper-bound
1726    // multiplier) e_i = -g`. Since `λ_x = z_l − z_u` packs the
1727    // bound-multiplier sign, the variable-bound term enters the
1728    // stationarity check with a negative sign — i.e. at the
1729    // optimum `∇f + Jᵀ λ_g = λ_x`.
1730    let mut stat = vec![0.0; n];
1731    for (s, &g) in stat.iter_mut().zip(grad_f.iter()) {
1732        *s = g;
1733    }
1734    // Add Jᵀ λ_g
1735    for k in 0..jac_c.irow.len() {
1736        let i = (jac_c.irow[k] - 1) as usize; // 0-based row in c
1737        let j = (jac_c.jcol[k] - 1) as usize; // 0-based col in x
1738        stat[j] += jac_c.vals[k] * iter.lambda_g[i];
1739    }
1740    // Subtract λ_x
1741    for (s, &lx) in stat.iter_mut().zip(iter.lambda_x.iter()) {
1742        *s -= lx;
1743    }
1744    let stat_max = crate::sqp::line_search::inf_norm(&stat);
1745
1746    // An iterate or a constraint value that is not finite makes every residual
1747    // computed from it meaningless, whichever way the arithmetic happens to
1748    // reduce: `+inf - inf` is `NaN`, but `(bl - inf).max(0.0)` is a tidy `0.0`
1749    // and `inf.abs()` is a large number that at least fails `<= tol`. Reporting
1750    // `NaN` for both is the honest answer and the one the caller's `<= tol`
1751    // gates already handle correctly.
1752    if nonfinite {
1753        return KktError {
1754            stationarity: Number::NAN,
1755            constr_viol: Number::NAN,
1756        };
1757    }
1758
1759    KktError {
1760        stationarity: stat_max,
1761        constr_viol: viol,
1762    }
1763}
1764
1765#[cfg(test)]
1766mod kkt_nan_tests {
1767    //! gh #876 — `check_kkt` must not launder a non-finite iterate into a
1768    //! clean `KktError`.
1769    //!
1770    //! The caller's convergence gate is
1771    //! `kkt.stationarity <= stationarity_tol && kkt.constr_viol <= constr_viol_tol`,
1772    //! so anything `check_kkt` reports as `0.0` is reported as converged. Both
1773    //! of its reductions used to swallow `NaN`:
1774    //!
1775    //! * `stat.iter().map(|s| s.abs()).fold(0.0_f64, f64::max)` — `f64::max`
1776    //!   is defined to *ignore* `NaN`, so an all-`NaN` stationarity vector
1777    //!   reduced to `0.0`. This is the site gh #876 reports, and the third
1778    //!   instance of the same shape in this workspace: gh #222 fixed it in
1779    //!   `pounce-convex`, gh #845 in `pounce-sensitivity`.
1780    //! * `viol.max(lo).max(hi)` where `lo = (bl - c).max(0.0)` — the same
1781    //!   definition one layer earlier, so a `NaN` constraint value scored as
1782    //!   *perfectly feasible* before the outer `max` ever saw it. The issue
1783    //!   does not name this one; fixing only the reported site would still
1784    //!   have left `constr_viol` reading `0.0` on a diverged iterate.
1785    //!
1786    //! ## Which branch each test reaches
1787    //!
1788    //! Per this repo's gh #756 lesson, a guard is only evidence about the
1789    //! branch its fixture reaches, so the four tests below are chosen to land
1790    //! in four different places: a `NaN` arriving through `c_vals`, one
1791    //! arriving through `x`, one arriving through the *duals* (where `x` and
1792    //! `c` are both finite and only the stationarity row is poisoned — the
1793    //! `inf_norm` fix, not the `nonfinite` flag), and an ordinary finite
1794    //! iterate that must be completely unaffected.
1795    //!
1796    //! ## Mutation table
1797    //!
1798    //! | revert | red |
1799    //! |---|---|
1800    //! | `inf_norm` back to `fold(0.0, f64::max)` | `a_nan_dual_poisons_only_stationarity`, `inf_norm_tests::a_nan_entry_propagates_rather_than_reducing_away` |
1801    //! | drop the `nonfinite` flag | `a_nan_constraint_value_is_not_perfect_feasibility`, `a_nan_iterate_is_not_perfect_feasibility` |
1802    //! | both | all four of the above |
1803    //!
1804    //! Measured, both directions: each mutation turns exactly the listed
1805    //! tests red and leaves the others — including
1806    //! `a_finite_iterate_is_measured_exactly_as_before` — green. Neither half
1807    //! subsumes the other, which is why both are here.
1808
1809    use super::*;
1810    use crate::sqp::qp_assembly::Triplet;
1811    use pounce_common::types::Index;
1812
1813    /// `min x0` s.t. one row `c(x) = x0`, no bounds — the smallest shape that
1814    /// exercises every accumulation in `check_kkt`.
1815    fn setup(x: Vec<Number>, c: Vec<Number>, lam_g: Vec<Number>) -> KktError {
1816        let n = x.len();
1817        let m = c.len();
1818        let iter = SqpIterates {
1819            x,
1820            lambda_g: lam_g,
1821            lambda_x: vec![0.0; n],
1822            working: None,
1823        };
1824        let jac = Triplet {
1825            n_rows: m,
1826            n_cols: n,
1827            irow: (1..=m as i32).map(|i| i as Index).collect(),
1828            jcol: vec![1 as Index; m],
1829            vals: vec![1.0; m],
1830        };
1831        check_kkt(
1832            n,
1833            m,
1834            &iter,
1835            &vec![1.0; n],
1836            &c,
1837            &vec![0.0; m],
1838            &vec![0.0; m],
1839            &vec![NLP_LOWER_BOUND_INF; n],
1840            &vec![NLP_UPPER_BOUND_INF; n],
1841            &jac,
1842        )
1843    }
1844
1845    #[test]
1846    fn a_finite_iterate_is_measured_exactly_as_before() {
1847        // x0 = 2 against the equality row c = x0 = 0: violation 2, and
1848        // stationarity ∇f + Jᵀλ = 1 + 1·(-1) = 0.
1849        let k = setup(vec![2.0], vec![2.0], vec![-1.0]);
1850        assert_eq!(k.constr_viol, 2.0);
1851        assert_eq!(k.stationarity, 0.0);
1852    }
1853
1854    #[test]
1855    fn a_nan_constraint_value_is_not_perfect_feasibility() {
1856        let k = setup(vec![0.0], vec![Number::NAN], vec![-1.0]);
1857        assert!(
1858            k.constr_viol.is_nan(),
1859            "a NaN constraint value reported constr_viol = {}, which clears \
1860             every tolerance the caller compares it against",
1861            k.constr_viol
1862        );
1863        assert!(k.stationarity.is_nan());
1864    }
1865
1866    #[test]
1867    fn a_nan_iterate_is_not_perfect_feasibility() {
1868        let k = setup(vec![Number::NAN], vec![0.0], vec![-1.0]);
1869        assert!(k.constr_viol.is_nan());
1870        assert!(k.stationarity.is_nan());
1871    }
1872
1873    /// The dual-side branch: `x` and `c` are both finite, so the `nonfinite`
1874    /// flag stays false and nothing but `inf_norm` stands between a `NaN`
1875    /// multiplier and a stationarity residual of `0.0`. This is the test that
1876    /// the `nonfinite` flag alone does **not** cover.
1877    #[test]
1878    fn a_nan_dual_poisons_only_stationarity() {
1879        let k = setup(vec![0.0], vec![0.0], vec![Number::NAN]);
1880        assert!(
1881            k.stationarity.is_nan(),
1882            "a NaN multiplier reduced to stationarity = {}",
1883            k.stationarity
1884        );
1885        // Feasibility genuinely holds at this x, and saying so is correct —
1886        // the flag is about the *primal* iterate, not about the duals.
1887        assert_eq!(k.constr_viol, 0.0);
1888    }
1889}
1890
1891#[cfg(test)]
1892mod inf_norm_tests {
1893    use crate::sqp::line_search::inf_norm;
1894
1895    #[test]
1896    fn a_nan_entry_propagates_rather_than_reducing_away() {
1897        assert!(inf_norm(&[1.0, f64::NAN, 2.0]).is_nan());
1898        assert!(inf_norm(&[f64::NAN]).is_nan());
1899    }
1900
1901    #[test]
1902    fn finite_vectors_are_unchanged() {
1903        assert_eq!(inf_norm(&[]), 0.0);
1904        assert_eq!(inf_norm(&[-3.0, 1.0, 2.0]), 3.0);
1905        assert_eq!(inf_norm(&[f64::INFINITY, 1.0]), f64::INFINITY);
1906    }
1907}