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