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::ipopt_alg::IpoptAlgorithm;
25use crate::ipopt_cq::IpoptCalculatedQuantities;
26use crate::ipopt_data::IpoptData as AlgIpoptData;
27use crate::ipopt_nlp::IpoptNlp;
28use crate::iterates_vector::IteratesVector;
29use crate::restoration::RestorationPhase;
30use crate::upstream_options::register_all_upstream_options;
31
32/// Factory that constructs a fresh restoration-phase strategy on
33/// demand. The outer algorithm owns at most one restoration object,
34/// so the factory is invoked once per `optimize_tnlp` call. The
35/// factory is `FnMut` to allow callers to capture a builder that
36/// internally reuses caches across builds.
37pub type RestorationFactory = Box<dyn FnMut() -> Box<dyn RestorationPhase>>;
38
39/// Provider that mints fresh [`RestorationFactory`] instances on
40/// demand. Used by drivers that need to run the inner IPM more than
41/// once per `optimize_tnlp` call — notably the Phase-3 ℓ₁-exact
42/// penalty-barrier outer loop (pounce#10), which the existing
43/// `RestorationFactory` cannot support because pounce's default
44/// `make_default_restoration_factory` is a one-shot. Callers wire
45/// this via [`IpoptApplication::set_restoration_factory_provider`].
46pub type RestorationFactoryProvider = Box<dyn FnMut() -> RestorationFactory>;
47
48/// Callback fired by [`IpoptApplication::optimize_constrained`] once
49/// the IPM has converged (status `SolveSucceeded` or
50/// `SolvedToAcceptableLevel`) and before the user TNLP's
51/// `finalize_solution` runs. Receives borrowed handles into the
52/// algorithm's converged state.
53///
54/// **Use case**: post-optimal sensitivity analysis (pounce#7 /
55/// `pounce-sensitivity`). The callback receives a shared handle to
56/// the PD solver so a `SensBacksolver` adapter can run backsolves
57/// against the converged KKT factor — and so that handle may outlive
58/// the call frame (e.g. the public `Solver` session API retains the
59/// factor for repeated `parametric_step` / `kkt_solve` calls);
60/// receives the data / cq / nlp handles so the adapter can reproduce
61/// the augmented-system coefficient layout the IPM converged at.
62///
63/// **Not** the same as `set_intermediate_callback` (per-iteration
64/// progress notification) — this fires exactly once per `optimize_*`
65/// call, only on success.
66pub type ConvergedCallback = Box<
67    dyn FnMut(
68        &crate::ipopt_data::IpoptDataHandle,
69        &crate::ipopt_cq::IpoptCqHandle,
70        &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
71        Rc<RefCell<crate::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
72    ),
73>;
74use pounce_common::diagnostics::DiagnosticsState;
75use pounce_common::exception::{ExceptionKind, SolverException};
76use pounce_common::journalist::{JournalLevel, Journalist};
77use pounce_common::options_list::OptionsList;
78use pounce_common::reg_options::{PrintOptionsMode, RegisteredOptions};
79use pounce_common::timing::TimingStatistics;
80use pounce_common::types::{Index, Number};
81use pounce_linalg::dense_vector::DenseVectorSpace;
82use pounce_linsol::summary::LinearSolverSummary;
83use pounce_linsol::SparseSymLinearSolverInterface;
84use pounce_nlp::alg_types::SolverReturn;
85use pounce_nlp::orig_ipopt_nlp::{NoScaling, OrigIpoptNlp, ScalingMethod};
86use pounce_nlp::return_codes::ApplicationReturnStatus;
87use pounce_nlp::solve_statistics::SolveStatistics;
88use pounce_nlp::tnlp::{
89    IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, NlpInfo, Solution, TNLP,
90};
91use pounce_nlp::tnlp_adapter::{
92    FixedVarTreatment, TNLPAdapter, DEFAULT_NLP_LOWER_BOUND_INF, DEFAULT_NLP_UPPER_BOUND_INF,
93};
94use std::cell::RefCell;
95use std::fmt;
96use std::path::Path;
97use std::rc::Rc;
98use std::sync::{Arc, Mutex};
99use std::time::Instant;
100
101pub struct IpoptApplication {
102    options: OptionsList,
103    reg_options: Rc<RegisteredOptions>,
104    journalist: Rc<Journalist>,
105    statistics: RefCell<SolveStatistics>,
106    /// Shared per-subsystem timing accumulator. Re-created at the top of
107    /// every solve (so back-to-back `optimize_tnlp` calls don't bleed
108    /// timings across invocations) and handed to the data, the NLP, and
109    /// any other consumer via `Rc`. Reported by [`Self::timing_stats`]
110    /// after the solve completes.
111    timing: RefCell<Rc<TimingStatistics>>,
112    /// Optional override factory for the symmetric linear-solver
113    /// backend. When `None`, we ship the workspace default (MA57 via
114    /// `pounce-hsl`). Tests can plug a stub via [`Self::set_linear_backend_factory`].
115    linear_backend_factory: Option<LinearBackendFactory>,
116    /// Optional factory for the restoration phase. Lives outside this
117    /// crate because `pounce-algorithm` cannot depend on
118    /// `pounce-restoration` (the dep edge is the other way). Callers
119    /// that need restoration plug a factory via
120    /// [`Self::set_restoration_factory`]; when unset, the outer
121    /// algorithm runs without a restoration fallback and surfaces
122    /// `RestorationFailure` as soon as the line-search would otherwise
123    /// jump into restoration.
124    restoration_factory: Option<RestorationFactory>,
125    /// Shared diagnostic-dump state, installed by the CLI when the
126    /// user passes `--dump <cat>:<spec>`. When set, the application
127    /// propagates an `Rc<DiagnosticsState>` into [`IpoptAlgorithm`]
128    /// via [`IpoptAlgorithm::with_diagnostics`] so the KKT solver and
129    /// other dump sites can consult per-iter gating.
130    diagnostics: Option<Rc<DiagnosticsState>>,
131    /// Optional interactive debugger hook. When set, it is moved into
132    /// the main [`IpoptAlgorithm`] for the next `optimize_*` call via
133    /// [`IpoptAlgorithm::with_debug_hook`], so a REPL or agent can pause
134    /// at each iteration to inspect / mutate live state. Consumed on use
135    /// (one solve per installed hook).
136    debug_hook: Option<std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>>,
137    /// Provider for the BNW outer loop (pounce#10 Phase 3). When set,
138    /// `optimize_constrained` consults the provider before each inner
139    /// solve, replacing `restoration_factory` with a fresh one so
140    /// multi-pass drivers can run the inner IPM repeatedly without
141    /// tripping the default factory's one-shot guard.
142    restoration_factory_provider: Option<RestorationFactoryProvider>,
143    /// Optional hook fired once per `optimize_*` call on convergence,
144    /// before the user TNLP's `finalize_solution`. See
145    /// [`ConvergedCallback`].
146    on_converged: Option<ConvergedCallback>,
147    /// When `true`, the per-iteration `IterRecord` trajectory is
148    /// captured into [`SolveStatistics::iterations`] for downstream
149    /// consumers (the JSON solve report in pounce-cli, pounce#8). Off
150    /// by default so library callers that never read the iterations
151    /// vector don't pay the per-iter alloc.
152    record_iter_history: bool,
153    /// Shared sink that the linear-solver backend writes a rolling
154    /// [`LinearSolverSummary`] into after every factor. Reset at the
155    /// top of every solve (so back-to-back `optimize_tnlp` calls don't
156    /// bleed stats across invocations) and read out via
157    /// [`Self::linear_solver_summary`] once the solve returns. Only
158    /// the workspace-default FERAL backend (via
159    /// [`default_backend_factory_with_sink`]) wires the sink today;
160    /// custom factories plugged through [`Self::set_linear_backend_factory`]
161    /// and the HSL MA57 backend leave the sink empty.
162    linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
163    /// Phase 5c (§6) SQP warm-start input. When `Some`, the next
164    /// `optimize_tnlp` call on the SQP path consumes the iterate
165    /// instead of cold-starting; consumed once per solve, then
166    /// auto-cleared. The IPM path ignores this field. Wire-set
167    /// via [`Self::set_sqp_warm_start`].
168    sqp_warm_start: Option<crate::sqp::SqpIterates>,
169    /// Phase 5c (§6) SQP warm-start output. Populated by every
170    /// `optimize_sqp_tnlp` call with the final QP working set.
171    /// Stays valid until the next solve (which overwrites it).
172    /// Accessed via [`Self::last_sqp_working_set`].
173    sqp_last_working_set: Option<pounce_qp::WorkingSet>,
174    /// Full primal-dual warm-start iterate for the IPM path, captured by
175    /// the interactive debugger's `resolve` command. When `Some`, the
176    /// next `optimize_tnlp` installs this 8-vector (algorithm space)
177    /// directly onto `data.curr` before the iterate initializer runs, so
178    /// a warm `resolve` continues from the paused interior point rather
179    /// than cold-restarting the duals. Consumed once per solve, then
180    /// auto-cleared. Requires `warm_start_init_point=yes` so the
181    /// re-optimize branch of `WarmStartIterateInitializer` keeps the
182    /// installed iterate. Wire-set via [`Self::set_warm_start_iterate`].
183    warm_start_iterate: Option<crate::debug::IterateSnapshot>,
184}
185
186impl fmt::Debug for IpoptApplication {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.debug_struct("IpoptApplication")
189            .field("options", &self.options)
190            .field("statistics", &self.statistics)
191            .finish_non_exhaustive()
192    }
193}
194
195impl Default for IpoptApplication {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl IpoptApplication {
202    /// New application with empty options and a default journalist.
203    /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
204    pub fn new() -> Self {
205        let reg = RegisteredOptions::default();
206        // Registration of a fresh registry can only fail on a duplicate
207        // name, which would be a programming error in `reg_op`.
208        register_all_upstream_options(&reg)
209            .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
210        pounce_presolve::register_options(&reg)
211            .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
212        let reg = Rc::new(reg);
213        Self {
214            options: OptionsList::with_registered(Rc::clone(&reg)),
215            reg_options: reg,
216            journalist: Rc::new(Journalist::new()),
217            statistics: RefCell::new(SolveStatistics::new()),
218            timing: RefCell::new(Rc::new(TimingStatistics::new())),
219            linear_backend_factory: None,
220            restoration_factory: None,
221            diagnostics: None,
222            debug_hook: None,
223            restoration_factory_provider: None,
224            on_converged: None,
225            record_iter_history: false,
226            linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
227            sqp_warm_start: None,
228            sqp_last_working_set: None,
229            warm_start_iterate: None,
230        }
231    }
232
233    pub fn options(&self) -> &OptionsList {
234        &self.options
235    }
236
237    pub fn options_mut(&mut self) -> &mut OptionsList {
238        &mut self.options
239    }
240
241    pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
242        &self.reg_options
243    }
244
245    pub fn journalist(&self) -> &Rc<Journalist> {
246        &self.journalist
247    }
248
249    /// Plug a custom symmetric-linear-solver factory. Useful for tests
250    /// that want to swap MA57 for a stub. Production callers should
251    /// leave this unset — the default ([`default_backend_factory`])
252    /// returns the workspace's MA57 binding.
253    pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
254        self.linear_backend_factory = Some(factory);
255    }
256
257    /// Plug a restoration-phase factory. Called once per
258    /// `optimize_tnlp` invocation to mint a fresh
259    /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
260    /// its line-search restoration fallback. Lives behind a setter
261    /// (rather than at construction) because the concrete restoration
262    /// strategies live in `pounce-restoration`, which depends on this
263    /// crate; consumers in `pounce-cli` / integration tests wire the
264    /// factory at the application boundary.
265    pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
266        self.restoration_factory = Some(factory);
267    }
268
269    /// Install the shared diagnostics state. Once set, every
270    /// subsequent `optimize_tnlp` call forwards the state into the
271    /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
272    /// solver can emit `--dump kkt:...` artifacts.
273    pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
274        self.diagnostics = Some(diag);
275    }
276
277    /// Install an interactive debugger hook for the next `optimize_*`
278    /// call. The hook is moved into the main [`IpoptAlgorithm`] and
279    /// consumed by that solve; reinstall it to debug a subsequent solve.
280    pub fn set_debug_hook(
281        &mut self,
282        hook: std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>,
283    ) {
284        self.debug_hook = Some(hook);
285    }
286
287    /// Read-side accessor for the installed diagnostics state, if any.
288    /// Lets the CLI write the top-level manifest/timing files after
289    /// the solve completes.
290    pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
291        self.diagnostics.as_ref().map(Rc::clone)
292    }
293
294    /// Plug a restoration-phase **factory provider** for drivers that
295    /// need to run the inner IPM more than once per `optimize_tnlp`
296    /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
297    /// pounce#10). On each inner solve, the application consults the
298    /// provider to mint a fresh [`RestorationFactory`], replacing any
299    /// stale one, so the default one-shot restoration factory does
300    /// not panic on its second invocation. If both `set_restoration_factory`
301    /// and this are configured, the provider wins.
302    pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
303        self.restoration_factory_provider = Some(provider);
304    }
305
306    /// Register a callback to run once the IPM has converged (status
307    /// [`ApplicationReturnStatus::SolveSucceeded`] or
308    /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
309    /// `finalize_solution` flows back to the TNLP. See
310    /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
311    pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
312        self.on_converged = Some(cb);
313    }
314
315    /// Enable per-iteration trajectory capture. After the solve
316    /// returns, [`Self::statistics()`] exposes
317    /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
318    /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
319    /// per accepted iterate. Off by default — the `pounce_sens` and
320    /// `pounce` binaries opt in when `--json-output` is passed.
321    pub fn enable_iter_history(&mut self) {
322        self.record_iter_history = true;
323    }
324
325    /// Read an `ipopt.opt`-format options file. Equivalent to
326    /// `IpoptApplication::Initialize(const std::string& options_file)`.
327    pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
328        let txt = std::fs::read_to_string(path).map_err(|e| {
329            SolverException::new(
330                ExceptionKind::IPOPT_APPLICATION_ERROR,
331                format!("could not read options file {}: {}", path.display(), e),
332                file!(),
333                line!() as Index,
334            )
335        })?;
336        self.options.read_from_str(&txt, true)?;
337        self.open_output_file_journal();
338        Ok(())
339    }
340
341    /// Read options from a string in `ipopt.opt` format. Useful for
342    /// tests and embedded callers.
343    pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
344        self.options.read_from_str(s, true)?;
345        self.open_output_file_journal();
346        Ok(())
347    }
348
349    /// Honor `output_file` / `file_print_level` / `file_append`: when
350    /// `output_file` is non-empty, attach a `FileJournal` named
351    /// `"OutputFile:<fname>"` at the requested level. Mirrors
352    /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
353    /// No-op if `output_file` is unset, empty, or could not be opened.
354    ///
355    /// NOTE: pounce's iteration output currently bypasses the
356    /// journalist and writes directly to stdout. The file journal is
357    /// attached and the timing report (gated by `print_timing_statistics`)
358    /// is mirrored to it; per-iter rows will start landing in the file
359    /// once the iter-output path is routed through the journalist.
360    fn open_output_file_journal(&self) {
361        let fname = match self.options.get_string_value("output_file", "") {
362            Ok((v, true)) if !v.is_empty() => v,
363            _ => return,
364        };
365        let level_int = self
366            .options
367            .get_integer_value("file_print_level", "")
368            .ok()
369            .and_then(|(v, f)| f.then_some(v))
370            .unwrap_or(5);
371        let level = journal_level_from_int(level_int);
372        let append = self
373            .options
374            .get_bool_value("file_append", "")
375            .ok()
376            .and_then(|(v, f)| f.then_some(v))
377            .unwrap_or(false);
378        let jname = format!("OutputFile:{}", fname);
379        let _ = self
380            .journalist
381            .add_file_journal(&jname, &fname, level, append);
382    }
383
384    /// No-op initialize (just succeeds). Mirrors
385    /// `IpoptApplication::Initialize(bool allow_clobber)` with no
386    /// options file.
387    pub fn initialize(&mut self) -> Result<(), SolverException> {
388        Ok(())
389    }
390
391    /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
392    /// / `file_print_level` options and attaches a matching
393    /// `FileJournal` named `OutputFile:<fname>` to the journalist.
394    /// Returns `false` if the file could not be opened or the option
395    /// store rejected the request (e.g. clamped print level).
396    pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
397        if self
398            .options
399            .set_string_value("output_file", fname, true, false)
400            .is_err()
401        {
402            return false;
403        }
404        if self
405            .options
406            .set_integer_value("file_print_level", print_level as Index, true, false)
407            .is_err()
408        {
409            return false;
410        }
411        let level = journal_level_from_int(print_level);
412        let jname = format!("OutputFile:{}", fname);
413        // Drop any previous file journal so a second call switches files
414        // cleanly. `add_file_journal` would otherwise refuse to attach
415        // a duplicate by name; remove-by-name isn't in the journalist
416        // API, so we settle for the name-collision case here.
417        self.journalist
418            .add_file_journal(&jname, fname, level, false)
419            .is_some()
420    }
421
422    /// Wrap a TNLP and report problem dimensions. Used in tests until
423    /// the full IPM path covers every entry shape.
424    pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
425        tnlp.get_nlp_info()
426    }
427
428    pub fn statistics(&self) -> SolveStatistics {
429        self.statistics.borrow().clone()
430    }
431
432    /// Shared timing accumulator from the most recent `optimize_tnlp`
433    /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
434    /// fields during the solve; consumers read totals out of the
435    /// returned `Rc`. The instance is replaced at the top of every
436    /// subsequent solve, so cloning the `Rc` and holding it past a
437    /// re-solve will give you the previous solve's timings — by design.
438    pub fn timing_stats(&self) -> Rc<TimingStatistics> {
439        Rc::clone(&self.timing.borrow())
440    }
441
442    /// Aggregate linear-solver post-mortem from the most recent
443    /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
444    /// backend ran at least one factor; `None` when no factors were
445    /// recorded (custom factory plugged via
446    /// [`Self::set_linear_backend_factory`], or solve aborted before
447    /// the first KKT factor). Reset at the top of every solve.
448    pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
449        let guard = self.linsol_summary_sink.lock().ok()?;
450        if guard.is_empty() {
451            None
452        } else {
453            Some(guard.clone())
454        }
455    }
456
457    /// Drive a solve.
458    ///
459    /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
460    ///   build a `TNLPAdapter` → `OrigIpoptNlp`, run the
461    ///   [`AlgorithmBuilder`] with the workspace MA57 backend, and
462    ///   call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
463    ///   `ApplicationReturnStatus` mapping mirrors the table in
464    ///   `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
465    ///   SolverReturn map").
466    /// * Unconstrained problems (`m == 0`) keep going through the
467    ///   in-`pounce-nlp` Newton driver so the trivial path is
468    ///   independent of the linear-solver backend.
469    pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
470        // Top-level algorithm dispatch (Phase 5b §7.1). When the
471        // `algorithm` option resolves to "active-set-sqp", route
472        // to the Phase 5b SQP path; otherwise fall through to the
473        // existing IPM flow unchanged.
474        if self.is_sqp_algorithm_selected() {
475            return self.optimize_sqp_tnlp(tnlp);
476        }
477        let info = match tnlp.borrow_mut().get_nlp_info() {
478            Some(info) => info,
479            None => return ApplicationReturnStatus::InvalidProblemDefinition,
480        };
481        // ℓ₁-exact penalty-barrier opt-in (pounce#10).
482        // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
483        // Waltz ρ-escalation loop around the constrained IPM, with a
484        // honest-infeasibility status upgrade when the slacks fail to
485        // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
486        // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
487        // no-op for problems with no equality rows, so the
488        // unconstrained dispatch below is unaffected when there is
489        // nothing to wrap.
490        if info.m > 0 && self.is_l1_penalty_enabled() {
491            if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
492                return status;
493            }
494            // Falls through: wrapper construction failed (inner refused
495            // get_nlp_info / get_bounds_info) or no equality rows to
496            // slack. Standard dispatch runs unmodified.
497        }
498        // Phase 3.5 auto-fallback (pounce#10): if the standard solve
499        // ends in a trigger-class status, retry transparently with
500        // the wrapper. Promote the retry's status only if it returns
501        // SolveSucceeded — otherwise return the original. Skipped if
502        // the user already opted into the wrapper above (this avoids
503        // a double pass and keeps semantics predictable).
504        if info.m > 0 && self.is_l1_fallback_enabled() && !self.is_l1_penalty_enabled() {
505            return self.run_with_l1_fallback(tnlp);
506        }
507        // Every problem — constrained or not — goes through the same
508        // primal-dual IPM, exactly as upstream Ipopt does. There is no
509        // separate "unconstrained Newton" path: the linear-solver
510        // backend (FERAL/MA57) handles the augmented system, so the
511        // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
512        // blowup.
513        self.optimize_constrained(tnlp)
514    }
515
516    /// Read the ℓ₁ wrapper master switch from the OptionsList.
517    /// Default `false` when the option is not set.
518    fn is_l1_penalty_enabled(&self) -> bool {
519        self.options
520            .get_bool_value("l1_exact_penalty_barrier", "")
521            .ok()
522            .and_then(|(v, found)| found.then_some(v))
523            .unwrap_or(false)
524    }
525
526    fn l1_penalty_init(&self) -> Number {
527        self.options
528            .get_numeric_value("l1_penalty_init", "")
529            .ok()
530            .and_then(|(v, found)| found.then_some(v))
531            .unwrap_or(1.0)
532    }
533    fn l1_penalty_max(&self) -> Number {
534        self.options
535            .get_numeric_value("l1_penalty_max", "")
536            .ok()
537            .and_then(|(v, found)| found.then_some(v))
538            .unwrap_or(1.0e6)
539    }
540    fn l1_penalty_increase_factor(&self) -> Number {
541        self.options
542            .get_numeric_value("l1_penalty_increase_factor", "")
543            .ok()
544            .and_then(|(v, found)| found.then_some(v))
545            .unwrap_or(8.0)
546    }
547    fn l1_penalty_max_outer_iter(&self) -> usize {
548        self.options
549            .get_integer_value("l1_penalty_max_outer_iter", "")
550            .ok()
551            .and_then(|(v, found)| found.then_some(v))
552            .unwrap_or(8) as usize
553    }
554    fn l1_slack_tol(&self) -> Number {
555        self.options
556            .get_numeric_value("l1_slack_tol", "")
557            .ok()
558            .and_then(|(v, found)| found.then_some(v))
559            .unwrap_or(1.0e-6)
560    }
561    fn l1_steering_factor(&self) -> Number {
562        self.options
563            .get_numeric_value("l1_steering_factor", "")
564            .ok()
565            .and_then(|(v, found)| found.then_some(v))
566            .unwrap_or(10.0)
567    }
568    fn is_l1_fallback_enabled(&self) -> bool {
569        self.options
570            .get_bool_value("l1_fallback_on_restoration_failure", "")
571            .ok()
572            .and_then(|(v, found)| found.then_some(v))
573            .unwrap_or(false)
574    }
575
576    /// Has the user set `algorithm = active-set-sqp`? Reads the
577    /// string option and matches case-insensitively against the
578    /// design-note §7.1 spelling. Any value other than
579    /// "active-set-sqp" (including absence) routes to the
580    /// default IPM path.
581    /// Stash a warm-start iterate for the SQP path. Consumed by
582    /// the next `optimize_tnlp` call when the `algorithm` option
583    /// resolves to `active-set-sqp`; the IPM path ignores it.
584    /// Phase 5c (§6) — the parametric / MPC warm-start hand-off.
585    ///
586    /// The iterate is auto-cleared after use, so a follow-up
587    /// solve without an intervening `set_sqp_warm_start` call
588    /// cold-starts.
589    pub fn set_sqp_warm_start(&mut self, warm: crate::sqp::SqpIterates) {
590        self.sqp_warm_start = Some(warm);
591    }
592
593    /// Drop any pending warm-start iterate without solving.
594    pub fn clear_sqp_warm_start(&mut self) {
595        self.sqp_warm_start = None;
596    }
597
598    /// Install a full primal-dual warm-start iterate for the next IPM
599    /// `optimize_tnlp`. Captured by the debugger's `resolve` so the
600    /// re-solve continues from the paused interior point. The caller is
601    /// responsible for also enabling `warm_start_init_point=yes` (and
602    /// usually `warm_start_target_mu=<μ>`) so the re-optimize branch of
603    /// `WarmStartIterateInitializer` preserves the installed iterate.
604    /// Consumed once per solve, then auto-cleared.
605    pub fn set_warm_start_iterate(&mut self, snap: crate::debug::IterateSnapshot) {
606        self.warm_start_iterate = Some(snap);
607    }
608
609    /// Return the final QP working set from the most recent SQP
610    /// solve, or `None` if the last solve wasn't SQP, didn't
611    /// produce a working set (cold-start declared the iterate
612    /// optimal before solving any QP), or no SQP solve has run.
613    pub fn last_sqp_working_set(&self) -> Option<&pounce_qp::WorkingSet> {
614        self.sqp_last_working_set.as_ref()
615    }
616
617    fn is_sqp_algorithm_selected(&self) -> bool {
618        match self.options.get_string_value("algorithm", "") {
619            Ok((v, true)) => v.eq_ignore_ascii_case("active-set-sqp"),
620            _ => false,
621        }
622    }
623
624    /// Phase 5b SQP entry point. Builds the same NLP chain
625    /// (`TNLPAdapter` → `OrigIpoptNlp` → `IpoptNlpAdapter`) the
626    /// IPM uses, then runs `SqpAlgorithm::optimize`. Maps the
627    /// `SqpResult.status` back to `ApplicationReturnStatus` and
628    /// hands the final iterate to the user TNLP's
629    /// `finalize_solution` callback via `finalize_via_sqp`.
630    fn optimize_sqp_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
631        use pounce_nlp::orig_ipopt_nlp::OrigIpoptNlp;
632        use pounce_nlp::tnlp_adapter::TNLPAdapter;
633        use pounce_nlp::NoScaling;
634
635        let adapter = match TNLPAdapter::new(Rc::clone(&tnlp)) {
636            Ok(a) => Rc::new(RefCell::new(a)),
637            Err(_) => return ApplicationReturnStatus::InvalidProblemDefinition,
638        };
639        let orig_nlp = match OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)) {
640            Ok(n) => n,
641            Err(_) => return ApplicationReturnStatus::InternalError,
642        };
643        let nlp_rc: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
644
645        let mut sqp_adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(&nlp_rc));
646
647        let mut builder = self.algorithm_builder_snapshot();
648        builder.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
649        let factory = self.make_backend_factory();
650        let mut alg = match builder.build_sqp_with_backend(factory) {
651            Some(a) => a,
652            None => return ApplicationReturnStatus::InternalError,
653        };
654
655        // Phase 5c (§6): consume any stashed warm-start iterate.
656        // `optimize_with_warm_start(warm=None)` is equivalent to
657        // `optimize`, so cold callers see no change.
658        let warm = self.sqp_warm_start.take();
659        let res = match alg.optimize_with_warm_start(&mut sqp_adapter, warm) {
660            Ok(r) => r,
661            Err(e) => {
662                if std::env::var_os("POUNCE_DBG_SQP").is_some() {
663                    tracing::warn!(target: "pounce::sqp", "[SQP] optimize_with_warm_start error: {e:?}");
664                }
665                return ApplicationReturnStatus::InternalError;
666            }
667        };
668        // Stash the result's working set so the next solve in a
669        // sequence can fetch it via `last_sqp_working_set`.
670        self.sqp_last_working_set = res.working_set.clone();
671        // Populate the shared `SolveStatistics` so the Python /
672        // C-API post-solve accessors (`GetIpoptIterCount`,
673        // `info["iter_count"]`, etc.) report the SQP outer-iter
674        // count rather than zero. Constraint-violation /
675        // dual-infeasibility residuals get the SQP-side values
676        // too. The IPM path overwrites this dict on its own
677        // solves, so SQP-vs-IPM mixing across solves stays
678        // honest.
679        {
680            let mut stats = self.statistics.borrow_mut();
681            stats.iteration_count = res.n_iter as Index;
682            stats.final_objective = res.obj;
683            stats.final_dual_inf = res.final_stationarity;
684            stats.final_constr_viol = res.final_constr_viol;
685            stats.final_compl = 0.0; // SQP has no barrier — no compl term.
686        }
687        let (app_status, solver_status) = match res.status {
688            crate::sqp::SqpStatus::Optimal => (
689                ApplicationReturnStatus::SolveSucceeded,
690                pounce_nlp::SolverReturn::Success,
691            ),
692            crate::sqp::SqpStatus::MaxIter => (
693                ApplicationReturnStatus::MaximumIterationsExceeded,
694                pounce_nlp::SolverReturn::MaxiterExceeded,
695            ),
696            crate::sqp::SqpStatus::InfeasibleSubproblem => (
697                ApplicationReturnStatus::InfeasibleProblemDetected,
698                pounce_nlp::SolverReturn::LocalInfeasibility,
699            ),
700            crate::sqp::SqpStatus::LineSearchFailed => (
701                ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
702                pounce_nlp::SolverReturn::ErrorInStepComputation,
703            ),
704        };
705
706        // Forward to the user TNLP's finalize_solution. We pass
707        // the SQP iterate and recovered multipliers via the
708        // OrigIpoptNlp's lifting hooks. Failure here is silent
709        // (we still return the algorithm's status) — the user
710        // sees the right ApplicationReturnStatus regardless.
711        let _ = finalize_via_sqp(&nlp_rc, &res, solver_status, &tnlp);
712
713        app_status
714    }
715
716    /// Build a *copy* of the algorithm builder configured per the
717    /// current options. The SQP path uses this so it gets a
718    /// fresh builder without mutating the application's state.
719    fn algorithm_builder_snapshot(&self) -> AlgorithmBuilder {
720        let mut builder = AlgorithmBuilder::default();
721        apply_sqp_options(&self.options, &mut builder.sqp);
722        builder
723    }
724
725    /// Construct a LinearBackendFactory honoring the
726    /// `linear_solver` option. Default FERAL; HSL MA57 when
727    /// built with the `ma57` feature.
728    fn make_backend_factory(&self) -> LinearBackendFactory {
729        Box::new(
730            |_choice| -> Box<dyn pounce_linsol::SparseSymLinearSolverInterface> {
731                Box::new(pounce_feral::FeralSolverInterface::new())
732            },
733        )
734    }
735
736    /// Phase 3.5 auto-fallback driver.
737    ///
738    /// Runs the standard solve (no wrapper) first. If it ends in a
739    /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
740    /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
741    /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
742    /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
743    /// it returns `Solve_Succeeded`; otherwise returns the original
744    /// status.
745    ///
746    /// Caveat: the user TNLP's `finalize_solution` runs once per
747    /// attempt. When the retry doesn't promote, the user's captured
748    /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
749    /// point) even though the returned status is the original's.
750    /// Documented on the option's help text; tightening this is a
751    /// Phase-4 follow-up.
752    fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
753        // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
754        // reached for `m > 0`, so `optimize_constrained` is exact.
755        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
756        if !is_l1_fallback_trigger(first_status) {
757            return first_status;
758        }
759        // Trigger fired. Flip the wrapper option for the retry and
760        // restore it after — keeps the user's option-table view of the
761        // session exactly as they left it.
762        let prev = self
763            .options
764            .get_string_value("l1_exact_penalty_barrier", "")
765            .ok();
766        let _ = self
767            .options
768            .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
769        let retry_status = self
770            .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
771            .unwrap_or(ApplicationReturnStatus::InternalError);
772        let _ = self.options.set_string_value(
773            "l1_exact_penalty_barrier",
774            prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
775            true,
776            false,
777        );
778        if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
779            retry_status
780        } else {
781            first_status
782        }
783    }
784
785    /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
786    ///
787    /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
788    /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
789    /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
790    ///   - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
791    ///   - inner solve returns non-Optimal (escalation won't fix
792    ///     numerical / restoration failure at this ρ)
793    ///   - ρ already at `l1_penalty_max`
794    ///   - `l1_penalty_max_outer_iter` reached
795    ///
796    /// After the loop, if the inner status is `SolveSucceeded` or
797    /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
798    /// to `Infeasible_Problem_Detected` — the returned point is the
799    /// ℓ₁-best least-infeasible iterate, which is informative even
800    /// though the original constraints are not satisfied.
801    ///
802    /// Returns `Some(status)` if the wrapper ran the solve, `None` if
803    /// wrapper construction failed (caller should fall through to the
804    /// standard dispatch path).
805    fn run_l1_penalty_outer_loop(
806        &mut self,
807        tnlp: Rc<RefCell<dyn TNLP>>,
808    ) -> Option<ApplicationReturnStatus> {
809        let rho_init = self.l1_penalty_init();
810        let rho_max = self.l1_penalty_max().max(rho_init);
811        let factor = self.l1_penalty_increase_factor().max(1.0);
812        let tau = self.l1_steering_factor();
813        let slack_tol = self.l1_slack_tol();
814        let max_outer = self.l1_penalty_max_outer_iter().max(1);
815
816        let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
817        if wrapper.m_eq() == 0 {
818            // Nothing to slack — let the standard dispatch path handle
819            // this TNLP unmodified.
820            return None;
821        }
822        wrapper.set_defer_inner_finalize(true);
823        let wrapper_rc = Rc::new(RefCell::new(wrapper));
824
825        let mut rho = rho_init;
826        let mut last_status = ApplicationReturnStatus::InternalError;
827        for _outer in 0..max_outer {
828            wrapper_rc.borrow_mut().set_rho(rho);
829            let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
830            last_status = self.optimize_constrained(dyn_tnlp);
831
832            let w = wrapper_rc.borrow();
833            if !w.has_solution() {
834                // Inner solve aborted before producing an iterate.
835                drop(w);
836                break;
837            }
838            let slack_sum = w.last_slack_sum();
839            let y_eq_inf = w.last_y_eq_inf_norm();
840            drop(w);
841
842            // Termination decisions.
843            let inner_ok = matches!(
844                last_status,
845                ApplicationReturnStatus::SolveSucceeded
846                    | ApplicationReturnStatus::SolvedToAcceptableLevel
847            );
848            if !inner_ok {
849                break;
850            }
851            if slack_sum.is_finite() && slack_sum <= slack_tol {
852                break;
853            }
854            if rho >= rho_max {
855                break;
856            }
857            // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
858            let geom = rho * factor;
859            let steer = tau * y_eq_inf + 1.0e-12;
860            rho = geom.max(steer).min(rho_max);
861        }
862
863        // Forward to the user's inner.finalize_solution exactly once.
864        let w = wrapper_rc.borrow();
865        if w.has_solution() {
866            let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
867            let lambda: Vec<Number> = w.last_lambda().to_vec();
868            let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
869            let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
870            let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
871            let slack_sum = w.last_slack_sum();
872            drop(w);
873
874            // Honest-infeasibility upgrade (Phase 3): if the inner
875            // solve says SolveSucceeded / SolvedToAcceptableLevel but
876            // the slacks did not collapse, the original problem is
877            // locally infeasible at the returned point. Override the
878            // application status; the user-visible Solution.status is
879            // updated below to the matching SolverReturn so the inner
880            // TNLP sees a consistent picture.
881            let infeasible_certificate = matches!(
882                last_status,
883                ApplicationReturnStatus::SolveSucceeded
884                    | ApplicationReturnStatus::SolvedToAcceptableLevel
885            ) && slack_sum.is_finite()
886                && slack_sum > slack_tol;
887            let final_app_status = if infeasible_certificate {
888                ApplicationReturnStatus::InfeasibleProblemDetected
889            } else {
890                last_status
891            };
892            let final_solver_status = if infeasible_certificate {
893                SolverReturn::LocalInfeasibility
894            } else {
895                solver_status
896            };
897
898            // Recompute f(x*) and c(x*) on the inner.
899            let f_inner = tnlp
900                .borrow_mut()
901                .eval_f(&x_trunc, true)
902                .unwrap_or(Number::NAN);
903            let m = tnlp
904                .borrow_mut()
905                .get_nlp_info()
906                .map(|i| i.m as usize)
907                .unwrap_or(0);
908            let mut g_inner = vec![0.0; m];
909            if m > 0 {
910                let _ = tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
911            }
912            tnlp.borrow_mut().finalize_solution(
913                Solution {
914                    status: final_solver_status,
915                    x: &x_trunc,
916                    z_l: &z_l,
917                    z_u: &z_u,
918                    g: &g_inner,
919                    lambda: &lambda,
920                    obj_value: f_inner,
921                },
922                &TnlpIpoptData::default(),
923                &TnlpIpoptCq::default(),
924            );
925            return Some(final_app_status);
926        }
927        // No solution captured at all — pass the inner status through.
928        Some(last_status)
929    }
930
931    /// **Stub.** Re-solve with a warm start. Phase 7+.
932    pub fn reoptimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
933        // Same dispatch as `optimize_tnlp` for now; warm-start handling
934        // lands once the IPM path's warm-start hooks are exposed.
935        self.optimize_tnlp(tnlp)
936    }
937
938    /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
939    /// bundle, run `optimize`, populate statistics, and call
940    /// `finalize_solution` on the user's TNLP.
941    fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
942        let t_start = Instant::now();
943
944        // `print_user_options yes` — dump the OptionsList before the
945        // solve. Mirrors `IpoptApplication::call_optimize` (upstream
946        // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
947        let print_opts = self
948            .options
949            .get_bool_value("print_user_options", "")
950            .ok()
951            .and_then(|(v, f)| f.then_some(v))
952            .unwrap_or(false);
953        if print_opts {
954            print!(
955                "\nList of user-set options:\n\n{}",
956                self.options.print_user_options()
957            );
958        }
959
960        // `print_options_documentation yes` — dump the full registry
961        // (every option with type, default, valid range/strings, and
962        // long description) before the solve. Honors
963        // `print_options_mode` (`text` / `latex` / `doxygen`; only
964        // `text` is implemented today, the others fall through with a
965        // one-line note) and `print_advanced_options`. Mirrors
966        // upstream `IpoptApplication::call_optimize`'s
967        // `print_options_documentation` branch and `Common/IpRegOptions.cpp`
968        // `OutputOptionDocumentation`.
969        let print_doc = self
970            .options
971            .get_bool_value("print_options_documentation", "")
972            .ok()
973            .and_then(|(v, f)| f.then_some(v))
974            .unwrap_or(false);
975        if print_doc {
976            let mode = self
977                .options
978                .get_string_value("print_options_mode", "")
979                .ok()
980                .map(|(v, _)| PrintOptionsMode::from_tag(&v))
981                .unwrap_or(PrintOptionsMode::Text);
982            let advanced = self
983                .options
984                .get_bool_value("print_advanced_options", "")
985                .ok()
986                .map(|(v, _)| v)
987                .unwrap_or(false);
988            print!(
989                "\n# Pounce options registry\n\n{}",
990                self.reg_options.print_options_documentation(mode, advanced)
991            );
992        }
993
994        // Mint a fresh `TimingStatistics` for this solve — shared (via
995        // `Rc`) with the data and the NLP below so every `eval_*` and
996        // every iterate-phase records into the same accumulator. The
997        // application keeps its own `Rc` so callers can read totals out
998        // via [`Self::timing_stats`].
999        let timing = Rc::new(TimingStatistics::new());
1000        *self.timing.borrow_mut() = Rc::clone(&timing);
1001        timing.overall_alg.start();
1002
1003        // Reset the linear-solver summary sink so back-to-back solves
1004        // don't bleed factor counters / extremal pivots into each
1005        // other. Surviving the lock failure with a debug-assert keeps
1006        // a poisoned mutex from sinking a release build that doesn't
1007        // even consume the summary.
1008        if let Ok(mut guard) = self.linsol_summary_sink.lock() {
1009            *guard = LinearSolverSummary::default();
1010        } else {
1011            debug_assert!(false, "linsol summary sink mutex poisoned");
1012        }
1013
1014        // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
1015        // `make_parameter`; pounce additionally implements `relax_bounds`,
1016        // which the adapter also auto-selects as a fallback when
1017        // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
1018        // `IpTNLPAdapter.cpp:623-633`).
1019        let lo_inf = self
1020            .options
1021            .get_numeric_value("nlp_lower_bound_inf", "")
1022            .ok()
1023            .and_then(|(v, f)| f.then_some(v))
1024            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
1025        let up_inf = self
1026            .options
1027            .get_numeric_value("nlp_upper_bound_inf", "")
1028            .ok()
1029            .and_then(|(v, f)| f.then_some(v))
1030            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
1031        let fixed_treatment = match self
1032            .options
1033            .get_string_value("fixed_variable_treatment", "")
1034            .ok()
1035            .and_then(|(v, f)| f.then_some(v))
1036            .as_deref()
1037        {
1038            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
1039            // `make_constraint` / `make_parameter_nodual` not yet
1040            // implemented; fall back to `make_parameter` (auto-retry to
1041            // `relax_bounds` will still kick in if DOF runs short).
1042            _ => FixedVarTreatment::MakeParameter,
1043        };
1044        let adapter = match TNLPAdapter::new_with_options(
1045            Rc::clone(&tnlp),
1046            lo_inf,
1047            up_inf,
1048            fixed_treatment,
1049        ) {
1050            Ok(a) => Rc::new(RefCell::new(a)),
1051            Err(_) => {
1052                timing.overall_alg.end();
1053                return ApplicationReturnStatus::InvalidProblemDefinition;
1054            }
1055        };
1056        let mut orig_nlp = match OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)) {
1057            Ok(n) => n,
1058            Err(_) => {
1059                timing.overall_alg.end();
1060                return ApplicationReturnStatus::InternalError;
1061            }
1062        };
1063        orig_nlp.set_timing_stats(Rc::clone(&timing));
1064
1065        // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
1066        // bail out with NotEnoughDegreesOfFreedom when there are fewer free
1067        // variables than equality constraints. Without this gate, square /
1068        // over-determined systems push the algorithm into restoration on
1069        // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
1070        let n_x_var = orig_nlp.x_space().dim();
1071        let n_c = orig_nlp.c_space().dim();
1072        if n_x_var > 0 && n_x_var < n_c {
1073            timing.overall_alg.end();
1074            return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
1075        }
1076
1077        // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
1078        // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
1079        // `OrigIpoptNLP::InitializeStructures` lines 343-358.
1080        let bound_relax_factor = self
1081            .options
1082            .get_numeric_value("bound_relax_factor", "")
1083            .ok()
1084            .and_then(|(v, f)| f.then_some(v))
1085            .unwrap_or(1e-8);
1086        let constr_viol_tol = self
1087            .options
1088            .get_numeric_value("constr_viol_tol", "")
1089            .ok()
1090            .and_then(|(v, f)| f.then_some(v))
1091            .unwrap_or(1e-4);
1092        orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
1093
1094        // Apply automatic NLP scaling per `nlp_scaling_method` option
1095        // (port of `OrigIpoptNLP::InitializeStructures` →
1096        // `NLPScalingObject::DetermineScaling`). Default is
1097        // `gradient-based` to match upstream Ipopt 3.14.
1098        let scaling_method = self
1099            .options
1100            .get_string_value("nlp_scaling_method", "")
1101            .ok()
1102            .and_then(|(v, f)| f.then_some(v))
1103            .unwrap_or_else(|| "gradient-based".to_string());
1104        let scaling_method = match scaling_method.as_str() {
1105            "none" => ScalingMethod::None,
1106            "gradient-based" => ScalingMethod::GradientBased,
1107            "user-scaling" => ScalingMethod::UserScaling,
1108            // `equilibration-based` is registered upstream but not yet
1109            // implemented in pounce; fall back to gradient-based (the
1110            // upstream default) to keep behavior predictable.
1111            _ => ScalingMethod::GradientBased,
1112        };
1113        let max_gradient = self
1114            .options
1115            .get_numeric_value("nlp_scaling_max_gradient", "")
1116            .ok()
1117            .and_then(|(v, f)| f.then_some(v))
1118            .unwrap_or(100.0);
1119        let min_value = self
1120            .options
1121            .get_numeric_value("nlp_scaling_min_value", "")
1122            .ok()
1123            .and_then(|(v, f)| f.then_some(v))
1124            .unwrap_or(1e-8);
1125        let obj_target_gradient = self
1126            .options
1127            .get_numeric_value("nlp_scaling_obj_target_gradient", "")
1128            .ok()
1129            .and_then(|(v, f)| f.then_some(v))
1130            .unwrap_or(0.0);
1131        let constr_target_gradient = self
1132            .options
1133            .get_numeric_value("nlp_scaling_constr_target_gradient", "")
1134            .ok()
1135            .and_then(|(v, f)| f.then_some(v))
1136            .unwrap_or(0.0);
1137        orig_nlp.determine_scaling_from_starting_point(
1138            scaling_method,
1139            max_gradient,
1140            min_value,
1141            obj_target_gradient,
1142            constr_target_gradient,
1143        );
1144
1145        let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
1146
1147        // Build the algorithm strategy bundle. Read coarse knobs from
1148        // the OptionsList where we have them; fall through to defaults
1149        // otherwise. The full upstream parsing surface (mu_strategy,
1150        // hessian_approximation, line_search_method, ...) is wired by
1151        // `AlgBuilder::RegisterOptions` in upstream — that registry
1152        // hookup lands as a follow-up; default builder is correct for
1153        // HS71-class problems.
1154        let builder = self.algorithm_builder_from_options();
1155
1156        // Linear-solver backend. The default factory is option-aware
1157        // — it reads the `feral_*` extension options off the same
1158        // `OptionsList` that drove the IPM-level builder above so
1159        // per-problem `.opt` files can flip backend knobs without
1160        // rebuilding pounce.
1161        let feral_cfg = feral_config_from_options(&self.options);
1162        let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
1163            default_backend_factory_with_sink(feral_cfg, Arc::clone(&self.linsol_summary_sink))
1164        });
1165        let bundle = builder.build_with_backend(factory);
1166
1167        // Wire the data / cq pair around the NLP. Install the shared
1168        // `TimingStatistics` so the algorithm's iterate phases
1169        // (output, convergence, hessian, μ, search-direction,
1170        // line-search, accept) all record into the same accumulator
1171        // the application exposes via `timing_stats()`.
1172        let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
1173        data.borrow_mut().timing = Rc::clone(&timing);
1174        let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
1175            IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
1176        ));
1177        // Correction size for very small slacks (default mach_eps^{3/4});
1178        // drives the safe-slack bound-adjustment mechanism.
1179        if let Ok((v, true)) = self.options.get_numeric_value("slack_move", "") {
1180            cq.borrow_mut().slack_move = v;
1181        }
1182
1183        // Seed `data.curr` with a zero-valued iterate of the correct
1184        // dimensions. The `IterateInitializer` consumes these as its
1185        // template (it overwrites `x`, `s`, multipliers in place); we
1186        // just need the dim metadata.
1187        {
1188            let nlp_borrow = nlp_handle.borrow();
1189            let n_x = nlp_borrow.n();
1190            let n_s = nlp_borrow.m_ineq();
1191            let n_yc = nlp_borrow.m_eq();
1192            let n_yd = nlp_borrow.m_ineq();
1193            let n_zl = nlp_borrow.x_l().dim();
1194            let n_zu = nlp_borrow.x_u().dim();
1195            let n_vl = nlp_borrow.d_l().dim();
1196            let n_vu = nlp_borrow.d_u().dim();
1197            drop(nlp_borrow);
1198            let iv = IteratesVector::new(
1199                Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
1200                Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
1201                Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
1202                Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
1203                Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
1204                Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
1205                Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
1206                Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
1207            );
1208            data.borrow_mut().set_curr(iv);
1209        }
1210
1211        // Full primal-dual warm restart (debugger `resolve`): if a
1212        // captured iterate is queued, install it onto `data.curr` over
1213        // the placeholder so the `WarmStartIterateInitializer`'s
1214        // re-optimize branch (x already initialized) keeps it and only
1215        // clamps multipliers / sets target_mu — no cold re-seed from the
1216        // NLP. Skipped (with a warning) if the dimensions don't line up,
1217        // e.g. an option changed the problem structure between solves.
1218        if let Some(snap) = self.warm_start_iterate.take() {
1219            let dims_match = {
1220                let borrow = data.borrow();
1221                borrow
1222                    .curr
1223                    .as_ref()
1224                    .map(|c| iterates_dims(c) == iterates_dims(snap.iterates()))
1225                    .unwrap_or(false)
1226            };
1227            if dims_match {
1228                data.borrow_mut().set_curr(snap.iterates().clone());
1229                data.borrow_mut().curr_mu = snap.mu();
1230            } else {
1231                tracing::warn!(
1232                    target: "pounce::warm_start",
1233                    "debugger warm-restart iterate dimensions differ from the fresh \
1234                     solve; ignoring the captured iterate and seeding normally"
1235                );
1236            }
1237        }
1238
1239        let max_iter = self
1240            .options
1241            .get_integer_value("max_iter", "")
1242            .ok()
1243            .and_then(|(v, f)| f.then_some(v))
1244            .unwrap_or(3000);
1245        let tol = self
1246            .options
1247            .get_numeric_value("tol", "")
1248            .ok()
1249            .and_then(|(v, f)| f.then_some(v))
1250            .unwrap_or(1e-8);
1251        data.borrow_mut().tol = tol;
1252
1253        let mut alg = IpoptAlgorithm::new(data, cq, bundle)
1254            .with_nlp(Rc::clone(&nlp_handle))
1255            .with_tnlp(Rc::clone(&tnlp));
1256        // Mint a fresh restoration factory per inner solve if a
1257        // provider is configured (pounce#10 Phase 3). Falls back to
1258        // the legacy one-shot `restoration_factory` slot when no
1259        // provider is set, preserving single-shot caller behavior.
1260        if let Some(provider) = self.restoration_factory_provider.as_mut() {
1261            self.restoration_factory = Some(provider());
1262        }
1263        if let Some(factory) = self.restoration_factory.as_mut() {
1264            alg = alg.with_restoration(factory());
1265        }
1266        if let Some(diag) = self.diagnostics.as_ref() {
1267            alg = alg.with_diagnostics(Rc::clone(diag));
1268        }
1269        // Move the interactive debugger hook (if any) into the main
1270        // algorithm. Taken — not cloned — so it drives exactly this
1271        // solve; a subsequent solve must reinstall it.
1272        if let Some(hook) = self.debug_hook.take() {
1273            alg = alg.with_debug_hook(hook);
1274        }
1275        alg.max_iter = max_iter;
1276        // Honor `print_level == 0`: suppress the per-iteration table
1277        // that the algorithm writes straight to stdout. (The Phase-7
1278        // journalist surface respects `print_level` already; this is
1279        // the legacy direct-print site that needs the same gate.)
1280        if let Ok((v, found)) = self.options.get_integer_value("print_level", "") {
1281            if found && v <= 0 {
1282                alg.print_iter_output = false;
1283                // The nested restoration IPM is built inside the
1284                // restoration driver, not by `IpoptAlgorithm::new`, so
1285                // it never sees this gate unless we forward it.
1286                if let Some(resto) = alg.restoration.as_mut() {
1287                    resto.set_print_iter_output(false);
1288                }
1289            }
1290        }
1291
1292        // Per-iteration history (pounce#71): when requested, capture the
1293        // `pounce::iteration` events emitted during the solve into an
1294        // `IterRecord` trajectory via the observability collector layer.
1295        // This replaces the old in-loop `iter_history` accumulation; it
1296        // requires the collector to be installed in the active
1297        // subscriber (the CLI / Python / C frontends install it via
1298        // `pounce_observability::init_subscriber`; tests call
1299        // `init_for_tests`). The collector scopes out restoration
1300        // sub-solve iterations via the `restoration` span, so the
1301        // trajectory matches the previous behavior (outer iters only).
1302        let iter_capture = self
1303            .record_iter_history
1304            .then(pounce_observability::IterCaptureGuard::start);
1305
1306        let solver_status = alg.optimize();
1307
1308        let captured_iters = iter_capture.map(|g| g.finish()).unwrap_or_default();
1309        // Close the overall-algorithm timer on the success path. The
1310        // early-return arms above end it themselves before bailing out;
1311        // this one matches upstream `IpoptApplication::call_optimize`
1312        // (which calls `EndCpuTime()` on overall_alg right after
1313        // `Optimize` returns, regardless of solver_status).
1314        timing.overall_alg.end();
1315
1316        // Drain counters / iter count off the algorithm.
1317        {
1318            let mut stats = self.statistics.borrow_mut();
1319            {
1320                let d = alg.data.borrow();
1321                stats.iteration_count = d.iter_count;
1322                // Converged barrier parameter μ — threaded forward into a
1323                // warm-started corrector's `mu_init` / `warm_start_target_mu`
1324                // for predictor–corrector path following (pounce#86).
1325                stats.final_mu = d.curr_mu;
1326            }
1327            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
1328            // Restoration-phase audit counters (pounce#12). Zero on
1329            // problems where restoration never fires; populated by
1330            // `IpoptAlgorithm::invoke_restoration`.
1331            stats.restoration_calls = alg.resto_calls;
1332            stats.restoration_inner_iters = alg.resto_inner_iters;
1333            stats.restoration_outer_iters = alg.resto_outer_iters;
1334            stats.restoration_wall_secs = alg.resto_wall_secs;
1335            stats.iterations = captured_iters;
1336            // Capture the final *scaled* objective at the algorithm's
1337            // (compressed `x_var`-space) iterate via the NLP: the
1338            // algorithm-side `eval_f` returns `f * obj_scale_factor`.
1339            // `final_objective` is seeded with it only as a best-effort
1340            // fallback; the success path below overwrites it with the
1341            // true unscaled objective from `finalize_via_orig_nlp`
1342            // (which evaluates the user TNLP directly).
1343            let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
1344            if let Some(x) = curr_x {
1345                if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
1346                    stats.final_objective = f;
1347                    stats.final_scaled_objective = f;
1348                }
1349            }
1350            // Final residuals straight off the cq cache. These mirror
1351            // the values upstream prints in its end-of-run summary
1352            // ("Dual infeasibility / Constraint violation /
1353            // Complementarity / Overall NLP error").
1354            let cq = alg.cq.borrow();
1355            stats.final_dual_inf = cq.curr_dual_infeasibility_max();
1356            stats.final_constr_viol = cq.curr_primal_infeasibility_max();
1357            // Infinity-norm complementarity, max over all four bound
1358            // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
1359            // empty-bound blocks return `0` from amax(), so the max is
1360            // safe even when only one side has bounds.
1361            let compl = cq
1362                .curr_compl_x_l()
1363                .amax()
1364                .max(cq.curr_compl_x_u().amax())
1365                .max(cq.curr_compl_s_l().amax())
1366                .max(cq.curr_compl_s_u().amax());
1367            stats.final_compl = compl;
1368            stats.final_kkt_error = cq.curr_nlp_error();
1369        }
1370
1371        // Map SolverReturn → ApplicationReturnStatus per
1372        // MAIN_LOOP.md's exception table.
1373        let app_status = solver_return_to_app_status(solver_status);
1374
1375        // On convergence, fire the user-supplied callback (post-optimal
1376        // sensitivity hook, pounce#16) before flowing back through
1377        // `finalize_via_orig_nlp`. Borrowed handles into the converged
1378        // KKT state stay alive for the duration of the closure.
1379        if matches!(
1380            app_status,
1381            ApplicationReturnStatus::SolveSucceeded
1382                | ApplicationReturnStatus::SolvedToAcceptableLevel
1383        ) {
1384            if let Some(cb) = self.on_converged.as_mut() {
1385                if let Some(sd) = alg.search_dir.as_mut() {
1386                    let pd = sd.pd_solver_rc();
1387                    cb(&alg.data, &alg.cq, &nlp_handle, pd);
1388                }
1389            }
1390        }
1391
1392        // Finalize: forward the final iterate to the user's TNLP. The
1393        // returned objective is evaluated on the *user* TNLP at the
1394        // unscaled iterate, so it overrides the scaled best-effort
1395        // value stashed in `final_objective` above (the algorithm-side
1396        // `eval_f` returns `f * obj_scale_factor`).
1397        match finalize_via_orig_nlp(&nlp_handle, &alg, solver_status, app_status, &tnlp) {
1398            Ok(f_unscaled) => {
1399                self.statistics.borrow_mut().final_objective = f_unscaled;
1400            }
1401            Err(()) => {
1402                // Couldn't finalize; keep the scaled fallback and
1403                // surface the original status.
1404            }
1405        }
1406
1407        // End-of-solve timing report. Gated on `print_timing_statistics`
1408        // (default "no"); mirrors upstream's
1409        // `IpoptApplication::call_optimize` →
1410        // `IpTimingStatistics::PrintAllValues` call site. The report
1411        // goes to stdout (for parity with the banner / iter-row output
1412        // path) and is also fanned out to the journalist so an
1413        // `output_file` attached via `Initialize` picks it up.
1414        let print_timing = self
1415            .options
1416            .get_bool_value("print_timing_statistics", "")
1417            .ok()
1418            .and_then(|(v, f)| f.then_some(v))
1419            .unwrap_or(false);
1420        if print_timing {
1421            let report = timing.report();
1422            print!("{}", report);
1423            use pounce_common::journalist::{JournalCategory, JournalLevel};
1424            self.journalist.print(
1425                JournalLevel::J_SUMMARY,
1426                JournalCategory::J_TIMING_STATISTICS,
1427                &report,
1428            );
1429        }
1430
1431        app_status
1432    }
1433
1434    /// Build an [`AlgorithmBuilder`] populated from the app's
1435    /// [`OptionsList`]. Public so callers wiring the restoration
1436    /// factory can hand the *inner* IPM a builder that mirrors the
1437    /// outer's `mu_strategy`/`mu_oracle`/line-search choices —
1438    /// matching upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`,
1439    /// which reads the same `mu_strategy` option with prefix `"resto."
1440    /// + prefix` and falls back to the outer setting.
1441    pub fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
1442        let mut builder = AlgorithmBuilder::new();
1443
1444        // `mehrotra_algorithm` is parsed first so its cascading
1445        // defaults (mu_strategy=adaptive, mu_oracle=probing) can be
1446        // overridden by an explicit user setting of those keys
1447        // below. Mirrors `IpAlgBuilder.cpp:Mehrotra`.
1448        let mut mehrotra_on = false;
1449        if let Ok((v, found)) = self.options.get_string_value("mehrotra_algorithm", "") {
1450            if found && v == "yes" {
1451                mehrotra_on = true;
1452                builder.mehrotra_algorithm = true;
1453                builder.mu_strategy = MuStrategyChoice::Adaptive;
1454                builder.mu_oracle = crate::mu::adaptive::MuOracleKind::Probing;
1455                // `accept_every_trial_step` short-circuits the alpha
1456                // loop / filter — Mehrotra steps would otherwise be
1457                // rejected by the filter on LP-shaped problems because
1458                // the barrier objective is non-monotone along the
1459                // corrector. Mirrors upstream `IpAlgBuilder.cpp:Mehrotra`.
1460                builder.line_search.accept_every_trial_step = true;
1461                // Aggressive iterate-push defaults (`SetNumericValueIfUnset`
1462                // in upstream). The explicit user parses below will
1463                // overwrite these if the user set them explicitly.
1464                builder.init.bound_push = 10.0;
1465                builder.init.bound_frac = 0.2;
1466                builder.init.slack_bound_push = 10.0;
1467                builder.init.slack_bound_frac = 0.2;
1468                builder.init.bound_mult_init_val = 10.0;
1469                builder.init.constr_mult_init_max = 0.0;
1470                // `alpha_for_y=bound_mult` — Mehrotra wants the
1471                // equality multipliers to advance with the dual
1472                // alpha so they stay in step with z/v. Mirrors
1473                // upstream `IpIpoptAlg.cpp:InitializeImpl`.
1474                builder.line_search.alpha_for_y =
1475                    crate::line_search::backtracking::AlphaForY::BoundMult;
1476                // `adaptive_mu_globalization=never-monotone-mode` —
1477                // upstream `IpIpoptAlg.cpp:148-154` enforces this:
1478                // Mehrotra disables the globalization switch entirely
1479                // (no fallback to monotone mode when convergence
1480                // stalls). Required for the unsafeguarded Mehrotra
1481                // path to function.
1482                builder.mu.adaptive_mu_globalization =
1483                    crate::mu::adaptive::AdaptiveMuGlobalization::NeverMonotoneMode;
1484                // `least_square_init_primal=yes` — upstream
1485                // `IpIpoptAlg.cpp:182` enables this for the Mehrotra
1486                // cascade. Replaces the user's starting `x` with the
1487                // min-norm primal that satisfies the linearized
1488                // equality+inequality constraints. Critical on
1489                // LP-shaped problems where the user's starting point
1490                // can be wildly infeasible (e.g. nuffield2_trap).
1491                builder.init.least_square_init_primal = true;
1492            }
1493        }
1494
1495        if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
1496            if found {
1497                let parsed = match v.as_str() {
1498                    "adaptive" => MuStrategyChoice::Adaptive,
1499                    _ => MuStrategyChoice::Monotone,
1500                };
1501                if mehrotra_on && matches!(parsed, MuStrategyChoice::Monotone) {
1502                    // Upstream Ipopt refuses this combination: Mehrotra
1503                    // needs an affine step every iter, which only the
1504                    // adaptive path computes. Keep adaptive and warn.
1505                    tracing::warn!(target: "pounce::algorithm",
1506                        "pounce: mehrotra_algorithm=yes requires \
1507                         mu_strategy=adaptive; ignoring \
1508                         mu_strategy=monotone."
1509                    );
1510                } else {
1511                    builder.mu_strategy = parsed;
1512                }
1513            }
1514        }
1515        if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
1516            if found {
1517                builder.mu_oracle = match v.as_str() {
1518                    "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
1519                    "probing" => crate::mu::adaptive::MuOracleKind::Probing,
1520                    _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
1521                };
1522            }
1523        }
1524        if let Ok((v, found)) = self
1525            .options
1526            .get_string_value("adaptive_mu_globalization", "")
1527        {
1528            if found {
1529                use crate::mu::adaptive::AdaptiveMuGlobalization;
1530                builder.mu.adaptive_mu_globalization = match v.as_str() {
1531                    "kkt-error" => AdaptiveMuGlobalization::KktError,
1532                    "never-monotone-mode" => AdaptiveMuGlobalization::NeverMonotoneMode,
1533                    _ => AdaptiveMuGlobalization::ObjConstrFilter,
1534                };
1535            }
1536        }
1537        if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
1538            if found {
1539                builder.hessian_approximation = match v.as_str() {
1540                    "limited-memory" => HessianApproxChoice::LimitedMemory,
1541                    _ => HessianApproxChoice::Exact,
1542                };
1543            }
1544        }
1545        if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
1546            if found {
1547                builder.line_search_method = match v.as_str() {
1548                    "cg-penalty" => LineSearchChoice::CgPenalty,
1549                    "penalty" => LineSearchChoice::Penalty,
1550                    _ => LineSearchChoice::Filter,
1551                };
1552            }
1553        }
1554        // `accept_every_trial_step` — direct user override. Parsed
1555        // after the Mehrotra cascade so an explicit `no` still wins.
1556        if let Ok((v, found)) = self.options.get_string_value("accept_every_trial_step", "") {
1557            if found {
1558                builder.line_search.accept_every_trial_step = v == "yes";
1559            }
1560        }
1561        // `alpha_for_y` — direct user override. Parsed after the
1562        // Mehrotra cascade so an explicit value still wins.
1563        if let Ok((v, found)) = self.options.get_string_value("alpha_for_y", "") {
1564            if found {
1565                use crate::line_search::backtracking::AlphaForY;
1566                builder.line_search.alpha_for_y = match v.as_str() {
1567                    "primal" => AlphaForY::Primal,
1568                    "bound-mult" | "bound_mult" => AlphaForY::BoundMult,
1569                    "full" => AlphaForY::Full,
1570                    "min" => AlphaForY::Min,
1571                    "max" => AlphaForY::Max,
1572                    "primal-and-full" | "dual-and-full" => AlphaForY::Primal,
1573                    _ => AlphaForY::Primal,
1574                };
1575            }
1576        }
1577        // `nlp_scaling_method` is consumed NLP-side in
1578        // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
1579        // `determine_scaling_from_starting_point` call earlier in this
1580        // method); there is no algorithm-side scaling strategy to wire.
1581
1582        // Unlike the other options here, we always honor the registry
1583        // value (not just when the user set it explicitly): the option
1584        // registry default is "ma57" but `AlgorithmBuilder::default`
1585        // has `linear_solver: Feral`, so gating on `found` would
1586        // silently route default runs through Feral while the banner
1587        // (and ipopt-compatible behavior) advertises MA57.
1588        if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
1589            builder.linear_solver = match v.as_str() {
1590                "ma57" => LinearSolverChoice::Ma57,
1591                _ => LinearSolverChoice::Feral,
1592            };
1593        }
1594
1595        // `linear_system_scaling` — symmetric scaling of the augmented
1596        // KKT matrix before factorization. Port of
1597        // `IpTSymLinearSolver.cpp:RegisterOptions` plumbing. Default
1598        // "none"; "ruiz" invokes the Ruiz-2001 symmetric ∞-norm
1599        // equilibration in `RuizTSymScalingMethod`. "mc19" and
1600        // "slack-based" are accepted by the registry but not yet
1601        // implemented at this layer; they fall back to no scaling
1602        // with a one-line stderr notice.
1603        if let Ok((v, found)) = self.options.get_string_value("linear_system_scaling", "") {
1604            if found {
1605                builder.linear_system_scaling = match v.as_str() {
1606                    "ruiz" => crate::alg_builder::LinearSystemScalingChoice::Ruiz,
1607                    "mc19" => crate::alg_builder::LinearSystemScalingChoice::Mc19,
1608                    _ => crate::alg_builder::LinearSystemScalingChoice::None,
1609                };
1610            }
1611        }
1612        if let Ok((v, found)) = self.options.get_bool_value("linear_scaling_on_demand", "") {
1613            if found {
1614                builder.linear_scaling_on_demand = v;
1615            }
1616        }
1617
1618        // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
1619        // `RegisterOptions` consumers). Defaults already match upstream
1620        // — only override when the user set the key explicitly.
1621        let read_num = |key: &str| -> Option<f64> {
1622            self.options
1623                .get_numeric_value(key, "")
1624                .ok()
1625                .and_then(|(v, f)| f.then_some(v))
1626        };
1627        let read_int = |key: &str| -> Option<i32> {
1628            self.options
1629                .get_integer_value(key, "")
1630                .ok()
1631                .and_then(|(v, f)| f.then_some(v))
1632        };
1633        if let Some(v) = read_num("tol") {
1634            builder.conv_check.tol = v;
1635        }
1636        if let Some(v) = read_num("dual_inf_tol") {
1637            builder.conv_check.dual_inf_tol = v;
1638        }
1639        if let Some(v) = read_num("constr_viol_tol") {
1640            builder.conv_check.constr_viol_tol = v;
1641        }
1642        if let Some(v) = read_num("compl_inf_tol") {
1643            builder.conv_check.compl_inf_tol = v;
1644        }
1645        if let Some(v) = read_int("max_iter") {
1646            builder.conv_check.max_iter = v;
1647        }
1648        if let Some(v) = read_num("max_cpu_time") {
1649            builder.conv_check.max_cpu_time = v;
1650        }
1651        if let Some(v) = read_num("max_wall_time") {
1652            builder.conv_check.max_wall_time = v;
1653        }
1654        if let Some(v) = read_num("acceptable_tol") {
1655            builder.conv_check.acceptable_tol = v;
1656        }
1657        if let Some(v) = read_num("acceptable_dual_inf_tol") {
1658            builder.conv_check.acceptable_dual_inf_tol = v;
1659        }
1660        if let Some(v) = read_num("acceptable_constr_viol_tol") {
1661            builder.conv_check.acceptable_constr_viol_tol = v;
1662        }
1663        if let Some(v) = read_num("acceptable_compl_inf_tol") {
1664            builder.conv_check.acceptable_compl_inf_tol = v;
1665        }
1666        if let Some(v) = read_num("acceptable_obj_change_tol") {
1667            builder.conv_check.acceptable_obj_change_tol = v;
1668        }
1669        if let Some(v) = read_int("acceptable_iter") {
1670            builder.conv_check.acceptable_iter = v;
1671        }
1672        if let Some(v) = read_num("infeas_stationarity_tol") {
1673            builder.conv_check.infeas_stationarity_tol = v;
1674        }
1675        if let Some(v) = read_num("infeas_viol_kappa") {
1676            builder.conv_check.infeas_viol_kappa = v;
1677        }
1678        if let Some(v) = read_int("infeas_max_streak") {
1679            builder.conv_check.infeas_max_streak = v;
1680        }
1681
1682        // Barrier-parameter (μ) options — consumers in
1683        // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
1684        // updaters share the same option names; the builder forwards
1685        // each into whichever strategy is assembled.
1686        if let Some(v) = read_num("mu_init") {
1687            builder.mu.mu_init = v;
1688        }
1689        if let Some(v) = read_num("mu_max") {
1690            builder.mu.mu_max = v;
1691        }
1692        if let Some(v) = read_num("mu_max_fact") {
1693            builder.mu.mu_max_fact = v;
1694        }
1695        if let Some(v) = read_num("mu_min") {
1696            builder.mu.mu_min = v;
1697        }
1698        if let Some(v) = read_num("mu_target") {
1699            builder.mu.mu_target = v;
1700        }
1701        if let Some(v) = read_num("mu_linear_decrease_factor") {
1702            builder.mu.mu_linear_decrease_factor = v;
1703        }
1704        if let Some(v) = read_num("mu_superlinear_decrease_power") {
1705            builder.mu.mu_superlinear_decrease_power = v;
1706        }
1707        if let Ok((v, found)) = self
1708            .options
1709            .get_string_value("mu_allow_fast_monotone_decrease", "")
1710        {
1711            if found {
1712                builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
1713            }
1714        }
1715        if let Some(v) = read_num("barrier_tol_factor") {
1716            builder.mu.barrier_tol_factor = v;
1717        }
1718        if let Some(v) = read_num("sigma_max") {
1719            builder.mu.sigma_max = v;
1720        }
1721        if let Some(v) = read_num("sigma_min") {
1722            builder.mu.sigma_min = v;
1723        }
1724
1725        // Quality-function oracle knobs — consumers in
1726        // `IpQualityFunctionMuOracle.cpp:RegisterOptions`. Forwarded
1727        // to the oracle on every free-mode call.
1728        if let Ok((v, found)) = self
1729            .options
1730            .get_string_value("quality_function_norm_type", "")
1731        {
1732            if found {
1733                use crate::mu::oracle::quality_function::NormType;
1734                builder.mu.quality_function_norm_type = match v.as_str() {
1735                    "1-norm" => NormType::OneNorm,
1736                    "2-norm" => NormType::TwoNorm,
1737                    "max-norm" => NormType::MaxNorm,
1738                    _ => NormType::TwoNormSquared,
1739                };
1740            }
1741        }
1742        if let Ok((v, found)) = self
1743            .options
1744            .get_string_value("quality_function_centrality", "")
1745        {
1746            if found {
1747                use crate::mu::oracle::quality_function::CentralityType;
1748                builder.mu.quality_function_centrality = match v.as_str() {
1749                    "log" => CentralityType::LogCenter,
1750                    "reciprocal" => CentralityType::ReciprocalCenter,
1751                    "cubed-reciprocal" => CentralityType::CubedReciprocalCenter,
1752                    _ => CentralityType::None,
1753                };
1754            }
1755        }
1756        if let Ok((v, found)) = self
1757            .options
1758            .get_string_value("quality_function_balancing_term", "")
1759        {
1760            if found {
1761                use crate::mu::oracle::quality_function::BalancingTermType;
1762                builder.mu.quality_function_balancing_term = match v.as_str() {
1763                    "cubic" => BalancingTermType::CubicTerm,
1764                    _ => BalancingTermType::None,
1765                };
1766            }
1767        }
1768        if let Some(v) = read_int("quality_function_max_section_steps") {
1769            builder.mu.quality_function_max_section_steps = v;
1770        }
1771        if let Some(v) = read_num("quality_function_section_sigma_tol") {
1772            builder.mu.quality_function_section_sigma_tol = v;
1773        }
1774        if let Some(v) = read_num("quality_function_section_qf_tol") {
1775            builder.mu.quality_function_section_qf_tol = v;
1776        }
1777
1778        // `probing_iterate_quality_factor` — pounce-specific guard
1779        // (pounce#58) on the probing μ-oracle's input iterate. When
1780        // `curr_avrg_compl / curr_mu` exceeds this factor, the
1781        // μ-update layer signals restoration via
1782        // `IpoptData::request_resto` instead of letting probing
1783        // return `σ · mu_curr` ≫ previous μ. Default 1e4; set to ≤ 0
1784        // to disable. No upstream Ipopt counterpart.
1785        if let Some(v) = read_num("probing_iterate_quality_factor") {
1786            builder.mu.probing_iterate_quality_factor = v;
1787        }
1788
1789        // Adaptive-μ extras — consumers in
1790        // `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Only active when
1791        // `mu_strategy=adaptive`.
1792        if let Some(v) = read_num("adaptive_mu_safeguard_factor") {
1793            builder.mu.adaptive_mu_safeguard_factor = v;
1794        }
1795        if let Some(v) = read_num("adaptive_mu_monotone_init_factor") {
1796            builder.mu.adaptive_mu_monotone_init_factor = v;
1797        }
1798        if let Ok((v, found)) = self
1799            .options
1800            .get_bool_value("adaptive_mu_restore_previous_iterate", "")
1801        {
1802            if found {
1803                builder.mu.adaptive_mu_restore_previous_iterate = v;
1804            }
1805        }
1806        if let Some(v) = read_int("adaptive_mu_kkterror_red_iters") {
1807            if v >= 0 {
1808                builder.mu.adaptive_mu_kkterror_red_iters = v as usize;
1809            }
1810        }
1811        if let Some(v) = read_num("adaptive_mu_kkterror_red_fact") {
1812            builder.mu.adaptive_mu_kkterror_red_fact = v;
1813        }
1814        if let Ok((v, found)) = self
1815            .options
1816            .get_string_value("adaptive_mu_kkt_norm_type", "")
1817        {
1818            if found {
1819                use crate::mu::adaptive::AdaptiveMuKktNorm;
1820                builder.mu.adaptive_mu_kkt_norm_type = match v.as_str() {
1821                    "1-norm" => AdaptiveMuKktNorm::OneNorm,
1822                    "2-norm" => AdaptiveMuKktNorm::TwoNorm,
1823                    "max-norm" => AdaptiveMuKktNorm::MaxNorm,
1824                    _ => AdaptiveMuKktNorm::TwoNormSquared,
1825                };
1826            }
1827        }
1828
1829        // Watchdog options — consumers in
1830        // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
1831        // the `BacktrackingLineSearch` at build time.
1832        if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
1833            builder.line_search.watchdog_shortened_iter_trigger = v;
1834        }
1835        if let Some(v) = read_int("watchdog_trial_iter_max") {
1836            builder.line_search.watchdog_trial_iter_max = v;
1837        }
1838        if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
1839            builder.line_search.soft_resto_pderror_reduction_factor = v;
1840        }
1841        if let Some(v) = read_int("max_soft_resto_iters") {
1842            builder.line_search.max_soft_resto_iters = v;
1843        }
1844
1845        // Iteration-output options — consumed by `OrigIterationOutput`.
1846        if let Some(v) = read_int("print_frequency_iter") {
1847            builder.output.print_frequency_iter = v;
1848        }
1849        if let Some(v) = read_num("print_frequency_time") {
1850            builder.output.print_frequency_time = v;
1851        }
1852        if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
1853            if found {
1854                builder.output.print_info_string = v;
1855            }
1856        }
1857        if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
1858            if found {
1859                builder.output.inf_pr_output_internal = v == "internal";
1860            }
1861        }
1862
1863        // Warm-start options — consumed by `WarmStartIterateInitializer`
1864        // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
1865        // `warm_start_init_point` is the toggle that picks between the
1866        // default (cold) and warm-start initializers; the remaining
1867        // knobs are baked onto the chosen initializer at build time.
1868        if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
1869            if found {
1870                builder.warm_start_init_point = v;
1871            }
1872        }
1873        if let Ok((v, found)) = self.options.get_bool_value("warm_start_same_structure", "") {
1874            if found {
1875                builder.warm.same_structure = v;
1876            }
1877        }
1878        if let Some(v) = read_num("warm_start_bound_push") {
1879            builder.warm.bound_push = v;
1880        }
1881        if let Some(v) = read_num("warm_start_bound_frac") {
1882            builder.warm.bound_frac = v;
1883        }
1884        if let Some(v) = read_num("warm_start_slack_bound_push") {
1885            builder.warm.slack_bound_push = v;
1886        }
1887        if let Some(v) = read_num("warm_start_slack_bound_frac") {
1888            builder.warm.slack_bound_frac = v;
1889        }
1890        if let Some(v) = read_num("warm_start_mult_bound_push") {
1891            builder.warm.mult_bound_push = v;
1892        }
1893        if let Some(v) = read_num("warm_start_mult_init_max") {
1894            builder.warm.mult_init_max = v;
1895        }
1896        if let Some(v) = read_num("warm_start_target_mu") {
1897            builder.warm.target_mu = v;
1898        }
1899        if let Ok((v, found)) = self
1900            .options
1901            .get_string_value("warm_start_entire_iterate", "")
1902        {
1903            if found {
1904                builder.warm.entire_iterate = v == "yes";
1905            }
1906        }
1907
1908        // `DefaultIterateInitializer` knobs — parsed after the Mehrotra
1909        // cascade so explicit user values win
1910        // (mirrors upstream's `SetNumericValueIfUnset` semantics).
1911        if let Some(v) = read_num("bound_push") {
1912            builder.init.bound_push = v;
1913        }
1914        if let Some(v) = read_num("bound_frac") {
1915            builder.init.bound_frac = v;
1916        }
1917        if let Some(v) = read_num("slack_bound_push") {
1918            builder.init.slack_bound_push = v;
1919        }
1920        if let Some(v) = read_num("slack_bound_frac") {
1921            builder.init.slack_bound_frac = v;
1922        }
1923        if let Some(v) = read_num("constr_mult_init_max") {
1924            builder.init.constr_mult_init_max = v;
1925        }
1926        if let Some(v) = read_num("bound_mult_init_val") {
1927            builder.init.bound_mult_init_val = v;
1928        }
1929        if let Ok((v, found)) = self.options.get_string_value("bound_mult_init_method", "") {
1930            if found {
1931                builder.init.bound_mult_init_method = v;
1932            }
1933        }
1934        if let Ok((v, found)) = self
1935            .options
1936            .get_string_value("least_square_init_primal", "")
1937        {
1938            if found {
1939                builder.init.least_square_init_primal = v == "yes";
1940            }
1941        }
1942        builder
1943    }
1944}
1945
1946/// Map the integer `print_level` / `file_print_level` option to the
1947/// matching [`JournalLevel`] variant. Mirrors upstream's
1948/// `static_cast<EJournalLevel>(int_value)` with clamping.
1949/// The eight block dimensions of an iterate, in canonical order
1950/// (x, s, y_c, y_d, z_l, z_u, v_l, v_u). Used to guard the debugger's
1951/// warm-restart install against a structural mismatch between solves.
1952fn iterates_dims(c: &IteratesVector) -> [i32; 8] {
1953    [
1954        c.x.dim(),
1955        c.s.dim(),
1956        c.y_c.dim(),
1957        c.y_d.dim(),
1958        c.z_l.dim(),
1959        c.z_u.dim(),
1960        c.v_l.dim(),
1961        c.v_u.dim(),
1962    ]
1963}
1964
1965fn journal_level_from_int(v: i32) -> JournalLevel {
1966    match v.clamp(0, 12) {
1967        0 => JournalLevel::J_NONE,
1968        1 => JournalLevel::J_ERROR,
1969        2 => JournalLevel::J_STRONGWARNING,
1970        3 => JournalLevel::J_SUMMARY,
1971        4 => JournalLevel::J_WARNING,
1972        5 => JournalLevel::J_ITERSUMMARY,
1973        6 => JournalLevel::J_DETAILED,
1974        7 => JournalLevel::J_MOREDETAILED,
1975        8 => JournalLevel::J_VECTOR,
1976        9 => JournalLevel::J_MOREVECTOR,
1977        10 => JournalLevel::J_MATRIX,
1978        11 => JournalLevel::J_MOREMATRIX,
1979        _ => JournalLevel::J_ALL,
1980    }
1981}
1982
1983/// Default symmetric linear-solver factory, parameterized by the
1984/// pounce-extension FERAL knobs read off the application's
1985/// `OptionsList`.
1986///
1987/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
1988/// available when the `ma57` cargo feature is enabled; without it,
1989/// requesting `linear_solver = ma57` falls back to FERAL with a
1990/// warning printed by the journalist (see [`AlgorithmBuilder`]).
1991pub fn default_backend_factory(feral_cfg: pounce_feral::FeralConfig) -> LinearBackendFactory {
1992    Box::new(
1993        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
1994            match choice {
1995                LinearSolverChoice::Feral => Box::new(
1996                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
1997                ),
1998                LinearSolverChoice::Ma57 => {
1999                    #[cfg(feature = "ma57")]
2000                    {
2001                        Box::new(pounce_hsl::Ma57SolverInterface::new())
2002                    }
2003                    #[cfg(not(feature = "ma57"))]
2004                    {
2005                        // ma57 feature not compiled in — fall back to FERAL.
2006                        Box::new(pounce_feral::FeralSolverInterface::with_config(
2007                            feral_cfg.clone(),
2008                        ))
2009                    }
2010                }
2011            }
2012        },
2013    )
2014}
2015
2016/// Sink-aware variant of [`default_backend_factory`]. Identical
2017/// dispatch, but the FERAL backend is constructed with a
2018/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
2019/// aggregate post-mortem stats (factor counts, fill ratio, extremal
2020/// pivots, final inertia) after the solve returns. MA57 ignores the
2021/// sink — the HSL backend doesn't carry the same instrumentation yet.
2022pub fn default_backend_factory_with_sink(
2023    feral_cfg: pounce_feral::FeralConfig,
2024    sink: Arc<Mutex<LinearSolverSummary>>,
2025) -> LinearBackendFactory {
2026    Box::new(
2027        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
2028            match choice {
2029                LinearSolverChoice::Feral => Box::new(
2030                    pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
2031                        .with_summary_sink(Arc::clone(&sink)),
2032                ),
2033                LinearSolverChoice::Ma57 => {
2034                    #[cfg(feature = "ma57")]
2035                    {
2036                        Box::new(pounce_hsl::Ma57SolverInterface::new())
2037                    }
2038                    #[cfg(not(feature = "ma57"))]
2039                    {
2040                        Box::new(
2041                            pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
2042                                .with_summary_sink(Arc::clone(&sink)),
2043                        )
2044                    }
2045                }
2046            }
2047        },
2048    )
2049}
2050
2051/// Read the `feral_*` extension options off `options`, falling
2052/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
2053/// for any knob the caller did not set explicitly. The returned
2054/// config is what every default-factory invocation (main IPM and
2055/// restoration sub-IPM) consumes.
2056pub fn feral_config_from_options(
2057    options: &pounce_common::options_list::OptionsList,
2058) -> pounce_feral::FeralConfig {
2059    let mut cfg = pounce_feral::FeralConfig::from_env();
2060    // Tri-state: the `(_, true)` arm only fires when the user set the
2061    // option explicitly. Leaving it unset keeps `cfg.cascade_break` at
2062    // `None`, which inherits FERAL's `NumericParams::default()` (CB on
2063    // as of FERAL Phase B / pounce#55). `Some(false)` explicitly
2064    // disarms (reproduces pre-Phase-B behaviour, surfaces FERAL's
2065    // `DelayBudgetExceeded` on non-root cascade victims).
2066    if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
2067        cfg.cascade_break = Some(v);
2068    }
2069    if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
2070        cfg.fma = v;
2071    }
2072    if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
2073        cfg.refine = v;
2074    }
2075    if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
2076        cfg.singular_pivot_floor = v;
2077    }
2078    if let Ok((v, true)) = options.get_numeric_value("feral_pivtol", "") {
2079        cfg.pivtol = v;
2080    }
2081    // Only override on explicit set so `from_env` (which itself
2082    // defaults to OrderingMethod::Auto) keeps governing unset cases.
2083    // Unrecognized tags are silently ignored — the registered enum
2084    // restricts inputs at the OptionsList layer.
2085    if let Ok((v, true)) = options.get_string_value("feral_ordering", "") {
2086        if let Some(m) = pounce_feral::parse_ordering_method(&v) {
2087            cfg.ordering = m;
2088        }
2089    }
2090    // Same explicit-set discipline as `feral_ordering`: `from_env`
2091    // defaults to ScalingStrategy::Auto (FERAL's current default), so
2092    // leaving the option unset preserves existing behaviour exactly.
2093    if let Ok((v, true)) = options.get_string_value("feral_scaling", "") {
2094        if let Some(s) = pounce_feral::parse_scaling_strategy(&v) {
2095            cfg.scaling = s;
2096        }
2097    }
2098    cfg
2099}
2100
2101/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
2102/// Mirrors the table in
2103/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
2104/// map") and the corresponding switch in
2105/// `IpIpoptApplication.cpp:call_optimize`.
2106fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
2107    match s {
2108        SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
2109        SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
2110        SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
2111        SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
2112        SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
2113        SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
2114        SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
2115        SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
2116        SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
2117        SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
2118        SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
2119        SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
2120        SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
2121        SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
2122        SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
2123        SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
2124        SolverReturn::InternalError | SolverReturn::Unassigned => {
2125            ApplicationReturnStatus::InternalError
2126        }
2127    }
2128}
2129
2130/// Best-effort evaluation of the objective at the algorithm's final
2131/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
2132/// to populate `SolveStatistics::final_scaled_objective`.
2133fn try_eval_curr_f(
2134    nlp: &Rc<RefCell<dyn IpoptNlp>>,
2135    x: &Rc<dyn pounce_linalg::Vector>,
2136) -> Result<Number, ()> {
2137    let mut nlp_mut = nlp.borrow_mut();
2138    Ok(nlp_mut.eval_f(&**x))
2139}
2140
2141/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
2142/// `true` when a status warrants a retry through the wrapper. Mirrors
2143/// ripopt#23's trigger set, extended per the audit's Refinement B
2144/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
2145/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
2146/// equivalent would let pass to the wrapper).
2147fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
2148    matches!(
2149        status,
2150        ApplicationReturnStatus::RestorationFailed
2151            | ApplicationReturnStatus::InfeasibleProblemDetected
2152            | ApplicationReturnStatus::SolvedToAcceptableLevel
2153            | ApplicationReturnStatus::MaximumIterationsExceeded
2154            | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
2155    )
2156}
2157
2158/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
2159/// We pull `x` (compressed in `x_var`-space) off the algorithm's
2160/// `data.curr`, lift it back to full TNLP indexing, and pass empty
2161/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
2162/// in compressed split form — re-assembling them into the user's
2163/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
2164/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
2165/// fleshed out). On success returns the unscaled objective evaluated
2166/// on the user TNLP at the final iterate; returns `Err` if the final
2167/// iterate is missing.
2168fn finalize_via_orig_nlp(
2169    nlp: &Rc<RefCell<dyn IpoptNlp>>,
2170    alg: &IpoptAlgorithm,
2171    solver_status: SolverReturn,
2172    _app_status: ApplicationReturnStatus,
2173    tnlp: &Rc<RefCell<dyn TNLP>>,
2174) -> Result<Number, ()> {
2175    let curr = alg.data.borrow().curr.clone().ok_or(())?;
2176    // Lift compressed x_var → full-x (length `info.n`) so the user
2177    // TNLP receives the same shape it provided. With `make_parameter`
2178    // the fixed components are spliced back in by the IpoptNlp.
2179    let nlp_borrow = nlp.borrow();
2180    let x_vec: Vec<Number> = nlp_borrow.lift_x_to_full(&*curr.x);
2181    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
2182    let n = info.n as usize;
2183    let m = info.m as usize;
2184    debug_assert_eq!(x_vec.len(), n);
2185    // Lift algorithm-side multipliers back into user-space (pounce#11).
2186    // Backends without overrides return empty; fall back to zero stubs
2187    // so the user sees a length-consistent vector.
2188    let mut z_l = nlp_borrow.pack_z_l_for_user(&*curr.z_l);
2189    if z_l.is_empty() {
2190        z_l = vec![0.0; n];
2191    }
2192    let mut z_u = nlp_borrow.pack_z_u_for_user(&*curr.z_u);
2193    if z_u.is_empty() {
2194        z_u = vec![0.0; n];
2195    }
2196    let mut lambda = nlp_borrow.pack_lambda_for_user(&*curr.y_c, &*curr.y_d);
2197    if lambda.is_empty() {
2198        lambda = vec![0.0; m];
2199    }
2200    drop(nlp_borrow);
2201    // Compute g(x) via the user TNLP so the final residual is
2202    // populated for the user.
2203    let mut g_final = vec![0.0; m];
2204    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
2205    let f_final = tnlp
2206        .borrow_mut()
2207        .eval_f(&x_vec, true)
2208        .unwrap_or(Number::NAN);
2209    tnlp.borrow_mut().finalize_solution(
2210        Solution {
2211            status: solver_status,
2212            x: &x_vec,
2213            z_l: &z_l,
2214            z_u: &z_u,
2215            g: &g_final,
2216            lambda: &lambda,
2217            obj_value: f_final,
2218        },
2219        &TnlpIpoptData::default(),
2220        &TnlpIpoptCq::default(),
2221    );
2222    Ok(f_final)
2223}
2224
2225/// Bind SQP suboptions registered in `upstream_options.rs`
2226/// (`sqp_globalization`, `sqp_hessian`, `sqp_max_iter`, `sqp_tol`,
2227/// `sqp_constr_viol_tol`, `sqp_dual_inf_tol`, `sqp_l1_penalty`,
2228/// `sqp_bt_reduction`, `sqp_bt_min_alpha`, `sqp_print_level`,
2229/// `sqp_lbfgs_max_history`) onto
2230/// `opts`. Used by [`IpoptApplication::algorithm_builder_snapshot`]
2231/// before constructing an SQP algorithm.
2232fn apply_sqp_options(options: &OptionsList, opts: &mut crate::sqp::SqpOptions) {
2233    use crate::sqp::{SqpGlobalization, SqpHessianSource};
2234
2235    if let Ok((s, true)) = options.get_string_value("sqp_globalization", "") {
2236        opts.globalization = match s.as_str() {
2237            "filter" => SqpGlobalization::Filter,
2238            "l1-elastic" => SqpGlobalization::L1Elastic,
2239            _ => opts.globalization,
2240        };
2241    }
2242    if let Ok((s, true)) = options.get_string_value("sqp_hessian", "") {
2243        opts.hessian = match s.as_str() {
2244            "exact" => SqpHessianSource::Exact,
2245            "damped-bfgs" => SqpHessianSource::DampedBfgs,
2246            "lbfgs" => SqpHessianSource::Lbfgs,
2247            _ => opts.hessian,
2248        };
2249    }
2250    if let Ok((v, true)) = options.get_integer_value("sqp_max_iter", "") {
2251        if v >= 0 {
2252            opts.max_iter = v as u32;
2253        }
2254    }
2255    if let Ok((v, true)) = options.get_numeric_value("sqp_tol", "") {
2256        opts.tol = v;
2257    }
2258    if let Ok((v, true)) = options.get_numeric_value("sqp_constr_viol_tol", "") {
2259        opts.constr_viol_tol = v;
2260    }
2261    if let Ok((v, true)) = options.get_numeric_value("sqp_dual_inf_tol", "") {
2262        opts.dual_inf_tol = v;
2263    }
2264    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty", "") {
2265        opts.l1_penalty = v;
2266    }
2267    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_safety", "") {
2268        opts.l1_penalty_safety = v;
2269    }
2270    if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_max", "") {
2271        opts.l1_penalty_max = v;
2272    }
2273    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_reduction", "") {
2274        opts.bt_reduction = v;
2275    }
2276    if let Ok((v, true)) = options.get_numeric_value("sqp_bt_min_alpha", "") {
2277        opts.bt_min_alpha = v;
2278    }
2279    if let Ok((v, true)) = options.get_integer_value("sqp_print_level", "") {
2280        opts.print_level = v.clamp(0, u8::MAX as i32) as u8;
2281    }
2282    if let Ok((v, true)) = options.get_integer_value("sqp_lbfgs_max_history", "") {
2283        if v >= 1 {
2284            opts.lbfgs_max_history = v as u32;
2285        }
2286    }
2287}
2288
2289/// SQP-side analog of [`finalize_via_orig_nlp`]. Hands the SQP
2290/// solution iterate to the user TNLP via the standard
2291/// `finalize_solution` callback. Multiplier lifting goes through
2292/// the same OrigIpoptNlp hooks so the user sees the same shape
2293/// regardless of which algorithm produced the iterate.
2294///
2295/// Returns the user-space objective value on success.
2296fn finalize_via_sqp(
2297    nlp: &Rc<RefCell<dyn IpoptNlp>>,
2298    res: &crate::sqp::SqpResult,
2299    solver_status: pounce_nlp::SolverReturn,
2300    tnlp: &Rc<RefCell<dyn TNLP>>,
2301) -> Result<Number, ()> {
2302    use pounce_linalg::dense_vector::DenseVectorSpace;
2303
2304    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
2305    let n = info.n as usize;
2306    let m = info.m as usize;
2307
2308    // Wrap SQP slices in DenseVectors so we can pass them through
2309    // the OrigIpoptNlp lift_x_to_full / pack_*_for_user hooks.
2310    let nlp_borrow = nlp.borrow();
2311    let n_alg = nlp_borrow.n() as usize;
2312    let m_eq = nlp_borrow.m_eq() as usize;
2313    let m_ineq = nlp_borrow.m_ineq() as usize;
2314    debug_assert_eq!(res.x.len(), n_alg);
2315    debug_assert_eq!(res.lambda_g.len(), m_eq + m_ineq);
2316    debug_assert_eq!(res.lambda_x.len(), n_alg);
2317
2318    let x_space = DenseVectorSpace::new(n_alg as Index);
2319    let c_space = DenseVectorSpace::new(m_eq as Index);
2320    let d_space = DenseVectorSpace::new(m_ineq as Index);
2321
2322    let mut x_dv = x_space.make_new_dense();
2323    x_dv.set_values(&res.x);
2324    let x_vec: Vec<Number> = nlp_borrow.lift_x_to_full(&x_dv);
2325    debug_assert_eq!(x_vec.len(), n);
2326
2327    // λ_x is packed signed (z_l − z_u). Split for lift.
2328    let mut z_l_compressed = x_space.make_new_dense();
2329    let mut z_u_compressed = x_space.make_new_dense();
2330    let zl_vals: Vec<Number> = res.lambda_x.iter().map(|v| v.max(0.0)).collect();
2331    let zu_vals: Vec<Number> = res.lambda_x.iter().map(|v| (-v).max(0.0)).collect();
2332    z_l_compressed.set_values(&zl_vals);
2333    z_u_compressed.set_values(&zu_vals);
2334    let mut z_l = nlp_borrow.pack_z_l_for_user(&z_l_compressed);
2335    if z_l.is_empty() {
2336        z_l = vec![0.0; n];
2337    }
2338    let mut z_u = nlp_borrow.pack_z_u_for_user(&z_u_compressed);
2339    if z_u.is_empty() {
2340        z_u = vec![0.0; n];
2341    }
2342
2343    // λ_g is [y_c; y_d]; split into the c/d blocks for lift.
2344    let mut y_c_dv = c_space.make_new_dense();
2345    let mut y_d_dv = d_space.make_new_dense();
2346    if m_eq > 0 {
2347        y_c_dv.set_values(&res.lambda_g[..m_eq]);
2348    }
2349    if m_ineq > 0 {
2350        y_d_dv.set_values(&res.lambda_g[m_eq..]);
2351    }
2352    let mut lambda = nlp_borrow.pack_lambda_for_user(&y_c_dv, &y_d_dv);
2353    if lambda.is_empty() {
2354        lambda = vec![0.0; m];
2355    }
2356    drop(nlp_borrow);
2357
2358    let mut g_final = vec![0.0; m];
2359    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
2360    let f_final = tnlp
2361        .borrow_mut()
2362        .eval_f(&x_vec, true)
2363        .unwrap_or(Number::NAN);
2364    tnlp.borrow_mut().finalize_solution(
2365        pounce_nlp::tnlp::Solution {
2366            status: solver_status,
2367            x: &x_vec,
2368            z_l: &z_l,
2369            z_u: &z_u,
2370            g: &g_final,
2371            lambda: &lambda,
2372            obj_value: f_final,
2373        },
2374        &TnlpIpoptData::default(),
2375        &TnlpIpoptCq::default(),
2376    );
2377    Ok(f_final)
2378}
2379
2380#[cfg(test)]
2381mod tests {
2382    use super::*;
2383    use pounce_nlp::tnlp::{
2384        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest,
2385        StartingPoint,
2386    };
2387
2388    struct Hs071Stub;
2389    impl TNLP for Hs071Stub {
2390        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2391            // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
2392            // dense lower-triangular Hessian (10 nz).
2393            Some(NlpInfo {
2394                n: 4,
2395                m: 2,
2396                nnz_jac_g: 8,
2397                nnz_h_lag: 10,
2398                index_style: IndexStyle::C,
2399            })
2400        }
2401        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2402            b.x_l.copy_from_slice(&[1.0; 4]);
2403            b.x_u.copy_from_slice(&[5.0; 4]);
2404            b.g_l.copy_from_slice(&[25.0, 40.0]);
2405            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
2406            true
2407        }
2408        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2409            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
2410            true
2411        }
2412        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
2413            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
2414        }
2415        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
2416            grad.fill(0.0);
2417            true
2418        }
2419        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
2420            g.fill(0.0);
2421            true
2422        }
2423        fn eval_jac_g(
2424            &mut self,
2425            _x: Option<&[Number]>,
2426            _new_x: bool,
2427            mode: SparsityRequest<'_>,
2428        ) -> bool {
2429            if let SparsityRequest::Structure { irow, jcol } = mode {
2430                irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
2431                jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
2432            }
2433            true
2434        }
2435        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
2436    }
2437
2438    #[test]
2439    fn application_default_does_not_select_sqp() {
2440        let mut app = IpoptApplication::new();
2441        app.initialize().unwrap();
2442        assert!(!app.is_sqp_algorithm_selected());
2443    }
2444
2445    #[test]
2446    fn application_routes_to_sqp_when_algorithm_option_set() {
2447        let mut app = IpoptApplication::new();
2448        app.initialize().unwrap();
2449        app.initialize_with_options_str("algorithm active-set-sqp\n")
2450            .unwrap();
2451        assert!(app.is_sqp_algorithm_selected());
2452    }
2453
2454    /// Convex equality NLP fixture for end-to-end SQP testing
2455    /// through `IpoptApplication`:
2456    ///
2457    ///     min ½(x₁² + x₂²) − x₁ − 2x₂  s.t.  x₁ + x₂ = 1
2458    ///
2459    /// Closed form: x* = (0, 1), obj = -1.5, λ_g = 1.
2460    struct ConvexEqTnlp {
2461        finalize_called: std::rc::Rc<std::cell::RefCell<Option<(Vec<Number>, Number)>>>,
2462    }
2463    impl TNLP for ConvexEqTnlp {
2464        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2465            Some(NlpInfo {
2466                n: 2,
2467                m: 1,
2468                nnz_jac_g: 2,
2469                nnz_h_lag: 2,
2470                index_style: IndexStyle::C,
2471            })
2472        }
2473        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2474            b.x_l.copy_from_slice(&[-2.0e19; 2]);
2475            b.x_u.copy_from_slice(&[2.0e19; 2]);
2476            b.g_l.copy_from_slice(&[1.0]);
2477            b.g_u.copy_from_slice(&[1.0]);
2478            true
2479        }
2480        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2481            sp.x.copy_from_slice(&[0.0, 0.0]);
2482            true
2483        }
2484        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
2485            Some(0.5 * (x[0] * x[0] + x[1] * x[1]) - x[0] - 2.0 * x[1])
2486        }
2487        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
2488            grad[0] = x[0] - 1.0;
2489            grad[1] = x[1] - 2.0;
2490            true
2491        }
2492        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
2493            g[0] = x[0] + x[1];
2494            true
2495        }
2496        fn eval_jac_g(
2497            &mut self,
2498            _x: Option<&[Number]>,
2499            _new_x: bool,
2500            mode: SparsityRequest<'_>,
2501        ) -> bool {
2502            match mode {
2503                SparsityRequest::Structure { irow, jcol } => {
2504                    irow.copy_from_slice(&[0, 0]);
2505                    jcol.copy_from_slice(&[0, 1]);
2506                }
2507                SparsityRequest::Values { values, .. } => {
2508                    values.copy_from_slice(&[1.0, 1.0]);
2509                }
2510            }
2511            true
2512        }
2513        fn eval_h(
2514            &mut self,
2515            _x: Option<&[Number]>,
2516            _new_x: bool,
2517            _obj_factor: Number,
2518            _lambda: Option<&[Number]>,
2519            _new_lambda: bool,
2520            mode: SparsityRequest<'_>,
2521        ) -> bool {
2522            match mode {
2523                SparsityRequest::Structure { irow, jcol } => {
2524                    irow.copy_from_slice(&[0, 1]);
2525                    jcol.copy_from_slice(&[0, 1]);
2526                }
2527                SparsityRequest::Values { values, .. } => {
2528                    values.copy_from_slice(&[1.0, 1.0]);
2529                }
2530            }
2531            true
2532        }
2533        fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
2534            *self.finalize_called.borrow_mut() = Some((sol.x.to_vec(), sol.obj_value));
2535        }
2536    }
2537
2538    #[test]
2539    fn application_sqp_path_solves_convex_eq_nlp_and_finalizes() {
2540        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
2541        let tnlp = std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
2542            finalize_called: std::rc::Rc::clone(&finalize_slot),
2543        }));
2544
2545        let mut app = IpoptApplication::new();
2546        app.initialize().unwrap();
2547        app.initialize_with_options_str("algorithm active-set-sqp\n")
2548            .unwrap();
2549        let status = app.optimize_tnlp(tnlp);
2550        assert_eq!(status, ApplicationReturnStatus::SolveSucceeded);
2551
2552        // The TNLP's finalize_solution must have been invoked.
2553        let recv = finalize_slot.borrow().clone();
2554        let (x_recv, obj_recv) = recv.expect("finalize_solution was not called");
2555        assert_eq!(x_recv.len(), 2);
2556        assert!((x_recv[0] - 0.0).abs() < 1e-6, "x[0] = {}", x_recv[0]);
2557        assert!((x_recv[1] - 1.0).abs() < 1e-6, "x[1] = {}", x_recv[1]);
2558        assert!(
2559            (obj_recv - (-1.5)).abs() < 1e-6,
2560            "obj = {} but expected -1.5",
2561            obj_recv
2562        );
2563    }
2564
2565    #[test]
2566    fn application_routes_to_sqp_case_insensitively() {
2567        let mut app = IpoptApplication::new();
2568        app.initialize().unwrap();
2569        app.initialize_with_options_str("algorithm Active-Set-SQP\n")
2570            .unwrap();
2571        // get_string_value may return the value as-stored (no
2572        // normalization); the dispatch must handle case
2573        // insensitively per the c11 design choice.
2574        assert!(app.is_sqp_algorithm_selected());
2575    }
2576
2577    #[test]
2578    fn application_constructs_and_loads_options() {
2579        let mut app = IpoptApplication::new();
2580        app.initialize().unwrap();
2581        // ipopt.opt-style file: an integer-typed option registered by
2582        // the Interfaces layer.
2583        app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
2584            .unwrap();
2585        let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
2586        assert!(found);
2587        assert_eq!(level, 5);
2588    }
2589
2590    #[test]
2591    fn application_sqp_suboptions_propagate_to_builder() {
2592        // All SQP suboptions are read by algorithm_builder_snapshot
2593        // and baked into the builder's `sqp` field.
2594        let mut app = IpoptApplication::new();
2595        app.initialize().unwrap();
2596        app.initialize_with_options_str(
2597            "algorithm active-set-sqp\n\
2598             sqp_globalization l1-elastic\n\
2599             sqp_hessian lbfgs\n\
2600             sqp_max_iter 17\n\
2601             sqp_tol 1e-7\n\
2602             sqp_constr_viol_tol 1e-5\n\
2603             sqp_dual_inf_tol 1e-3\n\
2604             sqp_l1_penalty 2.5\n\
2605             sqp_bt_reduction 0.25\n\
2606             sqp_bt_min_alpha 1e-10\n\
2607             sqp_print_level 2\n\
2608             sqp_lbfgs_max_history 12\n",
2609        )
2610        .unwrap();
2611        let snap = app.algorithm_builder_snapshot();
2612        assert_eq!(
2613            snap.sqp.globalization,
2614            crate::sqp::SqpGlobalization::L1Elastic
2615        );
2616        assert_eq!(snap.sqp.hessian, crate::sqp::SqpHessianSource::Lbfgs);
2617        assert_eq!(snap.sqp.max_iter, 17);
2618        assert!((snap.sqp.tol - 1e-7).abs() < 1e-18);
2619        assert!((snap.sqp.constr_viol_tol - 1e-5).abs() < 1e-18);
2620        assert!((snap.sqp.dual_inf_tol - 1e-3).abs() < 1e-18);
2621        assert!((snap.sqp.l1_penalty - 2.5).abs() < 1e-18);
2622        assert!((snap.sqp.bt_reduction - 0.25).abs() < 1e-18);
2623        assert!((snap.sqp.bt_min_alpha - 1e-10).abs() < 1e-18);
2624        assert_eq!(snap.sqp.print_level, 2);
2625        assert_eq!(snap.sqp.lbfgs_max_history, 12);
2626    }
2627
2628    #[test]
2629    fn application_sqp_warm_start_round_trip() {
2630        // Drive the convex-equality TNLP through the SQP path
2631        // twice. The first solve produces a working set; the
2632        // second is warm-started from it. The second must converge
2633        // with zero QP solves (the first KKT check declares
2634        // optimality immediately).
2635        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
2636        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
2637            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
2638                finalize_called: std::rc::Rc::clone(&finalize_slot),
2639            }));
2640
2641        let mut app = IpoptApplication::new();
2642        app.initialize().unwrap();
2643        app.initialize_with_options_str("algorithm active-set-sqp\n")
2644            .unwrap();
2645
2646        // Cold solve.
2647        let status_a = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
2648        assert_eq!(status_a, ApplicationReturnStatus::SolveSucceeded);
2649        let ws = app.last_sqp_working_set().cloned();
2650        assert!(ws.is_some(), "cold solve must yield a working set");
2651
2652        // Build the warm-start iterate from the converged finalize
2653        // payload (just x; pad multipliers to 0 since the test
2654        // problem is convex).
2655        let (x_recv, _) = finalize_slot.borrow().clone().unwrap();
2656        let warm = crate::sqp::SqpIterates {
2657            x: x_recv,
2658            lambda_g: vec![1.0],
2659            lambda_x: vec![0.0, 0.0],
2660            working: ws,
2661        };
2662        app.set_sqp_warm_start(warm);
2663
2664        // Warm solve.
2665        let status_b = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
2666        assert_eq!(status_b, ApplicationReturnStatus::SolveSucceeded);
2667        assert!(app.last_sqp_working_set().is_some());
2668    }
2669
2670    #[test]
2671    fn application_sqp_warm_start_auto_clears_after_use() {
2672        let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
2673        let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
2674            std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
2675                finalize_called: std::rc::Rc::clone(&finalize_slot),
2676            }));
2677        let mut app = IpoptApplication::new();
2678        app.initialize().unwrap();
2679        app.initialize_with_options_str("algorithm active-set-sqp\n")
2680            .unwrap();
2681        app.set_sqp_warm_start(crate::sqp::SqpIterates {
2682            x: vec![0.0, 1.0],
2683            lambda_g: vec![1.0],
2684            lambda_x: vec![0.0, 0.0],
2685            working: None,
2686        });
2687        assert!(app.sqp_warm_start.is_some());
2688        let _ = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
2689        assert!(
2690            app.sqp_warm_start.is_none(),
2691            "warm-start input must be auto-cleared after use"
2692        );
2693    }
2694
2695    #[test]
2696    fn application_sqp_suboptions_default_when_unset() {
2697        // Without any sqp_* settings, the snapshot should equal
2698        // SqpOptions::default().
2699        let mut app = IpoptApplication::new();
2700        app.initialize().unwrap();
2701        let snap = app.algorithm_builder_snapshot();
2702        let d = crate::sqp::SqpOptions::default();
2703        assert_eq!(snap.sqp.globalization, d.globalization);
2704        assert_eq!(snap.sqp.hessian, d.hessian);
2705        assert_eq!(snap.sqp.max_iter, d.max_iter);
2706        assert!((snap.sqp.tol - d.tol).abs() < 1e-18);
2707        assert!((snap.sqp.constr_viol_tol - d.constr_viol_tol).abs() < 1e-18);
2708        assert!((snap.sqp.dual_inf_tol - d.dual_inf_tol).abs() < 1e-18);
2709        assert!((snap.sqp.l1_penalty - d.l1_penalty).abs() < 1e-18);
2710        assert!((snap.sqp.bt_reduction - d.bt_reduction).abs() < 1e-18);
2711        assert!((snap.sqp.bt_min_alpha - d.bt_min_alpha).abs() < 1e-18);
2712        assert_eq!(snap.sqp.print_level, d.print_level);
2713        assert_eq!(snap.sqp.lbfgs_max_history, d.lbfgs_max_history);
2714    }
2715
2716    #[test]
2717    fn application_reports_problem_dimensions() {
2718        let app = IpoptApplication::new();
2719        let mut tnlp = Hs071Stub;
2720        let info = app.problem_dimensions(&mut tnlp).unwrap();
2721        assert_eq!(info.n, 4);
2722        assert_eq!(info.m, 2);
2723        assert_eq!(info.nnz_jac_g, 8);
2724        assert_eq!(info.nnz_h_lag, 10);
2725    }
2726}