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}