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/// Is the retry's answer admissible *as an answer*, next to the base
138/// attempt's — independent of which has the better multiplier?
139///
140/// gh#884's promotion gate ranked the two attempts on unscaled KKT error
141/// alone. That is a statement about the **certificate**, and it was
142/// allowed to decide which **point** shipped, on the argument that
143/// "conjunct 4 requires the promoted answer to satisfy the KKT conditions
144/// in the model's own units" — so, unlike the μ flip, this retry could not
145/// return a different local solution. The inference does not hold: *any*
146/// other KKT point satisfies the KKT conditions in the model's own units
147/// too. Measured on 400 random QPECs (`prod_eq` lowering), 42 of 68
148/// promotions moved the objective materially, i.e. returned a different
149/// local solution, and three returned a **worse feasible point** — worst
150/// case `-13.0057 → -1.2072`, both independently verified feasible.
151///
152/// Two rules, and both are about the *answer* rather than its certificate:
153///
154/// 1. **Never hand back a feasible point whose objective is worse than one
155///    this run already computed.** The base attempt's point is feasible
156///    and in hand; returning a worse one is a regression no certificate
157///    buys back.
158/// 2. **An objective *improvement* may not be bought with primal slack.**
159///    Two costs this carries, named rather than hidden (R3). It is an exact
160///    non-increase with **no noise floor**, so it fires the same on
161///    `2.07e-25 -> 1.09e-09` (where the move is the whole defect) as on
162///    `1e-17 -> 1.1e-17` (where which side a retry lands on is arithmetic):
163///    of the 45 promotions this PR removes, ~35 are improvements refused
164///    because the violation ticked up somewhere far below any tolerance. The
165///    direction is conservative — the base answer is feasible and in hand —
166///    but "refuses purchases, not improvements" is only true above the noise.
167///    And the rule detects *the primal moved*, not *below the optimum*: had
168///    `scholtes4`'s retry **held** its violation, `f = -6.6088e-05` would have
169///    been admitted, which the second assertion of
170///    `an_improvement_bought_with_primal_slack_is_refused` states outright.
171///    On that model the two coincided.
172///    The remedy is for a *dual* defect — the premise is that the primal
173///    has settled — so a retry that lands further outside the constraints
174///    *and* reports a better objective has not repaired the runaway, it
175///    has moved somewhere the model does not reach. This is what
176///    `scholtes4` does: from a base at `f = +1.82e-09` with a constraint
177///    violation of `2.07e-25` it promotes `f = -6.61e-05` at `1.09e-09`,
178///    below the model's exactly-known `f* = 0`, and reports
179///    `Optimal Solution Found`.
180///
181/// Both comparisons are skipped when the base attempt is not itself
182/// feasible within [`IpoptApplication::dual_divergence_retry_accept_tol`]:
183/// there is then no admissible point to protect, and the retry's is
184/// strictly better information.
185///
186/// `tol` is that same acceptable tolerance, scaled by
187/// `max(1, |base_obj|)` so the comparison is relative on a large
188/// objective and absolute on a small one — the convention
189/// `sigma_forward_error_is_small` uses for `‖x‖` and for the same reason.
190/// It is not a fitted constant: the objective moves this has to admit
191/// (`qpec_small`, `5.8e-11`) and the ones it has to refuse (`r201`,
192/// `0.198`; `scholtes4`, `6.6e-05`) sit four and five orders away from it
193/// on either side.
194///
195/// `sense` is `+1` for a minimization and `-1` for a maximization, and both
196/// objectives are multiplied by it before either rule looks at them.
197/// [`SolveStatistics::final_objective`] is the objective evaluated on the
198/// **user** TNLP — signed, and *not* premultiplied by `obj_scaling_factor` —
199/// and a negative `obj_scaling_factor` is the documented way to pose a
200/// maximization. Without the normalization both rules invert under it: rule 1
201/// would refuse genuine *improvements* (a regression against the behaviour
202/// before this conjunct existed) and rule 2 would admit strictly worse
203/// answers, re-arming the exact class the conjunct was added to block. The
204/// repo has shipped one defect from this sign already —
205/// `masked_certificate_fuzz.rs::the_veto_is_not_disabled_by_a_negative_objective_scaling_factor`,
206/// whose fix took `.abs()` in the residual accessors, which is also what keeps
207/// the detector firing under maximization and so keeps this path reachable.
208fn retry_answer_is_admissible(
209    base_obj: Number,
210    base_viol: Number,
211    retry_obj: Number,
212    retry_viol: Number,
213    accept_tol: Number,
214    sense: Number,
215) -> bool {
216    // Nothing to compare against: a non-finite or infeasible base answer
217    // is not a point worth protecting.
218    if !base_obj.is_finite() || !base_viol.is_finite() || base_viol > accept_tol {
219        return true;
220    }
221    // A non-finite retry objective is refused rather than admitted, for
222    // the same reason `runaway_is_the_whole_residual` refuses a NaN.
223    if !retry_obj.is_finite() {
224        return false;
225    }
226    // Both into a minimization frame, so "lower is better" below is true
227    // whichever sense the caller posed.
228    let base_obj = sense * base_obj;
229    let retry_obj = sense * retry_obj;
230    let tol = accept_tol * base_obj.abs().max(1.0);
231    if retry_obj > base_obj + tol {
232        return false; // rule 1: strictly worse feasible point
233    }
234    if retry_obj < base_obj - tol {
235        // Rule 2: an improvement is admissible only if the retry did not
236        // give up primal accuracy to get it.
237        return retry_viol.is_finite() && retry_viol <= base_viol;
238    }
239    true
240}
241
242/// What [`IpoptApplication::initialize_with_option_file`] did — enough
243/// for a caller to tell the user which file (if any) configured the run.
244#[derive(Debug, Default, Clone)]
245pub struct OptionFileLoad {
246    /// The file actually read. `None` means no options file was read:
247    /// nobody named one and neither default was present.
248    pub path: Option<PathBuf>,
249    /// Whether [`Self::path`] was named by the caller rather than found
250    /// by probing the working directory.
251    pub explicit: bool,
252    /// Non-fatal notes about option-file settings that did *not* take
253    /// effect. Nothing here stops a solve; the point is that it not
254    /// happen silently.
255    pub warnings: Vec<String>,
256}
257
258/// Factory that constructs a fresh restoration-phase strategy on
259/// demand. The outer algorithm owns at most one restoration object,
260/// so the factory is invoked once per `optimize_tnlp` call. The
261/// factory is `FnMut` to allow callers to capture a builder that
262/// internally reuses caches across builds.
263pub type RestorationFactory = Box<dyn FnMut() -> Box<dyn RestorationPhase>>;
264
265/// Provider that mints fresh [`RestorationFactory`] instances on
266/// demand. Used by drivers that need to run the inner IPM more than
267/// once per `optimize_tnlp` call — notably the Phase-3 ℓ₁-exact
268/// penalty-barrier outer loop (pounce#10), which the existing
269/// `RestorationFactory` cannot support because pounce's default
270/// `make_default_restoration_factory` is a one-shot. Callers wire
271/// this via [`IpoptApplication::set_restoration_factory_provider`].
272pub type RestorationFactoryProvider = Box<dyn FnMut() -> RestorationFactory>;
273
274/// Callback fired by [`IpoptApplication::optimize_constrained`] once
275/// the IPM has converged (status `SolveSucceeded` or
276/// `SolvedToAcceptableLevel`) and before the user TNLP's
277/// `finalize_solution` runs. Receives borrowed handles into the
278/// algorithm's converged state.
279///
280/// **Use case**: post-optimal sensitivity analysis (pounce#7 /
281/// `pounce-sensitivity`). The callback receives a shared handle to
282/// the PD solver so a `SensBacksolver` adapter can run backsolves
283/// against the converged KKT factor — and so that handle may outlive
284/// the call frame (e.g. the public `Solver` session API retains the
285/// factor for repeated `parametric_step` / `kkt_solve` calls);
286/// receives the data / cq / nlp handles so the adapter can reproduce
287/// the augmented-system coefficient layout the IPM converged at.
288///
289/// **Not** the same as `set_intermediate_callback` (per-iteration
290/// progress notification) — this fires exactly once per `optimize_*`
291/// call, only on success.
292pub type ConvergedCallback = Box<
293    dyn FnMut(
294        &crate::ipopt_data::IpoptDataHandle,
295        &crate::ipopt_cq::IpoptCqHandle,
296        &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
297        Rc<RefCell<crate::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
298    ),
299>;
300use pounce_common::diagnostics::DiagnosticsState;
301use pounce_common::exception::{ExceptionKind, SolverException};
302use pounce_common::journalist::{JournalLevel, Journalist};
303use pounce_common::options_list::OptionsList;
304use pounce_common::reg_options::{PrintOptionsMode, RegisteredOptions};
305use pounce_common::timing::TimingStatistics;
306use pounce_common::types::{Index, Number};
307use pounce_linalg::dense_vector::DenseVectorSpace;
308use pounce_linsol::SparseSymLinearSolverInterface;
309use pounce_linsol::summary::LinearSolverSummary;
310use pounce_nlp::alg_types::SolverReturn;
311use pounce_nlp::derivative_test::{DerivativeTest, DerivativeTestOptions};
312use pounce_nlp::orig_ipopt_nlp::{ConstObjScaling, OrigIpoptNlp, ScalingMethod};
313use pounce_nlp::return_codes::ApplicationReturnStatus;
314use pounce_nlp::solve_statistics::SolveStatistics;
315use pounce_nlp::tnlp::{
316    BoundsInfo, IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, NlpInfo, Solution, TNLP,
317};
318use pounce_nlp::tnlp_adapter::{
319    DEFAULT_NLP_LOWER_BOUND_INF, DEFAULT_NLP_UPPER_BOUND_INF, FixedVarTreatment, TNLPAdapter,
320};
321use std::cell::RefCell;
322use std::fmt;
323use std::path::{Path, PathBuf};
324use std::rc::Rc;
325use std::sync::{Arc, Mutex};
326use std::time::Instant;
327
328pub struct IpoptApplication {
329    options: OptionsList,
330    /// Diagnostics from the safeguarded `least_square_init_primal`
331    /// initializer step of the most recent solve (gh#605). `None` when
332    /// the step was not run. Read with
333    /// [`Self::least_square_init_report`].
334    least_square_init_report: Option<crate::init::default::LeastSquareInitReport>,
335    /// Per-variable scaling factors applied by the wrapper installed in
336    /// [`Self::optimize_tnlp`] (gh#486). Recorded so consumers that read
337    /// the algorithm's own iterate rather than the `finalize_solution`
338    /// payload — the CLI's `on_converged` hook feeding the `.sol` and
339    /// the JSON report — can undo the substitution. `None` when no
340    /// variable scaling was applied.
341    variable_scaling: RefCell<Option<Vec<Number>>>,
342    /// Whether the most recent `optimize_constrained` ran with per-row
343    /// constraint scaling active (`c_scale_vec`/`d_scale_vec` present).
344    ///
345    /// Read by [`Self::run_l1_penalty_outer_loop`], which measures the
346    /// user's rows in the model's own units and so may only write that
347    /// number into the `final_unscaled_*` family. `SolveStatistics`
348    /// documents `final_*` as the internally-scaled residuals and
349    /// `final_unscaled_*` as the same quantities in original units,
350    /// *equal when no scaling is active* — so the measurement may be
351    /// mirrored into the scaled family exactly when this is `false`
352    /// (gh#794 review). `None` when no solve has run.
353    row_scaling_active: std::cell::Cell<Option<bool>>,
354    /// Whether the submitted TNLP has already been explicitly wrapped by the
355    /// caller's presolve layer.
356    presolve_already_applied: bool,
357    reg_options: Rc<RegisteredOptions>,
358    journalist: Rc<Journalist>,
359    statistics: RefCell<SolveStatistics>,
360    /// Shared per-subsystem timing accumulator. Re-created at the top of
361    /// every solve (so back-to-back `optimize_tnlp` calls don't bleed
362    /// timings across invocations) and handed to the data, the NLP, and
363    /// any other consumer via `Rc`. Reported by [`Self::timing_stats`]
364    /// after the solve completes.
365    timing: RefCell<Rc<TimingStatistics>>,
366    /// Optional override factory for the symmetric linear-solver
367    /// backend. When `None`, we ship the workspace default (MA57 via
368    /// `pounce-hsl`). Tests can plug a stub via [`Self::set_linear_backend_factory`].
369    linear_backend_factory: Option<LinearBackendFactory>,
370    /// Optional factory for the restoration phase. Lives outside this
371    /// crate because `pounce-algorithm` cannot depend on
372    /// `pounce-restoration` (the dep edge is the other way). Callers
373    /// that need restoration plug a factory via
374    /// [`Self::set_restoration_factory`]; when unset, the outer
375    /// algorithm runs without a restoration fallback and surfaces
376    /// `RestorationFailure` as soon as the line-search would otherwise
377    /// jump into restoration.
378    restoration_factory: Option<RestorationFactory>,
379    /// Shared diagnostic-dump state, installed by the CLI when the
380    /// user passes `--dump <cat>:<spec>`. When set, the application
381    /// propagates an `Rc<DiagnosticsState>` into [`IpoptAlgorithm`]
382    /// via [`IpoptAlgorithm::with_diagnostics`] so the KKT solver and
383    /// other dump sites can consult per-iter gating.
384    diagnostics: Option<Rc<DiagnosticsState>>,
385    /// Optional interactive debugger hook. When set, it is moved into
386    /// the main [`IpoptAlgorithm`] for the next `optimize_*` call via
387    /// [`IpoptAlgorithm::with_debug_hook`], so a REPL or agent can pause
388    /// at each iteration to inspect / mutate live state. Consumed on use
389    /// (one solve per installed hook).
390    debug_hook: Option<std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>>,
391    /// Provider for the BNW outer loop (pounce#10 Phase 3). When set,
392    /// `optimize_constrained` consults the provider before each inner
393    /// solve, replacing `restoration_factory` with a fresh one so
394    /// multi-pass drivers can run the inner IPM repeatedly without
395    /// tripping the default factory's one-shot guard.
396    restoration_factory_provider: Option<RestorationFactoryProvider>,
397    /// Optional hook fired once per `optimize_*` call on convergence,
398    /// before the user TNLP's `finalize_solution`. See
399    /// [`ConvergedCallback`].
400    on_converged: Option<ConvergedCallback>,
401    /// When `true`, the per-iteration `IterRecord` trajectory is
402    /// captured into [`SolveStatistics::iterations`] for downstream
403    /// consumers (the JSON solve report in pounce-cli, pounce#8). Off
404    /// by default so library callers that never read the iterations
405    /// vector don't pay the per-iter alloc.
406    record_iter_history: bool,
407    /// Whether [`Self::initialize_with_option_file`] ran — i.e. whether
408    /// anything on this application actually consulted
409    /// `option_file_name` and resolved it to a file. Only the `pounce`
410    /// CLI does; a library caller sets its options directly. The guard
411    /// in [`Self::unhonored_option_file_name`] reads this so that
412    /// setting the option on a surface that cannot honor it is refused
413    /// rather than dropped (gh#518).
414    option_file_resolved: bool,
415    /// Whether this caller can route a model to the convex LP/QP/SOCP
416    /// engines — i.e. whether the `qp_*` knobs those engines read
417    /// configure anything here. Only the `pounce` CLI can (it owns the
418    /// `.nl` structure extraction that classifies a model), and it says
419    /// so via [`Self::set_convex_routing_available`]. The guard in
420    /// [`Self::unhonored_convex_option`] reads this, on the same
421    /// contract as `option_file_resolved` above (gh#604).
422    convex_routing_available: bool,
423    /// Whether the backend-knob warnings (gh#551) have already been
424    /// printed for this application. The CLI emits them before routing —
425    /// a convex model never reaches `optimize_tnlp` — and `optimize_tnlp`
426    /// emits them for every other frontend; without this flag a CLI run
427    /// would print each line twice, which is how a warning teaches its
428    /// reader to skip it.
429    backend_warnings_emitted: bool,
430    /// Shared sink that the linear-solver backend writes a rolling
431    /// [`LinearSolverSummary`] into after every factor. Reset at the
432    /// top of every solve (so back-to-back `optimize_tnlp` calls don't
433    /// bleed stats across invocations) and read out via
434    /// [`Self::linear_solver_summary`] once the solve returns. Only
435    /// the workspace-default FERAL backend (via
436    /// [`default_backend_factory_with_sink`]) wires the sink today;
437    /// custom factories plugged through [`Self::set_linear_backend_factory`]
438    /// and the HSL MA57 backend leave the sink empty.
439    linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
440    /// Shared tally of successful linear-solver quality escalations for
441    /// the current solve (gh#857). Handed to every `AlgorithmBuilder`
442    /// this application mints — [`Self::algorithm_builder_from_options`]
443    /// and [`Self::algorithm_builder_snapshot`] — which is how the
444    /// restoration sub-solve counts into the same total: each frontend
445    /// builds the restoration provider's inner builder from
446    /// `algorithm_builder_from_options`, so it receives this same `Rc`
447    /// without any frontend having to know the counter exists.
448    ///
449    /// Reset at the top of every solve, beside the linear-solver summary
450    /// sink and for the same reason: a second-opinion retry, or two
451    /// back-to-back `optimize_tnlp` calls, must not inherit the previous
452    /// solve's count. Read out into
453    /// [`SolveStatistics::quality_escalations`] once the solve returns.
454    quality_escalations: Rc<std::cell::Cell<u64>>,
455    /// gh#884. Set when the running [`IpoptAlgorithm`] observed the
456    /// biactive dual-divergence signature — a converged primal, a step
457    /// that has gone to zero on a scale-relative measure, and an
458    /// *unscaled* dual infeasibility still far above `dual_inf_tol`, all
459    /// at the same iterate.
460    ///
461    /// Read out of the algorithm once `optimize_constrained` returns, so
462    /// [`Self::run_with_dual_divergence_retry`] — which sits above that
463    /// call — can see it. Reset at the top of every solve for the same
464    /// reason as `quality_escalations`: a retry must not inherit the
465    /// previous attempt's verdict. Also copied into
466    /// [`SolveStatistics::dual_divergence_signature`].
467    dual_divergence_signature: std::cell::Cell<bool>,
468    /// gh#884. Set when a dual-divergence retry actually replaced the base
469    /// attempt's answer. Copied into
470    /// [`SolveStatistics::dual_divergence_retry_promoted`].
471    dual_divergence_retry_promoted: std::cell::Cell<bool>,
472    /// Set when a losing retry's answer was thrown away and an earlier
473    /// attempt's replayed through `FinalizeSnapshot::replay` — by the μ
474    /// fallback (pounce#870) or the gh#884 dual-divergence retry.
475    ///
476    /// It exists because [`Self::set_on_converged`] fires **per attempt**
477    /// and the floor does not reach it. A losing retry that reached
478    /// `Solve_Succeeded` has already run the callback, so a consumer
479    /// capturing the converged iterate there — the CLI's `nominal_capture`,
480    /// which is where `.sol` `x`, the JSON's `solution.x` and the dual
481    /// block all come from — holds the *discarded* attempt's point while
482    /// the status, objective and every statistic beside it have been
483    /// floored back to the attempt that won. Measured before this flag
484    /// existed: on a declined retry the `.sol` carried `f = -6.3274` while
485    /// the JSON report next to it said `-6.1768`, and `pounce verify` on
486    /// the `.sol` confirmed the file held the losing point.
487    ///
488    /// The `finalize_solution` payload does not have this problem — the
489    /// replay *is* a `finalize_solution` call, so the last one always
490    /// carries the answer being reported — which is why the fix is to tell
491    /// the caller "prefer that payload", not to re-run the callback (the
492    /// converged KKT state the callback borrows belongs to the retry by
493    /// then, and cannot be rewound).
494    answer_restored_from_floor: std::cell::Cell<bool>,
495    /// The payload of the most recent `finalize_solution` POUNCE sent to the
496    /// user's TNLP, so a second-opinion retry that loses can put the winning
497    /// attempt's answer back (pounce#870).
498    ///
499    /// Written by [`finalize_via_orig_nlp`] and [`finalize_via_sqp`], which are
500    /// free functions and so take this as a sink rather than reaching for
501    /// `self`. Read only by [`Self::run_with_mu_strategy_fallback`].
502    last_finalize: RefCell<Option<FinalizeSnapshot>>,
503    /// The last `IterStats` sent to the user's `intermediate_callback`
504    /// (pounce#870), shared with the running `IpoptAlgorithm`.
505    last_iter_stats: Rc<RefCell<Option<pounce_nlp::tnlp::IterStats>>>,
506    /// Phase 5c (§6) SQP warm-start input. When `Some`, the next
507    /// `optimize_tnlp` call on the SQP path consumes the iterate
508    /// instead of cold-starting; consumed once per solve, then
509    /// auto-cleared. The IPM path ignores this field. Wire-set
510    /// via [`Self::set_sqp_warm_start`].
511    sqp_warm_start: Option<crate::sqp::SqpIterates>,
512    /// Phase 5c (§6) SQP warm-start output. Populated by every
513    /// `optimize_sqp_tnlp` call with the final QP working set.
514    /// Stays valid until the next solve (which overwrites it).
515    /// Accessed via [`Self::last_sqp_working_set`].
516    sqp_last_working_set: Option<pounce_qp::WorkingSet>,
517    /// What the post-convergence crossover phase did on the most recent
518    /// IPM solve (gh#612). `None` when `crossover=no` (the default) or
519    /// when the solve did not converge — crossover only runs on a
520    /// converged interior iterate. Read via [`Self::crossover_report`].
521    ///
522    /// Kept separate from `sqp_last_working_set`, which crossover *also*
523    /// populates on success: that field answers "what can the next solve
524    /// warm-start from", this one answers "was the active set I am about
525    /// to trust actually identified, or merely inferred".
526    crossover_report: Option<crate::crossover::CrossoverReport>,
527    /// Full primal-dual warm-start iterate for the IPM path, captured by
528    /// the interactive debugger's `resolve` command. When `Some`, the
529    /// next `optimize_tnlp` installs this 8-vector (algorithm space)
530    /// directly onto `data.curr` before the iterate initializer runs, so
531    /// a warm `resolve` continues from the paused interior point rather
532    /// than cold-restarting the duals. Consumed once per solve, then
533    /// auto-cleared. Requires `warm_start_init_point=yes` so the
534    /// re-optimize branch of `WarmStartIterateInitializer` keeps the
535    /// installed iterate. Wire-set via [`Self::set_warm_start_iterate`].
536    warm_start_iterate: Option<crate::debug::IterateSnapshot>,
537    /// The warm-start initializer's verdict on the most recent solve's
538    /// supplied iterate (gh#606). Lifted off the solve-local
539    /// `IpoptData` so a caller can read it after `optimize_tnlp`
540    /// returns; `None` when the last solve was a cold start.
541    warm_start_diag: RefCell<Option<crate::init::warm_start::WarmStartDiagnostics>>,
542    /// Caller-supplied fill-reducing permutation for the KKT linear
543    /// solver (pounce#180 item 1 / FERAL#107). When `Some`, it overrides
544    /// whatever `feral_ordering` / `POUNCE_FERAL_ORDERING` resolves to,
545    /// installing [`pounce_feral::OrderingMethod::External`] on the FERAL
546    /// backend for the next solve. The vector is a **0-based, new-to-old
547    /// permutation** whose length must equal the augmented KKT system
548    /// dimension; FERAL validates it as a bijection and returns
549    /// `InvalidInput` (never panics) on a wrong length / index. Unlike the
550    /// warm-start hooks this is treated as persistent config — it is *not*
551    /// auto-cleared after a solve, so a caller sets it once for a run.
552    /// Wire-set via [`Self::set_external_ordering`]. Ignored by non-FERAL
553    /// backends and by any custom factory plugged via
554    /// [`Self::set_linear_backend_factory`].
555    external_ordering: Option<Vec<usize>>,
556    /// Caller-supplied block-triangular / Schur KKT partition (pounce#180
557    /// item 2). When `Some`, the next IPM solve on the feral + exact-Hessian
558    /// path routes the KKT linear solve through a
559    /// [`crate::kkt::SchurAugSystemSolver`] over these **KKT-space indices**
560    /// (`0..dim` in the `x, s, c, d` block order the aug-system solver
561    /// assembles): the `S` block is Schur-complemented out and only the two
562    /// diagonal blocks are factorized, with inertia recovered via Sylvester's
563    /// law. Beneficial only when `|S| ≪` the eliminated block; the Schur solver
564    /// falls back to the standard full-space solver transparently when the
565    /// partition is unsuitable (too large, malformed, or the backend errors),
566    /// so a stray hook never breaks a solve. Persistent config (not
567    /// auto-cleared). Wire-set via [`Self::set_kkt_schur_block`].
568    kkt_schur_block: Option<Vec<usize>>,
569}
570
571impl fmt::Debug for IpoptApplication {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        f.debug_struct("IpoptApplication")
574            .field("options", &self.options)
575            .field("statistics", &self.statistics)
576            .finish_non_exhaustive()
577    }
578}
579
580impl Default for IpoptApplication {
581    fn default() -> Self {
582        Self::new()
583    }
584}
585
586impl IpoptApplication {
587    /// New application with empty options and a default journalist.
588    /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
589    pub fn new() -> Self {
590        let reg = RegisteredOptions::default();
591        // Registration of a fresh registry can only fail on a duplicate
592        // name, which would be a programming error in `reg_op`.
593        register_all_upstream_options(&reg)
594            .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
595        pounce_presolve::register_options(&reg)
596            .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
597        let reg = Rc::new(reg);
598        Self {
599            options: OptionsList::with_registered(Rc::clone(&reg)),
600            least_square_init_report: None,
601            variable_scaling: RefCell::new(None),
602            row_scaling_active: std::cell::Cell::new(None),
603            presolve_already_applied: false,
604            reg_options: reg,
605            journalist: Rc::new(Journalist::new()),
606            statistics: RefCell::new(SolveStatistics::new()),
607            timing: RefCell::new(Rc::new(TimingStatistics::new())),
608            linear_backend_factory: None,
609            restoration_factory: None,
610            diagnostics: None,
611            debug_hook: None,
612            restoration_factory_provider: None,
613            on_converged: None,
614            record_iter_history: false,
615            option_file_resolved: false,
616            convex_routing_available: false,
617            backend_warnings_emitted: false,
618            linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
619            quality_escalations: Rc::new(std::cell::Cell::new(0)),
620            dual_divergence_signature: std::cell::Cell::new(false),
621            dual_divergence_retry_promoted: std::cell::Cell::new(false),
622            answer_restored_from_floor: std::cell::Cell::new(false),
623            last_finalize: RefCell::new(None),
624            last_iter_stats: Rc::new(RefCell::new(None)),
625            sqp_warm_start: None,
626            sqp_last_working_set: None,
627            crossover_report: None,
628            warm_start_iterate: None,
629            warm_start_diag: RefCell::new(None),
630            external_ordering: None,
631            kkt_schur_block: None,
632        }
633    }
634
635    pub fn options(&self) -> &OptionsList {
636        &self.options
637    }
638
639    pub fn options_mut(&mut self) -> &mut OptionsList {
640        &mut self.options
641    }
642
643    /// Declare whether callers have already applied an explicit presolve
644    /// wrapper to the TNLPs submitted to [`Self::optimize_tnlp`].
645    ///
646    /// When set, `optimize_tnlp` leaves its input TNLP unchanged even if the
647    /// `presolve` option is enabled. This preserves the option table for
648    /// reporting and debugger use while allowing specialized frontends to
649    /// supply a wrapper with capabilities unavailable to generic callback
650    /// TNLPs, such as an expression provider for FBBT.
651    pub fn set_presolve_already_applied(&mut self, applied: bool) {
652        self.presolve_already_applied = applied;
653    }
654
655    /// Solve without materializing the generic presolve wrapper.
656    ///
657    /// This is for consumers that require the original TNLP coordinate system
658    /// for the solve's KKT matrix, such as sensitivity and reduced-Hessian
659    /// drivers. It is scoped to this invocation and does not change the
660    /// application's `presolve` option or persistent explicit-wrapper setting.
661    pub fn optimize_tnlp_without_presolve(
662        &mut self,
663        tnlp: Rc<RefCell<dyn TNLP>>,
664    ) -> ApplicationReturnStatus {
665        let explicit_wrapper = self.presolve_already_applied;
666        self.presolve_already_applied = true;
667        let status = self.optimize_tnlp(tnlp);
668        self.presolve_already_applied = explicit_wrapper;
669        status
670    }
671
672    pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
673        &self.reg_options
674    }
675
676    pub fn journalist(&self) -> &Rc<Journalist> {
677        &self.journalist
678    }
679
680    /// Plug a custom symmetric-linear-solver factory. Useful for tests
681    /// that want to swap MA57 for a stub. Production callers should
682    /// leave this unset — the default ([`default_backend_factory`])
683    /// returns the workspace's MA57 binding.
684    pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
685        self.linear_backend_factory = Some(factory);
686    }
687
688    /// Plug a restoration-phase factory. Called once per
689    /// `optimize_tnlp` invocation to mint a fresh
690    /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
691    /// its line-search restoration fallback. Lives behind a setter
692    /// (rather than at construction) because the concrete restoration
693    /// strategies live in `pounce-restoration`, which depends on this
694    /// crate; consumers in `pounce-cli` / integration tests wire the
695    /// factory at the application boundary.
696    pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
697        self.restoration_factory = Some(factory);
698    }
699
700    /// Install the shared diagnostics state. Once set, every
701    /// subsequent `optimize_tnlp` call forwards the state into the
702    /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
703    /// solver can emit `--dump kkt:...` artifacts.
704    pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
705        self.diagnostics = Some(diag);
706    }
707
708    /// Install an interactive debugger hook for the next `optimize_*`
709    /// call. The hook is moved into the main [`IpoptAlgorithm`] and
710    /// consumed by that solve; reinstall it to debug a subsequent solve.
711    pub fn set_debug_hook(
712        &mut self,
713        hook: std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>,
714    ) {
715        self.debug_hook = Some(hook);
716    }
717
718    /// Read-side accessor for the installed diagnostics state, if any.
719    /// Lets the CLI write the top-level manifest/timing files after
720    /// the solve completes.
721    pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
722        self.diagnostics.as_ref().map(Rc::clone)
723    }
724
725    /// Plug a restoration-phase **factory provider** for drivers that
726    /// need to run the inner IPM more than once per `optimize_tnlp`
727    /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
728    /// pounce#10). On each inner solve, the application consults the
729    /// provider to mint a fresh [`RestorationFactory`], replacing any
730    /// stale one, so the default one-shot restoration factory does
731    /// not panic on its second invocation. If both `set_restoration_factory`
732    /// and this are configured, the provider wins.
733    pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
734        self.restoration_factory_provider = Some(provider);
735    }
736
737    /// Register a callback to run once the IPM has converged (status
738    /// [`ApplicationReturnStatus::SolveSucceeded`] or
739    /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
740    /// `finalize_solution` flows back to the TNLP. See
741    /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
742    pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
743        self.on_converged = Some(cb);
744    }
745
746    /// Was the reported answer replayed from an earlier attempt's floor,
747    /// after a later attempt lost?
748    ///
749    /// **A caller that captures the solution in [`Self::set_on_converged`]
750    /// must consult this.** That callback fires once per *attempt*, and a
751    /// losing retry that converged has already run it, so the capture
752    /// belongs to the discarded point while everything else — status,
753    /// objective, statistics — has been floored back. When this is `true`,
754    /// take `x` and the multipliers from the last `finalize_solution`
755    /// payload instead; that one is always the answer being reported,
756    /// because the floor restores it *by* calling `finalize_solution`.
757    ///
758    /// The payload is in the **model's own units** and in the **reduced**
759    /// presolve space: `CountingTnlp`-style consumers sit inside the gh#486
760    /// scaling wrapper (so `x /= d`, `z *= d` have already been applied) and
761    /// outside the presolve one (so the row/column lift has not). A caller
762    /// swapping it in must therefore skip its own scaling correction and keep
763    /// its own lift; getting that backwards squares the factor, which is a
764    /// silent wrong answer of exactly the shape this flag exists to remove.
765    ///
766    /// **Not consulted, and known not to be**: the three other
767    /// [`Self::set_on_converged`] consumers —
768    /// `pounce-sensitivity/src/{solver,convenience}.rs` and
769    /// `pounce-cli/src/minima/mod.rs` — read the converged KKT state and the
770    /// factorization, which the `finalize_solution` payload does not carry
771    /// and which cannot be rewound. After any floor replay their result
772    /// describes the attempt that lost. Pre-existing, unfixed, and annotated
773    /// at each site.
774    ///
775    /// `false` on every solve that never spent a second attempt, which is
776    /// almost all of them, so the ordinary path is unaffected.
777    pub fn answer_restored_from_floor(&self) -> bool {
778        self.answer_restored_from_floor.get()
779    }
780
781    /// Enable per-iteration trajectory capture. After the solve
782    /// returns, [`Self::statistics()`] exposes
783    /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
784    /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
785    /// per accepted iterate. Off by default — the `pounce_sens` and
786    /// `pounce` binaries opt in when `--json-output` is passed.
787    pub fn enable_iter_history(&mut self) {
788        self.record_iter_history = true;
789    }
790
791    /// Read the run's options file, resolving *which* file the way
792    /// upstream's `IpoptApplication::Initialize` does — with one
793    /// deliberate difference, below.
794    ///
795    /// `explicit` is the file the caller named (upstream: the
796    /// `option_file_name` option, read out of the option store before
797    /// this point). With `None`, the working directory is probed for
798    /// [`DEFAULT_OPTION_FILE_NAMES`] and the first hit is read; an
799    /// absent default file is not an error, it just means "no file".
800    ///
801    /// The difference: upstream opens a named file with a bare
802    /// `std::ifstream` and reads nothing if the open fails, so a typo'd
803    /// `option_file_name` runs at stock defaults without a word. That
804    /// silence is what gh#518 was reported for — a benchmark that
805    /// measured defaults while claiming to measure a configuration — so
806    /// a named file that cannot be read is an error here.
807    pub fn initialize_with_option_file(
808        &mut self,
809        explicit: Option<&Path>,
810    ) -> Result<OptionFileLoad, SolverException> {
811        let mut load = OptionFileLoad::default();
812        // Set before the early returns below: what this flag records is
813        // that `option_file_name` was *consulted*, not that a file turned
814        // up. A caller on this path who names nothing and has no
815        // `pounce.opt` to find still gets the option honored — there was
816        // simply nothing to read.
817        self.option_file_resolved = true;
818        let path = match explicit {
819            Some(p) => {
820                if !p.is_file() {
821                    return Err(SolverException::new(
822                        ExceptionKind::IPOPT_APPLICATION_ERROR,
823                        format!(
824                            "options file \"{}\" does not exist. It was named by \
825                             --options-file / option_file_name, so the run would \
826                             otherwise proceed at stock defaults with none of its \
827                             settings applied.",
828                            p.display()
829                        ),
830                        file!(),
831                        line!() as Index,
832                    ));
833                }
834                load.explicit = true;
835                p.to_path_buf()
836            }
837            None => {
838                let present: Vec<&&str> = DEFAULT_OPTION_FILE_NAMES
839                    .iter()
840                    .filter(|n| Path::new(n).is_file())
841                    .collect();
842                let Some(first) = present.first() else {
843                    return Ok(load);
844                };
845                // Both default names in one directory: say which one lost,
846                // rather than let the unread one look applied.
847                for other in &present[1..] {
848                    load.warnings.push(format!(
849                        "`{first}` and `{other}` are both present; reading `{first}` \
850                         only (pounce's own name wins). Pass \
851                         `option_file_name={other}` to read that one instead."
852                    ));
853                }
854                PathBuf::from(**first)
855            }
856        };
857        self.initialize_with_options_file(&path)?;
858        // `option_file_name` set *inside* an options file chains nowhere —
859        // by the time it is read, the file naming it has already been
860        // chosen. Upstream documents that ("it does not make any sense to
861        // specify this option within the options file") and then ignores
862        // it; name it instead, since an ignored setting that looks live is
863        // the whole complaint behind gh#518.
864        if let Ok((named, true)) = self.options.get_string_value("option_file_name", "")
865            && !named.is_empty()
866            && Path::new(&named) != path
867        {
868            load.warnings.push(format!(
869                "`{}` sets option_file_name to `{named}`, which has no effect: \
870                 the options file is chosen before it is read. Pass \
871                 `option_file_name={named}` on the command line to read that file.",
872                path.display()
873            ));
874        }
875        load.path = Some(path);
876        Ok(load)
877    }
878
879    /// Read an `ipopt.opt`-format options file. Equivalent to
880    /// `IpoptApplication::Initialize(const std::string& options_file)`.
881    pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
882        let txt = std::fs::read_to_string(path).map_err(|e| {
883            SolverException::new(
884                ExceptionKind::IPOPT_APPLICATION_ERROR,
885                format!("could not read options file {}: {}", path.display(), e),
886                file!(),
887                line!() as Index,
888            )
889        })?;
890        self.options.read_from_str(&txt, true)?;
891        self.open_output_file_journal();
892        Ok(())
893    }
894
895    /// Read options from a string in `ipopt.opt` format. Useful for
896    /// tests and embedded callers.
897    pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
898        self.options.read_from_str(s, true)?;
899        self.open_output_file_journal();
900        Ok(())
901    }
902
903    /// Honor `output_file` / `file_print_level` / `file_append`: when
904    /// `output_file` is non-empty, attach a `FileJournal` named
905    /// `"OutputFile:<fname>"` at the requested level. Mirrors
906    /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
907    /// No-op if `output_file` is unset, empty, or could not be opened.
908    ///
909    /// NOTE: pounce's iteration output currently bypasses the
910    /// journalist and writes directly to stdout. The file journal is
911    /// attached and the timing report (gated by `print_timing_statistics`)
912    /// is mirrored to it; per-iter rows will start landing in the file
913    /// once the iter-output path is routed through the journalist.
914    fn open_output_file_journal(&self) {
915        let fname = match self.options.get_string_value("output_file", "") {
916            Ok((v, true)) if !v.is_empty() => v,
917            _ => return,
918        };
919        let level_int = self
920            .options
921            .get_integer_value("file_print_level", "")
922            .ok()
923            .and_then(|(v, f)| f.then_some(v))
924            .unwrap_or(5);
925        let level = journal_level_from_int(level_int);
926        let append = self
927            .options
928            .get_bool_value("file_append", "")
929            .ok()
930            .and_then(|(v, f)| f.then_some(v))
931            .unwrap_or(false);
932        let jname = format!("OutputFile:{}", fname);
933        let _ = self
934            .journalist
935            .add_file_journal(&jname, &fname, level, append);
936    }
937
938    /// No-op initialize (just succeeds). Mirrors
939    /// `IpoptApplication::Initialize(bool allow_clobber)` with no
940    /// options file.
941    pub fn initialize(&mut self) -> Result<(), SolverException> {
942        Ok(())
943    }
944
945    /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
946    /// / `file_print_level` options and attaches a matching
947    /// `FileJournal` named `OutputFile:<fname>` to the journalist.
948    /// Returns `false` if the file could not be opened or the option
949    /// store rejected the request (e.g. clamped print level).
950    pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
951        if self
952            .options
953            .set_string_value("output_file", fname, true, false)
954            .is_err()
955        {
956            return false;
957        }
958        if self
959            .options
960            .set_integer_value("file_print_level", print_level as Index, true, false)
961            .is_err()
962        {
963            return false;
964        }
965        let level = journal_level_from_int(print_level);
966        let jname = format!("OutputFile:{}", fname);
967        // Drop any previous file journal so a second call switches files
968        // cleanly. `add_file_journal` would otherwise refuse to attach
969        // a duplicate by name; remove-by-name isn't in the journalist
970        // API, so we settle for the name-collision case here.
971        self.journalist
972            .add_file_journal(&jname, fname, level, false)
973            .is_some()
974    }
975
976    /// Wrap a TNLP and report problem dimensions. Used in tests until
977    /// the full IPM path covers every entry shape.
978    pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
979        tnlp.get_nlp_info()
980    }
981
982    /// Diagnostics from the safeguarded `least_square_init_primal`
983    /// initializer step of the last solve (gh#605): the nonlinear
984    /// violation before and after, the accepted step norm, how many
985    /// backtracking trials were rejected, and why it stopped. `None`
986    /// when `least_square_init_primal` was off or the model had no
987    /// constraints.
988    pub fn least_square_init_report(&self) -> Option<crate::init::default::LeastSquareInitReport> {
989        self.least_square_init_report.clone()
990    }
991
992    pub fn statistics(&self) -> SolveStatistics {
993        self.statistics.borrow().clone()
994    }
995
996    /// What the warm-start initializer made of the iterate the caller
997    /// supplied to the most recent solve (gh#606): the residuals it
998    /// measured, whether each multiplier block was accepted,
999    /// reconstructed or discarded, and the barrier parameter it
1000    /// settled on.
1001    ///
1002    /// `None` when the last solve was a cold start
1003    /// (`warm_start_init_point=no`), or when no solve has run. Reset at
1004    /// the top of every solve, like [`Self::timing_stats`].
1005    pub fn warm_start_diagnostics(&self) -> Option<crate::init::warm_start::WarmStartDiagnostics> {
1006        self.warm_start_diag.borrow().clone()
1007    }
1008
1009    /// Shared timing accumulator from the most recent `optimize_tnlp`
1010    /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
1011    /// fields during the solve; consumers read totals out of the
1012    /// returned `Rc`. The instance is replaced at the top of every
1013    /// subsequent solve, so cloning the `Rc` and holding it past a
1014    /// re-solve will give you the previous solve's timings — by design.
1015    pub fn timing_stats(&self) -> Rc<TimingStatistics> {
1016        Rc::clone(&self.timing.borrow())
1017    }
1018
1019    /// Aggregate linear-solver post-mortem from the most recent
1020    /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
1021    /// backend ran at least one factor; `None` when no factors were
1022    /// recorded (custom factory plugged via
1023    /// [`Self::set_linear_backend_factory`], or solve aborted before
1024    /// the first KKT factor). Reset at the top of every solve.
1025    pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
1026        let guard = self.linsol_summary_sink.lock().ok()?;
1027        if guard.is_empty() {
1028            None
1029        } else {
1030            Some(guard.clone())
1031        }
1032    }
1033
1034    /// Drive a solve.
1035    ///
1036    /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
1037    ///   build a `TNLPAdapter` → `OrigIpoptNlp`, run the
1038    ///   [`AlgorithmBuilder`] with the workspace MA57 backend, and
1039    ///   call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
1040    ///   `ApplicationReturnStatus` mapping mirrors the table in
1041    ///   `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
1042    ///   SolverReturn map").
1043    /// * Unconstrained problems (`m == 0`) keep going through the
1044    ///   in-`pounce-nlp` Newton driver so the trivial path is
1045    ///   independent of the linear-solver backend.
1046    /// Wrap `tnlp` so per-variable scaling factors are applied as a
1047    /// change of variables, when `nlp_scaling_method=user-scaling` is
1048    /// in effect and the problem supplies non-unit factors (gh#486).
1049    ///
1050    /// Returns the TNLP unchanged under any other scaling method, or
1051    /// when the problem asks for no variable scaling, so an unscaled
1052    /// solve pays nothing. The `Err` carries a message ready to print.
1053    fn install_variable_scaling(
1054        &self,
1055        tnlp: Rc<RefCell<dyn TNLP>>,
1056    ) -> Result<Rc<RefCell<dyn TNLP>>, String> {
1057        // Cleared first so the accessor describes *this* solve. An
1058        // application is reusable across solves (`pounce-cinterface`
1059        // holds one across `IpoptSolve` calls), and a stale vector
1060        // would have a later unscaled solve reporting the previous
1061        // solve's factors.
1062        *self.variable_scaling.borrow_mut() = None;
1063        let method = self
1064            .options
1065            .get_string_value("nlp_scaling_method", "")
1066            .ok()
1067            .and_then(|(v, f)| f.then_some(v))
1068            .unwrap_or_else(|| "gradient-based".to_string());
1069        // `curvature-based` (gh #703) delivers its factors through the
1070        // same `get_scaling_parameters` callback, so the per-variable half
1071        // needs the same substitution wrapper user factors get.
1072        if method != "user-scaling" && method != "curvature-based" {
1073            return Ok(tnlp);
1074        }
1075        match pounce_nlp::scaling_tnlp::wrap_with_scaling(
1076            Rc::clone(&tnlp),
1077            self.nlp_lower_bound_inf(),
1078            self.nlp_upper_bound_inf(),
1079        ) {
1080            Ok(Some(wrapped)) => {
1081                *self.variable_scaling.borrow_mut() =
1082                    pounce_nlp::scaling_tnlp::factors_of(&wrapped);
1083                Ok(wrapped)
1084            }
1085            Ok(None) => Ok(tnlp),
1086            Err(why) => Err(format!(
1087                // The trailing newline belongs to the message: the
1088                // caller emits it with `eprint!` and hands the same
1089                // string to the journalist, as the refusals below do.
1090                "pounce: nlp_scaling_method={method} supplied per-variable \
1091                 scaling factors that cannot be applied. {why}. Correct the \
1092                 factors, or drop nlp_scaling_method={method}.\n"
1093            )),
1094        }
1095    }
1096
1097    /// The per-variable scaling factors applied to the last solve, if
1098    /// any (gh#486). A consumer reading the algorithm's iterate rather
1099    /// than the `finalize_solution` payload sees scaled coordinates and
1100    /// must divide `x` by these, and multiply bound multipliers.
1101    pub fn variable_scaling(&self) -> Option<Vec<Number>> {
1102        self.variable_scaling.borrow().clone()
1103    }
1104
1105    /// Install a starting-point conditioner, if one is asked for.
1106    ///
1107    /// Returns the TNLP unchanged when neither `start_point_perturbation` nor
1108    /// `start_point_conditioner` is set, which is the default — so an ordinary
1109    /// solve pays nothing and its trajectory is untouched.
1110    ///
1111    /// Sits *above* the presolve wrapper and *below* the variable-scaling one,
1112    /// so the point it conditions is the point the algorithm will actually
1113    /// start from, in the coordinates the algorithm will see. Both
1114    /// conditioners override only `get_starting_point`; every other callback
1115    /// forwards, so the solve that follows is the solve pounce would have run
1116    /// had the conditioned point been submitted directly.
1117    ///
1118    /// The two are mutually exclusive by construction rather than by refusal:
1119    /// the displacement is the failure-recovery rung and the Adam warm-up is a
1120    /// user opt-in, and stacking them would put random noise on top of a point
1121    /// the warm-up just spent 200 evaluations choosing. The displacement wins
1122    /// when both are set, because the only thing that sets it is a solve that
1123    /// has already failed.
1124    fn install_start_conditioner(&self, tnlp: Rc<RefCell<dyn TNLP>>) -> Rc<RefCell<dyn TNLP>> {
1125        use pounce_nlp::start_conditioner::{AdamConfig, ConditionedStartTnlp, StartConditioner};
1126        // Each option is read with its literal tag rather than through a
1127        // `|name, fallback|` helper. A helper reads better and costs the
1128        // wiring guard in `tests/init_options_wiring.rs` its evidence: that
1129        // test scans the source for `get_*_value("<tag>"` to prove every
1130        // registered Initialization option is actually consumed, and a tag
1131        // passed as a variable is invisible to it. A registered knob nothing
1132        // reads validates, accepts a value, and lies.
1133        let perturbation = self
1134            .options
1135            .get_numeric_value("start_point_perturbation", "")
1136            .map(|(v, _found)| v)
1137            .unwrap_or(0.0);
1138        let conditioner = if perturbation > 0.0 {
1139            let seed = self
1140                .options
1141                .get_integer_value("start_point_perturbation_seed", "")
1142                .map(|(v, _found)| v)
1143                .unwrap_or(0);
1144            StartConditioner::Jitter {
1145                // `Index` is signed and the option is lower-bounded at 0, so
1146                // this cast cannot lose a set bit for any accepted value.
1147                seed: seed.max(0) as u64,
1148                scale: perturbation,
1149            }
1150        } else {
1151            let which = self
1152                .options
1153                .get_string_value("start_point_conditioner", "")
1154                .map(|(v, _found)| v)
1155                .unwrap_or_else(|_| "none".to_string());
1156            if which != "adam" {
1157                return tnlp;
1158            }
1159            let d = AdamConfig::default();
1160            StartConditioner::Adam(AdamConfig {
1161                iters: self
1162                    .options
1163                    .get_integer_value("adam_warmup_iters", "")
1164                    .map(|(v, _found)| v.max(0) as usize)
1165                    .unwrap_or(d.iters),
1166                lr: self
1167                    .options
1168                    .get_numeric_value("adam_warmup_learning_rate", "")
1169                    .map(|(v, _found)| v)
1170                    .unwrap_or(d.lr),
1171                rho: self
1172                    .options
1173                    .get_numeric_value("adam_warmup_penalty", "")
1174                    .map(|(v, _found)| v)
1175                    .unwrap_or(d.rho),
1176                ..d
1177            })
1178        };
1179        // The sentinels have to come from the options, not from the
1180        // conditioner's own default: a caller who moved `nlp_lower_bound_inf`
1181        // would otherwise have a bound the algorithm treats as absent clipped
1182        // against as if it were real.
1183        //
1184        // Passed through unclamped. These were once `lower.min(-DEFAULT)` /
1185        // `upper.max(DEFAULT)`, which honours only a *loosened* sentinel and
1186        // leaves the failure above intact for a tightened one: at
1187        // `nlp_lower_bound_inf=-1e10` the conditioner still used `-1e19`, so a
1188        // `-1e15` bound was absent to the algorithm and present to the
1189        // clipper — the exact case the comment exists to rule out. The
1190        // sentinel means "absent"; there is only one right answer for what it
1191        // is, and it is the caller's.
1192        let lower = self.nlp_lower_bound_inf();
1193        let upper = self.nlp_upper_bound_inf();
1194        let wrapped = ConditionedStartTnlp::new(tnlp, conditioner).with_bound_inf(lower, upper);
1195        Rc::new(RefCell::new(wrapped))
1196    }
1197
1198    pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1199        self.optimize_tnlp_with_derivative_test_tnlp(tnlp, None)
1200    }
1201
1202    /// Solve through `tnlp`, optionally overriding the derivative-test target.
1203    /// `None` tests the scaled and conditioned TNLP.
1204    pub fn optimize_tnlp_with_derivative_test_tnlp(
1205        &mut self,
1206        tnlp: Rc<RefCell<dyn TNLP>>,
1207        derivative_test_tnlp: Option<Rc<RefCell<dyn TNLP>>>,
1208    ) -> ApplicationReturnStatus {
1209        // gh#884. Both belong to this solve, not to whatever ran before
1210        // it. Reset *here* rather than in `optimize_constrained`, which
1211        // runs once per attempt: the signature accumulates across the
1212        // attempts a lower wrapper may spend, so
1213        // `run_with_dual_divergence_retry` reads "some attempt of the base
1214        // solve saw it" rather than "the last one did".
1215        self.dual_divergence_signature.set(false);
1216        self.dual_divergence_retry_promoted.set(false);
1217        self.answer_restored_from_floor.set(false);
1218        // gh#486 stage 2: per-variable `scaling_factor` is applied by
1219        // substituting variables one level below the algorithm, since
1220        // the core's scaling models the objective and the constraint
1221        // rows only. The wrapper consumes the variable factors and
1222        // forwards the rest, so `OrigIpoptNlp` sees exactly what it
1223        // has always handled. Installed here because every entry point
1224        // funnels through this method, and only under `user-scaling`,
1225        // the one method that consults the TNLP for factors at all.
1226        let tnlp = match self.install_variable_scaling(tnlp) {
1227            Ok(t) => t,
1228            Err(msg) => {
1229                use pounce_common::journalist::JournalCategory;
1230                eprint!("{msg}");
1231                self.journalist
1232                    .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
1233                return ApplicationReturnStatus::InvalidOption;
1234            }
1235        };
1236
1237        // Starting-point conditioning (`start_point_perturbation`,
1238        // `start_point_conditioner`). A no-op unless one is set, and set by
1239        // nothing automatic except the local-infeasibility ladder's third
1240        // rung, which only runs after a solve has already failed.
1241        let tnlp = self.install_start_conditioner(tnlp);
1242        let derivative_test_tnlp = derivative_test_tnlp.as_ref().unwrap_or(&tnlp);
1243
1244        if let Some(value) = self.unsupported_library_solver_selection() {
1245            use pounce_common::journalist::JournalCategory;
1246            self.journalist.print(
1247                JournalLevel::J_ERROR,
1248                JournalCategory::J_MAIN,
1249                &format!(
1250                    "pounce: solver_selection={value} routing is only available \
1251                     through the pounce CLI (.nl input); library consumers can use \
1252                     qp-active-set, nlp, or auto.\n"
1253                ),
1254            );
1255            return ApplicationReturnStatus::InvalidOption;
1256        }
1257
1258        // A `linear_solver` pounce does not implement is refused rather
1259        // than quietly served by FERAL (gh#483 follow-up). Checked here,
1260        // before any work, so a library consumer gets the same verdict the
1261        // CLI gives before its banner.
1262        if let Some(value) = self.unimplemented_linear_solver() {
1263            use pounce_common::journalist::JournalCategory;
1264            let msg = format!("{}\n", Self::unimplemented_linear_solver_message(&value));
1265            eprint!("{msg}");
1266            self.journalist
1267                .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
1268            return ApplicationReturnStatus::InvalidOption;
1269        }
1270
1271        // gh#483 follow-up: an option naming a feature pounce does not
1272        // implement is refused, not shrugged off. See
1273        // `unimplemented_options` for how membership was established and
1274        // why an explicitly-set *default* is deliberately still allowed.
1275        // gh#518: same treatment for `option_file_name` on an entry point
1276        // that cannot resolve it. Separate from the table above because
1277        // the *feature* now exists — just not here.
1278        // gh#604: same treatment one level down, for a registered *value*
1279        // of an option pounce otherwise reads (`bound_mult_init_method=
1280        // mu-based`).
1281        // gh#604: and for a convex-engine knob on an entry point that
1282        // cannot route to that engine — `option_file_name`'s case, one
1283        // feature over.
1284        if let Some(msg) = self
1285            .unimplemented_option_refusal()
1286            .or_else(|| self.unimplemented_option_value_refusal())
1287            .or_else(|| self.unhonored_option_file_name())
1288            .or_else(|| self.unhonored_convex_option())
1289        {
1290            use pounce_common::journalist::JournalCategory;
1291            eprintln!("{msg}");
1292            self.journalist.print(
1293                JournalLevel::J_ERROR,
1294                JournalCategory::J_MAIN,
1295                &format!("{msg}\n"),
1296            );
1297            return ApplicationReturnStatus::InvalidOption;
1298        }
1299        // A `ma57_pivtolmax` the user set *below* `ma57_pivtol` is a
1300        // contradiction — the escalation ceiling under its floor — and
1301        // upstream refuses it outright
1302        // (`IpMa57TSolverInterface.cpp:313`, `OPTION_INVALID`). pounce
1303        // used to silently rewrite it to `ma57_pivtol`. That was
1304        // unreachable while gh#825 was live, since no `ma57_*` value
1305        // reached the backend at all, and became reachable the moment
1306        // that was fixed — so it is refused here rather than shipped as
1307        // a new way to be quietly ignored.
1308        if let Some(msg) = self.ma57_pivtol_bracket_refusal() {
1309            use pounce_common::journalist::JournalCategory;
1310            eprintln!("{msg}");
1311            self.journalist.print(
1312                JournalLevel::J_ERROR,
1313                JournalCategory::J_MAIN,
1314                &format!("{msg}\n"),
1315            );
1316            return ApplicationReturnStatus::InvalidOption;
1317        }
1318
1319        let backend_warnings = self.take_unimplemented_backend_warnings();
1320        for warning in self
1321            .unexploited_hint_warnings()
1322            .into_iter()
1323            .chain(backend_warnings)
1324        {
1325            eprintln!("{warning}");
1326        }
1327
1328        // Test before presolve, using the requested coordinate space.
1329        self.run_derivative_test(derivative_test_tnlp);
1330
1331        // Top-level algorithm dispatch (Phase 5b §7.1). When the
1332        // `algorithm` option resolves to "active-set-sqp", route
1333        // to the Phase 5b SQP path; otherwise fall through to the
1334        // existing IPM flow unchanged.
1335        // Materialize generic TNLP presolve once at the public entry point.
1336        // The wrapper owns the submitted callback TNLP, so every algorithm
1337        // path below (including retry paths) continues to postsolve into
1338        // the original user-facing space. With `presolve=no`, this returns
1339        // the exact same Rc unchanged.
1340        let tnlp = if self.presolve_already_applied {
1341            tnlp
1342        } else {
1343            match pounce_presolve::wrap_from_options(tnlp, &self.options) {
1344                Ok(tnlp) => tnlp,
1345                Err(err) => {
1346                    use pounce_common::journalist::JournalCategory;
1347                    self.journalist.print(
1348                        JournalLevel::J_ERROR,
1349                        JournalCategory::J_MAIN,
1350                        &format!("pounce: could not materialize presolve options: {err}\n"),
1351                    );
1352                    return ApplicationReturnStatus::InvalidOption;
1353                }
1354            }
1355        };
1356
1357        if self.is_sqp_algorithm_selected() {
1358            return self.optimize_sqp_tnlp(tnlp);
1359        }
1360        let info = match tnlp.borrow_mut().get_nlp_info() {
1361            Some(info) => info,
1362            None => return ApplicationReturnStatus::InvalidProblemDefinition,
1363        };
1364
1365        // Presolve-certified infeasibility. `get_nlp_info` above is what forces
1366        // the (lazy) presolve init, so this is the first point at which the
1367        // proof exists. If bound propagation or FBBT established that the
1368        // feasible region is empty, there is nothing left to compute: return
1369        // the verdict directly.
1370        //
1371        // Short-circuiting *here*, before dispatch, is deliberate. Running the
1372        // solve anyway would only re-derive a strictly weaker result — a
1373        // stationary point of the constraint violation, which for a nonconvex
1374        // problem proves nothing globally — and would also hand an
1375        // `InfeasibleProblemDetected` to the ℓ₁ auto-fallback below
1376        // (`is_l1_fallback_trigger`), which would then burn a whole second
1377        // solve retrying a problem already proved to have no solution.
1378        //
1379        // Soundness rests on `presolve_infeasibility_proof` returning `Some`
1380        // only for a contradiction derived on an *un-clamped* box — a
1381        // detection made while a Phase-0 auxiliary elimination is in force can
1382        // be an artifact of that elimination and is re-checked after rollback
1383        // before it is certified. See `PresolveState::certified_infeasible`.
1384        if let Some(proof) = tnlp.borrow().presolve_infeasibility_proof() {
1385            use pounce_common::journalist::JournalCategory;
1386            let detail = match proof {
1387                pounce_nlp::tnlp::InfeasibilityProof::BoundPropagation => {
1388                    "bound propagation crossed a variable's bounds".to_string()
1389                }
1390                pounce_nlp::tnlp::InfeasibilityProof::IntervalArithmetic { witness } => {
1391                    format!("interval arithmetic emptied constraint {witness}'s range")
1392                }
1393            };
1394            self.journalist.print(
1395                JournalLevel::J_SUMMARY,
1396                JournalCategory::J_MAIN,
1397                &format!(
1398                    "\nEXIT: Presolve detected the feasible region is empty ({detail}).\n\
1399                     No feasible point exists; the solve was not run.\n"
1400                ),
1401            );
1402            return ApplicationReturnStatus::InfeasibleProblemDetected;
1403        }
1404        // ℓ₁-exact penalty-barrier opt-in (pounce#10).
1405        // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
1406        // Waltz ρ-escalation loop around the constrained IPM, with a
1407        // honest-infeasibility status upgrade when the slacks fail to
1408        // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
1409        // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
1410        // no-op for problems with no equality rows, so the
1411        // unconstrained dispatch below is unaffected when there is
1412        // nothing to wrap.
1413        if info.m > 0 && self.is_l1_penalty_enabled() {
1414            if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
1415                return status;
1416            }
1417            // Falls through: wrapper construction failed (inner refused
1418            // get_nlp_info / get_bounds_info) or no equality rows to
1419            // slack. Standard dispatch runs unmodified.
1420        }
1421        // Phase 3.5 auto-fallback (pounce#10): if the standard solve
1422        // ends in a trigger-class status, retry transparently with
1423        // the wrapper. Promote the retry's status only if it returns
1424        // SolveSucceeded — otherwise return the original. Skipped if
1425        // the user already opted into the wrapper above (this avoids
1426        // a double pass and keeps semantics predictable).
1427        if info.m > 0 && self.is_l1_fallback_enabled() && !self.is_l1_penalty_enabled() {
1428            return self.run_with_l1_fallback(tnlp);
1429        }
1430        // Biactive dual-divergence retry (gh#884): if the solve settles
1431        // its primal while its multipliers run away, throw the iterate
1432        // away and solve again from scratch with the constraint-Jacobian
1433        // perturbation on. Outermost of the two retry wrappers, so its
1434        // "base solve" is the whole standard dispatch below including the
1435        // μ flip — the signature is about a *solve*, and spending the μ
1436        // flip first is strictly cheaper than spending this one first.
1437        if self.is_dual_divergence_retry_enabled() {
1438            return self.run_with_dual_divergence_retry(tnlp);
1439        }
1440        self.dispatch_standard_solve(tnlp)
1441    }
1442
1443    /// The standard solve dispatch: the μ-strategy fallback if it is
1444    /// enabled, otherwise one `optimize_constrained` call.
1445    ///
1446    /// Factored out of `optimize_tnlp_with_derivative_test_tnlp` so
1447    /// [`Self::run_with_dual_divergence_retry`] can wrap the whole of it
1448    /// rather than only the bare IPM call (gh#884).
1449    fn dispatch_standard_solve(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1450        // μ-strategy auto-fallback (pounce#138): if the standard solve
1451        // stalls, retry once with the opposite mu_strategy and promote
1452        // only on Solve_Succeeded. Which stalls qualify depends on
1453        // whether the caller asked for the retry — see
1454        // `run_with_mu_strategy_fallback` (pounce#748).
1455        // Applies to constrained and unconstrained alike (both run the
1456        // same IPM). Independent of, and lower priority than, the ℓ₁
1457        // fallback above.
1458        if self.is_mu_strategy_fallback_enabled() {
1459            return self.run_with_mu_strategy_fallback(tnlp);
1460        }
1461        // Every problem — constrained or not — goes through the same
1462        // primal-dual IPM, exactly as upstream Ipopt does. There is no
1463        // separate "unconstrained Newton" path: the linear-solver
1464        // backend (FERAL/MA57) handles the augmented system, so the
1465        // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
1466        // blowup.
1467        self.optimize_constrained(tnlp)
1468    }
1469
1470    /// Read the ℓ₁ wrapper master switch from the OptionsList.
1471    /// Default `false` when the option is not set.
1472    fn is_l1_penalty_enabled(&self) -> bool {
1473        self.options
1474            .get_bool_value("l1_exact_penalty_barrier", "")
1475            .ok()
1476            .and_then(|(v, found)| found.then_some(v))
1477            .unwrap_or(false)
1478    }
1479
1480    fn l1_penalty_init(&self) -> Number {
1481        self.options
1482            .get_numeric_value("l1_penalty_init", "")
1483            .ok()
1484            .and_then(|(v, found)| found.then_some(v))
1485            .unwrap_or(1.0)
1486    }
1487    fn l1_penalty_max(&self) -> Number {
1488        self.options
1489            .get_numeric_value("l1_penalty_max", "")
1490            .ok()
1491            .and_then(|(v, found)| found.then_some(v))
1492            .unwrap_or(1.0e6)
1493    }
1494    fn l1_penalty_increase_factor(&self) -> Number {
1495        self.options
1496            .get_numeric_value("l1_penalty_increase_factor", "")
1497            .ok()
1498            .and_then(|(v, found)| found.then_some(v))
1499            .unwrap_or(8.0)
1500    }
1501    fn l1_penalty_max_outer_iter(&self) -> usize {
1502        self.options
1503            .get_integer_value("l1_penalty_max_outer_iter", "")
1504            .ok()
1505            .and_then(|(v, found)| found.then_some(v))
1506            .unwrap_or(8) as usize
1507    }
1508    fn l1_slack_tol(&self) -> Number {
1509        self.options
1510            .get_numeric_value("l1_slack_tol", "")
1511            .ok()
1512            .and_then(|(v, found)| found.then_some(v))
1513            .unwrap_or(1.0e-6)
1514    }
1515    fn l1_steering_factor(&self) -> Number {
1516        self.options
1517            .get_numeric_value("l1_steering_factor", "")
1518            .ok()
1519            .and_then(|(v, found)| found.then_some(v))
1520            .unwrap_or(10.0)
1521    }
1522    fn is_l1_fallback_enabled(&self) -> bool {
1523        self.options
1524            .get_bool_value("l1_fallback_on_restoration_failure", "")
1525            .ok()
1526            .and_then(|(v, found)| found.then_some(v))
1527            .unwrap_or(false)
1528    }
1529
1530    /// Did the caller set `mu_strategy` explicitly?
1531    ///
1532    /// The answer decides whether the limited-memory default applies
1533    /// (see `algorithm_builder_from_options`): upstream only substitutes
1534    /// `adaptive` for the registered `monotone` when the option is
1535    /// absent from the list.
1536    fn mu_strategy_was_set(&self) -> bool {
1537        matches!(
1538            self.options.get_string_value("mu_strategy", ""),
1539            Ok((_, true))
1540        )
1541    }
1542
1543    /// Did the caller set `mu_strategy_fallback` themselves? Separates
1544    /// the opted-in retry from the default-on one, which triggers on a
1545    /// narrower set of statuses (pounce#748).
1546    ///
1547    /// True for an explicit `no` as well as an explicit `yes`, which is
1548    /// harmless: this is only ever read downstream of
1549    /// [`Self::is_mu_strategy_fallback_enabled`], and an explicit `no`
1550    /// stops there.
1551    fn mu_strategy_fallback_was_set(&self) -> bool {
1552        matches!(
1553            self.options.get_bool_value("mu_strategy_fallback", ""),
1554            Ok((_, true))
1555        )
1556    }
1557
1558    /// Options whose presence means a `Solved_To_Acceptable_Level`
1559    /// exit may be something the *caller* asked for rather than a stall
1560    /// POUNCE fell into (gh #757).
1561    ///
1562    /// Every one of them either moves the bar a certificate has to clear
1563    /// (`tol` and the component tolerances, the `acceptable_*` family),
1564    /// arms a guard that refuses a certificate the iterate would
1565    /// otherwise have earned (`kkt_fidelity_tol`, the certificate-mask
1566    /// and noise-floor kappas, the divergence / infeasibility streaks,
1567    /// the restoration-decline pair).
1568    ///
1569    /// Options that only move the *starting point* are deliberately not
1570    /// here. `least_square_init_primal` was tried and removed: a caller
1571    /// who picks an initialization heuristic has said nothing about what
1572    /// convergence means, and listing it made an explicit `=no` behave
1573    /// differently from omitting the option, which is a distinction the
1574    /// rest of the solver does not draw.
1575    const TERMINATION_POLICY_OPTIONS: &'static [&'static str] = &[
1576        "tol",
1577        "dual_inf_tol",
1578        "constr_viol_tol",
1579        "compl_inf_tol",
1580        "acceptable_tol",
1581        "acceptable_iter",
1582        "acceptable_dual_inf_tol",
1583        "acceptable_constr_viol_tol",
1584        "acceptable_compl_inf_tol",
1585        "acceptable_obj_change_tol",
1586        "kkt_fidelity_tol",
1587        "obj_scale_certificate_threshold",
1588        "dual_inf_scale_kappa",
1589        "primal_noise_floor_kappa",
1590        "dual_diverging_streak",
1591        "infeas_max_streak",
1592        "resto_decline_deferrals",
1593        "resto_decline_progress_ratio",
1594        "neg_curv_escapes",
1595        "limited_memory_ls_failure_restarts",
1596    ];
1597
1598    /// Did the caller set any option from
1599    /// [`Self::TERMINATION_POLICY_OPTIONS`]?
1600    ///
1601    /// This is what separates the two readings of a
1602    /// `Solved_To_Acceptable_Level` exit. Under stock convergence
1603    /// settings it means POUNCE's own schedule parked the dual term
1604    /// above `tol` and a flipped schedule is worth one try. Under a
1605    /// caller-modified one it may be the signal the caller armed the
1606    /// option to receive, and erasing it with a retry is exactly the
1607    /// laundering pounce#748 refused to do by default.
1608    fn caller_set_termination_policy(&self) -> bool {
1609        Self::TERMINATION_POLICY_OPTIONS.iter().any(|name| {
1610            matches!(self.options.get_numeric_value(name, ""), Ok((_, true)))
1611                || matches!(self.options.get_integer_value(name, ""), Ok((_, true)))
1612                || matches!(self.options.get_bool_value(name, ""), Ok((_, true)))
1613                || matches!(self.options.get_string_value(name, ""), Ok((_, true)))
1614        })
1615    }
1616
1617    /// The μ strategy this option table actually resolves to, as
1618    /// `algorithm_builder_from_options` will build it: the explicit
1619    /// value when there is one, otherwise `adaptive` for a
1620    /// limited-memory Hessian and `monotone` for anything else.
1621    ///
1622    /// The fallback below flips *this*, not the registered default —
1623    /// flipping the registered default under limited-memory would
1624    /// "retry" with the strategy that just failed.
1625    fn effective_mu_strategy_is_adaptive(&self) -> bool {
1626        if let Ok((v, true)) = self.options.get_string_value("mu_strategy", "") {
1627            return v == "adaptive";
1628        }
1629        matches!(
1630            self.options.get_string_value("hessian_approximation", ""),
1631            Ok((ref v, true)) if v == "limited-memory"
1632        )
1633    }
1634
1635    /// Read the μ-strategy auto-fallback switch (pounce#138).
1636    ///
1637    /// An explicit setting always wins. Absent one the default is **on**
1638    /// (pounce#748) — but only while the user has not chosen a
1639    /// `mu_strategy` themselves. Retrying under the other schedule is a
1640    /// recovery for a solve that stalled on a strategy POUNCE picked; it
1641    /// is not licence to override a strategy the caller named. Without
1642    /// that condition, flipping the default would silently contaminate
1643    /// every controlled comparison that pins `mu_strategy` on purpose,
1644    /// this repository's own benchmark arms included. The motivating
1645    /// case is unaffected: `dirichlet120` stalls under the
1646    /// limited-memory substitution (pounce#746), which by definition
1647    /// only happens when `mu_strategy` is unset.
1648    fn is_mu_strategy_fallback_enabled(&self) -> bool {
1649        match self.options.get_bool_value("mu_strategy_fallback", "") {
1650            Ok((v, true)) => v,
1651            _ => !self.mu_strategy_was_set(),
1652        }
1653    }
1654
1655    /// Has the user set `algorithm = active-set-sqp`? Reads the
1656    /// string option and matches case-insensitively against the
1657    /// design-note §7.1 spelling. Any value other than
1658    /// "active-set-sqp" (including absence) routes to the
1659    /// default IPM path.
1660    /// Stash a warm-start iterate for the SQP path. Consumed by
1661    /// the next `optimize_tnlp` call when the `algorithm` option
1662    /// resolves to `active-set-sqp`; the IPM path ignores it.
1663    /// Phase 5c (§6) — the parametric / MPC warm-start hand-off.
1664    ///
1665    /// The iterate is auto-cleared after use, so a follow-up
1666    /// solve without an intervening `set_sqp_warm_start` call
1667    /// cold-starts.
1668    pub fn set_sqp_warm_start(&mut self, warm: crate::sqp::SqpIterates) {
1669        self.sqp_warm_start = Some(warm);
1670    }
1671
1672    /// Drop any pending warm-start iterate without solving.
1673    pub fn clear_sqp_warm_start(&mut self) {
1674        self.sqp_warm_start = None;
1675    }
1676
1677    /// What the crossover phase did on the most recent solve (gh#612).
1678    ///
1679    /// `None` means crossover never ran — either `crossover=no` (the
1680    /// default) or the solve did not converge. A `Some` whose
1681    /// [`CrossoverReport::accepted`] is false means it ran and *declined*;
1682    /// the two are different facts about a solve and consumers that reason
1683    /// about active-set certainty (sensitivity's AMBIGUOUS class, a
1684    /// downstream `var_status`) need to tell them apart.
1685    ///
1686    /// [`CrossoverReport::accepted`]: crate::crossover::CrossoverReport::accepted
1687    pub fn crossover_report(&self) -> Option<&crate::crossover::CrossoverReport> {
1688        self.crossover_report.as_ref()
1689    }
1690
1691    /// Install a full primal-dual warm-start iterate for the next IPM
1692    /// `optimize_tnlp`. Captured by the debugger's `resolve` so the
1693    /// re-solve continues from the paused interior point. The caller is
1694    /// responsible for also enabling `warm_start_init_point=yes` (and
1695    /// usually `warm_start_target_mu=<μ>`) so the re-optimize branch of
1696    /// `WarmStartIterateInitializer` preserves the installed iterate.
1697    /// Consumed once per solve, then auto-cleared.
1698    pub fn set_warm_start_iterate(&mut self, snap: crate::debug::IterateSnapshot) {
1699        self.warm_start_iterate = Some(snap);
1700    }
1701
1702    /// Install a caller-supplied fill-reducing permutation for the KKT
1703    /// linear solver (pounce#180 item 1). The next `optimize_*` builds
1704    /// the FERAL backend with [`pounce_feral::OrderingMethod::External`],
1705    /// overriding the `feral_ordering` string option / env var. Use this
1706    /// to inject a block-triangular / Schur ordering a generic algorithm
1707    /// cannot see (Parker, Garcia & Bent, arXiv:2602.17968) or a tearing
1708    /// ordering from equation-oriented decomposition.
1709    ///
1710    /// `perm` is a **0-based, new-to-old permutation** (`perm[k]` is the
1711    /// original index that becomes index `k`), and its length must equal
1712    /// the augmented KKT system dimension (variables + slacks +
1713    /// constraint duals), *not* the problem's `n`. A wrong length or a
1714    /// non-bijection is rejected by FERAL at the first factorization with
1715    /// an `InvalidInput` error (never a panic), surfacing as a solver
1716    /// failure rather than a silently-wrong solve — the ordering only
1717    /// affects fill/time, never the computed solution.
1718    ///
1719    /// Persistent config: unlike the warm-start hooks it is *not*
1720    /// auto-cleared after a solve. Call [`Self::clear_external_ordering`]
1721    /// to drop it. Ignored by non-FERAL backends and by any custom
1722    /// factory plugged via [`Self::set_linear_backend_factory`].
1723    pub fn set_external_ordering(&mut self, perm: Vec<usize>) {
1724        self.external_ordering = Some(perm);
1725    }
1726
1727    /// Drop any installed external KKT ordering, restoring the
1728    /// `feral_ordering`-driven default for subsequent solves.
1729    pub fn clear_external_ordering(&mut self) {
1730        self.external_ordering = None;
1731    }
1732
1733    /// The currently-installed external KKT ordering, if any.
1734    pub fn external_ordering(&self) -> Option<&[usize]> {
1735        self.external_ordering.as_deref()
1736    }
1737
1738    /// Install a block-triangular / Schur KKT partition (pounce#180 item 2).
1739    /// `indices` are KKT-space indices (`0..dim` in the `x, s, c, d` block
1740    /// order the aug-system solver assembles) naming the Schur block `S`; that
1741    /// block is Schur-complemented out and only the two diagonal blocks are
1742    /// factorized (inertia via Sylvester's law). Honored on the IPM + feral +
1743    /// exact-Hessian path; the Schur solver falls back to the standard
1744    /// full-space solver transparently when the partition is unsuitable (too
1745    /// large a fraction of the system, malformed, or a backend error), so a
1746    /// stray hook never breaks a solve. Persistent config (not auto-cleared);
1747    /// drop it via [`Self::clear_kkt_schur_block`].
1748    pub fn set_kkt_schur_block(&mut self, indices: Vec<usize>) {
1749        self.kkt_schur_block = Some(indices);
1750    }
1751
1752    /// Drop any installed Schur KKT partition, restoring the standard
1753    /// full-space solver for subsequent solves.
1754    pub fn clear_kkt_schur_block(&mut self) {
1755        self.kkt_schur_block = None;
1756    }
1757
1758    /// The currently-installed Schur KKT partition, if any.
1759    pub fn kkt_schur_block(&self) -> Option<&[usize]> {
1760        self.kkt_schur_block.as_deref()
1761    }
1762
1763    /// Return the final QP working set from the most recent SQP
1764    /// solve, or `None` if the last solve wasn't SQP, didn't
1765    /// produce a working set (cold-start declared the iterate
1766    /// optimal before solving any QP), or no SQP solve has run.
1767    pub fn last_sqp_working_set(&self) -> Option<&pounce_qp::WorkingSet> {
1768        self.sqp_last_working_set.as_ref()
1769    }
1770
1771    /// If `solver_selection` is explicitly set to a value whose routing lives
1772    /// only in the CLI's `.nl` dispatch, return  it; otherwise `None`.
1773    /// `optimize_tnlp` uses this to reject a forced convex selection a library
1774    /// consumer cannot honor.
1775    fn unsupported_library_solver_selection(&self) -> Option<&'static str> {
1776        let (v, found) = self.options.get_string_value("solver_selection", "").ok()?;
1777        if !found {
1778            return None;
1779        }
1780        ["lp-ipm", "qp-ipm", "socp"]
1781            .into_iter()
1782            .find(|c| v.eq_ignore_ascii_case(c))
1783    }
1784
1785    /// The `linear_solver` value when the caller explicitly asked for a
1786    /// backend pounce does not implement; `None` when the request can be
1787    /// served (or was never made).
1788    ///
1789    /// pounce ships two: **FERAL** (pure Rust, the effective default) and
1790    /// **MA57** (HSL, behind the `ma57` feature). The option's valid-value
1791    /// list is a faithful port of upstream Ipopt's — `ma27`, `ma77`,
1792    /// `ma86`, `ma97`, `mumps`, `pardiso`, `pardisomkl`, `spral`, `wsmp`,
1793    /// `custom` — so an `ipopt.opt` written for Ipopt parses here, and
1794    /// every one of those names used to fall through a `_ =>` arm to
1795    /// FERAL. A run "using MUMPS" was a FERAL run; a benchmark comparing
1796    /// backends compared FERAL with itself (gh#483 follow-up).
1797    ///
1798    /// The registered default is `feral`, which pounce implements, so no
1799    /// explicit-vs-default distinction is needed: whatever the option
1800    /// resolves to must be a backend that exists. (It is checked
1801    /// unconditionally on purpose — a future default naming something
1802    /// unimplemented should trip this, not slip past it.)
1803    ///
1804    /// Explicit `ma57` on a build that lacks the feature is *not* refused;
1805    /// that fallback is reported in the banner ("ma57 requested but not
1806    /// compiled"), so it is visible rather than silent, and failing a
1807    /// portable `ipopt.opt` over a build flag would cost more than it buys.
1808    pub fn unimplemented_linear_solver(&self) -> Option<String> {
1809        let (v, _) = self.options.get_string_value("linear_solver", "").ok()?;
1810        ["feral", "ma57"]
1811            .iter()
1812            .all(|ok| !v.eq_ignore_ascii_case(ok))
1813            .then_some(v)
1814    }
1815
1816    /// The message for the first option the caller set that names a
1817    /// feature pounce does not implement, or `None`. Public so the CLI
1818    /// can refuse before routing — the convex dispatch never reaches
1819    /// `optimize_tnlp`. See [`crate::unimplemented_options`].
1820    ///
1821    /// A run configuring nothing but backends pounce does not ship is
1822    /// refused here too, after the per-option table has had its say —
1823    /// see [`crate::unimplemented_options::backend_only_refusal`]. It
1824    /// is folded in rather than given its own accessor so that every
1825    /// surface already refusing on this method refuses on it as well;
1826    /// the CLI is not the only frontend, and a condition worth failing
1827    /// on is not worth failing on only from the CLI.
1828    pub fn unimplemented_option_refusal(&self) -> Option<String> {
1829        crate::unimplemented_options::refusal(&self.options, &self.reg_options).or_else(|| {
1830            crate::unimplemented_options::backend_only_refusal(&self.options, &self.reg_options)
1831        })
1832    }
1833
1834    /// The message for the first string option the caller set to a
1835    /// registered *value* pounce does not implement, or `None`.
1836    ///
1837    /// Separate from [`Self::unimplemented_option_refusal`] because the
1838    /// option itself is read and its other values work — it is one mode
1839    /// that is missing, not the feature. See
1840    /// [`crate::unimplemented_options::UNIMPLEMENTED_VALUES`].
1841    pub fn unimplemented_option_value_refusal(&self) -> Option<String> {
1842        crate::unimplemented_options::value_refusal(&self.options)
1843    }
1844
1845    /// `option_file_name` set on a surface that never resolves it.
1846    ///
1847    /// The option reaches a file through exactly one path —
1848    /// [`Self::initialize_with_option_file`], which the `pounce` CLI
1849    /// drives. A library caller (Python, the C interface, WASM) sets its
1850    /// options directly and calls no such thing, so on those surfaces the
1851    /// option names a whole configuration and applies none of it: gh#518's
1852    /// failure mode, one surface over. It used to be caught by the blanket
1853    /// [`crate::unimplemented_options`] refusal, which no longer covers it
1854    /// now that the feature exists; this keeps the guard exactly where the
1855    /// feature still doesn't.
1856    ///
1857    /// Deliberately *not* fixed by having the library read an options file
1858    /// too: an implicit `./ipopt.opt` lookup under Python or the GAMS C
1859    /// link would be a surprising action at a distance, and `pounce.opt`
1860    /// already means something else to GAMS.
1861    pub fn unhonored_option_file_name(&self) -> Option<String> {
1862        if self.option_file_resolved {
1863            return None;
1864        }
1865        // Same default gate as the table: an explicitly-set *default*
1866        // asks for nothing, so it must not fail. `option_file_name`
1867        // defaults to `ipopt.opt`, and a caller round-tripping a full
1868        // option dump — or a generated config that spells out every
1869        // registered name — hits that value without asking for anything.
1870        if !crate::unimplemented_options::set_to_a_non_default(
1871            &self.options,
1872            &self.reg_options,
1873            "option_file_name",
1874        ) {
1875            return None;
1876        }
1877        match self.options.get_string_value("option_file_name", "") {
1878            Ok((name, true)) if !name.is_empty() => Some(format!(
1879                "pounce: `option_file_name` was set to `{name}`, but this entry \
1880                 point does not read options files — it would configure nothing. \
1881                 The `pounce` CLI honors it (and `./pounce.opt` / `./ipopt.opt`); \
1882                 from a library, read the file yourself and pass its contents to \
1883                 `initialize_with_options_str`, or set the options directly. \
1884                 Tracking issue: https://github.com/jkitchin/pounce/issues/518"
1885            )),
1886            _ => None,
1887        }
1888    }
1889
1890    /// Declare that this caller can route a model to the convex LP/QP /
1891    /// SOCP engines, so the `qp_*` knobs they read configure something.
1892    ///
1893    /// The `pounce` CLI calls this: it owns the `.nl` structure
1894    /// extraction that classifies a model, which is the whole of what
1895    /// `solver_selection`'s convex values need. No library frontend can
1896    /// (see [`Self::unsupported_library_solver_selection`]), so the
1897    /// default is `false` and [`Self::unhonored_convex_option`] refuses
1898    /// the knobs there.
1899    ///
1900    /// Declaring it also covers the CLI's *fallback*: a convex attempt
1901    /// that returns no verified point hands the model to
1902    /// [`Self::optimize_tnlp`], and the `qp_*` values it was given
1903    /// configured that attempt for real. Refusing them at the handoff
1904    /// would fail a run that used them.
1905    pub fn set_convex_routing_available(&mut self, available: bool) {
1906        self.convex_routing_available = available;
1907    }
1908
1909    /// The convex LP/QP knobs are registered core-side so every frontend
1910    /// parses them — but only the CLI can reach the engines that read
1911    /// them. On any other entry point the option names a whole
1912    /// configuration and applies none of it; this is the message that
1913    /// says so, in place of the silence.
1914    ///
1915    /// gh#604. Same shape and same default gate as
1916    /// [`Self::unhonored_option_file_name`]: an explicitly-set *default*
1917    /// asks for nothing and must keep working, so only a value that
1918    /// differs is refused.
1919    pub fn unhonored_convex_option(&self) -> Option<String> {
1920        if self.convex_routing_available {
1921            return None;
1922        }
1923        const CONVEX_ONLY: &[&str] = &[
1924            "qp_presolve",
1925            "qp_tau",
1926            "qp_tau_max",
1927            "qp_reg",
1928            "qp_gondzio_corr",
1929            "qp_infeas_tol",
1930            "qp_hsde",
1931            "qp_equilibrate",
1932            "qp_crossover",
1933        ];
1934        let name = CONVEX_ONLY.iter().find(|name| {
1935            crate::unimplemented_options::set_to_a_non_default(
1936                &self.options,
1937                &self.reg_options,
1938                name,
1939            )
1940        })?;
1941        Some(format!(
1942            "pounce: `{name}` tunes the convex LP/QP interior-point engine, but \
1943             this entry point cannot route a model to it — the option would \
1944             configure nothing. The `pounce` CLI reaches that engine on `.nl` \
1945             input (`solver_selection=lp-ipm` / `qp-ipm` / `socp`, or `auto` on \
1946             a model that classifies as one); from Python, `pounce.solve_qp` / \
1947             `pounce.solve_cone` drive it directly and take the same knobs as \
1948             typed arguments. On this path, `solver_selection=qp-active-set` \
1949             (or `algorithm=active-set-sqp`) is the nearest thing, tuned by the \
1950             `sqp_qp_*` options. Tracking issue: \
1951             https://github.com/jkitchin/pounce/issues/604"
1952        ))
1953    }
1954
1955    /// Warnings for caching hints pounce does not exploit. These never
1956    /// block a solve: the answer is identical either way, so refusing
1957    /// would cost the caller more than the silence did.
1958    pub fn unexploited_hint_warnings(&self) -> Vec<String> {
1959        crate::unimplemented_options::hint_warnings(&self.options, &self.reg_options)
1960    }
1961
1962    /// Warnings for the constant-derivative hints when the solve routes to
1963    /// `pounce-convex` instead of here. Call site is the convex dispatch
1964    /// in the CLI, next to the other guards that live there for the same
1965    /// reason: that dispatch never reaches [`Self::optimize_tnlp`], so
1966    /// `install_constant_derivative_hints` never runs and the hints are
1967    /// unread. On the NLP route they are honoured, so this must not be
1968    /// called there.
1969    pub fn convex_unexploited_hint_warnings(&self) -> Vec<String> {
1970        crate::unimplemented_options::convex_hint_warnings(&self.options, &self.reg_options)
1971    }
1972
1973    /// Which of the four constant-derivative hints the caller actually
1974    /// asserted, in [`pounce_nlp::constant_derivatives::HINT_OPTIONS`]
1975    /// order — the order [`reconcile`] pairs against the model's own
1976    /// proofs.
1977    ///
1978    /// Each name is read as a literal rather than through the loop
1979    /// variable the caller used to use. The registered-but-unread scan
1980    /// (`tests/no_silent_options.rs`) keys on the option name as it
1981    /// appears at the accessor, so `get_bool_value(name, "")` over an
1982    /// array read as "no key here" and left all four sitting in the
1983    /// silent list while they were fully wired and consumed (#551 /
1984    /// #677).
1985    ///
1986    /// Matching over `HINT_OPTIONS` rather than writing a bare array
1987    /// keeps the slots right by construction, and a fifth hint added to
1988    /// `HINT_OPTIONS` trips the fallback arm instead of silently reading
1989    /// as "not asserted" — which is the failure mode this whole line of
1990    /// work exists to kill.
1991    fn asserted_constant_derivative_hints(&self) -> [bool; 4] {
1992        use pounce_nlp::constant_derivatives::HINT_OPTIONS;
1993        let read_yes = |key: &str| matches!(self.options.get_bool_value(key, ""), Ok((true, true)));
1994        HINT_OPTIONS.map(|name| match name {
1995            "grad_f_constant" => read_yes("grad_f_constant"),
1996            "hessian_constant" => read_yes("hessian_constant"),
1997            "jac_c_constant" => read_yes("jac_c_constant"),
1998            "jac_d_constant" => read_yes("jac_d_constant"),
1999            other => unreachable!("`{other}` is in HINT_OPTIONS with no read site"),
2000        })
2001    }
2002
2003    /// Resolve the four constant-derivative hints for this solve and
2004    /// install the result on the NLP (gh #588, phase Q6).
2005    ///
2006    /// `grad_f_constant` / `hessian_constant` / `jac_c_constant` /
2007    /// `jac_d_constant` are, upstream, unchecked user assertions: Ipopt
2008    /// reuses the derivative and returns a wrong answer if the assertion
2009    /// was false. pounce asks the model first. Where the model *proves*
2010    /// the derivative constant, the reuse happens whether or not the
2011    /// option was set — the hint is redundant. Where the model proves it
2012    /// **varies** and the option was set anyway, the option is refused
2013    /// with a warning, which is the deliberate divergence. Where the
2014    /// model can prove nothing — every callback front end, both GAMS
2015    /// links — the user's assertion is honoured on trust, exactly as
2016    /// upstream, because "unproved" is not "disproved" and silently
2017    /// overriding the caller there would be its own wrong answer.
2018    fn install_constant_derivative_hints(&self, orig_nlp: &mut OrigIpoptNlp) {
2019        use pounce_common::journalist::JournalCategory;
2020        use pounce_nlp::constant_derivatives::reconcile;
2021
2022        // Each name is read as a literal rather than through the loop
2023        // variable: the registered-but-unread scan
2024        // (`tests/no_silent_options.rs`) keys on the option name as it
2025        // appears at the accessor, so `get_bool_value(name, "")` read as
2026        // "no key here" and left all four of these sitting in the silent
2027        // list while they were fully wired and consumed (#551 / #677).
2028        //
2029        // Matching over `HINT_OPTIONS` rather than writing a bare array
2030        // keeps the order right by construction — `reconcile` pairs
2031        // `asserted[k]` with `proofs[k]` — and a fifth hint added to
2032        // `HINT_OPTIONS` trips the fallback arm instead of silently
2033        // reading as "not asserted", which is the failure mode this
2034        // whole line of work exists to kill.
2035        let asserted = self.asserted_constant_derivative_hints();
2036        let proofs = orig_nlp.derivative_proofs();
2037        let (outcomes, enabled) = reconcile(proofs, asserted);
2038
2039        for outcome in &outcomes {
2040            if let Some(warning) = outcome.warning() {
2041                eprintln!("{warning}");
2042                self.journalist.print(
2043                    JournalLevel::J_STRONGWARNING,
2044                    JournalCategory::J_MAIN,
2045                    &format!("{warning}\n"),
2046                );
2047            }
2048        }
2049        if std::env::var("POUNCE_DBG_CONSTDERIV").is_ok() {
2050            for outcome in &outcomes {
2051                eprintln!(
2052                    "[const deriv] {:<15} proof={:?} asserted={} reused={}",
2053                    outcome.name, outcome.proof, outcome.asserted, outcome.honoured,
2054                );
2055            }
2056        }
2057        orig_nlp.set_constant_derivatives(enabled);
2058    }
2059
2060    /// Warnings for knobs of a linear-solver backend pounce does not
2061    /// ship (`ma97_*`, `pardiso_*`, …), one line per backend family.
2062    ///
2063    /// Warnings and not refusals: an `ipopt.opt` carrying settings for
2064    /// several backends so that one file runs everywhere is exactly what
2065    /// the registry exists to accept, and refusing it would fail a run
2066    /// over knobs it never touches. See the "Backend knobs warn, they do
2067    /// not refuse" section of [`crate::unimplemented_options`]. gh#551.
2068    pub fn unimplemented_backend_warnings(&self) -> Vec<String> {
2069        crate::unimplemented_options::backend_warnings(&self.options, &self.reg_options)
2070    }
2071
2072    /// The same warnings, but at most once per application: the second
2073    /// caller gets nothing.
2074    ///
2075    /// Two sites emit them — the CLI, before routing, because a convex
2076    /// model never reaches [`Self::optimize_tnlp`], and `optimize_tnlp`
2077    /// itself, for every frontend that is not the CLI. A CLI run passes
2078    /// through both, and printing the identical paragraph twice is how a
2079    /// warning teaches its reader to skip warnings.
2080    pub fn take_unimplemented_backend_warnings(&mut self) -> Vec<String> {
2081        if self.backend_warnings_emitted {
2082            return Vec::new();
2083        }
2084        self.backend_warnings_emitted = true;
2085        self.unimplemented_backend_warnings()
2086    }
2087
2088    /// Resolve the five registered `derivative_test*` knobs. Every one
2089    /// of them was registered and never read, so `derivative_test=
2090    /// first-order` ran no test and printed nothing — a checker that
2091    /// silently checks nothing reports success by omission (gh#483
2092    /// follow-up).
2093    ///
2094    /// The numeric helper is named `read_num` to match the accessor
2095    /// idiom the rest of this file uses: the registered-but-unread scan
2096    /// (`tests/no_silent_options.rs`) discovers `read_*` helpers and the
2097    /// literal key passed to them, so a differently-named local closure
2098    /// made `derivative_test_perturbation` and `derivative_test_tol`
2099    /// read as silent when they are wired and consumed (#677, #551).
2100    fn derivative_test_options(&self) -> DerivativeTestOptions {
2101        let read_num = |key: &str, default: Number| -> Number {
2102            self.options
2103                .get_numeric_value(key, "")
2104                .ok()
2105                .and_then(|(v, f)| f.then_some(v))
2106                .unwrap_or(default)
2107        };
2108        DerivativeTestOptions {
2109            mode: self
2110                .options
2111                .get_string_value("derivative_test", "")
2112                .ok()
2113                .and_then(|(v, f)| f.then_some(v))
2114                .map(|v| DerivativeTest::from_option(&v))
2115                .unwrap_or_default(),
2116            perturbation: read_num("derivative_test_perturbation", 1e-8),
2117            tol: read_num("derivative_test_tol", 1e-4),
2118            first_index: self
2119                .options
2120                .get_integer_value("derivative_test_first_index", "")
2121                .ok()
2122                .and_then(|(v, f)| f.then_some(v))
2123                .unwrap_or(-2),
2124            print_all: self
2125                .options
2126                .get_bool_value("derivative_test_print_all", "")
2127                .ok()
2128                .and_then(|(v, f)| f.then_some(v))
2129                .unwrap_or(false),
2130        }
2131    }
2132
2133    /// Run the derivative checker, if requested, against `tnlp`.
2134    ///
2135    /// Advisory, like upstream: a suspicious entry is reported and the
2136    /// solve continues. The report goes to stderr so it survives
2137    /// `print_level=0` and leaves `--json-output`'s stdout clean.
2138    pub fn run_derivative_test(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
2139        let opts = self.derivative_test_options();
2140        if matches!(opts.mode, DerivativeTest::None) {
2141            return;
2142        }
2143        let report = {
2144            let mut borrowed = tnlp.borrow_mut();
2145            pounce_nlp::derivative_test::run(&mut *borrowed, &opts)
2146        };
2147        let Some(report) = report else {
2148            eprintln!(
2149                "pounce: derivative_test was requested but the TNLP declined to \
2150                 supply the information the check needs (dimensions, bounds, or \
2151                 a starting point); no test was run."
2152            );
2153            return;
2154        };
2155        use pounce_common::journalist::JournalCategory;
2156        for line in &report.lines {
2157            eprintln!("{line}");
2158            self.journalist.print(
2159                JournalLevel::J_SUMMARY,
2160                JournalCategory::J_MAIN,
2161                &format!("{line}\n"),
2162            );
2163        }
2164    }
2165
2166    /// The message [`Self::unimplemented_linear_solver`] earns, shared by
2167    /// every frontend so they cannot drift apart.
2168    pub fn unimplemented_linear_solver_message(value: &str) -> String {
2169        format!(
2170            "pounce: linear_solver={value} is not implemented. pounce provides \
2171             `feral` (pure-Rust sparse symmetric, the default) and `ma57` (HSL, \
2172             in a `--features ma57` build); the other names in the option's \
2173             list come from the upstream Ipopt registry so an ipopt.opt written \
2174             for Ipopt still parses. Selecting one used to run FERAL silently, \
2175             which makes a backend comparison measure nothing — so it is \
2176             refused instead. Use linear_solver=feral or linear_solver=ma57."
2177        )
2178    }
2179
2180    fn is_sqp_algorithm_selected(&self) -> bool {
2181        // `algorithm` is the primary selector.
2182        // `solver_selection = qp-active-set` selects the
2183        // same active-set SQP engine.
2184        let algo_sqp = matches!(
2185            self.options.get_string_value("algorithm", ""),
2186            Ok((v, true)) if v.eq_ignore_ascii_case("active-set-sqp")
2187        );
2188        let selection_sqp = matches!(
2189            self.options.get_string_value("solver_selection", ""),
2190            Ok((v, true)) if v.eq_ignore_ascii_case("qp-active-set")
2191        );
2192        algo_sqp || selection_sqp
2193    }
2194
2195    /// Phase 5b SQP entry point. Builds the same NLP chain
2196    /// (`TNLPAdapter` → `OrigIpoptNlp` → `IpoptNlpAdapter`) the
2197    /// IPM uses, then runs `SqpAlgorithm::optimize`. Maps the
2198    /// `SqpResult.status` back to `ApplicationReturnStatus` and
2199    /// hands the final iterate to the user TNLP's
2200    /// `finalize_solution` callback via `finalize_via_sqp`.
2201    fn optimize_sqp_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2202        use pounce_nlp::ConstObjScaling;
2203        use pounce_nlp::orig_ipopt_nlp::OrigIpoptNlp;
2204        use pounce_nlp::tnlp_adapter::TNLPAdapter;
2205
2206        // Wall-clock for the whole SQP solve, mirroring the IPM path's
2207        // `t_start` (see the `total_wallclock_time_secs` assignment in
2208        // `optimize_tnlp`). Without this the field stayed at its struct
2209        // default of 0.0 on every active-set solve, so `--json-output`
2210        // reported an instantaneous solve regardless of actual runtime and
2211        // the engine could not be speed-compared against qp-ipm at all
2212        // (benchmarks/scripts/compare_qp_four_way.py had to skip the column).
2213        let t_start = std::time::Instant::now();
2214
2215        let adapter = match TNLPAdapter::new(Rc::clone(&tnlp)) {
2216            Ok(a) => Rc::new(RefCell::new(a)),
2217            Err(_) => return ApplicationReturnStatus::InvalidProblemDefinition,
2218        };
2219        // The SQP path never runs gradient-based scaling, but the
2220        // constant `obj_scaling_factor` (negative ⇒ maximize) still
2221        // applies via the OrigIpoptNlp constructor.
2222        let obj_scaling_factor = self
2223            .options
2224            .get_numeric_value("obj_scaling_factor", "")
2225            .ok()
2226            .and_then(|(v, f)| f.then_some(v))
2227            .unwrap_or(1.0);
2228        let mut orig_nlp = match OrigIpoptNlp::new(
2229            Rc::clone(&adapter),
2230            Rc::new(ConstObjScaling(obj_scaling_factor)),
2231        ) {
2232            Ok(n) => n,
2233            Err(_) => return ApplicationReturnStatus::InternalError,
2234        };
2235        // Same Q6 reconciliation as the IPM route: the SQP driver
2236        // evaluates the same derivatives through the same NLP object.
2237        self.install_constant_derivative_hints(&mut orig_nlp);
2238        let nlp_rc: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
2239
2240        let mut sqp_adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(&nlp_rc));
2241
2242        let mut builder = self.algorithm_builder_snapshot();
2243        builder.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
2244        let factory = self.make_backend_factory();
2245        let mut alg = match builder.build_sqp_with_backend(factory) {
2246            Some(a) => a,
2247            None => return ApplicationReturnStatus::InternalError,
2248        };
2249
2250        // Problem statistics + end-of-run summary are emitted by the engine
2251        // itself here (#206), gated on the main `print_level`, so the SQP
2252        // route matches the IPM route across every frontend (CLI, Python, C).
2253        // The SQP's own per-iteration rows stay gated on the separate
2254        // `sqp_print_level`.
2255        let console_output = match self.options.get_integer_value("print_level", "") {
2256            Ok((v, true)) => v >= 1,
2257            _ => true,
2258        };
2259        self.emit_problem_stats(&tnlp, console_output);
2260
2261        // Phase 5c (§6): consume any stashed warm-start iterate.
2262        // `optimize_with_warm_start(warm=None)` is equivalent to
2263        // `optimize`, so cold callers see no change.
2264        let warm = self.sqp_warm_start.take();
2265        let res = match alg.optimize_with_warm_start(&mut sqp_adapter, warm) {
2266            Ok(r) => r,
2267            Err(e) => {
2268                // Always surface this. It used to be gated on the
2269                // undocumented `POUNCE_DBG_SQP`, so the only thing a user saw
2270                // was a bare `Internal_Error` with no indication of what went
2271                // wrong -- the underlying message here was
2272                // `QpFailure(LinearSolverFailure("QP subproblem returned
2273                // status unbounded"))`, which points straight at the cause.
2274                // A solve that is about to fail is exactly when the reason
2275                // should be cheapest to obtain.
2276                tracing::warn!(
2277                    target: "pounce::sqp",
2278                    "SQP solve failed: {e:?}"
2279                );
2280                return ApplicationReturnStatus::InternalError;
2281            }
2282        };
2283        // Stash the result's working set so the next solve in a
2284        // sequence can fetch it via `last_sqp_working_set`.
2285        self.sqp_last_working_set = res.working_set.clone();
2286        // Populate the shared `SolveStatistics` so the Python /
2287        // C-API post-solve accessors (`GetIpoptIterCount`,
2288        // `info["iter_count"]`, etc.) report the SQP outer-iter
2289        // count rather than zero. Constraint-violation /
2290        // dual-infeasibility residuals get the SQP-side values
2291        // too. The IPM path overwrites this dict on its own
2292        // solves, so SQP-vs-IPM mixing across solves stays
2293        // honest.
2294        {
2295            let mut stats = self.statistics.borrow_mut();
2296            stats.iteration_count = res.n_iter as Index;
2297            // Subproblem counters. The outer iteration count alone
2298            // cannot show what a working-set warm start bought — the
2299            // saved work is inside the QPs — so both are reported.
2300            stats.sqp_qp_solves = res.n_qp_solves as Index;
2301            stats.sqp_qp_working_set_changes = res.n_qp_working_set_changes as Index;
2302            stats.final_objective = res.obj;
2303            // `final_scaled_objective` defaults to NaN; the SQP path does not
2304            // thread nlp_scaling through the objective (same as the residuals
2305            // mirrored below), so the scaled objective equals the unscaled
2306            // one. Without this it stayed NaN and the console printed
2307            // "Objective ...: nan  <unscaled>" on every active-set solve
2308            // (gh #313), even on a clean optimal solve.
2309            stats.final_scaled_objective = res.obj;
2310            stats.final_dual_inf = res.final_stationarity;
2311            stats.final_constr_viol = res.final_constr_viol;
2312            stats.final_compl = 0.0; // SQP has no barrier — no compl term.
2313            // Overall KKT error. This was previously left at the struct
2314            // default, which made every successful SQP solve report an
2315            // overall error of exactly 0.0 — indistinguishable from a
2316            // genuinely perfect solve, and enough on its own to make
2317            // `pounce.minimize`'s acceptable-KKT fallback upgrade any status
2318            // on this path to `success=True`. Same expression as the unscaled
2319            // twin below; the two agree because the SQP path does not thread
2320            // nlp_scaling through its residuals.
2321            stats.final_kkt_error = res.final_stationarity.max(res.final_constr_viol);
2322            // Unscaled residuals (pounce#173). The SQP path does not thread
2323            // the nlp_scaling factors through to its residuals yet, so these
2324            // mirror the SQP-side values: correct when no scaling is active
2325            // (the common case) and a conservative proxy otherwise. Populated
2326            // here so the info dict's `final_unscaled_*` keys are honest
2327            // rather than left at the 0.0 default.
2328            stats.final_unscaled_dual_inf = res.final_stationarity;
2329            stats.final_unscaled_constr_viol = res.final_constr_viol;
2330            stats.final_unscaled_compl = 0.0;
2331            stats.final_unscaled_kkt_error = res.final_stationarity.max(res.final_constr_viol);
2332            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
2333        }
2334        let (app_status, solver_status) = match res.status {
2335            crate::sqp::SqpStatus::Optimal => (
2336                ApplicationReturnStatus::SolveSucceeded,
2337                pounce_nlp::SolverReturn::Success,
2338            ),
2339            crate::sqp::SqpStatus::MaxIter => (
2340                ApplicationReturnStatus::MaximumIterationsExceeded,
2341                pounce_nlp::SolverReturn::MaxiterExceeded,
2342            ),
2343            crate::sqp::SqpStatus::InfeasibleSubproblem => (
2344                ApplicationReturnStatus::InfeasibleProblemDetected,
2345                pounce_nlp::SolverReturn::LocalInfeasibility,
2346            ),
2347            crate::sqp::SqpStatus::LineSearchFailed => (
2348                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
2349                pounce_nlp::SolverReturn::ErrorInStepComputation,
2350            ),
2351            // Honest non-committal QP-subproblem failure (#282): the QP
2352            // solver could not compute a step and did NOT certify
2353            // infeasibility. Never report Infeasible_Problem_Detected here
2354            // — a feasible problem has no infeasibility certificate.
2355            crate::sqp::SqpStatus::QpStepFailed => (
2356                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
2357                pounce_nlp::SolverReturn::ErrorInStepComputation,
2358            ),
2359            // The QP subproblem ran out of its own iteration budget. Same
2360            // #282 guarantee — no infeasibility is asserted — but reported as
2361            // the budget exhaustion it is, so the user sees a limit they can
2362            // raise (`sqp_qp_max_iter`) instead of a step-size stall with no
2363            // remedy. See `SqpStatus::QpIterationLimit` for why these were
2364            // split.
2365            crate::sqp::SqpStatus::QpIterationLimit => (
2366                ApplicationReturnStatus::MaximumIterationsExceeded,
2367                pounce_nlp::SolverReturn::MaxiterExceeded,
2368            ),
2369            // Unbounded below, with a recession ray verified against the
2370            // true NLP (gh #388). `Diverging_Iterates` is POUNCE's (Ipopt's)
2371            // unboundedness verdict and maps to AMPL `solve_result_num=300`
2372            // — the same answer the IPM selectors give on the same model,
2373            // instead of the `Internal_Error` / 500 ("the solver broke")
2374            // this path used to report.
2375            crate::sqp::SqpStatus::Unbounded => (
2376                ApplicationReturnStatus::DivergingIterates,
2377                pounce_nlp::SolverReturn::DivergingIterates,
2378            ),
2379            // A non-finite iterate or constraint value (gh #876). Same
2380            // verdict, from the same condition, as the interior-point arm's
2381            // `if !nlp_err.is_finite()` screen — the two arms must not
2382            // disagree about what a `NaN` iterate means.
2383            crate::sqp::SqpStatus::InvalidNumber => (
2384                ApplicationReturnStatus::InvalidNumberDetected,
2385                pounce_nlp::SolverReturn::InvalidNumberDetected,
2386            ),
2387        };
2388
2389        // Same gate as the IPM path: an infeasible-subproblem exit is a
2390        // numerical inference, and a feasible starting point disproves it
2391        // (gh #379). Only the infeasibility verdict is rewritten — every other
2392        // status passes through untouched, so the pair stays in lockstep.
2393        let refuted = withdraw_infeasibility_if_refuted(
2394            &tnlp,
2395            solver_status,
2396            self.nlp_lower_bound_inf(),
2397            self.nlp_upper_bound_inf(),
2398            self.user_tol(),
2399        );
2400        let (app_status, solver_status) = if refuted == solver_status {
2401            (app_status, solver_status)
2402        } else {
2403            (solver_return_to_app_status(refuted), refuted)
2404        };
2405
2406        // Forward to the user TNLP's finalize_solution. We pass
2407        // the SQP iterate and recovered multipliers via the
2408        // OrigIpoptNlp's lifting hooks. Failure here is silent
2409        // (we still return the algorithm's status) — the user
2410        // sees the right ApplicationReturnStatus regardless.
2411        let _ = finalize_via_sqp(&nlp_rc, &res, solver_status, &tnlp, &self.last_finalize);
2412
2413        // Honor the opt-in status-fidelity gate on the SQP path too
2414        // (pounce#173), then emit the end-of-run summary with the final
2415        // (possibly downgraded) status so the console matches the returned
2416        // ApplicationReturnStatus.
2417        let final_status = self.apply_kkt_fidelity_gate(app_status);
2418        self.emit_end_summary(final_status, &nlp_rc, console_output);
2419        final_status
2420    }
2421
2422    /// Opt-in status-fidelity gate (pounce#173), shared by the IPM and
2423    /// SQP solve paths. When the user sets a positive `kkt_fidelity_tol`,
2424    /// a reported `Solve_Succeeded` whose max-norm UNSCALED KKT error
2425    /// (`SolveStatistics::final_unscaled_kkt_error`) exceeds it is
2426    /// downgraded to `Solved_To_Acceptable_Level` — the honest "this is a
2427    /// point, but not converged to the requested fidelity" status. This
2428    /// catches the ill-conditioned / nlp_scaling-deflated case where the
2429    /// scaled convergence test passes but the user-space duals have
2430    /// drifted. It is a pure relabel at termination (no extra iterations);
2431    /// unset or non-positive (the default) is a strict no-op, so every
2432    /// existing caller keeps the Ipopt-faithful status.
2433    fn apply_kkt_fidelity_gate(
2434        &self,
2435        app_status: ApplicationReturnStatus,
2436    ) -> ApplicationReturnStatus {
2437        if !matches!(app_status, ApplicationReturnStatus::SolveSucceeded) {
2438            return app_status;
2439        }
2440        if let Ok((ftol, true)) = self.options.get_numeric_value("kkt_fidelity_tol", "") {
2441            if ftol > 0.0 {
2442                let unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
2443                if unscaled_kkt > ftol {
2444                    tracing::info!(target: "pounce::diagnostics",
2445                        "kkt_fidelity_tol={ftol:.3e}: unscaled KKT error {unscaled_kkt:.3e} \
2446                         exceeds it — downgrading Solve_Succeeded → \
2447                         Solved_To_Acceptable_Level (pounce#173)");
2448                    return ApplicationReturnStatus::SolvedToAcceptableLevel;
2449                }
2450            }
2451        }
2452        app_status
2453    }
2454
2455    /// `nlp_lower_bound_inf` — the magnitude at or below which a bound is
2456    /// treated as absent.
2457    fn nlp_lower_bound_inf(&self) -> Number {
2458        self.options
2459            .get_numeric_value("nlp_lower_bound_inf", "")
2460            .ok()
2461            .and_then(|(v, f)| f.then_some(v))
2462            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF)
2463    }
2464
2465    /// `nlp_upper_bound_inf` — the magnitude at or above which a bound is
2466    /// treated as absent.
2467    fn nlp_upper_bound_inf(&self) -> Number {
2468        self.options
2469            .get_numeric_value("nlp_upper_bound_inf", "")
2470            .ok()
2471            .and_then(|(v, f)| f.then_some(v))
2472            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF)
2473    }
2474
2475    /// The user's convergence tolerance `tol`.
2476    fn user_tol(&self) -> Number {
2477        self.options
2478            .get_numeric_value("tol", "")
2479            .ok()
2480            .and_then(|(v, f)| f.then_some(v))
2481            .unwrap_or(1e-8)
2482    }
2483
2484    /// The user's `acceptable_tol` — the standard behind
2485    /// `Solved_To_Acceptable_Level`.
2486    fn user_acceptable_tol(&self) -> Number {
2487        self.options
2488            .get_numeric_value("acceptable_tol", "")
2489            .ok()
2490            .and_then(|(v, f)| f.then_some(v))
2491            .unwrap_or(1e-6)
2492    }
2493
2494    /// The user's `constr_viol_tol` — the **absolute** feasibility standard
2495    /// the strict gate's primal component judges by
2496    /// (`OptErrorConvCheck::primal_component_passes`).
2497    fn user_constr_viol_tol(&self) -> Number {
2498        self.options
2499            .get_numeric_value("constr_viol_tol", "")
2500            .ok()
2501            .and_then(|(v, f)| f.then_some(v))
2502            .unwrap_or(1e-4)
2503    }
2504
2505    /// The user's `acceptable_constr_viol_tol` — the same standard at the
2506    /// acceptable tier.
2507    fn user_acceptable_constr_viol_tol(&self) -> Number {
2508        self.options
2509            .get_numeric_value("acceptable_constr_viol_tol", "")
2510            .ok()
2511            .and_then(|(v, f)| f.then_some(v))
2512            .unwrap_or(1e-2)
2513    }
2514
2515    /// `primal_noise_floor_kappa` — the safety factor on the per-row
2516    /// floating-point noise floor (gh#528/gh#590). `0` opts out.
2517    fn user_primal_noise_floor_kappa(&self) -> Number {
2518        self.options
2519            .get_numeric_value("primal_noise_floor_kappa", "")
2520            .ok()
2521            .and_then(|(v, f)| f.then_some(v))
2522            .unwrap_or(64.0)
2523    }
2524
2525    /// Emit the Ipopt-style problem-statistics block (#206) from the
2526    /// engine's own reduced problem, gated on `console_output`
2527    /// (print_level >= 1). Shared by the IPM (`optimize_tnlp`) and SQP
2528    /// (`optimize_sqp_tnlp`) entry points so every algorithm and every
2529    /// frontend (CLI, Python, C) gets the identical block. Built from the
2530    /// same `collect_stats` inputs the CLI used, so the output is
2531    /// byte-identical to the historical CLI block.
2532    fn emit_problem_stats(&self, tnlp: &Rc<RefCell<dyn TNLP>>, console_output: bool) {
2533        if !console_output {
2534            return;
2535        }
2536        let lo_inf = self
2537            .options
2538            .get_numeric_value("nlp_lower_bound_inf", "")
2539            .ok()
2540            .and_then(|(v, f)| f.then_some(v))
2541            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
2542        let up_inf = self
2543            .options
2544            .get_numeric_value("nlp_upper_bound_inf", "")
2545            .ok()
2546            .and_then(|(v, f)| f.then_some(v))
2547            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
2548        let fixed_treatment = match self
2549            .options
2550            .get_string_value("fixed_variable_treatment", "")
2551            .ok()
2552            .and_then(|(v, f)| f.then_some(v))
2553            .as_deref()
2554        {
2555            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
2556            _ => FixedVarTreatment::MakeParameter,
2557        };
2558        if let Some(stats) =
2559            pounce_solve_report::console::collect_stats(tnlp, lo_inf, up_inf, fixed_treatment)
2560        {
2561            pounce_solve_report::console::print_problem_stats(&stats);
2562        }
2563    }
2564
2565    /// Drain the NLP's per-eval counters into the shared `SolveStatistics`
2566    /// and emit the Ipopt-style end-of-run summary (#206). Shared by both
2567    /// solve paths. The counts are read from the NLP AFTER the solve (so the
2568    /// final solution evaluation is included, matching the historical count)
2569    /// and written into `SolveStatistics` so the post-solve API accessors
2570    /// (`info["n_obj_evals"]`, …) report them even when the console is
2571    /// silent. c/d (and jac_c/jac_d) are per-subsystem, so the max recovers
2572    /// the eval_g / eval_jac_g call count. The console summary itself is
2573    /// gated on `console_output` (print_level >= 1).
2574    fn emit_end_summary(
2575        &self,
2576        app_status: ApplicationReturnStatus,
2577        nlp: &Rc<RefCell<dyn IpoptNlp>>,
2578        console_output: bool,
2579    ) {
2580        {
2581            let ec = nlp.borrow().eval_counts();
2582            let mut stats = self.statistics.borrow_mut();
2583            stats.num_obj_evals = ec[0];
2584            stats.num_obj_grad_evals = ec[1];
2585            stats.num_constr_evals = ec[2].max(ec[3]);
2586            stats.num_constr_jac_evals = ec[4].max(ec[5]);
2587            stats.num_hess_evals = ec[6];
2588        }
2589        if !console_output {
2590            return;
2591        }
2592        let stats = self.statistics.borrow();
2593        let counts = pounce_solve_report::console::EvalCounts {
2594            n_obj: stats.num_obj_evals as u64,
2595            n_grad_f: stats.num_obj_grad_evals as u64,
2596            n_g: stats.num_constr_evals as u64,
2597            n_jac_g: stats.num_constr_jac_evals as u64,
2598            n_h: stats.num_hess_evals as u64,
2599        };
2600        pounce_solve_report::console::print_summary(app_status, &stats, &counts);
2601    }
2602
2603    /// Build a *copy* of the algorithm builder configured per the
2604    /// current options. The SQP path uses this so it gets a
2605    /// fresh builder without mutating the application's state.
2606    /// Read the `crossover*` option family into a [`CrossoverOptions`].
2607    fn crossover_options(&self) -> crate::crossover::CrossoverOptions {
2608        let mut o = crate::crossover::CrossoverOptions::default();
2609        if let Ok((v, true)) = self.options.get_bool_value("crossover", "") {
2610            o.enabled = v;
2611        }
2612        if let Ok((v, true)) = self.options.get_integer_value("crossover_max_iter", "") {
2613            o.max_iter = v.max(0) as u32;
2614        }
2615        if let Ok((v, true)) = self.options.get_numeric_value("crossover_mult_tol", "") {
2616            o.mult_tol = v;
2617        }
2618        if let Ok((v, true)) = self.options.get_numeric_value("crossover_primal_tol", "") {
2619            o.primal_tol = v;
2620        }
2621        o
2622    }
2623
2624    /// Post-convergence crossover (gh#612): hand the converged interior
2625    /// iterate to the active-set path so the solve ends on an exact active
2626    /// set. See [`crate::crossover`] for the algorithm.
2627    ///
2628    /// On acceptance this **replaces `data.curr`** rather than reporting
2629    /// alongside it. Everything downstream — the residual drain, the KKT
2630    /// fidelity gate, `on_converged`, `finalize_via_orig_nlp`, the end
2631    /// summary — already reads that one iterate, so replacing it is what
2632    /// makes the crossed-over point the solution instead of an annotation on
2633    /// it, and it does so without a second copy of the unscaling path.
2634    ///
2635    /// A no-op unless `crossover=yes` and the solve converged: an
2636    /// unconverged interior point is not a KKT point, so there is no active
2637    /// set at it worth identifying.
2638    fn maybe_crossover(
2639        &mut self,
2640        alg: &mut IpoptAlgorithm,
2641        nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
2642        solver_status: SolverReturn,
2643    ) {
2644        self.crossover_report = None;
2645        // Cleared on every entry, alongside the report, so the flag
2646        // describes this solve and not a previous one — the same reason
2647        // `crossover_report` is reset here rather than only written on
2648        // the accept path.
2649        alg.data.borrow_mut().curr_from_crossover = false;
2650        let xopts = self.crossover_options();
2651        if !xopts.enabled {
2652            return;
2653        }
2654        if !matches!(
2655            solver_status,
2656            SolverReturn::Success | SolverReturn::StopAtAcceptablePoint
2657        ) {
2658            return;
2659        }
2660        let Some(curr) = alg.data.borrow().curr.clone() else {
2661            return;
2662        };
2663
2664        // Seed, in the algorithm's compressed / scaled space. The SQP
2665        // adapter presents that same space, so nothing is translated here
2666        // beyond repacking the bound duals: `λ_x = z_l − z_u`.
2667        let seed = crate::crossover::CrossoverSeed {
2668            x: dense_values(&*curr.x),
2669            lambda_g: {
2670                let mut v = dense_values(&*curr.y_c);
2671                v.extend(dense_values(&*curr.y_d));
2672                v
2673            },
2674            lambda_x: crate::sqp::ipopt_adapter::pack_bound_multipliers(
2675                nlp_handle,
2676                &dense_values(&*curr.z_l),
2677                &dense_values(&*curr.z_u),
2678            ),
2679        };
2680
2681        let snapshot = self.algorithm_builder_snapshot();
2682        let sqp_opts = snapshot.sqp.clone();
2683        let qp_opts = snapshot.sqp_qp.clone();
2684        // Declared bounds, not the live relaxed ones: crossover's whole claim
2685        // is that the returned point sits *on* the constraints the user
2686        // wrote. Against the `bound_relax_factor`-widened box it would pivot
2687        // to a point `1e-8` shy of every one of them and then correctly
2688        // report an empty active set.
2689        let mut adapter =
2690            crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(nlp_handle));
2691        let (report, accepted) = crate::crossover::run(
2692            &mut adapter,
2693            &seed,
2694            &xopts,
2695            &sqp_opts,
2696            &qp_opts,
2697            || {
2698                let mut f = self.make_backend_factory();
2699                // `make_backend_factory` ships the workspace-default
2700                // backend regardless of the choice passed, exactly as the
2701                // SQP path's `build_sqp_with_backend` call does; naming
2702                // `Feral` here keeps that visible rather than implicit.
2703                f(crate::alg_builder::LinearSolverChoice::Feral)
2704            },
2705            |step4_opts| {
2706                let mut b = self.algorithm_builder_snapshot();
2707                b.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
2708                b.sqp = step4_opts;
2709                b.build_sqp_with_backend(self.make_backend_factory())
2710            },
2711        );
2712
2713        if let Some(res) = accepted {
2714            self.install_crossover_iterate(alg, nlp_handle, &curr, &res);
2715            // Publish the identified set as the SQP warm-start output. This
2716            // is the IPM → SQP handoff the active-set path never had: a
2717            // sequence whose first solve wants the interior method can now
2718            // feed the next `algorithm=active-set-sqp` solve a working set.
2719            self.sqp_last_working_set = res.working_set.clone();
2720        }
2721        tracing::debug!(target: "pounce::crossover", "crossover: {report:?}");
2722        self.crossover_report = Some(report);
2723    }
2724
2725    /// Write an accepted crossover result back onto the IPM iterate.
2726    ///
2727    /// All eight components have to move together: leaving `s`, `v_l` or
2728    /// `v_u` describing the interior point while `x` and the duals describe
2729    /// the crossed-over one would make the calculated quantities read off an
2730    /// iterate that never existed, and the residuals reported to the user
2731    /// would be neither point's. The slack relations are the barrier
2732    /// problem's own — `s = d(x)` and `v_l − v_u = −y_d`.
2733    fn install_crossover_iterate(
2734        &self,
2735        alg: &mut IpoptAlgorithm,
2736        nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
2737        curr: &crate::iterates_vector::IteratesVector,
2738        res: &crate::sqp::SqpResult,
2739    ) {
2740        let (m_c, m_d) = {
2741            let b = nlp_handle.borrow();
2742            (b.m_eq() as usize, b.m_ineq() as usize)
2743        };
2744        let y_c = &res.lambda_g[..m_c];
2745        let y_d = &res.lambda_g[m_c..];
2746        // `s = d(x)`: the adapter's combined constraint vector is `[c ; d]`,
2747        // so the inequality block is its tail.
2748        let mut adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(nlp_handle));
2749        let c_all = crate::sqp::SqpProblemSpec::eval_c(&mut adapter, &res.x);
2750        let s_new = &c_all[m_c..];
2751        debug_assert_eq!(s_new.len(), m_d);
2752        let (z_l, z_u) =
2753            crate::sqp::ipopt_adapter::split_bound_multipliers(nlp_handle, &res.lambda_x);
2754        let (v_l, v_u) = crate::sqp::ipopt_adapter::split_slack_multipliers(nlp_handle, y_d);
2755
2756        let mut out = curr.deep_copy();
2757        let ok = set_dense(&mut *out.x, &res.x)
2758            && set_dense(&mut *out.s, s_new)
2759            && set_dense(&mut *out.y_c, y_c)
2760            && set_dense(&mut *out.y_d, y_d)
2761            && set_dense(&mut *out.z_l, &z_l)
2762            && set_dense(&mut *out.z_u, &z_u)
2763            && set_dense(&mut *out.v_l, &v_l)
2764            && set_dense(&mut *out.v_u, &v_u);
2765        if !ok {
2766            // A non-dense backing or a length mismatch. POUNCE is dense-only,
2767            // so this is defensive — but a partially-written iterate is worse
2768            // than no crossover at all, so bail without touching `curr`.
2769            tracing::warn!(
2770                target: "pounce::crossover",
2771                "crossover result did not fit the iterate; keeping the interior point"
2772            );
2773            return;
2774        }
2775        let mut d = alg.data.borrow_mut();
2776        d.set_curr(out.freeze());
2777        // Mark which frame the installed iterate belongs to. It sits on the
2778        // *declared* bounds, and every barrier quantity built off `curr` —
2779        // slacks, and through them `Σ = z/s` — is measured against the
2780        // `bound_relax_factor`-widened ones, so a consumer that wants the
2781        // point's own geometry rather than the barrier's has to know this
2782        // happened (gh#654). Set only on the path that actually replaced
2783        // `curr`: a declined or abandoned crossover leaves the interior
2784        // iterate, which is an interior-frame point.
2785        d.curr_from_crossover = true;
2786    }
2787
2788    fn algorithm_builder_snapshot(&self) -> AlgorithmBuilder {
2789        let mut builder = AlgorithmBuilder {
2790            quality_escalation_counter: Some(Rc::clone(&self.quality_escalations)),
2791            ..AlgorithmBuilder::default()
2792        };
2793        apply_sqp_options(&self.options, &mut builder.sqp);
2794        apply_qp_subproblem_options(&self.options, &mut builder.sqp_qp);
2795        builder
2796    }
2797
2798    /// Refuse an explicitly set `ma57_pivtolmax` that sits below
2799    /// `ma57_pivtol`, at either option prefix.
2800    ///
2801    /// Upstream's `Ma57TSolverInterface::InitializeImpl` asserts
2802    /// `pivtolmax >= pivtol` and raises `OPTION_INVALID`, but only when
2803    /// the user set `ma57_pivtolmax` explicitly; left unset, the
2804    /// registered default is lifted to `ma57_pivtol` instead. Both
2805    /// halves are mirrored — the lifting in
2806    /// `pounce_hsl::ma57::Options::from_options_list`, the refusal here.
2807    ///
2808    /// Checked at **both** prefixes because the restoration sub-IPM
2809    /// configures its own MA57 backend from `"resto."`-scoped options
2810    /// (gh#825), so `resto.ma57_pivtolmax` can contradict
2811    /// `resto.ma57_pivtol` without the un-prefixed pair being wrong.
2812    ///
2813    /// Deliberately **not** gated on the `ma57` cargo feature or on
2814    /// `linear_solver` resolving to MA57. It is a consistency check on
2815    /// two numbers the user wrote, needs no HSL to perform, and a
2816    /// verdict that changed with a build flag would be worse than a
2817    /// consistent one — it would also be untestable in CI, which cannot
2818    /// link CoinHSL. Only an *explicitly set* `ma57_pivtolmax` can
2819    /// trigger it, so an options file that never mentions the option is
2820    /// unaffected.
2821    fn ma57_pivtol_bracket_refusal(&self) -> Option<String> {
2822        for prefix in ["", "resto."] {
2823            // `(_, true)` is the explicitly-set arm; an unset option
2824            // reports the registry default with `false` and is the
2825            // branch upstream lifts rather than refuses.
2826            let Ok((pivtolmax, true)) = self.options.get_numeric_value("ma57_pivtolmax", prefix)
2827            else {
2828                continue;
2829            };
2830            let pivtol = self
2831                .options
2832                .get_numeric_value("ma57_pivtol", prefix)
2833                .map(|(v, _)| v)
2834                .unwrap_or(1e-8);
2835            if pivtolmax < pivtol {
2836                return Some(format!(
2837                    "pounce: {prefix}ma57_pivtolmax ({pivtolmax:e}) is below \
2838                     {prefix}ma57_pivtol ({pivtol:e}). ma57_pivtolmax is the ceiling MA57 \
2839                     may raise the pivot tolerance to when it escalates for accuracy, so it \
2840                     cannot sit below the tolerance it starts from. Raise \
2841                     {prefix}ma57_pivtolmax to at least {pivtol:e}, or lower \
2842                     {prefix}ma57_pivtol."
2843                ));
2844            }
2845        }
2846        None
2847    }
2848
2849    /// Construct a LinearBackendFactory honoring the
2850    /// `linear_solver` option. Default FERAL; HSL MA57 when
2851    /// built with the `ma57` feature.
2852    fn make_backend_factory(&self) -> LinearBackendFactory {
2853        Box::new(
2854            |_choice| -> Box<dyn pounce_linsol::SparseSymLinearSolverInterface> {
2855                Box::new(pounce_feral::FeralSolverInterface::new())
2856            },
2857        )
2858    }
2859
2860    /// Phase 3.5 auto-fallback driver.
2861    ///
2862    /// Runs the standard solve (no wrapper) first. If it ends in a
2863    /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
2864    /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
2865    /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
2866    /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
2867    /// it returns `Solve_Succeeded`; otherwise returns the original
2868    /// status.
2869    ///
2870    /// Caveat: the user TNLP's `finalize_solution` runs once per
2871    /// attempt. When the retry doesn't promote, the user's captured
2872    /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
2873    /// point) even though the returned status is the original's.
2874    /// Documented on the option's help text; tightening this is a
2875    /// Phase-4 follow-up.
2876    fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2877        // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
2878        // reached for `m > 0`, so `optimize_constrained` is exact.
2879        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
2880        if !is_l1_fallback_trigger(first_status) {
2881            return first_status;
2882        }
2883        // Trigger fired. Flip the wrapper option for the retry and
2884        // restore it after — keeps the user's option-table view of the
2885        // session exactly as they left it.
2886        let prev = self
2887            .options
2888            .get_string_value("l1_exact_penalty_barrier", "")
2889            .ok();
2890        let _ = self
2891            .options
2892            .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
2893        let retry_status = self
2894            .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
2895            .unwrap_or(ApplicationReturnStatus::InternalError);
2896        let _ = self.options.set_string_value(
2897            "l1_exact_penalty_barrier",
2898            prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
2899            true,
2900            false,
2901        );
2902        if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
2903            retry_status
2904        } else {
2905            first_status
2906        }
2907    }
2908
2909    /// μ-strategy auto-fallback driver (pounce#138).
2910    ///
2911    /// Runs the standard solve first. If it stalls short of optimal in a
2912    /// way a μ-strategy flip can plausibly fix — `Solved_To_Acceptable_Level`
2913    /// or `Maximum_Iterations_Exceeded`, the two signatures seen on the
2914    /// princetonlib instances where the dual infeasibility parks above
2915    /// `tol` while constraint violation and complementarity are already
2916    /// deeply converged — it flips `mu_strategy` (adaptive↔monotone) and
2917    /// solves once more. The retry's status is promoted only if it returns
2918    /// `Solve_Succeeded`; otherwise the original status is returned.
2919    ///
2920    /// (maxcut/price stall at acceptable-level under adaptive; fermat2_vareps
2921    /// stalls at `max_iter` — hence both triggers. flosp2tm is μ-independent
2922    /// and correctly does not promote.)
2923    ///
2924    /// The flip direction is taken from the strategy the option table
2925    /// actually resolves to (`effective_mu_strategy_is_adaptive`):
2926    /// `adaptive` → `monotone`, otherwise → `adaptive`. Absence is not
2927    /// the same as `monotone` — under a limited-memory Hessian an unset
2928    /// `mu_strategy` resolves to `adaptive` (gh#746), and flipping the
2929    /// *registered* default there would re-run the strategy that just
2930    /// stalled. The option table is restored to the resolved view
2931    /// afterward.
2932    ///
2933    /// Caveat (shared with the ℓ₁ fallback): the user TNLP's
2934    /// `finalize_solution` runs once per attempt, so when the retry
2935    /// doesn't promote the captured fields hold the retry's iterate.
2936    fn run_with_mu_strategy_fallback(
2937        &mut self,
2938        tnlp: Rc<RefCell<dyn TNLP>>,
2939    ) -> ApplicationReturnStatus {
2940        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
2941        // Which statuses are worth a second solve depends on who asked
2942        // for the retry (pounce#748).
2943        //
2944        // An explicit `mu_strategy_fallback=yes` keeps the historical
2945        // pair: the caller opted in and can afford the second solve.
2946        //
2947        // The *default*-on retry takes `Maximum_Iterations_Exceeded`
2948        // unconditionally, and `Solved_To_Acceptable_Level` only when
2949        // the caller left the convergence configuration alone
2950        // (gh #757). pounce#748 refused the latter status outright, for
2951        // three reasons; two of them are properties of a *caller-
2952        // modified* configuration, not of the status. It launders
2953        // downgrades the caller induced deliberately -- a tight
2954        // `kkt_fidelity_tol`, a certificate veto, `least_square_init_
2955        // primal` -- so the signal the option exists to produce never
2956        // reaches them. And because the retry returns the other run's
2957        // *point*, not just its status, it can hand back a different
2958        // local solution: on `autocorr_bern55-06` with the
2959        // dual-divergence guard on it swaps -2304.0000278 for
2960        // -2320.0000298 (crates/pounce-cli/tests/
2961        // issue_250_dual_guard_never_worse.rs). Both cases -- and all
2962        // five test targets the wide trigger broke -- arm a
2963        // non-default option from `TERMINATION_POLICY_OPTIONS`, so
2964        // deferring to that set preserves every one of them while
2965        // leaving a stock-options stall retryable. The third reason,
2966        // cost, stands and is the price: one extra solve on a run that
2967        // reached only the acceptable tolerance, paid to try for the
2968        // certificate. `cho_parmest` is the motivating case -- monotone
2969        // parks `inf_du` on a ~1e-6 evaluation-noise floor and misses
2970        // `tol` by 5%, taking six null steps of 1e-12 at `mu_min`,
2971        // while adaptive certifies it in 20 iterations.
2972        //
2973        // `dirichlet120`, the case that motivated turning the retry on,
2974        // stalls at `Maximum_Iterations_Exceeded`, so it is recovered
2975        // either way.
2976        let retry_worthy = match first_status {
2977            ApplicationReturnStatus::MaximumIterationsExceeded => true,
2978            ApplicationReturnStatus::SolvedToAcceptableLevel => {
2979                self.mu_strategy_fallback_was_set() || !self.caller_set_termination_policy()
2980            }
2981            _ => false,
2982        };
2983        if !retry_worthy {
2984            return first_status;
2985        }
2986        // gh#857: decline the flip when the solve that just failed escalated
2987        // the factorization and an escalation-off re-solve is enabled.
2988        //
2989        // The mu flip is a *blind* second opinion: it changes the barrier
2990        // schedule and hopes. A `feral_increase_quality` escalation is a
2991        // *measured* fact about the run that just failed -- FERAL reroutes
2992        // which pivots are taken and never steps back down, so every
2993        // iteration after the first escalation, restoration sub-solves
2994        // included, ran on a trajectory the defaults do not describe. Flipping
2995        // `mu_strategy` while leaving that in place is not a controlled
2996        // experiment: it varies the knob that is not implicated and holds the
2997        // one that is.
2998        //
2999        // It is not free either. On `square_flowsheet_resto`'s lbfgs leg the
3000        // flip escalates 25 times all over again, burns a second full
3001        // 3000-iteration budget, and ends no better than the first -- after
3002        // which rung 4 of the second-opinion ladder converges the model in 178
3003        // with the escalation off. That is 6178 real iterations to reach an
3004        // answer that 3178 reach without the flip, and the flip contributes
3005        // nothing to it. Measured both ways: `mu_strategy=adaptive` alone
3006        // still gives 3000 with 25 escalations, and `feral_increase_quality=no`
3007        // gives 178 under *either* mu strategy.
3008        //
3009        // Why decline rather than fold the escalation off into this retry: the
3010        // backend factory is minted from an options snapshot taken by the
3011        // caller *before* `solve()`, so writing `feral_increase_quality` here
3012        // is too late to reach the retry's linear solver. `mu_strategy` is read
3013        // per-solve from the option table and is not; that asymmetry is why
3014        // this layer can only choose whether to spend the solve, not what to
3015        // spend it on.
3016        //
3017        // Restricted to `Maximum_Iterations_Exceeded`, which is exactly the
3018        // status rung 4 opens on. A `Solved_To_Acceptable_Level` exit opens no
3019        // escalation rung, so declining there would drop a retry with nothing
3020        // in its place. Gated on `feral_increase_quality_retry`, so setting
3021        // that option to `no` restores the historical behaviour on both sides
3022        // at once: no rung 4, and no decline here.
3023        //
3024        // The one place the stand-down is not paired with the rung is the
3025        // multi-start paths (`solve_nlp_batch`, the CLI's `minima` search),
3026        // which deliberately do not drive the ladder -- a failed start there is
3027        // routine and extra solves per start multiply. Those paths lose the
3028        // flip on an escalating budget exit and gain nothing back, which is the
3029        // one behaviour change here that is not a strict improvement. It is the
3030        // same trade they already take on every other rung, and for the same
3031        // reason: an escalating capped start is one of many, and doubling its
3032        // cost to re-run the trajectory the escalation governs is the worse end
3033        // of it.
3034        if matches!(
3035            first_status,
3036            ApplicationReturnStatus::MaximumIterationsExceeded
3037        ) && self.quality_escalations.get() >= 1
3038            && self
3039                .options
3040                .get_bool_value("feral_increase_quality_retry", "")
3041                .map(|(v, _found)| v)
3042                .unwrap_or(true)
3043            && self
3044                .options
3045                .get_bool_value("feral_increase_quality", "")
3046                .map(|(v, _found)| v)
3047                .unwrap_or(true)
3048        {
3049            return first_status;
3050        }
3051        // Flip the strategy for one retry. The parser maps "adaptive" →
3052        // Adaptive and every other value (incl. unset) → Monotone, so the
3053        // opposite of an explicit "adaptive" is "monotone" and the
3054        // opposite of anything else is "adaptive".
3055        let prev = self.options.get_string_value("mu_strategy", "").ok();
3056        let was_adaptive = self.effective_mu_strategy_is_adaptive();
3057        let flipped = if was_adaptive { "monotone" } else { "adaptive" };
3058        let _ = self
3059            .options
3060            .set_string_value("mu_strategy", flipped, true, false);
3061        // Floor the *answer*, not just the status (pounce#870).
3062        //
3063        // The promote-only-on-`Solve_Succeeded` rule below has always floored
3064        // the status. It did not floor the point, and the point is what the
3065        // caller consumes: `optimize_constrained` calls the user TNLP's
3066        // `finalize_solution` once per attempt, so a retry that fails to
3067        // promote still overwrites the answer with its own iterate, and the
3068        // statistics with its own residuals. The result is a status describing
3069        // one attempt attached to a point from another.
3070        //
3071        // Measured on a random corpus of 1200 nonconvex models, 20 of them
3072        // (1.7%) returned a materially worse point under an unchanged status,
3073        // the worst flipping sign: a `Maximum_Iterations_Exceeded` exit went
3074        // from -2.38e7 to +7.89e7, and a `Solved_To_Acceptable_Level` one from
3075        // -3.83e7 to +3.41e5 while its reported `final_kkt_error` rose to
3076        // 2.85e-4 — 285x the `acceptable_tol` its own status names, so the
3077        // report contradicted itself. The known example on record understated
3078        // it by three orders: `autocorr_bern55-06` swapping -2304.0000278 for
3079        // -2320.0000298 is the same defect at 0.07%.
3080        //
3081        // This is the floor idiom the rest of the codebase already uses for a
3082        // bet it might lose — `honour_neg_curv_floor` (gh#797),
3083        // `honour_decline_floor` (gh#534), `honour_best_acceptable_after_dual_
3084        // guard` — applied to the one bet that was only half-floored.
3085        //
3086        // Not extended to `run_with_l1_fallback`, which carries the same
3087        // caveat in its doc comment but is NOT the same call: its retry
3088        // deliberately reports the l1-best least-infeasible point, which the
3089        // option help calls informative in its own right. Changing that needs
3090        // its own measurement.
3091        let solution_floor = self.last_finalize.borrow().clone();
3092        let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
3093        let trace_floor = *self.last_iter_stats.borrow();
3094        let retry_status = self.optimize_constrained(Rc::clone(&tnlp));
3095        // Restore the user's original option-table view.
3096        let _ = self.options.set_string_value(
3097            "mu_strategy",
3098            prev.as_ref()
3099                .filter(|(_, found)| *found)
3100                .map(|(v, _)| v.as_str())
3101                .unwrap_or(if was_adaptive { "adaptive" } else { "monotone" }),
3102            true,
3103            false,
3104        );
3105        if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
3106            return retry_status;
3107        }
3108        // The bet lost. Put the first attempt's answer back, so the point and
3109        // the statistics describe the same solve the returned status does.
3110        //
3111        // `finalize_solution` therefore runs once more than the number of
3112        // attempts on this path. That is deliberate, and is the cheaper of the
3113        // two corrections: withholding the retry's `finalize_solution` until it
3114        // is known to promote would deprive a caller that watches the callback
3115        // of the retry's progress, and buys nothing, since the retry's payload
3116        // is discarded either way.
3117        if let Some(floor) = solution_floor {
3118            tracing::debug!(target: "pounce::algorithm",
3119                "[POUNCE] the mu_strategy_fallback retry did not promote \
3120                 ({:?} is not Solve_Succeeded); restoring the first attempt's \
3121                 solution and statistics alongside its status (pounce#870).",
3122                retry_status);
3123            floor.replay(&tnlp);
3124            self.answer_restored_from_floor.set(true);
3125            certificate_floor.restore_into(&mut self.statistics.borrow_mut());
3126            // Third sink: the per-iteration trace. Consumers accumulate that
3127            // themselves from `intermediate_callback` — the CasADi plugin
3128            // pushes into its own vectors and clears once per `nlpsol` call —
3129            // so POUNCE cannot rewind it, and both attempts concatenate into
3130            // one trace. Restoring the certificate without touching the trace
3131            // leaves the reported numbers describing attempt 1 while the trace
3132            // ends on the retry, which breaks the invariant
3133            // `casadi/test_parity.py` states outright: "The final numbers and
3134            // the end of the trace are the same quantities, and must not come
3135            // from two different places."
3136            //
3137            // Re-emitting the winning attempt's final row restores it. It is
3138            // the trace analogue of `FinalizeSnapshot::replay` above, and it
3139            // makes the property hold by construction rather than by hoping a
3140            // consumer resets on an attempt boundary — nothing in the callback
3141            // contract marks one, and gh#634 is what happens when a consumer
3142            // has to guess the scope of a trace.
3143            //
3144            // The row is a real iterate that was already sent once, not a
3145            // synthesized one, so a trace still contains only points the solver
3146            // actually visited.
3147            if let Some(stats) = trace_floor {
3148                let _ = tnlp.borrow_mut().intermediate_callback(
3149                    stats,
3150                    &TnlpIpoptData::default(),
3151                    &TnlpIpoptCq::default(),
3152                );
3153            }
3154        }
3155        first_status
3156    }
3157
3158    /// Is the gh#884 biactive dual-divergence retry enabled? Default
3159    /// `yes`; `dual_divergence_retry=no` is the kill switch, and
3160    /// restores the pre-gh#884 behaviour outright (no detector cost, no
3161    /// second solve, and the base attempt's verdict returned unchanged).
3162    fn is_dual_divergence_retry_enabled(&self) -> bool {
3163        self.options
3164            .get_bool_value("dual_divergence_retry", "")
3165            .map(|(v, _found)| v)
3166            .unwrap_or(true)
3167    }
3168
3169    /// gh#884: throw the iterate away and solve again from scratch with
3170    /// `perturb_always_cd` on, when the base solve settled its primal
3171    /// while its multipliers ran away.
3172    ///
3173    /// # The defect
3174    ///
3175    /// On an MPCC lowered through an exact complementarity product
3176    /// `G·H = 0`, a pair that is **biactive** at the solution — both
3177    /// `G` and `H` zero — leaves that row's gradient
3178    /// `H∇G + G∇H` identically zero. The row is still there, so its
3179    /// multiplier is *arbitrary* rather than nonexistent, and the IPM
3180    /// drives it to infinity while the primal iterate sits on the
3181    /// answer. Because the convergence verdict is reached on an
3182    /// `s_d`-normalised aggregate and `s_d` grows with the mean
3183    /// multiplier magnitude, the aggregate reads clean: MacMPEC's
3184    /// `qpec_small` under `ncp_eq`/`prod_eq` reported
3185    /// `Solved_To_Acceptable_Level` at an *unscaled* dual infeasibility
3186    /// of `7.9e+04`.
3187    ///
3188    /// # Why a retry rather than a gate
3189    ///
3190    /// Four other shapes of fix were measured and rejected — a Hessian
3191    /// sparsity hypothesis, engaging `delta_c` *in flight*, flipping
3192    /// `perturb_always_cd` on globally, and putting a dual ceiling on
3193    /// the acceptable-level gate. `dev-notes/mpcc-biactive-dual-
3194    /// divergence.md` records all four with numbers. The short version:
3195    /// by the time the runaway is visible this iterate is unrecoverable,
3196    /// so the only action left is to *stop using it*; and the remedy
3197    /// that works — `perturb_always_cd=yes` — is measured to return a
3198    /// wrong answer reported as success on `ralph1`
3199    /// (`Solve_Succeeded` at `f = -2.71e-5`, below `f* = 0`), so it
3200    /// cannot be turned on for everyone.
3201    ///
3202    /// **The detector is therefore the entire safety barrier**, and the
3203    /// promotion gate below is the second one. `ralph1` is exactly the
3204    /// model the detector must not fire on, and
3205    /// `IpoptAlgorithm`'s scale-relative step floor is what keeps it
3206    /// from doing so: `qpec_small` settles to `4.3e-8` while `ralph1`
3207    /// bottoms out at `7.2e-3`, five orders apart.
3208    ///
3209    /// # The gate
3210    ///
3211    /// The retry's verdict replaces the base one only when **all** of:
3212    ///
3213    /// 1. the base attempt saw the signature (a converged primal, a step
3214    ///    at zero, and an unscaled `‖∇L‖∞` far above `dual_inf_tol`, all
3215    ///    at one iterate — see `IpoptAlgorithm::dual_divergence_signature`);
3216    /// 2. the base status is `Solved_To_Acceptable_Level` or
3217    ///    `Restoration_Failed` — the two verdicts the vanishing-gradient
3218    ///    row produces directly. Generic exhaustion exits are excluded;
3219    ///    `deb7` under L-BFGS is why, and the reason is in the code
3220    ///    below;
3221    /// 3. the retry returns `Solve_Succeeded`;
3222    /// 4. the retry's claimed success is **real in the model's own
3223    ///    units** — unscaled KKT error at or below `tol`-scale, which is
3224    ///    the property the base attempt failed and the whole point of
3225    ///    the issue;
3226    /// 5. the retry's unscaled KKT error is *strictly better* than the
3227    ///    base attempt's.
3228    ///
3229    /// Otherwise the base attempt's status, point and statistics are all
3230    /// put back, by the same three-sink floor
3231    /// [`Self::run_with_mu_strategy_fallback`] uses and for the same
3232    /// reason (pounce#870): a status describing one attempt attached to
3233    /// a point from another is worse than either.
3234    fn run_with_dual_divergence_retry(
3235        &mut self,
3236        tnlp: Rc<RefCell<dyn TNLP>>,
3237    ) -> ApplicationReturnStatus {
3238        let first_status = self.dispatch_standard_solve(Rc::clone(&tnlp));
3239        if !self.dual_divergence_signature.get() {
3240            return first_status;
3241        }
3242        // Which base verdicts this remedy is *for*.
3243        //
3244        // Two, and the narrowing was bought with a measurement. The
3245        // detector is a statement about the iterate; it is not a
3246        // statement that `perturb_always_cd` will help. `deb7` under
3247        // L-BFGS is the corpus case that separates the two: the
3248        // signature is real there — iteration 346, scale-relative step
3249        // `6.5e-6`, `inf_pr` `3.0e-12`, unscaled `inf_du` `9.2e5`,
3250        // which is an order *above* the gh#884 reproducer's `7.9e4`, so
3251        // no dual floor excludes it, and the step conjunct separates the
3252        // two only by fitting the default onto one fixture and spending
3253        // the margin that holds `ralph1` out — and the retry still does
3254        // not work: 3000 iterations to
3255        // `Maximum_Iterations_Exceeded` at an unscaled KKT error of
3256        // `6.7e1` against the base attempt's `9.9e1`. Pure cost, 4x the
3257        // base trajectory, on a fixture whose verdict does not move.
3258        //
3259        // The exclusion is by *status*, so it is only as complete as the
3260        // status is stable, and on this very fixture it is not. `deb7`
3261        // reaches `Error_In_Step_Computation` at default options and is
3262        // out; under `limited_memory_ls_failure_restarts=1` (gh#818's
3263        // rung, off by default) it reaches `Restoration_Failed` instead
3264        // and is therefore *in*. It used to pay exactly the cost above
3265        // there — 6.1 s to 25.2 s wall clock for the same
3266        // `Restoration_Failed` verdict and a declined retry, which is
3267        // gh#887. That is the price of scoping by status rather than by
3268        // model, and it is why the second gate below scopes by the
3269        // *answer* instead: `deb7` now declines before spending
3270        // anything. The status scope is kept because it is cheap and
3271        // reads on the verdict a caller sees, but it is not what is
3272        // being relied on.
3273        //
3274        // What separates them is the *status*, and it separates them
3275        // for a reason rather than by luck:
3276        //
3277        //  * `Solved_To_Acceptable_Level` is gh#884 verbatim — the
3278        //    runaway laundered itself through `s_d` into a success.
3279        //  * `Restoration_Failed` is the same defect one step earlier:
3280        //    the rows whose gradients vanished drive the solve into
3281        //    restoration, and restoration cannot repair a row that has
3282        //    no gradient. It is where the `qpec_small` TNLP fixture
3283        //    lands (unscaled KKT `3.3e11`).
3284        //  * `Error_In_Step_Computation` and
3285        //    `Maximum_Iterations_Exceeded` are generic exhaustion
3286        //    exits. Every hard model reaches them, for every reason.
3287        //    Retrying *those* is not "repair the runaway", it is "try
3288        //    again harder" — which is what `mu_strategy_fallback` and
3289        //    the second-opinion ladder already are, and `deb7` is what
3290        //    that costs.
3291        //
3292        // `Solve_Succeeded` is excluded because it is already the best
3293        // verdict available and its certificate has already been
3294        // checked in the model's own units; there is nothing to buy.
3295        //
3296        // Worst-case cost is therefore one extra solve under the
3297        // caller's own `max_iter` — the same contract
3298        // `run_with_mu_strategy_fallback` already has.
3299        //
3300        // Deliberately **not** deferred to `TERMINATION_POLICY_OPTIONS`
3301        // the way the μ flip's acceptable-level trigger is (gh#757).
3302        // That deferral exists because the μ flip returns a different
3303        // *local solution*, so laundering a caller's deliberate
3304        // downgrade loses a signal they asked for. This retry cannot:
3305        // conjunct 4 requires the promoted answer to satisfy the KKT
3306        // conditions in the model's own units, which a downgrade the
3307        // caller induced on purpose does not. And the gh#884 reproducer
3308        // sets `tol=1e-8` explicitly, so deferring would decline the
3309        // retry on the one case the issue is about.
3310        let retry_worthy = matches!(
3311            first_status,
3312            ApplicationReturnStatus::SolvedToAcceptableLevel
3313                | ApplicationReturnStatus::RestorationFailed
3314        );
3315        if !retry_worthy {
3316            return first_status;
3317        }
3318        let base_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
3319        // The runaway has to be the *whole* residual of the answer being
3320        // reported.
3321        //
3322        // The detector is a statement about an *iterate*, and the iterate
3323        // it fires on need not be the one the solve ends at. A run can
3324        // pass through a settled point with a diverged multiplier, work
3325        // its way back down, and report something ordinary — and then
3326        // there is nothing left here for `perturb_always_cd` to repair,
3327        // whatever the trajectory did in the middle. This is what makes
3328        // "one extra solve" a cost the caller only pays on a run that
3329        // still *exhibits* the defect (gh#887).
3330        //
3331        // The test is `runaway_is_the_whole_residual`, which reads only
3332        // the reported answer and only as a ratio within it; its doc
3333        // comment carries the rule and the measured populations.
3334        let (base_viol, base_compl) = {
3335            let st = self.statistics.borrow();
3336            (st.final_unscaled_constr_viol, st.final_unscaled_compl)
3337        };
3338        let base_dual_inf = self.statistics.borrow().final_unscaled_dual_inf;
3339        if !runaway_is_the_whole_residual(
3340            base_dual_inf,
3341            base_viol,
3342            base_compl,
3343            self.options
3344                .get_numeric_value("dual_divergence_retry_du_floor", "")
3345                .map(|(v, _)| v)
3346                .unwrap_or(DUAL_DIV_RETRY_DU_FLOOR),
3347        ) {
3348            tracing::debug!(target: "pounce::algorithm",
3349                "[POUNCE] gh#884: the signature fired mid-trajectory, but the \
3350                 answer being reported is not a converged point with a runaway \
3351                 multiplier — unscaled dual {:.3e} against viol {:.3e} and \
3352                 complementarity {:.3e}. Nothing here for perturb_always_cd to \
3353                 repair, so no retry (gh#887).",
3354                base_dual_inf, base_viol, base_compl);
3355            return first_status;
3356        }
3357        // Floor all three sinks — solution payload, certificate, and the
3358        // last trace row — exactly as the μ fallback does (pounce#870).
3359        let solution_floor = self.last_finalize.borrow().clone();
3360        let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
3361        let trace_floor = *self.last_iter_stats.borrow();
3362        tracing::debug!(target: "pounce::algorithm",
3363            "[POUNCE] gh#884: the primal settled while the multipliers ran away \
3364             (base {:?}, unscaled KKT {:.3e}); re-solving from scratch with \
3365             perturb_always_cd=yes.",
3366            first_status, base_unscaled_kkt);
3367        let prev = self.options.get_string_value("perturb_always_cd", "").ok();
3368        let _ = self
3369            .options
3370            .set_string_value("perturb_always_cd", "yes", true, false);
3371        let retry_status = self.dispatch_standard_solve(Rc::clone(&tnlp));
3372        // Restore the caller's option-table view. Absence is restored as
3373        // absence would be seen: the registered default is `no`.
3374        let _ = self.options.set_string_value(
3375            "perturb_always_cd",
3376            prev.as_ref()
3377                .filter(|(_, found)| *found)
3378                .map(|(v, _)| v.as_str())
3379                .unwrap_or("no"),
3380            true,
3381            false,
3382        );
3383        let retry_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
3384        let retry_viol = self.statistics.borrow().final_unscaled_constr_viol;
3385        // Conjuncts 3, 4 and 5. Conjunct 4 is the one that distinguishes
3386        // this gate from every other promote-on-`Solve_Succeeded` retry
3387        // in this file: the base attempt's defect *was* a status that its
3388        // own unscaled residual contradicts, so promoting on the status
3389        // alone would reproduce the bug one attempt later.
3390        let claimed_success_is_real = retry_unscaled_kkt <= self.dual_divergence_retry_accept_tol()
3391            && retry_viol <= self.dual_divergence_retry_accept_tol();
3392        // Conjuncts 6 and 7 — see `retry_answer_is_admissible`. Everything
3393        // above this line ranks the two attempts on their *certificates*;
3394        // this ranks them as *answers*, which is what a caller receives.
3395        // Without it a better multiplier is allowed to buy a worse point:
3396        // measured, `-13.0057 -> -1.2072` on a random QPEC, and
3397        // `+1.82e-09 -> -6.61e-05` on `scholtes4`, whose `f*` is exactly 0.
3398        let retry_obj = self.statistics.borrow().final_objective;
3399        // `obj_scaling_factor < 0` is how a maximization is posed, and
3400        // `final_objective` is the user's signed objective, so the
3401        // comparison direction has to follow it (R2).
3402        let sense = if self
3403            .options
3404            .get_numeric_value("obj_scaling_factor", "")
3405            .map(|(v, _)| v)
3406            .unwrap_or(1.0)
3407            < 0.0
3408        {
3409            -1.0
3410        } else {
3411            1.0
3412        };
3413        let answer_is_admissible = retry_answer_is_admissible(
3414            certificate_floor.objective,
3415            certificate_floor.unscaled_constr_viol,
3416            retry_obj,
3417            retry_viol,
3418            self.dual_divergence_retry_accept_tol(),
3419            sense,
3420        );
3421        let promote = matches!(retry_status, ApplicationReturnStatus::SolveSucceeded)
3422            && claimed_success_is_real
3423            && retry_unscaled_kkt < base_unscaled_kkt
3424            && answer_is_admissible;
3425        // Say on the console which of the two answers shipped.
3426        //
3427        // The per-attempt end summary cannot: `emit_end_summary` runs
3428        // inside `optimize_constrained`, once per attempt, and the
3429        // promotion is decided after the last of them. So a summary that
3430        // reported the promotion would report `false` on the very run that
3431        // promotes, contradicting the JSON report written from the same
3432        // statistics. The summary reports the *signature*, which is true
3433        // when it prints; this line reports the *outcome*, printed once,
3434        // where it is known.
3435        //
3436        // Gated on `print_level >= 1`, matching `emit_end_summary` — the
3437        // block this line follows.
3438        let console_output = match self.options.get_integer_value("print_level", "") {
3439            Ok((v, true)) => v >= 1,
3440            _ => true,
3441        };
3442        if console_output {
3443            println!();
3444            if promote {
3445                println!(
3446                    "gh#884 dual-divergence retry: promoted — unscaled KKT error \
3447                     {base_unscaled_kkt:.4e} -> {retry_unscaled_kkt:.4e}."
3448                );
3449            } else if !answer_is_admissible {
3450                // A distinct line, because this decline looks like a
3451                // contradiction otherwise: the retry converged, its
3452                // certificate is clean, and it was still refused. Say
3453                // which of the two rules refused it and on what numbers,
3454                // so the reader is not left comparing KKT errors that
3455                // had nothing to do with it.
3456                println!(
3457                    "gh#884 dual-divergence retry: declined on the ANSWER, not the \
3458                     certificate — the retry converged (unscaled KKT error \
3459                     {retry_unscaled_kkt:.4e} against the base attempt's \
3460                     {base_unscaled_kkt:.4e}) but its objective {retry_obj:.8e} at \
3461                     constraint violation {retry_viol:.4e} is not admissible next to \
3462                     the base attempt's {:.8e} at {:.4e}; the base attempt's answer \
3463                     is the one reported.",
3464                    certificate_floor.objective, certificate_floor.unscaled_constr_viol
3465                );
3466            } else {
3467                println!(
3468                    "gh#884 dual-divergence retry: declined ({retry_status:?}, \
3469                     unscaled KKT error {retry_unscaled_kkt:.4e} against the base \
3470                     attempt's {base_unscaled_kkt:.4e}); the base attempt's answer \
3471                     is the one reported."
3472                );
3473            }
3474        }
3475        if promote {
3476            self.dual_divergence_retry_promoted.set(true);
3477            self.statistics.borrow_mut().dual_divergence_retry_promoted = true;
3478            tracing::debug!(target: "pounce::algorithm",
3479                "[POUNCE] gh#884: the retry promoted — unscaled KKT {:.3e} \
3480                 (base {:.3e}).",
3481                retry_unscaled_kkt, base_unscaled_kkt);
3482            return retry_status;
3483        }
3484        if let Some(floor) = solution_floor {
3485            tracing::debug!(target: "pounce::algorithm",
3486                "[POUNCE] gh#884: the retry did not promote ({:?}, unscaled KKT \
3487                 {:.3e} vs base {:.3e}); restoring the first attempt's solution \
3488                 and statistics alongside its status.",
3489                retry_status, retry_unscaled_kkt, base_unscaled_kkt);
3490            floor.replay(&tnlp);
3491            self.answer_restored_from_floor.set(true);
3492            certificate_floor.restore_into(&mut self.statistics.borrow_mut());
3493            // The signature belongs to the attempt whose numbers are now
3494            // reported, and `certificate_floor` does not carry it.
3495            self.statistics.borrow_mut().dual_divergence_signature = true;
3496            if let Some(stats) = trace_floor {
3497                let _ = tnlp.borrow_mut().intermediate_callback(
3498                    stats,
3499                    &TnlpIpoptData::default(),
3500                    &TnlpIpoptCq::default(),
3501                );
3502            }
3503        }
3504        first_status
3505    }
3506
3507    /// The tolerance conjunct 4 of the dual-divergence promotion gate
3508    /// tests the retry's *unscaled* residuals against.
3509    ///
3510    /// `acceptable_tol`-scale rather than `tol`-scale on purpose: the
3511    /// unscaled residual is the one quantity nothing in the solve is
3512    /// driven against, so holding it to `tol` would decline honest
3513    /// retries on badly scaled models for a reason that has nothing to
3514    /// do with gh#884. `qpec_small`'s honest retry reaches `9.96e-8`,
3515    /// four orders inside it.
3516    fn dual_divergence_retry_accept_tol(&self) -> Number {
3517        self.options
3518            .get_numeric_value("acceptable_tol", "")
3519            .ok()
3520            .and_then(|(v, f)| f.then_some(v))
3521            .unwrap_or(1e-6)
3522    }
3523
3524    /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
3525    ///
3526    /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
3527    /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
3528    /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
3529    ///   - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
3530    ///   - inner solve returns non-Optimal (escalation won't fix
3531    ///     numerical / restoration failure at this ρ)
3532    ///   - ρ already at `l1_penalty_max`
3533    ///   - `l1_penalty_max_outer_iter` reached
3534    ///
3535    /// After the loop, if the inner status is `SolveSucceeded` or
3536    /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
3537    /// to `Infeasible_Problem_Detected` — the returned point is the
3538    /// ℓ₁-best least-infeasible iterate, which is informative even
3539    /// though the original constraints are not satisfied.
3540    ///
3541    /// Returns `Some(status)` if the wrapper ran the solve, `None` if
3542    /// wrapper construction failed (caller should fall through to the
3543    /// standard dispatch path).
3544    fn run_l1_penalty_outer_loop(
3545        &mut self,
3546        tnlp: Rc<RefCell<dyn TNLP>>,
3547    ) -> Option<ApplicationReturnStatus> {
3548        let rho_init = self.l1_penalty_init();
3549        let rho_max = self.l1_penalty_max().max(rho_init);
3550        let factor = self.l1_penalty_increase_factor().max(1.0);
3551        let tau = self.l1_steering_factor();
3552        let slack_tol = self.l1_slack_tol();
3553        let max_outer = self.l1_penalty_max_outer_iter().max(1);
3554
3555        let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
3556        if wrapper.m_eq() == 0 {
3557            // Nothing to slack — let the standard dispatch path handle
3558            // this TNLP unmodified.
3559            return None;
3560        }
3561        wrapper.set_defer_inner_finalize(true);
3562        let wrapper_rc = Rc::new(RefCell::new(wrapper));
3563
3564        let mut rho = rho_init;
3565        let mut last_status = ApplicationReturnStatus::InternalError;
3566        for _outer in 0..max_outer {
3567            wrapper_rc.borrow_mut().set_rho(rho);
3568            let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
3569            last_status = self.optimize_constrained(dyn_tnlp);
3570
3571            let w = wrapper_rc.borrow();
3572            if !w.has_solution() {
3573                // Inner solve aborted before producing an iterate.
3574                drop(w);
3575                break;
3576            }
3577            let slack_sum = w.last_slack_sum();
3578            let y_eq_inf = w.last_y_eq_inf_norm();
3579            let x_here: Vec<Number> = w.last_x_trunc().to_vec();
3580            drop(w);
3581
3582            // Termination decisions.
3583            let inner_ok = matches!(
3584                last_status,
3585                ApplicationReturnStatus::SolveSucceeded
3586                    | ApplicationReturnStatus::SolvedToAcceptableLevel
3587            );
3588            if !inner_ok {
3589                break;
3590            }
3591            // Stop escalating ρ once the **user's** constraints are
3592            // satisfied to the tolerance the caller asked for — not once
3593            // `Σ(p + n)` falls under `l1_slack_tol`, which is a different
3594            // quantity judged by a different number (gh#794 P1). The
3595            // slack sum stays the BNW steering signal below, which is
3596            // the job it is right for. One extra `eval_g` per outer
3597            // iteration buys the difference between stopping at the
3598            // penalty solution and stopping at the model's own.
3599            let feasible_here = {
3600                let m_inner = tnlp
3601                    .borrow_mut()
3602                    .get_nlp_info()
3603                    .map(|i| i.m.max(0) as usize)
3604                    .unwrap_or(0);
3605                let mut g_here = vec![0.0; m_inner];
3606                let evaluated =
3607                    m_inner == 0 || tnlp.borrow_mut().eval_g(&x_here, true, &mut g_here);
3608                evaluated
3609                    .then(|| {
3610                        original_space_feasibility(
3611                            &tnlp,
3612                            &x_here,
3613                            &g_here,
3614                            self.nlp_lower_bound_inf(),
3615                            self.nlp_upper_bound_inf(),
3616                            self.user_tol(),
3617                            self.user_acceptable_tol(),
3618                            self.user_constr_viol_tol(),
3619                            self.user_acceptable_constr_viol_tol(),
3620                            self.user_primal_noise_floor_kappa(),
3621                        )
3622                    })
3623                    .flatten()
3624                    .map(|f| f.negligible_at_tol)
3625            };
3626            match feasible_here {
3627                Some(true) => break,
3628                Some(false) => {}
3629                // Unmeasurable model: fall back to the historical
3630                // slack-sum test rather than looping to the cap.
3631                None if slack_sum.is_finite() && slack_sum <= slack_tol => break,
3632                None => {}
3633            }
3634            if rho >= rho_max {
3635                break;
3636            }
3637            // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
3638            let geom = rho * factor;
3639            let steer = tau * y_eq_inf + 1.0e-12;
3640            rho = geom.max(steer).min(rho_max);
3641        }
3642
3643        // Forward to the user's inner.finalize_solution exactly once.
3644        let w = wrapper_rc.borrow();
3645        if w.has_solution() {
3646            let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
3647            let lambda: Vec<Number> = w.last_lambda().to_vec();
3648            let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
3649            let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
3650            let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
3651            let slack_sum = w.last_slack_sum();
3652            drop(w);
3653
3654            // Recompute f(x*) and c(x*) on the inner. Both are needed
3655            // before the status is decided, because the status now turns
3656            // on the *original-space* feasibility at this point.
3657            let f_inner = tnlp
3658                .borrow_mut()
3659                .eval_f(&x_trunc, true)
3660                .unwrap_or(Number::NAN);
3661            let m = tnlp
3662                .borrow_mut()
3663                .get_nlp_info()
3664                .map(|i| i.m as usize)
3665                .unwrap_or(0);
3666            // The success flag decides whether `g_inner` is a measurement
3667            // or a zero-filled buffer. Dropping it would let a TNLP whose
3668            // final `eval_g` fails fabricate feasibility: every row would
3669            // read `0`, `original_space_feasibility` would return a
3670            // violation of zero, and that would flow into both the exit
3671            // status and the reported residuals. Gate it exactly as the
3672            // ρ-escalation measurement above does, so an evaluation
3673            // failure produces `None` and follows the documented
3674            // `l1_slack_tol` fallback instead (gh#794 review).
3675            let mut g_inner = vec![0.0; m];
3676            let g_evaluated = m == 0 || tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
3677
3678            // gh#794 P1. Everything below used to argue from `Σ(p + n)`,
3679            // the sum of the augmented slacks, judged against
3680            // `l1_slack_tol`. That is not the user's constraint
3681            // violation and it is not judged by the user's tolerance:
3682            //
3683            //   * the violation of equality row `i` is `|p_i − n_i|`,
3684            //     not `p_i + n_i`, and at the barrier's interior both
3685            //     slacks stay positive where their difference is zero,
3686            //     so the sum is an upper bound that is loose in one
3687            //     direction; and
3688            //   * `l1_slack_tol` defaults to `1e-6`, four orders looser
3689            //     than a `tol = 1e-8` solve asked for, so a violation
3690            //     that the solver's own strict gate would refuse on the
3691            //     unwrapped problem read as "the constraints are
3692            //     satisfied".
3693            //
3694            // Measured, not argued: the MPCC benchmark's `ralph1`
3695            // (`benchmarks/mpcc/`) returned `Solve_Succeeded` at a point
3696            // violating its one equality row by `2.5e-07`, with the
3697            // reported `final_constr_viol` — the *augmented* residual —
3698            // at `9.6e-15`, so no field in the result disclosed it. The
3699            // objective came back `5.0e-04` below the true optimum,
3700            // which is reachable only off the feasible set.
3701            //
3702            // So: measure the user's own rows at the returned point, and
3703            // judge them by the tolerances the caller set. The slack sum
3704            // keeps its other job unchanged — it is the BNW steering
3705            // signal for ρ escalation inside the loop above, which is
3706            // what it is the right quantity for.
3707            let feas = g_evaluated
3708                .then(|| {
3709                    original_space_feasibility(
3710                        &tnlp,
3711                        &x_trunc,
3712                        &g_inner,
3713                        self.nlp_lower_bound_inf(),
3714                        self.nlp_upper_bound_inf(),
3715                        self.user_tol(),
3716                        self.user_acceptable_tol(),
3717                        self.user_constr_viol_tol(),
3718                        self.user_acceptable_constr_viol_tol(),
3719                        self.user_primal_noise_floor_kappa(),
3720                    )
3721                })
3722                .flatten();
3723
3724            // The reported constraint violation must be the user's, not
3725            // the augmented problem's. Without this the KKT block of a
3726            // successful ℓ₁ solve describes a problem the caller never
3727            // posed. The aggregate errors take a `max` rather than a
3728            // rewrite: the NLP error's primal term enters undivided, so
3729            // the aggregate is never below the constraint violation, and
3730            // the other two components are unaffected by the wrapper.
3731            //
3732            // `f.max_violation` is measured on the inner TNLP's own rows
3733            // and bounds, so it is in the model's **original units**. That
3734            // decides which field family may carry it. `SolveStatistics`
3735            // documents `final_*` as the max-norms in the internally
3736            // scaled NLP space and `final_unscaled_*` as the same
3737            // residuals with the scaling divided back out — equal only
3738            // when no scaling is active — and `docs/src/python.md` states
3739            // the same contract to Python callers. Writing an
3740            // original-units number into `final_constr_viol` would break
3741            // it on any run with `nlp_scaling_method` engaged (gh#794
3742            // review).
3743            //
3744            // So the unscaled family always takes the measurement, and
3745            // the scaled family mirrors it exactly when per-row scaling
3746            // did not engage — the case in which the contract requires
3747            // the two to agree anyway. Under active row scaling the
3748            // scaled fields keep what the inner solve reported; the
3749            // converted number is not available here, because the
3750            // augmented problem's row-scale factors belong to an NLP that
3751            // `optimize_constrained` has already dropped. The status
3752            // decision below does not read these fields — it reads
3753            // `feas` directly — so an active-scaling run is judged on the
3754            // user's rows either way.
3755            if let Some(f) = feas.as_ref() {
3756                let scaled_may_mirror = self.row_scaling_active.get() == Some(false);
3757                let mut stats = self.statistics.borrow_mut();
3758                stats.final_unscaled_constr_viol = f.max_violation;
3759                stats.final_unscaled_kkt_error =
3760                    stats.final_unscaled_kkt_error.max(f.max_violation);
3761                if scaled_may_mirror {
3762                    stats.final_constr_viol = f.max_violation;
3763                    stats.final_kkt_error = stats.final_kkt_error.max(f.max_violation);
3764                    stats.final_kkt_error_above_noise =
3765                        stats.final_kkt_error_above_noise.max(f.max_violation);
3766                }
3767            }
3768
3769            let inner_claimed_success = matches!(
3770                last_status,
3771                ApplicationReturnStatus::SolveSucceeded
3772                    | ApplicationReturnStatus::SolvedToAcceptableLevel
3773            );
3774
3775            // Downgrade, not upgrade: a solve that reached the strict
3776            // standard on the user's own rows keeps whatever status the
3777            // inner gave it, and nothing here can turn a failure into a
3778            // success.
3779            let downgrade_to_acceptable = inner_claimed_success
3780                && feas
3781                    .as_ref()
3782                    .is_some_and(|f| !f.negligible_at_tol && f.negligible_at_acceptable);
3783
3784            let infeasible_certificate = inner_claimed_success
3785                && match feas.as_ref() {
3786                    // Measured: the point does not satisfy the user's
3787                    // constraints even to `acceptable_tol`.
3788                    Some(f) => !f.negligible_at_acceptable,
3789                    // Unmeasurable model — fall back to the historical
3790                    // slack-sum argument rather than to silence.
3791                    None => slack_sum.is_finite() && slack_sum > slack_tol,
3792                };
3793
3794            if let Some(f) = feas.as_ref()
3795                && inner_claimed_success
3796                && !f.negligible_at_tol
3797            {
3798                tracing::info!(
3799                    target: "pounce::application",
3800                    "l1 penalty-barrier: the inner solve converged the augmented NLP, \
3801                     but the returned point violates the model's own constraints by \
3802                     {:.3e}, which does not meet tol; reporting {} rather than success \
3803                     (gh#794)",
3804                    f.max_violation,
3805                    if f.negligible_at_acceptable {
3806                        "Solved_To_Acceptable_Level"
3807                    } else {
3808                        "an infeasibility verdict"
3809                    },
3810                );
3811            }
3812            // …unless the model's own starting point satisfies every
3813            // constraint, which disproves the certificate outright (gh #379).
3814            // Same gate as the IPM and SQP paths; see
3815            // `withdraw_infeasibility_if_refuted`.
3816            let refuted = infeasible_certificate
3817                && withdraw_infeasibility_if_refuted(
3818                    &tnlp,
3819                    SolverReturn::LocalInfeasibility,
3820                    self.nlp_lower_bound_inf(),
3821                    self.nlp_upper_bound_inf(),
3822                    self.user_tol(),
3823                ) != SolverReturn::LocalInfeasibility;
3824            let final_solver_status = match (infeasible_certificate, refuted) {
3825                (true, false) => SolverReturn::LocalInfeasibility,
3826                // The point is not feasible, so `Solve_Succeeded` would be
3827                // just as wrong as `Infeasible_Problem_Detected`. Report the
3828                // breakdown.
3829                (true, true) => SolverReturn::ErrorInStepComputation,
3830                (false, _) if downgrade_to_acceptable => SolverReturn::StopAtAcceptablePoint,
3831                (false, _) => solver_status,
3832            };
3833            let final_app_status = match (infeasible_certificate, refuted) {
3834                (true, false) => ApplicationReturnStatus::InfeasibleProblemDetected,
3835                (true, true) => ApplicationReturnStatus::ErrorInStepComputation,
3836                (false, _) if downgrade_to_acceptable => {
3837                    ApplicationReturnStatus::SolvedToAcceptableLevel
3838                }
3839                (false, _) => last_status,
3840            };
3841
3842            tnlp.borrow_mut().finalize_solution(
3843                Solution {
3844                    status: final_solver_status,
3845                    x: &x_trunc,
3846                    z_l: &z_l,
3847                    z_u: &z_u,
3848                    g: &g_inner,
3849                    lambda: &lambda,
3850                    obj_value: f_inner,
3851                },
3852                &TnlpIpoptData::default(),
3853                &TnlpIpoptCq::default(),
3854            );
3855            return Some(final_app_status);
3856        }
3857        // No solution captured at all — pass the inner status through.
3858        Some(last_status)
3859    }
3860
3861    /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
3862    /// bundle, run `optimize`, populate statistics, and call
3863    /// `finalize_solution` on the user's TNLP.
3864    /// Whether an over-determined model (more equality rows than free
3865    /// variables) is *provably* infeasible by linear bound propagation.
3866    ///
3867    /// Consulted only on the `NotEnoughDegreesOfFreedom` failure path, where
3868    /// the solve never runs and therefore can never itself discover the
3869    /// infeasibility (gh#387). Builds a throwaway presolve wrapper with only
3870    /// Phase 1 (bound tightening) enabled and asks it for a certified proof —
3871    /// this inherits the certification safety net wholesale: the crossing must
3872    /// exceed the solver's own acceptance margin at the crossed pair's scale,
3873    /// and a concrete witness point satisfying every constraint withdraws the
3874    /// verdict. A `false` here costs nothing but keeping the DOF error.
3875    ///
3876    /// The witness gate runs under [`pounce_presolve::WitnessRule`]'s
3877    /// `DeclaredRowRelative` form, which is admissible only because the solve
3878    /// cannot run on this path (gh#391) — see the comment on the probe below.
3879    ///
3880    /// Deliberately independent of the `presolve` master switch: this is not a
3881    /// model transformation (the wrapper is dropped without solving through
3882    /// it), it is a last check before reporting a structural error for a
3883    /// problem whose verdict is already decided.
3884    fn overdetermined_model_certified_infeasible(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> bool {
3885        let mut opts = pounce_presolve::PresolveOptions::from_options_list(&self.options)
3886            .unwrap_or_else(|_| pounce_presolve::PresolveOptions::defaults());
3887        opts.enabled = true;
3888        opts.bound_tightening = true;
3889        // Certification needs Phase 1 only; every transformative or
3890        // diagnostic phase is dead weight on a wrapper that is never
3891        // solved through.
3892        opts.auxiliary = false;
3893        opts.fbbt = false;
3894        opts.redundant_constraint_removal = false;
3895        opts.licq_check = false;
3896        opts.warm_z_bounds = false;
3897        // The one place the witness rule is raised off the solver's own
3898        // acceptance test (gh#391). It is sound *here specifically* because the
3899        // gate has already established the solve cannot run: the alternative to
3900        // the proof is the structural 5xx error, never `Solve_Succeeded`, so
3901        // the #380 "two routes, two answers" contradiction the clamp exists to
3902        // prevent has no second route to contradict. See `WitnessRule` for the
3903        // full argument and the homogeneous-row fallback.
3904        let mut probe =
3905            pounce_presolve::PresolveTnlp::new(Rc::clone(tnlp), opts).probing_without_a_solve();
3906        if probe.get_nlp_info().is_none() {
3907            return false;
3908        }
3909        probe.certified_infeasible().is_some()
3910    }
3911
3912    fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
3913        let t_start = Instant::now();
3914
3915        // Invalidate the row-scaling record before anything can read it.
3916        //
3917        // It is written near the end of this function, from the NLP the
3918        // solve actually built, so a solve that bails before that point
3919        // leaves whatever the *previous* one recorded. The ℓ₁ outer loop
3920        // calls this repeatedly and reads the flag after each call, so a
3921        // stale `Some(false)` would let it mirror an original-units
3922        // violation into the scaled family — the exact contract this flag
3923        // exists to protect (gh#794 review round 2). Clearing it here
3924        // makes the failure mode fail-closed: "not recorded" reads as
3925        // "cannot mirror", never as "scaling was off".
3926        self.row_scaling_active.set(None);
3927
3928        // `print_user_options yes` — dump the OptionsList before the
3929        // solve. Mirrors `IpoptApplication::call_optimize` (upstream
3930        // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
3931        let print_opts = self
3932            .options
3933            .get_bool_value("print_user_options", "")
3934            .ok()
3935            .and_then(|(v, f)| f.then_some(v))
3936            .unwrap_or(false);
3937        if print_opts {
3938            print!(
3939                "\nList of user-set options:\n\n{}",
3940                self.options.print_user_options()
3941            );
3942        }
3943
3944        // `print_options_documentation yes` — dump the full registry
3945        // (every option with type, default, valid range/strings, and
3946        // long description) before the solve. Honors
3947        // `print_options_mode` (`text` / `latex` / `doxygen`; only
3948        // `text` is implemented today, the others fall through with a
3949        // one-line note) and `print_advanced_options`. Mirrors
3950        // upstream `IpoptApplication::call_optimize`'s
3951        // `print_options_documentation` branch and `Common/IpRegOptions.cpp`
3952        // `OutputOptionDocumentation`.
3953        let print_doc = self
3954            .options
3955            .get_bool_value("print_options_documentation", "")
3956            .ok()
3957            .and_then(|(v, f)| f.then_some(v))
3958            .unwrap_or(false);
3959        if print_doc {
3960            let mode = self
3961                .options
3962                .get_string_value("print_options_mode", "")
3963                .ok()
3964                .map(|(v, _)| PrintOptionsMode::from_tag(&v))
3965                .unwrap_or(PrintOptionsMode::Text);
3966            let advanced = self
3967                .options
3968                .get_bool_value("print_advanced_options", "")
3969                .ok()
3970                .map(|(v, _)| v)
3971                .unwrap_or(false);
3972            print!(
3973                "\n# Pounce options registry\n\n{}",
3974                self.reg_options.print_options_documentation(mode, advanced)
3975            );
3976        }
3977
3978        // Mint a fresh `TimingStatistics` for this solve — shared (via
3979        // `Rc`) with the data and the NLP below so every `eval_*` and
3980        // every iterate-phase records into the same accumulator. The
3981        // application keeps its own `Rc` so callers can read totals out
3982        // via [`Self::timing_stats`].
3983        let timing = Rc::new(TimingStatistics::new());
3984        *self.timing.borrow_mut() = Rc::clone(&timing);
3985        // gh#606: same lifetime as the timings — a solve that bails out
3986        // before the initializer runs must not report the previous
3987        // solve's warm-start verdict.
3988        *self.warm_start_diag.borrow_mut() = None;
3989        // Gate the *detailed* per-subsystem timers on `timing_statistics`
3990        // (default "no"), matching upstream Ipopt. Without this, every
3991        // timed `eval_*` / phase section pays two `getrusage` syscalls per
3992        // start/end even when statistics are off — 16-20% of busy CPU on
3993        // fast-objective NLPs (issue #190). `print_timing_statistics=yes`
3994        // implies `timing_statistics=yes` (per its option help), so either
3995        // one enables the detailed timers. `overall_alg` is started
3996        // unconditionally below: it feeds the `max_cpu_time` check and is
3997        // reported regardless of the option.
3998        //
3999        // Each name is read as a literal rather than looped over an
4000        // array: the registered-but-unread scan
4001        // (`tests/no_silent_options.rs`) keys on the option name as it
4002        // appears at the accessor, so a loop variable reads as "no key
4003        // here" and hid `timing_statistics` among the silent options
4004        // when it has been wired since #190 (#677, #551).
4005        let read_yes = |key: &str| -> bool {
4006            self.options
4007                .get_bool_value(key, "")
4008                .ok()
4009                .and_then(|(v, f)| f.then_some(v))
4010                .unwrap_or(false)
4011        };
4012        let timing_enabled = read_yes("timing_statistics") || read_yes("print_timing_statistics");
4013        timing.set_detailed_enabled(timing_enabled);
4014        timing.overall_alg.start();
4015
4016        // Reset the linear-solver summary sink so back-to-back solves
4017        // don't bleed factor counters / extremal pivots into each
4018        // other. Surviving the lock failure with a debug-assert keeps
4019        // a poisoned mutex from sinking a release build that doesn't
4020        // even consume the summary.
4021        match self.linsol_summary_sink.lock() {
4022            Ok(mut guard) => {
4023                *guard = LinearSolverSummary::default();
4024            }
4025            _ => {
4026                debug_assert!(false, "linsol summary sink mutex poisoned");
4027            }
4028        }
4029        // Same reasoning for the quality-escalation tally (gh#857): the
4030        // number belongs to this solve, not to whatever ran before it.
4031        self.quality_escalations.set(0);
4032
4033        // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
4034        // `make_parameter`; pounce additionally implements `relax_bounds`,
4035        // which the adapter also auto-selects as a fallback when
4036        // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
4037        // `IpTNLPAdapter.cpp:623-633`).
4038        let lo_inf = self
4039            .options
4040            .get_numeric_value("nlp_lower_bound_inf", "")
4041            .ok()
4042            .and_then(|(v, f)| f.then_some(v))
4043            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
4044        let up_inf = self
4045            .options
4046            .get_numeric_value("nlp_upper_bound_inf", "")
4047            .ok()
4048            .and_then(|(v, f)| f.then_some(v))
4049            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
4050        let fixed_treatment = match self
4051            .options
4052            .get_string_value("fixed_variable_treatment", "")
4053            .ok()
4054            .and_then(|(v, f)| f.then_some(v))
4055            .as_deref()
4056        {
4057            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
4058            // `make_constraint` / `make_parameter_nodual` not yet
4059            // implemented; fall back to `make_parameter` (auto-retry to
4060            // `relax_bounds` will still kick in if DOF runs short).
4061            _ => FixedVarTreatment::MakeParameter,
4062        };
4063        let adapter = match TNLPAdapter::new_with_options(
4064            Rc::clone(&tnlp),
4065            lo_inf,
4066            up_inf,
4067            fixed_treatment,
4068        ) {
4069            Ok(a) => Rc::new(RefCell::new(a)),
4070            Err(_) => {
4071                timing.overall_alg.end();
4072                return ApplicationReturnStatus::InvalidProblemDefinition;
4073            }
4074        };
4075        // Carry the user's constant `obj_scaling_factor` (default 1.0;
4076        // negative ⇒ maximize) into the NLP. Until pounce#128's
4077        // follow-up this option was registered but never read, so it
4078        // was silently a no-op — maximization diverged because the
4079        // algorithm minimized the unscaled objective.
4080        let obj_scaling_factor = self
4081            .options
4082            .get_numeric_value("obj_scaling_factor", "")
4083            .ok()
4084            .and_then(|(v, f)| f.then_some(v))
4085            .unwrap_or(1.0);
4086        let mut orig_nlp = match OrigIpoptNlp::new(
4087            Rc::clone(&adapter),
4088            Rc::new(ConstObjScaling(obj_scaling_factor)),
4089        ) {
4090            Ok(n) => n,
4091            Err(_) => {
4092                timing.overall_alg.end();
4093                return ApplicationReturnStatus::InternalError;
4094            }
4095        };
4096        orig_nlp.set_timing_stats(Rc::clone(&timing));
4097        // Q6: decide which derivatives may be reused across iterates,
4098        // before anything is evaluated (gh #588).
4099        self.install_constant_derivative_hints(&mut orig_nlp);
4100
4101        // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
4102        // bail out with NotEnoughDegreesOfFreedom when there are fewer free
4103        // variables than equality constraints. Without this gate, square /
4104        // over-determined systems push the algorithm into restoration on
4105        // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
4106        let n_x_var = orig_nlp.x_space().dim();
4107        let n_c = orig_nlp.c_space().dim();
4108        if n_x_var > 0 && n_x_var < n_c {
4109            timing.overall_alg.end();
4110            // An over-determined system can still be *provably* infeasible —
4111            // `x == 0.2` with `x == 0.8` is about as provable as infeasibility
4112            // gets — and for such a model the structural DOF error is the
4113            // strictly weaker answer: it reports "cannot attempt this" for a
4114            // problem whose verdict is already decided (gh#387). The DOF gate
4115            // fires before any iteration runs, so nothing downstream will ever
4116            // get the chance to detect the infeasibility; check for a
4117            // bound-propagation proof here, on the rare failure path only.
4118            // The probe reuses presolve's full certification pipeline
4119            // (crossing margin + witness refutation), so a model the solver
4120            // would accept as feasible at its own tolerance is never upgraded
4121            // to "proved infeasible" — those still report the DOF error.
4122            if self.overdetermined_model_certified_infeasible(&tnlp) {
4123                use pounce_common::journalist::JournalCategory;
4124                self.journalist.print(
4125                    JournalLevel::J_SUMMARY,
4126                    JournalCategory::J_MAIN,
4127                    "\nEXIT: Problem has too few degrees of freedom, and bound \
4128                     propagation proves its constraints inconsistent.\n\
4129                     No feasible point exists; the solve was not run.\n",
4130                );
4131                return ApplicationReturnStatus::InfeasibleProblemDetected;
4132            }
4133            return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
4134        }
4135
4136        // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
4137        // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
4138        // `OrigIpoptNLP::InitializeStructures` lines 343-358.
4139        let bound_relax_factor = self
4140            .options
4141            .get_numeric_value("bound_relax_factor", "")
4142            .ok()
4143            .and_then(|(v, f)| f.then_some(v))
4144            .unwrap_or(1e-8);
4145        let constr_viol_tol = self
4146            .options
4147            .get_numeric_value("constr_viol_tol", "")
4148            .ok()
4149            .and_then(|(v, f)| f.then_some(v))
4150            .unwrap_or(1e-4);
4151        orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
4152
4153        // `honor_original_bounds` (default `no`, matching upstream):
4154        // project the reported point back into the un-relaxed box. Must
4155        // follow `relax_bounds`, which snapshots the bounds to project
4156        // onto. Registered but never read before, so a user asking for
4157        // it still got a bound-pinned solution sitting up to
4158        // `min(bound_relax_factor·max(1,|b|), constr_viol_tol)` outside
4159        // its own bounds (gh#483 follow-up).
4160        let honor_original_bounds = self
4161            .options
4162            .get_bool_value("honor_original_bounds", "")
4163            .ok()
4164            .and_then(|(v, f)| f.then_some(v))
4165            .unwrap_or(false);
4166        orig_nlp.set_honor_original_bounds(honor_original_bounds);
4167
4168        // Apply automatic NLP scaling per `nlp_scaling_method` option
4169        // (port of `OrigIpoptNLP::InitializeStructures` →
4170        // `NLPScalingObject::DetermineScaling`). Default is
4171        // `gradient-based` to match upstream Ipopt 3.14.
4172        let scaling_method = self
4173            .options
4174            .get_string_value("nlp_scaling_method", "")
4175            .ok()
4176            .and_then(|(v, f)| f.then_some(v))
4177            .unwrap_or_else(|| "gradient-based".to_string());
4178        let scaling_method = match scaling_method.as_str() {
4179            "none" => ScalingMethod::None,
4180            "gradient-based" => ScalingMethod::GradientBased,
4181            // `curvature-based` computes the factors from the model's
4182            // quadratic coefficients and hands them back through
4183            // `TNLP::get_scaling_parameters` (gh #703), so from the
4184            // engine's side it *is* user scaling — the only difference is
4185            // who filled the vectors in.
4186            "user-scaling" | "curvature-based" => ScalingMethod::UserScaling,
4187            // `equilibration-based` is registered upstream but not yet
4188            // implemented in pounce; fall back to gradient-based (the
4189            // upstream default) to keep behavior predictable.
4190            _ => ScalingMethod::GradientBased,
4191        };
4192        let max_gradient = self
4193            .options
4194            .get_numeric_value("nlp_scaling_max_gradient", "")
4195            .ok()
4196            .and_then(|(v, f)| f.then_some(v))
4197            .unwrap_or(100.0);
4198        let min_value = self
4199            .options
4200            .get_numeric_value("nlp_scaling_min_value", "")
4201            .ok()
4202            .and_then(|(v, f)| f.then_some(v))
4203            .unwrap_or(1e-8);
4204        let obj_target_gradient = self
4205            .options
4206            .get_numeric_value("nlp_scaling_obj_target_gradient", "")
4207            .ok()
4208            .and_then(|(v, f)| f.then_some(v))
4209            .unwrap_or(0.0);
4210        let constr_target_gradient = self
4211            .options
4212            .get_numeric_value("nlp_scaling_constr_target_gradient", "")
4213            .ok()
4214            .and_then(|(v, f)| f.then_some(v))
4215            .unwrap_or(0.0);
4216        orig_nlp.determine_scaling_from_starting_point(
4217            scaling_method,
4218            max_gradient,
4219            min_value,
4220            obj_target_gradient,
4221            constr_target_gradient,
4222        );
4223
4224        let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
4225
4226        // Build the algorithm strategy bundle. Read coarse knobs from
4227        // the OptionsList where we have them; fall through to defaults
4228        // otherwise. The full upstream parsing surface (mu_strategy,
4229        // hessian_approximation, line_search_method, ...) is wired by
4230        // `AlgBuilder::RegisterOptions` in upstream — that registry
4231        // hookup lands as a follow-up; default builder is correct for
4232        // HS71-class problems.
4233        let mut builder = self.algorithm_builder_from_options();
4234
4235        // The objective element's support for the partitioned Hessian.
4236        // Every constraint element reads its support off a Jacobian row;
4237        // only the objective has none declared, so without this the
4238        // updater falls back to the first `∇f`'s nonzeros — a
4239        // value-derived pattern. See
4240        // `TNLPAdapter::objective_nonlinear_vars`.
4241        if matches!(
4242            builder.hessian_approximation,
4243            HessianApproxChoice::Partitioned | HessianApproxChoice::FiniteDifference
4244        ) {
4245            builder.objective_nonlinear_vars = adapter.borrow().objective_nonlinear_vars();
4246        }
4247
4248        // Which variables the limited-memory Hessian should span (gh#624).
4249        // Upstream's precedence: a TNLP that implements
4250        // `get_number_of_nonlinear_variables` wins, and
4251        // `num_linear_variables` is only the contiguous-prefix fallback.
4252        // Exact-Hessian solves never consult either.
4253        if matches!(
4254            builder.hessian_approximation,
4255            HessianApproxChoice::LimitedMemory | HessianApproxChoice::FiniteDifference
4256        ) {
4257            let num_linear_variables = self
4258                .options
4259                .get_integer_value("num_linear_variables", "")
4260                .ok()
4261                .and_then(|(v, f)| f.then_some(v))
4262                .unwrap_or(0);
4263            match adapter
4264                .borrow()
4265                .quasi_newton_nonlinear_vars(num_linear_variables)
4266            {
4267                Ok(mask) => builder.limited_memory_nonlinear_vars = mask,
4268                Err(e) => {
4269                    use pounce_common::journalist::JournalCategory;
4270                    self.journalist.print(
4271                        JournalLevel::J_ERROR,
4272                        JournalCategory::J_MAIN,
4273                        &format!("\nEXIT: Invalid nonlinear-variable list: {}\n", e.message),
4274                    );
4275                    timing.overall_alg.end();
4276                    return ApplicationReturnStatus::InvalidProblemDefinition;
4277                }
4278            }
4279        }
4280
4281        // Linear-solver backend. The default factory is option-aware
4282        // — it reads the `feral_*` extension options off the same
4283        // `OptionsList` that drove the IPM-level builder above so
4284        // per-problem `.opt` files can flip backend knobs without
4285        // rebuilding pounce.
4286        let mut feral_cfg = feral_config_from_options(&self.options);
4287        // Block-triangular / Schur KKT partition (pounce#180 item 2). Configure
4288        // the Schur block solvers from the *base* feral cfg: a full-KKT external
4289        // ordering (item 1) is sized for the whole system and cannot apply to
4290        // the A_FF sub-block, so the Schur path keeps the default sub-block
4291        // ordering. `build_with_backend` honors this only on the IPM + feral +
4292        // exact-Hessian path and falls back to the standard solver otherwise.
4293        if let Some(indices) = &self.kkt_schur_block {
4294            builder.set_kkt_schur(indices.clone(), feral_cfg.clone());
4295        }
4296        // A caller-supplied KKT permutation (pounce#180 item 1) overrides
4297        // the string-option / env ordering: `OrderingMethod::External`
4298        // can't be expressed through the OptionsList (it carries a
4299        // vector), so it is injected here from the side-channel field.
4300        // Only applies to the workspace-default FERAL backend below; a
4301        // custom `linear_backend_factory` owns its own config.
4302        if let Some(perm) = &self.external_ordering {
4303            feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
4304        }
4305        // MA57's knobs come off the same `OptionsList`, at the main-IPM
4306        // prefix. The restoration sub-IPM reads them again under
4307        // `"resto."` when its caller mints the inner backend factory —
4308        // see `ma57_config_from_options`.
4309        let ma57_cfg = ma57_config_from_options(&self.options, "");
4310        let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
4311            default_backend_factory_with_sink(
4312                feral_cfg,
4313                ma57_cfg,
4314                Arc::clone(&self.linsol_summary_sink),
4315            )
4316        });
4317        let bundle = builder.build_with_backend(factory);
4318
4319        // Wire the data / cq pair around the NLP. Install the shared
4320        // `TimingStatistics` so the algorithm's iterate phases
4321        // (output, convergence, hessian, μ, search-direction,
4322        // line-search, accept) all record into the same accumulator
4323        // the application exposes via `timing_stats()`.
4324        let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
4325        data.borrow_mut().timing = Rc::clone(&timing);
4326        // Install a shared wall/CPU-time deadline (pounce#242) so the time
4327        // budget is honored at the granularity of the expensive inner
4328        // steps — the main loop's KKT factorization / line search and the
4329        // restoration inner IPM — instead of only between outer iterations.
4330        // The `Deadline` starts its clock now (right after `overall_alg`),
4331        // and the restoration sub-solve reuses this same instance, so the
4332        // caller's budget bounds the whole solve rather than each nested
4333        // level independently. The convergence check treats it as
4334        // authoritative when present (see `conv_check::opt_error`).
4335        data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(
4336            builder.conv_check.max_wall_time,
4337            builder.conv_check.max_cpu_time,
4338        ));
4339        let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
4340            IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
4341        ));
4342        // Correction size for very small slacks (default mach_eps^{3/4});
4343        // drives the safe-slack bound-adjustment mechanism.
4344        if let Ok((v, true)) = self.options.get_numeric_value("slack_move", "") {
4345            cq.borrow_mut().slack_move = v;
4346        }
4347        // `kappa_d` — weight of the linear damping term added to the
4348        // barrier objective/gradient to handle one-sided bounds
4349        // (`IpIpoptCalculatedQuantities.cpp`). Registered (default 1e-5)
4350        // but previously never read, so a user override was silently
4351        // ignored (#191). Routed through the builder for parity with the
4352        // other numeric knobs; the default matches the registered
4353        // default, so only explicit overrides change behavior.
4354        cq.borrow_mut().kappa_d = builder.kappa_d;
4355        // `s_max` — cap on the average multiplier magnitude in the
4356        // `(s_d, s_c)` scaling of the KKT error test. Same shape as
4357        // `kappa_d`: registered (default 100) and previously never read,
4358        // so an override was silently ignored (#551 / #677).
4359        cq.borrow_mut().s_max = builder.s_max;
4360
4361        // Seed `data.curr` with a zero-valued iterate of the correct
4362        // dimensions. The `IterateInitializer` consumes these as its
4363        // template (it overwrites `x`, `s`, multipliers in place); we
4364        // just need the dim metadata.
4365        {
4366            let nlp_borrow = nlp_handle.borrow();
4367            let n_x = nlp_borrow.n();
4368            let n_s = nlp_borrow.m_ineq();
4369            let n_yc = nlp_borrow.m_eq();
4370            let n_yd = nlp_borrow.m_ineq();
4371            let n_zl = nlp_borrow.x_l().dim();
4372            let n_zu = nlp_borrow.x_u().dim();
4373            let n_vl = nlp_borrow.d_l().dim();
4374            let n_vu = nlp_borrow.d_u().dim();
4375            drop(nlp_borrow);
4376            let iv = IteratesVector::new(
4377                Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
4378                Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
4379                Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
4380                Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
4381                Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
4382                Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
4383                Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
4384                Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
4385            );
4386            data.borrow_mut().set_curr(iv);
4387        }
4388
4389        // Full primal-dual warm restart (debugger `resolve`): if a
4390        // captured iterate is queued, install it onto `data.curr` over
4391        // the placeholder so the `WarmStartIterateInitializer`'s
4392        // re-optimize branch (x already initialized) keeps it and only
4393        // clamps multipliers / sets target_mu — no cold re-seed from the
4394        // NLP. Skipped (with a warning) if the dimensions don't line up,
4395        // e.g. an option changed the problem structure between solves.
4396        if let Some(snap) = self.warm_start_iterate.take() {
4397            let dims_match = {
4398                let borrow = data.borrow();
4399                borrow
4400                    .curr
4401                    .as_ref()
4402                    .map(|c| iterates_dims(c) == iterates_dims(snap.iterates()))
4403                    .unwrap_or(false)
4404            };
4405            if dims_match {
4406                data.borrow_mut().set_curr(snap.iterates().clone());
4407                data.borrow_mut().curr_mu = snap.mu();
4408            } else {
4409                tracing::warn!(
4410                    target: "pounce::warm_start",
4411                    "debugger warm-restart iterate dimensions differ from the fresh \
4412                     solve; ignoring the captured iterate and seeding normally"
4413                );
4414            }
4415        }
4416
4417        let max_iter = self
4418            .options
4419            .get_integer_value("max_iter", "")
4420            .ok()
4421            .and_then(|(v, f)| f.then_some(v))
4422            .unwrap_or(3000);
4423        let tol = self
4424            .options
4425            .get_numeric_value("tol", "")
4426            .ok()
4427            .and_then(|(v, f)| f.then_some(v))
4428            .unwrap_or(1e-8);
4429        data.borrow_mut().tol = tol;
4430
4431        let mut alg = IpoptAlgorithm::new(data, cq, bundle)
4432            .with_nlp(Rc::clone(&nlp_handle))
4433            .with_tnlp(Rc::clone(&tnlp));
4434        alg.last_iter_stats_sink = Some(Rc::clone(&self.last_iter_stats));
4435        // Mint a fresh restoration factory per inner solve if a
4436        // provider is configured (pounce#10 Phase 3). Falls back to
4437        // the legacy one-shot `restoration_factory` slot when no
4438        // provider is set, preserving single-shot caller behavior.
4439        if let Some(provider) = self.restoration_factory_provider.as_mut() {
4440            self.restoration_factory = Some(provider());
4441        }
4442        if let Some(factory) = self.restoration_factory.as_mut() {
4443            alg = alg.with_restoration(factory());
4444        }
4445        if let Some(diag) = self.diagnostics.as_ref() {
4446            alg = alg.with_diagnostics(Rc::clone(diag));
4447        }
4448        // Move the interactive debugger hook (if any) into the main
4449        // algorithm. Taken — not cloned — so it drives exactly this
4450        // solve; a subsequent solve must reinstall it.
4451        if let Some(hook) = self.debug_hook.take() {
4452            alg = alg.with_debug_hook(hook);
4453        }
4454        alg.max_iter = max_iter;
4455        // `kappa_sigma` — factor bounding how far the bound multipliers
4456        // may deviate from their primal estimates; the clamp runs after
4457        // every accepted step (`IpIpoptAlg.cpp`, Eqn. (16)). Registered
4458        // (default 1e10) but previously never read, so a user override —
4459        // including the documented `< 1` "disable the correction" — was
4460        // silently ignored (#191). Routed through the builder; the struct
4461        // default matches the registered default, so default runs are
4462        // unchanged.
4463        alg.kappa_sigma = builder.kappa_sigma;
4464        // `recalc_y` (#677) — see the read site above for why the
4465        // limited-memory path defaults it on.
4466        // `linear_system_scaling=slack-based` (#677) — the only scaling
4467        // choice whose factors depend on the iterate, so the main loop
4468        // has to refresh them. See `IpoptAlgorithm::push_slack_scaling`.
4469        alg.slack_based_scaling = matches!(
4470            builder.linear_system_scaling,
4471            crate::alg_builder::LinearSystemScalingChoice::SlackBased
4472        );
4473        alg.recalc_y = builder.recalc_y;
4474        alg.recalc_y_feas_tol = builder.recalc_y_feas_tol;
4475        // `start_with_resto` — the outer loop is what acts on it. It was
4476        // previously copied only into the restoration sub-solver's own
4477        // builder, which has no first outer iteration to force, so
4478        // setting the option did nothing at all.
4479        alg.start_with_resto = builder.resto.start_with_resto;
4480        // Tiny-step and divergence guards (#191): registered but
4481        // previously never read. Struct defaults match the registered
4482        // defaults, so default runs are unchanged.
4483        alg.tiny_step_tol = builder.tiny_step_tol;
4484        alg.tiny_step_y_tol = builder.tiny_step_y_tol;
4485        alg.diverging_iterates_tol = builder.diverging_iterates_tol;
4486        alg.dual_diverging_streak = builder.dual_diverging_streak.max(0) as usize;
4487        alg.dual_divergence_retry_step_tol = builder.dual_divergence_retry_step_tol;
4488        alg.dual_divergence_retry_du_floor = builder.dual_divergence_retry_du_floor;
4489        alg.resto_decline_deferrals = builder.resto_decline_deferrals.max(0) as usize;
4490        alg.resto_decline_progress_ratio = builder.resto_decline_progress_ratio;
4491        alg.neg_curv_escapes = builder.neg_curv_escapes.max(0) as usize;
4492        alg.lbfgs_ls_failure_restarts = builder.limited_memory_ls_failure_restarts.max(0) as usize;
4493        alg.kkt_fidelity_tol = builder.kkt_fidelity_tol;
4494        // Honor `print_level == 0`: silence the algorithm's direct-to-stdout
4495        // output — the per-iteration table and, new in #206, the
4496        // problem-statistics and end-of-run summary blocks the engine now
4497        // emits itself. Default (unset) or any positive level shows them; the
4498        // CLI's JSON mode forces print_level 0, so structured output stays
4499        // clean. (The Phase-7 journalist surface respects print_level already;
4500        // this is the legacy direct-print site that needs the same gate.)
4501        let console_output = match self.options.get_integer_value("print_level", "") {
4502            Ok((v, true)) => v >= 1,
4503            _ => true,
4504        };
4505        if !console_output {
4506            alg.print_iter_output = false;
4507            // The nested restoration IPM is built inside the restoration
4508            // driver, not by `IpoptAlgorithm::new`, so it never sees this
4509            // gate unless we forward it.
4510            if let Some(resto) = alg.restoration.as_mut() {
4511                resto.set_print_iter_output(false);
4512            }
4513        }
4514
4515        // Problem statistics, Ipopt-style, emitted before the iteration table
4516        // from the engine's own reduced problem (#206). Built from the same
4517        // collect_stats inputs the CLI used, so the block is byte-identical;
4518        // emitting it here means every frontend (CLI, Python, C) and every
4519        // algorithm (IPM, SQP) gets it.
4520        self.emit_problem_stats(&tnlp, console_output);
4521
4522        // Per-iteration history (pounce#71): when requested, capture the
4523        // `pounce::iteration` events emitted during the solve into an
4524        // `IterRecord` trajectory via the observability collector layer.
4525        // This replaces the old in-loop `iter_history` accumulation; it
4526        // requires the collector to be installed in the active
4527        // subscriber (the CLI / Python / C frontends install it via
4528        // `pounce_observability::init_subscriber`; tests call
4529        // `init_for_tests`). The collector scopes out restoration
4530        // sub-solve iterations via the `restoration` span, so the
4531        // trajectory matches the previous behavior (outer iters only).
4532        let iter_capture = self
4533            .record_iter_history
4534            .then(pounce_observability::IterCaptureGuard::start);
4535
4536        let solver_status = alg.optimize();
4537        // Keep the initializer's feasibility diagnostics reachable
4538        // after the algorithm goes out of scope (gh#605).
4539        self.least_square_init_report = alg.least_square_init_report();
4540
4541        let captured_iters = iter_capture.map(|g| g.finish()).unwrap_or_default();
4542        // Propagate to any enclosing capture (e.g. `with_iter_capture`
4543        // wrapped around a solve with iteration history enabled), whose
4544        // buffer this inner guard would otherwise leave empty.
4545        pounce_observability::extend_active_capture(&captured_iters);
4546        // Close the overall-algorithm timer on the success path. The
4547        // early-return arms above end it themselves before bailing out;
4548        // this one matches upstream `IpoptApplication::call_optimize`
4549        // (which calls `EndCpuTime()` on overall_alg right after
4550        // `Optimize` returns, regardless of solver_status).
4551        timing.overall_alg.end();
4552
4553        // gh#612: opt-in crossover. Runs here — after the algorithm is done
4554        // but BEFORE the statistics drain, the status gates, the
4555        // `on_converged` hook and `finalize_via_orig_nlp` — so that when it
4556        // accepts, every one of those describes the point actually returned.
4557        // Placing it later would mean reporting residuals for an iterate the
4558        // user is not given, and would hide the exact active set from the
4559        // post-optimal sensitivity hook, which is the first of the three
4560        // consumers this exists for.
4561        self.maybe_crossover(&mut alg, &nlp_handle, solver_status);
4562
4563        // Drain counters / iter count off the algorithm.
4564        {
4565            let mut stats = self.statistics.borrow_mut();
4566            {
4567                let d = alg.data.borrow();
4568                stats.iteration_count = d.iter_count;
4569                // Converged barrier parameter μ — threaded forward into a
4570                // warm-started corrector's `mu_init` / `warm_start_target_mu`
4571                // for predictor–corrector path following (pounce#86).
4572                stats.final_mu = d.curr_mu;
4573            }
4574            // gh#606: the warm-start initializer's verdict on what the
4575            // caller supplied. Lifted off the (solve-local) data handle
4576            // so it outlives the solve; `None` on a cold start.
4577            *self.warm_start_diag.borrow_mut() = alg.data.borrow().warm_start_diagnostics.clone();
4578            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
4579            // Restoration-phase audit counters (pounce#12). Zero on
4580            // problems where restoration never fires; populated by
4581            // `IpoptAlgorithm::invoke_restoration`.
4582            // Finite-difference Hessian census (gh#823 review). `None` on
4583            // every other updater, which leaves the `-1` sentinel in
4584            // place and says "this mode did not run" rather than "it ran
4585            // and found nothing".
4586            if let Some(fd) = alg.bundle.hess.fd_hessian_stats() {
4587                use crate::hess::fd_hessian::FdPatternSource;
4588                stats.fd_hessian_pattern_used = match fd.pattern_used {
4589                    Some(FdPatternSource::Declared) => 0,
4590                    Some(FdPatternSource::Jacobian) => 1,
4591                    None => -1,
4592                };
4593                stats.fd_hessian_nnz = fd.nnz as Index;
4594                stats.fd_hessian_n = fd.n as Index;
4595                stats.fd_hessian_groups = fd.groups as Index;
4596                stats.fd_hessian_rho_max = fd.rho_max as Index;
4597                stats.fd_hessian_coloring_fell_back = fd.coloring_fell_back;
4598                stats.fd_hessian_objective_clique_widened = fd.objective_clique_widened;
4599            }
4600            stats.restoration_calls = alg.resto_calls;
4601            stats.restoration_inner_iters = alg.resto_inner_iters;
4602            stats.restoration_outer_iters = alg.resto_outer_iters;
4603            stats.restoration_wall_secs = alg.resto_wall_secs;
4604            // gh#857. Read off the shared cell rather than the algorithm's
4605            // own `PdFullSpaceSolver`, so restoration sub-solves — which
4606            // run their own solver instance — are included.
4607            stats.quality_escalations = self.quality_escalations.get() as Index;
4608            // gh#884. Read off the algorithm that just ran, so
4609            // `run_with_dual_divergence_retry` — which sits above this
4610            // call — can see what it observed.
4611            self.dual_divergence_signature
4612                .set(self.dual_divergence_signature.get() || alg.dual_divergence_signature());
4613            stats.dual_divergence_signature = self.dual_divergence_signature.get();
4614            stats.dual_divergence_retry_promoted = self.dual_divergence_retry_promoted.get();
4615            stats.iterations = captured_iters;
4616            // A refused starting point does not produce a valid iterate.
4617            // Leave final objective/residual fields at their NaN defaults.
4618            // Capture the final *scaled* objective at the algorithm's
4619            // (compressed `x_var`-space) iterate via the NLP: the
4620            // algorithm-side `eval_f` returns `f * obj_scale_factor`.
4621            // `final_objective` is seeded with it only as a best-effort
4622            // fallback; the success path below overwrites it with the
4623            // true unscaled objective from `finalize_via_orig_nlp`
4624            // (which evaluates the user TNLP directly).
4625            if solver_status != SolverReturn::InvalidProblemDefinition {
4626                let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
4627                if let Some(x) = curr_x {
4628                    if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
4629                        stats.final_objective = f;
4630                        stats.final_scaled_objective = f;
4631                    }
4632                }
4633                // Final residuals straight off the cq cache. These mirror
4634                // the values upstream prints in its end-of-run summary
4635                // ("Dual infeasibility / Constraint violation /
4636                // Complementarity / Overall NLP error").
4637                let cq = alg.cq.borrow();
4638                stats.final_dual_inf = cq.curr_dual_infeasibility_max();
4639                // Stays on the *internal* measure deliberately: the summary's
4640                // "Overall NLP error" is `curr_nlp_error`, and it is built from
4641                // this same `max(||c||, ||d - s||)`. Switching the violation line
4642                // alone to the original-NLP measure
4643                // (`curr_unscaled_nlp_constraint_violation_max`, now used by the
4644                // `inf_pr` column) would leave the block self-inconsistent —
4645                // an error larger than the max of its own components. Making
4646                // them agree means deciding whether *convergence* should be
4647                // judged on the original NLP, which is a behaviour change for
4648                // every model, not a reporting fix. See pounce#476.
4649                //
4650                // NOTE (gh #528): "Overall NLP error" is no longer the number
4651                // the strict gate tests. That gate judges
4652                // `curr_nlp_error_above_primal_noise` — the same aggregate with
4653                // each row's residual counted only above what it can represent
4654                // in floating point — so on a model whose constraint values run
4655                // to `~1e8` the summary can report an error above `tol` beside
4656                // `EXIT: Optimal Solution Found`. The gap is exactly the part
4657                // of the residual that is quantisation noise, and it is bounded
4658                // by `constr_viol_tol`, which is still tested here on the full
4659                // unfloored residual. Reporting is deliberately left on the raw
4660                // value: it is the honest measurement, and at these magnitudes
4661                // the default `bound_relax_factor = 1e-8` has already moved
4662                // every bound by orders of magnitude more than the floor
4663                // forgives, so the raw number was never an exact statement
4664                // about the original NLP either.
4665                stats.final_constr_viol = cq.curr_primal_infeasibility_max();
4666                // How far outside the model AS DECLARED the returned point
4667                // sits. The line above is the internal slack measure the
4668                // convergence test reads, on the `bound_relax_factor`-widened
4669                // model this arm genuinely solves; the widening stays here
4670                // because a feasible-iterate log-barrier needs `x` strictly
4671                // inside its bounds (the convex arm's does not -- see
4672                // `qp_extract::BoundRelax`). The two can differ by orders and
4673                // nothing used to say so: on netlib `wood1p` this reports
4674                // `1.71e-14` at a point `7.96e-09` outside the declared rows
4675                // and `9.84e-09` outside the declared box. Only reported when
4676                // a widening was applied; without one the two coincide and
4677                // `NaN` says "nothing to add".
4678                stats.final_declared_constr_viol = if bound_relax_factor > 0.0 {
4679                    cq.curr_declared_primal_violation_max()
4680                } else {
4681                    Number::NAN
4682                };
4683                // The box half of the same measurement, unconditionally: this
4684                // one is a *summary line* (Ipopt's `Variable bound
4685                // violation`), not an extra warning, so it has to carry a real
4686                // number on every solve rather than only on a widened one. At
4687                // `bound_relax_factor = 0` the honest number is `0` and the
4688                // accessor returns it by measurement, not by assumption.
4689                stats.final_declared_box_viol = cq.curr_declared_box_violation_max();
4690                // Infinity-norm complementarity, max over all four bound
4691                // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
4692                // empty-bound blocks return `0` from amax(), so the max is
4693                // safe even when only one side has bounds.
4694                let compl = cq
4695                    .curr_compl_x_l()
4696                    .amax()
4697                    .max(cq.curr_compl_x_u().amax())
4698                    .max(cq.curr_compl_s_l().amax())
4699                    .max(cq.curr_compl_s_u().amax());
4700                stats.final_compl = compl;
4701                stats.final_kkt_error = cq.curr_nlp_error();
4702                // The aggregate the strict gate tested (gh #528). Reported
4703                // alongside the raw one so a summary can account for the gap
4704                // between them; equal to it on every `O(1)` model, and on any
4705                // run with `primal_noise_floor_kappa = 0`.
4706                stats.final_kkt_error_above_noise = cq
4707                    .curr_nlp_error_above_primal_noise(builder.conv_check.primal_noise_floor_kappa);
4708                // Unscaled (user-space) counterparts — divide the nlp_scaling
4709                // back out so a consumer can verify the certificate in its own
4710                // units (pounce#173). Identical to the scaled fields when no
4711                // scaling is active.
4712                stats.final_unscaled_dual_inf = cq.curr_unscaled_dual_infeasibility_max();
4713                stats.final_unscaled_constr_viol = cq.curr_unscaled_primal_infeasibility_max();
4714                // Record whether per-row scaling actually engaged, so a
4715                // wrapper that measures the user's rows in the model's own
4716                // units knows which field family may carry that number.
4717                // `curr_unscaled_primal_infeasibility_max` treats both
4718                // vectors absent as "scaled == unscaled"; the ℓ₁ outer loop
4719                // relies on the same equivalence (gh#794 review).
4720                {
4721                    let nlp_ref = nlp_handle.borrow();
4722                    self.row_scaling_active.set(Some(
4723                        nlp_ref.c_scale_vec().is_some() || nlp_ref.d_scale_vec().is_some(),
4724                    ));
4725                }
4726                stats.final_unscaled_compl = cq.curr_unscaled_complementarity_max();
4727                stats.final_unscaled_kkt_error = cq.curr_unscaled_nlp_error();
4728
4729                // Report an accepted crossover in the frame it solved in
4730                // (#646). Everything above measures against the bounds the
4731                // interior iteration ran against, which `bound_relax_factor`
4732                // widened by `δ` before the solve. That is the right frame
4733                // for an interior iterate — it never touches a bound — but a
4734                // crossed-over point sits *exactly* on the constraints of the
4735                // problem as declared, i.e. `δ` inside the relaxed ones, so
4736                // the four `s·z` blocks above read `|multiplier| · δ`. For a
4737                // unit multiplier and the default `δ = 1e-8` that is `1e-8`,
4738                // which is `tol`: a strictly better point printed an `Overall
4739                // NLP error` above tolerance, and the opt-in
4740                // `kkt_fidelity_tol` gate below downgraded it.
4741                //
4742                // Only complementarity moves. Stationarity involves no
4743                // bounds, and the crossed-over point is *interior* to the
4744                // relaxed box, so its violation is zero under either reading.
4745                //
4746                // The substitution is confined to reporting. Crossover runs
4747                // after the status is decided, and it only ever installs a
4748                // point the never-regress gate accepted on the declared-bound
4749                // residuals, so this cannot dress up a worse iterate — the
4750                // measurement it replaces is the artifact.
4751                if let Some(report) = self.crossover_report.as_ref()
4752                    && report.accepted()
4753                    && report.compl_after.is_finite()
4754                {
4755                    let compl_declared = report.compl_after;
4756                    stats.final_compl = compl_declared;
4757                    stats.final_kkt_error =
4758                        cq.curr_nlp_error_with_complementarity(compl_declared, 0.0);
4759                    stats.final_kkt_error_above_noise = cq.curr_nlp_error_with_complementarity(
4760                        compl_declared,
4761                        builder.conv_check.primal_noise_floor_kappa,
4762                    );
4763                    // Same unscaling as `curr_unscaled_complementarity_max`:
4764                    // the slack's row factor and the multiplier's cancel in
4765                    // the product, leaving the objective factor. Magnitude —
4766                    // `obj_scaling_factor` is signed, `-1` being the
4767                    // documented way to pose a maximization.
4768                    let df = cq.obj_scaling_factor().abs();
4769                    stats.final_unscaled_compl = if df == 0.0 || df == 1.0 {
4770                        compl_declared
4771                    } else {
4772                        compl_declared / df
4773                    };
4774                    stats.final_unscaled_kkt_error = stats
4775                        .final_unscaled_dual_inf
4776                        .max(stats.final_unscaled_constr_viol)
4777                        .max(stats.final_unscaled_compl);
4778                }
4779            }
4780        }
4781
4782        // Never report `Infeasible_Problem_Detected` while holding a point that
4783        // satisfies every constraint. The gates that produce this verdict argue
4784        // from a stalled feasibility sub-problem, and gh #379 is what that looks
4785        // like when the argument is wrong — a model whose own starting point is
4786        // exactly feasible, reported infeasible. See
4787        // `withdraw_infeasibility_if_refuted`.
4788        let solver_status =
4789            withdraw_infeasibility_if_refuted(&tnlp, solver_status, lo_inf, up_inf, tol);
4790
4791        // Map SolverReturn → ApplicationReturnStatus per
4792        // MAIN_LOOP.md's exception table, then apply the opt-in
4793        // status-fidelity gate (pounce#173).
4794        let app_status = self.apply_kkt_fidelity_gate(solver_return_to_app_status(solver_status));
4795
4796        // On convergence, fire the user-supplied callback (post-optimal
4797        // sensitivity hook, pounce#16) before flowing back through
4798        // `finalize_via_orig_nlp`. Borrowed handles into the converged
4799        // KKT state stay alive for the duration of the closure.
4800        if matches!(
4801            app_status,
4802            ApplicationReturnStatus::SolveSucceeded
4803                | ApplicationReturnStatus::SolvedToAcceptableLevel
4804        ) {
4805            if let Some(cb) = self.on_converged.as_mut() {
4806                if let Some(sd) = alg.search_dir.as_mut() {
4807                    let pd = sd.pd_solver_rc();
4808                    cb(&alg.data, &alg.cq, &nlp_handle, pd);
4809                }
4810            }
4811        }
4812
4813        // Finalize: forward the final iterate to the user's TNLP. The
4814        // returned objective is evaluated on the *user* TNLP at the
4815        // unscaled iterate, so it overrides the scaled best-effort
4816        // value stashed in `final_objective` above (the algorithm-side
4817        // `eval_f` returns `f * obj_scale_factor`).
4818        if solver_status != SolverReturn::InvalidProblemDefinition {
4819            match finalize_via_orig_nlp(
4820                &nlp_handle,
4821                &alg,
4822                solver_status,
4823                app_status,
4824                &tnlp,
4825                &self.last_finalize,
4826            ) {
4827                Ok(f_unscaled) => {
4828                    self.statistics.borrow_mut().final_objective = f_unscaled;
4829                }
4830                Err(()) => {}
4831            }
4832        }
4833
4834        // End-of-solve timing report. Gated on `print_timing_statistics`
4835        // (default "no"); mirrors upstream's
4836        // `IpoptApplication::call_optimize` →
4837        // `IpTimingStatistics::PrintAllValues` call site. The report
4838        // goes to stdout (for parity with the banner / iter-row output
4839        // path) and is also fanned out to the journalist so an
4840        // `output_file` attached via `Initialize` picks it up.
4841        let print_timing = self
4842            .options
4843            .get_bool_value("print_timing_statistics", "")
4844            .ok()
4845            .and_then(|(v, f)| f.then_some(v))
4846            .unwrap_or(false);
4847        if print_timing {
4848            let report = timing.report();
4849            print!("{}", report);
4850            use pounce_common::journalist::{JournalCategory, JournalLevel};
4851            self.journalist.print(
4852                JournalLevel::J_SUMMARY,
4853                JournalCategory::J_TIMING_STATISTICS,
4854                &report,
4855            );
4856        }
4857
4858        // End-of-run summary, Ipopt-style, emitted last (after any timing
4859        // report) from the engine's own statistics (#206). Drains the eval
4860        // tallies into SolveStatistics (read AFTER finalize so the final
4861        // solution evaluation is included) and prints the summary, gated on
4862        // the same print_level as the rest of the console.
4863        self.emit_end_summary(app_status, &nlp_handle, console_output);
4864
4865        app_status
4866    }
4867
4868    /// Build an [`AlgorithmBuilder`] populated from the app's
4869    /// [`OptionsList`]. Public so callers wiring the restoration
4870    /// factory can hand the *inner* IPM a builder that mirrors the
4871    /// outer's `mu_strategy`/`mu_oracle`/line-search choices —
4872    /// matching upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`,
4873    /// which reads the same `mu_strategy` option with prefix `"resto."
4874    /// + prefix` and falls back to the outer setting.
4875    pub fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
4876        let mut builder = AlgorithmBuilder::new();
4877        // gh#857: share this application's escalation tally. Every
4878        // frontend builds the restoration provider's inner builder from
4879        // this method, so restoration escalations aggregate here too.
4880        builder.quality_escalation_counter = Some(Rc::clone(&self.quality_escalations));
4881
4882        // `mehrotra_algorithm` is parsed first so its cascading
4883        // defaults (mu_strategy=adaptive, mu_oracle=probing) can be
4884        // overridden by an explicit user setting of those keys
4885        // below. Mirrors `IpAlgBuilder.cpp:Mehrotra`.
4886        // `fast_step_computation` — skip the search-direction residual
4887        // check and allow an inexact linear solve. `PdSearchDirCalc` has
4888        // consumed this flag since it landed, hard-coded to `false`; the
4889        // option's read site was simply missing, so setting it did
4890        // nothing at all (gh#483 follow-up, #191 round 2).
4891        if let Ok((v, true)) = self.options.get_string_value("fast_step_computation", "") {
4892            builder.fast_step_computation = v.eq_ignore_ascii_case("yes");
4893        }
4894
4895        let mut mehrotra_on = false;
4896        if let Ok((v, found)) = self.options.get_string_value("mehrotra_algorithm", "") {
4897            if found && v == "yes" {
4898                mehrotra_on = true;
4899                builder.mehrotra_algorithm = true;
4900                builder.mu_strategy = MuStrategyChoice::Adaptive;
4901                builder.mu_oracle = crate::mu::adaptive::MuOracleKind::Probing;
4902                // `accept_every_trial_step` short-circuits the alpha
4903                // loop / filter — Mehrotra steps would otherwise be
4904                // rejected by the filter on LP-shaped problems because
4905                // the barrier objective is non-monotone along the
4906                // corrector. Mirrors upstream `IpAlgBuilder.cpp:Mehrotra`.
4907                builder.line_search.accept_every_trial_step = true;
4908                // Aggressive iterate-push defaults (`SetNumericValueIfUnset`
4909                // in upstream). The explicit user parses below will
4910                // overwrite these if the user set them explicitly.
4911                builder.init.bound_push = 10.0;
4912                builder.init.bound_frac = 0.2;
4913                builder.init.slack_bound_push = 10.0;
4914                builder.init.slack_bound_frac = 0.2;
4915                builder.init.bound_mult_init_val = 10.0;
4916                builder.init.constr_mult_init_max = 0.0;
4917                // `alpha_for_y=bound-mult` — Mehrotra wants the
4918                // equality multipliers to advance with the dual
4919                // alpha so they stay in step with z/v. Mirrors
4920                // upstream `IpIpoptAlg.cpp:InitializeImpl`.
4921                builder.line_search.alpha_for_y =
4922                    crate::line_search::backtracking::AlphaForY::BoundMult;
4923                // `adaptive_mu_globalization=never-monotone-mode` —
4924                // upstream `IpIpoptAlg.cpp:148-154` enforces this:
4925                // Mehrotra disables the globalization switch entirely
4926                // (no fallback to monotone mode when convergence
4927                // stalls). Required for the unsafeguarded Mehrotra
4928                // path to function.
4929                builder.mu.adaptive_mu_globalization =
4930                    crate::mu::adaptive::AdaptiveMuGlobalization::NeverMonotoneMode;
4931                // `least_square_init_primal=yes` — upstream
4932                // `IpIpoptAlg.cpp:182` enables this for the Mehrotra
4933                // cascade. Replaces the user's starting `x` with the
4934                // min-norm primal that satisfies the linearized
4935                // equality+inequality constraints. Critical on
4936                // LP-shaped problems where the user's starting point
4937                // can be wildly infeasible (e.g. nuffield2_trap).
4938                builder.init.least_square_init_primal = true;
4939            }
4940        }
4941
4942        if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
4943            if found {
4944                let parsed = match v.as_str() {
4945                    "adaptive" => MuStrategyChoice::Adaptive,
4946                    _ => MuStrategyChoice::Monotone,
4947                };
4948                if mehrotra_on && matches!(parsed, MuStrategyChoice::Monotone) {
4949                    // Upstream Ipopt refuses this combination: Mehrotra
4950                    // needs an affine step every iter, which only the
4951                    // adaptive path computes. Keep adaptive and warn.
4952                    tracing::warn!(target: "pounce::algorithm",
4953                        "pounce: mehrotra_algorithm=yes requires \
4954                         mu_strategy=adaptive; ignoring \
4955                         mu_strategy=monotone."
4956                    );
4957                } else {
4958                    builder.mu_strategy = parsed;
4959                }
4960            }
4961        }
4962        if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
4963            if found {
4964                builder.mu_oracle = match v.as_str() {
4965                    "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
4966                    "probing" => crate::mu::adaptive::MuOracleKind::Probing,
4967                    _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
4968                };
4969            }
4970        }
4971        if let Ok((v, found)) = self
4972            .options
4973            .get_string_value("adaptive_mu_globalization", "")
4974        {
4975            if found {
4976                use crate::mu::adaptive::AdaptiveMuGlobalization;
4977                builder.mu.adaptive_mu_globalization = match v.as_str() {
4978                    "kkt-error" => AdaptiveMuGlobalization::KktError,
4979                    "never-monotone-mode" => AdaptiveMuGlobalization::NeverMonotoneMode,
4980                    _ => AdaptiveMuGlobalization::ObjConstrFilter,
4981                };
4982            }
4983        }
4984        if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
4985            if found {
4986                builder.hessian_approximation = match v.as_str() {
4987                    "limited-memory" => HessianApproxChoice::LimitedMemory,
4988                    "partitioned" => HessianApproxChoice::Partitioned,
4989                    "finite-difference" => HessianApproxChoice::FiniteDifference,
4990                    _ => HessianApproxChoice::Exact,
4991                };
4992            }
4993        }
4994        // **Upstream changes the `mu_strategy` default for a
4995        // limited-memory Hessian.** `IpAlgBuilder.cpp:1059`:
4996        //
4997        //     if( !options.GetStringValue("mu_strategy", smuupdate, prefix) )
4998        //     {
4999        //        // Change default for quasi-Newton option (then we use adaptive)
5000        //        ... if( hessian_approximation == LIMITED_MEMORY )
5001        //               smuupdate = "adaptive";
5002        //     }
5003        //
5004        // and again at `:920` for the restoration-phase algorithm.
5005        // Registered default is `monotone`; the quasi-Newton path takes
5006        // `adaptive` unless the caller says otherwise. pounce read the
5007        // registered default unconditionally, so every L-BFGS solve ran
5008        // a barrier schedule Ipopt does not use on that path — a
5009        // trajectory divergence on the arm the Python frontend and the
5010        // CasADi plugin select automatically (gh#746).
5011        //
5012        // The restoration sub-IPM inherits this: `run_inner_resto`
5013        // clones the configured `inner_alg_builder`, so the flag set
5014        // here is what the resto algorithm gets, matching `:920`.
5015        //
5016        // Only when unset — an explicit `mu_strategy` still wins, and
5017        // `mehrotra_algorithm` (parsed above) has already forced
5018        // adaptive on its own terms.
5019        if builder.hessian_approximation == HessianApproxChoice::LimitedMemory
5020            && !self.mu_strategy_was_set()
5021        {
5022            builder.mu_strategy = MuStrategyChoice::Adaptive;
5023        }
5024        // Limited-memory quasi-Newton update formula. Registered upstream
5025        // (`limited_memory_update_type`, IpLimMemQuasiNewtonUpdater.cpp) but
5026        // until now read nowhere on the IPM path — the updater was hard-wired
5027        // to Powell-damped BFGS. SR1 is honored too (the updater and the
5028        // low-rank/inertia path already handle its indefinite models).
5029        // Partitioned quasi-Newton knobs. `partitioned_update_type`
5030        // defaults to SR1 rather than BFGS — see
5031        // `crates::hess::partitioned_quasi_newton` for why damping is
5032        // the wrong choice on a per-constraint element.
5033        if let Ok((v, found)) = self.options.get_string_value("partitioned_update_type", "") {
5034            if found {
5035                builder.partitioned_update_type = match v.as_str() {
5036                    "bfgs" => UpdateType::Bfgs,
5037                    _ => UpdateType::Sr1,
5038                };
5039                builder.partitioned_update_type_was_set = true;
5040            }
5041        }
5042        if let Ok((v, found)) = self
5043            .options
5044            .get_integer_value("partitioned_max_element", "")
5045        {
5046            if found && v > 0 {
5047                builder.partitioned_max_element = v as usize;
5048            }
5049        }
5050        if let Ok((v, found)) = self.options.get_string_value("fd_hessian_pattern", "") {
5051            if found {
5052                builder.fd_hessian_pattern = match v.as_str() {
5053                    "jacobian" => crate::hess::fd_hessian::FdPatternSource::Jacobian,
5054                    _ => crate::hess::fd_hessian::FdPatternSource::Declared,
5055                };
5056            }
5057        }
5058        if let Ok((v, found)) = self.options.get_string_value("fd_hessian_coloring", "") {
5059            if found {
5060                builder.fd_hessian_coloring = match v.as_str() {
5061                    "cpr" => crate::hess::fd_hessian::FdColoring::Cpr,
5062                    _ => crate::hess::fd_hessian::FdColoring::Star,
5063                };
5064            }
5065        }
5066        if let Ok((v, found)) = self.options.get_numeric_value("fd_hessian_reuse_tol", "") {
5067            if found && v >= 0.0 {
5068                builder.fd_hessian_reuse_tol = v;
5069            }
5070        }
5071        if let Ok((v, found)) = self.options.get_string_value("partitioned_elements", "") {
5072            if found {
5073                builder.partitioned_elements = match v.as_str() {
5074                    "blocks" => crate::hess::partitioned_quasi_newton::ElementMode::PrimalBlock,
5075                    _ => crate::hess::partitioned_quasi_newton::ElementMode::PerConstraint,
5076                };
5077            }
5078        }
5079        if let Ok((v, found)) = self.options.get_integer_value("partitioned_block_size", "") {
5080            if found && v > 0 {
5081                builder.partitioned_block_size = v as usize;
5082            }
5083        }
5084        if let Ok((v, found)) = self
5085            .options
5086            .get_numeric_value("partitioned_curvature_cap", "")
5087        {
5088            if found && v > 0.0 {
5089                builder.partitioned_curvature_cap = v;
5090            }
5091        }
5092        if let Ok((v, found)) = self
5093            .options
5094            .get_string_value("limited_memory_update_type", "")
5095        {
5096            if found {
5097                builder.limited_memory_update_type = match v.as_str() {
5098                    "sr1" => UpdateType::Sr1,
5099                    _ => UpdateType::Bfgs,
5100                };
5101            }
5102        }
5103        // Limited-memory history length (`limited_memory_max_history`).
5104        if let Ok((v, found)) = self
5105            .options
5106            .get_integer_value("limited_memory_max_history", "")
5107        {
5108            if found && v >= 0 {
5109                builder.limited_memory_max_history = v as Index;
5110            }
5111        }
5112        // `limited_memory_initialization` — which formula picks the
5113        // initial Hessian scalar σ. Registered since the option port with
5114        // upstream's `scalar1` default and read nowhere until #677, so
5115        // the updater's own `Scalar2` default was the only value any
5116        // solve ever used: setting the option did nothing, and it warned
5117        // nothing. Same miss as gh#483 / #191 round 2 (which wired
5118        // `limited_memory_init_val_max`/`_min`) — this is the third
5119        // argument to that same `initial_hessian_scalar` call.
5120        //
5121        // The effective default now follows the registry (`scalar1`),
5122        // matching Ipopt. σ_scalar2/σ_scalar1 = (yᵀy·sᵀs)/(sᵀy)² ≥ 1 and
5123        // is unbounded as the curvature pair degrades, so on an
5124        // ill-conditioned problem `scalar2` inflates `B0 = σI` until it
5125        // swamps the rank-2 corrections and the step collapses.
5126        if let Ok((v, found)) = self
5127            .options
5128            .get_string_value("limited_memory_initialization", "")
5129        {
5130            if found {
5131                use crate::hess::lim_mem_quasi_newton::InitialApprox;
5132                // Every registered value is named explicitly. #551 §3
5133                // held this option back precisely because wiring only
5134                // the values that mapped would leave the rest falling
5135                // back silently — a new no-op created by the fix — so a
5136                // catch-all standing in for a real value is the one
5137                // shape to avoid here. `OptionsList` rejects any
5138                // unregistered value before this runs (`options_list.rs`
5139                // `OPTION_INVALID`), so the final arm is unreachable
5140                // rather than a fallback.
5141                builder.limited_memory_initialization = match v.as_str() {
5142                    "scalar1" => InitialApprox::Scalar1,
5143                    "scalar2" => InitialApprox::Scalar2,
5144                    "scalar3" => InitialApprox::Scalar3,
5145                    "scalar4" => InitialApprox::Scalar4,
5146                    "constant" => InitialApprox::Constant,
5147                    "history-max" => InitialApprox::HistoryMax,
5148                    _ => InitialApprox::Scalar2,
5149                };
5150            }
5151        }
5152        // `recalc_y` / `recalc_y_feas_tol` — least-square re-estimation
5153        // of the equality multipliers once feasible (#677).
5154        //
5155        // Upstream registers `recalc_y` as `no`, but its own option text
5156        // ends "If a limited memory quasi-Newton option is chosen, this
5157        // is used by default", so upstream's effective default is
5158        // conditional on the Hessian approximation.
5159        //
5160        // **pounce does not follow that, and the discrepancy is
5161        // deliberate.** Auto-enabling it for the limited-memory path was
5162        // implemented and measured against the fixture corpus first: it
5163        // moved 16 of 57 fixtures on the L-BFGS leg and took **7 from
5164        // solved to not solved, with nothing moving the other way** —
5165        // `airport` 56 it → `SearchDirectionBecomesTooSmall` at 541,
5166        // `pooling_rt2stp` 413 it → the same at 1775, all three `jit1`
5167        // variants, `linear_eq_collapsed_box`, and `hs13_bigstart` to
5168        // the iteration cap. The signature is consistent: re-estimating
5169        // `y` on every feasible iteration overwrites Newton multipliers
5170        // that were converging, the dual never settles, and the step
5171        // vanishes short of the certificate.
5172        //
5173        // So the feature is available and off by default. That still
5174        // closes the gap that mattered — until #677 the option was
5175        // refused outright as unimplemented, so an L-BFGS user could not
5176        // reach Ipopt's behaviour at all. They can now, by asking.
5177        //
5178        // Why it is worth having: a quasi-Newton model's dual step is
5179        // computed from an approximate `W`, so L-BFGS can settle a
5180        // feasible primal and still not drive `inf_du` to tolerance —
5181        // the failure a 59,939-variable CasADi model hit, oscillating
5182        // `inf_du` between 3.6e-3 and 1.8e+01 for 300 iterations with
5183        // the objective already settled. On that shape it is the fix;
5184        // on a corpus of small well-conditioned models it is a
5185        // pessimisation. Matching upstream's conditional default needs
5186        // to explain the 7 regressions first.
5187        if let Ok((v, true)) = self.options.get_string_value("recalc_y", "") {
5188            builder.recalc_y = v == "yes";
5189        }
5190        if let Ok((v, true)) = self.options.get_numeric_value("recalc_y_feas_tol", "") {
5191            builder.recalc_y_feas_tol = v;
5192        }
5193        // `limited_memory_init_val` — σ before any curvature pair exists,
5194        // and every iteration under `constant`. Also unread until #677;
5195        // the empty-history branch hard-coded the same `1.0`.
5196        if let Ok((v, true)) = self
5197            .options
5198            .get_numeric_value("limited_memory_init_val", "")
5199        {
5200            builder.limited_memory_init_val = v;
5201        }
5202        // `limited_memory_max_skipping` (#686) — registered and unread,
5203        // and the feature behind it did not exist either: the updater
5204        // counted nothing and never discarded its history.
5205        if let Ok((v, true)) = self
5206            .options
5207            .get_integer_value("limited_memory_max_skipping", "")
5208        {
5209            if v >= 0 {
5210                builder.limited_memory_max_skipping = v as Index;
5211            }
5212        }
5213        if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
5214            if found {
5215                builder.line_search_method = match v.as_str() {
5216                    "cg-penalty" => LineSearchChoice::CgPenalty,
5217                    "penalty" => LineSearchChoice::Penalty,
5218                    _ => LineSearchChoice::Filter,
5219                };
5220            }
5221        }
5222        // `accept_every_trial_step` — direct user override. Parsed
5223        // after the Mehrotra cascade so an explicit `no` still wins.
5224        if let Ok((v, found)) = self.options.get_string_value("accept_every_trial_step", "") {
5225            if found {
5226                builder.line_search.accept_every_trial_step = v == "yes";
5227            }
5228        }
5229        // `alpha_for_y` — direct user override. Parsed after the
5230        // Mehrotra cascade so an explicit value still wins.
5231        if let Ok((v, found)) = self.options.get_string_value("alpha_for_y", "") {
5232            if found {
5233                use crate::line_search::backtracking::AlphaForY;
5234                builder.line_search.alpha_for_y = match v.as_str() {
5235                    "primal" => AlphaForY::Primal,
5236                    "bound-mult" | "bound_mult" => AlphaForY::BoundMult,
5237                    "full" => AlphaForY::Full,
5238                    "min" => AlphaForY::Min,
5239                    "max" => AlphaForY::Max,
5240                    "primal-and-full" | "dual-and-full" => AlphaForY::Primal,
5241                    _ => AlphaForY::Primal,
5242                };
5243            }
5244        }
5245        // `nlp_scaling_method` is consumed NLP-side in
5246        // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
5247        // `determine_scaling_from_starting_point` call earlier in this
5248        // method); there is no algorithm-side scaling strategy to wire.
5249        // `limited_memory_init_val_max` / `_min` — the clamp on the
5250        // initial Hessian scalar. `LimMemQuasiNewtonUpdater` consumes
5251        // both in `initial_hessian_scalar`; only the read sites were
5252        // missing, so setting either did nothing (gh#483, #191 round 2).
5253        if let Ok((v, true)) = self
5254            .options
5255            .get_numeric_value("limited_memory_init_val_max", "")
5256        {
5257            builder.limited_memory_init_val_max = v;
5258        }
5259        if let Ok((v, true)) = self
5260            .options
5261            .get_numeric_value("limited_memory_init_val_min", "")
5262        {
5263            builder.limited_memory_init_val_min = v;
5264        }
5265
5266        // Unlike the other options here, we always honor the registry
5267        // value (not just when the user set it explicitly): the option
5268        // registry default is "ma57" but `AlgorithmBuilder::default`
5269        // has `linear_solver: Feral`, so gating on `found` would
5270        // silently route default runs through Feral while the banner
5271        // (and ipopt-compatible behavior) advertises MA57.
5272        //
5273        // Record the **effective** backend, not the requested one. MA57 lives
5274        // behind the optional `ma57` cargo feature (HSL is licensed and needs a
5275        // Fortran toolchain); without it `default_backend_factory` silently
5276        // substitutes FERAL. Storing `Ma57` here therefore made
5277        // `builder.linear_solver` disagree with the backend actually built, and
5278        // consumers acted on the lie: the Schur KKT gate in
5279        // `alg_builder::build_with_backend` tests `== Feral`, so on the
5280        // pure-Rust default build — where the registry default (then upstream's
5281        // "ma57") resolved to FERAL anyway — `set_kkt_schur_block()` silently
5282        // never engaged for ANY user. Resolving here keeps the field truthful
5283        // for every consumer.
5284        //
5285        // The `_ =>` arm is now only reachable for `feral`: every other name
5286        // is refused up front by `unimplemented_linear_solver`. It used to
5287        // swallow `mumps`, `pardiso`, `ma97`, … and run FERAL instead.
5288        if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
5289            let requested = if v.eq_ignore_ascii_case("ma57") {
5290                LinearSolverChoice::Ma57
5291            } else {
5292                LinearSolverChoice::Feral
5293            };
5294            builder.linear_solver =
5295                if matches!(requested, LinearSolverChoice::Ma57) && !cfg!(feature = "ma57") {
5296                    LinearSolverChoice::Feral
5297                } else {
5298                    requested
5299                };
5300        }
5301
5302        // `linear_system_scaling` — symmetric scaling of the augmented
5303        // KKT matrix before factorization. Port of
5304        // `IpTSymLinearSolver.cpp:RegisterOptions` plumbing. Default
5305        // "none"; "ruiz" invokes the Ruiz-2001 symmetric ∞-norm
5306        // equilibration in `RuizTSymScalingMethod`. "mc19" and
5307        // "slack-based" are accepted by the registry but not yet
5308        // implemented at this layer; they fall back to no scaling
5309        // with a one-line notice.
5310        //
5311        // `slack-based` is implemented as of #677. It used to reach the
5312        // no-scaling fallback through the catch-all arm, which meant it
5313        // fell back **silently** — the comment above promised a notice
5314        // that only `mc19` actually emitted. It is not a hypothetical
5315        // value: it is what Ipopt's own recommended configuration for
5316        // large collocation NLPs uses, so the users most likely to set
5317        // it were the least likely to be told it did nothing. The
5318        // catch-all is left for genuinely unreachable input —
5319        // `OptionsList` rejects anything the registry does not list.
5320        if let Ok((v, found)) = self.options.get_string_value("linear_system_scaling", "") {
5321            if found {
5322                builder.linear_system_scaling = match v.as_str() {
5323                    "ruiz" => crate::alg_builder::LinearSystemScalingChoice::Ruiz,
5324                    "mc19" => crate::alg_builder::LinearSystemScalingChoice::Mc19,
5325                    "slack-based" => crate::alg_builder::LinearSystemScalingChoice::SlackBased,
5326                    _ => crate::alg_builder::LinearSystemScalingChoice::None,
5327                };
5328            }
5329        }
5330        if let Ok((v, found)) = self.options.get_bool_value("linear_scaling_on_demand", "") {
5331            if found {
5332                builder.linear_scaling_on_demand = v;
5333            }
5334        }
5335
5336        // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
5337        // `RegisterOptions` consumers). Defaults already match upstream
5338        // — only override when the user set the key explicitly.
5339        let read_num = |key: &str| -> Option<f64> {
5340            self.options
5341                .get_numeric_value(key, "")
5342                .ok()
5343                .and_then(|(v, f)| f.then_some(v))
5344        };
5345        let read_int = |key: &str| -> Option<i32> {
5346            self.options
5347                .get_integer_value(key, "")
5348                .ok()
5349                .and_then(|(v, f)| f.then_some(v))
5350        };
5351        if let Some(v) = read_num("tol") {
5352            builder.conv_check.tol = v;
5353        }
5354        if let Some(v) = read_num("obj_scale_certificate_threshold") {
5355            builder.conv_check.obj_scale_certificate_threshold = v;
5356        }
5357        if let Some(v) = read_num("primal_noise_floor_kappa") {
5358            builder.conv_check.primal_noise_floor_kappa = v;
5359        }
5360        if let Some(v) = read_num("acceptable_progress_kappa") {
5361            builder.conv_check.acceptable_progress_kappa = v;
5362        }
5363        if let Some(v) = read_num("dual_inf_scale_kappa") {
5364            builder.conv_check.dual_inf_scale_kappa = v;
5365        }
5366        if let Some(v) = read_num("kkt_fidelity_tol") {
5367            builder.kkt_fidelity_tol = v;
5368        }
5369        if let Some(v) = read_num("dual_inf_tol") {
5370            builder.conv_check.dual_inf_tol = v;
5371        }
5372        if let Some(v) = read_num("constr_viol_tol") {
5373            builder.conv_check.constr_viol_tol = v;
5374        }
5375        if let Some(v) = read_num("compl_inf_tol") {
5376            builder.conv_check.compl_inf_tol = v;
5377        }
5378        if let Some(v) = read_int("max_iter") {
5379            builder.conv_check.max_iter = v;
5380        }
5381        if let Some(v) = read_num("max_cpu_time") {
5382            builder.conv_check.max_cpu_time = v;
5383        }
5384        if let Some(v) = read_num("max_wall_time") {
5385            builder.conv_check.max_wall_time = v;
5386        }
5387        if let Some(v) = read_num("acceptable_tol") {
5388            builder.conv_check.acceptable_tol = v;
5389        }
5390        if let Some(v) = read_num("acceptable_dual_inf_tol") {
5391            builder.conv_check.acceptable_dual_inf_tol = v;
5392        }
5393        if let Some(v) = read_num("acceptable_constr_viol_tol") {
5394            builder.conv_check.acceptable_constr_viol_tol = v;
5395        }
5396        if let Some(v) = read_num("acceptable_compl_inf_tol") {
5397            builder.conv_check.acceptable_compl_inf_tol = v;
5398        }
5399        if let Some(v) = read_num("acceptable_obj_change_tol") {
5400            builder.conv_check.acceptable_obj_change_tol = v;
5401        }
5402        if let Some(v) = read_int("acceptable_iter") {
5403            builder.conv_check.acceptable_iter = v;
5404        }
5405        if let Some(v) = read_num("infeas_stationarity_tol") {
5406            builder.conv_check.infeas_stationarity_tol = v;
5407        }
5408        if let Some(v) = read_num("infeas_viol_kappa") {
5409            builder.conv_check.infeas_viol_kappa = v;
5410        }
5411        if let Some(v) = read_int("infeas_max_streak") {
5412            builder.conv_check.infeas_max_streak = v;
5413        }
5414
5415        // Bound-multiplier / barrier damping constants (#191). Both were
5416        // registered but never read, so user overrides were silently
5417        // dropped; the algorithm ran with the hard-coded struct defaults.
5418        // Defaults equal the registered defaults, so this changes nothing
5419        // for a run that doesn't set them.
5420        if let Some(v) = read_num("kappa_sigma") {
5421            builder.kappa_sigma = v;
5422        }
5423        if let Some(v) = read_num("kappa_d") {
5424            builder.kappa_d = v;
5425        }
5426        // `s_max` — the cap in the `(s_d, s_c)` scaling of the KKT error
5427        // test (#551 / #677). `IpoptCalculatedQuantities` carried it as a
5428        // hard-coded 100 (the registered default) and nothing read the
5429        // option; a run that does not set it is unaffected.
5430        if let Some(v) = read_num("s_max") {
5431            builder.s_max = v;
5432        }
5433        if let Some(v) = read_num("tiny_step_tol") {
5434            builder.tiny_step_tol = v;
5435        }
5436        if let Some(v) = read_num("tiny_step_y_tol") {
5437            builder.tiny_step_y_tol = v;
5438        }
5439        if let Some(v) = read_num("diverging_iterates_tol") {
5440            builder.diverging_iterates_tol = v;
5441        }
5442        if let Some(v) = read_int("dual_diverging_streak") {
5443            builder.dual_diverging_streak = v;
5444        }
5445        if let Some(v) = read_num("dual_divergence_retry_step_tol") {
5446            builder.dual_divergence_retry_step_tol = v;
5447        }
5448        if let Some(v) = read_num("dual_divergence_retry_du_floor") {
5449            builder.dual_divergence_retry_du_floor = v;
5450        }
5451        if let Some(v) = read_int("resto_decline_deferrals") {
5452            builder.resto_decline_deferrals = v;
5453        }
5454        if let Some(v) = read_num("resto_decline_progress_ratio") {
5455            builder.resto_decline_progress_ratio = v;
5456        }
5457        if let Some(v) = read_int("neg_curv_escapes") {
5458            builder.neg_curv_escapes = v;
5459        }
5460        if let Some(v) = read_int("limited_memory_ls_failure_restarts") {
5461            builder.limited_memory_ls_failure_restarts = v;
5462        }
5463
5464        // Barrier-parameter (μ) options — consumers in
5465        // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
5466        // updaters share the same option names; the builder forwards
5467        // each into whichever strategy is assembled.
5468        if let Some(v) = read_num("mu_init") {
5469            builder.mu.mu_init = v;
5470        }
5471        if let Some(v) = read_num("mu_max") {
5472            builder.mu.mu_max = v;
5473        }
5474        if let Some(v) = read_num("mu_max_fact") {
5475            builder.mu.mu_max_fact = v;
5476        }
5477        if let Some(v) = read_num("mu_min") {
5478            builder.mu.mu_min = v;
5479        }
5480        if let Some(v) = read_num("mu_target") {
5481            builder.mu.mu_target = v;
5482        }
5483        if let Some(v) = read_num("mu_linear_decrease_factor") {
5484            builder.mu.mu_linear_decrease_factor = v;
5485        }
5486        if let Some(v) = read_num("mu_superlinear_decrease_power") {
5487            builder.mu.mu_superlinear_decrease_power = v;
5488        }
5489        if let Ok((v, found)) = self
5490            .options
5491            .get_string_value("mu_allow_fast_monotone_decrease", "")
5492        {
5493            if found {
5494                builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
5495            }
5496        }
5497        if let Some(v) = read_num("barrier_tol_factor") {
5498            builder.mu.barrier_tol_factor = v;
5499        }
5500        // `tau_min` — floor on the fraction-to-the-boundary parameter
5501        // (#551 / #677). Both `MonotoneMuUpdate` and `AdaptiveMuUpdate`
5502        // carried the field with upstream's 0.99 default and nothing
5503        // read the option, so an override was silently dropped. The
5504        // default equals the registered default, so this changes
5505        // nothing for a run that does not set it.
5506        if let Some(v) = read_num("tau_min") {
5507            builder.mu.tau_min = v;
5508        }
5509        if let Some(v) = read_num("sigma_max") {
5510            builder.mu.sigma_max = v;
5511        }
5512        if let Some(v) = read_num("sigma_min") {
5513            builder.mu.sigma_min = v;
5514        }
5515
5516        // Quality-function oracle knobs — consumers in
5517        // `IpQualityFunctionMuOracle.cpp:RegisterOptions`. Forwarded
5518        // to the oracle on every free-mode call.
5519        if let Ok((v, found)) = self
5520            .options
5521            .get_string_value("quality_function_norm_type", "")
5522        {
5523            if found {
5524                use crate::mu::oracle::quality_function::NormType;
5525                builder.mu.quality_function_norm_type = match v.as_str() {
5526                    "1-norm" => NormType::OneNorm,
5527                    "2-norm" => NormType::TwoNorm,
5528                    "max-norm" => NormType::MaxNorm,
5529                    _ => NormType::TwoNormSquared,
5530                };
5531            }
5532        }
5533        if let Ok((v, found)) = self
5534            .options
5535            .get_string_value("quality_function_centrality", "")
5536        {
5537            if found {
5538                use crate::mu::oracle::quality_function::CentralityType;
5539                builder.mu.quality_function_centrality = match v.as_str() {
5540                    "log" => CentralityType::LogCenter,
5541                    "reciprocal" => CentralityType::ReciprocalCenter,
5542                    "cubed-reciprocal" => CentralityType::CubedReciprocalCenter,
5543                    _ => CentralityType::None,
5544                };
5545            }
5546        }
5547        if let Ok((v, found)) = self
5548            .options
5549            .get_string_value("quality_function_balancing_term", "")
5550        {
5551            if found {
5552                use crate::mu::oracle::quality_function::BalancingTermType;
5553                builder.mu.quality_function_balancing_term = match v.as_str() {
5554                    "cubic" => BalancingTermType::CubicTerm,
5555                    _ => BalancingTermType::None,
5556                };
5557            }
5558        }
5559        if let Some(v) = read_int("quality_function_max_section_steps") {
5560            builder.mu.quality_function_max_section_steps = v;
5561        }
5562        if let Some(v) = read_num("quality_function_section_sigma_tol") {
5563            builder.mu.quality_function_section_sigma_tol = v;
5564        }
5565        if let Some(v) = read_num("quality_function_section_qf_tol") {
5566            builder.mu.quality_function_section_qf_tol = v;
5567        }
5568
5569        // `probing_iterate_quality_factor` — pounce-specific guard
5570        // (pounce#58) on the probing μ-oracle's input iterate. When
5571        // `curr_avrg_compl / curr_mu` exceeds this factor, the
5572        // μ-update layer signals restoration via
5573        // `IpoptData::request_resto` instead of letting probing
5574        // return `σ · mu_curr` ≫ previous μ. Default 1e4; set to ≤ 0
5575        // to disable. No upstream Ipopt counterpart.
5576        if let Some(v) = read_num("probing_iterate_quality_factor") {
5577            builder.mu.probing_iterate_quality_factor = v;
5578        }
5579
5580        // Adaptive-μ extras — consumers in
5581        // `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Only active when
5582        // `mu_strategy=adaptive`.
5583        if let Some(v) = read_num("adaptive_mu_safeguard_factor") {
5584            builder.mu.adaptive_mu_safeguard_factor = v;
5585        }
5586        if let Some(v) = read_num("adaptive_mu_monotone_init_factor") {
5587            builder.mu.adaptive_mu_monotone_init_factor = v;
5588        }
5589        if let Ok((v, found)) = self
5590            .options
5591            .get_bool_value("adaptive_mu_restore_previous_iterate", "")
5592        {
5593            if found {
5594                builder.mu.adaptive_mu_restore_previous_iterate = v;
5595            }
5596        }
5597        if let Some(v) = read_int("adaptive_mu_max_free_returns") {
5598            builder.mu.adaptive_mu_max_free_returns = v;
5599        }
5600        if let Some(v) = read_num("adaptive_mu_budget_pin_fraction") {
5601            builder.mu.adaptive_mu_budget_pin_fraction = v;
5602        }
5603        if let Some(v) = read_int("adaptive_mu_kkterror_red_iters") {
5604            if v >= 0 {
5605                builder.mu.adaptive_mu_kkterror_red_iters = v as usize;
5606            }
5607        }
5608        if let Some(v) = read_num("adaptive_mu_kkterror_red_fact") {
5609            builder.mu.adaptive_mu_kkterror_red_fact = v;
5610        }
5611        // `filter_margin_fact` / `filter_max_margin` (#551) — the margin
5612        // an entry must clear in the `obj-constr-filter` globalization
5613        // test. `AdaptiveMuUpdate` computes
5614        // `filter_margin_fact * min(filter_max_margin, err)` and has
5615        // always done so; only these two read sites were missing, so
5616        // setting either did nothing. Defaults equal the registered
5617        // defaults (1e-5 / 1.0), so an unset run is unchanged.
5618        if let Some(v) = read_num("filter_margin_fact") {
5619            builder.mu.filter_margin_fact = v;
5620        }
5621        if let Some(v) = read_num("filter_max_margin") {
5622            builder.mu.filter_max_margin = v;
5623        }
5624        if let Ok((v, found)) = self
5625            .options
5626            .get_string_value("adaptive_mu_kkt_norm_type", "")
5627        {
5628            if found {
5629                use crate::mu::adaptive::AdaptiveMuKktNorm;
5630                builder.mu.adaptive_mu_kkt_norm_type = match v.as_str() {
5631                    "1-norm" => AdaptiveMuKktNorm::OneNorm,
5632                    "2-norm" => AdaptiveMuKktNorm::TwoNorm,
5633                    "max-norm" => AdaptiveMuKktNorm::MaxNorm,
5634                    _ => AdaptiveMuKktNorm::TwoNormSquared,
5635                };
5636            }
5637        }
5638
5639        // Watchdog options — consumers in
5640        // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
5641        // the `BacktrackingLineSearch` at build time.
5642        if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
5643            builder.line_search.watchdog_shortened_iter_trigger = v;
5644        }
5645        if let Some(v) = read_int("watchdog_trial_iter_max") {
5646            builder.line_search.watchdog_trial_iter_max = v;
5647        }
5648        if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
5649            builder.line_search.soft_resto_pderror_reduction_factor = v;
5650        }
5651        if let Some(v) = read_int("max_soft_resto_iters") {
5652            builder.line_search.max_soft_resto_iters = v;
5653        }
5654        // `alpha_red_factor` (#678) and `accept_after_max_steps` (#551)
5655        // — both consumed by the α-loop in `BacktrackingLineSearch`,
5656        // both registered without a read site until those issues.
5657        // `alpha_red_factor`'s default (0.5) equals the registered one,
5658        // and `accept_after_max_steps` defaults to `-1`, which disables
5659        // the escape hatch, so neither moves a solve that leaves them
5660        // alone.
5661        if let Some(v) = read_num("alpha_red_factor") {
5662            builder.line_search.alpha_red_factor = v;
5663        }
5664        if let Some(v) = read_num("alpha_red_factor_min") {
5665            builder.line_search.alpha_red_factor_min = Some(v);
5666        }
5667        if let Some(v) = read_int("accept_after_max_steps") {
5668            builder.line_search.accept_after_max_steps = v;
5669        }
5670
5671        // Filter switching / Armijo / margin constants (#191). Consumed
5672        // by `FilterLsAcceptor` (only on the `Filter` line-search path);
5673        // registered but never read, so overrides were silently dropped.
5674        // Defaults equal the registered defaults.
5675        if let Some(v) = read_num("eta_phi") {
5676            builder.line_search.eta_phi = v;
5677        }
5678        // `delta` (#551) — the switching rule's multiplier on the
5679        // constraint violation (Eqn. (19)); `FilterLsAcceptor` has
5680        // always used it as `delta_armijo`, only the read site was
5681        // missing. Default 1.0 equals the registered default.
5682        if let Some(v) = read_num("delta") {
5683            builder.line_search.delta = v;
5684        }
5685        if let Some(v) = read_num("theta_min_fact") {
5686            builder.line_search.theta_min_fact = v;
5687        }
5688        if let Some(v) = read_num("theta_max_row_scale_kappa") {
5689            builder.line_search.theta_max_row_scale_kappa = v;
5690        }
5691        if let Some(v) = read_int("theta_max_adaptive_trigger") {
5692            builder.line_search.theta_max_adaptive_trigger = v.max(0) as u32;
5693        }
5694        if let Some(v) = read_num("theta_max_adaptive_factor") {
5695            builder.line_search.theta_max_adaptive_factor = v;
5696        }
5697        if let Some(v) = read_int("theta_max_adaptive_max_raises") {
5698            builder.line_search.theta_max_adaptive_max_raises = v.max(0) as u32;
5699        }
5700        if let Some(v) = read_num("theta_max_fact") {
5701            builder.line_search.theta_max_fact = v;
5702        }
5703        if let Some(v) = read_num("gamma_phi") {
5704            builder.line_search.gamma_phi = v;
5705        }
5706        if let Some(v) = read_num("gamma_theta") {
5707            builder.line_search.gamma_theta = v;
5708        }
5709        if let Some(v) = read_num("s_phi") {
5710            builder.line_search.s_phi = v;
5711        }
5712        if let Some(v) = read_num("s_theta") {
5713            builder.line_search.s_theta = v;
5714        }
5715        if let Some(v) = read_num("alpha_min_frac") {
5716            builder.line_search.alpha_min_frac = v;
5717        }
5718        if let Some(v) = read_num("obj_max_inc") {
5719            builder.line_search.obj_max_inc = v;
5720        }
5721        if let Some(v) = read_int("max_filter_resets") {
5722            builder.line_search.max_filter_resets = v;
5723        }
5724        if let Some(v) = read_int("filter_reset_trigger") {
5725            builder.line_search.filter_reset_trigger = v;
5726        }
5727        // Penalty line-search constants (#551), consumed by
5728        // `PenaltyLsAcceptor` (only on the `line_search_method=penalty`
5729        // / `cg-penalty` paths). The acceptor implements ν and the
5730        // Armijo test on the penalty merit function already; these four
5731        // were registered with no read site, so tuning the penalty
5732        // update did nothing. Defaults equal the registered defaults.
5733        if let Some(v) = read_num("nu_init") {
5734            builder.line_search.nu_init = v;
5735        }
5736        if let Some(v) = read_num("nu_inc") {
5737            builder.line_search.nu_inc = v;
5738        }
5739        if let Some(v) = read_num("rho") {
5740            builder.line_search.rho = v;
5741        }
5742        if let Some(v) = read_num("eta_penalty") {
5743            builder.line_search.eta_penalty = v;
5744        }
5745
5746        // Second-order-correction constants (#191), consumed by
5747        // `BacktrackingLineSearch`. `max_soc = 0` disables SOC.
5748        if let Some(v) = read_int("max_soc") {
5749            builder.line_search.max_soc = v;
5750        }
5751        if let Some(v) = read_num("kappa_soc") {
5752            builder.line_search.kappa_soc = v;
5753        }
5754        if let Some(v) = read_int("soc_method") {
5755            builder.line_search.soc_method = v;
5756        }
5757
5758        // Inertia-correction / Jacobian-regularization constants (#191),
5759        // consumed by `PdPerturbationHandler`. Registered but never read.
5760        if let Some(v) = read_num("max_hessian_perturbation") {
5761            builder.perturbation.max_hessian_perturbation = v;
5762        }
5763        if let Some(v) = read_num("min_hessian_perturbation") {
5764            builder.perturbation.min_hessian_perturbation = v;
5765        }
5766        if let Some(v) = read_num("perturb_inc_fact_first") {
5767            builder.perturbation.perturb_inc_fact_first = v;
5768        }
5769        if let Some(v) = read_num("perturb_inc_fact") {
5770            builder.perturbation.perturb_inc_fact = v;
5771        }
5772        if let Some(v) = read_num("perturb_dec_fact") {
5773            builder.perturbation.perturb_dec_fact = v;
5774        }
5775        if let Some(v) = read_num("first_hessian_perturbation") {
5776            builder.perturbation.first_hessian_perturbation = v;
5777        }
5778        if let Some(v) = read_num("jacobian_regularization_value") {
5779            builder.perturbation.jacobian_regularization_value = v;
5780        }
5781        if let Some(v) = read_num("jacobian_regularization_exponent") {
5782            builder.perturbation.jacobian_regularization_exponent = v;
5783        }
5784        if let Ok((v, true)) = self.options.get_bool_value("perturb_always_cd", "") {
5785            builder.perturbation.perturb_always_cd = v;
5786        }
5787        if let Some(v) = read_int("perturb_delta_c_max_rungs") {
5788            builder.perturbation.perturb_delta_c_max_rungs = v;
5789        }
5790
5791        // Iterative-refinement constants (#191), consumed by
5792        // `PdFullSpaceSolver`. Registered but never read.
5793        if let Some(v) = read_int("min_refinement_steps") {
5794            builder.refinement.min_refinement_steps = v;
5795        }
5796        if let Some(v) = read_int("max_refinement_steps") {
5797            builder.refinement.max_refinement_steps = v;
5798        }
5799        if let Some(v) = read_num("residual_ratio_max") {
5800            builder.refinement.residual_ratio_max = v;
5801        }
5802        if let Some(v) = read_num("residual_ratio_singular") {
5803            builder.refinement.residual_ratio_singular = v;
5804        }
5805        if let Some(v) = read_num("residual_improvement_factor") {
5806            builder.refinement.residual_improvement_factor = v;
5807        }
5808
5809        // Inertia-free curvature test (#551 / #677), also consumed by
5810        // `PdFullSpaceSolver`. `neg_curv_test_tol` had a field that only
5811        // ever held its 0.0 default, and `neg_curv_test_reg` had none at
5812        // all; both are now read, and the curvature test they configure
5813        // is implemented in `PdFullSpaceSolver::solve_once`. At the
5814        // registered default (`0.0`) the heuristic is off and the
5815        // inertia check runs as before.
5816        if let Some(v) = read_num("neg_curv_test_tol") {
5817            builder.refinement.neg_curv_test_tol = v;
5818        }
5819        if let Ok((v, true)) = self.options.get_bool_value("neg_curv_test_reg", "") {
5820            builder.refinement.neg_curv_test_reg = v;
5821        }
5822
5823        // Restoration-phase constants (#191). Carried on the outer builder
5824        // and copied into the `RestoAlgorithmBuilder` when the restoration
5825        // factory is minted (the frontends pass this builder in). The
5826        // restoration builder was never options-configured, so these were
5827        // registered but never read. Defaults equal the registered
5828        // defaults.
5829        if let Some(v) = read_num("bound_mult_reset_threshold") {
5830            builder.resto.bound_mult_reset_threshold = v;
5831        }
5832        if let Some(v) = read_num("constr_mult_reset_threshold") {
5833            builder.resto.constr_mult_reset_threshold = v;
5834        }
5835        if let Some(v) = read_num("resto_penalty_parameter") {
5836            builder.resto.resto_penalty_parameter = v;
5837        }
5838        if let Some(v) = read_num("resto_proximity_weight") {
5839            builder.resto.resto_proximity_weight = v;
5840        }
5841        // `required_infeasibility_reduction` (#439) — the κ_resto guard the
5842        // restoration sub-solve exits on. Registered since #191 but the
5843        // value was hardcoded at the callsite, so setting it was a silent
5844        // no-op.
5845        if let Some(v) = read_num("required_infeasibility_reduction") {
5846            builder.resto.required_infeasibility_reduction = v;
5847        }
5848        // gh#483 / #191 round 2: three restoration switches whose fields
5849        // `RestoAlgorithmBuilder` has consumed all along — the read site
5850        // was the only missing piece, so setting them did nothing.
5851        let read_yes = |key: &str| -> Option<bool> {
5852            match self.options.get_string_value(key, "") {
5853                Ok((v, true)) => Some(v.eq_ignore_ascii_case("yes")),
5854                _ => None,
5855            }
5856        };
5857        if let Some(v) = read_yes("evaluate_orig_obj_at_resto_trial") {
5858            builder.resto.evaluate_orig_obj_at_resto_trial = v;
5859        }
5860        if let Some(v) = read_yes("expect_infeasible_problem") {
5861            builder.resto.expect_infeasible_problem = v;
5862        }
5863        if let Some(v) = read_yes("start_with_resto") {
5864            builder.resto.start_with_resto = v;
5865        }
5866        // `max_resto_iter` (#551 / #677) — the cap on *successive*
5867        // restoration iterations. `RestoConvCheckAdapter` has enforced a cap
5868        // all along (returning `MaxIterExceeded` at the limit); the number
5869        // it enforced was a hard-coded constant in `resto_inner_solver.rs`,
5870        // so setting the option did nothing. The consumer's field is
5871        // `maximum_resto_iters`, not the option name — grepping for
5872        // `max_resto_iter` found only the registry (#551 caution 2).
5873        //
5874        // DEFAULT MISMATCH, LEFT AS IT IS ON PURPOSE: the registry declares
5875        // upstream's 3000000, pounce's effective cap is 3000
5876        // (`RestoOptions::default`). `read_int` fires only when the user set
5877        // the key, so an unset `max_resto_iter` still means 3000 and this
5878        // wiring is trajectory-neutral. Adopting upstream's number would let
5879        // restorations pounce currently truncates run on, which is a
5880        // trajectory change and needs its own measurement.
5881        if let Some(v) = read_int("max_resto_iter") {
5882            builder.resto.max_resto_iter = v;
5883        }
5884
5885        // Iteration-output options — consumed by `OrigIterationOutput`.
5886        if let Some(v) = read_int("print_frequency_iter") {
5887            builder.output.print_frequency_iter = v;
5888        }
5889        if let Some(v) = read_num("print_frequency_time") {
5890            builder.output.print_frequency_time = v;
5891        }
5892        if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
5893            if found {
5894                builder.output.print_info_string = v;
5895            }
5896        }
5897        if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
5898            if found {
5899                builder.output.inf_pr_output_internal = v == "internal";
5900            }
5901        }
5902
5903        // Warm-start options — consumed by `WarmStartIterateInitializer`
5904        // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
5905        // `warm_start_init_point` is the toggle that picks between the
5906        // default (cold) and warm-start initializers; the remaining
5907        // knobs are baked onto the chosen initializer at build time.
5908        if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
5909            if found {
5910                builder.warm_start_init_point = v;
5911            }
5912        }
5913        if let Some(v) = read_num("warm_start_bound_push") {
5914            builder.warm.bound_push = v;
5915        }
5916        if let Some(v) = read_num("warm_start_bound_frac") {
5917            builder.warm.bound_frac = v;
5918        }
5919        if let Some(v) = read_num("warm_start_slack_bound_push") {
5920            builder.warm.slack_bound_push = v;
5921        }
5922        if let Some(v) = read_num("warm_start_slack_bound_frac") {
5923            builder.warm.slack_bound_frac = v;
5924        }
5925        if let Some(v) = read_num("warm_start_mult_bound_push") {
5926            builder.warm.mult_bound_push = v;
5927        }
5928        if let Some(v) = read_num("warm_start_mult_init_max") {
5929            builder.warm.mult_init_max = v;
5930        }
5931        if let Some(v) = read_num("warm_start_target_mu") {
5932            builder.warm.target_mu = v;
5933        }
5934        // gh#606: residual-adaptive recentering. `warm_start_entire_iterate`
5935        // and `warm_start_same_structure` used to be parsed here into
5936        // fields nothing read; they are refused by
5937        // `unimplemented_options` instead (they name the
5938        // `GetWarmStartIterate` TNLP surface pounce does not expose).
5939        if let Ok((v, found)) = self.options.get_string_value("warm_start_recentering", "") {
5940            if found {
5941                builder.warm.recentering = if v.eq_ignore_ascii_case("none") {
5942                    crate::alg_builder::WarmStartRecentering::None
5943                } else {
5944                    crate::alg_builder::WarmStartRecentering::Residual
5945                };
5946            }
5947        }
5948
5949        // `DefaultIterateInitializer` knobs — parsed after the Mehrotra
5950        // cascade so explicit user values win
5951        // (mirrors upstream's `SetNumericValueIfUnset` semantics).
5952        if let Some(v) = read_num("bound_push") {
5953            builder.init.bound_push = v;
5954        }
5955        if let Some(v) = read_num("bound_frac") {
5956            builder.init.bound_frac = v;
5957        }
5958        if let Some(v) = read_num("slack_bound_push") {
5959            builder.init.slack_bound_push = v;
5960        }
5961        if let Some(v) = read_num("slack_bound_frac") {
5962            builder.init.slack_bound_frac = v;
5963        }
5964        if let Some(v) = read_num("constr_mult_init_max") {
5965            builder.init.constr_mult_init_max = v;
5966        }
5967        if let Some(v) = read_num("bound_mult_init_val") {
5968            builder.init.bound_mult_init_val = v;
5969        }
5970        if let Ok((v, found)) = self.options.get_string_value("bound_mult_init_method", "") {
5971            if found {
5972                builder.init.bound_mult_init_method = v;
5973            }
5974        }
5975        if let Ok((v, found)) = self
5976            .options
5977            .get_string_value("least_square_init_primal", "")
5978        {
5979            if found {
5980                builder.init.least_square_init_primal = v == "yes";
5981            }
5982        }
5983        builder
5984    }
5985}
5986
5987/// Map the integer `print_level` / `file_print_level` option to the
5988/// matching [`JournalLevel`] variant. Mirrors upstream's
5989/// `static_cast<EJournalLevel>(int_value)` with clamping.
5990/// The eight block dimensions of an iterate, in canonical order
5991/// (x, s, y_c, y_d, z_l, z_u, v_l, v_u). Used to guard the debugger's
5992/// warm-restart install against a structural mismatch between solves.
5993fn iterates_dims(c: &IteratesVector) -> [i32; 8] {
5994    [
5995        c.x.dim(),
5996        c.s.dim(),
5997        c.y_c.dim(),
5998        c.y_d.dim(),
5999        c.z_l.dim(),
6000        c.z_u.dim(),
6001        c.v_l.dim(),
6002        c.v_u.dim(),
6003    ]
6004}
6005
6006fn journal_level_from_int(v: i32) -> JournalLevel {
6007    match v.clamp(0, 12) {
6008        0 => JournalLevel::J_NONE,
6009        1 => JournalLevel::J_ERROR,
6010        2 => JournalLevel::J_STRONGWARNING,
6011        3 => JournalLevel::J_SUMMARY,
6012        4 => JournalLevel::J_WARNING,
6013        5 => JournalLevel::J_ITERSUMMARY,
6014        6 => JournalLevel::J_DETAILED,
6015        7 => JournalLevel::J_MOREDETAILED,
6016        8 => JournalLevel::J_VECTOR,
6017        9 => JournalLevel::J_MOREVECTOR,
6018        10 => JournalLevel::J_MATRIX,
6019        11 => JournalLevel::J_MOREMATRIX,
6020        _ => JournalLevel::J_ALL,
6021    }
6022}
6023
6024/// MA57 backend knobs snapshotted off an `OptionsList`, ready to be
6025/// handed to a backend factory.
6026///
6027/// The type exists — rather than passing `pounce_hsl::Ma57Options`
6028/// directly — so that the factory signature is the same shape whether or
6029/// not the `ma57` cargo feature is on. `pounce-py`, `pounce-cinterface`
6030/// and `pounce-restoration` all call [`default_backend_factory`] and none
6031/// of them can name a `pounce-hsl` type; without the feature this struct
6032/// carries nothing and costs nothing.
6033///
6034/// Build it with [`ma57_config_from_options`]. It is the MA57 half of
6035/// what [`feral_config_from_options`] does for FERAL, and the asymmetry
6036/// between the two was the root cause of gh#825: FERAL's knobs were
6037/// threaded into the factory and MA57's were not, so the factory had
6038/// nothing to give MA57 and called `Ma57SolverInterface::new()` — which
6039/// hard-codes the defaults. Every `ma57_*` option was registered,
6040/// documented, accepted, and then silently discarded.
6041#[derive(Debug, Clone, Default)]
6042pub struct Ma57Config {
6043    #[cfg(feature = "ma57")]
6044    opts: pounce_hsl::Ma57Options,
6045}
6046
6047impl Ma57Config {
6048    /// The wrapped MA57 settings.
6049    #[cfg(feature = "ma57")]
6050    pub fn options(&self) -> &pounce_hsl::Ma57Options {
6051        &self.opts
6052    }
6053}
6054
6055/// Read the `ma57_*` options off `options` under `prefix`, for handing
6056/// to [`default_backend_factory`] / [`default_backend_factory_with_sink`].
6057///
6058/// `prefix` is `""` for the main IPM and `"resto."` for the restoration
6059/// sub-IPM, mirroring upstream's
6060/// `Ma57TSolverInterface::InitializeImpl(options, prefix)`. The
6061/// restoration sub-IPM builds its own backend through its own
6062/// `InnerBackendFactoryFactory` (see
6063/// `pounce_restoration::resto_inner_solver`), so the two really are
6064/// separately configurable — before gh#825 neither was.
6065///
6066/// Without the `ma57` cargo feature this returns an empty config and
6067/// does not touch `options`; nothing downstream can consume MA57
6068/// settings in that build.
6069pub fn ma57_config_from_options(
6070    options: &pounce_common::options_list::OptionsList,
6071    prefix: &str,
6072) -> Ma57Config {
6073    #[cfg(feature = "ma57")]
6074    {
6075        Ma57Config {
6076            opts: pounce_hsl::Ma57Options::from_options_list(options, prefix),
6077        }
6078    }
6079    #[cfg(not(feature = "ma57"))]
6080    {
6081        let _ = (options, prefix);
6082        Ma57Config::default()
6083    }
6084}
6085
6086/// Construct the MA57 backend for a factory, or fall back to FERAL when
6087/// the `ma57` cargo feature is off.
6088///
6089/// Factored out of the two factories below so there is exactly one place
6090/// that decides how an MA57 backend is built from a [`Ma57Config`]. Both
6091/// factories used to inline `Ma57SolverInterface::new()`, and the
6092/// duplication is half of why gh#825 was easy to miss.
6093fn make_ma57_backend(
6094    ma57_cfg: &Ma57Config,
6095    feral_fallback: impl FnOnce() -> Box<dyn SparseSymLinearSolverInterface>,
6096) -> Box<dyn SparseSymLinearSolverInterface> {
6097    #[cfg(feature = "ma57")]
6098    {
6099        let _ = feral_fallback;
6100        Box::new(pounce_hsl::Ma57SolverInterface::with_options(
6101            *ma57_cfg.options(),
6102        ))
6103    }
6104    #[cfg(not(feature = "ma57"))]
6105    {
6106        // ma57 feature not compiled in — fall back to FERAL.
6107        let _ = ma57_cfg;
6108        feral_fallback()
6109    }
6110}
6111
6112/// Default symmetric linear-solver factory, parameterized by the
6113/// pounce-extension FERAL knobs and the `ma57_*` knobs read off the
6114/// application's `OptionsList`.
6115///
6116/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
6117/// available when the `ma57` cargo feature is enabled; without it,
6118/// requesting `linear_solver = ma57` falls back to FERAL with a
6119/// warning printed by the journalist (see [`AlgorithmBuilder`]).
6120///
6121/// Both configs are snapshots, not live views: take them with
6122/// [`feral_config_from_options`] and [`ma57_config_from_options`] at the
6123/// point the application's options are fully populated. The factory is
6124/// called more than once per solve (the main KKT solver and, under
6125/// limited memory, the Hessian-free bypass solver), so each call gets a
6126/// fresh backend built from the same snapshot.
6127pub fn default_backend_factory(
6128    feral_cfg: pounce_feral::FeralConfig,
6129    ma57_cfg: Ma57Config,
6130) -> LinearBackendFactory {
6131    Box::new(
6132        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
6133            match choice {
6134                LinearSolverChoice::Feral => Box::new(
6135                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
6136                ),
6137                LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
6138                    Box::new(pounce_feral::FeralSolverInterface::with_config(
6139                        feral_cfg.clone(),
6140                    ))
6141                }),
6142            }
6143        },
6144    )
6145}
6146
6147/// Sink-aware variant of [`default_backend_factory`]. Identical
6148/// dispatch, but the FERAL backend is constructed with a
6149/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
6150/// aggregate post-mortem stats (factor counts, fill ratio, extremal
6151/// pivots, final inertia) after the solve returns. MA57 ignores the
6152/// sink — the HSL backend doesn't carry the same instrumentation yet.
6153pub fn default_backend_factory_with_sink(
6154    feral_cfg: pounce_feral::FeralConfig,
6155    ma57_cfg: Ma57Config,
6156    sink: Arc<Mutex<LinearSolverSummary>>,
6157) -> LinearBackendFactory {
6158    Box::new(
6159        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
6160            match choice {
6161                LinearSolverChoice::Feral => Box::new(
6162                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
6163                        .with_summary_sink(Arc::clone(&sink)),
6164                ),
6165                LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
6166                    Box::new(
6167                        pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
6168                            .with_summary_sink(Arc::clone(&sink)),
6169                    )
6170                }),
6171            }
6172        },
6173    )
6174}
6175
6176/// Read the `feral_*` extension options off `options`, falling
6177/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
6178/// for any knob the caller did not set explicitly. The returned
6179/// config is what every default-factory invocation (main IPM and
6180/// restoration sub-IPM) consumes.
6181pub fn feral_config_from_options(
6182    options: &pounce_common::options_list::OptionsList,
6183) -> pounce_feral::FeralConfig {
6184    let mut cfg = pounce_feral::FeralConfig::from_env();
6185    // Tri-state: the `(_, true)` arm only fires when the user set the
6186    // option explicitly. Leaving it unset keeps `cfg.cascade_break` at
6187    // `None`, which inherits FERAL's `NumericParams::default()` (CB on
6188    // as of FERAL Phase B / pounce#55). `Some(false)` explicitly
6189    // disarms (reproduces pre-Phase-B behaviour, surfaces FERAL's
6190    // `DelayBudgetExceeded` on non-root cascade victims).
6191    if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
6192        cfg.cascade_break = Some(v);
6193    }
6194    if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
6195        cfg.fma = v;
6196    }
6197    // Not tri-state, and deliberately not: on the limited-memory path
6198    // the IPM's default is the opposite of the library's (gh#710,
6199    // gh#698 obs 5). `FeralConfig` ships `refine = true` because a
6200    // caller that only refines its own system needs the backend loop.
6201    //
6202    // Scoped to limited-memory, because that is where the win was
6203    // measured and where it comes from. Under L-BFGS the Hessian-free
6204    // bypass batches the low-rank SMW correction into one multi-RHS
6205    // back-solve, and the backend loop refines per right-hand side, so
6206    // its cost scales with the memory depth — while what it polishes is
6207    // the *condensed* system, which is not where Waechter-Biegler 3.10
6208    // puts refinement. Turning it off takes `laptime` under
6209    // limited-memory from 1397 s to 394 s.
6210    //
6211    // The same switch is a net loss on the exact path, so it is not
6212    // applied there. It costs `NARX_CFy` 230 iterations (400 -> 630,
6213    // 173 s -> 250 s) to buy back 34 on `laptime` (380 -> 346). And the
6214    // exact path has no rung left to stand in for it: the
6215    // `increase_quality` escalation that once argued for dropping the
6216    // backend loop here proved inert and was itself removed, so with
6217    // `refine` off the host loop in `PdFullSpaceSolver` is the only
6218    // refinement in the stack — and it stops the moment it crosses
6219    // `residual_ratio_max` (1e-10), three orders looser than the ~1e-16
6220    // the backend-refined solves reach.
6221    //
6222    // Tightening that threshold instead is not the fix, and was measured
6223    // rather than assumed: it is chaotic on this corpus, flipping `deb7`
6224    // to `Error_In_Step_Computation` at 1e-12 and swinging
6225    // `pooling_rt2stp` between 107 and 199 iterations across 1e-11 to
6226    // 1e-13. Forcing extra passes via `min_refinement_steps` behaves the
6227    // same way (both fixtures break at 2). Restoring the backend loop is
6228    // trajectory-neutral next to either (`deb7` 146 -> 147,
6229    // `pooling_rt2stp` 107 -> 109).
6230    //
6231    // Ordered env-then-option so `POUNCE_FERAL_REFINE` still reaches
6232    // this path and an explicit `feral_refine` still beats both.
6233    let limited_memory = matches!(
6234        options.get_string_value("hessian_approximation", ""),
6235        Ok((ref s, true)) if s == "limited-memory"
6236    );
6237    if limited_memory && std::env::var_os("POUNCE_FERAL_REFINE").is_none() {
6238        cfg.refine = false;
6239    }
6240    if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
6241        cfg.refine = v;
6242    }
6243    // gh #850: `feral_increase_quality` exists because this rung is a genuine
6244    // two-sided trade, and the option is the lever — the default is left ON,
6245    // which is the 0.11 behaviour.
6246    //
6247    // Ipopt's `IncreaseQuality` contract assumes a *monotone* escalation: MA57
6248    // raises `pivtol` toward `pivtolmax`, strictly more conservative each time,
6249    // so keeping it raised for the rest of the solve can only make the
6250    // factorization safer. FERAL's ladder changes which pivots are taken, which
6251    // is lateral in trajectory terms, and it persists the same way. So it
6252    // reroutes solves, and the reroute goes both ways:
6253    //
6254    //   it COSTS two whole solves, both `square_flowsheet_resto`:
6255    //     exact  Optimal/99  -> RestorationFailed/131 (a second-opinion rung
6256    //                          rescues it, at 185 iterations total)
6257    //     lbfgs  Optimal/178 -> 3000 iterations at the cap, rescued by nothing
6258    //   it BUYS accuracy where nothing else does:
6259    //     `watchdog_trial_is_not_a_divergence_verdict`'s 12-variable model ends
6260    //     `SolvedToAcceptableLevel` at obj 3.7e-6 with the rung and at obj 3.42
6261    //     against `f* = 0` without it — a wrong-ish answer under a
6262    //     success-shaped status, which is worse than an honest failure.
6263    //   and it buys iterations on five more fixture-legs (15-25%).
6264    //
6265    // There is no scoping that separates those. Measured with a process-global
6266    // firing cap on `square_flowsheet_resto`: the rung fires twice, once in the
6267    // main solve at iteration 25 and once inside restoration at `76r`, and
6268    // allowing only the first still loses the leg — so declining it just for
6269    // the restoration sub-solve would not help. Nor does a count separate them:
6270    // `deb7` and `square_flowsheet_resto` each fire it exactly twice on their
6271    // exact legs, one gaining 16% of its iterations and the other losing its
6272    // verdict.
6273    //
6274    // So the default stands, and the losing direction now recovers itself:
6275    // `feral_increase_quality_retry` (gh#857) re-solves once with this off when
6276    // a solve that actually escalated ends `Restoration_Failed` or
6277    // `Maximum_Iterations_Exceeded`.
6278    //
6279    // It is a re-solve rather than a *revertible* escalation because the
6280    // revertible one was tried. jkitchin/feral#192 landed as `reset_quality`,
6281    // was plumbed here and instrumented (376 escalations, 376 matching resets
6282    // on one solve), and recovers neither leg at either re-baselining boundary
6283    // -- the harm is the destination, not the duration. See
6284    // `dev-notes/second-opinion-promotions-in-the-sweep.md`.
6285    if let Ok((v, true)) = options.get_bool_value("feral_increase_quality", "") {
6286        cfg.increase_quality = v;
6287    }
6288    // Only consulted when `refine` is on; see `FeralConfig::refine_max_steps`
6289    // (gh#710). Registered as an integer option with lower bound 0, so the
6290    // cast cannot go negative.
6291    if let Ok((v, true)) = options.get_integer_value("feral_refine_steps", "") {
6292        cfg.refine_max_steps = v.max(0) as usize;
6293    }
6294    // Also only consulted when `refine` is on. Registered with lower bound
6295    // 0, and 0 disables the pre-check; see `FeralConfig::refine_target`.
6296    if let Ok((v, true)) = options.get_numeric_value("feral_refine_target", "") {
6297        cfg.refine_target = v.max(0.0);
6298    }
6299    // Explicit static-pivoting opt-in (feral#8 cascade breaker, pounce#254).
6300    // Same tri-state discipline: unset leaves `cfg.static_pivoting` at
6301    // whatever `from_env` resolved (`None` → inherit feral's delayed-pivot
6302    // default), so the default numeric path is unchanged.
6303    if let Ok((v, true)) = options.get_bool_value("feral_static_pivoting", "") {
6304        cfg.static_pivoting = Some(v);
6305    }
6306    if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
6307        cfg.singular_pivot_floor = v;
6308    }
6309    // Explicitly set pins an absolute floor for every dimension (`0`
6310    // disables the trigger); left unset, `None` keeps the dimension-aware
6311    // `n * eps` default (pounce gh#592).
6312    if let Ok((v, true)) = options.get_numeric_value("feral_inertia_pivot_floor", "") {
6313        cfg.inertia_pivot_floor = Some(v);
6314    }
6315    // Number option (not integer): the gate is a u64 and Index is i32, too
6316    // narrow for large flop counts or the u64::MAX reject-all sentinel. The
6317    // lower bound (0.0) rules out negatives; `as u64` then saturates a very
6318    // large finite value to u64::MAX (reject all tree-level parallelism).
6319    if let Ok((v, true)) = options.get_numeric_value("feral_min_par_flops", "") {
6320        cfg.min_par_flops = Some(v as u64);
6321    }
6322    if let Ok((v, true)) = options.get_numeric_value("feral_pivtol", "") {
6323        cfg.pivtol = v;
6324    }
6325    // Only override on explicit set so `from_env` (which itself
6326    // defaults to OrderingMethod::Auto) keeps governing unset cases.
6327    // Unrecognized tags are silently ignored — the registered enum
6328    // restricts inputs at the OptionsList layer.
6329    if let Ok((v, true)) = options.get_string_value("feral_ordering", "") {
6330        if let Some(m) = pounce_feral::parse_ordering_method(&v) {
6331            cfg.ordering = m;
6332        }
6333    }
6334    // Same explicit-set discipline as `feral_ordering`: `from_env`
6335    // defaults to ScalingStrategy::Auto (FERAL's current default), so
6336    // leaving the option unset preserves existing behaviour exactly.
6337    if let Ok((v, true)) = options.get_string_value("feral_scaling", "") {
6338        if let Some(s) = pounce_feral::parse_scaling_strategy(&v) {
6339            cfg.scaling = s;
6340        }
6341    }
6342    cfg
6343}
6344
6345/// Withdraw a numerical infeasibility verdict the model's own starting point
6346/// disproves.
6347///
6348/// Applied at every site in this file that can return
6349/// `Infeasible_Problem_Detected` from a *numerical* argument — the IPM path's
6350/// restoration / cycle gates, the SQP path's infeasible-subproblem exit, and the
6351/// ℓ₁ wrapper's uncollapsed-slack certificate. Deliberately one gate rather than
6352/// three: the two preceding safeguards in this area (gh #376, gh #380) were each
6353/// added to one path and not its twin, and a hole survived both times.
6354///
6355/// Not applied to a presolve *certificate*
6356/// (`TNLP::presolve_infeasibility_proof`), which carries its own, tighter
6357/// refutation
6358/// (`pounce_presolve::witness_refutes_infeasibility`) and is a proof rather than
6359/// a numerical inference.
6360///
6361/// The replacement is `Error_In_Step_Computation`, the status this codebase
6362/// already uses for "the solve broke down and we are **not** claiming
6363/// infeasibility" — see the `cycle_exit` fallback in
6364/// [`crate::ipopt_alg::IpoptAlgorithm::invoke_restoration`], which picks between
6365/// exactly these two on exactly this question. It maps to AMPL 500, an honest
6366/// failure the caller can see, instead of AMPL 200, a wrong answer they cannot.
6367///
6368/// gh #379.
6369fn withdraw_infeasibility_if_refuted(
6370    tnlp: &Rc<RefCell<dyn TNLP>>,
6371    solver_status: SolverReturn,
6372    lo_inf: Number,
6373    up_inf: Number,
6374    tol: Number,
6375) -> SolverReturn {
6376    if solver_status != SolverReturn::LocalInfeasibility {
6377        return solver_status;
6378    }
6379    // A presolve proof is not a numerical inference; it does its own refutation.
6380    if tnlp.borrow().presolve_infeasibility_proof().is_some() {
6381        return solver_status;
6382    }
6383    match crate::infeasibility_refutation::starting_point_refutes_infeasibility(
6384        tnlp, lo_inf, up_inf, tol,
6385    ) {
6386        Some(w) => {
6387            tracing::debug!(
6388                target: "pounce::application",
6389                "[PN_INFEAS_REFUTED] the model's starting point satisfies every constraint \
6390                 (max violation {:.3e}) — withdrawing Infeasible_Problem_Detected",
6391                w.max_violation
6392            );
6393            SolverReturn::ErrorInStepComputation
6394        }
6395        None => solver_status,
6396    }
6397}
6398
6399/// How well a point satisfies the **user's own** rows and bounds.
6400///
6401/// Computed from `g = c(x)` and the inner TNLP's declared bounds, in the
6402/// user's units, with no reference to whatever problem the algorithm
6403/// actually iterated on. That distinction is the whole reason this
6404/// exists: on the ℓ₁ path the IPM converges the *augmented* NLP
6405/// `c(x) − p + n = target`, whose equality rows the slacks satisfy to
6406/// machine precision by construction, and reporting that residual as
6407/// the solve's constraint violation says nothing about `c(x) − target`
6408/// (gh#794 finding P1).
6409struct OriginalSpaceFeasibility {
6410    /// Largest absolute violation of any row or bound, in the user's units.
6411    max_violation: Number,
6412    /// Every row and bound negligible at `tol`, judged scale-relative.
6413    negligible_at_tol: bool,
6414    /// The same at `acceptable_tol`.
6415    negligible_at_acceptable: bool,
6416}
6417
6418/// Measure [`OriginalSpaceFeasibility`] at `x`, given `g = c(x)`.
6419///
6420/// `is_negligible` rather than `!is_significant`, deliberately, and for
6421/// the reason that function's own documentation gives: the question here
6422/// is "did the solve converge well enough to call this point feasible",
6423/// which must never demand more precision than the solver promised, so
6424/// the threshold is clamped at `tol` from below (`tol · max(|scale|, 1)`).
6425/// The refutation path next door asks the opposite question — "is this
6426/// residual real at this row's scale" — and correctly uses the pure
6427/// relative form.
6428///
6429/// Returns `None` when the model cannot be measured (bounds unreadable, a
6430/// non-finite value). `None` means "not measured", never "feasible": the
6431/// caller keeps whatever verdict it already had.
6432fn original_space_feasibility(
6433    tnlp: &Rc<RefCell<dyn TNLP>>,
6434    x: &[Number],
6435    g: &[Number],
6436    lower_bound_inf: Number,
6437    upper_bound_inf: Number,
6438    tol: Number,
6439    acceptable_tol: Number,
6440    constr_viol_tol: Number,
6441    acceptable_constr_viol_tol: Number,
6442    noise_floor_kappa: Number,
6443) -> Option<OriginalSpaceFeasibility> {
6444    use pounce_common::tolerance::is_negligible;
6445
6446    let info = tnlp.borrow_mut().get_nlp_info()?;
6447    let n = info.n.max(0) as usize;
6448    let m = info.m.max(0) as usize;
6449    if x.len() < n || g.len() < m {
6450        return None;
6451    }
6452
6453    let mut x_l = vec![0.0; n];
6454    let mut x_u = vec![0.0; n];
6455    let mut g_l = vec![0.0; m];
6456    let mut g_u = vec![0.0; m];
6457    if !tnlp.borrow_mut().get_bounds_info(BoundsInfo {
6458        x_l: &mut x_l,
6459        x_u: &mut x_u,
6460        g_l: &mut g_l,
6461        g_u: &mut g_u,
6462    }) {
6463        return None;
6464    }
6465
6466    let mut max_violation: Number = 0.0;
6467    let mut ok_tol = true;
6468    let mut ok_acceptable = true;
6469
6470    // Only *finite, present* bounds inform a row's magnitude: letting the
6471    // `±1e19` sentinel set the scale would make every row look satisfied,
6472    // the same trap `infeasibility_refutation` documents.
6473    let present = |b: Number, is_lower: bool| -> Option<Number> {
6474        let absent = if is_lower {
6475            b <= lower_bound_inf
6476        } else {
6477            b >= upper_bound_inf
6478        };
6479        (b.is_finite() && !absent).then_some(b)
6480    };
6481
6482    // Accumulates into `max_violation` / `ok_tol` / `ok_acceptable`; a
6483    // non-positive `viol` means the side is satisfied and contributes
6484    // nothing. Returns nothing — every caller is a statement.
6485    let mut judge = |viol: Number, scale: Number| {
6486        if viol <= 0.0 {
6487            return;
6488        }
6489        max_violation = max_violation.max(viol);
6490        // The scale-relative test alone is not a feasibility standard, and
6491        // on a large-magnitude row it is not even close to one: it accepts
6492        // anything up to `tol · |row|`, which on a row near `1e10` is `1e2`
6493        // at the default `tol`. An adversary probe on this branch built a
6494        // model infeasible by exactly `50` with its row at `1e10` and got
6495        // `Solve_Succeeded` — a *worse* verdict than this branch's own
6496        // parent, which refused the same point (the old `Σ(p+n)` argument
6497        // was crude but absolute). So the wrapper has to judge feasibility
6498        // the way the rest of the solver does.
6499        //
6500        // `OptErrorConvCheck::primal_component_passes` is that standard:
6501        // an absolute `constr_viol <= constr_viol_tol`, with scale-awareness
6502        // supplied by an abstention when every row sits at its own
6503        // floating-point noise floor (gh#528/gh#590) rather than by
6504        // multiplying the tolerance by the row's magnitude. That
6505        // abstention "cannot fabricate a success on a genuinely infeasible
6506        // model: such a model's violation is pinned at its infeasibility
6507        // gap, orders above `eps ·` the row's own magnitude" — which is
6508        // exactly the property `is_negligible` lacks and the probe
6509        // exploited (`50` is `~2e7 ×` this row's floor).
6510        //
6511        // The strict gate's `primal_resolvable` cannot be reused verbatim:
6512        // it is computed by the CQ on the *augmented* NLP, whose rows the
6513        // slacks satisfy to machine precision, so it would abstain always
6514        // and accept everything. The floor is therefore recomputed here on
6515        // the user's own row, from the same `kappa · eps · magnitude` the
6516        // option documents. `primal_noise_floor_kappa = 0` opts out, as it
6517        // does for the strict gate.
6518        //
6519        // Both arms are conjoined rather than substituted: the relative
6520        // test still catches a violation that is small in absolute terms
6521        // but large for its row, which is the gh#794 P1 case itself
6522        // (`ralph1` at `2.5e-7` under a `2.5e-11` tol).
6523        let noise = noise_floor_kappa * Number::EPSILON * scale.abs();
6524        let absolute_ok = |bound: Number| viol <= bound || viol <= noise;
6525        ok_tol &= is_negligible(viol, scale, tol) && absolute_ok(constr_viol_tol);
6526        ok_acceptable &=
6527            is_negligible(viol, scale, acceptable_tol) && absolute_ok(acceptable_constr_viol_tol);
6528    };
6529
6530    for i in 0..m {
6531        let v = g[i];
6532        if !v.is_finite() {
6533            return None;
6534        }
6535        let lo = present(g_l[i], true);
6536        let hi = present(g_u[i], false);
6537        let scale = v
6538            .abs()
6539            .max(lo.map_or(0.0, Number::abs))
6540            .max(hi.map_or(0.0, Number::abs));
6541        judge(lo.map_or(0.0, |b| b - v), scale);
6542        judge(hi.map_or(0.0, |b| v - b), scale);
6543    }
6544    for j in 0..n {
6545        let v = x[j];
6546        if !v.is_finite() {
6547            return None;
6548        }
6549        let lo = present(x_l[j], true);
6550        let hi = present(x_u[j], false);
6551        let scale = v
6552            .abs()
6553            .max(lo.map_or(0.0, Number::abs))
6554            .max(hi.map_or(0.0, Number::abs));
6555        judge(lo.map_or(0.0, |b| b - v), scale);
6556        judge(hi.map_or(0.0, |b| v - b), scale);
6557    }
6558
6559    Some(OriginalSpaceFeasibility {
6560        max_violation,
6561        negligible_at_tol: ok_tol,
6562        negligible_at_acceptable: ok_acceptable,
6563    })
6564}
6565
6566/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
6567/// Mirrors the table in
6568/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
6569/// map") and the corresponding switch in
6570/// `IpIpoptApplication.cpp:call_optimize`.
6571fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
6572    match s {
6573        SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
6574        SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
6575        SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
6576        SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
6577        SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
6578        SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
6579        SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
6580        SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
6581        SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
6582        SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
6583        SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
6584        SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
6585        SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
6586        SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
6587        SolverReturn::InvalidProblemDefinition => ApplicationReturnStatus::InvalidProblemDefinition,
6588        SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
6589        SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
6590        SolverReturn::InternalError | SolverReturn::Unassigned => {
6591            ApplicationReturnStatus::InternalError
6592        }
6593    }
6594}
6595
6596/// Best-effort evaluation of the objective at the algorithm's final
6597/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
6598/// to populate `SolveStatistics::final_scaled_objective`.
6599fn try_eval_curr_f(
6600    nlp: &Rc<RefCell<dyn IpoptNlp>>,
6601    x: &Rc<dyn pounce_linalg::Vector>,
6602) -> Result<Number, ()> {
6603    let mut nlp_mut = nlp.borrow_mut();
6604    Ok(nlp_mut.eval_f(&**x))
6605}
6606
6607/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
6608/// `true` when a status warrants a retry through the wrapper. Mirrors
6609/// ripopt#23's trigger set, extended per the audit's Refinement B
6610/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
6611/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
6612/// equivalent would let pass to the wrapper).
6613fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
6614    matches!(
6615        status,
6616        ApplicationReturnStatus::RestorationFailed
6617            | ApplicationReturnStatus::InfeasibleProblemDetected
6618            | ApplicationReturnStatus::SolvedToAcceptableLevel
6619            | ApplicationReturnStatus::MaximumIterationsExceeded
6620            | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
6621    )
6622}
6623
6624/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
6625/// We pull `x` (compressed in `x_var`-space) off the algorithm's
6626/// `data.curr`, lift it back to full TNLP indexing, and pass empty
6627/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
6628/// in compressed split form — re-assembling them into the user's
6629/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
6630/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
6631/// fleshed out). On success returns the unscaled objective evaluated
6632/// on the user TNLP at the final iterate; returns `Err` if the final
6633/// iterate is missing.
6634/// Read a `dyn Vector`'s entries. Empty for a non-dense backing; POUNCE is
6635/// dense-only, so that is defensive rather than a supported case.
6636fn dense_values(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
6637    v.as_any()
6638        .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
6639        .map(|d| d.expanded_values())
6640        .unwrap_or_default()
6641}
6642
6643/// Overwrite a `dyn Vector`'s entries. Returns false — writing nothing —
6644/// when the backing is not dense or the lengths disagree, so a caller
6645/// updating several components together can abandon the whole update rather
6646/// than leave a half-written iterate.
6647fn set_dense(v: &mut dyn pounce_linalg::Vector, vals: &[Number]) -> bool {
6648    match v
6649        .as_any_mut()
6650        .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
6651    {
6652        Some(d) if pounce_linalg::Vector::dim(d) as usize == vals.len() => {
6653            d.set_values(vals);
6654            true
6655        }
6656        _ => false,
6657    }
6658}
6659
6660/// An owned copy of a [`Solution`] payload already delivered to the user's
6661/// TNLP, enough to deliver it again.
6662///
6663/// Exists because a losing second-opinion retry has to be undoable. The status
6664/// a retry earns is already floored — `run_with_mu_strategy_fallback` returns
6665/// `first_status` unless the retry promotes — but the *point* was not, and the
6666/// user consumes the point. See that function for what went wrong without this.
6667#[derive(Debug, Clone)]
6668struct FinalizeSnapshot {
6669    status: SolverReturn,
6670    x: Vec<Number>,
6671    z_l: Vec<Number>,
6672    z_u: Vec<Number>,
6673    g: Vec<Number>,
6674    lambda: Vec<Number>,
6675    obj_value: Number,
6676}
6677
6678/// The `final_*` half of [`SolveStatistics`] — the numbers that describe the
6679/// answer and the certificate attached to it.
6680///
6681/// Deliberately **not** the whole struct. `SolveStatistics` mixes two kinds of
6682/// number and they float back differently when a second-opinion retry loses:
6683///
6684/// * the `final_*` fields describe *the point being reported*, so they have to
6685///   agree with the status reported beside them. A `Solved_To_Acceptable_Level`
6686///   carrying a `final_kkt_error` two orders above `acceptable_tol` is
6687///   self-contradictory, and that is pounce#870.
6688/// * `iteration_count`, the evaluation counts, the timers, the restoration
6689///   tallies and `quality_escalations` describe *what the invocation did*.
6690///   Both attempts really ran, so rewinding those would under-report the work
6691///   actually spent — a different falsehood, not a fix. `deb7` at
6692///   `max_iter=100` is the case that caught this: rewinding the counter made
6693///   the run claim an iteration count belonging to only one of its two solves
6694///   (`issue857_escalation_gated_quality_rung.rs`).
6695///
6696/// So the certificate is floored and the cost is not.
6697#[derive(Debug, Clone, Copy)]
6698struct SolutionCertificate {
6699    objective: Number,
6700    scaled_objective: Number,
6701    dual_inf: Number,
6702    constr_viol: Number,
6703    compl: Number,
6704    kkt_error: Number,
6705    unscaled_dual_inf: Number,
6706    unscaled_constr_viol: Number,
6707    unscaled_compl: Number,
6708    unscaled_kkt_error: Number,
6709    kkt_error_above_noise: Number,
6710    mu: Number,
6711}
6712
6713impl SolutionCertificate {
6714    fn of(s: &pounce_nlp::solve_statistics::SolveStatistics) -> Self {
6715        Self {
6716            objective: s.final_objective,
6717            scaled_objective: s.final_scaled_objective,
6718            dual_inf: s.final_dual_inf,
6719            constr_viol: s.final_constr_viol,
6720            compl: s.final_compl,
6721            kkt_error: s.final_kkt_error,
6722            unscaled_dual_inf: s.final_unscaled_dual_inf,
6723            unscaled_constr_viol: s.final_unscaled_constr_viol,
6724            unscaled_compl: s.final_unscaled_compl,
6725            unscaled_kkt_error: s.final_unscaled_kkt_error,
6726            kkt_error_above_noise: s.final_kkt_error_above_noise,
6727            mu: s.final_mu,
6728        }
6729    }
6730
6731    fn restore_into(&self, s: &mut pounce_nlp::solve_statistics::SolveStatistics) {
6732        s.final_objective = self.objective;
6733        s.final_scaled_objective = self.scaled_objective;
6734        s.final_dual_inf = self.dual_inf;
6735        s.final_constr_viol = self.constr_viol;
6736        s.final_compl = self.compl;
6737        s.final_kkt_error = self.kkt_error;
6738        s.final_unscaled_dual_inf = self.unscaled_dual_inf;
6739        s.final_unscaled_constr_viol = self.unscaled_constr_viol;
6740        s.final_unscaled_compl = self.unscaled_compl;
6741        s.final_unscaled_kkt_error = self.unscaled_kkt_error;
6742        s.final_kkt_error_above_noise = self.kkt_error_above_noise;
6743        s.final_mu = self.mu;
6744    }
6745}
6746
6747impl FinalizeSnapshot {
6748    /// Re-deliver this payload to `tnlp`, overwriting whatever a later attempt
6749    /// captured there.
6750    fn replay(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
6751        tnlp.borrow_mut().finalize_solution(
6752            Solution {
6753                status: self.status,
6754                x: &self.x,
6755                z_l: &self.z_l,
6756                z_u: &self.z_u,
6757                g: &self.g,
6758                lambda: &self.lambda,
6759                obj_value: self.obj_value,
6760            },
6761            &TnlpIpoptData::default(),
6762            &TnlpIpoptCq::default(),
6763        );
6764    }
6765}
6766
6767fn finalize_via_orig_nlp(
6768    nlp: &Rc<RefCell<dyn IpoptNlp>>,
6769    alg: &IpoptAlgorithm,
6770    solver_status: SolverReturn,
6771    _app_status: ApplicationReturnStatus,
6772    tnlp: &Rc<RefCell<dyn TNLP>>,
6773    sink: &RefCell<Option<FinalizeSnapshot>>,
6774) -> Result<Number, ()> {
6775    let curr = alg.data.borrow().curr.clone().ok_or(())?;
6776    // Lift compressed x_var → full-x (length `info.n`) so the user
6777    // TNLP receives the same shape it provided. With `make_parameter`
6778    // the fixed components are spliced back in by the IpoptNlp.
6779    let nlp_borrow = nlp.borrow();
6780    // `finalize_solution_x`, not `lift_x_to_full`: the reported point also
6781    // owes the user the `honor_original_bounds` projection. `f` and `g`
6782    // below are then evaluated at the point actually reported, so x/f/g
6783    // agree with each other.
6784    let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&*curr.x);
6785    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
6786    let n = info.n as usize;
6787    let m = info.m as usize;
6788    debug_assert_eq!(x_vec.len(), n);
6789    // Lift algorithm-side multipliers back into user-space (pounce#11).
6790    // Use the `finalize_solution_*` family (not the `pack_*` family): the
6791    // final solution duals must be reported in the user's *unscaled-
6792    // Lagrangian* convention `∇f + λ·∇g + z = 0`, which divides out the
6793    // `obj_scale_factor` the algorithm threads through `eval_h`. The `pack_*`
6794    // family deliberately omits that division because it feeds the scaled
6795    // `eval_h`; calling it here left every dual scaled by `obj_scale_factor`
6796    // whenever gradient-based scaling triggered (pounce#11 F1).
6797    // Backends without overrides return empty; fall back to zero stubs so the
6798    // user sees a length-consistent vector.
6799    let mut z_l = nlp_borrow.finalize_solution_z_l(&*curr.z_l);
6800    if z_l.is_empty() {
6801        z_l = vec![0.0; n];
6802    }
6803    let mut z_u = nlp_borrow.finalize_solution_z_u(&*curr.z_u);
6804    if z_u.is_empty() {
6805        z_u = vec![0.0; n];
6806    }
6807    let mut lambda = nlp_borrow.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
6808    if lambda.is_empty() {
6809        lambda = vec![0.0; m];
6810    }
6811    drop(nlp_borrow);
6812    // Compute g(x) via the user TNLP so the final residual is
6813    // populated for the user.
6814    let mut g_final = vec![0.0; m];
6815    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
6816    let f_final = tnlp
6817        .borrow_mut()
6818        .eval_f(&x_vec, true)
6819        .unwrap_or(Number::NAN);
6820    let snap = FinalizeSnapshot {
6821        status: solver_status,
6822        x: x_vec,
6823        z_l,
6824        z_u,
6825        g: g_final,
6826        lambda,
6827        obj_value: f_final,
6828    };
6829    snap.replay(tnlp);
6830    *sink.borrow_mut() = Some(snap);
6831    Ok(f_final)
6832}
6833
6834/// Bind SQP suboptions registered in `upstream_options.rs`
6835/// (`sqp_globalization`, `sqp_hessian`, `sqp_max_iter`, `sqp_tol`,
6836/// `sqp_constr_viol_tol`, `sqp_dual_inf_tol`, `sqp_l1_penalty`,
6837/// `sqp_bt_reduction`, `sqp_bt_min_alpha`, `sqp_print_level`,
6838/// `sqp_lbfgs_max_history`) onto
6839/// `opts`. Used by [`IpoptApplication::algorithm_builder_snapshot`]
6840/// before constructing an SQP algorithm.
6841fn apply_sqp_options(options: &OptionsList, opts: &mut crate::sqp::SqpOptions) {
6842    use crate::sqp::{SqpGlobalization, SqpHessianSource};
6843
6844    if let Ok((s, true)) = options.get_string_value("sqp_globalization", "") {
6845        opts.globalization = match s.as_str() {
6846            "filter" => SqpGlobalization::Filter,
6847            "l1-elastic" => SqpGlobalization::L1Elastic,
6848            _ => opts.globalization,
6849        };
6850    }
6851    // `hessian_approximation` is the upstream Ipopt option a frontend sets
6852    // when the caller supplies no second derivatives -- `pounce.minimize` does
6853    // it automatically, and warns that it is doing so. It was only ever read
6854    // on the IPM path, so an SQP solve ignored it and fell back to the
6855    // `Exact` default, asking the NLP for a Lagrangian Hessian that was never
6856    // provided. A zero Hessian turns the QP subproblem into an LP, which is
6857    // unbounded whenever the objective gradient has a component in the null
6858    // space of the active constraints -- so the solve died with
6859    // `Internal_Error` on problems the IPM handles without complaint:
6860    //
6861    //     min (x0-3)^2 + (x1-2)^2  s.t.  4 - x0 - x1 >= 0
6862    //
6863    // (IPM: x = [2.5, 1.5]. Active-set SQP before this: Internal_Error, or
6864    // with variable bounds, a run to the box corner along the null-space
6865    // direction.)
6866    //
6867    // The quasi-Newton source picked here is the *dense Powell-damped BFGS*,
6868    // not the limited-memory one, even though the requesting option is spelled
6869    // `limited-memory`. On this active-set-SQP path L-BFGS buys nothing: its
6870    // `as_triplet` materializes a full dense `n×n` Hessian for the QP
6871    // subproblem exactly as `DampedBfgs` does (the matrix-free product
6872    // interface that would make L-BFGS cheaper is not implemented yet), and it
6873    // is markedly less robust -- it stalls with
6874    // `Search_Direction_Becomes_Too_Small` (or reports the QP subproblem
6875    // `unbounded`) on easy, well-conditioned convex QPs whenever a general
6876    // inequality is active at the optimum, returning `success=False` with a
6877    // wrong `x` (issue #358). `DampedBfgs` solves those. So the automatic
6878    // approximation the facade injects when no analytic Hessian is available
6879    // maps to the robust dense update; a caller who genuinely wants
6880    // limited-memory storage can still request it explicitly with
6881    // `sqp_hessian = "lbfgs"` below (read after this, so it wins).
6882    //
6883    // Read this before `sqp_hessian` so an explicit setting still wins.
6884    if let Ok((s, true)) = options.get_string_value("hessian_approximation", "") {
6885        if s == "limited-memory" {
6886            opts.hessian = SqpHessianSource::DampedBfgs;
6887        }
6888    }
6889    if let Ok((s, true)) = options.get_string_value("sqp_hessian", "") {
6890        opts.hessian = match s.as_str() {
6891            "exact" => SqpHessianSource::Exact,
6892            "damped-bfgs" => SqpHessianSource::DampedBfgs,
6893            "lbfgs" => SqpHessianSource::Lbfgs,
6894            _ => opts.hessian,
6895        };
6896    }
6897    if let Ok((v, true)) = options.get_integer_value("sqp_max_iter", "") {
6898        if v >= 0 {
6899            opts.max_iter = v as u32;
6900        }
6901    }
6902    if let Ok((v, true)) = options.get_numeric_value("sqp_tol", "") {
6903        opts.tol = v;
6904    }
6905    if let Ok((v, true)) = options.get_numeric_value("sqp_constr_viol_tol", "") {
6906        opts.constr_viol_tol = v;
6907    }
6908    if let Ok((v, true)) = options.get_numeric_value("sqp_dual_inf_tol", "") {
6909        opts.dual_inf_tol = v;
6910    }
6911    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty", "") {
6912        opts.l1_penalty = v;
6913    }
6914    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_safety", "") {
6915        opts.l1_penalty_safety = v;
6916    }
6917    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_max", "") {
6918        opts.l1_penalty_max = v;
6919    }
6920    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_reduction", "") {
6921        opts.bt_reduction = v;
6922    }
6923    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_min_alpha", "") {
6924        opts.bt_min_alpha = v;
6925    }
6926    if let Ok((v, true)) = options.get_integer_value("sqp_print_level", "") {
6927        opts.print_level = v.clamp(0, u8::MAX as i32) as u8;
6928    }
6929    if let Ok((v, true)) = options.get_integer_value("sqp_lbfgs_max_history", "") {
6930        if v >= 1 {
6931            opts.lbfgs_max_history = v as u32;
6932        }
6933    }
6934}
6935
6936/// Populate the active-set SQP **QP-subproblem** options
6937/// ([`pounce_qp::QpOptions`]) from the `sqp_qp_*` option family.
6938///
6939/// Sister to [`apply_sqp_options`], which handles the SQP *outer-loop*
6940/// options ([`crate::sqp::SqpOptions`]); this one feeds the inner QP
6941/// solver that `SqpAlgorithm` delegates each subproblem to. Consulted
6942/// only on the `ActiveSetSqp` path. Each knob is forwarded only when
6943/// the user explicitly set it, so the `pounce_qp` defaults stand
6944/// otherwise.
6945///
6946/// The reading itself is [`pounce_qp::ActiveSetOverrides`], shared with
6947/// `pounce_convex`'s direct active-set driver, which overlays the same
6948/// eight names onto the same `QpOptions` type. This function had its own
6949/// copy until then, and the two had drifted: this one silently ignored a
6950/// `sqp_qp_max_iter` of 0 and an unknown `sqp_qp_anti_cycling` value where
6951/// the other rejected them. Neither divergence was reachable — the
6952/// registry bounds `sqp_qp_max_iter` at 1 and restricts `anti_cycling` to
6953/// three values — but two readers of one option family is how a
6954/// reachable one starts.
6955fn apply_qp_subproblem_options(options: &OptionsList, opts: &mut pounce_qp::QpOptions) {
6956    match pounce_qp::ActiveSetOverrides::try_from_options_list(options) {
6957        Ok(overrides) => overrides.apply(opts),
6958        // Unreachable from here: this runs after `initialize()`, so every
6959        // value present has already been validated against the registered
6960        // bound that the reader re-checks. Say so out loud anyway rather
6961        // than solving with a configuration the user did not ask for —
6962        // silently dropping the whole family is exactly the failure mode
6963        // `tests/no_silent_options.rs` exists to prevent.
6964        Err(error) => tracing::error!(
6965            target: "pounce::options",
6966            %error,
6967            "sqp_qp_* options were rejected after the registry accepted them; \
6968             the QP subproblem is running on pounce-qp defaults"
6969        ),
6970    }
6971}
6972
6973/// SQP-side analog of [`finalize_via_orig_nlp`]. Hands the SQP
6974/// solution iterate to the user TNLP via the standard
6975/// `finalize_solution` callback. Multiplier lifting goes through
6976/// the same OrigIpoptNlp hooks so the user sees the same shape
6977/// regardless of which algorithm produced the iterate.
6978///
6979/// Returns the user-space objective value on success.
6980fn finalize_via_sqp(
6981    nlp: &Rc<RefCell<dyn IpoptNlp>>,
6982    res: &crate::sqp::SqpResult,
6983    solver_status: pounce_nlp::SolverReturn,
6984    tnlp: &Rc<RefCell<dyn TNLP>>,
6985    sink: &RefCell<Option<FinalizeSnapshot>>,
6986) -> Result<Number, ()> {
6987    use pounce_linalg::dense_vector::DenseVectorSpace;
6988
6989    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
6990    let n = info.n as usize;
6991    let m = info.m as usize;
6992
6993    // Wrap SQP slices in DenseVectors so we can pass them through
6994    // the OrigIpoptNlp lift_x_to_full / pack_*_for_user hooks.
6995    let nlp_borrow = nlp.borrow();
6996    let n_alg = nlp_borrow.n() as usize;
6997    let m_eq = nlp_borrow.m_eq() as usize;
6998    let m_ineq = nlp_borrow.m_ineq() as usize;
6999    debug_assert_eq!(res.x.len(), n_alg);
7000    debug_assert_eq!(res.lambda_g.len(), m_eq + m_ineq);
7001    debug_assert_eq!(res.lambda_x.len(), n_alg);
7002
7003    let x_space = DenseVectorSpace::new(n_alg as Index);
7004    let c_space = DenseVectorSpace::new(m_eq as Index);
7005    let d_space = DenseVectorSpace::new(m_ineq as Index);
7006
7007    let mut x_dv = x_space.make_new_dense();
7008    x_dv.set_values(&res.x);
7009    let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&x_dv);
7010    debug_assert_eq!(x_vec.len(), n);
7011
7012    // λ_x is packed signed (z_l − z_u). Split for lift.
7013    let mut z_l_compressed = x_space.make_new_dense();
7014    let mut z_u_compressed = x_space.make_new_dense();
7015    let zl_vals: Vec<Number> = res.lambda_x.iter().map(|v| v.max(0.0)).collect();
7016    let zu_vals: Vec<Number> = res.lambda_x.iter().map(|v| (-v).max(0.0)).collect();
7017    z_l_compressed.set_values(&zl_vals);
7018    z_u_compressed.set_values(&zu_vals);
7019    // `finalize_solution_*` (not `pack_*`): report unscaled-Lagrangian duals,
7020    // dividing out `obj_scale_factor` — see `finalize_via_orig_nlp` (F1).
7021    let mut z_l = nlp_borrow.finalize_solution_z_l(&z_l_compressed);
7022    if z_l.is_empty() {
7023        z_l = vec![0.0; n];
7024    }
7025    let mut z_u = nlp_borrow.finalize_solution_z_u(&z_u_compressed);
7026    if z_u.is_empty() {
7027        z_u = vec![0.0; n];
7028    }
7029
7030    // λ_g is [y_c; y_d]; split into the c/d blocks for lift.
7031    let mut y_c_dv = c_space.make_new_dense();
7032    let mut y_d_dv = d_space.make_new_dense();
7033    if m_eq > 0 {
7034        y_c_dv.set_values(&res.lambda_g[..m_eq]);
7035    }
7036    if m_ineq > 0 {
7037        y_d_dv.set_values(&res.lambda_g[m_eq..]);
7038    }
7039    let mut lambda = nlp_borrow.finalize_solution_lambda(&y_c_dv, &y_d_dv);
7040    if lambda.is_empty() {
7041        lambda = vec![0.0; m];
7042    }
7043    drop(nlp_borrow);
7044
7045    let mut g_final = vec![0.0; m];
7046    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
7047    let f_final = tnlp
7048        .borrow_mut()
7049        .eval_f(&x_vec, true)
7050        .unwrap_or(Number::NAN);
7051    let snap = FinalizeSnapshot {
7052        status: solver_status,
7053        x: x_vec,
7054        z_l,
7055        z_u,
7056        g: g_final,
7057        lambda,
7058        obj_value: f_final,
7059    };
7060    snap.replay(tnlp);
7061    *sink.borrow_mut() = Some(snap);
7062    Ok(f_final)
7063}
7064
7065#[cfg(test)]
7066mod tests {
7067    use super::*;
7068
7069    /// pounce#748 — the flipped default is conditional on the caller not
7070    /// having named a `mu_strategy`. All four combinations, because the
7071    /// point of the condition is that an explicit strategy suppresses the
7072    /// automatic retry while an explicit `mu_strategy_fallback` does not.
7073    #[test]
7074    fn mu_strategy_fallback_default_defers_to_an_explicit_strategy() {
7075        // Nothing set: the retry is on.
7076        let app = IpoptApplication::new();
7077        assert!(app.is_mu_strategy_fallback_enabled());
7078
7079        // Caller named a strategy: the automatic retry stands down.
7080        let mut app = IpoptApplication::new();
7081        app.options_mut()
7082            .set_string_value("mu_strategy", "monotone", true, false)
7083            .unwrap();
7084        assert!(!app.is_mu_strategy_fallback_enabled());
7085
7086        // ... unless they also asked for the retry explicitly.
7087        app.options_mut()
7088            .set_string_value("mu_strategy_fallback", "yes", true, false)
7089            .unwrap();
7090        assert!(app.is_mu_strategy_fallback_enabled());
7091
7092        // An explicit "no" is honoured with no strategy set.
7093        let mut app = IpoptApplication::new();
7094        app.options_mut()
7095            .set_string_value("mu_strategy_fallback", "no", true, false)
7096            .unwrap();
7097        assert!(!app.is_mu_strategy_fallback_enabled());
7098    }
7099
7100    use pounce_nlp::tnlp::{
7101        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution,
7102        SparsityRequest, StartingPoint,
7103    };
7104
7105    struct Hs071Stub;
7106    impl TNLP for Hs071Stub {
7107        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
7108            // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
7109            // dense lower-triangular Hessian (10 nz).
7110            Some(NlpInfo {
7111                n: 4,
7112                m: 2,
7113                nnz_jac_g: 8,
7114                nnz_h_lag: 10,
7115                index_style: IndexStyle::C,
7116            })
7117        }
7118        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
7119            b.x_l.copy_from_slice(&[1.0; 4]);
7120            b.x_u.copy_from_slice(&[5.0; 4]);
7121            b.g_l.copy_from_slice(&[25.0, 40.0]);
7122            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
7123            true
7124        }
7125        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
7126            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
7127            true
7128        }
7129        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
7130            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
7131        }
7132        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
7133            grad.fill(0.0);
7134            true
7135        }
7136        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
7137            g.fill(0.0);
7138            true
7139        }
7140        fn eval_jac_g(
7141            &mut self,
7142            _x: Option<&[Number]>,
7143            _new_x: bool,
7144            mode: SparsityRequest<'_>,
7145        ) -> bool {
7146            if let SparsityRequest::Structure { irow, jcol } = mode {
7147                irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
7148                jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
7149            }
7150            true
7151        }
7152        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
7153    }
7154
7155    #[test]
7156    fn application_default_does_not_select_sqp() {
7157        let mut app = IpoptApplication::new();
7158        app.initialize().unwrap();
7159        assert!(!app.is_sqp_algorithm_selected());
7160    }
7161
7162    #[test]
7163    fn application_routes_to_sqp_when_algorithm_option_set() {
7164        let mut app = IpoptApplication::new();
7165        app.initialize().unwrap();
7166        app.initialize_with_options_str("algorithm active-set-sqp\n")
7167            .unwrap();
7168        assert!(app.is_sqp_algorithm_selected());
7169    }
7170
7171    #[test]
7172    fn feral_min_par_flops_option_reaches_config() {
7173        let mut app = IpoptApplication::new();
7174        app.initialize().unwrap();
7175        // Unset on the OptionsList: falls through to FeralConfig::from_env,
7176        // which leaves it None (inherit feral's built-in default) when the
7177        // POUNCE_FERAL_MIN_PAR_FLOPS env var is also absent.
7178        assert_eq!(
7179            feral_config_from_options(app.options()).min_par_flops,
7180            None,
7181            "unset feral_min_par_flops should not force an override"
7182        );
7183        // Explicit set is mapped through, cast to u64. `0` is the "dispatch
7184        // on every eligible tree" setting and must survive the cast.
7185        app.initialize_with_options_str("feral_min_par_flops 0\n")
7186            .unwrap();
7187        assert_eq!(
7188            feral_config_from_options(app.options()).min_par_flops,
7189            Some(0)
7190        );
7191        // A large finite value passes through intact (5e8 > i32::MAX, which
7192        // is why this is a number option, not an integer one).
7193        app.initialize_with_options_str("feral_min_par_flops 5e8\n")
7194            .unwrap();
7195        assert_eq!(
7196            feral_config_from_options(app.options()).min_par_flops,
7197            Some(500_000_000)
7198        );
7199    }
7200
7201    #[test]
7202    fn feral_refine_steps_option_reaches_config() {
7203        let mut app = IpoptApplication::new();
7204        app.initialize().unwrap();
7205        // Unset: falls through to FeralConfig::from_env, which resolves to
7206        // feral's own DEFAULT_REFINE_MAX_STEPS. The bump to feral 0.17.0 is
7207        // deliberately behaviour-preserving here — the cap that gh#710 wants
7208        // to measure (1) is not yet the default.
7209        assert_eq!(
7210            feral_config_from_options(app.options()).refine_max_steps,
7211            pounce_feral::FeralConfig::default().refine_max_steps,
7212            "unset feral_refine_steps must keep feral's own default"
7213        );
7214        // The setting gh#710 exists to evaluate.
7215        app.initialize_with_options_str("feral_refine_steps 1\n")
7216            .unwrap();
7217        assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 1);
7218        // `0` is a legal cap (zero corrections, refined entry point still
7219        // taken) and must not be read as "unset".
7220        app.initialize_with_options_str("feral_refine_steps 0\n")
7221            .unwrap();
7222        assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 0);
7223    }
7224
7225    #[test]
7226    fn feral_static_pivoting_option_reaches_config() {
7227        let mut app = IpoptApplication::new();
7228        app.initialize().unwrap();
7229        // Unset on the OptionsList: falls through to FeralConfig::from_env,
7230        // which leaves it None (inherit feral's delayed-pivot default) when
7231        // the POUNCE_FERAL_STATIC_PIVOTING env var is also absent — so the
7232        // default numeric path is unchanged.
7233        assert_eq!(
7234            feral_config_from_options(app.options()).static_pivoting,
7235            None,
7236            "unset feral_static_pivoting must not force a numeric override"
7237        );
7238        // Explicit `yes` maps to Some(true): every supernode factors with
7239        // delayed pivoting disabled (feral#8 cascade breaker).
7240        app.initialize_with_options_str("feral_static_pivoting yes\n")
7241            .unwrap();
7242        assert_eq!(
7243            feral_config_from_options(app.options()).static_pivoting,
7244            Some(true)
7245        );
7246        // Explicit `no` maps to Some(false): keep delayed pivoting on
7247        // (distinct from unset, which merely inherits the default).
7248        app.initialize_with_options_str("feral_static_pivoting no\n")
7249            .unwrap();
7250        assert_eq!(
7251            feral_config_from_options(app.options()).static_pivoting,
7252            Some(false)
7253        );
7254    }
7255
7256    /// Convex equality NLP fixture for end-to-end SQP testing
7257    /// through `IpoptApplication`:
7258    ///
7259    ///     min ½(x₁² + x₂²) − x₁ − 2x₂  s.t.  x₁ + x₂ = 1
7260    ///
7261    /// Closed form: x* = (0, 1), obj = -1.5, λ_g = 1.
7262    struct ConvexEqTnlp {
7263        finalize_called: std::rc::Rc<std::cell::RefCell<Option<(Vec<Number>, Number)>>>,
7264    }
7265    impl TNLP for ConvexEqTnlp {
7266        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
7267            Some(NlpInfo {
7268                n: 2,
7269                m: 1,
7270                nnz_jac_g: 2,
7271                nnz_h_lag: 2,
7272                index_style: IndexStyle::C,
7273            })
7274        }
7275        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
7276            b.x_l.copy_from_slice(&[-2.0e19; 2]);
7277            b.x_u.copy_from_slice(&[2.0e19; 2]);
7278            b.g_l.copy_from_slice(&[1.0]);
7279            b.g_u.copy_from_slice(&[1.0]);
7280            true
7281        }
7282        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
7283            sp.x.copy_from_slice(&[0.0, 0.0]);
7284            true
7285        }
7286        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
7287            Some(0.5 * (x[0] * x[0] + x[1] * x[1]) - x[0] - 2.0 * x[1])
7288        }
7289        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
7290            grad[0] = x[0] - 1.0;
7291            grad[1] = x[1] - 2.0;
7292            true
7293        }
7294        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
7295            g[0] = x[0] + x[1];
7296            true
7297        }
7298        fn eval_jac_g(
7299            &mut self,
7300            _x: Option<&[Number]>,
7301            _new_x: bool,
7302            mode: SparsityRequest<'_>,
7303        ) -> bool {
7304            match mode {
7305                SparsityRequest::Structure { irow, jcol } => {
7306                    irow.copy_from_slice(&[0, 0]);
7307                    jcol.copy_from_slice(&[0, 1]);
7308                }
7309                SparsityRequest::Values { values, .. } => {
7310                    values.copy_from_slice(&[1.0, 1.0]);
7311                }
7312            }
7313            true
7314        }
7315        fn eval_h(
7316            &mut self,
7317            _x: Option<&[Number]>,
7318            _new_x: bool,
7319            _obj_factor: Number,
7320            _lambda: Option<&[Number]>,
7321            _new_lambda: bool,
7322            mode: SparsityRequest<'_>,
7323        ) -> bool {
7324            match mode {
7325                SparsityRequest::Structure { irow, jcol } => {
7326                    irow.copy_from_slice(&[0, 1]);
7327                    jcol.copy_from_slice(&[0, 1]);
7328                }
7329                SparsityRequest::Values { values, .. } => {
7330                    values.copy_from_slice(&[1.0, 1.0]);
7331                }
7332            }
7333            true
7334        }
7335        fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
7336            *self.finalize_called.borrow_mut() = Some((sol.x.to_vec(), sol.obj_value));
7337        }
7338    }
7339
7340    /// A TNLP that solves normally once, then declines to supply bounds.
7341    ///
7342    /// The second `optimize_constrained` therefore bails long before the
7343    /// statistics block that records `row_scaling_active`, which is the
7344    /// path the fail-closed reset exists for.
7345    struct DescribesItselfOnce {
7346        /// Flipped by the test between the two solves. A call counter
7347        /// would not work: these hooks are called more than once per
7348        /// solve, so it would trip inside the first one.
7349        refuse: std::rc::Rc<std::cell::Cell<bool>>,
7350        inner: ExactQuadratic,
7351    }
7352    impl TNLP for DescribesItselfOnce {
7353        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
7354            self.inner.get_nlp_info()
7355        }
7356        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
7357            // Declining here is a documented TNLP outcome and unwinds
7358            // cleanly; declining `get_nlp_info` mid-flight panics instead,
7359            // which would test the panic path rather than the reset.
7360            if self.refuse.get() {
7361                return false;
7362            }
7363            self.inner.get_bounds_info(b)
7364        }
7365        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
7366            self.inner.get_starting_point(sp)
7367        }
7368        fn eval_f(&mut self, x: &[Number], n: bool) -> Option<Number> {
7369            self.inner.eval_f(x, n)
7370        }
7371        fn eval_grad_f(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
7372            self.inner.eval_grad_f(x, n, g)
7373        }
7374        fn eval_g(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
7375            self.inner.eval_g(x, n, g)
7376        }
7377        fn eval_jac_g(&mut self, x: Option<&[Number]>, n: bool, mode: SparsityRequest<'_>) -> bool {
7378            self.inner.eval_jac_g(x, n, mode)
7379        }
7380        fn eval_h(
7381            &mut self,
7382            x: Option<&[Number]>,
7383            n: bool,
7384            o: Number,
7385            l: Option<&[Number]>,
7386            nl: bool,
7387            mode: SparsityRequest<'_>,
7388        ) -> bool {
7389            self.inner.eval_h(x, n, o, l, nl, mode)
7390        }
7391        fn finalize_solution(&mut self, s: Solution<'_>, d: &IpoptData, c: &IpoptCq) {
7392            self.inner.finalize_solution(s, d, c)
7393        }
7394    }
7395
7396    /// gh#794 review round 2: `row_scaling_active` must be fail-closed.
7397    ///
7398    /// It is written near the end of `optimize_constrained`, from the NLP
7399    /// that solve built. A solve that bails before that point used to
7400    /// leave the *previous* solve's value in place — and the ℓ₁ outer
7401    /// loop calls `optimize_constrained` repeatedly and reads the flag
7402    /// after each call, so a stale `Some(false)` would let it mirror an
7403    /// original-units violation into the scaled family. That is exactly
7404    /// the units contract the flag was added to protect.
7405    ///
7406    /// The two solves here are the shape that matters: the first records
7407    /// a verdict, the second never gets far enough to record one.
7408    /// Removing the reset at the top of `optimize_constrained` makes this
7409    /// fail with `Some(false)` where `None` is required — checked, not
7410    /// assumed.
7411    #[test]
7412    fn row_scaling_active_is_cleared_when_a_later_solve_bails_early() {
7413        let mut app = IpoptApplication::new();
7414        app.initialize().unwrap();
7415
7416        // Solve 1: reaches the statistics block and records a verdict.
7417        // `ExactQuadratic` supplies `eval_h`, so the solve stays on the
7418        // exact-Hessian NLP path that writes this flag; a fixture without
7419        // one lands on L-BFGS instead.
7420        let refuse = std::rc::Rc::new(std::cell::Cell::new(false));
7421        let first = std::rc::Rc::new(std::cell::RefCell::new(DescribesItselfOnce {
7422            refuse: std::rc::Rc::clone(&refuse),
7423            inner: ExactQuadratic,
7424        }));
7425        let _ = app
7426            .optimize_tnlp(std::rc::Rc::clone(&first) as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
7427        assert!(
7428            app.row_scaling_active.get().is_some(),
7429            "the first solve did not record a row-scaling verdict, so this \
7430             test cannot show the second one clearing it",
7431        );
7432
7433        // Solve 2, same application: bails before recording anything.
7434        refuse.set(true);
7435        let _ = app.optimize_tnlp(first as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
7436
7437        assert_eq!(
7438            app.row_scaling_active.get(),
7439            None,
7440            "a solve that bailed before recording row scaling left the \
7441             previous solve's verdict in place; the ℓ₁ outer loop would \
7442             read it as fact and mirror an original-units violation into \
7443             the scaled family (gh#794 review round 2)",
7444        );
7445    }
7446
7447    #[test]
7448    fn application_sqp_path_solves_convex_eq_nlp_and_finalizes() {
7449        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
7450        let tnlp = std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
7451            finalize_called: std::rc::Rc::clone(&finalize_slot),
7452        }));
7453
7454        let mut app = IpoptApplication::new();
7455        app.initialize().unwrap();
7456        app.initialize_with_options_str("algorithm active-set-sqp\n")
7457            .unwrap();
7458        let status = app.optimize_tnlp(tnlp);
7459        assert_eq!(status, ApplicationReturnStatus::SolveSucceeded);
7460
7461        // The TNLP's finalize_solution must have been invoked.
7462        let recv = finalize_slot.borrow().clone();
7463        let (x_recv, obj_recv) = recv.expect("finalize_solution was not called");
7464        assert_eq!(x_recv.len(), 2);
7465        assert!((x_recv[0] - 0.0).abs() < 1e-6, "x[0] = {}", x_recv[0]);
7466        assert!((x_recv[1] - 1.0).abs() < 1e-6, "x[1] = {}", x_recv[1]);
7467        assert!(
7468            (obj_recv - (-1.5)).abs() < 1e-6,
7469            "obj = {} but expected -1.5",
7470            obj_recv
7471        );
7472    }
7473
7474    #[test]
7475    fn application_routes_to_sqp_case_insensitively() {
7476        let mut app = IpoptApplication::new();
7477        app.initialize().unwrap();
7478        app.initialize_with_options_str("algorithm Active-Set-SQP\n")
7479            .unwrap();
7480        // get_string_value may return the value as-stored (no
7481        // normalization); the dispatch must handle case
7482        // insensitively per the c11 design choice.
7483        assert!(app.is_sqp_algorithm_selected());
7484    }
7485
7486    #[test]
7487    fn application_constructs_and_loads_options() {
7488        let mut app = IpoptApplication::new();
7489        app.initialize().unwrap();
7490        // ipopt.opt-style file: an integer-typed option registered by
7491        // the Interfaces layer.
7492        app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
7493            .unwrap();
7494        let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
7495        assert!(found);
7496        assert_eq!(level, 5);
7497    }
7498
7499    #[test]
7500    fn application_sqp_suboptions_propagate_to_builder() {
7501        // All SQP suboptions are read by algorithm_builder_snapshot
7502        // and baked into the builder's `sqp` field.
7503        let mut app = IpoptApplication::new();
7504        app.initialize().unwrap();
7505        app.initialize_with_options_str(
7506            "algorithm active-set-sqp\n\
7507             sqp_globalization l1-elastic\n\
7508             sqp_hessian lbfgs\n\
7509             sqp_max_iter 17\n\
7510             sqp_tol 1e-7\n\
7511             sqp_constr_viol_tol 1e-5\n\
7512             sqp_dual_inf_tol 1e-3\n\
7513             sqp_l1_penalty 2.5\n\
7514             sqp_bt_reduction 0.25\n\
7515             sqp_bt_min_alpha 1e-10\n\
7516             sqp_print_level 2\n\
7517             sqp_lbfgs_max_history 12\n",
7518        )
7519        .unwrap();
7520        let snap = app.algorithm_builder_snapshot();
7521        assert_eq!(
7522            snap.sqp.globalization,
7523            crate::sqp::SqpGlobalization::L1Elastic
7524        );
7525        assert_eq!(snap.sqp.hessian, crate::sqp::SqpHessianSource::Lbfgs);
7526        assert_eq!(snap.sqp.max_iter, 17);
7527        assert!((snap.sqp.tol - 1e-7).abs() < 1e-18);
7528        assert!((snap.sqp.constr_viol_tol - 1e-5).abs() < 1e-18);
7529        assert!((snap.sqp.dual_inf_tol - 1e-3).abs() < 1e-18);
7530        assert!((snap.sqp.l1_penalty - 2.5).abs() < 1e-18);
7531        assert!((snap.sqp.bt_reduction - 0.25).abs() < 1e-18);
7532        assert!((snap.sqp.bt_min_alpha - 1e-10).abs() < 1e-18);
7533        assert_eq!(snap.sqp.print_level, 2);
7534        assert_eq!(snap.sqp.lbfgs_max_history, 12);
7535    }
7536
7537    /// Every `sqp_qp_*` key that [`apply_qp_subproblem_options`] reads must
7538    /// actually be *registered*, and must reach `pounce_qp::QpOptions`.
7539    ///
7540    /// The whole family was readable-but-unregistered (gh #360): the options
7541    /// registry rejected each one with OPTION_INVALID, so the reader was
7542    /// unreachable and the documented knobs were unusable. This is the guard
7543    /// that class of omission needs — it fails both if a key stops being
7544    /// registered and if a newly-read key is never registered at all.
7545    #[test]
7546    fn application_sqp_qp_subproblem_options_are_registered_and_propagate() {
7547        use pounce_qp::AntiCyclingChoice;
7548
7549        // Source of truth: the keys `apply_qp_subproblem_options` reads.
7550        // Kept in step with that function by the round-trip assertions below.
7551        let mut app = IpoptApplication::new();
7552        app.initialize().unwrap();
7553        app.initialize_with_options_str(
7554            "algorithm active-set-sqp\n\
7555             sqp_qp_max_iter 37\n\
7556             sqp_qp_feas_tol 1e-7\n\
7557             sqp_qp_opt_tol 2e-7\n\
7558             sqp_qp_elastic_gamma 1e4\n\
7559             sqp_qp_anti_cycling bland\n\
7560             sqp_qp_use_schur_updates yes\n\
7561             sqp_qp_max_schur_updates_before_refactor 12\n\
7562             sqp_qp_use_homotopy yes\n\
7563             sqp_qp_certify_second_order yes\n",
7564        )
7565        .expect("every sqp_qp_* option must be registered (gh #360)");
7566
7567        let qp = &app.algorithm_builder_snapshot().sqp_qp;
7568        assert_eq!(qp.max_iter, 37);
7569        assert!((qp.feas_tol - 1e-7).abs() < 1e-20);
7570        assert!((qp.opt_tol - 2e-7).abs() < 1e-20);
7571        assert!((qp.elastic_gamma - 1e4).abs() < 1e-9);
7572        assert_eq!(qp.anti_cycling, AntiCyclingChoice::Bland);
7573        // The Schur update path was implemented but reachable only through
7574        // `SqpAlgorithm::with_qp_options`, so no CLI/library user could turn
7575        // it on — the same unreachable-knob defect gh #360 fixed for the rest
7576        // of this family.
7577        assert!(qp.use_schur_updates);
7578        assert_eq!(qp.max_schur_updates_before_refactor, 12);
7579        assert!(qp.use_homotopy);
7580        // gh #848. Off by default on this path (see
7581        // `QpOptions::sqp_subproblem`), so the value that proves the wire is
7582        // live is `yes` — asserting the default would pass on a reader that
7583        // never ran.
7584        assert!(qp.certify_second_order);
7585
7586        // Untouched options must keep the SQP subproblem base, not be
7587        // overwritten with zeros by the "explicitly set" gate. That base is
7588        // `QpOptions::default()` in every field but one; the exception is
7589        // asserted below.
7590        let mut app = IpoptApplication::new();
7591        app.initialize().unwrap();
7592        app.initialize_with_options_str("algorithm active-set-sqp\n")
7593            .unwrap();
7594        let defaults = pounce_qp::QpOptions::default();
7595        let qp = &app.algorithm_builder_snapshot().sqp_qp;
7596        assert_eq!(qp.max_iter, defaults.max_iter);
7597        assert!((qp.feas_tol - defaults.feas_tol).abs() < 1e-20);
7598        assert!((qp.opt_tol - defaults.opt_tol).abs() < 1e-20);
7599        assert_eq!(qp.anti_cycling, defaults.anti_cycling);
7600        // Default stays OFF, and that is a measured choice: enabling it breaks
7601        // 9 of the 46 Maros-Meszaros instances the default path solves
7602        // correctly. Do not flip this without re-running that comparison.
7603        assert!(!qp.use_schur_updates);
7604        assert_eq!(
7605            qp.max_schur_updates_before_refactor,
7606            defaults.max_schur_updates_before_refactor
7607        );
7608        // Off by default *on the SQP path*, and deliberately different from
7609        // `QpOptions::default()` — which is what a standalone `solve_qp`
7610        // gets, and where it is on. gh #848 / gh #856.
7611        assert!(!qp.certify_second_order);
7612        assert!(pounce_qp::QpOptions::default().certify_second_order);
7613    }
7614
7615    /// The other direction of the gh #360 guard: every **registered**
7616    /// `sqp_qp_*` option must be one `apply_qp_subproblem_options` actually
7617    /// reads.
7618    ///
7619    /// The sister test above checks read-keys-are-registered. It cannot catch
7620    /// the inverse, and the inverse happened: `sqp_qp_use_homotopy` was
7621    /// registered with the homotopy work and never wired into the reader, so
7622    /// setting it on the SQP path silently did nothing while the option's own
7623    /// documentation described what it would do. A registered knob that no
7624    /// code reads is worse than a missing one — it validates, it accepts a
7625    /// value, and it lies.
7626    ///
7627    /// Adding a new `sqp_qp_*` option therefore fails here until it is both
7628    /// read by `apply_qp_subproblem_options` and asserted in the round-trip
7629    /// test above.
7630    #[test]
7631    fn application_every_registered_sqp_qp_option_is_read_by_the_subproblem_reader() {
7632        let mut app = IpoptApplication::new();
7633        app.initialize().unwrap();
7634
7635        let mut registered: Vec<String> = app
7636            .registered_options()
7637            .registered_options_in_order()
7638            .iter()
7639            .map(|o| o.name.clone())
7640            .filter(|n| n.starts_with("sqp_qp_"))
7641            .collect();
7642        registered.sort();
7643
7644        // Kept in step by hand with the `options.get_*_value("sqp_qp_…")`
7645        // calls in `apply_qp_subproblem_options`, and cross-checked by the
7646        // round-trip assertions in the sister test.
7647        let mut read_by_the_reader = vec![
7648            "sqp_qp_anti_cycling".to_string(),
7649            "sqp_qp_certify_second_order".to_string(),
7650            "sqp_qp_elastic_gamma".to_string(),
7651            "sqp_qp_feas_tol".to_string(),
7652            "sqp_qp_max_iter".to_string(),
7653            "sqp_qp_max_schur_updates_before_refactor".to_string(),
7654            "sqp_qp_opt_tol".to_string(),
7655            "sqp_qp_use_homotopy".to_string(),
7656            "sqp_qp_use_schur_updates".to_string(),
7657        ];
7658        read_by_the_reader.sort();
7659
7660        assert_eq!(
7661            registered, read_by_the_reader,
7662            "registered sqp_qp_* options and the ones \
7663             `apply_qp_subproblem_options` reads have diverged. A key that is \
7664             registered but unread is a no-op knob with working documentation \
7665             (that is how `sqp_qp_use_homotopy` shipped); a key read but not \
7666             registered is gh #360. Wire it up in both places, assert it in \
7667             `application_sqp_qp_subproblem_options_are_registered_and_propagate`, \
7668             then add it here."
7669        );
7670    }
7671
7672    #[test]
7673    fn application_sqp_hessian_approximation_maps_to_damped_bfgs() {
7674        // The frontend sets `hessian_approximation = limited-memory` when no
7675        // exact Lagrangian Hessian is available (e.g. `pounce.minimize` with
7676        // no `hess`). On the active-set-SQP path that must resolve to the
7677        // dense Powell-damped BFGS, NOT the limited-memory update: L-BFGS
7678        // materializes the same dense Hessian for the QP subproblem yet stalls
7679        // (`Search_Direction_Becomes_Too_Small` / wrong `x`) on convex QPs with
7680        // an active inequality (issue #358); damped-BFGS solves them.
7681        let mut app = IpoptApplication::new();
7682        app.initialize().unwrap();
7683        app.initialize_with_options_str(
7684            "algorithm active-set-sqp\n\
7685             hessian_approximation limited-memory\n",
7686        )
7687        .unwrap();
7688        assert_eq!(
7689            app.algorithm_builder_snapshot().sqp.hessian,
7690            crate::sqp::SqpHessianSource::DampedBfgs
7691        );
7692
7693        // An explicit `sqp_hessian = lbfgs` is still honored (it is read after
7694        // `hessian_approximation`, so it wins): callers who genuinely want the
7695        // limited-memory update can still ask for it.
7696        let mut app = IpoptApplication::new();
7697        app.initialize().unwrap();
7698        app.initialize_with_options_str(
7699            "algorithm active-set-sqp\n\
7700             hessian_approximation limited-memory\n\
7701             sqp_hessian lbfgs\n",
7702        )
7703        .unwrap();
7704        assert_eq!(
7705            app.algorithm_builder_snapshot().sqp.hessian,
7706            crate::sqp::SqpHessianSource::Lbfgs
7707        );
7708    }
7709
7710    /// `builder.linear_solver` must name the backend that will actually be
7711    /// built, not the one the option string asked for.
7712    ///
7713    /// MA57 is behind the optional `ma57` cargo feature; without it
7714    /// `default_backend_factory` silently substitutes FERAL. Recording `Ma57`
7715    /// anyway made the field disagree with reality, and the Schur KKT gate in
7716    /// `alg_builder::build_with_backend` (which tests `== Feral`) consumed that
7717    /// disagreement — so `set_kkt_schur_block()` never engaged on the default
7718    /// pure-Rust build for any user, while the transparent fallback kept every
7719    /// answer correct and every test green.
7720    #[test]
7721    fn application_linear_solver_records_the_effective_backend() {
7722        // Default options resolve to FERAL in *every* build. The registry
7723        // used to default to upstream's "ma57", which meant an HSL build
7724        // silently ran MA57 without being asked and a pure-Rust build
7725        // advertised a backend it did not contain; the default now names
7726        // pounce's own solver and HSL is opt-in (gh#483 follow-up).
7727        let mut app = IpoptApplication::new();
7728        app.initialize().unwrap();
7729        assert_eq!(
7730            app.algorithm_builder_from_options().linear_solver,
7731            LinearSolverChoice::Feral,
7732            "the registered default is `feral`, in an ma57 build too"
7733        );
7734
7735        // An explicit ma57 request resolves the same way.
7736        let mut app = IpoptApplication::new();
7737        app.initialize().unwrap();
7738        app.initialize_with_options_str("linear_solver ma57\n")
7739            .unwrap();
7740        let got = app.algorithm_builder_from_options().linear_solver;
7741        if cfg!(feature = "ma57") {
7742            assert_eq!(got, LinearSolverChoice::Ma57);
7743        } else {
7744            assert_eq!(got, LinearSolverChoice::Feral);
7745        }
7746
7747        // An explicit feral request is honored in every build.
7748        let mut app = IpoptApplication::new();
7749        app.initialize().unwrap();
7750        app.initialize_with_options_str("linear_solver feral\n")
7751            .unwrap();
7752        assert_eq!(
7753            app.algorithm_builder_from_options().linear_solver,
7754            LinearSolverChoice::Feral
7755        );
7756    }
7757
7758    /// gh#746. `IpAlgBuilder.cpp:1059` substitutes `adaptive` for the
7759    /// registered `monotone` when `hessian_approximation` is
7760    /// limited-memory and the caller left `mu_strategy` alone. pounce
7761    /// read the registered default unconditionally, which is a
7762    /// different barrier schedule on the whole quasi-Newton arm.
7763    #[test]
7764    fn limited_memory_defaults_mu_strategy_to_adaptive() {
7765        // Exact Hessian, nothing set: monotone, as registered.
7766        let mut app = IpoptApplication::new();
7767        app.initialize().unwrap();
7768        assert_eq!(
7769            app.algorithm_builder_from_options().mu_strategy,
7770            MuStrategyChoice::Monotone,
7771            "the exact arm must keep the registered default"
7772        );
7773
7774        // Limited memory, nothing set: adaptive.
7775        let mut app = IpoptApplication::new();
7776        app.initialize().unwrap();
7777        app.initialize_with_options_str("hessian_approximation limited-memory\n")
7778            .unwrap();
7779        assert_eq!(
7780            app.algorithm_builder_from_options().mu_strategy,
7781            MuStrategyChoice::Adaptive,
7782            "limited-memory must take upstream's quasi-Newton default"
7783        );
7784
7785        // An explicit `monotone` still wins — the substitution is only
7786        // for an absent option.
7787        let mut app = IpoptApplication::new();
7788        app.initialize().unwrap();
7789        app.initialize_with_options_str(
7790            "hessian_approximation limited-memory\n\
7791             mu_strategy monotone\n",
7792        )
7793        .unwrap();
7794        assert_eq!(
7795            app.algorithm_builder_from_options().mu_strategy,
7796            MuStrategyChoice::Monotone,
7797            "an explicit mu_strategy must not be overridden"
7798        );
7799    }
7800
7801    /// The μ-strategy auto-fallback retries with the *other* strategy.
7802    /// Under limited-memory the first attempt is adaptive, so the flip
7803    /// has to be monotone — reading the registered default there would
7804    /// re-run the strategy that just stalled (gh#746).
7805    #[test]
7806    fn fallback_flip_follows_the_resolved_mu_strategy() {
7807        let mut app = IpoptApplication::new();
7808        app.initialize().unwrap();
7809        assert!(
7810            !app.effective_mu_strategy_is_adaptive(),
7811            "unset + exact resolves to monotone"
7812        );
7813
7814        let mut app = IpoptApplication::new();
7815        app.initialize().unwrap();
7816        app.initialize_with_options_str("hessian_approximation limited-memory\n")
7817            .unwrap();
7818        assert!(
7819            app.effective_mu_strategy_is_adaptive(),
7820            "unset + limited-memory resolves to adaptive"
7821        );
7822
7823        let mut app = IpoptApplication::new();
7824        app.initialize().unwrap();
7825        app.initialize_with_options_str(
7826            "hessian_approximation limited-memory\n\
7827             mu_strategy monotone\n",
7828        )
7829        .unwrap();
7830        assert!(
7831            !app.effective_mu_strategy_is_adaptive(),
7832            "an explicit monotone under limited-memory resolves to monotone"
7833        );
7834    }
7835
7836    #[test]
7837    fn application_limited_memory_options_propagate_to_builder() {
7838        use crate::hess::lim_mem_quasi_newton::UpdateType;
7839
7840        // Default: no options set -> bit-exact with Ipopt's default
7841        // (bfgs, history 6). This is what the IPM path runs unless the
7842        // user opts in, so it must not drift.
7843        let mut app = IpoptApplication::new();
7844        app.initialize().unwrap();
7845        let def = app.algorithm_builder_from_options();
7846        assert_eq!(def.limited_memory_update_type, UpdateType::Bfgs);
7847        assert_eq!(def.limited_memory_max_history, 6);
7848
7849        // `limited_memory_update_type=sr1` and a custom history length
7850        // must reach the builder (these were registered upstream but
7851        // read nowhere on the IPM path before — see #131). Honoring
7852        // them is what lets SR1 break the monotone L-BFGS stall.
7853        let mut app = IpoptApplication::new();
7854        app.initialize().unwrap();
7855        app.initialize_with_options_str(
7856            "hessian_approximation limited-memory\n\
7857             limited_memory_update_type sr1\n\
7858             limited_memory_max_history 9\n",
7859        )
7860        .unwrap();
7861        let snap = app.algorithm_builder_from_options();
7862        assert_eq!(snap.limited_memory_update_type, UpdateType::Sr1);
7863        assert_eq!(snap.limited_memory_max_history, 9);
7864    }
7865
7866    #[test]
7867    fn application_recalc_y_is_wired_and_defaults_off() {
7868        // #677. Upstream registers `recalc_y` as `no`, but its option
7869        // text ends "If a limited memory quasi-Newton option is chosen,
7870        // this is used by default" — the effective default is
7871        // conditional on the Hessian approximation. pounce refused the
7872        // option outright before this, so an L-BFGS user had no way to
7873        // reach Ipopt's behaviour.
7874
7875        // Exact Hessian: off, matching the registered default.
7876        let mut app = IpoptApplication::new();
7877        app.initialize().unwrap();
7878        let b = app.algorithm_builder_from_options();
7879        assert!(!b.recalc_y, "exact-Hessian default must stay off");
7880        assert_eq!(b.recalc_y_feas_tol, 1e-6, "default changed");
7881
7882        // Limited memory: also off. Upstream's option text says it is
7883        // used by default there; pounce deliberately does not, because
7884        // auto-enabling took 7 of 57 fixtures from solved to not solved
7885        // on the L-BFGS leg with nothing moving the other way. See the
7886        // read site in `algorithm_builder_from_options`. If this
7887        // assertion is what fails, the auto-enable is being restored —
7888        // re-run `scripts/sweep-fixtures.sh` and explain those 7 first.
7889        let mut app = IpoptApplication::new();
7890        app.initialize().unwrap();
7891        app.initialize_with_options_str("hessian_approximation limited-memory\n")
7892            .unwrap();
7893        assert!(
7894            !app.algorithm_builder_from_options().recalc_y,
7895            "limited-memory must not silently enable recalc_y"
7896        );
7897
7898        // An explicit `yes` reaches the exact-Hessian path, which
7899        // used to be refused as unimplemented.
7900        let mut app = IpoptApplication::new();
7901        app.initialize().unwrap();
7902        app.initialize_with_options_str("recalc_y yes\nrecalc_y_feas_tol 1e-3\n")
7903            .unwrap();
7904        let b = app.algorithm_builder_from_options();
7905        assert!(b.recalc_y);
7906        assert_eq!(b.recalc_y_feas_tol, 1e-3);
7907    }
7908
7909    #[test]
7910    fn application_limited_memory_initialization_propagates_to_builder() {
7911        use crate::hess::lim_mem_quasi_newton::InitialApprox;
7912
7913        // #677: registered with upstream's `scalar1` default and read
7914        // nowhere, so every limited-memory solve ran `scalar2` and
7915        // setting the option was a silent no-op. Each keyword must now
7916        // reach the builder.
7917        for (kw, want) in [
7918            ("scalar1", InitialApprox::Scalar1),
7919            ("scalar2", InitialApprox::Scalar2),
7920            ("scalar3", InitialApprox::Scalar3),
7921            ("scalar4", InitialApprox::Scalar4),
7922            ("constant", InitialApprox::Constant),
7923            ("history-max", InitialApprox::HistoryMax),
7924        ] {
7925            let mut app = IpoptApplication::new();
7926            app.initialize().unwrap();
7927            app.initialize_with_options_str(&format!(
7928                "hessian_approximation limited-memory\n\
7929                 limited_memory_initialization {kw}\n"
7930            ))
7931            .unwrap();
7932            assert_eq!(
7933                app.algorithm_builder_from_options()
7934                    .limited_memory_initialization,
7935                want,
7936                "limited_memory_initialization={kw} did not reach the builder"
7937            );
7938        }
7939
7940        // `limited_memory_init_val` was unread too — the empty-history
7941        // branch hard-coded the same 1.0, so the miss was invisible.
7942        let mut app = IpoptApplication::new();
7943        app.initialize().unwrap();
7944        app.initialize_with_options_str(
7945            "hessian_approximation limited-memory\n\
7946             limited_memory_init_val 4.5\n",
7947        )
7948        .unwrap();
7949        assert_eq!(
7950            app.algorithm_builder_from_options().limited_memory_init_val,
7951            4.5
7952        );
7953
7954        // The effective default matches the registry and Ipopt
7955        // (`scalar1`). This is the assertion that would have caught #677
7956        // when the option was first registered: it pins the *selection*,
7957        // which the per-formula tests in `lim_mem_quasi_newton` cannot
7958        // see. Do not relax it to make an unrelated change pass.
7959        let mut app = IpoptApplication::new();
7960        app.initialize().unwrap();
7961        let def = app.algorithm_builder_from_options();
7962        assert_eq!(def.limited_memory_initialization, InitialApprox::Scalar1);
7963        assert_eq!(def.limited_memory_init_val, 1.0);
7964    }
7965
7966    #[test]
7967    fn application_sqp_warm_start_round_trip() {
7968        // Drive the convex-equality TNLP through the SQP path
7969        // twice. The first solve produces a working set; the
7970        // second is warm-started from it. The second must converge
7971        // with zero QP solves (the first KKT check declares
7972        // optimality immediately).
7973        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
7974        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
7975            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
7976                finalize_called: std::rc::Rc::clone(&finalize_slot),
7977            }));
7978
7979        let mut app = IpoptApplication::new();
7980        app.initialize().unwrap();
7981        app.initialize_with_options_str("algorithm active-set-sqp\n")
7982            .unwrap();
7983
7984        // Cold solve.
7985        let status_a = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
7986        assert_eq!(status_a, ApplicationReturnStatus::SolveSucceeded);
7987        let ws = app.last_sqp_working_set().cloned();
7988        assert!(ws.is_some(), "cold solve must yield a working set");
7989
7990        // Build the warm-start iterate from the converged finalize
7991        // payload (just x; pad multipliers to 0 since the test
7992        // problem is convex).
7993        let (x_recv, _) = finalize_slot.borrow().clone().unwrap();
7994        let warm = crate::sqp::SqpIterates {
7995            x: x_recv,
7996            lambda_g: vec![1.0],
7997            lambda_x: vec![0.0, 0.0],
7998            working: ws,
7999        };
8000        app.set_sqp_warm_start(warm);
8001
8002        // Warm solve.
8003        let status_b = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
8004        assert_eq!(status_b, ApplicationReturnStatus::SolveSucceeded);
8005        assert!(app.last_sqp_working_set().is_some());
8006    }
8007
8008    #[test]
8009    fn application_sqp_warm_start_auto_clears_after_use() {
8010        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
8011        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
8012            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
8013                finalize_called: std::rc::Rc::clone(&finalize_slot),
8014            }));
8015        let mut app = IpoptApplication::new();
8016        app.initialize().unwrap();
8017        app.initialize_with_options_str("algorithm active-set-sqp\n")
8018            .unwrap();
8019        app.set_sqp_warm_start(crate::sqp::SqpIterates {
8020            x: vec![0.0, 1.0],
8021            lambda_g: vec![1.0],
8022            lambda_x: vec![0.0, 0.0],
8023            working: None,
8024        });
8025        assert!(app.sqp_warm_start.is_some());
8026        let _ = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
8027        assert!(
8028            app.sqp_warm_start.is_none(),
8029            "warm-start input must be auto-cleared after use"
8030        );
8031    }
8032
8033    #[test]
8034    fn application_sqp_suboptions_default_when_unset() {
8035        // Without any sqp_* settings, the snapshot should equal
8036        // SqpOptions::default().
8037        let mut app = IpoptApplication::new();
8038        app.initialize().unwrap();
8039        let snap = app.algorithm_builder_snapshot();
8040        let d = crate::sqp::SqpOptions::default();
8041        assert_eq!(snap.sqp.globalization, d.globalization);
8042        assert_eq!(snap.sqp.hessian, d.hessian);
8043        assert_eq!(snap.sqp.max_iter, d.max_iter);
8044        assert!((snap.sqp.tol - d.tol).abs() < 1e-18);
8045        assert!((snap.sqp.constr_viol_tol - d.constr_viol_tol).abs() < 1e-18);
8046        assert!((snap.sqp.dual_inf_tol - d.dual_inf_tol).abs() < 1e-18);
8047        assert!((snap.sqp.l1_penalty - d.l1_penalty).abs() < 1e-18);
8048        assert!((snap.sqp.bt_reduction - d.bt_reduction).abs() < 1e-18);
8049        assert!((snap.sqp.bt_min_alpha - d.bt_min_alpha).abs() < 1e-18);
8050        assert_eq!(snap.sqp.print_level, d.print_level);
8051        assert_eq!(snap.sqp.lbfgs_max_history, d.lbfgs_max_history);
8052    }
8053
8054    #[test]
8055    fn application_reports_problem_dimensions() {
8056        let app = IpoptApplication::new();
8057        let mut tnlp = Hs071Stub;
8058        let info = app.problem_dimensions(&mut tnlp).unwrap();
8059        assert_eq!(info.n, 4);
8060        assert_eq!(info.m, 2);
8061        assert_eq!(info.nnz_jac_g, 8);
8062        assert_eq!(info.nnz_h_lag, 10);
8063    }
8064
8065    /// Each of the four constant-derivative hints reaches the algorithm,
8066    /// and reaches its *own* slot (#551 / #677).
8067    ///
8068    /// All four were wired and consumed — gh#588 Q6 made pounce exploit
8069    /// them — but the read site looped over
8070    /// `constant_derivatives::HINT_OPTIONS`, so the registered-but-unread
8071    /// scan saw a loop variable where it needs a literal key and reported
8072    /// all four as silent no-ops. They are literals now, and this pins
8073    /// what a literal-per-name rewrite can get wrong that a loop could
8074    /// not: setting one hint must light up that hint's slot and no other,
8075    /// because `reconcile` pairs `asserted[k]` with the model's proof for
8076    /// `HINT_OPTIONS[k]` and a transposed pair would reuse the wrong
8077    /// derivative.
8078    #[test]
8079    fn each_constant_derivative_hint_lights_up_its_own_slot() {
8080        use pounce_nlp::constant_derivatives::HINT_OPTIONS;
8081
8082        let app = IpoptApplication::new();
8083        assert_eq!(
8084            app.asserted_constant_derivative_hints(),
8085            [false; 4],
8086            "no hint is asserted on a fresh options list",
8087        );
8088
8089        for (k, name) in HINT_OPTIONS.iter().enumerate() {
8090            let mut app = IpoptApplication::new();
8091            app.initialize().unwrap();
8092            app.initialize_with_options_str(&format!("{name} yes\n"))
8093                .unwrap();
8094            let mut expected = [false; 4];
8095            expected[k] = true;
8096            assert_eq!(
8097                app.asserted_constant_derivative_hints(),
8098                expected,
8099                "`{name}=yes` must set slot {k} and nothing else",
8100            );
8101
8102            // …and the registered default asks for nothing, so an
8103            // `ipopt.opt` that spells it out changes no derivative reuse.
8104            let mut app = IpoptApplication::new();
8105            app.initialize().unwrap();
8106            app.initialize_with_options_str(&format!("{name} no\n"))
8107                .unwrap();
8108            assert_eq!(
8109                app.asserted_constant_derivative_hints(),
8110                [false; 4],
8111                "`{name}=no` is the registered default and asserts nothing",
8112            );
8113        }
8114    }
8115
8116    /// `min x²` on `[-10, 10]` from `x = 1`, with an *exactly correct*
8117    /// gradient `2x`. Unconstrained and one-dimensional so the only
8118    /// thing the derivative checker can react to is its own step size
8119    /// and threshold.
8120    ///
8121    /// The forward difference at step `h` is `((1+h)² − 1)/h = 2 + h`,
8122    /// so the deviation from the analytic `2` is exactly `h`, and the
8123    /// relative test flags it when `h > tol·(2 + h)`. That makes the
8124    /// verdict a closed-form function of the two knobs under test.
8125    struct ExactQuadratic;
8126    impl TNLP for ExactQuadratic {
8127        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
8128            Some(NlpInfo {
8129                n: 1,
8130                m: 0,
8131                nnz_jac_g: 0,
8132                nnz_h_lag: 0,
8133                index_style: IndexStyle::C,
8134            })
8135        }
8136        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
8137            b.x_l.copy_from_slice(&[-10.0]);
8138            b.x_u.copy_from_slice(&[10.0]);
8139            true
8140        }
8141        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
8142            sp.x.copy_from_slice(&[1.0]);
8143            true
8144        }
8145        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
8146            Some(x[0] * x[0])
8147        }
8148        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
8149            grad[0] = 2.0 * x[0];
8150            true
8151        }
8152        fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
8153            true
8154        }
8155        fn eval_jac_g(
8156            &mut self,
8157            _x: Option<&[Number]>,
8158            _new_x: bool,
8159            _mode: SparsityRequest<'_>,
8160        ) -> bool {
8161            true
8162        }
8163        fn eval_h(
8164            &mut self,
8165            _x: Option<&[Number]>,
8166            _new_x: bool,
8167            _obj_factor: Number,
8168            _lambda: Option<&[Number]>,
8169            _new_lambda: bool,
8170            _mode: SparsityRequest<'_>,
8171        ) -> bool {
8172            true
8173        }
8174        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
8175    }
8176
8177    struct RecordingQuadratic {
8178        gradient_points: Rc<RefCell<Vec<Number>>>,
8179        objective_points: Rc<RefCell<Vec<Number>>>,
8180        x_scaling: Number,
8181    }
8182
8183    impl TNLP for RecordingQuadratic {
8184        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
8185            ExactQuadratic.get_nlp_info()
8186        }
8187
8188        fn get_bounds_info(&mut self, bounds: BoundsInfo<'_>) -> bool {
8189            ExactQuadratic.get_bounds_info(bounds)
8190        }
8191
8192        fn get_starting_point(&mut self, start: StartingPoint<'_>) -> bool {
8193            ExactQuadratic.get_starting_point(start)
8194        }
8195
8196        fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
8197            self.objective_points.borrow_mut().push(x[0]);
8198            ExactQuadratic.eval_f(x, new_x)
8199        }
8200
8201        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
8202            self.gradient_points.borrow_mut().push(x[0]);
8203            grad[0] = 2.0 * x[0];
8204            true
8205        }
8206
8207        fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
8208            ExactQuadratic.eval_g(x, new_x, g)
8209        }
8210
8211        fn eval_jac_g(
8212            &mut self,
8213            x: Option<&[Number]>,
8214            new_x: bool,
8215            mode: SparsityRequest<'_>,
8216        ) -> bool {
8217            ExactQuadratic.eval_jac_g(x, new_x, mode)
8218        }
8219
8220        fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
8221            *req.obj_scaling = 1.0;
8222            *req.use_x_scaling = true;
8223            req.x_scaling[0] = self.x_scaling;
8224            *req.use_g_scaling = false;
8225            true
8226        }
8227
8228        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
8229    }
8230
8231    fn derivative_test_verdict(extra: &str) -> pounce_nlp::derivative_test::DerivativeTestReport {
8232        let mut app = IpoptApplication::new();
8233        app.initialize().unwrap();
8234        app.initialize_with_options_str(&format!("derivative_test first-order\n{extra}"))
8235            .unwrap();
8236        let opts = app.derivative_test_options();
8237        pounce_nlp::derivative_test::run(&mut ExactQuadratic, &opts).expect("a report")
8238    }
8239
8240    /// `derivative_test_perturbation` and `derivative_test_tol` were
8241    /// registered, read into [`DerivativeTestOptions`], and consumed by
8242    /// the checker — but nothing proved a *set value* reached it, which
8243    /// is the only assertion that distinguishes a read site from a
8244    /// parse-and-discard (#677, #551).
8245    ///
8246    /// Each of the three verdicts below differs from the one above it by
8247    /// exactly one option, so a knob that stopped reaching the checker
8248    /// would collapse two of them together and fail here.
8249    #[test]
8250    fn the_derivative_checker_knobs_change_the_verdict() {
8251        // Registered defaults (1e-8 / 1e-4), which are also the read
8252        // site's fallbacks: a correct gradient looks correct.
8253        let clean = derivative_test_verdict("");
8254        assert_eq!(clean.checked, 1);
8255        assert_eq!(clean.suspicious, 0, "{:#?}", clean.lines);
8256
8257        // A coarse step makes the *same correct gradient* look wrong:
8258        // deviation 0.5 > 1e-4·2.5. Only `derivative_test_perturbation`
8259        // changed, so this is that option and nothing else.
8260        let coarse = derivative_test_verdict("derivative_test_perturbation 0.5\n");
8261        assert_eq!(coarse.checked, 1);
8262        assert_eq!(
8263            coarse.suspicious, 1,
8264            "derivative_test_perturbation never reached the checker: {:#?}",
8265            coarse.lines,
8266        );
8267
8268        // …and loosening the threshold at that same coarse step clears
8269        // it again: 0.5 < 0.5·2.5. Only `derivative_test_tol` changed.
8270        let tolerant =
8271            derivative_test_verdict("derivative_test_perturbation 0.5\nderivative_test_tol 0.5\n");
8272        assert_eq!(tolerant.checked, 1);
8273        assert_eq!(
8274            tolerant.suspicious, 0,
8275            "derivative_test_tol never reached the checker: {:#?}",
8276            tolerant.lines,
8277        );
8278
8279        // The report also prints what it used, so a user reading the
8280        // output can tell which step and threshold produced the verdict.
8281        assert!(
8282            tolerant.lines[0].contains("5.0e-1"),
8283            "{:#?}",
8284            tolerant.lines,
8285        );
8286    }
8287
8288    #[test]
8289    fn ordinary_derivative_test_keeps_the_conditioned_start() {
8290        let gradient_points = Rc::new(RefCell::new(Vec::new()));
8291        let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
8292            gradient_points: Rc::clone(&gradient_points),
8293            objective_points: Rc::new(RefCell::new(Vec::new())),
8294            x_scaling: 1.0,
8295        })) as Rc<RefCell<dyn TNLP>>;
8296        let mut app = IpoptApplication::new();
8297        app.initialize().unwrap();
8298        app.initialize_with_options_str(
8299            "derivative_test first-order\n\
8300             start_point_perturbation 0.5\n\
8301             hessian_approximation limited-memory\n\
8302             max_iter 0\n\
8303             print_level 0\n",
8304        )
8305        .unwrap();
8306
8307        let _ = app.optimize_tnlp(tnlp);
8308
8309        let first = gradient_points.borrow()[0];
8310        assert!((first - 1.7666216164272852).abs() < 1e-12, "{first}");
8311    }
8312
8313    #[test]
8314    fn ordinary_derivative_test_keeps_variable_scaling() {
8315        let objective_points = Rc::new(RefCell::new(Vec::new()));
8316        let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
8317            gradient_points: Rc::new(RefCell::new(Vec::new())),
8318            objective_points: Rc::clone(&objective_points),
8319            x_scaling: 0.5,
8320        })) as Rc<RefCell<dyn TNLP>>;
8321        let mut app = IpoptApplication::new();
8322        app.initialize().unwrap();
8323        app.initialize_with_options_str(
8324            "derivative_test first-order\n\
8325             derivative_test_perturbation 0.5\n\
8326             nlp_scaling_method user-scaling\n\
8327             hessian_approximation limited-memory\n\
8328             max_iter 0\n\
8329             print_level 0\n",
8330        )
8331        .unwrap();
8332
8333        let _ = app.optimize_tnlp(tnlp);
8334
8335        assert_eq!(&objective_points.borrow()[..2], &[1.0, 2.0]);
8336    }
8337
8338    // ---- gh#887: the dominance gate ------------------------------------
8339    //
8340    // These pin the rule directly, on numbers measured off real runs,
8341    // because the fixture that motivated the gate turned out not to be a
8342    // portable witness for it. `deb7` on the L-BFGS leg with
8343    // `limited_memory_ls_failure_restarts=1` reaches a *materially
8344    // different answer* on macOS and on Linux -- different objective
8345    // (99.677 vs 99.651) and, decisively, a different shape:
8346    //
8347    //   | run                 | unscaled dual | viol    | compl   | ratio   |
8348    //   | reproducer, .nl     | 7.90e4        | 1.1e-16 | 1.1e-9  | 1.5e-14 |
8349    //   | reproducer, TNLP    | 3.25e11       | 2.5e-16 | 2.8e-3  | 8.7e-15 |
8350    //   | deb7 + rung, macOS  | 9.90e1        | 8.0e-13 | 4.65e0  | 4.7e-2  |
8351    //   | deb7 + rung, Linux  | 5.5743e3      | 5.6e-14 | 2.08e-5 | 3.7e-9  |
8352    //
8353    // The Linux row is the one worth reading twice: that answer really is
8354    // gh#884's shape (scaled overall error 5.28e-1 against unscaled
8355    // 5.57e3, which is the `s_d` normalisation hiding a runaway exactly
8356    // as it did on `qpec_small`), so the retry there is the designed cost
8357    // and not the waste gh#887 filed. A CLI assertion of "deb7 declines"
8358    // is therefore false on Linux no matter what the threshold is, which
8359    // is why the pin lives here instead.
8360
8361    /// The dominance rule on its own, with the absolute floor switched
8362    /// off (`du_floor = 0`), so each of these tests is about one rule.
8363    fn ratio_only(dual_inf: Number, viol: Number, compl: Number) -> bool {
8364        runaway_is_the_whole_residual(dual_inf, viol, compl, 0.0)
8365    }
8366
8367    /// The gate as it actually runs: dominance *and* the detector's own
8368    /// absolute floor.
8369    fn runaway(dual_inf: Number, viol: Number, compl: Number) -> bool {
8370        runaway_is_the_whole_residual(dual_inf, viol, compl, DUAL_DIV_RETRY_DU_FLOOR)
8371    }
8372
8373    #[test]
8374    fn a_converged_point_with_a_runaway_multiplier_opens_the_retry() {
8375        // The gh#884 reproducer, both routes it reaches the gate by.
8376        assert!(runaway(7.90e4, 1.1e-16, 1.1e-9));
8377        assert!(runaway(3.25e11, 2.5e-16, 2.8e-3));
8378        // deb7 under the rung on Linux: primal exact, complementarity
8379        // eight orders under its own dual residual. Same shape.
8380        assert!(runaway(5.5743e3, 5.6e-14, 2.08e-5));
8381    }
8382
8383    #[test]
8384    fn an_unconverged_point_does_not_open_the_retry() {
8385        // deb7 under the rung on macOS: complementarity 4.65, five
8386        // percent of its own KKT error. Not a runaway multiplier on an
8387        // otherwise-converged point -- just an unconverged point.
8388        assert!(!ratio_only(9.90e1, 8.0e-13, 4.65e0));
8389        // Either residual alone is enough to close it: the gate takes the
8390        // max, so a clean complementarity does not excuse a violated
8391        // constraint. (`1.0e1` against `1.0e6` is a ratio of `1e-5`; note
8392        // that `1.0e0` there would be `1e-6` exactly, i.e. inside.)
8393        assert!(!ratio_only(1.0e6, 1.0e1, 1.0e-16));
8394        assert!(!ratio_only(1.0e6, 1.0e-16, 1.0e1));
8395    }
8396
8397    /// The dominance ratio is scale-free, and that is exactly why it
8398    /// cannot be the whole gate: a point converged to `1e-30` primal with
8399    /// a dual residual of `4.4e-1` satisfies it as comfortably as
8400    /// gh#884's `7.9e+04` does, and `4.4e-1` is not a runaway by any
8401    /// reading of the issue. Measured on the 400-model QPEC family, the
8402    /// floor alone removes 7 of 68 promotions, every one of them on an
8403    /// answer whose reported dual residual was below `1e2`.
8404    ///
8405    /// The floor is the *detector's*, so this is the answer-level gate
8406    /// asking about the same magnitude the iterate-level one did rather
8407    /// than being a strictly looser copy of it.
8408    #[test]
8409    fn a_small_dual_residual_is_not_a_runaway_however_dominant() {
8410        // Passes the ratio comfortably (1e-30 / 4.4e-1 = 2.3e-30) ...
8411        assert!(ratio_only(4.4e-1, 1.0e-30, 1.0e-30));
8412        // ... and is still refused, because it is not a runaway.
8413        assert!(!runaway(4.4e-1, 1.0e-30, 1.0e-30));
8414        // The two real `r`-family answers that reached the gate this way.
8415        assert!(!runaway(4.397e-1, 1.0e-16, 1.0e-16));
8416        assert!(!runaway(2.026e1, 1.0e-16, 1.0e-16));
8417        // Exactly at the floor is inside; a hair under is outside.
8418        assert!(runaway(DUAL_DIV_RETRY_DU_FLOOR, 0.0, 0.0));
8419        assert!(!runaway(DUAL_DIV_RETRY_DU_FLOOR * 0.999, 0.0, 0.0));
8420    }
8421
8422    #[test]
8423    fn the_threshold_is_where_the_constant_says_it_is() {
8424        // Exactly at the ratio is inside; a hair past it is outside.
8425        assert!(ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE, 0.0));
8426        assert!(!ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE * 1.001, 0.0));
8427    }
8428
8429    #[test]
8430    fn what_we_cannot_measure_does_not_buy_a_retry() {
8431        // A NaN compares false everywhere, so the condition is written so
8432        // that "we cannot tell" declines rather than retries. Each
8433        // argument in turn.
8434        assert!(!ratio_only(Number::NAN, 0.0, 0.0));
8435        assert!(!ratio_only(1.0e6, Number::NAN, 0.0));
8436        assert!(!ratio_only(1.0e6, 0.0, Number::NAN));
8437        assert!(!ratio_only(Number::INFINITY, 0.0, 0.0));
8438        // And a dual residual that is not a runaway at all: the ratio
8439        // would be meaningless, and a zero-dual point is not gh#884.
8440        assert!(!ratio_only(0.0, 0.0, 0.0));
8441        assert!(!ratio_only(-1.0, 0.0, 0.0));
8442    }
8443
8444    // ---- the promotion gate reads the ANSWER, not only the certificate --
8445    //
8446    // gh#884 ranked the two attempts on unscaled KKT error alone and
8447    // argued that this could not return a different local solution,
8448    // because "conjunct 4 requires the promoted answer to satisfy the KKT
8449    // conditions in the model's own units". Any other KKT point does too.
8450    // Measured on 400 random QPECs under the `prod_eq` lowering
8451    // (`bound_relax_factor=0 mu_strategy_fallback=no tol=1e-8`): 68
8452    // promotions, 42 of which moved the objective materially, and three
8453    // of which returned a strictly worse *feasible* point.
8454    //
8455    // Every number below is off a real run, and the two branches are
8456    // separated on purpose -- a rule that branches needs a case on each
8457    // side or the untaken one stays broken while the test is green.
8458    //
8459    //   | model      | base f      | base viol | retry f     | retry viol | branch |
8460    //   |------------|-------------|-----------|-------------|------------|--------|
8461    //   | qpec_small | +3.586e-28  | 1.11e-16  | +5.835e-11  | 5.47e-12   | admit  |
8462    //   | r116       | -1.3006e+01 | 2.22e-16  | -1.2072e+00 | 4.55e-13   | rule 1 |
8463    //   | r261       | -4.7919e+00 | 1.07e-14  | -9.8563e-01 | 7.99e-14   | rule 1 |
8464    //   | r201       | -2.9559e-01 | 6.25e-17  | -9.7321e-02 | 6.21e-13   | rule 1 |
8465    //   | scholtes4  | +1.8176e-09 | 2.07e-25  | -6.6088e-05 | 1.09e-09   | rule 2 |
8466
8467    const ACCEPT: Number = 1e-6;
8468    /// Minimization, which is every row of the table above.
8469    const MIN: Number = 1.0;
8470    /// Maximization, i.e. `obj_scaling_factor < 0`.
8471    const MAX: Number = -1.0;
8472
8473    fn admissible(bo: Number, bv: Number, ro: Number, rv: Number) -> bool {
8474        retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN)
8475    }
8476
8477    #[test]
8478    fn the_reproducers_promotion_is_still_admissible() {
8479        // `qpec_small`: the retry's objective is *worse*, by 5.8e-11 --
8480        // deliberately, since it buys nine orders of unscaled dual
8481        // residual. Five orders inside the tolerance, so rule 1 admits
8482        // it, which is the whole point of having a tolerance at all.
8483        assert!(admissible(3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12));
8484    }
8485
8486    /// Rule 1: a strictly worse feasible point is never an upgrade.
8487    #[test]
8488    fn a_worse_feasible_objective_is_refused_however_clean_the_certificate() {
8489        // r116: -13.0057 -> -1.2072, both independently verified feasible
8490        // by `pounce verify`. The retry's unscaled KKT error is 2.9e-11
8491        // against the base attempt's 3.0e+03, so every certificate
8492        // conjunct passes and only this one refuses it.
8493        assert!(!admissible(
8494            -1.3005680756e1,
8495            2.22e-16,
8496            -1.2072337962e0,
8497            4.55e-13
8498        ));
8499        assert!(!admissible(
8500            -4.7919265770e0,
8501            1.07e-14,
8502            -9.8562977711e-1,
8503            7.99e-14
8504        ));
8505        assert!(!admissible(
8506            -2.9558632401e-1,
8507            6.25e-17,
8508            -9.7321185691e-2,
8509            6.21e-13
8510        ));
8511    }
8512
8513    /// Rule 2: an objective *improvement* bought with primal slack.
8514    ///
8515    /// `scholtes4` (`benchmarks/mpcc/cases.py`, and now a CLI fixture) has
8516    /// `f* = 0` exactly -- for the MPCC and for the smooth lowering alike,
8517    /// since `x1*x2 = 0` forces one of them to zero, hence `x3 <= 0`, hence
8518    /// `f = x1 - x3 >= 0`. The retry reports `-6.61e-05`, which no feasible
8519    /// point reaches, by moving the complementarity row 16 orders further
8520    /// out. Rule 1 cannot see it: the objective got *better*.
8521    #[test]
8522    fn an_improvement_bought_with_primal_slack_is_refused() {
8523        assert!(!admissible(
8524            1.8175997416e-9,
8525            2.07e-25,
8526            -6.6088333055e-5,
8527            1.09e-9
8528        ));
8529        // The same numbers with the primal *held* would be admissible --
8530        // this is the conjunct that refuses it, not the objective move.
8531        assert!(admissible(
8532            1.8175997416e-9,
8533            2.07e-25,
8534            -6.6088333055e-5,
8535            2.07e-25
8536        ));
8537    }
8538
8539    /// The window the tolerance sits in, from both sides, so it is a
8540    /// checkable claim rather than a fitted constant: the smallest move
8541    /// that must be admitted is `qpec_small`'s `5.8e-11` and the smallest
8542    /// that must be refused is `r201`'s `0.198`, four and five orders
8543    /// away from `acceptable_tol` on either side.
8544    #[test]
8545    fn the_objective_tolerance_is_not_fitted_to_one_model() {
8546        let admit = 5.835e-11;
8547        let refuse = 0.198;
8548        assert!(admit < ACCEPT / 1.0e4, "{admit} is not well inside");
8549        assert!(refuse > ACCEPT * 1.0e4, "{refuse} is not well outside");
8550        // Scale-relative above 1, absolute below it -- the same
8551        // convention `sigma_forward_error_is_small` uses for `norm(x)`.
8552        assert!(admissible(1.0e6, 0.0, 1.0e6 + 0.5, 0.0));
8553        assert!(!admissible(1.0e6, 0.0, 1.0e6 + 5.0, 0.0));
8554    }
8555
8556    /// An infeasible base attempt is not a point worth protecting, so
8557    /// both rules stand down and the certificate conjuncts decide alone.
8558    /// R2: `obj_scaling_factor < 0` poses a maximization, and
8559    /// `final_objective` is the user's **signed** objective, so both rules
8560    /// have to follow the sense. This is the table above with every
8561    /// objective negated: the same three answers must be refused and the
8562    /// same one admitted, with `MAX` instead of `MIN`.
8563    ///
8564    /// Without the normalization each row flips — rule 1 starts refusing
8565    /// genuine improvements (a regression against the behaviour before the
8566    /// conjunct existed) and rule 2 starts admitting strictly worse
8567    /// answers, which is the class it was added to block.
8568    #[test]
8569    fn the_rules_follow_the_objective_sense() {
8570        // qpec_small, mirrored: the retry is worse by 5.8e-11, well inside
8571        // the tolerance, so it is still admitted.
8572        assert!(retry_answer_is_admissible(
8573            -3.586e-28, 1.11e-16, -5.835e-11, 5.47e-12, ACCEPT, MAX
8574        ));
8575        // r116, mirrored: +13.0057 given up for +1.2072 is now a *worse*
8576        // maximum, and rule 1 must still refuse it.
8577        assert!(!retry_answer_is_admissible(
8578            1.3005680756e1,
8579            2.22e-16,
8580            1.2072337962e0,
8581            4.55e-13,
8582            ACCEPT,
8583            MAX
8584        ));
8585        // scholtes4, mirrored: +6.6088e-05 is now an improvement bought
8586        // with primal slack, and rule 2 must still refuse it.
8587        assert!(!retry_answer_is_admissible(
8588            -1.8175997416e-9,
8589            2.07e-25,
8590            6.6088333055e-5,
8591            1.09e-9,
8592            ACCEPT,
8593            MAX
8594        ));
8595        // ... and the same improvement with the primal held is admitted.
8596        assert!(retry_answer_is_admissible(
8597            -1.8175997416e-9,
8598            2.07e-25,
8599            6.6088333055e-5,
8600            2.07e-25,
8601            ACCEPT,
8602            MAX
8603        ));
8604    }
8605
8606    /// The mirror of the above, stated as the property rather than the
8607    /// rows: negating both objectives and flipping the sense must leave
8608    /// every verdict unchanged.
8609    #[test]
8610    fn negating_the_objective_and_the_sense_is_inert() {
8611        for &(bo, bv, ro, rv) in &[
8612            (3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12),
8613            (-1.3005680756e1, 2.22e-16, -1.2072337962e0, 4.55e-13),
8614            (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 1.09e-9),
8615            (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 2.07e-25),
8616            (-1.0e3, 1.0e-2, 0.0, 1.0e-12),
8617        ] {
8618            assert_eq!(
8619                retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN),
8620                retry_answer_is_admissible(-bo, bv, -ro, rv, ACCEPT, MAX),
8621                "verdict moved under (obj, sense) -> (-obj, -sense) at {bo:e}/{ro:e}"
8622            );
8623        }
8624    }
8625
8626    #[test]
8627    fn an_infeasible_base_attempt_protects_nothing() {
8628        assert!(admissible(-1.0e3, 1.0e-2, 0.0, 1.0e-12));
8629        // ... and what cannot be measured is refused, not admitted, the
8630        // same way `runaway_is_the_whole_residual` treats a NaN.
8631        assert!(admissible(Number::NAN, 0.0, 0.0, 0.0));
8632        assert!(!admissible(0.0, 0.0, Number::NAN, 0.0));
8633        assert!(!admissible(0.0, 0.0, -1.0, Number::NAN));
8634    }
8635}