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    BoundStatus, ConsStatus, HessianInertia, ParametricActiveSetSolver, QpOptions, QpSolver,
32    QpStatus, WorkingSet,
33};
34
35/// Header for the `sqp_print_level >= 1` iteration table.
36///
37/// Column widths for `iter` / `objective` / `inf_pr` / `inf_du` are the
38/// interior-point table's exactly (`crate::output::orig`), so the two arms'
39/// logs line up when a reader puts them side by side on one model. The
40/// remaining columns are the ones this arm actually has: an active-set SQP
41/// has no barrier parameter and no dual step length, so `lg(mu)`, `lg(rg)`
42/// and `alpha_du` would be permanently blank, and it does have a working-set
43/// size that the IPM does not.
44const SQP_ITER_HEADER: &str = "iter      objective   inf_pr   inf_du    ||p||    alpha     ws";
45
46/// What the step into the current iterate cost — the trailing columns of a
47/// row. `None` on row 0, where no step has been taken yet.
48struct SqpStepRecord {
49    /// `‖p‖_inf` of the QP step accepted into this iterate.
50    p_inf: Number,
51    /// Line-search step length.
52    alpha: Number,
53    /// Whether the accepted step was a second-order correction. Rendered as
54    /// a trailing `c`, the same position Ipopt puts its `alpha_char` in.
55    soc: bool,
56    /// Active-set changes (adds + drops) in the QP that produced the step.
57    ws_changes: u32,
58}
59
60/// One row of the `sqp_print_level >= 1` table.
61///
62/// Row `k` reports the residuals **at** `x_k`; the step columns describe the
63/// step that arrived there, so they are blank on row 0. That is Ipopt's
64/// convention and `crate::output::orig::OrigIterationOutput::format_row`'s.
65fn format_sqp_row(
66    iter: u32,
67    obj: Number,
68    inf_pr: Number,
69    inf_du: Number,
70    step: Option<&SqpStepRecord>,
71) -> String {
72    use crate::output::orig::format_e;
73    match step {
74        Some(st) => format!(
75            "{:>4} {:>14} {:>8} {:>8} {:>8} {:>8}{} {:>5}",
76            iter,
77            format_e(obj, 7),
78            format_e(inf_pr, 2),
79            format_e(inf_du, 2),
80            format_e(st.p_inf, 2),
81            format_e(st.alpha, 2),
82            if st.soc { 'c' } else { ' ' },
83            st.ws_changes,
84        ),
85        None => format!(
86            "{:>4} {:>14} {:>8} {:>8} {:>8} {:>8}  {:>5}",
87            iter,
88            format_e(obj, 7),
89            format_e(inf_pr, 2),
90            format_e(inf_du, 2),
91            "-",
92            "-",
93            "-",
94        ),
95    }
96}
97
98/// What the per-iteration hook sees: the iterate `x_k` and the residuals
99/// measured **at** it (the same numbers the `sqp_print_level >= 1` row
100/// prints), plus the step that arrived there.
101pub struct SqpIterationInfo<'a> {
102    pub iter: u32,
103    pub obj: Number,
104    pub inf_pr: Number,
105    pub inf_du: Number,
106    /// `‖p‖_inf` of the accepted step into this iterate (0 on iteration 0).
107    pub p_inf: Number,
108    /// Line-search step length of that step (0 on iteration 0).
109    pub alpha: Number,
110    pub x: &'a [Number],
111    /// `[λ_c; λ_d]` in algorithm space.
112    pub lambda_g: &'a [Number],
113    /// Signed bound multiplier `z_L - z_U` per variable.
114    pub lambda_x: &'a [Number],
115    /// `[c(x); d(x)]` in algorithm space.
116    pub c_vals: &'a [Number],
117    /// `∇f + Jᵀλ_g − λ_x` (the stationarity residual `check_kkt` measures)
118    /// in algorithm space, from this iterate's `∇f` and `J`.
119    pub grad_lag_x: &'a [Number],
120}
121
122/// Per-iteration hook. Returning `false` stops the solve.
123pub type SqpIterationHook = Box<dyn FnMut(&SqpIterationInfo<'_>) -> bool>;
124
125/// SQP-side algorithm driver.
126pub struct SqpAlgorithm {
127    iteration_hook: Option<SqpIterationHook>,
128    qp_solver: ParametricActiveSetSolver,
129    qp_opts: QpOptions,
130    opts: SqpOptions,
131    /// `opts.hessian` resolved for the current solve's `n` (see
132    /// [`SqpHessianSource::resolve`]); set at the top of every solve.
133    hessian: SqpHessianSource,
134    iterates: Option<SqpIterates>,
135    /// Filter for Fletcher-Leyffer globalization; reset at the
136    /// top of each `optimize` call. Unused when
137    /// `opts.globalization = L1Elastic`.
138    filter: SqpFilter,
139    /// Whether this solve's cold step QPs are seeded by the convex interior
140    /// point; set per solve from `opts.qp_ipm_cold_start_min_dim`.
141    ipm_cold_start: bool,
142    /// The last interior-point seed's iteration count, which sets a warm
143    /// step QP's budget (see [`solve_subproblem_qp`]).
144    ipm_seed: IpmSeed,
145}
146
147/// State the IPM-seeded subproblem solves share across one SQP solve.
148#[derive(Default)]
149pub(crate) struct IpmSeed {
150    /// Interior-point iterations of the most recent successful seed.
151    pub(crate) last_iters: std::cell::Cell<Option<usize>>,
152}
153
154impl SqpAlgorithm {
155    pub fn new(qp_solver: ParametricActiveSetSolver, opts: SqpOptions) -> Self {
156        Self {
157            qp_solver,
158            qp_opts: QpOptions::sqp_subproblem(),
159            hessian: opts.hessian,
160            opts,
161            iterates: None,
162            filter: SqpFilter::new(),
163            iteration_hook: None,
164            ipm_cold_start: false,
165            ipm_seed: IpmSeed::default(),
166        }
167    }
168
169    /// Install a hook fired once per iteration, at the iterate the loop is
170    /// about to test for convergence (iteration 0 and the final iterate
171    /// included, as on the interior-point arm). Returning `false` ends the
172    /// solve with [`SqpStatus::UserRequestedStop`].
173    pub fn set_iteration_hook(&mut self, hook: SqpIterationHook) {
174        self.iteration_hook = Some(hook);
175    }
176
177    /// Override the per-call QP-solver options. Defaults are
178    /// `pounce_qp::QpOptions::sqp_subproblem()` — `QpOptions::default()`
179    /// with second-order certification off, for the reason given there
180    /// (which include the
181    /// `use_schur_updates = false` and `anti_cycling = Expand`
182    /// from Phase 5a.2). Callers can pin tighter tolerances or
183    /// flip `use_schur_updates = true` for warm-started workloads.
184    pub fn with_qp_options(mut self, qp_opts: QpOptions) -> Self {
185        self.qp_opts = qp_opts;
186        self
187    }
188
189    pub fn options(&self) -> &SqpOptions {
190        &self.opts
191    }
192
193    pub fn iterates(&self) -> Option<&SqpIterates> {
194        self.iterates.as_ref()
195    }
196
197    /// Run the SQP loop to convergence (or `max_iter`). Cold-starts
198    /// the iterate from `nlp.x_init()` and an empty working set.
199    pub fn optimize<N: SqpProblemSpec>(&mut self, nlp: &mut N) -> Result<SqpResult, SqpError> {
200        self.optimize_with_warm_start(nlp, None)
201    }
202
203    /// Warm-start variant. `warm = Some(prev)` seeds the iterate
204    /// from `prev.{x, lambda_g, lambda_x, working}` instead of the
205    /// NLP's cold defaults. Dimensions are validated against the
206    /// problem; any mismatch is fatal. The QP solver consumes
207    /// `warm.working` (when present) via `solve_with_working_set`.
208    ///
209    /// `warm = None` is equivalent to [`Self::optimize`].
210    ///
211    /// Implements the §6 design-note warm-start contract: the
212    /// tuple `(x, λ_g, λ_x, 𝒲)`. The Hessian carry-forward
213    /// (damped-BFGS / L-BFGS state) is *not* part of the warm-start
214    /// payload — each `optimize` call rebuilds its own Hessian
215    /// approximation from scratch.
216    pub fn optimize_with_warm_start<N: SqpProblemSpec>(
217        &mut self,
218        nlp: &mut N,
219        warm: Option<SqpIterates>,
220    ) -> Result<SqpResult, SqpError> {
221        // The step-QP iteration budget scales with the problem unless the
222        // caller set one (gh#1054). Restored afterwards, so a reused driver
223        // re-derives it for the next problem's size.
224        // Schur updates likewise follow the size when the caller left them
225        // to the driver (`SqpOptions::step_qp_schur_updates`). This is the
226        // cold rule; a warm-started step QP may override it (`warm_qp_opts`).
227        let saved = (self.qp_opts.max_iter, self.qp_opts.use_schur_updates);
228        self.qp_opts.max_iter = step_qp_iter_budget(saved.0, nlp.n(), nlp.m());
229        if let Some(on) = self.opts.step_qp_schur_updates(nlp.n() + nlp.m(), None) {
230            self.qp_opts.use_schur_updates = on;
231        }
232        self.ipm_cold_start = self
233            .opts
234            .qp_ipm_cold_start_min_dim
235            .is_some_and(|d| nlp.n() + nlp.m() >= d);
236        self.ipm_seed.last_iters.set(None);
237        let res = self.optimize_inner(nlp, warm);
238        (self.qp_opts.max_iter, self.qp_opts.use_schur_updates) = saved;
239        res
240    }
241
242    fn optimize_inner<N: SqpProblemSpec>(
243        &mut self,
244        nlp: &mut N,
245        warm: Option<SqpIterates>,
246    ) -> Result<SqpResult, SqpError> {
247        let n = nlp.n();
248        let m = nlp.m();
249        let (xl, xu) = nlp.variable_bounds();
250        let (bl_c, bu_c) = nlp.constraint_bounds();
251        if xl.len() != n || xu.len() != n {
252            return Err(SqpError::DimensionMismatch(format!(
253                "variable_bounds length must be n = {n}"
254            )));
255        }
256        if bl_c.len() != m || bu_c.len() != m {
257            return Err(SqpError::DimensionMismatch(format!(
258                "constraint_bounds length must be m = {m}"
259            )));
260        }
261
262        let mut iter = match warm {
263            Some(w) => {
264                if w.x.len() != n {
265                    return Err(SqpError::DimensionMismatch(format!(
266                        "warm.x length {} must equal n = {n}",
267                        w.x.len()
268                    )));
269                }
270                if w.lambda_g.len() != m {
271                    return Err(SqpError::DimensionMismatch(format!(
272                        "warm.lambda_g length {} must equal m = {m}",
273                        w.lambda_g.len()
274                    )));
275                }
276                if w.lambda_x.len() != n {
277                    return Err(SqpError::DimensionMismatch(format!(
278                        "warm.lambda_x length {} must equal n = {n}",
279                        w.lambda_x.len()
280                    )));
281                }
282                if let Some(ws) = w.working.as_ref() {
283                    ws.validate_dims(n, m).map_err(SqpError::QpFailure)?;
284                }
285                w
286            }
287            None => {
288                let mut cold = SqpIterates::cold(n, m);
289                let x_init = nlp.x_init();
290                if x_init.len() != n {
291                    return Err(SqpError::DimensionMismatch(format!(
292                        "x_init length must be n = {n}"
293                    )));
294                }
295                cold.x = x_init;
296                cold
297            }
298        };
299
300        let mut n_qp_solves: u32 = 0;
301        // Inner active-set work: adds + drops summed over every step QP
302        // solved in this call. This — not the outer iteration count — is
303        // what a warm start is trying to reduce, and on a QP-shaped NLP
304        // (one outer iteration by construction) it is the *only* thing
305        // that moves. Second-order-correction QPs are not counted: they
306        // are solved inside the line search, which does not surface its
307        // subproblem stats.
308        let mut n_qp_working_set_changes: u32 = 0;
309        let mut final_stationarity = 0.0;
310        let mut final_constr_viol = 0.0;
311        // l1-merit penalty parameter ν, adapted across iterations
312        // by `l1_merit_line_search`. Initialized from
313        // `SqpOptions::l1_penalty`.
314        let mut nu = self.opts.l1_penalty;
315        // Trailing columns of the `sqp_print_level >= 1` row: what the step
316        // into the CURRENT iterate cost. Written at the end of each
317        // iteration, read at the top of the next, `None` on row 0.
318        let mut prev_step: Option<SqpStepRecord> = None;
319        // Reset filter state at the top of each optimize call.
320        self.filter = SqpFilter::new();
321        // Cache the most recent f(x) and c(x) so we don't
322        // re-evaluate them after a successful line search (the
323        // LS already computed them at the new iterate).
324        let mut f_cached: Option<Number> = None;
325        let mut c_cached: Option<Vec<Number>> = None;
326        // Previous iterate's `(x, ∇f, ∇c)`, kept so the quasi-Newton
327        // curvature pair can difference `∇L` at a single fixed multiplier
328        // (see [`curvature_pair`]). Storing `∇L` directly — as the older
329        // `DampedBfgs::update(x, ∇L)` form did — bakes in the multiplier
330        // that was current at the time, which is precisely the bug.
331        let mut prev_point: Option<(Vec<Number>, Vec<Number>, Triplet)> = None;
332
333        // Damped-BFGS state, allocated only if needed. The
334        // matrix is updated at the END of each iteration (after
335        // we have x_new and the next ∇L), then queried at the
336        // TOP of the next iteration to populate the QP Hessian.
337        self.hessian = self.opts.hessian.resolve(n, self.opts.dense_hessian_max_n);
338        // L-BFGS above the dense ceiling goes to the QP in product form,
339        // lifted (gh#1054); below it, as the dense matrix it always was.
340        let lift_lbfgs =
341            self.hessian == SqpHessianSource::Lbfgs && n > self.opts.dense_hessian_max_n;
342        let mut bfgs: Option<DampedBfgs> = if matches!(self.hessian, SqpHessianSource::DampedBfgs) {
343            Some(DampedBfgs::new(n))
344        } else {
345            None
346        };
347        let mut lbfgs: Option<LBfgs> = if matches!(self.hessian, SqpHessianSource::Lbfgs) {
348            Some(LBfgs::new(n, self.opts.lbfgs_max_history.max(1) as usize))
349        } else {
350            None
351        };
352
353        // Iteration-0 curvature probe (issue #358 tail).
354        //
355        // `DampedBfgs::update` sizes the identity seed from the first
356        // `(s, y)` pair — but that pair only exists at iteration 1, and
357        // iteration **0** already solves a QP against `B`. With `B = I`
358        // on a problem where `‖∇²L‖ ≫ 1`, that first step overshoots the
359        // Newton step by `~cond(∇²L)`; the filter (empty, and `θ` tiny at
360        // a near-feasible start) accepts the objective-blowing step, the
361        // iterate is flung to `‖x‖ ~ 1e3`, and the huge `(s, y)` pairs
362        // that follow drive `B` so ill-conditioned that the QP subproblem
363        // itself fails a few iterations later (`QpStepFailed`, surfacing
364        // as `Search_Direction_Becomes_Too_Small`).
365        //
366        // Fix the scale *before* that first QP with one extra gradient
367        // evaluation: step a short distance along the steepest-descent
368        // direction, difference the gradients, and seed `B = γI` with the
369        // resulting Rayleigh quotient `γ = sᵀy / sᵀs`. For a quadratic
370        // this is exactly the curvature along the probe direction, and it
371        // lies in `[λ_min(∇²L), λ_max(∇²L)]`.
372        //
373        // The probe differences the *objective* gradient, so it estimates
374        // `∇²f` — which equals the Lagrangian Hessian `∇²L = ∇²f + Σλᵢ∇²cᵢ`
375        // only when the constraint-curvature term vanishes, i.e. when every
376        // constraint is linear (or there are none). That condition is
377        // exactly the #358 family, and it is *not* cosmetic: on the Maratos
378        // problem (`min 2(x₁²+x₂²−1) − x₁ s.t. x₁²+x₂²=1`) `∇²f = 4I` while
379        // `∇²L ≈ I` at the solution, so seeding the objective curvature
380        // would over-scale `B` fourfold and cost that solve its convergence.
381        //
382        // Detect linearity directly rather than trusting a declaration:
383        // compare the constraint Jacobian at the probe point with the one
384        // at `x`. Identical ⇒ `∇c` is constant ⇒ constraints are linear ⇒
385        // the objective Hessian *is* the Lagrangian Hessian and the probe
386        // is exact. Otherwise leave the identity seed alone and let the
387        // rank-2 updates (which see the true `∇L`) do the work.
388        if bfgs.is_some() || lbfgs.is_some() {
389            let g0 = nlp.eval_grad_f(&iter.x);
390            let g_norm = g0.iter().map(|v| v * v).sum::<Number>().sqrt();
391            if g_norm.is_finite() && g_norm > 0.0 {
392                // Absolute probe length, scaled by the iterate so the step
393                // is meaningful in the problem's own units but always tiny
394                // relative to it. `1e-7` keeps the gradient difference well
395                // above f64 roundoff without leaving the local model.
396                let x_scale = iter.x.iter().map(|v| v.abs()).fold(1.0, f64::max);
397                let eps = 1e-7 * x_scale;
398                let step: Vec<Number> = g0.iter().map(|gi| -eps * gi / g_norm).collect();
399                let x_probe: Vec<Number> =
400                    iter.x.iter().zip(step.iter()).map(|(a, d)| a + d).collect();
401                // Constant-Jacobian (linear-constraint) check, per above.
402                let linear_constraints = m == 0 || {
403                    let j0 = nlp.eval_jac_c(&iter.x);
404                    let j1 = nlp.eval_jac_c(&x_probe);
405                    j0.vals.len() == j1.vals.len()
406                        && j0.vals.iter().zip(j1.vals.iter()).all(|(a, c)| {
407                            // Relative comparison: a linear constraint
408                            // reproduces its Jacobian bit-for-bit, so this
409                            // only tolerates evaluation noise.
410                            let scale = a.abs().max(c.abs()).max(1.0);
411                            (a - c).abs() <= 1e-12 * scale
412                        })
413                };
414                if linear_constraints {
415                    let g1 = nlp.eval_grad_f(&x_probe);
416                    let s_y: Number = step
417                        .iter()
418                        .zip(g1.iter().zip(g0.iter()))
419                        .map(|(si, (a, bg))| si * (a - bg))
420                        .sum();
421                    let s_s: Number = step.iter().map(|v| v * v).sum();
422                    if s_s > 0.0 && s_y.is_finite() {
423                        // A non-positive quotient means the probe direction
424                        // has non-positive curvature (nonconvex or
425                        // numerically flat); `seed_scale` ignores it, leaving
426                        // the identity seed rather than a meaningless or
427                        // negative scale.
428                        // L-BFGS takes the same seed (gh#1054): it used to
429                        // start from `B = I` with no probe at all, which is
430                        // the #358 overshoot this block exists to prevent.
431                        if let Some(b) = bfgs.as_mut() {
432                            b.seed_scale(s_y / s_s);
433                        }
434                        if let Some(b) = lbfgs.as_mut() {
435                            b.seed_scale(s_y / s_s);
436                        }
437                    }
438                }
439            }
440        }
441
442        // Bounded so a curvature escape that keeps returning to the same
443        // neighbourhood cannot spin: each one costs a fresh descent, and a
444        // handful is far more than any of the measured models needs (one).
445        const MAX_SECOND_ORDER_ESCAPES: u32 = 8;
446        let mut escapes: u32 = 0;
447
448        for outer in 0..self.opts.max_iter {
449            let grad_f = nlp.eval_grad_f(&iter.x);
450            let c_vals = c_cached.take().unwrap_or_else(|| nlp.eval_c(&iter.x));
451            let f_curr = f_cached.take().unwrap_or_else(|| nlp.eval_f(&iter.x));
452            let jac_c = nlp.eval_jac_c(&iter.x);
453            // KKT check uses the current iterate's evaluations.
454            let kkt = check_kkt(
455                n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
456            );
457            final_stationarity = kkt.stationarity;
458            final_constr_viol = kkt.constr_viol;
459
460            // Fired BEFORE this iteration's Lagrangian Hessian is evaluated, as
461            // on the interior-point arm (callback after acceptance, before the
462            // next Hessian): a caller that builds `eval_h` from the iterate it
463            // read in the callback (gh#1015) must see THIS iterate, not the
464            // previous one.
465            if let Some(hook) = self.iteration_hook.as_mut() {
466                let (p_inf, alpha) = prev_step
467                    .as_ref()
468                    .map_or((0.0, 0.0), |s| (s.p_inf, s.alpha));
469                // `check_kkt`'s stationarity vector, from the same `∇f`, `J`.
470                let mut grad_lag_x = grad_f.clone();
471                for k in 0..jac_c.irow.len() {
472                    let (i, j) = ((jac_c.irow[k] - 1) as usize, (jac_c.jcol[k] - 1) as usize);
473                    if let (Some(g), Some(&l)) = (grad_lag_x.get_mut(j), iter.lambda_g.get(i)) {
474                        *g += jac_c.vals[k] * l;
475                    }
476                }
477                for (g, &lx) in grad_lag_x.iter_mut().zip(iter.lambda_x.iter()) {
478                    *g -= lx;
479                }
480                let keep_going = hook(&SqpIterationInfo {
481                    iter: outer,
482                    obj: f_curr,
483                    inf_pr: kkt.constr_viol,
484                    inf_du: kkt.stationarity,
485                    p_inf,
486                    alpha,
487                    x: &iter.x,
488                    lambda_g: &iter.lambda_g,
489                    lambda_x: &iter.lambda_x,
490                    c_vals: &c_vals,
491                    grad_lag_x: &grad_lag_x,
492                });
493                if !keep_going {
494                    self.iterates = Some(iter.clone());
495                    return Ok(SqpResult {
496                        x: iter.x,
497                        lambda_g: iter.lambda_g,
498                        lambda_x: iter.lambda_x,
499                        obj: f_curr,
500                        status: SqpStatus::UserRequestedStop,
501                        n_iter: outer,
502                        n_qp_solves,
503                        n_qp_working_set_changes,
504                        final_stationarity,
505                        final_constr_viol,
506                        working_set: iter.working,
507                    });
508                }
509            }
510
511            let mut low_rank = None;
512            let hess_lag = match self.hessian {
513                SqpHessianSource::Exact => nlp.eval_hess_lag(&iter.x, &iter.lambda_g),
514                SqpHessianSource::DampedBfgs => {
515                    let bfgs = bfgs.as_mut().expect("DampedBfgs state initialized above");
516                    if let Some((s, y)) =
517                        curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
518                    {
519                        bfgs.update_sy(&s, &y);
520                    }
521                    bfgs.as_triplet()
522                }
523                SqpHessianSource::Lbfgs => {
524                    let lb = lbfgs.as_mut().expect("LBfgs state initialized above");
525                    if let Some((s, y)) =
526                        curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
527                    {
528                        lb.update_sy(&s, &y);
529                    }
530                    if lift_lbfgs {
531                        low_rank = Some(lb.low_rank());
532                        // Never read: the step comes from `low_rank`, and the
533                        // only other consumer (the second-order check at a
534                        // KKT point) takes the exact Hessian on this source.
535                        Triplet {
536                            n_rows: n,
537                            n_cols: n,
538                            irow: Vec::new(),
539                            jcol: Vec::new(),
540                            vals: Vec::new(),
541                        }
542                    } else {
543                        lb.as_triplet()
544                    }
545                }
546                SqpHessianSource::QuasiNewtonAuto => {
547                    unreachable!("resolved at the top of the solve")
548                }
549            };
550
551            // Remember this iterate's `(x, ∇f, ∇c)` so the next
552            // iteration can build its curvature pair at a fixed
553            // multiplier. See `curvature_pair`.
554            prev_point = Some((iter.x.clone(), grad_f.clone(), jac_c.clone()));
555
556            // Non-finite residuals mean the iterate itself is garbage, and
557            // every gate below this point — the convergence test, the
558            // gh #856 negative-curvature escape installed on it, the filter,
559            // the merit function — is a comparison against a tolerance that
560            // a `NaN` cannot inform. Stop here and say so, mirroring the
561            // interior-point arm's `if !nlp_err.is_finite()` screen
562            // (`ipopt_alg.rs`) so the two arms give the same verdict on the
563            // same condition (gh #876).
564            //
565            // The objective is screened too. Without it an iterate whose
566            // objective has overflowed to `-inf`, with residuals still finite,
567            // went on to a line search whose trial point evaluated to `NaN`
568            // and was reported as a step-size stall
569            // (`Search_Direction_Becomes_Too_Small`) — on `unbounded_cubic`'s
570            // limited-memory leg, once the step QPs stopped stalling at their
571            // iteration cap (gh#1061) and the SQP followed the cubic down.
572            if !kkt.stationarity.is_finite() || !kkt.constr_viol.is_finite() || !f_curr.is_finite()
573            {
574                let obj = nlp.eval_f(&iter.x);
575                self.iterates = Some(iter.clone());
576                return Ok(SqpResult {
577                    x: iter.x,
578                    lambda_g: iter.lambda_g,
579                    lambda_x: iter.lambda_x,
580                    obj,
581                    status: SqpStatus::InvalidNumber,
582                    n_iter: outer,
583                    n_qp_solves,
584                    n_qp_working_set_changes,
585                    final_stationarity,
586                    final_constr_viol,
587                    working_set: iter.working,
588                });
589            }
590
591            // Per-iteration summary row (`sqp_print_level >= 1`). Emitted at
592            // the TOP of the iteration, on Ipopt's convention: row `k`
593            // reports the residuals AT `x_k`, and its step columns describe
594            // the step that arrived there — so they are blank on row 0. The
595            // interior-point arm's table reads the same way
596            // (`crate::output::orig::OrigIterationOutput::format_row`), which
597            // is the whole point: a reader should not have to learn a second
598            // convention to compare the two arms on one model.
599            //
600            // Written with `println!` rather than through a journalist because
601            // `SqpAlgorithm` has no handle on one — the same reason
602            // `pounce_solve_report::console` prints the problem statistics and
603            // the end-of-run summary that bracket this table directly.
604            //
605            // This block used to be `#[cfg(test)]` around a `tracing::debug!`,
606            // which meant the option was registered, documented in
607            // `docs/src/active-set-sqp.md` as "1=per-iter summary", threaded
608            // all the way to `opts.print_level` — and produced nothing
609            // whatsoever in any binary a user can run, at any level, with or
610            // without `RUST_LOG`. Same shape as gh#677
611            // (`limited_memory_initialization` registered and never read) and
612            // the `sqp_qp_use_homotopy` no-op: a switch nothing reads is worse
613            // than no switch, because its documentation describes behaviour
614            // that does not exist.
615            if self.opts.print_level >= 1 {
616                if outer % 10 == 0 {
617                    println!("{SQP_ITER_HEADER}");
618                }
619                println!(
620                    "{}",
621                    format_sqp_row(
622                        outer,
623                        f_curr,
624                        kkt.constr_viol,
625                        kkt.stationarity,
626                        prev_step.as_ref(),
627                    )
628                );
629            }
630            if self.opts.print_level >= 2 {
631                println!(
632                    "         x={:?} ν={:.2e}",
633                    iter.x.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>(),
634                    nu,
635                );
636            }
637
638            // `sqp_tol` and `sqp_dual_inf_tol` are both registered and both
639            // documented as a max-norm tolerance on the stationarity
640            // residual, but only `dual_inf_tol` was ever read — `opts.tol`
641            // (default 1e-8) was dead, so the loose 1e-4 governed alone and
642            // silently capped attainable accuracy (max x-error `7e-5` on the
643            // #358 sweep). Honor both by requiring the tighter, which is the
644            // only reading under which neither option is a no-op. Restores
645            // `~5e-9` worst-case accuracy for ~10% more iterations. Same
646            // registered-but-inert defect family as gh #360.
647            let stationarity_tol = self.opts.tol.min(self.opts.dual_inf_tol);
648            if kkt.stationarity <= stationarity_tol && kkt.constr_viol <= self.opts.constr_viol_tol
649            {
650                // First-order KKT is necessary and not sufficient. Before
651                // reporting success on an indefinite Lagrangian, look for a
652                // feasible direction of negative curvature *at the converged
653                // multipliers* and, where one is found, step along it and keep
654                // going (gh #856).
655                //
656                // `nonconvex_qp.nl` under `algorithm=active-set-sqp` is the
657                // case: `min x₀x₁ s.t. x₀+x₁ = 2, 0 ≤ x ≤ 4`, on whose
658                // feasible segment `f(x₀) = x₀(2−x₀)` is concave, so the
659                // `(1, 1)` this converges to at `f = 1` is the constrained
660                // **maximum** and the minimum is `0` at either endpoint. It was
661                // reported `Solve_Succeeded`. The escape below moves to
662                // `(2, 0)`, where a bound joins the active set, the null space
663                // closes and `f = 0` certifies.
664                //
665                // Refuted **by exhibition**, exactly as gh #848 does one layer
666                // down: the direction is only acted on after stepping along it
667                // and finding the true objective lower, so the curvature search
668                // is free to be approximate and a direction it gets wrong
669                // costs an evaluation rather than a wrong answer.
670                //
671                // The Hessian is the **exact** `∇²L`, taken here even when the
672                // steps were driven by a quasi-Newton one. That is not an
673                // optimization detail, it is what makes the check exist at all
674                // under `limited-memory`: a damped-BFGS or L-BFGS matrix is
675                // positive definite by construction, so searching it for
676                // negative curvature can only ever find none, and gating the
677                // check on `SqpHessianSource::Exact` left the L-BFGS leg
678                // certifying the same constrained maximum. `eval_hess_lag` is
679                // a required method of `SqpProblemSpec`, so it is always
680                // callable; this costs one Hessian evaluation per converged
681                // solve, and an implementation that has none to give returns
682                // an empty triplet, which finds no curvature and changes
683                // nothing.
684                //
685                // The L-BFGS leg is not exotic coverage: the Python frontend
686                // and the CasADi plugin both select `limited-memory` on their
687                // own whenever no exact Lagrangian Hessian is available.
688                let exact_hess = if matches!(self.hessian, SqpHessianSource::Exact) {
689                    None
690                } else {
691                    Some(nlp.eval_hess_lag(&iter.x, &iter.lambda_g))
692                };
693                if escapes < MAX_SECOND_ORDER_ESCAPES
694                    && let Some(d) = negative_curvature_at_kkt_point(
695                        n,
696                        m,
697                        &iter.x,
698                        exact_hess.as_ref().unwrap_or(&hess_lag),
699                        &jac_c,
700                        &c_vals,
701                        &bl_c,
702                        &bu_c,
703                        &xl,
704                        &xu,
705                        self.opts.constr_viol_tol,
706                    )
707                    && let Some(next) = exhibit_better_point(
708                        nlp,
709                        &iter.x,
710                        &d,
711                        f_curr,
712                        &xl,
713                        &xu,
714                        &bl_c,
715                        &bu_c,
716                        self.opts.constr_viol_tol,
717                    )
718                {
719                    tracing::debug!(target: "pounce::sqp",
720                        "first-order KKT point refuted at second order; stepping \
721                         along negative curvature to a strictly better feasible \
722                         point (gh #856)");
723                    escapes += 1;
724                    iter.x = next;
725                    iter.working = None;
726                    f_cached = None;
727                    c_cached = None;
728                    prev_point = None;
729                    continue;
730                }
731                self.iterates = Some(iter.clone());
732                return Ok(SqpResult {
733                    x: iter.x,
734                    lambda_g: iter.lambda_g,
735                    lambda_x: iter.lambda_x,
736                    obj: f_curr,
737                    status: SqpStatus::Optimal,
738                    n_iter: outer,
739                    n_qp_solves,
740                    n_qp_working_set_changes,
741                    final_stationarity,
742                    final_constr_viol,
743                    working_set: iter.working,
744                });
745            }
746
747            // Time budget (gh#1054). Tested after the convergence check, so
748            // an iterate that is already optimal is reported as such however
749            // late it arrived, and before the step QP, which is where an
750            // active-set solve at scale spends its time.
751            if let Some(kind) = self.opts.deadline.as_ref().and_then(|d| d.exceeded()) {
752                self.iterates = Some(iter.clone());
753                return Ok(SqpResult {
754                    x: iter.x,
755                    lambda_g: iter.lambda_g,
756                    lambda_x: iter.lambda_x,
757                    obj: f_curr,
758                    status: kind.into(),
759                    n_iter: outer,
760                    n_qp_solves,
761                    n_qp_working_set_changes,
762                    final_stationarity,
763                    final_constr_viol,
764                    working_set: iter.working,
765                });
766            }
767            // What is left of it, handed to every step QP this iteration as
768            // its `time_limit`, so one subproblem cannot outlive the solve.
769            // `qp_budget` remembers which budget set the limit, so a QP that
770            // stops on it is reported as that budget and not as the
771            // subproblem's own iteration limit.
772            let qp_budget = self.opts.deadline.as_ref().map(deadline_remaining);
773            let qp_time_limit =
774                qp_time_limit_now(self.opts.deadline.as_ref(), self.qp_opts.time_limit);
775
776            let qp_data = match low_rank.as_ref() {
777                Some(b) => SqpQpData::build_low_rank(
778                    &iter.x, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, jac_c, b,
779                ),
780                None => SqpQpData::build(
781                    &iter.x,
782                    &grad_f,
783                    &c_vals,
784                    &bl_c,
785                    &bu_c,
786                    &xl,
787                    &xu,
788                    jac_c,
789                    hess_lag,
790                    self.hessian_inertia(),
791                ),
792            };
793
794            // Scale-relative inner-QP tolerances (issue #358 tail).
795            //
796            // `QpOptions::{feas_tol, opt_tol}` are **absolute** (1e-9 each).
797            // That is a sane default for a standalone `solve_qp` on
798            // well-scaled data, but this QP is an *inner* subproblem whose
799            // data inherits the NLP's scale: with `‖∇f‖ ~ 1e3` and
800            // `‖B‖ ~ 1e3`, an absolute 1e-9 is ~1e-12 *relative* — at the
801            // f64 noise floor. The active-set solver then cannot certify
802            // its own optimality, burns its whole iteration budget, and
803            // returns `MaxIter`; the driver reports `QpStepFailed`, which
804            // surfaces to the user as `Search_Direction_Becomes_Too_Small`
805            // on a QP that is trivially solvable. This is what stalled the
806            // ill-conditioned tail of #358 even once the Hessian scale was
807            // fixed by the probe above.
808            //
809            // Scale both tolerances by the QP data magnitude, so the inner
810            // solve is asked for a *relative* accuracy it can actually
811            // reach. Nothing is lost in the answer: the SQP outer loop
812            // still gates optimality on the true, unscaled NLP KKT
813            // residuals (`dual_inf_tol` / `constr_viol_tol`) at the top of
814            // each iteration, so a sloppier inner step can only cost an
815            // extra outer iteration — never a false `Optimal`. Measured on
816            // a 500-instance convex-QP sweep this converts 34 failures into
817            // successes with a *bit-for-bit identical* error distribution
818            // (median 6e-11, max true constraint violation 4e-11).
819            //
820            // The `SCALE_MAX` clamp bounds the relaxation on pathological
821            // data (a quasi-Newton `B` that has blown up); it does not bind
822            // on any problem in the sweep.
823            const SCALE_MAX: Number = 1e6;
824            let g_inf = grad_f.iter().map(|v| v.abs()).fold(0.0, f64::max);
825            let b_inf = qp_data.hess_inf;
826            let base = self.qp_opts.clone();
827            self.qp_opts.time_limit = qp_time_limit;
828            let scale = g_inf.max(b_inf).clamp(1.0, SCALE_MAX);
829            if scale > 1.0 {
830                self.qp_opts.opt_tol = base.opt_tol * scale;
831                self.qp_opts.feas_tol = base.feas_tol * scale;
832            }
833
834            // Warm-start from the previous QP's working set when
835            // available. Pounce-qp's `solve_with_working_set`
836            // internally computes a feasible primal compatible
837            // with the supplied set (it satisfies every active
838            // row exactly) — necessary because each SQP
839            // linearization shifts the QP's constraint RHS by
840            // `-c(x_k)`, so the previous QP's *primal* doesn't
841            // carry over even when the active set does.
842            let warm_started = iter.working.is_some();
843            let mut sol = if let Some(prev_w) = iter.working.as_ref() {
844                // A warm solve that *errors* falls back to cold rather than
845                // aborting the SQP (gh #855). The cold-start fallback below
846                // already exists for a warm solve that comes back `MaxIter` /
847                // `NumericalError`, on the reasoning that the carried-over
848                // working set can be a poor guess; a hard `Err` from the same
849                // call is the **stronger** form of that signal and was the one
850                // case the fallback could not see, because `?` propagated out
851                // of the whole algorithm first.
852                //
853                // The error that motivates this says so itself: `eigena2`
854                // under `algorithm=active-set-sqp` reaches outer iteration 17
855                // and the warm solve fails with "pinned KKT constraint block
856                // is rank-deficient (inertia shift masked a singular
857                // constraint block); prune to a linearly-independent subset".
858                // That is a statement about the *pinned set*, which is exactly
859                // what a warm start supplies and a cold start rebuilds. The
860                // SQP exited `Internal_Error` / `solve_result_num=500` -- "the
861                // solver broke, retry" -- on a model whose objective it can
862                // report; with the cold re-solve it ends
863                // `Maximum_Iterations_Exceeded` at `obj = 82.5177`, against
864                // the NLP arm's 82.5 on the same file.
865                //
866                // The cold error is still propagated: if a clean start fails
867                // too, the failure is not about the working set and there is
868                // nothing further to try here.
869                let warm_opts = self.warm_qp_opts(&self.qp_opts, n + m, qp_data.hessian_inertia);
870                match solve_step_qp(
871                    &mut self.qp_solver,
872                    &qp_data,
873                    Some(prev_w),
874                    warm_opts.as_ref().unwrap_or(&self.qp_opts),
875                    self.ipm_cold_start.then_some(&self.ipm_seed),
876                ) {
877                    Ok(v) => v,
878                    Err(e) => {
879                        tracing::debug!(target: "pounce::sqp",
880                            "warm-started step QP failed hard ({e:?}); re-solving \
881                             from cold, since the carried working set is what a \
882                             cold start rebuilds (gh #855)");
883                        n_qp_solves += 1;
884                        solve_step_qp(
885                            &mut self.qp_solver,
886                            &qp_data,
887                            None,
888                            &self.qp_opts,
889                            self.ipm_cold_start.then_some(&self.ipm_seed),
890                        )?
891                    }
892                }
893            } else {
894                solve_step_qp(
895                    &mut self.qp_solver,
896                    &qp_data,
897                    None,
898                    &self.qp_opts,
899                    self.ipm_cold_start.then_some(&self.ipm_seed),
900                )?
901            };
902            n_qp_solves += 1;
903            n_qp_working_set_changes += sol.stats.n_working_set_changes;
904
905            // Cold-start fallback: a warm start seeds the QP with the
906            // previous iterate's working set, which is usually a big
907            // win but can occasionally strand the active-set solver at
908            // its iteration limit (or a numerical breakdown) on a QP
909            // that is perfectly solvable from a clean start — e.g.
910            // when a quasi-Newton Hessian has drifted enough that the
911            // carried-over active set is a poor guess. Rather than
912            // give up with `QpStepFailed`, re-solve once from cold;
913            // this is what rescues the curved-constraint SQP runs of
914            // issue #349 that previously reported
915            // `Search_Direction_Becomes_Too_Small`.
916            if warm_started && matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError) {
917                let cold = solve_step_qp(
918                    &mut self.qp_solver,
919                    &qp_data,
920                    None,
921                    &self.qp_opts,
922                    self.ipm_cold_start.then_some(&self.ipm_seed),
923                )?;
924                n_qp_solves += 1;
925                n_qp_working_set_changes += cold.stats.n_working_set_changes;
926                // `Unbounded` is accepted as well as `Optimal`, and it is
927                // the point of gh #855. A cold re-solve that comes back
928                // `Unbounded` has *found something* — an unblocked direction
929                // of negative curvature in the null space of its working set
930                // — and taking only `Optimal` threw that away, leaving `sol`
931                // on the original `MaxIter`. The unbounded-model fallback
932                // below is gated on `sol.status == Unbounded`, so the
933                // δ-shifted proximal step written for exactly this situation
934                // was unreachable from a retry.
935                //
936                // Accepting it is safe because the fallback does not trust it
937                // either: it re-tests the ray against the true NLP (gh #388)
938                // and only takes the proximal branch when the certificate
939                // does *not* survive. A spurious `Unbounded` therefore costs
940                // one re-test, not a wrong verdict.
941                //
942                // Nothing else can be accepted here: `Infeasible` from a cold
943                // solve would contradict the warm one on the same subproblem
944                // without a tie-breaker, and `MaxIter` / `NumericalError` are
945                // what we already have.
946                //
947                // COVERAGE, stated because it matters: no fixture in the CLI
948                // corpus reaches this branch. Swept under
949                // `algorithm=active-set-sqp`, the retries fire on three
950                // fixtures (`cresc4`, `eigena2`, `jit1_boxed`) and return only
951                // `MaxIter` or `Optimal`, including under `sqp_qp_max_iter`
952                // forced down to 2. gh #855 observed the `Unbounded` return on
953                // `eigena2` in a build carrying second-order certification for
954                // the step subproblem, which is gh #856's subject and does not
955                // exist here yet. It is kept rather than dropped because it is
956                // not redundant -- nothing else makes the unbounded-model
957                // fallback reachable from a retry -- which is the opposite of
958                // the gh #846 case, where a second arm already rejected
959                // everything the removed one would have.
960                if matches!(cold.status, QpStatus::Optimal | QpStatus::Unbounded) {
961                    sol = cold;
962                }
963            }
964
965            // Quasi-Newton reset fallback (issue #358 tail). If the QP
966            // still cannot be solved, the usual culprit is not the
967            // linearization but the *approximated* Hessian: a damped-BFGS
968            // matrix that has accumulated enough drift (typically after a
969            // large early step on an ill-conditioned problem) to make the
970            // step subproblem numerically unsolvable. That is recoverable
971            // — throwing away the accumulated curvature and retrying from
972            // a scaled identity almost always yields a usable step —
973            // whereas the alternative is aborting an otherwise healthy
974            // solve with `QpStepFailed`, which the user sees as
975            // `Search_Direction_Becomes_Too_Small` on a trivially solvable
976            // problem. Rebuild the subproblem around the reset Hessian and
977            // re-solve once, from cold (the carried working set belongs to
978            // the discarded model).
979            let mut qp_data = qp_data;
980            if matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError)
981                && let Some(b) = bfgs.as_mut()
982            {
983                b.reset_to_scale();
984                qp_data = SqpQpData::build(
985                    &iter.x,
986                    &grad_f,
987                    &c_vals,
988                    &bl_c,
989                    &bu_c,
990                    &xl,
991                    &xu,
992                    nlp.eval_jac_c(&iter.x),
993                    b.as_triplet(),
994                    self.hessian_inertia(),
995                );
996                let retry = solve_step_qp(
997                    &mut self.qp_solver,
998                    &qp_data,
999                    None,
1000                    &self.qp_opts,
1001                    self.ipm_cold_start.then_some(&self.ipm_seed),
1002                )?;
1003                n_qp_solves += 1;
1004                n_qp_working_set_changes += retry.stats.n_working_set_changes;
1005                // Same as the cold retry above (gh #855): an `Unbounded`
1006                // verdict is a finding, not a failure, and discarding it hid
1007                // the proximal fallback from this branch too.
1008                //
1009                // The subproblem stays consistent: this branch runs only
1010                // while `sol.status` is `MaxIter`/`NumericalError`, so it
1011                // cannot fire after the cold retry has been accepted, and
1012                // `qp_data` is rebound above — so the fallback below re-solves
1013                // the *reset-Hessian* subproblem that produced this verdict,
1014                // not the discarded one.
1015                if matches!(retry.status, QpStatus::Optimal | QpStatus::Unbounded) {
1016                    sol = retry;
1017                }
1018            }
1019
1020            // Unbounded-model fallback (gh #423). The step QP being
1021            // unbounded below is a statement about the *linearization*, so
1022            // re-test the ray against the true NLP (gh #388) — and when it
1023            // does not survive, do not stop there. An unbounded model on a
1024            // bounded NLP is not a dead end; it is the textbook signal that
1025            // the model needs regularizing (Nocedal-Wright §18.4), and δ
1026            // from §4.5 inertia control already *is* that regularization.
1027            // So re-solve declining the certificate: the same subproblem,
1028            // the same shift, but the unblocked direction takes the
1029            // δ-shifted proximal step instead of certifying recession.
1030            //
1031            // This is not a corner case. A nonconvex NLP with `m = 0` and
1032            // no finite bounds has *nothing that can ever block* a
1033            // negative-curvature direction, so every indefinite iterate
1034            // produces this certificate. gh #419 gave those iterates a real
1035            // step where a bound exists and left them with none where one
1036            // does not: a chain of coupled double wells (`n = 12`, `m = 0`)
1037            // that converged to f = 0.027424 in 24 iterations died at
1038            // iteration 1 with `QpStepFailed` at f = 26.03. The proximal
1039            // step is slow — that slowness is what #416 was about — but it
1040            // is a step, and it is only reached here where the alternative
1041            // is no step at all.
1042            let mut ray_certified = false;
1043            if sol.status == QpStatus::Unbounded {
1044                ray_certified = sol.unbounded_ray.as_ref().is_some_and(|d| {
1045                    ray_certifies_unbounded(
1046                        nlp,
1047                        &iter.x,
1048                        d,
1049                        f_curr,
1050                        &grad_f,
1051                        &bl_c,
1052                        &bu_c,
1053                        &xl,
1054                        &xu,
1055                        self.opts.constr_viol_tol,
1056                    )
1057                });
1058                if !ray_certified {
1059                    tracing::debug!(target: "pounce::sqp",
1060                        "unbounded step QP whose recession ray does not survive \
1061                         re-testing against the NLP — re-solving for the δ-shifted \
1062                         proximal step (gh #423)");
1063                    let prox_opts = QpOptions {
1064                        certify_recession_ray: false,
1065                        ..self.qp_opts.clone()
1066                    };
1067                    let prox = solve_step_qp(
1068                        &mut self.qp_solver,
1069                        &qp_data,
1070                        None,
1071                        &prox_opts,
1072                        self.ipm_cold_start.then_some(&self.ipm_seed),
1073                    )?;
1074                    n_qp_solves += 1;
1075                    if prox.status == QpStatus::Optimal {
1076                        sol = prox;
1077                    }
1078                }
1079            }
1080            self.qp_opts = base;
1081
1082            // An infeasible step QP is a statement about the linearization at
1083            // `x_k`, not about the NLP (Fletcher's Sℓ1QP; Nocedal-Wright
1084            // §18.5). The QP's elastic phase hands back the point minimizing
1085            // its ℓ1 violation, which is a step toward feasibility; when it
1086            // reduces the linearized violation, take it and let the ℓ1 merit
1087            // line search judge it. The multipliers it returns are a Farkas
1088            // certificate, not a step's multipliers, so the current ones are
1089            // kept. Only a step that cannot reduce the linearized violation —
1090            // `x_k` stationary for it — is the local-infeasibility signal.
1091            //
1092            // Stopping on the first infeasible QP declared `deb7` (813
1093            // variables, feasible: the interior point solves it at 97.56)
1094            // `Infeasible_Problem_Detected` at iteration 0, and the
1095            // second-opinion ladder agreed three times, since every re-solve
1096            // starts from the same linearization (gh#1061 sweep).
1097            let elastic_step = sol.status == QpStatus::Infeasible && {
1098                let zero = vec![0.0; n];
1099                let before = linearized_l1_violation(&qp_data, &zero);
1100                let after = linearized_l1_violation(&qp_data, &sol.x);
1101                tracing::debug!(target: "pounce::sqp",
1102                    "infeasible step QP: linearized ℓ1 violation {before:.3e} → {after:.3e} \
1103                     along its elastic step");
1104                after < (1.0 - ELASTIC_STEP_MIN_GAIN) * before
1105            };
1106            if elastic_step {
1107                sol.lambda_g.clone_from(&iter.lambda_g);
1108                sol.lambda_x.clone_from(&iter.lambda_x);
1109            }
1110
1111            match sol.status {
1112                QpStatus::Optimal => {}
1113                QpStatus::Infeasible if elastic_step => {}
1114                QpStatus::Infeasible => {
1115                    let obj = nlp.eval_f(&iter.x);
1116                    self.iterates = Some(iter.clone());
1117                    return Ok(SqpResult {
1118                        x: iter.x,
1119                        lambda_g: iter.lambda_g,
1120                        lambda_x: iter.lambda_x,
1121                        obj,
1122                        status: SqpStatus::InfeasibleSubproblem,
1123                        n_iter: outer,
1124                        n_qp_solves,
1125                        n_qp_working_set_changes,
1126                        final_stationarity,
1127                        final_constr_viol,
1128                        working_set: iter.working,
1129                    });
1130                }
1131                // The QP subproblem neither solved nor certified
1132                // infeasibility. `MaxIter` / `NumericalError` mean the
1133                // active-set QP could not resolve the (typically extremely
1134                // degenerate) step subproblem — the m/n ≫ 1 collapsed-cone
1135                // geometry of #282. Terminate the SQP with an HONEST
1136                // non-committal status rather than a hard error, and — the
1137                // point of #282 — WITHOUT ever asserting infeasibility on a
1138                // problem we have not certified infeasible.
1139                QpStatus::MaxIter | QpStatus::TimeLimit | QpStatus::NumericalError => {
1140                    let obj = nlp.eval_f(&iter.x);
1141                    self.iterates = Some(iter.clone());
1142                    // Report *which* of the two it was. Both are honest
1143                    // non-committal failures making no infeasibility claim
1144                    // (#282), but only the budget one is actionable by the
1145                    // user, and merging them hid the dominant Maros-Mészáros
1146                    // failure mode behind a step-size verdict. See
1147                    // `SqpStatus::QpIterationLimit`.
1148                    // `TimeLimit` is the solve's own `max_wall_time` /
1149                    // `max_cpu_time` when the deadline set the QP's limit
1150                    // (gh#1054), and is reported as that. A limit the caller
1151                    // put on the subproblem directly rides with `MaxIter`: a
1152                    // budget ran out, no claim about the problem.
1153                    let status = match sol.status {
1154                        QpStatus::TimeLimit => match qp_budget {
1155                            Some((left, budget))
1156                                if self.qp_opts.time_limit.is_none_or(|own| left <= own) =>
1157                            {
1158                                budget.into()
1159                            }
1160                            _ => SqpStatus::QpIterationLimit,
1161                        },
1162                        QpStatus::MaxIter => SqpStatus::QpIterationLimit,
1163                        _ => SqpStatus::QpStepFailed,
1164                    };
1165                    return Ok(SqpResult {
1166                        x: iter.x,
1167                        lambda_g: iter.lambda_g,
1168                        lambda_x: iter.lambda_x,
1169                        obj,
1170                        status,
1171                        n_iter: outer,
1172                        n_qp_solves,
1173                        n_qp_working_set_changes,
1174                        final_stationarity,
1175                        final_constr_viol,
1176                        working_set: iter.working,
1177                    });
1178                }
1179                // The step QP is unbounded below along a certified
1180                // recession ray of the LOCAL model (zero curvature,
1181                // feasible for every step length, strict descent). That
1182                // is a statement about the linearization, not yet about
1183                // the NLP — so it was re-tested against the true
1184                // objective and constraints above. If it survived, the
1185                // NLP itself is unbounded and we say so with the same
1186                // `Diverging_Iterates` verdict every other POUNCE path
1187                // returns. If it did not, the proximal re-solve above
1188                // already had its chance to produce a step, and reaching
1189                // here means even that failed: the QP simply could not
1190                // produce a usable step, which is `QpStepFailed`.
1191                //
1192                // Neither outcome is a hard error. This used to return
1193                // `QpFailure(LinearSolverFailure("QP subproblem returned
1194                // status unbounded"))`, which surfaced to AMPL / Pyomo /
1195                // GAMS consumers as `Internal_Error` /
1196                // `solve_result_num=500` — "the solver broke" — on a
1197                // model that is merely unbounded (`300`), and named a
1198                // linear-solver failure when no linear solver had failed
1199                // (gh #388).
1200                QpStatus::Unbounded => {
1201                    let certified = ray_certified;
1202                    let obj = nlp.eval_f(&iter.x);
1203                    self.iterates = Some(iter.clone());
1204                    return Ok(SqpResult {
1205                        x: iter.x,
1206                        lambda_g: iter.lambda_g,
1207                        lambda_x: iter.lambda_x,
1208                        obj,
1209                        status: if certified {
1210                            SqpStatus::Unbounded
1211                        } else {
1212                            SqpStatus::QpStepFailed
1213                        },
1214                        n_iter: outer,
1215                        n_qp_solves,
1216                        n_qp_working_set_changes,
1217                        final_stationarity,
1218                        final_constr_viol,
1219                        working_set: iter.working,
1220                    });
1221                }
1222            }
1223
1224            let p_inf = sol.x.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1225            if self.opts.print_level >= 2 {
1226                println!(
1227                    "         qp: ‖p‖_inf={:.3e} ‖λ_g_qp‖_inf={:.3e}",
1228                    p_inf,
1229                    sol.lambda_g.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
1230                );
1231            }
1232            // Globalization: l1-merit backtracking (Han-Powell)
1233            // or filter (Fletcher-Leyffer 2002). The two share
1234            // the same backtracking shell + acceptance API; the
1235            // filter keeps state across iterations on
1236            // `self.filter`.
1237            //
1238            // Both are handed a second-order-correction (SOC)
1239            // provider (the Maratos remedy). When the full step
1240            // (α = 1) is rejected because it increased the
1241            // constraint violation, the line search calls this
1242            // closure with `c(x_k + p)` to obtain a corrected full
1243            // step. We build that step by re-solving the SAME QP
1244            // with the general-constraint RHS re-centered on the
1245            // trial-point constraint values: the original QP models
1246            // `c(x_k) + A p`, and the SOC replaces `c(x_k)` by
1247            // `c(x_k + p) − A p`, so the correction subproblem
1248            // targets the true (curved) violation at the trial
1249            // point (Nocedal-Wright §18.11). The just-solved working
1250            // set warm-starts the correction. Only meaningful with
1251            // general constraints (`m > 0`).
1252            //
1253            // Pre-computed `A p` for the RHS re-centering:
1254            let a_p = if m > 0 {
1255                mat_vec_gen(&qp_data.a, &sol.x, m)
1256            } else {
1257                Vec::new()
1258            };
1259            let mut n_soc_solves: u32 = 0;
1260            // Working set from the SOC subproblem, kept so that a
1261            // taken SOC step warm-starts the next iteration from the
1262            // active set that actually describes `x + p_soc` (not the
1263            // original QP's set, which belongs to the rejected step).
1264            let mut soc_working: Option<WorkingSet> = None;
1265            let ls = {
1266                // Warm-started from the step QP's own working set.
1267                let warm_opts = self.warm_qp_opts(&self.qp_opts, n + m, qp_data.hessian_inertia);
1268                let qp_solver = &mut self.qp_solver;
1269                let qp_opts = warm_opts.as_ref().unwrap_or(&self.qp_opts);
1270                let deadline = self.opts.deadline.as_ref();
1271                let seed = self.ipm_cold_start.then_some(&self.ipm_seed);
1272                let qp_data_ref = &qp_data;
1273                let c_curr_ref = &c_vals;
1274                let a_p_ref = &a_p;
1275                let sol_working = &sol.working;
1276                let n_soc = &mut n_soc_solves;
1277                let soc_working_slot = &mut soc_working;
1278                let mut soc = |c_trial: &[Number]| -> Option<crate::sqp::line_search::SocStep> {
1279                    let mm = qp_data_ref.m;
1280                    // Re-center the general-constraint RHS on the
1281                    // trial-point violation, preserving ±∞ sentinels.
1282                    let mut bl_soc = qp_data_ref.bl.clone();
1283                    let mut bu_soc = qp_data_ref.bu.clone();
1284                    for i in 0..mm {
1285                        let delta = c_curr_ref[i] - c_trial[i] + a_p_ref[i];
1286                        if qp_data_ref.bl[i] > NLP_LOWER_BOUND_INF {
1287                            bl_soc[i] = qp_data_ref.bl[i] + delta;
1288                        }
1289                        if qp_data_ref.bu[i] < NLP_UPPER_BOUND_INF {
1290                            bu_soc[i] = qp_data_ref.bu[i] + delta;
1291                        }
1292                    }
1293                    // `bl` / `bu` carry the lifted rows (if any) past `mm`;
1294                    // only the NLP's own rows are re-centred.
1295                    let qp_soc = qp_data_ref.as_qp_with_row_bounds(&bl_soc, &bu_soc);
1296                    // What is left of the solve's budget now, not at the top of
1297                    // the iteration: the step QP and the trial points have
1298                    // spent part of it since (gh#1061: the correction QP ran
1299                    // 40–142 s past a wall cap on that stale figure).
1300                    let soc_opts = QpOptions {
1301                        time_limit: qp_time_limit_now(deadline, qp_opts.time_limit),
1302                        ..qp_opts.clone()
1303                    };
1304                    let sol_soc = solve_subproblem_qp(
1305                        qp_solver,
1306                        qp_data_ref,
1307                        qp_soc,
1308                        Some(sol_working),
1309                        &soc_opts,
1310                        seed,
1311                        "SOC QP",
1312                    )
1313                    .ok()?;
1314                    *n_soc += 1;
1315                    if sol_soc.status == QpStatus::Optimal {
1316                        *soc_working_slot = Some(sol_soc.working);
1317                        Some(crate::sqp::line_search::SocStep {
1318                            p: sol_soc.x,
1319                            lambda_g: sol_soc.lambda_g,
1320                            lambda_x: sol_soc.lambda_x,
1321                        })
1322                    } else {
1323                        None
1324                    }
1325                };
1326                let soc_ref: Option<crate::sqp::line_search::SocProvider<'_>> =
1327                    if m > 0 { Some(&mut soc) } else { None };
1328                match self.opts.globalization {
1329                    SqpGlobalization::L1Elastic => l1_merit_line_search(
1330                        nlp,
1331                        &iter.x,
1332                        &sol.x,
1333                        &sol.lambda_g,
1334                        &grad_f,
1335                        f_curr,
1336                        &c_vals,
1337                        &bl_c,
1338                        &bu_c,
1339                        &xl,
1340                        &xu,
1341                        nu,
1342                        &self.opts,
1343                        soc_ref,
1344                    ),
1345                    SqpGlobalization::Filter => filter_line_search(
1346                        nlp,
1347                        &mut self.filter,
1348                        &iter.x,
1349                        &sol.x,
1350                        f_curr,
1351                        &c_vals,
1352                        &bl_c,
1353                        &bu_c,
1354                        &xl,
1355                        &xu,
1356                        nu,
1357                        &self.opts,
1358                        soc_ref,
1359                    ),
1360                }
1361            };
1362            n_qp_solves += n_soc_solves;
1363            if self.opts.print_level >= 2 {
1364                println!(
1365                    "         ls: α={:.3e} ν={:.3e} ok={} f_new={:.3e}",
1366                    ls.alpha, ls.nu, ls.success, ls.f_new
1367                );
1368            }
1369            if !ls.success {
1370                self.iterates = Some(iter.clone());
1371                return Ok(SqpResult {
1372                    x: iter.x,
1373                    lambda_g: iter.lambda_g,
1374                    lambda_x: iter.lambda_x,
1375                    obj: f_curr,
1376                    status: SqpStatus::LineSearchFailed,
1377                    n_iter: outer,
1378                    n_qp_solves,
1379                    n_qp_working_set_changes,
1380                    final_stationarity,
1381                    final_constr_viol,
1382                    working_set: Some(sol.working),
1383                });
1384            }
1385            iter.x = ls.x_new;
1386            // Captured before the match below moves `sol.working` and
1387            // `ls.soc_duals` out from under them. Both are cheap Copy reads;
1388            // taking them here rather than after is what keeps the row's
1389            // `ws` column this QP's own count instead of the running total.
1390            let qp_ws_changes = sol.stats.n_working_set_changes;
1391            let soc_taken = ls.soc_duals.is_some();
1392            match ls.soc_duals {
1393                Some((soc_lg, soc_lx)) => {
1394                    // A second-order-correction step was taken (α = 1
1395                    // on the SOC subproblem). Adopt the SOC
1396                    // subproblem's own multipliers and working set so
1397                    // `(step, multipliers, active set)` stay a
1398                    // consistent triple — required for the quasi-
1399                    // Newton Hessian update to stay well-conditioned
1400                    // and for the next QP to warm-start correctly.
1401                    iter.lambda_g = soc_lg;
1402                    iter.lambda_x = soc_lx;
1403                    iter.working = soc_working.take().or(Some(sol.working));
1404                }
1405                None => {
1406                    for (l, &lq) in iter.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
1407                        *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
1408                    }
1409                    for (l, &lq) in iter.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
1410                        *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
1411                    }
1412                    iter.working = Some(sol.working);
1413                }
1414            }
1415            nu = ls.nu;
1416            f_cached = Some(ls.f_new);
1417            c_cached = Some(ls.c_new);
1418            // Recorded after acceptance, so the row describes the step that
1419            // was actually taken rather than the last one tried. `ws_changes`
1420            // is this QP's contribution to the running total, which is the
1421            // number the working-set warm start is judged on.
1422            prev_step = Some(SqpStepRecord {
1423                p_inf,
1424                alpha: ls.alpha,
1425                soc: soc_taken,
1426                ws_changes: qp_ws_changes,
1427            });
1428        }
1429
1430        let obj = nlp.eval_f(&iter.x);
1431        self.iterates = Some(iter.clone());
1432        Ok(SqpResult {
1433            x: iter.x,
1434            lambda_g: iter.lambda_g,
1435            lambda_x: iter.lambda_x,
1436            obj,
1437            status: SqpStatus::MaxIter,
1438            n_iter: self.opts.max_iter,
1439            n_qp_solves,
1440            n_qp_working_set_changes,
1441            final_stationarity,
1442            final_constr_viol,
1443            working_set: iter.working,
1444        })
1445    }
1446
1447    /// `base` with Schur updates set for a warm-started step QP of size
1448    /// `dim` carrying the Hessian hint `inertia`
1449    /// (`SqpOptions::step_qp_schur_updates`), or `None` when that is what
1450    /// `base` already says. `base` carries the cold rule. The hint is the
1451    /// built QP's, so a lifted L-BFGS subproblem reads `Indefinite` (gh#1091).
1452    pub(crate) fn warm_qp_opts(
1453        &self,
1454        base: &QpOptions,
1455        dim: usize,
1456        inertia: HessianInertia,
1457    ) -> Option<QpOptions> {
1458        self.opts
1459            .step_qp_schur_updates(dim, Some(inertia))
1460            .filter(|&on| on != base.use_schur_updates)
1461            .map(|on| QpOptions {
1462                use_schur_updates: on,
1463                ..base.clone()
1464            })
1465    }
1466
1467    fn hessian_inertia(&self) -> HessianInertia {
1468        if self.hessian.is_quasi_newton() {
1469            // Damped BFGS and L-BFGS are PSD by construction. (A lifted
1470            // L-BFGS subproblem sets its own, see `build_low_rank`.)
1471            HessianInertia::Psd
1472        } else {
1473            // Exact ∇²L is indefinite on nonconvex NLPs; let the
1474            // QP solver's §4.5 inertia control handle it.
1475            HessianInertia::Indefinite
1476        }
1477    }
1478}
1479
1480/// gh #388: does the step QP's certified recession ray certify the **NLP**
1481/// unbounded below?
1482///
1483/// The inner QP hands back a direction `d` that is a recession ray *of the
1484/// linearization at `x`*: `∇²L d ≈ 0`, `d` feasible for the linearized
1485/// constraints at every step length, `∇q(x)ᵀd < 0`. On an LP or a QP that
1486/// linearization is exact and `d` is a recession ray of the original
1487/// problem; on a general NLP it need not be — the constraints curve back
1488/// and the objective can turn around. The two cases must not share a
1489/// status, so we settle it by evaluation rather than by faith: walk the
1490/// ray and check, at the **true** `f` and `c`, that
1491///
1492///  1. every probe point is *feasible* (variable bounds and constraint
1493///     bounds, the latter with a roundoff allowance that grows with the
1494///     row scale so a linear row evaluated at `‖x‖ ~ 1e12` is not failed
1495///     on cancellation noise), and
1496///  2. the objective keeps falling at **at least half** the initial linear
1497///     rate `∇f(x)ᵀd` — not merely falling. A ray that decelerates is
1498///     settling onto a finite optimum, the same distinction the IPM's
1499///     divergence guard draws (#248/#252/#285).
1500///
1501/// Probes span twelve decades of step length, so a "pass" is a family of
1502/// genuinely feasible points whose objective marches to `−∞` at a linear
1503/// rate over `1e12`. Anything short of that — one infeasible probe, one
1504/// decelerating decade, a NaN — returns `false` and the caller reports the
1505/// non-committal `QpStepFailed` instead. False negatives cost an honest
1506/// "no step" status; a false positive would tell a modeler their bounded
1507/// model is unbounded, so the asymmetry is deliberate.
1508///
1509/// `dir` need not be normalized (it is rescaled to unit max-norm here, so
1510/// the probe lengths are in the iterate's own units).
1511#[allow(clippy::too_many_arguments)]
1512fn ray_certifies_unbounded<N: SqpProblemSpec>(
1513    nlp: &mut N,
1514    x: &[Number],
1515    dir: &[Number],
1516    f_x: Number,
1517    grad_f: &[Number],
1518    bl_c: &[Number],
1519    bu_c: &[Number],
1520    xl: &[Number],
1521    xu: &[Number],
1522    constr_viol_tol: Number,
1523) -> bool {
1524    /// Step lengths along the unit-max-norm ray, spanning twelve decades.
1525    const PROBES: [Number; 7] = [1e0, 1e2, 1e4, 1e6, 1e8, 1e10, 1e12];
1526    /// Roundoff allowance per unit of `row_scale · ‖x‖∞` when checking a
1527    /// constraint at a far-out probe: comfortably above f64 epsilon
1528    /// (`2.2e-16`) to absorb accumulation over a row, far below anything
1529    /// a real violation would produce.
1530    const ROUNDOFF_REL: Number = 1e-12;
1531
1532    let n = x.len();
1533    if dir.len() != n || grad_f.len() != n || !f_x.is_finite() {
1534        return false;
1535    }
1536    let scale = dir.iter().map(|v| v.abs()).fold(0.0, f64::max);
1537    if !scale.is_finite() || scale <= 0.0 {
1538        return false;
1539    }
1540    let d: Vec<Number> = dir.iter().map(|v| v / scale).collect();
1541
1542    // Descent of the TRUE objective along the ray. The QP certified this
1543    // for its own (possibly quasi-Newton) model gradient; re-derive it
1544    // from `∇f(x)` so the rate we hold the probes to is the real one.
1545    let slope: Number = grad_f.iter().zip(d.iter()).map(|(g, di)| g * di).sum();
1546    let g_norm = grad_f.iter().map(|v| v * v).sum::<Number>().sqrt();
1547    // Numerically meaningful (not roundoff-scale) descent; a NaN slope
1548    // fails the `is_finite` guard rather than sneaking past the comparison.
1549    let descent_bar = -1e-9 * g_norm.max(1.0);
1550    if !slope.is_finite() || slope >= descent_bar {
1551        return false;
1552    }
1553
1554    // Per-row `max_j |∂c_i/∂x_j|`, the scale a linear row's value grows
1555    // with along the ray — the basis for the roundoff allowance in (1).
1556    let m = bl_c.len();
1557    let mut row_scale: Vec<Number> = vec![0.0; m];
1558    {
1559        let jac = nlp.eval_jac_c(x);
1560        for k in 0..jac.vals.len() {
1561            let i = (jac.irow[k] - 1) as usize;
1562            row_scale[i] = row_scale[i].max(jac.vals[k].abs());
1563        }
1564    }
1565
1566    for &t in PROBES.iter() {
1567        let xt: Vec<Number> = x.iter().zip(d.iter()).map(|(xi, di)| xi + t * di).collect();
1568        if xt.iter().any(|v| !v.is_finite()) {
1569            return false;
1570        }
1571
1572        // (1a) Variable bounds. These are exact linear rows in the probe's
1573        // own arithmetic, so the tolerance stays tight.
1574        for i in 0..n {
1575            let tol = 1e-9 * (1.0 + xt[i].abs());
1576            if xl[i] > NLP_LOWER_BOUND_INF && xt[i] < xl[i] - tol {
1577                return false;
1578            }
1579            if xu[i] < NLP_UPPER_BOUND_INF && xt[i] > xu[i] + tol {
1580                return false;
1581            }
1582        }
1583
1584        // (1b) Constraint bounds, at the true (possibly nonlinear) `c`.
1585        let x_inf = xt.iter().map(|v| v.abs()).fold(0.0, f64::max);
1586        let c = nlp.eval_c(&xt);
1587        if c.len() != m {
1588            return false;
1589        }
1590        for i in 0..m {
1591            if c[i].is_nan() {
1592                return false;
1593            }
1594            let tol =
1595                constr_viol_tol.max(0.0) * (1.0 + c[i].abs()) + ROUNDOFF_REL * row_scale[i] * x_inf;
1596            if bl_c[i] > NLP_LOWER_BOUND_INF && c[i] < bl_c[i] - tol {
1597                return false;
1598            }
1599            if bu_c[i] < NLP_UPPER_BOUND_INF && c[i] > bu_c[i] + tol {
1600                return false;
1601            }
1602        }
1603
1604        // (2) Sustained (non-decelerating) descent. `-inf` passes: an
1605        // objective that has already overflowed downward is not evidence
1606        // against unboundedness.
1607        let f_t = nlp.eval_f(&xt);
1608        if f_t.is_nan() || f_t > f_x + 0.5 * slope * t {
1609            return false;
1610        }
1611    }
1612    true
1613}
1614
1615#[derive(Debug, Clone, Copy)]
1616pub(crate) struct KktError {
1617    pub stationarity: Number,
1618    pub constr_viol: Number,
1619}
1620
1621/// Fraction of the linearized ℓ1 violation an elastic step must remove to be
1622/// taken (see `elastic_step` in the outer loop). Any real reduction is a
1623/// direction toward feasibility; the margin only keeps round-off on a
1624/// stationary point of the violation from passing for one.
1625const ELASTIC_STEP_MIN_GAIN: Number = 1e-6;
1626
1627/// ℓ1 violation of the step QP's own rows and bounds at step `d`: the
1628/// linearization `c(x_k) + J d` against the row bounds, and `x_k + d` against
1629/// the variable bounds, both as the QP poses them (shifted to step space).
1630/// Only the NLP's rows and variables count; a lifted subproblem's extra
1631/// rows and columns are bookkeeping, not constraints.
1632fn linearized_l1_violation(data: &SqpQpData, d: &[Number]) -> Number {
1633    let (m, n) = (data.m, data.n);
1634    let ad = mat_vec_gen(&data.a, d, m);
1635    crate::sqp::line_search::l1_violation(
1636        &d[..n],
1637        &ad,
1638        &data.bl[..m],
1639        &data.bu[..m],
1640        &data.xl[..n],
1641        &data.xu[..n],
1642    )
1643}
1644
1645/// Sparse `A · p` for an `m × n` general-constraint Jacobian stored
1646/// as a `GenTMatrix` (1-based triplet indices). Used to re-center
1647/// the second-order-correction QP's RHS on the trial point.
1648fn mat_vec_gen(a: &GenTMatrix, p: &[Number], m: usize) -> Vec<Number> {
1649    let mut out = vec![0.0; m];
1650    let irows = a.irows();
1651    let jcols = a.jcols();
1652    let vals = a.values();
1653    for k in 0..vals.len() {
1654        let i = (irows[k] - 1) as usize;
1655        let j = (jcols[k] - 1) as usize;
1656        out[i] += vals[k] * p[j];
1657    }
1658    out
1659}
1660
1661/// Build the quasi-Newton curvature pair `(s, y)` for the step from the
1662/// previous iterate to the current one, differencing `∇L` at a **single,
1663/// fixed multiplier** (Nocedal-Wright §18.3):
1664///
1665/// ```text
1666///     s = x_k − x_{k−1}
1667///     y = ∇L(x_k, λ_k) − ∇L(x_{k−1}, λ_k)        ← the SAME λ_k twice
1668/// ```
1669///
1670/// Returns `None` on the first iteration (no previous point yet).
1671///
1672/// **Why the fixed multiplier matters (gh #361).** The previous code held
1673/// `∇L(x_{k−1}, λ_{k−1})` inside the Hessian object and differenced against
1674/// `∇L(x_k, λ_k)`, giving
1675///
1676/// ```text
1677///     y = (∇f_k − ∇f_{k−1}) + (J_kᵀλ_k − J_{k−1}ᵀλ_{k−1})
1678/// ```
1679///
1680/// For **linear** constraints `J` is constant, so that second group collapses
1681/// to `Aᵀ(λ_k − λ_{k−1})` — pure *multiplier* difference, carrying no
1682/// curvature information at all. Since the true `∇²L` equals `∇²f` there, the
1683/// whole term is spurious, and it feeds a divergent loop: a perturbed `B`
1684/// yields a worse QP multiplier, which injects a larger error into the next
1685/// `y`, which corrupts `B` further. On equality-constrained QPs (where `λ` is
1686/// sign-free and can swing hard) the multiplier was observed oscillating and
1687/// growing exponentially — `−13, 19, −69, 104, −145, 581, −1320, 3176, …` —
1688/// while `x` itself sat on the exact optimum. The solve then burned its whole
1689/// iteration budget and exited `Maximum_Iterations_Exceeded` *at the right
1690/// answer*, because the stationarity residual is computed from that garbage
1691/// multiplier.
1692///
1693/// Using one multiplier at both points makes the term telescope to
1694/// `Σλᵏᵢ(∇cᵢ(x_k) − ∇cᵢ(x_{k−1}))`, which is the genuine constraint-curvature
1695/// contribution: it vanishes identically for linear constraints (as it must)
1696/// and is retained for nonlinear ones.
1697fn curvature_pair(
1698    prev: Option<&(Vec<Number>, Vec<Number>, Triplet)>,
1699    iter: &SqpIterates,
1700    grad_f: &[Number],
1701    jac_c: &Triplet,
1702    n: usize,
1703) -> Option<(Vec<Number>, Vec<Number>)> {
1704    let (prev_x, prev_grad_f, prev_jac) = prev?;
1705    let s: Vec<Number> = iter
1706        .x
1707        .iter()
1708        .zip(prev_x.iter())
1709        .map(|(a, b)| a - b)
1710        .collect();
1711    // Both evaluated at the *current* multiplier `iter.lambda_g`.
1712    let lag_curr = compute_grad_lag(grad_f, jac_c, &iter.lambda_g, n);
1713    let lag_prev = compute_grad_lag(prev_grad_f, prev_jac, &iter.lambda_g, n);
1714    let y: Vec<Number> = lag_curr
1715        .iter()
1716        .zip(lag_prev.iter())
1717        .map(|(a, b)| a - b)
1718        .collect();
1719    Some((s, y))
1720}
1721
1722/// Lagrangian gradient `∇L(x, λ_g) = ∇f(x) + J_c(x)ᵀ λ_g` at the
1723/// current iterate. Used by the damped-BFGS update.
1724fn compute_grad_lag(
1725    grad_f: &[Number],
1726    jac_c: &Triplet,
1727    lambda_g: &[Number],
1728    n: usize,
1729) -> Vec<Number> {
1730    let mut out = grad_f.to_vec();
1731    debug_assert_eq!(out.len(), n);
1732    for k in 0..jac_c.irow.len() {
1733        let row_i = (jac_c.irow[k] - 1) as usize;
1734        let col_j = (jac_c.jcol[k] - 1) as usize;
1735        out[col_j] += jac_c.vals[k] * lambda_g[row_i];
1736    }
1737    out
1738}
1739/// Walk `d` from `x` and return a strictly better feasible point, or `None`.
1740///
1741/// This is what turns a curvature *direction* into a refutation. gh #848
1742/// established the shape one layer down: a feasible point with a strictly
1743/// lower objective is proof needing no theory, so the search that produced
1744/// `d` may be as approximate as it likes — a direction it gets wrong costs
1745/// the evaluations below and nothing else.
1746///
1747/// The step length is the distance to the first blocking variable bound,
1748/// halved back until the *nonlinear* constraints are satisfied to
1749/// `constr_viol_tol`. That backtrack is the difference between this and the
1750/// QP-level version: `d` lies in the null space of the *linearized* active
1751/// constraints, which holds them exactly only where they are linear.
1752/// `nonconvex_qp`'s equality is linear, so the first trial is accepted there;
1753/// a curved constraint gives up its step rather than trading feasibility for
1754/// objective, which the SQP's own merit function would then have to undo.
1755#[allow(clippy::too_many_arguments)]
1756fn exhibit_better_point<N: SqpProblemSpec>(
1757    nlp: &mut N,
1758    x: &[Number],
1759    d: &[Number],
1760    f_curr: Number,
1761    xl: &[Number],
1762    xu: &[Number],
1763    bl_c: &[Number],
1764    bu_c: &[Number],
1765    constr_viol_tol: Number,
1766) -> Option<Vec<Number>> {
1767    let n = x.len();
1768    let dn = d.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
1769    if !(dn > 0.0) || !dn.is_finite() {
1770        return None;
1771    }
1772    // Both signs: curvature is even, so `-d` descends wherever `d` does, and
1773    // only one of them may have room before a bound.
1774    //
1775    // Every feasible trial is *scored* and the best one is returned, rather
1776    // than the first that clears the bar (gh#873). Two reasons, and the second
1777    // is a defect the first exposed:
1778    //
1779    // 1. The profile along `d` is not monotone. `f(x + αd) ≈ f(x) + ½α²·dᵀ∇²f d`
1780    //    only near `x`; on `nonconvex_two_escapes` the quartic term takes `f`
1781    //    back to `+1.8` at the wall while the interior of the same ray reaches
1782    //    `−0.225`. So the step length has to be searched, not guessed —
1783    //    which is why the halving below now runs on *either* rejection and not
1784    //    only on infeasibility, the mechanism that had the SQP arm certifying
1785    //    that fixture's documented maximum at every `neg_curv_escapes`.
1786    // 2. `sign` is scanned in a fixed order, so "first acceptable" made the
1787    //    answer depend on it. On `min −x₀² + x₁²` with `x₀ ∈ [−2, g]` the `+d`
1788    //    wall is worth `−g²` and the `−d` wall is worth `−4`; returning the
1789    //    first meant a `g` small enough to clear the bar handed back `−g²` and
1790    //    threw away the global minimum sitting in the other sign.
1791    //
1792    // Scoring costs nothing extra — the evaluations already happened — and the
1793    // bound stays the 24 halvings per sign the loop always carried, spent once
1794    // at convergence on a point already known to have negative curvature.
1795    // The feasibility a refutation is held to is the *incumbent's*, not the
1796    // convergence tolerance (gh#873, found by the fixture sweep).
1797    //
1798    // `constr_viol_tol` is the bar for calling a solve converged; using it here
1799    // let the exhibition buy objective with infeasibility. Measured on
1800    // `cresc4.nl` (lbfgs leg): the KKT point is feasible to 2.2e-16, and the
1801    // trials this accepted violated the rows by 6e-7 — nine orders worse, but
1802    // legal under `constr_viol_tol = 1e-6` — to gain 4.4e-7 of objective. The
1803    // arm then restored feasibility and returned to the same point, eight
1804    // times over, until `MAX_SECOND_ORDER_ESCAPES` capped it: 15 iterations
1805    // became 45 for an answer identical to 15 significant figures. That is
1806    // gh#544's shape exactly — the right answer, slowly — and it is the reason
1807    // CLAUDE.md requires the sweep on a trajectory change.
1808    //
1809    // A point that proves the incumbent is not a local minimum has to be at
1810    // least as feasible as the incumbent is. The slack is `1e-12` *relative to
1811    // the trial's own row magnitudes*, so it is roundoff and not a distance in
1812    // the units of `c`; and it is clamped at `constr_viol_tol`, so this bar is
1813    // never looser than the one it replaces.
1814    //
1815    // The cost is honest and worth naming: against a *curved* active
1816    // constraint a straight tangent probe leaves the feasible set at order
1817    // `α²`, so on such a model the exhibition now declines rather than
1818    // accepting a point that is only tolerance-feasible. That is the same
1819    // structural limit as gh#873 D3 — this walks a straight line and tests the
1820    // objective — made visible instead of paid for in iterations.
1821    let viol_at = |nlp: &mut N, v: &[Number]| -> (Number, Number) {
1822        let c = nlp.eval_c(v);
1823        let mut viol = 0.0_f64;
1824        let mut scale = 0.0_f64;
1825        for (j, &cj) in c.iter().enumerate() {
1826            viol = viol
1827                .max((bl_c[j] - cj).max(0.0))
1828                .max((cj - bu_c[j]).max(0.0));
1829            scale = scale.max(cj.abs());
1830        }
1831        (viol, scale)
1832    };
1833    let (viol_curr, _) = viol_at(nlp, x);
1834
1835    let mut best: Option<(Number, Vec<Number>)> = None;
1836    for sign in [1.0_f64, -1.0] {
1837        let mut alpha = f64::INFINITY;
1838        for i in 0..n {
1839            let di = sign * d[i];
1840            if di > 1e-12 * dn && xu[i] < f64::INFINITY {
1841                alpha = alpha.min((xu[i] - x[i]) / di);
1842            }
1843            if di < -1e-12 * dn && xl[i] > f64::NEG_INFINITY {
1844                alpha = alpha.min((x[i] - xl[i]) / -di);
1845            }
1846        }
1847        // Unbounded in this direction is not this function's business -- the
1848        // unbounded-model fallback owns that -- so cap and keep going.
1849        if !alpha.is_finite() {
1850            alpha = 1.0 / dn;
1851        }
1852        for _ in 0..24 {
1853            if !(alpha > 0.0) {
1854                break;
1855            }
1856            let trial: Vec<Number> = (0..n)
1857                .map(|i| (x[i] + sign * alpha * d[i]).clamp(xl[i], xu[i]))
1858                .collect();
1859            let (viol, c_scale) = viol_at(nlp, &trial);
1860            let feas_bar = viol_curr.max(1e-12 * c_scale).min(constr_viol_tol);
1861            if viol <= feas_bar {
1862                let f_trial = nlp.eval_f(&trial);
1863                if f_trial.is_finite() && best.as_ref().is_none_or(|(b, _)| f_trial < *b) {
1864                    best = Some((f_trial, trial));
1865                }
1866            }
1867            alpha *= 0.5;
1868        }
1869    }
1870
1871    // Strictly better by more than the objective's own scale can round, so
1872    // this cannot fire on noise at a genuine optimum.
1873    //
1874    // The `1.0 +` is what made that "the objective's own scale" rather than an
1875    // absolute `1e-10`, and it is itself scaled here (gh#873 D2, the same class
1876    // as gh#872). On `min k·x₀x₁ s.t. x₀ + x₁ = 2` the true improvement is
1877    // `64·k` (the corner `(9, −7)` against the stationary `(1, 1)`), so from
1878    // `k ≈ 1e-12` down the whole model lived below the additive
1879    // `1e-10` and every genuine refutation was rejected as noise — while the
1880    // reduced Hessian is `−k`, as indefinite at `k = 1e-30` as at `k = 1`.
1881    //
1882    // Lowered only: `.min(1.0)` leaves the bar exactly as it was for any
1883    // objective at or above unit scale, so this cannot make an existing solve
1884    // newly refutable.
1885    let (f_best, x_best) = best?;
1886    let f_scale = f_curr.abs().max(f_best.abs());
1887    let bar = 1e-10 * (f_scale.min(1.0) + f_curr.abs());
1888    (f_best < f_curr - bar).then_some(x_best)
1889}
1890
1891/// The activity tolerance for one bound or row, in that quantity's own units.
1892///
1893/// A raw `constr_viol_tol` is a distance in the units of `x` (for a bound) or
1894/// of `c` (for a row), so using it directly is an absolute threshold on a
1895/// scale-dependent quantity — gh#873 D2, the same class as gh#872. Confirmed
1896/// as pure scaling by an exact change of variables `u = S·x` with the gap held
1897/// fixed in `x` units: at `S = 1e-2` a `5e-7` gap crosses `tol`, and a bound
1898/// with multiplier zero is frozen into the working set, closing the null space
1899/// and the second-order verdict with it.
1900///
1901/// `scale` is the quantity and its own bounds; infinite entries are skipped,
1902/// and everything left scales together under a change of units, so the ratio
1903/// the test really wants is invariant.
1904///
1905/// Scaled **only downwards**, as in gh#872's `psd_band`: widening the test
1906/// above unit scale would freeze bounds that are free today, which loses
1907/// refutations rather than gaining them, and every refutation still has to
1908/// exhibit a strictly better feasible point before it changes any answer.
1909fn active_tol(tol: Number, scale: &[Number]) -> Number {
1910    let s = scale
1911        .iter()
1912        .filter(|v| v.is_finite())
1913        .fold(0.0_f64, |a, v| a.max(v.abs()));
1914    tol * s.min(1.0)
1915}
1916
1917/// A feasible direction of negative curvature at a *converged* first-order
1918/// point, or `None` when the point survives the second-order test (gh #856).
1919///
1920/// # Why this runs at convergence and not at each step
1921///
1922/// gh #848 gave standalone QP solves a second-order screen. Applying the same
1923/// screen to the SQP's **step** subproblem is wrong, and gh #856 has the
1924/// counterexample: that QP is a local model built from the *current*
1925/// multiplier estimates, and its second-order verdict is not the NLP's. At
1926/// SQP iteration 0 the multipliers are still zero, so the exact Lagrangian
1927/// Hessian is `∇²f`; started at HS071's own `x*` the step QP's working set
1928/// leaves a one-dimensional null space on which `dᵀHd = -4.05e-2`, and the
1929/// point is refuted — correctly for that model, wrongly for the NLP, whose
1930/// reduced Hessian at `x*` is positive once the multipliers have converged.
1931///
1932/// At *convergence* that objection disappears: the multipliers are the
1933/// converged ones, so `∇²L` is the Hessian the second-order condition is
1934/// actually about. This is the same distinction gh #856 draws when it says
1935/// "with the converged multipliers the reduced Hessian is positive" — the
1936/// check is meaningful exactly where it is run.
1937///
1938/// # What it computes
1939///
1940/// `Z` is an orthonormal basis for the null space of the active constraint
1941/// normals — equality rows, active inequality rows and active bounds — taken
1942/// from the eigenvectors of `BᵀB` whose eigenvalue is negligible against the
1943/// largest. The reduced Hessian `ZᵀHZ` is then eigen-decomposed, and a
1944/// sufficiently negative eigenvalue yields `d = Z v`: a direction that holds
1945/// every active constraint to first order and along which the objective
1946/// curves down.
1947///
1948/// Returns `None` when there is no negative curvature, when the active set
1949/// leaves no degrees of freedom, or when either eigensolve fails to converge
1950/// — never a direction it is unsure of, since the caller acts on it.
1951#[allow(clippy::too_many_arguments)]
1952fn negative_curvature_at_kkt_point(
1953    n: usize,
1954    m: usize,
1955    x: &[Number],
1956    hess_lag: &Triplet,
1957    jac_c: &Triplet,
1958    c_vals: &[Number],
1959    bl_c: &[Number],
1960    bu_c: &[Number],
1961    xl: &[Number],
1962    xu: &[Number],
1963    tol: Number,
1964) -> Option<Vec<Number>> {
1965    // Dense and `O(n³)`, so it is bounded rather than let loose on a large
1966    // model. It runs once, at convergence, on the way to reporting success —
1967    // and skipping it returns exactly today's answer, so the ceiling costs
1968    // coverage and never correctness. (Contrast gh #849, where the analogous
1969    // ceiling silently withdrew a *guarantee*.)
1970    const MAX_N: usize = 512;
1971    if n == 0 || n > MAX_N {
1972        return None;
1973    }
1974
1975    // The active set. A bound or row counts as active when the iterate sits
1976    // on it to within the same tolerance the convergence test just used, so
1977    // this is the set the verdict was issued about.
1978    let mut rows: Vec<Vec<Number>> = Vec::new();
1979    for j in 0..m {
1980        let t = active_tol(tol, &[c_vals[j], bl_c[j], bu_c[j]]);
1981        let lo_active = bl_c[j] > f64::NEG_INFINITY && (c_vals[j] - bl_c[j]).abs() <= t;
1982        let hi_active = bu_c[j] < f64::INFINITY && (bu_c[j] - c_vals[j]).abs() <= t;
1983        if lo_active || hi_active {
1984            let mut r = vec![0.0; n];
1985            // `pounce_linalg` triplets are **1-based** (see `triplet.rs`).
1986            for k in 0..jac_c.vals.len() {
1987                if jac_c.irow[k] as usize == j + 1 {
1988                    r[jac_c.jcol[k] as usize - 1] += jac_c.vals[k];
1989                }
1990            }
1991            rows.push(r);
1992        }
1993    }
1994    for i in 0..n {
1995        let t = active_tol(tol, &[x[i], xl[i], xu[i]]);
1996        let on_lo = xl[i] > f64::NEG_INFINITY && (x[i] - xl[i]).abs() <= t;
1997        let on_hi = xu[i] < f64::INFINITY && (xu[i] - x[i]).abs() <= t;
1998        if on_lo || on_hi {
1999            let mut r = vec![0.0; n];
2000            r[i] = 1.0;
2001            rows.push(r);
2002        }
2003    }
2004
2005    // Null space of the active normals, from the eigenvectors of `BᵀB`.
2006    let mut z: Vec<Number> = Vec::new();
2007    let n_dof;
2008    if rows.is_empty() {
2009        n_dof = n;
2010        z = vec![0.0; n * n];
2011        for i in 0..n {
2012            z[i * n + i] = 1.0;
2013        }
2014    } else {
2015        let mut btb = vec![0.0; n * n];
2016        for r in &rows {
2017            for a in 0..n {
2018                if r[a] == 0.0 {
2019                    continue;
2020                }
2021                for c in 0..n {
2022                    btb[c * n + a] += r[a] * r[c];
2023                }
2024            }
2025        }
2026        let (mut ev, mut evec) = (vec![0.0; n], vec![0.0; n * n]);
2027        if !pounce_linalg::symmetric_eigen(&btb, n, &mut ev, &mut evec) {
2028            return None;
2029        }
2030        let lam_max = ev.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
2031        // Relative to `BᵀB`'s own spectral scale, with no absolute floor under
2032        // it (gh#873 D2). `lam_max` is already that scale, so the `.max(1.0)`
2033        // this carried made the rank cut absolute for small constraint
2034        // normals: a row scaled down by a change of units then read as part of
2035        // the null space, and the direction built from it does not respect the
2036        // constraint it came from.
2037        //
2038        // `lam_max == 0` means every active row has a zero gradient, so none
2039        // of them restricts anything and the whole space is free. The old
2040        // `.max(1.0)` produced that answer as a side effect of the floor;
2041        // stated here so removing the floor does not quietly change it into
2042        // "no degrees of freedom", which would decline the refutation.
2043        let cut = if lam_max > 0.0 { 1e-9 * lam_max } else { 0.0 };
2044        if lam_max == 0.0 {
2045            z.extend_from_slice(&evec[..n * n]);
2046            n_dof = n;
2047        } else {
2048            for (j, &lam) in ev.iter().enumerate() {
2049                if lam.abs() <= cut {
2050                    z.extend_from_slice(&evec[j * n..(j + 1) * n]);
2051                }
2052            }
2053            n_dof = z.len() / n;
2054        }
2055    }
2056    if n_dof == 0 {
2057        return None;
2058    }
2059
2060    // `H Z`, then `Zᵀ (H Z)`.
2061    let mut hz = vec![0.0; n * n_dof];
2062    for k in 0..n_dof {
2063        let (zc, out) = (&z[k * n..(k + 1) * n], &mut hz[k * n..(k + 1) * n]);
2064        // Stored as one triangle, so an off-diagonal entry contributes to
2065        // both of its rows.
2066        for e in 0..hess_lag.vals.len() {
2067            let (r, c, v) = (
2068                hess_lag.irow[e] as usize - 1,
2069                hess_lag.jcol[e] as usize - 1,
2070                hess_lag.vals[e],
2071            );
2072            out[r] += v * zc[c];
2073            if r != c {
2074                out[c] += v * zc[r];
2075            }
2076        }
2077    }
2078    let mut rh = vec![0.0; n_dof * n_dof];
2079    for a in 0..n_dof {
2080        for b in 0..n_dof {
2081            rh[b * n_dof + a] = (0..n).map(|i| z[a * n + i] * hz[b * n + i]).sum();
2082        }
2083    }
2084
2085    let (mut ev, mut evec) = (vec![0.0; n_dof], vec![0.0; n_dof * n_dof]);
2086    if !pounce_linalg::symmetric_eigen(&rh, n_dof, &mut ev, &mut evec) {
2087        return None;
2088    }
2089    // Relative to the Hessian's own scale, so this cannot fire on the
2090    // rounding noise of a genuinely positive-semidefinite reduced Hessian --
2091    // and with no absolute floor under it (gh#873 D2, gh#872's sibling). The
2092    // `.max(1.0)` this carried made the test absolute below unit Hessian
2093    // scale, and `min k·x₀x₁ s.t. x₀ + x₁ = 2` then returned the constrained
2094    // *maximum* `f = k` for every `k` from `1e-8` down -- a family whose
2095    // reduced Hessian is `−k`, i.e. as indefinite at `k = 1e-30` as at `k = 1`.
2096    let h_scale = hess_lag.vals.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
2097    if ev[0] >= -1e-8 * h_scale {
2098        return None;
2099    }
2100    // `d = Z v` with `v` the eigenvector of the most negative eigenvalue --
2101    // column 0 of the column-major `evec`, since the eigensolver returns them
2102    // in ascending order.
2103    let mut d = vec![0.0; n];
2104    for (i, di) in d.iter_mut().enumerate() {
2105        *di = (0..n_dof).map(|k| z[k * n + i] * evec[k]).sum();
2106    }
2107    Some(d)
2108}
2109
2110/// Iteration budget for one step QP.
2111///
2112/// `pounce-qp`'s default is a flat 200, the interior-point budget, and the
2113/// wrong shape for an active-set method, which needs about one iteration per
2114/// working-set change: a cold first QP on a model with thousands of active
2115/// bounds cannot finish inside it, and reports `QpIterationLimit` on a
2116/// subproblem it was solving correctly. Scaled exactly as
2117/// `pounce_convex::active_set` scales the direct driver's (10 per variable
2118/// and row, floor 200, ceiling 200 000), and like there an explicitly set
2119/// `sqp_qp_max_iter` — anything but the default — wins.
2120fn step_qp_iter_budget(current: u32, n: usize, m: usize) -> u32 {
2121    const DEFAULT_MAX_ITER: u32 = 200;
2122    const PER_DIM: usize = 10;
2123    if current != DEFAULT_MAX_ITER {
2124        return current;
2125    }
2126    n.saturating_add(m)
2127        .saturating_mul(PER_DIM)
2128        .clamp(200, 200_000) as u32
2129}
2130
2131/// Solve the step QP `data` poses, warm-started from `warm` (in the NLP's
2132/// dimensions) when given, and return the solution in the NLP's dimensions.
2133///
2134/// The one place the lifted low-rank form (gh#1054) meets the solver: the
2135/// working set goes in lifted and the solution comes out unlifted, so the
2136/// rest of the loop never sees the extra variables. On an unlifted
2137/// subproblem both maps are the identity.
2138///
2139/// Each solve is logged at debug level under `pounce::sqp::qp` with its
2140/// outcome, working-set changes, refactorizations, Schur updates, rank tests
2141/// and wall time (gh#1061). A QP that takes minutes is otherwise one opaque
2142/// number, and the remedies differ: many working-set changes say the working
2143/// set moved a lot, a refactor count says the Schur path kept resetting, and
2144/// a rank-test count says it spent its time pruning dependent rows.
2145///
2146/// The wall time is split as well: factorization, back-solves (with their
2147/// count), rank tests, and `other` — everything outside the linear solver,
2148/// which is KKT assembly, the ratio and drop tests and the SMW correction's
2149/// dense arithmetic. Counts say what the QP did; the split says which of it
2150/// cost the time. It was a large `other` with a modest `factor` that would
2151/// have named the Schur path's `O(m·nnz(A))` reset assembly at once (gh#1061:
2152/// 2.2 s per Schur-path refactorization against 0.57 s off it).
2153fn solve_step_qp(
2154    solver: &mut ParametricActiveSetSolver,
2155    data: &SqpQpData,
2156    warm: Option<&WorkingSet>,
2157    opts: &QpOptions,
2158    seed: Option<&IpmSeed>,
2159) -> Result<pounce_qp::QpSolution, pounce_qp::QpError> {
2160    solve_subproblem_qp(solver, data, data.as_qp(), warm, opts, seed, "step QP")
2161}
2162
2163/// [`solve_step_qp`] for a QP built on `data` with other row bounds — the
2164/// second-order-correction QP, which re-centres them on the trial point —
2165/// logged as `kind`.
2166///
2167/// The correction QP used to call the solver directly, so it had none of
2168/// the step QP's handling: no seeding for a warm start that pins infeasible,
2169/// and no log line. gh#1061: on the reporter's model the SQP reached
2170/// iteration 2, rejected the full step, and the correction QP sat in the
2171/// elastic phase for the rest of the wall budget (585 pivots in 345 s,
2172/// objective frozen) without a line in the log. Solved alone, the same QP
2173/// is optimal from the interior point in 32 s.
2174///
2175/// `seed` is `Some` where step QPs are seeded from the interior point (see
2176/// [`SqpOptions::qp_ipm_cold_start_min_dim`]). There a warm start that pins
2177/// feasibly is also given a budget: [`WARM_ITERS_PER_SEED_ITER`] active-set
2178/// iterations per iteration the last seed took. A warm solve that would need
2179/// more is costing more than seeding it, and is seeded instead.
2180pub(crate) fn solve_subproblem_qp(
2181    solver: &mut ParametricActiveSetSolver,
2182    data: &SqpQpData,
2183    qp: pounce_qp::QpProblem<'_>,
2184    warm: Option<&WorkingSet>,
2185    opts: &QpOptions,
2186    seed: Option<&IpmSeed>,
2187    kind: &str,
2188) -> Result<pounce_qp::QpSolution, pounce_qp::QpError> {
2189    let started = std::time::Instant::now();
2190    let guards_before = solver.rank_guard_calls();
2191    let split_before = solver.linsol_time_split();
2192    let seeded = match (warm, seed) {
2193        (None, Some(seed)) => ipm_seeded_step_qp(solver, &qp, data.hess_inf, opts, seed),
2194        _ => None,
2195    };
2196    // gh#1061: warm step QPs on the reporter's model needed 68–350
2197    // working-set changes and 51–263 s each, at about one factorization per
2198    // change, where the seed takes 19–22 interior-point iterations and
2199    // 15–20 s and finishes in 0–2 changes. Counted in iterations rather than
2200    // seconds, so the same model takes the same path on every machine.
2201    let budget = seed
2202        .and_then(|s| s.last_iters.get())
2203        .map(|k| (k * WARM_ITERS_PER_SEED_ITER) as u32)
2204        .filter(|&b| b < opts.max_iter);
2205    let budget_opts = budget.map(|b| QpOptions {
2206        max_iter: b,
2207        ..opts.clone()
2208    });
2209    let sol = match (seeded, warm) {
2210        (Some(sol), _) => Ok(sol),
2211        // A warm start that pins to an infeasible point would go to the
2212        // ℓ1-elastic phase 1, which at this size is the cold start's cost
2213        // again. gh#1061's second step QP (warm from the seeded first one,
2214        // feasible: IPOPT 128.990447) reached the 300 s cap there after 564
2215        // working-set changes; seeded like a cold QP it is optimal in 12.5 s
2216        // with 2. The elastic path stays for when the seed declines.
2217        (None, Some(w)) if seed.is_some() => {
2218            let seed = seed.expect("guarded");
2219            let lifted = data.lift_working_set(w);
2220            let warm_opts = budget_opts.as_ref().unwrap_or(opts);
2221            match solver.solve_with_working_set_if_feasible(&qp, &lifted, warm_opts) {
2222                // Over budget: seed, or if the seed declines, carry on from
2223                // where the warm solve stopped with the full budget.
2224                Ok(Some(sol)) if budget.is_some() && sol.status == QpStatus::MaxIter => {
2225                    tracing::debug!(target: "pounce::sqp::qp",
2226                        "warm start over its budget of {} iterations (n={}, m={}, {} working-set \
2227                         changes in {:.3?}); seeding from the interior point",
2228                        warm_opts.max_iter, qp.n, qp.m, sol.stats.n_working_set_changes,
2229                        started.elapsed());
2230                    match ipm_seeded_step_qp(solver, &qp, data.hess_inf, opts, seed) {
2231                        Some(seeded) => Ok(seeded),
2232                        None => solver.solve_with_working_set(&qp, &sol.working, opts),
2233                    }
2234                }
2235                Ok(Some(sol)) => Ok(sol),
2236                Ok(None) => {
2237                    tracing::debug!(target: "pounce::sqp::qp",
2238                        "warm start pins infeasible (n={}, m={}); seeding from the interior point",
2239                        qp.n, qp.m);
2240                    match ipm_seeded_step_qp(solver, &qp, data.hess_inf, opts, seed) {
2241                        Some(sol) => Ok(sol),
2242                        None => solver.solve_with_working_set(&qp, &lifted, opts),
2243                    }
2244                }
2245                Err(e) => Err(e),
2246            }
2247        }
2248        (None, Some(w)) => solver.solve_with_working_set(&qp, &data.lift_working_set(w), opts),
2249        (None, None) => solver.solve(&qp, None, opts),
2250    };
2251    let elapsed = started.elapsed();
2252    let rank_guard_calls = solver.rank_guard_calls() - guards_before;
2253    let t = solver.linsol_time_split().since(&split_before);
2254    let other = elapsed.saturating_sub(t.factor + t.resolve + t.rank_test);
2255    match &sol {
2256        Ok(s) => tracing::debug!(target: "pounce::sqp::qp",
2257            "{kind} (n={}, m={}, warm={}): {} in {:.3?}, {} working-set changes, \
2258             {} refactors, {} Schur updates, {} rank tests, phase1={}; \
2259             time: {} factorizations {:.3?}, {} back-solves {:.3?}, rank tests {:.3?}, other {:.3?}",
2260            qp.n, qp.m, warm.is_some(), s.status, elapsed,
2261            s.stats.n_working_set_changes, s.stats.n_refactor, s.stats.n_schur_updates,
2262            rank_guard_calls, s.stats.used_phase1,
2263            t.n_factor, t.factor, t.n_resolve, t.resolve, t.rank_test, other),
2264        Err(e) => tracing::debug!(target: "pounce::sqp::qp",
2265            "{kind} (n={}, m={}, warm={}): error {e:?} after {:.3?}, {} rank tests; \
2266             time: {} factorizations {:.3?}, {} back-solves {:.3?}, rank tests {:.3?}, other {:.3?}",
2267            qp.n, qp.m, warm.is_some(), elapsed, rank_guard_calls,
2268            t.n_factor, t.factor, t.n_resolve, t.resolve, t.rank_test, other),
2269    }
2270    Ok(data.unlift_solution(sol?))
2271}
2272
2273/// A step QP solved by the convex interior point and finished by the
2274/// active-set solver from the working set that solution identifies, or `None`
2275/// to fall back to the active-set path as before (see
2276/// [`SqpOptions::qp_ipm_cold_start_min_dim`]). Used for a cold QP, and for a
2277/// warm one whose working set pins to an infeasible point.
2278///
2279/// `None` unless every stage succeeds: the Hessian is certified PSD (an
2280/// interior point is not a method for an indefinite QP), the interior point
2281/// reaches `Optimal` (an infeasible QP goes to the active set's elastic
2282/// phase, whose step the SQP takes — see `elastic_step`), and the warm
2283/// active-set solve reaches `Optimal` too. The active-set solution is what
2284/// comes back, so the step, its multipliers and its working set are the
2285/// active-set method's own; the interior point only chose where it starts.
2286fn ipm_seeded_step_qp(
2287    solver: &mut ParametricActiveSetSolver,
2288    qp: &pounce_qp::QpProblem,
2289    hess_inf: Number,
2290    opts: &QpOptions,
2291    seed: &IpmSeed,
2292) -> Option<pounce_qp::QpSolution> {
2293    use pounce_convex::Triplet as T;
2294    let started = std::time::Instant::now();
2295    let (n, m) = (qp.n, qp.m);
2296    let feral = || {
2297        Box::new(pounce_feral::FeralSolverInterface::new())
2298            as Box<dyn pounce_linsol::SparseSymLinearSolverInterface>
2299    };
2300    let p_lower: Vec<T> = (0..qp.h.irows().len())
2301        .map(|k| {
2302            let (i, j) = (qp.h.irows()[k] as usize - 1, qp.h.jcols()[k] as usize - 1);
2303            T::new(i.max(j), i.min(j), qp.h.values()[k])
2304        })
2305        .collect();
2306    if qp.hessian_inertia != HessianInertia::Psd {
2307        // Absolute, as the certificate's contract is, at the scale of `H`.
2308        let tol = 1e-9 * (1.0 + hess_inf);
2309        if pounce_convex::certify_psd_lower_triangle(n, &p_lower, tol, feral) != Ok(true) {
2310            return None;
2311        }
2312    }
2313    let mut rows: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
2314    for k in 0..qp.a.irows().len() {
2315        let (i, j) = (qp.a.irows()[k] as usize - 1, qp.a.jcols()[k] as usize - 1);
2316        rows[i].push((j, qp.a.values()[k]));
2317    }
2318    let finite_lo = |v: Number| v > NLP_LOWER_BOUND_INF;
2319    let finite_hi = |v: Number| v < NLP_UPPER_BOUND_INF;
2320    let (mut a, mut b, mut g, mut h) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
2321    for (i, row) in rows.iter().enumerate() {
2322        if qp.bl[i] == qp.bu[i] {
2323            a.extend(row.iter().map(|&(j, v)| T::new(b.len(), j, v)));
2324            b.push(qp.bl[i]);
2325            continue;
2326        }
2327        if finite_hi(qp.bu[i]) {
2328            g.extend(row.iter().map(|&(j, v)| T::new(h.len(), j, v)));
2329            h.push(qp.bu[i]);
2330        }
2331        if finite_lo(qp.bl[i]) {
2332            g.extend(row.iter().map(|&(j, v)| T::new(h.len(), j, -v)));
2333            h.push(-qp.bl[i]);
2334        }
2335    }
2336    let prob = pounce_convex::QpProblem {
2337        n,
2338        p_lower,
2339        c: qp.g.to_vec(),
2340        a,
2341        b,
2342        g,
2343        h,
2344        lb: qp
2345            .xl
2346            .iter()
2347            .map(|&v| {
2348                if finite_lo(v) {
2349                    v
2350                } else {
2351                    pounce_convex::NEG_INF
2352                }
2353            })
2354            .collect(),
2355        ub: qp
2356            .xu
2357            .iter()
2358            .map(|&v| {
2359                if finite_hi(v) {
2360                    v
2361                } else {
2362                    pounce_convex::POS_INF
2363                }
2364            })
2365            .collect(),
2366    };
2367    let ipm_opts = pounce_convex::QpOptions {
2368        time_limit: opts.time_limit,
2369        max_iter: IPM_SEED_MAX_ITER,
2370        ..pounce_convex::QpOptions::default()
2371    };
2372    let ipm = pounce_convex::solve_qp_ipm(&prob, &ipm_opts, feral);
2373    let ipm_time = started.elapsed();
2374    if ipm.status == pounce_convex::QpStatus::Optimal {
2375        seed.last_iters.set(Some(ipm.iters));
2376    }
2377    if ipm.status != pounce_convex::QpStatus::Optimal {
2378        tracing::debug!(target: "pounce::sqp::qp",
2379            "IPM cold start (n={n}, m={m}): {:?} after {} iterations in {ipm_time:.3?}; \
2380             solving from cold instead", ipm.status, ipm.iters);
2381        return None;
2382    }
2383    // The working set the interior point's answer sits on: every row and
2384    // bound within a scaled `IPM_ACTIVE_TOL` of a side it can be active at.
2385    // A wrong guess either way costs the warm solve pivots, not correctness.
2386    let near = |v: Number, bound: Number| (v - bound).abs() <= IPM_ACTIVE_TOL * (1.0 + bound.abs());
2387    let ax = mat_vec_gen(qp.a, &ipm.x, m);
2388    let mut working = WorkingSet::cold(n, m);
2389    for i in 0..m {
2390        working.constraints[i] = if qp.bl[i] == qp.bu[i] {
2391            ConsStatus::Equality
2392        } else if finite_lo(qp.bl[i]) && near(ax[i], qp.bl[i]) {
2393            ConsStatus::AtLower
2394        } else if finite_hi(qp.bu[i]) && near(ax[i], qp.bu[i]) {
2395            ConsStatus::AtUpper
2396        } else {
2397            ConsStatus::Inactive
2398        };
2399    }
2400    for j in 0..n {
2401        working.bounds[j] = if qp.xl[j] == qp.xu[j] {
2402            BoundStatus::Fixed
2403        } else if finite_lo(qp.xl[j]) && near(ipm.x[j], qp.xl[j]) {
2404            BoundStatus::AtLower
2405        } else if finite_hi(qp.xu[j]) && near(ipm.x[j], qp.xu[j]) {
2406            BoundStatus::AtUpper
2407        } else {
2408            BoundStatus::Inactive
2409        };
2410    }
2411    let warm_opts = QpOptions {
2412        time_limit: opts.time_limit.map(|t| t.saturating_sub(started.elapsed())),
2413        ..opts.clone()
2414    };
2415    let sol = solver
2416        .solve_with_working_set(qp, &working, &warm_opts)
2417        .ok()?;
2418    tracing::debug!(target: "pounce::sqp::qp",
2419        "IPM cold start (n={n}, m={m}): optimal after {} iterations in {ipm_time:.3?}, \
2420         {} rows and bounds active; warm active-set solve {} in {:.3?} with {} \
2421         working-set changes", ipm.iters, working.active_count(), sol.status,
2422        sol.stats.time, sol.stats.n_working_set_changes);
2423    (sol.status == QpStatus::Optimal).then_some(sol)
2424}
2425
2426/// Iteration cap on the seeding interior point, against its own default of
2427/// 200. gh#1061's step QPs took 16 and 19; a QP the interior point cannot
2428/// solve in a few times that goes back to the active-set path rather than
2429/// spending the budget here. Measured on a borderline-feasible emulation of
2430/// the second step QP, the uncapped interior point ran 57 iterations to a
2431/// 300 s limit.
2432const IPM_SEED_MAX_ITER: usize = 50;
2433
2434/// Active-set iterations a warm-started subproblem QP may take, per
2435/// interior-point iteration the last seed took, before it is seeded instead
2436/// (see [`solve_subproblem_qp`]). An active-set iteration on gh#1061's model
2437/// is one factorization or one back-solve, and a working-set change about
2438/// two of them; an interior-point iteration, with the certificate and the
2439/// warm finish spread over it, about 0.8 s against 0.44 s per active-set
2440/// factorization. Four per seed iteration therefore lets a warm solve spend
2441/// about what a seed costs (80 iterations, ~40 changes, at the seed's 20)
2442/// before it gives up, so a warm start that is near its answer, as most are,
2443/// keeps it, and one that is not costs at most about twice the seed.
2444const WARM_ITERS_PER_SEED_ITER: usize = 4;
2445
2446/// Relative activity tolerance for reading a working set off an
2447/// interior-point solution in [`ipm_seeded_step_qp`]. The interior point stops
2448/// at a complementarity of order its `tol`, so an active row sits within a few
2449/// orders of that and an inactive one, at a nondegenerate solution, well
2450/// outside it.
2451const IPM_ACTIVE_TOL: Number = 1e-6;
2452
2453/// A subproblem QP's time limit: what is left of the solve's `deadline` at
2454/// this moment, or the QP's `own` limit if that is tighter.
2455fn qp_time_limit_now(
2456    deadline: Option<&pounce_common::timing::Deadline>,
2457    own: Option<std::time::Duration>,
2458) -> Option<std::time::Duration> {
2459    match (deadline.map(deadline_remaining), own) {
2460        (Some((left, _)), Some(own)) => Some(left.min(own)),
2461        (Some((left, _)), None) => Some(left),
2462        (None, own) => own,
2463    }
2464}
2465
2466/// What is left of `deadline`, as the wall-clock duration a step QP can take
2467/// as its `time_limit`, with the budget that set it (gh#1054).
2468///
2469/// The QP reads only a wall clock, so the CPU half is converted one-for-one:
2470/// a busy solve accrues CPU time at about the wall rate, so its remaining CPU
2471/// seconds are the best wall estimate the QP can be given. The tighter of the
2472/// two wins.
2473pub(crate) fn deadline_remaining(
2474    deadline: &pounce_common::timing::Deadline,
2475) -> (std::time::Duration, pounce_common::timing::DeadlineKind) {
2476    use pounce_common::timing::DeadlineKind;
2477    let wall = deadline.remaining_wall();
2478    let cpu = deadline.remaining_cpu();
2479    let (left, kind) = if cpu < wall {
2480        (cpu, DeadlineKind::Cpu)
2481    } else {
2482        (wall, DeadlineKind::Wall)
2483    };
2484    // `from_secs_f64` panics on a negative, non-finite or out-of-range
2485    // argument, and the registered "unlimited" default is `1e20` s, past
2486    // `Duration`'s range. A century is unlimited for this purpose.
2487    const UNLIMITED_S: Number = 3.2e9;
2488    let left = if left.is_finite() {
2489        left.clamp(0.0, UNLIMITED_S)
2490    } else {
2491        UNLIMITED_S
2492    };
2493    (std::time::Duration::from_secs_f64(left), kind)
2494}
2495
2496pub(crate) fn check_kkt(
2497    n: usize,
2498    m: usize,
2499    iter: &SqpIterates,
2500    grad_f: &[Number],
2501    c_vals: &[Number],
2502    bl_c: &[Number],
2503    bu_c: &[Number],
2504    xl: &[Number],
2505    xu: &[Number],
2506    jac_c: &crate::sqp::qp_assembly::Triplet,
2507) -> KktError {
2508    // Constraint violation: max(0, bl - c, c - bu) on every row,
2509    // plus bound violation on every variable.
2510    //
2511    // `NaN` propagates rather than reducing away (gh #876). `f64::max`
2512    // ignores `NaN`, so `(bl - c).max(0.0)` on a non-finite `c` is `0.0` and
2513    // a diverged iterate scores as perfectly feasible. The same reduction on
2514    // the stationarity rows below is the site the issue reports; both are
2515    // fixed, because either one alone still lets `check_kkt` return a clean
2516    // `KktError` from garbage.
2517    let mut viol = 0.0_f64;
2518    let mut nonfinite = false;
2519    for i in 0..m {
2520        let lo = if bl_c[i] > NLP_LOWER_BOUND_INF {
2521            (bl_c[i] - c_vals[i]).max(0.0)
2522        } else {
2523            0.0
2524        };
2525        let hi = if bu_c[i] < NLP_UPPER_BOUND_INF {
2526            (c_vals[i] - bu_c[i]).max(0.0)
2527        } else {
2528            0.0
2529        };
2530        nonfinite |= !c_vals[i].is_finite();
2531        viol = viol.max(lo).max(hi);
2532    }
2533    for i in 0..n {
2534        nonfinite |= !iter.x[i].is_finite();
2535        let lo = if xl[i] > NLP_LOWER_BOUND_INF {
2536            (xl[i] - iter.x[i]).max(0.0)
2537        } else {
2538            0.0
2539        };
2540        let hi = if xu[i] < NLP_UPPER_BOUND_INF {
2541            (iter.x[i] - xu[i]).max(0.0)
2542        } else {
2543            0.0
2544        };
2545        viol = viol.max(lo).max(hi);
2546    }
2547
2548    // Stationarity: ∇f + Jᵀ λ_g − λ_x. pounce-qp's KKT is
2549    // `Hx + Aᵀλ_qp + (lower-bound multiplier) e_i − (upper-bound
2550    // multiplier) e_i = -g`. Since `λ_x = z_l − z_u` packs the
2551    // bound-multiplier sign, the variable-bound term enters the
2552    // stationarity check with a negative sign — i.e. at the
2553    // optimum `∇f + Jᵀ λ_g = λ_x`.
2554    let mut stat = vec![0.0; n];
2555    for (s, &g) in stat.iter_mut().zip(grad_f.iter()) {
2556        *s = g;
2557    }
2558    // Add Jᵀ λ_g
2559    for k in 0..jac_c.irow.len() {
2560        let i = (jac_c.irow[k] - 1) as usize; // 0-based row in c
2561        let j = (jac_c.jcol[k] - 1) as usize; // 0-based col in x
2562        stat[j] += jac_c.vals[k] * iter.lambda_g[i];
2563    }
2564    // Subtract λ_x
2565    for (s, &lx) in stat.iter_mut().zip(iter.lambda_x.iter()) {
2566        *s -= lx;
2567    }
2568    let stat_max = crate::sqp::line_search::inf_norm(&stat);
2569
2570    // An iterate or a constraint value that is not finite makes every residual
2571    // computed from it meaningless, whichever way the arithmetic happens to
2572    // reduce: `+inf - inf` is `NaN`, but `(bl - inf).max(0.0)` is a tidy `0.0`
2573    // and `inf.abs()` is a large number that at least fails `<= tol`. Reporting
2574    // `NaN` for both is the honest answer and the one the caller's `<= tol`
2575    // gates already handle correctly.
2576    if nonfinite {
2577        return KktError {
2578            stationarity: Number::NAN,
2579            constr_viol: Number::NAN,
2580        };
2581    }
2582
2583    KktError {
2584        stationarity: stat_max,
2585        constr_viol: viol,
2586    }
2587}
2588
2589#[cfg(test)]
2590mod kkt_nan_tests {
2591    //! gh #876 — `check_kkt` must not launder a non-finite iterate into a
2592    //! clean `KktError`.
2593    //!
2594    //! The caller's convergence gate is
2595    //! `kkt.stationarity <= stationarity_tol && kkt.constr_viol <= constr_viol_tol`,
2596    //! so anything `check_kkt` reports as `0.0` is reported as converged. Both
2597    //! of its reductions used to swallow `NaN`:
2598    //!
2599    //! * `stat.iter().map(|s| s.abs()).fold(0.0_f64, f64::max)` — `f64::max`
2600    //!   is defined to *ignore* `NaN`, so an all-`NaN` stationarity vector
2601    //!   reduced to `0.0`. This is the site gh #876 reports, and the third
2602    //!   instance of the same shape in this workspace: gh #222 fixed it in
2603    //!   `pounce-convex`, gh #845 in `pounce-sensitivity`.
2604    //! * `viol.max(lo).max(hi)` where `lo = (bl - c).max(0.0)` — the same
2605    //!   definition one layer earlier, so a `NaN` constraint value scored as
2606    //!   *perfectly feasible* before the outer `max` ever saw it. The issue
2607    //!   does not name this one; fixing only the reported site would still
2608    //!   have left `constr_viol` reading `0.0` on a diverged iterate.
2609    //!
2610    //! ## Which branch each test reaches
2611    //!
2612    //! Per this repo's gh #756 lesson, a guard is only evidence about the
2613    //! branch its fixture reaches, so the four tests below are chosen to land
2614    //! in four different places: a `NaN` arriving through `c_vals`, one
2615    //! arriving through `x`, one arriving through the *duals* (where `x` and
2616    //! `c` are both finite and only the stationarity row is poisoned — the
2617    //! `inf_norm` fix, not the `nonfinite` flag), and an ordinary finite
2618    //! iterate that must be completely unaffected.
2619    //!
2620    //! ## Mutation table
2621    //!
2622    //! | revert | red |
2623    //! |---|---|
2624    //! | `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` |
2625    //! | drop the `nonfinite` flag | `a_nan_constraint_value_is_not_perfect_feasibility`, `a_nan_iterate_is_not_perfect_feasibility` |
2626    //! | both | all four of the above |
2627    //!
2628    //! Measured, both directions: each mutation turns exactly the listed
2629    //! tests red and leaves the others — including
2630    //! `a_finite_iterate_is_measured_exactly_as_before` — green. Neither half
2631    //! subsumes the other, which is why both are here.
2632
2633    use super::*;
2634    use crate::sqp::qp_assembly::Triplet;
2635    use pounce_common::types::Index;
2636
2637    /// `min x0` s.t. one row `c(x) = x0`, no bounds — the smallest shape that
2638    /// exercises every accumulation in `check_kkt`.
2639    fn setup(x: Vec<Number>, c: Vec<Number>, lam_g: Vec<Number>) -> KktError {
2640        let n = x.len();
2641        let m = c.len();
2642        let iter = SqpIterates {
2643            x,
2644            lambda_g: lam_g,
2645            lambda_x: vec![0.0; n],
2646            working: None,
2647        };
2648        let jac = Triplet {
2649            n_rows: m,
2650            n_cols: n,
2651            irow: (1..=m as i32).map(|i| i as Index).collect(),
2652            jcol: vec![1 as Index; m],
2653            vals: vec![1.0; m],
2654        };
2655        check_kkt(
2656            n,
2657            m,
2658            &iter,
2659            &vec![1.0; n],
2660            &c,
2661            &vec![0.0; m],
2662            &vec![0.0; m],
2663            &vec![NLP_LOWER_BOUND_INF; n],
2664            &vec![NLP_UPPER_BOUND_INF; n],
2665            &jac,
2666        )
2667    }
2668
2669    #[test]
2670    fn a_finite_iterate_is_measured_exactly_as_before() {
2671        // x0 = 2 against the equality row c = x0 = 0: violation 2, and
2672        // stationarity ∇f + Jᵀλ = 1 + 1·(-1) = 0.
2673        let k = setup(vec![2.0], vec![2.0], vec![-1.0]);
2674        assert_eq!(k.constr_viol, 2.0);
2675        assert_eq!(k.stationarity, 0.0);
2676    }
2677
2678    #[test]
2679    fn a_nan_constraint_value_is_not_perfect_feasibility() {
2680        let k = setup(vec![0.0], vec![Number::NAN], vec![-1.0]);
2681        assert!(
2682            k.constr_viol.is_nan(),
2683            "a NaN constraint value reported constr_viol = {}, which clears \
2684             every tolerance the caller compares it against",
2685            k.constr_viol
2686        );
2687        assert!(k.stationarity.is_nan());
2688    }
2689
2690    #[test]
2691    fn a_nan_iterate_is_not_perfect_feasibility() {
2692        let k = setup(vec![Number::NAN], vec![0.0], vec![-1.0]);
2693        assert!(k.constr_viol.is_nan());
2694        assert!(k.stationarity.is_nan());
2695    }
2696
2697    /// The dual-side branch: `x` and `c` are both finite, so the `nonfinite`
2698    /// flag stays false and nothing but `inf_norm` stands between a `NaN`
2699    /// multiplier and a stationarity residual of `0.0`. This is the test that
2700    /// the `nonfinite` flag alone does **not** cover.
2701    #[test]
2702    fn a_nan_dual_poisons_only_stationarity() {
2703        let k = setup(vec![0.0], vec![0.0], vec![Number::NAN]);
2704        assert!(
2705            k.stationarity.is_nan(),
2706            "a NaN multiplier reduced to stationarity = {}",
2707            k.stationarity
2708        );
2709        // Feasibility genuinely holds at this x, and saying so is correct —
2710        // the flag is about the *primal* iterate, not about the duals.
2711        assert_eq!(k.constr_viol, 0.0);
2712    }
2713}
2714
2715#[cfg(test)]
2716mod inf_norm_tests {
2717    use crate::sqp::line_search::inf_norm;
2718
2719    #[test]
2720    fn a_nan_entry_propagates_rather_than_reducing_away() {
2721        assert!(inf_norm(&[1.0, f64::NAN, 2.0]).is_nan());
2722        assert!(inf_norm(&[f64::NAN]).is_nan());
2723    }
2724
2725    #[test]
2726    fn finite_vectors_are_unchanged() {
2727        assert_eq!(inf_norm(&[]), 0.0);
2728        assert_eq!(inf_norm(&[-3.0, 1.0, 2.0]), 3.0);
2729        assert_eq!(inf_norm(&[f64::INFINITY, 1.0]), f64::INFINITY);
2730    }
2731}