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}