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(®)
182 .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
183 pounce_presolve::register_options(®)
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(®)),
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}