pounce_qp/solver.rs
1//! The [`QpSolver`] trait and its concrete implementation
2//! [`ParametricActiveSetSolver`].
3//!
4//! Phase 5a commit 2 ships the cold-start equality-only path: KKT
5//! assembly via [`crate::kkt`] + one factor-and-solve through a
6//! caller-provided linear-solver backend. Working-set machinery,
7//! Schur-complement updates, EXPAND anti-cycling, l1-elastic
8//! phase-1, and the parametric homotopy land in subsequent commits.
9
10use std::time::Instant;
11
12use crate::error::{QpError, QpStatus};
13use crate::factor::LinearSolver;
14use crate::kkt::{
15 KktTriplet, a_times_x, assemble_active_set_kkt, assemble_box_with_active,
16 assemble_equality_plus_bounds, h_times_x, is_all_equality_constraints, is_pure_box,
17 is_pure_equality_no_bounds, rhs_equality_only,
18};
19use crate::negcurv::SecondOrder;
20use crate::options::{AntiCyclingChoice, QpOptions};
21use crate::problem::{
22 HessianInertia, ParametricSource, QpProblem, QpSolution, QpStats, QpWarmStart,
23 SecondOrderVerdict,
24};
25use crate::working_set::{BoundStatus, ConsStatus, WorkingSet};
26use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
27use pounce_common::{Index, Number};
28use pounce_linalg::triplet::{SymTMatrix, SymTMatrixSpace};
29use pounce_linsol::SparseSymLinearSolverInterface;
30use pounce_linsol::status::ESymSolverStatus;
31
32/// Re-pin rounds [`ParametricActiveSetSolver::repair_pinned_hint`] will spend
33/// on an infeasible warm-start primal before giving up on it. Each round adds
34/// the rows the current pinned point violates and re-factors, so the cost of a
35/// failed repair is bounded by this many pinned-KKT factorizations. One round
36/// suffices for the parametric case the repair targets (a hint whose active
37/// set has drifted by a few entries); the extra rounds cover a re-pin that
38/// exposes a second row behind the first.
39const PIN_REPAIR_MAX_ROUNDS: usize = 3;
40
41/// Violated rows [`ParametricActiveSetSolver::repair_pinned_hint`] will always
42/// try to re-pin, however small the hint's active set. Beyond this floor the
43/// budget scales with the active set (a quarter of it): a hint wrong in a few
44/// entries is worth repairing, one wrong in a large fraction of its rows is
45/// the badly-wrong hint that l1-elastic phase-1 exists for.
46const PIN_REPAIR_MIN_ROWS: usize = 4;
47
48/// QP subproblem solver.
49///
50/// Two entry points: [`solve`](Self::solve) for a single QP with an
51/// optional warm-start seed, and [`solve_parametric`](Self::solve_parametric)
52/// for the SQP outer-loop case where the new QP is a perturbation of
53/// the previous one and the parametric homotopy of §4.2 can reuse
54/// the cached factorization across consecutive QPs without
55/// rebuilding it.
56pub trait QpSolver {
57 /// Solve a single QP. `ws == None` ⇒ cold start (phase-1
58 /// elastic mode infers the initial working set when the
59 /// machinery lands).
60 fn solve(
61 &mut self,
62 qp: &QpProblem,
63 ws: Option<&QpWarmStart>,
64 opts: &QpOptions,
65 ) -> Result<QpSolution, QpError>;
66
67 /// Parametric solve: trace the homotopy from `(qp_prev,
68 /// sol_prev)` to `qp_new`.
69 ///
70 /// The path interpolates `g` and the row bounds, and is traced when the
71 /// two problems have the same shape and a bit-identical `H`. A pair
72 /// that fails that — or a previous solve that did not reach
73 /// [`QpStatus::Optimal`], or a path the tracer cannot complete — falls
74 /// back to [`solve_with_working_set`](Self::solve_with_working_set) on
75 /// `sol_prev`'s working set, and to a cold [`solve`](Self::solve) when
76 /// that working set is unusable too. So handing over a previous solve
77 /// that turns out ineligible costs nothing beyond the cold solve the
78 /// caller would have done anyway.
79 ///
80 /// **`A` and `xl`/`xu` are not interpolated and not guarded on.** A pair
81 /// differing in either is still traced, and the path then extrapolates
82 /// about a point that is not on the previous problem's solution
83 /// manifold. The result stays correct — the path is a predictor and the
84 /// corrector re-solves — but the active-set prediction degrades, and how
85 /// much is not predictable from the size of the change. Callers wanting
86 /// the path to model what it is given should keep `A` and the variable
87 /// bounds fixed and vary `g` and the row bounds, which is the parametric
88 /// family this entry point is for. See gh #602.
89 fn solve_parametric(
90 &mut self,
91 qp_prev: &QpProblem,
92 sol_prev: &QpSolution,
93 qp_new: &QpProblem,
94 opts: &QpOptions,
95 ) -> Result<QpSolution, QpError>;
96
97 /// Warm-start variant that takes ONLY the working set from a
98 /// previous solve (not a primal `x`). Useful when the caller
99 /// — e.g., the SQP outer loop — has a previous QP's working
100 /// set but no compatible primal, because the new QP's
101 /// constraint RHS has shifted (each SQP linearization
102 /// translates `bl ≤ Ax ≤ bu` by `-c(x_k)`).
103 ///
104 /// Internally: build the KKT for the active rows of
105 /// `working` and solve for a primal that exactly satisfies
106 /// those rows. Pass that primal plus the supplied working
107 /// set as a regular `QpWarmStart` to
108 /// [`Self::solve`].
109 ///
110 /// Returns the same `QpSolution` shape as
111 /// [`Self::solve`].
112 fn solve_with_working_set(
113 &mut self,
114 qp: &QpProblem,
115 working: &crate::working_set::WorkingSet,
116 opts: &QpOptions,
117 ) -> Result<QpSolution, QpError>;
118}
119
120/// The sparse parametric active-set QP solver (§4.2 of the design
121/// note). Owns a single linear-solver backend; future Schur-
122/// complement state lives here too.
123pub struct ParametricActiveSetSolver {
124 /// Crate-visible so sibling modules — notably [`crate::homotopy`] — can
125 /// reuse the rank-repair helpers, which take the shared linear-solver
126 /// backend rather than owning one.
127 pub(crate) linsol: LinearSolver,
128}
129
130impl ParametricActiveSetSolver {
131 pub fn new(backend: Box<dyn SparseSymLinearSolverInterface>) -> Self {
132 Self {
133 linsol: LinearSolver::new(backend),
134 }
135 }
136
137 /// §4.5 inertia-controlled factorization. Tries the factor
138 /// without shift first; on `WrongInertia` or `Singular`, shifts
139 /// the H-block diagonal by progressively larger δ and re-tries.
140 /// Returns the final δ used (0.0 when no shift was needed) for
141 /// logging / diagnostics.
142 ///
143 /// `expected_neg` is required (no bypass) so the inertia signal
144 /// is always checked. The `HessianInertia::Indefinite` hint
145 /// merely tells the caller "shifts may be needed"; the
146 /// algorithm decides what to do based on the factor's report.
147 pub(crate) fn factorize_with_inertia_control(
148 &mut self,
149 mut kkt: KktTriplet,
150 rhs: &mut [Number],
151 expected_neg: i32,
152 n_h_rows: usize,
153 opts: &QpOptions,
154 ) -> Result<Number, QpError> {
155 // First attempt: no shift.
156 let rhs_snapshot = rhs.to_vec();
157 let mut rhs_local = rhs_snapshot.clone();
158 match self
159 .linsol
160 .factorize_and_solve(&kkt, &mut rhs_local, Some(expected_neg))
161 {
162 Ok(()) => {
163 rhs.copy_from_slice(&rhs_local);
164 return Ok(0.0);
165 }
166 Err(ref e) if e.is_recoverable_factorization_failure() => {}
167 Err(e) => return Err(e),
168 }
169
170 let mut current = 0.0;
171 let mut next = opts.inertia_shift_initial;
172 for _ in 0..opts.inertia_max_shifts {
173 if crate::deadline::expired() {
174 // Cancellation is an *error*, not a value. `Ok(current)` here
175 // would hand the caller an `rhs` that was never solved — it
176 // still holds `[-g; targets]` — while claiming a shift of
177 // `current` succeeded. `solve_equality_only` reads that back as
178 // `[x*; λ*]`, sees `delta == 0` (so it also skips the
179 // masked-rank-deficiency probe and the recession-ray test), and
180 // returns `x = -g` as `QpStatus::Optimal`; `audit_and_repair`
181 // only checks primal feasibility, so any such point that
182 // happens to satisfy `Ax = b` reaches the user as a certified
183 // optimum. Propagating an error instead makes `?` force every
184 // caller to deal with it, and the entry points below convert it
185 // to the soft `QpStatus::TimeLimit`.
186 return Err(QpError::DeadlineExpired);
187 }
188 kkt.add_h_diagonal_shift(n_h_rows, next - current);
189 current = next;
190 let mut rhs_local = rhs_snapshot.clone();
191 match self
192 .linsol
193 .factorize_and_solve(&kkt, &mut rhs_local, Some(expected_neg))
194 {
195 Ok(()) => {
196 rhs.copy_from_slice(&rhs_local);
197 return Ok(current);
198 }
199 Err(ref e) if e.is_recoverable_factorization_failure() => {
200 next *= opts.inertia_shift_factor;
201 }
202 Err(e) => return Err(e),
203 }
204 }
205 Err(QpError::LinearSolverFailure(format!(
206 "inertia control exhausted {} shifts (final δ = {:.3e}); reduced Hessian \
207 remains non-PD on null(A_W) — consider an `HessianInertia::Indefinite` \
208 problem with no PD reduced direction, or relax `inertia_shift_factor`",
209 opts.inertia_max_shifts, current
210 )))
211 }
212
213 /// Assemble and factor the pinned active-set KKT
214 /// `[H Aᵀ_W Eᵀ_W; A_W 0 0; E_W 0 0]` with right-hand side
215 /// `[-g; cons_targets; bound_targets]`, returning the primal `x`
216 /// (the first `n` entries of the KKT solution). `cons_targets` is
217 /// parallel to `active_cons`, `bound_targets` to `active_bounds`.
218 ///
219 /// Shared by the cold-start equality factor and the warm-start
220 /// `solve_with_working_set` factor; multipliers are recomputed by
221 /// the inner loop, so they are not returned here.
222 fn factor_pinned_primal(
223 &mut self,
224 qp: &QpProblem,
225 active_cons: &[usize],
226 cons_targets: &[Number],
227 active_bounds: &[usize],
228 bound_targets: &[Number],
229 opts: &QpOptions,
230 ) -> Result<Vec<Number>, QpError> {
231 let n = qp.n;
232 let k_c = active_cons.len();
233 let k_b = active_bounds.len();
234 let kkt = assemble_active_set_kkt(qp, active_cons, active_bounds);
235 let mut rhs = vec![0.0; n + k_c + k_b];
236 for (rhs_i, &g_i) in rhs[..n].iter_mut().zip(qp.g.iter()) {
237 *rhs_i = -g_i;
238 }
239 rhs[n..n + k_c].copy_from_slice(cons_targets);
240 rhs[n + k_c..n + k_c + k_b].copy_from_slice(bound_targets);
241 let delta =
242 self.factorize_with_inertia_control(kkt, &mut rhs, (k_c + k_b) as i32, n, opts)?;
243
244 // Masked-rank-deficiency guard. No H-block δ·I shift can repair a
245 // rank-deficient *constraint* block — but a large enough δ can grow
246 // the H diagonal until the backend stops flagging the singular block
247 // and returns a garbage solution instead of a failure (feral masks
248 // the null direction around δ≈1e8). The pinned callers
249 // (`cold_general_initial`, `solve_with_working_set`, and the #313
250 // equality+bounds path) rely on a *reported* recoverable failure to
251 // trigger their linear-independence prune; a masked deficiency slips
252 // past silently and the solve churns to `MaxIter` (or worse, reports a
253 // wrong `Optimal`). A nonzero δ on a pinned KKT is the tell: only then
254 // do we rank-reveal the pinned rows, and if any is redundant we
255 // convert the spurious success into the recoverable failure the
256 // callers already know how to prune. A δ > 0 with a full-rank
257 // constraint block is a legitimate indefinite-reduced-Hessian shift
258 // and passes through untouched (the common case is δ == 0, which skips
259 // the probe entirely).
260 if delta > 0.0 {
261 let (kc, kb) =
262 independent_active_subset(&mut self.linsol, qp, active_cons, active_bounds);
263 if kc.len() < k_c || kb.len() < k_b {
264 return Err(QpError::LinearSolverFailure(
265 "pinned KKT constraint block is rank-deficient (inertia shift masked a \
266 singular constraint block); prune to a linearly-independent subset"
267 .into(),
268 ));
269 }
270 }
271 Ok(rhs[..n].to_vec())
272 }
273
274 /// Pin every active row / bound of `working` to its boundary value and
275 /// factor that KKT for a primal `x`, returning `x` together with the
276 /// working set actually pinned.
277 ///
278 /// If the hint is rank-deficient — a degenerate optimum can pin more
279 /// binding rows than there are variables, and the LP-crossover bridge
280 /// hands over redundant equality rows — the saddle KKT is singular and
281 /// the §4.5 H-shift cannot repair a rank-deficient *constraint* block.
282 /// Linear-independence guard: prune the active set to a maximal
283 /// independent subset, retry once, and return the pruned working set so
284 /// the inner loop starts from a full-rank state. Dropped rows are linear
285 /// combinations of the kept ones, hence satisfied at the recovered primal
286 /// — and they stay `Inactive` in the returned set, since the ratio test
287 /// skips `bl == bu` rows so a dropped equality can never re-enter.
288 fn pin_working_set(
289 &mut self,
290 qp: &QpProblem,
291 working: &WorkingSet,
292 opts: &QpOptions,
293 ) -> Result<(Vec<Number>, WorkingSet), QpError> {
294 let active_cons: Vec<usize> = (0..qp.m)
295 .filter(|&i| working.constraints[i].is_active())
296 .collect();
297 let active_bounds: Vec<usize> = (0..qp.n)
298 .filter(|&i| working.bounds[i].is_active())
299 .collect();
300
301 // The boundary value each active row / bound is pinned to.
302 let cons_target = |i: usize| match working.constraints[i] {
303 ConsStatus::AtLower | ConsStatus::Equality => qp.bl[i],
304 ConsStatus::AtUpper => qp.bu[i],
305 ConsStatus::Inactive => unreachable!(),
306 };
307 let bound_target = |i: usize| match working.bounds[i] {
308 BoundStatus::AtLower | BoundStatus::Fixed => qp.xl[i],
309 BoundStatus::AtUpper => qp.xu[i],
310 BoundStatus::Inactive => unreachable!(),
311 };
312 let cons_targets: Vec<Number> = active_cons.iter().map(|&i| cons_target(i)).collect();
313 let bound_targets: Vec<Number> = active_bounds.iter().map(|&i| bound_target(i)).collect();
314
315 match self.factor_pinned_primal(
316 qp,
317 &active_cons,
318 &cons_targets,
319 &active_bounds,
320 &bound_targets,
321 opts,
322 ) {
323 Ok(x) => Ok((x, working.clone())),
324 Err(e) if e.is_recoverable_factorization_failure() => {
325 let (kc, kb) =
326 independent_active_subset(&mut self.linsol, qp, &active_cons, &active_bounds);
327 if kc.len() == active_cons.len() && kb.len() == active_bounds.len() {
328 // Full rank already — not a deficiency this repairs.
329 return Err(e);
330 }
331 let kc_targets: Vec<Number> = kc.iter().map(|&i| cons_target(i)).collect();
332 let kb_targets: Vec<Number> = kb.iter().map(|&i| bound_target(i)).collect();
333 let x = self.factor_pinned_primal(qp, &kc, &kc_targets, &kb, &kb_targets, opts)?;
334
335 // Forward a pruned working set: dropped active rows /
336 // bounds revert to Inactive. A dropped row has `a·p = 0`
337 // along every active-set step (it lies in the kept rows'
338 // span), so the inner loop never re-adds it and it stays
339 // at its boundary.
340 let mut fwd = working.clone();
341 let mut keep_c = vec![false; qp.m];
342 for &i in &kc {
343 keep_c[i] = true;
344 }
345 let mut keep_b = vec![false; qp.n];
346 for &i in &kb {
347 keep_b[i] = true;
348 }
349 for i in 0..qp.m {
350 if working.constraints[i].is_active() && !keep_c[i] {
351 fwd.constraints[i] = ConsStatus::Inactive;
352 }
353 }
354 for i in 0..qp.n {
355 if working.bounds[i].is_active() && !keep_b[i] {
356 fwd.bounds[i] = BoundStatus::Inactive;
357 }
358 }
359 Ok((x, fwd))
360 }
361 Err(e) => Err(e),
362 }
363 }
364
365 /// Repair a pinned warm-start primal that came out infeasible, rather than
366 /// let `solve`'s admission pre-check discard the whole hint (#428).
367 ///
368 /// When the true active set has moved, the hint still pins a row that
369 /// should have been released, so the pinned primal overshoots some *other*
370 /// row or bound — by roughly the distance the problem moved. The old
371 /// behavior was all-or-nothing: that point failed the admission pre-check
372 /// and the entire working set was thrown away for a cold l1-elastic
373 /// phase-1, whose recovery re-solve starts from `WorkingSet::cold`. A hint
374 /// wrong by *one* entry cost the same as one wrong by hundreds — on a
375 /// parametric MPC sweep, roughly one working-set change per constraint row
376 /// (issue #428: 403 pivots where 2 were needed, and past `m ≈ max_iter` no
377 /// answer at all), while the |A| − 1 entries the hint got *right* were
378 /// exactly its value.
379 ///
380 /// The repair keeps them: the rows the pinned point violates are known, so
381 /// add them to the working set at the boundary they overshot and re-pin.
382 /// The result satisfies both the hint's rows and the violated ones, and
383 /// the inner loop then drops whichever the multiplier signs reject — a
384 /// couple of pivots instead of `m`. Nothing here relaxes a tolerance: the
385 /// admission pre-check keeps its exact meaning and is simply handed a
386 /// feasible point.
387 ///
388 /// Returns `None` — leaving the caller's original hint, hence the old
389 /// elastic recovery — when the hint is not one repair is meant for:
390 ///
391 /// * an *active* row is itself violated (re-pinning cannot help);
392 /// * too many rows are violated relative to the hint's active set, the
393 /// badly-wrong-hint case the pre-check was written for (a degenerate
394 /// NETLIB `gen` crossover vertex violating hundreds of inactive rows);
395 /// * the repaired pin set would exceed `n` rows, hence be necessarily
396 /// rank-deficient. A hint that already pins a full vertex therefore
397 /// needs a *drop* the repair cannot choose without a ratio test, and
398 /// keeps the old path;
399 /// * the re-pin fails to factor, or does not reach feasibility within
400 /// [`PIN_REPAIR_MAX_ROUNDS`].
401 fn repair_pinned_hint(
402 &mut self,
403 qp: &QpProblem,
404 x: &[Number],
405 working: &WorkingSet,
406 opts: &QpOptions,
407 ) -> Option<(Vec<Number>, WorkingSet)> {
408 let mut x_cur = x.to_vec();
409 let mut w_cur = working.clone();
410
411 for _ in 0..PIN_REPAIR_MAX_ROUNDS {
412 let (cons, bounds) = violated_inactive(qp, &x_cur, &w_cur, opts.feas_tol)?;
413 if cons.is_empty() && bounds.is_empty() {
414 return Some((x_cur, w_cur));
415 }
416 let n_violated = cons.len() + bounds.len();
417 let active_total = w_cur.active_count();
418 if n_violated > (active_total / 4).max(PIN_REPAIR_MIN_ROWS)
419 || active_total + n_violated > qp.n
420 {
421 return None;
422 }
423 for (i, status) in cons {
424 w_cur.constraints[i] = status;
425 }
426 for (i, status) in bounds {
427 w_cur.bounds[i] = status;
428 }
429 let (x_new, w_new) = self.pin_working_set(qp, &w_cur, opts).ok()?;
430 x_cur = x_new;
431 w_cur = w_new;
432 }
433
434 point_is_feasible(qp, &x_cur, opts.feas_tol).then_some((x_cur, w_cur))
435 }
436
437 /// How wrong the caller's working set turns out to be, measured on `qp`
438 /// itself rather than predicted from it.
439 ///
440 /// Pins the hinted active rows and counts how many *other* rows and bounds
441 /// the resulting point violates. That count is the cheapest honest answer
442 /// available to "is this active set a good guess for this problem": it is a
443 /// property of the hint applied to the target, so it needs no model of what
444 /// changed between the two problems and no threshold on problem data — the
445 /// approach gh #434 refuted when `n_eq / n` failed to discriminate.
446 ///
447 /// Costs one pinned-KKT factorization, which
448 /// [`Self::solve_with_working_set`] already pays, so a caller that goes on
449 /// to take the working-set route pays nothing extra for having asked.
450 ///
451 /// `None` when the pin does not take at all — an active row itself
452 /// violated, or a factorization failure. That is a hint too broken to
453 /// measure, which callers should read the same way as a large count.
454 ///
455 /// **Test-only, deliberately.** This measures cleanly and cheaply; what it
456 /// does not do is answer the question the solver actually has. See
457 /// `tests::hint_signal` for the sweep that declined it, and
458 /// `dev-notes/issue-602-parametric-eligibility.md` for why. It stays in the
459 /// tree because the next person to reach for this idea should find the
460 /// instrument and the negative result, not just the idea.
461 #[cfg(test)]
462 pub(crate) fn hint_pin_quality(
463 &mut self,
464 qp: &QpProblem,
465 working: &WorkingSet,
466 opts: &QpOptions,
467 ) -> Option<HintPinQuality> {
468 let (x, w) = self.pin_working_set(qp, working, opts).ok()?;
469 let (cons, bounds) = violated_inactive(qp, &x, &w, opts.feas_tol)?;
470 Some(HintPinQuality {
471 active: w.active_count(),
472 violated: cons.len() + bounds.len(),
473 })
474 }
475
476 /// Primal active-set path for box-constrained QPs
477 /// (no general constraints, finite or infinite variable
478 /// bounds). Standard add/drop loop with refactor-per-change —
479 /// the Schur-complement update path (§4.2) replaces the
480 /// refactor in a later commit.
481 ///
482 /// Each iteration:
483 /// 1. assemble `[H Eᵀ_W; E_W 0]` from the current active set;
484 /// 2. solve for step `(p, λ_sat)` against RHS `[-(Hx+g); 0]`;
485 /// 3. if `‖p‖ < opt_tol`, examine multiplier signs — drop
486 /// one wrong-sign active bound, else declare optimal;
487 /// 4. otherwise ratio-test along `p` to the first blocking
488 /// bound, take that step, add the blocker to `W`.
489 ///
490 /// Sign convention for dropping (with our saddle Lagrangian
491 /// `L = ½xᵀHx + gᵀx + λᵀ_sat(E_W x − β_W)` and IPOPT-style
492 /// user-facing multipliers `lambda_x = z_l − z_u`):
493 /// * AtLower → `λ_sat ≤ 0` at optimum; drop if `λ_sat > tol`.
494 /// * AtUpper → `λ_sat ≥ 0` at optimum; drop if `λ_sat < -tol`.
495 /// * Fixed → never dropped.
496 fn solve_box_constrained(
497 &mut self,
498 qp: &QpProblem,
499 opts: &QpOptions,
500 ) -> Result<QpSolution, QpError> {
501 let started = Instant::now();
502 let n = qp.n;
503
504 // ---- 1. Initial primal x: project 0 into the box ----
505 let mut x = vec![0.0; n];
506 for (xi, (&l, &u)) in x.iter_mut().zip(qp.xl.iter().zip(qp.xu.iter())) {
507 if l > NLP_LOWER_BOUND_INF && *xi < l {
508 *xi = l;
509 }
510 if u < NLP_UPPER_BOUND_INF && *xi > u {
511 *xi = u;
512 }
513 }
514
515 // ---- 2. Initial working set ----
516 let mut working = WorkingSet::cold(n, 0);
517 for (i, (status, xi)) in working.bounds.iter_mut().zip(x.iter_mut()).enumerate() {
518 let l = qp.xl[i];
519 let u = qp.xu[i];
520 let l_finite = l > NLP_LOWER_BOUND_INF;
521 let u_finite = u < NLP_UPPER_BOUND_INF;
522 if l_finite && u_finite && (l - u).abs() <= opts.feas_tol {
523 *status = BoundStatus::Fixed;
524 *xi = l;
525 } else if l_finite && (*xi - l).abs() <= opts.feas_tol {
526 *status = BoundStatus::AtLower;
527 *xi = l;
528 } else if u_finite && (*xi - u).abs() <= opts.feas_tol {
529 *status = BoundStatus::AtUpper;
530 *xi = u;
531 }
532 }
533
534 let mut n_refactor: u32 = 0;
535 let mut n_changes: u32 = 0;
536
537 for _iter in 0..opts.max_iter {
538 if crate::deadline::expired() {
539 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
540 }
541 // Build active-bound index list (ascending = problem
542 // order) and assemble the KKT.
543 let active: Vec<usize> = (0..n).filter(|&i| working.bounds[i].is_active()).collect();
544 let k = active.len();
545
546 let kkt = assemble_box_with_active(qp, &active);
547
548 // RHS = [ -(H x + g) ; 0_k ]
549 let hx = h_times_x(qp.h, &x);
550 let mut rhs = vec![0.0; n + k];
551 for i in 0..n {
552 rhs[i] = -(hx[i] + qp.g[i]);
553 }
554
555 // Inertia expectation: k negative eigenvalues for full-
556 // rank E_W (always full rank since selection rows pick
557 // distinct columns) and PD reduced H. Inertia-control
558 // retry handles indefinite reduced H via §4.5.
559 let delta = self.factorize_with_inertia_control(kkt, &mut rhs, k as i32, qp.n, opts)?;
560 n_refactor += 1;
561 if crate::deadline::expired() {
562 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
563 }
564
565 // ---- 3. Check ‖p‖ ----
566 let p_inf = rhs[..n].iter().map(|pi| pi.abs()).fold(0.0, f64::max);
567
568 if p_inf <= opts.opt_tol {
569 // At KKT-stationary point for current W. Examine
570 // multiplier signs.
571 let mut worst: Option<(usize, Number)> = None;
572 for (j, &i) in active.iter().enumerate() {
573 let lam = rhs[n + j];
574 let viol = match working.bounds[i] {
575 BoundStatus::AtLower => lam, // want ≤ 0
576 BoundStatus::AtUpper => -lam, // want ≥ 0
577 BoundStatus::Fixed => 0.0, // never drop
578 BoundStatus::Inactive => unreachable!(),
579 };
580 if viol > worst.map(|(_, v)| v).unwrap_or(opts.opt_tol) {
581 worst = Some((i, viol));
582 }
583 }
584
585 if let Some((i_drop, _)) = worst {
586 working.bounds[i_drop] = BoundStatus::Inactive;
587 n_changes += 1;
588 continue;
589 }
590
591 // Optimal — pack user-facing multipliers.
592 // lambda_x = z_l − z_u = −λ_sat for active i, 0 else.
593 let mut lambda_x = vec![0.0; n];
594 for (j, &i) in active.iter().enumerate() {
595 lambda_x[i] = -rhs[n + j];
596 }
597
598 return Ok(QpSolution {
599 obj: quad_objective(qp, &x),
600 x,
601 lambda_g: Vec::new(),
602 lambda_x,
603 working,
604 status: QpStatus::Optimal,
605 stats: QpStats {
606 n_working_set_changes: n_changes,
607 n_refactor,
608 n_schur_updates: 0,
609 used_phase1: false,
610 time: started.elapsed(),
611 ..Default::default()
612 },
613 unbounded_ray: None,
614 });
615 }
616
617 // ---- 4. Ratio test along p ----
618 // First snapshot p so the in-place RHS solve doesn't
619 // alias the step buffer later.
620 let p: Vec<Number> = rhs[..n].to_vec();
621
622 // §4.5 companion (gh #416): a δ-shifted direction is not
623 // minimized by the unit step — see `model_step_cap`.
624 let mut alpha = model_step_cap(qp.h, qp.g, &hx, &p, delta);
625 let mut blocker: Option<(usize, BoundStatus)> = None;
626 for i in 0..n {
627 if working.bounds[i].is_active() {
628 continue;
629 }
630 if p[i] < -opts.feas_tol && qp.xl[i] > NLP_LOWER_BOUND_INF {
631 let r = (x[i] - qp.xl[i]) / -p[i];
632 if r < alpha {
633 alpha = r;
634 blocker = Some((i, BoundStatus::AtLower));
635 }
636 }
637 if p[i] > opts.feas_tol && qp.xu[i] < NLP_UPPER_BOUND_INF {
638 let r = (qp.xu[i] - x[i]) / p[i];
639 if r < alpha {
640 alpha = r;
641 blocker = Some((i, BoundStatus::AtUpper));
642 }
643 }
644 }
645
646 if !alpha.is_finite() && !opts.certify_recession_ray {
647 // The caller wants a point, not a verdict (gh #423): take
648 // the δ-shifted proximal step and keep iterating. See
649 // `QpOptions::certify_recession_ray`.
650 alpha = 1.0;
651 }
652
653 if !alpha.is_finite() {
654 // The model falls forever along `p` and no bound blocks:
655 // a certified recession ray (same F2 certificate as
656 // `solve_general`, with `pᵀHp < 0` in place of `Hp = 0`).
657 return Ok(QpSolution {
658 obj: Number::NEG_INFINITY,
659 x,
660 lambda_g: Vec::new(),
661 lambda_x: vec![0.0; n],
662 working,
663 status: QpStatus::Unbounded,
664 stats: QpStats {
665 n_working_set_changes: n_changes,
666 n_refactor,
667 n_schur_updates: 0,
668 used_phase1: false,
669 time: started.elapsed(),
670 ..Default::default()
671 },
672 unbounded_ray: Some(p),
673 });
674 }
675
676 if alpha < 0.0 {
677 // Defensive: numerical noise shouldn't drive α
678 // negative, but clip if it does.
679 alpha = 0.0;
680 }
681
682 for i in 0..n {
683 x[i] += alpha * p[i];
684 }
685
686 if let Some((i_block, status)) = blocker {
687 // Snap to the exact bound to avoid drift.
688 match status {
689 BoundStatus::AtLower => x[i_block] = qp.xl[i_block],
690 BoundStatus::AtUpper => x[i_block] = qp.xu[i_block],
691 _ => unreachable!(),
692 }
693 working.bounds[i_block] = status;
694 n_changes += 1;
695 }
696 }
697
698 // Hit max_iter.
699 Ok(QpSolution {
700 obj: quad_objective(qp, &x),
701 x,
702 lambda_g: Vec::new(),
703 lambda_x: vec![0.0; n],
704 working,
705 status: QpStatus::MaxIter,
706 stats: QpStats {
707 n_working_set_changes: n_changes,
708 n_refactor,
709 n_schur_updates: 0,
710 used_phase1: false,
711 time: started.elapsed(),
712 ..Default::default()
713 },
714 unbounded_ray: None,
715 })
716 }
717
718 /// Active-set path for QPs with general equality constraints
719 /// *and* finite variable bounds. The cold start solves the
720 /// equality-relaxed KKT (ignoring bounds) and routes to the
721 /// active-set inner loop when that solution is bound-feasible.
722 ///
723 /// Bound-infeasible equality solutions fall through to
724 /// [`Self::solve_elastic`] — the same §4.3 phase-1 recovery
725 /// `solve_general` uses via `cold_general_initial`.
726 ///
727 /// In the inner loop the equality rows live permanently in the
728 /// working set (`ConsStatus::Equality`) and are never dropped;
729 /// only variable bounds add and drop. The KKT layout is
730 /// `[H Aᵀ_eq Eᵀ_W; A_eq 0 0; E_W 0 0]` with expected inertia
731 /// `(n, m + k, 0)` for full-rank rows and PD reduced H.
732 fn solve_equality_plus_bounds(
733 &mut self,
734 qp: &QpProblem,
735 opts: &QpOptions,
736 ) -> Result<QpSolution, QpError> {
737 let started = Instant::now();
738 let n = qp.n;
739 let m = qp.m;
740
741 // ---- 1. Equality-relaxed initial point ----
742 // A rank-deficient equality block — redundant / linearly dependent
743 // rows, e.g. one row an exact scalar multiple of another — makes the
744 // saddle KKT singular, and no §4.5 H-block shift can rescue a
745 // rank-deficient *constraint* block. This path pins ALL `m` equality
746 // rows in every inner-loop KKT (`assemble_equality_plus_bounds` has no
747 // per-row selection), so it cannot prune the dependent rows itself.
748 // Factor through `factor_pinned_primal`, which reports a recoverable
749 // failure on such a block (whether the backend exhausted the inertia
750 // loop or a large shift masked the singular block — see its
751 // masked-rank-deficiency guard); on that signal, delegate to
752 // `solve_general`, whose `cold_general_initial` + inner-loop
753 // linear-independence guard prune the equalities to a maximal
754 // independent subset (a dropped row is a linear combination of the
755 // kept ones, hence satisfied at any constraint-consistent point) and
756 // reach the exact vertex. Without this the solve surfaced to the user
757 // as `InternalError` / exit 1, or churned to `MaxIter` (#313).
758 let eq_rows: Vec<usize> = (0..m).collect();
759 let eq_targets: Vec<Number> = eq_rows.iter().map(|&r| qp.bl[r]).collect();
760 let mut x: Vec<Number> =
761 match self.factor_pinned_primal(qp, &eq_rows, &eq_targets, &[], &[], opts) {
762 Ok(x) => x,
763 Err(ref e) if e.is_recoverable_factorization_failure() => {
764 return self.solve_general(qp, None, opts);
765 }
766 Err(e) => return Err(e),
767 };
768 let mut n_refactor: u32 = 1;
769 let mut n_changes: u32 = 0;
770
771 // ---- 2. Bound-feasibility check ----
772 // The cheap equality-relaxed cold start may land outside
773 // the box; fall through to the §4.3 elastic mode in that
774 // case (same recovery `solve_general` uses; see
775 // `cold_general_initial` → `solve_elastic` fall-through).
776 for (i, &xi) in x.iter().enumerate() {
777 let l = qp.xl[i];
778 let u = qp.xu[i];
779 if (l > NLP_LOWER_BOUND_INF && xi < l - opts.feas_tol)
780 || (u < NLP_UPPER_BOUND_INF && xi > u + opts.feas_tol)
781 {
782 return self.solve_elastic(qp, opts);
783 }
784 }
785
786 // ---- 3. Initial working set ----
787 let mut working = WorkingSet::cold(n, m);
788 for c in working.constraints.iter_mut() {
789 *c = ConsStatus::Equality;
790 }
791 for (i, (status, xi)) in working.bounds.iter_mut().zip(x.iter_mut()).enumerate() {
792 let l = qp.xl[i];
793 let u = qp.xu[i];
794 let l_finite = l > NLP_LOWER_BOUND_INF;
795 let u_finite = u < NLP_UPPER_BOUND_INF;
796 if l_finite && u_finite && (l - u).abs() <= opts.feas_tol {
797 *status = BoundStatus::Fixed;
798 *xi = l;
799 } else if l_finite && (*xi - l).abs() <= opts.feas_tol {
800 *status = BoundStatus::AtLower;
801 *xi = l;
802 } else if u_finite && (*xi - u).abs() <= opts.feas_tol {
803 *status = BoundStatus::AtUpper;
804 *xi = u;
805 }
806 }
807
808 // ---- 4. Active-set inner loop ----
809 for _iter in 0..opts.max_iter {
810 if crate::deadline::expired() {
811 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
812 }
813 let active: Vec<usize> = (0..n).filter(|&i| working.bounds[i].is_active()).collect();
814 let k = active.len();
815
816 let kkt = assemble_equality_plus_bounds(qp, &active);
817
818 let hx = h_times_x(qp.h, &x);
819 let mut rhs = vec![0.0; n + m + k];
820 for (rhs_i, (hx_i, &g_i)) in rhs[..n].iter_mut().zip(hx.iter().zip(qp.g.iter())) {
821 *rhs_i = -(hx_i + g_i);
822 }
823 // rhs[n..n+m] and rhs[n+m..n+m+k] stay zero.
824
825 let delta =
826 self.factorize_with_inertia_control(kkt, &mut rhs, (m + k) as i32, qp.n, opts)?;
827 n_refactor += 1;
828 if crate::deadline::expired() {
829 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
830 }
831
832 let p_inf = rhs[..n].iter().map(|pi| pi.abs()).fold(0.0, f64::max);
833
834 if p_inf <= opts.opt_tol {
835 // Check drop on bound multipliers in rhs[n+m..n+m+k].
836 let mut worst: Option<(usize, Number)> = None;
837 for (j, &i) in active.iter().enumerate() {
838 let lam = rhs[n + m + j];
839 let viol = match working.bounds[i] {
840 BoundStatus::AtLower => lam,
841 BoundStatus::AtUpper => -lam,
842 BoundStatus::Fixed => 0.0,
843 BoundStatus::Inactive => unreachable!(),
844 };
845 if viol > worst.map(|(_, v)| v).unwrap_or(opts.opt_tol) {
846 worst = Some((i, viol));
847 }
848 }
849
850 if let Some((i_drop, _)) = worst {
851 working.bounds[i_drop] = BoundStatus::Inactive;
852 n_changes += 1;
853 continue;
854 }
855
856 // Optimal — pack multipliers.
857 let lambda_g: Vec<Number> = rhs[n..n + m].to_vec();
858 let mut lambda_x = vec![0.0; n];
859 for (j, &i) in active.iter().enumerate() {
860 lambda_x[i] = -rhs[n + m + j];
861 }
862
863 return Ok(QpSolution {
864 obj: quad_objective(qp, &x),
865 x,
866 lambda_g,
867 lambda_x,
868 working,
869 status: QpStatus::Optimal,
870 stats: QpStats {
871 n_working_set_changes: n_changes,
872 n_refactor,
873 n_schur_updates: 0,
874 used_phase1: false,
875 time: started.elapsed(),
876 ..Default::default()
877 },
878 unbounded_ray: None,
879 });
880 }
881
882 // Ratio test along p.
883 let p: Vec<Number> = rhs[..n].to_vec();
884 // §4.5 companion (gh #416): a δ-shifted direction is not
885 // minimized by the unit step — see `model_step_cap`.
886 let mut alpha = model_step_cap(qp.h, qp.g, &hx, &p, delta);
887 let mut blocker: Option<(usize, BoundStatus)> = None;
888 for i in 0..n {
889 if working.bounds[i].is_active() {
890 continue;
891 }
892 if p[i] < -opts.feas_tol && qp.xl[i] > NLP_LOWER_BOUND_INF {
893 let r = (x[i] - qp.xl[i]) / -p[i];
894 if r < alpha {
895 alpha = r;
896 blocker = Some((i, BoundStatus::AtLower));
897 }
898 }
899 if p[i] > opts.feas_tol && qp.xu[i] < NLP_UPPER_BOUND_INF {
900 let r = (qp.xu[i] - x[i]) / p[i];
901 if r < alpha {
902 alpha = r;
903 blocker = Some((i, BoundStatus::AtUpper));
904 }
905 }
906 }
907 if !alpha.is_finite() && !opts.certify_recession_ray {
908 // Point, not verdict (gh #423) — see
909 // `QpOptions::certify_recession_ray`.
910 alpha = 1.0;
911 }
912 if !alpha.is_finite() {
913 // Nonpositive curvature along `p` with no blocking bound:
914 // certified recession ray (see `solve_box_constrained`).
915 return Ok(QpSolution {
916 obj: Number::NEG_INFINITY,
917 x,
918 lambda_g: vec![0.0; m],
919 lambda_x: vec![0.0; n],
920 working,
921 status: QpStatus::Unbounded,
922 stats: QpStats {
923 n_working_set_changes: n_changes,
924 n_refactor,
925 n_schur_updates: 0,
926 used_phase1: false,
927 time: started.elapsed(),
928 ..Default::default()
929 },
930 unbounded_ray: Some(p),
931 });
932 }
933 if alpha < 0.0 {
934 alpha = 0.0;
935 }
936 for (xi, &pi) in x.iter_mut().zip(p.iter()) {
937 *xi += alpha * pi;
938 }
939 if let Some((i_block, status)) = blocker {
940 match status {
941 BoundStatus::AtLower => x[i_block] = qp.xl[i_block],
942 BoundStatus::AtUpper => x[i_block] = qp.xu[i_block],
943 _ => unreachable!(),
944 }
945 working.bounds[i_block] = status;
946 n_changes += 1;
947 }
948 }
949
950 Ok(QpSolution {
951 obj: quad_objective(qp, &x),
952 x,
953 lambda_g: vec![0.0; m],
954 lambda_x: vec![0.0; n],
955 working,
956 status: QpStatus::MaxIter,
957 stats: QpStats {
958 n_working_set_changes: n_changes,
959 n_refactor,
960 n_schur_updates: 0,
961 used_phase1: false,
962 time: started.elapsed(),
963 ..Default::default()
964 },
965 unbounded_ray: None,
966 })
967 }
968
969 /// Cold-start path for QPs that have only equality constraints
970 /// and no variable bounds. Builds the saddle-point KKT and
971 /// hands it to the linear solver in one shot.
972 fn solve_equality_only(
973 &mut self,
974 qp: &QpProblem,
975 opts: &QpOptions,
976 ) -> Result<QpSolution, QpError> {
977 let started = Instant::now();
978 let kkt = KktTriplet::assemble_equality_only(qp);
979 let mut rhs = rhs_equality_only(qp);
980
981 // Inertia expectation for [H Aᵀ; A 0] with full-rank A and
982 // reduced Hessian PD on null(A): exactly m negative
983 // eigenvalues (Gould-Hribar-Nocedal 2001 §3.2). The
984 // inertia-control retry handles indefinite reduced H via
985 // §4.5.
986 //
987 // A rank-deficient equality block — redundant / linearly
988 // dependent rows, e.g. three identical rows or one row an
989 // integer combination of the others (#326) — makes the saddle
990 // KKT singular, and no §4.5 H-block shift can rescue a
991 // rank-deficient *constraint* block: the inertia loop exhausts
992 // and reports a recoverable failure. This fast path pins ALL `m`
993 // equality rows in one shot and has no per-row selection, so it
994 // cannot prune the dependent rows itself. On that signal,
995 // delegate to the rank-deficiency-aware `solve_general`, whose
996 // `cold_general_initial` prunes the equalities to a maximal
997 // independent subset (a dropped row is a linear combination of
998 // the kept ones, hence satisfied at any constraint-consistent
999 // point) and reaches the exact vertex. Without this the solve
1000 // surfaced to the user as `InternalError` / exit 1 (#326).
1001 let delta =
1002 match self.factorize_with_inertia_control(kkt, &mut rhs, qp.m as i32, qp.n, opts) {
1003 Ok(d) => d,
1004 Err(ref e) if e.is_recoverable_factorization_failure() => {
1005 return self.solve_general(qp, None, opts);
1006 }
1007 Err(e) => return Err(e),
1008 };
1009
1010 // Masked-rank-deficiency guard (companion to the one in
1011 // `factor_pinned_primal`). A large enough δ can grow the H
1012 // diagonal until the backend stops flagging the singular
1013 // constraint block and returns a solution instead of a failure
1014 // (feral masks the null direction around δ≈1e8), which would slip
1015 // a redundant — or worse, *inconsistent* — equality block past the
1016 // check above. A nonzero δ on this pinned equality KKT is the tell:
1017 // only then rank-reveal the rows, and if any is redundant delegate
1018 // to `solve_general` (same recovery as the exact-failure branch).
1019 // A δ > 0 with a full-rank block is a legitimate indefinite /
1020 // unbounded reduced-Hessian shift and falls through to the
1021 // recession-ray test below unchanged (#326).
1022 if delta > 0.0 {
1023 let eq_rows: Vec<usize> = (0..qp.m).collect();
1024 let (kept, _) = independent_active_subset(&mut self.linsol, qp, &eq_rows, &[]);
1025 if kept.len() < qp.m {
1026 return self.solve_general(qp, None, opts);
1027 }
1028 }
1029
1030 // RHS now holds [x*; λ*].
1031 let mut x = vec![0.0; qp.n];
1032 x.copy_from_slice(&rhs[..qp.n]);
1033 let mut lambda_g = vec![0.0; qp.m];
1034 lambda_g.copy_from_slice(&rhs[qp.n..]);
1035
1036 // H1 / N1: the inertia-control retry solved the *shifted* system
1037 // `(H+δI)` when `δ > 0`, which it must do whenever the reduced
1038 // Hessian is not PD on null(A). A `δ > 0` solve is consistent with
1039 // BOTH a bounded QP (the regularizer merely picks the min-norm
1040 // point along a flat, gradient-free direction) and an unbounded
1041 // one — so the shift alone proves nothing.
1042 //
1043 // The discriminator is a *certified recession ray*. A QP
1044 // `min ½xᵀHx + gᵀx s.t. Ax = b` is unbounded below iff there is a
1045 // direction `d` with `Hd = 0` (zero curvature — for PSD H
1046 // equivalent to `dᵀHd = 0`), `Ad = 0` (stays feasible), and
1047 // `gᵀd < 0` (descent). The shifted solve manufactures exactly
1048 // this witness when one exists: any descent component of `-g`
1049 // lying in a zero-curvature, feasible direction is amplified by
1050 // `1/δ`, so the normalized iterate `d = x/‖x‖` converges to that
1051 // recession ray as `δ → 0`. We therefore certify the three
1052 // conditions directly on `d`.
1053 //
1054 // This replaces the earlier magnitude heuristic `δ·‖x‖∞ >
1055 // 1e-3·‖g‖∞`, which fired on any large `‖x‖` and could not
1056 // distinguish a large-but-finite minimizer in a *curved*
1057 // direction (e.g. `H = diag(1e-6, 0)`, `g = (-1, 0)`: the curved
1058 // x₁ runs out to its finite optimum ≈ 1e6) from a genuine blow-up
1059 // along a *flat* descent ray (N1 false positive). The curvature
1060 // clause `‖Hd‖∞ ≈ 0` (structural-zero floor, see
1061 // `ray_is_unbounded_descent`) rejects the former (there `‖Hd‖∞ ≈
1062 // ‖H‖`) and admits the latter.
1063 // `certify_recession_ray = false` skips the N1 test outright (gh
1064 // #423): `x` here IS the δ-shifted proximal point — the exact
1065 // minimizer of `q(y) + ½δ‖y‖²` over `Ay = b` — which is the step
1066 // the caller asked for in place of the certificate.
1067 if delta > 0.0 && opts.certify_recession_ray {
1068 // Feasibility of the candidate ray `d = x/‖x‖`: the saddle
1069 // solve enforced `Ax = b` exactly, so `Ad = b/‖x‖`, which the
1070 // blow-up drives to ~0. Verify it explicitly (cheap guard;
1071 // trivially satisfied in the unconstrained `m = 0` case), then
1072 // delegate the curvature + descent clauses to the shared test.
1073 let x_norm = x.iter().map(|v| v * v).sum::<Number>().sqrt();
1074 let feasible_ray = if x_norm > 0.0 {
1075 let inv = 1.0 / x_norm;
1076 let mut ad = vec![0.0; qp.m];
1077 let mut a_scale: Number = 0.0;
1078 let irows = qp.a.irows();
1079 let jcols = qp.a.jcols();
1080 let vals = qp.a.values();
1081 for k in 0..irows.len() {
1082 let i = (irows[k] - 1) as usize;
1083 let j = (jcols[k] - 1) as usize;
1084 a_scale = a_scale.max(vals[k].abs());
1085 ad[i] += vals[k] * x[j] * inv;
1086 }
1087 let ad_inf = ad.iter().map(|v| v.abs()).fold(0.0, f64::max);
1088 ad_inf <= 1e-6 * (1.0 + a_scale)
1089 } else {
1090 false
1091 };
1092
1093 if feasible_ray && ray_is_unbounded_descent(qp.h, qp.g, &x, &x) {
1094 // The witness direction on this path IS the blown-up
1095 // iterate `x` (see the `d = x/‖x‖` argument above).
1096 let ray = x.clone();
1097 return Ok(QpSolution {
1098 x,
1099 lambda_g,
1100 lambda_x: vec![0.0; qp.n],
1101 working: WorkingSet::cold(qp.n, qp.m),
1102 obj: Number::NEG_INFINITY,
1103 status: QpStatus::Unbounded,
1104 stats: QpStats {
1105 n_working_set_changes: 0,
1106 n_refactor: 1,
1107 n_schur_updates: 0,
1108 used_phase1: false,
1109 time: started.elapsed(),
1110 ..Default::default()
1111 },
1112 unbounded_ray: Some(ray),
1113 });
1114 }
1115 }
1116
1117 let obj = quad_objective(qp, &x);
1118
1119 // All general constraints are equalities (precondition of
1120 // this entry point) — mark them as such in the working set.
1121 let mut working = WorkingSet::cold(qp.n, qp.m);
1122 for c in working.constraints.iter_mut() {
1123 *c = ConsStatus::Equality;
1124 }
1125
1126 let _ = opts; // QpOptions reserved for the working-set path.
1127
1128 Ok(QpSolution {
1129 x,
1130 lambda_g,
1131 lambda_x: vec![0.0; qp.n],
1132 working,
1133 obj,
1134 status: QpStatus::Optimal,
1135 stats: QpStats {
1136 n_working_set_changes: 0,
1137 n_refactor: 1,
1138 n_schur_updates: 0,
1139 used_phase1: false,
1140 time: started.elapsed(),
1141 ..Default::default()
1142 },
1143 unbounded_ray: None,
1144 })
1145 }
1146
1147 /// General-purpose active-set path: handles arbitrary mix of
1148 /// equality and inequality general constraints, plus variable
1149 /// bounds, plus optional warm-start. This is the path the
1150 /// dispatcher routes to whenever a warm start is supplied or
1151 /// when the problem has at least one one-sided / two-sided
1152 /// general inequality row.
1153 ///
1154 /// Cold-start initial point: solves the equality-relaxed KKT
1155 /// (only rows with `bl == bu` participate) and accepts the
1156 /// solution if it is feasible w.r.t. inequality rows and
1157 /// variable bounds. Bound- or inequality-infeasible cases are
1158 /// rejected with [`QpError::UnsupportedFeature`] pointing at
1159 /// the §4.3 elastic-mode commit.
1160 ///
1161 /// Warm-start initial point: trusts the caller's `(x, working)`
1162 /// pair. No correctness check; an infeasible warm start may
1163 /// diverge or hit max_iter. (Validation is deferred to a
1164 /// follow-up commit that adds an `OptimalityCheck` audit pass.)
1165 fn solve_general(
1166 &mut self,
1167 qp: &QpProblem,
1168 ws: Option<&QpWarmStart>,
1169 opts: &QpOptions,
1170 ) -> Result<QpSolution, QpError> {
1171 let started = Instant::now();
1172 let n = qp.n;
1173 let m = qp.m;
1174 let mut n_refactor: u32 = 0;
1175 let mut n_changes: u32 = 0;
1176
1177 // ---- 1. Initial (x, working) — warm-start or cold solve ----
1178 let (mut x, mut working) = if let Some(w) = ws {
1179 (w.x.clone(), w.working.clone())
1180 } else {
1181 // Try the cheap eq-relaxed cold start first; if it
1182 // produces an infeasible point, route through §4.3
1183 // l1-elastic mode instead.
1184 match self.cold_general_initial(qp, opts, &mut n_refactor)? {
1185 Some(p) => p,
1186 None => return self.solve_elastic(qp, opts),
1187 }
1188 };
1189
1190 // Snap primal coordinates of active bounds to their exact
1191 // bound values; protects against caller drift in warm-start
1192 // mode and against floating-point noise after the cold-init
1193 // KKT solve.
1194 for (i, &status) in working.bounds.iter().enumerate() {
1195 match status {
1196 BoundStatus::AtLower | BoundStatus::Fixed => x[i] = qp.xl[i],
1197 BoundStatus::AtUpper => x[i] = qp.xu[i],
1198 BoundStatus::Inactive => {}
1199 }
1200 }
1201
1202 // ---- 2. Active-set inner loop ----
1203 // GMSW EXPAND τ — primal-perturbation tolerance.
1204 // Consumed by `select_blocker` only when
1205 // `opts.anti_cycling = Expand`; tracked unconditionally
1206 // so the snap-and-reset logic below is a no-op for the
1207 // other anti-cycling choices.
1208 let mut expand_tol = opts.expand_tol_initial;
1209
1210 // Linear-independence anti-cycling tabu. When the rank guard
1211 // prunes a linearly-dependent row at a *stationary* (degenerate)
1212 // vertex, that row is satisfied at `x` and has true `a·p = 0`
1213 // along every feasible direction — yet numerical drift can give
1214 // it a tiny `|a·p| > feas_tol`, so the ratio test keeps re-adding
1215 // it, the factor goes rank-deficient again, and the engine cycles
1216 // (prune → re-add → prune …). Forbidding a pruned row from
1217 // re-entering until `x` actually moves breaks that cycle: while
1218 // the vertex is stationary the active set can only shrink, so the
1219 // degenerate phase terminates finitely; the tabu is cleared on the
1220 // first real step (`α > feas_tol`), after which the null space has
1221 // changed and a previously-dependent row may legitimately re-enter.
1222 let mut tabu_cons = vec![false; m];
1223 let mut tabu_bounds = vec![false; n];
1224
1225 // Anti-stall fallback to Bland's rule (§4.4). The default
1226 // steepest-violation drop + Harris/largest-pivot add is fast
1227 // but NOT cycle-free: on a degenerate vertex (notably the
1228 // elastic phase-1 high-penalty vertices the GEN family and even
1229 // trivial LPs like `afiro` park at) it can churn the working set
1230 // without improving the objective until `max_iter`. Bland's rule
1231 // (lowest-index drop/add) is provably finite. We monitor the
1232 // objective and, once it fails to improve for `stall_limit`
1233 // consecutive iterations, latch into Bland selection for the
1234 // remainder of the solve — the textbook "Bland as anti-cycling
1235 // fallback after stalling" safeguard. The latch is sticky (never
1236 // reverts) so it cannot flip-flop, and it is a no-op on problems
1237 // that make steady progress.
1238 let mut force_bland = false;
1239 let mut best_obj = Number::INFINITY;
1240 let mut stall_iters: u32 = 0;
1241 // A problem making genuine progress rarely goes this many
1242 // consecutive iterations without any objective improvement; a
1243 // degenerate cycle does. Constant (not size-scaled) so it fires
1244 // well inside the default `max_iter` on large problems too.
1245 const STALL_LIMIT: u32 = 50;
1246
1247 for _iter in 0..opts.max_iter {
1248 if crate::deadline::expired() {
1249 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
1250 }
1251 let active_cons: Vec<usize> = (0..m)
1252 .filter(|&i| working.constraints[i].is_active())
1253 .collect();
1254 let active_bounds: Vec<usize> =
1255 (0..n).filter(|&i| working.bounds[i].is_active()).collect();
1256 let k_c = active_cons.len();
1257 let k_b = active_bounds.len();
1258
1259 let kkt = assemble_active_set_kkt(qp, &active_cons, &active_bounds);
1260
1261 let hx = h_times_x(qp.h, &x);
1262 let mut rhs = vec![0.0; n + k_c + k_b];
1263 for (rhs_i, (hx_i, &g_i)) in rhs[..n].iter_mut().zip(hx.iter().zip(qp.g.iter())) {
1264 *rhs_i = -(hx_i + g_i);
1265 }
1266
1267 let delta = match self.factorize_with_inertia_control(
1268 kkt,
1269 &mut rhs,
1270 (k_c + k_b) as i32,
1271 qp.n,
1272 opts,
1273 ) {
1274 Ok(d) => d,
1275 Err(e) if e.is_recoverable_factorization_failure() => {
1276 // The active set went rank-deficient: at a degenerate
1277 // vertex more binding rows than variables can be linearly
1278 // dependent, and numerical drift can let a dependent row
1279 // (whose `a·p` should be 0) slip past the ratio test's
1280 // `feas_tol`. No H-block shift can repair a rank-deficient
1281 // *constraint* block, so the inertia loop just exhausted.
1282 // Linear-independence guard: prune the active set to a
1283 // maximal independent subset, deactivate the redundant
1284 // rows (still satisfied at `x` — they are combinations of
1285 // the kept ones), and retry on the next iteration.
1286 let (kc, kb) = independent_active_subset(
1287 &mut self.linsol,
1288 qp,
1289 &active_cons,
1290 &active_bounds,
1291 );
1292 if kc.len() == active_cons.len() && kb.len() == active_bounds.len() {
1293 return Err(e);
1294 }
1295 let mut keep_c = vec![false; m];
1296 for &i in &kc {
1297 keep_c[i] = true;
1298 }
1299 let mut keep_b = vec![false; n];
1300 for &i in &kb {
1301 keep_b[i] = true;
1302 }
1303 for &i in &active_cons {
1304 if !keep_c[i] {
1305 working.constraints[i] = ConsStatus::Inactive;
1306 tabu_cons[i] = true;
1307 n_changes += 1;
1308 }
1309 }
1310 for &i in &active_bounds {
1311 if !keep_b[i] {
1312 working.bounds[i] = BoundStatus::Inactive;
1313 tabu_bounds[i] = true;
1314 n_changes += 1;
1315 }
1316 }
1317 continue;
1318 }
1319 Err(e) => return Err(e),
1320 };
1321 n_refactor += 1;
1322 if crate::deadline::expired() {
1323 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
1324 }
1325
1326 let p_inf = rhs[..n].iter().map(|pi| pi.abs()).fold(0.0, f64::max);
1327
1328 if p_inf <= opts.opt_tol {
1329 // KKT-stationary for current W. Pick a wrong-sign
1330 // active row to drop.
1331 //
1332 // Tie-breaking rule (§4.4): `AntiCyclingChoice::Bland`
1333 // picks the lowest-indexed violation (Bland 1977 —
1334 // guarantees finite termination at the cost of slower
1335 // convergence); the default `Expand`/`None` picks
1336 // the largest-magnitude violation (Dantzig's
1337 // steepest-violation rule — faster but not cycle-
1338 // free under pathological degeneracy).
1339 //
1340 // Scope note: EXPAND (Gill-Murray-Saunders-Wright
1341 // 1989) governs the *ratio test*, and its τ
1342 // primal-perturbation machinery is implemented —
1343 // τ-relaxed blocker selection in `select_blocker`,
1344 // plus the τ-growth and snap-reset below. It does
1345 // **not** supply a drop rule, so under
1346 // `AntiCyclingChoice::Expand` this choice is
1347 // Dantzig's steepest-violation: correct on every
1348 // non-cycling problem in the analytical ladder, and
1349 // the qpOASES default, but not cycle-free on its own.
1350 // The anti-stall Bland latch (`force_bland`) is what
1351 // bounds the pathological case.
1352 //
1353 // (This comment previously said EXPAND's perturbation
1354 // machinery had not landed and that `Expand` aliased
1355 // wholesale to steepest-violation. That was true before
1356 // c20 and stale after it — the aliasing is specific to
1357 // the drop rule, not to EXPAND as a whole.)
1358 let use_bland =
1359 force_bland || matches!(opts.anti_cycling, AntiCyclingChoice::Bland);
1360
1361 let mut worst: Option<(DropTarget, Number)> = None;
1362 let consider =
1363 |worst: &mut Option<(DropTarget, Number)>, target: DropTarget, viol: Number| {
1364 if viol <= opts.opt_tol {
1365 return;
1366 }
1367 let take = match *worst {
1368 None => true,
1369 Some((prev_target, prev_viol)) => {
1370 if use_bland {
1371 // Smallest index wins. Compare
1372 // problem-space indices regardless
1373 // of cons-vs-bound; cons indices
1374 // come first.
1375 let new_key = drop_target_key(target);
1376 let prev_key = drop_target_key(prev_target);
1377 new_key < prev_key
1378 } else {
1379 viol > prev_viol
1380 }
1381 }
1382 };
1383 if take {
1384 *worst = Some((target, viol));
1385 }
1386 };
1387
1388 for (j, &i) in active_cons.iter().enumerate() {
1389 let lam = rhs[n + j];
1390 let viol = match working.constraints[i] {
1391 ConsStatus::AtLower => lam,
1392 ConsStatus::AtUpper => -lam,
1393 ConsStatus::Equality => 0.0,
1394 ConsStatus::Inactive => unreachable!(),
1395 };
1396 consider(&mut worst, DropTarget::Cons(i), viol);
1397 }
1398 for (j, &i) in active_bounds.iter().enumerate() {
1399 let lam = rhs[n + k_c + j];
1400 let viol = match working.bounds[i] {
1401 BoundStatus::AtLower => lam,
1402 BoundStatus::AtUpper => -lam,
1403 BoundStatus::Fixed => 0.0,
1404 BoundStatus::Inactive => unreachable!(),
1405 };
1406 consider(&mut worst, DropTarget::Bound(i), viol);
1407 }
1408
1409 if let Some((target, _viol)) = worst {
1410 match target {
1411 DropTarget::Cons(i) => working.constraints[i] = ConsStatus::Inactive,
1412 DropTarget::Bound(i) => working.bounds[i] = BoundStatus::Inactive,
1413 }
1414 n_changes += 1;
1415 continue;
1416 }
1417
1418 let mut lambda_g = vec![0.0; m];
1419 for (j, &i) in active_cons.iter().enumerate() {
1420 lambda_g[i] = rhs[n + j];
1421 }
1422 let mut lambda_x = vec![0.0; n];
1423 for (j, &i) in active_bounds.iter().enumerate() {
1424 lambda_x[i] = -rhs[n + k_c + j];
1425 }
1426
1427 return Ok(QpSolution {
1428 obj: quad_objective(qp, &x),
1429 x,
1430 lambda_g,
1431 lambda_x,
1432 working,
1433 status: QpStatus::Optimal,
1434 stats: QpStats {
1435 n_working_set_changes: n_changes,
1436 n_refactor,
1437 n_schur_updates: 0,
1438 used_phase1: false,
1439 time: started.elapsed(),
1440 ..Default::default()
1441 },
1442 unbounded_ray: None,
1443 });
1444 }
1445
1446 // Ratio test along p — scan inactive constraints AND
1447 // inactive bounds. For inactive constraint i, the rate
1448 // of change of `a_iᵀ x` along p is `a_iᵀ p`.
1449 let p: Vec<Number> = rhs[..n].to_vec();
1450 let ap = a_times_x(qp.a, &p, m);
1451 let ax = a_times_x(qp.a, &x, m);
1452
1453 // Collect every blocking direction as
1454 // (target, ratio, |a·p|).
1455 // The first pass below populates this list; the second
1456 // pass selects a winner per the active-cycling rule.
1457 // For Bland / steepest-violation the selection is the
1458 // strict-minimum ratio (with index- or step-magnitude
1459 // tie-break baked into the encounter order); for
1460 // EXPAND we use a Harris-style two-pass that picks the
1461 // largest-|a·p| direction among constraints within
1462 // tolerance of the minimum — this is the "guarantee
1463 // strict progress at degenerate vertices" half of GMSW
1464 // EXPAND (Hattingh 1989; Maros 1996 §4.2). The
1465 // primal-perturbation half (τ-growth + snap-reset) is
1466 // a follow-up commit.
1467 let mut candidates: Vec<(BlockerTarget, f64, f64)> = Vec::new();
1468 for i in 0..n {
1469 if working.bounds[i].is_active() {
1470 continue;
1471 }
1472 // Rank-tabu (rate-aware): a bound pruned as linearly
1473 // dependent has true `a·p = 0`, so suppress it from the
1474 // ratio test only while its rate stays in the drift band
1475 // (`|p[i]| ≤ TABU_DRIFT_REL·‖p‖∞`). If the active set has
1476 // since evolved and this bound now carries an O(1) rate,
1477 // it is a GENUINE blocker — let it through so the step is
1478 // capped (otherwise ‖p‖ overshoots to ~1e14) and Bland's
1479 // lowest-index rule sees the true candidate set.
1480 if tabu_bounds[i] && p[i].abs() <= TABU_DRIFT_REL * p_inf {
1481 continue;
1482 }
1483 if p[i] < -opts.feas_tol && qp.xl[i] > NLP_LOWER_BOUND_INF {
1484 let r = (x[i] - qp.xl[i]) / -p[i];
1485 candidates.push((BlockerTarget::Bound(i, BoundStatus::AtLower), r, p[i].abs()));
1486 }
1487 if p[i] > opts.feas_tol && qp.xu[i] < NLP_UPPER_BOUND_INF {
1488 let r = (qp.xu[i] - x[i]) / p[i];
1489 candidates.push((BlockerTarget::Bound(i, BoundStatus::AtUpper), r, p[i].abs()));
1490 }
1491 }
1492 for i in 0..m {
1493 if working.constraints[i].is_active() {
1494 continue;
1495 }
1496 if qp.bl[i] == qp.bu[i] {
1497 continue;
1498 }
1499 // Rank-tabu (rate-aware): see the bound loop above — a
1500 // pruned-dependent row has true `a·p = 0`, so suppress it
1501 // only while its rate stays in the drift band; a genuine
1502 // O(1) rate re-admits it so the step is capped and Bland
1503 // sees the true candidate set.
1504 if tabu_cons[i] && ap[i].abs() <= TABU_DRIFT_REL * p_inf {
1505 continue;
1506 }
1507 if ap[i] < -opts.feas_tol && qp.bl[i] > NLP_LOWER_BOUND_INF {
1508 let r = (ax[i] - qp.bl[i]) / -ap[i];
1509 candidates.push((BlockerTarget::Cons(i, ConsStatus::AtLower), r, ap[i].abs()));
1510 }
1511 if ap[i] > opts.feas_tol && qp.bu[i] < NLP_UPPER_BOUND_INF {
1512 let r = (qp.bu[i] - ax[i]) / ap[i];
1513 candidates.push((BlockerTarget::Cons(i, ConsStatus::AtUpper), r, ap[i].abs()));
1514 }
1515 }
1516 // (The rate-aware tabu skip is applied at the top of each loop
1517 // above: a pruned dependent row enters `candidates` only once
1518 // its rate along `p` leaves the linear-dependence drift band.)
1519
1520 // §4.5 companion: a δ-shifted direction is not minimized by
1521 // the unit step (see `model_step_cap`), so let the ratio test
1522 // run out to the model's own minimizer along `p`.
1523 let alpha_cap = model_step_cap(qp.h, qp.g, &hx, &p, delta);
1524
1525 let (mut alpha, blocker) =
1526 select_blocker(&candidates, opts, expand_tol, force_bland, alpha_cap);
1527
1528 // F2(a): certified unboundedness on the active-set path. An
1529 // empty candidate list means NO inactive row or bound blocks
1530 // along `+p` (and `p` already lies in the active constraints'
1531 // null space), so `+p` is feasible for every step length — a
1532 // recession ray if it is also zero-curvature and descent.
1533 // We only reach for this when the inertia shift fired
1534 // (`delta > 0`, i.e. the reduced Hessian was singular on the
1535 // active null space); a PD reduced Hessian gives a finite
1536 // Newton step and never trips here. Without this the loop
1537 // takes unbounded full steps until `MaxIter` (δ discarded).
1538 //
1539 // F2(b) is the negative-curvature sibling: `alpha_cap` is
1540 // infinite exactly when the model falls forever along `p`, and
1541 // `select_blocker` only returns it when nothing blocks — the
1542 // same recession-ray certificate with `pᵀHp < 0` in place of
1543 // `Hp = 0`. `ray_is_unbounded_descent` cannot see that case (it
1544 // demands zero curvature, correctly, since it also serves the
1545 // PSD paths), so it is checked separately below.
1546 //
1547 // Both are suppressed by `certify_recession_ray = false`, which
1548 // asks for a point rather than a verdict (gh #423); the α clamp
1549 // below then turns the unblocked direction into the δ-shifted
1550 // proximal step and the loop carries on.
1551 if candidates.is_empty()
1552 && delta > 0.0
1553 && opts.certify_recession_ray
1554 && (!alpha.is_finite() || ray_is_unbounded_descent(qp.h, qp.g, &x, &p))
1555 {
1556 let ray = p.clone();
1557 return Ok(QpSolution {
1558 obj: Number::NEG_INFINITY,
1559 x,
1560 lambda_g: vec![0.0; m],
1561 lambda_x: vec![0.0; n],
1562 working,
1563 status: QpStatus::Unbounded,
1564 stats: QpStats {
1565 n_working_set_changes: n_changes,
1566 n_refactor,
1567 n_schur_updates: 0,
1568 used_phase1: false,
1569 time: started.elapsed(),
1570 ..Default::default()
1571 },
1572 unbounded_ray: Some(ray),
1573 });
1574 }
1575
1576 if alpha < 0.0 {
1577 alpha = 0.0;
1578 }
1579 if !alpha.is_finite() {
1580 // The δ-shifted proximal step. Reached either when
1581 // `certify_recession_ray` declined the F2 return just above,
1582 // or — in principle unreachably, since an infinite
1583 // `alpha_cap` survives `select_blocker` only with an empty
1584 // candidate list — if a NaN ratio ever gets here; clamping
1585 // beats propagating a non-finite iterate.
1586 alpha = 1.0;
1587 }
1588
1589 // A genuine step changes the iterate, so the null space of the
1590 // active set moves and the rank-tabu list (built at the prior
1591 // stationary vertex) no longer applies — lift it so legitimately
1592 // independent rows can re-enter. Degenerate `α ≈ 0` pivots leave
1593 // the vertex fixed, so the tabu persists and keeps breaking the
1594 // prune→re-add cycle.
1595 if alpha > opts.feas_tol {
1596 tabu_cons.iter_mut().for_each(|t| *t = false);
1597 tabu_bounds.iter_mut().for_each(|t| *t = false);
1598 }
1599
1600 for (xi, &pi) in x.iter_mut().zip(p.iter()) {
1601 *xi += alpha * pi;
1602 }
1603
1604 if let Some(blk) = blocker {
1605 match blk {
1606 BlockerTarget::Bound(i, status) => {
1607 match status {
1608 BoundStatus::AtLower => x[i] = qp.xl[i],
1609 BoundStatus::AtUpper => x[i] = qp.xu[i],
1610 _ => unreachable!(),
1611 }
1612 working.bounds[i] = status;
1613 }
1614 BlockerTarget::Cons(i, status) => {
1615 // No primal snap: `α` was chosen so that
1616 // a_iᵀ (x + α p) is exactly at the boundary
1617 // by construction.
1618 working.constraints[i] = status;
1619 }
1620 }
1621 n_changes += 1;
1622 }
1623
1624 // EXPAND τ growth / hard reset. Per Gill-Murray-
1625 // Saunders-Wright 1989 §3, τ only grows when a
1626 // constraint actually blocked (α < 1 with a blocker
1627 // picked). Growing on every iteration regardless
1628 // (PR #50 review C5) unnecessarily forces the hard
1629 // reset on non-degenerate problems. No-op when
1630 // `anti_cycling != Expand` (select_blocker ignores τ).
1631 if matches!(opts.anti_cycling, AntiCyclingChoice::Expand) && blocker.is_some() {
1632 expand_tol += opts.expand_tol_growth;
1633 }
1634 if expand_tol > opts.expand_tol_max {
1635 // Cycling-protection hard reset: snap every
1636 // active-bound primal exactly to its bound to
1637 // clean out accumulated τ-relaxation drift.
1638 for (i, &status) in working.bounds.iter().enumerate() {
1639 match status {
1640 BoundStatus::AtLower | BoundStatus::Fixed => x[i] = qp.xl[i],
1641 BoundStatus::AtUpper => x[i] = qp.xu[i],
1642 BoundStatus::Inactive => {}
1643 }
1644 }
1645 expand_tol = opts.expand_tol_initial;
1646 }
1647
1648 // Anti-stall monitor: latch into Bland's rule once the
1649 // objective stops improving for `stall_limit` consecutive
1650 // iterations. Uses a relative-plus-absolute improvement test
1651 // so it is scale-invariant (the elastic phase-1 objective is
1652 // ~γ·infeasibility, often 1e7+). Once latched it stays
1653 // latched; Bland then guarantees finite termination.
1654 if !force_bland {
1655 let obj_now = quad_objective(qp, &x);
1656 let improved = obj_now < best_obj - 1e-9 * best_obj.abs() - 1e-12;
1657 if improved {
1658 best_obj = obj_now;
1659 stall_iters = 0;
1660 } else {
1661 stall_iters += 1;
1662 if stall_iters >= STALL_LIMIT {
1663 force_bland = true;
1664 }
1665 }
1666 }
1667 }
1668
1669 Ok(QpSolution {
1670 obj: quad_objective(qp, &x),
1671 x,
1672 lambda_g: vec![0.0; m],
1673 lambda_x: vec![0.0; n],
1674 working,
1675 status: QpStatus::MaxIter,
1676 stats: QpStats {
1677 n_working_set_changes: n_changes,
1678 n_refactor,
1679 n_schur_updates: 0,
1680 used_phase1: false,
1681 time: started.elapsed(),
1682 ..Default::default()
1683 },
1684 unbounded_ray: None,
1685 })
1686 }
1687
1688 /// Build a cold-start `(x, working)` for [`Self::solve_general`].
1689 /// Solves the equality-relaxed KKT (only rows with `bl == bu`
1690 /// participate). Returns `Ok(None)` when the resulting `x`
1691 /// violates an inequality row or variable bound — the caller
1692 /// (typically [`Self::solve_general`]) then dispatches to the
1693 /// §4.3 elastic mode.
1694 fn cold_general_initial(
1695 &mut self,
1696 qp: &QpProblem,
1697 opts: &QpOptions,
1698 n_refactor: &mut u32,
1699 ) -> Result<Option<(Vec<Number>, WorkingSet)>, QpError> {
1700 let n = qp.n;
1701 let m = qp.m;
1702
1703 let eq_rows: Vec<usize> = (0..m).filter(|&i| qp.bl[i] == qp.bu[i]).collect();
1704 let eq_targets: Vec<Number> = eq_rows.iter().map(|&r| qp.bl[r]).collect();
1705
1706 // Factor the equality block `[H Aᵀ_eq; A_eq 0]`. If the
1707 // equalities are rank-deficient — redundant rows, the
1708 // degenerate case a pure interior-point method hands the
1709 // LP-crossover bridge — the saddle KKT is singular and no
1710 // §4.5 H-block shift can rescue a rank-deficient *constraint*
1711 // block (the shift exhausts and reports a recoverable failure).
1712 // Linear-independence guard: prune the equalities to a maximal
1713 // independent subset and retry once. A dropped row is a linear
1714 // combination of the kept ones, so at the constraint-consistent
1715 // cold point it is automatically satisfied — the feasible set is
1716 // unchanged, only the rank deficiency is removed.
1717 // The prune is a *loop*, not a single retry. `independent_active_subset`
1718 // is a numerical rank test, and its answer depends on the shift the
1719 // factorization settled at — so a subset it called independent at one δ
1720 // can be rejected at the next. Pruning 4 equality rows to 2 and
1721 // factoring those 2 hit exactly that: the retry's own masked-deficiency
1722 // guard found only 1 of them independent, and the single-shot `?`
1723 // turned a solvable QP into a hard `LinearSolverFailure` for the user.
1724 //
1725 // Iterate while the subset keeps shrinking (so termination is
1726 // guaranteed — it is a strictly decreasing set), and if it still will
1727 // not factor, return `Ok(None)`. That is this function's existing
1728 // "fall through to elastic mode" signal, and elastic is precisely the
1729 // general recovery for a cold start that cannot be formed. An `Err`
1730 // here is the one outcome that helps nobody: the caller has a
1731 // perfectly good next thing to try.
1732 let mut rows: Vec<usize> = eq_rows.clone();
1733 let mut targets: Vec<Number> = eq_targets.clone();
1734 let (x, kept_eq): (Vec<Number>, Vec<usize>) = loop {
1735 match self.factor_pinned_primal(qp, &rows, &targets, &[], &[], opts) {
1736 Ok(x) => break (x, rows),
1737 Err(e) if e.is_recoverable_factorization_failure() => {
1738 let (kept, _) = independent_active_subset(&mut self.linsol, qp, &rows, &[]);
1739 if kept.len() >= rows.len() {
1740 // Not shrinking: either genuinely full rank (so the
1741 // failure is something this guard cannot repair) or the
1742 // rank test disagrees with the factorization. Either
1743 // way, hand it to elastic rather than to the user.
1744 return Ok(None);
1745 }
1746 targets = kept.iter().map(|&r| qp.bl[r]).collect();
1747 rows = kept;
1748 }
1749 Err(e) => return Err(e),
1750 }
1751 };
1752 *n_refactor += 1;
1753
1754 // Row feasibility check — any violation routes the caller to
1755 // elastic mode.
1756 let ax = a_times_x(qp.a, &x, m);
1757
1758 // Equality rows first. Every equality was *pinned* in the KKT
1759 // above, so the kept ones are satisfied by construction and
1760 // this costs nothing — but the rank guard may have PRUNED
1761 // some, and a pruned equality is only satisfied if it is both
1762 // linearly dependent on the kept ones *and consistent* with
1763 // them. Contradictory equalities (`x₀+x₁ = 1` and `x₀+x₁ = 3`)
1764 // are exactly the dependent-but-inconsistent case: the guard
1765 // prunes one, `x` satisfies the survivor, and the pruned row
1766 // is violated by 2.
1767 //
1768 // This loop used to `continue` past every `bl == bu` row, so
1769 // that violation was never seen: the caller took the returned
1770 // point as feasible, ran phase-2 on an infeasible iterate, and
1771 // reported `NumericalError` instead of routing to the elastic
1772 // phase-1 that would have certified the QP infeasible.
1773 //
1774 // It stayed hidden because the homotopy masked it — the path
1775 // reached `t = 1`, the corrector's own warm-start pre-check
1776 // caught the bad point, and elastic got its chance anyway. Only
1777 // the *seedless* cold route reaches this loop with a pruned
1778 // equality, and before #413 added a seeded retry nothing
1779 // exercised it on an infeasible model.
1780 for i in 0..m {
1781 if qp.bl[i] != qp.bu[i] {
1782 continue;
1783 }
1784 if (ax[i] - qp.bl[i]).abs() > opts.feas_tol {
1785 return Ok(None);
1786 }
1787 }
1788
1789 for i in 0..m {
1790 if qp.bl[i] == qp.bu[i] {
1791 continue;
1792 }
1793 if qp.bl[i] > NLP_LOWER_BOUND_INF && ax[i] < qp.bl[i] - opts.feas_tol {
1794 return Ok(None);
1795 }
1796 if qp.bu[i] < NLP_UPPER_BOUND_INF && ax[i] > qp.bu[i] + opts.feas_tol {
1797 return Ok(None);
1798 }
1799 }
1800 for (i, &xi) in x.iter().enumerate() {
1801 if qp.xl[i] > NLP_LOWER_BOUND_INF && xi < qp.xl[i] - opts.feas_tol {
1802 return Ok(None);
1803 }
1804 if qp.xu[i] < NLP_UPPER_BOUND_INF && xi > qp.xu[i] + opts.feas_tol {
1805 return Ok(None);
1806 }
1807 }
1808
1809 // Build the working set: equalities always active; rows /
1810 // bounds exactly at their boundary value snapped to active.
1811 let mut working = WorkingSet::cold(n, m);
1812 let mut kept_eq_flag = vec![false; m];
1813 for &r in &kept_eq {
1814 kept_eq_flag[r] = true;
1815 }
1816 for (i, c) in working.constraints.iter_mut().enumerate() {
1817 if qp.bl[i] == qp.bu[i] {
1818 if kept_eq_flag[i] {
1819 *c = ConsStatus::Equality;
1820 }
1821 // A redundant equality dropped by the rank-repair guard
1822 // stays Inactive: the ratio test skips `bl == bu` rows,
1823 // so it never re-enters the working set, and it remains
1824 // satisfied as a combination of the kept equalities.
1825 } else if qp.bl[i] > NLP_LOWER_BOUND_INF && (ax[i] - qp.bl[i]).abs() <= opts.feas_tol {
1826 *c = ConsStatus::AtLower;
1827 } else if qp.bu[i] < NLP_UPPER_BOUND_INF && (ax[i] - qp.bu[i]).abs() <= opts.feas_tol {
1828 *c = ConsStatus::AtUpper;
1829 }
1830 }
1831 for (i, status) in working.bounds.iter_mut().enumerate() {
1832 let l = qp.xl[i];
1833 let u = qp.xu[i];
1834 let l_finite = l > NLP_LOWER_BOUND_INF;
1835 let u_finite = u < NLP_UPPER_BOUND_INF;
1836 if l_finite && u_finite && (l - u).abs() <= opts.feas_tol {
1837 *status = BoundStatus::Fixed;
1838 } else if l_finite && (x[i] - l).abs() <= opts.feas_tol {
1839 *status = BoundStatus::AtLower;
1840 } else if u_finite && (x[i] - u).abs() <= opts.feas_tol {
1841 *status = BoundStatus::AtUpper;
1842 }
1843 }
1844
1845 Ok(Some((x, working)))
1846 }
1847
1848 /// Feasibility audit (M5) + elastic repair, applied to whatever a
1849 /// solve path produced.
1850 ///
1851 /// A solve that converged to a constraint-violating point and labelled
1852 /// it `Optimal` is a wrong answer, however it got there. Two routes
1853 /// reach that state: the warm-start inner loop steps with a zero-RHS
1854 /// active-set system, so caller-marked-active residuals are frozen and
1855 /// an `Inactive` equality can never enter the working set; and the
1856 /// cold fast paths never run that loop at all, so an inconsistent
1857 /// equality system passes straight through them.
1858 ///
1859 /// On violation, recover through elastic mode. `solve_elastic`
1860 /// recurses through `solve_general` / `solve_general_schur` *directly*,
1861 /// bypassing `solve`, and seeds a slack-feasible augmented problem —
1862 /// so the recursive solve is never re-audited and the recovery cannot
1863 /// loop.
1864 fn audit_and_repair(
1865 &mut self,
1866 qp: &QpProblem,
1867 sol: QpSolution,
1868 opts: &QpOptions,
1869 ) -> Result<QpSolution, QpError> {
1870 if !matches!(sol.status, QpStatus::Optimal) || point_is_feasible(qp, &sol.x, opts.feas_tol)
1871 {
1872 return Ok(sol);
1873 }
1874 // Never-regress on the recovery. Elastic phase-1 is a *repair* for a
1875 // solve that converged to a constraint-violating point, but it is not
1876 // guaranteed to land somewhere better, and when it does not the
1877 // substitution is destructive: on Maros-Meszaros `QADLITTL` (optimum
1878 // 480319) the audited iterate sits at 500918 with a small violation,
1879 // and the elastic result that replaced it was 8.07 — the elastic seed
1880 // (origin projected into the box), essentially no answer at all. The
1881 // symptom from outside was a *larger* `max_iter` producing a far worse
1882 // objective, because only the bigger budget got far enough to reach
1883 // `Optimal` and trip this audit.
1884 //
1885 // Keep whichever point is less infeasible. Elastic still wins whenever
1886 // it does its job — driving the slacks out — which is the case this
1887 // path exists for.
1888 let before = max_violation(qp, &sol.x);
1889 let repaired = self.solve_elastic(qp, opts)?;
1890 let after = max_violation(qp, &repaired.x);
1891 if after <= before {
1892 return Ok(repaired);
1893 }
1894 // Repair regressed feasibility: keep the audited point, but do not
1895 // dress it up as `Optimal` — it violates constraints, which is exactly
1896 // what the audit established.
1897 let mut kept = sol;
1898 kept.status = QpStatus::MaxIter;
1899 Ok(kept)
1900 }
1901
1902 /// l1-elastic mode — §4.3. Builds an
1903 /// [`ElasticReformulation`], seeds the augmented problem so
1904 /// the elastic slacks absorb any infeasibility at the initial
1905 /// `x`, and routes the augmented problem through
1906 /// [`Self::solve_general`] via the standard warm-start path.
1907 /// Unpacks the augmented solution into the original variable
1908 /// space and reports `QpStatus::Infeasible` when residual
1909 /// slacks exceed `feas_tol`.
1910 fn solve_elastic(&mut self, qp: &QpProblem, opts: &QpOptions) -> Result<QpSolution, QpError> {
1911 let started = Instant::now();
1912 let n = qp.n;
1913 let m = qp.m;
1914
1915 let reform = crate::elastic::ElasticReformulation::build(qp, opts.elastic_gamma);
1916 let qp_aug = reform.as_qp();
1917
1918 // Initial `x_orig` for the augmented seed: project 0 into
1919 // the original variable box. Slacks then absorb any
1920 // remaining infeasibility.
1921 let mut x_orig = vec![0.0; n];
1922 for (xi, (&l, &u)) in x_orig.iter_mut().zip(qp.xl.iter().zip(qp.xu.iter())) {
1923 if l > NLP_LOWER_BOUND_INF && *xi < l {
1924 *xi = l;
1925 }
1926 if u < NLP_UPPER_BOUND_INF && *xi > u {
1927 *xi = u;
1928 }
1929 }
1930 let (x_aug, working_aug) = reform.initial_seed(qp, &x_orig, opts.feas_tol);
1931
1932 let ws = QpWarmStart {
1933 x: x_aug,
1934 lambda_g: vec![0.0; reform.m_aug],
1935 lambda_x: vec![0.0; reform.n_aug],
1936 working: working_aug,
1937 };
1938
1939 // Recursive solve through the standard path, honoring the
1940 // same Schur-vs-refactor choice the top-level `solve` makes
1941 // (L15: this previously hard-called `solve_general`, so an
1942 // infeasible problem solved with `use_schur_updates = true`
1943 // silently fell back to the refactor path). Both inner solvers
1944 // bypass the `solve` feasibility audit, so the recursive solve
1945 // is still never re-audited and the recovery cannot loop.
1946 // Phase-1 infeasibility minimization is inherently highly
1947 // degenerate (many slacks sit exactly at zero), so the
1948 // steepest-violation default cycles at the elastic vertices the
1949 // GEN family and even trivial LPs like `afiro` park at. Bland's
1950 // rule is provably finite; use it for the recovery solve.
1951 let mut opts_p1 = opts.clone();
1952 opts_p1.anti_cycling = AntiCyclingChoice::Bland;
1953 // The caller's iteration budget is left alone. An earlier draft
1954 // raised it here, on the theory that passes exiting at `MaxIter`
1955 // near-feasible were short of iterations — the fuzz says
1956 // otherwise: dropping the bump changes neither the false-
1957 // certificate count (0) nor the certification rate (108/143). The
1958 // γ schedule below is what does the work. Overriding the budget
1959 // was also actively wrong: `sqp_qp_max_iter = 3` asks for a
1960 // bounded solve, and a phase-1 quietly spending 2000 is not that
1961 // (`sqp_qp_options_reach_the_active_set_engine` caught it).
1962 let sol_aug = if opts_p1.use_schur_updates {
1963 self.solve_general_schur(&qp_aug, Some(&ws), &opts_p1)?
1964 } else {
1965 self.solve_general(&qp_aug, Some(&ws), &opts_p1)?
1966 };
1967
1968 // Pack the original-space solution.
1969 let x = sol_aug.x[..n].to_vec();
1970 let lambda_g = sol_aug.lambda_g.clone();
1971 let lambda_x = sol_aug.lambda_x[..n].to_vec();
1972 let mut working = WorkingSet::cold(n, m);
1973 working
1974 .constraints
1975 .copy_from_slice(&sol_aug.working.constraints);
1976 working.bounds.copy_from_slice(&sol_aug.working.bounds[..n]);
1977 if sol_aug.status == QpStatus::TimeLimit || crate::deadline::expired() {
1978 return Ok(time_limit_solution(qp, Some(&x), sol_aug.stats.n_refactor));
1979 }
1980
1981 let feasible = reform.is_feasible(&sol_aug.x, opts.feas_tol);
1982 if feasible {
1983 // Elastic drove every slack to zero ⇒ the recovered `x` is
1984 // feasible for the original QP, and optimal for it iff the
1985 // phase-1 solve *converged*: past the point where the slacks
1986 // vanish the augmented objective is the original one plus a
1987 // zero penalty, so an unconverged phase-1 leaves an ordinary
1988 // feasible-but-suboptimal iterate. That caveat used to be a
1989 // parenthetical in this comment while the code labelled the
1990 // point `Optimal` regardless — a claim contradicted by the
1991 // returned KKT residual (afiro, `sqp_qp_max_iter=3`: phase-1
1992 // exits `MaxIter` slack-feasible at objective 440 against a
1993 // −464.75 optimum, and the driver reported `Optimal` with a
1994 // KKT error of 10). Carry the inner verdict instead; the point
1995 // is still returned, just not dressed up.
1996 let obj = quad_objective(qp, &x);
1997 return Ok(QpSolution {
1998 x,
1999 lambda_g,
2000 lambda_x,
2001 working,
2002 obj,
2003 status: if sol_aug.status == QpStatus::Optimal {
2004 QpStatus::Optimal
2005 } else {
2006 QpStatus::MaxIter
2007 },
2008 stats: QpStats {
2009 n_working_set_changes: sol_aug.stats.n_working_set_changes,
2010 n_refactor: sol_aug.stats.n_refactor,
2011 n_schur_updates: sol_aug.stats.n_schur_updates,
2012 used_phase1: true,
2013 time: started.elapsed(),
2014 ..Default::default()
2015 },
2016 unbounded_ray: None,
2017 });
2018 }
2019
2020 // Residual elastic slacks remain. This is *not* automatically an
2021 // infeasibility certificate: a phase-1 active-set solve can stall
2022 // at an extremely degenerate vertex — many more active rows than
2023 // variables and no interior (Slater fails), the m/n ≫ 1 collapsed-
2024 // cone geometry of #282 — and leave sub-feas_tol residual slacks
2025 // even though a feasible point plainly exists (e.g. the QP whose
2026 // feasible set is exactly {0}). Emitting `Infeasible` there is a
2027 // FALSE certificate: a feasible problem has no Farkas proof.
2028 //
2029 // Recovery: an active-set phase-2 solve started from a feasible
2030 // point of this geometry converges in a handful of pivots (it is
2031 // the *phase-1 feasibility hunt* that is degenerate, not the
2032 // phase-2 optimization). Re-solve the ORIGINAL QP, warm-started
2033 // (via `solve_general`, which bypasses the `solve` feasibility
2034 // audit and so cannot re-enter elastic) from the near-feasible
2035 // points phase-1 produced. If any converges to a genuinely
2036 // feasible optimum, return it — this turns the #282 family from a
2037 // false `Infeasible` into the correct `x* = 0` solution.
2038 //
2039 // Candidate seeds, cheapest-first: the recovered `x`, the
2040 // elastic seed `x_orig` (0 projected into the box — feasible
2041 // whenever the origin is, which is the exact #282 optimum), and
2042 // — last, because it costs a third solve — the minimum-norm
2043 // feasible point from a CONVEX feasibility-only phase-1.
2044 //
2045 // That third seed is what makes the certificate below sound on
2046 // a nonconvex QP. The elastic solve above minimizes
2047 // `½pᵀHp + gᵀp + γ‖v‖₁`; when `H` is indefinite — which is the
2048 // default for the SQP's step QP, whose `H` is the exact ∇²L —
2049 // an active-set method returns a *local* KKT point, so its
2050 // residual slacks are not the global minimal-l1 violation and
2051 // prove nothing. Worse, γ = 1e6 turns a slack of ~1e-7 into
2052 // ~0.1 of apparent objective, so the solve settles at a far box
2053 // vertex carrying a cancelling `(v_l, v_u)` pair rather than at
2054 // the small feasible step. HS071 warm-started near its own
2055 // solution hit exactly this: the recovered point missed
2056 // `feas_tol` by a factor of two (1.95e-9 against 1e-9) on a QP
2057 // with points feasible to slack 1.66, and the SQP reported
2058 // `Infeasible_Problem_Detected` at iteration 0 (gh#484 follow-up).
2059 let mut candidates = vec![x.clone(), x_orig.clone()];
2060 // The convex phase-1's point earns a place in the seed list
2061 // whether or not it cleared `feas_tol`: it is the closest thing
2062 // to a feasible point anyone here has, and phase-2 warm-started
2063 // from it routinely polishes the last few ulps that the proximal
2064 // term's bias left behind. Only `witness` — its *verified*
2065 // feasibility — is allowed to speak to the certificate.
2066 let (p1_point, p1_verdict) = self.convex_feasibility_seed(qp, opts);
2067 if crate::deadline::expired() {
2068 return Ok(time_limit_solution(
2069 qp,
2070 p1_point.as_deref().or(Some(x.as_slice())),
2071 0,
2072 ));
2073 }
2074 if let Some(seed) = p1_point {
2075 candidates.push(seed);
2076 }
2077 let mut have_feasible_witness = p1_verdict == Some(true);
2078 for seed in candidates {
2079 if !self.recovery_seed_usable(qp, &seed) {
2080 continue;
2081 }
2082 if self.original_qp_feasible(qp, &seed, opts.feas_tol) {
2083 have_feasible_witness = true;
2084 }
2085 // Classify the seed rather than handing the inner solve a
2086 // cold working set. A cold set marks every row `Inactive`,
2087 // *including equalities* — and the warm inner loop steps
2088 // with a zero-RHS active-set system, so an equality left
2089 // Inactive can never enter the working set and is simply
2090 // never enforced. That is the same M5 failure the `solve`
2091 // audit exists to catch, and seeding it here made this
2092 // recovery useless on any QP with an equality row: phase-2
2093 // reliably converged to `Optimal` at a point violating the
2094 // equality (by 7.8 on HS071's step QP, against a seed that
2095 // satisfied it to 1e-12), failed the feasibility check
2096 // below, and fell through to the certificate.
2097 let ws_rec = QpWarmStart {
2098 x: seed.clone(),
2099 lambda_g: vec![0.0; m],
2100 lambda_x: vec![0.0; n],
2101 working: self.working_set_at(qp, &seed, opts.feas_tol),
2102 };
2103 // A recovery re-solve that itself fails (a warm-started active-
2104 // set solve on this degenerate geometry can hit a non-recoverable
2105 // factorization) is NON-fatal: this is a best-effort attempt to
2106 // improve on the phase-1 outcome, so a failure just means "this
2107 // seed did not pan out" — skip it and fall through to the
2108 // certificate/honest-status logic. Never let it turn a
2109 // (correctly) infeasible or honest result into a hard error.
2110 let rec = if opts.use_schur_updates {
2111 self.solve_general_schur(qp, Some(&ws_rec), opts)
2112 } else {
2113 self.solve_general(qp, Some(&ws_rec), opts)
2114 };
2115 let rec = match rec {
2116 Ok(r) => r,
2117 // "This seed did not pan out" does not apply to cancellation:
2118 // `continue` would start the next recovery solve on a budget
2119 // that is already gone.
2120 Err(QpError::DeadlineExpired) => return Err(QpError::DeadlineExpired),
2121 Err(_) => continue,
2122 };
2123 if rec.status == QpStatus::TimeLimit || crate::deadline::expired() {
2124 return Ok(time_limit_solution(qp, Some(&rec.x), rec.stats.n_refactor));
2125 }
2126 if rec.status == QpStatus::Optimal
2127 && self.original_qp_feasible(qp, &rec.x, opts.feas_tol)
2128 {
2129 let mut rec = rec;
2130 rec.stats.used_phase1 = true;
2131 rec.stats.time = started.elapsed();
2132 return Ok(rec);
2133 }
2134 }
2135
2136 // Recovery found no feasible *optimum*. Only now may we speak to
2137 // infeasibility, and only when both premises of the certificate
2138 // hold:
2139 //
2140 // 1. Phase-1 actually CONVERGED to its minimal-l1 optimum. If it
2141 // stalled (MaxIter / numerical breakdown) we have no
2142 // certificate; report that honest, non-committal status
2143 // instead of asserting a confident `Infeasible` we cannot
2144 // back up.
2145 // 2. No seed above was itself feasible for the original rows.
2146 // Holding a feasible point while announcing infeasibility is
2147 // a contradiction in terms — phase-2 failing to *optimize*
2148 // from it says nothing about feasibility. Downgrade to the
2149 // same non-committal status rather than certify against a
2150 // witness we are carrying.
2151 let obj = quad_objective(qp, &x);
2152 let status = match sol_aug.status {
2153 // `p1_verdict == Some(false)` is the only thing here that can
2154 // carry an infeasibility claim: a *convex* phase-1 that
2155 // converged to a minimal infeasibility real on this data's
2156 // scale. The nonconvex elastic solve above cannot — its
2157 // residual slacks are a local artefact — and neither can
2158 // "phase-2 failed to improve", which is ignorance.
2159 QpStatus::Optimal if p1_verdict == Some(false) && !have_feasible_witness => {
2160 QpStatus::Infeasible
2161 }
2162 QpStatus::Optimal => QpStatus::MaxIter,
2163 other => other,
2164 };
2165
2166 Ok(QpSolution {
2167 x,
2168 lambda_g,
2169 lambda_x,
2170 working,
2171 obj,
2172 status,
2173 stats: QpStats {
2174 n_working_set_changes: sol_aug.stats.n_working_set_changes,
2175 n_refactor: sol_aug.stats.n_refactor,
2176 n_schur_updates: sol_aug.stats.n_schur_updates,
2177 used_phase1: true,
2178 time: started.elapsed(),
2179 ..Default::default()
2180 },
2181 // Deliberately `None` even when `status` carries an `Unbounded`
2182 // forwarded from phase-1: that ray lives in the *augmented*
2183 // (elastic) space, so it is neither dimensioned nor meaningful
2184 // for the original QP. A caller that needs a witness gets no
2185 // witness and must not claim unboundedness.
2186 unbounded_ray: None,
2187 })
2188 }
2189
2190 /// Working set describing which rows and bounds `x` sits on:
2191 /// equality rows are unconditionally `Equality`, and any inequality
2192 /// row or variable bound within `feas_tol` of its boundary value is
2193 /// snapped to the side it touches.
2194 ///
2195 /// Mirrors the classification `cold_general_initial` performs on its
2196 /// own starting point. Marking equalities matters most: the warm
2197 /// inner loop cannot pull an `Inactive` equality into the working
2198 /// set, so a warm start that leaves one Inactive is a warm start
2199 /// whose equality is unenforced for the whole solve.
2200 fn working_set_at(&self, qp: &QpProblem, x: &[Number], feas_tol: Number) -> WorkingSet {
2201 let mut working = WorkingSet::cold(qp.n, qp.m);
2202 let ax = a_times_x(qp.a, x, qp.m);
2203 for (i, c) in working.constraints.iter_mut().enumerate() {
2204 if qp.bl[i] == qp.bu[i] {
2205 *c = ConsStatus::Equality;
2206 } else if qp.bl[i] > NLP_LOWER_BOUND_INF && (ax[i] - qp.bl[i]).abs() <= feas_tol {
2207 *c = ConsStatus::AtLower;
2208 } else if qp.bu[i] < NLP_UPPER_BOUND_INF && (ax[i] - qp.bu[i]).abs() <= feas_tol {
2209 *c = ConsStatus::AtUpper;
2210 }
2211 }
2212 for (i, status) in working.bounds.iter_mut().enumerate() {
2213 let l = qp.xl[i];
2214 let u = qp.xu[i];
2215 let l_finite = l > NLP_LOWER_BOUND_INF;
2216 let u_finite = u < NLP_UPPER_BOUND_INF;
2217 if l_finite && u_finite && (l - u).abs() <= feas_tol {
2218 *status = BoundStatus::Fixed;
2219 } else if l_finite && (x[i] - l).abs() <= feas_tol {
2220 *status = BoundStatus::AtLower;
2221 } else if u_finite && (x[i] - u).abs() <= feas_tol {
2222 *status = BoundStatus::AtUpper;
2223 }
2224 }
2225 working
2226 }
2227
2228 /// What the convex feasibility phase-1 was able to establish.
2229 ///
2230 /// The distinction that matters is between "I found no feasible
2231 /// point" and "I proved there is none". Only the latter licenses a
2232 /// Farkas certificate, and it takes two things the first does not:
2233 /// a *converged* subproblem, and a residual large enough to mean
2234 /// something on this data.
2235 fn certify_threshold(qp: &QpProblem, feas_tol: Number) -> Number {
2236 // Scale-relative. An absolute residual is meaningless without the
2237 // magnitudes it came from: 8e-8 is enormous on data of order 1
2238 // and pure rounding on data of order 1e6 — and both occur here.
2239 let a_inf =
2240 qp.a.values()
2241 .iter()
2242 .map(|v| v.abs())
2243 .fold(1.0_f64, f64::max);
2244 let b_inf = qp
2245 .bl
2246 .iter()
2247 .chain(qp.bu.iter())
2248 .filter(|v| v.abs() < NLP_UPPER_BOUND_INF)
2249 .map(|v| v.abs())
2250 .fold(1.0_f64, f64::max);
2251 let scale = a_inf.max(b_inf);
2252 // One part per million of the data scale. Below that, "infeasible"
2253 // and "feasible up to roundoff" are not distinguishable, so the
2254 // honest verdict is the non-committal one. Genuinely infeasible
2255 // problems are not close: their minimal infeasibility sits at the
2256 // scale of the right-hand side that created it.
2257 (1e-6 * scale).max(feas_tol)
2258 }
2259
2260 /// Largest residual a *feasible* instance can leave behind, given the
2261 /// proximal centre `r` and penalty `γ`.
2262 ///
2263 /// If any feasible `x̂` exists then `(x̂, 0)` is admissible for the
2264 /// phase-1 and costs `½‖x̂ − r‖²` with no penalty, so the optimum's
2265 /// residual `s*` obeys `γ·s* ≤ ½‖x̂ − r‖²`. Bounding `‖x̂ − r‖` by the
2266 /// box gives a number that needs no knowledge of `x̂`:
2267 ///
2268 /// ```text
2269 /// s* ≤ D² / (2γ), D² = Σ_j max(|xl_j − r_j|, |xu_j − r_j|)²
2270 /// ```
2271 ///
2272 /// A residual *above* this cannot be penalty bias — no feasible point
2273 /// exists that would have produced it — so it certifies infeasibility.
2274 /// A residual below it proves nothing either way, which is why the
2275 /// caller escalates γ (shrinking this bound) rather than concluding.
2276 ///
2277 /// A coordinate with an infinite bound has no `D` from the box. Free
2278 /// variables are ordinary — an SQP step QP inherits them from every
2279 /// unbounded NLP variable — so returning `INFINITY` there would mean
2280 /// *never* certifying such a QP infeasible, trading one wrong answer
2281 /// for a different one. Those coordinates instead take a surrogate
2282 /// from where the solve actually went, `max(|x_j − r_j|, 1)` with
2283 /// three orders of headroom. That much is engineering judgment rather
2284 /// than a theorem, and it is confined to exactly the coordinates
2285 /// where the theorem has nothing to say.
2286 fn penalty_bias_bound(qp: &QpProblem, x: &[Number], r: &[Number], gamma: Number) -> Number {
2287 if gamma <= 0.0 {
2288 return Number::INFINITY;
2289 }
2290 const FREE_HEADROOM: Number = 1e3;
2291 let mut d_sq = 0.0;
2292 for j in 0..qp.n {
2293 let (l, u) = (qp.xl[j], qp.xu[j]);
2294 let d = if l <= NLP_LOWER_BOUND_INF || u >= NLP_UPPER_BOUND_INF {
2295 (x[j] - r[j]).abs().max(1.0) * FREE_HEADROOM
2296 } else {
2297 (l - r[j]).abs().max((u - r[j]).abs())
2298 };
2299 d_sq += d * d;
2300 if !d_sq.is_finite() {
2301 return Number::INFINITY;
2302 }
2303 }
2304 d_sq / (2.0 * gamma)
2305 }
2306
2307 /// Best point the *feasibility* question alone can produce, and
2308 /// whether it is actually feasible.
2309 ///
2310 /// Solves a convexified elastic phase-1
2311 ///
2312 /// ```text
2313 /// min ½‖x − r‖² + γ·Σ(v_l + v_u)
2314 /// s.t. bl ≤ A x + v_l − v_u ≤ bu, xl ≤ x ≤ xu, v ≥ 0
2315 /// ```
2316 ///
2317 /// — the same elastic reformulation [`Self::solve_elastic`] uses, with
2318 /// the caller's objective replaced by a proximal term. That
2319 /// substitution is the point: it drops `H` and `g`, so the subproblem
2320 /// is strictly convex however indefinite the caller's `H` is, and an
2321 /// active-set solve of a strictly convex QP reaches the global
2322 /// minimum. Feasibility is a property of `A`, `bl`, `bu` and the box
2323 /// alone, so nothing about the question being asked is lost.
2324 ///
2325 /// # Why γ is escalated
2326 ///
2327 /// The proximal term is not free: it competes with the penalty. If a
2328 /// feasible `x̂` exists then `(x̂, 0)` costs `½‖x̂ − r‖²`, so the
2329 /// optimum's residual `s*` obeys `γ·s* ≤ ½‖x̂ − r‖²`, i.e.
2330 ///
2331 /// ```text
2332 /// s* ≤ ‖x̂ − r‖² / (2γ)
2333 /// ```
2334 ///
2335 /// With `r = 0`, the default `γ = 1e6` and a box of radius ~6, that
2336 /// ceiling is ~2e-5 — four orders *above* `feas_tol`. A single solve
2337 /// can therefore stop several 1e-6 short of feasible while being the
2338 /// exact optimum of what it was asked. Judging that point against
2339 /// `feas_tol` and concluding "infeasible" reads a penalty artefact as
2340 /// a Farkas certificate, which is the defect this function exists to
2341 /// prevent and, at a fixed γ, quietly reproduced.
2342 ///
2343 /// Re-centring `r` alone does not rescue it. The bound is quadratic
2344 /// in `‖x̂ − r‖`, so it collapses only if the iterate stops moving —
2345 /// and in the geometry that lands here it does not: successive passes
2346 /// travel ~1.5 along a near-feasible manifold, holding `‖x̂ − r‖`
2347 /// roughly constant and the residual with it (measured: 3.2e-6 →
2348 /// 1.8e-6 → 3.1e-7, linear at best).
2349 ///
2350 /// What does work is making γ big enough for the bound to bite, and
2351 /// the residual itself says how big: a residual `s` at the ceiling
2352 /// means `γ` is short by about `s / feas_tol`, so scaling γ by that
2353 /// ratio (with margin) drives the next pass under tolerance. From
2354 /// `s = 3.2e-6` against `feas_tol = 1e-9` that is a single step.
2355 ///
2356 /// γ is escalated rather than the proximal term shrunk, though only
2357 /// their ratio matters to the bound, because γ is a *linear*
2358 /// coefficient: it never enters the KKT factorization, only the
2359 /// right-hand side and the optimality test. Shrinking `H` toward zero
2360 /// instead would degrade the factorization and hand back the
2361 /// degenerate-vertex geometry this whole path exists to escape —
2362 /// which is also why `H = I` and not a pure LP.
2363 ///
2364 /// Returns `(x, verdict)`:
2365 ///
2366 /// * `Some(true)` — `x` is feasible for the original rows. A witness;
2367 /// it refutes any infeasibility certificate outright.
2368 /// * `Some(false)` — the phase-1 converged *and* its minimal
2369 /// infeasibility is large on this data's scale. That is a real
2370 /// certificate: the subproblem is convex, so its optimum is global.
2371 /// * `None` — nothing was established. Either the phase-1 did
2372 /// not converge, or it did but stopped at a residual too small to
2373 /// distinguish from roundoff. Both are ignorance, not proof.
2374 ///
2375 /// The point comes back in every case and is worth having even when
2376 /// the verdict is `None`: it is the closest thing to a feasible point
2377 /// anyone here has, and it makes a good phase-2 seed.
2378 fn convex_feasibility_seed(
2379 &mut self,
2380 qp: &QpProblem,
2381 opts: &QpOptions,
2382 ) -> (Option<Vec<Number>>, Option<bool>) {
2383 let n = qp.n;
2384 let idx: Vec<Index> = (1..=n as Index).collect();
2385 let h_space = SymTMatrixSpace::new(n as Index, idx.clone(), idx);
2386 let mut h_id = SymTMatrix::new(h_space);
2387 h_id.set_values(&vec![1.0; n]);
2388
2389 // Proximal centre, starting at 0 projected into the box.
2390 let mut r = vec![0.0; n];
2391 for (xi, (&l, &u)) in r.iter_mut().zip(qp.xl.iter().zip(qp.xu.iter())) {
2392 if l > NLP_LOWER_BOUND_INF && *xi < l {
2393 *xi = l;
2394 }
2395 if u < NLP_UPPER_BOUND_INF && *xi > u {
2396 *xi = u;
2397 }
2398 }
2399
2400 // Bland's rule for the reason the caller uses it: a feasibility
2401 // hunt is inherently degenerate, and Bland's is the pivot rule
2402 // that provably terminates.
2403 let mut opts_p1 = opts.clone();
2404 opts_p1.anti_cycling = AntiCyclingChoice::Bland;
2405
2406 // Four passes: one to measure the residual, one to act on it,
2407 // and two of slack for geometries where the first escalation
2408 // overshoots into a different active set. The loop exits the
2409 // moment a point clears `feas_tol`, and bails when a pass stops
2410 // improving — a residual that will not shrink under a γ two
2411 // orders larger is not penalty bias, and more passes cannot help.
2412 const MAX_PASSES: usize = 4;
2413 // γ enters the objective linearly, so a large value costs the
2414 // factorization nothing — but it still has to be a subproblem the
2415 // active-set solve can finish, and past ~1e10 it increasingly is
2416 // not. The cap is that practical ceiling, not an arithmetic one.
2417 const GAMMA_MAX: Number = 1e10;
2418 let mut gamma = opts.elastic_gamma;
2419 let threshold = Self::certify_threshold(qp, opts.feas_tol);
2420 // Best pass so far: (point, residual, did-it-converge).
2421 let mut best: Option<(Vec<Number>, Number, bool)> = None;
2422 for _ in 0..MAX_PASSES {
2423 // ½‖x − r‖² = ½xᵀx − rᵀx + const, so g = −r.
2424 let g_prox: Vec<Number> = r.iter().map(|v| -v).collect();
2425 let qp_feas = QpProblem {
2426 n,
2427 m: qp.m,
2428 h: &h_id,
2429 g: &g_prox,
2430 a: qp.a,
2431 bl: qp.bl,
2432 bu: qp.bu,
2433 xl: qp.xl,
2434 xu: qp.xu,
2435 hessian_inertia: HessianInertia::Psd,
2436 };
2437 let reform = crate::elastic::ElasticReformulation::build(&qp_feas, gamma);
2438 let qp_aug = reform.as_qp();
2439 let (x_aug, working_aug) = reform.initial_seed(&qp_feas, &r, opts.feas_tol);
2440 let ws = QpWarmStart {
2441 x: x_aug,
2442 lambda_g: vec![0.0; reform.m_aug],
2443 lambda_x: vec![0.0; reform.n_aug],
2444 working: working_aug,
2445 };
2446 // Direct inner call, as elsewhere on this path: bypasses the
2447 // `solve` feasibility audit so this can never re-enter elastic.
2448 let sol = match if opts_p1.use_schur_updates {
2449 self.solve_general_schur(&qp_aug, Some(&ws), &opts_p1)
2450 } else {
2451 self.solve_general(&qp_aug, Some(&ws), &opts_p1)
2452 } {
2453 Ok(sol) => sol,
2454 // A phase-1 that errors establishes nothing; keep whatever
2455 // earlier passes found and stop. Never let a best-effort
2456 // refutation turn into a hard error.
2457 Err(_) => break,
2458 };
2459
2460 let x = sol.x[..n].to_vec();
2461 if sol.status == QpStatus::TimeLimit || crate::deadline::expired() {
2462 return (Some(x), None);
2463 }
2464 if !x.iter().all(|v| v.is_finite()) {
2465 break;
2466 }
2467 let viol = max_violation(qp, &x);
2468 let converged = sol.status == QpStatus::Optimal;
2469 let improved = best.as_ref().is_none_or(|(_, b, _)| viol < *b);
2470 if improved {
2471 best = Some((x.clone(), viol, converged));
2472 }
2473 if self.original_qp_feasible(qp, &x, opts.feas_tol) {
2474 return (Some(x), Some(true));
2475 }
2476 // A converged pass can answer the question outright, but only
2477 // once its residual is too big to be penalty bias — and that
2478 // is not a guess, it is the bound: with `(x̂, 0)` costing at
2479 // most `½‖x̂ − r‖²` and the box bounding `‖x̂ − r‖`, no
2480 // feasible instance can leave a residual above
2481 // `D²/(2γ)`. Above that (and above the noise floor) the
2482 // convex subproblem's optimum *is* the global minimal
2483 // infeasibility, so this is a proof.
2484 //
2485 // Stopping here matters: escalating γ past a solved problem
2486 // only makes it harder, and an escalated pass that then runs
2487 // out of iterations discards the proof. Without this branch
2488 // the fuzz certified 108/143 instead of 127/143. Without the
2489 // bias bound in the test, a feasible instance whose bias
2490 // (3.2e-6) merely exceeded the noise floor (2.7e-6) was
2491 // certified infeasible — the exact defect being fixed.
2492 let bias_bound = Self::penalty_bias_bound(qp, &x, &r, gamma);
2493 if converged && viol > threshold.max(bias_bound) {
2494 return (Some(x), Some(false));
2495 }
2496 if !improved {
2497 break;
2498 }
2499 // Everything left is the ambiguous band — converged, but at a
2500 // residual that could still be penalty bias. Raise γ, and aim
2501 // it rather than just cranking it: a bigger γ buys a smaller
2502 // bias bound at the cost of a harder subproblem, so overshoot
2503 // is not free. Two targets, whichever is larger:
2504 //
2505 // * enough to push the *bias bound* an order below this
2506 // residual, `γ ≥ 10·D²/(2s)`, which is what lets the next
2507 // pass certify;
2508 // * enough to push the *residual itself* under `feas_tol`,
2509 // `γ ≈ γ·s/feas_tol`, which is what lets it find a witness.
2510 //
2511 // Whichever fires, the point is that both are computed from
2512 // measured quantities. Jumping straight to the cap instead —
2513 // as scaling by `s/feas_tol` alone does on an infeasible
2514 // instance, where the residual never drops — leaves γ at 1e14
2515 // and the subproblem too hard to converge, which then throws
2516 // away the certificate: 107/143 rather than 118/143.
2517 //
2518 // Only when the pass converged: the bound describes an
2519 // optimum, so a residual it explains is one the solver
2520 // actually reached. A pass that ran out of iterations has no
2521 // such story, and a larger γ makes it harder, not easier.
2522 if converged && viol > 0.0 && gamma < GAMMA_MAX {
2523 // `bias_bound` scales as 1/γ, so the γ that would put it a
2524 // factor of 10 under this residual is `γ·10·bias/viol`.
2525 let for_certificate = gamma * 10.0 * bias_bound / viol;
2526 let for_witness = gamma * (viol / opts.feas_tol.max(Number::MIN_POSITIVE)) * 10.0;
2527 let target = if for_certificate.is_finite() {
2528 for_certificate.min(for_witness)
2529 } else {
2530 for_witness
2531 };
2532 // Written as max-then-min rather than `clamp`: once γ is
2533 // within a decade of the cap the "at least ×10" floor
2534 // exceeds the ceiling, and `clamp` panics on an inverted
2535 // range. (It did — the fuzz caught it on the first run.)
2536 gamma = target.max(gamma * 10.0).min(GAMMA_MAX);
2537 }
2538 r = x;
2539 }
2540
2541 match best {
2542 None => (None, None),
2543 Some((x, viol, converged)) => {
2544 let bias_bound = Self::penalty_bias_bound(qp, &x, &r, gamma);
2545 let verdict = if converged && viol > threshold.max(bias_bound) {
2546 // Converged, on a convex subproblem, to a minimal
2547 // infeasibility that is real on this data. A proof.
2548 Some(false)
2549 } else {
2550 // Either it never converged, or it stopped at a
2551 // residual indistinguishable from roundoff. Neither
2552 // establishes anything; say so rather than guess.
2553 None
2554 };
2555 (Some(x), verdict)
2556 }
2557 }
2558 }
2559
2560 /// True when `seed` is a sane warm-start point for the phase-2
2561 /// recovery re-solve in [`Self::solve_elastic`]: every entry is
2562 /// finite and inside the variable box (with a small feas_tol slack).
2563 /// A seed carrying a NaN/inf or grossly out-of-box coordinate would
2564 /// just send `solve_general` off into its own failure path, so skip
2565 /// it rather than burn a recovery solve on it.
2566 fn recovery_seed_usable(&self, qp: &QpProblem, seed: &[Number]) -> bool {
2567 for (i, &xi) in seed.iter().enumerate() {
2568 if !xi.is_finite() {
2569 return false;
2570 }
2571 if qp.xl[i] > NLP_LOWER_BOUND_INF && xi < qp.xl[i] - 1e-6 {
2572 return false;
2573 }
2574 if qp.xu[i] < NLP_UPPER_BOUND_INF && xi > qp.xu[i] + 1e-6 {
2575 return false;
2576 }
2577 }
2578 true
2579 }
2580
2581 /// True when `x` satisfies every original general-constraint row and
2582 /// variable bound to within `feas_tol`. Used to confirm a phase-2
2583 /// recovery re-solve landed on a genuinely feasible point before its
2584 /// `Optimal` status is trusted over a false `Infeasible`.
2585 fn original_qp_feasible(&self, qp: &QpProblem, x: &[Number], feas_tol: Number) -> bool {
2586 let ax = a_times_x(qp.a, x, qp.m);
2587 for i in 0..qp.m {
2588 if qp.bl[i] > NLP_LOWER_BOUND_INF && ax[i] < qp.bl[i] - feas_tol {
2589 return false;
2590 }
2591 if qp.bu[i] < NLP_UPPER_BOUND_INF && ax[i] > qp.bu[i] + feas_tol {
2592 return false;
2593 }
2594 }
2595 for (i, &xi) in x.iter().enumerate() {
2596 if qp.xl[i] > NLP_LOWER_BOUND_INF && xi < qp.xl[i] - feas_tol {
2597 return false;
2598 }
2599 if qp.xu[i] < NLP_UPPER_BOUND_INF && xi > qp.xu[i] + feas_tol {
2600 return false;
2601 }
2602 }
2603 true
2604 }
2605
2606 /// Schur-based variant of [`Self::solve_general`]. Opt-in via
2607 /// `QpOptions::use_schur_updates`. Replaces the per-iteration
2608 /// refactor with a cached factor of the fixed-dim K_max
2609 /// matrix and Sherman-Morrison-Woodbury rank-2 updates per
2610 /// working-set change. Resets the cached factor when the
2611 /// Schur block reaches `max_schur_updates_before_refactor`.
2612 ///
2613 /// Behavior matches the refactor-per-iteration path on every
2614 /// problem with a positive-definite reduced Hessian: same drop /
2615 /// ratio-test logic, same exit conditions. The difference is the
2616 /// inner-loop cost: one cached resolve + small dense Schur solve
2617 /// per iteration, plus two cached resolves per working-set change.
2618 ///
2619 /// Caveat (indefinite reduced Hessian only): the refactor path
2620 /// runs `factorize_with_inertia_control` — re-checking inertia
2621 /// and applying a δ-shift — on *every* iteration, whereas this
2622 /// path only runs inertia control inside `SchurState::reset`
2623 /// (at init and every `max_schur_updates_before_refactor`
2624 /// working-set changes). The rank-2 SMW update in `apply_change`
2625 /// does *not* re-check inertia. A DROP enlarges the active-set
2626 /// null space and can expose negative curvature that the cached
2627 /// factor does not regularize until the next reset; an ADD only
2628 /// shrinks the null space and cannot introduce new negative
2629 /// curvature. For the convex default (`HessianInertia::Psd`,
2630 /// which is what the SQP driver feeds) the reduced Hessian is
2631 /// always PD, so the two paths are identical; the gap is latent
2632 /// for indefinite inputs on the opt-in `use_schur_updates = true`
2633 /// path. See code-review item M10.
2634 /// Reset the Schur base factor, **repairing a rank-deficient active set**
2635 /// rather than failing on it.
2636 ///
2637 /// At a degenerate vertex more rows can be binding than there are
2638 /// variables, and those extra rows are linearly dependent — an LICQ
2639 /// violation. The resulting active-set KKT is singular, and no §4.5 H-block
2640 /// shift can repair a rank-deficient *constraint* block, so the inertia
2641 /// loop simply exhausts and reports failure.
2642 ///
2643 /// [`Self::solve_general`] has carried this guard for a long time;
2644 /// `solve_general_schur` never did. That asymmetry was invisible while the
2645 /// Schur path was opt-in, and became the dominant failure mode the moment
2646 /// it was switched on for the convex active-set driver: 27 of 138
2647 /// Maros-Mészáros problems (`QSHARE2B`, `QSCTAP1`, …) turned into a hard
2648 /// `LinearSolverFailure("KKT matrix is singular (LICQ violation or
2649 /// rank-deficient Jacobian)")` where the refactor path had merely failed to
2650 /// converge.
2651 ///
2652 /// The repair is the same one the refactor path and
2653 /// [`Self::cold_general_initial`] use: prune the active set to a maximal
2654 /// linearly independent subset and deactivate the rest. A dropped row is a
2655 /// linear combination of the kept ones, so it stays satisfied at the
2656 /// current `x` and the feasible set is unchanged — only the rank deficiency
2657 /// is removed. Deactivating a bound does not move `x`, so the iterate stays
2658 /// feasible throughout.
2659 ///
2660 /// Each repair strictly shrinks the active set, so the inner loop
2661 /// terminates. `budget` additionally caps how many repairs one *solve* may
2662 /// perform: the ratio test can re-admit a pruned row on a later iteration,
2663 /// and without the refactor path's rank-tabu bookkeeping there is nothing
2664 /// here to stop a prune/re-add cycle. Exhausting the budget surfaces the
2665 /// original error instead of spinning.
2666 fn schur_reset_rank_repaired(
2667 &mut self,
2668 schur: &mut crate::schur::SchurState,
2669 qp: &QpProblem,
2670 working: &mut WorkingSet,
2671 opts: &QpOptions,
2672 n_changes: &mut u32,
2673 budget: &mut u32,
2674 ) -> Result<(), QpError> {
2675 loop {
2676 let ac = active_slot_count(working);
2677 match schur.reset(&mut self.linsol, qp, working, ac as i32, opts) {
2678 Ok(()) => return Ok(()),
2679 Err(e) if e.is_recoverable_factorization_failure() => {
2680 if *budget == 0 {
2681 return Err(e);
2682 }
2683 let active_cons: Vec<usize> = (0..qp.m)
2684 .filter(|&i| working.constraints[i].is_active())
2685 .collect();
2686 let active_bounds: Vec<usize> = (0..qp.n)
2687 .filter(|&i| working.bounds[i].is_active())
2688 .collect();
2689 let (kc, kb) = independent_active_subset(
2690 &mut self.linsol,
2691 qp,
2692 &active_cons,
2693 &active_bounds,
2694 );
2695 // Already full rank ⇒ the failure is not a rank deficiency
2696 // this guard can repair; do not loop on it.
2697 if kc.len() == active_cons.len() && kb.len() == active_bounds.len() {
2698 return Err(e);
2699 }
2700 *budget -= 1;
2701 let mut keep_c = vec![false; qp.m];
2702 for &i in &kc {
2703 keep_c[i] = true;
2704 }
2705 let mut keep_b = vec![false; qp.n];
2706 for &i in &kb {
2707 keep_b[i] = true;
2708 }
2709 for &i in &active_cons {
2710 if !keep_c[i] {
2711 working.constraints[i] = ConsStatus::Inactive;
2712 *n_changes += 1;
2713 }
2714 }
2715 for &i in &active_bounds {
2716 if !keep_b[i] {
2717 working.bounds[i] = BoundStatus::Inactive;
2718 *n_changes += 1;
2719 }
2720 }
2721 }
2722 Err(e) => return Err(e),
2723 }
2724 }
2725 }
2726
2727 fn solve_general_schur(
2728 &mut self,
2729 qp: &QpProblem,
2730 ws: Option<&QpWarmStart>,
2731 opts: &QpOptions,
2732 ) -> Result<QpSolution, QpError> {
2733 let started = Instant::now();
2734 let n = qp.n;
2735 let m = qp.m;
2736 let m_total = m + n;
2737 let mut n_refactor: u32 = 0;
2738 let mut n_changes: u32 = 0;
2739 let mut n_schur_updates: u32 = 0;
2740 // Rank repairs allowed for this solve. Generous relative to the number
2741 // of genuinely dependent rows a degenerate vertex carries, but finite —
2742 // see `schur_reset_rank_repaired` on why a cap is needed here and not
2743 // on the refactor path.
2744 let mut rank_repair_budget: u32 = (qp.n + qp.m).min(1000) as u32;
2745
2746 let (mut x, mut working) = if let Some(w) = ws {
2747 (w.x.clone(), w.working.clone())
2748 } else {
2749 match self.cold_general_initial(qp, opts, &mut n_refactor)? {
2750 Some(p) => p,
2751 None => return self.solve_elastic(qp, opts),
2752 }
2753 };
2754
2755 for (i, &status) in working.bounds.iter().enumerate() {
2756 match status {
2757 BoundStatus::AtLower | BoundStatus::Fixed => x[i] = qp.xl[i],
2758 BoundStatus::AtUpper => x[i] = qp.xu[i],
2759 BoundStatus::Inactive => {}
2760 }
2761 }
2762
2763 // Initialize Schur and factor the base K_max.
2764 let mut schur = crate::schur::SchurState::new(n, m);
2765 self.schur_reset_rank_repaired(
2766 &mut schur,
2767 qp,
2768 &mut working,
2769 opts,
2770 &mut n_changes,
2771 &mut rank_repair_budget,
2772 )?;
2773 n_refactor += 1;
2774
2775 // GMSW EXPAND τ — same semantics as in solve_general.
2776 let mut expand_tol = opts.expand_tol_initial;
2777
2778 // ---- Null-iteration guard (numerical floor / SMW drift) ----
2779 //
2780 // An iteration that takes the full step (`α = 1`, no blocker added)
2781 // lands, in exact arithmetic, exactly on the minimizer of the current
2782 // working set — so the very next `‖p‖∞` is zero and the loop moves on
2783 // to the drop test. When it is *not* zero, the limit is the linear
2784 // algebra rather than the algorithm, and the loop repeats a literal
2785 // no-op until `max_iter`. Both variants were measured on
2786 // Maros-Mészáros `CVXQP3_S` (3650 of its 3750 iterations were such
2787 // no-ops):
2788 //
2789 // * SMW drift — after 15 accumulated rank-2 updates the direction
2790 // no longer lies in the active rows' null space at all
2791 // (`‖A_W p‖∞ ≈ 1e-3`), so the "full Newton step" is not one.
2792 // Discarding the update layer and refactoring cures this.
2793 // * Noise floor — in the `γ = 1e6` elastic phase-1 the active-set
2794 // KKT is solved to `‖r‖∞ ≈ 1e-9`, which is all the conditioning
2795 // allows, and that leaves `‖p‖∞ ≈ 1.9e-9` permanently above the
2796 // `opt_tol = 1e-9` stationarity test. No refactor helps; the
2797 // iterate simply *is* stationary to attainable precision.
2798 //
2799 // So: on the first no-op, refactor. If a fresh factor still cannot
2800 // shrink the step, accept the iterate as stationary for this working
2801 // set and let the drop test run. Accepting is safe — `QpSolver::solve`
2802 // re-audits feasibility and the convex driver re-measures the KKT
2803 // error, so a point that is not really optimal is demoted, not
2804 // believed — and it is strictly better than spending the whole budget
2805 // re-deriving the same step.
2806 let mut prev_p_inf = Number::INFINITY;
2807 let mut prev_was_null_step = false;
2808 let mut floor_refactored = false;
2809 /// A genuine Newton step drives `‖p‖∞` to round-off, so anything short
2810 /// of halving it means the step accomplished nothing.
2811 const NULL_STEP_GAIN: Number = 0.5;
2812
2813 let trace = std::env::var("POUNCE_QP_TRACE").is_ok();
2814 for _iter in 0..opts.max_iter {
2815 if crate::deadline::expired() {
2816 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
2817 }
2818 let hx = h_times_x(qp.h, &x);
2819 let mut rhs = vec![0.0; n + m_total];
2820 for (rhs_i, (hx_i, &g_i)) in rhs[..n].iter_mut().zip(hx.iter().zip(qp.g.iter())) {
2821 *rhs_i = -(hx_i + g_i);
2822 }
2823 // A singular Schur complement is a normal event in SMW updating,
2824 // not a solver breakdown: the accumulated rank-2 updates can leave
2825 // the small dense block `S` singular while the underlying
2826 // active-set KKT is perfectly well conditioned. Recover the way the
2827 // count-based path already does — discard the update layer and
2828 // refactor `K_max` against the current working set — then redo the
2829 // solve. Nothing but the failed `S⁻¹` is thrown away, so the answer
2830 // is unchanged; this is what keeps the Schur path a pure
2831 // *performance* switch.
2832 //
2833 // Without this recovery the entire solve aborted with
2834 // `LinearSolverFailure("Schur block is singular …")` on problems the
2835 // refactor path solves exactly — the reason the Schur path could not
2836 // be turned on by default. See `tests/schur_vs_refactor.rs`.
2837 let rhs_backup = rhs.clone();
2838 if let Err(e) = schur.solve(&mut self.linsol, &mut rhs) {
2839 if !e.is_recoverable_factorization_failure() {
2840 return Err(e);
2841 }
2842 self.schur_reset_rank_repaired(
2843 &mut schur,
2844 qp,
2845 &mut working,
2846 opts,
2847 &mut n_changes,
2848 &mut rank_repair_budget,
2849 )?;
2850 n_refactor += 1;
2851 // `solve` writes through `rhs`, so restore it before retrying.
2852 rhs.copy_from_slice(&rhs_backup);
2853 schur.solve(&mut self.linsol, &mut rhs)?;
2854 }
2855 if crate::deadline::expired() {
2856 return Ok(time_limit_solution(qp, Some(&x), n_refactor));
2857 }
2858
2859 let p: Vec<Number> = rhs[..n].to_vec();
2860 let p_inf = p.iter().map(|pi| pi.abs()).fold(0.0, f64::max);
2861
2862 if trace {
2863 // True residual of the *active-set* KKT system at (p, λ).
2864 // Row block 1: H p + Σ_active a_i λ_i = -(Hx + g)
2865 // Row block 2: a_iᵀ p = 0 for every active row / bound.
2866 let hp = h_times_x(qp.h, &p);
2867 let hxg = h_times_x(qp.h, &x);
2868 let mut r1: Vec<Number> = (0..n).map(|i| hp[i] + hxg[i] + qp.g[i]).collect();
2869 let (ir, jc, av) = (qp.a.irows(), qp.a.jcols(), qp.a.values());
2870 for k in 0..ir.len() {
2871 let i = (ir[k] - 1) as usize;
2872 let j = (jc[k] - 1) as usize;
2873 if working.constraints[i].is_active() {
2874 r1[j] += av[k] * rhs[n + i];
2875 }
2876 }
2877 for j in 0..n {
2878 if working.bounds[j].is_active() {
2879 r1[j] += rhs[n + m + j];
2880 }
2881 }
2882 let ap_dbg = a_times_x(qp.a, &p, m);
2883 let mut r2 = 0.0_f64;
2884 for i in 0..m {
2885 if working.constraints[i].is_active() {
2886 r2 = r2.max(ap_dbg[i].abs());
2887 }
2888 }
2889 for j in 0..n {
2890 if working.bounds[j].is_active() {
2891 r2 = r2.max(p[j].abs());
2892 }
2893 }
2894 let r1n = r1.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
2895 eprintln!(
2896 "[qp] it={_iter} RES stat={r1n:.3e} actrow={r2:.3e} pinf={p_inf:.3e} sdim={} nact={}",
2897 schur.n_schur_updates() * 2,
2898 active_slot_count(&working)
2899 );
2900 }
2901
2902 // Null-iteration guard — see the note above the loop.
2903 let mut stationary = p_inf <= opts.opt_tol;
2904 if !stationary && prev_was_null_step && p_inf > NULL_STEP_GAIN * prev_p_inf {
2905 if floor_refactored {
2906 // A fresh factor already failed to shrink the step: this is
2907 // the attainable-accuracy floor, not update drift.
2908 stationary = true;
2909 } else {
2910 floor_refactored = true;
2911 self.schur_reset_rank_repaired(
2912 &mut schur,
2913 qp,
2914 &mut working,
2915 opts,
2916 &mut n_changes,
2917 &mut rank_repair_budget,
2918 )?;
2919 n_refactor += 1;
2920 prev_was_null_step = false;
2921 prev_p_inf = Number::INFINITY;
2922 continue;
2923 }
2924 }
2925 prev_p_inf = p_inf;
2926
2927 if stationary {
2928 let mut worst: Option<(DropTarget, Number)> = None;
2929 for slot in 0..m_total {
2930 if !crate::schur::SchurState::slot_active(&working, slot) {
2931 continue;
2932 }
2933 let lam = rhs[n + slot];
2934 let (target, viol) = if slot < m {
2935 let v = match working.constraints[slot] {
2936 ConsStatus::AtLower => lam,
2937 ConsStatus::AtUpper => -lam,
2938 ConsStatus::Equality => 0.0,
2939 ConsStatus::Inactive => unreachable!(),
2940 };
2941 (DropTarget::Cons(slot), v)
2942 } else {
2943 let var = slot - m;
2944 let v = match working.bounds[var] {
2945 BoundStatus::AtLower => lam,
2946 BoundStatus::AtUpper => -lam,
2947 BoundStatus::Fixed => 0.0,
2948 BoundStatus::Inactive => unreachable!(),
2949 };
2950 (DropTarget::Bound(var), v)
2951 };
2952 if viol > worst.map(|(_, w)| w).unwrap_or(opts.opt_tol) {
2953 worst = Some((target, viol));
2954 }
2955 }
2956
2957 if let Some((target, _)) = worst {
2958 let slot = match target {
2959 DropTarget::Cons(i) => {
2960 working.constraints[i] = ConsStatus::Inactive;
2961 i
2962 }
2963 DropTarget::Bound(i) => {
2964 working.bounds[i] = BoundStatus::Inactive;
2965 m + i
2966 }
2967 };
2968 if trace {
2969 eprintln!(
2970 "[qp] it={_iter} DROP slot={slot} viol={:.3e} obj={:.12e} nact={} pinf={:.2e}",
2971 worst.unwrap().1,
2972 quad_objective(qp, &x),
2973 active_slot_count(&working),
2974 p_inf
2975 );
2976 }
2977 // Degenerate rank-2 update ⇒ refactor instead. `working`
2978 // already carries this drop, so resetting against it
2979 // reaches exactly the state the update was meant to
2980 // produce, without the update.
2981 if let Err(e) = schur.apply_change(&mut self.linsol, qp, slot, false) {
2982 if !e.is_recoverable_factorization_failure() {
2983 return Err(e);
2984 }
2985 self.schur_reset_rank_repaired(
2986 &mut schur,
2987 qp,
2988 &mut working,
2989 opts,
2990 &mut n_changes,
2991 &mut rank_repair_budget,
2992 )?;
2993 n_refactor += 1;
2994 }
2995 n_changes += 1;
2996 n_schur_updates += 1;
2997 if schur.needs_reset(opts) {
2998 self.schur_reset_rank_repaired(
2999 &mut schur,
3000 qp,
3001 &mut working,
3002 opts,
3003 &mut n_changes,
3004 &mut rank_repair_budget,
3005 )?;
3006 n_refactor += 1;
3007 }
3008 // The working set changed, so the next step is a fresh
3009 // Newton step, not a repeat: re-arm the null-iteration
3010 // guard.
3011 prev_was_null_step = false;
3012 floor_refactored = false;
3013 continue;
3014 }
3015
3016 // Optimal.
3017 let mut lambda_g = vec![0.0; m];
3018 for s in 0..m {
3019 if working.constraints[s].is_active() {
3020 lambda_g[s] = rhs[n + s];
3021 }
3022 }
3023 let mut lambda_x = vec![0.0; n];
3024 for j in 0..n {
3025 if working.bounds[j].is_active() {
3026 lambda_x[j] = -rhs[n + m + j];
3027 }
3028 }
3029
3030 return Ok(QpSolution {
3031 obj: quad_objective(qp, &x),
3032 x,
3033 lambda_g,
3034 lambda_x,
3035 working,
3036 status: QpStatus::Optimal,
3037 stats: QpStats {
3038 n_working_set_changes: n_changes,
3039 n_refactor,
3040 n_schur_updates,
3041 used_phase1: false,
3042 time: started.elapsed(),
3043 ..Default::default()
3044 },
3045 unbounded_ray: None,
3046 });
3047 }
3048
3049 // Ratio test — identical to solve_general but tracking
3050 // the slot index of the blocker for apply_change.
3051 let ap = a_times_x(qp.a, &p, m);
3052 let ax = a_times_x(qp.a, &x, m);
3053
3054 let mut candidates: Vec<(BlockerTarget, Number, Number)> = Vec::new();
3055 for i in 0..n {
3056 if working.bounds[i].is_active() {
3057 continue;
3058 }
3059 if p[i] < -opts.feas_tol && qp.xl[i] > NLP_LOWER_BOUND_INF {
3060 let r = (x[i] - qp.xl[i]) / -p[i];
3061 candidates.push((BlockerTarget::Bound(i, BoundStatus::AtLower), r, p[i].abs()));
3062 }
3063 if p[i] > opts.feas_tol && qp.xu[i] < NLP_UPPER_BOUND_INF {
3064 let r = (qp.xu[i] - x[i]) / p[i];
3065 candidates.push((BlockerTarget::Bound(i, BoundStatus::AtUpper), r, p[i].abs()));
3066 }
3067 }
3068 for i in 0..m {
3069 if working.constraints[i].is_active() {
3070 continue;
3071 }
3072 if qp.bl[i] == qp.bu[i] {
3073 continue;
3074 }
3075 if ap[i] < -opts.feas_tol && qp.bl[i] > NLP_LOWER_BOUND_INF {
3076 let r = (ax[i] - qp.bl[i]) / -ap[i];
3077 candidates.push((BlockerTarget::Cons(i, ConsStatus::AtLower), r, ap[i].abs()));
3078 }
3079 if ap[i] > opts.feas_tol && qp.bu[i] < NLP_UPPER_BOUND_INF {
3080 let r = (qp.bu[i] - ax[i]) / ap[i];
3081 candidates.push((BlockerTarget::Cons(i, ConsStatus::AtUpper), r, ap[i].abs()));
3082 }
3083 }
3084 // §4.5 companion (gh #416): a δ-shifted direction is not
3085 // minimized by the unit step — see `model_step_cap`. The shift
3086 // lives in the cached base factor here rather than in a local,
3087 // and rank-2 updates never touch the H block, so `schur.shift()`
3088 // is the δ that produced this `p`.
3089 let alpha_cap = model_step_cap(qp.h, qp.g, &hx, &p, schur.shift());
3090
3091 let (mut alpha, blocker) =
3092 select_blocker(&candidates, opts, expand_tol, false, alpha_cap);
3093
3094 // F2(a), Schur path. Same certificate as `solve_general`: an
3095 // empty candidate list means `+p` is feasible for every step
3096 // length, so a zero-curvature descent `p` is a recession ray.
3097 // The unconditional (un-gated on δ) check is safe because
3098 // `ray_is_unbounded_descent` rejects any direction with
3099 // measurable curvature (`‖Hp‖∞` above the 1e-10·‖H‖
3100 // structural-zero floor), so a PD-reduced-Hessian Newton step
3101 // never certifies. F2(b) — the negative-curvature sibling, an
3102 // infinite `alpha_cap` with nothing to block it — rides along.
3103 // Both are suppressed by `certify_recession_ray = false` (gh
3104 // #423); the α clamp below then takes the δ-shifted proximal
3105 // step instead.
3106 if candidates.is_empty()
3107 && opts.certify_recession_ray
3108 && (!alpha.is_finite() || ray_is_unbounded_descent(qp.h, qp.g, &x, &p))
3109 {
3110 let ray = p.clone();
3111 return Ok(QpSolution {
3112 obj: Number::NEG_INFINITY,
3113 x,
3114 lambda_g: vec![0.0; m],
3115 lambda_x: vec![0.0; n],
3116 working,
3117 status: QpStatus::Unbounded,
3118 stats: QpStats {
3119 n_working_set_changes: n_changes,
3120 n_refactor,
3121 n_schur_updates,
3122 used_phase1: false,
3123 time: started.elapsed(),
3124 ..Default::default()
3125 },
3126 unbounded_ray: Some(ray),
3127 });
3128 }
3129
3130 if alpha < 0.0 {
3131 alpha = 0.0;
3132 }
3133 if !alpha.is_finite() {
3134 // The δ-shifted proximal step — reached when
3135 // `certify_recession_ray` declined the F2 return above, or
3136 // (unreachably in principle, since an infinite cap survives
3137 // `select_blocker` only with an empty candidate list) if a
3138 // NaN ratio ever gets here. Clamping beats propagating a
3139 // non-finite iterate.
3140 alpha = 1.0;
3141 }
3142 if trace && blocker.is_none() {
3143 eprintln!(
3144 "[qp] it={_iter} NOBLOCK alpha={alpha:.3e} pinf={p_inf:.3e} obj={:.12e} nact={} ncand={}",
3145 quad_objective(qp, &x),
3146 active_slot_count(&working),
3147 candidates.len()
3148 );
3149 }
3150 for (xi, &pi) in x.iter_mut().zip(p.iter()) {
3151 *xi += alpha * pi;
3152 }
3153 if let Some(blk) = blocker {
3154 let slot = match blk {
3155 BlockerTarget::Bound(i, status) => {
3156 match status {
3157 BoundStatus::AtLower => x[i] = qp.xl[i],
3158 BoundStatus::AtUpper => x[i] = qp.xu[i],
3159 _ => unreachable!(),
3160 }
3161 working.bounds[i] = status;
3162 m + i
3163 }
3164 BlockerTarget::Cons(i, status) => {
3165 working.constraints[i] = status;
3166 i
3167 }
3168 };
3169 if trace {
3170 eprintln!(
3171 "[qp] it={_iter} ADD slot={slot} alpha={alpha:.3e} obj={:.12e} nact={} pinf={:.2e}",
3172 quad_objective(qp, &x),
3173 active_slot_count(&working),
3174 p_inf
3175 );
3176 }
3177 // Same recovery as the drop side: `working` already carries this
3178 // add, so a reset against it reproduces the intended state.
3179 if let Err(e) = schur.apply_change(&mut self.linsol, qp, slot, true) {
3180 if !e.is_recoverable_factorization_failure() {
3181 return Err(e);
3182 }
3183 self.schur_reset_rank_repaired(
3184 &mut schur,
3185 qp,
3186 &mut working,
3187 opts,
3188 &mut n_changes,
3189 &mut rank_repair_budget,
3190 )?;
3191 n_refactor += 1;
3192 }
3193 n_changes += 1;
3194 n_schur_updates += 1;
3195 if schur.needs_reset(opts) {
3196 self.schur_reset_rank_repaired(
3197 &mut schur,
3198 qp,
3199 &mut working,
3200 opts,
3201 &mut n_changes,
3202 &mut rank_repair_budget,
3203 )?;
3204 n_refactor += 1;
3205 }
3206 }
3207
3208 // EXPAND τ growth / hard reset (same semantics as in
3209 // solve_general; PR #50 C5 fix).
3210 if matches!(opts.anti_cycling, AntiCyclingChoice::Expand) && blocker.is_some() {
3211 expand_tol += opts.expand_tol_growth;
3212 }
3213 if expand_tol > opts.expand_tol_max {
3214 for (i, &status) in working.bounds.iter().enumerate() {
3215 match status {
3216 BoundStatus::AtLower | BoundStatus::Fixed => x[i] = qp.xl[i],
3217 BoundStatus::AtUpper => x[i] = qp.xu[i],
3218 BoundStatus::Inactive => {}
3219 }
3220 }
3221 expand_tol = opts.expand_tol_initial;
3222 }
3223
3224 // Null-iteration bookkeeping. A blocker means the working set grew,
3225 // so the next step solves a *different* system and the guard
3226 // re-arms; no blocker means a full step was taken and the next
3227 // `‖p‖∞` must be round-off if the linear algebra is sound.
3228 if blocker.is_some() {
3229 prev_was_null_step = false;
3230 floor_refactored = false;
3231 } else {
3232 prev_was_null_step = true;
3233 }
3234 }
3235
3236 Ok(QpSolution {
3237 obj: quad_objective(qp, &x),
3238 x,
3239 lambda_g: vec![0.0; m],
3240 lambda_x: vec![0.0; n],
3241 working,
3242 status: QpStatus::MaxIter,
3243 stats: QpStats {
3244 n_working_set_changes: n_changes,
3245 n_refactor,
3246 n_schur_updates,
3247 used_phase1: false,
3248 time: started.elapsed(),
3249 ..Default::default()
3250 },
3251 unbounded_ray: None,
3252 })
3253 }
3254}
3255
3256fn active_slot_count(working: &WorkingSet) -> usize {
3257 working.constraints.iter().filter(|s| s.is_active()).count()
3258 + working.bounds.iter().filter(|s| s.is_active()).count()
3259}
3260
3261/// Relative tolerance for the modified-Gram-Schmidt rank test in
3262/// [`independent_active_subset`]. A candidate normal whose component
3263/// orthogonal to the already-accepted normals falls below this
3264/// fraction of its original norm is judged linearly dependent
3265/// (redundant) and dropped.
3266const RANK_REL_TOL: Number = 1e-9;
3267
3268/// Rate threshold (relative to the step inf-norm) below which a
3269/// rank-tabu'd row is treated as genuinely linearly dependent and
3270/// kept out of the ratio test. A row pruned as a linear combination
3271/// of the kept active rows has true `a·p = 0`, so numerically
3272/// `|a·p|` sits at the refined-solve residual scale (≈1e-12·‖p‖);
3273/// anything above `TABU_DRIFT_REL·‖p‖∞` is an O(1) fraction of the
3274/// step — a *genuine* blocker that the active set's evolution has
3275/// re-exposed. Suppressing such a row hides it from the ratio test,
3276/// lets the step overshoot (observed ‖p‖→1e14 on degenerate NETLIB
3277/// gen), and voids Bland's lowest-index guarantee (it can only rank
3278/// the surviving candidates). So the tabu suppresses a row only while
3279/// its rate stays in this drift band; a genuine rate re-admits it.
3280const TABU_DRIFT_REL: Number = 1e-7;
3281
3282/// Select a maximal linearly-independent subset of the given active
3283/// constraint / bound normals by modified Gram-Schmidt with one
3284/// reorthogonalization pass.
3285///
3286/// Returns `(keep_cons, keep_bounds)` — the entries of `active_cons` /
3287/// `active_bounds`, in their original order, whose normals are
3288/// linearly independent of the earlier-kept ones. Dependent
3289/// (redundant) rows are omitted.
3290///
3291/// This is the linear-independence guard that lets the active-set
3292/// engine pin a degenerate / rank-deficient active set. A redundant
3293/// row is a linear combination of kept rows, so at any
3294/// constraint-consistent point it is automatically satisfied: dropping
3295/// it leaves the feasible vertex unchanged while removing the rank
3296/// deficiency that makes the active-set KKT singular (no H-block shift
3297/// can rescue a rank-deficient *constraint* block). General-constraint
3298/// rows are processed before variable bounds, so equality / general
3299/// rows are preferred over bounds when a tie must be broken.
3300pub(crate) fn independent_active_subset(
3301 linsol: &mut LinearSolver,
3302 qp: &QpProblem,
3303 active_cons: &[usize],
3304 active_bounds: &[usize],
3305) -> (Vec<usize>, Vec<usize>) {
3306 // Prefer the backend's sparse rank-reveal (feral's `SparseLu`
3307 // degeneracy probe) when available — it factors a sparse augmented
3308 // system in O(nnz) instead of the dense O(k²·n) modified-Gram-Schmidt
3309 // grind, which is the operation that grinds large degenerate LPs
3310 // (the NETLIB GEN family) to a halt. Fall back to dense MGS for
3311 // backends that don't rank-reveal (e.g. MA57).
3312 if linsol.provides_degeneracy_detection() {
3313 if let Some(kept) = independent_active_subset_sparse(linsol, qp, active_cons, active_bounds)
3314 {
3315 return kept;
3316 }
3317 }
3318 independent_active_subset_dense(qp, active_cons, active_bounds)
3319}
3320
3321/// Sparse linear-independence guard via the backend's Ipopt-style
3322/// degeneracy probe. Builds the active-row Jacobian `J` as a 1-based
3323/// triplet (`n_cols = qp.n`; general rows `0..active_cons.len()`
3324/// ordered before bound rows, so general rows win ties — matching the
3325/// dense path), calls `determine_dependent_rows`, and maps the flagged
3326/// rows back to `(keep_cons, keep_bounds)`. Returns `None` on a probe
3327/// failure so the caller can fall back to dense MGS.
3328fn independent_active_subset_sparse(
3329 linsol: &mut LinearSolver,
3330 qp: &QpProblem,
3331 active_cons: &[usize],
3332 active_bounds: &[usize],
3333) -> Option<(Vec<usize>, Vec<usize>)> {
3334 let n_cols = qp.n;
3335 let n_c = active_cons.len();
3336 let n_b = active_bounds.len();
3337 let n_rows = n_c + n_b;
3338 if n_rows == 0 {
3339 return Some((Vec::new(), Vec::new()));
3340 }
3341
3342 // Each active general row maps to J-row `pos` (its index in
3343 // `active_cons`); each active bound maps to J-row `n_c + b`.
3344 let mut j_row_of_con: Vec<Option<usize>> = vec![None; qp.m];
3345 for (pos, &row) in active_cons.iter().enumerate() {
3346 j_row_of_con[row] = Some(pos);
3347 }
3348
3349 let mut irn: Vec<Index> = Vec::new();
3350 let mut jcn: Vec<Index> = Vec::new();
3351 let mut vals: Vec<Number> = Vec::new();
3352
3353 // General-constraint rows: scatter the sparse Jacobian `A` in one pass.
3354 let a_irows = qp.a.irows();
3355 let a_jcols = qp.a.jcols();
3356 let a_vals = qp.a.values();
3357 for k in 0..a_irows.len() {
3358 let row = (a_irows[k] - 1) as usize;
3359 if let Some(pos) = j_row_of_con[row] {
3360 let col = (a_jcols[k] - 1) as usize;
3361 irn.push((pos + 1) as Index);
3362 jcn.push((col + 1) as Index);
3363 vals.push(a_vals[k]);
3364 }
3365 }
3366
3367 // Variable-bound rows: a unit entry `(n_c + b, var, 1)`.
3368 for (b, &var) in active_bounds.iter().enumerate() {
3369 irn.push((n_c + b + 1) as Index);
3370 jcn.push((var + 1) as Index);
3371 vals.push(1.0);
3372 }
3373
3374 let mut c_deps: Vec<Index> = Vec::new();
3375 let st = linsol.determine_dependent_rows(
3376 n_rows as Index,
3377 n_cols as Index,
3378 &irn,
3379 &jcn,
3380 &vals,
3381 &mut c_deps,
3382 );
3383 if st != ESymSolverStatus::Success {
3384 return None;
3385 }
3386
3387 let mut dropped = vec![false; n_rows];
3388 for &d in &c_deps {
3389 let d = d as usize;
3390 if d < n_rows {
3391 dropped[d] = true;
3392 }
3393 }
3394
3395 let mut keep_cons = Vec::with_capacity(n_c);
3396 for (pos, &row) in active_cons.iter().enumerate() {
3397 if !dropped[pos] {
3398 keep_cons.push(row);
3399 }
3400 }
3401 let mut keep_bounds = Vec::with_capacity(n_b);
3402 for (b, &var) in active_bounds.iter().enumerate() {
3403 if !dropped[n_c + b] {
3404 keep_bounds.push(var);
3405 }
3406 }
3407
3408 Some((keep_cons, keep_bounds))
3409}
3410
3411/// Dense modified-Gram-Schmidt linear-independence guard — the fallback
3412/// for backends without a sparse rank-reveal. Allocates a dense normal
3413/// per active row and orthogonalizes; O(k²·n). Retained byte-for-byte
3414/// for the MA57 backend.
3415fn independent_active_subset_dense(
3416 qp: &QpProblem,
3417 active_cons: &[usize],
3418 active_bounds: &[usize],
3419) -> (Vec<usize>, Vec<usize>) {
3420 let n = qp.n;
3421
3422 // Gather dense normals for the active general-constraint rows from
3423 // the sparse Jacobian in one pass.
3424 let mut pos_of_row: Vec<Option<usize>> = vec![None; qp.m];
3425 for (pos, &row) in active_cons.iter().enumerate() {
3426 pos_of_row[row] = Some(pos);
3427 }
3428 let mut cons_normals = vec![vec![0.0; n]; active_cons.len()];
3429 let a_irows = qp.a.irows();
3430 let a_jcols = qp.a.jcols();
3431 let a_vals = qp.a.values();
3432 for k in 0..a_irows.len() {
3433 let row = (a_irows[k] - 1) as usize;
3434 if let Some(pos) = pos_of_row[row] {
3435 let col = (a_jcols[k] - 1) as usize;
3436 cons_normals[pos][col] += a_vals[k];
3437 }
3438 }
3439
3440 let mut basis: Vec<Vec<Number>> = Vec::new();
3441 let mut keep_cons = Vec::new();
3442 let mut keep_bounds = Vec::new();
3443
3444 for (pos, &row) in active_cons.iter().enumerate() {
3445 let mut v = std::mem::take(&mut cons_normals[pos]);
3446 if accept_if_independent(&mut v, &mut basis) {
3447 keep_cons.push(row);
3448 }
3449 }
3450 for &var in active_bounds {
3451 let mut v = vec![0.0; n];
3452 v[var] = 1.0;
3453 if accept_if_independent(&mut v, &mut basis) {
3454 keep_bounds.push(var);
3455 }
3456 }
3457
3458 (keep_cons, keep_bounds)
3459}
3460
3461/// One modified-Gram-Schmidt step: orthogonalize `v` against `basis`
3462/// (two passes for numerical robustness against loss of orthogonality).
3463/// If the residual keeps a non-negligible fraction of `v`'s original
3464/// norm, normalize it, append it to `basis`, and return `true` (the row
3465/// is linearly independent); otherwise leave `basis` unchanged and
3466/// return `false` (linearly dependent / redundant).
3467fn accept_if_independent(v: &mut [Number], basis: &mut Vec<Vec<Number>>) -> bool {
3468 let orig = dot(v, v).sqrt();
3469 if orig == 0.0 {
3470 return false;
3471 }
3472 for _pass in 0..2 {
3473 for q in basis.iter() {
3474 let d = dot(q, v);
3475 if d != 0.0 {
3476 for (vi, &qi) in v.iter_mut().zip(q.iter()) {
3477 *vi -= d * qi;
3478 }
3479 }
3480 }
3481 }
3482 let r = dot(v, v).sqrt();
3483 if r > RANK_REL_TOL * orig {
3484 let inv = 1.0 / r;
3485 basis.push(v.iter().map(|&vi| vi * inv).collect());
3486 true
3487 } else {
3488 false
3489 }
3490}
3491
3492fn dot(a: &[Number], b: &[Number]) -> Number {
3493 a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
3494}
3495
3496#[derive(Clone, Copy)]
3497enum DropTarget {
3498 Cons(usize),
3499 Bound(usize),
3500}
3501
3502/// Total ordering on `DropTarget` used by Bland's tie-break:
3503/// constraint indices `0..m` come before bound indices `0..n`.
3504/// Stable across iterations because the index spaces don't change
3505/// over the lifetime of a single `solve_general` call.
3506fn drop_target_key(t: DropTarget) -> (u8, usize) {
3507 match t {
3508 DropTarget::Cons(i) => (0, i),
3509 DropTarget::Bound(i) => (1, i),
3510 }
3511}
3512
3513#[derive(Clone, Copy)]
3514enum BlockerTarget {
3515 Cons(usize, ConsStatus),
3516 Bound(usize, BoundStatus),
3517}
3518
3519fn blocker_index_key(b: BlockerTarget) -> (u8, usize) {
3520 match b {
3521 BlockerTarget::Cons(i, _) => (0, i),
3522 BlockerTarget::Bound(i, _) => (1, i),
3523 }
3524}
3525
3526/// Pick a blocking direction from the ratio-test candidate list.
3527///
3528/// `AntiCyclingChoice::None` and `AntiCyclingChoice::Bland` both
3529/// take the strict-minimum ratio. The two differ on the drop
3530/// path, not on the ratio test — at this point in the loop the
3531/// difference does not manifest, so both behave identically here.
3532///
3533/// `AntiCyclingChoice::Expand` runs the Harris-style two-pass: it
3534/// finds `α_min`, then among directions whose ratio is within
3535/// `feas_tol · (1 + |α_min|)` of `α_min`, picks the one with the
3536/// largest `|a·p|` — the most "expressive" direction. This is
3537/// the cycling-prevention core of GMSW EXPAND (Gill-Murray-
3538/// Saunders-Wright 1989); the τ-growth and snap-to-bound
3539/// machinery is a follow-up commit.
3540///
3541/// Returns `(α, blocker)` with `α = alpha_cap` and `blocker = None`
3542/// when no direction blocks at less than the full step.
3543///
3544/// `alpha_cap` is the unconstrained step length the caller wants —
3545/// the minimizer of the QP model along `p`, which is 1.0 for an
3546/// unshifted Newton direction and larger (possibly `+∞`) for a
3547/// δ-shifted one; see [`model_step_cap`]. It is the value returned
3548/// when nothing blocks, and the ceiling on every value that is.
3549///
3550/// `expand_tol` is the current GMSW EXPAND τ (only consumed when
3551/// `opts.anti_cycling = Expand`; pass 0.0 to disable). Non-zero
3552/// τ relaxes the Phase-1 minimum ratio by `τ / |a·p|` per
3553/// candidate, ensuring strictly positive step length even at
3554/// degenerate vertices where multiple constraints have α = 0
3555/// under the strict ratio test.
3556fn select_blocker(
3557 candidates: &[(BlockerTarget, f64, f64)],
3558 opts: &QpOptions,
3559 expand_tol: f64,
3560 force_bland: bool,
3561 alpha_cap: f64,
3562) -> (f64, Option<BlockerTarget>) {
3563 if candidates.is_empty() {
3564 return (alpha_cap, None);
3565 }
3566 // Pass 1: minimum ratio (strict and τ-relaxed).
3567 let mut alpha_min = alpha_cap;
3568 let mut alpha_min_relaxed = alpha_cap;
3569 for &(_, r, ap_mag) in candidates {
3570 if r < alpha_min {
3571 alpha_min = r;
3572 }
3573 let r_relaxed = if ap_mag > 0.0 {
3574 r + expand_tol / ap_mag
3575 } else {
3576 r
3577 };
3578 if r_relaxed < alpha_min_relaxed {
3579 alpha_min_relaxed = r_relaxed;
3580 }
3581 }
3582 if alpha_min >= alpha_cap {
3583 return (alpha_cap, None);
3584 }
3585
3586 // The anti-stall latch forces Bland (strict-min, lowest-index)
3587 // regardless of the configured rule.
3588 let effective = if force_bland {
3589 AntiCyclingChoice::Bland
3590 } else {
3591 opts.anti_cycling
3592 };
3593 match effective {
3594 AntiCyclingChoice::None | AntiCyclingChoice::Bland => {
3595 // Strict-min: pick the first candidate achieving
3596 // `alpha_min` (encounter order ⇒ lowest index for ties).
3597 let mut best: Option<(BlockerTarget, f64)> = None;
3598 for &(target, r, _) in candidates {
3599 if r > alpha_min {
3600 continue;
3601 }
3602 if best.is_none() {
3603 best = Some((target, r));
3604 }
3605 }
3606 let (target, r) = best.expect("non-empty candidates above");
3607 (r, Some(target))
3608 }
3609 AntiCyclingChoice::Expand => {
3610 // Harris two-pass with τ-relaxation. Phase 1 uses
3611 // `r_relaxed = r + τ/|a·p|`; Phase 2 picks largest
3612 // `|a·p|` among candidates within `tol · (1 + |α_min_relaxed|)`
3613 // of `α_min_relaxed`. The step length used is the
3614 // SELECTED candidate's *true* ratio, clamped from
3615 // below by `α_min_relaxed` so that even at a
3616 // degenerate vertex (true ratio = 0) we take a
3617 // strictly positive step of magnitude ≈ τ/|a·p|.
3618 let tol = opts.feas_tol * (1.0 + alpha_min_relaxed.abs());
3619 let mut best: Option<(BlockerTarget, f64, f64)> = None;
3620 for &(target, r, ap_mag) in candidates {
3621 let r_relaxed = if ap_mag > 0.0 {
3622 r + expand_tol / ap_mag
3623 } else {
3624 r
3625 };
3626 if r_relaxed > alpha_min_relaxed + tol {
3627 continue;
3628 }
3629 let take = match best {
3630 None => true,
3631 Some((prev_target, _, prev_ap)) => {
3632 if ap_mag > prev_ap {
3633 true
3634 } else if ap_mag == prev_ap {
3635 blocker_index_key(target) < blocker_index_key(prev_target)
3636 } else {
3637 false
3638 }
3639 }
3640 };
3641 if take {
3642 best = Some((target, r, ap_mag));
3643 }
3644 }
3645 match best {
3646 Some((target, r, _)) => {
3647 // Floor the step length at the τ-relaxed minimum so
3648 // we never freeze at α = 0; cap at the model minimizer.
3649 let alpha = r.max(alpha_min_relaxed).min(alpha_cap).max(0.0);
3650 (alpha, Some(target))
3651 }
3652 None => {
3653 // Pass 2 admitted nothing. This happens when every
3654 // candidate's τ-relaxed ratio exceeds the artificial
3655 // `α_min_relaxed = alpha_cap` initialization by more than
3656 // `tol` — reachable when |a·p| ≈ feas_tol makes
3657 // `τ/|a·p|` inflate `r_relaxed` above `alpha_cap + tol`
3658 // for ALL candidates (so the recorded minimum is the cap,
3659 // which no real candidate attains). Fall back to the
3660 // strict minimum-ratio blocker (guaranteed to exist since
3661 // `α_min < alpha_cap`) and step exactly `α_min`: never
3662 // freeze, panic, or overstep the first blocking
3663 // constraint.
3664 let mut fb: Option<BlockerTarget> = None;
3665 for &(target, r, _) in candidates {
3666 if r <= alpha_min {
3667 fb = Some(target);
3668 break;
3669 }
3670 }
3671 (alpha_min, fb)
3672 }
3673 }
3674 }
3675 }
3676}
3677
3678impl QpSolver for ParametricActiveSetSolver {
3679 fn solve(
3680 &mut self,
3681 qp: &QpProblem,
3682 ws: Option<&QpWarmStart>,
3683 opts: &QpOptions,
3684 ) -> Result<QpSolution, QpError> {
3685 let _deadline = crate::deadline::enter(opts.time_limit);
3686 // The second-order pass sits *here*, outside `solve_scoped`, for two
3687 // reasons. It is the one place every route through the engine — box,
3688 // equality-only, equality-plus-bounds, general, Schur, elastic,
3689 // homotopy — is guaranteed to pass through exactly once, so no inner
3690 // loop has to remember it. And its own re-solves call `solve_scoped`
3691 // directly, which is what stops an escape from recursing into another
3692 // escape (gh #848).
3693 let out = self
3694 .solve_scoped(qp, ws, opts)
3695 .and_then(|sol| self.escape_negative_curvature(qp, sol, opts));
3696 soften_deadline(qp, ws.map(|w| w.x.as_slice()), out)
3697 }
3698
3699 fn solve_parametric(
3700 &mut self,
3701 qp_prev: &QpProblem,
3702 sol_prev: &QpSolution,
3703 qp_new: &QpProblem,
3704 opts: &QpOptions,
3705 ) -> Result<QpSolution, QpError> {
3706 let _deadline = crate::deadline::enter(opts.time_limit);
3707 let out = self.solve_parametric_scoped(qp_prev, sol_prev, qp_new, opts);
3708 let mut out = soften_deadline(qp_new, Some(&sol_prev.x), out);
3709 // Every route through `solve_parametric_scoped` stamps its own source,
3710 // so the only way to arrive here unstamped is the deadline stub
3711 // `soften_deadline` just substituted for a cancelled solve. Nothing was
3712 // reused on that path either, which is what `Cold` says. Stamping it
3713 // here rather than leaving `None` keeps the field's contract exact:
3714 // `None` means "not a parametric call", never "a parametric call whose
3715 // route went unrecorded".
3716 if let Ok(sol) = &mut out
3717 && sol.stats.parametric_source.is_none()
3718 {
3719 sol.stats.parametric_source = Some(ParametricSource::Cold);
3720 }
3721 out
3722 }
3723
3724 fn solve_with_working_set(
3725 &mut self,
3726 qp: &QpProblem,
3727 working: &crate::working_set::WorkingSet,
3728 opts: &QpOptions,
3729 ) -> Result<QpSolution, QpError> {
3730 let _deadline = crate::deadline::enter(opts.time_limit);
3731 let out = self.solve_with_working_set_scoped(qp, working, opts);
3732 soften_deadline(qp, None, out)
3733 }
3734}
3735
3736/// Entry-point boundary for the internal cancellation error.
3737///
3738/// [`QpError::DeadlineExpired`] exists so `?` propagation forces every
3739/// internal caller to handle a timeout instead of consuming a half-finished
3740/// result. It is not part of the crate's contract with its callers, though:
3741/// a timeout is a *soft* outcome, reported as `QpStatus::TimeLimit` on an
3742/// `Ok` solution exactly like `MaxIter`. Every public entry point converts it
3743/// here, so the error can never escape.
3744/// Record which route a `solve_parametric` call took on the solution it is
3745/// about to return.
3746///
3747/// Applied at each of the three exits rather than inferred by the caller: the
3748/// guards that choose between them live here, `solve_homotopy` can decline the
3749/// path after they pass, and a caller re-deriving any of that would be keeping
3750/// a second copy of this function's control flow (gh #769).
3751fn stamp(mut sol: QpSolution, source: ParametricSource) -> QpSolution {
3752 sol.stats.parametric_source = Some(source);
3753 sol
3754}
3755
3756fn soften_deadline(
3757 qp: &QpProblem,
3758 hint: Option<&[Number]>,
3759 out: Result<QpSolution, QpError>,
3760) -> Result<QpSolution, QpError> {
3761 match out {
3762 // `n_refactor = 0`: the cancelled inner solve's counter died with the
3763 // `Err`, and inventing a number here would be worse than reporting
3764 // none. `stats.time` still reflects the real budget spent.
3765 Err(QpError::DeadlineExpired) => Ok(time_limit_solution(qp, hint, 0)),
3766 other => other,
3767 }
3768}
3769
3770impl ParametricActiveSetSolver {
3771 fn solve_scoped(
3772 &mut self,
3773 qp: &QpProblem,
3774 ws: Option<&QpWarmStart>,
3775 opts: &QpOptions,
3776 ) -> Result<QpSolution, QpError> {
3777 qp.validate()?;
3778 if crate::deadline::expired() {
3779 return Ok(time_limit_solution(qp, ws.map(|w| w.x.as_slice()), 0));
3780 }
3781 if let Some(w) = ws {
3782 w.working.validate_dims(qp.n, qp.m)?;
3783 if w.x.len() != qp.n {
3784 return Err(QpError::WarmStartDimensionMismatch(format!(
3785 "ws.x.len() = {} but n = {}",
3786 w.x.len(),
3787 qp.n
3788 )));
3789 }
3790 }
3791
3792 // Warm-start feasibility pre-check (companion to the M5
3793 // post-hoc audit below). A warm start whose primal is already
3794 // infeasible cannot be repaired by the zero-RHS warm inner
3795 // loop: its ratio test sees already-violated inactive rows,
3796 // yields negative step lengths that clamp to zero, and freezes
3797 // the objective until `MaxIter` (observed on degenerate NETLIB
3798 // `gen`, where the crossover hint pins a rank-deficient vertex
3799 // that violates ~hundreds of inactive rows). Route such a start
3800 // straight to l1-elastic phase-1 — the same recovery the cold
3801 // path takes when `cold_general_initial` returns infeasible, and
3802 // the M5 audit takes post-hoc. `solve_elastic` seeds a slack-
3803 // feasible augmented problem and recurses through `solve_general`
3804 // /`solve_general_schur` *directly*, bypassing this entry, so the
3805 // recovery cannot loop. A feasible warm start (the common case —
3806 // a good crossover/SQP hint) passes untouched.
3807 if let Some(w) = ws {
3808 if !point_is_feasible(qp, &w.x, opts.feas_tol) {
3809 return self.solve_elastic(qp, opts);
3810 }
3811 }
3812
3813 // Cold + general rows: try the parametric homotopy first. It returns
3814 // `Ok(None)` when the path cannot be started (no rows, or the box
3815 // relaxation is unbounded), which is a fall-through signal rather than a
3816 // verdict, so the conventional path below still handles everything it
3817 // handled before.
3818 if ws.is_none() && opts.use_homotopy {
3819 if let Some(sol) = self.solve_homotopy(qp, None, opts)? {
3820 return Ok(sol);
3821 }
3822 }
3823
3824 let has_general_inequality = !is_all_equality_constraints(qp);
3825
3826 // Any of: caller provided a warm start, or the problem has at
3827 // least one one-sided / two-sided general inequality row.
3828 if ws.is_some() || has_general_inequality {
3829 let sol = if opts.use_schur_updates {
3830 self.solve_general_schur(qp, ws, opts)?
3831 } else {
3832 self.solve_general(qp, ws, opts)?
3833 };
3834
3835 // Feasibility audit (M5): the warm-start inner loop steps
3836 // with a zero-RHS active-set system, so the residuals of
3837 // caller-marked-active rows are frozen and an equality row
3838 // left `Inactive` can never enter the working set — either
3839 // way the loop can converge to a constraint-violating point
3840 // and label it `Optimal`. Audit every row + bound; on
3841 // violation, recover through elastic mode (the same
3842 // recovery the cold path uses when `cold_general_initial`
3843 // returns an infeasible point). `solve_elastic` recurses
3844 // through `solve_general` / `solve_general_schur` *directly*
3845 // (per `use_schur_updates`), bypassing this entry, and seeds
3846 // a slack-feasible augmented problem — so the recursive solve
3847 // is never re-audited and the recovery cannot loop. Feasible
3848 // warm/cold results pass untouched.
3849 return self.audit_and_repair(qp, sol, opts);
3850 }
3851
3852 // Cold-start fast paths for problems with no general
3853 // inequalities and no warm-start.
3854 //
3855 // Audited too. These return a point without ever consulting the
3856 // active-set loop, so nothing else checks them — and an
3857 // inconsistent equality system is exactly what they cannot see:
3858 // the smallest possible infeasible QP, `aᵀx = c₁` and `aᵀx = c₂`
3859 // with `c₁ ≠ c₂` and a box, is all-equality with bounds, lands in
3860 // `solve_equality_plus_bounds`, and came back `Optimal` at a point
3861 // violating both rows by 2.9. Every tolerance, every version.
3862 let sol = if is_pure_equality_no_bounds(qp) {
3863 self.solve_equality_only(qp, opts)?
3864 } else if is_pure_box(qp) {
3865 self.solve_box_constrained(qp, opts)?
3866 } else {
3867 self.solve_equality_plus_bounds(qp, opts)?
3868 };
3869 self.audit_and_repair(qp, sol, opts)
3870 }
3871
3872 fn solve_parametric_scoped(
3873 &mut self,
3874 qp_prev: &QpProblem,
3875 sol_prev: &QpSolution,
3876 qp_new: &QpProblem,
3877 opts: &QpOptions,
3878 ) -> Result<QpSolution, QpError> {
3879 if crate::deadline::expired() {
3880 return Ok(stamp(
3881 time_limit_solution(qp_new, Some(&sol_prev.x), 0),
3882 ParametricSource::Cold,
3883 ));
3884 }
3885 // Trace the homotopy from the previous problem to the new one, starting
3886 // from the previous solution's working set.
3887 //
3888 // This is the crate's advertised feature and was a stub that discarded
3889 // both prior arguments. It is the *easy* direction of the homotopy: the
3890 // prior solution is already optimal for the prior QP, so the path starts
3891 // on the solution manifold — there is no `QP_0` to construct and no box
3892 // relaxation to bound, which is the whole difficulty of the cold case.
3893 //
3894 // Guards, in order: the two problems must have the same shape, and the
3895 // same `H`. `H` is not interpolated along the path (only `g` and the row
3896 // bounds are), so a changed Hessian would make the traced path solve a
3897 // different problem than the one requested. Rather than silently
3898 // mispredict, fall back (see below) — correct, just not warm.
3899 //
3900 // `A`, `xl`/`xu` and `hessian_inertia` are **deliberately not** guarded
3901 // on, though the path does not model them either. gh #602 proposed
3902 // adding them and the measurement declined it; do not re-add without
3903 // reading `dev-notes/issue-602-parametric-eligibility.md`.
3904 //
3905 // The short version: it is not that tracing an unmodelled change is
3906 // harmless, it is that declining is not reliably better. Rejecting sends
3907 // the call to the working-set fallback, and which of the two wins swings
3908 // with problem size on one synthetic family — at `n = 30` the guard is
3909 // better or equal in 14 of 14 rows, at `n = 20` it is worse in 9 of 14,
3910 // by as much as 2 working-set changes against 34. That is #434's
3911 // situation exactly: a rule that fires on the losses and the gains alike.
3912 //
3913 // `hessian_inertia` is the clearest case against, because it has no
3914 // upside at all: the tracer never reads it and neither does
3915 // `factorize_with_inertia_control`, so declining on it can only cost —
3916 // measured at 2 working-set changes becoming 5, for nothing.
3917 //
3918 // What would settle it is the discriminator #434 also wanted and did not
3919 // find: something observable at runtime that says whether the previous
3920 // active set is a good guess for this problem.
3921 // Bound *topology* — which rows are equalities, which variables are
3922 // fixed — is guarded on, and this is a correctness guard rather than a
3923 // cost one, which is why it stands where the `A` / box guards were
3924 // declined.
3925 //
3926 // `ConsStatus::Equality` and `BoundStatus::Fixed` are claims about the
3927 // problem, and no drop test can retract either. The tracer starts from
3928 // `sol_prev.working` and cannot re-type a row mid-path: the row type
3929 // does not interpolate, so a row that is an equality at `t = 0` and a
3930 // range at every `t > 0` stays marked `Equality` the whole way and is
3931 // handed to the corrector still claiming it. That pins it to
3932 // `qp_new.bl[i]` — the `-1e20` sentinel when the new lower bound is
3933 // infinite — at a point the feasibility audit accepts, and the solve
3934 // reports `Optimal`. Measured: `min ½x² s.t. x == 1` re-solved as
3935 // `min x² s.t. x ≤ 2` returned `Optimal` at `x = -1e19`, and the same
3936 // through `Fixed` when a pinned variable is freed (gh #602, found in
3937 // review of #614).
3938 //
3939 // Declining sends the pair to the fallback below, which runs the hint
3940 // through `WorkingSet::reconciled_with` and so cannot make that claim.
3941 // A genuine parametric family does not trip this: an equality row stays
3942 // an equality across a sweep, and a fixed variable stays fixed.
3943 let same_topology = qp_prev.m == qp_new.m
3944 && qp_prev.n == qp_new.n
3945 && (0..qp_prev.m)
3946 .all(|i| (qp_prev.bl[i] == qp_prev.bu[i]) == (qp_new.bl[i] == qp_new.bu[i]))
3947 && (0..qp_prev.n).all(|j| {
3948 let fixed = |xl: Number, xu: Number| {
3949 xl > NLP_LOWER_BOUND_INF
3950 && xu < NLP_UPPER_BOUND_INF
3951 && (xl - xu).abs() <= opts.feas_tol
3952 };
3953 fixed(qp_prev.xl[j], qp_prev.xu[j]) == fixed(qp_new.xl[j], qp_new.xu[j])
3954 });
3955
3956 let same_shape = qp_prev.n == qp_new.n && qp_prev.m == qp_new.m;
3957 let same_h = qp_prev.h.nonzeros() == qp_new.h.nonzeros()
3958 && qp_prev.h.values() == qp_new.h.values()
3959 && qp_prev.h.irows() == qp_new.h.irows()
3960 && qp_prev.h.jcols() == qp_new.h.jcols();
3961
3962 if same_shape
3963 && same_h
3964 && same_topology
3965 && sol_prev.status == QpStatus::Optimal
3966 && sol_prev.x.len() == qp_new.n
3967 && let Some(sol) = self.solve_homotopy(qp_new, Some((qp_prev, sol_prev)), opts)?
3968 {
3969 return Ok(stamp(sol, ParametricSource::Homotopy));
3970 }
3971
3972 // The path did not run — the guard declined it, or the tracer returned
3973 // `Ok(None)`. Neither is a reason to throw away the *working set* the
3974 // caller just handed us, which is what a cold solve here does.
3975 //
3976 // The primal genuinely does not carry over (that is why there is a
3977 // homotopy at all), but the discrete state does: `solve_with_working_set`
3978 // pins a fresh primal satisfying the hinted active rows, repairs the pin
3979 // if some other row is violated (#428), and only then runs the
3980 // conventional loop. So the hint costs one pinned-KKT factorization and
3981 // is worth having even when it is stale — which is exactly the SQP
3982 // driver's standing bet (`sqp_alg.rs` warm-starts this way on every
3983 // iteration, because each linearization moves `A` and translates the row
3984 // bounds by `-c(x_k)`).
3985 //
3986 // Measured on the synthetic family in
3987 // `examples/parametric_eligibility_sweep.rs`, over the rejected pairs
3988 // (`H` perturbed, so `same_h` is false and this branch is the whole
3989 // behaviour of `solve_parametric`):
3990 //
3991 // | H perturbation | cold (was) | working-set hint (now) |
3992 // |---|---|---|
3993 // | 1% | 18 changes | **3** |
3994 // | 10% | 18 changes | **3** |
3995 // | 50% | 17 changes | **2** |
3996 //
3997 // The hint survives a 50% Hessian perturbation because what it encodes
3998 // is which constraints bind, and that is far more stable under a change
3999 // of `H` than the iterate is. See gh #602 and
4000 // `dev-notes/issue-602-parametric-eligibility.md`.
4001 //
4002 // Conditions. The working set must be dimensionally valid for the new
4003 // problem, or `solve_with_working_set` rejects it with
4004 // `WarmStartDimensionMismatch` — a hard `Err` out of a call that has a
4005 // perfectly good cold answer available, which would turn a shape change
4006 // from "warm start unavailable" into "solve failed". And the previous
4007 // solve must have reached `Optimal`: a `TimeLimit` result carries
4008 // `WorkingSet::cold` (all-inactive, i.e. no information) and a `MaxIter`
4009 // one carries a set that was still moving, neither of which the
4010 // measurement above covers.
4011 //
4012 // `reconciled_with` is not optional. Dimensional validity is not enough
4013 // to make a working set *meaningful* for another problem: `Equality`
4014 // and `Fixed` assert `bl == bu` / `xl == xu` about the problem they came
4015 // from, and the solver never drops either, so carrying one onto a
4016 // problem where the row is an inequality pins it to a bound that does
4017 // not exist and reports the result `Optimal`. That is a wrong answer,
4018 // not a slow one — see `WorkingSet::reconciled_with`, and the two
4019 // regression tests it names.
4020 if sol_prev.status == QpStatus::Optimal
4021 && sol_prev.working.validate_dims(qp_new.n, qp_new.m).is_ok()
4022 {
4023 let hint = sol_prev.working.reconciled_with(qp_new, opts);
4024 return self
4025 .solve_with_working_set(qp_new, &hint, opts)
4026 .map(|sol| stamp(sol, ParametricSource::WorkingSet));
4027 }
4028 self.solve(qp_new, None, opts)
4029 .map(|sol| stamp(sol, ParametricSource::Cold))
4030 }
4031
4032 fn solve_with_working_set_scoped(
4033 &mut self,
4034 qp: &QpProblem,
4035 working: &crate::working_set::WorkingSet,
4036 opts: &QpOptions,
4037 ) -> Result<QpSolution, QpError> {
4038 qp.validate()?;
4039 working.validate_dims(qp.n, qp.m)?;
4040 if crate::deadline::expired() {
4041 return Ok(time_limit_solution(qp, None, 0));
4042 }
4043
4044 // Make the hint well-formed for `qp` before anything trusts it.
4045 //
4046 // This entry point is public API and the hint is caller-supplied, so
4047 // `Equality` / `Fixed` can arrive on a problem where the row is a range
4048 // or the variable free. Neither status is droppable, so the pin lands on
4049 // a bound `qp` does not have — the `-1e20` sentinel — and the feasibility
4050 // audit accepts it, because such a point genuinely is feasible. The
4051 // result is `Optimal` at a wrong answer, and no check downstream of here
4052 // can catch it: the point is optimal for the problem the working set
4053 // describes, and it is the working set that describes the wrong problem
4054 // (gh #602, raised in review of #614).
4055 //
4056 // It is *close* to a no-op for a well-formed hint — it re-derives
4057 // statuses that already agree with `qp` — but not exactly one, and the
4058 // measurement is the honest version of that claim rather than the
4059 // convenient one. On `benchmarks/warmstart` the conventional arms are
4060 // bit-identical and the homotopy arms improve (`cold-sqp-hom`
4061 // 28487 -> 27828, `warm-sqp-hom` 2005 -> 1755), solved counts unchanged.
4062 // On the CLI fixture sweep five lines move, all on fixtures that were
4063 // already failing; `jit1` on the homotopy arm goes from
4064 // `MaximumIterationsExceeded` to a converged solve (dual inf 9.3e-9,
4065 // constraint violation 6.5e-19). The residual difference comes from
4066 // hints where a variable's bounds sit within `feas_tol` of each other
4067 // without being equal, which this promotes to `Fixed`.
4068 let working = &working.reconciled_with(qp, opts);
4069
4070 // Factor the pinned KKT for a primal that satisfies the hinted active
4071 // rows (pruning the hint first if it is rank-deficient).
4072 let (x_init, fwd_working) = self.pin_working_set(qp, working, opts)?;
4073
4074 // A pinned primal that is infeasible for some *other* row is the
4075 // signature of an active set that has moved since the hint was
4076 // recorded. `solve`'s admission pre-check would drop the hint whole
4077 // and fall back to a cold l1-elastic phase-1, spending about one
4078 // working-set change per constraint row to rebuild what the hint
4079 // already had right. Repair it instead — pin the violated rows too —
4080 // and hand the pre-check a feasible point (#428). A hint the repair
4081 // cannot rescue keeps the old path untouched.
4082 let (x_init, fwd_working) = if point_is_feasible(qp, &x_init, opts.feas_tol) {
4083 (x_init, fwd_working)
4084 } else {
4085 self.repair_pinned_hint(qp, &x_init, &fwd_working, opts)
4086 .unwrap_or((x_init, fwd_working))
4087 };
4088
4089 // The inner loop recomputes multipliers each iteration from a
4090 // fresh KKT solve, so the warm-start multipliers are unused;
4091 // pass zeros and let `solve_general` drive from `(x, working)`.
4092 let ws = QpWarmStart {
4093 x: x_init,
4094 lambda_g: vec![0.0; qp.m],
4095 lambda_x: vec![0.0; qp.n],
4096 working: fwd_working,
4097 };
4098 self.solve(qp, Some(&ws), opts)
4099 }
4100
4101 /// Refuse to certify a saddle point as a solution, and get off it when
4102 /// there is somewhere to go (gh #848).
4103 ///
4104 /// Runs after the engine has produced its verdict, at the choke point
4105 /// every public entry funnels through, so no individual exit in the
4106 /// engine's five inner loops has to remember to call it. Only
4107 /// `QpStatus::Optimal` is examined: that is the one status whose meaning
4108 /// is a *claim about the model* rather than about the solve, and the only
4109 /// one a second-order finding can falsify.
4110 ///
4111 /// The loop is the classical nonconvex active-set move (Nocedal-Wright
4112 /// §16.4, "if the reduced Hessian is indefinite … a direction of negative
4113 /// curvature is followed to a new constraint"): at a first-order point
4114 /// with a witness `d` satisfying `A_W d = 0` and `dᵀHd < 0`,
4115 ///
4116 /// * `∇q(x)ᵀd = 0`, because at a first-order point the gradient is a
4117 /// combination of the working set's rows and `d` is orthogonal to all of
4118 /// them. So `q(x + αd) = q(x) + ½α²·dᵀHd` **decreases in both
4119 /// directions**. Neither sign is privileged, and the one taken is
4120 /// whichever the feasible set lets travel further — a longer step is
4121 /// strictly more objective decrease, since the decrease is quadratic in
4122 /// `α` with no linear term to trade against.
4123 /// * If nothing blocks either sign, `x + αd` is feasible for every `α` and
4124 /// the objective falls without bound: a certified recession ray, which
4125 /// is the `dᵀPd < 0` branch of `pounce-convex`'s `ray_certifies_unbounded`
4126 /// (gh #791) — a branch that had no reachable producer before this.
4127 /// That exit alone answers to
4128 /// [`QpOptions::certify_recession_ray`](crate::QpOptions::certify_recession_ray):
4129 /// a caller that has declined recession verdicts keeps its point and
4130 /// its `Optimal`, and reads the refutation off `stats.second_order`.
4131 /// * Otherwise the step ends on a new row or bound, which joins the
4132 /// working set, and the solve resumes from there.
4133 ///
4134 /// The resume is a full solve, so it may drop what the escape pinned and
4135 /// return to where it started; the loop therefore terminates on *measured
4136 /// objective progress*, not on the working set growing. Without that it
4137 /// spins the budget on one fixed point — see the guard below, and the
4138 /// HS071 measurement in it.
4139 ///
4140 /// Exhausting the budget, or stalling, with a live witness downgrades to
4141 /// `QpStatus::MaxIter`. It must not stay `Optimal`: that would be the
4142 /// original defect with extra steps. `MaxIter` here is the honest reading
4143 /// — the engine refuted the point and did not reach one it can certify.
4144 fn escape_negative_curvature(
4145 &mut self,
4146 qp: &QpProblem<'_>,
4147 mut sol: QpSolution,
4148 opts: &QpOptions,
4149 ) -> Result<QpSolution, QpError> {
4150 if !opts.certify_second_order
4151 || qp.hessian_inertia == HessianInertia::Psd
4152 || sol.status != QpStatus::Optimal
4153 {
4154 return Ok(sol);
4155 }
4156
4157 for _ in 0..opts.neg_curv_max_escapes {
4158 let d = match self.second_order_verdict(qp, &sol.working, opts)? {
4159 SecondOrder::Certified => {
4160 sol.stats.second_order = SecondOrderVerdict::Certified;
4161 return Ok(sol);
4162 }
4163 SecondOrder::NotChecked => return Ok(sol),
4164 SecondOrder::NegativeCurvature(d) => d,
4165 };
4166
4167 // Both signs descend; take the one with more room. `partial_cmp`
4168 // is not needed — an infinite α on either side is unboundedness
4169 // and is caught before the comparison matters.
4170 let neg: Vec<Number> = d.iter().map(|v| -v).collect();
4171 let fwd = feasible_step_along(qp, &sol.x, &d, &sol.working, opts.feas_tol);
4172 let bwd = feasible_step_along(qp, &sol.x, &neg, &sol.working, opts.feas_tol);
4173 let (dir, alpha, blocker) = if bwd.0 > fwd.0 {
4174 (neg, bwd.0, bwd.1)
4175 } else {
4176 (d, fwd.0, fwd.1)
4177 };
4178
4179 if !alpha.is_finite() {
4180 if !opts.certify_recession_ray {
4181 // The caller wants a point, not a verdict (gh #423) —
4182 // the same opt-out the box path honours at its own
4183 // unblocked-negative-curvature exit, and it has to be
4184 // honoured here for the same reason. The SQP's
4185 // unbounded-model fallback sets this flag and re-solves
4186 // precisely because the *unblocked* case is a statement
4187 // about the linearization: the δ-shifted proximal step
4188 // is a real step and certifying recession instead leaves
4189 // the outer loop with none at all. Without this arm the
4190 // fallback re-solve comes straight back `Unbounded`,
4191 // `sol` never becomes `Optimal`, and the SQP exits
4192 // `QpStepFailed` at iteration 1 — which is gh #419
4193 // verbatim, reached through a door gh #423 did not
4194 // close. Measured on `eigenb2` (110 free variables, 55
4195 // equalities, nothing that can ever block a direction):
4196 // 200 iterations at f = 1.6013 became 1 iteration at
4197 // f = 24.026.
4198 //
4199 // The finding is still reported. It is the *action* that
4200 // is declined, not the fact, and a caller that reads
4201 // `stats.second_order` (`pounce-convex`'s
4202 // `verify_status`) still sees the point refuted. Only
4203 // the unblocked branch is gated: a blocked escape ends
4204 // on a new row with a strictly lower objective and no
4205 // certificate is involved, so it runs either way.
4206 sol.stats.second_order = SecondOrderVerdict::NegativeCurvature;
4207 return Ok(sol);
4208 }
4209 // Feasible for every step length along a direction the
4210 // objective curves *down* along: the QP is unbounded below,
4211 // and `dir` is the witness. `obj` follows the convention the
4212 // engine's other unbounded exits use.
4213 sol.obj = Number::NEG_INFINITY;
4214 sol.status = QpStatus::Unbounded;
4215 sol.stats.second_order = SecondOrderVerdict::NegativeCurvature;
4216 sol.unbounded_ray = Some(dir);
4217 return Ok(sol);
4218 }
4219
4220 // Step to the blocker and pin it. A degenerate `α = 0` still
4221 // makes progress: the working set grows, so the next probe sees a
4222 // strictly smaller null space.
4223 let mut x = sol.x.clone();
4224 for (xi, di) in x.iter_mut().zip(dir.iter()) {
4225 *xi += alpha * di;
4226 }
4227 let mut working = sol.working.clone();
4228 match blocker {
4229 Some(Blocker::Bound(i, status)) => {
4230 // Snap to the bound rather than trusting `α·dᵢ`, the same
4231 // drift guard the box path applies to its own ratio test.
4232 match status {
4233 BoundStatus::AtLower => x[i] = qp.xl[i],
4234 BoundStatus::AtUpper => x[i] = qp.xu[i],
4235 _ => {}
4236 }
4237 working.bounds[i] = status;
4238 }
4239 Some(Blocker::Cons(i, status)) => working.constraints[i] = status,
4240 // `α` finite with no blocker is not reachable: `α` starts at
4241 // infinity and only a blocker lowers it.
4242 None => return Ok(sol),
4243 }
4244
4245 if crate::deadline::expired() {
4246 // Keep the point — it is feasible and its objective is no
4247 // worse than the one we arrived with — but not the status.
4248 // Returning `Optimal` here is exactly the claim the witness
4249 // just refuted.
4250 sol.x = x;
4251 sol.obj = quad_objective(qp, &sol.x);
4252 sol.working = working;
4253 sol.status = QpStatus::TimeLimit;
4254 sol.stats.second_order = SecondOrderVerdict::NegativeCurvature;
4255 return Ok(sol);
4256 }
4257
4258 // Resume from the escaped point. `solve_scoped`, not `solve`:
4259 // this call must not re-enter the escape, and the deadline scope
4260 // is already open.
4261 let escaped_obj = quad_objective(qp, &x);
4262 let prev_obj = sol.obj;
4263 let escaped = (x.clone(), working.clone(), escaped_obj);
4264 let ws = QpWarmStart {
4265 x,
4266 lambda_g: vec![0.0; qp.m],
4267 lambda_x: vec![0.0; qp.n],
4268 working,
4269 };
4270 let stats_so_far = sol.stats.clone();
4271 let mut next = self.solve_scoped(qp, Some(&ws), opts)?;
4272 next.stats.n_working_set_changes += stats_so_far.n_working_set_changes + 1;
4273 next.stats.n_refactor += stats_so_far.n_refactor;
4274 next.stats.n_schur_updates += stats_so_far.n_schur_updates;
4275 next.stats.used_phase1 |= stats_so_far.used_phase1;
4276 sol = next;
4277
4278 if sol.status != QpStatus::Optimal {
4279 // The re-solve reached a conclusion of its own — unbounded,
4280 // out of iterations, out of time. It is not a first-order
4281 // point being passed off as an optimum, so there is nothing
4282 // left for the second-order test to falsify.
4283 return Ok(sol);
4284 }
4285
4286 // The escape must not be undone by the solve that follows it.
4287 //
4288 // The loop's termination argument is that the working set grows by
4289 // one per escape, so a run ends within `n + m` of them. The resume
4290 // is free to *drop* rows again, and on an indefinite `H` it does
4291 // more than that: the inner loop's steps come from the δ-shifted
4292 // KKT of §4.5, whose model has the saddle as its *minimum*, so the
4293 // re-solve walks back uphill to the very point the escape left.
4294 // That is the same attraction gh #848 reports from the outside
4295 // ("the start point is ignored entirely — all three starts land on
4296 // `[0, 0]`"), met here from the inside, and left alone it spins the
4297 // whole budget on one fixed point: measured on HS071's first step
4298 // QP, all 20 escapes reported an identical working set, an
4299 // identical direction, `alpha = 1.4935` and `obj = -1.4116e-7`,
4300 // having each stepped to `obj = -4.52e-2` and been walked back.
4301 //
4302 // So require strict progress, and when there is none stop at the
4303 // better of the two points rather than re-deriving it 19 more
4304 // times. The status is not `Optimal` either way — the witness
4305 // refuted that, and getting off the saddle is what failed here,
4306 // not the finding.
4307 if sol.obj >= prev_obj - opts.opt_tol * (1.0 + prev_obj.abs()) {
4308 let (x_esc, working_esc, obj_esc) = escaped;
4309 if obj_esc < sol.obj {
4310 sol.x = x_esc;
4311 sol.obj = obj_esc;
4312 sol.working = working_esc;
4313 }
4314 sol.status = QpStatus::MaxIter;
4315 sol.stats.second_order = SecondOrderVerdict::NegativeCurvature;
4316 return Ok(sol);
4317 }
4318 }
4319
4320 // Out of escapes with the point still un-certified. Report the budget.
4321 sol.status = QpStatus::MaxIter;
4322 sol.stats.second_order = SecondOrderVerdict::NegativeCurvature;
4323 Ok(sol)
4324 }
4325}
4326
4327/// What stopped a step along a negative-curvature direction.
4328#[derive(Debug, Clone, Copy)]
4329enum Blocker {
4330 Bound(usize, BoundStatus),
4331 Cons(usize, ConsStatus),
4332}
4333
4334/// The largest `α ≥ 0` keeping `x + α d` feasible, and what stops it.
4335///
4336/// `INFINITY` with `None` means nothing blocks: `d` is a recession direction
4337/// of the feasible set. Rows and bounds already in `working` are skipped —
4338/// the witness satisfies `A_W d = 0`, so they neither block nor move, and
4339/// including them would let floating-point residual in `A_W d` manufacture a
4340/// spurious zero step.
4341fn feasible_step_along(
4342 qp: &QpProblem<'_>,
4343 x: &[Number],
4344 d: &[Number],
4345 working: &WorkingSet,
4346 feas_tol: Number,
4347) -> (Number, Option<Blocker>) {
4348 let mut alpha = Number::INFINITY;
4349 let mut blocker = None;
4350 let take = |r: Number, b: Blocker, alpha: &mut Number, blocker: &mut Option<Blocker>| {
4351 let r = if r.is_finite() { r.max(0.0) } else { r };
4352 if r < *alpha {
4353 *alpha = r;
4354 *blocker = Some(b);
4355 }
4356 };
4357
4358 for i in 0..qp.n {
4359 if working.bounds[i].is_active() {
4360 continue;
4361 }
4362 if d[i] < -feas_tol && qp.xl[i] > NLP_LOWER_BOUND_INF {
4363 let r = (x[i] - qp.xl[i]) / -d[i];
4364 take(
4365 r,
4366 Blocker::Bound(i, BoundStatus::AtLower),
4367 &mut alpha,
4368 &mut blocker,
4369 );
4370 }
4371 if d[i] > feas_tol && qp.xu[i] < NLP_UPPER_BOUND_INF {
4372 let r = (qp.xu[i] - x[i]) / d[i];
4373 take(
4374 r,
4375 Blocker::Bound(i, BoundStatus::AtUpper),
4376 &mut alpha,
4377 &mut blocker,
4378 );
4379 }
4380 }
4381
4382 if qp.m > 0 {
4383 let ax = a_times_x(qp.a, x, qp.m);
4384 let ad = a_times_x(qp.a, d, qp.m);
4385 for i in 0..qp.m {
4386 if working.constraints[i].is_active() {
4387 continue;
4388 }
4389 if ad[i] < -feas_tol && qp.bl[i] > NLP_LOWER_BOUND_INF {
4390 let r = (ax[i] - qp.bl[i]) / -ad[i];
4391 let status = if qp.bl[i] == qp.bu[i] {
4392 ConsStatus::Equality
4393 } else {
4394 ConsStatus::AtLower
4395 };
4396 take(r, Blocker::Cons(i, status), &mut alpha, &mut blocker);
4397 }
4398 if ad[i] > feas_tol && qp.bu[i] < NLP_UPPER_BOUND_INF {
4399 let r = (qp.bu[i] - ax[i]) / ad[i];
4400 let status = if qp.bl[i] == qp.bu[i] {
4401 ConsStatus::Equality
4402 } else {
4403 ConsStatus::AtUpper
4404 };
4405 take(r, Blocker::Cons(i, status), &mut alpha, &mut blocker);
4406 }
4407 }
4408 }
4409
4410 (alpha, blocker)
4411}
4412
4413/// The soft outcome for a cancelled solve: the best point we have, clamped
4414/// into the box so it is at least a usable starting iterate, with
4415/// `QpStatus::TimeLimit`.
4416///
4417/// `n_refactor` is the work done before the deadline hit, and `time` comes
4418/// from the enclosing deadline scope — a solve that spent the whole budget
4419/// must not be recorded as having taken no time.
4420fn time_limit_solution(qp: &QpProblem, hint: Option<&[Number]>, n_refactor: u32) -> QpSolution {
4421 let mut x = hint.map_or_else(|| vec![0.0; qp.n], ToOwned::to_owned);
4422 x.resize(qp.n, 0.0);
4423 for (xi, (&l, &u)) in x.iter_mut().zip(qp.xl.iter().zip(qp.xu.iter())) {
4424 if !xi.is_finite() {
4425 *xi = 0.0;
4426 }
4427 *xi = xi.clamp(l, u);
4428 }
4429 QpSolution {
4430 obj: quad_objective(qp, &x),
4431 x,
4432 lambda_g: vec![0.0; qp.m],
4433 lambda_x: vec![0.0; qp.n],
4434 working: WorkingSet::cold(qp.n, qp.m),
4435 status: QpStatus::TimeLimit,
4436 stats: QpStats {
4437 n_working_set_changes: 0,
4438 n_refactor,
4439 n_schur_updates: 0,
4440 used_phase1: false,
4441 time: crate::deadline::scope_elapsed(),
4442 ..Default::default()
4443 },
4444 unbounded_ray: None,
4445 }
4446}
4447
4448/// Evaluate `½ xᵀ H x + gᵀ x`, walking the symmetric Hessian once
4449/// and fanning each off-diagonal entry into both halves.
4450/// Feasibility audit for a candidate solution `x` (M5). Checks every
4451/// general-constraint row — **including equality rows** (`bl == bu`) —
4452/// and every variable bound against `feas_tol`. Returns `true` iff `x`
4453/// violates none of them.
4454///
4455/// The warm-start path of [`ParametricActiveSetSolver::solve_general`]
4456/// trusts the caller's `(x, working)` and steps with a zero-RHS active-
4457/// set system, so the residuals of rows the caller marked active are
4458/// frozen and never re-checked; an equality row the caller left
4459/// `Inactive` is skipped by the ratio test (`bl == bu` ⇒ `continue`)
4460/// and can never enter the working set. Either way the inner loop can
4461/// reach a KKT-stationary point that violates a constraint and report
4462/// it as `Optimal`. `solve` runs this audit before trusting an
4463/// `Optimal` and recovers through elastic mode on failure.
4464/// Largest constraint / bound violation at `x` (0.0 when feasible).
4465///
4466/// What [`ParametricActiveSetSolver::hint_pin_quality`] measured about a
4467/// working-set hint: how big it is, and how many rows and bounds outside it the
4468/// pinned point violates.
4469#[cfg(test)]
4470#[derive(Debug, Clone, Copy)]
4471pub(crate) struct HintPinQuality {
4472 /// Rows plus bounds the hint marks active.
4473 pub(crate) active: usize,
4474 /// Inactive rows plus bounds the pinned point violates beyond `feas_tol`.
4475 pub(crate) violated: usize,
4476}
4477
4478/// The magnitude behind [`point_is_feasible`]'s boolean, needed so a recovery
4479/// path can tell whether the point it is about to substitute is actually an
4480/// improvement on the one it is discarding.
4481pub(crate) fn max_violation(qp: &QpProblem, x: &[Number]) -> Number {
4482 let ax = a_times_x(qp.a, x, qp.m);
4483 let mut worst: Number = 0.0;
4484 for i in 0..qp.m {
4485 if qp.bl[i] > NLP_LOWER_BOUND_INF {
4486 worst = worst.max(qp.bl[i] - ax[i]);
4487 }
4488 if qp.bu[i] < NLP_UPPER_BOUND_INF {
4489 worst = worst.max(ax[i] - qp.bu[i]);
4490 }
4491 }
4492 for (i, &xi) in x.iter().enumerate() {
4493 if qp.xl[i] > NLP_LOWER_BOUND_INF {
4494 worst = worst.max(qp.xl[i] - xi);
4495 }
4496 if qp.xu[i] < NLP_UPPER_BOUND_INF {
4497 worst = worst.max(xi - qp.xu[i]);
4498 }
4499 }
4500 worst.max(0.0)
4501}
4502
4503/// Rows and bounds that `x` violates by more than `feas_tol`, each paired with
4504/// the working-set status that pins it back onto the side it overshot.
4505/// Companion to [`ParametricActiveSetSolver::repair_pinned_hint`].
4506///
4507/// Returns `None` when a row or bound the working set already marks *active*
4508/// is violated. A pinned row is satisfied by construction, so that means the
4509/// pin did not take (an inconsistent or numerically hopeless hint) — and since
4510/// the repair only ever *adds* pins, re-pinning such a row cannot fix it.
4511#[allow(clippy::type_complexity)]
4512fn violated_inactive(
4513 qp: &QpProblem,
4514 x: &[Number],
4515 working: &WorkingSet,
4516 feas_tol: Number,
4517) -> Option<(Vec<(usize, ConsStatus)>, Vec<(usize, BoundStatus)>)> {
4518 let ax = a_times_x(qp.a, x, qp.m);
4519 let mut cons = Vec::new();
4520 for i in 0..qp.m {
4521 let below = qp.bl[i] > NLP_LOWER_BOUND_INF && ax[i] < qp.bl[i] - feas_tol;
4522 let above = qp.bu[i] < NLP_UPPER_BOUND_INF && ax[i] > qp.bu[i] + feas_tol;
4523 if !below && !above {
4524 continue;
4525 }
4526 if working.constraints[i].is_active() {
4527 return None;
4528 }
4529 cons.push((
4530 i,
4531 if qp.bl[i] == qp.bu[i] {
4532 ConsStatus::Equality
4533 } else if below {
4534 ConsStatus::AtLower
4535 } else {
4536 ConsStatus::AtUpper
4537 },
4538 ));
4539 }
4540
4541 let mut bounds = Vec::new();
4542 for (i, &xi) in x.iter().enumerate() {
4543 let below = qp.xl[i] > NLP_LOWER_BOUND_INF && xi < qp.xl[i] - feas_tol;
4544 let above = qp.xu[i] < NLP_UPPER_BOUND_INF && xi > qp.xu[i] + feas_tol;
4545 if !below && !above {
4546 continue;
4547 }
4548 if working.bounds[i].is_active() {
4549 return None;
4550 }
4551 bounds.push((
4552 i,
4553 if qp.xl[i] == qp.xu[i] {
4554 BoundStatus::Fixed
4555 } else if below {
4556 BoundStatus::AtLower
4557 } else {
4558 BoundStatus::AtUpper
4559 },
4560 ));
4561 }
4562
4563 Some((cons, bounds))
4564}
4565
4566fn point_is_feasible(qp: &QpProblem, x: &[Number], feas_tol: Number) -> bool {
4567 let ax = a_times_x(qp.a, x, qp.m);
4568 for i in 0..qp.m {
4569 if qp.bl[i] > NLP_LOWER_BOUND_INF && ax[i] < qp.bl[i] - feas_tol {
4570 return false;
4571 }
4572 if qp.bu[i] < NLP_UPPER_BOUND_INF && ax[i] > qp.bu[i] + feas_tol {
4573 return false;
4574 }
4575 }
4576 for (i, &xi) in x.iter().enumerate() {
4577 if qp.xl[i] > NLP_LOWER_BOUND_INF && xi < qp.xl[i] - feas_tol {
4578 return false;
4579 }
4580 if qp.xu[i] < NLP_UPPER_BOUND_INF && xi > qp.xu[i] + feas_tol {
4581 return false;
4582 }
4583 }
4584 true
4585}
4586
4587/// Two intrinsic clauses of a certified-recession-ray test for QP
4588/// unboundedness. A QP `min ½xᵀHx + gᵀx s.t. Ax = b` is unbounded
4589/// below iff there is a direction `d` with `Hd = 0` (zero curvature —
4590/// for PSD `H` equivalent to `dᵀHd = 0`), `Ad = 0` (stays feasible),
4591/// and `gᵀd < 0` (descent). This helper checks the two clauses that
4592/// depend only on `(H, g)` and the current iterate `x_cand`:
4593/// (i) zero curvature `‖Hd‖∞ ≈ 0` relative to `‖H‖` (H ≡ 0 ⇒ flat),
4594/// (ii) strict descent of the *local* gradient `(H·x_cand + g)ᵀd < 0`.
4595///
4596/// **Feasibility of the ray is the caller's responsibility** — the
4597/// call sites certify it by different (both locally valid) arguments:
4598/// the equality-only solve maintains `Ax = b` so `A(x/‖x‖) = b/‖x‖ → 0`
4599/// as the iterate blows up; the active-set loop reaches its check only
4600/// when the ratio test finds NO inactive row blocking along `dir` (and
4601/// `dir` already lies in the active constraints' null space).
4602///
4603/// `dir` need not be normalized — the test is scale-invariant.
4604///
4605/// The curvature clause is deliberately near-exact (`1e-10·‖H‖`): a
4606/// false `Unbounded` is the dangerous direction. For PSD `H`, any
4607/// measurable curvature along `d` means a *finite* minimizer in that
4608/// direction at `‖∇q‖/λ`, however large — an earlier `dᵀHd ≤ 1e-3·‖H‖`
4609/// version certified `Unbounded` on bounded QPs whose softest mode sat
4610/// 3+ orders below the stiffest entry (e.g. `H = diag(1, 1e-4, 0)`,
4611/// `g = (0, -1, 0)`, true minimum −5000 at `x₂ = 10⁴`). Curvature below
4612/// `1e-10·‖H‖` is beneath any meaningful precision of the problem data
4613/// and is treated as structurally zero. Soft-but-real modes therefore
4614/// fall on the conservative side (reported bounded), never falsely
4615/// unbounded.
4616///
4617/// The descent clause uses the local gradient `H·x_cand + g`, not the
4618/// origin gradient `g`: with `Hd ≈ 0` enforced only to tolerance, the
4619/// two can disagree at a large iterate (the earlier `gᵀd` version read
4620/// "descent" while sitting essentially at the minimizer). For a genuine
4621/// recession ray they coincide (`xᵀ(Hd) ≈ 0`).
4622fn ray_is_unbounded_descent(
4623 h: &pounce_linalg::triplet::SymTMatrix,
4624 g: &[Number],
4625 x_cand: &[Number],
4626 dir: &[Number],
4627) -> bool {
4628 let norm = dir.iter().map(|v| v * v).sum::<Number>().sqrt();
4629 if norm == 0.0 {
4630 return false;
4631 }
4632 let inv = 1.0 / norm;
4633
4634 // ‖Hd‖∞, H·x_cand, and ‖H‖ (max |stored entry|), using the symmetric
4635 // triplet convention (off-diagonal pairs stored once ⇒ scatter both
4636 // (i,j) and (j,i)).
4637 let n = dir.len();
4638 let mut hd = vec![0.0; n];
4639 let mut hx = vec![0.0; n];
4640 let mut h_scale: Number = 0.0;
4641 let irows = h.irows();
4642 let jcols = h.jcols();
4643 let vals = h.values();
4644 for k in 0..irows.len() {
4645 let i = (irows[k] - 1) as usize;
4646 let j = (jcols[k] - 1) as usize;
4647 let v = vals[k];
4648 h_scale = h_scale.max(v.abs());
4649 hd[i] += v * dir[j] * inv;
4650 hx[i] += v * x_cand[j];
4651 if i != j {
4652 hd[j] += v * dir[i] * inv;
4653 hx[j] += v * x_cand[i];
4654 }
4655 }
4656 let hd_inf = hd.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
4657 let zero_curvature = if h_scale > 0.0 {
4658 hd_inf <= 1e-10 * h_scale
4659 } else {
4660 true // H ≡ 0: every direction is a zero-curvature ray.
4661 };
4662
4663 // Local directional derivative (H·x_cand + g)ᵀd vs ‖g‖₂ — strict
4664 // (numerically meaningful) descent.
4665 let slope: Number = g
4666 .iter()
4667 .zip(hx.iter())
4668 .zip(dir.iter())
4669 .map(|((&gi, &hxi), &di)| (gi + hxi) * di * inv)
4670 .sum();
4671 let g_norm = g.iter().map(|v| v * v).sum::<Number>().sqrt();
4672 let descent = slope < -1e-6 * g_norm.max(1.0);
4673
4674 zero_curvature && descent
4675}
4676
4677/// Step-length cap for the current search direction `p`: the exact
4678/// minimizer of the QP model along `p`, floored at the unit step.
4679///
4680/// With an **unshifted** active-set KKT the unit step already *is* that
4681/// minimizer. Writing `r = Hx + g`, the system solved is
4682/// `H p + A_Wᵀ λ = −r` with `A_W p = 0`, so `pᵀr = −pᵀHp` and
4683///
4684/// ```text
4685/// α* = −pᵀr / pᵀHp = 1.
4686/// ```
4687///
4688/// §4.5 inertia control breaks the identity: on an indefinite reduced
4689/// Hessian it factors `H + δI` instead, giving `pᵀr = −(pᵀHp + δ‖p‖²)`
4690/// and
4691///
4692/// ```text
4693/// α* = −pᵀr / pᵀHp = 1 + δ‖p‖² / pᵀHp (> 1)
4694/// α* = +∞ (pᵀHp ≤ 0)
4695/// ```
4696///
4697/// so the model asks for a step `1 + δ‖p‖²/pᵀHp` times longer than the
4698/// one the loop used to take, and asks for an unbounded one whenever the
4699/// true curvature along `p` is non-positive.
4700///
4701/// Taking the unit step anyway is gh #416: with `W` unchanged the inner
4702/// loop degenerates into proximal-point iteration with parameter δ —
4703/// `x ← argmin q(y) + ½δ‖y − x‖²` — whose contraction factor is
4704/// `δ/(λ + δ)` per eigenvalue λ of `H`. Since δ must exceed `|λ_min|` to
4705/// make the shifted system PD, and it is reached by multiplying by
4706/// `inertia_shift_factor` (100 by default), δ typically *dominates* the
4707/// spectrum: the reported Rosenbrock QP has `λ_min = −1.4` and δ = 100,
4708/// putting every factor within 3 % of 1. The result is a sequence of
4709/// ~1e-3-long "full steps" that never reach a bound, so 200 iterations
4710/// pass with zero working-set changes and the QP exits `MaxIter` — the
4711/// dimension-independent 200-iteration burn in the issue. With the cap
4712/// the negative-curvature direction runs to its blocking bound instead,
4713/// which is what an active-set method is supposed to do with it
4714/// (Nocedal-Wright §16.5).
4715///
4716/// `hx` must be `H·x` at the current iterate and `p` the direction just
4717/// solved for; `delta` is the shift that produced it. `delta == 0`
4718/// returns 1.0 without touching the data, so every non-shifted solve
4719/// keeps bit-identical behaviour.
4720fn model_step_cap(
4721 h: &pounce_linalg::triplet::SymTMatrix,
4722 g: &[Number],
4723 hx: &[Number],
4724 p: &[Number],
4725 delta: Number,
4726) -> Number {
4727 if delta <= 0.0 {
4728 return 1.0;
4729 }
4730
4731 let mut hp = vec![0.0; p.len()];
4732 let mut h_scale: Number = 0.0;
4733 let irows = h.irows();
4734 let jcols = h.jcols();
4735 let vals = h.values();
4736 for k in 0..irows.len() {
4737 let i = (irows[k] - 1) as usize;
4738 let j = (jcols[k] - 1) as usize;
4739 let v = vals[k];
4740 h_scale = h_scale.max(v.abs());
4741 hp[i] += v * p[j];
4742 if i != j {
4743 hp[j] += v * p[i];
4744 }
4745 }
4746
4747 let curv: Number = p.iter().zip(hp.iter()).map(|(pi, hpi)| pi * hpi).sum();
4748 let slope: Number = p
4749 .iter()
4750 .zip(hx.iter().zip(g.iter()))
4751 .map(|(pi, (hxi, gi))| pi * (hxi + gi))
4752 .sum();
4753
4754 // Relative floor on the curvature. `pᵀHp` below the round-off level
4755 // of its own accumulation is zero, not a tiny positive number —
4756 // dividing by it would manufacture an α* of 1e16 and hurl the
4757 // iterate out of the box. Below the floor the model is (at best)
4758 // linear along `p`, so the step is bounded only by the ratio test.
4759 let p_sq: Number = p.iter().map(|v| v * v).sum();
4760 if curv > 1e-12 * h_scale * p_sq {
4761 (-slope / curv).max(1.0)
4762 } else if slope < 0.0 {
4763 // A successful shifted factorization has `pᵀ(H + δI)p > 0`, hence
4764 // `pᵀr = −(pᵀHp + δ‖p‖²) < 0`: descent is structural here, and the
4765 // test only guards against a direction corrupted by round-off.
4766 Number::INFINITY
4767 } else {
4768 1.0
4769 }
4770}
4771
4772pub(crate) fn quad_objective(qp: &QpProblem, x: &[Number]) -> Number {
4773 let mut quad = 0.0;
4774 let irows = qp.h.irows();
4775 let jcols = qp.h.jcols();
4776 let vals = qp.h.values();
4777 for k in 0..irows.len() {
4778 let i = (irows[k] - 1) as usize;
4779 let j = (jcols[k] - 1) as usize;
4780 let v = vals[k];
4781 if i == j {
4782 quad += 0.5 * v * x[i] * x[i];
4783 } else {
4784 quad += v * x[i] * x[j]; // each off-diag pair contributes once
4785 }
4786 }
4787 let lin: Number = qp.g.iter().zip(x.iter()).map(|(&gi, &xi)| gi * xi).sum();
4788 quad + lin
4789}
4790
4791#[cfg(test)]
4792mod select_blocker_tests {
4793 //! Unit tests for the GMSW EXPAND ratio test in `select_blocker`.
4794 //! These live inside `solver` (not `crate::tests`) so they can reach
4795 //! the private `select_blocker`/`BlockerTarget` items.
4796 use super::{BlockerTarget, select_blocker};
4797 use crate::options::{AntiCyclingChoice, QpOptions};
4798 use crate::working_set::BoundStatus;
4799
4800 fn expand_opts(feas_tol: f64) -> QpOptions {
4801 QpOptions {
4802 feas_tol,
4803 anti_cycling: AntiCyclingChoice::Expand,
4804 ..QpOptions::default()
4805 }
4806 }
4807
4808 /// Regression for H6: the EXPAND branch panicked (`best.expect`)
4809 /// when every candidate's τ-relaxed ratio `r + τ/|a·p|` exceeded
4810 /// the artificial `α_min_relaxed = 1.0` initialization cap by more
4811 /// than `tol`. Reachable with a *single* candidate that has a true
4812 /// blocking ratio `r < 1` but a tiny `|a·p| ≈ feas_tol`, so
4813 /// `τ/|a·p|` inflates `r_relaxed` far above `1`. Pre-fix this hits
4814 /// `best = None → panic`; post-fix it falls back to the strict
4815 /// minimum-ratio blocker and steps exactly `α_min = r`.
4816 #[test]
4817 fn expand_tau_inflation_falls_back_to_strict_min_no_panic() {
4818 let opts = expand_opts(1e-6);
4819 // expand_tol (τ) = 1e-3, ap_mag = 1e-9 ⇒ r_relaxed ≈ 0.5 + 1e6.
4820 let candidates = [(BlockerTarget::Bound(0, BoundStatus::AtLower), 0.5, 1e-9)];
4821 let (alpha, blocker) = select_blocker(&candidates, &opts, 1e-3, false, 1.0);
4822 assert!(
4823 matches!(blocker, Some(BlockerTarget::Bound(0, BoundStatus::AtLower))),
4824 "expected the sole candidate as blocker, got {:?}",
4825 blocker.map(|b| match b {
4826 BlockerTarget::Bound(i, _) => ("bound", i),
4827 BlockerTarget::Cons(i, _) => ("cons", i),
4828 })
4829 );
4830 // Step the strict ratio, never the bogus 1.0 floor (which would
4831 // overstep the constraint).
4832 assert!(
4833 (alpha - 0.5).abs() < 1e-12,
4834 "expected α = 0.5 (strict min), got {alpha}"
4835 );
4836 }
4837
4838 /// Multiple inflated candidates: the fallback must still pick the
4839 /// strict minimum-ratio one (here index 1, r = 0.25) and step its
4840 /// ratio, not the larger-index r.
4841 #[test]
4842 fn expand_fallback_selects_strict_minimum_among_inflated() {
4843 let opts = expand_opts(1e-6);
4844 let candidates = [
4845 (BlockerTarget::Bound(0, BoundStatus::AtLower), 0.75, 1e-9),
4846 (BlockerTarget::Bound(1, BoundStatus::AtUpper), 0.25, 1e-9),
4847 ];
4848 let (alpha, blocker) = select_blocker(&candidates, &opts, 1e-3, false, 1.0);
4849 assert!(
4850 matches!(blocker, Some(BlockerTarget::Bound(1, BoundStatus::AtUpper))),
4851 "expected the strict-min candidate (index 1)"
4852 );
4853 assert!(
4854 (alpha - 0.25).abs() < 1e-12,
4855 "expected α = 0.25, got {alpha}"
4856 );
4857 }
4858
4859 /// Non-degenerate EXPAND still works: a candidate with a healthy
4860 /// `|a·p|` keeps its τ-relaxed ratio below the cap, so Pass 2
4861 /// admits it normally (no fallback).
4862 #[test]
4863 fn expand_normal_case_admits_in_pass_two() {
4864 let opts = expand_opts(1e-6);
4865 let candidates = [(BlockerTarget::Bound(0, BoundStatus::AtLower), 0.5, 1.0)];
4866 let (alpha, blocker) = select_blocker(&candidates, &opts, 1e-9, false, 1.0);
4867 assert!(matches!(
4868 blocker,
4869 Some(BlockerTarget::Bound(0, BoundStatus::AtLower))
4870 ));
4871 assert!(alpha >= 0.5 && alpha <= 1.0, "α in range, got {alpha}");
4872 }
4873}