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