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::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}
158
159impl fmt::Debug for IpoptApplication {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.debug_struct("IpoptApplication")
162            .field("options", &self.options)
163            .field("statistics", &self.statistics)
164            .finish_non_exhaustive()
165    }
166}
167
168impl Default for IpoptApplication {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl IpoptApplication {
175    /// New application with empty options and a default journalist.
176    /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
177    pub fn new() -> Self {
178        let reg = RegisteredOptions::default();
179        // Registration of a fresh registry can only fail on a duplicate
180        // name, which would be a programming error in `reg_op`.
181        register_all_upstream_options(&reg)
182            .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
183        pounce_presolve::register_options(&reg)
184            .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
185        let reg = Rc::new(reg);
186        Self {
187            options: OptionsList::with_registered(Rc::clone(&reg)),
188            reg_options: reg,
189            journalist: Rc::new(Journalist::new()),
190            statistics: RefCell::new(SolveStatistics::new()),
191            timing: RefCell::new(Rc::new(TimingStatistics::new())),
192            linear_backend_factory: None,
193            restoration_factory: None,
194            diagnostics: None,
195            restoration_factory_provider: None,
196            on_converged: None,
197            record_iter_history: false,
198            linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
199        }
200    }
201
202    pub fn options(&self) -> &OptionsList {
203        &self.options
204    }
205
206    pub fn options_mut(&mut self) -> &mut OptionsList {
207        &mut self.options
208    }
209
210    pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
211        &self.reg_options
212    }
213
214    pub fn journalist(&self) -> &Rc<Journalist> {
215        &self.journalist
216    }
217
218    /// Plug a custom symmetric-linear-solver factory. Useful for tests
219    /// that want to swap MA57 for a stub. Production callers should
220    /// leave this unset — the default ([`default_backend_factory`])
221    /// returns the workspace's MA57 binding.
222    pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
223        self.linear_backend_factory = Some(factory);
224    }
225
226    /// Plug a restoration-phase factory. Called once per
227    /// `optimize_tnlp` invocation to mint a fresh
228    /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
229    /// its line-search restoration fallback. Lives behind a setter
230    /// (rather than at construction) because the concrete restoration
231    /// strategies live in `pounce-restoration`, which depends on this
232    /// crate; consumers in `pounce-cli` / integration tests wire the
233    /// factory at the application boundary.
234    pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
235        self.restoration_factory = Some(factory);
236    }
237
238    /// Install the shared diagnostics state. Once set, every
239    /// subsequent `optimize_tnlp` call forwards the state into the
240    /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
241    /// solver can emit `--dump kkt:...` artifacts.
242    pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
243        self.diagnostics = Some(diag);
244    }
245
246    /// Read-side accessor for the installed diagnostics state, if any.
247    /// Lets the CLI write the top-level manifest/timing files after
248    /// the solve completes.
249    pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
250        self.diagnostics.as_ref().map(Rc::clone)
251    }
252
253    /// Plug a restoration-phase **factory provider** for drivers that
254    /// need to run the inner IPM more than once per `optimize_tnlp`
255    /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
256    /// pounce#10). On each inner solve, the application consults the
257    /// provider to mint a fresh [`RestorationFactory`], replacing any
258    /// stale one, so the default one-shot restoration factory does
259    /// not panic on its second invocation. If both `set_restoration_factory`
260    /// and this are configured, the provider wins.
261    pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
262        self.restoration_factory_provider = Some(provider);
263    }
264
265    /// Register a callback to run once the IPM has converged (status
266    /// [`ApplicationReturnStatus::SolveSucceeded`] or
267    /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
268    /// `finalize_solution` flows back to the TNLP. See
269    /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
270    pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
271        self.on_converged = Some(cb);
272    }
273
274    /// Enable per-iteration trajectory capture. After the solve
275    /// returns, [`Self::statistics()`] exposes
276    /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
277    /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
278    /// per accepted iterate. Off by default — the `pounce_sens` and
279    /// `pounce` binaries opt in when `--json-output` is passed.
280    pub fn enable_iter_history(&mut self) {
281        self.record_iter_history = true;
282    }
283
284    /// Read an `ipopt.opt`-format options file. Equivalent to
285    /// `IpoptApplication::Initialize(const std::string& options_file)`.
286    pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
287        let txt = std::fs::read_to_string(path).map_err(|e| {
288            SolverException::new(
289                ExceptionKind::IPOPT_APPLICATION_ERROR,
290                format!("could not read options file {}: {}", path.display(), e),
291                file!(),
292                line!() as Index,
293            )
294        })?;
295        self.options.read_from_str(&txt, true)?;
296        self.open_output_file_journal();
297        Ok(())
298    }
299
300    /// Read options from a string in `ipopt.opt` format. Useful for
301    /// tests and embedded callers.
302    pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
303        self.options.read_from_str(s, true)?;
304        self.open_output_file_journal();
305        Ok(())
306    }
307
308    /// Honor `output_file` / `file_print_level` / `file_append`: when
309    /// `output_file` is non-empty, attach a `FileJournal` named
310    /// `"OutputFile:<fname>"` at the requested level. Mirrors
311    /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
312    /// No-op if `output_file` is unset, empty, or could not be opened.
313    ///
314    /// NOTE: pounce's iteration output currently bypasses the
315    /// journalist and writes directly to stdout. The file journal is
316    /// attached and the timing report (gated by `print_timing_statistics`)
317    /// is mirrored to it; per-iter rows will start landing in the file
318    /// once the iter-output path is routed through the journalist.
319    fn open_output_file_journal(&self) {
320        let fname = match self.options.get_string_value("output_file", "") {
321            Ok((v, true)) if !v.is_empty() => v,
322            _ => return,
323        };
324        let level_int = self
325            .options
326            .get_integer_value("file_print_level", "")
327            .ok()
328            .and_then(|(v, f)| f.then_some(v))
329            .unwrap_or(5);
330        let level = journal_level_from_int(level_int);
331        let append = self
332            .options
333            .get_bool_value("file_append", "")
334            .ok()
335            .and_then(|(v, f)| f.then_some(v))
336            .unwrap_or(false);
337        let jname = format!("OutputFile:{}", fname);
338        let _ = self
339            .journalist
340            .add_file_journal(&jname, &fname, level, append);
341    }
342
343    /// No-op initialize (just succeeds). Mirrors
344    /// `IpoptApplication::Initialize(bool allow_clobber)` with no
345    /// options file.
346    pub fn initialize(&mut self) -> Result<(), SolverException> {
347        Ok(())
348    }
349
350    /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
351    /// / `file_print_level` options and attaches a matching
352    /// `FileJournal` named `OutputFile:<fname>` to the journalist.
353    /// Returns `false` if the file could not be opened or the option
354    /// store rejected the request (e.g. clamped print level).
355    pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
356        if self
357            .options
358            .set_string_value("output_file", fname, true, false)
359            .is_err()
360        {
361            return false;
362        }
363        if self
364            .options
365            .set_integer_value("file_print_level", print_level as Index, true, false)
366            .is_err()
367        {
368            return false;
369        }
370        let level = journal_level_from_int(print_level);
371        let jname = format!("OutputFile:{}", fname);
372        // Drop any previous file journal so a second call switches files
373        // cleanly. `add_file_journal` would otherwise refuse to attach
374        // a duplicate by name; remove-by-name isn't in the journalist
375        // API, so we settle for the name-collision case here.
376        self.journalist
377            .add_file_journal(&jname, fname, level, false)
378            .is_some()
379    }
380
381    /// Wrap a TNLP and report problem dimensions. Used in tests until
382    /// the full IPM path covers every entry shape.
383    pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
384        tnlp.get_nlp_info()
385    }
386
387    pub fn statistics(&self) -> SolveStatistics {
388        self.statistics.borrow().clone()
389    }
390
391    /// Shared timing accumulator from the most recent `optimize_tnlp`
392    /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
393    /// fields during the solve; consumers read totals out of the
394    /// returned `Rc`. The instance is replaced at the top of every
395    /// subsequent solve, so cloning the `Rc` and holding it past a
396    /// re-solve will give you the previous solve's timings — by design.
397    pub fn timing_stats(&self) -> Rc<TimingStatistics> {
398        Rc::clone(&self.timing.borrow())
399    }
400
401    /// Aggregate linear-solver post-mortem from the most recent
402    /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
403    /// backend ran at least one factor; `None` when no factors were
404    /// recorded (custom factory plugged via
405    /// [`Self::set_linear_backend_factory`], or solve aborted before
406    /// the first KKT factor). Reset at the top of every solve.
407    pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
408        let guard = self.linsol_summary_sink.lock().ok()?;
409        if guard.is_empty() {
410            None
411        } else {
412            Some(guard.clone())
413        }
414    }
415
416    /// Drive a solve.
417    ///
418    /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
419    ///   build a `TNLPAdapter` → `OrigIpoptNlp`, run the
420    ///   [`AlgorithmBuilder`] with the workspace MA57 backend, and
421    ///   call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
422    ///   `ApplicationReturnStatus` mapping mirrors the table in
423    ///   `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
424    ///   SolverReturn map").
425    /// * Unconstrained problems (`m == 0`) keep going through the
426    ///   in-`pounce-nlp` Newton driver so the trivial path is
427    ///   independent of the linear-solver backend.
428    pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
429        let info = match tnlp.borrow_mut().get_nlp_info() {
430            Some(info) => info,
431            None => return ApplicationReturnStatus::InvalidProblemDefinition,
432        };
433        // ℓ₁-exact penalty-barrier opt-in (pounce#10).
434        // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
435        // Waltz ρ-escalation loop around the constrained IPM, with a
436        // honest-infeasibility status upgrade when the slacks fail to
437        // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
438        // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
439        // no-op for problems with no equality rows, so the
440        // unconstrained dispatch below is unaffected when there is
441        // nothing to wrap.
442        if info.m > 0 && self.is_l1_penalty_enabled() {
443            if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
444                return status;
445            }
446            // Falls through: wrapper construction failed (inner refused
447            // get_nlp_info / get_bounds_info) or no equality rows to
448            // slack. Standard dispatch runs unmodified.
449        }
450        // Phase 3.5 auto-fallback (pounce#10): if the standard solve
451        // ends in a trigger-class status, retry transparently with
452        // the wrapper. Promote the retry's status only if it returns
453        // SolveSucceeded — otherwise return the original. Skipped if
454        // the user already opted into the wrapper above (this avoids
455        // a double pass and keeps semantics predictable).
456        if info.m > 0 && self.is_l1_fallback_enabled() && !self.is_l1_penalty_enabled() {
457            return self.run_with_l1_fallback(tnlp);
458        }
459        // Every problem — constrained or not — goes through the same
460        // primal-dual IPM, exactly as upstream Ipopt does. There is no
461        // separate "unconstrained Newton" path: the linear-solver
462        // backend (FERAL/MA57) handles the augmented system, so the
463        // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
464        // blowup.
465        self.optimize_constrained(tnlp)
466    }
467
468    /// Read the ℓ₁ wrapper master switch from the OptionsList.
469    /// Default `false` when the option is not set.
470    fn is_l1_penalty_enabled(&self) -> bool {
471        self.options
472            .get_bool_value("l1_exact_penalty_barrier", "")
473            .ok()
474            .and_then(|(v, found)| found.then_some(v))
475            .unwrap_or(false)
476    }
477
478    fn l1_penalty_init(&self) -> Number {
479        self.options
480            .get_numeric_value("l1_penalty_init", "")
481            .ok()
482            .and_then(|(v, found)| found.then_some(v))
483            .unwrap_or(1.0)
484    }
485    fn l1_penalty_max(&self) -> Number {
486        self.options
487            .get_numeric_value("l1_penalty_max", "")
488            .ok()
489            .and_then(|(v, found)| found.then_some(v))
490            .unwrap_or(1.0e6)
491    }
492    fn l1_penalty_increase_factor(&self) -> Number {
493        self.options
494            .get_numeric_value("l1_penalty_increase_factor", "")
495            .ok()
496            .and_then(|(v, found)| found.then_some(v))
497            .unwrap_or(8.0)
498    }
499    fn l1_penalty_max_outer_iter(&self) -> usize {
500        self.options
501            .get_integer_value("l1_penalty_max_outer_iter", "")
502            .ok()
503            .and_then(|(v, found)| found.then_some(v))
504            .unwrap_or(8) as usize
505    }
506    fn l1_slack_tol(&self) -> Number {
507        self.options
508            .get_numeric_value("l1_slack_tol", "")
509            .ok()
510            .and_then(|(v, found)| found.then_some(v))
511            .unwrap_or(1.0e-6)
512    }
513    fn l1_steering_factor(&self) -> Number {
514        self.options
515            .get_numeric_value("l1_steering_factor", "")
516            .ok()
517            .and_then(|(v, found)| found.then_some(v))
518            .unwrap_or(10.0)
519    }
520    fn is_l1_fallback_enabled(&self) -> bool {
521        self.options
522            .get_bool_value("l1_fallback_on_restoration_failure", "")
523            .ok()
524            .and_then(|(v, found)| found.then_some(v))
525            .unwrap_or(false)
526    }
527
528    /// Phase 3.5 auto-fallback driver.
529    ///
530    /// Runs the standard solve (no wrapper) first. If it ends in a
531    /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
532    /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
533    /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
534    /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
535    /// it returns `Solve_Succeeded`; otherwise returns the original
536    /// status.
537    ///
538    /// Caveat: the user TNLP's `finalize_solution` runs once per
539    /// attempt. When the retry doesn't promote, the user's captured
540    /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
541    /// point) even though the returned status is the original's.
542    /// Documented on the option's help text; tightening this is a
543    /// Phase-4 follow-up.
544    fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
545        // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
546        // reached for `m > 0`, so `optimize_constrained` is exact.
547        let first_status = self.optimize_constrained(Rc::clone(&tnlp));
548        if !is_l1_fallback_trigger(first_status) {
549            return first_status;
550        }
551        // Trigger fired. Flip the wrapper option for the retry and
552        // restore it after — keeps the user's option-table view of the
553        // session exactly as they left it.
554        let prev = self
555            .options
556            .get_string_value("l1_exact_penalty_barrier", "")
557            .ok();
558        let _ = self
559            .options
560            .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
561        let retry_status = self
562            .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
563            .unwrap_or(ApplicationReturnStatus::InternalError);
564        let _ = self.options.set_string_value(
565            "l1_exact_penalty_barrier",
566            prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
567            true,
568            false,
569        );
570        if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
571            retry_status
572        } else {
573            first_status
574        }
575    }
576
577    /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
578    ///
579    /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
580    /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
581    /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
582    ///   - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
583    ///   - inner solve returns non-Optimal (escalation won't fix
584    ///     numerical / restoration failure at this ρ)
585    ///   - ρ already at `l1_penalty_max`
586    ///   - `l1_penalty_max_outer_iter` reached
587    ///
588    /// After the loop, if the inner status is `SolveSucceeded` or
589    /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
590    /// to `Infeasible_Problem_Detected` — the returned point is the
591    /// ℓ₁-best least-infeasible iterate, which is informative even
592    /// though the original constraints are not satisfied.
593    ///
594    /// Returns `Some(status)` if the wrapper ran the solve, `None` if
595    /// wrapper construction failed (caller should fall through to the
596    /// standard dispatch path).
597    fn run_l1_penalty_outer_loop(
598        &mut self,
599        tnlp: Rc<RefCell<dyn TNLP>>,
600    ) -> Option<ApplicationReturnStatus> {
601        let rho_init = self.l1_penalty_init();
602        let rho_max = self.l1_penalty_max().max(rho_init);
603        let factor = self.l1_penalty_increase_factor().max(1.0);
604        let tau = self.l1_steering_factor();
605        let slack_tol = self.l1_slack_tol();
606        let max_outer = self.l1_penalty_max_outer_iter().max(1);
607
608        let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
609        if wrapper.m_eq() == 0 {
610            // Nothing to slack — let the standard dispatch path handle
611            // this TNLP unmodified.
612            return None;
613        }
614        wrapper.set_defer_inner_finalize(true);
615        let wrapper_rc = Rc::new(RefCell::new(wrapper));
616
617        let mut rho = rho_init;
618        let mut last_status = ApplicationReturnStatus::InternalError;
619        for _outer in 0..max_outer {
620            wrapper_rc.borrow_mut().set_rho(rho);
621            let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
622            last_status = self.optimize_constrained(dyn_tnlp);
623
624            let w = wrapper_rc.borrow();
625            if !w.has_solution() {
626                // Inner solve aborted before producing an iterate.
627                drop(w);
628                break;
629            }
630            let slack_sum = w.last_slack_sum();
631            let y_eq_inf = w.last_y_eq_inf_norm();
632            drop(w);
633
634            // Termination decisions.
635            let inner_ok = matches!(
636                last_status,
637                ApplicationReturnStatus::SolveSucceeded
638                    | ApplicationReturnStatus::SolvedToAcceptableLevel
639            );
640            if !inner_ok {
641                break;
642            }
643            if slack_sum.is_finite() && slack_sum <= slack_tol {
644                break;
645            }
646            if rho >= rho_max {
647                break;
648            }
649            // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
650            let geom = rho * factor;
651            let steer = tau * y_eq_inf + 1.0e-12;
652            rho = geom.max(steer).min(rho_max);
653        }
654
655        // Forward to the user's inner.finalize_solution exactly once.
656        let w = wrapper_rc.borrow();
657        if w.has_solution() {
658            let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
659            let lambda: Vec<Number> = w.last_lambda().to_vec();
660            let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
661            let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
662            let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
663            let slack_sum = w.last_slack_sum();
664            drop(w);
665
666            // Honest-infeasibility upgrade (Phase 3): if the inner
667            // solve says SolveSucceeded / SolvedToAcceptableLevel but
668            // the slacks did not collapse, the original problem is
669            // locally infeasible at the returned point. Override the
670            // application status; the user-visible Solution.status is
671            // updated below to the matching SolverReturn so the inner
672            // TNLP sees a consistent picture.
673            let infeasible_certificate = matches!(
674                last_status,
675                ApplicationReturnStatus::SolveSucceeded
676                    | ApplicationReturnStatus::SolvedToAcceptableLevel
677            ) && slack_sum.is_finite()
678                && slack_sum > slack_tol;
679            let final_app_status = if infeasible_certificate {
680                ApplicationReturnStatus::InfeasibleProblemDetected
681            } else {
682                last_status
683            };
684            let final_solver_status = if infeasible_certificate {
685                SolverReturn::LocalInfeasibility
686            } else {
687                solver_status
688            };
689
690            // Recompute f(x*) and c(x*) on the inner.
691            let f_inner = tnlp
692                .borrow_mut()
693                .eval_f(&x_trunc, true)
694                .unwrap_or(Number::NAN);
695            let m = tnlp
696                .borrow_mut()
697                .get_nlp_info()
698                .map(|i| i.m as usize)
699                .unwrap_or(0);
700            let mut g_inner = vec![0.0; m];
701            if m > 0 {
702                let _ = tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
703            }
704            tnlp.borrow_mut().finalize_solution(
705                Solution {
706                    status: final_solver_status,
707                    x: &x_trunc,
708                    z_l: &z_l,
709                    z_u: &z_u,
710                    g: &g_inner,
711                    lambda: &lambda,
712                    obj_value: f_inner,
713                },
714                &TnlpIpoptData::default(),
715                &TnlpIpoptCq::default(),
716            );
717            return Some(final_app_status);
718        }
719        // No solution captured at all — pass the inner status through.
720        Some(last_status)
721    }
722
723    /// **Stub.** Re-solve with a warm start. Phase 7+.
724    pub fn reoptimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
725        // Same dispatch as `optimize_tnlp` for now; warm-start handling
726        // lands once the IPM path's warm-start hooks are exposed.
727        self.optimize_tnlp(tnlp)
728    }
729
730    /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
731    /// bundle, run `optimize`, populate statistics, and call
732    /// `finalize_solution` on the user's TNLP.
733    fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
734        let t_start = Instant::now();
735
736        // `print_user_options yes` — dump the OptionsList before the
737        // solve. Mirrors `IpoptApplication::call_optimize` (upstream
738        // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
739        let print_opts = self
740            .options
741            .get_bool_value("print_user_options", "")
742            .ok()
743            .and_then(|(v, f)| f.then_some(v))
744            .unwrap_or(false);
745        if print_opts {
746            print!(
747                "\nList of user-set options:\n\n{}",
748                self.options.print_user_options()
749            );
750        }
751
752        // Mint a fresh `TimingStatistics` for this solve — shared (via
753        // `Rc`) with the data and the NLP below so every `eval_*` and
754        // every iterate-phase records into the same accumulator. The
755        // application keeps its own `Rc` so callers can read totals out
756        // via [`Self::timing_stats`].
757        let timing = Rc::new(TimingStatistics::new());
758        *self.timing.borrow_mut() = Rc::clone(&timing);
759        timing.overall_alg.start();
760
761        // Reset the linear-solver summary sink so back-to-back solves
762        // don't bleed factor counters / extremal pivots into each
763        // other. Surviving the lock failure with a debug-assert keeps
764        // a poisoned mutex from sinking a release build that doesn't
765        // even consume the summary.
766        if let Ok(mut guard) = self.linsol_summary_sink.lock() {
767            *guard = LinearSolverSummary::default();
768        } else {
769            debug_assert!(false, "linsol summary sink mutex poisoned");
770        }
771
772        // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
773        // `make_parameter`; pounce additionally implements `relax_bounds`,
774        // which the adapter also auto-selects as a fallback when
775        // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
776        // `IpTNLPAdapter.cpp:623-633`).
777        let lo_inf = self
778            .options
779            .get_numeric_value("nlp_lower_bound_inf", "")
780            .ok()
781            .and_then(|(v, f)| f.then_some(v))
782            .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
783        let up_inf = self
784            .options
785            .get_numeric_value("nlp_upper_bound_inf", "")
786            .ok()
787            .and_then(|(v, f)| f.then_some(v))
788            .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
789        let fixed_treatment = match self
790            .options
791            .get_string_value("fixed_variable_treatment", "")
792            .ok()
793            .and_then(|(v, f)| f.then_some(v))
794            .as_deref()
795        {
796            Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
797            // `make_constraint` / `make_parameter_nodual` not yet
798            // implemented; fall back to `make_parameter` (auto-retry to
799            // `relax_bounds` will still kick in if DOF runs short).
800            _ => FixedVarTreatment::MakeParameter,
801        };
802        let adapter = match TNLPAdapter::new_with_options(
803            Rc::clone(&tnlp),
804            lo_inf,
805            up_inf,
806            fixed_treatment,
807        ) {
808            Ok(a) => Rc::new(RefCell::new(a)),
809            Err(_) => {
810                timing.overall_alg.end();
811                return ApplicationReturnStatus::InvalidProblemDefinition;
812            }
813        };
814        let mut orig_nlp = match OrigIpoptNlp::new(Rc::clone(&adapter), Rc::new(NoScaling)) {
815            Ok(n) => n,
816            Err(_) => {
817                timing.overall_alg.end();
818                return ApplicationReturnStatus::InternalError;
819            }
820        };
821        orig_nlp.set_timing_stats(Rc::clone(&timing));
822
823        // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
824        // bail out with NotEnoughDegreesOfFreedom when there are fewer free
825        // variables than equality constraints. Without this gate, square /
826        // over-determined systems push the algorithm into restoration on
827        // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
828        let n_x_var = orig_nlp.x_space().dim();
829        let n_c = orig_nlp.c_space().dim();
830        if n_x_var > 0 && n_x_var < n_c {
831            timing.overall_alg.end();
832            return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
833        }
834
835        // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
836        // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
837        // `OrigIpoptNLP::InitializeStructures` lines 343-358.
838        let bound_relax_factor = self
839            .options
840            .get_numeric_value("bound_relax_factor", "")
841            .ok()
842            .and_then(|(v, f)| f.then_some(v))
843            .unwrap_or(1e-8);
844        let constr_viol_tol = self
845            .options
846            .get_numeric_value("constr_viol_tol", "")
847            .ok()
848            .and_then(|(v, f)| f.then_some(v))
849            .unwrap_or(1e-4);
850        orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
851
852        // Apply automatic NLP scaling per `nlp_scaling_method` option
853        // (port of `OrigIpoptNLP::InitializeStructures` →
854        // `NLPScalingObject::DetermineScaling`). Default is
855        // `gradient-based` to match upstream Ipopt 3.14.
856        let scaling_method = self
857            .options
858            .get_string_value("nlp_scaling_method", "")
859            .ok()
860            .and_then(|(v, f)| f.then_some(v))
861            .unwrap_or_else(|| "gradient-based".to_string());
862        let scaling_method = match scaling_method.as_str() {
863            "none" => ScalingMethod::None,
864            "gradient-based" => ScalingMethod::GradientBased,
865            // user-scaling / equilibration not yet implemented; fall back
866            // to gradient-based which matches the upstream default.
867            _ => ScalingMethod::GradientBased,
868        };
869        let max_gradient = self
870            .options
871            .get_numeric_value("nlp_scaling_max_gradient", "")
872            .ok()
873            .and_then(|(v, f)| f.then_some(v))
874            .unwrap_or(100.0);
875        let min_value = self
876            .options
877            .get_numeric_value("nlp_scaling_min_value", "")
878            .ok()
879            .and_then(|(v, f)| f.then_some(v))
880            .unwrap_or(1e-8);
881        orig_nlp.determine_scaling_from_starting_point(scaling_method, max_gradient, min_value);
882
883        let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
884
885        // Build the algorithm strategy bundle. Read coarse knobs from
886        // the OptionsList where we have them; fall through to defaults
887        // otherwise. The full upstream parsing surface (mu_strategy,
888        // hessian_approximation, line_search_method, ...) is wired by
889        // `AlgBuilder::RegisterOptions` in upstream — that registry
890        // hookup lands as a follow-up; default builder is correct for
891        // HS71-class problems.
892        let builder = self.algorithm_builder_from_options();
893
894        // Linear-solver backend. The default factory is option-aware
895        // — it reads the `feral_*` extension options off the same
896        // `OptionsList` that drove the IPM-level builder above so
897        // per-problem `.opt` files can flip backend knobs without
898        // rebuilding pounce.
899        let feral_cfg = feral_config_from_options(&self.options);
900        let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
901            default_backend_factory_with_sink(feral_cfg, Arc::clone(&self.linsol_summary_sink))
902        });
903        let bundle = builder.build_with_backend(factory);
904
905        // Wire the data / cq pair around the NLP. Install the shared
906        // `TimingStatistics` so the algorithm's iterate phases
907        // (output, convergence, hessian, μ, search-direction,
908        // line-search, accept) all record into the same accumulator
909        // the application exposes via `timing_stats()`.
910        let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
911        data.borrow_mut().timing = Rc::clone(&timing);
912        let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
913            IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
914        ));
915
916        // Seed `data.curr` with a zero-valued iterate of the correct
917        // dimensions. The `IterateInitializer` consumes these as its
918        // template (it overwrites `x`, `s`, multipliers in place); we
919        // just need the dim metadata.
920        {
921            let nlp_borrow = nlp_handle.borrow();
922            let n_x = nlp_borrow.n();
923            let n_s = nlp_borrow.m_ineq();
924            let n_yc = nlp_borrow.m_eq();
925            let n_yd = nlp_borrow.m_ineq();
926            let n_zl = nlp_borrow.x_l().dim();
927            let n_zu = nlp_borrow.x_u().dim();
928            let n_vl = nlp_borrow.d_l().dim();
929            let n_vu = nlp_borrow.d_u().dim();
930            drop(nlp_borrow);
931            let iv = IteratesVector::new(
932                Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
933                Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
934                Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
935                Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
936                Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
937                Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
938                Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
939                Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
940            );
941            data.borrow_mut().set_curr(iv);
942        }
943
944        let max_iter = self
945            .options
946            .get_integer_value("max_iter", "")
947            .ok()
948            .and_then(|(v, f)| f.then_some(v))
949            .unwrap_or(3000);
950        let tol = self
951            .options
952            .get_numeric_value("tol", "")
953            .ok()
954            .and_then(|(v, f)| f.then_some(v))
955            .unwrap_or(1e-8);
956        data.borrow_mut().tol = tol;
957
958        let mut alg = IpoptAlgorithm::new(data, cq, bundle)
959            .with_nlp(Rc::clone(&nlp_handle))
960            .with_tnlp(Rc::clone(&tnlp));
961        alg.record_iter_history = self.record_iter_history;
962        // Mint a fresh restoration factory per inner solve if a
963        // provider is configured (pounce#10 Phase 3). Falls back to
964        // the legacy one-shot `restoration_factory` slot when no
965        // provider is set, preserving single-shot caller behavior.
966        if let Some(provider) = self.restoration_factory_provider.as_mut() {
967            self.restoration_factory = Some(provider());
968        }
969        if let Some(factory) = self.restoration_factory.as_mut() {
970            alg = alg.with_restoration(factory());
971        }
972        if let Some(diag) = self.diagnostics.as_ref() {
973            alg = alg.with_diagnostics(Rc::clone(diag));
974        }
975        alg.max_iter = max_iter;
976        // Honor `print_level == 0`: suppress the per-iteration table
977        // that the algorithm writes straight to stdout. (The Phase-7
978        // journalist surface respects `print_level` already; this is
979        // the legacy direct-print site that needs the same gate.)
980        if let Ok((v, found)) = self.options.get_integer_value("print_level", "") {
981            if found && v <= 0 {
982                alg.print_iter_output = false;
983                // The nested restoration IPM is built inside the
984                // restoration driver, not by `IpoptAlgorithm::new`, so
985                // it never sees this gate unless we forward it.
986                if let Some(resto) = alg.restoration.as_mut() {
987                    resto.set_print_iter_output(false);
988                }
989            }
990        }
991
992        let solver_status = alg.optimize();
993        // Close the overall-algorithm timer on the success path. The
994        // early-return arms above end it themselves before bailing out;
995        // this one matches upstream `IpoptApplication::call_optimize`
996        // (which calls `EndCpuTime()` on overall_alg right after
997        // `Optimize` returns, regardless of solver_status).
998        timing.overall_alg.end();
999
1000        // Drain counters / iter count off the algorithm.
1001        {
1002            let mut stats = self.statistics.borrow_mut();
1003            stats.iteration_count = alg.data.borrow().iter_count;
1004            stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
1005            // Restoration-phase audit counters (pounce#12). Zero on
1006            // problems where restoration never fires; populated by
1007            // `IpoptAlgorithm::invoke_restoration`.
1008            stats.restoration_calls = alg.resto_calls;
1009            stats.restoration_inner_iters = alg.resto_inner_iters;
1010            stats.restoration_outer_iters = alg.resto_outer_iters;
1011            stats.restoration_wall_secs = alg.resto_wall_secs;
1012            stats.iterations = std::mem::take(&mut alg.iter_history);
1013            // Capture the final *scaled* objective at the algorithm's
1014            // (compressed `x_var`-space) iterate via the NLP: the
1015            // algorithm-side `eval_f` returns `f * obj_scale_factor`.
1016            // `final_objective` is seeded with it only as a best-effort
1017            // fallback; the success path below overwrites it with the
1018            // true unscaled objective from `finalize_via_orig_nlp`
1019            // (which evaluates the user TNLP directly).
1020            let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
1021            if let Some(x) = curr_x {
1022                if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
1023                    stats.final_objective = f;
1024                    stats.final_scaled_objective = f;
1025                }
1026            }
1027            // Final residuals straight off the cq cache. These mirror
1028            // the values upstream prints in its end-of-run summary
1029            // ("Dual infeasibility / Constraint violation /
1030            // Complementarity / Overall NLP error").
1031            let cq = alg.cq.borrow();
1032            stats.final_dual_inf = cq.curr_dual_infeasibility_max();
1033            stats.final_constr_viol = cq.curr_primal_infeasibility_max();
1034            // Infinity-norm complementarity, max over all four bound
1035            // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
1036            // empty-bound blocks return `0` from amax(), so the max is
1037            // safe even when only one side has bounds.
1038            let compl = cq
1039                .curr_compl_x_l()
1040                .amax()
1041                .max(cq.curr_compl_x_u().amax())
1042                .max(cq.curr_compl_s_l().amax())
1043                .max(cq.curr_compl_s_u().amax());
1044            stats.final_compl = compl;
1045            stats.final_kkt_error = cq.curr_nlp_error();
1046        }
1047
1048        // Map SolverReturn → ApplicationReturnStatus per
1049        // MAIN_LOOP.md's exception table.
1050        let app_status = solver_return_to_app_status(solver_status);
1051
1052        // On convergence, fire the user-supplied callback (post-optimal
1053        // sensitivity hook, pounce#16) before flowing back through
1054        // `finalize_via_orig_nlp`. Borrowed handles into the converged
1055        // KKT state stay alive for the duration of the closure.
1056        if matches!(
1057            app_status,
1058            ApplicationReturnStatus::SolveSucceeded
1059                | ApplicationReturnStatus::SolvedToAcceptableLevel
1060        ) {
1061            if let Some(cb) = self.on_converged.as_mut() {
1062                if let Some(sd) = alg.search_dir.as_mut() {
1063                    let pd = sd.pd_solver_rc();
1064                    cb(&alg.data, &alg.cq, &nlp_handle, pd);
1065                }
1066            }
1067        }
1068
1069        // Finalize: forward the final iterate to the user's TNLP. The
1070        // returned objective is evaluated on the *user* TNLP at the
1071        // unscaled iterate, so it overrides the scaled best-effort
1072        // value stashed in `final_objective` above (the algorithm-side
1073        // `eval_f` returns `f * obj_scale_factor`).
1074        match finalize_via_orig_nlp(&nlp_handle, &alg, solver_status, app_status, &tnlp) {
1075            Ok(f_unscaled) => {
1076                self.statistics.borrow_mut().final_objective = f_unscaled;
1077            }
1078            Err(()) => {
1079                // Couldn't finalize; keep the scaled fallback and
1080                // surface the original status.
1081            }
1082        }
1083
1084        // End-of-solve timing report. Gated on `print_timing_statistics`
1085        // (default "no"); mirrors upstream's
1086        // `IpoptApplication::call_optimize` →
1087        // `IpTimingStatistics::PrintAllValues` call site. The report
1088        // goes to stdout (for parity with the banner / iter-row output
1089        // path) and is also fanned out to the journalist so an
1090        // `output_file` attached via `Initialize` picks it up.
1091        let print_timing = self
1092            .options
1093            .get_bool_value("print_timing_statistics", "")
1094            .ok()
1095            .and_then(|(v, f)| f.then_some(v))
1096            .unwrap_or(false);
1097        if print_timing {
1098            let report = timing.report();
1099            print!("{}", report);
1100            use pounce_common::journalist::{JournalCategory, JournalLevel};
1101            self.journalist.print(
1102                JournalLevel::J_SUMMARY,
1103                JournalCategory::J_TIMING_STATISTICS,
1104                &report,
1105            );
1106        }
1107
1108        app_status
1109    }
1110
1111    fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
1112        let mut builder = AlgorithmBuilder::new();
1113        if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
1114            if found {
1115                builder.mu_strategy = match v.as_str() {
1116                    "adaptive" => MuStrategyChoice::Adaptive,
1117                    _ => MuStrategyChoice::Monotone,
1118                };
1119            }
1120        }
1121        if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
1122            if found {
1123                builder.mu_oracle = match v.as_str() {
1124                    "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
1125                    "probing" => crate::mu::adaptive::MuOracleKind::Probing,
1126                    _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
1127                };
1128            }
1129        }
1130        if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
1131            if found {
1132                builder.hessian_approximation = match v.as_str() {
1133                    "limited-memory" => HessianApproxChoice::LimitedMemory,
1134                    _ => HessianApproxChoice::Exact,
1135                };
1136            }
1137        }
1138        if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
1139            if found {
1140                builder.line_search_method = match v.as_str() {
1141                    "cg-penalty" => LineSearchChoice::CgPenalty,
1142                    "penalty" => LineSearchChoice::Penalty,
1143                    _ => LineSearchChoice::Filter,
1144                };
1145            }
1146        }
1147        // `nlp_scaling_method` is consumed NLP-side in
1148        // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
1149        // `determine_scaling_from_starting_point` call earlier in this
1150        // method); there is no algorithm-side scaling strategy to wire.
1151
1152        // Unlike the other options here, we always honor the registry
1153        // value (not just when the user set it explicitly): the option
1154        // registry default is "ma57" but `AlgorithmBuilder::default`
1155        // has `linear_solver: Feral`, so gating on `found` would
1156        // silently route default runs through Feral while the banner
1157        // (and ipopt-compatible behavior) advertises MA57.
1158        if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
1159            builder.linear_solver = match v.as_str() {
1160                "ma57" => LinearSolverChoice::Ma57,
1161                _ => LinearSolverChoice::Feral,
1162            };
1163        }
1164
1165        // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
1166        // `RegisterOptions` consumers). Defaults already match upstream
1167        // — only override when the user set the key explicitly.
1168        let read_num = |key: &str| -> Option<f64> {
1169            self.options
1170                .get_numeric_value(key, "")
1171                .ok()
1172                .and_then(|(v, f)| f.then_some(v))
1173        };
1174        let read_int = |key: &str| -> Option<i32> {
1175            self.options
1176                .get_integer_value(key, "")
1177                .ok()
1178                .and_then(|(v, f)| f.then_some(v))
1179        };
1180        if let Some(v) = read_num("tol") {
1181            builder.conv_check.tol = v;
1182        }
1183        if let Some(v) = read_num("dual_inf_tol") {
1184            builder.conv_check.dual_inf_tol = v;
1185        }
1186        if let Some(v) = read_num("constr_viol_tol") {
1187            builder.conv_check.constr_viol_tol = v;
1188        }
1189        if let Some(v) = read_num("compl_inf_tol") {
1190            builder.conv_check.compl_inf_tol = v;
1191        }
1192        if let Some(v) = read_int("max_iter") {
1193            builder.conv_check.max_iter = v;
1194        }
1195        if let Some(v) = read_num("max_cpu_time") {
1196            builder.conv_check.max_cpu_time = v;
1197        }
1198        if let Some(v) = read_num("max_wall_time") {
1199            builder.conv_check.max_wall_time = v;
1200        }
1201        if let Some(v) = read_num("acceptable_tol") {
1202            builder.conv_check.acceptable_tol = v;
1203        }
1204        if let Some(v) = read_num("acceptable_dual_inf_tol") {
1205            builder.conv_check.acceptable_dual_inf_tol = v;
1206        }
1207        if let Some(v) = read_num("acceptable_constr_viol_tol") {
1208            builder.conv_check.acceptable_constr_viol_tol = v;
1209        }
1210        if let Some(v) = read_num("acceptable_compl_inf_tol") {
1211            builder.conv_check.acceptable_compl_inf_tol = v;
1212        }
1213        if let Some(v) = read_num("acceptable_obj_change_tol") {
1214            builder.conv_check.acceptable_obj_change_tol = v;
1215        }
1216        if let Some(v) = read_int("acceptable_iter") {
1217            builder.conv_check.acceptable_iter = v;
1218        }
1219        if let Some(v) = read_num("infeas_stationarity_tol") {
1220            builder.conv_check.infeas_stationarity_tol = v;
1221        }
1222        if let Some(v) = read_num("infeas_viol_kappa") {
1223            builder.conv_check.infeas_viol_kappa = v;
1224        }
1225        if let Some(v) = read_int("infeas_max_streak") {
1226            builder.conv_check.infeas_max_streak = v;
1227        }
1228
1229        // Barrier-parameter (μ) options — consumers in
1230        // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
1231        // updaters share the same option names; the builder forwards
1232        // each into whichever strategy is assembled.
1233        if let Some(v) = read_num("mu_init") {
1234            builder.mu.mu_init = v;
1235        }
1236        if let Some(v) = read_num("mu_max") {
1237            builder.mu.mu_max = v;
1238        }
1239        if let Some(v) = read_num("mu_max_fact") {
1240            builder.mu.mu_max_fact = v;
1241        }
1242        if let Some(v) = read_num("mu_min") {
1243            builder.mu.mu_min = v;
1244        }
1245        if let Some(v) = read_num("mu_target") {
1246            builder.mu.mu_target = v;
1247        }
1248        if let Some(v) = read_num("mu_linear_decrease_factor") {
1249            builder.mu.mu_linear_decrease_factor = v;
1250        }
1251        if let Some(v) = read_num("mu_superlinear_decrease_power") {
1252            builder.mu.mu_superlinear_decrease_power = v;
1253        }
1254        if let Ok((v, found)) = self
1255            .options
1256            .get_string_value("mu_allow_fast_monotone_decrease", "")
1257        {
1258            if found {
1259                builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
1260            }
1261        }
1262        if let Some(v) = read_num("barrier_tol_factor") {
1263            builder.mu.barrier_tol_factor = v;
1264        }
1265        if let Some(v) = read_num("sigma_max") {
1266            builder.mu.sigma_max = v;
1267        }
1268        if let Some(v) = read_num("sigma_min") {
1269            builder.mu.sigma_min = v;
1270        }
1271
1272        // Watchdog options — consumers in
1273        // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
1274        // the `BacktrackingLineSearch` at build time.
1275        if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
1276            builder.line_search.watchdog_shortened_iter_trigger = v;
1277        }
1278        if let Some(v) = read_int("watchdog_trial_iter_max") {
1279            builder.line_search.watchdog_trial_iter_max = v;
1280        }
1281        if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
1282            builder.line_search.soft_resto_pderror_reduction_factor = v;
1283        }
1284        if let Some(v) = read_int("max_soft_resto_iters") {
1285            builder.line_search.max_soft_resto_iters = v;
1286        }
1287
1288        // Iteration-output options — consumed by `OrigIterationOutput`.
1289        if let Some(v) = read_int("print_frequency_iter") {
1290            builder.output.print_frequency_iter = v;
1291        }
1292        if let Some(v) = read_num("print_frequency_time") {
1293            builder.output.print_frequency_time = v;
1294        }
1295        if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
1296            if found {
1297                builder.output.print_info_string = v;
1298            }
1299        }
1300        if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
1301            if found {
1302                builder.output.inf_pr_output_internal = v == "internal";
1303            }
1304        }
1305
1306        // Warm-start options — consumed by `WarmStartIterateInitializer`
1307        // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
1308        // `warm_start_init_point` is the toggle that picks between the
1309        // default (cold) and warm-start initializers; the remaining
1310        // knobs are baked onto the chosen initializer at build time.
1311        if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
1312            if found {
1313                builder.warm_start_init_point = v;
1314            }
1315        }
1316        if let Ok((v, found)) = self.options.get_bool_value("warm_start_same_structure", "") {
1317            if found {
1318                builder.warm.same_structure = v;
1319            }
1320        }
1321        if let Some(v) = read_num("warm_start_bound_push") {
1322            builder.warm.bound_push = v;
1323        }
1324        if let Some(v) = read_num("warm_start_bound_frac") {
1325            builder.warm.bound_frac = v;
1326        }
1327        if let Some(v) = read_num("warm_start_slack_bound_push") {
1328            builder.warm.slack_bound_push = v;
1329        }
1330        if let Some(v) = read_num("warm_start_slack_bound_frac") {
1331            builder.warm.slack_bound_frac = v;
1332        }
1333        if let Some(v) = read_num("warm_start_mult_bound_push") {
1334            builder.warm.mult_bound_push = v;
1335        }
1336        if let Some(v) = read_num("warm_start_mult_init_max") {
1337            builder.warm.mult_init_max = v;
1338        }
1339        if let Some(v) = read_num("warm_start_target_mu") {
1340            builder.warm.target_mu = v;
1341        }
1342        if let Ok((v, found)) = self
1343            .options
1344            .get_string_value("warm_start_entire_iterate", "")
1345        {
1346            if found {
1347                builder.warm.entire_iterate = v == "yes";
1348            }
1349        }
1350        builder
1351    }
1352}
1353
1354/// Map the integer `print_level` / `file_print_level` option to the
1355/// matching [`JournalLevel`] variant. Mirrors upstream's
1356/// `static_cast<EJournalLevel>(int_value)` with clamping.
1357fn journal_level_from_int(v: i32) -> JournalLevel {
1358    match v.clamp(0, 12) {
1359        0 => JournalLevel::J_NONE,
1360        1 => JournalLevel::J_ERROR,
1361        2 => JournalLevel::J_STRONGWARNING,
1362        3 => JournalLevel::J_SUMMARY,
1363        4 => JournalLevel::J_WARNING,
1364        5 => JournalLevel::J_ITERSUMMARY,
1365        6 => JournalLevel::J_DETAILED,
1366        7 => JournalLevel::J_MOREDETAILED,
1367        8 => JournalLevel::J_VECTOR,
1368        9 => JournalLevel::J_MOREVECTOR,
1369        10 => JournalLevel::J_MATRIX,
1370        11 => JournalLevel::J_MOREMATRIX,
1371        _ => JournalLevel::J_ALL,
1372    }
1373}
1374
1375/// Default symmetric linear-solver factory, parameterized by the
1376/// pounce-extension FERAL knobs read off the application's
1377/// `OptionsList`.
1378///
1379/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
1380/// available when the `ma57` cargo feature is enabled; without it,
1381/// requesting `linear_solver = ma57` falls back to FERAL with a
1382/// warning printed by the journalist (see [`AlgorithmBuilder`]).
1383pub fn default_backend_factory(feral_cfg: pounce_feral::FeralConfig) -> LinearBackendFactory {
1384    Box::new(
1385        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
1386            match choice {
1387                LinearSolverChoice::Feral => {
1388                    Box::new(pounce_feral::FeralSolverInterface::with_config(feral_cfg))
1389                }
1390                LinearSolverChoice::Ma57 => {
1391                    #[cfg(feature = "ma57")]
1392                    {
1393                        Box::new(pounce_hsl::Ma57SolverInterface::new())
1394                    }
1395                    #[cfg(not(feature = "ma57"))]
1396                    {
1397                        // ma57 feature not compiled in — fall back to FERAL.
1398                        Box::new(pounce_feral::FeralSolverInterface::with_config(feral_cfg))
1399                    }
1400                }
1401            }
1402        },
1403    )
1404}
1405
1406/// Sink-aware variant of [`default_backend_factory`]. Identical
1407/// dispatch, but the FERAL backend is constructed with a
1408/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
1409/// aggregate post-mortem stats (factor counts, fill ratio, extremal
1410/// pivots, final inertia) after the solve returns. MA57 ignores the
1411/// sink — the HSL backend doesn't carry the same instrumentation yet.
1412pub fn default_backend_factory_with_sink(
1413    feral_cfg: pounce_feral::FeralConfig,
1414    sink: Arc<Mutex<LinearSolverSummary>>,
1415) -> LinearBackendFactory {
1416    Box::new(
1417        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
1418            match choice {
1419                LinearSolverChoice::Feral => Box::new(
1420                    pounce_feral::FeralSolverInterface::with_config(feral_cfg)
1421                        .with_summary_sink(Arc::clone(&sink)),
1422                ),
1423                LinearSolverChoice::Ma57 => {
1424                    #[cfg(feature = "ma57")]
1425                    {
1426                        Box::new(pounce_hsl::Ma57SolverInterface::new())
1427                    }
1428                    #[cfg(not(feature = "ma57"))]
1429                    {
1430                        Box::new(
1431                            pounce_feral::FeralSolverInterface::with_config(feral_cfg)
1432                                .with_summary_sink(Arc::clone(&sink)),
1433                        )
1434                    }
1435                }
1436            }
1437        },
1438    )
1439}
1440
1441/// Read the `feral_*` extension options off `options`, falling
1442/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
1443/// for any knob the caller did not set explicitly. The returned
1444/// config is what every default-factory invocation (main IPM and
1445/// restoration sub-IPM) consumes.
1446pub fn feral_config_from_options(
1447    options: &pounce_common::options_list::OptionsList,
1448) -> pounce_feral::FeralConfig {
1449    let mut cfg = pounce_feral::FeralConfig::from_env();
1450    if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
1451        cfg.cascade_break = v;
1452    }
1453    if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
1454        cfg.fma = v;
1455    }
1456    if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
1457        cfg.refine = v;
1458    }
1459    if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
1460        cfg.singular_pivot_floor = v;
1461    }
1462    cfg
1463}
1464
1465/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
1466/// Mirrors the table in
1467/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
1468/// map") and the corresponding switch in
1469/// `IpIpoptApplication.cpp:call_optimize`.
1470fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
1471    match s {
1472        SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
1473        SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
1474        SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
1475        SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
1476        SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
1477        SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
1478        SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
1479        SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
1480        SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
1481        SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
1482        SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
1483        SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
1484        SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
1485        SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
1486        SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
1487        SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
1488        SolverReturn::InternalError | SolverReturn::Unassigned => {
1489            ApplicationReturnStatus::InternalError
1490        }
1491    }
1492}
1493
1494/// Best-effort evaluation of the objective at the algorithm's final
1495/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
1496/// to populate `SolveStatistics::final_scaled_objective`.
1497fn try_eval_curr_f(
1498    nlp: &Rc<RefCell<dyn IpoptNlp>>,
1499    x: &Rc<dyn pounce_linalg::Vector>,
1500) -> Result<Number, ()> {
1501    let mut nlp_mut = nlp.borrow_mut();
1502    Ok(nlp_mut.eval_f(&**x))
1503}
1504
1505/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
1506/// `true` when a status warrants a retry through the wrapper. Mirrors
1507/// ripopt#23's trigger set, extended per the audit's Refinement B
1508/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
1509/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
1510/// equivalent would let pass to the wrapper).
1511fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
1512    matches!(
1513        status,
1514        ApplicationReturnStatus::RestorationFailed
1515            | ApplicationReturnStatus::InfeasibleProblemDetected
1516            | ApplicationReturnStatus::SolvedToAcceptableLevel
1517            | ApplicationReturnStatus::MaximumIterationsExceeded
1518            | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
1519    )
1520}
1521
1522/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
1523/// We pull `x` (compressed in `x_var`-space) off the algorithm's
1524/// `data.curr`, lift it back to full TNLP indexing, and pass empty
1525/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
1526/// in compressed split form — re-assembling them into the user's
1527/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
1528/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
1529/// fleshed out). On success returns the unscaled objective evaluated
1530/// on the user TNLP at the final iterate; returns `Err` if the final
1531/// iterate is missing.
1532fn finalize_via_orig_nlp(
1533    nlp: &Rc<RefCell<dyn IpoptNlp>>,
1534    alg: &IpoptAlgorithm,
1535    solver_status: SolverReturn,
1536    _app_status: ApplicationReturnStatus,
1537    tnlp: &Rc<RefCell<dyn TNLP>>,
1538) -> Result<Number, ()> {
1539    let curr = alg.data.borrow().curr.clone().ok_or(())?;
1540    // Lift compressed x_var → full-x (length `info.n`) so the user
1541    // TNLP receives the same shape it provided. With `make_parameter`
1542    // the fixed components are spliced back in by the IpoptNlp.
1543    let nlp_borrow = nlp.borrow();
1544    let x_vec: Vec<Number> = nlp_borrow.lift_x_to_full(&*curr.x);
1545    let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
1546    let n = info.n as usize;
1547    let m = info.m as usize;
1548    debug_assert_eq!(x_vec.len(), n);
1549    // Lift algorithm-side multipliers back into user-space (pounce#11).
1550    // Backends without overrides return empty; fall back to zero stubs
1551    // so the user sees a length-consistent vector.
1552    let mut z_l = nlp_borrow.pack_z_l_for_user(&*curr.z_l);
1553    if z_l.is_empty() {
1554        z_l = vec![0.0; n];
1555    }
1556    let mut z_u = nlp_borrow.pack_z_u_for_user(&*curr.z_u);
1557    if z_u.is_empty() {
1558        z_u = vec![0.0; n];
1559    }
1560    let mut lambda = nlp_borrow.pack_lambda_for_user(&*curr.y_c, &*curr.y_d);
1561    if lambda.is_empty() {
1562        lambda = vec![0.0; m];
1563    }
1564    drop(nlp_borrow);
1565    // Compute g(x) via the user TNLP so the final residual is
1566    // populated for the user.
1567    let mut g_final = vec![0.0; m];
1568    let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
1569    let f_final = tnlp
1570        .borrow_mut()
1571        .eval_f(&x_vec, true)
1572        .unwrap_or(Number::NAN);
1573    tnlp.borrow_mut().finalize_solution(
1574        Solution {
1575            status: solver_status,
1576            x: &x_vec,
1577            z_l: &z_l,
1578            z_u: &z_u,
1579            g: &g_final,
1580            lambda: &lambda,
1581            obj_value: f_final,
1582        },
1583        &TnlpIpoptData::default(),
1584        &TnlpIpoptCq::default(),
1585    );
1586    Ok(f_final)
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591    use super::*;
1592    use pounce_nlp::tnlp::{
1593        BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, Solution, SparsityRequest,
1594        StartingPoint,
1595    };
1596
1597    struct Hs071Stub;
1598    impl TNLP for Hs071Stub {
1599        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1600            // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
1601            // dense lower-triangular Hessian (10 nz).
1602            Some(NlpInfo {
1603                n: 4,
1604                m: 2,
1605                nnz_jac_g: 8,
1606                nnz_h_lag: 10,
1607                index_style: IndexStyle::C,
1608            })
1609        }
1610        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1611            b.x_l.copy_from_slice(&[1.0; 4]);
1612            b.x_u.copy_from_slice(&[5.0; 4]);
1613            b.g_l.copy_from_slice(&[25.0, 40.0]);
1614            b.g_u.copy_from_slice(&[2.0e19, 40.0]);
1615            true
1616        }
1617        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1618            sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
1619            true
1620        }
1621        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
1622            Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
1623        }
1624        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
1625            grad.fill(0.0);
1626            true
1627        }
1628        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1629            g.fill(0.0);
1630            true
1631        }
1632        fn eval_jac_g(
1633            &mut self,
1634            _x: Option<&[Number]>,
1635            _new_x: bool,
1636            mode: SparsityRequest<'_>,
1637        ) -> bool {
1638            if let SparsityRequest::Structure { irow, jcol } = mode {
1639                irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
1640                jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
1641            }
1642            true
1643        }
1644        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
1645    }
1646
1647    #[test]
1648    fn application_constructs_and_loads_options() {
1649        let mut app = IpoptApplication::new();
1650        app.initialize().unwrap();
1651        // ipopt.opt-style file: an integer-typed option registered by
1652        // the Interfaces layer.
1653        app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
1654            .unwrap();
1655        let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
1656        assert!(found);
1657        assert_eq!(level, 5);
1658    }
1659
1660    #[test]
1661    fn application_reports_problem_dimensions() {
1662        let app = IpoptApplication::new();
1663        let mut tnlp = Hs071Stub;
1664        let info = app.problem_dimensions(&mut tnlp).unwrap();
1665        assert_eq!(info.n, 4);
1666        assert_eq!(info.m, 2);
1667        assert_eq!(info.nnz_jac_g, 8);
1668        assert_eq!(info.nnz_h_lag, 10);
1669    }
1670}