Skip to main content

pounce_algorithm/sqp/
sqp_alg.rs

1//! `SqpAlgorithm` — active-set SQP outer loop. Consumes an
2//! `SqpProblemSpec` for evaluation; delegates the QP subproblem
3//! solve to `pounce_qp::ParametricActiveSetSolver`.
4//!
5//! Outer loop (Nocedal-Wright §18 standard SQP):
6//! 1. Evaluate `f, ∇f, c, ∇c, ∇²L` at `x_k`.
7//! 2. Build the QP via `SqpQpData::build`.
8//! 3. Solve the QP via `pounce-qp` (warm-started by the previous
9//!    `WorkingSet` when available).
10//! 4. KKT-error check on `x_k` (before stepping) — if all
11//!    component tolerances are met, declare optimal.
12//! 5. Globalization step acceptance via either the Fletcher-
13//!    Leyffer 2002 filter (`SqpGlobalization::Filter`, default)
14//!    or the Han-Powell l1-merit (`SqpGlobalization::L1Elastic`),
15//!    both backtracking on α.
16//! 6. Take `α·p`; promote `(x_k + α p, λ_g, λ_x)` to the next
17//!    iterate and carry the QP's `WorkingSet` for the next solve.
18
19use crate::sqp::bfgs::DampedBfgs;
20use crate::sqp::filter::{SqpFilter, filter_line_search};
21use crate::sqp::iterates::SqpIterates;
22use crate::sqp::lbfgs::LBfgs;
23use crate::sqp::line_search::l1_merit_line_search;
24use crate::sqp::options::{SqpGlobalization, SqpHessianSource, SqpOptions};
25use crate::sqp::problem::SqpProblemSpec;
26use crate::sqp::qp_assembly::{SqpQpData, Triplet};
27use crate::sqp::result::{SqpError, SqpResult, SqpStatus};
28use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, Number};
29use pounce_linalg::triplet::GenTMatrix;
30use pounce_qp::{
31    HessianInertia, ParametricActiveSetSolver, QpOptions, QpProblem, QpSolver, QpStatus, WorkingSet,
32};
33
34/// SQP-side algorithm driver.
35pub struct SqpAlgorithm {
36    qp_solver: ParametricActiveSetSolver,
37    qp_opts: QpOptions,
38    opts: SqpOptions,
39    iterates: Option<SqpIterates>,
40    /// Filter for Fletcher-Leyffer globalization; reset at the
41    /// top of each `optimize` call. Unused when
42    /// `opts.globalization = L1Elastic`.
43    filter: SqpFilter,
44}
45
46impl SqpAlgorithm {
47    pub fn new(qp_solver: ParametricActiveSetSolver, opts: SqpOptions) -> Self {
48        Self {
49            qp_solver,
50            qp_opts: QpOptions::default(),
51            opts,
52            iterates: None,
53            filter: SqpFilter::new(),
54        }
55    }
56
57    /// Override the per-call QP-solver options. Defaults are the
58    /// `pounce_qp::QpOptions::default()` (which include the
59    /// `use_schur_updates = false` and `anti_cycling = Expand`
60    /// from Phase 5a.2). Callers can pin tighter tolerances or
61    /// flip `use_schur_updates = true` for warm-started workloads.
62    pub fn with_qp_options(mut self, qp_opts: QpOptions) -> Self {
63        self.qp_opts = qp_opts;
64        self
65    }
66
67    pub fn options(&self) -> &SqpOptions {
68        &self.opts
69    }
70
71    pub fn iterates(&self) -> Option<&SqpIterates> {
72        self.iterates.as_ref()
73    }
74
75    /// Run the SQP loop to convergence (or `max_iter`). Cold-starts
76    /// the iterate from `nlp.x_init()` and an empty working set.
77    pub fn optimize<N: SqpProblemSpec>(&mut self, nlp: &mut N) -> Result<SqpResult, SqpError> {
78        self.optimize_with_warm_start(nlp, None)
79    }
80
81    /// Warm-start variant. `warm = Some(prev)` seeds the iterate
82    /// from `prev.{x, lambda_g, lambda_x, working}` instead of the
83    /// NLP's cold defaults. Dimensions are validated against the
84    /// problem; any mismatch is fatal. The QP solver consumes
85    /// `warm.working` (when present) via `solve_with_working_set`.
86    ///
87    /// `warm = None` is equivalent to [`Self::optimize`].
88    ///
89    /// Implements the §6 design-note warm-start contract: the
90    /// tuple `(x, λ_g, λ_x, 𝒲)`. The Hessian carry-forward
91    /// (damped-BFGS / L-BFGS state) is *not* part of the warm-start
92    /// payload — each `optimize` call rebuilds its own Hessian
93    /// approximation from scratch.
94    pub fn optimize_with_warm_start<N: SqpProblemSpec>(
95        &mut self,
96        nlp: &mut N,
97        warm: Option<SqpIterates>,
98    ) -> Result<SqpResult, SqpError> {
99        let n = nlp.n();
100        let m = nlp.m();
101        let (xl, xu) = nlp.variable_bounds();
102        let (bl_c, bu_c) = nlp.constraint_bounds();
103        if xl.len() != n || xu.len() != n {
104            return Err(SqpError::DimensionMismatch(format!(
105                "variable_bounds length must be n = {n}"
106            )));
107        }
108        if bl_c.len() != m || bu_c.len() != m {
109            return Err(SqpError::DimensionMismatch(format!(
110                "constraint_bounds length must be m = {m}"
111            )));
112        }
113
114        let mut iter = match warm {
115            Some(w) => {
116                if w.x.len() != n {
117                    return Err(SqpError::DimensionMismatch(format!(
118                        "warm.x length {} must equal n = {n}",
119                        w.x.len()
120                    )));
121                }
122                if w.lambda_g.len() != m {
123                    return Err(SqpError::DimensionMismatch(format!(
124                        "warm.lambda_g length {} must equal m = {m}",
125                        w.lambda_g.len()
126                    )));
127                }
128                if w.lambda_x.len() != n {
129                    return Err(SqpError::DimensionMismatch(format!(
130                        "warm.lambda_x length {} must equal n = {n}",
131                        w.lambda_x.len()
132                    )));
133                }
134                if let Some(ws) = w.working.as_ref() {
135                    ws.validate_dims(n, m).map_err(SqpError::QpFailure)?;
136                }
137                w
138            }
139            None => {
140                let mut cold = SqpIterates::cold(n, m);
141                let x_init = nlp.x_init();
142                if x_init.len() != n {
143                    return Err(SqpError::DimensionMismatch(format!(
144                        "x_init length must be n = {n}"
145                    )));
146                }
147                cold.x = x_init;
148                cold
149            }
150        };
151
152        let mut n_qp_solves: u32 = 0;
153        let mut final_stationarity = 0.0;
154        let mut final_constr_viol = 0.0;
155        // l1-merit penalty parameter ν, adapted across iterations
156        // by `l1_merit_line_search`. Initialized from
157        // `SqpOptions::l1_penalty`.
158        let mut nu = self.opts.l1_penalty;
159        // Reset filter state at the top of each optimize call.
160        self.filter = SqpFilter::new();
161        // Cache the most recent f(x) and c(x) so we don't
162        // re-evaluate them after a successful line search (the
163        // LS already computed them at the new iterate).
164        let mut f_cached: Option<Number> = None;
165        let mut c_cached: Option<Vec<Number>> = None;
166
167        // Damped-BFGS state, allocated only if needed. The
168        // matrix is updated at the END of each iteration (after
169        // we have x_new and the next ∇L), then queried at the
170        // TOP of the next iteration to populate the QP Hessian.
171        let mut bfgs: Option<DampedBfgs> =
172            if matches!(self.opts.hessian, SqpHessianSource::DampedBfgs) {
173                Some(DampedBfgs::new(n))
174            } else {
175                None
176            };
177        let mut lbfgs: Option<LBfgs> = if matches!(self.opts.hessian, SqpHessianSource::Lbfgs) {
178            Some(LBfgs::new(n, self.opts.lbfgs_max_history.max(1) as usize))
179        } else {
180            None
181        };
182
183        for outer in 0..self.opts.max_iter {
184            let grad_f = nlp.eval_grad_f(&iter.x);
185            let c_vals = c_cached.take().unwrap_or_else(|| nlp.eval_c(&iter.x));
186            let f_curr = f_cached.take().unwrap_or_else(|| nlp.eval_f(&iter.x));
187            let jac_c = nlp.eval_jac_c(&iter.x);
188            let hess_lag = match self.opts.hessian {
189                SqpHessianSource::Exact => nlp.eval_hess_lag(&iter.x, &iter.lambda_g),
190                SqpHessianSource::DampedBfgs => {
191                    let bfgs = bfgs.as_mut().expect("DampedBfgs state initialized above");
192                    // Update on the *current* (x, ∇L). The
193                    // very first iteration's update is a no-op
194                    // (no previous pair); the matrix stays I.
195                    let grad_lag = compute_grad_lag(&grad_f, &jac_c, &iter.lambda_g, n);
196                    bfgs.update(&iter.x, &grad_lag);
197                    bfgs.as_triplet()
198                }
199                SqpHessianSource::Lbfgs => {
200                    let lb = lbfgs.as_mut().expect("LBfgs state initialized above");
201                    let grad_lag = compute_grad_lag(&grad_f, &jac_c, &iter.lambda_g, n);
202                    lb.update(&iter.x, &grad_lag);
203                    lb.as_triplet()
204                }
205            };
206
207            // KKT check uses the current iterate's evaluations.
208            let kkt = check_kkt(
209                n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
210            );
211            final_stationarity = kkt.stationarity;
212            final_constr_viol = kkt.constr_viol;
213
214            #[cfg(test)]
215            if self.opts.print_level >= 1 {
216                tracing::debug!(target: "pounce::sqp",
217                    "[sqp k={outer:3}] x={:?} f={:.4e} ‖c‖={:.2e} stat={:.2e} ν={:.2e}",
218                    iter.x.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>(),
219                    f_curr,
220                    kkt.constr_viol,
221                    kkt.stationarity,
222                    nu,
223                );
224            }
225
226            if kkt.stationarity <= self.opts.dual_inf_tol
227                && kkt.constr_viol <= self.opts.constr_viol_tol
228            {
229                self.iterates = Some(iter.clone());
230                return Ok(SqpResult {
231                    x: iter.x,
232                    lambda_g: iter.lambda_g,
233                    lambda_x: iter.lambda_x,
234                    obj: f_curr,
235                    status: SqpStatus::Optimal,
236                    n_iter: outer,
237                    n_qp_solves,
238                    final_stationarity,
239                    final_constr_viol,
240                    working_set: iter.working,
241                });
242            }
243
244            let qp_data = SqpQpData::build(
245                &iter.x,
246                &grad_f,
247                &c_vals,
248                &bl_c,
249                &bu_c,
250                &xl,
251                &xu,
252                jac_c,
253                hess_lag,
254                self.hessian_inertia(),
255            );
256            let qp = qp_data.as_qp();
257
258            // Warm-start from the previous QP's working set when
259            // available. Pounce-qp's `solve_with_working_set`
260            // internally computes a feasible primal compatible
261            // with the supplied set (it satisfies every active
262            // row exactly) — necessary because each SQP
263            // linearization shifts the QP's constraint RHS by
264            // `-c(x_k)`, so the previous QP's *primal* doesn't
265            // carry over even when the active set does.
266            let warm_started = iter.working.is_some();
267            let mut sol = if let Some(prev_w) = iter.working.as_ref() {
268                self.qp_solver
269                    .solve_with_working_set(&qp, prev_w, &self.qp_opts)?
270            } else {
271                self.qp_solver.solve(&qp, None, &self.qp_opts)?
272            };
273            n_qp_solves += 1;
274
275            // Cold-start fallback: a warm start seeds the QP with the
276            // previous iterate's working set, which is usually a big
277            // win but can occasionally strand the active-set solver at
278            // its iteration limit (or a numerical breakdown) on a QP
279            // that is perfectly solvable from a clean start — e.g.
280            // when a quasi-Newton Hessian has drifted enough that the
281            // carried-over active set is a poor guess. Rather than
282            // give up with `QpStepFailed`, re-solve once from cold;
283            // this is what rescues the curved-constraint SQP runs of
284            // issue #349 that previously reported
285            // `Search_Direction_Becomes_Too_Small`.
286            if warm_started && matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError) {
287                let cold = self.qp_solver.solve(&qp, None, &self.qp_opts)?;
288                n_qp_solves += 1;
289                if cold.status == QpStatus::Optimal {
290                    sol = cold;
291                }
292            }
293
294            match sol.status {
295                QpStatus::Optimal => {}
296                QpStatus::Infeasible => {
297                    let obj = nlp.eval_f(&iter.x);
298                    self.iterates = Some(iter.clone());
299                    return Ok(SqpResult {
300                        x: iter.x,
301                        lambda_g: iter.lambda_g,
302                        lambda_x: iter.lambda_x,
303                        obj,
304                        status: SqpStatus::InfeasibleSubproblem,
305                        n_iter: outer,
306                        n_qp_solves,
307                        final_stationarity,
308                        final_constr_viol,
309                        working_set: iter.working,
310                    });
311                }
312                // The QP subproblem neither solved nor certified
313                // infeasibility. `MaxIter` / `NumericalError` mean the
314                // active-set QP could not resolve the (typically extremely
315                // degenerate) step subproblem — the m/n ≫ 1 collapsed-cone
316                // geometry of #282. Terminate the SQP with an HONEST
317                // non-committal status rather than a hard error, and — the
318                // point of #282 — WITHOUT ever asserting infeasibility on a
319                // problem we have not certified infeasible.
320                QpStatus::MaxIter | QpStatus::NumericalError => {
321                    let obj = nlp.eval_f(&iter.x);
322                    self.iterates = Some(iter.clone());
323                    return Ok(SqpResult {
324                        x: iter.x,
325                        lambda_g: iter.lambda_g,
326                        lambda_x: iter.lambda_x,
327                        obj,
328                        status: SqpStatus::QpStepFailed,
329                        n_iter: outer,
330                        n_qp_solves,
331                        final_stationarity,
332                        final_constr_viol,
333                        working_set: iter.working,
334                    });
335                }
336                // `Unbounded` on a step QP is a genuine pathology (an
337                // indefinite/negative-curvature ray); keep the historical
338                // hard-error behavior.
339                other => {
340                    return Err(SqpError::QpFailure(
341                        pounce_qp::QpError::LinearSolverFailure(format!(
342                            "QP subproblem returned status {other}"
343                        )),
344                    ));
345                }
346            }
347
348            #[cfg(test)]
349            if self.opts.print_level >= 1 {
350                let p_inf = sol.x.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
351                tracing::debug!(target: "pounce::sqp",
352                    "         qp: ‖p‖_inf={:.3e} ‖λ_g_qp‖_inf={:.3e}",
353                    p_inf,
354                    sol.lambda_g.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
355                );
356            }
357            // Globalization: l1-merit backtracking (Han-Powell)
358            // or filter (Fletcher-Leyffer 2002). The two share
359            // the same backtracking shell + acceptance API; the
360            // filter keeps state across iterations on
361            // `self.filter`.
362            //
363            // Both are handed a second-order-correction (SOC)
364            // provider (the Maratos remedy). When the full step
365            // (α = 1) is rejected because it increased the
366            // constraint violation, the line search calls this
367            // closure with `c(x_k + p)` to obtain a corrected full
368            // step. We build that step by re-solving the SAME QP
369            // with the general-constraint RHS re-centered on the
370            // trial-point constraint values: the original QP models
371            // `c(x_k) + A p`, and the SOC replaces `c(x_k)` by
372            // `c(x_k + p) − A p`, so the correction subproblem
373            // targets the true (curved) violation at the trial
374            // point (Nocedal-Wright §18.11). The just-solved working
375            // set warm-starts the correction. Only meaningful with
376            // general constraints (`m > 0`).
377            //
378            // Pre-computed `A p` for the RHS re-centering:
379            let a_p = if m > 0 {
380                mat_vec_gen(&qp_data.a, &sol.x, m)
381            } else {
382                Vec::new()
383            };
384            let mut n_soc_solves: u32 = 0;
385            // Working set from the SOC subproblem, kept so that a
386            // taken SOC step warm-starts the next iteration from the
387            // active set that actually describes `x + p_soc` (not the
388            // original QP's set, which belongs to the rejected step).
389            let mut soc_working: Option<WorkingSet> = None;
390            let ls = {
391                let qp_solver = &mut self.qp_solver;
392                let qp_opts = &self.qp_opts;
393                let qp_data_ref = &qp_data;
394                let c_curr_ref = &c_vals;
395                let a_p_ref = &a_p;
396                let sol_working = &sol.working;
397                let n_soc = &mut n_soc_solves;
398                let soc_working_slot = &mut soc_working;
399                let mut soc = |c_trial: &[Number]| -> Option<crate::sqp::line_search::SocStep> {
400                    let mm = qp_data_ref.m;
401                    // Re-center the general-constraint RHS on the
402                    // trial-point violation, preserving ±∞ sentinels.
403                    let mut bl_soc = qp_data_ref.bl.clone();
404                    let mut bu_soc = qp_data_ref.bu.clone();
405                    for i in 0..mm {
406                        let delta = c_curr_ref[i] - c_trial[i] + a_p_ref[i];
407                        if qp_data_ref.bl[i] > NLP_LOWER_BOUND_INF {
408                            bl_soc[i] = qp_data_ref.bl[i] + delta;
409                        }
410                        if qp_data_ref.bu[i] < NLP_UPPER_BOUND_INF {
411                            bu_soc[i] = qp_data_ref.bu[i] + delta;
412                        }
413                    }
414                    let qp_soc = QpProblem {
415                        n: qp_data_ref.n,
416                        m: qp_data_ref.m,
417                        h: &qp_data_ref.h,
418                        g: &qp_data_ref.g,
419                        a: &qp_data_ref.a,
420                        bl: &bl_soc,
421                        bu: &bu_soc,
422                        xl: &qp_data_ref.xl,
423                        xu: &qp_data_ref.xu,
424                        hessian_inertia: qp_data_ref.hessian_inertia,
425                    };
426                    let sol_soc = qp_solver
427                        .solve_with_working_set(&qp_soc, sol_working, qp_opts)
428                        .ok()?;
429                    *n_soc += 1;
430                    if sol_soc.status == QpStatus::Optimal {
431                        *soc_working_slot = Some(sol_soc.working);
432                        Some(crate::sqp::line_search::SocStep {
433                            p: sol_soc.x,
434                            lambda_g: sol_soc.lambda_g,
435                            lambda_x: sol_soc.lambda_x,
436                        })
437                    } else {
438                        None
439                    }
440                };
441                let soc_ref: Option<crate::sqp::line_search::SocProvider<'_>> =
442                    if m > 0 { Some(&mut soc) } else { None };
443                match self.opts.globalization {
444                    SqpGlobalization::L1Elastic => l1_merit_line_search(
445                        nlp,
446                        &iter.x,
447                        &sol.x,
448                        &sol.lambda_g,
449                        &grad_f,
450                        f_curr,
451                        &c_vals,
452                        &bl_c,
453                        &bu_c,
454                        &xl,
455                        &xu,
456                        nu,
457                        &self.opts,
458                        soc_ref,
459                    ),
460                    SqpGlobalization::Filter => filter_line_search(
461                        nlp,
462                        &mut self.filter,
463                        &iter.x,
464                        &sol.x,
465                        f_curr,
466                        &c_vals,
467                        &bl_c,
468                        &bu_c,
469                        &xl,
470                        &xu,
471                        nu,
472                        &self.opts,
473                        soc_ref,
474                    ),
475                }
476            };
477            n_qp_solves += n_soc_solves;
478            #[cfg(test)]
479            if self.opts.print_level >= 1 {
480                tracing::debug!(target: "pounce::sqp",
481                    "         ls: α={:.3e} ν={:.3e} ok={} f_new={:.3e}",
482                    ls.alpha, ls.nu, ls.success, ls.f_new
483                );
484            }
485            if !ls.success {
486                self.iterates = Some(iter.clone());
487                return Ok(SqpResult {
488                    x: iter.x,
489                    lambda_g: iter.lambda_g,
490                    lambda_x: iter.lambda_x,
491                    obj: f_curr,
492                    status: SqpStatus::LineSearchFailed,
493                    n_iter: outer,
494                    n_qp_solves,
495                    final_stationarity,
496                    final_constr_viol,
497                    working_set: Some(sol.working),
498                });
499            }
500            iter.x = ls.x_new;
501            match ls.soc_duals {
502                Some((soc_lg, soc_lx)) => {
503                    // A second-order-correction step was taken (α = 1
504                    // on the SOC subproblem). Adopt the SOC
505                    // subproblem's own multipliers and working set so
506                    // `(step, multipliers, active set)` stay a
507                    // consistent triple — required for the quasi-
508                    // Newton Hessian update to stay well-conditioned
509                    // and for the next QP to warm-start correctly.
510                    iter.lambda_g = soc_lg;
511                    iter.lambda_x = soc_lx;
512                    iter.working = soc_working.take().or(Some(sol.working));
513                }
514                None => {
515                    for (l, &lq) in iter.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
516                        *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
517                    }
518                    for (l, &lq) in iter.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
519                        *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
520                    }
521                    iter.working = Some(sol.working);
522                }
523            }
524            nu = ls.nu;
525            f_cached = Some(ls.f_new);
526            c_cached = Some(ls.c_new);
527        }
528
529        let obj = nlp.eval_f(&iter.x);
530        self.iterates = Some(iter.clone());
531        Ok(SqpResult {
532            x: iter.x,
533            lambda_g: iter.lambda_g,
534            lambda_x: iter.lambda_x,
535            obj,
536            status: SqpStatus::MaxIter,
537            n_iter: self.opts.max_iter,
538            n_qp_solves,
539            final_stationarity,
540            final_constr_viol,
541            working_set: iter.working,
542        })
543    }
544
545    fn hessian_inertia(&self) -> HessianInertia {
546        match self.opts.hessian {
547            // Exact ∇²L is indefinite on nonconvex NLPs; let the
548            // QP solver's §4.5 inertia control handle it.
549            crate::sqp::SqpHessianSource::Exact => HessianInertia::Indefinite,
550            // Damped BFGS and L-BFGS are PSD by construction.
551            crate::sqp::SqpHessianSource::DampedBfgs => HessianInertia::Psd,
552            crate::sqp::SqpHessianSource::Lbfgs => HessianInertia::Psd,
553        }
554    }
555}
556
557#[derive(Debug, Clone, Copy)]
558struct KktError {
559    pub stationarity: Number,
560    pub constr_viol: Number,
561}
562
563/// Sparse `A · p` for an `m × n` general-constraint Jacobian stored
564/// as a `GenTMatrix` (1-based triplet indices). Used to re-center
565/// the second-order-correction QP's RHS on the trial point.
566fn mat_vec_gen(a: &GenTMatrix, p: &[Number], m: usize) -> Vec<Number> {
567    let mut out = vec![0.0; m];
568    let irows = a.irows();
569    let jcols = a.jcols();
570    let vals = a.values();
571    for k in 0..vals.len() {
572        let i = (irows[k] - 1) as usize;
573        let j = (jcols[k] - 1) as usize;
574        out[i] += vals[k] * p[j];
575    }
576    out
577}
578
579/// Lagrangian gradient `∇L(x, λ_g) = ∇f(x) + J_c(x)ᵀ λ_g` at the
580/// current iterate. Used by the damped-BFGS update.
581fn compute_grad_lag(
582    grad_f: &[Number],
583    jac_c: &Triplet,
584    lambda_g: &[Number],
585    n: usize,
586) -> Vec<Number> {
587    let mut out = grad_f.to_vec();
588    debug_assert_eq!(out.len(), n);
589    for k in 0..jac_c.irow.len() {
590        let row_i = (jac_c.irow[k] - 1) as usize;
591        let col_j = (jac_c.jcol[k] - 1) as usize;
592        out[col_j] += jac_c.vals[k] * lambda_g[row_i];
593    }
594    out
595}
596
597fn check_kkt(
598    n: usize,
599    m: usize,
600    iter: &SqpIterates,
601    grad_f: &[Number],
602    c_vals: &[Number],
603    bl_c: &[Number],
604    bu_c: &[Number],
605    xl: &[Number],
606    xu: &[Number],
607    jac_c: &crate::sqp::qp_assembly::Triplet,
608) -> KktError {
609    // Constraint violation: max(0, bl - c, c - bu) on every row,
610    // plus bound violation on every variable.
611    let mut viol = 0.0_f64;
612    for i in 0..m {
613        let lo = if bl_c[i] > NLP_LOWER_BOUND_INF {
614            (bl_c[i] - c_vals[i]).max(0.0)
615        } else {
616            0.0
617        };
618        let hi = if bu_c[i] < NLP_UPPER_BOUND_INF {
619            (c_vals[i] - bu_c[i]).max(0.0)
620        } else {
621            0.0
622        };
623        viol = viol.max(lo).max(hi);
624    }
625    for i in 0..n {
626        let lo = if xl[i] > NLP_LOWER_BOUND_INF {
627            (xl[i] - iter.x[i]).max(0.0)
628        } else {
629            0.0
630        };
631        let hi = if xu[i] < NLP_UPPER_BOUND_INF {
632            (iter.x[i] - xu[i]).max(0.0)
633        } else {
634            0.0
635        };
636        viol = viol.max(lo).max(hi);
637    }
638
639    // Stationarity: ∇f + Jᵀ λ_g − λ_x. pounce-qp's KKT is
640    // `Hx + Aᵀλ_qp + (lower-bound multiplier) e_i − (upper-bound
641    // multiplier) e_i = -g`. Since `λ_x = z_l − z_u` packs the
642    // bound-multiplier sign, the variable-bound term enters the
643    // stationarity check with a negative sign — i.e. at the
644    // optimum `∇f + Jᵀ λ_g = λ_x`.
645    let mut stat = vec![0.0; n];
646    for (s, &g) in stat.iter_mut().zip(grad_f.iter()) {
647        *s = g;
648    }
649    // Add Jᵀ λ_g
650    for k in 0..jac_c.irow.len() {
651        let i = (jac_c.irow[k] - 1) as usize; // 0-based row in c
652        let j = (jac_c.jcol[k] - 1) as usize; // 0-based col in x
653        stat[j] += jac_c.vals[k] * iter.lambda_g[i];
654    }
655    // Subtract λ_x
656    for (s, &lx) in stat.iter_mut().zip(iter.lambda_x.iter()) {
657        *s -= lx;
658    }
659    let stat_max = stat.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
660
661    KktError {
662        stationarity: stat_max,
663        constr_viol: viol,
664    }
665}