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