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