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::{DUAL_DIV_RETRY_DU_FLOOR, 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/// gh#887 — how far the *runaway* must dominate everything else in the
44/// answer a solve finally reports, before
45/// [`IpoptApplication::run_with_dual_divergence_retry`] will spend a cold
46/// re-solve on it.
47///
48/// gh#884's defect is a point that is converged **except** that one
49/// multiplier ran away: the primal is exact, complementarity is met, and
50/// the entire residual is dual infeasibility. That is the shape
51/// `perturb_always_cd` repairs. A point whose other residuals are within
52/// a few orders of its dual one is not that — it is an ordinary
53/// unconverged answer, and re-solving it cold is what
54/// `mu_strategy_fallback` and the second-opinion ladder already are.
55///
56/// So the retry requires `max(viol, compl) <= 1e-6 * dual_inf`, all
57/// unscaled. Measured on every run in the corpus that reaches the test:
58///
59/// | run | dual inf | viol | compl | ratio |
60/// |---|---|---|---|---|
61/// | reproducer, `.nl` route | `7.90e4` | `1.1e-16` | `1.1e-9` | `1.5e-14` |
62/// | reproducer, TNLP route | `3.25e11` | `2.5e-16` | `2.8e-3` | `8.7e-15` |
63/// | `deb7` + L-BFGS + rung | `9.90e1` | `8.0e-13` | `4.65e0` | `4.7e-2` |
64///
65/// Twelve orders between the keeps and the reject, and `1e-6` leaves
66/// eight orders of margin on the tightest keep and four on the reject.
67///
68/// It is a **ratio of two residuals of the same answer**, so it carries
69/// no units and does not move with the model's scaling — and, unlike any
70/// test on the trajectory, it cannot depend on which attempt fired or on
71/// how a platform rounded its way there. That is not hypothetical: the
72/// first version of this gate compared the reported answer against the
73/// runaway the detector had seen, and `deb7`'s detector value is `9.2e5`
74/// on one attempt and `8.7e2` on another, differing between build
75/// profiles and again on CI's Linux runner, where the retry ran anyway.
76///
77/// Deliberately a constant and not an option. It does not express a
78/// tolerance a caller trades against — it says "the runaway is the whole
79/// residual" — and the escape hatch for the remedy is
80/// `dual_divergence_retry=no`.
81const DUAL_DIV_RETRY_DOMINANCE: Number = 1e-6;
82
83/// Does this answer have gh#884's *shape*?
84///
85/// gh#884's defect is a point converged **except** that one multiplier
86/// ran away: the primal is exact, complementarity is met, and the entire
87/// residual is dual infeasibility. `perturb_always_cd` has something to
88/// repair only there, so this is what opens the retry (gh#887).
89///
90/// All three arguments are in the **model's own units**, never the
91/// `s_d`-normalised frame the convergence gate reads — that frame is what
92/// hid gh#884 in the first place. The test is a ratio *within one
93/// answer*, so it carries no units and cannot depend on which attempt
94/// produced it or on how a platform rounded. That is the property that
95/// matters, not the margin; see the module tests for what happened to
96/// the two gates that did not have it.
97///
98/// Non-finite input disables the retry rather than enabling it: a NaN
99/// compares false everywhere, and writing this so a NaN *passed* would
100/// turn "we cannot tell" into "retry anyway". A non-positive dual
101/// residual cannot be a runaway either, and makes the ratio meaningless.
102///
103/// **The dominance ratio alone is not the whole test, and reading it as
104/// one is how a `dual_inf` of `0.44` opened a retry.** The ratio says the
105/// dual residual *dominates* the other two; it cannot say the residual is
106/// large, because a point converged to `1e-30` primal and `4.4e-1` dual
107/// passes it as comfortably as gh#884's `7.9e+04` does. So `dual_inf`
108/// must also clear the same absolute floor the detector's third conjunct
109/// applies to the iterate (`dual_divergence_retry_du_floor`, default
110/// `1e2`) — the doc above says "the entire residual is dual
111/// infeasibility", and without the floor the code only said "the largest
112/// third of it is".
113///
114/// The floor is the *detector's own*, deliberately, rather than a new
115/// constant: the detector fires on an iterate and this asks whether the
116/// **answer** still exhibits what the detector saw, so the two have to be
117/// asking about the same magnitude or the answer-level gate is a strictly
118/// looser copy of the iterate-level one. Measured on the 400-model QPEC
119/// family in `dev-notes/mpcc-biactive-dual-divergence.md`, this alone
120/// removes 7 of 68 promotions, every one of them on an answer whose
121/// reported dual residual was below `1e2` and therefore not a runaway by
122/// the issue's own description.
123fn runaway_is_the_whole_residual(
124 dual_inf: Number,
125 viol: Number,
126 compl: Number,
127 du_floor: Number,
128) -> bool {
129 dual_inf.is_finite()
130 && dual_inf > 0.0
131 && dual_inf >= du_floor
132 && viol.is_finite()
133 && compl.is_finite()
134 && viol.max(compl) <= DUAL_DIV_RETRY_DOMINANCE * dual_inf
135}
136
137/// Is the retry's answer admissible *as an answer*, next to the base
138/// attempt's — independent of which has the better multiplier?
139///
140/// gh#884's promotion gate ranked the two attempts on unscaled KKT error
141/// alone. That is a statement about the **certificate**, and it was
142/// allowed to decide which **point** shipped, on the argument that
143/// "conjunct 4 requires the promoted answer to satisfy the KKT conditions
144/// in the model's own units" — so, unlike the μ flip, this retry could not
145/// return a different local solution. The inference does not hold: *any*
146/// other KKT point satisfies the KKT conditions in the model's own units
147/// too. Measured on 400 random QPECs (`prod_eq` lowering), 42 of 68
148/// promotions moved the objective materially, i.e. returned a different
149/// local solution, and three returned a **worse feasible point** — worst
150/// case `-13.0057 → -1.2072`, both independently verified feasible.
151///
152/// Two rules, and both are about the *answer* rather than its certificate:
153///
154/// 1. **Never hand back a feasible point whose objective is worse than one
155/// this run already computed.** The base attempt's point is feasible
156/// and in hand; returning a worse one is a regression no certificate
157/// buys back.
158/// 2. **An objective *improvement* may not be bought with primal slack.**
159/// Two costs this carries, named rather than hidden (R3). It is an exact
160/// non-increase with **no noise floor**, so it fires the same on
161/// `2.07e-25 -> 1.09e-09` (where the move is the whole defect) as on
162/// `1e-17 -> 1.1e-17` (where which side a retry lands on is arithmetic):
163/// of the 45 promotions this PR removes, ~35 are improvements refused
164/// because the violation ticked up somewhere far below any tolerance. The
165/// direction is conservative — the base answer is feasible and in hand —
166/// but "refuses purchases, not improvements" is only true above the noise.
167/// And the rule detects *the primal moved*, not *below the optimum*: had
168/// `scholtes4`'s retry **held** its violation, `f = -6.6088e-05` would have
169/// been admitted, which the second assertion of
170/// `an_improvement_bought_with_primal_slack_is_refused` states outright.
171/// On that model the two coincided.
172/// The remedy is for a *dual* defect — the premise is that the primal
173/// has settled — so a retry that lands further outside the constraints
174/// *and* reports a better objective has not repaired the runaway, it
175/// has moved somewhere the model does not reach. This is what
176/// `scholtes4` does: from a base at `f = +1.82e-09` with a constraint
177/// violation of `2.07e-25` it promotes `f = -6.61e-05` at `1.09e-09`,
178/// below the model's exactly-known `f* = 0`, and reports
179/// `Optimal Solution Found`.
180///
181/// Both comparisons are skipped when the base attempt is not itself
182/// feasible within [`IpoptApplication::dual_divergence_retry_accept_tol`]:
183/// there is then no admissible point to protect, and the retry's is
184/// strictly better information.
185///
186/// `tol` is that same acceptable tolerance, scaled by
187/// `max(1, |base_obj|)` so the comparison is relative on a large
188/// objective and absolute on a small one — the convention
189/// `sigma_forward_error_is_small` uses for `‖x‖` and for the same reason.
190/// It is not a fitted constant: the objective moves this has to admit
191/// (`qpec_small`, `5.8e-11`) and the ones it has to refuse (`r201`,
192/// `0.198`; `scholtes4`, `6.6e-05`) sit four and five orders away from it
193/// on either side.
194///
195/// `sense` is `+1` for a minimization and `-1` for a maximization, and both
196/// objectives are multiplied by it before either rule looks at them.
197/// [`SolveStatistics::final_objective`] is the objective evaluated on the
198/// **user** TNLP — signed, and *not* premultiplied by `obj_scaling_factor` —
199/// and a negative `obj_scaling_factor` is the documented way to pose a
200/// maximization. Without the normalization both rules invert under it: rule 1
201/// would refuse genuine *improvements* (a regression against the behaviour
202/// before this conjunct existed) and rule 2 would admit strictly worse
203/// answers, re-arming the exact class the conjunct was added to block. The
204/// repo has shipped one defect from this sign already —
205/// `masked_certificate_fuzz.rs::the_veto_is_not_disabled_by_a_negative_objective_scaling_factor`,
206/// whose fix took `.abs()` in the residual accessors, which is also what keeps
207/// the detector firing under maximization and so keeps this path reachable.
208fn retry_answer_is_admissible(
209 base_obj: Number,
210 base_viol: Number,
211 retry_obj: Number,
212 retry_viol: Number,
213 accept_tol: Number,
214 sense: Number,
215) -> bool {
216 // Nothing to compare against: a non-finite or infeasible base answer
217 // is not a point worth protecting.
218 if !base_obj.is_finite() || !base_viol.is_finite() || base_viol > accept_tol {
219 return true;
220 }
221 // A non-finite retry objective is refused rather than admitted, for
222 // the same reason `runaway_is_the_whole_residual` refuses a NaN.
223 if !retry_obj.is_finite() {
224 return false;
225 }
226 // Both into a minimization frame, so "lower is better" below is true
227 // whichever sense the caller posed.
228 let base_obj = sense * base_obj;
229 let retry_obj = sense * retry_obj;
230 let tol = accept_tol * base_obj.abs().max(1.0);
231 if retry_obj > base_obj + tol {
232 return false; // rule 1: strictly worse feasible point
233 }
234 if retry_obj < base_obj - tol {
235 // Rule 2: an improvement is admissible only if the retry did not
236 // give up primal accuracy to get it.
237 return retry_viol.is_finite() && retry_viol <= base_viol;
238 }
239 true
240}
241
242/// What [`IpoptApplication::initialize_with_option_file`] did — enough
243/// for a caller to tell the user which file (if any) configured the run.
244#[derive(Debug, Default, Clone)]
245pub struct OptionFileLoad {
246 /// The file actually read. `None` means no options file was read:
247 /// nobody named one and neither default was present.
248 pub path: Option<PathBuf>,
249 /// Whether [`Self::path`] was named by the caller rather than found
250 /// by probing the working directory.
251 pub explicit: bool,
252 /// Non-fatal notes about option-file settings that did *not* take
253 /// effect. Nothing here stops a solve; the point is that it not
254 /// happen silently.
255 pub warnings: Vec<String>,
256}
257
258/// Factory that constructs a fresh restoration-phase strategy on
259/// demand. The outer algorithm owns at most one restoration object,
260/// so the factory is invoked once per `optimize_tnlp` call. The
261/// factory is `FnMut` to allow callers to capture a builder that
262/// internally reuses caches across builds.
263pub type RestorationFactory = Box<dyn FnMut() -> Box<dyn RestorationPhase>>;
264
265/// Provider that mints fresh [`RestorationFactory`] instances on
266/// demand. Used by drivers that need to run the inner IPM more than
267/// once per `optimize_tnlp` call — notably the Phase-3 ℓ₁-exact
268/// penalty-barrier outer loop (pounce#10), which the existing
269/// `RestorationFactory` cannot support because pounce's default
270/// `make_default_restoration_factory` is a one-shot. Callers wire
271/// this via [`IpoptApplication::set_restoration_factory_provider`].
272pub type RestorationFactoryProvider = Box<dyn FnMut() -> RestorationFactory>;
273
274/// Callback fired by [`IpoptApplication::optimize_constrained`] once
275/// the IPM has converged (status `SolveSucceeded` or
276/// `SolvedToAcceptableLevel`) and before the user TNLP's
277/// `finalize_solution` runs. Receives borrowed handles into the
278/// algorithm's converged state.
279///
280/// **Use case**: post-optimal sensitivity analysis (pounce#7 /
281/// `pounce-sensitivity`). The callback receives a shared handle to
282/// the PD solver so a `SensBacksolver` adapter can run backsolves
283/// against the converged KKT factor — and so that handle may outlive
284/// the call frame (e.g. the public `Solver` session API retains the
285/// factor for repeated `parametric_step` / `kkt_solve` calls);
286/// receives the data / cq / nlp handles so the adapter can reproduce
287/// the augmented-system coefficient layout the IPM converged at.
288///
289/// **Not** the same as `set_intermediate_callback` (per-iteration
290/// progress notification) — this fires exactly once per `optimize_*`
291/// call, only on success.
292pub type ConvergedCallback = Box<
293 dyn FnMut(
294 &crate::ipopt_data::IpoptDataHandle,
295 &crate::ipopt_cq::IpoptCqHandle,
296 &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
297 Rc<RefCell<crate::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
298 ),
299>;
300use pounce_common::diagnostics::DiagnosticsState;
301use pounce_common::exception::{ExceptionKind, SolverException};
302use pounce_common::journalist::{JournalLevel, Journalist};
303use pounce_common::options_list::OptionsList;
304use pounce_common::reg_options::{PrintOptionsMode, RegisteredOptions};
305use pounce_common::timing::TimingStatistics;
306use pounce_common::types::{Index, Number};
307use pounce_linalg::dense_vector::DenseVectorSpace;
308use pounce_linsol::SparseSymLinearSolverInterface;
309use pounce_linsol::summary::LinearSolverSummary;
310use pounce_nlp::alg_types::SolverReturn;
311use pounce_nlp::derivative_test::{DerivativeTest, DerivativeTestOptions};
312use pounce_nlp::orig_ipopt_nlp::{ConstObjScaling, OrigIpoptNlp, ScalingMethod};
313use pounce_nlp::return_codes::ApplicationReturnStatus;
314use pounce_nlp::solve_statistics::SolveStatistics;
315use pounce_nlp::tnlp::{
316 BoundsInfo, IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, NlpInfo, Solution, TNLP,
317};
318use pounce_nlp::tnlp_adapter::{
319 DEFAULT_NLP_LOWER_BOUND_INF, DEFAULT_NLP_UPPER_BOUND_INF, FixedVarTreatment, TNLPAdapter,
320};
321use std::cell::RefCell;
322use std::fmt;
323use std::path::{Path, PathBuf};
324use std::rc::Rc;
325use std::sync::{Arc, Mutex};
326use std::time::Instant;
327
328pub struct IpoptApplication {
329 options: OptionsList,
330 /// Diagnostics from the safeguarded `least_square_init_primal`
331 /// initializer step of the most recent solve (gh#605). `None` when
332 /// the step was not run. Read with
333 /// [`Self::least_square_init_report`].
334 least_square_init_report: Option<crate::init::default::LeastSquareInitReport>,
335 /// Per-variable scaling factors applied by the wrapper installed in
336 /// [`Self::optimize_tnlp`] (gh#486). Recorded so consumers that read
337 /// the algorithm's own iterate rather than the `finalize_solution`
338 /// payload — the CLI's `on_converged` hook feeding the `.sol` and
339 /// the JSON report — can undo the substitution. `None` when no
340 /// variable scaling was applied.
341 variable_scaling: RefCell<Option<Vec<Number>>>,
342 /// Whether the most recent `optimize_constrained` ran with per-row
343 /// constraint scaling active (`c_scale_vec`/`d_scale_vec` present).
344 ///
345 /// Read by [`Self::run_l1_penalty_outer_loop`], which measures the
346 /// user's rows in the model's own units and so may only write that
347 /// number into the `final_unscaled_*` family. `SolveStatistics`
348 /// documents `final_*` as the internally-scaled residuals and
349 /// `final_unscaled_*` as the same quantities in original units,
350 /// *equal when no scaling is active* — so the measurement may be
351 /// mirrored into the scaled family exactly when this is `false`
352 /// (gh#794 review). `None` when no solve has run.
353 row_scaling_active: std::cell::Cell<Option<bool>>,
354 /// Whether the submitted TNLP has already been explicitly wrapped by the
355 /// caller's presolve layer.
356 presolve_already_applied: bool,
357 reg_options: Rc<RegisteredOptions>,
358 journalist: Rc<Journalist>,
359 statistics: RefCell<SolveStatistics>,
360 /// Shared per-subsystem timing accumulator. Re-created at the top of
361 /// every solve (so back-to-back `optimize_tnlp` calls don't bleed
362 /// timings across invocations) and handed to the data, the NLP, and
363 /// any other consumer via `Rc`. Reported by [`Self::timing_stats`]
364 /// after the solve completes.
365 timing: RefCell<Rc<TimingStatistics>>,
366 /// Optional override factory for the symmetric linear-solver
367 /// backend. When `None`, we ship the workspace default (MA57 via
368 /// `pounce-hsl`). Tests can plug a stub via [`Self::set_linear_backend_factory`].
369 linear_backend_factory: Option<LinearBackendFactory>,
370 /// Optional factory for the restoration phase. Lives outside this
371 /// crate because `pounce-algorithm` cannot depend on
372 /// `pounce-restoration` (the dep edge is the other way). Callers
373 /// that need restoration plug a factory via
374 /// [`Self::set_restoration_factory`]; when unset, the outer
375 /// algorithm runs without a restoration fallback and surfaces
376 /// `RestorationFailure` as soon as the line-search would otherwise
377 /// jump into restoration.
378 restoration_factory: Option<RestorationFactory>,
379 /// Shared diagnostic-dump state, installed by the CLI when the
380 /// user passes `--dump <cat>:<spec>`. When set, the application
381 /// propagates an `Rc<DiagnosticsState>` into [`IpoptAlgorithm`]
382 /// via [`IpoptAlgorithm::with_diagnostics`] so the KKT solver and
383 /// other dump sites can consult per-iter gating.
384 diagnostics: Option<Rc<DiagnosticsState>>,
385 /// Optional interactive debugger hook. When set, it is moved into
386 /// the main [`IpoptAlgorithm`] for the next `optimize_*` call via
387 /// [`IpoptAlgorithm::with_debug_hook`], so a REPL or agent can pause
388 /// at each iteration to inspect / mutate live state. Consumed on use
389 /// (one solve per installed hook).
390 debug_hook: Option<std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>>,
391 /// Provider for the BNW outer loop (pounce#10 Phase 3). When set,
392 /// `optimize_constrained` consults the provider before each inner
393 /// solve, replacing `restoration_factory` with a fresh one so
394 /// multi-pass drivers can run the inner IPM repeatedly without
395 /// tripping the default factory's one-shot guard.
396 restoration_factory_provider: Option<RestorationFactoryProvider>,
397 /// Optional hook fired once per `optimize_*` call on convergence,
398 /// before the user TNLP's `finalize_solution`. See
399 /// [`ConvergedCallback`].
400 on_converged: Option<ConvergedCallback>,
401 /// When `true`, the per-iteration `IterRecord` trajectory is
402 /// captured into [`SolveStatistics::iterations`] for downstream
403 /// consumers (the JSON solve report in pounce-cli, pounce#8). Off
404 /// by default so library callers that never read the iterations
405 /// vector don't pay the per-iter alloc.
406 record_iter_history: bool,
407 /// Whether [`Self::initialize_with_option_file`] ran — i.e. whether
408 /// anything on this application actually consulted
409 /// `option_file_name` and resolved it to a file. Only the `pounce`
410 /// CLI does; a library caller sets its options directly. The guard
411 /// in [`Self::unhonored_option_file_name`] reads this so that
412 /// setting the option on a surface that cannot honor it is refused
413 /// rather than dropped (gh#518).
414 option_file_resolved: bool,
415 /// Whether this caller can route a model to the convex LP/QP/SOCP
416 /// engines — i.e. whether the `qp_*` knobs those engines read
417 /// configure anything here. Only the `pounce` CLI can (it owns the
418 /// `.nl` structure extraction that classifies a model), and it says
419 /// so via [`Self::set_convex_routing_available`]. The guard in
420 /// [`Self::unhonored_convex_option`] reads this, on the same
421 /// contract as `option_file_resolved` above (gh#604).
422 convex_routing_available: bool,
423 /// Whether the backend-knob warnings (gh#551) have already been
424 /// printed for this application. The CLI emits them before routing —
425 /// a convex model never reaches `optimize_tnlp` — and `optimize_tnlp`
426 /// emits them for every other frontend; without this flag a CLI run
427 /// would print each line twice, which is how a warning teaches its
428 /// reader to skip it.
429 backend_warnings_emitted: bool,
430 /// Shared sink that the linear-solver backend writes a rolling
431 /// [`LinearSolverSummary`] into after every factor. Reset at the
432 /// top of every solve (so back-to-back `optimize_tnlp` calls don't
433 /// bleed stats across invocations) and read out via
434 /// [`Self::linear_solver_summary`] once the solve returns. Only
435 /// the workspace-default FERAL backend (via
436 /// [`default_backend_factory_with_sink`]) wires the sink today;
437 /// custom factories plugged through [`Self::set_linear_backend_factory`]
438 /// and the HSL MA57 backend leave the sink empty.
439 linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
440 /// Shared tally of successful linear-solver quality escalations for
441 /// the current solve (gh#857). Handed to every `AlgorithmBuilder`
442 /// this application mints — [`Self::algorithm_builder_from_options`]
443 /// and [`Self::algorithm_builder_snapshot`] — which is how the
444 /// restoration sub-solve counts into the same total: each frontend
445 /// builds the restoration provider's inner builder from
446 /// `algorithm_builder_from_options`, so it receives this same `Rc`
447 /// without any frontend having to know the counter exists.
448 ///
449 /// Reset at the top of every solve, beside the linear-solver summary
450 /// sink and for the same reason: a second-opinion retry, or two
451 /// back-to-back `optimize_tnlp` calls, must not inherit the previous
452 /// solve's count. Read out into
453 /// [`SolveStatistics::quality_escalations`] once the solve returns.
454 quality_escalations: Rc<std::cell::Cell<u64>>,
455 /// gh#884. Set when the running [`IpoptAlgorithm`] observed the
456 /// biactive dual-divergence signature — a converged primal, a step
457 /// that has gone to zero on a scale-relative measure, and an
458 /// *unscaled* dual infeasibility still far above `dual_inf_tol`, all
459 /// at the same iterate.
460 ///
461 /// Read out of the algorithm once `optimize_constrained` returns, so
462 /// [`Self::run_with_dual_divergence_retry`] — which sits above that
463 /// call — can see it. Reset at the top of every solve for the same
464 /// reason as `quality_escalations`: a retry must not inherit the
465 /// previous attempt's verdict. Also copied into
466 /// [`SolveStatistics::dual_divergence_signature`].
467 dual_divergence_signature: std::cell::Cell<bool>,
468 /// gh#884. Set when a dual-divergence retry actually replaced the base
469 /// attempt's answer. Copied into
470 /// [`SolveStatistics::dual_divergence_retry_promoted`].
471 dual_divergence_retry_promoted: std::cell::Cell<bool>,
472 /// Set when a losing retry's answer was thrown away and an earlier
473 /// attempt's replayed through `FinalizeSnapshot::replay` — by the μ
474 /// fallback (pounce#870) or the gh#884 dual-divergence retry.
475 ///
476 /// It exists because [`Self::set_on_converged`] fires **per attempt**
477 /// and the floor does not reach it. A losing retry that reached
478 /// `Solve_Succeeded` has already run the callback, so a consumer
479 /// capturing the converged iterate there — the CLI's `nominal_capture`,
480 /// which is where `.sol` `x`, the JSON's `solution.x` and the dual
481 /// block all come from — holds the *discarded* attempt's point while
482 /// the status, objective and every statistic beside it have been
483 /// floored back to the attempt that won. Measured before this flag
484 /// existed: on a declined retry the `.sol` carried `f = -6.3274` while
485 /// the JSON report next to it said `-6.1768`, and `pounce verify` on
486 /// the `.sol` confirmed the file held the losing point.
487 ///
488 /// The `finalize_solution` payload does not have this problem — the
489 /// replay *is* a `finalize_solution` call, so the last one always
490 /// carries the answer being reported — which is why the fix is to tell
491 /// the caller "prefer that payload", not to re-run the callback (the
492 /// converged KKT state the callback borrows belongs to the retry by
493 /// then, and cannot be rewound).
494 answer_restored_from_floor: std::cell::Cell<bool>,
495 /// The payload of the most recent `finalize_solution` POUNCE sent to the
496 /// user's TNLP, so a second-opinion retry that loses can put the winning
497 /// attempt's answer back (pounce#870).
498 ///
499 /// Written by [`finalize_via_orig_nlp`] and [`finalize_via_sqp`], which are
500 /// free functions and so take this as a sink rather than reaching for
501 /// `self`. Read only by [`Self::run_with_mu_strategy_fallback`].
502 last_finalize: RefCell<Option<FinalizeSnapshot>>,
503 /// The last `IterStats` sent to the user's `intermediate_callback`
504 /// (pounce#870), shared with the running `IpoptAlgorithm`.
505 last_iter_stats: Rc<RefCell<Option<pounce_nlp::tnlp::IterStats>>>,
506 /// Phase 5c (§6) SQP warm-start input. When `Some`, the next
507 /// `optimize_tnlp` call on the SQP path consumes the iterate
508 /// instead of cold-starting; consumed once per solve, then
509 /// auto-cleared. The IPM path ignores this field. Wire-set
510 /// via [`Self::set_sqp_warm_start`].
511 sqp_warm_start: Option<crate::sqp::SqpIterates>,
512 /// Phase 5c (§6) SQP warm-start output. Populated by every
513 /// `optimize_sqp_tnlp` call with the final QP working set.
514 /// Stays valid until the next solve (which overwrites it).
515 /// Accessed via [`Self::last_sqp_working_set`].
516 sqp_last_working_set: Option<pounce_qp::WorkingSet>,
517 /// What the post-convergence crossover phase did on the most recent
518 /// IPM solve (gh#612). `None` when `crossover=no` (the default) or
519 /// when the solve did not converge — crossover only runs on a
520 /// converged interior iterate. Read via [`Self::crossover_report`].
521 ///
522 /// Kept separate from `sqp_last_working_set`, which crossover *also*
523 /// populates on success: that field answers "what can the next solve
524 /// warm-start from", this one answers "was the active set I am about
525 /// to trust actually identified, or merely inferred".
526 crossover_report: Option<crate::crossover::CrossoverReport>,
527 /// Full primal-dual warm-start iterate for the IPM path, captured by
528 /// the interactive debugger's `resolve` command. When `Some`, the
529 /// next `optimize_tnlp` installs this 8-vector (algorithm space)
530 /// directly onto `data.curr` before the iterate initializer runs, so
531 /// a warm `resolve` continues from the paused interior point rather
532 /// than cold-restarting the duals. Consumed once per solve, then
533 /// auto-cleared. Requires `warm_start_init_point=yes` so the
534 /// re-optimize branch of `WarmStartIterateInitializer` keeps the
535 /// installed iterate. Wire-set via [`Self::set_warm_start_iterate`].
536 warm_start_iterate: Option<crate::debug::IterateSnapshot>,
537 /// The warm-start initializer's verdict on the most recent solve's
538 /// supplied iterate (gh#606). Lifted off the solve-local
539 /// `IpoptData` so a caller can read it after `optimize_tnlp`
540 /// returns; `None` when the last solve was a cold start.
541 warm_start_diag: RefCell<Option<crate::init::warm_start::WarmStartDiagnostics>>,
542 /// Caller-supplied fill-reducing permutation for the KKT linear
543 /// solver (pounce#180 item 1 / FERAL#107). When `Some`, it overrides
544 /// whatever `feral_ordering` / `POUNCE_FERAL_ORDERING` resolves to,
545 /// installing [`pounce_feral::OrderingMethod::External`] on the FERAL
546 /// backend for the next solve. The vector is a **0-based, new-to-old
547 /// permutation** whose length must equal the augmented KKT system
548 /// dimension; FERAL validates it as a bijection and returns
549 /// `InvalidInput` (never panics) on a wrong length / index. Unlike the
550 /// warm-start hooks this is treated as persistent config — it is *not*
551 /// auto-cleared after a solve, so a caller sets it once for a run.
552 /// Wire-set via [`Self::set_external_ordering`]. Ignored by non-FERAL
553 /// backends and by any custom factory plugged via
554 /// [`Self::set_linear_backend_factory`].
555 external_ordering: Option<Vec<usize>>,
556 /// Caller-supplied block-triangular / Schur KKT partition (pounce#180
557 /// item 2). When `Some`, the next IPM solve on the feral + exact-Hessian
558 /// path routes the KKT linear solve through a
559 /// [`crate::kkt::SchurAugSystemSolver`] over these **KKT-space indices**
560 /// (`0..dim` in the `x, s, c, d` block order the aug-system solver
561 /// assembles): the `S` block is Schur-complemented out and only the two
562 /// diagonal blocks are factorized, with inertia recovered via Sylvester's
563 /// law. Beneficial only when `|S| ≪` the eliminated block; the Schur solver
564 /// falls back to the standard full-space solver transparently when the
565 /// partition is unsuitable (too large, malformed, or the backend errors),
566 /// so a stray hook never breaks a solve. Persistent config (not
567 /// auto-cleared). Wire-set via [`Self::set_kkt_schur_block`].
568 kkt_schur_block: Option<Vec<usize>>,
569}
570
571impl fmt::Debug for IpoptApplication {
572 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573 f.debug_struct("IpoptApplication")
574 .field("options", &self.options)
575 .field("statistics", &self.statistics)
576 .finish_non_exhaustive()
577 }
578}
579
580impl Default for IpoptApplication {
581 fn default() -> Self {
582 Self::new()
583 }
584}
585
586impl IpoptApplication {
587 /// New application with empty options and a default journalist.
588 /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
589 pub fn new() -> Self {
590 let reg = RegisteredOptions::default();
591 // Registration of a fresh registry can only fail on a duplicate
592 // name, which would be a programming error in `reg_op`.
593 register_all_upstream_options(®)
594 .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
595 pounce_presolve::register_options(®)
596 .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
597 let reg = Rc::new(reg);
598 Self {
599 options: OptionsList::with_registered(Rc::clone(®)),
600 least_square_init_report: None,
601 variable_scaling: RefCell::new(None),
602 row_scaling_active: std::cell::Cell::new(None),
603 presolve_already_applied: false,
604 reg_options: reg,
605 journalist: Rc::new(Journalist::new()),
606 statistics: RefCell::new(SolveStatistics::new()),
607 timing: RefCell::new(Rc::new(TimingStatistics::new())),
608 linear_backend_factory: None,
609 restoration_factory: None,
610 diagnostics: None,
611 debug_hook: None,
612 restoration_factory_provider: None,
613 on_converged: None,
614 record_iter_history: false,
615 option_file_resolved: false,
616 convex_routing_available: false,
617 backend_warnings_emitted: false,
618 linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
619 quality_escalations: Rc::new(std::cell::Cell::new(0)),
620 dual_divergence_signature: std::cell::Cell::new(false),
621 dual_divergence_retry_promoted: std::cell::Cell::new(false),
622 answer_restored_from_floor: std::cell::Cell::new(false),
623 last_finalize: RefCell::new(None),
624 last_iter_stats: Rc::new(RefCell::new(None)),
625 sqp_warm_start: None,
626 sqp_last_working_set: None,
627 crossover_report: None,
628 warm_start_iterate: None,
629 warm_start_diag: RefCell::new(None),
630 external_ordering: None,
631 kkt_schur_block: None,
632 }
633 }
634
635 pub fn options(&self) -> &OptionsList {
636 &self.options
637 }
638
639 pub fn options_mut(&mut self) -> &mut OptionsList {
640 &mut self.options
641 }
642
643 /// Declare whether callers have already applied an explicit presolve
644 /// wrapper to the TNLPs submitted to [`Self::optimize_tnlp`].
645 ///
646 /// When set, `optimize_tnlp` leaves its input TNLP unchanged even if the
647 /// `presolve` option is enabled. This preserves the option table for
648 /// reporting and debugger use while allowing specialized frontends to
649 /// supply a wrapper with capabilities unavailable to generic callback
650 /// TNLPs, such as an expression provider for FBBT.
651 pub fn set_presolve_already_applied(&mut self, applied: bool) {
652 self.presolve_already_applied = applied;
653 }
654
655 /// Solve without materializing the generic presolve wrapper.
656 ///
657 /// This is for consumers that require the original TNLP coordinate system
658 /// for the solve's KKT matrix, such as sensitivity and reduced-Hessian
659 /// drivers. It is scoped to this invocation and does not change the
660 /// application's `presolve` option or persistent explicit-wrapper setting.
661 pub fn optimize_tnlp_without_presolve(
662 &mut self,
663 tnlp: Rc<RefCell<dyn TNLP>>,
664 ) -> ApplicationReturnStatus {
665 let explicit_wrapper = self.presolve_already_applied;
666 self.presolve_already_applied = true;
667 let status = self.optimize_tnlp(tnlp);
668 self.presolve_already_applied = explicit_wrapper;
669 status
670 }
671
672 pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
673 &self.reg_options
674 }
675
676 pub fn journalist(&self) -> &Rc<Journalist> {
677 &self.journalist
678 }
679
680 /// Plug a custom symmetric-linear-solver factory. Useful for tests
681 /// that want to swap MA57 for a stub. Production callers should
682 /// leave this unset — the default ([`default_backend_factory`])
683 /// returns the workspace's MA57 binding.
684 pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
685 self.linear_backend_factory = Some(factory);
686 }
687
688 /// Plug a restoration-phase factory. Called once per
689 /// `optimize_tnlp` invocation to mint a fresh
690 /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
691 /// its line-search restoration fallback. Lives behind a setter
692 /// (rather than at construction) because the concrete restoration
693 /// strategies live in `pounce-restoration`, which depends on this
694 /// crate; consumers in `pounce-cli` / integration tests wire the
695 /// factory at the application boundary.
696 pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
697 self.restoration_factory = Some(factory);
698 }
699
700 /// Install the shared diagnostics state. Once set, every
701 /// subsequent `optimize_tnlp` call forwards the state into the
702 /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
703 /// solver can emit `--dump kkt:...` artifacts.
704 pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
705 self.diagnostics = Some(diag);
706 }
707
708 /// Install an interactive debugger hook for the next `optimize_*`
709 /// call. The hook is moved into the main [`IpoptAlgorithm`] and
710 /// consumed by that solve; reinstall it to debug a subsequent solve.
711 pub fn set_debug_hook(
712 &mut self,
713 hook: std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>,
714 ) {
715 self.debug_hook = Some(hook);
716 }
717
718 /// Read-side accessor for the installed diagnostics state, if any.
719 /// Lets the CLI write the top-level manifest/timing files after
720 /// the solve completes.
721 pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
722 self.diagnostics.as_ref().map(Rc::clone)
723 }
724
725 /// Plug a restoration-phase **factory provider** for drivers that
726 /// need to run the inner IPM more than once per `optimize_tnlp`
727 /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
728 /// pounce#10). On each inner solve, the application consults the
729 /// provider to mint a fresh [`RestorationFactory`], replacing any
730 /// stale one, so the default one-shot restoration factory does
731 /// not panic on its second invocation. If both `set_restoration_factory`
732 /// and this are configured, the provider wins.
733 pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
734 self.restoration_factory_provider = Some(provider);
735 }
736
737 /// Register a callback to run once the IPM has converged (status
738 /// [`ApplicationReturnStatus::SolveSucceeded`] or
739 /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
740 /// `finalize_solution` flows back to the TNLP. See
741 /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
742 pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
743 self.on_converged = Some(cb);
744 }
745
746 /// Was the reported answer replayed from an earlier attempt's floor,
747 /// after a later attempt lost?
748 ///
749 /// **A caller that captures the solution in [`Self::set_on_converged`]
750 /// must consult this.** That callback fires once per *attempt*, and a
751 /// losing retry that converged has already run it, so the capture
752 /// belongs to the discarded point while everything else — status,
753 /// objective, statistics — has been floored back. When this is `true`,
754 /// take `x` and the multipliers from the last `finalize_solution`
755 /// payload instead; that one is always the answer being reported,
756 /// because the floor restores it *by* calling `finalize_solution`.
757 ///
758 /// The payload is in the **model's own units** and in the **reduced**
759 /// presolve space: `CountingTnlp`-style consumers sit inside the gh#486
760 /// scaling wrapper (so `x /= d`, `z *= d` have already been applied) and
761 /// outside the presolve one (so the row/column lift has not). A caller
762 /// swapping it in must therefore skip its own scaling correction and keep
763 /// its own lift; getting that backwards squares the factor, which is a
764 /// silent wrong answer of exactly the shape this flag exists to remove.
765 ///
766 /// **Not consulted, and known not to be**: the three other
767 /// [`Self::set_on_converged`] consumers —
768 /// `pounce-sensitivity/src/{solver,convenience}.rs` and
769 /// `pounce-cli/src/minima/mod.rs` — read the converged KKT state and the
770 /// factorization, which the `finalize_solution` payload does not carry
771 /// and which cannot be rewound. After any floor replay their result
772 /// describes the attempt that lost. Pre-existing, unfixed, and annotated
773 /// at each site.
774 ///
775 /// `false` on every solve that never spent a second attempt, which is
776 /// almost all of them, so the ordinary path is unaffected.
777 pub fn answer_restored_from_floor(&self) -> bool {
778 self.answer_restored_from_floor.get()
779 }
780
781 /// Enable per-iteration trajectory capture. After the solve
782 /// returns, [`Self::statistics()`] exposes
783 /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
784 /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
785 /// per accepted iterate. Off by default — the `pounce_sens` and
786 /// `pounce` binaries opt in when `--json-output` is passed.
787 pub fn enable_iter_history(&mut self) {
788 self.record_iter_history = true;
789 }
790
791 /// Read the run's options file, resolving *which* file the way
792 /// upstream's `IpoptApplication::Initialize` does — with one
793 /// deliberate difference, below.
794 ///
795 /// `explicit` is the file the caller named (upstream: the
796 /// `option_file_name` option, read out of the option store before
797 /// this point). With `None`, the working directory is probed for
798 /// [`DEFAULT_OPTION_FILE_NAMES`] and the first hit is read; an
799 /// absent default file is not an error, it just means "no file".
800 ///
801 /// The difference: upstream opens a named file with a bare
802 /// `std::ifstream` and reads nothing if the open fails, so a typo'd
803 /// `option_file_name` runs at stock defaults without a word. That
804 /// silence is what gh#518 was reported for — a benchmark that
805 /// measured defaults while claiming to measure a configuration — so
806 /// a named file that cannot be read is an error here.
807 pub fn initialize_with_option_file(
808 &mut self,
809 explicit: Option<&Path>,
810 ) -> Result<OptionFileLoad, SolverException> {
811 let mut load = OptionFileLoad::default();
812 // Set before the early returns below: what this flag records is
813 // that `option_file_name` was *consulted*, not that a file turned
814 // up. A caller on this path who names nothing and has no
815 // `pounce.opt` to find still gets the option honored — there was
816 // simply nothing to read.
817 self.option_file_resolved = true;
818 let path = match explicit {
819 Some(p) => {
820 if !p.is_file() {
821 return Err(SolverException::new(
822 ExceptionKind::IPOPT_APPLICATION_ERROR,
823 format!(
824 "options file \"{}\" does not exist. It was named by \
825 --options-file / option_file_name, so the run would \
826 otherwise proceed at stock defaults with none of its \
827 settings applied.",
828 p.display()
829 ),
830 file!(),
831 line!() as Index,
832 ));
833 }
834 load.explicit = true;
835 p.to_path_buf()
836 }
837 None => {
838 let present: Vec<&&str> = DEFAULT_OPTION_FILE_NAMES
839 .iter()
840 .filter(|n| Path::new(n).is_file())
841 .collect();
842 let Some(first) = present.first() else {
843 return Ok(load);
844 };
845 // Both default names in one directory: say which one lost,
846 // rather than let the unread one look applied.
847 for other in &present[1..] {
848 load.warnings.push(format!(
849 "`{first}` and `{other}` are both present; reading `{first}` \
850 only (pounce's own name wins). Pass \
851 `option_file_name={other}` to read that one instead."
852 ));
853 }
854 PathBuf::from(**first)
855 }
856 };
857 self.initialize_with_options_file(&path)?;
858 // `option_file_name` set *inside* an options file chains nowhere —
859 // by the time it is read, the file naming it has already been
860 // chosen. Upstream documents that ("it does not make any sense to
861 // specify this option within the options file") and then ignores
862 // it; name it instead, since an ignored setting that looks live is
863 // the whole complaint behind gh#518.
864 if let Ok((named, true)) = self.options.get_string_value("option_file_name", "")
865 && !named.is_empty()
866 && Path::new(&named) != path
867 {
868 load.warnings.push(format!(
869 "`{}` sets option_file_name to `{named}`, which has no effect: \
870 the options file is chosen before it is read. Pass \
871 `option_file_name={named}` on the command line to read that file.",
872 path.display()
873 ));
874 }
875 load.path = Some(path);
876 Ok(load)
877 }
878
879 /// Read an `ipopt.opt`-format options file. Equivalent to
880 /// `IpoptApplication::Initialize(const std::string& options_file)`.
881 pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
882 let txt = std::fs::read_to_string(path).map_err(|e| {
883 SolverException::new(
884 ExceptionKind::IPOPT_APPLICATION_ERROR,
885 format!("could not read options file {}: {}", path.display(), e),
886 file!(),
887 line!() as Index,
888 )
889 })?;
890 self.options.read_from_str(&txt, true)?;
891 self.open_output_file_journal();
892 Ok(())
893 }
894
895 /// Read options from a string in `ipopt.opt` format. Useful for
896 /// tests and embedded callers.
897 pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
898 self.options.read_from_str(s, true)?;
899 self.open_output_file_journal();
900 Ok(())
901 }
902
903 /// Honor `output_file` / `file_print_level` / `file_append`: when
904 /// `output_file` is non-empty, attach a `FileJournal` named
905 /// `"OutputFile:<fname>"` at the requested level. Mirrors
906 /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
907 /// No-op if `output_file` is unset, empty, or could not be opened.
908 ///
909 /// NOTE: pounce's iteration output currently bypasses the
910 /// journalist and writes directly to stdout. The file journal is
911 /// attached and the timing report (gated by `print_timing_statistics`)
912 /// is mirrored to it; per-iter rows will start landing in the file
913 /// once the iter-output path is routed through the journalist.
914 fn open_output_file_journal(&self) {
915 let fname = match self.options.get_string_value("output_file", "") {
916 Ok((v, true)) if !v.is_empty() => v,
917 _ => return,
918 };
919 let level_int = self
920 .options
921 .get_integer_value("file_print_level", "")
922 .ok()
923 .and_then(|(v, f)| f.then_some(v))
924 .unwrap_or(5);
925 let level = journal_level_from_int(level_int);
926 let append = self
927 .options
928 .get_bool_value("file_append", "")
929 .ok()
930 .and_then(|(v, f)| f.then_some(v))
931 .unwrap_or(false);
932 let jname = format!("OutputFile:{}", fname);
933 let _ = self
934 .journalist
935 .add_file_journal(&jname, &fname, level, append);
936 }
937
938 /// No-op initialize (just succeeds). Mirrors
939 /// `IpoptApplication::Initialize(bool allow_clobber)` with no
940 /// options file.
941 pub fn initialize(&mut self) -> Result<(), SolverException> {
942 Ok(())
943 }
944
945 /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
946 /// / `file_print_level` options and attaches a matching
947 /// `FileJournal` named `OutputFile:<fname>` to the journalist.
948 /// Returns `false` if the file could not be opened or the option
949 /// store rejected the request (e.g. clamped print level).
950 pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
951 if self
952 .options
953 .set_string_value("output_file", fname, true, false)
954 .is_err()
955 {
956 return false;
957 }
958 if self
959 .options
960 .set_integer_value("file_print_level", print_level as Index, true, false)
961 .is_err()
962 {
963 return false;
964 }
965 let level = journal_level_from_int(print_level);
966 let jname = format!("OutputFile:{}", fname);
967 // Drop any previous file journal so a second call switches files
968 // cleanly. `add_file_journal` would otherwise refuse to attach
969 // a duplicate by name; remove-by-name isn't in the journalist
970 // API, so we settle for the name-collision case here.
971 self.journalist
972 .add_file_journal(&jname, fname, level, false)
973 .is_some()
974 }
975
976 /// Wrap a TNLP and report problem dimensions. Used in tests until
977 /// the full IPM path covers every entry shape.
978 pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
979 tnlp.get_nlp_info()
980 }
981
982 /// Diagnostics from the safeguarded `least_square_init_primal`
983 /// initializer step of the last solve (gh#605): the nonlinear
984 /// violation before and after, the accepted step norm, how many
985 /// backtracking trials were rejected, and why it stopped. `None`
986 /// when `least_square_init_primal` was off or the model had no
987 /// constraints.
988 pub fn least_square_init_report(&self) -> Option<crate::init::default::LeastSquareInitReport> {
989 self.least_square_init_report.clone()
990 }
991
992 pub fn statistics(&self) -> SolveStatistics {
993 self.statistics.borrow().clone()
994 }
995
996 /// What the warm-start initializer made of the iterate the caller
997 /// supplied to the most recent solve (gh#606): the residuals it
998 /// measured, whether each multiplier block was accepted,
999 /// reconstructed or discarded, and the barrier parameter it
1000 /// settled on.
1001 ///
1002 /// `None` when the last solve was a cold start
1003 /// (`warm_start_init_point=no`), or when no solve has run. Reset at
1004 /// the top of every solve, like [`Self::timing_stats`].
1005 pub fn warm_start_diagnostics(&self) -> Option<crate::init::warm_start::WarmStartDiagnostics> {
1006 self.warm_start_diag.borrow().clone()
1007 }
1008
1009 /// Shared timing accumulator from the most recent `optimize_tnlp`
1010 /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
1011 /// fields during the solve; consumers read totals out of the
1012 /// returned `Rc`. The instance is replaced at the top of every
1013 /// subsequent solve, so cloning the `Rc` and holding it past a
1014 /// re-solve will give you the previous solve's timings — by design.
1015 pub fn timing_stats(&self) -> Rc<TimingStatistics> {
1016 Rc::clone(&self.timing.borrow())
1017 }
1018
1019 /// Aggregate linear-solver post-mortem from the most recent
1020 /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
1021 /// backend ran at least one factor; `None` when no factors were
1022 /// recorded (custom factory plugged via
1023 /// [`Self::set_linear_backend_factory`], or solve aborted before
1024 /// the first KKT factor). Reset at the top of every solve.
1025 pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
1026 let guard = self.linsol_summary_sink.lock().ok()?;
1027 if guard.is_empty() {
1028 None
1029 } else {
1030 Some(guard.clone())
1031 }
1032 }
1033
1034 /// Drive a solve.
1035 ///
1036 /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
1037 /// build a `TNLPAdapter` → `OrigIpoptNlp`, run the
1038 /// [`AlgorithmBuilder`] with the workspace MA57 backend, and
1039 /// call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
1040 /// `ApplicationReturnStatus` mapping mirrors the table in
1041 /// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
1042 /// SolverReturn map").
1043 /// * Unconstrained problems (`m == 0`) keep going through the
1044 /// in-`pounce-nlp` Newton driver so the trivial path is
1045 /// independent of the linear-solver backend.
1046 /// Wrap `tnlp` so per-variable scaling factors are applied as a
1047 /// change of variables, when `nlp_scaling_method=user-scaling` is
1048 /// in effect and the problem supplies non-unit factors (gh#486).
1049 ///
1050 /// Returns the TNLP unchanged under any other scaling method, or
1051 /// when the problem asks for no variable scaling, so an unscaled
1052 /// solve pays nothing. The `Err` carries a message ready to print.
1053 fn install_variable_scaling(
1054 &self,
1055 tnlp: Rc<RefCell<dyn TNLP>>,
1056 ) -> Result<Rc<RefCell<dyn TNLP>>, String> {
1057 // Cleared first so the accessor describes *this* solve. An
1058 // application is reusable across solves (`pounce-cinterface`
1059 // holds one across `IpoptSolve` calls), and a stale vector
1060 // would have a later unscaled solve reporting the previous
1061 // solve's factors.
1062 *self.variable_scaling.borrow_mut() = None;
1063 let method = self
1064 .options
1065 .get_string_value("nlp_scaling_method", "")
1066 .ok()
1067 .and_then(|(v, f)| f.then_some(v))
1068 .unwrap_or_else(|| "gradient-based".to_string());
1069 // `curvature-based` (gh #703) delivers its factors through the
1070 // same `get_scaling_parameters` callback, so the per-variable half
1071 // needs the same substitution wrapper user factors get.
1072 if method != "user-scaling" && method != "curvature-based" {
1073 return Ok(tnlp);
1074 }
1075 match pounce_nlp::scaling_tnlp::wrap_with_scaling(
1076 Rc::clone(&tnlp),
1077 self.nlp_lower_bound_inf(),
1078 self.nlp_upper_bound_inf(),
1079 ) {
1080 Ok(Some(wrapped)) => {
1081 *self.variable_scaling.borrow_mut() =
1082 pounce_nlp::scaling_tnlp::factors_of(&wrapped);
1083 Ok(wrapped)
1084 }
1085 Ok(None) => Ok(tnlp),
1086 Err(why) => Err(format!(
1087 // The trailing newline belongs to the message: the
1088 // caller emits it with `eprint!` and hands the same
1089 // string to the journalist, as the refusals below do.
1090 "pounce: nlp_scaling_method={method} supplied per-variable \
1091 scaling factors that cannot be applied. {why}. Correct the \
1092 factors, or drop nlp_scaling_method={method}.\n"
1093 )),
1094 }
1095 }
1096
1097 /// The per-variable scaling factors applied to the last solve, if
1098 /// any (gh#486). A consumer reading the algorithm's iterate rather
1099 /// than the `finalize_solution` payload sees scaled coordinates and
1100 /// must divide `x` by these, and multiply bound multipliers.
1101 pub fn variable_scaling(&self) -> Option<Vec<Number>> {
1102 self.variable_scaling.borrow().clone()
1103 }
1104
1105 /// Install a starting-point conditioner, if one is asked for.
1106 ///
1107 /// Returns the TNLP unchanged when neither `start_point_perturbation` nor
1108 /// `start_point_conditioner` is set, which is the default — so an ordinary
1109 /// solve pays nothing and its trajectory is untouched.
1110 ///
1111 /// Sits *above* the presolve wrapper and *below* the variable-scaling one,
1112 /// so the point it conditions is the point the algorithm will actually
1113 /// start from, in the coordinates the algorithm will see. Both
1114 /// conditioners override only `get_starting_point`; every other callback
1115 /// forwards, so the solve that follows is the solve pounce would have run
1116 /// had the conditioned point been submitted directly.
1117 ///
1118 /// The two are mutually exclusive by construction rather than by refusal:
1119 /// the displacement is the failure-recovery rung and the Adam warm-up is a
1120 /// user opt-in, and stacking them would put random noise on top of a point
1121 /// the warm-up just spent 200 evaluations choosing. The displacement wins
1122 /// when both are set, because the only thing that sets it is a solve that
1123 /// has already failed.
1124 fn install_start_conditioner(&self, tnlp: Rc<RefCell<dyn TNLP>>) -> Rc<RefCell<dyn TNLP>> {
1125 use pounce_nlp::start_conditioner::{AdamConfig, ConditionedStartTnlp, StartConditioner};
1126 // Each option is read with its literal tag rather than through a
1127 // `|name, fallback|` helper. A helper reads better and costs the
1128 // wiring guard in `tests/init_options_wiring.rs` its evidence: that
1129 // test scans the source for `get_*_value("<tag>"` to prove every
1130 // registered Initialization option is actually consumed, and a tag
1131 // passed as a variable is invisible to it. A registered knob nothing
1132 // reads validates, accepts a value, and lies.
1133 let perturbation = self
1134 .options
1135 .get_numeric_value("start_point_perturbation", "")
1136 .map(|(v, _found)| v)
1137 .unwrap_or(0.0);
1138 let conditioner = if perturbation > 0.0 {
1139 let seed = self
1140 .options
1141 .get_integer_value("start_point_perturbation_seed", "")
1142 .map(|(v, _found)| v)
1143 .unwrap_or(0);
1144 StartConditioner::Jitter {
1145 // `Index` is signed and the option is lower-bounded at 0, so
1146 // this cast cannot lose a set bit for any accepted value.
1147 seed: seed.max(0) as u64,
1148 scale: perturbation,
1149 }
1150 } else {
1151 let which = self
1152 .options
1153 .get_string_value("start_point_conditioner", "")
1154 .map(|(v, _found)| v)
1155 .unwrap_or_else(|_| "none".to_string());
1156 if which != "adam" {
1157 return tnlp;
1158 }
1159 let d = AdamConfig::default();
1160 StartConditioner::Adam(AdamConfig {
1161 iters: self
1162 .options
1163 .get_integer_value("adam_warmup_iters", "")
1164 .map(|(v, _found)| v.max(0) as usize)
1165 .unwrap_or(d.iters),
1166 lr: self
1167 .options
1168 .get_numeric_value("adam_warmup_learning_rate", "")
1169 .map(|(v, _found)| v)
1170 .unwrap_or(d.lr),
1171 rho: self
1172 .options
1173 .get_numeric_value("adam_warmup_penalty", "")
1174 .map(|(v, _found)| v)
1175 .unwrap_or(d.rho),
1176 ..d
1177 })
1178 };
1179 // The sentinels have to come from the options, not from the
1180 // conditioner's own default: a caller who moved `nlp_lower_bound_inf`
1181 // would otherwise have a bound the algorithm treats as absent clipped
1182 // against as if it were real.
1183 //
1184 // Passed through unclamped. These were once `lower.min(-DEFAULT)` /
1185 // `upper.max(DEFAULT)`, which honours only a *loosened* sentinel and
1186 // leaves the failure above intact for a tightened one: at
1187 // `nlp_lower_bound_inf=-1e10` the conditioner still used `-1e19`, so a
1188 // `-1e15` bound was absent to the algorithm and present to the
1189 // clipper — the exact case the comment exists to rule out. The
1190 // sentinel means "absent"; there is only one right answer for what it
1191 // is, and it is the caller's.
1192 let lower = self.nlp_lower_bound_inf();
1193 let upper = self.nlp_upper_bound_inf();
1194 let wrapped = ConditionedStartTnlp::new(tnlp, conditioner).with_bound_inf(lower, upper);
1195 Rc::new(RefCell::new(wrapped))
1196 }
1197
1198 pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1199 self.optimize_tnlp_with_derivative_test_tnlp(tnlp, None)
1200 }
1201
1202 /// Solve through `tnlp`, optionally overriding the derivative-test target.
1203 /// `None` tests the scaled and conditioned TNLP.
1204 pub fn optimize_tnlp_with_derivative_test_tnlp(
1205 &mut self,
1206 tnlp: Rc<RefCell<dyn TNLP>>,
1207 derivative_test_tnlp: Option<Rc<RefCell<dyn TNLP>>>,
1208 ) -> ApplicationReturnStatus {
1209 // gh#884. Both belong to this solve, not to whatever ran before
1210 // it. Reset *here* rather than in `optimize_constrained`, which
1211 // runs once per attempt: the signature accumulates across the
1212 // attempts a lower wrapper may spend, so
1213 // `run_with_dual_divergence_retry` reads "some attempt of the base
1214 // solve saw it" rather than "the last one did".
1215 self.dual_divergence_signature.set(false);
1216 self.dual_divergence_retry_promoted.set(false);
1217 self.answer_restored_from_floor.set(false);
1218 // gh#486 stage 2: per-variable `scaling_factor` is applied by
1219 // substituting variables one level below the algorithm, since
1220 // the core's scaling models the objective and the constraint
1221 // rows only. The wrapper consumes the variable factors and
1222 // forwards the rest, so `OrigIpoptNlp` sees exactly what it
1223 // has always handled. Installed here because every entry point
1224 // funnels through this method, and only under `user-scaling`,
1225 // the one method that consults the TNLP for factors at all.
1226 let tnlp = match self.install_variable_scaling(tnlp) {
1227 Ok(t) => t,
1228 Err(msg) => {
1229 use pounce_common::journalist::JournalCategory;
1230 eprint!("{msg}");
1231 self.journalist
1232 .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
1233 return ApplicationReturnStatus::InvalidOption;
1234 }
1235 };
1236
1237 // Starting-point conditioning (`start_point_perturbation`,
1238 // `start_point_conditioner`). A no-op unless one is set, and set by
1239 // nothing automatic except the local-infeasibility ladder's third
1240 // rung, which only runs after a solve has already failed.
1241 let tnlp = self.install_start_conditioner(tnlp);
1242 let derivative_test_tnlp = derivative_test_tnlp.as_ref().unwrap_or(&tnlp);
1243
1244 if let Some(value) = self.unsupported_library_solver_selection() {
1245 use pounce_common::journalist::JournalCategory;
1246 self.journalist.print(
1247 JournalLevel::J_ERROR,
1248 JournalCategory::J_MAIN,
1249 &format!(
1250 "pounce: solver_selection={value} routing is only available \
1251 through the pounce CLI (.nl input); library consumers can use \
1252 qp-active-set, nlp, or auto.\n"
1253 ),
1254 );
1255 return ApplicationReturnStatus::InvalidOption;
1256 }
1257
1258 // A `linear_solver` pounce does not implement is refused rather
1259 // than quietly served by FERAL (gh#483 follow-up). Checked here,
1260 // before any work, so a library consumer gets the same verdict the
1261 // CLI gives before its banner.
1262 if let Some(value) = self.unimplemented_linear_solver() {
1263 use pounce_common::journalist::JournalCategory;
1264 let msg = format!("{}\n", Self::unimplemented_linear_solver_message(&value));
1265 eprint!("{msg}");
1266 self.journalist
1267 .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
1268 return ApplicationReturnStatus::InvalidOption;
1269 }
1270
1271 // gh#483 follow-up: an option naming a feature pounce does not
1272 // implement is refused, not shrugged off. See
1273 // `unimplemented_options` for how membership was established and
1274 // why an explicitly-set *default* is deliberately still allowed.
1275 // gh#518: same treatment for `option_file_name` on an entry point
1276 // that cannot resolve it. Separate from the table above because
1277 // the *feature* now exists — just not here.
1278 // gh#604: same treatment one level down, for a registered *value*
1279 // of an option pounce otherwise reads (`bound_mult_init_method=
1280 // mu-based`).
1281 // gh#604: and for a convex-engine knob on an entry point that
1282 // cannot route to that engine — `option_file_name`'s case, one
1283 // feature over.
1284 if let Some(msg) = self
1285 .unimplemented_option_refusal()
1286 .or_else(|| self.unimplemented_option_value_refusal())
1287 .or_else(|| self.unhonored_option_file_name())
1288 .or_else(|| self.unhonored_convex_option())
1289 {
1290 use pounce_common::journalist::JournalCategory;
1291 eprintln!("{msg}");
1292 self.journalist.print(
1293 JournalLevel::J_ERROR,
1294 JournalCategory::J_MAIN,
1295 &format!("{msg}\n"),
1296 );
1297 return ApplicationReturnStatus::InvalidOption;
1298 }
1299 // A `ma57_pivtolmax` the user set *below* `ma57_pivtol` is a
1300 // contradiction — the escalation ceiling under its floor — and
1301 // upstream refuses it outright
1302 // (`IpMa57TSolverInterface.cpp:313`, `OPTION_INVALID`). pounce
1303 // used to silently rewrite it to `ma57_pivtol`. That was
1304 // unreachable while gh#825 was live, since no `ma57_*` value
1305 // reached the backend at all, and became reachable the moment
1306 // that was fixed — so it is refused here rather than shipped as
1307 // a new way to be quietly ignored.
1308 if let Some(msg) = self.ma57_pivtol_bracket_refusal() {
1309 use pounce_common::journalist::JournalCategory;
1310 eprintln!("{msg}");
1311 self.journalist.print(
1312 JournalLevel::J_ERROR,
1313 JournalCategory::J_MAIN,
1314 &format!("{msg}\n"),
1315 );
1316 return ApplicationReturnStatus::InvalidOption;
1317 }
1318
1319 let backend_warnings = self.take_unimplemented_backend_warnings();
1320 for warning in self
1321 .unexploited_hint_warnings()
1322 .into_iter()
1323 .chain(backend_warnings)
1324 {
1325 eprintln!("{warning}");
1326 }
1327
1328 // Test before presolve, using the requested coordinate space.
1329 self.run_derivative_test(derivative_test_tnlp);
1330
1331 // Top-level algorithm dispatch (Phase 5b §7.1). When the
1332 // `algorithm` option resolves to "active-set-sqp", route
1333 // to the Phase 5b SQP path; otherwise fall through to the
1334 // existing IPM flow unchanged.
1335 // Materialize generic TNLP presolve once at the public entry point.
1336 // The wrapper owns the submitted callback TNLP, so every algorithm
1337 // path below (including retry paths) continues to postsolve into
1338 // the original user-facing space. With `presolve=no`, this returns
1339 // the exact same Rc unchanged.
1340 let tnlp = if self.presolve_already_applied {
1341 tnlp
1342 } else {
1343 match pounce_presolve::wrap_from_options(tnlp, &self.options) {
1344 Ok(tnlp) => tnlp,
1345 Err(err) => {
1346 use pounce_common::journalist::JournalCategory;
1347 self.journalist.print(
1348 JournalLevel::J_ERROR,
1349 JournalCategory::J_MAIN,
1350 &format!("pounce: could not materialize presolve options: {err}\n"),
1351 );
1352 return ApplicationReturnStatus::InvalidOption;
1353 }
1354 }
1355 };
1356
1357 if self.is_sqp_algorithm_selected() {
1358 return self.optimize_sqp_tnlp(tnlp);
1359 }
1360 let info = match tnlp.borrow_mut().get_nlp_info() {
1361 Some(info) => info,
1362 None => return ApplicationReturnStatus::InvalidProblemDefinition,
1363 };
1364
1365 // Presolve-certified infeasibility. `get_nlp_info` above is what forces
1366 // the (lazy) presolve init, so this is the first point at which the
1367 // proof exists. If bound propagation or FBBT established that the
1368 // feasible region is empty, there is nothing left to compute: return
1369 // the verdict directly.
1370 //
1371 // Short-circuiting *here*, before dispatch, is deliberate. Running the
1372 // solve anyway would only re-derive a strictly weaker result — a
1373 // stationary point of the constraint violation, which for a nonconvex
1374 // problem proves nothing globally — and would also hand an
1375 // `InfeasibleProblemDetected` to the ℓ₁ auto-fallback below
1376 // (`is_l1_fallback_trigger`), which would then burn a whole second
1377 // solve retrying a problem already proved to have no solution.
1378 //
1379 // Soundness rests on `presolve_infeasibility_proof` returning `Some`
1380 // only for a contradiction derived on an *un-clamped* box — a
1381 // detection made while a Phase-0 auxiliary elimination is in force can
1382 // be an artifact of that elimination and is re-checked after rollback
1383 // before it is certified. See `PresolveState::certified_infeasible`.
1384 if let Some(proof) = tnlp.borrow().presolve_infeasibility_proof() {
1385 use pounce_common::journalist::JournalCategory;
1386 let detail = match proof {
1387 pounce_nlp::tnlp::InfeasibilityProof::BoundPropagation => {
1388 "bound propagation crossed a variable's bounds".to_string()
1389 }
1390 pounce_nlp::tnlp::InfeasibilityProof::IntervalArithmetic { witness } => {
1391 format!("interval arithmetic emptied constraint {witness}'s range")
1392 }
1393 };
1394 self.journalist.print(
1395 JournalLevel::J_SUMMARY,
1396 JournalCategory::J_MAIN,
1397 &format!(
1398 "\nEXIT: Presolve detected the feasible region is empty ({detail}).\n\
1399 No feasible point exists; the solve was not run.\n"
1400 ),
1401 );
1402 return ApplicationReturnStatus::InfeasibleProblemDetected;
1403 }
1404 // ℓ₁-exact penalty-barrier opt-in (pounce#10).
1405 // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
1406 // Waltz ρ-escalation loop around the constrained IPM, with a
1407 // honest-infeasibility status upgrade when the slacks fail to
1408 // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
1409 // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
1410 // no-op for problems with no equality rows, so the
1411 // unconstrained dispatch below is unaffected when there is
1412 // nothing to wrap.
1413 if info.m > 0 && self.is_l1_penalty_enabled() {
1414 if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
1415 return status;
1416 }
1417 // Falls through: wrapper construction failed (inner refused
1418 // get_nlp_info / get_bounds_info) or no equality rows to
1419 // slack. Standard dispatch runs unmodified.
1420 }
1421 // Phase 3.5 auto-fallback (pounce#10): if the standard solve
1422 // ends in a trigger-class status, retry transparently with
1423 // the wrapper. Promote the retry's status only if it returns
1424 // SolveSucceeded — otherwise return the original. Skipped if
1425 // the user already opted into the wrapper above (this avoids
1426 // a double pass and keeps semantics predictable).
1427 if info.m > 0 && self.is_l1_fallback_enabled() && !self.is_l1_penalty_enabled() {
1428 return self.run_with_l1_fallback(tnlp);
1429 }
1430 // Biactive dual-divergence retry (gh#884): if the solve settles
1431 // its primal while its multipliers run away, throw the iterate
1432 // away and solve again from scratch with the constraint-Jacobian
1433 // perturbation on. Outermost of the two retry wrappers, so its
1434 // "base solve" is the whole standard dispatch below including the
1435 // μ flip — the signature is about a *solve*, and spending the μ
1436 // flip first is strictly cheaper than spending this one first.
1437 if self.is_dual_divergence_retry_enabled() {
1438 return self.run_with_dual_divergence_retry(tnlp);
1439 }
1440 self.dispatch_standard_solve(tnlp)
1441 }
1442
1443 /// The standard solve dispatch: the μ-strategy fallback if it is
1444 /// enabled, otherwise one `optimize_constrained` call.
1445 ///
1446 /// Factored out of `optimize_tnlp_with_derivative_test_tnlp` so
1447 /// [`Self::run_with_dual_divergence_retry`] can wrap the whole of it
1448 /// rather than only the bare IPM call (gh#884).
1449 fn dispatch_standard_solve(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
1450 // μ-strategy auto-fallback (pounce#138): if the standard solve
1451 // stalls, retry once with the opposite mu_strategy and promote
1452 // only on Solve_Succeeded. Which stalls qualify depends on
1453 // whether the caller asked for the retry — see
1454 // `run_with_mu_strategy_fallback` (pounce#748).
1455 // Applies to constrained and unconstrained alike (both run the
1456 // same IPM). Independent of, and lower priority than, the ℓ₁
1457 // fallback above.
1458 if self.is_mu_strategy_fallback_enabled() {
1459 return self.run_with_mu_strategy_fallback(tnlp);
1460 }
1461 // Every problem — constrained or not — goes through the same
1462 // primal-dual IPM, exactly as upstream Ipopt does. There is no
1463 // separate "unconstrained Newton" path: the linear-solver
1464 // backend (FERAL/MA57) handles the augmented system, so the
1465 // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
1466 // blowup.
1467 self.optimize_constrained(tnlp)
1468 }
1469
1470 /// Read the ℓ₁ wrapper master switch from the OptionsList.
1471 /// Default `false` when the option is not set.
1472 fn is_l1_penalty_enabled(&self) -> bool {
1473 self.options
1474 .get_bool_value("l1_exact_penalty_barrier", "")
1475 .ok()
1476 .and_then(|(v, found)| found.then_some(v))
1477 .unwrap_or(false)
1478 }
1479
1480 fn l1_penalty_init(&self) -> Number {
1481 self.options
1482 .get_numeric_value("l1_penalty_init", "")
1483 .ok()
1484 .and_then(|(v, found)| found.then_some(v))
1485 .unwrap_or(1.0)
1486 }
1487 fn l1_penalty_max(&self) -> Number {
1488 self.options
1489 .get_numeric_value("l1_penalty_max", "")
1490 .ok()
1491 .and_then(|(v, found)| found.then_some(v))
1492 .unwrap_or(1.0e6)
1493 }
1494 fn l1_penalty_increase_factor(&self) -> Number {
1495 self.options
1496 .get_numeric_value("l1_penalty_increase_factor", "")
1497 .ok()
1498 .and_then(|(v, found)| found.then_some(v))
1499 .unwrap_or(8.0)
1500 }
1501 fn l1_penalty_max_outer_iter(&self) -> usize {
1502 self.options
1503 .get_integer_value("l1_penalty_max_outer_iter", "")
1504 .ok()
1505 .and_then(|(v, found)| found.then_some(v))
1506 .unwrap_or(8) as usize
1507 }
1508 fn l1_slack_tol(&self) -> Number {
1509 self.options
1510 .get_numeric_value("l1_slack_tol", "")
1511 .ok()
1512 .and_then(|(v, found)| found.then_some(v))
1513 .unwrap_or(1.0e-6)
1514 }
1515 fn l1_steering_factor(&self) -> Number {
1516 self.options
1517 .get_numeric_value("l1_steering_factor", "")
1518 .ok()
1519 .and_then(|(v, found)| found.then_some(v))
1520 .unwrap_or(10.0)
1521 }
1522 fn is_l1_fallback_enabled(&self) -> bool {
1523 self.options
1524 .get_bool_value("l1_fallback_on_restoration_failure", "")
1525 .ok()
1526 .and_then(|(v, found)| found.then_some(v))
1527 .unwrap_or(false)
1528 }
1529
1530 /// Did the caller set `mu_strategy` explicitly?
1531 ///
1532 /// The answer decides whether the limited-memory default applies
1533 /// (see `algorithm_builder_from_options`): upstream only substitutes
1534 /// `adaptive` for the registered `monotone` when the option is
1535 /// absent from the list.
1536 fn mu_strategy_was_set(&self) -> bool {
1537 matches!(
1538 self.options.get_string_value("mu_strategy", ""),
1539 Ok((_, true))
1540 )
1541 }
1542
1543 /// Did the caller set `mu_strategy_fallback` themselves? Separates
1544 /// the opted-in retry from the default-on one, which triggers on a
1545 /// narrower set of statuses (pounce#748).
1546 ///
1547 /// True for an explicit `no` as well as an explicit `yes`, which is
1548 /// harmless: this is only ever read downstream of
1549 /// [`Self::is_mu_strategy_fallback_enabled`], and an explicit `no`
1550 /// stops there.
1551 fn mu_strategy_fallback_was_set(&self) -> bool {
1552 matches!(
1553 self.options.get_bool_value("mu_strategy_fallback", ""),
1554 Ok((_, true))
1555 )
1556 }
1557
1558 /// Options whose presence means a `Solved_To_Acceptable_Level`
1559 /// exit may be something the *caller* asked for rather than a stall
1560 /// POUNCE fell into (gh #757).
1561 ///
1562 /// Every one of them either moves the bar a certificate has to clear
1563 /// (`tol` and the component tolerances, the `acceptable_*` family),
1564 /// arms a guard that refuses a certificate the iterate would
1565 /// otherwise have earned (`kkt_fidelity_tol`, the certificate-mask
1566 /// and noise-floor kappas, the divergence / infeasibility streaks,
1567 /// the restoration-decline pair).
1568 ///
1569 /// Options that only move the *starting point* are deliberately not
1570 /// here. `least_square_init_primal` was tried and removed: a caller
1571 /// who picks an initialization heuristic has said nothing about what
1572 /// convergence means, and listing it made an explicit `=no` behave
1573 /// differently from omitting the option, which is a distinction the
1574 /// rest of the solver does not draw.
1575 const TERMINATION_POLICY_OPTIONS: &'static [&'static str] = &[
1576 "tol",
1577 "dual_inf_tol",
1578 "constr_viol_tol",
1579 "compl_inf_tol",
1580 "acceptable_tol",
1581 "acceptable_iter",
1582 "acceptable_dual_inf_tol",
1583 "acceptable_constr_viol_tol",
1584 "acceptable_compl_inf_tol",
1585 "acceptable_obj_change_tol",
1586 "kkt_fidelity_tol",
1587 "obj_scale_certificate_threshold",
1588 "dual_inf_scale_kappa",
1589 "primal_noise_floor_kappa",
1590 "dual_diverging_streak",
1591 "infeas_max_streak",
1592 "resto_decline_deferrals",
1593 "resto_decline_progress_ratio",
1594 "neg_curv_escapes",
1595 "limited_memory_ls_failure_restarts",
1596 ];
1597
1598 /// Did the caller set any option from
1599 /// [`Self::TERMINATION_POLICY_OPTIONS`]?
1600 ///
1601 /// This is what separates the two readings of a
1602 /// `Solved_To_Acceptable_Level` exit. Under stock convergence
1603 /// settings it means POUNCE's own schedule parked the dual term
1604 /// above `tol` and a flipped schedule is worth one try. Under a
1605 /// caller-modified one it may be the signal the caller armed the
1606 /// option to receive, and erasing it with a retry is exactly the
1607 /// laundering pounce#748 refused to do by default.
1608 fn caller_set_termination_policy(&self) -> bool {
1609 Self::TERMINATION_POLICY_OPTIONS.iter().any(|name| {
1610 matches!(self.options.get_numeric_value(name, ""), Ok((_, true)))
1611 || matches!(self.options.get_integer_value(name, ""), Ok((_, true)))
1612 || matches!(self.options.get_bool_value(name, ""), Ok((_, true)))
1613 || matches!(self.options.get_string_value(name, ""), Ok((_, true)))
1614 })
1615 }
1616
1617 /// The μ strategy this option table actually resolves to, as
1618 /// `algorithm_builder_from_options` will build it: the explicit
1619 /// value when there is one, otherwise `adaptive` for a
1620 /// limited-memory Hessian and `monotone` for anything else.
1621 ///
1622 /// The fallback below flips *this*, not the registered default —
1623 /// flipping the registered default under limited-memory would
1624 /// "retry" with the strategy that just failed.
1625 fn effective_mu_strategy_is_adaptive(&self) -> bool {
1626 if let Ok((v, true)) = self.options.get_string_value("mu_strategy", "") {
1627 return v == "adaptive";
1628 }
1629 matches!(
1630 self.options.get_string_value("hessian_approximation", ""),
1631 Ok((ref v, true)) if v == "limited-memory"
1632 )
1633 }
1634
1635 /// Read the μ-strategy auto-fallback switch (pounce#138).
1636 ///
1637 /// An explicit setting always wins. Absent one the default is **on**
1638 /// (pounce#748) — but only while the user has not chosen a
1639 /// `mu_strategy` themselves. Retrying under the other schedule is a
1640 /// recovery for a solve that stalled on a strategy POUNCE picked; it
1641 /// is not licence to override a strategy the caller named. Without
1642 /// that condition, flipping the default would silently contaminate
1643 /// every controlled comparison that pins `mu_strategy` on purpose,
1644 /// this repository's own benchmark arms included. The motivating
1645 /// case is unaffected: `dirichlet120` stalls under the
1646 /// limited-memory substitution (pounce#746), which by definition
1647 /// only happens when `mu_strategy` is unset.
1648 fn is_mu_strategy_fallback_enabled(&self) -> bool {
1649 match self.options.get_bool_value("mu_strategy_fallback", "") {
1650 Ok((v, true)) => v,
1651 _ => !self.mu_strategy_was_set(),
1652 }
1653 }
1654
1655 /// Has the user set `algorithm = active-set-sqp`? Reads the
1656 /// string option and matches case-insensitively against the
1657 /// design-note §7.1 spelling. Any value other than
1658 /// "active-set-sqp" (including absence) routes to the
1659 /// default IPM path.
1660 /// Stash a warm-start iterate for the SQP path. Consumed by
1661 /// the next `optimize_tnlp` call when the `algorithm` option
1662 /// resolves to `active-set-sqp`; the IPM path ignores it.
1663 /// Phase 5c (§6) — the parametric / MPC warm-start hand-off.
1664 ///
1665 /// The iterate is auto-cleared after use, so a follow-up
1666 /// solve without an intervening `set_sqp_warm_start` call
1667 /// cold-starts.
1668 pub fn set_sqp_warm_start(&mut self, warm: crate::sqp::SqpIterates) {
1669 self.sqp_warm_start = Some(warm);
1670 }
1671
1672 /// Drop any pending warm-start iterate without solving.
1673 pub fn clear_sqp_warm_start(&mut self) {
1674 self.sqp_warm_start = None;
1675 }
1676
1677 /// What the crossover phase did on the most recent solve (gh#612).
1678 ///
1679 /// `None` means crossover never ran — either `crossover=no` (the
1680 /// default) or the solve did not converge. A `Some` whose
1681 /// [`CrossoverReport::accepted`] is false means it ran and *declined*;
1682 /// the two are different facts about a solve and consumers that reason
1683 /// about active-set certainty (sensitivity's AMBIGUOUS class, a
1684 /// downstream `var_status`) need to tell them apart.
1685 ///
1686 /// [`CrossoverReport::accepted`]: crate::crossover::CrossoverReport::accepted
1687 pub fn crossover_report(&self) -> Option<&crate::crossover::CrossoverReport> {
1688 self.crossover_report.as_ref()
1689 }
1690
1691 /// Install a full primal-dual warm-start iterate for the next IPM
1692 /// `optimize_tnlp`. Captured by the debugger's `resolve` so the
1693 /// re-solve continues from the paused interior point. The caller is
1694 /// responsible for also enabling `warm_start_init_point=yes` (and
1695 /// usually `warm_start_target_mu=<μ>`) so the re-optimize branch of
1696 /// `WarmStartIterateInitializer` preserves the installed iterate.
1697 /// Consumed once per solve, then auto-cleared.
1698 pub fn set_warm_start_iterate(&mut self, snap: crate::debug::IterateSnapshot) {
1699 self.warm_start_iterate = Some(snap);
1700 }
1701
1702 /// Install a caller-supplied fill-reducing permutation for the KKT
1703 /// linear solver (pounce#180 item 1). The next `optimize_*` builds
1704 /// the FERAL backend with [`pounce_feral::OrderingMethod::External`],
1705 /// overriding the `feral_ordering` string option / env var. Use this
1706 /// to inject a block-triangular / Schur ordering a generic algorithm
1707 /// cannot see (Parker, Garcia & Bent, arXiv:2602.17968) or a tearing
1708 /// ordering from equation-oriented decomposition.
1709 ///
1710 /// `perm` is a **0-based, new-to-old permutation** (`perm[k]` is the
1711 /// original index that becomes index `k`), and its length must equal
1712 /// the augmented KKT system dimension (variables + slacks +
1713 /// constraint duals), *not* the problem's `n`. A wrong length or a
1714 /// non-bijection is rejected by FERAL at the first factorization with
1715 /// an `InvalidInput` error (never a panic), surfacing as a solver
1716 /// failure rather than a silently-wrong solve — the ordering only
1717 /// affects fill/time, never the computed solution.
1718 ///
1719 /// Persistent config: unlike the warm-start hooks it is *not*
1720 /// auto-cleared after a solve. Call [`Self::clear_external_ordering`]
1721 /// to drop it. Ignored by non-FERAL backends and by any custom
1722 /// factory plugged via [`Self::set_linear_backend_factory`].
1723 pub fn set_external_ordering(&mut self, perm: Vec<usize>) {
1724 self.external_ordering = Some(perm);
1725 }
1726
1727 /// Drop any installed external KKT ordering, restoring the
1728 /// `feral_ordering`-driven default for subsequent solves.
1729 pub fn clear_external_ordering(&mut self) {
1730 self.external_ordering = None;
1731 }
1732
1733 /// The currently-installed external KKT ordering, if any.
1734 pub fn external_ordering(&self) -> Option<&[usize]> {
1735 self.external_ordering.as_deref()
1736 }
1737
1738 /// Install a block-triangular / Schur KKT partition (pounce#180 item 2).
1739 /// `indices` are KKT-space indices (`0..dim` in the `x, s, c, d` block
1740 /// order the aug-system solver assembles) naming the Schur block `S`; that
1741 /// block is Schur-complemented out and only the two diagonal blocks are
1742 /// factorized (inertia via Sylvester's law). Honored on the IPM + feral +
1743 /// exact-Hessian path; the Schur solver falls back to the standard
1744 /// full-space solver transparently when the partition is unsuitable (too
1745 /// large a fraction of the system, malformed, or a backend error), so a
1746 /// stray hook never breaks a solve. Persistent config (not auto-cleared);
1747 /// drop it via [`Self::clear_kkt_schur_block`].
1748 pub fn set_kkt_schur_block(&mut self, indices: Vec<usize>) {
1749 self.kkt_schur_block = Some(indices);
1750 }
1751
1752 /// Drop any installed Schur KKT partition, restoring the standard
1753 /// full-space solver for subsequent solves.
1754 pub fn clear_kkt_schur_block(&mut self) {
1755 self.kkt_schur_block = None;
1756 }
1757
1758 /// The currently-installed Schur KKT partition, if any.
1759 pub fn kkt_schur_block(&self) -> Option<&[usize]> {
1760 self.kkt_schur_block.as_deref()
1761 }
1762
1763 /// Return the final QP working set from the most recent SQP
1764 /// solve, or `None` if the last solve wasn't SQP, didn't
1765 /// produce a working set (cold-start declared the iterate
1766 /// optimal before solving any QP), or no SQP solve has run.
1767 pub fn last_sqp_working_set(&self) -> Option<&pounce_qp::WorkingSet> {
1768 self.sqp_last_working_set.as_ref()
1769 }
1770
1771 /// If `solver_selection` is explicitly set to a value whose routing lives
1772 /// only in the CLI's `.nl` dispatch, return it; otherwise `None`.
1773 /// `optimize_tnlp` uses this to reject a forced convex selection a library
1774 /// consumer cannot honor.
1775 fn unsupported_library_solver_selection(&self) -> Option<&'static str> {
1776 let (v, found) = self.options.get_string_value("solver_selection", "").ok()?;
1777 if !found {
1778 return None;
1779 }
1780 ["lp-ipm", "qp-ipm", "socp"]
1781 .into_iter()
1782 .find(|c| v.eq_ignore_ascii_case(c))
1783 }
1784
1785 /// The `linear_solver` value when the caller explicitly asked for a
1786 /// backend pounce does not implement; `None` when the request can be
1787 /// served (or was never made).
1788 ///
1789 /// pounce ships two: **FERAL** (pure Rust, the effective default) and
1790 /// **MA57** (HSL, behind the `ma57` feature). The option's valid-value
1791 /// list is a faithful port of upstream Ipopt's — `ma27`, `ma77`,
1792 /// `ma86`, `ma97`, `mumps`, `pardiso`, `pardisomkl`, `spral`, `wsmp`,
1793 /// `custom` — so an `ipopt.opt` written for Ipopt parses here, and
1794 /// every one of those names used to fall through a `_ =>` arm to
1795 /// FERAL. A run "using MUMPS" was a FERAL run; a benchmark comparing
1796 /// backends compared FERAL with itself (gh#483 follow-up).
1797 ///
1798 /// The registered default is `feral`, which pounce implements, so no
1799 /// explicit-vs-default distinction is needed: whatever the option
1800 /// resolves to must be a backend that exists. (It is checked
1801 /// unconditionally on purpose — a future default naming something
1802 /// unimplemented should trip this, not slip past it.)
1803 ///
1804 /// Explicit `ma57` on a build that lacks the feature is *not* refused;
1805 /// that fallback is reported in the banner ("ma57 requested but not
1806 /// compiled"), so it is visible rather than silent, and failing a
1807 /// portable `ipopt.opt` over a build flag would cost more than it buys.
1808 pub fn unimplemented_linear_solver(&self) -> Option<String> {
1809 let (v, _) = self.options.get_string_value("linear_solver", "").ok()?;
1810 ["feral", "ma57"]
1811 .iter()
1812 .all(|ok| !v.eq_ignore_ascii_case(ok))
1813 .then_some(v)
1814 }
1815
1816 /// The message for the first option the caller set that names a
1817 /// feature pounce does not implement, or `None`. Public so the CLI
1818 /// can refuse before routing — the convex dispatch never reaches
1819 /// `optimize_tnlp`. See [`crate::unimplemented_options`].
1820 ///
1821 /// A run configuring nothing but backends pounce does not ship is
1822 /// refused here too, after the per-option table has had its say —
1823 /// see [`crate::unimplemented_options::backend_only_refusal`]. It
1824 /// is folded in rather than given its own accessor so that every
1825 /// surface already refusing on this method refuses on it as well;
1826 /// the CLI is not the only frontend, and a condition worth failing
1827 /// on is not worth failing on only from the CLI.
1828 pub fn unimplemented_option_refusal(&self) -> Option<String> {
1829 crate::unimplemented_options::refusal(&self.options, &self.reg_options).or_else(|| {
1830 crate::unimplemented_options::backend_only_refusal(&self.options, &self.reg_options)
1831 })
1832 }
1833
1834 /// The message for the first string option the caller set to a
1835 /// registered *value* pounce does not implement, or `None`.
1836 ///
1837 /// Separate from [`Self::unimplemented_option_refusal`] because the
1838 /// option itself is read and its other values work — it is one mode
1839 /// that is missing, not the feature. See
1840 /// [`crate::unimplemented_options::UNIMPLEMENTED_VALUES`].
1841 pub fn unimplemented_option_value_refusal(&self) -> Option<String> {
1842 crate::unimplemented_options::value_refusal(&self.options)
1843 }
1844
1845 /// `option_file_name` set on a surface that never resolves it.
1846 ///
1847 /// The option reaches a file through exactly one path —
1848 /// [`Self::initialize_with_option_file`], which the `pounce` CLI
1849 /// drives. A library caller (Python, the C interface, WASM) sets its
1850 /// options directly and calls no such thing, so on those surfaces the
1851 /// option names a whole configuration and applies none of it: gh#518's
1852 /// failure mode, one surface over. It used to be caught by the blanket
1853 /// [`crate::unimplemented_options`] refusal, which no longer covers it
1854 /// now that the feature exists; this keeps the guard exactly where the
1855 /// feature still doesn't.
1856 ///
1857 /// Deliberately *not* fixed by having the library read an options file
1858 /// too: an implicit `./ipopt.opt` lookup under Python or the GAMS C
1859 /// link would be a surprising action at a distance, and `pounce.opt`
1860 /// already means something else to GAMS.
1861 pub fn unhonored_option_file_name(&self) -> Option<String> {
1862 if self.option_file_resolved {
1863 return None;
1864 }
1865 // Same default gate as the table: an explicitly-set *default*
1866 // asks for nothing, so it must not fail. `option_file_name`
1867 // defaults to `ipopt.opt`, and a caller round-tripping a full
1868 // option dump — or a generated config that spells out every
1869 // registered name — hits that value without asking for anything.
1870 if !crate::unimplemented_options::set_to_a_non_default(
1871 &self.options,
1872 &self.reg_options,
1873 "option_file_name",
1874 ) {
1875 return None;
1876 }
1877 match self.options.get_string_value("option_file_name", "") {
1878 Ok((name, true)) if !name.is_empty() => Some(format!(
1879 "pounce: `option_file_name` was set to `{name}`, but this entry \
1880 point does not read options files — it would configure nothing. \
1881 The `pounce` CLI honors it (and `./pounce.opt` / `./ipopt.opt`); \
1882 from a library, read the file yourself and pass its contents to \
1883 `initialize_with_options_str`, or set the options directly. \
1884 Tracking issue: https://github.com/jkitchin/pounce/issues/518"
1885 )),
1886 _ => None,
1887 }
1888 }
1889
1890 /// Declare that this caller can route a model to the convex LP/QP /
1891 /// SOCP engines, so the `qp_*` knobs they read configure something.
1892 ///
1893 /// The `pounce` CLI calls this: it owns the `.nl` structure
1894 /// extraction that classifies a model, which is the whole of what
1895 /// `solver_selection`'s convex values need. No library frontend can
1896 /// (see [`Self::unsupported_library_solver_selection`]), so the
1897 /// default is `false` and [`Self::unhonored_convex_option`] refuses
1898 /// the knobs there.
1899 ///
1900 /// Declaring it also covers the CLI's *fallback*: a convex attempt
1901 /// that returns no verified point hands the model to
1902 /// [`Self::optimize_tnlp`], and the `qp_*` values it was given
1903 /// configured that attempt for real. Refusing them at the handoff
1904 /// would fail a run that used them.
1905 pub fn set_convex_routing_available(&mut self, available: bool) {
1906 self.convex_routing_available = available;
1907 }
1908
1909 /// The convex LP/QP knobs are registered core-side so every frontend
1910 /// parses them — but only the CLI can reach the engines that read
1911 /// them. On any other entry point the option names a whole
1912 /// configuration and applies none of it; this is the message that
1913 /// says so, in place of the silence.
1914 ///
1915 /// gh#604. Same shape and same default gate as
1916 /// [`Self::unhonored_option_file_name`]: an explicitly-set *default*
1917 /// asks for nothing and must keep working, so only a value that
1918 /// differs is refused.
1919 pub fn unhonored_convex_option(&self) -> Option<String> {
1920 if self.convex_routing_available {
1921 return None;
1922 }
1923 const CONVEX_ONLY: &[&str] = &[
1924 "qp_presolve",
1925 "qp_tau",
1926 "qp_tau_max",
1927 "qp_reg",
1928 "qp_gondzio_corr",
1929 "qp_infeas_tol",
1930 "qp_hsde",
1931 "qp_equilibrate",
1932 "qp_crossover",
1933 ];
1934 let name = CONVEX_ONLY.iter().find(|name| {
1935 crate::unimplemented_options::set_to_a_non_default(
1936 &self.options,
1937 &self.reg_options,
1938 name,
1939 )
1940 })?;
1941 Some(format!(
1942 "pounce: `{name}` tunes the convex LP/QP interior-point engine, but \
1943 this entry point cannot route a model to it — the option would \
1944 configure nothing. The `pounce` CLI reaches that engine on `.nl` \
1945 input (`solver_selection=lp-ipm` / `qp-ipm` / `socp`, or `auto` on \
1946 a model that classifies as one); from Python, `pounce.solve_qp` / \
1947 `pounce.solve_cone` drive it directly and take the same knobs as \
1948 typed arguments. On this path, `solver_selection=qp-active-set` \
1949 (or `algorithm=active-set-sqp`) is the nearest thing, tuned by the \
1950 `sqp_qp_*` options. Tracking issue: \
1951 https://github.com/jkitchin/pounce/issues/604"
1952 ))
1953 }
1954
1955 /// Warnings for caching hints pounce does not exploit. These never
1956 /// block a solve: the answer is identical either way, so refusing
1957 /// would cost the caller more than the silence did.
1958 pub fn unexploited_hint_warnings(&self) -> Vec<String> {
1959 crate::unimplemented_options::hint_warnings(&self.options, &self.reg_options)
1960 }
1961
1962 /// Warnings for the constant-derivative hints when the solve routes to
1963 /// `pounce-convex` instead of here. Call site is the convex dispatch
1964 /// in the CLI, next to the other guards that live there for the same
1965 /// reason: that dispatch never reaches [`Self::optimize_tnlp`], so
1966 /// `install_constant_derivative_hints` never runs and the hints are
1967 /// unread. On the NLP route they are honoured, so this must not be
1968 /// called there.
1969 pub fn convex_unexploited_hint_warnings(&self) -> Vec<String> {
1970 crate::unimplemented_options::convex_hint_warnings(&self.options, &self.reg_options)
1971 }
1972
1973 /// Which of the four constant-derivative hints the caller actually
1974 /// asserted, in [`pounce_nlp::constant_derivatives::HINT_OPTIONS`]
1975 /// order — the order [`reconcile`] pairs against the model's own
1976 /// proofs.
1977 ///
1978 /// Each name is read as a literal rather than through the loop
1979 /// variable the caller used to use. The registered-but-unread scan
1980 /// (`tests/no_silent_options.rs`) keys on the option name as it
1981 /// appears at the accessor, so `get_bool_value(name, "")` over an
1982 /// array read as "no key here" and left all four sitting in the
1983 /// silent list while they were fully wired and consumed (#551 /
1984 /// #677).
1985 ///
1986 /// Matching over `HINT_OPTIONS` rather than writing a bare array
1987 /// keeps the slots right by construction, and a fifth hint added to
1988 /// `HINT_OPTIONS` trips the fallback arm instead of silently reading
1989 /// as "not asserted" — which is the failure mode this whole line of
1990 /// work exists to kill.
1991 fn asserted_constant_derivative_hints(&self) -> [bool; 4] {
1992 use pounce_nlp::constant_derivatives::HINT_OPTIONS;
1993 let read_yes = |key: &str| matches!(self.options.get_bool_value(key, ""), Ok((true, true)));
1994 HINT_OPTIONS.map(|name| match name {
1995 "grad_f_constant" => read_yes("grad_f_constant"),
1996 "hessian_constant" => read_yes("hessian_constant"),
1997 "jac_c_constant" => read_yes("jac_c_constant"),
1998 "jac_d_constant" => read_yes("jac_d_constant"),
1999 other => unreachable!("`{other}` is in HINT_OPTIONS with no read site"),
2000 })
2001 }
2002
2003 /// Resolve the four constant-derivative hints for this solve and
2004 /// install the result on the NLP (gh #588, phase Q6).
2005 ///
2006 /// `grad_f_constant` / `hessian_constant` / `jac_c_constant` /
2007 /// `jac_d_constant` are, upstream, unchecked user assertions: Ipopt
2008 /// reuses the derivative and returns a wrong answer if the assertion
2009 /// was false. pounce asks the model first. Where the model *proves*
2010 /// the derivative constant, the reuse happens whether or not the
2011 /// option was set — the hint is redundant. Where the model proves it
2012 /// **varies** and the option was set anyway, the option is refused
2013 /// with a warning, which is the deliberate divergence. Where the
2014 /// model can prove nothing — every callback front end, both GAMS
2015 /// links — the user's assertion is honoured on trust, exactly as
2016 /// upstream, because "unproved" is not "disproved" and silently
2017 /// overriding the caller there would be its own wrong answer.
2018 fn install_constant_derivative_hints(&self, orig_nlp: &mut OrigIpoptNlp) {
2019 use pounce_common::journalist::JournalCategory;
2020 use pounce_nlp::constant_derivatives::reconcile;
2021
2022 // Each name is read as a literal rather than through the loop
2023 // variable: the registered-but-unread scan
2024 // (`tests/no_silent_options.rs`) keys on the option name as it
2025 // appears at the accessor, so `get_bool_value(name, "")` read as
2026 // "no key here" and left all four of these sitting in the silent
2027 // list while they were fully wired and consumed (#551 / #677).
2028 //
2029 // Matching over `HINT_OPTIONS` rather than writing a bare array
2030 // keeps the order right by construction — `reconcile` pairs
2031 // `asserted[k]` with `proofs[k]` — and a fifth hint added to
2032 // `HINT_OPTIONS` trips the fallback arm instead of silently
2033 // reading as "not asserted", which is the failure mode this
2034 // whole line of work exists to kill.
2035 let asserted = self.asserted_constant_derivative_hints();
2036 let proofs = orig_nlp.derivative_proofs();
2037 let (outcomes, enabled) = reconcile(proofs, asserted);
2038
2039 for outcome in &outcomes {
2040 if let Some(warning) = outcome.warning() {
2041 eprintln!("{warning}");
2042 self.journalist.print(
2043 JournalLevel::J_STRONGWARNING,
2044 JournalCategory::J_MAIN,
2045 &format!("{warning}\n"),
2046 );
2047 }
2048 }
2049 if std::env::var("POUNCE_DBG_CONSTDERIV").is_ok() {
2050 for outcome in &outcomes {
2051 eprintln!(
2052 "[const deriv] {:<15} proof={:?} asserted={} reused={}",
2053 outcome.name, outcome.proof, outcome.asserted, outcome.honoured,
2054 );
2055 }
2056 }
2057 orig_nlp.set_constant_derivatives(enabled);
2058 }
2059
2060 /// Warnings for knobs of a linear-solver backend pounce does not
2061 /// ship (`ma97_*`, `pardiso_*`, …), one line per backend family.
2062 ///
2063 /// Warnings and not refusals: an `ipopt.opt` carrying settings for
2064 /// several backends so that one file runs everywhere is exactly what
2065 /// the registry exists to accept, and refusing it would fail a run
2066 /// over knobs it never touches. See the "Backend knobs warn, they do
2067 /// not refuse" section of [`crate::unimplemented_options`]. gh#551.
2068 pub fn unimplemented_backend_warnings(&self) -> Vec<String> {
2069 crate::unimplemented_options::backend_warnings(&self.options, &self.reg_options)
2070 }
2071
2072 /// The same warnings, but at most once per application: the second
2073 /// caller gets nothing.
2074 ///
2075 /// Two sites emit them — the CLI, before routing, because a convex
2076 /// model never reaches [`Self::optimize_tnlp`], and `optimize_tnlp`
2077 /// itself, for every frontend that is not the CLI. A CLI run passes
2078 /// through both, and printing the identical paragraph twice is how a
2079 /// warning teaches its reader to skip warnings.
2080 pub fn take_unimplemented_backend_warnings(&mut self) -> Vec<String> {
2081 if self.backend_warnings_emitted {
2082 return Vec::new();
2083 }
2084 self.backend_warnings_emitted = true;
2085 self.unimplemented_backend_warnings()
2086 }
2087
2088 /// Resolve the five registered `derivative_test*` knobs. Every one
2089 /// of them was registered and never read, so `derivative_test=
2090 /// first-order` ran no test and printed nothing — a checker that
2091 /// silently checks nothing reports success by omission (gh#483
2092 /// follow-up).
2093 ///
2094 /// The numeric helper is named `read_num` to match the accessor
2095 /// idiom the rest of this file uses: the registered-but-unread scan
2096 /// (`tests/no_silent_options.rs`) discovers `read_*` helpers and the
2097 /// literal key passed to them, so a differently-named local closure
2098 /// made `derivative_test_perturbation` and `derivative_test_tol`
2099 /// read as silent when they are wired and consumed (#677, #551).
2100 fn derivative_test_options(&self) -> DerivativeTestOptions {
2101 let read_num = |key: &str, default: Number| -> Number {
2102 self.options
2103 .get_numeric_value(key, "")
2104 .ok()
2105 .and_then(|(v, f)| f.then_some(v))
2106 .unwrap_or(default)
2107 };
2108 DerivativeTestOptions {
2109 mode: self
2110 .options
2111 .get_string_value("derivative_test", "")
2112 .ok()
2113 .and_then(|(v, f)| f.then_some(v))
2114 .map(|v| DerivativeTest::from_option(&v))
2115 .unwrap_or_default(),
2116 perturbation: read_num("derivative_test_perturbation", 1e-8),
2117 tol: read_num("derivative_test_tol", 1e-4),
2118 first_index: self
2119 .options
2120 .get_integer_value("derivative_test_first_index", "")
2121 .ok()
2122 .and_then(|(v, f)| f.then_some(v))
2123 .unwrap_or(-2),
2124 print_all: self
2125 .options
2126 .get_bool_value("derivative_test_print_all", "")
2127 .ok()
2128 .and_then(|(v, f)| f.then_some(v))
2129 .unwrap_or(false),
2130 }
2131 }
2132
2133 /// Run the derivative checker, if requested, against `tnlp`.
2134 ///
2135 /// Advisory, like upstream: a suspicious entry is reported and the
2136 /// solve continues. The report goes to stderr so it survives
2137 /// `print_level=0` and leaves `--json-output`'s stdout clean.
2138 pub fn run_derivative_test(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
2139 let opts = self.derivative_test_options();
2140 if matches!(opts.mode, DerivativeTest::None) {
2141 return;
2142 }
2143 let report = {
2144 let mut borrowed = tnlp.borrow_mut();
2145 pounce_nlp::derivative_test::run(&mut *borrowed, &opts)
2146 };
2147 let Some(report) = report else {
2148 eprintln!(
2149 "pounce: derivative_test was requested but the TNLP declined to \
2150 supply the information the check needs (dimensions, bounds, or \
2151 a starting point); no test was run."
2152 );
2153 return;
2154 };
2155 use pounce_common::journalist::JournalCategory;
2156 for line in &report.lines {
2157 eprintln!("{line}");
2158 self.journalist.print(
2159 JournalLevel::J_SUMMARY,
2160 JournalCategory::J_MAIN,
2161 &format!("{line}\n"),
2162 );
2163 }
2164 }
2165
2166 /// The message [`Self::unimplemented_linear_solver`] earns, shared by
2167 /// every frontend so they cannot drift apart.
2168 pub fn unimplemented_linear_solver_message(value: &str) -> String {
2169 format!(
2170 "pounce: linear_solver={value} is not implemented. pounce provides \
2171 `feral` (pure-Rust sparse symmetric, the default) and `ma57` (HSL, \
2172 in a `--features ma57` build); the other names in the option's \
2173 list come from the upstream Ipopt registry so an ipopt.opt written \
2174 for Ipopt still parses. Selecting one used to run FERAL silently, \
2175 which makes a backend comparison measure nothing — so it is \
2176 refused instead. Use linear_solver=feral or linear_solver=ma57."
2177 )
2178 }
2179
2180 fn is_sqp_algorithm_selected(&self) -> bool {
2181 // `algorithm` is the primary selector.
2182 // `solver_selection = qp-active-set` selects the
2183 // same active-set SQP engine.
2184 let algo_sqp = matches!(
2185 self.options.get_string_value("algorithm", ""),
2186 Ok((v, true)) if v.eq_ignore_ascii_case("active-set-sqp")
2187 );
2188 let selection_sqp = matches!(
2189 self.options.get_string_value("solver_selection", ""),
2190 Ok((v, true)) if v.eq_ignore_ascii_case("qp-active-set")
2191 );
2192 algo_sqp || selection_sqp
2193 }
2194
2195 /// Phase 5b SQP entry point. Builds the same NLP chain
2196 /// (`TNLPAdapter` → `OrigIpoptNlp` → `IpoptNlpAdapter`) the
2197 /// IPM uses, then runs `SqpAlgorithm::optimize`. Maps the
2198 /// `SqpResult.status` back to `ApplicationReturnStatus` and
2199 /// hands the final iterate to the user TNLP's
2200 /// `finalize_solution` callback via `finalize_via_sqp`.
2201 fn optimize_sqp_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2202 use pounce_nlp::ConstObjScaling;
2203 use pounce_nlp::orig_ipopt_nlp::OrigIpoptNlp;
2204 use pounce_nlp::tnlp_adapter::TNLPAdapter;
2205
2206 // Wall-clock for the whole SQP solve, mirroring the IPM path's
2207 // `t_start` (see the `total_wallclock_time_secs` assignment in
2208 // `optimize_tnlp`). Without this the field stayed at its struct
2209 // default of 0.0 on every active-set solve, so `--json-output`
2210 // reported an instantaneous solve regardless of actual runtime and
2211 // the engine could not be speed-compared against qp-ipm at all
2212 // (benchmarks/scripts/compare_qp_four_way.py had to skip the column).
2213 let t_start = std::time::Instant::now();
2214
2215 let adapter = match TNLPAdapter::new(Rc::clone(&tnlp)) {
2216 Ok(a) => Rc::new(RefCell::new(a)),
2217 Err(_) => return ApplicationReturnStatus::InvalidProblemDefinition,
2218 };
2219 // The SQP path never runs gradient-based scaling, but the
2220 // constant `obj_scaling_factor` (negative ⇒ maximize) still
2221 // applies via the OrigIpoptNlp constructor.
2222 let obj_scaling_factor = self
2223 .options
2224 .get_numeric_value("obj_scaling_factor", "")
2225 .ok()
2226 .and_then(|(v, f)| f.then_some(v))
2227 .unwrap_or(1.0);
2228 let mut orig_nlp = match OrigIpoptNlp::new(
2229 Rc::clone(&adapter),
2230 Rc::new(ConstObjScaling(obj_scaling_factor)),
2231 ) {
2232 Ok(n) => n,
2233 Err(_) => return ApplicationReturnStatus::InternalError,
2234 };
2235 // Same Q6 reconciliation as the IPM route: the SQP driver
2236 // evaluates the same derivatives through the same NLP object.
2237 self.install_constant_derivative_hints(&mut orig_nlp);
2238 let nlp_rc: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
2239
2240 let mut sqp_adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(&nlp_rc));
2241
2242 let mut builder = self.algorithm_builder_snapshot();
2243 builder.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
2244 let factory = self.make_backend_factory();
2245 let mut alg = match builder.build_sqp_with_backend(factory) {
2246 Some(a) => a,
2247 None => return ApplicationReturnStatus::InternalError,
2248 };
2249
2250 // Problem statistics + end-of-run summary are emitted by the engine
2251 // itself here (#206), gated on the main `print_level`, so the SQP
2252 // route matches the IPM route across every frontend (CLI, Python, C).
2253 // The SQP's own per-iteration rows stay gated on the separate
2254 // `sqp_print_level`.
2255 let console_output = match self.options.get_integer_value("print_level", "") {
2256 Ok((v, true)) => v >= 1,
2257 _ => true,
2258 };
2259 self.emit_problem_stats(&tnlp, console_output);
2260
2261 // Phase 5c (§6): consume any stashed warm-start iterate.
2262 // `optimize_with_warm_start(warm=None)` is equivalent to
2263 // `optimize`, so cold callers see no change.
2264 let warm = self.sqp_warm_start.take();
2265 let res = match alg.optimize_with_warm_start(&mut sqp_adapter, warm) {
2266 Ok(r) => r,
2267 Err(e) => {
2268 // Always surface this. It used to be gated on the
2269 // undocumented `POUNCE_DBG_SQP`, so the only thing a user saw
2270 // was a bare `Internal_Error` with no indication of what went
2271 // wrong -- the underlying message here was
2272 // `QpFailure(LinearSolverFailure("QP subproblem returned
2273 // status unbounded"))`, which points straight at the cause.
2274 // A solve that is about to fail is exactly when the reason
2275 // should be cheapest to obtain.
2276 tracing::warn!(
2277 target: "pounce::sqp",
2278 "SQP solve failed: {e:?}"
2279 );
2280 return ApplicationReturnStatus::InternalError;
2281 }
2282 };
2283 // Stash the result's working set so the next solve in a
2284 // sequence can fetch it via `last_sqp_working_set`.
2285 self.sqp_last_working_set = res.working_set.clone();
2286 // Populate the shared `SolveStatistics` so the Python /
2287 // C-API post-solve accessors (`GetIpoptIterCount`,
2288 // `info["iter_count"]`, etc.) report the SQP outer-iter
2289 // count rather than zero. Constraint-violation /
2290 // dual-infeasibility residuals get the SQP-side values
2291 // too. The IPM path overwrites this dict on its own
2292 // solves, so SQP-vs-IPM mixing across solves stays
2293 // honest.
2294 {
2295 let mut stats = self.statistics.borrow_mut();
2296 stats.iteration_count = res.n_iter as Index;
2297 // Subproblem counters. The outer iteration count alone
2298 // cannot show what a working-set warm start bought — the
2299 // saved work is inside the QPs — so both are reported.
2300 stats.sqp_qp_solves = res.n_qp_solves as Index;
2301 stats.sqp_qp_working_set_changes = res.n_qp_working_set_changes as Index;
2302 stats.final_objective = res.obj;
2303 // `final_scaled_objective` defaults to NaN; the SQP path does not
2304 // thread nlp_scaling through the objective (same as the residuals
2305 // mirrored below), so the scaled objective equals the unscaled
2306 // one. Without this it stayed NaN and the console printed
2307 // "Objective ...: nan <unscaled>" on every active-set solve
2308 // (gh #313), even on a clean optimal solve.
2309 stats.final_scaled_objective = res.obj;
2310 stats.final_dual_inf = res.final_stationarity;
2311 stats.final_constr_viol = res.final_constr_viol;
2312 stats.final_compl = 0.0; // SQP has no barrier — no compl term.
2313 // Overall KKT error. This was previously left at the struct
2314 // default, which made every successful SQP solve report an
2315 // overall error of exactly 0.0 — indistinguishable from a
2316 // genuinely perfect solve, and enough on its own to make
2317 // `pounce.minimize`'s acceptable-KKT fallback upgrade any status
2318 // on this path to `success=True`. Same expression as the unscaled
2319 // twin below; the two agree because the SQP path does not thread
2320 // nlp_scaling through its residuals.
2321 stats.final_kkt_error = res.final_stationarity.max(res.final_constr_viol);
2322 // Unscaled residuals (pounce#173). The SQP path does not thread
2323 // the nlp_scaling factors through to its residuals yet, so these
2324 // mirror the SQP-side values: correct when no scaling is active
2325 // (the common case) and a conservative proxy otherwise. Populated
2326 // here so the info dict's `final_unscaled_*` keys are honest
2327 // rather than left at the 0.0 default.
2328 stats.final_unscaled_dual_inf = res.final_stationarity;
2329 stats.final_unscaled_constr_viol = res.final_constr_viol;
2330 stats.final_unscaled_compl = 0.0;
2331 stats.final_unscaled_kkt_error = res.final_stationarity.max(res.final_constr_viol);
2332 stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
2333 }
2334 let (app_status, solver_status) = match res.status {
2335 crate::sqp::SqpStatus::Optimal => (
2336 ApplicationReturnStatus::SolveSucceeded,
2337 pounce_nlp::SolverReturn::Success,
2338 ),
2339 crate::sqp::SqpStatus::MaxIter => (
2340 ApplicationReturnStatus::MaximumIterationsExceeded,
2341 pounce_nlp::SolverReturn::MaxiterExceeded,
2342 ),
2343 crate::sqp::SqpStatus::InfeasibleSubproblem => (
2344 ApplicationReturnStatus::InfeasibleProblemDetected,
2345 pounce_nlp::SolverReturn::LocalInfeasibility,
2346 ),
2347 crate::sqp::SqpStatus::LineSearchFailed => (
2348 ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
2349 pounce_nlp::SolverReturn::ErrorInStepComputation,
2350 ),
2351 // Honest non-committal QP-subproblem failure (#282): the QP
2352 // solver could not compute a step and did NOT certify
2353 // infeasibility. Never report Infeasible_Problem_Detected here
2354 // — a feasible problem has no infeasibility certificate.
2355 crate::sqp::SqpStatus::QpStepFailed => (
2356 ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
2357 pounce_nlp::SolverReturn::ErrorInStepComputation,
2358 ),
2359 // The QP subproblem ran out of its own iteration budget. Same
2360 // #282 guarantee — no infeasibility is asserted — but reported as
2361 // the budget exhaustion it is, so the user sees a limit they can
2362 // raise (`sqp_qp_max_iter`) instead of a step-size stall with no
2363 // remedy. See `SqpStatus::QpIterationLimit` for why these were
2364 // split.
2365 crate::sqp::SqpStatus::QpIterationLimit => (
2366 ApplicationReturnStatus::MaximumIterationsExceeded,
2367 pounce_nlp::SolverReturn::MaxiterExceeded,
2368 ),
2369 // Unbounded below, with a recession ray verified against the
2370 // true NLP (gh #388). `Diverging_Iterates` is POUNCE's (Ipopt's)
2371 // unboundedness verdict and maps to AMPL `solve_result_num=300`
2372 // — the same answer the IPM selectors give on the same model,
2373 // instead of the `Internal_Error` / 500 ("the solver broke")
2374 // this path used to report.
2375 crate::sqp::SqpStatus::Unbounded => (
2376 ApplicationReturnStatus::DivergingIterates,
2377 pounce_nlp::SolverReturn::DivergingIterates,
2378 ),
2379 // A non-finite iterate or constraint value (gh #876). Same
2380 // verdict, from the same condition, as the interior-point arm's
2381 // `if !nlp_err.is_finite()` screen — the two arms must not
2382 // disagree about what a `NaN` iterate means.
2383 crate::sqp::SqpStatus::InvalidNumber => (
2384 ApplicationReturnStatus::InvalidNumberDetected,
2385 pounce_nlp::SolverReturn::InvalidNumberDetected,
2386 ),
2387 };
2388
2389 // Same gate as the IPM path: an infeasible-subproblem exit is a
2390 // numerical inference, and a feasible starting point disproves it
2391 // (gh #379). Only the infeasibility verdict is rewritten — every other
2392 // status passes through untouched, so the pair stays in lockstep.
2393 let refuted = withdraw_infeasibility_if_refuted(
2394 &tnlp,
2395 solver_status,
2396 self.nlp_lower_bound_inf(),
2397 self.nlp_upper_bound_inf(),
2398 self.user_tol(),
2399 );
2400 let (app_status, solver_status) = if refuted == solver_status {
2401 (app_status, solver_status)
2402 } else {
2403 (solver_return_to_app_status(refuted), refuted)
2404 };
2405
2406 // Forward to the user TNLP's finalize_solution. We pass
2407 // the SQP iterate and recovered multipliers via the
2408 // OrigIpoptNlp's lifting hooks. Failure here is silent
2409 // (we still return the algorithm's status) — the user
2410 // sees the right ApplicationReturnStatus regardless.
2411 let _ = finalize_via_sqp(&nlp_rc, &res, solver_status, &tnlp, &self.last_finalize);
2412
2413 // Honor the opt-in status-fidelity gate on the SQP path too
2414 // (pounce#173), then emit the end-of-run summary with the final
2415 // (possibly downgraded) status so the console matches the returned
2416 // ApplicationReturnStatus.
2417 let final_status = self.apply_kkt_fidelity_gate(app_status);
2418 self.emit_end_summary(final_status, &nlp_rc, console_output);
2419 final_status
2420 }
2421
2422 /// Opt-in status-fidelity gate (pounce#173), shared by the IPM and
2423 /// SQP solve paths. When the user sets a positive `kkt_fidelity_tol`,
2424 /// a reported `Solve_Succeeded` whose max-norm UNSCALED KKT error
2425 /// (`SolveStatistics::final_unscaled_kkt_error`) exceeds it is
2426 /// downgraded to `Solved_To_Acceptable_Level` — the honest "this is a
2427 /// point, but not converged to the requested fidelity" status. This
2428 /// catches the ill-conditioned / nlp_scaling-deflated case where the
2429 /// scaled convergence test passes but the user-space duals have
2430 /// drifted. It is a pure relabel at termination (no extra iterations);
2431 /// unset or non-positive (the default) is a strict no-op, so every
2432 /// existing caller keeps the Ipopt-faithful status.
2433 fn apply_kkt_fidelity_gate(
2434 &self,
2435 app_status: ApplicationReturnStatus,
2436 ) -> ApplicationReturnStatus {
2437 if !matches!(app_status, ApplicationReturnStatus::SolveSucceeded) {
2438 return app_status;
2439 }
2440 if let Ok((ftol, true)) = self.options.get_numeric_value("kkt_fidelity_tol", "") {
2441 if ftol > 0.0 {
2442 let unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
2443 if unscaled_kkt > ftol {
2444 tracing::info!(target: "pounce::diagnostics",
2445 "kkt_fidelity_tol={ftol:.3e}: unscaled KKT error {unscaled_kkt:.3e} \
2446 exceeds it — downgrading Solve_Succeeded → \
2447 Solved_To_Acceptable_Level (pounce#173)");
2448 return ApplicationReturnStatus::SolvedToAcceptableLevel;
2449 }
2450 }
2451 }
2452 app_status
2453 }
2454
2455 /// `nlp_lower_bound_inf` — the magnitude at or below which a bound is
2456 /// treated as absent.
2457 fn nlp_lower_bound_inf(&self) -> Number {
2458 self.options
2459 .get_numeric_value("nlp_lower_bound_inf", "")
2460 .ok()
2461 .and_then(|(v, f)| f.then_some(v))
2462 .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF)
2463 }
2464
2465 /// `nlp_upper_bound_inf` — the magnitude at or above which a bound is
2466 /// treated as absent.
2467 fn nlp_upper_bound_inf(&self) -> Number {
2468 self.options
2469 .get_numeric_value("nlp_upper_bound_inf", "")
2470 .ok()
2471 .and_then(|(v, f)| f.then_some(v))
2472 .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF)
2473 }
2474
2475 /// The user's convergence tolerance `tol`.
2476 fn user_tol(&self) -> Number {
2477 self.options
2478 .get_numeric_value("tol", "")
2479 .ok()
2480 .and_then(|(v, f)| f.then_some(v))
2481 .unwrap_or(1e-8)
2482 }
2483
2484 /// The user's `acceptable_tol` — the standard behind
2485 /// `Solved_To_Acceptable_Level`.
2486 fn user_acceptable_tol(&self) -> Number {
2487 self.options
2488 .get_numeric_value("acceptable_tol", "")
2489 .ok()
2490 .and_then(|(v, f)| f.then_some(v))
2491 .unwrap_or(1e-6)
2492 }
2493
2494 /// The user's `constr_viol_tol` — the **absolute** feasibility standard
2495 /// the strict gate's primal component judges by
2496 /// (`OptErrorConvCheck::primal_component_passes`).
2497 fn user_constr_viol_tol(&self) -> Number {
2498 self.options
2499 .get_numeric_value("constr_viol_tol", "")
2500 .ok()
2501 .and_then(|(v, f)| f.then_some(v))
2502 .unwrap_or(1e-4)
2503 }
2504
2505 /// The user's `acceptable_constr_viol_tol` — the same standard at the
2506 /// acceptable tier.
2507 fn user_acceptable_constr_viol_tol(&self) -> Number {
2508 self.options
2509 .get_numeric_value("acceptable_constr_viol_tol", "")
2510 .ok()
2511 .and_then(|(v, f)| f.then_some(v))
2512 .unwrap_or(1e-2)
2513 }
2514
2515 /// `primal_noise_floor_kappa` — the safety factor on the per-row
2516 /// floating-point noise floor (gh#528/gh#590). `0` opts out.
2517 fn user_primal_noise_floor_kappa(&self) -> Number {
2518 self.options
2519 .get_numeric_value("primal_noise_floor_kappa", "")
2520 .ok()
2521 .and_then(|(v, f)| f.then_some(v))
2522 .unwrap_or(64.0)
2523 }
2524
2525 /// Emit the Ipopt-style problem-statistics block (#206) from the
2526 /// engine's own reduced problem, gated on `console_output`
2527 /// (print_level >= 1). Shared by the IPM (`optimize_tnlp`) and SQP
2528 /// (`optimize_sqp_tnlp`) entry points so every algorithm and every
2529 /// frontend (CLI, Python, C) gets the identical block. Built from the
2530 /// same `collect_stats` inputs the CLI used, so the output is
2531 /// byte-identical to the historical CLI block.
2532 fn emit_problem_stats(&self, tnlp: &Rc<RefCell<dyn TNLP>>, console_output: bool) {
2533 if !console_output {
2534 return;
2535 }
2536 let lo_inf = self
2537 .options
2538 .get_numeric_value("nlp_lower_bound_inf", "")
2539 .ok()
2540 .and_then(|(v, f)| f.then_some(v))
2541 .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
2542 let up_inf = self
2543 .options
2544 .get_numeric_value("nlp_upper_bound_inf", "")
2545 .ok()
2546 .and_then(|(v, f)| f.then_some(v))
2547 .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
2548 let fixed_treatment = match self
2549 .options
2550 .get_string_value("fixed_variable_treatment", "")
2551 .ok()
2552 .and_then(|(v, f)| f.then_some(v))
2553 .as_deref()
2554 {
2555 Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
2556 _ => FixedVarTreatment::MakeParameter,
2557 };
2558 if let Some(stats) =
2559 pounce_solve_report::console::collect_stats(tnlp, lo_inf, up_inf, fixed_treatment)
2560 {
2561 pounce_solve_report::console::print_problem_stats(&stats);
2562 }
2563 }
2564
2565 /// Drain the NLP's per-eval counters into the shared `SolveStatistics`
2566 /// and emit the Ipopt-style end-of-run summary (#206). Shared by both
2567 /// solve paths. The counts are read from the NLP AFTER the solve (so the
2568 /// final solution evaluation is included, matching the historical count)
2569 /// and written into `SolveStatistics` so the post-solve API accessors
2570 /// (`info["n_obj_evals"]`, …) report them even when the console is
2571 /// silent. c/d (and jac_c/jac_d) are per-subsystem, so the max recovers
2572 /// the eval_g / eval_jac_g call count. The console summary itself is
2573 /// gated on `console_output` (print_level >= 1).
2574 fn emit_end_summary(
2575 &self,
2576 app_status: ApplicationReturnStatus,
2577 nlp: &Rc<RefCell<dyn IpoptNlp>>,
2578 console_output: bool,
2579 ) {
2580 {
2581 let ec = nlp.borrow().eval_counts();
2582 let mut stats = self.statistics.borrow_mut();
2583 stats.num_obj_evals = ec[0];
2584 stats.num_obj_grad_evals = ec[1];
2585 stats.num_constr_evals = ec[2].max(ec[3]);
2586 stats.num_constr_jac_evals = ec[4].max(ec[5]);
2587 stats.num_hess_evals = ec[6];
2588 }
2589 if !console_output {
2590 return;
2591 }
2592 let stats = self.statistics.borrow();
2593 let counts = pounce_solve_report::console::EvalCounts {
2594 n_obj: stats.num_obj_evals as u64,
2595 n_grad_f: stats.num_obj_grad_evals as u64,
2596 n_g: stats.num_constr_evals as u64,
2597 n_jac_g: stats.num_constr_jac_evals as u64,
2598 n_h: stats.num_hess_evals as u64,
2599 };
2600 pounce_solve_report::console::print_summary(app_status, &stats, &counts);
2601 }
2602
2603 /// Build a *copy* of the algorithm builder configured per the
2604 /// current options. The SQP path uses this so it gets a
2605 /// fresh builder without mutating the application's state.
2606 /// Read the `crossover*` option family into a [`CrossoverOptions`].
2607 fn crossover_options(&self) -> crate::crossover::CrossoverOptions {
2608 let mut o = crate::crossover::CrossoverOptions::default();
2609 if let Ok((v, true)) = self.options.get_bool_value("crossover", "") {
2610 o.enabled = v;
2611 }
2612 if let Ok((v, true)) = self.options.get_integer_value("crossover_max_iter", "") {
2613 o.max_iter = v.max(0) as u32;
2614 }
2615 if let Ok((v, true)) = self.options.get_numeric_value("crossover_mult_tol", "") {
2616 o.mult_tol = v;
2617 }
2618 if let Ok((v, true)) = self.options.get_numeric_value("crossover_primal_tol", "") {
2619 o.primal_tol = v;
2620 }
2621 o
2622 }
2623
2624 /// Post-convergence crossover (gh#612): hand the converged interior
2625 /// iterate to the active-set path so the solve ends on an exact active
2626 /// set. See [`crate::crossover`] for the algorithm.
2627 ///
2628 /// On acceptance this **replaces `data.curr`** rather than reporting
2629 /// alongside it. Everything downstream — the residual drain, the KKT
2630 /// fidelity gate, `on_converged`, `finalize_via_orig_nlp`, the end
2631 /// summary — already reads that one iterate, so replacing it is what
2632 /// makes the crossed-over point the solution instead of an annotation on
2633 /// it, and it does so without a second copy of the unscaling path.
2634 ///
2635 /// A no-op unless `crossover=yes` and the solve converged: an
2636 /// unconverged interior point is not a KKT point, so there is no active
2637 /// set at it worth identifying.
2638 fn maybe_crossover(
2639 &mut self,
2640 alg: &mut IpoptAlgorithm,
2641 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
2642 solver_status: SolverReturn,
2643 ) {
2644 self.crossover_report = None;
2645 // Cleared on every entry, alongside the report, so the flag
2646 // describes this solve and not a previous one — the same reason
2647 // `crossover_report` is reset here rather than only written on
2648 // the accept path.
2649 alg.data.borrow_mut().curr_from_crossover = false;
2650 let xopts = self.crossover_options();
2651 if !xopts.enabled {
2652 return;
2653 }
2654 if !matches!(
2655 solver_status,
2656 SolverReturn::Success | SolverReturn::StopAtAcceptablePoint
2657 ) {
2658 return;
2659 }
2660 let Some(curr) = alg.data.borrow().curr.clone() else {
2661 return;
2662 };
2663
2664 // Seed, in the algorithm's compressed / scaled space. The SQP
2665 // adapter presents that same space, so nothing is translated here
2666 // beyond repacking the bound duals: `λ_x = z_l − z_u`.
2667 let seed = crate::crossover::CrossoverSeed {
2668 x: dense_values(&*curr.x),
2669 lambda_g: {
2670 let mut v = dense_values(&*curr.y_c);
2671 v.extend(dense_values(&*curr.y_d));
2672 v
2673 },
2674 lambda_x: crate::sqp::ipopt_adapter::pack_bound_multipliers(
2675 nlp_handle,
2676 &dense_values(&*curr.z_l),
2677 &dense_values(&*curr.z_u),
2678 ),
2679 };
2680
2681 let snapshot = self.algorithm_builder_snapshot();
2682 let sqp_opts = snapshot.sqp.clone();
2683 let qp_opts = snapshot.sqp_qp.clone();
2684 // Declared bounds, not the live relaxed ones: crossover's whole claim
2685 // is that the returned point sits *on* the constraints the user
2686 // wrote. Against the `bound_relax_factor`-widened box it would pivot
2687 // to a point `1e-8` shy of every one of them and then correctly
2688 // report an empty active set.
2689 let mut adapter =
2690 crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(nlp_handle));
2691 let (report, accepted) = crate::crossover::run(
2692 &mut adapter,
2693 &seed,
2694 &xopts,
2695 &sqp_opts,
2696 &qp_opts,
2697 || {
2698 let mut f = self.make_backend_factory();
2699 // `make_backend_factory` ships the workspace-default
2700 // backend regardless of the choice passed, exactly as the
2701 // SQP path's `build_sqp_with_backend` call does; naming
2702 // `Feral` here keeps that visible rather than implicit.
2703 f(crate::alg_builder::LinearSolverChoice::Feral)
2704 },
2705 |step4_opts| {
2706 let mut b = self.algorithm_builder_snapshot();
2707 b.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
2708 b.sqp = step4_opts;
2709 b.build_sqp_with_backend(self.make_backend_factory())
2710 },
2711 );
2712
2713 if let Some(res) = accepted {
2714 self.install_crossover_iterate(alg, nlp_handle, &curr, &res);
2715 // Publish the identified set as the SQP warm-start output. This
2716 // is the IPM → SQP handoff the active-set path never had: a
2717 // sequence whose first solve wants the interior method can now
2718 // feed the next `algorithm=active-set-sqp` solve a working set.
2719 self.sqp_last_working_set = res.working_set.clone();
2720 }
2721 tracing::debug!(target: "pounce::crossover", "crossover: {report:?}");
2722 self.crossover_report = Some(report);
2723 }
2724
2725 /// Write an accepted crossover result back onto the IPM iterate.
2726 ///
2727 /// All eight components have to move together: leaving `s`, `v_l` or
2728 /// `v_u` describing the interior point while `x` and the duals describe
2729 /// the crossed-over one would make the calculated quantities read off an
2730 /// iterate that never existed, and the residuals reported to the user
2731 /// would be neither point's. The slack relations are the barrier
2732 /// problem's own — `s = d(x)` and `v_l − v_u = −y_d`.
2733 fn install_crossover_iterate(
2734 &self,
2735 alg: &mut IpoptAlgorithm,
2736 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
2737 curr: &crate::iterates_vector::IteratesVector,
2738 res: &crate::sqp::SqpResult,
2739 ) {
2740 let (m_c, m_d) = {
2741 let b = nlp_handle.borrow();
2742 (b.m_eq() as usize, b.m_ineq() as usize)
2743 };
2744 let y_c = &res.lambda_g[..m_c];
2745 let y_d = &res.lambda_g[m_c..];
2746 // `s = d(x)`: the adapter's combined constraint vector is `[c ; d]`,
2747 // so the inequality block is its tail.
2748 let mut adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(nlp_handle));
2749 let c_all = crate::sqp::SqpProblemSpec::eval_c(&mut adapter, &res.x);
2750 let s_new = &c_all[m_c..];
2751 debug_assert_eq!(s_new.len(), m_d);
2752 let (z_l, z_u) =
2753 crate::sqp::ipopt_adapter::split_bound_multipliers(nlp_handle, &res.lambda_x);
2754 let (v_l, v_u) = crate::sqp::ipopt_adapter::split_slack_multipliers(nlp_handle, y_d);
2755
2756 let mut out = curr.deep_copy();
2757 let ok = set_dense(&mut *out.x, &res.x)
2758 && set_dense(&mut *out.s, s_new)
2759 && set_dense(&mut *out.y_c, y_c)
2760 && set_dense(&mut *out.y_d, y_d)
2761 && set_dense(&mut *out.z_l, &z_l)
2762 && set_dense(&mut *out.z_u, &z_u)
2763 && set_dense(&mut *out.v_l, &v_l)
2764 && set_dense(&mut *out.v_u, &v_u);
2765 if !ok {
2766 // A non-dense backing or a length mismatch. POUNCE is dense-only,
2767 // so this is defensive — but a partially-written iterate is worse
2768 // than no crossover at all, so bail without touching `curr`.
2769 tracing::warn!(
2770 target: "pounce::crossover",
2771 "crossover result did not fit the iterate; keeping the interior point"
2772 );
2773 return;
2774 }
2775 let mut d = alg.data.borrow_mut();
2776 d.set_curr(out.freeze());
2777 // Mark which frame the installed iterate belongs to. It sits on the
2778 // *declared* bounds, and every barrier quantity built off `curr` —
2779 // slacks, and through them `Σ = z/s` — is measured against the
2780 // `bound_relax_factor`-widened ones, so a consumer that wants the
2781 // point's own geometry rather than the barrier's has to know this
2782 // happened (gh#654). Set only on the path that actually replaced
2783 // `curr`: a declined or abandoned crossover leaves the interior
2784 // iterate, which is an interior-frame point.
2785 d.curr_from_crossover = true;
2786 }
2787
2788 fn algorithm_builder_snapshot(&self) -> AlgorithmBuilder {
2789 let mut builder = AlgorithmBuilder {
2790 quality_escalation_counter: Some(Rc::clone(&self.quality_escalations)),
2791 ..AlgorithmBuilder::default()
2792 };
2793 apply_sqp_options(&self.options, &mut builder.sqp);
2794 apply_qp_subproblem_options(&self.options, &mut builder.sqp_qp);
2795 builder
2796 }
2797
2798 /// Refuse an explicitly set `ma57_pivtolmax` that sits below
2799 /// `ma57_pivtol`, at either option prefix.
2800 ///
2801 /// Upstream's `Ma57TSolverInterface::InitializeImpl` asserts
2802 /// `pivtolmax >= pivtol` and raises `OPTION_INVALID`, but only when
2803 /// the user set `ma57_pivtolmax` explicitly; left unset, the
2804 /// registered default is lifted to `ma57_pivtol` instead. Both
2805 /// halves are mirrored — the lifting in
2806 /// `pounce_hsl::ma57::Options::from_options_list`, the refusal here.
2807 ///
2808 /// Checked at **both** prefixes because the restoration sub-IPM
2809 /// configures its own MA57 backend from `"resto."`-scoped options
2810 /// (gh#825), so `resto.ma57_pivtolmax` can contradict
2811 /// `resto.ma57_pivtol` without the un-prefixed pair being wrong.
2812 ///
2813 /// Deliberately **not** gated on the `ma57` cargo feature or on
2814 /// `linear_solver` resolving to MA57. It is a consistency check on
2815 /// two numbers the user wrote, needs no HSL to perform, and a
2816 /// verdict that changed with a build flag would be worse than a
2817 /// consistent one — it would also be untestable in CI, which cannot
2818 /// link CoinHSL. Only an *explicitly set* `ma57_pivtolmax` can
2819 /// trigger it, so an options file that never mentions the option is
2820 /// unaffected.
2821 fn ma57_pivtol_bracket_refusal(&self) -> Option<String> {
2822 for prefix in ["", "resto."] {
2823 // `(_, true)` is the explicitly-set arm; an unset option
2824 // reports the registry default with `false` and is the
2825 // branch upstream lifts rather than refuses.
2826 let Ok((pivtolmax, true)) = self.options.get_numeric_value("ma57_pivtolmax", prefix)
2827 else {
2828 continue;
2829 };
2830 let pivtol = self
2831 .options
2832 .get_numeric_value("ma57_pivtol", prefix)
2833 .map(|(v, _)| v)
2834 .unwrap_or(1e-8);
2835 if pivtolmax < pivtol {
2836 return Some(format!(
2837 "pounce: {prefix}ma57_pivtolmax ({pivtolmax:e}) is below \
2838 {prefix}ma57_pivtol ({pivtol:e}). ma57_pivtolmax is the ceiling MA57 \
2839 may raise the pivot tolerance to when it escalates for accuracy, so it \
2840 cannot sit below the tolerance it starts from. Raise \
2841 {prefix}ma57_pivtolmax to at least {pivtol:e}, or lower \
2842 {prefix}ma57_pivtol."
2843 ));
2844 }
2845 }
2846 None
2847 }
2848
2849 /// Construct a LinearBackendFactory honoring the
2850 /// `linear_solver` option. Default FERAL; HSL MA57 when
2851 /// built with the `ma57` feature.
2852 fn make_backend_factory(&self) -> LinearBackendFactory {
2853 Box::new(
2854 |_choice| -> Box<dyn pounce_linsol::SparseSymLinearSolverInterface> {
2855 Box::new(pounce_feral::FeralSolverInterface::new())
2856 },
2857 )
2858 }
2859
2860 /// Phase 3.5 auto-fallback driver.
2861 ///
2862 /// Runs the standard solve (no wrapper) first. If it ends in a
2863 /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
2864 /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
2865 /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
2866 /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
2867 /// it returns `Solve_Succeeded`; otherwise returns the original
2868 /// status.
2869 ///
2870 /// Caveat: the user TNLP's `finalize_solution` runs once per
2871 /// attempt. When the retry doesn't promote, the user's captured
2872 /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
2873 /// point) even though the returned status is the original's.
2874 /// Documented on the option's help text; tightening this is a
2875 /// Phase-4 follow-up.
2876 fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2877 // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
2878 // reached for `m > 0`, so `optimize_constrained` is exact.
2879 let first_status = self.optimize_constrained(Rc::clone(&tnlp));
2880 if !is_l1_fallback_trigger(first_status) {
2881 return first_status;
2882 }
2883 // Trigger fired. Flip the wrapper option for the retry and
2884 // restore it after — keeps the user's option-table view of the
2885 // session exactly as they left it.
2886 let prev = self
2887 .options
2888 .get_string_value("l1_exact_penalty_barrier", "")
2889 .ok();
2890 let _ = self
2891 .options
2892 .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
2893 let retry_status = self
2894 .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
2895 .unwrap_or(ApplicationReturnStatus::InternalError);
2896 let _ = self.options.set_string_value(
2897 "l1_exact_penalty_barrier",
2898 prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
2899 true,
2900 false,
2901 );
2902 if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
2903 retry_status
2904 } else {
2905 first_status
2906 }
2907 }
2908
2909 /// μ-strategy auto-fallback driver (pounce#138).
2910 ///
2911 /// Runs the standard solve first. If it stalls short of optimal in a
2912 /// way a μ-strategy flip can plausibly fix — `Solved_To_Acceptable_Level`
2913 /// or `Maximum_Iterations_Exceeded`, the two signatures seen on the
2914 /// princetonlib instances where the dual infeasibility parks above
2915 /// `tol` while constraint violation and complementarity are already
2916 /// deeply converged — it flips `mu_strategy` (adaptive↔monotone) and
2917 /// solves once more. The retry's status is promoted only if it returns
2918 /// `Solve_Succeeded`; otherwise the original status is returned.
2919 ///
2920 /// (maxcut/price stall at acceptable-level under adaptive; fermat2_vareps
2921 /// stalls at `max_iter` — hence both triggers. flosp2tm is μ-independent
2922 /// and correctly does not promote.)
2923 ///
2924 /// The flip direction is taken from the strategy the option table
2925 /// actually resolves to (`effective_mu_strategy_is_adaptive`):
2926 /// `adaptive` → `monotone`, otherwise → `adaptive`. Absence is not
2927 /// the same as `monotone` — under a limited-memory Hessian an unset
2928 /// `mu_strategy` resolves to `adaptive` (gh#746), and flipping the
2929 /// *registered* default there would re-run the strategy that just
2930 /// stalled. The option table is restored to the resolved view
2931 /// afterward.
2932 ///
2933 /// Caveat (shared with the ℓ₁ fallback): the user TNLP's
2934 /// `finalize_solution` runs once per attempt, so when the retry
2935 /// doesn't promote the captured fields hold the retry's iterate.
2936 fn run_with_mu_strategy_fallback(
2937 &mut self,
2938 tnlp: Rc<RefCell<dyn TNLP>>,
2939 ) -> ApplicationReturnStatus {
2940 let first_status = self.optimize_constrained(Rc::clone(&tnlp));
2941 // Which statuses are worth a second solve depends on who asked
2942 // for the retry (pounce#748).
2943 //
2944 // An explicit `mu_strategy_fallback=yes` keeps the historical
2945 // pair: the caller opted in and can afford the second solve.
2946 //
2947 // The *default*-on retry takes `Maximum_Iterations_Exceeded`
2948 // unconditionally, and `Solved_To_Acceptable_Level` only when
2949 // the caller left the convergence configuration alone
2950 // (gh #757). pounce#748 refused the latter status outright, for
2951 // three reasons; two of them are properties of a *caller-
2952 // modified* configuration, not of the status. It launders
2953 // downgrades the caller induced deliberately -- a tight
2954 // `kkt_fidelity_tol`, a certificate veto, `least_square_init_
2955 // primal` -- so the signal the option exists to produce never
2956 // reaches them. And because the retry returns the other run's
2957 // *point*, not just its status, it can hand back a different
2958 // local solution: on `autocorr_bern55-06` with the
2959 // dual-divergence guard on it swaps -2304.0000278 for
2960 // -2320.0000298 (crates/pounce-cli/tests/
2961 // issue_250_dual_guard_never_worse.rs). Both cases -- and all
2962 // five test targets the wide trigger broke -- arm a
2963 // non-default option from `TERMINATION_POLICY_OPTIONS`, so
2964 // deferring to that set preserves every one of them while
2965 // leaving a stock-options stall retryable. The third reason,
2966 // cost, stands and is the price: one extra solve on a run that
2967 // reached only the acceptable tolerance, paid to try for the
2968 // certificate. `cho_parmest` is the motivating case -- monotone
2969 // parks `inf_du` on a ~1e-6 evaluation-noise floor and misses
2970 // `tol` by 5%, taking six null steps of 1e-12 at `mu_min`,
2971 // while adaptive certifies it in 20 iterations.
2972 //
2973 // `dirichlet120`, the case that motivated turning the retry on,
2974 // stalls at `Maximum_Iterations_Exceeded`, so it is recovered
2975 // either way.
2976 let retry_worthy = match first_status {
2977 ApplicationReturnStatus::MaximumIterationsExceeded => true,
2978 ApplicationReturnStatus::SolvedToAcceptableLevel => {
2979 self.mu_strategy_fallback_was_set() || !self.caller_set_termination_policy()
2980 }
2981 _ => false,
2982 };
2983 if !retry_worthy {
2984 return first_status;
2985 }
2986 // gh#857: decline the flip when the solve that just failed escalated
2987 // the factorization and an escalation-off re-solve is enabled.
2988 //
2989 // The mu flip is a *blind* second opinion: it changes the barrier
2990 // schedule and hopes. A `feral_increase_quality` escalation is a
2991 // *measured* fact about the run that just failed -- FERAL reroutes
2992 // which pivots are taken and never steps back down, so every
2993 // iteration after the first escalation, restoration sub-solves
2994 // included, ran on a trajectory the defaults do not describe. Flipping
2995 // `mu_strategy` while leaving that in place is not a controlled
2996 // experiment: it varies the knob that is not implicated and holds the
2997 // one that is.
2998 //
2999 // It is not free either. On `square_flowsheet_resto`'s lbfgs leg the
3000 // flip escalates 25 times all over again, burns a second full
3001 // 3000-iteration budget, and ends no better than the first -- after
3002 // which rung 4 of the second-opinion ladder converges the model in 178
3003 // with the escalation off. That is 6178 real iterations to reach an
3004 // answer that 3178 reach without the flip, and the flip contributes
3005 // nothing to it. Measured both ways: `mu_strategy=adaptive` alone
3006 // still gives 3000 with 25 escalations, and `feral_increase_quality=no`
3007 // gives 178 under *either* mu strategy.
3008 //
3009 // Why decline rather than fold the escalation off into this retry: the
3010 // backend factory is minted from an options snapshot taken by the
3011 // caller *before* `solve()`, so writing `feral_increase_quality` here
3012 // is too late to reach the retry's linear solver. `mu_strategy` is read
3013 // per-solve from the option table and is not; that asymmetry is why
3014 // this layer can only choose whether to spend the solve, not what to
3015 // spend it on.
3016 //
3017 // Restricted to `Maximum_Iterations_Exceeded`, which is exactly the
3018 // status rung 4 opens on. A `Solved_To_Acceptable_Level` exit opens no
3019 // escalation rung, so declining there would drop a retry with nothing
3020 // in its place. Gated on `feral_increase_quality_retry`, so setting
3021 // that option to `no` restores the historical behaviour on both sides
3022 // at once: no rung 4, and no decline here.
3023 //
3024 // The one place the stand-down is not paired with the rung is the
3025 // multi-start paths (`solve_nlp_batch`, the CLI's `minima` search),
3026 // which deliberately do not drive the ladder -- a failed start there is
3027 // routine and extra solves per start multiply. Those paths lose the
3028 // flip on an escalating budget exit and gain nothing back, which is the
3029 // one behaviour change here that is not a strict improvement. It is the
3030 // same trade they already take on every other rung, and for the same
3031 // reason: an escalating capped start is one of many, and doubling its
3032 // cost to re-run the trajectory the escalation governs is the worse end
3033 // of it.
3034 if matches!(
3035 first_status,
3036 ApplicationReturnStatus::MaximumIterationsExceeded
3037 ) && self.quality_escalations.get() >= 1
3038 && self
3039 .options
3040 .get_bool_value("feral_increase_quality_retry", "")
3041 .map(|(v, _found)| v)
3042 .unwrap_or(true)
3043 && self
3044 .options
3045 .get_bool_value("feral_increase_quality", "")
3046 .map(|(v, _found)| v)
3047 .unwrap_or(true)
3048 {
3049 return first_status;
3050 }
3051 // Flip the strategy for one retry. The parser maps "adaptive" →
3052 // Adaptive and every other value (incl. unset) → Monotone, so the
3053 // opposite of an explicit "adaptive" is "monotone" and the
3054 // opposite of anything else is "adaptive".
3055 let prev = self.options.get_string_value("mu_strategy", "").ok();
3056 let was_adaptive = self.effective_mu_strategy_is_adaptive();
3057 let flipped = if was_adaptive { "monotone" } else { "adaptive" };
3058 let _ = self
3059 .options
3060 .set_string_value("mu_strategy", flipped, true, false);
3061 // Floor the *answer*, not just the status (pounce#870).
3062 //
3063 // The promote-only-on-`Solve_Succeeded` rule below has always floored
3064 // the status. It did not floor the point, and the point is what the
3065 // caller consumes: `optimize_constrained` calls the user TNLP's
3066 // `finalize_solution` once per attempt, so a retry that fails to
3067 // promote still overwrites the answer with its own iterate, and the
3068 // statistics with its own residuals. The result is a status describing
3069 // one attempt attached to a point from another.
3070 //
3071 // Measured on a random corpus of 1200 nonconvex models, 20 of them
3072 // (1.7%) returned a materially worse point under an unchanged status,
3073 // the worst flipping sign: a `Maximum_Iterations_Exceeded` exit went
3074 // from -2.38e7 to +7.89e7, and a `Solved_To_Acceptable_Level` one from
3075 // -3.83e7 to +3.41e5 while its reported `final_kkt_error` rose to
3076 // 2.85e-4 — 285x the `acceptable_tol` its own status names, so the
3077 // report contradicted itself. The known example on record understated
3078 // it by three orders: `autocorr_bern55-06` swapping -2304.0000278 for
3079 // -2320.0000298 is the same defect at 0.07%.
3080 //
3081 // This is the floor idiom the rest of the codebase already uses for a
3082 // bet it might lose — `honour_neg_curv_floor` (gh#797),
3083 // `honour_decline_floor` (gh#534), `honour_best_acceptable_after_dual_
3084 // guard` — applied to the one bet that was only half-floored.
3085 //
3086 // Not extended to `run_with_l1_fallback`, which carries the same
3087 // caveat in its doc comment but is NOT the same call: its retry
3088 // deliberately reports the l1-best least-infeasible point, which the
3089 // option help calls informative in its own right. Changing that needs
3090 // its own measurement.
3091 let solution_floor = self.last_finalize.borrow().clone();
3092 let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
3093 let trace_floor = *self.last_iter_stats.borrow();
3094 let retry_status = self.optimize_constrained(Rc::clone(&tnlp));
3095 // Restore the user's original option-table view.
3096 let _ = self.options.set_string_value(
3097 "mu_strategy",
3098 prev.as_ref()
3099 .filter(|(_, found)| *found)
3100 .map(|(v, _)| v.as_str())
3101 .unwrap_or(if was_adaptive { "adaptive" } else { "monotone" }),
3102 true,
3103 false,
3104 );
3105 if matches!(retry_status, ApplicationReturnStatus::SolveSucceeded) {
3106 return retry_status;
3107 }
3108 // The bet lost. Put the first attempt's answer back, so the point and
3109 // the statistics describe the same solve the returned status does.
3110 //
3111 // `finalize_solution` therefore runs once more than the number of
3112 // attempts on this path. That is deliberate, and is the cheaper of the
3113 // two corrections: withholding the retry's `finalize_solution` until it
3114 // is known to promote would deprive a caller that watches the callback
3115 // of the retry's progress, and buys nothing, since the retry's payload
3116 // is discarded either way.
3117 if let Some(floor) = solution_floor {
3118 tracing::debug!(target: "pounce::algorithm",
3119 "[POUNCE] the mu_strategy_fallback retry did not promote \
3120 ({:?} is not Solve_Succeeded); restoring the first attempt's \
3121 solution and statistics alongside its status (pounce#870).",
3122 retry_status);
3123 floor.replay(&tnlp);
3124 self.answer_restored_from_floor.set(true);
3125 certificate_floor.restore_into(&mut self.statistics.borrow_mut());
3126 // Third sink: the per-iteration trace. Consumers accumulate that
3127 // themselves from `intermediate_callback` — the CasADi plugin
3128 // pushes into its own vectors and clears once per `nlpsol` call —
3129 // so POUNCE cannot rewind it, and both attempts concatenate into
3130 // one trace. Restoring the certificate without touching the trace
3131 // leaves the reported numbers describing attempt 1 while the trace
3132 // ends on the retry, which breaks the invariant
3133 // `casadi/test_parity.py` states outright: "The final numbers and
3134 // the end of the trace are the same quantities, and must not come
3135 // from two different places."
3136 //
3137 // Re-emitting the winning attempt's final row restores it. It is
3138 // the trace analogue of `FinalizeSnapshot::replay` above, and it
3139 // makes the property hold by construction rather than by hoping a
3140 // consumer resets on an attempt boundary — nothing in the callback
3141 // contract marks one, and gh#634 is what happens when a consumer
3142 // has to guess the scope of a trace.
3143 //
3144 // The row is a real iterate that was already sent once, not a
3145 // synthesized one, so a trace still contains only points the solver
3146 // actually visited.
3147 if let Some(stats) = trace_floor {
3148 let _ = tnlp.borrow_mut().intermediate_callback(
3149 stats,
3150 &TnlpIpoptData::default(),
3151 &TnlpIpoptCq::default(),
3152 );
3153 }
3154 }
3155 first_status
3156 }
3157
3158 /// Is the gh#884 biactive dual-divergence retry enabled? Default
3159 /// `yes`; `dual_divergence_retry=no` is the kill switch, and
3160 /// restores the pre-gh#884 behaviour outright (no detector cost, no
3161 /// second solve, and the base attempt's verdict returned unchanged).
3162 fn is_dual_divergence_retry_enabled(&self) -> bool {
3163 self.options
3164 .get_bool_value("dual_divergence_retry", "")
3165 .map(|(v, _found)| v)
3166 .unwrap_or(true)
3167 }
3168
3169 /// gh#884: throw the iterate away and solve again from scratch with
3170 /// `perturb_always_cd` on, when the base solve settled its primal
3171 /// while its multipliers ran away.
3172 ///
3173 /// # The defect
3174 ///
3175 /// On an MPCC lowered through an exact complementarity product
3176 /// `G·H = 0`, a pair that is **biactive** at the solution — both
3177 /// `G` and `H` zero — leaves that row's gradient
3178 /// `H∇G + G∇H` identically zero. The row is still there, so its
3179 /// multiplier is *arbitrary* rather than nonexistent, and the IPM
3180 /// drives it to infinity while the primal iterate sits on the
3181 /// answer. Because the convergence verdict is reached on an
3182 /// `s_d`-normalised aggregate and `s_d` grows with the mean
3183 /// multiplier magnitude, the aggregate reads clean: MacMPEC's
3184 /// `qpec_small` under `ncp_eq`/`prod_eq` reported
3185 /// `Solved_To_Acceptable_Level` at an *unscaled* dual infeasibility
3186 /// of `7.9e+04`.
3187 ///
3188 /// # Why a retry rather than a gate
3189 ///
3190 /// Four other shapes of fix were measured and rejected — a Hessian
3191 /// sparsity hypothesis, engaging `delta_c` *in flight*, flipping
3192 /// `perturb_always_cd` on globally, and putting a dual ceiling on
3193 /// the acceptable-level gate. `dev-notes/mpcc-biactive-dual-
3194 /// divergence.md` records all four with numbers. The short version:
3195 /// by the time the runaway is visible this iterate is unrecoverable,
3196 /// so the only action left is to *stop using it*; and the remedy
3197 /// that works — `perturb_always_cd=yes` — is measured to return a
3198 /// wrong answer reported as success on `ralph1`
3199 /// (`Solve_Succeeded` at `f = -2.71e-5`, below `f* = 0`), so it
3200 /// cannot be turned on for everyone.
3201 ///
3202 /// **The detector is therefore the entire safety barrier**, and the
3203 /// promotion gate below is the second one. `ralph1` is exactly the
3204 /// model the detector must not fire on, and
3205 /// `IpoptAlgorithm`'s scale-relative step floor is what keeps it
3206 /// from doing so: `qpec_small` settles to `4.3e-8` while `ralph1`
3207 /// bottoms out at `7.2e-3`, five orders apart.
3208 ///
3209 /// # The gate
3210 ///
3211 /// The retry's verdict replaces the base one only when **all** of:
3212 ///
3213 /// 1. the base attempt saw the signature (a converged primal, a step
3214 /// at zero, and an unscaled `‖∇L‖∞` far above `dual_inf_tol`, all
3215 /// at one iterate — see `IpoptAlgorithm::dual_divergence_signature`);
3216 /// 2. the base status is `Solved_To_Acceptable_Level` or
3217 /// `Restoration_Failed` — the two verdicts the vanishing-gradient
3218 /// row produces directly. Generic exhaustion exits are excluded;
3219 /// `deb7` under L-BFGS is why, and the reason is in the code
3220 /// below;
3221 /// 3. the retry returns `Solve_Succeeded`;
3222 /// 4. the retry's claimed success is **real in the model's own
3223 /// units** — unscaled KKT error at or below `tol`-scale, which is
3224 /// the property the base attempt failed and the whole point of
3225 /// the issue;
3226 /// 5. the retry's unscaled KKT error is *strictly better* than the
3227 /// base attempt's.
3228 ///
3229 /// Otherwise the base attempt's status, point and statistics are all
3230 /// put back, by the same three-sink floor
3231 /// [`Self::run_with_mu_strategy_fallback`] uses and for the same
3232 /// reason (pounce#870): a status describing one attempt attached to
3233 /// a point from another is worse than either.
3234 fn run_with_dual_divergence_retry(
3235 &mut self,
3236 tnlp: Rc<RefCell<dyn TNLP>>,
3237 ) -> ApplicationReturnStatus {
3238 let first_status = self.dispatch_standard_solve(Rc::clone(&tnlp));
3239 if !self.dual_divergence_signature.get() {
3240 return first_status;
3241 }
3242 // Which base verdicts this remedy is *for*.
3243 //
3244 // Two, and the narrowing was bought with a measurement. The
3245 // detector is a statement about the iterate; it is not a
3246 // statement that `perturb_always_cd` will help. `deb7` under
3247 // L-BFGS is the corpus case that separates the two: the
3248 // signature is real there — iteration 346, scale-relative step
3249 // `6.5e-6`, `inf_pr` `3.0e-12`, unscaled `inf_du` `9.2e5`,
3250 // which is an order *above* the gh#884 reproducer's `7.9e4`, so
3251 // no dual floor excludes it, and the step conjunct separates the
3252 // two only by fitting the default onto one fixture and spending
3253 // the margin that holds `ralph1` out — and the retry still does
3254 // not work: 3000 iterations to
3255 // `Maximum_Iterations_Exceeded` at an unscaled KKT error of
3256 // `6.7e1` against the base attempt's `9.9e1`. Pure cost, 4x the
3257 // base trajectory, on a fixture whose verdict does not move.
3258 //
3259 // The exclusion is by *status*, so it is only as complete as the
3260 // status is stable, and on this very fixture it is not. `deb7`
3261 // reaches `Error_In_Step_Computation` at default options and is
3262 // out; under `limited_memory_ls_failure_restarts=1` (gh#818's
3263 // rung, off by default) it reaches `Restoration_Failed` instead
3264 // and is therefore *in*. It used to pay exactly the cost above
3265 // there — 6.1 s to 25.2 s wall clock for the same
3266 // `Restoration_Failed` verdict and a declined retry, which is
3267 // gh#887. That is the price of scoping by status rather than by
3268 // model, and it is why the second gate below scopes by the
3269 // *answer* instead: `deb7` now declines before spending
3270 // anything. The status scope is kept because it is cheap and
3271 // reads on the verdict a caller sees, but it is not what is
3272 // being relied on.
3273 //
3274 // What separates them is the *status*, and it separates them
3275 // for a reason rather than by luck:
3276 //
3277 // * `Solved_To_Acceptable_Level` is gh#884 verbatim — the
3278 // runaway laundered itself through `s_d` into a success.
3279 // * `Restoration_Failed` is the same defect one step earlier:
3280 // the rows whose gradients vanished drive the solve into
3281 // restoration, and restoration cannot repair a row that has
3282 // no gradient. It is where the `qpec_small` TNLP fixture
3283 // lands (unscaled KKT `3.3e11`).
3284 // * `Error_In_Step_Computation` and
3285 // `Maximum_Iterations_Exceeded` are generic exhaustion
3286 // exits. Every hard model reaches them, for every reason.
3287 // Retrying *those* is not "repair the runaway", it is "try
3288 // again harder" — which is what `mu_strategy_fallback` and
3289 // the second-opinion ladder already are, and `deb7` is what
3290 // that costs.
3291 //
3292 // `Solve_Succeeded` is excluded because it is already the best
3293 // verdict available and its certificate has already been
3294 // checked in the model's own units; there is nothing to buy.
3295 //
3296 // Worst-case cost is therefore one extra solve under the
3297 // caller's own `max_iter` — the same contract
3298 // `run_with_mu_strategy_fallback` already has.
3299 //
3300 // Deliberately **not** deferred to `TERMINATION_POLICY_OPTIONS`
3301 // the way the μ flip's acceptable-level trigger is (gh#757).
3302 // That deferral exists because the μ flip returns a different
3303 // *local solution*, so laundering a caller's deliberate
3304 // downgrade loses a signal they asked for. This retry cannot:
3305 // conjunct 4 requires the promoted answer to satisfy the KKT
3306 // conditions in the model's own units, which a downgrade the
3307 // caller induced on purpose does not. And the gh#884 reproducer
3308 // sets `tol=1e-8` explicitly, so deferring would decline the
3309 // retry on the one case the issue is about.
3310 let retry_worthy = matches!(
3311 first_status,
3312 ApplicationReturnStatus::SolvedToAcceptableLevel
3313 | ApplicationReturnStatus::RestorationFailed
3314 );
3315 if !retry_worthy {
3316 return first_status;
3317 }
3318 let base_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
3319 // The runaway has to be the *whole* residual of the answer being
3320 // reported.
3321 //
3322 // The detector is a statement about an *iterate*, and the iterate
3323 // it fires on need not be the one the solve ends at. A run can
3324 // pass through a settled point with a diverged multiplier, work
3325 // its way back down, and report something ordinary — and then
3326 // there is nothing left here for `perturb_always_cd` to repair,
3327 // whatever the trajectory did in the middle. This is what makes
3328 // "one extra solve" a cost the caller only pays on a run that
3329 // still *exhibits* the defect (gh#887).
3330 //
3331 // The test is `runaway_is_the_whole_residual`, which reads only
3332 // the reported answer and only as a ratio within it; its doc
3333 // comment carries the rule and the measured populations.
3334 let (base_viol, base_compl) = {
3335 let st = self.statistics.borrow();
3336 (st.final_unscaled_constr_viol, st.final_unscaled_compl)
3337 };
3338 let base_dual_inf = self.statistics.borrow().final_unscaled_dual_inf;
3339 if !runaway_is_the_whole_residual(
3340 base_dual_inf,
3341 base_viol,
3342 base_compl,
3343 self.options
3344 .get_numeric_value("dual_divergence_retry_du_floor", "")
3345 .map(|(v, _)| v)
3346 .unwrap_or(DUAL_DIV_RETRY_DU_FLOOR),
3347 ) {
3348 tracing::debug!(target: "pounce::algorithm",
3349 "[POUNCE] gh#884: the signature fired mid-trajectory, but the \
3350 answer being reported is not a converged point with a runaway \
3351 multiplier — unscaled dual {:.3e} against viol {:.3e} and \
3352 complementarity {:.3e}. Nothing here for perturb_always_cd to \
3353 repair, so no retry (gh#887).",
3354 base_dual_inf, base_viol, base_compl);
3355 return first_status;
3356 }
3357 // Floor all three sinks — solution payload, certificate, and the
3358 // last trace row — exactly as the μ fallback does (pounce#870).
3359 let solution_floor = self.last_finalize.borrow().clone();
3360 let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
3361 let trace_floor = *self.last_iter_stats.borrow();
3362 tracing::debug!(target: "pounce::algorithm",
3363 "[POUNCE] gh#884: the primal settled while the multipliers ran away \
3364 (base {:?}, unscaled KKT {:.3e}); re-solving from scratch with \
3365 perturb_always_cd=yes.",
3366 first_status, base_unscaled_kkt);
3367 let prev = self.options.get_string_value("perturb_always_cd", "").ok();
3368 let _ = self
3369 .options
3370 .set_string_value("perturb_always_cd", "yes", true, false);
3371 let retry_status = self.dispatch_standard_solve(Rc::clone(&tnlp));
3372 // Restore the caller's option-table view. Absence is restored as
3373 // absence would be seen: the registered default is `no`.
3374 let _ = self.options.set_string_value(
3375 "perturb_always_cd",
3376 prev.as_ref()
3377 .filter(|(_, found)| *found)
3378 .map(|(v, _)| v.as_str())
3379 .unwrap_or("no"),
3380 true,
3381 false,
3382 );
3383 let retry_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
3384 let retry_viol = self.statistics.borrow().final_unscaled_constr_viol;
3385 // Conjuncts 3, 4 and 5. Conjunct 4 is the one that distinguishes
3386 // this gate from every other promote-on-`Solve_Succeeded` retry
3387 // in this file: the base attempt's defect *was* a status that its
3388 // own unscaled residual contradicts, so promoting on the status
3389 // alone would reproduce the bug one attempt later.
3390 let claimed_success_is_real = retry_unscaled_kkt <= self.dual_divergence_retry_accept_tol()
3391 && retry_viol <= self.dual_divergence_retry_accept_tol();
3392 // Conjuncts 6 and 7 — see `retry_answer_is_admissible`. Everything
3393 // above this line ranks the two attempts on their *certificates*;
3394 // this ranks them as *answers*, which is what a caller receives.
3395 // Without it a better multiplier is allowed to buy a worse point:
3396 // measured, `-13.0057 -> -1.2072` on a random QPEC, and
3397 // `+1.82e-09 -> -6.61e-05` on `scholtes4`, whose `f*` is exactly 0.
3398 let retry_obj = self.statistics.borrow().final_objective;
3399 // `obj_scaling_factor < 0` is how a maximization is posed, and
3400 // `final_objective` is the user's signed objective, so the
3401 // comparison direction has to follow it (R2).
3402 let sense = if self
3403 .options
3404 .get_numeric_value("obj_scaling_factor", "")
3405 .map(|(v, _)| v)
3406 .unwrap_or(1.0)
3407 < 0.0
3408 {
3409 -1.0
3410 } else {
3411 1.0
3412 };
3413 let answer_is_admissible = retry_answer_is_admissible(
3414 certificate_floor.objective,
3415 certificate_floor.unscaled_constr_viol,
3416 retry_obj,
3417 retry_viol,
3418 self.dual_divergence_retry_accept_tol(),
3419 sense,
3420 );
3421 let promote = matches!(retry_status, ApplicationReturnStatus::SolveSucceeded)
3422 && claimed_success_is_real
3423 && retry_unscaled_kkt < base_unscaled_kkt
3424 && answer_is_admissible;
3425 // Say on the console which of the two answers shipped.
3426 //
3427 // The per-attempt end summary cannot: `emit_end_summary` runs
3428 // inside `optimize_constrained`, once per attempt, and the
3429 // promotion is decided after the last of them. So a summary that
3430 // reported the promotion would report `false` on the very run that
3431 // promotes, contradicting the JSON report written from the same
3432 // statistics. The summary reports the *signature*, which is true
3433 // when it prints; this line reports the *outcome*, printed once,
3434 // where it is known.
3435 //
3436 // Gated on `print_level >= 1`, matching `emit_end_summary` — the
3437 // block this line follows.
3438 let console_output = match self.options.get_integer_value("print_level", "") {
3439 Ok((v, true)) => v >= 1,
3440 _ => true,
3441 };
3442 if console_output {
3443 println!();
3444 if promote {
3445 println!(
3446 "gh#884 dual-divergence retry: promoted — unscaled KKT error \
3447 {base_unscaled_kkt:.4e} -> {retry_unscaled_kkt:.4e}."
3448 );
3449 } else if !answer_is_admissible {
3450 // A distinct line, because this decline looks like a
3451 // contradiction otherwise: the retry converged, its
3452 // certificate is clean, and it was still refused. Say
3453 // which of the two rules refused it and on what numbers,
3454 // so the reader is not left comparing KKT errors that
3455 // had nothing to do with it.
3456 println!(
3457 "gh#884 dual-divergence retry: declined on the ANSWER, not the \
3458 certificate — the retry converged (unscaled KKT error \
3459 {retry_unscaled_kkt:.4e} against the base attempt's \
3460 {base_unscaled_kkt:.4e}) but its objective {retry_obj:.8e} at \
3461 constraint violation {retry_viol:.4e} is not admissible next to \
3462 the base attempt's {:.8e} at {:.4e}; the base attempt's answer \
3463 is the one reported.",
3464 certificate_floor.objective, certificate_floor.unscaled_constr_viol
3465 );
3466 } else {
3467 println!(
3468 "gh#884 dual-divergence retry: declined ({retry_status:?}, \
3469 unscaled KKT error {retry_unscaled_kkt:.4e} against the base \
3470 attempt's {base_unscaled_kkt:.4e}); the base attempt's answer \
3471 is the one reported."
3472 );
3473 }
3474 }
3475 if promote {
3476 self.dual_divergence_retry_promoted.set(true);
3477 self.statistics.borrow_mut().dual_divergence_retry_promoted = true;
3478 tracing::debug!(target: "pounce::algorithm",
3479 "[POUNCE] gh#884: the retry promoted — unscaled KKT {:.3e} \
3480 (base {:.3e}).",
3481 retry_unscaled_kkt, base_unscaled_kkt);
3482 return retry_status;
3483 }
3484 if let Some(floor) = solution_floor {
3485 tracing::debug!(target: "pounce::algorithm",
3486 "[POUNCE] gh#884: the retry did not promote ({:?}, unscaled KKT \
3487 {:.3e} vs base {:.3e}); restoring the first attempt's solution \
3488 and statistics alongside its status.",
3489 retry_status, retry_unscaled_kkt, base_unscaled_kkt);
3490 floor.replay(&tnlp);
3491 self.answer_restored_from_floor.set(true);
3492 certificate_floor.restore_into(&mut self.statistics.borrow_mut());
3493 // The signature belongs to the attempt whose numbers are now
3494 // reported, and `certificate_floor` does not carry it.
3495 self.statistics.borrow_mut().dual_divergence_signature = true;
3496 if let Some(stats) = trace_floor {
3497 let _ = tnlp.borrow_mut().intermediate_callback(
3498 stats,
3499 &TnlpIpoptData::default(),
3500 &TnlpIpoptCq::default(),
3501 );
3502 }
3503 }
3504 first_status
3505 }
3506
3507 /// The tolerance conjunct 4 of the dual-divergence promotion gate
3508 /// tests the retry's *unscaled* residuals against.
3509 ///
3510 /// `acceptable_tol`-scale rather than `tol`-scale on purpose: the
3511 /// unscaled residual is the one quantity nothing in the solve is
3512 /// driven against, so holding it to `tol` would decline honest
3513 /// retries on badly scaled models for a reason that has nothing to
3514 /// do with gh#884. `qpec_small`'s honest retry reaches `9.96e-8`,
3515 /// four orders inside it.
3516 fn dual_divergence_retry_accept_tol(&self) -> Number {
3517 self.options
3518 .get_numeric_value("acceptable_tol", "")
3519 .ok()
3520 .and_then(|(v, f)| f.then_some(v))
3521 .unwrap_or(1e-6)
3522 }
3523
3524 /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
3525 ///
3526 /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
3527 /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
3528 /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
3529 /// - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
3530 /// - inner solve returns non-Optimal (escalation won't fix
3531 /// numerical / restoration failure at this ρ)
3532 /// - ρ already at `l1_penalty_max`
3533 /// - `l1_penalty_max_outer_iter` reached
3534 ///
3535 /// After the loop, if the inner status is `SolveSucceeded` or
3536 /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
3537 /// to `Infeasible_Problem_Detected` — the returned point is the
3538 /// ℓ₁-best least-infeasible iterate, which is informative even
3539 /// though the original constraints are not satisfied.
3540 ///
3541 /// Returns `Some(status)` if the wrapper ran the solve, `None` if
3542 /// wrapper construction failed (caller should fall through to the
3543 /// standard dispatch path).
3544 fn run_l1_penalty_outer_loop(
3545 &mut self,
3546 tnlp: Rc<RefCell<dyn TNLP>>,
3547 ) -> Option<ApplicationReturnStatus> {
3548 let rho_init = self.l1_penalty_init();
3549 let rho_max = self.l1_penalty_max().max(rho_init);
3550 let factor = self.l1_penalty_increase_factor().max(1.0);
3551 let tau = self.l1_steering_factor();
3552 let slack_tol = self.l1_slack_tol();
3553 let max_outer = self.l1_penalty_max_outer_iter().max(1);
3554
3555 let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
3556 if wrapper.m_eq() == 0 {
3557 // Nothing to slack — let the standard dispatch path handle
3558 // this TNLP unmodified.
3559 return None;
3560 }
3561 wrapper.set_defer_inner_finalize(true);
3562 let wrapper_rc = Rc::new(RefCell::new(wrapper));
3563
3564 let mut rho = rho_init;
3565 let mut last_status = ApplicationReturnStatus::InternalError;
3566 for _outer in 0..max_outer {
3567 wrapper_rc.borrow_mut().set_rho(rho);
3568 let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
3569 last_status = self.optimize_constrained(dyn_tnlp);
3570
3571 let w = wrapper_rc.borrow();
3572 if !w.has_solution() {
3573 // Inner solve aborted before producing an iterate.
3574 drop(w);
3575 break;
3576 }
3577 let slack_sum = w.last_slack_sum();
3578 let y_eq_inf = w.last_y_eq_inf_norm();
3579 let x_here: Vec<Number> = w.last_x_trunc().to_vec();
3580 drop(w);
3581
3582 // Termination decisions.
3583 let inner_ok = matches!(
3584 last_status,
3585 ApplicationReturnStatus::SolveSucceeded
3586 | ApplicationReturnStatus::SolvedToAcceptableLevel
3587 );
3588 if !inner_ok {
3589 break;
3590 }
3591 // Stop escalating ρ once the **user's** constraints are
3592 // satisfied to the tolerance the caller asked for — not once
3593 // `Σ(p + n)` falls under `l1_slack_tol`, which is a different
3594 // quantity judged by a different number (gh#794 P1). The
3595 // slack sum stays the BNW steering signal below, which is
3596 // the job it is right for. One extra `eval_g` per outer
3597 // iteration buys the difference between stopping at the
3598 // penalty solution and stopping at the model's own.
3599 let feasible_here = {
3600 let m_inner = tnlp
3601 .borrow_mut()
3602 .get_nlp_info()
3603 .map(|i| i.m.max(0) as usize)
3604 .unwrap_or(0);
3605 let mut g_here = vec![0.0; m_inner];
3606 let evaluated =
3607 m_inner == 0 || tnlp.borrow_mut().eval_g(&x_here, true, &mut g_here);
3608 evaluated
3609 .then(|| {
3610 original_space_feasibility(
3611 &tnlp,
3612 &x_here,
3613 &g_here,
3614 self.nlp_lower_bound_inf(),
3615 self.nlp_upper_bound_inf(),
3616 self.user_tol(),
3617 self.user_acceptable_tol(),
3618 self.user_constr_viol_tol(),
3619 self.user_acceptable_constr_viol_tol(),
3620 self.user_primal_noise_floor_kappa(),
3621 )
3622 })
3623 .flatten()
3624 .map(|f| f.negligible_at_tol)
3625 };
3626 match feasible_here {
3627 Some(true) => break,
3628 Some(false) => {}
3629 // Unmeasurable model: fall back to the historical
3630 // slack-sum test rather than looping to the cap.
3631 None if slack_sum.is_finite() && slack_sum <= slack_tol => break,
3632 None => {}
3633 }
3634 if rho >= rho_max {
3635 break;
3636 }
3637 // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
3638 let geom = rho * factor;
3639 let steer = tau * y_eq_inf + 1.0e-12;
3640 rho = geom.max(steer).min(rho_max);
3641 }
3642
3643 // Forward to the user's inner.finalize_solution exactly once.
3644 let w = wrapper_rc.borrow();
3645 if w.has_solution() {
3646 let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
3647 let lambda: Vec<Number> = w.last_lambda().to_vec();
3648 let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
3649 let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
3650 let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
3651 let slack_sum = w.last_slack_sum();
3652 drop(w);
3653
3654 // Recompute f(x*) and c(x*) on the inner. Both are needed
3655 // before the status is decided, because the status now turns
3656 // on the *original-space* feasibility at this point.
3657 let f_inner = tnlp
3658 .borrow_mut()
3659 .eval_f(&x_trunc, true)
3660 .unwrap_or(Number::NAN);
3661 let m = tnlp
3662 .borrow_mut()
3663 .get_nlp_info()
3664 .map(|i| i.m as usize)
3665 .unwrap_or(0);
3666 // The success flag decides whether `g_inner` is a measurement
3667 // or a zero-filled buffer. Dropping it would let a TNLP whose
3668 // final `eval_g` fails fabricate feasibility: every row would
3669 // read `0`, `original_space_feasibility` would return a
3670 // violation of zero, and that would flow into both the exit
3671 // status and the reported residuals. Gate it exactly as the
3672 // ρ-escalation measurement above does, so an evaluation
3673 // failure produces `None` and follows the documented
3674 // `l1_slack_tol` fallback instead (gh#794 review).
3675 let mut g_inner = vec![0.0; m];
3676 let g_evaluated = m == 0 || tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
3677
3678 // gh#794 P1. Everything below used to argue from `Σ(p + n)`,
3679 // the sum of the augmented slacks, judged against
3680 // `l1_slack_tol`. That is not the user's constraint
3681 // violation and it is not judged by the user's tolerance:
3682 //
3683 // * the violation of equality row `i` is `|p_i − n_i|`,
3684 // not `p_i + n_i`, and at the barrier's interior both
3685 // slacks stay positive where their difference is zero,
3686 // so the sum is an upper bound that is loose in one
3687 // direction; and
3688 // * `l1_slack_tol` defaults to `1e-6`, four orders looser
3689 // than a `tol = 1e-8` solve asked for, so a violation
3690 // that the solver's own strict gate would refuse on the
3691 // unwrapped problem read as "the constraints are
3692 // satisfied".
3693 //
3694 // Measured, not argued: the MPCC benchmark's `ralph1`
3695 // (`benchmarks/mpcc/`) returned `Solve_Succeeded` at a point
3696 // violating its one equality row by `2.5e-07`, with the
3697 // reported `final_constr_viol` — the *augmented* residual —
3698 // at `9.6e-15`, so no field in the result disclosed it. The
3699 // objective came back `5.0e-04` below the true optimum,
3700 // which is reachable only off the feasible set.
3701 //
3702 // So: measure the user's own rows at the returned point, and
3703 // judge them by the tolerances the caller set. The slack sum
3704 // keeps its other job unchanged — it is the BNW steering
3705 // signal for ρ escalation inside the loop above, which is
3706 // what it is the right quantity for.
3707 let feas = g_evaluated
3708 .then(|| {
3709 original_space_feasibility(
3710 &tnlp,
3711 &x_trunc,
3712 &g_inner,
3713 self.nlp_lower_bound_inf(),
3714 self.nlp_upper_bound_inf(),
3715 self.user_tol(),
3716 self.user_acceptable_tol(),
3717 self.user_constr_viol_tol(),
3718 self.user_acceptable_constr_viol_tol(),
3719 self.user_primal_noise_floor_kappa(),
3720 )
3721 })
3722 .flatten();
3723
3724 // The reported constraint violation must be the user's, not
3725 // the augmented problem's. Without this the KKT block of a
3726 // successful ℓ₁ solve describes a problem the caller never
3727 // posed. The aggregate errors take a `max` rather than a
3728 // rewrite: the NLP error's primal term enters undivided, so
3729 // the aggregate is never below the constraint violation, and
3730 // the other two components are unaffected by the wrapper.
3731 //
3732 // `f.max_violation` is measured on the inner TNLP's own rows
3733 // and bounds, so it is in the model's **original units**. That
3734 // decides which field family may carry it. `SolveStatistics`
3735 // documents `final_*` as the max-norms in the internally
3736 // scaled NLP space and `final_unscaled_*` as the same
3737 // residuals with the scaling divided back out — equal only
3738 // when no scaling is active — and `docs/src/python.md` states
3739 // the same contract to Python callers. Writing an
3740 // original-units number into `final_constr_viol` would break
3741 // it on any run with `nlp_scaling_method` engaged (gh#794
3742 // review).
3743 //
3744 // So the unscaled family always takes the measurement, and
3745 // the scaled family mirrors it exactly when per-row scaling
3746 // did not engage — the case in which the contract requires
3747 // the two to agree anyway. Under active row scaling the
3748 // scaled fields keep what the inner solve reported; the
3749 // converted number is not available here, because the
3750 // augmented problem's row-scale factors belong to an NLP that
3751 // `optimize_constrained` has already dropped. The status
3752 // decision below does not read these fields — it reads
3753 // `feas` directly — so an active-scaling run is judged on the
3754 // user's rows either way.
3755 if let Some(f) = feas.as_ref() {
3756 let scaled_may_mirror = self.row_scaling_active.get() == Some(false);
3757 let mut stats = self.statistics.borrow_mut();
3758 stats.final_unscaled_constr_viol = f.max_violation;
3759 stats.final_unscaled_kkt_error =
3760 stats.final_unscaled_kkt_error.max(f.max_violation);
3761 if scaled_may_mirror {
3762 stats.final_constr_viol = f.max_violation;
3763 stats.final_kkt_error = stats.final_kkt_error.max(f.max_violation);
3764 stats.final_kkt_error_above_noise =
3765 stats.final_kkt_error_above_noise.max(f.max_violation);
3766 }
3767 }
3768
3769 let inner_claimed_success = matches!(
3770 last_status,
3771 ApplicationReturnStatus::SolveSucceeded
3772 | ApplicationReturnStatus::SolvedToAcceptableLevel
3773 );
3774
3775 // Downgrade, not upgrade: a solve that reached the strict
3776 // standard on the user's own rows keeps whatever status the
3777 // inner gave it, and nothing here can turn a failure into a
3778 // success.
3779 let downgrade_to_acceptable = inner_claimed_success
3780 && feas
3781 .as_ref()
3782 .is_some_and(|f| !f.negligible_at_tol && f.negligible_at_acceptable);
3783
3784 let infeasible_certificate = inner_claimed_success
3785 && match feas.as_ref() {
3786 // Measured: the point does not satisfy the user's
3787 // constraints even to `acceptable_tol`.
3788 Some(f) => !f.negligible_at_acceptable,
3789 // Unmeasurable model — fall back to the historical
3790 // slack-sum argument rather than to silence.
3791 None => slack_sum.is_finite() && slack_sum > slack_tol,
3792 };
3793
3794 if let Some(f) = feas.as_ref()
3795 && inner_claimed_success
3796 && !f.negligible_at_tol
3797 {
3798 tracing::info!(
3799 target: "pounce::application",
3800 "l1 penalty-barrier: the inner solve converged the augmented NLP, \
3801 but the returned point violates the model's own constraints by \
3802 {:.3e}, which does not meet tol; reporting {} rather than success \
3803 (gh#794)",
3804 f.max_violation,
3805 if f.negligible_at_acceptable {
3806 "Solved_To_Acceptable_Level"
3807 } else {
3808 "an infeasibility verdict"
3809 },
3810 );
3811 }
3812 // …unless the model's own starting point satisfies every
3813 // constraint, which disproves the certificate outright (gh #379).
3814 // Same gate as the IPM and SQP paths; see
3815 // `withdraw_infeasibility_if_refuted`.
3816 let refuted = infeasible_certificate
3817 && withdraw_infeasibility_if_refuted(
3818 &tnlp,
3819 SolverReturn::LocalInfeasibility,
3820 self.nlp_lower_bound_inf(),
3821 self.nlp_upper_bound_inf(),
3822 self.user_tol(),
3823 ) != SolverReturn::LocalInfeasibility;
3824 let final_solver_status = match (infeasible_certificate, refuted) {
3825 (true, false) => SolverReturn::LocalInfeasibility,
3826 // The point is not feasible, so `Solve_Succeeded` would be
3827 // just as wrong as `Infeasible_Problem_Detected`. Report the
3828 // breakdown.
3829 (true, true) => SolverReturn::ErrorInStepComputation,
3830 (false, _) if downgrade_to_acceptable => SolverReturn::StopAtAcceptablePoint,
3831 (false, _) => solver_status,
3832 };
3833 let final_app_status = match (infeasible_certificate, refuted) {
3834 (true, false) => ApplicationReturnStatus::InfeasibleProblemDetected,
3835 (true, true) => ApplicationReturnStatus::ErrorInStepComputation,
3836 (false, _) if downgrade_to_acceptable => {
3837 ApplicationReturnStatus::SolvedToAcceptableLevel
3838 }
3839 (false, _) => last_status,
3840 };
3841
3842 tnlp.borrow_mut().finalize_solution(
3843 Solution {
3844 status: final_solver_status,
3845 x: &x_trunc,
3846 z_l: &z_l,
3847 z_u: &z_u,
3848 g: &g_inner,
3849 lambda: &lambda,
3850 obj_value: f_inner,
3851 },
3852 &TnlpIpoptData::default(),
3853 &TnlpIpoptCq::default(),
3854 );
3855 return Some(final_app_status);
3856 }
3857 // No solution captured at all — pass the inner status through.
3858 Some(last_status)
3859 }
3860
3861 /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
3862 /// bundle, run `optimize`, populate statistics, and call
3863 /// `finalize_solution` on the user's TNLP.
3864 /// Whether an over-determined model (more equality rows than free
3865 /// variables) is *provably* infeasible by linear bound propagation.
3866 ///
3867 /// Consulted only on the `NotEnoughDegreesOfFreedom` failure path, where
3868 /// the solve never runs and therefore can never itself discover the
3869 /// infeasibility (gh#387). Builds a throwaway presolve wrapper with only
3870 /// Phase 1 (bound tightening) enabled and asks it for a certified proof —
3871 /// this inherits the certification safety net wholesale: the crossing must
3872 /// exceed the solver's own acceptance margin at the crossed pair's scale,
3873 /// and a concrete witness point satisfying every constraint withdraws the
3874 /// verdict. A `false` here costs nothing but keeping the DOF error.
3875 ///
3876 /// The witness gate runs under [`pounce_presolve::WitnessRule`]'s
3877 /// `DeclaredRowRelative` form, which is admissible only because the solve
3878 /// cannot run on this path (gh#391) — see the comment on the probe below.
3879 ///
3880 /// Deliberately independent of the `presolve` master switch: this is not a
3881 /// model transformation (the wrapper is dropped without solving through
3882 /// it), it is a last check before reporting a structural error for a
3883 /// problem whose verdict is already decided.
3884 fn overdetermined_model_certified_infeasible(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> bool {
3885 let mut opts = pounce_presolve::PresolveOptions::from_options_list(&self.options)
3886 .unwrap_or_else(|_| pounce_presolve::PresolveOptions::defaults());
3887 opts.enabled = true;
3888 opts.bound_tightening = true;
3889 // Certification needs Phase 1 only; every transformative or
3890 // diagnostic phase is dead weight on a wrapper that is never
3891 // solved through.
3892 opts.auxiliary = false;
3893 opts.fbbt = false;
3894 opts.redundant_constraint_removal = false;
3895 opts.licq_check = false;
3896 opts.warm_z_bounds = false;
3897 // The one place the witness rule is raised off the solver's own
3898 // acceptance test (gh#391). It is sound *here specifically* because the
3899 // gate has already established the solve cannot run: the alternative to
3900 // the proof is the structural 5xx error, never `Solve_Succeeded`, so
3901 // the #380 "two routes, two answers" contradiction the clamp exists to
3902 // prevent has no second route to contradict. See `WitnessRule` for the
3903 // full argument and the homogeneous-row fallback.
3904 let mut probe =
3905 pounce_presolve::PresolveTnlp::new(Rc::clone(tnlp), opts).probing_without_a_solve();
3906 if probe.get_nlp_info().is_none() {
3907 return false;
3908 }
3909 probe.certified_infeasible().is_some()
3910 }
3911
3912 fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
3913 let t_start = Instant::now();
3914
3915 // Invalidate the row-scaling record before anything can read it.
3916 //
3917 // It is written near the end of this function, from the NLP the
3918 // solve actually built, so a solve that bails before that point
3919 // leaves whatever the *previous* one recorded. The ℓ₁ outer loop
3920 // calls this repeatedly and reads the flag after each call, so a
3921 // stale `Some(false)` would let it mirror an original-units
3922 // violation into the scaled family — the exact contract this flag
3923 // exists to protect (gh#794 review round 2). Clearing it here
3924 // makes the failure mode fail-closed: "not recorded" reads as
3925 // "cannot mirror", never as "scaling was off".
3926 self.row_scaling_active.set(None);
3927
3928 // `print_user_options yes` — dump the OptionsList before the
3929 // solve. Mirrors `IpoptApplication::call_optimize` (upstream
3930 // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
3931 let print_opts = self
3932 .options
3933 .get_bool_value("print_user_options", "")
3934 .ok()
3935 .and_then(|(v, f)| f.then_some(v))
3936 .unwrap_or(false);
3937 if print_opts {
3938 print!(
3939 "\nList of user-set options:\n\n{}",
3940 self.options.print_user_options()
3941 );
3942 }
3943
3944 // `print_options_documentation yes` — dump the full registry
3945 // (every option with type, default, valid range/strings, and
3946 // long description) before the solve. Honors
3947 // `print_options_mode` (`text` / `latex` / `doxygen`; only
3948 // `text` is implemented today, the others fall through with a
3949 // one-line note) and `print_advanced_options`. Mirrors
3950 // upstream `IpoptApplication::call_optimize`'s
3951 // `print_options_documentation` branch and `Common/IpRegOptions.cpp`
3952 // `OutputOptionDocumentation`.
3953 let print_doc = self
3954 .options
3955 .get_bool_value("print_options_documentation", "")
3956 .ok()
3957 .and_then(|(v, f)| f.then_some(v))
3958 .unwrap_or(false);
3959 if print_doc {
3960 let mode = self
3961 .options
3962 .get_string_value("print_options_mode", "")
3963 .ok()
3964 .map(|(v, _)| PrintOptionsMode::from_tag(&v))
3965 .unwrap_or(PrintOptionsMode::Text);
3966 let advanced = self
3967 .options
3968 .get_bool_value("print_advanced_options", "")
3969 .ok()
3970 .map(|(v, _)| v)
3971 .unwrap_or(false);
3972 print!(
3973 "\n# Pounce options registry\n\n{}",
3974 self.reg_options.print_options_documentation(mode, advanced)
3975 );
3976 }
3977
3978 // Mint a fresh `TimingStatistics` for this solve — shared (via
3979 // `Rc`) with the data and the NLP below so every `eval_*` and
3980 // every iterate-phase records into the same accumulator. The
3981 // application keeps its own `Rc` so callers can read totals out
3982 // via [`Self::timing_stats`].
3983 let timing = Rc::new(TimingStatistics::new());
3984 *self.timing.borrow_mut() = Rc::clone(&timing);
3985 // gh#606: same lifetime as the timings — a solve that bails out
3986 // before the initializer runs must not report the previous
3987 // solve's warm-start verdict.
3988 *self.warm_start_diag.borrow_mut() = None;
3989 // Gate the *detailed* per-subsystem timers on `timing_statistics`
3990 // (default "no"), matching upstream Ipopt. Without this, every
3991 // timed `eval_*` / phase section pays two `getrusage` syscalls per
3992 // start/end even when statistics are off — 16-20% of busy CPU on
3993 // fast-objective NLPs (issue #190). `print_timing_statistics=yes`
3994 // implies `timing_statistics=yes` (per its option help), so either
3995 // one enables the detailed timers. `overall_alg` is started
3996 // unconditionally below: it feeds the `max_cpu_time` check and is
3997 // reported regardless of the option.
3998 //
3999 // Each name is read as a literal rather than looped over an
4000 // array: the registered-but-unread scan
4001 // (`tests/no_silent_options.rs`) keys on the option name as it
4002 // appears at the accessor, so a loop variable reads as "no key
4003 // here" and hid `timing_statistics` among the silent options
4004 // when it has been wired since #190 (#677, #551).
4005 let read_yes = |key: &str| -> bool {
4006 self.options
4007 .get_bool_value(key, "")
4008 .ok()
4009 .and_then(|(v, f)| f.then_some(v))
4010 .unwrap_or(false)
4011 };
4012 let timing_enabled = read_yes("timing_statistics") || read_yes("print_timing_statistics");
4013 timing.set_detailed_enabled(timing_enabled);
4014 timing.overall_alg.start();
4015
4016 // Reset the linear-solver summary sink so back-to-back solves
4017 // don't bleed factor counters / extremal pivots into each
4018 // other. Surviving the lock failure with a debug-assert keeps
4019 // a poisoned mutex from sinking a release build that doesn't
4020 // even consume the summary.
4021 match self.linsol_summary_sink.lock() {
4022 Ok(mut guard) => {
4023 *guard = LinearSolverSummary::default();
4024 }
4025 _ => {
4026 debug_assert!(false, "linsol summary sink mutex poisoned");
4027 }
4028 }
4029 // Same reasoning for the quality-escalation tally (gh#857): the
4030 // number belongs to this solve, not to whatever ran before it.
4031 self.quality_escalations.set(0);
4032
4033 // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
4034 // `make_parameter`; pounce additionally implements `relax_bounds`,
4035 // which the adapter also auto-selects as a fallback when
4036 // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
4037 // `IpTNLPAdapter.cpp:623-633`).
4038 let lo_inf = self
4039 .options
4040 .get_numeric_value("nlp_lower_bound_inf", "")
4041 .ok()
4042 .and_then(|(v, f)| f.then_some(v))
4043 .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
4044 let up_inf = self
4045 .options
4046 .get_numeric_value("nlp_upper_bound_inf", "")
4047 .ok()
4048 .and_then(|(v, f)| f.then_some(v))
4049 .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
4050 let fixed_treatment = match self
4051 .options
4052 .get_string_value("fixed_variable_treatment", "")
4053 .ok()
4054 .and_then(|(v, f)| f.then_some(v))
4055 .as_deref()
4056 {
4057 Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
4058 // `make_constraint` / `make_parameter_nodual` not yet
4059 // implemented; fall back to `make_parameter` (auto-retry to
4060 // `relax_bounds` will still kick in if DOF runs short).
4061 _ => FixedVarTreatment::MakeParameter,
4062 };
4063 let adapter = match TNLPAdapter::new_with_options(
4064 Rc::clone(&tnlp),
4065 lo_inf,
4066 up_inf,
4067 fixed_treatment,
4068 ) {
4069 Ok(a) => Rc::new(RefCell::new(a)),
4070 Err(_) => {
4071 timing.overall_alg.end();
4072 return ApplicationReturnStatus::InvalidProblemDefinition;
4073 }
4074 };
4075 // Carry the user's constant `obj_scaling_factor` (default 1.0;
4076 // negative ⇒ maximize) into the NLP. Until pounce#128's
4077 // follow-up this option was registered but never read, so it
4078 // was silently a no-op — maximization diverged because the
4079 // algorithm minimized the unscaled objective.
4080 let obj_scaling_factor = self
4081 .options
4082 .get_numeric_value("obj_scaling_factor", "")
4083 .ok()
4084 .and_then(|(v, f)| f.then_some(v))
4085 .unwrap_or(1.0);
4086 let mut orig_nlp = match OrigIpoptNlp::new(
4087 Rc::clone(&adapter),
4088 Rc::new(ConstObjScaling(obj_scaling_factor)),
4089 ) {
4090 Ok(n) => n,
4091 Err(_) => {
4092 timing.overall_alg.end();
4093 return ApplicationReturnStatus::InternalError;
4094 }
4095 };
4096 orig_nlp.set_timing_stats(Rc::clone(&timing));
4097 // Q6: decide which derivatives may be reused across iterates,
4098 // before anything is evaluated (gh #588).
4099 self.install_constant_derivative_hints(&mut orig_nlp);
4100
4101 // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
4102 // bail out with NotEnoughDegreesOfFreedom when there are fewer free
4103 // variables than equality constraints. Without this gate, square /
4104 // over-determined systems push the algorithm into restoration on
4105 // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
4106 let n_x_var = orig_nlp.x_space().dim();
4107 let n_c = orig_nlp.c_space().dim();
4108 if n_x_var > 0 && n_x_var < n_c {
4109 timing.overall_alg.end();
4110 // An over-determined system can still be *provably* infeasible —
4111 // `x == 0.2` with `x == 0.8` is about as provable as infeasibility
4112 // gets — and for such a model the structural DOF error is the
4113 // strictly weaker answer: it reports "cannot attempt this" for a
4114 // problem whose verdict is already decided (gh#387). The DOF gate
4115 // fires before any iteration runs, so nothing downstream will ever
4116 // get the chance to detect the infeasibility; check for a
4117 // bound-propagation proof here, on the rare failure path only.
4118 // The probe reuses presolve's full certification pipeline
4119 // (crossing margin + witness refutation), so a model the solver
4120 // would accept as feasible at its own tolerance is never upgraded
4121 // to "proved infeasible" — those still report the DOF error.
4122 if self.overdetermined_model_certified_infeasible(&tnlp) {
4123 use pounce_common::journalist::JournalCategory;
4124 self.journalist.print(
4125 JournalLevel::J_SUMMARY,
4126 JournalCategory::J_MAIN,
4127 "\nEXIT: Problem has too few degrees of freedom, and bound \
4128 propagation proves its constraints inconsistent.\n\
4129 No feasible point exists; the solve was not run.\n",
4130 );
4131 return ApplicationReturnStatus::InfeasibleProblemDetected;
4132 }
4133 return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
4134 }
4135
4136 // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
4137 // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
4138 // `OrigIpoptNLP::InitializeStructures` lines 343-358.
4139 let bound_relax_factor = self
4140 .options
4141 .get_numeric_value("bound_relax_factor", "")
4142 .ok()
4143 .and_then(|(v, f)| f.then_some(v))
4144 .unwrap_or(1e-8);
4145 let constr_viol_tol = self
4146 .options
4147 .get_numeric_value("constr_viol_tol", "")
4148 .ok()
4149 .and_then(|(v, f)| f.then_some(v))
4150 .unwrap_or(1e-4);
4151 orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
4152
4153 // `honor_original_bounds` (default `no`, matching upstream):
4154 // project the reported point back into the un-relaxed box. Must
4155 // follow `relax_bounds`, which snapshots the bounds to project
4156 // onto. Registered but never read before, so a user asking for
4157 // it still got a bound-pinned solution sitting up to
4158 // `min(bound_relax_factor·max(1,|b|), constr_viol_tol)` outside
4159 // its own bounds (gh#483 follow-up).
4160 let honor_original_bounds = self
4161 .options
4162 .get_bool_value("honor_original_bounds", "")
4163 .ok()
4164 .and_then(|(v, f)| f.then_some(v))
4165 .unwrap_or(false);
4166 orig_nlp.set_honor_original_bounds(honor_original_bounds);
4167
4168 // Apply automatic NLP scaling per `nlp_scaling_method` option
4169 // (port of `OrigIpoptNLP::InitializeStructures` →
4170 // `NLPScalingObject::DetermineScaling`). Default is
4171 // `gradient-based` to match upstream Ipopt 3.14.
4172 let scaling_method = self
4173 .options
4174 .get_string_value("nlp_scaling_method", "")
4175 .ok()
4176 .and_then(|(v, f)| f.then_some(v))
4177 .unwrap_or_else(|| "gradient-based".to_string());
4178 let scaling_method = match scaling_method.as_str() {
4179 "none" => ScalingMethod::None,
4180 "gradient-based" => ScalingMethod::GradientBased,
4181 // `curvature-based` computes the factors from the model's
4182 // quadratic coefficients and hands them back through
4183 // `TNLP::get_scaling_parameters` (gh #703), so from the
4184 // engine's side it *is* user scaling — the only difference is
4185 // who filled the vectors in.
4186 "user-scaling" | "curvature-based" => ScalingMethod::UserScaling,
4187 // `equilibration-based` is registered upstream but not yet
4188 // implemented in pounce; fall back to gradient-based (the
4189 // upstream default) to keep behavior predictable.
4190 _ => ScalingMethod::GradientBased,
4191 };
4192 let max_gradient = self
4193 .options
4194 .get_numeric_value("nlp_scaling_max_gradient", "")
4195 .ok()
4196 .and_then(|(v, f)| f.then_some(v))
4197 .unwrap_or(100.0);
4198 let min_value = self
4199 .options
4200 .get_numeric_value("nlp_scaling_min_value", "")
4201 .ok()
4202 .and_then(|(v, f)| f.then_some(v))
4203 .unwrap_or(1e-8);
4204 let obj_target_gradient = self
4205 .options
4206 .get_numeric_value("nlp_scaling_obj_target_gradient", "")
4207 .ok()
4208 .and_then(|(v, f)| f.then_some(v))
4209 .unwrap_or(0.0);
4210 let constr_target_gradient = self
4211 .options
4212 .get_numeric_value("nlp_scaling_constr_target_gradient", "")
4213 .ok()
4214 .and_then(|(v, f)| f.then_some(v))
4215 .unwrap_or(0.0);
4216 orig_nlp.determine_scaling_from_starting_point(
4217 scaling_method,
4218 max_gradient,
4219 min_value,
4220 obj_target_gradient,
4221 constr_target_gradient,
4222 );
4223
4224 let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
4225
4226 // Build the algorithm strategy bundle. Read coarse knobs from
4227 // the OptionsList where we have them; fall through to defaults
4228 // otherwise. The full upstream parsing surface (mu_strategy,
4229 // hessian_approximation, line_search_method, ...) is wired by
4230 // `AlgBuilder::RegisterOptions` in upstream — that registry
4231 // hookup lands as a follow-up; default builder is correct for
4232 // HS71-class problems.
4233 let mut builder = self.algorithm_builder_from_options();
4234
4235 // The objective element's support for the partitioned Hessian.
4236 // Every constraint element reads its support off a Jacobian row;
4237 // only the objective has none declared, so without this the
4238 // updater falls back to the first `∇f`'s nonzeros — a
4239 // value-derived pattern. See
4240 // `TNLPAdapter::objective_nonlinear_vars`.
4241 if matches!(
4242 builder.hessian_approximation,
4243 HessianApproxChoice::Partitioned | HessianApproxChoice::FiniteDifference
4244 ) {
4245 builder.objective_nonlinear_vars = adapter.borrow().objective_nonlinear_vars();
4246 }
4247
4248 // Which variables the limited-memory Hessian should span (gh#624).
4249 // Upstream's precedence: a TNLP that implements
4250 // `get_number_of_nonlinear_variables` wins, and
4251 // `num_linear_variables` is only the contiguous-prefix fallback.
4252 // Exact-Hessian solves never consult either.
4253 if matches!(
4254 builder.hessian_approximation,
4255 HessianApproxChoice::LimitedMemory | HessianApproxChoice::FiniteDifference
4256 ) {
4257 let num_linear_variables = self
4258 .options
4259 .get_integer_value("num_linear_variables", "")
4260 .ok()
4261 .and_then(|(v, f)| f.then_some(v))
4262 .unwrap_or(0);
4263 match adapter
4264 .borrow()
4265 .quasi_newton_nonlinear_vars(num_linear_variables)
4266 {
4267 Ok(mask) => builder.limited_memory_nonlinear_vars = mask,
4268 Err(e) => {
4269 use pounce_common::journalist::JournalCategory;
4270 self.journalist.print(
4271 JournalLevel::J_ERROR,
4272 JournalCategory::J_MAIN,
4273 &format!("\nEXIT: Invalid nonlinear-variable list: {}\n", e.message),
4274 );
4275 timing.overall_alg.end();
4276 return ApplicationReturnStatus::InvalidProblemDefinition;
4277 }
4278 }
4279 }
4280
4281 // Linear-solver backend. The default factory is option-aware
4282 // — it reads the `feral_*` extension options off the same
4283 // `OptionsList` that drove the IPM-level builder above so
4284 // per-problem `.opt` files can flip backend knobs without
4285 // rebuilding pounce.
4286 let mut feral_cfg = feral_config_from_options(&self.options);
4287 // Block-triangular / Schur KKT partition (pounce#180 item 2). Configure
4288 // the Schur block solvers from the *base* feral cfg: a full-KKT external
4289 // ordering (item 1) is sized for the whole system and cannot apply to
4290 // the A_FF sub-block, so the Schur path keeps the default sub-block
4291 // ordering. `build_with_backend` honors this only on the IPM + feral +
4292 // exact-Hessian path and falls back to the standard solver otherwise.
4293 if let Some(indices) = &self.kkt_schur_block {
4294 builder.set_kkt_schur(indices.clone(), feral_cfg.clone());
4295 }
4296 // A caller-supplied KKT permutation (pounce#180 item 1) overrides
4297 // the string-option / env ordering: `OrderingMethod::External`
4298 // can't be expressed through the OptionsList (it carries a
4299 // vector), so it is injected here from the side-channel field.
4300 // Only applies to the workspace-default FERAL backend below; a
4301 // custom `linear_backend_factory` owns its own config.
4302 if let Some(perm) = &self.external_ordering {
4303 feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
4304 }
4305 // MA57's knobs come off the same `OptionsList`, at the main-IPM
4306 // prefix. The restoration sub-IPM reads them again under
4307 // `"resto."` when its caller mints the inner backend factory —
4308 // see `ma57_config_from_options`.
4309 let ma57_cfg = ma57_config_from_options(&self.options, "");
4310 let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
4311 default_backend_factory_with_sink(
4312 feral_cfg,
4313 ma57_cfg,
4314 Arc::clone(&self.linsol_summary_sink),
4315 )
4316 });
4317 let bundle = builder.build_with_backend(factory);
4318
4319 // Wire the data / cq pair around the NLP. Install the shared
4320 // `TimingStatistics` so the algorithm's iterate phases
4321 // (output, convergence, hessian, μ, search-direction,
4322 // line-search, accept) all record into the same accumulator
4323 // the application exposes via `timing_stats()`.
4324 let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
4325 data.borrow_mut().timing = Rc::clone(&timing);
4326 // Install a shared wall/CPU-time deadline (pounce#242) so the time
4327 // budget is honored at the granularity of the expensive inner
4328 // steps — the main loop's KKT factorization / line search and the
4329 // restoration inner IPM — instead of only between outer iterations.
4330 // The `Deadline` starts its clock now (right after `overall_alg`),
4331 // and the restoration sub-solve reuses this same instance, so the
4332 // caller's budget bounds the whole solve rather than each nested
4333 // level independently. The convergence check treats it as
4334 // authoritative when present (see `conv_check::opt_error`).
4335 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(
4336 builder.conv_check.max_wall_time,
4337 builder.conv_check.max_cpu_time,
4338 ));
4339 let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
4340 IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
4341 ));
4342 // Correction size for very small slacks (default mach_eps^{3/4});
4343 // drives the safe-slack bound-adjustment mechanism.
4344 if let Ok((v, true)) = self.options.get_numeric_value("slack_move", "") {
4345 cq.borrow_mut().slack_move = v;
4346 }
4347 // `kappa_d` — weight of the linear damping term added to the
4348 // barrier objective/gradient to handle one-sided bounds
4349 // (`IpIpoptCalculatedQuantities.cpp`). Registered (default 1e-5)
4350 // but previously never read, so a user override was silently
4351 // ignored (#191). Routed through the builder for parity with the
4352 // other numeric knobs; the default matches the registered
4353 // default, so only explicit overrides change behavior.
4354 cq.borrow_mut().kappa_d = builder.kappa_d;
4355 // `s_max` — cap on the average multiplier magnitude in the
4356 // `(s_d, s_c)` scaling of the KKT error test. Same shape as
4357 // `kappa_d`: registered (default 100) and previously never read,
4358 // so an override was silently ignored (#551 / #677).
4359 cq.borrow_mut().s_max = builder.s_max;
4360
4361 // Seed `data.curr` with a zero-valued iterate of the correct
4362 // dimensions. The `IterateInitializer` consumes these as its
4363 // template (it overwrites `x`, `s`, multipliers in place); we
4364 // just need the dim metadata.
4365 {
4366 let nlp_borrow = nlp_handle.borrow();
4367 let n_x = nlp_borrow.n();
4368 let n_s = nlp_borrow.m_ineq();
4369 let n_yc = nlp_borrow.m_eq();
4370 let n_yd = nlp_borrow.m_ineq();
4371 let n_zl = nlp_borrow.x_l().dim();
4372 let n_zu = nlp_borrow.x_u().dim();
4373 let n_vl = nlp_borrow.d_l().dim();
4374 let n_vu = nlp_borrow.d_u().dim();
4375 drop(nlp_borrow);
4376 let iv = IteratesVector::new(
4377 Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
4378 Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
4379 Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
4380 Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
4381 Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
4382 Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
4383 Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
4384 Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
4385 );
4386 data.borrow_mut().set_curr(iv);
4387 }
4388
4389 // Full primal-dual warm restart (debugger `resolve`): if a
4390 // captured iterate is queued, install it onto `data.curr` over
4391 // the placeholder so the `WarmStartIterateInitializer`'s
4392 // re-optimize branch (x already initialized) keeps it and only
4393 // clamps multipliers / sets target_mu — no cold re-seed from the
4394 // NLP. Skipped (with a warning) if the dimensions don't line up,
4395 // e.g. an option changed the problem structure between solves.
4396 if let Some(snap) = self.warm_start_iterate.take() {
4397 let dims_match = {
4398 let borrow = data.borrow();
4399 borrow
4400 .curr
4401 .as_ref()
4402 .map(|c| iterates_dims(c) == iterates_dims(snap.iterates()))
4403 .unwrap_or(false)
4404 };
4405 if dims_match {
4406 data.borrow_mut().set_curr(snap.iterates().clone());
4407 data.borrow_mut().curr_mu = snap.mu();
4408 } else {
4409 tracing::warn!(
4410 target: "pounce::warm_start",
4411 "debugger warm-restart iterate dimensions differ from the fresh \
4412 solve; ignoring the captured iterate and seeding normally"
4413 );
4414 }
4415 }
4416
4417 let max_iter = self
4418 .options
4419 .get_integer_value("max_iter", "")
4420 .ok()
4421 .and_then(|(v, f)| f.then_some(v))
4422 .unwrap_or(3000);
4423 let tol = self
4424 .options
4425 .get_numeric_value("tol", "")
4426 .ok()
4427 .and_then(|(v, f)| f.then_some(v))
4428 .unwrap_or(1e-8);
4429 data.borrow_mut().tol = tol;
4430
4431 let mut alg = IpoptAlgorithm::new(data, cq, bundle)
4432 .with_nlp(Rc::clone(&nlp_handle))
4433 .with_tnlp(Rc::clone(&tnlp));
4434 alg.last_iter_stats_sink = Some(Rc::clone(&self.last_iter_stats));
4435 // Mint a fresh restoration factory per inner solve if a
4436 // provider is configured (pounce#10 Phase 3). Falls back to
4437 // the legacy one-shot `restoration_factory` slot when no
4438 // provider is set, preserving single-shot caller behavior.
4439 if let Some(provider) = self.restoration_factory_provider.as_mut() {
4440 self.restoration_factory = Some(provider());
4441 }
4442 if let Some(factory) = self.restoration_factory.as_mut() {
4443 alg = alg.with_restoration(factory());
4444 }
4445 if let Some(diag) = self.diagnostics.as_ref() {
4446 alg = alg.with_diagnostics(Rc::clone(diag));
4447 }
4448 // Move the interactive debugger hook (if any) into the main
4449 // algorithm. Taken — not cloned — so it drives exactly this
4450 // solve; a subsequent solve must reinstall it.
4451 if let Some(hook) = self.debug_hook.take() {
4452 alg = alg.with_debug_hook(hook);
4453 }
4454 alg.max_iter = max_iter;
4455 // `kappa_sigma` — factor bounding how far the bound multipliers
4456 // may deviate from their primal estimates; the clamp runs after
4457 // every accepted step (`IpIpoptAlg.cpp`, Eqn. (16)). Registered
4458 // (default 1e10) but previously never read, so a user override —
4459 // including the documented `< 1` "disable the correction" — was
4460 // silently ignored (#191). Routed through the builder; the struct
4461 // default matches the registered default, so default runs are
4462 // unchanged.
4463 alg.kappa_sigma = builder.kappa_sigma;
4464 // `recalc_y` (#677) — see the read site above for why the
4465 // limited-memory path defaults it on.
4466 // `linear_system_scaling=slack-based` (#677) — the only scaling
4467 // choice whose factors depend on the iterate, so the main loop
4468 // has to refresh them. See `IpoptAlgorithm::push_slack_scaling`.
4469 alg.slack_based_scaling = matches!(
4470 builder.linear_system_scaling,
4471 crate::alg_builder::LinearSystemScalingChoice::SlackBased
4472 );
4473 alg.recalc_y = builder.recalc_y;
4474 alg.recalc_y_feas_tol = builder.recalc_y_feas_tol;
4475 // `start_with_resto` — the outer loop is what acts on it. It was
4476 // previously copied only into the restoration sub-solver's own
4477 // builder, which has no first outer iteration to force, so
4478 // setting the option did nothing at all.
4479 alg.start_with_resto = builder.resto.start_with_resto;
4480 // Tiny-step and divergence guards (#191): registered but
4481 // previously never read. Struct defaults match the registered
4482 // defaults, so default runs are unchanged.
4483 alg.tiny_step_tol = builder.tiny_step_tol;
4484 alg.tiny_step_y_tol = builder.tiny_step_y_tol;
4485 alg.diverging_iterates_tol = builder.diverging_iterates_tol;
4486 alg.dual_diverging_streak = builder.dual_diverging_streak.max(0) as usize;
4487 alg.dual_divergence_retry_step_tol = builder.dual_divergence_retry_step_tol;
4488 alg.dual_divergence_retry_du_floor = builder.dual_divergence_retry_du_floor;
4489 alg.resto_decline_deferrals = builder.resto_decline_deferrals.max(0) as usize;
4490 alg.resto_decline_progress_ratio = builder.resto_decline_progress_ratio;
4491 alg.neg_curv_escapes = builder.neg_curv_escapes.max(0) as usize;
4492 alg.lbfgs_ls_failure_restarts = builder.limited_memory_ls_failure_restarts.max(0) as usize;
4493 alg.kkt_fidelity_tol = builder.kkt_fidelity_tol;
4494 // Honor `print_level == 0`: silence the algorithm's direct-to-stdout
4495 // output — the per-iteration table and, new in #206, the
4496 // problem-statistics and end-of-run summary blocks the engine now
4497 // emits itself. Default (unset) or any positive level shows them; the
4498 // CLI's JSON mode forces print_level 0, so structured output stays
4499 // clean. (The Phase-7 journalist surface respects print_level already;
4500 // this is the legacy direct-print site that needs the same gate.)
4501 let console_output = match self.options.get_integer_value("print_level", "") {
4502 Ok((v, true)) => v >= 1,
4503 _ => true,
4504 };
4505 if !console_output {
4506 alg.print_iter_output = false;
4507 // The nested restoration IPM is built inside the restoration
4508 // driver, not by `IpoptAlgorithm::new`, so it never sees this
4509 // gate unless we forward it.
4510 if let Some(resto) = alg.restoration.as_mut() {
4511 resto.set_print_iter_output(false);
4512 }
4513 }
4514
4515 // Problem statistics, Ipopt-style, emitted before the iteration table
4516 // from the engine's own reduced problem (#206). Built from the same
4517 // collect_stats inputs the CLI used, so the block is byte-identical;
4518 // emitting it here means every frontend (CLI, Python, C) and every
4519 // algorithm (IPM, SQP) gets it.
4520 self.emit_problem_stats(&tnlp, console_output);
4521
4522 // Per-iteration history (pounce#71): when requested, capture the
4523 // `pounce::iteration` events emitted during the solve into an
4524 // `IterRecord` trajectory via the observability collector layer.
4525 // This replaces the old in-loop `iter_history` accumulation; it
4526 // requires the collector to be installed in the active
4527 // subscriber (the CLI / Python / C frontends install it via
4528 // `pounce_observability::init_subscriber`; tests call
4529 // `init_for_tests`). The collector scopes out restoration
4530 // sub-solve iterations via the `restoration` span, so the
4531 // trajectory matches the previous behavior (outer iters only).
4532 let iter_capture = self
4533 .record_iter_history
4534 .then(pounce_observability::IterCaptureGuard::start);
4535
4536 let solver_status = alg.optimize();
4537 // Keep the initializer's feasibility diagnostics reachable
4538 // after the algorithm goes out of scope (gh#605).
4539 self.least_square_init_report = alg.least_square_init_report();
4540
4541 let captured_iters = iter_capture.map(|g| g.finish()).unwrap_or_default();
4542 // Propagate to any enclosing capture (e.g. `with_iter_capture`
4543 // wrapped around a solve with iteration history enabled), whose
4544 // buffer this inner guard would otherwise leave empty.
4545 pounce_observability::extend_active_capture(&captured_iters);
4546 // Close the overall-algorithm timer on the success path. The
4547 // early-return arms above end it themselves before bailing out;
4548 // this one matches upstream `IpoptApplication::call_optimize`
4549 // (which calls `EndCpuTime()` on overall_alg right after
4550 // `Optimize` returns, regardless of solver_status).
4551 timing.overall_alg.end();
4552
4553 // gh#612: opt-in crossover. Runs here — after the algorithm is done
4554 // but BEFORE the statistics drain, the status gates, the
4555 // `on_converged` hook and `finalize_via_orig_nlp` — so that when it
4556 // accepts, every one of those describes the point actually returned.
4557 // Placing it later would mean reporting residuals for an iterate the
4558 // user is not given, and would hide the exact active set from the
4559 // post-optimal sensitivity hook, which is the first of the three
4560 // consumers this exists for.
4561 self.maybe_crossover(&mut alg, &nlp_handle, solver_status);
4562
4563 // Drain counters / iter count off the algorithm.
4564 {
4565 let mut stats = self.statistics.borrow_mut();
4566 {
4567 let d = alg.data.borrow();
4568 stats.iteration_count = d.iter_count;
4569 // Converged barrier parameter μ — threaded forward into a
4570 // warm-started corrector's `mu_init` / `warm_start_target_mu`
4571 // for predictor–corrector path following (pounce#86).
4572 stats.final_mu = d.curr_mu;
4573 }
4574 // gh#606: the warm-start initializer's verdict on what the
4575 // caller supplied. Lifted off the (solve-local) data handle
4576 // so it outlives the solve; `None` on a cold start.
4577 *self.warm_start_diag.borrow_mut() = alg.data.borrow().warm_start_diagnostics.clone();
4578 stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
4579 // Restoration-phase audit counters (pounce#12). Zero on
4580 // problems where restoration never fires; populated by
4581 // `IpoptAlgorithm::invoke_restoration`.
4582 // Finite-difference Hessian census (gh#823 review). `None` on
4583 // every other updater, which leaves the `-1` sentinel in
4584 // place and says "this mode did not run" rather than "it ran
4585 // and found nothing".
4586 if let Some(fd) = alg.bundle.hess.fd_hessian_stats() {
4587 use crate::hess::fd_hessian::FdPatternSource;
4588 stats.fd_hessian_pattern_used = match fd.pattern_used {
4589 Some(FdPatternSource::Declared) => 0,
4590 Some(FdPatternSource::Jacobian) => 1,
4591 None => -1,
4592 };
4593 stats.fd_hessian_nnz = fd.nnz as Index;
4594 stats.fd_hessian_n = fd.n as Index;
4595 stats.fd_hessian_groups = fd.groups as Index;
4596 stats.fd_hessian_rho_max = fd.rho_max as Index;
4597 stats.fd_hessian_coloring_fell_back = fd.coloring_fell_back;
4598 stats.fd_hessian_objective_clique_widened = fd.objective_clique_widened;
4599 }
4600 stats.restoration_calls = alg.resto_calls;
4601 stats.restoration_inner_iters = alg.resto_inner_iters;
4602 stats.restoration_outer_iters = alg.resto_outer_iters;
4603 stats.restoration_wall_secs = alg.resto_wall_secs;
4604 // gh#857. Read off the shared cell rather than the algorithm's
4605 // own `PdFullSpaceSolver`, so restoration sub-solves — which
4606 // run their own solver instance — are included.
4607 stats.quality_escalations = self.quality_escalations.get() as Index;
4608 // gh#884. Read off the algorithm that just ran, so
4609 // `run_with_dual_divergence_retry` — which sits above this
4610 // call — can see what it observed.
4611 self.dual_divergence_signature
4612 .set(self.dual_divergence_signature.get() || alg.dual_divergence_signature());
4613 stats.dual_divergence_signature = self.dual_divergence_signature.get();
4614 stats.dual_divergence_retry_promoted = self.dual_divergence_retry_promoted.get();
4615 stats.iterations = captured_iters;
4616 // A refused starting point does not produce a valid iterate.
4617 // Leave final objective/residual fields at their NaN defaults.
4618 // Capture the final *scaled* objective at the algorithm's
4619 // (compressed `x_var`-space) iterate via the NLP: the
4620 // algorithm-side `eval_f` returns `f * obj_scale_factor`.
4621 // `final_objective` is seeded with it only as a best-effort
4622 // fallback; the success path below overwrites it with the
4623 // true unscaled objective from `finalize_via_orig_nlp`
4624 // (which evaluates the user TNLP directly).
4625 if solver_status != SolverReturn::InvalidProblemDefinition {
4626 let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
4627 if let Some(x) = curr_x {
4628 if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
4629 stats.final_objective = f;
4630 stats.final_scaled_objective = f;
4631 }
4632 }
4633 // Final residuals straight off the cq cache. These mirror
4634 // the values upstream prints in its end-of-run summary
4635 // ("Dual infeasibility / Constraint violation /
4636 // Complementarity / Overall NLP error").
4637 let cq = alg.cq.borrow();
4638 stats.final_dual_inf = cq.curr_dual_infeasibility_max();
4639 // Stays on the *internal* measure deliberately: the summary's
4640 // "Overall NLP error" is `curr_nlp_error`, and it is built from
4641 // this same `max(||c||, ||d - s||)`. Switching the violation line
4642 // alone to the original-NLP measure
4643 // (`curr_unscaled_nlp_constraint_violation_max`, now used by the
4644 // `inf_pr` column) would leave the block self-inconsistent —
4645 // an error larger than the max of its own components. Making
4646 // them agree means deciding whether *convergence* should be
4647 // judged on the original NLP, which is a behaviour change for
4648 // every model, not a reporting fix. See pounce#476.
4649 //
4650 // NOTE (gh #528): "Overall NLP error" is no longer the number
4651 // the strict gate tests. That gate judges
4652 // `curr_nlp_error_above_primal_noise` — the same aggregate with
4653 // each row's residual counted only above what it can represent
4654 // in floating point — so on a model whose constraint values run
4655 // to `~1e8` the summary can report an error above `tol` beside
4656 // `EXIT: Optimal Solution Found`. The gap is exactly the part
4657 // of the residual that is quantisation noise, and it is bounded
4658 // by `constr_viol_tol`, which is still tested here on the full
4659 // unfloored residual. Reporting is deliberately left on the raw
4660 // value: it is the honest measurement, and at these magnitudes
4661 // the default `bound_relax_factor = 1e-8` has already moved
4662 // every bound by orders of magnitude more than the floor
4663 // forgives, so the raw number was never an exact statement
4664 // about the original NLP either.
4665 stats.final_constr_viol = cq.curr_primal_infeasibility_max();
4666 // How far outside the model AS DECLARED the returned point
4667 // sits. The line above is the internal slack measure the
4668 // convergence test reads, on the `bound_relax_factor`-widened
4669 // model this arm genuinely solves; the widening stays here
4670 // because a feasible-iterate log-barrier needs `x` strictly
4671 // inside its bounds (the convex arm's does not -- see
4672 // `qp_extract::BoundRelax`). The two can differ by orders and
4673 // nothing used to say so: on netlib `wood1p` this reports
4674 // `1.71e-14` at a point `7.96e-09` outside the declared rows
4675 // and `9.84e-09` outside the declared box. Only reported when
4676 // a widening was applied; without one the two coincide and
4677 // `NaN` says "nothing to add".
4678 stats.final_declared_constr_viol = if bound_relax_factor > 0.0 {
4679 cq.curr_declared_primal_violation_max()
4680 } else {
4681 Number::NAN
4682 };
4683 // The box half of the same measurement, unconditionally: this
4684 // one is a *summary line* (Ipopt's `Variable bound
4685 // violation`), not an extra warning, so it has to carry a real
4686 // number on every solve rather than only on a widened one. At
4687 // `bound_relax_factor = 0` the honest number is `0` and the
4688 // accessor returns it by measurement, not by assumption.
4689 stats.final_declared_box_viol = cq.curr_declared_box_violation_max();
4690 // Infinity-norm complementarity, max over all four bound
4691 // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
4692 // empty-bound blocks return `0` from amax(), so the max is
4693 // safe even when only one side has bounds.
4694 let compl = cq
4695 .curr_compl_x_l()
4696 .amax()
4697 .max(cq.curr_compl_x_u().amax())
4698 .max(cq.curr_compl_s_l().amax())
4699 .max(cq.curr_compl_s_u().amax());
4700 stats.final_compl = compl;
4701 stats.final_kkt_error = cq.curr_nlp_error();
4702 // The aggregate the strict gate tested (gh #528). Reported
4703 // alongside the raw one so a summary can account for the gap
4704 // between them; equal to it on every `O(1)` model, and on any
4705 // run with `primal_noise_floor_kappa = 0`.
4706 stats.final_kkt_error_above_noise = cq
4707 .curr_nlp_error_above_primal_noise(builder.conv_check.primal_noise_floor_kappa);
4708 // Unscaled (user-space) counterparts — divide the nlp_scaling
4709 // back out so a consumer can verify the certificate in its own
4710 // units (pounce#173). Identical to the scaled fields when no
4711 // scaling is active.
4712 stats.final_unscaled_dual_inf = cq.curr_unscaled_dual_infeasibility_max();
4713 stats.final_unscaled_constr_viol = cq.curr_unscaled_primal_infeasibility_max();
4714 // Record whether per-row scaling actually engaged, so a
4715 // wrapper that measures the user's rows in the model's own
4716 // units knows which field family may carry that number.
4717 // `curr_unscaled_primal_infeasibility_max` treats both
4718 // vectors absent as "scaled == unscaled"; the ℓ₁ outer loop
4719 // relies on the same equivalence (gh#794 review).
4720 {
4721 let nlp_ref = nlp_handle.borrow();
4722 self.row_scaling_active.set(Some(
4723 nlp_ref.c_scale_vec().is_some() || nlp_ref.d_scale_vec().is_some(),
4724 ));
4725 }
4726 stats.final_unscaled_compl = cq.curr_unscaled_complementarity_max();
4727 stats.final_unscaled_kkt_error = cq.curr_unscaled_nlp_error();
4728
4729 // Report an accepted crossover in the frame it solved in
4730 // (#646). Everything above measures against the bounds the
4731 // interior iteration ran against, which `bound_relax_factor`
4732 // widened by `δ` before the solve. That is the right frame
4733 // for an interior iterate — it never touches a bound — but a
4734 // crossed-over point sits *exactly* on the constraints of the
4735 // problem as declared, i.e. `δ` inside the relaxed ones, so
4736 // the four `s·z` blocks above read `|multiplier| · δ`. For a
4737 // unit multiplier and the default `δ = 1e-8` that is `1e-8`,
4738 // which is `tol`: a strictly better point printed an `Overall
4739 // NLP error` above tolerance, and the opt-in
4740 // `kkt_fidelity_tol` gate below downgraded it.
4741 //
4742 // Only complementarity moves. Stationarity involves no
4743 // bounds, and the crossed-over point is *interior* to the
4744 // relaxed box, so its violation is zero under either reading.
4745 //
4746 // The substitution is confined to reporting. Crossover runs
4747 // after the status is decided, and it only ever installs a
4748 // point the never-regress gate accepted on the declared-bound
4749 // residuals, so this cannot dress up a worse iterate — the
4750 // measurement it replaces is the artifact.
4751 if let Some(report) = self.crossover_report.as_ref()
4752 && report.accepted()
4753 && report.compl_after.is_finite()
4754 {
4755 let compl_declared = report.compl_after;
4756 stats.final_compl = compl_declared;
4757 stats.final_kkt_error =
4758 cq.curr_nlp_error_with_complementarity(compl_declared, 0.0);
4759 stats.final_kkt_error_above_noise = cq.curr_nlp_error_with_complementarity(
4760 compl_declared,
4761 builder.conv_check.primal_noise_floor_kappa,
4762 );
4763 // Same unscaling as `curr_unscaled_complementarity_max`:
4764 // the slack's row factor and the multiplier's cancel in
4765 // the product, leaving the objective factor. Magnitude —
4766 // `obj_scaling_factor` is signed, `-1` being the
4767 // documented way to pose a maximization.
4768 let df = cq.obj_scaling_factor().abs();
4769 stats.final_unscaled_compl = if df == 0.0 || df == 1.0 {
4770 compl_declared
4771 } else {
4772 compl_declared / df
4773 };
4774 stats.final_unscaled_kkt_error = stats
4775 .final_unscaled_dual_inf
4776 .max(stats.final_unscaled_constr_viol)
4777 .max(stats.final_unscaled_compl);
4778 }
4779 }
4780 }
4781
4782 // Never report `Infeasible_Problem_Detected` while holding a point that
4783 // satisfies every constraint. The gates that produce this verdict argue
4784 // from a stalled feasibility sub-problem, and gh #379 is what that looks
4785 // like when the argument is wrong — a model whose own starting point is
4786 // exactly feasible, reported infeasible. See
4787 // `withdraw_infeasibility_if_refuted`.
4788 let solver_status =
4789 withdraw_infeasibility_if_refuted(&tnlp, solver_status, lo_inf, up_inf, tol);
4790
4791 // Map SolverReturn → ApplicationReturnStatus per
4792 // MAIN_LOOP.md's exception table, then apply the opt-in
4793 // status-fidelity gate (pounce#173).
4794 let app_status = self.apply_kkt_fidelity_gate(solver_return_to_app_status(solver_status));
4795
4796 // On convergence, fire the user-supplied callback (post-optimal
4797 // sensitivity hook, pounce#16) before flowing back through
4798 // `finalize_via_orig_nlp`. Borrowed handles into the converged
4799 // KKT state stay alive for the duration of the closure.
4800 if matches!(
4801 app_status,
4802 ApplicationReturnStatus::SolveSucceeded
4803 | ApplicationReturnStatus::SolvedToAcceptableLevel
4804 ) {
4805 if let Some(cb) = self.on_converged.as_mut() {
4806 if let Some(sd) = alg.search_dir.as_mut() {
4807 let pd = sd.pd_solver_rc();
4808 cb(&alg.data, &alg.cq, &nlp_handle, pd);
4809 }
4810 }
4811 }
4812
4813 // Finalize: forward the final iterate to the user's TNLP. The
4814 // returned objective is evaluated on the *user* TNLP at the
4815 // unscaled iterate, so it overrides the scaled best-effort
4816 // value stashed in `final_objective` above (the algorithm-side
4817 // `eval_f` returns `f * obj_scale_factor`).
4818 if solver_status != SolverReturn::InvalidProblemDefinition {
4819 match finalize_via_orig_nlp(
4820 &nlp_handle,
4821 &alg,
4822 solver_status,
4823 app_status,
4824 &tnlp,
4825 &self.last_finalize,
4826 ) {
4827 Ok(f_unscaled) => {
4828 self.statistics.borrow_mut().final_objective = f_unscaled;
4829 }
4830 Err(()) => {}
4831 }
4832 }
4833
4834 // End-of-solve timing report. Gated on `print_timing_statistics`
4835 // (default "no"); mirrors upstream's
4836 // `IpoptApplication::call_optimize` →
4837 // `IpTimingStatistics::PrintAllValues` call site. The report
4838 // goes to stdout (for parity with the banner / iter-row output
4839 // path) and is also fanned out to the journalist so an
4840 // `output_file` attached via `Initialize` picks it up.
4841 let print_timing = self
4842 .options
4843 .get_bool_value("print_timing_statistics", "")
4844 .ok()
4845 .and_then(|(v, f)| f.then_some(v))
4846 .unwrap_or(false);
4847 if print_timing {
4848 let report = timing.report();
4849 print!("{}", report);
4850 use pounce_common::journalist::{JournalCategory, JournalLevel};
4851 self.journalist.print(
4852 JournalLevel::J_SUMMARY,
4853 JournalCategory::J_TIMING_STATISTICS,
4854 &report,
4855 );
4856 }
4857
4858 // End-of-run summary, Ipopt-style, emitted last (after any timing
4859 // report) from the engine's own statistics (#206). Drains the eval
4860 // tallies into SolveStatistics (read AFTER finalize so the final
4861 // solution evaluation is included) and prints the summary, gated on
4862 // the same print_level as the rest of the console.
4863 self.emit_end_summary(app_status, &nlp_handle, console_output);
4864
4865 app_status
4866 }
4867
4868 /// Build an [`AlgorithmBuilder`] populated from the app's
4869 /// [`OptionsList`]. Public so callers wiring the restoration
4870 /// factory can hand the *inner* IPM a builder that mirrors the
4871 /// outer's `mu_strategy`/`mu_oracle`/line-search choices —
4872 /// matching upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`,
4873 /// which reads the same `mu_strategy` option with prefix `"resto."
4874 /// + prefix` and falls back to the outer setting.
4875 pub fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
4876 let mut builder = AlgorithmBuilder::new();
4877 // gh#857: share this application's escalation tally. Every
4878 // frontend builds the restoration provider's inner builder from
4879 // this method, so restoration escalations aggregate here too.
4880 builder.quality_escalation_counter = Some(Rc::clone(&self.quality_escalations));
4881
4882 // `mehrotra_algorithm` is parsed first so its cascading
4883 // defaults (mu_strategy=adaptive, mu_oracle=probing) can be
4884 // overridden by an explicit user setting of those keys
4885 // below. Mirrors `IpAlgBuilder.cpp:Mehrotra`.
4886 // `fast_step_computation` — skip the search-direction residual
4887 // check and allow an inexact linear solve. `PdSearchDirCalc` has
4888 // consumed this flag since it landed, hard-coded to `false`; the
4889 // option's read site was simply missing, so setting it did
4890 // nothing at all (gh#483 follow-up, #191 round 2).
4891 if let Ok((v, true)) = self.options.get_string_value("fast_step_computation", "") {
4892 builder.fast_step_computation = v.eq_ignore_ascii_case("yes");
4893 }
4894
4895 let mut mehrotra_on = false;
4896 if let Ok((v, found)) = self.options.get_string_value("mehrotra_algorithm", "") {
4897 if found && v == "yes" {
4898 mehrotra_on = true;
4899 builder.mehrotra_algorithm = true;
4900 builder.mu_strategy = MuStrategyChoice::Adaptive;
4901 builder.mu_oracle = crate::mu::adaptive::MuOracleKind::Probing;
4902 // `accept_every_trial_step` short-circuits the alpha
4903 // loop / filter — Mehrotra steps would otherwise be
4904 // rejected by the filter on LP-shaped problems because
4905 // the barrier objective is non-monotone along the
4906 // corrector. Mirrors upstream `IpAlgBuilder.cpp:Mehrotra`.
4907 builder.line_search.accept_every_trial_step = true;
4908 // Aggressive iterate-push defaults (`SetNumericValueIfUnset`
4909 // in upstream). The explicit user parses below will
4910 // overwrite these if the user set them explicitly.
4911 builder.init.bound_push = 10.0;
4912 builder.init.bound_frac = 0.2;
4913 builder.init.slack_bound_push = 10.0;
4914 builder.init.slack_bound_frac = 0.2;
4915 builder.init.bound_mult_init_val = 10.0;
4916 builder.init.constr_mult_init_max = 0.0;
4917 // `alpha_for_y=bound-mult` — Mehrotra wants the
4918 // equality multipliers to advance with the dual
4919 // alpha so they stay in step with z/v. Mirrors
4920 // upstream `IpIpoptAlg.cpp:InitializeImpl`.
4921 builder.line_search.alpha_for_y =
4922 crate::line_search::backtracking::AlphaForY::BoundMult;
4923 // `adaptive_mu_globalization=never-monotone-mode` —
4924 // upstream `IpIpoptAlg.cpp:148-154` enforces this:
4925 // Mehrotra disables the globalization switch entirely
4926 // (no fallback to monotone mode when convergence
4927 // stalls). Required for the unsafeguarded Mehrotra
4928 // path to function.
4929 builder.mu.adaptive_mu_globalization =
4930 crate::mu::adaptive::AdaptiveMuGlobalization::NeverMonotoneMode;
4931 // `least_square_init_primal=yes` — upstream
4932 // `IpIpoptAlg.cpp:182` enables this for the Mehrotra
4933 // cascade. Replaces the user's starting `x` with the
4934 // min-norm primal that satisfies the linearized
4935 // equality+inequality constraints. Critical on
4936 // LP-shaped problems where the user's starting point
4937 // can be wildly infeasible (e.g. nuffield2_trap).
4938 builder.init.least_square_init_primal = true;
4939 }
4940 }
4941
4942 if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
4943 if found {
4944 let parsed = match v.as_str() {
4945 "adaptive" => MuStrategyChoice::Adaptive,
4946 _ => MuStrategyChoice::Monotone,
4947 };
4948 if mehrotra_on && matches!(parsed, MuStrategyChoice::Monotone) {
4949 // Upstream Ipopt refuses this combination: Mehrotra
4950 // needs an affine step every iter, which only the
4951 // adaptive path computes. Keep adaptive and warn.
4952 tracing::warn!(target: "pounce::algorithm",
4953 "pounce: mehrotra_algorithm=yes requires \
4954 mu_strategy=adaptive; ignoring \
4955 mu_strategy=monotone."
4956 );
4957 } else {
4958 builder.mu_strategy = parsed;
4959 }
4960 }
4961 }
4962 if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
4963 if found {
4964 builder.mu_oracle = match v.as_str() {
4965 "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
4966 "probing" => crate::mu::adaptive::MuOracleKind::Probing,
4967 _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
4968 };
4969 }
4970 }
4971 if let Ok((v, found)) = self
4972 .options
4973 .get_string_value("adaptive_mu_globalization", "")
4974 {
4975 if found {
4976 use crate::mu::adaptive::AdaptiveMuGlobalization;
4977 builder.mu.adaptive_mu_globalization = match v.as_str() {
4978 "kkt-error" => AdaptiveMuGlobalization::KktError,
4979 "never-monotone-mode" => AdaptiveMuGlobalization::NeverMonotoneMode,
4980 _ => AdaptiveMuGlobalization::ObjConstrFilter,
4981 };
4982 }
4983 }
4984 if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
4985 if found {
4986 builder.hessian_approximation = match v.as_str() {
4987 "limited-memory" => HessianApproxChoice::LimitedMemory,
4988 "partitioned" => HessianApproxChoice::Partitioned,
4989 "finite-difference" => HessianApproxChoice::FiniteDifference,
4990 _ => HessianApproxChoice::Exact,
4991 };
4992 }
4993 }
4994 // **Upstream changes the `mu_strategy` default for a
4995 // limited-memory Hessian.** `IpAlgBuilder.cpp:1059`:
4996 //
4997 // if( !options.GetStringValue("mu_strategy", smuupdate, prefix) )
4998 // {
4999 // // Change default for quasi-Newton option (then we use adaptive)
5000 // ... if( hessian_approximation == LIMITED_MEMORY )
5001 // smuupdate = "adaptive";
5002 // }
5003 //
5004 // and again at `:920` for the restoration-phase algorithm.
5005 // Registered default is `monotone`; the quasi-Newton path takes
5006 // `adaptive` unless the caller says otherwise. pounce read the
5007 // registered default unconditionally, so every L-BFGS solve ran
5008 // a barrier schedule Ipopt does not use on that path — a
5009 // trajectory divergence on the arm the Python frontend and the
5010 // CasADi plugin select automatically (gh#746).
5011 //
5012 // The restoration sub-IPM inherits this: `run_inner_resto`
5013 // clones the configured `inner_alg_builder`, so the flag set
5014 // here is what the resto algorithm gets, matching `:920`.
5015 //
5016 // Only when unset — an explicit `mu_strategy` still wins, and
5017 // `mehrotra_algorithm` (parsed above) has already forced
5018 // adaptive on its own terms.
5019 if builder.hessian_approximation == HessianApproxChoice::LimitedMemory
5020 && !self.mu_strategy_was_set()
5021 {
5022 builder.mu_strategy = MuStrategyChoice::Adaptive;
5023 }
5024 // Limited-memory quasi-Newton update formula. Registered upstream
5025 // (`limited_memory_update_type`, IpLimMemQuasiNewtonUpdater.cpp) but
5026 // until now read nowhere on the IPM path — the updater was hard-wired
5027 // to Powell-damped BFGS. SR1 is honored too (the updater and the
5028 // low-rank/inertia path already handle its indefinite models).
5029 // Partitioned quasi-Newton knobs. `partitioned_update_type`
5030 // defaults to SR1 rather than BFGS — see
5031 // `crates::hess::partitioned_quasi_newton` for why damping is
5032 // the wrong choice on a per-constraint element.
5033 if let Ok((v, found)) = self.options.get_string_value("partitioned_update_type", "") {
5034 if found {
5035 builder.partitioned_update_type = match v.as_str() {
5036 "bfgs" => UpdateType::Bfgs,
5037 _ => UpdateType::Sr1,
5038 };
5039 builder.partitioned_update_type_was_set = true;
5040 }
5041 }
5042 if let Ok((v, found)) = self
5043 .options
5044 .get_integer_value("partitioned_max_element", "")
5045 {
5046 if found && v > 0 {
5047 builder.partitioned_max_element = v as usize;
5048 }
5049 }
5050 if let Ok((v, found)) = self.options.get_string_value("fd_hessian_pattern", "") {
5051 if found {
5052 builder.fd_hessian_pattern = match v.as_str() {
5053 "jacobian" => crate::hess::fd_hessian::FdPatternSource::Jacobian,
5054 _ => crate::hess::fd_hessian::FdPatternSource::Declared,
5055 };
5056 }
5057 }
5058 if let Ok((v, found)) = self.options.get_string_value("fd_hessian_coloring", "") {
5059 if found {
5060 builder.fd_hessian_coloring = match v.as_str() {
5061 "cpr" => crate::hess::fd_hessian::FdColoring::Cpr,
5062 _ => crate::hess::fd_hessian::FdColoring::Star,
5063 };
5064 }
5065 }
5066 if let Ok((v, found)) = self.options.get_numeric_value("fd_hessian_reuse_tol", "") {
5067 if found && v >= 0.0 {
5068 builder.fd_hessian_reuse_tol = v;
5069 }
5070 }
5071 if let Ok((v, found)) = self.options.get_string_value("partitioned_elements", "") {
5072 if found {
5073 builder.partitioned_elements = match v.as_str() {
5074 "blocks" => crate::hess::partitioned_quasi_newton::ElementMode::PrimalBlock,
5075 _ => crate::hess::partitioned_quasi_newton::ElementMode::PerConstraint,
5076 };
5077 }
5078 }
5079 if let Ok((v, found)) = self.options.get_integer_value("partitioned_block_size", "") {
5080 if found && v > 0 {
5081 builder.partitioned_block_size = v as usize;
5082 }
5083 }
5084 if let Ok((v, found)) = self
5085 .options
5086 .get_numeric_value("partitioned_curvature_cap", "")
5087 {
5088 if found && v > 0.0 {
5089 builder.partitioned_curvature_cap = v;
5090 }
5091 }
5092 if let Ok((v, found)) = self
5093 .options
5094 .get_string_value("limited_memory_update_type", "")
5095 {
5096 if found {
5097 builder.limited_memory_update_type = match v.as_str() {
5098 "sr1" => UpdateType::Sr1,
5099 _ => UpdateType::Bfgs,
5100 };
5101 }
5102 }
5103 // Limited-memory history length (`limited_memory_max_history`).
5104 if let Ok((v, found)) = self
5105 .options
5106 .get_integer_value("limited_memory_max_history", "")
5107 {
5108 if found && v >= 0 {
5109 builder.limited_memory_max_history = v as Index;
5110 }
5111 }
5112 // `limited_memory_initialization` — which formula picks the
5113 // initial Hessian scalar σ. Registered since the option port with
5114 // upstream's `scalar1` default and read nowhere until #677, so
5115 // the updater's own `Scalar2` default was the only value any
5116 // solve ever used: setting the option did nothing, and it warned
5117 // nothing. Same miss as gh#483 / #191 round 2 (which wired
5118 // `limited_memory_init_val_max`/`_min`) — this is the third
5119 // argument to that same `initial_hessian_scalar` call.
5120 //
5121 // The effective default now follows the registry (`scalar1`),
5122 // matching Ipopt. σ_scalar2/σ_scalar1 = (yᵀy·sᵀs)/(sᵀy)² ≥ 1 and
5123 // is unbounded as the curvature pair degrades, so on an
5124 // ill-conditioned problem `scalar2` inflates `B0 = σI` until it
5125 // swamps the rank-2 corrections and the step collapses.
5126 if let Ok((v, found)) = self
5127 .options
5128 .get_string_value("limited_memory_initialization", "")
5129 {
5130 if found {
5131 use crate::hess::lim_mem_quasi_newton::InitialApprox;
5132 // Every registered value is named explicitly. #551 §3
5133 // held this option back precisely because wiring only
5134 // the values that mapped would leave the rest falling
5135 // back silently — a new no-op created by the fix — so a
5136 // catch-all standing in for a real value is the one
5137 // shape to avoid here. `OptionsList` rejects any
5138 // unregistered value before this runs (`options_list.rs`
5139 // `OPTION_INVALID`), so the final arm is unreachable
5140 // rather than a fallback.
5141 builder.limited_memory_initialization = match v.as_str() {
5142 "scalar1" => InitialApprox::Scalar1,
5143 "scalar2" => InitialApprox::Scalar2,
5144 "scalar3" => InitialApprox::Scalar3,
5145 "scalar4" => InitialApprox::Scalar4,
5146 "constant" => InitialApprox::Constant,
5147 "history-max" => InitialApprox::HistoryMax,
5148 _ => InitialApprox::Scalar2,
5149 };
5150 }
5151 }
5152 // `recalc_y` / `recalc_y_feas_tol` — least-square re-estimation
5153 // of the equality multipliers once feasible (#677).
5154 //
5155 // Upstream registers `recalc_y` as `no`, but its own option text
5156 // ends "If a limited memory quasi-Newton option is chosen, this
5157 // is used by default", so upstream's effective default is
5158 // conditional on the Hessian approximation.
5159 //
5160 // **pounce does not follow that, and the discrepancy is
5161 // deliberate.** Auto-enabling it for the limited-memory path was
5162 // implemented and measured against the fixture corpus first: it
5163 // moved 16 of 57 fixtures on the L-BFGS leg and took **7 from
5164 // solved to not solved, with nothing moving the other way** —
5165 // `airport` 56 it → `SearchDirectionBecomesTooSmall` at 541,
5166 // `pooling_rt2stp` 413 it → the same at 1775, all three `jit1`
5167 // variants, `linear_eq_collapsed_box`, and `hs13_bigstart` to
5168 // the iteration cap. The signature is consistent: re-estimating
5169 // `y` on every feasible iteration overwrites Newton multipliers
5170 // that were converging, the dual never settles, and the step
5171 // vanishes short of the certificate.
5172 //
5173 // So the feature is available and off by default. That still
5174 // closes the gap that mattered — until #677 the option was
5175 // refused outright as unimplemented, so an L-BFGS user could not
5176 // reach Ipopt's behaviour at all. They can now, by asking.
5177 //
5178 // Why it is worth having: a quasi-Newton model's dual step is
5179 // computed from an approximate `W`, so L-BFGS can settle a
5180 // feasible primal and still not drive `inf_du` to tolerance —
5181 // the failure a 59,939-variable CasADi model hit, oscillating
5182 // `inf_du` between 3.6e-3 and 1.8e+01 for 300 iterations with
5183 // the objective already settled. On that shape it is the fix;
5184 // on a corpus of small well-conditioned models it is a
5185 // pessimisation. Matching upstream's conditional default needs
5186 // to explain the 7 regressions first.
5187 if let Ok((v, true)) = self.options.get_string_value("recalc_y", "") {
5188 builder.recalc_y = v == "yes";
5189 }
5190 if let Ok((v, true)) = self.options.get_numeric_value("recalc_y_feas_tol", "") {
5191 builder.recalc_y_feas_tol = v;
5192 }
5193 // `limited_memory_init_val` — σ before any curvature pair exists,
5194 // and every iteration under `constant`. Also unread until #677;
5195 // the empty-history branch hard-coded the same `1.0`.
5196 if let Ok((v, true)) = self
5197 .options
5198 .get_numeric_value("limited_memory_init_val", "")
5199 {
5200 builder.limited_memory_init_val = v;
5201 }
5202 // `limited_memory_max_skipping` (#686) — registered and unread,
5203 // and the feature behind it did not exist either: the updater
5204 // counted nothing and never discarded its history.
5205 if let Ok((v, true)) = self
5206 .options
5207 .get_integer_value("limited_memory_max_skipping", "")
5208 {
5209 if v >= 0 {
5210 builder.limited_memory_max_skipping = v as Index;
5211 }
5212 }
5213 if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
5214 if found {
5215 builder.line_search_method = match v.as_str() {
5216 "cg-penalty" => LineSearchChoice::CgPenalty,
5217 "penalty" => LineSearchChoice::Penalty,
5218 _ => LineSearchChoice::Filter,
5219 };
5220 }
5221 }
5222 // `accept_every_trial_step` — direct user override. Parsed
5223 // after the Mehrotra cascade so an explicit `no` still wins.
5224 if let Ok((v, found)) = self.options.get_string_value("accept_every_trial_step", "") {
5225 if found {
5226 builder.line_search.accept_every_trial_step = v == "yes";
5227 }
5228 }
5229 // `alpha_for_y` — direct user override. Parsed after the
5230 // Mehrotra cascade so an explicit value still wins.
5231 if let Ok((v, found)) = self.options.get_string_value("alpha_for_y", "") {
5232 if found {
5233 use crate::line_search::backtracking::AlphaForY;
5234 builder.line_search.alpha_for_y = match v.as_str() {
5235 "primal" => AlphaForY::Primal,
5236 "bound-mult" | "bound_mult" => AlphaForY::BoundMult,
5237 "full" => AlphaForY::Full,
5238 "min" => AlphaForY::Min,
5239 "max" => AlphaForY::Max,
5240 "primal-and-full" | "dual-and-full" => AlphaForY::Primal,
5241 _ => AlphaForY::Primal,
5242 };
5243 }
5244 }
5245 // `nlp_scaling_method` is consumed NLP-side in
5246 // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
5247 // `determine_scaling_from_starting_point` call earlier in this
5248 // method); there is no algorithm-side scaling strategy to wire.
5249 // `limited_memory_init_val_max` / `_min` — the clamp on the
5250 // initial Hessian scalar. `LimMemQuasiNewtonUpdater` consumes
5251 // both in `initial_hessian_scalar`; only the read sites were
5252 // missing, so setting either did nothing (gh#483, #191 round 2).
5253 if let Ok((v, true)) = self
5254 .options
5255 .get_numeric_value("limited_memory_init_val_max", "")
5256 {
5257 builder.limited_memory_init_val_max = v;
5258 }
5259 if let Ok((v, true)) = self
5260 .options
5261 .get_numeric_value("limited_memory_init_val_min", "")
5262 {
5263 builder.limited_memory_init_val_min = v;
5264 }
5265
5266 // Unlike the other options here, we always honor the registry
5267 // value (not just when the user set it explicitly): the option
5268 // registry default is "ma57" but `AlgorithmBuilder::default`
5269 // has `linear_solver: Feral`, so gating on `found` would
5270 // silently route default runs through Feral while the banner
5271 // (and ipopt-compatible behavior) advertises MA57.
5272 //
5273 // Record the **effective** backend, not the requested one. MA57 lives
5274 // behind the optional `ma57` cargo feature (HSL is licensed and needs a
5275 // Fortran toolchain); without it `default_backend_factory` silently
5276 // substitutes FERAL. Storing `Ma57` here therefore made
5277 // `builder.linear_solver` disagree with the backend actually built, and
5278 // consumers acted on the lie: the Schur KKT gate in
5279 // `alg_builder::build_with_backend` tests `== Feral`, so on the
5280 // pure-Rust default build — where the registry default (then upstream's
5281 // "ma57") resolved to FERAL anyway — `set_kkt_schur_block()` silently
5282 // never engaged for ANY user. Resolving here keeps the field truthful
5283 // for every consumer.
5284 //
5285 // The `_ =>` arm is now only reachable for `feral`: every other name
5286 // is refused up front by `unimplemented_linear_solver`. It used to
5287 // swallow `mumps`, `pardiso`, `ma97`, … and run FERAL instead.
5288 if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
5289 let requested = if v.eq_ignore_ascii_case("ma57") {
5290 LinearSolverChoice::Ma57
5291 } else {
5292 LinearSolverChoice::Feral
5293 };
5294 builder.linear_solver =
5295 if matches!(requested, LinearSolverChoice::Ma57) && !cfg!(feature = "ma57") {
5296 LinearSolverChoice::Feral
5297 } else {
5298 requested
5299 };
5300 }
5301
5302 // `linear_system_scaling` — symmetric scaling of the augmented
5303 // KKT matrix before factorization. Port of
5304 // `IpTSymLinearSolver.cpp:RegisterOptions` plumbing. Default
5305 // "none"; "ruiz" invokes the Ruiz-2001 symmetric ∞-norm
5306 // equilibration in `RuizTSymScalingMethod`. "mc19" and
5307 // "slack-based" are accepted by the registry but not yet
5308 // implemented at this layer; they fall back to no scaling
5309 // with a one-line notice.
5310 //
5311 // `slack-based` is implemented as of #677. It used to reach the
5312 // no-scaling fallback through the catch-all arm, which meant it
5313 // fell back **silently** — the comment above promised a notice
5314 // that only `mc19` actually emitted. It is not a hypothetical
5315 // value: it is what Ipopt's own recommended configuration for
5316 // large collocation NLPs uses, so the users most likely to set
5317 // it were the least likely to be told it did nothing. The
5318 // catch-all is left for genuinely unreachable input —
5319 // `OptionsList` rejects anything the registry does not list.
5320 if let Ok((v, found)) = self.options.get_string_value("linear_system_scaling", "") {
5321 if found {
5322 builder.linear_system_scaling = match v.as_str() {
5323 "ruiz" => crate::alg_builder::LinearSystemScalingChoice::Ruiz,
5324 "mc19" => crate::alg_builder::LinearSystemScalingChoice::Mc19,
5325 "slack-based" => crate::alg_builder::LinearSystemScalingChoice::SlackBased,
5326 _ => crate::alg_builder::LinearSystemScalingChoice::None,
5327 };
5328 }
5329 }
5330 if let Ok((v, found)) = self.options.get_bool_value("linear_scaling_on_demand", "") {
5331 if found {
5332 builder.linear_scaling_on_demand = v;
5333 }
5334 }
5335
5336 // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
5337 // `RegisterOptions` consumers). Defaults already match upstream
5338 // — only override when the user set the key explicitly.
5339 let read_num = |key: &str| -> Option<f64> {
5340 self.options
5341 .get_numeric_value(key, "")
5342 .ok()
5343 .and_then(|(v, f)| f.then_some(v))
5344 };
5345 let read_int = |key: &str| -> Option<i32> {
5346 self.options
5347 .get_integer_value(key, "")
5348 .ok()
5349 .and_then(|(v, f)| f.then_some(v))
5350 };
5351 if let Some(v) = read_num("tol") {
5352 builder.conv_check.tol = v;
5353 }
5354 if let Some(v) = read_num("obj_scale_certificate_threshold") {
5355 builder.conv_check.obj_scale_certificate_threshold = v;
5356 }
5357 if let Some(v) = read_num("primal_noise_floor_kappa") {
5358 builder.conv_check.primal_noise_floor_kappa = v;
5359 }
5360 if let Some(v) = read_num("acceptable_progress_kappa") {
5361 builder.conv_check.acceptable_progress_kappa = v;
5362 }
5363 if let Some(v) = read_num("dual_inf_scale_kappa") {
5364 builder.conv_check.dual_inf_scale_kappa = v;
5365 }
5366 if let Some(v) = read_num("kkt_fidelity_tol") {
5367 builder.kkt_fidelity_tol = v;
5368 }
5369 if let Some(v) = read_num("dual_inf_tol") {
5370 builder.conv_check.dual_inf_tol = v;
5371 }
5372 if let Some(v) = read_num("constr_viol_tol") {
5373 builder.conv_check.constr_viol_tol = v;
5374 }
5375 if let Some(v) = read_num("compl_inf_tol") {
5376 builder.conv_check.compl_inf_tol = v;
5377 }
5378 if let Some(v) = read_int("max_iter") {
5379 builder.conv_check.max_iter = v;
5380 }
5381 if let Some(v) = read_num("max_cpu_time") {
5382 builder.conv_check.max_cpu_time = v;
5383 }
5384 if let Some(v) = read_num("max_wall_time") {
5385 builder.conv_check.max_wall_time = v;
5386 }
5387 if let Some(v) = read_num("acceptable_tol") {
5388 builder.conv_check.acceptable_tol = v;
5389 }
5390 if let Some(v) = read_num("acceptable_dual_inf_tol") {
5391 builder.conv_check.acceptable_dual_inf_tol = v;
5392 }
5393 if let Some(v) = read_num("acceptable_constr_viol_tol") {
5394 builder.conv_check.acceptable_constr_viol_tol = v;
5395 }
5396 if let Some(v) = read_num("acceptable_compl_inf_tol") {
5397 builder.conv_check.acceptable_compl_inf_tol = v;
5398 }
5399 if let Some(v) = read_num("acceptable_obj_change_tol") {
5400 builder.conv_check.acceptable_obj_change_tol = v;
5401 }
5402 if let Some(v) = read_int("acceptable_iter") {
5403 builder.conv_check.acceptable_iter = v;
5404 }
5405 if let Some(v) = read_num("infeas_stationarity_tol") {
5406 builder.conv_check.infeas_stationarity_tol = v;
5407 }
5408 if let Some(v) = read_num("infeas_viol_kappa") {
5409 builder.conv_check.infeas_viol_kappa = v;
5410 }
5411 if let Some(v) = read_int("infeas_max_streak") {
5412 builder.conv_check.infeas_max_streak = v;
5413 }
5414
5415 // Bound-multiplier / barrier damping constants (#191). Both were
5416 // registered but never read, so user overrides were silently
5417 // dropped; the algorithm ran with the hard-coded struct defaults.
5418 // Defaults equal the registered defaults, so this changes nothing
5419 // for a run that doesn't set them.
5420 if let Some(v) = read_num("kappa_sigma") {
5421 builder.kappa_sigma = v;
5422 }
5423 if let Some(v) = read_num("kappa_d") {
5424 builder.kappa_d = v;
5425 }
5426 // `s_max` — the cap in the `(s_d, s_c)` scaling of the KKT error
5427 // test (#551 / #677). `IpoptCalculatedQuantities` carried it as a
5428 // hard-coded 100 (the registered default) and nothing read the
5429 // option; a run that does not set it is unaffected.
5430 if let Some(v) = read_num("s_max") {
5431 builder.s_max = v;
5432 }
5433 if let Some(v) = read_num("tiny_step_tol") {
5434 builder.tiny_step_tol = v;
5435 }
5436 if let Some(v) = read_num("tiny_step_y_tol") {
5437 builder.tiny_step_y_tol = v;
5438 }
5439 if let Some(v) = read_num("diverging_iterates_tol") {
5440 builder.diverging_iterates_tol = v;
5441 }
5442 if let Some(v) = read_int("dual_diverging_streak") {
5443 builder.dual_diverging_streak = v;
5444 }
5445 if let Some(v) = read_num("dual_divergence_retry_step_tol") {
5446 builder.dual_divergence_retry_step_tol = v;
5447 }
5448 if let Some(v) = read_num("dual_divergence_retry_du_floor") {
5449 builder.dual_divergence_retry_du_floor = v;
5450 }
5451 if let Some(v) = read_int("resto_decline_deferrals") {
5452 builder.resto_decline_deferrals = v;
5453 }
5454 if let Some(v) = read_num("resto_decline_progress_ratio") {
5455 builder.resto_decline_progress_ratio = v;
5456 }
5457 if let Some(v) = read_int("neg_curv_escapes") {
5458 builder.neg_curv_escapes = v;
5459 }
5460 if let Some(v) = read_int("limited_memory_ls_failure_restarts") {
5461 builder.limited_memory_ls_failure_restarts = v;
5462 }
5463
5464 // Barrier-parameter (μ) options — consumers in
5465 // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
5466 // updaters share the same option names; the builder forwards
5467 // each into whichever strategy is assembled.
5468 if let Some(v) = read_num("mu_init") {
5469 builder.mu.mu_init = v;
5470 }
5471 if let Some(v) = read_num("mu_max") {
5472 builder.mu.mu_max = v;
5473 }
5474 if let Some(v) = read_num("mu_max_fact") {
5475 builder.mu.mu_max_fact = v;
5476 }
5477 if let Some(v) = read_num("mu_min") {
5478 builder.mu.mu_min = v;
5479 }
5480 if let Some(v) = read_num("mu_target") {
5481 builder.mu.mu_target = v;
5482 }
5483 if let Some(v) = read_num("mu_linear_decrease_factor") {
5484 builder.mu.mu_linear_decrease_factor = v;
5485 }
5486 if let Some(v) = read_num("mu_superlinear_decrease_power") {
5487 builder.mu.mu_superlinear_decrease_power = v;
5488 }
5489 if let Ok((v, found)) = self
5490 .options
5491 .get_string_value("mu_allow_fast_monotone_decrease", "")
5492 {
5493 if found {
5494 builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
5495 }
5496 }
5497 if let Some(v) = read_num("barrier_tol_factor") {
5498 builder.mu.barrier_tol_factor = v;
5499 }
5500 // `tau_min` — floor on the fraction-to-the-boundary parameter
5501 // (#551 / #677). Both `MonotoneMuUpdate` and `AdaptiveMuUpdate`
5502 // carried the field with upstream's 0.99 default and nothing
5503 // read the option, so an override was silently dropped. The
5504 // default equals the registered default, so this changes
5505 // nothing for a run that does not set it.
5506 if let Some(v) = read_num("tau_min") {
5507 builder.mu.tau_min = v;
5508 }
5509 if let Some(v) = read_num("sigma_max") {
5510 builder.mu.sigma_max = v;
5511 }
5512 if let Some(v) = read_num("sigma_min") {
5513 builder.mu.sigma_min = v;
5514 }
5515
5516 // Quality-function oracle knobs — consumers in
5517 // `IpQualityFunctionMuOracle.cpp:RegisterOptions`. Forwarded
5518 // to the oracle on every free-mode call.
5519 if let Ok((v, found)) = self
5520 .options
5521 .get_string_value("quality_function_norm_type", "")
5522 {
5523 if found {
5524 use crate::mu::oracle::quality_function::NormType;
5525 builder.mu.quality_function_norm_type = match v.as_str() {
5526 "1-norm" => NormType::OneNorm,
5527 "2-norm" => NormType::TwoNorm,
5528 "max-norm" => NormType::MaxNorm,
5529 _ => NormType::TwoNormSquared,
5530 };
5531 }
5532 }
5533 if let Ok((v, found)) = self
5534 .options
5535 .get_string_value("quality_function_centrality", "")
5536 {
5537 if found {
5538 use crate::mu::oracle::quality_function::CentralityType;
5539 builder.mu.quality_function_centrality = match v.as_str() {
5540 "log" => CentralityType::LogCenter,
5541 "reciprocal" => CentralityType::ReciprocalCenter,
5542 "cubed-reciprocal" => CentralityType::CubedReciprocalCenter,
5543 _ => CentralityType::None,
5544 };
5545 }
5546 }
5547 if let Ok((v, found)) = self
5548 .options
5549 .get_string_value("quality_function_balancing_term", "")
5550 {
5551 if found {
5552 use crate::mu::oracle::quality_function::BalancingTermType;
5553 builder.mu.quality_function_balancing_term = match v.as_str() {
5554 "cubic" => BalancingTermType::CubicTerm,
5555 _ => BalancingTermType::None,
5556 };
5557 }
5558 }
5559 if let Some(v) = read_int("quality_function_max_section_steps") {
5560 builder.mu.quality_function_max_section_steps = v;
5561 }
5562 if let Some(v) = read_num("quality_function_section_sigma_tol") {
5563 builder.mu.quality_function_section_sigma_tol = v;
5564 }
5565 if let Some(v) = read_num("quality_function_section_qf_tol") {
5566 builder.mu.quality_function_section_qf_tol = v;
5567 }
5568
5569 // `probing_iterate_quality_factor` — pounce-specific guard
5570 // (pounce#58) on the probing μ-oracle's input iterate. When
5571 // `curr_avrg_compl / curr_mu` exceeds this factor, the
5572 // μ-update layer signals restoration via
5573 // `IpoptData::request_resto` instead of letting probing
5574 // return `σ · mu_curr` ≫ previous μ. Default 1e4; set to ≤ 0
5575 // to disable. No upstream Ipopt counterpart.
5576 if let Some(v) = read_num("probing_iterate_quality_factor") {
5577 builder.mu.probing_iterate_quality_factor = v;
5578 }
5579
5580 // Adaptive-μ extras — consumers in
5581 // `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Only active when
5582 // `mu_strategy=adaptive`.
5583 if let Some(v) = read_num("adaptive_mu_safeguard_factor") {
5584 builder.mu.adaptive_mu_safeguard_factor = v;
5585 }
5586 if let Some(v) = read_num("adaptive_mu_monotone_init_factor") {
5587 builder.mu.adaptive_mu_monotone_init_factor = v;
5588 }
5589 if let Ok((v, found)) = self
5590 .options
5591 .get_bool_value("adaptive_mu_restore_previous_iterate", "")
5592 {
5593 if found {
5594 builder.mu.adaptive_mu_restore_previous_iterate = v;
5595 }
5596 }
5597 if let Some(v) = read_int("adaptive_mu_max_free_returns") {
5598 builder.mu.adaptive_mu_max_free_returns = v;
5599 }
5600 if let Some(v) = read_num("adaptive_mu_budget_pin_fraction") {
5601 builder.mu.adaptive_mu_budget_pin_fraction = v;
5602 }
5603 if let Some(v) = read_int("adaptive_mu_kkterror_red_iters") {
5604 if v >= 0 {
5605 builder.mu.adaptive_mu_kkterror_red_iters = v as usize;
5606 }
5607 }
5608 if let Some(v) = read_num("adaptive_mu_kkterror_red_fact") {
5609 builder.mu.adaptive_mu_kkterror_red_fact = v;
5610 }
5611 // `filter_margin_fact` / `filter_max_margin` (#551) — the margin
5612 // an entry must clear in the `obj-constr-filter` globalization
5613 // test. `AdaptiveMuUpdate` computes
5614 // `filter_margin_fact * min(filter_max_margin, err)` and has
5615 // always done so; only these two read sites were missing, so
5616 // setting either did nothing. Defaults equal the registered
5617 // defaults (1e-5 / 1.0), so an unset run is unchanged.
5618 if let Some(v) = read_num("filter_margin_fact") {
5619 builder.mu.filter_margin_fact = v;
5620 }
5621 if let Some(v) = read_num("filter_max_margin") {
5622 builder.mu.filter_max_margin = v;
5623 }
5624 if let Ok((v, found)) = self
5625 .options
5626 .get_string_value("adaptive_mu_kkt_norm_type", "")
5627 {
5628 if found {
5629 use crate::mu::adaptive::AdaptiveMuKktNorm;
5630 builder.mu.adaptive_mu_kkt_norm_type = match v.as_str() {
5631 "1-norm" => AdaptiveMuKktNorm::OneNorm,
5632 "2-norm" => AdaptiveMuKktNorm::TwoNorm,
5633 "max-norm" => AdaptiveMuKktNorm::MaxNorm,
5634 _ => AdaptiveMuKktNorm::TwoNormSquared,
5635 };
5636 }
5637 }
5638
5639 // Watchdog options — consumers in
5640 // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
5641 // the `BacktrackingLineSearch` at build time.
5642 if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
5643 builder.line_search.watchdog_shortened_iter_trigger = v;
5644 }
5645 if let Some(v) = read_int("watchdog_trial_iter_max") {
5646 builder.line_search.watchdog_trial_iter_max = v;
5647 }
5648 if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
5649 builder.line_search.soft_resto_pderror_reduction_factor = v;
5650 }
5651 if let Some(v) = read_int("max_soft_resto_iters") {
5652 builder.line_search.max_soft_resto_iters = v;
5653 }
5654 // `alpha_red_factor` (#678) and `accept_after_max_steps` (#551)
5655 // — both consumed by the α-loop in `BacktrackingLineSearch`,
5656 // both registered without a read site until those issues.
5657 // `alpha_red_factor`'s default (0.5) equals the registered one,
5658 // and `accept_after_max_steps` defaults to `-1`, which disables
5659 // the escape hatch, so neither moves a solve that leaves them
5660 // alone.
5661 if let Some(v) = read_num("alpha_red_factor") {
5662 builder.line_search.alpha_red_factor = v;
5663 }
5664 if let Some(v) = read_num("alpha_red_factor_min") {
5665 builder.line_search.alpha_red_factor_min = Some(v);
5666 }
5667 if let Some(v) = read_int("accept_after_max_steps") {
5668 builder.line_search.accept_after_max_steps = v;
5669 }
5670
5671 // Filter switching / Armijo / margin constants (#191). Consumed
5672 // by `FilterLsAcceptor` (only on the `Filter` line-search path);
5673 // registered but never read, so overrides were silently dropped.
5674 // Defaults equal the registered defaults.
5675 if let Some(v) = read_num("eta_phi") {
5676 builder.line_search.eta_phi = v;
5677 }
5678 // `delta` (#551) — the switching rule's multiplier on the
5679 // constraint violation (Eqn. (19)); `FilterLsAcceptor` has
5680 // always used it as `delta_armijo`, only the read site was
5681 // missing. Default 1.0 equals the registered default.
5682 if let Some(v) = read_num("delta") {
5683 builder.line_search.delta = v;
5684 }
5685 if let Some(v) = read_num("theta_min_fact") {
5686 builder.line_search.theta_min_fact = v;
5687 }
5688 if let Some(v) = read_num("theta_max_row_scale_kappa") {
5689 builder.line_search.theta_max_row_scale_kappa = v;
5690 }
5691 if let Some(v) = read_int("theta_max_adaptive_trigger") {
5692 builder.line_search.theta_max_adaptive_trigger = v.max(0) as u32;
5693 }
5694 if let Some(v) = read_num("theta_max_adaptive_factor") {
5695 builder.line_search.theta_max_adaptive_factor = v;
5696 }
5697 if let Some(v) = read_int("theta_max_adaptive_max_raises") {
5698 builder.line_search.theta_max_adaptive_max_raises = v.max(0) as u32;
5699 }
5700 if let Some(v) = read_num("theta_max_fact") {
5701 builder.line_search.theta_max_fact = v;
5702 }
5703 if let Some(v) = read_num("gamma_phi") {
5704 builder.line_search.gamma_phi = v;
5705 }
5706 if let Some(v) = read_num("gamma_theta") {
5707 builder.line_search.gamma_theta = v;
5708 }
5709 if let Some(v) = read_num("s_phi") {
5710 builder.line_search.s_phi = v;
5711 }
5712 if let Some(v) = read_num("s_theta") {
5713 builder.line_search.s_theta = v;
5714 }
5715 if let Some(v) = read_num("alpha_min_frac") {
5716 builder.line_search.alpha_min_frac = v;
5717 }
5718 if let Some(v) = read_num("obj_max_inc") {
5719 builder.line_search.obj_max_inc = v;
5720 }
5721 if let Some(v) = read_int("max_filter_resets") {
5722 builder.line_search.max_filter_resets = v;
5723 }
5724 if let Some(v) = read_int("filter_reset_trigger") {
5725 builder.line_search.filter_reset_trigger = v;
5726 }
5727 // Penalty line-search constants (#551), consumed by
5728 // `PenaltyLsAcceptor` (only on the `line_search_method=penalty`
5729 // / `cg-penalty` paths). The acceptor implements ν and the
5730 // Armijo test on the penalty merit function already; these four
5731 // were registered with no read site, so tuning the penalty
5732 // update did nothing. Defaults equal the registered defaults.
5733 if let Some(v) = read_num("nu_init") {
5734 builder.line_search.nu_init = v;
5735 }
5736 if let Some(v) = read_num("nu_inc") {
5737 builder.line_search.nu_inc = v;
5738 }
5739 if let Some(v) = read_num("rho") {
5740 builder.line_search.rho = v;
5741 }
5742 if let Some(v) = read_num("eta_penalty") {
5743 builder.line_search.eta_penalty = v;
5744 }
5745
5746 // Second-order-correction constants (#191), consumed by
5747 // `BacktrackingLineSearch`. `max_soc = 0` disables SOC.
5748 if let Some(v) = read_int("max_soc") {
5749 builder.line_search.max_soc = v;
5750 }
5751 if let Some(v) = read_num("kappa_soc") {
5752 builder.line_search.kappa_soc = v;
5753 }
5754 if let Some(v) = read_int("soc_method") {
5755 builder.line_search.soc_method = v;
5756 }
5757
5758 // Inertia-correction / Jacobian-regularization constants (#191),
5759 // consumed by `PdPerturbationHandler`. Registered but never read.
5760 if let Some(v) = read_num("max_hessian_perturbation") {
5761 builder.perturbation.max_hessian_perturbation = v;
5762 }
5763 if let Some(v) = read_num("min_hessian_perturbation") {
5764 builder.perturbation.min_hessian_perturbation = v;
5765 }
5766 if let Some(v) = read_num("perturb_inc_fact_first") {
5767 builder.perturbation.perturb_inc_fact_first = v;
5768 }
5769 if let Some(v) = read_num("perturb_inc_fact") {
5770 builder.perturbation.perturb_inc_fact = v;
5771 }
5772 if let Some(v) = read_num("perturb_dec_fact") {
5773 builder.perturbation.perturb_dec_fact = v;
5774 }
5775 if let Some(v) = read_num("first_hessian_perturbation") {
5776 builder.perturbation.first_hessian_perturbation = v;
5777 }
5778 if let Some(v) = read_num("jacobian_regularization_value") {
5779 builder.perturbation.jacobian_regularization_value = v;
5780 }
5781 if let Some(v) = read_num("jacobian_regularization_exponent") {
5782 builder.perturbation.jacobian_regularization_exponent = v;
5783 }
5784 if let Ok((v, true)) = self.options.get_bool_value("perturb_always_cd", "") {
5785 builder.perturbation.perturb_always_cd = v;
5786 }
5787 if let Some(v) = read_int("perturb_delta_c_max_rungs") {
5788 builder.perturbation.perturb_delta_c_max_rungs = v;
5789 }
5790
5791 // Iterative-refinement constants (#191), consumed by
5792 // `PdFullSpaceSolver`. Registered but never read.
5793 if let Some(v) = read_int("min_refinement_steps") {
5794 builder.refinement.min_refinement_steps = v;
5795 }
5796 if let Some(v) = read_int("max_refinement_steps") {
5797 builder.refinement.max_refinement_steps = v;
5798 }
5799 if let Some(v) = read_num("residual_ratio_max") {
5800 builder.refinement.residual_ratio_max = v;
5801 }
5802 if let Some(v) = read_num("residual_ratio_singular") {
5803 builder.refinement.residual_ratio_singular = v;
5804 }
5805 if let Some(v) = read_num("residual_improvement_factor") {
5806 builder.refinement.residual_improvement_factor = v;
5807 }
5808
5809 // Inertia-free curvature test (#551 / #677), also consumed by
5810 // `PdFullSpaceSolver`. `neg_curv_test_tol` had a field that only
5811 // ever held its 0.0 default, and `neg_curv_test_reg` had none at
5812 // all; both are now read, and the curvature test they configure
5813 // is implemented in `PdFullSpaceSolver::solve_once`. At the
5814 // registered default (`0.0`) the heuristic is off and the
5815 // inertia check runs as before.
5816 if let Some(v) = read_num("neg_curv_test_tol") {
5817 builder.refinement.neg_curv_test_tol = v;
5818 }
5819 if let Ok((v, true)) = self.options.get_bool_value("neg_curv_test_reg", "") {
5820 builder.refinement.neg_curv_test_reg = v;
5821 }
5822
5823 // Restoration-phase constants (#191). Carried on the outer builder
5824 // and copied into the `RestoAlgorithmBuilder` when the restoration
5825 // factory is minted (the frontends pass this builder in). The
5826 // restoration builder was never options-configured, so these were
5827 // registered but never read. Defaults equal the registered
5828 // defaults.
5829 if let Some(v) = read_num("bound_mult_reset_threshold") {
5830 builder.resto.bound_mult_reset_threshold = v;
5831 }
5832 if let Some(v) = read_num("constr_mult_reset_threshold") {
5833 builder.resto.constr_mult_reset_threshold = v;
5834 }
5835 if let Some(v) = read_num("resto_penalty_parameter") {
5836 builder.resto.resto_penalty_parameter = v;
5837 }
5838 if let Some(v) = read_num("resto_proximity_weight") {
5839 builder.resto.resto_proximity_weight = v;
5840 }
5841 // `required_infeasibility_reduction` (#439) — the κ_resto guard the
5842 // restoration sub-solve exits on. Registered since #191 but the
5843 // value was hardcoded at the callsite, so setting it was a silent
5844 // no-op.
5845 if let Some(v) = read_num("required_infeasibility_reduction") {
5846 builder.resto.required_infeasibility_reduction = v;
5847 }
5848 // gh#483 / #191 round 2: three restoration switches whose fields
5849 // `RestoAlgorithmBuilder` has consumed all along — the read site
5850 // was the only missing piece, so setting them did nothing.
5851 let read_yes = |key: &str| -> Option<bool> {
5852 match self.options.get_string_value(key, "") {
5853 Ok((v, true)) => Some(v.eq_ignore_ascii_case("yes")),
5854 _ => None,
5855 }
5856 };
5857 if let Some(v) = read_yes("evaluate_orig_obj_at_resto_trial") {
5858 builder.resto.evaluate_orig_obj_at_resto_trial = v;
5859 }
5860 if let Some(v) = read_yes("expect_infeasible_problem") {
5861 builder.resto.expect_infeasible_problem = v;
5862 }
5863 if let Some(v) = read_yes("start_with_resto") {
5864 builder.resto.start_with_resto = v;
5865 }
5866 // `max_resto_iter` (#551 / #677) — the cap on *successive*
5867 // restoration iterations. `RestoConvCheckAdapter` has enforced a cap
5868 // all along (returning `MaxIterExceeded` at the limit); the number
5869 // it enforced was a hard-coded constant in `resto_inner_solver.rs`,
5870 // so setting the option did nothing. The consumer's field is
5871 // `maximum_resto_iters`, not the option name — grepping for
5872 // `max_resto_iter` found only the registry (#551 caution 2).
5873 //
5874 // DEFAULT MISMATCH, LEFT AS IT IS ON PURPOSE: the registry declares
5875 // upstream's 3000000, pounce's effective cap is 3000
5876 // (`RestoOptions::default`). `read_int` fires only when the user set
5877 // the key, so an unset `max_resto_iter` still means 3000 and this
5878 // wiring is trajectory-neutral. Adopting upstream's number would let
5879 // restorations pounce currently truncates run on, which is a
5880 // trajectory change and needs its own measurement.
5881 if let Some(v) = read_int("max_resto_iter") {
5882 builder.resto.max_resto_iter = v;
5883 }
5884
5885 // Iteration-output options — consumed by `OrigIterationOutput`.
5886 if let Some(v) = read_int("print_frequency_iter") {
5887 builder.output.print_frequency_iter = v;
5888 }
5889 if let Some(v) = read_num("print_frequency_time") {
5890 builder.output.print_frequency_time = v;
5891 }
5892 if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
5893 if found {
5894 builder.output.print_info_string = v;
5895 }
5896 }
5897 if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
5898 if found {
5899 builder.output.inf_pr_output_internal = v == "internal";
5900 }
5901 }
5902
5903 // Warm-start options — consumed by `WarmStartIterateInitializer`
5904 // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
5905 // `warm_start_init_point` is the toggle that picks between the
5906 // default (cold) and warm-start initializers; the remaining
5907 // knobs are baked onto the chosen initializer at build time.
5908 if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
5909 if found {
5910 builder.warm_start_init_point = v;
5911 }
5912 }
5913 if let Some(v) = read_num("warm_start_bound_push") {
5914 builder.warm.bound_push = v;
5915 }
5916 if let Some(v) = read_num("warm_start_bound_frac") {
5917 builder.warm.bound_frac = v;
5918 }
5919 if let Some(v) = read_num("warm_start_slack_bound_push") {
5920 builder.warm.slack_bound_push = v;
5921 }
5922 if let Some(v) = read_num("warm_start_slack_bound_frac") {
5923 builder.warm.slack_bound_frac = v;
5924 }
5925 if let Some(v) = read_num("warm_start_mult_bound_push") {
5926 builder.warm.mult_bound_push = v;
5927 }
5928 if let Some(v) = read_num("warm_start_mult_init_max") {
5929 builder.warm.mult_init_max = v;
5930 }
5931 if let Some(v) = read_num("warm_start_target_mu") {
5932 builder.warm.target_mu = v;
5933 }
5934 // gh#606: residual-adaptive recentering. `warm_start_entire_iterate`
5935 // and `warm_start_same_structure` used to be parsed here into
5936 // fields nothing read; they are refused by
5937 // `unimplemented_options` instead (they name the
5938 // `GetWarmStartIterate` TNLP surface pounce does not expose).
5939 if let Ok((v, found)) = self.options.get_string_value("warm_start_recentering", "") {
5940 if found {
5941 builder.warm.recentering = if v.eq_ignore_ascii_case("none") {
5942 crate::alg_builder::WarmStartRecentering::None
5943 } else {
5944 crate::alg_builder::WarmStartRecentering::Residual
5945 };
5946 }
5947 }
5948
5949 // `DefaultIterateInitializer` knobs — parsed after the Mehrotra
5950 // cascade so explicit user values win
5951 // (mirrors upstream's `SetNumericValueIfUnset` semantics).
5952 if let Some(v) = read_num("bound_push") {
5953 builder.init.bound_push = v;
5954 }
5955 if let Some(v) = read_num("bound_frac") {
5956 builder.init.bound_frac = v;
5957 }
5958 if let Some(v) = read_num("slack_bound_push") {
5959 builder.init.slack_bound_push = v;
5960 }
5961 if let Some(v) = read_num("slack_bound_frac") {
5962 builder.init.slack_bound_frac = v;
5963 }
5964 if let Some(v) = read_num("constr_mult_init_max") {
5965 builder.init.constr_mult_init_max = v;
5966 }
5967 if let Some(v) = read_num("bound_mult_init_val") {
5968 builder.init.bound_mult_init_val = v;
5969 }
5970 if let Ok((v, found)) = self.options.get_string_value("bound_mult_init_method", "") {
5971 if found {
5972 builder.init.bound_mult_init_method = v;
5973 }
5974 }
5975 if let Ok((v, found)) = self
5976 .options
5977 .get_string_value("least_square_init_primal", "")
5978 {
5979 if found {
5980 builder.init.least_square_init_primal = v == "yes";
5981 }
5982 }
5983 builder
5984 }
5985}
5986
5987/// Map the integer `print_level` / `file_print_level` option to the
5988/// matching [`JournalLevel`] variant. Mirrors upstream's
5989/// `static_cast<EJournalLevel>(int_value)` with clamping.
5990/// The eight block dimensions of an iterate, in canonical order
5991/// (x, s, y_c, y_d, z_l, z_u, v_l, v_u). Used to guard the debugger's
5992/// warm-restart install against a structural mismatch between solves.
5993fn iterates_dims(c: &IteratesVector) -> [i32; 8] {
5994 [
5995 c.x.dim(),
5996 c.s.dim(),
5997 c.y_c.dim(),
5998 c.y_d.dim(),
5999 c.z_l.dim(),
6000 c.z_u.dim(),
6001 c.v_l.dim(),
6002 c.v_u.dim(),
6003 ]
6004}
6005
6006fn journal_level_from_int(v: i32) -> JournalLevel {
6007 match v.clamp(0, 12) {
6008 0 => JournalLevel::J_NONE,
6009 1 => JournalLevel::J_ERROR,
6010 2 => JournalLevel::J_STRONGWARNING,
6011 3 => JournalLevel::J_SUMMARY,
6012 4 => JournalLevel::J_WARNING,
6013 5 => JournalLevel::J_ITERSUMMARY,
6014 6 => JournalLevel::J_DETAILED,
6015 7 => JournalLevel::J_MOREDETAILED,
6016 8 => JournalLevel::J_VECTOR,
6017 9 => JournalLevel::J_MOREVECTOR,
6018 10 => JournalLevel::J_MATRIX,
6019 11 => JournalLevel::J_MOREMATRIX,
6020 _ => JournalLevel::J_ALL,
6021 }
6022}
6023
6024/// MA57 backend knobs snapshotted off an `OptionsList`, ready to be
6025/// handed to a backend factory.
6026///
6027/// The type exists — rather than passing `pounce_hsl::Ma57Options`
6028/// directly — so that the factory signature is the same shape whether or
6029/// not the `ma57` cargo feature is on. `pounce-py`, `pounce-cinterface`
6030/// and `pounce-restoration` all call [`default_backend_factory`] and none
6031/// of them can name a `pounce-hsl` type; without the feature this struct
6032/// carries nothing and costs nothing.
6033///
6034/// Build it with [`ma57_config_from_options`]. It is the MA57 half of
6035/// what [`feral_config_from_options`] does for FERAL, and the asymmetry
6036/// between the two was the root cause of gh#825: FERAL's knobs were
6037/// threaded into the factory and MA57's were not, so the factory had
6038/// nothing to give MA57 and called `Ma57SolverInterface::new()` — which
6039/// hard-codes the defaults. Every `ma57_*` option was registered,
6040/// documented, accepted, and then silently discarded.
6041#[derive(Debug, Clone, Default)]
6042pub struct Ma57Config {
6043 #[cfg(feature = "ma57")]
6044 opts: pounce_hsl::Ma57Options,
6045}
6046
6047impl Ma57Config {
6048 /// The wrapped MA57 settings.
6049 #[cfg(feature = "ma57")]
6050 pub fn options(&self) -> &pounce_hsl::Ma57Options {
6051 &self.opts
6052 }
6053}
6054
6055/// Read the `ma57_*` options off `options` under `prefix`, for handing
6056/// to [`default_backend_factory`] / [`default_backend_factory_with_sink`].
6057///
6058/// `prefix` is `""` for the main IPM and `"resto."` for the restoration
6059/// sub-IPM, mirroring upstream's
6060/// `Ma57TSolverInterface::InitializeImpl(options, prefix)`. The
6061/// restoration sub-IPM builds its own backend through its own
6062/// `InnerBackendFactoryFactory` (see
6063/// `pounce_restoration::resto_inner_solver`), so the two really are
6064/// separately configurable — before gh#825 neither was.
6065///
6066/// Without the `ma57` cargo feature this returns an empty config and
6067/// does not touch `options`; nothing downstream can consume MA57
6068/// settings in that build.
6069pub fn ma57_config_from_options(
6070 options: &pounce_common::options_list::OptionsList,
6071 prefix: &str,
6072) -> Ma57Config {
6073 #[cfg(feature = "ma57")]
6074 {
6075 Ma57Config {
6076 opts: pounce_hsl::Ma57Options::from_options_list(options, prefix),
6077 }
6078 }
6079 #[cfg(not(feature = "ma57"))]
6080 {
6081 let _ = (options, prefix);
6082 Ma57Config::default()
6083 }
6084}
6085
6086/// Construct the MA57 backend for a factory, or fall back to FERAL when
6087/// the `ma57` cargo feature is off.
6088///
6089/// Factored out of the two factories below so there is exactly one place
6090/// that decides how an MA57 backend is built from a [`Ma57Config`]. Both
6091/// factories used to inline `Ma57SolverInterface::new()`, and the
6092/// duplication is half of why gh#825 was easy to miss.
6093fn make_ma57_backend(
6094 ma57_cfg: &Ma57Config,
6095 feral_fallback: impl FnOnce() -> Box<dyn SparseSymLinearSolverInterface>,
6096) -> Box<dyn SparseSymLinearSolverInterface> {
6097 #[cfg(feature = "ma57")]
6098 {
6099 let _ = feral_fallback;
6100 Box::new(pounce_hsl::Ma57SolverInterface::with_options(
6101 *ma57_cfg.options(),
6102 ))
6103 }
6104 #[cfg(not(feature = "ma57"))]
6105 {
6106 // ma57 feature not compiled in — fall back to FERAL.
6107 let _ = ma57_cfg;
6108 feral_fallback()
6109 }
6110}
6111
6112/// Default symmetric linear-solver factory, parameterized by the
6113/// pounce-extension FERAL knobs and the `ma57_*` knobs read off the
6114/// application's `OptionsList`.
6115///
6116/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
6117/// available when the `ma57` cargo feature is enabled; without it,
6118/// requesting `linear_solver = ma57` falls back to FERAL with a
6119/// warning printed by the journalist (see [`AlgorithmBuilder`]).
6120///
6121/// Both configs are snapshots, not live views: take them with
6122/// [`feral_config_from_options`] and [`ma57_config_from_options`] at the
6123/// point the application's options are fully populated. The factory is
6124/// called more than once per solve (the main KKT solver and, under
6125/// limited memory, the Hessian-free bypass solver), so each call gets a
6126/// fresh backend built from the same snapshot.
6127pub fn default_backend_factory(
6128 feral_cfg: pounce_feral::FeralConfig,
6129 ma57_cfg: Ma57Config,
6130) -> LinearBackendFactory {
6131 Box::new(
6132 move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
6133 match choice {
6134 LinearSolverChoice::Feral => Box::new(
6135 pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
6136 ),
6137 LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
6138 Box::new(pounce_feral::FeralSolverInterface::with_config(
6139 feral_cfg.clone(),
6140 ))
6141 }),
6142 }
6143 },
6144 )
6145}
6146
6147/// Sink-aware variant of [`default_backend_factory`]. Identical
6148/// dispatch, but the FERAL backend is constructed with a
6149/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
6150/// aggregate post-mortem stats (factor counts, fill ratio, extremal
6151/// pivots, final inertia) after the solve returns. MA57 ignores the
6152/// sink — the HSL backend doesn't carry the same instrumentation yet.
6153pub fn default_backend_factory_with_sink(
6154 feral_cfg: pounce_feral::FeralConfig,
6155 ma57_cfg: Ma57Config,
6156 sink: Arc<Mutex<LinearSolverSummary>>,
6157) -> LinearBackendFactory {
6158 Box::new(
6159 move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
6160 match choice {
6161 LinearSolverChoice::Feral => Box::new(
6162 pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
6163 .with_summary_sink(Arc::clone(&sink)),
6164 ),
6165 LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
6166 Box::new(
6167 pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
6168 .with_summary_sink(Arc::clone(&sink)),
6169 )
6170 }),
6171 }
6172 },
6173 )
6174}
6175
6176/// Read the `feral_*` extension options off `options`, falling
6177/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
6178/// for any knob the caller did not set explicitly. The returned
6179/// config is what every default-factory invocation (main IPM and
6180/// restoration sub-IPM) consumes.
6181pub fn feral_config_from_options(
6182 options: &pounce_common::options_list::OptionsList,
6183) -> pounce_feral::FeralConfig {
6184 let mut cfg = pounce_feral::FeralConfig::from_env();
6185 // Tri-state: the `(_, true)` arm only fires when the user set the
6186 // option explicitly. Leaving it unset keeps `cfg.cascade_break` at
6187 // `None`, which inherits FERAL's `NumericParams::default()` (CB on
6188 // as of FERAL Phase B / pounce#55). `Some(false)` explicitly
6189 // disarms (reproduces pre-Phase-B behaviour, surfaces FERAL's
6190 // `DelayBudgetExceeded` on non-root cascade victims).
6191 if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
6192 cfg.cascade_break = Some(v);
6193 }
6194 if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
6195 cfg.fma = v;
6196 }
6197 // Not tri-state, and deliberately not: on the limited-memory path
6198 // the IPM's default is the opposite of the library's (gh#710,
6199 // gh#698 obs 5). `FeralConfig` ships `refine = true` because a
6200 // caller that only refines its own system needs the backend loop.
6201 //
6202 // Scoped to limited-memory, because that is where the win was
6203 // measured and where it comes from. Under L-BFGS the Hessian-free
6204 // bypass batches the low-rank SMW correction into one multi-RHS
6205 // back-solve, and the backend loop refines per right-hand side, so
6206 // its cost scales with the memory depth — while what it polishes is
6207 // the *condensed* system, which is not where Waechter-Biegler 3.10
6208 // puts refinement. Turning it off takes `laptime` under
6209 // limited-memory from 1397 s to 394 s.
6210 //
6211 // The same switch is a net loss on the exact path, so it is not
6212 // applied there. It costs `NARX_CFy` 230 iterations (400 -> 630,
6213 // 173 s -> 250 s) to buy back 34 on `laptime` (380 -> 346). And the
6214 // exact path has no rung left to stand in for it: the
6215 // `increase_quality` escalation that once argued for dropping the
6216 // backend loop here proved inert and was itself removed, so with
6217 // `refine` off the host loop in `PdFullSpaceSolver` is the only
6218 // refinement in the stack — and it stops the moment it crosses
6219 // `residual_ratio_max` (1e-10), three orders looser than the ~1e-16
6220 // the backend-refined solves reach.
6221 //
6222 // Tightening that threshold instead is not the fix, and was measured
6223 // rather than assumed: it is chaotic on this corpus, flipping `deb7`
6224 // to `Error_In_Step_Computation` at 1e-12 and swinging
6225 // `pooling_rt2stp` between 107 and 199 iterations across 1e-11 to
6226 // 1e-13. Forcing extra passes via `min_refinement_steps` behaves the
6227 // same way (both fixtures break at 2). Restoring the backend loop is
6228 // trajectory-neutral next to either (`deb7` 146 -> 147,
6229 // `pooling_rt2stp` 107 -> 109).
6230 //
6231 // Ordered env-then-option so `POUNCE_FERAL_REFINE` still reaches
6232 // this path and an explicit `feral_refine` still beats both.
6233 let limited_memory = matches!(
6234 options.get_string_value("hessian_approximation", ""),
6235 Ok((ref s, true)) if s == "limited-memory"
6236 );
6237 if limited_memory && std::env::var_os("POUNCE_FERAL_REFINE").is_none() {
6238 cfg.refine = false;
6239 }
6240 if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
6241 cfg.refine = v;
6242 }
6243 // gh #850: `feral_increase_quality` exists because this rung is a genuine
6244 // two-sided trade, and the option is the lever — the default is left ON,
6245 // which is the 0.11 behaviour.
6246 //
6247 // Ipopt's `IncreaseQuality` contract assumes a *monotone* escalation: MA57
6248 // raises `pivtol` toward `pivtolmax`, strictly more conservative each time,
6249 // so keeping it raised for the rest of the solve can only make the
6250 // factorization safer. FERAL's ladder changes which pivots are taken, which
6251 // is lateral in trajectory terms, and it persists the same way. So it
6252 // reroutes solves, and the reroute goes both ways:
6253 //
6254 // it COSTS two whole solves, both `square_flowsheet_resto`:
6255 // exact Optimal/99 -> RestorationFailed/131 (a second-opinion rung
6256 // rescues it, at 185 iterations total)
6257 // lbfgs Optimal/178 -> 3000 iterations at the cap, rescued by nothing
6258 // it BUYS accuracy where nothing else does:
6259 // `watchdog_trial_is_not_a_divergence_verdict`'s 12-variable model ends
6260 // `SolvedToAcceptableLevel` at obj 3.7e-6 with the rung and at obj 3.42
6261 // against `f* = 0` without it — a wrong-ish answer under a
6262 // success-shaped status, which is worse than an honest failure.
6263 // and it buys iterations on five more fixture-legs (15-25%).
6264 //
6265 // There is no scoping that separates those. Measured with a process-global
6266 // firing cap on `square_flowsheet_resto`: the rung fires twice, once in the
6267 // main solve at iteration 25 and once inside restoration at `76r`, and
6268 // allowing only the first still loses the leg — so declining it just for
6269 // the restoration sub-solve would not help. Nor does a count separate them:
6270 // `deb7` and `square_flowsheet_resto` each fire it exactly twice on their
6271 // exact legs, one gaining 16% of its iterations and the other losing its
6272 // verdict.
6273 //
6274 // So the default stands, and the losing direction now recovers itself:
6275 // `feral_increase_quality_retry` (gh#857) re-solves once with this off when
6276 // a solve that actually escalated ends `Restoration_Failed` or
6277 // `Maximum_Iterations_Exceeded`.
6278 //
6279 // It is a re-solve rather than a *revertible* escalation because the
6280 // revertible one was tried. jkitchin/feral#192 landed as `reset_quality`,
6281 // was plumbed here and instrumented (376 escalations, 376 matching resets
6282 // on one solve), and recovers neither leg at either re-baselining boundary
6283 // -- the harm is the destination, not the duration. See
6284 // `dev-notes/second-opinion-promotions-in-the-sweep.md`.
6285 if let Ok((v, true)) = options.get_bool_value("feral_increase_quality", "") {
6286 cfg.increase_quality = v;
6287 }
6288 // Only consulted when `refine` is on; see `FeralConfig::refine_max_steps`
6289 // (gh#710). Registered as an integer option with lower bound 0, so the
6290 // cast cannot go negative.
6291 if let Ok((v, true)) = options.get_integer_value("feral_refine_steps", "") {
6292 cfg.refine_max_steps = v.max(0) as usize;
6293 }
6294 // Also only consulted when `refine` is on. Registered with lower bound
6295 // 0, and 0 disables the pre-check; see `FeralConfig::refine_target`.
6296 if let Ok((v, true)) = options.get_numeric_value("feral_refine_target", "") {
6297 cfg.refine_target = v.max(0.0);
6298 }
6299 // Explicit static-pivoting opt-in (feral#8 cascade breaker, pounce#254).
6300 // Same tri-state discipline: unset leaves `cfg.static_pivoting` at
6301 // whatever `from_env` resolved (`None` → inherit feral's delayed-pivot
6302 // default), so the default numeric path is unchanged.
6303 if let Ok((v, true)) = options.get_bool_value("feral_static_pivoting", "") {
6304 cfg.static_pivoting = Some(v);
6305 }
6306 if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
6307 cfg.singular_pivot_floor = v;
6308 }
6309 // Explicitly set pins an absolute floor for every dimension (`0`
6310 // disables the trigger); left unset, `None` keeps the dimension-aware
6311 // `n * eps` default (pounce gh#592).
6312 if let Ok((v, true)) = options.get_numeric_value("feral_inertia_pivot_floor", "") {
6313 cfg.inertia_pivot_floor = Some(v);
6314 }
6315 // Number option (not integer): the gate is a u64 and Index is i32, too
6316 // narrow for large flop counts or the u64::MAX reject-all sentinel. The
6317 // lower bound (0.0) rules out negatives; `as u64` then saturates a very
6318 // large finite value to u64::MAX (reject all tree-level parallelism).
6319 if let Ok((v, true)) = options.get_numeric_value("feral_min_par_flops", "") {
6320 cfg.min_par_flops = Some(v as u64);
6321 }
6322 if let Ok((v, true)) = options.get_numeric_value("feral_pivtol", "") {
6323 cfg.pivtol = v;
6324 }
6325 // Only override on explicit set so `from_env` (which itself
6326 // defaults to OrderingMethod::Auto) keeps governing unset cases.
6327 // Unrecognized tags are silently ignored — the registered enum
6328 // restricts inputs at the OptionsList layer.
6329 if let Ok((v, true)) = options.get_string_value("feral_ordering", "") {
6330 if let Some(m) = pounce_feral::parse_ordering_method(&v) {
6331 cfg.ordering = m;
6332 }
6333 }
6334 // Same explicit-set discipline as `feral_ordering`: `from_env`
6335 // defaults to ScalingStrategy::Auto (FERAL's current default), so
6336 // leaving the option unset preserves existing behaviour exactly.
6337 if let Ok((v, true)) = options.get_string_value("feral_scaling", "") {
6338 if let Some(s) = pounce_feral::parse_scaling_strategy(&v) {
6339 cfg.scaling = s;
6340 }
6341 }
6342 cfg
6343}
6344
6345/// Withdraw a numerical infeasibility verdict the model's own starting point
6346/// disproves.
6347///
6348/// Applied at every site in this file that can return
6349/// `Infeasible_Problem_Detected` from a *numerical* argument — the IPM path's
6350/// restoration / cycle gates, the SQP path's infeasible-subproblem exit, and the
6351/// ℓ₁ wrapper's uncollapsed-slack certificate. Deliberately one gate rather than
6352/// three: the two preceding safeguards in this area (gh #376, gh #380) were each
6353/// added to one path and not its twin, and a hole survived both times.
6354///
6355/// Not applied to a presolve *certificate*
6356/// (`TNLP::presolve_infeasibility_proof`), which carries its own, tighter
6357/// refutation
6358/// (`pounce_presolve::witness_refutes_infeasibility`) and is a proof rather than
6359/// a numerical inference.
6360///
6361/// The replacement is `Error_In_Step_Computation`, the status this codebase
6362/// already uses for "the solve broke down and we are **not** claiming
6363/// infeasibility" — see the `cycle_exit` fallback in
6364/// [`crate::ipopt_alg::IpoptAlgorithm::invoke_restoration`], which picks between
6365/// exactly these two on exactly this question. It maps to AMPL 500, an honest
6366/// failure the caller can see, instead of AMPL 200, a wrong answer they cannot.
6367///
6368/// gh #379.
6369fn withdraw_infeasibility_if_refuted(
6370 tnlp: &Rc<RefCell<dyn TNLP>>,
6371 solver_status: SolverReturn,
6372 lo_inf: Number,
6373 up_inf: Number,
6374 tol: Number,
6375) -> SolverReturn {
6376 if solver_status != SolverReturn::LocalInfeasibility {
6377 return solver_status;
6378 }
6379 // A presolve proof is not a numerical inference; it does its own refutation.
6380 if tnlp.borrow().presolve_infeasibility_proof().is_some() {
6381 return solver_status;
6382 }
6383 match crate::infeasibility_refutation::starting_point_refutes_infeasibility(
6384 tnlp, lo_inf, up_inf, tol,
6385 ) {
6386 Some(w) => {
6387 tracing::debug!(
6388 target: "pounce::application",
6389 "[PN_INFEAS_REFUTED] the model's starting point satisfies every constraint \
6390 (max violation {:.3e}) — withdrawing Infeasible_Problem_Detected",
6391 w.max_violation
6392 );
6393 SolverReturn::ErrorInStepComputation
6394 }
6395 None => solver_status,
6396 }
6397}
6398
6399/// How well a point satisfies the **user's own** rows and bounds.
6400///
6401/// Computed from `g = c(x)` and the inner TNLP's declared bounds, in the
6402/// user's units, with no reference to whatever problem the algorithm
6403/// actually iterated on. That distinction is the whole reason this
6404/// exists: on the ℓ₁ path the IPM converges the *augmented* NLP
6405/// `c(x) − p + n = target`, whose equality rows the slacks satisfy to
6406/// machine precision by construction, and reporting that residual as
6407/// the solve's constraint violation says nothing about `c(x) − target`
6408/// (gh#794 finding P1).
6409struct OriginalSpaceFeasibility {
6410 /// Largest absolute violation of any row or bound, in the user's units.
6411 max_violation: Number,
6412 /// Every row and bound negligible at `tol`, judged scale-relative.
6413 negligible_at_tol: bool,
6414 /// The same at `acceptable_tol`.
6415 negligible_at_acceptable: bool,
6416}
6417
6418/// Measure [`OriginalSpaceFeasibility`] at `x`, given `g = c(x)`.
6419///
6420/// `is_negligible` rather than `!is_significant`, deliberately, and for
6421/// the reason that function's own documentation gives: the question here
6422/// is "did the solve converge well enough to call this point feasible",
6423/// which must never demand more precision than the solver promised, so
6424/// the threshold is clamped at `tol` from below (`tol · max(|scale|, 1)`).
6425/// The refutation path next door asks the opposite question — "is this
6426/// residual real at this row's scale" — and correctly uses the pure
6427/// relative form.
6428///
6429/// Returns `None` when the model cannot be measured (bounds unreadable, a
6430/// non-finite value). `None` means "not measured", never "feasible": the
6431/// caller keeps whatever verdict it already had.
6432fn original_space_feasibility(
6433 tnlp: &Rc<RefCell<dyn TNLP>>,
6434 x: &[Number],
6435 g: &[Number],
6436 lower_bound_inf: Number,
6437 upper_bound_inf: Number,
6438 tol: Number,
6439 acceptable_tol: Number,
6440 constr_viol_tol: Number,
6441 acceptable_constr_viol_tol: Number,
6442 noise_floor_kappa: Number,
6443) -> Option<OriginalSpaceFeasibility> {
6444 use pounce_common::tolerance::is_negligible;
6445
6446 let info = tnlp.borrow_mut().get_nlp_info()?;
6447 let n = info.n.max(0) as usize;
6448 let m = info.m.max(0) as usize;
6449 if x.len() < n || g.len() < m {
6450 return None;
6451 }
6452
6453 let mut x_l = vec![0.0; n];
6454 let mut x_u = vec![0.0; n];
6455 let mut g_l = vec![0.0; m];
6456 let mut g_u = vec![0.0; m];
6457 if !tnlp.borrow_mut().get_bounds_info(BoundsInfo {
6458 x_l: &mut x_l,
6459 x_u: &mut x_u,
6460 g_l: &mut g_l,
6461 g_u: &mut g_u,
6462 }) {
6463 return None;
6464 }
6465
6466 let mut max_violation: Number = 0.0;
6467 let mut ok_tol = true;
6468 let mut ok_acceptable = true;
6469
6470 // Only *finite, present* bounds inform a row's magnitude: letting the
6471 // `±1e19` sentinel set the scale would make every row look satisfied,
6472 // the same trap `infeasibility_refutation` documents.
6473 let present = |b: Number, is_lower: bool| -> Option<Number> {
6474 let absent = if is_lower {
6475 b <= lower_bound_inf
6476 } else {
6477 b >= upper_bound_inf
6478 };
6479 (b.is_finite() && !absent).then_some(b)
6480 };
6481
6482 // Accumulates into `max_violation` / `ok_tol` / `ok_acceptable`; a
6483 // non-positive `viol` means the side is satisfied and contributes
6484 // nothing. Returns nothing — every caller is a statement.
6485 let mut judge = |viol: Number, scale: Number| {
6486 if viol <= 0.0 {
6487 return;
6488 }
6489 max_violation = max_violation.max(viol);
6490 // The scale-relative test alone is not a feasibility standard, and
6491 // on a large-magnitude row it is not even close to one: it accepts
6492 // anything up to `tol · |row|`, which on a row near `1e10` is `1e2`
6493 // at the default `tol`. An adversary probe on this branch built a
6494 // model infeasible by exactly `50` with its row at `1e10` and got
6495 // `Solve_Succeeded` — a *worse* verdict than this branch's own
6496 // parent, which refused the same point (the old `Σ(p+n)` argument
6497 // was crude but absolute). So the wrapper has to judge feasibility
6498 // the way the rest of the solver does.
6499 //
6500 // `OptErrorConvCheck::primal_component_passes` is that standard:
6501 // an absolute `constr_viol <= constr_viol_tol`, with scale-awareness
6502 // supplied by an abstention when every row sits at its own
6503 // floating-point noise floor (gh#528/gh#590) rather than by
6504 // multiplying the tolerance by the row's magnitude. That
6505 // abstention "cannot fabricate a success on a genuinely infeasible
6506 // model: such a model's violation is pinned at its infeasibility
6507 // gap, orders above `eps ·` the row's own magnitude" — which is
6508 // exactly the property `is_negligible` lacks and the probe
6509 // exploited (`50` is `~2e7 ×` this row's floor).
6510 //
6511 // The strict gate's `primal_resolvable` cannot be reused verbatim:
6512 // it is computed by the CQ on the *augmented* NLP, whose rows the
6513 // slacks satisfy to machine precision, so it would abstain always
6514 // and accept everything. The floor is therefore recomputed here on
6515 // the user's own row, from the same `kappa · eps · magnitude` the
6516 // option documents. `primal_noise_floor_kappa = 0` opts out, as it
6517 // does for the strict gate.
6518 //
6519 // Both arms are conjoined rather than substituted: the relative
6520 // test still catches a violation that is small in absolute terms
6521 // but large for its row, which is the gh#794 P1 case itself
6522 // (`ralph1` at `2.5e-7` under a `2.5e-11` tol).
6523 let noise = noise_floor_kappa * Number::EPSILON * scale.abs();
6524 let absolute_ok = |bound: Number| viol <= bound || viol <= noise;
6525 ok_tol &= is_negligible(viol, scale, tol) && absolute_ok(constr_viol_tol);
6526 ok_acceptable &=
6527 is_negligible(viol, scale, acceptable_tol) && absolute_ok(acceptable_constr_viol_tol);
6528 };
6529
6530 for i in 0..m {
6531 let v = g[i];
6532 if !v.is_finite() {
6533 return None;
6534 }
6535 let lo = present(g_l[i], true);
6536 let hi = present(g_u[i], false);
6537 let scale = v
6538 .abs()
6539 .max(lo.map_or(0.0, Number::abs))
6540 .max(hi.map_or(0.0, Number::abs));
6541 judge(lo.map_or(0.0, |b| b - v), scale);
6542 judge(hi.map_or(0.0, |b| v - b), scale);
6543 }
6544 for j in 0..n {
6545 let v = x[j];
6546 if !v.is_finite() {
6547 return None;
6548 }
6549 let lo = present(x_l[j], true);
6550 let hi = present(x_u[j], false);
6551 let scale = v
6552 .abs()
6553 .max(lo.map_or(0.0, Number::abs))
6554 .max(hi.map_or(0.0, Number::abs));
6555 judge(lo.map_or(0.0, |b| b - v), scale);
6556 judge(hi.map_or(0.0, |b| v - b), scale);
6557 }
6558
6559 Some(OriginalSpaceFeasibility {
6560 max_violation,
6561 negligible_at_tol: ok_tol,
6562 negligible_at_acceptable: ok_acceptable,
6563 })
6564}
6565
6566/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
6567/// Mirrors the table in
6568/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
6569/// map") and the corresponding switch in
6570/// `IpIpoptApplication.cpp:call_optimize`.
6571fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
6572 match s {
6573 SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
6574 SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
6575 SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
6576 SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
6577 SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
6578 SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
6579 SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
6580 SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
6581 SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
6582 SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
6583 SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
6584 SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
6585 SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
6586 SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
6587 SolverReturn::InvalidProblemDefinition => ApplicationReturnStatus::InvalidProblemDefinition,
6588 SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
6589 SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
6590 SolverReturn::InternalError | SolverReturn::Unassigned => {
6591 ApplicationReturnStatus::InternalError
6592 }
6593 }
6594}
6595
6596/// Best-effort evaluation of the objective at the algorithm's final
6597/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
6598/// to populate `SolveStatistics::final_scaled_objective`.
6599fn try_eval_curr_f(
6600 nlp: &Rc<RefCell<dyn IpoptNlp>>,
6601 x: &Rc<dyn pounce_linalg::Vector>,
6602) -> Result<Number, ()> {
6603 let mut nlp_mut = nlp.borrow_mut();
6604 Ok(nlp_mut.eval_f(&**x))
6605}
6606
6607/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
6608/// `true` when a status warrants a retry through the wrapper. Mirrors
6609/// ripopt#23's trigger set, extended per the audit's Refinement B
6610/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
6611/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
6612/// equivalent would let pass to the wrapper).
6613fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
6614 matches!(
6615 status,
6616 ApplicationReturnStatus::RestorationFailed
6617 | ApplicationReturnStatus::InfeasibleProblemDetected
6618 | ApplicationReturnStatus::SolvedToAcceptableLevel
6619 | ApplicationReturnStatus::MaximumIterationsExceeded
6620 | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
6621 )
6622}
6623
6624/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
6625/// We pull `x` (compressed in `x_var`-space) off the algorithm's
6626/// `data.curr`, lift it back to full TNLP indexing, and pass empty
6627/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
6628/// in compressed split form — re-assembling them into the user's
6629/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
6630/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
6631/// fleshed out). On success returns the unscaled objective evaluated
6632/// on the user TNLP at the final iterate; returns `Err` if the final
6633/// iterate is missing.
6634/// Read a `dyn Vector`'s entries. Empty for a non-dense backing; POUNCE is
6635/// dense-only, so that is defensive rather than a supported case.
6636fn dense_values(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
6637 v.as_any()
6638 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
6639 .map(|d| d.expanded_values())
6640 .unwrap_or_default()
6641}
6642
6643/// Overwrite a `dyn Vector`'s entries. Returns false — writing nothing —
6644/// when the backing is not dense or the lengths disagree, so a caller
6645/// updating several components together can abandon the whole update rather
6646/// than leave a half-written iterate.
6647fn set_dense(v: &mut dyn pounce_linalg::Vector, vals: &[Number]) -> bool {
6648 match v
6649 .as_any_mut()
6650 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
6651 {
6652 Some(d) if pounce_linalg::Vector::dim(d) as usize == vals.len() => {
6653 d.set_values(vals);
6654 true
6655 }
6656 _ => false,
6657 }
6658}
6659
6660/// An owned copy of a [`Solution`] payload already delivered to the user's
6661/// TNLP, enough to deliver it again.
6662///
6663/// Exists because a losing second-opinion retry has to be undoable. The status
6664/// a retry earns is already floored — `run_with_mu_strategy_fallback` returns
6665/// `first_status` unless the retry promotes — but the *point* was not, and the
6666/// user consumes the point. See that function for what went wrong without this.
6667#[derive(Debug, Clone)]
6668struct FinalizeSnapshot {
6669 status: SolverReturn,
6670 x: Vec<Number>,
6671 z_l: Vec<Number>,
6672 z_u: Vec<Number>,
6673 g: Vec<Number>,
6674 lambda: Vec<Number>,
6675 obj_value: Number,
6676}
6677
6678/// The `final_*` half of [`SolveStatistics`] — the numbers that describe the
6679/// answer and the certificate attached to it.
6680///
6681/// Deliberately **not** the whole struct. `SolveStatistics` mixes two kinds of
6682/// number and they float back differently when a second-opinion retry loses:
6683///
6684/// * the `final_*` fields describe *the point being reported*, so they have to
6685/// agree with the status reported beside them. A `Solved_To_Acceptable_Level`
6686/// carrying a `final_kkt_error` two orders above `acceptable_tol` is
6687/// self-contradictory, and that is pounce#870.
6688/// * `iteration_count`, the evaluation counts, the timers, the restoration
6689/// tallies and `quality_escalations` describe *what the invocation did*.
6690/// Both attempts really ran, so rewinding those would under-report the work
6691/// actually spent — a different falsehood, not a fix. `deb7` at
6692/// `max_iter=100` is the case that caught this: rewinding the counter made
6693/// the run claim an iteration count belonging to only one of its two solves
6694/// (`issue857_escalation_gated_quality_rung.rs`).
6695///
6696/// So the certificate is floored and the cost is not.
6697#[derive(Debug, Clone, Copy)]
6698struct SolutionCertificate {
6699 objective: Number,
6700 scaled_objective: Number,
6701 dual_inf: Number,
6702 constr_viol: Number,
6703 compl: Number,
6704 kkt_error: Number,
6705 unscaled_dual_inf: Number,
6706 unscaled_constr_viol: Number,
6707 unscaled_compl: Number,
6708 unscaled_kkt_error: Number,
6709 kkt_error_above_noise: Number,
6710 mu: Number,
6711}
6712
6713impl SolutionCertificate {
6714 fn of(s: &pounce_nlp::solve_statistics::SolveStatistics) -> Self {
6715 Self {
6716 objective: s.final_objective,
6717 scaled_objective: s.final_scaled_objective,
6718 dual_inf: s.final_dual_inf,
6719 constr_viol: s.final_constr_viol,
6720 compl: s.final_compl,
6721 kkt_error: s.final_kkt_error,
6722 unscaled_dual_inf: s.final_unscaled_dual_inf,
6723 unscaled_constr_viol: s.final_unscaled_constr_viol,
6724 unscaled_compl: s.final_unscaled_compl,
6725 unscaled_kkt_error: s.final_unscaled_kkt_error,
6726 kkt_error_above_noise: s.final_kkt_error_above_noise,
6727 mu: s.final_mu,
6728 }
6729 }
6730
6731 fn restore_into(&self, s: &mut pounce_nlp::solve_statistics::SolveStatistics) {
6732 s.final_objective = self.objective;
6733 s.final_scaled_objective = self.scaled_objective;
6734 s.final_dual_inf = self.dual_inf;
6735 s.final_constr_viol = self.constr_viol;
6736 s.final_compl = self.compl;
6737 s.final_kkt_error = self.kkt_error;
6738 s.final_unscaled_dual_inf = self.unscaled_dual_inf;
6739 s.final_unscaled_constr_viol = self.unscaled_constr_viol;
6740 s.final_unscaled_compl = self.unscaled_compl;
6741 s.final_unscaled_kkt_error = self.unscaled_kkt_error;
6742 s.final_kkt_error_above_noise = self.kkt_error_above_noise;
6743 s.final_mu = self.mu;
6744 }
6745}
6746
6747impl FinalizeSnapshot {
6748 /// Re-deliver this payload to `tnlp`, overwriting whatever a later attempt
6749 /// captured there.
6750 fn replay(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
6751 tnlp.borrow_mut().finalize_solution(
6752 Solution {
6753 status: self.status,
6754 x: &self.x,
6755 z_l: &self.z_l,
6756 z_u: &self.z_u,
6757 g: &self.g,
6758 lambda: &self.lambda,
6759 obj_value: self.obj_value,
6760 },
6761 &TnlpIpoptData::default(),
6762 &TnlpIpoptCq::default(),
6763 );
6764 }
6765}
6766
6767fn finalize_via_orig_nlp(
6768 nlp: &Rc<RefCell<dyn IpoptNlp>>,
6769 alg: &IpoptAlgorithm,
6770 solver_status: SolverReturn,
6771 _app_status: ApplicationReturnStatus,
6772 tnlp: &Rc<RefCell<dyn TNLP>>,
6773 sink: &RefCell<Option<FinalizeSnapshot>>,
6774) -> Result<Number, ()> {
6775 let curr = alg.data.borrow().curr.clone().ok_or(())?;
6776 // Lift compressed x_var → full-x (length `info.n`) so the user
6777 // TNLP receives the same shape it provided. With `make_parameter`
6778 // the fixed components are spliced back in by the IpoptNlp.
6779 let nlp_borrow = nlp.borrow();
6780 // `finalize_solution_x`, not `lift_x_to_full`: the reported point also
6781 // owes the user the `honor_original_bounds` projection. `f` and `g`
6782 // below are then evaluated at the point actually reported, so x/f/g
6783 // agree with each other.
6784 let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&*curr.x);
6785 let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
6786 let n = info.n as usize;
6787 let m = info.m as usize;
6788 debug_assert_eq!(x_vec.len(), n);
6789 // Lift algorithm-side multipliers back into user-space (pounce#11).
6790 // Use the `finalize_solution_*` family (not the `pack_*` family): the
6791 // final solution duals must be reported in the user's *unscaled-
6792 // Lagrangian* convention `∇f + λ·∇g + z = 0`, which divides out the
6793 // `obj_scale_factor` the algorithm threads through `eval_h`. The `pack_*`
6794 // family deliberately omits that division because it feeds the scaled
6795 // `eval_h`; calling it here left every dual scaled by `obj_scale_factor`
6796 // whenever gradient-based scaling triggered (pounce#11 F1).
6797 // Backends without overrides return empty; fall back to zero stubs so the
6798 // user sees a length-consistent vector.
6799 let mut z_l = nlp_borrow.finalize_solution_z_l(&*curr.z_l);
6800 if z_l.is_empty() {
6801 z_l = vec![0.0; n];
6802 }
6803 let mut z_u = nlp_borrow.finalize_solution_z_u(&*curr.z_u);
6804 if z_u.is_empty() {
6805 z_u = vec![0.0; n];
6806 }
6807 let mut lambda = nlp_borrow.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
6808 if lambda.is_empty() {
6809 lambda = vec![0.0; m];
6810 }
6811 drop(nlp_borrow);
6812 // Compute g(x) via the user TNLP so the final residual is
6813 // populated for the user.
6814 let mut g_final = vec![0.0; m];
6815 let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
6816 let f_final = tnlp
6817 .borrow_mut()
6818 .eval_f(&x_vec, true)
6819 .unwrap_or(Number::NAN);
6820 let snap = FinalizeSnapshot {
6821 status: solver_status,
6822 x: x_vec,
6823 z_l,
6824 z_u,
6825 g: g_final,
6826 lambda,
6827 obj_value: f_final,
6828 };
6829 snap.replay(tnlp);
6830 *sink.borrow_mut() = Some(snap);
6831 Ok(f_final)
6832}
6833
6834/// Bind SQP suboptions registered in `upstream_options.rs`
6835/// (`sqp_globalization`, `sqp_hessian`, `sqp_max_iter`, `sqp_tol`,
6836/// `sqp_constr_viol_tol`, `sqp_dual_inf_tol`, `sqp_l1_penalty`,
6837/// `sqp_bt_reduction`, `sqp_bt_min_alpha`, `sqp_print_level`,
6838/// `sqp_lbfgs_max_history`) onto
6839/// `opts`. Used by [`IpoptApplication::algorithm_builder_snapshot`]
6840/// before constructing an SQP algorithm.
6841fn apply_sqp_options(options: &OptionsList, opts: &mut crate::sqp::SqpOptions) {
6842 use crate::sqp::{SqpGlobalization, SqpHessianSource};
6843
6844 if let Ok((s, true)) = options.get_string_value("sqp_globalization", "") {
6845 opts.globalization = match s.as_str() {
6846 "filter" => SqpGlobalization::Filter,
6847 "l1-elastic" => SqpGlobalization::L1Elastic,
6848 _ => opts.globalization,
6849 };
6850 }
6851 // `hessian_approximation` is the upstream Ipopt option a frontend sets
6852 // when the caller supplies no second derivatives -- `pounce.minimize` does
6853 // it automatically, and warns that it is doing so. It was only ever read
6854 // on the IPM path, so an SQP solve ignored it and fell back to the
6855 // `Exact` default, asking the NLP for a Lagrangian Hessian that was never
6856 // provided. A zero Hessian turns the QP subproblem into an LP, which is
6857 // unbounded whenever the objective gradient has a component in the null
6858 // space of the active constraints -- so the solve died with
6859 // `Internal_Error` on problems the IPM handles without complaint:
6860 //
6861 // min (x0-3)^2 + (x1-2)^2 s.t. 4 - x0 - x1 >= 0
6862 //
6863 // (IPM: x = [2.5, 1.5]. Active-set SQP before this: Internal_Error, or
6864 // with variable bounds, a run to the box corner along the null-space
6865 // direction.)
6866 //
6867 // The quasi-Newton source picked here is the *dense Powell-damped BFGS*,
6868 // not the limited-memory one, even though the requesting option is spelled
6869 // `limited-memory`. On this active-set-SQP path L-BFGS buys nothing: its
6870 // `as_triplet` materializes a full dense `n×n` Hessian for the QP
6871 // subproblem exactly as `DampedBfgs` does (the matrix-free product
6872 // interface that would make L-BFGS cheaper is not implemented yet), and it
6873 // is markedly less robust -- it stalls with
6874 // `Search_Direction_Becomes_Too_Small` (or reports the QP subproblem
6875 // `unbounded`) on easy, well-conditioned convex QPs whenever a general
6876 // inequality is active at the optimum, returning `success=False` with a
6877 // wrong `x` (issue #358). `DampedBfgs` solves those. So the automatic
6878 // approximation the facade injects when no analytic Hessian is available
6879 // maps to the robust dense update; a caller who genuinely wants
6880 // limited-memory storage can still request it explicitly with
6881 // `sqp_hessian = "lbfgs"` below (read after this, so it wins).
6882 //
6883 // Read this before `sqp_hessian` so an explicit setting still wins.
6884 if let Ok((s, true)) = options.get_string_value("hessian_approximation", "") {
6885 if s == "limited-memory" {
6886 opts.hessian = SqpHessianSource::DampedBfgs;
6887 }
6888 }
6889 if let Ok((s, true)) = options.get_string_value("sqp_hessian", "") {
6890 opts.hessian = match s.as_str() {
6891 "exact" => SqpHessianSource::Exact,
6892 "damped-bfgs" => SqpHessianSource::DampedBfgs,
6893 "lbfgs" => SqpHessianSource::Lbfgs,
6894 _ => opts.hessian,
6895 };
6896 }
6897 if let Ok((v, true)) = options.get_integer_value("sqp_max_iter", "") {
6898 if v >= 0 {
6899 opts.max_iter = v as u32;
6900 }
6901 }
6902 if let Ok((v, true)) = options.get_numeric_value("sqp_tol", "") {
6903 opts.tol = v;
6904 }
6905 if let Ok((v, true)) = options.get_numeric_value("sqp_constr_viol_tol", "") {
6906 opts.constr_viol_tol = v;
6907 }
6908 if let Ok((v, true)) = options.get_numeric_value("sqp_dual_inf_tol", "") {
6909 opts.dual_inf_tol = v;
6910 }
6911 if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty", "") {
6912 opts.l1_penalty = v;
6913 }
6914 if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_safety", "") {
6915 opts.l1_penalty_safety = v;
6916 }
6917 if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_max", "") {
6918 opts.l1_penalty_max = v;
6919 }
6920 if let Ok((v, true)) = options.get_numeric_value("sqp_bt_reduction", "") {
6921 opts.bt_reduction = v;
6922 }
6923 if let Ok((v, true)) = options.get_numeric_value("sqp_bt_min_alpha", "") {
6924 opts.bt_min_alpha = v;
6925 }
6926 if let Ok((v, true)) = options.get_integer_value("sqp_print_level", "") {
6927 opts.print_level = v.clamp(0, u8::MAX as i32) as u8;
6928 }
6929 if let Ok((v, true)) = options.get_integer_value("sqp_lbfgs_max_history", "") {
6930 if v >= 1 {
6931 opts.lbfgs_max_history = v as u32;
6932 }
6933 }
6934}
6935
6936/// Populate the active-set SQP **QP-subproblem** options
6937/// ([`pounce_qp::QpOptions`]) from the `sqp_qp_*` option family.
6938///
6939/// Sister to [`apply_sqp_options`], which handles the SQP *outer-loop*
6940/// options ([`crate::sqp::SqpOptions`]); this one feeds the inner QP
6941/// solver that `SqpAlgorithm` delegates each subproblem to. Consulted
6942/// only on the `ActiveSetSqp` path. Each knob is forwarded only when
6943/// the user explicitly set it, so the `pounce_qp` defaults stand
6944/// otherwise.
6945///
6946/// The reading itself is [`pounce_qp::ActiveSetOverrides`], shared with
6947/// `pounce_convex`'s direct active-set driver, which overlays the same
6948/// eight names onto the same `QpOptions` type. This function had its own
6949/// copy until then, and the two had drifted: this one silently ignored a
6950/// `sqp_qp_max_iter` of 0 and an unknown `sqp_qp_anti_cycling` value where
6951/// the other rejected them. Neither divergence was reachable — the
6952/// registry bounds `sqp_qp_max_iter` at 1 and restricts `anti_cycling` to
6953/// three values — but two readers of one option family is how a
6954/// reachable one starts.
6955fn apply_qp_subproblem_options(options: &OptionsList, opts: &mut pounce_qp::QpOptions) {
6956 match pounce_qp::ActiveSetOverrides::try_from_options_list(options) {
6957 Ok(overrides) => overrides.apply(opts),
6958 // Unreachable from here: this runs after `initialize()`, so every
6959 // value present has already been validated against the registered
6960 // bound that the reader re-checks. Say so out loud anyway rather
6961 // than solving with a configuration the user did not ask for —
6962 // silently dropping the whole family is exactly the failure mode
6963 // `tests/no_silent_options.rs` exists to prevent.
6964 Err(error) => tracing::error!(
6965 target: "pounce::options",
6966 %error,
6967 "sqp_qp_* options were rejected after the registry accepted them; \
6968 the QP subproblem is running on pounce-qp defaults"
6969 ),
6970 }
6971}
6972
6973/// SQP-side analog of [`finalize_via_orig_nlp`]. Hands the SQP
6974/// solution iterate to the user TNLP via the standard
6975/// `finalize_solution` callback. Multiplier lifting goes through
6976/// the same OrigIpoptNlp hooks so the user sees the same shape
6977/// regardless of which algorithm produced the iterate.
6978///
6979/// Returns the user-space objective value on success.
6980fn finalize_via_sqp(
6981 nlp: &Rc<RefCell<dyn IpoptNlp>>,
6982 res: &crate::sqp::SqpResult,
6983 solver_status: pounce_nlp::SolverReturn,
6984 tnlp: &Rc<RefCell<dyn TNLP>>,
6985 sink: &RefCell<Option<FinalizeSnapshot>>,
6986) -> Result<Number, ()> {
6987 use pounce_linalg::dense_vector::DenseVectorSpace;
6988
6989 let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
6990 let n = info.n as usize;
6991 let m = info.m as usize;
6992
6993 // Wrap SQP slices in DenseVectors so we can pass them through
6994 // the OrigIpoptNlp lift_x_to_full / pack_*_for_user hooks.
6995 let nlp_borrow = nlp.borrow();
6996 let n_alg = nlp_borrow.n() as usize;
6997 let m_eq = nlp_borrow.m_eq() as usize;
6998 let m_ineq = nlp_borrow.m_ineq() as usize;
6999 debug_assert_eq!(res.x.len(), n_alg);
7000 debug_assert_eq!(res.lambda_g.len(), m_eq + m_ineq);
7001 debug_assert_eq!(res.lambda_x.len(), n_alg);
7002
7003 let x_space = DenseVectorSpace::new(n_alg as Index);
7004 let c_space = DenseVectorSpace::new(m_eq as Index);
7005 let d_space = DenseVectorSpace::new(m_ineq as Index);
7006
7007 let mut x_dv = x_space.make_new_dense();
7008 x_dv.set_values(&res.x);
7009 let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&x_dv);
7010 debug_assert_eq!(x_vec.len(), n);
7011
7012 // λ_x is packed signed (z_l − z_u). Split for lift.
7013 let mut z_l_compressed = x_space.make_new_dense();
7014 let mut z_u_compressed = x_space.make_new_dense();
7015 let zl_vals: Vec<Number> = res.lambda_x.iter().map(|v| v.max(0.0)).collect();
7016 let zu_vals: Vec<Number> = res.lambda_x.iter().map(|v| (-v).max(0.0)).collect();
7017 z_l_compressed.set_values(&zl_vals);
7018 z_u_compressed.set_values(&zu_vals);
7019 // `finalize_solution_*` (not `pack_*`): report unscaled-Lagrangian duals,
7020 // dividing out `obj_scale_factor` — see `finalize_via_orig_nlp` (F1).
7021 let mut z_l = nlp_borrow.finalize_solution_z_l(&z_l_compressed);
7022 if z_l.is_empty() {
7023 z_l = vec![0.0; n];
7024 }
7025 let mut z_u = nlp_borrow.finalize_solution_z_u(&z_u_compressed);
7026 if z_u.is_empty() {
7027 z_u = vec![0.0; n];
7028 }
7029
7030 // λ_g is [y_c; y_d]; split into the c/d blocks for lift.
7031 let mut y_c_dv = c_space.make_new_dense();
7032 let mut y_d_dv = d_space.make_new_dense();
7033 if m_eq > 0 {
7034 y_c_dv.set_values(&res.lambda_g[..m_eq]);
7035 }
7036 if m_ineq > 0 {
7037 y_d_dv.set_values(&res.lambda_g[m_eq..]);
7038 }
7039 let mut lambda = nlp_borrow.finalize_solution_lambda(&y_c_dv, &y_d_dv);
7040 if lambda.is_empty() {
7041 lambda = vec![0.0; m];
7042 }
7043 drop(nlp_borrow);
7044
7045 let mut g_final = vec![0.0; m];
7046 let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
7047 let f_final = tnlp
7048 .borrow_mut()
7049 .eval_f(&x_vec, true)
7050 .unwrap_or(Number::NAN);
7051 let snap = FinalizeSnapshot {
7052 status: solver_status,
7053 x: x_vec,
7054 z_l,
7055 z_u,
7056 g: g_final,
7057 lambda,
7058 obj_value: f_final,
7059 };
7060 snap.replay(tnlp);
7061 *sink.borrow_mut() = Some(snap);
7062 Ok(f_final)
7063}
7064
7065#[cfg(test)]
7066mod tests {
7067 use super::*;
7068
7069 /// pounce#748 — the flipped default is conditional on the caller not
7070 /// having named a `mu_strategy`. All four combinations, because the
7071 /// point of the condition is that an explicit strategy suppresses the
7072 /// automatic retry while an explicit `mu_strategy_fallback` does not.
7073 #[test]
7074 fn mu_strategy_fallback_default_defers_to_an_explicit_strategy() {
7075 // Nothing set: the retry is on.
7076 let app = IpoptApplication::new();
7077 assert!(app.is_mu_strategy_fallback_enabled());
7078
7079 // Caller named a strategy: the automatic retry stands down.
7080 let mut app = IpoptApplication::new();
7081 app.options_mut()
7082 .set_string_value("mu_strategy", "monotone", true, false)
7083 .unwrap();
7084 assert!(!app.is_mu_strategy_fallback_enabled());
7085
7086 // ... unless they also asked for the retry explicitly.
7087 app.options_mut()
7088 .set_string_value("mu_strategy_fallback", "yes", true, false)
7089 .unwrap();
7090 assert!(app.is_mu_strategy_fallback_enabled());
7091
7092 // An explicit "no" is honoured with no strategy set.
7093 let mut app = IpoptApplication::new();
7094 app.options_mut()
7095 .set_string_value("mu_strategy_fallback", "no", true, false)
7096 .unwrap();
7097 assert!(!app.is_mu_strategy_fallback_enabled());
7098 }
7099
7100 use pounce_nlp::tnlp::{
7101 BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution,
7102 SparsityRequest, StartingPoint,
7103 };
7104
7105 struct Hs071Stub;
7106 impl TNLP for Hs071Stub {
7107 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
7108 // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
7109 // dense lower-triangular Hessian (10 nz).
7110 Some(NlpInfo {
7111 n: 4,
7112 m: 2,
7113 nnz_jac_g: 8,
7114 nnz_h_lag: 10,
7115 index_style: IndexStyle::C,
7116 })
7117 }
7118 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
7119 b.x_l.copy_from_slice(&[1.0; 4]);
7120 b.x_u.copy_from_slice(&[5.0; 4]);
7121 b.g_l.copy_from_slice(&[25.0, 40.0]);
7122 b.g_u.copy_from_slice(&[2.0e19, 40.0]);
7123 true
7124 }
7125 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
7126 sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
7127 true
7128 }
7129 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
7130 Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
7131 }
7132 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
7133 grad.fill(0.0);
7134 true
7135 }
7136 fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
7137 g.fill(0.0);
7138 true
7139 }
7140 fn eval_jac_g(
7141 &mut self,
7142 _x: Option<&[Number]>,
7143 _new_x: bool,
7144 mode: SparsityRequest<'_>,
7145 ) -> bool {
7146 if let SparsityRequest::Structure { irow, jcol } = mode {
7147 irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
7148 jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
7149 }
7150 true
7151 }
7152 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
7153 }
7154
7155 #[test]
7156 fn application_default_does_not_select_sqp() {
7157 let mut app = IpoptApplication::new();
7158 app.initialize().unwrap();
7159 assert!(!app.is_sqp_algorithm_selected());
7160 }
7161
7162 #[test]
7163 fn application_routes_to_sqp_when_algorithm_option_set() {
7164 let mut app = IpoptApplication::new();
7165 app.initialize().unwrap();
7166 app.initialize_with_options_str("algorithm active-set-sqp\n")
7167 .unwrap();
7168 assert!(app.is_sqp_algorithm_selected());
7169 }
7170
7171 #[test]
7172 fn feral_min_par_flops_option_reaches_config() {
7173 let mut app = IpoptApplication::new();
7174 app.initialize().unwrap();
7175 // Unset on the OptionsList: falls through to FeralConfig::from_env,
7176 // which leaves it None (inherit feral's built-in default) when the
7177 // POUNCE_FERAL_MIN_PAR_FLOPS env var is also absent.
7178 assert_eq!(
7179 feral_config_from_options(app.options()).min_par_flops,
7180 None,
7181 "unset feral_min_par_flops should not force an override"
7182 );
7183 // Explicit set is mapped through, cast to u64. `0` is the "dispatch
7184 // on every eligible tree" setting and must survive the cast.
7185 app.initialize_with_options_str("feral_min_par_flops 0\n")
7186 .unwrap();
7187 assert_eq!(
7188 feral_config_from_options(app.options()).min_par_flops,
7189 Some(0)
7190 );
7191 // A large finite value passes through intact (5e8 > i32::MAX, which
7192 // is why this is a number option, not an integer one).
7193 app.initialize_with_options_str("feral_min_par_flops 5e8\n")
7194 .unwrap();
7195 assert_eq!(
7196 feral_config_from_options(app.options()).min_par_flops,
7197 Some(500_000_000)
7198 );
7199 }
7200
7201 #[test]
7202 fn feral_refine_steps_option_reaches_config() {
7203 let mut app = IpoptApplication::new();
7204 app.initialize().unwrap();
7205 // Unset: falls through to FeralConfig::from_env, which resolves to
7206 // feral's own DEFAULT_REFINE_MAX_STEPS. The bump to feral 0.17.0 is
7207 // deliberately behaviour-preserving here — the cap that gh#710 wants
7208 // to measure (1) is not yet the default.
7209 assert_eq!(
7210 feral_config_from_options(app.options()).refine_max_steps,
7211 pounce_feral::FeralConfig::default().refine_max_steps,
7212 "unset feral_refine_steps must keep feral's own default"
7213 );
7214 // The setting gh#710 exists to evaluate.
7215 app.initialize_with_options_str("feral_refine_steps 1\n")
7216 .unwrap();
7217 assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 1);
7218 // `0` is a legal cap (zero corrections, refined entry point still
7219 // taken) and must not be read as "unset".
7220 app.initialize_with_options_str("feral_refine_steps 0\n")
7221 .unwrap();
7222 assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 0);
7223 }
7224
7225 #[test]
7226 fn feral_static_pivoting_option_reaches_config() {
7227 let mut app = IpoptApplication::new();
7228 app.initialize().unwrap();
7229 // Unset on the OptionsList: falls through to FeralConfig::from_env,
7230 // which leaves it None (inherit feral's delayed-pivot default) when
7231 // the POUNCE_FERAL_STATIC_PIVOTING env var is also absent — so the
7232 // default numeric path is unchanged.
7233 assert_eq!(
7234 feral_config_from_options(app.options()).static_pivoting,
7235 None,
7236 "unset feral_static_pivoting must not force a numeric override"
7237 );
7238 // Explicit `yes` maps to Some(true): every supernode factors with
7239 // delayed pivoting disabled (feral#8 cascade breaker).
7240 app.initialize_with_options_str("feral_static_pivoting yes\n")
7241 .unwrap();
7242 assert_eq!(
7243 feral_config_from_options(app.options()).static_pivoting,
7244 Some(true)
7245 );
7246 // Explicit `no` maps to Some(false): keep delayed pivoting on
7247 // (distinct from unset, which merely inherits the default).
7248 app.initialize_with_options_str("feral_static_pivoting no\n")
7249 .unwrap();
7250 assert_eq!(
7251 feral_config_from_options(app.options()).static_pivoting,
7252 Some(false)
7253 );
7254 }
7255
7256 /// Convex equality NLP fixture for end-to-end SQP testing
7257 /// through `IpoptApplication`:
7258 ///
7259 /// min ½(x₁² + x₂²) − x₁ − 2x₂ s.t. x₁ + x₂ = 1
7260 ///
7261 /// Closed form: x* = (0, 1), obj = -1.5, λ_g = 1.
7262 struct ConvexEqTnlp {
7263 finalize_called: std::rc::Rc<std::cell::RefCell<Option<(Vec<Number>, Number)>>>,
7264 }
7265 impl TNLP for ConvexEqTnlp {
7266 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
7267 Some(NlpInfo {
7268 n: 2,
7269 m: 1,
7270 nnz_jac_g: 2,
7271 nnz_h_lag: 2,
7272 index_style: IndexStyle::C,
7273 })
7274 }
7275 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
7276 b.x_l.copy_from_slice(&[-2.0e19; 2]);
7277 b.x_u.copy_from_slice(&[2.0e19; 2]);
7278 b.g_l.copy_from_slice(&[1.0]);
7279 b.g_u.copy_from_slice(&[1.0]);
7280 true
7281 }
7282 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
7283 sp.x.copy_from_slice(&[0.0, 0.0]);
7284 true
7285 }
7286 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
7287 Some(0.5 * (x[0] * x[0] + x[1] * x[1]) - x[0] - 2.0 * x[1])
7288 }
7289 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
7290 grad[0] = x[0] - 1.0;
7291 grad[1] = x[1] - 2.0;
7292 true
7293 }
7294 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
7295 g[0] = x[0] + x[1];
7296 true
7297 }
7298 fn eval_jac_g(
7299 &mut self,
7300 _x: Option<&[Number]>,
7301 _new_x: bool,
7302 mode: SparsityRequest<'_>,
7303 ) -> bool {
7304 match mode {
7305 SparsityRequest::Structure { irow, jcol } => {
7306 irow.copy_from_slice(&[0, 0]);
7307 jcol.copy_from_slice(&[0, 1]);
7308 }
7309 SparsityRequest::Values { values, .. } => {
7310 values.copy_from_slice(&[1.0, 1.0]);
7311 }
7312 }
7313 true
7314 }
7315 fn eval_h(
7316 &mut self,
7317 _x: Option<&[Number]>,
7318 _new_x: bool,
7319 _obj_factor: Number,
7320 _lambda: Option<&[Number]>,
7321 _new_lambda: bool,
7322 mode: SparsityRequest<'_>,
7323 ) -> bool {
7324 match mode {
7325 SparsityRequest::Structure { irow, jcol } => {
7326 irow.copy_from_slice(&[0, 1]);
7327 jcol.copy_from_slice(&[0, 1]);
7328 }
7329 SparsityRequest::Values { values, .. } => {
7330 values.copy_from_slice(&[1.0, 1.0]);
7331 }
7332 }
7333 true
7334 }
7335 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
7336 *self.finalize_called.borrow_mut() = Some((sol.x.to_vec(), sol.obj_value));
7337 }
7338 }
7339
7340 /// A TNLP that solves normally once, then declines to supply bounds.
7341 ///
7342 /// The second `optimize_constrained` therefore bails long before the
7343 /// statistics block that records `row_scaling_active`, which is the
7344 /// path the fail-closed reset exists for.
7345 struct DescribesItselfOnce {
7346 /// Flipped by the test between the two solves. A call counter
7347 /// would not work: these hooks are called more than once per
7348 /// solve, so it would trip inside the first one.
7349 refuse: std::rc::Rc<std::cell::Cell<bool>>,
7350 inner: ExactQuadratic,
7351 }
7352 impl TNLP for DescribesItselfOnce {
7353 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
7354 self.inner.get_nlp_info()
7355 }
7356 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
7357 // Declining here is a documented TNLP outcome and unwinds
7358 // cleanly; declining `get_nlp_info` mid-flight panics instead,
7359 // which would test the panic path rather than the reset.
7360 if self.refuse.get() {
7361 return false;
7362 }
7363 self.inner.get_bounds_info(b)
7364 }
7365 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
7366 self.inner.get_starting_point(sp)
7367 }
7368 fn eval_f(&mut self, x: &[Number], n: bool) -> Option<Number> {
7369 self.inner.eval_f(x, n)
7370 }
7371 fn eval_grad_f(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
7372 self.inner.eval_grad_f(x, n, g)
7373 }
7374 fn eval_g(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
7375 self.inner.eval_g(x, n, g)
7376 }
7377 fn eval_jac_g(&mut self, x: Option<&[Number]>, n: bool, mode: SparsityRequest<'_>) -> bool {
7378 self.inner.eval_jac_g(x, n, mode)
7379 }
7380 fn eval_h(
7381 &mut self,
7382 x: Option<&[Number]>,
7383 n: bool,
7384 o: Number,
7385 l: Option<&[Number]>,
7386 nl: bool,
7387 mode: SparsityRequest<'_>,
7388 ) -> bool {
7389 self.inner.eval_h(x, n, o, l, nl, mode)
7390 }
7391 fn finalize_solution(&mut self, s: Solution<'_>, d: &IpoptData, c: &IpoptCq) {
7392 self.inner.finalize_solution(s, d, c)
7393 }
7394 }
7395
7396 /// gh#794 review round 2: `row_scaling_active` must be fail-closed.
7397 ///
7398 /// It is written near the end of `optimize_constrained`, from the NLP
7399 /// that solve built. A solve that bails before that point used to
7400 /// leave the *previous* solve's value in place — and the ℓ₁ outer
7401 /// loop calls `optimize_constrained` repeatedly and reads the flag
7402 /// after each call, so a stale `Some(false)` would let it mirror an
7403 /// original-units violation into the scaled family. That is exactly
7404 /// the units contract the flag was added to protect.
7405 ///
7406 /// The two solves here are the shape that matters: the first records
7407 /// a verdict, the second never gets far enough to record one.
7408 /// Removing the reset at the top of `optimize_constrained` makes this
7409 /// fail with `Some(false)` where `None` is required — checked, not
7410 /// assumed.
7411 #[test]
7412 fn row_scaling_active_is_cleared_when_a_later_solve_bails_early() {
7413 let mut app = IpoptApplication::new();
7414 app.initialize().unwrap();
7415
7416 // Solve 1: reaches the statistics block and records a verdict.
7417 // `ExactQuadratic` supplies `eval_h`, so the solve stays on the
7418 // exact-Hessian NLP path that writes this flag; a fixture without
7419 // one lands on L-BFGS instead.
7420 let refuse = std::rc::Rc::new(std::cell::Cell::new(false));
7421 let first = std::rc::Rc::new(std::cell::RefCell::new(DescribesItselfOnce {
7422 refuse: std::rc::Rc::clone(&refuse),
7423 inner: ExactQuadratic,
7424 }));
7425 let _ = app
7426 .optimize_tnlp(std::rc::Rc::clone(&first) as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
7427 assert!(
7428 app.row_scaling_active.get().is_some(),
7429 "the first solve did not record a row-scaling verdict, so this \
7430 test cannot show the second one clearing it",
7431 );
7432
7433 // Solve 2, same application: bails before recording anything.
7434 refuse.set(true);
7435 let _ = app.optimize_tnlp(first as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
7436
7437 assert_eq!(
7438 app.row_scaling_active.get(),
7439 None,
7440 "a solve that bailed before recording row scaling left the \
7441 previous solve's verdict in place; the ℓ₁ outer loop would \
7442 read it as fact and mirror an original-units violation into \
7443 the scaled family (gh#794 review round 2)",
7444 );
7445 }
7446
7447 #[test]
7448 fn application_sqp_path_solves_convex_eq_nlp_and_finalizes() {
7449 let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
7450 let tnlp = std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
7451 finalize_called: std::rc::Rc::clone(&finalize_slot),
7452 }));
7453
7454 let mut app = IpoptApplication::new();
7455 app.initialize().unwrap();
7456 app.initialize_with_options_str("algorithm active-set-sqp\n")
7457 .unwrap();
7458 let status = app.optimize_tnlp(tnlp);
7459 assert_eq!(status, ApplicationReturnStatus::SolveSucceeded);
7460
7461 // The TNLP's finalize_solution must have been invoked.
7462 let recv = finalize_slot.borrow().clone();
7463 let (x_recv, obj_recv) = recv.expect("finalize_solution was not called");
7464 assert_eq!(x_recv.len(), 2);
7465 assert!((x_recv[0] - 0.0).abs() < 1e-6, "x[0] = {}", x_recv[0]);
7466 assert!((x_recv[1] - 1.0).abs() < 1e-6, "x[1] = {}", x_recv[1]);
7467 assert!(
7468 (obj_recv - (-1.5)).abs() < 1e-6,
7469 "obj = {} but expected -1.5",
7470 obj_recv
7471 );
7472 }
7473
7474 #[test]
7475 fn application_routes_to_sqp_case_insensitively() {
7476 let mut app = IpoptApplication::new();
7477 app.initialize().unwrap();
7478 app.initialize_with_options_str("algorithm Active-Set-SQP\n")
7479 .unwrap();
7480 // get_string_value may return the value as-stored (no
7481 // normalization); the dispatch must handle case
7482 // insensitively per the c11 design choice.
7483 assert!(app.is_sqp_algorithm_selected());
7484 }
7485
7486 #[test]
7487 fn application_constructs_and_loads_options() {
7488 let mut app = IpoptApplication::new();
7489 app.initialize().unwrap();
7490 // ipopt.opt-style file: an integer-typed option registered by
7491 // the Interfaces layer.
7492 app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
7493 .unwrap();
7494 let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
7495 assert!(found);
7496 assert_eq!(level, 5);
7497 }
7498
7499 #[test]
7500 fn application_sqp_suboptions_propagate_to_builder() {
7501 // All SQP suboptions are read by algorithm_builder_snapshot
7502 // and baked into the builder's `sqp` field.
7503 let mut app = IpoptApplication::new();
7504 app.initialize().unwrap();
7505 app.initialize_with_options_str(
7506 "algorithm active-set-sqp\n\
7507 sqp_globalization l1-elastic\n\
7508 sqp_hessian lbfgs\n\
7509 sqp_max_iter 17\n\
7510 sqp_tol 1e-7\n\
7511 sqp_constr_viol_tol 1e-5\n\
7512 sqp_dual_inf_tol 1e-3\n\
7513 sqp_l1_penalty 2.5\n\
7514 sqp_bt_reduction 0.25\n\
7515 sqp_bt_min_alpha 1e-10\n\
7516 sqp_print_level 2\n\
7517 sqp_lbfgs_max_history 12\n",
7518 )
7519 .unwrap();
7520 let snap = app.algorithm_builder_snapshot();
7521 assert_eq!(
7522 snap.sqp.globalization,
7523 crate::sqp::SqpGlobalization::L1Elastic
7524 );
7525 assert_eq!(snap.sqp.hessian, crate::sqp::SqpHessianSource::Lbfgs);
7526 assert_eq!(snap.sqp.max_iter, 17);
7527 assert!((snap.sqp.tol - 1e-7).abs() < 1e-18);
7528 assert!((snap.sqp.constr_viol_tol - 1e-5).abs() < 1e-18);
7529 assert!((snap.sqp.dual_inf_tol - 1e-3).abs() < 1e-18);
7530 assert!((snap.sqp.l1_penalty - 2.5).abs() < 1e-18);
7531 assert!((snap.sqp.bt_reduction - 0.25).abs() < 1e-18);
7532 assert!((snap.sqp.bt_min_alpha - 1e-10).abs() < 1e-18);
7533 assert_eq!(snap.sqp.print_level, 2);
7534 assert_eq!(snap.sqp.lbfgs_max_history, 12);
7535 }
7536
7537 /// Every `sqp_qp_*` key that [`apply_qp_subproblem_options`] reads must
7538 /// actually be *registered*, and must reach `pounce_qp::QpOptions`.
7539 ///
7540 /// The whole family was readable-but-unregistered (gh #360): the options
7541 /// registry rejected each one with OPTION_INVALID, so the reader was
7542 /// unreachable and the documented knobs were unusable. This is the guard
7543 /// that class of omission needs — it fails both if a key stops being
7544 /// registered and if a newly-read key is never registered at all.
7545 #[test]
7546 fn application_sqp_qp_subproblem_options_are_registered_and_propagate() {
7547 use pounce_qp::AntiCyclingChoice;
7548
7549 // Source of truth: the keys `apply_qp_subproblem_options` reads.
7550 // Kept in step with that function by the round-trip assertions below.
7551 let mut app = IpoptApplication::new();
7552 app.initialize().unwrap();
7553 app.initialize_with_options_str(
7554 "algorithm active-set-sqp\n\
7555 sqp_qp_max_iter 37\n\
7556 sqp_qp_feas_tol 1e-7\n\
7557 sqp_qp_opt_tol 2e-7\n\
7558 sqp_qp_elastic_gamma 1e4\n\
7559 sqp_qp_anti_cycling bland\n\
7560 sqp_qp_use_schur_updates yes\n\
7561 sqp_qp_max_schur_updates_before_refactor 12\n\
7562 sqp_qp_use_homotopy yes\n\
7563 sqp_qp_certify_second_order yes\n",
7564 )
7565 .expect("every sqp_qp_* option must be registered (gh #360)");
7566
7567 let qp = &app.algorithm_builder_snapshot().sqp_qp;
7568 assert_eq!(qp.max_iter, 37);
7569 assert!((qp.feas_tol - 1e-7).abs() < 1e-20);
7570 assert!((qp.opt_tol - 2e-7).abs() < 1e-20);
7571 assert!((qp.elastic_gamma - 1e4).abs() < 1e-9);
7572 assert_eq!(qp.anti_cycling, AntiCyclingChoice::Bland);
7573 // The Schur update path was implemented but reachable only through
7574 // `SqpAlgorithm::with_qp_options`, so no CLI/library user could turn
7575 // it on — the same unreachable-knob defect gh #360 fixed for the rest
7576 // of this family.
7577 assert!(qp.use_schur_updates);
7578 assert_eq!(qp.max_schur_updates_before_refactor, 12);
7579 assert!(qp.use_homotopy);
7580 // gh #848. Off by default on this path (see
7581 // `QpOptions::sqp_subproblem`), so the value that proves the wire is
7582 // live is `yes` — asserting the default would pass on a reader that
7583 // never ran.
7584 assert!(qp.certify_second_order);
7585
7586 // Untouched options must keep the SQP subproblem base, not be
7587 // overwritten with zeros by the "explicitly set" gate. That base is
7588 // `QpOptions::default()` in every field but one; the exception is
7589 // asserted below.
7590 let mut app = IpoptApplication::new();
7591 app.initialize().unwrap();
7592 app.initialize_with_options_str("algorithm active-set-sqp\n")
7593 .unwrap();
7594 let defaults = pounce_qp::QpOptions::default();
7595 let qp = &app.algorithm_builder_snapshot().sqp_qp;
7596 assert_eq!(qp.max_iter, defaults.max_iter);
7597 assert!((qp.feas_tol - defaults.feas_tol).abs() < 1e-20);
7598 assert!((qp.opt_tol - defaults.opt_tol).abs() < 1e-20);
7599 assert_eq!(qp.anti_cycling, defaults.anti_cycling);
7600 // Default stays OFF, and that is a measured choice: enabling it breaks
7601 // 9 of the 46 Maros-Meszaros instances the default path solves
7602 // correctly. Do not flip this without re-running that comparison.
7603 assert!(!qp.use_schur_updates);
7604 assert_eq!(
7605 qp.max_schur_updates_before_refactor,
7606 defaults.max_schur_updates_before_refactor
7607 );
7608 // Off by default *on the SQP path*, and deliberately different from
7609 // `QpOptions::default()` — which is what a standalone `solve_qp`
7610 // gets, and where it is on. gh #848 / gh #856.
7611 assert!(!qp.certify_second_order);
7612 assert!(pounce_qp::QpOptions::default().certify_second_order);
7613 }
7614
7615 /// The other direction of the gh #360 guard: every **registered**
7616 /// `sqp_qp_*` option must be one `apply_qp_subproblem_options` actually
7617 /// reads.
7618 ///
7619 /// The sister test above checks read-keys-are-registered. It cannot catch
7620 /// the inverse, and the inverse happened: `sqp_qp_use_homotopy` was
7621 /// registered with the homotopy work and never wired into the reader, so
7622 /// setting it on the SQP path silently did nothing while the option's own
7623 /// documentation described what it would do. A registered knob that no
7624 /// code reads is worse than a missing one — it validates, it accepts a
7625 /// value, and it lies.
7626 ///
7627 /// Adding a new `sqp_qp_*` option therefore fails here until it is both
7628 /// read by `apply_qp_subproblem_options` and asserted in the round-trip
7629 /// test above.
7630 #[test]
7631 fn application_every_registered_sqp_qp_option_is_read_by_the_subproblem_reader() {
7632 let mut app = IpoptApplication::new();
7633 app.initialize().unwrap();
7634
7635 let mut registered: Vec<String> = app
7636 .registered_options()
7637 .registered_options_in_order()
7638 .iter()
7639 .map(|o| o.name.clone())
7640 .filter(|n| n.starts_with("sqp_qp_"))
7641 .collect();
7642 registered.sort();
7643
7644 // Kept in step by hand with the `options.get_*_value("sqp_qp_…")`
7645 // calls in `apply_qp_subproblem_options`, and cross-checked by the
7646 // round-trip assertions in the sister test.
7647 let mut read_by_the_reader = vec![
7648 "sqp_qp_anti_cycling".to_string(),
7649 "sqp_qp_certify_second_order".to_string(),
7650 "sqp_qp_elastic_gamma".to_string(),
7651 "sqp_qp_feas_tol".to_string(),
7652 "sqp_qp_max_iter".to_string(),
7653 "sqp_qp_max_schur_updates_before_refactor".to_string(),
7654 "sqp_qp_opt_tol".to_string(),
7655 "sqp_qp_use_homotopy".to_string(),
7656 "sqp_qp_use_schur_updates".to_string(),
7657 ];
7658 read_by_the_reader.sort();
7659
7660 assert_eq!(
7661 registered, read_by_the_reader,
7662 "registered sqp_qp_* options and the ones \
7663 `apply_qp_subproblem_options` reads have diverged. A key that is \
7664 registered but unread is a no-op knob with working documentation \
7665 (that is how `sqp_qp_use_homotopy` shipped); a key read but not \
7666 registered is gh #360. Wire it up in both places, assert it in \
7667 `application_sqp_qp_subproblem_options_are_registered_and_propagate`, \
7668 then add it here."
7669 );
7670 }
7671
7672 #[test]
7673 fn application_sqp_hessian_approximation_maps_to_damped_bfgs() {
7674 // The frontend sets `hessian_approximation = limited-memory` when no
7675 // exact Lagrangian Hessian is available (e.g. `pounce.minimize` with
7676 // no `hess`). On the active-set-SQP path that must resolve to the
7677 // dense Powell-damped BFGS, NOT the limited-memory update: L-BFGS
7678 // materializes the same dense Hessian for the QP subproblem yet stalls
7679 // (`Search_Direction_Becomes_Too_Small` / wrong `x`) on convex QPs with
7680 // an active inequality (issue #358); damped-BFGS solves them.
7681 let mut app = IpoptApplication::new();
7682 app.initialize().unwrap();
7683 app.initialize_with_options_str(
7684 "algorithm active-set-sqp\n\
7685 hessian_approximation limited-memory\n",
7686 )
7687 .unwrap();
7688 assert_eq!(
7689 app.algorithm_builder_snapshot().sqp.hessian,
7690 crate::sqp::SqpHessianSource::DampedBfgs
7691 );
7692
7693 // An explicit `sqp_hessian = lbfgs` is still honored (it is read after
7694 // `hessian_approximation`, so it wins): callers who genuinely want the
7695 // limited-memory update can still ask for it.
7696 let mut app = IpoptApplication::new();
7697 app.initialize().unwrap();
7698 app.initialize_with_options_str(
7699 "algorithm active-set-sqp\n\
7700 hessian_approximation limited-memory\n\
7701 sqp_hessian lbfgs\n",
7702 )
7703 .unwrap();
7704 assert_eq!(
7705 app.algorithm_builder_snapshot().sqp.hessian,
7706 crate::sqp::SqpHessianSource::Lbfgs
7707 );
7708 }
7709
7710 /// `builder.linear_solver` must name the backend that will actually be
7711 /// built, not the one the option string asked for.
7712 ///
7713 /// MA57 is behind the optional `ma57` cargo feature; without it
7714 /// `default_backend_factory` silently substitutes FERAL. Recording `Ma57`
7715 /// anyway made the field disagree with reality, and the Schur KKT gate in
7716 /// `alg_builder::build_with_backend` (which tests `== Feral`) consumed that
7717 /// disagreement — so `set_kkt_schur_block()` never engaged on the default
7718 /// pure-Rust build for any user, while the transparent fallback kept every
7719 /// answer correct and every test green.
7720 #[test]
7721 fn application_linear_solver_records_the_effective_backend() {
7722 // Default options resolve to FERAL in *every* build. The registry
7723 // used to default to upstream's "ma57", which meant an HSL build
7724 // silently ran MA57 without being asked and a pure-Rust build
7725 // advertised a backend it did not contain; the default now names
7726 // pounce's own solver and HSL is opt-in (gh#483 follow-up).
7727 let mut app = IpoptApplication::new();
7728 app.initialize().unwrap();
7729 assert_eq!(
7730 app.algorithm_builder_from_options().linear_solver,
7731 LinearSolverChoice::Feral,
7732 "the registered default is `feral`, in an ma57 build too"
7733 );
7734
7735 // An explicit ma57 request resolves the same way.
7736 let mut app = IpoptApplication::new();
7737 app.initialize().unwrap();
7738 app.initialize_with_options_str("linear_solver ma57\n")
7739 .unwrap();
7740 let got = app.algorithm_builder_from_options().linear_solver;
7741 if cfg!(feature = "ma57") {
7742 assert_eq!(got, LinearSolverChoice::Ma57);
7743 } else {
7744 assert_eq!(got, LinearSolverChoice::Feral);
7745 }
7746
7747 // An explicit feral request is honored in every build.
7748 let mut app = IpoptApplication::new();
7749 app.initialize().unwrap();
7750 app.initialize_with_options_str("linear_solver feral\n")
7751 .unwrap();
7752 assert_eq!(
7753 app.algorithm_builder_from_options().linear_solver,
7754 LinearSolverChoice::Feral
7755 );
7756 }
7757
7758 /// gh#746. `IpAlgBuilder.cpp:1059` substitutes `adaptive` for the
7759 /// registered `monotone` when `hessian_approximation` is
7760 /// limited-memory and the caller left `mu_strategy` alone. pounce
7761 /// read the registered default unconditionally, which is a
7762 /// different barrier schedule on the whole quasi-Newton arm.
7763 #[test]
7764 fn limited_memory_defaults_mu_strategy_to_adaptive() {
7765 // Exact Hessian, nothing set: monotone, as registered.
7766 let mut app = IpoptApplication::new();
7767 app.initialize().unwrap();
7768 assert_eq!(
7769 app.algorithm_builder_from_options().mu_strategy,
7770 MuStrategyChoice::Monotone,
7771 "the exact arm must keep the registered default"
7772 );
7773
7774 // Limited memory, nothing set: adaptive.
7775 let mut app = IpoptApplication::new();
7776 app.initialize().unwrap();
7777 app.initialize_with_options_str("hessian_approximation limited-memory\n")
7778 .unwrap();
7779 assert_eq!(
7780 app.algorithm_builder_from_options().mu_strategy,
7781 MuStrategyChoice::Adaptive,
7782 "limited-memory must take upstream's quasi-Newton default"
7783 );
7784
7785 // An explicit `monotone` still wins — the substitution is only
7786 // for an absent option.
7787 let mut app = IpoptApplication::new();
7788 app.initialize().unwrap();
7789 app.initialize_with_options_str(
7790 "hessian_approximation limited-memory\n\
7791 mu_strategy monotone\n",
7792 )
7793 .unwrap();
7794 assert_eq!(
7795 app.algorithm_builder_from_options().mu_strategy,
7796 MuStrategyChoice::Monotone,
7797 "an explicit mu_strategy must not be overridden"
7798 );
7799 }
7800
7801 /// The μ-strategy auto-fallback retries with the *other* strategy.
7802 /// Under limited-memory the first attempt is adaptive, so the flip
7803 /// has to be monotone — reading the registered default there would
7804 /// re-run the strategy that just stalled (gh#746).
7805 #[test]
7806 fn fallback_flip_follows_the_resolved_mu_strategy() {
7807 let mut app = IpoptApplication::new();
7808 app.initialize().unwrap();
7809 assert!(
7810 !app.effective_mu_strategy_is_adaptive(),
7811 "unset + exact resolves to monotone"
7812 );
7813
7814 let mut app = IpoptApplication::new();
7815 app.initialize().unwrap();
7816 app.initialize_with_options_str("hessian_approximation limited-memory\n")
7817 .unwrap();
7818 assert!(
7819 app.effective_mu_strategy_is_adaptive(),
7820 "unset + limited-memory resolves to adaptive"
7821 );
7822
7823 let mut app = IpoptApplication::new();
7824 app.initialize().unwrap();
7825 app.initialize_with_options_str(
7826 "hessian_approximation limited-memory\n\
7827 mu_strategy monotone\n",
7828 )
7829 .unwrap();
7830 assert!(
7831 !app.effective_mu_strategy_is_adaptive(),
7832 "an explicit monotone under limited-memory resolves to monotone"
7833 );
7834 }
7835
7836 #[test]
7837 fn application_limited_memory_options_propagate_to_builder() {
7838 use crate::hess::lim_mem_quasi_newton::UpdateType;
7839
7840 // Default: no options set -> bit-exact with Ipopt's default
7841 // (bfgs, history 6). This is what the IPM path runs unless the
7842 // user opts in, so it must not drift.
7843 let mut app = IpoptApplication::new();
7844 app.initialize().unwrap();
7845 let def = app.algorithm_builder_from_options();
7846 assert_eq!(def.limited_memory_update_type, UpdateType::Bfgs);
7847 assert_eq!(def.limited_memory_max_history, 6);
7848
7849 // `limited_memory_update_type=sr1` and a custom history length
7850 // must reach the builder (these were registered upstream but
7851 // read nowhere on the IPM path before — see #131). Honoring
7852 // them is what lets SR1 break the monotone L-BFGS stall.
7853 let mut app = IpoptApplication::new();
7854 app.initialize().unwrap();
7855 app.initialize_with_options_str(
7856 "hessian_approximation limited-memory\n\
7857 limited_memory_update_type sr1\n\
7858 limited_memory_max_history 9\n",
7859 )
7860 .unwrap();
7861 let snap = app.algorithm_builder_from_options();
7862 assert_eq!(snap.limited_memory_update_type, UpdateType::Sr1);
7863 assert_eq!(snap.limited_memory_max_history, 9);
7864 }
7865
7866 #[test]
7867 fn application_recalc_y_is_wired_and_defaults_off() {
7868 // #677. Upstream registers `recalc_y` as `no`, but its option
7869 // text ends "If a limited memory quasi-Newton option is chosen,
7870 // this is used by default" — the effective default is
7871 // conditional on the Hessian approximation. pounce refused the
7872 // option outright before this, so an L-BFGS user had no way to
7873 // reach Ipopt's behaviour.
7874
7875 // Exact Hessian: off, matching the registered default.
7876 let mut app = IpoptApplication::new();
7877 app.initialize().unwrap();
7878 let b = app.algorithm_builder_from_options();
7879 assert!(!b.recalc_y, "exact-Hessian default must stay off");
7880 assert_eq!(b.recalc_y_feas_tol, 1e-6, "default changed");
7881
7882 // Limited memory: also off. Upstream's option text says it is
7883 // used by default there; pounce deliberately does not, because
7884 // auto-enabling took 7 of 57 fixtures from solved to not solved
7885 // on the L-BFGS leg with nothing moving the other way. See the
7886 // read site in `algorithm_builder_from_options`. If this
7887 // assertion is what fails, the auto-enable is being restored —
7888 // re-run `scripts/sweep-fixtures.sh` and explain those 7 first.
7889 let mut app = IpoptApplication::new();
7890 app.initialize().unwrap();
7891 app.initialize_with_options_str("hessian_approximation limited-memory\n")
7892 .unwrap();
7893 assert!(
7894 !app.algorithm_builder_from_options().recalc_y,
7895 "limited-memory must not silently enable recalc_y"
7896 );
7897
7898 // An explicit `yes` reaches the exact-Hessian path, which
7899 // used to be refused as unimplemented.
7900 let mut app = IpoptApplication::new();
7901 app.initialize().unwrap();
7902 app.initialize_with_options_str("recalc_y yes\nrecalc_y_feas_tol 1e-3\n")
7903 .unwrap();
7904 let b = app.algorithm_builder_from_options();
7905 assert!(b.recalc_y);
7906 assert_eq!(b.recalc_y_feas_tol, 1e-3);
7907 }
7908
7909 #[test]
7910 fn application_limited_memory_initialization_propagates_to_builder() {
7911 use crate::hess::lim_mem_quasi_newton::InitialApprox;
7912
7913 // #677: registered with upstream's `scalar1` default and read
7914 // nowhere, so every limited-memory solve ran `scalar2` and
7915 // setting the option was a silent no-op. Each keyword must now
7916 // reach the builder.
7917 for (kw, want) in [
7918 ("scalar1", InitialApprox::Scalar1),
7919 ("scalar2", InitialApprox::Scalar2),
7920 ("scalar3", InitialApprox::Scalar3),
7921 ("scalar4", InitialApprox::Scalar4),
7922 ("constant", InitialApprox::Constant),
7923 ("history-max", InitialApprox::HistoryMax),
7924 ] {
7925 let mut app = IpoptApplication::new();
7926 app.initialize().unwrap();
7927 app.initialize_with_options_str(&format!(
7928 "hessian_approximation limited-memory\n\
7929 limited_memory_initialization {kw}\n"
7930 ))
7931 .unwrap();
7932 assert_eq!(
7933 app.algorithm_builder_from_options()
7934 .limited_memory_initialization,
7935 want,
7936 "limited_memory_initialization={kw} did not reach the builder"
7937 );
7938 }
7939
7940 // `limited_memory_init_val` was unread too — the empty-history
7941 // branch hard-coded the same 1.0, so the miss was invisible.
7942 let mut app = IpoptApplication::new();
7943 app.initialize().unwrap();
7944 app.initialize_with_options_str(
7945 "hessian_approximation limited-memory\n\
7946 limited_memory_init_val 4.5\n",
7947 )
7948 .unwrap();
7949 assert_eq!(
7950 app.algorithm_builder_from_options().limited_memory_init_val,
7951 4.5
7952 );
7953
7954 // The effective default matches the registry and Ipopt
7955 // (`scalar1`). This is the assertion that would have caught #677
7956 // when the option was first registered: it pins the *selection*,
7957 // which the per-formula tests in `lim_mem_quasi_newton` cannot
7958 // see. Do not relax it to make an unrelated change pass.
7959 let mut app = IpoptApplication::new();
7960 app.initialize().unwrap();
7961 let def = app.algorithm_builder_from_options();
7962 assert_eq!(def.limited_memory_initialization, InitialApprox::Scalar1);
7963 assert_eq!(def.limited_memory_init_val, 1.0);
7964 }
7965
7966 #[test]
7967 fn application_sqp_warm_start_round_trip() {
7968 // Drive the convex-equality TNLP through the SQP path
7969 // twice. The first solve produces a working set; the
7970 // second is warm-started from it. The second must converge
7971 // with zero QP solves (the first KKT check declares
7972 // optimality immediately).
7973 let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
7974 let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
7975 std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
7976 finalize_called: std::rc::Rc::clone(&finalize_slot),
7977 }));
7978
7979 let mut app = IpoptApplication::new();
7980 app.initialize().unwrap();
7981 app.initialize_with_options_str("algorithm active-set-sqp\n")
7982 .unwrap();
7983
7984 // Cold solve.
7985 let status_a = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
7986 assert_eq!(status_a, ApplicationReturnStatus::SolveSucceeded);
7987 let ws = app.last_sqp_working_set().cloned();
7988 assert!(ws.is_some(), "cold solve must yield a working set");
7989
7990 // Build the warm-start iterate from the converged finalize
7991 // payload (just x; pad multipliers to 0 since the test
7992 // problem is convex).
7993 let (x_recv, _) = finalize_slot.borrow().clone().unwrap();
7994 let warm = crate::sqp::SqpIterates {
7995 x: x_recv,
7996 lambda_g: vec![1.0],
7997 lambda_x: vec![0.0, 0.0],
7998 working: ws,
7999 };
8000 app.set_sqp_warm_start(warm);
8001
8002 // Warm solve.
8003 let status_b = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
8004 assert_eq!(status_b, ApplicationReturnStatus::SolveSucceeded);
8005 assert!(app.last_sqp_working_set().is_some());
8006 }
8007
8008 #[test]
8009 fn application_sqp_warm_start_auto_clears_after_use() {
8010 let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
8011 let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
8012 std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
8013 finalize_called: std::rc::Rc::clone(&finalize_slot),
8014 }));
8015 let mut app = IpoptApplication::new();
8016 app.initialize().unwrap();
8017 app.initialize_with_options_str("algorithm active-set-sqp\n")
8018 .unwrap();
8019 app.set_sqp_warm_start(crate::sqp::SqpIterates {
8020 x: vec![0.0, 1.0],
8021 lambda_g: vec![1.0],
8022 lambda_x: vec![0.0, 0.0],
8023 working: None,
8024 });
8025 assert!(app.sqp_warm_start.is_some());
8026 let _ = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
8027 assert!(
8028 app.sqp_warm_start.is_none(),
8029 "warm-start input must be auto-cleared after use"
8030 );
8031 }
8032
8033 #[test]
8034 fn application_sqp_suboptions_default_when_unset() {
8035 // Without any sqp_* settings, the snapshot should equal
8036 // SqpOptions::default().
8037 let mut app = IpoptApplication::new();
8038 app.initialize().unwrap();
8039 let snap = app.algorithm_builder_snapshot();
8040 let d = crate::sqp::SqpOptions::default();
8041 assert_eq!(snap.sqp.globalization, d.globalization);
8042 assert_eq!(snap.sqp.hessian, d.hessian);
8043 assert_eq!(snap.sqp.max_iter, d.max_iter);
8044 assert!((snap.sqp.tol - d.tol).abs() < 1e-18);
8045 assert!((snap.sqp.constr_viol_tol - d.constr_viol_tol).abs() < 1e-18);
8046 assert!((snap.sqp.dual_inf_tol - d.dual_inf_tol).abs() < 1e-18);
8047 assert!((snap.sqp.l1_penalty - d.l1_penalty).abs() < 1e-18);
8048 assert!((snap.sqp.bt_reduction - d.bt_reduction).abs() < 1e-18);
8049 assert!((snap.sqp.bt_min_alpha - d.bt_min_alpha).abs() < 1e-18);
8050 assert_eq!(snap.sqp.print_level, d.print_level);
8051 assert_eq!(snap.sqp.lbfgs_max_history, d.lbfgs_max_history);
8052 }
8053
8054 #[test]
8055 fn application_reports_problem_dimensions() {
8056 let app = IpoptApplication::new();
8057 let mut tnlp = Hs071Stub;
8058 let info = app.problem_dimensions(&mut tnlp).unwrap();
8059 assert_eq!(info.n, 4);
8060 assert_eq!(info.m, 2);
8061 assert_eq!(info.nnz_jac_g, 8);
8062 assert_eq!(info.nnz_h_lag, 10);
8063 }
8064
8065 /// Each of the four constant-derivative hints reaches the algorithm,
8066 /// and reaches its *own* slot (#551 / #677).
8067 ///
8068 /// All four were wired and consumed — gh#588 Q6 made pounce exploit
8069 /// them — but the read site looped over
8070 /// `constant_derivatives::HINT_OPTIONS`, so the registered-but-unread
8071 /// scan saw a loop variable where it needs a literal key and reported
8072 /// all four as silent no-ops. They are literals now, and this pins
8073 /// what a literal-per-name rewrite can get wrong that a loop could
8074 /// not: setting one hint must light up that hint's slot and no other,
8075 /// because `reconcile` pairs `asserted[k]` with the model's proof for
8076 /// `HINT_OPTIONS[k]` and a transposed pair would reuse the wrong
8077 /// derivative.
8078 #[test]
8079 fn each_constant_derivative_hint_lights_up_its_own_slot() {
8080 use pounce_nlp::constant_derivatives::HINT_OPTIONS;
8081
8082 let app = IpoptApplication::new();
8083 assert_eq!(
8084 app.asserted_constant_derivative_hints(),
8085 [false; 4],
8086 "no hint is asserted on a fresh options list",
8087 );
8088
8089 for (k, name) in HINT_OPTIONS.iter().enumerate() {
8090 let mut app = IpoptApplication::new();
8091 app.initialize().unwrap();
8092 app.initialize_with_options_str(&format!("{name} yes\n"))
8093 .unwrap();
8094 let mut expected = [false; 4];
8095 expected[k] = true;
8096 assert_eq!(
8097 app.asserted_constant_derivative_hints(),
8098 expected,
8099 "`{name}=yes` must set slot {k} and nothing else",
8100 );
8101
8102 // …and the registered default asks for nothing, so an
8103 // `ipopt.opt` that spells it out changes no derivative reuse.
8104 let mut app = IpoptApplication::new();
8105 app.initialize().unwrap();
8106 app.initialize_with_options_str(&format!("{name} no\n"))
8107 .unwrap();
8108 assert_eq!(
8109 app.asserted_constant_derivative_hints(),
8110 [false; 4],
8111 "`{name}=no` is the registered default and asserts nothing",
8112 );
8113 }
8114 }
8115
8116 /// `min x²` on `[-10, 10]` from `x = 1`, with an *exactly correct*
8117 /// gradient `2x`. Unconstrained and one-dimensional so the only
8118 /// thing the derivative checker can react to is its own step size
8119 /// and threshold.
8120 ///
8121 /// The forward difference at step `h` is `((1+h)² − 1)/h = 2 + h`,
8122 /// so the deviation from the analytic `2` is exactly `h`, and the
8123 /// relative test flags it when `h > tol·(2 + h)`. That makes the
8124 /// verdict a closed-form function of the two knobs under test.
8125 struct ExactQuadratic;
8126 impl TNLP for ExactQuadratic {
8127 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
8128 Some(NlpInfo {
8129 n: 1,
8130 m: 0,
8131 nnz_jac_g: 0,
8132 nnz_h_lag: 0,
8133 index_style: IndexStyle::C,
8134 })
8135 }
8136 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
8137 b.x_l.copy_from_slice(&[-10.0]);
8138 b.x_u.copy_from_slice(&[10.0]);
8139 true
8140 }
8141 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
8142 sp.x.copy_from_slice(&[1.0]);
8143 true
8144 }
8145 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
8146 Some(x[0] * x[0])
8147 }
8148 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
8149 grad[0] = 2.0 * x[0];
8150 true
8151 }
8152 fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
8153 true
8154 }
8155 fn eval_jac_g(
8156 &mut self,
8157 _x: Option<&[Number]>,
8158 _new_x: bool,
8159 _mode: SparsityRequest<'_>,
8160 ) -> bool {
8161 true
8162 }
8163 fn eval_h(
8164 &mut self,
8165 _x: Option<&[Number]>,
8166 _new_x: bool,
8167 _obj_factor: Number,
8168 _lambda: Option<&[Number]>,
8169 _new_lambda: bool,
8170 _mode: SparsityRequest<'_>,
8171 ) -> bool {
8172 true
8173 }
8174 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
8175 }
8176
8177 struct RecordingQuadratic {
8178 gradient_points: Rc<RefCell<Vec<Number>>>,
8179 objective_points: Rc<RefCell<Vec<Number>>>,
8180 x_scaling: Number,
8181 }
8182
8183 impl TNLP for RecordingQuadratic {
8184 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
8185 ExactQuadratic.get_nlp_info()
8186 }
8187
8188 fn get_bounds_info(&mut self, bounds: BoundsInfo<'_>) -> bool {
8189 ExactQuadratic.get_bounds_info(bounds)
8190 }
8191
8192 fn get_starting_point(&mut self, start: StartingPoint<'_>) -> bool {
8193 ExactQuadratic.get_starting_point(start)
8194 }
8195
8196 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
8197 self.objective_points.borrow_mut().push(x[0]);
8198 ExactQuadratic.eval_f(x, new_x)
8199 }
8200
8201 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
8202 self.gradient_points.borrow_mut().push(x[0]);
8203 grad[0] = 2.0 * x[0];
8204 true
8205 }
8206
8207 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
8208 ExactQuadratic.eval_g(x, new_x, g)
8209 }
8210
8211 fn eval_jac_g(
8212 &mut self,
8213 x: Option<&[Number]>,
8214 new_x: bool,
8215 mode: SparsityRequest<'_>,
8216 ) -> bool {
8217 ExactQuadratic.eval_jac_g(x, new_x, mode)
8218 }
8219
8220 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
8221 *req.obj_scaling = 1.0;
8222 *req.use_x_scaling = true;
8223 req.x_scaling[0] = self.x_scaling;
8224 *req.use_g_scaling = false;
8225 true
8226 }
8227
8228 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
8229 }
8230
8231 fn derivative_test_verdict(extra: &str) -> pounce_nlp::derivative_test::DerivativeTestReport {
8232 let mut app = IpoptApplication::new();
8233 app.initialize().unwrap();
8234 app.initialize_with_options_str(&format!("derivative_test first-order\n{extra}"))
8235 .unwrap();
8236 let opts = app.derivative_test_options();
8237 pounce_nlp::derivative_test::run(&mut ExactQuadratic, &opts).expect("a report")
8238 }
8239
8240 /// `derivative_test_perturbation` and `derivative_test_tol` were
8241 /// registered, read into [`DerivativeTestOptions`], and consumed by
8242 /// the checker — but nothing proved a *set value* reached it, which
8243 /// is the only assertion that distinguishes a read site from a
8244 /// parse-and-discard (#677, #551).
8245 ///
8246 /// Each of the three verdicts below differs from the one above it by
8247 /// exactly one option, so a knob that stopped reaching the checker
8248 /// would collapse two of them together and fail here.
8249 #[test]
8250 fn the_derivative_checker_knobs_change_the_verdict() {
8251 // Registered defaults (1e-8 / 1e-4), which are also the read
8252 // site's fallbacks: a correct gradient looks correct.
8253 let clean = derivative_test_verdict("");
8254 assert_eq!(clean.checked, 1);
8255 assert_eq!(clean.suspicious, 0, "{:#?}", clean.lines);
8256
8257 // A coarse step makes the *same correct gradient* look wrong:
8258 // deviation 0.5 > 1e-4·2.5. Only `derivative_test_perturbation`
8259 // changed, so this is that option and nothing else.
8260 let coarse = derivative_test_verdict("derivative_test_perturbation 0.5\n");
8261 assert_eq!(coarse.checked, 1);
8262 assert_eq!(
8263 coarse.suspicious, 1,
8264 "derivative_test_perturbation never reached the checker: {:#?}",
8265 coarse.lines,
8266 );
8267
8268 // …and loosening the threshold at that same coarse step clears
8269 // it again: 0.5 < 0.5·2.5. Only `derivative_test_tol` changed.
8270 let tolerant =
8271 derivative_test_verdict("derivative_test_perturbation 0.5\nderivative_test_tol 0.5\n");
8272 assert_eq!(tolerant.checked, 1);
8273 assert_eq!(
8274 tolerant.suspicious, 0,
8275 "derivative_test_tol never reached the checker: {:#?}",
8276 tolerant.lines,
8277 );
8278
8279 // The report also prints what it used, so a user reading the
8280 // output can tell which step and threshold produced the verdict.
8281 assert!(
8282 tolerant.lines[0].contains("5.0e-1"),
8283 "{:#?}",
8284 tolerant.lines,
8285 );
8286 }
8287
8288 #[test]
8289 fn ordinary_derivative_test_keeps_the_conditioned_start() {
8290 let gradient_points = Rc::new(RefCell::new(Vec::new()));
8291 let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
8292 gradient_points: Rc::clone(&gradient_points),
8293 objective_points: Rc::new(RefCell::new(Vec::new())),
8294 x_scaling: 1.0,
8295 })) as Rc<RefCell<dyn TNLP>>;
8296 let mut app = IpoptApplication::new();
8297 app.initialize().unwrap();
8298 app.initialize_with_options_str(
8299 "derivative_test first-order\n\
8300 start_point_perturbation 0.5\n\
8301 hessian_approximation limited-memory\n\
8302 max_iter 0\n\
8303 print_level 0\n",
8304 )
8305 .unwrap();
8306
8307 let _ = app.optimize_tnlp(tnlp);
8308
8309 let first = gradient_points.borrow()[0];
8310 assert!((first - 1.7666216164272852).abs() < 1e-12, "{first}");
8311 }
8312
8313 #[test]
8314 fn ordinary_derivative_test_keeps_variable_scaling() {
8315 let objective_points = Rc::new(RefCell::new(Vec::new()));
8316 let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
8317 gradient_points: Rc::new(RefCell::new(Vec::new())),
8318 objective_points: Rc::clone(&objective_points),
8319 x_scaling: 0.5,
8320 })) as Rc<RefCell<dyn TNLP>>;
8321 let mut app = IpoptApplication::new();
8322 app.initialize().unwrap();
8323 app.initialize_with_options_str(
8324 "derivative_test first-order\n\
8325 derivative_test_perturbation 0.5\n\
8326 nlp_scaling_method user-scaling\n\
8327 hessian_approximation limited-memory\n\
8328 max_iter 0\n\
8329 print_level 0\n",
8330 )
8331 .unwrap();
8332
8333 let _ = app.optimize_tnlp(tnlp);
8334
8335 assert_eq!(&objective_points.borrow()[..2], &[1.0, 2.0]);
8336 }
8337
8338 // ---- gh#887: the dominance gate ------------------------------------
8339 //
8340 // These pin the rule directly, on numbers measured off real runs,
8341 // because the fixture that motivated the gate turned out not to be a
8342 // portable witness for it. `deb7` on the L-BFGS leg with
8343 // `limited_memory_ls_failure_restarts=1` reaches a *materially
8344 // different answer* on macOS and on Linux -- different objective
8345 // (99.677 vs 99.651) and, decisively, a different shape:
8346 //
8347 // | run | unscaled dual | viol | compl | ratio |
8348 // | reproducer, .nl | 7.90e4 | 1.1e-16 | 1.1e-9 | 1.5e-14 |
8349 // | reproducer, TNLP | 3.25e11 | 2.5e-16 | 2.8e-3 | 8.7e-15 |
8350 // | deb7 + rung, macOS | 9.90e1 | 8.0e-13 | 4.65e0 | 4.7e-2 |
8351 // | deb7 + rung, Linux | 5.5743e3 | 5.6e-14 | 2.08e-5 | 3.7e-9 |
8352 //
8353 // The Linux row is the one worth reading twice: that answer really is
8354 // gh#884's shape (scaled overall error 5.28e-1 against unscaled
8355 // 5.57e3, which is the `s_d` normalisation hiding a runaway exactly
8356 // as it did on `qpec_small`), so the retry there is the designed cost
8357 // and not the waste gh#887 filed. A CLI assertion of "deb7 declines"
8358 // is therefore false on Linux no matter what the threshold is, which
8359 // is why the pin lives here instead.
8360
8361 /// The dominance rule on its own, with the absolute floor switched
8362 /// off (`du_floor = 0`), so each of these tests is about one rule.
8363 fn ratio_only(dual_inf: Number, viol: Number, compl: Number) -> bool {
8364 runaway_is_the_whole_residual(dual_inf, viol, compl, 0.0)
8365 }
8366
8367 /// The gate as it actually runs: dominance *and* the detector's own
8368 /// absolute floor.
8369 fn runaway(dual_inf: Number, viol: Number, compl: Number) -> bool {
8370 runaway_is_the_whole_residual(dual_inf, viol, compl, DUAL_DIV_RETRY_DU_FLOOR)
8371 }
8372
8373 #[test]
8374 fn a_converged_point_with_a_runaway_multiplier_opens_the_retry() {
8375 // The gh#884 reproducer, both routes it reaches the gate by.
8376 assert!(runaway(7.90e4, 1.1e-16, 1.1e-9));
8377 assert!(runaway(3.25e11, 2.5e-16, 2.8e-3));
8378 // deb7 under the rung on Linux: primal exact, complementarity
8379 // eight orders under its own dual residual. Same shape.
8380 assert!(runaway(5.5743e3, 5.6e-14, 2.08e-5));
8381 }
8382
8383 #[test]
8384 fn an_unconverged_point_does_not_open_the_retry() {
8385 // deb7 under the rung on macOS: complementarity 4.65, five
8386 // percent of its own KKT error. Not a runaway multiplier on an
8387 // otherwise-converged point -- just an unconverged point.
8388 assert!(!ratio_only(9.90e1, 8.0e-13, 4.65e0));
8389 // Either residual alone is enough to close it: the gate takes the
8390 // max, so a clean complementarity does not excuse a violated
8391 // constraint. (`1.0e1` against `1.0e6` is a ratio of `1e-5`; note
8392 // that `1.0e0` there would be `1e-6` exactly, i.e. inside.)
8393 assert!(!ratio_only(1.0e6, 1.0e1, 1.0e-16));
8394 assert!(!ratio_only(1.0e6, 1.0e-16, 1.0e1));
8395 }
8396
8397 /// The dominance ratio is scale-free, and that is exactly why it
8398 /// cannot be the whole gate: a point converged to `1e-30` primal with
8399 /// a dual residual of `4.4e-1` satisfies it as comfortably as
8400 /// gh#884's `7.9e+04` does, and `4.4e-1` is not a runaway by any
8401 /// reading of the issue. Measured on the 400-model QPEC family, the
8402 /// floor alone removes 7 of 68 promotions, every one of them on an
8403 /// answer whose reported dual residual was below `1e2`.
8404 ///
8405 /// The floor is the *detector's*, so this is the answer-level gate
8406 /// asking about the same magnitude the iterate-level one did rather
8407 /// than being a strictly looser copy of it.
8408 #[test]
8409 fn a_small_dual_residual_is_not_a_runaway_however_dominant() {
8410 // Passes the ratio comfortably (1e-30 / 4.4e-1 = 2.3e-30) ...
8411 assert!(ratio_only(4.4e-1, 1.0e-30, 1.0e-30));
8412 // ... and is still refused, because it is not a runaway.
8413 assert!(!runaway(4.4e-1, 1.0e-30, 1.0e-30));
8414 // The two real `r`-family answers that reached the gate this way.
8415 assert!(!runaway(4.397e-1, 1.0e-16, 1.0e-16));
8416 assert!(!runaway(2.026e1, 1.0e-16, 1.0e-16));
8417 // Exactly at the floor is inside; a hair under is outside.
8418 assert!(runaway(DUAL_DIV_RETRY_DU_FLOOR, 0.0, 0.0));
8419 assert!(!runaway(DUAL_DIV_RETRY_DU_FLOOR * 0.999, 0.0, 0.0));
8420 }
8421
8422 #[test]
8423 fn the_threshold_is_where_the_constant_says_it_is() {
8424 // Exactly at the ratio is inside; a hair past it is outside.
8425 assert!(ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE, 0.0));
8426 assert!(!ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE * 1.001, 0.0));
8427 }
8428
8429 #[test]
8430 fn what_we_cannot_measure_does_not_buy_a_retry() {
8431 // A NaN compares false everywhere, so the condition is written so
8432 // that "we cannot tell" declines rather than retries. Each
8433 // argument in turn.
8434 assert!(!ratio_only(Number::NAN, 0.0, 0.0));
8435 assert!(!ratio_only(1.0e6, Number::NAN, 0.0));
8436 assert!(!ratio_only(1.0e6, 0.0, Number::NAN));
8437 assert!(!ratio_only(Number::INFINITY, 0.0, 0.0));
8438 // And a dual residual that is not a runaway at all: the ratio
8439 // would be meaningless, and a zero-dual point is not gh#884.
8440 assert!(!ratio_only(0.0, 0.0, 0.0));
8441 assert!(!ratio_only(-1.0, 0.0, 0.0));
8442 }
8443
8444 // ---- the promotion gate reads the ANSWER, not only the certificate --
8445 //
8446 // gh#884 ranked the two attempts on unscaled KKT error alone and
8447 // argued that this could not return a different local solution,
8448 // because "conjunct 4 requires the promoted answer to satisfy the KKT
8449 // conditions in the model's own units". Any other KKT point does too.
8450 // Measured on 400 random QPECs under the `prod_eq` lowering
8451 // (`bound_relax_factor=0 mu_strategy_fallback=no tol=1e-8`): 68
8452 // promotions, 42 of which moved the objective materially, and three
8453 // of which returned a strictly worse *feasible* point.
8454 //
8455 // Every number below is off a real run, and the two branches are
8456 // separated on purpose -- a rule that branches needs a case on each
8457 // side or the untaken one stays broken while the test is green.
8458 //
8459 // | model | base f | base viol | retry f | retry viol | branch |
8460 // |------------|-------------|-----------|-------------|------------|--------|
8461 // | qpec_small | +3.586e-28 | 1.11e-16 | +5.835e-11 | 5.47e-12 | admit |
8462 // | r116 | -1.3006e+01 | 2.22e-16 | -1.2072e+00 | 4.55e-13 | rule 1 |
8463 // | r261 | -4.7919e+00 | 1.07e-14 | -9.8563e-01 | 7.99e-14 | rule 1 |
8464 // | r201 | -2.9559e-01 | 6.25e-17 | -9.7321e-02 | 6.21e-13 | rule 1 |
8465 // | scholtes4 | +1.8176e-09 | 2.07e-25 | -6.6088e-05 | 1.09e-09 | rule 2 |
8466
8467 const ACCEPT: Number = 1e-6;
8468 /// Minimization, which is every row of the table above.
8469 const MIN: Number = 1.0;
8470 /// Maximization, i.e. `obj_scaling_factor < 0`.
8471 const MAX: Number = -1.0;
8472
8473 fn admissible(bo: Number, bv: Number, ro: Number, rv: Number) -> bool {
8474 retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN)
8475 }
8476
8477 #[test]
8478 fn the_reproducers_promotion_is_still_admissible() {
8479 // `qpec_small`: the retry's objective is *worse*, by 5.8e-11 --
8480 // deliberately, since it buys nine orders of unscaled dual
8481 // residual. Five orders inside the tolerance, so rule 1 admits
8482 // it, which is the whole point of having a tolerance at all.
8483 assert!(admissible(3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12));
8484 }
8485
8486 /// Rule 1: a strictly worse feasible point is never an upgrade.
8487 #[test]
8488 fn a_worse_feasible_objective_is_refused_however_clean_the_certificate() {
8489 // r116: -13.0057 -> -1.2072, both independently verified feasible
8490 // by `pounce verify`. The retry's unscaled KKT error is 2.9e-11
8491 // against the base attempt's 3.0e+03, so every certificate
8492 // conjunct passes and only this one refuses it.
8493 assert!(!admissible(
8494 -1.3005680756e1,
8495 2.22e-16,
8496 -1.2072337962e0,
8497 4.55e-13
8498 ));
8499 assert!(!admissible(
8500 -4.7919265770e0,
8501 1.07e-14,
8502 -9.8562977711e-1,
8503 7.99e-14
8504 ));
8505 assert!(!admissible(
8506 -2.9558632401e-1,
8507 6.25e-17,
8508 -9.7321185691e-2,
8509 6.21e-13
8510 ));
8511 }
8512
8513 /// Rule 2: an objective *improvement* bought with primal slack.
8514 ///
8515 /// `scholtes4` (`benchmarks/mpcc/cases.py`, and now a CLI fixture) has
8516 /// `f* = 0` exactly -- for the MPCC and for the smooth lowering alike,
8517 /// since `x1*x2 = 0` forces one of them to zero, hence `x3 <= 0`, hence
8518 /// `f = x1 - x3 >= 0`. The retry reports `-6.61e-05`, which no feasible
8519 /// point reaches, by moving the complementarity row 16 orders further
8520 /// out. Rule 1 cannot see it: the objective got *better*.
8521 #[test]
8522 fn an_improvement_bought_with_primal_slack_is_refused() {
8523 assert!(!admissible(
8524 1.8175997416e-9,
8525 2.07e-25,
8526 -6.6088333055e-5,
8527 1.09e-9
8528 ));
8529 // The same numbers with the primal *held* would be admissible --
8530 // this is the conjunct that refuses it, not the objective move.
8531 assert!(admissible(
8532 1.8175997416e-9,
8533 2.07e-25,
8534 -6.6088333055e-5,
8535 2.07e-25
8536 ));
8537 }
8538
8539 /// The window the tolerance sits in, from both sides, so it is a
8540 /// checkable claim rather than a fitted constant: the smallest move
8541 /// that must be admitted is `qpec_small`'s `5.8e-11` and the smallest
8542 /// that must be refused is `r201`'s `0.198`, four and five orders
8543 /// away from `acceptable_tol` on either side.
8544 #[test]
8545 fn the_objective_tolerance_is_not_fitted_to_one_model() {
8546 let admit = 5.835e-11;
8547 let refuse = 0.198;
8548 assert!(admit < ACCEPT / 1.0e4, "{admit} is not well inside");
8549 assert!(refuse > ACCEPT * 1.0e4, "{refuse} is not well outside");
8550 // Scale-relative above 1, absolute below it -- the same
8551 // convention `sigma_forward_error_is_small` uses for `norm(x)`.
8552 assert!(admissible(1.0e6, 0.0, 1.0e6 + 0.5, 0.0));
8553 assert!(!admissible(1.0e6, 0.0, 1.0e6 + 5.0, 0.0));
8554 }
8555
8556 /// An infeasible base attempt is not a point worth protecting, so
8557 /// both rules stand down and the certificate conjuncts decide alone.
8558 /// R2: `obj_scaling_factor < 0` poses a maximization, and
8559 /// `final_objective` is the user's **signed** objective, so both rules
8560 /// have to follow the sense. This is the table above with every
8561 /// objective negated: the same three answers must be refused and the
8562 /// same one admitted, with `MAX` instead of `MIN`.
8563 ///
8564 /// Without the normalization each row flips — rule 1 starts refusing
8565 /// genuine improvements (a regression against the behaviour before the
8566 /// conjunct existed) and rule 2 starts admitting strictly worse
8567 /// answers, which is the class it was added to block.
8568 #[test]
8569 fn the_rules_follow_the_objective_sense() {
8570 // qpec_small, mirrored: the retry is worse by 5.8e-11, well inside
8571 // the tolerance, so it is still admitted.
8572 assert!(retry_answer_is_admissible(
8573 -3.586e-28, 1.11e-16, -5.835e-11, 5.47e-12, ACCEPT, MAX
8574 ));
8575 // r116, mirrored: +13.0057 given up for +1.2072 is now a *worse*
8576 // maximum, and rule 1 must still refuse it.
8577 assert!(!retry_answer_is_admissible(
8578 1.3005680756e1,
8579 2.22e-16,
8580 1.2072337962e0,
8581 4.55e-13,
8582 ACCEPT,
8583 MAX
8584 ));
8585 // scholtes4, mirrored: +6.6088e-05 is now an improvement bought
8586 // with primal slack, and rule 2 must still refuse it.
8587 assert!(!retry_answer_is_admissible(
8588 -1.8175997416e-9,
8589 2.07e-25,
8590 6.6088333055e-5,
8591 1.09e-9,
8592 ACCEPT,
8593 MAX
8594 ));
8595 // ... and the same improvement with the primal held is admitted.
8596 assert!(retry_answer_is_admissible(
8597 -1.8175997416e-9,
8598 2.07e-25,
8599 6.6088333055e-5,
8600 2.07e-25,
8601 ACCEPT,
8602 MAX
8603 ));
8604 }
8605
8606 /// The mirror of the above, stated as the property rather than the
8607 /// rows: negating both objectives and flipping the sense must leave
8608 /// every verdict unchanged.
8609 #[test]
8610 fn negating_the_objective_and_the_sense_is_inert() {
8611 for &(bo, bv, ro, rv) in &[
8612 (3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12),
8613 (-1.3005680756e1, 2.22e-16, -1.2072337962e0, 4.55e-13),
8614 (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 1.09e-9),
8615 (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 2.07e-25),
8616 (-1.0e3, 1.0e-2, 0.0, 1.0e-12),
8617 ] {
8618 assert_eq!(
8619 retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN),
8620 retry_answer_is_admissible(-bo, bv, -ro, rv, ACCEPT, MAX),
8621 "verdict moved under (obj, sense) -> (-obj, -sense) at {bo:e}/{ro:e}"
8622 );
8623 }
8624 }
8625
8626 #[test]
8627 fn an_infeasible_base_attempt_protects_nothing() {
8628 assert!(admissible(-1.0e3, 1.0e-2, 0.0, 1.0e-12));
8629 // ... and what cannot be measured is refused, not admitted, the
8630 // same way `runaway_is_the_whole_residual` treats a NaN.
8631 assert!(admissible(Number::NAN, 0.0, 0.0, 0.0));
8632 assert!(!admissible(0.0, 0.0, Number::NAN, 0.0));
8633 assert!(!admissible(0.0, 0.0, -1.0, Number::NAN));
8634 }
8635}