Skip to main content

pounce_algorithm/
crossover.rs

1//! NLP crossover: hand a converged interior-point iterate to the
2//! active-set path so the solve ends on an **exact** active set
3//! (Byrd, Nocedal & Waltz, "KNITRO: An Integrated Package for
4//! Nonlinear Optimization", 2006, §7; gh#612).
5//!
6//! # Why
7//!
8//! An interior-point method never puts an iterate *on* a constraint: the
9//! fraction-to-boundary rule keeps every slack strictly positive, so at
10//! termination "which constraints are active" is an inference from a
11//! tolerance test, not a fact the solve established. Where **strict
12//! complementarity holds** that inference is right and this whole phase is a
13//! no-op by design. Where it fails — a weakly active bound whose slack and
14//! multiplier are both `O(√μ)` — the interior solve cannot answer the
15//! question at all, and three subsystems downstream are already paying for
16//! that:
17//!
18//! 1. [`pounce_sensitivity`]'s `covariance()` classifies activity into
19//!    STRONGLY ACTIVE / WEAKLY ACTIVE / **AMBIGUOUS** / UNIDENTIFIED
20//!    (`docs/src/sensitivity.md`). The AMBIGUOUS class exists precisely
21//!    because a barrier geometry cannot decide.
22//! 2. A degenerate solution collapses the reduced Hessian, which has come
23//!    back as an inertia problem repeatedly (#540, #541, #544, #592, the
24//!    `feral_singular_pivot_floor` knob) and been met each time on the
25//!    perturbation side.
26//! 3. The active-set SQP's warm start could only come from a previous *SQP*
27//!    solve (`docs/src/active-set-sqp.md`), so a sequence whose first solve
28//!    wants the IPM had no way to hand off. Crossover is that missing edge:
29//!    after it runs, [`crate::application::IpoptApplication::last_sqp_working_set`]
30//!    returns a working set the next `algorithm=active-set-sqp` solve can
31//!    consume.
32//!
33//! `pounce-convex` has had the LP form of this for a while
34//! ([`pounce_convex::crossover`]); this is the NLP analogue, and it borrows
35//! that module's two load-bearing ideas: crossover is a **bridge**, not a new
36//! solver, and it is **never-regress** — the crossed-over point replaces the
37//! interior one only when it is at least as good a KKT point.
38//!
39//! # What it does (paper §7)
40//!
41//! 1. The IPM terminates at `(x, y, z)` within `E_tol`.
42//! 2. Estimate the active set `A` by a tolerance test on primal distance and
43//!    multiplier magnitude — [`crate::sqp::classify_working_set`], the same
44//!    classifier the sensitivity-corrector handoff already uses.
45//! 3. Take **one EQP step over `A`** plus a line search on the penalty model.
46//!    If the result satisfies the stopping tolerances, terminate. This is the
47//!    common path and it solves no LPs, so on a well-behaved problem
48//!    crossover costs about one iteration.
49//! 4. Otherwise run the full active-set algorithm from the interior iterate,
50//!    seeded with `A` and with `ν₀` a little above the largest `|multiplier|`
51//!    at the interior solution.
52//!
53//! # Where this departs from the paper
54//!
55//! KNITRO's active-set path is **SLQP**: an LP phase picks the working set
56//! and an EQP phase computes the step, so its step 3 is a literal EQP solve
57//! and its step 4 sizes an LP trust region (their eq. 7.22) to exclude every
58//! inactive constraint. POUNCE's active-set path is an ordinary line-search
59//! **SQP** over `pounce-qp`'s working-set interface, so:
60//!
61//! - Step 3 is expressed as one `pounce-qp`
62//!   [`QpSolver::solve_with_working_set`] against the NLP linearization at
63//!   the interior iterate, warm-started with `A`. That call factorizes the
64//!   hinted active set to recover a primal, then pivots — which is exactly
65//!   "solve the EQP over `A`, and fix `A` where the tolerance test got it
66//!   wrong". The paper's guarantee that step 3 avoids an LP is preserved:
67//!   `pounce-qp` solves no LP either.
68//! - Step 4's LP trust region has no analogue and is **not** implemented; the
69//!   `ν₀` half of that setup is, since the ℓ₁ merit the SQP already carries
70//!   takes exactly that parameter.
71//!
72//! # It runs against the *declared* bounds
73//!
74//! The caller hands this an
75//! [`crate::sqp::IpoptNlpAdapter::new_with_declared_bounds`], not a plain
76//! one, and that is load-bearing rather than tidy. `bound_relax_factor`
77//! (default `1e-8`) widens every bound before the interior solve starts, so
78//! a point sitting exactly on a bound the user declared is a full `1e-8`
79//! *inside* the relaxed one. Measured against the relaxed bounds, a pivot
80//! that lands precisely on the binding constraint reads as strictly
81//! interior, and the identification step then correctly reports an empty
82//! active set — crossover would run, succeed, and answer nothing. Worse, the
83//! pivot itself would stop `1e-8` shy of each constraint, because against
84//! the relaxed problem that point genuinely is optimal.
85//!
86//! So the whole phase is posed on the model as written. The consequence to
87//! be aware of is that the returned point can sit on a declared bound rather
88//! than inside it, which is `constr_viol_tol`-legal by construction (the
89//! relaxation is capped there) and is the result being asked for.
90//!
91//! # Never-regress
92//!
93//! Crossover is a strict refinement of a solve that has already succeeded, so
94//! the bar is not "did it solve" but "is this at least as good a KKT point".
95//! [`accepts`] applies three gates against the interior iterate — constraint
96//! violation, stationarity, and objective — and any failure returns the
97//! interior solution untouched. Nothing here can turn a converged solve into
98//! a failed one: on every abandonment path the caller keeps what the IPM
99//! produced.
100//!
101//! # Cost and defaults
102//!
103//! Off by default (`crossover=no`). It runs strictly *after* convergence, so
104//! enabling it moves no interior trajectory and needs no baseline fixture
105//! sweep (contrast an initial-point or merit-function change, per
106//! `CLAUDE.md`).
107
108use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, Number};
109use pounce_linsol::SparseSymLinearSolverInterface;
110use pounce_qp::{
111    BoundStatus, ConsStatus, ParametricActiveSetSolver, QpError, QpOptions, QpSolver, QpStatus,
112    WorkingSet,
113};
114
115use crate::sqp::iterates::SqpIterates;
116use crate::sqp::line_search::l1_merit_line_search;
117use crate::sqp::options::SqpOptions;
118use crate::sqp::problem::SqpProblemSpec;
119use crate::sqp::qp_assembly::{SqpQpData, Triplet};
120use crate::sqp::result::{SqpResult, SqpStatus};
121use crate::sqp::sqp_alg::{SqpAlgorithm, check_kkt};
122use crate::sqp::warm_start::classify_working_set;
123
124/// Primal tolerance used to read the active set off the **crossed-over**
125/// point (as opposed to the interior one).
126///
127/// This is deliberately far tighter than `crossover_primal_tol`, which has to
128/// tolerate the `O(√μ)` standoff of an interior iterate — typically `1e-5`.
129/// After crossover the active constraints hold to machine precision, so a
130/// tight test is finally meaningful, and that is the whole point of the
131/// phase: the same question that could not be answered at the interior point
132/// has a definite answer here. `1e-9` is generous by several orders against
133/// the `~1e-16` actually observed, while still an order of magnitude below
134/// the standoff it replaces.
135const IDENTIFIED_PRIMAL_TOL: Number = 1e-9;
136
137/// Slack on the never-regress comparisons, so a crossed-over point is not
138/// rejected for a change at the last bit of a residual that is otherwise
139/// identical.
140const REGRESS_SLACK: Number = 1e-12;
141
142/// Relative slack on the objective gate. The crossed-over point sits on the
143/// active constraints exactly rather than `O(μ)` inside them, so the
144/// objective is expected to move at the tolerance level; anything beyond this
145/// means the active-set phase walked somewhere else and the result is
146/// refused.
147const OBJ_REL_SLACK: Number = 1e-6;
148
149/// Tuning for the crossover phase. Populated from the `crossover*` options by
150/// [`crate::application::IpoptApplication`].
151#[derive(Debug, Clone)]
152pub struct CrossoverOptions {
153    /// Master switch (`crossover`). Default off.
154    pub enabled: bool,
155    /// Multiplier magnitude above which a row is taken active in the §7
156    /// step-2 tolerance test (`crossover_mult_tol`).
157    pub mult_tol: Number,
158    /// Primal distance to a bound below which a row is taken binding in the
159    /// §7 step-2 tolerance test (`crossover_primal_tol`).
160    pub primal_tol: Number,
161    /// Outer-iteration budget for the §7 step-4 fallback
162    /// (`crossover_max_iter`). `0` disables step 4 entirely, leaving
163    /// crossover as the one-step refinement of step 3.
164    pub max_iter: u32,
165}
166
167impl Default for CrossoverOptions {
168    fn default() -> Self {
169        Self {
170            enabled: false,
171            mult_tol: 1e-8,
172            primal_tol: 1e-6,
173            max_iter: 30,
174        }
175    }
176}
177
178/// Which of the paper's two paths produced the returned point.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum CrossoverPhase {
181    /// §7 step 3 — one EQP-equivalent step over the tolerance-test active
182    /// set plus a penalty line search was enough. The common path; no full
183    /// active-set run.
184    EqpStep,
185    /// §7 step 4 — step 3 did not reach the stopping tolerances, so the full
186    /// active-set SQP ran from the interior iterate.
187    ActiveSet,
188}
189
190/// Why crossover did not replace the interior iterate. Reported rather than
191/// swallowed: "crossover ran and declined" and "crossover never ran" are
192/// different facts about a solve, and the AMBIGUOUS-activity consumers this
193/// exists for need to tell them apart.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum CrossoverDecline {
196    /// The problem has no bounds and no constraints, so there is no active
197    /// set to identify.
198    NothingToIdentify,
199    /// The EQP-equivalent QP did not solve, and step 4 was disabled or also
200    /// failed. [`CrossoverReport::eqp`] says how it ended.
201    QpFailed,
202    /// The penalty line search could not accept a step from the interior
203    /// iterate.
204    LineSearchFailed,
205    /// The full active-set run did not converge within `crossover_max_iter`.
206    ActiveSetNotConverged,
207    /// A point was produced but it is not at least as good a KKT point as the
208    /// interior iterate (see [`accepts`]).
209    Regressed,
210    /// The point meets the SQP's tolerances in the scaled space but not the
211    /// user's `constr_viol_tol` / `dual_inf_tol` / `compl_inf_tol` on the
212    /// model as declared (gh#1054). Scaling can shrink a row's violation by
213    /// orders of magnitude, so a point can be a scaled KKT point and an
214    /// infeasible one at once; the interior-point verdict reads user units,
215    /// and so does this gate.
216    UserTolerances,
217    /// The solve's `max_wall_time` / `max_cpu_time` ran out before
218    /// crossover finished (gh#1054). The interior solution stands, as on
219    /// every other decline: crossover refines a converged solve, so running
220    /// out of time is never a reason to fail it.
221    TimeLimit,
222}
223
224/// What the §7 step-3 QP did (gh#1061).
225///
226/// [`CrossoverDecline::QpFailed`] covers every way that QP can fail to reach
227/// `Optimal` — an iteration cap, an infeasible verdict, a hard linear-solver
228/// error — and on a large model those call for different responses. This is
229/// the detail behind it, kept on accepted reports too, so a slow crossover can
230/// be attributed: working-set changes, refactorizations and rank tests against the
231/// wall time they cost.
232#[derive(Debug, Clone)]
233pub struct EqpReport {
234    /// The QP's status, or the hard error it returned instead of one.
235    pub outcome: Result<QpStatus, QpError>,
236    /// Working-set changes (adds + drops). `0` when the QP returned an error.
237    pub working_set_changes: u32,
238    /// Base-KKT refactorizations. `0` when the QP returned an error.
239    pub refactors: u32,
240    /// Schur-complement updates between refactorizations. `0` when the QP
241    /// returned an error.
242    pub schur_updates: u32,
243    /// Linear-independence rank tests, counted on errors too.
244    pub rank_guard_calls: u32,
245    /// Wall time of the QP call.
246    pub time: std::time::Duration,
247    /// How that wall time split between factorization, back-solves and rank
248    /// tests; the rest of `time` went outside the linear solver (gh#1061).
249    pub linsol_times: pounce_qp::LinsolTimes,
250}
251
252/// What crossover did. Retrieved from
253/// [`crate::application::IpoptApplication::crossover_report`].
254#[derive(Debug, Clone)]
255pub struct CrossoverReport {
256    /// The phase that produced the accepted point; `None` when crossover
257    /// declined.
258    pub phase: Option<CrossoverPhase>,
259    /// Why it declined; `None` when it did not.
260    pub declined: Option<CrossoverDecline>,
261    /// Outer iterations spent in the step-4 fallback. `0` on the step-3 path.
262    pub n_iter: u32,
263    /// QP subproblems solved across both phases, step 3's included.
264    pub n_qp_solves: u32,
265    /// Variable bounds in the identified active set (`AtLower`, `AtUpper` or
266    /// `Fixed`), read off the returned point where the primal test is exact.
267    /// See [`identify_at`].
268    pub active_bounds: usize,
269    /// Constraint rows in the identified active set (equalities included —
270    /// they are unconditionally active).
271    pub active_constraints: usize,
272    /// Rows and bounds the §7 step-2 tolerance test called active *at the
273    /// interior iterate*, before any pivoting.
274    ///
275    /// Compare against `active_bounds + active_constraints`, which is the
276    /// same question answered at the crossed-over point. They differ exactly
277    /// where the interior iterate could not support the inference — the
278    /// measurement this phase exists to make.
279    pub estimated_active: usize,
280    /// `max(stationarity, constraint violation)` at the interior iterate.
281    pub kkt_before: Number,
282    /// The same at the returned point. Never worse than `kkt_before` beyond
283    /// the tolerances [`accepts`] allows.
284    pub kkt_after: Number,
285    /// Max-norm complementarity at the returned point, measured against the
286    /// **declared** bounds — see [`complementarity_at`]. `NaN` when crossover
287    /// declined.
288    ///
289    /// This exists because the interior method's own complementarity is
290    /// measured against the *relaxed* bounds, and after crossover the two
291    /// frames disagree by the entire relaxation: an iterate sitting exactly
292    /// on a declared bound is `bound_relax_factor` inside the relaxed one, so
293    /// the relaxed reading is `|multiplier| · δ` — around `1e-8` — where the
294    /// truth is zero. Reporting that as the solve's complementarity printed a
295    /// converged run as `Overall NLP error` above `tol` (#646). The caller
296    /// substitutes this figure when the point was accepted.
297    pub compl_after: Number,
298    /// The §7 step-3 QP, when it ran. `None` when crossover declined before
299    /// building it.
300    pub eqp: Option<EqpReport>,
301}
302
303impl CrossoverReport {
304    fn declined(reason: CrossoverDecline) -> Self {
305        Self {
306            phase: None,
307            declined: Some(reason),
308            n_iter: 0,
309            n_qp_solves: 0,
310            active_bounds: 0,
311            active_constraints: 0,
312            estimated_active: 0,
313            kkt_before: Number::NAN,
314            kkt_after: Number::NAN,
315            compl_after: Number::NAN,
316            eqp: None,
317        }
318    }
319
320    fn with_eqp(mut self, eqp: &EqpReport) -> Self {
321        self.eqp = Some(eqp.clone());
322        self
323    }
324
325    /// This report, turned into a decline for `reason` after the fact,
326    /// keeping its counts and residuals — what the attempt cost and found
327    /// stays visible when a later gate refuses its point.
328    pub(crate) fn decline(mut self, reason: CrossoverDecline) -> Self {
329        self.phase = None;
330        self.declined = Some(reason);
331        self
332    }
333
334    /// Did crossover replace the interior iterate?
335    pub fn accepted(&self) -> bool {
336        self.phase.is_some()
337    }
338}
339
340/// The converged interior-point iterate, in the algorithm's (compressed,
341/// scaled) space — the same space [`crate::sqp::IpoptNlpAdapter`] presents,
342/// so no translation is needed between the two engines.
343///
344/// `lambda_x` is the **packed** bound multiplier `z_l − z_u`, matching
345/// [`SqpIterates`] and [`classify_working_set`]; `lambda_g` is `[y_c ; y_d]`.
346#[derive(Debug, Clone)]
347pub struct CrossoverSeed {
348    pub x: Vec<Number>,
349    pub lambda_g: Vec<Number>,
350    pub lambda_x: Vec<Number>,
351}
352
353/// Never-regress gate. The crossed-over point is accepted only when it is at
354/// least as good a KKT point of the *original* NLP as the interior iterate,
355/// on all three of feasibility, stationarity, and objective.
356///
357/// Each residual is compared against `max(interior residual, its tolerance)`
358/// rather than against the interior residual alone: crossover puts the
359/// iterate *on* the active constraints, which can nudge a residual that was
360/// `1e-12` up to `1e-10` while the point is unambiguously better identified.
361/// Refusing that would make the gate reject exactly the cases the phase
362/// exists for. What it still refuses is a residual that crosses its own
363/// tolerance, which is the thing that would turn a converged solve into a
364/// misreported one.
365pub fn accepts(
366    before: (Number, Number, Number),
367    after: (Number, Number, Number),
368    sqp_opts: &SqpOptions,
369) -> bool {
370    let (stat_b, viol_b, obj_b) = before;
371    let (stat_a, viol_a, obj_a) = after;
372    if !(stat_a.is_finite() && viol_a.is_finite() && obj_a.is_finite()) {
373        return false;
374    }
375    let stat_tol = sqp_opts.tol.min(sqp_opts.dual_inf_tol);
376    if stat_a > stat_b.max(stat_tol) + REGRESS_SLACK {
377        return false;
378    }
379    if viol_a > viol_b.max(sqp_opts.constr_viol_tol) + REGRESS_SLACK {
380        return false;
381    }
382    // Objective: a *decrease* is always fine (crossover found a better point
383    // on the same active set); an increase is bounded relative to the
384    // interior objective's own magnitude.
385    let obj_slack = OBJ_REL_SLACK * obj_b.abs().max(1.0);
386    obj_a <= obj_b + obj_slack
387}
388
389/// Max-norm complementarity `max_i |slack_i · multiplier_i|` at a
390/// crossed-over point, in the frame crossover actually solved in.
391///
392/// Two things make this different from [`crate::ipopt_cq::IpoptCq`]'s
393/// complementarity, and both are deliberate.
394///
395/// **The bounds are the declared ones.** `nlp` here is the adapter built by
396/// `new_with_declared_bounds`, so `xl`/`xu`/`bl_c`/`bu_c` are the box the
397/// user wrote rather than the `bound_relax_factor`-widened one the interior
398/// iteration ran against. Crossover's whole job is to put the iterate *on*
399/// the active constraints of the problem as posed; measured against the
400/// relaxed bounds that same point reads `|multiplier| · δ` — the relaxation
401/// times the dual, `~1e-8` for a unit multiplier — which is not a residual of
402/// anything, just the width of an internal safeguard (#646).
403///
404/// **The slacks are raw.** The CQ floors a slack that falls below
405/// `eps·min(1,μ)` up to about `μ/z`, which keeps the barrier's `Σ = V/S`
406/// finite during the iteration. At a purified point the active slacks are
407/// *exactly* zero and that floor would put `μ/z ≈ 1e-9` back — reintroducing,
408/// as a reporting artifact, the very quantity crossover removed.
409///
410/// Sign conventions follow the rest of this module: `λ_x = z_l − z_u`
411/// (positive at a lower bound), while a row's `λ_g` is **negative** at its
412/// lower bound, because the bound block enters stationarity negated.
413fn complementarity_at(
414    x: &[Number],
415    c_vals: &[Number],
416    lambda_x: &[Number],
417    lambda_g: &[Number],
418    xl: &[Number],
419    xu: &[Number],
420    bl_c: &[Number],
421    bu_c: &[Number],
422) -> Number {
423    let mut worst = 0.0_f64;
424    // A point may sit a rounding step outside a bound; that is constraint
425    // violation, which `check_kkt` already reports. Clamping at zero here
426    // keeps it from re-entering as a *negative* complementarity.
427    let mut take = |slack: Number, mult: Number| {
428        worst = worst.max((slack.max(0.0) * mult).abs());
429    };
430    for i in 0..x.len() {
431        if xl[i] > NLP_LOWER_BOUND_INF {
432            take(x[i] - xl[i], lambda_x[i].max(0.0));
433        }
434        if xu[i] < NLP_UPPER_BOUND_INF {
435            take(xu[i] - x[i], (-lambda_x[i]).max(0.0));
436        }
437    }
438    for i in 0..c_vals.len() {
439        if bl_c[i] > NLP_LOWER_BOUND_INF {
440            take(c_vals[i] - bl_c[i], (-lambda_g[i]).max(0.0));
441        }
442        if bu_c[i] < NLP_UPPER_BOUND_INF {
443            take(bu_c[i] - c_vals[i], lambda_g[i].max(0.0));
444        }
445    }
446    worst
447}
448
449/// Count the active entries of a working set, split bounds / rows.
450/// The inertia hint for a QP built on the exact Lagrangian Hessian `h`.
451///
452/// `Indefinite`, as the SQP loop has it for its exact source, so inertia
453/// control runs on every drop. A model that supplies no Hessian (the Python
454/// frontend and the CasADi plugin under `limited-memory`) gives an empty or
455/// all-zero triplet, which is PSD, and is hinted so.
456fn exact_hessian_inertia(h: &Triplet) -> pounce_qp::HessianInertia {
457    if h.vals.iter().all(|&v| v == 0.0) {
458        pounce_qp::HessianInertia::Psd
459    } else {
460        pounce_qp::HessianInertia::Indefinite
461    }
462}
463
464fn count_active(w: &WorkingSet) -> (usize, usize) {
465    let bounds = w
466        .bounds
467        .iter()
468        .filter(|b| !matches!(b, BoundStatus::Inactive))
469        .count();
470    let rows = w
471        .constraints
472        .iter()
473        .filter(|c| !matches!(c, ConsStatus::Inactive))
474        .count();
475    (bounds, rows)
476}
477
478/// Run the crossover phase (paper §7 steps 2-4).
479///
480/// `seed` is the converged interior iterate. `make_backend` supplies the
481/// sparse symmetric linear solver for the active-set engine — the same
482/// factory the IPM used, so crossover inherits the caller's `linear_solver`
483/// choice. `make_sqp` builds the step-4 driver; it is a closure rather than a
484/// value because step 4 needs its own iteration budget and `ν₀`, and it is
485/// never called at all on the step-3 path.
486///
487/// Returns the report always, and the replacement solution only when it was
488/// accepted.
489pub fn run<N, B, S>(
490    nlp: &mut N,
491    seed: &CrossoverSeed,
492    opts: &CrossoverOptions,
493    sqp_opts: &SqpOptions,
494    qp_opts: &QpOptions,
495    mut make_backend: B,
496    mut make_sqp: S,
497) -> (CrossoverReport, Option<SqpResult>)
498where
499    N: SqpProblemSpec,
500    B: FnMut() -> Box<dyn SparseSymLinearSolverInterface>,
501    S: FnMut(SqpOptions) -> Option<SqpAlgorithm>,
502{
503    let n = nlp.n();
504    let m = nlp.m();
505    let (xl, xu) = nlp.variable_bounds();
506    let (bl_c, bu_c) = nlp.constraint_bounds();
507
508    // Nothing to identify: no general rows and no finite bound anywhere. The
509    // interior iterate is already an unconstrained stationary point and its
510    // active set is empty by construction.
511    let any_bound = xl
512        .iter()
513        .any(|&v| v > NLP_LOWER_BOUND_INF)
514        .then_some(true)
515        .or_else(|| xu.iter().any(|&v| v < NLP_UPPER_BOUND_INF).then_some(true))
516        .unwrap_or(false);
517    if m == 0 && !any_bound {
518        return (
519            CrossoverReport::declined(CrossoverDecline::NothingToIdentify),
520            None,
521        );
522    }
523
524    // ---- §7 step 1: residuals at the interior iterate ----
525    let f_curr = nlp.eval_f(&seed.x);
526    let c_vals = nlp.eval_c(&seed.x);
527    let grad_f = nlp.eval_grad_f(&seed.x);
528    let jac_c = nlp.eval_jac_c(&seed.x);
529
530    let mut iter = SqpIterates {
531        x: seed.x.clone(),
532        lambda_g: seed.lambda_g.clone(),
533        lambda_x: seed.lambda_x.clone(),
534        working: None,
535    };
536    let kkt_before = check_kkt(
537        n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
538    );
539    let before = (kkt_before.stationarity, kkt_before.constr_viol, f_curr);
540
541    // ---- §7 step 2: estimate the active set by the tolerance test ----
542    let m_eq = m_eq_count(&bl_c, &bu_c);
543    let working = classify_working_set(
544        &seed.lambda_x,
545        &seed.lambda_g,
546        m_eq,
547        &seed.x,
548        &xl,
549        &xu,
550        &c_vals,
551        &bl_c,
552        &bu_c,
553        opts.mult_tol,
554        opts.primal_tol,
555    );
556    let (est_bounds, est_rows) = count_active(&working);
557    let estimated_active = est_bounds + est_rows;
558
559    // `ν₀` a little above the largest |multiplier| at the interior solution
560    // (paper §7). The ℓ₁ merit's own Han-Powell update only ever raises ν, so
561    // seeding it here is what keeps the first crossover step from being
562    // rejected by a penalty that has not yet caught up with the duals the IPM
563    // already found.
564    let mult_inf = seed
565        .lambda_g
566        .iter()
567        .chain(seed.lambda_x.iter())
568        .map(|v| v.abs())
569        .fold(0.0_f64, f64::max);
570    let nu0 = (mult_inf + sqp_opts.l1_penalty_safety)
571        .max(sqp_opts.l1_penalty)
572        .min(sqp_opts.l1_penalty_max);
573
574    // ---- §7 step 3: one EQP-equivalent step over the estimated set ----
575    let mut n_qp_solves = 0_u32;
576    // The step-3 QP holds the exact Lagrangian Hessian whatever the SQP's own
577    // source is, so it is hinted by that matrix, not by the source (gh#1095).
578    // The hint also decides the Schur rule below.
579    let hess_lag = nlp.eval_hess_lag(&seed.x, &seed.lambda_g);
580    let hessian_inertia = exact_hessian_inertia(&hess_lag);
581    let qp_data = SqpQpData::build(
582        &seed.x,
583        &grad_f,
584        &c_vals,
585        &bl_c,
586        &bu_c,
587        &xl,
588        &xu,
589        jac_c.clone(),
590        hess_lag,
591        hessian_inertia,
592    );
593    let qp = qp_data.as_qp();
594    // The step-3 QP gets whatever the solve has left (gh#1054); a QP that
595    // stops on it comes back `TimeLimit`, which is a decline like any other.
596    if sqp_opts
597        .deadline
598        .as_ref()
599        .is_some_and(|d| d.exceeded().is_some())
600    {
601        return (CrossoverReport::declined(CrossoverDecline::TimeLimit), None);
602    }
603    let eqp_opts = QpOptions {
604        time_limit: sqp_opts
605            .deadline
606            .as_ref()
607            .map(|d| crate::sqp::sqp_alg::deadline_remaining(d).0)
608            .map_or(qp_opts.time_limit, |left| {
609                Some(qp_opts.time_limit.map_or(left, |own| own.min(left)))
610            }),
611        // The SQP driver's rule for Schur updates (gh#1054, gh#1083), so the
612        // step-3 QP of a large model does not refactor per pivot. It is
613        // warm-started from the estimated working set.
614        use_schur_updates: sqp_opts
615            .step_qp_schur_updates(n + m, Some(hessian_inertia))
616            .unwrap_or(qp_opts.use_schur_updates),
617        ..qp_opts.clone()
618    };
619    let mut qp_solver = ParametricActiveSetSolver::new(make_backend());
620    let eqp_started = std::time::Instant::now();
621    let eqp = qp_solver.solve_with_working_set(&qp, &working, &eqp_opts);
622    n_qp_solves += 1;
623    let eqp_report = EqpReport {
624        outcome: eqp.as_ref().map(|sol| sol.status).map_err(Clone::clone),
625        working_set_changes: eqp.as_ref().map_or(0, |s| s.stats.n_working_set_changes),
626        refactors: eqp.as_ref().map_or(0, |s| s.stats.n_refactor),
627        schur_updates: eqp.as_ref().map_or(0, |s| s.stats.n_schur_updates),
628        rank_guard_calls: qp_solver.rank_guard_calls(),
629        time: eqp_started.elapsed(),
630        linsol_times: qp_solver.linsol_time_split(),
631    };
632    tracing::debug!(target: "pounce::crossover", "crossover step 3 QP: {eqp_report:?}");
633
634    let mut step3_failure = match &eqp {
635        Ok(sol) if sol.status == QpStatus::TimeLimit => CrossoverDecline::TimeLimit,
636        _ => CrossoverDecline::QpFailed,
637    };
638    if let Ok(sol) = eqp
639        && sol.status == QpStatus::Optimal
640    {
641        // Line search on the penalty model (paper §7 step 3). No
642        // second-order correction: this is a single refinement step at a
643        // point that is already converged, and the Maratos effect the SOC
644        // exists for is a *far*-from-solution phenomenon.
645        let ls = l1_merit_line_search(
646            nlp,
647            &seed.x,
648            &sol.x,
649            &sol.lambda_g,
650            &grad_f,
651            f_curr,
652            &c_vals,
653            &bl_c,
654            &bu_c,
655            &xl,
656            &xu,
657            nu0,
658            sqp_opts,
659            None,
660        );
661        if ls.success {
662            let mut cand = SqpIterates {
663                x: ls.x_new.clone(),
664                lambda_g: seed.lambda_g.clone(),
665                lambda_x: seed.lambda_x.clone(),
666                working: Some(sol.working.clone()),
667            };
668            // Interpolate the duals with the accepted step length, exactly
669            // as the SQP driver does — the multipliers must describe the
670            // step that was actually taken.
671            for (l, &lq) in cand.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
672                *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
673            }
674            for (l, &lq) in cand.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
675                *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
676            }
677            let grad_new = nlp.eval_grad_f(&cand.x);
678            let jac_new = nlp.eval_jac_c(&cand.x);
679            let kkt_after = check_kkt(
680                n, m, &cand, &grad_new, &ls.c_new, &bl_c, &bu_c, &xl, &xu, &jac_new,
681            );
682            let after = (kkt_after.stationarity, kkt_after.constr_viol, ls.f_new);
683            // "If that satisfies the stopping tolerances, terminate" — the
684            // paper's step 3 exit, and the reason the common case costs one
685            // iteration and no LP.
686            let stat_tol = sqp_opts.tol.min(sqp_opts.dual_inf_tol);
687            let within_tol = kkt_after.stationarity <= stat_tol
688                && kkt_after.constr_viol <= sqp_opts.constr_viol_tol;
689            if within_tol && accepts(before, after, sqp_opts) {
690                let identified = identify_at(nlp, &cand.x, m_eq, &xl, &xu, &bl_c, &bu_c);
691                let (active_bounds, active_constraints) = count_active(&identified);
692                let compl_after = complementarity_at(
693                    &cand.x,
694                    &ls.c_new,
695                    &cand.lambda_x,
696                    &cand.lambda_g,
697                    &xl,
698                    &xu,
699                    &bl_c,
700                    &bu_c,
701                );
702                return (
703                    CrossoverReport {
704                        phase: Some(CrossoverPhase::EqpStep),
705                        declined: None,
706                        n_iter: 1,
707                        n_qp_solves,
708                        active_bounds,
709                        active_constraints,
710                        estimated_active,
711                        kkt_before: kkt_before.stationarity.max(kkt_before.constr_viol),
712                        kkt_after: kkt_after.stationarity.max(kkt_after.constr_viol),
713                        compl_after,
714                        eqp: Some(eqp_report),
715                    },
716                    Some(SqpResult {
717                        x: cand.x,
718                        lambda_g: cand.lambda_g,
719                        lambda_x: cand.lambda_x,
720                        obj: ls.f_new,
721                        status: SqpStatus::Optimal,
722                        n_iter: 1,
723                        n_qp_solves,
724                        n_qp_working_set_changes: sol.stats.n_working_set_changes,
725                        final_stationarity: kkt_after.stationarity,
726                        final_constr_viol: kkt_after.constr_viol,
727                        working_set: Some(identified),
728                    }),
729                );
730            }
731            step3_failure = CrossoverDecline::Regressed;
732        } else {
733            step3_failure = CrossoverDecline::LineSearchFailed;
734        }
735    }
736
737    // ---- §7 step 4: full active-set run from the interior iterate ----
738    if step3_failure == CrossoverDecline::TimeLimit
739        || sqp_opts
740            .deadline
741            .as_ref()
742            .is_some_and(|d| d.exceeded().is_some())
743    {
744        return (
745            CrossoverReport::declined(CrossoverDecline::TimeLimit).with_eqp(&eqp_report),
746            None,
747        );
748    }
749    if opts.max_iter == 0 {
750        return (
751            CrossoverReport::declined(step3_failure).with_eqp(&eqp_report),
752            None,
753        );
754    }
755    let step4_opts = SqpOptions {
756        max_iter: opts.max_iter,
757        l1_penalty: nu0,
758        ..sqp_opts.clone()
759    };
760    let Some(mut sqp) = make_sqp(step4_opts) else {
761        return (
762            CrossoverReport::declined(step3_failure).with_eqp(&eqp_report),
763            None,
764        );
765    };
766    iter.working = Some(working);
767    let res = match sqp.optimize_with_warm_start(nlp, Some(iter)) {
768        Ok(r) => r,
769        Err(e) => {
770            tracing::debug!(target: "pounce::crossover", "crossover step 4 failed: {e:?}");
771            return (
772                CrossoverReport::declined(step3_failure).with_eqp(&eqp_report),
773                None,
774            );
775        }
776    };
777    n_qp_solves += res.n_qp_solves;
778    if res.status != SqpStatus::Optimal {
779        let reason = if matches!(
780            res.status,
781            SqpStatus::WallTimeExceeded | SqpStatus::CpuTimeExceeded
782        ) {
783            CrossoverDecline::TimeLimit
784        } else {
785            CrossoverDecline::ActiveSetNotConverged
786        };
787        return (
788            CrossoverReport::declined(reason).with_eqp(&eqp_report),
789            None,
790        );
791    }
792    let after = (res.final_stationarity, res.final_constr_viol, res.obj);
793    if !accepts(before, after, sqp_opts) {
794        return (
795            CrossoverReport::declined(CrossoverDecline::Regressed).with_eqp(&eqp_report),
796            None,
797        );
798    }
799    let identified = identify_at(nlp, &res.x, m_eq, &xl, &xu, &bl_c, &bu_c);
800    let (active_bounds, active_constraints) = count_active(&identified);
801    let compl_after = {
802        let c_final = nlp.eval_c(&res.x);
803        complementarity_at(
804            &res.x,
805            &c_final,
806            &res.lambda_x,
807            &res.lambda_g,
808            &xl,
809            &xu,
810            &bl_c,
811            &bu_c,
812        )
813    };
814    let mut res = res;
815    res.working_set = Some(identified);
816    let report = CrossoverReport {
817        phase: Some(CrossoverPhase::ActiveSet),
818        declined: None,
819        n_iter: res.n_iter,
820        n_qp_solves,
821        active_bounds,
822        active_constraints,
823        estimated_active,
824        kkt_before: kkt_before.stationarity.max(kkt_before.constr_viol),
825        kkt_after: res.final_stationarity.max(res.final_constr_viol),
826        compl_after,
827        eqp: Some(eqp_report),
828    };
829    (report, Some(res))
830}
831
832/// Read the active set off a crossed-over point.
833///
834/// Not the same thing as the working set `pounce-qp` returns, and the
835/// difference matters. The QP's working set answers "which rows did I have
836/// to constrain to compute this step" — a row the step lands *exactly* on
837/// without ever being blocked by it is legitimately absent from it. That is
838/// precisely the weakly-active case (multiplier zero, constraint binding),
839/// i.e. the case crossover exists to resolve, so reporting the QP's set as
840/// the identified active set would report `Inactive` for the one row the
841/// user ran crossover to ask about.
842///
843/// It is also not [`classify_working_set`], which is the *interior*-iterate
844/// test: there the primal distance is `O(√μ)` and unusable, so multiplier
845/// sign carries the decision. Here the situation is inverted. The primal
846/// test is exact — that is what the phase bought — while the multiplier at a
847/// weakly active constraint is zero to within rounding, so its sign is
848/// noise. Deciding activity on a `−1e-17` multiplier would discard exactly
849/// the constraints crossover was run to identify. Multiplier sign is
850/// consulted only where the primal test genuinely cannot choose a side:
851/// a point tight against *both* of two distinct bounds.
852///
853/// `pounce-qp` treats an incoming working set as a hint and prunes to a
854/// maximal linearly independent subset, so publishing this as the
855/// warm-start set is safe even where the tight test admits a dependent row.
856#[allow(clippy::too_many_arguments)]
857fn identify_at<N: SqpProblemSpec>(
858    nlp: &mut N,
859    x: &[Number],
860    m_eq: usize,
861    xl: &[Number],
862    xu: &[Number],
863    bl_c: &[Number],
864    bu_c: &[Number],
865) -> WorkingSet {
866    let c_vals = nlp.eval_c(x);
867    // Relative tolerance: a constraint whose bound is `1e6` holds to
868    // machine precision at about `1e-10` absolute, which a fixed `1e-9`
869    // floor would only just admit and a slightly larger bound would not.
870    let tight = |v: Number, bound: Number| -> bool {
871        (v - bound).abs() <= IDENTIFIED_PRIMAL_TOL * bound.abs().max(1.0)
872    };
873
874    let mut bounds = Vec::with_capacity(xl.len());
875    for i in 0..xl.len() {
876        let lo_fin = xl[i] > NLP_LOWER_BOUND_INF;
877        let up_fin = xu[i] < NLP_UPPER_BOUND_INF;
878        let at_lo = lo_fin && tight(x[i], xl[i]);
879        let at_up = up_fin && tight(x[i], xu[i]);
880        bounds.push(if at_lo && at_up {
881            // Both bounds tight: either genuinely fixed, or a box so
882            // narrow the two are indistinguishable at this tolerance.
883            BoundStatus::Fixed
884        } else if at_lo {
885            BoundStatus::AtLower
886        } else if at_up {
887            BoundStatus::AtUpper
888        } else {
889            BoundStatus::Inactive
890        });
891    }
892
893    let mut constraints = Vec::with_capacity(bl_c.len());
894    for i in 0..bl_c.len() {
895        if i < m_eq {
896            constraints.push(ConsStatus::Equality);
897            continue;
898        }
899        let lo_fin = bl_c[i] > NLP_LOWER_BOUND_INF;
900        let up_fin = bu_c[i] < NLP_UPPER_BOUND_INF;
901        let g = c_vals.get(i).copied().unwrap_or(0.0);
902        let at_lo = lo_fin && tight(g, bl_c[i]);
903        let at_up = up_fin && tight(g, bu_c[i]);
904        constraints.push(if at_lo && at_up {
905            // A range row pinched to a point, or tight against both ends of
906            // a very narrow range: active either way, so report it as the
907            // equality it effectively is. The one place a side genuinely
908            // cannot be read off the primal.
909            ConsStatus::Equality
910        } else if at_lo {
911            ConsStatus::AtLower
912        } else if at_up {
913            ConsStatus::AtUpper
914        } else {
915            ConsStatus::Inactive
916        });
917    }
918    WorkingSet {
919        bounds,
920        constraints,
921    }
922}
923
924/// How many leading rows are equalities.
925///
926/// The IPM-side adapter orders constraints `[c ; d]` — equalities first — so
927/// this is a prefix count, and [`classify_working_set`] takes it as such.
928/// Counting rather than asking the adapter keeps this function usable against
929/// any [`SqpProblemSpec`], including the hand-built ones in the tests.
930fn m_eq_count(bl_c: &[Number], bu_c: &[Number]) -> usize {
931    bl_c.iter()
932        .zip(bu_c.iter())
933        .take_while(|(lo, hi)| lo == hi)
934        .count()
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    fn opts() -> SqpOptions {
942        SqpOptions {
943            tol: 1e-8,
944            dual_inf_tol: 1e-4,
945            constr_viol_tol: 1e-6,
946            ..SqpOptions::default()
947        }
948    }
949
950    #[test]
951    fn m_eq_count_takes_the_leading_equality_block_only() {
952        // [eq, eq, ineq, eq] — the trailing equality is NOT counted: the
953        // adapter's layout guarantees equalities are a prefix, and a
954        // mid-vector match would mean the caller broke that contract.
955        let bl = [0.0, 0.0, -1.0, 2.0];
956        let bu = [0.0, 0.0, 1.0, 2.0];
957        assert_eq!(m_eq_count(&bl, &bu), 2);
958    }
959
960    #[test]
961    fn accepts_lets_a_residual_move_inside_its_own_tolerance() {
962        let o = opts();
963        // Stationarity rises 1e-12 → 1e-10 but stays far inside dual_inf_tol:
964        // exactly the "now sitting ON the constraint" case crossover creates.
965        assert!(accepts((1e-12, 1e-12, 1.0), (1e-10, 1e-10, 1.0), &o));
966    }
967
968    #[test]
969    fn accepts_refuses_a_residual_that_crosses_its_tolerance() {
970        let o = opts();
971        assert!(!accepts((1e-12, 1e-12, 1.0), (1e-3, 1e-12, 1.0), &o));
972        assert!(!accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-4, 1.0), &o));
973    }
974
975    #[test]
976    fn accepts_refuses_an_objective_that_walked_away() {
977        let o = opts();
978        // A point that is KKT-clean but at a different, worse optimum.
979        assert!(!accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-12, 1.5), &o));
980        // A decrease is always fine.
981        assert!(accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-12, 0.5), &o));
982    }
983
984    #[test]
985    fn accepts_refuses_non_finite_residuals() {
986        let o = opts();
987        assert!(!accepts((1e-12, 1e-12, 1.0), (Number::NAN, 1e-12, 1.0), &o));
988        assert!(!accepts(
989            (1e-12, 1e-12, 1.0),
990            (1e-12, 1e-12, Number::INFINITY),
991            &o
992        ));
993    }
994
995    #[test]
996    fn count_active_splits_bounds_and_rows() {
997        let w = WorkingSet {
998            bounds: vec![
999                BoundStatus::AtLower,
1000                BoundStatus::Inactive,
1001                BoundStatus::Fixed,
1002            ],
1003            constraints: vec![
1004                ConsStatus::Equality,
1005                ConsStatus::Inactive,
1006                ConsStatus::AtUpper,
1007            ],
1008        };
1009        assert_eq!(count_active(&w), (2, 2));
1010    }
1011
1012    #[test]
1013    fn declined_report_is_not_accepted() {
1014        let r = CrossoverReport::declined(CrossoverDecline::NothingToIdentify);
1015        assert!(!r.accepted());
1016        assert_eq!(r.declined, Some(CrossoverDecline::NothingToIdentify));
1017    }
1018}