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}