Skip to main content

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}