Skip to main content

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