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