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