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