Expand description
NLP crossover: hand a converged interior-point iterate to the active-set path so the solve ends on an exact active set (Byrd, Nocedal & Waltz, “KNITRO: An Integrated Package for Nonlinear Optimization”, 2006, §7; gh#612).
§Why
An interior-point method never puts an iterate on a constraint: the
fraction-to-boundary rule keeps every slack strictly positive, so at
termination “which constraints are active” is an inference from a
tolerance test, not a fact the solve established. Where strict
complementarity holds that inference is right and this whole phase is a
no-op by design. Where it fails — a weakly active bound whose slack and
multiplier are both O(√μ) — the interior solve cannot answer the
question at all, and three subsystems downstream are already paying for
that:
- [
pounce_sensitivity]’scovariance()classifies activity into STRONGLY ACTIVE / WEAKLY ACTIVE / AMBIGUOUS / UNIDENTIFIED (docs/src/sensitivity.md). The AMBIGUOUS class exists precisely because a barrier geometry cannot decide. - A degenerate solution collapses the reduced Hessian, which has come
back as an inertia problem repeatedly (#540, #541, #544, #592, the
feral_singular_pivot_floorknob) and been met each time on the perturbation side. - The active-set SQP’s warm start could only come from a previous SQP
solve (
docs/src/active-set-sqp.md), so a sequence whose first solve wants the IPM had no way to hand off. Crossover is that missing edge: after it runs,crate::application::IpoptApplication::last_sqp_working_setreturns a working set the nextalgorithm=active-set-sqpsolve can consume.
pounce-convex has had the LP form of this for a while
([pounce_convex::crossover]); this is the NLP analogue, and it borrows
that module’s two load-bearing ideas: crossover is a bridge, not a new
solver, and it is never-regress — the crossed-over point replaces the
interior one only when it is at least as good a KKT point.
§What it does (paper §7)
- The IPM terminates at
(x, y, z)withinE_tol. - Estimate the active set
Aby a tolerance test on primal distance and multiplier magnitude —crate::sqp::classify_working_set, the same classifier the sensitivity-corrector handoff already uses. - Take one EQP step over
Aplus a line search on the penalty model. If the result satisfies the stopping tolerances, terminate. This is the common path and it solves no LPs, so on a well-behaved problem crossover costs about one iteration. - Otherwise run the full active-set algorithm from the interior iterate,
seeded with
Aand withν₀a little above the largest|multiplier|at the interior solution.
§Where this departs from the paper
KNITRO’s active-set path is SLQP: an LP phase picks the working set
and an EQP phase computes the step, so its step 3 is a literal EQP solve
and its step 4 sizes an LP trust region (their eq. 7.22) to exclude every
inactive constraint. POUNCE’s active-set path is an ordinary line-search
SQP over pounce-qp’s working-set interface, so:
- Step 3 is expressed as one
pounce-qpQpSolver::solve_with_working_setagainst the NLP linearization at the interior iterate, warm-started withA. That call factorizes the hinted active set to recover a primal, then pivots — which is exactly “solve the EQP overA, and fixAwhere the tolerance test got it wrong”. The paper’s guarantee that step 3 avoids an LP is preserved:pounce-qpsolves no LP either. - Step 4’s LP trust region has no analogue and is not implemented; the
ν₀half of that setup is, since the ℓ₁ merit the SQP already carries takes exactly that parameter.
§It runs against the declared bounds
The caller hands this an
crate::sqp::IpoptNlpAdapter::new_with_declared_bounds, not a plain
one, and that is load-bearing rather than tidy. bound_relax_factor
(default 1e-8) widens every bound before the interior solve starts, so
a point sitting exactly on a bound the user declared is a full 1e-8
inside the relaxed one. Measured against the relaxed bounds, a pivot
that lands precisely on the binding constraint reads as strictly
interior, and the identification step then correctly reports an empty
active set — crossover would run, succeed, and answer nothing. Worse, the
pivot itself would stop 1e-8 shy of each constraint, because against
the relaxed problem that point genuinely is optimal.
So the whole phase is posed on the model as written. The consequence to
be aware of is that the returned point can sit on a declared bound rather
than inside it, which is constr_viol_tol-legal by construction (the
relaxation is capped there) and is the result being asked for.
§Never-regress
Crossover is a strict refinement of a solve that has already succeeded, so
the bar is not “did it solve” but “is this at least as good a KKT point”.
accepts applies three gates against the interior iterate — constraint
violation, stationarity, and objective — and any failure returns the
interior solution untouched. Nothing here can turn a converged solve into
a failed one: on every abandonment path the caller keeps what the IPM
produced.
§Cost and defaults
Off by default (crossover=no). It runs strictly after convergence, so
enabling it moves no interior trajectory and needs no baseline fixture
sweep (contrast an initial-point or merit-function change, per
CLAUDE.md).
Structs§
- Crossover
Options - Tuning for the crossover phase. Populated from the
crossover*options bycrate::application::IpoptApplication. - Crossover
Report - What crossover did. Retrieved from
crate::application::IpoptApplication::crossover_report. - Crossover
Seed - The converged interior-point iterate, in the algorithm’s (compressed,
scaled) space — the same space
crate::sqp::IpoptNlpAdapterpresents, so no translation is needed between the two engines.
Enums§
- Crossover
Decline - Why crossover did not replace the interior iterate. Reported rather than swallowed: “crossover ran and declined” and “crossover never ran” are different facts about a solve, and the AMBIGUOUS-activity consumers this exists for need to tell them apart.
- Crossover
Phase - Which of the paper’s two paths produced the returned point.