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, QpOptions, QpSolver, QpStatus, WorkingSet,
112};
113
114use crate::sqp::iterates::SqpIterates;
115use crate::sqp::line_search::l1_merit_line_search;
116use crate::sqp::options::{SqpHessianSource, SqpOptions};
117use crate::sqp::problem::SqpProblemSpec;
118use crate::sqp::qp_assembly::SqpQpData;
119use crate::sqp::result::{SqpResult, SqpStatus};
120use crate::sqp::sqp_alg::{SqpAlgorithm, check_kkt};
121use crate::sqp::warm_start::classify_working_set;
122
123/// Primal tolerance used to read the active set off the **crossed-over**
124/// point (as opposed to the interior one).
125///
126/// This is deliberately far tighter than `crossover_primal_tol`, which has to
127/// tolerate the `O(√μ)` standoff of an interior iterate — typically `1e-5`.
128/// After crossover the active constraints hold to machine precision, so a
129/// tight test is finally meaningful, and that is the whole point of the
130/// phase: the same question that could not be answered at the interior point
131/// has a definite answer here. `1e-9` is generous by several orders against
132/// the `~1e-16` actually observed, while still an order of magnitude below
133/// the standoff it replaces.
134const IDENTIFIED_PRIMAL_TOL: Number = 1e-9;
135
136/// Slack on the never-regress comparisons, so a crossed-over point is not
137/// rejected for a change at the last bit of a residual that is otherwise
138/// identical.
139const REGRESS_SLACK: Number = 1e-12;
140
141/// Relative slack on the objective gate. The crossed-over point sits on the
142/// active constraints exactly rather than `O(μ)` inside them, so the
143/// objective is expected to move at the tolerance level; anything beyond this
144/// means the active-set phase walked somewhere else and the result is
145/// refused.
146const OBJ_REL_SLACK: Number = 1e-6;
147
148/// Tuning for the crossover phase. Populated from the `crossover*` options by
149/// [`crate::application::IpoptApplication`].
150#[derive(Debug, Clone)]
151pub struct CrossoverOptions {
152    /// Master switch (`crossover`). Default off.
153    pub enabled: bool,
154    /// Multiplier magnitude above which a row is taken active in the §7
155    /// step-2 tolerance test (`crossover_mult_tol`).
156    pub mult_tol: Number,
157    /// Primal distance to a bound below which a row is taken binding in the
158    /// §7 step-2 tolerance test (`crossover_primal_tol`).
159    pub primal_tol: Number,
160    /// Outer-iteration budget for the §7 step-4 fallback
161    /// (`crossover_max_iter`). `0` disables step 4 entirely, leaving
162    /// crossover as the one-step refinement of step 3.
163    pub max_iter: u32,
164}
165
166impl Default for CrossoverOptions {
167    fn default() -> Self {
168        Self {
169            enabled: false,
170            mult_tol: 1e-8,
171            primal_tol: 1e-6,
172            max_iter: 30,
173        }
174    }
175}
176
177/// Which of the paper's two paths produced the returned point.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum CrossoverPhase {
180    /// §7 step 3 — one EQP-equivalent step over the tolerance-test active
181    /// set plus a penalty line search was enough. The common path; no full
182    /// active-set run.
183    EqpStep,
184    /// §7 step 4 — step 3 did not reach the stopping tolerances, so the full
185    /// active-set SQP ran from the interior iterate.
186    ActiveSet,
187}
188
189/// Why crossover did not replace the interior iterate. Reported rather than
190/// swallowed: "crossover ran and declined" and "crossover never ran" are
191/// different facts about a solve, and the AMBIGUOUS-activity consumers this
192/// exists for need to tell them apart.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum CrossoverDecline {
195    /// The problem has no bounds and no constraints, so there is no active
196    /// set to identify.
197    NothingToIdentify,
198    /// The EQP-equivalent QP did not solve, and step 4 was disabled or also
199    /// failed.
200    QpFailed,
201    /// The penalty line search could not accept a step from the interior
202    /// iterate.
203    LineSearchFailed,
204    /// The full active-set run did not converge within `crossover_max_iter`.
205    ActiveSetNotConverged,
206    /// A point was produced but it is not at least as good a KKT point as the
207    /// interior iterate (see [`accepts`]).
208    Regressed,
209}
210
211/// What crossover did. Retrieved from
212/// [`crate::application::IpoptApplication::crossover_report`].
213#[derive(Debug, Clone)]
214pub struct CrossoverReport {
215    /// The phase that produced the accepted point; `None` when crossover
216    /// declined.
217    pub phase: Option<CrossoverPhase>,
218    /// Why it declined; `None` when it did not.
219    pub declined: Option<CrossoverDecline>,
220    /// Outer iterations spent in the step-4 fallback. `0` on the step-3 path.
221    pub n_iter: u32,
222    /// QP subproblems solved across both phases, step 3's included.
223    pub n_qp_solves: u32,
224    /// Variable bounds in the identified active set (`AtLower`, `AtUpper` or
225    /// `Fixed`), read off the returned point where the primal test is exact.
226    /// See [`identify_at`].
227    pub active_bounds: usize,
228    /// Constraint rows in the identified active set (equalities included —
229    /// they are unconditionally active).
230    pub active_constraints: usize,
231    /// Rows and bounds the §7 step-2 tolerance test called active *at the
232    /// interior iterate*, before any pivoting.
233    ///
234    /// Compare against `active_bounds + active_constraints`, which is the
235    /// same question answered at the crossed-over point. They differ exactly
236    /// where the interior iterate could not support the inference — the
237    /// measurement this phase exists to make.
238    pub estimated_active: usize,
239    /// `max(stationarity, constraint violation)` at the interior iterate.
240    pub kkt_before: Number,
241    /// The same at the returned point. Never worse than `kkt_before` beyond
242    /// the tolerances [`accepts`] allows.
243    pub kkt_after: Number,
244    /// Max-norm complementarity at the returned point, measured against the
245    /// **declared** bounds — see [`complementarity_at`]. `NaN` when crossover
246    /// declined.
247    ///
248    /// This exists because the interior method's own complementarity is
249    /// measured against the *relaxed* bounds, and after crossover the two
250    /// frames disagree by the entire relaxation: an iterate sitting exactly
251    /// on a declared bound is `bound_relax_factor` inside the relaxed one, so
252    /// the relaxed reading is `|multiplier| · δ` — around `1e-8` — where the
253    /// truth is zero. Reporting that as the solve's complementarity printed a
254    /// converged run as `Overall NLP error` above `tol` (#646). The caller
255    /// substitutes this figure when the point was accepted.
256    pub compl_after: Number,
257}
258
259impl CrossoverReport {
260    fn declined(reason: CrossoverDecline) -> Self {
261        Self {
262            phase: None,
263            declined: Some(reason),
264            n_iter: 0,
265            n_qp_solves: 0,
266            active_bounds: 0,
267            active_constraints: 0,
268            estimated_active: 0,
269            kkt_before: Number::NAN,
270            kkt_after: Number::NAN,
271            compl_after: Number::NAN,
272        }
273    }
274
275    /// Did crossover replace the interior iterate?
276    pub fn accepted(&self) -> bool {
277        self.phase.is_some()
278    }
279}
280
281/// The converged interior-point iterate, in the algorithm's (compressed,
282/// scaled) space — the same space [`crate::sqp::IpoptNlpAdapter`] presents,
283/// so no translation is needed between the two engines.
284///
285/// `lambda_x` is the **packed** bound multiplier `z_l − z_u`, matching
286/// [`SqpIterates`] and [`classify_working_set`]; `lambda_g` is `[y_c ; y_d]`.
287#[derive(Debug, Clone)]
288pub struct CrossoverSeed {
289    pub x: Vec<Number>,
290    pub lambda_g: Vec<Number>,
291    pub lambda_x: Vec<Number>,
292}
293
294/// Never-regress gate. The crossed-over point is accepted only when it is at
295/// least as good a KKT point of the *original* NLP as the interior iterate,
296/// on all three of feasibility, stationarity, and objective.
297///
298/// Each residual is compared against `max(interior residual, its tolerance)`
299/// rather than against the interior residual alone: crossover puts the
300/// iterate *on* the active constraints, which can nudge a residual that was
301/// `1e-12` up to `1e-10` while the point is unambiguously better identified.
302/// Refusing that would make the gate reject exactly the cases the phase
303/// exists for. What it still refuses is a residual that crosses its own
304/// tolerance, which is the thing that would turn a converged solve into a
305/// misreported one.
306pub fn accepts(
307    before: (Number, Number, Number),
308    after: (Number, Number, Number),
309    sqp_opts: &SqpOptions,
310) -> bool {
311    let (stat_b, viol_b, obj_b) = before;
312    let (stat_a, viol_a, obj_a) = after;
313    if !(stat_a.is_finite() && viol_a.is_finite() && obj_a.is_finite()) {
314        return false;
315    }
316    let stat_tol = sqp_opts.tol.min(sqp_opts.dual_inf_tol);
317    if stat_a > stat_b.max(stat_tol) + REGRESS_SLACK {
318        return false;
319    }
320    if viol_a > viol_b.max(sqp_opts.constr_viol_tol) + REGRESS_SLACK {
321        return false;
322    }
323    // Objective: a *decrease* is always fine (crossover found a better point
324    // on the same active set); an increase is bounded relative to the
325    // interior objective's own magnitude.
326    let obj_slack = OBJ_REL_SLACK * obj_b.abs().max(1.0);
327    obj_a <= obj_b + obj_slack
328}
329
330/// Max-norm complementarity `max_i |slack_i · multiplier_i|` at a
331/// crossed-over point, in the frame crossover actually solved in.
332///
333/// Two things make this different from [`crate::ipopt_cq::IpoptCq`]'s
334/// complementarity, and both are deliberate.
335///
336/// **The bounds are the declared ones.** `nlp` here is the adapter built by
337/// `new_with_declared_bounds`, so `xl`/`xu`/`bl_c`/`bu_c` are the box the
338/// user wrote rather than the `bound_relax_factor`-widened one the interior
339/// iteration ran against. Crossover's whole job is to put the iterate *on*
340/// the active constraints of the problem as posed; measured against the
341/// relaxed bounds that same point reads `|multiplier| · δ` — the relaxation
342/// times the dual, `~1e-8` for a unit multiplier — which is not a residual of
343/// anything, just the width of an internal safeguard (#646).
344///
345/// **The slacks are raw.** The CQ floors a slack that falls below
346/// `eps·min(1,μ)` up to about `μ/z`, which keeps the barrier's `Σ = V/S`
347/// finite during the iteration. At a purified point the active slacks are
348/// *exactly* zero and that floor would put `μ/z ≈ 1e-9` back — reintroducing,
349/// as a reporting artifact, the very quantity crossover removed.
350///
351/// Sign conventions follow the rest of this module: `λ_x = z_l − z_u`
352/// (positive at a lower bound), while a row's `λ_g` is **negative** at its
353/// lower bound, because the bound block enters stationarity negated.
354fn complementarity_at(
355    x: &[Number],
356    c_vals: &[Number],
357    lambda_x: &[Number],
358    lambda_g: &[Number],
359    xl: &[Number],
360    xu: &[Number],
361    bl_c: &[Number],
362    bu_c: &[Number],
363) -> Number {
364    let mut worst = 0.0_f64;
365    // A point may sit a rounding step outside a bound; that is constraint
366    // violation, which `check_kkt` already reports. Clamping at zero here
367    // keeps it from re-entering as a *negative* complementarity.
368    let mut take = |slack: Number, mult: Number| {
369        worst = worst.max((slack.max(0.0) * mult).abs());
370    };
371    for i in 0..x.len() {
372        if xl[i] > NLP_LOWER_BOUND_INF {
373            take(x[i] - xl[i], lambda_x[i].max(0.0));
374        }
375        if xu[i] < NLP_UPPER_BOUND_INF {
376            take(xu[i] - x[i], (-lambda_x[i]).max(0.0));
377        }
378    }
379    for i in 0..c_vals.len() {
380        if bl_c[i] > NLP_LOWER_BOUND_INF {
381            take(c_vals[i] - bl_c[i], (-lambda_g[i]).max(0.0));
382        }
383        if bu_c[i] < NLP_UPPER_BOUND_INF {
384            take(bu_c[i] - c_vals[i], lambda_g[i].max(0.0));
385        }
386    }
387    worst
388}
389
390/// Count the active entries of a working set, split bounds / rows.
391fn count_active(w: &WorkingSet) -> (usize, usize) {
392    let bounds = w
393        .bounds
394        .iter()
395        .filter(|b| !matches!(b, BoundStatus::Inactive))
396        .count();
397    let rows = w
398        .constraints
399        .iter()
400        .filter(|c| !matches!(c, ConsStatus::Inactive))
401        .count();
402    (bounds, rows)
403}
404
405/// Run the crossover phase (paper §7 steps 2-4).
406///
407/// `seed` is the converged interior iterate. `make_backend` supplies the
408/// sparse symmetric linear solver for the active-set engine — the same
409/// factory the IPM used, so crossover inherits the caller's `linear_solver`
410/// choice. `make_sqp` builds the step-4 driver; it is a closure rather than a
411/// value because step 4 needs its own iteration budget and `ν₀`, and it is
412/// never called at all on the step-3 path.
413///
414/// Returns the report always, and the replacement solution only when it was
415/// accepted.
416pub fn run<N, B, S>(
417    nlp: &mut N,
418    seed: &CrossoverSeed,
419    opts: &CrossoverOptions,
420    sqp_opts: &SqpOptions,
421    qp_opts: &QpOptions,
422    mut make_backend: B,
423    mut make_sqp: S,
424) -> (CrossoverReport, Option<SqpResult>)
425where
426    N: SqpProblemSpec,
427    B: FnMut() -> Box<dyn SparseSymLinearSolverInterface>,
428    S: FnMut(SqpOptions) -> Option<SqpAlgorithm>,
429{
430    let n = nlp.n();
431    let m = nlp.m();
432    let (xl, xu) = nlp.variable_bounds();
433    let (bl_c, bu_c) = nlp.constraint_bounds();
434
435    // Nothing to identify: no general rows and no finite bound anywhere. The
436    // interior iterate is already an unconstrained stationary point and its
437    // active set is empty by construction.
438    let any_bound = xl
439        .iter()
440        .any(|&v| v > NLP_LOWER_BOUND_INF)
441        .then_some(true)
442        .or_else(|| xu.iter().any(|&v| v < NLP_UPPER_BOUND_INF).then_some(true))
443        .unwrap_or(false);
444    if m == 0 && !any_bound {
445        return (
446            CrossoverReport::declined(CrossoverDecline::NothingToIdentify),
447            None,
448        );
449    }
450
451    // ---- §7 step 1: residuals at the interior iterate ----
452    let f_curr = nlp.eval_f(&seed.x);
453    let c_vals = nlp.eval_c(&seed.x);
454    let grad_f = nlp.eval_grad_f(&seed.x);
455    let jac_c = nlp.eval_jac_c(&seed.x);
456
457    let mut iter = SqpIterates {
458        x: seed.x.clone(),
459        lambda_g: seed.lambda_g.clone(),
460        lambda_x: seed.lambda_x.clone(),
461        working: None,
462    };
463    let kkt_before = check_kkt(
464        n, m, &iter, &grad_f, &c_vals, &bl_c, &bu_c, &xl, &xu, &jac_c,
465    );
466    let before = (kkt_before.stationarity, kkt_before.constr_viol, f_curr);
467
468    // ---- §7 step 2: estimate the active set by the tolerance test ----
469    let m_eq = m_eq_count(&bl_c, &bu_c);
470    let working = classify_working_set(
471        &seed.lambda_x,
472        &seed.lambda_g,
473        m_eq,
474        &seed.x,
475        &xl,
476        &xu,
477        &c_vals,
478        &bl_c,
479        &bu_c,
480        opts.mult_tol,
481        opts.primal_tol,
482    );
483    let (est_bounds, est_rows) = count_active(&working);
484    let estimated_active = est_bounds + est_rows;
485
486    // `ν₀` a little above the largest |multiplier| at the interior solution
487    // (paper §7). The ℓ₁ merit's own Han-Powell update only ever raises ν, so
488    // seeding it here is what keeps the first crossover step from being
489    // rejected by a penalty that has not yet caught up with the duals the IPM
490    // already found.
491    let mult_inf = seed
492        .lambda_g
493        .iter()
494        .chain(seed.lambda_x.iter())
495        .map(|v| v.abs())
496        .fold(0.0_f64, f64::max);
497    let nu0 = (mult_inf + sqp_opts.l1_penalty_safety)
498        .max(sqp_opts.l1_penalty)
499        .min(sqp_opts.l1_penalty_max);
500
501    // ---- §7 step 3: one EQP-equivalent step over the estimated set ----
502    let mut n_qp_solves = 0_u32;
503    let hessian_inertia = match sqp_opts.hessian {
504        SqpHessianSource::Exact => pounce_qp::HessianInertia::Indefinite,
505        _ => pounce_qp::HessianInertia::Psd,
506    };
507    let hess_lag = nlp.eval_hess_lag(&seed.x, &seed.lambda_g);
508    let qp_data = SqpQpData::build(
509        &seed.x,
510        &grad_f,
511        &c_vals,
512        &bl_c,
513        &bu_c,
514        &xl,
515        &xu,
516        jac_c.clone(),
517        hess_lag,
518        hessian_inertia,
519    );
520    let qp = qp_data.as_qp();
521    let mut qp_solver = ParametricActiveSetSolver::new(make_backend());
522    let eqp = qp_solver.solve_with_working_set(&qp, &working, qp_opts);
523    n_qp_solves += 1;
524
525    let mut step3_failure = CrossoverDecline::QpFailed;
526    if let Ok(sol) = eqp
527        && sol.status == QpStatus::Optimal
528    {
529        // Line search on the penalty model (paper §7 step 3). No
530        // second-order correction: this is a single refinement step at a
531        // point that is already converged, and the Maratos effect the SOC
532        // exists for is a *far*-from-solution phenomenon.
533        let ls = l1_merit_line_search(
534            nlp,
535            &seed.x,
536            &sol.x,
537            &sol.lambda_g,
538            &grad_f,
539            f_curr,
540            &c_vals,
541            &bl_c,
542            &bu_c,
543            &xl,
544            &xu,
545            nu0,
546            sqp_opts,
547            None,
548        );
549        if ls.success {
550            let mut cand = SqpIterates {
551                x: ls.x_new.clone(),
552                lambda_g: seed.lambda_g.clone(),
553                lambda_x: seed.lambda_x.clone(),
554                working: Some(sol.working.clone()),
555            };
556            // Interpolate the duals with the accepted step length, exactly
557            // as the SQP driver does — the multipliers must describe the
558            // step that was actually taken.
559            for (l, &lq) in cand.lambda_g.iter_mut().zip(sol.lambda_g.iter()) {
560                *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
561            }
562            for (l, &lq) in cand.lambda_x.iter_mut().zip(sol.lambda_x.iter()) {
563                *l = (1.0 - ls.alpha) * *l + ls.alpha * lq;
564            }
565            let grad_new = nlp.eval_grad_f(&cand.x);
566            let jac_new = nlp.eval_jac_c(&cand.x);
567            let kkt_after = check_kkt(
568                n, m, &cand, &grad_new, &ls.c_new, &bl_c, &bu_c, &xl, &xu, &jac_new,
569            );
570            let after = (kkt_after.stationarity, kkt_after.constr_viol, ls.f_new);
571            // "If that satisfies the stopping tolerances, terminate" — the
572            // paper's step 3 exit, and the reason the common case costs one
573            // iteration and no LP.
574            let stat_tol = sqp_opts.tol.min(sqp_opts.dual_inf_tol);
575            let within_tol = kkt_after.stationarity <= stat_tol
576                && kkt_after.constr_viol <= sqp_opts.constr_viol_tol;
577            if within_tol && accepts(before, after, sqp_opts) {
578                let identified = identify_at(nlp, &cand.x, m_eq, &xl, &xu, &bl_c, &bu_c);
579                let (active_bounds, active_constraints) = count_active(&identified);
580                let compl_after = complementarity_at(
581                    &cand.x,
582                    &ls.c_new,
583                    &cand.lambda_x,
584                    &cand.lambda_g,
585                    &xl,
586                    &xu,
587                    &bl_c,
588                    &bu_c,
589                );
590                return (
591                    CrossoverReport {
592                        phase: Some(CrossoverPhase::EqpStep),
593                        declined: None,
594                        n_iter: 1,
595                        n_qp_solves,
596                        active_bounds,
597                        active_constraints,
598                        estimated_active,
599                        kkt_before: kkt_before.stationarity.max(kkt_before.constr_viol),
600                        kkt_after: kkt_after.stationarity.max(kkt_after.constr_viol),
601                        compl_after,
602                    },
603                    Some(SqpResult {
604                        x: cand.x,
605                        lambda_g: cand.lambda_g,
606                        lambda_x: cand.lambda_x,
607                        obj: ls.f_new,
608                        status: SqpStatus::Optimal,
609                        n_iter: 1,
610                        n_qp_solves,
611                        n_qp_working_set_changes: sol.stats.n_working_set_changes,
612                        final_stationarity: kkt_after.stationarity,
613                        final_constr_viol: kkt_after.constr_viol,
614                        working_set: Some(identified),
615                    }),
616                );
617            }
618            step3_failure = CrossoverDecline::Regressed;
619        } else {
620            step3_failure = CrossoverDecline::LineSearchFailed;
621        }
622    }
623
624    // ---- §7 step 4: full active-set run from the interior iterate ----
625    if opts.max_iter == 0 {
626        return (CrossoverReport::declined(step3_failure), None);
627    }
628    let step4_opts = SqpOptions {
629        max_iter: opts.max_iter,
630        l1_penalty: nu0,
631        ..sqp_opts.clone()
632    };
633    let Some(mut sqp) = make_sqp(step4_opts) else {
634        return (CrossoverReport::declined(step3_failure), None);
635    };
636    iter.working = Some(working);
637    let res = match sqp.optimize_with_warm_start(nlp, Some(iter)) {
638        Ok(r) => r,
639        Err(e) => {
640            tracing::debug!(target: "pounce::crossover", "crossover step 4 failed: {e:?}");
641            return (CrossoverReport::declined(step3_failure), None);
642        }
643    };
644    n_qp_solves += res.n_qp_solves;
645    if res.status != SqpStatus::Optimal {
646        return (
647            CrossoverReport::declined(CrossoverDecline::ActiveSetNotConverged),
648            None,
649        );
650    }
651    let after = (res.final_stationarity, res.final_constr_viol, res.obj);
652    if !accepts(before, after, sqp_opts) {
653        return (CrossoverReport::declined(CrossoverDecline::Regressed), None);
654    }
655    let identified = identify_at(nlp, &res.x, m_eq, &xl, &xu, &bl_c, &bu_c);
656    let (active_bounds, active_constraints) = count_active(&identified);
657    let compl_after = {
658        let c_final = nlp.eval_c(&res.x);
659        complementarity_at(
660            &res.x,
661            &c_final,
662            &res.lambda_x,
663            &res.lambda_g,
664            &xl,
665            &xu,
666            &bl_c,
667            &bu_c,
668        )
669    };
670    let mut res = res;
671    res.working_set = Some(identified);
672    let report = CrossoverReport {
673        phase: Some(CrossoverPhase::ActiveSet),
674        declined: None,
675        n_iter: res.n_iter,
676        n_qp_solves,
677        active_bounds,
678        active_constraints,
679        estimated_active,
680        kkt_before: kkt_before.stationarity.max(kkt_before.constr_viol),
681        kkt_after: res.final_stationarity.max(res.final_constr_viol),
682        compl_after,
683    };
684    (report, Some(res))
685}
686
687/// Read the active set off a crossed-over point.
688///
689/// Not the same thing as the working set `pounce-qp` returns, and the
690/// difference matters. The QP's working set answers "which rows did I have
691/// to constrain to compute this step" — a row the step lands *exactly* on
692/// without ever being blocked by it is legitimately absent from it. That is
693/// precisely the weakly-active case (multiplier zero, constraint binding),
694/// i.e. the case crossover exists to resolve, so reporting the QP's set as
695/// the identified active set would report `Inactive` for the one row the
696/// user ran crossover to ask about.
697///
698/// It is also not [`classify_working_set`], which is the *interior*-iterate
699/// test: there the primal distance is `O(√μ)` and unusable, so multiplier
700/// sign carries the decision. Here the situation is inverted. The primal
701/// test is exact — that is what the phase bought — while the multiplier at a
702/// weakly active constraint is zero to within rounding, so its sign is
703/// noise. Deciding activity on a `−1e-17` multiplier would discard exactly
704/// the constraints crossover was run to identify. Multiplier sign is
705/// consulted only where the primal test genuinely cannot choose a side:
706/// a point tight against *both* of two distinct bounds.
707///
708/// `pounce-qp` treats an incoming working set as a hint and prunes to a
709/// maximal linearly independent subset, so publishing this as the
710/// warm-start set is safe even where the tight test admits a dependent row.
711#[allow(clippy::too_many_arguments)]
712fn identify_at<N: SqpProblemSpec>(
713    nlp: &mut N,
714    x: &[Number],
715    m_eq: usize,
716    xl: &[Number],
717    xu: &[Number],
718    bl_c: &[Number],
719    bu_c: &[Number],
720) -> WorkingSet {
721    let c_vals = nlp.eval_c(x);
722    // Relative tolerance: a constraint whose bound is `1e6` holds to
723    // machine precision at about `1e-10` absolute, which a fixed `1e-9`
724    // floor would only just admit and a slightly larger bound would not.
725    let tight = |v: Number, bound: Number| -> bool {
726        (v - bound).abs() <= IDENTIFIED_PRIMAL_TOL * bound.abs().max(1.0)
727    };
728
729    let mut bounds = Vec::with_capacity(xl.len());
730    for i in 0..xl.len() {
731        let lo_fin = xl[i] > NLP_LOWER_BOUND_INF;
732        let up_fin = xu[i] < NLP_UPPER_BOUND_INF;
733        let at_lo = lo_fin && tight(x[i], xl[i]);
734        let at_up = up_fin && tight(x[i], xu[i]);
735        bounds.push(if at_lo && at_up {
736            // Both bounds tight: either genuinely fixed, or a box so
737            // narrow the two are indistinguishable at this tolerance.
738            BoundStatus::Fixed
739        } else if at_lo {
740            BoundStatus::AtLower
741        } else if at_up {
742            BoundStatus::AtUpper
743        } else {
744            BoundStatus::Inactive
745        });
746    }
747
748    let mut constraints = Vec::with_capacity(bl_c.len());
749    for i in 0..bl_c.len() {
750        if i < m_eq {
751            constraints.push(ConsStatus::Equality);
752            continue;
753        }
754        let lo_fin = bl_c[i] > NLP_LOWER_BOUND_INF;
755        let up_fin = bu_c[i] < NLP_UPPER_BOUND_INF;
756        let g = c_vals.get(i).copied().unwrap_or(0.0);
757        let at_lo = lo_fin && tight(g, bl_c[i]);
758        let at_up = up_fin && tight(g, bu_c[i]);
759        constraints.push(if at_lo && at_up {
760            // A range row pinched to a point, or tight against both ends of
761            // a very narrow range: active either way, so report it as the
762            // equality it effectively is. The one place a side genuinely
763            // cannot be read off the primal.
764            ConsStatus::Equality
765        } else if at_lo {
766            ConsStatus::AtLower
767        } else if at_up {
768            ConsStatus::AtUpper
769        } else {
770            ConsStatus::Inactive
771        });
772    }
773    WorkingSet {
774        bounds,
775        constraints,
776    }
777}
778
779/// How many leading rows are equalities.
780///
781/// The IPM-side adapter orders constraints `[c ; d]` — equalities first — so
782/// this is a prefix count, and [`classify_working_set`] takes it as such.
783/// Counting rather than asking the adapter keeps this function usable against
784/// any [`SqpProblemSpec`], including the hand-built ones in the tests.
785fn m_eq_count(bl_c: &[Number], bu_c: &[Number]) -> usize {
786    bl_c.iter()
787        .zip(bu_c.iter())
788        .take_while(|(lo, hi)| lo == hi)
789        .count()
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    fn opts() -> SqpOptions {
797        SqpOptions {
798            tol: 1e-8,
799            dual_inf_tol: 1e-4,
800            constr_viol_tol: 1e-6,
801            ..SqpOptions::default()
802        }
803    }
804
805    #[test]
806    fn m_eq_count_takes_the_leading_equality_block_only() {
807        // [eq, eq, ineq, eq] — the trailing equality is NOT counted: the
808        // adapter's layout guarantees equalities are a prefix, and a
809        // mid-vector match would mean the caller broke that contract.
810        let bl = [0.0, 0.0, -1.0, 2.0];
811        let bu = [0.0, 0.0, 1.0, 2.0];
812        assert_eq!(m_eq_count(&bl, &bu), 2);
813    }
814
815    #[test]
816    fn accepts_lets_a_residual_move_inside_its_own_tolerance() {
817        let o = opts();
818        // Stationarity rises 1e-12 → 1e-10 but stays far inside dual_inf_tol:
819        // exactly the "now sitting ON the constraint" case crossover creates.
820        assert!(accepts((1e-12, 1e-12, 1.0), (1e-10, 1e-10, 1.0), &o));
821    }
822
823    #[test]
824    fn accepts_refuses_a_residual_that_crosses_its_tolerance() {
825        let o = opts();
826        assert!(!accepts((1e-12, 1e-12, 1.0), (1e-3, 1e-12, 1.0), &o));
827        assert!(!accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-4, 1.0), &o));
828    }
829
830    #[test]
831    fn accepts_refuses_an_objective_that_walked_away() {
832        let o = opts();
833        // A point that is KKT-clean but at a different, worse optimum.
834        assert!(!accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-12, 1.5), &o));
835        // A decrease is always fine.
836        assert!(accepts((1e-12, 1e-12, 1.0), (1e-12, 1e-12, 0.5), &o));
837    }
838
839    #[test]
840    fn accepts_refuses_non_finite_residuals() {
841        let o = opts();
842        assert!(!accepts((1e-12, 1e-12, 1.0), (Number::NAN, 1e-12, 1.0), &o));
843        assert!(!accepts(
844            (1e-12, 1e-12, 1.0),
845            (1e-12, 1e-12, Number::INFINITY),
846            &o
847        ));
848    }
849
850    #[test]
851    fn count_active_splits_bounds_and_rows() {
852        let w = WorkingSet {
853            bounds: vec![
854                BoundStatus::AtLower,
855                BoundStatus::Inactive,
856                BoundStatus::Fixed,
857            ],
858            constraints: vec![
859                ConsStatus::Equality,
860                ConsStatus::Inactive,
861                ConsStatus::AtUpper,
862            ],
863        };
864        assert_eq!(count_active(&w), (2, 2));
865    }
866
867    #[test]
868    fn declined_report_is_not_accepted() {
869        let r = CrossoverReport::declined(CrossoverDecline::NothingToIdentify);
870        assert!(!r.accepted());
871        assert_eq!(r.declined, Some(CrossoverDecline::NothingToIdentify));
872    }
873}