Skip to main content

pounce_algorithm/
application.rs

1//! User-facing application object — port of `Interfaces/IpIpoptApplication.{hpp,cpp}`.
2//!
3//! # Crate placement
4//!
5//! `IpoptApplication` lives in `pounce-algorithm` (rather than
6//! alongside the other Interfaces-side ports in `pounce-nlp`) because
7//! `optimize_tnlp` needs to drive the full IPM: it constructs a
8//! `TNLPAdapter` + `OrigIpoptNlp` (from `pounce-nlp`) and hands the
9//! NLP off to an [`IpoptAlgorithm`] (this crate). `pounce-nlp` cannot
10//! depend on `pounce-algorithm` (the reverse already exists), so
11//! orchestration must live on the algorithm side. Public callers
12//! continue to import via `pounce_algorithm::IpoptApplication`.
13//!
14//! `optimize_tnlp` routes every problem — constrained or not —
15//! through the same primal-dual IPM, exactly as upstream Ipopt does:
16//! it builds the algorithm via [`crate::alg_builder::AlgorithmBuilder`]
17//! (default backend MA57 from `pounce-hsl`) and runs
18//! [`IpoptAlgorithm::optimize`].
19
20use crate::alg_builder::{
21    AlgorithmBuilder, HessianApproxChoice, LineSearchChoice, LinearBackendFactory,
22    LinearSolverChoice, MuStrategyChoice,
23};
24use crate::hess::lim_mem_quasi_newton::UpdateType;
25use crate::ipopt_alg::{DUAL_DIV_RETRY_DU_FLOOR, IpoptAlgorithm};
26use crate::ipopt_cq::IpoptCalculatedQuantities;
27use crate::ipopt_data::IpoptData as AlgIpoptData;
28use crate::ipopt_nlp::IpoptNlp;
29use crate::iterates_vector::IteratesVector;
30use crate::restoration::RestorationPhase;
31use crate::upstream_options::register_all_upstream_options;
32
33/// Options-file names probed in the working directory when the caller
34/// names none, in probe order: pounce's own name first, then upstream's
35/// so an `ipopt.opt` written for Ipopt is honored unchanged.
36///
37/// Upstream probes only `ipopt.opt` (the registered default of
38/// `option_file_name`). Both are read here because a port that answers
39/// to `ipopt.opt` but not to its own name is the more surprising of the
40/// two behaviours — and gh#518 reported trying both.
41pub const DEFAULT_OPTION_FILE_NAMES: &[&str] = &["pounce.opt", "ipopt.opt"];
42
43/// gh#887 — how far the *runaway* must dominate everything else in the
44/// answer a solve finally reports, before
45/// [`IpoptApplication::run_with_dual_divergence_retry`] will spend a cold
46/// re-solve on it.
47///
48/// gh#884's defect is a point that is converged **except** that one
49/// multiplier ran away: the primal is exact, complementarity is met, and
50/// the entire residual is dual infeasibility. That is the shape
51/// `perturb_always_cd` repairs. A point whose other residuals are within
52/// a few orders of its dual one is not that — it is an ordinary
53/// unconverged answer, and re-solving it cold is what
54/// `mu_strategy_fallback` and the second-opinion ladder already are.
55///
56/// So the retry requires `max(viol, compl) <= 1e-6 * dual_inf`, all
57/// unscaled. Measured on every run in the corpus that reaches the test:
58///
59/// | run | dual inf | viol | compl | ratio |
60/// |---|---|---|---|---|
61/// | reproducer, `.nl` route | `7.90e4` | `1.1e-16` | `1.1e-9` | `1.5e-14` |
62/// | reproducer, TNLP route | `3.25e11` | `2.5e-16` | `2.8e-3` | `8.7e-15` |
63/// | `deb7` + L-BFGS + rung | `9.90e1` | `8.0e-13` | `4.65e0` | `4.7e-2` |
64///
65/// Twelve orders between the keeps and the reject, and `1e-6` leaves
66/// eight orders of margin on the tightest keep and four on the reject.
67///
68/// It is a **ratio of two residuals of the same answer**, so it carries
69/// no units and does not move with the model's scaling — and, unlike any
70/// test on the trajectory, it cannot depend on which attempt fired or on
71/// how a platform rounded its way there. That is not hypothetical: the
72/// first version of this gate compared the reported answer against the
73/// runaway the detector had seen, and `deb7`'s detector value is `9.2e5`
74/// on one attempt and `8.7e2` on another, differing between build
75/// profiles and again on CI's Linux runner, where the retry ran anyway.
76///
77/// Deliberately a constant and not an option. It does not express a
78/// tolerance a caller trades against — it says "the runaway is the whole
79/// residual" — and the escape hatch for the remedy is
80/// `dual_divergence_retry=no`.
81const DUAL_DIV_RETRY_DOMINANCE: Number = 1e-6;
82
83/// Does this answer have gh#884's *shape*?
84///
85/// gh#884's defect is a point converged **except** that one multiplier
86/// ran away: the primal is exact, complementarity is met, and the entire
87/// residual is dual infeasibility. `perturb_always_cd` has something to
88/// repair only there, so this is what opens the retry (gh#887).
89///
90/// All three arguments are in the **model's own units**, never the
91/// `s_d`-normalised frame the convergence gate reads — that frame is what
92/// hid gh#884 in the first place. The test is a ratio *within one
93/// answer*, so it carries no units and cannot depend on which attempt
94/// produced it or on how a platform rounded. That is the property that
95/// matters, not the margin; see the module tests for what happened to
96/// the two gates that did not have it.
97///
98/// Non-finite input disables the retry rather than enabling it: a NaN
99/// compares false everywhere, and writing this so a NaN *passed* would
100/// turn "we cannot tell" into "retry anyway". A non-positive dual
101/// residual cannot be a runaway either, and makes the ratio meaningless.
102///
103/// **The dominance ratio alone is not the whole test, and reading it as
104/// one is how a `dual_inf` of `0.44` opened a retry.** The ratio says the
105/// dual residual *dominates* the other two; it cannot say the residual is
106/// large, because a point converged to `1e-30` primal and `4.4e-1` dual
107/// passes it as comfortably as gh#884's `7.9e+04` does. So `dual_inf`
108/// must also clear the same absolute floor the detector's third conjunct
109/// applies to the iterate (`dual_divergence_retry_du_floor`, default
110/// `1e2`) — the doc above says "the entire residual is dual
111/// infeasibility", and without the floor the code only said "the largest
112/// third of it is".
113///
114/// The floor is the *detector's own*, deliberately, rather than a new
115/// constant: the detector fires on an iterate and this asks whether the
116/// **answer** still exhibits what the detector saw, so the two have to be
117/// asking about the same magnitude or the answer-level gate is a strictly
118/// looser copy of the iterate-level one. Measured on the 400-model QPEC
119/// family in `dev-notes/mpcc-biactive-dual-divergence.md`, this alone
120/// removes 7 of 68 promotions, every one of them on an answer whose
121/// reported dual residual was below `1e2` and therefore not a runaway by
122/// the issue's own description.
123fn runaway_is_the_whole_residual(
124    dual_inf: Number,
125    viol: Number,
126    compl: Number,
127    du_floor: Number,
128) -> bool {
129    dual_inf.is_finite()
130        && dual_inf > 0.0
131        && dual_inf >= du_floor
132        && viol.is_finite()
133        && compl.is_finite()
134        && viol.max(compl) <= DUAL_DIV_RETRY_DOMINANCE * dual_inf
135}
136
137/// gh#983: which scaling defect a success verdict shows.
138#[derive(Debug, Clone, Copy, PartialEq)]
139enum ScaleAudit {
140    /// Gradient-based scaling froze a tiny factor at a huge-gradient start:
141    /// re-evaluate it at the returned point and solve again from there.
142    Rescale,
143    /// The model's gradient is tiny and nothing scales it up: multiply the
144    /// objective by the carried factor (`1 / g`, `g` the gradient scale).
145    Upscale(Number),
146    /// The same small-objective signature, but a re-solve would not move the
147    /// objective's scale materially (`1/g` below [`SCALE_AUDIT_MATERIAL`]):
148    /// warn, do not re-solve.
149    SmallObjective,
150}
151
152/// A complementarity above this fraction of the objective's own gradient scale
153/// is read as "the objective is too small for the absolute tolerances".
154///
155/// gh#983 review item 4: a single threshold, used both to re-solve and to
156/// warn, so there is no band in which a run gets neither. Measured on the
157/// issue's reactor model (`issue_983_review.rs::cstr_*`), whose relative
158/// objective error is `~1.9x` this ratio: `fs = 1` reads `2.5e-10` (error
159/// `3.5e-8`), `fs = 1e-3` `2.5e-7` (`4.3e-7`), `3e-4` `8.4e-7` (`1.5e-6`),
160/// `1e-4` `2.5e-6` (`4.7e-6`), `3e-5` `8.4e-6` (`1.6e-5`), `1e-6` `2.5e-4`
161/// (`4.7e-4`). The first-pass value `1e-5` left `fs` in `[3e-5, 1e-3]` with
162/// errors up to `1.6e-5` and no signal at all. `1e-7` is a tenth of the default
163/// `acceptable_tol`: every run whose objective is predicted to be good to
164/// worse than about `2e-7` relative is either re-solved or warned about.
165const SCALE_AUDIT_COMPL_REL: Number = 1e-7;
166/// The small-objective audit only applies when the caller's `tol` is at most
167/// this (the default is `1e-8`).
168const SCALE_AUDIT_MAX_TOL: Number = 1e-6;
169/// The re-solve never multiplies the objective by more than this.
170const SCALE_AUDIT_MAX_UPSCALE: Number = 1e10;
171/// gh#983 review item 2: a re-solve is spent only when it would change the
172/// objective's scale by at least this factor. Below it the re-solve is the
173/// same solve again -- the first pass measured `hs71` times `1e8` paying a
174/// declined re-solve whose factor, re-evaluated at the optimum, was the one
175/// it already had.
176const SCALE_AUDIT_MATERIAL: Number = 10.0;
177/// The re-solve's iteration budget is this multiple of the base solve's
178/// iterations (never more than the caller's `max_iter`, never less than
179/// [`SCALE_AUDIT_RETRY_MIN_ITER`]).
180///
181/// The re-solve is a bet placed after the run has already succeeded, so it is
182/// bounded by what the run cost. Measured over the 64 audited solves of the
183/// 1,345-problem release sweep (`origin/main` @ `68e438d0`): every promoted
184/// re-solve but one took at most 2.4x the base iterations, or fewer than 50
185/// (`sreadin3` 8 -> 19, `reading2` 15 -> 29, `camshape_6400` 88 -> 112). The
186/// exception is `robot_b`, 271 -> 1425 iterations and 23 s -> 178 s for a
187/// 1.3e-6 relative move in the objective; it now keeps its base answer, which
188/// was already `Solve_Succeeded`. The declined side is where the cost was:
189/// `meyer3` 256 -> 3000 + 3000 (the re-solve's own mu flip), and the
190/// `brainpc` family, where a 1-2 s solve was followed by a re-solve of a
191/// minute or more that was then discarded -- under the benchmark's 300 s
192/// wall it never returned, so the base `Optimal` was lost as a timeout.
193const SCALE_AUDIT_RETRY_ITER_FACTOR: Index = 3;
194/// Floor on the re-solve's iteration budget, so a base solve of a handful of
195/// iterations still leaves the re-solve room to move.
196const SCALE_AUDIT_RETRY_MIN_ITER: Index = 50;
197/// Under a warm start, an objective gradient measured at an *answer* (the
198/// seed, or the returned point) that is within this factor of the run's
199/// unscaled complementarity is what the barrier alone leaves behind
200/// (`grad f = z_L - z_U` with `z ~ mu / s`) and carries no model scale.
201///
202/// Measured on `pounce-rs`'s `session::tests::cold_then_warm_through_presolve`
203/// (an interior optimum, objective `(x0 - 2)^2`): the cold solve reads a start
204/// gradient of `4` and is left alone, while the warm re-solve of the identical
205/// model starts at that answer (`grad f = 1.0e-11`) and returns at
206/// `grad f = 3.73e-9` against a complementarity of `3.72e-9` -- a ratio of
207/// `1.001`, which read as a "tiny objective" and paid a `2.7e8` up-scaled
208/// re-solve that doubled the warm solve's iterations. The reactor population
209/// the small-objective branch exists for has an answer gradient at least
210/// `4e3` times its complementarity (`fs = 1e-6`: `1e-5` against `2.5e-9`), so
211/// `10` -- the audit's own predicted relative error of `0.1` -- separates the
212/// two by more than two orders.
213const SCALE_AUDIT_BARRIER_GRAD: Number = 10.0;
214
215/// What the audit reads off a success verdict.
216#[derive(Debug, Clone, Copy)]
217struct ScaleAuditInput {
218    /// The caller's `tol`.
219    tol: Number,
220    /// The solver-computed (gradient-based) objective factor the run used.
221    obj_scale: Number,
222    /// The factor gradient-based scaling would compute **at the returned
223    /// point** (`gradient_obj_scale(max|grad f(x*)|)`), `NaN` if unknown.
224    rescaled_obj_scale: Number,
225    /// `max|grad f|` at the starting point (`NaN` when not measured).
226    start_grad_max: Number,
227    /// `max|grad f|` at the returned point (`NaN` when not measured).
228    final_grad_max: Number,
229    unscaled_kkt: Number,
230    unscaled_compl: Number,
231    acceptable_tol: Number,
232    masked_threshold: Number,
233    /// `warm_start_init_point=yes`: the start is an earlier answer, not an
234    /// independent measurement of the model.
235    warm_start: bool,
236}
237
238impl ScaleAuditInput {
239    /// The objective's gradient scale: the larger of the two measurements, so
240    /// a start that happens to sit near a stationary point (`grad f(x0) ~
241    /// 1e-7`, `grad f(x*) ~ 1`) does not read as a tiny objective.
242    ///
243    /// A start gradient measured as exactly `0` is a start *at* a stationary
244    /// point, which carries no scale at all; the answer's gradient is then ~0
245    /// too wherever the optimum is unconstrained (`scaled_feasible_a`, a
246    /// zero-residual least squares whose `x0` is its minimizer, reads `2e-4`
247    /// there against data of `1e5`). So that case reports no scale (`0`, the
248    /// audit stays out). An *unmeasured* start (`NaN`: the caller owns the
249    /// scaling) defers to the returned point.
250    ///
251    /// Under a warm start both measurements are taken at answers, so the
252    /// same reasoning applies to each of them: one that sits at the barrier
253    /// level ([`SCALE_AUDIT_BARRIER_GRAD`] times the complementarity) is the
254    /// stationary point's noise, not the model's scale, and when neither
255    /// clears it the audit stays out. Without this the verdict depended on
256    /// the start rather than the model: a cold solve of an interior optimum
257    /// is left alone, and a warm re-solve of the *same* data from its answer
258    /// was up-scaled and re-solved.
259    fn grad_scale(&self) -> Number {
260        let fin = |v: Number| if v.is_finite() && v > 0.0 { v } else { 0.0 };
261        if self.start_grad_max == 0.0 {
262            return 0.0;
263        }
264        if self.warm_start && self.unscaled_compl.is_finite() {
265            let floor = SCALE_AUDIT_BARRIER_GRAD * self.unscaled_compl;
266            let answer = |v: Number| if fin(v) > floor { v } else { 0.0 };
267            return answer(self.start_grad_max).max(answer(self.final_grad_max));
268        }
269        fin(self.start_grad_max).max(fin(self.final_grad_max))
270    }
271}
272
273/// Does a success verdict need the gh#983 scale audit, and which kind?
274///
275/// Pure so the thresholds are unit-testable.
276///
277/// gh#983 review item 2: neither branch is a function of the objective's
278/// magnitude alone. The re-scale branch requires the factor re-evaluated at
279/// the returned point to differ materially from the frozen one (a
280/// legitimately large objective re-evaluates to the factor it already has),
281/// and the small-objective branch reads the gradient at both ends of the run.
282fn scale_audit_trigger(inp: ScaleAuditInput) -> Option<ScaleAudit> {
283    use crate::conv_check::opt_error::certificate_masked;
284    if inp.obj_scale.is_finite()
285        && inp.obj_scale > 0.0
286        && inp.unscaled_kkt.is_finite()
287        && certificate_masked(
288            inp.obj_scale,
289            inp.unscaled_kkt,
290            inp.masked_threshold,
291            inp.acceptable_tol,
292        )
293        && inp.rescaled_obj_scale.is_finite()
294        && inp.rescaled_obj_scale >= SCALE_AUDIT_MATERIAL * inp.obj_scale
295    {
296        return Some(ScaleAudit::Rescale);
297    }
298    // The small-objective branch is about a *tight* absolute tolerance that is
299    // crude relative to the objective. A caller who loosened `tol` themselves
300    // (`tol = 5e-2` in `test_loose_mu_reports_ambiguous`) asked for the
301    // complementarity they got.
302    let g = inp.grad_scale();
303    if inp.tol <= SCALE_AUDIT_MAX_TOL
304        && g > 0.0
305        && g < 1.0
306        && inp.unscaled_compl.is_finite()
307        && inp.unscaled_compl / g > SCALE_AUDIT_COMPL_REL
308    {
309        let k = (1.0 / g).min(SCALE_AUDIT_MAX_UPSCALE);
310        return Some(if k >= SCALE_AUDIT_MATERIAL {
311            ScaleAudit::Upscale(k)
312        } else {
313            ScaleAudit::SmallObjective
314        });
315    }
316    None
317}
318
319/// Text of the structured warning for an audit that was not (or could not be)
320/// resolved by a re-solve. `outcome` completes "A re-solve ... <outcome>".
321fn scale_audit_warning(
322    kind: ScaleAudit,
323    base: &SolveStatistics,
324    grad_scale: Number,
325    acceptable_tol: Number,
326    outcome: &str,
327) -> String {
328    match kind {
329        ScaleAudit::Rescale => format!(
330            "unscaled_stationarity_above_tol: gradient-based scaling froze the objective \
331             factor at {:.2e} at the starting point and the reported point's unscaled KKT \
332             error is {:.2e} (acceptable_tol {:.0e}); the scaled test passed in a space \
333             where the objective gradient is shrunk by that factor. A re-solve from this \
334             point with the scaling re-evaluated {outcome}.",
335            base.final_obj_scaling_factor, base.final_unscaled_kkt_error, acceptable_tol
336        ),
337        ScaleAudit::Upscale(k) => format!(
338            "objective_scale_small: the objective gradient is at most {:.2e} (start and \
339             returned point) and nothing scales it up, so the unscaled complementarity {:.2e} \
340             is {:.1e} of that scale -- the objective is only good to about that relative \
341             error. A re-solve with the objective multiplied by {:.1e} {outcome}. Rescale the \
342             objective (e.g. obj_scaling_factor) to silence this.",
343            grad_scale,
344            base.final_unscaled_compl,
345            base.final_unscaled_compl / grad_scale,
346            k
347        ),
348        ScaleAudit::SmallObjective => format!(
349            "objective_scale_small: the objective gradient is at most {:.2e} (start and \
350             returned point), so the unscaled complementarity {:.2e} is {:.1e} of that scale \
351             -- the objective is only good to about that relative error. No re-solve: \
352             scaling the objective up by {:.1e} is below the factor of {SCALE_AUDIT_MATERIAL} \
353             that would change the solve. Rescale the objective (e.g. obj_scaling_factor) or \
354             tighten tol to silence this.",
355            grad_scale,
356            base.final_unscaled_compl,
357            base.final_unscaled_compl / grad_scale,
358            1.0 / grad_scale
359        ),
360    }
361}
362
363/// Model-scale caveats on a success verdict that need no re-solve.
364///
365/// * `large_dual_scale`: the terms the stationarity residual is made of
366///   reach `1e8` or more *and* the residual exceeds `acceptable_tol`, which is
367///   the failed-constraint-qualification signature (the cusp of gh#983 item 1 has multipliers of `3.5e9` and a
368///   residual of `0.144`). The scale-relative floor (gh#532) judges such a
369///   point against that scale, so it is accepted; the warning is how the
370///   caller finds out. It is not capped, because the cap that rejects the
371///   cusp (`~0.1`) also rejects Vanderbei's `orthrds2` (`89.7` at scale
372///   `1.6e10`), which is stationary to nine digits relative to its scale.
373///   Under `bound_relax_factor = 0` it names that option: every model of
374///   gh#1000 items II.22, II.23 and I.15 that carried it (pump network,
375///   process-network superstructure, Haverly multistart) had an active
376///   bound coinciding with a dependent row, and each ends `Solve_Succeeded`
377///   at the default relaxation. Ipopt follows the same trajectory at `0`.
378/// * `unscaled_stationarity_above_tol`: the unscaled residual sits above
379///   `1e-3` while the strict gate, which tolerates up to `dual_inf_tol`
380///   (`1` by default), passed it.
381fn quality_warnings(
382    status: ApplicationReturnStatus,
383    unscaled_dual_inf: Number,
384    dual_scale: Number,
385    dual_inf_tol: Number,
386    acceptable_tol: Number,
387    bound_relax_off: bool,
388) -> Vec<String> {
389    let mut out = Vec::new();
390    if !matches!(
391        status,
392        ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
393    ) {
394        return out;
395    }
396    // Only when the residual is itself above `acceptable_tol`: a legitimately
397    // large objective (`hs71` times `1e8`) has terms of `1e9` and a residual of
398    // `3e-7`, which is not a symptom of anything.
399    if dual_scale.is_finite()
400        && dual_scale >= 1e8
401        && unscaled_dual_inf.is_finite()
402        && unscaled_dual_inf > acceptable_tol
403    {
404        out.push(format!(
405            "large_dual_scale: the stationarity residual is assembled from terms of size {dual_scale:.2e} \
406             (multipliers or gradients this large usually mean a failed constraint qualification), \
407             so the unscaled dual infeasibility {unscaled_dual_inf:.2e} was judged relative to that scale{}",
408            if bound_relax_off {
409                ". bound_relax_factor = 0 is set: an active bound that coincides with a \
410                 dependent row has no interior there, and the default relaxation (1e-8) \
411                 usually removes this"
412            } else {
413                ""
414            }
415        ));
416    }
417    if unscaled_dual_inf.is_finite()
418        && unscaled_dual_inf > UNSCALED_DUAL_WARN
419        && unscaled_dual_inf > acceptable_tol
420    {
421        out.push(format!(
422            "unscaled_dual_inf_above_acceptable: the unscaled dual infeasibility is {unscaled_dual_inf:.2e}, \
423             above acceptable_tol {acceptable_tol:.0e}; the strict gate tolerates up to dual_inf_tol \
424             = {dual_inf_tol:.0e}. Verify stationarity in your own units"
425        ));
426    }
427    out
428}
429
430/// See [`quality_warnings`].
431///
432/// Also the bar for the gh#983 item-5 downgrade (`finalize_quality_verdict`).
433/// Measured population near it (gh#983 review), over the CLI fixture corpus on
434/// both sweep legs: six runs return `Solve_Succeeded` with the gh#884
435/// signature, at unscaled dual infeasibilities of `1.6e-9`
436/// (`linear_eq_collapsed_box`), `2.8e-9` (`autocorr_bern55-06`), `1.0e-8` /
437/// `1.1e-8` (`pooling_rt2stp`, both legs), `5.5e-8`
438/// (`mpcc_qpec_small_biactive`) and `3.6e-6` (`deb7`) -- the largest two and a
439/// half orders below the bar. The runs the bar exists for sit above it: the
440/// cusp with the floor opted back in at `1.44e-1`, and the first pass's
441/// toll-pricing report at `3.3e-2` (since gh#1000 exempt as roundoff, see
442/// [`UNSCALED_DUAL_ROUNDOFF_ULPS`]). So the threshold falls in an empty band of
443/// the measured population; nothing between `3.6e-6` and `3.3e-2` was observed,
444/// and the corpus says nothing about how dense that band is elsewhere (the
445/// 400-model QPEC family of `dev-notes/mpcc-biactive-dual-divergence.md` was
446/// not re-measured for this).
447const UNSCALED_DUAL_WARN: Number = 1e-3;
448
449/// gh#1000 (II.22): how many ulps of its own terms an unscaled stationarity
450/// residual may read and still be roundoff rather than a measured miss.
451///
452/// The residual `∇f + Jᵀλ + z` is assembled from terms of size
453/// `final_unscaled_dual_scale`, so in floating point it cannot read much below
454/// `ε·scale` however exact the point is. Under `bound_relax_factor = 0` an
455/// active bound and a row can be linearly dependent, the multipliers drift
456/// along the unbounded ray of valid ones, and the scale follows them: the
457/// pump-network NLP of the issue ends with multipliers near `1e13`, a residual
458/// of `5.5e-3` -- 1.6 ulps of its terms -- and an objective right to `5e-9`
459/// relative, and was downgraded on that `5.5e-3` alone. The measured
460/// population on either side, as `residual / scale`: that run at `3.5e-16`
461/// (`2.8e-16` on the Python path), and gh#983's toll-pricing MPCC at
462/// `bound_relax_factor = 0` at `1.1e-16` (`3.3e-2` against `3e14`, objective
463/// `9e-10` from the default-options solve), which gh#983 had pinned as a
464/// downgrade and which this exempts too; the downgrades that are correct --
465/// Haverly at `2e-13` (objective `7e-8` off, complementarity `1.8e-6`), the
466/// process network's all-zero-flow subproblem at `6.6e-13` (`6e-7` off) --
467/// and the cusp the downgrade was built for at `4e-11`. 64 ulps (`1.4e-14`)
468/// sits forty times above the first and fourteen times below the nearest
469/// correct downgrade.
470///
471/// What this cannot see: on a model with no finite multiplier (the cusp) the
472/// residual falls as the multipliers grow and the floor rises with them, so a
473/// run that drove them far enough would cross it. None measured has; the
474/// cusp sits more than three orders above. The `large_dual_scale` caveat
475/// still attaches to every run this exempts.
476const UNSCALED_DUAL_ROUNDOFF_ULPS: Number = 64.0;
477
478/// Is `du` within [`UNSCALED_DUAL_ROUNDOFF_ULPS`] of the rounding error of
479/// the terms it was assembled from? `false` whenever either input is not
480/// finite, so a path that does not measure the scale keeps the downgrade.
481fn unscaled_dual_inf_is_roundoff(du: Number, scale: Number) -> bool {
482    du.is_finite()
483        && scale.is_finite()
484        && scale > 0.0
485        && du <= UNSCALED_DUAL_ROUNDOFF_ULPS * Number::EPSILON * scale
486}
487
488/// The code of a structured warning: the text before the first `:`.
489fn warning_code(w: &str) -> &str {
490    w.split(':').next().unwrap_or(w)
491}
492
493/// Push `w` unless a warning with the same code is already attached
494/// (gh#983 review item 7: `unscaled_dual_inf_above_acceptable` was pushed
495/// twice, once by the downgrade and once by the caveat list).
496fn push_warning_once(warnings: &mut Vec<String>, w: String) {
497    if !warnings.iter().any(|x| warning_code(x) == warning_code(&w)) {
498        warnings.push(w);
499    }
500}
501
502/// gh#983 item 4: a retry violation at or below this is arithmetic noise, not
503/// "primal slack". Rule 2 compared the retry's violation to the base's with no
504/// floor, so a base at `3e-38` refused a retry at `2e-13` that halved the
505/// objective (`x*y == 0` from `(0.3, 0.3)`: base `f = 2` with unscaled dual
506/// infeasibility `1.4e9`, retry `f = 1`). The case rule 2 exists for,
507/// `scholtes4`, buys `6.6e-05` of objective with a violation of `1.09e-09`, an
508/// order above this floor, so it is still refused. The floor was `1e-12` until
509/// MacMPEC `pack-comp1p-32` (complementarity driver review): the retry reached
510/// the published optimum, `0.652997` against the base's `0.704` (whose own
511/// unscaled dual residual was `7.2e7`), at a violation of `2.6e-11` -- three
512/// orders inside `tol` and four inside `constr_viol_tol` -- and was refused
513/// because the base sat at `2.5e-12`. Two orders below the default `tol` is
514/// still far below any violation that changes which point a model accepts.
515const RETRY_VIOL_NOISE_FLOOR: Number = 1e-10;
516
517/// gh#983 review item 5: how many orders a retry's unscaled KKT error must sit
518/// below the base attempt's before it may be promoted without reaching
519/// `acceptable_tol` itself.
520const RETRY_DOMINANCE_ORDERS: Number = 1e-6;
521
522/// gh#983 review item 5: the second way conjunct 4 of the gh#884 promotion
523/// gate can hold.
524///
525/// Conjunct 4 demanded the retry's unscaled KKT error be at or below
526/// `acceptable_tol`. On the toll-pricing MPCC at `bound_relax_factor = 0` the
527/// base attempt returns `Solved_To_Acceptable_Level` at an unscaled dual
528/// infeasibility of `1.03e5` and the retry `Solve_Succeeded` at `1.69e-5`:
529/// ten orders better, and refused because `1.69e-5 > 1e-6`. That is a
530/// certificate an ordinary first solve would ship as `Solve_Succeeded`
531/// without a second look (it is under `1e-3`, the bar
532/// `finalize_quality_verdict` holds strict verdicts to), held to a stricter
533/// standard only because it was a retry, while the answer reported instead is
534/// one whose residual is the runaway itself.
535///
536/// So the retry's certificate also counts as real when **all** of:
537/// * it is at least six orders below the base attempt's
538///   (`RETRY_DOMINANCE_ORDERS`) -- not "better", *categorically* better;
539/// * it is at or below `UNSCALED_DUAL_WARN` (`1e-3`) in absolute terms, the
540///   same bar a strict verdict is held to everywhere else, so no promotion
541///   ships a residual the audit would flag on a first solve;
542/// * the retry's unscaled constraint violation is within `acceptable_tol`
543///   (the primal half is not relaxed at all).
544///
545/// The answer rules ([`retry_answer_is_admissible`]) and the `Solve_Succeeded`
546/// conjunct are unchanged and still apply. Why this does not let `ralph1`
547/// through: `ralph1`'s defect is that `perturb_always_cd` returns a wrong
548/// answer reported as success, and what keeps it out is that the retry is
549/// never *opened* there -- the detector's scale-relative step conjunct and
550/// the `1e2` dual floor on the reported answer
551/// (`runaway_is_the_whole_residual`), neither of which this touches. Within an
552/// opened retry the base residual is at least that floor, so the dominance
553/// conjunct alone would admit `1e-4`; the absolute `1e-3` cap and the
554/// unchanged answer rules are what bound it, and `issue884_*` pins both
555/// barriers (`ralph1` is in that corpus).
556fn retry_certificate_dominates(
557    base_unscaled_kkt: Number,
558    retry_unscaled_kkt: Number,
559    retry_viol: Number,
560    accept_tol: Number,
561) -> bool {
562    base_unscaled_kkt.is_finite()
563        && retry_unscaled_kkt.is_finite()
564        && retry_viol.is_finite()
565        && retry_unscaled_kkt <= RETRY_DOMINANCE_ORDERS * base_unscaled_kkt
566        && retry_unscaled_kkt <= UNSCALED_DUAL_WARN
567        && retry_viol <= accept_tol
568}
569
570/// Is the retry's answer admissible *as an answer*, next to the base
571/// attempt's — independent of which has the better multiplier?
572///
573/// gh#884's promotion gate ranked the two attempts on unscaled KKT error
574/// alone. That is a statement about the **certificate**, and it was
575/// allowed to decide which **point** shipped, on the argument that
576/// "conjunct 4 requires the promoted answer to satisfy the KKT conditions
577/// in the model's own units" — so, unlike the μ flip, this retry could not
578/// return a different local solution. The inference does not hold: *any*
579/// other KKT point satisfies the KKT conditions in the model's own units
580/// too. Measured on 400 random QPECs (`prod_eq` lowering), 42 of 68
581/// promotions moved the objective materially, i.e. returned a different
582/// local solution, and three returned a **worse feasible point** — worst
583/// case `-13.0057 → -1.2072`, both independently verified feasible.
584///
585/// Two rules, and both are about the *answer* rather than its certificate:
586///
587/// 1. **Never hand back a feasible point whose objective is worse than one
588///    this run already computed.** The base attempt's point is feasible
589///    and in hand; returning a worse one is a regression no certificate
590///    buys back.
591/// 2. **An objective *improvement* may not be bought with primal slack.**
592///    Two costs this carries, named rather than hidden (R3). It is an exact
593///    non-increase with **no noise floor**, so it fires the same on
594///    `2.07e-25 -> 1.09e-09` (where the move is the whole defect) as on
595///    `1e-17 -> 1.1e-17` (where which side a retry lands on is arithmetic):
596///    of the 45 promotions this PR removes, ~35 are improvements refused
597///    because the violation ticked up somewhere far below any tolerance. The
598///    direction is conservative — the base answer is feasible and in hand —
599///    but "refuses purchases, not improvements" is only true above the noise.
600///    And the rule detects *the primal moved*, not *below the optimum*: had
601///    `scholtes4`'s retry **held** its violation, `f = -6.6088e-05` would have
602///    been admitted, which the second assertion of
603///    `an_improvement_bought_with_primal_slack_is_refused` states outright.
604///    On that model the two coincided.
605///    The remedy is for a *dual* defect — the premise is that the primal
606///    has settled — so a retry that lands further outside the constraints
607///    *and* reports a better objective has not repaired the runaway, it
608///    has moved somewhere the model does not reach. This is what
609///    `scholtes4` does: from a base at `f = +1.82e-09` with a constraint
610///    violation of `2.07e-25` it promotes `f = -6.61e-05` at `1.09e-09`,
611///    below the model's exactly-known `f* = 0`, and reports
612///    `Optimal Solution Found`.
613///
614/// Both comparisons are skipped when the base attempt is not itself
615/// feasible within [`IpoptApplication::dual_divergence_retry_accept_tol`]:
616/// there is then no admissible point to protect, and the retry's is
617/// strictly better information.
618///
619/// `tol` is that same acceptable tolerance, scaled by
620/// `max(1, |base_obj|)` so the comparison is relative on a large
621/// objective and absolute on a small one — the convention
622/// `sigma_forward_error_is_small` uses for `‖x‖` and for the same reason.
623/// It is not a fitted constant: the objective moves this has to admit
624/// (`qpec_small`, `5.8e-11`) and the ones it has to refuse (`r201`,
625/// `0.198`; `scholtes4`, `6.6e-05`) sit four and five orders away from it
626/// on either side.
627///
628/// `sense` is `+1` for a minimization and `-1` for a maximization, and both
629/// objectives are multiplied by it before either rule looks at them.
630/// [`SolveStatistics::final_objective`] is the objective evaluated on the
631/// **user** TNLP — signed, and *not* premultiplied by `obj_scaling_factor` —
632/// and a negative `obj_scaling_factor` is the documented way to pose a
633/// maximization. Without the normalization both rules invert under it: rule 1
634/// would refuse genuine *improvements* (a regression against the behaviour
635/// before this conjunct existed) and rule 2 would admit strictly worse
636/// answers, re-arming the exact class the conjunct was added to block. The
637/// repo has shipped one defect from this sign already —
638/// `masked_certificate_fuzz.rs::the_veto_is_not_disabled_by_a_negative_objective_scaling_factor`,
639/// whose fix took `.abs()` in the residual accessors, which is also what keeps
640/// the detector firing under maximization and so keeps this path reachable.
641fn retry_answer_is_admissible(
642    base_obj: Number,
643    base_viol: Number,
644    retry_obj: Number,
645    retry_viol: Number,
646    accept_tol: Number,
647    sense: Number,
648) -> bool {
649    // Nothing to compare against: a non-finite or infeasible base answer
650    // is not a point worth protecting.
651    if !base_obj.is_finite() || !base_viol.is_finite() || base_viol > accept_tol {
652        return true;
653    }
654    // A non-finite retry objective is refused rather than admitted, for
655    // the same reason `runaway_is_the_whole_residual` refuses a NaN.
656    if !retry_obj.is_finite() {
657        return false;
658    }
659    // Both into a minimization frame, so "lower is better" below is true
660    // whichever sense the caller posed.
661    let base_obj = sense * base_obj;
662    let retry_obj = sense * retry_obj;
663    let tol = accept_tol * base_obj.abs().max(1.0);
664    if retry_obj > base_obj + tol {
665        return false; // rule 1: strictly worse feasible point
666    }
667    if retry_obj < base_obj - tol {
668        // Rule 2: an improvement is admissible only if the retry did not
669        // give up primal accuracy to get it.
670        return retry_viol.is_finite() && retry_viol <= base_viol.max(RETRY_VIOL_NOISE_FLOOR);
671    }
672    true
673}
674
675/// How far `x` sits outside the declared box, **relative to each bound's own
676/// magnitude**: `max_i viol_i / max(1, |b_i|)`, where `b_i` is the bound
677/// `x_i` crosses and bounds at or beyond the infinity sentinels are absent.
678///
679/// Relative because the quantity it is compared on is: `bound_relax_factor`
680/// widens bound `b` by `factor · max(1, |b|)` (capped at `constr_viol_tol`),
681/// so a point pinned to the widened bound sits a *relative* distance from the
682/// box that does not depend on `|b|`, while its rounding is one ulp of `|b|`
683/// -- `1.2e-10` at `|b| = 1e6`, two orders above any absolute floor that
684/// still separates `1e-8` from noise on a unit bound.
685///
686/// A variable fixed by its declared box (`x_L == x_U`) is skipped. Under
687/// `fixed_variable_treatment=relax_bounds` it stays in the iterate with its
688/// point widened into a band -- in the base attempt and the re-solve alike,
689/// since a barrier needs an interior -- so where it lands inside that band is
690/// barrier noise, not a choice to use the widening: on a fixed `z = 0.5`
691/// beside three bound-active controls the base attempt returned it `1.2e-11`
692/// off and the re-solve `2.0e-11`, and counting it declined a re-solve whose
693/// controls were all inside their box. Under the default `make_parameter` it
694/// is spliced in at its exact value and reads `0` either way.
695///
696/// `NaN` when the lengths disagree (nothing to measure against).
697fn declared_box_relative_violation(
698    x: &[Number],
699    x_l: &[Number],
700    x_u: &[Number],
701    lower_inf: Number,
702    upper_inf: Number,
703) -> Number {
704    if x.len() != x_l.len() || x.len() != x_u.len() {
705        return Number::NAN;
706    }
707    let mut worst = 0.0_f64;
708    for i in 0..x.len() {
709        let (l, u) = (x_l[i], x_u[i]);
710        if l == u {
711            continue;
712        }
713        if l > lower_inf && x[i] < l {
714            worst = worst.max((l - x[i]) / l.abs().max(1.0));
715        }
716        if u < upper_inf && x[i] > u {
717            worst = worst.max((x[i] - u) / u.abs().max(1.0));
718        }
719    }
720    worst
721}
722
723/// A relative declared-box violation at or below this is rounding, not use of
724/// the widening: four ulps of the bound's magnitude (`4 · ε ≈ 8.9e-16`).
725///
726/// Measured populations on either side of it. Rounding: a point pinned to a
727/// bound reads at most one ulp off it, `ε/2 .. ε` relative at any magnitude.
728/// Genuine use of the widening, from the bound-magnitude probe (the
729/// `scale_audit_bound_active` model with `x_U` in `{1, 10, 1e2, 1e3, 1e4,
730/// 1e6}`, each with the box at `[0, x_U]` and at `[x_U − 1, x_U]`, under a
731/// named `bound_relax_factor=1e-8`): the re-solve's excess over the base
732/// attempt is `4.97e-9` relative at `x_U = 1`, `9.5e-9` at `10`, `9.9e-9` at
733/// `1e2`, `2.5e-9` at `1e3`, `2.5e-10` at `1e4` and `2.5e-12` at `1e6` (the
734/// last three are where `constr_viol_tol = 1e-4` caps the widening). The
735/// smallest of those is three and a half orders above this floor, the
736/// largest rounding three times below it.
737const RETRY_BOX_REL_NOISE: Number = 4.0 * Number::EPSILON;
738
739/// May a retry's point ship, as far as the box the caller *declared* goes?
740///
741/// Only when it sits no further outside that box than the base attempt's
742/// point does, measured relative to each bound's magnitude
743/// ([`declared_box_relative_violation`]) and up to [`RETRY_BOX_REL_NOISE`].
744/// Every interior-point solve runs on bounds widened by `bound_relax_factor`,
745/// so "outside the declared box" is something any converged point can be, by
746/// up to the widening; what this refuses is a retry that *uses more of it*
747/// than the answer already in hand. That is the gh#983 scale audit's failure
748/// mode exactly: its re-solve drives complementarity down, which on a
749/// bound-active variable is the same as driving the point onto the widened
750/// bound, so `ex1_320`'s re-solve shipped `1.0e-8` outside the box its base
751/// attempt satisfied exactly.
752///
753/// Unconditional, unlike rule 2 of [`retry_answer_is_admissible`]: a point
754/// outside the box is the defect whatever the objective did, and a re-solve
755/// whose objective move is under that rule's tolerance shipped it all the
756/// same. When the caller left `bound_relax_factor` alone the scale audit's
757/// re-solve keeps the variable box as declared (its inequality rows are
758/// still widened, as in the base attempt), so there this conjunct is a
759/// backstop. It bites when the caller named a widening: both attempts then
760/// solve the widened box, and the re-solve may not exploit it further.
761///
762/// `NaN` means "not measured". A retry that cannot be measured is refused
763/// unless the base could not be either: there is then nothing to compare and
764/// the rule has no opinion.
765fn retry_keeps_declared_box(base_rel_viol: Number, retry_rel_viol: Number) -> bool {
766    if retry_rel_viol.is_nan() {
767        return base_rel_viol.is_nan();
768    }
769    let base = if base_rel_viol.is_nan() {
770        0.0
771    } else {
772        base_rel_viol.max(0.0)
773    };
774    retry_rel_viol <= base + RETRY_BOX_REL_NOISE
775}
776
777/// What [`IpoptApplication::initialize_with_option_file`] did — enough
778/// for a caller to tell the user which file (if any) configured the run.
779#[derive(Debug, Default, Clone)]
780pub struct OptionFileLoad {
781    /// The file actually read. `None` means no options file was read:
782    /// nobody named one and neither default was present.
783    pub path: Option<PathBuf>,
784    /// Whether [`Self::path`] was named by the caller rather than found
785    /// by probing the working directory.
786    pub explicit: bool,
787    /// Non-fatal notes about option-file settings that did *not* take
788    /// effect. Nothing here stops a solve; the point is that it not
789    /// happen silently.
790    pub warnings: Vec<String>,
791}
792
793/// Opaque snapshot of an application's linear-solver summaries; see
794/// [`IpoptApplication::linear_solver_summary_state`].
795#[derive(Debug, Clone)]
796pub struct LinearSolverSummaryState {
797    main: LinearSolverSummary,
798    restoration: LinearSolverSummary,
799}
800
801/// Factory that constructs a fresh restoration-phase strategy on
802/// demand. The outer algorithm owns at most one restoration object,
803/// so the factory is invoked once per `optimize_tnlp` call. The
804/// factory is `FnMut` to allow callers to capture a builder that
805/// internally reuses caches across builds.
806pub type RestorationFactory = Box<dyn FnMut() -> Box<dyn RestorationPhase>>;
807
808/// Provider that mints fresh [`RestorationFactory`] instances on
809/// demand. Used by drivers that need to run the inner IPM more than
810/// once per `optimize_tnlp` call — notably the Phase-3 ℓ₁-exact
811/// penalty-barrier outer loop (pounce#10), which the existing
812/// `RestorationFactory` cannot support because pounce's default
813/// `make_default_restoration_factory` is a one-shot. Callers wire
814/// this via [`IpoptApplication::set_restoration_factory_provider`].
815pub type RestorationFactoryProvider = Box<dyn FnMut() -> RestorationFactory>;
816
817/// Running totals over the passes of a multi-pass solve (the ℓ₁ exact-penalty
818/// ρ loop, and the plain attempt that precedes it under
819/// `l1_fallback_on_restoration_failure`). Every pass starts from reset
820/// statistics, so the passes are summed here and written back once, after
821/// the last, as the **total** `iteration_count` plus a `passes` summary
822/// (gh#987 item 6). Everything else in the statistics keeps describing the
823/// last pass — the one that produced the returned point.
824#[derive(Default)]
825struct MultiPassTotals {
826    iterations: Index,
827    rows: Vec<pounce_nlp::solve_statistics::IterRecord>,
828    obj_evals: Index,
829    constr_evals: Index,
830    grad_evals: Index,
831    jac_evals: Index,
832    hess_evals: Index,
833    restoration_calls: Index,
834    restoration_inner_iters: Index,
835    restoration_outer_iters: Index,
836    passes: Vec<pounce_nlp::SolvePassRecord>,
837}
838
839impl MultiPassTotals {
840    fn push_pass(
841        &mut self,
842        st: &SolveStatistics,
843        rho: Option<Number>,
844        status: ApplicationReturnStatus,
845    ) {
846        self.passes.push(pounce_nlp::SolvePassRecord {
847            rho,
848            iterations: st.iteration_count,
849            first_row: self.rows.len(),
850            slack_sum: None,
851            constraint_violation: None,
852            status: format!("{status:?}"),
853        });
854        self.iterations += st.iteration_count;
855        self.rows.extend(st.iterations.iter().cloned());
856        self.obj_evals += st.num_obj_evals;
857        self.constr_evals += st.num_constr_evals;
858        self.grad_evals += st.num_obj_grad_evals;
859        self.jac_evals += st.num_constr_jac_evals;
860        self.hess_evals += st.num_hess_evals;
861        self.restoration_calls += st.restoration_calls;
862        self.restoration_inner_iters += st.restoration_inner_iters;
863        self.restoration_outer_iters += st.restoration_outer_iters;
864    }
865
866    fn set_slack_sum(&mut self, v: Number) {
867        if let Some(p) = self.passes.last_mut() {
868            p.slack_sum = v.is_finite().then_some(v);
869        }
870    }
871
872    fn set_violation(&mut self, v: Number) {
873        if let Some(p) = self.passes.last_mut() {
874            p.constraint_violation = v.is_finite().then_some(v);
875        }
876    }
877
878    fn apply(&self, st: &mut SolveStatistics) {
879        if self.passes.is_empty() {
880            return;
881        }
882        st.iteration_count = self.iterations;
883        st.iterations = self.rows.clone();
884        st.num_obj_evals = self.obj_evals;
885        st.num_constr_evals = self.constr_evals;
886        st.num_obj_grad_evals = self.grad_evals;
887        st.num_constr_jac_evals = self.jac_evals;
888        st.num_hess_evals = self.hess_evals;
889        st.restoration_calls = self.restoration_calls;
890        st.restoration_inner_iters = self.restoration_inner_iters;
891        st.restoration_outer_iters = self.restoration_outer_iters;
892        st.passes = self.passes.clone();
893    }
894}
895
896/// Callback fired by [`IpoptApplication::optimize_constrained`] once
897/// the IPM has converged (status `SolveSucceeded` or
898/// `SolvedToAcceptableLevel`) and before the user TNLP's
899/// `finalize_solution` runs. Receives borrowed handles into the
900/// algorithm's converged state.
901///
902/// **Use case**: post-optimal sensitivity analysis (pounce#7 /
903/// `pounce-sensitivity`). The callback receives a shared handle to
904/// the PD solver so a `SensBacksolver` adapter can run backsolves
905/// against the converged KKT factor — and so that handle may outlive
906/// the call frame (e.g. the public `Solver` session API retains the
907/// factor for repeated `parametric_step` / `kkt_solve` calls);
908/// receives the data / cq / nlp handles so the adapter can reproduce
909/// the augmented-system coefficient layout the IPM converged at.
910///
911/// **Not** the same as `set_intermediate_callback` (per-iteration
912/// progress notification) — this fires exactly once per `optimize_*`
913/// call, only on success.
914pub type ConvergedCallback = Box<
915    dyn FnMut(
916        &crate::ipopt_data::IpoptDataHandle,
917        &crate::ipopt_cq::IpoptCqHandle,
918        &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
919        Rc<RefCell<crate::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
920    ),
921>;
922use pounce_common::diagnostics::DiagnosticsState;
923use pounce_common::exception::{ExceptionKind, SolverException};
924use pounce_common::journalist::{JournalLevel, Journalist};
925use pounce_common::options_list::OptionsList;
926use pounce_common::reg_options::{PrintOptionsMode, RegisteredOptions};
927use pounce_common::timing::TimingStatistics;
928use pounce_common::types::{Index, Number};
929use pounce_linalg::dense_vector::DenseVectorSpace;
930use pounce_linsol::SparseSymLinearSolverInterface;
931use pounce_linsol::summary::LinearSolverSummary;
932use pounce_nlp::alg_types::SolverReturn;
933use pounce_nlp::derivative_test::{DerivativeTest, DerivativeTestOptions};
934use pounce_nlp::orig_ipopt_nlp::{ConstObjScaling, OrigIpoptNlp, ScalingMethod};
935use pounce_nlp::return_codes::ApplicationReturnStatus;
936use pounce_nlp::solve_statistics::SolveStatistics;
937use pounce_nlp::tnlp::{
938    BoundsInfo, IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, NlpInfo, Solution, TNLP,
939};
940use pounce_nlp::tnlp_adapter::{
941    DEFAULT_NLP_LOWER_BOUND_INF, DEFAULT_NLP_UPPER_BOUND_INF, FixedVarTreatment, TNLPAdapter,
942};
943use std::cell::RefCell;
944use std::fmt;
945use std::path::{Path, PathBuf};
946use std::rc::Rc;
947use std::sync::{Arc, Mutex};
948use std::time::Instant;
949
950/// Smallest `n + m` at which `algorithm=active-set-sqp` starts from the
951/// interior point; see [`IpoptApplication::sqp_auto_handover`]. The fixture
952/// corpus (largest model 813 variables) sits far below it.
953pub const AUTO_HANDOVER_MIN_DIM: usize = 5_000;
954
955pub struct IpoptApplication {
956    options: OptionsList,
957    /// Diagnostics from the safeguarded `least_square_init_primal`
958    /// initializer step of the most recent solve (gh#605). `None` when
959    /// the step was not run. Read with
960    /// [`Self::least_square_init_report`].
961    least_square_init_report: Option<crate::init::default::LeastSquareInitReport>,
962    /// Per-variable scaling factors applied by the wrapper installed in
963    /// [`Self::optimize_tnlp`] (gh#486). Recorded so consumers that read
964    /// the algorithm's own iterate rather than the `finalize_solution`
965    /// payload — the CLI's `on_converged` hook feeding the `.sol` and
966    /// the JSON report — can undo the substitution. `None` when no
967    /// variable scaling was applied.
968    variable_scaling: RefCell<Option<Vec<Number>>>,
969    /// Whether the most recent `optimize_constrained` ran with per-row
970    /// constraint scaling active (`c_scale_vec`/`d_scale_vec` present).
971    ///
972    /// Read by [`Self::run_l1_penalty_outer_loop`], which measures the
973    /// user's rows in the model's own units and so may only write that
974    /// number into the `final_unscaled_*` family. `SolveStatistics`
975    /// documents `final_*` as the internally-scaled residuals and
976    /// `final_unscaled_*` as the same quantities in original units,
977    /// *equal when no scaling is active* — so the measurement may be
978    /// mirrored into the scaled family exactly when this is `false`
979    /// (gh#794 review). `None` when no solve has run.
980    row_scaling_active: std::cell::Cell<Option<bool>>,
981    /// Whether the submitted TNLP has already been explicitly wrapped by the
982    /// caller's presolve layer.
983    presolve_already_applied: bool,
984    reg_options: Rc<RegisteredOptions>,
985    journalist: Rc<Journalist>,
986    statistics: RefCell<SolveStatistics>,
987    /// Shared per-subsystem timing accumulator. Re-created at the top of
988    /// every solve (so back-to-back `optimize_tnlp` calls don't bleed
989    /// timings across invocations) and handed to the data, the NLP, and
990    /// any other consumer via `Rc`. Reported by [`Self::timing_stats`]
991    /// after the solve completes.
992    timing: RefCell<Rc<TimingStatistics>>,
993    /// Optional override factory for the symmetric linear-solver
994    /// backend. When `None`, we ship the workspace default (MA57 via
995    /// `pounce-hsl`). Tests can plug a stub via [`Self::set_linear_backend_factory`].
996    linear_backend_factory: Option<LinearBackendFactory>,
997    /// Optional factory for the restoration phase. Lives outside this
998    /// crate because `pounce-algorithm` cannot depend on
999    /// `pounce-restoration` (the dep edge is the other way). Callers
1000    /// that need restoration plug a factory via
1001    /// [`Self::set_restoration_factory`]; when unset, the outer
1002    /// algorithm runs without a restoration fallback and surfaces
1003    /// `RestorationFailure` as soon as the line-search would otherwise
1004    /// jump into restoration.
1005    restoration_factory: Option<RestorationFactory>,
1006    /// Shared diagnostic-dump state, installed by the CLI when the
1007    /// user passes `--dump <cat>:<spec>`. When set, the application
1008    /// propagates an `Rc<DiagnosticsState>` into [`IpoptAlgorithm`]
1009    /// via [`IpoptAlgorithm::with_diagnostics`] so the KKT solver and
1010    /// other dump sites can consult per-iter gating.
1011    diagnostics: Option<Rc<DiagnosticsState>>,
1012    /// Optional interactive debugger hook. When set, it is moved into
1013    /// the main [`IpoptAlgorithm`] for the next `optimize_*` call via
1014    /// [`IpoptAlgorithm::with_debug_hook`], so a REPL or agent can pause
1015    /// at each iteration to inspect / mutate live state. Consumed on use
1016    /// (one solve per installed hook).
1017    debug_hook: Option<std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>>,
1018    /// Provider for the BNW outer loop (pounce#10 Phase 3). When set,
1019    /// `optimize_constrained` consults the provider before each inner
1020    /// solve, replacing `restoration_factory` with a fresh one so
1021    /// multi-pass drivers can run the inner IPM repeatedly without
1022    /// tripping the default factory's one-shot guard.
1023    restoration_factory_provider: Option<RestorationFactoryProvider>,
1024    /// Optional hook fired once per `optimize_*` call on convergence,
1025    /// before the user TNLP's `finalize_solution`. See
1026    /// [`ConvergedCallback`].
1027    on_converged: Option<ConvergedCallback>,
1028    /// Re-evaluate `W` at the converged point before `on_converged` fires.
1029    /// See [`Self::set_on_converged_refreshes_hessian`].
1030    on_converged_refreshes_hessian: bool,
1031    /// Ids handed out so far to `on_converged` firings; the next firing gets
1032    /// one more. Never reset, so an id is unique over the application's life.
1033    /// See [`IpoptData::on_converged_id`](crate::ipopt_data::IpoptData).
1034    on_converged_fired: std::cell::Cell<u64>,
1035    /// When `true`, the per-iteration `IterRecord` trajectory is
1036    /// captured into [`SolveStatistics::iterations`] for downstream
1037    /// consumers (the JSON solve report in pounce-cli, pounce#8). Off
1038    /// by default so library callers that never read the iterations
1039    /// vector don't pay the per-iter alloc.
1040    record_iter_history: bool,
1041    /// Whether [`Self::initialize_with_option_file`] ran — i.e. whether
1042    /// anything on this application actually consulted
1043    /// `option_file_name` and resolved it to a file. Only the `pounce`
1044    /// CLI does; a library caller sets its options directly. The guard
1045    /// in [`Self::unhonored_option_file_name`] reads this so that
1046    /// setting the option on a surface that cannot honor it is refused
1047    /// rather than dropped (gh#518).
1048    option_file_resolved: bool,
1049    /// Whether this caller can route a model to the convex LP/QP/SOCP
1050    /// engines — i.e. whether the `qp_*` knobs those engines read
1051    /// configure anything here. Only the `pounce` CLI can (it owns the
1052    /// `.nl` structure extraction that classifies a model), and it says
1053    /// so via [`Self::set_convex_routing_available`]. The guard in
1054    /// [`Self::unhonored_convex_option`] reads this, on the same
1055    /// contract as `option_file_resolved` above (gh#604).
1056    convex_routing_available: bool,
1057    /// Whether the backend-knob warnings (gh#551) have already been
1058    /// printed for this application. The CLI emits them before routing —
1059    /// a convex model never reaches `optimize_tnlp` — and `optimize_tnlp`
1060    /// emits them for every other frontend; without this flag a CLI run
1061    /// would print each line twice, which is how a warning teaches its
1062    /// reader to skip it.
1063    backend_warnings_emitted: bool,
1064    /// Shared sink that the linear-solver backend writes a rolling
1065    /// [`LinearSolverSummary`] into after every factor. Reset at the
1066    /// top of every solve (so back-to-back `optimize_tnlp` calls don't
1067    /// bleed stats across invocations) and read out via
1068    /// [`Self::linear_solver_summary`] once the solve returns. Only
1069    /// the workspace-default FERAL backend (via
1070    /// [`default_backend_factory_with_sink`]) wires the sink today;
1071    /// custom factories plugged through [`Self::set_linear_backend_factory`]
1072    /// and the HSL MA57 backend leave the sink empty.
1073    linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
1074    /// Companion to [`Self::linsol_summary_sink`] for the restoration
1075    /// phase's sub-solves. The frontends that wire a restoration factory
1076    /// install it via [`Self::restoration_summary_sink`]; it is reset with
1077    /// the main sink and surfaces as [`LinearSolverSummary::restoration`].
1078    resto_linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
1079    /// The block-parallel KKT partition this solve resolved, published for
1080    /// the restoration sub-IPM (structured-KKT Phase 5b). Restoration's inner
1081    /// builder is minted by the frontend *before* the solve — before the KKT
1082    /// layout, and so the mapping onto it, exists — so the labels cannot be
1083    /// installed on it directly. They are written here as the outer algorithm
1084    /// is built and read when restoration runs. `AugRestoSystemSolver` reduces
1085    /// the restoration KKT onto the original 4-block system before delegating,
1086    /// so the matrix the inner solver factors is the one these labels
1087    /// describe. Reset with the sinks at the top of every solve.
1088    kkt_blocks_published: crate::alg_builder::KktBlocksCell,
1089    /// Shared tally of successful linear-solver quality escalations for
1090    /// the current solve (gh#857). Handed to every `AlgorithmBuilder`
1091    /// this application mints — [`Self::algorithm_builder_from_options`]
1092    /// and [`Self::algorithm_builder_snapshot`] — which is how the
1093    /// restoration sub-solve counts into the same total: each frontend
1094    /// builds the restoration provider's inner builder from
1095    /// `algorithm_builder_from_options`, so it receives this same `Rc`
1096    /// without any frontend having to know the counter exists.
1097    ///
1098    /// Reset at the top of every solve, beside the linear-solver summary
1099    /// sink and for the same reason: a second-opinion retry, or two
1100    /// back-to-back `optimize_tnlp` calls, must not inherit the previous
1101    /// solve's count. Read out into
1102    /// [`SolveStatistics::quality_escalations`] once the solve returns.
1103    quality_escalations: Rc<std::cell::Cell<u64>>,
1104    /// gh#990: the `mu_strategy` value in the option table was written by a
1105    /// POUNCE frontend (the Python no-`hessian` path pins `monotone`), not by
1106    /// the caller. See [`Self::mark_mu_strategy_pounce_chosen`].
1107    mu_strategy_pounce_chosen: bool,
1108    /// gh#884. Set when the running [`IpoptAlgorithm`] observed the
1109    /// biactive dual-divergence signature — a converged primal, a step
1110    /// that has gone to zero on a scale-relative measure, and an
1111    /// *unscaled* dual infeasibility still far above `dual_inf_tol`, all
1112    /// at the same iterate.
1113    ///
1114    /// Read out of the algorithm once `optimize_constrained` returns, so
1115    /// [`Self::run_with_dual_divergence_retry`] — which sits above that
1116    /// call — can see it. Reset at the top of every solve for the same
1117    /// reason as `quality_escalations`: a retry must not inherit the
1118    /// previous attempt's verdict. Also copied into
1119    /// [`SolveStatistics::dual_divergence_signature`].
1120    dual_divergence_signature: std::cell::Cell<bool>,
1121    /// The returned run's algorithm released the tiny-step stop through the
1122    /// `tiny_step_dual_stall_iters` rule. Read by the mu-strategy fallback,
1123    /// which retries a `Search_Direction_Becomes_Too_Small` reached this way
1124    /// as it does an iteration limit. Reset at the top of every solve.
1125    tiny_step_dual_stall: std::cell::Cell<bool>,
1126    /// gh#990 item 3: the derivative checker's structured result for the
1127    /// current solve; copied onto the statistics when the solve finishes.
1128    derivative_check: RefCell<Option<pounce_nlp::derivative_test::DerivativeCheckSummary>>,
1129    /// gh#990 item 13: some attempt of this run refused a termination
1130    /// certificate the objective scaling had masked (strict, acceptable).
1131    /// Sticky across the attempts a retry driver spends, like
1132    /// `dual_divergence_signature`: the returned run is often a re-solve, and
1133    /// the decision that led to it belongs on the record.
1134    obj_scale_refused: std::cell::Cell<(bool, bool)>,
1135    /// gh#884. Set when a dual-divergence retry actually replaced the base
1136    /// attempt's answer. Copied into
1137    /// [`SolveStatistics::dual_divergence_retry_promoted`].
1138    dual_divergence_retry_promoted: std::cell::Cell<bool>,
1139    /// gh#1042 C1. What the `mu_strategy_fallback` retry did in this solve,
1140    /// if it ran. Copied into [`SolveStatistics::mu_strategy_fallback`] by
1141    /// every attempt that follows it, and written into the statistics
1142    /// directly once the retry has decided — so a promoted re-solve no
1143    /// longer hides the first attempt's verdict and cost.
1144    mu_strategy_fallback_record: RefCell<Option<pounce_nlp::MuStrategyFallbackRecord>>,
1145    /// Set when a losing retry's answer was thrown away and an earlier
1146    /// attempt's replayed through `FinalizeSnapshot::replay` — by the μ
1147    /// fallback (pounce#870) or the gh#884 dual-divergence retry.
1148    ///
1149    /// It exists because [`Self::set_on_converged`] fires **per attempt**
1150    /// and the floor does not reach it. A losing retry that reached
1151    /// `Solve_Succeeded` has already run the callback, so a consumer
1152    /// capturing the converged iterate there — the CLI's `nominal_capture`,
1153    /// which is where `.sol` `x`, the JSON's `solution.x` and the dual
1154    /// block all come from — holds the *discarded* attempt's point while
1155    /// the status, objective and every statistic beside it have been
1156    /// floored back to the attempt that won. Measured before this flag
1157    /// existed: on a declined retry the `.sol` carried `f = -6.3274` while
1158    /// the JSON report next to it said `-6.1768`, and `pounce verify` on
1159    /// the `.sol` confirmed the file held the losing point.
1160    ///
1161    /// The `finalize_solution` payload does not have this problem — the
1162    /// replay *is* a `finalize_solution` call, so the last one always
1163    /// carries the answer being reported — which is why the fix is to tell
1164    /// the caller "prefer that payload", not to re-run the callback (the
1165    /// converged KKT state the callback borrows belongs to the retry by
1166    /// then, and cannot be rewound). Consumers of that state select the
1167    /// reported attempt's firing through [`Self::reported_on_converged_id`].
1168    answer_restored_from_floor: std::cell::Cell<bool>,
1169    /// Set only for the duration of the gh#983 scale audit's re-solve: the
1170    /// interior-point arm then widens the inequality rows by
1171    /// `bound_relax_factor` as usual but leaves the **variable box as the
1172    /// caller declared it** (`OrigIpoptNlp::relax_bounds_keeping_box`). See
1173    /// `run_scale_retry` for why.
1174    retry_keeps_declared_box: std::cell::Cell<bool>,
1175    /// The payload of the most recent `finalize_solution` POUNCE sent to the
1176    /// user's TNLP, so a second-opinion retry that loses can put the winning
1177    /// attempt's answer back (pounce#870).
1178    ///
1179    /// Written by [`finalize_via_orig_nlp`] and [`finalize_via_sqp`], which are
1180    /// free functions and so take this as a sink rather than reaching for
1181    /// `self`, and by [`Self::restore_floor`] when a floor is replayed, so it
1182    /// always holds the answer currently delivered. Cleared at the start of
1183    /// each solve. Read by the retry wrappers (each floors to it) and by
1184    /// [`Self::reported_on_converged_id`].
1185    last_finalize: RefCell<Option<FinalizeSnapshot>>,
1186    /// The last `IterStats` sent to the user's `intermediate_callback`
1187    /// (pounce#870), shared with the running `IpoptAlgorithm`.
1188    last_iter_stats: Rc<RefCell<Option<pounce_nlp::tnlp::IterStats>>>,
1189    /// Phase 5c (§6) SQP warm-start input. When `Some`, the next
1190    /// `optimize_tnlp` call on the SQP path consumes the iterate
1191    /// instead of cold-starting; consumed once per solve, then
1192    /// auto-cleared. The IPM path ignores this field. Wire-set
1193    /// via [`Self::set_sqp_warm_start`].
1194    sqp_warm_start: Option<crate::sqp::SqpIterates>,
1195    /// Phase 5c (§6) SQP warm-start output. Populated by every
1196    /// `optimize_sqp_tnlp` call with the final QP working set.
1197    /// Stays valid until the next solve (which overwrites it).
1198    /// Accessed via [`Self::last_sqp_working_set`].
1199    sqp_last_working_set: Option<pounce_qp::WorkingSet>,
1200    /// gh#986 review item 10: the fixed-variable elimination of the solve in
1201    /// progress, `(x_not_fixed_map, n_full_x)`. Working sets are published in
1202    /// the caller's full space (fixed variables marked `Fixed`) and warm
1203    /// starts are mapped through it, so a working set recorded on one node
1204    /// lands on the right columns of a node that fixes different variables.
1205    x_elimination: Option<(Vec<Index>, usize)>,
1206    /// What the post-convergence crossover phase did on the most recent
1207    /// IPM solve (gh#612). `None` when `crossover=no` (the default) or
1208    /// when the solve did not converge — crossover only runs on a
1209    /// converged interior iterate. Read via [`Self::crossover_report`].
1210    ///
1211    /// Kept separate from `sqp_last_working_set`, which crossover *also*
1212    /// populates on success: that field answers "what can the next solve
1213    /// warm-start from", this one answers "was the active set I am about
1214    /// to trust actually identified, or merely inferred".
1215    crossover_report: Option<crate::crossover::CrossoverReport>,
1216    /// What the interior-point → active-set handover did on the most recent
1217    /// IPM solve (gh#1054). `None` when `ipm_sqp_handover=no` (the default).
1218    /// Read via [`Self::handover_report`].
1219    handover_report: Option<crate::handover::HandoverReport>,
1220    /// Whether the current solve is an `algorithm=active-set-sqp` request
1221    /// run as interior point plus handover; see [`Self::sqp_auto_handover`].
1222    auto_handover: std::cell::Cell<bool>,
1223    /// Full primal-dual warm-start iterate for the IPM path, captured by
1224    /// the interactive debugger's `resolve` command. When `Some`, the
1225    /// next `optimize_tnlp` installs this 8-vector (algorithm space)
1226    /// directly onto `data.curr` before the iterate initializer runs, so
1227    /// a warm `resolve` continues from the paused interior point rather
1228    /// than cold-restarting the duals. Consumed once per solve, then
1229    /// auto-cleared. Requires `warm_start_init_point=yes` so the
1230    /// re-optimize branch of `WarmStartIterateInitializer` keeps the
1231    /// installed iterate. Wire-set via [`Self::set_warm_start_iterate`].
1232    warm_start_iterate: Option<crate::debug::IterateSnapshot>,
1233    /// The warm-start initializer's verdict on the most recent solve's
1234    /// supplied iterate (gh#606). Lifted off the solve-local
1235    /// `IpoptData` so a caller can read it after `optimize_tnlp`
1236    /// returns; `None` when the last solve was a cold start.
1237    warm_start_diag: RefCell<Option<crate::init::warm_start::WarmStartDiagnostics>>,
1238    /// Caller-supplied fill-reducing permutation for the KKT linear
1239    /// solver (pounce#180 item 1 / FERAL#107). When `Some`, it overrides
1240    /// whatever `feral_ordering` / `POUNCE_FERAL_ORDERING` resolves to,
1241    /// installing [`pounce_feral::OrderingMethod::External`] on the FERAL
1242    /// backend for the next solve. The vector is a **0-based, new-to-old
1243    /// permutation** whose length must equal the augmented KKT system
1244    /// dimension; FERAL validates it as a bijection and returns
1245    /// `InvalidInput` (never panics) on a wrong length / index. Unlike the
1246    /// warm-start hooks this is treated as persistent config — it is *not*
1247    /// auto-cleared after a solve, so a caller sets it once for a run.
1248    /// Wire-set via [`Self::set_external_ordering`]. Ignored by non-FERAL
1249    /// backends and by any custom factory plugged via
1250    /// [`Self::set_linear_backend_factory`].
1251    external_ordering: Option<Vec<usize>>,
1252    /// Caller-supplied block-triangular / Schur KKT partition (pounce#180
1253    /// item 2). When `Some`, the next IPM solve on the feral + exact-Hessian
1254    /// path routes the KKT linear solve through a
1255    /// [`crate::kkt::SchurAugSystemSolver`] over these **KKT-space indices**
1256    /// (`0..dim` in the `x, s, c, d` block order the aug-system solver
1257    /// assembles): the `S` block is Schur-complemented out and only the two
1258    /// diagonal blocks are factorized, with inertia recovered via Sylvester's
1259    /// law. Beneficial only when `|S| ≪` the eliminated block; the Schur solver
1260    /// falls back to the standard full-space solver transparently when the
1261    /// partition is unsuitable (too large, malformed, or the backend errors),
1262    /// so a stray hook never breaks a solve. Persistent config (not
1263    /// auto-cleared). Wire-set via [`Self::set_kkt_schur_block`].
1264    kkt_schur_block: Option<Vec<usize>>,
1265    /// Block-parallel KKT partition (structured-KKT Phase 5b): KKT-space block
1266    /// labels, `< 0` for the shared border, **empty** meaning "detect".
1267    /// Wire-set via [`Self::set_kkt_block_structure`].
1268    kkt_blocks: Option<Vec<i32>>,
1269    /// The same partition declared in the *model's* own terms — one block per
1270    /// variable and per constraint — which [`map_block_structure_to_kkt`]
1271    /// turns into `kkt_blocks` once the NLP layout is known. Wire-set via
1272    /// [`Self::set_block_structure`].
1273    model_blocks: Option<(Vec<i32>, Vec<i32>)>,
1274    /// The problem-statistics block most recently printed during the current
1275    /// run, so a retry attempt does not reprint an identical one.
1276    ///
1277    /// Every retry driver — the ℓ₁ fallback, the μ-strategy fallback, the
1278    /// second-opinion ladder, the dual-divergence retry — re-enters the solve
1279    /// routine that emits this block, so a run that retried once printed the
1280    /// whole Ipopt-style header twice with nothing between the copies saying a
1281    /// second attempt had started. Read as "the solver printed everything
1282    /// twice", which is what it looked like.
1283    ///
1284    /// Scoped to one run: reset at the top of
1285    /// [`Self::optimize_tnlp_with_derivative_test_tnlp`], which every entry
1286    /// point funnels through. Without that reset a library caller's second
1287    /// deliberate `optimize_tnlp` on the same application would lose its
1288    /// header, which is a different thing entirely from a retry inside one
1289    /// call.
1290    ///
1291    /// Compared rather than counted because the block can legitimately differ
1292    /// between attempts: the ℓ₁ wrapper adds slack variables, so its retry
1293    /// solves a genuinely larger problem and reprinting is informative.
1294    last_printed_problem_stats: RefCell<Option<pounce_solve_report::console::ProblemStats>>,
1295    /// Set while an *outside* retry driver is re-entering `optimize_tnlp` for
1296    /// another attempt on the same problem — today only
1297    /// `pounce_restoration::second_opinion_driver::run_second_opinion_ladder`.
1298    ///
1299    /// The retries that nest INSIDE one `optimize_tnlp` call (the ℓ₁ fallback,
1300    /// the μ-strategy fallback) are covered by `last_printed_problem_stats`
1301    /// alone, because the funnel that resets it runs once for all of them. The
1302    /// ladder's rungs are `optimize_tnlp` calls in their own right, so without
1303    /// this flag each one resets the memo and reprints the header — which is
1304    /// how a four-rung ladder printed five identical copies of it.
1305    ///
1306    /// It also decides who explains the retry. The ladder narrates every rung
1307    /// on stderr, naming the knob it varies and whether the rung recovered, so
1308    /// `emit_problem_stats` stays quiet and lets that narration stand. The
1309    /// in-call fallbacks say nothing at all, so there the suppressed header is
1310    /// replaced by a line announcing the attempt.
1311    in_retry_sequence: std::cell::Cell<bool>,
1312    /// Nesting depth of retry drivers that have taken ownership of the
1313    /// run-ending `EXIT:` / `POUNCE <version>:` verdict.
1314    ///
1315    /// `emit_end_summary` prints the per-attempt statistics block whatever
1316    /// this reads, and prints the verdict only at zero. A driver raises it
1317    /// before its first attempt and lowers it after its last, then prints the
1318    /// verdict once with the status it is actually returning.
1319    ///
1320    /// A counter rather than a flag because the drivers nest: the ℓ₁ fallback
1321    /// can wrap the μ-strategy fallback, which the dual-divergence retry can
1322    /// wrap in turn, and the CLI brackets the whole lot again around the
1323    /// second-opinion ladder. Only the outermost knows the run is over, and
1324    /// "outermost" is exactly "the one that brings this back to zero".
1325    end_verdict_deferrals: std::cell::Cell<u32>,
1326}
1327
1328impl fmt::Debug for IpoptApplication {
1329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1330        f.debug_struct("IpoptApplication")
1331            .field("options", &self.options)
1332            .field("statistics", &self.statistics)
1333            .finish_non_exhaustive()
1334    }
1335}
1336
1337impl Default for IpoptApplication {
1338    fn default() -> Self {
1339        Self::new()
1340    }
1341}
1342
1343impl IpoptApplication {
1344    /// New application with empty options and a default journalist.
1345    /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
1346    pub fn new() -> Self {
1347        let reg = RegisteredOptions::default();
1348        // Registration of a fresh registry can only fail on a duplicate
1349        // name, which would be a programming error in `reg_op`.
1350        register_all_upstream_options(&reg)
1351            .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
1352        pounce_presolve::register_options(&reg)
1353            .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
1354        let reg = Rc::new(reg);
1355        Self {
1356            options: OptionsList::with_registered(Rc::clone(&reg)),
1357            least_square_init_report: None,
1358            variable_scaling: RefCell::new(None),
1359            row_scaling_active: std::cell::Cell::new(None),
1360            presolve_already_applied: false,
1361            reg_options: reg,
1362            journalist: Rc::new(Journalist::new()),
1363            statistics: RefCell::new(SolveStatistics::new()),
1364            timing: RefCell::new(Rc::new(TimingStatistics::new())),
1365            linear_backend_factory: None,
1366            restoration_factory: None,
1367            diagnostics: None,
1368            debug_hook: None,
1369            restoration_factory_provider: None,
1370            on_converged: None,
1371            on_converged_refreshes_hessian: true,
1372            on_converged_fired: std::cell::Cell::new(0),
1373            record_iter_history: false,
1374            option_file_resolved: false,
1375            convex_routing_available: false,
1376            backend_warnings_emitted: false,
1377            linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
1378            resto_linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
1379            kkt_blocks_published: Arc::new(Mutex::new(None)),
1380            quality_escalations: Rc::new(std::cell::Cell::new(0)),
1381            mu_strategy_pounce_chosen: false,
1382            dual_divergence_signature: std::cell::Cell::new(false),
1383            tiny_step_dual_stall: std::cell::Cell::new(false),
1384            derivative_check: RefCell::new(None),
1385            obj_scale_refused: std::cell::Cell::new((false, false)),
1386            dual_divergence_retry_promoted: std::cell::Cell::new(false),
1387            mu_strategy_fallback_record: RefCell::new(None),
1388            answer_restored_from_floor: std::cell::Cell::new(false),
1389            retry_keeps_declared_box: std::cell::Cell::new(false),
1390            last_finalize: RefCell::new(None),
1391            last_iter_stats: Rc::new(RefCell::new(None)),
1392            sqp_warm_start: None,
1393            sqp_last_working_set: None,
1394            x_elimination: None,
1395            crossover_report: None,
1396            handover_report: None,
1397            auto_handover: std::cell::Cell::new(false),
1398            warm_start_iterate: None,
1399            warm_start_diag: RefCell::new(None),
1400            external_ordering: None,
1401            kkt_schur_block: None,
1402            kkt_blocks: None,
1403            model_blocks: None,
1404            last_printed_problem_stats: RefCell::new(None),
1405            in_retry_sequence: std::cell::Cell::new(false),
1406            end_verdict_deferrals: std::cell::Cell::new(0),
1407        }
1408    }
1409
1410    pub fn options(&self) -> &OptionsList {
1411        &self.options
1412    }
1413
1414    pub fn options_mut(&mut self) -> &mut OptionsList {
1415        &mut self.options
1416    }
1417
1418    /// Declare whether callers have already applied an explicit presolve
1419    /// wrapper to the TNLPs submitted to [`Self::optimize_tnlp`].
1420    ///
1421    /// When set, `optimize_tnlp` leaves its input TNLP unchanged even if the
1422    /// `presolve` option is enabled. This preserves the option table for
1423    /// reporting and debugger use while allowing specialized frontends to
1424    /// supply a wrapper with capabilities unavailable to generic callback
1425    /// TNLPs, such as an expression provider for FBBT.
1426    pub fn set_presolve_already_applied(&mut self, applied: bool) {
1427        self.presolve_already_applied = applied;
1428    }
1429
1430    /// Solve without materializing the generic presolve wrapper.
1431    ///
1432    /// This is for consumers that require the original TNLP coordinate system
1433    /// for the solve's KKT matrix, such as sensitivity and reduced-Hessian
1434    /// drivers. It is scoped to this invocation and does not change the
1435    /// application's `presolve` option or persistent explicit-wrapper setting.
1436    pub fn optimize_tnlp_without_presolve(
1437        &mut self,
1438        tnlp: Rc<RefCell<dyn TNLP>>,
1439    ) -> ApplicationReturnStatus {
1440        let explicit_wrapper = self.presolve_already_applied;
1441        self.presolve_already_applied = true;
1442        let status = self.optimize_tnlp(tnlp);
1443        self.presolve_already_applied = explicit_wrapper;
1444        status
1445    }
1446
1447    pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
1448        &self.reg_options
1449    }
1450
1451    pub fn journalist(&self) -> &Rc<Journalist> {
1452        &self.journalist
1453    }
1454
1455    /// Plug a custom symmetric-linear-solver factory. Useful for tests
1456    /// that want to swap MA57 for a stub. Production callers should
1457    /// leave this unset — the default ([`default_backend_factory`])
1458    /// returns the workspace's MA57 binding.
1459    pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
1460        self.linear_backend_factory = Some(factory);
1461    }
1462
1463    /// Plug a restoration-phase factory. Called once per
1464    /// `optimize_tnlp` invocation to mint a fresh
1465    /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
1466    /// its line-search restoration fallback. Lives behind a setter
1467    /// (rather than at construction) because the concrete restoration
1468    /// strategies live in `pounce-restoration`, which depends on this
1469    /// crate; consumers in `pounce-cli` / integration tests wire the
1470    /// factory at the application boundary.
1471    pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
1472        self.restoration_factory = Some(factory);
1473    }
1474
1475    /// Install the shared diagnostics state. Once set, every
1476    /// subsequent `optimize_tnlp` call forwards the state into the
1477    /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
1478    /// solver can emit `--dump kkt:...` artifacts.
1479    pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
1480        self.diagnostics = Some(diag);
1481    }
1482
1483    /// Install an interactive debugger hook for the next `optimize_*`
1484    /// call. The hook is moved into the main [`IpoptAlgorithm`] and
1485    /// consumed by that solve; reinstall it to debug a subsequent solve.
1486    pub fn set_debug_hook(
1487        &mut self,
1488        hook: std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>,
1489    ) {
1490        self.debug_hook = Some(hook);
1491    }
1492
1493    /// Read-side accessor for the installed diagnostics state, if any.
1494    /// Lets the CLI write the top-level manifest/timing files after
1495    /// the solve completes.
1496    pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
1497        self.diagnostics.as_ref().map(Rc::clone)
1498    }
1499
1500    /// The three options that decide how the TNLP is *classified* before any
1501    /// algorithm sees it: the two infinity thresholds and the fixed-variable
1502    /// treatment.
1503    ///
1504    /// One reader, because there were three copies and they drifted. The
1505    /// active-set SQP path built its adapter with `TNLPAdapter::new`, which
1506    /// hard-codes the same three defaults — so the options were accepted and
1507    /// discarded on that arm, and a model with `fixed_variable_treatment=
1508    /// relax_bounds` or a non-default `nlp_upper_bound_inf` was classified one
1509    /// way for the interior-point arm and another for this one. Silently: the
1510    /// defaults coincide, so it only diverges for a caller who sets them, and
1511    /// nothing reported the difference.
1512    ///
1513    /// `make_constraint` / `make_parameter_nodual` are not implemented and
1514    /// fall back to `make_parameter`; the adapter auto-retries to
1515    /// `relax_bounds` when `make_parameter` would leave `n_x_var < n_c`
1516    /// (upstream `IpTNLPAdapter.cpp:623-633`).
1517    fn adapter_options(&self) -> (Number, Number, FixedVarTreatment) {
1518        let lo_inf = self
1519            .options
1520            .get_numeric_value("nlp_lower_bound_inf", "")
1521            .ok()
1522            .and_then(|(v, f)| f.then_some(v))
1523            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
1524        let up_inf = self
1525            .options
1526            .get_numeric_value("nlp_upper_bound_inf", "")
1527            .ok()
1528            .and_then(|(v, f)| f.then_some(v))
1529            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
1530        let fixed_treatment = match self
1531            .options
1532            .get_string_value("fixed_variable_treatment", "")
1533            .ok()
1534            .and_then(|(v, f)| f.then_some(v))
1535            .as_deref()
1536        {
1537            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
1538            _ => FixedVarTreatment::MakeParameter,
1539        };
1540        (lo_inf, up_inf, fixed_treatment)
1541    }
1542
1543    /// Whether the final solution reports bound multipliers for variables
1544    /// `make_parameter` removed (see
1545    /// `OrigIpoptNlp::complete_fixed_var_bound_multipliers`).
1546    /// Ipopt 3.14 does, for every treatment except `make_parameter_nodual`,
1547    /// which exists to ask it not to.
1548    pub fn reports_fixed_var_duals(&self) -> bool {
1549        !matches!(
1550            self.options
1551                .get_string_value("fixed_variable_treatment", "")
1552                .ok()
1553                .map(|(v, _)| v)
1554                .as_deref(),
1555            Some("make_parameter_nodual")
1556        )
1557    }
1558
1559    /// Mark that the next `optimize_*` calls are further **attempts at the
1560    /// same problem**, driven from outside, rather than new solves.
1561    ///
1562    /// Retry drivers that re-enter `optimize_tnlp` per attempt — the
1563    /// second-opinion ladder is the only one today — bracket their attempts
1564    /// with `true` … `false`. The effect is confined to console presentation:
1565    /// the Ipopt-style problem-statistics header is printed once for the run
1566    /// instead of once per attempt. Nothing about the solve changes.
1567    ///
1568    /// Callers must clear it on every exit path, including the early returns a
1569    /// ladder takes when a rung promotes; a stuck `true` costs a later solve
1570    /// its header and nothing worse.
1571    pub fn set_in_retry_sequence(&self, active: bool) {
1572        self.in_retry_sequence.set(active);
1573    }
1574
1575    /// Take ownership of the run-ending `EXIT:` / `POUNCE <version>:` verdict
1576    /// for the attempts that follow.
1577    ///
1578    /// Between this and [`Self::release_end_verdict`] the per-attempt end
1579    /// summary prints its statistics block but not the verdict, so a retried
1580    /// run no longer reports a mid-run verdict that reads as the final answer.
1581    /// The releaser prints it once, with the status it is returning.
1582    ///
1583    /// Callers must pair the two on every exit path. An unmatched acquire
1584    /// costs the run its verdict line; the counter saturates rather than
1585    /// wrapping, so an unmatched release cannot make a nested driver print
1586    /// one early.
1587    pub fn defer_end_verdict(&self) {
1588        self.end_verdict_deferrals
1589            .set(self.end_verdict_deferrals.get().saturating_add(1));
1590    }
1591
1592    /// Release one [`Self::defer_end_verdict`]. Returns `true` when this was
1593    /// the outermost one — i.e. when the caller now owns printing the verdict.
1594    #[must_use]
1595    pub fn release_end_verdict(&self) -> bool {
1596        let next = self.end_verdict_deferrals.get().saturating_sub(1);
1597        self.end_verdict_deferrals.set(next);
1598        next == 0
1599    }
1600
1601    /// Print the run-ending verdict, honouring `print_level`.
1602    ///
1603    /// The gate matches `emit_end_summary`'s, which is the block this follows:
1604    /// at `print_level 0` there is no summary for it to end.
1605    pub fn print_end_verdict(&self, status: ApplicationReturnStatus) {
1606        let console_output = match self.options.get_integer_value("print_level", "") {
1607            Ok((v, true)) => v >= 1,
1608            _ => true,
1609        };
1610        if console_output {
1611            pounce_solve_report::console::print_exit_verdict(status);
1612        }
1613    }
1614
1615    /// Plug a restoration-phase **factory provider** for drivers that
1616    /// need to run the inner IPM more than once per `optimize_tnlp`
1617    /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
1618    /// pounce#10). On each inner solve, the application consults the
1619    /// provider to mint a fresh [`RestorationFactory`], replacing any
1620    /// stale one, so the default one-shot restoration factory does
1621    /// not panic on its second invocation. If both `set_restoration_factory`
1622    /// and this are configured, the provider wins.
1623    pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
1624        self.restoration_factory_provider = Some(provider);
1625    }
1626
1627    /// Register a callback to run once the IPM has converged (status
1628    /// [`ApplicationReturnStatus::SolveSucceeded`] or
1629    /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
1630    /// `finalize_solution` flows back to the TNLP. See
1631    /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
1632    pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
1633        self.on_converged = Some(cb);
1634    }
1635
1636    /// Remove and return the registered on-converged callback, if any, so a
1637    /// caller that installs its own for a while can put the previous one
1638    /// back (`pounce_minima::IpoptLocalSolve` does, gh#966).
1639    pub fn take_on_converged(&mut self) -> Option<ConvergedCallback> {
1640        self.on_converged.take()
1641    }
1642
1643    /// Whether `data.w` is re-evaluated at the converged point `w*` before
1644    /// the [`Self::set_on_converged`] hook fires. On by default.
1645    ///
1646    /// The main loop checks convergence before it updates the Hessian, so at
1647    /// that moment `data.w` is still `W(w_k)` from the final Newton step,
1648    /// while the Jacobians, slacks, multipliers and `Σ` are read at `w*`. A
1649    /// hook that factors the KKT matrix (post-optimal sensitivity) needs the
1650    /// Hessian at `w*` too, or it factors a hybrid of two iterates.
1651    ///
1652    /// The refresh costs one Hessian evaluation, counted in the solve's
1653    /// statistics like any other. A hook that only reads the iterate and
1654    /// never factors (the CLI collecting duals for a `.sol` file) can turn it
1655    /// off so a plain solve reports the same evaluation counts with or
1656    /// without the hook installed.
1657    pub fn set_on_converged_refreshes_hessian(&mut self, refresh: bool) {
1658        self.on_converged_refreshes_hessian = refresh;
1659    }
1660
1661    /// Was the reported answer replayed from an earlier attempt's floor,
1662    /// after a later attempt lost?
1663    ///
1664    /// **A caller that captures the solution in [`Self::set_on_converged`]
1665    /// must consult this.** That callback fires once per *attempt*, and a
1666    /// losing retry that converged has already run it, so the capture
1667    /// belongs to the discarded point while everything else — status,
1668    /// objective, statistics — has been floored back. When this is `true`,
1669    /// take `x` and the multipliers from the last `finalize_solution`
1670    /// payload instead; that one is always the answer being reported,
1671    /// because the floor restores it *by* calling `finalize_solution`.
1672    ///
1673    /// The payload is in the **model's own units** and in the **reduced**
1674    /// presolve space: `CountingTnlp`-style consumers sit inside the gh#486
1675    /// scaling wrapper (so `x /= d`, `z *= d` have already been applied) and
1676    /// outside the presolve one (so the row/column lift has not). A caller
1677    /// swapping it in must therefore skip its own scaling correction and keep
1678    /// its own lift; getting that backwards squares the factor, which is a
1679    /// silent wrong answer of exactly the shape this flag exists to remove.
1680    ///
1681    /// A [`Self::set_on_converged`] consumer that needs more than the point --
1682    /// the converged KKT state and its factorization, which the payload does
1683    /// not carry and which cannot be rewound -- uses
1684    /// [`Self::reported_on_converged_id`] instead: it keeps each firing under
1685    /// its id and holds the one the solve reported, whose handles keep that
1686    /// attempt's state alive. `pounce-sensitivity`'s `Solver` and `SensSolve`
1687    /// and the CLI's hook do.
1688    ///
1689    /// `false` on every solve that never spent a second attempt, which is
1690    /// almost all of them, so the ordinary path is unaffected.
1691    pub fn answer_restored_from_floor(&self) -> bool {
1692        self.answer_restored_from_floor.get()
1693    }
1694
1695    /// The [`Self::set_on_converged`] firing whose attempt produced the
1696    /// answer the last solve reported, or `None` if that attempt did not fire
1697    /// the hook (it did not converge, or it was not the interior-point arm).
1698    ///
1699    /// The hook fires once per converged attempt, and when a later attempt
1700    /// loses the answer is replayed from an earlier one, so the most recent
1701    /// firing is not necessarily the reported one. A hook that keeps state
1702    /// files it under [`IpoptData::on_converged_id`](crate::ipopt_data::IpoptData)
1703    /// and keeps the firing this names; everything else it captured describes
1704    /// an attempt the solver discarded.
1705    pub fn reported_on_converged_id(&self) -> Option<u64> {
1706        self.last_finalize
1707            .borrow()
1708            .as_ref()
1709            .and_then(|s| s.on_converged_id)
1710    }
1711
1712    /// When the last solve ended in local infeasibility, probe the point it
1713    /// reported for a nearby point with less constraint violation
1714    /// ([`crate::infeasibility_saddle`]). `None` when the last solve reported
1715    /// anything else, or when the probe finds no such point.
1716    ///
1717    /// The point probed is the payload the user's TNLP was handed, so it is
1718    /// the reported point after any retry's floor; it is cleared at the start
1719    /// of every solve, so a stale point cannot be probed. `tnlp` must be the
1720    /// TNLP that solve ran on. Read by the second-opinion ladder.
1721    pub fn local_infeasibility_saddle_escape(
1722        &self,
1723        tnlp: &Rc<RefCell<dyn TNLP>>,
1724    ) -> Option<crate::infeasibility_saddle::SaddleEscape> {
1725        let x = self
1726            .last_finalize
1727            .borrow()
1728            .as_ref()
1729            .filter(|s| s.status == SolverReturn::LocalInfeasibility)
1730            .map(|s| s.x.clone())?;
1731        crate::infeasibility_saddle::infeasibility_saddle_escape(
1732            tnlp,
1733            &x,
1734            self.nlp_lower_bound_inf(),
1735            self.nlp_upper_bound_inf(),
1736        )
1737    }
1738
1739    /// Put a losing retry's floor back: re-deliver the winning attempt's
1740    /// payload to the user's TNLP and make it the recorded answer again.
1741    ///
1742    /// Recording it matters beyond this call. The retries nest -- the gh#884
1743    /// retry wraps the μ fallback and the scale audit -- and each takes its
1744    /// floor from `last_finalize`, so a replay that only re-delivered the
1745    /// payload left the *losing* inner retry's payload there for the outer
1746    /// wrapper to floor to, and to deliver as the answer: measured on
1747    /// `mpcc_scholtes4_biactive` with `mu_strategy_fallback=yes`, a declined
1748    /// μ retry and then a declined gh#884 retry
1749    /// (`pounce-sensitivity/tests/floored_solve_retry_wrappers.rs`).
1750    /// `reported_on_converged_id` reads it too, so without the record even a
1751    /// single declined retry would name its own firing.
1752    fn restore_floor(&self, floor: &FinalizeSnapshot, tnlp: &Rc<RefCell<dyn TNLP>>) {
1753        floor.replay(tnlp);
1754        *self.last_finalize.borrow_mut() = Some(floor.clone());
1755        self.answer_restored_from_floor.set(true);
1756    }
1757
1758    /// Enable per-iteration trajectory capture. After the solve
1759    /// returns, [`Self::statistics()`] exposes
1760    /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
1761    /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
1762    /// per accepted iterate. Off by default — the `pounce_sens` and
1763    /// `pounce` binaries opt in when `--json-output` is passed.
1764    pub fn enable_iter_history(&mut self) {
1765        self.record_iter_history = true;
1766    }
1767
1768    /// Whether [`Self::enable_iter_history`] is in force — read by a stage
1769    /// that produces a verdict outside `optimize_tnlp` (the second-opinion
1770    /// ladder's square-system stage) so its statistics keep the same
1771    /// `iterations` / `iteration_count` pairing.
1772    pub fn iter_history_enabled(&self) -> bool {
1773        self.record_iter_history
1774    }
1775
1776    /// Read the run's options file, resolving *which* file the way
1777    /// upstream's `IpoptApplication::Initialize` does — with one
1778    /// deliberate difference, below.
1779    ///
1780    /// `explicit` is the file the caller named (upstream: the
1781    /// `option_file_name` option, read out of the option store before
1782    /// this point). With `None`, the working directory is probed for
1783    /// [`DEFAULT_OPTION_FILE_NAMES`] and the first hit is read; an
1784    /// absent default file is not an error, it just means "no file".
1785    ///
1786    /// The difference: upstream opens a named file with a bare
1787    /// `std::ifstream` and reads nothing if the open fails, so a typo'd
1788    /// `option_file_name` runs at stock defaults without a word. That
1789    /// silence is what gh#518 was reported for — a benchmark that
1790    /// measured defaults while claiming to measure a configuration — so
1791    /// a named file that cannot be read is an error here.
1792    pub fn initialize_with_option_file(
1793        &mut self,
1794        explicit: Option<&Path>,
1795    ) -> Result<OptionFileLoad, SolverException> {
1796        let mut load = OptionFileLoad::default();
1797        // Set before the early returns below: what this flag records is
1798        // that `option_file_name` was *consulted*, not that a file turned
1799        // up. A caller on this path who names nothing and has no
1800        // `pounce.opt` to find still gets the option honored — there was
1801        // simply nothing to read.
1802        self.option_file_resolved = true;
1803        let path = match explicit {
1804            Some(p) => {
1805                if !p.is_file() {
1806                    return Err(SolverException::new(
1807                        ExceptionKind::IPOPT_APPLICATION_ERROR,
1808                        format!(
1809                            "options file \"{}\" does not exist. It was named by \
1810                             --options-file / option_file_name, so the run would \
1811                             otherwise proceed at stock defaults with none of its \
1812                             settings applied.",
1813                            p.display()
1814                        ),
1815                        file!(),
1816                        line!() as Index,
1817                    ));
1818                }
1819                load.explicit = true;
1820                p.to_path_buf()
1821            }
1822            None => {
1823                let present: Vec<&&str> = DEFAULT_OPTION_FILE_NAMES
1824                    .iter()
1825                    .filter(|n| Path::new(n).is_file())
1826                    .collect();
1827                let Some(first) = present.first() else {
1828                    return Ok(load);
1829                };
1830                // Both default names in one directory: say which one lost,
1831                // rather than let the unread one look applied.
1832                for other in &present[1..] {
1833                    load.warnings.push(format!(
1834                        "`{first}` and `{other}` are both present; reading `{first}` \
1835                         only (pounce's own name wins). Pass \
1836                         `option_file_name={other}` to read that one instead."
1837                    ));
1838                }
1839                PathBuf::from(**first)
1840            }
1841        };
1842        self.initialize_with_options_file(&path)?;
1843        // `option_file_name` set *inside* an options file chains nowhere —
1844        // by the time it is read, the file naming it has already been
1845        // chosen. Upstream documents that ("it does not make any sense to
1846        // specify this option within the options file") and then ignores
1847        // it; name it instead, since an ignored setting that looks live is
1848        // the whole complaint behind gh#518.
1849        if let Ok((named, true)) = self.options.get_string_value("option_file_name", "")
1850            && !named.is_empty()
1851            && Path::new(&named) != path
1852        {
1853            load.warnings.push(format!(
1854                "`{}` sets option_file_name to `{named}`, which has no effect: \
1855                 the options file is chosen before it is read. Pass \
1856                 `option_file_name={named}` on the command line to read that file.",
1857                path.display()
1858            ));
1859        }
1860        load.path = Some(path);
1861        Ok(load)
1862    }
1863
1864    /// Read an `ipopt.opt`-format options file. Equivalent to
1865    /// `IpoptApplication::Initialize(const std::string& options_file)`.
1866    pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
1867        let txt = std::fs::read_to_string(path).map_err(|e| {
1868            SolverException::new(
1869                ExceptionKind::IPOPT_APPLICATION_ERROR,
1870                format!("could not read options file {}: {}", path.display(), e),
1871                file!(),
1872                line!() as Index,
1873            )
1874        })?;
1875        self.options.read_from_str(&txt, true)?;
1876        self.open_output_file_journal();
1877        Ok(())
1878    }
1879
1880    /// Read options from a string in `ipopt.opt` format. Useful for
1881    /// tests and embedded callers.
1882    pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
1883        self.options.read_from_str(s, true)?;
1884        self.open_output_file_journal();
1885        Ok(())
1886    }
1887
1888    /// Honor `output_file` / `file_print_level` / `file_append`: when
1889    /// `output_file` is non-empty, attach a `FileJournal` named
1890    /// `"OutputFile:<fname>"` at the requested level. Mirrors
1891    /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
1892    /// No-op if `output_file` is unset, empty, or could not be opened.
1893    ///
1894    /// NOTE: pounce's iteration output currently bypasses the
1895    /// journalist and writes directly to stdout. The file journal is
1896    /// attached and the timing report (gated by `print_timing_statistics`)
1897    /// is mirrored to it; per-iter rows will start landing in the file
1898    /// once the iter-output path is routed through the journalist.
1899    fn open_output_file_journal(&self) {
1900        let fname = match self.options.get_string_value("output_file", "") {
1901            Ok((v, true)) if !v.is_empty() => v,
1902            _ => return,
1903        };
1904        let level_int = self
1905            .options
1906            .get_integer_value("file_print_level", "")
1907            .ok()
1908            .and_then(|(v, f)| f.then_some(v))
1909            .unwrap_or(5);
1910        let level = journal_level_from_int(level_int);
1911        let append = self
1912            .options
1913            .get_bool_value("file_append", "")
1914            .ok()
1915            .and_then(|(v, f)| f.then_some(v))
1916            .unwrap_or(false);
1917        let jname = format!("OutputFile:{}", fname);
1918        let _ = self
1919            .journalist
1920            .add_file_journal(&jname, &fname, level, append);
1921    }
1922
1923    /// No-op initialize (just succeeds). Mirrors
1924    /// `IpoptApplication::Initialize(bool allow_clobber)` with no
1925    /// options file.
1926    pub fn initialize(&mut self) -> Result<(), SolverException> {
1927        Ok(())
1928    }
1929
1930    /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
1931    /// / `file_print_level` options and attaches a matching
1932    /// `FileJournal` named `OutputFile:<fname>` to the journalist.
1933    /// Returns `false` if the file could not be opened or the option
1934    /// store rejected the request (e.g. clamped print level).
1935    pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
1936        if self
1937            .options
1938            .set_string_value("output_file", fname, true, false)
1939            .is_err()
1940        {
1941            return false;
1942        }
1943        if self
1944            .options
1945            .set_integer_value("file_print_level", print_level as Index, true, false)
1946            .is_err()
1947        {
1948            return false;
1949        }
1950        let level = journal_level_from_int(print_level);
1951        let jname = format!("OutputFile:{}", fname);
1952        // Drop any previous file journal so a second call switches files
1953        // cleanly. `add_file_journal` would otherwise refuse to attach
1954        // a duplicate by name; remove-by-name isn't in the journalist
1955        // API, so we settle for the name-collision case here.
1956        self.journalist
1957            .add_file_journal(&jname, fname, level, false)
1958            .is_some()
1959    }
1960
1961    /// Wrap a TNLP and report problem dimensions. Used in tests until
1962    /// the full IPM path covers every entry shape.
1963    pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
1964        tnlp.get_nlp_info()
1965    }
1966
1967    /// Diagnostics from the safeguarded `least_square_init_primal`
1968    /// initializer step of the last solve (gh#605): the nonlinear
1969    /// violation before and after, the accepted step norm, how many
1970    /// backtracking trials were rejected, and why it stopped. `None`
1971    /// when `least_square_init_primal` was off or the model had no
1972    /// constraints.
1973    pub fn least_square_init_report(&self) -> Option<crate::init::default::LeastSquareInitReport> {
1974        self.least_square_init_report.clone()
1975    }
1976
1977    pub fn statistics(&self) -> SolveStatistics {
1978        self.statistics.borrow().clone()
1979    }
1980
1981    /// What the warm-start initializer made of the iterate the caller
1982    /// supplied to the most recent solve (gh#606): the residuals it
1983    /// measured, whether each multiplier block was accepted,
1984    /// reconstructed or discarded, and the barrier parameter it
1985    /// settled on.
1986    ///
1987    /// `None` when the last solve was a cold start
1988    /// (`warm_start_init_point=no`), or when no solve has run. Reset at
1989    /// the top of every solve, like [`Self::timing_stats`].
1990    pub fn warm_start_diagnostics(&self) -> Option<crate::init::warm_start::WarmStartDiagnostics> {
1991        self.warm_start_diag.borrow().clone()
1992    }
1993
1994    /// Shared timing accumulator from the most recent `optimize_tnlp`
1995    /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
1996    /// fields during the solve; consumers read totals out of the
1997    /// returned `Rc`. The instance is replaced at the top of every
1998    /// subsequent solve, so cloning the `Rc` and holding it past a
1999    /// re-solve will give you the previous solve's timings — by design.
2000    pub fn timing_stats(&self) -> Rc<TimingStatistics> {
2001        Rc::clone(&self.timing.borrow())
2002    }
2003
2004    /// Aggregate linear-solver post-mortem from the most recent
2005    /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
2006    /// backend ran at least one factor; `None` when no factors were
2007    /// recorded (custom factory plugged via
2008    /// [`Self::set_linear_backend_factory`], or solve aborted before
2009    /// the first KKT factor). Reset at the top of every solve.
2010    ///
2011    /// Restoration-phase factorizations are attached as
2012    /// [`LinearSolverSummary::restoration`] when the frontend wired
2013    /// [`Self::restoration_summary_sink`] into its restoration factory.
2014    pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
2015        let mut main = self.linsol_summary_sink.lock().ok()?.clone();
2016        let resto = self
2017            .resto_linsol_summary_sink
2018            .lock()
2019            .ok()
2020            .map(|g| g.clone())
2021            .filter(|r| !r.is_empty());
2022        if main.is_empty() && resto.is_none() {
2023            return None;
2024        }
2025        main.restoration = resto.map(Box::new);
2026        // gh#990 item 7: name what was asked for beside what ran.
2027        main.requested = self
2028            .options
2029            .get_string_value("linear_solver", "")
2030            .ok()
2031            .map(|(v, _)| v);
2032        Some(main)
2033    }
2034
2035    /// Snapshot of both linear-solver summary sinks (main and
2036    /// restoration), for a driver that re-solves and must report the
2037    /// summary of the solve whose verdict it keeps. The second-opinion
2038    /// ladder takes one before its rungs and restores it when no rung is
2039    /// promoted, exactly as it does for the statistics.
2040    pub fn linear_solver_summary_state(&self) -> LinearSolverSummaryState {
2041        let read =
2042            |s: &Arc<Mutex<LinearSolverSummary>>| s.lock().map(|g| g.clone()).unwrap_or_default();
2043        LinearSolverSummaryState {
2044            main: read(&self.linsol_summary_sink),
2045            restoration: read(&self.resto_linsol_summary_sink),
2046        }
2047    }
2048
2049    /// Put back a [`Self::linear_solver_summary_state`] snapshot.
2050    pub fn restore_linear_solver_summary_state(&self, state: LinearSolverSummaryState) {
2051        if let Ok(mut g) = self.linsol_summary_sink.lock() {
2052            *g = state.main;
2053        }
2054        if let Ok(mut g) = self.resto_linsol_summary_sink.lock() {
2055            *g = state.restoration;
2056        }
2057    }
2058
2059    /// The sink a restoration backend factory should record into, e.g.
2060    /// through [`default_backend_factory_with_sink`]. Kept separate from
2061    /// the main solve's sink so the main-solve totals stay about the main
2062    /// solve.
2063    pub fn restoration_summary_sink(&self) -> Arc<Mutex<LinearSolverSummary>> {
2064        Arc::clone(&self.resto_linsol_summary_sink)
2065    }
2066
2067    /// Drive a solve.
2068    ///
2069    /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
2070    ///   build a `TNLPAdapter` → `OrigIpoptNlp`, run the
2071    ///   [`AlgorithmBuilder`] with the workspace MA57 backend, and
2072    ///   call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
2073    ///   `ApplicationReturnStatus` mapping mirrors the table in
2074    ///   `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
2075    ///   SolverReturn map").
2076    /// * Unconstrained problems (`m == 0`) keep going through the
2077    ///   in-`pounce-nlp` Newton driver so the trivial path is
2078    ///   independent of the linear-solver backend.
2079    /// Wrap `tnlp` so per-variable scaling factors are applied as a
2080    /// change of variables, when `nlp_scaling_method=user-scaling` is
2081    /// in effect and the problem supplies non-unit factors (gh#486).
2082    ///
2083    /// Returns the TNLP unchanged under any other scaling method, or
2084    /// when the problem asks for no variable scaling, so an unscaled
2085    /// solve pays nothing. The `Err` carries a message ready to print.
2086    fn install_variable_scaling(
2087        &self,
2088        tnlp: Rc<RefCell<dyn TNLP>>,
2089    ) -> Result<Rc<RefCell<dyn TNLP>>, String> {
2090        // Cleared first so the accessor describes *this* solve. An
2091        // application is reusable across solves (`pounce-cinterface`
2092        // holds one across `IpoptSolve` calls), and a stale vector
2093        // would have a later unscaled solve reporting the previous
2094        // solve's factors.
2095        *self.variable_scaling.borrow_mut() = None;
2096        let method = self
2097            .options
2098            .get_string_value("nlp_scaling_method", "")
2099            .ok()
2100            .and_then(|(v, f)| f.then_some(v))
2101            .unwrap_or_else(|| "gradient-based".to_string());
2102        // `curvature-based` (gh #703) delivers its factors through the
2103        // same `get_scaling_parameters` callback, so the per-variable half
2104        // needs the same substitution wrapper user factors get.
2105        if method != "user-scaling" && method != "curvature-based" {
2106            return Ok(tnlp);
2107        }
2108        match pounce_nlp::scaling_tnlp::wrap_with_scaling(
2109            Rc::clone(&tnlp),
2110            self.nlp_lower_bound_inf(),
2111            self.nlp_upper_bound_inf(),
2112        ) {
2113            Ok(Some(wrapped)) => {
2114                *self.variable_scaling.borrow_mut() =
2115                    pounce_nlp::scaling_tnlp::factors_of(&wrapped);
2116                Ok(wrapped)
2117            }
2118            Ok(None) => Ok(tnlp),
2119            Err(why) => Err(format!(
2120                // The trailing newline belongs to the message: the
2121                // caller emits it with `eprint!` and hands the same
2122                // string to the journalist, as the refusals below do.
2123                "pounce: nlp_scaling_method={method} supplied per-variable \
2124                 scaling factors that cannot be applied. {why}. Correct the \
2125                 factors, or drop nlp_scaling_method={method}.\n"
2126            )),
2127        }
2128    }
2129
2130    /// The per-variable scaling factors applied to the last solve, if
2131    /// any (gh#486). A consumer reading the algorithm's iterate rather
2132    /// than the `finalize_solution` payload sees scaled coordinates and
2133    /// must divide `x` by these, and multiply bound multipliers.
2134    pub fn variable_scaling(&self) -> Option<Vec<Number>> {
2135        self.variable_scaling.borrow().clone()
2136    }
2137
2138    /// Install a starting-point conditioner, if one is asked for.
2139    ///
2140    /// Returns the TNLP unchanged when neither `start_point_perturbation` nor
2141    /// `start_point_conditioner` is set, which is the default — so an ordinary
2142    /// solve pays nothing and its trajectory is untouched.
2143    ///
2144    /// Sits *above* the presolve wrapper and *below* the variable-scaling one,
2145    /// so the point it conditions is the point the algorithm will actually
2146    /// start from, in the coordinates the algorithm will see. Both
2147    /// conditioners override only `get_starting_point`; every other callback
2148    /// forwards, so the solve that follows is the solve pounce would have run
2149    /// had the conditioned point been submitted directly.
2150    ///
2151    /// The two are mutually exclusive by construction rather than by refusal:
2152    /// the displacement is the failure-recovery rung and the Adam warm-up is a
2153    /// user opt-in, and stacking them would put random noise on top of a point
2154    /// the warm-up just spent 200 evaluations choosing. The displacement wins
2155    /// when both are set, because the only thing that sets it is a solve that
2156    /// has already failed.
2157    fn install_start_conditioner(&self, tnlp: Rc<RefCell<dyn TNLP>>) -> Rc<RefCell<dyn TNLP>> {
2158        use pounce_nlp::start_conditioner::{AdamConfig, ConditionedStartTnlp, StartConditioner};
2159        // Each option is read with its literal tag rather than through a
2160        // `|name, fallback|` helper. A helper reads better and costs the
2161        // wiring guard in `tests/init_options_wiring.rs` its evidence: that
2162        // test scans the source for `get_*_value("<tag>"` to prove every
2163        // registered Initialization option is actually consumed, and a tag
2164        // passed as a variable is invisible to it. A registered knob nothing
2165        // reads validates, accepts a value, and lies.
2166        let perturbation = self
2167            .options
2168            .get_numeric_value("start_point_perturbation", "")
2169            .map(|(v, _found)| v)
2170            .unwrap_or(0.0);
2171        let conditioner = if perturbation > 0.0 {
2172            let seed = self
2173                .options
2174                .get_integer_value("start_point_perturbation_seed", "")
2175                .map(|(v, _found)| v)
2176                .unwrap_or(0);
2177            StartConditioner::Jitter {
2178                // `Index` is signed and the option is lower-bounded at 0, so
2179                // this cast cannot lose a set bit for any accepted value.
2180                seed: seed.max(0) as u64,
2181                scale: perturbation,
2182            }
2183        } else {
2184            let which = self
2185                .options
2186                .get_string_value("start_point_conditioner", "")
2187                .map(|(v, _found)| v)
2188                .unwrap_or_else(|_| "none".to_string());
2189            if which != "adam" {
2190                return tnlp;
2191            }
2192            let d = AdamConfig::default();
2193            StartConditioner::Adam(AdamConfig {
2194                iters: self
2195                    .options
2196                    .get_integer_value("adam_warmup_iters", "")
2197                    .map(|(v, _found)| v.max(0) as usize)
2198                    .unwrap_or(d.iters),
2199                lr: self
2200                    .options
2201                    .get_numeric_value("adam_warmup_learning_rate", "")
2202                    .map(|(v, _found)| v)
2203                    .unwrap_or(d.lr),
2204                rho: self
2205                    .options
2206                    .get_numeric_value("adam_warmup_penalty", "")
2207                    .map(|(v, _found)| v)
2208                    .unwrap_or(d.rho),
2209                ..d
2210            })
2211        };
2212        // The sentinels have to come from the options, not from the
2213        // conditioner's own default: a caller who moved `nlp_lower_bound_inf`
2214        // would otherwise have a bound the algorithm treats as absent clipped
2215        // against as if it were real.
2216        //
2217        // Passed through unclamped. These were once `lower.min(-DEFAULT)` /
2218        // `upper.max(DEFAULT)`, which honours only a *loosened* sentinel and
2219        // leaves the failure above intact for a tightened one: at
2220        // `nlp_lower_bound_inf=-1e10` the conditioner still used `-1e19`, so a
2221        // `-1e15` bound was absent to the algorithm and present to the
2222        // clipper — the exact case the comment exists to rule out. The
2223        // sentinel means "absent"; there is only one right answer for what it
2224        // is, and it is the caller's.
2225        let lower = self.nlp_lower_bound_inf();
2226        let upper = self.nlp_upper_bound_inf();
2227        let wrapped = ConditionedStartTnlp::new(tnlp, conditioner).with_bound_inf(lower, upper);
2228        Rc::new(RefCell::new(wrapped))
2229    }
2230
2231    pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2232        self.optimize_tnlp_with_derivative_test_tnlp(tnlp, None)
2233    }
2234
2235    /// Solve through `tnlp`, optionally overriding the derivative-test target.
2236    /// `None` tests the scaled and conditioned TNLP.
2237    pub fn optimize_tnlp_with_derivative_test_tnlp(
2238        &mut self,
2239        tnlp: Rc<RefCell<dyn TNLP>>,
2240        derivative_test_tnlp: Option<Rc<RefCell<dyn TNLP>>>,
2241    ) -> ApplicationReturnStatus {
2242        // The statistics belong to this solve, all of them. A solve that
2243        // returns before any attempt runs (the degrees-of-freedom refusal, an
2244        // option refusal, a presolve proof) used to leave the previous solve's
2245        // record standing, so `statistics()` on an application reused for a
2246        // second solve reported the first one's iterations for a solve that
2247        // never iterated. The complementarity driver is the case that showed
2248        // it: each `G·H = 0` finish refused for too few degrees of freedom
2249        // was recorded with the iteration count of the solve before it, and
2250        // `total_iterations` counted it again. This replaces the field-by-field
2251        // clears (gh#983 review item 1: `warnings`; `sqp_warm_working_set_applied`)
2252        // that patched the same leak one field at a time.
2253        *self.statistics.borrow_mut() = SolveStatistics::new();
2254        // Belongs to the scale audit's re-solve alone, which runs below this
2255        // entry point (`optimize_constrained`, never `optimize_tnlp`), so no
2256        // solve that starts here may inherit it. `RetryOptionScope` clears it
2257        // on every exit already; this is the belt to that, for a solve on an
2258        // application a caught panic left behind.
2259        self.retry_keeps_declared_box.set(false);
2260        let status = self.optimize_tnlp_dispatch(tnlp, derivative_test_tnlp);
2261        // Printed once, for the run whose point is returned. They used to be
2262        // printed by every audited attempt, so a promoted retry's console
2263        // carried the discarded base attempt's `WARNING:` lines too.
2264        let console_output = match self.options.get_integer_value("print_level", "") {
2265            Ok((v, true)) => v >= 1,
2266            _ => true,
2267        };
2268        if console_output {
2269            for w in &self.statistics.borrow().warnings {
2270                println!("WARNING: {w}");
2271            }
2272        }
2273        status
2274    }
2275
2276    /// The body of [`Self::optimize_tnlp_with_derivative_test_tnlp`].
2277    fn optimize_tnlp_dispatch(
2278        &mut self,
2279        tnlp: Rc<RefCell<dyn TNLP>>,
2280        derivative_test_tnlp: Option<Rc<RefCell<dyn TNLP>>>,
2281    ) -> ApplicationReturnStatus {
2282        // gh#884. Both belong to this solve, not to whatever ran before
2283        // it. Reset *here* rather than in `optimize_constrained`, which
2284        // runs once per attempt: the signature accumulates across the
2285        // attempts a lower wrapper may spend, so
2286        // `run_with_dual_divergence_retry` reads "some attempt of the base
2287        // solve saw it" rather than "the last one did".
2288        self.dual_divergence_signature.set(false);
2289        self.tiny_step_dual_stall.set(false);
2290        self.obj_scale_refused.set((false, false));
2291        self.dual_divergence_retry_promoted.set(false);
2292        *self.mu_strategy_fallback_record.borrow_mut() = None;
2293        self.answer_restored_from_floor.set(false);
2294        // The answer this solve reports is whatever it finalizes; a payload
2295        // left from an earlier solve must not stand in for one this solve
2296        // never wrote, since `reported_on_converged_id` reads it.
2297        *self.last_finalize.borrow_mut() = None;
2298        // Same scoping argument as the three above, for the same reason: the
2299        // memo suppresses a REPEATED header within one run, so it must not
2300        // carry across runs. A library caller solving twice on one
2301        // application gets its header both times — which is exactly what the
2302        // guard protects: an outside retry driver re-entering here for another
2303        // attempt is not such a caller, and says so by setting the flag.
2304        if !self.in_retry_sequence.get() {
2305            *self.last_printed_problem_stats.borrow_mut() = None;
2306        }
2307        // gh#486 stage 2: per-variable `scaling_factor` is applied by
2308        // substituting variables one level below the algorithm, since
2309        // the core's scaling models the objective and the constraint
2310        // rows only. The wrapper consumes the variable factors and
2311        // forwards the rest, so `OrigIpoptNlp` sees exactly what it
2312        // has always handled. Installed here because every entry point
2313        // funnels through this method, and only under `user-scaling`,
2314        // the one method that consults the TNLP for factors at all.
2315        let tnlp = match self.install_variable_scaling(tnlp) {
2316            Ok(t) => t,
2317            Err(msg) => {
2318                use pounce_common::journalist::JournalCategory;
2319                eprint!("{msg}");
2320                self.journalist
2321                    .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
2322                return ApplicationReturnStatus::InvalidOption;
2323            }
2324        };
2325
2326        // Starting-point conditioning (`start_point_perturbation`,
2327        // `start_point_conditioner`). A no-op unless one is set, and set by
2328        // nothing automatic except the local-infeasibility ladder's third
2329        // rung, which only runs after a solve has already failed.
2330        let tnlp = self.install_start_conditioner(tnlp);
2331        let derivative_test_tnlp = derivative_test_tnlp.as_ref().unwrap_or(&tnlp);
2332
2333        if let Some(value) = self.unsupported_library_solver_selection() {
2334            use pounce_common::journalist::JournalCategory;
2335            self.journalist.print(
2336                JournalLevel::J_ERROR,
2337                JournalCategory::J_MAIN,
2338                &format!(
2339                    "pounce: solver_selection={value} routing is only available \
2340                     through the pounce CLI (.nl input); library consumers can use \
2341                     qp-active-set, nlp, or auto.\n"
2342                ),
2343            );
2344            return ApplicationReturnStatus::InvalidOption;
2345        }
2346
2347        // A `linear_solver` pounce does not implement is refused rather
2348        // than quietly served by FERAL (gh#483 follow-up). Checked here,
2349        // before any work, so a library consumer gets the same verdict the
2350        // CLI gives before its banner.
2351        if let Some(value) = self.unimplemented_linear_solver() {
2352            use pounce_common::journalist::JournalCategory;
2353            let msg = format!("{}\n", Self::unimplemented_linear_solver_message(&value));
2354            eprint!("{msg}");
2355            self.journalist
2356                .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
2357            return ApplicationReturnStatus::InvalidOption;
2358        }
2359
2360        // gh#483 follow-up: an option naming a feature pounce does not
2361        // implement is refused, not shrugged off. See
2362        // `unimplemented_options` for how membership was established and
2363        // why an explicitly-set *default* is deliberately still allowed.
2364        // gh#518: same treatment for `option_file_name` on an entry point
2365        // that cannot resolve it. Separate from the table above because
2366        // the *feature* now exists — just not here.
2367        // gh#604: same treatment one level down, for a registered *value*
2368        // of an option pounce otherwise reads (`bound_mult_init_method=
2369        // mu-based`).
2370        // gh#604: and for a convex-engine knob on an entry point that
2371        // cannot route to that engine — `option_file_name`'s case, one
2372        // feature over.
2373        if let Some(msg) = self
2374            .unimplemented_option_refusal()
2375            .or_else(|| self.unimplemented_option_value_refusal())
2376            .or_else(|| self.unhonored_option_file_name())
2377            .or_else(|| self.unhonored_convex_option())
2378        {
2379            use pounce_common::journalist::JournalCategory;
2380            eprintln!("{msg}");
2381            self.journalist.print(
2382                JournalLevel::J_ERROR,
2383                JournalCategory::J_MAIN,
2384                &format!("{msg}\n"),
2385            );
2386            return ApplicationReturnStatus::InvalidOption;
2387        }
2388        // A `ma57_pivtolmax` the user set *below* `ma57_pivtol` is a
2389        // contradiction — the escalation ceiling under its floor — and
2390        // upstream refuses it outright
2391        // (`IpMa57TSolverInterface.cpp:313`, `OPTION_INVALID`). pounce
2392        // used to silently rewrite it to `ma57_pivtol`. That was
2393        // unreachable while gh#825 was live, since no `ma57_*` value
2394        // reached the backend at all, and became reachable the moment
2395        // that was fixed — so it is refused here rather than shipped as
2396        // a new way to be quietly ignored.
2397        if let Some(msg) = self.ma57_pivtol_bracket_refusal() {
2398            use pounce_common::journalist::JournalCategory;
2399            eprintln!("{msg}");
2400            self.journalist.print(
2401                JournalLevel::J_ERROR,
2402                JournalCategory::J_MAIN,
2403                &format!("{msg}\n"),
2404            );
2405            return ApplicationReturnStatus::InvalidOption;
2406        }
2407
2408        let backend_warnings = self.take_unimplemented_backend_warnings();
2409        for warning in self
2410            .unexploited_hint_warnings()
2411            .into_iter()
2412            .chain(backend_warnings)
2413        {
2414            eprintln!("{warning}");
2415        }
2416
2417        // Test before presolve, using the requested coordinate space.
2418        self.run_derivative_test(derivative_test_tnlp);
2419
2420        // Top-level algorithm dispatch (Phase 5b §7.1). When the
2421        // `algorithm` option resolves to "active-set-sqp", route
2422        // to the Phase 5b SQP path; otherwise fall through to the
2423        // existing IPM flow unchanged.
2424        // Materialize generic TNLP presolve once at the public entry point.
2425        // The wrapper owns the submitted callback TNLP, so every algorithm
2426        // path below (including retry paths) continues to postsolve into
2427        // the original user-facing space. With `presolve=no`, this returns
2428        // the exact same Rc unchanged.
2429        let tnlp = if self.presolve_already_applied {
2430            tnlp
2431        } else {
2432            match pounce_presolve::wrap_from_options(tnlp, &self.options) {
2433                Ok(tnlp) => tnlp,
2434                Err(err) => {
2435                    use pounce_common::journalist::JournalCategory;
2436                    self.journalist.print(
2437                        JournalLevel::J_ERROR,
2438                        JournalCategory::J_MAIN,
2439                        &format!("pounce: could not materialize presolve options: {err}\n"),
2440                    );
2441                    return ApplicationReturnStatus::InvalidOption;
2442                }
2443            }
2444        };
2445
2446        if self.is_sqp_algorithm_selected() {
2447            let dims = tnlp
2448                .borrow_mut()
2449                .get_nlp_info()
2450                .map(|i| (i.n.max(0) as usize, i.m.max(0) as usize));
2451            let auto = dims.is_some_and(|(n, m)| self.sqp_auto_handover(n, m));
2452            self.auto_handover.set(auto);
2453            if !auto {
2454                return self.optimize_sqp_tnlp(tnlp);
2455            }
2456            use pounce_common::journalist::JournalCategory;
2457            self.journalist.print(
2458                JournalLevel::J_SUMMARY,
2459                JournalCategory::J_MAIN,
2460                &format!(
2461                    "pounce: active-set SQP on a model this size (n + m ≥ {AUTO_HANDOVER_MIN_DIM}) \
2462                     starts from the interior point and hands over \
2463                     (ipm_sqp_handover=no for a cold active-set SQP).\n"
2464                ),
2465            );
2466        } else {
2467            self.auto_handover.set(false);
2468        }
2469        let info = match tnlp.borrow_mut().get_nlp_info() {
2470            Some(info) => info,
2471            None => return ApplicationReturnStatus::InvalidProblemDefinition,
2472        };
2473
2474        // Presolve-certified infeasibility. `get_nlp_info` above is what forces
2475        // the (lazy) presolve init, so this is the first point at which the
2476        // proof exists. If bound propagation or FBBT established that the
2477        // feasible region is empty, there is nothing left to compute: return
2478        // the verdict directly.
2479        //
2480        // Short-circuiting *here*, before dispatch, is deliberate. Running the
2481        // solve anyway would only re-derive a strictly weaker result — a
2482        // stationary point of the constraint violation, which for a nonconvex
2483        // problem proves nothing globally — and would also hand an
2484        // `InfeasibleProblemDetected` to the ℓ₁ auto-fallback below
2485        // (`is_l1_fallback_trigger`), which would then burn a whole second
2486        // solve retrying a problem already proved to have no solution.
2487        //
2488        // Soundness rests on `presolve_infeasibility_proof` returning `Some`
2489        // only for a contradiction derived on an *un-clamped* box — a
2490        // detection made while a Phase-0 auxiliary elimination is in force can
2491        // be an artifact of that elimination and is re-checked after rollback
2492        // before it is certified. See `PresolveState::certified_infeasible`.
2493        if let Some(proof) = tnlp.borrow().presolve_infeasibility_proof() {
2494            use pounce_common::journalist::JournalCategory;
2495            let detail = match proof {
2496                pounce_nlp::tnlp::InfeasibilityProof::BoundPropagation => {
2497                    "bound propagation crossed a variable's bounds".to_string()
2498                }
2499                pounce_nlp::tnlp::InfeasibilityProof::IntervalArithmetic { witness } => {
2500                    format!("interval arithmetic emptied constraint {witness}'s range")
2501                }
2502            };
2503            self.journalist.print(
2504                JournalLevel::J_SUMMARY,
2505                JournalCategory::J_MAIN,
2506                &format!(
2507                    "\nEXIT: Presolve detected the feasible region is empty ({detail}).\n\
2508                     No feasible point exists; the solve was not run.\n"
2509                ),
2510            );
2511            return ApplicationReturnStatus::InfeasibleProblemDetected;
2512        }
2513        // ℓ₁-exact penalty-barrier opt-in (pounce#10).
2514        // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
2515        // Waltz ρ-escalation loop around the constrained IPM, with a
2516        // honest-infeasibility status upgrade when the slacks fail to
2517        // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
2518        // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
2519        // no-op for problems with no equality rows, so the
2520        // unconstrained dispatch below is unaffected when there is
2521        // nothing to wrap.
2522        if info.m > 0 && self.is_l1_penalty_enabled() {
2523            if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
2524                return status;
2525            }
2526            // Falls through: wrapper construction failed (inner refused
2527            // get_nlp_info / get_bounds_info) or no equality rows to
2528            // slack. Standard dispatch runs unmodified.
2529        }
2530        // Phase 3.5 auto-fallback (pounce#10): if the standard solve
2531        // ends in a trigger-class status, retry transparently with
2532        // the wrapper. Promote the retry's status only if it returns
2533        // SolveSucceeded — otherwise return the original. Skipped if
2534        // the user already opted into the wrapper above (this avoids
2535        // a double pass and keeps semantics predictable).
2536        if info.m > 0 && self.is_l1_fallback_enabled() && !self.is_l1_penalty_enabled() {
2537            return self.run_with_l1_fallback(tnlp);
2538        }
2539        // Biactive dual-divergence retry (gh#884): if the solve settles
2540        // its primal while its multipliers run away, throw the iterate
2541        // away and solve again from scratch with the constraint-Jacobian
2542        // perturbation on. Outermost of the two retry wrappers, so its
2543        // "base solve" is the whole standard dispatch below including the
2544        // μ flip — the signature is about a *solve*, and spending the μ
2545        // flip first is strictly cheaper than spending this one first.
2546        let status = if self.is_dual_divergence_retry_enabled() {
2547            self.run_with_dual_divergence_retry(tnlp)
2548        } else {
2549            self.dispatch_standard_solve(tnlp)
2550        };
2551        // gh#983 review item 3: the verdict-level audit runs once, on the
2552        // final verdict, after every retry and fallback has decided.
2553        self.finalize_quality_verdict(status)
2554    }
2555
2556    /// Open a counted re-solve: the base attempt's spend, and its sticky
2557    /// `mu_strategy_fallback` record set aside. That record lives on the
2558    /// application and is copied into the statistics at the end of every
2559    /// run, so without this a re-solve that runs no fallback would be
2560    /// measured with the base's. Nothing reads the record to decide anything.
2561    fn begin_counted_resolve(&self) -> CountedResolve {
2562        let base_spent = self.statistics.borrow().spent_iteration_count();
2563        let base_fallback = self.mu_strategy_fallback_record.borrow_mut().take();
2564        self.statistics.borrow_mut().mu_strategy_fallback = None;
2565        CountedResolve {
2566            base_spent,
2567            base_fallback,
2568        }
2569    }
2570
2571    /// Close a counted re-solve: measure its spend, put the base's fallback
2572    /// record back as the sticky rule leaves it (the re-solve's own record
2573    /// if it ran a fallback, the base's otherwise), and record both attempts'
2574    /// spend in [`SolveStatistics::retry_total_iterations`]. Promoted or
2575    /// declined, that total stands: `restore_into` does not touch it.
2576    fn end_counted_resolve(&self, mark: CountedResolve) {
2577        let retry_spent = self.statistics.borrow().spent_iteration_count();
2578        if self.mu_strategy_fallback_record.borrow().is_none() {
2579            *self.mu_strategy_fallback_record.borrow_mut() = mark.base_fallback.clone();
2580            self.statistics.borrow_mut().mu_strategy_fallback = mark.base_fallback;
2581        }
2582        self.statistics.borrow_mut().retry_total_iterations = Some(mark.base_spent + retry_spent);
2583    }
2584
2585    /// The standard solve dispatch: the μ-strategy fallback if it is
2586    /// enabled, otherwise one `optimize_constrained` call.
2587    ///
2588    /// Factored out of `optimize_tnlp_with_derivative_test_tnlp` so
2589    /// [`Self::run_with_dual_divergence_retry`] can wrap the whole of it
2590    /// rather than only the bare IPM call (gh#884).
2591    fn dispatch_standard_solve(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2592        // This attempt's re-solve total starts empty: one left by an earlier
2593        // attempt (the gh#884 retry's base, a second-opinion rung that
2594        // re-enters below `optimize_tnlp`'s reset) is not this attempt's.
2595        self.statistics.borrow_mut().retry_total_iterations = None;
2596        let status = self.dispatch_standard_solve_inner(Rc::clone(&tnlp));
2597        self.audit_solve_quality(tnlp, status)
2598    }
2599
2600    /// [`Self::dispatch_standard_solve`] without the gh#983 quality audit.
2601    fn dispatch_standard_solve_inner(
2602        &mut self,
2603        tnlp: Rc<RefCell<dyn TNLP>>,
2604    ) -> ApplicationReturnStatus {
2605        // μ-strategy auto-fallback (pounce#138): if the standard solve
2606        // stalls, retry once with the opposite mu_strategy and promote
2607        // only on Solve_Succeeded. Which stalls qualify depends on
2608        // whether the caller asked for the retry — see
2609        // `run_with_mu_strategy_fallback` (pounce#748).
2610        // Applies to constrained and unconstrained alike (both run the
2611        // same IPM). Independent of, and lower priority than, the ℓ₁
2612        // fallback above.
2613        if self.is_mu_strategy_fallback_enabled() {
2614            return self.run_with_mu_strategy_fallback(tnlp);
2615        }
2616        // Every problem — constrained or not — goes through the same
2617        // primal-dual IPM, exactly as upstream Ipopt does. There is no
2618        // separate "unconstrained Newton" path: the linear-solver
2619        // backend (FERAL/MA57) handles the augmented system, so the
2620        // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
2621        // blowup.
2622        self.optimize_constrained(tnlp)
2623    }
2624
2625    /// Read the ℓ₁ wrapper master switch from the OptionsList.
2626    /// Default `false` when the option is not set.
2627    fn is_l1_penalty_enabled(&self) -> bool {
2628        self.options
2629            .get_bool_value("l1_exact_penalty_barrier", "")
2630            .ok()
2631            .and_then(|(v, found)| found.then_some(v))
2632            .unwrap_or(false)
2633    }
2634
2635    fn l1_penalty_init(&self) -> Number {
2636        self.options
2637            .get_numeric_value("l1_penalty_init", "")
2638            .ok()
2639            .and_then(|(v, found)| found.then_some(v))
2640            .unwrap_or(1.0)
2641    }
2642    fn l1_penalty_max(&self) -> Number {
2643        self.options
2644            .get_numeric_value("l1_penalty_max", "")
2645            .ok()
2646            .and_then(|(v, found)| found.then_some(v))
2647            .unwrap_or(1.0e6)
2648    }
2649    fn l1_penalty_increase_factor(&self) -> Number {
2650        self.options
2651            .get_numeric_value("l1_penalty_increase_factor", "")
2652            .ok()
2653            .and_then(|(v, found)| found.then_some(v))
2654            .unwrap_or(8.0)
2655    }
2656    fn l1_penalty_max_outer_iter(&self) -> usize {
2657        self.options
2658            .get_integer_value("l1_penalty_max_outer_iter", "")
2659            .ok()
2660            .and_then(|(v, found)| found.then_some(v))
2661            .unwrap_or(8) as usize
2662    }
2663    fn l1_slack_tol(&self) -> Number {
2664        self.options
2665            .get_numeric_value("l1_slack_tol", "")
2666            .ok()
2667            .and_then(|(v, found)| found.then_some(v))
2668            .unwrap_or(1.0e-6)
2669    }
2670    fn l1_steering_factor(&self) -> Number {
2671        self.options
2672            .get_numeric_value("l1_steering_factor", "")
2673            .ok()
2674            .and_then(|(v, found)| found.then_some(v))
2675            .unwrap_or(10.0)
2676    }
2677    fn is_l1_fallback_enabled(&self) -> bool {
2678        self.options
2679            .get_bool_value("l1_fallback_on_restoration_failure", "")
2680            .ok()
2681            .and_then(|(v, found)| found.then_some(v))
2682            .unwrap_or(false)
2683    }
2684
2685    /// Did the caller set `mu_strategy` explicitly?
2686    ///
2687    /// The answer decides whether the limited-memory default applies
2688    /// (see `algorithm_builder_from_options`): upstream only substitutes
2689    /// `adaptive` for the registered `monotone` when the option is
2690    /// absent from the list.
2691    fn mu_strategy_was_set(&self) -> bool {
2692        matches!(
2693            self.options.get_string_value("mu_strategy", ""),
2694            Ok((_, true))
2695        )
2696    }
2697
2698    /// Did the caller set `mu_strategy_fallback` themselves? Separates
2699    /// the opted-in retry from the default-on one, which triggers on a
2700    /// narrower set of statuses (pounce#748).
2701    ///
2702    /// True for an explicit `no` as well as an explicit `yes`, which is
2703    /// harmless: this is only ever read downstream of
2704    /// [`Self::is_mu_strategy_fallback_enabled`], and an explicit `no`
2705    /// stops there.
2706    fn mu_strategy_fallback_was_set(&self) -> bool {
2707        matches!(
2708            self.options.get_bool_value("mu_strategy_fallback", ""),
2709            Ok((_, true))
2710        )
2711    }
2712
2713    /// Options whose presence means a `Solved_To_Acceptable_Level`
2714    /// exit may be something the *caller* asked for rather than a stall
2715    /// POUNCE fell into (gh #757).
2716    ///
2717    /// Every one of them either moves the bar a certificate has to clear
2718    /// (`tol` and the component tolerances, the `acceptable_*` family),
2719    /// arms a guard that refuses a certificate the iterate would
2720    /// otherwise have earned (`kkt_fidelity_tol`, the certificate-mask
2721    /// and noise-floor kappas, the divergence / infeasibility streaks,
2722    /// the restoration-decline pair).
2723    ///
2724    /// Options that only move the *starting point* are deliberately not
2725    /// here. `least_square_init_primal` was tried and removed: a caller
2726    /// who picks an initialization heuristic has said nothing about what
2727    /// convergence means, and listing it made an explicit `=no` behave
2728    /// differently from omitting the option, which is a distinction the
2729    /// rest of the solver does not draw.
2730    const TERMINATION_POLICY_OPTIONS: &'static [&'static str] = &[
2731        "tol",
2732        "dual_inf_tol",
2733        "constr_viol_tol",
2734        "compl_inf_tol",
2735        "acceptable_tol",
2736        "acceptable_iter",
2737        "acceptable_dual_inf_tol",
2738        "acceptable_constr_viol_tol",
2739        "acceptable_compl_inf_tol",
2740        "acceptable_obj_change_tol",
2741        "kkt_fidelity_tol",
2742        "obj_scale_certificate_threshold",
2743        "dual_inf_scale_kappa",
2744        "primal_noise_floor_kappa",
2745        "dual_diverging_streak",
2746        "infeas_max_streak",
2747        "resto_decline_deferrals",
2748        "resto_decline_progress_ratio",
2749        "neg_curv_escapes",
2750        "limited_memory_ls_failure_restarts",
2751    ];
2752
2753    /// Did the caller set any option from
2754    /// [`Self::TERMINATION_POLICY_OPTIONS`]?
2755    ///
2756    /// This is what separates the two readings of a
2757    /// `Solved_To_Acceptable_Level` exit. Under stock convergence
2758    /// settings it means POUNCE's own schedule parked the dual term
2759    /// above `tol` and a flipped schedule is worth one try. Under a
2760    /// caller-modified one it may be the signal the caller armed the
2761    /// option to receive, and erasing it with a retry is exactly the
2762    /// laundering pounce#748 refused to do by default.
2763    fn caller_set_termination_policy(&self) -> bool {
2764        Self::TERMINATION_POLICY_OPTIONS.iter().any(|name| {
2765            matches!(self.options.get_numeric_value(name, ""), Ok((_, true)))
2766                || matches!(self.options.get_integer_value(name, ""), Ok((_, true)))
2767                || matches!(self.options.get_bool_value(name, ""), Ok((_, true)))
2768                || matches!(self.options.get_string_value(name, ""), Ok((_, true)))
2769        })
2770    }
2771
2772    /// The μ strategy this option table actually resolves to, as
2773    /// `algorithm_builder_from_options` will build it: the explicit
2774    /// value when there is one, otherwise `adaptive` for a
2775    /// limited-memory Hessian and `monotone` for anything else.
2776    ///
2777    /// The fallback below flips *this*, not the registered default —
2778    /// flipping the registered default under limited-memory would
2779    /// "retry" with the strategy that just failed.
2780    fn effective_mu_strategy_is_adaptive(&self) -> bool {
2781        mu_strategy_resolves_to_adaptive(&self.options)
2782    }
2783
2784    /// Read the μ-strategy auto-fallback switch (pounce#138).
2785    ///
2786    /// An explicit setting always wins. Absent one the default is **on**
2787    /// (pounce#748) — but only while the user has not chosen a
2788    /// `mu_strategy` themselves. Retrying under the other schedule is a
2789    /// recovery for a solve that stalled on a strategy POUNCE picked; it
2790    /// is not licence to override a strategy the caller named. Without
2791    /// that condition, flipping the default would silently contaminate
2792    /// every controlled comparison that pins `mu_strategy` on purpose,
2793    /// this repository's own benchmark arms included. The motivating
2794    /// case is unaffected: `dirichlet120` stalls under the
2795    /// limited-memory substitution (pounce#746), which by definition
2796    /// only happens when `mu_strategy` is unset.
2797    ///
2798    /// Public so a driver that budgets a solve on the assumption that a
2799    /// stalled one is re-solved (the complementarity driver's warm rungs)
2800    /// can check that the retry will actually run.
2801    pub fn is_mu_strategy_fallback_enabled(&self) -> bool {
2802        match self.options.get_bool_value("mu_strategy_fallback", "") {
2803            Ok((v, true)) => v,
2804            _ => !self.mu_strategy_was_set() || self.mu_strategy_pounce_chosen,
2805        }
2806    }
2807
2808    /// Record that the `mu_strategy` now in the option table was chosen by a
2809    /// POUNCE frontend, not by the caller (gh#990).
2810    ///
2811    /// The Python path for a problem without `hessian` writes
2812    /// `mu_strategy=monotone` so that gh#746's limited-memory -> adaptive
2813    /// substitution does not read a barrier preference out of a Hessian
2814    /// choice POUNCE made itself. Written as an ordinary option, that pin
2815    /// also read as "the caller named a strategy" to
2816    /// [`Self::is_mu_strategy_fallback_enabled`], which switched off the
2817    /// default-on stall retry (gh#748) on exactly that path — the same
2818    /// set-ness defect the second-opinion ladder's write-backs once had.
2819    /// With this mark the pin still decides the schedule, and the fallback
2820    /// stays on. A frontend calls it only when the caller's own options carry
2821    /// no `mu_strategy`.
2822    pub fn mark_mu_strategy_pounce_chosen(&mut self) {
2823        self.mu_strategy_pounce_chosen = true;
2824    }
2825
2826    /// Has the user set `algorithm = active-set-sqp`? Reads the
2827    /// string option and matches case-insensitively against the
2828    /// design-note §7.1 spelling. Any value other than
2829    /// "active-set-sqp" (including absence) routes to the
2830    /// default IPM path.
2831    /// Stash a warm-start iterate for the SQP path. Consumed by
2832    /// the next `optimize_tnlp` call when the `algorithm` option
2833    /// resolves to `active-set-sqp`; the IPM path ignores it.
2834    /// Phase 5c (§6) — the parametric / MPC warm-start hand-off.
2835    ///
2836    /// The iterate is auto-cleared after use, so a follow-up
2837    /// solve without an intervening `set_sqp_warm_start` call
2838    /// cold-starts.
2839    ///
2840    /// `x`, `lambda_g` and `lambda_x` are in the **caller's** space — what
2841    /// `finalize_solution` reports: full-`n` `x` in model units, `lambda_g`
2842    /// in `g` order on the unscaled Lagrangian `f + λᵀg`, and
2843    /// `lambda_x = z_L − z_U`. The solve maps them into the algorithm's
2844    /// space (fixed variables dropped, `[y_c; y_d]` row order, objective,
2845    /// constraint and per-variable scaling) itself, so a converged solve's
2846    /// reported multipliers can be fed back unchanged. The working set is in
2847    /// the same space: bound statuses full-`n` in variable order (fixed
2848    /// variables' entries are ignored), constraint statuses one per `g` row
2849    /// in the user's row order — exactly what [`Self::last_sqp_working_set`]
2850    /// publishes, so a published set can be fed back as-is.
2851    pub fn set_sqp_warm_start(&mut self, warm: crate::sqp::SqpIterates) {
2852        self.sqp_warm_start = Some(warm);
2853    }
2854
2855    /// Drop any pending warm-start iterate without solving.
2856    pub fn clear_sqp_warm_start(&mut self) {
2857        self.sqp_warm_start = None;
2858    }
2859
2860    /// What the crossover phase did on the most recent solve (gh#612).
2861    ///
2862    /// `None` means crossover never ran — either `crossover=no` (the
2863    /// default) or the solve did not converge. A `Some` whose
2864    /// [`CrossoverReport::accepted`] is false means it ran and *declined*;
2865    /// the two are different facts about a solve and consumers that reason
2866    /// about active-set certainty (sensitivity's AMBIGUOUS class, a
2867    /// downstream `var_status`) need to tell them apart.
2868    ///
2869    /// [`CrossoverReport::accepted`]: crate::crossover::CrossoverReport::accepted
2870    pub fn crossover_report(&self) -> Option<&crate::crossover::CrossoverReport> {
2871        self.crossover_report.as_ref()
2872    }
2873
2874    /// What the interior-point → active-set handover did on the most recent
2875    /// solve (gh#1054); `None` unless `ipm_sqp_handover=yes`.
2876    pub fn handover_report(&self) -> Option<&crate::handover::HandoverReport> {
2877        self.handover_report.as_ref()
2878    }
2879
2880    /// Install a full primal-dual warm-start iterate for the next IPM
2881    /// `optimize_tnlp`. Captured by the debugger's `resolve` so the
2882    /// re-solve continues from the paused interior point. The caller is
2883    /// responsible for also enabling `warm_start_init_point=yes` (and
2884    /// usually `warm_start_target_mu=<μ>`) so the re-optimize branch of
2885    /// `WarmStartIterateInitializer` preserves the installed iterate.
2886    /// Consumed once per solve, then auto-cleared.
2887    pub fn set_warm_start_iterate(&mut self, snap: crate::debug::IterateSnapshot) {
2888        self.warm_start_iterate = Some(snap);
2889    }
2890
2891    /// Install a caller-supplied fill-reducing permutation for the KKT
2892    /// linear solver (pounce#180 item 1). The next `optimize_*` builds
2893    /// the FERAL backend with [`pounce_feral::OrderingMethod::External`],
2894    /// overriding the `feral_ordering` string option / env var. Use this
2895    /// to inject a block-triangular / Schur ordering a generic algorithm
2896    /// cannot see (Parker, Garcia & Bent, arXiv:2602.17968) or a tearing
2897    /// ordering from equation-oriented decomposition.
2898    ///
2899    /// `perm` is a **0-based, new-to-old permutation** (`perm[k]` is the
2900    /// original index that becomes index `k`), and its length must equal
2901    /// the augmented KKT system dimension (variables + slacks +
2902    /// constraint duals), *not* the problem's `n`. A wrong length or a
2903    /// non-bijection is rejected by FERAL at the first factorization with
2904    /// an `InvalidInput` error (never a panic), surfacing as a solver
2905    /// failure rather than a silently-wrong solve — the ordering only
2906    /// affects fill/time, never the computed solution.
2907    ///
2908    /// Persistent config: unlike the warm-start hooks it is *not*
2909    /// auto-cleared after a solve. Call [`Self::clear_external_ordering`]
2910    /// to drop it. Ignored by non-FERAL backends and by any custom
2911    /// factory plugged via [`Self::set_linear_backend_factory`].
2912    pub fn set_external_ordering(&mut self, perm: Vec<usize>) {
2913        self.external_ordering = Some(perm);
2914    }
2915
2916    /// Drop any installed external KKT ordering, restoring the
2917    /// `feral_ordering`-driven default for subsequent solves.
2918    pub fn clear_external_ordering(&mut self) {
2919        self.external_ordering = None;
2920    }
2921
2922    /// The currently-installed external KKT ordering, if any.
2923    pub fn external_ordering(&self) -> Option<&[usize]> {
2924        self.external_ordering.as_deref()
2925    }
2926
2927    /// Install a block-triangular / Schur KKT partition (pounce#180 item 2).
2928    /// `indices` are KKT-space indices (`0..dim` in the `x, s, c, d` block
2929    /// order the aug-system solver assembles) naming the Schur block `S`; that
2930    /// block is Schur-complemented out and only the two diagonal blocks are
2931    /// factorized (inertia via Sylvester's law). Honored on the IPM + feral +
2932    /// exact-Hessian path; the Schur solver falls back to the standard
2933    /// full-space solver transparently when the partition is unsuitable (too
2934    /// large a fraction of the system, malformed, or a backend error), so a
2935    /// stray hook never breaks a solve. Persistent config (not auto-cleared);
2936    /// drop it via [`Self::clear_kkt_schur_block`].
2937    /// Declare the block structure in the **model's own terms**: one block id
2938    /// per variable and per constraint, negative for the shared ones
2939    /// (structured-KKT Phase 5b). Blocks must couple only through the shared
2940    /// entries; the solver checks that against the assembled KKT and falls
2941    /// back when it does not hold.
2942    ///
2943    /// This is the form a model can produce — "`pg[g]` is shared, everything
2944    /// in contingency `k` is block `k`" — and pounce maps it to KKT indices
2945    /// itself, accounting for fixed variables and the equality / inequality
2946    /// split. Lengths are the problem's own `n` and `m`; a mismatch is
2947    /// reported and the solve proceeds monolithically.
2948    ///
2949    /// Persistent config; drop it with [`Self::clear_block_structure`].
2950    pub fn set_block_structure(&mut self, var_blocks: Vec<i32>, con_blocks: Vec<i32>) {
2951        self.model_blocks = Some((var_blocks, con_blocks));
2952    }
2953
2954    /// Drop any declared model-space block structure.
2955    pub fn clear_block_structure(&mut self) {
2956        self.model_blocks = None;
2957    }
2958
2959    /// The declared model-space block structure, if any.
2960    pub fn block_structure(&self) -> Option<(&[i32], &[i32])> {
2961        self.model_blocks
2962            .as_ref()
2963            .map(|(v, c)| (v.as_slice(), c.as_slice()))
2964    }
2965
2966    /// Install a block-parallel KKT partition (structured-KKT Phase 5b).
2967    /// `labels` are KKT-space block ids in the solver's internal
2968    /// `x, slack, eq-dual, ineq-dual` order, `< 0` for the shared border; an
2969    /// **empty** vector asks the solver to detect the structure from the
2970    /// assembled KKT. Honored on the IPM + feral + exact-Hessian path, with a
2971    /// transparent fallback to the standard solver when the partition does not
2972    /// match the matrix. Persistent config; drop it with
2973    /// [`Self::clear_kkt_block_structure`].
2974    pub fn set_kkt_block_structure(&mut self, labels: Vec<i32>) {
2975        self.kkt_blocks = Some(labels);
2976    }
2977
2978    /// Drop any installed block partition.
2979    pub fn clear_kkt_block_structure(&mut self) {
2980        self.kkt_blocks = None;
2981    }
2982
2983    /// The currently-installed block partition, if any.
2984    pub fn kkt_block_structure(&self) -> Option<&[i32]> {
2985        self.kkt_blocks.as_deref()
2986    }
2987
2988    pub fn set_kkt_schur_block(&mut self, indices: Vec<usize>) {
2989        self.kkt_schur_block = Some(indices);
2990    }
2991
2992    /// Drop any installed Schur KKT partition, restoring the standard
2993    /// full-space solver for subsequent solves.
2994    pub fn clear_kkt_schur_block(&mut self) {
2995        self.kkt_schur_block = None;
2996    }
2997
2998    /// The currently-installed Schur KKT partition, if any.
2999    pub fn kkt_schur_block(&self) -> Option<&[usize]> {
3000        self.kkt_schur_block.as_deref()
3001    }
3002
3003    /// Return the final QP working set from the most recent SQP
3004    /// solve, or `None` if the last solve wasn't SQP, didn't
3005    /// produce a working set (cold-start declared the iterate
3006    /// optimal before solving any QP), or no SQP solve has run.
3007    ///
3008    /// The set is in the **caller's** index spaces, the ones
3009    /// `finalize_solution` uses: `bounds[j]` is variable `j` of the full
3010    /// `n` (a variable `fixed_variable_treatment = make_parameter` removed
3011    /// reads `Fixed`), and `constraints[i]` is `g` row `i` in the model's
3012    /// own row order — not the SQP's internal equalities-first order. It is
3013    /// the form [`Self::set_sqp_warm_start`] takes back.
3014    pub fn last_sqp_working_set(&self) -> Option<&pounce_qp::WorkingSet> {
3015        self.sqp_last_working_set.as_ref()
3016    }
3017
3018    /// A working set computed in the solve's algorithm space, mapped to the
3019    /// caller's: bound statuses lifted to the full variable vector (gh#986
3020    /// review item 10 — fixed variables are `Fixed`), constraint statuses
3021    /// moved from the algorithm's `[c; d]` row order (equalities first) to
3022    /// the user's `g` row order through the NLP's `c_map` / `d_map`. A
3023    /// bound vector that is not reduced-sized is left as it is.
3024    fn working_set_to_user(
3025        &self,
3026        nlp: &dyn IpoptNlp,
3027        ws: pounce_qp::WorkingSet,
3028    ) -> pounce_qp::WorkingSet {
3029        let bounds = match &self.x_elimination {
3030            Some((map, n_full)) if ws.bounds.len() == map.len() && map.len() != *n_full => {
3031                let mut bounds = vec![pounce_qp::BoundStatus::Fixed; *n_full];
3032                for (k, &j) in map.iter().enumerate() {
3033                    bounds[j as usize] = ws.bounds[k];
3034                }
3035                bounds
3036            }
3037            _ => ws.bounds,
3038        };
3039        pounce_qp::WorkingSet {
3040            bounds,
3041            constraints: working_set_rows_to_user(nlp, ws.constraints),
3042        }
3043    }
3044
3045    /// If `solver_selection` is explicitly set to a value whose routing lives
3046    /// only in the CLI's `.nl` dispatch, return  it; otherwise `None`.
3047    /// `optimize_tnlp` uses this to reject a forced convex selection a library
3048    /// consumer cannot honor.
3049    fn unsupported_library_solver_selection(&self) -> Option<&'static str> {
3050        let (v, found) = self.options.get_string_value("solver_selection", "").ok()?;
3051        if !found {
3052            return None;
3053        }
3054        ["lp-ipm", "qp-ipm", "socp"]
3055            .into_iter()
3056            .find(|c| v.eq_ignore_ascii_case(c))
3057    }
3058
3059    /// The `linear_solver` value when the caller explicitly asked for a
3060    /// backend pounce does not implement; `None` when the request can be
3061    /// served (or was never made).
3062    ///
3063    /// pounce ships two: **FERAL** (pure Rust, the effective default) and
3064    /// **MA57** (HSL, behind the `ma57` feature). The option's valid-value
3065    /// list is a faithful port of upstream Ipopt's — `ma27`, `ma77`,
3066    /// `ma86`, `ma97`, `mumps`, `pardiso`, `pardisomkl`, `spral`, `wsmp`,
3067    /// `custom` — so an `ipopt.opt` written for Ipopt parses here, and
3068    /// every one of those names used to fall through a `_ =>` arm to
3069    /// FERAL. A run "using MUMPS" was a FERAL run; a benchmark comparing
3070    /// backends compared FERAL with itself (gh#483 follow-up).
3071    ///
3072    /// The registered default is `feral`, which pounce implements, so no
3073    /// explicit-vs-default distinction is needed: whatever the option
3074    /// resolves to must be a backend that exists. (It is checked
3075    /// unconditionally on purpose — a future default naming something
3076    /// unimplemented should trip this, not slip past it.)
3077    ///
3078    /// Explicit `ma57` on a build that lacks the feature is *not* refused;
3079    /// that fallback is reported in the banner ("ma57 requested but not
3080    /// compiled"), so it is visible rather than silent, and failing a
3081    /// portable `ipopt.opt` over a build flag would cost more than it buys.
3082    pub fn unimplemented_linear_solver(&self) -> Option<String> {
3083        let (v, _) = self.options.get_string_value("linear_solver", "").ok()?;
3084        ["feral", "ma57"]
3085            .iter()
3086            .all(|ok| !v.eq_ignore_ascii_case(ok))
3087            .then_some(v)
3088    }
3089
3090    /// The message for the first option the caller set that names a
3091    /// feature pounce does not implement, or `None`. Public so the CLI
3092    /// can refuse before routing — the convex dispatch never reaches
3093    /// `optimize_tnlp`. See [`crate::unimplemented_options`].
3094    ///
3095    /// A run configuring nothing but backends pounce does not ship is
3096    /// refused here too, after the per-option table has had its say —
3097    /// see [`crate::unimplemented_options::backend_only_refusal`]. It
3098    /// is folded in rather than given its own accessor so that every
3099    /// surface already refusing on this method refuses on it as well;
3100    /// the CLI is not the only frontend, and a condition worth failing
3101    /// on is not worth failing on only from the CLI.
3102    pub fn unimplemented_option_refusal(&self) -> Option<String> {
3103        crate::unimplemented_options::refusal(&self.options, &self.reg_options).or_else(|| {
3104            crate::unimplemented_options::backend_only_refusal(&self.options, &self.reg_options)
3105        })
3106    }
3107
3108    /// The message for the first string option the caller set to a
3109    /// registered *value* pounce does not implement, or `None`.
3110    ///
3111    /// Separate from [`Self::unimplemented_option_refusal`] because the
3112    /// option itself is read and its other values work — it is one mode
3113    /// that is missing, not the feature. See
3114    /// [`crate::unimplemented_options::UNIMPLEMENTED_VALUES`].
3115    pub fn unimplemented_option_value_refusal(&self) -> Option<String> {
3116        crate::unimplemented_options::value_refusal(&self.options)
3117    }
3118
3119    /// `option_file_name` set on a surface that never resolves it.
3120    ///
3121    /// The option reaches a file through exactly one path —
3122    /// [`Self::initialize_with_option_file`], which the `pounce` CLI
3123    /// drives. A library caller (Python, the C interface, WASM) sets its
3124    /// options directly and calls no such thing, so on those surfaces the
3125    /// option names a whole configuration and applies none of it: gh#518's
3126    /// failure mode, one surface over. It used to be caught by the blanket
3127    /// [`crate::unimplemented_options`] refusal, which no longer covers it
3128    /// now that the feature exists; this keeps the guard exactly where the
3129    /// feature still doesn't.
3130    ///
3131    /// Deliberately *not* fixed by having the library read an options file
3132    /// too: an implicit `./ipopt.opt` lookup under Python or the GAMS C
3133    /// link would be a surprising action at a distance, and `pounce.opt`
3134    /// already means something else to GAMS.
3135    pub fn unhonored_option_file_name(&self) -> Option<String> {
3136        if self.option_file_resolved {
3137            return None;
3138        }
3139        // Same default gate as the table: an explicitly-set *default*
3140        // asks for nothing, so it must not fail. `option_file_name`
3141        // defaults to `ipopt.opt`, and a caller round-tripping a full
3142        // option dump — or a generated config that spells out every
3143        // registered name — hits that value without asking for anything.
3144        if !crate::unimplemented_options::set_to_a_non_default(
3145            &self.options,
3146            &self.reg_options,
3147            "option_file_name",
3148        ) {
3149            return None;
3150        }
3151        match self.options.get_string_value("option_file_name", "") {
3152            Ok((name, true)) if !name.is_empty() => Some(format!(
3153                "pounce: `option_file_name` was set to `{name}`, but this entry \
3154                 point does not read options files — it would configure nothing. \
3155                 The `pounce` CLI honors it (and `./pounce.opt` / `./ipopt.opt`); \
3156                 from a library, read the file yourself and pass its contents to \
3157                 `initialize_with_options_str`, or set the options directly. \
3158                 Tracking issue: https://github.com/jkitchin/pounce/issues/518"
3159            )),
3160            _ => None,
3161        }
3162    }
3163
3164    /// Declare that this caller can route a model to the convex LP/QP /
3165    /// SOCP engines, so the `qp_*` knobs they read configure something.
3166    ///
3167    /// The `pounce` CLI calls this: it owns the `.nl` structure
3168    /// extraction that classifies a model, which is the whole of what
3169    /// `solver_selection`'s convex values need. No library frontend can
3170    /// (see [`Self::unsupported_library_solver_selection`]), so the
3171    /// default is `false` and [`Self::unhonored_convex_option`] refuses
3172    /// the knobs there.
3173    ///
3174    /// Declaring it also covers the CLI's *fallback*: a convex attempt
3175    /// that returns no verified point hands the model to
3176    /// [`Self::optimize_tnlp`], and the `qp_*` values it was given
3177    /// configured that attempt for real. Refusing them at the handoff
3178    /// would fail a run that used them.
3179    pub fn set_convex_routing_available(&mut self, available: bool) {
3180        self.convex_routing_available = available;
3181    }
3182
3183    /// The convex LP/QP knobs are registered core-side so every frontend
3184    /// parses them — but only the CLI can reach the engines that read
3185    /// them. On any other entry point the option names a whole
3186    /// configuration and applies none of it; this is the message that
3187    /// says so, in place of the silence.
3188    ///
3189    /// gh#604. Same shape and same default gate as
3190    /// [`Self::unhonored_option_file_name`]: an explicitly-set *default*
3191    /// asks for nothing and must keep working, so only a value that
3192    /// differs is refused.
3193    pub fn unhonored_convex_option(&self) -> Option<String> {
3194        if self.convex_routing_available {
3195            return None;
3196        }
3197        const CONVEX_ONLY: &[&str] = &[
3198            "qp_presolve",
3199            "qp_tau",
3200            "qp_tau_max",
3201            "qp_reg",
3202            "qp_gondzio_corr",
3203            "qp_infeas_tol",
3204            "qp_hsde",
3205            "qp_equilibrate",
3206            "qp_crossover",
3207        ];
3208        let name = CONVEX_ONLY.iter().find(|name| {
3209            crate::unimplemented_options::set_to_a_non_default(
3210                &self.options,
3211                &self.reg_options,
3212                name,
3213            )
3214        })?;
3215        Some(format!(
3216            "pounce: `{name}` tunes the convex LP/QP interior-point engine, but \
3217             this entry point cannot route a model to it — the option would \
3218             configure nothing. The `pounce` CLI reaches that engine on `.nl` \
3219             input (`solver_selection=lp-ipm` / `qp-ipm` / `socp`, or `auto` on \
3220             a model that classifies as one); the `qp_*` options themselves are \
3221             read only there and by `pounce.solve_qp`. From Python, \
3222             `pounce.solve_qp` drives the engine directly and takes `qp_presolve`, \
3223             `qp_reg`, `qp_hsde`, `qp_equilibrate` and `qp_crossover` as keyword \
3224             arguments (presolve is off there unless asked for). \
3225             On this path, `solver_selection=qp-active-set` \
3226             (or `algorithm=active-set-sqp`) is the nearest thing, tuned by the \
3227             `sqp_qp_*` options. Tracking issue: \
3228             https://github.com/jkitchin/pounce/issues/604"
3229        ))
3230    }
3231
3232    /// Warnings for caching hints pounce does not exploit. These never
3233    /// block a solve: the answer is identical either way, so refusing
3234    /// would cost the caller more than the silence did.
3235    pub fn unexploited_hint_warnings(&self) -> Vec<String> {
3236        crate::unimplemented_options::hint_warnings(&self.options, &self.reg_options)
3237    }
3238
3239    /// Warnings for the constant-derivative hints when the solve routes to
3240    /// `pounce-convex` instead of here. Call site is the convex dispatch
3241    /// in the CLI, next to the other guards that live there for the same
3242    /// reason: that dispatch never reaches [`Self::optimize_tnlp`], so
3243    /// `install_constant_derivative_hints` never runs and the hints are
3244    /// unread. On the NLP route they are honoured, so this must not be
3245    /// called there.
3246    pub fn convex_unexploited_hint_warnings(&self) -> Vec<String> {
3247        crate::unimplemented_options::convex_hint_warnings(&self.options, &self.reg_options)
3248    }
3249
3250    /// Which of the four constant-derivative hints the caller actually
3251    /// asserted, in [`pounce_nlp::constant_derivatives::HINT_OPTIONS`]
3252    /// order — the order [`reconcile`] pairs against the model's own
3253    /// proofs.
3254    ///
3255    /// Each name is read as a literal rather than through the loop
3256    /// variable the caller used to use. The registered-but-unread scan
3257    /// (`tests/no_silent_options.rs`) keys on the option name as it
3258    /// appears at the accessor, so `get_bool_value(name, "")` over an
3259    /// array read as "no key here" and left all four sitting in the
3260    /// silent list while they were fully wired and consumed (#551 /
3261    /// #677).
3262    ///
3263    /// Matching over `HINT_OPTIONS` rather than writing a bare array
3264    /// keeps the slots right by construction, and a fifth hint added to
3265    /// `HINT_OPTIONS` trips the fallback arm instead of silently reading
3266    /// as "not asserted" — which is the failure mode this whole line of
3267    /// work exists to kill.
3268    fn asserted_constant_derivative_hints(&self) -> [bool; 4] {
3269        use pounce_nlp::constant_derivatives::HINT_OPTIONS;
3270        let read_yes = |key: &str| matches!(self.options.get_bool_value(key, ""), Ok((true, true)));
3271        HINT_OPTIONS.map(|name| match name {
3272            "grad_f_constant" => read_yes("grad_f_constant"),
3273            "hessian_constant" => read_yes("hessian_constant"),
3274            "jac_c_constant" => read_yes("jac_c_constant"),
3275            "jac_d_constant" => read_yes("jac_d_constant"),
3276            other => unreachable!("`{other}` is in HINT_OPTIONS with no read site"),
3277        })
3278    }
3279
3280    /// Resolve the four constant-derivative hints for this solve and
3281    /// install the result on the NLP (gh #588, phase Q6).
3282    ///
3283    /// `grad_f_constant` / `hessian_constant` / `jac_c_constant` /
3284    /// `jac_d_constant` are, upstream, unchecked user assertions: Ipopt
3285    /// reuses the derivative and returns a wrong answer if the assertion
3286    /// was false. pounce asks the model first. Where the model *proves*
3287    /// the derivative constant, the reuse happens whether or not the
3288    /// option was set — the hint is redundant. Where the model proves it
3289    /// **varies** and the option was set anyway, the option is refused
3290    /// with a warning, which is the deliberate divergence. Where the
3291    /// model can prove nothing — every callback front end, both GAMS
3292    /// links — the user's assertion is honoured on trust, exactly as
3293    /// upstream, because "unproved" is not "disproved" and silently
3294    /// overriding the caller there would be its own wrong answer.
3295    fn install_constant_derivative_hints(&self, orig_nlp: &mut OrigIpoptNlp) {
3296        use pounce_common::journalist::JournalCategory;
3297        use pounce_nlp::constant_derivatives::reconcile;
3298
3299        // Each name is read as a literal rather than through the loop
3300        // variable: the registered-but-unread scan
3301        // (`tests/no_silent_options.rs`) keys on the option name as it
3302        // appears at the accessor, so `get_bool_value(name, "")` read as
3303        // "no key here" and left all four of these sitting in the silent
3304        // list while they were fully wired and consumed (#551 / #677).
3305        //
3306        // Matching over `HINT_OPTIONS` rather than writing a bare array
3307        // keeps the order right by construction — `reconcile` pairs
3308        // `asserted[k]` with `proofs[k]` — and a fifth hint added to
3309        // `HINT_OPTIONS` trips the fallback arm instead of silently
3310        // reading as "not asserted", which is the failure mode this
3311        // whole line of work exists to kill.
3312        let asserted = self.asserted_constant_derivative_hints();
3313        let proofs = orig_nlp.derivative_proofs();
3314        let (outcomes, enabled) = reconcile(proofs, asserted);
3315
3316        for outcome in &outcomes {
3317            if let Some(warning) = outcome.warning() {
3318                eprintln!("{warning}");
3319                self.journalist.print(
3320                    JournalLevel::J_STRONGWARNING,
3321                    JournalCategory::J_MAIN,
3322                    &format!("{warning}\n"),
3323                );
3324            }
3325        }
3326        if std::env::var("POUNCE_DBG_CONSTDERIV").is_ok() {
3327            for outcome in &outcomes {
3328                eprintln!(
3329                    "[const deriv] {:<15} proof={:?} asserted={} reused={}",
3330                    outcome.name, outcome.proof, outcome.asserted, outcome.honoured,
3331                );
3332            }
3333        }
3334        orig_nlp.set_constant_derivatives(enabled);
3335    }
3336
3337    /// Warnings for knobs of a linear-solver backend pounce does not
3338    /// ship (`ma97_*`, `pardiso_*`, …), one line per backend family.
3339    ///
3340    /// Warnings and not refusals: an `ipopt.opt` carrying settings for
3341    /// several backends so that one file runs everywhere is exactly what
3342    /// the registry exists to accept, and refusing it would fail a run
3343    /// over knobs it never touches. See the "Backend knobs warn, they do
3344    /// not refuse" section of [`crate::unimplemented_options`]. gh#551.
3345    pub fn unimplemented_backend_warnings(&self) -> Vec<String> {
3346        crate::unimplemented_options::backend_warnings(&self.options, &self.reg_options)
3347    }
3348
3349    /// The same warnings, but at most once per application: the second
3350    /// caller gets nothing.
3351    ///
3352    /// Two sites emit them — the CLI, before routing, because a convex
3353    /// model never reaches [`Self::optimize_tnlp`], and `optimize_tnlp`
3354    /// itself, for every frontend that is not the CLI. A CLI run passes
3355    /// through both, and printing the identical paragraph twice is how a
3356    /// warning teaches its reader to skip warnings.
3357    pub fn take_unimplemented_backend_warnings(&mut self) -> Vec<String> {
3358        if self.backend_warnings_emitted {
3359            return Vec::new();
3360        }
3361        self.backend_warnings_emitted = true;
3362        self.unimplemented_backend_warnings()
3363    }
3364
3365    /// Resolve the five registered `derivative_test*` knobs. Every one
3366    /// of them was registered and never read, so `derivative_test=
3367    /// first-order` ran no test and printed nothing — a checker that
3368    /// silently checks nothing reports success by omission (gh#483
3369    /// follow-up).
3370    ///
3371    /// The numeric helper is named `read_num` to match the accessor
3372    /// idiom the rest of this file uses: the registered-but-unread scan
3373    /// (`tests/no_silent_options.rs`) discovers `read_*` helpers and the
3374    /// literal key passed to them, so a differently-named local closure
3375    /// made `derivative_test_perturbation` and `derivative_test_tol`
3376    /// read as silent when they are wired and consumed (#677, #551).
3377    fn derivative_test_options(&self) -> DerivativeTestOptions {
3378        let read_num = |key: &str, default: Number| -> Number {
3379            self.options
3380                .get_numeric_value(key, "")
3381                .ok()
3382                .and_then(|(v, f)| f.then_some(v))
3383                .unwrap_or(default)
3384        };
3385        DerivativeTestOptions {
3386            mode: self
3387                .options
3388                .get_string_value("derivative_test", "")
3389                .ok()
3390                .and_then(|(v, f)| f.then_some(v))
3391                .map(|v| DerivativeTest::from_option(&v))
3392                .unwrap_or_default(),
3393            perturbation: read_num("derivative_test_perturbation", 1e-8),
3394            tol: read_num("derivative_test_tol", 1e-4),
3395            first_index: self
3396                .options
3397                .get_integer_value("derivative_test_first_index", "")
3398                .ok()
3399                .and_then(|(v, f)| f.then_some(v))
3400                .unwrap_or(-2),
3401            print_all: self
3402                .options
3403                .get_bool_value("derivative_test_print_all", "")
3404                .ok()
3405                .and_then(|(v, f)| f.then_some(v))
3406                .unwrap_or(false),
3407        }
3408    }
3409
3410    /// The derivative checker's structured verdict from the most recent
3411    /// [`Self::run_derivative_test`], or `None` when no test ran (gh#990).
3412    /// The IPM and SQP drains copy it into `SolveStatistics`; a caller that
3413    /// builds its own report (the CLI's convex route) reads it here.
3414    pub fn derivative_check(&self) -> Option<pounce_nlp::derivative_test::DerivativeCheckSummary> {
3415        self.derivative_check.borrow().clone()
3416    }
3417
3418    /// Run the derivative checker, if requested, against `tnlp`.
3419    ///
3420    /// Advisory, like upstream: a suspicious entry is reported and the
3421    /// solve continues. The report goes to stderr so it survives
3422    /// `print_level=0` and leaves `--json-output`'s stdout clean.
3423    pub fn run_derivative_test(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
3424        // gh#990 item 3: the structured result belongs to this solve; a solve
3425        // that asks for no test must not inherit the previous one's.
3426        *self.derivative_check.borrow_mut() = None;
3427        let opts = self.derivative_test_options();
3428        if matches!(opts.mode, DerivativeTest::None) {
3429            return;
3430        }
3431        let report = {
3432            let mut borrowed = tnlp.borrow_mut();
3433            pounce_nlp::derivative_test::run(&mut *borrowed, &opts)
3434        };
3435        let Some(report) = report else {
3436            eprintln!(
3437                "pounce: derivative_test was requested but the TNLP declined to \
3438                 supply the information the check needs (dimensions, bounds, or \
3439                 a starting point); no test was run."
3440            );
3441            return;
3442        };
3443        *self.derivative_check.borrow_mut() = Some(report.summary.clone());
3444        use pounce_common::journalist::JournalCategory;
3445        for line in &report.lines {
3446            eprintln!("{line}");
3447            self.journalist.print(
3448                JournalLevel::J_SUMMARY,
3449                JournalCategory::J_MAIN,
3450                &format!("{line}\n"),
3451            );
3452        }
3453    }
3454
3455    /// The message [`Self::unimplemented_linear_solver`] earns, shared by
3456    /// every frontend so they cannot drift apart.
3457    pub fn unimplemented_linear_solver_message(value: &str) -> String {
3458        format!(
3459            "pounce: linear_solver={value} is not implemented. pounce provides \
3460             `feral` (pure-Rust sparse symmetric, the default) and `ma57` (HSL, \
3461             in a `--features ma57` build); the other names in the option's \
3462             list come from the upstream Ipopt registry so an ipopt.opt written \
3463             for Ipopt still parses. Selecting one used to run FERAL silently, \
3464             which makes a backend comparison measure nothing — so it is \
3465             refused instead. Use linear_solver=feral or linear_solver=ma57."
3466        )
3467    }
3468
3469    /// Will the next `optimize_tnlp` run the active-set SQP driver rather
3470    /// than the interior-point one?
3471    ///
3472    /// Public because the dispatch decision has to be *reportable*, not just
3473    /// taken: before this was reachable, the CLI's `Selected solver:` line
3474    /// and the JSON report's `solution.engine` both named the filter-IPM on
3475    /// every `algorithm=active-set-sqp` run, because the only thing either
3476    /// consulted was `solver_selection` — which is `auto` on a general NLP
3477    /// however the `algorithm` option is set. Reading the same predicate
3478    /// `optimize_tnlp` dispatches on is what keeps the report and the run
3479    /// from disagreeing; deriving it a second time in the CLI is how they
3480    /// drifted in the first place.
3481    pub fn is_sqp_algorithm_selected(&self) -> bool {
3482        // `algorithm` is the primary selector.
3483        // `solver_selection = qp-active-set` selects the
3484        // same active-set SQP engine.
3485        let algo_sqp = matches!(
3486            self.options.get_string_value("algorithm", ""),
3487            Ok((v, true)) if v.eq_ignore_ascii_case("active-set-sqp")
3488        );
3489        let selection_sqp = matches!(
3490            self.options.get_string_value("solver_selection", ""),
3491            Ok((v, true)) if v.eq_ignore_ascii_case("qp-active-set")
3492        );
3493        algo_sqp || selection_sqp
3494    }
3495
3496    /// Whether an active-set SQP request on a model with `n` variables and
3497    /// `m` constraints runs as the interior point with the IPM→SQP handover
3498    /// and post-convergence crossover, instead of a cold active-set SQP
3499    /// (gh#1061).
3500    ///
3501    /// A primal active-set QP started cold changes its working set one row or
3502    /// bound per iteration, so the SQP's first step QP needs on the order of
3503    /// one iteration per active constraint, each a KKT update the size of the
3504    /// model. Measured on four 9–20 k-variable equality-heavy NLPs
3505    /// (`brainpc2`, `robot_1600`, `WM_CFy`, `ubh5`): not one first QP finished
3506    /// in 300 s. Started from the interior point instead, `ubh5` solves in
3507    /// 0.5 s and `robot_1600` in 6 s. The active-set SQP produces the answer
3508    /// when a handover attempt is accepted; when every attempt declines, the
3509    /// converged interior point is the answer, exactly as under
3510    /// `ipm_sqp_handover=yes`. Post-convergence `crossover` is not turned on
3511    /// as well: measured, it bought `robot_1600` its active-set answer for
3512    /// 6 s → 41 s, and on `brainpc2` declined after a 194 s step-3 QP, past
3513    /// the wall-time limit.
3514    ///
3515    /// Only above [`AUTO_HANDOVER_MIN_DIM`], where a cold SQP is the problem,
3516    /// and only when `ipm_sqp_handover` was not set: `ipm_sqp_handover=no`
3517    /// asks for the cold active-set SQP and gets it.
3518    pub fn sqp_auto_handover(&self, n: usize, m: usize) -> bool {
3519        self.is_sqp_algorithm_selected()
3520            && n + m >= AUTO_HANDOVER_MIN_DIM
3521            && !matches!(
3522                self.options.get_bool_value("ipm_sqp_handover", ""),
3523                Ok((_, true))
3524            )
3525    }
3526
3527    /// Whether the most recent solve was run as interior point plus handover
3528    /// under [`Self::sqp_auto_handover`].
3529    pub fn auto_handover_ran(&self) -> bool {
3530        self.auto_handover.get()
3531    }
3532
3533    /// Whether the active-set SQP produced the most recent solve's point:
3534    /// always on the cold SQP route, and on the auto-handover route when a
3535    /// handover attempt (or a crossover the user asked for) was accepted.
3536    pub fn answer_from_active_set_sqp(&self) -> bool {
3537        if !self.auto_handover.get() {
3538            return self.is_sqp_algorithm_selected();
3539        }
3540        self.handover_report
3541            .as_ref()
3542            .is_some_and(|r| r.accepted_at_iter.is_some())
3543            || self.crossover_report.as_ref().is_some_and(|r| r.accepted())
3544    }
3545
3546    /// Phase 5b SQP entry point. Builds the same NLP chain
3547    /// (`TNLPAdapter` → `OrigIpoptNlp` → `IpoptNlpAdapter`) the
3548    /// IPM uses, then runs `SqpAlgorithm::optimize`. Maps the
3549    /// `SqpResult.status` back to `ApplicationReturnStatus` and
3550    /// hands the final iterate to the user TNLP's
3551    /// `finalize_solution` callback via `finalize_via_sqp`.
3552    fn optimize_sqp_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
3553        use pounce_nlp::ConstObjScaling;
3554        use pounce_nlp::orig_ipopt_nlp::OrigIpoptNlp;
3555        use pounce_nlp::tnlp_adapter::TNLPAdapter;
3556
3557        // Wall-clock for the whole SQP solve, mirroring the IPM path's
3558        // `t_start` (see the `total_wallclock_time_secs` assignment in
3559        // `optimize_tnlp`). Without this the field stayed at its struct
3560        // default of 0.0 on every active-set solve, so `--json-output`
3561        // reported an instantaneous solve regardless of actual runtime and
3562        // the engine could not be speed-compared against qp-ipm at all
3563        // (benchmarks/scripts/compare_qp_four_way.py had to skip the column).
3564        let t_start = std::time::Instant::now();
3565
3566        // `new_with_options`, not `new`: the latter hard-codes the same three
3567        // defaults, so `fixed_variable_treatment`, `nlp_lower_bound_inf` and
3568        // `nlp_upper_bound_inf` were accepted and discarded on this arm. The
3569        // defaults coincide, which is why it went unnoticed — it diverges only
3570        // for a caller who sets one, and then the two arms CLASSIFY THE MODEL
3571        // DIFFERENTLY: a bound at the caller's own infinity threshold is a
3572        // bound here and no bound there, and a fixed variable is eliminated on
3573        // one arm and relaxed on the other. That is a different problem, not a
3574        // different trajectory on one.
3575        let (lo_inf, up_inf, fixed_treatment) = self.adapter_options();
3576        let adapter = match TNLPAdapter::new_with_options(
3577            Rc::clone(&tnlp),
3578            lo_inf,
3579            up_inf,
3580            fixed_treatment,
3581        ) {
3582            Ok(a) => Rc::new(RefCell::new(a)),
3583            Err(_) => return ApplicationReturnStatus::InvalidProblemDefinition,
3584        };
3585        // The SQP path never runs gradient-based scaling, but the
3586        // constant `obj_scaling_factor` (negative ⇒ maximize) still
3587        // applies via the OrigIpoptNlp constructor.
3588        let obj_scaling_factor = self
3589            .options
3590            .get_numeric_value("obj_scaling_factor", "")
3591            .ok()
3592            .and_then(|(v, f)| f.then_some(v))
3593            .unwrap_or(1.0);
3594        let mut orig_nlp = match OrigIpoptNlp::new(
3595            Rc::clone(&adapter),
3596            Rc::new(ConstObjScaling(obj_scaling_factor)),
3597        ) {
3598            Ok(n) => n,
3599            Err(_) => return ApplicationReturnStatus::InternalError,
3600        };
3601        // Same Q6 reconciliation as the IPM route: the SQP driver
3602        // evaluates the same derivatives through the same NLP object.
3603        self.install_constant_derivative_hints(&mut orig_nlp);
3604        // `bound_relax_factor`, on the convex arm's rule (gh#745's
3605        // `convex_bound_relax`), because this arm's position is the convex
3606        // arm's and not the interior-point arm's.
3607        //
3608        // The widening is a change to the MODEL, and its error is one-signed:
3609        // enlarging the feasible set can only flatter the objective, by `δ`
3610        // times the bound's multiplier, with nothing bounding that product and
3611        // no amount of tightening `tol` closing it. On `LISWET1` it buys `9.0`
3612        // of objective (`27.1221` against the true `36.1224`); over the
3613        // 91-instance netlib LP corpus, dropping it took the median objective
3614        // error from `1.2e-08` to `3.8e-11`.
3615        //
3616        // The interior-point arm keeps it and must: it is a feasible-iterate
3617        // log-barrier that needs `x` strictly inside its bounds, and matching
3618        // Ipopt is that arm's contract — the fixture sweep at
3619        // `bound_relax_factor=0` turns `square_flowsheet_resto` into
3620        // `InfeasibleProblemDetected`. An active-set QP needs none of it: its
3621        // iterates are feasible for the box by construction, which is why this
3622        // arm has always run un-widened without incident.
3623        //
3624        // So: unset means solve the model as declared, which is what this arm
3625        // already did. What it did NOT do is honour an explicit request — the
3626        // option was accepted and discarded, the gh#677 shape, and a caller
3627        // comparing arms under a named `bound_relax_factor` was comparing two
3628        // different models without being told. Set means the caller asked by
3629        // name and gets exactly the interior-point arm's model.
3630        //
3631        // Either branch snapshots the declared bounds first (`relax_bounds`
3632        // does it before widening), which `declared_box_violation` needs: its
3633        // absence is what printed `Variable bound violation: nan` here.
3634        let requested_relax = self
3635            .options
3636            .get_numeric_value("bound_relax_factor", "")
3637            .ok()
3638            .and_then(|(v, set)| set.then_some(v));
3639        match requested_relax {
3640            Some(factor) => {
3641                let cap = self
3642                    .options
3643                    .get_numeric_value("constr_viol_tol", "")
3644                    .ok()
3645                    .and_then(|(v, set)| set.then_some(v))
3646                    .unwrap_or(1e-4);
3647                orig_nlp.relax_bounds(factor, cap);
3648            }
3649            None => orig_nlp.snapshot_declared_bounds(),
3650        }
3651        let nlp_rc: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
3652
3653        let mut sqp_adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(&nlp_rc));
3654
3655        let mut builder = self.algorithm_builder_snapshot();
3656        builder.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
3657        // `max_wall_time` / `max_cpu_time` on this arm (gh#1054). They were
3658        // registered, documented as solve-wide, and read only by the
3659        // interior-point convergence check, so an active-set solve stuck in
3660        // its first QP ran until something outside POUNCE killed it.
3661        builder.sqp.deadline = Some(self.solve_deadline());
3662        // Prefer a caller-installed factory, exactly as `optimize_constrained`
3663        // does, so an embedder that plugs its own backend gets it on both
3664        // arms. `take()` matches the IPM's one-shot semantics rather than
3665        // introducing a second, different lifetime for the same hook.
3666        //
3667        // `maybe_crossover` deliberately does NOT do this: it runs after the
3668        // IPM solve has already taken the factory, so there is never one left
3669        // to find, and its two closures cannot both hold `&mut self` anyway.
3670        let factory = self
3671            .linear_backend_factory
3672            .take()
3673            .unwrap_or_else(|| self.make_backend_factory());
3674        let mut alg = match builder.build_sqp_with_backend(factory) {
3675            Some(a) => a,
3676            None => return ApplicationReturnStatus::InternalError,
3677        };
3678
3679        // Problem statistics + end-of-run summary are emitted by the engine
3680        // itself here (#206), gated on the main `print_level`, so the SQP
3681        // route matches the IPM route across every frontend (CLI, Python, C).
3682        // The SQP's own per-iteration rows stay gated on the separate
3683        // `sqp_print_level`.
3684        let console_output = match self.options.get_integer_value("print_level", "") {
3685            Ok((v, true)) => v >= 1,
3686            _ => true,
3687        };
3688        self.emit_problem_stats(&tnlp, console_output);
3689
3690        // Phase 5c (§6): consume any stashed warm-start iterate.
3691        // `optimize_with_warm_start(warm=None)` is equivalent to
3692        // `optimize`, so cold callers see no change.
3693        let mut warm = self.sqp_warm_start.take();
3694        // gh#986 review item 10: map a warm start given in the caller's full
3695        // variable space through this problem's fixed-variable elimination.
3696        // The working set this application publishes is in that space
3697        // (`working_set_to_user`), and so is what Python's
3698        // `solve(..., working_set=...)` decodes, so a child that fixes `x1`
3699        // gets the parent's statuses for exactly the columns it still has.
3700        // The first pass compared lengths only: a fixed variable made the
3701        // full-space hint "the wrong size" and it was dropped (a cold solve
3702        // and `info["working_set"] = None`), while a reduced-space hint from a
3703        // sibling that fixed a *different* variable had the right size and was
3704        // applied to the wrong columns.
3705        let (x_map, n_full) = {
3706            let a = adapter.borrow();
3707            let cls = a.classification();
3708            (cls.x_not_fixed_map.clone(), cls.n_full_x as usize)
3709        };
3710        let reduced = x_map.len() != n_full;
3711        let prev_elimination = self.x_elimination.replace((x_map.clone(), n_full));
3712        if let Some(w) = warm.as_mut() {
3713            // The staged iterate is in the caller's (user) space; the
3714            // algorithm's is not. Converted here, once, for every frontend.
3715            sqp_warm_start_to_algorithm(&*nlp_rc.borrow(), w);
3716            if reduced {
3717                let mut drop_reason: Option<String> = None;
3718                if let Some(ws) = w.working.as_mut() {
3719                    if ws.bounds.len() == n_full {
3720                        ws.bounds = x_map.iter().map(|&j| ws.bounds[j as usize]).collect();
3721                    } else if ws.bounds.len() == x_map.len()
3722                        && prev_elimination.as_ref() != Some(&(x_map.clone(), n_full))
3723                    {
3724                        // A reduced-space working set from a solve that
3725                        // eliminated *different* variables: its columns do
3726                        // not line up with this problem's, however equal the
3727                        // count.
3728                        drop_reason = Some(
3729                            "its bound statuses are in the reduced space of a solve that \
3730                             fixed different variables"
3731                                .into(),
3732                        );
3733                    }
3734                }
3735                if let Some(why) = drop_reason {
3736                    tracing::warn!(
3737                        target: "pounce::sqp",
3738                        "SQP warm-start working set dropped: {why}; pass the full-space \
3739                         working set (last_sqp_working_set / info[\"working_set\"]) instead"
3740                    );
3741                    w.working = None;
3742                }
3743            }
3744        }
3745        // `warm_start_init_point=yes` with nothing staged: take the TNLP's
3746        // own starting multipliers, as the interior-point arm does. They
3747        // arrive through the same `IpoptNlp` projections the IPM's warm
3748        // initializer uses, so they are already in algorithm space (scaled,
3749        // compressed, `[y_c; y_d]`). Without this the SQP arm read only the
3750        // primal: a `.nl` `d` segment or `ipopt_z*_in` suffix, or a Python
3751        // `lagrange=` start, began from zero multipliers on this arm alone.
3752        if warm.is_none()
3753            && matches!(
3754                self.options.get_bool_value("warm_start_init_point", ""),
3755                Ok((true, _))
3756            )
3757        {
3758            warm = sqp_tnlp_warm_start(&nlp_rc);
3759        }
3760        // gh#986: a warm start recorded on one problem is dimensioned for
3761        // that problem. A child that fixes a variable (branching) reaches
3762        // here with a smaller reduced `n` (and possibly `m`), where the
3763        // algorithm's contract is to refuse a mismatched iterate outright —
3764        // which surfaced as `Internal_Error` on a perfectly legal solve.
3765        // A warm start is only a hint, so drop it with a warning and solve
3766        // cold instead.
3767        if let Some(w) = warm.as_ref() {
3768            use crate::sqp::SqpProblemSpec;
3769            let (n, m) = (sqp_adapter.n(), sqp_adapter.m());
3770            let fits = w.x.len() == n
3771                && w.lambda_x.len() == n
3772                && w.lambda_g.len() == m
3773                && w.working
3774                    .as_ref()
3775                    .map(|ws| ws.validate_dims(n, m).is_ok())
3776                    .unwrap_or(true);
3777            if !fits {
3778                tracing::warn!(
3779                    target: "pounce::sqp",
3780                    "SQP warm start dropped: it is sized for a problem with n = {}, m = {} \
3781                     but this one has n = {n}, m = {m} (a fixed variable or removed row \
3782                     changes the reduced dimensions); solving cold",
3783                    w.x.len(),
3784                    w.lambda_g.len(),
3785                );
3786                warm = None;
3787            }
3788        }
3789        let warm_working_set_applied = warm.as_ref().is_some_and(|w| w.working.is_some());
3790        // gh#1015: the user's `intermediate_callback`, once per iteration,
3791        // with `GetIpoptCurrentIterate` answering from the SQP iterate. The
3792        // snapshot is built before the callback runs because the callback
3793        // holds the TNLP; nothing in it may call back into the user's
3794        // `eval_*`.
3795        {
3796            let hook_nlp = Rc::clone(&nlp_rc);
3797            let hook_tnlp = Rc::clone(&tnlp);
3798            let fixed_var_duals = self.reports_fixed_var_duals();
3799            alg.set_iteration_hook(Box::new(move |info| {
3800                // Built only for a TNLP that reads it, as on the
3801                // interior-point arm: the fixed-variable recovery is not
3802                // free, and most callbacks read only the stats.
3803                let _guard = hook_tnlp.borrow().wants_current_iterate().then(|| {
3804                    crate::intermediate::UserIterateGuard::install(sqp_user_iterate(
3805                        &hook_nlp,
3806                        info.x,
3807                        info.lambda_g,
3808                        info.lambda_x,
3809                        info.c_vals,
3810                        info.grad_lag_x,
3811                        fixed_var_duals,
3812                    ))
3813                });
3814                let stats = pounce_nlp::tnlp::IterStats {
3815                    mode: pounce_nlp::return_codes::AlgorithmMode::RegularMode,
3816                    iter: info.iter as Index,
3817                    obj_value: info.obj,
3818                    inf_pr: info.inf_pr,
3819                    inf_du: info.inf_du,
3820                    // No barrier, no regularisation, no dual step length.
3821                    mu: 0.0,
3822                    d_norm: info.p_inf,
3823                    regularization_size: 0.0,
3824                    alpha_du: 0.0,
3825                    alpha_pr: info.alpha,
3826                    ls_trials: 0,
3827                };
3828                hook_tnlp.borrow_mut().intermediate_callback(
3829                    stats,
3830                    &pounce_nlp::tnlp::IpoptData::default(),
3831                    &pounce_nlp::tnlp::IpoptCq::default(),
3832                )
3833            }));
3834        }
3835        let res = match alg.optimize_with_warm_start(&mut sqp_adapter, warm) {
3836            Ok(r) => r,
3837            Err(e) => {
3838                // Always surface this. It used to be gated on the
3839                // undocumented `POUNCE_DBG_SQP`, so the only thing a user saw
3840                // was a bare `Internal_Error` with no indication of what went
3841                // wrong -- the underlying message here was
3842                // `QpFailure(LinearSolverFailure("QP subproblem returned
3843                // status unbounded"))`, which points straight at the cause.
3844                // A solve that is about to fail is exactly when the reason
3845                // should be cheapest to obtain.
3846                tracing::warn!(
3847                    target: "pounce::sqp",
3848                    "SQP solve failed: {e:?}"
3849                );
3850                return ApplicationReturnStatus::InternalError;
3851            }
3852        };
3853        // Stash the result's working set so the next solve in a
3854        // sequence can fetch it via `last_sqp_working_set` -- in the caller's
3855        // full variable space (gh#986 review item 10).
3856        self.sqp_last_working_set = res
3857            .working_set
3858            .clone()
3859            .map(|ws| self.working_set_to_user(&*nlp_rc.borrow(), ws));
3860        // Populate the shared `SolveStatistics` so the Python /
3861        // C-API post-solve accessors (`GetIpoptIterCount`,
3862        // `info["iter_count"]`, etc.) report the SQP outer-iter
3863        // count rather than zero. Constraint-violation /
3864        // dual-infeasibility residuals get the SQP-side values
3865        // too. The IPM path overwrites this dict on its own
3866        // solves, so SQP-vs-IPM mixing across solves stays
3867        // honest.
3868        {
3869            let mut stats = self.statistics.borrow_mut();
3870            stats.iteration_count = res.n_iter as Index;
3871            // Subproblem counters. The outer iteration count alone
3872            // cannot show what a working-set warm start bought — the
3873            // saved work is inside the QPs — so both are reported.
3874            stats.sqp_qp_solves = res.n_qp_solves as Index;
3875            stats.sqp_qp_working_set_changes = res.n_qp_working_set_changes as Index;
3876            stats.sqp_warm_working_set_applied = warm_working_set_applied;
3877            stats.final_objective = res.obj;
3878            // `final_scaled_objective` defaults to NaN; the SQP path does not
3879            // thread nlp_scaling through the objective (same as the residuals
3880            // mirrored below), so the scaled objective equals the unscaled
3881            // one. Without this it stayed NaN and the console printed
3882            // "Objective ...: nan  <unscaled>" on every active-set solve
3883            // (gh #313), even on a clean optimal solve.
3884            stats.final_scaled_objective = res.obj;
3885            stats.final_dual_inf = res.final_stationarity;
3886            stats.final_constr_viol = res.final_constr_viol;
3887            stats.final_compl = 0.0; // SQP has no barrier — no compl term.
3888            // Overall KKT error. This was previously left at the struct
3889            // default, which made every successful SQP solve report an
3890            // overall error of exactly 0.0 — indistinguishable from a
3891            // genuinely perfect solve, and enough on its own to make
3892            // `pounce.minimize`'s acceptable-KKT fallback upgrade any status
3893            // on this path to `success=True`. Same expression as the unscaled
3894            // twin below; the two agree because the SQP path does not thread
3895            // nlp_scaling through its residuals.
3896            stats.final_kkt_error = res.final_stationarity.max(res.final_constr_viol);
3897            // Unscaled residuals (pounce#173). The SQP path does not thread
3898            // the nlp_scaling factors through to its residuals yet, so these
3899            // mirror the SQP-side values: correct when no scaling is active
3900            // (the common case) and a conservative proxy otherwise. Populated
3901            // here so the info dict's `final_unscaled_*` keys are honest
3902            // rather than left at the 0.0 default.
3903            stats.final_unscaled_dual_inf = res.final_stationarity;
3904            stats.final_unscaled_constr_viol = res.final_constr_viol;
3905            stats.final_unscaled_compl = 0.0;
3906            stats.final_unscaled_kkt_error = res.final_stationarity.max(res.final_constr_viol);
3907            // Ipopt's `Variable bound violation` — how far the returned point
3908            // sits outside the box the caller wrote. Left at its `NaN` default
3909            // until now, which printed `nan` on this arm's every summary.
3910            //
3911            // Measured through the same `declared_box_violation` accessor the
3912            // interior-point arm reads (`curr_declared_box_violation_max`), so
3913            // the two arms cannot drift into reporting different quantities
3914            // under one heading. gh#900 is the precedent and the warning: it
3915            // fixed this row on the NLP and convex arms and noted the two were
3916            // "independent code paths, so a test that exercised one would say
3917            // nothing about the other" — this is the third, and it was missed.
3918            //
3919            // `res.x` is algorithm-space, which is what the accessor expects
3920            // (it lifts to full-x itself); `finalize_via_sqp` wraps it exactly
3921            // this way for the same reason.
3922            stats.final_declared_box_viol = {
3923                use pounce_linalg::dense_vector::DenseVectorSpace;
3924                let nlp_borrow = nlp_rc.borrow();
3925                let x_space = DenseVectorSpace::new(nlp_borrow.n());
3926                let mut x_dv = x_space.make_new_dense();
3927                x_dv.set_values(&res.x);
3928                // `NaN`, not `0.0`, on the abstention — deliberately unlike
3929                // the IPM path's `unwrap_or(0.0)`.
3930                //
3931                // `None` from this accessor means one thing only: the declared
3932                // bounds were never snapshotted. It is NOT "the model has no
3933                // finite bounds" — an unbounded model still snapshots, the
3934                // loops find nothing, and the answer is a measured `Some(0.0)`.
3935                // So on this arm `None` can only mean the
3936                // `snapshot_declared_bounds` call above was lost, and mapping
3937                // that to zero would replace a visible "no answer" with an
3938                // invisible false claim that the point is inside its box.
3939                // gh#900's subject is exactly that: a zero that must not be
3940                // fabricated. Checked by mutation — delete the snapshot call
3941                // and this row goes back to `nan` rather than quietly reading
3942                // `0.00e+00`.
3943                nlp_borrow
3944                    .declared_box_violation(&x_dv)
3945                    .unwrap_or(Number::NAN)
3946            };
3947            // gh#990: the derivative checker's verdict reaches `info` and the
3948            // report on this arm too; it was copied on the IPM drain only.
3949            stats.derivative_check = self.derivative_check.borrow().clone();
3950            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
3951        }
3952        let (app_status, solver_status) = match res.status {
3953            crate::sqp::SqpStatus::Optimal => (
3954                ApplicationReturnStatus::SolveSucceeded,
3955                pounce_nlp::SolverReturn::Success,
3956            ),
3957            crate::sqp::SqpStatus::MaxIter => (
3958                ApplicationReturnStatus::MaximumIterationsExceeded,
3959                pounce_nlp::SolverReturn::MaxiterExceeded,
3960            ),
3961            crate::sqp::SqpStatus::InfeasibleSubproblem => (
3962                ApplicationReturnStatus::InfeasibleProblemDetected,
3963                pounce_nlp::SolverReturn::LocalInfeasibility,
3964            ),
3965            crate::sqp::SqpStatus::LineSearchFailed => (
3966                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
3967                pounce_nlp::SolverReturn::ErrorInStepComputation,
3968            ),
3969            // Honest non-committal QP-subproblem failure (#282): the QP
3970            // solver could not compute a step and did NOT certify
3971            // infeasibility. Never report Infeasible_Problem_Detected here
3972            // — a feasible problem has no infeasibility certificate.
3973            crate::sqp::SqpStatus::QpStepFailed => (
3974                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
3975                pounce_nlp::SolverReturn::ErrorInStepComputation,
3976            ),
3977            // The QP subproblem ran out of its own iteration budget. Same
3978            // #282 guarantee — no infeasibility is asserted — but reported as
3979            // the budget exhaustion it is, so the user sees a limit they can
3980            // raise (`sqp_qp_max_iter`) instead of a step-size stall with no
3981            // remedy. See `SqpStatus::QpIterationLimit` for why these were
3982            // split.
3983            crate::sqp::SqpStatus::QpIterationLimit => (
3984                ApplicationReturnStatus::MaximumIterationsExceeded,
3985                pounce_nlp::SolverReturn::MaxiterExceeded,
3986            ),
3987            // Unbounded below, with a recession ray verified against the
3988            // true NLP (gh #388). `Diverging_Iterates` is POUNCE's (Ipopt's)
3989            // unboundedness verdict and maps to AMPL `solve_result_num=300`
3990            // — the same answer the IPM selectors give on the same model,
3991            // instead of the `Internal_Error` / 500 ("the solver broke")
3992            // this path used to report.
3993            crate::sqp::SqpStatus::Unbounded => (
3994                ApplicationReturnStatus::DivergingIterates,
3995                pounce_nlp::SolverReturn::DivergingIterates,
3996            ),
3997            // A non-finite iterate or constraint value (gh #876). Same
3998            // verdict, from the same condition, as the interior-point arm's
3999            // `if !nlp_err.is_finite()` screen — the two arms must not
4000            // disagree about what a `NaN` iterate means.
4001            crate::sqp::SqpStatus::InvalidNumber => (
4002                ApplicationReturnStatus::InvalidNumberDetected,
4003                pounce_nlp::SolverReturn::InvalidNumberDetected,
4004            ),
4005            crate::sqp::SqpStatus::UserRequestedStop => (
4006                ApplicationReturnStatus::UserRequestedStop,
4007                pounce_nlp::SolverReturn::UserRequestedStop,
4008            ),
4009            // gh#1054: the same verdicts the interior-point arm's
4010            // convergence check gives on the same budgets.
4011            crate::sqp::SqpStatus::WallTimeExceeded => (
4012                ApplicationReturnStatus::MaximumWallTimeExceeded,
4013                pounce_nlp::SolverReturn::WallTimeExceeded,
4014            ),
4015            crate::sqp::SqpStatus::CpuTimeExceeded => (
4016                ApplicationReturnStatus::MaximumCpuTimeExceeded,
4017                pounce_nlp::SolverReturn::CpuTimeExceeded,
4018            ),
4019        };
4020
4021        // Same gate as the IPM path: an infeasible-subproblem exit is a
4022        // numerical inference, and a feasible starting point disproves it
4023        // (gh #379). Only the infeasibility verdict is rewritten — every other
4024        // status passes through untouched, so the pair stays in lockstep.
4025        let refuted = withdraw_infeasibility_if_refuted(
4026            &tnlp,
4027            solver_status,
4028            self.nlp_lower_bound_inf(),
4029            self.nlp_upper_bound_inf(),
4030            self.user_tol(),
4031        );
4032        let (app_status, solver_status) = if refuted == solver_status {
4033            (app_status, solver_status)
4034        } else {
4035            (solver_return_to_app_status(refuted), refuted)
4036        };
4037
4038        // Forward to the user TNLP's finalize_solution. We pass
4039        // the SQP iterate and recovered multipliers via the
4040        // OrigIpoptNlp's lifting hooks. Failure here is silent
4041        // (we still return the algorithm's status) — the user
4042        // sees the right ApplicationReturnStatus regardless.
4043        let _ = finalize_via_sqp(
4044            &nlp_rc,
4045            &res,
4046            solver_status,
4047            &tnlp,
4048            &self.last_finalize,
4049            self.reports_fixed_var_duals(),
4050        );
4051
4052        // Honor the opt-in status-fidelity gate on the SQP path too
4053        // (pounce#173), then emit the end-of-run summary with the final
4054        // (possibly downgraded) status so the console matches the returned
4055        // ApplicationReturnStatus.
4056        let final_status = self.apply_kkt_fidelity_gate(app_status);
4057        self.emit_end_summary(final_status, &nlp_rc, console_output);
4058        final_status
4059    }
4060
4061    /// Opt-in status-fidelity gate (pounce#173), shared by the IPM and
4062    /// SQP solve paths. When the user sets a positive `kkt_fidelity_tol`,
4063    /// a reported `Solve_Succeeded` whose max-norm UNSCALED KKT error
4064    /// (`SolveStatistics::final_unscaled_kkt_error`) exceeds it is
4065    /// downgraded to `Solved_To_Acceptable_Level` — the honest "this is a
4066    /// point, but not converged to the requested fidelity" status. This
4067    /// catches the ill-conditioned / nlp_scaling-deflated case where the
4068    /// scaled convergence test passes but the user-space duals have
4069    /// drifted. It is a pure relabel at termination (no extra iterations);
4070    /// unset or non-positive (the default) is a strict no-op, so every
4071    /// existing caller keeps the Ipopt-faithful status.
4072    fn apply_kkt_fidelity_gate(
4073        &self,
4074        app_status: ApplicationReturnStatus,
4075    ) -> ApplicationReturnStatus {
4076        if !matches!(app_status, ApplicationReturnStatus::SolveSucceeded) {
4077            return app_status;
4078        }
4079        if let Ok((ftol, true)) = self.options.get_numeric_value("kkt_fidelity_tol", "") {
4080            if ftol > 0.0 {
4081                let unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
4082                if unscaled_kkt > ftol {
4083                    tracing::info!(target: "pounce::diagnostics",
4084                        "kkt_fidelity_tol={ftol:.3e}: unscaled KKT error {unscaled_kkt:.3e} \
4085                         exceeds it — downgrading Solve_Succeeded → \
4086                         Solved_To_Acceptable_Level (pounce#173)");
4087                    return ApplicationReturnStatus::SolvedToAcceptableLevel;
4088                }
4089            }
4090        }
4091        app_status
4092    }
4093
4094    /// `nlp_lower_bound_inf` — the magnitude at or below which a bound is
4095    /// treated as absent.
4096    fn nlp_lower_bound_inf(&self) -> Number {
4097        self.options
4098            .get_numeric_value("nlp_lower_bound_inf", "")
4099            .ok()
4100            .and_then(|(v, f)| f.then_some(v))
4101            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF)
4102    }
4103
4104    /// `nlp_upper_bound_inf` — the magnitude at or above which a bound is
4105    /// treated as absent.
4106    fn nlp_upper_bound_inf(&self) -> Number {
4107        self.options
4108            .get_numeric_value("nlp_upper_bound_inf", "")
4109            .ok()
4110            .and_then(|(v, f)| f.then_some(v))
4111            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF)
4112    }
4113
4114    /// The user's convergence tolerance `tol`.
4115    fn user_tol(&self) -> Number {
4116        self.options
4117            .get_numeric_value("tol", "")
4118            .ok()
4119            .and_then(|(v, f)| f.then_some(v))
4120            .unwrap_or(1e-8)
4121    }
4122
4123    /// The user's `acceptable_tol` — the standard behind
4124    /// `Solved_To_Acceptable_Level`.
4125    fn user_acceptable_tol(&self) -> Number {
4126        self.options
4127            .get_numeric_value("acceptable_tol", "")
4128            .ok()
4129            .and_then(|(v, f)| f.then_some(v))
4130            .unwrap_or(1e-6)
4131    }
4132
4133    /// The user's `constr_viol_tol` — the **absolute** feasibility standard
4134    /// the strict gate's primal component judges by
4135    /// (`OptErrorConvCheck::primal_component_passes`).
4136    fn user_constr_viol_tol(&self) -> Number {
4137        self.options
4138            .get_numeric_value("constr_viol_tol", "")
4139            .ok()
4140            .and_then(|(v, f)| f.then_some(v))
4141            .unwrap_or(1e-4)
4142    }
4143
4144    /// The user's `acceptable_constr_viol_tol` — the same standard at the
4145    /// acceptable tier.
4146    fn user_acceptable_constr_viol_tol(&self) -> Number {
4147        self.options
4148            .get_numeric_value("acceptable_constr_viol_tol", "")
4149            .ok()
4150            .and_then(|(v, f)| f.then_some(v))
4151            .unwrap_or(1e-2)
4152    }
4153
4154    /// `primal_noise_floor_kappa` — the safety factor on the per-row
4155    /// floating-point noise floor (gh#528/gh#590). `0` opts out.
4156    fn user_primal_noise_floor_kappa(&self) -> Number {
4157        self.options
4158            .get_numeric_value("primal_noise_floor_kappa", "")
4159            .ok()
4160            .and_then(|(v, f)| f.then_some(v))
4161            .unwrap_or(64.0)
4162    }
4163
4164    /// Emit the Ipopt-style problem-statistics block (#206) from the
4165    /// engine's own reduced problem, gated on `console_output`
4166    /// (print_level >= 1). Shared by the IPM (`optimize_tnlp`) and SQP
4167    /// (`optimize_sqp_tnlp`) entry points so every algorithm and every
4168    /// frontend (CLI, Python, C) gets the identical block. Built from the
4169    /// same `collect_stats` inputs the CLI used, so the output is
4170    /// byte-identical to the historical CLI block.
4171    fn emit_problem_stats(&self, tnlp: &Rc<RefCell<dyn TNLP>>, console_output: bool) {
4172        if !console_output {
4173            return;
4174        }
4175        let (lo_inf, up_inf, fixed_treatment) = self.adapter_options();
4176        if let Some(stats) =
4177            pounce_solve_report::console::collect_stats(tnlp, lo_inf, up_inf, fixed_treatment)
4178        {
4179            // A retry re-enters here with the same problem. Reprinting the
4180            // block verbatim is what made a retried run look like it had
4181            // printed its whole report twice for no reason, so say what is
4182            // actually happening instead: a new attempt on an unchanged
4183            // problem. One line, in place of seventeen that carry no
4184            // information the reader does not already have.
4185            //
4186            // The attempt is announced rather than merely suppressed because
4187            // the iteration table below it restarts at iteration 0 either way.
4188            // Silence would leave that table appearing from nowhere — trading
4189            // a confusing duplicate for a confusing gap.
4190            let mut memo = self.last_printed_problem_stats.borrow_mut();
4191            if memo.as_ref() == Some(&stats) {
4192                // Silent under an outside retry driver, which narrates its own
4193                // attempts in more detail than this line could ("pounce:
4194                // second opinion — re-solving with feral_scaling=mc64…").
4195                // Printing both would be two announcements of one event.
4196                if !self.in_retry_sequence.get() {
4197                    println!("Re-solving the same problem (a further attempt follows).");
4198                    println!();
4199                }
4200            } else {
4201                pounce_solve_report::console::print_problem_stats(&stats);
4202                *memo = Some(stats);
4203            }
4204        }
4205    }
4206
4207    /// Drain the NLP's per-eval counters into the shared `SolveStatistics`
4208    /// and emit the Ipopt-style end-of-run summary (#206). Shared by both
4209    /// solve paths. The counts are read from the NLP AFTER the solve (so the
4210    /// final solution evaluation is included, matching the historical count)
4211    /// and written into `SolveStatistics` so the post-solve API accessors
4212    /// (`info["n_obj_evals"]`, …) report them even when the console is
4213    /// silent. c/d (and jac_c/jac_d) are per-subsystem, so the max recovers
4214    /// the eval_g / eval_jac_g call count. The console summary itself is
4215    /// gated on `console_output` (print_level >= 1).
4216    fn emit_end_summary(
4217        &self,
4218        app_status: ApplicationReturnStatus,
4219        nlp: &Rc<RefCell<dyn IpoptNlp>>,
4220        console_output: bool,
4221    ) {
4222        {
4223            let ec = nlp.borrow().eval_counts();
4224            let mut stats = self.statistics.borrow_mut();
4225            stats.num_obj_evals = ec[0];
4226            stats.num_obj_grad_evals = ec[1];
4227            stats.num_constr_evals = ec[2].max(ec[3]);
4228            stats.num_constr_jac_evals = ec[4].max(ec[5]);
4229            stats.num_hess_evals = ec[6];
4230        }
4231        if !console_output {
4232            return;
4233        }
4234        let stats = self.statistics.borrow();
4235        let counts = pounce_solve_report::console::EvalCounts {
4236            n_obj: stats.num_obj_evals as u64,
4237            n_grad_f: stats.num_obj_grad_evals as u64,
4238            n_g: stats.num_constr_evals as u64,
4239            n_jac_g: stats.num_constr_jac_evals as u64,
4240            n_h: stats.num_hess_evals as u64,
4241        };
4242        // gh#987: no widening at `bound_relax_factor = 0`, so the console's
4243        // "before the bound_relax_factor widening" line has nothing to report.
4244        let widening_applied = self
4245            .options
4246            .get_numeric_value("bound_relax_factor", "")
4247            .map(|(v, _)| v > 0.0)
4248            .unwrap_or(true);
4249        pounce_solve_report::console::print_summary_with_widening(
4250            app_status,
4251            &stats,
4252            &counts,
4253            widening_applied,
4254        );
4255        // The verdict belongs to the run, not to the attempt. Held back while
4256        // any retry driver is managing attempts; that driver prints it once,
4257        // with the status it actually returns.
4258        if self.end_verdict_deferrals.get() == 0 {
4259            pounce_solve_report::console::print_exit_verdict(app_status);
4260        }
4261    }
4262
4263    /// Build a *copy* of the algorithm builder configured per the
4264    /// current options. The SQP path uses this so it gets a
4265    /// fresh builder without mutating the application's state.
4266    /// Read the `ipm_sqp_handover*` option family into a
4267    /// [`crate::handover::HandoverOptions`].
4268    fn handover_options(&self) -> crate::handover::HandoverOptions {
4269        let mut o = crate::handover::HandoverOptions::default();
4270        // `sqp_auto_handover` only fires when this option was not set.
4271        o.enabled = self.auto_handover.get();
4272        if let Ok((v, true)) = self.options.get_bool_value("ipm_sqp_handover", "") {
4273            o.enabled = v;
4274        }
4275        if let Ok((v, true)) = self.options.get_numeric_value("ipm_sqp_handover_tol", "") {
4276            o.tol = v;
4277        }
4278        if let Ok((v, true)) = self
4279            .options
4280            .get_integer_value("ipm_sqp_handover_stall_iter", "")
4281        {
4282            o.stall_iter = v.max(0);
4283        }
4284        if let Ok((v, true)) = self
4285            .options
4286            .get_integer_value("ipm_sqp_handover_max_attempts", "")
4287        {
4288            o.max_attempts = v.max(0) as u32;
4289        }
4290        o
4291    }
4292
4293    /// Install the interior-point → active-set handover hook on `alg` when
4294    /// `ipm_sqp_handover=yes` (gh#1054; see [`crate::handover`]). Returns
4295    /// the cells the hook reports into — the report, and the working set of
4296    /// an accepted attempt — for [`Self::finish_handover`] to read after the
4297    /// solve.
4298    #[allow(clippy::type_complexity)]
4299    fn install_handover(
4300        &self,
4301        alg: &mut IpoptAlgorithm,
4302        nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4303    ) -> Option<(
4304        Rc<RefCell<crate::handover::HandoverReport>>,
4305        Rc<RefCell<Option<pounce_qp::WorkingSet>>>,
4306    )> {
4307        let hopts = self.handover_options();
4308        if !hopts.enabled {
4309            return None;
4310        }
4311        let mut trigger = crate::handover::HandoverTrigger::new(hopts);
4312        let xopts = crate::crossover::CrossoverOptions {
4313            enabled: true,
4314            ..self.crossover_options()
4315        };
4316        let snapshot = self.algorithm_builder_snapshot();
4317        let qp_opts = snapshot.sqp_qp.clone();
4318        let data = Rc::clone(&alg.data);
4319        let nlp = Rc::clone(nlp_handle);
4320        // One factory serves every backend the attempts build: a factory is
4321        // reusable, but `build_sqp_with_backend` takes one by value, so the
4322        // step-4 SQP gets a forwarding closure onto the shared one.
4323        let backend = Rc::new(RefCell::new(self.make_backend_factory()));
4324        let tols = self.user_tolerances();
4325        let report = Rc::new(RefCell::new(crate::handover::HandoverReport::default()));
4326        let working = Rc::new(RefCell::new(None));
4327        let (report_out, working_out) = (Rc::clone(&report), Rc::clone(&working));
4328        let (wall0, cpu0) = (
4329            pounce_common::utils::wallclock_time(),
4330            pounce_common::utils::cpu_time(),
4331        );
4332        alg.handover = Some(Box::new(move |curr, iter, err, mu| {
4333            if !trigger.should_try(iter, err) {
4334                return None;
4335            }
4336            let seed = crossover_seed(&nlp, curr);
4337            let x = crate::crossover::CrossoverOptions {
4338                primal_tol: crate::handover::identification_primal_tol(xopts.primal_tol, mu),
4339                ..xopts.clone()
4340            };
4341            // An attempt may spend at most what the interior method has
4342            // spent so far (floor 1 s), and never past the solve's own
4343            // budget. Without a cap a declined attempt is unbounded: on
4344            // `deb7` (813 variables) one ran the crossover fallback for
4345            // minutes, against a 114-iteration interior solve. With it, a
4346            // declined attempt costs at most a doubling — the right scale
4347            // for a bet on shortening the tail.
4348            let (wall_left, cpu_left) = data
4349                .borrow()
4350                .deadline
4351                .as_ref()
4352                .map_or((Number::INFINITY, Number::INFINITY), |d| {
4353                    (d.remaining_wall(), d.remaining_cpu())
4354                });
4355            let spent_wall = (pounce_common::utils::wallclock_time() - wall0).max(1.0);
4356            let spent_cpu = (pounce_common::utils::cpu_time() - cpu0).max(1.0);
4357            let sqp_opts = crate::sqp::SqpOptions {
4358                deadline: Some(pounce_common::timing::Deadline::new(
4359                    spent_wall.min(wall_left),
4360                    spent_cpu.min(cpu_left),
4361                )),
4362                ..snapshot.sqp.clone()
4363            };
4364            // Declared bounds, for the reason `maybe_crossover` gives.
4365            let mut adapter =
4366                crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(&nlp));
4367            let (mut rep, mut accepted) = crate::crossover::run(
4368                &mut adapter,
4369                &seed,
4370                &x,
4371                &sqp_opts,
4372                &qp_opts,
4373                || (backend.borrow_mut())(crate::alg_builder::LinearSolverChoice::Feral),
4374                |step4_opts| {
4375                    let mut b = snapshot.clone();
4376                    b.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
4377                    b.sqp = step4_opts;
4378                    let shared = Rc::clone(&backend);
4379                    b.build_sqp_with_backend(Box::new(move |c| (shared.borrow_mut())(c)))
4380                },
4381            );
4382            // The interior-point verdict this replaces is issued in user
4383            // units on the declared model; so is this one.
4384            if let Some(res) = &accepted
4385                && !meets_user_tolerances(&nlp, res, &rep, &tols)
4386            {
4387                rep = rep.decline(crate::crossover::CrossoverDecline::UserTolerances);
4388                accepted = None;
4389            }
4390            tracing::info!(
4391                target: "pounce::handover",
4392                "IPM→SQP handover attempt {} at iteration {iter} (error {err:.2e}, mu {mu:.2e}): {}",
4393                trigger.attempts(),
4394                if rep.accepted() { "accepted" } else { "declined" }
4395            );
4396            tracing::debug!(target: "pounce::handover", "handover: {rep:?}");
4397            let mut r = report.borrow_mut();
4398            r.attempts = trigger.attempts();
4399            r.last = Some(rep);
4400            let res = accepted?;
4401            let next = crossover_iterate(&nlp, curr, &res)?;
4402            r.accepted_at_iter = Some(iter);
4403            *working.borrow_mut() = res.working_set;
4404            Some(next)
4405        }));
4406        Some((report_out, working_out))
4407    }
4408
4409    /// After the solve: publish the handover report, and an accepted
4410    /// attempt's working set as the next SQP solve's warm start (the same
4411    /// hand-off crossover makes).
4412    fn finish_handover(
4413        &mut self,
4414        nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4415        cells: Option<(
4416            Rc<RefCell<crate::handover::HandoverReport>>,
4417            Rc<RefCell<Option<pounce_qp::WorkingSet>>>,
4418        )>,
4419    ) {
4420        self.handover_report = None;
4421        let Some((report, working)) = cells else {
4422            return;
4423        };
4424        let report = report.borrow().clone();
4425        if report.accepted_at_iter.is_some()
4426            && let Some(ws) = working.borrow_mut().take()
4427        {
4428            self.sqp_last_working_set = Some(self.working_set_to_user(&*nlp_handle.borrow(), ws));
4429        }
4430        self.handover_report = Some(report);
4431    }
4432
4433    /// The interior-point arm's user-unit tolerances, which a crossed-over
4434    /// point must also meet (see [`meets_user_tolerances`]).
4435    fn user_tolerances(&self) -> UserTolerances {
4436        let read = |name: &str, default: Number| {
4437            self.options
4438                .get_numeric_value(name, "")
4439                .map_or(default, |(v, _)| v)
4440        };
4441        UserTolerances {
4442            constr_viol: read("constr_viol_tol", 1e-4),
4443            dual_inf: read("dual_inf_tol", 1.0),
4444            compl_inf: read("compl_inf_tol", 1e-4),
4445        }
4446    }
4447
4448    /// A [`pounce_common::timing::Deadline`] for `max_wall_time` /
4449    /// `max_cpu_time`, starting now.
4450    fn solve_deadline(&self) -> pounce_common::timing::Deadline {
4451        let read = |name: &str| {
4452            self.options
4453                .get_numeric_value(name, "")
4454                .map_or(1e6, |(v, _)| v)
4455        };
4456        pounce_common::timing::Deadline::new(read("max_wall_time"), read("max_cpu_time"))
4457    }
4458
4459    /// Read the `crossover*` option family into a [`CrossoverOptions`].
4460    fn crossover_options(&self) -> crate::crossover::CrossoverOptions {
4461        let mut o = crate::crossover::CrossoverOptions::default();
4462        if let Ok((v, true)) = self.options.get_bool_value("crossover", "") {
4463            o.enabled = v;
4464        }
4465        if let Ok((v, true)) = self.options.get_integer_value("crossover_max_iter", "") {
4466            o.max_iter = v.max(0) as u32;
4467        }
4468        if let Ok((v, true)) = self.options.get_numeric_value("crossover_mult_tol", "") {
4469            o.mult_tol = v;
4470        }
4471        if let Ok((v, true)) = self.options.get_numeric_value("crossover_primal_tol", "") {
4472            o.primal_tol = v;
4473        }
4474        o
4475    }
4476
4477    /// Post-convergence crossover (gh#612): hand the converged interior
4478    /// iterate to the active-set path so the solve ends on an exact active
4479    /// set. See [`crate::crossover`] for the algorithm.
4480    ///
4481    /// On acceptance this **replaces `data.curr`** rather than reporting
4482    /// alongside it. Everything downstream — the residual drain, the KKT
4483    /// fidelity gate, `on_converged`, `finalize_via_orig_nlp`, the end
4484    /// summary — already reads that one iterate, so replacing it is what
4485    /// makes the crossed-over point the solution instead of an annotation on
4486    /// it, and it does so without a second copy of the unscaling path.
4487    ///
4488    /// A no-op unless `crossover=yes` and the solve converged: an
4489    /// unconverged interior point is not a KKT point, so there is no active
4490    /// set at it worth identifying.
4491    fn maybe_crossover(
4492        &mut self,
4493        alg: &mut IpoptAlgorithm,
4494        nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4495        solver_status: SolverReturn,
4496    ) {
4497        self.crossover_report = None;
4498        // Cleared on every entry, alongside the report, so the flag
4499        // describes this solve and not a previous one — the same reason
4500        // `crossover_report` is reset here rather than only written on
4501        // the accept path.
4502        //
4503        // Except when the in-solve handover installed the iterate (gh#1054):
4504        // then the flag was set during *this* solve and is still true of the
4505        // point in `curr` — clearing it here mislabelled the handover's
4506        // declared-frame point as an interior one, and its complementarity
4507        // was reported in the relaxed frame (`0.1` on `jit1`).
4508        let handover_point = self
4509            .handover_report
4510            .as_ref()
4511            .is_some_and(|h| h.accepted_at_iter.is_some());
4512        if !handover_point {
4513            alg.data.borrow_mut().curr_from_crossover = false;
4514        }
4515        let xopts = self.crossover_options();
4516        if !xopts.enabled {
4517            return;
4518        }
4519        if !matches!(
4520            solver_status,
4521            SolverReturn::Success | SolverReturn::StopAtAcceptablePoint
4522        ) {
4523            return;
4524        }
4525        let Some(curr) = alg.data.borrow().curr.clone() else {
4526            return;
4527        };
4528
4529        let seed = crossover_seed(nlp_handle, &curr);
4530
4531        let snapshot = self.algorithm_builder_snapshot();
4532        let mut sqp_opts = snapshot.sqp.clone();
4533        // The interior-point phase's own deadline, not a fresh one:
4534        // crossover is part of the same solve, so it spends what the IPM
4535        // left of `max_wall_time` / `max_cpu_time` (gh#1054). Before this it
4536        // had no budget at all and could outlive the solve's by any amount.
4537        sqp_opts.deadline = alg
4538            .data
4539            .borrow()
4540            .deadline
4541            .clone()
4542            .or_else(|| Some(self.solve_deadline()));
4543        let qp_opts = snapshot.sqp_qp.clone();
4544        // Declared bounds, not the live relaxed ones: crossover's whole claim
4545        // is that the returned point sits *on* the constraints the user
4546        // wrote. Against the `bound_relax_factor`-widened box it would pivot
4547        // to a point `1e-8` shy of every one of them and then correctly
4548        // report an empty active set.
4549        let mut adapter =
4550            crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(nlp_handle));
4551        let (report, accepted) = crate::crossover::run(
4552            &mut adapter,
4553            &seed,
4554            &xopts,
4555            &sqp_opts,
4556            &qp_opts,
4557            || {
4558                let mut f = self.make_backend_factory();
4559                // `make_backend_factory` ships the workspace-default
4560                // backend regardless of the choice passed, exactly as the
4561                // SQP path's `build_sqp_with_backend` call does; naming
4562                // `Feral` here keeps that visible rather than implicit.
4563                f(crate::alg_builder::LinearSolverChoice::Feral)
4564            },
4565            |step4_opts| {
4566                let mut b = self.algorithm_builder_snapshot();
4567                b.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
4568                b.sqp = step4_opts;
4569                b.build_sqp_with_backend(self.make_backend_factory())
4570            },
4571        );
4572
4573        let tols = self.user_tolerances();
4574        let (report, accepted) = match accepted {
4575            Some(res) if !meets_user_tolerances(nlp_handle, &res, &report, &tols) => (
4576                report.decline(crate::crossover::CrossoverDecline::UserTolerances),
4577                None,
4578            ),
4579            other => (report, other),
4580        };
4581        if let Some(res) = accepted {
4582            self.install_crossover_iterate(alg, nlp_handle, &curr, &res);
4583            // Publish the identified set as the SQP warm-start output. This
4584            // is the IPM → SQP handoff the active-set path never had: a
4585            // sequence whose first solve wants the interior method can now
4586            // feed the next `algorithm=active-set-sqp` solve a working set.
4587            self.sqp_last_working_set = res
4588                .working_set
4589                .clone()
4590                .map(|ws| self.working_set_to_user(&*nlp_handle.borrow(), ws));
4591        }
4592        tracing::debug!(target: "pounce::crossover", "crossover: {report:?}");
4593        self.crossover_report = Some(report);
4594    }
4595
4596    /// Write an accepted crossover result back onto the IPM iterate (built by
4597    /// [`crossover_iterate`]).
4598    fn install_crossover_iterate(
4599        &self,
4600        alg: &mut IpoptAlgorithm,
4601        nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4602        curr: &crate::iterates_vector::IteratesVector,
4603        res: &crate::sqp::SqpResult,
4604    ) {
4605        let Some(out) = crossover_iterate(nlp_handle, curr, res) else {
4606            return;
4607        };
4608        let mut d = alg.data.borrow_mut();
4609        d.set_curr(out);
4610        // Mark which frame the installed iterate belongs to. It sits on the
4611        // *declared* bounds, and every barrier quantity built off `curr` —
4612        // slacks, and through them `Σ = z/s` — is measured against the
4613        // `bound_relax_factor`-widened ones, so a consumer that wants the
4614        // point's own geometry rather than the barrier's has to know this
4615        // happened (gh#654). Set only on the path that actually replaced
4616        // `curr`: a declined or abandoned crossover leaves the interior
4617        // iterate, which is an interior-frame point.
4618        d.curr_from_crossover = true;
4619    }
4620
4621    fn algorithm_builder_snapshot(&self) -> AlgorithmBuilder {
4622        let mut builder = AlgorithmBuilder {
4623            quality_escalation_counter: Some(Rc::clone(&self.quality_escalations)),
4624            ..AlgorithmBuilder::default()
4625        };
4626        apply_sqp_options(&self.options, &mut builder.sqp);
4627        apply_qp_subproblem_options(&self.options, &mut builder.sqp_qp);
4628        builder
4629    }
4630
4631    /// Refuse an explicitly set `ma57_pivtolmax` that sits below
4632    /// `ma57_pivtol`, at either option prefix.
4633    ///
4634    /// Upstream's `Ma57TSolverInterface::InitializeImpl` asserts
4635    /// `pivtolmax >= pivtol` and raises `OPTION_INVALID`, but only when
4636    /// the user set `ma57_pivtolmax` explicitly; left unset, the
4637    /// registered default is lifted to `ma57_pivtol` instead. Both
4638    /// halves are mirrored — the lifting in
4639    /// `pounce_hsl::ma57::Options::from_options_list`, the refusal here.
4640    ///
4641    /// Checked at **both** prefixes because the restoration sub-IPM
4642    /// configures its own MA57 backend from `"resto."`-scoped options
4643    /// (gh#825), so `resto.ma57_pivtolmax` can contradict
4644    /// `resto.ma57_pivtol` without the un-prefixed pair being wrong.
4645    ///
4646    /// Deliberately **not** gated on the `ma57` cargo feature or on
4647    /// `linear_solver` resolving to MA57. It is a consistency check on
4648    /// two numbers the user wrote, needs no HSL to perform, and a
4649    /// verdict that changed with a build flag would be worse than a
4650    /// consistent one — it would also be untestable in CI, which cannot
4651    /// link CoinHSL. Only an *explicitly set* `ma57_pivtolmax` can
4652    /// trigger it, so an options file that never mentions the option is
4653    /// unaffected.
4654    fn ma57_pivtol_bracket_refusal(&self) -> Option<String> {
4655        for prefix in ["", "resto."] {
4656            // `(_, true)` is the explicitly-set arm; an unset option
4657            // reports the registry default with `false` and is the
4658            // branch upstream lifts rather than refuses.
4659            let Ok((pivtolmax, true)) = self.options.get_numeric_value("ma57_pivtolmax", prefix)
4660            else {
4661                continue;
4662            };
4663            let pivtol = self
4664                .options
4665                .get_numeric_value("ma57_pivtol", prefix)
4666                .map(|(v, _)| v)
4667                .unwrap_or(1e-8);
4668            if pivtolmax < pivtol {
4669                return Some(format!(
4670                    "pounce: {prefix}ma57_pivtolmax ({pivtolmax:e}) is below \
4671                     {prefix}ma57_pivtol ({pivtol:e}). ma57_pivtolmax is the ceiling MA57 \
4672                     may raise the pivot tolerance to when it escalates for accuracy, so it \
4673                     cannot sit below the tolerance it starts from. Raise \
4674                     {prefix}ma57_pivtolmax to at least {pivtol:e}, or lower \
4675                     {prefix}ma57_pivtol."
4676                ));
4677            }
4678        }
4679        None
4680    }
4681
4682    /// Build a linear-solver factory from the application's current options,
4683    /// for the paths that do not go through `optimize_constrained`'s own
4684    /// factory plumbing — the active-set SQP driver and the post-convergence
4685    /// crossover.
4686    ///
4687    /// This used to ignore its `choice` argument and return
4688    /// `FeralSolverInterface::new()`: a default-configured FERAL, whatever the
4689    /// caller asked for. Three things were silently dropped on every SQP
4690    /// solve. `linear_solver=ma57` ran FERAL while the banner said
4691    /// `MA57 (HSL)`, so an MA57-vs-FERAL comparison on this path measured
4692    /// nothing — the same defect as gh#825, one path over. Every `feral_*`
4693    /// option (`feral_refine`, `feral_ordering`, `feral_scaling`,
4694    /// `feral_increase_quality`, …) was accepted and discarded. And
4695    /// `set_external_ordering` — whose whole purpose is to inject an ordering
4696    /// a generic algorithm cannot derive — reached the IPM and not this arm.
4697    ///
4698    /// Reads the same two snapshots and calls the same constructor the IPM
4699    /// path does, so there is one answer to "what does `linear_solver` mean"
4700    /// rather than one per algorithm.
4701    fn make_backend_factory(&self) -> LinearBackendFactory {
4702        // `RefineCarveOut::None`: this arm does not refine the unreduced system
4703        // itself, so the backend loop is the only refinement in the stack and
4704        // the interior-point carve-out's premise is false here. See
4705        // `feral_config_from_options_scoped` for the measurement.
4706        let mut feral_cfg = feral_config_from_options_scoped(&self.options, RefineCarveOut::None);
4707        // Same override, and for the same reason, as the IPM path: the
4708        // external permutation carries a vector, which no string option can
4709        // express, so it arrives through the side-channel field instead.
4710        if let Some(perm) = &self.external_ordering {
4711            feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
4712        }
4713        let ma57_cfg = ma57_config_from_options(&self.options, "");
4714        default_backend_factory_with_sink(
4715            feral_cfg,
4716            ma57_cfg,
4717            Arc::clone(&self.linsol_summary_sink),
4718        )
4719    }
4720
4721    /// Phase 3.5 auto-fallback driver.
4722    ///
4723    /// Runs the standard solve (no wrapper) first. If it ends in a
4724    /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
4725    /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
4726    /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
4727    /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
4728    /// it returns `Solve_Succeeded`; otherwise returns the original
4729    /// status.
4730    ///
4731    /// Caveat: the user TNLP's `finalize_solution` runs once per
4732    /// attempt. When the retry doesn't promote, the user's captured
4733    /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
4734    /// point) even though the returned status is the original's.
4735    /// Documented on the option's help text; tightening this is a
4736    /// Phase-4 follow-up.
4737    fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
4738        // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
4739        // reached for `m > 0`, so `optimize_constrained` is exact.
4740        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
4741        if !is_l1_fallback_trigger(first_status) {
4742            return first_status;
4743        }
4744        // gh#987 item 6: the plain attempt's iterations are part of what this
4745        // call spent, and the retry's `optimize_constrained` resets them.
4746        let first_attempt = self.statistics.borrow().clone();
4747        // Trigger fired. Flip the wrapper option for the retry and
4748        // restore it after — keeps the user's option-table view of the
4749        // session exactly as they left it.
4750        let prev = self
4751            .options
4752            .get_string_value("l1_exact_penalty_barrier", "")
4753            .ok();
4754        let _ = self
4755            .options
4756            .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
4757        let retry_status = self
4758            .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
4759            .unwrap_or(ApplicationReturnStatus::InternalError);
4760        let _ = self.options.set_string_value(
4761            "l1_exact_penalty_barrier",
4762            prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
4763            true,
4764            false,
4765        );
4766        // Fold the plain attempt in ahead of the retry's passes. A retry that
4767        // never ran a pass (wrapper construction declined) leaves the plain
4768        // attempt's own statistics standing untouched.
4769        {
4770            let mut st = self.statistics.borrow_mut();
4771            if !st.passes.is_empty() {
4772                let mut head = MultiPassTotals::default();
4773                head.push_pass(&first_attempt, None, first_status);
4774                let shift = head.rows.len();
4775                let mut passes = head.passes;
4776                passes.extend(st.passes.iter().cloned().map(|mut p| {
4777                    p.first_row += shift;
4778                    p
4779                }));
4780                let mut rows = head.rows;
4781                rows.append(&mut st.iterations);
4782                st.iterations = rows;
4783                st.iteration_count += first_attempt.iteration_count;
4784                st.num_obj_evals += first_attempt.num_obj_evals;
4785                st.num_constr_evals += first_attempt.num_constr_evals;
4786                st.num_obj_grad_evals += first_attempt.num_obj_grad_evals;
4787                st.num_constr_jac_evals += first_attempt.num_constr_jac_evals;
4788                st.num_hess_evals += first_attempt.num_hess_evals;
4789                st.restoration_calls += first_attempt.restoration_calls;
4790                st.restoration_inner_iters += first_attempt.restoration_inner_iters;
4791                st.restoration_outer_iters += first_attempt.restoration_outer_iters;
4792                st.passes = passes;
4793            }
4794        }
4795        // A retry the caller's intermediate callback stopped reports the
4796        // stop: returning the first attempt's status would override it.
4797        if matches!(
4798            retry_status,
4799            ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::UserRequestedStop
4800        ) {
4801            retry_status
4802        } else {
4803            first_status
4804        }
4805    }
4806
4807    /// μ-strategy auto-fallback driver (pounce#138).
4808    ///
4809    /// Runs the standard solve first. If it stalls short of optimal in a
4810    /// way a μ-strategy flip can plausibly fix — `Solved_To_Acceptable_Level`
4811    /// or `Maximum_Iterations_Exceeded`, the two signatures seen on the
4812    /// princetonlib instances where the dual infeasibility parks above
4813    /// `tol` while constraint violation and complementarity are already
4814    /// deeply converged — it flips `mu_strategy` (adaptive↔monotone) and
4815    /// solves once more. The retry's status is promoted only if it returns
4816    /// `Solve_Succeeded`; otherwise the original status is returned.
4817    ///
4818    /// (maxcut/price stall at acceptable-level under adaptive; fermat2_vareps
4819    /// stalls at `max_iter` — hence both triggers. flosp2tm is μ-independent
4820    /// and correctly does not promote.)
4821    ///
4822    /// The flip direction is taken from the strategy the option table
4823    /// actually resolves to (`effective_mu_strategy_is_adaptive`):
4824    /// `adaptive` → `monotone`, otherwise → `adaptive`. Absence is not
4825    /// the same as `monotone` — under a limited-memory Hessian an unset
4826    /// `mu_strategy` resolves to `adaptive` (gh#746), and flipping the
4827    /// *registered* default there would re-run the strategy that just
4828    /// stalled. The option table is restored to the resolved view
4829    /// afterward.
4830    ///
4831    /// Caveat (shared with the ℓ₁ fallback): the user TNLP's
4832    /// `finalize_solution` runs once per attempt, so when the retry
4833    /// doesn't promote the captured fields hold the retry's iterate.
4834    fn run_with_mu_strategy_fallback(
4835        &mut self,
4836        tnlp: Rc<RefCell<dyn TNLP>>,
4837    ) -> ApplicationReturnStatus {
4838        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
4839        // Which statuses are worth a second solve depends on who asked
4840        // for the retry (pounce#748).
4841        //
4842        // An explicit `mu_strategy_fallback=yes` keeps the historical
4843        // pair: the caller opted in and can afford the second solve.
4844        //
4845        // The *default*-on retry takes `Maximum_Iterations_Exceeded`
4846        // unconditionally, and `Solved_To_Acceptable_Level` only when
4847        // the caller left the convergence configuration alone
4848        // (gh #757). pounce#748 refused the latter status outright, for
4849        // three reasons; two of them are properties of a *caller-
4850        // modified* configuration, not of the status. It launders
4851        // downgrades the caller induced deliberately -- a tight
4852        // `kkt_fidelity_tol`, a certificate veto, `least_square_init_
4853        // primal` -- so the signal the option exists to produce never
4854        // reaches them. And because the retry returns the other run's
4855        // *point*, not just its status, it can hand back a different
4856        // local solution: on `autocorr_bern55-06` with the
4857        // dual-divergence guard on it swaps -2304.0000278 for
4858        // -2320.0000298 (crates/pounce-cli/tests/
4859        // issue_250_dual_guard_never_worse.rs). Both cases -- and all
4860        // five test targets the wide trigger broke -- arm a
4861        // non-default option from `TERMINATION_POLICY_OPTIONS`, so
4862        // deferring to that set preserves every one of them while
4863        // leaving a stock-options stall retryable. The third reason,
4864        // cost, stands and is the price: one extra solve on a run that
4865        // reached only the acceptable tolerance, paid to try for the
4866        // certificate. `cho_parmest` is the motivating case -- monotone
4867        // parks `inf_du` on a ~1e-6 evaluation-noise floor and misses
4868        // `tol` by 5%, taking six null steps of 1e-12 at `mu_min`,
4869        // while adaptive certifies it in 20 iterations.
4870        //
4871        // `dirichlet120`, the case that motivated turning the retry on,
4872        // stalls at `Maximum_Iterations_Exceeded`, so it is recovered
4873        // either way.
4874        let retry_worthy = match first_status {
4875            ApplicationReturnStatus::MaximumIterationsExceeded => true,
4876            // A tiny-step stop the `tiny_step_dual_stall_iters` rule released
4877            // is an iteration limit reached early: the solve had stopped
4878            // moving with its multipliers running away, and before the rule
4879            // it took the remaining budget to say so and then came here as
4880            // `Maximum_Iterations_Exceeded`. Retrying it keeps that rescue.
4881            // A tiny-step stop upstream's own rule reached (multiplier step
4882            // settled) is not retried, as before.
4883            ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
4884                self.tiny_step_dual_stall.get()
4885            }
4886            ApplicationReturnStatus::SolvedToAcceptableLevel => {
4887                self.mu_strategy_fallback_was_set() || !self.caller_set_termination_policy()
4888            }
4889            _ => false,
4890        };
4891        if !retry_worthy {
4892            return first_status;
4893        }
4894        // gh#857: decline the flip when the solve that just failed escalated
4895        // the factorization and an escalation-off re-solve is enabled.
4896        //
4897        // The mu flip is a *blind* second opinion: it changes the barrier
4898        // schedule and hopes. A `feral_increase_quality` escalation is a
4899        // *measured* fact about the run that just failed -- FERAL reroutes
4900        // which pivots are taken and never steps back down, so every
4901        // iteration after the first escalation, restoration sub-solves
4902        // included, ran on a trajectory the defaults do not describe. Flipping
4903        // `mu_strategy` while leaving that in place is not a controlled
4904        // experiment: it varies the knob that is not implicated and holds the
4905        // one that is.
4906        //
4907        // It is not free either. On `square_flowsheet_resto`'s lbfgs leg the
4908        // flip escalates 25 times all over again, burns a second full
4909        // 3000-iteration budget, and ends no better than the first -- after
4910        // which rung 4 of the second-opinion ladder converges the model in 178
4911        // with the escalation off. That is 6178 real iterations to reach an
4912        // answer that 3178 reach without the flip, and the flip contributes
4913        // nothing to it. Measured both ways: `mu_strategy=adaptive` alone
4914        // still gives 3000 with 25 escalations, and `feral_increase_quality=no`
4915        // gives 178 under *either* mu strategy.
4916        //
4917        // Why decline rather than fold the escalation off into this retry: the
4918        // backend factory is minted from an options snapshot taken by the
4919        // caller *before* `solve()`, so writing `feral_increase_quality` here
4920        // is too late to reach the retry's linear solver. `mu_strategy` is read
4921        // per-solve from the option table and is not; that asymmetry is why
4922        // this layer can only choose whether to spend the solve, not what to
4923        // spend it on.
4924        //
4925        // Restricted to `Maximum_Iterations_Exceeded`, which is exactly the
4926        // status rung 4 opens on. A `Solved_To_Acceptable_Level` exit opens no
4927        // escalation rung, so declining there would drop a retry with nothing
4928        // in its place. Gated on `feral_increase_quality_retry`, so setting
4929        // that option to `no` restores the historical behaviour on both sides
4930        // at once: no rung 4, and no decline here.
4931        //
4932        // The one place the stand-down is not paired with the rung is the
4933        // multi-start paths (`solve_nlp_batch`, the CLI's `minima` search),
4934        // which deliberately do not drive the ladder -- a failed start there is
4935        // routine and extra solves per start multiply. Those paths lose the
4936        // flip on an escalating budget exit and gain nothing back, which is the
4937        // one behaviour change here that is not a strict improvement. It is the
4938        // same trade they already take on every other rung, and for the same
4939        // reason: an escalating capped start is one of many, and doubling its
4940        // cost to re-run the trajectory the escalation governs is the worse end
4941        // of it.
4942        if matches!(
4943            first_status,
4944            ApplicationReturnStatus::MaximumIterationsExceeded
4945        ) && self.quality_escalations.get() >= 1
4946            && self
4947                .options
4948                .get_bool_value("feral_increase_quality_retry", "")
4949                .map(|(v, _found)| v)
4950                .unwrap_or(true)
4951            && self
4952                .options
4953                .get_bool_value("feral_increase_quality", "")
4954                .map(|(v, _found)| v)
4955                .unwrap_or(true)
4956        {
4957            return first_status;
4958        }
4959        // Flip the strategy for one retry. The parser maps "adaptive" →
4960        // Adaptive and every other value (incl. unset) → Monotone, so the
4961        // opposite of an explicit "adaptive" is "monotone" and the
4962        // opposite of anything else is "adaptive".
4963        let saved_mu_strategy = self.options.snapshot_entry("mu_strategy");
4964        let was_adaptive = self.effective_mu_strategy_is_adaptive();
4965        let flipped = if was_adaptive { "monotone" } else { "adaptive" };
4966        // Unwind cover only; the normal path restores the same snapshot
4967        // below. A panic in the retry used to leave the flipped strategy in
4968        // the option table of an application `ffi_guard` keeps.
4969        let mut scope = RetryOptionScope::enter(self, &["mu_strategy"], true);
4970        let _ = scope
4971            .options
4972            .set_string_value("mu_strategy", flipped, true, false);
4973        // Floor the *answer*, not just the status (pounce#870).
4974        //
4975        // The promote-only-on-`Solve_Succeeded` rule below has always floored
4976        // the status. It did not floor the point, and the point is what the
4977        // caller consumes: `optimize_constrained` calls the user TNLP's
4978        // `finalize_solution` once per attempt, so a retry that fails to
4979        // promote still overwrites the answer with its own iterate, and the
4980        // statistics with its own residuals. The result is a status describing
4981        // one attempt attached to a point from another.
4982        //
4983        // Measured on a random corpus of 1200 nonconvex models, 20 of them
4984        // (1.7%) returned a materially worse point under an unchanged status,
4985        // the worst flipping sign: a `Maximum_Iterations_Exceeded` exit went
4986        // from -2.38e7 to +7.89e7, and a `Solved_To_Acceptable_Level` one from
4987        // -3.83e7 to +3.41e5 while its reported `final_kkt_error` rose to
4988        // 2.85e-4 — 285x the `acceptable_tol` its own status names, so the
4989        // report contradicted itself. The known example on record understated
4990        // it by three orders: `autocorr_bern55-06` swapping -2304.0000278 for
4991        // -2320.0000298 is the same defect at 0.07%.
4992        //
4993        // This is the floor idiom the rest of the codebase already uses for a
4994        // bet it might lose — `honour_neg_curv_floor` (gh#797),
4995        // `honour_decline_floor` (gh#534), `honour_best_acceptable_after_dual_
4996        // guard` — applied to the one bet that was only half-floored.
4997        //
4998        // Not extended to `run_with_l1_fallback`, which carries the same
4999        // caveat in its doc comment but is NOT the same call: its retry
5000        // deliberately reports the l1-best least-infeasible point, which the
5001        // option help calls informative in its own right. Changing that needs
5002        // its own measurement.
5003        let solution_floor = scope.last_finalize.borrow().clone();
5004        let certificate_floor = SolutionCertificate::of(&scope.statistics.borrow());
5005        let trace_floor = *scope.last_iter_stats.borrow();
5006        // gh#1042 C1: the first attempt's verdict and cost, recorded before
5007        // the re-solve overwrites the statistics. A promoted re-solve reports
5008        // its own `iteration_count` and `restoration_calls` only, and without
5009        // this nothing says the first attempt got worse.
5010        let mut record = {
5011            let st = scope.statistics.borrow();
5012            pounce_nlp::MuStrategyFallbackRecord {
5013                base_status: first_status.upstream_name().to_string(),
5014                base_iteration_count: st.iteration_count,
5015                base_restoration_calls: st.restoration_calls,
5016                base_objective: st.final_objective,
5017                retry_mu_strategy: flipped.to_string(),
5018                ..Default::default()
5019            }
5020        };
5021        *scope.mu_strategy_fallback_record.borrow_mut() = Some(record.clone());
5022        let retry_status = scope.optimize_constrained(Rc::clone(&tnlp));
5023        record.retry_status = retry_status.upstream_name().to_string();
5024        record.retry_iteration_count = scope.statistics.borrow().iteration_count;
5025        record.promoted = matches!(retry_status, ApplicationReturnStatus::SolveSucceeded);
5026        *scope.mu_strategy_fallback_record.borrow_mut() = Some(record.clone());
5027        // Put the entry back exactly as it was, absence as absence. Writing
5028        // the resolved strategy back instead marked it caller-set, and a
5029        // caller-set `mu_strategy` switches this fallback off
5030        // (`is_mu_strategy_fallback_enabled`): every later solve on the same
5031        // application lost it, which the declared-complementarity driver --
5032        // one application, every stage -- hit whenever an early stage fell
5033        // back. An unset entry still resolves the same way, so the view the
5034        // write-back was preserving is unchanged.
5035        scope
5036            .options
5037            .restore_entry("mu_strategy", saved_mu_strategy);
5038        drop(scope);
5039        self.statistics.borrow_mut().mu_strategy_fallback = Some(record);
5040        // A retry the caller's intermediate callback stopped reports the
5041        // stop, at the point it stopped: the first attempt's status would
5042        // override the request.
5043        if matches!(
5044            retry_status,
5045            ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::UserRequestedStop
5046        ) {
5047            return retry_status;
5048        }
5049        // The bet lost. Put the first attempt's answer back, so the point and
5050        // the statistics describe the same solve the returned status does.
5051        //
5052        // `finalize_solution` therefore runs once more than the number of
5053        // attempts on this path. That is deliberate, and is the cheaper of the
5054        // two corrections: withholding the retry's `finalize_solution` until it
5055        // is known to promote would deprive a caller that watches the callback
5056        // of the retry's progress, and buys nothing, since the retry's payload
5057        // is discarded either way.
5058        if let Some(floor) = solution_floor {
5059            tracing::debug!(target: "pounce::algorithm",
5060                "[POUNCE] the mu_strategy_fallback retry did not promote \
5061                 ({:?} is not Solve_Succeeded); restoring the first attempt's \
5062                 solution and statistics alongside its status (pounce#870).",
5063                retry_status);
5064            self.restore_floor(&floor, &tnlp);
5065            certificate_floor.restore_into(&mut self.statistics.borrow_mut());
5066            // Third sink: the per-iteration trace. Consumers accumulate that
5067            // themselves from `intermediate_callback` — the CasADi plugin
5068            // pushes into its own vectors and clears once per `nlpsol` call —
5069            // so POUNCE cannot rewind it, and both attempts concatenate into
5070            // one trace. Restoring the certificate without touching the trace
5071            // leaves the reported numbers describing attempt 1 while the trace
5072            // ends on the retry, which breaks the invariant
5073            // `casadi/test_parity.py` states outright: "The final numbers and
5074            // the end of the trace are the same quantities, and must not come
5075            // from two different places."
5076            //
5077            // Re-emitting the winning attempt's final row restores it. It is
5078            // the trace analogue of `FinalizeSnapshot::replay` above, and it
5079            // makes the property hold by construction rather than by hoping a
5080            // consumer resets on an attempt boundary — nothing in the callback
5081            // contract marks one, and gh#634 is what happens when a consumer
5082            // has to guess the scope of a trace.
5083            //
5084            // The row is a real iterate that was already sent once, not a
5085            // synthesized one, so a trace still contains only points the solver
5086            // actually visited.
5087            // The replayed row is the reported answer's last iterate, so a
5088            // `false` here is a stop request on it, honored as a solve's
5089            // final-iterate stop is.
5090            if let Some(stats) = trace_floor {
5091                if !tnlp.borrow_mut().intermediate_callback(
5092                    stats,
5093                    &TnlpIpoptData::default(),
5094                    &TnlpIpoptCq::default(),
5095                ) {
5096                    return ApplicationReturnStatus::UserRequestedStop;
5097                }
5098            }
5099        }
5100        first_status
5101    }
5102
5103    /// Is the gh#884 biactive dual-divergence retry enabled? Default
5104    /// `yes`; `dual_divergence_retry=no` is the kill switch, and
5105    /// restores the pre-gh#884 behaviour outright (no detector cost, no
5106    /// second solve, and the base attempt's verdict returned unchanged).
5107    fn is_dual_divergence_retry_enabled(&self) -> bool {
5108        self.options
5109            .get_bool_value("dual_divergence_retry", "")
5110            .map(|(v, _found)| v)
5111            .unwrap_or(true)
5112    }
5113
5114    /// gh#884: throw the iterate away and solve again from scratch with
5115    /// `perturb_always_cd` on, when the base solve settled its primal
5116    /// while its multipliers ran away.
5117    ///
5118    /// # The defect
5119    ///
5120    /// On an MPCC lowered through an exact complementarity product
5121    /// `G·H = 0`, a pair that is **biactive** at the solution — both
5122    /// `G` and `H` zero — leaves that row's gradient
5123    /// `H∇G + G∇H` identically zero. The row is still there, so its
5124    /// multiplier is *arbitrary* rather than nonexistent, and the IPM
5125    /// drives it to infinity while the primal iterate sits on the
5126    /// answer. Because the convergence verdict is reached on an
5127    /// `s_d`-normalised aggregate and `s_d` grows with the mean
5128    /// multiplier magnitude, the aggregate reads clean: MacMPEC's
5129    /// `qpec_small` under `ncp_eq`/`prod_eq` reported
5130    /// `Solved_To_Acceptable_Level` at an *unscaled* dual infeasibility
5131    /// of `7.9e+04`.
5132    ///
5133    /// # Why a retry rather than a gate
5134    ///
5135    /// Four other shapes of fix were measured and rejected — a Hessian
5136    /// sparsity hypothesis, engaging `delta_c` *in flight*, flipping
5137    /// `perturb_always_cd` on globally, and putting a dual ceiling on
5138    /// the acceptable-level gate. `dev-notes/mpcc-biactive-dual-
5139    /// divergence.md` records all four with numbers. The short version:
5140    /// by the time the runaway is visible this iterate is unrecoverable,
5141    /// so the only action left is to *stop using it*; and the remedy
5142    /// that works — `perturb_always_cd=yes` — is measured to return a
5143    /// wrong answer reported as success on `ralph1`
5144    /// (`Solve_Succeeded` at `f = -2.71e-5`, below `f* = 0`), so it
5145    /// cannot be turned on for everyone.
5146    ///
5147    /// **The detector is therefore the entire safety barrier**, and the
5148    /// promotion gate below is the second one. `ralph1` is exactly the
5149    /// model the detector must not fire on, and
5150    /// `IpoptAlgorithm`'s scale-relative step floor is what keeps it
5151    /// from doing so: `qpec_small` settles to `4.3e-8` while `ralph1`
5152    /// bottoms out at `7.2e-3`, five orders apart.
5153    ///
5154    /// # The gate
5155    ///
5156    /// The retry's verdict replaces the base one only when **all** of:
5157    ///
5158    /// 1. the base attempt saw the signature (a converged primal, a step
5159    ///    at zero, and an unscaled `‖∇L‖∞` far above `dual_inf_tol`, all
5160    ///    at one iterate — see `IpoptAlgorithm::dual_divergence_signature`);
5161    /// 2. the base status is `Solved_To_Acceptable_Level` or
5162    ///    `Restoration_Failed` — the two verdicts the vanishing-gradient
5163    ///    row produces directly. Generic exhaustion exits are excluded;
5164    ///    `deb7` under L-BFGS is why, and the reason is in the code
5165    ///    below;
5166    /// 3. the retry returns `Solve_Succeeded`;
5167    /// 4. the retry's claimed success is **real in the model's own
5168    ///    units** — unscaled KKT error at or below `tol`-scale, which is
5169    ///    the property the base attempt failed and the whole point of
5170    ///    the issue;
5171    /// 5. the retry's unscaled KKT error is *strictly better* than the
5172    ///    base attempt's.
5173    ///
5174    /// Otherwise the base attempt's status, point and statistics are all
5175    /// put back, by the same three-sink floor
5176    /// [`Self::run_with_mu_strategy_fallback`] uses and for the same
5177    /// reason (pounce#870): a status describing one attempt attached to
5178    /// a point from another is worse than either.
5179    fn run_with_dual_divergence_retry(
5180        &mut self,
5181        tnlp: Rc<RefCell<dyn TNLP>>,
5182    ) -> ApplicationReturnStatus {
5183        let first_status = self.dispatch_standard_solve(Rc::clone(&tnlp));
5184        if !self.dual_divergence_signature.get() {
5185            return first_status;
5186        }
5187        // Which base verdicts this remedy is *for*.
5188        //
5189        // Two, and the narrowing was bought with a measurement. The
5190        // detector is a statement about the iterate; it is not a
5191        // statement that `perturb_always_cd` will help. `deb7` under
5192        // L-BFGS is the corpus case that separates the two: the
5193        // signature is real there — iteration 346, scale-relative step
5194        // `6.5e-6`, `inf_pr` `3.0e-12`, unscaled `inf_du` `9.2e5`,
5195        // which is an order *above* the gh#884 reproducer's `7.9e4`, so
5196        // no dual floor excludes it, and the step conjunct separates the
5197        // two only by fitting the default onto one fixture and spending
5198        // the margin that holds `ralph1` out — and the retry still does
5199        // not work: 3000 iterations to
5200        // `Maximum_Iterations_Exceeded` at an unscaled KKT error of
5201        // `6.7e1` against the base attempt's `9.9e1`. Pure cost, 4x the
5202        // base trajectory, on a fixture whose verdict does not move.
5203        //
5204        // The exclusion is by *status*, so it is only as complete as the
5205        // status is stable, and on this very fixture it is not. `deb7`
5206        // reaches `Error_In_Step_Computation` at default options and is
5207        // out; under `limited_memory_ls_failure_restarts=1` (gh#818's
5208        // rung, off by default) it reaches `Restoration_Failed` instead
5209        // and is therefore *in*. It used to pay exactly the cost above
5210        // there — 6.1 s to 25.2 s wall clock for the same
5211        // `Restoration_Failed` verdict and a declined retry, which is
5212        // gh#887. That is the price of scoping by status rather than by
5213        // model, and it is why the second gate below scopes by the
5214        // *answer* instead: `deb7` now declines before spending
5215        // anything. The status scope is kept because it is cheap and
5216        // reads on the verdict a caller sees, but it is not what is
5217        // being relied on.
5218        //
5219        // What separates them is the *status*, and it separates them
5220        // for a reason rather than by luck:
5221        //
5222        //  * `Solved_To_Acceptable_Level` is gh#884 verbatim — the
5223        //    runaway laundered itself through `s_d` into a success.
5224        //  * `Restoration_Failed` is the same defect one step earlier:
5225        //    the rows whose gradients vanished drive the solve into
5226        //    restoration, and restoration cannot repair a row that has
5227        //    no gradient. It is where the `qpec_small` TNLP fixture
5228        //    lands (unscaled KKT `3.3e11`).
5229        //  * `Error_In_Step_Computation` and
5230        //    `Maximum_Iterations_Exceeded` are generic exhaustion
5231        //    exits. Every hard model reaches them, for every reason.
5232        //    Retrying *those* is not "repair the runaway", it is "try
5233        //    again harder" — which is what `mu_strategy_fallback` and
5234        //    the second-opinion ladder already are, and `deb7` is what
5235        //    that costs.
5236        //
5237        // `Solve_Succeeded` is excluded because it is already the best
5238        // verdict available and its certificate has already been
5239        // checked in the model's own units; there is nothing to buy.
5240        //
5241        // Worst-case cost is therefore one extra solve under the
5242        // caller's own `max_iter` — the same contract
5243        // `run_with_mu_strategy_fallback` already has.
5244        //
5245        // Deliberately **not** deferred to `TERMINATION_POLICY_OPTIONS`
5246        // the way the μ flip's acceptable-level trigger is (gh#757).
5247        // That deferral exists because the μ flip returns a different
5248        // *local solution*, so laundering a caller's deliberate
5249        // downgrade loses a signal they asked for. This retry cannot:
5250        // conjunct 4 requires the promoted answer to satisfy the KKT
5251        // conditions in the model's own units, which a downgrade the
5252        // caller induced on purpose does not. And the gh#884 reproducer
5253        // sets `tol=1e-8` explicitly, so deferring would decline the
5254        // retry on the one case the issue is about.
5255        let retry_worthy = matches!(
5256            first_status,
5257            ApplicationReturnStatus::SolvedToAcceptableLevel
5258                | ApplicationReturnStatus::RestorationFailed
5259        );
5260        if !retry_worthy {
5261            return first_status;
5262        }
5263        let base_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
5264        // The runaway has to be the *whole* residual of the answer being
5265        // reported.
5266        //
5267        // The detector is a statement about an *iterate*, and the iterate
5268        // it fires on need not be the one the solve ends at. A run can
5269        // pass through a settled point with a diverged multiplier, work
5270        // its way back down, and report something ordinary — and then
5271        // there is nothing left here for `perturb_always_cd` to repair,
5272        // whatever the trajectory did in the middle. This is what makes
5273        // "one extra solve" a cost the caller only pays on a run that
5274        // still *exhibits* the defect (gh#887).
5275        //
5276        // The test is `runaway_is_the_whole_residual`, which reads only
5277        // the reported answer and only as a ratio within it; its doc
5278        // comment carries the rule and the measured populations.
5279        let (base_viol, base_compl) = {
5280            let st = self.statistics.borrow();
5281            (st.final_unscaled_constr_viol, st.final_unscaled_compl)
5282        };
5283        let base_dual_inf = self.statistics.borrow().final_unscaled_dual_inf;
5284        if !runaway_is_the_whole_residual(
5285            base_dual_inf,
5286            base_viol,
5287            base_compl,
5288            self.options
5289                .get_numeric_value("dual_divergence_retry_du_floor", "")
5290                .map(|(v, _)| v)
5291                .unwrap_or(DUAL_DIV_RETRY_DU_FLOOR),
5292        ) {
5293            tracing::debug!(target: "pounce::algorithm",
5294                "[POUNCE] gh#884: the signature fired mid-trajectory, but the \
5295                 answer being reported is not a converged point with a runaway \
5296                 multiplier — unscaled dual {:.3e} against viol {:.3e} and \
5297                 complementarity {:.3e}. Nothing here for perturb_always_cd to \
5298                 repair, so no retry (gh#887).",
5299                base_dual_inf, base_viol, base_compl);
5300            return first_status;
5301        }
5302        // Floor all three sinks — solution payload, certificate, and the
5303        // last trace row — exactly as the μ fallback does (pounce#870).
5304        let solution_floor = self.last_finalize.borrow().clone();
5305        let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
5306        let trace_floor = *self.last_iter_stats.borrow();
5307        tracing::debug!(target: "pounce::algorithm",
5308            "[POUNCE] gh#884: the primal settled while the multipliers ran away \
5309             (base {:?}, unscaled KKT {:.3e}); re-solving from scratch with \
5310             perturb_always_cd=yes.",
5311            first_status, base_unscaled_kkt);
5312        let saved_cd = self.options.snapshot_entry("perturb_always_cd");
5313        // Unwind cover only, as for the μ-strategy fallback; the normal path
5314        // restores the same snapshot below.
5315        let mut scope = RetryOptionScope::enter(self, &["perturb_always_cd"], true);
5316        let _ = scope
5317            .options
5318            .set_string_value("perturb_always_cd", "yes", true, false);
5319        let mark = scope.begin_counted_resolve();
5320        let retry_status = scope.dispatch_standard_solve(Rc::clone(&tnlp));
5321        scope.end_counted_resolve(mark);
5322        // Exactly as it was, absence as absence, so a later solve on the
5323        // same application does not read a caller-set `no`.
5324        scope.options.restore_entry("perturb_always_cd", saved_cd);
5325        drop(scope);
5326        // A retry the caller's intermediate callback stopped reports the
5327        // stop, at the point it stopped; the comparison below would put the
5328        // first attempt's status back over the request.
5329        if retry_status == ApplicationReturnStatus::UserRequestedStop {
5330            return retry_status;
5331        }
5332        let retry_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
5333        let retry_viol = self.statistics.borrow().final_unscaled_constr_viol;
5334        // Conjuncts 3, 4 and 5. Conjunct 4 is the one that distinguishes
5335        // this gate from every other promote-on-`Solve_Succeeded` retry
5336        // in this file: the base attempt's defect *was* a status that its
5337        // own unscaled residual contradicts, so promoting on the status
5338        // alone would reproduce the bug one attempt later.
5339        let accept_tol = self.dual_divergence_retry_accept_tol();
5340        let claimed_success_is_real = (retry_unscaled_kkt <= accept_tol
5341            && retry_viol <= accept_tol)
5342            || retry_certificate_dominates(
5343                base_unscaled_kkt,
5344                retry_unscaled_kkt,
5345                retry_viol,
5346                accept_tol,
5347            );
5348        // Conjuncts 6 and 7 — see `retry_answer_is_admissible`. Everything
5349        // above this line ranks the two attempts on their *certificates*;
5350        // this ranks them as *answers*, which is what a caller receives.
5351        // Without it a better multiplier is allowed to buy a worse point:
5352        // measured, `-13.0057 -> -1.2072` on a random QPEC, and
5353        // `+1.82e-09 -> -6.61e-05` on `scholtes4`, whose `f*` is exactly 0.
5354        let retry_obj = self.statistics.borrow().final_objective;
5355        // `obj_scaling_factor < 0` is how a maximization is posed, and
5356        // `final_objective` is the user's signed objective, so the
5357        // comparison direction has to follow it (R2).
5358        let sense = if self
5359            .options
5360            .get_numeric_value("obj_scaling_factor", "")
5361            .map(|(v, _)| v)
5362            .unwrap_or(1.0)
5363            < 0.0
5364        {
5365            -1.0
5366        } else {
5367            1.0
5368        };
5369        let answer_is_admissible = retry_answer_is_admissible(
5370            certificate_floor.objective,
5371            certificate_floor.unscaled_constr_viol,
5372            retry_obj,
5373            retry_viol,
5374            self.dual_divergence_retry_accept_tol(),
5375            sense,
5376        );
5377        let promote = matches!(retry_status, ApplicationReturnStatus::SolveSucceeded)
5378            && claimed_success_is_real
5379            && retry_unscaled_kkt < base_unscaled_kkt
5380            && answer_is_admissible;
5381        // Say on the console which of the two answers shipped.
5382        //
5383        // The per-attempt end summary cannot: `emit_end_summary` runs
5384        // inside `optimize_constrained`, once per attempt, and the
5385        // promotion is decided after the last of them. So a summary that
5386        // reported the promotion would report `false` on the very run that
5387        // promotes, contradicting the JSON report written from the same
5388        // statistics. The summary reports the *signature*, which is true
5389        // when it prints; this line reports the *outcome*, printed once,
5390        // where it is known.
5391        //
5392        // Gated on `print_level >= 1`, matching `emit_end_summary` — the
5393        // block this line follows.
5394        let console_output = match self.options.get_integer_value("print_level", "") {
5395            Ok((v, true)) => v >= 1,
5396            _ => true,
5397        };
5398        if console_output {
5399            println!();
5400            if promote {
5401                println!(
5402                    "gh#884 dual-divergence retry: promoted — unscaled KKT error \
5403                     {base_unscaled_kkt:.4e} -> {retry_unscaled_kkt:.4e}."
5404                );
5405            } else if !answer_is_admissible {
5406                // A distinct line, because this decline looks like a
5407                // contradiction otherwise: the retry converged, its
5408                // certificate is clean, and it was still refused. Say
5409                // which of the two rules refused it and on what numbers,
5410                // so the reader is not left comparing KKT errors that
5411                // had nothing to do with it.
5412                println!(
5413                    "gh#884 dual-divergence retry: declined on the ANSWER, not the \
5414                     certificate — the retry converged (unscaled KKT error \
5415                     {retry_unscaled_kkt:.4e} against the base attempt's \
5416                     {base_unscaled_kkt:.4e}) but its objective {retry_obj:.8e} at \
5417                     constraint violation {retry_viol:.4e} is not admissible next to \
5418                     the base attempt's {:.8e} at {:.4e}; the base attempt's answer \
5419                     is the one reported.",
5420                    certificate_floor.objective, certificate_floor.unscaled_constr_viol
5421                );
5422            } else {
5423                println!(
5424                    "gh#884 dual-divergence retry: declined ({retry_status:?}, \
5425                     unscaled KKT error {retry_unscaled_kkt:.4e} against the base \
5426                     attempt's {base_unscaled_kkt:.4e}); the base attempt's answer \
5427                     is the one reported."
5428                );
5429            }
5430        }
5431        if promote {
5432            self.dual_divergence_retry_promoted.set(true);
5433            self.statistics.borrow_mut().dual_divergence_retry_promoted = true;
5434            tracing::debug!(target: "pounce::algorithm",
5435                "[POUNCE] gh#884: the retry promoted — unscaled KKT {:.3e} \
5436                 (base {:.3e}).",
5437                retry_unscaled_kkt, base_unscaled_kkt);
5438            return retry_status;
5439        }
5440        if let Some(floor) = solution_floor {
5441            tracing::debug!(target: "pounce::algorithm",
5442                "[POUNCE] gh#884: the retry did not promote ({:?}, unscaled KKT \
5443                 {:.3e} vs base {:.3e}); restoring the first attempt's solution \
5444                 and statistics alongside its status.",
5445                retry_status, retry_unscaled_kkt, base_unscaled_kkt);
5446            self.restore_floor(&floor, &tnlp);
5447            certificate_floor.restore_into(&mut self.statistics.borrow_mut());
5448            // The signature belongs to the attempt whose numbers are now
5449            // reported, and `certificate_floor` does not carry it.
5450            self.statistics.borrow_mut().dual_divergence_signature = true;
5451            // The replayed row is the reported answer's last iterate, so a
5452            // `false` here is a stop request on it, honored as a solve's
5453            // final-iterate stop is.
5454            if let Some(stats) = trace_floor {
5455                if !tnlp.borrow_mut().intermediate_callback(
5456                    stats,
5457                    &TnlpIpoptData::default(),
5458                    &TnlpIpoptCq::default(),
5459                ) {
5460                    return ApplicationReturnStatus::UserRequestedStop;
5461                }
5462            }
5463        }
5464        first_status
5465    }
5466
5467    /// Is the gh#983 solve-quality audit enabled? Default `yes`;
5468    /// `solve_quality_audit=no` restores the pre-gh#983 behaviour (no
5469    /// re-scale retry, no `warnings`).
5470    fn is_solve_quality_audit_enabled(&self) -> bool {
5471        self.options
5472            .get_bool_value("solve_quality_audit", "")
5473            .map(|(v, _found)| v)
5474            .unwrap_or(true)
5475    }
5476
5477    /// gh#983 items 2 and 3: audit a success verdict against the *model's
5478    /// own* scale, re-solve once when the scaling is what is wrong, and say
5479    /// so in `statistics.warnings` when it is not fixed.
5480    ///
5481    /// The strict gate judges a scaled aggregate. Gradient-based scaling
5482    /// measures the objective gradient once, at the starting point, and
5483    /// freezes the factor; it also only ever scales *down*. Two failures
5484    /// follow, one on each side, and neither is visible to a gate that reads
5485    /// the scaled space:
5486    ///
5487    /// * **Frozen at a huge-gradient start** (LJ7 from a start whose gradient
5488    ///   is `3e8`): the factor is `3e-7`, the scaled test passes at a point
5489    ///   whose unscaled stationarity residual is `2.9e-2`, and no amount of
5490    ///   continued iteration under that factor improves it
5491    ///   (`obj_scale_certificate_threshold` already tries, and its budget is
5492    ///   spent). The fix is the one the issue names: re-evaluate the scaling
5493    ///   *at the returned point*, where the gradient is small, and solve
5494    ///   again from there.
5495    /// * **Small user objective** (profit in M$/L, gradient `1e-5`): nothing
5496    ///   scales up, so the absolute complementarity floor `~mu` is a
5497    ///   `2.5e-4` fraction of the objective gradient and the answer sits
5498    ///   `4.7e-4` (relative) off the optimum on an active bound. The retry
5499    ///   multiplies the objective by `1/max|grad f|` and solves again from
5500    ///   the returned point.
5501    ///
5502    /// Both retries are *bets that are tested*, floored exactly like the
5503    /// gh#884 and mu-strategy retries: the retry is promoted only when it
5504    /// returns a clean verdict, its unscaled KKT error is no worse, its
5505    /// complementarity (small-objective case) or unscaled KKT error (frozen
5506    /// case) is strictly better, and its answer is admissible next to the
5507    /// base attempt's. Otherwise the base attempt's status, point and
5508    /// statistics are restored and a structured warning is attached.
5509    fn audit_solve_quality(
5510        &mut self,
5511        tnlp: Rc<RefCell<dyn TNLP>>,
5512        first_status: ApplicationReturnStatus,
5513    ) -> ApplicationReturnStatus {
5514        if !self.is_solve_quality_audit_enabled()
5515            || !matches!(
5516                first_status,
5517                ApplicationReturnStatus::SolveSucceeded
5518                    | ApplicationReturnStatus::SolvedToAcceptableLevel
5519            )
5520        {
5521            return first_status;
5522        }
5523        let num = |name: &str, default: Number| -> Number {
5524            self.options
5525                .get_numeric_value(name, "")
5526                .ok()
5527                .and_then(|(v, f)| f.then_some(v))
5528                .unwrap_or(default)
5529        };
5530        let acceptable_tol = num("acceptable_tol", 1e-6);
5531        let masked_threshold = num("obj_scale_certificate_threshold", 1e-4);
5532        // `equilibration-based` is registered but pounce runs it as
5533        // gradient-based (see `algorithm_builder_from_options`), so it gets the
5534        // gradient-based audit.
5535        let gradient_based = self
5536            .options
5537            .get_string_value("nlp_scaling_method", "")
5538            .ok()
5539            .and_then(|(v, f)| f.then_some(v))
5540            .map(|v| v == "gradient-based" || v == "equilibration-based")
5541            .unwrap_or(true);
5542        let base = self.statistics.borrow().clone();
5543        // gh#983 review item 2: measure the objective gradient at the point
5544        // being returned, so the audit asks what re-evaluating the scaling
5545        // *there* would do instead of keying on the start alone.
5546        let final_grad_max = self
5547            .last_finalize
5548            .borrow()
5549            .as_ref()
5550            .and_then(|f| {
5551                let mut g = vec![0.0; f.x.len()];
5552                tnlp.borrow_mut()
5553                    .eval_grad_f(&f.x, true, &mut g)
5554                    .then(|| g.iter().fold(0.0_f64, |m, v| m.max(v.abs())))
5555            })
5556            .unwrap_or(Number::NAN);
5557        let rescaled_obj_scale = if final_grad_max.is_finite() {
5558            pounce_nlp::orig_ipopt_nlp::gradient_obj_scale(
5559                final_grad_max,
5560                num("nlp_scaling_max_gradient", 100.0),
5561                num("nlp_scaling_min_value", 1e-8),
5562                num("nlp_scaling_obj_target_gradient", 0.0),
5563            )
5564        } else {
5565            Number::NAN
5566        };
5567        let input = ScaleAuditInput {
5568            tol: num("tol", 1e-8),
5569            obj_scale: base.final_obj_scaling_factor,
5570            rescaled_obj_scale,
5571            start_grad_max: base.start_obj_grad_max,
5572            final_grad_max,
5573            unscaled_kkt: base.final_unscaled_kkt_error,
5574            unscaled_compl: base.final_unscaled_compl,
5575            acceptable_tol,
5576            masked_threshold,
5577            warm_start: self
5578                .options
5579                .get_bool_value("warm_start_init_point", "")
5580                .map(|(v, _)| v)
5581                .unwrap_or(false),
5582        };
5583        let grad_scale = input.grad_scale();
5584        let trigger = scale_audit_trigger(input);
5585        let mut status = first_status;
5586        match trigger {
5587            Some(ScaleAudit::SmallObjective) => {
5588                self.statistics
5589                    .borrow_mut()
5590                    .warnings
5591                    .push(scale_audit_warning(
5592                        ScaleAudit::SmallObjective,
5593                        &base,
5594                        grad_scale,
5595                        acceptable_tol,
5596                        "",
5597                    ));
5598            }
5599            Some(kind) if gradient_based => {
5600                status = self.run_scale_retry(tnlp, first_status, kind, grad_scale);
5601            }
5602            Some(kind) => {
5603                // The caller owns the scaling (`none` / `user-scaling`): not ours
5604                // to override, but the verdict still deserves its caveat.
5605                self.statistics
5606                    .borrow_mut()
5607                    .warnings
5608                    .push(scale_audit_warning(
5609                        kind,
5610                        &base,
5611                        grad_scale,
5612                        acceptable_tol,
5613                        "was not attempted: nlp_scaling_method is not gradient-based",
5614                    ));
5615            }
5616            None => {}
5617        }
5618        status
5619    }
5620
5621    /// gh#983 review items 1 and 3: the verdict-level half of the audit, run
5622    /// **once**, on the run whose point is being returned -- after every
5623    /// retry and fallback has decided.
5624    ///
5625    /// * The item-5 downgrade (a strict verdict on a run that saw the gh#884
5626    ///   signature and still carries an unscaled dual infeasibility above
5627    ///   `max(acceptable_tol, 1e-3)`) used to run inside each attempt, so it
5628    ///   could turn a base `Solve_Succeeded` into the acceptable level *before*
5629    ///   the gh#884 retry decided, reopening that retry for a case it was
5630    ///   deliberately not widened to; and it read the cumulative signature, so
5631    ///   a promoted mu-fallback run that never showed it was downgraded for its
5632    ///   discarded sibling. It now reads
5633    ///   `returned_run_dual_divergence_signature`, which the certificate floor
5634    ///   saves and restores with the rest of the run's tally.
5635    /// * The model-scale caveats ([`quality_warnings`]) are attached here, and
5636    ///   deduplicated by code, so a code appears at most once.
5637    fn finalize_quality_verdict(
5638        &mut self,
5639        status: ApplicationReturnStatus,
5640    ) -> ApplicationReturnStatus {
5641        if !self.is_solve_quality_audit_enabled() {
5642            return status;
5643        }
5644        let acceptable_tol = self.dual_divergence_retry_accept_tol();
5645        let mut status = status;
5646        if matches!(status, ApplicationReturnStatus::SolveSucceeded) {
5647            let (sig, du, du_scale) = {
5648                let st = self.statistics.borrow();
5649                (
5650                    st.returned_run_dual_divergence_signature,
5651                    st.final_unscaled_dual_inf,
5652                    st.final_unscaled_dual_scale,
5653                )
5654            };
5655            // gh#983 item 5. `1e-3` is `UNSCALED_DUAL_WARN`; the measured
5656            // population behind it is in that constant's doc. A residual at
5657            // the rounding floor of its own terms is not evidence of a miss
5658            // (gh#1000, II.22): see `UNSCALED_DUAL_ROUNDOFF_ULPS`.
5659            if sig
5660                && du.is_finite()
5661                && du > acceptable_tol.max(UNSCALED_DUAL_WARN)
5662                && !unscaled_dual_inf_is_roundoff(du, du_scale)
5663            {
5664                status = ApplicationReturnStatus::SolvedToAcceptableLevel;
5665                push_warning_once(
5666                    &mut self.statistics.borrow_mut().warnings,
5667                    format!(
5668                        "unscaled_dual_inf_above_acceptable: the run saw a settled primal with a \
5669                         runaway multiplier (gh#884) and the returned point's unscaled dual \
5670                         infeasibility is {du:.2e}, above {:.0e}; the status is downgraded from \
5671                         Solve_Succeeded to Solved_To_Acceptable_Level",
5672                        acceptable_tol.max(UNSCALED_DUAL_WARN)
5673                    ),
5674                );
5675            }
5676        }
5677        let st = self.statistics.borrow().clone();
5678        let extra = quality_warnings(
5679            status,
5680            st.final_unscaled_dual_inf,
5681            st.final_unscaled_dual_scale,
5682            self.options
5683                .get_numeric_value("dual_inf_tol", "")
5684                .map(|(v, _)| v)
5685                .unwrap_or(1.0),
5686            acceptable_tol,
5687            self.options
5688                .get_numeric_value("bound_relax_factor", "")
5689                .is_ok_and(|(v, _)| v == 0.0),
5690        );
5691        let mut sm = self.statistics.borrow_mut();
5692        for w in extra {
5693            push_warning_once(&mut sm.warnings, w);
5694        }
5695        status
5696    }
5697
5698    /// The variable box `tnlp` declares, `(x_L, x_U)` in its own (full-x)
5699    /// space -- the space a `finalize_solution` payload is in. `None` if the
5700    /// TNLP declines to say.
5701    fn declared_x_box(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> Option<(Vec<Number>, Vec<Number>)> {
5702        let mut t = tnlp.borrow_mut();
5703        let info = t.get_nlp_info()?;
5704        let (n, m) = (info.n.max(0) as usize, info.m.max(0) as usize);
5705        let (mut x_l, mut x_u) = (vec![0.0; n], vec![0.0; n]);
5706        let (mut g_l, mut g_u) = (vec![0.0; m], vec![0.0; m]);
5707        t.get_bounds_info(BoundsInfo {
5708            x_l: &mut x_l,
5709            x_u: &mut x_u,
5710            g_l: &mut g_l,
5711            g_u: &mut g_u,
5712        })
5713        .then_some((x_l, x_u))
5714    }
5715
5716    /// The re-solve half of [`Self::audit_solve_quality`].
5717    fn run_scale_retry(
5718        &mut self,
5719        tnlp: Rc<RefCell<dyn TNLP>>,
5720        first_status: ApplicationReturnStatus,
5721        kind: ScaleAudit,
5722        grad_scale: Number,
5723    ) -> ApplicationReturnStatus {
5724        let acceptable_tol = self.dual_divergence_retry_accept_tol();
5725        let solution_floor = self.last_finalize.borrow().clone();
5726        let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
5727        let trace_floor = *self.last_iter_stats.borrow();
5728        let base = self.statistics.borrow().clone();
5729        let Some(seed) = solution_floor.as_ref().map(|f| f.x.clone()) else {
5730            return first_status;
5731        };
5732        // Budget the re-solve: an iteration cap tied to the base solve
5733        // (`SCALE_AUDIT_RETRY_ITER_FACTOR`), and only the time the caller's
5734        // limits have left. Every solve builds a fresh deadline, so without
5735        // this the re-solve got a second full `max_cpu_time` / `max_wall_time`
5736        // after the run had already succeeded. Restored below, absence as
5737        // absence.
5738        let prev_iter = self.options.get_integer_value("max_iter", "").ok();
5739        let prev_cpu = self.options.get_numeric_value("max_cpu_time", "").ok();
5740        let prev_wall = self.options.get_numeric_value("max_wall_time", "").ok();
5741        let cpu_left = prev_cpu.map(|(v, _)| v - base.total_cpu_time_secs);
5742        let wall_left = prev_wall.map(|(v, _)| v - base.total_wallclock_time_secs);
5743        if cpu_left.is_some_and(|t| t <= 0.0) || wall_left.is_some_and(|t| t <= 0.0) {
5744            return first_status;
5745        }
5746        let retry_iter = (SCALE_AUDIT_RETRY_ITER_FACTOR * base.iteration_count)
5747            .max(SCALE_AUDIT_RETRY_MIN_ITER)
5748            .min(prev_iter.map_or(Index::MAX, |(v, _)| v));
5749        let seeded: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(
5750            pounce_nlp::seeded_tnlp::SeededTnlp::new(Rc::clone(&tnlp), seed),
5751        ));
5752        // gh#983 review item 7: remember whether the caller *named* the
5753        // factor, so the restore puts back absence as absence. Writing the
5754        // default back with `found = true` left `obj_scaling_factor` reading as
5755        // user-set after the audit, which every "did the caller name it" test
5756        // downstream (`TERMINATION_POLICY_OPTIONS`, the gh#884 sense) then
5757        // misread.
5758        let prev_user = self
5759            .options
5760            .get_numeric_value("obj_scaling_factor", "")
5761            .ok()
5762            .and_then(|(v, f)| f.then_some(v));
5763        let prev_user_factor = prev_user.unwrap_or(1.0);
5764        let caller_named_relax = self
5765            .options
5766            .get_numeric_value("bound_relax_factor", "")
5767            .is_ok_and(|(_, found)| found);
5768        // Everything below until the scope drops is this re-solve's own
5769        // configuration; the scope puts every entry back, and clears the box
5770        // flag, however the re-solve exits.
5771        let mut scope = RetryOptionScope::enter(
5772            self,
5773            &[
5774                "obj_scaling_factor",
5775                "max_iter",
5776                "max_cpu_time",
5777                "max_wall_time",
5778            ],
5779            false,
5780        );
5781        if let ScaleAudit::Upscale(k) = kind {
5782            let _ = scope.options.set_numeric_value(
5783                "obj_scaling_factor",
5784                prev_user_factor * k,
5785                true,
5786                false,
5787            );
5788        }
5789        let _ = scope
5790            .options
5791            .set_integer_value("max_iter", retry_iter, true, false);
5792        // Solve the re-solve on the caller's own variable box: the rows are
5793        // widened by `bound_relax_factor` exactly as in the base attempt, the
5794        // box is not -- unless the caller named a widening.
5795        //
5796        // The re-solve exists to drive complementarity down (`Upscale`) or the
5797        // unscaled residual (`Rescale`), and on a bound-active variable that
5798        // is the same motion as driving the point onto its bound: `s · z = mu`
5799        // with `z` multiplied by the objective factor, so the slack to the
5800        // *widened* bound shrinks by that factor and the point lands at
5801        // `x_U + bound_relax_factor · max(1, |x_U|)` -- outside the box the
5802        // caller wrote. The base attempt, less converged, usually sat inside
5803        // it. So every promoted re-solve on a bound-active model shipped a
5804        // point outside its declared bounds: measured with `pounce verify`,
5805        // `ex1_320` `1.0e-8` across thousands of controls, `trainf` `7.0e-8`,
5806        // `reading1` `9.9e-9`, against `0` from the base attempt.
5807        //
5808        // The improvement itself is real, not bought with that slack: solved
5809        // with the declared box the re-solve reaches the same objective
5810        // (`reading1` `-1.604802802e-1` against `-1.604802843e-1` widened, the
5811        // base attempt's `-1.604578481e-1`) with the box held exactly. So the
5812        // box widening is dropped for this one solve rather than the re-solve
5813        // refused: it starts from a point that already satisfies the box, and
5814        // `bound_push` moves the seed off any bound it sits on.
5815        //
5816        // Why the box and not `bound_relax_factor=0`: that also un-widens the
5817        // inequality rows, which makes the re-solve a strictly tighter model
5818        // than the base attempt, and its objective then reads *worse* than
5819        // the base's by the row widening's worth -- measured on
5820        // `masked_certificate_fuzz` case 12, `-3.453863914e7` against
5821        // `-3.453863947e7`, with both points inside their box. The rows are
5822        // what the base attempt's `final_unscaled_constr_viol` and rule 2 of
5823        // `retry_answer_is_admissible` already compare like with like; the box
5824        // is the one thing neither did.
5825        //
5826        // A caller who named the factor defined the model that way and keeps
5827        // it; for that case the declared-box conjunct of the promotion gate
5828        // below is what holds the line.
5829        scope.retry_keeps_declared_box.set(!caller_named_relax);
5830        if let Some(t) = cpu_left {
5831            let _ = scope
5832                .options
5833                .set_numeric_value("max_cpu_time", t, true, false);
5834        }
5835        if let Some(t) = wall_left {
5836            let _ = scope
5837                .options
5838                .set_numeric_value("max_wall_time", t, true, false);
5839        }
5840        tracing::debug!(target: "pounce::algorithm",
5841            "[POUNCE] gh#983: {:?} verdict audited ({:?}); re-solving from the \
5842             returned point (max_iter {retry_iter}).", first_status, kind);
5843        // One IPM solve, without the mu-strategy fallback: the re-solve is
5844        // already a second opinion on a run that succeeded, and when it hit
5845        // its cap the flip spent the same budget again (`meyer3` 768 + 768,
5846        // `brainpc3` 120 + 120, `brainpc9` timing out in the second leg).
5847        // No promoted re-solve in the release sweep needed the flip except
5848        // `robot_b` past the cap, which keeps its `Solve_Succeeded` base.
5849        if match scope.options.get_integer_value("print_level", "") {
5850            Ok((v, true)) => v >= 1,
5851            _ => true,
5852        } {
5853            println!();
5854            println!(
5855                "gh#983 scale audit: re-solving from the returned point ({kind:?}); the \
5856                 summary below is a candidate's, and the line after it says whether it \
5857                 replaced the answer above."
5858            );
5859        }
5860        let mark = scope.begin_counted_resolve();
5861        let retry_status = scope.optimize_constrained(Rc::clone(&seeded));
5862        scope.end_counted_resolve(mark);
5863        drop(scope);
5864        // A re-solve the caller's intermediate callback stopped is declined
5865        // like any unclean one -- the audit only runs after a converged base,
5866        // and the stop's mid-run iterate must not replace it -- but the
5867        // status reported is the stop, and nothing calls back after it.
5868        let stopped = retry_status == ApplicationReturnStatus::UserRequestedStop;
5869        let retry = self.statistics.borrow().clone();
5870        let sense = if prev_user_factor < 0.0 { -1.0 } else { 1.0 };
5871        let clean = match first_status {
5872            ApplicationReturnStatus::SolveSucceeded => {
5873                matches!(retry_status, ApplicationReturnStatus::SolveSucceeded)
5874            }
5875            _ => matches!(
5876                retry_status,
5877                ApplicationReturnStatus::SolveSucceeded
5878                    | ApplicationReturnStatus::SolvedToAcceptableLevel
5879            ),
5880        };
5881        let better = match kind {
5882            ScaleAudit::Rescale => retry.final_unscaled_kkt_error < base.final_unscaled_kkt_error,
5883            ScaleAudit::Upscale(_) | ScaleAudit::SmallObjective => {
5884                retry.final_unscaled_compl < 0.1 * base.final_unscaled_compl
5885                    && retry.final_unscaled_kkt_error <= base.final_unscaled_kkt_error
5886            }
5887        };
5888        let admissible = retry_answer_is_admissible(
5889            certificate_floor.objective,
5890            certificate_floor.unscaled_constr_viol,
5891            retry.final_objective,
5892            retry.final_unscaled_constr_viol,
5893            acceptable_tol,
5894            sense,
5895        );
5896        // Measured on the two `finalize_solution` payloads -- the points a
5897        // caller would actually receive -- against the box the user TNLP
5898        // declares.
5899        let (base_box_rel, retry_box_rel) = match self.declared_x_box(&tnlp) {
5900            Some((x_l, x_u)) => {
5901                let (lo, up) = (self.nlp_lower_bound_inf(), self.nlp_upper_bound_inf());
5902                let rel = |x: &[Number]| declared_box_relative_violation(x, &x_l, &x_u, lo, up);
5903                (
5904                    solution_floor.as_ref().map_or(Number::NAN, |f| rel(&f.x)),
5905                    self.last_finalize
5906                        .borrow()
5907                        .as_ref()
5908                        .map_or(Number::NAN, |f| rel(&f.x)),
5909                )
5910            }
5911            None => (Number::NAN, Number::NAN),
5912        };
5913        let keeps_box = retry_keeps_declared_box(base_box_rel, retry_box_rel);
5914        let promote = clean && better && admissible && keeps_box;
5915        let console_output = match self.options.get_integer_value("print_level", "") {
5916            Ok((v, true)) => v >= 1,
5917            _ => true,
5918        };
5919        if console_output {
5920            println!();
5921            if clean && better && admissible && !keeps_box {
5922                println!(
5923                    "gh#983 scale audit: declined (the re-solve's point is {:.4e} outside the \
5924                     declared variable bounds, the base attempt's {:.4e}); the base attempt's \
5925                     answer is the one reported.",
5926                    retry.final_declared_box_viol, certificate_floor.declared_box_viol
5927                );
5928            } else if promote {
5929                println!(
5930                    "gh#983 scale audit: promoted -- unscaled KKT error {:.4e} -> {:.4e}, \
5931                     unscaled complementarity {:.4e} -> {:.4e}.",
5932                    base.final_unscaled_kkt_error,
5933                    retry.final_unscaled_kkt_error,
5934                    base.final_unscaled_compl,
5935                    retry.final_unscaled_compl
5936                );
5937            } else {
5938                println!(
5939                    "gh#983 scale audit: declined ({retry_status:?}, unscaled KKT error \
5940                     {:.4e} against the base attempt's {:.4e}); the base attempt's answer \
5941                     is the one reported.",
5942                    retry.final_unscaled_kkt_error, base.final_unscaled_kkt_error
5943                );
5944            }
5945            if !promote {
5946                // The re-solve's own end summary printed above this line, with
5947                // *its* objective and bound violation, before the verdict was
5948                // known (`emit_end_summary` runs inside each attempt). Say
5949                // which block that was and restate the numbers that ship, so
5950                // the console cannot be read as reporting the discarded point.
5951                println!(
5952                    "gh#983 scale audit: the summary block above is the discarded re-solve's; \
5953                     the reported answer is the base attempt's (objective {:.16e}, variable \
5954                     bound violation {:.16e}).",
5955                    certificate_floor.objective, certificate_floor.declared_box_viol
5956                );
5957            }
5958        }
5959        if promote {
5960            return retry_status;
5961        }
5962        if let Some(floor) = solution_floor {
5963            self.restore_floor(&floor, &tnlp);
5964            certificate_floor.restore_into(&mut self.statistics.borrow_mut());
5965            self.statistics.borrow_mut().dual_divergence_signature |=
5966                retry.dual_divergence_signature;
5967            // The replayed row is the reported answer's last iterate, so a
5968            // `false` here is a stop request on it, honored as a solve's
5969            // final-iterate stop is.
5970            if let Some(stats) = trace_floor.filter(|_| !stopped) {
5971                if !tnlp.borrow_mut().intermediate_callback(
5972                    stats,
5973                    &TnlpIpoptData::default(),
5974                    &TnlpIpoptCq::default(),
5975                ) {
5976                    return ApplicationReturnStatus::UserRequestedStop;
5977                }
5978            }
5979        }
5980        if stopped {
5981            return retry_status;
5982        }
5983        let detail = if keeps_box {
5984            format!(
5985                "was declined: the re-solve returned {retry_status:?} with unscaled KKT error {:.2e}",
5986                retry.final_unscaled_kkt_error
5987            )
5988        } else {
5989            format!(
5990                "was declined: the re-solve returned {retry_status:?} at a point {:.2e} outside \
5991                 the declared variable bounds",
5992                retry.final_declared_box_viol
5993            )
5994        };
5995        self.statistics
5996            .borrow_mut()
5997            .warnings
5998            .push(scale_audit_warning(
5999                kind,
6000                &base,
6001                grad_scale,
6002                acceptable_tol,
6003                &detail,
6004            ));
6005        first_status
6006    }
6007
6008    /// The tolerance conjunct 4 of the dual-divergence promotion gate
6009    /// tests the retry's *unscaled* residuals against.
6010    ///
6011    /// `acceptable_tol`-scale rather than `tol`-scale on purpose: the
6012    /// unscaled residual is the one quantity nothing in the solve is
6013    /// driven against, so holding it to `tol` would decline honest
6014    /// retries on badly scaled models for a reason that has nothing to
6015    /// do with gh#884. `qpec_small`'s honest retry reaches `9.96e-8`,
6016    /// four orders inside it.
6017    fn dual_divergence_retry_accept_tol(&self) -> Number {
6018        self.options
6019            .get_numeric_value("acceptable_tol", "")
6020            .ok()
6021            .and_then(|(v, f)| f.then_some(v))
6022            .unwrap_or(1e-6)
6023    }
6024
6025    /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
6026    ///
6027    /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
6028    /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
6029    /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
6030    ///   - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
6031    ///   - inner solve returns non-Optimal (escalation won't fix
6032    ///     numerical / restoration failure at this ρ)
6033    ///   - ρ already at `l1_penalty_max`
6034    ///   - `l1_penalty_max_outer_iter` reached
6035    ///
6036    /// After the loop, if the inner status is `SolveSucceeded` or
6037    /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
6038    /// to `Infeasible_Problem_Detected` — the returned point is the
6039    /// ℓ₁-best least-infeasible iterate, which is informative even
6040    /// though the original constraints are not satisfied.
6041    ///
6042    /// Returns `Some(status)` if the wrapper ran the solve, `None` if
6043    /// wrapper construction failed (caller should fall through to the
6044    /// standard dispatch path).
6045    fn run_l1_penalty_outer_loop(
6046        &mut self,
6047        tnlp: Rc<RefCell<dyn TNLP>>,
6048    ) -> Option<ApplicationReturnStatus> {
6049        let rho_init = self.l1_penalty_init();
6050        let rho_max = self.l1_penalty_max().max(rho_init);
6051        let factor = self.l1_penalty_increase_factor().max(1.0);
6052        let tau = self.l1_steering_factor();
6053        let slack_tol = self.l1_slack_tol();
6054        let max_outer = self.l1_penalty_max_outer_iter().max(1);
6055
6056        let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
6057        if wrapper.m_eq() == 0 {
6058            // Nothing to slack — let the standard dispatch path handle
6059            // this TNLP unmodified.
6060            return None;
6061        }
6062        wrapper.set_defer_inner_finalize(true);
6063        let wrapper_rc = Rc::new(RefCell::new(wrapper));
6064
6065        let mut rho = rho_init;
6066        let mut last_status = ApplicationReturnStatus::InternalError;
6067        // gh#987 item 6: every pass resets the statistics, so without this the
6068        // report described only the last ρ — `iter_count` 26 for a solve that
6069        // ran 18 + 22 + 22 + 26 iterations. Accumulated here, applied once
6070        // after the loop.
6071        let mut totals = MultiPassTotals::default();
6072        for _outer in 0..max_outer {
6073            wrapper_rc.borrow_mut().set_rho(rho);
6074            let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
6075            last_status = self.optimize_constrained(dyn_tnlp);
6076            totals.push_pass(&self.statistics.borrow(), Some(rho), last_status);
6077
6078            let w = wrapper_rc.borrow();
6079            if !w.has_solution() {
6080                // Inner solve aborted before producing an iterate.
6081                drop(w);
6082                break;
6083            }
6084            let slack_sum = w.last_slack_sum();
6085            let y_eq_inf = w.last_y_eq_inf_norm();
6086            let x_here: Vec<Number> = w.last_x_trunc().to_vec();
6087            drop(w);
6088            totals.set_slack_sum(slack_sum);
6089
6090            // Termination decisions.
6091            let inner_ok = matches!(
6092                last_status,
6093                ApplicationReturnStatus::SolveSucceeded
6094                    | ApplicationReturnStatus::SolvedToAcceptableLevel
6095            );
6096            if !inner_ok {
6097                break;
6098            }
6099            // Stop escalating ρ once the **user's** constraints are
6100            // satisfied to the tolerance the caller asked for — not once
6101            // `Σ(p + n)` falls under `l1_slack_tol`, which is a different
6102            // quantity judged by a different number (gh#794 P1). The
6103            // slack sum stays the BNW steering signal below, which is
6104            // the job it is right for. One extra `eval_g` per outer
6105            // iteration buys the difference between stopping at the
6106            // penalty solution and stopping at the model's own.
6107            let feasible_here = {
6108                let m_inner = tnlp
6109                    .borrow_mut()
6110                    .get_nlp_info()
6111                    .map(|i| i.m.max(0) as usize)
6112                    .unwrap_or(0);
6113                let mut g_here = vec![0.0; m_inner];
6114                let evaluated =
6115                    m_inner == 0 || tnlp.borrow_mut().eval_g(&x_here, true, &mut g_here);
6116                evaluated
6117                    .then(|| {
6118                        original_space_feasibility(
6119                            &tnlp,
6120                            &x_here,
6121                            &g_here,
6122                            self.nlp_lower_bound_inf(),
6123                            self.nlp_upper_bound_inf(),
6124                            self.user_tol(),
6125                            self.user_acceptable_tol(),
6126                            self.user_constr_viol_tol(),
6127                            self.user_acceptable_constr_viol_tol(),
6128                            self.user_primal_noise_floor_kappa(),
6129                        )
6130                    })
6131                    .flatten()
6132                    .map(|f| {
6133                        totals.set_violation(f.max_violation);
6134                        f.negligible_at_tol
6135                    })
6136            };
6137            match feasible_here {
6138                Some(true) => break,
6139                Some(false) => {}
6140                // Unmeasurable model: fall back to the historical
6141                // slack-sum test rather than looping to the cap.
6142                None if slack_sum.is_finite() && slack_sum <= slack_tol => break,
6143                None => {}
6144            }
6145            if rho >= rho_max {
6146                break;
6147            }
6148            // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
6149            let geom = rho * factor;
6150            let steer = tau * y_eq_inf + 1.0e-12;
6151            rho = geom.max(steer).min(rho_max);
6152        }
6153
6154        // gh#987 item 6: total iterations and the per-pass summary.
6155        totals.apply(&mut self.statistics.borrow_mut());
6156
6157        // Forward to the user's inner.finalize_solution exactly once.
6158        let w = wrapper_rc.borrow();
6159        if w.has_solution() {
6160            let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
6161            let lambda: Vec<Number> = w.last_lambda().to_vec();
6162            let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
6163            let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
6164            let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
6165            let slack_sum = w.last_slack_sum();
6166            drop(w);
6167
6168            // Recompute f(x*) and c(x*) on the inner. Both are needed
6169            // before the status is decided, because the status now turns
6170            // on the *original-space* feasibility at this point.
6171            let f_inner = tnlp
6172                .borrow_mut()
6173                .eval_f(&x_trunc, true)
6174                .unwrap_or(Number::NAN);
6175            let m = tnlp
6176                .borrow_mut()
6177                .get_nlp_info()
6178                .map(|i| i.m as usize)
6179                .unwrap_or(0);
6180            // The success flag decides whether `g_inner` is a measurement
6181            // or a zero-filled buffer. Dropping it would let a TNLP whose
6182            // final `eval_g` fails fabricate feasibility: every row would
6183            // read `0`, `original_space_feasibility` would return a
6184            // violation of zero, and that would flow into both the exit
6185            // status and the reported residuals. Gate it exactly as the
6186            // ρ-escalation measurement above does, so an evaluation
6187            // failure produces `None` and follows the documented
6188            // `l1_slack_tol` fallback instead (gh#794 review).
6189            let mut g_inner = vec![0.0; m];
6190            let g_evaluated = m == 0 || tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
6191
6192            // gh#794 P1. Everything below used to argue from `Σ(p + n)`,
6193            // the sum of the augmented slacks, judged against
6194            // `l1_slack_tol`. That is not the user's constraint
6195            // violation and it is not judged by the user's tolerance:
6196            //
6197            //   * the violation of equality row `i` is `|p_i − n_i|`,
6198            //     not `p_i + n_i`, and at the barrier's interior both
6199            //     slacks stay positive where their difference is zero,
6200            //     so the sum is an upper bound that is loose in one
6201            //     direction; and
6202            //   * `l1_slack_tol` defaults to `1e-6`, four orders looser
6203            //     than a `tol = 1e-8` solve asked for, so a violation
6204            //     that the solver's own strict gate would refuse on the
6205            //     unwrapped problem read as "the constraints are
6206            //     satisfied".
6207            //
6208            // Measured, not argued: the MPCC benchmark's `ralph1`
6209            // (`benchmarks/mpcc/`) returned `Solve_Succeeded` at a point
6210            // violating its one equality row by `2.5e-07`, with the
6211            // reported `final_constr_viol` — the *augmented* residual —
6212            // at `9.6e-15`, so no field in the result disclosed it. The
6213            // objective came back `5.0e-04` below the true optimum,
6214            // which is reachable only off the feasible set.
6215            //
6216            // So: measure the user's own rows at the returned point, and
6217            // judge them by the tolerances the caller set. The slack sum
6218            // keeps its other job unchanged — it is the BNW steering
6219            // signal for ρ escalation inside the loop above, which is
6220            // what it is the right quantity for.
6221            let feas = g_evaluated
6222                .then(|| {
6223                    original_space_feasibility(
6224                        &tnlp,
6225                        &x_trunc,
6226                        &g_inner,
6227                        self.nlp_lower_bound_inf(),
6228                        self.nlp_upper_bound_inf(),
6229                        self.user_tol(),
6230                        self.user_acceptable_tol(),
6231                        self.user_constr_viol_tol(),
6232                        self.user_acceptable_constr_viol_tol(),
6233                        self.user_primal_noise_floor_kappa(),
6234                    )
6235                })
6236                .flatten();
6237
6238            // The reported constraint violation must be the user's, not
6239            // the augmented problem's. Without this the KKT block of a
6240            // successful ℓ₁ solve describes a problem the caller never
6241            // posed. The aggregate errors take a `max` rather than a
6242            // rewrite: the NLP error's primal term enters undivided, so
6243            // the aggregate is never below the constraint violation, and
6244            // the other two components are unaffected by the wrapper.
6245            //
6246            // `f.max_violation` is measured on the inner TNLP's own rows
6247            // and bounds, so it is in the model's **original units**. That
6248            // decides which field family may carry it. `SolveStatistics`
6249            // documents `final_*` as the max-norms in the internally
6250            // scaled NLP space and `final_unscaled_*` as the same
6251            // residuals with the scaling divided back out — equal only
6252            // when no scaling is active — and `docs/src/python.md` states
6253            // the same contract to Python callers. Writing an
6254            // original-units number into `final_constr_viol` would break
6255            // it on any run with `nlp_scaling_method` engaged (gh#794
6256            // review).
6257            //
6258            // So the unscaled family always takes the measurement, and
6259            // the scaled family mirrors it exactly when per-row scaling
6260            // did not engage — the case in which the contract requires
6261            // the two to agree anyway. Under active row scaling the
6262            // scaled fields keep what the inner solve reported; the
6263            // converted number is not available here, because the
6264            // augmented problem's row-scale factors belong to an NLP that
6265            // `optimize_constrained` has already dropped. The status
6266            // decision below does not read these fields — it reads
6267            // `feas` directly — so an active-scaling run is judged on the
6268            // user's rows either way.
6269            // gh#987 (remaining item b): `final_declared_constr_viol` is the
6270            // violation of the model AS THE USER DECLARED IT. The inner
6271            // solve's value describes the wrapper's augmented rows, where
6272            // the elastic slacks absorb any violation, so it read ~1e-12
6273            // even at an infeasible point. Overwrite it with the measurement
6274            // on the user's own rows and box (original units, the same
6275            // quantity the non-l1 path reports); `NaN` when the model could
6276            // not be evaluated, never the augmented number.
6277            self.statistics.borrow_mut().final_declared_constr_viol =
6278                feas.as_ref().map_or(Number::NAN, |f| f.max_violation);
6279            if let Some(f) = feas.as_ref() {
6280                let scaled_may_mirror = self.row_scaling_active.get() == Some(false);
6281                let mut stats = self.statistics.borrow_mut();
6282                stats.final_unscaled_constr_viol = f.max_violation;
6283                stats.final_unscaled_kkt_error =
6284                    stats.final_unscaled_kkt_error.max(f.max_violation);
6285                if scaled_may_mirror {
6286                    stats.final_constr_viol = f.max_violation;
6287                    stats.final_kkt_error = stats.final_kkt_error.max(f.max_violation);
6288                    stats.final_kkt_error_above_noise =
6289                        stats.final_kkt_error_above_noise.max(f.max_violation);
6290                }
6291            }
6292
6293            let inner_claimed_success = matches!(
6294                last_status,
6295                ApplicationReturnStatus::SolveSucceeded
6296                    | ApplicationReturnStatus::SolvedToAcceptableLevel
6297            );
6298
6299            // Downgrade, not upgrade: a solve that reached the strict
6300            // standard on the user's own rows keeps whatever status the
6301            // inner gave it, and nothing here can turn a failure into a
6302            // success.
6303            let downgrade_to_acceptable = inner_claimed_success
6304                && feas
6305                    .as_ref()
6306                    .is_some_and(|f| !f.negligible_at_tol && f.negligible_at_acceptable);
6307
6308            let infeasible_certificate = inner_claimed_success
6309                && match feas.as_ref() {
6310                    // Measured: the point does not satisfy the user's
6311                    // constraints even to `acceptable_tol`.
6312                    Some(f) => !f.negligible_at_acceptable,
6313                    // Unmeasurable model — fall back to the historical
6314                    // slack-sum argument rather than to silence.
6315                    None => slack_sum.is_finite() && slack_sum > slack_tol,
6316                };
6317
6318            if let Some(f) = feas.as_ref()
6319                && inner_claimed_success
6320                && !f.negligible_at_tol
6321            {
6322                tracing::info!(
6323                    target: "pounce::application",
6324                    "l1 penalty-barrier: the inner solve converged the augmented NLP, \
6325                     but the returned point violates the model's own constraints by \
6326                     {:.3e}, which does not meet tol; reporting {} rather than success \
6327                     (gh#794)",
6328                    f.max_violation,
6329                    if f.negligible_at_acceptable {
6330                        "Solved_To_Acceptable_Level"
6331                    } else {
6332                        "an infeasibility verdict"
6333                    },
6334                );
6335            }
6336            // …unless the model's own starting point satisfies every
6337            // constraint, which disproves the certificate outright (gh #379).
6338            // Same gate as the IPM and SQP paths; see
6339            // `withdraw_infeasibility_if_refuted`.
6340            let refuted = infeasible_certificate
6341                && withdraw_infeasibility_if_refuted(
6342                    &tnlp,
6343                    SolverReturn::LocalInfeasibility,
6344                    self.nlp_lower_bound_inf(),
6345                    self.nlp_upper_bound_inf(),
6346                    self.user_tol(),
6347                ) != SolverReturn::LocalInfeasibility;
6348            let final_solver_status = match (infeasible_certificate, refuted) {
6349                (true, false) => SolverReturn::LocalInfeasibility,
6350                // The point is not feasible, so `Solve_Succeeded` would be
6351                // just as wrong as `Infeasible_Problem_Detected`. Report the
6352                // breakdown.
6353                (true, true) => SolverReturn::ErrorInStepComputation,
6354                (false, _) if downgrade_to_acceptable => SolverReturn::StopAtAcceptablePoint,
6355                (false, _) => solver_status,
6356            };
6357            let final_app_status = match (infeasible_certificate, refuted) {
6358                (true, false) => ApplicationReturnStatus::InfeasibleProblemDetected,
6359                (true, true) => ApplicationReturnStatus::ErrorInStepComputation,
6360                (false, _) if downgrade_to_acceptable => {
6361                    ApplicationReturnStatus::SolvedToAcceptableLevel
6362                }
6363                (false, _) => last_status,
6364            };
6365
6366            tnlp.borrow_mut().finalize_solution(
6367                Solution {
6368                    status: final_solver_status,
6369                    x: &x_trunc,
6370                    z_l: &z_l,
6371                    z_u: &z_u,
6372                    g: &g_inner,
6373                    lambda: &lambda,
6374                    obj_value: f_inner,
6375                },
6376                &TnlpIpoptData::default(),
6377                &TnlpIpoptCq::default(),
6378            );
6379            return Some(final_app_status);
6380        }
6381        // No solution captured at all — pass the inner status through.
6382        Some(last_status)
6383    }
6384
6385    /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
6386    /// bundle, run `optimize`, populate statistics, and call
6387    /// `finalize_solution` on the user's TNLP.
6388    /// Whether an over-determined model (more equality rows than free
6389    /// variables) is *provably* infeasible by linear bound propagation.
6390    ///
6391    /// Consulted only on the `NotEnoughDegreesOfFreedom` failure path, where
6392    /// the solve never runs and therefore can never itself discover the
6393    /// infeasibility (gh#387). Builds a throwaway presolve wrapper with only
6394    /// Phase 1 (bound tightening) enabled and asks it for a certified proof —
6395    /// this inherits the certification safety net wholesale: the crossing must
6396    /// exceed the solver's own acceptance margin at the crossed pair's scale,
6397    /// and a concrete witness point satisfying every constraint withdraws the
6398    /// verdict. A `false` here costs nothing but keeping the DOF error.
6399    ///
6400    /// The witness gate runs under [`pounce_presolve::WitnessRule`]'s
6401    /// `DeclaredRowRelative` form, which is admissible only because the solve
6402    /// cannot run on this path (gh#391) — see the comment on the probe below.
6403    ///
6404    /// Deliberately independent of the `presolve` master switch: this is not a
6405    /// model transformation (the wrapper is dropped without solving through
6406    /// it), it is a last check before reporting a structural error for a
6407    /// problem whose verdict is already decided.
6408    fn overdetermined_model_certified_infeasible(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> bool {
6409        let mut opts = pounce_presolve::PresolveOptions::from_options_list(&self.options)
6410            .unwrap_or_else(|_| pounce_presolve::PresolveOptions::defaults());
6411        opts.enabled = true;
6412        opts.bound_tightening = true;
6413        // Certification needs Phase 1 only; every transformative or
6414        // diagnostic phase is dead weight on a wrapper that is never
6415        // solved through.
6416        opts.auxiliary = false;
6417        opts.fbbt = false;
6418        opts.redundant_constraint_removal = false;
6419        opts.licq_check = false;
6420        opts.warm_z_bounds = false;
6421        // The one place the witness rule is raised off the solver's own
6422        // acceptance test (gh#391). It is sound *here specifically* because the
6423        // gate has already established the solve cannot run: the alternative to
6424        // the proof is the structural 5xx error, never `Solve_Succeeded`, so
6425        // the #380 "two routes, two answers" contradiction the clamp exists to
6426        // prevent has no second route to contradict. See `WitnessRule` for the
6427        // full argument and the homogeneous-row fallback.
6428        let mut probe =
6429            pounce_presolve::PresolveTnlp::new(Rc::clone(tnlp), opts).probing_without_a_solve();
6430        if probe.get_nlp_info().is_none() {
6431            return false;
6432        }
6433        probe.certified_infeasible().is_some()
6434    }
6435
6436    fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
6437        let t_start = Instant::now();
6438
6439        // Invalidate the row-scaling record before anything can read it.
6440        //
6441        // It is written near the end of this function, from the NLP the
6442        // solve actually built, so a solve that bails before that point
6443        // leaves whatever the *previous* one recorded. The ℓ₁ outer loop
6444        // calls this repeatedly and reads the flag after each call, so a
6445        // stale `Some(false)` would let it mirror an original-units
6446        // violation into the scaled family — the exact contract this flag
6447        // exists to protect (gh#794 review round 2). Clearing it here
6448        // makes the failure mode fail-closed: "not recorded" reads as
6449        // "cannot mirror", never as "scaling was off".
6450        self.row_scaling_active.set(None);
6451        // gh#983 review item 1: the structured warnings describe the run whose
6452        // point is returned, so each attempt starts with none. They used to
6453        // accumulate on the application for its lifetime, and a promoted
6454        // gh#884 retry (`Solve_Succeeded` at `du = 1.6e-7`) carried the
6455        // discarded base attempt's `large_dual_scale ... 1.43e9`. A declined
6456        // retry restores the floored attempt's own list via
6457        // `SolutionCertificate`.
6458        self.statistics.borrow_mut().warnings.clear();
6459        self.statistics
6460            .borrow_mut()
6461            .returned_run_dual_divergence_signature = false;
6462
6463        // `print_user_options yes` — dump the OptionsList before the
6464        // solve. Mirrors `IpoptApplication::call_optimize` (upstream
6465        // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
6466        let print_opts = self
6467            .options
6468            .get_bool_value("print_user_options", "")
6469            .ok()
6470            .and_then(|(v, f)| f.then_some(v))
6471            .unwrap_or(false);
6472        if print_opts {
6473            print!(
6474                "\nList of user-set options:\n\n{}",
6475                self.options.print_user_options()
6476            );
6477        }
6478
6479        // `print_options_documentation yes` — dump the full registry
6480        // (every option with type, default, valid range/strings, and
6481        // long description) before the solve. Honors
6482        // `print_options_mode` (`text` / `latex` / `doxygen`; only
6483        // `text` is implemented today, the others fall through with a
6484        // one-line note) and `print_advanced_options`. Mirrors
6485        // upstream `IpoptApplication::call_optimize`'s
6486        // `print_options_documentation` branch and `Common/IpRegOptions.cpp`
6487        // `OutputOptionDocumentation`.
6488        let print_doc = self
6489            .options
6490            .get_bool_value("print_options_documentation", "")
6491            .ok()
6492            .and_then(|(v, f)| f.then_some(v))
6493            .unwrap_or(false);
6494        if print_doc {
6495            let mode = self
6496                .options
6497                .get_string_value("print_options_mode", "")
6498                .ok()
6499                .map(|(v, _)| PrintOptionsMode::from_tag(&v))
6500                .unwrap_or(PrintOptionsMode::Text);
6501            let advanced = self
6502                .options
6503                .get_bool_value("print_advanced_options", "")
6504                .ok()
6505                .map(|(v, _)| v)
6506                .unwrap_or(false);
6507            print!(
6508                "\n# Pounce options registry\n\n{}",
6509                self.reg_options.print_options_documentation(mode, advanced)
6510            );
6511        }
6512
6513        // Mint a fresh `TimingStatistics` for this solve — shared (via
6514        // `Rc`) with the data and the NLP below so every `eval_*` and
6515        // every iterate-phase records into the same accumulator. The
6516        // application keeps its own `Rc` so callers can read totals out
6517        // via [`Self::timing_stats`].
6518        let timing = Rc::new(TimingStatistics::new());
6519        *self.timing.borrow_mut() = Rc::clone(&timing);
6520        // gh#606: same lifetime as the timings — a solve that bails out
6521        // before the initializer runs must not report the previous
6522        // solve's warm-start verdict.
6523        *self.warm_start_diag.borrow_mut() = None;
6524        // Gate the *detailed* per-subsystem timers on `timing_statistics`
6525        // (default "no"), matching upstream Ipopt. Without this, every
6526        // timed `eval_*` / phase section pays two `getrusage` syscalls per
6527        // start/end even when statistics are off — 16-20% of busy CPU on
6528        // fast-objective NLPs (issue #190). `print_timing_statistics=yes`
6529        // implies `timing_statistics=yes` (per its option help), so either
6530        // one enables the detailed timers. `overall_alg` is started
6531        // unconditionally below: it feeds the `max_cpu_time` check and is
6532        // reported regardless of the option.
6533        //
6534        // Each name is read as a literal rather than looped over an
6535        // array: the registered-but-unread scan
6536        // (`tests/no_silent_options.rs`) keys on the option name as it
6537        // appears at the accessor, so a loop variable reads as "no key
6538        // here" and hid `timing_statistics` among the silent options
6539        // when it has been wired since #190 (#677, #551).
6540        let read_yes = |key: &str| -> bool {
6541            self.options
6542                .get_bool_value(key, "")
6543                .ok()
6544                .and_then(|(v, f)| f.then_some(v))
6545                .unwrap_or(false)
6546        };
6547        let timing_enabled = read_yes("timing_statistics") || read_yes("print_timing_statistics");
6548        timing.set_detailed_enabled(timing_enabled);
6549        timing.overall_alg.start();
6550
6551        // Reset the linear-solver summary sink so back-to-back solves
6552        // don't bleed factor counters / extremal pivots into each
6553        // other. Surviving the lock failure with a debug-assert keeps
6554        // a poisoned mutex from sinking a release build that doesn't
6555        // even consume the summary.
6556        for sink in [&self.linsol_summary_sink, &self.resto_linsol_summary_sink] {
6557            match sink.lock() {
6558                Ok(mut guard) => {
6559                    *guard = LinearSolverSummary::default();
6560                }
6561                _ => {
6562                    debug_assert!(false, "linsol summary sink mutex poisoned");
6563                }
6564            }
6565        }
6566        // Same reasoning for the quality-escalation tally (gh#857): the
6567        // number belongs to this solve, not to whatever ran before it.
6568        self.quality_escalations.set(0);
6569        // And for the published partition: back-to-back solves on one
6570        // application can differ in which variables are fixed, so last
6571        // solve's labels must not describe this solve's KKT. Cleared here and
6572        // written again below only if this solve resolves one.
6573        if let Ok(mut g) = self.kkt_blocks_published.lock() {
6574            *g = None;
6575        }
6576
6577        // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
6578        // `make_parameter`; pounce additionally implements `relax_bounds`,
6579        // which the adapter also auto-selects as a fallback when
6580        // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
6581        // `IpTNLPAdapter.cpp:623-633`).
6582        let (lo_inf, up_inf, fixed_treatment) = self.adapter_options();
6583        let adapter = match TNLPAdapter::new_with_options(
6584            Rc::clone(&tnlp),
6585            lo_inf,
6586            up_inf,
6587            fixed_treatment,
6588        ) {
6589            Ok(a) => Rc::new(RefCell::new(a)),
6590            Err(_) => {
6591                timing.overall_alg.end();
6592                return ApplicationReturnStatus::InvalidProblemDefinition;
6593            }
6594        };
6595        // Carry the user's constant `obj_scaling_factor` (default 1.0;
6596        // negative ⇒ maximize) into the NLP. Until pounce#128's
6597        // follow-up this option was registered but never read, so it
6598        // was silently a no-op — maximization diverged because the
6599        // algorithm minimized the unscaled objective.
6600        let obj_scaling_factor = self
6601            .options
6602            .get_numeric_value("obj_scaling_factor", "")
6603            .ok()
6604            .and_then(|(v, f)| f.then_some(v))
6605            .unwrap_or(1.0);
6606        let mut orig_nlp = match OrigIpoptNlp::new(
6607            Rc::clone(&adapter),
6608            Rc::new(ConstObjScaling(obj_scaling_factor)),
6609        ) {
6610            Ok(n) => n,
6611            Err(_) => {
6612                timing.overall_alg.end();
6613                return ApplicationReturnStatus::InternalError;
6614            }
6615        };
6616        orig_nlp.set_timing_stats(Rc::clone(&timing));
6617        // Q6: decide which derivatives may be reused across iterates,
6618        // before anything is evaluated (gh #588).
6619        self.install_constant_derivative_hints(&mut orig_nlp);
6620
6621        // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
6622        // bail out with NotEnoughDegreesOfFreedom when there are fewer free
6623        // variables than equality constraints. Without this gate, square /
6624        // over-determined systems push the algorithm into restoration on
6625        // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
6626        let n_x_var = orig_nlp.x_space().dim();
6627        let n_c = orig_nlp.c_space().dim();
6628        if n_x_var > 0 && n_x_var < n_c {
6629            timing.overall_alg.end();
6630            // An over-determined system can still be *provably* infeasible —
6631            // `x == 0.2` with `x == 0.8` is about as provable as infeasibility
6632            // gets — and for such a model the structural DOF error is the
6633            // strictly weaker answer: it reports "cannot attempt this" for a
6634            // problem whose verdict is already decided (gh#387). The DOF gate
6635            // fires before any iteration runs, so nothing downstream will ever
6636            // get the chance to detect the infeasibility; check for a
6637            // bound-propagation proof here, on the rare failure path only.
6638            // The probe reuses presolve's full certification pipeline
6639            // (crossing margin + witness refutation), so a model the solver
6640            // would accept as feasible at its own tolerance is never upgraded
6641            // to "proved infeasible" — those still report the DOF error.
6642            if self.overdetermined_model_certified_infeasible(&tnlp) {
6643                use pounce_common::journalist::JournalCategory;
6644                self.journalist.print(
6645                    JournalLevel::J_SUMMARY,
6646                    JournalCategory::J_MAIN,
6647                    "\nEXIT: Problem has too few degrees of freedom, and bound \
6648                     propagation proves its constraints inconsistent.\n\
6649                     No feasible point exists; the solve was not run.\n",
6650                );
6651                return ApplicationReturnStatus::InfeasibleProblemDetected;
6652            }
6653            return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
6654        }
6655
6656        // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
6657        // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
6658        // `OrigIpoptNLP::InitializeStructures` lines 343-358.
6659        let bound_relax_factor = self
6660            .options
6661            .get_numeric_value("bound_relax_factor", "")
6662            .ok()
6663            .and_then(|(v, f)| f.then_some(v))
6664            .unwrap_or(1e-8);
6665        let constr_viol_tol = self
6666            .options
6667            .get_numeric_value("constr_viol_tol", "")
6668            .ok()
6669            .and_then(|(v, f)| f.then_some(v))
6670            .unwrap_or(1e-4);
6671        if self.retry_keeps_declared_box.get() {
6672            orig_nlp.relax_bounds_keeping_box(bound_relax_factor, constr_viol_tol);
6673        } else {
6674            orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
6675        }
6676
6677        // `honor_original_bounds` (default `no`, matching upstream):
6678        // project the reported point back into the un-relaxed box. Must
6679        // follow `relax_bounds`, which snapshots the bounds to project
6680        // onto. Registered but never read before, so a user asking for
6681        // it still got a bound-pinned solution sitting up to
6682        // `min(bound_relax_factor·max(1,|b|), constr_viol_tol)` outside
6683        // its own bounds (gh#483 follow-up).
6684        let honor_original_bounds = self
6685            .options
6686            .get_bool_value("honor_original_bounds", "")
6687            .ok()
6688            .and_then(|(v, f)| f.then_some(v))
6689            .unwrap_or(false);
6690        orig_nlp.set_honor_original_bounds(honor_original_bounds);
6691
6692        // Apply automatic NLP scaling per `nlp_scaling_method` option
6693        // (port of `OrigIpoptNLP::InitializeStructures` →
6694        // `NLPScalingObject::DetermineScaling`). Default is
6695        // `gradient-based` to match upstream Ipopt 3.14.
6696        let scaling_method = self
6697            .options
6698            .get_string_value("nlp_scaling_method", "")
6699            .ok()
6700            .and_then(|(v, f)| f.then_some(v))
6701            .unwrap_or_else(|| "gradient-based".to_string());
6702        let scaling_method = match scaling_method.as_str() {
6703            "none" => ScalingMethod::None,
6704            "gradient-based" => ScalingMethod::GradientBased,
6705            // `curvature-based` computes the factors from the model's
6706            // quadratic coefficients and hands them back through
6707            // `TNLP::get_scaling_parameters` (gh #703), so from the
6708            // engine's side it *is* user scaling — the only difference is
6709            // who filled the vectors in.
6710            "user-scaling" | "curvature-based" => ScalingMethod::UserScaling,
6711            // `equilibration-based` is registered upstream but not yet
6712            // implemented in pounce; fall back to gradient-based (the
6713            // upstream default) to keep behavior predictable.
6714            _ => ScalingMethod::GradientBased,
6715        };
6716        let max_gradient = self
6717            .options
6718            .get_numeric_value("nlp_scaling_max_gradient", "")
6719            .ok()
6720            .and_then(|(v, f)| f.then_some(v))
6721            .unwrap_or(100.0);
6722        let min_value = self
6723            .options
6724            .get_numeric_value("nlp_scaling_min_value", "")
6725            .ok()
6726            .and_then(|(v, f)| f.then_some(v))
6727            .unwrap_or(1e-8);
6728        let obj_target_gradient = self
6729            .options
6730            .get_numeric_value("nlp_scaling_obj_target_gradient", "")
6731            .ok()
6732            .and_then(|(v, f)| f.then_some(v))
6733            .unwrap_or(0.0);
6734        let constr_target_gradient = self
6735            .options
6736            .get_numeric_value("nlp_scaling_constr_target_gradient", "")
6737            .ok()
6738            .and_then(|(v, f)| f.then_some(v))
6739            .unwrap_or(0.0);
6740        orig_nlp.determine_scaling_from_starting_point(
6741            scaling_method,
6742            max_gradient,
6743            min_value,
6744            obj_target_gradient,
6745            constr_target_gradient,
6746        );
6747
6748        let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
6749
6750        // Build the algorithm strategy bundle. Read coarse knobs from
6751        // the OptionsList where we have them; fall through to defaults
6752        // otherwise. The full upstream parsing surface (mu_strategy,
6753        // hessian_approximation, line_search_method, ...) is wired by
6754        // `AlgBuilder::RegisterOptions` in upstream — that registry
6755        // hookup lands as a follow-up; default builder is correct for
6756        // HS71-class problems.
6757        let mut builder = self.algorithm_builder_from_options();
6758
6759        // The objective element's support for the partitioned Hessian.
6760        // Every constraint element reads its support off a Jacobian row;
6761        // only the objective has none declared, so without this the
6762        // updater falls back to the first `∇f`'s nonzeros — a
6763        // value-derived pattern. See
6764        // `TNLPAdapter::objective_nonlinear_vars`.
6765        if matches!(
6766            builder.hessian_approximation,
6767            HessianApproxChoice::Partitioned | HessianApproxChoice::FiniteDifference
6768        ) {
6769            builder.objective_nonlinear_vars = adapter.borrow().objective_nonlinear_vars();
6770        }
6771        // gh#989 item 5: the declared Lagrangian Hessian structure splits the
6772        // partitioned elements into the independent blocks they really are.
6773        if matches!(
6774            builder.hessian_approximation,
6775            HessianApproxChoice::Partitioned
6776        ) {
6777            let declared = !matches!(
6778                self.options.get_string_value("partitioned_structure", ""),
6779                Ok((ref v, true)) if v == "jacobian"
6780            );
6781            if declared {
6782                builder.hessian_declared_pattern = adapter.borrow().lagrangian_hessian_pattern();
6783            }
6784        }
6785
6786        // Which variables the limited-memory Hessian should span (gh#624).
6787        // Upstream's precedence: a TNLP that implements
6788        // `get_number_of_nonlinear_variables` wins, and
6789        // `num_linear_variables` is only the contiguous-prefix fallback.
6790        // Exact-Hessian solves never consult either.
6791        if matches!(
6792            builder.hessian_approximation,
6793            HessianApproxChoice::LimitedMemory | HessianApproxChoice::FiniteDifference
6794        ) {
6795            let num_linear_variables = self
6796                .options
6797                .get_integer_value("num_linear_variables", "")
6798                .ok()
6799                .and_then(|(v, f)| f.then_some(v))
6800                .unwrap_or(0);
6801            match adapter
6802                .borrow()
6803                .quasi_newton_nonlinear_vars(num_linear_variables)
6804            {
6805                Ok(mask) => builder.limited_memory_nonlinear_vars = mask,
6806                Err(e) => {
6807                    use pounce_common::journalist::JournalCategory;
6808                    self.journalist.print(
6809                        JournalLevel::J_ERROR,
6810                        JournalCategory::J_MAIN,
6811                        &format!("\nEXIT: Invalid nonlinear-variable list: {}\n", e.message),
6812                    );
6813                    timing.overall_alg.end();
6814                    return ApplicationReturnStatus::InvalidProblemDefinition;
6815                }
6816            }
6817        }
6818
6819        // Linear-solver backend. The default factory is option-aware
6820        // — it reads the `feral_*` extension options off the same
6821        // `OptionsList` that drove the IPM-level builder above so
6822        // per-problem `.opt` files can flip backend knobs without
6823        // rebuilding pounce.
6824        let mut feral_cfg = feral_config_from_options(&self.options);
6825        // Block-triangular / Schur KKT partition (pounce#180 item 2). Configure
6826        // the Schur block solvers from the *base* feral cfg: a full-KKT external
6827        // ordering (item 1) is sized for the whole system and cannot apply to
6828        // the A_FF sub-block, so the Schur path keeps the default sub-block
6829        // ordering. `build_with_backend` honors this only on the IPM + feral +
6830        // exact-Hessian path and falls back to the standard solver otherwise.
6831        if let Some(indices) = &self.kkt_schur_block {
6832            builder.set_kkt_schur(indices.clone(), feral_cfg.clone());
6833            builder.set_kkt_schur_summary_sink(Arc::clone(&self.linsol_summary_sink));
6834        }
6835        // Block-parallel KKT partition (structured-KKT Phase 5b), explicit or
6836        // detected (`kkt_block_detect`).
6837        let detect = matches!(
6838            self.options.get_string_value("kkt_block_detect", ""),
6839            Ok((ref v, true)) if v.eq_ignore_ascii_case("yes")
6840        );
6841        // A model-space declaration (§45) is mapped to KKT indices here, where
6842        // the NLP layout — which variables survived fixing, how constraints
6843        // split into equalities and inequalities — is known.
6844        {
6845            let a = adapter.borrow();
6846            let cls = a.classification();
6847            self.x_elimination = Some((cls.x_not_fixed_map.clone(), cls.n_full_x as usize));
6848        }
6849        let mapped = self.model_blocks.as_ref().and_then(|(v, c)| {
6850            let cls = adapter.borrow().classification().clone();
6851            match map_block_structure_to_kkt(&cls, v, c) {
6852                Ok(l) => Some(l),
6853                Err(e) => {
6854                    tracing::warn!(
6855                        target: "pounce::kkt",
6856                        error = %e,
6857                        "declared block structure does not fit this problem; using the standard solver"
6858                    );
6859                    None
6860                }
6861            }
6862        });
6863        if let Some(labels) = mapped
6864            .or_else(|| self.kkt_blocks.clone())
6865            .or(detect.then(Vec::new))
6866        {
6867            builder.set_kkt_blocks(labels.clone(), feral_cfg.clone());
6868            builder.set_kkt_schur_summary_sink(Arc::clone(&self.linsol_summary_sink));
6869            // And to the restoration sub-IPM, whose builder was minted before
6870            // this mapping existed. Its reduced system is this one, so the
6871            // same labels apply; see `kkt_blocks_published`. Off with
6872            // `kkt_block_restoration no`, which is how restoration's own
6873            // contribution is measured.
6874            let resto_blocks = !matches!(
6875                self.options.get_string_value("kkt_block_restoration", ""),
6876                Ok((ref v, _)) if v.eq_ignore_ascii_case("no")
6877            );
6878            if resto_blocks {
6879                if let Ok(mut g) = self.kkt_blocks_published.lock() {
6880                    *g = Some((labels, feral_cfg.clone()));
6881                }
6882            }
6883        }
6884        // A caller-supplied KKT permutation (pounce#180 item 1) overrides
6885        // the string-option / env ordering: `OrderingMethod::External`
6886        // can't be expressed through the OptionsList (it carries a
6887        // vector), so it is injected here from the side-channel field.
6888        // Only applies to the workspace-default FERAL backend below; a
6889        // custom `linear_backend_factory` owns its own config.
6890        if let Some(perm) = &self.external_ordering {
6891            feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
6892        }
6893        // MA57's knobs come off the same `OptionsList`, at the main-IPM
6894        // prefix. The restoration sub-IPM reads them again under
6895        // `"resto."` when its caller mints the inner backend factory —
6896        // see `ma57_config_from_options`.
6897        let ma57_cfg = ma57_config_from_options(&self.options, "");
6898        // gh#987 item 3: publish the unperturbed trials' inertia next to the
6899        // backend's own per-factorization record.
6900        builder.inertia_sink = Some(Arc::clone(&self.linsol_summary_sink));
6901        let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
6902            default_backend_factory_with_sink(
6903                feral_cfg,
6904                ma57_cfg,
6905                Arc::clone(&self.linsol_summary_sink),
6906            )
6907        });
6908        let bundle = builder.build_with_backend(factory);
6909
6910        // Wire the data / cq pair around the NLP. Install the shared
6911        // `TimingStatistics` so the algorithm's iterate phases
6912        // (output, convergence, hessian, μ, search-direction,
6913        // line-search, accept) all record into the same accumulator
6914        // the application exposes via `timing_stats()`.
6915        let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
6916        data.borrow_mut().timing = Rc::clone(&timing);
6917        // Install a shared wall/CPU-time deadline (pounce#242) so the time
6918        // budget is honored at the granularity of the expensive inner
6919        // steps — the main loop's KKT factorization / line search and the
6920        // restoration inner IPM — instead of only between outer iterations.
6921        // The `Deadline` starts its clock now (right after `overall_alg`),
6922        // and the restoration sub-solve reuses this same instance, so the
6923        // caller's budget bounds the whole solve rather than each nested
6924        // level independently. The convergence check treats it as
6925        // authoritative when present (see `conv_check::opt_error`).
6926        data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(
6927            builder.conv_check.max_wall_time,
6928            builder.conv_check.max_cpu_time,
6929        ));
6930        let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
6931            IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
6932        ));
6933        // Correction size for very small slacks (default mach_eps^{3/4});
6934        // drives the safe-slack bound-adjustment mechanism.
6935        if let Ok((v, true)) = self.options.get_numeric_value("slack_move", "") {
6936            cq.borrow_mut().slack_move = v;
6937        }
6938        // `kappa_d` — weight of the linear damping term added to the
6939        // barrier objective/gradient to handle one-sided bounds
6940        // (`IpIpoptCalculatedQuantities.cpp`). Registered (default 1e-5)
6941        // but previously never read, so a user override was silently
6942        // ignored (#191). Routed through the builder for parity with the
6943        // other numeric knobs; the default matches the registered
6944        // default, so only explicit overrides change behavior.
6945        cq.borrow_mut().kappa_d = builder.kappa_d;
6946        // `s_max` — cap on the average multiplier magnitude in the
6947        // `(s_d, s_c)` scaling of the KKT error test. Same shape as
6948        // `kappa_d`: registered (default 100) and previously never read,
6949        // so an override was silently ignored (#551 / #677).
6950        cq.borrow_mut().s_max = builder.s_max;
6951
6952        // Seed `data.curr` with a zero-valued iterate of the correct
6953        // dimensions. The `IterateInitializer` consumes these as its
6954        // template (it overwrites `x`, `s`, multipliers in place); we
6955        // just need the dim metadata.
6956        {
6957            let nlp_borrow = nlp_handle.borrow();
6958            let n_x = nlp_borrow.n();
6959            let n_s = nlp_borrow.m_ineq();
6960            let n_yc = nlp_borrow.m_eq();
6961            let n_yd = nlp_borrow.m_ineq();
6962            let n_zl = nlp_borrow.x_l().dim();
6963            let n_zu = nlp_borrow.x_u().dim();
6964            let n_vl = nlp_borrow.d_l().dim();
6965            let n_vu = nlp_borrow.d_u().dim();
6966            drop(nlp_borrow);
6967            let iv = IteratesVector::new(
6968                Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
6969                Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
6970                Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
6971                Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
6972                Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
6973                Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
6974                Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
6975                Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
6976            );
6977            data.borrow_mut().set_curr(iv);
6978        }
6979
6980        // Full primal-dual warm restart (debugger `resolve`): if a
6981        // captured iterate is queued, install it onto `data.curr` over
6982        // the placeholder so the `WarmStartIterateInitializer`'s
6983        // re-optimize branch (x already initialized) keeps it and only
6984        // clamps multipliers / sets target_mu — no cold re-seed from the
6985        // NLP. Skipped (with a warning) if the dimensions don't line up,
6986        // e.g. an option changed the problem structure between solves.
6987        if let Some(snap) = self.warm_start_iterate.take() {
6988            let dims_match = {
6989                let borrow = data.borrow();
6990                borrow
6991                    .curr
6992                    .as_ref()
6993                    .map(|c| iterates_dims(c) == iterates_dims(snap.iterates()))
6994                    .unwrap_or(false)
6995            };
6996            if dims_match {
6997                data.borrow_mut().set_curr(snap.iterates().clone());
6998                data.borrow_mut().curr_mu = snap.mu();
6999            } else {
7000                tracing::warn!(
7001                    target: "pounce::warm_start",
7002                    "debugger warm-restart iterate dimensions differ from the fresh \
7003                     solve; ignoring the captured iterate and seeding normally"
7004                );
7005            }
7006        }
7007
7008        let max_iter = self
7009            .options
7010            .get_integer_value("max_iter", "")
7011            .ok()
7012            .and_then(|(v, f)| f.then_some(v))
7013            .unwrap_or(3000);
7014        let tol = self
7015            .options
7016            .get_numeric_value("tol", "")
7017            .ok()
7018            .and_then(|(v, f)| f.then_some(v))
7019            .unwrap_or(1e-8);
7020        data.borrow_mut().tol = tol;
7021
7022        let mut alg = IpoptAlgorithm::new(data, cq, bundle)
7023            .with_nlp(Rc::clone(&nlp_handle))
7024            .with_tnlp(Rc::clone(&tnlp));
7025        alg.last_iter_stats_sink = Some(Rc::clone(&self.last_iter_stats));
7026        alg.live_iterate_fixed_var_duals = self.reports_fixed_var_duals();
7027        // Mint a fresh restoration factory per inner solve if a
7028        // provider is configured (pounce#10 Phase 3). Falls back to
7029        // the legacy one-shot `restoration_factory` slot when no
7030        // provider is set, preserving single-shot caller behavior.
7031        if let Some(provider) = self.restoration_factory_provider.as_mut() {
7032            self.restoration_factory = Some(provider());
7033        }
7034        if let Some(factory) = self.restoration_factory.as_mut() {
7035            alg = alg.with_restoration(factory());
7036        }
7037        if let Some(diag) = self.diagnostics.as_ref() {
7038            alg = alg.with_diagnostics(Rc::clone(diag));
7039        }
7040        // Move the interactive debugger hook (if any) into the main
7041        // algorithm. Taken — not cloned — so it drives exactly this
7042        // solve; a subsequent solve must reinstall it.
7043        if let Some(hook) = self.debug_hook.take() {
7044            alg = alg.with_debug_hook(hook);
7045        }
7046        alg.max_iter = max_iter;
7047        // `kappa_sigma` — factor bounding how far the bound multipliers
7048        // may deviate from their primal estimates; the clamp runs after
7049        // every accepted step (`IpIpoptAlg.cpp`, Eqn. (16)). Registered
7050        // (default 1e10) but previously never read, so a user override —
7051        // including the documented `< 1` "disable the correction" — was
7052        // silently ignored (#191). Routed through the builder; the struct
7053        // default matches the registered default, so default runs are
7054        // unchanged.
7055        alg.kappa_sigma = builder.kappa_sigma;
7056        alg.ftb_centrality_floor = builder.ftb_centrality_floor;
7057        // `recalc_y` (#677) — see the read site above for why the
7058        // limited-memory path defaults it on.
7059        // `linear_system_scaling=slack-based` (#677) — the only scaling
7060        // choice whose factors depend on the iterate, so the main loop
7061        // has to refresh them. See `IpoptAlgorithm::push_slack_scaling`.
7062        alg.slack_based_scaling = matches!(
7063            builder.linear_system_scaling,
7064            crate::alg_builder::LinearSystemScalingChoice::SlackBased
7065        );
7066        alg.recalc_y = builder.recalc_y;
7067        alg.recalc_y_feas_tol = builder.recalc_y_feas_tol;
7068        // `start_with_resto` — the outer loop is what acts on it. It was
7069        // previously copied only into the restoration sub-solver's own
7070        // builder, which has no first outer iteration to force, so
7071        // setting the option did nothing at all.
7072        alg.start_with_resto = builder.resto.start_with_resto;
7073        // Tiny-step and divergence guards (#191): registered but
7074        // previously never read. Struct defaults match the registered
7075        // defaults, so default runs are unchanged.
7076        alg.tiny_step_tol = builder.tiny_step_tol;
7077        alg.tiny_step_y_tol = builder.tiny_step_y_tol;
7078        alg.diverging_iterates_tol = builder.diverging_iterates_tol;
7079        alg.dual_diverging_streak = builder.dual_diverging_streak.max(0) as usize;
7080        alg.dual_divergence_retry_step_tol = builder.dual_divergence_retry_step_tol;
7081        alg.dual_divergence_retry_du_floor = builder.dual_divergence_retry_du_floor;
7082        alg.tiny_stall = crate::tiny_step_stall::TinyStepDualStall::new(
7083            builder.tiny_step_dual_stall_iters.max(0) as usize,
7084        );
7085        alg.resto_decline_deferrals = builder.resto_decline_deferrals.max(0) as usize;
7086        alg.resto_decline_progress_ratio = builder.resto_decline_progress_ratio;
7087        alg.neg_curv_escapes = builder.neg_curv_escapes.max(0) as usize;
7088        alg.lbfgs_ls_failure_restarts = builder.limited_memory_ls_failure_restarts.max(0) as usize;
7089        alg.kkt_fidelity_tol = builder.kkt_fidelity_tol;
7090        // Honor `print_level == 0`: silence the algorithm's direct-to-stdout
7091        // output — the per-iteration table and, new in #206, the
7092        // problem-statistics and end-of-run summary blocks the engine now
7093        // emits itself. Default (unset) or any positive level shows them; the
7094        // CLI's JSON mode forces print_level 0, so structured output stays
7095        // clean. (The Phase-7 journalist surface respects print_level already;
7096        // this is the legacy direct-print site that needs the same gate.)
7097        let console_output = match self.options.get_integer_value("print_level", "") {
7098            Ok((v, true)) => v >= 1,
7099            _ => true,
7100        };
7101        if !console_output {
7102            alg.print_iter_output = false;
7103            // The nested restoration IPM is built inside the restoration
7104            // driver, not by `IpoptAlgorithm::new`, so it never sees this
7105            // gate unless we forward it.
7106            if let Some(resto) = alg.restoration.as_mut() {
7107                resto.set_print_iter_output(false);
7108            }
7109        }
7110
7111        // Problem statistics, Ipopt-style, emitted before the iteration table
7112        // from the engine's own reduced problem (#206). Built from the same
7113        // collect_stats inputs the CLI used, so the block is byte-identical;
7114        // emitting it here means every frontend (CLI, Python, C) and every
7115        // algorithm (IPM, SQP) gets it.
7116        self.emit_problem_stats(&tnlp, console_output);
7117
7118        // Per-iteration history (pounce#71): when requested, capture the
7119        // `pounce::iteration` events emitted during the solve into an
7120        // `IterRecord` trajectory via the observability collector layer.
7121        // This replaces the old in-loop `iter_history` accumulation; it
7122        // requires the collector to be installed in the active
7123        // subscriber (the CLI / Python / C frontends install it via
7124        // `pounce_observability::init_subscriber`; tests call
7125        // `init_for_tests`). The collector scopes out restoration
7126        // sub-solve iterations via the `restoration` span, so the
7127        // trajectory matches the previous behavior (outer iters only).
7128        let iter_capture = self
7129            .record_iter_history
7130            .then(pounce_observability::IterCaptureGuard::start);
7131
7132        let handover_cells = self.install_handover(&mut alg, &nlp_handle);
7133        let solver_status = alg.optimize();
7134        self.finish_handover(&nlp_handle, handover_cells);
7135        // Keep the initializer's feasibility diagnostics reachable
7136        // after the algorithm goes out of scope (gh#605).
7137        self.least_square_init_report = alg.least_square_init_report();
7138
7139        let captured_iters = iter_capture.map(|g| g.finish()).unwrap_or_default();
7140        // Propagate to any enclosing capture (e.g. `with_iter_capture`
7141        // wrapped around a solve with iteration history enabled), whose
7142        // buffer this inner guard would otherwise leave empty.
7143        pounce_observability::extend_active_capture(&captured_iters);
7144        // Close the overall-algorithm timer on the success path. The
7145        // early-return arms above end it themselves before bailing out;
7146        // this one matches upstream `IpoptApplication::call_optimize`
7147        // (which calls `EndCpuTime()` on overall_alg right after
7148        // `Optimize` returns, regardless of solver_status).
7149        timing.overall_alg.end();
7150
7151        // gh#612: opt-in crossover. Runs here — after the algorithm is done
7152        // but BEFORE the statistics drain, the status gates, the
7153        // `on_converged` hook and `finalize_via_orig_nlp` — so that when it
7154        // accepts, every one of those describes the point actually returned.
7155        // Placing it later would mean reporting residuals for an iterate the
7156        // user is not given, and would hide the exact active set from the
7157        // post-optimal sensitivity hook, which is the first of the three
7158        // consumers this exists for.
7159        self.maybe_crossover(&mut alg, &nlp_handle, solver_status);
7160
7161        // Drain counters / iter count off the algorithm.
7162        {
7163            let mut stats = self.statistics.borrow_mut();
7164            {
7165                let d = alg.data.borrow();
7166                stats.iteration_count = d.iter_count;
7167                // Converged barrier parameter μ — threaded forward into a
7168                // warm-started corrector's `mu_init` / `warm_start_target_mu`
7169                // for predictor–corrector path following (pounce#86).
7170                stats.final_mu = d.curr_mu;
7171            }
7172            // gh#606: the warm-start initializer's verdict on what the
7173            // caller supplied. Lifted off the (solve-local) data handle
7174            // so it outlives the solve; `None` on a cold start.
7175            *self.warm_start_diag.borrow_mut() = alg.data.borrow().warm_start_diagnostics.clone();
7176            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
7177            // Restoration-phase audit counters (pounce#12). Zero on
7178            // problems where restoration never fires; populated by
7179            // `IpoptAlgorithm::invoke_restoration`.
7180            // Finite-difference Hessian census (gh#823 review). `None` on
7181            // every other updater, which leaves the `-1` sentinel in
7182            // place and says "this mode did not run" rather than "it ran
7183            // and found nothing".
7184            if let Some(fd) = alg.bundle.hess.fd_hessian_stats() {
7185                use crate::hess::fd_hessian::FdPatternSource;
7186                stats.fd_hessian_pattern_used = match fd.pattern_used {
7187                    Some(FdPatternSource::Declared) => 0,
7188                    Some(FdPatternSource::Jacobian) => 1,
7189                    None => -1,
7190                };
7191                stats.fd_hessian_nnz = fd.nnz as Index;
7192                stats.fd_hessian_n = fd.n as Index;
7193                stats.fd_hessian_groups = fd.groups as Index;
7194                stats.fd_hessian_rho_max = fd.rho_max as Index;
7195                stats.fd_hessian_coloring_fell_back = fd.coloring_fell_back;
7196                stats.fd_hessian_objective_clique_widened = fd.objective_clique_widened;
7197            }
7198            stats.restoration_calls = alg.resto_calls;
7199            stats.restoration_inner_iters = alg.resto_inner_iters;
7200            stats.restoration_outer_iters = alg.resto_outer_iters;
7201            stats.restoration_wall_secs = alg.resto_wall_secs;
7202            // gh#857. Read off the shared cell rather than the algorithm's
7203            // own `PdFullSpaceSolver`, so restoration sub-solves — which
7204            // run their own solver instance — are included.
7205            stats.quality_escalations = self.quality_escalations.get() as Index;
7206            // gh#1042: which `Restoration_Failed` this is, for the ladder.
7207            stats.unreleased_handback_exit = alg.unreleased_handback_exit();
7208            // gh#884. Read off the algorithm that just ran, so
7209            // `run_with_dual_divergence_retry` — which sits above this
7210            // call — can see what it observed.
7211            self.dual_divergence_signature
7212                .set(self.dual_divergence_signature.get() || alg.dual_divergence_signature());
7213            stats.dual_divergence_signature = self.dual_divergence_signature.get();
7214            stats.returned_run_dual_divergence_signature = alg.dual_divergence_signature();
7215            self.tiny_step_dual_stall.set(alg.tiny_step_dual_stall());
7216            stats.dual_divergence_retry_promoted = self.dual_divergence_retry_promoted.get();
7217            stats.mu_strategy_fallback = self.mu_strategy_fallback_record.borrow().clone();
7218            stats.derivative_check = self.derivative_check.borrow().clone();
7219            let (rs, ra) = self.obj_scale_refused.get();
7220            self.obj_scale_refused.set((
7221                rs || alg.obj_scale_certificate_refused(),
7222                ra || alg.obj_scale_acceptable_refused(),
7223            ));
7224            stats.obj_scale_certificate_refused = self.obj_scale_refused.get().0;
7225            stats.obj_scale_acceptable_refused = self.obj_scale_refused.get().1;
7226            stats.iterations = captured_iters;
7227            // A refused starting point does not produce a valid iterate.
7228            // Leave final objective/residual fields at their NaN defaults.
7229            // Capture the final *scaled* objective at the algorithm's
7230            // (compressed `x_var`-space) iterate via the NLP: the
7231            // algorithm-side `eval_f` returns `f * obj_scale_factor`.
7232            // `final_objective` is seeded with it only as a best-effort
7233            // fallback; the success path below overwrites it with the
7234            // true unscaled objective from `finalize_via_orig_nlp`
7235            // (which evaluates the user TNLP directly).
7236            if solver_status != SolverReturn::InvalidProblemDefinition {
7237                let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
7238                if let Some(x) = curr_x {
7239                    if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
7240                        stats.final_objective = f;
7241                        stats.final_scaled_objective = f;
7242                    }
7243                }
7244                // Final residuals straight off the cq cache. These mirror
7245                // the values upstream prints in its end-of-run summary
7246                // ("Dual infeasibility / Constraint violation /
7247                // Complementarity / Overall NLP error").
7248                let cq = alg.cq.borrow();
7249                stats.final_dual_inf = cq.curr_dual_infeasibility_max();
7250                // Stays on the *internal* measure deliberately: the summary's
7251                // "Overall NLP error" is `curr_nlp_error`, and it is built from
7252                // this same `max(||c||, ||d - s||)`. Switching the violation line
7253                // alone to the original-NLP measure
7254                // (`curr_unscaled_nlp_constraint_violation_max`, now used by the
7255                // `inf_pr` column) would leave the block self-inconsistent —
7256                // an error larger than the max of its own components. Making
7257                // them agree means deciding whether *convergence* should be
7258                // judged on the original NLP, which is a behaviour change for
7259                // every model, not a reporting fix. See pounce#476.
7260                //
7261                // NOTE (gh #528): "Overall NLP error" is no longer the number
7262                // the strict gate tests. That gate judges
7263                // `curr_nlp_error_above_primal_noise` — the same aggregate with
7264                // each row's residual counted only above what it can represent
7265                // in floating point — so on a model whose constraint values run
7266                // to `~1e8` the summary can report an error above `tol` beside
7267                // `EXIT: Optimal Solution Found`. The gap is exactly the part
7268                // of the residual that is quantisation noise, and it is bounded
7269                // by `constr_viol_tol`, which is still tested here on the full
7270                // unfloored residual. Reporting is deliberately left on the raw
7271                // value: it is the honest measurement, and at these magnitudes
7272                // the default `bound_relax_factor = 1e-8` has already moved
7273                // every bound by orders of magnitude more than the floor
7274                // forgives, so the raw number was never an exact statement
7275                // about the original NLP either.
7276                stats.final_constr_viol = cq.curr_primal_infeasibility_max();
7277                // How far outside the model AS DECLARED the returned point
7278                // sits. The line above is the internal slack measure the
7279                // convergence test reads, on the `bound_relax_factor`-widened
7280                // model this arm genuinely solves; the widening stays here
7281                // because a feasible-iterate log-barrier needs `x` strictly
7282                // inside its bounds (the convex arm's does not -- see
7283                // `qp_extract::BoundRelax`). The two can differ by orders and
7284                // nothing used to say so: on netlib `wood1p` this reports
7285                // `1.71e-14` at a point `7.96e-09` outside the declared rows
7286                // and `9.84e-09` outside the declared box. Reported on
7287                // every solve (see gh#987 below).
7288                //
7289                // gh#987: computed on every solve, not only a widened one.
7290                // `final_constr_viol` is documented as the internal scaled
7291                // residual, so under row scaling with `bound_relax_factor=0`
7292                // the user-unit violation was reported nowhere (the field read
7293                // NaN). Without a widening the declared and live models
7294                // coincide, but the *units* do not, and this is the one field
7295                // that is always in the caller's.
7296                stats.final_declared_constr_viol = cq.curr_declared_primal_violation_max();
7297                // The box half of the same measurement, unconditionally: this
7298                // one is a *summary line* (Ipopt's `Variable bound
7299                // violation`), not an extra warning, so it has to carry a real
7300                // number on every solve rather than only on a widened one. At
7301                // `bound_relax_factor = 0` the honest number is `0` and the
7302                // accessor returns it by measurement, not by assumption.
7303                stats.final_declared_box_viol = cq.curr_declared_box_violation_max();
7304                // Infinity-norm complementarity, max over all four bound
7305                // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
7306                // empty-bound blocks return `0` from amax(), so the max is
7307                // safe even when only one side has bounds.
7308                let compl = cq
7309                    .curr_compl_x_l()
7310                    .amax()
7311                    .max(cq.curr_compl_x_u().amax())
7312                    .max(cq.curr_compl_s_l().amax())
7313                    .max(cq.curr_compl_s_u().amax());
7314                stats.final_compl = compl;
7315                stats.final_kkt_error = cq.curr_nlp_error();
7316                // The aggregate the strict gate tested (gh #528). Reported
7317                // alongside the raw one so a summary can account for the gap
7318                // between them; equal to it on every `O(1)` model, and on any
7319                // run with `primal_noise_floor_kappa = 0`.
7320                stats.final_kkt_error_above_noise = cq
7321                    .curr_nlp_error_above_primal_noise(builder.conv_check.primal_noise_floor_kappa);
7322                // Unscaled (user-space) counterparts — divide the nlp_scaling
7323                // back out so a consumer can verify the certificate in its own
7324                // units (pounce#173). Identical to the scaled fields when no
7325                // scaling is active.
7326                stats.final_unscaled_dual_inf = cq.curr_unscaled_dual_infeasibility_max();
7327                stats.final_unscaled_dual_scale = cq.curr_unscaled_dual_infeasibility_scale_max();
7328                stats.final_obj_scaling_factor = cq.computed_obj_scaling_factor();
7329                stats.final_total_obj_scaling_factor = cq.obj_scaling_factor().abs();
7330                stats.start_obj_grad_max = cq.start_obj_gradient_max();
7331                stats.final_unscaled_constr_viol = cq.curr_unscaled_primal_infeasibility_max();
7332                // Record whether per-row scaling actually engaged, so a
7333                // wrapper that measures the user's rows in the model's own
7334                // units knows which field family may carry that number.
7335                // `curr_unscaled_primal_infeasibility_max` treats both
7336                // vectors absent as "scaled == unscaled"; the ℓ₁ outer loop
7337                // relies on the same equivalence (gh#794 review).
7338                {
7339                    let nlp_ref = nlp_handle.borrow();
7340                    self.row_scaling_active.set(Some(
7341                        nlp_ref.c_scale_vec().is_some() || nlp_ref.d_scale_vec().is_some(),
7342                    ));
7343                }
7344                stats.final_unscaled_compl = cq.curr_unscaled_complementarity_max();
7345                stats.final_unscaled_kkt_error = cq.curr_unscaled_nlp_error();
7346
7347                // Report an accepted crossover in the frame it solved in
7348                // (#646). Everything above measures against the bounds the
7349                // interior iteration ran against, which `bound_relax_factor`
7350                // widened by `δ` before the solve. That is the right frame
7351                // for an interior iterate — it never touches a bound — but a
7352                // crossed-over point sits *exactly* on the constraints of the
7353                // problem as declared, i.e. `δ` inside the relaxed ones, so
7354                // the four `s·z` blocks above read `|multiplier| · δ`. For a
7355                // unit multiplier and the default `δ = 1e-8` that is `1e-8`,
7356                // which is `tol`: a strictly better point printed an `Overall
7357                // NLP error` above tolerance, and the opt-in
7358                // `kkt_fidelity_tol` gate below downgraded it.
7359                //
7360                // Only complementarity moves. Stationarity involves no
7361                // bounds, and the crossed-over point is *interior* to the
7362                // relaxed box, so its violation is zero under either reading.
7363                //
7364                // The substitution is confined to reporting. Crossover runs
7365                // after the status is decided, and it only ever installs a
7366                // point the never-regress gate accepted on the declared-bound
7367                // residuals, so this cannot dress up a worse iterate — the
7368                // measurement it replaces is the artifact.
7369                // The same holds for a point the in-solve handover installed
7370                // (gh#1054) while it is still the iterate being reported —
7371                // `curr_from_crossover` is cleared if the interior method
7372                // moved on from it.
7373                let declared_compl = self
7374                    .crossover_report
7375                    .as_ref()
7376                    .filter(|r| r.accepted())
7377                    .or_else(|| {
7378                        self.handover_report
7379                            .as_ref()
7380                            .filter(|_| alg.data.borrow().curr_from_crossover)
7381                            .and_then(|h| h.last.as_ref())
7382                            .filter(|r| r.accepted())
7383                    })
7384                    .map(|r| r.compl_after)
7385                    .filter(|c| c.is_finite());
7386                if let Some(compl_declared) = declared_compl {
7387                    stats.final_compl = compl_declared;
7388                    stats.final_kkt_error =
7389                        cq.curr_nlp_error_with_complementarity(compl_declared, 0.0);
7390                    stats.final_kkt_error_above_noise = cq.curr_nlp_error_with_complementarity(
7391                        compl_declared,
7392                        builder.conv_check.primal_noise_floor_kappa,
7393                    );
7394                    // Same unscaling as `curr_unscaled_complementarity_max`:
7395                    // the slack's row factor and the multiplier's cancel in
7396                    // the product, leaving the objective factor. Magnitude —
7397                    // `obj_scaling_factor` is signed, `-1` being the
7398                    // documented way to pose a maximization.
7399                    let df = cq.obj_scaling_factor().abs();
7400                    stats.final_unscaled_compl = if df == 0.0 || df == 1.0 {
7401                        compl_declared
7402                    } else {
7403                        compl_declared / df
7404                    };
7405                    stats.final_unscaled_kkt_error = stats
7406                        .final_unscaled_dual_inf
7407                        .max(stats.final_unscaled_constr_viol)
7408                        .max(stats.final_unscaled_compl);
7409                }
7410            }
7411        }
7412
7413        // Never report `Infeasible_Problem_Detected` while holding a point that
7414        // satisfies every constraint. The gates that produce this verdict argue
7415        // from a stalled feasibility sub-problem, and gh #379 is what that looks
7416        // like when the argument is wrong — a model whose own starting point is
7417        // exactly feasible, reported infeasible. See
7418        // `withdraw_infeasibility_if_refuted`.
7419        let solver_status =
7420            withdraw_infeasibility_if_refuted(&tnlp, solver_status, lo_inf, up_inf, tol);
7421
7422        // Map SolverReturn → ApplicationReturnStatus per
7423        // MAIN_LOOP.md's exception table, then apply the opt-in
7424        // status-fidelity gate (pounce#173).
7425        let app_status = self.apply_kkt_fidelity_gate(solver_return_to_app_status(solver_status));
7426        // gh#986 item 5: `mehrotra_algorithm=yes` runs with no line search
7427        // (every trial step is accepted), which is sound on LP / convex QP and
7428        // not on a general NLP — there an unglobalized step can walk the
7429        // iterate into an infeasible region that restoration then cannot
7430        // leave. (A *feasible* iterate is no longer sent to restoration by the
7431        // probing guard: it is recentred, gh#986 item 5, so this no longer
7432        // fires on the guard's own account.) Say so when it happens.
7433        if app_status == ApplicationReturnStatus::RestorationFailed
7434            && matches!(
7435                self.options.get_string_value("mehrotra_algorithm", ""),
7436                Ok((ref v, true)) if v == "yes"
7437            )
7438        {
7439            tracing::warn!(
7440                target: "pounce::algorithm",
7441                "pounce: restoration failed under mehrotra_algorithm=yes. That option \
7442                 disables the line search and is intended for LPs and convex QPs; on a \
7443                 general NLP every trial step is accepted, so the iterate can leave the \
7444                 region restoration can repair, and a successful run may stop at a \
7445                 different local solution than the default algorithm finds. Retry with \
7446                 the default (mehrotra_algorithm=no)."
7447            );
7448        }
7449
7450        // On convergence, fire the user-supplied callback (post-optimal
7451        // sensitivity hook, pounce#16) before flowing back through
7452        // `finalize_via_orig_nlp`. Borrowed handles into the converged
7453        // KKT state stay alive for the duration of the closure.
7454        if matches!(
7455            app_status,
7456            ApplicationReturnStatus::SolveSucceeded
7457                | ApplicationReturnStatus::SolvedToAcceptableLevel
7458        ) {
7459            if let Some(cb) = self.on_converged.as_mut() {
7460                // `data.w` is one iterate behind here: the main loop checks
7461                // convergence before it updates the Hessian, so it still holds
7462                // `W(w_k)` from the final Newton step while every other block
7463                // the hook factors (Jacobians, slacks, multipliers, `Σ`) is
7464                // read at the converged point `w*`. Re-evaluate it so the
7465                // sensitivity matrix is the KKT Jacobian at `w*` rather than a
7466                // hybrid of two iterates. Updaters with no `W` to re-evaluate
7467                // (the quasi-Newton ones) answer `None` and keep theirs.
7468                // See `set_on_converged_refreshes_hessian`.
7469                if self.on_converged_refreshes_hessian
7470                    && let Some(w) = alg.bundle.hess.hessian_at_current(&alg.data, &alg.cq)
7471                {
7472                    alg.data.borrow_mut().w = Some(w);
7473                }
7474                if let Some(sd) = alg.search_dir.as_mut() {
7475                    let pd = sd.pd_solver_rc();
7476                    // Tag the firing before it runs, so the hook can file what
7477                    // it keeps under the id `finalize_via_orig_nlp` then
7478                    // stamps on this attempt's payload.
7479                    let id = self.on_converged_fired.get() + 1;
7480                    self.on_converged_fired.set(id);
7481                    alg.data.borrow_mut().on_converged_id = Some(id);
7482                    cb(&alg.data, &alg.cq, &nlp_handle, pd);
7483                }
7484            }
7485        }
7486
7487        // Finalize: forward the final iterate to the user's TNLP. The
7488        // returned objective is evaluated on the *user* TNLP at the
7489        // unscaled iterate, so it overrides the scaled best-effort
7490        // value stashed in `final_objective` above (the algorithm-side
7491        // `eval_f` returns `f * obj_scale_factor`).
7492        if solver_status != SolverReturn::InvalidProblemDefinition {
7493            match finalize_via_orig_nlp(
7494                &nlp_handle,
7495                &alg,
7496                solver_status,
7497                app_status,
7498                &tnlp,
7499                &self.last_finalize,
7500                self.reports_fixed_var_duals(),
7501            ) {
7502                Ok(f_unscaled) => {
7503                    self.statistics.borrow_mut().final_objective = f_unscaled;
7504                }
7505                Err(()) => {}
7506            }
7507        }
7508
7509        // End-of-solve timing report. Gated on `print_timing_statistics`
7510        // (default "no"); mirrors upstream's
7511        // `IpoptApplication::call_optimize` →
7512        // `IpTimingStatistics::PrintAllValues` call site. The report
7513        // goes to stdout (for parity with the banner / iter-row output
7514        // path) and is also fanned out to the journalist so an
7515        // `output_file` attached via `Initialize` picks it up.
7516        let print_timing = self
7517            .options
7518            .get_bool_value("print_timing_statistics", "")
7519            .ok()
7520            .and_then(|(v, f)| f.then_some(v))
7521            .unwrap_or(false);
7522        if print_timing {
7523            let report = timing.report();
7524            print!("{}", report);
7525            use pounce_common::journalist::{JournalCategory, JournalLevel};
7526            self.journalist.print(
7527                JournalLevel::J_SUMMARY,
7528                JournalCategory::J_TIMING_STATISTICS,
7529                &report,
7530            );
7531        }
7532
7533        // End-of-run summary, Ipopt-style, emitted last (after any timing
7534        // report) from the engine's own statistics (#206). Drains the eval
7535        // tallies into SolveStatistics (read AFTER finalize so the final
7536        // solution evaluation is included) and prints the summary, gated on
7537        // the same print_level as the rest of the console.
7538        self.emit_end_summary(app_status, &nlp_handle, console_output);
7539
7540        app_status
7541    }
7542
7543    /// Build an [`AlgorithmBuilder`] populated from the app's
7544    /// [`OptionsList`]. Public so callers wiring the restoration
7545    /// factory can hand the *inner* IPM a builder that mirrors the
7546    /// outer's `mu_strategy`/`mu_oracle`/line-search choices —
7547    /// matching upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`,
7548    /// which reads the same `mu_strategy` option with prefix `"resto."
7549    /// + prefix` and falls back to the outer setting.
7550    pub fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
7551        let mut builder = AlgorithmBuilder::new();
7552        // gh#857: share this application's escalation tally. Every
7553        // frontend builds the restoration provider's inner builder from
7554        // this method, so restoration escalations aggregate here too.
7555        builder.quality_escalation_counter = Some(Rc::clone(&self.quality_escalations));
7556        // Structured-KKT Phase 5b: the same reasoning, one solve later. The
7557        // partition this solve resolves is not known yet — it is derived from
7558        // a KKT layout that does not exist until `optimize` builds it — so the
7559        // builder takes the cell rather than the labels, and reads it when
7560        // restoration actually runs. Block-path factorizations record into the
7561        // restoration sink, so `linear_solver.restoration` reports them where
7562        // the rest of restoration's linear algebra is reported.
7563        if let Ok((v, _)) = self.options.get_integer_value("kkt_block_min_size", "") {
7564            builder.kkt_block_min_size = v.max(0) as usize;
7565        }
7566        builder.set_kkt_blocks_cell(Arc::clone(&self.kkt_blocks_published));
7567        builder.set_kkt_schur_summary_sink(Arc::clone(&self.resto_linsol_summary_sink));
7568
7569        // `mehrotra_algorithm` is parsed first so its cascading
7570        // defaults (mu_strategy=adaptive, mu_oracle=probing) can be
7571        // overridden by an explicit user setting of those keys
7572        // below. Mirrors `IpAlgBuilder.cpp:Mehrotra`.
7573        // `fast_step_computation` — skip the search-direction residual
7574        // check and allow an inexact linear solve. `PdSearchDirCalc` has
7575        // consumed this flag since it landed, hard-coded to `false`; the
7576        // option's read site was simply missing, so setting it did
7577        // nothing at all (gh#483 follow-up, #191 round 2).
7578        if let Ok((v, true)) = self.options.get_string_value("fast_step_computation", "") {
7579            builder.fast_step_computation = v.eq_ignore_ascii_case("yes");
7580        }
7581
7582        let mut mehrotra_on = false;
7583        if let Ok((v, found)) = self.options.get_string_value("mehrotra_algorithm", "") {
7584            if found && v == "yes" {
7585                mehrotra_on = true;
7586                builder.mehrotra_algorithm = true;
7587                builder.mu_strategy = MuStrategyChoice::Adaptive;
7588                builder.mu_oracle = crate::mu::adaptive::MuOracleKind::Probing;
7589                // `accept_every_trial_step` short-circuits the alpha
7590                // loop / filter — Mehrotra steps would otherwise be
7591                // rejected by the filter on LP-shaped problems because
7592                // the barrier objective is non-monotone along the
7593                // corrector. Mirrors upstream `IpAlgBuilder.cpp:Mehrotra`.
7594                builder.line_search.accept_every_trial_step = true;
7595                // Aggressive iterate-push defaults (`SetNumericValueIfUnset`
7596                // in upstream). The explicit user parses below will
7597                // overwrite these if the user set them explicitly.
7598                builder.init.bound_push = 10.0;
7599                builder.init.bound_frac = 0.2;
7600                builder.init.slack_bound_push = 10.0;
7601                builder.init.slack_bound_frac = 0.2;
7602                builder.init.bound_mult_init_val = 10.0;
7603                builder.init.constr_mult_init_max = 0.0;
7604                // `alpha_for_y=bound-mult` — Mehrotra wants the
7605                // equality multipliers to advance with the dual
7606                // alpha so they stay in step with z/v. Mirrors
7607                // upstream `IpIpoptAlg.cpp:InitializeImpl`.
7608                builder.line_search.alpha_for_y =
7609                    crate::line_search::backtracking::AlphaForY::BoundMult;
7610                // `adaptive_mu_globalization=never-monotone-mode` —
7611                // upstream `IpIpoptAlg.cpp:148-154` enforces this:
7612                // Mehrotra disables the globalization switch entirely
7613                // (no fallback to monotone mode when convergence
7614                // stalls). Required for the unsafeguarded Mehrotra
7615                // path to function.
7616                builder.mu.adaptive_mu_globalization =
7617                    crate::mu::adaptive::AdaptiveMuGlobalization::NeverMonotoneMode;
7618                // `least_square_init_primal=yes` — upstream
7619                // `IpIpoptAlg.cpp:182` enables this for the Mehrotra
7620                // cascade. Replaces the user's starting `x` with the
7621                // min-norm primal that satisfies the linearized
7622                // equality+inequality constraints. Critical on
7623                // LP-shaped problems where the user's starting point
7624                // can be wildly infeasible (e.g. nuffield2_trap).
7625                builder.init.least_square_init_primal = true;
7626            }
7627        }
7628
7629        if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
7630            if found {
7631                let parsed = match v.as_str() {
7632                    "adaptive" => MuStrategyChoice::Adaptive,
7633                    _ => MuStrategyChoice::Monotone,
7634                };
7635                if mehrotra_on && matches!(parsed, MuStrategyChoice::Monotone) {
7636                    // Upstream Ipopt refuses this combination: Mehrotra
7637                    // needs an affine step every iter, which only the
7638                    // adaptive path computes. Keep adaptive and warn.
7639                    tracing::warn!(target: "pounce::algorithm",
7640                        "pounce: mehrotra_algorithm=yes requires \
7641                         mu_strategy=adaptive; ignoring \
7642                         mu_strategy=monotone."
7643                    );
7644                } else {
7645                    builder.mu_strategy = parsed;
7646                }
7647            }
7648        }
7649        if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
7650            if found {
7651                builder.mu_oracle = match v.as_str() {
7652                    "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
7653                    "probing" => crate::mu::adaptive::MuOracleKind::Probing,
7654                    _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
7655                };
7656            }
7657        }
7658        if let Ok((v, found)) = self
7659            .options
7660            .get_string_value("adaptive_mu_globalization", "")
7661        {
7662            if found {
7663                use crate::mu::adaptive::AdaptiveMuGlobalization;
7664                builder.mu.adaptive_mu_globalization = match v.as_str() {
7665                    "kkt-error" => AdaptiveMuGlobalization::KktError,
7666                    "never-monotone-mode" => AdaptiveMuGlobalization::NeverMonotoneMode,
7667                    _ => AdaptiveMuGlobalization::ObjConstrFilter,
7668                };
7669            }
7670        }
7671        if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
7672            if found {
7673                builder.hessian_approximation = match v.as_str() {
7674                    "limited-memory" => HessianApproxChoice::LimitedMemory,
7675                    "partitioned" => HessianApproxChoice::Partitioned,
7676                    "finite-difference" => HessianApproxChoice::FiniteDifference,
7677                    _ => HessianApproxChoice::Exact,
7678                };
7679            }
7680        }
7681        // **Upstream changes the `mu_strategy` default for a
7682        // limited-memory Hessian.** `IpAlgBuilder.cpp:1059`:
7683        //
7684        //     if( !options.GetStringValue("mu_strategy", smuupdate, prefix) )
7685        //     {
7686        //        // Change default for quasi-Newton option (then we use adaptive)
7687        //        ... if( hessian_approximation == LIMITED_MEMORY )
7688        //               smuupdate = "adaptive";
7689        //     }
7690        //
7691        // and again at `:920` for the restoration-phase algorithm.
7692        // Registered default is `monotone`; the quasi-Newton path takes
7693        // `adaptive` unless the caller says otherwise. pounce read the
7694        // registered default unconditionally, so every L-BFGS solve ran
7695        // a barrier schedule Ipopt does not use on that path — a
7696        // trajectory divergence on the arm the Python frontend and the
7697        // CasADi plugin select automatically (gh#746).
7698        //
7699        // The restoration sub-IPM inherits this: `run_inner_resto`
7700        // clones the configured `inner_alg_builder`, so the flag set
7701        // here is what the resto algorithm gets, matching `:920`.
7702        //
7703        // Only when unset — an explicit `mu_strategy` still wins, and
7704        // `mehrotra_algorithm` (parsed above) has already forced
7705        // adaptive on its own terms.
7706        if builder.hessian_approximation == HessianApproxChoice::LimitedMemory
7707            && !self.mu_strategy_was_set()
7708        {
7709            builder.mu_strategy = MuStrategyChoice::Adaptive;
7710        }
7711        // Limited-memory quasi-Newton update formula. Registered upstream
7712        // (`limited_memory_update_type`, IpLimMemQuasiNewtonUpdater.cpp) but
7713        // until now read nowhere on the IPM path — the updater was hard-wired
7714        // to Powell-damped BFGS. SR1 is honored too (the updater and the
7715        // low-rank/inertia path already handle its indefinite models).
7716        // Partitioned quasi-Newton knobs. `partitioned_update_type`
7717        // defaults to SR1 rather than BFGS — see
7718        // `crates::hess::partitioned_quasi_newton` for why damping is
7719        // the wrong choice on a per-constraint element.
7720        if let Ok((v, found)) = self.options.get_string_value("partitioned_update_type", "") {
7721            if found {
7722                builder.partitioned_update_type = match v.as_str() {
7723                    "bfgs" => UpdateType::Bfgs,
7724                    _ => UpdateType::Sr1,
7725                };
7726                builder.partitioned_update_type_was_set = true;
7727            }
7728        }
7729        if let Ok((v, found)) = self
7730            .options
7731            .get_integer_value("partitioned_max_element", "")
7732        {
7733            if found && v > 0 {
7734                builder.partitioned_max_element = v as usize;
7735            }
7736        }
7737        if let Ok((v, found)) = self.options.get_string_value("fd_hessian_pattern", "") {
7738            if found {
7739                builder.fd_hessian_pattern = match v.as_str() {
7740                    "jacobian" => crate::hess::fd_hessian::FdPatternSource::Jacobian,
7741                    _ => crate::hess::fd_hessian::FdPatternSource::Declared,
7742                };
7743            }
7744        }
7745        if let Ok((v, found)) = self.options.get_string_value("fd_hessian_coloring", "") {
7746            if found {
7747                builder.fd_hessian_coloring = match v.as_str() {
7748                    "cpr" => crate::hess::fd_hessian::FdColoring::Cpr,
7749                    _ => crate::hess::fd_hessian::FdColoring::Star,
7750                };
7751            }
7752        }
7753        if let Ok((v, found)) = self.options.get_numeric_value("fd_hessian_reuse_tol", "") {
7754            if found && v >= 0.0 {
7755                builder.fd_hessian_reuse_tol = v;
7756            }
7757        }
7758        if let Ok((v, found)) = self.options.get_string_value("partitioned_elements", "") {
7759            if found {
7760                builder.partitioned_elements = match v.as_str() {
7761                    "blocks" => crate::hess::partitioned_quasi_newton::ElementMode::PrimalBlock,
7762                    _ => crate::hess::partitioned_quasi_newton::ElementMode::PerConstraint,
7763                };
7764            }
7765        }
7766        if let Ok((v, found)) = self.options.get_integer_value("partitioned_block_size", "") {
7767            if found && v > 0 {
7768                builder.partitioned_block_size = v as usize;
7769            }
7770        }
7771        if let Ok((v, found)) = self
7772            .options
7773            .get_numeric_value("partitioned_curvature_cap", "")
7774        {
7775            if found && v > 0.0 {
7776                builder.partitioned_curvature_cap = v;
7777            }
7778        }
7779        if let Ok((v, found)) = self
7780            .options
7781            .get_string_value("limited_memory_update_type", "")
7782        {
7783            if found {
7784                builder.limited_memory_update_type = match v.as_str() {
7785                    "sr1" => UpdateType::Sr1,
7786                    _ => UpdateType::Bfgs,
7787                };
7788            }
7789        }
7790        // Limited-memory history length (`limited_memory_max_history`).
7791        if let Ok((v, found)) = self
7792            .options
7793            .get_integer_value("limited_memory_max_history", "")
7794        {
7795            if found && v >= 0 {
7796                builder.limited_memory_max_history = v as Index;
7797            }
7798        }
7799        // `limited_memory_initialization` — which formula picks the
7800        // initial Hessian scalar σ. Registered since the option port with
7801        // upstream's `scalar1` default and read nowhere until #677, so
7802        // the updater's own `Scalar2` default was the only value any
7803        // solve ever used: setting the option did nothing, and it warned
7804        // nothing. Same miss as gh#483 / #191 round 2 (which wired
7805        // `limited_memory_init_val_max`/`_min`) — this is the third
7806        // argument to that same `initial_hessian_scalar` call.
7807        //
7808        // The effective default now follows the registry (`scalar1`),
7809        // matching Ipopt. σ_scalar2/σ_scalar1 = (yᵀy·sᵀs)/(sᵀy)² ≥ 1 and
7810        // is unbounded as the curvature pair degrades, so on an
7811        // ill-conditioned problem `scalar2` inflates `B0 = σI` until it
7812        // swamps the rank-2 corrections and the step collapses.
7813        if let Ok((v, found)) = self
7814            .options
7815            .get_string_value("limited_memory_initialization", "")
7816        {
7817            if found {
7818                use crate::hess::lim_mem_quasi_newton::InitialApprox;
7819                // Every registered value is named explicitly. #551 §3
7820                // held this option back precisely because wiring only
7821                // the values that mapped would leave the rest falling
7822                // back silently — a new no-op created by the fix — so a
7823                // catch-all standing in for a real value is the one
7824                // shape to avoid here. `OptionsList` rejects any
7825                // unregistered value before this runs (`options_list.rs`
7826                // `OPTION_INVALID`), so the final arm is unreachable
7827                // rather than a fallback.
7828                builder.limited_memory_initialization = match v.as_str() {
7829                    "scalar1" => InitialApprox::Scalar1,
7830                    "scalar2" => InitialApprox::Scalar2,
7831                    "scalar3" => InitialApprox::Scalar3,
7832                    "scalar4" => InitialApprox::Scalar4,
7833                    "constant" => InitialApprox::Constant,
7834                    "history-max" => InitialApprox::HistoryMax,
7835                    _ => InitialApprox::Scalar2,
7836                };
7837            }
7838        }
7839        // `recalc_y` / `recalc_y_feas_tol` — least-square re-estimation
7840        // of the equality multipliers once feasible (#677).
7841        //
7842        // Upstream registers `recalc_y` as `no`, but its own option text
7843        // ends "If a limited memory quasi-Newton option is chosen, this
7844        // is used by default", so upstream's effective default is
7845        // conditional on the Hessian approximation.
7846        //
7847        // **pounce does not follow that, and the discrepancy is
7848        // deliberate.** Auto-enabling it for the limited-memory path was
7849        // implemented and measured against the fixture corpus first: it
7850        // moved 16 of 57 fixtures on the L-BFGS leg and took **7 from
7851        // solved to not solved, with nothing moving the other way** —
7852        // `airport` 56 it → `SearchDirectionBecomesTooSmall` at 541,
7853        // `pooling_rt2stp` 413 it → the same at 1775, all three `jit1`
7854        // variants, `linear_eq_collapsed_box`, and `hs13_bigstart` to
7855        // the iteration cap. The signature is consistent: re-estimating
7856        // `y` on every feasible iteration overwrites Newton multipliers
7857        // that were converging, the dual never settles, and the step
7858        // vanishes short of the certificate.
7859        //
7860        // So the feature is available and off by default. That still
7861        // closes the gap that mattered — until #677 the option was
7862        // refused outright as unimplemented, so an L-BFGS user could not
7863        // reach Ipopt's behaviour at all. They can now, by asking.
7864        //
7865        // Why it is worth having: a quasi-Newton model's dual step is
7866        // computed from an approximate `W`, so L-BFGS can settle a
7867        // feasible primal and still not drive `inf_du` to tolerance —
7868        // the failure a 59,939-variable CasADi model hit, oscillating
7869        // `inf_du` between 3.6e-3 and 1.8e+01 for 300 iterations with
7870        // the objective already settled. On that shape it is the fix;
7871        // on a corpus of small well-conditioned models it is a
7872        // pessimisation. Matching upstream's conditional default needs
7873        // to explain the 7 regressions first.
7874        if let Ok((v, true)) = self.options.get_string_value("recalc_y", "") {
7875            builder.recalc_y = v == "yes";
7876        }
7877        if let Ok((v, true)) = self.options.get_numeric_value("recalc_y_feas_tol", "") {
7878            builder.recalc_y_feas_tol = v;
7879        }
7880        // `limited_memory_init_val` — σ before any curvature pair exists,
7881        // and every iteration under `constant`. Also unread until #677;
7882        // the empty-history branch hard-coded the same `1.0`.
7883        if let Ok((v, true)) = self
7884            .options
7885            .get_numeric_value("limited_memory_init_val", "")
7886        {
7887            builder.limited_memory_init_val = v;
7888        }
7889        // `limited_memory_max_skipping` (#686) — registered and unread,
7890        // and the feature behind it did not exist either: the updater
7891        // counted nothing and never discarded its history.
7892        if let Ok((v, true)) = self
7893            .options
7894            .get_integer_value("limited_memory_max_skipping", "")
7895        {
7896            if v >= 0 {
7897                builder.limited_memory_max_skipping = v as Index;
7898            }
7899        }
7900        if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
7901            if found {
7902                builder.line_search_method = match v.as_str() {
7903                    // Unreachable from a real solve since gh#981:
7904                    // `cg-penalty` is refused by
7905                    // `unimplemented_options::UNIMPLEMENTED_VALUES`
7906                    // before the builder is configured, because the
7907                    // variant assembles the plain penalty acceptor.
7908                    "cg-penalty" => LineSearchChoice::CgPenalty,
7909                    "penalty" => LineSearchChoice::Penalty,
7910                    _ => LineSearchChoice::Filter,
7911                };
7912            }
7913        }
7914        // `accept_every_trial_step` — direct user override. Parsed
7915        // after the Mehrotra cascade so an explicit `no` still wins.
7916        if let Ok((v, found)) = self.options.get_string_value("accept_every_trial_step", "") {
7917            if found {
7918                builder.line_search.accept_every_trial_step = v == "yes";
7919            }
7920        }
7921        // `alpha_for_y` — direct user override. Parsed after the
7922        // Mehrotra cascade so an explicit value still wins.
7923        if let Ok((v, found)) = self.options.get_string_value("alpha_for_y", "") {
7924            if found {
7925                use crate::line_search::backtracking::AlphaForY;
7926                builder.line_search.alpha_for_y = match v.as_str() {
7927                    "primal" => AlphaForY::Primal,
7928                    "bound-mult" | "bound_mult" => AlphaForY::BoundMult,
7929                    "full" => AlphaForY::Full,
7930                    "min" => AlphaForY::Min,
7931                    "max" => AlphaForY::Max,
7932                    "primal-and-full" | "dual-and-full" => AlphaForY::Primal,
7933                    _ => AlphaForY::Primal,
7934                };
7935            }
7936        }
7937        // `nlp_scaling_method` is consumed NLP-side in
7938        // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
7939        // `determine_scaling_from_starting_point` call earlier in this
7940        // method); there is no algorithm-side scaling strategy to wire.
7941        // `limited_memory_init_val_max` / `_min` — the clamp on the
7942        // initial Hessian scalar. `LimMemQuasiNewtonUpdater` consumes
7943        // both in `initial_hessian_scalar`; only the read sites were
7944        // missing, so setting either did nothing (gh#483, #191 round 2).
7945        if let Ok((v, true)) = self
7946            .options
7947            .get_numeric_value("limited_memory_init_val_max", "")
7948        {
7949            builder.limited_memory_init_val_max = v;
7950        }
7951        if let Ok((v, true)) = self
7952            .options
7953            .get_numeric_value("limited_memory_init_val_min", "")
7954        {
7955            builder.limited_memory_init_val_min = v;
7956        }
7957
7958        // Unlike the other options here, we always honor the registry
7959        // value (not just when the user set it explicitly): the option
7960        // registry default is "ma57" but `AlgorithmBuilder::default`
7961        // has `linear_solver: Feral`, so gating on `found` would
7962        // silently route default runs through Feral while the banner
7963        // (and ipopt-compatible behavior) advertises MA57.
7964        //
7965        // Record the **effective** backend, not the requested one. MA57 lives
7966        // behind the optional `ma57` cargo feature (HSL is licensed and needs a
7967        // Fortran toolchain); without it `default_backend_factory` silently
7968        // substitutes FERAL. Storing `Ma57` here therefore made
7969        // `builder.linear_solver` disagree with the backend actually built, and
7970        // consumers acted on the lie: the Schur KKT gate in
7971        // `alg_builder::build_with_backend` tests `== Feral`, so on the
7972        // pure-Rust default build — where the registry default (then upstream's
7973        // "ma57") resolved to FERAL anyway — `set_kkt_schur_block()` silently
7974        // never engaged for ANY user. Resolving here keeps the field truthful
7975        // for every consumer.
7976        //
7977        // The `_ =>` arm is now only reachable for `feral`: every other name
7978        // is refused up front by `unimplemented_linear_solver`. It used to
7979        // swallow `mumps`, `pardiso`, `ma97`, … and run FERAL instead.
7980        if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
7981            let requested = if v.eq_ignore_ascii_case("ma57") {
7982                LinearSolverChoice::Ma57
7983            } else {
7984                LinearSolverChoice::Feral
7985            };
7986            builder.linear_solver =
7987                if matches!(requested, LinearSolverChoice::Ma57) && !cfg!(feature = "ma57") {
7988                    LinearSolverChoice::Feral
7989                } else {
7990                    requested
7991                };
7992        }
7993
7994        // `linear_system_scaling` — symmetric scaling of the augmented
7995        // KKT matrix before factorization. Port of
7996        // `IpTSymLinearSolver.cpp:RegisterOptions` plumbing. Default
7997        // "none"; "ruiz" invokes the Ruiz-2001 symmetric ∞-norm
7998        // equilibration in `RuizTSymScalingMethod`. "mc19" and
7999        // "slack-based" are accepted by the registry but not yet
8000        // implemented at this layer; they fall back to no scaling
8001        // with a one-line notice.
8002        //
8003        // `slack-based` is implemented as of #677. It used to reach the
8004        // no-scaling fallback through the catch-all arm, which meant it
8005        // fell back **silently** — the comment above promised a notice
8006        // that only `mc19` actually emitted. It is not a hypothetical
8007        // value: it is what Ipopt's own recommended configuration for
8008        // large collocation NLPs uses, so the users most likely to set
8009        // it were the least likely to be told it did nothing. The
8010        // catch-all is left for genuinely unreachable input —
8011        // `OptionsList` rejects anything the registry does not list.
8012        if let Ok((v, found)) = self.options.get_string_value("linear_system_scaling", "") {
8013            if found {
8014                builder.linear_system_scaling = match v.as_str() {
8015                    "ruiz" => crate::alg_builder::LinearSystemScalingChoice::Ruiz,
8016                    "mc19" => crate::alg_builder::LinearSystemScalingChoice::Mc19,
8017                    "slack-based" => crate::alg_builder::LinearSystemScalingChoice::SlackBased,
8018                    _ => crate::alg_builder::LinearSystemScalingChoice::None,
8019                };
8020            }
8021        }
8022        if let Ok((v, found)) = self.options.get_bool_value("linear_scaling_on_demand", "") {
8023            if found {
8024                builder.linear_scaling_on_demand = v;
8025            }
8026        }
8027
8028        // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
8029        // `RegisterOptions` consumers). Defaults already match upstream
8030        // — only override when the user set the key explicitly.
8031        let read_num = |key: &str| -> Option<f64> {
8032            self.options
8033                .get_numeric_value(key, "")
8034                .ok()
8035                .and_then(|(v, f)| f.then_some(v))
8036        };
8037        let read_int = |key: &str| -> Option<i32> {
8038            self.options
8039                .get_integer_value(key, "")
8040                .ok()
8041                .and_then(|(v, f)| f.then_some(v))
8042        };
8043        if let Some(v) = read_num("tol") {
8044            builder.conv_check.tol = v;
8045        }
8046        if let Some(v) = read_num("obj_scale_certificate_threshold") {
8047            builder.conv_check.obj_scale_certificate_threshold = v;
8048        }
8049        if let Some(v) = read_num("primal_noise_floor_kappa") {
8050            builder.conv_check.primal_noise_floor_kappa = v;
8051        }
8052        if let Some(v) = read_num("acceptable_progress_kappa") {
8053            builder.conv_check.acceptable_progress_kappa = v;
8054        }
8055        if let Some(v) = read_num("dual_inf_scale_kappa") {
8056            builder.conv_check.dual_inf_scale_kappa = v;
8057        }
8058        if let Some(v) = read_num("kkt_fidelity_tol") {
8059            builder.kkt_fidelity_tol = v;
8060        }
8061        if let Some(v) = read_num("dual_inf_tol") {
8062            builder.conv_check.dual_inf_tol = v;
8063            // gh#983 item 1: a caller who names `dual_inf_tol` has asked for
8064            // that absolute standard. The scale-relative floor (gh#532) is a
8065            // default for a caller who has not, and it let `1e-6` certify a
8066            // point with `‖∇L‖∞ = 0.144` because the multipliers were 3.5e9
8067            // (a failed constraint qualification, which is not a reason to
8068            // loosen the test). Naming `dual_inf_scale_kappa` as well opts the
8069            // floor back in.
8070            //
8071            // gh#983 review: a value equal to the registered default is read
8072            // as "not named". Front ends that echo every default (option
8073            // files copied from Ipopt's documentation, wrappers that pass the
8074            // full table) would otherwise lose the gh#532 floor without asking
8075            // for anything. That is safe because the floor with the default
8076            // tolerance IS the default behaviour; a caller who wants the
8077            // absolute standard at `1` names `dual_inf_scale_kappa 0`, which
8078            // the option help documents.
8079            let default_tol = crate::alg_builder::ConvCheckOptions::default().dual_inf_tol;
8080            if read_num("dual_inf_scale_kappa").is_none() && v != default_tol {
8081                builder.conv_check.dual_inf_scale_kappa = 0.0;
8082            }
8083        }
8084        if let Some(v) = read_num("constr_viol_tol") {
8085            builder.conv_check.constr_viol_tol = v;
8086        }
8087        if let Some(v) = read_num("compl_inf_tol") {
8088            builder.conv_check.compl_inf_tol = v;
8089        }
8090        if let Some(v) = read_int("max_iter") {
8091            builder.conv_check.max_iter = v;
8092        }
8093        if let Some(v) = read_num("max_cpu_time") {
8094            builder.conv_check.max_cpu_time = v;
8095        }
8096        if let Some(v) = read_num("max_wall_time") {
8097            builder.conv_check.max_wall_time = v;
8098        }
8099        if let Some(v) = read_num("acceptable_tol") {
8100            builder.conv_check.acceptable_tol = v;
8101        }
8102        if let Some(v) = read_num("acceptable_dual_inf_tol") {
8103            builder.conv_check.acceptable_dual_inf_tol = v;
8104        }
8105        if let Some(v) = read_num("acceptable_constr_viol_tol") {
8106            builder.conv_check.acceptable_constr_viol_tol = v;
8107        }
8108        if let Some(v) = read_num("acceptable_compl_inf_tol") {
8109            builder.conv_check.acceptable_compl_inf_tol = v;
8110        }
8111        if let Some(v) = read_num("acceptable_obj_change_tol") {
8112            builder.conv_check.acceptable_obj_change_tol = v;
8113        }
8114        if let Some(v) = read_int("acceptable_iter") {
8115            builder.conv_check.acceptable_iter = v;
8116        }
8117        if let Some(v) = read_num("infeas_stationarity_tol") {
8118            builder.conv_check.infeas_stationarity_tol = v;
8119        }
8120        if let Some(v) = read_num("infeas_viol_kappa") {
8121            builder.conv_check.infeas_viol_kappa = v;
8122        }
8123        if let Some(v) = read_int("infeas_max_streak") {
8124            builder.conv_check.infeas_max_streak = v;
8125        }
8126
8127        // Bound-multiplier / barrier damping constants (#191). Both were
8128        // registered but never read, so user overrides were silently
8129        // dropped; the algorithm ran with the hard-coded struct defaults.
8130        // Defaults equal the registered defaults, so this changes nothing
8131        // for a run that doesn't set them.
8132        if let Some(v) = read_num("kappa_sigma") {
8133            builder.kappa_sigma = v;
8134        }
8135        if let Some(v) = read_num("ftb_centrality_floor") {
8136            builder.ftb_centrality_floor = v;
8137        }
8138        if let Some(v) = read_num("kappa_d") {
8139            builder.kappa_d = v;
8140        }
8141        // `s_max` — the cap in the `(s_d, s_c)` scaling of the KKT error
8142        // test (#551 / #677). `IpoptCalculatedQuantities` carried it as a
8143        // hard-coded 100 (the registered default) and nothing read the
8144        // option; a run that does not set it is unaffected.
8145        if let Some(v) = read_num("s_max") {
8146            builder.s_max = v;
8147        }
8148        if let Some(v) = read_num("tiny_step_tol") {
8149            builder.tiny_step_tol = v;
8150        }
8151        if let Some(v) = read_num("tiny_step_y_tol") {
8152            builder.tiny_step_y_tol = v;
8153        }
8154        if let Some(v) = read_num("diverging_iterates_tol") {
8155            builder.diverging_iterates_tol = v;
8156        }
8157        if let Some(v) = read_int("dual_diverging_streak") {
8158            builder.dual_diverging_streak = v;
8159        }
8160        if let Some(v) = read_num("dual_divergence_retry_step_tol") {
8161            builder.dual_divergence_retry_step_tol = v;
8162        }
8163        if let Some(v) = read_num("dual_divergence_retry_du_floor") {
8164            builder.dual_divergence_retry_du_floor = v;
8165        }
8166        if let Some(v) = read_int("tiny_step_dual_stall_iters") {
8167            builder.tiny_step_dual_stall_iters = v;
8168        }
8169        if let Some(v) = read_int("resto_decline_deferrals") {
8170            builder.resto_decline_deferrals = v;
8171        }
8172        if let Some(v) = read_num("resto_decline_progress_ratio") {
8173            builder.resto_decline_progress_ratio = v;
8174        }
8175        if let Some(v) = read_int("neg_curv_escapes") {
8176            builder.neg_curv_escapes = v;
8177        }
8178        if let Some(v) = read_int("limited_memory_ls_failure_restarts") {
8179            builder.limited_memory_ls_failure_restarts = v;
8180        }
8181
8182        // Barrier-parameter (μ) options — consumers in
8183        // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
8184        // updaters share the same option names; the builder forwards
8185        // each into whichever strategy is assembled.
8186        if let Some(v) = read_num("mu_init") {
8187            builder.mu.mu_init = v;
8188        }
8189        if let Some(v) = read_num("mu_max") {
8190            builder.mu.mu_max = v;
8191        }
8192        if let Some(v) = read_num("mu_max_fact") {
8193            builder.mu.mu_max_fact = v;
8194        }
8195        if let Some(v) = read_num("mu_min") {
8196            builder.mu.mu_min = v;
8197        }
8198        if let Some(v) = read_num("mu_target") {
8199            builder.mu.mu_target = v;
8200        }
8201        if let Some(v) = read_num("mu_linear_decrease_factor") {
8202            builder.mu.mu_linear_decrease_factor = v;
8203        }
8204        if let Some(v) = read_num("mu_superlinear_decrease_power") {
8205            builder.mu.mu_superlinear_decrease_power = v;
8206        }
8207        if let Ok((v, found)) = self
8208            .options
8209            .get_string_value("mu_allow_fast_monotone_decrease", "")
8210        {
8211            if found {
8212                builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
8213            }
8214        }
8215        if let Some(v) = read_num("barrier_tol_factor") {
8216            builder.mu.barrier_tol_factor = v;
8217        }
8218        // `tau_min` — floor on the fraction-to-the-boundary parameter
8219        // (#551 / #677). Both `MonotoneMuUpdate` and `AdaptiveMuUpdate`
8220        // carried the field with upstream's 0.99 default and nothing
8221        // read the option, so an override was silently dropped. The
8222        // default equals the registered default, so this changes
8223        // nothing for a run that does not set it.
8224        if let Some(v) = read_num("tau_min") {
8225            builder.mu.tau_min = v;
8226        }
8227        if let Some(v) = read_num("sigma_max") {
8228            builder.mu.sigma_max = v;
8229        }
8230        if let Some(v) = read_num("sigma_min") {
8231            builder.mu.sigma_min = v;
8232        }
8233
8234        // Quality-function oracle knobs — consumers in
8235        // `IpQualityFunctionMuOracle.cpp:RegisterOptions`. Forwarded
8236        // to the oracle on every free-mode call.
8237        if let Ok((v, found)) = self
8238            .options
8239            .get_string_value("quality_function_norm_type", "")
8240        {
8241            if found {
8242                use crate::mu::oracle::quality_function::NormType;
8243                builder.mu.quality_function_norm_type = match v.as_str() {
8244                    "1-norm" => NormType::OneNorm,
8245                    "2-norm" => NormType::TwoNorm,
8246                    "max-norm" => NormType::MaxNorm,
8247                    _ => NormType::TwoNormSquared,
8248                };
8249            }
8250        }
8251        if let Ok((v, found)) = self
8252            .options
8253            .get_string_value("quality_function_centrality", "")
8254        {
8255            if found {
8256                use crate::mu::oracle::quality_function::CentralityType;
8257                builder.mu.quality_function_centrality = match v.as_str() {
8258                    "log" => CentralityType::LogCenter,
8259                    "reciprocal" => CentralityType::ReciprocalCenter,
8260                    "cubed-reciprocal" => CentralityType::CubedReciprocalCenter,
8261                    _ => CentralityType::None,
8262                };
8263            }
8264        }
8265        if let Ok((v, found)) = self
8266            .options
8267            .get_string_value("quality_function_balancing_term", "")
8268        {
8269            if found {
8270                use crate::mu::oracle::quality_function::BalancingTermType;
8271                builder.mu.quality_function_balancing_term = match v.as_str() {
8272                    "cubic" => BalancingTermType::CubicTerm,
8273                    _ => BalancingTermType::None,
8274                };
8275            }
8276        }
8277        if let Some(v) = read_int("quality_function_max_section_steps") {
8278            builder.mu.quality_function_max_section_steps = v;
8279        }
8280        if let Some(v) = read_num("quality_function_section_sigma_tol") {
8281            builder.mu.quality_function_section_sigma_tol = v;
8282        }
8283        if let Some(v) = read_num("quality_function_section_qf_tol") {
8284            builder.mu.quality_function_section_qf_tol = v;
8285        }
8286
8287        // `probing_iterate_quality_factor` — pounce-specific guard
8288        // (pounce#58) on the probing μ-oracle's input iterate. When
8289        // `curr_avrg_compl / curr_mu` exceeds this factor, the
8290        // μ-update layer signals restoration via
8291        // `IpoptData::request_resto` instead of letting probing
8292        // return `σ · mu_curr` ≫ previous μ. Default 1e4; set to ≤ 0
8293        // to disable. No upstream Ipopt counterpart.
8294        if let Some(v) = read_num("probing_iterate_quality_factor") {
8295            builder.mu.probing_iterate_quality_factor = v;
8296        }
8297
8298        // Adaptive-μ extras — consumers in
8299        // `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Only active when
8300        // `mu_strategy=adaptive`.
8301        if let Some(v) = read_num("adaptive_mu_safeguard_factor") {
8302            builder.mu.adaptive_mu_safeguard_factor = v;
8303        }
8304        if let Some(v) = read_num("adaptive_mu_monotone_init_factor") {
8305            builder.mu.adaptive_mu_monotone_init_factor = v;
8306        }
8307        if let Ok((v, found)) = self
8308            .options
8309            .get_bool_value("adaptive_mu_restore_previous_iterate", "")
8310        {
8311            if found {
8312                builder.mu.adaptive_mu_restore_previous_iterate = v;
8313            }
8314        }
8315        if let Some(v) = read_int("adaptive_mu_max_free_returns") {
8316            builder.mu.adaptive_mu_max_free_returns = v;
8317        }
8318        if let Some(v) = read_num("adaptive_mu_budget_pin_fraction") {
8319            builder.mu.adaptive_mu_budget_pin_fraction = v;
8320        }
8321        if let Some(v) = read_int("adaptive_mu_kkterror_red_iters") {
8322            if v >= 0 {
8323                builder.mu.adaptive_mu_kkterror_red_iters = v as usize;
8324            }
8325        }
8326        if let Some(v) = read_num("adaptive_mu_kkterror_red_fact") {
8327            builder.mu.adaptive_mu_kkterror_red_fact = v;
8328        }
8329        // `filter_margin_fact` / `filter_max_margin` (#551) — the margin
8330        // an entry must clear in the `obj-constr-filter` globalization
8331        // test. `AdaptiveMuUpdate` computes
8332        // `filter_margin_fact * min(filter_max_margin, err)` and has
8333        // always done so; only these two read sites were missing, so
8334        // setting either did nothing. Defaults equal the registered
8335        // defaults (1e-5 / 1.0), so an unset run is unchanged.
8336        if let Some(v) = read_num("filter_margin_fact") {
8337            builder.mu.filter_margin_fact = v;
8338        }
8339        if let Some(v) = read_num("filter_max_margin") {
8340            builder.mu.filter_max_margin = v;
8341        }
8342        if let Ok((v, found)) = self
8343            .options
8344            .get_string_value("adaptive_mu_kkt_norm_type", "")
8345        {
8346            if found {
8347                use crate::mu::adaptive::AdaptiveMuKktNorm;
8348                builder.mu.adaptive_mu_kkt_norm_type = match v.as_str() {
8349                    "1-norm" => AdaptiveMuKktNorm::OneNorm,
8350                    "2-norm" => AdaptiveMuKktNorm::TwoNorm,
8351                    "max-norm" => AdaptiveMuKktNorm::MaxNorm,
8352                    _ => AdaptiveMuKktNorm::TwoNormSquared,
8353                };
8354            }
8355        }
8356
8357        // Watchdog options — consumers in
8358        // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
8359        // the `BacktrackingLineSearch` at build time.
8360        if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
8361            builder.line_search.watchdog_shortened_iter_trigger = v;
8362        }
8363        if let Some(v) = read_int("watchdog_trial_iter_max") {
8364            builder.line_search.watchdog_trial_iter_max = v;
8365        }
8366        if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
8367            builder.line_search.soft_resto_pderror_reduction_factor = v;
8368        }
8369        if let Some(v) = read_int("max_soft_resto_iters") {
8370            builder.line_search.max_soft_resto_iters = v;
8371        }
8372        // `alpha_red_factor` (#678) and `accept_after_max_steps` (#551)
8373        // — both consumed by the α-loop in `BacktrackingLineSearch`,
8374        // both registered without a read site until those issues.
8375        // `alpha_red_factor`'s default (0.5) equals the registered one,
8376        // and `accept_after_max_steps` defaults to `-1`, which disables
8377        // the escape hatch, so neither moves a solve that leaves them
8378        // alone.
8379        if let Some(v) = read_num("alpha_red_factor") {
8380            builder.line_search.alpha_red_factor = v;
8381        }
8382        if let Some(v) = read_num("alpha_red_factor_min") {
8383            builder.line_search.alpha_red_factor_min = Some(v);
8384        }
8385        if let Some(v) = read_int("accept_after_max_steps") {
8386            builder.line_search.accept_after_max_steps = v;
8387        }
8388
8389        // Filter switching / Armijo / margin constants (#191). Consumed
8390        // by `FilterLsAcceptor` (only on the `Filter` line-search path);
8391        // registered but never read, so overrides were silently dropped.
8392        // Defaults equal the registered defaults.
8393        if let Some(v) = read_num("eta_phi") {
8394            builder.line_search.eta_phi = v;
8395        }
8396        // `delta` (#551) — the switching rule's multiplier on the
8397        // constraint violation (Eqn. (19)); `FilterLsAcceptor` has
8398        // always used it as `delta_armijo`, only the read site was
8399        // missing. Default 1.0 equals the registered default.
8400        if let Some(v) = read_num("delta") {
8401            builder.line_search.delta = v;
8402        }
8403        if let Some(v) = read_num("theta_min_fact") {
8404            builder.line_search.theta_min_fact = v;
8405        }
8406        if let Some(v) = read_num("theta_max_row_scale_kappa") {
8407            builder.line_search.theta_max_row_scale_kappa = v;
8408        }
8409        if let Some(v) = read_int("theta_max_adaptive_trigger") {
8410            builder.line_search.theta_max_adaptive_trigger = v.max(0) as u32;
8411        }
8412        if let Some(v) = read_num("theta_max_adaptive_factor") {
8413            builder.line_search.theta_max_adaptive_factor = v;
8414        }
8415        if let Some(v) = read_int("theta_max_adaptive_max_raises") {
8416            builder.line_search.theta_max_adaptive_max_raises = v.max(0) as u32;
8417        }
8418        if let Some(v) = read_num("theta_max_fact") {
8419            builder.line_search.theta_max_fact = v;
8420        }
8421        if let Some(v) = read_num("gamma_phi") {
8422            builder.line_search.gamma_phi = v;
8423        }
8424        if let Some(v) = read_num("gamma_theta") {
8425            builder.line_search.gamma_theta = v;
8426        }
8427        if let Some(v) = read_num("s_phi") {
8428            builder.line_search.s_phi = v;
8429        }
8430        if let Some(v) = read_num("s_theta") {
8431            builder.line_search.s_theta = v;
8432        }
8433        if let Some(v) = read_num("alpha_min_frac") {
8434            builder.line_search.alpha_min_frac = v;
8435        }
8436        if let Some(v) = read_num("obj_max_inc") {
8437            builder.line_search.obj_max_inc = v;
8438        }
8439        if let Some(v) = read_int("max_filter_resets") {
8440            builder.line_search.max_filter_resets = v;
8441        }
8442        if let Some(v) = read_int("filter_reset_trigger") {
8443            builder.line_search.filter_reset_trigger = v;
8444        }
8445        // `filter_theta_roundoff_retry` (gh#945).
8446        if let Ok((v, true)) = self
8447            .options
8448            .get_bool_value("filter_theta_roundoff_retry", "")
8449        {
8450            builder.line_search.filter_theta_roundoff_retry = v;
8451        }
8452        // `objective_noise_wolfe` (gh#1016).
8453        if let Ok((v, true)) = self.options.get_bool_value("objective_noise_wolfe", "") {
8454            builder.line_search.objective_noise_wolfe = v;
8455        }
8456        // Penalty line-search constants (#551), consumed by
8457        // `PenaltyLsAcceptor` (only on the `line_search_method=penalty`
8458        // / `cg-penalty` paths). The acceptor implements ν and the
8459        // Armijo test on the penalty merit function already; these four
8460        // were registered with no read site, so tuning the penalty
8461        // update did nothing. Defaults equal the registered defaults.
8462        if let Some(v) = read_num("nu_init") {
8463            builder.line_search.nu_init = v;
8464        }
8465        if let Some(v) = read_num("nu_inc") {
8466            builder.line_search.nu_inc = v;
8467        }
8468        if let Some(v) = read_num("rho") {
8469            builder.line_search.rho = v;
8470        }
8471        if let Some(v) = read_num("eta_penalty") {
8472            builder.line_search.eta_penalty = v;
8473        }
8474
8475        // Second-order-correction constants (#191), consumed by
8476        // `BacktrackingLineSearch`. `max_soc = 0` disables SOC.
8477        if let Some(v) = read_int("max_soc") {
8478            builder.line_search.max_soc = v;
8479        }
8480        if let Some(v) = read_num("kappa_soc") {
8481            builder.line_search.kappa_soc = v;
8482        }
8483        if let Some(v) = read_int("soc_method") {
8484            builder.line_search.soc_method = v;
8485        }
8486
8487        // Inertia-correction / Jacobian-regularization constants (#191),
8488        // consumed by `PdPerturbationHandler`. Registered but never read.
8489        if let Some(v) = read_num("max_hessian_perturbation") {
8490            builder.perturbation.max_hessian_perturbation = v;
8491        }
8492        if let Some(v) = read_num("min_hessian_perturbation") {
8493            builder.perturbation.min_hessian_perturbation = v;
8494        }
8495        if let Some(v) = read_num("perturb_inc_fact_first") {
8496            builder.perturbation.perturb_inc_fact_first = v;
8497        }
8498        if let Some(v) = read_num("perturb_inc_fact") {
8499            builder.perturbation.perturb_inc_fact = v;
8500        }
8501        if let Some(v) = read_num("perturb_dec_fact") {
8502            builder.perturbation.perturb_dec_fact = v;
8503        }
8504        if let Some(v) = read_num("first_hessian_perturbation") {
8505            builder.perturbation.first_hessian_perturbation = v;
8506        }
8507        if let Some(v) = read_num("jacobian_regularization_value") {
8508            builder.perturbation.jacobian_regularization_value = v;
8509        }
8510        if let Some(v) = read_num("jacobian_regularization_exponent") {
8511            builder.perturbation.jacobian_regularization_exponent = v;
8512        }
8513        if let Ok((v, true)) = self.options.get_bool_value("perturb_always_cd", "") {
8514            builder.perturbation.perturb_always_cd = v;
8515        }
8516        if let Some(v) = read_int("perturb_delta_c_max_rungs") {
8517            builder.perturbation.perturb_delta_c_max_rungs = v;
8518        }
8519        if let Some(v) = read_num("perturb_slack_fact") {
8520            builder.perturbation.perturb_slack_fact = v;
8521        }
8522        if let Some(v) = read_int("perturb_slack_max_rows") {
8523            builder.perturbation.perturb_slack_max_rows = v;
8524        }
8525
8526        // Iterative-refinement constants (#191), consumed by
8527        // `PdFullSpaceSolver`. Registered but never read.
8528        if let Some(v) = read_int("min_refinement_steps") {
8529            builder.refinement.min_refinement_steps = v;
8530        }
8531        if let Some(v) = read_int("max_refinement_steps") {
8532            builder.refinement.max_refinement_steps = v;
8533        }
8534        if let Some(v) = read_num("residual_ratio_max") {
8535            builder.refinement.residual_ratio_max = v;
8536        }
8537        if let Some(v) = read_num("residual_ratio_singular") {
8538            builder.refinement.residual_ratio_singular = v;
8539        }
8540        if let Some(v) = read_num("residual_improvement_factor") {
8541            builder.refinement.residual_improvement_factor = v;
8542        }
8543
8544        // Inertia-free curvature test (#551 / #677), also consumed by
8545        // `PdFullSpaceSolver`. `neg_curv_test_tol` had a field that only
8546        // ever held its 0.0 default, and `neg_curv_test_reg` had none at
8547        // all; both are now read, and the curvature test they configure
8548        // is implemented in `PdFullSpaceSolver::solve_once`. At the
8549        // registered default (`0.0`) the heuristic is off and the
8550        // inertia check runs as before.
8551        if let Some(v) = read_num("neg_curv_test_tol") {
8552            builder.refinement.neg_curv_test_tol = v;
8553        }
8554        if let Ok((v, true)) = self.options.get_bool_value("neg_curv_test_reg", "") {
8555            builder.refinement.neg_curv_test_reg = v;
8556        }
8557
8558        // Restoration-phase constants (#191). Carried on the outer builder
8559        // and copied into the `RestoAlgorithmBuilder` when the restoration
8560        // factory is minted (the frontends pass this builder in). The
8561        // restoration builder was never options-configured, so these were
8562        // registered but never read. Defaults equal the registered
8563        // defaults.
8564        if let Some(v) = read_num("bound_mult_reset_threshold") {
8565            builder.resto.bound_mult_reset_threshold = v;
8566        }
8567        if let Some(v) = read_num("constr_mult_reset_threshold") {
8568            builder.resto.constr_mult_reset_threshold = v;
8569        }
8570        if let Some(v) = read_num("resto_penalty_parameter") {
8571            builder.resto.resto_penalty_parameter = v;
8572        }
8573        if let Some(v) = read_num("resto_proximity_weight") {
8574            builder.resto.resto_proximity_weight = v;
8575        }
8576        // `required_infeasibility_reduction` (#439) — the κ_resto guard the
8577        // restoration sub-solve exits on. Registered since #191 but the
8578        // value was hardcoded at the callsite, so setting it was a silent
8579        // no-op.
8580        if let Some(v) = read_num("required_infeasibility_reduction") {
8581            builder.resto.required_infeasibility_reduction = v;
8582        }
8583        // gh#483 / #191 round 2: three restoration switches whose fields
8584        // `RestoAlgorithmBuilder` has consumed all along — the read site
8585        // was the only missing piece, so setting them did nothing.
8586        let read_yes = |key: &str| -> Option<bool> {
8587            match self.options.get_string_value(key, "") {
8588                Ok((v, true)) => Some(v.eq_ignore_ascii_case("yes")),
8589                _ => None,
8590            }
8591        };
8592        if let Some(v) = read_yes("evaluate_orig_obj_at_resto_trial") {
8593            builder.resto.evaluate_orig_obj_at_resto_trial = v;
8594        }
8595        if let Some(v) = read_yes("expect_infeasible_problem") {
8596            builder.resto.expect_infeasible_problem = v;
8597        }
8598        if let Some(v) = read_yes("start_with_resto") {
8599            builder.resto.start_with_resto = v;
8600        }
8601        // `max_resto_iter` (#551 / #677) — the cap on *successive*
8602        // restoration iterations. `RestoConvCheckAdapter` has enforced a cap
8603        // all along (returning `MaxIterExceeded` at the limit); the number
8604        // it enforced was a hard-coded constant in `resto_inner_solver.rs`,
8605        // so setting the option did nothing. The consumer's field is
8606        // `maximum_resto_iters`, not the option name — grepping for
8607        // `max_resto_iter` found only the registry (#551 caution 2).
8608        //
8609        // DEFAULT MISMATCH, LEFT AS IT IS ON PURPOSE: the registry declares
8610        // upstream's 3000000, pounce's effective cap is 3000
8611        // (`RestoOptions::default`). `read_int` fires only when the user set
8612        // the key, so an unset `max_resto_iter` still means 3000 and this
8613        // wiring is trajectory-neutral. Adopting upstream's number would let
8614        // restorations pounce currently truncates run on, which is a
8615        // trajectory change and needs its own measurement.
8616        if let Some(v) = read_int("max_resto_iter") {
8617            builder.resto.max_resto_iter = v;
8618        }
8619
8620        // Iteration-output options — consumed by `OrigIterationOutput`.
8621        if let Some(v) = read_int("print_frequency_iter") {
8622            builder.output.print_frequency_iter = v;
8623        }
8624        if let Some(v) = read_num("print_frequency_time") {
8625            builder.output.print_frequency_time = v;
8626        }
8627        if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
8628            if found {
8629                builder.output.print_info_string = v;
8630            }
8631        }
8632        if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
8633            if found {
8634                builder.output.inf_pr_output_internal = v == "internal";
8635            }
8636        }
8637
8638        // Warm-start options — consumed by `WarmStartIterateInitializer`
8639        // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
8640        // `warm_start_init_point` is the toggle that picks between the
8641        // default (cold) and warm-start initializers; the remaining
8642        // knobs are baked onto the chosen initializer at build time.
8643        if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
8644            if found {
8645                builder.warm_start_init_point = v;
8646            }
8647        }
8648        if let Some(v) = read_num("warm_start_bound_push") {
8649            builder.warm.bound_push = v;
8650        }
8651        if let Some(v) = read_num("warm_start_bound_frac") {
8652            builder.warm.bound_frac = v;
8653        }
8654        if let Some(v) = read_num("warm_start_slack_bound_push") {
8655            builder.warm.slack_bound_push = v;
8656        }
8657        if let Some(v) = read_num("warm_start_slack_bound_frac") {
8658            builder.warm.slack_bound_frac = v;
8659        }
8660        if let Some(v) = read_num("warm_start_mult_bound_push") {
8661            builder.warm.mult_bound_push = v;
8662        }
8663        if let Some(v) = read_num("warm_start_mult_init_max") {
8664            builder.warm.mult_init_max = v;
8665        }
8666        if let Some(v) = read_num("warm_start_target_mu") {
8667            builder.warm.target_mu = v;
8668        }
8669        // gh#606: residual-adaptive recentering. `warm_start_entire_iterate`
8670        // and `warm_start_same_structure` used to be parsed here into
8671        // fields nothing read; they are refused by
8672        // `unimplemented_options` instead (they name the
8673        // `GetWarmStartIterate` TNLP surface pounce does not expose).
8674        if let Ok((v, found)) = self.options.get_string_value("warm_start_recentering", "") {
8675            if found {
8676                builder.warm.recentering = if v.eq_ignore_ascii_case("none") {
8677                    crate::alg_builder::WarmStartRecentering::None
8678                } else {
8679                    crate::alg_builder::WarmStartRecentering::Residual
8680                };
8681            }
8682        }
8683
8684        // `DefaultIterateInitializer` knobs — parsed after the Mehrotra
8685        // cascade so explicit user values win
8686        // (mirrors upstream's `SetNumericValueIfUnset` semantics).
8687        if let Some(v) = read_num("bound_push") {
8688            builder.init.bound_push = v;
8689        }
8690        if let Some(v) = read_num("bound_frac") {
8691            builder.init.bound_frac = v;
8692        }
8693        if let Some(v) = read_num("slack_bound_push") {
8694            builder.init.slack_bound_push = v;
8695        }
8696        if let Some(v) = read_num("slack_bound_frac") {
8697            builder.init.slack_bound_frac = v;
8698        }
8699        if let Some(v) = read_num("constr_mult_init_max") {
8700            builder.init.constr_mult_init_max = v;
8701        }
8702        if let Some(v) = read_num("bound_mult_init_val") {
8703            builder.init.bound_mult_init_val = v;
8704        }
8705        if let Ok((v, found)) = self.options.get_string_value("bound_mult_init_method", "") {
8706            if found {
8707                builder.init.bound_mult_init_method = v;
8708            }
8709        }
8710        if let Ok((v, found)) = self
8711            .options
8712            .get_string_value("least_square_init_primal", "")
8713        {
8714            if found {
8715                builder.init.least_square_init_primal = v == "yes";
8716            }
8717        }
8718        builder
8719    }
8720}
8721
8722/// Map the integer `print_level` / `file_print_level` option to the
8723/// matching [`JournalLevel`] variant. Mirrors upstream's
8724/// `static_cast<EJournalLevel>(int_value)` with clamping.
8725/// The eight block dimensions of an iterate, in canonical order
8726/// (x, s, y_c, y_d, z_l, z_u, v_l, v_u). Used to guard the debugger's
8727/// warm-restart install against a structural mismatch between solves.
8728fn iterates_dims(c: &IteratesVector) -> [i32; 8] {
8729    [
8730        c.x.dim(),
8731        c.s.dim(),
8732        c.y_c.dim(),
8733        c.y_d.dim(),
8734        c.z_l.dim(),
8735        c.z_u.dim(),
8736        c.v_l.dim(),
8737        c.v_u.dim(),
8738    ]
8739}
8740
8741fn journal_level_from_int(v: i32) -> JournalLevel {
8742    match v.clamp(0, 12) {
8743        0 => JournalLevel::J_NONE,
8744        1 => JournalLevel::J_ERROR,
8745        2 => JournalLevel::J_STRONGWARNING,
8746        3 => JournalLevel::J_SUMMARY,
8747        4 => JournalLevel::J_WARNING,
8748        5 => JournalLevel::J_ITERSUMMARY,
8749        6 => JournalLevel::J_DETAILED,
8750        7 => JournalLevel::J_MOREDETAILED,
8751        8 => JournalLevel::J_VECTOR,
8752        9 => JournalLevel::J_MOREVECTOR,
8753        10 => JournalLevel::J_MATRIX,
8754        11 => JournalLevel::J_MOREMATRIX,
8755        _ => JournalLevel::J_ALL,
8756    }
8757}
8758
8759/// MA57 backend knobs snapshotted off an `OptionsList`, ready to be
8760/// handed to a backend factory.
8761///
8762/// The type exists — rather than passing `pounce_hsl::Ma57Options`
8763/// directly — so that the factory signature is the same shape whether or
8764/// not the `ma57` cargo feature is on. `pounce-py`, `pounce-cinterface`
8765/// and `pounce-restoration` all call [`default_backend_factory`] and none
8766/// of them can name a `pounce-hsl` type; without the feature this struct
8767/// carries nothing and costs nothing.
8768///
8769/// Build it with [`ma57_config_from_options`]. It is the MA57 half of
8770/// what [`feral_config_from_options`] does for FERAL, and the asymmetry
8771/// between the two was the root cause of gh#825: FERAL's knobs were
8772/// threaded into the factory and MA57's were not, so the factory had
8773/// nothing to give MA57 and called `Ma57SolverInterface::new()` — which
8774/// hard-codes the defaults. Every `ma57_*` option was registered,
8775/// documented, accepted, and then silently discarded.
8776#[derive(Debug, Clone, Default)]
8777pub struct Ma57Config {
8778    #[cfg(feature = "ma57")]
8779    opts: pounce_hsl::Ma57Options,
8780}
8781
8782impl Ma57Config {
8783    /// The wrapped MA57 settings.
8784    #[cfg(feature = "ma57")]
8785    pub fn options(&self) -> &pounce_hsl::Ma57Options {
8786        &self.opts
8787    }
8788}
8789
8790/// Read the `ma57_*` options off `options` under `prefix`, for handing
8791/// to [`default_backend_factory`] / [`default_backend_factory_with_sink`].
8792///
8793/// `prefix` is `""` for the main IPM and `"resto."` for the restoration
8794/// sub-IPM, mirroring upstream's
8795/// `Ma57TSolverInterface::InitializeImpl(options, prefix)`. The
8796/// restoration sub-IPM builds its own backend through its own
8797/// `InnerBackendFactoryFactory` (see
8798/// `pounce_restoration::resto_inner_solver`), so the two really are
8799/// separately configurable — before gh#825 neither was.
8800///
8801/// Without the `ma57` cargo feature this returns an empty config and
8802/// does not touch `options`; nothing downstream can consume MA57
8803/// settings in that build.
8804pub fn ma57_config_from_options(
8805    options: &pounce_common::options_list::OptionsList,
8806    prefix: &str,
8807) -> Ma57Config {
8808    #[cfg(feature = "ma57")]
8809    {
8810        Ma57Config {
8811            opts: pounce_hsl::Ma57Options::from_options_list(options, prefix),
8812        }
8813    }
8814    #[cfg(not(feature = "ma57"))]
8815    {
8816        let _ = (options, prefix);
8817        Ma57Config::default()
8818    }
8819}
8820
8821/// Construct the MA57 backend for a factory, or fall back to FERAL when
8822/// the `ma57` cargo feature is off.
8823///
8824/// Factored out of the two factories below so there is exactly one place
8825/// that decides how an MA57 backend is built from a [`Ma57Config`]. Both
8826/// factories used to inline `Ma57SolverInterface::new()`, and the
8827/// duplication is half of why gh#825 was easy to miss.
8828fn make_ma57_backend(
8829    ma57_cfg: &Ma57Config,
8830    feral_fallback: impl FnOnce() -> Box<dyn SparseSymLinearSolverInterface>,
8831) -> Box<dyn SparseSymLinearSolverInterface> {
8832    #[cfg(feature = "ma57")]
8833    {
8834        let _ = feral_fallback;
8835        Box::new(pounce_hsl::Ma57SolverInterface::with_options(
8836            *ma57_cfg.options(),
8837        ))
8838    }
8839    #[cfg(not(feature = "ma57"))]
8840    {
8841        // ma57 feature not compiled in — fall back to FERAL.
8842        let _ = ma57_cfg;
8843        feral_fallback()
8844    }
8845}
8846
8847/// Default symmetric linear-solver factory, parameterized by the
8848/// pounce-extension FERAL knobs and the `ma57_*` knobs read off the
8849/// application's `OptionsList`.
8850///
8851/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
8852/// available when the `ma57` cargo feature is enabled; without it,
8853/// requesting `linear_solver = ma57` falls back to FERAL with a
8854/// warning printed by the journalist (see [`AlgorithmBuilder`]).
8855///
8856/// Both configs are snapshots, not live views: take them with
8857/// [`feral_config_from_options`] and [`ma57_config_from_options`] at the
8858/// point the application's options are fully populated. The factory is
8859/// called more than once per solve (the main KKT solver and, under
8860/// limited memory, the Hessian-free bypass solver), so each call gets a
8861/// fresh backend built from the same snapshot.
8862pub fn default_backend_factory(
8863    feral_cfg: pounce_feral::FeralConfig,
8864    ma57_cfg: Ma57Config,
8865) -> LinearBackendFactory {
8866    Box::new(
8867        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
8868            match choice {
8869                LinearSolverChoice::Feral => Box::new(
8870                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
8871                ),
8872                LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
8873                    Box::new(pounce_feral::FeralSolverInterface::with_config(
8874                        feral_cfg.clone(),
8875                    ))
8876                }),
8877            }
8878        },
8879    )
8880}
8881
8882/// Turn a model-space block declaration into the KKT-space labels the block
8883/// solver takes (structured-KKT Phase 5b's mapper, plan §63.6).
8884///
8885/// The KKT is laid out `[x | slacks | equality duals | inequality duals]`, so
8886/// each part inherits a label from what it belongs to:
8887///
8888/// * an `x` column takes its variable's label — skipping variables fixed out
8889///   by `fixed_variable_treatment`, which are not in the KKT at all;
8890/// * a slack and its inequality dual take their constraint's label;
8891/// * an equality dual takes its constraint's label.
8892///
8893/// `var_blocks` / `con_blocks` are in the model's own indexing (`n_full_x`,
8894/// `n_full_g`); negative means shared. Returns the label per KKT index, or an
8895/// error naming the mismatch — the caller then falls back rather than
8896/// mislabel, because a label that is off by one row silently declares a
8897/// structure the matrix does not have.
8898pub fn map_block_structure_to_kkt(
8899    cls: &pounce_nlp::tnlp_adapter::BoundClassification,
8900    var_blocks: &[i32],
8901    con_blocks: &[i32],
8902) -> Result<Vec<i32>, String> {
8903    if var_blocks.len() != cls.n_full_x as usize {
8904        return Err(format!(
8905            "block structure has {} variable labels, the problem has {} variables",
8906            var_blocks.len(),
8907            cls.n_full_x
8908        ));
8909    }
8910    if con_blocks.len() != cls.n_full_g as usize {
8911        return Err(format!(
8912            "block structure has {} constraint labels, the problem has {} constraints",
8913            con_blocks.len(),
8914            cls.n_full_g
8915        ));
8916    }
8917    let n_x = cls.x_not_fixed_map.len();
8918    let n_c = cls.c_map.len();
8919    let n_d = cls.d_map.len();
8920    let mut labels = Vec::with_capacity(n_x + 2 * n_d + n_c);
8921    for &full in &cls.x_not_fixed_map {
8922        labels.push(var_blocks[full as usize]);
8923    }
8924    for &full in &cls.d_map {
8925        labels.push(con_blocks[full as usize]); // slack
8926    }
8927    for &full in &cls.c_map {
8928        labels.push(con_blocks[full as usize]); // equality dual
8929    }
8930    for &full in &cls.d_map {
8931        labels.push(con_blocks[full as usize]); // inequality dual
8932    }
8933    Ok(labels)
8934}
8935
8936/// Sink-aware variant of [`default_backend_factory`]. Identical
8937/// dispatch, but the FERAL backend is constructed with a
8938/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
8939/// aggregate post-mortem stats (factor counts, fill ratio, extremal
8940/// pivots, final inertia) after the solve returns. MA57 ignores the
8941/// sink — the HSL backend doesn't carry the same instrumentation yet.
8942pub fn default_backend_factory_with_sink(
8943    feral_cfg: pounce_feral::FeralConfig,
8944    ma57_cfg: Ma57Config,
8945    sink: Arc<Mutex<LinearSolverSummary>>,
8946) -> LinearBackendFactory {
8947    Box::new(
8948        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
8949            match choice {
8950                LinearSolverChoice::Feral => Box::new(
8951                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
8952                        .with_summary_sink(Arc::clone(&sink)),
8953                ),
8954                LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
8955                    Box::new(
8956                        pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
8957                            .with_summary_sink(Arc::clone(&sink)),
8958                    )
8959                }),
8960            }
8961        },
8962    )
8963}
8964
8965/// Read the `feral_*` extension options off `options`, falling
8966/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
8967/// for any knob the caller did not set explicitly. The returned
8968/// config is what every default-factory invocation (main IPM and
8969/// restoration sub-IPM) consumes.
8970pub fn feral_config_from_options(
8971    options: &pounce_common::options_list::OptionsList,
8972) -> pounce_feral::FeralConfig {
8973    feral_config_from_options_scoped(options, RefineCarveOut::InteriorPoint)
8974}
8975
8976/// Whether [`feral_config_from_options_scoped`] applies the interior-point
8977/// arm's limited-memory `feral_refine` carve-out.
8978#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8979pub enum RefineCarveOut {
8980    /// Apply it: under `hessian_approximation=limited-memory`, force
8981    /// `refine = false` unless the user said otherwise. This is the shipped
8982    /// interior-point behaviour and what [`feral_config_from_options`] does.
8983    InteriorPoint,
8984    /// Skip it. For arms that do not refine the unreduced system themselves,
8985    /// where the backend loop is the only refinement in the stack.
8986    None,
8987}
8988
8989/// [`feral_config_from_options`] with the limited-memory `refine` carve-out
8990/// made explicit.
8991///
8992/// The carve-out is interior-point-specific in both of its premises, and
8993/// neither holds on the active-set SQP arm. It is justified by
8994/// `PdFullSpaceSolver::compute_residuals` already refining the *unreduced*
8995/// Newton system — the SQP arm has no such host loop, so
8996/// `pounce_qp::factor::LinearSolver` relies on the backend's own refinement
8997/// ("refinement is *implicit* in this layer", that module's docs) and turning
8998/// it off removes refinement from the stack entirely. And it keys off
8999/// `hessian_approximation`, which is not that arm's Hessian selector at all:
9000/// `sqp_hessian` is.
9001///
9002/// Measured, and this is why the parameter exists rather than the SQP path
9003/// just calling the plain function: inheriting the carve-out moved 10 of 97
9004/// fixtures on the sweep's L-BFGS leg under `algorithm=active-set-sqp`, while
9005/// the exact leg did not move at all. Some of that movement looked like a win
9006/// (`mu_fallback_point_floor` MaximumIterationsExceeded/200 ->
9007/// SolveSucceeded/8), but it was a win bought by disabling refinement for a
9008/// reason that does not apply here, and it came with `hs13_bigstart` landing
9009/// on a different point (objective 1 -> 1.0018) and `eigena2` and
9010/// `mpcc_scholtes4_biactive` likewise. Turning refinement off on this arm may
9011/// well be worth measuring on its own merits; it should not arrive as a side
9012/// effect of honouring `linear_solver`.
9013pub fn feral_config_from_options_scoped(
9014    options: &pounce_common::options_list::OptionsList,
9015    carve_out: RefineCarveOut,
9016) -> pounce_feral::FeralConfig {
9017    let mut cfg = pounce_feral::FeralConfig::from_env();
9018    // Tri-state: the `(_, true)` arm only fires when the user set the
9019    // option explicitly. Leaving it unset keeps `cfg.cascade_break` at
9020    // `None`, which inherits FERAL's `NumericParams::default()` (CB on
9021    // as of FERAL Phase B / pounce#55). `Some(false)` explicitly
9022    // disarms (reproduces pre-Phase-B behaviour, surfaces FERAL's
9023    // `DelayBudgetExceeded` on non-root cascade victims).
9024    if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
9025        cfg.cascade_break = Some(v);
9026    }
9027    if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
9028        cfg.fma = v;
9029    }
9030    // Not tri-state, and deliberately not: on the limited-memory path
9031    // the IPM's default is the opposite of the library's (gh#710,
9032    // gh#698 obs 5). `FeralConfig` ships `refine = true` because a
9033    // caller that only refines its own system needs the backend loop.
9034    //
9035    // Scoped to limited-memory, because that is where the win was
9036    // measured and where it comes from. Under L-BFGS the Hessian-free
9037    // bypass batches the low-rank SMW correction into one multi-RHS
9038    // back-solve, and the backend loop refines per right-hand side, so
9039    // its cost scales with the memory depth — while what it polishes is
9040    // the *condensed* system, which is not where Waechter-Biegler 3.10
9041    // puts refinement. Turning it off takes `laptime` under
9042    // limited-memory from 1397 s to 394 s.
9043    //
9044    // The same switch is a net loss on the exact path, so it is not
9045    // applied there. It costs `NARX_CFy` 230 iterations (400 -> 630,
9046    // 173 s -> 250 s) to buy back 34 on `laptime` (380 -> 346). And the
9047    // exact path has no rung left to stand in for it: the
9048    // `increase_quality` escalation that once argued for dropping the
9049    // backend loop here proved inert and was itself removed, so with
9050    // `refine` off the host loop in `PdFullSpaceSolver` is the only
9051    // refinement in the stack — and it stops the moment it crosses
9052    // `residual_ratio_max` (1e-10), three orders looser than the ~1e-16
9053    // the backend-refined solves reach.
9054    //
9055    // Tightening that threshold instead is not the fix, and was measured
9056    // rather than assumed: it is chaotic on this corpus, flipping `deb7`
9057    // to `Error_In_Step_Computation` at 1e-12 and swinging
9058    // `pooling_rt2stp` between 107 and 199 iterations across 1e-11 to
9059    // 1e-13. Forcing extra passes via `min_refinement_steps` behaves the
9060    // same way (both fixtures break at 2). Restoring the backend loop is
9061    // trajectory-neutral next to either (`deb7` 146 -> 147,
9062    // `pooling_rt2stp` 107 -> 109).
9063    //
9064    // Ordered env-then-registry-then-option so `POUNCE_FERAL_REFINE`
9065    // still reaches this path and an explicit `feral_refine` still beats
9066    // both.
9067    //
9068    // gh#909: the registry read below takes the value and IGNORES the
9069    // found flag, which is the whole point. Reading it as `Ok((v, true))`
9070    // — the tri-state shape every other option in this function uses —
9071    // consults the user's setting and never the *registered default*, so
9072    // an unset option left `cfg.refine` at `FeralConfig::default()`'s
9073    // `true` while `pounce --print-options` reported the registry's.
9074    // Registered `no`, ran `yes`: the gh#677 shape, one option family
9075    // over. It is fixed by making the registry authoritative here rather
9076    // than by changing what runs — see below for why — so the registered
9077    // default moved to `yes` in the same commit and this read is what
9078    // keeps the two from drifting apart again.
9079    //
9080    // The scoping stays. There is no single default that is right on both
9081    // paths, and this is a two-sided trade of the same shape as
9082    // `feral_increase_quality` above: the option is the lever, and the
9083    // default is a choice rather than a fact. Measured on `e5f5830b`,
9084    // one binary, `feral_refine=no` against the default:
9085    //
9086    //   fixture corpus, exact leg: 10 legs move, NO status flips, and
9087    //     the net is favourable -- `issue_508_infeasible_gap_1em4`
9088    //     441 -> 245, `square_flowsheet_resto` 54 -> 47 (tot 185 -> 176),
9089    //     `issue_508_infeasible_gap_1em2` 114 -> 112, `deb7` 147 -> 146,
9090    //     `pooling_rt2stp` 109 -> 107, against one loss
9091    //     (`mu_fallback_point_floor` 31 -> 32).
9092    //   fixture corpus, lbfgs leg: identical, refine is already off there.
9093    //   `NARX_CFy` (Mittelmann): 400 -> 630 iterations, 208.7 s -> 254.2 s.
9094    //   `eigena2` final dual infeasibility: 3.43e-10 -> 5.20e-09, both
9095    //     inside `tol`, and see `issue_540_eigena2_superlinear_tail.rs`.
9096    //   AC-OPF (PGLib), gh#909: 10-18% faster, iteration counts
9097    //     unchanged, solution identical to ~13 significant figures.
9098    //
9099    // So the corpus and the AC-OPF benchmarks both prefer `no` and
9100    // `NARX_CFy` pays 57% more iterations for it. Nothing here separates
9101    // those, which is why the default is scoped rather than flipped.
9102    let limited_memory = carve_out == RefineCarveOut::InteriorPoint
9103        && matches!(
9104            options.get_string_value("hessian_approximation", ""),
9105            Ok((ref s, true)) if s == "limited-memory"
9106        );
9107    if std::env::var_os("POUNCE_FERAL_REFINE").is_none() {
9108        // Deliberately `_`, not `true`: see above.
9109        if let Ok((v, _)) = options.get_bool_value("feral_refine", "") {
9110            cfg.refine = v;
9111        }
9112        if limited_memory {
9113            cfg.refine = false;
9114        }
9115    }
9116    // An explicit setting beats both the carve-out and the environment.
9117    if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
9118        cfg.refine = v;
9119    }
9120    // gh #850: `feral_increase_quality` exists because this rung is a genuine
9121    // two-sided trade, and the option is the lever — the default is left ON,
9122    // which is the 0.11 behaviour.
9123    //
9124    // Ipopt's `IncreaseQuality` contract assumes a *monotone* escalation: MA57
9125    // raises `pivtol` toward `pivtolmax`, strictly more conservative each time,
9126    // so keeping it raised for the rest of the solve can only make the
9127    // factorization safer. FERAL's ladder changes which pivots are taken, which
9128    // is lateral in trajectory terms, and it persists the same way. So it
9129    // reroutes solves, and the reroute goes both ways:
9130    //
9131    //   it COSTS two whole solves, both `square_flowsheet_resto`:
9132    //     exact  Optimal/99  -> RestorationFailed/131 (a second-opinion rung
9133    //                          rescues it, at 185 iterations total)
9134    //     lbfgs  Optimal/178 -> 3000 iterations at the cap, rescued by nothing
9135    //   it BUYS accuracy where nothing else does:
9136    //     `watchdog_trial_is_not_a_divergence_verdict`'s 12-variable model ends
9137    //     `SolvedToAcceptableLevel` at obj 3.7e-6 with the rung and at obj 3.42
9138    //     against `f* = 0` without it — a wrong-ish answer under a
9139    //     success-shaped status, which is worse than an honest failure.
9140    //   and it buys iterations on five more fixture-legs (15-25%).
9141    //
9142    // There is no scoping that separates those. Measured with a process-global
9143    // firing cap on `square_flowsheet_resto`: the rung fires twice, once in the
9144    // main solve at iteration 25 and once inside restoration at `76r`, and
9145    // allowing only the first still loses the leg — so declining it just for
9146    // the restoration sub-solve would not help. Nor does a count separate them:
9147    // `deb7` and `square_flowsheet_resto` each fire it exactly twice on their
9148    // exact legs, one gaining 16% of its iterations and the other losing its
9149    // verdict.
9150    //
9151    // So the default stands, and the losing direction now recovers itself:
9152    // `feral_increase_quality_retry` (gh#857) re-solves once with this off when
9153    // a solve that actually escalated ends `Restoration_Failed` or
9154    // `Maximum_Iterations_Exceeded`.
9155    //
9156    // It is a re-solve rather than a *revertible* escalation because the
9157    // revertible one was tried. jkitchin/feral#192 landed as `reset_quality`,
9158    // was plumbed here and instrumented (376 escalations, 376 matching resets
9159    // on one solve), and recovers neither leg at either re-baselining boundary
9160    // -- the harm is the destination, not the duration. See
9161    // `dev-notes/second-opinion-promotions-in-the-sweep.md`.
9162    if let Ok((v, true)) = options.get_bool_value("feral_increase_quality", "") {
9163        cfg.increase_quality = v;
9164    }
9165    // Only consulted when `refine` is on; see `FeralConfig::refine_max_steps`
9166    // (gh#710). Registered as an integer option with lower bound 0, so the
9167    // cast cannot go negative.
9168    if let Ok((v, true)) = options.get_integer_value("feral_refine_steps", "") {
9169        cfg.refine_max_steps = v.max(0) as usize;
9170    }
9171    // Also only consulted when `refine` is on. Registered with lower bound
9172    // 0, and 0 disables the pre-check; see `FeralConfig::refine_target`.
9173    if let Ok((v, true)) = options.get_numeric_value("feral_refine_target", "") {
9174        cfg.refine_target = v.max(0.0);
9175    }
9176    // Explicit static-pivoting opt-in (feral#8 cascade breaker, pounce#254).
9177    // Same tri-state discipline: unset leaves `cfg.static_pivoting` at
9178    // whatever `from_env` resolved (`None` → inherit feral's delayed-pivot
9179    // default), so the default numeric path is unchanged.
9180    if let Ok((v, true)) = options.get_bool_value("feral_static_pivoting", "") {
9181        cfg.static_pivoting = Some(v);
9182    }
9183    if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
9184        cfg.singular_pivot_floor = v;
9185    }
9186    // Explicitly set pins an absolute floor for every dimension (`0`
9187    // disables the trigger); left unset, `None` keeps the dimension-aware
9188    // `n * eps` default (pounce gh#592).
9189    if let Ok((v, true)) = options.get_numeric_value("feral_inertia_pivot_floor", "") {
9190        cfg.inertia_pivot_floor = Some(v);
9191    }
9192    // Number option (not integer): the gate is a u64 and Index is i32, too
9193    // narrow for large flop counts or the u64::MAX reject-all sentinel. The
9194    // lower bound (0.0) rules out negatives; `as u64` then saturates a very
9195    // large finite value to u64::MAX (reject all tree-level parallelism).
9196    if let Ok((v, true)) = options.get_numeric_value("feral_min_par_flops", "") {
9197        cfg.min_par_flops = Some(v as u64);
9198    }
9199    if let Ok((v, true)) = options.get_numeric_value("feral_pivtol", "") {
9200        cfg.pivtol = v;
9201    }
9202    // Only override on explicit set so `from_env` (which itself
9203    // defaults to OrderingMethod::Auto) keeps governing unset cases.
9204    // Unrecognized tags are silently ignored — the registered enum
9205    // restricts inputs at the OptionsList layer.
9206    if let Ok((v, true)) = options.get_string_value("feral_ordering", "") {
9207        if let Some(m) = pounce_feral::parse_ordering_method(&v) {
9208            cfg.ordering = m;
9209        }
9210    }
9211    if let Ok((v, true)) = options.get_string_value("feral_ordering_preprocess", "") {
9212        if let Some(p) = pounce_feral::parse_ordering_preprocess(&v) {
9213            cfg.ordering_preprocess = p;
9214        }
9215    }
9216    // Same explicit-set discipline as `feral_ordering`: `from_env`
9217    // defaults to ScalingStrategy::Auto (FERAL's current default), so
9218    // leaving the option unset preserves existing behaviour exactly.
9219    if let Ok((v, true)) = options.get_string_value("feral_scaling", "") {
9220        if let Some(s) = pounce_feral::parse_scaling_strategy(&v) {
9221            cfg.scaling = s;
9222        }
9223    }
9224    cfg
9225}
9226
9227/// Withdraw a numerical infeasibility verdict the model's own starting point
9228/// disproves.
9229///
9230/// Applied at every site in this file that can return
9231/// `Infeasible_Problem_Detected` from a *numerical* argument — the IPM path's
9232/// restoration / cycle gates, the SQP path's infeasible-subproblem exit, and the
9233/// ℓ₁ wrapper's uncollapsed-slack certificate. Deliberately one gate rather than
9234/// three: the two preceding safeguards in this area (gh #376, gh #380) were each
9235/// added to one path and not its twin, and a hole survived both times.
9236///
9237/// Not applied to a presolve *certificate*
9238/// (`TNLP::presolve_infeasibility_proof`), which carries its own, tighter
9239/// refutation
9240/// (`pounce_presolve::witness_refutes_infeasibility`) and is a proof rather than
9241/// a numerical inference.
9242///
9243/// The replacement is `Error_In_Step_Computation`, the status this codebase
9244/// already uses for "the solve broke down and we are **not** claiming
9245/// infeasibility" — see the `cycle_exit` fallback in
9246/// [`crate::ipopt_alg::IpoptAlgorithm::invoke_restoration`], which picks between
9247/// exactly these two on exactly this question. It maps to AMPL 500, an honest
9248/// failure the caller can see, instead of AMPL 200, a wrong answer they cannot.
9249///
9250/// gh #379.
9251fn withdraw_infeasibility_if_refuted(
9252    tnlp: &Rc<RefCell<dyn TNLP>>,
9253    solver_status: SolverReturn,
9254    lo_inf: Number,
9255    up_inf: Number,
9256    tol: Number,
9257) -> SolverReturn {
9258    if solver_status != SolverReturn::LocalInfeasibility {
9259        return solver_status;
9260    }
9261    // A presolve proof is not a numerical inference; it does its own refutation.
9262    if tnlp.borrow().presolve_infeasibility_proof().is_some() {
9263        return solver_status;
9264    }
9265    match crate::infeasibility_refutation::starting_point_refutes_infeasibility(
9266        tnlp, lo_inf, up_inf, tol,
9267    ) {
9268        Some(w) => {
9269            tracing::debug!(
9270                target: "pounce::application",
9271                "[PN_INFEAS_REFUTED] the model's starting point satisfies every constraint \
9272                 (max violation {:.3e}) — withdrawing Infeasible_Problem_Detected",
9273                w.max_violation
9274            );
9275            SolverReturn::ErrorInStepComputation
9276        }
9277        None => solver_status,
9278    }
9279}
9280
9281/// How well a point satisfies the **user's own** rows and bounds.
9282///
9283/// Computed from `g = c(x)` and the inner TNLP's declared bounds, in the
9284/// user's units, with no reference to whatever problem the algorithm
9285/// actually iterated on. That distinction is the whole reason this
9286/// exists: on the ℓ₁ path the IPM converges the *augmented* NLP
9287/// `c(x) − p + n = target`, whose equality rows the slacks satisfy to
9288/// machine precision by construction, and reporting that residual as
9289/// the solve's constraint violation says nothing about `c(x) − target`
9290/// (gh#794 finding P1).
9291struct OriginalSpaceFeasibility {
9292    /// Largest absolute violation of any row or bound, in the user's units.
9293    max_violation: Number,
9294    /// Every row and bound negligible at `tol`, judged scale-relative.
9295    negligible_at_tol: bool,
9296    /// The same at `acceptable_tol`.
9297    negligible_at_acceptable: bool,
9298}
9299
9300/// Measure [`OriginalSpaceFeasibility`] at `x`, given `g = c(x)`.
9301///
9302/// `is_negligible` rather than `!is_significant`, deliberately, and for
9303/// the reason that function's own documentation gives: the question here
9304/// is "did the solve converge well enough to call this point feasible",
9305/// which must never demand more precision than the solver promised, so
9306/// the threshold is clamped at `tol` from below (`tol · max(|scale|, 1)`).
9307/// The refutation path next door asks the opposite question — "is this
9308/// residual real at this row's scale" — and correctly uses the pure
9309/// relative form.
9310///
9311/// Returns `None` when the model cannot be measured (bounds unreadable, a
9312/// non-finite value). `None` means "not measured", never "feasible": the
9313/// caller keeps whatever verdict it already had.
9314fn original_space_feasibility(
9315    tnlp: &Rc<RefCell<dyn TNLP>>,
9316    x: &[Number],
9317    g: &[Number],
9318    lower_bound_inf: Number,
9319    upper_bound_inf: Number,
9320    tol: Number,
9321    acceptable_tol: Number,
9322    constr_viol_tol: Number,
9323    acceptable_constr_viol_tol: Number,
9324    noise_floor_kappa: Number,
9325) -> Option<OriginalSpaceFeasibility> {
9326    use pounce_common::tolerance::is_negligible;
9327
9328    let info = tnlp.borrow_mut().get_nlp_info()?;
9329    let n = info.n.max(0) as usize;
9330    let m = info.m.max(0) as usize;
9331    if x.len() < n || g.len() < m {
9332        return None;
9333    }
9334
9335    let mut x_l = vec![0.0; n];
9336    let mut x_u = vec![0.0; n];
9337    let mut g_l = vec![0.0; m];
9338    let mut g_u = vec![0.0; m];
9339    if !tnlp.borrow_mut().get_bounds_info(BoundsInfo {
9340        x_l: &mut x_l,
9341        x_u: &mut x_u,
9342        g_l: &mut g_l,
9343        g_u: &mut g_u,
9344    }) {
9345        return None;
9346    }
9347
9348    let mut max_violation: Number = 0.0;
9349    let mut ok_tol = true;
9350    let mut ok_acceptable = true;
9351
9352    // Only *finite, present* bounds inform a row's magnitude: letting the
9353    // `±1e19` sentinel set the scale would make every row look satisfied,
9354    // the same trap `infeasibility_refutation` documents.
9355    let present = |b: Number, is_lower: bool| -> Option<Number> {
9356        let absent = if is_lower {
9357            b <= lower_bound_inf
9358        } else {
9359            b >= upper_bound_inf
9360        };
9361        (b.is_finite() && !absent).then_some(b)
9362    };
9363
9364    // Accumulates into `max_violation` / `ok_tol` / `ok_acceptable`; a
9365    // non-positive `viol` means the side is satisfied and contributes
9366    // nothing. Returns nothing — every caller is a statement.
9367    let mut judge = |viol: Number, scale: Number| {
9368        if viol <= 0.0 {
9369            return;
9370        }
9371        max_violation = max_violation.max(viol);
9372        // The scale-relative test alone is not a feasibility standard, and
9373        // on a large-magnitude row it is not even close to one: it accepts
9374        // anything up to `tol · |row|`, which on a row near `1e10` is `1e2`
9375        // at the default `tol`. An adversary probe on this branch built a
9376        // model infeasible by exactly `50` with its row at `1e10` and got
9377        // `Solve_Succeeded` — a *worse* verdict than this branch's own
9378        // parent, which refused the same point (the old `Σ(p+n)` argument
9379        // was crude but absolute). So the wrapper has to judge feasibility
9380        // the way the rest of the solver does.
9381        //
9382        // `OptErrorConvCheck::primal_component_passes` is that standard:
9383        // an absolute `constr_viol <= constr_viol_tol`, with scale-awareness
9384        // supplied by an abstention when every row sits at its own
9385        // floating-point noise floor (gh#528/gh#590) rather than by
9386        // multiplying the tolerance by the row's magnitude. That
9387        // abstention "cannot fabricate a success on a genuinely infeasible
9388        // model: such a model's violation is pinned at its infeasibility
9389        // gap, orders above `eps ·` the row's own magnitude" — which is
9390        // exactly the property `is_negligible` lacks and the probe
9391        // exploited (`50` is `~2e7 ×` this row's floor).
9392        //
9393        // The strict gate's `primal_resolvable` cannot be reused verbatim:
9394        // it is computed by the CQ on the *augmented* NLP, whose rows the
9395        // slacks satisfy to machine precision, so it would abstain always
9396        // and accept everything. The floor is therefore recomputed here on
9397        // the user's own row, from the same `kappa · eps · magnitude` the
9398        // option documents. `primal_noise_floor_kappa = 0` opts out, as it
9399        // does for the strict gate.
9400        //
9401        // Both arms are conjoined rather than substituted: the relative
9402        // test still catches a violation that is small in absolute terms
9403        // but large for its row, which is the gh#794 P1 case itself
9404        // (`ralph1` at `2.5e-7` under a `2.5e-11` tol).
9405        let noise = noise_floor_kappa * Number::EPSILON * scale.abs();
9406        let absolute_ok = |bound: Number| viol <= bound || viol <= noise;
9407        ok_tol &= is_negligible(viol, scale, tol) && absolute_ok(constr_viol_tol);
9408        ok_acceptable &=
9409            is_negligible(viol, scale, acceptable_tol) && absolute_ok(acceptable_constr_viol_tol);
9410    };
9411
9412    for i in 0..m {
9413        let v = g[i];
9414        if !v.is_finite() {
9415            return None;
9416        }
9417        let lo = present(g_l[i], true);
9418        let hi = present(g_u[i], false);
9419        let scale = v
9420            .abs()
9421            .max(lo.map_or(0.0, Number::abs))
9422            .max(hi.map_or(0.0, Number::abs));
9423        judge(lo.map_or(0.0, |b| b - v), scale);
9424        judge(hi.map_or(0.0, |b| v - b), scale);
9425    }
9426    for j in 0..n {
9427        let v = x[j];
9428        if !v.is_finite() {
9429            return None;
9430        }
9431        let lo = present(x_l[j], true);
9432        let hi = present(x_u[j], false);
9433        let scale = v
9434            .abs()
9435            .max(lo.map_or(0.0, Number::abs))
9436            .max(hi.map_or(0.0, Number::abs));
9437        judge(lo.map_or(0.0, |b| b - v), scale);
9438        judge(hi.map_or(0.0, |b| v - b), scale);
9439    }
9440
9441    Some(OriginalSpaceFeasibility {
9442        max_violation,
9443        negligible_at_tol: ok_tol,
9444        negligible_at_acceptable: ok_acceptable,
9445    })
9446}
9447
9448/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
9449/// Mirrors the table in
9450/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
9451/// map") and the corresponding switch in
9452/// `IpIpoptApplication.cpp:call_optimize`.
9453fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
9454    match s {
9455        SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
9456        SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
9457        SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
9458        SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
9459        SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
9460        SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
9461        SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
9462        SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
9463        SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
9464        SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
9465        SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
9466        SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
9467        SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
9468        SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
9469        SolverReturn::InvalidProblemDefinition => ApplicationReturnStatus::InvalidProblemDefinition,
9470        SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
9471        SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
9472        SolverReturn::InternalError | SolverReturn::Unassigned => {
9473            ApplicationReturnStatus::InternalError
9474        }
9475    }
9476}
9477
9478/// Best-effort evaluation of the objective at the algorithm's final
9479/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
9480/// to populate `SolveStatistics::final_scaled_objective`.
9481fn try_eval_curr_f(
9482    nlp: &Rc<RefCell<dyn IpoptNlp>>,
9483    x: &Rc<dyn pounce_linalg::Vector>,
9484) -> Result<Number, ()> {
9485    let mut nlp_mut = nlp.borrow_mut();
9486    Ok(nlp_mut.eval_f(&**x))
9487}
9488
9489/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
9490/// `true` when a status warrants a retry through the wrapper. Mirrors
9491/// ripopt#23's trigger set, extended per the audit's Refinement B
9492/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
9493/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
9494/// equivalent would let pass to the wrapper).
9495fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
9496    matches!(
9497        status,
9498        ApplicationReturnStatus::RestorationFailed
9499            | ApplicationReturnStatus::InfeasibleProblemDetected
9500            | ApplicationReturnStatus::SolvedToAcceptableLevel
9501            | ApplicationReturnStatus::MaximumIterationsExceeded
9502            | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
9503    )
9504}
9505
9506/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
9507/// We pull `x` (compressed in `x_var`-space) off the algorithm's
9508/// `data.curr`, lift it back to full TNLP indexing, and pass empty
9509/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
9510/// in compressed split form — re-assembling them into the user's
9511/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
9512/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
9513/// fleshed out). On success returns the unscaled objective evaluated
9514/// on the user TNLP at the final iterate; returns `Err` if the final
9515/// iterate is missing.
9516/// Read a `dyn Vector`'s entries. Empty for a non-dense backing; POUNCE is
9517/// dense-only, so that is defensive rather than a supported case.
9518/// `constr_viol_tol`, `dual_inf_tol` and `compl_inf_tol`, as the user set
9519/// them (unscaled).
9520struct UserTolerances {
9521    constr_viol: Number,
9522    dual_inf: Number,
9523    compl_inf: Number,
9524}
9525
9526/// Whether a crossed-over point meets the user's tolerances in user units on
9527/// the model as declared (gh#1054).
9528///
9529/// Crossover's own acceptance reads the SQP's residuals, which live in the
9530/// scaled space. That is the right space for the never-regress comparison
9531/// against the interior iterate, and the wrong one for "is this a solution of
9532/// the user's problem": gradient-based scaling sized a row at a start point
9533/// with `x₀ = 1e4`, and at `x = (2, 0)` a violation of `1` read `3.3e-7` —
9534/// under every scaled tolerance, so `hs13_bigstart` came back
9535/// `Solve_Succeeded` at an infeasible point with every printed residual `0`.
9536/// The interior-point verdict is gated in user units (pounce#173); a point
9537/// that replaces it is held to the same gate. Stationarity and
9538/// complementarity unscale by `|df|`, as the calculated quantities do.
9539fn meets_user_tolerances(
9540    nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
9541    res: &crate::sqp::SqpResult,
9542    report: &crate::crossover::CrossoverReport,
9543    tols: &UserTolerances,
9544) -> bool {
9545    let adapter = crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(nlp_handle));
9546    let (rows, bx) = adapter.declared_violation(&res.x);
9547    let df = adapter.obj_scale();
9548    let dual = res.final_stationarity / df;
9549    let compl = report.compl_after / df;
9550    let ok = rows <= tols.constr_viol
9551        && bx.unwrap_or(0.0) <= tols.constr_viol
9552        && dual <= tols.dual_inf
9553        && (compl.is_nan() || compl <= tols.compl_inf);
9554    if !ok {
9555        tracing::debug!(
9556            target: "pounce::crossover",
9557            "crossed-over point fails the user tolerances: rows {rows:.2e}, box {bx:?}, \
9558             dual {dual:.2e}, compl {compl:.2e}"
9559        );
9560    }
9561    ok
9562}
9563
9564/// The converged-or-not interior iterate `curr` as a crossover seed, in the
9565/// algorithm's compressed / scaled space. The SQP adapter presents that same
9566/// space, so nothing is translated beyond repacking the bound duals:
9567/// `λ_x = z_l − z_u`.
9568fn crossover_seed(
9569    nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
9570    curr: &crate::iterates_vector::IteratesVector,
9571) -> crate::crossover::CrossoverSeed {
9572    crate::crossover::CrossoverSeed {
9573        x: dense_values(&*curr.x),
9574        lambda_g: {
9575            let mut v = dense_values(&*curr.y_c);
9576            v.extend(dense_values(&*curr.y_d));
9577            v
9578        },
9579        lambda_x: crate::sqp::ipopt_adapter::pack_bound_multipliers(
9580            nlp_handle,
9581            &dense_values(&*curr.z_l),
9582            &dense_values(&*curr.z_u),
9583        ),
9584    }
9585}
9586
9587/// An accepted crossover result as an interior-point iterate.
9588///
9589/// All eight components have to move together: leaving `s`, `v_l` or `v_u`
9590/// describing the interior point while `x` and the duals describe the
9591/// crossed-over one would make the calculated quantities read off an iterate
9592/// that never existed, and the residuals reported to the user would be
9593/// neither point's. The slack relations are the barrier problem's own —
9594/// `s = d(x)` and `v_l − v_u = −y_d`. `None` (with a warning) when the result
9595/// does not fit the iterate's vectors.
9596fn crossover_iterate(
9597    nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
9598    curr: &crate::iterates_vector::IteratesVector,
9599    res: &crate::sqp::SqpResult,
9600) -> Option<crate::iterates_vector::IteratesVector> {
9601    let (m_c, m_d) = {
9602        let b = nlp_handle.borrow();
9603        (b.m_eq() as usize, b.m_ineq() as usize)
9604    };
9605    let y_c = &res.lambda_g[..m_c];
9606    let y_d = &res.lambda_g[m_c..];
9607    // `s = d(x)`: the adapter's combined constraint vector is `[c ; d]`,
9608    // so the inequality block is its tail.
9609    let mut adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(nlp_handle));
9610    let c_all = crate::sqp::SqpProblemSpec::eval_c(&mut adapter, &res.x);
9611    let s_new = &c_all[m_c..];
9612    debug_assert_eq!(s_new.len(), m_d);
9613    let (z_l, z_u) = crate::sqp::ipopt_adapter::split_bound_multipliers(nlp_handle, &res.lambda_x);
9614    let (v_l, v_u) = crate::sqp::ipopt_adapter::split_slack_multipliers(nlp_handle, y_d);
9615
9616    let mut out = curr.deep_copy();
9617    let ok = set_dense(&mut *out.x, &res.x)
9618        && set_dense(&mut *out.s, s_new)
9619        && set_dense(&mut *out.y_c, y_c)
9620        && set_dense(&mut *out.y_d, y_d)
9621        && set_dense(&mut *out.z_l, &z_l)
9622        && set_dense(&mut *out.z_u, &z_u)
9623        && set_dense(&mut *out.v_l, &v_l)
9624        && set_dense(&mut *out.v_u, &v_u);
9625    if !ok {
9626        // A non-dense backing or a length mismatch. POUNCE is dense-only,
9627        // so this is defensive — but a partially-written iterate is worse
9628        // than no crossover at all, so bail without touching `curr`.
9629        tracing::warn!(
9630            target: "pounce::crossover",
9631            "crossover result did not fit the iterate; keeping the interior point"
9632        );
9633        return None;
9634    }
9635    Some(out.freeze())
9636}
9637
9638fn dense_values(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
9639    v.as_any()
9640        .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
9641        .map(|d| d.expanded_values())
9642        .unwrap_or_default()
9643}
9644
9645/// Overwrite a `dyn Vector`'s entries. Returns false — writing nothing —
9646/// when the backing is not dense or the lengths disagree, so a caller
9647/// updating several components together can abandon the whole update rather
9648/// than leave a half-written iterate.
9649fn set_dense(v: &mut dyn pounce_linalg::Vector, vals: &[Number]) -> bool {
9650    match v
9651        .as_any_mut()
9652        .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
9653    {
9654        Some(d) if pounce_linalg::Vector::dim(d) as usize == vals.len() => {
9655            d.set_values(vals);
9656            true
9657        }
9658        _ => false,
9659    }
9660}
9661
9662/// An owned copy of a [`Solution`] payload already delivered to the user's
9663/// TNLP, enough to deliver it again.
9664///
9665/// Exists because a losing second-opinion retry has to be undoable. The status
9666/// a retry earns is already floored — `run_with_mu_strategy_fallback` returns
9667/// `first_status` unless the retry promotes — but the *point* was not, and the
9668/// user consumes the point. See that function for what went wrong without this.
9669#[derive(Debug, Clone)]
9670struct FinalizeSnapshot {
9671    status: SolverReturn,
9672    x: Vec<Number>,
9673    z_l: Vec<Number>,
9674    z_u: Vec<Number>,
9675    g: Vec<Number>,
9676    lambda: Vec<Number>,
9677    obj_value: Number,
9678    /// The `on_converged` firing of the attempt that produced this payload,
9679    /// `None` if the hook did not fire for it. Travels with the payload, so a
9680    /// floor replay brings the matching firing back with the answer.
9681    on_converged_id: Option<u64>,
9682}
9683
9684/// The option-table overrides (and the box flag) one retry applies for its
9685/// own solve, put back when the scope ends -- on **every** exit, unwinding
9686/// included.
9687///
9688/// The retries used to restore by hand after `optimize_constrained`
9689/// returned, so a panic inside it left the overrides in place. That is not
9690/// hypothetical for an embedder: `pounce-cinterface`'s `ffi_guard` catches the
9691/// panic and keeps the `IpoptApplication`, so the next `IpoptSolve` on that
9692/// handle ran with the scale audit's `max_iter` (`3x` the base solve's),
9693/// `obj_scaling_factor` multiplied by `k`, a CPU / wall budget shrunk to what
9694/// the audited run had left -- and, since gh#983's declared-box fix, the
9695/// variable box un-widened.
9696///
9697/// Derefs to the application, so the retry runs *through* the scope:
9698/// `scope.optimize_constrained(..)`. Each entry is restored with
9699/// [`OptionsList::restore_entry`], i.e. exactly as it stood, absence as
9700/// absence.
9701///
9702/// `on_unwind_only` is for the two retries (μ-strategy fallback, gh#884)
9703/// whose normal-path restore is deliberately *not* "as it stood" -- both
9704/// write the resolved value back with `found = true` -- and whose behaviour
9705/// on that path this guard must not change: there it only covers the unwind.
9706struct RetryOptionScope<'a> {
9707    app: &'a mut IpoptApplication,
9708    saved: Vec<(
9709        &'static str,
9710        pounce_common::options_list::OptionEntrySnapshot,
9711    )>,
9712    on_unwind_only: bool,
9713}
9714
9715impl<'a> RetryOptionScope<'a> {
9716    fn enter(app: &'a mut IpoptApplication, tags: &[&'static str], on_unwind_only: bool) -> Self {
9717        let saved = tags
9718            .iter()
9719            .map(|&t| (t, app.options.snapshot_entry(t)))
9720            .collect();
9721        Self {
9722            app,
9723            saved,
9724            on_unwind_only,
9725        }
9726    }
9727}
9728
9729impl std::ops::Deref for RetryOptionScope<'_> {
9730    type Target = IpoptApplication;
9731    fn deref(&self) -> &IpoptApplication {
9732        self.app
9733    }
9734}
9735
9736impl std::ops::DerefMut for RetryOptionScope<'_> {
9737    fn deref_mut(&mut self) -> &mut IpoptApplication {
9738        self.app
9739    }
9740}
9741
9742impl Drop for RetryOptionScope<'_> {
9743    fn drop(&mut self) {
9744        if self.on_unwind_only && !std::thread::panicking() {
9745            return;
9746        }
9747        for (tag, snap) in self.saved.drain(..) {
9748            self.app.options.restore_entry(tag, snap);
9749        }
9750        self.app.retry_keeps_declared_box.set(false);
9751    }
9752}
9753
9754/// The `final_*` half of [`SolveStatistics`] — the numbers that describe the
9755/// answer and the certificate attached to it.
9756///
9757/// Deliberately **not** the whole struct. `SolveStatistics` mixes two kinds of
9758/// number and they float back differently when a second-opinion retry loses:
9759///
9760/// * the `final_*` fields describe *the point being reported*, so they have to
9761///   agree with the status reported beside them. A `Solved_To_Acceptable_Level`
9762///   carrying a `final_kkt_error` two orders above `acceptable_tol` is
9763///   self-contradictory, and that is pounce#870.
9764/// * the wall clock (`total_wallclock_time_secs`) and the cumulative
9765///   dual-divergence flags describe *what the invocation did*: both attempts
9766///   really ran, so rewinding them would under-report the work actually
9767///   spent.
9768///
9769/// Everything else a solve report reads beside the final residuals describes
9770/// the *run that produced the returned point* (gh#983 item 5): the iteration
9771/// count and rows, the evaluation counts, the restoration tallies and
9772/// `quality_escalations`. Before that fix `deb7` at `max_iter=100` reported the
9773/// declined retry's 100 iterations beside the first attempt's objective and
9774/// point, and `eigenb2` (limited memory) 86 objective evaluations beside the
9775/// first attempt's 47 iterations and 159 evaluations. The first attempt's own
9776/// counts (`deb7`: 84 outer + 32 restoration = 116) are the ones that belong
9777/// with its point. The console's per-attempt summaries still show each
9778/// attempt's own tally.
9779///
9780/// So the certificate and the run it came from are floored together; only the
9781/// wall clock is the invocation's.
9782#[derive(Debug, Clone)]
9783struct SolutionCertificate {
9784    objective: Number,
9785    scaled_objective: Number,
9786    dual_inf: Number,
9787    constr_viol: Number,
9788    compl: Number,
9789    kkt_error: Number,
9790    unscaled_dual_inf: Number,
9791    unscaled_constr_viol: Number,
9792    unscaled_compl: Number,
9793    unscaled_kkt_error: Number,
9794    kkt_error_above_noise: Number,
9795    mu: Number,
9796    /// How far the attempt's point sits outside the model as declared --
9797    /// rows and box. Both are read off the statistics beside the residuals
9798    /// above, and both describe the *point*; leaving them out meant a
9799    /// declined retry's measurement was reported beside the base attempt's
9800    /// point (`Variable bound violation` and `final_declared_box_viol` naming
9801    /// the discarded re-solve's `9.9e-9` next to a point that sits inside
9802    /// its box).
9803    declared_constr_viol: Number,
9804    declared_box_viol: Number,
9805    /// gh#983 item 5: the iteration count and per-iteration rows of the
9806    /// attempt being floored. A solve report reads both from the statistics,
9807    /// so restoring the certificate without them left a report whose
9808    /// numbers describe the returned point and whose iteration table
9809    /// describes the declined retry.
9810    iteration_count: Index,
9811    iterations: Vec<pounce_nlp::solve_statistics::IterRecord>,
9812    /// The per-run evaluation and restoration tallies of the attempt, for the
9813    /// same reason as the iteration table: they are read off the statistics
9814    /// beside the final residuals, and a declined retry used to leave its own
9815    /// (`eigenb2` limited-memory: 86 objective evaluations reported beside the
9816    /// base attempt's 47 iterations and 159 evaluations).
9817    evals: [Index; 5],
9818    restoration: (Index, Index, Index, Number),
9819    quality_escalations: Index,
9820    /// gh#983: the scale the stationarity residual is judged against, the
9821    /// objective scale the run ended with, and the warnings raised about the
9822    /// attempt. All three describe the *point being reported*.
9823    unscaled_dual_scale: Number,
9824    obj_scaling_factor: Number,
9825    total_obj_scaling_factor: Number,
9826    start_obj_grad_max: Number,
9827    warnings: Vec<String>,
9828    /// gh#983 review item 3: the detector's verdict on this run alone.
9829    returned_run_signature: bool,
9830}
9831
9832/// A re-solve's counting state between [`IpoptApplication::begin_counted_resolve`]
9833/// and [`IpoptApplication::end_counted_resolve`].
9834struct CountedResolve {
9835    base_spent: Index,
9836    base_fallback: Option<pounce_nlp::MuStrategyFallbackRecord>,
9837}
9838
9839impl SolutionCertificate {
9840    fn of(s: &pounce_nlp::solve_statistics::SolveStatistics) -> Self {
9841        Self {
9842            objective: s.final_objective,
9843            scaled_objective: s.final_scaled_objective,
9844            dual_inf: s.final_dual_inf,
9845            constr_viol: s.final_constr_viol,
9846            compl: s.final_compl,
9847            kkt_error: s.final_kkt_error,
9848            unscaled_dual_inf: s.final_unscaled_dual_inf,
9849            unscaled_constr_viol: s.final_unscaled_constr_viol,
9850            unscaled_compl: s.final_unscaled_compl,
9851            unscaled_kkt_error: s.final_unscaled_kkt_error,
9852            kkt_error_above_noise: s.final_kkt_error_above_noise,
9853            mu: s.final_mu,
9854            declared_constr_viol: s.final_declared_constr_viol,
9855            declared_box_viol: s.final_declared_box_viol,
9856            iteration_count: s.iteration_count,
9857            iterations: s.iterations.clone(),
9858            evals: [
9859                s.num_obj_evals,
9860                s.num_constr_evals,
9861                s.num_obj_grad_evals,
9862                s.num_constr_jac_evals,
9863                s.num_hess_evals,
9864            ],
9865            restoration: (
9866                s.restoration_calls,
9867                s.restoration_inner_iters,
9868                s.restoration_outer_iters,
9869                s.restoration_wall_secs,
9870            ),
9871            quality_escalations: s.quality_escalations,
9872            unscaled_dual_scale: s.final_unscaled_dual_scale,
9873            obj_scaling_factor: s.final_obj_scaling_factor,
9874            total_obj_scaling_factor: s.final_total_obj_scaling_factor,
9875            start_obj_grad_max: s.start_obj_grad_max,
9876            warnings: s.warnings.clone(),
9877            returned_run_signature: s.returned_run_dual_divergence_signature,
9878        }
9879    }
9880
9881    fn restore_into(&self, s: &mut pounce_nlp::solve_statistics::SolveStatistics) {
9882        s.final_objective = self.objective;
9883        s.final_scaled_objective = self.scaled_objective;
9884        s.final_dual_inf = self.dual_inf;
9885        s.final_constr_viol = self.constr_viol;
9886        s.final_compl = self.compl;
9887        s.final_kkt_error = self.kkt_error;
9888        s.final_unscaled_dual_inf = self.unscaled_dual_inf;
9889        s.final_unscaled_constr_viol = self.unscaled_constr_viol;
9890        s.final_unscaled_compl = self.unscaled_compl;
9891        s.final_unscaled_kkt_error = self.unscaled_kkt_error;
9892        s.final_kkt_error_above_noise = self.kkt_error_above_noise;
9893        s.final_mu = self.mu;
9894        s.final_declared_constr_viol = self.declared_constr_viol;
9895        s.final_declared_box_viol = self.declared_box_viol;
9896        s.iteration_count = self.iteration_count;
9897        s.iterations = self.iterations.clone();
9898        s.num_obj_evals = self.evals[0];
9899        s.num_constr_evals = self.evals[1];
9900        s.num_obj_grad_evals = self.evals[2];
9901        s.num_constr_jac_evals = self.evals[3];
9902        s.num_hess_evals = self.evals[4];
9903        s.restoration_calls = self.restoration.0;
9904        s.restoration_inner_iters = self.restoration.1;
9905        s.restoration_outer_iters = self.restoration.2;
9906        s.restoration_wall_secs = self.restoration.3;
9907        s.quality_escalations = self.quality_escalations;
9908        s.final_unscaled_dual_scale = self.unscaled_dual_scale;
9909        s.final_obj_scaling_factor = self.obj_scaling_factor;
9910        s.final_total_obj_scaling_factor = self.total_obj_scaling_factor;
9911        s.start_obj_grad_max = self.start_obj_grad_max;
9912        s.warnings = self.warnings.clone();
9913        s.returned_run_dual_divergence_signature = self.returned_run_signature;
9914    }
9915}
9916
9917impl FinalizeSnapshot {
9918    /// Re-deliver this payload to `tnlp`, overwriting whatever a later attempt
9919    /// captured there.
9920    fn replay(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
9921        tnlp.borrow_mut().finalize_solution(
9922            Solution {
9923                status: self.status,
9924                x: &self.x,
9925                z_l: &self.z_l,
9926                z_u: &self.z_u,
9927                g: &self.g,
9928                lambda: &self.lambda,
9929                obj_value: self.obj_value,
9930            },
9931            &TnlpIpoptData::default(),
9932            &TnlpIpoptCq::default(),
9933        );
9934    }
9935}
9936
9937fn finalize_via_orig_nlp(
9938    nlp: &Rc<RefCell<dyn IpoptNlp>>,
9939    alg: &IpoptAlgorithm,
9940    solver_status: SolverReturn,
9941    _app_status: ApplicationReturnStatus,
9942    tnlp: &Rc<RefCell<dyn TNLP>>,
9943    sink: &RefCell<Option<FinalizeSnapshot>>,
9944    fixed_var_duals: bool,
9945) -> Result<Number, ()> {
9946    let curr = alg.data.borrow().curr.clone().ok_or(())?;
9947    // Lift compressed x_var → full-x (length `info.n`) so the user
9948    // TNLP receives the same shape it provided. With `make_parameter`
9949    // the fixed components are spliced back in by the IpoptNlp.
9950    let nlp_borrow = nlp.borrow();
9951    // `finalize_solution_x`, not `lift_x_to_full`: the reported point also
9952    // owes the user the `honor_original_bounds` projection. `f` and `g`
9953    // below are then evaluated at the point actually reported, so x/f/g
9954    // agree with each other.
9955    let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&*curr.x);
9956    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
9957    let n = info.n as usize;
9958    let m = info.m as usize;
9959    debug_assert_eq!(x_vec.len(), n);
9960    // Lift algorithm-side multipliers back into user-space (pounce#11).
9961    // Use the `finalize_solution_*` family (not the `pack_*` family): the
9962    // final solution duals must be reported in the user's *unscaled-
9963    // Lagrangian* convention `∇f + λ·∇g + z = 0`, which divides out the
9964    // `obj_scale_factor` the algorithm threads through `eval_h`. The `pack_*`
9965    // family deliberately omits that division because it feeds the scaled
9966    // `eval_h`; calling it here left every dual scaled by `obj_scale_factor`
9967    // whenever gradient-based scaling triggered (pounce#11 F1).
9968    // Backends without overrides return empty; fall back to zero stubs so the
9969    // user sees a length-consistent vector.
9970    let mut z_l = nlp_borrow.finalize_solution_z_l(&*curr.z_l);
9971    if z_l.is_empty() {
9972        z_l = vec![0.0; n];
9973    }
9974    let mut z_u = nlp_borrow.finalize_solution_z_u(&*curr.z_u);
9975    if z_u.is_empty() {
9976        z_u = vec![0.0; n];
9977    }
9978    let mut lambda = nlp_borrow.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
9979    if lambda.is_empty() {
9980        lambda = vec![0.0; m];
9981    }
9982    if fixed_var_duals {
9983        nlp_borrow.complete_fixed_var_bound_multipliers(&x_vec, &lambda, &mut z_l, &mut z_u);
9984    }
9985    drop(nlp_borrow);
9986    // Compute g(x) via the user TNLP so the final residual is
9987    // populated for the user.
9988    let mut g_final = vec![0.0; m];
9989    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
9990    let f_final = tnlp
9991        .borrow_mut()
9992        .eval_f(&x_vec, true)
9993        .unwrap_or(Number::NAN);
9994    let snap = FinalizeSnapshot {
9995        status: solver_status,
9996        x: x_vec,
9997        z_l,
9998        z_u,
9999        g: g_final,
10000        lambda,
10001        obj_value: f_final,
10002        on_converged_id: alg.data.borrow().on_converged_id,
10003    };
10004    snap.replay(tnlp);
10005    *sink.borrow_mut() = Some(snap);
10006    Ok(f_final)
10007}
10008
10009/// Bind SQP suboptions registered in `upstream_options.rs`
10010/// (`sqp_globalization`, `sqp_hessian`, `sqp_max_iter`, `sqp_tol`,
10011/// `sqp_constr_viol_tol`, `sqp_dual_inf_tol`, `sqp_l1_penalty`,
10012/// `sqp_bt_reduction`, `sqp_bt_min_alpha`, `sqp_print_level`,
10013/// `sqp_lbfgs_max_history`) onto
10014/// `opts`. Used by [`IpoptApplication::algorithm_builder_snapshot`]
10015/// before constructing an SQP algorithm.
10016fn apply_sqp_options(options: &OptionsList, opts: &mut crate::sqp::SqpOptions) {
10017    use crate::sqp::{SqpGlobalization, SqpHessianSource};
10018
10019    if let Ok((s, true)) = options.get_string_value("sqp_globalization", "") {
10020        opts.globalization = match s.as_str() {
10021            "filter" => SqpGlobalization::Filter,
10022            "l1-elastic" => SqpGlobalization::L1Elastic,
10023            _ => opts.globalization,
10024        };
10025    }
10026    // `hessian_approximation` is the upstream Ipopt option a frontend sets
10027    // when the caller supplies no second derivatives -- `pounce.minimize` does
10028    // it automatically, and warns that it is doing so. It was only ever read
10029    // on the IPM path, so an SQP solve ignored it and fell back to the
10030    // `Exact` default, asking the NLP for a Lagrangian Hessian that was never
10031    // provided. A zero Hessian turns the QP subproblem into an LP, which is
10032    // unbounded whenever the objective gradient has a component in the null
10033    // space of the active constraints -- so the solve died with
10034    // `Internal_Error` on problems the IPM handles without complaint:
10035    //
10036    //     min (x0-3)^2 + (x1-2)^2  s.t.  4 - x0 - x1 >= 0
10037    //
10038    // (IPM: x = [2.5, 1.5]. Active-set SQP before this: Internal_Error, or
10039    // with variable bounds, a run to the box corner along the null-space
10040    // direction.)
10041    //
10042    // The quasi-Newton source picked here is the *dense Powell-damped BFGS*,
10043    // not the limited-memory one, even though the requesting option is spelled
10044    // `limited-memory`. On this active-set-SQP path L-BFGS buys nothing: its
10045    // `as_triplet` materializes a full dense `n×n` Hessian for the QP
10046    // subproblem exactly as `DampedBfgs` does (the matrix-free product
10047    // interface that would make L-BFGS cheaper is not implemented yet), and it
10048    // is markedly less robust -- it stalls with
10049    // `Search_Direction_Becomes_Too_Small` (or reports the QP subproblem
10050    // `unbounded`) on easy, well-conditioned convex QPs whenever a general
10051    // inequality is active at the optimum, returning `success=False` with a
10052    // wrong `x` (issue #358). `DampedBfgs` solves those. So the automatic
10053    // approximation the facade injects when no analytic Hessian is available
10054    // maps to the robust dense update; a caller who genuinely wants
10055    // limited-memory storage can still request it explicitly with
10056    // `sqp_hessian = "lbfgs"` below (read after this, so it wins).
10057    //
10058    // ...up to `sqp_dense_hessian_max_n` variables (gh#1054). Above that the
10059    // dense update is not a robust choice but an impossible one -- `n²`
10060    // storage, and every QP factorization dense -- so `QuasiNewtonAuto`
10061    // resolves per solve, from the model's `n`, to L-BFGS in the lifted
10062    // low-rank form that keeps the QP sparse. The paragraph above still
10063    // governs every model at or below the ceiling.
10064    //
10065    // Read this before `sqp_hessian` so an explicit setting still wins.
10066    if let Ok((s, true)) = options.get_string_value("hessian_approximation", "") {
10067        if s == "limited-memory" {
10068            opts.hessian = SqpHessianSource::QuasiNewtonAuto;
10069        }
10070    }
10071    if let Ok((s, true)) = options.get_string_value("sqp_hessian", "") {
10072        opts.hessian = match s.as_str() {
10073            "exact" => SqpHessianSource::Exact,
10074            "damped-bfgs" => SqpHessianSource::DampedBfgs,
10075            "lbfgs" => SqpHessianSource::Lbfgs,
10076            _ => opts.hessian,
10077        };
10078    }
10079    if let Ok((v, true)) = options.get_integer_value("sqp_max_iter", "") {
10080        if v >= 0 {
10081            opts.max_iter = v as u32;
10082        }
10083    }
10084    if let Ok((v, true)) = options.get_numeric_value("sqp_tol", "") {
10085        opts.tol = v;
10086    }
10087    if let Ok((v, true)) = options.get_numeric_value("sqp_constr_viol_tol", "") {
10088        opts.constr_viol_tol = v;
10089    }
10090    if let Ok((v, true)) = options.get_numeric_value("sqp_dual_inf_tol", "") {
10091        opts.dual_inf_tol = v;
10092    }
10093    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty", "") {
10094        opts.l1_penalty = v;
10095    }
10096    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_safety", "") {
10097        opts.l1_penalty_safety = v;
10098    }
10099    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_max", "") {
10100        opts.l1_penalty_max = v;
10101    }
10102    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_reduction", "") {
10103        opts.bt_reduction = v;
10104    }
10105    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_min_alpha", "") {
10106        opts.bt_min_alpha = v;
10107    }
10108    if let Ok((v, true)) = options.get_integer_value("sqp_print_level", "") {
10109        opts.print_level = v.clamp(0, u8::MAX as i32) as u8;
10110    }
10111    if let Ok((v, true)) = options.get_integer_value("sqp_lbfgs_max_history", "") {
10112        if v >= 1 {
10113            opts.lbfgs_max_history = v as u32;
10114        }
10115    }
10116    // An explicit `sqp_qp_use_schur_updates` is the caller's choice and goes
10117    // to the QP options as given (`apply_qp_subproblem_options`); only an
10118    // unset one is left to the size rule.
10119    if let Ok((_, true)) = options.get_string_value("sqp_qp_use_schur_updates", "") {
10120        opts.qp_schur_min_dim = None;
10121    }
10122    if let Ok((v, true)) = options.get_integer_value("sqp_dense_hessian_max_n", "") {
10123        opts.dense_hessian_max_n = usize::try_from(v).unwrap_or(0);
10124    }
10125}
10126
10127/// Populate the active-set SQP **QP-subproblem** options
10128/// ([`pounce_qp::QpOptions`]) from the `sqp_qp_*` option family.
10129///
10130/// Sister to [`apply_sqp_options`], which handles the SQP *outer-loop*
10131/// options ([`crate::sqp::SqpOptions`]); this one feeds the inner QP
10132/// solver that `SqpAlgorithm` delegates each subproblem to. Consulted
10133/// only on the `ActiveSetSqp` path. Each knob is forwarded only when
10134/// the user explicitly set it, so the `pounce_qp` defaults stand
10135/// otherwise.
10136///
10137/// The reading itself is [`pounce_qp::ActiveSetOverrides`], shared with
10138/// `pounce_convex`'s direct active-set driver, which overlays the same
10139/// eight names onto the same `QpOptions` type. This function had its own
10140/// copy until then, and the two had drifted: this one silently ignored a
10141/// `sqp_qp_max_iter` of 0 and an unknown `sqp_qp_anti_cycling` value where
10142/// the other rejected them. Neither divergence was reachable — the
10143/// registry bounds `sqp_qp_max_iter` at 1 and restricts `anti_cycling` to
10144/// three values — but two readers of one option family is how a
10145/// reachable one starts.
10146fn apply_qp_subproblem_options(options: &OptionsList, opts: &mut pounce_qp::QpOptions) {
10147    match pounce_qp::ActiveSetOverrides::try_from_options_list(options) {
10148        Ok(overrides) => overrides.apply(opts),
10149        // Unreachable from here: this runs after `initialize()`, so every
10150        // value present has already been validated against the registered
10151        // bound that the reader re-checks. Say so out loud anyway rather
10152        // than solving with a configuration the user did not ask for —
10153        // silently dropping the whole family is exactly the failure mode
10154        // `tests/no_silent_options.rs` exists to prevent.
10155        Err(error) => tracing::error!(
10156            target: "pounce::options",
10157            %error,
10158            "sqp_qp_* options were rejected after the registry accepted them; \
10159             the QP subproblem is running on pounce-qp defaults"
10160        ),
10161    }
10162}
10163
10164/// The TNLP's own starting point as an SQP warm start, in algorithm space,
10165/// through the projections the IPM's warm initializer reads
10166/// (`prepare_warm_start`, `get_starting_x/y/z`). `None` when the NLP
10167/// declines. An unseeded bound multiplier (NaN, gh#622) counts as zero: the
10168/// SQP has no `bound_mult_init_val` of its own to resolve it to.
10169fn sqp_tnlp_warm_start(nlp_rc: &Rc<RefCell<dyn IpoptNlp>>) -> Option<crate::sqp::SqpIterates> {
10170    use pounce_linalg::dense_vector::DenseVectorSpace;
10171    let dense = |v: &dyn pounce_linalg::Vector| -> Vec<Number> {
10172        v.as_any()
10173            .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
10174            .map(|d| d.expanded_values().to_vec())
10175            .unwrap_or_default()
10176    };
10177    let (z_l, z_u, x, lambda_g) = {
10178        let mut nlp = nlp_rc.borrow_mut();
10179        if !nlp.prepare_warm_start() {
10180            return None;
10181        }
10182        let (n, m_eq, m_ineq) = (nlp.n(), nlp.m_eq(), nlp.m_ineq());
10183        let (n_xl, n_xu) = (nlp.x_l().dim(), nlp.x_u().dim());
10184        let (n_dl, n_du) = (nlp.d_l().dim(), nlp.d_u().dim());
10185        let mut x = DenseVectorSpace::new(n).make_new_dense();
10186        let mut y_c = DenseVectorSpace::new(m_eq).make_new_dense();
10187        let mut y_d = DenseVectorSpace::new(m_ineq).make_new_dense();
10188        let mut z_l = DenseVectorSpace::new(n_xl).make_new_dense();
10189        let mut z_u = DenseVectorSpace::new(n_xu).make_new_dense();
10190        let mut v_l = DenseVectorSpace::new(n_dl).make_new_dense();
10191        let mut v_u = DenseVectorSpace::new(n_du).make_new_dense();
10192        let ok = nlp.get_starting_x(&mut x)
10193            && nlp.get_starting_y(&mut y_c, &mut y_d)
10194            && nlp.get_starting_z(&mut z_l, &mut z_u, &mut v_l, &mut v_u);
10195        nlp.finish_warm_start();
10196        if !ok {
10197            return None;
10198        }
10199        let finite = |v: Vec<Number>| -> Vec<Number> {
10200            v.into_iter()
10201                .map(|a| if a.is_finite() { a } else { 0.0 })
10202                .collect()
10203        };
10204        let mut lambda_g = finite(dense(&y_c));
10205        lambda_g.extend(finite(dense(&y_d)));
10206        (
10207            finite(dense(&z_l)),
10208            finite(dense(&z_u)),
10209            dense(&x),
10210            lambda_g,
10211        )
10212    };
10213    let lambda_x = crate::sqp::ipopt_adapter::pack_bound_multipliers(nlp_rc, &z_l, &z_u);
10214    Some(crate::sqp::SqpIterates {
10215        x,
10216        lambda_g,
10217        lambda_x,
10218        working: None,
10219    })
10220}
10221
10222/// Map a staged SQP warm start from the caller's space into the algorithm's:
10223/// the exact inverse of what [`finalize_via_sqp`] / [`sqp_user_iterate`] do on
10224/// the way out, so that the multipliers a solve reports, fed back unchanged,
10225/// are the iterate the next solve starts from.
10226///
10227/// | field | user space (what callers stage) | algorithm space |
10228/// |---|---|---|
10229/// | `x` | full `n`, model units | fixed columns dropped, `x̃ = d ⊙ x` |
10230/// | `lambda_x` | full `n`, `z_L − z_U` | fixed dropped, `· obj_scal ⊘ d` |
10231/// | `lambda_g` | `m`, in `g` order | `[y_c; y_d]`, `y = λ · obj_scal / {c,d}_scale` |
10232/// | `working.constraints` | `m`, in `g` order | `[c; d]` order |
10233///
10234/// (`working.bounds` goes from full `n` to the reduced columns in the
10235/// caller, which also knows which elimination an already-reduced set came
10236/// from.)
10237///
10238/// `d` is the per-variable factor of a `ScalingTnlp` below the NLP (gh#486;
10239/// `z̃ = z ⊘ d`, its `get_starting_point` rule) and `obj_scal` includes the
10240/// sign of a maximize. The `λ` rule is `OrigIpoptNlp::initialize_starting_point`'s,
10241/// the IPM's inbound half of the same inverse.
10242///
10243/// Every frontend stages user space — the C API and `pounce-py` pass the
10244/// caller's `x` / `mult_g` / `z_L − z_U` through untouched. Before this they
10245/// went in as-is: on a model whose equality is not the first row, the two
10246/// constraint multipliers landed on each other's rows, and any
10247/// `obj_scaling_factor` ≠ 1 left them off by that factor.
10248///
10249/// An `x` / `lambda_x` already of the reduced length (a problem with fixed
10250/// variables) is taken as user-unit values of the surviving columns, in
10251/// order; any other length is left alone for the dimension check to reject.
10252fn sqp_warm_start_to_algorithm(nlp: &dyn IpoptNlp, w: &mut crate::sqp::SqpIterates) {
10253    let n_alg = nlp.n() as usize;
10254    let n_full = nlp.n_full_x() as usize;
10255    let m_full = nlp.n_full_g() as usize;
10256    let m_eq = nlp.m_eq() as usize;
10257    let obj_scal = nlp.obj_scaling_factor();
10258    let d = nlp.variable_scaling();
10259    // Algorithm column -> (user column, its x-scaling factor).
10260    let cols: Vec<(usize, Number)> = (0..n_alg)
10261        .map(|k| {
10262            let j = nlp.var_x_to_full_x(k as Index) as usize;
10263            let dj = d.as_ref().and_then(|d| d.get(j).copied()).unwrap_or(1.0);
10264            (j, dj)
10265        })
10266        .collect();
10267    let to_alg = |v: &[Number], f: &dyn Fn(Number, Number) -> Number| -> Option<Vec<Number>> {
10268        if v.len() == n_full {
10269            Some(cols.iter().map(|&(j, dj)| f(v[j], dj)).collect())
10270        } else if v.len() == n_alg {
10271            Some(cols.iter().zip(v).map(|(&(_, dj), &x)| f(x, dj)).collect())
10272        } else {
10273            None
10274        }
10275    };
10276    if let Some(x) = to_alg(&w.x, &|x, dj| x * dj) {
10277        w.x = x;
10278    }
10279    if let Some(lx) = to_alg(&w.lambda_x, &|z, dj| z * obj_scal / dj) {
10280        w.lambda_x = lx;
10281    }
10282    if w.lambda_g.len() == m_full {
10283        let cs = nlp.c_scale_vec();
10284        let ds = nlp.d_scale_vec();
10285        let mut y = vec![0.0; m_full];
10286        for (i, &lam) in w.lambda_g.iter().enumerate() {
10287            let i = i as Index;
10288            if let Some(k) = nlp.full_g_to_c_block(i) {
10289                let k = k as usize;
10290                let s = cs.as_ref().map_or(1.0, |v| v[k]);
10291                y[k] = lam / s * obj_scal;
10292            } else if let Some(k) = nlp.full_g_to_d_block(i) {
10293                let k = k as usize;
10294                let s = ds.as_ref().map_or(1.0, |v| v[k]);
10295                y[m_eq + k] = lam / s * obj_scal;
10296            }
10297        }
10298        w.lambda_g = y;
10299    }
10300    if let Some(ws) = w.working.as_mut() {
10301        ws.constraints = working_set_rows_to_algorithm(nlp, std::mem::take(&mut ws.constraints));
10302    }
10303}
10304
10305/// Algorithm row `k` of the `[c; d]` constraint vector → user `g` row, or
10306/// `None` when the NLP's row count does not describe a full split.
10307fn algorithm_rows_to_user(nlp: &dyn IpoptNlp) -> Option<Vec<usize>> {
10308    let m_full = nlp.n_full_g() as usize;
10309    let m_eq = nlp.m_eq() as usize;
10310    let m_alg = m_eq + nlp.m_ineq() as usize;
10311    if m_full != m_alg {
10312        return None;
10313    }
10314    let mut map = vec![usize::MAX; m_alg];
10315    for i in 0..m_full {
10316        let k = if let Some(k) = nlp.full_g_to_c_block(i as Index) {
10317            k as usize
10318        } else if let Some(k) = nlp.full_g_to_d_block(i as Index) {
10319            m_eq + k as usize
10320        } else {
10321            return None;
10322        };
10323        if k >= m_alg || map[k] != usize::MAX {
10324            return None;
10325        }
10326        map[k] = i;
10327    }
10328    Some(map)
10329}
10330
10331/// Constraint statuses of a working set, algorithm `[c; d]` order → user
10332/// `g` order. The SQP stacks equalities first, so on a model whose first
10333/// row is an inequality the statuses published positionally landed on the
10334/// wrong rows (`[Equality, AtUpper]` for `g = [ineq, eq]`). A vector of
10335/// any other length is returned unchanged for the dimension check to judge.
10336fn working_set_rows_to_user(
10337    nlp: &dyn IpoptNlp,
10338    rows: Vec<pounce_qp::ConsStatus>,
10339) -> Vec<pounce_qp::ConsStatus> {
10340    match algorithm_rows_to_user(nlp) {
10341        Some(map) if map.len() == rows.len() => {
10342            let mut user = vec![pounce_qp::ConsStatus::Inactive; rows.len()];
10343            for (k, &i) in map.iter().enumerate() {
10344                user[i] = rows[k];
10345            }
10346            user
10347        }
10348        _ => rows,
10349    }
10350}
10351
10352/// Inverse of [`working_set_rows_to_user`]: user `g` order → `[c; d]`.
10353fn working_set_rows_to_algorithm(
10354    nlp: &dyn IpoptNlp,
10355    rows: Vec<pounce_qp::ConsStatus>,
10356) -> Vec<pounce_qp::ConsStatus> {
10357    match algorithm_rows_to_user(nlp) {
10358        Some(map) if map.len() == rows.len() => map.iter().map(|&i| rows[i]).collect(),
10359        _ => rows,
10360    }
10361}
10362
10363/// The SQP iterate in the user's index spaces, for the live-inspector window
10364/// (gh#1015). Same conversions as [`finalize_via_sqp`] — `λ_x` is packed
10365/// signed (`z_L − z_U`) and is split by sign — minus the user-callback
10366/// evaluations: `g` comes from the loop's own `[c; d]` values.
10367fn sqp_user_iterate(
10368    nlp: &Rc<RefCell<dyn IpoptNlp>>,
10369    x: &[Number],
10370    lambda_g: &[Number],
10371    lambda_x: &[Number],
10372    c_vals: &[Number],
10373    grad_lag_x: &[Number],
10374    fixed_var_duals: bool,
10375) -> crate::intermediate::UserIterate {
10376    use pounce_linalg::dense_vector::DenseVectorSpace;
10377
10378    // `finalize_solution_z_*` read the compressed x_l / x_u spaces (one
10379    // entry per finite bound, via `x_l_map`), not one entry per variable;
10380    // `split_bound_multipliers` does that split, as crossover does.
10381    let (zl, zu) = crate::sqp::ipopt_adapter::split_bound_multipliers(nlp, lambda_x);
10382    let nlp = nlp.borrow();
10383    let n_alg = nlp.n() as usize;
10384    let m_eq = nlp.m_eq() as usize;
10385    let m_ineq = nlp.m_ineq() as usize;
10386    let n_user = nlp.n_full_x() as usize;
10387    let m_user = nlp.n_full_g() as usize;
10388
10389    let x_space = DenseVectorSpace::new(n_alg as Index);
10390    let c_space = DenseVectorSpace::new(m_eq as Index);
10391    let d_space = DenseVectorSpace::new(m_ineq as Index);
10392    let dv = |space: &Rc<DenseVectorSpace>, v: &[Number]| {
10393        let mut d = space.make_new_dense();
10394        if !v.is_empty() {
10395            d.set_values(v);
10396        }
10397        d
10398    };
10399
10400    let x_full = nlp.finalize_solution_x(&dv(&x_space, x));
10401    let mut z_l = nlp.finalize_solution_z_l(&dv(&DenseVectorSpace::new(zl.len() as Index), &zl));
10402    if z_l.is_empty() {
10403        z_l = vec![0.0; n_user];
10404    }
10405    let mut z_u = nlp.finalize_solution_z_u(&dv(&DenseVectorSpace::new(zu.len() as Index), &zu));
10406    if z_u.is_empty() {
10407        z_u = vec![0.0; n_user];
10408    }
10409    let (y_c, y_d) = lambda_g.split_at(m_eq.min(lambda_g.len()));
10410    let mut lambda = nlp.finalize_solution_lambda(&dv(&c_space, y_c), &dv(&d_space, y_d));
10411    if lambda.is_empty() {
10412        lambda = vec![0.0; m_user];
10413    }
10414    if fixed_var_duals {
10415        nlp.complete_fixed_var_bound_multipliers(&x_full, &lambda, &mut z_l, &mut z_u);
10416    }
10417    // Everything here is in the space of the TNLP the NLP wraps, which
10418    // under per-variable `user-scaling` is a `ScalingTnlp` seeing
10419    // `x̃ = d ⊙ x`; that wrapper undoes the substitution on the iterate's
10420    // way through its `intermediate_callback`, by its `finalize_solution`'s
10421    // rule (`x = x̃ ⊘ d`, `z = d ⊙ z̃`).
10422    let (c, d) = c_vals.split_at(m_eq.min(c_vals.len()));
10423    let g = nlp.pack_g_for_user(&dv(&c_space, c), &dv(&d_space, d));
10424    // The SQP iterates against the same live bounds (widened only when
10425    // `bound_relax_factor` is set by name on this arm).
10426    let (x_l_alg, x_u_alg) = crate::intermediate::algorithm_x_bounds(&*nlp, &x_full);
10427    crate::intermediate::UserIterate {
10428        x: x_full,
10429        z_l,
10430        z_u,
10431        g,
10432        lambda,
10433        scaling: Some(crate::intermediate::iterate_scaling(&*nlp)),
10434        x_l_alg,
10435        x_u_alg,
10436        grad_lag_x: crate::intermediate::user_grad_lag(&*nlp, grad_lag_x),
10437    }
10438}
10439
10440/// SQP-side analog of [`finalize_via_orig_nlp`]. Hands the SQP
10441/// solution iterate to the user TNLP via the standard
10442/// `finalize_solution` callback. Multiplier lifting goes through
10443/// the same OrigIpoptNlp hooks so the user sees the same shape
10444/// regardless of which algorithm produced the iterate.
10445///
10446/// Returns the user-space objective value on success.
10447fn finalize_via_sqp(
10448    nlp: &Rc<RefCell<dyn IpoptNlp>>,
10449    res: &crate::sqp::SqpResult,
10450    solver_status: pounce_nlp::SolverReturn,
10451    tnlp: &Rc<RefCell<dyn TNLP>>,
10452    sink: &RefCell<Option<FinalizeSnapshot>>,
10453    fixed_var_duals: bool,
10454) -> Result<Number, ()> {
10455    use pounce_linalg::dense_vector::DenseVectorSpace;
10456
10457    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
10458    let n = info.n as usize;
10459    let m = info.m as usize;
10460
10461    // Wrap SQP slices in DenseVectors so we can pass them through
10462    // the OrigIpoptNlp lift_x_to_full / pack_*_for_user hooks.
10463    let nlp_borrow = nlp.borrow();
10464    let n_alg = nlp_borrow.n() as usize;
10465    let m_eq = nlp_borrow.m_eq() as usize;
10466    let m_ineq = nlp_borrow.m_ineq() as usize;
10467    debug_assert_eq!(res.x.len(), n_alg);
10468    debug_assert_eq!(res.lambda_g.len(), m_eq + m_ineq);
10469    debug_assert_eq!(res.lambda_x.len(), n_alg);
10470
10471    let x_space = DenseVectorSpace::new(n_alg as Index);
10472    let c_space = DenseVectorSpace::new(m_eq as Index);
10473    let d_space = DenseVectorSpace::new(m_ineq as Index);
10474
10475    let mut x_dv = x_space.make_new_dense();
10476    x_dv.set_values(&res.x);
10477    let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&x_dv);
10478    debug_assert_eq!(x_vec.len(), n);
10479
10480    // λ_x is packed signed (z_l − z_u), one entry per variable. Split it
10481    // into the compressed x_l / x_u spaces `finalize_solution_z_*` read
10482    // (one entry per finite bound): splitting per variable put each
10483    // multiplier on the wrong variable whenever an unbounded one came
10484    // first, as crossover's `split_bound_multipliers` call already avoids.
10485    let (zl_vals, zu_vals) = crate::sqp::ipopt_adapter::split_bound_multipliers(nlp, &res.lambda_x);
10486    let mut z_l_compressed = DenseVectorSpace::new(zl_vals.len() as Index).make_new_dense();
10487    let mut z_u_compressed = DenseVectorSpace::new(zu_vals.len() as Index).make_new_dense();
10488    if !zl_vals.is_empty() {
10489        z_l_compressed.set_values(&zl_vals);
10490    }
10491    if !zu_vals.is_empty() {
10492        z_u_compressed.set_values(&zu_vals);
10493    }
10494    // `finalize_solution_*` (not `pack_*`): report unscaled-Lagrangian duals,
10495    // dividing out `obj_scale_factor` — see `finalize_via_orig_nlp` (F1).
10496    let mut z_l = nlp_borrow.finalize_solution_z_l(&z_l_compressed);
10497    if z_l.is_empty() {
10498        z_l = vec![0.0; n];
10499    }
10500    let mut z_u = nlp_borrow.finalize_solution_z_u(&z_u_compressed);
10501    if z_u.is_empty() {
10502        z_u = vec![0.0; n];
10503    }
10504
10505    // λ_g is [y_c; y_d]; split into the c/d blocks for lift.
10506    let mut y_c_dv = c_space.make_new_dense();
10507    let mut y_d_dv = d_space.make_new_dense();
10508    if m_eq > 0 {
10509        y_c_dv.set_values(&res.lambda_g[..m_eq]);
10510    }
10511    if m_ineq > 0 {
10512        y_d_dv.set_values(&res.lambda_g[m_eq..]);
10513    }
10514    let mut lambda = nlp_borrow.finalize_solution_lambda(&y_c_dv, &y_d_dv);
10515    if lambda.is_empty() {
10516        lambda = vec![0.0; m];
10517    }
10518    if fixed_var_duals {
10519        nlp_borrow.complete_fixed_var_bound_multipliers(&x_vec, &lambda, &mut z_l, &mut z_u);
10520    }
10521    drop(nlp_borrow);
10522
10523    let mut g_final = vec![0.0; m];
10524    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
10525    let f_final = tnlp
10526        .borrow_mut()
10527        .eval_f(&x_vec, true)
10528        .unwrap_or(Number::NAN);
10529    let snap = FinalizeSnapshot {
10530        status: solver_status,
10531        x: x_vec,
10532        z_l,
10533        z_u,
10534        g: g_final,
10535        lambda,
10536        obj_value: f_final,
10537        // The SQP arm has no `on_converged` firing.
10538        on_converged_id: None,
10539    };
10540    snap.replay(tnlp);
10541    *sink.borrow_mut() = Some(snap);
10542    Ok(f_final)
10543}
10544
10545/// Whether `options` resolve to the adaptive μ strategy, by the rules
10546/// `IpoptApplication::algorithm_builder_from_options` applies:
10547/// `mehrotra_algorithm=yes` forces adaptive; otherwise an explicit
10548/// `mu_strategy` wins; otherwise a limited-memory Hessian takes upstream's
10549/// quasi-Newton default, adaptive (gh#746), and anything else the registered
10550/// `monotone`.
10551///
10552/// Anything that asks "would this solve already run adaptive?" must ask this,
10553/// not read the raw `mu_strategy` string: under L-BFGS the string is unset and
10554/// the solve is adaptive. The second-opinion ladder read the string, so its
10555/// `mu_strategy=adaptive` rung re-ran a limited-memory base solve
10556/// byte-for-byte (`deb7`, 1610 iterations and `f = 99.636943584` both times).
10557pub fn mu_strategy_resolves_to_adaptive(options: &OptionsList) -> bool {
10558    if matches!(
10559        options.get_string_value("mehrotra_algorithm", ""),
10560        Ok((ref v, true)) if v == "yes"
10561    ) {
10562        return true;
10563    }
10564    if let Ok((v, true)) = options.get_string_value("mu_strategy", "") {
10565        return v == "adaptive";
10566    }
10567    matches!(
10568        options.get_string_value("hessian_approximation", ""),
10569        Ok((ref v, true)) if v == "limited-memory"
10570    )
10571}
10572
10573#[cfg(test)]
10574mod block_structure_tests {
10575    use super::map_block_structure_to_kkt;
10576    use pounce_nlp::tnlp_adapter::BoundClassification;
10577
10578    /// Three variables (the middle one fixed out) and four constraints: two
10579    /// equalities (rows 0 and 3) and two inequalities (rows 1 and 2).
10580    fn classification() -> BoundClassification {
10581        BoundClassification {
10582            n_full_x: 3,
10583            n_full_g: 4,
10584            n_x_fixed: 1,
10585            x_not_fixed_map: vec![0, 2],
10586            x_fixed_map: vec![1],
10587            x_fixed_vals: vec![0.0],
10588            full_to_var: vec![0, -1, 1],
10589            x_l_map: vec![],
10590            x_u_map: vec![],
10591            n_c: 2,
10592            c_map: vec![0, 3],
10593            n_d: 2,
10594            d_map: vec![1, 2],
10595            d_l_map: vec![],
10596            d_u_map: vec![],
10597            full_to_c: vec![0, -1, -1, 1],
10598            full_to_d: vec![-1, 0, 1, -1],
10599        }
10600    }
10601
10602    /// The KKT is `[x | slacks | equality duals | inequality duals]`, so a
10603    /// declaration in the model's terms lands as: the surviving variables'
10604    /// labels, then each inequality's label twice (slack and dual) around the
10605    /// equalities' labels. A variable fixed out of the problem contributes
10606    /// nothing — reading its label anyway would shift every later one.
10607    #[test]
10608    fn model_labels_map_onto_the_kkt_layout() {
10609        let cls = classification();
10610        let var_blocks = vec![7, 99, -1]; // x0 -> block 7, x1 fixed, x2 shared
10611        let con_blocks = vec![5, 6, -1, 8]; // eq 5, ineq 6, ineq shared, eq 8
10612        let labels = map_block_structure_to_kkt(&cls, &var_blocks, &con_blocks).unwrap();
10613        assert_eq!(
10614            labels,
10615            vec![
10616                7, -1, // x: the two unfixed variables
10617                6, -1, // slacks, in d order (rows 1 and 2)
10618                5, 8, // equality duals, in c order (rows 0 and 3)
10619                6, -1, // inequality duals, same order as their slacks
10620            ]
10621        );
10622    }
10623
10624    /// A length that does not match the problem is refused rather than
10625    /// truncated: a structure off by one row declares blocks the matrix does
10626    /// not have, and the solver would only notice as a wrong answer or a
10627    /// rejected partition.
10628    #[test]
10629    fn a_mismatched_declaration_is_refused() {
10630        let cls = classification();
10631        assert!(map_block_structure_to_kkt(&cls, &[1, 2], &[1, 2, 3, 4]).is_err());
10632        assert!(map_block_structure_to_kkt(&cls, &[1, 2, 3], &[1, 2, 3]).is_err());
10633    }
10634}
10635
10636#[cfg(test)]
10637mod tests {
10638    use super::*;
10639
10640    /// pounce#748 — the flipped default is conditional on the caller not
10641    /// having named a `mu_strategy`. All four combinations, because the
10642    /// point of the condition is that an explicit strategy suppresses the
10643    /// automatic retry while an explicit `mu_strategy_fallback` does not.
10644    #[test]
10645    fn mu_strategy_fallback_default_defers_to_an_explicit_strategy() {
10646        // Nothing set: the retry is on.
10647        let app = IpoptApplication::new();
10648        assert!(app.is_mu_strategy_fallback_enabled());
10649
10650        // Caller named a strategy: the automatic retry stands down.
10651        let mut app = IpoptApplication::new();
10652        app.options_mut()
10653            .set_string_value("mu_strategy", "monotone", true, false)
10654            .unwrap();
10655        assert!(!app.is_mu_strategy_fallback_enabled());
10656
10657        // ... unless they also asked for the retry explicitly.
10658        app.options_mut()
10659            .set_string_value("mu_strategy_fallback", "yes", true, false)
10660            .unwrap();
10661        assert!(app.is_mu_strategy_fallback_enabled());
10662
10663        // An explicit "no" is honoured with no strategy set.
10664        let mut app = IpoptApplication::new();
10665        app.options_mut()
10666            .set_string_value("mu_strategy_fallback", "no", true, false)
10667            .unwrap();
10668        assert!(!app.is_mu_strategy_fallback_enabled());
10669    }
10670
10671    /// gh#990: a `mu_strategy` a POUNCE frontend pinned (the Python
10672    /// no-`hessian` path's `monotone`) is not the caller naming a strategy,
10673    /// so the default-on stall retry stays on; an explicit `no` still wins.
10674    #[test]
10675    fn a_pounce_chosen_mu_strategy_keeps_the_fallback_on() {
10676        let mut app = IpoptApplication::new();
10677        app.options_mut()
10678            .set_string_value("hessian_approximation", "limited-memory", true, false)
10679            .unwrap();
10680        app.options_mut()
10681            .set_string_value("mu_strategy", "monotone", true, false)
10682            .unwrap();
10683        assert!(!app.is_mu_strategy_fallback_enabled());
10684        app.mark_mu_strategy_pounce_chosen();
10685        assert!(app.is_mu_strategy_fallback_enabled());
10686        // The pin still decides the schedule: no limited-memory -> adaptive.
10687        assert!(!app.effective_mu_strategy_is_adaptive());
10688        app.options_mut()
10689            .set_string_value("mu_strategy_fallback", "no", true, false)
10690            .unwrap();
10691        assert!(!app.is_mu_strategy_fallback_enabled());
10692    }
10693
10694    use pounce_nlp::tnlp::{
10695        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution,
10696        SparsityRequest, StartingPoint,
10697    };
10698
10699    struct Hs071Stub;
10700    impl TNLP for Hs071Stub {
10701        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
10702            // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
10703            // dense lower-triangular Hessian (10 nz).
10704            Some(NlpInfo {
10705                n: 4,
10706                m: 2,
10707                nnz_jac_g: 8,
10708                nnz_h_lag: 10,
10709                index_style: IndexStyle::C,
10710            })
10711        }
10712        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
10713            b.x_l.copy_from_slice(&[1.0; 4]);
10714            b.x_u.copy_from_slice(&[5.0; 4]);
10715            b.g_l.copy_from_slice(&[25.0, 40.0]);
10716            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
10717            true
10718        }
10719        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
10720            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
10721            true
10722        }
10723        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
10724            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
10725        }
10726        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
10727            grad.fill(0.0);
10728            true
10729        }
10730        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
10731            g.fill(0.0);
10732            true
10733        }
10734        fn eval_jac_g(
10735            &mut self,
10736            _x: Option<&[Number]>,
10737            _new_x: bool,
10738            mode: SparsityRequest<'_>,
10739        ) -> bool {
10740            if let SparsityRequest::Structure { irow, jcol } = mode {
10741                irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
10742                jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
10743            }
10744            true
10745        }
10746        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
10747    }
10748
10749    #[test]
10750    fn application_default_does_not_select_sqp() {
10751        let mut app = IpoptApplication::new();
10752        app.initialize().unwrap();
10753        assert!(!app.is_sqp_algorithm_selected());
10754    }
10755
10756    /// gh#1061: `algorithm=active-set-sqp` starts from the interior point at
10757    /// `n + m ≥ AUTO_HANDOVER_MIN_DIM` — and only there, only when the user
10758    /// asked for the active-set SQP, and never when they set
10759    /// `ipm_sqp_handover` themselves.
10760    #[test]
10761    fn active_set_sqp_hands_over_from_the_interior_point_at_size() {
10762        let big = (AUTO_HANDOVER_MIN_DIM - 1000, 1000);
10763        let small = (AUTO_HANDOVER_MIN_DIM - 1000, 999);
10764        let with = |opts: &str| {
10765            let mut app = IpoptApplication::new();
10766            app.initialize().unwrap();
10767            app.initialize_with_options_str(opts).unwrap();
10768            app
10769        };
10770        let sqp = with("algorithm active-set-sqp\n");
10771        assert!(sqp.sqp_auto_handover(big.0, big.1));
10772        assert!(!sqp.sqp_auto_handover(small.0, small.1));
10773        assert!(with("solver_selection qp-active-set\n").sqp_auto_handover(big.0, big.1));
10774        let ipm = with("");
10775        assert!(
10776            !ipm.sqp_auto_handover(big.0, big.1),
10777            "the IPM is not rerouted"
10778        );
10779        for set in ["no", "yes"] {
10780            let app = with(&format!(
10781                "algorithm active-set-sqp\nipm_sqp_handover {set}\n"
10782            ));
10783            assert!(
10784                !app.sqp_auto_handover(big.0, big.1),
10785                "ipm_sqp_handover={set} was set, so the user decides"
10786            );
10787        }
10788    }
10789
10790    #[test]
10791    fn application_routes_to_sqp_when_algorithm_option_set() {
10792        let mut app = IpoptApplication::new();
10793        app.initialize().unwrap();
10794        app.initialize_with_options_str("algorithm active-set-sqp\n")
10795            .unwrap();
10796        assert!(app.is_sqp_algorithm_selected());
10797    }
10798
10799    #[test]
10800    fn feral_min_par_flops_option_reaches_config() {
10801        let mut app = IpoptApplication::new();
10802        app.initialize().unwrap();
10803        // Unset on the OptionsList: falls through to FeralConfig::from_env,
10804        // which leaves it None (inherit feral's built-in default) when the
10805        // POUNCE_FERAL_MIN_PAR_FLOPS env var is also absent.
10806        assert_eq!(
10807            feral_config_from_options(app.options()).min_par_flops,
10808            None,
10809            "unset feral_min_par_flops should not force an override"
10810        );
10811        // Explicit set is mapped through, cast to u64. `0` is the "dispatch
10812        // on every eligible tree" setting and must survive the cast.
10813        app.initialize_with_options_str("feral_min_par_flops 0\n")
10814            .unwrap();
10815        assert_eq!(
10816            feral_config_from_options(app.options()).min_par_flops,
10817            Some(0)
10818        );
10819        // A large finite value passes through intact (5e8 > i32::MAX, which
10820        // is why this is a number option, not an integer one).
10821        app.initialize_with_options_str("feral_min_par_flops 5e8\n")
10822            .unwrap();
10823        assert_eq!(
10824            feral_config_from_options(app.options()).min_par_flops,
10825            Some(500_000_000)
10826        );
10827    }
10828
10829    #[test]
10830    fn feral_refine_steps_option_reaches_config() {
10831        let mut app = IpoptApplication::new();
10832        app.initialize().unwrap();
10833        // Unset: falls through to FeralConfig::from_env, which resolves to
10834        // feral's own DEFAULT_REFINE_MAX_STEPS. The bump to feral 0.17.0 is
10835        // deliberately behaviour-preserving here — the cap that gh#710 wants
10836        // to measure (1) is not yet the default.
10837        assert_eq!(
10838            feral_config_from_options(app.options()).refine_max_steps,
10839            pounce_feral::FeralConfig::default().refine_max_steps,
10840            "unset feral_refine_steps must keep feral's own default"
10841        );
10842        // The setting gh#710 exists to evaluate.
10843        app.initialize_with_options_str("feral_refine_steps 1\n")
10844            .unwrap();
10845        assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 1);
10846        // `0` is a legal cap (zero corrections, refined entry point still
10847        // taken) and must not be read as "unset".
10848        app.initialize_with_options_str("feral_refine_steps 0\n")
10849            .unwrap();
10850        assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 0);
10851    }
10852
10853    #[test]
10854    fn feral_static_pivoting_option_reaches_config() {
10855        let mut app = IpoptApplication::new();
10856        app.initialize().unwrap();
10857        // Unset on the OptionsList: falls through to FeralConfig::from_env,
10858        // which leaves it None (inherit feral's delayed-pivot default) when
10859        // the POUNCE_FERAL_STATIC_PIVOTING env var is also absent — so the
10860        // default numeric path is unchanged.
10861        assert_eq!(
10862            feral_config_from_options(app.options()).static_pivoting,
10863            None,
10864            "unset feral_static_pivoting must not force a numeric override"
10865        );
10866        // Explicit `yes` maps to Some(true): every supernode factors with
10867        // delayed pivoting disabled (feral#8 cascade breaker).
10868        app.initialize_with_options_str("feral_static_pivoting yes\n")
10869            .unwrap();
10870        assert_eq!(
10871            feral_config_from_options(app.options()).static_pivoting,
10872            Some(true)
10873        );
10874        // Explicit `no` maps to Some(false): keep delayed pivoting on
10875        // (distinct from unset, which merely inherits the default).
10876        app.initialize_with_options_str("feral_static_pivoting no\n")
10877            .unwrap();
10878        assert_eq!(
10879            feral_config_from_options(app.options()).static_pivoting,
10880            Some(false)
10881        );
10882    }
10883
10884    /// Convex equality NLP fixture for end-to-end SQP testing
10885    /// through `IpoptApplication`:
10886    ///
10887    ///     min ½(x₁² + x₂²) − x₁ − 2x₂  s.t.  x₁ + x₂ = 1
10888    ///
10889    /// Closed form: x* = (0, 1), obj = -1.5, λ_g = 1.
10890    struct ConvexEqTnlp {
10891        finalize_called: std::rc::Rc<std::cell::RefCell<Option<(Vec<Number>, Number)>>>,
10892    }
10893    impl TNLP for ConvexEqTnlp {
10894        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
10895            Some(NlpInfo {
10896                n: 2,
10897                m: 1,
10898                nnz_jac_g: 2,
10899                nnz_h_lag: 2,
10900                index_style: IndexStyle::C,
10901            })
10902        }
10903        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
10904            b.x_l.copy_from_slice(&[-2.0e19; 2]);
10905            b.x_u.copy_from_slice(&[2.0e19; 2]);
10906            b.g_l.copy_from_slice(&[1.0]);
10907            b.g_u.copy_from_slice(&[1.0]);
10908            true
10909        }
10910        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
10911            sp.x.copy_from_slice(&[0.0, 0.0]);
10912            true
10913        }
10914        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
10915            Some(0.5 * (x[0] * x[0] + x[1] * x[1]) - x[0] - 2.0 * x[1])
10916        }
10917        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
10918            grad[0] = x[0] - 1.0;
10919            grad[1] = x[1] - 2.0;
10920            true
10921        }
10922        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
10923            g[0] = x[0] + x[1];
10924            true
10925        }
10926        fn eval_jac_g(
10927            &mut self,
10928            _x: Option<&[Number]>,
10929            _new_x: bool,
10930            mode: SparsityRequest<'_>,
10931        ) -> bool {
10932            match mode {
10933                SparsityRequest::Structure { irow, jcol } => {
10934                    irow.copy_from_slice(&[0, 0]);
10935                    jcol.copy_from_slice(&[0, 1]);
10936                }
10937                SparsityRequest::Values { values, .. } => {
10938                    values.copy_from_slice(&[1.0, 1.0]);
10939                }
10940            }
10941            true
10942        }
10943        fn eval_h(
10944            &mut self,
10945            _x: Option<&[Number]>,
10946            _new_x: bool,
10947            _obj_factor: Number,
10948            _lambda: Option<&[Number]>,
10949            _new_lambda: bool,
10950            mode: SparsityRequest<'_>,
10951        ) -> bool {
10952            match mode {
10953                SparsityRequest::Structure { irow, jcol } => {
10954                    irow.copy_from_slice(&[0, 1]);
10955                    jcol.copy_from_slice(&[0, 1]);
10956                }
10957                SparsityRequest::Values { values, .. } => {
10958                    values.copy_from_slice(&[1.0, 1.0]);
10959                }
10960            }
10961            true
10962        }
10963        fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
10964            *self.finalize_called.borrow_mut() = Some((sol.x.to_vec(), sol.obj_value));
10965        }
10966    }
10967
10968    /// A TNLP that solves normally once, then declines to supply bounds.
10969    ///
10970    /// The second `optimize_constrained` therefore bails long before the
10971    /// statistics block that records `row_scaling_active`, which is the
10972    /// path the fail-closed reset exists for.
10973    struct DescribesItselfOnce {
10974        /// Flipped by the test between the two solves. A call counter
10975        /// would not work: these hooks are called more than once per
10976        /// solve, so it would trip inside the first one.
10977        refuse: std::rc::Rc<std::cell::Cell<bool>>,
10978        inner: ExactQuadratic,
10979    }
10980    impl TNLP for DescribesItselfOnce {
10981        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
10982            self.inner.get_nlp_info()
10983        }
10984        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
10985            // Declining here is a documented TNLP outcome and unwinds
10986            // cleanly; declining `get_nlp_info` mid-flight panics instead,
10987            // which would test the panic path rather than the reset.
10988            if self.refuse.get() {
10989                return false;
10990            }
10991            self.inner.get_bounds_info(b)
10992        }
10993        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
10994            self.inner.get_starting_point(sp)
10995        }
10996        fn eval_f(&mut self, x: &[Number], n: bool) -> Option<Number> {
10997            self.inner.eval_f(x, n)
10998        }
10999        fn eval_grad_f(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
11000            self.inner.eval_grad_f(x, n, g)
11001        }
11002        fn eval_g(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
11003            self.inner.eval_g(x, n, g)
11004        }
11005        fn eval_jac_g(&mut self, x: Option<&[Number]>, n: bool, mode: SparsityRequest<'_>) -> bool {
11006            self.inner.eval_jac_g(x, n, mode)
11007        }
11008        fn eval_h(
11009            &mut self,
11010            x: Option<&[Number]>,
11011            n: bool,
11012            o: Number,
11013            l: Option<&[Number]>,
11014            nl: bool,
11015            mode: SparsityRequest<'_>,
11016        ) -> bool {
11017            self.inner.eval_h(x, n, o, l, nl, mode)
11018        }
11019        fn finalize_solution(&mut self, s: Solution<'_>, d: &IpoptData, c: &IpoptCq) {
11020            self.inner.finalize_solution(s, d, c)
11021        }
11022    }
11023
11024    /// gh#794 review round 2: `row_scaling_active` must be fail-closed.
11025    ///
11026    /// It is written near the end of `optimize_constrained`, from the NLP
11027    /// that solve built. A solve that bails before that point used to
11028    /// leave the *previous* solve's value in place — and the ℓ₁ outer
11029    /// loop calls `optimize_constrained` repeatedly and reads the flag
11030    /// after each call, so a stale `Some(false)` would let it mirror an
11031    /// original-units violation into the scaled family. That is exactly
11032    /// the units contract the flag was added to protect.
11033    ///
11034    /// The two solves here are the shape that matters: the first records
11035    /// a verdict, the second never gets far enough to record one.
11036    /// Removing the reset at the top of `optimize_constrained` makes this
11037    /// fail with `Some(false)` where `None` is required — checked, not
11038    /// assumed.
11039    #[test]
11040    fn row_scaling_active_is_cleared_when_a_later_solve_bails_early() {
11041        let mut app = IpoptApplication::new();
11042        app.initialize().unwrap();
11043
11044        // Solve 1: reaches the statistics block and records a verdict.
11045        // `ExactQuadratic` supplies `eval_h`, so the solve stays on the
11046        // exact-Hessian NLP path that writes this flag; a fixture without
11047        // one lands on L-BFGS instead.
11048        let refuse = std::rc::Rc::new(std::cell::Cell::new(false));
11049        let first = std::rc::Rc::new(std::cell::RefCell::new(DescribesItselfOnce {
11050            refuse: std::rc::Rc::clone(&refuse),
11051            inner: ExactQuadratic,
11052        }));
11053        let _ = app
11054            .optimize_tnlp(std::rc::Rc::clone(&first) as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
11055        assert!(
11056            app.row_scaling_active.get().is_some(),
11057            "the first solve did not record a row-scaling verdict, so this \
11058             test cannot show the second one clearing it",
11059        );
11060
11061        // Solve 2, same application: bails before recording anything.
11062        refuse.set(true);
11063        let _ = app.optimize_tnlp(first as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
11064
11065        assert_eq!(
11066            app.row_scaling_active.get(),
11067            None,
11068            "a solve that bailed before recording row scaling left the \
11069             previous solve's verdict in place; the ℓ₁ outer loop would \
11070             read it as fact and mirror an original-units violation into \
11071             the scaled family (gh#794 review round 2)",
11072        );
11073    }
11074
11075    #[test]
11076    fn application_sqp_path_solves_convex_eq_nlp_and_finalizes() {
11077        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
11078        let tnlp = std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
11079            finalize_called: std::rc::Rc::clone(&finalize_slot),
11080        }));
11081
11082        let mut app = IpoptApplication::new();
11083        app.initialize().unwrap();
11084        app.initialize_with_options_str("algorithm active-set-sqp\n")
11085            .unwrap();
11086        let status = app.optimize_tnlp(tnlp);
11087        assert_eq!(status, ApplicationReturnStatus::SolveSucceeded);
11088
11089        // The TNLP's finalize_solution must have been invoked.
11090        let recv = finalize_slot.borrow().clone();
11091        let (x_recv, obj_recv) = recv.expect("finalize_solution was not called");
11092        assert_eq!(x_recv.len(), 2);
11093        assert!((x_recv[0] - 0.0).abs() < 1e-6, "x[0] = {}", x_recv[0]);
11094        assert!((x_recv[1] - 1.0).abs() < 1e-6, "x[1] = {}", x_recv[1]);
11095        assert!(
11096            (obj_recv - (-1.5)).abs() < 1e-6,
11097            "obj = {} but expected -1.5",
11098            obj_recv
11099        );
11100    }
11101
11102    #[test]
11103    fn application_routes_to_sqp_case_insensitively() {
11104        let mut app = IpoptApplication::new();
11105        app.initialize().unwrap();
11106        app.initialize_with_options_str("algorithm Active-Set-SQP\n")
11107            .unwrap();
11108        // get_string_value may return the value as-stored (no
11109        // normalization); the dispatch must handle case
11110        // insensitively per the c11 design choice.
11111        assert!(app.is_sqp_algorithm_selected());
11112    }
11113
11114    #[test]
11115    fn application_constructs_and_loads_options() {
11116        let mut app = IpoptApplication::new();
11117        app.initialize().unwrap();
11118        // ipopt.opt-style file: an integer-typed option registered by
11119        // the Interfaces layer.
11120        app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
11121            .unwrap();
11122        let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
11123        assert!(found);
11124        assert_eq!(level, 5);
11125    }
11126
11127    #[test]
11128    fn application_sqp_suboptions_propagate_to_builder() {
11129        // All SQP suboptions are read by algorithm_builder_snapshot
11130        // and baked into the builder's `sqp` field.
11131        let mut app = IpoptApplication::new();
11132        app.initialize().unwrap();
11133        app.initialize_with_options_str(
11134            "algorithm active-set-sqp\n\
11135             sqp_globalization l1-elastic\n\
11136             sqp_hessian lbfgs\n\
11137             sqp_max_iter 17\n\
11138             sqp_tol 1e-7\n\
11139             sqp_constr_viol_tol 1e-5\n\
11140             sqp_dual_inf_tol 1e-3\n\
11141             sqp_l1_penalty 2.5\n\
11142             sqp_bt_reduction 0.25\n\
11143             sqp_bt_min_alpha 1e-10\n\
11144             sqp_print_level 2\n\
11145             sqp_lbfgs_max_history 12\n",
11146        )
11147        .unwrap();
11148        let snap = app.algorithm_builder_snapshot();
11149        assert_eq!(
11150            snap.sqp.globalization,
11151            crate::sqp::SqpGlobalization::L1Elastic
11152        );
11153        assert_eq!(snap.sqp.hessian, crate::sqp::SqpHessianSource::Lbfgs);
11154        assert_eq!(snap.sqp.max_iter, 17);
11155        assert!((snap.sqp.tol - 1e-7).abs() < 1e-18);
11156        assert!((snap.sqp.constr_viol_tol - 1e-5).abs() < 1e-18);
11157        assert!((snap.sqp.dual_inf_tol - 1e-3).abs() < 1e-18);
11158        assert!((snap.sqp.l1_penalty - 2.5).abs() < 1e-18);
11159        assert!((snap.sqp.bt_reduction - 0.25).abs() < 1e-18);
11160        assert!((snap.sqp.bt_min_alpha - 1e-10).abs() < 1e-18);
11161        assert_eq!(snap.sqp.print_level, 2);
11162        assert_eq!(snap.sqp.lbfgs_max_history, 12);
11163    }
11164
11165    /// Every `sqp_qp_*` key that [`apply_qp_subproblem_options`] reads must
11166    /// actually be *registered*, and must reach `pounce_qp::QpOptions`.
11167    ///
11168    /// The whole family was readable-but-unregistered (gh #360): the options
11169    /// registry rejected each one with OPTION_INVALID, so the reader was
11170    /// unreachable and the documented knobs were unusable. This is the guard
11171    /// that class of omission needs — it fails both if a key stops being
11172    /// registered and if a newly-read key is never registered at all.
11173    #[test]
11174    fn issue_1054_schur_updates_follow_size_unless_set() {
11175        let mut app = IpoptApplication::new();
11176        app.initialize().unwrap();
11177        app.initialize_with_options_str("algorithm active-set-sqp\n")
11178            .unwrap();
11179        assert_eq!(
11180            app.algorithm_builder_snapshot().sqp.qp_schur_min_dim,
11181            Some(crate::sqp::options::DEFAULT_QP_SCHUR_MIN_DIM)
11182        );
11183        for v in ["yes", "no"] {
11184            let mut app = IpoptApplication::new();
11185            app.initialize().unwrap();
11186            app.initialize_with_options_str(&format!(
11187                "algorithm active-set-sqp\nsqp_qp_use_schur_updates {v}\n"
11188            ))
11189            .unwrap();
11190            let snap = app.algorithm_builder_snapshot();
11191            assert_eq!(snap.sqp.qp_schur_min_dim, None, "{v}: explicit wins");
11192            assert_eq!(snap.sqp_qp.use_schur_updates, v == "yes");
11193        }
11194    }
11195
11196    #[test]
11197    fn application_sqp_qp_subproblem_options_are_registered_and_propagate() {
11198        use pounce_qp::AntiCyclingChoice;
11199
11200        // Source of truth: the keys `apply_qp_subproblem_options` reads.
11201        // Kept in step with that function by the round-trip assertions below.
11202        let mut app = IpoptApplication::new();
11203        app.initialize().unwrap();
11204        app.initialize_with_options_str(
11205            "algorithm active-set-sqp\n\
11206             sqp_qp_max_iter 37\n\
11207             sqp_qp_feas_tol 1e-7\n\
11208             sqp_qp_opt_tol 2e-7\n\
11209             sqp_qp_elastic_gamma 1e4\n\
11210             sqp_qp_anti_cycling bland\n\
11211             sqp_qp_use_schur_updates yes\n\
11212             sqp_qp_max_schur_updates_before_refactor 12\n\
11213             sqp_qp_use_homotopy yes\n\
11214             sqp_qp_certify_second_order yes\n",
11215        )
11216        .expect("every sqp_qp_* option must be registered (gh #360)");
11217
11218        let qp = &app.algorithm_builder_snapshot().sqp_qp;
11219        assert_eq!(qp.max_iter, 37);
11220        assert!((qp.feas_tol - 1e-7).abs() < 1e-20);
11221        assert!((qp.opt_tol - 2e-7).abs() < 1e-20);
11222        assert!((qp.elastic_gamma - 1e4).abs() < 1e-9);
11223        assert_eq!(qp.anti_cycling, AntiCyclingChoice::Bland);
11224        // The Schur update path was implemented but reachable only through
11225        // `SqpAlgorithm::with_qp_options`, so no CLI/library user could turn
11226        // it on — the same unreachable-knob defect gh #360 fixed for the rest
11227        // of this family.
11228        assert!(qp.use_schur_updates);
11229        assert_eq!(qp.max_schur_updates_before_refactor, 12);
11230        assert!(qp.use_homotopy);
11231        // gh #848. Off by default on this path (see
11232        // `QpOptions::sqp_subproblem`), so the value that proves the wire is
11233        // live is `yes` — asserting the default would pass on a reader that
11234        // never ran.
11235        assert!(qp.certify_second_order);
11236
11237        // Untouched options must keep the SQP subproblem base, not be
11238        // overwritten with zeros by the "explicitly set" gate. That base is
11239        // `QpOptions::default()` in every field but one; the exception is
11240        // asserted below.
11241        let mut app = IpoptApplication::new();
11242        app.initialize().unwrap();
11243        app.initialize_with_options_str("algorithm active-set-sqp\n")
11244            .unwrap();
11245        let defaults = pounce_qp::QpOptions::default();
11246        let qp = &app.algorithm_builder_snapshot().sqp_qp;
11247        assert_eq!(qp.max_iter, defaults.max_iter);
11248        assert!((qp.feas_tol - defaults.feas_tol).abs() < 1e-20);
11249        assert!((qp.opt_tol - defaults.opt_tol).abs() < 1e-20);
11250        assert_eq!(qp.anti_cycling, defaults.anti_cycling);
11251        // Off in the base QP options: the SQP driver turns it on per solve
11252        // from `n + m >= 500` (gh#1054; measured on Maros-Meszaros, see the
11253        // option text). Do not move that threshold without re-running the
11254        // comparison.
11255        assert!(!qp.use_schur_updates);
11256        assert_eq!(
11257            qp.max_schur_updates_before_refactor,
11258            defaults.max_schur_updates_before_refactor
11259        );
11260        // Off by default *on the SQP path*, and deliberately different from
11261        // `QpOptions::default()` — which is what a standalone `solve_qp`
11262        // gets, and where it is on. gh #848 / gh #856.
11263        assert!(!qp.certify_second_order);
11264        assert!(pounce_qp::QpOptions::default().certify_second_order);
11265    }
11266
11267    /// The other direction of the gh #360 guard: every **registered**
11268    /// `sqp_qp_*` option must be one `apply_qp_subproblem_options` actually
11269    /// reads.
11270    ///
11271    /// The sister test above checks read-keys-are-registered. It cannot catch
11272    /// the inverse, and the inverse happened: `sqp_qp_use_homotopy` was
11273    /// registered with the homotopy work and never wired into the reader, so
11274    /// setting it on the SQP path silently did nothing while the option's own
11275    /// documentation described what it would do. A registered knob that no
11276    /// code reads is worse than a missing one — it validates, it accepts a
11277    /// value, and it lies.
11278    ///
11279    /// Adding a new `sqp_qp_*` option therefore fails here until it is both
11280    /// read by `apply_qp_subproblem_options` and asserted in the round-trip
11281    /// test above.
11282    #[test]
11283    fn application_every_registered_sqp_qp_option_is_read_by_the_subproblem_reader() {
11284        let mut app = IpoptApplication::new();
11285        app.initialize().unwrap();
11286
11287        let mut registered: Vec<String> = app
11288            .registered_options()
11289            .registered_options_in_order()
11290            .iter()
11291            .map(|o| o.name.clone())
11292            .filter(|n| n.starts_with("sqp_qp_"))
11293            .collect();
11294        registered.sort();
11295
11296        // Kept in step by hand with the `options.get_*_value("sqp_qp_…")`
11297        // calls in `apply_qp_subproblem_options`, and cross-checked by the
11298        // round-trip assertions in the sister test.
11299        let mut read_by_the_reader = vec![
11300            "sqp_qp_anti_cycling".to_string(),
11301            "sqp_qp_certify_second_order".to_string(),
11302            "sqp_qp_elastic_gamma".to_string(),
11303            "sqp_qp_feas_tol".to_string(),
11304            "sqp_qp_max_iter".to_string(),
11305            "sqp_qp_max_schur_updates_before_refactor".to_string(),
11306            "sqp_qp_opt_tol".to_string(),
11307            "sqp_qp_use_homotopy".to_string(),
11308            "sqp_qp_use_schur_updates".to_string(),
11309        ];
11310        read_by_the_reader.sort();
11311
11312        assert_eq!(
11313            registered, read_by_the_reader,
11314            "registered sqp_qp_* options and the ones \
11315             `apply_qp_subproblem_options` reads have diverged. A key that is \
11316             registered but unread is a no-op knob with working documentation \
11317             (that is how `sqp_qp_use_homotopy` shipped); a key read but not \
11318             registered is gh #360. Wire it up in both places, assert it in \
11319             `application_sqp_qp_subproblem_options_are_registered_and_propagate`, \
11320             then add it here."
11321        );
11322    }
11323
11324    #[test]
11325    fn application_sqp_hessian_approximation_maps_to_damped_bfgs() {
11326        // The frontend sets `hessian_approximation = limited-memory` when no
11327        // exact Lagrangian Hessian is available (e.g. `pounce.minimize` with
11328        // no `hess`). On the active-set-SQP path that must resolve to the
11329        // dense Powell-damped BFGS, NOT the limited-memory update: L-BFGS
11330        // materializes the same dense Hessian for the QP subproblem yet stalls
11331        // (`Search_Direction_Becomes_Too_Small` / wrong `x`) on convex QPs with
11332        // an active inequality (issue #358); damped-BFGS solves them.
11333        let mut app = IpoptApplication::new();
11334        app.initialize().unwrap();
11335        app.initialize_with_options_str(
11336            "algorithm active-set-sqp\n\
11337             hessian_approximation limited-memory\n",
11338        )
11339        .unwrap();
11340        let snap = app.algorithm_builder_snapshot().sqp;
11341        assert_eq!(snap.hessian, crate::sqp::SqpHessianSource::QuasiNewtonAuto);
11342        // ...which is the dense update at or below the ceiling, and the
11343        // lifted limited-memory one above it (gh#1054).
11344        assert_eq!(
11345            snap.hessian
11346                .resolve(snap.dense_hessian_max_n, snap.dense_hessian_max_n),
11347            crate::sqp::SqpHessianSource::DampedBfgs
11348        );
11349        assert_eq!(
11350            snap.hessian
11351                .resolve(snap.dense_hessian_max_n + 1, snap.dense_hessian_max_n),
11352            crate::sqp::SqpHessianSource::Lbfgs
11353        );
11354
11355        // An explicit `sqp_hessian = lbfgs` is still honored (it is read after
11356        // `hessian_approximation`, so it wins): callers who genuinely want the
11357        // limited-memory update can still ask for it.
11358        let mut app = IpoptApplication::new();
11359        app.initialize().unwrap();
11360        app.initialize_with_options_str(
11361            "algorithm active-set-sqp\n\
11362             hessian_approximation limited-memory\n\
11363             sqp_hessian lbfgs\n",
11364        )
11365        .unwrap();
11366        assert_eq!(
11367            app.algorithm_builder_snapshot().sqp.hessian,
11368            crate::sqp::SqpHessianSource::Lbfgs
11369        );
11370    }
11371
11372    /// `builder.linear_solver` must name the backend that will actually be
11373    /// built, not the one the option string asked for.
11374    ///
11375    /// MA57 is behind the optional `ma57` cargo feature; without it
11376    /// `default_backend_factory` silently substitutes FERAL. Recording `Ma57`
11377    /// anyway made the field disagree with reality, and the Schur KKT gate in
11378    /// `alg_builder::build_with_backend` (which tests `== Feral`) consumed that
11379    /// disagreement — so `set_kkt_schur_block()` never engaged on the default
11380    /// pure-Rust build for any user, while the transparent fallback kept every
11381    /// answer correct and every test green.
11382    #[test]
11383    fn application_linear_solver_records_the_effective_backend() {
11384        // Default options resolve to FERAL in *every* build. The registry
11385        // used to default to upstream's "ma57", which meant an HSL build
11386        // silently ran MA57 without being asked and a pure-Rust build
11387        // advertised a backend it did not contain; the default now names
11388        // pounce's own solver and HSL is opt-in (gh#483 follow-up).
11389        let mut app = IpoptApplication::new();
11390        app.initialize().unwrap();
11391        assert_eq!(
11392            app.algorithm_builder_from_options().linear_solver,
11393            LinearSolverChoice::Feral,
11394            "the registered default is `feral`, in an ma57 build too"
11395        );
11396
11397        // An explicit ma57 request resolves the same way.
11398        let mut app = IpoptApplication::new();
11399        app.initialize().unwrap();
11400        app.initialize_with_options_str("linear_solver ma57\n")
11401            .unwrap();
11402        let got = app.algorithm_builder_from_options().linear_solver;
11403        if cfg!(feature = "ma57") {
11404            assert_eq!(got, LinearSolverChoice::Ma57);
11405        } else {
11406            assert_eq!(got, LinearSolverChoice::Feral);
11407        }
11408
11409        // An explicit feral request is honored in every build.
11410        let mut app = IpoptApplication::new();
11411        app.initialize().unwrap();
11412        app.initialize_with_options_str("linear_solver feral\n")
11413            .unwrap();
11414        assert_eq!(
11415            app.algorithm_builder_from_options().linear_solver,
11416            LinearSolverChoice::Feral
11417        );
11418    }
11419
11420    /// gh#746. `IpAlgBuilder.cpp:1059` substitutes `adaptive` for the
11421    /// registered `monotone` when `hessian_approximation` is
11422    /// limited-memory and the caller left `mu_strategy` alone. pounce
11423    /// read the registered default unconditionally, which is a
11424    /// different barrier schedule on the whole quasi-Newton arm.
11425    #[test]
11426    fn limited_memory_defaults_mu_strategy_to_adaptive() {
11427        // Exact Hessian, nothing set: monotone, as registered.
11428        let mut app = IpoptApplication::new();
11429        app.initialize().unwrap();
11430        assert_eq!(
11431            app.algorithm_builder_from_options().mu_strategy,
11432            MuStrategyChoice::Monotone,
11433            "the exact arm must keep the registered default"
11434        );
11435
11436        // Limited memory, nothing set: adaptive.
11437        let mut app = IpoptApplication::new();
11438        app.initialize().unwrap();
11439        app.initialize_with_options_str("hessian_approximation limited-memory\n")
11440            .unwrap();
11441        assert_eq!(
11442            app.algorithm_builder_from_options().mu_strategy,
11443            MuStrategyChoice::Adaptive,
11444            "limited-memory must take upstream's quasi-Newton default"
11445        );
11446
11447        // An explicit `monotone` still wins — the substitution is only
11448        // for an absent option.
11449        let mut app = IpoptApplication::new();
11450        app.initialize().unwrap();
11451        app.initialize_with_options_str(
11452            "hessian_approximation limited-memory\n\
11453             mu_strategy monotone\n",
11454        )
11455        .unwrap();
11456        assert_eq!(
11457            app.algorithm_builder_from_options().mu_strategy,
11458            MuStrategyChoice::Monotone,
11459            "an explicit mu_strategy must not be overridden"
11460        );
11461    }
11462
11463    /// The μ-strategy auto-fallback retries with the *other* strategy.
11464    /// Under limited-memory the first attempt is adaptive, so the flip
11465    /// has to be monotone — reading the registered default there would
11466    /// re-run the strategy that just stalled (gh#746).
11467    #[test]
11468    fn fallback_flip_follows_the_resolved_mu_strategy() {
11469        let mut app = IpoptApplication::new();
11470        app.initialize().unwrap();
11471        assert!(
11472            !app.effective_mu_strategy_is_adaptive(),
11473            "unset + exact resolves to monotone"
11474        );
11475
11476        let mut app = IpoptApplication::new();
11477        app.initialize().unwrap();
11478        app.initialize_with_options_str("hessian_approximation limited-memory\n")
11479            .unwrap();
11480        assert!(
11481            app.effective_mu_strategy_is_adaptive(),
11482            "unset + limited-memory resolves to adaptive"
11483        );
11484
11485        let mut app = IpoptApplication::new();
11486        app.initialize().unwrap();
11487        app.initialize_with_options_str(
11488            "hessian_approximation limited-memory\n\
11489             mu_strategy monotone\n",
11490        )
11491        .unwrap();
11492        assert!(
11493            !app.effective_mu_strategy_is_adaptive(),
11494            "an explicit monotone under limited-memory resolves to monotone"
11495        );
11496    }
11497
11498    #[test]
11499    fn application_limited_memory_options_propagate_to_builder() {
11500        use crate::hess::lim_mem_quasi_newton::UpdateType;
11501
11502        // Default: no options set -> bit-exact with Ipopt's default
11503        // (bfgs, history 6). This is what the IPM path runs unless the
11504        // user opts in, so it must not drift.
11505        let mut app = IpoptApplication::new();
11506        app.initialize().unwrap();
11507        let def = app.algorithm_builder_from_options();
11508        assert_eq!(def.limited_memory_update_type, UpdateType::Bfgs);
11509        assert_eq!(def.limited_memory_max_history, 6);
11510
11511        // `limited_memory_update_type=sr1` and a custom history length
11512        // must reach the builder (these were registered upstream but
11513        // read nowhere on the IPM path before — see #131). Honoring
11514        // them is what lets SR1 break the monotone L-BFGS stall.
11515        let mut app = IpoptApplication::new();
11516        app.initialize().unwrap();
11517        app.initialize_with_options_str(
11518            "hessian_approximation limited-memory\n\
11519             limited_memory_update_type sr1\n\
11520             limited_memory_max_history 9\n",
11521        )
11522        .unwrap();
11523        let snap = app.algorithm_builder_from_options();
11524        assert_eq!(snap.limited_memory_update_type, UpdateType::Sr1);
11525        assert_eq!(snap.limited_memory_max_history, 9);
11526    }
11527
11528    #[test]
11529    fn application_recalc_y_is_wired_and_defaults_off() {
11530        // #677. Upstream registers `recalc_y` as `no`, but its option
11531        // text ends "If a limited memory quasi-Newton option is chosen,
11532        // this is used by default" — the effective default is
11533        // conditional on the Hessian approximation. pounce refused the
11534        // option outright before this, so an L-BFGS user had no way to
11535        // reach Ipopt's behaviour.
11536
11537        // Exact Hessian: off, matching the registered default.
11538        let mut app = IpoptApplication::new();
11539        app.initialize().unwrap();
11540        let b = app.algorithm_builder_from_options();
11541        assert!(!b.recalc_y, "exact-Hessian default must stay off");
11542        assert_eq!(b.recalc_y_feas_tol, 1e-6, "default changed");
11543
11544        // Limited memory: also off. Upstream's option text says it is
11545        // used by default there; pounce deliberately does not, because
11546        // auto-enabling took 7 of 57 fixtures from solved to not solved
11547        // on the L-BFGS leg with nothing moving the other way. See the
11548        // read site in `algorithm_builder_from_options`. If this
11549        // assertion is what fails, the auto-enable is being restored —
11550        // re-run `scripts/sweep-fixtures.sh` and explain those 7 first.
11551        let mut app = IpoptApplication::new();
11552        app.initialize().unwrap();
11553        app.initialize_with_options_str("hessian_approximation limited-memory\n")
11554            .unwrap();
11555        assert!(
11556            !app.algorithm_builder_from_options().recalc_y,
11557            "limited-memory must not silently enable recalc_y"
11558        );
11559
11560        // An explicit `yes` reaches the exact-Hessian path, which
11561        // used to be refused as unimplemented.
11562        let mut app = IpoptApplication::new();
11563        app.initialize().unwrap();
11564        app.initialize_with_options_str("recalc_y yes\nrecalc_y_feas_tol 1e-3\n")
11565            .unwrap();
11566        let b = app.algorithm_builder_from_options();
11567        assert!(b.recalc_y);
11568        assert_eq!(b.recalc_y_feas_tol, 1e-3);
11569    }
11570
11571    #[test]
11572    fn application_limited_memory_initialization_propagates_to_builder() {
11573        use crate::hess::lim_mem_quasi_newton::InitialApprox;
11574
11575        // #677: registered with upstream's `scalar1` default and read
11576        // nowhere, so every limited-memory solve ran `scalar2` and
11577        // setting the option was a silent no-op. Each keyword must now
11578        // reach the builder.
11579        for (kw, want) in [
11580            ("scalar1", InitialApprox::Scalar1),
11581            ("scalar2", InitialApprox::Scalar2),
11582            ("scalar3", InitialApprox::Scalar3),
11583            ("scalar4", InitialApprox::Scalar4),
11584            ("constant", InitialApprox::Constant),
11585            ("history-max", InitialApprox::HistoryMax),
11586        ] {
11587            let mut app = IpoptApplication::new();
11588            app.initialize().unwrap();
11589            app.initialize_with_options_str(&format!(
11590                "hessian_approximation limited-memory\n\
11591                 limited_memory_initialization {kw}\n"
11592            ))
11593            .unwrap();
11594            assert_eq!(
11595                app.algorithm_builder_from_options()
11596                    .limited_memory_initialization,
11597                want,
11598                "limited_memory_initialization={kw} did not reach the builder"
11599            );
11600        }
11601
11602        // `limited_memory_init_val` was unread too — the empty-history
11603        // branch hard-coded the same 1.0, so the miss was invisible.
11604        let mut app = IpoptApplication::new();
11605        app.initialize().unwrap();
11606        app.initialize_with_options_str(
11607            "hessian_approximation limited-memory\n\
11608             limited_memory_init_val 4.5\n",
11609        )
11610        .unwrap();
11611        assert_eq!(
11612            app.algorithm_builder_from_options().limited_memory_init_val,
11613            4.5
11614        );
11615
11616        // The effective default matches the registry and Ipopt
11617        // (`scalar1`). This is the assertion that would have caught #677
11618        // when the option was first registered: it pins the *selection*,
11619        // which the per-formula tests in `lim_mem_quasi_newton` cannot
11620        // see. Do not relax it to make an unrelated change pass.
11621        let mut app = IpoptApplication::new();
11622        app.initialize().unwrap();
11623        let def = app.algorithm_builder_from_options();
11624        assert_eq!(def.limited_memory_initialization, InitialApprox::Scalar1);
11625        assert_eq!(def.limited_memory_init_val, 1.0);
11626    }
11627
11628    #[test]
11629    fn application_sqp_warm_start_round_trip() {
11630        // Drive the convex-equality TNLP through the SQP path
11631        // twice. The first solve produces a working set; the
11632        // second is warm-started from it. The second must converge
11633        // with zero QP solves (the first KKT check declares
11634        // optimality immediately).
11635        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
11636        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
11637            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
11638                finalize_called: std::rc::Rc::clone(&finalize_slot),
11639            }));
11640
11641        let mut app = IpoptApplication::new();
11642        app.initialize().unwrap();
11643        app.initialize_with_options_str("algorithm active-set-sqp\n")
11644            .unwrap();
11645
11646        // Cold solve.
11647        let status_a = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
11648        assert_eq!(status_a, ApplicationReturnStatus::SolveSucceeded);
11649        let ws = app.last_sqp_working_set().cloned();
11650        assert!(ws.is_some(), "cold solve must yield a working set");
11651
11652        // Build the warm-start iterate from the converged finalize
11653        // payload (just x; pad multipliers to 0 since the test
11654        // problem is convex).
11655        let (x_recv, _) = finalize_slot.borrow().clone().unwrap();
11656        let warm = crate::sqp::SqpIterates {
11657            x: x_recv,
11658            lambda_g: vec![1.0],
11659            lambda_x: vec![0.0, 0.0],
11660            working: ws,
11661        };
11662        app.set_sqp_warm_start(warm);
11663
11664        // Warm solve.
11665        let status_b = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
11666        assert_eq!(status_b, ApplicationReturnStatus::SolveSucceeded);
11667        assert!(app.last_sqp_working_set().is_some());
11668    }
11669
11670    #[test]
11671    fn application_sqp_warm_start_auto_clears_after_use() {
11672        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
11673        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
11674            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
11675                finalize_called: std::rc::Rc::clone(&finalize_slot),
11676            }));
11677        let mut app = IpoptApplication::new();
11678        app.initialize().unwrap();
11679        app.initialize_with_options_str("algorithm active-set-sqp\n")
11680            .unwrap();
11681        app.set_sqp_warm_start(crate::sqp::SqpIterates {
11682            x: vec![0.0, 1.0],
11683            lambda_g: vec![1.0],
11684            lambda_x: vec![0.0, 0.0],
11685            working: None,
11686        });
11687        assert!(app.sqp_warm_start.is_some());
11688        let _ = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
11689        assert!(
11690            app.sqp_warm_start.is_none(),
11691            "warm-start input must be auto-cleared after use"
11692        );
11693    }
11694
11695    #[test]
11696    fn application_sqp_suboptions_default_when_unset() {
11697        // Without any sqp_* settings, the snapshot should equal
11698        // SqpOptions::default().
11699        let mut app = IpoptApplication::new();
11700        app.initialize().unwrap();
11701        let snap = app.algorithm_builder_snapshot();
11702        let d = crate::sqp::SqpOptions::default();
11703        assert_eq!(snap.sqp.globalization, d.globalization);
11704        assert_eq!(snap.sqp.hessian, d.hessian);
11705        assert_eq!(snap.sqp.max_iter, d.max_iter);
11706        assert!((snap.sqp.tol - d.tol).abs() < 1e-18);
11707        assert!((snap.sqp.constr_viol_tol - d.constr_viol_tol).abs() < 1e-18);
11708        assert!((snap.sqp.dual_inf_tol - d.dual_inf_tol).abs() < 1e-18);
11709        assert!((snap.sqp.l1_penalty - d.l1_penalty).abs() < 1e-18);
11710        assert!((snap.sqp.bt_reduction - d.bt_reduction).abs() < 1e-18);
11711        assert!((snap.sqp.bt_min_alpha - d.bt_min_alpha).abs() < 1e-18);
11712        assert_eq!(snap.sqp.print_level, d.print_level);
11713        assert_eq!(snap.sqp.lbfgs_max_history, d.lbfgs_max_history);
11714    }
11715
11716    #[test]
11717    fn application_reports_problem_dimensions() {
11718        let app = IpoptApplication::new();
11719        let mut tnlp = Hs071Stub;
11720        let info = app.problem_dimensions(&mut tnlp).unwrap();
11721        assert_eq!(info.n, 4);
11722        assert_eq!(info.m, 2);
11723        assert_eq!(info.nnz_jac_g, 8);
11724        assert_eq!(info.nnz_h_lag, 10);
11725    }
11726
11727    /// Each of the four constant-derivative hints reaches the algorithm,
11728    /// and reaches its *own* slot (#551 / #677).
11729    ///
11730    /// All four were wired and consumed — gh#588 Q6 made pounce exploit
11731    /// them — but the read site looped over
11732    /// `constant_derivatives::HINT_OPTIONS`, so the registered-but-unread
11733    /// scan saw a loop variable where it needs a literal key and reported
11734    /// all four as silent no-ops. They are literals now, and this pins
11735    /// what a literal-per-name rewrite can get wrong that a loop could
11736    /// not: setting one hint must light up that hint's slot and no other,
11737    /// because `reconcile` pairs `asserted[k]` with the model's proof for
11738    /// `HINT_OPTIONS[k]` and a transposed pair would reuse the wrong
11739    /// derivative.
11740    #[test]
11741    fn each_constant_derivative_hint_lights_up_its_own_slot() {
11742        use pounce_nlp::constant_derivatives::HINT_OPTIONS;
11743
11744        let app = IpoptApplication::new();
11745        assert_eq!(
11746            app.asserted_constant_derivative_hints(),
11747            [false; 4],
11748            "no hint is asserted on a fresh options list",
11749        );
11750
11751        for (k, name) in HINT_OPTIONS.iter().enumerate() {
11752            let mut app = IpoptApplication::new();
11753            app.initialize().unwrap();
11754            app.initialize_with_options_str(&format!("{name} yes\n"))
11755                .unwrap();
11756            let mut expected = [false; 4];
11757            expected[k] = true;
11758            assert_eq!(
11759                app.asserted_constant_derivative_hints(),
11760                expected,
11761                "`{name}=yes` must set slot {k} and nothing else",
11762            );
11763
11764            // …and the registered default asks for nothing, so an
11765            // `ipopt.opt` that spells it out changes no derivative reuse.
11766            let mut app = IpoptApplication::new();
11767            app.initialize().unwrap();
11768            app.initialize_with_options_str(&format!("{name} no\n"))
11769                .unwrap();
11770            assert_eq!(
11771                app.asserted_constant_derivative_hints(),
11772                [false; 4],
11773                "`{name}=no` is the registered default and asserts nothing",
11774            );
11775        }
11776    }
11777
11778    /// `min x²` on `[-10, 10]` from `x = 1`, with an *exactly correct*
11779    /// gradient `2x`. Unconstrained and one-dimensional so the only
11780    /// thing the derivative checker can react to is its own step size
11781    /// and threshold.
11782    ///
11783    /// The forward difference at step `h` is `((1+h)² − 1)/h = 2 + h`,
11784    /// so the deviation from the analytic `2` is exactly `h`, and the
11785    /// relative test flags it when `h > tol·(2 + h)`. That makes the
11786    /// verdict a closed-form function of the two knobs under test.
11787    struct ExactQuadratic;
11788    impl TNLP for ExactQuadratic {
11789        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
11790            Some(NlpInfo {
11791                n: 1,
11792                m: 0,
11793                nnz_jac_g: 0,
11794                nnz_h_lag: 0,
11795                index_style: IndexStyle::C,
11796            })
11797        }
11798        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
11799            b.x_l.copy_from_slice(&[-10.0]);
11800            b.x_u.copy_from_slice(&[10.0]);
11801            true
11802        }
11803        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
11804            sp.x.copy_from_slice(&[1.0]);
11805            true
11806        }
11807        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
11808            Some(x[0] * x[0])
11809        }
11810        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
11811            grad[0] = 2.0 * x[0];
11812            true
11813        }
11814        fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
11815            true
11816        }
11817        fn eval_jac_g(
11818            &mut self,
11819            _x: Option<&[Number]>,
11820            _new_x: bool,
11821            _mode: SparsityRequest<'_>,
11822        ) -> bool {
11823            true
11824        }
11825        fn eval_h(
11826            &mut self,
11827            _x: Option<&[Number]>,
11828            _new_x: bool,
11829            _obj_factor: Number,
11830            _lambda: Option<&[Number]>,
11831            _new_lambda: bool,
11832            _mode: SparsityRequest<'_>,
11833        ) -> bool {
11834            true
11835        }
11836        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
11837    }
11838
11839    struct RecordingQuadratic {
11840        gradient_points: Rc<RefCell<Vec<Number>>>,
11841        objective_points: Rc<RefCell<Vec<Number>>>,
11842        x_scaling: Number,
11843    }
11844
11845    impl TNLP for RecordingQuadratic {
11846        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
11847            ExactQuadratic.get_nlp_info()
11848        }
11849
11850        fn get_bounds_info(&mut self, bounds: BoundsInfo<'_>) -> bool {
11851            ExactQuadratic.get_bounds_info(bounds)
11852        }
11853
11854        fn get_starting_point(&mut self, start: StartingPoint<'_>) -> bool {
11855            ExactQuadratic.get_starting_point(start)
11856        }
11857
11858        fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
11859            self.objective_points.borrow_mut().push(x[0]);
11860            ExactQuadratic.eval_f(x, new_x)
11861        }
11862
11863        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
11864            self.gradient_points.borrow_mut().push(x[0]);
11865            grad[0] = 2.0 * x[0];
11866            true
11867        }
11868
11869        fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
11870            ExactQuadratic.eval_g(x, new_x, g)
11871        }
11872
11873        fn eval_jac_g(
11874            &mut self,
11875            x: Option<&[Number]>,
11876            new_x: bool,
11877            mode: SparsityRequest<'_>,
11878        ) -> bool {
11879            ExactQuadratic.eval_jac_g(x, new_x, mode)
11880        }
11881
11882        fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
11883            *req.obj_scaling = 1.0;
11884            *req.use_x_scaling = true;
11885            req.x_scaling[0] = self.x_scaling;
11886            *req.use_g_scaling = false;
11887            true
11888        }
11889
11890        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
11891    }
11892
11893    /// A floor replay makes the floor the recorded answer again, firing id
11894    /// included. The retries nest and each floors to `last_finalize`, so a
11895    /// replay that only re-delivered the payload left the inner retry's
11896    /// discarded payload there for an outer wrapper to floor to, and for
11897    /// `reported_on_converged_id` to name.
11898    #[test]
11899    fn restoring_a_floor_records_it_as_the_answer() {
11900        let snap = |x: Number, id: Option<u64>| FinalizeSnapshot {
11901            status: SolverReturn::Success,
11902            x: vec![x],
11903            z_l: vec![0.0],
11904            z_u: vec![0.0],
11905            g: vec![],
11906            lambda: vec![],
11907            obj_value: x * x,
11908            on_converged_id: id,
11909        };
11910        let app = IpoptApplication::new();
11911        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ExactQuadratic));
11912        let base = snap(1.0, Some(1));
11913        // The retry's payload is the last one written, as after an inner
11914        // retry that lost.
11915        *app.last_finalize.borrow_mut() = Some(snap(2.0, Some(2)));
11916        assert_eq!(app.reported_on_converged_id(), Some(2));
11917
11918        app.restore_floor(&base, &tnlp);
11919        assert!(app.answer_restored_from_floor());
11920        assert_eq!(app.reported_on_converged_id(), Some(1));
11921        // What an outer wrapper would take as its own floor.
11922        let outer_floor = app.last_finalize.borrow().clone().unwrap();
11923        assert_eq!(outer_floor.x, base.x);
11924    }
11925
11926    fn derivative_test_verdict(extra: &str) -> pounce_nlp::derivative_test::DerivativeTestReport {
11927        let mut app = IpoptApplication::new();
11928        app.initialize().unwrap();
11929        app.initialize_with_options_str(&format!("derivative_test first-order\n{extra}"))
11930            .unwrap();
11931        let opts = app.derivative_test_options();
11932        pounce_nlp::derivative_test::run(&mut ExactQuadratic, &opts).expect("a report")
11933    }
11934
11935    /// `derivative_test_perturbation` and `derivative_test_tol` were
11936    /// registered, read into [`DerivativeTestOptions`], and consumed by
11937    /// the checker — but nothing proved a *set value* reached it, which
11938    /// is the only assertion that distinguishes a read site from a
11939    /// parse-and-discard (#677, #551).
11940    ///
11941    /// Each of the three verdicts below differs from the one above it by
11942    /// exactly one option, so a knob that stopped reaching the checker
11943    /// would collapse two of them together and fail here.
11944    #[test]
11945    fn the_derivative_checker_knobs_change_the_verdict() {
11946        // Registered defaults (1e-8 / 1e-4), which are also the read
11947        // site's fallbacks: a correct gradient looks correct.
11948        let clean = derivative_test_verdict("");
11949        assert_eq!(clean.checked, 1);
11950        assert_eq!(clean.suspicious, 0, "{:#?}", clean.lines);
11951
11952        // A coarse step makes the *same correct gradient* look wrong:
11953        // deviation 0.5 > 1e-4·2.5. Only `derivative_test_perturbation`
11954        // changed, so this is that option and nothing else.
11955        let coarse = derivative_test_verdict("derivative_test_perturbation 0.5\n");
11956        assert_eq!(coarse.checked, 1);
11957        assert_eq!(
11958            coarse.suspicious, 1,
11959            "derivative_test_perturbation never reached the checker: {:#?}",
11960            coarse.lines,
11961        );
11962
11963        // …and loosening the threshold at that same coarse step clears
11964        // it again: 0.5 < 0.5·2.5. Only `derivative_test_tol` changed.
11965        let tolerant =
11966            derivative_test_verdict("derivative_test_perturbation 0.5\nderivative_test_tol 0.5\n");
11967        assert_eq!(tolerant.checked, 1);
11968        assert_eq!(
11969            tolerant.suspicious, 0,
11970            "derivative_test_tol never reached the checker: {:#?}",
11971            tolerant.lines,
11972        );
11973
11974        // The report also prints what it used, so a user reading the
11975        // output can tell which step and threshold produced the verdict.
11976        assert!(
11977            tolerant.lines[0].contains("5.0e-1"),
11978            "{:#?}",
11979            tolerant.lines,
11980        );
11981    }
11982
11983    #[test]
11984    fn ordinary_derivative_test_keeps_the_conditioned_start() {
11985        let gradient_points = Rc::new(RefCell::new(Vec::new()));
11986        let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
11987            gradient_points: Rc::clone(&gradient_points),
11988            objective_points: Rc::new(RefCell::new(Vec::new())),
11989            x_scaling: 1.0,
11990        })) as Rc<RefCell<dyn TNLP>>;
11991        let mut app = IpoptApplication::new();
11992        app.initialize().unwrap();
11993        app.initialize_with_options_str(
11994            "derivative_test first-order\n\
11995             start_point_perturbation 0.5\n\
11996             hessian_approximation limited-memory\n\
11997             max_iter 0\n\
11998             print_level 0\n",
11999        )
12000        .unwrap();
12001
12002        let _ = app.optimize_tnlp(tnlp);
12003
12004        let first = gradient_points.borrow()[0];
12005        assert!((first - 1.7666216164272852).abs() < 1e-12, "{first}");
12006    }
12007
12008    #[test]
12009    fn ordinary_derivative_test_keeps_variable_scaling() {
12010        let objective_points = Rc::new(RefCell::new(Vec::new()));
12011        let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
12012            gradient_points: Rc::new(RefCell::new(Vec::new())),
12013            objective_points: Rc::clone(&objective_points),
12014            x_scaling: 0.5,
12015        })) as Rc<RefCell<dyn TNLP>>;
12016        let mut app = IpoptApplication::new();
12017        app.initialize().unwrap();
12018        app.initialize_with_options_str(
12019            "derivative_test first-order\n\
12020             derivative_test_perturbation 0.5\n\
12021             nlp_scaling_method user-scaling\n\
12022             hessian_approximation limited-memory\n\
12023             max_iter 0\n\
12024             print_level 0\n",
12025        )
12026        .unwrap();
12027
12028        let _ = app.optimize_tnlp(tnlp);
12029
12030        assert_eq!(&objective_points.borrow()[..2], &[1.0, 2.0]);
12031    }
12032
12033    // ---- gh#887: the dominance gate ------------------------------------
12034    //
12035    // These pin the rule directly, on numbers measured off real runs,
12036    // because the fixture that motivated the gate turned out not to be a
12037    // portable witness for it. `deb7` on the L-BFGS leg with
12038    // `limited_memory_ls_failure_restarts=1` reaches a *materially
12039    // different answer* on macOS and on Linux -- different objective
12040    // (99.677 vs 99.651) and, decisively, a different shape:
12041    //
12042    //   | run                 | unscaled dual | viol    | compl   | ratio   |
12043    //   | reproducer, .nl     | 7.90e4        | 1.1e-16 | 1.1e-9  | 1.5e-14 |
12044    //   | reproducer, TNLP    | 3.25e11       | 2.5e-16 | 2.8e-3  | 8.7e-15 |
12045    //   | deb7 + rung, macOS  | 9.90e1        | 8.0e-13 | 4.65e0  | 4.7e-2  |
12046    //   | deb7 + rung, Linux  | 5.5743e3      | 5.6e-14 | 2.08e-5 | 3.7e-9  |
12047    //
12048    // The Linux row is the one worth reading twice: that answer really is
12049    // gh#884's shape (scaled overall error 5.28e-1 against unscaled
12050    // 5.57e3, which is the `s_d` normalisation hiding a runaway exactly
12051    // as it did on `qpec_small`), so the retry there is the designed cost
12052    // and not the waste gh#887 filed. A CLI assertion of "deb7 declines"
12053    // is therefore false on Linux no matter what the threshold is, which
12054    // is why the pin lives here instead.
12055
12056    /// The dominance rule on its own, with the absolute floor switched
12057    /// off (`du_floor = 0`), so each of these tests is about one rule.
12058    fn ratio_only(dual_inf: Number, viol: Number, compl: Number) -> bool {
12059        runaway_is_the_whole_residual(dual_inf, viol, compl, 0.0)
12060    }
12061
12062    /// The gate as it actually runs: dominance *and* the detector's own
12063    /// absolute floor.
12064    fn runaway(dual_inf: Number, viol: Number, compl: Number) -> bool {
12065        runaway_is_the_whole_residual(dual_inf, viol, compl, DUAL_DIV_RETRY_DU_FLOOR)
12066    }
12067
12068    #[test]
12069    fn a_converged_point_with_a_runaway_multiplier_opens_the_retry() {
12070        // The gh#884 reproducer, both routes it reaches the gate by.
12071        assert!(runaway(7.90e4, 1.1e-16, 1.1e-9));
12072        assert!(runaway(3.25e11, 2.5e-16, 2.8e-3));
12073        // deb7 under the rung on Linux: primal exact, complementarity
12074        // eight orders under its own dual residual. Same shape.
12075        assert!(runaway(5.5743e3, 5.6e-14, 2.08e-5));
12076    }
12077
12078    #[test]
12079    fn an_unconverged_point_does_not_open_the_retry() {
12080        // deb7 under the rung on macOS: complementarity 4.65, five
12081        // percent of its own KKT error. Not a runaway multiplier on an
12082        // otherwise-converged point -- just an unconverged point.
12083        assert!(!ratio_only(9.90e1, 8.0e-13, 4.65e0));
12084        // Either residual alone is enough to close it: the gate takes the
12085        // max, so a clean complementarity does not excuse a violated
12086        // constraint. (`1.0e1` against `1.0e6` is a ratio of `1e-5`; note
12087        // that `1.0e0` there would be `1e-6` exactly, i.e. inside.)
12088        assert!(!ratio_only(1.0e6, 1.0e1, 1.0e-16));
12089        assert!(!ratio_only(1.0e6, 1.0e-16, 1.0e1));
12090    }
12091
12092    /// The dominance ratio is scale-free, and that is exactly why it
12093    /// cannot be the whole gate: a point converged to `1e-30` primal with
12094    /// a dual residual of `4.4e-1` satisfies it as comfortably as
12095    /// gh#884's `7.9e+04` does, and `4.4e-1` is not a runaway by any
12096    /// reading of the issue. Measured on the 400-model QPEC family, the
12097    /// floor alone removes 7 of 68 promotions, every one of them on an
12098    /// answer whose reported dual residual was below `1e2`.
12099    ///
12100    /// The floor is the *detector's*, so this is the answer-level gate
12101    /// asking about the same magnitude the iterate-level one did rather
12102    /// than being a strictly looser copy of it.
12103    #[test]
12104    fn a_small_dual_residual_is_not_a_runaway_however_dominant() {
12105        // Passes the ratio comfortably (1e-30 / 4.4e-1 = 2.3e-30) ...
12106        assert!(ratio_only(4.4e-1, 1.0e-30, 1.0e-30));
12107        // ... and is still refused, because it is not a runaway.
12108        assert!(!runaway(4.4e-1, 1.0e-30, 1.0e-30));
12109        // The two real `r`-family answers that reached the gate this way.
12110        assert!(!runaway(4.397e-1, 1.0e-16, 1.0e-16));
12111        assert!(!runaway(2.026e1, 1.0e-16, 1.0e-16));
12112        // Exactly at the floor is inside; a hair under is outside.
12113        assert!(runaway(DUAL_DIV_RETRY_DU_FLOOR, 0.0, 0.0));
12114        assert!(!runaway(DUAL_DIV_RETRY_DU_FLOOR * 0.999, 0.0, 0.0));
12115    }
12116
12117    #[test]
12118    fn the_threshold_is_where_the_constant_says_it_is() {
12119        // Exactly at the ratio is inside; a hair past it is outside.
12120        assert!(ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE, 0.0));
12121        assert!(!ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE * 1.001, 0.0));
12122    }
12123
12124    #[test]
12125    fn what_we_cannot_measure_does_not_buy_a_retry() {
12126        // A NaN compares false everywhere, so the condition is written so
12127        // that "we cannot tell" declines rather than retries. Each
12128        // argument in turn.
12129        assert!(!ratio_only(Number::NAN, 0.0, 0.0));
12130        assert!(!ratio_only(1.0e6, Number::NAN, 0.0));
12131        assert!(!ratio_only(1.0e6, 0.0, Number::NAN));
12132        assert!(!ratio_only(Number::INFINITY, 0.0, 0.0));
12133        // And a dual residual that is not a runaway at all: the ratio
12134        // would be meaningless, and a zero-dual point is not gh#884.
12135        assert!(!ratio_only(0.0, 0.0, 0.0));
12136        assert!(!ratio_only(-1.0, 0.0, 0.0));
12137    }
12138
12139    // ---- the promotion gate reads the ANSWER, not only the certificate --
12140    //
12141    // gh#884 ranked the two attempts on unscaled KKT error alone and
12142    // argued that this could not return a different local solution,
12143    // because "conjunct 4 requires the promoted answer to satisfy the KKT
12144    // conditions in the model's own units". Any other KKT point does too.
12145    // Measured on 400 random QPECs under the `prod_eq` lowering
12146    // (`bound_relax_factor=0 mu_strategy_fallback=no tol=1e-8`): 68
12147    // promotions, 42 of which moved the objective materially, and three
12148    // of which returned a strictly worse *feasible* point.
12149    //
12150    // Every number below is off a real run, and the two branches are
12151    // separated on purpose -- a rule that branches needs a case on each
12152    // side or the untaken one stays broken while the test is green.
12153    //
12154    //   | model      | base f      | base viol | retry f     | retry viol | branch |
12155    //   |------------|-------------|-----------|-------------|------------|--------|
12156    //   | qpec_small | +3.586e-28  | 1.11e-16  | +5.835e-11  | 5.47e-12   | admit  |
12157    //   | r116       | -1.3006e+01 | 2.22e-16  | -1.2072e+00 | 4.55e-13   | rule 1 |
12158    //   | r261       | -4.7919e+00 | 1.07e-14  | -9.8563e-01 | 7.99e-14   | rule 1 |
12159    //   | r201       | -2.9559e-01 | 6.25e-17  | -9.7321e-02 | 6.21e-13   | rule 1 |
12160    //   | scholtes4  | +1.8176e-09 | 2.07e-25  | -6.6088e-05 | 1.09e-09   | rule 2 |
12161
12162    const ACCEPT: Number = 1e-6;
12163    /// Minimization, which is every row of the table above.
12164    const MIN: Number = 1.0;
12165    /// Maximization, i.e. `obj_scaling_factor < 0`.
12166    const MAX: Number = -1.0;
12167
12168    fn admissible(bo: Number, bv: Number, ro: Number, rv: Number) -> bool {
12169        retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN)
12170    }
12171
12172    #[test]
12173    fn the_reproducers_promotion_is_still_admissible() {
12174        // `qpec_small`: the retry's objective is *worse*, by 5.8e-11 --
12175        // deliberately, since it buys nine orders of unscaled dual
12176        // residual. Five orders inside the tolerance, so rule 1 admits
12177        // it, which is the whole point of having a tolerance at all.
12178        assert!(admissible(3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12));
12179    }
12180
12181    /// Rule 1: a strictly worse feasible point is never an upgrade.
12182    #[test]
12183    fn a_worse_feasible_objective_is_refused_however_clean_the_certificate() {
12184        // r116: -13.0057 -> -1.2072, both independently verified feasible
12185        // by `pounce verify`. The retry's unscaled KKT error is 2.9e-11
12186        // against the base attempt's 3.0e+03, so every certificate
12187        // conjunct passes and only this one refuses it.
12188        assert!(!admissible(
12189            -1.3005680756e1,
12190            2.22e-16,
12191            -1.2072337962e0,
12192            4.55e-13
12193        ));
12194        assert!(!admissible(
12195            -4.7919265770e0,
12196            1.07e-14,
12197            -9.8562977711e-1,
12198            7.99e-14
12199        ));
12200        assert!(!admissible(
12201            -2.9558632401e-1,
12202            6.25e-17,
12203            -9.7321185691e-2,
12204            6.21e-13
12205        ));
12206    }
12207
12208    /// The declared-box conjunct is scale-safe: at a bound of `1e6`, two
12209    /// points pinned to the same widened bound differ by an ulp (`1.2e-10`),
12210    /// which an absolute `1e-12` floor read as the re-solve using more of the
12211    /// widening; relative to the bound it is rounding. A genuine excess at
12212    /// that magnitude -- the bound-magnitude probe's `2.5e-6`, i.e. `2.5e-12`
12213    /// relative -- is still refused, and at a unit bound `1e-8` is.
12214    #[test]
12215    fn the_declared_box_conjunct_is_relative_to_each_bounds_magnitude() {
12216        let (lo, up) = (-1e19, 1e19);
12217        let rel = |x: &[Number], l: &[Number], u: &[Number]| {
12218            declared_box_relative_violation(x, l, u, lo, up)
12219        };
12220        let ub: Number = 1e6;
12221        let pinned: Number = ub + 1e-4; // on the widened bound (`constr_viol_tol` cap)
12222        let one_ulp_out = f64::from_bits(pinned.to_bits() + 1);
12223        assert!(
12224            one_ulp_out - pinned > 1e-12,
12225            "premise: an ulp of 1e6 exceeds 1e-12"
12226        );
12227        let (l, u) = (vec![0.0], vec![ub]);
12228        let base = rel(&[pinned], &l, &u);
12229        assert!(retry_keeps_declared_box(base, rel(&[one_ulp_out], &l, &u)));
12230        // The absolute rule this replaces declined exactly that pair.
12231        assert!(one_ulp_out - ub > (pinned - ub) + 1e-12);
12232        let genuine = rel(&[ub + 9.9995e-5], &l, &u);
12233        let base_genuine = rel(&[ub + 9.7494e-5], &l, &u);
12234        assert!(!retry_keeps_declared_box(base_genuine, genuine));
12235        // Unit bound: the widening's own size is refused, rounding is not.
12236        let (l1, u1) = (vec![0.0], vec![1.0]);
12237        assert!(!retry_keeps_declared_box(0.0, rel(&[1.0 + 1e-8], &l1, &u1)));
12238        assert!(retry_keeps_declared_box(
12239            0.0,
12240            rel(&[f64::from_bits(1.0f64.to_bits() + 1)], &l1, &u1)
12241        ));
12242        // Lower bounds, sentinels, and single-point boxes.
12243        assert_eq!(rel(&[-2e-4], &[-1e4], &[1e19]), 0.0);
12244        assert!(rel(&[-1e4 - 1e-4], &[-1e4], &[1e19]) > 0.0);
12245        assert_eq!(rel(&[5.0], &[-1e19], &[1e19]), 0.0);
12246        assert_eq!(rel(&[0.5 + 1e-8], &[0.5], &[0.5]), 0.0);
12247        assert!(rel(&[1.0], &[0.0, 0.0], &[1.0, 1.0]).is_nan());
12248        // `NaN` is "not measured".
12249        assert!(retry_keeps_declared_box(Number::NAN, Number::NAN));
12250        assert!(!retry_keeps_declared_box(0.0, Number::NAN));
12251    }
12252
12253    /// Rule 2: an objective *improvement* bought with primal slack.
12254    ///
12255    /// `scholtes4` (`benchmarks/mpcc/cases.py`, and now a CLI fixture) has
12256    /// `f* = 0` exactly -- for the MPCC and for the smooth lowering alike,
12257    /// since `x1*x2 = 0` forces one of them to zero, hence `x3 <= 0`, hence
12258    /// `f = x1 - x3 >= 0`. The retry reports `-6.61e-05`, which no feasible
12259    /// point reaches, by moving the complementarity row 16 orders further
12260    /// out. Rule 1 cannot see it: the objective got *better*.
12261    #[test]
12262    fn an_improvement_bought_with_primal_slack_is_refused() {
12263        assert!(!admissible(
12264            1.8175997416e-9,
12265            2.07e-25,
12266            -6.6088333055e-5,
12267            1.09e-9
12268        ));
12269        // The same numbers with the primal *held* would be admissible --
12270        // this is the conjunct that refuses it, not the objective move.
12271        assert!(admissible(
12272            1.8175997416e-9,
12273            2.07e-25,
12274            -6.6088333055e-5,
12275            2.07e-25
12276        ));
12277    }
12278
12279    /// The window the tolerance sits in, from both sides, so it is a
12280    /// checkable claim rather than a fitted constant: the smallest move
12281    /// that must be admitted is `qpec_small`'s `5.8e-11` and the smallest
12282    /// that must be refused is `r201`'s `0.198`, four and five orders
12283    /// away from `acceptable_tol` on either side.
12284    #[test]
12285    fn the_objective_tolerance_is_not_fitted_to_one_model() {
12286        let admit = 5.835e-11;
12287        let refuse = 0.198;
12288        assert!(admit < ACCEPT / 1.0e4, "{admit} is not well inside");
12289        assert!(refuse > ACCEPT * 1.0e4, "{refuse} is not well outside");
12290        // Scale-relative above 1, absolute below it -- the same
12291        // convention `sigma_forward_error_is_small` uses for `norm(x)`.
12292        assert!(admissible(1.0e6, 0.0, 1.0e6 + 0.5, 0.0));
12293        assert!(!admissible(1.0e6, 0.0, 1.0e6 + 5.0, 0.0));
12294    }
12295
12296    /// gh#983 item 4: `x*y == 0` from `(0.3, 0.3)`. The base attempt sits at
12297    /// `f = 2` with a violation of `3e-38` and an unscaled dual infeasibility
12298    /// of `1.4e9`; the retry reaches `f = 1` at `1.96e-13`, six orders inside
12299    /// `constr_viol_tol`. Rule 2 compared the two violations bare and refused
12300    /// it. The floor admits arithmetic noise and nothing above it: the
12301    /// scholtes4 row (`1.09e-9`) is refused in the same breath.
12302    #[test]
12303    fn a_retry_violation_at_the_noise_floor_does_not_block_an_improvement() {
12304        assert!(retry_answer_is_admissible(
12305            2.0, 3.037e-38, 1.0, 1.9584e-13, ACCEPT, MIN
12306        ));
12307        assert!(retry_answer_is_admissible(
12308            -2.0, 3.037e-38, -1.0, 1.9584e-13, ACCEPT, MAX
12309        ));
12310        // Just above the floor is still slack.
12311        assert!(!retry_answer_is_admissible(
12312            2.0,
12313            3.037e-38,
12314            1.0,
12315            RETRY_VIOL_NOISE_FLOOR * 10.0,
12316            ACCEPT,
12317            MIN
12318        ));
12319        // ... and rule 1 is untouched by the floor.
12320        assert!(!retry_answer_is_admissible(
12321            1.0, 3.037e-38, 2.0, 1.0e-13, ACCEPT, MIN
12322        ));
12323        // MacMPEC pack-comp1p-32's exact finish: the retry reaches the
12324        // published optimum at a violation three orders inside `tol`, next to
12325        // a base at 2.5e-12. Admitted.
12326        assert!(retry_answer_is_admissible(
12327            0.704, 2.5e-12, 0.652997, 2.6e-11, ACCEPT, MIN
12328        ));
12329        // scholtes4: f* = 0 exactly, and the retry buys -6.6e-5 with a
12330        // violation of 1.09e-9. Still refused.
12331        assert!(!retry_answer_is_admissible(
12332            1.82e-9, 2.07e-25, -6.61e-5, 1.09e-9, ACCEPT, MIN
12333        ));
12334    }
12335
12336    /// The audit input with every measurement given and the defaults for
12337    /// the rest (`tol 1e-8`, `acceptable_tol 1e-6`, threshold `1e-4`).
12338    #[allow(clippy::too_many_arguments)]
12339    fn audit_in(
12340        tol: Number,
12341        obj_scale: Number,
12342        rescaled: Number,
12343        start_grad: Number,
12344        final_grad: Number,
12345        kkt: Number,
12346        compl: Number,
12347        th: Number,
12348    ) -> ScaleAuditInput {
12349        ScaleAuditInput {
12350            tol,
12351            obj_scale,
12352            rescaled_obj_scale: rescaled,
12353            start_grad_max: start_grad,
12354            final_grad_max: final_grad,
12355            unscaled_kkt: kkt,
12356            unscaled_compl: compl,
12357            acceptable_tol: 1e-6,
12358            masked_threshold: th,
12359            warm_start: false,
12360        }
12361    }
12362
12363    /// `audit_in` for a warm start (`warm_start_init_point=yes`).
12364    #[allow(clippy::too_many_arguments)]
12365    fn audit_in_warm(
12366        tol: Number,
12367        obj_scale: Number,
12368        rescaled: Number,
12369        start_grad: Number,
12370        final_grad: Number,
12371        kkt: Number,
12372        compl: Number,
12373        th: Number,
12374    ) -> ScaleAuditInput {
12375        ScaleAuditInput {
12376            warm_start: true,
12377            ..audit_in(
12378                tol, obj_scale, rescaled, start_grad, final_grad, kkt, compl, th,
12379            )
12380        }
12381    }
12382
12383    /// gh#983 / CI: a warm start is an answer, so its gradient -- and the
12384    /// returned point's -- carry the model's scale only above the barrier
12385    /// level. Both branches of the warm rule need a row: the session's
12386    /// interior optimum (gradient at the complementarity) stays out, and the
12387    /// reactor warm-started at its answer (gradient `4e3` times it) is still
12388    /// up-scaled.
12389    #[test]
12390    fn a_warm_start_at_an_answer_is_not_read_as_a_tiny_objective() {
12391        let th = 1e-4;
12392        let t = |inp| scale_audit_trigger(inp);
12393        // `cold_then_warm_through_presolve`'s warm re-solve, measured.
12394        assert_eq!(
12395            t(audit_in_warm(
12396                1e-8, 1.0, 1.0, 1.0e-11, 3.726e-9, 3.72e-9, 3.72e-9, th
12397            )),
12398            None
12399        );
12400        // The same numbers on a cold start are the tiny-objective signature:
12401        // the rule is the warm start's, not a change to the cold branch.
12402        assert!(matches!(
12403            t(audit_in(
12404                1e-8, 1.0, 1.0, 1.0e-11, 3.726e-9, 3.72e-9, 3.72e-9, th
12405            )),
12406            Some(ScaleAudit::Upscale(_))
12407        ));
12408        // The reactor (fs = 1e-6) warm-started from its answer: the active
12409        // bound's multiplier keeps the gradient at 1e-5, the model's scale.
12410        match t(audit_in_warm(
12411            1e-8, 1.0, 1.0, 1e-5, 1e-5, 2.5e-9, 2.5e-9, th,
12412        )) {
12413            Some(ScaleAudit::Upscale(k)) => assert!((k - 1e5).abs() < 1.0),
12414            other => panic!("{other:?}"),
12415        }
12416        // A barrier-level seed does not mask an answer that carries scale.
12417        assert!(matches!(
12418            t(audit_in_warm(
12419                1e-8, 1.0, 1.0, 1e-11, 1e-5, 2.5e-9, 2.5e-9, th
12420            )),
12421            Some(ScaleAudit::Upscale(_))
12422        ));
12423        // The re-scale branch does not read the gradient scale at all.
12424        assert_eq!(
12425            t(audit_in_warm(
12426                1e-8, 3e-7, 1.0, 3e8, 2.9e-2, 2.9e-2, 1e-9, th
12427            )),
12428            Some(ScaleAudit::Rescale)
12429        );
12430    }
12431
12432    /// gh#983: the audit's trigger branches on *which* scaling defect shows,
12433    /// and each branch needs its own row.
12434    #[test]
12435    fn the_scale_audit_trigger_takes_each_branch_and_neither_by_default() {
12436        let th = 1e-4;
12437        let t = |inp| scale_audit_trigger(inp);
12438        // Frozen factor, non-stationary in the model's own units, and the
12439        // factor re-evaluated at the returned point (gradient 2.9e-2 -> 1) is
12440        // seven orders from the frozen one: re-scale.
12441        assert_eq!(
12442            t(audit_in(1e-8, 3e-7, 1.0, 3e8, 2.9e-2, 2.9e-2, 1e-9, th)),
12443            Some(ScaleAudit::Rescale)
12444        );
12445        // gh#983 review item 2(b): the same masked certificate on a
12446        // legitimately large objective (`hs71 x 1e8`) re-evaluates to the
12447        // factor it already has -- the re-solve would be the same solve.
12448        assert_eq!(
12449            t(audit_in(1e-8, 2.2e-7, 2.4e-7, 4.6e8, 4.2e8, 3e-6, 1e-9, th)),
12450            None
12451        );
12452        // Tiny gradient, complementarity 2.5e-4 of it (the reactor, fs = 1e-6).
12453        match t(audit_in(1e-8, 1.0, 1.0, 1e-5, 1e-5, 2.5e-9, 2.5e-9, th)) {
12454            Some(ScaleAudit::Upscale(k)) => assert!((k - 1e5).abs() < 1.0),
12455            other => panic!("{other:?}"),
12456        }
12457        // gh#983 review item 4: no band without a signal. fs = 3e-5 (gradient
12458        // 3e-4, relative complementarity 8.4e-6) and fs = 1e-3 (2.5e-7) are
12459        // both re-solved now; fs = 1 is left alone.
12460        assert!(matches!(
12461            t(audit_in(1e-8, 1.0, 1.0, 3e-4, 3e-4, 2.5e-9, 2.5e-9, th)),
12462            Some(ScaleAudit::Upscale(_))
12463        ));
12464        assert!(matches!(
12465            t(audit_in(1e-8, 1.0, 1.0, 1e-2, 1e-2, 2.5e-9, 2.5e-9, th)),
12466            Some(ScaleAudit::Upscale(_))
12467        ));
12468        assert_eq!(
12469            t(audit_in(1e-8, 1.0, 1.0, 10.0, 10.0, 2.5e-9, 2.5e-9, th)),
12470            None
12471        );
12472        // A gradient in [0.1, 1): the signature, but an up-scale below the
12473        // material factor -- warn, do not re-solve.
12474        assert_eq!(
12475            t(audit_in(1e-8, 1.0, 1.0, 0.5, 0.5, 1e-6, 1e-6, th)),
12476            Some(ScaleAudit::SmallObjective)
12477        );
12478        // gh#983 review item 2(a): a start near a stationary point
12479        // (`grad f(x0) = 2e-7`) is not a tiny objective when the gradient at
12480        // the answer is 1.
12481        assert_eq!(
12482            t(audit_in(1e-8, 1.0, 1.0, 2e-7, 1.0, 2.5e-9, 2.5e-9, th)),
12483            None
12484        );
12485        // A start measured at a stationary point (gradient exactly 0) carries
12486        // no scale: the audit stays out however small the answer's gradient.
12487        assert_eq!(
12488            t(audit_in(1e-8, 1.0, 1.0, 0.0, 2e-4, 6e-3, 2.5e-9, th)),
12489            None
12490        );
12491        // An unmeasured start (caller-owned scaling) defers to the answer.
12492        assert!(matches!(
12493            t(audit_in(
12494                1e-8,
12495                f64::NAN,
12496                f64::NAN,
12497                f64::NAN,
12498                1e-5,
12499                2.5e-9,
12500                2.5e-9,
12501                th
12502            )),
12503            Some(ScaleAudit::Upscale(_))
12504        ));
12505        // A big gradient never up-scales, whatever the complementarity.
12506        assert_eq!(
12507            t(audit_in(1e-8, 1.0, 1.0, 50.0, 50.0, 1e-3, 1e-3, th)),
12508            None
12509        );
12510        // An extreme factor at a stationary point is the honest case.
12511        assert_eq!(
12512            t(audit_in(1e-8, 1e-8, 1e-8, 1e9, 1e9, 1e-9, 1e-12, th)),
12513            None
12514        );
12515        // The masked opt-out (threshold 0) disables the re-scale branch.
12516        assert_eq!(
12517            t(audit_in(1e-8, 3e-7, 1.0, 3e8, 2.9e-2, 2.9e-2, 1e-9, 0.0)),
12518            None
12519        );
12520        // A caller who loosened tol asked for the complementarity they got.
12521        assert_eq!(
12522            t(audit_in(5e-2, 1.0, 1.0, 1e-5, 1e-5, 1e-2, 1e-2, th)),
12523            None
12524        );
12525        // NaN stays quiet.
12526        let nan = f64::NAN;
12527        assert_eq!(t(audit_in(1e-8, nan, nan, nan, nan, nan, nan, th)), None);
12528        // The upscale is capped.
12529        match t(audit_in(1e-8, 1.0, 1.0, 1e-14, 1e-14, 1.0, 1.0, th)) {
12530            Some(ScaleAudit::Upscale(k)) => assert_eq!(k, SCALE_AUDIT_MAX_UPSCALE),
12531            other => panic!("{other:?}"),
12532        }
12533    }
12534
12535    /// gh#1000 (II.22): the roundoff exemption, on the measured population
12536    /// named in `UNSCALED_DUAL_ROUNDOFF_ULPS`'s doc.
12537    #[test]
12538    fn a_residual_at_the_roundoff_floor_of_its_terms_is_not_a_miss() {
12539        // Pump network at H0_max 148: 1.6 ulps of its terms.
12540        assert!(unscaled_dual_inf_is_roundoff(5.54e-3, 1.57e13));
12541        assert!(unscaled_dual_inf_is_roundoff(7.39e-3, 2.66e13));
12542        // The downgrades that are right keep it: Haverly, the process
12543        // network's T2 and S2+T2 subproblems, the eps-perspective root, and
12544        // the gh#983 cusp.
12545        assert!(!unscaled_dual_inf_is_roundoff(1.33e-2, 6.6e10));
12546        assert!(!unscaled_dual_inf_is_roundoff(0.5, 7.55e11));
12547        assert!(!unscaled_dual_inf_is_roundoff(6.25e-2, 3.21e11));
12548        assert!(!unscaled_dual_inf_is_roundoff(3.92e-3, 5.03e9));
12549        assert!(!unscaled_dual_inf_is_roundoff(0.144, 3.55e9));
12550        // No scale measured: keep the downgrade.
12551        assert!(!unscaled_dual_inf_is_roundoff(5.54e-3, f64::NAN));
12552        assert!(!unscaled_dual_inf_is_roundoff(5.54e-3, 0.0));
12553        assert!(!unscaled_dual_inf_is_roundoff(f64::NAN, 1e13));
12554    }
12555
12556    /// gh#983 review item 7: one warning per code.
12557    #[test]
12558    fn a_warning_code_is_attached_at_most_once() {
12559        let mut w = vec!["unscaled_dual_inf_above_acceptable: downgraded".to_string()];
12560        push_warning_once(
12561            &mut w,
12562            "unscaled_dual_inf_above_acceptable: verify in your units".to_string(),
12563        );
12564        push_warning_once(&mut w, "large_dual_scale: terms of 1e9".to_string());
12565        assert_eq!(w.len(), 2, "{w:?}");
12566        assert!(w[0].ends_with("downgraded"));
12567    }
12568
12569    /// gh#983: the structured warnings, by code, on success verdicts only.
12570    #[test]
12571    fn quality_warnings_carry_a_code_and_only_on_success_verdicts() {
12572        use ApplicationReturnStatus::*;
12573        // The cusp: multipliers 3.5e9, residual 0.144.
12574        let w = quality_warnings(SolveSucceeded, 0.144, 3.55e9, 1.0, 1e-6, false);
12575        assert!(
12576            w.iter().any(|m| m.starts_with("large_dual_scale:")),
12577            "{w:?}"
12578        );
12579        assert!(
12580            w.iter()
12581                .any(|m| m.starts_with("unscaled_dual_inf_above_acceptable:")),
12582            "{w:?}"
12583        );
12584        // orthrds2-like: scale 1.6e10, residual 89.7 -> only the scale caveat
12585        // and the absolute one; never a status change (the function returns
12586        // text, not a verdict).
12587        let w = quality_warnings(SolveSucceeded, 89.7, 1.6e10, 1.0, 1e-6, false);
12588        assert!(w.iter().any(|m| m.starts_with("large_dual_scale:")));
12589        assert!(!w.iter().any(|m| m.contains("bound_relax_factor")), "{w:?}");
12590        // gh#1000: under bound_relax_factor = 0 the caveat names the option.
12591        let w = quality_warnings(SolveSucceeded, 89.7, 1.6e10, 1.0, 1e-6, true);
12592        assert!(
12593            w.iter()
12594                .any(|m| m.starts_with("large_dual_scale:") && m.contains("bound_relax_factor = 0")),
12595            "{w:?}"
12596        );
12597        // A clean point has none.
12598        assert!(quality_warnings(SolveSucceeded, 1e-9, 5.0, 1.0, 1e-6, false).is_empty());
12599        // A large objective with a stationary residual is not a failed CQ.
12600        assert!(quality_warnings(SolveSucceeded, 2.9e-7, 1.46e9, 1.0, 1e-6, false).is_empty());
12601        // 5e-4 is below the warning bar even though it exceeds acceptable_tol.
12602        assert!(quality_warnings(SolveSucceeded, 5e-4, 5.0, 1.0, 1e-6, false).is_empty());
12603        // Failures carry no success caveat.
12604        assert!(quality_warnings(RestorationFailed, 0.144, 3.55e9, 1.0, 1e-6, false).is_empty());
12605        assert!(quality_warnings(SolveSucceeded, f64::NAN, f64::NAN, 1.0, 1e-6, false).is_empty());
12606    }
12607
12608    /// A counted re-solve (gh#983 scale audit, gh#884 retry) records every
12609    /// attempt's spend, and the total survives a decline (#1140 review). The
12610    /// base attempt here ran a `mu_strategy_fallback` (30 + 11 iterations)
12611    /// and the re-solve ran none (19): its spend is measured without the
12612    /// base's sticky record, which goes back afterwards. A decline restores
12613    /// the base's tally with `restore_into`, which must not touch the total.
12614    /// No CLI fixture reaches either branch (a declined retry, a base with a
12615    /// fallback), which is why this is a unit test.
12616    #[test]
12617    fn a_counted_resolve_keeps_every_attempts_spend_through_a_decline() {
12618        use pounce_nlp::solve_statistics::MuStrategyFallbackRecord;
12619        let app = IpoptApplication::new();
12620        let fallback = MuStrategyFallbackRecord {
12621            base_iteration_count: 30,
12622            retry_iteration_count: 11,
12623            ..Default::default()
12624        };
12625        *app.mu_strategy_fallback_record.borrow_mut() = Some(fallback.clone());
12626        {
12627            let mut st = app.statistics.borrow_mut();
12628            st.iteration_count = 30;
12629            st.mu_strategy_fallback = Some(fallback.clone());
12630        }
12631        let floor = SolutionCertificate::of(&app.statistics.borrow());
12632        let mark = app.begin_counted_resolve();
12633        // The re-solve: no fallback of its own, so the copy at the end of a
12634        // run leaves the (set-aside, now empty) record empty.
12635        {
12636            let mut st = app.statistics.borrow_mut();
12637            st.iteration_count = 19;
12638            st.mu_strategy_fallback = app.mu_strategy_fallback_record.borrow().clone();
12639        }
12640        app.end_counted_resolve(mark);
12641        let st = app.statistics.borrow().clone();
12642        assert_eq!(st.retry_total_iterations, Some(30 + 11 + 19));
12643        assert_eq!(st.spent_iteration_count(), 60);
12644        // The sticky record is back, as the rule leaves it.
12645        assert_eq!(
12646            app.mu_strategy_fallback_record
12647                .borrow()
12648                .as_ref()
12649                .map(|r| r.total_iteration_count()),
12650            Some(41)
12651        );
12652        // Declined: the base's tally comes back, the total stays.
12653        floor.restore_into(&mut app.statistics.borrow_mut());
12654        let st = app.statistics.borrow();
12655        assert_eq!(st.iteration_count, 30);
12656        assert_eq!(st.retry_total_iterations, Some(60));
12657        assert_eq!(st.budget_iteration_count(), 41);
12658    }
12659
12660    /// gh#983 item 5: flooring an attempt must floor everything a solve
12661    /// report reads off the statistics, the iteration count and the row table
12662    /// included, or the report describes the declined retry.
12663    #[test]
12664    fn the_certificate_floor_carries_the_iteration_trace() {
12665        use pounce_nlp::solve_statistics::{IterRecord, SolveStatistics};
12666        let mut base = SolveStatistics::new();
12667        base.iteration_count = 64;
12668        base.iterations = vec![IterRecord::default(); 3];
12669        base.num_obj_evals = 159;
12670        base.restoration_calls = 2;
12671        let floor = SolutionCertificate::of(&base);
12672        let mut retry = SolveStatistics::new();
12673        retry.iteration_count = 15;
12674        retry.iterations = vec![IterRecord::default(); 15];
12675        retry.num_obj_evals = 86;
12676        floor.restore_into(&mut retry);
12677        assert_eq!(retry.num_obj_evals, 159);
12678        assert_eq!(retry.restoration_calls, 2);
12679        assert_eq!(retry.iteration_count, 64);
12680        assert_eq!(retry.iterations.len(), 3);
12681    }
12682
12683    /// An infeasible base attempt is not a point worth protecting, so
12684    /// both rules stand down and the certificate conjuncts decide alone.
12685    /// R2: `obj_scaling_factor < 0` poses a maximization, and
12686    /// `final_objective` is the user's **signed** objective, so both rules
12687    /// have to follow the sense. This is the table above with every
12688    /// objective negated: the same three answers must be refused and the
12689    /// same one admitted, with `MAX` instead of `MIN`.
12690    ///
12691    /// Without the normalization each row flips — rule 1 starts refusing
12692    /// genuine improvements (a regression against the behaviour before the
12693    /// conjunct existed) and rule 2 starts admitting strictly worse
12694    /// answers, which is the class it was added to block.
12695    #[test]
12696    fn the_rules_follow_the_objective_sense() {
12697        // qpec_small, mirrored: the retry is worse by 5.8e-11, well inside
12698        // the tolerance, so it is still admitted.
12699        assert!(retry_answer_is_admissible(
12700            -3.586e-28, 1.11e-16, -5.835e-11, 5.47e-12, ACCEPT, MAX
12701        ));
12702        // r116, mirrored: +13.0057 given up for +1.2072 is now a *worse*
12703        // maximum, and rule 1 must still refuse it.
12704        assert!(!retry_answer_is_admissible(
12705            1.3005680756e1,
12706            2.22e-16,
12707            1.2072337962e0,
12708            4.55e-13,
12709            ACCEPT,
12710            MAX
12711        ));
12712        // scholtes4, mirrored: +6.6088e-05 is now an improvement bought
12713        // with primal slack, and rule 2 must still refuse it.
12714        assert!(!retry_answer_is_admissible(
12715            -1.8175997416e-9,
12716            2.07e-25,
12717            6.6088333055e-5,
12718            1.09e-9,
12719            ACCEPT,
12720            MAX
12721        ));
12722        // ... and the same improvement with the primal held is admitted.
12723        assert!(retry_answer_is_admissible(
12724            -1.8175997416e-9,
12725            2.07e-25,
12726            6.6088333055e-5,
12727            2.07e-25,
12728            ACCEPT,
12729            MAX
12730        ));
12731    }
12732
12733    /// The mirror of the above, stated as the property rather than the
12734    /// rows: negating both objectives and flipping the sense must leave
12735    /// every verdict unchanged.
12736    #[test]
12737    fn negating_the_objective_and_the_sense_is_inert() {
12738        for &(bo, bv, ro, rv) in &[
12739            (3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12),
12740            (-1.3005680756e1, 2.22e-16, -1.2072337962e0, 4.55e-13),
12741            (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 1.09e-9),
12742            (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 2.07e-25),
12743            (-1.0e3, 1.0e-2, 0.0, 1.0e-12),
12744        ] {
12745            assert_eq!(
12746                retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN),
12747                retry_answer_is_admissible(-bo, bv, -ro, rv, ACCEPT, MAX),
12748                "verdict moved under (obj, sense) -> (-obj, -sense) at {bo:e}/{ro:e}"
12749            );
12750        }
12751    }
12752
12753    #[test]
12754    fn an_infeasible_base_attempt_protects_nothing() {
12755        assert!(admissible(-1.0e3, 1.0e-2, 0.0, 1.0e-12));
12756        // ... and what cannot be measured is refused, not admitted, the
12757        // same way `runaway_is_the_whole_residual` treats a NaN.
12758        assert!(admissible(Number::NAN, 0.0, 0.0, 0.0));
12759        assert!(!admissible(0.0, 0.0, Number::NAN, 0.0));
12760        assert!(!admissible(0.0, 0.0, -1.0, Number::NAN));
12761    }
12762}