pounce_algorithm/ipopt_alg.rs
1//! Main optimization loop — port of
2//! `Algorithm/IpIpoptAlg.{hpp,cpp}`.
3//!
4//! Phase 7 ships the loop scaffold matching `Optimize()` lines
5//! 292-563 in upstream. The body invokes:
6//!
7//! 1. `IterateInitializer::set_initial_iterates`
8//! 2. (loop) `OutputIteration` → `CheckConvergence` →
9//! `UpdateBarrierParameter` → `UpdateHessian` →
10//! `ComputeSearchDirection` → `ComputeAcceptableTrialPoint` →
11//! `AcceptTrialPoint`
12//! 3. `correct_bound_multiplier` (kappa_sigma) per `MAIN_LOOP.md`
13//! §"Bound multiplier reset" lines 1055-1134
14//! 4. exception → `SolverReturn` mapping per the table in
15//! `MAIN_LOOP.md`.
16//!
17//! The NLP handle and search-direction calculator are optional:
18//! when both are present, `iterate()` computes a real Newton step and
19//! drives the line search. Without them, `iterate()` runs the bookkeeping
20//! pieces (mu update, hessian update, conv check, kappa_sigma reset)
21//! and is exercised by structural unit tests. The full path lights up
22//! once `pounce-nlp::OrigIpoptNLP` lands.
23
24use crate::alg_builder::AlgorithmBundle;
25use crate::conv_check::r#trait::ConvergenceStatus;
26use crate::intermediate::{CtxGuard, IntermediateContext};
27use crate::ipopt_cq::IpoptCqHandle;
28use crate::ipopt_data::IpoptDataHandle;
29use crate::ipopt_nlp::IpoptNlp;
30use crate::iter_dump::IterDumper;
31use crate::iterate_dump::emit_record as emit_iterate_record;
32use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
33use crate::line_search::backtracking::Outcome;
34use crate::restoration::{RestorationOutcome, RestorationPhase};
35use pounce_common::diagnostics::DiagnosticsState;
36use pounce_common::types::{Index, Number};
37use pounce_linalg::Vector;
38use pounce_nlp::alg_types::SolverReturn;
39use pounce_nlp::return_codes::AlgorithmMode;
40use pounce_nlp::tnlp::{IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, IterStats, TNLP};
41use std::cell::RefCell;
42use std::rc::Rc;
43
44/// Dual-divergence guard (pounce#246): only dual-infeasibility growth in the
45/// *elevated* regime (`inf_du` above this) counts toward the streak, so the
46/// noisy early iterations of a normal solve never build one.
47const DUAL_DIV_COUNT_FLOOR: Number = 1e2;
48/// Dual-divergence guard: the guard fires only once `inf_du` is this large in
49/// absolute terms — well above the transient peaks a converging solve reaches
50/// (e.g. the least-square-init path recovers from ~6e9 on emfl050), and below
51/// the ~1e10 where the KKT factorizations begin to choke, so the diversion to
52/// restoration happens *before* the seconds-long factorizations start.
53const DUAL_DIV_FIRE_TOL: Number = 1e8;
54
55/// gh#884 — the scale-relative search direction below which the iterate
56/// counts as **settled** for the dual-divergence-retry signature.
57///
58/// Measured on `d89771bc`, minimum over the iterates where the primal is
59/// already converged: `qpec_small`/`ncp_eq`/origin reaches `8.6e-14`
60/// through the `.nl` path and `4.3e-8` through a Rust TNLP, while
61/// `ralph1`/`direct`/origin — which *must not* fire, because no
62/// sign-feasible multiplier exists at its origin and failing there is
63/// correct — bottoms out at `7.2e-3`. Five orders of separation; `1e-5`
64/// sits near the middle of it in log terms. The census behind this, and
65/// the corpus fixtures that come closest on either side, are in
66/// `dev-notes/mpcc-biactive-dual-divergence.md`.
67const DUAL_DIV_RETRY_STEP_TOL: Number = 1e-5;
68
69/// gh#884 — the unscaled `‖∇L‖∞` floor for that signature.
70///
71/// Deliberately the same `1e2` as [`DUAL_DIV_COUNT_FLOOR`], and for the
72/// same reason: below it a solve is merely mid-flight. It is what
73/// excludes `eigena2` on the L-BFGS leg, which reaches a settled step of
74/// `7.9e-9` but at an unscaled dual of only `37`.
75pub(crate) const DUAL_DIV_RETRY_DU_FLOOR: Number = 1e2;
76
77/// gh#884 — primal infeasibility below which the primal counts as
78/// converged for that signature. The failure mode is defined by the
79/// primal being *done* while the duals run away, so this conjunct is
80/// what separates it from an ordinary struggling solve.
81const DUAL_DIV_RETRY_PRIMAL_TOL: Number = 1e-8;
82
83/// gh #534 — how many consecutive outer NLP errors the progress test reads.
84/// Four samples give three ratios: enough that a single lucky step cannot pass
85/// the test, short enough to still be inside the endgame it is meant to
86/// recognise. `eigena2`'s quoted tail is exactly four iterations long
87/// (`1.19e-5 → 2.96e-6 → 7.38e-7 → 1.84e-7`).
88const DECLINE_PROGRESS_SAMPLES: usize = 4;
89/// gh #534 — default `resto_decline_progress_ratio`: every one of those ratios
90/// must be at least this contraction for the decline to be deferred. `eigena2`
91/// quarters (ratio `0.249`) and passes; `eigenb2`'s tail *rises*
92/// (`1.88e-7, 2.69e-7, 2.89e-7, 2.93e-7`) and fails, which is the intended
93/// split — the issue calls `eigenb2` a plausible genuine stall and the guard
94/// plausibly right there.
95const DEFAULT_DECLINE_PROGRESS_RATIO: Number = 0.5;
96/// gh #534 — outer iterations a deferred continuation gets to produce a strict
97/// certificate before it is cut and the floor reported. `eigena2`'s
98/// extrapolation needs three; ten leaves room for a slower but still genuine
99/// endgame while keeping the cost of a lost bet bounded and small.
100const DECLINE_CONTINUATION_BUDGET: Index = 10;
101/// gh #534 — default for `resto_decline_deferrals`. One deferral is enough for
102/// the reported case (the continuation either converges within the budget or it
103/// does not); more entries would mostly re-bet on a point the first bet already
104/// failed to improve.
105const DEFAULT_RESTO_DECLINE_DEFERRALS: usize = 1;
106/// gh #797 — default for `neg_curv_escapes`. One is enough for the reported
107/// shape: the escape lands on a point whose reduced Hessian *is* positive
108/// definite, so the probe declines there and a second escape would have nothing
109/// to spend itself on. It is also the conservative default — each escape is a
110/// separate bet, and while none of them can return a worse point than the
111/// certificate it left, each costs its own continuation budget.
112/// Default `limited_memory_ls_failure_restarts` (gh #818): **off**. The
113/// rung is available, and it is not what fixes gh #818.
114///
115/// It shipped in the first draft of this work defaulted to one, on a
116/// measurement taken before `ALPHA_INTERP_MIN_TRIALS` existed: with the
117/// interpolation firing on every trial, a line search failed often
118/// enough that standing in front of the restoration hand-off was worth
119/// something. Gating the interpolation removed most of those failures,
120/// and re-measuring the rung on top of the gate turned the trade
121/// negative. `scripts/sweep-fixtures.sh` against `a5e0a837`, both with
122/// the gate, rung off against rung on:
123///
124/// | fixture | rung off | rung on |
125/// |---|---|---|
126/// | `pooling_rt2stp` | `ErrorInStepComputation`/716 — *unmoved from `main`* | `ErrorInStepComputation`/**744** |
127/// | `infeasible_square_scaled_1em4` | `InfeasibleProblemDetected`/24 — *unmoved from `main`* | **26** |
128/// | `deb7` | `ErrorInStepComputation`/1010 | **`RestorationFailed`**/460 |
129/// | `eigena2` | `ErrorInStepComputation`/201 | **`SolvedToAcceptableLevel`**/174 |
130/// | `issue_508_infeasible_gap_1em4` | `InfeasibleProblemDetected`/79 | 76 |
131///
132/// **This ledger is not the one that set the default.** At the gate of
133/// 5 an earlier revision shipped, the rung cost iterations on both
134/// `eigena2` and `infeasible_square_scaled_1em4` to the same verdict,
135/// and that pair is what kept it off. At 6, `eigena2` *gains* a
136/// reportable point. What still argues for off is narrower: the rung
137/// moves two fixtures off the numbers they have on `main` at no benefit
138/// (`pooling_rt2stp`, `infeasible_square_scaled_1em4`), and it changes
139/// `deb7`'s verdict rather than shortening it — a different answer, not
140/// a faster one. Turning it on is a trajectory change over the whole
141/// corpus and needs its own `scripts/sweep-fixtures.sh` run to justify;
142/// **that case has improved and is worth re-opening.** Every
143/// `issue_818_*` test in `pounce-rs` passes with the rung compiled out
144/// — the interpolation is the fix, not this.
145///
146/// Left in the tree rather than deleted because the reasoning behind it
147/// is sound and unaddressed elsewhere: a restoration phase entered at a
148/// feasible point has no constraint violation to minimize and cannot
149/// help. Some model will want it. Setting the option to a positive value
150/// enables it — and note that setting it *at all*, including to 0, opts
151/// out of the `Solved_To_Acceptable_Level` re-solve, because it is a
152/// [`TERMINATION_POLICY_OPTIONS`](crate::application) key.
153const DEFAULT_LBFGS_LS_FAILURE_RESTARTS: usize = 0;
154
155const DEFAULT_NEG_CURV_ESCAPES: usize = 1;
156/// gh #797 — outer iterations a negative-curvature escape gets to produce a
157/// certificate of its own before it is cut and the stationary point reported.
158/// Generous relative to gh #534's ten, because the escape deliberately lands
159/// far from the point it left (a full fraction-to-the-boundary step) and the
160/// continuation is a fresh endgame rather than the tail of one already in
161/// progress.
162const NEG_CURV_CONTINUATION_BUDGET: Index = 30;
163/// gh #797 — cap on the escape step as a multiple of `1 + ‖(x, s)‖∞`. The
164/// probe's direction has unit infinity-norm, so this bounds the escape by the
165/// iterate's own scale; an absolute cap would mean different things on
166/// differently scaled models. Only binds when the fraction-to-the-boundary rule
167/// does not, i.e. when nothing in the direction runs into a bound.
168const NEG_CURV_MAX_STEP_FACTOR: Number = 10.0;
169/// gh #797 — backtracking steps available to the escape, and the ratio between
170/// them. `0.5^12 ≈ 2.4e-4` of the boundary step, past which a direction that
171/// still fails the decrease test is not one worth taking.
172const NEG_CURV_BACKTRACKS: usize = 12;
173const NEG_CURV_BACKTRACK_FACTOR: Number = 0.5;
174/// gh #797 — Armijo factor on the escape's *second-order* decrease model. The
175/// gradient is (near) zero at a stationary point, so the model is
176/// `½α²dᵀ(W + Σ)d` and this is the fraction of it the trial must actually
177/// realise. Mirrors the line search's `eta_phi` in role, not in value: the
178/// quantity being tested is curvature, and a nonlinear objective gives back
179/// less of a quadratic model's prediction than a linear one gives of a
180/// first-order model's.
181const NEG_CURV_ARMIJO: Number = 0.1;
182
183pub struct IpoptAlgorithm {
184 pub data: IpoptDataHandle,
185 pub cq: IpoptCqHandle,
186 pub bundle: AlgorithmBundle,
187 /// Optional NLP handle. Required for any step that evaluates
188 /// problem functions or pulls bound expansion matrices (init,
189 /// search direction, line-search trial-point evaluation). Absent
190 /// in the structural unit tests of Phases 5-6.
191 pub nlp: Option<Rc<RefCell<dyn IpoptNlp>>>,
192 /// Optional TNLP handle — the user-facing problem. When present,
193 /// `iterate()` fires `TNLP::intermediate_callback` once per outer
194 /// iteration so callers can monitor progress or request early
195 /// termination (returning `false` from the callback surfaces as
196 /// `SolverReturn::UserRequestedStop`). Kept separate from `nlp`
197 /// because the algorithm-side NLP is the *compressed* `OrigIpoptNlp`
198 /// view (fixed-variable elimination, c/d split) while the callback
199 /// payload needs to expose the original-coordinate iterate.
200 pub tnlp: Option<Rc<RefCell<dyn TNLP>>>,
201 /// Set on the *restoration* inner IPM so its callback fires report
202 /// `AlgorithmMode::RestorationPhaseMode` (gh#645). Two things hang
203 /// off it, both in [`Self::fire_intermediate`]:
204 ///
205 /// 1. the mode field of the [`IterStats`] payload, which is what
206 /// tells a caller the numbers beside it (`obj`, `inf_pr`,
207 /// `inf_du`, `alpha_*`) describe the min-C1-norm feasibility
208 /// subproblem rather than the user's NLP;
209 /// 2. whether the live-inspector `IntermediateContext` is installed
210 /// — and for a restoration fire it deliberately is **not**. The
211 /// inner iterate is a `CompoundVector` over `(x_orig, n, p)`, so
212 /// it does not even have the user's `n`, and the C API's
213 /// `GetIpoptCurrent*` family checks the caller's `n`/`m` against
214 /// the *problem's* registered dimensions rather than the
215 /// context's. Installing this context would sail past that check
216 /// and read a differently-shaped `cq`. The live accessors
217 /// therefore report "no data" during restoration, which is the
218 /// truth: there is no current iterate of the user's problem
219 /// while the subproblem is being solved.
220 pub fires_as_restoration: bool,
221 /// Search-direction calculator (`PdSearchDirCalc`). Lands once a
222 /// concrete `SymLinearSolver` backend (MUMPS / FERAL) is wired
223 /// through `AlgBuilder` in Phase 7's tail.
224 pub search_dir: Option<PdSearchDirCalc>,
225 /// Restoration-phase strategy. Invoked when the line search
226 /// returns [`Outcome::Failed`] (port of upstream
227 /// `IpBacktrackingLineSearch::ActivateLineSearch`'s resto
228 /// fallback). Optional: in its absence, line-search failure maps
229 /// directly to [`SolverReturn::RestorationFailure`] so the main
230 /// loop's exit-code semantics match upstream's "no resto built"
231 /// case.
232 pub restoration: Option<Box<dyn RestorationPhase>>,
233
234 /// `kappa_sigma` for the post-AcceptTrialPoint multiplier reset
235 /// (`IpIpoptAlg.cpp:correct_bound_multiplier`, line 1055-1134).
236 pub kappa_sigma: Number,
237 /// `recalc_y` — recompute `y_c`/`y_d` as least-square estimates
238 /// once the iterate is feasible enough, instead of carrying the
239 /// multipliers the Newton step produced. Upstream registers this
240 /// `no`, but its own option text says "If a limited memory
241 /// quasi-Newton option is chosen, this is used by default", so the
242 /// L-BFGS path auto-enables it (see
243 /// `application.rs`). Costs one extra augmented-system solve on
244 /// every iteration where it fires.
245 ///
246 /// It exists because a quasi-Newton model's multipliers are only as
247 /// good as the Hessian approximation behind them: L-BFGS can reach
248 /// a feasible primal and still fail to drive `inf_du` down, because
249 /// the dual step is computed from an approximate `W`. Re-estimating
250 /// `y` by least squares side-steps the approximation entirely.
251 /// `linear_system_scaling=slack-based` is active, so the
252 /// iterate-dependent `s`-block scaling must be refreshed each
253 /// iteration. See [`Self::push_slack_scaling`].
254 pub slack_based_scaling: bool,
255 pub recalc_y: bool,
256 /// `recalc_y_feas_tol` — the constraint-violation threshold below
257 /// which [`Self::recalc_y`] fires. Upstream default `1e-6`.
258 pub recalc_y_feas_tol: Number,
259 pub max_iter: Index,
260 /// `start_with_resto` — force the feasibility restoration phase in
261 /// the first iteration.
262 ///
263 /// This is an **outer**-loop behaviour, which is where it went wrong
264 /// before: the option was threaded from the `OptionsList` through
265 /// `AlgorithmBuilder::resto` into `RestoAlgorithmBuilder` and on into
266 /// `MinC1NrmDriver`, a field on the *inner* restoration solver, where
267 /// there is no first iteration of the outer algorithm to act on. It
268 /// was set by everything and read by nothing, so `start_with_resto
269 /// yes` was a silent no-op. `unimplemented_options.rs`'s
270 /// `the_restoration_switches_reach_the_builder` asserted only that the
271 /// value reached the builder — the very "read site populating a field
272 /// nobody consumes" its own comment names as the defect to avoid.
273 pub start_with_resto: bool,
274 /// Initial primal step length offered to the line search at the
275 /// top of each iteration. Mirrors `IpBacktrackingLineSearch`'s
276 /// fraction-to-the-boundary primal step (with τ = `data.curr_tau`).
277 /// In v1.0 the structural value here is 1.0 and the FTB cap is
278 /// applied per-component when the line-search driver computes
279 /// trial slacks; the simplification holds for non-degenerate runs.
280 pub alpha_init: Number,
281 /// Tiny-step relative tolerance — port of upstream
282 /// `IpBacktrackingLineSearch::tiny_step_tol_` (default `10·EPSILON`).
283 /// Step is "tiny" when `max_i |δx_i|/(1+|x_i|) ≤ tiny_step_tol`
284 /// (and same for s, and `c_viol ≤ 1e-4`).
285 pub tiny_step_tol: Number,
286 /// Port of upstream `IpIpoptAlg.cpp` divergence guard: when
287 /// `max_i |x_i|` exceeds this threshold the optimization aborts with
288 /// `SolverReturn::DivergingIterates`. Default `1e20` matches the
289 /// registered `diverging_iterates_tol` option. Catches MESH and
290 /// similar cases where the normal-mode IPM heads off to infinity
291 /// (orig `f` to ±1e33 by iter 90) before line-search failure forces
292 /// a degenerate restoration entry.
293 pub diverging_iterates_tol: Number,
294 /// #248 divergence persistence — consecutive iterations the primal
295 /// iterate has kept *growing* while past `diverging_iterates_tol` on a
296 /// structurally unbounded side. A genuine recession ray sustains this;
297 /// a transient ill-scaling excursion on a bounded-below problem peaks
298 /// and recedes (MINLPLib `jit1`: `|x|` climbs to ~16 then falls back to
299 /// ~2.9 at the finite optimum). Reset to zero whenever the iterate is
300 /// within the threshold or is not growing.
301 divergence_streak: u32,
302 /// Consecutive iterations for which the primal divergence guard has
303 /// been suppressed because the line search reported
304 /// [`crate::line_search::backtracking::BacktrackingLineSearch::in_watchdog`].
305 ///
306 /// The suppression is a *deferral*, and this is what bounds it. A
307 /// watchdog sequence is supposed to end within
308 /// `watchdog_trial_iter_max` (default 3) iterations, but the flag can
309 /// outlive one: `run_filter_line_search`'s `TinyStep` arm returns
310 /// without consulting `in_watchdog`, so a tiny step taken mid-watchdog
311 /// hands off to restoration with the flag still set, and
312 /// `reset_after_restoration` clears `watchdog_shortened_iter` but not
313 /// `in_watchdog`. Rather than change the line search's state machine
314 /// (a trajectory change, for a hole this guard need not depend on),
315 /// the guard simply stops deferring past
316 /// [`Self::WATCHDOG_DEFER_MAX`] and checks the iterate anyway. Reset
317 /// to zero on any iteration the guard actually runs.
318 watchdog_defer_streak: u32,
319 /// Largest `|x|` seen in the current growth run (companion to
320 /// [`Self::divergence_streak`]). Zero when no run is active.
321 divergence_prev_amax: Number,
322 /// #252 objective at the previous over-threshold iterate of the current
323 /// growth run (companion to [`Self::divergence_streak`]). A genuine
324 /// recession ray drives the (minimized) objective toward `−∞`, so a
325 /// diverging iterate only counts toward the streak when the objective is
326 /// *still descending* against this reference. A transient ill-scaling
327 /// excursion past a finite optimum grows `|x|` while the objective
328 /// *worsens* (the linear tail dominates), so it never accumulates the
329 /// streak — this is the fix for the unbounded-box (`ub = +∞`) B&B node
330 /// subproblems of jit1 that #248's growth-only check still mislabelled
331 /// `UNBOUNDED`. `+∞` when no run is active.
332 divergence_prev_f: Number,
333 /// #252 objective *decrease* at the previous step of the current growth
334 /// run (`prev_prev_f − prev_f`), the companion that lets the streak
335 /// require the descent to be *non-decelerating*. A recession ray's
336 /// per-step objective drop keeps up or accelerates as `|x|` grows
337 /// geometrically (`f` is at least linear along the ray); an excursion
338 /// converging to a finite optimum has a per-step drop that shrinks
339 /// toward zero. `NaN` (non-finite) until the run has a first finite
340 /// decrease to compare against, which bootstraps the check.
341 divergence_prev_decrease: Number,
342 /// #285 recession-ray persistence — consecutive iterations for which the
343 /// *checked recession-ray proof* ([`Self::curr_is_recession_ray`]) held
344 /// while the primal iterate kept growing. This is a second, independent
345 /// unboundedness path that catches a genuine recession ray in
346 /// `null(A_eq)` over free variables whose `|x|` grows only *linearly*
347 /// (the regularized zero-Hessian step in an equality null space marches
348 /// out at a bounded rate), so it never crosses `diverging_iterates_tol`
349 /// (`1e20`) within `max_iter` and the geometric-growth
350 /// [`Self::divergence_streak`] never accumulates. Reset to zero whenever
351 /// the proof fails or the iterate stops growing.
352 recession_streak: u32,
353 /// Largest `|x|` seen in the current recession-ray run (companion to
354 /// [`Self::recession_streak`]). Zero when no run is active.
355 recession_prev_amax: Number,
356 /// Companion threshold on the dual step — when both primal and dual
357 /// steps are tiny in two consecutive iterations the algorithm
358 /// declares convergence at the best attainable accuracy. Default
359 /// `1e-2` matches upstream.
360 pub tiny_step_y_tol: Number,
361 /// `dual_diverging_streak` (pounce#246) — number of consecutive
362 /// iterations of *growing* dual infeasibility (in the elevated regime,
363 /// `inf_du > `[`DUAL_DIV_COUNT_FLOOR`]) that must accumulate before the
364 /// dual-divergence guard fires. When the streak reaches the limit and
365 /// `inf_du > `[`DUAL_DIV_FIRE_TOL`], the outer routes to restoration.
366 ///
367 /// **`0` (off) is the default**, set from the option of the same name
368 /// (`application.rs`). It defaulted to `15` when introduced; see the option
369 /// help in `upstream_options.rs` for why that changed, and
370 /// [`Self::honour_best_acceptable_after_dual_guard`] for what protects a
371 /// solve when it is enabled. See the guard itself in [`Self::iterate`].
372 pub dual_diverging_streak: usize,
373 dual_inf_prev: Number,
374 dual_growth_streak: usize,
375 /// gh#884 — thresholds for the dual-divergence *retry* signature.
376 /// Distinct from the pounce#246 guard above in both mechanism and
377 /// consequence: that one diverts a running solve to restoration, this
378 /// one only *records* that a cold retry is worth attempting after the
379 /// solve has already given up. Set from options of the same name; see
380 /// [`DUAL_DIV_RETRY_STEP_TOL`] for the measured populations.
381 pub dual_divergence_retry_step_tol: Number,
382 /// Companion floor on the unscaled dual — see
383 /// [`DUAL_DIV_RETRY_DU_FLOOR`].
384 pub dual_divergence_retry_du_floor: Number,
385 /// Scale-relative magnitude of the most recent search direction,
386 /// `max(max_i |δx_i|/(1+|x_i|), max_i |δs_i|/(1+|s_i|))` — the
387 /// `detect_tiny_step` measure, kept as a number rather than a
388 /// predicate. `INFINITY` before the first direction is computed, so
389 /// the signature cannot fire on iteration 0.
390 last_step_rel: Number,
391 /// gh#884 — sticky: set once the four-conjunct signature is seen at a
392 /// single iterate, never cleared. Read by the application layer after
393 /// the solve to decide whether a cold retry is authorized. It never
394 /// changes a verdict by itself.
395 dual_divergence_signature: bool,
396 /// Set true when the previous iterate was tagged tiny; on the
397 /// second consecutive tiny step the loop sets `data.tiny_step_flag`
398 /// so the mu update can attempt to terminate. Mirrors
399 /// `IpBacktrackingLineSearch::tiny_step_last_iteration_`.
400 pub tiny_step_last_iteration: bool,
401 /// Cycle-detection state for [`Self::invoke_restoration`]: the
402 /// outer `(x, s)` snapshot from the previous restoration entry,
403 /// cleared on any iteration that exits via a normal line-search
404 /// accept. When restoration is invoked twice in a row and the
405 /// outer iterate has not moved between entries (relative
406 /// 2-norm < 1e-10 on both `x` and `s`), the inner resto-IPM is
407 /// returning Recovered points indistinguishable from `curr` — a
408 /// cycle. Surfaces as `ErrorInStepComputation`. Mirrors the
409 /// *intent* of upstream `IpBacktrackingLineSearch.cpp:580-600`'s
410 /// almost-feasible-resto guard while staying robust against the
411 /// `inf_pr` micro-drift seen on ACOPR14 (delta ~3e-12 per entry,
412 /// inf_du essentially constant) where a scalar-`inf_pr` heuristic
413 /// fails. Productive single-restoration sequences (BT8, HIMMELBJ,
414 /// LINSPANH, LSNNODOC, ODFITS, OET3) clear the snapshot via
415 /// `Outcome::Accepted` between entries and are unaffected.
416 last_resto_entry_x: Option<Box<dyn Vector>>,
417 last_resto_entry_s: Option<Box<dyn Vector>>,
418 /// Snapshot of the *recovery* iterate from the previous
419 /// restoration. Compared against the next entry's `(x, s)` to
420 /// detect "outer made no progress between consecutive resto
421 /// invocations". When this distance is below threshold for
422 /// several consecutive entries, terminate — catching
423 /// slow-non-convergence cycles (ACOPR14, TRO3X3, ACOPR30) where
424 /// resto's *inner* moves substantively each call but the *outer*
425 /// makes no progress between calls. Cleared on any LS-accepted
426 /// step.
427 last_resto_recovery_x: Option<Box<dyn Vector>>,
428 last_resto_recovery_s: Option<Box<dyn Vector>>,
429 /// Count of consecutive restoration entries on which the outer
430 /// step (recovery → next-entry) was below the iterate-distance
431 /// threshold. Cleared on any LS-accepted step. Limit chosen to
432 /// let MAKELA3, HAIFAM, HALDMADS, ROBOT, TENBARS2 — which need
433 /// 2-3 consecutive resto entries to recover — pass through.
434 resto_no_outer_progress_count: usize,
435 /// `resto_decline_deferrals` (gh #534) — how many times the
436 /// acceptable-point restoration decline in [`Self::invoke_restoration`] may
437 /// be *deferred* on a solve whose NLP error is still contracting. `0`
438 /// restores the pre-#534 behaviour (decline immediately, always).
439 ///
440 /// See [`Self::may_defer_acceptable_decline`] for the progress test and
441 /// [`Self::honour_decline_floor`] for what makes a spent deferral harmless.
442 pub resto_decline_deferrals: usize,
443 /// `resto_decline_progress_ratio` (gh #534) — the contraction each of the
444 /// last [`DECLINE_PROGRESS_SAMPLES`]` - 1` iterations must have achieved for
445 /// the decline to be deferred. Default
446 /// [`DEFAULT_DECLINE_PROGRESS_RATIO`]. A value of `1` admits any
447 /// non-increasing window and a large one drops the progress requirement
448 /// altogether, which is the "patch the guard and see" experiment the issue
449 /// asks for, available without patching.
450 pub resto_decline_progress_ratio: Number,
451 /// The most recent outer-iteration NLP errors, oldest first, with
452 /// [`Self::nlp_err_recent_len`] entries live. Feeds the gh #534 progress
453 /// test and nothing else.
454 nlp_err_recent: [Number; DECLINE_PROGRESS_SAMPLES],
455 nlp_err_recent_len: usize,
456 /// gh #534 — deferrals of the acceptable-point decline spent so far.
457 decline_deferrals_used: usize,
458 /// gh #534 — the iterate the guard would have returned had it not been
459 /// deferred. Captured at the *first* deferral only, because that point is
460 /// precisely the answer the pre-#534 build reports; it is the floor the
461 /// continuation is never allowed to fall below.
462 decline_floor: Option<VetoSnapshot>,
463 /// gh #534 — outer iteration by which the deferred continuation must have
464 /// produced a strict certificate. Past it the continuation is cut and the
465 /// floor is reported, so the bet costs a bounded number of iterations.
466 decline_deadline_iter: Option<Index>,
467 /// `neg_curv_escapes` (gh #797) — how many times a certified stationary
468 /// point whose reduced Hessian is not positive semidefinite may be *left*
469 /// along a direction of negative curvature instead of reported. `0` restores
470 /// the pre-#797 behaviour (report the first-order certificate, whatever its
471 /// curvature).
472 ///
473 /// See [`Self::try_neg_curv_escape`] for the test and the step, and
474 /// [`Self::honour_neg_curv_floor`] for what makes a lost bet harmless.
475 pub neg_curv_escapes: usize,
476 /// `limited_memory_ls_failure_restarts` (gh #818) — how many times a
477 /// line-search failure at an *already feasible* point may re-anchor
478 /// the quasi-Newton model and retry, instead of handing off to a
479 /// restoration phase that has no constraint violation to reduce.
480 /// `0` restores the pre-#818 behaviour (always hand off).
481 ///
482 /// See [`Self::try_reanchor_before_restoration`] for the rung and
483 /// what bounds it.
484 pub lbfgs_ls_failure_restarts: usize,
485 /// gh #818 — re-anchors spent so far this solve.
486 lbfgs_ls_restarts_used: usize,
487 /// gh #797 — escapes spent so far this solve.
488 neg_curv_escapes_used: usize,
489 /// Sink for the last `IterStats` handed to the user's
490 /// `intermediate_callback` (pounce#870). A second-opinion retry that loses
491 /// needs it, so that the trace a consumer accumulated can be made to end
492 /// on the iterate actually reported. Set by `IpoptApplication`; `None`
493 /// everywhere else.
494 pub last_iter_stats_sink: Option<Rc<RefCell<Option<IterStats>>>>,
495 /// gh #797 — the certified stationary point the escape left. It is a strict
496 /// certificate, so it is the floor the continuation must beat *with a
497 /// certificate of its own* to be preferred.
498 ///
499 /// With more than one escape available this holds the **best** of the
500 /// certificates left so far, not the most recent (gh #805). The first entry
501 /// is the answer a `neg_curv_escapes = 0` build returns, and it is only
502 /// ever displaced by a point that outranks it, so the guarantee holds at
503 /// any number of escapes and the floor never moves backwards.
504 neg_curv_floor: Option<VetoSnapshot>,
505 /// gh #797 — outer iteration by which the escape's continuation must have
506 /// produced a certificate. Past it the continuation is cut and the floor
507 /// reported, so the bet costs a bounded number of iterations.
508 neg_curv_deadline_iter: Option<Index>,
509 /// Count of consecutive restoration entries on which the outer
510 /// constraint violation at entry was already below `tol` (the
511 /// outer optimality tolerance). Matches the *intent* of upstream
512 /// `IpBacktrackingLineSearch.cpp:580-600`'s almost-feasible-resto
513 /// guard while using a looser cv threshold (`tol` vs `1e-2·tol`)
514 /// — catches DECONVBNE's resto-thrash where each cycle re-enters
515 /// at cv ≈ 3e-10 < tol with bound multipliers reset to 1, the
516 /// outer's σ-blowup explodes inf_du to 1.9e7, alpha-min triggers
517 /// resto re-entry, and the (inf_pr, inf_du) post-recovery state
518 /// is essentially identical across cycles but `x` drifts enough
519 /// that [`Self::last_resto_recovery_x`]-based detection misses.
520 /// Cumulative (never cleared on LS-accept), since DECONVBNE's
521 /// cycle interleaves R-recoveries with sub-tol accepts that
522 /// accomplish no real outer progress. Fires after 3 near-feasible
523 /// entries — surfaces as `StopAtAcceptablePoint` since the
524 /// recovered point already satisfies constraint feasibility
525 /// within `tol`.
526 resto_near_feasible_count: usize,
527 /// Snapshot of the most recent iterate that the convergence check
528 /// flagged "acceptable" (NLP error ≤ `acceptable_tol`). Mirrors
529 /// upstream `IpBacktrackingLineSearch::acceptable_iterate_`
530 /// (`IpBacktrackingLineSearch.cpp:1286-1310`). Used by
531 /// [`Self::restore_acceptable_point`] to roll back when restoration
532 /// fails — if such an iterate exists, the algorithm exits with
533 /// `SolverReturn::StopAtAcceptablePoint` rather than
534 /// `RestorationFailure`. Cleared/refreshed on every iteration that
535 /// satisfies the acceptable predicate.
536 acceptable_iterate: Option<crate::iterates_vector::IteratesVector>,
537 /// The first iterate whose *strict* certificate the masked-scale veto
538 /// refused (gh #200), kept so the refusal can be undone verbatim if the
539 /// continued run does not do better. Deliberately not the acceptable
540 /// snapshot: that one is overwritten unconditionally and drifts.
541 vetoed: Option<VetoSnapshot>,
542 /// The iterate at which a refused *acceptable-level* termination would have
543 /// fired. Held separately from `vetoed` because it restores under a weaker
544 /// status, and claiming `Success` for it would over-report.
545 vetoed_acceptable: Option<VetoSnapshot>,
546 /// Whether a strict refusal has already been *seen*, independent of whether
547 /// a snapshot was successfully captured for it.
548 ///
549 /// This is the first-only latch, held apart from `vetoed` on purpose.
550 /// Testing `vetoed.is_none()` instead would let a refusal whose capture
551 /// failed be "completed" at a later iterate — the veto flag on the
552 /// convergence check is sticky, so it still reads true next pass, and the
553 /// fallback would then restore a point that never passed the strict test.
554 /// With the latch, a failed capture stays failed and the fallback declines.
555 ///
556 /// Declining is *not* the baseline outcome — the baseline stopped and
557 /// reported a certificate at the uncaptured iterate, and declining fails to
558 /// reproduce it. It is the least-bad handling of an unidentifiable baseline,
559 /// not a faithful one.
560 vetoed_seen: bool,
561 /// Same latch for the acceptable-level refusal.
562 vetoed_acceptable_seen: bool,
563 /// Whether the dual-divergence guard (pounce#246) actually fired this
564 /// solve. Gates the *use* of [`Self::best_acceptable`], so solves the guard
565 /// never touches behave identically — see
566 /// [`Self::honour_best_acceptable_after_dual_guard`].
567 dual_guard_fired: bool,
568 /// Best (lowest scaled objective) acceptable-quality iterate seen anywhere
569 /// in this solve. Recorded unconditionally — including *before* any
570 /// diversion, which is the point: the guard returns to the driver before
571 /// the recording site on the iteration it fires, so gating the recording on
572 /// `dual_guard_fired` would miss everything up to and including the
573 /// diversion. Only read when `dual_guard_fired`.
574 best_acceptable: Option<VetoSnapshot>,
575 /// `kkt_fidelity_tol` (pounce#173), needed here — not just at termination —
576 /// because the fallback's tiebreak has to predict the post-solve status
577 /// gate. See [`Self::honour_refused_certificate`]. Zero (the default)
578 /// disables the gate, and with it every tiebreak effect it has.
579 pub kkt_fidelity_tol: Number,
580 acceptable_iter_number: Index,
581 /// Shared per-solve diagnostics state. `None` unless the CLI
582 /// requested `--dump <cat>:<spec>`. When set, the outer loop
583 /// advances the state's iter counter and the augmented-system
584 /// solver consults it to gate KKT dumps.
585 diagnostics: Option<Rc<DiagnosticsState>>,
586 /// Optional interactive debugger. Shared (`Rc<RefCell<…>>`) so the
587 /// same debugger instance also drives the restoration inner IPM —
588 /// one debugger sees both levels. Fired at every
589 /// [`crate::debug::Checkpoint`]. See `crate::debug`.
590 debug: Option<Rc<RefCell<dyn crate::debug::DebugHook>>>,
591
592 // ---- Restoration-phase audit counters (pounce#12). ----
593 //
594 // Drained into `SolveStatistics` by `IpoptApplication::optimize_constrained`
595 // after the solve completes. Counts are cumulative across the run.
596 /// Number of `invoke_restoration` entries.
597 pub resto_calls: Index,
598 /// Sum of inner-IPM iter counts across every restoration call.
599 pub resto_inner_iters: Index,
600 /// Number of outer iters that ran in restoration mode (R-line
601 /// equivalents in `print_level=5` output).
602 pub resto_outer_iters: Index,
603 /// Cumulative wall-clock seconds spent inside `perform_restoration`.
604 pub resto_wall_secs: Number,
605
606 // ---- Per-iteration history capture (pounce#8, pounce#71). ----
607 //
608 // The per-iteration trajectory is no longer accumulated on the
609 // algorithm: `iterate()` emits a structured `pounce::iteration`
610 // event each step, and `pounce_observability::IterCollectorLayer`
611 // rebuilds the `IterRecord`s into the active `IterCaptureGuard`
612 // that `IpoptApplication` installs around the solve.
613 /// When `false`, the per-iteration table that `iterate()` writes
614 /// straight to stdout is suppressed. Wired from
615 /// `IpoptApplication`'s `print_level` option: level 0 turns this
616 /// off (matches upstream's "no console output" contract). Default
617 /// `true` so CLI / direct-driver users keep the familiar trace.
618 pub print_iter_output: bool,
619}
620
621impl IpoptAlgorithm {
622 /// Diagnostics from the safeguarded `least_square_init_primal`
623 /// initializer step (gh#605). `None` when the step was not run.
624 pub fn least_square_init_report(&self) -> Option<crate::init::default::LeastSquareInitReport> {
625 self.bundle.init.least_square_report()
626 }
627
628 pub fn new(data: IpoptDataHandle, cq: IpoptCqHandle, mut bundle: AlgorithmBundle) -> Self {
629 // The builder may pre-populate `bundle.search_dir` when given a
630 // `LinearBackendFactory`; lift it onto the algorithm so the
631 // iterate body can call into it directly.
632 let search_dir = bundle.search_dir.take();
633 Self {
634 data,
635 cq,
636 bundle,
637 nlp: None,
638 tnlp: None,
639 fires_as_restoration: false,
640 search_dir,
641 restoration: None,
642 kappa_sigma: 1e10,
643 slack_based_scaling: false,
644 recalc_y: false,
645 recalc_y_feas_tol: 1e-6,
646 max_iter: 3000,
647 start_with_resto: false,
648 alpha_init: 1.0,
649 tiny_step_tol: 10.0 * Number::EPSILON,
650 diverging_iterates_tol: 1e20,
651 divergence_streak: 0,
652 watchdog_defer_streak: 0,
653 divergence_prev_amax: 0.0,
654 divergence_prev_f: Number::INFINITY,
655 divergence_prev_decrease: Number::NAN,
656 recession_streak: 0,
657 recession_prev_amax: 0.0,
658 tiny_step_y_tol: 1e-2,
659 dual_diverging_streak: 15,
660 dual_inf_prev: 0.0,
661 dual_growth_streak: 0,
662 dual_divergence_retry_step_tol: DUAL_DIV_RETRY_STEP_TOL,
663 dual_divergence_retry_du_floor: DUAL_DIV_RETRY_DU_FLOOR,
664 last_step_rel: Number::INFINITY,
665 dual_divergence_signature: false,
666 tiny_step_last_iteration: false,
667 last_resto_entry_x: None,
668 last_resto_entry_s: None,
669 last_resto_recovery_x: None,
670 last_resto_recovery_s: None,
671 resto_no_outer_progress_count: 0,
672 resto_decline_deferrals: DEFAULT_RESTO_DECLINE_DEFERRALS,
673 neg_curv_escapes: DEFAULT_NEG_CURV_ESCAPES,
674 neg_curv_escapes_used: 0,
675 last_iter_stats_sink: None,
676 lbfgs_ls_failure_restarts: DEFAULT_LBFGS_LS_FAILURE_RESTARTS,
677 lbfgs_ls_restarts_used: 0,
678 neg_curv_floor: None,
679 neg_curv_deadline_iter: None,
680 resto_decline_progress_ratio: DEFAULT_DECLINE_PROGRESS_RATIO,
681 nlp_err_recent: [Number::NAN; DECLINE_PROGRESS_SAMPLES],
682 nlp_err_recent_len: 0,
683 decline_deferrals_used: 0,
684 decline_floor: None,
685 decline_deadline_iter: None,
686 resto_near_feasible_count: 0,
687 acceptable_iterate: None,
688 vetoed: None,
689 vetoed_acceptable: None,
690 dual_guard_fired: false,
691 best_acceptable: None,
692 vetoed_seen: false,
693 vetoed_acceptable_seen: false,
694 kkt_fidelity_tol: 0.0,
695 acceptable_iter_number: 0,
696 diagnostics: None,
697 debug: None,
698 resto_calls: 0,
699 resto_inner_iters: 0,
700 resto_outer_iters: 0,
701 resto_wall_secs: 0.0,
702 print_iter_output: true,
703 }
704 }
705
706 /// Stash the current iterate as the "last acceptable" backup —
707 /// port of `IpBacktrackingLineSearch::StoreAcceptablePoint`
708 /// (`IpBacktrackingLineSearch.cpp:1286-1293`).
709 fn store_acceptable_point(&mut self) {
710 let d = self.data.borrow();
711 if let Some(curr) = d.curr.as_ref() {
712 self.acceptable_iterate = Some(curr.clone());
713 self.acceptable_iter_number = d.iter_count;
714 }
715 }
716
717 /// Record this outer iteration's NLP error for the gh #534 progress test.
718 ///
719 /// One push per `iterate()` call, so the samples are consecutive outer
720 /// iterations by construction. Deliberately *not* cleared when restoration
721 /// recovers: a recovery that helped shows up as continued contraction and a
722 /// recovery that hurt shows up as a jump, and the ratio test reads both
723 /// correctly without needing to know which happened.
724 fn note_nlp_err(&mut self, nlp_err: Number) {
725 push_sample(
726 &mut self.nlp_err_recent,
727 &mut self.nlp_err_recent_len,
728 nlp_err,
729 );
730 }
731
732 /// Whether the last [`DECLINE_PROGRESS_SAMPLES`] outer iterations each cut
733 /// the NLP error by at least `resto_decline_progress_ratio` (gh #534).
734 ///
735 /// The question the restoration-decline guard never asked: *is this solve
736 /// still converging?* A full window is required, so the test cannot pass on
737 /// a short history — the early iterations of every solve included.
738 ///
739 /// The test itself lives in the pure [`window_is_contracting`], for the
740 /// reason [`ranks_better_within_band`] does: what it must and must not fire
741 /// on is stated in the issue as two recorded traces, and those are provable
742 /// by deterministic unit test rather than inferable from a solve.
743 fn nlp_err_contracting(&self) -> bool {
744 if self.nlp_err_recent_len < DECLINE_PROGRESS_SAMPLES {
745 return false;
746 }
747 window_is_contracting(&self.nlp_err_recent, self.resto_decline_progress_ratio)
748 }
749
750 /// The live progress window, oldest first, for the gh #534 trace lines.
751 fn nlp_err_window_str(&self) -> String {
752 let live = &self.nlp_err_recent[..self.nlp_err_recent_len];
753 let parts: Vec<String> = live.iter().map(|e| format!("{e:.3e}")).collect();
754 format!("[{}]", parts.join(" -> "))
755 }
756
757 /// Roll the iterate back to the last acceptable snapshot — port of
758 /// `IpBacktrackingLineSearch::RestoreAcceptablePoint`
759 /// (`IpBacktrackingLineSearch.cpp:1295-1310`). Returns `true` if a
760 /// snapshot was available and applied; `false` otherwise (caller
761 /// then surfaces the original failure status).
762 fn restore_acceptable_point(&mut self) -> bool {
763 let Some(prev) = self.acceptable_iterate.clone() else {
764 return false;
765 };
766 let mut d = self.data.borrow_mut();
767 d.set_trial(prev);
768 // `accept_trial_point` promotes `trial → curr`, mirroring the
769 // upstream sequence `set_trial(...); AcceptTrialPoint();`.
770 d.accept_trial_point();
771 true
772 }
773
774 /// Whether a diverging primal iterate is consistent with the feasible
775 /// region actually being *unbounded* (issue #248).
776 ///
777 /// `DivergingIterates` is Ipopt's unboundedness verdict, but a large
778 /// `|x_i|` only proves unboundedness if variable `i` is free to escape
779 /// to infinity in the direction it is heading — i.e. it has no finite
780 /// bound on that side. This lifts a vector of ones from the compressed
781 /// lower/upper bound spaces through the `Px_L` / `Px_U` expansion
782 /// matrices to obtain full-length indicators of which variables carry a
783 /// finite bound, then returns `true` only when some component whose
784 /// magnitude exceeds `diverging_iterates_tol` is heading toward a side
785 /// with no finite bound.
786 ///
787 /// When every large component is pinned by a finite bound — in
788 /// particular when all variables are boxed, so the feasible region is a
789 /// bounded box and unboundedness is structurally impossible — this
790 /// returns `false`, and the caller reports the best iterate via the
791 /// normal convergence / restoration path instead of a spurious
792 /// `Unbounded`.
793 /// #248: consecutive growing, over-threshold iterations required before
794 /// a structurally-free divergence is reported as `DivergingIterates`.
795 /// `jit1`'s transient excursion lasts ~2 growing steps and then
796 /// recedes, so a small persistence requirement clears it without
797 /// materially delaying a genuine ray.
798 const DIVERGENCE_PERSIST_ITERS: u32 = 4;
799 /// #248: an iterate counts as "still growing" toward divergence when it
800 /// grows at least this factor over the previous over-threshold iterate.
801 /// A recession ray in an interior-point method grows geometrically; an
802 /// iterate settling onto a finite optimum above the threshold does not.
803 const DIVERGENCE_GROWTH_FACTOR: Number = 2.0;
804 /// #252: the objective descent must *keep up* — each step's drop must be
805 /// at least this fraction of the previous step's drop for the iterate to
806 /// count toward the divergence streak. A recession ray descends `f` to
807 /// `−∞` with per-step drops that grow (ratio ≥ 1) as `|x|` grows
808 /// geometrically; an excursion converging to a finite optimum decelerates
809 /// (ratio → 0). The slack below 1 tolerates ordinary interior-point noise
810 /// on a genuine ray without admitting a decelerating excursion — jit1's
811 /// node subproblems shrink the drop by 3–15× per step, far past this bar.
812 const DIVERGENCE_DESCENT_KEEPUP: Number = 0.9;
813 /// #248: absolute runaway backstop. An iterate this large is reported
814 /// unbounded regardless of persistence. It sits at or below the default
815 /// `diverging_iterates_tol = 1e20`, so the default behaviour (fire the
816 /// instant `|x|` crosses the threshold) is preserved, while a low
817 /// user threshold no longer fires on the way to a finite optimum.
818 const DIVERGENCE_ABS_RUNAWAY: Number = 1e18;
819 /// Most consecutive iterations the primal divergence guard will defer
820 /// to a watchdog sequence before checking the iterate anyway. Upstream's
821 /// `watchdog_trial_iter_max` default is 3; one spare covers the
822 /// iteration on which the watchdog is armed. See
823 /// [`Self::watchdog_defer_streak`] for why the bound is not simply
824 /// "until `in_watchdog` clears".
825 const WATCHDOG_DEFER_MAX: u32 = 4;
826
827 /// #285: magnitude floor for the checked recession-ray unboundedness path.
828 /// Below this the (slightly more expensive) recession proof is not even
829 /// attempted, so it is inert on every normal, well-scaled solve. Above it,
830 /// unboundedness is only ever concluded through the full checked proof in
831 /// [`Self::curr_is_recession_ray`] — a genuinely *feasible* iterate of this
832 /// magnitude already witnesses an unbounded feasible region, and the proof
833 /// additionally certifies the escape direction. Sits far below the
834 /// `diverging_iterates_tol` (`1e20`) magnitude guard so a linearly-growing
835 /// ray (which never reaches `1e20` within `max_iter`) is still caught.
836 const RECESSION_MIN_NORM: Number = 1e10;
837 /// #285: consecutive growing, proof-passing iterations required before the
838 /// recession-ray path reports `DivergingIterates`. A bounded feasible
839 /// region cannot supply a *growing* sequence of feasible over-floor
840 /// iterates, so persistence is defense-in-depth against a lone numerical
841 /// fluke rather than a soundness requirement.
842 const RECESSION_PERSIST_ITERS: u32 = 4;
843 /// #285: relative feasibility bar for the recession proof. The current
844 /// iterate counts as feasible (hence a witness that the feasible region
845 /// reaches its magnitude) when its unscaled max-norm primal infeasibility
846 /// is at most this fraction of `|x|_∞`. The check is *relative* on purpose:
847 /// evaluating `A_eq x − b` at `|x| ~ 1e17` carries floating-point roundoff
848 /// that scales with `|x|`, while a genuinely infeasible excursion (e.g.
849 /// mid-restoration) has a residual comparable to `|x|` itself.
850 const RECESSION_FEAS_REL: Number = 1e-6;
851 /// #285: relative bar for "the escape direction lies in `null(A_eq)`".
852 /// `‖J_c x‖_∞ ≤ this · |x|_∞` certifies that moving along `d ≈ x` preserves
853 /// the (linearized) equality constraints — `A_eq d ≈ 0`.
854 const RECESSION_DIR_TOL: Number = 1e-6;
855 /// #285: relative descent bar. The objective must strictly decrease along
856 /// the escape direction with a real margin — `∇f·x ≤ −this · ‖∇f‖ ‖x‖` —
857 /// so a variable drifting orthogonally to the objective (`∇f·x ≈ 0`) can
858 /// never be mistaken for a recession ray driving `f → −∞`.
859 const RECESSION_DESC_REL: Number = 1e-6;
860
861 /// Update the divergence-persistence state for the current iterate and
862 /// return whether `DivergingIterates` should be reported now (issues
863 /// #248 / #252). `amax` is `max_i |x_i|`; `structural_free` is the result
864 /// of [`Self::divergence_is_true_unboundedness`] (already gated on
865 /// `amax > diverging_iterates_tol`); `f` is the (minimized, internally
866 /// scaled) objective at the current iterate, supplied only while
867 /// `structural_free` holds.
868 ///
869 /// A large `|x|` is reported as unbounded only when it is heading to an
870 /// unbounded side (`structural_free`) *and* the divergence looks like a
871 /// genuine recession ray: the iterate keeps *growing* while the objective
872 /// keeps *descending toward `−∞` without decelerating* — the per-step drop
873 /// holds up as `|x|` grows geometrically — for
874 /// [`Self::DIVERGENCE_PERSIST_ITERS`] consecutive iterations (or it has
875 /// blown past the absolute runaway backstop). Two failure modes are thereby
876 /// left to the normal convergence machinery instead of being mislabelled
877 /// `UNBOUNDED`:
878 ///
879 /// * #248 — a transient ill-scaling excursion that peaks in `|x|` and
880 /// recedes never sustains the growth streak.
881 /// * #252 — an excursion that *keeps* growing in `|x|` toward an unbounded
882 /// box side (a jit1 B&B node subproblem with `ub = +∞`), lowering `f` as
883 /// it goes, but with a per-step objective drop that *decelerates* toward
884 /// zero: it is settling onto a finite optimum, not riding a recession
885 /// ray. The descent must keep up (not merely exist), so this no longer
886 /// accumulates the streak.
887 fn update_divergence_verdict(
888 &mut self,
889 amax: Option<Number>,
890 structural_free: bool,
891 f: Option<Number>,
892 ) -> bool {
893 let over = matches!(amax, Some(a) if a > self.diverging_iterates_tol) && structural_free;
894 if !over {
895 self.divergence_streak = 0;
896 self.divergence_prev_amax = 0.0;
897 self.divergence_prev_f = Number::INFINITY;
898 self.divergence_prev_decrease = Number::NAN;
899 return false;
900 }
901 let a = amax.expect("over implies amax is Some");
902 // A recession ray in an interior-point method grows the iterate
903 // geometrically *and* drives the objective down without bound, with a
904 // per-step drop that keeps up as `|x|` grows. A finite-optimum
905 // excursion may grow `|x|` and even lower `f` for a few steps, but its
906 // per-step objective drop decelerates toward zero as it settles onto
907 // the finite floor. Require all three — growth, descent, and
908 // non-decelerating descent — before a step counts toward the streak.
909 let growing = a >= self.divergence_prev_amax * Self::DIVERGENCE_GROWTH_FACTOR;
910 // `f` is `None` only when `structural_free` is false, already handled
911 // by the `!over` branch; treat a missing value as non-descending so a
912 // run can never accumulate without objective evidence.
913 let fv = f.unwrap_or(Number::INFINITY);
914 let decrease = self.divergence_prev_f - fv;
915 let descending = decrease > 0.0;
916 // Non-decelerating: the drop must be at least a fixed fraction of the
917 // previous step's drop. Bootstrapped `true` until a first finite
918 // decrease has been recorded (`divergence_prev_decrease` non-finite),
919 // so the run's opening steps are admitted on growth + descent alone.
920 let keeping_up = !self.divergence_prev_decrease.is_finite()
921 || decrease >= self.divergence_prev_decrease * Self::DIVERGENCE_DESCENT_KEEPUP;
922 if growing && descending && keeping_up {
923 self.divergence_streak += 1;
924 } else {
925 // Over the threshold on an unbounded side, but the divergence is
926 // not sustaining a recession ray's growth-and-accelerating-descent
927 // profile — the hallmark of a scaling excursion toward a finite
928 // optimum. Drop the streak; a genuine ray re-accumulates it on its
929 // next qualifying step (or trips the absolute runaway backstop).
930 self.divergence_streak = 0;
931 }
932 self.divergence_prev_amax = a;
933 self.divergence_prev_f = fv;
934 // Record the baseline for the next step's keep-up comparison only from
935 // a finite, real decrease; skip the `+∞` opening step and reset the
936 // baseline whenever the objective stops descending.
937 self.divergence_prev_decrease = if decrease.is_finite() && descending {
938 decrease
939 } else {
940 Number::NAN
941 };
942 a >= Self::DIVERGENCE_ABS_RUNAWAY
943 || self.divergence_streak >= Self::DIVERGENCE_PERSIST_ITERS
944 }
945
946 fn divergence_is_true_unboundedness(&self, x: &dyn Vector) -> bool {
947 self.free_to_escape_over(x, self.diverging_iterates_tol)
948 }
949
950 /// Shared core of the free-variable structural check: returns `true` when
951 /// some component of `x` with magnitude exceeding `thresh` is heading
952 /// toward a side (positive → upper, negative → lower) that carries *no*
953 /// finite bound, so it is free to escape to infinity. Parameterized on the
954 /// magnitude threshold so both the `diverging_iterates_tol` (`1e20`)
955 /// divergence guard and the lower `RECESSION_MIN_NORM` recession-ray path
956 /// (#285) share one implementation.
957 fn free_to_escape_over(&self, x: &dyn Vector, thresh: Number) -> bool {
958 use pounce_linalg::DenseVector;
959
960 let cq = self.cq.borrow();
961 let nlp = cq.nlp().borrow();
962
963 // Full-length 0/1 indicators of finite lower / upper bounds,
964 // built by scattering ones through the bound expansion matrices.
965 let mut ones_l = nlp.x_l().make_new();
966 ones_l.set(1.0);
967 let mut has_lb = x.make_new();
968 nlp.px_l().mult_vector(1.0, &*ones_l, 0.0, &mut *has_lb);
969
970 let mut ones_u = nlp.x_u().make_new();
971 ones_u.set(1.0);
972 let mut has_ub = x.make_new();
973 nlp.px_u().mult_vector(1.0, &*ones_u, 0.0, &mut *has_ub);
974
975 let downcast = |v: &dyn Vector| -> Option<Vec<Number>> {
976 v.as_any()
977 .downcast_ref::<DenseVector>()
978 .map(|d| d.expanded_values())
979 };
980
981 // POUNCE is dense-only; if a backing is unexpectedly non-dense we
982 // cannot prove the divergence is spurious, so fall back to the
983 // original (magnitude-only) verdict to avoid changing behaviour.
984 let (Some(xv), Some(lb), Some(ub)) = (downcast(x), downcast(&*has_lb), downcast(&*has_ub))
985 else {
986 return true;
987 };
988
989 for i in 0..xv.len() {
990 if xv[i].abs() > thresh {
991 let free_to_diverge = if xv[i] > 0.0 {
992 ub[i] == 0.0
993 } else {
994 lb[i] == 0.0
995 };
996 if free_to_diverge {
997 return true;
998 }
999 }
1000 }
1001 false
1002 }
1003
1004 /// #285: checked recession-ray unboundedness proof at the current iterate.
1005 ///
1006 /// Returns `true` only when the current iterate `x` (with `|x|_∞ = amax`,
1007 /// already known `> RECESSION_MIN_NORM` by the caller) *proves* the
1008 /// problem is unbounded below via a genuine recession ray — the same
1009 /// standard the LP/symmetric path holds itself to, not a magnitude
1010 /// heuristic. All of the following must hold:
1011 ///
1012 /// 1. **Feasible witness.** The iterate's unscaled primal infeasibility is
1013 /// at most `RECESSION_FEAS_REL · amax`. A genuinely feasible iterate of
1014 /// norm `≥ 1e10` witnesses that the feasible region reaches that far —
1015 /// a *bounded* region cannot contain it. (Relative bar: the residual of
1016 /// `A_eq x − b` carries roundoff that scales with `|x|`.)
1017 /// 2. **Free to escape.** Some over-floor component heads toward a side
1018 /// with no finite variable bound ([`Self::free_to_escape_over`] at
1019 /// `RECESSION_MIN_NORM`).
1020 /// 3. **Direction in `null(A_eq)`.** `‖J_c x‖_∞ ≤ RECESSION_DIR_TOL · amax`
1021 /// — moving along `d ≈ x` preserves the equality constraints.
1022 /// 4. **Inequalities not blocking.** No finitely-bounded inequality row is
1023 /// driven toward its bound along `d ≈ x`
1024 /// ([`Self::recession_blocked_by_inequality`]).
1025 /// 5. **Objective descending.** `∇f·x ≤ −RECESSION_DESC_REL · ‖∇f‖ ‖x‖` —
1026 /// the objective strictly decreases along the escape direction with a
1027 /// real (non-orthogonal) margin, so `f → −∞` along the ray.
1028 ///
1029 /// On a *bounded* problem at least one of (1)/(2)/(3)/(4)/(5) fails, so
1030 /// this can never manufacture a spurious `DivergingIterates`.
1031 fn curr_is_recession_ray(&self, x: &dyn Vector, amax: Number) -> bool {
1032 // (1) Feasible witness (relative bar).
1033 let primal_inf = self.cq.borrow().curr_unscaled_primal_infeasibility_max();
1034 if !(primal_inf.is_finite() && primal_inf <= Self::RECESSION_FEAS_REL * amax) {
1035 return false;
1036 }
1037 // (2) Some over-floor component free to escape to infinity.
1038 if !self.free_to_escape_over(x, Self::RECESSION_MIN_NORM) {
1039 return false;
1040 }
1041 // (3) Escape direction lies in the equality null space. A non-finite
1042 // (NaN) residual is treated as failing, so the direction is only
1043 // accepted on a genuinely small, finite `‖J_c x‖∞`.
1044 let jc_x_amax = self.cq.borrow().curr_jac_c_times_vec(x).amax();
1045 if !jc_x_amax.is_finite() || jc_x_amax > Self::RECESSION_DIR_TOL * amax {
1046 return false;
1047 }
1048 // (4) No finitely-bounded inequality blocks the direction.
1049 if self.recession_blocked_by_inequality(x, amax) {
1050 return false;
1051 }
1052 // (5) Objective strictly descending along the escape direction.
1053 let (dot, gnorm, xnorm) = {
1054 let cq = self.cq.borrow();
1055 let g = cq.curr_grad_f();
1056 (g.dot(x), g.nrm2(), x.nrm2())
1057 };
1058 if !(dot < 0.0 && dot <= -Self::RECESSION_DESC_REL * gnorm * xnorm) {
1059 return false;
1060 }
1061 true
1062 }
1063
1064 /// #285: does any *finitely-bounded* inequality constraint block motion
1065 /// along the escape direction `d ≈ x`? For each inequality row the
1066 /// constraint value `d(x)` changes at rate `(J_d x)_j` per unit of the
1067 /// direction; if that row has a finite upper bound and the rate is
1068 /// positive (or a finite lower bound and the rate is negative) beyond a
1069 /// relative tolerance, moving out along `d` would eventually violate it,
1070 /// so it is not a feasible recession direction. Bounds are detected via
1071 /// the `Pd_L / Pd_U` expansion matrices exactly as the variable-bound
1072 /// check uses `Px_L / Px_U`.
1073 fn recession_blocked_by_inequality(&self, x: &dyn Vector, amax: Number) -> bool {
1074 use pounce_linalg::DenseVector;
1075
1076 let cq = self.cq.borrow();
1077 // Rate of change of each inequality value along d ≈ x (length m_ineq).
1078 // Compute first so the internal `nlp.borrow_mut()` is released before
1079 // the immutable borrow below.
1080 let jd_x = cq.curr_jac_d_times_vec(x);
1081 let (has_dlb, has_dub) = {
1082 let nlp = cq.nlp().borrow();
1083 let mut ones_dl = nlp.d_l().make_new();
1084 ones_dl.set(1.0);
1085 let mut has_dlb = jd_x.make_new();
1086 nlp.pd_l().mult_vector(1.0, &*ones_dl, 0.0, &mut *has_dlb);
1087
1088 let mut ones_du = nlp.d_u().make_new();
1089 ones_du.set(1.0);
1090 let mut has_dub = jd_x.make_new();
1091 nlp.pd_u().mult_vector(1.0, &*ones_du, 0.0, &mut *has_dub);
1092 // Order matters: bind `(has_dlb, has_dub)` in that exact order so
1093 // the finite-lower / finite-upper indicators are not transposed.
1094 // #314: this pair was returned swapped, inverting the bound
1095 // semantics below — a ray *increasing* a lower-bounded row (moving
1096 // deeper into the feasible set, slack growing) was wrongly treated
1097 // as blocked, so a genuine inequality-slack recession ray was never
1098 // proven unbounded.
1099 (has_dlb, has_dub)
1100 };
1101
1102 let downcast = |v: &dyn Vector| -> Option<Vec<Number>> {
1103 v.as_any()
1104 .downcast_ref::<DenseVector>()
1105 .map(|d| d.expanded_values())
1106 };
1107 // Dense-only fallback: if we cannot inspect the rows, conservatively
1108 // treat the direction as blocked (no spurious unbounded verdict).
1109 let (Some(jd), Some(dlb), Some(dub)) =
1110 (downcast(&*jd_x), downcast(&*has_dlb), downcast(&*has_dub))
1111 else {
1112 return true;
1113 };
1114 let tol = Self::RECESSION_DIR_TOL * amax;
1115 for j in 0..jd.len() {
1116 // Increasing a row that has a finite upper bound, or decreasing a
1117 // row that has a finite lower bound, would leave the feasible set.
1118 if (jd[j] > tol && dub[j] != 0.0) || (jd[j] < -tol && dlb[j] != 0.0) {
1119 return true;
1120 }
1121 }
1122 false
1123 }
1124
1125 /// #285: update the recession-ray persistence state and return whether
1126 /// `DivergingIterates` should be reported now. `amax` is `|x|_∞`;
1127 /// `is_ray` is the result of [`Self::curr_is_recession_ray`]. The verdict
1128 /// fires once the checked proof has held for
1129 /// [`Self::RECESSION_PERSIST_ITERS`] consecutive *growing* iterations — a
1130 /// bounded region cannot supply a growing sequence of feasible over-floor
1131 /// iterates, so this is impossible to satisfy on a bounded problem.
1132 fn update_recession_verdict(&mut self, amax: Number, is_ray: bool) -> bool {
1133 if !is_ray {
1134 self.recession_streak = 0;
1135 self.recession_prev_amax = 0.0;
1136 return false;
1137 }
1138 if amax > self.recession_prev_amax {
1139 self.recession_streak += 1;
1140 } else {
1141 // Proof holds but the iterate is not growing (a stalled or rejected
1142 // step). Restart the run at the current witness rather than firing
1143 // on a plateau; a genuine ray resumes growing next step.
1144 self.recession_streak = 1;
1145 }
1146 self.recession_prev_amax = amax;
1147 self.recession_streak >= Self::RECESSION_PERSIST_ITERS
1148 }
1149
1150 /// Honour a certificate the masked-scale veto refused, when the run that
1151 /// was allowed to continue did not end in one of its own (gh #200).
1152 ///
1153 /// The veto's bargain is "never worse off": it refuses a point that had
1154 /// *already passed the strict test*, betting that continuing reaches a
1155 /// better one. This is the losing side of that bet — so hand back exactly
1156 /// what would have been returned without the veto, point and status both.
1157 ///
1158 /// Two details make that guarantee real rather than approximate:
1159 ///
1160 /// - It runs on **every** non-success exit, applied once where the driver
1161 /// loop's result is finalized. Wiring individual termination sites was
1162 /// tried and is not safe: there are sixteen, and the ones easiest to
1163 /// overlook are the ones most likely to fire here — the veto's extra
1164 /// iterations are exactly what pushes a run past `max_cpu_time`.
1165 /// - It restores the **refused iterate itself** (`vetoed`), not the last
1166 /// acceptable snapshot. `store_acceptable_point` overwrites
1167 /// unconditionally, so after the veto the stored point drifts to whatever
1168 /// the continued run last touched — which may be worse than the point
1169 /// that was refused.
1170 ///
1171 /// "Better" is **status-dominant lexicographic**: the reported status first,
1172 /// and the objective only to break a tie *within equal status*. Both halves
1173 /// matter and the order between them is not cosmetic — see the `Success`
1174 /// branch, where reading it as a plain objective comparison costs a status.
1175 fn honour_refused_certificate(&mut self, result: SolverReturn) -> SolverReturn {
1176 if matches!(result, SolverReturn::Success) {
1177 // The continued run produced a certificate of its own — but not
1178 // necessarily a better *outcome*.
1179 //
1180 // This is what makes "never worse" hold even when the bet loses in a
1181 // way that still converges: on a non-convex problem the extra travel
1182 // can reach a different, worse stationary point, and the budget cap
1183 // (`VETO_MAX_EXTRA_ITERS`) can also hand back a late-but-converged
1184 // one. Neither may silently replace a better answer the solver
1185 // already had in hand.
1186 //
1187 // The comparison is NOT objective-only. That was the original bug
1188 // here: both points passed `passes_component_tols`, which looked
1189 // like a licence to treat them as equally valid certificates and
1190 // just take the lower objective. They are not equally valid when
1191 // `kkt_fidelity_tol` is set — `apply_kkt_fidelity_gate` re-grades a
1192 // `Success` on the unscaled KKT error afterwards, on a strictly
1193 // finer criterion than the convergence test. Taking a 3-ulp
1194 // objective win at a point whose unscaled error is 5x worse traded
1195 // `Solve_Succeeded` for `Solved_To_Acceptable_Level`: a status
1196 // regression against baseline, which is the strongest form of the
1197 // guarantee breaking. So rank by the status each point will actually
1198 // be *reported* under, and only then by objective.
1199 let Some((refused, refused_status)) = self.baseline_outcome() else {
1200 return result;
1201 };
1202 self.assert_comparable_scale(&refused);
1203 let (curr_f, curr_kkt) = self.curr_obj_and_unscaled_kkt();
1204 // Rank each candidate by the status it will actually be *reported*
1205 // under, which for a `Success` means after the fidelity gate has had
1206 // its say.
1207 let continued_success = self.survives_fidelity_gate(curr_kkt);
1208 let refused_success = matches!(refused_status, SolverReturn::Success)
1209 && self.survives_fidelity_gate(refused.unscaled_kkt);
1210 let keep_refused = match (continued_success, refused_success) {
1211 // Equal reported status: the objective breaks the tie, which is
1212 // legitimate because both points are feasible to tolerance.
1213 //
1214 // Negated `<=`, not `>`: they differ at NaN, and the difference
1215 // matters. A `Converged` exit at an iterate whose objective is
1216 // NaN but whose residuals are finite and tiny is reachable (the
1217 // convergence test never inspects `f`), and `NaN > x` is false,
1218 // which would keep the NaN point over a finite refused one.
1219 // Phrased as a negated `<=`, an incomparable objective fails to
1220 // justify keeping the continued point and the refused one wins.
1221 (true, true) | (false, false) => !(curr_f <= refused.obj),
1222 // The refused point keeps a status the continued one loses.
1223 (false, true) => true,
1224 (true, false) => false,
1225 };
1226 if !keep_refused {
1227 return result;
1228 }
1229 self.restore_snapshot(&refused);
1230 // The restored point's own status, which is what the baseline
1231 // reported for it. For a strict refusal that is `Success` even when
1232 // it fails the fidelity gate — the gate re-grades the restored point
1233 // downstream, exactly as it would have re-graded the baseline's. For
1234 // an acceptable-level refusal it is `StopAtAcceptablePoint`, since
1235 // claiming `Success` for a point that only ever qualified at the
1236 // acceptable level would over-report.
1237 return refused_status;
1238 }
1239 // The continued run did not certify — but its final point can still be a
1240 // *better* would-be certificate than the one the baseline stopped at, and
1241 // restoring the chronologically-first refusal unconditionally throws it
1242 // away (gh #327). The masking veto keeps refusing at the true optimum too
1243 // (its unscaled error stays above `acceptable_tol` under an extreme
1244 // objective scale), so a run that actually reaches the optimum never gets
1245 // to certify there and instead exits non-`Success` — typically on a tiny
1246 // step once it settles. Rolling straight back to the first refusal then
1247 // hands back the point the baseline stopped at, which can be far worse:
1248 // on `min 1/x` over `[1e-12, 10]` the solve reaches x≈10 (f≈0.1) but was
1249 // rolled back to the first refusal at x≈2.84 (f≈0.35) and reported
1250 // success there.
1251 //
1252 // The extra candidate is admitted *narrowly*, and the gate is
1253 // load-bearing: the continued point may displace the refused snapshot
1254 // only if it itself passes the strict per-component tolerances — i.e. it
1255 // is a would-be strict certificate the veto refused solely because of
1256 // masking. That is precisely what tells the settled true optimum apart
1257 // from a merely lower objective reached on an unbounded ray (e.g.
1258 // `A(x−a)⁴ − K·√(1+y²)`, unbounded below in y): the diverging iterate
1259 // never passes the strict test, so it can never win here, and those runs
1260 // stay bit-for-bit as before. When the gate does open, keep whichever
1261 // point ranks better under the same feasibility-aware key the dual-guard
1262 // fallback uses, and report the baseline's restored status either way —
1263 // never worse than baseline on status, never worse (often better) on the
1264 // point.
1265 let Some((refused, restored_status)) = self.baseline_outcome() else {
1266 return result;
1267 };
1268 self.assert_comparable_scale(&refused);
1269 let curr_nlp_err = self.cq.borrow().curr_nlp_error();
1270 let curr_passes_strict =
1271 self.bundle
1272 .conv_check
1273 .current_passes_strict(curr_nlp_err, &self.data, &self.cq);
1274 let curr_f = self.cq.borrow().curr_f();
1275 let curr_viol = self.cq.borrow().curr_unscaled_primal_infeasibility_max();
1276 // The second admissible candidate: a continued run that ends *at the
1277 // acceptable level itself* (gh #533). The `curr_passes_strict` gate
1278 // exists to tell a settled optimum from a diverging ray, and on this
1279 // exit the exit itself already answers that — `StopAtAcceptablePoint` is
1280 // only reachable at a point that passed the acceptable per-component
1281 // tolerances, either by qualifying here or by being the stashed
1282 // acceptable iterate a rollback restored. A diverging iterate cannot
1283 // produce it.
1284 //
1285 // This matters because the gh #533 progress refusal is frequently paid
1286 // off by a *better acceptable point* rather than by a strict
1287 // certificate: the streak refuses while the solve is still descending,
1288 // the solve descends, and then settles somewhere better but still short
1289 // of `tol`. Without this the refused point is restored and the entire
1290 // continuation is discarded — never worse than baseline, but never
1291 // better either, which for that whole class is pure cost.
1292 //
1293 // Gated on the *restored* status also being `StopAtAcceptablePoint`, so
1294 // a strict refusal's `Success` is never reported at a point that only
1295 // ever qualified at the acceptable level.
1296 let continued_is_acceptable_exit = matches!(result, SolverReturn::StopAtAcceptablePoint)
1297 && matches!(restored_status, SolverReturn::StopAtAcceptablePoint);
1298 // Keep the continued point in place only when it is an admissible
1299 // candidate that also ranks strictly better; otherwise restore the
1300 // refused snapshot exactly as before. `ranks_better` treats a non-finite
1301 // continued objective as worst, so a NaN-objective continuation never
1302 // displaces a finite refused point (the NaN-loses convention the
1303 // `Success` branch relies on).
1304 let keep_continued = (curr_passes_strict || continued_is_acceptable_exit)
1305 && self.ranks_better(curr_f, curr_viol, refused.obj, refused.constr_viol);
1306 if !keep_continued {
1307 self.restore_snapshot(&refused);
1308 }
1309 if self.cq.borrow().curr_f().is_finite() {
1310 restored_status
1311 } else {
1312 result
1313 }
1314 }
1315
1316 /// What the baseline — the same solve with the veto disabled — would have
1317 /// returned, as (point, status), or `None` if nothing was ever refused.
1318 ///
1319 /// The **chronologically first** refusal, not the strictest one. Both arms
1320 /// follow the same trajectory until the first refusal, so that iterate is
1321 /// where the baseline stopped and what it reported. A refusal recorded later
1322 /// sits on the continued trajectory, which the baseline never walked — its
1323 /// point was never on offer, and restoring it would neither reproduce the
1324 /// baseline nor be comparable to it.
1325 ///
1326 /// Both kinds do occur, and in either order: an acceptable-level refusal
1327 /// needs `acceptable_iter` consecutive qualifying iterates, so a strict
1328 /// refusal can precede it, while a run that first drifts through the
1329 /// acceptable band can refuse there and only later pass the strict test.
1330 /// Preferring `Success` unconditionally was wrong for exactly the second
1331 /// case — it compared against a strict point from iteration 50-odd when the
1332 /// baseline had already stopped and reported acceptable at iteration 43.
1333 fn baseline_outcome(&self) -> Option<(VetoSnapshot, SolverReturn)> {
1334 // A refusal that was seen but not captured makes the baseline
1335 // unidentifiable, so decline rather than guess. Without this, a failed
1336 // strict capture alongside a successful acceptable one would present the
1337 // acceptable snapshot as the baseline outcome — but that snapshot sits
1338 // on the continued trajectory, so this would silently reintroduce the
1339 // very misidentification the chronological rule exists to prevent.
1340 // Declining loses the restore; misidentifying reports a wrong point
1341 // under a confident status.
1342 //
1343 // Unreachable today (`data.curr` is always `Some` inside `iterate()`, so
1344 // `snapshot_current` cannot fail), but the latches make the state
1345 // representable, and it must not be handled by accident.
1346 if (self.vetoed_seen && self.vetoed.is_none())
1347 || (self.vetoed_acceptable_seen && self.vetoed_acceptable.is_none())
1348 {
1349 return None;
1350 }
1351 match (&self.vetoed, &self.vetoed_acceptable) {
1352 // Ties go to the strict refusal, and the tie is reachable: both can
1353 // arm in the same call when the acceptable streak crosses on the
1354 // same iterate a strict certificate is refused. Strict is correct
1355 // there because of the baseline's own branch order — the `Converged`
1356 // gate (`opt_error.rs`, in `check_convergence_with_state`) precedes
1357 // `note_acceptable`, so the baseline returned `Converged` at that
1358 // iterate. Reordering those two branches would invert this.
1359 (Some(strict), Some(acc)) => Some(if strict.iter <= acc.iter {
1360 (strict.clone(), SolverReturn::Success)
1361 } else {
1362 (acc.clone(), SolverReturn::StopAtAcceptablePoint)
1363 }),
1364 (Some(strict), None) => Some((strict.clone(), SolverReturn::Success)),
1365 (None, Some(acc)) => Some((acc.clone(), SolverReturn::StopAtAcceptablePoint)),
1366 (None, None) => None,
1367 }
1368 }
1369
1370 /// Capture the current iterate as a veto snapshot, or `None` if there is no
1371 /// current iterate to capture.
1372 ///
1373 /// All-or-nothing by construction — see [`VetoSnapshot`].
1374 fn snapshot_current(&self, iter: Index) -> Option<VetoSnapshot> {
1375 let iterate = self.data.borrow().curr.as_ref().cloned()?;
1376 let cq = self.cq.borrow();
1377 Some(VetoSnapshot {
1378 iterate,
1379 iter,
1380 obj: cq.curr_f(),
1381 mu: self.data.borrow().curr_mu,
1382 unscaled_kkt: cq.curr_unscaled_nlp_error(),
1383 constr_viol: cq.curr_unscaled_primal_infeasibility_max(),
1384 obj_scale: cq.obj_scaling_factor(),
1385 })
1386 }
1387
1388 /// Current objective and max-norm unscaled KKT error, read together so the
1389 /// pair cannot describe different iterates.
1390 fn curr_obj_and_unscaled_kkt(&self) -> (Number, Number) {
1391 let cq = self.cq.borrow();
1392 (cq.curr_f(), cq.curr_unscaled_nlp_error())
1393 }
1394
1395 /// Guard the precondition of every scaled-objective comparison in
1396 /// [`Self::honour_refused_certificate`]: the factor must not have moved
1397 /// between the refusal and now, or the two numbers are not comparable.
1398 fn assert_comparable_scale(&self, snap: &VetoSnapshot) {
1399 debug_assert_eq!(
1400 snap.obj_scale,
1401 self.cq.borrow().obj_scaling_factor(),
1402 "objective scaling factor moved during the solve; the refused and \
1403 continued objectives are scaled differently and cannot be compared \
1404 (gh #200)"
1405 );
1406 }
1407
1408 /// Whether a point with this unscaled KKT error would keep `Solve_Succeeded`
1409 /// through [`IpoptApplication::apply_kkt_fidelity_gate`].
1410 ///
1411 /// Mirrors that gate rather than approximating it: same quantity
1412 /// (`final_unscaled_kkt_error`), same strict comparison, same "non-positive
1413 /// tolerance disables". With the default `kkt_fidelity_tol = 0` this is
1414 /// always `true`, so every caller collapses to the plain objective
1415 /// comparison and the mechanism's behaviour is unchanged.
1416 fn survives_fidelity_gate(&self, unscaled_kkt: Number) -> bool {
1417 // Phrased as the negation of the gate's own `> tol` test rather than as
1418 // `<= tol`, because the two disagree at NaN and the gate is the
1419 // authority: it demotes only on `> tol`, so a NaN error keeps `Success`
1420 // there and must keep it here. Written as `<= tol` this mirror said the
1421 // opposite, which would rank a NaN-error continued point below a refused
1422 // one. Benign in that direction — it restores the baseline point — but a
1423 // mirror that disagrees with the thing it mirrors is a latent trap.
1424 !(self.kkt_fidelity_tol > 0.0) || !(unscaled_kkt > self.kkt_fidelity_tol)
1425 }
1426
1427 /// Record the current iterate as the best acceptable-quality point seen so
1428 /// far in this solve (pounce#250 follow-up).
1429 ///
1430 /// Recording runs on **every** acceptable iterate, not only after the
1431 /// dual-divergence guard has fired. Gating it on the guard was the first
1432 /// attempt and left a hole: the guard fires and returns to the driver
1433 /// *before* this site is reached on that iteration (see the guard block in
1434 /// [`Self::iterate`]), so nothing at or before the diversion was ever
1435 /// captured. A diversion that wrecks the solve immediately — reaching no
1436 /// acceptable point afterwards — therefore had nothing to hand back, which
1437 /// is precisely the case the fallback exists for. `autocorr_bern55-06` hid
1438 /// this, because its better point happens to arrive at iteration 86, well
1439 /// after the guard fires at 23.
1440 ///
1441 /// Recording always is still behaviour-neutral, because the record is only
1442 /// ever *read* under `dual_guard_fired` — see
1443 /// [`Self::honour_best_acceptable_after_dual_guard`]. A solve the guard
1444 /// never touches computes a comparison per acceptable iterate and nothing
1445 /// else.
1446 ///
1447 /// The cost is one `f64` comparison per acceptable iterate; the iterate is
1448 /// cloned only on an actual improvement, so this does not double the
1449 /// per-iteration clone `store_acceptable_point` already pays.
1450 ///
1451 /// "Best" is a feasibility-aware ranking, **not** the lowest objective:
1452 /// candidates are ordered by [`Self::ranks_better`]'s `(feasible_enough,
1453 /// objective)` key, so objective only decides among points already inside a
1454 /// capped feasibility band. Being *bounded* by `acceptable_constr_viol_tol`
1455 /// is not the same as *not trading* feasibility within it — that band is a
1456 /// user option and can be widened to `1e1` or beyond. A pure-objective argmax
1457 /// over it has no lower bound on the feasibility it will spend, and one
1458 /// option-value away it returns a point `pounce verify` rejects under a
1459 /// `Solved_To_Acceptable_Level` status (gh #267). Whether an early
1460 /// low-objective iterate is even a candidate is the user's
1461 /// `acceptable_constr_viol_tol`; the capped feasibility key is what keeps a
1462 /// grossly-infeasible one from winning even when the band admits it.
1463 fn record_best_acceptable(&mut self, curr_f: Number) {
1464 if !curr_f.is_finite() {
1465 return;
1466 }
1467 // Same quantity the acceptable-point gate keys on, so the recorded
1468 // feasibility matches the band the candidate just passed.
1469 let curr_viol = self.cq.borrow().curr_unscaled_primal_infeasibility_max();
1470 // Reject before cloning: only a strictly better candidate — by the
1471 // feasibility-aware key, not objective alone — is worth a snapshot.
1472 if let Some(best) = self.best_acceptable.as_ref() {
1473 let (b_obj, b_viol) = (best.obj, best.constr_viol);
1474 if !self.ranks_better(curr_f, curr_viol, b_obj, b_viol) {
1475 return;
1476 }
1477 }
1478 let iter = self.data.borrow().iter_count;
1479 let Some(snap) = self.snapshot_current(iter) else {
1480 return;
1481 };
1482 // Scaled objectives are only comparable under an unchanged factor; if it
1483 // ever moved, keep the earlier point rather than compare noise.
1484 if let Some(best) = self.best_acceptable.as_ref() {
1485 if snap.obj_scale != best.obj_scale {
1486 return;
1487 }
1488 }
1489 self.best_acceptable = Some(snap);
1490 }
1491
1492 /// Cap on the feasibility band [`Self::ranks_better`] admits, matching the
1493 /// upstream default `acceptable_constr_viol_tol`. The fallback treats a point
1494 /// as "feasible enough to win on objective" only within this band, *however
1495 /// loose the user made `acceptable_constr_viol_tol`*, so widening that option
1496 /// cannot let the fallback trade feasibility for objective (gh #267).
1497 const FEASIBLE_ENOUGH_CAP: Number = 1e-2;
1498
1499 /// Whether candidate `(a_obj, a_viol)` ranks strictly better than
1500 /// `(b_obj, b_viol)` for the best-acceptable fallback (gh #267, gh #280).
1501 ///
1502 /// The key is `(band_clamped_viol, objective)` compared lexicographically,
1503 /// where each violation is clamped *up* to
1504 /// `band = min(acceptable_constr_viol_tol, FEASIBLE_ENOUGH_CAP)` before it is
1505 /// compared. Inside the band every point clamps to `band`, so they tie on
1506 /// feasibility and objective decides — objective still rules *only among
1507 /// points already feasible-enough*. Outside the band the actual violation
1508 /// decides, so the less-infeasible point always wins and a
1509 /// strictly-more-infeasible point can never rank better (gh #280 — the
1510 /// earlier `feasible_enough` partition fell through to objective-only once
1511 /// both points were outside the band). The cap keeps the band no looser than
1512 /// the upstream default: `acceptable_constr_viol_tol` is user-widenable, and
1513 /// admitting a wide band into the *objective-decides* region would let a
1514 /// grossly-infeasible low-objective iterate win. Capping the band bounds that.
1515 ///
1516 /// At default (or tighter) tolerances this is behaviour-neutral: every
1517 /// recorded point already passed the `acceptable_constr_viol_tol` gate, so
1518 /// with that band at or below the cap every candidate clamps to `band` and
1519 /// objective alone decides, exactly as before. The feasibility ordering only
1520 /// bites once the user loosens `acceptable_constr_viol_tol` past its default
1521 /// and two candidates both sit outside the cap.
1522 ///
1523 /// A non-finite objective ranks worst and can never win — feasibility never
1524 /// rescues a `NaN`/`Inf` `f`. This mirrors the `NaN`-loses convention the
1525 /// gh #200 comparisons already rely on, and it keeps a `NaN`-objective
1526 /// returned point losing to a finite recorded one in
1527 /// [`Self::honour_best_acceptable_after_dual_guard`].
1528 ///
1529 /// The ranking itself lives in the pure [`ranks_better_within_band`] so its
1530 /// never-worse-off guarantee can be proven by deterministic unit tests rather
1531 /// than inferred from a host-dependent end-to-end objective comparison (see
1532 /// gh #267, which flagged an earlier CLI test for measuring the wrong,
1533 /// host-varying property). This method only resolves the admitted band.
1534 fn ranks_better(&self, a_obj: Number, a_viol: Number, b_obj: Number, b_viol: Number) -> bool {
1535 let band = self
1536 .bundle
1537 .conv_check
1538 .acceptable_constr_viol_tol_or_default()
1539 .min(Self::FEASIBLE_ENOUGH_CAP);
1540 ranks_better_within_band(a_obj, a_viol, b_obj, b_viol, band)
1541 }
1542
1543 /// Make the dual-divergence guard's diversion non-destructive (pounce#250
1544 /// follow-up).
1545 ///
1546 /// The guard bets that routing to restoration beats grinding on, and nothing
1547 /// made losing that bet safe: it could return a materially worse point than
1548 /// the solve already had, under a status that does not admit it.
1549 ///
1550 /// WHAT THIS DOES AND DOES NOT GUARANTEE. It guarantees the diverted run
1551 /// never returns worse than the best acceptable-quality point **that same
1552 /// run visited**. It does *not* guarantee the diverted run is no worse than
1553 /// not diverting at all — that counterfactual solve never happened, and its
1554 /// points were never on offer to compare against. The distinction is not
1555 /// academic: on the Linux CI host `deb7` returns 97.56 with the guard off and
1556 /// 127.87 with it on at streak 15, and this fallback cannot close that gap,
1557 /// because 127.87 is the best acceptable point the diverted run ever reached.
1558 /// Bounding the diversion's damage is a weaker property than making the
1559 /// diversion harmless, and only the weaker one is available from inside a
1560 /// single solve. It is a large part of why the guard is off by default.
1561 ///
1562 /// The observed case is `autocorr_bern55-06`. The guard fires at iteration
1563 /// 23, the diverted run reaches the true optimum (-2304.0000278, matching
1564 /// Ipopt to 12 significant figures) and holds it from iteration 57 to 86 —
1565 /// but the dual residual sawtooths between 1e-8 and 2e-1 there, so it never
1566 /// strings together the `acceptable_iter` consecutive qualifying iterates
1567 /// that would stop the solve. It then enters restoration a second time,
1568 /// wanders into a worse basin, and terminates `StopAtAcceptablePoint` at
1569 /// -2263.46 — 1.8 % worse, with an overall NLP error of 1.0. The better
1570 /// point was *visited and passed the acceptable test*; it was simply
1571 /// overwritten, because `store_acceptable_point` keeps the latest rather
1572 /// than the best.
1573 ///
1574 /// So: on a non-`Success` exit, if the best acceptable-quality iterate seen
1575 /// anywhere in the solve beats the point being returned, hand that back
1576 /// instead. This is the same "never worse off" bargain the gh #200 veto
1577 /// makes, applied to the other bet in the algorithm.
1578 ///
1579 /// "Beats" is the feasibility-aware ranking in [`Self::ranks_better`], not a
1580 /// bare objective comparison: the recorded point wins only if it is
1581 /// feasible-enough while the returned point is not, or both are in the same
1582 /// feasibility class and it has a lower objective. Ranking by objective alone
1583 /// let a widened `acceptable_constr_viol_tol` band trade feasibility for
1584 /// objective here — restoring a lower-objective point that `pounce verify`
1585 /// rejects, under a success-mapped status (gh #267). The key prevents that:
1586 /// objective can only win among points already inside the capped acceptable
1587 /// feasibility band.
1588 ///
1589 /// Note "anywhere in the solve", not "since the guard fired":
1590 /// [`Self::record_best_acceptable`] runs unconditionally and explains why —
1591 /// points at or before the diversion have to be on offer, or a diversion that
1592 /// wrecks the solve immediately has nothing to hand back. Only this *read* is
1593 /// gated on `dual_guard_fired`.
1594 ///
1595 /// A strict `Success` is never overridden — that point carries a real
1596 /// certificate, and a lower objective at a merely-acceptable point must not
1597 /// displace it.
1598 ///
1599 /// Tuning the guard's firing threshold was tried first and rejected: no
1600 /// setting separates the models it helps from the ones it harms, and the
1601 /// effect turned out to differ by host anyway (see the option help in
1602 /// `upstream_options.rs`). Fixing the consequence is what remained available.
1603 fn honour_best_acceptable_after_dual_guard(&mut self, result: SolverReturn) -> SolverReturn {
1604 if !self.dual_guard_fired || matches!(result, SolverReturn::Success) {
1605 return result;
1606 }
1607 let Some(best) = self.best_acceptable.clone() else {
1608 return result;
1609 };
1610 let (curr_f, _) = self.curr_obj_and_unscaled_kkt();
1611 let curr_viol = self.cq.borrow().curr_unscaled_primal_infeasibility_max();
1612 let curr_scale = self.cq.borrow().obj_scaling_factor();
1613 // Only comparable under the same factor, sign included.
1614 if curr_scale != best.obj_scale {
1615 return result;
1616 }
1617 // Restore only when the recorded point ranks strictly better under the
1618 // feasibility-aware key — more feasible, or equally feasible at a lower
1619 // objective. `ranks_better` also handles the `NaN` case the previous
1620 // bare `!(curr_f <= best.obj)` did: a non-finite returned objective
1621 // ranks worst, so a finite recorded point wins and is restored.
1622 if self.ranks_better(best.obj, best.constr_viol, curr_f, curr_viol) {
1623 tracing::debug!(target: "pounce::algorithm",
1624 "[POUNCE] dual-divergence diversion ended worse than a point already \
1625 in hand (obj {:.10e} viol {:.3e} -> obj {:.10e} viol {:.3e}, iter {}); \
1626 restoring it (pounce#250, gh#267).",
1627 curr_f, curr_viol, best.obj, best.constr_viol, best.iter,
1628 );
1629 self.restore_snapshot(&best);
1630 // Swap the *point*, but never let the swap erase why the solve
1631 // stopped. A budget that was exhausted stays reported as exhausted:
1632 // a caller polling for "did I run out of time" must not be told
1633 // "solved to acceptable level" merely because a better point was
1634 // recoverable. Only the outcomes that carry no such fact of their
1635 // own are relabelled to describe what is now being returned.
1636 return match result {
1637 SolverReturn::MaxiterExceeded
1638 | SolverReturn::CpuTimeExceeded
1639 | SolverReturn::WallTimeExceeded
1640 | SolverReturn::UserRequestedStop => result,
1641 _ => SolverReturn::StopAtAcceptablePoint,
1642 };
1643 }
1644 result
1645 }
1646
1647 /// Make a refused snapshot the current iterate again.
1648 fn restore_snapshot(&mut self, snap: &VetoSnapshot) {
1649 let mut d = self.data.borrow_mut();
1650 d.set_trial(snap.iterate.clone());
1651 d.accept_trial_point();
1652 // The restored point's own barrier parameter, not the continued run's —
1653 // see `VetoSnapshot::mu`.
1654 d.curr_mu = snap.mu;
1655 }
1656
1657 /// Decide whether the acceptable-point restoration decline may be deferred
1658 /// this once (gh #534), and arm the bookkeeping that bounds the bet.
1659 ///
1660 /// Four conditions, all required:
1661 ///
1662 /// * the option leaves deferrals available at all
1663 /// (`resto_decline_deferrals`, `0` = pre-#534 behaviour);
1664 /// * the budget is not already spent;
1665 /// * the NLP error has contracted on every one of the last
1666 /// [`DECLINE_PROGRESS_SAMPLES`]` - 1` iterations
1667 /// ([`Self::nlp_err_contracting`]) — the progress test the guard lacked;
1668 /// * the iteration budget has room for a continuation, and the entry point
1669 /// can actually be captured. Without a floor there is nothing to fall
1670 /// back to, and a bet with no floor is exactly what must not be placed.
1671 ///
1672 /// The deadline is clamped below `max_iter` so a lost bet can never turn a
1673 /// reportable `StopAtAcceptablePoint` into `Maximum_Iterations_Exceeded`:
1674 /// the continuation is always cut before the iteration budget runs out. A
1675 /// *time* budget is not clamped the same way — elapsed time is an external
1676 /// fact and the deadline cannot predict it — so a solve that expires inside
1677 /// the continuation window still reports the time limit, at the floor
1678 /// iterate rather than at whatever the continuation last touched.
1679 fn may_defer_acceptable_decline(&mut self) -> bool {
1680 if self.decline_deferrals_used >= self.resto_decline_deferrals {
1681 return false;
1682 }
1683 if !self.nlp_err_contracting() {
1684 return false;
1685 }
1686 let iter = self.data.borrow().iter_count;
1687 // No room to continue: the deadline below would fire on the very next
1688 // iteration, so the deferral would buy nothing and cost a restoration.
1689 if iter.saturating_add(1) >= self.max_iter {
1690 return false;
1691 }
1692 if self.decline_floor.is_none() {
1693 let Some(snap) = self.snapshot_current(iter) else {
1694 return false;
1695 };
1696 self.decline_floor = Some(snap);
1697 }
1698 self.decline_deferrals_used += 1;
1699 self.decline_deadline_iter = Some(
1700 iter.saturating_add(DECLINE_CONTINUATION_BUDGET)
1701 .min(self.max_iter.saturating_sub(1)),
1702 );
1703 true
1704 }
1705
1706 /// The deferred continuation ran out of budget without a strict certificate
1707 /// (gh #534). Report the floor — the point the pre-#534 guard would have
1708 /// returned — unless the continuation is standing somewhere at least as
1709 /// good.
1710 fn terminate_at_decline_floor(&mut self) -> IterateOutcome {
1711 let Some(floor) = self.decline_floor.clone() else {
1712 // Unreachable in practice: the deadline is only ever set after a
1713 // floor is captured. Stopping at the current point is still the
1714 // right thing if it somehow is not — the point passed the
1715 // acceptable-level triplet when the deferral was taken.
1716 return IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint);
1717 };
1718 tracing::debug!(target: "pounce::algorithm",
1719 "[POUNCE] deferred restoration decline expired at iter {} without a strict \
1720 certificate; falling back to the floor from iter {} (gh #534).",
1721 self.data.borrow().iter_count, floor.iter,
1722 );
1723 if !self.continuation_outranks(&floor) {
1724 self.restore_snapshot(&floor);
1725 }
1726 IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint)
1727 }
1728
1729 /// Whether the current iterate is a *better* answer than the gh #534 floor.
1730 ///
1731 /// Two gates, in order. The current point must itself pass the
1732 /// acceptable-level triplet — the floor is going to be reported under
1733 /// `Solved_To_Acceptable_Level`, and a continuation that wandered off is not
1734 /// entitled to that status however attractive its objective looks. Only then
1735 /// does [`Self::ranks_better`]'s feasibility-first key decide, and only under
1736 /// an unmoved objective scaling factor, since the two objectives are
1737 /// otherwise not comparable.
1738 fn continuation_outranks(&self, floor: &VetoSnapshot) -> bool {
1739 let (curr_f, _) = self.curr_obj_and_unscaled_kkt();
1740 if !curr_f.is_finite() {
1741 return false;
1742 }
1743 let nlp_err = self.cq.borrow().curr_nlp_error();
1744 if !self
1745 .bundle
1746 .conv_check
1747 .current_is_acceptable_with_state(nlp_err, &self.data, &self.cq)
1748 {
1749 return false;
1750 }
1751 let (curr_viol, curr_scale) = {
1752 let cq = self.cq.borrow();
1753 (
1754 cq.curr_unscaled_primal_infeasibility_max(),
1755 cq.obj_scaling_factor(),
1756 )
1757 };
1758 if curr_scale != floor.obj_scale {
1759 return false;
1760 }
1761 !self.ranks_better(floor.obj, floor.constr_viol, curr_f, curr_viol)
1762 }
1763 /// Try to leave a first-order-stationary point that is not a local minimum
1764 /// (gh #797). Returns `true` when the current iterate has been replaced and
1765 /// the solve should continue instead of terminating.
1766 ///
1767 /// # What this is for
1768 ///
1769 /// The convergence check is a *first-order* test, and on a nonconvex model
1770 /// that is strictly weaker than "local minimum". `nonconvex_qp.nl` is the
1771 /// reported case: `min x₀x₁ s.t. x₀ + x₁ = 2, 0 ≤ x ≤ 4` restricted to its
1772 /// feasible segment is the concave `f(x₀) = x₀(2 - x₀)`, *maximized* at
1773 /// `(1,1)` and minimized at the two endpoints. From the bound-pushed start
1774 /// `(0.01, 0.01)` the first Newton step lands exactly on `(1,1)`, every KKT
1775 /// residual there is zero, and the solve reports `Solve_Succeeded` at
1776 /// `obj = 1` — the constrained maximum.
1777 ///
1778 /// Inertia correction does not save this. It engages (the iteration log
1779 /// shows `lg(rg)` from the second iteration on) and cannot help: `δ_x I` is
1780 /// symmetric, the model and the iterate are symmetric under `x₀ ↔ x₁`, and
1781 /// a symmetric correction applied to a zero gradient gives a zero step
1782 /// however indefinite the reduced Hessian is. The regularization makes the
1783 /// *step* well-posed; nothing in the algorithm asks whether the point it
1784 /// has converged to is a minimum.
1785 ///
1786 /// # What it does
1787 ///
1788 /// [`PdFullSpaceSolver::negative_curvature_direction`] answers that
1789 /// question with the KKT factorization already in hand, and hands back a
1790 /// measured direction `d` with `J_c d_x = 0` and `dᵀ(W + Σ)d < 0`. Since
1791 /// the gradient is (near) zero the barrier objective along `±d` is
1792 /// `φ(α) ≈ φ(0) + ½α²dᵀ(W + Σ)d`, decreasing on *both* sides, so both signs
1793 /// are tried and the better trial wins. The step is capped by the ordinary
1794 /// fraction-to-the-boundary rule and by [`NEG_CURV_MAX_STEP_FACTOR`] times
1795 /// the iterate's own scale, then backtracked until it satisfies that
1796 /// second-order decrease model with an Armijo factor — the same shape as
1797 /// the line search, on the curvature term rather than the gradient term.
1798 /// A trial whose constraint violation exceeds what the convergence check
1799 /// itself calls feasible is refused outright.
1800 ///
1801 /// # Why it cannot make an answer worse
1802 ///
1803 /// The point being left is a *strict certificate* — the solve was about to
1804 /// report `Solve_Succeeded` at it — so it is snapshotted as a floor before
1805 /// the step, exactly as gh #534's deferred restoration decline does with
1806 /// the point its guard would have returned. The continuation gets
1807 /// [`NEG_CURV_CONTINUATION_BUDGET`] outer iterations; past that, and at
1808 /// every other exit of the driver loop, [`Self::honour_neg_curv_floor`]
1809 /// hands the floor back unless the continuation is standing somewhere that
1810 /// both outranks it and carries a certificate of its own. So the escape
1811 /// costs a bounded number of iterations and can only trade the stationary
1812 /// point for a strictly better one.
1813 ///
1814 /// That holds however many escapes are spent, not only at the default of
1815 /// one: a later escape displaces the floor only with a certificate that
1816 /// outranks the one already held (gh #805), so the floor is always at
1817 /// least as good as the point a `neg_curv_escapes = 0` build reports.
1818 fn try_neg_curv_escape(&mut self, iter_count: Index) -> bool {
1819 if self.neg_curv_escapes_used >= self.neg_curv_escapes {
1820 return false;
1821 }
1822 // No room to continue: the deadline below would fire on the very next
1823 // iteration, so the escape would buy nothing and cost a factorization.
1824 if iter_count.saturating_add(1) >= self.max_iter {
1825 return false;
1826 }
1827 if self.nlp.is_none() || self.search_dir.is_none() {
1828 return false;
1829 }
1830 // `data.w` still holds `W(curr_{N-1})` — step 3 of `iterate()` runs
1831 // *after* the convergence check — and curvature at the previous iterate
1832 // is not the question being asked. Re-evaluate it here rather than
1833 // running the Hessian updater a second time at this iterate: that would
1834 // hand the limited-memory updater a zero-length curvature pair to skip
1835 // and count against `limited_memory_max_skipping`, and it would leave
1836 // `data.w` describing a different iterate than it did before, which the
1837 // post-optimal sensitivity hook reads.
1838 //
1839 // With a quasi-Newton `B` there is nothing to re-evaluate and the stale
1840 // one is used. That is not a gap being papered over: BFGS maintains `B`
1841 // positive definite by construction, so under
1842 // `hessian_approximation=limited-memory` the probe's inertia test
1843 // passes at δ_x = 0 and the escape declines — correctly, since the only
1844 // curvature information the solve has says the point is a minimum.
1845 //
1846 // That argument is about BFGS's definiteness, NOT about "not exact",
1847 // and gating on `provides_exact_hessian` conflated the two. A
1848 // finite-difference `W` is not exact but does carry genuine negative
1849 // curvature, so judging the current iterate by the previous one's
1850 // matrix let a stationary maximum be reported as optimal where the
1851 // exact path escapes it (gh#823 review, finding 1, @srikanth-gm).
1852 // `hessian_at_current` asks the question the probe actually has:
1853 // can you give me `W` here? Quasi-Newton updaters still answer `None`
1854 // and still take the stale path, for the reason above.
1855 let w_at_curr = self.bundle.hess.hessian_at_current(&self.data, &self.cq);
1856
1857 let probe = {
1858 let (Some(nlp), Some(sd)) = (self.nlp.as_ref(), self.search_dir.as_mut()) else {
1859 return false;
1860 };
1861 let mut pd = sd.pd_solver_mut();
1862 pd.negative_curvature_direction(&self.data, &self.cq, nlp, w_at_curr)
1863 };
1864 let Some(probe) = probe else {
1865 return false;
1866 };
1867
1868 let Some(floor) = self.snapshot_current(iter_count) else {
1869 // Nothing to fall back to, and a bet with no floor is exactly what
1870 // must not be placed (the gh #534 rule, for the same reason).
1871 return false;
1872 };
1873 let curr = floor.iterate.clone();
1874 let (tau, curr_barr, curr_theta) = {
1875 let d = self.data.borrow();
1876 let cq = self.cq.borrow();
1877 (
1878 d.curr_tau,
1879 cq.curr_barrier_obj(),
1880 cq.curr_constraint_violation(),
1881 )
1882 };
1883 if !curr_barr.is_finite() {
1884 return false;
1885 }
1886 // A trial may raise the violation up to what this solve's own
1887 // convergence check calls feasible, and no further.
1888 let theta_cap = curr_theta.max(self.bundle.conv_check.constr_viol_tol_or_default());
1889 // The direction has unit infinity-norm, so this caps the escape at a
1890 // multiple of the iterate's own scale rather than at an absolute
1891 // distance, which would mean different things on differently scaled
1892 // models.
1893 let step_cap = NEG_CURV_MAX_STEP_FACTOR * (1.0 + curr.x.amax().max(curr.s.amax()));
1894
1895 let mut accepted: Option<(crate::iterates_vector::IteratesVector, Number, Number)> = None;
1896 for sign in [1.0, -1.0] {
1897 let mut dir = probe.delta.deep_copy();
1898 dir.scal(sign);
1899 let dir = dir.freeze();
1900 let alpha_max = self.cq.borrow().aff_step_alpha_primal_max(&dir, tau);
1901 if !(alpha_max > 0.0) {
1902 continue;
1903 }
1904 let mut alpha = alpha_max.min(step_cap);
1905 for _ in 0..NEG_CURV_BACKTRACKS {
1906 if !(alpha > 0.0) || !alpha.is_finite() {
1907 break;
1908 }
1909 let mut trial = curr.deep_copy();
1910 trial.x.axpy(alpha, &*dir.x);
1911 trial.s.axpy(alpha, &*dir.s);
1912 let trial = trial.freeze();
1913 self.data.borrow_mut().set_trial(trial.clone());
1914 let (barr, theta) = {
1915 let cq = self.cq.borrow();
1916 (cq.trial_barrier_obj(), cq.trial_constraint_violation())
1917 };
1918 self.data.borrow_mut().trial = None;
1919 // Second-order sufficient decrease. `probe.curvature` is
1920 // negative, so this asks the trial to realise at least
1921 // `NEG_CURV_ARMIJO` of the decrease the curvature model
1922 // predicts — the guard against a direction that is only
1923 // downhill in the quadratic model and uphill in the function.
1924 let predicted = 0.5 * alpha * alpha * probe.curvature;
1925 if barr.is_finite()
1926 && theta.is_finite()
1927 && theta <= theta_cap
1928 && barr <= curr_barr + NEG_CURV_ARMIJO * predicted
1929 {
1930 let better = match accepted.as_ref() {
1931 Some((_, best, _)) => barr < *best,
1932 None => true,
1933 };
1934 if better {
1935 accepted = Some((trial, barr, alpha));
1936 }
1937 break;
1938 }
1939 alpha *= NEG_CURV_BACKTRACK_FACTOR;
1940 }
1941 }
1942
1943 let Some((trial, barr, alpha)) = accepted else {
1944 return false;
1945 };
1946
1947 tracing::debug!(target: "pounce::algorithm",
1948 "[POUNCE] iter {}: certified point has negative reduced curvature \
1949 (dᵀ(W+Σ)d = {:.3e}); escaping along it with α = {:.3e} \
1950 (barrier obj {:.10e} -> {:.10e}) and continuing (gh#797).",
1951 iter_count, probe.curvature, alpha, curr_barr, barr,
1952 );
1953
1954 self.neg_curv_escapes_used += 1;
1955 // gh #805 — the floor is the *best* certificate the escapes have left,
1956 // never simply the most recent one. Replacing it unconditionally is
1957 // what broke the guarantee above at `neg_curv_escapes >= 2`: escape 1
1958 // floors at A, the continuation certifies a different indefinite point
1959 // B, escape 2 overwrites the floor with B, and if that bet is lost the
1960 // run reports B — which nothing has ever compared against A. With
1961 // `f(B) > f(A)` that is worse than `neg_curv_escapes = 0` returns, the
1962 // one outcome the mechanism promises cannot happen. The filter method's
1963 // barrier objective is not monotone across μ updates, so the escape's
1964 // own accounting does not exclude it.
1965 //
1966 // Ranked rather than merely kept (gh #805's suggested fix), because
1967 // keeping A unconditionally has the mirror-image flaw: where B *is* the
1968 // better certificate, a lost second bet would hand back A and make
1969 // `neg_curv_escapes = 2` worse than `= 1`, which returns B. Both points
1970 // are strict certificates — the escape only fires on
1971 // `ConvergenceStatus::Converged` — so the same status-dominant ranking
1972 // [`Self::honour_neg_curv_floor`] uses on the way out decides between
1973 // them, and the floor is monotone in the number of escapes spent.
1974 //
1975 // A provable no-op at the default: the branch is only reachable with a
1976 // floor already held, which takes a second escape, which takes
1977 // `neg_curv_escapes >= 2`.
1978 let replace_floor = match self.neg_curv_floor.as_ref() {
1979 None => true,
1980 Some(held) => self.current_outranks_neg_curv_floor(held, true),
1981 };
1982 if replace_floor {
1983 self.neg_curv_floor = Some(floor);
1984 }
1985 self.neg_curv_deadline_iter = Some(
1986 iter_count
1987 .saturating_add(NEG_CURV_CONTINUATION_BUDGET)
1988 .min(self.max_iter.saturating_sub(1)),
1989 );
1990 {
1991 let mut d = self.data.borrow_mut();
1992 d.set_trial(trial);
1993 d.accept_trial_point();
1994 // Iteration-log marker. `n` is not one of upstream's codes and is
1995 // unused elsewhere in POUNCE; it is what tells a reader of the
1996 // table that the jump between two iterates was an escape and not a
1997 // line-search step.
1998 d.append_info_string("n");
1999 }
2000 // The filter's entries were computed at, and around, a point the
2001 // algorithm has just left discontinuously — the same situation a
2002 // successful restoration leaves behind, and handled the same way.
2003 self.bundle.line_search.reset();
2004 self.bundle.line_search.reset_after_restoration();
2005 true
2006 }
2007
2008 /// Rank the current iterate against a held negative-curvature floor
2009 /// (gh #797, gh #805).
2010 ///
2011 /// Rank by the status each point will actually be *reported* under, and
2012 /// only then by objective — the status-dominant order gh #200 arrived at
2013 /// the hard way. `apply_kkt_fidelity_gate` re-grades a `Success` on the
2014 /// unscaled KKT error after the driver loop returns, so with
2015 /// `kkt_fidelity_tol` set a lower objective at a coarser point is a status
2016 /// regression dressed up as a win.
2017 ///
2018 /// `current_certifies` is whether the current point comes with a
2019 /// certificate of its own: at the exit hook that is the driver's status,
2020 /// and at an escape site it is true by construction, since the escape only
2021 /// fires on `ConvergenceStatus::Converged`.
2022 ///
2023 /// Shared by the two sites that have to answer this question — the exit
2024 /// hook [`Self::honour_neg_curv_floor`], and the point at which a second
2025 /// escape decides which of two certificates to hold (gh #805) — so the two
2026 /// cannot drift into disagreeing about which point is the better answer.
2027 fn current_outranks_neg_curv_floor(
2028 &self,
2029 floor: &VetoSnapshot,
2030 current_certifies: bool,
2031 ) -> bool {
2032 let (_, curr_kkt) = self.curr_obj_and_unscaled_kkt();
2033 let current_success = current_certifies && self.survives_fidelity_gate(curr_kkt);
2034 let floor_success = self.survives_fidelity_gate(floor.unscaled_kkt);
2035 match (current_success, floor_success) {
2036 (true, true) => self.continuation_outranks(floor),
2037 // The current point reports `Solve_Succeeded` where the floor would
2038 // be re-graded down. A better status wins outright.
2039 (true, false) => true,
2040 // No certificate of its own — the escape was a bet placed *from*
2041 // one, so anything short of that loses it.
2042 (false, _) => false,
2043 }
2044 }
2045
2046 /// Make a negative-curvature escape non-destructive (gh #797).
2047 ///
2048 /// The escape is a bet placed *from a strict certificate*, which is what
2049 /// makes its accounting stricter than gh #534's: the floor is a point the
2050 /// solve was about to report `Solve_Succeeded` at, so the continuation has
2051 /// to come back with a certificate of its own at a better point to be
2052 /// preferred. Anything else — a worse point, an acceptable-level stall, a
2053 /// spent iteration budget, a restoration failure — restores the floor and
2054 /// reports it under the status it always had.
2055 ///
2056 /// Restoring reports `Success` even over a budget or user-stop exit, which
2057 /// is the opposite of what [`Self::honour_decline_floor`] and
2058 /// [`Self::honour_best_acceptable_after_dual_guard`] do — and deliberately
2059 /// so. Those two hold an *acceptable-level* point and must not let it erase
2060 /// why the solve stopped. Here a pre-#797 build returns `Solve_Succeeded`
2061 /// at this exact point, and returns it *before* the continuation that spent
2062 /// the budget ever runs: the budget was spent by the bet, not by the
2063 /// caller's problem. Reproducing the baseline outcome — point and status
2064 /// both — is the guarantee, and it is the reading gh #200's hook already
2065 /// takes of a refused certificate.
2066 fn honour_neg_curv_floor(&mut self, result: SolverReturn) -> SolverReturn {
2067 let Some(floor) = self.neg_curv_floor.clone() else {
2068 return result;
2069 };
2070 self.assert_comparable_scale(&floor);
2071 let keep_continuation =
2072 self.current_outranks_neg_curv_floor(&floor, matches!(result, SolverReturn::Success));
2073 if keep_continuation {
2074 tracing::debug!(target: "pounce::algorithm",
2075 "[POUNCE] the negative-curvature escape paid off: the continuation \
2076 certified a better point than the stationary one it left \
2077 (obj {:.10e} -> {:.10e}, gh#797).",
2078 floor.obj, self.curr_obj_and_unscaled_kkt().0,
2079 );
2080 return result;
2081 }
2082 tracing::debug!(target: "pounce::algorithm",
2083 "[POUNCE] the negative-curvature escape did not pay off; restoring the \
2084 certified stationary point from iter {} (obj {:.10e} viol {:.3e}) \
2085 and reporting it (gh#797).",
2086 floor.iter, floor.obj, floor.constr_viol,
2087 );
2088 self.restore_snapshot(&floor);
2089 SolverReturn::Success
2090 }
2091
2092 /// The escape's continuation ran out of budget without a certificate of its
2093 /// own (gh #797). Restore the stationary point the escape left and report
2094 /// it — that point is what a pre-#797 build returns, and it is a genuine
2095 /// strict certificate, so `Success` is the honest status for it.
2096 ///
2097 /// Takes the floor rather than cloning it, so
2098 /// [`Self::honour_neg_curv_floor`] is a no-op on the way out.
2099 fn terminate_at_neg_curv_floor(&mut self) -> IterateOutcome {
2100 if let Some(floor) = self.neg_curv_floor.take() {
2101 tracing::debug!(target: "pounce::algorithm",
2102 "[POUNCE] the negative-curvature escape spent its {} iterations \
2103 without a new certificate; restoring the stationary point from \
2104 iter {} (obj {:.10e}) and reporting it (gh#797).",
2105 NEG_CURV_CONTINUATION_BUDGET, floor.iter, floor.obj,
2106 );
2107 self.restore_snapshot(&floor);
2108 }
2109 IterateOutcome::Terminate(SolverReturn::Success)
2110 }
2111
2112 /// Make a deferred restoration decline non-destructive (gh #534).
2113 ///
2114 /// The deferral is a bet that a contracting endgame is three iterations from
2115 /// a certificate. This is what makes losing it cost only those iterations:
2116 /// whatever the continued run ends up returning, if it is not at least as
2117 /// good an answer as the floor — the point the pre-#534 guard would have
2118 /// reported — the floor is restored and reported instead.
2119 ///
2120 /// Applied once, in [`Self::optimize`], for the same reason the gh #200 and
2121 /// pounce#250 hooks are: the driver loop has many `return`s and this must
2122 /// see all of them.
2123 ///
2124 /// A strict `Success` is never overridden — that is the bet paying off, and
2125 /// a real certificate outranks any acceptable-level point by construction.
2126 /// The budget statuses keep their own status, as they do in
2127 /// [`Self::honour_best_acceptable_after_dual_guard`]: a caller polling for
2128 /// "did I run out of time" must be told so, even while the point it gets
2129 /// back is swapped for the better one.
2130 fn honour_decline_floor(&mut self, result: SolverReturn) -> SolverReturn {
2131 if matches!(result, SolverReturn::Success) {
2132 if self.decline_floor.is_some() {
2133 tracing::debug!(target: "pounce::algorithm",
2134 "[POUNCE] the deferred restoration decline paid off: the continuation \
2135 reached a strict certificate (gh #534).",
2136 );
2137 }
2138 return result;
2139 }
2140 let Some(floor) = self.decline_floor.clone() else {
2141 return result;
2142 };
2143 if self.continuation_outranks(&floor) {
2144 return result;
2145 }
2146 tracing::debug!(target: "pounce::algorithm",
2147 "[POUNCE] the deferred restoration decline did not pay off; restoring the \
2148 floor from iter {} (obj {:.10e} viol {:.3e}) and reporting it (gh #534).",
2149 floor.iter, floor.obj, floor.constr_viol,
2150 );
2151 self.restore_snapshot(&floor);
2152 match result {
2153 SolverReturn::MaxiterExceeded
2154 | SolverReturn::CpuTimeExceeded
2155 | SolverReturn::WallTimeExceeded
2156 | SolverReturn::UserRequestedStop => result,
2157 _ => SolverReturn::StopAtAcceptablePoint,
2158 }
2159 }
2160
2161 /// Terminal fallback for a near-feasible numerical breakdown (a
2162 /// restoration cycle or a failed step computation). If a finite
2163 /// acceptable iterate was recorded earlier in the solve, roll back
2164 /// to it and stop at [`SolverReturn::StopAtAcceptablePoint`] (mapped
2165 /// by the application layer to `Solved_To_Acceptable_Level`) rather
2166 /// than surfacing the hard `fallback` error. This mirrors upstream
2167 /// `IpBacktrackingLineSearch`'s `ACCEPTABLE_POINT_REACHED`
2168 /// precedence: when the line search exhausts but an acceptable point
2169 /// was stored, that point is returned instead of the failure. With
2170 /// no snapshot — or if the restored objective is non-finite — the
2171 /// original `fallback` status is surfaced unchanged, so genuinely
2172 /// failed/infeasible solves keep their honest status. Catches
2173 /// degenerate LPs (kleemin8, nsir2) whose μ-endgame reaches the
2174 /// optimum, then destabilizes on the ill-conditioned vertex and
2175 /// cycles in restoration instead of stopping at the acceptable
2176 /// iterate it already passed through.
2177 fn terminate_acceptable_or(&mut self, fallback: SolverReturn) -> IterateOutcome {
2178 if self.restore_acceptable_point() && self.cq.borrow().curr_f().is_finite() {
2179 IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint)
2180 } else {
2181 IterateOutcome::Terminate(fallback)
2182 }
2183 }
2184
2185 /// The single place this module turns a local-infeasibility conclusion
2186 /// into a returned status (gh #505).
2187 ///
2188 /// Three separate routes reach that verdict — the conv-check's rapid
2189 /// detection, restoration layer 2, and the slow-cycle exits — and the same
2190 /// defect was found in two of them independently: returning the hard
2191 /// verdict without consulting the acceptable-point stash, so a solve that
2192 /// had already passed through an acceptable iterate discarded it. Only the
2193 /// cycle exits got it right, and nothing structural said the other two were
2194 /// wrong.
2195 ///
2196 /// That is the shape of a defect that comes back. The route a solve takes
2197 /// to the verdict is an internal detail — the user sees one status either
2198 /// way — so the *decision* about what that status means must not live at
2199 /// each route. It lives here, and
2200 /// [`no_route_concludes_local_infeasibility_alone`] is a tripwire against
2201 /// a new site rebuilding the outcome inline.
2202 ///
2203 /// The cycle exits are not routed through here because their fallback is
2204 /// chosen between `LocalInfeasibility` and `ErrorInStepComputation` at the
2205 /// call site; they already reach `terminate_acceptable_or`, which is the
2206 /// behaviour this guarantees.
2207 ///
2208 /// Scope: this governs how *this module* returns the verdict. Other layers
2209 /// name `SolverReturn::LocalInfeasibility` for their own reasons — the SQP
2210 /// status map and the ℓ₁ elastic path in `application.rs`, for instance —
2211 /// and are outside both this funnel and its tripwire.
2212 fn terminate_local_infeasibility(&mut self) -> IterateOutcome {
2213 self.terminate_acceptable_or(SolverReturn::LocalInfeasibility)
2214 }
2215
2216 pub fn with_nlp(mut self, nlp: Rc<RefCell<dyn IpoptNlp>>) -> Self {
2217 self.nlp = Some(nlp);
2218 self
2219 }
2220
2221 /// Install a user-facing TNLP handle. Enables per-iteration
2222 /// `TNLP::intermediate_callback` invocation from `optimize()`.
2223 pub fn with_tnlp(mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> Self {
2224 self.tnlp = Some(tnlp);
2225 self
2226 }
2227
2228 /// Build an [`IterStats`] payload from the current `IpoptData` /
2229 /// `IpoptCq` state. Mirrors the field set the upstream Ipopt main
2230 /// loop hands to `IntermediateCallback` after each `AcceptTrialPoint`.
2231 fn build_iter_stats(&self) -> IterStats {
2232 let d = self.data.borrow();
2233 let c = self.cq.borrow();
2234 let dnrm = match d.delta.as_ref() {
2235 Some(delta) => delta.x.amax().max(delta.s.amax()),
2236 None => 0.0,
2237 };
2238 IterStats {
2239 // Regular from the outer loop; restoration from the inner
2240 // sub-IPM, which the outer driver flags at construction
2241 // (gh#645). The outer loop never sets the flag, so every
2242 // fire from here is still `RegularMode` — what changed is
2243 // that restoration now fires at all.
2244 mode: if self.fires_as_restoration {
2245 AlgorithmMode::RestorationPhaseMode
2246 } else {
2247 AlgorithmMode::RegularMode
2248 },
2249 iter: d.iter_count,
2250 obj_value: c.curr_f(),
2251 inf_pr: c.curr_primal_infeasibility_max(),
2252 inf_du: c.curr_dual_infeasibility_max(),
2253 mu: d.curr_mu,
2254 d_norm: dnrm,
2255 regularization_size: d.info_regu_x,
2256 alpha_du: d.info_alpha_dual,
2257 alpha_pr: d.info_alpha_primal,
2258 ls_trials: d.info_ls_count,
2259 }
2260 }
2261
2262 /// Fire `TNLP::intermediate_callback` if a TNLP handle and NLP
2263 /// handle are installed. Wraps the call in an [`IntermediateContext`]
2264 /// guard so downstream inspector entry points (the C API's
2265 /// `GetIpoptCurrent*`) can read live state for the duration. Returns
2266 /// `true` to continue, `false` if the user requested termination.
2267 fn fire_intermediate(&self) -> bool {
2268 let timing = self.data.borrow().timing.clone();
2269 let _guard = timing.fire_intermediate.guard();
2270 let Some(tnlp) = self.tnlp.as_ref() else {
2271 return true;
2272 };
2273 let Some(nlp) = self.nlp.as_ref() else {
2274 return true;
2275 };
2276 let stats = self.build_iter_stats();
2277 // Record exactly what the callback is about to receive, so a losing
2278 // retry can re-emit the winning attempt's final row (pounce#870).
2279 if let Some(sink) = self.last_iter_stats_sink.as_ref() {
2280 *sink.borrow_mut() = Some(stats);
2281 }
2282 // The live-inspector context is for iterates of the *user's*
2283 // problem only. During restoration the iterate belongs to the
2284 // feasibility subproblem and is not even the same length, so no
2285 // context is installed and `GetIpoptCurrent*` reports no data
2286 // for the duration. See `fires_as_restoration`.
2287 let _guard = (!self.fires_as_restoration).then(|| {
2288 CtxGuard::install(IntermediateContext {
2289 data: Rc::clone(&self.data),
2290 cq: Rc::clone(&self.cq),
2291 nlp: Rc::clone(nlp),
2292 })
2293 });
2294 tnlp.borrow_mut().intermediate_callback(
2295 stats,
2296 &TnlpIpoptData::default(),
2297 &TnlpIpoptCq::default(),
2298 )
2299 }
2300
2301 pub fn with_search_dir(mut self, sd: PdSearchDirCalc) -> Self {
2302 self.search_dir = Some(sd);
2303 self
2304 }
2305
2306 pub fn with_restoration(mut self, resto: Box<dyn RestorationPhase>) -> Self {
2307 self.restoration = Some(resto);
2308 self
2309 }
2310
2311 /// Install the shared diagnostics state. The state is propagated
2312 /// to the augmented-system solver at the top of [`Self::optimize`]
2313 /// so dump sites can consult per-iter gating.
2314 pub fn with_diagnostics(mut self, diag: Rc<DiagnosticsState>) -> Self {
2315 self.diagnostics = Some(diag);
2316 self
2317 }
2318
2319 /// Install an interactive debugger hook. Fired at each checkpoint
2320 /// in [`Self::optimize`]; returning [`crate::debug::DebugAction::Stop`]
2321 /// ends the solve with `SolverReturn::UserRequestedStop`.
2322 pub fn with_debug_hook(mut self, hook: Rc<RefCell<dyn crate::debug::DebugHook>>) -> Self {
2323 self.debug = Some(hook);
2324 self
2325 }
2326
2327 /// Shared handle to the installed debugger, if any — used to forward
2328 /// it into the restoration inner IPM.
2329 pub fn debug_hook(&self) -> Option<Rc<RefCell<dyn crate::debug::DebugHook>>> {
2330 self.debug.as_ref().map(Rc::clone)
2331 }
2332
2333 /// Fire the debugger hook (if installed) at `cp`, building a live
2334 /// [`crate::debug::DebugCtx`] over cheap handle clones. Returns the
2335 /// requested action, defaulting to `Resume` when no hook is set.
2336 fn fire_debug(&mut self, cp: crate::debug::Checkpoint) -> crate::debug::DebugAction {
2337 use crate::debug::{DebugAction, DebugCtx};
2338 // Clone the Rc so the hook borrow is released before we touch
2339 // `self.bundle` to apply any live option changes below.
2340 let Some(hook) = self.debug.as_ref().map(Rc::clone) else {
2341 return DebugAction::Resume;
2342 };
2343 let mut ctx = DebugCtx::new(Rc::clone(&self.data), Rc::clone(&self.cq), cp);
2344 let action = hook.borrow_mut().at_checkpoint(&mut ctx);
2345 // Drain any tolerances the hook asked to hot-swap and write them
2346 // into the live convergence-check policy, so the next iteration's
2347 // termination test uses the new value (no `resolve` needed).
2348 for (name, value) in ctx.take_live_tolerances() {
2349 self.bundle.conv_check.set_tolerance(&name, value);
2350 }
2351 action
2352 }
2353
2354 /// Run the restoration phase, bracketed by the `PreRestoration` /
2355 /// `PostRestoration` debug checkpoints so a debugger can inspect the
2356 /// iterate just before entry and just after exit. With no debugger
2357 /// installed this is exactly `invoke_restoration()`.
2358 fn invoke_restoration_debugged(&mut self) -> IterateOutcome {
2359 if let Some(o) = self.debug_stop(crate::debug::Checkpoint::PreRestoration) {
2360 return o;
2361 }
2362 let outcome = self.invoke_restoration();
2363 if let Some(o) = self.debug_stop(crate::debug::Checkpoint::PostRestoration) {
2364 return o;
2365 }
2366 outcome
2367 }
2368
2369 /// Fire a sub-iteration checkpoint from inside [`Self::iterate`].
2370 /// Returns `Some(Terminate(UserRequestedStop))` if the debugger asked
2371 /// to stop, so the caller can `return` it; `None` to continue.
2372 fn debug_stop(&mut self, cp: crate::debug::Checkpoint) -> Option<IterateOutcome> {
2373 if self.debug.is_none() {
2374 return None;
2375 }
2376 if self.fire_debug(cp) == crate::debug::DebugAction::Stop {
2377 Some(IterateOutcome::Terminate(SolverReturn::UserRequestedStop))
2378 } else {
2379 None
2380 }
2381 }
2382
2383 /// Fire the terminal post-mortem checkpoint (if a debugger is set),
2384 /// carrying the solve outcome so the hook can decide whether to pause
2385 /// at the final iterate. The action is advisory — the loop returns
2386 /// `result` regardless — so the hook just gets a last look.
2387 fn fire_debug_terminal(&mut self, result: SolverReturn) {
2388 use crate::debug::{Checkpoint, DebugCtx};
2389 let Some(hook) = self.debug.as_ref() else {
2390 return;
2391 };
2392 let mut ctx = DebugCtx::new(
2393 Rc::clone(&self.data),
2394 Rc::clone(&self.cq),
2395 Checkpoint::Terminated,
2396 )
2397 .with_status(format!("{result:?}"));
2398 let _ = hook.borrow_mut().at_checkpoint(&mut ctx);
2399 }
2400
2401 /// Cheap mid-iteration time-budget check (pounce#242). Returns the
2402 /// terminal [`SolverReturn`] when the shared [`Deadline`] has been
2403 /// crossed, so the caller can bail *within* an iteration — after the
2404 /// KKT factorization, before the line search — rather than only at the
2405 /// next outer-iteration convergence check. Returns `None` (never
2406 /// terminating) when no deadline is installed, keeping the
2407 /// direct-driver / unit-test paths on their `overall_alg`-based gate.
2408 /// A `None` here is not "no budget" but "check it at the coarse site".
2409 fn deadline_status(&self) -> Option<SolverReturn> {
2410 let d = self.data.borrow();
2411 let kind = d.deadline.as_ref()?.exceeded()?;
2412 Some(match kind {
2413 pounce_common::timing::DeadlineKind::Cpu => SolverReturn::CpuTimeExceeded,
2414 pounce_common::timing::DeadlineKind::Wall => SolverReturn::WallTimeExceeded,
2415 })
2416 }
2417
2418 /// One iteration body — port of `Optimize()`'s inner loop.
2419 /// Returns either `Continue` to keep iterating or a terminal
2420 /// [`SolverReturn`] mirroring upstream's exception → return-code
2421 /// translation table (see `MAIN_LOOP.md` §"Exception mapping").
2422 fn iterate(&mut self) -> IterateOutcome {
2423 // Shared timing accumulator — cheap Rc clone so each phase can
2424 // bump its own counter without re-borrowing `data`.
2425 let timing = self.data.borrow().timing.clone();
2426
2427 // Per-iteration span so every event emitted in this body (the
2428 // structured iteration record, restoration/linear-solve spans)
2429 // is tagged with the iteration index.
2430 let _iter_span =
2431 tracing::info_span!("iteration", iter = self.data.borrow().iter_count).entered();
2432
2433 // 1. Output iteration row. Header every 10 iters; the row itself
2434 // is built plain by the strategy (so the column widths stay
2435 // exact and unit-testable) and wrapped in a tiger/rust style
2436 // at the print site (pounce#71). `anstream::stdout()` strips
2437 // the escapes automatically when stdout is redirected or
2438 // `NO_COLOR` is set, so non-TTY output is plain text.
2439 //
2440 // Print BEFORE `reset_info` so the row reflects the accepted
2441 // step from the previous iteration (alphas, ls count,
2442 // alpha_char), matching upstream's `IpIpoptAlgorithm::Optimize`
2443 // ordering.
2444 timing.output_iteration.start();
2445 self.bundle.iter_output.write_output();
2446 if self.print_iter_output {
2447 use std::io::Write as _;
2448 let (iter_count, alpha_pr, alpha_char) = {
2449 let d = self.data.borrow();
2450 (d.iter_count, d.info_alpha_primal, d.info_alpha_primal_char)
2451 };
2452 let row = self.bundle.iter_output.format_row(&self.data, &self.cq);
2453 // Iteration 0 is the initial point — no step has been taken
2454 // yet, so `alpha_primal` is 0; treat it as a full step
2455 // (neutral black) rather than a stalling alarm (red).
2456 let style_alpha = if iter_count == 0 { 1.0 } else { alpha_pr };
2457 let style = pounce_common::style::iteration_row_style(style_alpha, alpha_char);
2458 let mut out = anstream::stdout();
2459 // Write errors (e.g. a closed pipe / `head` on the output)
2460 // are deliberately ignored: a vanished terminal must not
2461 // panic the solver, unlike the old `println!`.
2462 if iter_count % 10 == 0 {
2463 let _ = write!(out, "{}", crate::output::orig::OrigIterationOutput::HEADER);
2464 }
2465 let _ = writeln!(out, "{}{}{}", style.render(), row, style.render_reset());
2466 }
2467 timing.output_iteration.end();
2468
2469 // Structured per-iteration event (pounce#71) — the single source
2470 // of truth for the per-iteration trajectory. The JSON log sink
2471 // and the solve-report collector
2472 // (`pounce_observability::IterCollectorLayer`) both derive from
2473 // it. The text console layer filters this target out (its human
2474 // form is the colored table above).
2475 //
2476 // Skipped entirely when nothing consumes it (no iter-history
2477 // capture active and JSON logging off) so the default run pays
2478 // no per-iteration field-evaluation / allocation cost.
2479 if pounce_observability::iteration_event_wanted() {
2480 let d = self.data.borrow();
2481 let c = self.cq.borrow();
2482 let alpha_char = d.info_alpha_primal_char;
2483 let alpha_char_s = alpha_char.to_string();
2484 let d_norm = match &d.delta {
2485 Some(delta) => delta.x.amax().max(delta.s.amax()),
2486 None => 0.0,
2487 };
2488 tracing::info!(
2489 target: pounce_observability::ITER_TARGET,
2490 iter = d.iter_count,
2491 objective = c.unscaled_curr_f(),
2492 inf_pr = c.curr_primal_infeasibility_max(),
2493 inf_du = c.curr_dual_infeasibility_max(),
2494 mu = d.curr_mu,
2495 d_norm = d_norm,
2496 regularization = d.info_regu_x,
2497 alpha_dual = d.info_alpha_dual,
2498 alpha_primal = d.info_alpha_primal,
2499 ls_trials = d.info_ls_count,
2500 alpha_char = alpha_char_s.as_str(),
2501 resto_kind = pounce_common::style::resto_kind_str(alpha_char),
2502 );
2503 }
2504
2505 // Reset per-iteration info on data (after printing previous
2506 // iter's accepted-step info; before the next line search).
2507 self.data.borrow_mut().reset_info();
2508
2509 // 2. Convergence check.
2510 timing.check_convergence.start();
2511 let nlp_err = self.cq.borrow().curr_nlp_error();
2512 let iter_count = self.data.borrow().iter_count;
2513 if !nlp_err.is_finite() {
2514 timing.check_convergence.end();
2515 return IterateOutcome::Terminate(SolverReturn::InvalidNumberDetected);
2516 }
2517 // gh #534 progress history. One sample per outer iteration, recorded
2518 // before any of the guards below can divert, so the samples the
2519 // restoration-decline test reads are consecutive by construction.
2520 self.note_nlp_err(nlp_err);
2521 // Divergence guard — port of upstream `IpIpoptAlg.cpp` post-
2522 // AcceptTrialPoint check. When `max_i |x_i|` exceeds the
2523 // registered `diverging_iterates_tol` (default `1e20`), exit
2524 // cleanly with `DivergingIterates` rather than spiralling into
2525 // a degenerate restoration whose inner sub-NLP can't recover
2526 // (MESH: orig `f` already at -3.6e33 by iter 90, restoration
2527 // entered too late to bound `x`).
2528 //
2529 // A large `|x|` alone does not prove unboundedness, though:
2530 // `DivergingIterates` is Ipopt's *unboundedness* signal (it maps
2531 // to the AMPL 300 "unbounded" range), and under severe objective
2532 // ill-scaling the normal-mode IPM can take a large but transient
2533 // excursion on a problem that is bounded below with a finite
2534 // optimum (issue #248: MINLPLib `jit1`). Only conclude divergence
2535 // when the growth is *structurally* consistent with an unbounded
2536 // feasible region — some over-threshold component heading toward a
2537 // side with no finite bound. If every large component is pinned by
2538 // a finite bound (in particular, all variables boxed), the growth
2539 // is a scaling artifact, so let the normal convergence / iteration
2540 // machinery return the best iterate instead of a spurious
2541 // `Unbounded`.
2542 // Evaluate the structural check under an immutable borrow, then
2543 // update the persistence state and take the verdict separately so
2544 // the mutable field updates don't clash with the `data` borrow.
2545 // Two independent unboundedness paths share this block:
2546 // * the `diverging_iterates_tol` (`1e20`) magnitude guard, gated on
2547 // the free-variable structural check + geometric-growth streak
2548 // (issues #248 / #252); and
2549 // * the #285 recession-ray path — a *checked proof*, active from a
2550 // far lower magnitude floor, that catches a genuine recession ray
2551 // in `null(A_eq)` over free variables whose `|x|` grows only
2552 // linearly and so never reaches `1e20` within `max_iter`.
2553 //
2554 // The whole block is skipped while the line search is inside a
2555 // watchdog trial sequence (gh #818 review). A `'w'` iterate is
2556 // provisional by construction: the acceptor *rejected* it, the
2557 // filter was not augmented, and the line search is holding a
2558 // snapshot it will revert to within `watchdog_trial_iter_max`
2559 // (default 3) iterations. Reporting `DivergingIterates` there
2560 // throws that snapshot away and calls a problem unbounded on a
2561 // point the algorithm had already decided not to keep. This is
2562 // the same false positive `DIVERGENCE_PERSIST_ITERS` was
2563 // introduced for — a transient excursion that peaks and recedes —
2564 // except that a watchdog excursion recedes *by construction*, and
2565 // `DIVERGENCE_ABS_RUNAWAY` bypasses the streak, so the streak
2566 // alone does not cover it. Skipping rather than resetting leaves
2567 // the streak state untouched, so a watchdog gamble in the middle
2568 // of a genuine ray neither accumulates nor erases evidence; a real
2569 // divergence is reported at most three iterations later, from a
2570 // committed iterate. The deferral is capped at
2571 // `WATCHDOG_DEFER_MAX` consecutive iterations so it can never be
2572 // held open by a stale `in_watchdog` — see
2573 // `Self::watchdog_defer_streak` for the path that leaks one.
2574 //
2575 // Measured on the gh #818 quadratic at `n = 8`, cond `1e12`,
2576 // `limited_memory_max_history 6`: the solve reaches iteration 352
2577 // on the *third* watchdog trial of a sequence, at `|x|_inf ~ 5e22`
2578 // with the objective climbing to `+2.0e45` — the opposite of the
2579 // `f -> -inf` that `DivergingIterates` is supposed to mean — one
2580 // iteration before `StopWatchDog` would have restored an iterate
2581 // at `f = 2.26e4`.
2582 let in_watchdog = self.bundle.line_search.in_watchdog()
2583 && self.watchdog_defer_streak < Self::WATCHDOG_DEFER_MAX;
2584 if in_watchdog {
2585 self.watchdog_defer_streak += 1;
2586 } else {
2587 self.watchdog_defer_streak = 0;
2588 }
2589 let (amax, structural_free, is_ray) = {
2590 let data = self.data.borrow();
2591 match data.curr.as_ref() {
2592 Some(curr) if !in_watchdog => {
2593 let amax = curr.x.amax();
2594 let structural = amax > self.diverging_iterates_tol
2595 && self.divergence_is_true_unboundedness(&*curr.x);
2596 let is_ray = amax > Self::RECESSION_MIN_NORM
2597 && self.curr_is_recession_ray(&*curr.x, amax);
2598 (Some(amax), structural, is_ray)
2599 }
2600 _ => (None, false, false),
2601 }
2602 };
2603 // Evaluate the (scaled) objective only while a structural divergence
2604 // is live — the streak's descent gate needs it, and it costs an
2605 // objective evaluation, so skip it on the common non-diverging path.
2606 let curr_f = structural_free.then(|| self.cq.borrow().curr_f());
2607 // Evaluate both streak updates (no short-circuit) so each keeps its
2608 // state current, then fire if either concludes divergence.
2609 // ... but only when the streaks are actually being fed. Inside a
2610 // watchdog sequence `amax` is `None`, and running the updates
2611 // would reset both streaks on a point they never saw.
2612 let (fire_magnitude, fire_recession) = if in_watchdog {
2613 (false, false)
2614 } else {
2615 (
2616 self.update_divergence_verdict(amax, structural_free, curr_f),
2617 self.update_recession_verdict(amax.unwrap_or(0.0), is_ray),
2618 )
2619 };
2620 if fire_magnitude || fire_recession {
2621 if fire_recession && !fire_magnitude {
2622 tracing::debug!(target: "pounce::algorithm",
2623 "[POUNCE] recession-ray guard fired at iter {} (|x|_inf={:.2e}); \
2624 reporting DivergingIterates (pounce#285).",
2625 self.data.borrow().iter_count,
2626 amax.unwrap_or(f64::NAN),
2627 );
2628 }
2629 timing.check_convergence.end();
2630 return IterateOutcome::Terminate(SolverReturn::DivergingIterates);
2631 }
2632 // Dual-divergence guard (pounce#246). The primal guard above only
2633 // catches `|x|` blowing up; a bad warm start can instead send the
2634 // *dual* infeasibility diverging — `inf_du` 1 -> 1e14, the inertia
2635 // regularization -> 1e14, the barrier parameter frozen, full steps
2636 // still accepted by the filter because primal feasibility inches
2637 // down — while `|x|` stays bounded. `diverging_iterates_tol` never
2638 // trips, restoration is never entered, and the solve grinds in
2639 // ever-more-ill-conditioned KKT factorizations that each take
2640 // seconds (the emfl050 warm-start overshoot: one 3.8 s factorization
2641 // per iteration, forever). Detect a sustained streak of growing dual
2642 // infeasibility in the elevated regime and route to restoration —
2643 // the same recovery the least-square-multiplier init path reaches on
2644 // its own — before the factorizations start choking. Gated on a
2645 // large absolute `inf_du` so a well-behaved solve whose dual
2646 // residual transiently rises (then falls) is never diverted:
2647 // restoration is a heavier hammer than the guard should swing at a
2648 // merely-bumpy-but-converging iterate.
2649 //
2650 // OFF BY DEFAULT (pounce#250 follow-up). The emfl050 overshoot above is
2651 // how this was justified, and it did not reproduce: that measurement was
2652 // caller-side JAX compilation, and the build predating the guard solves
2653 // both emfl050 instances to the same optimum in the same time. What is
2654 // left is an effect on four of 1284 MINLPLib models that is knife-edge
2655 // and non-monotone in `dual_diverging_streak` — a better local optimum on
2656 // deb7/deb9 at exactly 15, and pooling_rt2stp turning Solve_Succeeded
2657 // into Maximum_Iterations_Exceeded at 10 and 15 only. Kept because it
2658 // does help when it helps, but not imposed. Full account in the option
2659 // help (`upstream_options.rs`).
2660 //
2661 // Two things to know before changing this:
2662 //
2663 // * `curr_dual_infeasibility_max` is the RAW ‖∇L‖∞, not divided by the
2664 // `s_d` optimality scaling the convergence check applies, and this runs
2665 // *before* `conv_check`. So the thresholds below are not on the same
2666 // quantity the solver's own tolerances are on, and the claim that they
2667 // are scale-robust holds only while `nlp_scaling_method != none`. No
2668 // exploit is known; the margin is thinner than it looks.
2669 // * The `DivergingIterates` fallback at the end is unreachable from every
2670 // shipped front end — CLI, pounce-py and cinterface all wire a
2671 // restoration provider, so the guard can only ever route to
2672 // restoration. Do not assume it is dead code and delete the provider
2673 // check; do not assume it is live and rely on the status either.
2674 // gh#884 — the dual-divergence-at-a-settled-primal signature.
2675 //
2676 // Four conjuncts, all required **at the same iterate**. That is not
2677 // stylistic: measured on the corpus, `deb7` on the L-BFGS leg reaches
2678 // a settled step of `5.9e-13` at an unscaled dual of `8.6e-7`, and its
2679 // *maximum* unscaled dual over settled iterates is `7.2e6`. A
2680 // formulation that took the minimum step and the maximum dual over a
2681 // window would fire on it; this one does not.
2682 //
2683 // What it is for: at a biactive complementarity pair the product row's
2684 // multiplier is arbitrary rather than determined, so it runs away, `s_d`
2685 // grows with it, and the `s_d`-normalised aggregate the convergence
2686 // check reads stays clean while the honest residual is `7.9e+04`. The
2687 // one quantity that feedback loop cannot fake is the step: with the
2688 // primal settled and the duals diverging the direction collapses. So
2689 // this reads the *raw unscaled* residual, and only at iterates where
2690 // the algorithm has demonstrably stopped moving with the primal solved.
2691 // A multiplier of `1e9` on a `1e-9` gradient cannot satisfy it, because
2692 // nothing here is normalised by a multiplier — which is gh#884's
2693 // criterion 2.
2694 //
2695 // It never changes a verdict. All it does is authorize the application
2696 // layer to spend a second solve; see `run_with_dual_divergence_retry`.
2697 if !self.dual_divergence_signature && self.dual_divergence_retry_step_tol > 0.0 {
2698 let cq = self.cq.borrow();
2699 let has_rows = cq.curr_c().dim() > 0 || cq.curr_d().dim() > 0;
2700 if has_rows && self.last_step_rel <= self.dual_divergence_retry_step_tol {
2701 let inf_pr = cq.curr_primal_infeasibility_max();
2702 let unscaled_du = cq.curr_unscaled_dual_infeasibility_max();
2703 if inf_pr <= DUAL_DIV_RETRY_PRIMAL_TOL
2704 && unscaled_du >= self.dual_divergence_retry_du_floor
2705 && unscaled_du.is_finite()
2706 {
2707 self.dual_divergence_signature = true;
2708 tracing::debug!(target: "pounce::algorithm",
2709 "[POUNCE] gh#884 dual-divergence signature at iter {}: \
2710 step_rel={:.2e} inf_pr={:.2e} unscaled_inf_du={:.2e}; \
2711 a cold retry is authorized if this solve does not succeed.",
2712 self.data.borrow().iter_count,
2713 self.last_step_rel, inf_pr, unscaled_du,
2714 );
2715 }
2716 }
2717 }
2718
2719 if self.dual_diverging_streak > 0 {
2720 let inf_du = self.cq.borrow().curr_dual_infeasibility_max();
2721 if inf_du.is_finite() && inf_du > self.dual_inf_prev && inf_du > DUAL_DIV_COUNT_FLOOR {
2722 self.dual_growth_streak += 1;
2723 } else {
2724 self.dual_growth_streak = 0;
2725 }
2726 self.dual_inf_prev = inf_du;
2727 if self.dual_growth_streak >= self.dual_diverging_streak && inf_du > DUAL_DIV_FIRE_TOL {
2728 self.dual_growth_streak = 0;
2729 self.dual_inf_prev = 0.0;
2730 // Arm the "never worse off" bookkeeping for the bet about to be
2731 // placed (pounce#250 follow-up).
2732 self.dual_guard_fired = true;
2733 timing.check_convergence.end();
2734 tracing::debug!(target: "pounce::algorithm",
2735 "[POUNCE] dual-divergence guard fired at iter {} (inf_du={:.2e}); \
2736 routing to restoration (pounce#246).",
2737 self.data.borrow().iter_count, inf_du,
2738 );
2739 if self.restoration.is_some() {
2740 return self.invoke_restoration_debugged();
2741 }
2742 return IterateOutcome::Terminate(SolverReturn::DivergingIterates);
2743 }
2744 }
2745 let conv_status = self
2746 .bundle
2747 .conv_check
2748 .check_convergence_with_state(nlp_err, iter_count, &self.data, &self.cq);
2749 // Snapshot the *first* refused certificate. Baseline would have stopped
2750 // and returned exactly this point, so keeping it — and only it — is what
2751 // makes the "never worse" guarantee exact rather than approximate. A
2752 // later refusal is also a valid certificate but not necessarily a
2753 // better one, so it must not overwrite this.
2754 if !self.vetoed_seen && self.bundle.conv_check.certificate_vetoed() {
2755 // Latch on *seeing* the refusal, not on the snapshot being present:
2756 // the veto flag is sticky, so keying off `vetoed.is_none()` would
2757 // let a failed capture be completed at a later, arbitrary iterate.
2758 // See `IpoptAlgorithm::vetoed_seen`.
2759 self.vetoed_seen = true;
2760 self.vetoed = self.snapshot_current(iter_count);
2761 }
2762 if !self.vetoed_acceptable_seen && self.bundle.conv_check.acceptable_certificate_vetoed() {
2763 self.vetoed_acceptable_seen = true;
2764 self.vetoed_acceptable = self.snapshot_current(iter_count);
2765 }
2766 // gh #695: a successful verdict asserts the convergence test passed;
2767 // reporting one alongside a non-finite objective is self-contradictory,
2768 // and a caller that gates on `status` and then reads `obj_val` silently
2769 // receives `NaN`. The convergence test cannot notice on its own — it
2770 // reads gradients, residuals and complementarity, never the objective
2771 // *value* — so with finite derivatives and a satisfied equality the KKT
2772 // residuals are genuinely small and the solve converges on a point
2773 // whose objective is not a number.
2774 //
2775 // Only the *equality*-constrained shape reached here: the unconstrained
2776 // and bounds-only shapes fail in the step computation and the
2777 // inequality-constrained one already trips an invalid-number guard, so
2778 // this closes the one column of that matrix that was reporting success.
2779 // gh #292 closed the NaN-*gradient* hole and recorded `f`-returns-NaN as
2780 // the safe contrast case, which held for the shapes it exercised and not
2781 // for this one.
2782 //
2783 // `Invalid_Number_Detected` is the status Ipopt's `Eval_f` gives a
2784 // non-finite objective, which POUNCE's own inequality-constrained shape
2785 // already agreed with. The same check already guards the restoration
2786 // near-feasible exit below, for the same reason on a different path
2787 // (CUTE `himmelbj`); this extends it to the ordinary convergence exit
2788 // rather than adding a second rule.
2789 let converged_success = matches!(
2790 conv_status,
2791 ConvergenceStatus::Converged | ConvergenceStatus::ConvergedToAcceptable
2792 );
2793 if converged_success && !self.cq.borrow().curr_f().is_finite() {
2794 timing.check_convergence.end();
2795 return IterateOutcome::Terminate(SolverReturn::InvalidNumberDetected);
2796 }
2797 match conv_status {
2798 ConvergenceStatus::Continue => {}
2799 ConvergenceStatus::Converged => {
2800 timing.check_convergence.end();
2801 // gh #797: first-order stationarity is not a local minimum on a
2802 // nonconvex model. If the reduced Hessian here is indefinite,
2803 // leave along a direction of negative curvature instead of
2804 // certifying a constrained maximum — bounded in cost, and
2805 // floored at this very point.
2806 if self.try_neg_curv_escape(iter_count) {
2807 return IterateOutcome::Continue;
2808 }
2809 return IterateOutcome::Terminate(SolverReturn::Success);
2810 }
2811 ConvergenceStatus::ConvergedToAcceptable => {
2812 timing.check_convergence.end();
2813 return IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint);
2814 }
2815 ConvergenceStatus::MaxIterExceeded => {
2816 timing.check_convergence.end();
2817 return IterateOutcome::Terminate(SolverReturn::MaxiterExceeded);
2818 }
2819 ConvergenceStatus::CpuTimeExceeded => {
2820 timing.check_convergence.end();
2821 return IterateOutcome::Terminate(SolverReturn::CpuTimeExceeded);
2822 }
2823 ConvergenceStatus::WallTimeExceeded => {
2824 timing.check_convergence.end();
2825 return IterateOutcome::Terminate(SolverReturn::WallTimeExceeded);
2826 }
2827 ConvergenceStatus::LocallyInfeasible => {
2828 timing.check_convergence.end();
2829 // gh #505: consult the acceptable-point stash, as the
2830 // restoration-cycle exits below already do (`:2686`, `:2716`,
2831 // both via `terminate_acceptable_or`). This arm used to return
2832 // without it, so a solve that had passed through an acceptable
2833 // iterate — stashed, un-vetoed, sitting there as a rollback
2834 // target — discarded it and surfaced the hard verdict instead.
2835 // The stashing code sits *after* this match, so the firing
2836 // iteration returns before it would even consider stashing;
2837 // only iterates from earlier in the solve are on offer, which
2838 // is exactly what a rollback target is.
2839 //
2840 // This is about what to *return* once the verdict has fired,
2841 // not about when it fires. Whether the rapid detector should
2842 // have convicted this point at all is a separate question,
2843 // answered by the violation floor in `OptErrorConvCheck`
2844 // (gh #519).
2845 //
2846 // Inert on genuinely infeasible models: `store_acceptable_point`
2847 // is gated on `current_is_acceptable_with_state`, which requires
2848 // `acceptable_tol` *and* the unscaled violation against
2849 // `acceptable_constr_viol_tol`, and the scale-relative veto
2850 // blocks the stash outright for a row violated relative to its
2851 // own magnitude. Nothing is stashed on such a model, so
2852 // `terminate_acceptable_or` falls through to the verdict
2853 // unchanged. `infeasible_models_are_never_reported_solved`
2854 // (`infeasible_status_tol_invariance.rs`) is the standing guard.
2855 //
2856 // That inertness rests entirely on the stash gate having no
2857 // iteration budget (gh #693). While it carried the certificate
2858 // veto's `VETO_MAX_EXTRA_ITERS`, a solve that took more than 60
2859 // blocked iterations to convict stashed the very point the veto
2860 // exists to reject and rolled back to it here — an infeasible
2861 // model reported `Solved_To_Acceptable_Level`. See
2862 // `issue_693_relative_infeasibility_stash.rs`.
2863 return self.terminate_local_infeasibility();
2864 }
2865 ConvergenceStatus::Failed => {
2866 timing.check_convergence.end();
2867 return IterateOutcome::Terminate(SolverReturn::InternalError);
2868 }
2869 }
2870
2871 // Stash the iterate if it satisfies the per-component
2872 // `acceptable_*_tol` triplet. Mirrors upstream
2873 // `IpBacktrackingLineSearch.cpp:282-289` — checked at the top
2874 // of every line-search call so the most recent acceptable
2875 // iterate is always available as a rollback target if
2876 // restoration later fails. The recorder feeds
2877 // `acceptable_obj_change_tol`'s stability cross-check on
2878 // subsequent iterates.
2879 if self
2880 .bundle
2881 .conv_check
2882 .current_is_acceptable_with_state(nlp_err, &self.data, &self.cq)
2883 {
2884 self.store_acceptable_point();
2885 let curr_f = self.cq.borrow().curr_f();
2886 self.bundle.conv_check.set_curr_acceptable_obj(curr_f);
2887 // pounce#250 follow-up: keep the *best* acceptable iterate, not just
2888 // the latest. `store_acceptable_point` overwrites unconditionally,
2889 // so once the dual-divergence guard diverts a solve the rollback
2890 // target drifts to whatever the diverted run last touched — which
2891 // may be far worse than a point already in hand. Recorded on every
2892 // acceptable iterate (including before any diversion) and read only
2893 // when the guard fired; see `honour_best_acceptable_after_dual_guard`.
2894 self.record_best_acceptable(curr_f);
2895 }
2896 timing.check_convergence.end();
2897
2898 // gh #534: a deferred restoration decline is a bet with a deadline.
2899 // Checked *after* the convergence check, so a strict certificate the
2900 // continuation reached in the meantime wins the bet rather than being
2901 // pre-empted by its own expiry; and after the acceptable stash, so a
2902 // continuation that ended somewhere better has been recorded before the
2903 // floor comparison reads it.
2904 if self.decline_deadline_iter.is_some_and(|d| iter_count > d) {
2905 return self.terminate_at_decline_floor();
2906 }
2907
2908 // gh #797: the negative-curvature escape is the same kind of bet, and
2909 // its deadline is checked in the same place and for the same reason —
2910 // after the convergence check, so a certificate the continuation
2911 // reached wins rather than being pre-empted by its own expiry.
2912 if self.neg_curv_deadline_iter.is_some_and(|d| iter_count > d) {
2913 return self.terminate_at_neg_curv_floor();
2914 }
2915
2916 // 3. Hessian update. Must run BEFORE `update_barrier_parameter`
2917 // so the adaptive-μ oracles (probing, quality-function) drive
2918 // their affine/centering solves against `W(curr_N)`, not the
2919 // stale `W(curr_{N-1})` left in `data.w` by the previous iter's
2920 // tail-end Hessian update. Upstream calls `UpdateHessian()`
2921 // first in every main-loop body (`IpIpoptAlg.cpp:386`); pounce
2922 // previously reordered this to the tail, which made iters 1+
2923 // pick μ from the prior iterate's Hessian on adaptive-mu +
2924 // quality-function — visible on CRESC50 as a catastrophic
2925 // early-iter divergence (theta=5.8e5 by iter 61 vs upstream
2926 // never entering restoration).
2927 timing.update_hessian.start();
2928 let _ = self.bundle.hess.update_hessian(&self.data, &self.cq);
2929 timing.update_hessian.end();
2930
2931 // 4. Barrier parameter. Pass nlp + search_dir through so the
2932 // adaptive μ oracles (probing, quality-function) can drive
2933 // their own affine-step solves; monotone ignores them.
2934 // Snapshot the tiny-step flag (set by the previous iteration's
2935 // tiny-step branch) and the entry mu — if μ can't reduce while
2936 // the flag is on, upstream `IpMonotoneMuUpdate.cpp:158-161`
2937 // throws TINY_STEP_DETECTED → STOP_AT_TINY_STEP, which we
2938 // realise as a clean termination here.
2939 //
2940 // Both updates terminate, by different routes (pounce#512).
2941 // Monotone has one throw site covering its whole update, so the
2942 // μ-unchanged comparison below reconstructs it exactly, gated on
2943 // `terminates_on_tiny_step()`. `IpAdaptiveMuUpdate.cpp` throws at
2944 // two specific sites (`:330-333`, `:377-380`) and merely fixes μ
2945 // and keeps iterating elsewhere, so the comparison would over-fire
2946 // there — on the no-bounds short-circuit, which returns before
2947 // upstream even reads the flag, and on a free-mode oracle that
2948 // re-picks the current μ. The adaptive update therefore raises
2949 // `request_tiny_step_stop` at its own two sites and opts out of
2950 // the comparison. (An earlier comment here claimed the adaptive
2951 // update never self-terminates; it does — `force_no_progress` is
2952 // what happens on the iterations that do *not* throw.)
2953 timing.update_barrier_parameter.start();
2954 let tiny_at_entry = self.data.borrow().tiny_step_flag;
2955 let mu_before = self.data.borrow().curr_mu;
2956 let mu_terminates_on_tiny = self.bundle.mu_update.terminates_on_tiny_step();
2957 let next_mu = self.bundle.mu_update.update_barrier_parameter(
2958 &self.data,
2959 &self.cq,
2960 self.nlp.as_ref(),
2961 self.search_dir.as_mut(),
2962 );
2963 self.data.borrow_mut().curr_mu = next_mu;
2964 timing.update_barrier_parameter.end();
2965
2966 // pounce#510 — line-search reset. Upstream's μ updates own a
2967 // `linesearch_` handle and call `linesearch_->Reset()` (which
2968 // clears the filter via `FilterLSAcceptor::Reset`,
2969 // `IpFilterLSAcceptor.cpp:524-532`) at four fixed points:
2970 // `IpAdaptiveMuUpdate.cpp:339` (fixed-mode decrease), `:386`
2971 // (free→fixed switch), `:431` (**unconditionally** on every
2972 // free-mode iteration, μ moved or not), and
2973 // `IpMonotoneMuUpdate.cpp:165` (after a monotone reduction).
2974 // Pounce's `MuUpdate` trait has no line-search handle, so each
2975 // update raises `request_ls_reset` at exactly those points and
2976 // we honour it here — the same plumbing `request_resto` uses
2977 // below.
2978 //
2979 // This used to be inferred from `next_mu != mu_before`. That
2980 // proxy is right for the monotone update but wrong for the
2981 // adaptive one, which resets every free-mode iteration
2982 // regardless of μ: whenever μ stayed numerically put (the
2983 // free-mode endgame, and any iteration after a restoration that
2984 // returns at the same μ) the filter kept entries computed
2985 // against a barrier parameter and an iterate the algorithm had
2986 // already left. On #505's reproducer that rejected every trial
2987 // step from α=2.4e-6 down to 1e-12 on the filter alone and
2988 // forced a spurious restoration.
2989 //
2990 // Both flags are consumed here, but the tiny-step stop is
2991 // answered first: at each of the two adaptive sites that raise
2992 // it, upstream's `TINY_STEP_DETECTED` throw sits *above* the
2993 // reset it would otherwise reach (`cpp:330-333` before `:339`,
2994 // `:377-380` before `:386`), so a terminating iteration never
2995 // resets the line search.
2996 let (tiny_step_stop_requested, ls_reset) = {
2997 let mut d = self.data.borrow_mut();
2998 let flags = (d.request_tiny_step_stop, d.request_ls_reset);
2999 d.request_tiny_step_stop = false;
3000 d.request_ls_reset = false;
3001 flags
3002 };
3003 if tiny_step_stop_requested
3004 || (tiny_at_entry
3005 && mu_terminates_on_tiny
3006 && (next_mu - mu_before).abs() < Number::EPSILON)
3007 {
3008 return IterateOutcome::Terminate(SolverReturn::StopAtTinyStep);
3009 }
3010 if ls_reset {
3011 self.bundle.line_search.reset();
3012 }
3013
3014 // pounce#58 — iterate-quality guard for the probing oracle.
3015 // The μ-update layer sets `request_resto` when the input
3016 // iterate is too corrupted for the probing rule to produce a
3017 // sane μ (see `mu/adaptive.rs` Probing dispatch). Restoration
3018 // re-initialises the multipliers and gives the outer loop a
3019 // clean iterate to continue from. When no restoration phase
3020 // is configured (embedded callers, tests), emit a one-line
3021 // notice and continue with the current μ — the guard has
3022 // already prevented the destabilising 4-order μ jump.
3023 let request_resto = {
3024 let mut d = self.data.borrow_mut();
3025 let f = d.request_resto;
3026 d.request_resto = false;
3027 // `start_with_resto` — upstream's "switch to the feasibility
3028 // restoration phase in the first iteration". It rides the
3029 // same request flag rather than adding a second path into
3030 // restoration, and it is consumed here so it fires exactly
3031 // once: `iter_count` is 0 only on the first pass, and
3032 // restoration advances it.
3033 f || (self.start_with_resto && d.iter_count == 0)
3034 };
3035 if request_resto {
3036 if self.restoration.is_some() {
3037 return self.invoke_restoration_debugged();
3038 } else {
3039 tracing::warn!(target: "pounce::algorithm",
3040 "[POUNCE] probing-oracle iterate-quality guard fired \
3041 at iter {}, but no restoration phase is configured; \
3042 continuing with μ={:.3e}.",
3043 self.data.borrow().iter_count,
3044 next_mu,
3045 );
3046 }
3047 }
3048
3049 // Sub-iteration checkpoint: μ has been updated for this iteration.
3050 if let Some(o) = self.debug_stop(crate::debug::Checkpoint::AfterBarrierUpdate) {
3051 return o;
3052 }
3053
3054 // 4b. `linear_system_scaling=slack-based` — refresh the
3055 // iterate-dependent part of the augmented-system scaling
3056 // before anything factorizes it.
3057 //
3058 // The other scaling methods (Ruiz, MC19) derive their
3059 // factors from the matrix they are handed and need nothing
3060 // from here. Slack-based is a function of the iterate, and
3061 // upstream's method reads `IpCq()` directly; pounce's
3062 // scaling methods live in `pounce-linsol`, below the
3063 // algorithm, so the value is computed here and pushed down.
3064 // Inert unless the option selected it.
3065 self.push_slack_scaling();
3066
3067 // 5. Search direction. Skipped without an NLP + search_dir.
3068 // (Hessian was updated in step 3 above before the barrier-μ
3069 // oracle so that adaptive-μ uses W(curr_N), not stale W.)
3070 if let (Some(nlp), Some(sd)) = (self.nlp.as_ref(), self.search_dir.as_mut()) {
3071 timing.compute_search_direction.start();
3072 // Fields are declared `Empty` and filled by the linear
3073 // solver (matrix size, factor nnz, inertia, ordering — see
3074 // `pounce_feral::record_factor_stats`) and below
3075 // (regularization), so the `linear_solve` span carries the
3076 // KKT-solve characteristics for the JSON sink (pounce#71).
3077 let ls_span = tracing::info_span!(
3078 target: "pounce::linsol",
3079 "linear_solve",
3080 n = tracing::field::Empty,
3081 matrix_nnz = tracing::field::Empty,
3082 factor_nnz = tracing::field::Empty,
3083 inertia_neg = tracing::field::Empty,
3084 fill_ratio = tracing::field::Empty,
3085 ordering = tracing::field::Empty,
3086 regularization = tracing::field::Empty,
3087 );
3088 let ls_enter = ls_span.enter();
3089 let ok = sd.compute_search_direction(&self.data, &self.cq, nlp);
3090 ls_span.record("regularization", self.data.borrow().info_regu_x);
3091 // Within-span marker so the enriched `linear_solve` fields
3092 // (filled by the solver above) surface to the JSON sink at
3093 // debug level; off at the default `info` level.
3094 tracing::debug!(target: "pounce::linsol", "kkt solve complete");
3095 drop(ls_enter);
3096 timing.compute_search_direction.end();
3097 // Fine-grained time-budget gate (pounce#244). The KKT solve now
3098 // checks the shared deadline *between* its major factorization
3099 // steps (inertia correction / iterative refinement) and aborts
3100 // cooperatively when the budget is crossed — bounding the
3101 // overshoot to roughly one factorization instead of the whole
3102 // multi-factorization sweep that #242's post-solve check let run
3103 // to completion. Whether the solve returned a completed step or
3104 // bailed mid-escalation, if the deadline tripped, stop here with
3105 // the time-limit status *before* the `!ok` branch below would
3106 // otherwise route a deadline-aborted solve into restoration.
3107 // `data.curr` is untouched by the step computation, so it still
3108 // holds the last accepted iterate.
3109 if let Some(ret) = self.deadline_status() {
3110 return IterateOutcome::Terminate(ret);
3111 }
3112 if !ok {
3113 // Mirror upstream `IpIpoptAlg.cpp:417-430`: a failed
3114 // step computation puts the algorithm in emergency
3115 // mode, which calls `BacktrackingLineSearch::
3116 // ActivateFallbackMechanism` (cpp:1312-1328). When a
3117 // restoration phase is configured, the next pass of
3118 // `ComputeAcceptableTrialPoint` sees `goto_resto` at
3119 // cpp:299-306 and hands control to restoration. Only
3120 // when neither restoration nor an acceptor-level
3121 // fallback is available does upstream throw
3122 // `STEP_COMPUTATION_FAILED`.
3123 if self.restoration.is_some() {
3124 return self.invoke_restoration_debugged();
3125 }
3126 return IterateOutcome::Terminate(SolverReturn::ErrorInStepComputation);
3127 }
3128 if std::env::var_os("POUNCE_DBG_DELTA").is_some() {
3129 let d = self.data.borrow();
3130 let it = d.iter_count;
3131 if let Some(delta) = d.delta.as_ref() {
3132 use crate::iterates_vector::IteratesVector;
3133 use pounce_linalg::{Vector, compound_vector::CompoundVector};
3134 let dv: &IteratesVector = delta;
3135 tracing::debug!(target: "pounce::algorithm",
3136 "[PN_DELTA] iter={} mu={:.6e} dx_amax={:.6e} ds_amax={:.6e} dyc_amax={:.6e} dyd_amax={:.6e} dzL_amax={:.6e} dzU_amax={:.6e} dvL_amax={:.6e} dvU_amax={:.6e}",
3137 it, d.curr_mu,
3138 dv.x.amax(), dv.s.amax(), dv.y_c.amax(), dv.y_d.amax(),
3139 dv.z_l.amax(), dv.z_u.amax(), dv.v_l.amax(), dv.v_u.amax()
3140 );
3141 if let Some(cdx) = dv.x.as_any().downcast_ref::<CompoundVector>() {
3142 tracing::debug!(target: "pounce::algorithm",
3143 "[PN_DELTA] iter={} dx_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
3144 it,
3145 cdx.comp(0).amax(),
3146 cdx.comp(1).amax(),
3147 cdx.comp(2).amax(),
3148 cdx.comp(3).amax(),
3149 cdx.comp(4).amax(),
3150 );
3151 tracing::debug!(target: "pounce::algorithm",
3152 "[PN_DELTA] iter={} dx_blocks_nrm2: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
3153 it,
3154 cdx.comp(0).nrm2(),
3155 cdx.comp(1).nrm2(),
3156 cdx.comp(2).nrm2(),
3157 cdx.comp(3).nrm2(),
3158 cdx.comp(4).nrm2(),
3159 );
3160 tracing::debug!(target: "pounce::algorithm",
3161 "[PN_DELTA] iter={} dx_blocks_asum: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
3162 it,
3163 cdx.comp(0).asum(),
3164 cdx.comp(1).asum(),
3165 cdx.comp(2).asum(),
3166 cdx.comp(3).asum(),
3167 cdx.comp(4).asum(),
3168 );
3169 // Argmax of orig block via dot with sign — print first few values.
3170 if let Some(dv_orig) =
3171 cdx.comp(0)
3172 .as_any()
3173 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
3174 {
3175 let v = dv_orig.values();
3176 let mut imax = 0usize;
3177 let mut amax = 0.0f64;
3178 for (i, &x) in v.iter().enumerate() {
3179 if x.abs() > amax {
3180 amax = x.abs();
3181 imax = i;
3182 }
3183 }
3184 tracing::debug!(target: "pounce::algorithm",
3185 "[PN_DELTA] iter={} dx_orig argmax: i={} v={:.17e} (n={})",
3186 it,
3187 imax,
3188 v[imax],
3189 v.len()
3190 );
3191 }
3192 }
3193 let p = &d.perturbations;
3194 tracing::debug!(target: "pounce::algorithm",
3195 "[PN_DELTA] iter={} pert: dx={:.6e} ds={:.6e} dc={:.6e} dd={:.6e}",
3196 it, p.delta_x, p.delta_s, p.delta_c, p.delta_d
3197 );
3198 drop(d);
3199 let cq = self.cq.borrow();
3200 let gf = cq.curr_grad_f();
3201 let gl = cq.curr_grad_lag_x();
3202 let cc = cq.curr_c();
3203 let cd = cq.curr_d_minus_s();
3204 let sx = cq.curr_sigma_x();
3205 let ss = cq.curr_sigma_s();
3206 tracing::debug!(target: "pounce::algorithm",
3207 "[PN_DELTA] iter={} cq: gradf_amax={:.6e} gradf_nrm2={:.6e} gradlag_amax={:.6e} gradlag_nrm2={:.6e} c_amax={:.6e} c_nrm2={:.6e} d_amax={:.6e} d_nrm2={:.6e} sigx_amax={:.6e} sigx_nrm2={:.6e} sigs_amax={:.6e} sigs_nrm2={:.6e}",
3208 it,
3209 gf.amax(), gf.nrm2(),
3210 gl.amax(), gl.nrm2(),
3211 cc.amax(), cc.nrm2(),
3212 cd.amax(), cd.nrm2(),
3213 sx.amax(), sx.nrm2(),
3214 ss.amax(), ss.nrm2(),
3215 );
3216 if let Some(cgf) = gf.as_any().downcast_ref::<CompoundVector>() {
3217 tracing::debug!(target: "pounce::algorithm",
3218 "[PN_DELTA] iter={} gradf_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
3219 it,
3220 cgf.comp(0).amax(),
3221 cgf.comp(1).amax(),
3222 cgf.comp(2).amax(),
3223 cgf.comp(3).amax(),
3224 cgf.comp(4).amax(),
3225 );
3226 }
3227 if let Some(curr) = self.data.borrow().curr.clone() {
3228 tracing::debug!(target: "pounce::algorithm",
3229 "[PN_DELTA] iter={} bound_mults: zL_amax={:.6e} zU_amax={:.6e} vL_amax={:.6e} vU_amax={:.6e} s_amax={:.6e} s_nrm2={:.6e} x_amax={:.6e} x_nrm2={:.6e}",
3230 it,
3231 curr.z_l.amax(), curr.z_u.amax(),
3232 curr.v_l.amax(), curr.v_u.amax(),
3233 curr.s.amax(), curr.s.nrm2(),
3234 curr.x.amax(), curr.x.nrm2(),
3235 );
3236 if let Some(czl) = curr.z_l.as_any().downcast_ref::<CompoundVector>() {
3237 tracing::debug!(target: "pounce::algorithm",
3238 "[PN_DELTA] iter={} zL_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
3239 it,
3240 czl.comp(0).amax(),
3241 czl.comp(1).amax(),
3242 czl.comp(2).amax(),
3243 czl.comp(3).amax(),
3244 czl.comp(4).amax(),
3245 );
3246 }
3247 if let Some(czu) = curr.z_u.as_any().downcast_ref::<CompoundVector>() {
3248 tracing::debug!(target: "pounce::algorithm", "[PN_DELTA] iter={} zU_ncomps={}", it, czu.n_comps());
3249 for ic in 0..czu.n_comps() {
3250 tracing::debug!(target: "pounce::algorithm",
3251 "[PN_DELTA] iter={} zU_block[{}]_amax={:.6e} dim={}",
3252 it,
3253 ic,
3254 czu.comp(ic).amax(),
3255 czu.comp(ic).dim()
3256 );
3257 }
3258 }
3259 }
3260 if let Some(csx) = sx.as_any().downcast_ref::<CompoundVector>() {
3261 tracing::debug!(target: "pounce::algorithm",
3262 "[PN_DELTA] iter={} sigx_blocks_amax: orig={:.6e} nc={:.6e} pc={:.6e} nd={:.6e} pd={:.6e}",
3263 it,
3264 csx.comp(0).amax(),
3265 csx.comp(1).amax(),
3266 csx.comp(2).amax(),
3267 csx.comp(3).amax(),
3268 csx.comp(4).amax(),
3269 );
3270 }
3271 drop(cq);
3272 let d = self.data.borrow();
3273 // Also dump curr.x_orig argmax
3274 if let Some(curr) = d.curr.as_ref() {
3275 if let Some(cx) = curr.x.as_any().downcast_ref::<CompoundVector>() {
3276 if let Some(xo) =
3277 cx.comp(0)
3278 .as_any()
3279 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
3280 {
3281 let v = xo.values();
3282 let mut imax = 0usize;
3283 let mut amax = 0.0f64;
3284 for (i, &x) in v.iter().enumerate() {
3285 if x.abs() > amax {
3286 amax = x.abs();
3287 imax = i;
3288 }
3289 }
3290 tracing::debug!(target: "pounce::algorithm", "[PN_DELTA] iter={} curr_x_orig argmax: i={} v={:.17e} amax={:.17e} nrm2={:.17e}",
3291 it, imax, v[imax], xo.amax(), xo.nrm2());
3292 }
3293 }
3294 }
3295 }
3296 }
3297 }
3298
3299 // Capture KKT-factorization diagnostics for the debugger before
3300 // the line search runs. Only when a debugger is installed. The
3301 // inertia/status fields are cheap and always captured; the matrix
3302 // triplets and `LDLᵀ` factor are O(nnz) assemblies, so they're
3303 // captured only while the debugger is stepping (`wants_kkt_capture`)
3304 // — a detached/free-running debugger drops them to keep the run
3305 // cheap. `kkt_debug` is overwritten every iteration and never
3306 // cleared at `iter_start`, so a stepping session always has the
3307 // previous iteration's system to look back at via `viz kkt`/`viz L`.
3308 if let Some(hook) = self.debug.as_ref() {
3309 let capture_heavy = hook.borrow().wants_kkt_capture();
3310 let captured_iter = self.data.borrow().iter_count;
3311 let info = self.search_dir.as_ref().map(|sd| {
3312 let pd = sd.pd_solver_mut();
3313 let aug = pd.aug_solver();
3314 let provides = aug.provides_inertia();
3315 crate::ipopt_data::KktDebug {
3316 iter: captured_iter,
3317 dim: aug.system_dim(),
3318 n_neg: if provides {
3319 aug.number_of_neg_evals()
3320 } else {
3321 -1
3322 },
3323 provides_inertia: provides,
3324 status: format!("{:?}", aug.last_solve_status()),
3325 matrix: if capture_heavy {
3326 aug.kkt_triplets()
3327 } else {
3328 None
3329 },
3330 l_factor: if capture_heavy {
3331 aug.l_factor(true)
3332 } else {
3333 None
3334 },
3335 }
3336 });
3337 self.data.borrow_mut().kkt_debug = info;
3338 }
3339
3340 // Sub-iteration checkpoint: the Newton step `δ` (data.delta) and
3341 // the applied regularization are now available, before the line
3342 // search consumes them.
3343 if let Some(o) = self.debug_stop(crate::debug::Checkpoint::AfterSearchDirection) {
3344 return o;
3345 }
3346
3347 // Fine-grained time-budget gate (pounce#242). The KKT
3348 // factorization (and any inertia-correction / quality-escalation
3349 // refactorizations) is the single most expensive step of a large
3350 // solve, and it has just finished. Check the deadline here so an
3351 // over-budget solve returns its current best iterate *before*
3352 // spending a line search and another whole iteration — bounding
3353 // the overshoot to roughly one search-direction computation
3354 // instead of a full outer iteration. `data.curr` is untouched by
3355 // the step computation (the trial lives in `data.trial`), so it
3356 // still holds the last accepted iterate.
3357 if let Some(ret) = self.deadline_status() {
3358 return IterateOutcome::Terminate(ret);
3359 }
3360
3361 // 6. Acceptable trial point — run the line search if we have a
3362 // primal/dual step on `data.delta`. Wrap in a guard so all
3363 // early-return paths (ErrorInStepComputation, InternalError,
3364 // restoration entry) still stop the timer.
3365 let _ls_guard = timing.compute_acceptable_trial_point.guard();
3366 let have_delta = self.data.borrow().delta.is_some();
3367 if have_delta {
3368 let delta = match self.data.borrow().delta.as_ref().cloned() {
3369 Some(d) => d,
3370 None => {
3371 return IterateOutcome::Terminate(SolverReturn::ErrorInStepComputation);
3372 }
3373 };
3374 // Cap alpha by the primal fraction-to-the-boundary so the
3375 // first trial cannot push slacks past their bounds, and by
3376 // the dual FTB so bound multipliers stay positive. Mirrors
3377 // upstream `IpBacktrackingLineSearch::FindAcceptableTrialPoint`'s
3378 // calls to `IpCq.primal_frac_to_the_bound` /
3379 // `IpCq.dual_frac_to_the_bound` with τ = `curr_tau`.
3380 let tau = self.data.borrow().curr_tau;
3381 let alpha_p_max = self.cq.borrow().aff_step_alpha_primal_max(&delta, tau);
3382 let alpha_d_max = self.cq.borrow().aff_step_alpha_dual_max(&delta, tau);
3383
3384 // Tiny-step gate — port of `IpBacktrackingLineSearch.cpp:363`
3385 // and the handling block at lines 382-435. When the search
3386 // direction is so small that any nonzero α would just
3387 // bounce inside floating-point noise, take the FTB step
3388 // unchecked and skip the line search; that's the only way
3389 // to hit `STOP_AT_TINY_STEP` cleanly when the iterate is
3390 // already at a converged point but `nlp_error > tol` due to
3391 // scaling or unbounded duals.
3392 // gh#884 — record the direction's scale-relative magnitude for
3393 // the dual-divergence-retry signature. Done here, where `delta`
3394 // is already in hand, rather than recomputed at the gate.
3395 self.last_step_rel = self.scale_relative_step_max(&delta);
3396
3397 if self.detect_tiny_step(&delta) {
3398 let alpha_p = alpha_p_max;
3399 let alpha_d = alpha_d_max;
3400 let curr = match self.data.borrow().curr.clone() {
3401 Some(c) => c,
3402 None => return IterateOutcome::Terminate(SolverReturn::InternalError),
3403 };
3404 let trial_iv = scaled_step_unchecked(&curr, &delta, alpha_p, alpha_d);
3405 {
3406 let mut d = self.data.borrow_mut();
3407 d.set_trial(trial_iv);
3408 d.info_alpha_primal = alpha_p;
3409 d.info_alpha_dual = alpha_d;
3410 d.info_ls_count = 0;
3411 if self.tiny_step_last_iteration {
3412 d.info_alpha_primal_char = 'T';
3413 d.tiny_step_flag = true;
3414 } else {
3415 d.info_alpha_primal_char = 't';
3416 }
3417 }
3418 let dy_amax = delta.y_c.amax().max(delta.y_d.amax());
3419 self.tiny_step_last_iteration = dy_amax < self.tiny_step_y_tol;
3420 } else {
3421 self.tiny_step_last_iteration = false;
3422 let alpha_init = self.alpha_init.min(alpha_p_max);
3423 let alpha_dual = self.alpha_init.min(alpha_d_max);
3424 let outcome = self.bundle.line_search.find_acceptable_trial_point(
3425 &self.data,
3426 &self.cq,
3427 &delta,
3428 alpha_init,
3429 alpha_dual,
3430 self.nlp.as_ref(),
3431 self.search_dir.as_mut(),
3432 );
3433 match outcome {
3434 Outcome::Accepted => {
3435 // A normal LS-accepted step breaks any in-flight
3436 // restoration cycle — clear the cycle detector
3437 // so the next resto entry starts fresh.
3438 self.last_resto_entry_x = None;
3439 self.last_resto_entry_s = None;
3440 self.last_resto_recovery_x = None;
3441 self.last_resto_recovery_s = None;
3442 self.resto_no_outer_progress_count = 0;
3443 // Intentionally *not* clearing
3444 // `resto_near_feasible_count` here: DECONVBNE's
3445 // cycle interleaves R-recoveries with 2-3
3446 // LS-accepted 'f'/'h' steps (which return
3447 // `Outcome::Accepted` but accomplish no real
3448 // outer progress — alpha drops to 1e-6 and
3449 // inf_du remains pinned at 1.9e7), so resetting
3450 // on every accept would zero the counter every
3451 // cycle and never fire. The counter persists
3452 // for the duration of the run and trips after
3453 // 3 cumulative near-feasible entries; legitimate
3454 // solves enter resto at most once at near-
3455 // feasibility (POLAK6, HAIFAM) and stay under
3456 // the limit.
3457 }
3458 Outcome::TinyStep | Outcome::Failed => {
3459 // Debugger stop: the line search rejected the step
3460 // (tiny-step floor or all backtracks failed), before
3461 // we fall into restoration. Lets a "why did the line
3462 // search give up?" inspection happen at the failing
3463 // point distinctly from the restoration entry.
3464 if let Some(o) = self.debug_stop(crate::debug::Checkpoint::StepRejected) {
3465 return o;
3466 }
3467 // Upstream `IpBacktrackingLineSearch.cpp` raises
3468 // `LINE_SEARCH_FAILED` when α drops below
3469 // `alpha_min` or all retries reject, which in
3470 // turn triggers `ActivateLineSearch` →
3471 // restoration.
3472 return self.invoke_restoration_debugged();
3473 }
3474 Outcome::Deadline => {
3475 // The time budget was crossed inside the line
3476 // search (pounce#242). No trial was promoted, so
3477 // `data.curr` still holds the best iterate; stop
3478 // with the matching time-limit status. Re-derive
3479 // wall vs CPU from the deadline (it can only still
3480 // be exceeded — time is monotonic).
3481 return IterateOutcome::Terminate(
3482 self.deadline_status()
3483 .unwrap_or(SolverReturn::WallTimeExceeded),
3484 );
3485 }
3486 }
3487 }
3488 }
3489
3490 // End the line-search/trial timer here so the bookkeeping in
3491 // steps 7-8 below is attributed to `accept_trial_point` (which
3492 // mirrors upstream's split: filter update and FTB reset are
3493 // accept-side, not line-search-side).
3494 _ls_guard.stop();
3495
3496 // 7. Accept trial point (promotes `trial` to `curr` if set).
3497 // The acceptor's filter has already been augmented (when
3498 // appropriate) inside `find_acceptable_trial_point` via
3499 // `update_for_next_iteration`, mirroring upstream's call
3500 // chain in `IpBacktrackingLineSearch.cpp:839`.
3501 let _accept_guard = timing.accept_trial_point.guard();
3502
3503 // 7a. Safe-slack bound adjustment. Before promoting `trial`, move
3504 // any `x_L/x_U/d_L/d_U` whose trial slack fell below
3505 // `eps*min(1,mu)` so the slack becomes representable (port of
3506 // the bound-adjustment block in
3507 // `IpoptAlgorithm::AcceptTrialPoint`, `IpIpoptAlg.cpp:664-706`).
3508 self.adjust_variable_bounds_for_small_slacks();
3509
3510 self.data.borrow_mut().accept_trial_point();
3511
3512 // 8. Bound multiplier kappa_sigma reset.
3513 self.correct_bound_multiplier();
3514
3515 // 8b. `recalc_y` — re-estimate the equality/inequality
3516 // multipliers by least squares once the iterate is feasible
3517 // enough (`IpIpoptAlg.cpp:AcceptTrialPoint`). Off unless the
3518 // user asks; see `application.rs` for why we do not turn it
3519 // on for L-BFGS the way upstream's option text says it does.
3520 //
3521 // Ordering: this runs after the kappa_sigma reset. The two
3522 // are not obviously independent — the least-square RHS is
3523 // `−∇f + Pₗz_L − Pᵤz_U` (`IpLeastSquareMults.cpp:54`), so it
3524 // reads the bound multipliers step 8 just corrected — but
3525 // running the sweep with the two swapped produces a
3526 // byte-identical corpus, so the coupling does not bite in
3527 // practice. Kept here on the argument that `y` should be
3528 // estimated against the multipliers the iteration actually
3529 // ends with.
3530 self.maybe_recalc_y();
3531
3532 // 8c. Square-problem multipliers. `IpIpoptAlg.cpp:409` runs this
3533 // between `AcceptTrialPoint` and the next `CheckConvergence`,
3534 // on every iteration, for square problems only. pounce's
3535 // `iterate()` boundary falls between those two — the outer
3536 // loop bumps `iter_count` and the next `iterate()` opens with
3537 // the convergence check — so this is the same slot, and the
3538 // `+ 1` inside is that pending bump (upstream increments
3539 // before the call, at `IpIpoptAlg.cpp:407`).
3540 if self.is_square_problem() {
3541 self.compute_feasibility_multipliers();
3542 }
3543
3544 // Sub-iteration checkpoint: the trial point was accepted; α and
3545 // the new iterate are in place (before the loop's iter bookkeeping
3546 // and the next `IterStart`).
3547 drop(_accept_guard);
3548 if let Some(o) = self.debug_stop(crate::debug::Checkpoint::AfterStep) {
3549 return o;
3550 }
3551
3552 IterateOutcome::Continue
3553 }
3554
3555 /// `max(max_i |δx_i|/(1+|x_i|), max_i |δs_i|/(1+|s_i|))` — the same
3556 /// scale-relative measure [`Self::detect_tiny_step`] thresholds, kept
3557 /// as a magnitude.
3558 ///
3559 /// Scale-relative rather than a bare `‖d‖` on purpose: a bare norm is
3560 /// a length in the model's units, so on a badly scaled model it says
3561 /// more about the units than about whether the iterate has stopped
3562 /// moving. gh#884's signature needs the latter.
3563 fn scale_relative_step_max(&self, delta: &crate::iterates_vector::IteratesVector) -> Number {
3564 let curr = match self.data.borrow().curr.clone() {
3565 Some(c) => c,
3566 None => return Number::INFINITY,
3567 };
3568
3569 let mut tmp = curr.x.make_new_copy();
3570 tmp.element_wise_abs();
3571 tmp.add_scalar(1.0);
3572 let mut tmp2 = delta.x.make_new_copy();
3573 tmp2.element_wise_divide(&*tmp);
3574 let mut worst = tmp2.amax();
3575
3576 if curr.s.dim() > 0 {
3577 let mut tmp = curr.s.make_new_copy();
3578 tmp.element_wise_abs();
3579 tmp.add_scalar(1.0);
3580 let mut tmp2 = delta.s.make_new_copy();
3581 tmp2.element_wise_divide(&*tmp);
3582 worst = worst.max(tmp2.amax());
3583 }
3584 worst
3585 }
3586
3587 /// Whether this solve ever saw gh#884's dual-divergence-at-a-settled-primal
3588 /// signature. Sticky; see [`Self::dual_divergence_signature`].
3589 pub fn dual_divergence_signature(&self) -> bool {
3590 self.dual_divergence_signature
3591 }
3592
3593 /// Port of `IpBacktrackingLineSearch::DetectTinyStep`
3594 /// (`IpBacktrackingLineSearch.cpp:1219-1278`). Returns true iff
3595 /// `max_i |δx_i|/(1+|x_i|) ≤ tiny_step_tol`,
3596 /// `max_i |δs_i|/(1+|s_i|) ≤ tiny_step_tol`, AND
3597 /// `curr_constraint_violation ≤ 1e-4`. Disabled when
3598 /// `tiny_step_tol == 0`.
3599 fn detect_tiny_step(&self, delta: &crate::iterates_vector::IteratesVector) -> bool {
3600 if self.tiny_step_tol == 0.0 {
3601 return false;
3602 }
3603 let curr = match self.data.borrow().curr.clone() {
3604 Some(c) => c,
3605 None => return false,
3606 };
3607
3608 // |x_i|+1
3609 let mut tmp = curr.x.make_new_copy();
3610 tmp.element_wise_abs();
3611 tmp.add_scalar(1.0);
3612 // |δx_i|/(|x_i|+1) ; checked via Amax of (δx ./ (|x|+1)).
3613 let mut tmp2 = delta.x.make_new_copy();
3614 tmp2.element_wise_divide(&*tmp);
3615 if tmp2.amax() > self.tiny_step_tol {
3616 return false;
3617 }
3618
3619 if curr.s.dim() > 0 {
3620 let mut tmp = curr.s.make_new_copy();
3621 tmp.element_wise_abs();
3622 tmp.add_scalar(1.0);
3623 let mut tmp2 = delta.s.make_new_copy();
3624 tmp2.element_wise_divide(&*tmp);
3625 if tmp2.amax() > self.tiny_step_tol {
3626 return false;
3627 }
3628 }
3629
3630 let cviol = self.cq.borrow().curr_constraint_violation();
3631 if cviol > 1e-4 {
3632 return false;
3633 }
3634 true
3635 }
3636
3637 /// Re-anchor the quasi-Newton model instead of handing off to
3638 /// restoration, when the line search has failed at a point
3639 /// restoration cannot improve (gh#818). Returns `true` if the model
3640 /// was re-anchored, in which case the caller retries this iterate.
3641 ///
3642 /// **The two failures the line search cannot tell apart.** When no
3643 /// trial step is acceptable, either the *point* is bad — infeasible,
3644 /// and restoration is exactly the right tool — or the *direction* is,
3645 /// because `W` is a quasi-Newton model carrying curvature the iterate
3646 /// has left behind. Upstream has one answer for both, because
3647 /// restoration is the only fallback it has. At an already-feasible
3648 /// point that answer is a no-op: the restoration NLP minimizes the
3649 /// constraint violation, and there is none to minimize, so it wanders
3650 /// at `theta ~ 1e-13` and reports `Restoration_Failed`.
3651 ///
3652 /// Measured on the `deb7` fixture under `limited-memory`: the solve
3653 /// stalls with `inf_pr ~ 1e-12` and `inf_du ~ 1e5`, enters
3654 /// restoration at a point feasible to 8e-13, and spends 340 of its
3655 /// 1242 iterations there before failing. On the unconstrained
3656 /// gh#818 quadratic under `alpha_red_factor 0.8` it is starker
3657 /// still — `theta` is identically zero, so restoration cannot move
3658 /// at all, and the solve dies at **iteration 1** with
3659 /// `Error_In_Step_Computation` and the objective still at its
3660 /// starting value.
3661 ///
3662 /// So this is a rung, not a refusal: it fires only where restoration
3663 /// has nothing to reduce, it is bounded, and every path that reached
3664 /// restoration before still reaches it once the rung is spent.
3665 ///
3666 /// **Deliberately *after* the acceptable-point decline.** The call
3667 /// site is inside [`Self::invoke_restoration`], immediately behind
3668 /// that decline, and not at the `Outcome::Failed` arm in
3669 /// [`Self::iterate`] where the hand-off is decided — `eigena2` and
3670 /// `csfi2` reach the hand-off at feasible points that already pass
3671 /// the acceptable tolerances, and those must go on being reported
3672 /// rather than re-anchored and continued. Being inside
3673 /// `invoke_restoration` means the `PreRestoration` debug checkpoint
3674 /// fires ahead of a rung that then does not enter restoration; that
3675 /// is the price of the ordering and is the checkpoint's documented
3676 /// meaning ("just before entry"), not a promise that entry follows.
3677 ///
3678 /// **And deliberately not a feasibility gate on restoration itself.**
3679 /// That was tried and rejected before (see the `constr_viol_tol`
3680 /// paragraph in [`Self::invoke_restoration`]): feasible entries are
3681 /// ordinary, and nothing observable at the doorway separates a
3682 /// restoration that recovers from one that does not. This rung does
3683 /// not decide that question — it spends one cheap retry on the
3684 /// hypothesis that the model, not the point, is at fault, and hands
3685 /// over unchanged if the retry fails too.
3686 ///
3687 /// The bound is structural as well as counted. `reanchor` returns
3688 /// `false` once the history is down to its newest pair, so a second
3689 /// failure at the same iterate finds nothing to give up and falls
3690 /// through; refilling the history takes accepted steps, so the
3691 /// counter only advances once per genuine stall.
3692 /// `limited_memory_ls_failure_restarts` caps the total.
3693 fn try_reanchor_before_restoration(&mut self) -> bool {
3694 if self.lbfgs_ls_failure_restarts == 0
3695 || self.lbfgs_ls_restarts_used >= self.lbfgs_ls_failure_restarts
3696 {
3697 return false;
3698 }
3699 // Restoration's objective is the constraint violation. Only
3700 // stand in front of it where that objective is already at its
3701 // floor, so the rung can never pre-empt a restoration that had
3702 // real work to do. `constr_viol_tol` is the same tolerance the
3703 // convergence check calls feasible.
3704 let theta = self.cq.borrow().curr_constraint_violation();
3705 if !(theta <= self.bundle.conv_check.constr_viol_tol_or_default()) {
3706 return false;
3707 }
3708 if !self.bundle.hess.reanchor() {
3709 return false;
3710 }
3711 self.lbfgs_ls_restarts_used += 1;
3712 // 'Wa' alongside the updater's own 'Wr' (the
3713 // `limited_memory_max_skipping` reset), so the two re-anchorings
3714 // are distinguishable in the iteration table rather than both
3715 // reading as "the Hessian did something".
3716 self.data.borrow_mut().append_info_string("Wa");
3717 tracing::debug!(target: "pounce::algorithm",
3718 "[POUNCE] line search failed at a feasible point (theta {:.3e}); re-anchoring \
3719 the limited-memory Hessian on its newest curvature pair and retrying instead \
3720 of entering restoration, which has nothing to reduce here (gh#818). \
3721 Restart {} of {}.",
3722 theta, self.lbfgs_ls_restarts_used, self.lbfgs_ls_failure_restarts,
3723 );
3724 true
3725 }
3726
3727 /// Drive the restoration phase after a line-search failure.
3728 /// Returns `IterateOutcome::Continue` if the restoration driver
3729 /// recovered (the algorithm carries on from the recovered iterate);
3730 /// otherwise terminates with [`SolverReturn::RestorationFailure`].
3731 /// Mirrors upstream's
3732 /// `IpBacktrackingLineSearch::ActivateLineSearch` → `PerformRestoration`
3733 /// chain.
3734 fn invoke_restoration(&mut self) -> IterateOutcome {
3735 // Snapshot the outer reference iterate's `(theta, barr)` and
3736 // build the orig-progress callback the inner IPM will consult
3737 // at every iteration (mirrors upstream
3738 // `IpRestoFilterConvCheck::SetOrigLSAcceptor` plus
3739 // `IpFilterLSAcceptor::Reset`'s `reference_*_` snapshot).
3740 let reference_theta = self.cq.borrow().curr_constraint_violation();
3741 let reference_barr = self.cq.borrow().curr_barrier_obj();
3742
3743 if std::env::var("POUNCE_DBG_RESTO").is_ok() {
3744 let iter = self.data.borrow().iter_count;
3745 tracing::debug!(target: "pounce::algorithm",
3746 "RESTO_ENTRY iter={} theta={:.6e} barr={:.6e} near_feas_ct={}",
3747 iter, reference_theta, reference_barr, self.resto_near_feasible_count,
3748 );
3749 }
3750
3751 // Port gap: upstream refuses to enter restoration from an acceptable
3752 // point, and this was missing. `IpBacktrackingLineSearch.cpp:557-570`,
3753 // in the `if (!accept)` arm that hands off to restoration:
3754 //
3755 // if( CurrentIsAcceptable() )
3756 // {
3757 // THROW_EXCEPTION(ACCEPTABLE_POINT_REACHED,
3758 // "Restoration phase called at acceptable point.");
3759 // }
3760 //
3761 // The rationale is the obvious one: restoration reduces the constraint
3762 // violation, so from a point that already passes the acceptable-level
3763 // tolerances it has nothing to reduce, and entering can only risk a
3764 // reportable solution.
3765 //
3766 // What the gap cost, measured on mittelmann `qcqp1000-1nc` (n=1000):
3767 // the line search fails at iteration 187 on a point carrying the
3768 // published optimum (`-2.6628866e+07`, matching ipopt-ma57 to 9
3769 // significant figures) with overall NLP error `6.0e-8` — two orders
3770 // inside `acceptable_tol`. Restoration walked it to `theta 5e-3` and
3771 // ground out 2780 further iterations without recovering, so a solved
3772 // problem reported a failure.
3773 //
3774 // The predicate is upstream's, unmodified: acceptability alone. A
3775 // strict `constr_viol_tol` gate was tried on top and is both a
3776 // deviation and useless — at their restoration entries `qcqp1000-1nc`
3777 // sits at `theta = 6.0e-8`, `csfi2` at `1.5e-7`, `eigena2` at
3778 // `2.1e-10`, all strictly feasible, one by six orders. Nothing
3779 // observable at the doorway separates a restoration that recovers from
3780 // one that does not, which is why upstream does not try to.
3781 //
3782 // Placed ahead of the cycle detectors below rather than beside
3783 // upstream's `PrepareRestoPhaseStart()`: those detectors are a
3784 // pounce-side addition, and an acceptable point should be reported
3785 // regardless of cycle state. Filter augmentation is skipped on this
3786 // path, which is immaterial — the run stops here.
3787 //
3788 // `current_is_acceptable_with_state` is the full triplet, never
3789 // `theta` alone: gh #274, a perfectly feasible point can be
3790 // arbitrarily far from stationary (`min -exp(x) s.t. x >= 0` reaches
3791 // here with `inf_pr = 1.7e-10` and `inf_du = 8.8e+47`), and the
3792 // triplet carries `acceptable_dual_inf_tol` to reject it. The
3793 // finiteness check mirrors the one below (CUTE `himmelbj` reaches a
3794 // near-feasible point where `f` evaluates to NaN) and matches
3795 // upstream's own `curr_f` precondition for acceptability.
3796 let (entry_f_finite, entry_nlp_err) = {
3797 let cq = self.cq.borrow();
3798 (cq.curr_f().is_finite(), cq.curr_nlp_error())
3799 };
3800 //
3801 // What the guard still did not ask is whether the solve was *converging*
3802 // (gh #534). It reads the entry point and nothing about the trajectory
3803 // that reached it, so it stops a contracting endgame and a dead stall
3804 // with equal confidence. On `eigena2` it fires while the dual
3805 // infeasibility is quartering every iteration on unit steps
3806 // (`1.19e-5 → 2.96e-6 → 7.38e-7 → 1.84e-7`), three iterations short of a
3807 // strict certificate that costs nothing but those three iterations.
3808 // [`Self::may_defer_acceptable_decline`] adds that missing question,
3809 // and only that: when the answer is no — `eigenb2`'s tail rises, and
3810 // `csfi2`'s last two iterations are flat to three digits — the guard
3811 // fires exactly as before.
3812 if entry_f_finite
3813 && self.bundle.conv_check.current_is_acceptable_with_state(
3814 entry_nlp_err,
3815 &self.data,
3816 &self.cq,
3817 )
3818 {
3819 if self.may_defer_acceptable_decline() {
3820 tracing::debug!(target: "pounce::algorithm",
3821 "[POUNCE] deferring the restoration decline at theta {:.3e}: the entry \
3822 point passes the acceptable-level tolerances (nlp_err {:.3e}) but the \
3823 NLP error has contracted every iteration over the last {} \
3824 ({:.3e} -> {:.3e}); continuing for up to {} iterations, with that point \
3825 held as the floor (gh #534).",
3826 reference_theta, entry_nlp_err, DECLINE_PROGRESS_SAMPLES - 1,
3827 self.nlp_err_recent[0], entry_nlp_err, DECLINE_CONTINUATION_BUDGET,
3828 );
3829 } else {
3830 // The window is on the line because "why did the guard not
3831 // defer?" is the first question anyone reading this trace has
3832 // (gh #534), and reconstructing it from the iteration table
3833 // means recomputing the scaled aggregate by hand.
3834 tracing::debug!(target: "pounce::algorithm",
3835 "[POUNCE] declining restoration at theta {:.3e}: the entry point already \
3836 passes the acceptable-level tolerances (nlp_err {:.3e}); reporting it \
3837 rather than risking it in restoration. Recent NLP errors {} \
3838 (contracting: {}).",
3839 reference_theta, entry_nlp_err,
3840 self.nlp_err_window_str(), self.nlp_err_contracting(),
3841 );
3842 return IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint);
3843 }
3844 }
3845
3846 // gh#818 — one rung before the hand-off proper: re-anchor the
3847 // quasi-Newton model and retry this iterate. Placed *here*, and
3848 // not at the `Outcome::Failed` arm in `iterate`, because it has
3849 // to run behind the acceptable-point decline above: `eigena2`
3850 // and `csfi2` arrive at feasible points that already pass the
3851 // acceptable tolerances, and those must go on being reported
3852 // rather than re-anchored and continued. The gh#534 deferral
3853 // path falls through to here, which is the right order too —
3854 // the deferral has already captured its floor, so a rung taken
3855 // under a live deferral is protected by it.
3856 if self.try_reanchor_before_restoration() {
3857 return IterateOutcome::Continue;
3858 }
3859
3860 // No-progress restoration cycle detector. Two layered checks
3861 // surface as `ErrorInStepComputation` instead of cycling to
3862 // `max_iter` exhaustion (mirrors the *intent* of upstream
3863 // `IpBacktrackingLineSearch.cpp:580-600`'s almost-feasible
3864 // resto guard):
3865 //
3866 // 1. *Static cycle*: entry-to-entry — when the curr `(x, s)`
3867 // at this entry is essentially identical to the snapshot
3868 // from the previous entry, the inner resto-IPM is
3869 // returning recovered iterates indistinguishable from
3870 // entry, AND the outer didn't move either. Fires
3871 // immediately. Catches QCNEW, EQC, MESH, POLAK6, S365,
3872 // S365MOD, SIPOW2M, PFIT4.
3873 //
3874 // 2. *Slow-progress cycle*: recovery-to-entry — when curr at
3875 // this entry is essentially identical to the *recovery*
3876 // iterate from the previous resto, the outer made no
3877 // progress between resto invocations even though resto's
3878 // inner moved substantively. Counted, fires after 5
3879 // consecutive entries. Catches ACOPR14, ACOPR30, TRO3X3
3880 // while letting MAKELA3, HAIFAM, HALDMADS, ROBOT,
3881 // TENBARS2 — which need 2-3 productive resto entries
3882 // before LS accepts — pass through.
3883 //
3884 // A productive single-restoration sequence (BT8, HIMMELBJ,
3885 // LINSPANH, LSNNODOC, ODFITS, OET3) clears both snapshots via
3886 // `Outcome::Accepted` between entries and is unaffected.
3887 let curr = self
3888 .data
3889 .borrow()
3890 .curr
3891 .as_ref()
3892 .expect("curr set before invoke_restoration")
3893 .clone();
3894 // Helper: when the cycle detector fires and the orig cv is a
3895 // violation the *user* calls a violation (e.g. PFIT1's 2.73e-2),
3896 // the outer is stuck at a feasibility-stationary point and the
3897 // honest exit is `LocalInfeasibility`. Below that threshold the
3898 // iterate is primal-feasible by the user's own declaration, so there
3899 // is no infeasibility to certify — the failure is numerical, not
3900 // algorithmic, and `ErrorInStepComputation` is retained.
3901 //
3902 // The threshold is `constr_viol_tol`, and *only* `constr_viol_tol`
3903 // (gh #508). The question this ternary asks — "is this violation
3904 // real?" — is a question about the constraint violation, so it has to
3905 // be asked with the option that declares what a violated constraint
3906 // is. The previous form, `max(100·tol, 1e-4)`, was built from `tol`, a
3907 // tolerance on the **KKT error**: different quantity, different units,
3908 // and it never consulted `constr_viol_tol` at all. Two consequences,
3909 // both measured on `min (x-5)² s.t. x²+δ = 0` (infeasible for every
3910 // δ>0, reported violation exactly δ):
3911 //
3912 // * sweeping `constr_viol_tol` over four orders moved the boundary
3913 // not at all — at `constr_viol_tol = 1e-3` a violation of `1e-4`,
3914 // comfortably inside the user's declared feasibility tolerance,
3915 // still exited 500;
3916 // * sweeping `tol` moved it a great deal, and in the wrong
3917 // direction: at `tol = 1e-4` the `1e-2` threshold swallowed every
3918 // δ from `3e-4` to `1e-2` — a model infeasible by a full percent
3919 // answered "your solver broke". Loosening `tol` is the standard
3920 // user reaction to a struggling solve, so the failure widened
3921 // exactly when the user tried to help.
3922 //
3923 // No `infeas_viol_kappa` margin on top, unlike the rapid-infeasibility
3924 // pre-filter in `conv_check`. That detector fires *during* the solve
3925 // off a streak heuristic and needs the margin to avoid convicting an
3926 // iterate that is still converging; here restoration has already
3927 // demonstrably cycled, so the certainty comes from the cycle evidence
3928 // rather than from extra violation headroom. Widening to
3929 // `kappa·constr_viol_tol` would move the default threshold from `1e-4`
3930 // to `1e-2` and hand back 500 on the whole band in between.
3931 //
3932 // The comparison is `>=`, not `>`. A violation landing exactly on the
3933 // threshold is a violation at the user's declared tolerance, and the
3934 // reproducer above hits the boundary to the digit (`δ = 1e-4` at the
3935 // default `constr_viol_tol`), where `>` returned 500 for a model
3936 // infeasible by precisely the amount the user said was too much.
3937 //
3938 // The violation is measured **unscaled**. `reference_theta` is the
3939 // row-scaled residual, but the floor below is an absolute, user-facing
3940 // magnitude, so comparing the two mixes unit systems — and on a problem
3941 // whose rows are scaled down the scaled residual can never clear it.
3942 // `infeasible_equalities.nl` is the worked example: a square 2x2 system
3943 // with a true violation of 2.0 that NLP scaling reports as 6.67e-7, so
3944 // this test read `6.67e-7 > 1e-4` = false and a blatantly infeasible
3945 // model exited `Error_In_Step_Computation` (AMPL 500, Pyomo
3946 // `internalSolverError`). Square problems have no restoration-side
3947 // locally-infeasible gate — `strict` carves them out so the outer gets
3948 // another shot — so this cycle exit *is* their safety net, and it was
3949 // disabled by the unit mismatch. Same user-visible family as gh #372.
3950 //
3951 // Note this also moves from a 1-norm (`curr_constraint_violation`) to a
3952 // max-norm. Max-norm <= 1-norm, so the test is marginally stricter
3953 // about declaring infeasibility on an unscaled problem — the safe
3954 // direction for a verdict this consequential.
3955 //
3956 // `theta > 0` in front of the `>=` is not redundant: the options layer
3957 // registers `constr_viol_tol` with a *strict* lower bound of zero, but
3958 // a library embedder setting `ConvCheckOptions` directly is not bound
3959 // by that, and `0 >= 0` would turn an exactly-feasible iterate into an
3960 // infeasibility certificate. A zero violation never proves anything.
3961 let cycle_viol_tol = self.bundle.conv_check.constr_viol_tol_or_default();
3962 let reference_theta_unscaled = self.cq.borrow().curr_unscaled_primal_infeasibility_max();
3963 let cycle_exit =
3964 if reference_theta_unscaled > 0.0 && reference_theta_unscaled >= cycle_viol_tol {
3965 SolverReturn::LocalInfeasibility
3966 } else {
3967 SolverReturn::ErrorInStepComputation
3968 };
3969 let static_cycle = if let (Some(prev_x), Some(prev_s)) = (
3970 self.last_resto_entry_x.as_ref(),
3971 self.last_resto_entry_s.as_ref(),
3972 ) {
3973 let dx_rel = relative_distance(&*curr.x, &**prev_x);
3974 let ds_rel = relative_distance(&*curr.s, &**prev_s);
3975 if std::env::var_os("POUNCE_DBG_RESTO_CYCLE").is_some() {
3976 tracing::debug!(target: "pounce::algorithm",
3977 "[PN_RESTO_CYCLE] entry-vs-entry dx_rel={:.6e} ds_rel={:.6e}",
3978 dx_rel, ds_rel
3979 );
3980 }
3981 dx_rel <= 1e-10 && ds_rel <= 1e-10
3982 } else {
3983 false
3984 };
3985 if static_cycle {
3986 // Prefer the last acceptable point over the cycle error —
3987 // the borrows above are released, so the `&mut self` helper
3988 // is free to roll back.
3989 return self.terminate_acceptable_or(cycle_exit);
3990 }
3991 let recovery_cycle = if let (Some(prev_x), Some(prev_s)) = (
3992 self.last_resto_recovery_x.as_ref(),
3993 self.last_resto_recovery_s.as_ref(),
3994 ) {
3995 let dx_rel = relative_distance(&*curr.x, &**prev_x);
3996 let ds_rel = relative_distance(&*curr.s, &**prev_s);
3997 if std::env::var_os("POUNCE_DBG_RESTO_CYCLE").is_some() {
3998 tracing::debug!(target: "pounce::algorithm",
3999 "[PN_RESTO_CYCLE] entry-vs-recovery dx_rel={:.6e} ds_rel={:.6e} count={}",
4000 dx_rel, ds_rel, self.resto_no_outer_progress_count
4001 );
4002 }
4003 dx_rel <= 1e-10 && ds_rel <= 1e-10
4004 } else {
4005 false
4006 };
4007 if recovery_cycle {
4008 self.resto_no_outer_progress_count =
4009 self.resto_no_outer_progress_count.saturating_add(1);
4010 // 10-strike limit: tuned to give OET7-style traces room
4011 // to break through (inner inf_pr still decreasing across
4012 // strikes) while still bounding DECONVBNE-style cycles
4013 // (which need a guard but tolerate a wider window —
4014 // ~3 outer steps per cycle, so 10 strikes ≈ 30 outer
4015 // iters, well below the 2987-iter pathological run).
4016 if self.resto_no_outer_progress_count >= 10 {
4017 // Prefer the last acceptable point over the cycle error;
4018 // borrows are released, so the `&mut self` helper is free.
4019 return self.terminate_acceptable_or(cycle_exit);
4020 }
4021 } else {
4022 self.resto_no_outer_progress_count = 0;
4023 }
4024 // Near-feasible resto re-entry detector — matches the *intent*
4025 // of upstream `IpBacktrackingLineSearch.cpp:580-600`'s almost-
4026 // feasible-resto guard with a looser cv threshold. When the
4027 // outer enters restoration with the constraint violation
4028 // already at or below `tol`, the resto sub-IPM will produce a
4029 // recovered iterate that's at most marginally more feasible,
4030 // and any post-recovery σ-blowup from the next outer KKT solve
4031 // will re-trigger resto on the next iteration. Counting these
4032 // entries surfaces the cycle as `StopAtAcceptablePoint` —
4033 // primal feasibility is already met, only the dual residual
4034 // remains. Catches DECONVBNE: pounce ran 2987 iters before
4035 // this guard (cycle of ~30-inner-resto + 3 outer per cycle);
4036 // upstream solves in 505 iters via a different x trajectory.
4037 // Single-entry productive restos (BT8, HIMMELBJ, ODFITS) and
4038 // sub-tol-but-recoverable starts pass through under the 3-
4039 // strike limit.
4040 let outer_tol = self.bundle.conv_check.tol_or_default();
4041 if reference_theta <= outer_tol {
4042 self.resto_near_feasible_count = self.resto_near_feasible_count.saturating_add(1);
4043 if self.resto_near_feasible_count >= 3 {
4044 // Constraint feasibility is met, but a near-feasible iterate is
4045 // only "acceptable" if its objective is finite. CUTE `himmelbj`
4046 // reaches a point with cv ≈ 2e-9 where f evaluates to NaN; that
4047 // must surface as Invalid_Number_Detected rather than be
4048 // reported as Solved_To_Acceptable_Level with a `nan` objective.
4049 if !self.cq.borrow().curr_f().is_finite() {
4050 return IterateOutcome::Terminate(SolverReturn::InvalidNumberDetected);
4051 }
4052 // Constraint feasibility alone does not make a point
4053 // acceptable. `reference_theta` measures only the *primal*
4054 // residual, so a perfectly feasible iterate can still be
4055 // arbitrarily far from stationary — which is exactly what an
4056 // unbounded objective looks like from here: the constraints
4057 // stay satisfied while the iterates run off toward -inf.
4058 //
4059 // `min -exp(x) s.t. x >= 0` re-enters restoration with
4060 // `inf_pr = 1.7e-10` and `inf_du = 8.8e+47`; before gh #274
4061 // the finiteness check was the only gate, `-8.8e47` is
4062 // finite, and the solve was reported as
4063 // `Solved_To_Acceptable_Level` — which Pyomo maps into the
4064 // *solved* family, loading the diverging iterate as an
4065 // optimal solution.
4066 //
4067 // So require the point to pass the full acceptable-level
4068 // triplet (which includes `acceptable_dual_inf_tol`) before
4069 // claiming acceptability. When it does not, surface
4070 // `cycle_exit` — the same honest status the other two
4071 // restoration-cycle exits in this function use.
4072 let nlp_err = self.cq.borrow().curr_nlp_error();
4073 if !self
4074 .bundle
4075 .conv_check
4076 .current_is_acceptable_with_state(nlp_err, &self.data, &self.cq)
4077 {
4078 tracing::debug!(target: "pounce::algorithm",
4079 "[POUNCE] near-feasible restoration re-entry at theta {:.3e} \
4080 but the point fails the acceptable-level tolerances \
4081 (nlp_err {:.3e}); reporting {:?} rather than \
4082 Solved_To_Acceptable_Level (gh#274).",
4083 reference_theta, nlp_err, cycle_exit,
4084 );
4085 return IterateOutcome::Terminate(cycle_exit);
4086 }
4087 return IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint);
4088 }
4089 } else {
4090 self.resto_near_feasible_count = 0;
4091 }
4092 self.last_resto_entry_x = Some(curr.x.make_new_copy());
4093 self.last_resto_entry_s = Some(curr.s.make_new_copy());
4094
4095 // Augment the outer's filter with the resto-entry envelope —
4096 // mirrors upstream `IpBacktrackingLineSearch.cpp:566`:
4097 // `acceptor_->PrepareRestoPhaseStart()`. Adds
4098 // `((1-γ_θ)·θ_entry, φ_entry - γ_φ·θ_entry)` to the filter so
4099 // that after restoration recovers, the outer's Newton step is
4100 // forced by the filter to make real progress vs the entry
4101 // point. Without this, the outer accepts null-progress 'h'
4102 // steps and re-enters restoration on the next iteration (root
4103 // cause of DECONVBNE's 323 R-accepts vs ipopt's 21).
4104 self.bundle
4105 .line_search
4106 .acceptor_mut()
4107 .prepare_resto_phase_start(reference_theta, reference_barr);
4108
4109 let orig_progress_cb = self.bundle.line_search.acceptor().make_orig_progress_check(
4110 reference_theta,
4111 reference_barr,
4112 5.0,
4113 );
4114
4115 let (Some(nlp), Some(sd), Some(resto)) = (
4116 self.nlp.as_ref(),
4117 self.search_dir.as_mut(),
4118 self.restoration.as_mut(),
4119 ) else {
4120 return IterateOutcome::Terminate(SolverReturn::RestorationFailure);
4121 };
4122 resto.set_orig_progress_check(orig_progress_cb);
4123 // Forward the shared debugger so it can step the inner solve.
4124 resto.set_debug_hook(self.debug.as_ref().map(Rc::clone));
4125 // Forward the user's TNLP so the callback fires from the inner
4126 // solve too (gh#645). `None` when the caller installed no
4127 // callback, which keeps the whole path inert for them.
4128 resto.set_intermediate_tnlp(self.tnlp.as_ref().map(Rc::clone));
4129 let mut pd_guard = sd.pd_solver_mut();
4130 let aug = pd_guard.aug_solver_mut();
4131 // Audit counters (pounce#12). Increment call count + outer-iter
4132 // count (one outer iter is consumed per restoration call) and
4133 // wall-time around the inner call. Inner iter count is read
4134 // after via the trait accessor.
4135 //
4136 // `outer_iter_at_entry` is captured *before* the call because the
4137 // inner IPM's counter is seeded from it (`inner.iter_count =
4138 // outer_iter + 1`, upstream `IpRestoMinC_1Nrm.cpp:181`). The
4139 // accessor hands back that seeded, absolute number; the sub-solve's
4140 // own length is the difference. Adding the raw accessor value was
4141 // gh #819's second defect — `restoration_inner_iters` was a sum of
4142 // absolute positions, a quantity with no meaning — and it is the
4143 // same misreading gh#664 records for the stall gate.
4144 let outer_iter_at_entry = self.data.borrow().iter_count;
4145 self.resto_calls = self.resto_calls.saturating_add(1);
4146 self.resto_outer_iters = self.resto_outer_iters.saturating_add(1);
4147 let resto_t0 = std::time::Instant::now();
4148 let outcome = resto.perform_restoration(&self.data, &self.cq, nlp, aug);
4149 drop(pd_guard);
4150 self.resto_wall_secs += resto_t0.elapsed().as_secs_f64();
4151 let inner_final_iter = resto.last_inner_iter_count();
4152 self.resto_inner_iters = self
4153 .resto_inner_iters
4154 .saturating_add((inner_final_iter - outer_iter_at_entry).max(0));
4155 // gh #819. Roll the reported iteration count forward over the
4156 // restoration rows on the paths that *terminate* the solve.
4157 //
4158 // `RestorationOutcome::Recovered` already does this for itself, in
4159 // `min_c_1nrm.rs`'s step 2g (`Set_iter_count(resto_iter_count - 1)`,
4160 // one short because the outer loop is about to increment). Every
4161 // other outcome returns `Terminate` from the match below without
4162 // passing through that block, so the whole sub-solve used to vanish
4163 // from the summary: on gh #815's flowsheet the log ends at row
4164 // `3000r`, above a summary that said `Number of Iterations....: 3`.
4165 //
4166 // Ipopt reports the index of the last row it printed, `r` rows
4167 // included, on every exit path. Measured on the gh #815 model and
4168 // three variants of it: last row `2418r` / reported 2418, `412r` /
4169 // 412, `1547r` / 1547, `1348r` / 1348 — exits `Restoration Failed`,
4170 // local infeasibility and `Maximum Number of Iterations Exceeded`
4171 // respectively. Assigning the absolute inner count reproduces that
4172 // rule exactly.
4173 //
4174 // Safe against trajectory: every non-`Recovered` arm below returns
4175 // `IterateOutcome::Terminate`, and `optimize_inner`'s loop breaks on
4176 // `Terminate` without consulting the counter again. Nothing reads
4177 // `iter_count` between here and the summary.
4178 if !matches!(outcome, RestorationOutcome::Recovered)
4179 && inner_final_iter > outer_iter_at_entry
4180 {
4181 self.data.borrow_mut().iter_count = inner_final_iter;
4182 }
4183 // pounce#244: the restoration inner IPM shares the outer solve's
4184 // `Deadline` (both its convergence check and — post-#244 — its KKT
4185 // solves consult it), so a budget crossing inside restoration
4186 // terminates the inner solve with a time-limit status. Surface that
4187 // as the time limit directly instead of letting the `Failed` arm map
4188 // it onto `RestorationFailure` / `StopAtAcceptablePoint`. `data.curr`
4189 // is the last accepted outer iterate — restoration stages its
4190 // recovered point onto `trial`, not `curr`, and we return before
4191 // promoting it — so this hands back a valid iterate.
4192 if let Some(ret) = self.deadline_status() {
4193 return IterateOutcome::Terminate(ret);
4194 }
4195 match outcome {
4196 RestorationOutcome::Recovered => {
4197 // Mirror upstream `IpBacktrackingLineSearch.cpp:624-631`:
4198 // a successful restoration clears the line search's
4199 // cross-iteration globalization counters. Upstream runs
4200 // restoration inside `FindAcceptableTrialPoint` so those
4201 // assignments are inline; pounce runs it here, so the
4202 // reset has to be driven from here. Without it
4203 // `watchdog_shortened_iter` survives a restoration
4204 // episode and runs of shortened steps on either side of
4205 // one accumulate as if consecutive, arming the watchdog
4206 // where upstream would not. See
4207 // `BacktrackingLineSearch::reset_after_restoration`.
4208 self.bundle.line_search.reset_after_restoration();
4209 // The driver has staged the recovered point on
4210 // `data.trial`; apply the safe-slack bound adjustment
4211 // (as the main accept path does), then promote it and
4212 // continue iterating.
4213 self.adjust_variable_bounds_for_small_slacks();
4214 self.data.borrow_mut().accept_trial_point();
4215 // Snapshot the recovery iterate for the slow-cycle
4216 // detector at the top of the next `invoke_restoration`.
4217 // Compared against next-entry curr, dx_rel ≈ ‖α·d‖ —
4218 // measures purely the outer step. See header comment
4219 // on the cycle detector above.
4220 let recovered = self
4221 .data
4222 .borrow()
4223 .curr
4224 .as_ref()
4225 .expect("accept_trial_point sets curr")
4226 .clone();
4227 self.last_resto_recovery_x = Some(recovered.x.make_new_copy());
4228 self.last_resto_recovery_s = Some(recovered.s.make_new_copy());
4229 // Mirror upstream `IpoptAlgorithm::AcceptTrialPoint`
4230 // (`IpIpoptAlg.cpp:917-963`): kappa_sigma clamp on the
4231 // four bound-multiplier vectors. Upstream applies this
4232 // unconditionally inside AcceptTrialPoint, so the
4233 // post-restoration path inherits it; pounce factored
4234 // the clamp out of the data swap so we must call it
4235 // explicitly here. Without it the all-1 multiplier
4236 // reset (`bound_mult_reset_threshold`) leaves z*s far
4237 // from mu at the recovered iterate, blowing up the
4238 // next KKT solve's σ = z/s diagonal.
4239 self.correct_bound_multiplier();
4240 IterateOutcome::Continue
4241 }
4242 RestorationOutcome::Failed => {
4243 // Mirrors upstream `IpBacktrackingLineSearch.cpp:611-623`:
4244 // when `PerformRestoration` returns false, attempt to
4245 // roll back to the most recent acceptable iterate before
4246 // surfacing failure. If a snapshot is available we exit
4247 // cleanly with `StopAtAcceptablePoint` (mapped by the
4248 // application layer to `Solved_To_Acceptable_Level`),
4249 // matching the upstream `ACCEPTABLE_POINT_REACHED`
4250 // throw. Without a snapshot we surface
4251 // `RestorationFailure` — unless the restoration left the
4252 // iterate diverging (`|x|_∞ > diverging_iterates_tol`), in
4253 // which case we surface `DivergingIterates` to mirror the
4254 // outcome upstream produces on pathological problems like
4255 // MESH (where ipopt reports `Diverging_Iterates` and
4256 // pounce previously reported `Restoration_Failed` with an
4257 // obj of −3.6e+33). As in the running guard above, a large
4258 // `|x|` is only reported as unbounded when it is
4259 // structurally consistent with an unbounded feasible region
4260 // and the divergence is genuine — either it has persisted
4261 // (the running guard's growth-and-descent streak, which only
4262 // accumulates on a real recession ray; issues #248 / #252) or
4263 // blown past the absolute runaway backstop; otherwise the
4264 // failure is a plain `RestorationFailure`, never a spurious
4265 // `Unbounded`.
4266 if self.restore_acceptable_point() {
4267 IterateOutcome::Terminate(SolverReturn::StopAtAcceptablePoint)
4268 } else {
4269 let diverging = {
4270 let data = self.data.borrow();
4271 match data.curr.as_ref() {
4272 Some(curr) => {
4273 let amax = curr.x.amax();
4274 amax > self.diverging_iterates_tol
4275 && self.divergence_is_true_unboundedness(&*curr.x)
4276 && (amax >= Self::DIVERGENCE_ABS_RUNAWAY
4277 || self.divergence_streak >= Self::DIVERGENCE_PERSIST_ITERS)
4278 }
4279 None => false,
4280 }
4281 };
4282 if diverging {
4283 IterateOutcome::Terminate(SolverReturn::DivergingIterates)
4284 } else {
4285 IterateOutcome::Terminate(SolverReturn::RestorationFailure)
4286 }
4287 }
4288 }
4289 RestorationOutcome::UserRequestedStop => {
4290 // gh#645. Same discipline as the pounce#244 deadline
4291 // exit a few lines up, and for the same reason: the
4292 // recovered point is staged on `data.trial` and we
4293 // return without promoting it, so `data.curr` is still
4294 // the last iterate accepted for the *original* NLP.
4295 // That matters more than the status code to the caller
4296 // this exists for — a controller that aborts on a
4297 // deadline still has to apply something, and the
4298 // subproblem's iterate is not a point it should apply.
4299 IterateOutcome::Terminate(SolverReturn::UserRequestedStop)
4300 }
4301 RestorationOutcome::FeasiblePointFound => {
4302 // Port of `IpIpoptAlg.cpp:542` — the catch of
4303 // `FEASIBILITY_PROBLEM_SOLVED`, thrown by
4304 // `IpRestoMinC_1Nrm.cpp:269` when restoration reaches a
4305 // point feasible for a *square* original NLP. Upstream
4306 // recomputes the multipliers before returning
4307 // `FEASIBLE_POINT_FOUND`; without that step the reported
4308 // dual infeasibility is `∇f` at a point whose status says
4309 // the constraints are satisfied. On the gh#508 probe that
4310 // is the difference between Ipopt's `1.78e-15` and a bare
4311 // `10.0`.
4312 //
4313 // The driver has already promoted the recovered point to
4314 // `data.curr`, so the multipliers are computed at the
4315 // point that will be reported.
4316 if self.is_square_problem() {
4317 self.compute_feasibility_multipliers_postprocess();
4318 }
4319 IterateOutcome::Terminate(SolverReturn::FeasiblePointFound)
4320 }
4321 RestorationOutcome::LocallyInfeasible => {
4322 // Mirrors upstream's catch of `LOCALLY_INFEASIBLE` thrown
4323 // from `IpRestoConvCheck.cpp:240` — the resto sub-IPM
4324 // settled at a stationary point of `||c(x)||_1` whose
4325 // residual is still well above `tol`. Without this
4326 // detection the outer would re-enter restoration on the
4327 // unchanged iterate forever.
4328 //
4329 // gh #505: consult the acceptable-point stash, for the same
4330 // reason the conv-check arm above does and the cycle exits
4331 // already did. This is the *third* site that produced
4332 // `LocalInfeasibility`, and the only one not gated on
4333 // `infeas_max_streak` — which matters, because on the reported
4334 // instance raising that knob to 15 did not move the run by a
4335 // single iteration, so the verdict there is not the outer
4336 // detector's. Whichever route reaches it, a solve that passed
4337 // through an acceptable iterate must not discard it.
4338 //
4339 // Inert on genuinely infeasible models by the same argument as
4340 // the other two: nothing is stashed unless the whole acceptable
4341 // triplet passed, so `terminate_acceptable_or` falls through to
4342 // the verdict unchanged.
4343 self.terminate_local_infeasibility()
4344 }
4345 }
4346 }
4347
4348 /// Safe-slack bound adjustment, applied to the staged `trial`
4349 /// iterate before it is promoted to `curr`. When one or more trial
4350 /// slacks fell below `eps*min(1,mu)`, [`IpoptCalculatedQuantities::
4351 /// adjusted_trial_bounds`] returns the moved `x_L/x_U/d_L/d_U`; we
4352 /// install them on the NLP so the slack becomes representable. Port
4353 /// of the bound-adjustment block in `IpoptAlgorithm::AcceptTrialPoint`
4354 /// (`IpIpoptAlg.cpp:664-706`).
4355 fn adjust_variable_bounds_for_small_slacks(&mut self) {
4356 // Compute the moved bounds (releases the CQ/NLP borrows on return).
4357 let adjusted = {
4358 let trial_set = self.data.borrow().trial.is_some();
4359 if !trial_set {
4360 return;
4361 }
4362 self.cq.borrow().adjusted_trial_bounds()
4363 };
4364 let Some(bounds) = adjusted else {
4365 return;
4366 };
4367 tracing::debug!(
4368 target: "pounce::algorithm",
4369 "slack_move: {} slack(s) too small, adjusting variable bound(s) at iter {}",
4370 bounds.adjusted,
4371 self.data.borrow().iter_count,
4372 );
4373 let nlp = Rc::clone(self.cq.borrow().nlp());
4374 nlp.borrow_mut().adjust_variable_bounds(
4375 &*bounds.x_l,
4376 &*bounds.x_u,
4377 &*bounds.d_l,
4378 &*bounds.d_u,
4379 );
4380 }
4381
4382 /// Refresh the `s`-block factors for
4383 /// `linear_system_scaling=slack-based`.
4384 ///
4385 /// A no-op for every other scaling choice: the flag is set only when
4386 /// the builder installed a `SlackBasedTSymScalingMethod`, and the
4387 /// method itself ignores the push if it never receives one.
4388 ///
4389 /// Silently skips when the quantity cannot be formed — no NLP, no
4390 /// current iterate, no inequality rows, or a primal vector shape the
4391 /// CQ does not recognise. The scaling method then keeps behaving as
4392 /// identity, which is what `linear_system_scaling=none` would have
4393 /// done, so a missing push costs conditioning and never correctness.
4394 fn push_slack_scaling(&mut self) {
4395 if !self.slack_based_scaling {
4396 return;
4397 }
4398 if self.nlp.is_none() {
4399 return;
4400 }
4401 let nx = match self.data.borrow().curr.as_ref() {
4402 Some(c) => c.x.dim(),
4403 None => return,
4404 };
4405 let Some(s_scale) = self.cq.borrow().curr_slack_based_s_scaling() else {
4406 return;
4407 };
4408 if s_scale.is_empty() {
4409 return;
4410 }
4411 if let Some(sd) = self.search_dir.as_mut() {
4412 sd.pd_solver_mut()
4413 .aug_solver_mut()
4414 .set_slack_scaling(nx, &s_scale);
4415 }
4416 }
4417
4418 /// `recalc_y` — replace `y_c`/`y_d` with least-square estimates once
4419 /// the iterate is feasible enough. Port of the `recalc_y_` block in
4420 /// `IpIpoptAlg.cpp:AcceptTrialPoint`.
4421 ///
4422 /// Silently does nothing — leaving the Newton-step multipliers in
4423 /// place — when disabled, when the violation is still above
4424 /// `recalc_y_feas_tol`, when there is nothing to estimate, or when
4425 /// the augmented-system solve fails. A failed estimate is not an
4426 /// error: the multipliers we already have are valid, just less
4427 /// accurate, so falling back to them costs accuracy and never
4428 /// correctness. Same reasoning as the initializer's `y0` fallback in
4429 /// `init/default.rs`.
4430 fn maybe_recalc_y(&mut self) {
4431 if !self.recalc_y {
4432 return;
4433 }
4434 let Some(nlp) = self.nlp.as_ref().map(Rc::clone) else {
4435 return;
4436 };
4437 // Feasibility gate. Upstream compares against the same
4438 // `curr_constraint_violation` the convergence check uses.
4439 if self.cq.borrow().curr_constraint_violation() >= self.recalc_y_feas_tol {
4440 return;
4441 }
4442 let (n_yc, n_yd) = {
4443 let d = self.data.borrow();
4444 match d.curr.as_ref() {
4445 Some(c) => (c.y_c.dim(), c.y_d.dim()),
4446 None => return,
4447 }
4448 };
4449 if n_yc + n_yd == 0 {
4450 return;
4451 }
4452 // The augmented-system solver is owned by the search-direction
4453 // calculator, as it is for the initializer's least-square call.
4454 let Some(sd) = self.search_dir.as_mut() else {
4455 return;
4456 };
4457 let mut new_y_c = pounce_linalg::dense_vector::DenseVectorSpace::new(n_yc).make_new_dense();
4458 let mut new_y_d = pounce_linalg::dense_vector::DenseVectorSpace::new(n_yd).make_new_dense();
4459 let mut pd_guard = sd.pd_solver_mut();
4460 // This was the first call site to drop review item M3's 1e-8
4461 // perturbation (#688), on the argument that `recalc_y` overwrites
4462 // `y` every iteration — so a bias in the estimator is a *fixed
4463 // point* rather than a transient, nothing downstream corrects it,
4464 // and it lands directly in `inf_du`, the quantity the run is
4465 // judged on. gh#693 extended the same treatment to the other
4466 // three sites, so `calculate_y_eq` no longer takes a flag: every
4467 // caller now gets δ=0 with the perturbed solve as a retry.
4468 let ok = self.bundle.eq_mult.calculate_y_eq(
4469 &self.data,
4470 &self.cq,
4471 &nlp,
4472 pd_guard.aug_solver_mut(),
4473 &mut new_y_c,
4474 &mut new_y_d,
4475 );
4476 drop(pd_guard);
4477 if !ok {
4478 tracing::debug!(
4479 target: "pounce::algorithm",
4480 "recalc_y: least-square solve failed at iter {}, keeping Newton multipliers",
4481 self.data.borrow().iter_count,
4482 );
4483 return;
4484 }
4485 // Mark the iteration, exactly as upstream does
4486 // (`IpData().Append_info_string("y ")` in
4487 // `IpIpoptAlg.cpp:AcceptTrialPoint`). Without it the iteration
4488 // log gives no way to tell which iterations re-estimated `y`
4489 // and which carried the Newton multipliers — and when a solve
4490 // stalls with an oscillating `inf_du`, whether the oscillation
4491 // tracks the re-estimation is the first thing worth knowing.
4492 self.data.borrow_mut().append_info_string("y ");
4493
4494 // Share x/s/z/v; swap only the equality/inequality multipliers.
4495 let curr = match self.data.borrow().curr.clone() {
4496 Some(c) => c,
4497 None => return,
4498 };
4499 let new_iv = crate::iterates_vector::IteratesVector::new(
4500 curr.x.clone(),
4501 curr.s.clone(),
4502 Rc::new(new_y_c),
4503 Rc::new(new_y_d),
4504 curr.z_l.clone(),
4505 curr.z_u.clone(),
4506 curr.v_l.clone(),
4507 curr.v_u.clone(),
4508 );
4509 self.data.borrow_mut().set_curr(new_iv);
4510 }
4511
4512 /// Port of `IpoptCalculatedQuantities::IsSquareProblem`
4513 /// (`IpIpoptCalculatedQuantities.cpp:3732`): as many equality
4514 /// constraints as variables, so the NLP has zero degrees of freedom.
4515 /// There is nothing to optimise — only a system to solve — and the
4516 /// objective is decorative.
4517 ///
4518 /// The consequence that matters is algebraic. `J_c` is square, so the
4519 /// least-square multiplier system `J_cᵀ y = −∇f (+ bound terms)` is
4520 /// exactly solvable and the dual residual can always be driven to
4521 /// zero, however large `y` has to be. On a non-square problem it
4522 /// generally cannot, which is why this is the right gate and not a
4523 /// heuristic.
4524 fn is_square_problem(&self) -> bool {
4525 match self.data.borrow().curr.as_ref() {
4526 Some(c) => c.x.dim() == c.y_c.dim(),
4527 None => false,
4528 }
4529 }
4530
4531 /// Zero the four bound multipliers and replace `y_c`/`y_d` with the
4532 /// least-square multipliers of the resulting feasibility problem —
4533 /// the shared body of `ComputeFeasibilityMultipliers`
4534 /// (`IpIpoptAlg.cpp:893-922`) and
4535 /// `ComputeFeasibilityMultipliersPostprocess` (`cpp:964-984`), which
4536 /// upstream writes out twice.
4537 ///
4538 /// Returns the iterate that was in place beforehand, so a caller that
4539 /// must be able to undo the swap can. `None` means nothing was
4540 /// installed — no iterate, no multipliers, or the least-square solve
4541 /// failed — in which case the original iterate is left untouched.
4542 fn install_feasibility_multipliers(
4543 &mut self,
4544 ) -> Option<crate::iterates_vector::IteratesVector> {
4545 let nlp = self.nlp.as_ref().map(Rc::clone)?;
4546 let curr_backup = self.data.borrow().curr.clone()?;
4547 let (n_yc, n_yd) = (curr_backup.y_c.dim(), curr_backup.y_d.dim());
4548 if n_yc + n_yd == 0 {
4549 return None;
4550 }
4551
4552 // Zero the bound multipliers and install that iterate *before* the
4553 // solve, so the least-square RHS is the feasibility problem's
4554 // (`cpp:893-910`): the calculator reads `curr`, so the zeroing has
4555 // to be visible to it, not applied to the result afterwards.
4556 let zeroed = |v: &Rc<dyn Vector>| -> Rc<dyn Vector> {
4557 let mut t = v.make_new();
4558 t.set(0.0);
4559 Rc::from(t)
4560 };
4561 let z_l = zeroed(&curr_backup.z_l);
4562 let z_u = zeroed(&curr_backup.z_u);
4563 let v_l = zeroed(&curr_backup.v_l);
4564 let v_u = zeroed(&curr_backup.v_u);
4565 self.data
4566 .borrow_mut()
4567 .set_curr(crate::iterates_vector::IteratesVector::new(
4568 curr_backup.x.clone(),
4569 curr_backup.s.clone(),
4570 curr_backup.y_c.clone(),
4571 curr_backup.y_d.clone(),
4572 z_l.clone(),
4573 z_u.clone(),
4574 v_l.clone(),
4575 v_u.clone(),
4576 ));
4577
4578 let mut new_y_c = pounce_linalg::dense_vector::DenseVectorSpace::new(n_yc).make_new_dense();
4579 let mut new_y_d = pounce_linalg::dense_vector::DenseVectorSpace::new(n_yd).make_new_dense();
4580 let ok = match self.search_dir.as_mut() {
4581 None => false,
4582 Some(sd) => {
4583 let mut pd_guard = sd.pd_solver_mut();
4584 let ok = self.bundle.eq_mult.calculate_y_eq(
4585 &self.data,
4586 &self.cq,
4587 &nlp,
4588 pd_guard.aug_solver_mut(),
4589 &mut new_y_c,
4590 &mut new_y_d,
4591 );
4592 drop(pd_guard);
4593 ok
4594 }
4595 };
4596 if !ok {
4597 // `cpp:986` logs a warning and keeps whatever `y` was there.
4598 tracing::debug!(
4599 target: "pounce::algorithm",
4600 "square problem: least-square multiplier solve failed, keeping Newton multipliers",
4601 );
4602 self.data.borrow_mut().set_curr(curr_backup);
4603 return None;
4604 }
4605
4606 self.data
4607 .borrow_mut()
4608 .set_curr(crate::iterates_vector::IteratesVector::new(
4609 curr_backup.x.clone(),
4610 curr_backup.s.clone(),
4611 Rc::new(new_y_c),
4612 Rc::new(new_y_d),
4613 z_l,
4614 z_u,
4615 v_l,
4616 v_u,
4617 ));
4618 Some(curr_backup)
4619 }
4620
4621 /// Port of `IpoptAlgorithm::ComputeFeasibilityMultipliersPostprocess`
4622 /// (`IpIpoptAlg.cpp:949`). Same swap as
4623 /// [`Self::compute_feasibility_multipliers`], but unconditional: the
4624 /// run is over and the point has already been judged, so there is no
4625 /// convergence check to gate on and nothing to restore. Called on the
4626 /// two square-problem exits that report a feasible point
4627 /// (`cpp:484`, `cpp:542`), whose whole claim is that the constraints
4628 /// are satisfied — reporting `∇f` as the dual residual of such a point
4629 /// would contradict the status printed next to it.
4630 fn compute_feasibility_multipliers_postprocess(&mut self) {
4631 debug_assert!(self.is_square_problem());
4632 let _ = self.install_feasibility_multipliers();
4633 }
4634
4635 /// Port of `IpoptAlgorithm::ComputeFeasibilityMultipliers`
4636 /// (`IpIpoptAlg.cpp:857`). On a square problem, once the iterate is
4637 /// primal-feasible to `constr_viol_tol`, re-estimate `y_c`/`y_d` as
4638 /// the multipliers of the *feasibility* problem: zero the four bound
4639 /// multipliers and take the least-square `y` against that iterate. If
4640 /// the convergence check then accepts, keep them; otherwise restore
4641 /// the iterate untouched.
4642 ///
4643 /// Why it exists (gh#508). A square problem is a system of equations.
4644 /// If the solver has found a point satisfying them to the tolerance
4645 /// the user declared, that point *is* the answer, and the leftover
4646 /// objective gradient is not evidence of anything. Without this,
4647 /// `inf_du` carries `∇f` — on the gh#508 probe `|2(x−5)| = 10` at a
4648 /// point whose violation is `1e-4` inside a `constr_viol_tol` of
4649 /// `1e-3` — the convergence check refuses, and the rapid-infeasibility
4650 /// detector convicts a point Ipopt calls feasible.
4651 ///
4652 /// Note the double convergence check, which is upstream's too: one to
4653 /// decide whether to bother (`cpp:880`), one to decide whether to keep
4654 /// the result (`cpp:924`). Both go through
4655 /// [`ConvergenceCheck::probe_convergence`], not the real check —
4656 /// upstream can afford `CheckConvergence` here because the only state
4657 /// it carries is `acceptable_counter_`, whereas pounce's also carries
4658 /// the gh#505 rapid-infeasibility streak, the gh#200 veto budget and
4659 /// the gh#533 progress window. Advancing those three times per
4660 /// iteration instead of once is not a faithful port of anything: on
4661 /// the gh#508 probe it moved the infeasibility conviction from
4662 /// iteration 86 to 33.
4663 fn compute_feasibility_multipliers(&mut self) {
4664 debug_assert!(self.is_square_problem());
4665
4666 // Not primal feasible yet → no multipliers to compute (cpp:864).
4667 // Upstream measures this on the *unscaled* violation in the max
4668 // norm, against `constr_viol_tol`.
4669 let constr_viol_tol = self.bundle.conv_check.constr_viol_tol_or_default();
4670 if self.cq.borrow().curr_unscaled_primal_infeasibility_max() > constr_viol_tol {
4671 return;
4672 }
4673
4674 // No calculator → upstream logs and leaves `y` alone (cpp:872).
4675 if self.nlp.is_none() {
4676 return;
4677 }
4678
4679 // `iter_count + 1`: see the call site. Upstream has already
4680 // incremented when it reaches here.
4681 let iter_count = self.data.borrow().iter_count + 1;
4682 let nlp_err = self.cq.borrow().curr_nlp_error();
4683 if !nlp_err.is_finite() {
4684 return;
4685 }
4686
4687 // Already converged, or out of iterations/time → do not touch the
4688 // multipliers (cpp:884). `Continue` is the case worth acting on:
4689 // it usually means dual feasibility is what is still missing.
4690 if self
4691 .bundle
4692 .conv_check
4693 .probe_convergence(nlp_err, iter_count, &self.data, &self.cq)
4694 != ConvergenceStatus::Continue
4695 {
4696 return;
4697 }
4698
4699 let Some(curr_backup) = self.install_feasibility_multipliers() else {
4700 return;
4701 };
4702
4703 // Keep them only if they actually buy a verdict (cpp:924).
4704 let nlp_err = self.cq.borrow().curr_nlp_error();
4705 if nlp_err.is_finite()
4706 && matches!(
4707 self.bundle
4708 .conv_check
4709 .probe_convergence(nlp_err, iter_count, &self.data, &self.cq,),
4710 ConvergenceStatus::Converged | ConvergenceStatus::ConvergedToAcceptable
4711 )
4712 {
4713 // Upstream marks nothing here; `"y "` is `recalc_y`'s. Use a
4714 // distinct tag so the iteration log says which mechanism
4715 // moved the multipliers.
4716 self.data.borrow_mut().append_info_string("f ");
4717 return;
4718 }
4719
4720 tracing::debug!(
4721 target: "pounce::algorithm",
4722 "square problem: feasibility multipliers at iter {} did not converge the check, restoring",
4723 iter_count,
4724 );
4725 self.data.borrow_mut().set_curr(curr_backup);
4726 }
4727
4728 /// Port of `IpIpoptAlg::correct_bound_multiplier`
4729 /// (`IpIpoptAlg.cpp:1055-1134`). Clamp each bound multiplier
4730 /// component into `[mu/(kappa_sigma * s_i), kappa_sigma * mu / s_i]`
4731 /// for all four bound-multiplier vectors.
4732 fn correct_bound_multiplier(&mut self) {
4733 if self.kappa_sigma < 1.0 {
4734 return;
4735 }
4736 let mu = self.data.borrow().curr_mu;
4737 let curr = match self.data.borrow().curr.clone() {
4738 Some(c) => c,
4739 None => return,
4740 };
4741
4742 let cq = self.cq.borrow();
4743
4744 let z_l_new = clamp_against_slack(&*curr.z_l, &*cq.curr_slack_x_l(), mu, self.kappa_sigma);
4745 let z_u_new = clamp_against_slack(&*curr.z_u, &*cq.curr_slack_x_u(), mu, self.kappa_sigma);
4746 let v_l_new = clamp_against_slack(&*curr.v_l, &*cq.curr_slack_s_l(), mu, self.kappa_sigma);
4747 let v_u_new = clamp_against_slack(&*curr.v_u, &*cq.curr_slack_s_u(), mu, self.kappa_sigma);
4748 drop(cq);
4749
4750 let new_iv = crate::iterates_vector::IteratesVector::new(
4751 curr.x.clone(),
4752 curr.s.clone(),
4753 curr.y_c.clone(),
4754 curr.y_d.clone(),
4755 z_l_new,
4756 z_u_new,
4757 v_l_new,
4758 v_u_new,
4759 );
4760 self.data.borrow_mut().set_curr(new_iv);
4761 }
4762
4763 /// Outer entry point — port of `IpoptAlgorithm::Optimize()`. Calls
4764 /// the iterate-initializer once, then loops `iterate()` until a
4765 /// terminal status. The exception → SolverReturn mapping
4766 /// (TINY_STEP_DETECTED → STEP_BECOMES_TINY,
4767 /// RESTORATION_FAILED → RESTORATION_FAILURE, etc.) lands in
4768 /// Phase 9 alongside the restoration phase.
4769 /// Run the solve and finalize its result.
4770 ///
4771 /// A thin wrapper on purpose. The gh #200 fallback must see **every** exit
4772 /// of the driver loop, and wiring it into individual termination sites was
4773 /// tried and failed — there are sixteen, and the ones easiest to overlook
4774 /// are the ones most likely to matter. Keeping the loop in a separate
4775 /// function means every `return` inside it, present or future, flows through
4776 /// [`Self::honour_refused_certificate`] by construction rather than by the
4777 /// author remembering to.
4778 ///
4779 /// This got more important once the fallback started changing the status in
4780 /// *both* directions: it can now hand back `StopAtAcceptablePoint` for a
4781 /// `Success` it was given. Anything reading `result` before the hook is
4782 /// reading a status that is not the one reported.
4783 pub fn optimize(&mut self) -> SolverReturn {
4784 let result = self.optimize_inner();
4785
4786 // gh #200: a refused certificate outranks any non-success verdict the
4787 // continued run reached, and an earlier refusal can outrank the
4788 // continued run's own certificate. Applied here, once.
4789 let result = self.honour_refused_certificate(result);
4790
4791 // pounce#250 follow-up: the dual-divergence guard's diversion to
4792 // restoration is a bet, and a lost bet must not return a worse point
4793 // than the solve already had in hand. Applied here, once, for the same
4794 // reason the #200 hook is — every `return` in the loop flows through
4795 // this point by construction.
4796 let result = self.honour_best_acceptable_after_dual_guard(result);
4797
4798 // gh #534: deferring the acceptable-point restoration decline is also a
4799 // bet, and this is the net under it — a continuation that did not beat
4800 // the point the guard would have returned hands that point back. Last of
4801 // the three, so it compares against whatever the hooks above settled on.
4802 let result = self.honour_decline_floor(result);
4803
4804 // gh #797: leaving a certified stationary point along a direction of
4805 // negative curvature is a bet placed *from* a certificate, so it is
4806 // settled last — whatever the hooks above arrived at, the escape either
4807 // beat the point it left with a certificate of its own or that point is
4808 // handed back.
4809 let result = self.honour_neg_curv_floor(result);
4810
4811 // Terminal post-mortem checkpoint. Skipped when the user already
4812 // asked to stop (they were just at a prompt); otherwise the
4813 // debugger gets a last look at the final/failing iterate.
4814 if !matches!(result, SolverReturn::UserRequestedStop) {
4815 self.fire_debug_terminal(result);
4816 }
4817 result
4818 }
4819
4820 fn optimize_inner(&mut self) -> SolverReturn {
4821 // Top-level span for the whole solve; every iteration / linear
4822 // solve / restoration event nests under it (pounce#71).
4823 let _solve_span = tracing::info_span!("solve").entered();
4824
4825 // Shared timing accumulator — every phase below records into it.
4826 let timing = self.data.borrow().timing.clone();
4827
4828 // Install the shared accumulator on the augmented-system solver
4829 // so its factor / back-solve calls are attributed to
4830 // `linear_system_factorization` / `linear_system_back_solve`.
4831 // Same pattern for the diagnostics state when present, so KKT
4832 // dump sites can consult per-iter gating.
4833 if let Some(sd) = self.search_dir.as_mut() {
4834 sd.pd_solver_mut()
4835 .aug_solver_mut()
4836 .set_timing_stats(std::rc::Rc::clone(&timing));
4837 if let Some(diag) = self.diagnostics.as_ref() {
4838 sd.pd_solver_mut()
4839 .aug_solver_mut()
4840 .set_diagnostics(Rc::clone(diag));
4841 }
4842 }
4843
4844 // 0a. Strategy initialization — port of upstream's
4845 // `IpoptAlgorithm::InitializeImpl` calls. The mu update needs
4846 // `data.curr_mu`/`curr_tau` seeded before the iterate
4847 // initializer runs (`CalculateSafeSlack` reads them).
4848 self.bundle.mu_update.initialize(&self.data);
4849
4850 // 0b. Iterate initializer. Requires NLP; without one the caller
4851 // must have populated `data.curr` themselves.
4852 if let Some(nlp) = self.nlp.as_ref() {
4853 // The initializer needs an aug-system solver for the
4854 // least-square multiplier branch; until that's wired we
4855 // route through whatever the search-direction calculator
4856 // owns when present. For the stub flow we skip the LSM
4857 // path by giving the initializer a dummy solver only if
4858 // the search_dir is present (otherwise the init function
4859 // is responsible for not consulting it).
4860 if let Some(sd) = self.search_dir.as_mut() {
4861 timing.initialize_iterates.start();
4862 let mut pd_guard = sd.pd_solver_mut();
4863 let aug_solver = pd_guard.aug_solver_mut();
4864 let ok = self
4865 .bundle
4866 .init
4867 .set_initial_iterates(&self.data, &self.cq, nlp, aug_solver);
4868 drop(pd_guard);
4869 timing.initialize_iterates.end();
4870 if !ok {
4871 return SolverReturn::InvalidProblemDefinition;
4872 }
4873 }
4874 }
4875
4876 // 0c. Seed `IpoptData::w` with the initial-iterate Hessian.
4877 // Redundant with the iter-body `update_hessian` call (which
4878 // now runs BEFORE `update_barrier_parameter`) but kept to
4879 // cover any code path that consults `data.w` between
4880 // `set_initial_iterates` and the first `iterate()` call
4881 // (e.g. the iter-0 trace dump below).
4882 if self.data.borrow().curr.is_some() {
4883 timing.update_hessian.start();
4884 let _ = self.bundle.hess.update_hessian(&self.data, &self.cq);
4885 timing.update_hessian.end();
4886 }
4887
4888 // Track-A iterate-trace dumper. Activated by
4889 // `IPOPT_ITER_DUMP_PATH`; otherwise no-op. See `iter_dump.rs`.
4890 let mut dumper = IterDumper::from_env();
4891 // Iter 0 record — captures the initialised iterate before any
4892 // step. Mirrors upstream's "after InitializeIterates(), before
4893 // the loop" emission point.
4894 if let Some(d) = dumper.as_mut() {
4895 d.write_record(&self.data, &self.cq);
4896 }
4897
4898 // Advance the diagnostics iter counter so the first `iterate()`
4899 // body reports as iter 0 (matches `data.iter_count`). Subsequent
4900 // bumps live at the bottom of the loop alongside the iter_count
4901 // bookkeeping.
4902 if let Some(diag) = self.diagnostics.as_ref() {
4903 diag.bump_iter();
4904 // Iter-0 iterate row (issue #68). Same hook point as
4905 // the binary IterDumper above; emits only when
4906 // `--dump iterates:*` is configured.
4907 emit_iterate_record(diag.as_ref(), &self.data, &self.cq);
4908 }
4909
4910 // Iter 0 intermediate callback — upstream fires once after
4911 // `InitializeIterates` before the loop body starts so users
4912 // observe the initial point.
4913 if !self.fire_intermediate() {
4914 return SolverReturn::UserRequestedStop;
4915 }
4916 if self.fire_debug(crate::debug::Checkpoint::IterStart) == crate::debug::DebugAction::Stop {
4917 return SolverReturn::UserRequestedStop;
4918 }
4919
4920 // pounce#246: bound the initialization / restoration-entry window.
4921 // Everything above — `mu_update.initialize`, `set_initial_iterates`
4922 // (which for a bad warm start can grind in its least-square /
4923 // feasibility setup), and the initial `update_hessian` — runs
4924 // *before* the first `iterate()`, whose convergence check and
4925 // post-`compute_search_direction` gate are the earliest deadline
4926 // checks (#242/#244/#245). A solve handed a poor warm start could
4927 // therefore spend the whole budget here and only consult the
4928 // deadline once it reached the first outer-iteration /
4929 // KKT-factorization boundary. Consult it now, before the loop, so a
4930 // bad-start init stall returns promptly with the time-limit status
4931 // (best-so-far being the initialised iterate) instead of running to
4932 // a multiple of the budget. This also bounds the *restoration
4933 // entry*: the nested restoration IPM shares this `Deadline` and runs
4934 // the same `optimize_inner`, so a budget already crossed by the time
4935 // the inner solve starts up terminates it here rather than after its
4936 // own first iterate.
4937 if let Some(ret) = self.deadline_status() {
4938 return ret;
4939 }
4940
4941 let result = loop {
4942 match self.iterate() {
4943 IterateOutcome::Terminate(ret) => break ret,
4944 IterateOutcome::Continue => {
4945 // Source the local counter from `data.iter_count`
4946 // each pass so a pre-seeded counter (e.g. the inner
4947 // restoration IPM at `outer.iter + 1`, matching
4948 // upstream `IpRestoMinC_1Nrm.cpp:181`) and any
4949 // restoration step that set
4950 // `data.iter_count = inner.iter_count - 1`
4951 // (mirroring `IpRestoMinC_1Nrm.cpp:Set_iter_count`)
4952 // are honored — without this the local counter
4953 // would advance from its pre-restoration value,
4954 // ignoring the inner-IPM iterations.
4955 let mut iter_count: Index = self.data.borrow().iter_count;
4956 iter_count += 1;
4957 // Do NOT short-circuit to `MaxiterExceeded` here: bump the
4958 // counter and loop, letting the next `iterate()` run its
4959 // convergence check (`OptimalityErrorConvergenceCheck`,
4960 // which tests the component tolerances *before* its own
4961 // `iter_count >= max_iter` gate at
4962 // `conv_check/opt_error.rs`). Breaking before that call
4963 // skipped the convergence test on the iterate produced by
4964 // the final permitted step, so a solve converging on
4965 // exactly the `max_iter`-th iterate reported
4966 // `Maximum_Iterations_Exceeded` where upstream Ipopt —
4967 // which runs `CheckConvergence` at the top of its loop,
4968 // convergence-first — reports success. The check is
4969 // guaranteed to terminate the loop: once `iter_count`
4970 // reaches `max_iter`, `check_convergence_with_state`
4971 // returns either `Converged`/`ConvergedToAcceptable` or
4972 // `MaxIterExceeded`, never `Continue` (L1).
4973 self.data.borrow_mut().iter_count = iter_count;
4974 // Floor evidence for restoration's gh#661 divergence
4975 // guard: how long this solve has sat at a violation
4976 // it could not get below. Sampled here, once per
4977 // accepted iterate, from the same quantity the
4978 // `inf_pr` column reports — so it is free, and it
4979 // sees the whole outer trajectory rather than the
4980 // handful of iterations a restoration sub-solve runs.
4981 // The nested restoration IPM reaches this line too,
4982 // but writes its own `IpoptData`, so the two
4983 // trajectories never mix.
4984 let inf_pr_now = self.cq.borrow().curr_primal_infeasibility_max();
4985 self.data.borrow_mut().inf_pr_floor.observe(inf_pr_now);
4986 // Keep the diagnostics counter in lock-step with
4987 // `data.iter_count` so KKT-dump gating reflects the
4988 // about-to-execute iteration.
4989 if let Some(diag) = self.diagnostics.as_ref() {
4990 diag.bump_iter();
4991 // Per-iter iterate row (issue #68). Mirrors
4992 // the binary IterDumper hook below.
4993 emit_iterate_record(diag.as_ref(), &self.data, &self.cq);
4994 }
4995 // Per-iteration record — emitted after the
4996 // iter_count bump so the recorded `iter` field
4997 // matches `IpData().iter_count()` at the moment of
4998 // emission, identical to upstream's writer.
4999 if let Some(d) = dumper.as_mut() {
5000 d.write_record(&self.data, &self.cq);
5001 }
5002 // Per-iteration intermediate callback — fired with
5003 // an `IntermediateContext` guard so downstream
5004 // inspector entry points (the C API
5005 // `GetIpoptCurrent*` family) see live state for the
5006 // duration of the user callback.
5007 if !self.fire_intermediate() {
5008 break SolverReturn::UserRequestedStop;
5009 }
5010 if self.fire_debug(crate::debug::Checkpoint::IterStart)
5011 == crate::debug::DebugAction::Stop
5012 {
5013 break SolverReturn::UserRequestedStop;
5014 }
5015 }
5016 }
5017 };
5018
5019 result
5020 }
5021}
5022
5023/// A termination certificate the masked-scale veto refused (gh #200), with
5024/// everything the fallback needs to undo the refusal verbatim.
5025///
5026/// One struct rather than a field per component. The fallback is only correct if
5027/// these all describe *the same iterate*, and parallel `Option`s make
5028/// "objective recorded, iterate missing" representable — which was reachable:
5029/// the iterate is cloned out of `data.curr` and can come back `None`, while the
5030/// objective and barrier parameter were written unconditionally. Capture is now
5031/// all-or-nothing, so the disagreement cannot be constructed.
5032#[derive(Clone)]
5033struct VetoSnapshot {
5034 /// The refused iterate itself.
5035 iterate: crate::iterates_vector::IteratesVector,
5036 /// Iteration at which the refusal happened.
5037 ///
5038 /// Needed to identify which refusal is the *baseline-equivalent* one. The
5039 /// baseline stops at the first iterate where it would terminate, so when
5040 /// both a strict and an acceptable-level refusal are on record it is the
5041 /// chronologically earlier one that says what the baseline returned — not
5042 /// the stricter one. The later refusal sits on the continued trajectory,
5043 /// which the baseline never walked, so comparing against it compares
5044 /// against a point that was never on offer.
5045 iter: Index,
5046 /// Scaled objective there, so the refused point can be compared against
5047 /// whatever the continued run reached without re-evaluating it.
5048 obj: Number,
5049 /// Barrier parameter there.
5050 ///
5051 /// `curr_mu` lives on `IpoptData` rather than in the `IteratesVector`, so
5052 /// restoring the iterate does not rewind it, and `stats.final_mu` is read
5053 /// after the restore — leaving the continued run's barrier parameter
5054 /// reported next to the refused run's `x`. That pair feeds a warm-started
5055 /// corrector's `mu_init` and reaches callers as `info["mu"]`, so it must
5056 /// describe the point actually returned. (Not currently observable: `mu` has
5057 /// bottomed out at its floor in every fallback case reachable so far, making
5058 /// the two values coincide. Kept correct rather than left to depend on that.)
5059 mu: Number,
5060 /// Max-norm unscaled KKT error there, so the tiebreak can see what
5061 /// `apply_kkt_fidelity_gate` will see. Recorded at refusal time because the
5062 /// gate runs post-solve, long after this iterate is gone.
5063 unscaled_kkt: Number,
5064 /// Unscaled max-norm constraint violation there — the same quantity the
5065 /// `acceptable_constr_viol_tol` gate is defined against
5066 /// (`curr_unscaled_primal_infeasibility_max`, cf. gh #261). The
5067 /// best-acceptable fallback ranks candidates by `(feasible_enough,
5068 /// objective)` rather than objective alone, so a point outside a capped
5069 /// feasibility band can never displace one inside it on objective grounds;
5070 /// without this field the ranking has no feasibility term and, under a
5071 /// user-widened `acceptable_constr_viol_tol`, will trade feasibility for
5072 /// objective and hand back a verifiably infeasible point under a success
5073 /// status (gh #267). Unused by the gh #200 masked-scale paths, which key on
5074 /// objective only.
5075 constr_viol: Number,
5076 /// Objective scaling factor in force when `obj` was recorded.
5077 ///
5078 /// `obj` is a *scaled* objective, so comparing it against the continued
5079 /// run's is only meaningful under the same factor, sign included. Held so
5080 /// that assumption is asserted rather than trusted: periodic or adaptive
5081 /// rescaling is a natural thing to add for exactly the ill-scaled problems
5082 /// this mechanism targets, and it would silently turn the comparison into
5083 /// noise.
5084 obj_scale: Number,
5085}
5086
5087/// Internal result of one [`IpoptAlgorithm::iterate`] call. Mirrors the
5088/// upstream try/catch around `IpoptAlg::Optimize` — anything that's not
5089/// `Continue` carries the [`SolverReturn`] that the outer loop will
5090/// surface to `IpoptApplication`.
5091enum IterateOutcome {
5092 Continue,
5093 Terminate(SolverReturn),
5094}
5095
5096/// Feasibility-aware ranking core for the best-acceptable fallback (gh #267).
5097///
5098/// `true` iff candidate `(a_obj, a_viol)` ranks **strictly** better than
5099/// `(b_obj, b_viol)` under the `(band_clamped_viol, objective)` key, where each
5100/// violation is clamped up to `band` before it is compared. Lexicographic: the
5101/// point with the smaller clamped violation wins outright; only when the clamped
5102/// violations tie does the lower objective decide. A non-finite objective ranks
5103/// worst (never wins, always loses to a finite one); a non-finite violation is
5104/// treated as infinitely infeasible.
5105///
5106/// The clamp is what makes this a **total order** rather than a two-class
5107/// partition, and it is the whole gh #280 fix. Clamping the violation *up* to
5108/// `band` collapses every point inside the feasibility band to the single value
5109/// `band`, so within the band those points tie on feasibility and objective
5110/// decides — the intended, gh #267-preserving behaviour. Outside the band the
5111/// clamp is the identity, so the *actual* violation decides and the
5112/// less-infeasible point always wins. The earlier `(feasible_enough, objective)`
5113/// key was a two-class partition: once **both** points sat outside the band it
5114/// fell through to a bare `a_obj < b_obj`, reading neither violation — the exact
5115/// pre-#267 objective-only rule, which lets a strictly-more-infeasible point win
5116/// on objective (gh #280). Under the clamped key a strictly-more-infeasible
5117/// point can never rank better, at any band.
5118///
5119/// Pure and total, so the fallback's "never worse off" guarantee is a theorem
5120/// this function's unit tests prove by cases — host-independent by construction,
5121/// unlike an end-to-end objective comparison across two live nonconvex solves
5122/// (the trap gh #267 caught). [`IpoptAlgorithm::ranks_better`] supplies `band`
5123/// as `min(acceptable_constr_viol_tol, FEASIBLE_ENOUGH_CAP)`; both the record
5124/// and the read side route through here, so they cannot disagree.
5125/// Append `value` to a fixed-capacity oldest-first window, dropping the oldest
5126/// sample once the window is full (gh #534).
5127fn push_sample(buf: &mut [Number; DECLINE_PROGRESS_SAMPLES], len: &mut usize, value: Number) {
5128 if *len < DECLINE_PROGRESS_SAMPLES {
5129 buf[*len] = value;
5130 *len += 1;
5131 } else {
5132 buf.rotate_left(1);
5133 buf[DECLINE_PROGRESS_SAMPLES - 1] = value;
5134 }
5135}
5136
5137/// Whether every consecutive pair in `samples` (oldest first) contracted by at
5138/// least `ratio` — the gh #534 progress test, as a pure function.
5139///
5140/// A sample that is not finite, or a predecessor that is not strictly positive,
5141/// fails the window: neither is evidence of progress, and a zero predecessor
5142/// makes the ratio meaningless. A `ratio` of `1` admits any non-increasing
5143/// window, and a large one admits every finite window — which is how
5144/// `resto_decline_progress_ratio` doubles as the "drop the progress
5145/// requirement" switch.
5146///
5147/// Pure and total for the same reason [`ranks_better_within_band`] is: the two
5148/// traces the issue records — `eigena2` quartering and `eigenb2` rising — decide
5149/// what this must do, and a unit test can hold it to them exactly.
5150fn window_is_contracting(samples: &[Number], ratio: Number) -> bool {
5151 samples.windows(2).all(|w| {
5152 let (prev, next) = (w[0], w[1]);
5153 prev.is_finite() && prev > 0.0 && next.is_finite() && next <= ratio * prev
5154 })
5155}
5156
5157fn ranks_better_within_band(
5158 a_obj: Number,
5159 a_viol: Number,
5160 b_obj: Number,
5161 b_viol: Number,
5162 band: Number,
5163) -> bool {
5164 if !a_obj.is_finite() {
5165 return false;
5166 }
5167 if !b_obj.is_finite() {
5168 return true;
5169 }
5170 // Clamp each violation up to `band`: everything inside the feasibility band
5171 // maps to the single value `band` (so objective decides there), while outside
5172 // it the actual violation is kept (so the less-infeasible point strictly
5173 // wins). A non-finite violation is infinitely infeasible. This is a total
5174 // order — a strictly-more-infeasible point can never rank better (gh #280).
5175 let clamped = |v: Number| {
5176 if v.is_finite() {
5177 v.max(band)
5178 } else {
5179 Number::INFINITY
5180 }
5181 };
5182 let (a_key, b_key) = (clamped(a_viol), clamped(b_viol));
5183 if a_key != b_key {
5184 // The less-infeasible point wins outright, whatever the objectives.
5185 return a_key < b_key;
5186 }
5187 // Same clamped feasibility (both inside the band, or an exact tie outside it):
5188 // lower objective wins (the original within-band behaviour).
5189 a_obj < b_obj
5190}
5191
5192/// `||a - b||_2 / (1 + ||b||_2)`. Used by the restoration cycle
5193/// detector in [`IpoptAlgorithm::invoke_restoration`] to test whether
5194/// the outer iterate has moved between two consecutive restoration
5195/// entries.
5196fn relative_distance(a: &dyn Vector, b: &dyn Vector) -> Number {
5197 if a.dim() == 0 {
5198 return 0.0;
5199 }
5200 let mut diff = a.make_new_copy();
5201 diff.axpy(-1.0, b);
5202 diff.nrm2() / (1.0 + b.nrm2())
5203}
5204
5205/// `out = curr + α_p · δ` for the primal/equality blocks and
5206/// `out = curr + α_d · δ` for the bound multipliers, returned as a
5207/// fresh frozen `IteratesVector`. Mirrors `scaled_step` in the line
5208/// search; duplicated here for the tiny-step branch which bypasses
5209/// the line-search driver.
5210fn scaled_step_unchecked(
5211 curr: &crate::iterates_vector::IteratesVector,
5212 delta: &crate::iterates_vector::IteratesVector,
5213 alpha_primal: Number,
5214 alpha_dual: Number,
5215) -> crate::iterates_vector::IteratesVector {
5216 let mut out = curr.make_new_zeroed();
5217 out.add_one_vector(1.0, curr, 0.0);
5218 out.x.axpy(alpha_primal, &*delta.x);
5219 out.s.axpy(alpha_primal, &*delta.s);
5220 out.y_c.axpy(alpha_primal, &*delta.y_c);
5221 out.y_d.axpy(alpha_primal, &*delta.y_d);
5222 out.z_l.axpy(alpha_dual, &*delta.z_l);
5223 out.z_u.axpy(alpha_dual, &*delta.z_u);
5224 out.v_l.axpy(alpha_dual, &*delta.v_l);
5225 out.v_u.axpy(alpha_dual, &*delta.v_u);
5226 out.freeze()
5227}
5228
5229/// Allocate a fresh `Rc<dyn Vector>` with `kappa_sigma_clamp`
5230/// applied component-wise against the supplied `slack`. Inputs are
5231/// borrowed; the original `z` is never mutated. Ports the per-vector
5232/// piece of `IpIpoptAlg.cpp:1080-1133`.
5233fn clamp_against_slack(
5234 z: &dyn Vector,
5235 slack: &dyn Vector,
5236 mu: Number,
5237 kappa_sigma: Number,
5238) -> Rc<dyn Vector> {
5239 debug_assert_eq!(z.dim(), slack.dim());
5240 let n = z.dim() as usize;
5241 // Flatten both z and slack into contiguous slices so the
5242 // elementwise clamp doesn't care whether the inputs are
5243 // [`DenseVector`] (regular IPM path) or [`CompoundVector`]
5244 // (resto IPM path). The result is reconstructed into a
5245 // same-shape Vector via `Vector::make_new` + a flat-write
5246 // helper so the caller sees a vector with the same blocking as
5247 // its input.
5248 let mut buf = vec![0.0_f64; n];
5249 flat_read_into(z, &mut buf);
5250 let s_vals = flat_read_owned(slack);
5251 let _ = kappa_sigma_clamp(&mut buf, &s_vals, mu, kappa_sigma);
5252 let mut out: Box<dyn Vector> = z.make_new();
5253 flat_write_into(&mut *out, &buf);
5254 Rc::from(out)
5255}
5256
5257pub(crate) fn flat_read_into(v: &dyn Vector, dst: &mut [Number]) {
5258 if let Some(dv) = v
5259 .as_any()
5260 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
5261 {
5262 let vs = dv.expanded_values();
5263 dst.copy_from_slice(&vs);
5264 return;
5265 }
5266 if let Some(cv) = v.as_any().downcast_ref::<pounce_linalg::CompoundVector>() {
5267 let mut off = 0usize;
5268 for k in 0..cv.n_comps() {
5269 let blk = cv.comp(k);
5270 let dim = blk.dim() as usize;
5271 let dblk = blk
5272 .as_any()
5273 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
5274 .expect("clamp_against_slack: CompoundVector blocks must be DenseVectors");
5275 let vs = dblk.expanded_values();
5276 dst[off..off + dim].copy_from_slice(&vs);
5277 off += dim;
5278 }
5279 return;
5280 }
5281 panic!("clamp_against_slack: unsupported Vector kind");
5282}
5283
5284pub(crate) fn flat_read_owned(v: &dyn Vector) -> Vec<Number> {
5285 let mut out = vec![0.0; v.dim() as usize];
5286 flat_read_into(v, &mut out);
5287 out
5288}
5289
5290pub(crate) fn flat_write_into(v: &mut dyn Vector, src: &[Number]) {
5291 if let Some(dv) = v
5292 .as_any_mut()
5293 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
5294 {
5295 dv.set_values(src);
5296 return;
5297 }
5298 if let Some(cv) = v
5299 .as_any_mut()
5300 .downcast_mut::<pounce_linalg::CompoundVector>()
5301 {
5302 let mut off = 0usize;
5303 for k in 0..cv.n_comps() {
5304 let blk = cv.comp_mut(k);
5305 let dim = blk.dim() as usize;
5306 let dblk = blk
5307 .as_any_mut()
5308 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
5309 .expect("clamp_against_slack: CompoundVector blocks must be DenseVectors");
5310 dblk.set_values(&src[off..off + dim]);
5311 off += dim;
5312 }
5313 return;
5314 }
5315 panic!("clamp_against_slack: unsupported Vector kind");
5316}
5317
5318/// Per-element kappa-sigma clamp — the elementwise arithmetic at the
5319/// heart of `IpIpoptAlg.cpp:correct_bound_multiplier` (lines
5320/// 1090-1133). For each index `i`:
5321///
5322/// ```text
5323/// slack_i = max(slack_i, tiny_double) // avoid /0
5324/// z_lo_i = mu / (kappa_sigma * slack_i)
5325/// z_hi_i = kappa_sigma * mu / slack_i
5326/// z_i ← clamp(z_i, z_lo_i, z_hi_i)
5327/// ```
5328///
5329/// Returns the maximum elementwise correction magnitude (matching
5330/// upstream's `Max(max_correction_up, max_correction_low)`).
5331///
5332/// `kappa_sigma < 1` short-circuits to the identity per upstream's
5333/// guard at line 1065.
5334pub fn kappa_sigma_clamp(
5335 z: &mut [Number],
5336 slack: &[Number],
5337 mu: Number,
5338 kappa_sigma: Number,
5339) -> Number {
5340 debug_assert_eq!(z.len(), slack.len());
5341 if kappa_sigma < 1.0 {
5342 return 0.0;
5343 }
5344 let mut max_correction = 0.0_f64;
5345 for (zi, &si) in z.iter_mut().zip(slack.iter()) {
5346 let s_safe = si.max(Number::MIN_POSITIVE);
5347 let lo = mu / (kappa_sigma * s_safe);
5348 let hi = kappa_sigma * mu / s_safe;
5349 let clamped = zi.clamp(lo, hi);
5350 let delta = (clamped - *zi).abs();
5351 if delta > max_correction {
5352 max_correction = delta;
5353 }
5354 *zi = clamped;
5355 }
5356 max_correction
5357}
5358
5359#[cfg(test)]
5360mod tests {
5361 use super::*;
5362
5363 #[test]
5364 fn kappa_sigma_below_one_is_identity() {
5365 let mut z = vec![1.0, 2.0, 3.0];
5366 let slack = [1.0, 1.0, 1.0];
5367 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 0.5);
5368 assert_eq!(m, 0.0);
5369 assert_eq!(z, [1.0, 2.0, 3.0]);
5370 }
5371
5372 #[test]
5373 fn within_band_is_unchanged() {
5374 // mu=1, kappa=10, slack=1 → band [0.1, 10]. z=1 → unchanged.
5375 let mut z = vec![1.0];
5376 let slack = [1.0];
5377 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
5378 assert_eq!(m, 0.0);
5379 assert_eq!(z, [1.0]);
5380 }
5381
5382 #[test]
5383 fn above_upper_clamped_down() {
5384 // mu=1, kappa=10, slack=1 → upper = 10. z=100 → 10.
5385 let mut z = vec![100.0];
5386 let slack = [1.0];
5387 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
5388 assert!((m - 90.0).abs() < 1e-13);
5389 assert_eq!(z, [10.0]);
5390 }
5391
5392 #[test]
5393 fn below_lower_clamped_up() {
5394 // mu=1, kappa=10, slack=1 → lower = 0.1. z=0.001 → 0.1.
5395 let mut z = vec![0.001];
5396 let slack = [1.0];
5397 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
5398 assert!((m - 0.099).abs() < 1e-13);
5399 assert!((z[0] - 0.1).abs() < 1e-15);
5400 }
5401
5402 #[test]
5403 fn returns_max_over_components() {
5404 let mut z = vec![100.0, 0.001];
5405 let slack = [1.0, 1.0];
5406 let m = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
5407 assert!((m - 90.0).abs() < 1e-13);
5408 assert_eq!(z[0], 10.0);
5409 assert!((z[1] - 0.1).abs() < 1e-15);
5410 }
5411
5412 #[test]
5413 fn slack_clamped_to_min_positive_avoids_division_by_zero() {
5414 let mut z = vec![1e100];
5415 let slack = [0.0];
5416 let _ = kappa_sigma_clamp(&mut z, &slack, 1.0, 10.0);
5417 assert!(z[0].is_finite() || z[0] == 1e100);
5418 }
5419
5420 /// The restoration slot is exercised structurally:
5421 /// `IpoptAlgorithm::with_restoration` accepts a
5422 /// `Box<dyn RestorationPhase>` and the trait's default
5423 /// `perform_restoration` returns `Failed`. End-to-end coverage
5424 /// (iterate() → line-search-Failed → restoration → recovered)
5425 /// lands in the Phase 9 integration suite alongside the nested
5426 /// IPM driver.
5427 struct _DummyResto;
5428 impl RestorationPhase for _DummyResto {}
5429
5430 // --------------------------------------------------------------
5431 // Best-acceptable fallback ranking (gh #267).
5432 //
5433 // These prove the "never worse off" guarantee host-independently, by
5434 // cases, on the pure ranking core — the property the earlier end-to-end
5435 // `hair_trigger_*` objective comparison could only *approximate* on one
5436 // host's basin luck (gh #267's secondary finding). `band` here stands in
5437 // for the resolved `min(acceptable_constr_viol_tol, FEASIBLE_ENOUGH_CAP)`.
5438 // --------------------------------------------------------------
5439
5440 #[test]
5441 fn ranks_better_is_a_strict_order_within_a_feasibility_class() {
5442 let band = 1e-2;
5443 // Both feasible: lower objective wins, strictly.
5444 assert!(ranks_better_within_band(-2.0, 1e-4, -1.0, 1e-4, band));
5445 assert!(!ranks_better_within_band(-1.0, 1e-4, -2.0, 1e-4, band));
5446 // Ties are not "strictly better" in either direction — so an equal
5447 // returned point is never displaced, matching the read side's
5448 // keep-current-on-tie contract.
5449 assert!(!ranks_better_within_band(-1.0, 1e-4, -1.0, 5e-3, band));
5450 assert!(!ranks_better_within_band(-1.0, 5e-3, -1.0, 1e-4, band));
5451 // Both infeasible: the less-infeasible point wins. Here it also has the
5452 // lower objective, so this held under the old objective-only fall-through
5453 // too — `ranks_better_puts_feasibility_first_among_two_infeasibles` is the
5454 // case that separates the two rules (gh #280).
5455 assert!(ranks_better_within_band(-2.0, 5.0, -1.0, 9.0, band));
5456 }
5457
5458 #[test]
5459 fn ranks_better_puts_feasibility_first_among_two_infeasibles() {
5460 // The gh #280 hole: once BOTH points sit outside the (capped) band the
5461 // old `(feasible_enough, objective)` partition read a_ok == b_ok == false
5462 // and fell through to `a_obj < b_obj` — objective alone, the exact
5463 // pre-#267 rule — so a strictly-MORE-infeasible point could win by having
5464 // a better objective. This is the deb7 swap the fallback made: incumbent
5465 // at viol 5.292e-1, recorded point at viol 9.951e-1 with a 36%-better
5466 // objective. The less-infeasible point must win regardless of objective.
5467 let band = 1e-2;
5468 // Less-infeasible incumbent, WORSE objective — must still win.
5469 assert!(ranks_better_within_band(
5470 89.0, 5.292e-1, 56.9, 9.951e-1, band
5471 ));
5472 // The more-infeasible, better-objective point must NOT win — the swap
5473 // gh #280 forbids.
5474 assert!(!ranks_better_within_band(
5475 56.9, 9.951e-1, 89.0, 5.292e-1, band
5476 ));
5477 // A strictly-more-infeasible point never replaces the incumbent even with
5478 // an arbitrarily better objective.
5479 assert!(!ranks_better_within_band(-1e12, 9.0, 0.0, 5.0, band));
5480 assert!(ranks_better_within_band(0.0, 5.0, -1e12, 9.0, band));
5481 }
5482
5483 #[test]
5484 fn ranks_better_puts_feasibility_first_regardless_of_objective() {
5485 let band = 1e-2;
5486 // The gh #267 case in miniature: a feasible point outranks an
5487 // arbitrarily-lower-objective infeasible one, and vice-versa.
5488 assert!(ranks_better_within_band(0.0, 1e-4, -1e9, 9.94, band));
5489 assert!(!ranks_better_within_band(-1e9, 9.94, 0.0, 1e-4, band));
5490 // Exactly at the band is still feasible_enough; just past it is not.
5491 assert!(ranks_better_within_band(
5492 1.0,
5493 band,
5494 -1.0,
5495 band * 1.000_001,
5496 band
5497 ));
5498 }
5499
5500 #[test]
5501 fn ranks_better_never_lets_a_widened_band_matter_past_the_cap() {
5502 // The band the method feeds is capped at FEASIBLE_ENOUGH_CAP, so a
5503 // point beyond the cap is never feasible_enough however loose the
5504 // user's `acceptable_constr_viol_tol`. Model that by passing the capped
5505 // band: the near-optimal-but-mildly-infeasible endpoint (viol 1.13e-4,
5506 // within the cap) must beat the grossly-infeasible lower-objective
5507 // point (viol 9.94, past it) — the exact swap the fix forbids.
5508 let band = IpoptAlgorithm::FEASIBLE_ENOUGH_CAP;
5509 assert!(ranks_better_within_band(
5510 -2303.99, 1.13e-4, -2307.32, 9.94, band
5511 ));
5512 assert!(!ranks_better_within_band(
5513 -2307.32, 9.94, -2303.99, 1.13e-4, band
5514 ));
5515 }
5516
5517 #[test]
5518 fn ranks_better_ranks_a_nonfinite_objective_worst() {
5519 let band = 1e-2;
5520 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5521 // A non-finite objective never ranks better than a finite one...
5522 assert!(!ranks_better_within_band(bad, 0.0, 0.0, 9.9, band));
5523 // ...and always loses to one, even on worse feasibility — so a
5524 // finite recorded point is restored over a NaN-objective return,
5525 // preserving the pre-fix `!(curr <= best)` NaN behaviour.
5526 assert!(ranks_better_within_band(0.0, 9.9, bad, 0.0, band));
5527 }
5528 }
5529
5530 #[test]
5531 fn ranks_better_treats_a_nonfinite_violation_as_infeasible() {
5532 let band = 1e-2;
5533 // A NaN violation is never feasible_enough, so a genuinely feasible
5534 // point outranks it regardless of objective.
5535 assert!(ranks_better_within_band(0.0, 0.0, -100.0, f64::NAN, band));
5536 assert!(!ranks_better_within_band(-100.0, f64::NAN, 0.0, 0.0, band));
5537 }
5538
5539 /// gh #534, the case the guard was stopping: `eigena2`'s dual infeasibility
5540 /// quarters on unit steps for four straight iterations, three short of a
5541 /// strict certificate. Quoted from the issue's own iteration table.
5542 #[test]
5543 fn eigena2_endgame_reads_as_contracting() {
5544 let eigena2 = [1.19e-05, 2.96e-06, 7.38e-07, 1.84e-07];
5545 assert!(window_is_contracting(
5546 &eigena2,
5547 DEFAULT_DECLINE_PROGRESS_RATIO
5548 ));
5549 }
5550
5551 /// gh #534, the case the guard was right about: `eigenb2`'s tail *rises*
5552 /// on heavily backtracked steps. The issue calls it a plausible genuine
5553 /// stall, so the progress test must refuse it and leave the guard alone.
5554 #[test]
5555 fn eigenb2_stall_does_not_read_as_contracting() {
5556 let eigenb2 = [1.88e-07, 2.69e-07, 2.89e-07, 2.93e-07];
5557 assert!(!window_is_contracting(
5558 &eigenb2,
5559 DEFAULT_DECLINE_PROGRESS_RATIO
5560 ));
5561 }
5562
5563 /// gh #534: `csfi2`'s window, measured on this build at the guard. Three
5564 /// healthy contractions and then a flat step — the solve has stopped
5565 /// moving, so the deferral must not fire however good the earlier ratios
5566 /// look. This is the shape every live guard firing reachable from the
5567 /// in-repo corpus has, which is why the whole window is tested and not
5568 /// just its first ratios.
5569 #[test]
5570 fn csfi2_flat_final_step_does_not_read_as_contracting() {
5571 let csfi2 = [3.267e0, 1.845e-6, 8.468e-8, 8.524e-8];
5572 assert!(!window_is_contracting(
5573 &csfi2,
5574 DEFAULT_DECLINE_PROGRESS_RATIO
5575 ));
5576 // ... and it is the *last* step that decides: drop it and the same
5577 // trace passes, which is exactly the distinction the test exists for.
5578 assert!(window_is_contracting(
5579 &csfi2[..3],
5580 DEFAULT_DECLINE_PROGRESS_RATIO
5581 ));
5582 }
5583
5584 /// gh #534: a large ratio drops the progress requirement, so the decline is
5585 /// deferred on any window. That is the "bypass the guard and see how far the
5586 /// solve gets" switch the issue asks for. A ratio of exactly `1` is the
5587 /// weaker "no backsliding" reading and still refuses `csfi2`, whose last
5588 /// step rises.
5589 #[test]
5590 fn a_large_ratio_accepts_a_stalled_window() {
5591 let csfi2 = [3.267e0, 1.845e-6, 8.468e-8, 8.524e-8];
5592 assert!(!window_is_contracting(&csfi2, 1.0));
5593 assert!(window_is_contracting(&[1e-8, 1e-8, 1e-8, 1e-8], 1.0));
5594 assert!(window_is_contracting(&csfi2, 1e20));
5595 // Still not a licence to read garbage as progress.
5596 assert!(!window_is_contracting(
5597 &[1.0, Number::NAN, 1e-9, 1e-12],
5598 1e20
5599 ));
5600 }
5601
5602 /// gh #534 edge cases: the ratio must never be evaluated against a
5603 /// non-positive or non-finite predecessor.
5604 #[test]
5605 fn degenerate_windows_never_read_as_contracting() {
5606 let r = DEFAULT_DECLINE_PROGRESS_RATIO;
5607 // A zero predecessor makes the ratio meaningless (0 <= 0.5*0 would
5608 // otherwise read as "contracting" forever).
5609 assert!(!window_is_contracting(&[0.0, 0.0, 0.0, 0.0], r));
5610 assert!(!window_is_contracting(&[1e-9, 0.0, 0.0, 0.0], r));
5611 assert!(!window_is_contracting(
5612 &[Number::INFINITY, 1e-3, 1e-6, 1e-9],
5613 r
5614 ));
5615 assert!(!window_is_contracting(&[1e-3, 1e-6, 1e-9, Number::NAN], r));
5616 // A genuine run down to exactly zero is progress, not a degenerate
5617 // window — the predecessor is positive at every step.
5618 assert!(window_is_contracting(&[1e-3, 1e-6, 1e-9, 0.0], r));
5619 }
5620
5621 /// gh #534: the window slides one sample per outer iteration and holds the
5622 /// most recent [`DECLINE_PROGRESS_SAMPLES`]. A short history is never a full
5623 /// window, which is what stops the first restoration entry of a solve from
5624 /// being deferred on no evidence at all — `nlp_err_contracting` requires
5625 /// `len == DECLINE_PROGRESS_SAMPLES` before it consults the samples.
5626 #[test]
5627 fn progress_window_slides_oldest_out() {
5628 let mut buf = [Number::NAN; DECLINE_PROGRESS_SAMPLES];
5629 let mut len = 0usize;
5630 for e in [1e-1, 1e-2, 1e-3] {
5631 push_sample(&mut buf, &mut len, e);
5632 }
5633 assert_eq!(len, 3);
5634 push_sample(&mut buf, &mut len, 1e-4);
5635 assert_eq!(len, DECLINE_PROGRESS_SAMPLES);
5636 assert_eq!(buf, [1e-1, 1e-2, 1e-3, 1e-4]);
5637 assert!(window_is_contracting(&buf, DEFAULT_DECLINE_PROGRESS_RATIO));
5638 // One flat iteration slides the oldest sample out and withdraws the
5639 // verdict.
5640 push_sample(&mut buf, &mut len, 1e-4);
5641 assert_eq!(len, DECLINE_PROGRESS_SAMPLES);
5642 assert_eq!(buf, [1e-2, 1e-3, 1e-4, 1e-4]);
5643 assert!(!window_is_contracting(&buf, DEFAULT_DECLINE_PROGRESS_RATIO));
5644 }
5645
5646 /// gh #505: no route may conclude `LocalInfeasibility` on its own.
5647 ///
5648 /// Three routes reach that verdict, and two of them independently shipped
5649 /// the same defect — building the terminate outcome directly, so the
5650 /// acceptable-point stash was never consulted and a good point the solve
5651 /// already had in hand was discarded. They were found one at a time,
5652 /// because nothing tied them together.
5653 ///
5654 /// The route a solve takes is an internal detail; the user sees one status
5655 /// either way. So what that status means is decided in one place — every
5656 /// route goes through [`IpoptAlgorithm::terminate_local_infeasibility`],
5657 /// or, for the cycle exits whose fallback is chosen between two statuses
5658 /// at the call site, through `terminate_acceptable_or`. Both consult the
5659 /// stash.
5660 ///
5661 /// **This is a tripwire, not a proof.** It is a substring scan of this
5662 /// file's source for the bare `IterateOutcome::Terminate(SolverReturn::
5663 /// LocalInfeasibility)` construction. A rustfmt line break through that
5664 /// expression, a `let` binding for the status, or a construction in
5665 /// another module all evade it — `application.rs` names the same variant
5666 /// on the SQP and ℓ₁ elastic paths and is deliberately out of scope. What
5667 /// it does buy is that the *obvious* way to add a fourth bare exit here
5668 /// fails loudly and points at the helper, which is the mistake that was
5669 /// actually made twice.
5670 ///
5671 /// The needle is assembled at runtime so this test's own source cannot
5672 /// satisfy the pattern it is checking for; an earlier version counted its
5673 /// own lines and failed against clean code.
5674 #[test]
5675 fn no_route_concludes_local_infeasibility_alone() {
5676 let needle = format!(
5677 "IterateOutcome::Terminate(SolverReturn::{})",
5678 "LocalInfeasibility"
5679 );
5680 let offenders: Vec<usize> = include_str!("ipopt_alg.rs")
5681 .lines()
5682 .enumerate()
5683 .filter(|(_, l)| {
5684 let t = l.trim_start();
5685 !t.starts_with("//") && !t.starts_with("///")
5686 })
5687 .filter(|(_, l)| l.contains(&needle))
5688 .map(|(i, _)| i + 1)
5689 .collect();
5690 assert!(
5691 offenders.is_empty(),
5692 "line(s) {offenders:?} build the local-infeasibility verdict directly. \
5693 Call `terminate_local_infeasibility()` instead — it consults the \
5694 acceptable-point stash first, so a solve that already passed through an \
5695 acceptable iterate returns that point rather than a hard failure. Two \
5696 routes shipped this bug before the helper existed (gh #505)."
5697 );
5698 }
5699}