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 BoundStatus, ConsStatus, HessianInertia, ParametricActiveSetSolver, QpOptions, QpSolver,
32 QpStatus, WorkingSet,
33};
34
35/// Header for the `sqp_print_level >= 1` iteration table.
36///
37/// Column widths for `iter` / `objective` / `inf_pr` / `inf_du` are the
38/// interior-point table's exactly (`crate::output::orig`), so the two arms'
39/// logs line up when a reader puts them side by side on one model. The
40/// remaining columns are the ones this arm actually has: an active-set SQP
41/// has no barrier parameter and no dual step length, so `lg(mu)`, `lg(rg)`
42/// and `alpha_du` would be permanently blank, and it does have a working-set
43/// size that the IPM does not.
44const SQP_ITER_HEADER: &str = "iter objective inf_pr inf_du ||p|| alpha ws";
45
46/// What the step into the current iterate cost — the trailing columns of a
47/// row. `None` on row 0, where no step has been taken yet.
48struct SqpStepRecord {
49 /// `‖p‖_inf` of the QP step accepted into this iterate.
50 p_inf: Number,
51 /// Line-search step length.
52 alpha: Number,
53 /// Whether the accepted step was a second-order correction. Rendered as
54 /// a trailing `c`, the same position Ipopt puts its `alpha_char` in.
55 soc: bool,
56 /// Active-set changes (adds + drops) in the QP that produced the step.
57 ws_changes: u32,
58}
59
60/// One row of the `sqp_print_level >= 1` table.
61///
62/// Row `k` reports the residuals **at** `x_k`; the step columns describe the
63/// step that arrived there, so they are blank on row 0. That is Ipopt's
64/// convention and `crate::output::orig::OrigIterationOutput::format_row`'s.
65fn format_sqp_row(
66 iter: u32,
67 obj: Number,
68 inf_pr: Number,
69 inf_du: Number,
70 step: Option<&SqpStepRecord>,
71) -> String {
72 use crate::output::orig::format_e;
73 match step {
74 Some(st) => format!(
75 "{:>4} {:>14} {:>8} {:>8} {:>8} {:>8}{} {:>5}",
76 iter,
77 format_e(obj, 7),
78 format_e(inf_pr, 2),
79 format_e(inf_du, 2),
80 format_e(st.p_inf, 2),
81 format_e(st.alpha, 2),
82 if st.soc { 'c' } else { ' ' },
83 st.ws_changes,
84 ),
85 None => format!(
86 "{:>4} {:>14} {:>8} {:>8} {:>8} {:>8} {:>5}",
87 iter,
88 format_e(obj, 7),
89 format_e(inf_pr, 2),
90 format_e(inf_du, 2),
91 "-",
92 "-",
93 "-",
94 ),
95 }
96}
97
98/// What the per-iteration hook sees: the iterate `x_k` and the residuals
99/// measured **at** it (the same numbers the `sqp_print_level >= 1` row
100/// prints), plus the step that arrived there.
101pub struct SqpIterationInfo<'a> {
102 pub iter: u32,
103 pub obj: Number,
104 pub inf_pr: Number,
105 pub inf_du: Number,
106 /// `‖p‖_inf` of the accepted step into this iterate (0 on iteration 0).
107 pub p_inf: Number,
108 /// Line-search step length of that step (0 on iteration 0).
109 pub alpha: Number,
110 pub x: &'a [Number],
111 /// `[λ_c; λ_d]` in algorithm space.
112 pub lambda_g: &'a [Number],
113 /// Signed bound multiplier `z_L - z_U` per variable.
114 pub lambda_x: &'a [Number],
115 /// `[c(x); d(x)]` in algorithm space.
116 pub c_vals: &'a [Number],
117 /// `∇f + Jᵀλ_g − λ_x` (the stationarity residual `check_kkt` measures)
118 /// in algorithm space, from this iterate's `∇f` and `J`.
119 pub grad_lag_x: &'a [Number],
120}
121
122/// Per-iteration hook. Returning `false` stops the solve.
123pub type SqpIterationHook = Box<dyn FnMut(&SqpIterationInfo<'_>) -> bool>;
124
125/// SQP-side algorithm driver.
126pub struct SqpAlgorithm {
127 iteration_hook: Option<SqpIterationHook>,
128 qp_solver: ParametricActiveSetSolver,
129 qp_opts: QpOptions,
130 opts: SqpOptions,
131 /// `opts.hessian` resolved for the current solve's `n` (see
132 /// [`SqpHessianSource::resolve`]); set at the top of every solve.
133 hessian: SqpHessianSource,
134 iterates: Option<SqpIterates>,
135 /// Filter for Fletcher-Leyffer globalization; reset at the
136 /// top of each `optimize` call. Unused when
137 /// `opts.globalization = L1Elastic`.
138 filter: SqpFilter,
139 /// Whether this solve's cold step QPs are seeded by the convex interior
140 /// point; set per solve from `opts.qp_ipm_cold_start_min_dim`.
141 ipm_cold_start: bool,
142 /// The last interior-point seed's iteration count, which sets a warm
143 /// step QP's budget (see [`solve_subproblem_qp`]).
144 ipm_seed: IpmSeed,
145}
146
147/// State the IPM-seeded subproblem solves share across one SQP solve.
148#[derive(Default)]
149pub(crate) struct IpmSeed {
150 /// Interior-point iterations of the most recent successful seed.
151 pub(crate) last_iters: std::cell::Cell<Option<usize>>,
152}
153
154impl SqpAlgorithm {
155 pub fn new(qp_solver: ParametricActiveSetSolver, opts: SqpOptions) -> Self {
156 Self {
157 qp_solver,
158 qp_opts: QpOptions::sqp_subproblem(),
159 hessian: opts.hessian,
160 opts,
161 iterates: None,
162 filter: SqpFilter::new(),
163 iteration_hook: None,
164 ipm_cold_start: false,
165 ipm_seed: IpmSeed::default(),
166 }
167 }
168
169 /// Install a hook fired once per iteration, at the iterate the loop is
170 /// about to test for convergence (iteration 0 and the final iterate
171 /// included, as on the interior-point arm). Returning `false` ends the
172 /// solve with [`SqpStatus::UserRequestedStop`].
173 pub fn set_iteration_hook(&mut self, hook: SqpIterationHook) {
174 self.iteration_hook = Some(hook);
175 }
176
177 /// Override the per-call QP-solver options. Defaults are
178 /// `pounce_qp::QpOptions::sqp_subproblem()` — `QpOptions::default()`
179 /// with second-order certification off, for the reason given there
180 /// (which include the
181 /// `use_schur_updates = false` and `anti_cycling = Expand`
182 /// from Phase 5a.2). Callers can pin tighter tolerances or
183 /// flip `use_schur_updates = true` for warm-started workloads.
184 pub fn with_qp_options(mut self, qp_opts: QpOptions) -> Self {
185 self.qp_opts = qp_opts;
186 self
187 }
188
189 pub fn options(&self) -> &SqpOptions {
190 &self.opts
191 }
192
193 pub fn iterates(&self) -> Option<&SqpIterates> {
194 self.iterates.as_ref()
195 }
196
197 /// Run the SQP loop to convergence (or `max_iter`). Cold-starts
198 /// the iterate from `nlp.x_init()` and an empty working set.
199 pub fn optimize<N: SqpProblemSpec>(&mut self, nlp: &mut N) -> Result<SqpResult, SqpError> {
200 self.optimize_with_warm_start(nlp, None)
201 }
202
203 /// Warm-start variant. `warm = Some(prev)` seeds the iterate
204 /// from `prev.{x, lambda_g, lambda_x, working}` instead of the
205 /// NLP's cold defaults. Dimensions are validated against the
206 /// problem; any mismatch is fatal. The QP solver consumes
207 /// `warm.working` (when present) via `solve_with_working_set`.
208 ///
209 /// `warm = None` is equivalent to [`Self::optimize`].
210 ///
211 /// Implements the §6 design-note warm-start contract: the
212 /// tuple `(x, λ_g, λ_x, 𝒲)`. The Hessian carry-forward
213 /// (damped-BFGS / L-BFGS state) is *not* part of the warm-start
214 /// payload — each `optimize` call rebuilds its own Hessian
215 /// approximation from scratch.
216 pub fn optimize_with_warm_start<N: SqpProblemSpec>(
217 &mut self,
218 nlp: &mut N,
219 warm: Option<SqpIterates>,
220 ) -> Result<SqpResult, SqpError> {
221 // The step-QP iteration budget scales with the problem unless the
222 // caller set one (gh#1054). Restored afterwards, so a reused driver
223 // re-derives it for the next problem's size.
224 // Schur updates likewise follow the size when the caller left them
225 // to the driver (`SqpOptions::step_qp_schur_updates`). This is the
226 // cold rule; a warm-started step QP may override it (`warm_qp_opts`).
227 let saved = (self.qp_opts.max_iter, self.qp_opts.use_schur_updates);
228 self.qp_opts.max_iter = step_qp_iter_budget(saved.0, nlp.n(), nlp.m());
229 if let Some(on) = self.opts.step_qp_schur_updates(nlp.n() + nlp.m(), None) {
230 self.qp_opts.use_schur_updates = on;
231 }
232 self.ipm_cold_start = self
233 .opts
234 .qp_ipm_cold_start_min_dim
235 .is_some_and(|d| nlp.n() + nlp.m() >= d);
236 self.ipm_seed.last_iters.set(None);
237 let res = self.optimize_inner(nlp, warm);
238 (self.qp_opts.max_iter, self.qp_opts.use_schur_updates) = saved;
239 res
240 }
241
242 fn optimize_inner<N: SqpProblemSpec>(
243 &mut self,
244 nlp: &mut N,
245 warm: Option<SqpIterates>,
246 ) -> Result<SqpResult, SqpError> {
247 let n = nlp.n();
248 let m = nlp.m();
249 let (xl, xu) = nlp.variable_bounds();
250 let (bl_c, bu_c) = nlp.constraint_bounds();
251 if xl.len() != n || xu.len() != n {
252 return Err(SqpError::DimensionMismatch(format!(
253 "variable_bounds length must be n = {n}"
254 )));
255 }
256 if bl_c.len() != m || bu_c.len() != m {
257 return Err(SqpError::DimensionMismatch(format!(
258 "constraint_bounds length must be m = {m}"
259 )));
260 }
261
262 let mut iter = match warm {
263 Some(w) => {
264 if w.x.len() != n {
265 return Err(SqpError::DimensionMismatch(format!(
266 "warm.x length {} must equal n = {n}",
267 w.x.len()
268 )));
269 }
270 if w.lambda_g.len() != m {
271 return Err(SqpError::DimensionMismatch(format!(
272 "warm.lambda_g length {} must equal m = {m}",
273 w.lambda_g.len()
274 )));
275 }
276 if w.lambda_x.len() != n {
277 return Err(SqpError::DimensionMismatch(format!(
278 "warm.lambda_x length {} must equal n = {n}",
279 w.lambda_x.len()
280 )));
281 }
282 if let Some(ws) = w.working.as_ref() {
283 ws.validate_dims(n, m).map_err(SqpError::QpFailure)?;
284 }
285 w
286 }
287 None => {
288 let mut cold = SqpIterates::cold(n, m);
289 let x_init = nlp.x_init();
290 if x_init.len() != n {
291 return Err(SqpError::DimensionMismatch(format!(
292 "x_init length must be n = {n}"
293 )));
294 }
295 cold.x = x_init;
296 cold
297 }
298 };
299
300 let mut n_qp_solves: u32 = 0;
301 // Inner active-set work: adds + drops summed over every step QP
302 // solved in this call. This — not the outer iteration count — is
303 // what a warm start is trying to reduce, and on a QP-shaped NLP
304 // (one outer iteration by construction) it is the *only* thing
305 // that moves. Second-order-correction QPs are not counted: they
306 // are solved inside the line search, which does not surface its
307 // subproblem stats.
308 let mut n_qp_working_set_changes: u32 = 0;
309 let mut final_stationarity = 0.0;
310 let mut final_constr_viol = 0.0;
311 // l1-merit penalty parameter ν, adapted across iterations
312 // by `l1_merit_line_search`. Initialized from
313 // `SqpOptions::l1_penalty`.
314 let mut nu = self.opts.l1_penalty;
315 // Trailing columns of the `sqp_print_level >= 1` row: what the step
316 // into the CURRENT iterate cost. Written at the end of each
317 // iteration, read at the top of the next, `None` on row 0.
318 let mut prev_step: Option<SqpStepRecord> = None;
319 // Reset filter state at the top of each optimize call.
320 self.filter = SqpFilter::new();
321 // Cache the most recent f(x) and c(x) so we don't
322 // re-evaluate them after a successful line search (the
323 // LS already computed them at the new iterate).
324 let mut f_cached: Option<Number> = None;
325 let mut c_cached: Option<Vec<Number>> = None;
326 // Previous iterate's `(x, ∇f, ∇c)`, kept so the quasi-Newton
327 // curvature pair can difference `∇L` at a single fixed multiplier
328 // (see [`curvature_pair`]). Storing `∇L` directly — as the older
329 // `DampedBfgs::update(x, ∇L)` form did — bakes in the multiplier
330 // that was current at the time, which is precisely the bug.
331 let mut prev_point: Option<(Vec<Number>, Vec<Number>, Triplet)> = None;
332
333 // Damped-BFGS state, allocated only if needed. The
334 // matrix is updated at the END of each iteration (after
335 // we have x_new and the next ∇L), then queried at the
336 // TOP of the next iteration to populate the QP Hessian.
337 self.hessian = self.opts.hessian.resolve(n, self.opts.dense_hessian_max_n);
338 // L-BFGS above the dense ceiling goes to the QP in product form,
339 // lifted (gh#1054); below it, as the dense matrix it always was.
340 let lift_lbfgs =
341 self.hessian == SqpHessianSource::Lbfgs && n > self.opts.dense_hessian_max_n;
342 let mut bfgs: Option<DampedBfgs> = if matches!(self.hessian, SqpHessianSource::DampedBfgs) {
343 Some(DampedBfgs::new(n))
344 } else {
345 None
346 };
347 let mut lbfgs: Option<LBfgs> = if matches!(self.hessian, SqpHessianSource::Lbfgs) {
348 Some(LBfgs::new(n, self.opts.lbfgs_max_history.max(1) as usize))
349 } else {
350 None
351 };
352
353 // Iteration-0 curvature probe (issue #358 tail).
354 //
355 // `DampedBfgs::update` sizes the identity seed from the first
356 // `(s, y)` pair — but that pair only exists at iteration 1, and
357 // iteration **0** already solves a QP against `B`. With `B = I`
358 // on a problem where `‖∇²L‖ ≫ 1`, that first step overshoots the
359 // Newton step by `~cond(∇²L)`; the filter (empty, and `θ` tiny at
360 // a near-feasible start) accepts the objective-blowing step, the
361 // iterate is flung to `‖x‖ ~ 1e3`, and the huge `(s, y)` pairs
362 // that follow drive `B` so ill-conditioned that the QP subproblem
363 // itself fails a few iterations later (`QpStepFailed`, surfacing
364 // as `Search_Direction_Becomes_Too_Small`).
365 //
366 // Fix the scale *before* that first QP with one extra gradient
367 // evaluation: step a short distance along the steepest-descent
368 // direction, difference the gradients, and seed `B = γI` with the
369 // resulting Rayleigh quotient `γ = sᵀy / sᵀs`. For a quadratic
370 // this is exactly the curvature along the probe direction, and it
371 // lies in `[λ_min(∇²L), λ_max(∇²L)]`.
372 //
373 // The probe differences the *objective* gradient, so it estimates
374 // `∇²f` — which equals the Lagrangian Hessian `∇²L = ∇²f + Σλᵢ∇²cᵢ`
375 // only when the constraint-curvature term vanishes, i.e. when every
376 // constraint is linear (or there are none). That condition is
377 // exactly the #358 family, and it is *not* cosmetic: on the Maratos
378 // problem (`min 2(x₁²+x₂²−1) − x₁ s.t. x₁²+x₂²=1`) `∇²f = 4I` while
379 // `∇²L ≈ I` at the solution, so seeding the objective curvature
380 // would over-scale `B` fourfold and cost that solve its convergence.
381 //
382 // Detect linearity directly rather than trusting a declaration:
383 // compare the constraint Jacobian at the probe point with the one
384 // at `x`. Identical ⇒ `∇c` is constant ⇒ constraints are linear ⇒
385 // the objective Hessian *is* the Lagrangian Hessian and the probe
386 // is exact. Otherwise leave the identity seed alone and let the
387 // rank-2 updates (which see the true `∇L`) do the work.
388 if bfgs.is_some() || lbfgs.is_some() {
389 let g0 = nlp.eval_grad_f(&iter.x);
390 let g_norm = g0.iter().map(|v| v * v).sum::<Number>().sqrt();
391 if g_norm.is_finite() && g_norm > 0.0 {
392 // Absolute probe length, scaled by the iterate so the step
393 // is meaningful in the problem's own units but always tiny
394 // relative to it. `1e-7` keeps the gradient difference well
395 // above f64 roundoff without leaving the local model.
396 let x_scale = iter.x.iter().map(|v| v.abs()).fold(1.0, f64::max);
397 let eps = 1e-7 * x_scale;
398 let step: Vec<Number> = g0.iter().map(|gi| -eps * gi / g_norm).collect();
399 let x_probe: Vec<Number> =
400 iter.x.iter().zip(step.iter()).map(|(a, d)| a + d).collect();
401 // Constant-Jacobian (linear-constraint) check, per above.
402 let linear_constraints = m == 0 || {
403 let j0 = nlp.eval_jac_c(&iter.x);
404 let j1 = nlp.eval_jac_c(&x_probe);
405 j0.vals.len() == j1.vals.len()
406 && j0.vals.iter().zip(j1.vals.iter()).all(|(a, c)| {
407 // Relative comparison: a linear constraint
408 // reproduces its Jacobian bit-for-bit, so this
409 // only tolerates evaluation noise.
410 let scale = a.abs().max(c.abs()).max(1.0);
411 (a - c).abs() <= 1e-12 * scale
412 })
413 };
414 if linear_constraints {
415 let g1 = nlp.eval_grad_f(&x_probe);
416 let s_y: Number = step
417 .iter()
418 .zip(g1.iter().zip(g0.iter()))
419 .map(|(si, (a, bg))| si * (a - bg))
420 .sum();
421 let s_s: Number = step.iter().map(|v| v * v).sum();
422 if s_s > 0.0 && s_y.is_finite() {
423 // A non-positive quotient means the probe direction
424 // has non-positive curvature (nonconvex or
425 // numerically flat); `seed_scale` ignores it, leaving
426 // the identity seed rather than a meaningless or
427 // negative scale.
428 // L-BFGS takes the same seed (gh#1054): it used to
429 // start from `B = I` with no probe at all, which is
430 // the #358 overshoot this block exists to prevent.
431 if let Some(b) = bfgs.as_mut() {
432 b.seed_scale(s_y / s_s);
433 }
434 if let Some(b) = lbfgs.as_mut() {
435 b.seed_scale(s_y / s_s);
436 }
437 }
438 }
439 }
440 }
441
442 // Bounded so a curvature escape that keeps returning to the same
443 // neighbourhood cannot spin: each one costs a fresh descent, and a
444 // handful is far more than any of the measured models needs (one).
445 const MAX_SECOND_ORDER_ESCAPES: u32 = 8;
446 let mut escapes: u32 = 0;
447
448 for outer in 0..self.opts.max_iter {
449 let grad_f = nlp.eval_grad_f(&iter.x);
450 let c_vals = c_cached.take().unwrap_or_else(|| nlp.eval_c(&iter.x));
451 let f_curr = f_cached.take().unwrap_or_else(|| nlp.eval_f(&iter.x));
452 let jac_c = nlp.eval_jac_c(&iter.x);
453 // KKT check uses the current iterate's evaluations.
454 let kkt = check_kkt(
455 n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
456 );
457 final_stationarity = kkt.stationarity;
458 final_constr_viol = kkt.constr_viol;
459
460 // Fired BEFORE this iteration's Lagrangian Hessian is evaluated, as
461 // on the interior-point arm (callback after acceptance, before the
462 // next Hessian): a caller that builds `eval_h` from the iterate it
463 // read in the callback (gh#1015) must see THIS iterate, not the
464 // previous one.
465 if let Some(hook) = self.iteration_hook.as_mut() {
466 let (p_inf, alpha) = prev_step
467 .as_ref()
468 .map_or((0.0, 0.0), |s| (s.p_inf, s.alpha));
469 // `check_kkt`'s stationarity vector, from the same `∇f`, `J`.
470 let mut grad_lag_x = grad_f.clone();
471 for k in 0..jac_c.irow.len() {
472 let (i, j) = ((jac_c.irow[k] - 1) as usize, (jac_c.jcol[k] - 1) as usize);
473 if let (Some(g), Some(&l)) = (grad_lag_x.get_mut(j), iter.lambda_g.get(i)) {
474 *g += jac_c.vals[k] * l;
475 }
476 }
477 for (g, &lx) in grad_lag_x.iter_mut().zip(iter.lambda_x.iter()) {
478 *g -= lx;
479 }
480 let keep_going = hook(&SqpIterationInfo {
481 iter: outer,
482 obj: f_curr,
483 inf_pr: kkt.constr_viol,
484 inf_du: kkt.stationarity,
485 p_inf,
486 alpha,
487 x: &iter.x,
488 lambda_g: &iter.lambda_g,
489 lambda_x: &iter.lambda_x,
490 c_vals: &c_vals,
491 grad_lag_x: &grad_lag_x,
492 });
493 if !keep_going {
494 self.iterates = Some(iter.clone());
495 return Ok(SqpResult {
496 x: iter.x,
497 lambda_g: iter.lambda_g,
498 lambda_x: iter.lambda_x,
499 obj: f_curr,
500 status: SqpStatus::UserRequestedStop,
501 n_iter: outer,
502 n_qp_solves,
503 n_qp_working_set_changes,
504 final_stationarity,
505 final_constr_viol,
506 working_set: iter.working,
507 });
508 }
509 }
510
511 let mut low_rank = None;
512 let hess_lag = match self.hessian {
513 SqpHessianSource::Exact => nlp.eval_hess_lag(&iter.x, &iter.lambda_g),
514 SqpHessianSource::DampedBfgs => {
515 let bfgs = bfgs.as_mut().expect("DampedBfgs state initialized above");
516 if let Some((s, y)) =
517 curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
518 {
519 bfgs.update_sy(&s, &y);
520 }
521 bfgs.as_triplet()
522 }
523 SqpHessianSource::Lbfgs => {
524 let lb = lbfgs.as_mut().expect("LBfgs state initialized above");
525 if let Some((s, y)) =
526 curvature_pair(prev_point.as_ref(), &iter, &grad_f, &jac_c, n)
527 {
528 lb.update_sy(&s, &y);
529 }
530 if lift_lbfgs {
531 low_rank = Some(lb.low_rank());
532 // Never read: the step comes from `low_rank`, and the
533 // only other consumer (the second-order check at a
534 // KKT point) takes the exact Hessian on this source.
535 Triplet {
536 n_rows: n,
537 n_cols: n,
538 irow: Vec::new(),
539 jcol: Vec::new(),
540 vals: Vec::new(),
541 }
542 } else {
543 lb.as_triplet()
544 }
545 }
546 SqpHessianSource::QuasiNewtonAuto => {
547 unreachable!("resolved at the top of the solve")
548 }
549 };
550
551 // Remember this iterate's `(x, ∇f, ∇c)` so the next
552 // iteration can build its curvature pair at a fixed
553 // multiplier. See `curvature_pair`.
554 prev_point = Some((iter.x.clone(), grad_f.clone(), jac_c.clone()));
555
556 // Non-finite residuals mean the iterate itself is garbage, and
557 // every gate below this point — the convergence test, the
558 // gh #856 negative-curvature escape installed on it, the filter,
559 // the merit function — is a comparison against a tolerance that
560 // a `NaN` cannot inform. Stop here and say so, mirroring the
561 // interior-point arm's `if !nlp_err.is_finite()` screen
562 // (`ipopt_alg.rs`) so the two arms give the same verdict on the
563 // same condition (gh #876).
564 //
565 // The objective is screened too. Without it an iterate whose
566 // objective has overflowed to `-inf`, with residuals still finite,
567 // went on to a line search whose trial point evaluated to `NaN`
568 // and was reported as a step-size stall
569 // (`Search_Direction_Becomes_Too_Small`) — on `unbounded_cubic`'s
570 // limited-memory leg, once the step QPs stopped stalling at their
571 // iteration cap (gh#1061) and the SQP followed the cubic down.
572 if !kkt.stationarity.is_finite() || !kkt.constr_viol.is_finite() || !f_curr.is_finite()
573 {
574 let obj = nlp.eval_f(&iter.x);
575 self.iterates = Some(iter.clone());
576 return Ok(SqpResult {
577 x: iter.x,
578 lambda_g: iter.lambda_g,
579 lambda_x: iter.lambda_x,
580 obj,
581 status: SqpStatus::InvalidNumber,
582 n_iter: outer,
583 n_qp_solves,
584 n_qp_working_set_changes,
585 final_stationarity,
586 final_constr_viol,
587 working_set: iter.working,
588 });
589 }
590
591 // Per-iteration summary row (`sqp_print_level >= 1`). Emitted at
592 // the TOP of the iteration, on Ipopt's convention: row `k`
593 // reports the residuals AT `x_k`, and its step columns describe
594 // the step that arrived there — so they are blank on row 0. The
595 // interior-point arm's table reads the same way
596 // (`crate::output::orig::OrigIterationOutput::format_row`), which
597 // is the whole point: a reader should not have to learn a second
598 // convention to compare the two arms on one model.
599 //
600 // Written with `println!` rather than through a journalist because
601 // `SqpAlgorithm` has no handle on one — the same reason
602 // `pounce_solve_report::console` prints the problem statistics and
603 // the end-of-run summary that bracket this table directly.
604 //
605 // This block used to be `#[cfg(test)]` around a `tracing::debug!`,
606 // which meant the option was registered, documented in
607 // `docs/src/active-set-sqp.md` as "1=per-iter summary", threaded
608 // all the way to `opts.print_level` — and produced nothing
609 // whatsoever in any binary a user can run, at any level, with or
610 // without `RUST_LOG`. Same shape as gh#677
611 // (`limited_memory_initialization` registered and never read) and
612 // the `sqp_qp_use_homotopy` no-op: a switch nothing reads is worse
613 // than no switch, because its documentation describes behaviour
614 // that does not exist.
615 if self.opts.print_level >= 1 {
616 if outer % 10 == 0 {
617 println!("{SQP_ITER_HEADER}");
618 }
619 println!(
620 "{}",
621 format_sqp_row(
622 outer,
623 f_curr,
624 kkt.constr_viol,
625 kkt.stationarity,
626 prev_step.as_ref(),
627 )
628 );
629 }
630 if self.opts.print_level >= 2 {
631 println!(
632 " x={:?} ν={:.2e}",
633 iter.x.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>(),
634 nu,
635 );
636 }
637
638 // `sqp_tol` and `sqp_dual_inf_tol` are both registered and both
639 // documented as a max-norm tolerance on the stationarity
640 // residual, but only `dual_inf_tol` was ever read — `opts.tol`
641 // (default 1e-8) was dead, so the loose 1e-4 governed alone and
642 // silently capped attainable accuracy (max x-error `7e-5` on the
643 // #358 sweep). Honor both by requiring the tighter, which is the
644 // only reading under which neither option is a no-op. Restores
645 // `~5e-9` worst-case accuracy for ~10% more iterations. Same
646 // registered-but-inert defect family as gh #360.
647 let stationarity_tol = self.opts.tol.min(self.opts.dual_inf_tol);
648 if kkt.stationarity <= stationarity_tol && kkt.constr_viol <= self.opts.constr_viol_tol
649 {
650 // First-order KKT is necessary and not sufficient. Before
651 // reporting success on an indefinite Lagrangian, look for a
652 // feasible direction of negative curvature *at the converged
653 // multipliers* and, where one is found, step along it and keep
654 // going (gh #856).
655 //
656 // `nonconvex_qp.nl` under `algorithm=active-set-sqp` is the
657 // case: `min x₀x₁ s.t. x₀+x₁ = 2, 0 ≤ x ≤ 4`, on whose
658 // feasible segment `f(x₀) = x₀(2−x₀)` is concave, so the
659 // `(1, 1)` this converges to at `f = 1` is the constrained
660 // **maximum** and the minimum is `0` at either endpoint. It was
661 // reported `Solve_Succeeded`. The escape below moves to
662 // `(2, 0)`, where a bound joins the active set, the null space
663 // closes and `f = 0` certifies.
664 //
665 // Refuted **by exhibition**, exactly as gh #848 does one layer
666 // down: the direction is only acted on after stepping along it
667 // and finding the true objective lower, so the curvature search
668 // is free to be approximate and a direction it gets wrong
669 // costs an evaluation rather than a wrong answer.
670 //
671 // The Hessian is the **exact** `∇²L`, taken here even when the
672 // steps were driven by a quasi-Newton one. That is not an
673 // optimization detail, it is what makes the check exist at all
674 // under `limited-memory`: a damped-BFGS or L-BFGS matrix is
675 // positive definite by construction, so searching it for
676 // negative curvature can only ever find none, and gating the
677 // check on `SqpHessianSource::Exact` left the L-BFGS leg
678 // certifying the same constrained maximum. `eval_hess_lag` is
679 // a required method of `SqpProblemSpec`, so it is always
680 // callable; this costs one Hessian evaluation per converged
681 // solve, and an implementation that has none to give returns
682 // an empty triplet, which finds no curvature and changes
683 // nothing.
684 //
685 // The L-BFGS leg is not exotic coverage: the Python frontend
686 // and the CasADi plugin both select `limited-memory` on their
687 // own whenever no exact Lagrangian Hessian is available.
688 let exact_hess = if matches!(self.hessian, SqpHessianSource::Exact) {
689 None
690 } else {
691 Some(nlp.eval_hess_lag(&iter.x, &iter.lambda_g))
692 };
693 if escapes < MAX_SECOND_ORDER_ESCAPES
694 && let Some(d) = negative_curvature_at_kkt_point(
695 n,
696 m,
697 &iter.x,
698 exact_hess.as_ref().unwrap_or(&hess_lag),
699 &jac_c,
700 &c_vals,
701 &bl_c,
702 &bu_c,
703 &xl,
704 &xu,
705 self.opts.constr_viol_tol,
706 )
707 && let Some(next) = exhibit_better_point(
708 nlp,
709 &iter.x,
710 &d,
711 f_curr,
712 &xl,
713 &xu,
714 &bl_c,
715 &bu_c,
716 self.opts.constr_viol_tol,
717 )
718 {
719 tracing::debug!(target: "pounce::sqp",
720 "first-order KKT point refuted at second order; stepping \
721 along negative curvature to a strictly better feasible \
722 point (gh #856)");
723 escapes += 1;
724 iter.x = next;
725 iter.working = None;
726 f_cached = None;
727 c_cached = None;
728 prev_point = None;
729 continue;
730 }
731 self.iterates = Some(iter.clone());
732 return Ok(SqpResult {
733 x: iter.x,
734 lambda_g: iter.lambda_g,
735 lambda_x: iter.lambda_x,
736 obj: f_curr,
737 status: SqpStatus::Optimal,
738 n_iter: outer,
739 n_qp_solves,
740 n_qp_working_set_changes,
741 final_stationarity,
742 final_constr_viol,
743 working_set: iter.working,
744 });
745 }
746
747 // Time budget (gh#1054). Tested after the convergence check, so
748 // an iterate that is already optimal is reported as such however
749 // late it arrived, and before the step QP, which is where an
750 // active-set solve at scale spends its time.
751 if let Some(kind) = self.opts.deadline.as_ref().and_then(|d| d.exceeded()) {
752 self.iterates = Some(iter.clone());
753 return Ok(SqpResult {
754 x: iter.x,
755 lambda_g: iter.lambda_g,
756 lambda_x: iter.lambda_x,
757 obj: f_curr,
758 status: kind.into(),
759 n_iter: outer,
760 n_qp_solves,
761 n_qp_working_set_changes,
762 final_stationarity,
763 final_constr_viol,
764 working_set: iter.working,
765 });
766 }
767 // What is left of it, handed to every step QP this iteration as
768 // its `time_limit`, so one subproblem cannot outlive the solve.
769 // `qp_budget` remembers which budget set the limit, so a QP that
770 // stops on it is reported as that budget and not as the
771 // subproblem's own iteration limit.
772 let qp_budget = self.opts.deadline.as_ref().map(deadline_remaining);
773 let qp_time_limit =
774 qp_time_limit_now(self.opts.deadline.as_ref(), self.qp_opts.time_limit);
775
776 let qp_data = match low_rank.as_ref() {
777 Some(b) => SqpQpData::build_low_rank(
778 &iter.x, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, jac_c, b,
779 ),
780 None => SqpQpData::build(
781 &iter.x,
782 &grad_f,
783 &c_vals,
784 &bl_c,
785 &bu_c,
786 &xl,
787 &xu,
788 jac_c,
789 hess_lag,
790 self.hessian_inertia(),
791 ),
792 };
793
794 // Scale-relative inner-QP tolerances (issue #358 tail).
795 //
796 // `QpOptions::{feas_tol, opt_tol}` are **absolute** (1e-9 each).
797 // That is a sane default for a standalone `solve_qp` on
798 // well-scaled data, but this QP is an *inner* subproblem whose
799 // data inherits the NLP's scale: with `‖∇f‖ ~ 1e3` and
800 // `‖B‖ ~ 1e3`, an absolute 1e-9 is ~1e-12 *relative* — at the
801 // f64 noise floor. The active-set solver then cannot certify
802 // its own optimality, burns its whole iteration budget, and
803 // returns `MaxIter`; the driver reports `QpStepFailed`, which
804 // surfaces to the user as `Search_Direction_Becomes_Too_Small`
805 // on a QP that is trivially solvable. This is what stalled the
806 // ill-conditioned tail of #358 even once the Hessian scale was
807 // fixed by the probe above.
808 //
809 // Scale both tolerances by the QP data magnitude, so the inner
810 // solve is asked for a *relative* accuracy it can actually
811 // reach. Nothing is lost in the answer: the SQP outer loop
812 // still gates optimality on the true, unscaled NLP KKT
813 // residuals (`dual_inf_tol` / `constr_viol_tol`) at the top of
814 // each iteration, so a sloppier inner step can only cost an
815 // extra outer iteration — never a false `Optimal`. Measured on
816 // a 500-instance convex-QP sweep this converts 34 failures into
817 // successes with a *bit-for-bit identical* error distribution
818 // (median 6e-11, max true constraint violation 4e-11).
819 //
820 // The `SCALE_MAX` clamp bounds the relaxation on pathological
821 // data (a quasi-Newton `B` that has blown up); it does not bind
822 // on any problem in the sweep.
823 const SCALE_MAX: Number = 1e6;
824 let g_inf = grad_f.iter().map(|v| v.abs()).fold(0.0, f64::max);
825 let b_inf = qp_data.hess_inf;
826 let base = self.qp_opts.clone();
827 self.qp_opts.time_limit = qp_time_limit;
828 let scale = g_inf.max(b_inf).clamp(1.0, SCALE_MAX);
829 if scale > 1.0 {
830 self.qp_opts.opt_tol = base.opt_tol * scale;
831 self.qp_opts.feas_tol = base.feas_tol * scale;
832 }
833
834 // Warm-start from the previous QP's working set when
835 // available. Pounce-qp's `solve_with_working_set`
836 // internally computes a feasible primal compatible
837 // with the supplied set (it satisfies every active
838 // row exactly) — necessary because each SQP
839 // linearization shifts the QP's constraint RHS by
840 // `-c(x_k)`, so the previous QP's *primal* doesn't
841 // carry over even when the active set does.
842 let warm_started = iter.working.is_some();
843 let mut sol = if let Some(prev_w) = iter.working.as_ref() {
844 // A warm solve that *errors* falls back to cold rather than
845 // aborting the SQP (gh #855). The cold-start fallback below
846 // already exists for a warm solve that comes back `MaxIter` /
847 // `NumericalError`, on the reasoning that the carried-over
848 // working set can be a poor guess; a hard `Err` from the same
849 // call is the **stronger** form of that signal and was the one
850 // case the fallback could not see, because `?` propagated out
851 // of the whole algorithm first.
852 //
853 // The error that motivates this says so itself: `eigena2`
854 // under `algorithm=active-set-sqp` reaches outer iteration 17
855 // and the warm solve fails with "pinned KKT constraint block
856 // is rank-deficient (inertia shift masked a singular
857 // constraint block); prune to a linearly-independent subset".
858 // That is a statement about the *pinned set*, which is exactly
859 // what a warm start supplies and a cold start rebuilds. The
860 // SQP exited `Internal_Error` / `solve_result_num=500` -- "the
861 // solver broke, retry" -- on a model whose objective it can
862 // report; with the cold re-solve it ends
863 // `Maximum_Iterations_Exceeded` at `obj = 82.5177`, against
864 // the NLP arm's 82.5 on the same file.
865 //
866 // The cold error is still propagated: if a clean start fails
867 // too, the failure is not about the working set and there is
868 // nothing further to try here.
869 let warm_opts = self.warm_qp_opts(&self.qp_opts, n + m, qp_data.hessian_inertia);
870 match solve_step_qp(
871 &mut self.qp_solver,
872 &qp_data,
873 Some(prev_w),
874 warm_opts.as_ref().unwrap_or(&self.qp_opts),
875 self.ipm_cold_start.then_some(&self.ipm_seed),
876 ) {
877 Ok(v) => v,
878 Err(e) => {
879 tracing::debug!(target: "pounce::sqp",
880 "warm-started step QP failed hard ({e:?}); re-solving \
881 from cold, since the carried working set is what a \
882 cold start rebuilds (gh #855)");
883 n_qp_solves += 1;
884 solve_step_qp(
885 &mut self.qp_solver,
886 &qp_data,
887 None,
888 &self.qp_opts,
889 self.ipm_cold_start.then_some(&self.ipm_seed),
890 )?
891 }
892 }
893 } else {
894 solve_step_qp(
895 &mut self.qp_solver,
896 &qp_data,
897 None,
898 &self.qp_opts,
899 self.ipm_cold_start.then_some(&self.ipm_seed),
900 )?
901 };
902 n_qp_solves += 1;
903 n_qp_working_set_changes += sol.stats.n_working_set_changes;
904
905 // Cold-start fallback: a warm start seeds the QP with the
906 // previous iterate's working set, which is usually a big
907 // win but can occasionally strand the active-set solver at
908 // its iteration limit (or a numerical breakdown) on a QP
909 // that is perfectly solvable from a clean start — e.g.
910 // when a quasi-Newton Hessian has drifted enough that the
911 // carried-over active set is a poor guess. Rather than
912 // give up with `QpStepFailed`, re-solve once from cold;
913 // this is what rescues the curved-constraint SQP runs of
914 // issue #349 that previously reported
915 // `Search_Direction_Becomes_Too_Small`.
916 if warm_started && matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError) {
917 let cold = solve_step_qp(
918 &mut self.qp_solver,
919 &qp_data,
920 None,
921 &self.qp_opts,
922 self.ipm_cold_start.then_some(&self.ipm_seed),
923 )?;
924 n_qp_solves += 1;
925 n_qp_working_set_changes += cold.stats.n_working_set_changes;
926 // `Unbounded` is accepted as well as `Optimal`, and it is
927 // the point of gh #855. A cold re-solve that comes back
928 // `Unbounded` has *found something* — an unblocked direction
929 // of negative curvature in the null space of its working set
930 // — and taking only `Optimal` threw that away, leaving `sol`
931 // on the original `MaxIter`. The unbounded-model fallback
932 // below is gated on `sol.status == Unbounded`, so the
933 // δ-shifted proximal step written for exactly this situation
934 // was unreachable from a retry.
935 //
936 // Accepting it is safe because the fallback does not trust it
937 // either: it re-tests the ray against the true NLP (gh #388)
938 // and only takes the proximal branch when the certificate
939 // does *not* survive. A spurious `Unbounded` therefore costs
940 // one re-test, not a wrong verdict.
941 //
942 // Nothing else can be accepted here: `Infeasible` from a cold
943 // solve would contradict the warm one on the same subproblem
944 // without a tie-breaker, and `MaxIter` / `NumericalError` are
945 // what we already have.
946 //
947 // COVERAGE, stated because it matters: no fixture in the CLI
948 // corpus reaches this branch. Swept under
949 // `algorithm=active-set-sqp`, the retries fire on three
950 // fixtures (`cresc4`, `eigena2`, `jit1_boxed`) and return only
951 // `MaxIter` or `Optimal`, including under `sqp_qp_max_iter`
952 // forced down to 2. gh #855 observed the `Unbounded` return on
953 // `eigena2` in a build carrying second-order certification for
954 // the step subproblem, which is gh #856's subject and does not
955 // exist here yet. It is kept rather than dropped because it is
956 // not redundant -- nothing else makes the unbounded-model
957 // fallback reachable from a retry -- which is the opposite of
958 // the gh #846 case, where a second arm already rejected
959 // everything the removed one would have.
960 if matches!(cold.status, QpStatus::Optimal | QpStatus::Unbounded) {
961 sol = cold;
962 }
963 }
964
965 // Quasi-Newton reset fallback (issue #358 tail). If the QP
966 // still cannot be solved, the usual culprit is not the
967 // linearization but the *approximated* Hessian: a damped-BFGS
968 // matrix that has accumulated enough drift (typically after a
969 // large early step on an ill-conditioned problem) to make the
970 // step subproblem numerically unsolvable. That is recoverable
971 // — throwing away the accumulated curvature and retrying from
972 // a scaled identity almost always yields a usable step —
973 // whereas the alternative is aborting an otherwise healthy
974 // solve with `QpStepFailed`, which the user sees as
975 // `Search_Direction_Becomes_Too_Small` on a trivially solvable
976 // problem. Rebuild the subproblem around the reset Hessian and
977 // re-solve once, from cold (the carried working set belongs to
978 // the discarded model).
979 let mut qp_data = qp_data;
980 if matches!(sol.status, QpStatus::MaxIter | QpStatus::NumericalError)
981 && let Some(b) = bfgs.as_mut()
982 {
983 b.reset_to_scale();
984 qp_data = SqpQpData::build(
985 &iter.x,
986 &grad_f,
987 &c_vals,
988 &bl_c,
989 &bu_c,
990 &xl,
991 &xu,
992 nlp.eval_jac_c(&iter.x),
993 b.as_triplet(),
994 self.hessian_inertia(),
995 );
996 let retry = solve_step_qp(
997 &mut self.qp_solver,
998 &qp_data,
999 None,
1000 &self.qp_opts,
1001 self.ipm_cold_start.then_some(&self.ipm_seed),
1002 )?;
1003 n_qp_solves += 1;
1004 n_qp_working_set_changes += retry.stats.n_working_set_changes;
1005 // Same as the cold retry above (gh #855): an `Unbounded`
1006 // verdict is a finding, not a failure, and discarding it hid
1007 // the proximal fallback from this branch too.
1008 //
1009 // The subproblem stays consistent: this branch runs only
1010 // while `sol.status` is `MaxIter`/`NumericalError`, so it
1011 // cannot fire after the cold retry has been accepted, and
1012 // `qp_data` is rebound above — so the fallback below re-solves
1013 // the *reset-Hessian* subproblem that produced this verdict,
1014 // not the discarded one.
1015 if matches!(retry.status, QpStatus::Optimal | QpStatus::Unbounded) {
1016 sol = retry;
1017 }
1018 }
1019
1020 // Unbounded-model fallback (gh #423). The step QP being
1021 // unbounded below is a statement about the *linearization*, so
1022 // re-test the ray against the true NLP (gh #388) — and when it
1023 // does not survive, do not stop there. An unbounded model on a
1024 // bounded NLP is not a dead end; it is the textbook signal that
1025 // the model needs regularizing (Nocedal-Wright §18.4), and δ
1026 // from §4.5 inertia control already *is* that regularization.
1027 // So re-solve declining the certificate: the same subproblem,
1028 // the same shift, but the unblocked direction takes the
1029 // δ-shifted proximal step instead of certifying recession.
1030 //
1031 // This is not a corner case. A nonconvex NLP with `m = 0` and
1032 // no finite bounds has *nothing that can ever block* a
1033 // negative-curvature direction, so every indefinite iterate
1034 // produces this certificate. gh #419 gave those iterates a real
1035 // step where a bound exists and left them with none where one
1036 // does not: a chain of coupled double wells (`n = 12`, `m = 0`)
1037 // that converged to f = 0.027424 in 24 iterations died at
1038 // iteration 1 with `QpStepFailed` at f = 26.03. The proximal
1039 // step is slow — that slowness is what #416 was about — but it
1040 // is a step, and it is only reached here where the alternative
1041 // is no step at all.
1042 let mut ray_certified = false;
1043 if sol.status == QpStatus::Unbounded {
1044 ray_certified = sol.unbounded_ray.as_ref().is_some_and(|d| {
1045 ray_certifies_unbounded(
1046 nlp,
1047 &iter.x,
1048 d,
1049 f_curr,
1050 &grad_f,
1051 &bl_c,
1052 &bu_c,
1053 &xl,
1054 &xu,
1055 self.opts.constr_viol_tol,
1056 )
1057 });
1058 if !ray_certified {
1059 tracing::debug!(target: "pounce::sqp",
1060 "unbounded step QP whose recession ray does not survive \
1061 re-testing against the NLP — re-solving for the δ-shifted \
1062 proximal step (gh #423)");
1063 let prox_opts = QpOptions {
1064 certify_recession_ray: false,
1065 ..self.qp_opts.clone()
1066 };
1067 let prox = solve_step_qp(
1068 &mut self.qp_solver,
1069 &qp_data,
1070 None,
1071 &prox_opts,
1072 self.ipm_cold_start.then_some(&self.ipm_seed),
1073 )?;
1074 n_qp_solves += 1;
1075 if prox.status == QpStatus::Optimal {
1076 sol = prox;
1077 }
1078 }
1079 }
1080 self.qp_opts = base;
1081
1082 // An infeasible step QP is a statement about the linearization at
1083 // `x_k`, not about the NLP (Fletcher's Sℓ1QP; Nocedal-Wright
1084 // §18.5). The QP's elastic phase hands back the point minimizing
1085 // its ℓ1 violation, which is a step toward feasibility; when it
1086 // reduces the linearized violation, take it and let the ℓ1 merit
1087 // line search judge it. The multipliers it returns are a Farkas
1088 // certificate, not a step's multipliers, so the current ones are
1089 // kept. Only a step that cannot reduce the linearized violation —
1090 // `x_k` stationary for it — is the local-infeasibility signal.
1091 //
1092 // Stopping on the first infeasible QP declared `deb7` (813
1093 // variables, feasible: the interior point solves it at 97.56)
1094 // `Infeasible_Problem_Detected` at iteration 0, and the
1095 // second-opinion ladder agreed three times, since every re-solve
1096 // starts from the same linearization (gh#1061 sweep).
1097 let elastic_step = sol.status == QpStatus::Infeasible && {
1098 let zero = vec![0.0; n];
1099 let before = linearized_l1_violation(&qp_data, &zero);
1100 let after = linearized_l1_violation(&qp_data, &sol.x);
1101 tracing::debug!(target: "pounce::sqp",
1102 "infeasible step QP: linearized ℓ1 violation {before:.3e} → {after:.3e} \
1103 along its elastic step");
1104 after < (1.0 - ELASTIC_STEP_MIN_GAIN) * before
1105 };
1106 if elastic_step {
1107 sol.lambda_g.clone_from(&iter.lambda_g);
1108 sol.lambda_x.clone_from(&iter.lambda_x);
1109 }
1110
1111 match sol.status {
1112 QpStatus::Optimal => {}
1113 QpStatus::Infeasible if elastic_step => {}
1114 QpStatus::Infeasible => {
1115 let obj = nlp.eval_f(&iter.x);
1116 self.iterates = Some(iter.clone());
1117 return Ok(SqpResult {
1118 x: iter.x,
1119 lambda_g: iter.lambda_g,
1120 lambda_x: iter.lambda_x,
1121 obj,
1122 status: SqpStatus::InfeasibleSubproblem,
1123 n_iter: outer,
1124 n_qp_solves,
1125 n_qp_working_set_changes,
1126 final_stationarity,
1127 final_constr_viol,
1128 working_set: iter.working,
1129 });
1130 }
1131 // The QP subproblem neither solved nor certified
1132 // infeasibility. `MaxIter` / `NumericalError` mean the
1133 // active-set QP could not resolve the (typically extremely
1134 // degenerate) step subproblem — the m/n ≫ 1 collapsed-cone
1135 // geometry of #282. Terminate the SQP with an HONEST
1136 // non-committal status rather than a hard error, and — the
1137 // point of #282 — WITHOUT ever asserting infeasibility on a
1138 // problem we have not certified infeasible.
1139 QpStatus::MaxIter | QpStatus::TimeLimit | QpStatus::NumericalError => {
1140 let obj = nlp.eval_f(&iter.x);
1141 self.iterates = Some(iter.clone());
1142 // Report *which* of the two it was. Both are honest
1143 // non-committal failures making no infeasibility claim
1144 // (#282), but only the budget one is actionable by the
1145 // user, and merging them hid the dominant Maros-Mészáros
1146 // failure mode behind a step-size verdict. See
1147 // `SqpStatus::QpIterationLimit`.
1148 // `TimeLimit` is the solve's own `max_wall_time` /
1149 // `max_cpu_time` when the deadline set the QP's limit
1150 // (gh#1054), and is reported as that. A limit the caller
1151 // put on the subproblem directly rides with `MaxIter`: a
1152 // budget ran out, no claim about the problem.
1153 let status = match sol.status {
1154 QpStatus::TimeLimit => match qp_budget {
1155 Some((left, budget))
1156 if self.qp_opts.time_limit.is_none_or(|own| left <= own) =>
1157 {
1158 budget.into()
1159 }
1160 _ => SqpStatus::QpIterationLimit,
1161 },
1162 QpStatus::MaxIter => SqpStatus::QpIterationLimit,
1163 _ => SqpStatus::QpStepFailed,
1164 };
1165 return Ok(SqpResult {
1166 x: iter.x,
1167 lambda_g: iter.lambda_g,
1168 lambda_x: iter.lambda_x,
1169 obj,
1170 status,
1171 n_iter: outer,
1172 n_qp_solves,
1173 n_qp_working_set_changes,
1174 final_stationarity,
1175 final_constr_viol,
1176 working_set: iter.working,
1177 });
1178 }
1179 // The step QP is unbounded below along a certified
1180 // recession ray of the LOCAL model (zero curvature,
1181 // feasible for every step length, strict descent). That
1182 // is a statement about the linearization, not yet about
1183 // the NLP — so it was re-tested against the true
1184 // objective and constraints above. If it survived, the
1185 // NLP itself is unbounded and we say so with the same
1186 // `Diverging_Iterates` verdict every other POUNCE path
1187 // returns. If it did not, the proximal re-solve above
1188 // already had its chance to produce a step, and reaching
1189 // here means even that failed: the QP simply could not
1190 // produce a usable step, which is `QpStepFailed`.
1191 //
1192 // Neither outcome is a hard error. This used to return
1193 // `QpFailure(LinearSolverFailure("QP subproblem returned
1194 // status unbounded"))`, which surfaced to AMPL / Pyomo /
1195 // GAMS consumers as `Internal_Error` /
1196 // `solve_result_num=500` — "the solver broke" — on a
1197 // model that is merely unbounded (`300`), and named a
1198 // linear-solver failure when no linear solver had failed
1199 // (gh #388).
1200 QpStatus::Unbounded => {
1201 let certified = ray_certified;
1202 let obj = nlp.eval_f(&iter.x);
1203 self.iterates = Some(iter.clone());
1204 return Ok(SqpResult {
1205 x: iter.x,
1206 lambda_g: iter.lambda_g,
1207 lambda_x: iter.lambda_x,
1208 obj,
1209 status: if certified {
1210 SqpStatus::Unbounded
1211 } else {
1212 SqpStatus::QpStepFailed
1213 },
1214 n_iter: outer,
1215 n_qp_solves,
1216 n_qp_working_set_changes,
1217 final_stationarity,
1218 final_constr_viol,
1219 working_set: iter.working,
1220 });
1221 }
1222 }
1223
1224 let p_inf = sol.x.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1225 if self.opts.print_level >= 2 {
1226 println!(
1227 " qp: ‖p‖_inf={:.3e} ‖λ_g_qp‖_inf={:.3e}",
1228 p_inf,
1229 sol.lambda_g.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
1230 );
1231 }
1232 // Globalization: l1-merit backtracking (Han-Powell)
1233 // or filter (Fletcher-Leyffer 2002). The two share
1234 // the same backtracking shell + acceptance API; the
1235 // filter keeps state across iterations on
1236 // `self.filter`.
1237 //
1238 // Both are handed a second-order-correction (SOC)
1239 // provider (the Maratos remedy). When the full step
1240 // (α = 1) is rejected because it increased the
1241 // constraint violation, the line search calls this
1242 // closure with `c(x_k + p)` to obtain a corrected full
1243 // step. We build that step by re-solving the SAME QP
1244 // with the general-constraint RHS re-centered on the
1245 // trial-point constraint values: the original QP models
1246 // `c(x_k) + A p`, and the SOC replaces `c(x_k)` by
1247 // `c(x_k + p) − A p`, so the correction subproblem
1248 // targets the true (curved) violation at the trial
1249 // point (Nocedal-Wright §18.11). The just-solved working
1250 // set warm-starts the correction. Only meaningful with
1251 // general constraints (`m > 0`).
1252 //
1253 // Pre-computed `A p` for the RHS re-centering:
1254 let a_p = if m > 0 {
1255 mat_vec_gen(&qp_data.a, &sol.x, m)
1256 } else {
1257 Vec::new()
1258 };
1259 let mut n_soc_solves: u32 = 0;
1260 // Working set from the SOC subproblem, kept so that a
1261 // taken SOC step warm-starts the next iteration from the
1262 // active set that actually describes `x + p_soc` (not the
1263 // original QP's set, which belongs to the rejected step).
1264 let mut soc_working: Option<WorkingSet> = None;
1265 let ls = {
1266 // Warm-started from the step QP's own working set.
1267 let warm_opts = self.warm_qp_opts(&self.qp_opts, n + m, qp_data.hessian_inertia);
1268 let qp_solver = &mut self.qp_solver;
1269 let qp_opts = warm_opts.as_ref().unwrap_or(&self.qp_opts);
1270 let deadline = self.opts.deadline.as_ref();
1271 let seed = self.ipm_cold_start.then_some(&self.ipm_seed);
1272 let qp_data_ref = &qp_data;
1273 let c_curr_ref = &c_vals;
1274 let a_p_ref = &a_p;
1275 let sol_working = &sol.working;
1276 let n_soc = &mut n_soc_solves;
1277 let soc_working_slot = &mut soc_working;
1278 let mut soc = |c_trial: &[Number]| -> Option<crate::sqp::line_search::SocStep> {
1279 let mm = qp_data_ref.m;
1280 // Re-center the general-constraint RHS on the
1281 // trial-point violation, preserving ±∞ sentinels.
1282 let mut bl_soc = qp_data_ref.bl.clone();
1283 let mut bu_soc = qp_data_ref.bu.clone();
1284 for i in 0..mm {
1285 let delta = c_curr_ref[i] - c_trial[i] + a_p_ref[i];
1286 if qp_data_ref.bl[i] > NLP_LOWER_BOUND_INF {
1287 bl_soc[i] = qp_data_ref.bl[i] + delta;
1288 }
1289 if qp_data_ref.bu[i] < NLP_UPPER_BOUND_INF {
1290 bu_soc[i] = qp_data_ref.bu[i] + delta;
1291 }
1292 }
1293 // `bl` / `bu` carry the lifted rows (if any) past `mm`;
1294 // only the NLP's own rows are re-centred.
1295 let qp_soc = qp_data_ref.as_qp_with_row_bounds(&bl_soc, &bu_soc);
1296 // What is left of the solve's budget now, not at the top of
1297 // the iteration: the step QP and the trial points have
1298 // spent part of it since (gh#1061: the correction QP ran
1299 // 40–142 s past a wall cap on that stale figure).
1300 let soc_opts = QpOptions {
1301 time_limit: qp_time_limit_now(deadline, qp_opts.time_limit),
1302 ..qp_opts.clone()
1303 };
1304 let sol_soc = solve_subproblem_qp(
1305 qp_solver,
1306 qp_data_ref,
1307 qp_soc,
1308 Some(sol_working),
1309 &soc_opts,
1310 seed,
1311 "SOC QP",
1312 )
1313 .ok()?;
1314 *n_soc += 1;
1315 if sol_soc.status == QpStatus::Optimal {
1316 *soc_working_slot = Some(sol_soc.working);
1317 Some(crate::sqp::line_search::SocStep {
1318 p: sol_soc.x,
1319 lambda_g: sol_soc.lambda_g,
1320 lambda_x: sol_soc.lambda_x,
1321 })
1322 } else {
1323 None
1324 }
1325 };
1326 let soc_ref: Option<crate::sqp::line_search::SocProvider<'_>> =
1327 if m > 0 { Some(&mut soc) } else { None };
1328 match self.opts.globalization {
1329 SqpGlobalization::L1Elastic => l1_merit_line_search(
1330 nlp,
1331 &iter.x,
1332 &sol.x,
1333 &sol.lambda_g,
1334 &grad_f,
1335 f_curr,
1336 &c_vals,
1337 &bl_c,
1338 &bu_c,
1339 &xl,
1340 &xu,
1341 nu,
1342 &self.opts,
1343 soc_ref,
1344 ),
1345 SqpGlobalization::Filter => filter_line_search(
1346 nlp,
1347 &mut self.filter,
1348 &iter.x,
1349 &sol.x,
1350 f_curr,
1351 &c_vals,
1352 &bl_c,
1353 &bu_c,
1354 &xl,
1355 &xu,
1356 nu,
1357 &self.opts,
1358 soc_ref,
1359 ),
1360 }
1361 };
1362 n_qp_solves += n_soc_solves;
1363 if self.opts.print_level >= 2 {
1364 println!(
1365 " ls: α={:.3e} ν={:.3e} ok={} f_new={:.3e}",
1366 ls.alpha, ls.nu, ls.success, ls.f_new
1367 );
1368 }
1369 if !ls.success {
1370 self.iterates = Some(iter.clone());
1371 return Ok(SqpResult {
1372 x: iter.x,
1373 lambda_g: iter.lambda_g,
1374 lambda_x: iter.lambda_x,
1375 obj: f_curr,
1376 status: SqpStatus::LineSearchFailed,
1377 n_iter: outer,
1378 n_qp_solves,
1379 n_qp_working_set_changes,
1380 final_stationarity,
1381 final_constr_viol,
1382 working_set: Some(sol.working),
1383 });
1384 }
1385 iter.x = ls.x_new;
1386 // Captured before the match below moves `sol.working` and
1387 // `ls.soc_duals` out from under them. Both are cheap Copy reads;
1388 // taking them here rather than after is what keeps the row's
1389 // `ws` column this QP's own count instead of the running total.
1390 let qp_ws_changes = sol.stats.n_working_set_changes;
1391 let soc_taken = ls.soc_duals.is_some();
1392 match ls.soc_duals {
1393 Some((soc_lg, soc_lx)) => {
1394 // A second-order-correction step was taken (α = 1
1395 // on the SOC subproblem). Adopt the SOC
1396 // subproblem's own multipliers and working set so
1397 // `(step, multipliers, active set)` stay a
1398 // consistent triple — required for the quasi-
1399 // Newton Hessian update to stay well-conditioned
1400 // and for the next QP to warm-start correctly.
1401 iter.lambda_g = soc_lg;
1402 iter.lambda_x = soc_lx;
1403 iter.working = soc_working.take().or(Some(sol.working));
1404 }
1405 None => {
1406 for (l, &lq) in iter.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
1407 *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
1408 }
1409 for (l, &lq) in iter.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
1410 *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
1411 }
1412 iter.working = Some(sol.working);
1413 }
1414 }
1415 nu = ls.nu;
1416 f_cached = Some(ls.f_new);
1417 c_cached = Some(ls.c_new);
1418 // Recorded after acceptance, so the row describes the step that
1419 // was actually taken rather than the last one tried. `ws_changes`
1420 // is this QP's contribution to the running total, which is the
1421 // number the working-set warm start is judged on.
1422 prev_step = Some(SqpStepRecord {
1423 p_inf,
1424 alpha: ls.alpha,
1425 soc: soc_taken,
1426 ws_changes: qp_ws_changes,
1427 });
1428 }
1429
1430 let obj = nlp.eval_f(&iter.x);
1431 self.iterates = Some(iter.clone());
1432 Ok(SqpResult {
1433 x: iter.x,
1434 lambda_g: iter.lambda_g,
1435 lambda_x: iter.lambda_x,
1436 obj,
1437 status: SqpStatus::MaxIter,
1438 n_iter: self.opts.max_iter,
1439 n_qp_solves,
1440 n_qp_working_set_changes,
1441 final_stationarity,
1442 final_constr_viol,
1443 working_set: iter.working,
1444 })
1445 }
1446
1447 /// `base` with Schur updates set for a warm-started step QP of size
1448 /// `dim` carrying the Hessian hint `inertia`
1449 /// (`SqpOptions::step_qp_schur_updates`), or `None` when that is what
1450 /// `base` already says. `base` carries the cold rule. The hint is the
1451 /// built QP's, so a lifted L-BFGS subproblem reads `Indefinite` (gh#1091).
1452 pub(crate) fn warm_qp_opts(
1453 &self,
1454 base: &QpOptions,
1455 dim: usize,
1456 inertia: HessianInertia,
1457 ) -> Option<QpOptions> {
1458 self.opts
1459 .step_qp_schur_updates(dim, Some(inertia))
1460 .filter(|&on| on != base.use_schur_updates)
1461 .map(|on| QpOptions {
1462 use_schur_updates: on,
1463 ..base.clone()
1464 })
1465 }
1466
1467 fn hessian_inertia(&self) -> HessianInertia {
1468 if self.hessian.is_quasi_newton() {
1469 // Damped BFGS and L-BFGS are PSD by construction. (A lifted
1470 // L-BFGS subproblem sets its own, see `build_low_rank`.)
1471 HessianInertia::Psd
1472 } else {
1473 // Exact ∇²L is indefinite on nonconvex NLPs; let the
1474 // QP solver's §4.5 inertia control handle it.
1475 HessianInertia::Indefinite
1476 }
1477 }
1478}
1479
1480/// gh #388: does the step QP's certified recession ray certify the **NLP**
1481/// unbounded below?
1482///
1483/// The inner QP hands back a direction `d` that is a recession ray *of the
1484/// linearization at `x`*: `∇²L d ≈ 0`, `d` feasible for the linearized
1485/// constraints at every step length, `∇q(x)ᵀd < 0`. On an LP or a QP that
1486/// linearization is exact and `d` is a recession ray of the original
1487/// problem; on a general NLP it need not be — the constraints curve back
1488/// and the objective can turn around. The two cases must not share a
1489/// status, so we settle it by evaluation rather than by faith: walk the
1490/// ray and check, at the **true** `f` and `c`, that
1491///
1492/// 1. every probe point is *feasible* (variable bounds and constraint
1493/// bounds, the latter with a roundoff allowance that grows with the
1494/// row scale so a linear row evaluated at `‖x‖ ~ 1e12` is not failed
1495/// on cancellation noise), and
1496/// 2. the objective keeps falling at **at least half** the initial linear
1497/// rate `∇f(x)ᵀd` — not merely falling. A ray that decelerates is
1498/// settling onto a finite optimum, the same distinction the IPM's
1499/// divergence guard draws (#248/#252/#285).
1500///
1501/// Probes span twelve decades of step length, so a "pass" is a family of
1502/// genuinely feasible points whose objective marches to `−∞` at a linear
1503/// rate over `1e12`. Anything short of that — one infeasible probe, one
1504/// decelerating decade, a NaN — returns `false` and the caller reports the
1505/// non-committal `QpStepFailed` instead. False negatives cost an honest
1506/// "no step" status; a false positive would tell a modeler their bounded
1507/// model is unbounded, so the asymmetry is deliberate.
1508///
1509/// `dir` need not be normalized (it is rescaled to unit max-norm here, so
1510/// the probe lengths are in the iterate's own units).
1511#[allow(clippy::too_many_arguments)]
1512fn ray_certifies_unbounded<N: SqpProblemSpec>(
1513 nlp: &mut N,
1514 x: &[Number],
1515 dir: &[Number],
1516 f_x: Number,
1517 grad_f: &[Number],
1518 bl_c: &[Number],
1519 bu_c: &[Number],
1520 xl: &[Number],
1521 xu: &[Number],
1522 constr_viol_tol: Number,
1523) -> bool {
1524 /// Step lengths along the unit-max-norm ray, spanning twelve decades.
1525 const PROBES: [Number; 7] = [1e0, 1e2, 1e4, 1e6, 1e8, 1e10, 1e12];
1526 /// Roundoff allowance per unit of `row_scale · ‖x‖∞` when checking a
1527 /// constraint at a far-out probe: comfortably above f64 epsilon
1528 /// (`2.2e-16`) to absorb accumulation over a row, far below anything
1529 /// a real violation would produce.
1530 const ROUNDOFF_REL: Number = 1e-12;
1531
1532 let n = x.len();
1533 if dir.len() != n || grad_f.len() != n || !f_x.is_finite() {
1534 return false;
1535 }
1536 let scale = dir.iter().map(|v| v.abs()).fold(0.0, f64::max);
1537 if !scale.is_finite() || scale <= 0.0 {
1538 return false;
1539 }
1540 let d: Vec<Number> = dir.iter().map(|v| v / scale).collect();
1541
1542 // Descent of the TRUE objective along the ray. The QP certified this
1543 // for its own (possibly quasi-Newton) model gradient; re-derive it
1544 // from `∇f(x)` so the rate we hold the probes to is the real one.
1545 let slope: Number = grad_f.iter().zip(d.iter()).map(|(g, di)| g * di).sum();
1546 let g_norm = grad_f.iter().map(|v| v * v).sum::<Number>().sqrt();
1547 // Numerically meaningful (not roundoff-scale) descent; a NaN slope
1548 // fails the `is_finite` guard rather than sneaking past the comparison.
1549 let descent_bar = -1e-9 * g_norm.max(1.0);
1550 if !slope.is_finite() || slope >= descent_bar {
1551 return false;
1552 }
1553
1554 // Per-row `max_j |∂c_i/∂x_j|`, the scale a linear row's value grows
1555 // with along the ray — the basis for the roundoff allowance in (1).
1556 let m = bl_c.len();
1557 let mut row_scale: Vec<Number> = vec![0.0; m];
1558 {
1559 let jac = nlp.eval_jac_c(x);
1560 for k in 0..jac.vals.len() {
1561 let i = (jac.irow[k] - 1) as usize;
1562 row_scale[i] = row_scale[i].max(jac.vals[k].abs());
1563 }
1564 }
1565
1566 for &t in PROBES.iter() {
1567 let xt: Vec<Number> = x.iter().zip(d.iter()).map(|(xi, di)| xi + t * di).collect();
1568 if xt.iter().any(|v| !v.is_finite()) {
1569 return false;
1570 }
1571
1572 // (1a) Variable bounds. These are exact linear rows in the probe's
1573 // own arithmetic, so the tolerance stays tight.
1574 for i in 0..n {
1575 let tol = 1e-9 * (1.0 + xt[i].abs());
1576 if xl[i] > NLP_LOWER_BOUND_INF && xt[i] < xl[i] - tol {
1577 return false;
1578 }
1579 if xu[i] < NLP_UPPER_BOUND_INF && xt[i] > xu[i] + tol {
1580 return false;
1581 }
1582 }
1583
1584 // (1b) Constraint bounds, at the true (possibly nonlinear) `c`.
1585 let x_inf = xt.iter().map(|v| v.abs()).fold(0.0, f64::max);
1586 let c = nlp.eval_c(&xt);
1587 if c.len() != m {
1588 return false;
1589 }
1590 for i in 0..m {
1591 if c[i].is_nan() {
1592 return false;
1593 }
1594 let tol =
1595 constr_viol_tol.max(0.0) * (1.0 + c[i].abs()) + ROUNDOFF_REL * row_scale[i] * x_inf;
1596 if bl_c[i] > NLP_LOWER_BOUND_INF && c[i] < bl_c[i] - tol {
1597 return false;
1598 }
1599 if bu_c[i] < NLP_UPPER_BOUND_INF && c[i] > bu_c[i] + tol {
1600 return false;
1601 }
1602 }
1603
1604 // (2) Sustained (non-decelerating) descent. `-inf` passes: an
1605 // objective that has already overflowed downward is not evidence
1606 // against unboundedness.
1607 let f_t = nlp.eval_f(&xt);
1608 if f_t.is_nan() || f_t > f_x + 0.5 * slope * t {
1609 return false;
1610 }
1611 }
1612 true
1613}
1614
1615#[derive(Debug, Clone, Copy)]
1616pub(crate) struct KktError {
1617 pub stationarity: Number,
1618 pub constr_viol: Number,
1619}
1620
1621/// Fraction of the linearized ℓ1 violation an elastic step must remove to be
1622/// taken (see `elastic_step` in the outer loop). Any real reduction is a
1623/// direction toward feasibility; the margin only keeps round-off on a
1624/// stationary point of the violation from passing for one.
1625const ELASTIC_STEP_MIN_GAIN: Number = 1e-6;
1626
1627/// ℓ1 violation of the step QP's own rows and bounds at step `d`: the
1628/// linearization `c(x_k) + J d` against the row bounds, and `x_k + d` against
1629/// the variable bounds, both as the QP poses them (shifted to step space).
1630/// Only the NLP's rows and variables count; a lifted subproblem's extra
1631/// rows and columns are bookkeeping, not constraints.
1632fn linearized_l1_violation(data: &SqpQpData, d: &[Number]) -> Number {
1633 let (m, n) = (data.m, data.n);
1634 let ad = mat_vec_gen(&data.a, d, m);
1635 crate::sqp::line_search::l1_violation(
1636 &d[..n],
1637 &ad,
1638 &data.bl[..m],
1639 &data.bu[..m],
1640 &data.xl[..n],
1641 &data.xu[..n],
1642 )
1643}
1644
1645/// Sparse `A · p` for an `m × n` general-constraint Jacobian stored
1646/// as a `GenTMatrix` (1-based triplet indices). Used to re-center
1647/// the second-order-correction QP's RHS on the trial point.
1648fn mat_vec_gen(a: &GenTMatrix, p: &[Number], m: usize) -> Vec<Number> {
1649 let mut out = vec![0.0; m];
1650 let irows = a.irows();
1651 let jcols = a.jcols();
1652 let vals = a.values();
1653 for k in 0..vals.len() {
1654 let i = (irows[k] - 1) as usize;
1655 let j = (jcols[k] - 1) as usize;
1656 out[i] += vals[k] * p[j];
1657 }
1658 out
1659}
1660
1661/// Build the quasi-Newton curvature pair `(s, y)` for the step from the
1662/// previous iterate to the current one, differencing `∇L` at a **single,
1663/// fixed multiplier** (Nocedal-Wright §18.3):
1664///
1665/// ```text
1666/// s = x_k − x_{k−1}
1667/// y = ∇L(x_k, λ_k) − ∇L(x_{k−1}, λ_k) ← the SAME λ_k twice
1668/// ```
1669///
1670/// Returns `None` on the first iteration (no previous point yet).
1671///
1672/// **Why the fixed multiplier matters (gh #361).** The previous code held
1673/// `∇L(x_{k−1}, λ_{k−1})` inside the Hessian object and differenced against
1674/// `∇L(x_k, λ_k)`, giving
1675///
1676/// ```text
1677/// y = (∇f_k − ∇f_{k−1}) + (J_kᵀλ_k − J_{k−1}ᵀλ_{k−1})
1678/// ```
1679///
1680/// For **linear** constraints `J` is constant, so that second group collapses
1681/// to `Aᵀ(λ_k − λ_{k−1})` — pure *multiplier* difference, carrying no
1682/// curvature information at all. Since the true `∇²L` equals `∇²f` there, the
1683/// whole term is spurious, and it feeds a divergent loop: a perturbed `B`
1684/// yields a worse QP multiplier, which injects a larger error into the next
1685/// `y`, which corrupts `B` further. On equality-constrained QPs (where `λ` is
1686/// sign-free and can swing hard) the multiplier was observed oscillating and
1687/// growing exponentially — `−13, 19, −69, 104, −145, 581, −1320, 3176, …` —
1688/// while `x` itself sat on the exact optimum. The solve then burned its whole
1689/// iteration budget and exited `Maximum_Iterations_Exceeded` *at the right
1690/// answer*, because the stationarity residual is computed from that garbage
1691/// multiplier.
1692///
1693/// Using one multiplier at both points makes the term telescope to
1694/// `Σλᵏᵢ(∇cᵢ(x_k) − ∇cᵢ(x_{k−1}))`, which is the genuine constraint-curvature
1695/// contribution: it vanishes identically for linear constraints (as it must)
1696/// and is retained for nonlinear ones.
1697fn curvature_pair(
1698 prev: Option<&(Vec<Number>, Vec<Number>, Triplet)>,
1699 iter: &SqpIterates,
1700 grad_f: &[Number],
1701 jac_c: &Triplet,
1702 n: usize,
1703) -> Option<(Vec<Number>, Vec<Number>)> {
1704 let (prev_x, prev_grad_f, prev_jac) = prev?;
1705 let s: Vec<Number> = iter
1706 .x
1707 .iter()
1708 .zip(prev_x.iter())
1709 .map(|(a, b)| a - b)
1710 .collect();
1711 // Both evaluated at the *current* multiplier `iter.lambda_g`.
1712 let lag_curr = compute_grad_lag(grad_f, jac_c, &iter.lambda_g, n);
1713 let lag_prev = compute_grad_lag(prev_grad_f, prev_jac, &iter.lambda_g, n);
1714 let y: Vec<Number> = lag_curr
1715 .iter()
1716 .zip(lag_prev.iter())
1717 .map(|(a, b)| a - b)
1718 .collect();
1719 Some((s, y))
1720}
1721
1722/// Lagrangian gradient `∇L(x, λ_g) = ∇f(x) + J_c(x)ᵀ λ_g` at the
1723/// current iterate. Used by the damped-BFGS update.
1724fn compute_grad_lag(
1725 grad_f: &[Number],
1726 jac_c: &Triplet,
1727 lambda_g: &[Number],
1728 n: usize,
1729) -> Vec<Number> {
1730 let mut out = grad_f.to_vec();
1731 debug_assert_eq!(out.len(), n);
1732 for k in 0..jac_c.irow.len() {
1733 let row_i = (jac_c.irow[k] - 1) as usize;
1734 let col_j = (jac_c.jcol[k] - 1) as usize;
1735 out[col_j] += jac_c.vals[k] * lambda_g[row_i];
1736 }
1737 out
1738}
1739/// Walk `d` from `x` and return a strictly better feasible point, or `None`.
1740///
1741/// This is what turns a curvature *direction* into a refutation. gh #848
1742/// established the shape one layer down: a feasible point with a strictly
1743/// lower objective is proof needing no theory, so the search that produced
1744/// `d` may be as approximate as it likes — a direction it gets wrong costs
1745/// the evaluations below and nothing else.
1746///
1747/// The step length is the distance to the first blocking variable bound,
1748/// halved back until the *nonlinear* constraints are satisfied to
1749/// `constr_viol_tol`. That backtrack is the difference between this and the
1750/// QP-level version: `d` lies in the null space of the *linearized* active
1751/// constraints, which holds them exactly only where they are linear.
1752/// `nonconvex_qp`'s equality is linear, so the first trial is accepted there;
1753/// a curved constraint gives up its step rather than trading feasibility for
1754/// objective, which the SQP's own merit function would then have to undo.
1755#[allow(clippy::too_many_arguments)]
1756fn exhibit_better_point<N: SqpProblemSpec>(
1757 nlp: &mut N,
1758 x: &[Number],
1759 d: &[Number],
1760 f_curr: Number,
1761 xl: &[Number],
1762 xu: &[Number],
1763 bl_c: &[Number],
1764 bu_c: &[Number],
1765 constr_viol_tol: Number,
1766) -> Option<Vec<Number>> {
1767 let n = x.len();
1768 let dn = d.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
1769 if !(dn > 0.0) || !dn.is_finite() {
1770 return None;
1771 }
1772 // Both signs: curvature is even, so `-d` descends wherever `d` does, and
1773 // only one of them may have room before a bound.
1774 //
1775 // Every feasible trial is *scored* and the best one is returned, rather
1776 // than the first that clears the bar (gh#873). Two reasons, and the second
1777 // is a defect the first exposed:
1778 //
1779 // 1. The profile along `d` is not monotone. `f(x + αd) ≈ f(x) + ½α²·dᵀ∇²f d`
1780 // only near `x`; on `nonconvex_two_escapes` the quartic term takes `f`
1781 // back to `+1.8` at the wall while the interior of the same ray reaches
1782 // `−0.225`. So the step length has to be searched, not guessed —
1783 // which is why the halving below now runs on *either* rejection and not
1784 // only on infeasibility, the mechanism that had the SQP arm certifying
1785 // that fixture's documented maximum at every `neg_curv_escapes`.
1786 // 2. `sign` is scanned in a fixed order, so "first acceptable" made the
1787 // answer depend on it. On `min −x₀² + x₁²` with `x₀ ∈ [−2, g]` the `+d`
1788 // wall is worth `−g²` and the `−d` wall is worth `−4`; returning the
1789 // first meant a `g` small enough to clear the bar handed back `−g²` and
1790 // threw away the global minimum sitting in the other sign.
1791 //
1792 // Scoring costs nothing extra — the evaluations already happened — and the
1793 // bound stays the 24 halvings per sign the loop always carried, spent once
1794 // at convergence on a point already known to have negative curvature.
1795 // The feasibility a refutation is held to is the *incumbent's*, not the
1796 // convergence tolerance (gh#873, found by the fixture sweep).
1797 //
1798 // `constr_viol_tol` is the bar for calling a solve converged; using it here
1799 // let the exhibition buy objective with infeasibility. Measured on
1800 // `cresc4.nl` (lbfgs leg): the KKT point is feasible to 2.2e-16, and the
1801 // trials this accepted violated the rows by 6e-7 — nine orders worse, but
1802 // legal under `constr_viol_tol = 1e-6` — to gain 4.4e-7 of objective. The
1803 // arm then restored feasibility and returned to the same point, eight
1804 // times over, until `MAX_SECOND_ORDER_ESCAPES` capped it: 15 iterations
1805 // became 45 for an answer identical to 15 significant figures. That is
1806 // gh#544's shape exactly — the right answer, slowly — and it is the reason
1807 // CLAUDE.md requires the sweep on a trajectory change.
1808 //
1809 // A point that proves the incumbent is not a local minimum has to be at
1810 // least as feasible as the incumbent is. The slack is `1e-12` *relative to
1811 // the trial's own row magnitudes*, so it is roundoff and not a distance in
1812 // the units of `c`; and it is clamped at `constr_viol_tol`, so this bar is
1813 // never looser than the one it replaces.
1814 //
1815 // The cost is honest and worth naming: against a *curved* active
1816 // constraint a straight tangent probe leaves the feasible set at order
1817 // `α²`, so on such a model the exhibition now declines rather than
1818 // accepting a point that is only tolerance-feasible. That is the same
1819 // structural limit as gh#873 D3 — this walks a straight line and tests the
1820 // objective — made visible instead of paid for in iterations.
1821 let viol_at = |nlp: &mut N, v: &[Number]| -> (Number, Number) {
1822 let c = nlp.eval_c(v);
1823 let mut viol = 0.0_f64;
1824 let mut scale = 0.0_f64;
1825 for (j, &cj) in c.iter().enumerate() {
1826 viol = viol
1827 .max((bl_c[j] - cj).max(0.0))
1828 .max((cj - bu_c[j]).max(0.0));
1829 scale = scale.max(cj.abs());
1830 }
1831 (viol, scale)
1832 };
1833 let (viol_curr, _) = viol_at(nlp, x);
1834
1835 let mut best: Option<(Number, Vec<Number>)> = None;
1836 for sign in [1.0_f64, -1.0] {
1837 let mut alpha = f64::INFINITY;
1838 for i in 0..n {
1839 let di = sign * d[i];
1840 if di > 1e-12 * dn && xu[i] < f64::INFINITY {
1841 alpha = alpha.min((xu[i] - x[i]) / di);
1842 }
1843 if di < -1e-12 * dn && xl[i] > f64::NEG_INFINITY {
1844 alpha = alpha.min((x[i] - xl[i]) / -di);
1845 }
1846 }
1847 // Unbounded in this direction is not this function's business -- the
1848 // unbounded-model fallback owns that -- so cap and keep going.
1849 if !alpha.is_finite() {
1850 alpha = 1.0 / dn;
1851 }
1852 for _ in 0..24 {
1853 if !(alpha > 0.0) {
1854 break;
1855 }
1856 let trial: Vec<Number> = (0..n)
1857 .map(|i| (x[i] + sign * alpha * d[i]).clamp(xl[i], xu[i]))
1858 .collect();
1859 let (viol, c_scale) = viol_at(nlp, &trial);
1860 let feas_bar = viol_curr.max(1e-12 * c_scale).min(constr_viol_tol);
1861 if viol <= feas_bar {
1862 let f_trial = nlp.eval_f(&trial);
1863 if f_trial.is_finite() && best.as_ref().is_none_or(|(b, _)| f_trial < *b) {
1864 best = Some((f_trial, trial));
1865 }
1866 }
1867 alpha *= 0.5;
1868 }
1869 }
1870
1871 // Strictly better by more than the objective's own scale can round, so
1872 // this cannot fire on noise at a genuine optimum.
1873 //
1874 // The `1.0 +` is what made that "the objective's own scale" rather than an
1875 // absolute `1e-10`, and it is itself scaled here (gh#873 D2, the same class
1876 // as gh#872). On `min k·x₀x₁ s.t. x₀ + x₁ = 2` the true improvement is
1877 // `64·k` (the corner `(9, −7)` against the stationary `(1, 1)`), so from
1878 // `k ≈ 1e-12` down the whole model lived below the additive
1879 // `1e-10` and every genuine refutation was rejected as noise — while the
1880 // reduced Hessian is `−k`, as indefinite at `k = 1e-30` as at `k = 1`.
1881 //
1882 // Lowered only: `.min(1.0)` leaves the bar exactly as it was for any
1883 // objective at or above unit scale, so this cannot make an existing solve
1884 // newly refutable.
1885 let (f_best, x_best) = best?;
1886 let f_scale = f_curr.abs().max(f_best.abs());
1887 let bar = 1e-10 * (f_scale.min(1.0) + f_curr.abs());
1888 (f_best < f_curr - bar).then_some(x_best)
1889}
1890
1891/// The activity tolerance for one bound or row, in that quantity's own units.
1892///
1893/// A raw `constr_viol_tol` is a distance in the units of `x` (for a bound) or
1894/// of `c` (for a row), so using it directly is an absolute threshold on a
1895/// scale-dependent quantity — gh#873 D2, the same class as gh#872. Confirmed
1896/// as pure scaling by an exact change of variables `u = S·x` with the gap held
1897/// fixed in `x` units: at `S = 1e-2` a `5e-7` gap crosses `tol`, and a bound
1898/// with multiplier zero is frozen into the working set, closing the null space
1899/// and the second-order verdict with it.
1900///
1901/// `scale` is the quantity and its own bounds; infinite entries are skipped,
1902/// and everything left scales together under a change of units, so the ratio
1903/// the test really wants is invariant.
1904///
1905/// Scaled **only downwards**, as in gh#872's `psd_band`: widening the test
1906/// above unit scale would freeze bounds that are free today, which loses
1907/// refutations rather than gaining them, and every refutation still has to
1908/// exhibit a strictly better feasible point before it changes any answer.
1909fn active_tol(tol: Number, scale: &[Number]) -> Number {
1910 let s = scale
1911 .iter()
1912 .filter(|v| v.is_finite())
1913 .fold(0.0_f64, |a, v| a.max(v.abs()));
1914 tol * s.min(1.0)
1915}
1916
1917/// A feasible direction of negative curvature at a *converged* first-order
1918/// point, or `None` when the point survives the second-order test (gh #856).
1919///
1920/// # Why this runs at convergence and not at each step
1921///
1922/// gh #848 gave standalone QP solves a second-order screen. Applying the same
1923/// screen to the SQP's **step** subproblem is wrong, and gh #856 has the
1924/// counterexample: that QP is a local model built from the *current*
1925/// multiplier estimates, and its second-order verdict is not the NLP's. At
1926/// SQP iteration 0 the multipliers are still zero, so the exact Lagrangian
1927/// Hessian is `∇²f`; started at HS071's own `x*` the step QP's working set
1928/// leaves a one-dimensional null space on which `dᵀHd = -4.05e-2`, and the
1929/// point is refuted — correctly for that model, wrongly for the NLP, whose
1930/// reduced Hessian at `x*` is positive once the multipliers have converged.
1931///
1932/// At *convergence* that objection disappears: the multipliers are the
1933/// converged ones, so `∇²L` is the Hessian the second-order condition is
1934/// actually about. This is the same distinction gh #856 draws when it says
1935/// "with the converged multipliers the reduced Hessian is positive" — the
1936/// check is meaningful exactly where it is run.
1937///
1938/// # What it computes
1939///
1940/// `Z` is an orthonormal basis for the null space of the active constraint
1941/// normals — equality rows, active inequality rows and active bounds — taken
1942/// from the eigenvectors of `BᵀB` whose eigenvalue is negligible against the
1943/// largest. The reduced Hessian `ZᵀHZ` is then eigen-decomposed, and a
1944/// sufficiently negative eigenvalue yields `d = Z v`: a direction that holds
1945/// every active constraint to first order and along which the objective
1946/// curves down.
1947///
1948/// Returns `None` when there is no negative curvature, when the active set
1949/// leaves no degrees of freedom, or when either eigensolve fails to converge
1950/// — never a direction it is unsure of, since the caller acts on it.
1951#[allow(clippy::too_many_arguments)]
1952fn negative_curvature_at_kkt_point(
1953 n: usize,
1954 m: usize,
1955 x: &[Number],
1956 hess_lag: &Triplet,
1957 jac_c: &Triplet,
1958 c_vals: &[Number],
1959 bl_c: &[Number],
1960 bu_c: &[Number],
1961 xl: &[Number],
1962 xu: &[Number],
1963 tol: Number,
1964) -> Option<Vec<Number>> {
1965 // Dense and `O(n³)`, so it is bounded rather than let loose on a large
1966 // model. It runs once, at convergence, on the way to reporting success —
1967 // and skipping it returns exactly today's answer, so the ceiling costs
1968 // coverage and never correctness. (Contrast gh #849, where the analogous
1969 // ceiling silently withdrew a *guarantee*.)
1970 const MAX_N: usize = 512;
1971 if n == 0 || n > MAX_N {
1972 return None;
1973 }
1974
1975 // The active set. A bound or row counts as active when the iterate sits
1976 // on it to within the same tolerance the convergence test just used, so
1977 // this is the set the verdict was issued about.
1978 let mut rows: Vec<Vec<Number>> = Vec::new();
1979 for j in 0..m {
1980 let t = active_tol(tol, &[c_vals[j], bl_c[j], bu_c[j]]);
1981 let lo_active = bl_c[j] > f64::NEG_INFINITY && (c_vals[j] - bl_c[j]).abs() <= t;
1982 let hi_active = bu_c[j] < f64::INFINITY && (bu_c[j] - c_vals[j]).abs() <= t;
1983 if lo_active || hi_active {
1984 let mut r = vec![0.0; n];
1985 // `pounce_linalg` triplets are **1-based** (see `triplet.rs`).
1986 for k in 0..jac_c.vals.len() {
1987 if jac_c.irow[k] as usize == j + 1 {
1988 r[jac_c.jcol[k] as usize - 1] += jac_c.vals[k];
1989 }
1990 }
1991 rows.push(r);
1992 }
1993 }
1994 for i in 0..n {
1995 let t = active_tol(tol, &[x[i], xl[i], xu[i]]);
1996 let on_lo = xl[i] > f64::NEG_INFINITY && (x[i] - xl[i]).abs() <= t;
1997 let on_hi = xu[i] < f64::INFINITY && (xu[i] - x[i]).abs() <= t;
1998 if on_lo || on_hi {
1999 let mut r = vec![0.0; n];
2000 r[i] = 1.0;
2001 rows.push(r);
2002 }
2003 }
2004
2005 // Null space of the active normals, from the eigenvectors of `BᵀB`.
2006 let mut z: Vec<Number> = Vec::new();
2007 let n_dof;
2008 if rows.is_empty() {
2009 n_dof = n;
2010 z = vec![0.0; n * n];
2011 for i in 0..n {
2012 z[i * n + i] = 1.0;
2013 }
2014 } else {
2015 let mut btb = vec![0.0; n * n];
2016 for r in &rows {
2017 for a in 0..n {
2018 if r[a] == 0.0 {
2019 continue;
2020 }
2021 for c in 0..n {
2022 btb[c * n + a] += r[a] * r[c];
2023 }
2024 }
2025 }
2026 let (mut ev, mut evec) = (vec![0.0; n], vec![0.0; n * n]);
2027 if !pounce_linalg::symmetric_eigen(&btb, n, &mut ev, &mut evec) {
2028 return None;
2029 }
2030 let lam_max = ev.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
2031 // Relative to `BᵀB`'s own spectral scale, with no absolute floor under
2032 // it (gh#873 D2). `lam_max` is already that scale, so the `.max(1.0)`
2033 // this carried made the rank cut absolute for small constraint
2034 // normals: a row scaled down by a change of units then read as part of
2035 // the null space, and the direction built from it does not respect the
2036 // constraint it came from.
2037 //
2038 // `lam_max == 0` means every active row has a zero gradient, so none
2039 // of them restricts anything and the whole space is free. The old
2040 // `.max(1.0)` produced that answer as a side effect of the floor;
2041 // stated here so removing the floor does not quietly change it into
2042 // "no degrees of freedom", which would decline the refutation.
2043 let cut = if lam_max > 0.0 { 1e-9 * lam_max } else { 0.0 };
2044 if lam_max == 0.0 {
2045 z.extend_from_slice(&evec[..n * n]);
2046 n_dof = n;
2047 } else {
2048 for (j, &lam) in ev.iter().enumerate() {
2049 if lam.abs() <= cut {
2050 z.extend_from_slice(&evec[j * n..(j + 1) * n]);
2051 }
2052 }
2053 n_dof = z.len() / n;
2054 }
2055 }
2056 if n_dof == 0 {
2057 return None;
2058 }
2059
2060 // `H Z`, then `Zᵀ (H Z)`.
2061 let mut hz = vec![0.0; n * n_dof];
2062 for k in 0..n_dof {
2063 let (zc, out) = (&z[k * n..(k + 1) * n], &mut hz[k * n..(k + 1) * n]);
2064 // Stored as one triangle, so an off-diagonal entry contributes to
2065 // both of its rows.
2066 for e in 0..hess_lag.vals.len() {
2067 let (r, c, v) = (
2068 hess_lag.irow[e] as usize - 1,
2069 hess_lag.jcol[e] as usize - 1,
2070 hess_lag.vals[e],
2071 );
2072 out[r] += v * zc[c];
2073 if r != c {
2074 out[c] += v * zc[r];
2075 }
2076 }
2077 }
2078 let mut rh = vec![0.0; n_dof * n_dof];
2079 for a in 0..n_dof {
2080 for b in 0..n_dof {
2081 rh[b * n_dof + a] = (0..n).map(|i| z[a * n + i] * hz[b * n + i]).sum();
2082 }
2083 }
2084
2085 let (mut ev, mut evec) = (vec![0.0; n_dof], vec![0.0; n_dof * n_dof]);
2086 if !pounce_linalg::symmetric_eigen(&rh, n_dof, &mut ev, &mut evec) {
2087 return None;
2088 }
2089 // Relative to the Hessian's own scale, so this cannot fire on the
2090 // rounding noise of a genuinely positive-semidefinite reduced Hessian --
2091 // and with no absolute floor under it (gh#873 D2, gh#872's sibling). The
2092 // `.max(1.0)` this carried made the test absolute below unit Hessian
2093 // scale, and `min k·x₀x₁ s.t. x₀ + x₁ = 2` then returned the constrained
2094 // *maximum* `f = k` for every `k` from `1e-8` down -- a family whose
2095 // reduced Hessian is `−k`, i.e. as indefinite at `k = 1e-30` as at `k = 1`.
2096 let h_scale = hess_lag.vals.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
2097 if ev[0] >= -1e-8 * h_scale {
2098 return None;
2099 }
2100 // `d = Z v` with `v` the eigenvector of the most negative eigenvalue --
2101 // column 0 of the column-major `evec`, since the eigensolver returns them
2102 // in ascending order.
2103 let mut d = vec![0.0; n];
2104 for (i, di) in d.iter_mut().enumerate() {
2105 *di = (0..n_dof).map(|k| z[k * n + i] * evec[k]).sum();
2106 }
2107 Some(d)
2108}
2109
2110/// Iteration budget for one step QP.
2111///
2112/// `pounce-qp`'s default is a flat 200, the interior-point budget, and the
2113/// wrong shape for an active-set method, which needs about one iteration per
2114/// working-set change: a cold first QP on a model with thousands of active
2115/// bounds cannot finish inside it, and reports `QpIterationLimit` on a
2116/// subproblem it was solving correctly. Scaled exactly as
2117/// `pounce_convex::active_set` scales the direct driver's (10 per variable
2118/// and row, floor 200, ceiling 200 000), and like there an explicitly set
2119/// `sqp_qp_max_iter` — anything but the default — wins.
2120fn step_qp_iter_budget(current: u32, n: usize, m: usize) -> u32 {
2121 const DEFAULT_MAX_ITER: u32 = 200;
2122 const PER_DIM: usize = 10;
2123 if current != DEFAULT_MAX_ITER {
2124 return current;
2125 }
2126 n.saturating_add(m)
2127 .saturating_mul(PER_DIM)
2128 .clamp(200, 200_000) as u32
2129}
2130
2131/// Solve the step QP `data` poses, warm-started from `warm` (in the NLP's
2132/// dimensions) when given, and return the solution in the NLP's dimensions.
2133///
2134/// The one place the lifted low-rank form (gh#1054) meets the solver: the
2135/// working set goes in lifted and the solution comes out unlifted, so the
2136/// rest of the loop never sees the extra variables. On an unlifted
2137/// subproblem both maps are the identity.
2138///
2139/// Each solve is logged at debug level under `pounce::sqp::qp` with its
2140/// outcome, working-set changes, refactorizations, Schur updates, rank tests
2141/// and wall time (gh#1061). A QP that takes minutes is otherwise one opaque
2142/// number, and the remedies differ: many working-set changes say the working
2143/// set moved a lot, a refactor count says the Schur path kept resetting, and
2144/// a rank-test count says it spent its time pruning dependent rows.
2145///
2146/// The wall time is split as well: factorization, back-solves (with their
2147/// count), rank tests, and `other` — everything outside the linear solver,
2148/// which is KKT assembly, the ratio and drop tests and the SMW correction's
2149/// dense arithmetic. Counts say what the QP did; the split says which of it
2150/// cost the time. It was a large `other` with a modest `factor` that would
2151/// have named the Schur path's `O(m·nnz(A))` reset assembly at once (gh#1061:
2152/// 2.2 s per Schur-path refactorization against 0.57 s off it).
2153fn solve_step_qp(
2154 solver: &mut ParametricActiveSetSolver,
2155 data: &SqpQpData,
2156 warm: Option<&WorkingSet>,
2157 opts: &QpOptions,
2158 seed: Option<&IpmSeed>,
2159) -> Result<pounce_qp::QpSolution, pounce_qp::QpError> {
2160 solve_subproblem_qp(solver, data, data.as_qp(), warm, opts, seed, "step QP")
2161}
2162
2163/// [`solve_step_qp`] for a QP built on `data` with other row bounds — the
2164/// second-order-correction QP, which re-centres them on the trial point —
2165/// logged as `kind`.
2166///
2167/// The correction QP used to call the solver directly, so it had none of
2168/// the step QP's handling: no seeding for a warm start that pins infeasible,
2169/// and no log line. gh#1061: on the reporter's model the SQP reached
2170/// iteration 2, rejected the full step, and the correction QP sat in the
2171/// elastic phase for the rest of the wall budget (585 pivots in 345 s,
2172/// objective frozen) without a line in the log. Solved alone, the same QP
2173/// is optimal from the interior point in 32 s.
2174///
2175/// `seed` is `Some` where step QPs are seeded from the interior point (see
2176/// [`SqpOptions::qp_ipm_cold_start_min_dim`]). There a warm start that pins
2177/// feasibly is also given a budget: [`WARM_ITERS_PER_SEED_ITER`] active-set
2178/// iterations per iteration the last seed took. A warm solve that would need
2179/// more is costing more than seeding it, and is seeded instead.
2180pub(crate) fn solve_subproblem_qp(
2181 solver: &mut ParametricActiveSetSolver,
2182 data: &SqpQpData,
2183 qp: pounce_qp::QpProblem<'_>,
2184 warm: Option<&WorkingSet>,
2185 opts: &QpOptions,
2186 seed: Option<&IpmSeed>,
2187 kind: &str,
2188) -> Result<pounce_qp::QpSolution, pounce_qp::QpError> {
2189 let started = std::time::Instant::now();
2190 let guards_before = solver.rank_guard_calls();
2191 let split_before = solver.linsol_time_split();
2192 let seeded = match (warm, seed) {
2193 (None, Some(seed)) => ipm_seeded_step_qp(solver, &qp, data.hess_inf, opts, seed),
2194 _ => None,
2195 };
2196 // gh#1061: warm step QPs on the reporter's model needed 68–350
2197 // working-set changes and 51–263 s each, at about one factorization per
2198 // change, where the seed takes 19–22 interior-point iterations and
2199 // 15–20 s and finishes in 0–2 changes. Counted in iterations rather than
2200 // seconds, so the same model takes the same path on every machine.
2201 let budget = seed
2202 .and_then(|s| s.last_iters.get())
2203 .map(|k| (k * WARM_ITERS_PER_SEED_ITER) as u32)
2204 .filter(|&b| b < opts.max_iter);
2205 let budget_opts = budget.map(|b| QpOptions {
2206 max_iter: b,
2207 ..opts.clone()
2208 });
2209 let sol = match (seeded, warm) {
2210 (Some(sol), _) => Ok(sol),
2211 // A warm start that pins to an infeasible point would go to the
2212 // ℓ1-elastic phase 1, which at this size is the cold start's cost
2213 // again. gh#1061's second step QP (warm from the seeded first one,
2214 // feasible: IPOPT 128.990447) reached the 300 s cap there after 564
2215 // working-set changes; seeded like a cold QP it is optimal in 12.5 s
2216 // with 2. The elastic path stays for when the seed declines.
2217 (None, Some(w)) if seed.is_some() => {
2218 let seed = seed.expect("guarded");
2219 let lifted = data.lift_working_set(w);
2220 let warm_opts = budget_opts.as_ref().unwrap_or(opts);
2221 match solver.solve_with_working_set_if_feasible(&qp, &lifted, warm_opts) {
2222 // Over budget: seed, or if the seed declines, carry on from
2223 // where the warm solve stopped with the full budget.
2224 Ok(Some(sol)) if budget.is_some() && sol.status == QpStatus::MaxIter => {
2225 tracing::debug!(target: "pounce::sqp::qp",
2226 "warm start over its budget of {} iterations (n={}, m={}, {} working-set \
2227 changes in {:.3?}); seeding from the interior point",
2228 warm_opts.max_iter, qp.n, qp.m, sol.stats.n_working_set_changes,
2229 started.elapsed());
2230 match ipm_seeded_step_qp(solver, &qp, data.hess_inf, opts, seed) {
2231 Some(seeded) => Ok(seeded),
2232 None => solver.solve_with_working_set(&qp, &sol.working, opts),
2233 }
2234 }
2235 Ok(Some(sol)) => Ok(sol),
2236 Ok(None) => {
2237 tracing::debug!(target: "pounce::sqp::qp",
2238 "warm start pins infeasible (n={}, m={}); seeding from the interior point",
2239 qp.n, qp.m);
2240 match ipm_seeded_step_qp(solver, &qp, data.hess_inf, opts, seed) {
2241 Some(sol) => Ok(sol),
2242 None => solver.solve_with_working_set(&qp, &lifted, opts),
2243 }
2244 }
2245 Err(e) => Err(e),
2246 }
2247 }
2248 (None, Some(w)) => solver.solve_with_working_set(&qp, &data.lift_working_set(w), opts),
2249 (None, None) => solver.solve(&qp, None, opts),
2250 };
2251 let elapsed = started.elapsed();
2252 let rank_guard_calls = solver.rank_guard_calls() - guards_before;
2253 let t = solver.linsol_time_split().since(&split_before);
2254 let other = elapsed.saturating_sub(t.factor + t.resolve + t.rank_test);
2255 match &sol {
2256 Ok(s) => tracing::debug!(target: "pounce::sqp::qp",
2257 "{kind} (n={}, m={}, warm={}): {} in {:.3?}, {} working-set changes, \
2258 {} refactors, {} Schur updates, {} rank tests, phase1={}; \
2259 time: {} factorizations {:.3?}, {} back-solves {:.3?}, rank tests {:.3?}, other {:.3?}",
2260 qp.n, qp.m, warm.is_some(), s.status, elapsed,
2261 s.stats.n_working_set_changes, s.stats.n_refactor, s.stats.n_schur_updates,
2262 rank_guard_calls, s.stats.used_phase1,
2263 t.n_factor, t.factor, t.n_resolve, t.resolve, t.rank_test, other),
2264 Err(e) => tracing::debug!(target: "pounce::sqp::qp",
2265 "{kind} (n={}, m={}, warm={}): error {e:?} after {:.3?}, {} rank tests; \
2266 time: {} factorizations {:.3?}, {} back-solves {:.3?}, rank tests {:.3?}, other {:.3?}",
2267 qp.n, qp.m, warm.is_some(), elapsed, rank_guard_calls,
2268 t.n_factor, t.factor, t.n_resolve, t.resolve, t.rank_test, other),
2269 }
2270 Ok(data.unlift_solution(sol?))
2271}
2272
2273/// A step QP solved by the convex interior point and finished by the
2274/// active-set solver from the working set that solution identifies, or `None`
2275/// to fall back to the active-set path as before (see
2276/// [`SqpOptions::qp_ipm_cold_start_min_dim`]). Used for a cold QP, and for a
2277/// warm one whose working set pins to an infeasible point.
2278///
2279/// `None` unless every stage succeeds: the Hessian is certified PSD (an
2280/// interior point is not a method for an indefinite QP), the interior point
2281/// reaches `Optimal` (an infeasible QP goes to the active set's elastic
2282/// phase, whose step the SQP takes — see `elastic_step`), and the warm
2283/// active-set solve reaches `Optimal` too. The active-set solution is what
2284/// comes back, so the step, its multipliers and its working set are the
2285/// active-set method's own; the interior point only chose where it starts.
2286fn ipm_seeded_step_qp(
2287 solver: &mut ParametricActiveSetSolver,
2288 qp: &pounce_qp::QpProblem,
2289 hess_inf: Number,
2290 opts: &QpOptions,
2291 seed: &IpmSeed,
2292) -> Option<pounce_qp::QpSolution> {
2293 use pounce_convex::Triplet as T;
2294 let started = std::time::Instant::now();
2295 let (n, m) = (qp.n, qp.m);
2296 let feral = || {
2297 Box::new(pounce_feral::FeralSolverInterface::new())
2298 as Box<dyn pounce_linsol::SparseSymLinearSolverInterface>
2299 };
2300 let p_lower: Vec<T> = (0..qp.h.irows().len())
2301 .map(|k| {
2302 let (i, j) = (qp.h.irows()[k] as usize - 1, qp.h.jcols()[k] as usize - 1);
2303 T::new(i.max(j), i.min(j), qp.h.values()[k])
2304 })
2305 .collect();
2306 if qp.hessian_inertia != HessianInertia::Psd {
2307 // Absolute, as the certificate's contract is, at the scale of `H`.
2308 let tol = 1e-9 * (1.0 + hess_inf);
2309 if pounce_convex::certify_psd_lower_triangle(n, &p_lower, tol, feral) != Ok(true) {
2310 return None;
2311 }
2312 }
2313 let mut rows: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
2314 for k in 0..qp.a.irows().len() {
2315 let (i, j) = (qp.a.irows()[k] as usize - 1, qp.a.jcols()[k] as usize - 1);
2316 rows[i].push((j, qp.a.values()[k]));
2317 }
2318 let finite_lo = |v: Number| v > NLP_LOWER_BOUND_INF;
2319 let finite_hi = |v: Number| v < NLP_UPPER_BOUND_INF;
2320 let (mut a, mut b, mut g, mut h) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
2321 for (i, row) in rows.iter().enumerate() {
2322 if qp.bl[i] == qp.bu[i] {
2323 a.extend(row.iter().map(|&(j, v)| T::new(b.len(), j, v)));
2324 b.push(qp.bl[i]);
2325 continue;
2326 }
2327 if finite_hi(qp.bu[i]) {
2328 g.extend(row.iter().map(|&(j, v)| T::new(h.len(), j, v)));
2329 h.push(qp.bu[i]);
2330 }
2331 if finite_lo(qp.bl[i]) {
2332 g.extend(row.iter().map(|&(j, v)| T::new(h.len(), j, -v)));
2333 h.push(-qp.bl[i]);
2334 }
2335 }
2336 let prob = pounce_convex::QpProblem {
2337 n,
2338 p_lower,
2339 c: qp.g.to_vec(),
2340 a,
2341 b,
2342 g,
2343 h,
2344 lb: qp
2345 .xl
2346 .iter()
2347 .map(|&v| {
2348 if finite_lo(v) {
2349 v
2350 } else {
2351 pounce_convex::NEG_INF
2352 }
2353 })
2354 .collect(),
2355 ub: qp
2356 .xu
2357 .iter()
2358 .map(|&v| {
2359 if finite_hi(v) {
2360 v
2361 } else {
2362 pounce_convex::POS_INF
2363 }
2364 })
2365 .collect(),
2366 };
2367 let ipm_opts = pounce_convex::QpOptions {
2368 time_limit: opts.time_limit,
2369 max_iter: IPM_SEED_MAX_ITER,
2370 ..pounce_convex::QpOptions::default()
2371 };
2372 let ipm = pounce_convex::solve_qp_ipm(&prob, &ipm_opts, feral);
2373 let ipm_time = started.elapsed();
2374 if ipm.status == pounce_convex::QpStatus::Optimal {
2375 seed.last_iters.set(Some(ipm.iters));
2376 }
2377 if ipm.status != pounce_convex::QpStatus::Optimal {
2378 tracing::debug!(target: "pounce::sqp::qp",
2379 "IPM cold start (n={n}, m={m}): {:?} after {} iterations in {ipm_time:.3?}; \
2380 solving from cold instead", ipm.status, ipm.iters);
2381 return None;
2382 }
2383 // The working set the interior point's answer sits on: every row and
2384 // bound within a scaled `IPM_ACTIVE_TOL` of a side it can be active at.
2385 // A wrong guess either way costs the warm solve pivots, not correctness.
2386 let near = |v: Number, bound: Number| (v - bound).abs() <= IPM_ACTIVE_TOL * (1.0 + bound.abs());
2387 let ax = mat_vec_gen(qp.a, &ipm.x, m);
2388 let mut working = WorkingSet::cold(n, m);
2389 for i in 0..m {
2390 working.constraints[i] = if qp.bl[i] == qp.bu[i] {
2391 ConsStatus::Equality
2392 } else if finite_lo(qp.bl[i]) && near(ax[i], qp.bl[i]) {
2393 ConsStatus::AtLower
2394 } else if finite_hi(qp.bu[i]) && near(ax[i], qp.bu[i]) {
2395 ConsStatus::AtUpper
2396 } else {
2397 ConsStatus::Inactive
2398 };
2399 }
2400 for j in 0..n {
2401 working.bounds[j] = if qp.xl[j] == qp.xu[j] {
2402 BoundStatus::Fixed
2403 } else if finite_lo(qp.xl[j]) && near(ipm.x[j], qp.xl[j]) {
2404 BoundStatus::AtLower
2405 } else if finite_hi(qp.xu[j]) && near(ipm.x[j], qp.xu[j]) {
2406 BoundStatus::AtUpper
2407 } else {
2408 BoundStatus::Inactive
2409 };
2410 }
2411 let warm_opts = QpOptions {
2412 time_limit: opts.time_limit.map(|t| t.saturating_sub(started.elapsed())),
2413 ..opts.clone()
2414 };
2415 let sol = solver
2416 .solve_with_working_set(qp, &working, &warm_opts)
2417 .ok()?;
2418 tracing::debug!(target: "pounce::sqp::qp",
2419 "IPM cold start (n={n}, m={m}): optimal after {} iterations in {ipm_time:.3?}, \
2420 {} rows and bounds active; warm active-set solve {} in {:.3?} with {} \
2421 working-set changes", ipm.iters, working.active_count(), sol.status,
2422 sol.stats.time, sol.stats.n_working_set_changes);
2423 (sol.status == QpStatus::Optimal).then_some(sol)
2424}
2425
2426/// Iteration cap on the seeding interior point, against its own default of
2427/// 200. gh#1061's step QPs took 16 and 19; a QP the interior point cannot
2428/// solve in a few times that goes back to the active-set path rather than
2429/// spending the budget here. Measured on a borderline-feasible emulation of
2430/// the second step QP, the uncapped interior point ran 57 iterations to a
2431/// 300 s limit.
2432const IPM_SEED_MAX_ITER: usize = 50;
2433
2434/// Active-set iterations a warm-started subproblem QP may take, per
2435/// interior-point iteration the last seed took, before it is seeded instead
2436/// (see [`solve_subproblem_qp`]). An active-set iteration on gh#1061's model
2437/// is one factorization or one back-solve, and a working-set change about
2438/// two of them; an interior-point iteration, with the certificate and the
2439/// warm finish spread over it, about 0.8 s against 0.44 s per active-set
2440/// factorization. Four per seed iteration therefore lets a warm solve spend
2441/// about what a seed costs (80 iterations, ~40 changes, at the seed's 20)
2442/// before it gives up, so a warm start that is near its answer, as most are,
2443/// keeps it, and one that is not costs at most about twice the seed.
2444const WARM_ITERS_PER_SEED_ITER: usize = 4;
2445
2446/// Relative activity tolerance for reading a working set off an
2447/// interior-point solution in [`ipm_seeded_step_qp`]. The interior point stops
2448/// at a complementarity of order its `tol`, so an active row sits within a few
2449/// orders of that and an inactive one, at a nondegenerate solution, well
2450/// outside it.
2451const IPM_ACTIVE_TOL: Number = 1e-6;
2452
2453/// A subproblem QP's time limit: what is left of the solve's `deadline` at
2454/// this moment, or the QP's `own` limit if that is tighter.
2455fn qp_time_limit_now(
2456 deadline: Option<&pounce_common::timing::Deadline>,
2457 own: Option<std::time::Duration>,
2458) -> Option<std::time::Duration> {
2459 match (deadline.map(deadline_remaining), own) {
2460 (Some((left, _)), Some(own)) => Some(left.min(own)),
2461 (Some((left, _)), None) => Some(left),
2462 (None, own) => own,
2463 }
2464}
2465
2466/// What is left of `deadline`, as the wall-clock duration a step QP can take
2467/// as its `time_limit`, with the budget that set it (gh#1054).
2468///
2469/// The QP reads only a wall clock, so the CPU half is converted one-for-one:
2470/// a busy solve accrues CPU time at about the wall rate, so its remaining CPU
2471/// seconds are the best wall estimate the QP can be given. The tighter of the
2472/// two wins.
2473pub(crate) fn deadline_remaining(
2474 deadline: &pounce_common::timing::Deadline,
2475) -> (std::time::Duration, pounce_common::timing::DeadlineKind) {
2476 use pounce_common::timing::DeadlineKind;
2477 let wall = deadline.remaining_wall();
2478 let cpu = deadline.remaining_cpu();
2479 let (left, kind) = if cpu < wall {
2480 (cpu, DeadlineKind::Cpu)
2481 } else {
2482 (wall, DeadlineKind::Wall)
2483 };
2484 // `from_secs_f64` panics on a negative, non-finite or out-of-range
2485 // argument, and the registered "unlimited" default is `1e20` s, past
2486 // `Duration`'s range. A century is unlimited for this purpose.
2487 const UNLIMITED_S: Number = 3.2e9;
2488 let left = if left.is_finite() {
2489 left.clamp(0.0, UNLIMITED_S)
2490 } else {
2491 UNLIMITED_S
2492 };
2493 (std::time::Duration::from_secs_f64(left), kind)
2494}
2495
2496pub(crate) fn check_kkt(
2497 n: usize,
2498 m: usize,
2499 iter: &SqpIterates,
2500 grad_f: &[Number],
2501 c_vals: &[Number],
2502 bl_c: &[Number],
2503 bu_c: &[Number],
2504 xl: &[Number],
2505 xu: &[Number],
2506 jac_c: &crate::sqp::qp_assembly::Triplet,
2507) -> KktError {
2508 // Constraint violation: max(0, bl - c, c - bu) on every row,
2509 // plus bound violation on every variable.
2510 //
2511 // `NaN` propagates rather than reducing away (gh #876). `f64::max`
2512 // ignores `NaN`, so `(bl - c).max(0.0)` on a non-finite `c` is `0.0` and
2513 // a diverged iterate scores as perfectly feasible. The same reduction on
2514 // the stationarity rows below is the site the issue reports; both are
2515 // fixed, because either one alone still lets `check_kkt` return a clean
2516 // `KktError` from garbage.
2517 let mut viol = 0.0_f64;
2518 let mut nonfinite = false;
2519 for i in 0..m {
2520 let lo = if bl_c[i] > NLP_LOWER_BOUND_INF {
2521 (bl_c[i] - c_vals[i]).max(0.0)
2522 } else {
2523 0.0
2524 };
2525 let hi = if bu_c[i] < NLP_UPPER_BOUND_INF {
2526 (c_vals[i] - bu_c[i]).max(0.0)
2527 } else {
2528 0.0
2529 };
2530 nonfinite |= !c_vals[i].is_finite();
2531 viol = viol.max(lo).max(hi);
2532 }
2533 for i in 0..n {
2534 nonfinite |= !iter.x[i].is_finite();
2535 let lo = if xl[i] > NLP_LOWER_BOUND_INF {
2536 (xl[i] - iter.x[i]).max(0.0)
2537 } else {
2538 0.0
2539 };
2540 let hi = if xu[i] < NLP_UPPER_BOUND_INF {
2541 (iter.x[i] - xu[i]).max(0.0)
2542 } else {
2543 0.0
2544 };
2545 viol = viol.max(lo).max(hi);
2546 }
2547
2548 // Stationarity: ∇f + Jᵀ λ_g − λ_x. pounce-qp's KKT is
2549 // `Hx + Aᵀλ_qp + (lower-bound multiplier) e_i − (upper-bound
2550 // multiplier) e_i = -g`. Since `λ_x = z_l − z_u` packs the
2551 // bound-multiplier sign, the variable-bound term enters the
2552 // stationarity check with a negative sign — i.e. at the
2553 // optimum `∇f + Jᵀ λ_g = λ_x`.
2554 let mut stat = vec![0.0; n];
2555 for (s, &g) in stat.iter_mut().zip(grad_f.iter()) {
2556 *s = g;
2557 }
2558 // Add Jᵀ λ_g
2559 for k in 0..jac_c.irow.len() {
2560 let i = (jac_c.irow[k] - 1) as usize; // 0-based row in c
2561 let j = (jac_c.jcol[k] - 1) as usize; // 0-based col in x
2562 stat[j] += jac_c.vals[k] * iter.lambda_g[i];
2563 }
2564 // Subtract λ_x
2565 for (s, &lx) in stat.iter_mut().zip(iter.lambda_x.iter()) {
2566 *s -= lx;
2567 }
2568 let stat_max = crate::sqp::line_search::inf_norm(&stat);
2569
2570 // An iterate or a constraint value that is not finite makes every residual
2571 // computed from it meaningless, whichever way the arithmetic happens to
2572 // reduce: `+inf - inf` is `NaN`, but `(bl - inf).max(0.0)` is a tidy `0.0`
2573 // and `inf.abs()` is a large number that at least fails `<= tol`. Reporting
2574 // `NaN` for both is the honest answer and the one the caller's `<= tol`
2575 // gates already handle correctly.
2576 if nonfinite {
2577 return KktError {
2578 stationarity: Number::NAN,
2579 constr_viol: Number::NAN,
2580 };
2581 }
2582
2583 KktError {
2584 stationarity: stat_max,
2585 constr_viol: viol,
2586 }
2587}
2588
2589#[cfg(test)]
2590mod kkt_nan_tests {
2591 //! gh #876 — `check_kkt` must not launder a non-finite iterate into a
2592 //! clean `KktError`.
2593 //!
2594 //! The caller's convergence gate is
2595 //! `kkt.stationarity <= stationarity_tol && kkt.constr_viol <= constr_viol_tol`,
2596 //! so anything `check_kkt` reports as `0.0` is reported as converged. Both
2597 //! of its reductions used to swallow `NaN`:
2598 //!
2599 //! * `stat.iter().map(|s| s.abs()).fold(0.0_f64, f64::max)` — `f64::max`
2600 //! is defined to *ignore* `NaN`, so an all-`NaN` stationarity vector
2601 //! reduced to `0.0`. This is the site gh #876 reports, and the third
2602 //! instance of the same shape in this workspace: gh #222 fixed it in
2603 //! `pounce-convex`, gh #845 in `pounce-sensitivity`.
2604 //! * `viol.max(lo).max(hi)` where `lo = (bl - c).max(0.0)` — the same
2605 //! definition one layer earlier, so a `NaN` constraint value scored as
2606 //! *perfectly feasible* before the outer `max` ever saw it. The issue
2607 //! does not name this one; fixing only the reported site would still
2608 //! have left `constr_viol` reading `0.0` on a diverged iterate.
2609 //!
2610 //! ## Which branch each test reaches
2611 //!
2612 //! Per this repo's gh #756 lesson, a guard is only evidence about the
2613 //! branch its fixture reaches, so the four tests below are chosen to land
2614 //! in four different places: a `NaN` arriving through `c_vals`, one
2615 //! arriving through `x`, one arriving through the *duals* (where `x` and
2616 //! `c` are both finite and only the stationarity row is poisoned — the
2617 //! `inf_norm` fix, not the `nonfinite` flag), and an ordinary finite
2618 //! iterate that must be completely unaffected.
2619 //!
2620 //! ## Mutation table
2621 //!
2622 //! | revert | red |
2623 //! |---|---|
2624 //! | `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` |
2625 //! | drop the `nonfinite` flag | `a_nan_constraint_value_is_not_perfect_feasibility`, `a_nan_iterate_is_not_perfect_feasibility` |
2626 //! | both | all four of the above |
2627 //!
2628 //! Measured, both directions: each mutation turns exactly the listed
2629 //! tests red and leaves the others — including
2630 //! `a_finite_iterate_is_measured_exactly_as_before` — green. Neither half
2631 //! subsumes the other, which is why both are here.
2632
2633 use super::*;
2634 use crate::sqp::qp_assembly::Triplet;
2635 use pounce_common::types::Index;
2636
2637 /// `min x0` s.t. one row `c(x) = x0`, no bounds — the smallest shape that
2638 /// exercises every accumulation in `check_kkt`.
2639 fn setup(x: Vec<Number>, c: Vec<Number>, lam_g: Vec<Number>) -> KktError {
2640 let n = x.len();
2641 let m = c.len();
2642 let iter = SqpIterates {
2643 x,
2644 lambda_g: lam_g,
2645 lambda_x: vec![0.0; n],
2646 working: None,
2647 };
2648 let jac = Triplet {
2649 n_rows: m,
2650 n_cols: n,
2651 irow: (1..=m as i32).map(|i| i as Index).collect(),
2652 jcol: vec![1 as Index; m],
2653 vals: vec![1.0; m],
2654 };
2655 check_kkt(
2656 n,
2657 m,
2658 &iter,
2659 &vec![1.0; n],
2660 &c,
2661 &vec![0.0; m],
2662 &vec![0.0; m],
2663 &vec![NLP_LOWER_BOUND_INF; n],
2664 &vec![NLP_UPPER_BOUND_INF; n],
2665 &jac,
2666 )
2667 }
2668
2669 #[test]
2670 fn a_finite_iterate_is_measured_exactly_as_before() {
2671 // x0 = 2 against the equality row c = x0 = 0: violation 2, and
2672 // stationarity ∇f + Jᵀλ = 1 + 1·(-1) = 0.
2673 let k = setup(vec![2.0], vec![2.0], vec![-1.0]);
2674 assert_eq!(k.constr_viol, 2.0);
2675 assert_eq!(k.stationarity, 0.0);
2676 }
2677
2678 #[test]
2679 fn a_nan_constraint_value_is_not_perfect_feasibility() {
2680 let k = setup(vec![0.0], vec![Number::NAN], vec![-1.0]);
2681 assert!(
2682 k.constr_viol.is_nan(),
2683 "a NaN constraint value reported constr_viol = {}, which clears \
2684 every tolerance the caller compares it against",
2685 k.constr_viol
2686 );
2687 assert!(k.stationarity.is_nan());
2688 }
2689
2690 #[test]
2691 fn a_nan_iterate_is_not_perfect_feasibility() {
2692 let k = setup(vec![Number::NAN], vec![0.0], vec![-1.0]);
2693 assert!(k.constr_viol.is_nan());
2694 assert!(k.stationarity.is_nan());
2695 }
2696
2697 /// The dual-side branch: `x` and `c` are both finite, so the `nonfinite`
2698 /// flag stays false and nothing but `inf_norm` stands between a `NaN`
2699 /// multiplier and a stationarity residual of `0.0`. This is the test that
2700 /// the `nonfinite` flag alone does **not** cover.
2701 #[test]
2702 fn a_nan_dual_poisons_only_stationarity() {
2703 let k = setup(vec![0.0], vec![0.0], vec![Number::NAN]);
2704 assert!(
2705 k.stationarity.is_nan(),
2706 "a NaN multiplier reduced to stationarity = {}",
2707 k.stationarity
2708 );
2709 // Feasibility genuinely holds at this x, and saying so is correct —
2710 // the flag is about the *primal* iterate, not about the duals.
2711 assert_eq!(k.constr_viol, 0.0);
2712 }
2713}
2714
2715#[cfg(test)]
2716mod inf_norm_tests {
2717 use crate::sqp::line_search::inf_norm;
2718
2719 #[test]
2720 fn a_nan_entry_propagates_rather_than_reducing_away() {
2721 assert!(inf_norm(&[1.0, f64::NAN, 2.0]).is_nan());
2722 assert!(inf_norm(&[f64::NAN]).is_nan());
2723 }
2724
2725 #[test]
2726 fn finite_vectors_are_unchanged() {
2727 assert_eq!(inf_norm(&[]), 0.0);
2728 assert_eq!(inf_norm(&[-3.0, 1.0, 2.0]), 3.0);
2729 assert_eq!(inf_norm(&[f64::INFINITY, 1.0]), f64::INFINITY);
2730 }
2731}