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::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/// What [`IpoptApplication::initialize_with_option_file`] did — enough
44/// for a caller to tell the user which file (if any) configured the run.
45#[derive(Debug, Default, Clone)]
46pub struct OptionFileLoad {
47    /// The file actually read. `None` means no options file was read:
48    /// nobody named one and neither default was present.
49    pub path: Option<PathBuf>,
50    /// Whether [`Self::path`] was named by the caller rather than found
51    /// by probing the working directory.
52    pub explicit: bool,
53    /// Non-fatal notes about option-file settings that did *not* take
54    /// effect. Nothing here stops a solve; the point is that it not
55    /// happen silently.
56    pub warnings: Vec<String>,
57}
58
59/// Factory that constructs a fresh restoration-phase strategy on
60/// demand. The outer algorithm owns at most one restoration object,
61/// so the factory is invoked once per `optimize_tnlp` call. The
62/// factory is `FnMut` to allow callers to capture a builder that
63/// internally reuses caches across builds.
64pub type RestorationFactory = Box<dyn FnMut() -> Box<dyn RestorationPhase>>;
65
66/// Provider that mints fresh [`RestorationFactory`] instances on
67/// demand. Used by drivers that need to run the inner IPM more than
68/// once per `optimize_tnlp` call — notably the Phase-3 ℓ₁-exact
69/// penalty-barrier outer loop (pounce#10), which the existing
70/// `RestorationFactory` cannot support because pounce's default
71/// `make_default_restoration_factory` is a one-shot. Callers wire
72/// this via [`IpoptApplication::set_restoration_factory_provider`].
73pub type RestorationFactoryProvider = Box<dyn FnMut() -> RestorationFactory>;
74
75/// Callback fired by [`IpoptApplication::optimize_constrained`] once
76/// the IPM has converged (status `SolveSucceeded` or
77/// `SolvedToAcceptableLevel`) and before the user TNLP's
78/// `finalize_solution` runs. Receives borrowed handles into the
79/// algorithm's converged state.
80///
81/// **Use case**: post-optimal sensitivity analysis (pounce#7 /
82/// `pounce-sensitivity`). The callback receives a shared handle to
83/// the PD solver so a `SensBacksolver` adapter can run backsolves
84/// against the converged KKT factor — and so that handle may outlive
85/// the call frame (e.g. the public `Solver` session API retains the
86/// factor for repeated `parametric_step` / `kkt_solve` calls);
87/// receives the data / cq / nlp handles so the adapter can reproduce
88/// the augmented-system coefficient layout the IPM converged at.
89///
90/// **Not** the same as `set_intermediate_callback` (per-iteration
91/// progress notification) — this fires exactly once per `optimize_*`
92/// call, only on success.
93pub type ConvergedCallback = Box<
94    dyn FnMut(
95        &crate::ipopt_data::IpoptDataHandle,
96        &crate::ipopt_cq::IpoptCqHandle,
97        &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
98        Rc<RefCell<crate::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
99    ),
100>;
101use pounce_common::diagnostics::DiagnosticsState;
102use pounce_common::exception::{ExceptionKind, SolverException};
103use pounce_common::journalist::{JournalLevel, Journalist};
104use pounce_common::options_list::OptionsList;
105use pounce_common::reg_options::{PrintOptionsMode, RegisteredOptions};
106use pounce_common::timing::TimingStatistics;
107use pounce_common::types::{Index, Number};
108use pounce_linalg::dense_vector::DenseVectorSpace;
109use pounce_linsol::SparseSymLinearSolverInterface;
110use pounce_linsol::summary::LinearSolverSummary;
111use pounce_nlp::alg_types::SolverReturn;
112use pounce_nlp::derivative_test::{DerivativeTest, DerivativeTestOptions};
113use pounce_nlp::orig_ipopt_nlp::{ConstObjScaling, OrigIpoptNlp, ScalingMethod};
114use pounce_nlp::return_codes::ApplicationReturnStatus;
115use pounce_nlp::solve_statistics::SolveStatistics;
116use pounce_nlp::tnlp::{
117    IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, NlpInfo, Solution, TNLP,
118};
119use pounce_nlp::tnlp_adapter::{
120    DEFAULT_NLP_LOWER_BOUND_INF, DEFAULT_NLP_UPPER_BOUND_INF, FixedVarTreatment, TNLPAdapter,
121};
122use std::cell::RefCell;
123use std::fmt;
124use std::path::{Path, PathBuf};
125use std::rc::Rc;
126use std::sync::{Arc, Mutex};
127use std::time::Instant;
128
129pub struct IpoptApplication {
130    options: OptionsList,
131    /// Per-variable scaling factors applied by the wrapper installed in
132    /// [`Self::optimize_tnlp`] (gh#486). Recorded so consumers that read
133    /// the algorithm's own iterate rather than the `finalize_solution`
134    /// payload — the CLI's `on_converged` hook feeding the `.sol` and
135    /// the JSON report — can undo the substitution. `None` when no
136    /// variable scaling was applied.
137    variable_scaling: RefCell<Option<Vec<Number>>>,
138    /// Whether the submitted TNLP has already been explicitly wrapped by the
139    /// caller's presolve layer.
140    presolve_already_applied: bool,
141    reg_options: Rc<RegisteredOptions>,
142    journalist: Rc<Journalist>,
143    statistics: RefCell<SolveStatistics>,
144    /// Shared per-subsystem timing accumulator. Re-created at the top of
145    /// every solve (so back-to-back `optimize_tnlp` calls don't bleed
146    /// timings across invocations) and handed to the data, the NLP, and
147    /// any other consumer via `Rc`. Reported by [`Self::timing_stats`]
148    /// after the solve completes.
149    timing: RefCell<Rc<TimingStatistics>>,
150    /// Optional override factory for the symmetric linear-solver
151    /// backend. When `None`, we ship the workspace default (MA57 via
152    /// `pounce-hsl`). Tests can plug a stub via [`Self::set_linear_backend_factory`].
153    linear_backend_factory: Option<LinearBackendFactory>,
154    /// Optional factory for the restoration phase. Lives outside this
155    /// crate because `pounce-algorithm` cannot depend on
156    /// `pounce-restoration` (the dep edge is the other way). Callers
157    /// that need restoration plug a factory via
158    /// [`Self::set_restoration_factory`]; when unset, the outer
159    /// algorithm runs without a restoration fallback and surfaces
160    /// `RestorationFailure` as soon as the line-search would otherwise
161    /// jump into restoration.
162    restoration_factory: Option<RestorationFactory>,
163    /// Shared diagnostic-dump state, installed by the CLI when the
164    /// user passes `--dump <cat>:<spec>`. When set, the application
165    /// propagates an `Rc<DiagnosticsState>` into [`IpoptAlgorithm`]
166    /// via [`IpoptAlgorithm::with_diagnostics`] so the KKT solver and
167    /// other dump sites can consult per-iter gating.
168    diagnostics: Option<Rc<DiagnosticsState>>,
169    /// Optional interactive debugger hook. When set, it is moved into
170    /// the main [`IpoptAlgorithm`] for the next `optimize_*` call via
171    /// [`IpoptAlgorithm::with_debug_hook`], so a REPL or agent can pause
172    /// at each iteration to inspect / mutate live state. Consumed on use
173    /// (one solve per installed hook).
174    debug_hook: Option<std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>>,
175    /// Provider for the BNW outer loop (pounce#10 Phase 3). When set,
176    /// `optimize_constrained` consults the provider before each inner
177    /// solve, replacing `restoration_factory` with a fresh one so
178    /// multi-pass drivers can run the inner IPM repeatedly without
179    /// tripping the default factory's one-shot guard.
180    restoration_factory_provider: Option<RestorationFactoryProvider>,
181    /// Optional hook fired once per `optimize_*` call on convergence,
182    /// before the user TNLP's `finalize_solution`. See
183    /// [`ConvergedCallback`].
184    on_converged: Option<ConvergedCallback>,
185    /// When `true`, the per-iteration `IterRecord` trajectory is
186    /// captured into [`SolveStatistics::iterations`] for downstream
187    /// consumers (the JSON solve report in pounce-cli, pounce#8). Off
188    /// by default so library callers that never read the iterations
189    /// vector don't pay the per-iter alloc.
190    record_iter_history: bool,
191    /// Whether [`Self::initialize_with_option_file`] ran — i.e. whether
192    /// anything on this application actually consulted
193    /// `option_file_name` and resolved it to a file. Only the `pounce`
194    /// CLI does; a library caller sets its options directly. The guard
195    /// in [`Self::unhonored_option_file_name`] reads this so that
196    /// setting the option on a surface that cannot honor it is refused
197    /// rather than dropped (gh#518).
198    option_file_resolved: bool,
199    /// Shared sink that the linear-solver backend writes a rolling
200    /// [`LinearSolverSummary`] into after every factor. Reset at the
201    /// top of every solve (so back-to-back `optimize_tnlp` calls don't
202    /// bleed stats across invocations) and read out via
203    /// [`Self::linear_solver_summary`] once the solve returns. Only
204    /// the workspace-default FERAL backend (via
205    /// [`default_backend_factory_with_sink`]) wires the sink today;
206    /// custom factories plugged through [`Self::set_linear_backend_factory`]
207    /// and the HSL MA57 backend leave the sink empty.
208    linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
209    /// Phase 5c (§6) SQP warm-start input. When `Some`, the next
210    /// `optimize_tnlp` call on the SQP path consumes the iterate
211    /// instead of cold-starting; consumed once per solve, then
212    /// auto-cleared. The IPM path ignores this field. Wire-set
213    /// via [`Self::set_sqp_warm_start`].
214    sqp_warm_start: Option<crate::sqp::SqpIterates>,
215    /// Phase 5c (§6) SQP warm-start output. Populated by every
216    /// `optimize_sqp_tnlp` call with the final QP working set.
217    /// Stays valid until the next solve (which overwrites it).
218    /// Accessed via [`Self::last_sqp_working_set`].
219    sqp_last_working_set: Option<pounce_qp::WorkingSet>,
220    /// Full primal-dual warm-start iterate for the IPM path, captured by
221    /// the interactive debugger's `resolve` command. When `Some`, the
222    /// next `optimize_tnlp` installs this 8-vector (algorithm space)
223    /// directly onto `data.curr` before the iterate initializer runs, so
224    /// a warm `resolve` continues from the paused interior point rather
225    /// than cold-restarting the duals. Consumed once per solve, then
226    /// auto-cleared. Requires `warm_start_init_point=yes` so the
227    /// re-optimize branch of `WarmStartIterateInitializer` keeps the
228    /// installed iterate. Wire-set via [`Self::set_warm_start_iterate`].
229    warm_start_iterate: Option<crate::debug::IterateSnapshot>,
230    /// Caller-supplied fill-reducing permutation for the KKT linear
231    /// solver (pounce#180 item 1 / FERAL#107). When `Some`, it overrides
232    /// whatever `feral_ordering` / `POUNCE_FERAL_ORDERING` resolves to,
233    /// installing [`pounce_feral::OrderingMethod::External`] on the FERAL
234    /// backend for the next solve. The vector is a **0-based, new-to-old
235    /// permutation** whose length must equal the augmented KKT system
236    /// dimension; FERAL validates it as a bijection and returns
237    /// `InvalidInput` (never panics) on a wrong length / index. Unlike the
238    /// warm-start hooks this is treated as persistent config — it is *not*
239    /// auto-cleared after a solve, so a caller sets it once for a run.
240    /// Wire-set via [`Self::set_external_ordering`]. Ignored by non-FERAL
241    /// backends and by any custom factory plugged via
242    /// [`Self::set_linear_backend_factory`].
243    external_ordering: Option<Vec<usize>>,
244    /// Caller-supplied block-triangular / Schur KKT partition (pounce#180
245    /// item 2). When `Some`, the next IPM solve on the feral + exact-Hessian
246    /// path routes the KKT linear solve through a
247    /// [`crate::kkt::SchurAugSystemSolver`] over these **KKT-space indices**
248    /// (`0..dim` in the `x, s, c, d` block order the aug-system solver
249    /// assembles): the `S` block is Schur-complemented out and only the two
250    /// diagonal blocks are factorized, with inertia recovered via Sylvester's
251    /// law. Beneficial only when `|S| ≪` the eliminated block; the Schur solver
252    /// falls back to the standard full-space solver transparently when the
253    /// partition is unsuitable (too large, malformed, or the backend errors),
254    /// so a stray hook never breaks a solve. Persistent config (not
255    /// auto-cleared). Wire-set via [`Self::set_kkt_schur_block`].
256    kkt_schur_block: Option<Vec<usize>>,
257}
258
259impl fmt::Debug for IpoptApplication {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        f.debug_struct("IpoptApplication")
262            .field("options", &self.options)
263            .field("statistics", &self.statistics)
264            .finish_non_exhaustive()
265    }
266}
267
268impl Default for IpoptApplication {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274impl IpoptApplication {
275    /// New application with empty options and a default journalist.
276    /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
277    pub fn new() -> Self {
278        let reg = RegisteredOptions::default();
279        // Registration of a fresh registry can only fail on a duplicate
280        // name, which would be a programming error in `reg_op`.
281        register_all_upstream_options(&reg)
282            .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
283        pounce_presolve::register_options(&reg)
284            .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
285        let reg = Rc::new(reg);
286        Self {
287            options: OptionsList::with_registered(Rc::clone(&reg)),
288            variable_scaling: RefCell::new(None),
289            presolve_already_applied: false,
290            reg_options: reg,
291            journalist: Rc::new(Journalist::new()),
292            statistics: RefCell::new(SolveStatistics::new()),
293            timing: RefCell::new(Rc::new(TimingStatistics::new())),
294            linear_backend_factory: None,
295            restoration_factory: None,
296            diagnostics: None,
297            debug_hook: None,
298            restoration_factory_provider: None,
299            on_converged: None,
300            record_iter_history: false,
301            option_file_resolved: false,
302            linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
303            sqp_warm_start: None,
304            sqp_last_working_set: None,
305            warm_start_iterate: None,
306            external_ordering: None,
307            kkt_schur_block: None,
308        }
309    }
310
311    pub fn options(&self) -> &OptionsList {
312        &self.options
313    }
314
315    pub fn options_mut(&mut self) -> &mut OptionsList {
316        &mut self.options
317    }
318
319    /// Declare whether callers have already applied an explicit presolve
320    /// wrapper to the TNLPs submitted to [`Self::optimize_tnlp`].
321    ///
322    /// When set, `optimize_tnlp` leaves its input TNLP unchanged even if the
323    /// `presolve` option is enabled. This preserves the option table for
324    /// reporting and debugger use while allowing specialized frontends to
325    /// supply a wrapper with capabilities unavailable to generic callback
326    /// TNLPs, such as an expression provider for FBBT.
327    pub fn set_presolve_already_applied(&mut self, applied: bool) {
328        self.presolve_already_applied = applied;
329    }
330
331    /// Solve without materializing the generic presolve wrapper.
332    ///
333    /// This is for consumers that require the original TNLP coordinate system
334    /// for the solve's KKT matrix, such as sensitivity and reduced-Hessian
335    /// drivers. It is scoped to this invocation and does not change the
336    /// application's `presolve` option or persistent explicit-wrapper setting.
337    pub fn optimize_tnlp_without_presolve(
338        &mut self,
339        tnlp: Rc<RefCell<dyn TNLP>>,
340    ) -> ApplicationReturnStatus {
341        let explicit_wrapper = self.presolve_already_applied;
342        self.presolve_already_applied = true;
343        let status = self.optimize_tnlp(tnlp);
344        self.presolve_already_applied = explicit_wrapper;
345        status
346    }
347
348    pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
349        &self.reg_options
350    }
351
352    pub fn journalist(&self) -> &Rc<Journalist> {
353        &self.journalist
354    }
355
356    /// Plug a custom symmetric-linear-solver factory. Useful for tests
357    /// that want to swap MA57 for a stub. Production callers should
358    /// leave this unset — the default ([`default_backend_factory`])
359    /// returns the workspace's MA57 binding.
360    pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
361        self.linear_backend_factory = Some(factory);
362    }
363
364    /// Plug a restoration-phase factory. Called once per
365    /// `optimize_tnlp` invocation to mint a fresh
366    /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
367    /// its line-search restoration fallback. Lives behind a setter
368    /// (rather than at construction) because the concrete restoration
369    /// strategies live in `pounce-restoration`, which depends on this
370    /// crate; consumers in `pounce-cli` / integration tests wire the
371    /// factory at the application boundary.
372    pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
373        self.restoration_factory = Some(factory);
374    }
375
376    /// Install the shared diagnostics state. Once set, every
377    /// subsequent `optimize_tnlp` call forwards the state into the
378    /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
379    /// solver can emit `--dump kkt:...` artifacts.
380    pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
381        self.diagnostics = Some(diag);
382    }
383
384    /// Install an interactive debugger hook for the next `optimize_*`
385    /// call. The hook is moved into the main [`IpoptAlgorithm`] and
386    /// consumed by that solve; reinstall it to debug a subsequent solve.
387    pub fn set_debug_hook(
388        &mut self,
389        hook: std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>,
390    ) {
391        self.debug_hook = Some(hook);
392    }
393
394    /// Read-side accessor for the installed diagnostics state, if any.
395    /// Lets the CLI write the top-level manifest/timing files after
396    /// the solve completes.
397    pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
398        self.diagnostics.as_ref().map(Rc::clone)
399    }
400
401    /// Plug a restoration-phase **factory provider** for drivers that
402    /// need to run the inner IPM more than once per `optimize_tnlp`
403    /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
404    /// pounce#10). On each inner solve, the application consults the
405    /// provider to mint a fresh [`RestorationFactory`], replacing any
406    /// stale one, so the default one-shot restoration factory does
407    /// not panic on its second invocation. If both `set_restoration_factory`
408    /// and this are configured, the provider wins.
409    pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
410        self.restoration_factory_provider = Some(provider);
411    }
412
413    /// Register a callback to run once the IPM has converged (status
414    /// [`ApplicationReturnStatus::SolveSucceeded`] or
415    /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
416    /// `finalize_solution` flows back to the TNLP. See
417    /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
418    pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
419        self.on_converged = Some(cb);
420    }
421
422    /// Enable per-iteration trajectory capture. After the solve
423    /// returns, [`Self::statistics()`] exposes
424    /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
425    /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
426    /// per accepted iterate. Off by default — the `pounce_sens` and
427    /// `pounce` binaries opt in when `--json-output` is passed.
428    pub fn enable_iter_history(&mut self) {
429        self.record_iter_history = true;
430    }
431
432    /// Read the run's options file, resolving *which* file the way
433    /// upstream's `IpoptApplication::Initialize` does — with one
434    /// deliberate difference, below.
435    ///
436    /// `explicit` is the file the caller named (upstream: the
437    /// `option_file_name` option, read out of the option store before
438    /// this point). With `None`, the working directory is probed for
439    /// [`DEFAULT_OPTION_FILE_NAMES`] and the first hit is read; an
440    /// absent default file is not an error, it just means "no file".
441    ///
442    /// The difference: upstream opens a named file with a bare
443    /// `std::ifstream` and reads nothing if the open fails, so a typo'd
444    /// `option_file_name` runs at stock defaults without a word. That
445    /// silence is what gh#518 was reported for — a benchmark that
446    /// measured defaults while claiming to measure a configuration — so
447    /// a named file that cannot be read is an error here.
448    pub fn initialize_with_option_file(
449        &mut self,
450        explicit: Option<&Path>,
451    ) -> Result<OptionFileLoad, SolverException> {
452        let mut load = OptionFileLoad::default();
453        // Set before the early returns below: what this flag records is
454        // that `option_file_name` was *consulted*, not that a file turned
455        // up. A caller on this path who names nothing and has no
456        // `pounce.opt` to find still gets the option honored — there was
457        // simply nothing to read.
458        self.option_file_resolved = true;
459        let path = match explicit {
460            Some(p) => {
461                if !p.is_file() {
462                    return Err(SolverException::new(
463                        ExceptionKind::IPOPT_APPLICATION_ERROR,
464                        format!(
465                            "options file \"{}\" does not exist. It was named by \
466                             --options-file / option_file_name, so the run would \
467                             otherwise proceed at stock defaults with none of its \
468                             settings applied.",
469                            p.display()
470                        ),
471                        file!(),
472                        line!() as Index,
473                    ));
474                }
475                load.explicit = true;
476                p.to_path_buf()
477            }
478            None => {
479                let present: Vec<&&str> = DEFAULT_OPTION_FILE_NAMES
480                    .iter()
481                    .filter(|n| Path::new(n).is_file())
482                    .collect();
483                let Some(first) = present.first() else {
484                    return Ok(load);
485                };
486                // Both default names in one directory: say which one lost,
487                // rather than let the unread one look applied.
488                for other in &present[1..] {
489                    load.warnings.push(format!(
490                        "`{first}` and `{other}` are both present; reading `{first}` \
491                         only (pounce's own name wins). Pass \
492                         `option_file_name={other}` to read that one instead."
493                    ));
494                }
495                PathBuf::from(**first)
496            }
497        };
498        self.initialize_with_options_file(&path)?;
499        // `option_file_name` set *inside* an options file chains nowhere —
500        // by the time it is read, the file naming it has already been
501        // chosen. Upstream documents that ("it does not make any sense to
502        // specify this option within the options file") and then ignores
503        // it; name it instead, since an ignored setting that looks live is
504        // the whole complaint behind gh#518.
505        if let Ok((named, true)) = self.options.get_string_value("option_file_name", "")
506            && !named.is_empty()
507            && Path::new(&named) != path
508        {
509            load.warnings.push(format!(
510                "`{}` sets option_file_name to `{named}`, which has no effect: \
511                 the options file is chosen before it is read. Pass \
512                 `option_file_name={named}` on the command line to read that file.",
513                path.display()
514            ));
515        }
516        load.path = Some(path);
517        Ok(load)
518    }
519
520    /// Read an `ipopt.opt`-format options file. Equivalent to
521    /// `IpoptApplication::Initialize(const std::string& options_file)`.
522    pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
523        let txt = std::fs::read_to_string(path).map_err(|e| {
524            SolverException::new(
525                ExceptionKind::IPOPT_APPLICATION_ERROR,
526                format!("could not read options file {}: {}", path.display(), e),
527                file!(),
528                line!() as Index,
529            )
530        })?;
531        self.options.read_from_str(&txt, true)?;
532        self.open_output_file_journal();
533        Ok(())
534    }
535
536    /// Read options from a string in `ipopt.opt` format. Useful for
537    /// tests and embedded callers.
538    pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
539        self.options.read_from_str(s, true)?;
540        self.open_output_file_journal();
541        Ok(())
542    }
543
544    /// Honor `output_file` / `file_print_level` / `file_append`: when
545    /// `output_file` is non-empty, attach a `FileJournal` named
546    /// `"OutputFile:<fname>"` at the requested level. Mirrors
547    /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
548    /// No-op if `output_file` is unset, empty, or could not be opened.
549    ///
550    /// NOTE: pounce's iteration output currently bypasses the
551    /// journalist and writes directly to stdout. The file journal is
552    /// attached and the timing report (gated by `print_timing_statistics`)
553    /// is mirrored to it; per-iter rows will start landing in the file
554    /// once the iter-output path is routed through the journalist.
555    fn open_output_file_journal(&self) {
556        let fname = match self.options.get_string_value("output_file", "") {
557            Ok((v, true)) if !v.is_empty() => v,
558            _ => return,
559        };
560        let level_int = self
561            .options
562            .get_integer_value("file_print_level", "")
563            .ok()
564            .and_then(|(v, f)| f.then_some(v))
565            .unwrap_or(5);
566        let level = journal_level_from_int(level_int);
567        let append = self
568            .options
569            .get_bool_value("file_append", "")
570            .ok()
571            .and_then(|(v, f)| f.then_some(v))
572            .unwrap_or(false);
573        let jname = format!("OutputFile:{}", fname);
574        let _ = self
575            .journalist
576            .add_file_journal(&jname, &fname, level, append);
577    }
578
579    /// No-op initialize (just succeeds). Mirrors
580    /// `IpoptApplication::Initialize(bool allow_clobber)` with no
581    /// options file.
582    pub fn initialize(&mut self) -> Result<(), SolverException> {
583        Ok(())
584    }
585
586    /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
587    /// / `file_print_level` options and attaches a matching
588    /// `FileJournal` named `OutputFile:<fname>` to the journalist.
589    /// Returns `false` if the file could not be opened or the option
590    /// store rejected the request (e.g. clamped print level).
591    pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
592        if self
593            .options
594            .set_string_value("output_file", fname, true, false)
595            .is_err()
596        {
597            return false;
598        }
599        if self
600            .options
601            .set_integer_value("file_print_level", print_level as Index, true, false)
602            .is_err()
603        {
604            return false;
605        }
606        let level = journal_level_from_int(print_level);
607        let jname = format!("OutputFile:{}", fname);
608        // Drop any previous file journal so a second call switches files
609        // cleanly. `add_file_journal` would otherwise refuse to attach
610        // a duplicate by name; remove-by-name isn't in the journalist
611        // API, so we settle for the name-collision case here.
612        self.journalist
613            .add_file_journal(&jname, fname, level, false)
614            .is_some()
615    }
616
617    /// Wrap a TNLP and report problem dimensions. Used in tests until
618    /// the full IPM path covers every entry shape.
619    pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
620        tnlp.get_nlp_info()
621    }
622
623    pub fn statistics(&self) -> SolveStatistics {
624        self.statistics.borrow().clone()
625    }
626
627    /// Shared timing accumulator from the most recent `optimize_tnlp`
628    /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
629    /// fields during the solve; consumers read totals out of the
630    /// returned `Rc`. The instance is replaced at the top of every
631    /// subsequent solve, so cloning the `Rc` and holding it past a
632    /// re-solve will give you the previous solve's timings — by design.
633    pub fn timing_stats(&self) -> Rc<TimingStatistics> {
634        Rc::clone(&self.timing.borrow())
635    }
636
637    /// Aggregate linear-solver post-mortem from the most recent
638    /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
639    /// backend ran at least one factor; `None` when no factors were
640    /// recorded (custom factory plugged via
641    /// [`Self::set_linear_backend_factory`], or solve aborted before
642    /// the first KKT factor). Reset at the top of every solve.
643    pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
644        let guard = self.linsol_summary_sink.lock().ok()?;
645        if guard.is_empty() {
646            None
647        } else {
648            Some(guard.clone())
649        }
650    }
651
652    /// Drive a solve.
653    ///
654    /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
655    ///   build a `TNLPAdapter` → `OrigIpoptNlp`, run the
656    ///   [`AlgorithmBuilder`] with the workspace MA57 backend, and
657    ///   call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
658    ///   `ApplicationReturnStatus` mapping mirrors the table in
659    ///   `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
660    ///   SolverReturn map").
661    /// * Unconstrained problems (`m == 0`) keep going through the
662    ///   in-`pounce-nlp` Newton driver so the trivial path is
663    ///   independent of the linear-solver backend.
664    /// Wrap `tnlp` so per-variable scaling factors are applied as a
665    /// change of variables, when `nlp_scaling_method=user-scaling` is
666    /// in effect and the problem supplies non-unit factors (gh#486).
667    ///
668    /// Returns the TNLP unchanged under any other scaling method, or
669    /// when the problem asks for no variable scaling, so an unscaled
670    /// solve pays nothing. The `Err` carries a message ready to print.
671    fn install_variable_scaling(
672        &self,
673        tnlp: Rc<RefCell<dyn TNLP>>,
674    ) -> Result<Rc<RefCell<dyn TNLP>>, String> {
675        // Cleared first so the accessor describes *this* solve. An
676        // application is reusable across solves (`pounce-cinterface`
677        // holds one across `IpoptSolve` calls), and a stale vector
678        // would have a later unscaled solve reporting the previous
679        // solve's factors.
680        *self.variable_scaling.borrow_mut() = None;
681        let method = self
682            .options
683            .get_string_value("nlp_scaling_method", "")
684            .ok()
685            .and_then(|(v, f)| f.then_some(v))
686            .unwrap_or_else(|| "gradient-based".to_string());
687        if method != "user-scaling" {
688            return Ok(tnlp);
689        }
690        match pounce_nlp::scaling_tnlp::wrap_with_scaling(
691            Rc::clone(&tnlp),
692            self.nlp_lower_bound_inf(),
693            self.nlp_upper_bound_inf(),
694        ) {
695            Ok(Some(wrapped)) => {
696                *self.variable_scaling.borrow_mut() =
697                    pounce_nlp::scaling_tnlp::factors_of(&wrapped);
698                Ok(wrapped)
699            }
700            Ok(None) => Ok(tnlp),
701            Err(why) => Err(format!(
702                // The trailing newline belongs to the message: the
703                // caller emits it with `eprint!` and hands the same
704                // string to the journalist, as the refusals below do.
705                "pounce: nlp_scaling_method=user-scaling supplied per-variable \
706                 scaling factors that cannot be applied. {why}. Correct the \
707                 factors, or drop nlp_scaling_method=user-scaling.\n"
708            )),
709        }
710    }
711
712    /// The per-variable scaling factors applied to the last solve, if
713    /// any (gh#486). A consumer reading the algorithm's iterate rather
714    /// than the `finalize_solution` payload sees scaled coordinates and
715    /// must divide `x` by these, and multiply bound multipliers.
716    pub fn variable_scaling(&self) -> Option<Vec<Number>> {
717        self.variable_scaling.borrow().clone()
718    }
719
720    pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
721        // gh#486 stage 2: per-variable `scaling_factor` is applied by
722        // substituting variables one level below the algorithm, since
723        // the core's scaling models the objective and the constraint
724        // rows only. The wrapper consumes the variable factors and
725        // forwards the rest, so `OrigIpoptNlp` sees exactly what it
726        // has always handled. Installed here because every entry point
727        // funnels through this method, and only under `user-scaling`,
728        // the one method that consults the TNLP for factors at all.
729        let tnlp = match self.install_variable_scaling(tnlp) {
730            Ok(t) => t,
731            Err(msg) => {
732                use pounce_common::journalist::JournalCategory;
733                eprint!("{msg}");
734                self.journalist
735                    .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
736                return ApplicationReturnStatus::InvalidOption;
737            }
738        };
739
740        if let Some(value) = self.unsupported_library_solver_selection() {
741            use pounce_common::journalist::JournalCategory;
742            self.journalist.print(
743                JournalLevel::J_ERROR,
744                JournalCategory::J_MAIN,
745                &format!(
746                    "pounce: solver_selection={value} routing is only available \
747                     through the pounce CLI (.nl input); library consumers can use \
748                     qp-active-set, nlp, or auto.\n"
749                ),
750            );
751            return ApplicationReturnStatus::InvalidOption;
752        }
753
754        // A `linear_solver` pounce does not implement is refused rather
755        // than quietly served by FERAL (gh#483 follow-up). Checked here,
756        // before any work, so a library consumer gets the same verdict the
757        // CLI gives before its banner.
758        if let Some(value) = self.unimplemented_linear_solver() {
759            use pounce_common::journalist::JournalCategory;
760            let msg = format!("{}\n", Self::unimplemented_linear_solver_message(&value));
761            eprint!("{msg}");
762            self.journalist
763                .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
764            return ApplicationReturnStatus::InvalidOption;
765        }
766
767        // gh#483 follow-up: an option naming a feature pounce does not
768        // implement is refused, not shrugged off. See
769        // `unimplemented_options` for how membership was established and
770        // why an explicitly-set *default* is deliberately still allowed.
771        // gh#518: same treatment for `option_file_name` on an entry point
772        // that cannot resolve it. Separate from the table above because
773        // the *feature* now exists — just not here.
774        if let Some(msg) = self
775            .unimplemented_option_refusal()
776            .or_else(|| self.unhonored_option_file_name())
777        {
778            use pounce_common::journalist::JournalCategory;
779            eprintln!("{msg}");
780            self.journalist.print(
781                JournalLevel::J_ERROR,
782                JournalCategory::J_MAIN,
783                &format!("{msg}\n"),
784            );
785            return ApplicationReturnStatus::InvalidOption;
786        }
787        for warning in self.unexploited_hint_warnings() {
788            eprintln!("{warning}");
789        }
790
791        // `derivative_test`: check the caller's analytic derivatives
792        // against finite differences before anything else runs — on the
793        // raw TNLP, before the presolve wrapper below changes its
794        // coordinates.
795        self.run_derivative_test(&tnlp);
796
797        // Top-level algorithm dispatch (Phase 5b §7.1). When the
798        // `algorithm` option resolves to "active-set-sqp", route
799        // to the Phase 5b SQP path; otherwise fall through to the
800        // existing IPM flow unchanged.
801        // Materialize generic TNLP presolve once at the public entry point.
802        // The wrapper owns the submitted callback TNLP, so every algorithm
803        // path below (including retry paths) continues to postsolve into
804        // the original user-facing space. With `presolve=no`, this returns
805        // the exact same Rc unchanged.
806        let tnlp = if self.presolve_already_applied {
807            tnlp
808        } else {
809            match pounce_presolve::wrap_from_options(tnlp, &self.options) {
810                Ok(tnlp) => tnlp,
811                Err(err) => {
812                    use pounce_common::journalist::JournalCategory;
813                    self.journalist.print(
814                        JournalLevel::J_ERROR,
815                        JournalCategory::J_MAIN,
816                        &format!("pounce: could not materialize presolve options: {err}\n"),
817                    );
818                    return ApplicationReturnStatus::InvalidOption;
819                }
820            }
821        };
822
823        if self.is_sqp_algorithm_selected() {
824            return self.optimize_sqp_tnlp(tnlp);
825        }
826        let info = match tnlp.borrow_mut().get_nlp_info() {
827            Some(info) => info,
828            None => return ApplicationReturnStatus::InvalidProblemDefinition,
829        };
830
831        // Presolve-certified infeasibility. `get_nlp_info` above is what forces
832        // the (lazy) presolve init, so this is the first point at which the
833        // proof exists. If bound propagation or FBBT established that the
834        // feasible region is empty, there is nothing left to compute: return
835        // the verdict directly.
836        //
837        // Short-circuiting *here*, before dispatch, is deliberate. Running the
838        // solve anyway would only re-derive a strictly weaker result — a
839        // stationary point of the constraint violation, which for a nonconvex
840        // problem proves nothing globally — and would also hand an
841        // `InfeasibleProblemDetected` to the ℓ₁ auto-fallback below
842        // (`is_l1_fallback_trigger`), which would then burn a whole second
843        // solve retrying a problem already proved to have no solution.
844        //
845        // Soundness rests on `presolve_infeasibility_proof` returning `Some`
846        // only for a contradiction derived on an *un-clamped* box — a
847        // detection made while a Phase-0 auxiliary elimination is in force can
848        // be an artifact of that elimination and is re-checked after rollback
849        // before it is certified. See `PresolveState::certified_infeasible`.
850        if let Some(proof) = tnlp.borrow().presolve_infeasibility_proof() {
851            use pounce_common::journalist::JournalCategory;
852            let detail = match proof {
853                pounce_nlp::tnlp::InfeasibilityProof::BoundPropagation => {
854                    "bound propagation crossed a variable's bounds".to_string()
855                }
856                pounce_nlp::tnlp::InfeasibilityProof::IntervalArithmetic { witness } => {
857                    format!("interval arithmetic emptied constraint {witness}'s range")
858                }
859            };
860            self.journalist.print(
861                JournalLevel::J_SUMMARY,
862                JournalCategory::J_MAIN,
863                &format!(
864                    "\nEXIT: Presolve detected the feasible region is empty ({detail}).\n\
865                     No feasible point exists; the solve was not run.\n"
866                ),
867            );
868            return ApplicationReturnStatus::InfeasibleProblemDetected;
869        }
870        // ℓ₁-exact penalty-barrier opt-in (pounce#10).
871        // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
872        // Waltz ρ-escalation loop around the constrained IPM, with a
873        // honest-infeasibility status upgrade when the slacks fail to
874        // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
875        // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
876        // no-op for problems with no equality rows, so the
877        // unconstrained dispatch below is unaffected when there is
878        // nothing to wrap.
879        if info.m > 0 && self.is_l1_penalty_enabled() {
880            if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
881                return status;
882            }
883            // Falls through: wrapper construction failed (inner refused
884            // get_nlp_info / get_bounds_info) or no equality rows to
885            // slack. Standard dispatch runs unmodified.
886        }
887        // Phase 3.5 auto-fallback (pounce#10): if the standard solve
888        // ends in a trigger-class status, retry transparently with
889        // the wrapper. Promote the retry's status only if it returns
890        // SolveSucceeded — otherwise return the original. Skipped if
891        // the user already opted into the wrapper above (this avoids
892        // a double pass and keeps semantics predictable).
893        if info.m > 0 && self.is_l1_fallback_enabled() && !self.is_l1_penalty_enabled() {
894            return self.run_with_l1_fallback(tnlp);
895        }
896        // μ-strategy auto-fallback (pounce#138): if the standard solve
897        // only reaches Solved_To_Acceptable_Level, retry once with the
898        // opposite mu_strategy and promote only on Solve_Succeeded.
899        // Applies to constrained and unconstrained alike (both run the
900        // same IPM). Independent of, and lower priority than, the ℓ₁
901        // fallback above.
902        if self.is_mu_strategy_fallback_enabled() {
903            return self.run_with_mu_strategy_fallback(tnlp);
904        }
905        // Every problem — constrained or not — goes through the same
906        // primal-dual IPM, exactly as upstream Ipopt does. There is no
907        // separate "unconstrained Newton" path: the linear-solver
908        // backend (FERAL/MA57) handles the augmented system, so the
909        // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
910        // blowup.
911        self.optimize_constrained(tnlp)
912    }
913
914    /// Read the ℓ₁ wrapper master switch from the OptionsList.
915    /// Default `false` when the option is not set.
916    fn is_l1_penalty_enabled(&self) -> bool {
917        self.options
918            .get_bool_value("l1_exact_penalty_barrier", "")
919            .ok()
920            .and_then(|(v, found)| found.then_some(v))
921            .unwrap_or(false)
922    }
923
924    fn l1_penalty_init(&self) -> Number {
925        self.options
926            .get_numeric_value("l1_penalty_init", "")
927            .ok()
928            .and_then(|(v, found)| found.then_some(v))
929            .unwrap_or(1.0)
930    }
931    fn l1_penalty_max(&self) -> Number {
932        self.options
933            .get_numeric_value("l1_penalty_max", "")
934            .ok()
935            .and_then(|(v, found)| found.then_some(v))
936            .unwrap_or(1.0e6)
937    }
938    fn l1_penalty_increase_factor(&self) -> Number {
939        self.options
940            .get_numeric_value("l1_penalty_increase_factor", "")
941            .ok()
942            .and_then(|(v, found)| found.then_some(v))
943            .unwrap_or(8.0)
944    }
945    fn l1_penalty_max_outer_iter(&self) -> usize {
946        self.options
947            .get_integer_value("l1_penalty_max_outer_iter", "")
948            .ok()
949            .and_then(|(v, found)| found.then_some(v))
950            .unwrap_or(8) as usize
951    }
952    fn l1_slack_tol(&self) -> Number {
953        self.options
954            .get_numeric_value("l1_slack_tol", "")
955            .ok()
956            .and_then(|(v, found)| found.then_some(v))
957            .unwrap_or(1.0e-6)
958    }
959    fn l1_steering_factor(&self) -> Number {
960        self.options
961            .get_numeric_value("l1_steering_factor", "")
962            .ok()
963            .and_then(|(v, found)| found.then_some(v))
964            .unwrap_or(10.0)
965    }
966    fn is_l1_fallback_enabled(&self) -> bool {
967        self.options
968            .get_bool_value("l1_fallback_on_restoration_failure", "")
969            .ok()
970            .and_then(|(v, found)| found.then_some(v))
971            .unwrap_or(false)
972    }
973
974    /// Read the μ-strategy auto-fallback switch (pounce#138).
975    /// Default `false` when the option is not set.
976    fn is_mu_strategy_fallback_enabled(&self) -> bool {
977        self.options
978            .get_bool_value("mu_strategy_fallback", "")
979            .ok()
980            .and_then(|(v, found)| found.then_some(v))
981            .unwrap_or(false)
982    }
983
984    /// Has the user set `algorithm = active-set-sqp`? Reads the
985    /// string option and matches case-insensitively against the
986    /// design-note §7.1 spelling. Any value other than
987    /// "active-set-sqp" (including absence) routes to the
988    /// default IPM path.
989    /// Stash a warm-start iterate for the SQP path. Consumed by
990    /// the next `optimize_tnlp` call when the `algorithm` option
991    /// resolves to `active-set-sqp`; the IPM path ignores it.
992    /// Phase 5c (§6) — the parametric / MPC warm-start hand-off.
993    ///
994    /// The iterate is auto-cleared after use, so a follow-up
995    /// solve without an intervening `set_sqp_warm_start` call
996    /// cold-starts.
997    pub fn set_sqp_warm_start(&mut self, warm: crate::sqp::SqpIterates) {
998        self.sqp_warm_start = Some(warm);
999    }
1000
1001    /// Drop any pending warm-start iterate without solving.
1002    pub fn clear_sqp_warm_start(&mut self) {
1003        self.sqp_warm_start = None;
1004    }
1005
1006    /// Install a full primal-dual warm-start iterate for the next IPM
1007    /// `optimize_tnlp`. Captured by the debugger's `resolve` so the
1008    /// re-solve continues from the paused interior point. The caller is
1009    /// responsible for also enabling `warm_start_init_point=yes` (and
1010    /// usually `warm_start_target_mu=<μ>`) so the re-optimize branch of
1011    /// `WarmStartIterateInitializer` preserves the installed iterate.
1012    /// Consumed once per solve, then auto-cleared.
1013    pub fn set_warm_start_iterate(&mut self, snap: crate::debug::IterateSnapshot) {
1014        self.warm_start_iterate = Some(snap);
1015    }
1016
1017    /// Install a caller-supplied fill-reducing permutation for the KKT
1018    /// linear solver (pounce#180 item 1). The next `optimize_*` builds
1019    /// the FERAL backend with [`pounce_feral::OrderingMethod::External`],
1020    /// overriding the `feral_ordering` string option / env var. Use this
1021    /// to inject a block-triangular / Schur ordering a generic algorithm
1022    /// cannot see (Parker, Garcia & Bent, arXiv:2602.17968) or a tearing
1023    /// ordering from equation-oriented decomposition.
1024    ///
1025    /// `perm` is a **0-based, new-to-old permutation** (`perm[k]` is the
1026    /// original index that becomes index `k`), and its length must equal
1027    /// the augmented KKT system dimension (variables + slacks +
1028    /// constraint duals), *not* the problem's `n`. A wrong length or a
1029    /// non-bijection is rejected by FERAL at the first factorization with
1030    /// an `InvalidInput` error (never a panic), surfacing as a solver
1031    /// failure rather than a silently-wrong solve — the ordering only
1032    /// affects fill/time, never the computed solution.
1033    ///
1034    /// Persistent config: unlike the warm-start hooks it is *not*
1035    /// auto-cleared after a solve. Call [`Self::clear_external_ordering`]
1036    /// to drop it. Ignored by non-FERAL backends and by any custom
1037    /// factory plugged via [`Self::set_linear_backend_factory`].
1038    pub fn set_external_ordering(&mut self, perm: Vec<usize>) {
1039        self.external_ordering = Some(perm);
1040    }
1041
1042    /// Drop any installed external KKT ordering, restoring the
1043    /// `feral_ordering`-driven default for subsequent solves.
1044    pub fn clear_external_ordering(&mut self) {
1045        self.external_ordering = None;
1046    }
1047
1048    /// The currently-installed external KKT ordering, if any.
1049    pub fn external_ordering(&self) -> Option<&[usize]> {
1050        self.external_ordering.as_deref()
1051    }
1052
1053    /// Install a block-triangular / Schur KKT partition (pounce#180 item 2).
1054    /// `indices` are KKT-space indices (`0..dim` in the `x, s, c, d` block
1055    /// order the aug-system solver assembles) naming the Schur block `S`; that
1056    /// block is Schur-complemented out and only the two diagonal blocks are
1057    /// factorized (inertia via Sylvester's law). Honored on the IPM + feral +
1058    /// exact-Hessian path; the Schur solver falls back to the standard
1059    /// full-space solver transparently when the partition is unsuitable (too
1060    /// large a fraction of the system, malformed, or a backend error), so a
1061    /// stray hook never breaks a solve. Persistent config (not auto-cleared);
1062    /// drop it via [`Self::clear_kkt_schur_block`].
1063    pub fn set_kkt_schur_block(&mut self, indices: Vec<usize>) {
1064        self.kkt_schur_block = Some(indices);
1065    }
1066
1067    /// Drop any installed Schur KKT partition, restoring the standard
1068    /// full-space solver for subsequent solves.
1069    pub fn clear_kkt_schur_block(&mut self) {
1070        self.kkt_schur_block = None;
1071    }
1072
1073    /// The currently-installed Schur KKT partition, if any.
1074    pub fn kkt_schur_block(&self) -> Option<&[usize]> {
1075        self.kkt_schur_block.as_deref()
1076    }
1077
1078    /// Return the final QP working set from the most recent SQP
1079    /// solve, or `None` if the last solve wasn't SQP, didn't
1080    /// produce a working set (cold-start declared the iterate
1081    /// optimal before solving any QP), or no SQP solve has run.
1082    pub fn last_sqp_working_set(&self) -> Option<&pounce_qp::WorkingSet> {
1083        self.sqp_last_working_set.as_ref()
1084    }
1085
1086    /// If `solver_selection` is explicitly set to a value whose routing lives
1087    /// only in the CLI's `.nl` dispatch, return  it; otherwise `None`.
1088    /// `optimize_tnlp` uses this to reject a forced convex selection a library
1089    /// consumer cannot honor.
1090    fn unsupported_library_solver_selection(&self) -> Option<&'static str> {
1091        let (v, found) = self.options.get_string_value("solver_selection", "").ok()?;
1092        if !found {
1093            return None;
1094        }
1095        ["lp-ipm", "qp-ipm", "socp"]
1096            .into_iter()
1097            .find(|c| v.eq_ignore_ascii_case(c))
1098    }
1099
1100    /// The `linear_solver` value when the caller explicitly asked for a
1101    /// backend pounce does not implement; `None` when the request can be
1102    /// served (or was never made).
1103    ///
1104    /// pounce ships two: **FERAL** (pure Rust, the effective default) and
1105    /// **MA57** (HSL, behind the `ma57` feature). The option's valid-value
1106    /// list is a faithful port of upstream Ipopt's — `ma27`, `ma77`,
1107    /// `ma86`, `ma97`, `mumps`, `pardiso`, `pardisomkl`, `spral`, `wsmp`,
1108    /// `custom` — so an `ipopt.opt` written for Ipopt parses here, and
1109    /// every one of those names used to fall through a `_ =>` arm to
1110    /// FERAL. A run "using MUMPS" was a FERAL run; a benchmark comparing
1111    /// backends compared FERAL with itself (gh#483 follow-up).
1112    ///
1113    /// The registered default is `feral`, which pounce implements, so no
1114    /// explicit-vs-default distinction is needed: whatever the option
1115    /// resolves to must be a backend that exists. (It is checked
1116    /// unconditionally on purpose — a future default naming something
1117    /// unimplemented should trip this, not slip past it.)
1118    ///
1119    /// Explicit `ma57` on a build that lacks the feature is *not* refused;
1120    /// that fallback is reported in the banner ("ma57 requested but not
1121    /// compiled"), so it is visible rather than silent, and failing a
1122    /// portable `ipopt.opt` over a build flag would cost more than it buys.
1123    pub fn unimplemented_linear_solver(&self) -> Option<String> {
1124        let (v, _) = self.options.get_string_value("linear_solver", "").ok()?;
1125        ["feral", "ma57"]
1126            .iter()
1127            .all(|ok| !v.eq_ignore_ascii_case(ok))
1128            .then_some(v)
1129    }
1130
1131    /// The message for the first option the caller set that names a
1132    /// feature pounce does not implement, or `None`. Public so the CLI
1133    /// can refuse before routing — the convex dispatch never reaches
1134    /// `optimize_tnlp`. See [`crate::unimplemented_options`].
1135    pub fn unimplemented_option_refusal(&self) -> Option<String> {
1136        crate::unimplemented_options::refusal(&self.options, &self.reg_options)
1137    }
1138
1139    /// `option_file_name` set on a surface that never resolves it.
1140    ///
1141    /// The option reaches a file through exactly one path —
1142    /// [`Self::initialize_with_option_file`], which the `pounce` CLI
1143    /// drives. A library caller (Python, the C interface, WASM) sets its
1144    /// options directly and calls no such thing, so on those surfaces the
1145    /// option names a whole configuration and applies none of it: gh#518's
1146    /// failure mode, one surface over. It used to be caught by the blanket
1147    /// [`crate::unimplemented_options`] refusal, which no longer covers it
1148    /// now that the feature exists; this keeps the guard exactly where the
1149    /// feature still doesn't.
1150    ///
1151    /// Deliberately *not* fixed by having the library read an options file
1152    /// too: an implicit `./ipopt.opt` lookup under Python or the GAMS C
1153    /// link would be a surprising action at a distance, and `pounce.opt`
1154    /// already means something else to GAMS.
1155    pub fn unhonored_option_file_name(&self) -> Option<String> {
1156        if self.option_file_resolved {
1157            return None;
1158        }
1159        // Same default gate as the table: an explicitly-set *default*
1160        // asks for nothing, so it must not fail. `option_file_name`
1161        // defaults to `ipopt.opt`, and a caller round-tripping a full
1162        // option dump — or a generated config that spells out every
1163        // registered name — hits that value without asking for anything.
1164        if !crate::unimplemented_options::set_to_a_non_default(
1165            &self.options,
1166            &self.reg_options,
1167            "option_file_name",
1168        ) {
1169            return None;
1170        }
1171        match self.options.get_string_value("option_file_name", "") {
1172            Ok((name, true)) if !name.is_empty() => Some(format!(
1173                "pounce: `option_file_name` was set to `{name}`, but this entry \
1174                 point does not read options files — it would configure nothing. \
1175                 The `pounce` CLI honors it (and `./pounce.opt` / `./ipopt.opt`); \
1176                 from a library, read the file yourself and pass its contents to \
1177                 `initialize_with_options_str`, or set the options directly. \
1178                 Tracking issue: https://github.com/jkitchin/pounce/issues/518"
1179            )),
1180            _ => None,
1181        }
1182    }
1183
1184    /// Warnings for caching hints pounce does not exploit. These never
1185    /// block a solve: the answer is identical either way, so refusing
1186    /// would cost the caller more than the silence did.
1187    pub fn unexploited_hint_warnings(&self) -> Vec<String> {
1188        crate::unimplemented_options::hint_warnings(&self.options, &self.reg_options)
1189    }
1190
1191    /// Resolve the five registered `derivative_test*` knobs. Every one
1192    /// of them was registered and never read, so `derivative_test=
1193    /// first-order` ran no test and printed nothing — a checker that
1194    /// silently checks nothing reports success by omission (gh#483
1195    /// follow-up).
1196    fn derivative_test_options(&self) -> DerivativeTestOptions {
1197        let num = |key: &str, default: Number| -> Number {
1198            self.options
1199                .get_numeric_value(key, "")
1200                .ok()
1201                .and_then(|(v, f)| f.then_some(v))
1202                .unwrap_or(default)
1203        };
1204        DerivativeTestOptions {
1205            mode: self
1206                .options
1207                .get_string_value("derivative_test", "")
1208                .ok()
1209                .and_then(|(v, f)| f.then_some(v))
1210                .map(|v| DerivativeTest::from_option(&v))
1211                .unwrap_or_default(),
1212            perturbation: num("derivative_test_perturbation", 1e-8),
1213            tol: num("derivative_test_tol", 1e-4),
1214            first_index: self
1215                .options
1216                .get_integer_value("derivative_test_first_index", "")
1217                .ok()
1218                .and_then(|(v, f)| f.then_some(v))
1219                .unwrap_or(-2),
1220            print_all: self
1221                .options
1222                .get_bool_value("derivative_test_print_all", "")
1223                .ok()
1224                .and_then(|(v, f)| f.then_some(v))
1225                .unwrap_or(false),
1226        }
1227    }
1228
1229    /// Run the derivative checker, if asked, against the **user's own**
1230    /// TNLP — before presolve wraps it and before any scaling — so the
1231    /// report is about the derivatives the caller wrote, in the caller's
1232    /// own indices.
1233    ///
1234    /// Advisory, like upstream: a suspicious entry is reported and the
1235    /// solve continues. The report goes to stderr so it survives
1236    /// `print_level=0` and leaves `--json-output`'s stdout clean.
1237    pub fn run_derivative_test(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
1238        let opts = self.derivative_test_options();
1239        if matches!(opts.mode, DerivativeTest::None) {
1240            return;
1241        }
1242        let report = {
1243            let mut borrowed = tnlp.borrow_mut();
1244            pounce_nlp::derivative_test::run(&mut *borrowed, &opts)
1245        };
1246        let Some(report) = report else {
1247            eprintln!(
1248                "pounce: derivative_test was requested but the TNLP declined to \
1249                 supply the information the check needs (dimensions, bounds, or \
1250                 a starting point); no test was run."
1251            );
1252            return;
1253        };
1254        use pounce_common::journalist::JournalCategory;
1255        for line in &report.lines {
1256            eprintln!("{line}");
1257            self.journalist.print(
1258                JournalLevel::J_SUMMARY,
1259                JournalCategory::J_MAIN,
1260                &format!("{line}\n"),
1261            );
1262        }
1263    }
1264
1265    /// The message [`Self::unimplemented_linear_solver`] earns, shared by
1266    /// every frontend so they cannot drift apart.
1267    pub fn unimplemented_linear_solver_message(value: &str) -> String {
1268        format!(
1269            "pounce: linear_solver={value} is not implemented. pounce provides \
1270             `feral` (pure-Rust sparse symmetric, the default) and `ma57` (HSL, \
1271             in a `--features ma57` build); the other names in the option's \
1272             list come from the upstream Ipopt registry so an ipopt.opt written \
1273             for Ipopt still parses. Selecting one used to run FERAL silently, \
1274             which makes a backend comparison measure nothing — so it is \
1275             refused instead. Use linear_solver=feral or linear_solver=ma57."
1276        )
1277    }
1278
1279    fn is_sqp_algorithm_selected(&self) -> bool {
1280        // `algorithm` is the primary selector.
1281        // `solver_selection = qp-active-set` selects the
1282        // same active-set SQP engine.
1283        let algo_sqp = matches!(
1284            self.options.get_string_value("algorithm", ""),
1285            Ok((v, true)) if v.eq_ignore_ascii_case("active-set-sqp")
1286        );
1287        let selection_sqp = matches!(
1288            self.options.get_string_value("solver_selection", ""),
1289            Ok((v, true)) if v.eq_ignore_ascii_case("qp-active-set")
1290        );
1291        algo_sqp || selection_sqp
1292    }
1293
1294    /// Phase 5b SQP entry point. Builds the same NLP chain
1295    /// (`TNLPAdapter` → `OrigIpoptNlp` → `IpoptNlpAdapter`) the
1296    /// IPM uses, then runs `SqpAlgorithm::optimize`. Maps the
1297    /// `SqpResult.status` back to `ApplicationReturnStatus` and
1298    /// hands the final iterate to the user TNLP's
1299    /// `finalize_solution` callback via `finalize_via_sqp`.
1300    fn optimize_sqp_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1301        use pounce_nlp::ConstObjScaling;
1302        use pounce_nlp::orig_ipopt_nlp::OrigIpoptNlp;
1303        use pounce_nlp::tnlp_adapter::TNLPAdapter;
1304
1305        // Wall-clock for the whole SQP solve, mirroring the IPM path's
1306        // `t_start` (see the `total_wallclock_time_secs` assignment in
1307        // `optimize_tnlp`). Without this the field stayed at its struct
1308        // default of 0.0 on every active-set solve, so `--json-output`
1309        // reported an instantaneous solve regardless of actual runtime and
1310        // the engine could not be speed-compared against qp-ipm at all
1311        // (benchmarks/scripts/compare_qp_four_way.py had to skip the column).
1312        let t_start = std::time::Instant::now();
1313
1314        let adapter = match TNLPAdapter::new(Rc::clone(&tnlp)) {
1315            Ok(a) => Rc::new(RefCell::new(a)),
1316            Err(_) => return ApplicationReturnStatus::InvalidProblemDefinition,
1317        };
1318        // The SQP path never runs gradient-based scaling, but the
1319        // constant `obj_scaling_factor` (negative ⇒ maximize) still
1320        // applies via the OrigIpoptNlp constructor.
1321        let obj_scaling_factor = self
1322            .options
1323            .get_numeric_value("obj_scaling_factor", "")
1324            .ok()
1325            .and_then(|(v, f)| f.then_some(v))
1326            .unwrap_or(1.0);
1327        let orig_nlp = match OrigIpoptNlp::new(
1328            Rc::clone(&adapter),
1329            Rc::new(ConstObjScaling(obj_scaling_factor)),
1330        ) {
1331            Ok(n) => n,
1332            Err(_) => return ApplicationReturnStatus::InternalError,
1333        };
1334        let nlp_rc: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
1335
1336        let mut sqp_adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(&nlp_rc));
1337
1338        let mut builder = self.algorithm_builder_snapshot();
1339        builder.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
1340        let factory = self.make_backend_factory();
1341        let mut alg = match builder.build_sqp_with_backend(factory) {
1342            Some(a) => a,
1343            None => return ApplicationReturnStatus::InternalError,
1344        };
1345
1346        // Problem statistics + end-of-run summary are emitted by the engine
1347        // itself here (#206), gated on the main `print_level`, so the SQP
1348        // route matches the IPM route across every frontend (CLI, Python, C).
1349        // The SQP's own per-iteration rows stay gated on the separate
1350        // `sqp_print_level`.
1351        let console_output = match self.options.get_integer_value("print_level", "") {
1352            Ok((v, true)) => v >= 1,
1353            _ => true,
1354        };
1355        self.emit_problem_stats(&tnlp, console_output);
1356
1357        // Phase 5c (§6): consume any stashed warm-start iterate.
1358        // `optimize_with_warm_start(warm=None)` is equivalent to
1359        // `optimize`, so cold callers see no change.
1360        let warm = self.sqp_warm_start.take();
1361        let res = match alg.optimize_with_warm_start(&mut sqp_adapter, warm) {
1362            Ok(r) => r,
1363            Err(e) => {
1364                // Always surface this. It used to be gated on the
1365                // undocumented `POUNCE_DBG_SQP`, so the only thing a user saw
1366                // was a bare `Internal_Error` with no indication of what went
1367                // wrong -- the underlying message here was
1368                // `QpFailure(LinearSolverFailure("QP subproblem returned
1369                // status unbounded"))`, which points straight at the cause.
1370                // A solve that is about to fail is exactly when the reason
1371                // should be cheapest to obtain.
1372                tracing::warn!(
1373                    target: "pounce::sqp",
1374                    "SQP solve failed: {e:?}"
1375                );
1376                return ApplicationReturnStatus::InternalError;
1377            }
1378        };
1379        // Stash the result's working set so the next solve in a
1380        // sequence can fetch it via `last_sqp_working_set`.
1381        self.sqp_last_working_set = res.working_set.clone();
1382        // Populate the shared `SolveStatistics` so the Python /
1383        // C-API post-solve accessors (`GetIpoptIterCount`,
1384        // `info["iter_count"]`, etc.) report the SQP outer-iter
1385        // count rather than zero. Constraint-violation /
1386        // dual-infeasibility residuals get the SQP-side values
1387        // too. The IPM path overwrites this dict on its own
1388        // solves, so SQP-vs-IPM mixing across solves stays
1389        // honest.
1390        {
1391            let mut stats = self.statistics.borrow_mut();
1392            stats.iteration_count = res.n_iter as Index;
1393            // Subproblem counters. The outer iteration count alone
1394            // cannot show what a working-set warm start bought — the
1395            // saved work is inside the QPs — so both are reported.
1396            stats.sqp_qp_solves = res.n_qp_solves as Index;
1397            stats.sqp_qp_working_set_changes = res.n_qp_working_set_changes as Index;
1398            stats.final_objective = res.obj;
1399            // `final_scaled_objective` defaults to NaN; the SQP path does not
1400            // thread nlp_scaling through the objective (same as the residuals
1401            // mirrored below), so the scaled objective equals the unscaled
1402            // one. Without this it stayed NaN and the console printed
1403            // "Objective ...: nan  <unscaled>" on every active-set solve
1404            // (gh #313), even on a clean optimal solve.
1405            stats.final_scaled_objective = res.obj;
1406            stats.final_dual_inf = res.final_stationarity;
1407            stats.final_constr_viol = res.final_constr_viol;
1408            stats.final_compl = 0.0; // SQP has no barrier — no compl term.
1409            // Overall KKT error. This was previously left at the struct
1410            // default, which made every successful SQP solve report an
1411            // overall error of exactly 0.0 — indistinguishable from a
1412            // genuinely perfect solve, and enough on its own to make
1413            // `pounce.minimize`'s acceptable-KKT fallback upgrade any status
1414            // on this path to `success=True`. Same expression as the unscaled
1415            // twin below; the two agree because the SQP path does not thread
1416            // nlp_scaling through its residuals.
1417            stats.final_kkt_error = res.final_stationarity.max(res.final_constr_viol);
1418            // Unscaled residuals (pounce#173). The SQP path does not thread
1419            // the nlp_scaling factors through to its residuals yet, so these
1420            // mirror the SQP-side values: correct when no scaling is active
1421            // (the common case) and a conservative proxy otherwise. Populated
1422            // here so the info dict's `final_unscaled_*` keys are honest
1423            // rather than left at the 0.0 default.
1424            stats.final_unscaled_dual_inf = res.final_stationarity;
1425            stats.final_unscaled_constr_viol = res.final_constr_viol;
1426            stats.final_unscaled_compl = 0.0;
1427            stats.final_unscaled_kkt_error = res.final_stationarity.max(res.final_constr_viol);
1428            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
1429        }
1430        let (app_status, solver_status) = match res.status {
1431            crate::sqp::SqpStatus::Optimal => (
1432                ApplicationReturnStatus::SolveSucceeded,
1433                pounce_nlp::SolverReturn::Success,
1434            ),
1435            crate::sqp::SqpStatus::MaxIter => (
1436                ApplicationReturnStatus::MaximumIterationsExceeded,
1437                pounce_nlp::SolverReturn::MaxiterExceeded,
1438            ),
1439            crate::sqp::SqpStatus::InfeasibleSubproblem => (
1440                ApplicationReturnStatus::InfeasibleProblemDetected,
1441                pounce_nlp::SolverReturn::LocalInfeasibility,
1442            ),
1443            crate::sqp::SqpStatus::LineSearchFailed => (
1444                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
1445                pounce_nlp::SolverReturn::ErrorInStepComputation,
1446            ),
1447            // Honest non-committal QP-subproblem failure (#282): the QP
1448            // solver could not compute a step and did NOT certify
1449            // infeasibility. Never report Infeasible_Problem_Detected here
1450            // — a feasible problem has no infeasibility certificate.
1451            crate::sqp::SqpStatus::QpStepFailed => (
1452                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
1453                pounce_nlp::SolverReturn::ErrorInStepComputation,
1454            ),
1455            // The QP subproblem ran out of its own iteration budget. Same
1456            // #282 guarantee — no infeasibility is asserted — but reported as
1457            // the budget exhaustion it is, so the user sees a limit they can
1458            // raise (`sqp_qp_max_iter`) instead of a step-size stall with no
1459            // remedy. See `SqpStatus::QpIterationLimit` for why these were
1460            // split.
1461            crate::sqp::SqpStatus::QpIterationLimit => (
1462                ApplicationReturnStatus::MaximumIterationsExceeded,
1463                pounce_nlp::SolverReturn::MaxiterExceeded,
1464            ),
1465            // Unbounded below, with a recession ray verified against the
1466            // true NLP (gh #388). `Diverging_Iterates` is POUNCE's (Ipopt's)
1467            // unboundedness verdict and maps to AMPL `solve_result_num=300`
1468            // — the same answer the IPM selectors give on the same model,
1469            // instead of the `Internal_Error` / 500 ("the solver broke")
1470            // this path used to report.
1471            crate::sqp::SqpStatus::Unbounded => (
1472                ApplicationReturnStatus::DivergingIterates,
1473                pounce_nlp::SolverReturn::DivergingIterates,
1474            ),
1475        };
1476
1477        // Same gate as the IPM path: an infeasible-subproblem exit is a
1478        // numerical inference, and a feasible starting point disproves it
1479        // (gh #379). Only the infeasibility verdict is rewritten — every other
1480        // status passes through untouched, so the pair stays in lockstep.
1481        let refuted = withdraw_infeasibility_if_refuted(
1482            &tnlp,
1483            solver_status,
1484            self.nlp_lower_bound_inf(),
1485            self.nlp_upper_bound_inf(),
1486            self.user_tol(),
1487        );
1488        let (app_status, solver_status) = if refuted == solver_status {
1489            (app_status, solver_status)
1490        } else {
1491            (solver_return_to_app_status(refuted), refuted)
1492        };
1493
1494        // Forward to the user TNLP's finalize_solution. We pass
1495        // the SQP iterate and recovered multipliers via the
1496        // OrigIpoptNlp's lifting hooks. Failure here is silent
1497        // (we still return the algorithm's status) — the user
1498        // sees the right ApplicationReturnStatus regardless.
1499        let _ = finalize_via_sqp(&nlp_rc, &res, solver_status, &tnlp);
1500
1501        // Honor the opt-in status-fidelity gate on the SQP path too
1502        // (pounce#173), then emit the end-of-run summary with the final
1503        // (possibly downgraded) status so the console matches the returned
1504        // ApplicationReturnStatus.
1505        let final_status = self.apply_kkt_fidelity_gate(app_status);
1506        self.emit_end_summary(final_status, &nlp_rc, console_output);
1507        final_status
1508    }
1509
1510    /// Opt-in status-fidelity gate (pounce#173), shared by the IPM and
1511    /// SQP solve paths. When the user sets a positive `kkt_fidelity_tol`,
1512    /// a reported `Solve_Succeeded` whose max-norm UNSCALED KKT error
1513    /// (`SolveStatistics::final_unscaled_kkt_error`) exceeds it is
1514    /// downgraded to `Solved_To_Acceptable_Level` — the honest "this is a
1515    /// point, but not converged to the requested fidelity" status. This
1516    /// catches the ill-conditioned / nlp_scaling-deflated case where the
1517    /// scaled convergence test passes but the user-space duals have
1518    /// drifted. It is a pure relabel at termination (no extra iterations);
1519    /// unset or non-positive (the default) is a strict no-op, so every
1520    /// existing caller keeps the Ipopt-faithful status.
1521    fn apply_kkt_fidelity_gate(
1522        &self,
1523        app_status: ApplicationReturnStatus,
1524    ) -> ApplicationReturnStatus {
1525        if !matches!(app_status, ApplicationReturnStatus::SolveSucceeded) {
1526            return app_status;
1527        }
1528        if let Ok((ftol, true)) = self.options.get_numeric_value("kkt_fidelity_tol", "") {
1529            if ftol > 0.0 {
1530                let unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
1531                if unscaled_kkt > ftol {
1532                    tracing::info!(target: "pounce::diagnostics",
1533                        "kkt_fidelity_tol={ftol:.3e}: unscaled KKT error {unscaled_kkt:.3e} \
1534                         exceeds it — downgrading Solve_Succeeded → \
1535                         Solved_To_Acceptable_Level (pounce#173)");
1536                    return ApplicationReturnStatus::SolvedToAcceptableLevel;
1537                }
1538            }
1539        }
1540        app_status
1541    }
1542
1543    /// `nlp_lower_bound_inf` — the magnitude at or below which a bound is
1544    /// treated as absent.
1545    fn nlp_lower_bound_inf(&self) -> Number {
1546        self.options
1547            .get_numeric_value("nlp_lower_bound_inf", "")
1548            .ok()
1549            .and_then(|(v, f)| f.then_some(v))
1550            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF)
1551    }
1552
1553    /// `nlp_upper_bound_inf` — the magnitude at or above which a bound is
1554    /// treated as absent.
1555    fn nlp_upper_bound_inf(&self) -> Number {
1556        self.options
1557            .get_numeric_value("nlp_upper_bound_inf", "")
1558            .ok()
1559            .and_then(|(v, f)| f.then_some(v))
1560            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF)
1561    }
1562
1563    /// The user's convergence tolerance `tol`.
1564    fn user_tol(&self) -> Number {
1565        self.options
1566            .get_numeric_value("tol", "")
1567            .ok()
1568            .and_then(|(v, f)| f.then_some(v))
1569            .unwrap_or(1e-8)
1570    }
1571
1572    /// Emit the Ipopt-style problem-statistics block (#206) from the
1573    /// engine's own reduced problem, gated on `console_output`
1574    /// (print_level >= 1). Shared by the IPM (`optimize_tnlp`) and SQP
1575    /// (`optimize_sqp_tnlp`) entry points so every algorithm and every
1576    /// frontend (CLI, Python, C) gets the identical block. Built from the
1577    /// same `collect_stats` inputs the CLI used, so the output is
1578    /// byte-identical to the historical CLI block.
1579    fn emit_problem_stats(&self, tnlp: &Rc<RefCell<dyn TNLP>>, console_output: bool) {
1580        if !console_output {
1581            return;
1582        }
1583        let lo_inf = self
1584            .options
1585            .get_numeric_value("nlp_lower_bound_inf", "")
1586            .ok()
1587            .and_then(|(v, f)| f.then_some(v))
1588            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
1589        let up_inf = self
1590            .options
1591            .get_numeric_value("nlp_upper_bound_inf", "")
1592            .ok()
1593            .and_then(|(v, f)| f.then_some(v))
1594            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
1595        let fixed_treatment = match self
1596            .options
1597            .get_string_value("fixed_variable_treatment", "")
1598            .ok()
1599            .and_then(|(v, f)| f.then_some(v))
1600            .as_deref()
1601        {
1602            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
1603            _ => FixedVarTreatment::MakeParameter,
1604        };
1605        if let Some(stats) =
1606            pounce_solve_report::console::collect_stats(tnlp, lo_inf, up_inf, fixed_treatment)
1607        {
1608            pounce_solve_report::console::print_problem_stats(&stats);
1609        }
1610    }
1611
1612    /// Drain the NLP's per-eval counters into the shared `SolveStatistics`
1613    /// and emit the Ipopt-style end-of-run summary (#206). Shared by both
1614    /// solve paths. The counts are read from the NLP AFTER the solve (so the
1615    /// final solution evaluation is included, matching the historical count)
1616    /// and written into `SolveStatistics` so the post-solve API accessors
1617    /// (`info["n_obj_evals"]`, …) report them even when the console is
1618    /// silent. c/d (and jac_c/jac_d) are per-subsystem, so the max recovers
1619    /// the eval_g / eval_jac_g call count. The console summary itself is
1620    /// gated on `console_output` (print_level >= 1).
1621    fn emit_end_summary(
1622        &self,
1623        app_status: ApplicationReturnStatus,
1624        nlp: &Rc<RefCell<dyn IpoptNlp>>,
1625        console_output: bool,
1626    ) {
1627        {
1628            let ec = nlp.borrow().eval_counts();
1629            let mut stats = self.statistics.borrow_mut();
1630            stats.num_obj_evals = ec[0];
1631            stats.num_obj_grad_evals = ec[1];
1632            stats.num_constr_evals = ec[2].max(ec[3]);
1633            stats.num_constr_jac_evals = ec[4].max(ec[5]);
1634            stats.num_hess_evals = ec[6];
1635        }
1636        if !console_output {
1637            return;
1638        }
1639        let stats = self.statistics.borrow();
1640        let counts = pounce_solve_report::console::EvalCounts {
1641            n_obj: stats.num_obj_evals as u64,
1642            n_grad_f: stats.num_obj_grad_evals as u64,
1643            n_g: stats.num_constr_evals as u64,
1644            n_jac_g: stats.num_constr_jac_evals as u64,
1645            n_h: stats.num_hess_evals as u64,
1646        };
1647        pounce_solve_report::console::print_summary(app_status, &stats, &counts);
1648    }
1649
1650    /// Build a *copy* of the algorithm builder configured per the
1651    /// current options. The SQP path uses this so it gets a
1652    /// fresh builder without mutating the application's state.
1653    fn algorithm_builder_snapshot(&self) -> AlgorithmBuilder {
1654        let mut builder = AlgorithmBuilder::default();
1655        apply_sqp_options(&self.options, &mut builder.sqp);
1656        apply_qp_subproblem_options(&self.options, &mut builder.sqp_qp);
1657        builder
1658    }
1659
1660    /// Construct a LinearBackendFactory honoring the
1661    /// `linear_solver` option. Default FERAL; HSL MA57 when
1662    /// built with the `ma57` feature.
1663    fn make_backend_factory(&self) -> LinearBackendFactory {
1664        Box::new(
1665            |_choice| -> Box<dyn pounce_linsol::SparseSymLinearSolverInterface> {
1666                Box::new(pounce_feral::FeralSolverInterface::new())
1667            },
1668        )
1669    }
1670
1671    /// Phase 3.5 auto-fallback driver.
1672    ///
1673    /// Runs the standard solve (no wrapper) first. If it ends in a
1674    /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
1675    /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
1676    /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
1677    /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
1678    /// it returns `Solve_Succeeded`; otherwise returns the original
1679    /// status.
1680    ///
1681    /// Caveat: the user TNLP's `finalize_solution` runs once per
1682    /// attempt. When the retry doesn't promote, the user's captured
1683    /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
1684    /// point) even though the returned status is the original's.
1685    /// Documented on the option's help text; tightening this is a
1686    /// Phase-4 follow-up.
1687    fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1688        // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
1689        // reached for `m > 0`, so `optimize_constrained` is exact.
1690        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
1691        if !is_l1_fallback_trigger(first_status) {
1692            return first_status;
1693        }
1694        // Trigger fired. Flip the wrapper option for the retry and
1695        // restore it after — keeps the user's option-table view of the
1696        // session exactly as they left it.
1697        let prev = self
1698            .options
1699            .get_string_value("l1_exact_penalty_barrier", "")
1700            .ok();
1701        let _ = self
1702            .options
1703            .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
1704        let retry_status = self
1705            .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
1706            .unwrap_or(ApplicationReturnStatus::InternalError);
1707        let _ = self.options.set_string_value(
1708            "l1_exact_penalty_barrier",
1709            prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
1710            true,
1711            false,
1712        );
1713        if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
1714            retry_status
1715        } else {
1716            first_status
1717        }
1718    }
1719
1720    /// μ-strategy auto-fallback driver (pounce#138).
1721    ///
1722    /// Runs the standard solve first. If it stalls short of optimal in a
1723    /// way a μ-strategy flip can plausibly fix — `Solved_To_Acceptable_Level`
1724    /// or `Maximum_Iterations_Exceeded`, the two signatures seen on the
1725    /// princetonlib instances where the dual infeasibility parks above
1726    /// `tol` while constraint violation and complementarity are already
1727    /// deeply converged — it flips `mu_strategy` (adaptive↔monotone) and
1728    /// solves once more. The retry's status is promoted only if it returns
1729    /// `Solve_Succeeded`; otherwise the original status is returned.
1730    ///
1731    /// (maxcut/price stall at acceptable-level under adaptive; fermat2_vareps
1732    /// stalls at `max_iter` — hence both triggers. flosp2tm is μ-independent
1733    /// and correctly does not promote.)
1734    ///
1735    /// The flip direction is taken from the option's current value:
1736    /// `adaptive` → `monotone`, anything else (including absent, which
1737    /// the builder treats as monotone) → `adaptive`. The option table is
1738    /// restored to the user's original view afterward.
1739    ///
1740    /// Caveat (shared with the ℓ₁ fallback): the user TNLP's
1741    /// `finalize_solution` runs once per attempt, so when the retry
1742    /// doesn't promote the captured fields hold the retry's iterate.
1743    fn run_with_mu_strategy_fallback(
1744        &mut self,
1745        tnlp: Rc<RefCell<dyn TNLP>>,
1746    ) -> ApplicationReturnStatus {
1747        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
1748        if !matches!(
1749            first_status,
1750            ApplicationReturnStatus::SolvedToAcceptableLevel
1751                | ApplicationReturnStatus::MaximumIterationsExceeded
1752        ) {
1753            return first_status;
1754        }
1755        // Flip the strategy for one retry. The parser maps "adaptive" →
1756        // Adaptive and every other value (incl. unset) → Monotone, so the
1757        // opposite of an explicit "adaptive" is "monotone" and the
1758        // opposite of anything else is "adaptive".
1759        let prev = self.options.get_string_value("mu_strategy", "").ok();
1760        let was_adaptive = prev
1761            .as_ref()
1762            .map(|(v, found)| *found && v == "adaptive")
1763            .unwrap_or(false);
1764        let flipped = if was_adaptive { "monotone" } else { "adaptive" };
1765        let _ = self
1766            .options
1767            .set_string_value("mu_strategy", flipped, true, false);
1768        let retry_status = self.optimize_constrained(Rc::clone(&tnlp));
1769        // Restore the user's original option-table view.
1770        let _ = self.options.set_string_value(
1771            "mu_strategy",
1772            prev.as_ref()
1773                .filter(|(_, found)| *found)
1774                .map(|(v, _)| v.as_str())
1775                .unwrap_or("monotone"),
1776            true,
1777            false,
1778        );
1779        if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
1780            retry_status
1781        } else {
1782            first_status
1783        }
1784    }
1785
1786    /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
1787    ///
1788    /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
1789    /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
1790    /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
1791    ///   - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
1792    ///   - inner solve returns non-Optimal (escalation won't fix
1793    ///     numerical / restoration failure at this ρ)
1794    ///   - ρ already at `l1_penalty_max`
1795    ///   - `l1_penalty_max_outer_iter` reached
1796    ///
1797    /// After the loop, if the inner status is `SolveSucceeded` or
1798    /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
1799    /// to `Infeasible_Problem_Detected` — the returned point is the
1800    /// ℓ₁-best least-infeasible iterate, which is informative even
1801    /// though the original constraints are not satisfied.
1802    ///
1803    /// Returns `Some(status)` if the wrapper ran the solve, `None` if
1804    /// wrapper construction failed (caller should fall through to the
1805    /// standard dispatch path).
1806    fn run_l1_penalty_outer_loop(
1807        &mut self,
1808        tnlp: Rc<RefCell<dyn TNLP>>,
1809    ) -> Option<ApplicationReturnStatus> {
1810        let rho_init = self.l1_penalty_init();
1811        let rho_max = self.l1_penalty_max().max(rho_init);
1812        let factor = self.l1_penalty_increase_factor().max(1.0);
1813        let tau = self.l1_steering_factor();
1814        let slack_tol = self.l1_slack_tol();
1815        let max_outer = self.l1_penalty_max_outer_iter().max(1);
1816
1817        let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
1818        if wrapper.m_eq() == 0 {
1819            // Nothing to slack — let the standard dispatch path handle
1820            // this TNLP unmodified.
1821            return None;
1822        }
1823        wrapper.set_defer_inner_finalize(true);
1824        let wrapper_rc = Rc::new(RefCell::new(wrapper));
1825
1826        let mut rho = rho_init;
1827        let mut last_status = ApplicationReturnStatus::InternalError;
1828        for _outer in 0..max_outer {
1829            wrapper_rc.borrow_mut().set_rho(rho);
1830            let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
1831            last_status = self.optimize_constrained(dyn_tnlp);
1832
1833            let w = wrapper_rc.borrow();
1834            if !w.has_solution() {
1835                // Inner solve aborted before producing an iterate.
1836                drop(w);
1837                break;
1838            }
1839            let slack_sum = w.last_slack_sum();
1840            let y_eq_inf = w.last_y_eq_inf_norm();
1841            drop(w);
1842
1843            // Termination decisions.
1844            let inner_ok = matches!(
1845                last_status,
1846                ApplicationReturnStatus::SolveSucceeded
1847                    | ApplicationReturnStatus::SolvedToAcceptableLevel
1848            );
1849            if !inner_ok {
1850                break;
1851            }
1852            if slack_sum.is_finite() && slack_sum <= slack_tol {
1853                break;
1854            }
1855            if rho >= rho_max {
1856                break;
1857            }
1858            // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
1859            let geom = rho * factor;
1860            let steer = tau * y_eq_inf + 1.0e-12;
1861            rho = geom.max(steer).min(rho_max);
1862        }
1863
1864        // Forward to the user's inner.finalize_solution exactly once.
1865        let w = wrapper_rc.borrow();
1866        if w.has_solution() {
1867            let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
1868            let lambda: Vec<Number> = w.last_lambda().to_vec();
1869            let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
1870            let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
1871            let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
1872            let slack_sum = w.last_slack_sum();
1873            drop(w);
1874
1875            // Honest-infeasibility upgrade (Phase 3): if the inner
1876            // solve says SolveSucceeded / SolvedToAcceptableLevel but
1877            // the slacks did not collapse, the original problem is
1878            // locally infeasible at the returned point. Override the
1879            // application status; the user-visible Solution.status is
1880            // updated below to the matching SolverReturn so the inner
1881            // TNLP sees a consistent picture.
1882            let infeasible_certificate = matches!(
1883                last_status,
1884                ApplicationReturnStatus::SolveSucceeded
1885                    | ApplicationReturnStatus::SolvedToAcceptableLevel
1886            ) && slack_sum.is_finite()
1887                && slack_sum > slack_tol;
1888            // …unless the model's own starting point satisfies every
1889            // constraint, which disproves the certificate outright (gh #379).
1890            // Same gate as the IPM and SQP paths; see
1891            // `withdraw_infeasibility_if_refuted`.
1892            let refuted = infeasible_certificate
1893                && withdraw_infeasibility_if_refuted(
1894                    &tnlp,
1895                    SolverReturn::LocalInfeasibility,
1896                    self.nlp_lower_bound_inf(),
1897                    self.nlp_upper_bound_inf(),
1898                    self.user_tol(),
1899                ) != SolverReturn::LocalInfeasibility;
1900            let final_solver_status = match (infeasible_certificate, refuted) {
1901                (true, false) => SolverReturn::LocalInfeasibility,
1902                // The slacks did not collapse, so the returned point is not
1903                // feasible and `Solve_Succeeded` would be just as wrong as
1904                // `Infeasible_Problem_Detected`. Report the breakdown.
1905                (true, true) => SolverReturn::ErrorInStepComputation,
1906                (false, _) => solver_status,
1907            };
1908            let final_app_status = match (infeasible_certificate, refuted) {
1909                (true, false) => ApplicationReturnStatus::InfeasibleProblemDetected,
1910                (true, true) => ApplicationReturnStatus::ErrorInStepComputation,
1911                (false, _) => last_status,
1912            };
1913
1914            // Recompute f(x*) and c(x*) on the inner.
1915            let f_inner = tnlp
1916                .borrow_mut()
1917                .eval_f(&x_trunc, true)
1918                .unwrap_or(Number::NAN);
1919            let m = tnlp
1920                .borrow_mut()
1921                .get_nlp_info()
1922                .map(|i| i.m as usize)
1923                .unwrap_or(0);
1924            let mut g_inner = vec![0.0; m];
1925            if m > 0 {
1926                let _ = tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
1927            }
1928            tnlp.borrow_mut().finalize_solution(
1929                Solution {
1930                    status: final_solver_status,
1931                    x: &x_trunc,
1932                    z_l: &z_l,
1933                    z_u: &z_u,
1934                    g: &g_inner,
1935                    lambda: &lambda,
1936                    obj_value: f_inner,
1937                },
1938                &TnlpIpoptData::default(),
1939                &TnlpIpoptCq::default(),
1940            );
1941            return Some(final_app_status);
1942        }
1943        // No solution captured at all — pass the inner status through.
1944        Some(last_status)
1945    }
1946
1947    /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
1948    /// bundle, run `optimize`, populate statistics, and call
1949    /// `finalize_solution` on the user's TNLP.
1950    /// Whether an over-determined model (more equality rows than free
1951    /// variables) is *provably* infeasible by linear bound propagation.
1952    ///
1953    /// Consulted only on the `NotEnoughDegreesOfFreedom` failure path, where
1954    /// the solve never runs and therefore can never itself discover the
1955    /// infeasibility (gh#387). Builds a throwaway presolve wrapper with only
1956    /// Phase 1 (bound tightening) enabled and asks it for a certified proof —
1957    /// this inherits the certification safety net wholesale: the crossing must
1958    /// exceed the solver's own acceptance margin at the crossed pair's scale,
1959    /// and a concrete witness point satisfying every constraint withdraws the
1960    /// verdict. A `false` here costs nothing but keeping the DOF error.
1961    ///
1962    /// The witness gate runs under [`pounce_presolve::WitnessRule`]'s
1963    /// `DeclaredRowRelative` form, which is admissible only because the solve
1964    /// cannot run on this path (gh#391) — see the comment on the probe below.
1965    ///
1966    /// Deliberately independent of the `presolve` master switch: this is not a
1967    /// model transformation (the wrapper is dropped without solving through
1968    /// it), it is a last check before reporting a structural error for a
1969    /// problem whose verdict is already decided.
1970    fn overdetermined_model_certified_infeasible(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> bool {
1971        let mut opts = pounce_presolve::PresolveOptions::from_options_list(&self.options)
1972            .unwrap_or_else(|_| pounce_presolve::PresolveOptions::defaults());
1973        opts.enabled = true;
1974        opts.bound_tightening = true;
1975        // Certification needs Phase 1 only; every transformative or
1976        // diagnostic phase is dead weight on a wrapper that is never
1977        // solved through.
1978        opts.auxiliary = false;
1979        opts.fbbt = false;
1980        opts.redundant_constraint_removal = false;
1981        opts.licq_check = false;
1982        opts.warm_z_bounds = false;
1983        // The one place the witness rule is raised off the solver's own
1984        // acceptance test (gh#391). It is sound *here specifically* because the
1985        // gate has already established the solve cannot run: the alternative to
1986        // the proof is the structural 5xx error, never `Solve_Succeeded`, so
1987        // the #380 "two routes, two answers" contradiction the clamp exists to
1988        // prevent has no second route to contradict. See `WitnessRule` for the
1989        // full argument and the homogeneous-row fallback.
1990        let mut probe =
1991            pounce_presolve::PresolveTnlp::new(Rc::clone(tnlp), opts).probing_without_a_solve();
1992        if probe.get_nlp_info().is_none() {
1993            return false;
1994        }
1995        probe.certified_infeasible().is_some()
1996    }
1997
1998    fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1999        let t_start = Instant::now();
2000
2001        // `print_user_options yes` — dump the OptionsList before the
2002        // solve. Mirrors `IpoptApplication::call_optimize` (upstream
2003        // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
2004        let print_opts = self
2005            .options
2006            .get_bool_value("print_user_options", "")
2007            .ok()
2008            .and_then(|(v, f)| f.then_some(v))
2009            .unwrap_or(false);
2010        if print_opts {
2011            print!(
2012                "\nList of user-set options:\n\n{}",
2013                self.options.print_user_options()
2014            );
2015        }
2016
2017        // `print_options_documentation yes` — dump the full registry
2018        // (every option with type, default, valid range/strings, and
2019        // long description) before the solve. Honors
2020        // `print_options_mode` (`text` / `latex` / `doxygen`; only
2021        // `text` is implemented today, the others fall through with a
2022        // one-line note) and `print_advanced_options`. Mirrors
2023        // upstream `IpoptApplication::call_optimize`'s
2024        // `print_options_documentation` branch and `Common/IpRegOptions.cpp`
2025        // `OutputOptionDocumentation`.
2026        let print_doc = self
2027            .options
2028            .get_bool_value("print_options_documentation", "")
2029            .ok()
2030            .and_then(|(v, f)| f.then_some(v))
2031            .unwrap_or(false);
2032        if print_doc {
2033            let mode = self
2034                .options
2035                .get_string_value("print_options_mode", "")
2036                .ok()
2037                .map(|(v, _)| PrintOptionsMode::from_tag(&v))
2038                .unwrap_or(PrintOptionsMode::Text);
2039            let advanced = self
2040                .options
2041                .get_bool_value("print_advanced_options", "")
2042                .ok()
2043                .map(|(v, _)| v)
2044                .unwrap_or(false);
2045            print!(
2046                "\n# Pounce options registry\n\n{}",
2047                self.reg_options.print_options_documentation(mode, advanced)
2048            );
2049        }
2050
2051        // Mint a fresh `TimingStatistics` for this solve — shared (via
2052        // `Rc`) with the data and the NLP below so every `eval_*` and
2053        // every iterate-phase records into the same accumulator. The
2054        // application keeps its own `Rc` so callers can read totals out
2055        // via [`Self::timing_stats`].
2056        let timing = Rc::new(TimingStatistics::new());
2057        *self.timing.borrow_mut() = Rc::clone(&timing);
2058        // Gate the *detailed* per-subsystem timers on `timing_statistics`
2059        // (default "no"), matching upstream Ipopt. Without this, every
2060        // timed `eval_*` / phase section pays two `getrusage` syscalls per
2061        // start/end even when statistics are off — 16-20% of busy CPU on
2062        // fast-objective NLPs (issue #190). `print_timing_statistics=yes`
2063        // implies `timing_statistics=yes` (per its option help), so either
2064        // one enables the detailed timers. `overall_alg` is started
2065        // unconditionally below: it feeds the `max_cpu_time` check and is
2066        // reported regardless of the option.
2067        let timing_enabled = ["timing_statistics", "print_timing_statistics"]
2068            .iter()
2069            .any(|opt| {
2070                self.options
2071                    .get_bool_value(opt, "")
2072                    .ok()
2073                    .and_then(|(v, f)| f.then_some(v))
2074                    .unwrap_or(false)
2075            });
2076        timing.set_detailed_enabled(timing_enabled);
2077        timing.overall_alg.start();
2078
2079        // Reset the linear-solver summary sink so back-to-back solves
2080        // don't bleed factor counters / extremal pivots into each
2081        // other. Surviving the lock failure with a debug-assert keeps
2082        // a poisoned mutex from sinking a release build that doesn't
2083        // even consume the summary.
2084        match self.linsol_summary_sink.lock() {
2085            Ok(mut guard) => {
2086                *guard = LinearSolverSummary::default();
2087            }
2088            _ => {
2089                debug_assert!(false, "linsol summary sink mutex poisoned");
2090            }
2091        }
2092
2093        // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
2094        // `make_parameter`; pounce additionally implements `relax_bounds`,
2095        // which the adapter also auto-selects as a fallback when
2096        // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
2097        // `IpTNLPAdapter.cpp:623-633`).
2098        let lo_inf = self
2099            .options
2100            .get_numeric_value("nlp_lower_bound_inf", "")
2101            .ok()
2102            .and_then(|(v, f)| f.then_some(v))
2103            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
2104        let up_inf = self
2105            .options
2106            .get_numeric_value("nlp_upper_bound_inf", "")
2107            .ok()
2108            .and_then(|(v, f)| f.then_some(v))
2109            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
2110        let fixed_treatment = match self
2111            .options
2112            .get_string_value("fixed_variable_treatment", "")
2113            .ok()
2114            .and_then(|(v, f)| f.then_some(v))
2115            .as_deref()
2116        {
2117            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
2118            // `make_constraint` / `make_parameter_nodual` not yet
2119            // implemented; fall back to `make_parameter` (auto-retry to
2120            // `relax_bounds` will still kick in if DOF runs short).
2121            _ => FixedVarTreatment::MakeParameter,
2122        };
2123        let adapter = match TNLPAdapter::new_with_options(
2124            Rc::clone(&tnlp),
2125            lo_inf,
2126            up_inf,
2127            fixed_treatment,
2128        ) {
2129            Ok(a) => Rc::new(RefCell::new(a)),
2130            Err(_) => {
2131                timing.overall_alg.end();
2132                return ApplicationReturnStatus::InvalidProblemDefinition;
2133            }
2134        };
2135        // Carry the user's constant `obj_scaling_factor` (default 1.0;
2136        // negative ⇒ maximize) into the NLP. Until pounce#128's
2137        // follow-up this option was registered but never read, so it
2138        // was silently a no-op — maximization diverged because the
2139        // algorithm minimized the unscaled objective.
2140        let obj_scaling_factor = self
2141            .options
2142            .get_numeric_value("obj_scaling_factor", "")
2143            .ok()
2144            .and_then(|(v, f)| f.then_some(v))
2145            .unwrap_or(1.0);
2146        let mut orig_nlp = match OrigIpoptNlp::new(
2147            Rc::clone(&adapter),
2148            Rc::new(ConstObjScaling(obj_scaling_factor)),
2149        ) {
2150            Ok(n) => n,
2151            Err(_) => {
2152                timing.overall_alg.end();
2153                return ApplicationReturnStatus::InternalError;
2154            }
2155        };
2156        orig_nlp.set_timing_stats(Rc::clone(&timing));
2157
2158        // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
2159        // bail out with NotEnoughDegreesOfFreedom when there are fewer free
2160        // variables than equality constraints. Without this gate, square /
2161        // over-determined systems push the algorithm into restoration on
2162        // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
2163        let n_x_var = orig_nlp.x_space().dim();
2164        let n_c = orig_nlp.c_space().dim();
2165        if n_x_var > 0 && n_x_var < n_c {
2166            timing.overall_alg.end();
2167            // An over-determined system can still be *provably* infeasible —
2168            // `x == 0.2` with `x == 0.8` is about as provable as infeasibility
2169            // gets — and for such a model the structural DOF error is the
2170            // strictly weaker answer: it reports "cannot attempt this" for a
2171            // problem whose verdict is already decided (gh#387). The DOF gate
2172            // fires before any iteration runs, so nothing downstream will ever
2173            // get the chance to detect the infeasibility; check for a
2174            // bound-propagation proof here, on the rare failure path only.
2175            // The probe reuses presolve's full certification pipeline
2176            // (crossing margin + witness refutation), so a model the solver
2177            // would accept as feasible at its own tolerance is never upgraded
2178            // to "proved infeasible" — those still report the DOF error.
2179            if self.overdetermined_model_certified_infeasible(&tnlp) {
2180                use pounce_common::journalist::JournalCategory;
2181                self.journalist.print(
2182                    JournalLevel::J_SUMMARY,
2183                    JournalCategory::J_MAIN,
2184                    "\nEXIT: Problem has too few degrees of freedom, and bound \
2185                     propagation proves its constraints inconsistent.\n\
2186                     No feasible point exists; the solve was not run.\n",
2187                );
2188                return ApplicationReturnStatus::InfeasibleProblemDetected;
2189            }
2190            return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
2191        }
2192
2193        // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
2194        // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
2195        // `OrigIpoptNLP::InitializeStructures` lines 343-358.
2196        let bound_relax_factor = self
2197            .options
2198            .get_numeric_value("bound_relax_factor", "")
2199            .ok()
2200            .and_then(|(v, f)| f.then_some(v))
2201            .unwrap_or(1e-8);
2202        let constr_viol_tol = self
2203            .options
2204            .get_numeric_value("constr_viol_tol", "")
2205            .ok()
2206            .and_then(|(v, f)| f.then_some(v))
2207            .unwrap_or(1e-4);
2208        orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
2209
2210        // `honor_original_bounds` (default `no`, matching upstream):
2211        // project the reported point back into the un-relaxed box. Must
2212        // follow `relax_bounds`, which snapshots the bounds to project
2213        // onto. Registered but never read before, so a user asking for
2214        // it still got a bound-pinned solution sitting up to
2215        // `min(bound_relax_factor·max(1,|b|), constr_viol_tol)` outside
2216        // its own bounds (gh#483 follow-up).
2217        let honor_original_bounds = self
2218            .options
2219            .get_bool_value("honor_original_bounds", "")
2220            .ok()
2221            .and_then(|(v, f)| f.then_some(v))
2222            .unwrap_or(false);
2223        orig_nlp.set_honor_original_bounds(honor_original_bounds);
2224
2225        // Apply automatic NLP scaling per `nlp_scaling_method` option
2226        // (port of `OrigIpoptNLP::InitializeStructures` →
2227        // `NLPScalingObject::DetermineScaling`). Default is
2228        // `gradient-based` to match upstream Ipopt 3.14.
2229        let scaling_method = self
2230            .options
2231            .get_string_value("nlp_scaling_method", "")
2232            .ok()
2233            .and_then(|(v, f)| f.then_some(v))
2234            .unwrap_or_else(|| "gradient-based".to_string());
2235        let scaling_method = match scaling_method.as_str() {
2236            "none" => ScalingMethod::None,
2237            "gradient-based" => ScalingMethod::GradientBased,
2238            "user-scaling" => ScalingMethod::UserScaling,
2239            // `equilibration-based` is registered upstream but not yet
2240            // implemented in pounce; fall back to gradient-based (the
2241            // upstream default) to keep behavior predictable.
2242            _ => ScalingMethod::GradientBased,
2243        };
2244        let max_gradient = self
2245            .options
2246            .get_numeric_value("nlp_scaling_max_gradient", "")
2247            .ok()
2248            .and_then(|(v, f)| f.then_some(v))
2249            .unwrap_or(100.0);
2250        let min_value = self
2251            .options
2252            .get_numeric_value("nlp_scaling_min_value", "")
2253            .ok()
2254            .and_then(|(v, f)| f.then_some(v))
2255            .unwrap_or(1e-8);
2256        let obj_target_gradient = self
2257            .options
2258            .get_numeric_value("nlp_scaling_obj_target_gradient", "")
2259            .ok()
2260            .and_then(|(v, f)| f.then_some(v))
2261            .unwrap_or(0.0);
2262        let constr_target_gradient = self
2263            .options
2264            .get_numeric_value("nlp_scaling_constr_target_gradient", "")
2265            .ok()
2266            .and_then(|(v, f)| f.then_some(v))
2267            .unwrap_or(0.0);
2268        orig_nlp.determine_scaling_from_starting_point(
2269            scaling_method,
2270            max_gradient,
2271            min_value,
2272            obj_target_gradient,
2273            constr_target_gradient,
2274        );
2275
2276        let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
2277
2278        // Build the algorithm strategy bundle. Read coarse knobs from
2279        // the OptionsList where we have them; fall through to defaults
2280        // otherwise. The full upstream parsing surface (mu_strategy,
2281        // hessian_approximation, line_search_method, ...) is wired by
2282        // `AlgBuilder::RegisterOptions` in upstream — that registry
2283        // hookup lands as a follow-up; default builder is correct for
2284        // HS71-class problems.
2285        let mut builder = self.algorithm_builder_from_options();
2286
2287        // Linear-solver backend. The default factory is option-aware
2288        // — it reads the `feral_*` extension options off the same
2289        // `OptionsList` that drove the IPM-level builder above so
2290        // per-problem `.opt` files can flip backend knobs without
2291        // rebuilding pounce.
2292        let mut feral_cfg = feral_config_from_options(&self.options);
2293        // Block-triangular / Schur KKT partition (pounce#180 item 2). Configure
2294        // the Schur block solvers from the *base* feral cfg: a full-KKT external
2295        // ordering (item 1) is sized for the whole system and cannot apply to
2296        // the A_FF sub-block, so the Schur path keeps the default sub-block
2297        // ordering. `build_with_backend` honors this only on the IPM + feral +
2298        // exact-Hessian path and falls back to the standard solver otherwise.
2299        if let Some(indices) = &self.kkt_schur_block {
2300            builder.set_kkt_schur(indices.clone(), feral_cfg.clone());
2301        }
2302        // A caller-supplied KKT permutation (pounce#180 item 1) overrides
2303        // the string-option / env ordering: `OrderingMethod::External`
2304        // can't be expressed through the OptionsList (it carries a
2305        // vector), so it is injected here from the side-channel field.
2306        // Only applies to the workspace-default FERAL backend below; a
2307        // custom `linear_backend_factory` owns its own config.
2308        if let Some(perm) = &self.external_ordering {
2309            feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
2310        }
2311        let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
2312            default_backend_factory_with_sink(feral_cfg, Arc::clone(&self.linsol_summary_sink))
2313        });
2314        let bundle = builder.build_with_backend(factory);
2315
2316        // Wire the data / cq pair around the NLP. Install the shared
2317        // `TimingStatistics` so the algorithm's iterate phases
2318        // (output, convergence, hessian, μ, search-direction,
2319        // line-search, accept) all record into the same accumulator
2320        // the application exposes via `timing_stats()`.
2321        let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
2322        data.borrow_mut().timing = Rc::clone(&timing);
2323        // Install a shared wall/CPU-time deadline (pounce#242) so the time
2324        // budget is honored at the granularity of the expensive inner
2325        // steps — the main loop's KKT factorization / line search and the
2326        // restoration inner IPM — instead of only between outer iterations.
2327        // The `Deadline` starts its clock now (right after `overall_alg`),
2328        // and the restoration sub-solve reuses this same instance, so the
2329        // caller's budget bounds the whole solve rather than each nested
2330        // level independently. The convergence check treats it as
2331        // authoritative when present (see `conv_check::opt_error`).
2332        data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(
2333            builder.conv_check.max_wall_time,
2334            builder.conv_check.max_cpu_time,
2335        ));
2336        let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
2337            IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
2338        ));
2339        // Correction size for very small slacks (default mach_eps^{3/4});
2340        // drives the safe-slack bound-adjustment mechanism.
2341        if let Ok((v, true)) = self.options.get_numeric_value("slack_move", "") {
2342            cq.borrow_mut().slack_move = v;
2343        }
2344        // `kappa_d` — weight of the linear damping term added to the
2345        // barrier objective/gradient to handle one-sided bounds
2346        // (`IpIpoptCalculatedQuantities.cpp`). Registered (default 1e-5)
2347        // but previously never read, so a user override was silently
2348        // ignored (#191). Routed through the builder for parity with the
2349        // other numeric knobs; the default matches the registered
2350        // default, so only explicit overrides change behavior.
2351        cq.borrow_mut().kappa_d = builder.kappa_d;
2352
2353        // Seed `data.curr` with a zero-valued iterate of the correct
2354        // dimensions. The `IterateInitializer` consumes these as its
2355        // template (it overwrites `x`, `s`, multipliers in place); we
2356        // just need the dim metadata.
2357        {
2358            let nlp_borrow = nlp_handle.borrow();
2359            let n_x = nlp_borrow.n();
2360            let n_s = nlp_borrow.m_ineq();
2361            let n_yc = nlp_borrow.m_eq();
2362            let n_yd = nlp_borrow.m_ineq();
2363            let n_zl = nlp_borrow.x_l().dim();
2364            let n_zu = nlp_borrow.x_u().dim();
2365            let n_vl = nlp_borrow.d_l().dim();
2366            let n_vu = nlp_borrow.d_u().dim();
2367            drop(nlp_borrow);
2368            let iv = IteratesVector::new(
2369                Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
2370                Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
2371                Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
2372                Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
2373                Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
2374                Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
2375                Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
2376                Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
2377            );
2378            data.borrow_mut().set_curr(iv);
2379        }
2380
2381        // Full primal-dual warm restart (debugger `resolve`): if a
2382        // captured iterate is queued, install it onto `data.curr` over
2383        // the placeholder so the `WarmStartIterateInitializer`'s
2384        // re-optimize branch (x already initialized) keeps it and only
2385        // clamps multipliers / sets target_mu — no cold re-seed from the
2386        // NLP. Skipped (with a warning) if the dimensions don't line up,
2387        // e.g. an option changed the problem structure between solves.
2388        if let Some(snap) = self.warm_start_iterate.take() {
2389            let dims_match = {
2390                let borrow = data.borrow();
2391                borrow
2392                    .curr
2393                    .as_ref()
2394                    .map(|c| iterates_dims(c) == iterates_dims(snap.iterates()))
2395                    .unwrap_or(false)
2396            };
2397            if dims_match {
2398                data.borrow_mut().set_curr(snap.iterates().clone());
2399                data.borrow_mut().curr_mu = snap.mu();
2400            } else {
2401                tracing::warn!(
2402                    target: "pounce::warm_start",
2403                    "debugger warm-restart iterate dimensions differ from the fresh \
2404                     solve; ignoring the captured iterate and seeding normally"
2405                );
2406            }
2407        }
2408
2409        let max_iter = self
2410            .options
2411            .get_integer_value("max_iter", "")
2412            .ok()
2413            .and_then(|(v, f)| f.then_some(v))
2414            .unwrap_or(3000);
2415        let tol = self
2416            .options
2417            .get_numeric_value("tol", "")
2418            .ok()
2419            .and_then(|(v, f)| f.then_some(v))
2420            .unwrap_or(1e-8);
2421        data.borrow_mut().tol = tol;
2422
2423        let mut alg = IpoptAlgorithm::new(data, cq, bundle)
2424            .with_nlp(Rc::clone(&nlp_handle))
2425            .with_tnlp(Rc::clone(&tnlp));
2426        // Mint a fresh restoration factory per inner solve if a
2427        // provider is configured (pounce#10 Phase 3). Falls back to
2428        // the legacy one-shot `restoration_factory` slot when no
2429        // provider is set, preserving single-shot caller behavior.
2430        if let Some(provider) = self.restoration_factory_provider.as_mut() {
2431            self.restoration_factory = Some(provider());
2432        }
2433        if let Some(factory) = self.restoration_factory.as_mut() {
2434            alg = alg.with_restoration(factory());
2435        }
2436        if let Some(diag) = self.diagnostics.as_ref() {
2437            alg = alg.with_diagnostics(Rc::clone(diag));
2438        }
2439        // Move the interactive debugger hook (if any) into the main
2440        // algorithm. Taken — not cloned — so it drives exactly this
2441        // solve; a subsequent solve must reinstall it.
2442        if let Some(hook) = self.debug_hook.take() {
2443            alg = alg.with_debug_hook(hook);
2444        }
2445        alg.max_iter = max_iter;
2446        // `kappa_sigma` — factor bounding how far the bound multipliers
2447        // may deviate from their primal estimates; the clamp runs after
2448        // every accepted step (`IpIpoptAlg.cpp`, Eqn. (16)). Registered
2449        // (default 1e10) but previously never read, so a user override —
2450        // including the documented `< 1` "disable the correction" — was
2451        // silently ignored (#191). Routed through the builder; the struct
2452        // default matches the registered default, so default runs are
2453        // unchanged.
2454        alg.kappa_sigma = builder.kappa_sigma;
2455        // Tiny-step and divergence guards (#191): registered but
2456        // previously never read. Struct defaults match the registered
2457        // defaults, so default runs are unchanged.
2458        alg.tiny_step_tol = builder.tiny_step_tol;
2459        alg.tiny_step_y_tol = builder.tiny_step_y_tol;
2460        alg.diverging_iterates_tol = builder.diverging_iterates_tol;
2461        alg.dual_diverging_streak = builder.dual_diverging_streak.max(0) as usize;
2462        alg.resto_decline_deferrals = builder.resto_decline_deferrals.max(0) as usize;
2463        alg.resto_decline_progress_ratio = builder.resto_decline_progress_ratio;
2464        alg.kkt_fidelity_tol = builder.kkt_fidelity_tol;
2465        // Honor `print_level == 0`: silence the algorithm's direct-to-stdout
2466        // output — the per-iteration table and, new in #206, the
2467        // problem-statistics and end-of-run summary blocks the engine now
2468        // emits itself. Default (unset) or any positive level shows them; the
2469        // CLI's JSON mode forces print_level 0, so structured output stays
2470        // clean. (The Phase-7 journalist surface respects print_level already;
2471        // this is the legacy direct-print site that needs the same gate.)
2472        let console_output = match self.options.get_integer_value("print_level", "") {
2473            Ok((v, true)) => v >= 1,
2474            _ => true,
2475        };
2476        if !console_output {
2477            alg.print_iter_output = false;
2478            // The nested restoration IPM is built inside the restoration
2479            // driver, not by `IpoptAlgorithm::new`, so it never sees this
2480            // gate unless we forward it.
2481            if let Some(resto) = alg.restoration.as_mut() {
2482                resto.set_print_iter_output(false);
2483            }
2484        }
2485
2486        // Problem statistics, Ipopt-style, emitted before the iteration table
2487        // from the engine's own reduced problem (#206). Built from the same
2488        // collect_stats inputs the CLI used, so the block is byte-identical;
2489        // emitting it here means every frontend (CLI, Python, C) and every
2490        // algorithm (IPM, SQP) gets it.
2491        self.emit_problem_stats(&tnlp, console_output);
2492
2493        // Per-iteration history (pounce#71): when requested, capture the
2494        // `pounce::iteration` events emitted during the solve into an
2495        // `IterRecord` trajectory via the observability collector layer.
2496        // This replaces the old in-loop `iter_history` accumulation; it
2497        // requires the collector to be installed in the active
2498        // subscriber (the CLI / Python / C frontends install it via
2499        // `pounce_observability::init_subscriber`; tests call
2500        // `init_for_tests`). The collector scopes out restoration
2501        // sub-solve iterations via the `restoration` span, so the
2502        // trajectory matches the previous behavior (outer iters only).
2503        let iter_capture = self
2504            .record_iter_history
2505            .then(pounce_observability::IterCaptureGuard::start);
2506
2507        let solver_status = alg.optimize();
2508
2509        let captured_iters = iter_capture.map(|g| g.finish()).unwrap_or_default();
2510        // Propagate to any enclosing capture (e.g. `with_iter_capture`
2511        // wrapped around a solve with iteration history enabled), whose
2512        // buffer this inner guard would otherwise leave empty.
2513        pounce_observability::extend_active_capture(&captured_iters);
2514        // Close the overall-algorithm timer on the success path. The
2515        // early-return arms above end it themselves before bailing out;
2516        // this one matches upstream `IpoptApplication::call_optimize`
2517        // (which calls `EndCpuTime()` on overall_alg right after
2518        // `Optimize` returns, regardless of solver_status).
2519        timing.overall_alg.end();
2520
2521        // Drain counters / iter count off the algorithm.
2522        {
2523            let mut stats = self.statistics.borrow_mut();
2524            {
2525                let d = alg.data.borrow();
2526                stats.iteration_count = d.iter_count;
2527                // Converged barrier parameter μ — threaded forward into a
2528                // warm-started corrector's `mu_init` / `warm_start_target_mu`
2529                // for predictor–corrector path following (pounce#86).
2530                stats.final_mu = d.curr_mu;
2531            }
2532            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
2533            // Restoration-phase audit counters (pounce#12). Zero on
2534            // problems where restoration never fires; populated by
2535            // `IpoptAlgorithm::invoke_restoration`.
2536            stats.restoration_calls = alg.resto_calls;
2537            stats.restoration_inner_iters = alg.resto_inner_iters;
2538            stats.restoration_outer_iters = alg.resto_outer_iters;
2539            stats.restoration_wall_secs = alg.resto_wall_secs;
2540            stats.iterations = captured_iters;
2541            // A refused starting point does not produce a valid iterate.
2542            // Leave final objective/residual fields at their NaN defaults.
2543            // Capture the final *scaled* objective at the algorithm's
2544            // (compressed `x_var`-space) iterate via the NLP: the
2545            // algorithm-side `eval_f` returns `f * obj_scale_factor`.
2546            // `final_objective` is seeded with it only as a best-effort
2547            // fallback; the success path below overwrites it with the
2548            // true unscaled objective from `finalize_via_orig_nlp`
2549            // (which evaluates the user TNLP directly).
2550            if solver_status != SolverReturn::InvalidProblemDefinition {
2551                let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
2552                if let Some(x) = curr_x {
2553                    if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
2554                        stats.final_objective = f;
2555                        stats.final_scaled_objective = f;
2556                    }
2557                }
2558                // Final residuals straight off the cq cache. These mirror
2559                // the values upstream prints in its end-of-run summary
2560                // ("Dual infeasibility / Constraint violation /
2561                // Complementarity / Overall NLP error").
2562                let cq = alg.cq.borrow();
2563                stats.final_dual_inf = cq.curr_dual_infeasibility_max();
2564                // Stays on the *internal* measure deliberately: the summary's
2565                // "Overall NLP error" is `curr_nlp_error`, and it is built from
2566                // this same `max(||c||, ||d - s||)`. Switching the violation line
2567                // alone to the original-NLP measure
2568                // (`curr_unscaled_nlp_constraint_violation_max`, now used by the
2569                // `inf_pr` column) would leave the block self-inconsistent —
2570                // an error larger than the max of its own components. Making
2571                // them agree means deciding whether *convergence* should be
2572                // judged on the original NLP, which is a behaviour change for
2573                // every model, not a reporting fix. See pounce#476.
2574                //
2575                // NOTE (gh #528): "Overall NLP error" is no longer the number
2576                // the strict gate tests. That gate judges
2577                // `curr_nlp_error_above_primal_noise` — the same aggregate with
2578                // each row's residual counted only above what it can represent
2579                // in floating point — so on a model whose constraint values run
2580                // to `~1e8` the summary can report an error above `tol` beside
2581                // `EXIT: Optimal Solution Found`. The gap is exactly the part
2582                // of the residual that is quantisation noise, and it is bounded
2583                // by `constr_viol_tol`, which is still tested here on the full
2584                // unfloored residual. Reporting is deliberately left on the raw
2585                // value: it is the honest measurement, and at these magnitudes
2586                // the default `bound_relax_factor = 1e-8` has already moved
2587                // every bound by orders of magnitude more than the floor
2588                // forgives, so the raw number was never an exact statement
2589                // about the original NLP either.
2590                stats.final_constr_viol = cq.curr_primal_infeasibility_max();
2591                // Infinity-norm complementarity, max over all four bound
2592                // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
2593                // empty-bound blocks return `0` from amax(), so the max is
2594                // safe even when only one side has bounds.
2595                let compl = cq
2596                    .curr_compl_x_l()
2597                    .amax()
2598                    .max(cq.curr_compl_x_u().amax())
2599                    .max(cq.curr_compl_s_l().amax())
2600                    .max(cq.curr_compl_s_u().amax());
2601                stats.final_compl = compl;
2602                stats.final_kkt_error = cq.curr_nlp_error();
2603                // The aggregate the strict gate tested (gh #528). Reported
2604                // alongside the raw one so a summary can account for the gap
2605                // between them; equal to it on every `O(1)` model, and on any
2606                // run with `primal_noise_floor_kappa = 0`.
2607                stats.final_kkt_error_above_noise = cq
2608                    .curr_nlp_error_above_primal_noise(builder.conv_check.primal_noise_floor_kappa);
2609                // Unscaled (user-space) counterparts — divide the nlp_scaling
2610                // back out so a consumer can verify the certificate in its own
2611                // units (pounce#173). Identical to the scaled fields when no
2612                // scaling is active.
2613                stats.final_unscaled_dual_inf = cq.curr_unscaled_dual_infeasibility_max();
2614                stats.final_unscaled_constr_viol = cq.curr_unscaled_primal_infeasibility_max();
2615                stats.final_unscaled_compl = cq.curr_unscaled_complementarity_max();
2616                stats.final_unscaled_kkt_error = cq.curr_unscaled_nlp_error();
2617            }
2618        }
2619
2620        // Never report `Infeasible_Problem_Detected` while holding a point that
2621        // satisfies every constraint. The gates that produce this verdict argue
2622        // from a stalled feasibility sub-problem, and gh #379 is what that looks
2623        // like when the argument is wrong — a model whose own starting point is
2624        // exactly feasible, reported infeasible. See
2625        // `withdraw_infeasibility_if_refuted`.
2626        let solver_status =
2627            withdraw_infeasibility_if_refuted(&tnlp, solver_status, lo_inf, up_inf, tol);
2628
2629        // Map SolverReturn → ApplicationReturnStatus per
2630        // MAIN_LOOP.md's exception table, then apply the opt-in
2631        // status-fidelity gate (pounce#173).
2632        let app_status = self.apply_kkt_fidelity_gate(solver_return_to_app_status(solver_status));
2633
2634        // On convergence, fire the user-supplied callback (post-optimal
2635        // sensitivity hook, pounce#16) before flowing back through
2636        // `finalize_via_orig_nlp`. Borrowed handles into the converged
2637        // KKT state stay alive for the duration of the closure.
2638        if matches!(
2639            app_status,
2640            ApplicationReturnStatus::SolveSucceeded
2641                | ApplicationReturnStatus::SolvedToAcceptableLevel
2642        ) {
2643            if let Some(cb) = self.on_converged.as_mut() {
2644                if let Some(sd) = alg.search_dir.as_mut() {
2645                    let pd = sd.pd_solver_rc();
2646                    cb(&alg.data, &alg.cq, &nlp_handle, pd);
2647                }
2648            }
2649        }
2650
2651        // Finalize: forward the final iterate to the user's TNLP. The
2652        // returned objective is evaluated on the *user* TNLP at the
2653        // unscaled iterate, so it overrides the scaled best-effort
2654        // value stashed in `final_objective` above (the algorithm-side
2655        // `eval_f` returns `f * obj_scale_factor`).
2656        if solver_status != SolverReturn::InvalidProblemDefinition {
2657            match finalize_via_orig_nlp(&nlp_handle, &alg, solver_status, app_status, &tnlp) {
2658                Ok(f_unscaled) => {
2659                    self.statistics.borrow_mut().final_objective = f_unscaled;
2660                }
2661                Err(()) => {}
2662            }
2663        }
2664
2665        // End-of-solve timing report. Gated on `print_timing_statistics`
2666        // (default "no"); mirrors upstream's
2667        // `IpoptApplication::call_optimize` →
2668        // `IpTimingStatistics::PrintAllValues` call site. The report
2669        // goes to stdout (for parity with the banner / iter-row output
2670        // path) and is also fanned out to the journalist so an
2671        // `output_file` attached via `Initialize` picks it up.
2672        let print_timing = self
2673            .options
2674            .get_bool_value("print_timing_statistics", "")
2675            .ok()
2676            .and_then(|(v, f)| f.then_some(v))
2677            .unwrap_or(false);
2678        if print_timing {
2679            let report = timing.report();
2680            print!("{}", report);
2681            use pounce_common::journalist::{JournalCategory, JournalLevel};
2682            self.journalist.print(
2683                JournalLevel::J_SUMMARY,
2684                JournalCategory::J_TIMING_STATISTICS,
2685                &report,
2686            );
2687        }
2688
2689        // End-of-run summary, Ipopt-style, emitted last (after any timing
2690        // report) from the engine's own statistics (#206). Drains the eval
2691        // tallies into SolveStatistics (read AFTER finalize so the final
2692        // solution evaluation is included) and prints the summary, gated on
2693        // the same print_level as the rest of the console.
2694        self.emit_end_summary(app_status, &nlp_handle, console_output);
2695
2696        app_status
2697    }
2698
2699    /// Build an [`AlgorithmBuilder`] populated from the app's
2700    /// [`OptionsList`]. Public so callers wiring the restoration
2701    /// factory can hand the *inner* IPM a builder that mirrors the
2702    /// outer's `mu_strategy`/`mu_oracle`/line-search choices —
2703    /// matching upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`,
2704    /// which reads the same `mu_strategy` option with prefix `"resto."
2705    /// + prefix` and falls back to the outer setting.
2706    pub fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
2707        let mut builder = AlgorithmBuilder::new();
2708
2709        // `mehrotra_algorithm` is parsed first so its cascading
2710        // defaults (mu_strategy=adaptive, mu_oracle=probing) can be
2711        // overridden by an explicit user setting of those keys
2712        // below. Mirrors `IpAlgBuilder.cpp:Mehrotra`.
2713        // `fast_step_computation` — skip the search-direction residual
2714        // check and allow an inexact linear solve. `PdSearchDirCalc` has
2715        // consumed this flag since it landed, hard-coded to `false`; the
2716        // option's read site was simply missing, so setting it did
2717        // nothing at all (gh#483 follow-up, #191 round 2).
2718        if let Ok((v, true)) = self.options.get_string_value("fast_step_computation", "") {
2719            builder.fast_step_computation = v.eq_ignore_ascii_case("yes");
2720        }
2721
2722        let mut mehrotra_on = false;
2723        if let Ok((v, found)) = self.options.get_string_value("mehrotra_algorithm", "") {
2724            if found && v == "yes" {
2725                mehrotra_on = true;
2726                builder.mehrotra_algorithm = true;
2727                builder.mu_strategy = MuStrategyChoice::Adaptive;
2728                builder.mu_oracle = crate::mu::adaptive::MuOracleKind::Probing;
2729                // `accept_every_trial_step` short-circuits the alpha
2730                // loop / filter — Mehrotra steps would otherwise be
2731                // rejected by the filter on LP-shaped problems because
2732                // the barrier objective is non-monotone along the
2733                // corrector. Mirrors upstream `IpAlgBuilder.cpp:Mehrotra`.
2734                builder.line_search.accept_every_trial_step = true;
2735                // Aggressive iterate-push defaults (`SetNumericValueIfUnset`
2736                // in upstream). The explicit user parses below will
2737                // overwrite these if the user set them explicitly.
2738                builder.init.bound_push = 10.0;
2739                builder.init.bound_frac = 0.2;
2740                builder.init.slack_bound_push = 10.0;
2741                builder.init.slack_bound_frac = 0.2;
2742                builder.init.bound_mult_init_val = 10.0;
2743                builder.init.constr_mult_init_max = 0.0;
2744                // `alpha_for_y=bound_mult` — Mehrotra wants the
2745                // equality multipliers to advance with the dual
2746                // alpha so they stay in step with z/v. Mirrors
2747                // upstream `IpIpoptAlg.cpp:InitializeImpl`.
2748                builder.line_search.alpha_for_y =
2749                    crate::line_search::backtracking::AlphaForY::BoundMult;
2750                // `adaptive_mu_globalization=never-monotone-mode` —
2751                // upstream `IpIpoptAlg.cpp:148-154` enforces this:
2752                // Mehrotra disables the globalization switch entirely
2753                // (no fallback to monotone mode when convergence
2754                // stalls). Required for the unsafeguarded Mehrotra
2755                // path to function.
2756                builder.mu.adaptive_mu_globalization =
2757                    crate::mu::adaptive::AdaptiveMuGlobalization::NeverMonotoneMode;
2758                // `least_square_init_primal=yes` — upstream
2759                // `IpIpoptAlg.cpp:182` enables this for the Mehrotra
2760                // cascade. Replaces the user's starting `x` with the
2761                // min-norm primal that satisfies the linearized
2762                // equality+inequality constraints. Critical on
2763                // LP-shaped problems where the user's starting point
2764                // can be wildly infeasible (e.g. nuffield2_trap).
2765                builder.init.least_square_init_primal = true;
2766            }
2767        }
2768
2769        if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
2770            if found {
2771                let parsed = match v.as_str() {
2772                    "adaptive" => MuStrategyChoice::Adaptive,
2773                    _ => MuStrategyChoice::Monotone,
2774                };
2775                if mehrotra_on && matches!(parsed, MuStrategyChoice::Monotone) {
2776                    // Upstream Ipopt refuses this combination: Mehrotra
2777                    // needs an affine step every iter, which only the
2778                    // adaptive path computes. Keep adaptive and warn.
2779                    tracing::warn!(target: "pounce::algorithm",
2780                        "pounce: mehrotra_algorithm=yes requires \
2781                         mu_strategy=adaptive; ignoring \
2782                         mu_strategy=monotone."
2783                    );
2784                } else {
2785                    builder.mu_strategy = parsed;
2786                }
2787            }
2788        }
2789        if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
2790            if found {
2791                builder.mu_oracle = match v.as_str() {
2792                    "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
2793                    "probing" => crate::mu::adaptive::MuOracleKind::Probing,
2794                    _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
2795                };
2796            }
2797        }
2798        if let Ok((v, found)) = self
2799            .options
2800            .get_string_value("adaptive_mu_globalization", "")
2801        {
2802            if found {
2803                use crate::mu::adaptive::AdaptiveMuGlobalization;
2804                builder.mu.adaptive_mu_globalization = match v.as_str() {
2805                    "kkt-error" => AdaptiveMuGlobalization::KktError,
2806                    "never-monotone-mode" => AdaptiveMuGlobalization::NeverMonotoneMode,
2807                    _ => AdaptiveMuGlobalization::ObjConstrFilter,
2808                };
2809            }
2810        }
2811        if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
2812            if found {
2813                builder.hessian_approximation = match v.as_str() {
2814                    "limited-memory" => HessianApproxChoice::LimitedMemory,
2815                    _ => HessianApproxChoice::Exact,
2816                };
2817            }
2818        }
2819        // Limited-memory quasi-Newton update formula. Registered upstream
2820        // (`limited_memory_update_type`, IpLimMemQuasiNewtonUpdater.cpp) but
2821        // until now read nowhere on the IPM path — the updater was hard-wired
2822        // to Powell-damped BFGS. SR1 is honored too (the updater and the
2823        // low-rank/inertia path already handle its indefinite models).
2824        if let Ok((v, found)) = self
2825            .options
2826            .get_string_value("limited_memory_update_type", "")
2827        {
2828            if found {
2829                builder.limited_memory_update_type = match v.as_str() {
2830                    "sr1" => UpdateType::Sr1,
2831                    _ => UpdateType::Bfgs,
2832                };
2833            }
2834        }
2835        // Limited-memory history length (`limited_memory_max_history`).
2836        if let Ok((v, found)) = self
2837            .options
2838            .get_integer_value("limited_memory_max_history", "")
2839        {
2840            if found && v >= 0 {
2841                builder.limited_memory_max_history = v as Index;
2842            }
2843        }
2844        if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
2845            if found {
2846                builder.line_search_method = match v.as_str() {
2847                    "cg-penalty" => LineSearchChoice::CgPenalty,
2848                    "penalty" => LineSearchChoice::Penalty,
2849                    _ => LineSearchChoice::Filter,
2850                };
2851            }
2852        }
2853        // `accept_every_trial_step` — direct user override. Parsed
2854        // after the Mehrotra cascade so an explicit `no` still wins.
2855        if let Ok((v, found)) = self.options.get_string_value("accept_every_trial_step", "") {
2856            if found {
2857                builder.line_search.accept_every_trial_step = v == "yes";
2858            }
2859        }
2860        // `alpha_for_y` — direct user override. Parsed after the
2861        // Mehrotra cascade so an explicit value still wins.
2862        if let Ok((v, found)) = self.options.get_string_value("alpha_for_y", "") {
2863            if found {
2864                use crate::line_search::backtracking::AlphaForY;
2865                builder.line_search.alpha_for_y = match v.as_str() {
2866                    "primal" => AlphaForY::Primal,
2867                    "bound-mult" | "bound_mult" => AlphaForY::BoundMult,
2868                    "full" => AlphaForY::Full,
2869                    "min" => AlphaForY::Min,
2870                    "max" => AlphaForY::Max,
2871                    "primal-and-full" | "dual-and-full" => AlphaForY::Primal,
2872                    _ => AlphaForY::Primal,
2873                };
2874            }
2875        }
2876        // `nlp_scaling_method` is consumed NLP-side in
2877        // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
2878        // `determine_scaling_from_starting_point` call earlier in this
2879        // method); there is no algorithm-side scaling strategy to wire.
2880        // `limited_memory_init_val_max` / `_min` — the clamp on the
2881        // initial Hessian scalar. `LimMemQuasiNewtonUpdater` consumes
2882        // both in `initial_hessian_scalar`; only the read sites were
2883        // missing, so setting either did nothing (gh#483, #191 round 2).
2884        if let Ok((v, true)) = self
2885            .options
2886            .get_numeric_value("limited_memory_init_val_max", "")
2887        {
2888            builder.limited_memory_init_val_max = v;
2889        }
2890        if let Ok((v, true)) = self
2891            .options
2892            .get_numeric_value("limited_memory_init_val_min", "")
2893        {
2894            builder.limited_memory_init_val_min = v;
2895        }
2896
2897        // Unlike the other options here, we always honor the registry
2898        // value (not just when the user set it explicitly): the option
2899        // registry default is "ma57" but `AlgorithmBuilder::default`
2900        // has `linear_solver: Feral`, so gating on `found` would
2901        // silently route default runs through Feral while the banner
2902        // (and ipopt-compatible behavior) advertises MA57.
2903        //
2904        // Record the **effective** backend, not the requested one. MA57 lives
2905        // behind the optional `ma57` cargo feature (HSL is licensed and needs a
2906        // Fortran toolchain); without it `default_backend_factory` silently
2907        // substitutes FERAL. Storing `Ma57` here therefore made
2908        // `builder.linear_solver` disagree with the backend actually built, and
2909        // consumers acted on the lie: the Schur KKT gate in
2910        // `alg_builder::build_with_backend` tests `== Feral`, so on the
2911        // pure-Rust default build — where the registry default (then upstream's
2912        // "ma57") resolved to FERAL anyway — `set_kkt_schur_block()` silently
2913        // never engaged for ANY user. Resolving here keeps the field truthful
2914        // for every consumer.
2915        //
2916        // The `_ =>` arm is now only reachable for `feral`: every other name
2917        // is refused up front by `unimplemented_linear_solver`. It used to
2918        // swallow `mumps`, `pardiso`, `ma97`, … and run FERAL instead.
2919        if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
2920            let requested = if v.eq_ignore_ascii_case("ma57") {
2921                LinearSolverChoice::Ma57
2922            } else {
2923                LinearSolverChoice::Feral
2924            };
2925            builder.linear_solver =
2926                if matches!(requested, LinearSolverChoice::Ma57) && !cfg!(feature = "ma57") {
2927                    LinearSolverChoice::Feral
2928                } else {
2929                    requested
2930                };
2931        }
2932
2933        // `linear_system_scaling` — symmetric scaling of the augmented
2934        // KKT matrix before factorization. Port of
2935        // `IpTSymLinearSolver.cpp:RegisterOptions` plumbing. Default
2936        // "none"; "ruiz" invokes the Ruiz-2001 symmetric ∞-norm
2937        // equilibration in `RuizTSymScalingMethod`. "mc19" and
2938        // "slack-based" are accepted by the registry but not yet
2939        // implemented at this layer; they fall back to no scaling
2940        // with a one-line stderr notice.
2941        if let Ok((v, found)) = self.options.get_string_value("linear_system_scaling", "") {
2942            if found {
2943                builder.linear_system_scaling = match v.as_str() {
2944                    "ruiz" => crate::alg_builder::LinearSystemScalingChoice::Ruiz,
2945                    "mc19" => crate::alg_builder::LinearSystemScalingChoice::Mc19,
2946                    _ => crate::alg_builder::LinearSystemScalingChoice::None,
2947                };
2948            }
2949        }
2950        if let Ok((v, found)) = self.options.get_bool_value("linear_scaling_on_demand", "") {
2951            if found {
2952                builder.linear_scaling_on_demand = v;
2953            }
2954        }
2955
2956        // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
2957        // `RegisterOptions` consumers). Defaults already match upstream
2958        // — only override when the user set the key explicitly.
2959        let read_num = |key: &str| -> Option<f64> {
2960            self.options
2961                .get_numeric_value(key, "")
2962                .ok()
2963                .and_then(|(v, f)| f.then_some(v))
2964        };
2965        let read_int = |key: &str| -> Option<i32> {
2966            self.options
2967                .get_integer_value(key, "")
2968                .ok()
2969                .and_then(|(v, f)| f.then_some(v))
2970        };
2971        if let Some(v) = read_num("tol") {
2972            builder.conv_check.tol = v;
2973        }
2974        if let Some(v) = read_num("obj_scale_certificate_threshold") {
2975            builder.conv_check.obj_scale_certificate_threshold = v;
2976        }
2977        if let Some(v) = read_num("primal_noise_floor_kappa") {
2978            builder.conv_check.primal_noise_floor_kappa = v;
2979        }
2980        if let Some(v) = read_num("acceptable_progress_kappa") {
2981            builder.conv_check.acceptable_progress_kappa = v;
2982        }
2983        if let Some(v) = read_num("dual_inf_scale_kappa") {
2984            builder.conv_check.dual_inf_scale_kappa = v;
2985        }
2986        if let Some(v) = read_num("kkt_fidelity_tol") {
2987            builder.kkt_fidelity_tol = v;
2988        }
2989        if let Some(v) = read_num("dual_inf_tol") {
2990            builder.conv_check.dual_inf_tol = v;
2991        }
2992        if let Some(v) = read_num("constr_viol_tol") {
2993            builder.conv_check.constr_viol_tol = v;
2994        }
2995        if let Some(v) = read_num("compl_inf_tol") {
2996            builder.conv_check.compl_inf_tol = v;
2997        }
2998        if let Some(v) = read_int("max_iter") {
2999            builder.conv_check.max_iter = v;
3000        }
3001        if let Some(v) = read_num("max_cpu_time") {
3002            builder.conv_check.max_cpu_time = v;
3003        }
3004        if let Some(v) = read_num("max_wall_time") {
3005            builder.conv_check.max_wall_time = v;
3006        }
3007        if let Some(v) = read_num("acceptable_tol") {
3008            builder.conv_check.acceptable_tol = v;
3009        }
3010        if let Some(v) = read_num("acceptable_dual_inf_tol") {
3011            builder.conv_check.acceptable_dual_inf_tol = v;
3012        }
3013        if let Some(v) = read_num("acceptable_constr_viol_tol") {
3014            builder.conv_check.acceptable_constr_viol_tol = v;
3015        }
3016        if let Some(v) = read_num("acceptable_compl_inf_tol") {
3017            builder.conv_check.acceptable_compl_inf_tol = v;
3018        }
3019        if let Some(v) = read_num("acceptable_obj_change_tol") {
3020            builder.conv_check.acceptable_obj_change_tol = v;
3021        }
3022        if let Some(v) = read_int("acceptable_iter") {
3023            builder.conv_check.acceptable_iter = v;
3024        }
3025        if let Some(v) = read_num("infeas_stationarity_tol") {
3026            builder.conv_check.infeas_stationarity_tol = v;
3027        }
3028        if let Some(v) = read_num("infeas_viol_kappa") {
3029            builder.conv_check.infeas_viol_kappa = v;
3030        }
3031        if let Some(v) = read_int("infeas_max_streak") {
3032            builder.conv_check.infeas_max_streak = v;
3033        }
3034
3035        // Bound-multiplier / barrier damping constants (#191). Both were
3036        // registered but never read, so user overrides were silently
3037        // dropped; the algorithm ran with the hard-coded struct defaults.
3038        // Defaults equal the registered defaults, so this changes nothing
3039        // for a run that doesn't set them.
3040        if let Some(v) = read_num("kappa_sigma") {
3041            builder.kappa_sigma = v;
3042        }
3043        if let Some(v) = read_num("kappa_d") {
3044            builder.kappa_d = v;
3045        }
3046        if let Some(v) = read_num("tiny_step_tol") {
3047            builder.tiny_step_tol = v;
3048        }
3049        if let Some(v) = read_num("tiny_step_y_tol") {
3050            builder.tiny_step_y_tol = v;
3051        }
3052        if let Some(v) = read_num("diverging_iterates_tol") {
3053            builder.diverging_iterates_tol = v;
3054        }
3055        if let Some(v) = read_int("dual_diverging_streak") {
3056            builder.dual_diverging_streak = v;
3057        }
3058        if let Some(v) = read_int("resto_decline_deferrals") {
3059            builder.resto_decline_deferrals = v;
3060        }
3061        if let Some(v) = read_num("resto_decline_progress_ratio") {
3062            builder.resto_decline_progress_ratio = v;
3063        }
3064
3065        // Barrier-parameter (μ) options — consumers in
3066        // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
3067        // updaters share the same option names; the builder forwards
3068        // each into whichever strategy is assembled.
3069        if let Some(v) = read_num("mu_init") {
3070            builder.mu.mu_init = v;
3071        }
3072        if let Some(v) = read_num("mu_max") {
3073            builder.mu.mu_max = v;
3074        }
3075        if let Some(v) = read_num("mu_max_fact") {
3076            builder.mu.mu_max_fact = v;
3077        }
3078        if let Some(v) = read_num("mu_min") {
3079            builder.mu.mu_min = v;
3080        }
3081        if let Some(v) = read_num("mu_target") {
3082            builder.mu.mu_target = v;
3083        }
3084        if let Some(v) = read_num("mu_linear_decrease_factor") {
3085            builder.mu.mu_linear_decrease_factor = v;
3086        }
3087        if let Some(v) = read_num("mu_superlinear_decrease_power") {
3088            builder.mu.mu_superlinear_decrease_power = v;
3089        }
3090        if let Ok((v, found)) = self
3091            .options
3092            .get_string_value("mu_allow_fast_monotone_decrease", "")
3093        {
3094            if found {
3095                builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
3096            }
3097        }
3098        if let Some(v) = read_num("barrier_tol_factor") {
3099            builder.mu.barrier_tol_factor = v;
3100        }
3101        if let Some(v) = read_num("sigma_max") {
3102            builder.mu.sigma_max = v;
3103        }
3104        if let Some(v) = read_num("sigma_min") {
3105            builder.mu.sigma_min = v;
3106        }
3107
3108        // Quality-function oracle knobs — consumers in
3109        // `IpQualityFunctionMuOracle.cpp:RegisterOptions`. Forwarded
3110        // to the oracle on every free-mode call.
3111        if let Ok((v, found)) = self
3112            .options
3113            .get_string_value("quality_function_norm_type", "")
3114        {
3115            if found {
3116                use crate::mu::oracle::quality_function::NormType;
3117                builder.mu.quality_function_norm_type = match v.as_str() {
3118                    "1-norm" => NormType::OneNorm,
3119                    "2-norm" => NormType::TwoNorm,
3120                    "max-norm" => NormType::MaxNorm,
3121                    _ => NormType::TwoNormSquared,
3122                };
3123            }
3124        }
3125        if let Ok((v, found)) = self
3126            .options
3127            .get_string_value("quality_function_centrality", "")
3128        {
3129            if found {
3130                use crate::mu::oracle::quality_function::CentralityType;
3131                builder.mu.quality_function_centrality = match v.as_str() {
3132                    "log" => CentralityType::LogCenter,
3133                    "reciprocal" => CentralityType::ReciprocalCenter,
3134                    "cubed-reciprocal" => CentralityType::CubedReciprocalCenter,
3135                    _ => CentralityType::None,
3136                };
3137            }
3138        }
3139        if let Ok((v, found)) = self
3140            .options
3141            .get_string_value("quality_function_balancing_term", "")
3142        {
3143            if found {
3144                use crate::mu::oracle::quality_function::BalancingTermType;
3145                builder.mu.quality_function_balancing_term = match v.as_str() {
3146                    "cubic" => BalancingTermType::CubicTerm,
3147                    _ => BalancingTermType::None,
3148                };
3149            }
3150        }
3151        if let Some(v) = read_int("quality_function_max_section_steps") {
3152            builder.mu.quality_function_max_section_steps = v;
3153        }
3154        if let Some(v) = read_num("quality_function_section_sigma_tol") {
3155            builder.mu.quality_function_section_sigma_tol = v;
3156        }
3157        if let Some(v) = read_num("quality_function_section_qf_tol") {
3158            builder.mu.quality_function_section_qf_tol = v;
3159        }
3160
3161        // `probing_iterate_quality_factor` — pounce-specific guard
3162        // (pounce#58) on the probing μ-oracle's input iterate. When
3163        // `curr_avrg_compl / curr_mu` exceeds this factor, the
3164        // μ-update layer signals restoration via
3165        // `IpoptData::request_resto` instead of letting probing
3166        // return `σ · mu_curr` ≫ previous μ. Default 1e4; set to ≤ 0
3167        // to disable. No upstream Ipopt counterpart.
3168        if let Some(v) = read_num("probing_iterate_quality_factor") {
3169            builder.mu.probing_iterate_quality_factor = v;
3170        }
3171
3172        // Adaptive-μ extras — consumers in
3173        // `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Only active when
3174        // `mu_strategy=adaptive`.
3175        if let Some(v) = read_num("adaptive_mu_safeguard_factor") {
3176            builder.mu.adaptive_mu_safeguard_factor = v;
3177        }
3178        if let Some(v) = read_num("adaptive_mu_monotone_init_factor") {
3179            builder.mu.adaptive_mu_monotone_init_factor = v;
3180        }
3181        if let Ok((v, found)) = self
3182            .options
3183            .get_bool_value("adaptive_mu_restore_previous_iterate", "")
3184        {
3185            if found {
3186                builder.mu.adaptive_mu_restore_previous_iterate = v;
3187            }
3188        }
3189        if let Some(v) = read_int("adaptive_mu_kkterror_red_iters") {
3190            if v >= 0 {
3191                builder.mu.adaptive_mu_kkterror_red_iters = v as usize;
3192            }
3193        }
3194        if let Some(v) = read_num("adaptive_mu_kkterror_red_fact") {
3195            builder.mu.adaptive_mu_kkterror_red_fact = v;
3196        }
3197        if let Ok((v, found)) = self
3198            .options
3199            .get_string_value("adaptive_mu_kkt_norm_type", "")
3200        {
3201            if found {
3202                use crate::mu::adaptive::AdaptiveMuKktNorm;
3203                builder.mu.adaptive_mu_kkt_norm_type = match v.as_str() {
3204                    "1-norm" => AdaptiveMuKktNorm::OneNorm,
3205                    "2-norm" => AdaptiveMuKktNorm::TwoNorm,
3206                    "max-norm" => AdaptiveMuKktNorm::MaxNorm,
3207                    _ => AdaptiveMuKktNorm::TwoNormSquared,
3208                };
3209            }
3210        }
3211
3212        // Watchdog options — consumers in
3213        // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
3214        // the `BacktrackingLineSearch` at build time.
3215        if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
3216            builder.line_search.watchdog_shortened_iter_trigger = v;
3217        }
3218        if let Some(v) = read_int("watchdog_trial_iter_max") {
3219            builder.line_search.watchdog_trial_iter_max = v;
3220        }
3221        if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
3222            builder.line_search.soft_resto_pderror_reduction_factor = v;
3223        }
3224        if let Some(v) = read_int("max_soft_resto_iters") {
3225            builder.line_search.max_soft_resto_iters = v;
3226        }
3227
3228        // Filter switching / Armijo / margin constants (#191). Consumed
3229        // by `FilterLsAcceptor` (only on the `Filter` line-search path);
3230        // registered but never read, so overrides were silently dropped.
3231        // Defaults equal the registered defaults.
3232        if let Some(v) = read_num("eta_phi") {
3233            builder.line_search.eta_phi = v;
3234        }
3235        if let Some(v) = read_num("theta_min_fact") {
3236            builder.line_search.theta_min_fact = v;
3237        }
3238        if let Some(v) = read_num("theta_max_row_scale_kappa") {
3239            builder.line_search.theta_max_row_scale_kappa = v;
3240        }
3241        if let Some(v) = read_int("theta_max_adaptive_trigger") {
3242            builder.line_search.theta_max_adaptive_trigger = v.max(0) as u32;
3243        }
3244        if let Some(v) = read_num("theta_max_adaptive_factor") {
3245            builder.line_search.theta_max_adaptive_factor = v;
3246        }
3247        if let Some(v) = read_int("theta_max_adaptive_max_raises") {
3248            builder.line_search.theta_max_adaptive_max_raises = v.max(0) as u32;
3249        }
3250        if let Some(v) = read_num("theta_max_fact") {
3251            builder.line_search.theta_max_fact = v;
3252        }
3253        if let Some(v) = read_num("gamma_phi") {
3254            builder.line_search.gamma_phi = v;
3255        }
3256        if let Some(v) = read_num("gamma_theta") {
3257            builder.line_search.gamma_theta = v;
3258        }
3259        if let Some(v) = read_num("s_phi") {
3260            builder.line_search.s_phi = v;
3261        }
3262        if let Some(v) = read_num("s_theta") {
3263            builder.line_search.s_theta = v;
3264        }
3265        if let Some(v) = read_num("alpha_min_frac") {
3266            builder.line_search.alpha_min_frac = v;
3267        }
3268        if let Some(v) = read_num("obj_max_inc") {
3269            builder.line_search.obj_max_inc = v;
3270        }
3271        if let Some(v) = read_int("max_filter_resets") {
3272            builder.line_search.max_filter_resets = v;
3273        }
3274        if let Some(v) = read_int("filter_reset_trigger") {
3275            builder.line_search.filter_reset_trigger = v;
3276        }
3277        // Second-order-correction constants (#191), consumed by
3278        // `BacktrackingLineSearch`. `max_soc = 0` disables SOC.
3279        if let Some(v) = read_int("max_soc") {
3280            builder.line_search.max_soc = v;
3281        }
3282        if let Some(v) = read_num("kappa_soc") {
3283            builder.line_search.kappa_soc = v;
3284        }
3285        if let Some(v) = read_int("soc_method") {
3286            builder.line_search.soc_method = v;
3287        }
3288
3289        // Inertia-correction / Jacobian-regularization constants (#191),
3290        // consumed by `PdPerturbationHandler`. Registered but never read.
3291        if let Some(v) = read_num("max_hessian_perturbation") {
3292            builder.perturbation.max_hessian_perturbation = v;
3293        }
3294        if let Some(v) = read_num("min_hessian_perturbation") {
3295            builder.perturbation.min_hessian_perturbation = v;
3296        }
3297        if let Some(v) = read_num("perturb_inc_fact_first") {
3298            builder.perturbation.perturb_inc_fact_first = v;
3299        }
3300        if let Some(v) = read_num("perturb_inc_fact") {
3301            builder.perturbation.perturb_inc_fact = v;
3302        }
3303        if let Some(v) = read_num("perturb_dec_fact") {
3304            builder.perturbation.perturb_dec_fact = v;
3305        }
3306        if let Some(v) = read_num("first_hessian_perturbation") {
3307            builder.perturbation.first_hessian_perturbation = v;
3308        }
3309        if let Some(v) = read_num("jacobian_regularization_value") {
3310            builder.perturbation.jacobian_regularization_value = v;
3311        }
3312        if let Some(v) = read_num("jacobian_regularization_exponent") {
3313            builder.perturbation.jacobian_regularization_exponent = v;
3314        }
3315        if let Ok((v, true)) = self.options.get_bool_value("perturb_always_cd", "") {
3316            builder.perturbation.perturb_always_cd = v;
3317        }
3318
3319        // Iterative-refinement constants (#191), consumed by
3320        // `PdFullSpaceSolver`. Registered but never read.
3321        if let Some(v) = read_int("min_refinement_steps") {
3322            builder.refinement.min_refinement_steps = v;
3323        }
3324        if let Some(v) = read_int("max_refinement_steps") {
3325            builder.refinement.max_refinement_steps = v;
3326        }
3327        if let Some(v) = read_num("residual_ratio_max") {
3328            builder.refinement.residual_ratio_max = v;
3329        }
3330        if let Some(v) = read_num("residual_ratio_singular") {
3331            builder.refinement.residual_ratio_singular = v;
3332        }
3333        if let Some(v) = read_num("residual_improvement_factor") {
3334            builder.refinement.residual_improvement_factor = v;
3335        }
3336
3337        // Restoration-phase constants (#191). Carried on the outer builder
3338        // and copied into the `RestoAlgorithmBuilder` when the restoration
3339        // factory is minted (the frontends pass this builder in). The
3340        // restoration builder was never options-configured, so these were
3341        // registered but never read. Defaults equal the registered
3342        // defaults.
3343        if let Some(v) = read_num("bound_mult_reset_threshold") {
3344            builder.resto.bound_mult_reset_threshold = v;
3345        }
3346        if let Some(v) = read_num("constr_mult_reset_threshold") {
3347            builder.resto.constr_mult_reset_threshold = v;
3348        }
3349        if let Some(v) = read_num("resto_penalty_parameter") {
3350            builder.resto.resto_penalty_parameter = v;
3351        }
3352        if let Some(v) = read_num("resto_proximity_weight") {
3353            builder.resto.resto_proximity_weight = v;
3354        }
3355        // `required_infeasibility_reduction` (#439) — the κ_resto guard the
3356        // restoration sub-solve exits on. Registered since #191 but the
3357        // value was hardcoded at the callsite, so setting it was a silent
3358        // no-op.
3359        if let Some(v) = read_num("required_infeasibility_reduction") {
3360            builder.resto.required_infeasibility_reduction = v;
3361        }
3362        // gh#483 / #191 round 2: three restoration switches whose fields
3363        // `RestoAlgorithmBuilder` has consumed all along — the read site
3364        // was the only missing piece, so setting them did nothing.
3365        let read_yes = |key: &str| -> Option<bool> {
3366            match self.options.get_string_value(key, "") {
3367                Ok((v, true)) => Some(v.eq_ignore_ascii_case("yes")),
3368                _ => None,
3369            }
3370        };
3371        if let Some(v) = read_yes("evaluate_orig_obj_at_resto_trial") {
3372            builder.resto.evaluate_orig_obj_at_resto_trial = v;
3373        }
3374        if let Some(v) = read_yes("expect_infeasible_problem") {
3375            builder.resto.expect_infeasible_problem = v;
3376        }
3377        if let Some(v) = read_yes("start_with_resto") {
3378            builder.resto.start_with_resto = v;
3379        }
3380
3381        // Iteration-output options — consumed by `OrigIterationOutput`.
3382        if let Some(v) = read_int("print_frequency_iter") {
3383            builder.output.print_frequency_iter = v;
3384        }
3385        if let Some(v) = read_num("print_frequency_time") {
3386            builder.output.print_frequency_time = v;
3387        }
3388        if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
3389            if found {
3390                builder.output.print_info_string = v;
3391            }
3392        }
3393        if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
3394            if found {
3395                builder.output.inf_pr_output_internal = v == "internal";
3396            }
3397        }
3398
3399        // Warm-start options — consumed by `WarmStartIterateInitializer`
3400        // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
3401        // `warm_start_init_point` is the toggle that picks between the
3402        // default (cold) and warm-start initializers; the remaining
3403        // knobs are baked onto the chosen initializer at build time.
3404        if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
3405            if found {
3406                builder.warm_start_init_point = v;
3407            }
3408        }
3409        if let Ok((v, found)) = self.options.get_bool_value("warm_start_same_structure", "") {
3410            if found {
3411                builder.warm.same_structure = v;
3412            }
3413        }
3414        if let Some(v) = read_num("warm_start_bound_push") {
3415            builder.warm.bound_push = v;
3416        }
3417        if let Some(v) = read_num("warm_start_bound_frac") {
3418            builder.warm.bound_frac = v;
3419        }
3420        if let Some(v) = read_num("warm_start_slack_bound_push") {
3421            builder.warm.slack_bound_push = v;
3422        }
3423        if let Some(v) = read_num("warm_start_slack_bound_frac") {
3424            builder.warm.slack_bound_frac = v;
3425        }
3426        if let Some(v) = read_num("warm_start_mult_bound_push") {
3427            builder.warm.mult_bound_push = v;
3428        }
3429        if let Some(v) = read_num("warm_start_mult_init_max") {
3430            builder.warm.mult_init_max = v;
3431        }
3432        if let Some(v) = read_num("warm_start_target_mu") {
3433            builder.warm.target_mu = v;
3434        }
3435        if let Ok((v, found)) = self
3436            .options
3437            .get_string_value("warm_start_entire_iterate", "")
3438        {
3439            if found {
3440                builder.warm.entire_iterate = v == "yes";
3441            }
3442        }
3443
3444        // `DefaultIterateInitializer` knobs — parsed after the Mehrotra
3445        // cascade so explicit user values win
3446        // (mirrors upstream's `SetNumericValueIfUnset` semantics).
3447        if let Some(v) = read_num("bound_push") {
3448            builder.init.bound_push = v;
3449        }
3450        if let Some(v) = read_num("bound_frac") {
3451            builder.init.bound_frac = v;
3452        }
3453        if let Some(v) = read_num("slack_bound_push") {
3454            builder.init.slack_bound_push = v;
3455        }
3456        if let Some(v) = read_num("slack_bound_frac") {
3457            builder.init.slack_bound_frac = v;
3458        }
3459        if let Some(v) = read_num("constr_mult_init_max") {
3460            builder.init.constr_mult_init_max = v;
3461        }
3462        if let Some(v) = read_num("bound_mult_init_val") {
3463            builder.init.bound_mult_init_val = v;
3464        }
3465        if let Ok((v, found)) = self.options.get_string_value("bound_mult_init_method", "") {
3466            if found {
3467                builder.init.bound_mult_init_method = v;
3468            }
3469        }
3470        if let Ok((v, found)) = self
3471            .options
3472            .get_string_value("least_square_init_primal", "")
3473        {
3474            if found {
3475                builder.init.least_square_init_primal = v == "yes";
3476            }
3477        }
3478        builder
3479    }
3480}
3481
3482/// Map the integer `print_level` / `file_print_level` option to the
3483/// matching [`JournalLevel`] variant. Mirrors upstream's
3484/// `static_cast<EJournalLevel>(int_value)` with clamping.
3485/// The eight block dimensions of an iterate, in canonical order
3486/// (x, s, y_c, y_d, z_l, z_u, v_l, v_u). Used to guard the debugger's
3487/// warm-restart install against a structural mismatch between solves.
3488fn iterates_dims(c: &IteratesVector) -> [i32; 8] {
3489    [
3490        c.x.dim(),
3491        c.s.dim(),
3492        c.y_c.dim(),
3493        c.y_d.dim(),
3494        c.z_l.dim(),
3495        c.z_u.dim(),
3496        c.v_l.dim(),
3497        c.v_u.dim(),
3498    ]
3499}
3500
3501fn journal_level_from_int(v: i32) -> JournalLevel {
3502    match v.clamp(0, 12) {
3503        0 => JournalLevel::J_NONE,
3504        1 => JournalLevel::J_ERROR,
3505        2 => JournalLevel::J_STRONGWARNING,
3506        3 => JournalLevel::J_SUMMARY,
3507        4 => JournalLevel::J_WARNING,
3508        5 => JournalLevel::J_ITERSUMMARY,
3509        6 => JournalLevel::J_DETAILED,
3510        7 => JournalLevel::J_MOREDETAILED,
3511        8 => JournalLevel::J_VECTOR,
3512        9 => JournalLevel::J_MOREVECTOR,
3513        10 => JournalLevel::J_MATRIX,
3514        11 => JournalLevel::J_MOREMATRIX,
3515        _ => JournalLevel::J_ALL,
3516    }
3517}
3518
3519/// Default symmetric linear-solver factory, parameterized by the
3520/// pounce-extension FERAL knobs read off the application's
3521/// `OptionsList`.
3522///
3523/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
3524/// available when the `ma57` cargo feature is enabled; without it,
3525/// requesting `linear_solver = ma57` falls back to FERAL with a
3526/// warning printed by the journalist (see [`AlgorithmBuilder`]).
3527pub fn default_backend_factory(feral_cfg: pounce_feral::FeralConfig) -> LinearBackendFactory {
3528    Box::new(
3529        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
3530            match choice {
3531                LinearSolverChoice::Feral => Box::new(
3532                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
3533                ),
3534                LinearSolverChoice::Ma57 => {
3535                    #[cfg(feature = "ma57")]
3536                    {
3537                        Box::new(pounce_hsl::Ma57SolverInterface::new())
3538                    }
3539                    #[cfg(not(feature = "ma57"))]
3540                    {
3541                        // ma57 feature not compiled in — fall back to FERAL.
3542                        Box::new(pounce_feral::FeralSolverInterface::with_config(
3543                            feral_cfg.clone(),
3544                        ))
3545                    }
3546                }
3547            }
3548        },
3549    )
3550}
3551
3552/// Sink-aware variant of [`default_backend_factory`]. Identical
3553/// dispatch, but the FERAL backend is constructed with a
3554/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
3555/// aggregate post-mortem stats (factor counts, fill ratio, extremal
3556/// pivots, final inertia) after the solve returns. MA57 ignores the
3557/// sink — the HSL backend doesn't carry the same instrumentation yet.
3558pub fn default_backend_factory_with_sink(
3559    feral_cfg: pounce_feral::FeralConfig,
3560    sink: Arc<Mutex<LinearSolverSummary>>,
3561) -> LinearBackendFactory {
3562    Box::new(
3563        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
3564            match choice {
3565                LinearSolverChoice::Feral => Box::new(
3566                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
3567                        .with_summary_sink(Arc::clone(&sink)),
3568                ),
3569                LinearSolverChoice::Ma57 => {
3570                    #[cfg(feature = "ma57")]
3571                    {
3572                        Box::new(pounce_hsl::Ma57SolverInterface::new())
3573                    }
3574                    #[cfg(not(feature = "ma57"))]
3575                    {
3576                        Box::new(
3577                            pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
3578                                .with_summary_sink(Arc::clone(&sink)),
3579                        )
3580                    }
3581                }
3582            }
3583        },
3584    )
3585}
3586
3587/// Read the `feral_*` extension options off `options`, falling
3588/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
3589/// for any knob the caller did not set explicitly. The returned
3590/// config is what every default-factory invocation (main IPM and
3591/// restoration sub-IPM) consumes.
3592pub fn feral_config_from_options(
3593    options: &pounce_common::options_list::OptionsList,
3594) -> pounce_feral::FeralConfig {
3595    let mut cfg = pounce_feral::FeralConfig::from_env();
3596    // Tri-state: the `(_, true)` arm only fires when the user set the
3597    // option explicitly. Leaving it unset keeps `cfg.cascade_break` at
3598    // `None`, which inherits FERAL's `NumericParams::default()` (CB on
3599    // as of FERAL Phase B / pounce#55). `Some(false)` explicitly
3600    // disarms (reproduces pre-Phase-B behaviour, surfaces FERAL's
3601    // `DelayBudgetExceeded` on non-root cascade victims).
3602    if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
3603        cfg.cascade_break = Some(v);
3604    }
3605    if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
3606        cfg.fma = v;
3607    }
3608    if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
3609        cfg.refine = v;
3610    }
3611    // Explicit static-pivoting opt-in (feral#8 cascade breaker, pounce#254).
3612    // Same tri-state discipline: unset leaves `cfg.static_pivoting` at
3613    // whatever `from_env` resolved (`None` → inherit feral's delayed-pivot
3614    // default), so the default numeric path is unchanged.
3615    if let Ok((v, true)) = options.get_bool_value("feral_static_pivoting", "") {
3616        cfg.static_pivoting = Some(v);
3617    }
3618    if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
3619        cfg.singular_pivot_floor = v;
3620    }
3621    if let Ok((v, true)) = options.get_numeric_value("feral_inertia_pivot_floor", "") {
3622        cfg.inertia_pivot_floor = v;
3623    }
3624    // Number option (not integer): the gate is a u64 and Index is i32, too
3625    // narrow for large flop counts or the u64::MAX reject-all sentinel. The
3626    // lower bound (0.0) rules out negatives; `as u64` then saturates a very
3627    // large finite value to u64::MAX (reject all tree-level parallelism).
3628    if let Ok((v, true)) = options.get_numeric_value("feral_min_par_flops", "") {
3629        cfg.min_par_flops = Some(v as u64);
3630    }
3631    if let Ok((v, true)) = options.get_numeric_value("feral_pivtol", "") {
3632        cfg.pivtol = v;
3633    }
3634    // Only override on explicit set so `from_env` (which itself
3635    // defaults to OrderingMethod::Auto) keeps governing unset cases.
3636    // Unrecognized tags are silently ignored — the registered enum
3637    // restricts inputs at the OptionsList layer.
3638    if let Ok((v, true)) = options.get_string_value("feral_ordering", "") {
3639        if let Some(m) = pounce_feral::parse_ordering_method(&v) {
3640            cfg.ordering = m;
3641        }
3642    }
3643    // Same explicit-set discipline as `feral_ordering`: `from_env`
3644    // defaults to ScalingStrategy::Auto (FERAL's current default), so
3645    // leaving the option unset preserves existing behaviour exactly.
3646    if let Ok((v, true)) = options.get_string_value("feral_scaling", "") {
3647        if let Some(s) = pounce_feral::parse_scaling_strategy(&v) {
3648            cfg.scaling = s;
3649        }
3650    }
3651    cfg
3652}
3653
3654/// Withdraw a numerical infeasibility verdict the model's own starting point
3655/// disproves.
3656///
3657/// Applied at every site in this file that can return
3658/// `Infeasible_Problem_Detected` from a *numerical* argument — the IPM path's
3659/// restoration / cycle gates, the SQP path's infeasible-subproblem exit, and the
3660/// ℓ₁ wrapper's uncollapsed-slack certificate. Deliberately one gate rather than
3661/// three: the two preceding safeguards in this area (gh #376, gh #380) were each
3662/// added to one path and not its twin, and a hole survived both times.
3663///
3664/// Not applied to a presolve *certificate*
3665/// (`TNLP::presolve_infeasibility_proof`), which carries its own, tighter
3666/// refutation
3667/// (`pounce_presolve::witness_refutes_infeasibility`) and is a proof rather than
3668/// a numerical inference.
3669///
3670/// The replacement is `Error_In_Step_Computation`, the status this codebase
3671/// already uses for "the solve broke down and we are **not** claiming
3672/// infeasibility" — see the `cycle_exit` fallback in
3673/// [`crate::ipopt_alg::IpoptAlgorithm::invoke_restoration`], which picks between
3674/// exactly these two on exactly this question. It maps to AMPL 500, an honest
3675/// failure the caller can see, instead of AMPL 200, a wrong answer they cannot.
3676///
3677/// gh #379.
3678fn withdraw_infeasibility_if_refuted(
3679    tnlp: &Rc<RefCell<dyn TNLP>>,
3680    solver_status: SolverReturn,
3681    lo_inf: Number,
3682    up_inf: Number,
3683    tol: Number,
3684) -> SolverReturn {
3685    if solver_status != SolverReturn::LocalInfeasibility {
3686        return solver_status;
3687    }
3688    // A presolve proof is not a numerical inference; it does its own refutation.
3689    if tnlp.borrow().presolve_infeasibility_proof().is_some() {
3690        return solver_status;
3691    }
3692    match crate::infeasibility_refutation::starting_point_refutes_infeasibility(
3693        tnlp, lo_inf, up_inf, tol,
3694    ) {
3695        Some(w) => {
3696            tracing::debug!(
3697                target: "pounce::application",
3698                "[PN_INFEAS_REFUTED] the model's starting point satisfies every constraint \
3699                 (max violation {:.3e}) — withdrawing Infeasible_Problem_Detected",
3700                w.max_violation
3701            );
3702            SolverReturn::ErrorInStepComputation
3703        }
3704        None => solver_status,
3705    }
3706}
3707
3708/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
3709/// Mirrors the table in
3710/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
3711/// map") and the corresponding switch in
3712/// `IpIpoptApplication.cpp:call_optimize`.
3713fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
3714    match s {
3715        SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
3716        SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
3717        SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
3718        SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
3719        SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
3720        SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
3721        SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
3722        SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
3723        SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
3724        SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
3725        SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
3726        SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
3727        SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
3728        SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
3729        SolverReturn::InvalidProblemDefinition => ApplicationReturnStatus::InvalidProblemDefinition,
3730        SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
3731        SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
3732        SolverReturn::InternalError | SolverReturn::Unassigned => {
3733            ApplicationReturnStatus::InternalError
3734        }
3735    }
3736}
3737
3738/// Best-effort evaluation of the objective at the algorithm's final
3739/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
3740/// to populate `SolveStatistics::final_scaled_objective`.
3741fn try_eval_curr_f(
3742    nlp: &Rc<RefCell<dyn IpoptNlp>>,
3743    x: &Rc<dyn pounce_linalg::Vector>,
3744) -> Result<Number, ()> {
3745    let mut nlp_mut = nlp.borrow_mut();
3746    Ok(nlp_mut.eval_f(&**x))
3747}
3748
3749/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
3750/// `true` when a status warrants a retry through the wrapper. Mirrors
3751/// ripopt#23's trigger set, extended per the audit's Refinement B
3752/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
3753/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
3754/// equivalent would let pass to the wrapper).
3755fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
3756    matches!(
3757        status,
3758        ApplicationReturnStatus::RestorationFailed
3759            | ApplicationReturnStatus::InfeasibleProblemDetected
3760            | ApplicationReturnStatus::SolvedToAcceptableLevel
3761            | ApplicationReturnStatus::MaximumIterationsExceeded
3762            | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
3763    )
3764}
3765
3766/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
3767/// We pull `x` (compressed in `x_var`-space) off the algorithm's
3768/// `data.curr`, lift it back to full TNLP indexing, and pass empty
3769/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
3770/// in compressed split form — re-assembling them into the user's
3771/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
3772/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
3773/// fleshed out). On success returns the unscaled objective evaluated
3774/// on the user TNLP at the final iterate; returns `Err` if the final
3775/// iterate is missing.
3776fn finalize_via_orig_nlp(
3777    nlp: &Rc<RefCell<dyn IpoptNlp>>,
3778    alg: &IpoptAlgorithm,
3779    solver_status: SolverReturn,
3780    _app_status: ApplicationReturnStatus,
3781    tnlp: &Rc<RefCell<dyn TNLP>>,
3782) -> Result<Number, ()> {
3783    let curr = alg.data.borrow().curr.clone().ok_or(())?;
3784    // Lift compressed x_var → full-x (length `info.n`) so the user
3785    // TNLP receives the same shape it provided. With `make_parameter`
3786    // the fixed components are spliced back in by the IpoptNlp.
3787    let nlp_borrow = nlp.borrow();
3788    // `finalize_solution_x`, not `lift_x_to_full`: the reported point also
3789    // owes the user the `honor_original_bounds` projection. `f` and `g`
3790    // below are then evaluated at the point actually reported, so x/f/g
3791    // agree with each other.
3792    let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&*curr.x);
3793    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
3794    let n = info.n as usize;
3795    let m = info.m as usize;
3796    debug_assert_eq!(x_vec.len(), n);
3797    // Lift algorithm-side multipliers back into user-space (pounce#11).
3798    // Use the `finalize_solution_*` family (not the `pack_*` family): the
3799    // final solution duals must be reported in the user's *unscaled-
3800    // Lagrangian* convention `∇f + λ·∇g + z = 0`, which divides out the
3801    // `obj_scale_factor` the algorithm threads through `eval_h`. The `pack_*`
3802    // family deliberately omits that division because it feeds the scaled
3803    // `eval_h`; calling it here left every dual scaled by `obj_scale_factor`
3804    // whenever gradient-based scaling triggered (pounce#11 F1).
3805    // Backends without overrides return empty; fall back to zero stubs so the
3806    // user sees a length-consistent vector.
3807    let mut z_l = nlp_borrow.finalize_solution_z_l(&*curr.z_l);
3808    if z_l.is_empty() {
3809        z_l = vec![0.0; n];
3810    }
3811    let mut z_u = nlp_borrow.finalize_solution_z_u(&*curr.z_u);
3812    if z_u.is_empty() {
3813        z_u = vec![0.0; n];
3814    }
3815    let mut lambda = nlp_borrow.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
3816    if lambda.is_empty() {
3817        lambda = vec![0.0; m];
3818    }
3819    drop(nlp_borrow);
3820    // Compute g(x) via the user TNLP so the final residual is
3821    // populated for the user.
3822    let mut g_final = vec![0.0; m];
3823    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
3824    let f_final = tnlp
3825        .borrow_mut()
3826        .eval_f(&x_vec, true)
3827        .unwrap_or(Number::NAN);
3828    tnlp.borrow_mut().finalize_solution(
3829        Solution {
3830            status: solver_status,
3831            x: &x_vec,
3832            z_l: &z_l,
3833            z_u: &z_u,
3834            g: &g_final,
3835            lambda: &lambda,
3836            obj_value: f_final,
3837        },
3838        &TnlpIpoptData::default(),
3839        &TnlpIpoptCq::default(),
3840    );
3841    Ok(f_final)
3842}
3843
3844/// Bind SQP suboptions registered in `upstream_options.rs`
3845/// (`sqp_globalization`, `sqp_hessian`, `sqp_max_iter`, `sqp_tol`,
3846/// `sqp_constr_viol_tol`, `sqp_dual_inf_tol`, `sqp_l1_penalty`,
3847/// `sqp_bt_reduction`, `sqp_bt_min_alpha`, `sqp_print_level`,
3848/// `sqp_lbfgs_max_history`) onto
3849/// `opts`. Used by [`IpoptApplication::algorithm_builder_snapshot`]
3850/// before constructing an SQP algorithm.
3851fn apply_sqp_options(options: &OptionsList, opts: &mut crate::sqp::SqpOptions) {
3852    use crate::sqp::{SqpGlobalization, SqpHessianSource};
3853
3854    if let Ok((s, true)) = options.get_string_value("sqp_globalization", "") {
3855        opts.globalization = match s.as_str() {
3856            "filter" => SqpGlobalization::Filter,
3857            "l1-elastic" => SqpGlobalization::L1Elastic,
3858            _ => opts.globalization,
3859        };
3860    }
3861    // `hessian_approximation` is the upstream Ipopt option a frontend sets
3862    // when the caller supplies no second derivatives -- `pounce.minimize` does
3863    // it automatically, and warns that it is doing so. It was only ever read
3864    // on the IPM path, so an SQP solve ignored it and fell back to the
3865    // `Exact` default, asking the NLP for a Lagrangian Hessian that was never
3866    // provided. A zero Hessian turns the QP subproblem into an LP, which is
3867    // unbounded whenever the objective gradient has a component in the null
3868    // space of the active constraints -- so the solve died with
3869    // `Internal_Error` on problems the IPM handles without complaint:
3870    //
3871    //     min (x0-3)^2 + (x1-2)^2  s.t.  4 - x0 - x1 >= 0
3872    //
3873    // (IPM: x = [2.5, 1.5]. Active-set SQP before this: Internal_Error, or
3874    // with variable bounds, a run to the box corner along the null-space
3875    // direction.)
3876    //
3877    // The quasi-Newton source picked here is the *dense Powell-damped BFGS*,
3878    // not the limited-memory one, even though the requesting option is spelled
3879    // `limited-memory`. On this active-set-SQP path L-BFGS buys nothing: its
3880    // `as_triplet` materializes a full dense `n×n` Hessian for the QP
3881    // subproblem exactly as `DampedBfgs` does (the matrix-free product
3882    // interface that would make L-BFGS cheaper is not implemented yet), and it
3883    // is markedly less robust -- it stalls with
3884    // `Search_Direction_Becomes_Too_Small` (or reports the QP subproblem
3885    // `unbounded`) on easy, well-conditioned convex QPs whenever a general
3886    // inequality is active at the optimum, returning `success=False` with a
3887    // wrong `x` (issue #358). `DampedBfgs` solves those. So the automatic
3888    // approximation the facade injects when no analytic Hessian is available
3889    // maps to the robust dense update; a caller who genuinely wants
3890    // limited-memory storage can still request it explicitly with
3891    // `sqp_hessian = "lbfgs"` below (read after this, so it wins).
3892    //
3893    // Read this before `sqp_hessian` so an explicit setting still wins.
3894    if let Ok((s, true)) = options.get_string_value("hessian_approximation", "") {
3895        if s == "limited-memory" {
3896            opts.hessian = SqpHessianSource::DampedBfgs;
3897        }
3898    }
3899    if let Ok((s, true)) = options.get_string_value("sqp_hessian", "") {
3900        opts.hessian = match s.as_str() {
3901            "exact" => SqpHessianSource::Exact,
3902            "damped-bfgs" => SqpHessianSource::DampedBfgs,
3903            "lbfgs" => SqpHessianSource::Lbfgs,
3904            _ => opts.hessian,
3905        };
3906    }
3907    if let Ok((v, true)) = options.get_integer_value("sqp_max_iter", "") {
3908        if v >= 0 {
3909            opts.max_iter = v as u32;
3910        }
3911    }
3912    if let Ok((v, true)) = options.get_numeric_value("sqp_tol", "") {
3913        opts.tol = v;
3914    }
3915    if let Ok((v, true)) = options.get_numeric_value("sqp_constr_viol_tol", "") {
3916        opts.constr_viol_tol = v;
3917    }
3918    if let Ok((v, true)) = options.get_numeric_value("sqp_dual_inf_tol", "") {
3919        opts.dual_inf_tol = v;
3920    }
3921    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty", "") {
3922        opts.l1_penalty = v;
3923    }
3924    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_safety", "") {
3925        opts.l1_penalty_safety = v;
3926    }
3927    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_max", "") {
3928        opts.l1_penalty_max = v;
3929    }
3930    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_reduction", "") {
3931        opts.bt_reduction = v;
3932    }
3933    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_min_alpha", "") {
3934        opts.bt_min_alpha = v;
3935    }
3936    if let Ok((v, true)) = options.get_integer_value("sqp_print_level", "") {
3937        opts.print_level = v.clamp(0, u8::MAX as i32) as u8;
3938    }
3939    if let Ok((v, true)) = options.get_integer_value("sqp_lbfgs_max_history", "") {
3940        if v >= 1 {
3941            opts.lbfgs_max_history = v as u32;
3942        }
3943    }
3944}
3945
3946/// Populate the active-set SQP **QP-subproblem** options
3947/// ([`pounce_qp::QpOptions`]) from the `sqp_qp_*` option family.
3948///
3949/// Sister to [`apply_sqp_options`], which handles the SQP *outer-loop*
3950/// options ([`crate::sqp::SqpOptions`]); this one feeds the inner QP
3951/// solver that `SqpAlgorithm` delegates each subproblem to. Consulted
3952/// only on the `ActiveSetSqp` path. Each knob is forwarded only when
3953/// the user explicitly set it (the `true` flag), so the `pounce_qp`
3954/// defaults stand otherwise.
3955fn apply_qp_subproblem_options(options: &OptionsList, opts: &mut pounce_qp::QpOptions) {
3956    use pounce_qp::AntiCyclingChoice;
3957
3958    if let Ok((v, true)) = options.get_integer_value("sqp_qp_max_iter", "") {
3959        if v >= 0 {
3960            opts.max_iter = v as u32;
3961        }
3962    }
3963    if let Ok((v, true)) = options.get_numeric_value("sqp_qp_feas_tol", "") {
3964        opts.feas_tol = v;
3965    }
3966    if let Ok((v, true)) = options.get_numeric_value("sqp_qp_opt_tol", "") {
3967        opts.opt_tol = v;
3968    }
3969    if let Ok((v, true)) = options.get_numeric_value("sqp_qp_elastic_gamma", "") {
3970        opts.elastic_gamma = v;
3971    }
3972    if let Ok((v, true)) = options.get_bool_value("sqp_qp_use_schur_updates", "") {
3973        opts.use_schur_updates = v;
3974    }
3975    // Registered by the homotopy work but never read here, so the knob was a
3976    // no-op on the SQP path: `pounce_qp`'s own default is `false`, and only
3977    // `pounce_convex::active_set` set it (in Rust, not through options). The
3978    // inverse of gh #360 — registered-but-unread rather than
3979    // read-but-unregistered — and invisible to that issue's guard test, which
3980    // only checked one direction.
3981    if let Ok((v, true)) = options.get_bool_value("sqp_qp_use_homotopy", "") {
3982        opts.use_homotopy = v;
3983    }
3984    if let Ok((v, true)) = options.get_integer_value("sqp_qp_max_schur_updates_before_refactor", "")
3985    {
3986        if v >= 1 {
3987            opts.max_schur_updates_before_refactor = v as u32;
3988        }
3989    }
3990    if let Ok((s, true)) = options.get_string_value("sqp_qp_anti_cycling", "") {
3991        opts.anti_cycling = match s.as_str() {
3992            "expand" => AntiCyclingChoice::Expand,
3993            "bland" => AntiCyclingChoice::Bland,
3994            "none" => AntiCyclingChoice::None,
3995            _ => opts.anti_cycling,
3996        };
3997    }
3998}
3999
4000/// SQP-side analog of [`finalize_via_orig_nlp`]. Hands the SQP
4001/// solution iterate to the user TNLP via the standard
4002/// `finalize_solution` callback. Multiplier lifting goes through
4003/// the same OrigIpoptNlp hooks so the user sees the same shape
4004/// regardless of which algorithm produced the iterate.
4005///
4006/// Returns the user-space objective value on success.
4007fn finalize_via_sqp(
4008    nlp: &Rc<RefCell<dyn IpoptNlp>>,
4009    res: &crate::sqp::SqpResult,
4010    solver_status: pounce_nlp::SolverReturn,
4011    tnlp: &Rc<RefCell<dyn TNLP>>,
4012) -> Result<Number, ()> {
4013    use pounce_linalg::dense_vector::DenseVectorSpace;
4014
4015    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
4016    let n = info.n as usize;
4017    let m = info.m as usize;
4018
4019    // Wrap SQP slices in DenseVectors so we can pass them through
4020    // the OrigIpoptNlp lift_x_to_full / pack_*_for_user hooks.
4021    let nlp_borrow = nlp.borrow();
4022    let n_alg = nlp_borrow.n() as usize;
4023    let m_eq = nlp_borrow.m_eq() as usize;
4024    let m_ineq = nlp_borrow.m_ineq() as usize;
4025    debug_assert_eq!(res.x.len(), n_alg);
4026    debug_assert_eq!(res.lambda_g.len(), m_eq + m_ineq);
4027    debug_assert_eq!(res.lambda_x.len(), n_alg);
4028
4029    let x_space = DenseVectorSpace::new(n_alg as Index);
4030    let c_space = DenseVectorSpace::new(m_eq as Index);
4031    let d_space = DenseVectorSpace::new(m_ineq as Index);
4032
4033    let mut x_dv = x_space.make_new_dense();
4034    x_dv.set_values(&res.x);
4035    let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&x_dv);
4036    debug_assert_eq!(x_vec.len(), n);
4037
4038    // λ_x is packed signed (z_l − z_u). Split for lift.
4039    let mut z_l_compressed = x_space.make_new_dense();
4040    let mut z_u_compressed = x_space.make_new_dense();
4041    let zl_vals: Vec<Number> = res.lambda_x.iter().map(|v| v.max(0.0)).collect();
4042    let zu_vals: Vec<Number> = res.lambda_x.iter().map(|v| (-v).max(0.0)).collect();
4043    z_l_compressed.set_values(&zl_vals);
4044    z_u_compressed.set_values(&zu_vals);
4045    // `finalize_solution_*` (not `pack_*`): report unscaled-Lagrangian duals,
4046    // dividing out `obj_scale_factor` — see `finalize_via_orig_nlp` (F1).
4047    let mut z_l = nlp_borrow.finalize_solution_z_l(&z_l_compressed);
4048    if z_l.is_empty() {
4049        z_l = vec![0.0; n];
4050    }
4051    let mut z_u = nlp_borrow.finalize_solution_z_u(&z_u_compressed);
4052    if z_u.is_empty() {
4053        z_u = vec![0.0; n];
4054    }
4055
4056    // λ_g is [y_c; y_d]; split into the c/d blocks for lift.
4057    let mut y_c_dv = c_space.make_new_dense();
4058    let mut y_d_dv = d_space.make_new_dense();
4059    if m_eq > 0 {
4060        y_c_dv.set_values(&res.lambda_g[..m_eq]);
4061    }
4062    if m_ineq > 0 {
4063        y_d_dv.set_values(&res.lambda_g[m_eq..]);
4064    }
4065    let mut lambda = nlp_borrow.finalize_solution_lambda(&y_c_dv, &y_d_dv);
4066    if lambda.is_empty() {
4067        lambda = vec![0.0; m];
4068    }
4069    drop(nlp_borrow);
4070
4071    let mut g_final = vec![0.0; m];
4072    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
4073    let f_final = tnlp
4074        .borrow_mut()
4075        .eval_f(&x_vec, true)
4076        .unwrap_or(Number::NAN);
4077    tnlp.borrow_mut().finalize_solution(
4078        pounce_nlp::tnlp::Solution {
4079            status: solver_status,
4080            x: &x_vec,
4081            z_l: &z_l,
4082            z_u: &z_u,
4083            g: &g_final,
4084            lambda: &lambda,
4085            obj_value: f_final,
4086        },
4087        &TnlpIpoptData::default(),
4088        &TnlpIpoptCq::default(),
4089    );
4090    Ok(f_final)
4091}
4092
4093#[cfg(test)]
4094mod tests {
4095    use super::*;
4096    use pounce_nlp::tnlp::{
4097        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest,
4098        StartingPoint,
4099    };
4100
4101    struct Hs071Stub;
4102    impl TNLP for Hs071Stub {
4103        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4104            // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
4105            // dense lower-triangular Hessian (10 nz).
4106            Some(NlpInfo {
4107                n: 4,
4108                m: 2,
4109                nnz_jac_g: 8,
4110                nnz_h_lag: 10,
4111                index_style: IndexStyle::C,
4112            })
4113        }
4114        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4115            b.x_l.copy_from_slice(&[1.0; 4]);
4116            b.x_u.copy_from_slice(&[5.0; 4]);
4117            b.g_l.copy_from_slice(&[25.0, 40.0]);
4118            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
4119            true
4120        }
4121        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4122            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
4123            true
4124        }
4125        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
4126            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
4127        }
4128        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
4129            grad.fill(0.0);
4130            true
4131        }
4132        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4133            g.fill(0.0);
4134            true
4135        }
4136        fn eval_jac_g(
4137            &mut self,
4138            _x: Option<&[Number]>,
4139            _new_x: bool,
4140            mode: SparsityRequest<'_>,
4141        ) -> bool {
4142            if let SparsityRequest::Structure { irow, jcol } = mode {
4143                irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
4144                jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
4145            }
4146            true
4147        }
4148        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
4149    }
4150
4151    #[test]
4152    fn application_default_does_not_select_sqp() {
4153        let mut app = IpoptApplication::new();
4154        app.initialize().unwrap();
4155        assert!(!app.is_sqp_algorithm_selected());
4156    }
4157
4158    #[test]
4159    fn application_routes_to_sqp_when_algorithm_option_set() {
4160        let mut app = IpoptApplication::new();
4161        app.initialize().unwrap();
4162        app.initialize_with_options_str("algorithm active-set-sqp\n")
4163            .unwrap();
4164        assert!(app.is_sqp_algorithm_selected());
4165    }
4166
4167    #[test]
4168    fn feral_min_par_flops_option_reaches_config() {
4169        let mut app = IpoptApplication::new();
4170        app.initialize().unwrap();
4171        // Unset on the OptionsList: falls through to FeralConfig::from_env,
4172        // which leaves it None (inherit feral's built-in default) when the
4173        // POUNCE_FERAL_MIN_PAR_FLOPS env var is also absent.
4174        assert_eq!(
4175            feral_config_from_options(app.options()).min_par_flops,
4176            None,
4177            "unset feral_min_par_flops should not force an override"
4178        );
4179        // Explicit set is mapped through, cast to u64. `0` is the "dispatch
4180        // on every eligible tree" setting and must survive the cast.
4181        app.initialize_with_options_str("feral_min_par_flops 0\n")
4182            .unwrap();
4183        assert_eq!(
4184            feral_config_from_options(app.options()).min_par_flops,
4185            Some(0)
4186        );
4187        // A large finite value passes through intact (5e8 > i32::MAX, which
4188        // is why this is a number option, not an integer one).
4189        app.initialize_with_options_str("feral_min_par_flops 5e8\n")
4190            .unwrap();
4191        assert_eq!(
4192            feral_config_from_options(app.options()).min_par_flops,
4193            Some(500_000_000)
4194        );
4195    }
4196
4197    #[test]
4198    fn feral_static_pivoting_option_reaches_config() {
4199        let mut app = IpoptApplication::new();
4200        app.initialize().unwrap();
4201        // Unset on the OptionsList: falls through to FeralConfig::from_env,
4202        // which leaves it None (inherit feral's delayed-pivot default) when
4203        // the POUNCE_FERAL_STATIC_PIVOTING env var is also absent — so the
4204        // default numeric path is unchanged.
4205        assert_eq!(
4206            feral_config_from_options(app.options()).static_pivoting,
4207            None,
4208            "unset feral_static_pivoting must not force a numeric override"
4209        );
4210        // Explicit `yes` maps to Some(true): every supernode factors with
4211        // delayed pivoting disabled (feral#8 cascade breaker).
4212        app.initialize_with_options_str("feral_static_pivoting yes\n")
4213            .unwrap();
4214        assert_eq!(
4215            feral_config_from_options(app.options()).static_pivoting,
4216            Some(true)
4217        );
4218        // Explicit `no` maps to Some(false): keep delayed pivoting on
4219        // (distinct from unset, which merely inherits the default).
4220        app.initialize_with_options_str("feral_static_pivoting no\n")
4221            .unwrap();
4222        assert_eq!(
4223            feral_config_from_options(app.options()).static_pivoting,
4224            Some(false)
4225        );
4226    }
4227
4228    /// Convex equality NLP fixture for end-to-end SQP testing
4229    /// through `IpoptApplication`:
4230    ///
4231    ///     min ½(x₁² + x₂²) − x₁ − 2x₂  s.t.  x₁ + x₂ = 1
4232    ///
4233    /// Closed form: x* = (0, 1), obj = -1.5, λ_g = 1.
4234    struct ConvexEqTnlp {
4235        finalize_called: std::rc::Rc<std::cell::RefCell<Option<(Vec<Number>, Number)>>>,
4236    }
4237    impl TNLP for ConvexEqTnlp {
4238        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
4239            Some(NlpInfo {
4240                n: 2,
4241                m: 1,
4242                nnz_jac_g: 2,
4243                nnz_h_lag: 2,
4244                index_style: IndexStyle::C,
4245            })
4246        }
4247        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
4248            b.x_l.copy_from_slice(&[-2.0e19; 2]);
4249            b.x_u.copy_from_slice(&[2.0e19; 2]);
4250            b.g_l.copy_from_slice(&[1.0]);
4251            b.g_u.copy_from_slice(&[1.0]);
4252            true
4253        }
4254        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
4255            sp.x.copy_from_slice(&[0.0, 0.0]);
4256            true
4257        }
4258        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
4259            Some(0.5 * (x[0] * x[0] + x[1] * x[1]) - x[0] - 2.0 * x[1])
4260        }
4261        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
4262            grad[0] = x[0] - 1.0;
4263            grad[1] = x[1] - 2.0;
4264            true
4265        }
4266        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
4267            g[0] = x[0] + x[1];
4268            true
4269        }
4270        fn eval_jac_g(
4271            &mut self,
4272            _x: Option<&[Number]>,
4273            _new_x: bool,
4274            mode: SparsityRequest<'_>,
4275        ) -> bool {
4276            match mode {
4277                SparsityRequest::Structure { irow, jcol } => {
4278                    irow.copy_from_slice(&[0, 0]);
4279                    jcol.copy_from_slice(&[0, 1]);
4280                }
4281                SparsityRequest::Values { values, .. } => {
4282                    values.copy_from_slice(&[1.0, 1.0]);
4283                }
4284            }
4285            true
4286        }
4287        fn eval_h(
4288            &mut self,
4289            _x: Option<&[Number]>,
4290            _new_x: bool,
4291            _obj_factor: Number,
4292            _lambda: Option<&[Number]>,
4293            _new_lambda: bool,
4294            mode: SparsityRequest<'_>,
4295        ) -> bool {
4296            match mode {
4297                SparsityRequest::Structure { irow, jcol } => {
4298                    irow.copy_from_slice(&[0, 1]);
4299                    jcol.copy_from_slice(&[0, 1]);
4300                }
4301                SparsityRequest::Values { values, .. } => {
4302                    values.copy_from_slice(&[1.0, 1.0]);
4303                }
4304            }
4305            true
4306        }
4307        fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
4308            *self.finalize_called.borrow_mut() = Some((sol.x.to_vec(), sol.obj_value));
4309        }
4310    }
4311
4312    #[test]
4313    fn application_sqp_path_solves_convex_eq_nlp_and_finalizes() {
4314        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
4315        let tnlp = std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
4316            finalize_called: std::rc::Rc::clone(&finalize_slot),
4317        }));
4318
4319        let mut app = IpoptApplication::new();
4320        app.initialize().unwrap();
4321        app.initialize_with_options_str("algorithm active-set-sqp\n")
4322            .unwrap();
4323        let status = app.optimize_tnlp(tnlp);
4324        assert_eq!(status, ApplicationReturnStatus::SolveSucceeded);
4325
4326        // The TNLP's finalize_solution must have been invoked.
4327        let recv = finalize_slot.borrow().clone();
4328        let (x_recv, obj_recv) = recv.expect("finalize_solution was not called");
4329        assert_eq!(x_recv.len(), 2);
4330        assert!((x_recv[0] - 0.0).abs() < 1e-6, "x[0] = {}", x_recv[0]);
4331        assert!((x_recv[1] - 1.0).abs() < 1e-6, "x[1] = {}", x_recv[1]);
4332        assert!(
4333            (obj_recv - (-1.5)).abs() < 1e-6,
4334            "obj = {} but expected -1.5",
4335            obj_recv
4336        );
4337    }
4338
4339    #[test]
4340    fn application_routes_to_sqp_case_insensitively() {
4341        let mut app = IpoptApplication::new();
4342        app.initialize().unwrap();
4343        app.initialize_with_options_str("algorithm Active-Set-SQP\n")
4344            .unwrap();
4345        // get_string_value may return the value as-stored (no
4346        // normalization); the dispatch must handle case
4347        // insensitively per the c11 design choice.
4348        assert!(app.is_sqp_algorithm_selected());
4349    }
4350
4351    #[test]
4352    fn application_constructs_and_loads_options() {
4353        let mut app = IpoptApplication::new();
4354        app.initialize().unwrap();
4355        // ipopt.opt-style file: an integer-typed option registered by
4356        // the Interfaces layer.
4357        app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
4358            .unwrap();
4359        let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
4360        assert!(found);
4361        assert_eq!(level, 5);
4362    }
4363
4364    #[test]
4365    fn application_sqp_suboptions_propagate_to_builder() {
4366        // All SQP suboptions are read by algorithm_builder_snapshot
4367        // and baked into the builder's `sqp` field.
4368        let mut app = IpoptApplication::new();
4369        app.initialize().unwrap();
4370        app.initialize_with_options_str(
4371            "algorithm active-set-sqp\n\
4372             sqp_globalization l1-elastic\n\
4373             sqp_hessian lbfgs\n\
4374             sqp_max_iter 17\n\
4375             sqp_tol 1e-7\n\
4376             sqp_constr_viol_tol 1e-5\n\
4377             sqp_dual_inf_tol 1e-3\n\
4378             sqp_l1_penalty 2.5\n\
4379             sqp_bt_reduction 0.25\n\
4380             sqp_bt_min_alpha 1e-10\n\
4381             sqp_print_level 2\n\
4382             sqp_lbfgs_max_history 12\n",
4383        )
4384        .unwrap();
4385        let snap = app.algorithm_builder_snapshot();
4386        assert_eq!(
4387            snap.sqp.globalization,
4388            crate::sqp::SqpGlobalization::L1Elastic
4389        );
4390        assert_eq!(snap.sqp.hessian, crate::sqp::SqpHessianSource::Lbfgs);
4391        assert_eq!(snap.sqp.max_iter, 17);
4392        assert!((snap.sqp.tol - 1e-7).abs() < 1e-18);
4393        assert!((snap.sqp.constr_viol_tol - 1e-5).abs() < 1e-18);
4394        assert!((snap.sqp.dual_inf_tol - 1e-3).abs() < 1e-18);
4395        assert!((snap.sqp.l1_penalty - 2.5).abs() < 1e-18);
4396        assert!((snap.sqp.bt_reduction - 0.25).abs() < 1e-18);
4397        assert!((snap.sqp.bt_min_alpha - 1e-10).abs() < 1e-18);
4398        assert_eq!(snap.sqp.print_level, 2);
4399        assert_eq!(snap.sqp.lbfgs_max_history, 12);
4400    }
4401
4402    /// Every `sqp_qp_*` key that [`apply_qp_subproblem_options`] reads must
4403    /// actually be *registered*, and must reach `pounce_qp::QpOptions`.
4404    ///
4405    /// The whole family was readable-but-unregistered (gh #360): the options
4406    /// registry rejected each one with OPTION_INVALID, so the reader was
4407    /// unreachable and the documented knobs were unusable. This is the guard
4408    /// that class of omission needs — it fails both if a key stops being
4409    /// registered and if a newly-read key is never registered at all.
4410    #[test]
4411    fn application_sqp_qp_subproblem_options_are_registered_and_propagate() {
4412        use pounce_qp::AntiCyclingChoice;
4413
4414        // Source of truth: the keys `apply_qp_subproblem_options` reads.
4415        // Kept in step with that function by the round-trip assertions below.
4416        let mut app = IpoptApplication::new();
4417        app.initialize().unwrap();
4418        app.initialize_with_options_str(
4419            "algorithm active-set-sqp\n\
4420             sqp_qp_max_iter 37\n\
4421             sqp_qp_feas_tol 1e-7\n\
4422             sqp_qp_opt_tol 2e-7\n\
4423             sqp_qp_elastic_gamma 1e4\n\
4424             sqp_qp_anti_cycling bland\n\
4425             sqp_qp_use_schur_updates yes\n\
4426             sqp_qp_max_schur_updates_before_refactor 12\n\
4427             sqp_qp_use_homotopy yes\n",
4428        )
4429        .expect("every sqp_qp_* option must be registered (gh #360)");
4430
4431        let qp = &app.algorithm_builder_snapshot().sqp_qp;
4432        assert_eq!(qp.max_iter, 37);
4433        assert!((qp.feas_tol - 1e-7).abs() < 1e-20);
4434        assert!((qp.opt_tol - 2e-7).abs() < 1e-20);
4435        assert!((qp.elastic_gamma - 1e4).abs() < 1e-9);
4436        assert_eq!(qp.anti_cycling, AntiCyclingChoice::Bland);
4437        // The Schur update path was implemented but reachable only through
4438        // `SqpAlgorithm::with_qp_options`, so no CLI/library user could turn
4439        // it on — the same unreachable-knob defect gh #360 fixed for the rest
4440        // of this family.
4441        assert!(qp.use_schur_updates);
4442        assert_eq!(qp.max_schur_updates_before_refactor, 12);
4443        assert!(qp.use_homotopy);
4444
4445        // Untouched options must keep the pounce-qp defaults, not be
4446        // overwritten with zeros by the "explicitly set" gate.
4447        let mut app = IpoptApplication::new();
4448        app.initialize().unwrap();
4449        app.initialize_with_options_str("algorithm active-set-sqp\n")
4450            .unwrap();
4451        let defaults = pounce_qp::QpOptions::default();
4452        let qp = &app.algorithm_builder_snapshot().sqp_qp;
4453        assert_eq!(qp.max_iter, defaults.max_iter);
4454        assert!((qp.feas_tol - defaults.feas_tol).abs() < 1e-20);
4455        assert!((qp.opt_tol - defaults.opt_tol).abs() < 1e-20);
4456        assert_eq!(qp.anti_cycling, defaults.anti_cycling);
4457        // Default stays OFF, and that is a measured choice: enabling it breaks
4458        // 9 of the 46 Maros-Meszaros instances the default path solves
4459        // correctly. Do not flip this without re-running that comparison.
4460        assert!(!qp.use_schur_updates);
4461        assert_eq!(
4462            qp.max_schur_updates_before_refactor,
4463            defaults.max_schur_updates_before_refactor
4464        );
4465    }
4466
4467    /// The other direction of the gh #360 guard: every **registered**
4468    /// `sqp_qp_*` option must be one `apply_qp_subproblem_options` actually
4469    /// reads.
4470    ///
4471    /// The sister test above checks read-keys-are-registered. It cannot catch
4472    /// the inverse, and the inverse happened: `sqp_qp_use_homotopy` was
4473    /// registered with the homotopy work and never wired into the reader, so
4474    /// setting it on the SQP path silently did nothing while the option's own
4475    /// documentation described what it would do. A registered knob that no
4476    /// code reads is worse than a missing one — it validates, it accepts a
4477    /// value, and it lies.
4478    ///
4479    /// Adding a new `sqp_qp_*` option therefore fails here until it is both
4480    /// read by `apply_qp_subproblem_options` and asserted in the round-trip
4481    /// test above.
4482    #[test]
4483    fn application_every_registered_sqp_qp_option_is_read_by_the_subproblem_reader() {
4484        let mut app = IpoptApplication::new();
4485        app.initialize().unwrap();
4486
4487        let mut registered: Vec<String> = app
4488            .registered_options()
4489            .registered_options_in_order()
4490            .iter()
4491            .map(|o| o.name.clone())
4492            .filter(|n| n.starts_with("sqp_qp_"))
4493            .collect();
4494        registered.sort();
4495
4496        // Kept in step by hand with the `options.get_*_value("sqp_qp_…")`
4497        // calls in `apply_qp_subproblem_options`, and cross-checked by the
4498        // round-trip assertions in the sister test.
4499        let mut read_by_the_reader = vec![
4500            "sqp_qp_anti_cycling".to_string(),
4501            "sqp_qp_elastic_gamma".to_string(),
4502            "sqp_qp_feas_tol".to_string(),
4503            "sqp_qp_max_iter".to_string(),
4504            "sqp_qp_max_schur_updates_before_refactor".to_string(),
4505            "sqp_qp_opt_tol".to_string(),
4506            "sqp_qp_use_homotopy".to_string(),
4507            "sqp_qp_use_schur_updates".to_string(),
4508        ];
4509        read_by_the_reader.sort();
4510
4511        assert_eq!(
4512            registered, read_by_the_reader,
4513            "registered sqp_qp_* options and the ones \
4514             `apply_qp_subproblem_options` reads have diverged. A key that is \
4515             registered but unread is a no-op knob with working documentation \
4516             (that is how `sqp_qp_use_homotopy` shipped); a key read but not \
4517             registered is gh #360. Wire it up in both places, assert it in \
4518             `application_sqp_qp_subproblem_options_are_registered_and_propagate`, \
4519             then add it here."
4520        );
4521    }
4522
4523    #[test]
4524    fn application_sqp_hessian_approximation_maps_to_damped_bfgs() {
4525        // The frontend sets `hessian_approximation = limited-memory` when no
4526        // exact Lagrangian Hessian is available (e.g. `pounce.minimize` with
4527        // no `hess`). On the active-set-SQP path that must resolve to the
4528        // dense Powell-damped BFGS, NOT the limited-memory update: L-BFGS
4529        // materializes the same dense Hessian for the QP subproblem yet stalls
4530        // (`Search_Direction_Becomes_Too_Small` / wrong `x`) on convex QPs with
4531        // an active inequality (issue #358); damped-BFGS solves them.
4532        let mut app = IpoptApplication::new();
4533        app.initialize().unwrap();
4534        app.initialize_with_options_str(
4535            "algorithm active-set-sqp\n\
4536             hessian_approximation limited-memory\n",
4537        )
4538        .unwrap();
4539        assert_eq!(
4540            app.algorithm_builder_snapshot().sqp.hessian,
4541            crate::sqp::SqpHessianSource::DampedBfgs
4542        );
4543
4544        // An explicit `sqp_hessian = lbfgs` is still honored (it is read after
4545        // `hessian_approximation`, so it wins): callers who genuinely want the
4546        // limited-memory update can still ask for it.
4547        let mut app = IpoptApplication::new();
4548        app.initialize().unwrap();
4549        app.initialize_with_options_str(
4550            "algorithm active-set-sqp\n\
4551             hessian_approximation limited-memory\n\
4552             sqp_hessian lbfgs\n",
4553        )
4554        .unwrap();
4555        assert_eq!(
4556            app.algorithm_builder_snapshot().sqp.hessian,
4557            crate::sqp::SqpHessianSource::Lbfgs
4558        );
4559    }
4560
4561    /// `builder.linear_solver` must name the backend that will actually be
4562    /// built, not the one the option string asked for.
4563    ///
4564    /// MA57 is behind the optional `ma57` cargo feature; without it
4565    /// `default_backend_factory` silently substitutes FERAL. Recording `Ma57`
4566    /// anyway made the field disagree with reality, and the Schur KKT gate in
4567    /// `alg_builder::build_with_backend` (which tests `== Feral`) consumed that
4568    /// disagreement — so `set_kkt_schur_block()` never engaged on the default
4569    /// pure-Rust build for any user, while the transparent fallback kept every
4570    /// answer correct and every test green.
4571    #[test]
4572    fn application_linear_solver_records_the_effective_backend() {
4573        // Default options resolve to FERAL in *every* build. The registry
4574        // used to default to upstream's "ma57", which meant an HSL build
4575        // silently ran MA57 without being asked and a pure-Rust build
4576        // advertised a backend it did not contain; the default now names
4577        // pounce's own solver and HSL is opt-in (gh#483 follow-up).
4578        let mut app = IpoptApplication::new();
4579        app.initialize().unwrap();
4580        assert_eq!(
4581            app.algorithm_builder_from_options().linear_solver,
4582            LinearSolverChoice::Feral,
4583            "the registered default is `feral`, in an ma57 build too"
4584        );
4585
4586        // An explicit ma57 request resolves the same way.
4587        let mut app = IpoptApplication::new();
4588        app.initialize().unwrap();
4589        app.initialize_with_options_str("linear_solver ma57\n")
4590            .unwrap();
4591        let got = app.algorithm_builder_from_options().linear_solver;
4592        if cfg!(feature = "ma57") {
4593            assert_eq!(got, LinearSolverChoice::Ma57);
4594        } else {
4595            assert_eq!(got, LinearSolverChoice::Feral);
4596        }
4597
4598        // An explicit feral request is honored in every build.
4599        let mut app = IpoptApplication::new();
4600        app.initialize().unwrap();
4601        app.initialize_with_options_str("linear_solver feral\n")
4602            .unwrap();
4603        assert_eq!(
4604            app.algorithm_builder_from_options().linear_solver,
4605            LinearSolverChoice::Feral
4606        );
4607    }
4608
4609    #[test]
4610    fn application_limited_memory_options_propagate_to_builder() {
4611        use crate::hess::lim_mem_quasi_newton::UpdateType;
4612
4613        // Default: no options set -> bit-exact with Ipopt's default
4614        // (bfgs, history 6). This is what the IPM path runs unless the
4615        // user opts in, so it must not drift.
4616        let mut app = IpoptApplication::new();
4617        app.initialize().unwrap();
4618        let def = app.algorithm_builder_from_options();
4619        assert_eq!(def.limited_memory_update_type, UpdateType::Bfgs);
4620        assert_eq!(def.limited_memory_max_history, 6);
4621
4622        // `limited_memory_update_type=sr1` and a custom history length
4623        // must reach the builder (these were registered upstream but
4624        // read nowhere on the IPM path before — see #131). Honoring
4625        // them is what lets SR1 break the monotone L-BFGS stall.
4626        let mut app = IpoptApplication::new();
4627        app.initialize().unwrap();
4628        app.initialize_with_options_str(
4629            "hessian_approximation limited-memory\n\
4630             limited_memory_update_type sr1\n\
4631             limited_memory_max_history 9\n",
4632        )
4633        .unwrap();
4634        let snap = app.algorithm_builder_from_options();
4635        assert_eq!(snap.limited_memory_update_type, UpdateType::Sr1);
4636        assert_eq!(snap.limited_memory_max_history, 9);
4637    }
4638
4639    #[test]
4640    fn application_sqp_warm_start_round_trip() {
4641        // Drive the convex-equality TNLP through the SQP path
4642        // twice. The first solve produces a working set; the
4643        // second is warm-started from it. The second must converge
4644        // with zero QP solves (the first KKT check declares
4645        // optimality immediately).
4646        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
4647        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
4648            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
4649                finalize_called: std::rc::Rc::clone(&finalize_slot),
4650            }));
4651
4652        let mut app = IpoptApplication::new();
4653        app.initialize().unwrap();
4654        app.initialize_with_options_str("algorithm active-set-sqp\n")
4655            .unwrap();
4656
4657        // Cold solve.
4658        let status_a = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
4659        assert_eq!(status_a, ApplicationReturnStatus::SolveSucceeded);
4660        let ws = app.last_sqp_working_set().cloned();
4661        assert!(ws.is_some(), "cold solve must yield a working set");
4662
4663        // Build the warm-start iterate from the converged finalize
4664        // payload (just x; pad multipliers to 0 since the test
4665        // problem is convex).
4666        let (x_recv, _) = finalize_slot.borrow().clone().unwrap();
4667        let warm = crate::sqp::SqpIterates {
4668            x: x_recv,
4669            lambda_g: vec![1.0],
4670            lambda_x: vec![0.0, 0.0],
4671            working: ws,
4672        };
4673        app.set_sqp_warm_start(warm);
4674
4675        // Warm solve.
4676        let status_b = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
4677        assert_eq!(status_b, ApplicationReturnStatus::SolveSucceeded);
4678        assert!(app.last_sqp_working_set().is_some());
4679    }
4680
4681    #[test]
4682    fn application_sqp_warm_start_auto_clears_after_use() {
4683        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
4684        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
4685            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
4686                finalize_called: std::rc::Rc::clone(&finalize_slot),
4687            }));
4688        let mut app = IpoptApplication::new();
4689        app.initialize().unwrap();
4690        app.initialize_with_options_str("algorithm active-set-sqp\n")
4691            .unwrap();
4692        app.set_sqp_warm_start(crate::sqp::SqpIterates {
4693            x: vec![0.0, 1.0],
4694            lambda_g: vec![1.0],
4695            lambda_x: vec![0.0, 0.0],
4696            working: None,
4697        });
4698        assert!(app.sqp_warm_start.is_some());
4699        let _ = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
4700        assert!(
4701            app.sqp_warm_start.is_none(),
4702            "warm-start input must be auto-cleared after use"
4703        );
4704    }
4705
4706    #[test]
4707    fn application_sqp_suboptions_default_when_unset() {
4708        // Without any sqp_* settings, the snapshot should equal
4709        // SqpOptions::default().
4710        let mut app = IpoptApplication::new();
4711        app.initialize().unwrap();
4712        let snap = app.algorithm_builder_snapshot();
4713        let d = crate::sqp::SqpOptions::default();
4714        assert_eq!(snap.sqp.globalization, d.globalization);
4715        assert_eq!(snap.sqp.hessian, d.hessian);
4716        assert_eq!(snap.sqp.max_iter, d.max_iter);
4717        assert!((snap.sqp.tol - d.tol).abs() < 1e-18);
4718        assert!((snap.sqp.constr_viol_tol - d.constr_viol_tol).abs() < 1e-18);
4719        assert!((snap.sqp.dual_inf_tol - d.dual_inf_tol).abs() < 1e-18);
4720        assert!((snap.sqp.l1_penalty - d.l1_penalty).abs() < 1e-18);
4721        assert!((snap.sqp.bt_reduction - d.bt_reduction).abs() < 1e-18);
4722        assert!((snap.sqp.bt_min_alpha - d.bt_min_alpha).abs() < 1e-18);
4723        assert_eq!(snap.sqp.print_level, d.print_level);
4724        assert_eq!(snap.sqp.lbfgs_max_history, d.lbfgs_max_history);
4725    }
4726
4727    #[test]
4728    fn application_reports_problem_dimensions() {
4729        let app = IpoptApplication::new();
4730        let mut tnlp = Hs071Stub;
4731        let info = app.problem_dimensions(&mut tnlp).unwrap();
4732        assert_eq!(info.n, 4);
4733        assert_eq!(info.m, 2);
4734        assert_eq!(info.nnz_jac_g, 8);
4735        assert_eq!(info.nnz_h_lag, 10);
4736    }
4737}