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/// gh#983: which scaling defect a success verdict shows.
138#[derive(Debug, Clone, Copy, PartialEq)]
139enum ScaleAudit {
140 /// Gradient-based scaling froze a tiny factor at a huge-gradient start:
141 /// re-evaluate it at the returned point and solve again from there.
142 Rescale,
143 /// The model's gradient is tiny and nothing scales it up: multiply the
144 /// objective by the carried factor (`1 / g`, `g` the gradient scale).
145 Upscale(Number),
146 /// The same small-objective signature, but a re-solve would not move the
147 /// objective's scale materially (`1/g` below [`SCALE_AUDIT_MATERIAL`]):
148 /// warn, do not re-solve.
149 SmallObjective,
150}
151
152/// A complementarity above this fraction of the objective's own gradient scale
153/// is read as "the objective is too small for the absolute tolerances".
154///
155/// gh#983 review item 4: a single threshold, used both to re-solve and to
156/// warn, so there is no band in which a run gets neither. Measured on the
157/// issue's reactor model (`issue_983_review.rs::cstr_*`), whose relative
158/// objective error is `~1.9x` this ratio: `fs = 1` reads `2.5e-10` (error
159/// `3.5e-8`), `fs = 1e-3` `2.5e-7` (`4.3e-7`), `3e-4` `8.4e-7` (`1.5e-6`),
160/// `1e-4` `2.5e-6` (`4.7e-6`), `3e-5` `8.4e-6` (`1.6e-5`), `1e-6` `2.5e-4`
161/// (`4.7e-4`). The first-pass value `1e-5` left `fs` in `[3e-5, 1e-3]` with
162/// errors up to `1.6e-5` and no signal at all. `1e-7` is a tenth of the default
163/// `acceptable_tol`: every run whose objective is predicted to be good to
164/// worse than about `2e-7` relative is either re-solved or warned about.
165const SCALE_AUDIT_COMPL_REL: Number = 1e-7;
166/// The small-objective audit only applies when the caller's `tol` is at most
167/// this (the default is `1e-8`).
168const SCALE_AUDIT_MAX_TOL: Number = 1e-6;
169/// The re-solve never multiplies the objective by more than this.
170const SCALE_AUDIT_MAX_UPSCALE: Number = 1e10;
171/// gh#983 review item 2: a re-solve is spent only when it would change the
172/// objective's scale by at least this factor. Below it the re-solve is the
173/// same solve again -- the first pass measured `hs71` times `1e8` paying a
174/// declined re-solve whose factor, re-evaluated at the optimum, was the one
175/// it already had.
176const SCALE_AUDIT_MATERIAL: Number = 10.0;
177/// The re-solve's iteration budget is this multiple of the base solve's
178/// iterations (never more than the caller's `max_iter`, never less than
179/// [`SCALE_AUDIT_RETRY_MIN_ITER`]).
180///
181/// The re-solve is a bet placed after the run has already succeeded, so it is
182/// bounded by what the run cost. Measured over the 64 audited solves of the
183/// 1,345-problem release sweep (`origin/main` @ `68e438d0`): every promoted
184/// re-solve but one took at most 2.4x the base iterations, or fewer than 50
185/// (`sreadin3` 8 -> 19, `reading2` 15 -> 29, `camshape_6400` 88 -> 112). The
186/// exception is `robot_b`, 271 -> 1425 iterations and 23 s -> 178 s for a
187/// 1.3e-6 relative move in the objective; it now keeps its base answer, which
188/// was already `Solve_Succeeded`. The declined side is where the cost was:
189/// `meyer3` 256 -> 3000 + 3000 (the re-solve's own mu flip), and the
190/// `brainpc` family, where a 1-2 s solve was followed by a re-solve of a
191/// minute or more that was then discarded -- under the benchmark's 300 s
192/// wall it never returned, so the base `Optimal` was lost as a timeout.
193const SCALE_AUDIT_RETRY_ITER_FACTOR: Index = 3;
194/// Floor on the re-solve's iteration budget, so a base solve of a handful of
195/// iterations still leaves the re-solve room to move.
196const SCALE_AUDIT_RETRY_MIN_ITER: Index = 50;
197/// Under a warm start, an objective gradient measured at an *answer* (the
198/// seed, or the returned point) that is within this factor of the run's
199/// unscaled complementarity is what the barrier alone leaves behind
200/// (`grad f = z_L - z_U` with `z ~ mu / s`) and carries no model scale.
201///
202/// Measured on `pounce-rs`'s `session::tests::cold_then_warm_through_presolve`
203/// (an interior optimum, objective `(x0 - 2)^2`): the cold solve reads a start
204/// gradient of `4` and is left alone, while the warm re-solve of the identical
205/// model starts at that answer (`grad f = 1.0e-11`) and returns at
206/// `grad f = 3.73e-9` against a complementarity of `3.72e-9` -- a ratio of
207/// `1.001`, which read as a "tiny objective" and paid a `2.7e8` up-scaled
208/// re-solve that doubled the warm solve's iterations. The reactor population
209/// the small-objective branch exists for has an answer gradient at least
210/// `4e3` times its complementarity (`fs = 1e-6`: `1e-5` against `2.5e-9`), so
211/// `10` -- the audit's own predicted relative error of `0.1` -- separates the
212/// two by more than two orders.
213const SCALE_AUDIT_BARRIER_GRAD: Number = 10.0;
214
215/// What the audit reads off a success verdict.
216#[derive(Debug, Clone, Copy)]
217struct ScaleAuditInput {
218 /// The caller's `tol`.
219 tol: Number,
220 /// The solver-computed (gradient-based) objective factor the run used.
221 obj_scale: Number,
222 /// The factor gradient-based scaling would compute **at the returned
223 /// point** (`gradient_obj_scale(max|grad f(x*)|)`), `NaN` if unknown.
224 rescaled_obj_scale: Number,
225 /// `max|grad f|` at the starting point (`NaN` when not measured).
226 start_grad_max: Number,
227 /// `max|grad f|` at the returned point (`NaN` when not measured).
228 final_grad_max: Number,
229 unscaled_kkt: Number,
230 unscaled_compl: Number,
231 acceptable_tol: Number,
232 masked_threshold: Number,
233 /// `warm_start_init_point=yes`: the start is an earlier answer, not an
234 /// independent measurement of the model.
235 warm_start: bool,
236}
237
238impl ScaleAuditInput {
239 /// The objective's gradient scale: the larger of the two measurements, so
240 /// a start that happens to sit near a stationary point (`grad f(x0) ~
241 /// 1e-7`, `grad f(x*) ~ 1`) does not read as a tiny objective.
242 ///
243 /// A start gradient measured as exactly `0` is a start *at* a stationary
244 /// point, which carries no scale at all; the answer's gradient is then ~0
245 /// too wherever the optimum is unconstrained (`scaled_feasible_a`, a
246 /// zero-residual least squares whose `x0` is its minimizer, reads `2e-4`
247 /// there against data of `1e5`). So that case reports no scale (`0`, the
248 /// audit stays out). An *unmeasured* start (`NaN`: the caller owns the
249 /// scaling) defers to the returned point.
250 ///
251 /// Under a warm start both measurements are taken at answers, so the
252 /// same reasoning applies to each of them: one that sits at the barrier
253 /// level ([`SCALE_AUDIT_BARRIER_GRAD`] times the complementarity) is the
254 /// stationary point's noise, not the model's scale, and when neither
255 /// clears it the audit stays out. Without this the verdict depended on
256 /// the start rather than the model: a cold solve of an interior optimum
257 /// is left alone, and a warm re-solve of the *same* data from its answer
258 /// was up-scaled and re-solved.
259 fn grad_scale(&self) -> Number {
260 let fin = |v: Number| if v.is_finite() && v > 0.0 { v } else { 0.0 };
261 if self.start_grad_max == 0.0 {
262 return 0.0;
263 }
264 if self.warm_start && self.unscaled_compl.is_finite() {
265 let floor = SCALE_AUDIT_BARRIER_GRAD * self.unscaled_compl;
266 let answer = |v: Number| if fin(v) > floor { v } else { 0.0 };
267 return answer(self.start_grad_max).max(answer(self.final_grad_max));
268 }
269 fin(self.start_grad_max).max(fin(self.final_grad_max))
270 }
271}
272
273/// Does a success verdict need the gh#983 scale audit, and which kind?
274///
275/// Pure so the thresholds are unit-testable.
276///
277/// gh#983 review item 2: neither branch is a function of the objective's
278/// magnitude alone. The re-scale branch requires the factor re-evaluated at
279/// the returned point to differ materially from the frozen one (a
280/// legitimately large objective re-evaluates to the factor it already has),
281/// and the small-objective branch reads the gradient at both ends of the run.
282fn scale_audit_trigger(inp: ScaleAuditInput) -> Option<ScaleAudit> {
283 use crate::conv_check::opt_error::certificate_masked;
284 if inp.obj_scale.is_finite()
285 && inp.obj_scale > 0.0
286 && inp.unscaled_kkt.is_finite()
287 && certificate_masked(
288 inp.obj_scale,
289 inp.unscaled_kkt,
290 inp.masked_threshold,
291 inp.acceptable_tol,
292 )
293 && inp.rescaled_obj_scale.is_finite()
294 && inp.rescaled_obj_scale >= SCALE_AUDIT_MATERIAL * inp.obj_scale
295 {
296 return Some(ScaleAudit::Rescale);
297 }
298 // The small-objective branch is about a *tight* absolute tolerance that is
299 // crude relative to the objective. A caller who loosened `tol` themselves
300 // (`tol = 5e-2` in `test_loose_mu_reports_ambiguous`) asked for the
301 // complementarity they got.
302 let g = inp.grad_scale();
303 if inp.tol <= SCALE_AUDIT_MAX_TOL
304 && g > 0.0
305 && g < 1.0
306 && inp.unscaled_compl.is_finite()
307 && inp.unscaled_compl / g > SCALE_AUDIT_COMPL_REL
308 {
309 let k = (1.0 / g).min(SCALE_AUDIT_MAX_UPSCALE);
310 return Some(if k >= SCALE_AUDIT_MATERIAL {
311 ScaleAudit::Upscale(k)
312 } else {
313 ScaleAudit::SmallObjective
314 });
315 }
316 None
317}
318
319/// Text of the structured warning for an audit that was not (or could not be)
320/// resolved by a re-solve. `outcome` completes "A re-solve ... <outcome>".
321fn scale_audit_warning(
322 kind: ScaleAudit,
323 base: &SolveStatistics,
324 grad_scale: Number,
325 acceptable_tol: Number,
326 outcome: &str,
327) -> String {
328 match kind {
329 ScaleAudit::Rescale => format!(
330 "unscaled_stationarity_above_tol: gradient-based scaling froze the objective \
331 factor at {:.2e} at the starting point and the reported point's unscaled KKT \
332 error is {:.2e} (acceptable_tol {:.0e}); the scaled test passed in a space \
333 where the objective gradient is shrunk by that factor. A re-solve from this \
334 point with the scaling re-evaluated {outcome}.",
335 base.final_obj_scaling_factor, base.final_unscaled_kkt_error, acceptable_tol
336 ),
337 ScaleAudit::Upscale(k) => format!(
338 "objective_scale_small: the objective gradient is at most {:.2e} (start and \
339 returned point) and nothing scales it up, so the unscaled complementarity {:.2e} \
340 is {:.1e} of that scale -- the objective is only good to about that relative \
341 error. A re-solve with the objective multiplied by {:.1e} {outcome}. Rescale the \
342 objective (e.g. obj_scaling_factor) to silence this.",
343 grad_scale,
344 base.final_unscaled_compl,
345 base.final_unscaled_compl / grad_scale,
346 k
347 ),
348 ScaleAudit::SmallObjective => format!(
349 "objective_scale_small: the objective gradient is at most {:.2e} (start and \
350 returned point), so the unscaled complementarity {:.2e} is {:.1e} of that scale \
351 -- the objective is only good to about that relative error. No re-solve: \
352 scaling the objective up by {:.1e} is below the factor of {SCALE_AUDIT_MATERIAL} \
353 that would change the solve. Rescale the objective (e.g. obj_scaling_factor) or \
354 tighten tol to silence this.",
355 grad_scale,
356 base.final_unscaled_compl,
357 base.final_unscaled_compl / grad_scale,
358 1.0 / grad_scale
359 ),
360 }
361}
362
363/// Model-scale caveats on a success verdict that need no re-solve.
364///
365/// * `large_dual_scale`: the terms the stationarity residual is made of
366/// reach `1e8` or more *and* the residual exceeds `acceptable_tol`, which is
367/// the failed-constraint-qualification signature (the cusp of gh#983 item 1 has multipliers of `3.5e9` and a
368/// residual of `0.144`). The scale-relative floor (gh#532) judges such a
369/// point against that scale, so it is accepted; the warning is how the
370/// caller finds out. It is not capped, because the cap that rejects the
371/// cusp (`~0.1`) also rejects Vanderbei's `orthrds2` (`89.7` at scale
372/// `1.6e10`), which is stationary to nine digits relative to its scale.
373/// Under `bound_relax_factor = 0` it names that option: every model of
374/// gh#1000 items II.22, II.23 and I.15 that carried it (pump network,
375/// process-network superstructure, Haverly multistart) had an active
376/// bound coinciding with a dependent row, and each ends `Solve_Succeeded`
377/// at the default relaxation. Ipopt follows the same trajectory at `0`.
378/// * `unscaled_stationarity_above_tol`: the unscaled residual sits above
379/// `1e-3` while the strict gate, which tolerates up to `dual_inf_tol`
380/// (`1` by default), passed it.
381fn quality_warnings(
382 status: ApplicationReturnStatus,
383 unscaled_dual_inf: Number,
384 dual_scale: Number,
385 dual_inf_tol: Number,
386 acceptable_tol: Number,
387 bound_relax_off: bool,
388) -> Vec<String> {
389 let mut out = Vec::new();
390 if !matches!(
391 status,
392 ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
393 ) {
394 return out;
395 }
396 // Only when the residual is itself above `acceptable_tol`: a legitimately
397 // large objective (`hs71` times `1e8`) has terms of `1e9` and a residual of
398 // `3e-7`, which is not a symptom of anything.
399 if dual_scale.is_finite()
400 && dual_scale >= 1e8
401 && unscaled_dual_inf.is_finite()
402 && unscaled_dual_inf > acceptable_tol
403 {
404 out.push(format!(
405 "large_dual_scale: the stationarity residual is assembled from terms of size {dual_scale:.2e} \
406 (multipliers or gradients this large usually mean a failed constraint qualification), \
407 so the unscaled dual infeasibility {unscaled_dual_inf:.2e} was judged relative to that scale{}",
408 if bound_relax_off {
409 ". bound_relax_factor = 0 is set: an active bound that coincides with a \
410 dependent row has no interior there, and the default relaxation (1e-8) \
411 usually removes this"
412 } else {
413 ""
414 }
415 ));
416 }
417 if unscaled_dual_inf.is_finite()
418 && unscaled_dual_inf > UNSCALED_DUAL_WARN
419 && unscaled_dual_inf > acceptable_tol
420 {
421 out.push(format!(
422 "unscaled_dual_inf_above_acceptable: the unscaled dual infeasibility is {unscaled_dual_inf:.2e}, \
423 above acceptable_tol {acceptable_tol:.0e}; the strict gate tolerates up to dual_inf_tol \
424 = {dual_inf_tol:.0e}. Verify stationarity in your own units"
425 ));
426 }
427 out
428}
429
430/// See [`quality_warnings`].
431///
432/// Also the bar for the gh#983 item-5 downgrade (`finalize_quality_verdict`).
433/// Measured population near it (gh#983 review), over the CLI fixture corpus on
434/// both sweep legs: six runs return `Solve_Succeeded` with the gh#884
435/// signature, at unscaled dual infeasibilities of `1.6e-9`
436/// (`linear_eq_collapsed_box`), `2.8e-9` (`autocorr_bern55-06`), `1.0e-8` /
437/// `1.1e-8` (`pooling_rt2stp`, both legs), `5.5e-8`
438/// (`mpcc_qpec_small_biactive`) and `3.6e-6` (`deb7`) -- the largest two and a
439/// half orders below the bar. The runs the bar exists for sit above it: the
440/// cusp with the floor opted back in at `1.44e-1`, and the first pass's
441/// toll-pricing report at `3.3e-2` (since gh#1000 exempt as roundoff, see
442/// [`UNSCALED_DUAL_ROUNDOFF_ULPS`]). So the threshold falls in an empty band of
443/// the measured population; nothing between `3.6e-6` and `3.3e-2` was observed,
444/// and the corpus says nothing about how dense that band is elsewhere (the
445/// 400-model QPEC family of `dev-notes/mpcc-biactive-dual-divergence.md` was
446/// not re-measured for this).
447const UNSCALED_DUAL_WARN: Number = 1e-3;
448
449/// gh#1000 (II.22): how many ulps of its own terms an unscaled stationarity
450/// residual may read and still be roundoff rather than a measured miss.
451///
452/// The residual `∇f + Jᵀλ + z` is assembled from terms of size
453/// `final_unscaled_dual_scale`, so in floating point it cannot read much below
454/// `ε·scale` however exact the point is. Under `bound_relax_factor = 0` an
455/// active bound and a row can be linearly dependent, the multipliers drift
456/// along the unbounded ray of valid ones, and the scale follows them: the
457/// pump-network NLP of the issue ends with multipliers near `1e13`, a residual
458/// of `5.5e-3` -- 1.6 ulps of its terms -- and an objective right to `5e-9`
459/// relative, and was downgraded on that `5.5e-3` alone. The measured
460/// population on either side, as `residual / scale`: that run at `3.5e-16`
461/// (`2.8e-16` on the Python path), and gh#983's toll-pricing MPCC at
462/// `bound_relax_factor = 0` at `1.1e-16` (`3.3e-2` against `3e14`, objective
463/// `9e-10` from the default-options solve), which gh#983 had pinned as a
464/// downgrade and which this exempts too; the downgrades that are correct --
465/// Haverly at `2e-13` (objective `7e-8` off, complementarity `1.8e-6`), the
466/// process network's all-zero-flow subproblem at `6.6e-13` (`6e-7` off) --
467/// and the cusp the downgrade was built for at `4e-11`. 64 ulps (`1.4e-14`)
468/// sits forty times above the first and fourteen times below the nearest
469/// correct downgrade.
470///
471/// What this cannot see: on a model with no finite multiplier (the cusp) the
472/// residual falls as the multipliers grow and the floor rises with them, so a
473/// run that drove them far enough would cross it. None measured has; the
474/// cusp sits more than three orders above. The `large_dual_scale` caveat
475/// still attaches to every run this exempts.
476const UNSCALED_DUAL_ROUNDOFF_ULPS: Number = 64.0;
477
478/// Is `du` within [`UNSCALED_DUAL_ROUNDOFF_ULPS`] of the rounding error of
479/// the terms it was assembled from? `false` whenever either input is not
480/// finite, so a path that does not measure the scale keeps the downgrade.
481fn unscaled_dual_inf_is_roundoff(du: Number, scale: Number) -> bool {
482 du.is_finite()
483 && scale.is_finite()
484 && scale > 0.0
485 && du <= UNSCALED_DUAL_ROUNDOFF_ULPS * Number::EPSILON * scale
486}
487
488/// The code of a structured warning: the text before the first `:`.
489fn warning_code(w: &str) -> &str {
490 w.split(':').next().unwrap_or(w)
491}
492
493/// Push `w` unless a warning with the same code is already attached
494/// (gh#983 review item 7: `unscaled_dual_inf_above_acceptable` was pushed
495/// twice, once by the downgrade and once by the caveat list).
496fn push_warning_once(warnings: &mut Vec<String>, w: String) {
497 if !warnings.iter().any(|x| warning_code(x) == warning_code(&w)) {
498 warnings.push(w);
499 }
500}
501
502/// gh#983 item 4: a retry violation at or below this is arithmetic noise, not
503/// "primal slack". Rule 2 compared the retry's violation to the base's with no
504/// floor, so a base at `3e-38` refused a retry at `2e-13` that halved the
505/// objective (`x*y == 0` from `(0.3, 0.3)`: base `f = 2` with unscaled dual
506/// infeasibility `1.4e9`, retry `f = 1`). The case rule 2 exists for,
507/// `scholtes4`, buys `6.6e-05` of objective with a violation of `1.09e-09`, an
508/// order above this floor, so it is still refused. The floor was `1e-12` until
509/// MacMPEC `pack-comp1p-32` (complementarity driver review): the retry reached
510/// the published optimum, `0.652997` against the base's `0.704` (whose own
511/// unscaled dual residual was `7.2e7`), at a violation of `2.6e-11` -- three
512/// orders inside `tol` and four inside `constr_viol_tol` -- and was refused
513/// because the base sat at `2.5e-12`. Two orders below the default `tol` is
514/// still far below any violation that changes which point a model accepts.
515const RETRY_VIOL_NOISE_FLOOR: Number = 1e-10;
516
517/// gh#983 review item 5: how many orders a retry's unscaled KKT error must sit
518/// below the base attempt's before it may be promoted without reaching
519/// `acceptable_tol` itself.
520const RETRY_DOMINANCE_ORDERS: Number = 1e-6;
521
522/// gh#983 review item 5: the second way conjunct 4 of the gh#884 promotion
523/// gate can hold.
524///
525/// Conjunct 4 demanded the retry's unscaled KKT error be at or below
526/// `acceptable_tol`. On the toll-pricing MPCC at `bound_relax_factor = 0` the
527/// base attempt returns `Solved_To_Acceptable_Level` at an unscaled dual
528/// infeasibility of `1.03e5` and the retry `Solve_Succeeded` at `1.69e-5`:
529/// ten orders better, and refused because `1.69e-5 > 1e-6`. That is a
530/// certificate an ordinary first solve would ship as `Solve_Succeeded`
531/// without a second look (it is under `1e-3`, the bar
532/// `finalize_quality_verdict` holds strict verdicts to), held to a stricter
533/// standard only because it was a retry, while the answer reported instead is
534/// one whose residual is the runaway itself.
535///
536/// So the retry's certificate also counts as real when **all** of:
537/// * it is at least six orders below the base attempt's
538/// (`RETRY_DOMINANCE_ORDERS`) -- not "better", *categorically* better;
539/// * it is at or below `UNSCALED_DUAL_WARN` (`1e-3`) in absolute terms, the
540/// same bar a strict verdict is held to everywhere else, so no promotion
541/// ships a residual the audit would flag on a first solve;
542/// * the retry's unscaled constraint violation is within `acceptable_tol`
543/// (the primal half is not relaxed at all).
544///
545/// The answer rules ([`retry_answer_is_admissible`]) and the `Solve_Succeeded`
546/// conjunct are unchanged and still apply. Why this does not let `ralph1`
547/// through: `ralph1`'s defect is that `perturb_always_cd` returns a wrong
548/// answer reported as success, and what keeps it out is that the retry is
549/// never *opened* there -- the detector's scale-relative step conjunct and
550/// the `1e2` dual floor on the reported answer
551/// (`runaway_is_the_whole_residual`), neither of which this touches. Within an
552/// opened retry the base residual is at least that floor, so the dominance
553/// conjunct alone would admit `1e-4`; the absolute `1e-3` cap and the
554/// unchanged answer rules are what bound it, and `issue884_*` pins both
555/// barriers (`ralph1` is in that corpus).
556fn retry_certificate_dominates(
557 base_unscaled_kkt: Number,
558 retry_unscaled_kkt: Number,
559 retry_viol: Number,
560 accept_tol: Number,
561) -> bool {
562 base_unscaled_kkt.is_finite()
563 && retry_unscaled_kkt.is_finite()
564 && retry_viol.is_finite()
565 && retry_unscaled_kkt <= RETRY_DOMINANCE_ORDERS * base_unscaled_kkt
566 && retry_unscaled_kkt <= UNSCALED_DUAL_WARN
567 && retry_viol <= accept_tol
568}
569
570/// Is the retry's answer admissible *as an answer*, next to the base
571/// attempt's — independent of which has the better multiplier?
572///
573/// gh#884's promotion gate ranked the two attempts on unscaled KKT error
574/// alone. That is a statement about the **certificate**, and it was
575/// allowed to decide which **point** shipped, on the argument that
576/// "conjunct 4 requires the promoted answer to satisfy the KKT conditions
577/// in the model's own units" — so, unlike the μ flip, this retry could not
578/// return a different local solution. The inference does not hold: *any*
579/// other KKT point satisfies the KKT conditions in the model's own units
580/// too. Measured on 400 random QPECs (`prod_eq` lowering), 42 of 68
581/// promotions moved the objective materially, i.e. returned a different
582/// local solution, and three returned a **worse feasible point** — worst
583/// case `-13.0057 → -1.2072`, both independently verified feasible.
584///
585/// Two rules, and both are about the *answer* rather than its certificate:
586///
587/// 1. **Never hand back a feasible point whose objective is worse than one
588/// this run already computed.** The base attempt's point is feasible
589/// and in hand; returning a worse one is a regression no certificate
590/// buys back.
591/// 2. **An objective *improvement* may not be bought with primal slack.**
592/// Two costs this carries, named rather than hidden (R3). It is an exact
593/// non-increase with **no noise floor**, so it fires the same on
594/// `2.07e-25 -> 1.09e-09` (where the move is the whole defect) as on
595/// `1e-17 -> 1.1e-17` (where which side a retry lands on is arithmetic):
596/// of the 45 promotions this PR removes, ~35 are improvements refused
597/// because the violation ticked up somewhere far below any tolerance. The
598/// direction is conservative — the base answer is feasible and in hand —
599/// but "refuses purchases, not improvements" is only true above the noise.
600/// And the rule detects *the primal moved*, not *below the optimum*: had
601/// `scholtes4`'s retry **held** its violation, `f = -6.6088e-05` would have
602/// been admitted, which the second assertion of
603/// `an_improvement_bought_with_primal_slack_is_refused` states outright.
604/// On that model the two coincided.
605/// The remedy is for a *dual* defect — the premise is that the primal
606/// has settled — so a retry that lands further outside the constraints
607/// *and* reports a better objective has not repaired the runaway, it
608/// has moved somewhere the model does not reach. This is what
609/// `scholtes4` does: from a base at `f = +1.82e-09` with a constraint
610/// violation of `2.07e-25` it promotes `f = -6.61e-05` at `1.09e-09`,
611/// below the model's exactly-known `f* = 0`, and reports
612/// `Optimal Solution Found`.
613///
614/// Both comparisons are skipped when the base attempt is not itself
615/// feasible within [`IpoptApplication::dual_divergence_retry_accept_tol`]:
616/// there is then no admissible point to protect, and the retry's is
617/// strictly better information.
618///
619/// `tol` is that same acceptable tolerance, scaled by
620/// `max(1, |base_obj|)` so the comparison is relative on a large
621/// objective and absolute on a small one — the convention
622/// `sigma_forward_error_is_small` uses for `‖x‖` and for the same reason.
623/// It is not a fitted constant: the objective moves this has to admit
624/// (`qpec_small`, `5.8e-11`) and the ones it has to refuse (`r201`,
625/// `0.198`; `scholtes4`, `6.6e-05`) sit four and five orders away from it
626/// on either side.
627///
628/// `sense` is `+1` for a minimization and `-1` for a maximization, and both
629/// objectives are multiplied by it before either rule looks at them.
630/// [`SolveStatistics::final_objective`] is the objective evaluated on the
631/// **user** TNLP — signed, and *not* premultiplied by `obj_scaling_factor` —
632/// and a negative `obj_scaling_factor` is the documented way to pose a
633/// maximization. Without the normalization both rules invert under it: rule 1
634/// would refuse genuine *improvements* (a regression against the behaviour
635/// before this conjunct existed) and rule 2 would admit strictly worse
636/// answers, re-arming the exact class the conjunct was added to block. The
637/// repo has shipped one defect from this sign already —
638/// `masked_certificate_fuzz.rs::the_veto_is_not_disabled_by_a_negative_objective_scaling_factor`,
639/// whose fix took `.abs()` in the residual accessors, which is also what keeps
640/// the detector firing under maximization and so keeps this path reachable.
641fn retry_answer_is_admissible(
642 base_obj: Number,
643 base_viol: Number,
644 retry_obj: Number,
645 retry_viol: Number,
646 accept_tol: Number,
647 sense: Number,
648) -> bool {
649 // Nothing to compare against: a non-finite or infeasible base answer
650 // is not a point worth protecting.
651 if !base_obj.is_finite() || !base_viol.is_finite() || base_viol > accept_tol {
652 return true;
653 }
654 // A non-finite retry objective is refused rather than admitted, for
655 // the same reason `runaway_is_the_whole_residual` refuses a NaN.
656 if !retry_obj.is_finite() {
657 return false;
658 }
659 // Both into a minimization frame, so "lower is better" below is true
660 // whichever sense the caller posed.
661 let base_obj = sense * base_obj;
662 let retry_obj = sense * retry_obj;
663 let tol = accept_tol * base_obj.abs().max(1.0);
664 if retry_obj > base_obj + tol {
665 return false; // rule 1: strictly worse feasible point
666 }
667 if retry_obj < base_obj - tol {
668 // Rule 2: an improvement is admissible only if the retry did not
669 // give up primal accuracy to get it.
670 return retry_viol.is_finite() && retry_viol <= base_viol.max(RETRY_VIOL_NOISE_FLOOR);
671 }
672 true
673}
674
675/// How far `x` sits outside the declared box, **relative to each bound's own
676/// magnitude**: `max_i viol_i / max(1, |b_i|)`, where `b_i` is the bound
677/// `x_i` crosses and bounds at or beyond the infinity sentinels are absent.
678///
679/// Relative because the quantity it is compared on is: `bound_relax_factor`
680/// widens bound `b` by `factor · max(1, |b|)` (capped at `constr_viol_tol`),
681/// so a point pinned to the widened bound sits a *relative* distance from the
682/// box that does not depend on `|b|`, while its rounding is one ulp of `|b|`
683/// -- `1.2e-10` at `|b| = 1e6`, two orders above any absolute floor that
684/// still separates `1e-8` from noise on a unit bound.
685///
686/// A variable fixed by its declared box (`x_L == x_U`) is skipped. Under
687/// `fixed_variable_treatment=relax_bounds` it stays in the iterate with its
688/// point widened into a band -- in the base attempt and the re-solve alike,
689/// since a barrier needs an interior -- so where it lands inside that band is
690/// barrier noise, not a choice to use the widening: on a fixed `z = 0.5`
691/// beside three bound-active controls the base attempt returned it `1.2e-11`
692/// off and the re-solve `2.0e-11`, and counting it declined a re-solve whose
693/// controls were all inside their box. Under the default `make_parameter` it
694/// is spliced in at its exact value and reads `0` either way.
695///
696/// `NaN` when the lengths disagree (nothing to measure against).
697fn declared_box_relative_violation(
698 x: &[Number],
699 x_l: &[Number],
700 x_u: &[Number],
701 lower_inf: Number,
702 upper_inf: Number,
703) -> Number {
704 if x.len() != x_l.len() || x.len() != x_u.len() {
705 return Number::NAN;
706 }
707 let mut worst = 0.0_f64;
708 for i in 0..x.len() {
709 let (l, u) = (x_l[i], x_u[i]);
710 if l == u {
711 continue;
712 }
713 if l > lower_inf && x[i] < l {
714 worst = worst.max((l - x[i]) / l.abs().max(1.0));
715 }
716 if u < upper_inf && x[i] > u {
717 worst = worst.max((x[i] - u) / u.abs().max(1.0));
718 }
719 }
720 worst
721}
722
723/// A relative declared-box violation at or below this is rounding, not use of
724/// the widening: four ulps of the bound's magnitude (`4 · ε ≈ 8.9e-16`).
725///
726/// Measured populations on either side of it. Rounding: a point pinned to a
727/// bound reads at most one ulp off it, `ε/2 .. ε` relative at any magnitude.
728/// Genuine use of the widening, from the bound-magnitude probe (the
729/// `scale_audit_bound_active` model with `x_U` in `{1, 10, 1e2, 1e3, 1e4,
730/// 1e6}`, each with the box at `[0, x_U]` and at `[x_U − 1, x_U]`, under a
731/// named `bound_relax_factor=1e-8`): the re-solve's excess over the base
732/// attempt is `4.97e-9` relative at `x_U = 1`, `9.5e-9` at `10`, `9.9e-9` at
733/// `1e2`, `2.5e-9` at `1e3`, `2.5e-10` at `1e4` and `2.5e-12` at `1e6` (the
734/// last three are where `constr_viol_tol = 1e-4` caps the widening). The
735/// smallest of those is three and a half orders above this floor, the
736/// largest rounding three times below it.
737const RETRY_BOX_REL_NOISE: Number = 4.0 * Number::EPSILON;
738
739/// May a retry's point ship, as far as the box the caller *declared* goes?
740///
741/// Only when it sits no further outside that box than the base attempt's
742/// point does, measured relative to each bound's magnitude
743/// ([`declared_box_relative_violation`]) and up to [`RETRY_BOX_REL_NOISE`].
744/// Every interior-point solve runs on bounds widened by `bound_relax_factor`,
745/// so "outside the declared box" is something any converged point can be, by
746/// up to the widening; what this refuses is a retry that *uses more of it*
747/// than the answer already in hand. That is the gh#983 scale audit's failure
748/// mode exactly: its re-solve drives complementarity down, which on a
749/// bound-active variable is the same as driving the point onto the widened
750/// bound, so `ex1_320`'s re-solve shipped `1.0e-8` outside the box its base
751/// attempt satisfied exactly.
752///
753/// Unconditional, unlike rule 2 of [`retry_answer_is_admissible`]: a point
754/// outside the box is the defect whatever the objective did, and a re-solve
755/// whose objective move is under that rule's tolerance shipped it all the
756/// same. When the caller left `bound_relax_factor` alone the scale audit's
757/// re-solve keeps the variable box as declared (its inequality rows are
758/// still widened, as in the base attempt), so there this conjunct is a
759/// backstop. It bites when the caller named a widening: both attempts then
760/// solve the widened box, and the re-solve may not exploit it further.
761///
762/// `NaN` means "not measured". A retry that cannot be measured is refused
763/// unless the base could not be either: there is then nothing to compare and
764/// the rule has no opinion.
765fn retry_keeps_declared_box(base_rel_viol: Number, retry_rel_viol: Number) -> bool {
766 if retry_rel_viol.is_nan() {
767 return base_rel_viol.is_nan();
768 }
769 let base = if base_rel_viol.is_nan() {
770 0.0
771 } else {
772 base_rel_viol.max(0.0)
773 };
774 retry_rel_viol <= base + RETRY_BOX_REL_NOISE
775}
776
777/// What [`IpoptApplication::initialize_with_option_file`] did — enough
778/// for a caller to tell the user which file (if any) configured the run.
779#[derive(Debug, Default, Clone)]
780pub struct OptionFileLoad {
781 /// The file actually read. `None` means no options file was read:
782 /// nobody named one and neither default was present.
783 pub path: Option<PathBuf>,
784 /// Whether [`Self::path`] was named by the caller rather than found
785 /// by probing the working directory.
786 pub explicit: bool,
787 /// Non-fatal notes about option-file settings that did *not* take
788 /// effect. Nothing here stops a solve; the point is that it not
789 /// happen silently.
790 pub warnings: Vec<String>,
791}
792
793/// Opaque snapshot of an application's linear-solver summaries; see
794/// [`IpoptApplication::linear_solver_summary_state`].
795#[derive(Debug, Clone)]
796pub struct LinearSolverSummaryState {
797 main: LinearSolverSummary,
798 restoration: LinearSolverSummary,
799}
800
801/// Factory that constructs a fresh restoration-phase strategy on
802/// demand. The outer algorithm owns at most one restoration object,
803/// so the factory is invoked once per `optimize_tnlp` call. The
804/// factory is `FnMut` to allow callers to capture a builder that
805/// internally reuses caches across builds.
806pub type RestorationFactory = Box<dyn FnMut() -> Box<dyn RestorationPhase>>;
807
808/// Provider that mints fresh [`RestorationFactory`] instances on
809/// demand. Used by drivers that need to run the inner IPM more than
810/// once per `optimize_tnlp` call — notably the Phase-3 ℓ₁-exact
811/// penalty-barrier outer loop (pounce#10), which the existing
812/// `RestorationFactory` cannot support because pounce's default
813/// `make_default_restoration_factory` is a one-shot. Callers wire
814/// this via [`IpoptApplication::set_restoration_factory_provider`].
815pub type RestorationFactoryProvider = Box<dyn FnMut() -> RestorationFactory>;
816
817/// Running totals over the passes of a multi-pass solve (the ℓ₁ exact-penalty
818/// ρ loop, and the plain attempt that precedes it under
819/// `l1_fallback_on_restoration_failure`). Every pass starts from reset
820/// statistics, so the passes are summed here and written back once, after
821/// the last, as the **total** `iteration_count` plus a `passes` summary
822/// (gh#987 item 6). Everything else in the statistics keeps describing the
823/// last pass — the one that produced the returned point.
824#[derive(Default)]
825struct MultiPassTotals {
826 iterations: Index,
827 rows: Vec<pounce_nlp::solve_statistics::IterRecord>,
828 obj_evals: Index,
829 constr_evals: Index,
830 grad_evals: Index,
831 jac_evals: Index,
832 hess_evals: Index,
833 restoration_calls: Index,
834 restoration_inner_iters: Index,
835 restoration_outer_iters: Index,
836 passes: Vec<pounce_nlp::SolvePassRecord>,
837}
838
839impl MultiPassTotals {
840 fn push_pass(
841 &mut self,
842 st: &SolveStatistics,
843 rho: Option<Number>,
844 status: ApplicationReturnStatus,
845 ) {
846 self.passes.push(pounce_nlp::SolvePassRecord {
847 rho,
848 iterations: st.iteration_count,
849 first_row: self.rows.len(),
850 slack_sum: None,
851 constraint_violation: None,
852 status: format!("{status:?}"),
853 });
854 self.iterations += st.iteration_count;
855 self.rows.extend(st.iterations.iter().cloned());
856 self.obj_evals += st.num_obj_evals;
857 self.constr_evals += st.num_constr_evals;
858 self.grad_evals += st.num_obj_grad_evals;
859 self.jac_evals += st.num_constr_jac_evals;
860 self.hess_evals += st.num_hess_evals;
861 self.restoration_calls += st.restoration_calls;
862 self.restoration_inner_iters += st.restoration_inner_iters;
863 self.restoration_outer_iters += st.restoration_outer_iters;
864 }
865
866 fn set_slack_sum(&mut self, v: Number) {
867 if let Some(p) = self.passes.last_mut() {
868 p.slack_sum = v.is_finite().then_some(v);
869 }
870 }
871
872 fn set_violation(&mut self, v: Number) {
873 if let Some(p) = self.passes.last_mut() {
874 p.constraint_violation = v.is_finite().then_some(v);
875 }
876 }
877
878 fn apply(&self, st: &mut SolveStatistics) {
879 if self.passes.is_empty() {
880 return;
881 }
882 st.iteration_count = self.iterations;
883 st.iterations = self.rows.clone();
884 st.num_obj_evals = self.obj_evals;
885 st.num_constr_evals = self.constr_evals;
886 st.num_obj_grad_evals = self.grad_evals;
887 st.num_constr_jac_evals = self.jac_evals;
888 st.num_hess_evals = self.hess_evals;
889 st.restoration_calls = self.restoration_calls;
890 st.restoration_inner_iters = self.restoration_inner_iters;
891 st.restoration_outer_iters = self.restoration_outer_iters;
892 st.passes = self.passes.clone();
893 }
894}
895
896/// Callback fired by [`IpoptApplication::optimize_constrained`] once
897/// the IPM has converged (status `SolveSucceeded` or
898/// `SolvedToAcceptableLevel`) and before the user TNLP's
899/// `finalize_solution` runs. Receives borrowed handles into the
900/// algorithm's converged state.
901///
902/// **Use case**: post-optimal sensitivity analysis (pounce#7 /
903/// `pounce-sensitivity`). The callback receives a shared handle to
904/// the PD solver so a `SensBacksolver` adapter can run backsolves
905/// against the converged KKT factor — and so that handle may outlive
906/// the call frame (e.g. the public `Solver` session API retains the
907/// factor for repeated `parametric_step` / `kkt_solve` calls);
908/// receives the data / cq / nlp handles so the adapter can reproduce
909/// the augmented-system coefficient layout the IPM converged at.
910///
911/// **Not** the same as `set_intermediate_callback` (per-iteration
912/// progress notification) — this fires exactly once per `optimize_*`
913/// call, only on success.
914pub type ConvergedCallback = Box<
915 dyn FnMut(
916 &crate::ipopt_data::IpoptDataHandle,
917 &crate::ipopt_cq::IpoptCqHandle,
918 &Rc<RefCell<dyn pounce_nlp::ipopt_nlp::IpoptNlp>>,
919 Rc<RefCell<crate::kkt::pd_full_space_solver::PdFullSpaceSolver>>,
920 ),
921>;
922use pounce_common::diagnostics::DiagnosticsState;
923use pounce_common::exception::{ExceptionKind, SolverException};
924use pounce_common::journalist::{JournalLevel, Journalist};
925use pounce_common::options_list::OptionsList;
926use pounce_common::reg_options::{PrintOptionsMode, RegisteredOptions};
927use pounce_common::timing::TimingStatistics;
928use pounce_common::types::{Index, Number};
929use pounce_linalg::dense_vector::DenseVectorSpace;
930use pounce_linsol::SparseSymLinearSolverInterface;
931use pounce_linsol::summary::LinearSolverSummary;
932use pounce_nlp::alg_types::SolverReturn;
933use pounce_nlp::derivative_test::{DerivativeTest, DerivativeTestOptions};
934use pounce_nlp::orig_ipopt_nlp::{ConstObjScaling, OrigIpoptNlp, ScalingMethod};
935use pounce_nlp::return_codes::ApplicationReturnStatus;
936use pounce_nlp::solve_statistics::SolveStatistics;
937use pounce_nlp::tnlp::{
938 BoundsInfo, IpoptCq as TnlpIpoptCq, IpoptData as TnlpIpoptData, NlpInfo, Solution, TNLP,
939};
940use pounce_nlp::tnlp_adapter::{
941 DEFAULT_NLP_LOWER_BOUND_INF, DEFAULT_NLP_UPPER_BOUND_INF, FixedVarTreatment, TNLPAdapter,
942};
943use std::cell::RefCell;
944use std::fmt;
945use std::path::{Path, PathBuf};
946use std::rc::Rc;
947use std::sync::{Arc, Mutex};
948use std::time::Instant;
949
950/// Smallest `n + m` at which `algorithm=active-set-sqp` starts from the
951/// interior point; see [`IpoptApplication::sqp_auto_handover`]. The fixture
952/// corpus (largest model 813 variables) sits far below it.
953pub const AUTO_HANDOVER_MIN_DIM: usize = 5_000;
954
955pub struct IpoptApplication {
956 options: OptionsList,
957 /// Diagnostics from the safeguarded `least_square_init_primal`
958 /// initializer step of the most recent solve (gh#605). `None` when
959 /// the step was not run. Read with
960 /// [`Self::least_square_init_report`].
961 least_square_init_report: Option<crate::init::default::LeastSquareInitReport>,
962 /// Per-variable scaling factors applied by the wrapper installed in
963 /// [`Self::optimize_tnlp`] (gh#486). Recorded so consumers that read
964 /// the algorithm's own iterate rather than the `finalize_solution`
965 /// payload — the CLI's `on_converged` hook feeding the `.sol` and
966 /// the JSON report — can undo the substitution. `None` when no
967 /// variable scaling was applied.
968 variable_scaling: RefCell<Option<Vec<Number>>>,
969 /// Whether the most recent `optimize_constrained` ran with per-row
970 /// constraint scaling active (`c_scale_vec`/`d_scale_vec` present).
971 ///
972 /// Read by [`Self::run_l1_penalty_outer_loop`], which measures the
973 /// user's rows in the model's own units and so may only write that
974 /// number into the `final_unscaled_*` family. `SolveStatistics`
975 /// documents `final_*` as the internally-scaled residuals and
976 /// `final_unscaled_*` as the same quantities in original units,
977 /// *equal when no scaling is active* — so the measurement may be
978 /// mirrored into the scaled family exactly when this is `false`
979 /// (gh#794 review). `None` when no solve has run.
980 row_scaling_active: std::cell::Cell<Option<bool>>,
981 /// Whether the submitted TNLP has already been explicitly wrapped by the
982 /// caller's presolve layer.
983 presolve_already_applied: bool,
984 reg_options: Rc<RegisteredOptions>,
985 journalist: Rc<Journalist>,
986 statistics: RefCell<SolveStatistics>,
987 /// Shared per-subsystem timing accumulator. Re-created at the top of
988 /// every solve (so back-to-back `optimize_tnlp` calls don't bleed
989 /// timings across invocations) and handed to the data, the NLP, and
990 /// any other consumer via `Rc`. Reported by [`Self::timing_stats`]
991 /// after the solve completes.
992 timing: RefCell<Rc<TimingStatistics>>,
993 /// Optional override factory for the symmetric linear-solver
994 /// backend. When `None`, we ship the workspace default (MA57 via
995 /// `pounce-hsl`). Tests can plug a stub via [`Self::set_linear_backend_factory`].
996 linear_backend_factory: Option<LinearBackendFactory>,
997 /// Optional factory for the restoration phase. Lives outside this
998 /// crate because `pounce-algorithm` cannot depend on
999 /// `pounce-restoration` (the dep edge is the other way). Callers
1000 /// that need restoration plug a factory via
1001 /// [`Self::set_restoration_factory`]; when unset, the outer
1002 /// algorithm runs without a restoration fallback and surfaces
1003 /// `RestorationFailure` as soon as the line-search would otherwise
1004 /// jump into restoration.
1005 restoration_factory: Option<RestorationFactory>,
1006 /// Shared diagnostic-dump state, installed by the CLI when the
1007 /// user passes `--dump <cat>:<spec>`. When set, the application
1008 /// propagates an `Rc<DiagnosticsState>` into [`IpoptAlgorithm`]
1009 /// via [`IpoptAlgorithm::with_diagnostics`] so the KKT solver and
1010 /// other dump sites can consult per-iter gating.
1011 diagnostics: Option<Rc<DiagnosticsState>>,
1012 /// Optional interactive debugger hook. When set, it is moved into
1013 /// the main [`IpoptAlgorithm`] for the next `optimize_*` call via
1014 /// [`IpoptAlgorithm::with_debug_hook`], so a REPL or agent can pause
1015 /// at each iteration to inspect / mutate live state. Consumed on use
1016 /// (one solve per installed hook).
1017 debug_hook: Option<std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>>,
1018 /// True for the whole of an `optimize_tnlp` call that started with a
1019 /// debugger hook installed. The hook drives exactly one IPM solve, so
1020 /// any re-solve after it (the ℓ₁ fallback, the gh#884 retry, the
1021 /// `mu_strategy_fallback` flip, the gh#983 scale retry) would run
1022 /// unobserved *after* the debugger has shown its `final: true`
1023 /// terminal pause, and could replace the verdict the user was shown.
1024 /// Those retries stand down while this is set, as the second-opinion
1025 /// ladder already does for an attached debugger.
1026 debugger_attached: std::cell::Cell<bool>,
1027 /// Provider for the BNW outer loop (pounce#10 Phase 3). When set,
1028 /// `optimize_constrained` consults the provider before each inner
1029 /// solve, replacing `restoration_factory` with a fresh one so
1030 /// multi-pass drivers can run the inner IPM repeatedly without
1031 /// tripping the default factory's one-shot guard.
1032 restoration_factory_provider: Option<RestorationFactoryProvider>,
1033 /// Optional hook fired once per `optimize_*` call on convergence,
1034 /// before the user TNLP's `finalize_solution`. See
1035 /// [`ConvergedCallback`].
1036 on_converged: Option<ConvergedCallback>,
1037 /// Re-evaluate `W` at the converged point before `on_converged` fires.
1038 /// See [`Self::set_on_converged_refreshes_hessian`].
1039 on_converged_refreshes_hessian: bool,
1040 /// Ids handed out so far to `on_converged` firings; the next firing gets
1041 /// one more. Never reset, so an id is unique over the application's life.
1042 /// See [`IpoptData::on_converged_id`](crate::ipopt_data::IpoptData).
1043 on_converged_fired: std::cell::Cell<u64>,
1044 /// When `true`, the per-iteration `IterRecord` trajectory is
1045 /// captured into [`SolveStatistics::iterations`] for downstream
1046 /// consumers (the JSON solve report in pounce-cli, pounce#8). Off
1047 /// by default so library callers that never read the iterations
1048 /// vector don't pay the per-iter alloc.
1049 record_iter_history: bool,
1050 /// Whether [`Self::initialize_with_option_file`] ran — i.e. whether
1051 /// anything on this application actually consulted
1052 /// `option_file_name` and resolved it to a file. Only the `pounce`
1053 /// CLI does; a library caller sets its options directly. The guard
1054 /// in [`Self::unhonored_option_file_name`] reads this so that
1055 /// setting the option on a surface that cannot honor it is refused
1056 /// rather than dropped (gh#518).
1057 option_file_resolved: bool,
1058 /// Whether this caller can route a model to the convex LP/QP/SOCP
1059 /// engines — i.e. whether the `qp_*` knobs those engines read
1060 /// configure anything here. Only the `pounce` CLI can (it owns the
1061 /// `.nl` structure extraction that classifies a model), and it says
1062 /// so via [`Self::set_convex_routing_available`]. The guard in
1063 /// [`Self::unhonored_convex_option`] reads this, on the same
1064 /// contract as `option_file_resolved` above (gh#604).
1065 convex_routing_available: bool,
1066 /// Whether the backend-knob warnings (gh#551) have already been
1067 /// printed for this application. The CLI emits them before routing —
1068 /// a convex model never reaches `optimize_tnlp` — and `optimize_tnlp`
1069 /// emits them for every other frontend; without this flag a CLI run
1070 /// would print each line twice, which is how a warning teaches its
1071 /// reader to skip it.
1072 backend_warnings_emitted: bool,
1073 /// Shared sink that the linear-solver backend writes a rolling
1074 /// [`LinearSolverSummary`] into after every factor. Reset at the
1075 /// top of every solve (so back-to-back `optimize_tnlp` calls don't
1076 /// bleed stats across invocations) and read out via
1077 /// [`Self::linear_solver_summary`] once the solve returns. Only
1078 /// the workspace-default FERAL backend (via
1079 /// [`default_backend_factory_with_sink`]) wires the sink today;
1080 /// custom factories plugged through [`Self::set_linear_backend_factory`]
1081 /// and the HSL MA57 backend leave the sink empty.
1082 linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
1083 /// Companion to [`Self::linsol_summary_sink`] for the restoration
1084 /// phase's sub-solves. The frontends that wire a restoration factory
1085 /// install it via [`Self::restoration_summary_sink`]; it is reset with
1086 /// the main sink and surfaces as [`LinearSolverSummary::restoration`].
1087 resto_linsol_summary_sink: Arc<Mutex<LinearSolverSummary>>,
1088 /// The block-parallel KKT partition this solve resolved, published for
1089 /// the restoration sub-IPM (structured-KKT Phase 5b). Restoration's inner
1090 /// builder is minted by the frontend *before* the solve — before the KKT
1091 /// layout, and so the mapping onto it, exists — so the labels cannot be
1092 /// installed on it directly. They are written here as the outer algorithm
1093 /// is built and read when restoration runs. `AugRestoSystemSolver` reduces
1094 /// the restoration KKT onto the original 4-block system before delegating,
1095 /// so the matrix the inner solver factors is the one these labels
1096 /// describe. Reset with the sinks at the top of every solve.
1097 kkt_blocks_published: crate::alg_builder::KktBlocksCell,
1098 /// Shared tally of successful linear-solver quality escalations for
1099 /// the current solve (gh#857). Handed to every `AlgorithmBuilder`
1100 /// this application mints — [`Self::algorithm_builder_from_options`]
1101 /// and [`Self::algorithm_builder_snapshot`] — which is how the
1102 /// restoration sub-solve counts into the same total: each frontend
1103 /// builds the restoration provider's inner builder from
1104 /// `algorithm_builder_from_options`, so it receives this same `Rc`
1105 /// without any frontend having to know the counter exists.
1106 ///
1107 /// Reset at the top of every solve, beside the linear-solver summary
1108 /// sink and for the same reason: a second-opinion retry, or two
1109 /// back-to-back `optimize_tnlp` calls, must not inherit the previous
1110 /// solve's count. Read out into
1111 /// [`SolveStatistics::quality_escalations`] once the solve returns.
1112 quality_escalations: Rc<std::cell::Cell<u64>>,
1113 /// gh#990: the `mu_strategy` value in the option table was written by a
1114 /// POUNCE frontend (the Python no-`hessian` path pins `monotone`), not by
1115 /// the caller. See [`Self::mark_mu_strategy_pounce_chosen`].
1116 mu_strategy_pounce_chosen: bool,
1117 /// gh#884. Set when the running [`IpoptAlgorithm`] observed the
1118 /// biactive dual-divergence signature — a converged primal, a step
1119 /// that has gone to zero on a scale-relative measure, and an
1120 /// *unscaled* dual infeasibility still far above `dual_inf_tol`, all
1121 /// at the same iterate.
1122 ///
1123 /// Read out of the algorithm once `optimize_constrained` returns, so
1124 /// [`Self::run_with_dual_divergence_retry`] — which sits above that
1125 /// call — can see it. Reset at the top of every solve for the same
1126 /// reason as `quality_escalations`: a retry must not inherit the
1127 /// previous attempt's verdict. Also copied into
1128 /// [`SolveStatistics::dual_divergence_signature`].
1129 dual_divergence_signature: std::cell::Cell<bool>,
1130 /// The returned run's algorithm released the tiny-step stop through the
1131 /// `tiny_step_dual_stall_iters` rule. Read by the mu-strategy fallback,
1132 /// which retries a `Search_Direction_Becomes_Too_Small` reached this way
1133 /// as it does an iteration limit. Reset at the top of every solve.
1134 tiny_step_dual_stall: std::cell::Cell<bool>,
1135 /// gh#990 item 3: the derivative checker's structured result for the
1136 /// current solve; copied onto the statistics when the solve finishes.
1137 derivative_check: RefCell<Option<pounce_nlp::derivative_test::DerivativeCheckSummary>>,
1138 /// gh#990 item 13: some attempt of this run refused a termination
1139 /// certificate the objective scaling had masked (strict, acceptable).
1140 /// Sticky across the attempts a retry driver spends, like
1141 /// `dual_divergence_signature`: the returned run is often a re-solve, and
1142 /// the decision that led to it belongs on the record.
1143 obj_scale_refused: std::cell::Cell<(bool, bool)>,
1144 /// gh#884. Set when a dual-divergence retry actually replaced the base
1145 /// attempt's answer. Copied into
1146 /// [`SolveStatistics::dual_divergence_retry_promoted`].
1147 dual_divergence_retry_promoted: std::cell::Cell<bool>,
1148 /// gh#1042 C1. What the `mu_strategy_fallback` retry did in this solve,
1149 /// if it ran. Copied into [`SolveStatistics::mu_strategy_fallback`] by
1150 /// every attempt that follows it, and written into the statistics
1151 /// directly once the retry has decided — so a promoted re-solve no
1152 /// longer hides the first attempt's verdict and cost.
1153 mu_strategy_fallback_record: RefCell<Option<pounce_nlp::MuStrategyFallbackRecord>>,
1154 /// Set when a losing retry's answer was thrown away and an earlier
1155 /// attempt's replayed through `FinalizeSnapshot::replay` — by the μ
1156 /// fallback (pounce#870) or the gh#884 dual-divergence retry.
1157 ///
1158 /// It exists because [`Self::set_on_converged`] fires **per attempt**
1159 /// and the floor does not reach it. A losing retry that reached
1160 /// `Solve_Succeeded` has already run the callback, so a consumer
1161 /// capturing the converged iterate there — the CLI's `nominal_capture`,
1162 /// which is where `.sol` `x`, the JSON's `solution.x` and the dual
1163 /// block all come from — holds the *discarded* attempt's point while
1164 /// the status, objective and every statistic beside it have been
1165 /// floored back to the attempt that won. Measured before this flag
1166 /// existed: on a declined retry the `.sol` carried `f = -6.3274` while
1167 /// the JSON report next to it said `-6.1768`, and `pounce verify` on
1168 /// the `.sol` confirmed the file held the losing point.
1169 ///
1170 /// The `finalize_solution` payload does not have this problem — the
1171 /// replay *is* a `finalize_solution` call, so the last one always
1172 /// carries the answer being reported — which is why the fix is to tell
1173 /// the caller "prefer that payload", not to re-run the callback (the
1174 /// converged KKT state the callback borrows belongs to the retry by
1175 /// then, and cannot be rewound). Consumers of that state select the
1176 /// reported attempt's firing through [`Self::reported_on_converged_id`].
1177 answer_restored_from_floor: std::cell::Cell<bool>,
1178 /// Set only for the duration of the gh#983 scale audit's re-solve: the
1179 /// interior-point arm then widens the inequality rows by
1180 /// `bound_relax_factor` as usual but leaves the **variable box as the
1181 /// caller declared it** (`OrigIpoptNlp::relax_bounds_keeping_box`). See
1182 /// `run_scale_retry` for why.
1183 retry_keeps_declared_box: std::cell::Cell<bool>,
1184 /// The payload of the most recent `finalize_solution` POUNCE sent to the
1185 /// user's TNLP, so a second-opinion retry that loses can put the winning
1186 /// attempt's answer back (pounce#870).
1187 ///
1188 /// Written by [`finalize_via_orig_nlp`] and [`finalize_via_sqp`], which are
1189 /// free functions and so take this as a sink rather than reaching for
1190 /// `self`, and by [`Self::restore_floor`] when a floor is replayed, so it
1191 /// always holds the answer currently delivered. Cleared at the start of
1192 /// each solve. Read by the retry wrappers (each floors to it) and by
1193 /// [`Self::reported_on_converged_id`].
1194 last_finalize: RefCell<Option<FinalizeSnapshot>>,
1195 /// The last `IterStats` sent to the user's `intermediate_callback`
1196 /// (pounce#870), shared with the running `IpoptAlgorithm`.
1197 last_iter_stats: Rc<RefCell<Option<pounce_nlp::tnlp::IterStats>>>,
1198 /// Phase 5c (§6) SQP warm-start input. When `Some`, the next
1199 /// `optimize_tnlp` call on the SQP path consumes the iterate
1200 /// instead of cold-starting; consumed once per solve, then
1201 /// auto-cleared. The IPM path ignores this field. Wire-set
1202 /// via [`Self::set_sqp_warm_start`].
1203 sqp_warm_start: Option<crate::sqp::SqpIterates>,
1204 /// Phase 5c (§6) SQP warm-start output. Populated by every
1205 /// `optimize_sqp_tnlp` call with the final QP working set.
1206 /// Stays valid until the next solve (which overwrites it).
1207 /// Accessed via [`Self::last_sqp_working_set`].
1208 sqp_last_working_set: Option<pounce_qp::WorkingSet>,
1209 /// gh#986 review item 10: the fixed-variable elimination of the solve in
1210 /// progress, `(x_not_fixed_map, n_full_x)`. Working sets are published in
1211 /// the caller's full space (fixed variables marked `Fixed`) and warm
1212 /// starts are mapped through it, so a working set recorded on one node
1213 /// lands on the right columns of a node that fixes different variables.
1214 x_elimination: Option<(Vec<Index>, usize)>,
1215 /// What the post-convergence crossover phase did on the most recent
1216 /// IPM solve (gh#612). `None` when `crossover=no` (the default) or
1217 /// when the solve did not converge — crossover only runs on a
1218 /// converged interior iterate. Read via [`Self::crossover_report`].
1219 ///
1220 /// Kept separate from `sqp_last_working_set`, which crossover *also*
1221 /// populates on success: that field answers "what can the next solve
1222 /// warm-start from", this one answers "was the active set I am about
1223 /// to trust actually identified, or merely inferred".
1224 crossover_report: Option<crate::crossover::CrossoverReport>,
1225 /// What the interior-point → active-set handover did on the most recent
1226 /// IPM solve (gh#1054). `None` when `ipm_sqp_handover=no` (the default).
1227 /// Read via [`Self::handover_report`].
1228 handover_report: Option<crate::handover::HandoverReport>,
1229 /// Whether the current solve is an `algorithm=active-set-sqp` request
1230 /// run as interior point plus handover; see [`Self::sqp_auto_handover`].
1231 auto_handover: std::cell::Cell<bool>,
1232 /// Full primal-dual warm-start iterate for the IPM path, captured by
1233 /// the interactive debugger's `resolve` command. When `Some`, the
1234 /// next `optimize_tnlp` installs this 8-vector (algorithm space)
1235 /// directly onto `data.curr` before the iterate initializer runs, so
1236 /// a warm `resolve` continues from the paused interior point rather
1237 /// than cold-restarting the duals. Consumed once per solve, then
1238 /// auto-cleared. Requires `warm_start_init_point=yes` so the
1239 /// re-optimize branch of `WarmStartIterateInitializer` keeps the
1240 /// installed iterate. Wire-set via [`Self::set_warm_start_iterate`].
1241 warm_start_iterate: Option<crate::debug::IterateSnapshot>,
1242 /// The warm-start initializer's verdict on the most recent solve's
1243 /// supplied iterate (gh#606). Lifted off the solve-local
1244 /// `IpoptData` so a caller can read it after `optimize_tnlp`
1245 /// returns; `None` when the last solve was a cold start.
1246 warm_start_diag: RefCell<Option<crate::init::warm_start::WarmStartDiagnostics>>,
1247 /// Caller-supplied fill-reducing permutation for the KKT linear
1248 /// solver (pounce#180 item 1 / FERAL#107). When `Some`, it overrides
1249 /// whatever `feral_ordering` / `POUNCE_FERAL_ORDERING` resolves to,
1250 /// installing [`pounce_feral::OrderingMethod::External`] on the FERAL
1251 /// backend for the next solve. The vector is a **0-based, new-to-old
1252 /// permutation** whose length must equal the augmented KKT system
1253 /// dimension; FERAL validates it as a bijection and returns
1254 /// `InvalidInput` (never panics) on a wrong length / index. Unlike the
1255 /// warm-start hooks this is treated as persistent config — it is *not*
1256 /// auto-cleared after a solve, so a caller sets it once for a run.
1257 /// Wire-set via [`Self::set_external_ordering`]. Ignored by non-FERAL
1258 /// backends and by any custom factory plugged via
1259 /// [`Self::set_linear_backend_factory`].
1260 external_ordering: Option<Vec<usize>>,
1261 /// Caller-supplied block-triangular / Schur KKT partition (pounce#180
1262 /// item 2). When `Some`, the next IPM solve on the feral + exact-Hessian
1263 /// path routes the KKT linear solve through a
1264 /// [`crate::kkt::SchurAugSystemSolver`] over these **KKT-space indices**
1265 /// (`0..dim` in the `x, s, c, d` block order the aug-system solver
1266 /// assembles): the `S` block is Schur-complemented out and only the two
1267 /// diagonal blocks are factorized, with inertia recovered via Sylvester's
1268 /// law. Beneficial only when `|S| ≪` the eliminated block; the Schur solver
1269 /// falls back to the standard full-space solver transparently when the
1270 /// partition is unsuitable (too large, malformed, or the backend errors),
1271 /// so a stray hook never breaks a solve. Persistent config (not
1272 /// auto-cleared). Wire-set via [`Self::set_kkt_schur_block`].
1273 kkt_schur_block: Option<Vec<usize>>,
1274 /// Block-parallel KKT partition (structured-KKT Phase 5b): KKT-space block
1275 /// labels, `< 0` for the shared border, **empty** meaning "detect".
1276 /// Wire-set via [`Self::set_kkt_block_structure`].
1277 kkt_blocks: Option<Vec<i32>>,
1278 /// The same partition declared in the *model's* own terms — one block per
1279 /// variable and per constraint — which [`map_block_structure_to_kkt`]
1280 /// turns into `kkt_blocks` once the NLP layout is known. Wire-set via
1281 /// [`Self::set_block_structure`].
1282 model_blocks: Option<(Vec<i32>, Vec<i32>)>,
1283 /// The problem-statistics block most recently printed during the current
1284 /// run, so a retry attempt does not reprint an identical one.
1285 ///
1286 /// Every retry driver — the ℓ₁ fallback, the μ-strategy fallback, the
1287 /// second-opinion ladder, the dual-divergence retry — re-enters the solve
1288 /// routine that emits this block, so a run that retried once printed the
1289 /// whole Ipopt-style header twice with nothing between the copies saying a
1290 /// second attempt had started. Read as "the solver printed everything
1291 /// twice", which is what it looked like.
1292 ///
1293 /// Scoped to one run: reset at the top of
1294 /// [`Self::optimize_tnlp_with_derivative_test_tnlp`], which every entry
1295 /// point funnels through. Without that reset a library caller's second
1296 /// deliberate `optimize_tnlp` on the same application would lose its
1297 /// header, which is a different thing entirely from a retry inside one
1298 /// call.
1299 ///
1300 /// Compared rather than counted because the block can legitimately differ
1301 /// between attempts: the ℓ₁ wrapper adds slack variables, so its retry
1302 /// solves a genuinely larger problem and reprinting is informative.
1303 last_printed_problem_stats: RefCell<Option<pounce_solve_report::console::ProblemStats>>,
1304 /// Set while an *outside* retry driver is re-entering `optimize_tnlp` for
1305 /// another attempt on the same problem — today only
1306 /// `pounce_restoration::second_opinion_driver::run_second_opinion_ladder`.
1307 ///
1308 /// The retries that nest INSIDE one `optimize_tnlp` call (the ℓ₁ fallback,
1309 /// the μ-strategy fallback) are covered by `last_printed_problem_stats`
1310 /// alone, because the funnel that resets it runs once for all of them. The
1311 /// ladder's rungs are `optimize_tnlp` calls in their own right, so without
1312 /// this flag each one resets the memo and reprints the header — which is
1313 /// how a four-rung ladder printed five identical copies of it.
1314 ///
1315 /// It also decides who explains the retry. The ladder narrates every rung
1316 /// on stderr, naming the knob it varies and whether the rung recovered, so
1317 /// `emit_problem_stats` stays quiet and lets that narration stand. The
1318 /// in-call fallbacks say nothing at all, so there the suppressed header is
1319 /// replaced by a line announcing the attempt.
1320 in_retry_sequence: std::cell::Cell<bool>,
1321 /// Nesting depth of retry drivers that have taken ownership of the
1322 /// run-ending `EXIT:` / `POUNCE <version>:` verdict.
1323 ///
1324 /// `emit_end_summary` prints the per-attempt statistics block whatever
1325 /// this reads, and prints the verdict only at zero. A driver raises it
1326 /// before its first attempt and lowers it after its last, then prints the
1327 /// verdict once with the status it is actually returning.
1328 ///
1329 /// A counter rather than a flag because the drivers nest: the ℓ₁ fallback
1330 /// can wrap the μ-strategy fallback, which the dual-divergence retry can
1331 /// wrap in turn, and the CLI brackets the whole lot again around the
1332 /// second-opinion ladder. Only the outermost knows the run is over, and
1333 /// "outermost" is exactly "the one that brings this back to zero".
1334 end_verdict_deferrals: std::cell::Cell<u32>,
1335}
1336
1337impl fmt::Debug for IpoptApplication {
1338 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1339 f.debug_struct("IpoptApplication")
1340 .field("options", &self.options)
1341 .field("statistics", &self.statistics)
1342 .finish_non_exhaustive()
1343 }
1344}
1345
1346impl Default for IpoptApplication {
1347 fn default() -> Self {
1348 Self::new()
1349 }
1350}
1351
1352impl IpoptApplication {
1353 /// New application with empty options and a default journalist.
1354 /// Equivalent to `IpoptApplication::IpoptApplication(true,true)`.
1355 pub fn new() -> Self {
1356 let reg = RegisteredOptions::default();
1357 // Registration of a fresh registry can only fail on a duplicate
1358 // name, which would be a programming error in `reg_op`.
1359 register_all_upstream_options(®)
1360 .unwrap_or_else(|e| panic!("Upstream options registration failed: {e}"));
1361 pounce_presolve::register_options(®)
1362 .unwrap_or_else(|e| panic!("Presolve options registration failed: {e}"));
1363 let reg = Rc::new(reg);
1364 Self {
1365 options: OptionsList::with_registered(Rc::clone(®)),
1366 least_square_init_report: None,
1367 variable_scaling: RefCell::new(None),
1368 row_scaling_active: std::cell::Cell::new(None),
1369 presolve_already_applied: false,
1370 reg_options: reg,
1371 journalist: Rc::new(Journalist::new()),
1372 statistics: RefCell::new(SolveStatistics::new()),
1373 timing: RefCell::new(Rc::new(TimingStatistics::new())),
1374 linear_backend_factory: None,
1375 restoration_factory: None,
1376 diagnostics: None,
1377 debug_hook: None,
1378 debugger_attached: std::cell::Cell::new(false),
1379 restoration_factory_provider: None,
1380 on_converged: None,
1381 on_converged_refreshes_hessian: true,
1382 on_converged_fired: std::cell::Cell::new(0),
1383 record_iter_history: false,
1384 option_file_resolved: false,
1385 convex_routing_available: false,
1386 backend_warnings_emitted: false,
1387 linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
1388 resto_linsol_summary_sink: Arc::new(Mutex::new(LinearSolverSummary::default())),
1389 kkt_blocks_published: Arc::new(Mutex::new(None)),
1390 quality_escalations: Rc::new(std::cell::Cell::new(0)),
1391 mu_strategy_pounce_chosen: false,
1392 dual_divergence_signature: std::cell::Cell::new(false),
1393 tiny_step_dual_stall: std::cell::Cell::new(false),
1394 derivative_check: RefCell::new(None),
1395 obj_scale_refused: std::cell::Cell::new((false, false)),
1396 dual_divergence_retry_promoted: std::cell::Cell::new(false),
1397 mu_strategy_fallback_record: RefCell::new(None),
1398 answer_restored_from_floor: std::cell::Cell::new(false),
1399 retry_keeps_declared_box: std::cell::Cell::new(false),
1400 last_finalize: RefCell::new(None),
1401 last_iter_stats: Rc::new(RefCell::new(None)),
1402 sqp_warm_start: None,
1403 sqp_last_working_set: None,
1404 x_elimination: None,
1405 crossover_report: None,
1406 handover_report: None,
1407 auto_handover: std::cell::Cell::new(false),
1408 warm_start_iterate: None,
1409 warm_start_diag: RefCell::new(None),
1410 external_ordering: None,
1411 kkt_schur_block: None,
1412 kkt_blocks: None,
1413 model_blocks: None,
1414 last_printed_problem_stats: RefCell::new(None),
1415 in_retry_sequence: std::cell::Cell::new(false),
1416 end_verdict_deferrals: std::cell::Cell::new(0),
1417 }
1418 }
1419
1420 pub fn options(&self) -> &OptionsList {
1421 &self.options
1422 }
1423
1424 pub fn options_mut(&mut self) -> &mut OptionsList {
1425 &mut self.options
1426 }
1427
1428 /// Declare whether callers have already applied an explicit presolve
1429 /// wrapper to the TNLPs submitted to [`Self::optimize_tnlp`].
1430 ///
1431 /// When set, `optimize_tnlp` leaves its input TNLP unchanged even if the
1432 /// `presolve` option is enabled. This preserves the option table for
1433 /// reporting and debugger use while allowing specialized frontends to
1434 /// supply a wrapper with capabilities unavailable to generic callback
1435 /// TNLPs, such as an expression provider for FBBT.
1436 pub fn set_presolve_already_applied(&mut self, applied: bool) {
1437 self.presolve_already_applied = applied;
1438 }
1439
1440 /// Solve without materializing the generic presolve wrapper.
1441 ///
1442 /// This is for consumers that require the original TNLP coordinate system
1443 /// for the solve's KKT matrix, such as sensitivity and reduced-Hessian
1444 /// drivers. It is scoped to this invocation and does not change the
1445 /// application's `presolve` option or persistent explicit-wrapper setting.
1446 pub fn optimize_tnlp_without_presolve(
1447 &mut self,
1448 tnlp: Rc<RefCell<dyn TNLP>>,
1449 ) -> ApplicationReturnStatus {
1450 let explicit_wrapper = self.presolve_already_applied;
1451 self.presolve_already_applied = true;
1452 let status = self.optimize_tnlp(tnlp);
1453 self.presolve_already_applied = explicit_wrapper;
1454 status
1455 }
1456
1457 pub fn registered_options(&self) -> &Rc<RegisteredOptions> {
1458 &self.reg_options
1459 }
1460
1461 pub fn journalist(&self) -> &Rc<Journalist> {
1462 &self.journalist
1463 }
1464
1465 /// Plug a custom symmetric-linear-solver factory. Useful for tests
1466 /// that want to swap MA57 for a stub. Production callers should
1467 /// leave this unset — the default ([`default_backend_factory`])
1468 /// returns the workspace's MA57 binding.
1469 pub fn set_linear_backend_factory(&mut self, factory: LinearBackendFactory) {
1470 self.linear_backend_factory = Some(factory);
1471 }
1472
1473 /// Plug a restoration-phase factory. Called once per
1474 /// `optimize_tnlp` invocation to mint a fresh
1475 /// `Box<dyn RestorationPhase>` that the outer algorithm uses as
1476 /// its line-search restoration fallback. Lives behind a setter
1477 /// (rather than at construction) because the concrete restoration
1478 /// strategies live in `pounce-restoration`, which depends on this
1479 /// crate; consumers in `pounce-cli` / integration tests wire the
1480 /// factory at the application boundary.
1481 pub fn set_restoration_factory(&mut self, factory: RestorationFactory) {
1482 self.restoration_factory = Some(factory);
1483 }
1484
1485 /// Install the shared diagnostics state. Once set, every
1486 /// subsequent `optimize_tnlp` call forwards the state into the
1487 /// algorithm via [`IpoptAlgorithm::with_diagnostics`] so the KKT
1488 /// solver can emit `--dump kkt:...` artifacts.
1489 pub fn set_diagnostics(&mut self, diag: Rc<DiagnosticsState>) {
1490 self.diagnostics = Some(diag);
1491 }
1492
1493 /// Install an interactive debugger hook for the next `optimize_*`
1494 /// call. The hook is moved into the main [`IpoptAlgorithm`] and
1495 /// consumed by that solve; reinstall it to debug a subsequent solve.
1496 pub fn set_debug_hook(
1497 &mut self,
1498 hook: std::rc::Rc<std::cell::RefCell<dyn crate::debug::DebugHook>>,
1499 ) {
1500 self.debug_hook = Some(hook);
1501 }
1502
1503 /// Read-side accessor for the installed diagnostics state, if any.
1504 /// Lets the CLI write the top-level manifest/timing files after
1505 /// the solve completes.
1506 pub fn diagnostics(&self) -> Option<Rc<DiagnosticsState>> {
1507 self.diagnostics.as_ref().map(Rc::clone)
1508 }
1509
1510 /// The three options that decide how the TNLP is *classified* before any
1511 /// algorithm sees it: the two infinity thresholds and the fixed-variable
1512 /// treatment.
1513 ///
1514 /// One reader, because there were three copies and they drifted. The
1515 /// active-set SQP path built its adapter with `TNLPAdapter::new`, which
1516 /// hard-codes the same three defaults — so the options were accepted and
1517 /// discarded on that arm, and a model with `fixed_variable_treatment=
1518 /// relax_bounds` or a non-default `nlp_upper_bound_inf` was classified one
1519 /// way for the interior-point arm and another for this one. Silently: the
1520 /// defaults coincide, so it only diverges for a caller who sets them, and
1521 /// nothing reported the difference.
1522 ///
1523 /// `make_constraint` / `make_parameter_nodual` are not implemented and
1524 /// fall back to `make_parameter`; the adapter auto-retries to
1525 /// `relax_bounds` when `make_parameter` would leave `n_x_var < n_c`
1526 /// (upstream `IpTNLPAdapter.cpp:623-633`).
1527 fn adapter_options(&self) -> (Number, Number, FixedVarTreatment) {
1528 let lo_inf = self
1529 .options
1530 .get_numeric_value("nlp_lower_bound_inf", "")
1531 .ok()
1532 .and_then(|(v, f)| f.then_some(v))
1533 .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF);
1534 let up_inf = self
1535 .options
1536 .get_numeric_value("nlp_upper_bound_inf", "")
1537 .ok()
1538 .and_then(|(v, f)| f.then_some(v))
1539 .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF);
1540 let fixed_treatment = match self
1541 .options
1542 .get_string_value("fixed_variable_treatment", "")
1543 .ok()
1544 .and_then(|(v, f)| f.then_some(v))
1545 .as_deref()
1546 {
1547 Some("relax_bounds") => FixedVarTreatment::RelaxBounds,
1548 _ => FixedVarTreatment::MakeParameter,
1549 };
1550 (lo_inf, up_inf, fixed_treatment)
1551 }
1552
1553 /// Whether the final solution reports bound multipliers for variables
1554 /// `make_parameter` removed (see
1555 /// `OrigIpoptNlp::complete_fixed_var_bound_multipliers`).
1556 /// Ipopt 3.14 does, for every treatment except `make_parameter_nodual`,
1557 /// which exists to ask it not to.
1558 pub fn reports_fixed_var_duals(&self) -> bool {
1559 !matches!(
1560 self.options
1561 .get_string_value("fixed_variable_treatment", "")
1562 .ok()
1563 .map(|(v, _)| v)
1564 .as_deref(),
1565 Some("make_parameter_nodual")
1566 )
1567 }
1568
1569 /// Mark that the next `optimize_*` calls are further **attempts at the
1570 /// same problem**, driven from outside, rather than new solves.
1571 ///
1572 /// Retry drivers that re-enter `optimize_tnlp` per attempt — the
1573 /// second-opinion ladder is the only one today — bracket their attempts
1574 /// with `true` … `false`. The effect is confined to console presentation:
1575 /// the Ipopt-style problem-statistics header is printed once for the run
1576 /// instead of once per attempt. Nothing about the solve changes.
1577 ///
1578 /// Callers must clear it on every exit path, including the early returns a
1579 /// ladder takes when a rung promotes; a stuck `true` costs a later solve
1580 /// its header and nothing worse.
1581 pub fn set_in_retry_sequence(&self, active: bool) {
1582 self.in_retry_sequence.set(active);
1583 }
1584
1585 /// Take ownership of the run-ending `EXIT:` / `POUNCE <version>:` verdict
1586 /// for the attempts that follow.
1587 ///
1588 /// Between this and [`Self::release_end_verdict`] the per-attempt end
1589 /// summary prints its statistics block but not the verdict, so a retried
1590 /// run no longer reports a mid-run verdict that reads as the final answer.
1591 /// The releaser prints it once, with the status it is returning.
1592 ///
1593 /// Callers must pair the two on every exit path. An unmatched acquire
1594 /// costs the run its verdict line; the counter saturates rather than
1595 /// wrapping, so an unmatched release cannot make a nested driver print
1596 /// one early.
1597 pub fn defer_end_verdict(&self) {
1598 self.end_verdict_deferrals
1599 .set(self.end_verdict_deferrals.get().saturating_add(1));
1600 }
1601
1602 /// Release one [`Self::defer_end_verdict`]. Returns `true` when this was
1603 /// the outermost one — i.e. when the caller now owns printing the verdict.
1604 #[must_use]
1605 pub fn release_end_verdict(&self) -> bool {
1606 let next = self.end_verdict_deferrals.get().saturating_sub(1);
1607 self.end_verdict_deferrals.set(next);
1608 next == 0
1609 }
1610
1611 /// Print the run-ending verdict, honouring `print_level`.
1612 ///
1613 /// The gate matches `emit_end_summary`'s, which is the block this follows:
1614 /// at `print_level 0` there is no summary for it to end.
1615 pub fn print_end_verdict(&self, status: ApplicationReturnStatus) {
1616 let console_output = match self.options.get_integer_value("print_level", "") {
1617 Ok((v, true)) => v >= 1,
1618 _ => true,
1619 };
1620 if console_output {
1621 pounce_solve_report::console::print_exit_verdict(status);
1622 }
1623 }
1624
1625 /// Plug a restoration-phase **factory provider** for drivers that
1626 /// need to run the inner IPM more than once per `optimize_tnlp`
1627 /// call (notably the Phase-3 ℓ₁-exact penalty-barrier outer loop,
1628 /// pounce#10). On each inner solve, the application consults the
1629 /// provider to mint a fresh [`RestorationFactory`], replacing any
1630 /// stale one, so the default one-shot restoration factory does
1631 /// not panic on its second invocation. If both `set_restoration_factory`
1632 /// and this are configured, the provider wins.
1633 pub fn set_restoration_factory_provider(&mut self, provider: RestorationFactoryProvider) {
1634 self.restoration_factory_provider = Some(provider);
1635 }
1636
1637 /// Register a callback to run once the IPM has converged (status
1638 /// [`ApplicationReturnStatus::SolveSucceeded`] or
1639 /// [`ApplicationReturnStatus::SolvedToAcceptableLevel`]) but before
1640 /// `finalize_solution` flows back to the TNLP. See
1641 /// [`ConvergedCallback`] for the use case (post-optimal sensitivity).
1642 pub fn set_on_converged(&mut self, cb: ConvergedCallback) {
1643 self.on_converged = Some(cb);
1644 }
1645
1646 /// Remove and return the registered on-converged callback, if any, so a
1647 /// caller that installs its own for a while can put the previous one
1648 /// back (`pounce_minima::IpoptLocalSolve` does, gh#966).
1649 pub fn take_on_converged(&mut self) -> Option<ConvergedCallback> {
1650 self.on_converged.take()
1651 }
1652
1653 /// Whether `data.w` is re-evaluated at the converged point `w*` before
1654 /// the [`Self::set_on_converged`] hook fires. On by default.
1655 ///
1656 /// The main loop checks convergence before it updates the Hessian, so at
1657 /// that moment `data.w` is still `W(w_k)` from the final Newton step,
1658 /// while the Jacobians, slacks, multipliers and `Σ` are read at `w*`. A
1659 /// hook that factors the KKT matrix (post-optimal sensitivity) needs the
1660 /// Hessian at `w*` too, or it factors a hybrid of two iterates.
1661 ///
1662 /// The refresh costs one Hessian evaluation, counted in the solve's
1663 /// statistics like any other. A hook that only reads the iterate and
1664 /// never factors (the CLI collecting duals for a `.sol` file) can turn it
1665 /// off so a plain solve reports the same evaluation counts with or
1666 /// without the hook installed.
1667 pub fn set_on_converged_refreshes_hessian(&mut self, refresh: bool) {
1668 self.on_converged_refreshes_hessian = refresh;
1669 }
1670
1671 /// Was the reported answer replayed from an earlier attempt's floor,
1672 /// after a later attempt lost?
1673 ///
1674 /// **A caller that captures the solution in [`Self::set_on_converged`]
1675 /// must consult this.** That callback fires once per *attempt*, and a
1676 /// losing retry that converged has already run it, so the capture
1677 /// belongs to the discarded point while everything else — status,
1678 /// objective, statistics — has been floored back. When this is `true`,
1679 /// take `x` and the multipliers from the last `finalize_solution`
1680 /// payload instead; that one is always the answer being reported,
1681 /// because the floor restores it *by* calling `finalize_solution`.
1682 ///
1683 /// The payload is in the **model's own units** and in the **reduced**
1684 /// presolve space: `CountingTnlp`-style consumers sit inside the gh#486
1685 /// scaling wrapper (so `x /= d`, `z *= d` have already been applied) and
1686 /// outside the presolve one (so the row/column lift has not). A caller
1687 /// swapping it in must therefore skip its own scaling correction and keep
1688 /// its own lift; getting that backwards squares the factor, which is a
1689 /// silent wrong answer of exactly the shape this flag exists to remove.
1690 ///
1691 /// A [`Self::set_on_converged`] consumer that needs more than the point --
1692 /// the converged KKT state and its factorization, which the payload does
1693 /// not carry and which cannot be rewound -- uses
1694 /// [`Self::reported_on_converged_id`] instead: it keeps each firing under
1695 /// its id and holds the one the solve reported, whose handles keep that
1696 /// attempt's state alive. `pounce-sensitivity`'s `Solver` and `SensSolve`
1697 /// and the CLI's hook do.
1698 ///
1699 /// `false` on every solve that never spent a second attempt, which is
1700 /// almost all of them, so the ordinary path is unaffected.
1701 pub fn answer_restored_from_floor(&self) -> bool {
1702 self.answer_restored_from_floor.get()
1703 }
1704
1705 /// The [`Self::set_on_converged`] firing whose attempt produced the
1706 /// answer the last solve reported, or `None` if that attempt did not fire
1707 /// the hook (it did not converge, or it was not the interior-point arm).
1708 ///
1709 /// The hook fires once per converged attempt, and when a later attempt
1710 /// loses the answer is replayed from an earlier one, so the most recent
1711 /// firing is not necessarily the reported one. A hook that keeps state
1712 /// files it under [`IpoptData::on_converged_id`](crate::ipopt_data::IpoptData)
1713 /// and keeps the firing this names; everything else it captured describes
1714 /// an attempt the solver discarded.
1715 pub fn reported_on_converged_id(&self) -> Option<u64> {
1716 self.last_finalize
1717 .borrow()
1718 .as_ref()
1719 .and_then(|s| s.on_converged_id)
1720 }
1721
1722 /// When the last solve ended in local infeasibility, probe the point it
1723 /// reported for a nearby point with less constraint violation
1724 /// ([`crate::infeasibility_saddle`]). `None` when the last solve reported
1725 /// anything else, or when the probe finds no such point.
1726 ///
1727 /// The point probed is the payload the user's TNLP was handed, so it is
1728 /// the reported point after any retry's floor; it is cleared at the start
1729 /// of every solve, so a stale point cannot be probed. `tnlp` must be the
1730 /// TNLP that solve ran on. Read by the second-opinion ladder.
1731 pub fn local_infeasibility_saddle_escape(
1732 &self,
1733 tnlp: &Rc<RefCell<dyn TNLP>>,
1734 ) -> Option<crate::infeasibility_saddle::SaddleEscape> {
1735 let x = self
1736 .last_finalize
1737 .borrow()
1738 .as_ref()
1739 .filter(|s| s.status == SolverReturn::LocalInfeasibility)
1740 .map(|s| s.x.clone())?;
1741 crate::infeasibility_saddle::infeasibility_saddle_escape(
1742 tnlp,
1743 &x,
1744 self.nlp_lower_bound_inf(),
1745 self.nlp_upper_bound_inf(),
1746 )
1747 }
1748
1749 /// Put a losing retry's floor back: re-deliver the winning attempt's
1750 /// payload to the user's TNLP and make it the recorded answer again.
1751 ///
1752 /// Recording it matters beyond this call. The retries nest -- the gh#884
1753 /// retry wraps the μ fallback and the scale audit -- and each takes its
1754 /// floor from `last_finalize`, so a replay that only re-delivered the
1755 /// payload left the *losing* inner retry's payload there for the outer
1756 /// wrapper to floor to, and to deliver as the answer: measured on
1757 /// `mpcc_scholtes4_biactive` with `mu_strategy_fallback=yes`, a declined
1758 /// μ retry and then a declined gh#884 retry
1759 /// (`pounce-sensitivity/tests/floored_solve_retry_wrappers.rs`).
1760 /// `reported_on_converged_id` reads it too, so without the record even a
1761 /// single declined retry would name its own firing.
1762 fn restore_floor(&self, floor: &FinalizeSnapshot, tnlp: &Rc<RefCell<dyn TNLP>>) {
1763 floor.replay(tnlp);
1764 *self.last_finalize.borrow_mut() = Some(floor.clone());
1765 self.answer_restored_from_floor.set(true);
1766 }
1767
1768 /// Enable per-iteration trajectory capture. After the solve
1769 /// returns, [`Self::statistics()`] exposes
1770 /// [`pounce_nlp::solve_statistics::SolveStatistics::iterations`]
1771 /// populated with one [`pounce_nlp::solve_statistics::IterRecord`]
1772 /// per accepted iterate. Off by default — the `pounce_sens` and
1773 /// `pounce` binaries opt in when `--json-output` is passed.
1774 pub fn enable_iter_history(&mut self) {
1775 self.record_iter_history = true;
1776 }
1777
1778 /// Whether [`Self::enable_iter_history`] is in force — read by a stage
1779 /// that produces a verdict outside `optimize_tnlp` (the second-opinion
1780 /// ladder's square-system stage) so its statistics keep the same
1781 /// `iterations` / `iteration_count` pairing.
1782 pub fn iter_history_enabled(&self) -> bool {
1783 self.record_iter_history
1784 }
1785
1786 /// Read the run's options file, resolving *which* file the way
1787 /// upstream's `IpoptApplication::Initialize` does — with one
1788 /// deliberate difference, below.
1789 ///
1790 /// `explicit` is the file the caller named (upstream: the
1791 /// `option_file_name` option, read out of the option store before
1792 /// this point). With `None`, the working directory is probed for
1793 /// [`DEFAULT_OPTION_FILE_NAMES`] and the first hit is read; an
1794 /// absent default file is not an error, it just means "no file".
1795 ///
1796 /// The difference: upstream opens a named file with a bare
1797 /// `std::ifstream` and reads nothing if the open fails, so a typo'd
1798 /// `option_file_name` runs at stock defaults without a word. That
1799 /// silence is what gh#518 was reported for — a benchmark that
1800 /// measured defaults while claiming to measure a configuration — so
1801 /// a named file that cannot be read is an error here.
1802 pub fn initialize_with_option_file(
1803 &mut self,
1804 explicit: Option<&Path>,
1805 ) -> Result<OptionFileLoad, SolverException> {
1806 let mut load = OptionFileLoad::default();
1807 // Set before the early returns below: what this flag records is
1808 // that `option_file_name` was *consulted*, not that a file turned
1809 // up. A caller on this path who names nothing and has no
1810 // `pounce.opt` to find still gets the option honored — there was
1811 // simply nothing to read.
1812 self.option_file_resolved = true;
1813 let path = match explicit {
1814 Some(p) => {
1815 if !p.is_file() {
1816 return Err(SolverException::new(
1817 ExceptionKind::IPOPT_APPLICATION_ERROR,
1818 format!(
1819 "options file \"{}\" does not exist. It was named by \
1820 --options-file / option_file_name, so the run would \
1821 otherwise proceed at stock defaults with none of its \
1822 settings applied.",
1823 p.display()
1824 ),
1825 file!(),
1826 line!() as Index,
1827 ));
1828 }
1829 load.explicit = true;
1830 p.to_path_buf()
1831 }
1832 None => {
1833 let present: Vec<&&str> = DEFAULT_OPTION_FILE_NAMES
1834 .iter()
1835 .filter(|n| Path::new(n).is_file())
1836 .collect();
1837 let Some(first) = present.first() else {
1838 return Ok(load);
1839 };
1840 // Both default names in one directory: say which one lost,
1841 // rather than let the unread one look applied.
1842 for other in &present[1..] {
1843 load.warnings.push(format!(
1844 "`{first}` and `{other}` are both present; reading `{first}` \
1845 only (pounce's own name wins). Pass \
1846 `option_file_name={other}` to read that one instead."
1847 ));
1848 }
1849 PathBuf::from(**first)
1850 }
1851 };
1852 self.initialize_with_options_file(&path)?;
1853 // `option_file_name` set *inside* an options file chains nowhere —
1854 // by the time it is read, the file naming it has already been
1855 // chosen. Upstream documents that ("it does not make any sense to
1856 // specify this option within the options file") and then ignores
1857 // it; name it instead, since an ignored setting that looks live is
1858 // the whole complaint behind gh#518.
1859 if let Ok((named, true)) = self.options.get_string_value("option_file_name", "")
1860 && !named.is_empty()
1861 && Path::new(&named) != path
1862 {
1863 load.warnings.push(format!(
1864 "`{}` sets option_file_name to `{named}`, which has no effect: \
1865 the options file is chosen before it is read. Pass \
1866 `option_file_name={named}` on the command line to read that file.",
1867 path.display()
1868 ));
1869 }
1870 load.path = Some(path);
1871 Ok(load)
1872 }
1873
1874 /// Read an `ipopt.opt`-format options file. Equivalent to
1875 /// `IpoptApplication::Initialize(const std::string& options_file)`.
1876 pub fn initialize_with_options_file(&mut self, path: &Path) -> Result<(), SolverException> {
1877 let txt = std::fs::read_to_string(path).map_err(|e| {
1878 SolverException::new(
1879 ExceptionKind::IPOPT_APPLICATION_ERROR,
1880 format!("could not read options file {}: {}", path.display(), e),
1881 file!(),
1882 line!() as Index,
1883 )
1884 })?;
1885 self.options.read_from_str(&txt, true)?;
1886 self.open_output_file_journal();
1887 Ok(())
1888 }
1889
1890 /// Read options from a string in `ipopt.opt` format. Useful for
1891 /// tests and embedded callers.
1892 pub fn initialize_with_options_str(&mut self, s: &str) -> Result<(), SolverException> {
1893 self.options.read_from_str(s, true)?;
1894 self.open_output_file_journal();
1895 Ok(())
1896 }
1897
1898 /// Honor `output_file` / `file_print_level` / `file_append`: when
1899 /// `output_file` is non-empty, attach a `FileJournal` named
1900 /// `"OutputFile:<fname>"` at the requested level. Mirrors
1901 /// `IpoptApplication::OpenOutputFile` (called from `Initialize`).
1902 /// No-op if `output_file` is unset, empty, or could not be opened.
1903 ///
1904 /// NOTE: pounce's iteration output currently bypasses the
1905 /// journalist and writes directly to stdout. The file journal is
1906 /// attached and the timing report (gated by `print_timing_statistics`)
1907 /// is mirrored to it; per-iter rows will start landing in the file
1908 /// once the iter-output path is routed through the journalist.
1909 fn open_output_file_journal(&self) {
1910 let fname = match self.options.get_string_value("output_file", "") {
1911 Ok((v, true)) if !v.is_empty() => v,
1912 _ => return,
1913 };
1914 let level_int = self
1915 .options
1916 .get_integer_value("file_print_level", "")
1917 .ok()
1918 .and_then(|(v, f)| f.then_some(v))
1919 .unwrap_or(5);
1920 let level = journal_level_from_int(level_int);
1921 let append = self
1922 .options
1923 .get_bool_value("file_append", "")
1924 .ok()
1925 .and_then(|(v, f)| f.then_some(v))
1926 .unwrap_or(false);
1927 let jname = format!("OutputFile:{}", fname);
1928 let _ = self
1929 .journalist
1930 .add_file_journal(&jname, &fname, level, append);
1931 }
1932
1933 /// No-op initialize (just succeeds). Mirrors
1934 /// `IpoptApplication::Initialize(bool allow_clobber)` with no
1935 /// options file.
1936 pub fn initialize(&mut self) -> Result<(), SolverException> {
1937 Ok(())
1938 }
1939
1940 /// Mirror `IpoptApplication::OpenOutputFile`. Sets the `output_file`
1941 /// / `file_print_level` options and attaches a matching
1942 /// `FileJournal` named `OutputFile:<fname>` to the journalist.
1943 /// Returns `false` if the file could not be opened or the option
1944 /// store rejected the request (e.g. clamped print level).
1945 pub fn open_output_file(&mut self, fname: &str, print_level: i32) -> bool {
1946 if self
1947 .options
1948 .set_string_value("output_file", fname, true, false)
1949 .is_err()
1950 {
1951 return false;
1952 }
1953 if self
1954 .options
1955 .set_integer_value("file_print_level", print_level as Index, true, false)
1956 .is_err()
1957 {
1958 return false;
1959 }
1960 let level = journal_level_from_int(print_level);
1961 let jname = format!("OutputFile:{}", fname);
1962 // Drop any previous file journal so a second call switches files
1963 // cleanly. `add_file_journal` would otherwise refuse to attach
1964 // a duplicate by name; remove-by-name isn't in the journalist
1965 // API, so we settle for the name-collision case here.
1966 self.journalist
1967 .add_file_journal(&jname, fname, level, false)
1968 .is_some()
1969 }
1970
1971 /// Wrap a TNLP and report problem dimensions. Used in tests until
1972 /// the full IPM path covers every entry shape.
1973 pub fn problem_dimensions(&self, tnlp: &mut dyn TNLP) -> Option<NlpInfo> {
1974 tnlp.get_nlp_info()
1975 }
1976
1977 /// Diagnostics from the safeguarded `least_square_init_primal`
1978 /// initializer step of the last solve (gh#605): the nonlinear
1979 /// violation before and after, the accepted step norm, how many
1980 /// backtracking trials were rejected, and why it stopped. `None`
1981 /// when `least_square_init_primal` was off or the model had no
1982 /// constraints.
1983 pub fn least_square_init_report(&self) -> Option<crate::init::default::LeastSquareInitReport> {
1984 self.least_square_init_report.clone()
1985 }
1986
1987 pub fn statistics(&self) -> SolveStatistics {
1988 self.statistics.borrow().clone()
1989 }
1990
1991 /// What the warm-start initializer made of the iterate the caller
1992 /// supplied to the most recent solve (gh#606): the residuals it
1993 /// measured, whether each multiplier block was accepted,
1994 /// reconstructed or discarded, and the barrier parameter it
1995 /// settled on.
1996 ///
1997 /// `None` when the last solve was a cold start
1998 /// (`warm_start_init_point=no`), or when no solve has run. Reset at
1999 /// the top of every solve, like [`Self::timing_stats`].
2000 pub fn warm_start_diagnostics(&self) -> Option<crate::init::warm_start::WarmStartDiagnostics> {
2001 self.warm_start_diag.borrow().clone()
2002 }
2003
2004 /// Shared timing accumulator from the most recent `optimize_tnlp`
2005 /// call. Each subsystem (algorithm, NLP, KKT solver) bumped its own
2006 /// fields during the solve; consumers read totals out of the
2007 /// returned `Rc`. The instance is replaced at the top of every
2008 /// subsequent solve, so cloning the `Rc` and holding it past a
2009 /// re-solve will give you the previous solve's timings — by design.
2010 pub fn timing_stats(&self) -> Rc<TimingStatistics> {
2011 Rc::clone(&self.timing.borrow())
2012 }
2013
2014 /// Aggregate linear-solver post-mortem from the most recent
2015 /// `optimize_tnlp` call. `Some` when the workspace-default FERAL
2016 /// backend ran at least one factor; `None` when no factors were
2017 /// recorded (custom factory plugged via
2018 /// [`Self::set_linear_backend_factory`], or solve aborted before
2019 /// the first KKT factor). Reset at the top of every solve.
2020 ///
2021 /// Restoration-phase factorizations are attached as
2022 /// [`LinearSolverSummary::restoration`] when the frontend wired
2023 /// [`Self::restoration_summary_sink`] into its restoration factory.
2024 pub fn linear_solver_summary(&self) -> Option<LinearSolverSummary> {
2025 let mut main = self.linsol_summary_sink.lock().ok()?.clone();
2026 let resto = self
2027 .resto_linsol_summary_sink
2028 .lock()
2029 .ok()
2030 .map(|g| g.clone())
2031 .filter(|r| !r.is_empty());
2032 if main.is_empty() && resto.is_none() {
2033 return None;
2034 }
2035 main.restoration = resto.map(Box::new);
2036 // gh#990 item 7: name what was asked for beside what ran.
2037 main.requested = self
2038 .options
2039 .get_string_value("linear_solver", "")
2040 .ok()
2041 .map(|(v, _)| v);
2042 Some(main)
2043 }
2044
2045 /// Snapshot of both linear-solver summary sinks (main and
2046 /// restoration), for a driver that re-solves and must report the
2047 /// summary of the solve whose verdict it keeps. The second-opinion
2048 /// ladder takes one before its rungs and restores it when no rung is
2049 /// promoted, exactly as it does for the statistics.
2050 pub fn linear_solver_summary_state(&self) -> LinearSolverSummaryState {
2051 let read =
2052 |s: &Arc<Mutex<LinearSolverSummary>>| s.lock().map(|g| g.clone()).unwrap_or_default();
2053 LinearSolverSummaryState {
2054 main: read(&self.linsol_summary_sink),
2055 restoration: read(&self.resto_linsol_summary_sink),
2056 }
2057 }
2058
2059 /// Put back a [`Self::linear_solver_summary_state`] snapshot.
2060 pub fn restore_linear_solver_summary_state(&self, state: LinearSolverSummaryState) {
2061 if let Ok(mut g) = self.linsol_summary_sink.lock() {
2062 *g = state.main;
2063 }
2064 if let Ok(mut g) = self.resto_linsol_summary_sink.lock() {
2065 *g = state.restoration;
2066 }
2067 }
2068
2069 /// The sink a restoration backend factory should record into, e.g.
2070 /// through [`default_backend_factory_with_sink`]. Kept separate from
2071 /// the main solve's sink so the main-solve totals stay about the main
2072 /// solve.
2073 pub fn restoration_summary_sink(&self) -> Arc<Mutex<LinearSolverSummary>> {
2074 Arc::clone(&self.resto_linsol_summary_sink)
2075 }
2076
2077 /// Drive a solve.
2078 ///
2079 /// * Constrained problems (`m > 0`) take the primal-dual IPM path:
2080 /// build a `TNLPAdapter` → `OrigIpoptNlp`, run the
2081 /// [`AlgorithmBuilder`] with the workspace MA57 backend, and
2082 /// call [`IpoptAlgorithm::optimize`]. The `SolverReturn` →
2083 /// `ApplicationReturnStatus` mapping mirrors the table in
2084 /// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception →
2085 /// SolverReturn map").
2086 /// * Unconstrained problems (`m == 0`) keep going through the
2087 /// in-`pounce-nlp` Newton driver so the trivial path is
2088 /// independent of the linear-solver backend.
2089 /// Wrap `tnlp` so per-variable scaling factors are applied as a
2090 /// change of variables, when `nlp_scaling_method=user-scaling` is
2091 /// in effect and the problem supplies non-unit factors (gh#486).
2092 ///
2093 /// Returns the TNLP unchanged under any other scaling method, or
2094 /// when the problem asks for no variable scaling, so an unscaled
2095 /// solve pays nothing. The `Err` carries a message ready to print.
2096 fn install_variable_scaling(
2097 &self,
2098 tnlp: Rc<RefCell<dyn TNLP>>,
2099 ) -> Result<Rc<RefCell<dyn TNLP>>, String> {
2100 // Cleared first so the accessor describes *this* solve. An
2101 // application is reusable across solves (`pounce-cinterface`
2102 // holds one across `IpoptSolve` calls), and a stale vector
2103 // would have a later unscaled solve reporting the previous
2104 // solve's factors.
2105 *self.variable_scaling.borrow_mut() = None;
2106 let method = self
2107 .options
2108 .get_string_value("nlp_scaling_method", "")
2109 .ok()
2110 .and_then(|(v, f)| f.then_some(v))
2111 .unwrap_or_else(|| "gradient-based".to_string());
2112 // `curvature-based` (gh #703) delivers its factors through the
2113 // same `get_scaling_parameters` callback, so the per-variable half
2114 // needs the same substitution wrapper user factors get.
2115 if method != "user-scaling" && method != "curvature-based" {
2116 return Ok(tnlp);
2117 }
2118 match pounce_nlp::scaling_tnlp::wrap_with_scaling(
2119 Rc::clone(&tnlp),
2120 self.nlp_lower_bound_inf(),
2121 self.nlp_upper_bound_inf(),
2122 ) {
2123 Ok(Some(wrapped)) => {
2124 *self.variable_scaling.borrow_mut() =
2125 pounce_nlp::scaling_tnlp::factors_of(&wrapped);
2126 Ok(wrapped)
2127 }
2128 Ok(None) => Ok(tnlp),
2129 Err(why) => Err(format!(
2130 // The trailing newline belongs to the message: the
2131 // caller emits it with `eprint!` and hands the same
2132 // string to the journalist, as the refusals below do.
2133 "pounce: nlp_scaling_method={method} supplied per-variable \
2134 scaling factors that cannot be applied. {why}. Correct the \
2135 factors, or drop nlp_scaling_method={method}.\n"
2136 )),
2137 }
2138 }
2139
2140 /// The per-variable scaling factors applied to the last solve, if
2141 /// any (gh#486). A consumer reading the algorithm's iterate rather
2142 /// than the `finalize_solution` payload sees scaled coordinates and
2143 /// must divide `x` by these, and multiply bound multipliers.
2144 pub fn variable_scaling(&self) -> Option<Vec<Number>> {
2145 self.variable_scaling.borrow().clone()
2146 }
2147
2148 /// Install a starting-point conditioner, if one is asked for.
2149 ///
2150 /// Returns the TNLP unchanged when neither `start_point_perturbation` nor
2151 /// `start_point_conditioner` is set, which is the default — so an ordinary
2152 /// solve pays nothing and its trajectory is untouched.
2153 ///
2154 /// Sits *above* the presolve wrapper and *below* the variable-scaling one,
2155 /// so the point it conditions is the point the algorithm will actually
2156 /// start from, in the coordinates the algorithm will see. Both
2157 /// conditioners override only `get_starting_point`; every other callback
2158 /// forwards, so the solve that follows is the solve pounce would have run
2159 /// had the conditioned point been submitted directly.
2160 ///
2161 /// The two are mutually exclusive by construction rather than by refusal:
2162 /// the displacement is the failure-recovery rung and the Adam warm-up is a
2163 /// user opt-in, and stacking them would put random noise on top of a point
2164 /// the warm-up just spent 200 evaluations choosing. The displacement wins
2165 /// when both are set, because the only thing that sets it is a solve that
2166 /// has already failed.
2167 fn install_start_conditioner(&self, tnlp: Rc<RefCell<dyn TNLP>>) -> Rc<RefCell<dyn TNLP>> {
2168 use pounce_nlp::start_conditioner::{AdamConfig, ConditionedStartTnlp, StartConditioner};
2169 // Each option is read with its literal tag rather than through a
2170 // `|name, fallback|` helper. A helper reads better and costs the
2171 // wiring guard in `tests/init_options_wiring.rs` its evidence: that
2172 // test scans the source for `get_*_value("<tag>"` to prove every
2173 // registered Initialization option is actually consumed, and a tag
2174 // passed as a variable is invisible to it. A registered knob nothing
2175 // reads validates, accepts a value, and lies.
2176 let perturbation = self
2177 .options
2178 .get_numeric_value("start_point_perturbation", "")
2179 .map(|(v, _found)| v)
2180 .unwrap_or(0.0);
2181 let conditioner = if perturbation > 0.0 {
2182 let seed = self
2183 .options
2184 .get_integer_value("start_point_perturbation_seed", "")
2185 .map(|(v, _found)| v)
2186 .unwrap_or(0);
2187 StartConditioner::Jitter {
2188 // `Index` is signed and the option is lower-bounded at 0, so
2189 // this cast cannot lose a set bit for any accepted value.
2190 seed: seed.max(0) as u64,
2191 scale: perturbation,
2192 }
2193 } else {
2194 let which = self
2195 .options
2196 .get_string_value("start_point_conditioner", "")
2197 .map(|(v, _found)| v)
2198 .unwrap_or_else(|_| "none".to_string());
2199 if which != "adam" {
2200 return tnlp;
2201 }
2202 let d = AdamConfig::default();
2203 StartConditioner::Adam(AdamConfig {
2204 iters: self
2205 .options
2206 .get_integer_value("adam_warmup_iters", "")
2207 .map(|(v, _found)| v.max(0) as usize)
2208 .unwrap_or(d.iters),
2209 lr: self
2210 .options
2211 .get_numeric_value("adam_warmup_learning_rate", "")
2212 .map(|(v, _found)| v)
2213 .unwrap_or(d.lr),
2214 rho: self
2215 .options
2216 .get_numeric_value("adam_warmup_penalty", "")
2217 .map(|(v, _found)| v)
2218 .unwrap_or(d.rho),
2219 ..d
2220 })
2221 };
2222 // The sentinels have to come from the options, not from the
2223 // conditioner's own default: a caller who moved `nlp_lower_bound_inf`
2224 // would otherwise have a bound the algorithm treats as absent clipped
2225 // against as if it were real.
2226 //
2227 // Passed through unclamped. These were once `lower.min(-DEFAULT)` /
2228 // `upper.max(DEFAULT)`, which honours only a *loosened* sentinel and
2229 // leaves the failure above intact for a tightened one: at
2230 // `nlp_lower_bound_inf=-1e10` the conditioner still used `-1e19`, so a
2231 // `-1e15` bound was absent to the algorithm and present to the
2232 // clipper — the exact case the comment exists to rule out. The
2233 // sentinel means "absent"; there is only one right answer for what it
2234 // is, and it is the caller's.
2235 let lower = self.nlp_lower_bound_inf();
2236 let upper = self.nlp_upper_bound_inf();
2237 let wrapped = ConditionedStartTnlp::new(tnlp, conditioner).with_bound_inf(lower, upper);
2238 Rc::new(RefCell::new(wrapped))
2239 }
2240
2241 pub fn optimize_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2242 self.optimize_tnlp_with_derivative_test_tnlp(tnlp, None)
2243 }
2244
2245 /// Solve through `tnlp`, optionally overriding the derivative-test target.
2246 /// `None` tests the scaled and conditioned TNLP.
2247 pub fn optimize_tnlp_with_derivative_test_tnlp(
2248 &mut self,
2249 tnlp: Rc<RefCell<dyn TNLP>>,
2250 derivative_test_tnlp: Option<Rc<RefCell<dyn TNLP>>>,
2251 ) -> ApplicationReturnStatus {
2252 // The statistics belong to this solve, all of them. A solve that
2253 // returns before any attempt runs (the degrees-of-freedom refusal, an
2254 // option refusal, a presolve proof) used to leave the previous solve's
2255 // record standing, so `statistics()` on an application reused for a
2256 // second solve reported the first one's iterations for a solve that
2257 // never iterated. The complementarity driver is the case that showed
2258 // it: each `G·H = 0` finish refused for too few degrees of freedom
2259 // was recorded with the iteration count of the solve before it, and
2260 // `total_iterations` counted it again. This replaces the field-by-field
2261 // clears (gh#983 review item 1: `warnings`; `sqp_warm_working_set_applied`)
2262 // that patched the same leak one field at a time.
2263 *self.statistics.borrow_mut() = SolveStatistics::new();
2264 // Belongs to the scale audit's re-solve alone, which runs below this
2265 // entry point (`optimize_constrained`, never `optimize_tnlp`), so no
2266 // solve that starts here may inherit it. `RetryOptionScope` clears it
2267 // on every exit already; this is the belt to that, for a solve on an
2268 // application a caught panic left behind.
2269 self.retry_keeps_declared_box.set(false);
2270 let status = self.optimize_tnlp_dispatch(tnlp, derivative_test_tnlp);
2271 // Printed once, for the run whose point is returned. They used to be
2272 // printed by every audited attempt, so a promoted retry's console
2273 // carried the discarded base attempt's `WARNING:` lines too.
2274 let console_output = match self.options.get_integer_value("print_level", "") {
2275 Ok((v, true)) => v >= 1,
2276 _ => true,
2277 };
2278 if console_output {
2279 for w in &self.statistics.borrow().warnings {
2280 println!("WARNING: {w}");
2281 }
2282 }
2283 status
2284 }
2285
2286 /// The body of [`Self::optimize_tnlp_with_derivative_test_tnlp`].
2287 fn optimize_tnlp_dispatch(
2288 &mut self,
2289 tnlp: Rc<RefCell<dyn TNLP>>,
2290 derivative_test_tnlp: Option<Rc<RefCell<dyn TNLP>>>,
2291 ) -> ApplicationReturnStatus {
2292 // gh#884. Both belong to this solve, not to whatever ran before
2293 // it. Reset *here* rather than in `optimize_constrained`, which
2294 // runs once per attempt: the signature accumulates across the
2295 // attempts a lower wrapper may spend, so
2296 // `run_with_dual_divergence_retry` reads "some attempt of the base
2297 // solve saw it" rather than "the last one did".
2298 self.dual_divergence_signature.set(false);
2299 self.tiny_step_dual_stall.set(false);
2300 self.debugger_attached.set(self.debug_hook.is_some());
2301 self.obj_scale_refused.set((false, false));
2302 self.dual_divergence_retry_promoted.set(false);
2303 *self.mu_strategy_fallback_record.borrow_mut() = None;
2304 self.answer_restored_from_floor.set(false);
2305 // The answer this solve reports is whatever it finalizes; a payload
2306 // left from an earlier solve must not stand in for one this solve
2307 // never wrote, since `reported_on_converged_id` reads it.
2308 *self.last_finalize.borrow_mut() = None;
2309 // Same scoping argument as the three above, for the same reason: the
2310 // memo suppresses a REPEATED header within one run, so it must not
2311 // carry across runs. A library caller solving twice on one
2312 // application gets its header both times — which is exactly what the
2313 // guard protects: an outside retry driver re-entering here for another
2314 // attempt is not such a caller, and says so by setting the flag.
2315 if !self.in_retry_sequence.get() {
2316 *self.last_printed_problem_stats.borrow_mut() = None;
2317 }
2318 // gh#486 stage 2: per-variable `scaling_factor` is applied by
2319 // substituting variables one level below the algorithm, since
2320 // the core's scaling models the objective and the constraint
2321 // rows only. The wrapper consumes the variable factors and
2322 // forwards the rest, so `OrigIpoptNlp` sees exactly what it
2323 // has always handled. Installed here because every entry point
2324 // funnels through this method, and only under `user-scaling`,
2325 // the one method that consults the TNLP for factors at all.
2326 let tnlp = match self.install_variable_scaling(tnlp) {
2327 Ok(t) => t,
2328 Err(msg) => {
2329 use pounce_common::journalist::JournalCategory;
2330 eprint!("{msg}");
2331 self.journalist
2332 .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
2333 return ApplicationReturnStatus::InvalidOption;
2334 }
2335 };
2336
2337 // Starting-point conditioning (`start_point_perturbation`,
2338 // `start_point_conditioner`). A no-op unless one is set, and set by
2339 // nothing automatic except the local-infeasibility ladder's third
2340 // rung, which only runs after a solve has already failed.
2341 let tnlp = self.install_start_conditioner(tnlp);
2342 let derivative_test_tnlp = derivative_test_tnlp.as_ref().unwrap_or(&tnlp);
2343
2344 if let Some(value) = self.unsupported_library_solver_selection() {
2345 use pounce_common::journalist::JournalCategory;
2346 self.journalist.print(
2347 JournalLevel::J_ERROR,
2348 JournalCategory::J_MAIN,
2349 &format!(
2350 "pounce: solver_selection={value} routing is only available \
2351 through the pounce CLI (.nl input); library consumers can use \
2352 qp-active-set, nlp, or auto.\n"
2353 ),
2354 );
2355 return ApplicationReturnStatus::InvalidOption;
2356 }
2357
2358 // A `linear_solver` pounce does not implement is refused rather
2359 // than quietly served by FERAL (gh#483 follow-up). Checked here,
2360 // before any work, so a library consumer gets the same verdict the
2361 // CLI gives before its banner.
2362 if let Some(value) = self.unimplemented_linear_solver() {
2363 use pounce_common::journalist::JournalCategory;
2364 let msg = format!("{}\n", Self::unimplemented_linear_solver_message(&value));
2365 eprint!("{msg}");
2366 self.journalist
2367 .print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, &msg);
2368 return ApplicationReturnStatus::InvalidOption;
2369 }
2370
2371 // gh#483 follow-up: an option naming a feature pounce does not
2372 // implement is refused, not shrugged off. See
2373 // `unimplemented_options` for how membership was established and
2374 // why an explicitly-set *default* is deliberately still allowed.
2375 // gh#518: same treatment for `option_file_name` on an entry point
2376 // that cannot resolve it. Separate from the table above because
2377 // the *feature* now exists — just not here.
2378 // gh#604: same treatment one level down, for a registered *value*
2379 // of an option pounce otherwise reads (`bound_mult_init_method=
2380 // mu-based`).
2381 // gh#604: and for a convex-engine knob on an entry point that
2382 // cannot route to that engine — `option_file_name`'s case, one
2383 // feature over.
2384 if let Some(msg) = self
2385 .unimplemented_option_refusal()
2386 .or_else(|| self.unimplemented_option_value_refusal())
2387 .or_else(|| self.unhonored_option_file_name())
2388 .or_else(|| self.unhonored_convex_option())
2389 {
2390 use pounce_common::journalist::JournalCategory;
2391 eprintln!("{msg}");
2392 self.journalist.print(
2393 JournalLevel::J_ERROR,
2394 JournalCategory::J_MAIN,
2395 &format!("{msg}\n"),
2396 );
2397 return ApplicationReturnStatus::InvalidOption;
2398 }
2399 // A `ma57_pivtolmax` the user set *below* `ma57_pivtol` is a
2400 // contradiction — the escalation ceiling under its floor — and
2401 // upstream refuses it outright
2402 // (`IpMa57TSolverInterface.cpp:313`, `OPTION_INVALID`). pounce
2403 // used to silently rewrite it to `ma57_pivtol`. That was
2404 // unreachable while gh#825 was live, since no `ma57_*` value
2405 // reached the backend at all, and became reachable the moment
2406 // that was fixed — so it is refused here rather than shipped as
2407 // a new way to be quietly ignored.
2408 if let Some(msg) = self.ma57_pivtol_bracket_refusal() {
2409 use pounce_common::journalist::JournalCategory;
2410 eprintln!("{msg}");
2411 self.journalist.print(
2412 JournalLevel::J_ERROR,
2413 JournalCategory::J_MAIN,
2414 &format!("{msg}\n"),
2415 );
2416 return ApplicationReturnStatus::InvalidOption;
2417 }
2418
2419 let backend_warnings = self.take_unimplemented_backend_warnings();
2420 for warning in self
2421 .unexploited_hint_warnings()
2422 .into_iter()
2423 .chain(backend_warnings)
2424 {
2425 eprintln!("{warning}");
2426 }
2427
2428 // Test before presolve, using the requested coordinate space.
2429 self.run_derivative_test(derivative_test_tnlp);
2430
2431 // Top-level algorithm dispatch (Phase 5b §7.1). When the
2432 // `algorithm` option resolves to "active-set-sqp", route
2433 // to the Phase 5b SQP path; otherwise fall through to the
2434 // existing IPM flow unchanged.
2435 // Materialize generic TNLP presolve once at the public entry point.
2436 // The wrapper owns the submitted callback TNLP, so every algorithm
2437 // path below (including retry paths) continues to postsolve into
2438 // the original user-facing space. With `presolve=no`, this returns
2439 // the exact same Rc unchanged.
2440 let tnlp = if self.presolve_already_applied {
2441 tnlp
2442 } else {
2443 match pounce_presolve::wrap_from_options(tnlp, &self.options) {
2444 Ok(tnlp) => tnlp,
2445 Err(err) => {
2446 use pounce_common::journalist::JournalCategory;
2447 self.journalist.print(
2448 JournalLevel::J_ERROR,
2449 JournalCategory::J_MAIN,
2450 &format!("pounce: could not materialize presolve options: {err}\n"),
2451 );
2452 return ApplicationReturnStatus::InvalidOption;
2453 }
2454 }
2455 };
2456
2457 if self.is_sqp_algorithm_selected() {
2458 let dims = tnlp
2459 .borrow_mut()
2460 .get_nlp_info()
2461 .map(|i| (i.n.max(0) as usize, i.m.max(0) as usize));
2462 let auto = dims.is_some_and(|(n, m)| self.sqp_auto_handover(n, m));
2463 self.auto_handover.set(auto);
2464 if !auto {
2465 return self.optimize_sqp_tnlp(tnlp);
2466 }
2467 use pounce_common::journalist::JournalCategory;
2468 self.journalist.print(
2469 JournalLevel::J_SUMMARY,
2470 JournalCategory::J_MAIN,
2471 &format!(
2472 "pounce: active-set SQP on a model this size (n + m ≥ {AUTO_HANDOVER_MIN_DIM}) \
2473 starts from the interior point and hands over \
2474 (ipm_sqp_handover=no for a cold active-set SQP).\n"
2475 ),
2476 );
2477 } else {
2478 self.auto_handover.set(false);
2479 }
2480 let info = match tnlp.borrow_mut().get_nlp_info() {
2481 Some(info) => info,
2482 None => return ApplicationReturnStatus::InvalidProblemDefinition,
2483 };
2484
2485 // Presolve-certified infeasibility. `get_nlp_info` above is what forces
2486 // the (lazy) presolve init, so this is the first point at which the
2487 // proof exists. If bound propagation or FBBT established that the
2488 // feasible region is empty, there is nothing left to compute: return
2489 // the verdict directly.
2490 //
2491 // Short-circuiting *here*, before dispatch, is deliberate. Running the
2492 // solve anyway would only re-derive a strictly weaker result — a
2493 // stationary point of the constraint violation, which for a nonconvex
2494 // problem proves nothing globally — and would also hand an
2495 // `InfeasibleProblemDetected` to the ℓ₁ auto-fallback below
2496 // (`is_l1_fallback_trigger`), which would then burn a whole second
2497 // solve retrying a problem already proved to have no solution.
2498 //
2499 // Soundness rests on `presolve_infeasibility_proof` returning `Some`
2500 // only for a contradiction derived on an *un-clamped* box — a
2501 // detection made while a Phase-0 auxiliary elimination is in force can
2502 // be an artifact of that elimination and is re-checked after rollback
2503 // before it is certified. See `PresolveState::certified_infeasible`.
2504 if let Some(proof) = tnlp.borrow().presolve_infeasibility_proof() {
2505 use pounce_common::journalist::JournalCategory;
2506 let detail = match proof {
2507 pounce_nlp::tnlp::InfeasibilityProof::BoundPropagation => {
2508 "bound propagation crossed a variable's bounds".to_string()
2509 }
2510 pounce_nlp::tnlp::InfeasibilityProof::IntervalArithmetic { witness } => {
2511 format!("interval arithmetic emptied constraint {witness}'s range")
2512 }
2513 };
2514 self.journalist.print(
2515 JournalLevel::J_SUMMARY,
2516 JournalCategory::J_MAIN,
2517 &format!(
2518 "\nEXIT: Presolve detected the feasible region is empty ({detail}).\n\
2519 No feasible point exists; the solve was not run.\n"
2520 ),
2521 );
2522 return ApplicationReturnStatus::InfeasibleProblemDetected;
2523 }
2524 // ℓ₁-exact penalty-barrier opt-in (pounce#10).
2525 // Phase 3 wraps the user TNLP and runs an outer Byrd-Nocedal-
2526 // Waltz ρ-escalation loop around the constrained IPM, with a
2527 // honest-infeasibility status upgrade when the slacks fail to
2528 // collapse at saturated ρ. Phase-1/2 one-shot use is preserved
2529 // when `l1_penalty_max_outer_iter == 1`. The wrapper is a
2530 // no-op for problems with no equality rows, so the
2531 // unconstrained dispatch below is unaffected when there is
2532 // nothing to wrap.
2533 if info.m > 0 && self.is_l1_penalty_enabled() {
2534 if let Some(status) = self.run_l1_penalty_outer_loop(Rc::clone(&tnlp)) {
2535 return status;
2536 }
2537 // Falls through: wrapper construction failed (inner refused
2538 // get_nlp_info / get_bounds_info) or no equality rows to
2539 // slack. Standard dispatch runs unmodified.
2540 }
2541 // Phase 3.5 auto-fallback (pounce#10): if the standard solve
2542 // ends in a trigger-class status, retry transparently with
2543 // the wrapper. Promote the retry's status only if it returns
2544 // SolveSucceeded — otherwise return the original. Skipped if
2545 // the user already opted into the wrapper above (this avoids
2546 // a double pass and keeps semantics predictable).
2547 if info.m > 0
2548 && self.is_l1_fallback_enabled()
2549 && !self.is_l1_penalty_enabled()
2550 && !self.debugger_attached.get()
2551 {
2552 return self.run_with_l1_fallback(tnlp);
2553 }
2554 // Biactive dual-divergence retry (gh#884): if the solve settles
2555 // its primal while its multipliers run away, throw the iterate
2556 // away and solve again from scratch with the constraint-Jacobian
2557 // perturbation on. Outermost of the two retry wrappers, so its
2558 // "base solve" is the whole standard dispatch below including the
2559 // μ flip — the signature is about a *solve*, and spending the μ
2560 // flip first is strictly cheaper than spending this one first.
2561 let status = if self.is_dual_divergence_retry_enabled() && !self.debugger_attached.get() {
2562 self.run_with_dual_divergence_retry(tnlp)
2563 } else {
2564 self.dispatch_standard_solve(tnlp)
2565 };
2566 // gh#983 review item 3: the verdict-level audit runs once, on the
2567 // final verdict, after every retry and fallback has decided.
2568 self.finalize_quality_verdict(status)
2569 }
2570
2571 /// Open a counted re-solve: the base attempt's spend, and its sticky
2572 /// `mu_strategy_fallback` record set aside. That record lives on the
2573 /// application and is copied into the statistics at the end of every
2574 /// run, so without this a re-solve that runs no fallback would be
2575 /// measured with the base's. Nothing reads the record to decide anything.
2576 fn begin_counted_resolve(&self) -> CountedResolve {
2577 let base_spent = self.statistics.borrow().spent_iteration_count();
2578 let base_fallback = self.mu_strategy_fallback_record.borrow_mut().take();
2579 self.statistics.borrow_mut().mu_strategy_fallback = None;
2580 CountedResolve {
2581 base_spent,
2582 base_fallback,
2583 }
2584 }
2585
2586 /// Close a counted re-solve: measure its spend, put the base's fallback
2587 /// record back as the sticky rule leaves it (the re-solve's own record
2588 /// if it ran a fallback, the base's otherwise), and record both attempts'
2589 /// spend in [`SolveStatistics::retry_total_iterations`]. Promoted or
2590 /// declined, that total stands: `restore_into` does not touch it.
2591 fn end_counted_resolve(&self, mark: CountedResolve) {
2592 let retry_spent = self.statistics.borrow().spent_iteration_count();
2593 if self.mu_strategy_fallback_record.borrow().is_none() {
2594 *self.mu_strategy_fallback_record.borrow_mut() = mark.base_fallback.clone();
2595 self.statistics.borrow_mut().mu_strategy_fallback = mark.base_fallback;
2596 }
2597 self.statistics.borrow_mut().retry_total_iterations = Some(mark.base_spent + retry_spent);
2598 }
2599
2600 /// The standard solve dispatch: the μ-strategy fallback if it is
2601 /// enabled, otherwise one `optimize_constrained` call.
2602 ///
2603 /// Factored out of `optimize_tnlp_with_derivative_test_tnlp` so
2604 /// [`Self::run_with_dual_divergence_retry`] can wrap the whole of it
2605 /// rather than only the bare IPM call (gh#884).
2606 fn dispatch_standard_solve(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
2607 // This attempt's re-solve total starts empty: one left by an earlier
2608 // attempt (the gh#884 retry's base, a second-opinion rung that
2609 // re-enters below `optimize_tnlp`'s reset) is not this attempt's.
2610 self.statistics.borrow_mut().retry_total_iterations = None;
2611 let status = self.dispatch_standard_solve_inner(Rc::clone(&tnlp));
2612 self.audit_solve_quality(tnlp, status)
2613 }
2614
2615 /// [`Self::dispatch_standard_solve`] without the gh#983 quality audit.
2616 fn dispatch_standard_solve_inner(
2617 &mut self,
2618 tnlp: Rc<RefCell<dyn TNLP>>,
2619 ) -> ApplicationReturnStatus {
2620 // μ-strategy auto-fallback (pounce#138): if the standard solve
2621 // stalls, retry once with the opposite mu_strategy and promote
2622 // only on Solve_Succeeded. Which stalls qualify depends on
2623 // whether the caller asked for the retry — see
2624 // `run_with_mu_strategy_fallback` (pounce#748).
2625 // Applies to constrained and unconstrained alike (both run the
2626 // same IPM). Independent of, and lower priority than, the ℓ₁
2627 // fallback above.
2628 if self.is_mu_strategy_fallback_enabled() && !self.debugger_attached.get() {
2629 return self.run_with_mu_strategy_fallback(tnlp);
2630 }
2631 // Every problem — constrained or not — goes through the same
2632 // primal-dual IPM, exactly as upstream Ipopt does. There is no
2633 // separate "unconstrained Newton" path: the linear-solver
2634 // backend (FERAL/MA57) handles the augmented system, so the
2635 // sparse IPM covers `m == 0` at any `n` without a dense-Hessian
2636 // blowup.
2637 self.optimize_constrained(tnlp)
2638 }
2639
2640 /// Read the ℓ₁ wrapper master switch from the OptionsList.
2641 /// Default `false` when the option is not set.
2642 fn is_l1_penalty_enabled(&self) -> bool {
2643 self.options
2644 .get_bool_value("l1_exact_penalty_barrier", "")
2645 .ok()
2646 .and_then(|(v, found)| found.then_some(v))
2647 .unwrap_or(false)
2648 }
2649
2650 fn l1_penalty_init(&self) -> Number {
2651 self.options
2652 .get_numeric_value("l1_penalty_init", "")
2653 .ok()
2654 .and_then(|(v, found)| found.then_some(v))
2655 .unwrap_or(1.0)
2656 }
2657 fn l1_penalty_max(&self) -> Number {
2658 self.options
2659 .get_numeric_value("l1_penalty_max", "")
2660 .ok()
2661 .and_then(|(v, found)| found.then_some(v))
2662 .unwrap_or(1.0e6)
2663 }
2664 fn l1_penalty_increase_factor(&self) -> Number {
2665 self.options
2666 .get_numeric_value("l1_penalty_increase_factor", "")
2667 .ok()
2668 .and_then(|(v, found)| found.then_some(v))
2669 .unwrap_or(8.0)
2670 }
2671 fn l1_penalty_max_outer_iter(&self) -> usize {
2672 self.options
2673 .get_integer_value("l1_penalty_max_outer_iter", "")
2674 .ok()
2675 .and_then(|(v, found)| found.then_some(v))
2676 .unwrap_or(8) as usize
2677 }
2678 fn l1_slack_tol(&self) -> Number {
2679 self.options
2680 .get_numeric_value("l1_slack_tol", "")
2681 .ok()
2682 .and_then(|(v, found)| found.then_some(v))
2683 .unwrap_or(1.0e-6)
2684 }
2685 fn l1_steering_factor(&self) -> Number {
2686 self.options
2687 .get_numeric_value("l1_steering_factor", "")
2688 .ok()
2689 .and_then(|(v, found)| found.then_some(v))
2690 .unwrap_or(10.0)
2691 }
2692 fn is_l1_fallback_enabled(&self) -> bool {
2693 self.options
2694 .get_bool_value("l1_fallback_on_restoration_failure", "")
2695 .ok()
2696 .and_then(|(v, found)| found.then_some(v))
2697 .unwrap_or(false)
2698 }
2699
2700 /// Did the caller set `mu_strategy` explicitly?
2701 ///
2702 /// The answer decides whether the limited-memory default applies
2703 /// (see `algorithm_builder_from_options`): upstream only substitutes
2704 /// `adaptive` for the registered `monotone` when the option is
2705 /// absent from the list.
2706 fn mu_strategy_was_set(&self) -> bool {
2707 matches!(
2708 self.options.get_string_value("mu_strategy", ""),
2709 Ok((_, true))
2710 )
2711 }
2712
2713 /// Did the caller set `mu_strategy_fallback` themselves? Separates
2714 /// the opted-in retry from the default-on one, which triggers on a
2715 /// narrower set of statuses (pounce#748).
2716 ///
2717 /// True for an explicit `no` as well as an explicit `yes`, which is
2718 /// harmless: this is only ever read downstream of
2719 /// [`Self::is_mu_strategy_fallback_enabled`], and an explicit `no`
2720 /// stops there.
2721 fn mu_strategy_fallback_was_set(&self) -> bool {
2722 matches!(
2723 self.options.get_bool_value("mu_strategy_fallback", ""),
2724 Ok((_, true))
2725 )
2726 }
2727
2728 /// Options whose presence means a `Solved_To_Acceptable_Level`
2729 /// exit may be something the *caller* asked for rather than a stall
2730 /// POUNCE fell into (gh #757).
2731 ///
2732 /// Every one of them either moves the bar a certificate has to clear
2733 /// (`tol` and the component tolerances, the `acceptable_*` family),
2734 /// arms a guard that refuses a certificate the iterate would
2735 /// otherwise have earned (`kkt_fidelity_tol`, the certificate-mask
2736 /// and noise-floor kappas, the divergence / infeasibility streaks,
2737 /// the restoration-decline pair).
2738 ///
2739 /// Options that only move the *starting point* are deliberately not
2740 /// here. `least_square_init_primal` was tried and removed: a caller
2741 /// who picks an initialization heuristic has said nothing about what
2742 /// convergence means, and listing it made an explicit `=no` behave
2743 /// differently from omitting the option, which is a distinction the
2744 /// rest of the solver does not draw.
2745 const TERMINATION_POLICY_OPTIONS: &'static [&'static str] = &[
2746 "tol",
2747 "dual_inf_tol",
2748 "constr_viol_tol",
2749 "compl_inf_tol",
2750 "acceptable_tol",
2751 "acceptable_iter",
2752 "acceptable_dual_inf_tol",
2753 "acceptable_constr_viol_tol",
2754 "acceptable_compl_inf_tol",
2755 "acceptable_obj_change_tol",
2756 "kkt_fidelity_tol",
2757 "obj_scale_certificate_threshold",
2758 "dual_inf_scale_kappa",
2759 "primal_noise_floor_kappa",
2760 "dual_diverging_streak",
2761 "infeas_max_streak",
2762 "resto_decline_deferrals",
2763 "resto_decline_progress_ratio",
2764 "neg_curv_escapes",
2765 "limited_memory_ls_failure_restarts",
2766 ];
2767
2768 /// Did the caller set any option from
2769 /// [`Self::TERMINATION_POLICY_OPTIONS`]?
2770 ///
2771 /// This is what separates the two readings of a
2772 /// `Solved_To_Acceptable_Level` exit. Under stock convergence
2773 /// settings it means POUNCE's own schedule parked the dual term
2774 /// above `tol` and a flipped schedule is worth one try. Under a
2775 /// caller-modified one it may be the signal the caller armed the
2776 /// option to receive, and erasing it with a retry is exactly the
2777 /// laundering pounce#748 refused to do by default.
2778 fn caller_set_termination_policy(&self) -> bool {
2779 Self::TERMINATION_POLICY_OPTIONS.iter().any(|name| {
2780 matches!(self.options.get_numeric_value(name, ""), Ok((_, true)))
2781 || matches!(self.options.get_integer_value(name, ""), Ok((_, true)))
2782 || matches!(self.options.get_bool_value(name, ""), Ok((_, true)))
2783 || matches!(self.options.get_string_value(name, ""), Ok((_, true)))
2784 })
2785 }
2786
2787 /// The μ strategy this option table actually resolves to, as
2788 /// `algorithm_builder_from_options` will build it: the explicit
2789 /// value when there is one, otherwise `adaptive` for a
2790 /// limited-memory Hessian and `monotone` for anything else.
2791 ///
2792 /// The fallback below flips *this*, not the registered default —
2793 /// flipping the registered default under limited-memory would
2794 /// "retry" with the strategy that just failed.
2795 fn effective_mu_strategy_is_adaptive(&self) -> bool {
2796 mu_strategy_resolves_to_adaptive(&self.options)
2797 }
2798
2799 /// Read the μ-strategy auto-fallback switch (pounce#138).
2800 ///
2801 /// An explicit setting always wins. Absent one the default is **on**
2802 /// (pounce#748) — but only while the user has not chosen a
2803 /// `mu_strategy` themselves. Retrying under the other schedule is a
2804 /// recovery for a solve that stalled on a strategy POUNCE picked; it
2805 /// is not licence to override a strategy the caller named. Without
2806 /// that condition, flipping the default would silently contaminate
2807 /// every controlled comparison that pins `mu_strategy` on purpose,
2808 /// this repository's own benchmark arms included. The motivating
2809 /// case is unaffected: `dirichlet120` stalls under the
2810 /// limited-memory substitution (pounce#746), which by definition
2811 /// only happens when `mu_strategy` is unset.
2812 ///
2813 /// Public so a driver that budgets a solve on the assumption that a
2814 /// stalled one is re-solved (the complementarity driver's warm rungs)
2815 /// can check that the retry will actually run.
2816 pub fn is_mu_strategy_fallback_enabled(&self) -> bool {
2817 match self.options.get_bool_value("mu_strategy_fallback", "") {
2818 Ok((v, true)) => v,
2819 _ => !self.mu_strategy_was_set() || self.mu_strategy_pounce_chosen,
2820 }
2821 }
2822
2823 /// Record that the `mu_strategy` now in the option table was chosen by a
2824 /// POUNCE frontend, not by the caller (gh#990).
2825 ///
2826 /// The Python path for a problem without `hessian` writes
2827 /// `mu_strategy=monotone` so that gh#746's limited-memory -> adaptive
2828 /// substitution does not read a barrier preference out of a Hessian
2829 /// choice POUNCE made itself. Written as an ordinary option, that pin
2830 /// also read as "the caller named a strategy" to
2831 /// [`Self::is_mu_strategy_fallback_enabled`], which switched off the
2832 /// default-on stall retry (gh#748) on exactly that path — the same
2833 /// set-ness defect the second-opinion ladder's write-backs once had.
2834 /// With this mark the pin still decides the schedule, and the fallback
2835 /// stays on. A frontend calls it only when the caller's own options carry
2836 /// no `mu_strategy`.
2837 pub fn mark_mu_strategy_pounce_chosen(&mut self) {
2838 self.mu_strategy_pounce_chosen = true;
2839 }
2840
2841 /// Has the user set `algorithm = active-set-sqp`? Reads the
2842 /// string option and matches case-insensitively against the
2843 /// design-note §7.1 spelling. Any value other than
2844 /// "active-set-sqp" (including absence) routes to the
2845 /// default IPM path.
2846 /// Stash a warm-start iterate for the SQP path. Consumed by
2847 /// the next `optimize_tnlp` call when the `algorithm` option
2848 /// resolves to `active-set-sqp`; the IPM path ignores it.
2849 /// Phase 5c (§6) — the parametric / MPC warm-start hand-off.
2850 ///
2851 /// The iterate is auto-cleared after use, so a follow-up
2852 /// solve without an intervening `set_sqp_warm_start` call
2853 /// cold-starts.
2854 ///
2855 /// `x`, `lambda_g` and `lambda_x` are in the **caller's** space — what
2856 /// `finalize_solution` reports: full-`n` `x` in model units, `lambda_g`
2857 /// in `g` order on the unscaled Lagrangian `f + λᵀg`, and
2858 /// `lambda_x = z_L − z_U`. The solve maps them into the algorithm's
2859 /// space (fixed variables dropped, `[y_c; y_d]` row order, objective,
2860 /// constraint and per-variable scaling) itself, so a converged solve's
2861 /// reported multipliers can be fed back unchanged. The working set is in
2862 /// the same space: bound statuses full-`n` in variable order (fixed
2863 /// variables' entries are ignored), constraint statuses one per `g` row
2864 /// in the user's row order — exactly what [`Self::last_sqp_working_set`]
2865 /// publishes, so a published set can be fed back as-is.
2866 pub fn set_sqp_warm_start(&mut self, warm: crate::sqp::SqpIterates) {
2867 self.sqp_warm_start = Some(warm);
2868 }
2869
2870 /// Drop any pending warm-start iterate without solving.
2871 pub fn clear_sqp_warm_start(&mut self) {
2872 self.sqp_warm_start = None;
2873 }
2874
2875 /// What the crossover phase did on the most recent solve (gh#612).
2876 ///
2877 /// `None` means crossover never ran — either `crossover=no` (the
2878 /// default) or the solve did not converge. A `Some` whose
2879 /// [`CrossoverReport::accepted`] is false means it ran and *declined*;
2880 /// the two are different facts about a solve and consumers that reason
2881 /// about active-set certainty (sensitivity's AMBIGUOUS class, a
2882 /// downstream `var_status`) need to tell them apart.
2883 ///
2884 /// [`CrossoverReport::accepted`]: crate::crossover::CrossoverReport::accepted
2885 pub fn crossover_report(&self) -> Option<&crate::crossover::CrossoverReport> {
2886 self.crossover_report.as_ref()
2887 }
2888
2889 /// What the interior-point → active-set handover did on the most recent
2890 /// solve (gh#1054); `None` unless `ipm_sqp_handover=yes`.
2891 pub fn handover_report(&self) -> Option<&crate::handover::HandoverReport> {
2892 self.handover_report.as_ref()
2893 }
2894
2895 /// Install a full primal-dual warm-start iterate for the next IPM
2896 /// `optimize_tnlp`. Captured by the debugger's `resolve` so the
2897 /// re-solve continues from the paused interior point. The caller is
2898 /// responsible for also enabling `warm_start_init_point=yes` (and
2899 /// usually `warm_start_target_mu=<μ>`) so the re-optimize branch of
2900 /// `WarmStartIterateInitializer` preserves the installed iterate.
2901 /// Consumed once per solve, then auto-cleared.
2902 pub fn set_warm_start_iterate(&mut self, snap: crate::debug::IterateSnapshot) {
2903 self.warm_start_iterate = Some(snap);
2904 }
2905
2906 /// Install a caller-supplied fill-reducing permutation for the KKT
2907 /// linear solver (pounce#180 item 1). The next `optimize_*` builds
2908 /// the FERAL backend with [`pounce_feral::OrderingMethod::External`],
2909 /// overriding the `feral_ordering` string option / env var. Use this
2910 /// to inject a block-triangular / Schur ordering a generic algorithm
2911 /// cannot see (Parker, Garcia & Bent, arXiv:2602.17968) or a tearing
2912 /// ordering from equation-oriented decomposition.
2913 ///
2914 /// `perm` is a **0-based, new-to-old permutation** (`perm[k]` is the
2915 /// original index that becomes index `k`), and its length must equal
2916 /// the augmented KKT system dimension (variables + slacks +
2917 /// constraint duals), *not* the problem's `n`. A wrong length or a
2918 /// non-bijection is rejected by FERAL at the first factorization with
2919 /// an `InvalidInput` error (never a panic), surfacing as a solver
2920 /// failure rather than a silently-wrong solve — the ordering only
2921 /// affects fill/time, never the computed solution.
2922 ///
2923 /// Persistent config: unlike the warm-start hooks it is *not*
2924 /// auto-cleared after a solve. Call [`Self::clear_external_ordering`]
2925 /// to drop it. Ignored by non-FERAL backends and by any custom
2926 /// factory plugged via [`Self::set_linear_backend_factory`].
2927 pub fn set_external_ordering(&mut self, perm: Vec<usize>) {
2928 self.external_ordering = Some(perm);
2929 }
2930
2931 /// Drop any installed external KKT ordering, restoring the
2932 /// `feral_ordering`-driven default for subsequent solves.
2933 pub fn clear_external_ordering(&mut self) {
2934 self.external_ordering = None;
2935 }
2936
2937 /// The currently-installed external KKT ordering, if any.
2938 pub fn external_ordering(&self) -> Option<&[usize]> {
2939 self.external_ordering.as_deref()
2940 }
2941
2942 /// Install a block-triangular / Schur KKT partition (pounce#180 item 2).
2943 /// `indices` are KKT-space indices (`0..dim` in the `x, s, c, d` block
2944 /// order the aug-system solver assembles) naming the Schur block `S`; that
2945 /// block is Schur-complemented out and only the two diagonal blocks are
2946 /// factorized (inertia via Sylvester's law). Honored on the IPM + feral +
2947 /// exact-Hessian path; the Schur solver falls back to the standard
2948 /// full-space solver transparently when the partition is unsuitable (too
2949 /// large a fraction of the system, malformed, or a backend error), so a
2950 /// stray hook never breaks a solve. Persistent config (not auto-cleared);
2951 /// drop it via [`Self::clear_kkt_schur_block`].
2952 /// Declare the block structure in the **model's own terms**: one block id
2953 /// per variable and per constraint, negative for the shared ones
2954 /// (structured-KKT Phase 5b). Blocks must couple only through the shared
2955 /// entries; the solver checks that against the assembled KKT and falls
2956 /// back when it does not hold.
2957 ///
2958 /// This is the form a model can produce — "`pg[g]` is shared, everything
2959 /// in contingency `k` is block `k`" — and pounce maps it to KKT indices
2960 /// itself, accounting for fixed variables and the equality / inequality
2961 /// split. Lengths are the problem's own `n` and `m`; a mismatch is
2962 /// reported and the solve proceeds monolithically.
2963 ///
2964 /// Persistent config; drop it with [`Self::clear_block_structure`].
2965 pub fn set_block_structure(&mut self, var_blocks: Vec<i32>, con_blocks: Vec<i32>) {
2966 self.model_blocks = Some((var_blocks, con_blocks));
2967 }
2968
2969 /// Drop any declared model-space block structure.
2970 pub fn clear_block_structure(&mut self) {
2971 self.model_blocks = None;
2972 }
2973
2974 /// The declared model-space block structure, if any.
2975 pub fn block_structure(&self) -> Option<(&[i32], &[i32])> {
2976 self.model_blocks
2977 .as_ref()
2978 .map(|(v, c)| (v.as_slice(), c.as_slice()))
2979 }
2980
2981 /// Install a block-parallel KKT partition (structured-KKT Phase 5b).
2982 /// `labels` are KKT-space block ids in the solver's internal
2983 /// `x, slack, eq-dual, ineq-dual` order, `< 0` for the shared border; an
2984 /// **empty** vector asks the solver to detect the structure from the
2985 /// assembled KKT. Honored on the IPM + feral + exact-Hessian path, with a
2986 /// transparent fallback to the standard solver when the partition does not
2987 /// match the matrix. Persistent config; drop it with
2988 /// [`Self::clear_kkt_block_structure`].
2989 pub fn set_kkt_block_structure(&mut self, labels: Vec<i32>) {
2990 self.kkt_blocks = Some(labels);
2991 }
2992
2993 /// Drop any installed block partition.
2994 pub fn clear_kkt_block_structure(&mut self) {
2995 self.kkt_blocks = None;
2996 }
2997
2998 /// The currently-installed block partition, if any.
2999 pub fn kkt_block_structure(&self) -> Option<&[i32]> {
3000 self.kkt_blocks.as_deref()
3001 }
3002
3003 pub fn set_kkt_schur_block(&mut self, indices: Vec<usize>) {
3004 self.kkt_schur_block = Some(indices);
3005 }
3006
3007 /// Drop any installed Schur KKT partition, restoring the standard
3008 /// full-space solver for subsequent solves.
3009 pub fn clear_kkt_schur_block(&mut self) {
3010 self.kkt_schur_block = None;
3011 }
3012
3013 /// The currently-installed Schur KKT partition, if any.
3014 pub fn kkt_schur_block(&self) -> Option<&[usize]> {
3015 self.kkt_schur_block.as_deref()
3016 }
3017
3018 /// Return the final QP working set from the most recent SQP
3019 /// solve, or `None` if the last solve wasn't SQP, didn't
3020 /// produce a working set (cold-start declared the iterate
3021 /// optimal before solving any QP), or no SQP solve has run.
3022 ///
3023 /// The set is in the **caller's** index spaces, the ones
3024 /// `finalize_solution` uses: `bounds[j]` is variable `j` of the full
3025 /// `n` (a variable `fixed_variable_treatment = make_parameter` removed
3026 /// reads `Fixed`), and `constraints[i]` is `g` row `i` in the model's
3027 /// own row order — not the SQP's internal equalities-first order. It is
3028 /// the form [`Self::set_sqp_warm_start`] takes back.
3029 pub fn last_sqp_working_set(&self) -> Option<&pounce_qp::WorkingSet> {
3030 self.sqp_last_working_set.as_ref()
3031 }
3032
3033 /// A working set computed in the solve's algorithm space, mapped to the
3034 /// caller's: bound statuses lifted to the full variable vector (gh#986
3035 /// review item 10 — fixed variables are `Fixed`), constraint statuses
3036 /// moved from the algorithm's `[c; d]` row order (equalities first) to
3037 /// the user's `g` row order through the NLP's `c_map` / `d_map`. A
3038 /// bound vector that is not reduced-sized is left as it is.
3039 fn working_set_to_user(
3040 &self,
3041 nlp: &dyn IpoptNlp,
3042 ws: pounce_qp::WorkingSet,
3043 ) -> pounce_qp::WorkingSet {
3044 let bounds = match &self.x_elimination {
3045 Some((map, n_full)) if ws.bounds.len() == map.len() && map.len() != *n_full => {
3046 let mut bounds = vec![pounce_qp::BoundStatus::Fixed; *n_full];
3047 for (k, &j) in map.iter().enumerate() {
3048 bounds[j as usize] = ws.bounds[k];
3049 }
3050 bounds
3051 }
3052 _ => ws.bounds,
3053 };
3054 pounce_qp::WorkingSet {
3055 bounds,
3056 constraints: working_set_rows_to_user(nlp, ws.constraints),
3057 }
3058 }
3059
3060 /// If `solver_selection` is explicitly set to a value whose routing lives
3061 /// only in the CLI's `.nl` dispatch, return it; otherwise `None`.
3062 /// `optimize_tnlp` uses this to reject a forced convex selection a library
3063 /// consumer cannot honor.
3064 fn unsupported_library_solver_selection(&self) -> Option<&'static str> {
3065 let (v, found) = self.options.get_string_value("solver_selection", "").ok()?;
3066 if !found {
3067 return None;
3068 }
3069 ["lp-ipm", "qp-ipm", "socp"]
3070 .into_iter()
3071 .find(|c| v.eq_ignore_ascii_case(c))
3072 }
3073
3074 /// The `linear_solver` value when the caller explicitly asked for a
3075 /// backend pounce does not implement; `None` when the request can be
3076 /// served (or was never made).
3077 ///
3078 /// pounce ships two: **FERAL** (pure Rust, the effective default) and
3079 /// **MA57** (HSL, behind the `ma57` feature). The option's valid-value
3080 /// list is a faithful port of upstream Ipopt's — `ma27`, `ma77`,
3081 /// `ma86`, `ma97`, `mumps`, `pardiso`, `pardisomkl`, `spral`, `wsmp`,
3082 /// `custom` — so an `ipopt.opt` written for Ipopt parses here, and
3083 /// every one of those names used to fall through a `_ =>` arm to
3084 /// FERAL. A run "using MUMPS" was a FERAL run; a benchmark comparing
3085 /// backends compared FERAL with itself (gh#483 follow-up).
3086 ///
3087 /// The registered default is `feral`, which pounce implements, so no
3088 /// explicit-vs-default distinction is needed: whatever the option
3089 /// resolves to must be a backend that exists. (It is checked
3090 /// unconditionally on purpose — a future default naming something
3091 /// unimplemented should trip this, not slip past it.)
3092 ///
3093 /// Explicit `ma57` on a build that lacks the feature is *not* refused;
3094 /// that fallback is reported in the banner ("ma57 requested but not
3095 /// compiled"), so it is visible rather than silent, and failing a
3096 /// portable `ipopt.opt` over a build flag would cost more than it buys.
3097 pub fn unimplemented_linear_solver(&self) -> Option<String> {
3098 let (v, _) = self.options.get_string_value("linear_solver", "").ok()?;
3099 ["feral", "ma57"]
3100 .iter()
3101 .all(|ok| !v.eq_ignore_ascii_case(ok))
3102 .then_some(v)
3103 }
3104
3105 /// The message for the first option the caller set that names a
3106 /// feature pounce does not implement, or `None`. Public so the CLI
3107 /// can refuse before routing — the convex dispatch never reaches
3108 /// `optimize_tnlp`. See [`crate::unimplemented_options`].
3109 ///
3110 /// A run configuring nothing but backends pounce does not ship is
3111 /// refused here too, after the per-option table has had its say —
3112 /// see [`crate::unimplemented_options::backend_only_refusal`]. It
3113 /// is folded in rather than given its own accessor so that every
3114 /// surface already refusing on this method refuses on it as well;
3115 /// the CLI is not the only frontend, and a condition worth failing
3116 /// on is not worth failing on only from the CLI.
3117 pub fn unimplemented_option_refusal(&self) -> Option<String> {
3118 crate::unimplemented_options::refusal(&self.options, &self.reg_options).or_else(|| {
3119 crate::unimplemented_options::backend_only_refusal(&self.options, &self.reg_options)
3120 })
3121 }
3122
3123 /// The message for the first string option the caller set to a
3124 /// registered *value* pounce does not implement, or `None`.
3125 ///
3126 /// Separate from [`Self::unimplemented_option_refusal`] because the
3127 /// option itself is read and its other values work — it is one mode
3128 /// that is missing, not the feature. See
3129 /// [`crate::unimplemented_options::UNIMPLEMENTED_VALUES`].
3130 pub fn unimplemented_option_value_refusal(&self) -> Option<String> {
3131 crate::unimplemented_options::value_refusal(&self.options)
3132 }
3133
3134 /// `option_file_name` set on a surface that never resolves it.
3135 ///
3136 /// The option reaches a file through exactly one path —
3137 /// [`Self::initialize_with_option_file`], which the `pounce` CLI
3138 /// drives. A library caller (Python, the C interface, WASM) sets its
3139 /// options directly and calls no such thing, so on those surfaces the
3140 /// option names a whole configuration and applies none of it: gh#518's
3141 /// failure mode, one surface over. It used to be caught by the blanket
3142 /// [`crate::unimplemented_options`] refusal, which no longer covers it
3143 /// now that the feature exists; this keeps the guard exactly where the
3144 /// feature still doesn't.
3145 ///
3146 /// Deliberately *not* fixed by having the library read an options file
3147 /// too: an implicit `./ipopt.opt` lookup under Python or the GAMS C
3148 /// link would be a surprising action at a distance, and `pounce.opt`
3149 /// already means something else to GAMS.
3150 pub fn unhonored_option_file_name(&self) -> Option<String> {
3151 if self.option_file_resolved {
3152 return None;
3153 }
3154 // Same default gate as the table: an explicitly-set *default*
3155 // asks for nothing, so it must not fail. `option_file_name`
3156 // defaults to `ipopt.opt`, and a caller round-tripping a full
3157 // option dump — or a generated config that spells out every
3158 // registered name — hits that value without asking for anything.
3159 if !crate::unimplemented_options::set_to_a_non_default(
3160 &self.options,
3161 &self.reg_options,
3162 "option_file_name",
3163 ) {
3164 return None;
3165 }
3166 match self.options.get_string_value("option_file_name", "") {
3167 Ok((name, true)) if !name.is_empty() => Some(format!(
3168 "pounce: `option_file_name` was set to `{name}`, but this entry \
3169 point does not read options files — it would configure nothing. \
3170 The `pounce` CLI honors it (and `./pounce.opt` / `./ipopt.opt`); \
3171 from a library, read the file yourself and pass its contents to \
3172 `initialize_with_options_str`, or set the options directly. \
3173 Tracking issue: https://github.com/jkitchin/pounce/issues/518"
3174 )),
3175 _ => None,
3176 }
3177 }
3178
3179 /// Declare that this caller can route a model to the convex LP/QP /
3180 /// SOCP engines, so the `qp_*` knobs they read configure something.
3181 ///
3182 /// The `pounce` CLI calls this: it owns the `.nl` structure
3183 /// extraction that classifies a model, which is the whole of what
3184 /// `solver_selection`'s convex values need. No library frontend can
3185 /// (see [`Self::unsupported_library_solver_selection`]), so the
3186 /// default is `false` and [`Self::unhonored_convex_option`] refuses
3187 /// the knobs there.
3188 ///
3189 /// Declaring it also covers the CLI's *fallback*: a convex attempt
3190 /// that returns no verified point hands the model to
3191 /// [`Self::optimize_tnlp`], and the `qp_*` values it was given
3192 /// configured that attempt for real. Refusing them at the handoff
3193 /// would fail a run that used them.
3194 pub fn set_convex_routing_available(&mut self, available: bool) {
3195 self.convex_routing_available = available;
3196 }
3197
3198 /// The convex LP/QP knobs are registered core-side so every frontend
3199 /// parses them — but only the CLI can reach the engines that read
3200 /// them. On any other entry point the option names a whole
3201 /// configuration and applies none of it; this is the message that
3202 /// says so, in place of the silence.
3203 ///
3204 /// gh#604. Same shape and same default gate as
3205 /// [`Self::unhonored_option_file_name`]: an explicitly-set *default*
3206 /// asks for nothing and must keep working, so only a value that
3207 /// differs is refused.
3208 pub fn unhonored_convex_option(&self) -> Option<String> {
3209 if self.convex_routing_available {
3210 return None;
3211 }
3212 const CONVEX_ONLY: &[&str] = &[
3213 "qp_presolve",
3214 "qp_tau",
3215 "qp_tau_max",
3216 "qp_reg",
3217 "qp_gondzio_corr",
3218 "qp_infeas_tol",
3219 "qp_hsde",
3220 "qp_equilibrate",
3221 "qp_crossover",
3222 ];
3223 let name = CONVEX_ONLY.iter().find(|name| {
3224 crate::unimplemented_options::set_to_a_non_default(
3225 &self.options,
3226 &self.reg_options,
3227 name,
3228 )
3229 })?;
3230 Some(format!(
3231 "pounce: `{name}` tunes the convex LP/QP interior-point engine, but \
3232 this entry point cannot route a model to it — the option would \
3233 configure nothing. The `pounce` CLI reaches that engine on `.nl` \
3234 input (`solver_selection=lp-ipm` / `qp-ipm` / `socp`, or `auto` on \
3235 a model that classifies as one); the `qp_*` options themselves are \
3236 read only there and by `pounce.solve_qp`. From Python, \
3237 `pounce.solve_qp` drives the engine directly and takes `qp_presolve`, \
3238 `qp_reg`, `qp_hsde`, `qp_equilibrate` and `qp_crossover` as keyword \
3239 arguments (presolve is off there unless asked for). \
3240 On this path, `solver_selection=qp-active-set` \
3241 (or `algorithm=active-set-sqp`) is the nearest thing, tuned by the \
3242 `sqp_qp_*` options. Tracking issue: \
3243 https://github.com/jkitchin/pounce/issues/604"
3244 ))
3245 }
3246
3247 /// Warnings for caching hints pounce does not exploit. These never
3248 /// block a solve: the answer is identical either way, so refusing
3249 /// would cost the caller more than the silence did.
3250 pub fn unexploited_hint_warnings(&self) -> Vec<String> {
3251 crate::unimplemented_options::hint_warnings(&self.options, &self.reg_options)
3252 }
3253
3254 /// Warnings for the constant-derivative hints when the solve routes to
3255 /// `pounce-convex` instead of here. Call site is the convex dispatch
3256 /// in the CLI, next to the other guards that live there for the same
3257 /// reason: that dispatch never reaches [`Self::optimize_tnlp`], so
3258 /// `install_constant_derivative_hints` never runs and the hints are
3259 /// unread. On the NLP route they are honoured, so this must not be
3260 /// called there.
3261 pub fn convex_unexploited_hint_warnings(&self) -> Vec<String> {
3262 crate::unimplemented_options::convex_hint_warnings(&self.options, &self.reg_options)
3263 }
3264
3265 /// Which of the four constant-derivative hints the caller actually
3266 /// asserted, in [`pounce_nlp::constant_derivatives::HINT_OPTIONS`]
3267 /// order — the order [`reconcile`] pairs against the model's own
3268 /// proofs.
3269 ///
3270 /// Each name is read as a literal rather than through the loop
3271 /// variable the caller used to use. The registered-but-unread scan
3272 /// (`tests/no_silent_options.rs`) keys on the option name as it
3273 /// appears at the accessor, so `get_bool_value(name, "")` over an
3274 /// array read as "no key here" and left all four sitting in the
3275 /// silent list while they were fully wired and consumed (#551 /
3276 /// #677).
3277 ///
3278 /// Matching over `HINT_OPTIONS` rather than writing a bare array
3279 /// keeps the slots right by construction, and a fifth hint added to
3280 /// `HINT_OPTIONS` trips the fallback arm instead of silently reading
3281 /// as "not asserted" — which is the failure mode this whole line of
3282 /// work exists to kill.
3283 fn asserted_constant_derivative_hints(&self) -> [bool; 4] {
3284 use pounce_nlp::constant_derivatives::HINT_OPTIONS;
3285 let read_yes = |key: &str| matches!(self.options.get_bool_value(key, ""), Ok((true, true)));
3286 HINT_OPTIONS.map(|name| match name {
3287 "grad_f_constant" => read_yes("grad_f_constant"),
3288 "hessian_constant" => read_yes("hessian_constant"),
3289 "jac_c_constant" => read_yes("jac_c_constant"),
3290 "jac_d_constant" => read_yes("jac_d_constant"),
3291 other => unreachable!("`{other}` is in HINT_OPTIONS with no read site"),
3292 })
3293 }
3294
3295 /// Resolve the four constant-derivative hints for this solve and
3296 /// install the result on the NLP (gh #588, phase Q6).
3297 ///
3298 /// `grad_f_constant` / `hessian_constant` / `jac_c_constant` /
3299 /// `jac_d_constant` are, upstream, unchecked user assertions: Ipopt
3300 /// reuses the derivative and returns a wrong answer if the assertion
3301 /// was false. pounce asks the model first. Where the model *proves*
3302 /// the derivative constant, the reuse happens whether or not the
3303 /// option was set — the hint is redundant. Where the model proves it
3304 /// **varies** and the option was set anyway, the option is refused
3305 /// with a warning, which is the deliberate divergence. Where the
3306 /// model can prove nothing — every callback front end, both GAMS
3307 /// links — the user's assertion is honoured on trust, exactly as
3308 /// upstream, because "unproved" is not "disproved" and silently
3309 /// overriding the caller there would be its own wrong answer.
3310 fn install_constant_derivative_hints(&self, orig_nlp: &mut OrigIpoptNlp) {
3311 use pounce_common::journalist::JournalCategory;
3312 use pounce_nlp::constant_derivatives::reconcile;
3313
3314 // Each name is read as a literal rather than through the loop
3315 // variable: the registered-but-unread scan
3316 // (`tests/no_silent_options.rs`) keys on the option name as it
3317 // appears at the accessor, so `get_bool_value(name, "")` read as
3318 // "no key here" and left all four of these sitting in the silent
3319 // list while they were fully wired and consumed (#551 / #677).
3320 //
3321 // Matching over `HINT_OPTIONS` rather than writing a bare array
3322 // keeps the order right by construction — `reconcile` pairs
3323 // `asserted[k]` with `proofs[k]` — and a fifth hint added to
3324 // `HINT_OPTIONS` trips the fallback arm instead of silently
3325 // reading as "not asserted", which is the failure mode this
3326 // whole line of work exists to kill.
3327 let asserted = self.asserted_constant_derivative_hints();
3328 let proofs = orig_nlp.derivative_proofs();
3329 let (outcomes, enabled) = reconcile(proofs, asserted);
3330
3331 for outcome in &outcomes {
3332 if let Some(warning) = outcome.warning() {
3333 eprintln!("{warning}");
3334 self.journalist.print(
3335 JournalLevel::J_STRONGWARNING,
3336 JournalCategory::J_MAIN,
3337 &format!("{warning}\n"),
3338 );
3339 }
3340 }
3341 if std::env::var("POUNCE_DBG_CONSTDERIV").is_ok() {
3342 for outcome in &outcomes {
3343 eprintln!(
3344 "[const deriv] {:<15} proof={:?} asserted={} reused={}",
3345 outcome.name, outcome.proof, outcome.asserted, outcome.honoured,
3346 );
3347 }
3348 }
3349 orig_nlp.set_constant_derivatives(enabled);
3350 }
3351
3352 /// Warnings for knobs of a linear-solver backend pounce does not
3353 /// ship (`ma97_*`, `pardiso_*`, …), one line per backend family.
3354 ///
3355 /// Warnings and not refusals: an `ipopt.opt` carrying settings for
3356 /// several backends so that one file runs everywhere is exactly what
3357 /// the registry exists to accept, and refusing it would fail a run
3358 /// over knobs it never touches. See the "Backend knobs warn, they do
3359 /// not refuse" section of [`crate::unimplemented_options`]. gh#551.
3360 pub fn unimplemented_backend_warnings(&self) -> Vec<String> {
3361 crate::unimplemented_options::backend_warnings(&self.options, &self.reg_options)
3362 }
3363
3364 /// The same warnings, but at most once per application: the second
3365 /// caller gets nothing.
3366 ///
3367 /// Two sites emit them — the CLI, before routing, because a convex
3368 /// model never reaches [`Self::optimize_tnlp`], and `optimize_tnlp`
3369 /// itself, for every frontend that is not the CLI. A CLI run passes
3370 /// through both, and printing the identical paragraph twice is how a
3371 /// warning teaches its reader to skip warnings.
3372 pub fn take_unimplemented_backend_warnings(&mut self) -> Vec<String> {
3373 if self.backend_warnings_emitted {
3374 return Vec::new();
3375 }
3376 self.backend_warnings_emitted = true;
3377 self.unimplemented_backend_warnings()
3378 }
3379
3380 /// Resolve the five registered `derivative_test*` knobs. Every one
3381 /// of them was registered and never read, so `derivative_test=
3382 /// first-order` ran no test and printed nothing — a checker that
3383 /// silently checks nothing reports success by omission (gh#483
3384 /// follow-up).
3385 ///
3386 /// The numeric helper is named `read_num` to match the accessor
3387 /// idiom the rest of this file uses: the registered-but-unread scan
3388 /// (`tests/no_silent_options.rs`) discovers `read_*` helpers and the
3389 /// literal key passed to them, so a differently-named local closure
3390 /// made `derivative_test_perturbation` and `derivative_test_tol`
3391 /// read as silent when they are wired and consumed (#677, #551).
3392 fn derivative_test_options(&self) -> DerivativeTestOptions {
3393 let read_num = |key: &str, default: Number| -> Number {
3394 self.options
3395 .get_numeric_value(key, "")
3396 .ok()
3397 .and_then(|(v, f)| f.then_some(v))
3398 .unwrap_or(default)
3399 };
3400 DerivativeTestOptions {
3401 mode: self
3402 .options
3403 .get_string_value("derivative_test", "")
3404 .ok()
3405 .and_then(|(v, f)| f.then_some(v))
3406 .map(|v| DerivativeTest::from_option(&v))
3407 .unwrap_or_default(),
3408 perturbation: read_num("derivative_test_perturbation", 1e-8),
3409 tol: read_num("derivative_test_tol", 1e-4),
3410 first_index: self
3411 .options
3412 .get_integer_value("derivative_test_first_index", "")
3413 .ok()
3414 .and_then(|(v, f)| f.then_some(v))
3415 .unwrap_or(-2),
3416 print_all: self
3417 .options
3418 .get_bool_value("derivative_test_print_all", "")
3419 .ok()
3420 .and_then(|(v, f)| f.then_some(v))
3421 .unwrap_or(false),
3422 }
3423 }
3424
3425 /// The derivative checker's structured verdict from the most recent
3426 /// [`Self::run_derivative_test`], or `None` when no test ran (gh#990).
3427 /// The IPM and SQP drains copy it into `SolveStatistics`; a caller that
3428 /// builds its own report (the CLI's convex route) reads it here.
3429 pub fn derivative_check(&self) -> Option<pounce_nlp::derivative_test::DerivativeCheckSummary> {
3430 self.derivative_check.borrow().clone()
3431 }
3432
3433 /// Run the derivative checker, if requested, against `tnlp`.
3434 ///
3435 /// Advisory, like upstream: a suspicious entry is reported and the
3436 /// solve continues. The report goes to stderr so it survives
3437 /// `print_level=0` and leaves `--json-output`'s stdout clean.
3438 pub fn run_derivative_test(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
3439 // gh#990 item 3: the structured result belongs to this solve; a solve
3440 // that asks for no test must not inherit the previous one's.
3441 *self.derivative_check.borrow_mut() = None;
3442 let opts = self.derivative_test_options();
3443 if matches!(opts.mode, DerivativeTest::None) {
3444 return;
3445 }
3446 let report = {
3447 let mut borrowed = tnlp.borrow_mut();
3448 pounce_nlp::derivative_test::run(&mut *borrowed, &opts)
3449 };
3450 let Some(report) = report else {
3451 eprintln!(
3452 "pounce: derivative_test was requested but the TNLP declined to \
3453 supply the information the check needs (dimensions, bounds, or \
3454 a starting point); no test was run."
3455 );
3456 return;
3457 };
3458 *self.derivative_check.borrow_mut() = Some(report.summary.clone());
3459 use pounce_common::journalist::JournalCategory;
3460 for line in &report.lines {
3461 eprintln!("{line}");
3462 self.journalist.print(
3463 JournalLevel::J_SUMMARY,
3464 JournalCategory::J_MAIN,
3465 &format!("{line}\n"),
3466 );
3467 }
3468 }
3469
3470 /// The message [`Self::unimplemented_linear_solver`] earns, shared by
3471 /// every frontend so they cannot drift apart.
3472 pub fn unimplemented_linear_solver_message(value: &str) -> String {
3473 format!(
3474 "pounce: linear_solver={value} is not implemented. pounce provides \
3475 `feral` (pure-Rust sparse symmetric, the default) and `ma57` (HSL, \
3476 in a `--features ma57` build); the other names in the option's \
3477 list come from the upstream Ipopt registry so an ipopt.opt written \
3478 for Ipopt still parses. Selecting one used to run FERAL silently, \
3479 which makes a backend comparison measure nothing — so it is \
3480 refused instead. Use linear_solver=feral or linear_solver=ma57."
3481 )
3482 }
3483
3484 /// Will the next `optimize_tnlp` run the active-set SQP driver rather
3485 /// than the interior-point one?
3486 ///
3487 /// Public because the dispatch decision has to be *reportable*, not just
3488 /// taken: before this was reachable, the CLI's `Selected solver:` line
3489 /// and the JSON report's `solution.engine` both named the filter-IPM on
3490 /// every `algorithm=active-set-sqp` run, because the only thing either
3491 /// consulted was `solver_selection` — which is `auto` on a general NLP
3492 /// however the `algorithm` option is set. Reading the same predicate
3493 /// `optimize_tnlp` dispatches on is what keeps the report and the run
3494 /// from disagreeing; deriving it a second time in the CLI is how they
3495 /// drifted in the first place.
3496 pub fn is_sqp_algorithm_selected(&self) -> bool {
3497 // `algorithm` is the primary selector.
3498 // `solver_selection = qp-active-set` selects the
3499 // same active-set SQP engine.
3500 let algo_sqp = matches!(
3501 self.options.get_string_value("algorithm", ""),
3502 Ok((v, true)) if v.eq_ignore_ascii_case("active-set-sqp")
3503 );
3504 let selection_sqp = matches!(
3505 self.options.get_string_value("solver_selection", ""),
3506 Ok((v, true)) if v.eq_ignore_ascii_case("qp-active-set")
3507 );
3508 algo_sqp || selection_sqp
3509 }
3510
3511 /// Whether an active-set SQP request on a model with `n` variables and
3512 /// `m` constraints runs as the interior point with the IPM→SQP handover
3513 /// and post-convergence crossover, instead of a cold active-set SQP
3514 /// (gh#1061).
3515 ///
3516 /// A primal active-set QP started cold changes its working set one row or
3517 /// bound per iteration, so the SQP's first step QP needs on the order of
3518 /// one iteration per active constraint, each a KKT update the size of the
3519 /// model. Measured on four 9–20 k-variable equality-heavy NLPs
3520 /// (`brainpc2`, `robot_1600`, `WM_CFy`, `ubh5`): not one first QP finished
3521 /// in 300 s. Started from the interior point instead, `ubh5` solves in
3522 /// 0.5 s and `robot_1600` in 6 s. The active-set SQP produces the answer
3523 /// when a handover attempt is accepted; when every attempt declines, the
3524 /// converged interior point is the answer, exactly as under
3525 /// `ipm_sqp_handover=yes`. Post-convergence `crossover` is not turned on
3526 /// as well: measured, it bought `robot_1600` its active-set answer for
3527 /// 6 s → 41 s, and on `brainpc2` declined after a 194 s step-3 QP, past
3528 /// the wall-time limit.
3529 ///
3530 /// Only above [`AUTO_HANDOVER_MIN_DIM`], where a cold SQP is the problem,
3531 /// and only when `ipm_sqp_handover` was not set: `ipm_sqp_handover=no`
3532 /// asks for the cold active-set SQP and gets it.
3533 pub fn sqp_auto_handover(&self, n: usize, m: usize) -> bool {
3534 self.is_sqp_algorithm_selected()
3535 && n + m >= AUTO_HANDOVER_MIN_DIM
3536 && !matches!(
3537 self.options.get_bool_value("ipm_sqp_handover", ""),
3538 Ok((_, true))
3539 )
3540 }
3541
3542 /// Whether the most recent solve was run as interior point plus handover
3543 /// under [`Self::sqp_auto_handover`].
3544 pub fn auto_handover_ran(&self) -> bool {
3545 self.auto_handover.get()
3546 }
3547
3548 /// Whether the active-set SQP produced the most recent solve's point:
3549 /// always on the cold SQP route, and on the auto-handover route when a
3550 /// handover attempt (or a crossover the user asked for) was accepted.
3551 pub fn answer_from_active_set_sqp(&self) -> bool {
3552 if !self.auto_handover.get() {
3553 return self.is_sqp_algorithm_selected();
3554 }
3555 self.handover_report
3556 .as_ref()
3557 .is_some_and(|r| r.accepted_at_iter.is_some())
3558 || self.crossover_report.as_ref().is_some_and(|r| r.accepted())
3559 }
3560
3561 /// Phase 5b SQP entry point. Builds the same NLP chain
3562 /// (`TNLPAdapter` → `OrigIpoptNlp` → `IpoptNlpAdapter`) the
3563 /// IPM uses, then runs `SqpAlgorithm::optimize`. Maps the
3564 /// `SqpResult.status` back to `ApplicationReturnStatus` and
3565 /// hands the final iterate to the user TNLP's
3566 /// `finalize_solution` callback via `finalize_via_sqp`.
3567 fn optimize_sqp_tnlp(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
3568 use pounce_nlp::ConstObjScaling;
3569 use pounce_nlp::orig_ipopt_nlp::OrigIpoptNlp;
3570 use pounce_nlp::tnlp_adapter::TNLPAdapter;
3571
3572 // Wall-clock for the whole SQP solve, mirroring the IPM path's
3573 // `t_start` (see the `total_wallclock_time_secs` assignment in
3574 // `optimize_tnlp`). Without this the field stayed at its struct
3575 // default of 0.0 on every active-set solve, so `--json-output`
3576 // reported an instantaneous solve regardless of actual runtime and
3577 // the engine could not be speed-compared against qp-ipm at all
3578 // (benchmarks/scripts/compare_qp_four_way.py had to skip the column).
3579 let t_start = std::time::Instant::now();
3580
3581 // `new_with_options`, not `new`: the latter hard-codes the same three
3582 // defaults, so `fixed_variable_treatment`, `nlp_lower_bound_inf` and
3583 // `nlp_upper_bound_inf` were accepted and discarded on this arm. The
3584 // defaults coincide, which is why it went unnoticed — it diverges only
3585 // for a caller who sets one, and then the two arms CLASSIFY THE MODEL
3586 // DIFFERENTLY: a bound at the caller's own infinity threshold is a
3587 // bound here and no bound there, and a fixed variable is eliminated on
3588 // one arm and relaxed on the other. That is a different problem, not a
3589 // different trajectory on one.
3590 let (lo_inf, up_inf, fixed_treatment) = self.adapter_options();
3591 let adapter = match TNLPAdapter::new_with_options(
3592 Rc::clone(&tnlp),
3593 lo_inf,
3594 up_inf,
3595 fixed_treatment,
3596 ) {
3597 Ok(a) => Rc::new(RefCell::new(a)),
3598 Err(_) => return ApplicationReturnStatus::InvalidProblemDefinition,
3599 };
3600 // The SQP path never runs gradient-based scaling, but the
3601 // constant `obj_scaling_factor` (negative ⇒ maximize) still
3602 // applies via the OrigIpoptNlp constructor.
3603 let obj_scaling_factor = self
3604 .options
3605 .get_numeric_value("obj_scaling_factor", "")
3606 .ok()
3607 .and_then(|(v, f)| f.then_some(v))
3608 .unwrap_or(1.0);
3609 let mut orig_nlp = match OrigIpoptNlp::new(
3610 Rc::clone(&adapter),
3611 Rc::new(ConstObjScaling(obj_scaling_factor)),
3612 ) {
3613 Ok(n) => n,
3614 Err(_) => return ApplicationReturnStatus::InternalError,
3615 };
3616 // Same Q6 reconciliation as the IPM route: the SQP driver
3617 // evaluates the same derivatives through the same NLP object.
3618 self.install_constant_derivative_hints(&mut orig_nlp);
3619 // `bound_relax_factor`, on the convex arm's rule (gh#745's
3620 // `convex_bound_relax`), because this arm's position is the convex
3621 // arm's and not the interior-point arm's.
3622 //
3623 // The widening is a change to the MODEL, and its error is one-signed:
3624 // enlarging the feasible set can only flatter the objective, by `δ`
3625 // times the bound's multiplier, with nothing bounding that product and
3626 // no amount of tightening `tol` closing it. On `LISWET1` it buys `9.0`
3627 // of objective (`27.1221` against the true `36.1224`); over the
3628 // 91-instance netlib LP corpus, dropping it took the median objective
3629 // error from `1.2e-08` to `3.8e-11`.
3630 //
3631 // The interior-point arm keeps it and must: it is a feasible-iterate
3632 // log-barrier that needs `x` strictly inside its bounds, and matching
3633 // Ipopt is that arm's contract — the fixture sweep at
3634 // `bound_relax_factor=0` turns `square_flowsheet_resto` into
3635 // `InfeasibleProblemDetected`. An active-set QP needs none of it: its
3636 // iterates are feasible for the box by construction, which is why this
3637 // arm has always run un-widened without incident.
3638 //
3639 // So: unset means solve the model as declared, which is what this arm
3640 // already did. What it did NOT do is honour an explicit request — the
3641 // option was accepted and discarded, the gh#677 shape, and a caller
3642 // comparing arms under a named `bound_relax_factor` was comparing two
3643 // different models without being told. Set means the caller asked by
3644 // name and gets exactly the interior-point arm's model.
3645 //
3646 // Either branch snapshots the declared bounds first (`relax_bounds`
3647 // does it before widening), which `declared_box_violation` needs: its
3648 // absence is what printed `Variable bound violation: nan` here.
3649 let requested_relax = self
3650 .options
3651 .get_numeric_value("bound_relax_factor", "")
3652 .ok()
3653 .and_then(|(v, set)| set.then_some(v));
3654 match requested_relax {
3655 Some(factor) => {
3656 let cap = self
3657 .options
3658 .get_numeric_value("constr_viol_tol", "")
3659 .ok()
3660 .and_then(|(v, set)| set.then_some(v))
3661 .unwrap_or(1e-4);
3662 orig_nlp.relax_bounds(factor, cap);
3663 }
3664 None => orig_nlp.snapshot_declared_bounds(),
3665 }
3666 let nlp_rc: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
3667
3668 let mut sqp_adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(&nlp_rc));
3669
3670 let mut builder = self.algorithm_builder_snapshot();
3671 builder.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
3672 // `max_wall_time` / `max_cpu_time` on this arm (gh#1054). They were
3673 // registered, documented as solve-wide, and read only by the
3674 // interior-point convergence check, so an active-set solve stuck in
3675 // its first QP ran until something outside POUNCE killed it.
3676 builder.sqp.deadline = Some(self.solve_deadline());
3677 // Prefer a caller-installed factory, exactly as `optimize_constrained`
3678 // does, so an embedder that plugs its own backend gets it on both
3679 // arms. `take()` matches the IPM's one-shot semantics rather than
3680 // introducing a second, different lifetime for the same hook.
3681 //
3682 // `maybe_crossover` deliberately does NOT do this: it runs after the
3683 // IPM solve has already taken the factory, so there is never one left
3684 // to find, and its two closures cannot both hold `&mut self` anyway.
3685 let factory = self
3686 .linear_backend_factory
3687 .take()
3688 .unwrap_or_else(|| self.make_backend_factory());
3689 let mut alg = match builder.build_sqp_with_backend(factory) {
3690 Some(a) => a,
3691 None => return ApplicationReturnStatus::InternalError,
3692 };
3693
3694 // Problem statistics + end-of-run summary are emitted by the engine
3695 // itself here (#206), gated on the main `print_level`, so the SQP
3696 // route matches the IPM route across every frontend (CLI, Python, C).
3697 // The SQP's own per-iteration rows stay gated on the separate
3698 // `sqp_print_level`.
3699 let console_output = match self.options.get_integer_value("print_level", "") {
3700 Ok((v, true)) => v >= 1,
3701 _ => true,
3702 };
3703 self.emit_problem_stats(&tnlp, console_output);
3704
3705 // Phase 5c (§6): consume any stashed warm-start iterate.
3706 // `optimize_with_warm_start(warm=None)` is equivalent to
3707 // `optimize`, so cold callers see no change.
3708 let mut warm = self.sqp_warm_start.take();
3709 // gh#986 review item 10: map a warm start given in the caller's full
3710 // variable space through this problem's fixed-variable elimination.
3711 // The working set this application publishes is in that space
3712 // (`working_set_to_user`), and so is what Python's
3713 // `solve(..., working_set=...)` decodes, so a child that fixes `x1`
3714 // gets the parent's statuses for exactly the columns it still has.
3715 // The first pass compared lengths only: a fixed variable made the
3716 // full-space hint "the wrong size" and it was dropped (a cold solve
3717 // and `info["working_set"] = None`), while a reduced-space hint from a
3718 // sibling that fixed a *different* variable had the right size and was
3719 // applied to the wrong columns.
3720 let (x_map, n_full) = {
3721 let a = adapter.borrow();
3722 let cls = a.classification();
3723 (cls.x_not_fixed_map.clone(), cls.n_full_x as usize)
3724 };
3725 let reduced = x_map.len() != n_full;
3726 let prev_elimination = self.x_elimination.replace((x_map.clone(), n_full));
3727 if let Some(w) = warm.as_mut() {
3728 // The staged iterate is in the caller's (user) space; the
3729 // algorithm's is not. Converted here, once, for every frontend.
3730 sqp_warm_start_to_algorithm(&*nlp_rc.borrow(), w);
3731 if reduced {
3732 let mut drop_reason: Option<String> = None;
3733 if let Some(ws) = w.working.as_mut() {
3734 if ws.bounds.len() == n_full {
3735 ws.bounds = x_map.iter().map(|&j| ws.bounds[j as usize]).collect();
3736 } else if ws.bounds.len() == x_map.len()
3737 && prev_elimination.as_ref() != Some(&(x_map.clone(), n_full))
3738 {
3739 // A reduced-space working set from a solve that
3740 // eliminated *different* variables: its columns do
3741 // not line up with this problem's, however equal the
3742 // count.
3743 drop_reason = Some(
3744 "its bound statuses are in the reduced space of a solve that \
3745 fixed different variables"
3746 .into(),
3747 );
3748 }
3749 }
3750 if let Some(why) = drop_reason {
3751 tracing::warn!(
3752 target: "pounce::sqp",
3753 "SQP warm-start working set dropped: {why}; pass the full-space \
3754 working set (last_sqp_working_set / info[\"working_set\"]) instead"
3755 );
3756 w.working = None;
3757 }
3758 }
3759 }
3760 // `warm_start_init_point=yes` with nothing staged: take the TNLP's
3761 // own starting multipliers, as the interior-point arm does. They
3762 // arrive through the same `IpoptNlp` projections the IPM's warm
3763 // initializer uses, so they are already in algorithm space (scaled,
3764 // compressed, `[y_c; y_d]`). Without this the SQP arm read only the
3765 // primal: a `.nl` `d` segment or `ipopt_z*_in` suffix, or a Python
3766 // `lagrange=` start, began from zero multipliers on this arm alone.
3767 if warm.is_none()
3768 && matches!(
3769 self.options.get_bool_value("warm_start_init_point", ""),
3770 Ok((true, _))
3771 )
3772 {
3773 warm = sqp_tnlp_warm_start(&nlp_rc);
3774 }
3775 // gh#986: a warm start recorded on one problem is dimensioned for
3776 // that problem. A child that fixes a variable (branching) reaches
3777 // here with a smaller reduced `n` (and possibly `m`), where the
3778 // algorithm's contract is to refuse a mismatched iterate outright —
3779 // which surfaced as `Internal_Error` on a perfectly legal solve.
3780 // A warm start is only a hint, so drop it with a warning and solve
3781 // cold instead.
3782 if let Some(w) = warm.as_ref() {
3783 use crate::sqp::SqpProblemSpec;
3784 let (n, m) = (sqp_adapter.n(), sqp_adapter.m());
3785 let fits = w.x.len() == n
3786 && w.lambda_x.len() == n
3787 && w.lambda_g.len() == m
3788 && w.working
3789 .as_ref()
3790 .map(|ws| ws.validate_dims(n, m).is_ok())
3791 .unwrap_or(true);
3792 if !fits {
3793 tracing::warn!(
3794 target: "pounce::sqp",
3795 "SQP warm start dropped: it is sized for a problem with n = {}, m = {} \
3796 but this one has n = {n}, m = {m} (a fixed variable or removed row \
3797 changes the reduced dimensions); solving cold",
3798 w.x.len(),
3799 w.lambda_g.len(),
3800 );
3801 warm = None;
3802 }
3803 }
3804 let warm_working_set_applied = warm.as_ref().is_some_and(|w| w.working.is_some());
3805 // gh#1015: the user's `intermediate_callback`, once per iteration,
3806 // with `GetIpoptCurrentIterate` answering from the SQP iterate. The
3807 // snapshot is built before the callback runs because the callback
3808 // holds the TNLP; nothing in it may call back into the user's
3809 // `eval_*`.
3810 {
3811 let hook_nlp = Rc::clone(&nlp_rc);
3812 let hook_tnlp = Rc::clone(&tnlp);
3813 let fixed_var_duals = self.reports_fixed_var_duals();
3814 alg.set_iteration_hook(Box::new(move |info| {
3815 // Built only for a TNLP that reads it, as on the
3816 // interior-point arm: the fixed-variable recovery is not
3817 // free, and most callbacks read only the stats.
3818 let _guard = hook_tnlp.borrow().wants_current_iterate().then(|| {
3819 crate::intermediate::UserIterateGuard::install(sqp_user_iterate(
3820 &hook_nlp,
3821 info.x,
3822 info.lambda_g,
3823 info.lambda_x,
3824 info.c_vals,
3825 info.grad_lag_x,
3826 fixed_var_duals,
3827 ))
3828 });
3829 let stats = pounce_nlp::tnlp::IterStats {
3830 mode: pounce_nlp::return_codes::AlgorithmMode::RegularMode,
3831 iter: info.iter as Index,
3832 obj_value: info.obj,
3833 inf_pr: info.inf_pr,
3834 inf_du: info.inf_du,
3835 // No barrier, no regularisation, no dual step length.
3836 mu: 0.0,
3837 d_norm: info.p_inf,
3838 regularization_size: 0.0,
3839 alpha_du: 0.0,
3840 alpha_pr: info.alpha,
3841 ls_trials: 0,
3842 };
3843 hook_tnlp.borrow_mut().intermediate_callback(
3844 stats,
3845 &pounce_nlp::tnlp::IpoptData::default(),
3846 &pounce_nlp::tnlp::IpoptCq::default(),
3847 )
3848 }));
3849 }
3850 let res = match alg.optimize_with_warm_start(&mut sqp_adapter, warm) {
3851 Ok(r) => r,
3852 Err(e) => {
3853 // Always surface this. It used to be gated on the
3854 // undocumented `POUNCE_DBG_SQP`, so the only thing a user saw
3855 // was a bare `Internal_Error` with no indication of what went
3856 // wrong -- the underlying message here was
3857 // `QpFailure(LinearSolverFailure("QP subproblem returned
3858 // status unbounded"))`, which points straight at the cause.
3859 // A solve that is about to fail is exactly when the reason
3860 // should be cheapest to obtain.
3861 tracing::warn!(
3862 target: "pounce::sqp",
3863 "SQP solve failed: {e:?}"
3864 );
3865 return ApplicationReturnStatus::InternalError;
3866 }
3867 };
3868 // Stash the result's working set so the next solve in a
3869 // sequence can fetch it via `last_sqp_working_set` -- in the caller's
3870 // full variable space (gh#986 review item 10).
3871 self.sqp_last_working_set = res
3872 .working_set
3873 .clone()
3874 .map(|ws| self.working_set_to_user(&*nlp_rc.borrow(), ws));
3875 // Populate the shared `SolveStatistics` so the Python /
3876 // C-API post-solve accessors (`GetIpoptIterCount`,
3877 // `info["iter_count"]`, etc.) report the SQP outer-iter
3878 // count rather than zero. Constraint-violation /
3879 // dual-infeasibility residuals get the SQP-side values
3880 // too. The IPM path overwrites this dict on its own
3881 // solves, so SQP-vs-IPM mixing across solves stays
3882 // honest.
3883 {
3884 let mut stats = self.statistics.borrow_mut();
3885 stats.iteration_count = res.n_iter as Index;
3886 // Subproblem counters. The outer iteration count alone
3887 // cannot show what a working-set warm start bought — the
3888 // saved work is inside the QPs — so both are reported.
3889 stats.sqp_qp_solves = res.n_qp_solves as Index;
3890 stats.sqp_qp_working_set_changes = res.n_qp_working_set_changes as Index;
3891 stats.sqp_warm_working_set_applied = warm_working_set_applied;
3892 stats.final_objective = res.obj;
3893 // `final_scaled_objective` defaults to NaN; the SQP path does not
3894 // thread nlp_scaling through the objective (same as the residuals
3895 // mirrored below), so the scaled objective equals the unscaled
3896 // one. Without this it stayed NaN and the console printed
3897 // "Objective ...: nan <unscaled>" on every active-set solve
3898 // (gh #313), even on a clean optimal solve.
3899 stats.final_scaled_objective = res.obj;
3900 stats.final_dual_inf = res.final_stationarity;
3901 stats.final_constr_viol = res.final_constr_viol;
3902 stats.final_compl = 0.0; // SQP has no barrier — no compl term.
3903 // Overall KKT error. This was previously left at the struct
3904 // default, which made every successful SQP solve report an
3905 // overall error of exactly 0.0 — indistinguishable from a
3906 // genuinely perfect solve, and enough on its own to make
3907 // `pounce.minimize`'s acceptable-KKT fallback upgrade any status
3908 // on this path to `success=True`. Same expression as the unscaled
3909 // twin below; the two agree because the SQP path does not thread
3910 // nlp_scaling through its residuals.
3911 stats.final_kkt_error = res.final_stationarity.max(res.final_constr_viol);
3912 // Unscaled residuals (pounce#173). The SQP path does not thread
3913 // the nlp_scaling factors through to its residuals yet, so these
3914 // mirror the SQP-side values: correct when no scaling is active
3915 // (the common case) and a conservative proxy otherwise. Populated
3916 // here so the info dict's `final_unscaled_*` keys are honest
3917 // rather than left at the 0.0 default.
3918 stats.final_unscaled_dual_inf = res.final_stationarity;
3919 stats.final_unscaled_constr_viol = res.final_constr_viol;
3920 stats.final_unscaled_compl = 0.0;
3921 stats.final_unscaled_kkt_error = res.final_stationarity.max(res.final_constr_viol);
3922 // Ipopt's `Variable bound violation` — how far the returned point
3923 // sits outside the box the caller wrote. Left at its `NaN` default
3924 // until now, which printed `nan` on this arm's every summary.
3925 //
3926 // Measured through the same `declared_box_violation` accessor the
3927 // interior-point arm reads (`curr_declared_box_violation_max`), so
3928 // the two arms cannot drift into reporting different quantities
3929 // under one heading. gh#900 is the precedent and the warning: it
3930 // fixed this row on the NLP and convex arms and noted the two were
3931 // "independent code paths, so a test that exercised one would say
3932 // nothing about the other" — this is the third, and it was missed.
3933 //
3934 // `res.x` is algorithm-space, which is what the accessor expects
3935 // (it lifts to full-x itself); `finalize_via_sqp` wraps it exactly
3936 // this way for the same reason.
3937 stats.final_declared_box_viol = {
3938 use pounce_linalg::dense_vector::DenseVectorSpace;
3939 let nlp_borrow = nlp_rc.borrow();
3940 let x_space = DenseVectorSpace::new(nlp_borrow.n());
3941 let mut x_dv = x_space.make_new_dense();
3942 x_dv.set_values(&res.x);
3943 // `NaN`, not `0.0`, on the abstention — deliberately unlike
3944 // the IPM path's `unwrap_or(0.0)`.
3945 //
3946 // `None` from this accessor means one thing only: the declared
3947 // bounds were never snapshotted. It is NOT "the model has no
3948 // finite bounds" — an unbounded model still snapshots, the
3949 // loops find nothing, and the answer is a measured `Some(0.0)`.
3950 // So on this arm `None` can only mean the
3951 // `snapshot_declared_bounds` call above was lost, and mapping
3952 // that to zero would replace a visible "no answer" with an
3953 // invisible false claim that the point is inside its box.
3954 // gh#900's subject is exactly that: a zero that must not be
3955 // fabricated. Checked by mutation — delete the snapshot call
3956 // and this row goes back to `nan` rather than quietly reading
3957 // `0.00e+00`.
3958 nlp_borrow
3959 .declared_box_violation(&x_dv)
3960 .unwrap_or(Number::NAN)
3961 };
3962 // gh#990: the derivative checker's verdict reaches `info` and the
3963 // report on this arm too; it was copied on the IPM drain only.
3964 stats.derivative_check = self.derivative_check.borrow().clone();
3965 stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
3966 }
3967 let (app_status, solver_status) = match res.status {
3968 crate::sqp::SqpStatus::Optimal => (
3969 ApplicationReturnStatus::SolveSucceeded,
3970 pounce_nlp::SolverReturn::Success,
3971 ),
3972 crate::sqp::SqpStatus::MaxIter => (
3973 ApplicationReturnStatus::MaximumIterationsExceeded,
3974 pounce_nlp::SolverReturn::MaxiterExceeded,
3975 ),
3976 crate::sqp::SqpStatus::InfeasibleSubproblem => (
3977 ApplicationReturnStatus::InfeasibleProblemDetected,
3978 pounce_nlp::SolverReturn::LocalInfeasibility,
3979 ),
3980 crate::sqp::SqpStatus::LineSearchFailed => (
3981 ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
3982 pounce_nlp::SolverReturn::ErrorInStepComputation,
3983 ),
3984 // Honest non-committal QP-subproblem failure (#282): the QP
3985 // solver could not compute a step and did NOT certify
3986 // infeasibility. Never report Infeasible_Problem_Detected here
3987 // — a feasible problem has no infeasibility certificate.
3988 crate::sqp::SqpStatus::QpStepFailed => (
3989 ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
3990 pounce_nlp::SolverReturn::ErrorInStepComputation,
3991 ),
3992 // The QP subproblem ran out of its own iteration budget. Same
3993 // #282 guarantee — no infeasibility is asserted — but reported as
3994 // the budget exhaustion it is, so the user sees a limit they can
3995 // raise (`sqp_qp_max_iter`) instead of a step-size stall with no
3996 // remedy. See `SqpStatus::QpIterationLimit` for why these were
3997 // split.
3998 crate::sqp::SqpStatus::QpIterationLimit => (
3999 ApplicationReturnStatus::MaximumIterationsExceeded,
4000 pounce_nlp::SolverReturn::MaxiterExceeded,
4001 ),
4002 // Unbounded below, with a recession ray verified against the
4003 // true NLP (gh #388). `Diverging_Iterates` is POUNCE's (Ipopt's)
4004 // unboundedness verdict and maps to AMPL `solve_result_num=300`
4005 // — the same answer the IPM selectors give on the same model,
4006 // instead of the `Internal_Error` / 500 ("the solver broke")
4007 // this path used to report.
4008 crate::sqp::SqpStatus::Unbounded => (
4009 ApplicationReturnStatus::DivergingIterates,
4010 pounce_nlp::SolverReturn::DivergingIterates,
4011 ),
4012 // A non-finite iterate or constraint value (gh #876). Same
4013 // verdict, from the same condition, as the interior-point arm's
4014 // `if !nlp_err.is_finite()` screen — the two arms must not
4015 // disagree about what a `NaN` iterate means.
4016 crate::sqp::SqpStatus::InvalidNumber => (
4017 ApplicationReturnStatus::InvalidNumberDetected,
4018 pounce_nlp::SolverReturn::InvalidNumberDetected,
4019 ),
4020 crate::sqp::SqpStatus::UserRequestedStop => (
4021 ApplicationReturnStatus::UserRequestedStop,
4022 pounce_nlp::SolverReturn::UserRequestedStop,
4023 ),
4024 // gh#1054: the same verdicts the interior-point arm's
4025 // convergence check gives on the same budgets.
4026 crate::sqp::SqpStatus::WallTimeExceeded => (
4027 ApplicationReturnStatus::MaximumWallTimeExceeded,
4028 pounce_nlp::SolverReturn::WallTimeExceeded,
4029 ),
4030 crate::sqp::SqpStatus::CpuTimeExceeded => (
4031 ApplicationReturnStatus::MaximumCpuTimeExceeded,
4032 pounce_nlp::SolverReturn::CpuTimeExceeded,
4033 ),
4034 };
4035
4036 // Same gate as the IPM path: an infeasible-subproblem exit is a
4037 // numerical inference, and a feasible starting point disproves it
4038 // (gh #379). Only the infeasibility verdict is rewritten — every other
4039 // status passes through untouched, so the pair stays in lockstep.
4040 let refuted = withdraw_infeasibility_if_refuted(
4041 &tnlp,
4042 solver_status,
4043 self.nlp_lower_bound_inf(),
4044 self.nlp_upper_bound_inf(),
4045 self.user_tol(),
4046 );
4047 let (app_status, solver_status) = if refuted == solver_status {
4048 (app_status, solver_status)
4049 } else {
4050 (solver_return_to_app_status(refuted), refuted)
4051 };
4052
4053 // Forward to the user TNLP's finalize_solution. We pass
4054 // the SQP iterate and recovered multipliers via the
4055 // OrigIpoptNlp's lifting hooks. Failure here is silent
4056 // (we still return the algorithm's status) — the user
4057 // sees the right ApplicationReturnStatus regardless.
4058 let _ = finalize_via_sqp(
4059 &nlp_rc,
4060 &res,
4061 solver_status,
4062 &tnlp,
4063 &self.last_finalize,
4064 self.reports_fixed_var_duals(),
4065 );
4066
4067 // Honor the opt-in status-fidelity gate on the SQP path too
4068 // (pounce#173), then emit the end-of-run summary with the final
4069 // (possibly downgraded) status so the console matches the returned
4070 // ApplicationReturnStatus.
4071 let final_status = self.apply_kkt_fidelity_gate(app_status);
4072 self.emit_end_summary(final_status, &nlp_rc, console_output);
4073 final_status
4074 }
4075
4076 /// Opt-in status-fidelity gate (pounce#173), shared by the IPM and
4077 /// SQP solve paths. When the user sets a positive `kkt_fidelity_tol`,
4078 /// a reported `Solve_Succeeded` whose max-norm UNSCALED KKT error
4079 /// (`SolveStatistics::final_unscaled_kkt_error`) exceeds it is
4080 /// downgraded to `Solved_To_Acceptable_Level` — the honest "this is a
4081 /// point, but not converged to the requested fidelity" status. This
4082 /// catches the ill-conditioned / nlp_scaling-deflated case where the
4083 /// scaled convergence test passes but the user-space duals have
4084 /// drifted. It is a pure relabel at termination (no extra iterations);
4085 /// unset or non-positive (the default) is a strict no-op, so every
4086 /// existing caller keeps the Ipopt-faithful status.
4087 fn apply_kkt_fidelity_gate(
4088 &self,
4089 app_status: ApplicationReturnStatus,
4090 ) -> ApplicationReturnStatus {
4091 if !matches!(app_status, ApplicationReturnStatus::SolveSucceeded) {
4092 return app_status;
4093 }
4094 if let Ok((ftol, true)) = self.options.get_numeric_value("kkt_fidelity_tol", "") {
4095 if ftol > 0.0 {
4096 let unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
4097 if unscaled_kkt > ftol {
4098 tracing::info!(target: "pounce::diagnostics",
4099 "kkt_fidelity_tol={ftol:.3e}: unscaled KKT error {unscaled_kkt:.3e} \
4100 exceeds it — downgrading Solve_Succeeded → \
4101 Solved_To_Acceptable_Level (pounce#173)");
4102 return ApplicationReturnStatus::SolvedToAcceptableLevel;
4103 }
4104 }
4105 }
4106 app_status
4107 }
4108
4109 /// `nlp_lower_bound_inf` — the magnitude at or below which a bound is
4110 /// treated as absent.
4111 fn nlp_lower_bound_inf(&self) -> Number {
4112 self.options
4113 .get_numeric_value("nlp_lower_bound_inf", "")
4114 .ok()
4115 .and_then(|(v, f)| f.then_some(v))
4116 .unwrap_or(DEFAULT_NLP_LOWER_BOUND_INF)
4117 }
4118
4119 /// `nlp_upper_bound_inf` — the magnitude at or above which a bound is
4120 /// treated as absent.
4121 fn nlp_upper_bound_inf(&self) -> Number {
4122 self.options
4123 .get_numeric_value("nlp_upper_bound_inf", "")
4124 .ok()
4125 .and_then(|(v, f)| f.then_some(v))
4126 .unwrap_or(DEFAULT_NLP_UPPER_BOUND_INF)
4127 }
4128
4129 /// The user's convergence tolerance `tol`.
4130 fn user_tol(&self) -> Number {
4131 self.options
4132 .get_numeric_value("tol", "")
4133 .ok()
4134 .and_then(|(v, f)| f.then_some(v))
4135 .unwrap_or(1e-8)
4136 }
4137
4138 /// The user's `acceptable_tol` — the standard behind
4139 /// `Solved_To_Acceptable_Level`.
4140 fn user_acceptable_tol(&self) -> Number {
4141 self.options
4142 .get_numeric_value("acceptable_tol", "")
4143 .ok()
4144 .and_then(|(v, f)| f.then_some(v))
4145 .unwrap_or(1e-6)
4146 }
4147
4148 /// The user's `constr_viol_tol` — the **absolute** feasibility standard
4149 /// the strict gate's primal component judges by
4150 /// (`OptErrorConvCheck::primal_component_passes`).
4151 fn user_constr_viol_tol(&self) -> Number {
4152 self.options
4153 .get_numeric_value("constr_viol_tol", "")
4154 .ok()
4155 .and_then(|(v, f)| f.then_some(v))
4156 .unwrap_or(1e-4)
4157 }
4158
4159 /// The user's `acceptable_constr_viol_tol` — the same standard at the
4160 /// acceptable tier.
4161 fn user_acceptable_constr_viol_tol(&self) -> Number {
4162 self.options
4163 .get_numeric_value("acceptable_constr_viol_tol", "")
4164 .ok()
4165 .and_then(|(v, f)| f.then_some(v))
4166 .unwrap_or(1e-2)
4167 }
4168
4169 /// `primal_noise_floor_kappa` — the safety factor on the per-row
4170 /// floating-point noise floor (gh#528/gh#590). `0` opts out.
4171 fn user_primal_noise_floor_kappa(&self) -> Number {
4172 self.options
4173 .get_numeric_value("primal_noise_floor_kappa", "")
4174 .ok()
4175 .and_then(|(v, f)| f.then_some(v))
4176 .unwrap_or(64.0)
4177 }
4178
4179 /// Emit the Ipopt-style problem-statistics block (#206) from the
4180 /// engine's own reduced problem, gated on `console_output`
4181 /// (print_level >= 1). Shared by the IPM (`optimize_tnlp`) and SQP
4182 /// (`optimize_sqp_tnlp`) entry points so every algorithm and every
4183 /// frontend (CLI, Python, C) gets the identical block. Built from the
4184 /// same `collect_stats` inputs the CLI used, so the output is
4185 /// byte-identical to the historical CLI block.
4186 fn emit_problem_stats(&self, tnlp: &Rc<RefCell<dyn TNLP>>, console_output: bool) {
4187 if !console_output {
4188 return;
4189 }
4190 let (lo_inf, up_inf, fixed_treatment) = self.adapter_options();
4191 if let Some(stats) =
4192 pounce_solve_report::console::collect_stats(tnlp, lo_inf, up_inf, fixed_treatment)
4193 {
4194 // A retry re-enters here with the same problem. Reprinting the
4195 // block verbatim is what made a retried run look like it had
4196 // printed its whole report twice for no reason, so say what is
4197 // actually happening instead: a new attempt on an unchanged
4198 // problem. One line, in place of seventeen that carry no
4199 // information the reader does not already have.
4200 //
4201 // The attempt is announced rather than merely suppressed because
4202 // the iteration table below it restarts at iteration 0 either way.
4203 // Silence would leave that table appearing from nowhere — trading
4204 // a confusing duplicate for a confusing gap.
4205 let mut memo = self.last_printed_problem_stats.borrow_mut();
4206 if memo.as_ref() == Some(&stats) {
4207 // Silent under an outside retry driver, which narrates its own
4208 // attempts in more detail than this line could ("pounce:
4209 // second opinion — re-solving with feral_scaling=mc64…").
4210 // Printing both would be two announcements of one event.
4211 if !self.in_retry_sequence.get() {
4212 println!("Re-solving the same problem (a further attempt follows).");
4213 println!();
4214 }
4215 } else {
4216 pounce_solve_report::console::print_problem_stats(&stats);
4217 *memo = Some(stats);
4218 }
4219 }
4220 }
4221
4222 /// Drain the NLP's per-eval counters into the shared `SolveStatistics`
4223 /// and emit the Ipopt-style end-of-run summary (#206). Shared by both
4224 /// solve paths. The counts are read from the NLP AFTER the solve (so the
4225 /// final solution evaluation is included, matching the historical count)
4226 /// and written into `SolveStatistics` so the post-solve API accessors
4227 /// (`info["n_obj_evals"]`, …) report them even when the console is
4228 /// silent. c/d (and jac_c/jac_d) are per-subsystem, so the max recovers
4229 /// the eval_g / eval_jac_g call count. The console summary itself is
4230 /// gated on `console_output` (print_level >= 1).
4231 fn emit_end_summary(
4232 &self,
4233 app_status: ApplicationReturnStatus,
4234 nlp: &Rc<RefCell<dyn IpoptNlp>>,
4235 console_output: bool,
4236 ) {
4237 {
4238 let ec = nlp.borrow().eval_counts();
4239 let mut stats = self.statistics.borrow_mut();
4240 stats.num_obj_evals = ec[0];
4241 stats.num_obj_grad_evals = ec[1];
4242 stats.num_constr_evals = ec[2].max(ec[3]);
4243 stats.num_constr_jac_evals = ec[4].max(ec[5]);
4244 stats.num_hess_evals = ec[6];
4245 }
4246 if !console_output {
4247 return;
4248 }
4249 let stats = self.statistics.borrow();
4250 let counts = pounce_solve_report::console::EvalCounts {
4251 n_obj: stats.num_obj_evals as u64,
4252 n_grad_f: stats.num_obj_grad_evals as u64,
4253 n_g: stats.num_constr_evals as u64,
4254 n_jac_g: stats.num_constr_jac_evals as u64,
4255 n_h: stats.num_hess_evals as u64,
4256 };
4257 // gh#987: no widening at `bound_relax_factor = 0`, so the console's
4258 // "before the bound_relax_factor widening" line has nothing to report.
4259 let widening_applied = self
4260 .options
4261 .get_numeric_value("bound_relax_factor", "")
4262 .map(|(v, _)| v > 0.0)
4263 .unwrap_or(true);
4264 pounce_solve_report::console::print_summary_with_widening(
4265 app_status,
4266 &stats,
4267 &counts,
4268 widening_applied,
4269 );
4270 // The verdict belongs to the run, not to the attempt. Held back while
4271 // any retry driver is managing attempts; that driver prints it once,
4272 // with the status it actually returns.
4273 if self.end_verdict_deferrals.get() == 0 {
4274 pounce_solve_report::console::print_exit_verdict(app_status);
4275 }
4276 }
4277
4278 /// Build a *copy* of the algorithm builder configured per the
4279 /// current options. The SQP path uses this so it gets a
4280 /// fresh builder without mutating the application's state.
4281 /// Read the `ipm_sqp_handover*` option family into a
4282 /// [`crate::handover::HandoverOptions`].
4283 fn handover_options(&self) -> crate::handover::HandoverOptions {
4284 let mut o = crate::handover::HandoverOptions::default();
4285 // `sqp_auto_handover` only fires when this option was not set.
4286 o.enabled = self.auto_handover.get();
4287 if let Ok((v, true)) = self.options.get_bool_value("ipm_sqp_handover", "") {
4288 o.enabled = v;
4289 }
4290 if let Ok((v, true)) = self.options.get_numeric_value("ipm_sqp_handover_tol", "") {
4291 o.tol = v;
4292 }
4293 if let Ok((v, true)) = self
4294 .options
4295 .get_integer_value("ipm_sqp_handover_stall_iter", "")
4296 {
4297 o.stall_iter = v.max(0);
4298 }
4299 if let Ok((v, true)) = self
4300 .options
4301 .get_integer_value("ipm_sqp_handover_max_attempts", "")
4302 {
4303 o.max_attempts = v.max(0) as u32;
4304 }
4305 o
4306 }
4307
4308 /// Install the interior-point → active-set handover hook on `alg` when
4309 /// `ipm_sqp_handover=yes` (gh#1054; see [`crate::handover`]). Returns
4310 /// the cells the hook reports into — the report, and the working set of
4311 /// an accepted attempt — for [`Self::finish_handover`] to read after the
4312 /// solve.
4313 #[allow(clippy::type_complexity)]
4314 fn install_handover(
4315 &self,
4316 alg: &mut IpoptAlgorithm,
4317 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4318 ) -> Option<(
4319 Rc<RefCell<crate::handover::HandoverReport>>,
4320 Rc<RefCell<Option<pounce_qp::WorkingSet>>>,
4321 )> {
4322 let hopts = self.handover_options();
4323 if !hopts.enabled {
4324 return None;
4325 }
4326 let mut trigger = crate::handover::HandoverTrigger::new(hopts);
4327 let xopts = crate::crossover::CrossoverOptions {
4328 enabled: true,
4329 ..self.crossover_options()
4330 };
4331 let snapshot = self.algorithm_builder_snapshot();
4332 let qp_opts = snapshot.sqp_qp.clone();
4333 let data = Rc::clone(&alg.data);
4334 let nlp = Rc::clone(nlp_handle);
4335 // One factory serves every backend the attempts build: a factory is
4336 // reusable, but `build_sqp_with_backend` takes one by value, so the
4337 // step-4 SQP gets a forwarding closure onto the shared one.
4338 let backend = Rc::new(RefCell::new(self.make_backend_factory()));
4339 let tols = self.user_tolerances();
4340 let report = Rc::new(RefCell::new(crate::handover::HandoverReport::default()));
4341 let working = Rc::new(RefCell::new(None));
4342 let (report_out, working_out) = (Rc::clone(&report), Rc::clone(&working));
4343 let (wall0, cpu0) = (
4344 pounce_common::utils::wallclock_time(),
4345 pounce_common::utils::cpu_time(),
4346 );
4347 alg.handover = Some(Box::new(move |curr, iter, err, mu| {
4348 if !trigger.should_try(iter, err) {
4349 return None;
4350 }
4351 let seed = crossover_seed(&nlp, curr);
4352 let x = crate::crossover::CrossoverOptions {
4353 primal_tol: crate::handover::identification_primal_tol(xopts.primal_tol, mu),
4354 ..xopts.clone()
4355 };
4356 // An attempt may spend at most what the interior method has
4357 // spent so far (floor 1 s), and never past the solve's own
4358 // budget. Without a cap a declined attempt is unbounded: on
4359 // `deb7` (813 variables) one ran the crossover fallback for
4360 // minutes, against a 114-iteration interior solve. With it, a
4361 // declined attempt costs at most a doubling — the right scale
4362 // for a bet on shortening the tail.
4363 let (wall_left, cpu_left) = data
4364 .borrow()
4365 .deadline
4366 .as_ref()
4367 .map_or((Number::INFINITY, Number::INFINITY), |d| {
4368 (d.remaining_wall(), d.remaining_cpu())
4369 });
4370 let spent_wall = (pounce_common::utils::wallclock_time() - wall0).max(1.0);
4371 let spent_cpu = (pounce_common::utils::cpu_time() - cpu0).max(1.0);
4372 let sqp_opts = crate::sqp::SqpOptions {
4373 deadline: Some(pounce_common::timing::Deadline::new(
4374 spent_wall.min(wall_left),
4375 spent_cpu.min(cpu_left),
4376 )),
4377 ..snapshot.sqp.clone()
4378 };
4379 // Declared bounds, for the reason `maybe_crossover` gives.
4380 let mut adapter =
4381 crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(&nlp));
4382 let (mut rep, mut accepted) = crate::crossover::run(
4383 &mut adapter,
4384 &seed,
4385 &x,
4386 &sqp_opts,
4387 &qp_opts,
4388 || (backend.borrow_mut())(crate::alg_builder::LinearSolverChoice::Feral),
4389 |step4_opts| {
4390 let mut b = snapshot.clone();
4391 b.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
4392 b.sqp = step4_opts;
4393 let shared = Rc::clone(&backend);
4394 b.build_sqp_with_backend(Box::new(move |c| (shared.borrow_mut())(c)))
4395 },
4396 );
4397 // The interior-point verdict this replaces is issued in user
4398 // units on the declared model; so is this one.
4399 if let Some(res) = &accepted
4400 && !meets_user_tolerances(&nlp, res, &rep, &tols)
4401 {
4402 rep = rep.decline(crate::crossover::CrossoverDecline::UserTolerances);
4403 accepted = None;
4404 }
4405 tracing::info!(
4406 target: "pounce::handover",
4407 "IPM→SQP handover attempt {} at iteration {iter} (error {err:.2e}, mu {mu:.2e}): {}",
4408 trigger.attempts(),
4409 if rep.accepted() { "accepted" } else { "declined" }
4410 );
4411 tracing::debug!(target: "pounce::handover", "handover: {rep:?}");
4412 let mut r = report.borrow_mut();
4413 r.attempts = trigger.attempts();
4414 r.last = Some(rep);
4415 let res = accepted?;
4416 let next = crossover_iterate(&nlp, curr, &res)?;
4417 r.accepted_at_iter = Some(iter);
4418 *working.borrow_mut() = res.working_set;
4419 Some(next)
4420 }));
4421 Some((report_out, working_out))
4422 }
4423
4424 /// After the solve: publish the handover report, and an accepted
4425 /// attempt's working set as the next SQP solve's warm start (the same
4426 /// hand-off crossover makes).
4427 fn finish_handover(
4428 &mut self,
4429 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4430 cells: Option<(
4431 Rc<RefCell<crate::handover::HandoverReport>>,
4432 Rc<RefCell<Option<pounce_qp::WorkingSet>>>,
4433 )>,
4434 ) {
4435 self.handover_report = None;
4436 let Some((report, working)) = cells else {
4437 return;
4438 };
4439 let report = report.borrow().clone();
4440 if report.accepted_at_iter.is_some()
4441 && let Some(ws) = working.borrow_mut().take()
4442 {
4443 self.sqp_last_working_set = Some(self.working_set_to_user(&*nlp_handle.borrow(), ws));
4444 }
4445 self.handover_report = Some(report);
4446 }
4447
4448 /// The interior-point arm's user-unit tolerances, which a crossed-over
4449 /// point must also meet (see [`meets_user_tolerances`]).
4450 fn user_tolerances(&self) -> UserTolerances {
4451 let read = |name: &str, default: Number| {
4452 self.options
4453 .get_numeric_value(name, "")
4454 .map_or(default, |(v, _)| v)
4455 };
4456 UserTolerances {
4457 constr_viol: read("constr_viol_tol", 1e-4),
4458 dual_inf: read("dual_inf_tol", 1.0),
4459 compl_inf: read("compl_inf_tol", 1e-4),
4460 }
4461 }
4462
4463 /// A [`pounce_common::timing::Deadline`] for `max_wall_time` /
4464 /// `max_cpu_time`, starting now.
4465 fn solve_deadline(&self) -> pounce_common::timing::Deadline {
4466 let read = |name: &str| {
4467 self.options
4468 .get_numeric_value(name, "")
4469 .map_or(1e6, |(v, _)| v)
4470 };
4471 pounce_common::timing::Deadline::new(read("max_wall_time"), read("max_cpu_time"))
4472 }
4473
4474 /// Read the `crossover*` option family into a [`CrossoverOptions`].
4475 fn crossover_options(&self) -> crate::crossover::CrossoverOptions {
4476 let mut o = crate::crossover::CrossoverOptions::default();
4477 if let Ok((v, true)) = self.options.get_bool_value("crossover", "") {
4478 o.enabled = v;
4479 }
4480 if let Ok((v, true)) = self.options.get_integer_value("crossover_max_iter", "") {
4481 o.max_iter = v.max(0) as u32;
4482 }
4483 if let Ok((v, true)) = self.options.get_numeric_value("crossover_mult_tol", "") {
4484 o.mult_tol = v;
4485 }
4486 if let Ok((v, true)) = self.options.get_numeric_value("crossover_primal_tol", "") {
4487 o.primal_tol = v;
4488 }
4489 o
4490 }
4491
4492 /// Post-convergence crossover (gh#612): hand the converged interior
4493 /// iterate to the active-set path so the solve ends on an exact active
4494 /// set. See [`crate::crossover`] for the algorithm.
4495 ///
4496 /// On acceptance this **replaces `data.curr`** rather than reporting
4497 /// alongside it. Everything downstream — the residual drain, the KKT
4498 /// fidelity gate, `on_converged`, `finalize_via_orig_nlp`, the end
4499 /// summary — already reads that one iterate, so replacing it is what
4500 /// makes the crossed-over point the solution instead of an annotation on
4501 /// it, and it does so without a second copy of the unscaling path.
4502 ///
4503 /// A no-op unless `crossover=yes` and the solve converged: an
4504 /// unconverged interior point is not a KKT point, so there is no active
4505 /// set at it worth identifying.
4506 fn maybe_crossover(
4507 &mut self,
4508 alg: &mut IpoptAlgorithm,
4509 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4510 solver_status: SolverReturn,
4511 ) {
4512 self.crossover_report = None;
4513 // Cleared on every entry, alongside the report, so the flag
4514 // describes this solve and not a previous one — the same reason
4515 // `crossover_report` is reset here rather than only written on
4516 // the accept path.
4517 //
4518 // Except when the in-solve handover installed the iterate (gh#1054):
4519 // then the flag was set during *this* solve and is still true of the
4520 // point in `curr` — clearing it here mislabelled the handover's
4521 // declared-frame point as an interior one, and its complementarity
4522 // was reported in the relaxed frame (`0.1` on `jit1`).
4523 let handover_point = self
4524 .handover_report
4525 .as_ref()
4526 .is_some_and(|h| h.accepted_at_iter.is_some());
4527 if !handover_point {
4528 alg.data.borrow_mut().curr_from_crossover = false;
4529 }
4530 let xopts = self.crossover_options();
4531 if !xopts.enabled {
4532 return;
4533 }
4534 if !matches!(
4535 solver_status,
4536 SolverReturn::Success | SolverReturn::StopAtAcceptablePoint
4537 ) {
4538 return;
4539 }
4540 let Some(curr) = alg.data.borrow().curr.clone() else {
4541 return;
4542 };
4543
4544 let seed = crossover_seed(nlp_handle, &curr);
4545
4546 let snapshot = self.algorithm_builder_snapshot();
4547 let mut sqp_opts = snapshot.sqp.clone();
4548 // The interior-point phase's own deadline, not a fresh one:
4549 // crossover is part of the same solve, so it spends what the IPM
4550 // left of `max_wall_time` / `max_cpu_time` (gh#1054). Before this it
4551 // had no budget at all and could outlive the solve's by any amount.
4552 sqp_opts.deadline = alg
4553 .data
4554 .borrow()
4555 .deadline
4556 .clone()
4557 .or_else(|| Some(self.solve_deadline()));
4558 let qp_opts = snapshot.sqp_qp.clone();
4559 // Declared bounds, not the live relaxed ones: crossover's whole claim
4560 // is that the returned point sits *on* the constraints the user
4561 // wrote. Against the `bound_relax_factor`-widened box it would pivot
4562 // to a point `1e-8` shy of every one of them and then correctly
4563 // report an empty active set.
4564 let mut adapter =
4565 crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(nlp_handle));
4566 let (report, accepted) = crate::crossover::run(
4567 &mut adapter,
4568 &seed,
4569 &xopts,
4570 &sqp_opts,
4571 &qp_opts,
4572 || {
4573 let mut f = self.make_backend_factory();
4574 // `make_backend_factory` ships the workspace-default
4575 // backend regardless of the choice passed, exactly as the
4576 // SQP path's `build_sqp_with_backend` call does; naming
4577 // `Feral` here keeps that visible rather than implicit.
4578 f(crate::alg_builder::LinearSolverChoice::Feral)
4579 },
4580 |step4_opts| {
4581 let mut b = self.algorithm_builder_snapshot();
4582 b.algorithm = crate::alg_builder::AlgorithmChoice::ActiveSetSqp;
4583 b.sqp = step4_opts;
4584 b.build_sqp_with_backend(self.make_backend_factory())
4585 },
4586 );
4587
4588 let tols = self.user_tolerances();
4589 let (report, accepted) = match accepted {
4590 Some(res) if !meets_user_tolerances(nlp_handle, &res, &report, &tols) => (
4591 report.decline(crate::crossover::CrossoverDecline::UserTolerances),
4592 None,
4593 ),
4594 other => (report, other),
4595 };
4596 if let Some(res) = accepted {
4597 self.install_crossover_iterate(alg, nlp_handle, &curr, &res);
4598 // Publish the identified set as the SQP warm-start output. This
4599 // is the IPM → SQP handoff the active-set path never had: a
4600 // sequence whose first solve wants the interior method can now
4601 // feed the next `algorithm=active-set-sqp` solve a working set.
4602 self.sqp_last_working_set = res
4603 .working_set
4604 .clone()
4605 .map(|ws| self.working_set_to_user(&*nlp_handle.borrow(), ws));
4606 }
4607 tracing::debug!(target: "pounce::crossover", "crossover: {report:?}");
4608 self.crossover_report = Some(report);
4609 }
4610
4611 /// Write an accepted crossover result back onto the IPM iterate (built by
4612 /// [`crossover_iterate`]).
4613 fn install_crossover_iterate(
4614 &self,
4615 alg: &mut IpoptAlgorithm,
4616 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
4617 curr: &crate::iterates_vector::IteratesVector,
4618 res: &crate::sqp::SqpResult,
4619 ) {
4620 let Some(out) = crossover_iterate(nlp_handle, curr, res) else {
4621 return;
4622 };
4623 let mut d = alg.data.borrow_mut();
4624 d.set_curr(out);
4625 // Mark which frame the installed iterate belongs to. It sits on the
4626 // *declared* bounds, and every barrier quantity built off `curr` —
4627 // slacks, and through them `Σ = z/s` — is measured against the
4628 // `bound_relax_factor`-widened ones, so a consumer that wants the
4629 // point's own geometry rather than the barrier's has to know this
4630 // happened (gh#654). Set only on the path that actually replaced
4631 // `curr`: a declined or abandoned crossover leaves the interior
4632 // iterate, which is an interior-frame point.
4633 d.curr_from_crossover = true;
4634 }
4635
4636 fn algorithm_builder_snapshot(&self) -> AlgorithmBuilder {
4637 let mut builder = AlgorithmBuilder {
4638 quality_escalation_counter: Some(Rc::clone(&self.quality_escalations)),
4639 ..AlgorithmBuilder::default()
4640 };
4641 apply_sqp_options(&self.options, &mut builder.sqp);
4642 apply_qp_subproblem_options(&self.options, &mut builder.sqp_qp);
4643 builder
4644 }
4645
4646 /// Refuse an explicitly set `ma57_pivtolmax` that sits below
4647 /// `ma57_pivtol`, at either option prefix.
4648 ///
4649 /// Upstream's `Ma57TSolverInterface::InitializeImpl` asserts
4650 /// `pivtolmax >= pivtol` and raises `OPTION_INVALID`, but only when
4651 /// the user set `ma57_pivtolmax` explicitly; left unset, the
4652 /// registered default is lifted to `ma57_pivtol` instead. Both
4653 /// halves are mirrored — the lifting in
4654 /// `pounce_hsl::ma57::Options::from_options_list`, the refusal here.
4655 ///
4656 /// Checked at **both** prefixes because the restoration sub-IPM
4657 /// configures its own MA57 backend from `"resto."`-scoped options
4658 /// (gh#825), so `resto.ma57_pivtolmax` can contradict
4659 /// `resto.ma57_pivtol` without the un-prefixed pair being wrong.
4660 ///
4661 /// Deliberately **not** gated on the `ma57` cargo feature or on
4662 /// `linear_solver` resolving to MA57. It is a consistency check on
4663 /// two numbers the user wrote, needs no HSL to perform, and a
4664 /// verdict that changed with a build flag would be worse than a
4665 /// consistent one — it would also be untestable in CI, which cannot
4666 /// link CoinHSL. Only an *explicitly set* `ma57_pivtolmax` can
4667 /// trigger it, so an options file that never mentions the option is
4668 /// unaffected.
4669 fn ma57_pivtol_bracket_refusal(&self) -> Option<String> {
4670 for prefix in ["", "resto."] {
4671 // `(_, true)` is the explicitly-set arm; an unset option
4672 // reports the registry default with `false` and is the
4673 // branch upstream lifts rather than refuses.
4674 let Ok((pivtolmax, true)) = self.options.get_numeric_value("ma57_pivtolmax", prefix)
4675 else {
4676 continue;
4677 };
4678 let pivtol = self
4679 .options
4680 .get_numeric_value("ma57_pivtol", prefix)
4681 .map(|(v, _)| v)
4682 .unwrap_or(1e-8);
4683 if pivtolmax < pivtol {
4684 return Some(format!(
4685 "pounce: {prefix}ma57_pivtolmax ({pivtolmax:e}) is below \
4686 {prefix}ma57_pivtol ({pivtol:e}). ma57_pivtolmax is the ceiling MA57 \
4687 may raise the pivot tolerance to when it escalates for accuracy, so it \
4688 cannot sit below the tolerance it starts from. Raise \
4689 {prefix}ma57_pivtolmax to at least {pivtol:e}, or lower \
4690 {prefix}ma57_pivtol."
4691 ));
4692 }
4693 }
4694 None
4695 }
4696
4697 /// Build a linear-solver factory from the application's current options,
4698 /// for the paths that do not go through `optimize_constrained`'s own
4699 /// factory plumbing — the active-set SQP driver and the post-convergence
4700 /// crossover.
4701 ///
4702 /// This used to ignore its `choice` argument and return
4703 /// `FeralSolverInterface::new()`: a default-configured FERAL, whatever the
4704 /// caller asked for. Three things were silently dropped on every SQP
4705 /// solve. `linear_solver=ma57` ran FERAL while the banner said
4706 /// `MA57 (HSL)`, so an MA57-vs-FERAL comparison on this path measured
4707 /// nothing — the same defect as gh#825, one path over. Every `feral_*`
4708 /// option (`feral_refine`, `feral_ordering`, `feral_scaling`,
4709 /// `feral_increase_quality`, …) was accepted and discarded. And
4710 /// `set_external_ordering` — whose whole purpose is to inject an ordering
4711 /// a generic algorithm cannot derive — reached the IPM and not this arm.
4712 ///
4713 /// Reads the same two snapshots and calls the same constructor the IPM
4714 /// path does, so there is one answer to "what does `linear_solver` mean"
4715 /// rather than one per algorithm.
4716 fn make_backend_factory(&self) -> LinearBackendFactory {
4717 // `RefineCarveOut::None`: this arm does not refine the unreduced system
4718 // itself, so the backend loop is the only refinement in the stack and
4719 // the interior-point carve-out's premise is false here. See
4720 // `feral_config_from_options_scoped` for the measurement.
4721 let mut feral_cfg = feral_config_from_options_scoped(&self.options, RefineCarveOut::None);
4722 // Same override, and for the same reason, as the IPM path: the
4723 // external permutation carries a vector, which no string option can
4724 // express, so it arrives through the side-channel field instead.
4725 if let Some(perm) = &self.external_ordering {
4726 feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
4727 }
4728 let ma57_cfg = ma57_config_from_options(&self.options, "");
4729 default_backend_factory_with_sink(
4730 feral_cfg,
4731 ma57_cfg,
4732 Arc::clone(&self.linsol_summary_sink),
4733 )
4734 }
4735
4736 /// Phase 3.5 auto-fallback driver.
4737 ///
4738 /// Runs the standard solve (no wrapper) first. If it ends in a
4739 /// trigger-class status (`Restoration_Failed`, `Infeasible_Problem_Detected`,
4740 /// `Solved_To_Acceptable_Level`, `Maximum_Iterations_Exceeded`, or
4741 /// `Not_Enough_Degrees_Of_Freedom`), retries transparently with
4742 /// the ℓ₁ wrapper enabled. Promotes the retry's status only if
4743 /// it returns `Solve_Succeeded`; otherwise returns the original
4744 /// status.
4745 ///
4746 /// Caveat: the user TNLP's `finalize_solution` runs once per
4747 /// attempt. When the retry doesn't promote, the user's captured
4748 /// fields hold the retry's iterate (the ℓ₁-best least-infeasible
4749 /// point) even though the returned status is the original's.
4750 /// Documented on the option's help text; tightening this is a
4751 /// Phase-4 follow-up.
4752 fn run_with_l1_fallback(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
4753 // First attempt: the standard IPM solve, no ℓ₁ wrapper. Only
4754 // reached for `m > 0`, so `optimize_constrained` is exact.
4755 let first_status = self.optimize_constrained(Rc::clone(&tnlp));
4756 if !is_l1_fallback_trigger(first_status) {
4757 return first_status;
4758 }
4759 // gh#987 item 6: the plain attempt's iterations are part of what this
4760 // call spent, and the retry's `optimize_constrained` resets them.
4761 let first_attempt = self.statistics.borrow().clone();
4762 // Trigger fired. Flip the wrapper option for the retry and
4763 // restore it after — keeps the user's option-table view of the
4764 // session exactly as they left it.
4765 let prev = self
4766 .options
4767 .get_string_value("l1_exact_penalty_barrier", "")
4768 .ok();
4769 let _ = self
4770 .options
4771 .set_string_value("l1_exact_penalty_barrier", "yes", true, false);
4772 let retry_status = self
4773 .run_l1_penalty_outer_loop(Rc::clone(&tnlp))
4774 .unwrap_or(ApplicationReturnStatus::InternalError);
4775 let _ = self.options.set_string_value(
4776 "l1_exact_penalty_barrier",
4777 prev.as_ref().map(|(v, _)| v.as_str()).unwrap_or("no"),
4778 true,
4779 false,
4780 );
4781 // Fold the plain attempt in ahead of the retry's passes. A retry that
4782 // never ran a pass (wrapper construction declined) leaves the plain
4783 // attempt's own statistics standing untouched.
4784 {
4785 let mut st = self.statistics.borrow_mut();
4786 if !st.passes.is_empty() {
4787 let mut head = MultiPassTotals::default();
4788 head.push_pass(&first_attempt, None, first_status);
4789 let shift = head.rows.len();
4790 let mut passes = head.passes;
4791 passes.extend(st.passes.iter().cloned().map(|mut p| {
4792 p.first_row += shift;
4793 p
4794 }));
4795 let mut rows = head.rows;
4796 rows.append(&mut st.iterations);
4797 st.iterations = rows;
4798 st.iteration_count += first_attempt.iteration_count;
4799 st.num_obj_evals += first_attempt.num_obj_evals;
4800 st.num_constr_evals += first_attempt.num_constr_evals;
4801 st.num_obj_grad_evals += first_attempt.num_obj_grad_evals;
4802 st.num_constr_jac_evals += first_attempt.num_constr_jac_evals;
4803 st.num_hess_evals += first_attempt.num_hess_evals;
4804 st.restoration_calls += first_attempt.restoration_calls;
4805 st.restoration_inner_iters += first_attempt.restoration_inner_iters;
4806 st.restoration_outer_iters += first_attempt.restoration_outer_iters;
4807 st.passes = passes;
4808 }
4809 }
4810 // A retry the caller's intermediate callback stopped reports the
4811 // stop: returning the first attempt's status would override it.
4812 if matches!(
4813 retry_status,
4814 ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::UserRequestedStop
4815 ) {
4816 retry_status
4817 } else {
4818 first_status
4819 }
4820 }
4821
4822 /// μ-strategy auto-fallback driver (pounce#138).
4823 ///
4824 /// Runs the standard solve first. If it stalls short of optimal in a
4825 /// way a μ-strategy flip can plausibly fix — `Solved_To_Acceptable_Level`
4826 /// or `Maximum_Iterations_Exceeded`, the two signatures seen on the
4827 /// princetonlib instances where the dual infeasibility parks above
4828 /// `tol` while constraint violation and complementarity are already
4829 /// deeply converged — it flips `mu_strategy` (adaptive↔monotone) and
4830 /// solves once more. The retry's status is promoted only if it returns
4831 /// `Solve_Succeeded`; otherwise the original status is returned.
4832 ///
4833 /// (maxcut/price stall at acceptable-level under adaptive; fermat2_vareps
4834 /// stalls at `max_iter` — hence both triggers. flosp2tm is μ-independent
4835 /// and correctly does not promote.)
4836 ///
4837 /// The flip direction is taken from the strategy the option table
4838 /// actually resolves to (`effective_mu_strategy_is_adaptive`):
4839 /// `adaptive` → `monotone`, otherwise → `adaptive`. Absence is not
4840 /// the same as `monotone` — under a limited-memory Hessian an unset
4841 /// `mu_strategy` resolves to `adaptive` (gh#746), and flipping the
4842 /// *registered* default there would re-run the strategy that just
4843 /// stalled. The option table is restored to the resolved view
4844 /// afterward.
4845 ///
4846 /// Caveat (shared with the ℓ₁ fallback): the user TNLP's
4847 /// `finalize_solution` runs once per attempt, so when the retry
4848 /// doesn't promote the captured fields hold the retry's iterate.
4849 fn run_with_mu_strategy_fallback(
4850 &mut self,
4851 tnlp: Rc<RefCell<dyn TNLP>>,
4852 ) -> ApplicationReturnStatus {
4853 let first_status = self.optimize_constrained(Rc::clone(&tnlp));
4854 // Which statuses are worth a second solve depends on who asked
4855 // for the retry (pounce#748).
4856 //
4857 // An explicit `mu_strategy_fallback=yes` keeps the historical
4858 // pair: the caller opted in and can afford the second solve.
4859 //
4860 // The *default*-on retry takes `Maximum_Iterations_Exceeded`
4861 // unconditionally, and `Solved_To_Acceptable_Level` only when
4862 // the caller left the convergence configuration alone
4863 // (gh #757). pounce#748 refused the latter status outright, for
4864 // three reasons; two of them are properties of a *caller-
4865 // modified* configuration, not of the status. It launders
4866 // downgrades the caller induced deliberately -- a tight
4867 // `kkt_fidelity_tol`, a certificate veto, `least_square_init_
4868 // primal` -- so the signal the option exists to produce never
4869 // reaches them. And because the retry returns the other run's
4870 // *point*, not just its status, it can hand back a different
4871 // local solution: on `autocorr_bern55-06` with the
4872 // dual-divergence guard on it swaps -2304.0000278 for
4873 // -2320.0000298 (crates/pounce-cli/tests/
4874 // issue_250_dual_guard_never_worse.rs). Both cases -- and all
4875 // five test targets the wide trigger broke -- arm a
4876 // non-default option from `TERMINATION_POLICY_OPTIONS`, so
4877 // deferring to that set preserves every one of them while
4878 // leaving a stock-options stall retryable. The third reason,
4879 // cost, stands and is the price: one extra solve on a run that
4880 // reached only the acceptable tolerance, paid to try for the
4881 // certificate. `cho_parmest` is the motivating case -- monotone
4882 // parks `inf_du` on a ~1e-6 evaluation-noise floor and misses
4883 // `tol` by 5%, taking six null steps of 1e-12 at `mu_min`,
4884 // while adaptive certifies it in 20 iterations.
4885 //
4886 // `dirichlet120`, the case that motivated turning the retry on,
4887 // stalls at `Maximum_Iterations_Exceeded`, so it is recovered
4888 // either way.
4889 let retry_worthy = match first_status {
4890 ApplicationReturnStatus::MaximumIterationsExceeded => true,
4891 // A tiny-step stop the `tiny_step_dual_stall_iters` rule released
4892 // is an iteration limit reached early: the solve had stopped
4893 // moving with its multipliers running away, and before the rule
4894 // it took the remaining budget to say so and then came here as
4895 // `Maximum_Iterations_Exceeded`. Retrying it keeps that rescue.
4896 // A tiny-step stop upstream's own rule reached (multiplier step
4897 // settled) is not retried, as before.
4898 ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
4899 self.tiny_step_dual_stall.get()
4900 }
4901 ApplicationReturnStatus::SolvedToAcceptableLevel => {
4902 self.mu_strategy_fallback_was_set() || !self.caller_set_termination_policy()
4903 }
4904 _ => false,
4905 };
4906 if !retry_worthy {
4907 return first_status;
4908 }
4909 // gh#857: decline the flip when the solve that just failed escalated
4910 // the factorization and an escalation-off re-solve is enabled.
4911 //
4912 // The mu flip is a *blind* second opinion: it changes the barrier
4913 // schedule and hopes. A `feral_increase_quality` escalation is a
4914 // *measured* fact about the run that just failed -- FERAL reroutes
4915 // which pivots are taken and never steps back down, so every
4916 // iteration after the first escalation, restoration sub-solves
4917 // included, ran on a trajectory the defaults do not describe. Flipping
4918 // `mu_strategy` while leaving that in place is not a controlled
4919 // experiment: it varies the knob that is not implicated and holds the
4920 // one that is.
4921 //
4922 // It is not free either. On `square_flowsheet_resto`'s lbfgs leg the
4923 // flip escalates 25 times all over again, burns a second full
4924 // 3000-iteration budget, and ends no better than the first -- after
4925 // which rung 4 of the second-opinion ladder converges the model in 178
4926 // with the escalation off. That is 6178 real iterations to reach an
4927 // answer that 3178 reach without the flip, and the flip contributes
4928 // nothing to it. Measured both ways: `mu_strategy=adaptive` alone
4929 // still gives 3000 with 25 escalations, and `feral_increase_quality=no`
4930 // gives 178 under *either* mu strategy.
4931 //
4932 // Why decline rather than fold the escalation off into this retry: the
4933 // backend factory is minted from an options snapshot taken by the
4934 // caller *before* `solve()`, so writing `feral_increase_quality` here
4935 // is too late to reach the retry's linear solver. `mu_strategy` is read
4936 // per-solve from the option table and is not; that asymmetry is why
4937 // this layer can only choose whether to spend the solve, not what to
4938 // spend it on.
4939 //
4940 // Restricted to `Maximum_Iterations_Exceeded`, which is exactly the
4941 // status rung 4 opens on. A `Solved_To_Acceptable_Level` exit opens no
4942 // escalation rung, so declining there would drop a retry with nothing
4943 // in its place. Gated on `feral_increase_quality_retry`, so setting
4944 // that option to `no` restores the historical behaviour on both sides
4945 // at once: no rung 4, and no decline here.
4946 //
4947 // The one place the stand-down is not paired with the rung is the
4948 // multi-start paths (`solve_nlp_batch`, the CLI's `minima` search),
4949 // which deliberately do not drive the ladder -- a failed start there is
4950 // routine and extra solves per start multiply. Those paths lose the
4951 // flip on an escalating budget exit and gain nothing back, which is the
4952 // one behaviour change here that is not a strict improvement. It is the
4953 // same trade they already take on every other rung, and for the same
4954 // reason: an escalating capped start is one of many, and doubling its
4955 // cost to re-run the trajectory the escalation governs is the worse end
4956 // of it.
4957 if matches!(
4958 first_status,
4959 ApplicationReturnStatus::MaximumIterationsExceeded
4960 ) && self.quality_escalations.get() >= 1
4961 && self
4962 .options
4963 .get_bool_value("feral_increase_quality_retry", "")
4964 .map(|(v, _found)| v)
4965 .unwrap_or(true)
4966 && self
4967 .options
4968 .get_bool_value("feral_increase_quality", "")
4969 .map(|(v, _found)| v)
4970 .unwrap_or(true)
4971 {
4972 return first_status;
4973 }
4974 // Flip the strategy for one retry. The parser maps "adaptive" →
4975 // Adaptive and every other value (incl. unset) → Monotone, so the
4976 // opposite of an explicit "adaptive" is "monotone" and the
4977 // opposite of anything else is "adaptive".
4978 let saved_mu_strategy = self.options.snapshot_entry("mu_strategy");
4979 let was_adaptive = self.effective_mu_strategy_is_adaptive();
4980 let flipped = if was_adaptive { "monotone" } else { "adaptive" };
4981 // Unwind cover only; the normal path restores the same snapshot
4982 // below. A panic in the retry used to leave the flipped strategy in
4983 // the option table of an application `ffi_guard` keeps.
4984 let mut scope = RetryOptionScope::enter(self, &["mu_strategy"], true);
4985 let _ = scope
4986 .options
4987 .set_string_value("mu_strategy", flipped, true, false);
4988 // Floor the *answer*, not just the status (pounce#870).
4989 //
4990 // The promote-only-on-`Solve_Succeeded` rule below has always floored
4991 // the status. It did not floor the point, and the point is what the
4992 // caller consumes: `optimize_constrained` calls the user TNLP's
4993 // `finalize_solution` once per attempt, so a retry that fails to
4994 // promote still overwrites the answer with its own iterate, and the
4995 // statistics with its own residuals. The result is a status describing
4996 // one attempt attached to a point from another.
4997 //
4998 // Measured on a random corpus of 1200 nonconvex models, 20 of them
4999 // (1.7%) returned a materially worse point under an unchanged status,
5000 // the worst flipping sign: a `Maximum_Iterations_Exceeded` exit went
5001 // from -2.38e7 to +7.89e7, and a `Solved_To_Acceptable_Level` one from
5002 // -3.83e7 to +3.41e5 while its reported `final_kkt_error` rose to
5003 // 2.85e-4 — 285x the `acceptable_tol` its own status names, so the
5004 // report contradicted itself. The known example on record understated
5005 // it by three orders: `autocorr_bern55-06` swapping -2304.0000278 for
5006 // -2320.0000298 is the same defect at 0.07%.
5007 //
5008 // This is the floor idiom the rest of the codebase already uses for a
5009 // bet it might lose — `honour_neg_curv_floor` (gh#797),
5010 // `honour_decline_floor` (gh#534), `honour_best_acceptable_after_dual_
5011 // guard` — applied to the one bet that was only half-floored.
5012 //
5013 // Not extended to `run_with_l1_fallback`, which carries the same
5014 // caveat in its doc comment but is NOT the same call: its retry
5015 // deliberately reports the l1-best least-infeasible point, which the
5016 // option help calls informative in its own right. Changing that needs
5017 // its own measurement.
5018 let solution_floor = scope.last_finalize.borrow().clone();
5019 let certificate_floor = SolutionCertificate::of(&scope.statistics.borrow());
5020 let trace_floor = *scope.last_iter_stats.borrow();
5021 // gh#1042 C1: the first attempt's verdict and cost, recorded before
5022 // the re-solve overwrites the statistics. A promoted re-solve reports
5023 // its own `iteration_count` and `restoration_calls` only, and without
5024 // this nothing says the first attempt got worse.
5025 let mut record = {
5026 let st = scope.statistics.borrow();
5027 pounce_nlp::MuStrategyFallbackRecord {
5028 base_status: first_status.upstream_name().to_string(),
5029 base_iteration_count: st.iteration_count,
5030 base_restoration_calls: st.restoration_calls,
5031 base_objective: st.final_objective,
5032 retry_mu_strategy: flipped.to_string(),
5033 ..Default::default()
5034 }
5035 };
5036 *scope.mu_strategy_fallback_record.borrow_mut() = Some(record.clone());
5037 let retry_status = scope.optimize_constrained(Rc::clone(&tnlp));
5038 record.retry_status = retry_status.upstream_name().to_string();
5039 record.retry_iteration_count = scope.statistics.borrow().iteration_count;
5040 record.promoted = matches!(retry_status, ApplicationReturnStatus::SolveSucceeded);
5041 *scope.mu_strategy_fallback_record.borrow_mut() = Some(record.clone());
5042 // Put the entry back exactly as it was, absence as absence. Writing
5043 // the resolved strategy back instead marked it caller-set, and a
5044 // caller-set `mu_strategy` switches this fallback off
5045 // (`is_mu_strategy_fallback_enabled`): every later solve on the same
5046 // application lost it, which the declared-complementarity driver --
5047 // one application, every stage -- hit whenever an early stage fell
5048 // back. An unset entry still resolves the same way, so the view the
5049 // write-back was preserving is unchanged.
5050 scope
5051 .options
5052 .restore_entry("mu_strategy", saved_mu_strategy);
5053 drop(scope);
5054 self.statistics.borrow_mut().mu_strategy_fallback = Some(record);
5055 // A retry the caller's intermediate callback stopped reports the
5056 // stop, at the point it stopped: the first attempt's status would
5057 // override the request.
5058 if matches!(
5059 retry_status,
5060 ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::UserRequestedStop
5061 ) {
5062 return retry_status;
5063 }
5064 // The bet lost. Put the first attempt's answer back, so the point and
5065 // the statistics describe the same solve the returned status does.
5066 //
5067 // `finalize_solution` therefore runs once more than the number of
5068 // attempts on this path. That is deliberate, and is the cheaper of the
5069 // two corrections: withholding the retry's `finalize_solution` until it
5070 // is known to promote would deprive a caller that watches the callback
5071 // of the retry's progress, and buys nothing, since the retry's payload
5072 // is discarded either way.
5073 if let Some(floor) = solution_floor {
5074 tracing::debug!(target: "pounce::algorithm",
5075 "[POUNCE] the mu_strategy_fallback retry did not promote \
5076 ({:?} is not Solve_Succeeded); restoring the first attempt's \
5077 solution and statistics alongside its status (pounce#870).",
5078 retry_status);
5079 self.restore_floor(&floor, &tnlp);
5080 certificate_floor.restore_into(&mut self.statistics.borrow_mut());
5081 // Third sink: the per-iteration trace. Consumers accumulate that
5082 // themselves from `intermediate_callback` — the CasADi plugin
5083 // pushes into its own vectors and clears once per `nlpsol` call —
5084 // so POUNCE cannot rewind it, and both attempts concatenate into
5085 // one trace. Restoring the certificate without touching the trace
5086 // leaves the reported numbers describing attempt 1 while the trace
5087 // ends on the retry, which breaks the invariant
5088 // `casadi/test_parity.py` states outright: "The final numbers and
5089 // the end of the trace are the same quantities, and must not come
5090 // from two different places."
5091 //
5092 // Re-emitting the winning attempt's final row restores it. It is
5093 // the trace analogue of `FinalizeSnapshot::replay` above, and it
5094 // makes the property hold by construction rather than by hoping a
5095 // consumer resets on an attempt boundary — nothing in the callback
5096 // contract marks one, and gh#634 is what happens when a consumer
5097 // has to guess the scope of a trace.
5098 //
5099 // The row is a real iterate that was already sent once, not a
5100 // synthesized one, so a trace still contains only points the solver
5101 // actually visited.
5102 // The replayed row is the reported answer's last iterate, so a
5103 // `false` here is a stop request on it, honored as a solve's
5104 // final-iterate stop is.
5105 if let Some(stats) = trace_floor {
5106 if !tnlp.borrow_mut().intermediate_callback(
5107 stats,
5108 &TnlpIpoptData::default(),
5109 &TnlpIpoptCq::default(),
5110 ) {
5111 return ApplicationReturnStatus::UserRequestedStop;
5112 }
5113 }
5114 }
5115 first_status
5116 }
5117
5118 /// Is the gh#884 biactive dual-divergence retry enabled? Default
5119 /// `yes`; `dual_divergence_retry=no` is the kill switch, and
5120 /// restores the pre-gh#884 behaviour outright (no detector cost, no
5121 /// second solve, and the base attempt's verdict returned unchanged).
5122 fn is_dual_divergence_retry_enabled(&self) -> bool {
5123 self.options
5124 .get_bool_value("dual_divergence_retry", "")
5125 .map(|(v, _found)| v)
5126 .unwrap_or(true)
5127 }
5128
5129 /// gh#884: throw the iterate away and solve again from scratch with
5130 /// `perturb_always_cd` on, when the base solve settled its primal
5131 /// while its multipliers ran away.
5132 ///
5133 /// # The defect
5134 ///
5135 /// On an MPCC lowered through an exact complementarity product
5136 /// `G·H = 0`, a pair that is **biactive** at the solution — both
5137 /// `G` and `H` zero — leaves that row's gradient
5138 /// `H∇G + G∇H` identically zero. The row is still there, so its
5139 /// multiplier is *arbitrary* rather than nonexistent, and the IPM
5140 /// drives it to infinity while the primal iterate sits on the
5141 /// answer. Because the convergence verdict is reached on an
5142 /// `s_d`-normalised aggregate and `s_d` grows with the mean
5143 /// multiplier magnitude, the aggregate reads clean: MacMPEC's
5144 /// `qpec_small` under `ncp_eq`/`prod_eq` reported
5145 /// `Solved_To_Acceptable_Level` at an *unscaled* dual infeasibility
5146 /// of `7.9e+04`.
5147 ///
5148 /// # Why a retry rather than a gate
5149 ///
5150 /// Four other shapes of fix were measured and rejected — a Hessian
5151 /// sparsity hypothesis, engaging `delta_c` *in flight*, flipping
5152 /// `perturb_always_cd` on globally, and putting a dual ceiling on
5153 /// the acceptable-level gate. `dev-notes/mpcc-biactive-dual-
5154 /// divergence.md` records all four with numbers. The short version:
5155 /// by the time the runaway is visible this iterate is unrecoverable,
5156 /// so the only action left is to *stop using it*; and the remedy
5157 /// that works — `perturb_always_cd=yes` — is measured to return a
5158 /// wrong answer reported as success on `ralph1`
5159 /// (`Solve_Succeeded` at `f = -2.71e-5`, below `f* = 0`), so it
5160 /// cannot be turned on for everyone.
5161 ///
5162 /// **The detector is therefore the entire safety barrier**, and the
5163 /// promotion gate below is the second one. `ralph1` is exactly the
5164 /// model the detector must not fire on, and
5165 /// `IpoptAlgorithm`'s scale-relative step floor is what keeps it
5166 /// from doing so: `qpec_small` settles to `4.3e-8` while `ralph1`
5167 /// bottoms out at `7.2e-3`, five orders apart.
5168 ///
5169 /// # The gate
5170 ///
5171 /// The retry's verdict replaces the base one only when **all** of:
5172 ///
5173 /// 1. the base attempt saw the signature (a converged primal, a step
5174 /// at zero, and an unscaled `‖∇L‖∞` far above `dual_inf_tol`, all
5175 /// at one iterate — see `IpoptAlgorithm::dual_divergence_signature`);
5176 /// 2. the base status is `Solved_To_Acceptable_Level` or
5177 /// `Restoration_Failed` — the two verdicts the vanishing-gradient
5178 /// row produces directly. Generic exhaustion exits are excluded;
5179 /// `deb7` under L-BFGS is why, and the reason is in the code
5180 /// below;
5181 /// 3. the retry returns `Solve_Succeeded`;
5182 /// 4. the retry's claimed success is **real in the model's own
5183 /// units** — unscaled KKT error at or below `tol`-scale, which is
5184 /// the property the base attempt failed and the whole point of
5185 /// the issue;
5186 /// 5. the retry's unscaled KKT error is *strictly better* than the
5187 /// base attempt's.
5188 ///
5189 /// Otherwise the base attempt's status, point and statistics are all
5190 /// put back, by the same three-sink floor
5191 /// [`Self::run_with_mu_strategy_fallback`] uses and for the same
5192 /// reason (pounce#870): a status describing one attempt attached to
5193 /// a point from another is worse than either.
5194 fn run_with_dual_divergence_retry(
5195 &mut self,
5196 tnlp: Rc<RefCell<dyn TNLP>>,
5197 ) -> ApplicationReturnStatus {
5198 let first_status = self.dispatch_standard_solve(Rc::clone(&tnlp));
5199 if !self.dual_divergence_signature.get() {
5200 return first_status;
5201 }
5202 // Which base verdicts this remedy is *for*.
5203 //
5204 // Two, and the narrowing was bought with a measurement. The
5205 // detector is a statement about the iterate; it is not a
5206 // statement that `perturb_always_cd` will help. `deb7` under
5207 // L-BFGS is the corpus case that separates the two: the
5208 // signature is real there — iteration 346, scale-relative step
5209 // `6.5e-6`, `inf_pr` `3.0e-12`, unscaled `inf_du` `9.2e5`,
5210 // which is an order *above* the gh#884 reproducer's `7.9e4`, so
5211 // no dual floor excludes it, and the step conjunct separates the
5212 // two only by fitting the default onto one fixture and spending
5213 // the margin that holds `ralph1` out — and the retry still does
5214 // not work: 3000 iterations to
5215 // `Maximum_Iterations_Exceeded` at an unscaled KKT error of
5216 // `6.7e1` against the base attempt's `9.9e1`. Pure cost, 4x the
5217 // base trajectory, on a fixture whose verdict does not move.
5218 //
5219 // The exclusion is by *status*, so it is only as complete as the
5220 // status is stable, and on this very fixture it is not. `deb7`
5221 // reaches `Error_In_Step_Computation` at default options and is
5222 // out; under `limited_memory_ls_failure_restarts=1` (gh#818's
5223 // rung, off by default) it reaches `Restoration_Failed` instead
5224 // and is therefore *in*. It used to pay exactly the cost above
5225 // there — 6.1 s to 25.2 s wall clock for the same
5226 // `Restoration_Failed` verdict and a declined retry, which is
5227 // gh#887. That is the price of scoping by status rather than by
5228 // model, and it is why the second gate below scopes by the
5229 // *answer* instead: `deb7` now declines before spending
5230 // anything. The status scope is kept because it is cheap and
5231 // reads on the verdict a caller sees, but it is not what is
5232 // being relied on.
5233 //
5234 // What separates them is the *status*, and it separates them
5235 // for a reason rather than by luck:
5236 //
5237 // * `Solved_To_Acceptable_Level` is gh#884 verbatim — the
5238 // runaway laundered itself through `s_d` into a success.
5239 // * `Restoration_Failed` is the same defect one step earlier:
5240 // the rows whose gradients vanished drive the solve into
5241 // restoration, and restoration cannot repair a row that has
5242 // no gradient. It is where the `qpec_small` TNLP fixture
5243 // lands (unscaled KKT `3.3e11`).
5244 // * `Error_In_Step_Computation` and
5245 // `Maximum_Iterations_Exceeded` are generic exhaustion
5246 // exits. Every hard model reaches them, for every reason.
5247 // Retrying *those* is not "repair the runaway", it is "try
5248 // again harder" — which is what `mu_strategy_fallback` and
5249 // the second-opinion ladder already are, and `deb7` is what
5250 // that costs.
5251 //
5252 // `Solve_Succeeded` is excluded because it is already the best
5253 // verdict available and its certificate has already been
5254 // checked in the model's own units; there is nothing to buy.
5255 //
5256 // Worst-case cost is therefore one extra solve under the
5257 // caller's own `max_iter` — the same contract
5258 // `run_with_mu_strategy_fallback` already has.
5259 //
5260 // Deliberately **not** deferred to `TERMINATION_POLICY_OPTIONS`
5261 // the way the μ flip's acceptable-level trigger is (gh#757).
5262 // That deferral exists because the μ flip returns a different
5263 // *local solution*, so laundering a caller's deliberate
5264 // downgrade loses a signal they asked for. This retry cannot:
5265 // conjunct 4 requires the promoted answer to satisfy the KKT
5266 // conditions in the model's own units, which a downgrade the
5267 // caller induced on purpose does not. And the gh#884 reproducer
5268 // sets `tol=1e-8` explicitly, so deferring would decline the
5269 // retry on the one case the issue is about.
5270 let retry_worthy = matches!(
5271 first_status,
5272 ApplicationReturnStatus::SolvedToAcceptableLevel
5273 | ApplicationReturnStatus::RestorationFailed
5274 );
5275 if !retry_worthy {
5276 return first_status;
5277 }
5278 let base_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
5279 // The runaway has to be the *whole* residual of the answer being
5280 // reported.
5281 //
5282 // The detector is a statement about an *iterate*, and the iterate
5283 // it fires on need not be the one the solve ends at. A run can
5284 // pass through a settled point with a diverged multiplier, work
5285 // its way back down, and report something ordinary — and then
5286 // there is nothing left here for `perturb_always_cd` to repair,
5287 // whatever the trajectory did in the middle. This is what makes
5288 // "one extra solve" a cost the caller only pays on a run that
5289 // still *exhibits* the defect (gh#887).
5290 //
5291 // The test is `runaway_is_the_whole_residual`, which reads only
5292 // the reported answer and only as a ratio within it; its doc
5293 // comment carries the rule and the measured populations.
5294 let (base_viol, base_compl) = {
5295 let st = self.statistics.borrow();
5296 (st.final_unscaled_constr_viol, st.final_unscaled_compl)
5297 };
5298 let base_dual_inf = self.statistics.borrow().final_unscaled_dual_inf;
5299 if !runaway_is_the_whole_residual(
5300 base_dual_inf,
5301 base_viol,
5302 base_compl,
5303 self.options
5304 .get_numeric_value("dual_divergence_retry_du_floor", "")
5305 .map(|(v, _)| v)
5306 .unwrap_or(DUAL_DIV_RETRY_DU_FLOOR),
5307 ) {
5308 tracing::debug!(target: "pounce::algorithm",
5309 "[POUNCE] gh#884: the signature fired mid-trajectory, but the \
5310 answer being reported is not a converged point with a runaway \
5311 multiplier — unscaled dual {:.3e} against viol {:.3e} and \
5312 complementarity {:.3e}. Nothing here for perturb_always_cd to \
5313 repair, so no retry (gh#887).",
5314 base_dual_inf, base_viol, base_compl);
5315 return first_status;
5316 }
5317 // Floor all three sinks — solution payload, certificate, and the
5318 // last trace row — exactly as the μ fallback does (pounce#870).
5319 let solution_floor = self.last_finalize.borrow().clone();
5320 let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
5321 let trace_floor = *self.last_iter_stats.borrow();
5322 tracing::debug!(target: "pounce::algorithm",
5323 "[POUNCE] gh#884: the primal settled while the multipliers ran away \
5324 (base {:?}, unscaled KKT {:.3e}); re-solving from scratch with \
5325 perturb_always_cd=yes.",
5326 first_status, base_unscaled_kkt);
5327 let saved_cd = self.options.snapshot_entry("perturb_always_cd");
5328 // Unwind cover only, as for the μ-strategy fallback; the normal path
5329 // restores the same snapshot below.
5330 let mut scope = RetryOptionScope::enter(self, &["perturb_always_cd"], true);
5331 let _ = scope
5332 .options
5333 .set_string_value("perturb_always_cd", "yes", true, false);
5334 let mark = scope.begin_counted_resolve();
5335 let retry_status = scope.dispatch_standard_solve(Rc::clone(&tnlp));
5336 scope.end_counted_resolve(mark);
5337 // Exactly as it was, absence as absence, so a later solve on the
5338 // same application does not read a caller-set `no`.
5339 scope.options.restore_entry("perturb_always_cd", saved_cd);
5340 drop(scope);
5341 // A retry the caller's intermediate callback stopped reports the
5342 // stop, at the point it stopped; the comparison below would put the
5343 // first attempt's status back over the request.
5344 if retry_status == ApplicationReturnStatus::UserRequestedStop {
5345 return retry_status;
5346 }
5347 let retry_unscaled_kkt = self.statistics.borrow().final_unscaled_kkt_error;
5348 let retry_viol = self.statistics.borrow().final_unscaled_constr_viol;
5349 // Conjuncts 3, 4 and 5. Conjunct 4 is the one that distinguishes
5350 // this gate from every other promote-on-`Solve_Succeeded` retry
5351 // in this file: the base attempt's defect *was* a status that its
5352 // own unscaled residual contradicts, so promoting on the status
5353 // alone would reproduce the bug one attempt later.
5354 let accept_tol = self.dual_divergence_retry_accept_tol();
5355 let claimed_success_is_real = (retry_unscaled_kkt <= accept_tol
5356 && retry_viol <= accept_tol)
5357 || retry_certificate_dominates(
5358 base_unscaled_kkt,
5359 retry_unscaled_kkt,
5360 retry_viol,
5361 accept_tol,
5362 );
5363 // Conjuncts 6 and 7 — see `retry_answer_is_admissible`. Everything
5364 // above this line ranks the two attempts on their *certificates*;
5365 // this ranks them as *answers*, which is what a caller receives.
5366 // Without it a better multiplier is allowed to buy a worse point:
5367 // measured, `-13.0057 -> -1.2072` on a random QPEC, and
5368 // `+1.82e-09 -> -6.61e-05` on `scholtes4`, whose `f*` is exactly 0.
5369 let retry_obj = self.statistics.borrow().final_objective;
5370 // `obj_scaling_factor < 0` is how a maximization is posed, and
5371 // `final_objective` is the user's signed objective, so the
5372 // comparison direction has to follow it (R2).
5373 let sense = if self
5374 .options
5375 .get_numeric_value("obj_scaling_factor", "")
5376 .map(|(v, _)| v)
5377 .unwrap_or(1.0)
5378 < 0.0
5379 {
5380 -1.0
5381 } else {
5382 1.0
5383 };
5384 let answer_is_admissible = retry_answer_is_admissible(
5385 certificate_floor.objective,
5386 certificate_floor.unscaled_constr_viol,
5387 retry_obj,
5388 retry_viol,
5389 self.dual_divergence_retry_accept_tol(),
5390 sense,
5391 );
5392 let promote = matches!(retry_status, ApplicationReturnStatus::SolveSucceeded)
5393 && claimed_success_is_real
5394 && retry_unscaled_kkt < base_unscaled_kkt
5395 && answer_is_admissible;
5396 // Say on the console which of the two answers shipped.
5397 //
5398 // The per-attempt end summary cannot: `emit_end_summary` runs
5399 // inside `optimize_constrained`, once per attempt, and the
5400 // promotion is decided after the last of them. So a summary that
5401 // reported the promotion would report `false` on the very run that
5402 // promotes, contradicting the JSON report written from the same
5403 // statistics. The summary reports the *signature*, which is true
5404 // when it prints; this line reports the *outcome*, printed once,
5405 // where it is known.
5406 //
5407 // Gated on `print_level >= 1`, matching `emit_end_summary` — the
5408 // block this line follows.
5409 let console_output = match self.options.get_integer_value("print_level", "") {
5410 Ok((v, true)) => v >= 1,
5411 _ => true,
5412 };
5413 if console_output {
5414 println!();
5415 if promote {
5416 println!(
5417 "gh#884 dual-divergence retry: promoted — unscaled KKT error \
5418 {base_unscaled_kkt:.4e} -> {retry_unscaled_kkt:.4e}."
5419 );
5420 } else if !answer_is_admissible {
5421 // A distinct line, because this decline looks like a
5422 // contradiction otherwise: the retry converged, its
5423 // certificate is clean, and it was still refused. Say
5424 // which of the two rules refused it and on what numbers,
5425 // so the reader is not left comparing KKT errors that
5426 // had nothing to do with it.
5427 println!(
5428 "gh#884 dual-divergence retry: declined on the ANSWER, not the \
5429 certificate — the retry converged (unscaled KKT error \
5430 {retry_unscaled_kkt:.4e} against the base attempt's \
5431 {base_unscaled_kkt:.4e}) but its objective {retry_obj:.8e} at \
5432 constraint violation {retry_viol:.4e} is not admissible next to \
5433 the base attempt's {:.8e} at {:.4e}; the base attempt's answer \
5434 is the one reported.",
5435 certificate_floor.objective, certificate_floor.unscaled_constr_viol
5436 );
5437 } else {
5438 println!(
5439 "gh#884 dual-divergence retry: declined ({retry_status:?}, \
5440 unscaled KKT error {retry_unscaled_kkt:.4e} against the base \
5441 attempt's {base_unscaled_kkt:.4e}); the base attempt's answer \
5442 is the one reported."
5443 );
5444 }
5445 }
5446 if promote {
5447 self.dual_divergence_retry_promoted.set(true);
5448 self.statistics.borrow_mut().dual_divergence_retry_promoted = true;
5449 tracing::debug!(target: "pounce::algorithm",
5450 "[POUNCE] gh#884: the retry promoted — unscaled KKT {:.3e} \
5451 (base {:.3e}).",
5452 retry_unscaled_kkt, base_unscaled_kkt);
5453 return retry_status;
5454 }
5455 if let Some(floor) = solution_floor {
5456 tracing::debug!(target: "pounce::algorithm",
5457 "[POUNCE] gh#884: the retry did not promote ({:?}, unscaled KKT \
5458 {:.3e} vs base {:.3e}); restoring the first attempt's solution \
5459 and statistics alongside its status.",
5460 retry_status, retry_unscaled_kkt, base_unscaled_kkt);
5461 self.restore_floor(&floor, &tnlp);
5462 certificate_floor.restore_into(&mut self.statistics.borrow_mut());
5463 // The signature belongs to the attempt whose numbers are now
5464 // reported, and `certificate_floor` does not carry it.
5465 self.statistics.borrow_mut().dual_divergence_signature = true;
5466 // The replayed row is the reported answer's last iterate, so a
5467 // `false` here is a stop request on it, honored as a solve's
5468 // final-iterate stop is.
5469 if let Some(stats) = trace_floor {
5470 if !tnlp.borrow_mut().intermediate_callback(
5471 stats,
5472 &TnlpIpoptData::default(),
5473 &TnlpIpoptCq::default(),
5474 ) {
5475 return ApplicationReturnStatus::UserRequestedStop;
5476 }
5477 }
5478 }
5479 first_status
5480 }
5481
5482 /// Is the gh#983 solve-quality audit enabled? Default `yes`;
5483 /// `solve_quality_audit=no` restores the pre-gh#983 behaviour (no
5484 /// re-scale retry, no `warnings`).
5485 fn is_solve_quality_audit_enabled(&self) -> bool {
5486 self.options
5487 .get_bool_value("solve_quality_audit", "")
5488 .map(|(v, _found)| v)
5489 .unwrap_or(true)
5490 }
5491
5492 /// gh#983 items 2 and 3: audit a success verdict against the *model's
5493 /// own* scale, re-solve once when the scaling is what is wrong, and say
5494 /// so in `statistics.warnings` when it is not fixed.
5495 ///
5496 /// The strict gate judges a scaled aggregate. Gradient-based scaling
5497 /// measures the objective gradient once, at the starting point, and
5498 /// freezes the factor; it also only ever scales *down*. Two failures
5499 /// follow, one on each side, and neither is visible to a gate that reads
5500 /// the scaled space:
5501 ///
5502 /// * **Frozen at a huge-gradient start** (LJ7 from a start whose gradient
5503 /// is `3e8`): the factor is `3e-7`, the scaled test passes at a point
5504 /// whose unscaled stationarity residual is `2.9e-2`, and no amount of
5505 /// continued iteration under that factor improves it
5506 /// (`obj_scale_certificate_threshold` already tries, and its budget is
5507 /// spent). The fix is the one the issue names: re-evaluate the scaling
5508 /// *at the returned point*, where the gradient is small, and solve
5509 /// again from there.
5510 /// * **Small user objective** (profit in M$/L, gradient `1e-5`): nothing
5511 /// scales up, so the absolute complementarity floor `~mu` is a
5512 /// `2.5e-4` fraction of the objective gradient and the answer sits
5513 /// `4.7e-4` (relative) off the optimum on an active bound. The retry
5514 /// multiplies the objective by `1/max|grad f|` and solves again from
5515 /// the returned point.
5516 ///
5517 /// Both retries are *bets that are tested*, floored exactly like the
5518 /// gh#884 and mu-strategy retries: the retry is promoted only when it
5519 /// returns a clean verdict, its unscaled KKT error is no worse, its
5520 /// complementarity (small-objective case) or unscaled KKT error (frozen
5521 /// case) is strictly better, and its answer is admissible next to the
5522 /// base attempt's. Otherwise the base attempt's status, point and
5523 /// statistics are restored and a structured warning is attached.
5524 fn audit_solve_quality(
5525 &mut self,
5526 tnlp: Rc<RefCell<dyn TNLP>>,
5527 first_status: ApplicationReturnStatus,
5528 ) -> ApplicationReturnStatus {
5529 if !self.is_solve_quality_audit_enabled()
5530 || !matches!(
5531 first_status,
5532 ApplicationReturnStatus::SolveSucceeded
5533 | ApplicationReturnStatus::SolvedToAcceptableLevel
5534 )
5535 {
5536 return first_status;
5537 }
5538 let num = |name: &str, default: Number| -> Number {
5539 self.options
5540 .get_numeric_value(name, "")
5541 .ok()
5542 .and_then(|(v, f)| f.then_some(v))
5543 .unwrap_or(default)
5544 };
5545 let acceptable_tol = num("acceptable_tol", 1e-6);
5546 let masked_threshold = num("obj_scale_certificate_threshold", 1e-4);
5547 // `equilibration-based` is registered but pounce runs it as
5548 // gradient-based (see `algorithm_builder_from_options`), so it gets the
5549 // gradient-based audit.
5550 let gradient_based = self
5551 .options
5552 .get_string_value("nlp_scaling_method", "")
5553 .ok()
5554 .and_then(|(v, f)| f.then_some(v))
5555 .map(|v| v == "gradient-based" || v == "equilibration-based")
5556 .unwrap_or(true);
5557 let base = self.statistics.borrow().clone();
5558 // gh#983 review item 2: measure the objective gradient at the point
5559 // being returned, so the audit asks what re-evaluating the scaling
5560 // *there* would do instead of keying on the start alone.
5561 let final_grad_max = self
5562 .last_finalize
5563 .borrow()
5564 .as_ref()
5565 .and_then(|f| {
5566 let mut g = vec![0.0; f.x.len()];
5567 tnlp.borrow_mut()
5568 .eval_grad_f(&f.x, true, &mut g)
5569 .then(|| g.iter().fold(0.0_f64, |m, v| m.max(v.abs())))
5570 })
5571 .unwrap_or(Number::NAN);
5572 let rescaled_obj_scale = if final_grad_max.is_finite() {
5573 pounce_nlp::orig_ipopt_nlp::gradient_obj_scale(
5574 final_grad_max,
5575 num("nlp_scaling_max_gradient", 100.0),
5576 num("nlp_scaling_min_value", 1e-8),
5577 num("nlp_scaling_obj_target_gradient", 0.0),
5578 )
5579 } else {
5580 Number::NAN
5581 };
5582 let input = ScaleAuditInput {
5583 tol: num("tol", 1e-8),
5584 obj_scale: base.final_obj_scaling_factor,
5585 rescaled_obj_scale,
5586 start_grad_max: base.start_obj_grad_max,
5587 final_grad_max,
5588 unscaled_kkt: base.final_unscaled_kkt_error,
5589 unscaled_compl: base.final_unscaled_compl,
5590 acceptable_tol,
5591 masked_threshold,
5592 warm_start: self
5593 .options
5594 .get_bool_value("warm_start_init_point", "")
5595 .map(|(v, _)| v)
5596 .unwrap_or(false),
5597 };
5598 let grad_scale = input.grad_scale();
5599 let trigger = scale_audit_trigger(input);
5600 let mut status = first_status;
5601 match trigger {
5602 Some(ScaleAudit::SmallObjective) => {
5603 self.statistics
5604 .borrow_mut()
5605 .warnings
5606 .push(scale_audit_warning(
5607 ScaleAudit::SmallObjective,
5608 &base,
5609 grad_scale,
5610 acceptable_tol,
5611 "",
5612 ));
5613 }
5614 Some(kind) if gradient_based && !self.debugger_attached.get() => {
5615 status = self.run_scale_retry(tnlp, first_status, kind, grad_scale);
5616 }
5617 Some(kind) if gradient_based => {
5618 // Same caveat as below, different reason: the re-solve would
5619 // run after the debugger's final pause, unobserved.
5620 self.statistics
5621 .borrow_mut()
5622 .warnings
5623 .push(scale_audit_warning(
5624 kind,
5625 &base,
5626 grad_scale,
5627 acceptable_tol,
5628 "was not attempted: the interactive debugger is attached",
5629 ));
5630 }
5631 Some(kind) => {
5632 // The caller owns the scaling (`none` / `user-scaling`): not ours
5633 // to override, but the verdict still deserves its caveat.
5634 self.statistics
5635 .borrow_mut()
5636 .warnings
5637 .push(scale_audit_warning(
5638 kind,
5639 &base,
5640 grad_scale,
5641 acceptable_tol,
5642 "was not attempted: nlp_scaling_method is not gradient-based",
5643 ));
5644 }
5645 None => {}
5646 }
5647 status
5648 }
5649
5650 /// gh#983 review items 1 and 3: the verdict-level half of the audit, run
5651 /// **once**, on the run whose point is being returned -- after every
5652 /// retry and fallback has decided.
5653 ///
5654 /// * The item-5 downgrade (a strict verdict on a run that saw the gh#884
5655 /// signature and still carries an unscaled dual infeasibility above
5656 /// `max(acceptable_tol, 1e-3)`) used to run inside each attempt, so it
5657 /// could turn a base `Solve_Succeeded` into the acceptable level *before*
5658 /// the gh#884 retry decided, reopening that retry for a case it was
5659 /// deliberately not widened to; and it read the cumulative signature, so
5660 /// a promoted mu-fallback run that never showed it was downgraded for its
5661 /// discarded sibling. It now reads
5662 /// `returned_run_dual_divergence_signature`, which the certificate floor
5663 /// saves and restores with the rest of the run's tally.
5664 /// * The model-scale caveats ([`quality_warnings`]) are attached here, and
5665 /// deduplicated by code, so a code appears at most once.
5666 fn finalize_quality_verdict(
5667 &mut self,
5668 status: ApplicationReturnStatus,
5669 ) -> ApplicationReturnStatus {
5670 if !self.is_solve_quality_audit_enabled() {
5671 return status;
5672 }
5673 let acceptable_tol = self.dual_divergence_retry_accept_tol();
5674 let mut status = status;
5675 if matches!(status, ApplicationReturnStatus::SolveSucceeded) {
5676 let (sig, du, du_scale) = {
5677 let st = self.statistics.borrow();
5678 (
5679 st.returned_run_dual_divergence_signature,
5680 st.final_unscaled_dual_inf,
5681 st.final_unscaled_dual_scale,
5682 )
5683 };
5684 // gh#983 item 5. `1e-3` is `UNSCALED_DUAL_WARN`; the measured
5685 // population behind it is in that constant's doc. A residual at
5686 // the rounding floor of its own terms is not evidence of a miss
5687 // (gh#1000, II.22): see `UNSCALED_DUAL_ROUNDOFF_ULPS`.
5688 if sig
5689 && du.is_finite()
5690 && du > acceptable_tol.max(UNSCALED_DUAL_WARN)
5691 && !unscaled_dual_inf_is_roundoff(du, du_scale)
5692 {
5693 status = ApplicationReturnStatus::SolvedToAcceptableLevel;
5694 push_warning_once(
5695 &mut self.statistics.borrow_mut().warnings,
5696 format!(
5697 "unscaled_dual_inf_above_acceptable: the run saw a settled primal with a \
5698 runaway multiplier (gh#884) and the returned point's unscaled dual \
5699 infeasibility is {du:.2e}, above {:.0e}; the status is downgraded from \
5700 Solve_Succeeded to Solved_To_Acceptable_Level",
5701 acceptable_tol.max(UNSCALED_DUAL_WARN)
5702 ),
5703 );
5704 }
5705 }
5706 let st = self.statistics.borrow().clone();
5707 let extra = quality_warnings(
5708 status,
5709 st.final_unscaled_dual_inf,
5710 st.final_unscaled_dual_scale,
5711 self.options
5712 .get_numeric_value("dual_inf_tol", "")
5713 .map(|(v, _)| v)
5714 .unwrap_or(1.0),
5715 acceptable_tol,
5716 self.options
5717 .get_numeric_value("bound_relax_factor", "")
5718 .is_ok_and(|(v, _)| v == 0.0),
5719 );
5720 let mut sm = self.statistics.borrow_mut();
5721 for w in extra {
5722 push_warning_once(&mut sm.warnings, w);
5723 }
5724 status
5725 }
5726
5727 /// The variable box `tnlp` declares, `(x_L, x_U)` in its own (full-x)
5728 /// space -- the space a `finalize_solution` payload is in. `None` if the
5729 /// TNLP declines to say.
5730 fn declared_x_box(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> Option<(Vec<Number>, Vec<Number>)> {
5731 let mut t = tnlp.borrow_mut();
5732 let info = t.get_nlp_info()?;
5733 let (n, m) = (info.n.max(0) as usize, info.m.max(0) as usize);
5734 let (mut x_l, mut x_u) = (vec![0.0; n], vec![0.0; n]);
5735 let (mut g_l, mut g_u) = (vec![0.0; m], vec![0.0; m]);
5736 t.get_bounds_info(BoundsInfo {
5737 x_l: &mut x_l,
5738 x_u: &mut x_u,
5739 g_l: &mut g_l,
5740 g_u: &mut g_u,
5741 })
5742 .then_some((x_l, x_u))
5743 }
5744
5745 /// The re-solve half of [`Self::audit_solve_quality`].
5746 fn run_scale_retry(
5747 &mut self,
5748 tnlp: Rc<RefCell<dyn TNLP>>,
5749 first_status: ApplicationReturnStatus,
5750 kind: ScaleAudit,
5751 grad_scale: Number,
5752 ) -> ApplicationReturnStatus {
5753 let acceptable_tol = self.dual_divergence_retry_accept_tol();
5754 let solution_floor = self.last_finalize.borrow().clone();
5755 let certificate_floor = SolutionCertificate::of(&self.statistics.borrow());
5756 let trace_floor = *self.last_iter_stats.borrow();
5757 let base = self.statistics.borrow().clone();
5758 let Some(seed) = solution_floor.as_ref().map(|f| f.x.clone()) else {
5759 return first_status;
5760 };
5761 // Budget the re-solve: an iteration cap tied to the base solve
5762 // (`SCALE_AUDIT_RETRY_ITER_FACTOR`), and only the time the caller's
5763 // limits have left. Every solve builds a fresh deadline, so without
5764 // this the re-solve got a second full `max_cpu_time` / `max_wall_time`
5765 // after the run had already succeeded. Restored below, absence as
5766 // absence.
5767 let prev_iter = self.options.get_integer_value("max_iter", "").ok();
5768 let prev_cpu = self.options.get_numeric_value("max_cpu_time", "").ok();
5769 let prev_wall = self.options.get_numeric_value("max_wall_time", "").ok();
5770 let cpu_left = prev_cpu.map(|(v, _)| v - base.total_cpu_time_secs);
5771 let wall_left = prev_wall.map(|(v, _)| v - base.total_wallclock_time_secs);
5772 if cpu_left.is_some_and(|t| t <= 0.0) || wall_left.is_some_and(|t| t <= 0.0) {
5773 return first_status;
5774 }
5775 let retry_iter = (SCALE_AUDIT_RETRY_ITER_FACTOR * base.iteration_count)
5776 .max(SCALE_AUDIT_RETRY_MIN_ITER)
5777 .min(prev_iter.map_or(Index::MAX, |(v, _)| v));
5778 let seeded: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(
5779 pounce_nlp::seeded_tnlp::SeededTnlp::new(Rc::clone(&tnlp), seed),
5780 ));
5781 // gh#983 review item 7: remember whether the caller *named* the
5782 // factor, so the restore puts back absence as absence. Writing the
5783 // default back with `found = true` left `obj_scaling_factor` reading as
5784 // user-set after the audit, which every "did the caller name it" test
5785 // downstream (`TERMINATION_POLICY_OPTIONS`, the gh#884 sense) then
5786 // misread.
5787 let prev_user = self
5788 .options
5789 .get_numeric_value("obj_scaling_factor", "")
5790 .ok()
5791 .and_then(|(v, f)| f.then_some(v));
5792 let prev_user_factor = prev_user.unwrap_or(1.0);
5793 let caller_named_relax = self
5794 .options
5795 .get_numeric_value("bound_relax_factor", "")
5796 .is_ok_and(|(_, found)| found);
5797 // Everything below until the scope drops is this re-solve's own
5798 // configuration; the scope puts every entry back, and clears the box
5799 // flag, however the re-solve exits.
5800 let mut scope = RetryOptionScope::enter(
5801 self,
5802 &[
5803 "obj_scaling_factor",
5804 "max_iter",
5805 "max_cpu_time",
5806 "max_wall_time",
5807 ],
5808 false,
5809 );
5810 if let ScaleAudit::Upscale(k) = kind {
5811 let _ = scope.options.set_numeric_value(
5812 "obj_scaling_factor",
5813 prev_user_factor * k,
5814 true,
5815 false,
5816 );
5817 }
5818 let _ = scope
5819 .options
5820 .set_integer_value("max_iter", retry_iter, true, false);
5821 // Solve the re-solve on the caller's own variable box: the rows are
5822 // widened by `bound_relax_factor` exactly as in the base attempt, the
5823 // box is not -- unless the caller named a widening.
5824 //
5825 // The re-solve exists to drive complementarity down (`Upscale`) or the
5826 // unscaled residual (`Rescale`), and on a bound-active variable that
5827 // is the same motion as driving the point onto its bound: `s · z = mu`
5828 // with `z` multiplied by the objective factor, so the slack to the
5829 // *widened* bound shrinks by that factor and the point lands at
5830 // `x_U + bound_relax_factor · max(1, |x_U|)` -- outside the box the
5831 // caller wrote. The base attempt, less converged, usually sat inside
5832 // it. So every promoted re-solve on a bound-active model shipped a
5833 // point outside its declared bounds: measured with `pounce verify`,
5834 // `ex1_320` `1.0e-8` across thousands of controls, `trainf` `7.0e-8`,
5835 // `reading1` `9.9e-9`, against `0` from the base attempt.
5836 //
5837 // The improvement itself is real, not bought with that slack: solved
5838 // with the declared box the re-solve reaches the same objective
5839 // (`reading1` `-1.604802802e-1` against `-1.604802843e-1` widened, the
5840 // base attempt's `-1.604578481e-1`) with the box held exactly. So the
5841 // box widening is dropped for this one solve rather than the re-solve
5842 // refused: it starts from a point that already satisfies the box, and
5843 // `bound_push` moves the seed off any bound it sits on.
5844 //
5845 // Why the box and not `bound_relax_factor=0`: that also un-widens the
5846 // inequality rows, which makes the re-solve a strictly tighter model
5847 // than the base attempt, and its objective then reads *worse* than
5848 // the base's by the row widening's worth -- measured on
5849 // `masked_certificate_fuzz` case 12, `-3.453863914e7` against
5850 // `-3.453863947e7`, with both points inside their box. The rows are
5851 // what the base attempt's `final_unscaled_constr_viol` and rule 2 of
5852 // `retry_answer_is_admissible` already compare like with like; the box
5853 // is the one thing neither did.
5854 //
5855 // A caller who named the factor defined the model that way and keeps
5856 // it; for that case the declared-box conjunct of the promotion gate
5857 // below is what holds the line.
5858 scope.retry_keeps_declared_box.set(!caller_named_relax);
5859 if let Some(t) = cpu_left {
5860 let _ = scope
5861 .options
5862 .set_numeric_value("max_cpu_time", t, true, false);
5863 }
5864 if let Some(t) = wall_left {
5865 let _ = scope
5866 .options
5867 .set_numeric_value("max_wall_time", t, true, false);
5868 }
5869 tracing::debug!(target: "pounce::algorithm",
5870 "[POUNCE] gh#983: {:?} verdict audited ({:?}); re-solving from the \
5871 returned point (max_iter {retry_iter}).", first_status, kind);
5872 // One IPM solve, without the mu-strategy fallback: the re-solve is
5873 // already a second opinion on a run that succeeded, and when it hit
5874 // its cap the flip spent the same budget again (`meyer3` 768 + 768,
5875 // `brainpc3` 120 + 120, `brainpc9` timing out in the second leg).
5876 // No promoted re-solve in the release sweep needed the flip except
5877 // `robot_b` past the cap, which keeps its `Solve_Succeeded` base.
5878 if match scope.options.get_integer_value("print_level", "") {
5879 Ok((v, true)) => v >= 1,
5880 _ => true,
5881 } {
5882 println!();
5883 println!(
5884 "gh#983 scale audit: re-solving from the returned point ({kind:?}); the \
5885 summary below is a candidate's, and the line after it says whether it \
5886 replaced the answer above."
5887 );
5888 }
5889 let mark = scope.begin_counted_resolve();
5890 let retry_status = scope.optimize_constrained(Rc::clone(&seeded));
5891 scope.end_counted_resolve(mark);
5892 drop(scope);
5893 // A re-solve the caller's intermediate callback stopped is declined
5894 // like any unclean one -- the audit only runs after a converged base,
5895 // and the stop's mid-run iterate must not replace it -- but the
5896 // status reported is the stop, and nothing calls back after it.
5897 let stopped = retry_status == ApplicationReturnStatus::UserRequestedStop;
5898 let retry = self.statistics.borrow().clone();
5899 let sense = if prev_user_factor < 0.0 { -1.0 } else { 1.0 };
5900 let clean = match first_status {
5901 ApplicationReturnStatus::SolveSucceeded => {
5902 matches!(retry_status, ApplicationReturnStatus::SolveSucceeded)
5903 }
5904 _ => matches!(
5905 retry_status,
5906 ApplicationReturnStatus::SolveSucceeded
5907 | ApplicationReturnStatus::SolvedToAcceptableLevel
5908 ),
5909 };
5910 let better = match kind {
5911 ScaleAudit::Rescale => retry.final_unscaled_kkt_error < base.final_unscaled_kkt_error,
5912 ScaleAudit::Upscale(_) | ScaleAudit::SmallObjective => {
5913 retry.final_unscaled_compl < 0.1 * base.final_unscaled_compl
5914 && retry.final_unscaled_kkt_error <= base.final_unscaled_kkt_error
5915 }
5916 };
5917 let admissible = retry_answer_is_admissible(
5918 certificate_floor.objective,
5919 certificate_floor.unscaled_constr_viol,
5920 retry.final_objective,
5921 retry.final_unscaled_constr_viol,
5922 acceptable_tol,
5923 sense,
5924 );
5925 // Measured on the two `finalize_solution` payloads -- the points a
5926 // caller would actually receive -- against the box the user TNLP
5927 // declares.
5928 let (base_box_rel, retry_box_rel) = match self.declared_x_box(&tnlp) {
5929 Some((x_l, x_u)) => {
5930 let (lo, up) = (self.nlp_lower_bound_inf(), self.nlp_upper_bound_inf());
5931 let rel = |x: &[Number]| declared_box_relative_violation(x, &x_l, &x_u, lo, up);
5932 (
5933 solution_floor.as_ref().map_or(Number::NAN, |f| rel(&f.x)),
5934 self.last_finalize
5935 .borrow()
5936 .as_ref()
5937 .map_or(Number::NAN, |f| rel(&f.x)),
5938 )
5939 }
5940 None => (Number::NAN, Number::NAN),
5941 };
5942 let keeps_box = retry_keeps_declared_box(base_box_rel, retry_box_rel);
5943 let promote = clean && better && admissible && keeps_box;
5944 let console_output = match self.options.get_integer_value("print_level", "") {
5945 Ok((v, true)) => v >= 1,
5946 _ => true,
5947 };
5948 if console_output {
5949 println!();
5950 if clean && better && admissible && !keeps_box {
5951 println!(
5952 "gh#983 scale audit: declined (the re-solve's point is {:.4e} outside the \
5953 declared variable bounds, the base attempt's {:.4e}); the base attempt's \
5954 answer is the one reported.",
5955 retry.final_declared_box_viol, certificate_floor.declared_box_viol
5956 );
5957 } else if promote {
5958 println!(
5959 "gh#983 scale audit: promoted -- unscaled KKT error {:.4e} -> {:.4e}, \
5960 unscaled complementarity {:.4e} -> {:.4e}.",
5961 base.final_unscaled_kkt_error,
5962 retry.final_unscaled_kkt_error,
5963 base.final_unscaled_compl,
5964 retry.final_unscaled_compl
5965 );
5966 } else {
5967 println!(
5968 "gh#983 scale audit: declined ({retry_status:?}, unscaled KKT error \
5969 {:.4e} against the base attempt's {:.4e}); the base attempt's answer \
5970 is the one reported.",
5971 retry.final_unscaled_kkt_error, base.final_unscaled_kkt_error
5972 );
5973 }
5974 if !promote {
5975 // The re-solve's own end summary printed above this line, with
5976 // *its* objective and bound violation, before the verdict was
5977 // known (`emit_end_summary` runs inside each attempt). Say
5978 // which block that was and restate the numbers that ship, so
5979 // the console cannot be read as reporting the discarded point.
5980 println!(
5981 "gh#983 scale audit: the summary block above is the discarded re-solve's; \
5982 the reported answer is the base attempt's (objective {:.16e}, variable \
5983 bound violation {:.16e}).",
5984 certificate_floor.objective, certificate_floor.declared_box_viol
5985 );
5986 }
5987 }
5988 if promote {
5989 return retry_status;
5990 }
5991 if let Some(floor) = solution_floor {
5992 self.restore_floor(&floor, &tnlp);
5993 certificate_floor.restore_into(&mut self.statistics.borrow_mut());
5994 self.statistics.borrow_mut().dual_divergence_signature |=
5995 retry.dual_divergence_signature;
5996 // The replayed row is the reported answer's last iterate, so a
5997 // `false` here is a stop request on it, honored as a solve's
5998 // final-iterate stop is.
5999 if let Some(stats) = trace_floor.filter(|_| !stopped) {
6000 if !tnlp.borrow_mut().intermediate_callback(
6001 stats,
6002 &TnlpIpoptData::default(),
6003 &TnlpIpoptCq::default(),
6004 ) {
6005 return ApplicationReturnStatus::UserRequestedStop;
6006 }
6007 }
6008 }
6009 if stopped {
6010 return retry_status;
6011 }
6012 let detail = if keeps_box {
6013 format!(
6014 "was declined: the re-solve returned {retry_status:?} with unscaled KKT error {:.2e}",
6015 retry.final_unscaled_kkt_error
6016 )
6017 } else {
6018 format!(
6019 "was declined: the re-solve returned {retry_status:?} at a point {:.2e} outside \
6020 the declared variable bounds",
6021 retry.final_declared_box_viol
6022 )
6023 };
6024 self.statistics
6025 .borrow_mut()
6026 .warnings
6027 .push(scale_audit_warning(
6028 kind,
6029 &base,
6030 grad_scale,
6031 acceptable_tol,
6032 &detail,
6033 ));
6034 first_status
6035 }
6036
6037 /// The tolerance conjunct 4 of the dual-divergence promotion gate
6038 /// tests the retry's *unscaled* residuals against.
6039 ///
6040 /// `acceptable_tol`-scale rather than `tol`-scale on purpose: the
6041 /// unscaled residual is the one quantity nothing in the solve is
6042 /// driven against, so holding it to `tol` would decline honest
6043 /// retries on badly scaled models for a reason that has nothing to
6044 /// do with gh#884. `qpec_small`'s honest retry reaches `9.96e-8`,
6045 /// four orders inside it.
6046 fn dual_divergence_retry_accept_tol(&self) -> Number {
6047 self.options
6048 .get_numeric_value("acceptable_tol", "")
6049 .ok()
6050 .and_then(|(v, f)| f.then_some(v))
6051 .unwrap_or(1e-6)
6052 }
6053
6054 /// Phase-3 ℓ₁-exact penalty-barrier outer loop.
6055 ///
6056 /// Builds an [`L1PenaltyBarrierTnlp`] wrapper around the user
6057 /// TNLP, runs the constrained IPM at the current ρ, escalates ρ
6058 /// per Byrd-Nocedal-Waltz steering, and terminates on any of:
6059 /// - slack sum collapses (`Σ(p+n) ≤ l1_slack_tol`)
6060 /// - inner solve returns non-Optimal (escalation won't fix
6061 /// numerical / restoration failure at this ρ)
6062 /// - ρ already at `l1_penalty_max`
6063 /// - `l1_penalty_max_outer_iter` reached
6064 ///
6065 /// After the loop, if the inner status is `SolveSucceeded` or
6066 /// `SolvedToAcceptableLevel` but slacks didn't collapse, override
6067 /// to `Infeasible_Problem_Detected` — the returned point is the
6068 /// ℓ₁-best least-infeasible iterate, which is informative even
6069 /// though the original constraints are not satisfied.
6070 ///
6071 /// Returns `Some(status)` if the wrapper ran the solve, `None` if
6072 /// wrapper construction failed (caller should fall through to the
6073 /// standard dispatch path).
6074 fn run_l1_penalty_outer_loop(
6075 &mut self,
6076 tnlp: Rc<RefCell<dyn TNLP>>,
6077 ) -> Option<ApplicationReturnStatus> {
6078 let rho_init = self.l1_penalty_init();
6079 let rho_max = self.l1_penalty_max().max(rho_init);
6080 let factor = self.l1_penalty_increase_factor().max(1.0);
6081 let tau = self.l1_steering_factor();
6082 let slack_tol = self.l1_slack_tol();
6083 let max_outer = self.l1_penalty_max_outer_iter().max(1);
6084
6085 let mut wrapper = pounce_l1penalty::L1PenaltyBarrierTnlp::new(Rc::clone(&tnlp), rho_init)?;
6086 if wrapper.m_eq() == 0 {
6087 // Nothing to slack — let the standard dispatch path handle
6088 // this TNLP unmodified.
6089 return None;
6090 }
6091 wrapper.set_defer_inner_finalize(true);
6092 let wrapper_rc = Rc::new(RefCell::new(wrapper));
6093
6094 let mut rho = rho_init;
6095 let mut last_status = ApplicationReturnStatus::InternalError;
6096 // gh#987 item 6: every pass resets the statistics, so without this the
6097 // report described only the last ρ — `iter_count` 26 for a solve that
6098 // ran 18 + 22 + 22 + 26 iterations. Accumulated here, applied once
6099 // after the loop.
6100 let mut totals = MultiPassTotals::default();
6101 for _outer in 0..max_outer {
6102 wrapper_rc.borrow_mut().set_rho(rho);
6103 let dyn_tnlp: Rc<RefCell<dyn TNLP>> = wrapper_rc.clone();
6104 last_status = self.optimize_constrained(dyn_tnlp);
6105 totals.push_pass(&self.statistics.borrow(), Some(rho), last_status);
6106
6107 let w = wrapper_rc.borrow();
6108 if !w.has_solution() {
6109 // Inner solve aborted before producing an iterate.
6110 drop(w);
6111 break;
6112 }
6113 let slack_sum = w.last_slack_sum();
6114 let y_eq_inf = w.last_y_eq_inf_norm();
6115 let x_here: Vec<Number> = w.last_x_trunc().to_vec();
6116 drop(w);
6117 totals.set_slack_sum(slack_sum);
6118
6119 // Termination decisions.
6120 let inner_ok = matches!(
6121 last_status,
6122 ApplicationReturnStatus::SolveSucceeded
6123 | ApplicationReturnStatus::SolvedToAcceptableLevel
6124 );
6125 if !inner_ok {
6126 break;
6127 }
6128 // Stop escalating ρ once the **user's** constraints are
6129 // satisfied to the tolerance the caller asked for — not once
6130 // `Σ(p + n)` falls under `l1_slack_tol`, which is a different
6131 // quantity judged by a different number (gh#794 P1). The
6132 // slack sum stays the BNW steering signal below, which is
6133 // the job it is right for. One extra `eval_g` per outer
6134 // iteration buys the difference between stopping at the
6135 // penalty solution and stopping at the model's own.
6136 let feasible_here = {
6137 let m_inner = tnlp
6138 .borrow_mut()
6139 .get_nlp_info()
6140 .map(|i| i.m.max(0) as usize)
6141 .unwrap_or(0);
6142 let mut g_here = vec![0.0; m_inner];
6143 let evaluated =
6144 m_inner == 0 || tnlp.borrow_mut().eval_g(&x_here, true, &mut g_here);
6145 evaluated
6146 .then(|| {
6147 original_space_feasibility(
6148 &tnlp,
6149 &x_here,
6150 &g_here,
6151 self.nlp_lower_bound_inf(),
6152 self.nlp_upper_bound_inf(),
6153 self.user_tol(),
6154 self.user_acceptable_tol(),
6155 self.user_constr_viol_tol(),
6156 self.user_acceptable_constr_viol_tol(),
6157 self.user_primal_noise_floor_kappa(),
6158 )
6159 })
6160 .flatten()
6161 .map(|f| {
6162 totals.set_violation(f.max_violation);
6163 f.negligible_at_tol
6164 })
6165 };
6166 match feasible_here {
6167 Some(true) => break,
6168 Some(false) => {}
6169 // Unmeasurable model: fall back to the historical
6170 // slack-sum test rather than looping to the cap.
6171 None if slack_sum.is_finite() && slack_sum <= slack_tol => break,
6172 None => {}
6173 }
6174 if rho >= rho_max {
6175 break;
6176 }
6177 // BNW steering: ρ_new = max(ρ·factor, τ·‖y_eq‖∞ + ε)
6178 let geom = rho * factor;
6179 let steer = tau * y_eq_inf + 1.0e-12;
6180 rho = geom.max(steer).min(rho_max);
6181 }
6182
6183 // gh#987 item 6: total iterations and the per-pass summary.
6184 totals.apply(&mut self.statistics.borrow_mut());
6185
6186 // Forward to the user's inner.finalize_solution exactly once.
6187 let w = wrapper_rc.borrow();
6188 if w.has_solution() {
6189 let x_trunc: Vec<Number> = w.last_x_trunc().to_vec();
6190 let lambda: Vec<Number> = w.last_lambda().to_vec();
6191 let z_l: Vec<Number> = w.last_z_l_trunc().to_vec();
6192 let z_u: Vec<Number> = w.last_z_u_trunc().to_vec();
6193 let solver_status = w.last_status().unwrap_or(SolverReturn::InternalError);
6194 let slack_sum = w.last_slack_sum();
6195 drop(w);
6196
6197 // Recompute f(x*) and c(x*) on the inner. Both are needed
6198 // before the status is decided, because the status now turns
6199 // on the *original-space* feasibility at this point.
6200 let f_inner = tnlp
6201 .borrow_mut()
6202 .eval_f(&x_trunc, true)
6203 .unwrap_or(Number::NAN);
6204 let m = tnlp
6205 .borrow_mut()
6206 .get_nlp_info()
6207 .map(|i| i.m as usize)
6208 .unwrap_or(0);
6209 // The success flag decides whether `g_inner` is a measurement
6210 // or a zero-filled buffer. Dropping it would let a TNLP whose
6211 // final `eval_g` fails fabricate feasibility: every row would
6212 // read `0`, `original_space_feasibility` would return a
6213 // violation of zero, and that would flow into both the exit
6214 // status and the reported residuals. Gate it exactly as the
6215 // ρ-escalation measurement above does, so an evaluation
6216 // failure produces `None` and follows the documented
6217 // `l1_slack_tol` fallback instead (gh#794 review).
6218 let mut g_inner = vec![0.0; m];
6219 let g_evaluated = m == 0 || tnlp.borrow_mut().eval_g(&x_trunc, false, &mut g_inner);
6220
6221 // gh#794 P1. Everything below used to argue from `Σ(p + n)`,
6222 // the sum of the augmented slacks, judged against
6223 // `l1_slack_tol`. That is not the user's constraint
6224 // violation and it is not judged by the user's tolerance:
6225 //
6226 // * the violation of equality row `i` is `|p_i − n_i|`,
6227 // not `p_i + n_i`, and at the barrier's interior both
6228 // slacks stay positive where their difference is zero,
6229 // so the sum is an upper bound that is loose in one
6230 // direction; and
6231 // * `l1_slack_tol` defaults to `1e-6`, four orders looser
6232 // than a `tol = 1e-8` solve asked for, so a violation
6233 // that the solver's own strict gate would refuse on the
6234 // unwrapped problem read as "the constraints are
6235 // satisfied".
6236 //
6237 // Measured, not argued: the MPCC benchmark's `ralph1`
6238 // (`benchmarks/mpcc/`) returned `Solve_Succeeded` at a point
6239 // violating its one equality row by `2.5e-07`, with the
6240 // reported `final_constr_viol` — the *augmented* residual —
6241 // at `9.6e-15`, so no field in the result disclosed it. The
6242 // objective came back `5.0e-04` below the true optimum,
6243 // which is reachable only off the feasible set.
6244 //
6245 // So: measure the user's own rows at the returned point, and
6246 // judge them by the tolerances the caller set. The slack sum
6247 // keeps its other job unchanged — it is the BNW steering
6248 // signal for ρ escalation inside the loop above, which is
6249 // what it is the right quantity for.
6250 let feas = g_evaluated
6251 .then(|| {
6252 original_space_feasibility(
6253 &tnlp,
6254 &x_trunc,
6255 &g_inner,
6256 self.nlp_lower_bound_inf(),
6257 self.nlp_upper_bound_inf(),
6258 self.user_tol(),
6259 self.user_acceptable_tol(),
6260 self.user_constr_viol_tol(),
6261 self.user_acceptable_constr_viol_tol(),
6262 self.user_primal_noise_floor_kappa(),
6263 )
6264 })
6265 .flatten();
6266
6267 // The reported constraint violation must be the user's, not
6268 // the augmented problem's. Without this the KKT block of a
6269 // successful ℓ₁ solve describes a problem the caller never
6270 // posed. The aggregate errors take a `max` rather than a
6271 // rewrite: the NLP error's primal term enters undivided, so
6272 // the aggregate is never below the constraint violation, and
6273 // the other two components are unaffected by the wrapper.
6274 //
6275 // `f.max_violation` is measured on the inner TNLP's own rows
6276 // and bounds, so it is in the model's **original units**. That
6277 // decides which field family may carry it. `SolveStatistics`
6278 // documents `final_*` as the max-norms in the internally
6279 // scaled NLP space and `final_unscaled_*` as the same
6280 // residuals with the scaling divided back out — equal only
6281 // when no scaling is active — and `docs/src/python.md` states
6282 // the same contract to Python callers. Writing an
6283 // original-units number into `final_constr_viol` would break
6284 // it on any run with `nlp_scaling_method` engaged (gh#794
6285 // review).
6286 //
6287 // So the unscaled family always takes the measurement, and
6288 // the scaled family mirrors it exactly when per-row scaling
6289 // did not engage — the case in which the contract requires
6290 // the two to agree anyway. Under active row scaling the
6291 // scaled fields keep what the inner solve reported; the
6292 // converted number is not available here, because the
6293 // augmented problem's row-scale factors belong to an NLP that
6294 // `optimize_constrained` has already dropped. The status
6295 // decision below does not read these fields — it reads
6296 // `feas` directly — so an active-scaling run is judged on the
6297 // user's rows either way.
6298 // gh#987 (remaining item b): `final_declared_constr_viol` is the
6299 // violation of the model AS THE USER DECLARED IT. The inner
6300 // solve's value describes the wrapper's augmented rows, where
6301 // the elastic slacks absorb any violation, so it read ~1e-12
6302 // even at an infeasible point. Overwrite it with the measurement
6303 // on the user's own rows and box (original units, the same
6304 // quantity the non-l1 path reports); `NaN` when the model could
6305 // not be evaluated, never the augmented number.
6306 self.statistics.borrow_mut().final_declared_constr_viol =
6307 feas.as_ref().map_or(Number::NAN, |f| f.max_violation);
6308 if let Some(f) = feas.as_ref() {
6309 let scaled_may_mirror = self.row_scaling_active.get() == Some(false);
6310 let mut stats = self.statistics.borrow_mut();
6311 stats.final_unscaled_constr_viol = f.max_violation;
6312 stats.final_unscaled_kkt_error =
6313 stats.final_unscaled_kkt_error.max(f.max_violation);
6314 if scaled_may_mirror {
6315 stats.final_constr_viol = f.max_violation;
6316 stats.final_kkt_error = stats.final_kkt_error.max(f.max_violation);
6317 stats.final_kkt_error_above_noise =
6318 stats.final_kkt_error_above_noise.max(f.max_violation);
6319 }
6320 }
6321
6322 let inner_claimed_success = matches!(
6323 last_status,
6324 ApplicationReturnStatus::SolveSucceeded
6325 | ApplicationReturnStatus::SolvedToAcceptableLevel
6326 );
6327
6328 // Downgrade, not upgrade: a solve that reached the strict
6329 // standard on the user's own rows keeps whatever status the
6330 // inner gave it, and nothing here can turn a failure into a
6331 // success.
6332 let downgrade_to_acceptable = inner_claimed_success
6333 && feas
6334 .as_ref()
6335 .is_some_and(|f| !f.negligible_at_tol && f.negligible_at_acceptable);
6336
6337 let infeasible_certificate = inner_claimed_success
6338 && match feas.as_ref() {
6339 // Measured: the point does not satisfy the user's
6340 // constraints even to `acceptable_tol`.
6341 Some(f) => !f.negligible_at_acceptable,
6342 // Unmeasurable model — fall back to the historical
6343 // slack-sum argument rather than to silence.
6344 None => slack_sum.is_finite() && slack_sum > slack_tol,
6345 };
6346
6347 if let Some(f) = feas.as_ref()
6348 && inner_claimed_success
6349 && !f.negligible_at_tol
6350 {
6351 tracing::info!(
6352 target: "pounce::application",
6353 "l1 penalty-barrier: the inner solve converged the augmented NLP, \
6354 but the returned point violates the model's own constraints by \
6355 {:.3e}, which does not meet tol; reporting {} rather than success \
6356 (gh#794)",
6357 f.max_violation,
6358 if f.negligible_at_acceptable {
6359 "Solved_To_Acceptable_Level"
6360 } else {
6361 "an infeasibility verdict"
6362 },
6363 );
6364 }
6365 // …unless the model's own starting point satisfies every
6366 // constraint, which disproves the certificate outright (gh #379).
6367 // Same gate as the IPM and SQP paths; see
6368 // `withdraw_infeasibility_if_refuted`.
6369 let refuted = infeasible_certificate
6370 && withdraw_infeasibility_if_refuted(
6371 &tnlp,
6372 SolverReturn::LocalInfeasibility,
6373 self.nlp_lower_bound_inf(),
6374 self.nlp_upper_bound_inf(),
6375 self.user_tol(),
6376 ) != SolverReturn::LocalInfeasibility;
6377 let final_solver_status = match (infeasible_certificate, refuted) {
6378 (true, false) => SolverReturn::LocalInfeasibility,
6379 // The point is not feasible, so `Solve_Succeeded` would be
6380 // just as wrong as `Infeasible_Problem_Detected`. Report the
6381 // breakdown.
6382 (true, true) => SolverReturn::ErrorInStepComputation,
6383 (false, _) if downgrade_to_acceptable => SolverReturn::StopAtAcceptablePoint,
6384 (false, _) => solver_status,
6385 };
6386 let final_app_status = match (infeasible_certificate, refuted) {
6387 (true, false) => ApplicationReturnStatus::InfeasibleProblemDetected,
6388 (true, true) => ApplicationReturnStatus::ErrorInStepComputation,
6389 (false, _) if downgrade_to_acceptable => {
6390 ApplicationReturnStatus::SolvedToAcceptableLevel
6391 }
6392 (false, _) => last_status,
6393 };
6394
6395 tnlp.borrow_mut().finalize_solution(
6396 Solution {
6397 status: final_solver_status,
6398 x: &x_trunc,
6399 z_l: &z_l,
6400 z_u: &z_u,
6401 g: &g_inner,
6402 lambda: &lambda,
6403 obj_value: f_inner,
6404 },
6405 &TnlpIpoptData::default(),
6406 &TnlpIpoptCq::default(),
6407 );
6408 return Some(final_app_status);
6409 }
6410 // No solution captured at all — pass the inner status through.
6411 Some(last_status)
6412 }
6413
6414 /// Constrained-NLP path: build adapter → OrigIpoptNlp → algorithm
6415 /// bundle, run `optimize`, populate statistics, and call
6416 /// `finalize_solution` on the user's TNLP.
6417 /// Whether an over-determined model (more equality rows than free
6418 /// variables) is *provably* infeasible by linear bound propagation.
6419 ///
6420 /// Consulted only on the `NotEnoughDegreesOfFreedom` failure path, where
6421 /// the solve never runs and therefore can never itself discover the
6422 /// infeasibility (gh#387). Builds a throwaway presolve wrapper with only
6423 /// Phase 1 (bound tightening) enabled and asks it for a certified proof —
6424 /// this inherits the certification safety net wholesale: the crossing must
6425 /// exceed the solver's own acceptance margin at the crossed pair's scale,
6426 /// and a concrete witness point satisfying every constraint withdraws the
6427 /// verdict. A `false` here costs nothing but keeping the DOF error.
6428 ///
6429 /// The witness gate runs under [`pounce_presolve::WitnessRule`]'s
6430 /// `DeclaredRowRelative` form, which is admissible only because the solve
6431 /// cannot run on this path (gh#391) — see the comment on the probe below.
6432 ///
6433 /// Deliberately independent of the `presolve` master switch: this is not a
6434 /// model transformation (the wrapper is dropped without solving through
6435 /// it), it is a last check before reporting a structural error for a
6436 /// problem whose verdict is already decided.
6437 fn overdetermined_model_certified_infeasible(&self, tnlp: &Rc<RefCell<dyn TNLP>>) -> bool {
6438 let mut opts = pounce_presolve::PresolveOptions::from_options_list(&self.options)
6439 .unwrap_or_else(|_| pounce_presolve::PresolveOptions::defaults());
6440 opts.enabled = true;
6441 opts.bound_tightening = true;
6442 // Certification needs Phase 1 only; every transformative or
6443 // diagnostic phase is dead weight on a wrapper that is never
6444 // solved through.
6445 opts.auxiliary = false;
6446 opts.fbbt = false;
6447 opts.redundant_constraint_removal = false;
6448 opts.licq_check = false;
6449 opts.warm_z_bounds = false;
6450 // The one place the witness rule is raised off the solver's own
6451 // acceptance test (gh#391). It is sound *here specifically* because the
6452 // gate has already established the solve cannot run: the alternative to
6453 // the proof is the structural 5xx error, never `Solve_Succeeded`, so
6454 // the #380 "two routes, two answers" contradiction the clamp exists to
6455 // prevent has no second route to contradict. See `WitnessRule` for the
6456 // full argument and the homogeneous-row fallback.
6457 let mut probe =
6458 pounce_presolve::PresolveTnlp::new(Rc::clone(tnlp), opts).probing_without_a_solve();
6459 if probe.get_nlp_info().is_none() {
6460 return false;
6461 }
6462 probe.certified_infeasible().is_some()
6463 }
6464
6465 fn optimize_constrained(&mut self, tnlp: Rc<RefCell<dyn TNLP>>) -> ApplicationReturnStatus {
6466 let t_start = Instant::now();
6467
6468 // Invalidate the row-scaling record before anything can read it.
6469 //
6470 // It is written near the end of this function, from the NLP the
6471 // solve actually built, so a solve that bails before that point
6472 // leaves whatever the *previous* one recorded. The ℓ₁ outer loop
6473 // calls this repeatedly and reads the flag after each call, so a
6474 // stale `Some(false)` would let it mirror an original-units
6475 // violation into the scaled family — the exact contract this flag
6476 // exists to protect (gh#794 review round 2). Clearing it here
6477 // makes the failure mode fail-closed: "not recorded" reads as
6478 // "cannot mirror", never as "scaling was off".
6479 self.row_scaling_active.set(None);
6480 // gh#983 review item 1: the structured warnings describe the run whose
6481 // point is returned, so each attempt starts with none. They used to
6482 // accumulate on the application for its lifetime, and a promoted
6483 // gh#884 retry (`Solve_Succeeded` at `du = 1.6e-7`) carried the
6484 // discarded base attempt's `large_dual_scale ... 1.43e9`. A declined
6485 // retry restores the floored attempt's own list via
6486 // `SolutionCertificate`.
6487 self.statistics.borrow_mut().warnings.clear();
6488 self.statistics
6489 .borrow_mut()
6490 .returned_run_dual_divergence_signature = false;
6491
6492 // `print_user_options yes` — dump the OptionsList before the
6493 // solve. Mirrors `IpoptApplication::call_optimize` (upstream
6494 // calls `Jnlst().Printf(.., "%s", options_->PrintUserOptions())`).
6495 let print_opts = self
6496 .options
6497 .get_bool_value("print_user_options", "")
6498 .ok()
6499 .and_then(|(v, f)| f.then_some(v))
6500 .unwrap_or(false);
6501 if print_opts {
6502 print!(
6503 "\nList of user-set options:\n\n{}",
6504 self.options.print_user_options()
6505 );
6506 }
6507
6508 // `print_options_documentation yes` — dump the full registry
6509 // (every option with type, default, valid range/strings, and
6510 // long description) before the solve. Honors
6511 // `print_options_mode` (`text` / `latex` / `doxygen`; only
6512 // `text` is implemented today, the others fall through with a
6513 // one-line note) and `print_advanced_options`. Mirrors
6514 // upstream `IpoptApplication::call_optimize`'s
6515 // `print_options_documentation` branch and `Common/IpRegOptions.cpp`
6516 // `OutputOptionDocumentation`.
6517 let print_doc = self
6518 .options
6519 .get_bool_value("print_options_documentation", "")
6520 .ok()
6521 .and_then(|(v, f)| f.then_some(v))
6522 .unwrap_or(false);
6523 if print_doc {
6524 let mode = self
6525 .options
6526 .get_string_value("print_options_mode", "")
6527 .ok()
6528 .map(|(v, _)| PrintOptionsMode::from_tag(&v))
6529 .unwrap_or(PrintOptionsMode::Text);
6530 let advanced = self
6531 .options
6532 .get_bool_value("print_advanced_options", "")
6533 .ok()
6534 .map(|(v, _)| v)
6535 .unwrap_or(false);
6536 print!(
6537 "\n# Pounce options registry\n\n{}",
6538 self.reg_options.print_options_documentation(mode, advanced)
6539 );
6540 }
6541
6542 // Mint a fresh `TimingStatistics` for this solve — shared (via
6543 // `Rc`) with the data and the NLP below so every `eval_*` and
6544 // every iterate-phase records into the same accumulator. The
6545 // application keeps its own `Rc` so callers can read totals out
6546 // via [`Self::timing_stats`].
6547 let timing = Rc::new(TimingStatistics::new());
6548 *self.timing.borrow_mut() = Rc::clone(&timing);
6549 // gh#606: same lifetime as the timings — a solve that bails out
6550 // before the initializer runs must not report the previous
6551 // solve's warm-start verdict.
6552 *self.warm_start_diag.borrow_mut() = None;
6553 // Gate the *detailed* per-subsystem timers on `timing_statistics`
6554 // (default "no"), matching upstream Ipopt. Without this, every
6555 // timed `eval_*` / phase section pays two `getrusage` syscalls per
6556 // start/end even when statistics are off — 16-20% of busy CPU on
6557 // fast-objective NLPs (issue #190). `print_timing_statistics=yes`
6558 // implies `timing_statistics=yes` (per its option help), so either
6559 // one enables the detailed timers. `overall_alg` is started
6560 // unconditionally below: it feeds the `max_cpu_time` check and is
6561 // reported regardless of the option.
6562 //
6563 // Each name is read as a literal rather than looped over an
6564 // array: the registered-but-unread scan
6565 // (`tests/no_silent_options.rs`) keys on the option name as it
6566 // appears at the accessor, so a loop variable reads as "no key
6567 // here" and hid `timing_statistics` among the silent options
6568 // when it has been wired since #190 (#677, #551).
6569 let read_yes = |key: &str| -> bool {
6570 self.options
6571 .get_bool_value(key, "")
6572 .ok()
6573 .and_then(|(v, f)| f.then_some(v))
6574 .unwrap_or(false)
6575 };
6576 let timing_enabled = read_yes("timing_statistics") || read_yes("print_timing_statistics");
6577 timing.set_detailed_enabled(timing_enabled);
6578 timing.overall_alg.start();
6579
6580 // Reset the linear-solver summary sink so back-to-back solves
6581 // don't bleed factor counters / extremal pivots into each
6582 // other. Surviving the lock failure with a debug-assert keeps
6583 // a poisoned mutex from sinking a release build that doesn't
6584 // even consume the summary.
6585 for sink in [&self.linsol_summary_sink, &self.resto_linsol_summary_sink] {
6586 match sink.lock() {
6587 Ok(mut guard) => {
6588 *guard = LinearSolverSummary::default();
6589 }
6590 _ => {
6591 debug_assert!(false, "linsol summary sink mutex poisoned");
6592 }
6593 }
6594 }
6595 // Same reasoning for the quality-escalation tally (gh#857): the
6596 // number belongs to this solve, not to whatever ran before it.
6597 self.quality_escalations.set(0);
6598 // And for the published partition: back-to-back solves on one
6599 // application can differ in which variables are fixed, so last
6600 // solve's labels must not describe this solve's KKT. Cleared here and
6601 // written again below only if this solve resolves one.
6602 if let Ok(mut g) = self.kkt_blocks_published.lock() {
6603 *g = None;
6604 }
6605
6606 // Build adapter + Nlp. Honor `fixed_variable_treatment` (default
6607 // `make_parameter`; pounce additionally implements `relax_bounds`,
6608 // which the adapter also auto-selects as a fallback when
6609 // `make_parameter` would leave `n_x_var < n_c` — mirrors upstream
6610 // `IpTNLPAdapter.cpp:623-633`).
6611 let (lo_inf, up_inf, fixed_treatment) = self.adapter_options();
6612 let adapter = match TNLPAdapter::new_with_options(
6613 Rc::clone(&tnlp),
6614 lo_inf,
6615 up_inf,
6616 fixed_treatment,
6617 ) {
6618 Ok(a) => Rc::new(RefCell::new(a)),
6619 Err(_) => {
6620 timing.overall_alg.end();
6621 return ApplicationReturnStatus::InvalidProblemDefinition;
6622 }
6623 };
6624 // Carry the user's constant `obj_scaling_factor` (default 1.0;
6625 // negative ⇒ maximize) into the NLP. Until pounce#128's
6626 // follow-up this option was registered but never read, so it
6627 // was silently a no-op — maximization diverged because the
6628 // algorithm minimized the unscaled objective.
6629 let obj_scaling_factor = self
6630 .options
6631 .get_numeric_value("obj_scaling_factor", "")
6632 .ok()
6633 .and_then(|(v, f)| f.then_some(v))
6634 .unwrap_or(1.0);
6635 let mut orig_nlp = match OrigIpoptNlp::new(
6636 Rc::clone(&adapter),
6637 Rc::new(ConstObjScaling(obj_scaling_factor)),
6638 ) {
6639 Ok(n) => n,
6640 Err(_) => {
6641 timing.overall_alg.end();
6642 return ApplicationReturnStatus::InternalError;
6643 }
6644 };
6645 orig_nlp.set_timing_stats(Rc::clone(&timing));
6646 // Q6: decide which derivatives may be reused across iterates,
6647 // before anything is evaluated (gh #588).
6648 self.install_constant_derivative_hints(&mut orig_nlp);
6649
6650 // Mirror upstream `OrigIpoptNLP::InitializeStructures` (IpOrigIpoptNLP.cpp:299):
6651 // bail out with NotEnoughDegreesOfFreedom when there are fewer free
6652 // variables than equality constraints. Without this gate, square /
6653 // over-determined systems push the algorithm into restoration on
6654 // iter 0 and exit Restoration_Failed instead of the cleaner DOF code.
6655 let n_x_var = orig_nlp.x_space().dim();
6656 let n_c = orig_nlp.c_space().dim();
6657 if n_x_var > 0 && n_x_var < n_c {
6658 timing.overall_alg.end();
6659 // An over-determined system can still be *provably* infeasible —
6660 // `x == 0.2` with `x == 0.8` is about as provable as infeasibility
6661 // gets — and for such a model the structural DOF error is the
6662 // strictly weaker answer: it reports "cannot attempt this" for a
6663 // problem whose verdict is already decided (gh#387). The DOF gate
6664 // fires before any iteration runs, so nothing downstream will ever
6665 // get the chance to detect the infeasibility; check for a
6666 // bound-propagation proof here, on the rare failure path only.
6667 // The probe reuses presolve's full certification pipeline
6668 // (crossing margin + witness refutation), so a model the solver
6669 // would accept as feasible at its own tolerance is never upgraded
6670 // to "proved infeasible" — those still report the DOF error.
6671 if self.overdetermined_model_certified_infeasible(&tnlp) {
6672 use pounce_common::journalist::JournalCategory;
6673 self.journalist.print(
6674 JournalLevel::J_SUMMARY,
6675 JournalCategory::J_MAIN,
6676 "\nEXIT: Problem has too few degrees of freedom, and bound \
6677 propagation proves its constraints inconsistent.\n\
6678 No feasible point exists; the solve was not run.\n",
6679 );
6680 return ApplicationReturnStatus::InfeasibleProblemDetected;
6681 }
6682 return ApplicationReturnStatus::NotEnoughDegreesOfFreedom;
6683 }
6684
6685 // Relax `x_L / x_U / d_L / d_U` by `bound_relax_factor` (default
6686 // 1e-8), capped by `constr_viol_tol` (default 1e-4). Matches
6687 // `OrigIpoptNLP::InitializeStructures` lines 343-358.
6688 let bound_relax_factor = self
6689 .options
6690 .get_numeric_value("bound_relax_factor", "")
6691 .ok()
6692 .and_then(|(v, f)| f.then_some(v))
6693 .unwrap_or(1e-8);
6694 let constr_viol_tol = self
6695 .options
6696 .get_numeric_value("constr_viol_tol", "")
6697 .ok()
6698 .and_then(|(v, f)| f.then_some(v))
6699 .unwrap_or(1e-4);
6700 if self.retry_keeps_declared_box.get() {
6701 orig_nlp.relax_bounds_keeping_box(bound_relax_factor, constr_viol_tol);
6702 } else {
6703 orig_nlp.relax_bounds(bound_relax_factor, constr_viol_tol);
6704 }
6705
6706 // `honor_original_bounds` (default `no`, matching upstream):
6707 // project the reported point back into the un-relaxed box. Must
6708 // follow `relax_bounds`, which snapshots the bounds to project
6709 // onto. Registered but never read before, so a user asking for
6710 // it still got a bound-pinned solution sitting up to
6711 // `min(bound_relax_factor·max(1,|b|), constr_viol_tol)` outside
6712 // its own bounds (gh#483 follow-up).
6713 let honor_original_bounds = self
6714 .options
6715 .get_bool_value("honor_original_bounds", "")
6716 .ok()
6717 .and_then(|(v, f)| f.then_some(v))
6718 .unwrap_or(false);
6719 orig_nlp.set_honor_original_bounds(honor_original_bounds);
6720
6721 // Apply automatic NLP scaling per `nlp_scaling_method` option
6722 // (port of `OrigIpoptNLP::InitializeStructures` →
6723 // `NLPScalingObject::DetermineScaling`). Default is
6724 // `gradient-based` to match upstream Ipopt 3.14.
6725 let scaling_method = self
6726 .options
6727 .get_string_value("nlp_scaling_method", "")
6728 .ok()
6729 .and_then(|(v, f)| f.then_some(v))
6730 .unwrap_or_else(|| "gradient-based".to_string());
6731 let scaling_method = match scaling_method.as_str() {
6732 "none" => ScalingMethod::None,
6733 "gradient-based" => ScalingMethod::GradientBased,
6734 // `curvature-based` computes the factors from the model's
6735 // quadratic coefficients and hands them back through
6736 // `TNLP::get_scaling_parameters` (gh #703), so from the
6737 // engine's side it *is* user scaling — the only difference is
6738 // who filled the vectors in.
6739 "user-scaling" | "curvature-based" => ScalingMethod::UserScaling,
6740 // `equilibration-based` is registered upstream but not yet
6741 // implemented in pounce; fall back to gradient-based (the
6742 // upstream default) to keep behavior predictable.
6743 _ => ScalingMethod::GradientBased,
6744 };
6745 let max_gradient = self
6746 .options
6747 .get_numeric_value("nlp_scaling_max_gradient", "")
6748 .ok()
6749 .and_then(|(v, f)| f.then_some(v))
6750 .unwrap_or(100.0);
6751 let min_value = self
6752 .options
6753 .get_numeric_value("nlp_scaling_min_value", "")
6754 .ok()
6755 .and_then(|(v, f)| f.then_some(v))
6756 .unwrap_or(1e-8);
6757 let obj_target_gradient = self
6758 .options
6759 .get_numeric_value("nlp_scaling_obj_target_gradient", "")
6760 .ok()
6761 .and_then(|(v, f)| f.then_some(v))
6762 .unwrap_or(0.0);
6763 let constr_target_gradient = self
6764 .options
6765 .get_numeric_value("nlp_scaling_constr_target_gradient", "")
6766 .ok()
6767 .and_then(|(v, f)| f.then_some(v))
6768 .unwrap_or(0.0);
6769 orig_nlp.determine_scaling_from_starting_point(
6770 scaling_method,
6771 max_gradient,
6772 min_value,
6773 obj_target_gradient,
6774 constr_target_gradient,
6775 );
6776
6777 let nlp_handle: Rc<RefCell<dyn IpoptNlp>> = Rc::new(RefCell::new(orig_nlp));
6778
6779 // Build the algorithm strategy bundle. Read coarse knobs from
6780 // the OptionsList where we have them; fall through to defaults
6781 // otherwise. The full upstream parsing surface (mu_strategy,
6782 // hessian_approximation, line_search_method, ...) is wired by
6783 // `AlgBuilder::RegisterOptions` in upstream — that registry
6784 // hookup lands as a follow-up; default builder is correct for
6785 // HS71-class problems.
6786 let mut builder = self.algorithm_builder_from_options();
6787
6788 // The objective element's support for the partitioned Hessian.
6789 // Every constraint element reads its support off a Jacobian row;
6790 // only the objective has none declared, so without this the
6791 // updater falls back to the first `∇f`'s nonzeros — a
6792 // value-derived pattern. See
6793 // `TNLPAdapter::objective_nonlinear_vars`.
6794 if matches!(
6795 builder.hessian_approximation,
6796 HessianApproxChoice::Partitioned | HessianApproxChoice::FiniteDifference
6797 ) {
6798 builder.objective_nonlinear_vars = adapter.borrow().objective_nonlinear_vars();
6799 }
6800 // gh#989 item 5: the declared Lagrangian Hessian structure splits the
6801 // partitioned elements into the independent blocks they really are.
6802 if matches!(
6803 builder.hessian_approximation,
6804 HessianApproxChoice::Partitioned
6805 ) {
6806 let declared = !matches!(
6807 self.options.get_string_value("partitioned_structure", ""),
6808 Ok((ref v, true)) if v == "jacobian"
6809 );
6810 if declared {
6811 builder.hessian_declared_pattern = adapter.borrow().lagrangian_hessian_pattern();
6812 }
6813 }
6814
6815 // Which variables the limited-memory Hessian should span (gh#624).
6816 // Upstream's precedence: a TNLP that implements
6817 // `get_number_of_nonlinear_variables` wins, and
6818 // `num_linear_variables` is only the contiguous-prefix fallback.
6819 // Exact-Hessian solves never consult either.
6820 if matches!(
6821 builder.hessian_approximation,
6822 HessianApproxChoice::LimitedMemory | HessianApproxChoice::FiniteDifference
6823 ) {
6824 let num_linear_variables = self
6825 .options
6826 .get_integer_value("num_linear_variables", "")
6827 .ok()
6828 .and_then(|(v, f)| f.then_some(v))
6829 .unwrap_or(0);
6830 match adapter
6831 .borrow()
6832 .quasi_newton_nonlinear_vars(num_linear_variables)
6833 {
6834 Ok(mask) => builder.limited_memory_nonlinear_vars = mask,
6835 Err(e) => {
6836 use pounce_common::journalist::JournalCategory;
6837 self.journalist.print(
6838 JournalLevel::J_ERROR,
6839 JournalCategory::J_MAIN,
6840 &format!("\nEXIT: Invalid nonlinear-variable list: {}\n", e.message),
6841 );
6842 timing.overall_alg.end();
6843 return ApplicationReturnStatus::InvalidProblemDefinition;
6844 }
6845 }
6846 }
6847
6848 // Linear-solver backend. The default factory is option-aware
6849 // — it reads the `feral_*` extension options off the same
6850 // `OptionsList` that drove the IPM-level builder above so
6851 // per-problem `.opt` files can flip backend knobs without
6852 // rebuilding pounce.
6853 let mut feral_cfg = feral_config_from_options(&self.options);
6854 // Block-triangular / Schur KKT partition (pounce#180 item 2). Configure
6855 // the Schur block solvers from the *base* feral cfg: a full-KKT external
6856 // ordering (item 1) is sized for the whole system and cannot apply to
6857 // the A_FF sub-block, so the Schur path keeps the default sub-block
6858 // ordering. `build_with_backend` honors this only on the IPM + feral +
6859 // exact-Hessian path and falls back to the standard solver otherwise.
6860 if let Some(indices) = &self.kkt_schur_block {
6861 builder.set_kkt_schur(indices.clone(), feral_cfg.clone());
6862 builder.set_kkt_schur_summary_sink(Arc::clone(&self.linsol_summary_sink));
6863 }
6864 // Block-parallel KKT partition (structured-KKT Phase 5b), explicit or
6865 // detected (`kkt_block_detect`).
6866 let detect = matches!(
6867 self.options.get_string_value("kkt_block_detect", ""),
6868 Ok((ref v, true)) if v.eq_ignore_ascii_case("yes")
6869 );
6870 // A model-space declaration (§45) is mapped to KKT indices here, where
6871 // the NLP layout — which variables survived fixing, how constraints
6872 // split into equalities and inequalities — is known.
6873 {
6874 let a = adapter.borrow();
6875 let cls = a.classification();
6876 self.x_elimination = Some((cls.x_not_fixed_map.clone(), cls.n_full_x as usize));
6877 }
6878 let mapped = self.model_blocks.as_ref().and_then(|(v, c)| {
6879 let cls = adapter.borrow().classification().clone();
6880 match map_block_structure_to_kkt(&cls, v, c) {
6881 Ok(l) => Some(l),
6882 Err(e) => {
6883 tracing::warn!(
6884 target: "pounce::kkt",
6885 error = %e,
6886 "declared block structure does not fit this problem; using the standard solver"
6887 );
6888 None
6889 }
6890 }
6891 });
6892 if let Some(labels) = mapped
6893 .or_else(|| self.kkt_blocks.clone())
6894 .or(detect.then(Vec::new))
6895 {
6896 builder.set_kkt_blocks(labels.clone(), feral_cfg.clone());
6897 builder.set_kkt_schur_summary_sink(Arc::clone(&self.linsol_summary_sink));
6898 // And to the restoration sub-IPM, whose builder was minted before
6899 // this mapping existed. Its reduced system is this one, so the
6900 // same labels apply; see `kkt_blocks_published`. Off with
6901 // `kkt_block_restoration no`, which is how restoration's own
6902 // contribution is measured.
6903 let resto_blocks = !matches!(
6904 self.options.get_string_value("kkt_block_restoration", ""),
6905 Ok((ref v, _)) if v.eq_ignore_ascii_case("no")
6906 );
6907 if resto_blocks {
6908 if let Ok(mut g) = self.kkt_blocks_published.lock() {
6909 *g = Some((labels, feral_cfg.clone()));
6910 }
6911 }
6912 }
6913 // A caller-supplied KKT permutation (pounce#180 item 1) overrides
6914 // the string-option / env ordering: `OrderingMethod::External`
6915 // can't be expressed through the OptionsList (it carries a
6916 // vector), so it is injected here from the side-channel field.
6917 // Only applies to the workspace-default FERAL backend below; a
6918 // custom `linear_backend_factory` owns its own config.
6919 if let Some(perm) = &self.external_ordering {
6920 feral_cfg.ordering = pounce_feral::OrderingMethod::External(perm.clone());
6921 }
6922 // MA57's knobs come off the same `OptionsList`, at the main-IPM
6923 // prefix. The restoration sub-IPM reads them again under
6924 // `"resto."` when its caller mints the inner backend factory —
6925 // see `ma57_config_from_options`.
6926 let ma57_cfg = ma57_config_from_options(&self.options, "");
6927 // gh#987 item 3: publish the unperturbed trials' inertia next to the
6928 // backend's own per-factorization record.
6929 builder.inertia_sink = Some(Arc::clone(&self.linsol_summary_sink));
6930 let factory = self.linear_backend_factory.take().unwrap_or_else(|| {
6931 default_backend_factory_with_sink(
6932 feral_cfg,
6933 ma57_cfg,
6934 Arc::clone(&self.linsol_summary_sink),
6935 )
6936 });
6937 let bundle = builder.build_with_backend(factory);
6938
6939 // Wire the data / cq pair around the NLP. Install the shared
6940 // `TimingStatistics` so the algorithm's iterate phases
6941 // (output, convergence, hessian, μ, search-direction,
6942 // line-search, accept) all record into the same accumulator
6943 // the application exposes via `timing_stats()`.
6944 let data: crate::ipopt_data::IpoptDataHandle = Rc::new(RefCell::new(AlgIpoptData::new()));
6945 data.borrow_mut().timing = Rc::clone(&timing);
6946 // Install a shared wall/CPU-time deadline (pounce#242) so the time
6947 // budget is honored at the granularity of the expensive inner
6948 // steps — the main loop's KKT factorization / line search and the
6949 // restoration inner IPM — instead of only between outer iterations.
6950 // The `Deadline` starts its clock now (right after `overall_alg`),
6951 // and the restoration sub-solve reuses this same instance, so the
6952 // caller's budget bounds the whole solve rather than each nested
6953 // level independently. The convergence check treats it as
6954 // authoritative when present (see `conv_check::opt_error`).
6955 data.borrow_mut().deadline = Some(pounce_common::timing::Deadline::new(
6956 builder.conv_check.max_wall_time,
6957 builder.conv_check.max_cpu_time,
6958 ));
6959 let cq: crate::ipopt_cq::IpoptCqHandle = Rc::new(RefCell::new(
6960 IpoptCalculatedQuantities::new(Rc::clone(&data), Rc::clone(&nlp_handle)),
6961 ));
6962 // Correction size for very small slacks (default mach_eps^{3/4});
6963 // drives the safe-slack bound-adjustment mechanism.
6964 if let Ok((v, true)) = self.options.get_numeric_value("slack_move", "") {
6965 cq.borrow_mut().slack_move = v;
6966 }
6967 // `kappa_d` — weight of the linear damping term added to the
6968 // barrier objective/gradient to handle one-sided bounds
6969 // (`IpIpoptCalculatedQuantities.cpp`). Registered (default 1e-5)
6970 // but previously never read, so a user override was silently
6971 // ignored (#191). Routed through the builder for parity with the
6972 // other numeric knobs; the default matches the registered
6973 // default, so only explicit overrides change behavior.
6974 cq.borrow_mut().kappa_d = builder.kappa_d;
6975 // `s_max` — cap on the average multiplier magnitude in the
6976 // `(s_d, s_c)` scaling of the KKT error test. Same shape as
6977 // `kappa_d`: registered (default 100) and previously never read,
6978 // so an override was silently ignored (#551 / #677).
6979 cq.borrow_mut().s_max = builder.s_max;
6980
6981 // Seed `data.curr` with a zero-valued iterate of the correct
6982 // dimensions. The `IterateInitializer` consumes these as its
6983 // template (it overwrites `x`, `s`, multipliers in place); we
6984 // just need the dim metadata.
6985 {
6986 let nlp_borrow = nlp_handle.borrow();
6987 let n_x = nlp_borrow.n();
6988 let n_s = nlp_borrow.m_ineq();
6989 let n_yc = nlp_borrow.m_eq();
6990 let n_yd = nlp_borrow.m_ineq();
6991 let n_zl = nlp_borrow.x_l().dim();
6992 let n_zu = nlp_borrow.x_u().dim();
6993 let n_vl = nlp_borrow.d_l().dim();
6994 let n_vu = nlp_borrow.d_u().dim();
6995 drop(nlp_borrow);
6996 let iv = IteratesVector::new(
6997 Rc::new(DenseVectorSpace::new(n_x).make_new_dense()),
6998 Rc::new(DenseVectorSpace::new(n_s).make_new_dense()),
6999 Rc::new(DenseVectorSpace::new(n_yc).make_new_dense()),
7000 Rc::new(DenseVectorSpace::new(n_yd).make_new_dense()),
7001 Rc::new(DenseVectorSpace::new(n_zl).make_new_dense()),
7002 Rc::new(DenseVectorSpace::new(n_zu).make_new_dense()),
7003 Rc::new(DenseVectorSpace::new(n_vl).make_new_dense()),
7004 Rc::new(DenseVectorSpace::new(n_vu).make_new_dense()),
7005 );
7006 data.borrow_mut().set_curr(iv);
7007 }
7008
7009 // Full primal-dual warm restart (debugger `resolve`): if a
7010 // captured iterate is queued, install it onto `data.curr` over
7011 // the placeholder so the `WarmStartIterateInitializer`'s
7012 // re-optimize branch (x already initialized) keeps it and only
7013 // clamps multipliers / sets target_mu — no cold re-seed from the
7014 // NLP. Skipped (with a warning) if the dimensions don't line up,
7015 // e.g. an option changed the problem structure between solves.
7016 if let Some(snap) = self.warm_start_iterate.take() {
7017 let dims_match = {
7018 let borrow = data.borrow();
7019 borrow
7020 .curr
7021 .as_ref()
7022 .map(|c| iterates_dims(c) == iterates_dims(snap.iterates()))
7023 .unwrap_or(false)
7024 };
7025 if dims_match {
7026 data.borrow_mut().set_curr(snap.iterates().clone());
7027 data.borrow_mut().curr_mu = snap.mu();
7028 } else {
7029 tracing::warn!(
7030 target: "pounce::warm_start",
7031 "debugger warm-restart iterate dimensions differ from the fresh \
7032 solve; ignoring the captured iterate and seeding normally"
7033 );
7034 }
7035 }
7036
7037 let max_iter = self
7038 .options
7039 .get_integer_value("max_iter", "")
7040 .ok()
7041 .and_then(|(v, f)| f.then_some(v))
7042 .unwrap_or(3000);
7043 let tol = self
7044 .options
7045 .get_numeric_value("tol", "")
7046 .ok()
7047 .and_then(|(v, f)| f.then_some(v))
7048 .unwrap_or(1e-8);
7049 data.borrow_mut().tol = tol;
7050
7051 let mut alg = IpoptAlgorithm::new(data, cq, bundle)
7052 .with_nlp(Rc::clone(&nlp_handle))
7053 .with_tnlp(Rc::clone(&tnlp));
7054 alg.last_iter_stats_sink = Some(Rc::clone(&self.last_iter_stats));
7055 alg.live_iterate_fixed_var_duals = self.reports_fixed_var_duals();
7056 // Mint a fresh restoration factory per inner solve if a
7057 // provider is configured (pounce#10 Phase 3). Falls back to
7058 // the legacy one-shot `restoration_factory` slot when no
7059 // provider is set, preserving single-shot caller behavior.
7060 if let Some(provider) = self.restoration_factory_provider.as_mut() {
7061 self.restoration_factory = Some(provider());
7062 }
7063 if let Some(factory) = self.restoration_factory.as_mut() {
7064 alg = alg.with_restoration(factory());
7065 }
7066 if let Some(diag) = self.diagnostics.as_ref() {
7067 alg = alg.with_diagnostics(Rc::clone(diag));
7068 }
7069 // Move the interactive debugger hook (if any) into the main
7070 // algorithm. Taken — not cloned — so it drives exactly this
7071 // solve; a subsequent solve must reinstall it.
7072 if let Some(hook) = self.debug_hook.take() {
7073 alg = alg.with_debug_hook(hook);
7074 }
7075 alg.max_iter = max_iter;
7076 // `kappa_sigma` — factor bounding how far the bound multipliers
7077 // may deviate from their primal estimates; the clamp runs after
7078 // every accepted step (`IpIpoptAlg.cpp`, Eqn. (16)). Registered
7079 // (default 1e10) but previously never read, so a user override —
7080 // including the documented `< 1` "disable the correction" — was
7081 // silently ignored (#191). Routed through the builder; the struct
7082 // default matches the registered default, so default runs are
7083 // unchanged.
7084 alg.kappa_sigma = builder.kappa_sigma;
7085 alg.ftb_centrality_floor = builder.ftb_centrality_floor;
7086 // `recalc_y` (#677) — see the read site above for why the
7087 // limited-memory path defaults it on.
7088 // `linear_system_scaling=slack-based` (#677) — the only scaling
7089 // choice whose factors depend on the iterate, so the main loop
7090 // has to refresh them. See `IpoptAlgorithm::push_slack_scaling`.
7091 alg.slack_based_scaling = matches!(
7092 builder.linear_system_scaling,
7093 crate::alg_builder::LinearSystemScalingChoice::SlackBased
7094 );
7095 alg.recalc_y = builder.recalc_y;
7096 alg.recalc_y_feas_tol = builder.recalc_y_feas_tol;
7097 // `start_with_resto` — the outer loop is what acts on it. It was
7098 // previously copied only into the restoration sub-solver's own
7099 // builder, which has no first outer iteration to force, so
7100 // setting the option did nothing at all.
7101 alg.start_with_resto = builder.resto.start_with_resto;
7102 // Tiny-step and divergence guards (#191): registered but
7103 // previously never read. Struct defaults match the registered
7104 // defaults, so default runs are unchanged.
7105 alg.tiny_step_tol = builder.tiny_step_tol;
7106 alg.tiny_step_y_tol = builder.tiny_step_y_tol;
7107 alg.diverging_iterates_tol = builder.diverging_iterates_tol;
7108 alg.dual_diverging_streak = builder.dual_diverging_streak.max(0) as usize;
7109 alg.dual_divergence_retry_step_tol = builder.dual_divergence_retry_step_tol;
7110 alg.dual_divergence_retry_du_floor = builder.dual_divergence_retry_du_floor;
7111 alg.tiny_stall = crate::tiny_step_stall::TinyStepDualStall::new(
7112 builder.tiny_step_dual_stall_iters.max(0) as usize,
7113 );
7114 alg.resto_decline_deferrals = builder.resto_decline_deferrals.max(0) as usize;
7115 alg.resto_decline_progress_ratio = builder.resto_decline_progress_ratio;
7116 alg.neg_curv_escapes = builder.neg_curv_escapes.max(0) as usize;
7117 alg.lbfgs_ls_failure_restarts = builder.limited_memory_ls_failure_restarts.max(0) as usize;
7118 alg.kkt_fidelity_tol = builder.kkt_fidelity_tol;
7119 // Honor `print_level == 0`: silence the algorithm's direct-to-stdout
7120 // output — the per-iteration table and, new in #206, the
7121 // problem-statistics and end-of-run summary blocks the engine now
7122 // emits itself. Default (unset) or any positive level shows them; the
7123 // CLI's JSON mode forces print_level 0, so structured output stays
7124 // clean. (The Phase-7 journalist surface respects print_level already;
7125 // this is the legacy direct-print site that needs the same gate.)
7126 let console_output = match self.options.get_integer_value("print_level", "") {
7127 Ok((v, true)) => v >= 1,
7128 _ => true,
7129 };
7130 if !console_output {
7131 alg.print_iter_output = false;
7132 // The nested restoration IPM is built inside the restoration
7133 // driver, not by `IpoptAlgorithm::new`, so it never sees this
7134 // gate unless we forward it.
7135 if let Some(resto) = alg.restoration.as_mut() {
7136 resto.set_print_iter_output(false);
7137 }
7138 }
7139
7140 // Problem statistics, Ipopt-style, emitted before the iteration table
7141 // from the engine's own reduced problem (#206). Built from the same
7142 // collect_stats inputs the CLI used, so the block is byte-identical;
7143 // emitting it here means every frontend (CLI, Python, C) and every
7144 // algorithm (IPM, SQP) gets it.
7145 self.emit_problem_stats(&tnlp, console_output);
7146
7147 // Per-iteration history (pounce#71): when requested, capture the
7148 // `pounce::iteration` events emitted during the solve into an
7149 // `IterRecord` trajectory via the observability collector layer.
7150 // This replaces the old in-loop `iter_history` accumulation; it
7151 // requires the collector to be installed in the active
7152 // subscriber (the CLI / Python / C frontends install it via
7153 // `pounce_observability::init_subscriber`; tests call
7154 // `init_for_tests`). The collector scopes out restoration
7155 // sub-solve iterations via the `restoration` span, so the
7156 // trajectory matches the previous behavior (outer iters only).
7157 let iter_capture = self
7158 .record_iter_history
7159 .then(pounce_observability::IterCaptureGuard::start);
7160
7161 let handover_cells = self.install_handover(&mut alg, &nlp_handle);
7162 let solver_status = alg.optimize();
7163 self.finish_handover(&nlp_handle, handover_cells);
7164 // Keep the initializer's feasibility diagnostics reachable
7165 // after the algorithm goes out of scope (gh#605).
7166 self.least_square_init_report = alg.least_square_init_report();
7167
7168 let captured_iters = iter_capture.map(|g| g.finish()).unwrap_or_default();
7169 // Propagate to any enclosing capture (e.g. `with_iter_capture`
7170 // wrapped around a solve with iteration history enabled), whose
7171 // buffer this inner guard would otherwise leave empty.
7172 pounce_observability::extend_active_capture(&captured_iters);
7173 // Close the overall-algorithm timer on the success path. The
7174 // early-return arms above end it themselves before bailing out;
7175 // this one matches upstream `IpoptApplication::call_optimize`
7176 // (which calls `EndCpuTime()` on overall_alg right after
7177 // `Optimize` returns, regardless of solver_status).
7178 timing.overall_alg.end();
7179
7180 // gh#612: opt-in crossover. Runs here — after the algorithm is done
7181 // but BEFORE the statistics drain, the status gates, the
7182 // `on_converged` hook and `finalize_via_orig_nlp` — so that when it
7183 // accepts, every one of those describes the point actually returned.
7184 // Placing it later would mean reporting residuals for an iterate the
7185 // user is not given, and would hide the exact active set from the
7186 // post-optimal sensitivity hook, which is the first of the three
7187 // consumers this exists for.
7188 self.maybe_crossover(&mut alg, &nlp_handle, solver_status);
7189
7190 // Drain counters / iter count off the algorithm.
7191 {
7192 let mut stats = self.statistics.borrow_mut();
7193 {
7194 let d = alg.data.borrow();
7195 stats.iteration_count = d.iter_count;
7196 // Converged barrier parameter μ — threaded forward into a
7197 // warm-started corrector's `mu_init` / `warm_start_target_mu`
7198 // for predictor–corrector path following (pounce#86).
7199 stats.final_mu = d.curr_mu;
7200 }
7201 // gh#606: the warm-start initializer's verdict on what the
7202 // caller supplied. Lifted off the (solve-local) data handle
7203 // so it outlives the solve; `None` on a cold start.
7204 *self.warm_start_diag.borrow_mut() = alg.data.borrow().warm_start_diagnostics.clone();
7205 stats.total_wallclock_time_secs = t_start.elapsed().as_secs_f64();
7206 // Restoration-phase audit counters (pounce#12). Zero on
7207 // problems where restoration never fires; populated by
7208 // `IpoptAlgorithm::invoke_restoration`.
7209 // Finite-difference Hessian census (gh#823 review). `None` on
7210 // every other updater, which leaves the `-1` sentinel in
7211 // place and says "this mode did not run" rather than "it ran
7212 // and found nothing".
7213 if let Some(fd) = alg.bundle.hess.fd_hessian_stats() {
7214 use crate::hess::fd_hessian::FdPatternSource;
7215 stats.fd_hessian_pattern_used = match fd.pattern_used {
7216 Some(FdPatternSource::Declared) => 0,
7217 Some(FdPatternSource::Jacobian) => 1,
7218 None => -1,
7219 };
7220 stats.fd_hessian_nnz = fd.nnz as Index;
7221 stats.fd_hessian_n = fd.n as Index;
7222 stats.fd_hessian_groups = fd.groups as Index;
7223 stats.fd_hessian_rho_max = fd.rho_max as Index;
7224 stats.fd_hessian_coloring_fell_back = fd.coloring_fell_back;
7225 stats.fd_hessian_objective_clique_widened = fd.objective_clique_widened;
7226 }
7227 stats.restoration_calls = alg.resto_calls;
7228 stats.restoration_inner_iters = alg.resto_inner_iters;
7229 stats.restoration_outer_iters = alg.resto_outer_iters;
7230 stats.restoration_wall_secs = alg.resto_wall_secs;
7231 // gh#857. Read off the shared cell rather than the algorithm's
7232 // own `PdFullSpaceSolver`, so restoration sub-solves — which
7233 // run their own solver instance — are included.
7234 stats.quality_escalations = self.quality_escalations.get() as Index;
7235 // gh#1042: which `Restoration_Failed` this is, for the ladder.
7236 stats.unreleased_handback_exit = alg.unreleased_handback_exit();
7237 // gh#884. Read off the algorithm that just ran, so
7238 // `run_with_dual_divergence_retry` — which sits above this
7239 // call — can see what it observed.
7240 self.dual_divergence_signature
7241 .set(self.dual_divergence_signature.get() || alg.dual_divergence_signature());
7242 stats.dual_divergence_signature = self.dual_divergence_signature.get();
7243 stats.returned_run_dual_divergence_signature = alg.dual_divergence_signature();
7244 self.tiny_step_dual_stall.set(alg.tiny_step_dual_stall());
7245 stats.dual_divergence_retry_promoted = self.dual_divergence_retry_promoted.get();
7246 stats.mu_strategy_fallback = self.mu_strategy_fallback_record.borrow().clone();
7247 stats.derivative_check = self.derivative_check.borrow().clone();
7248 let (rs, ra) = self.obj_scale_refused.get();
7249 self.obj_scale_refused.set((
7250 rs || alg.obj_scale_certificate_refused(),
7251 ra || alg.obj_scale_acceptable_refused(),
7252 ));
7253 stats.obj_scale_certificate_refused = self.obj_scale_refused.get().0;
7254 stats.obj_scale_acceptable_refused = self.obj_scale_refused.get().1;
7255 stats.iterations = captured_iters;
7256 // A refused starting point does not produce a valid iterate.
7257 // Leave final objective/residual fields at their NaN defaults.
7258 // Capture the final *scaled* objective at the algorithm's
7259 // (compressed `x_var`-space) iterate via the NLP: the
7260 // algorithm-side `eval_f` returns `f * obj_scale_factor`.
7261 // `final_objective` is seeded with it only as a best-effort
7262 // fallback; the success path below overwrites it with the
7263 // true unscaled objective from `finalize_via_orig_nlp`
7264 // (which evaluates the user TNLP directly).
7265 if solver_status != SolverReturn::InvalidProblemDefinition {
7266 let curr_x = alg.data.borrow().curr.as_ref().map(|c| c.x.clone());
7267 if let Some(x) = curr_x {
7268 if let Ok(f) = try_eval_curr_f(&nlp_handle, &x) {
7269 stats.final_objective = f;
7270 stats.final_scaled_objective = f;
7271 }
7272 }
7273 // Final residuals straight off the cq cache. These mirror
7274 // the values upstream prints in its end-of-run summary
7275 // ("Dual infeasibility / Constraint violation /
7276 // Complementarity / Overall NLP error").
7277 let cq = alg.cq.borrow();
7278 stats.final_dual_inf = cq.curr_dual_infeasibility_max();
7279 // Stays on the *internal* measure deliberately: the summary's
7280 // "Overall NLP error" is `curr_nlp_error`, and it is built from
7281 // this same `max(||c||, ||d - s||)`. Switching the violation line
7282 // alone to the original-NLP measure
7283 // (`curr_unscaled_nlp_constraint_violation_max`, now used by the
7284 // `inf_pr` column) would leave the block self-inconsistent —
7285 // an error larger than the max of its own components. Making
7286 // them agree means deciding whether *convergence* should be
7287 // judged on the original NLP, which is a behaviour change for
7288 // every model, not a reporting fix. See pounce#476.
7289 //
7290 // NOTE (gh #528): "Overall NLP error" is no longer the number
7291 // the strict gate tests. That gate judges
7292 // `curr_nlp_error_above_primal_noise` — the same aggregate with
7293 // each row's residual counted only above what it can represent
7294 // in floating point — so on a model whose constraint values run
7295 // to `~1e8` the summary can report an error above `tol` beside
7296 // `EXIT: Optimal Solution Found`. The gap is exactly the part
7297 // of the residual that is quantisation noise, and it is bounded
7298 // by `constr_viol_tol`, which is still tested here on the full
7299 // unfloored residual. Reporting is deliberately left on the raw
7300 // value: it is the honest measurement, and at these magnitudes
7301 // the default `bound_relax_factor = 1e-8` has already moved
7302 // every bound by orders of magnitude more than the floor
7303 // forgives, so the raw number was never an exact statement
7304 // about the original NLP either.
7305 stats.final_constr_viol = cq.curr_primal_infeasibility_max();
7306 // How far outside the model AS DECLARED the returned point
7307 // sits. The line above is the internal slack measure the
7308 // convergence test reads, on the `bound_relax_factor`-widened
7309 // model this arm genuinely solves; the widening stays here
7310 // because a feasible-iterate log-barrier needs `x` strictly
7311 // inside its bounds (the convex arm's does not -- see
7312 // `qp_extract::BoundRelax`). The two can differ by orders and
7313 // nothing used to say so: on netlib `wood1p` this reports
7314 // `1.71e-14` at a point `7.96e-09` outside the declared rows
7315 // and `9.84e-09` outside the declared box. Reported on
7316 // every solve (see gh#987 below).
7317 //
7318 // gh#987: computed on every solve, not only a widened one.
7319 // `final_constr_viol` is documented as the internal scaled
7320 // residual, so under row scaling with `bound_relax_factor=0`
7321 // the user-unit violation was reported nowhere (the field read
7322 // NaN). Without a widening the declared and live models
7323 // coincide, but the *units* do not, and this is the one field
7324 // that is always in the caller's.
7325 stats.final_declared_constr_viol = cq.curr_declared_primal_violation_max();
7326 // The box half of the same measurement, unconditionally: this
7327 // one is a *summary line* (Ipopt's `Variable bound
7328 // violation`), not an extra warning, so it has to carry a real
7329 // number on every solve rather than only on a widened one. At
7330 // `bound_relax_factor = 0` the honest number is `0` and the
7331 // accessor returns it by measurement, not by assumption.
7332 stats.final_declared_box_viol = cq.curr_declared_box_violation_max();
7333 // Infinity-norm complementarity, max over all four bound
7334 // blocks (s_xl·z_l, s_xu·z_u, s_sl·v_l, s_su·v_u). The
7335 // empty-bound blocks return `0` from amax(), so the max is
7336 // safe even when only one side has bounds.
7337 let compl = cq
7338 .curr_compl_x_l()
7339 .amax()
7340 .max(cq.curr_compl_x_u().amax())
7341 .max(cq.curr_compl_s_l().amax())
7342 .max(cq.curr_compl_s_u().amax());
7343 stats.final_compl = compl;
7344 stats.final_kkt_error = cq.curr_nlp_error();
7345 // The aggregate the strict gate tested (gh #528). Reported
7346 // alongside the raw one so a summary can account for the gap
7347 // between them; equal to it on every `O(1)` model, and on any
7348 // run with `primal_noise_floor_kappa = 0`.
7349 stats.final_kkt_error_above_noise = cq
7350 .curr_nlp_error_above_primal_noise(builder.conv_check.primal_noise_floor_kappa);
7351 // Unscaled (user-space) counterparts — divide the nlp_scaling
7352 // back out so a consumer can verify the certificate in its own
7353 // units (pounce#173). Identical to the scaled fields when no
7354 // scaling is active.
7355 stats.final_unscaled_dual_inf = cq.curr_unscaled_dual_infeasibility_max();
7356 stats.final_unscaled_dual_scale = cq.curr_unscaled_dual_infeasibility_scale_max();
7357 stats.final_obj_scaling_factor = cq.computed_obj_scaling_factor();
7358 stats.final_total_obj_scaling_factor = cq.obj_scaling_factor().abs();
7359 stats.start_obj_grad_max = cq.start_obj_gradient_max();
7360 stats.final_unscaled_constr_viol = cq.curr_unscaled_primal_infeasibility_max();
7361 // Record whether per-row scaling actually engaged, so a
7362 // wrapper that measures the user's rows in the model's own
7363 // units knows which field family may carry that number.
7364 // `curr_unscaled_primal_infeasibility_max` treats both
7365 // vectors absent as "scaled == unscaled"; the ℓ₁ outer loop
7366 // relies on the same equivalence (gh#794 review).
7367 {
7368 let nlp_ref = nlp_handle.borrow();
7369 self.row_scaling_active.set(Some(
7370 nlp_ref.c_scale_vec().is_some() || nlp_ref.d_scale_vec().is_some(),
7371 ));
7372 }
7373 stats.final_unscaled_compl = cq.curr_unscaled_complementarity_max();
7374 stats.final_unscaled_kkt_error = cq.curr_unscaled_nlp_error();
7375
7376 // Report an accepted crossover in the frame it solved in
7377 // (#646). Everything above measures against the bounds the
7378 // interior iteration ran against, which `bound_relax_factor`
7379 // widened by `δ` before the solve. That is the right frame
7380 // for an interior iterate — it never touches a bound — but a
7381 // crossed-over point sits *exactly* on the constraints of the
7382 // problem as declared, i.e. `δ` inside the relaxed ones, so
7383 // the four `s·z` blocks above read `|multiplier| · δ`. For a
7384 // unit multiplier and the default `δ = 1e-8` that is `1e-8`,
7385 // which is `tol`: a strictly better point printed an `Overall
7386 // NLP error` above tolerance, and the opt-in
7387 // `kkt_fidelity_tol` gate below downgraded it.
7388 //
7389 // Only complementarity moves. Stationarity involves no
7390 // bounds, and the crossed-over point is *interior* to the
7391 // relaxed box, so its violation is zero under either reading.
7392 //
7393 // The substitution is confined to reporting. Crossover runs
7394 // after the status is decided, and it only ever installs a
7395 // point the never-regress gate accepted on the declared-bound
7396 // residuals, so this cannot dress up a worse iterate — the
7397 // measurement it replaces is the artifact.
7398 // The same holds for a point the in-solve handover installed
7399 // (gh#1054) while it is still the iterate being reported —
7400 // `curr_from_crossover` is cleared if the interior method
7401 // moved on from it.
7402 let declared_compl = self
7403 .crossover_report
7404 .as_ref()
7405 .filter(|r| r.accepted())
7406 .or_else(|| {
7407 self.handover_report
7408 .as_ref()
7409 .filter(|_| alg.data.borrow().curr_from_crossover)
7410 .and_then(|h| h.last.as_ref())
7411 .filter(|r| r.accepted())
7412 })
7413 .map(|r| r.compl_after)
7414 .filter(|c| c.is_finite());
7415 if let Some(compl_declared) = declared_compl {
7416 stats.final_compl = compl_declared;
7417 stats.final_kkt_error =
7418 cq.curr_nlp_error_with_complementarity(compl_declared, 0.0);
7419 stats.final_kkt_error_above_noise = cq.curr_nlp_error_with_complementarity(
7420 compl_declared,
7421 builder.conv_check.primal_noise_floor_kappa,
7422 );
7423 // Same unscaling as `curr_unscaled_complementarity_max`:
7424 // the slack's row factor and the multiplier's cancel in
7425 // the product, leaving the objective factor. Magnitude —
7426 // `obj_scaling_factor` is signed, `-1` being the
7427 // documented way to pose a maximization.
7428 let df = cq.obj_scaling_factor().abs();
7429 stats.final_unscaled_compl = if df == 0.0 || df == 1.0 {
7430 compl_declared
7431 } else {
7432 compl_declared / df
7433 };
7434 stats.final_unscaled_kkt_error = stats
7435 .final_unscaled_dual_inf
7436 .max(stats.final_unscaled_constr_viol)
7437 .max(stats.final_unscaled_compl);
7438 }
7439 }
7440 }
7441
7442 // Never report `Infeasible_Problem_Detected` while holding a point that
7443 // satisfies every constraint. The gates that produce this verdict argue
7444 // from a stalled feasibility sub-problem, and gh #379 is what that looks
7445 // like when the argument is wrong — a model whose own starting point is
7446 // exactly feasible, reported infeasible. See
7447 // `withdraw_infeasibility_if_refuted`.
7448 let solver_status =
7449 withdraw_infeasibility_if_refuted(&tnlp, solver_status, lo_inf, up_inf, tol);
7450
7451 // Map SolverReturn → ApplicationReturnStatus per
7452 // MAIN_LOOP.md's exception table, then apply the opt-in
7453 // status-fidelity gate (pounce#173).
7454 let app_status = self.apply_kkt_fidelity_gate(solver_return_to_app_status(solver_status));
7455 // gh#986 item 5: `mehrotra_algorithm=yes` runs with no line search
7456 // (every trial step is accepted), which is sound on LP / convex QP and
7457 // not on a general NLP — there an unglobalized step can walk the
7458 // iterate into an infeasible region that restoration then cannot
7459 // leave. (A *feasible* iterate is no longer sent to restoration by the
7460 // probing guard: it is recentred, gh#986 item 5, so this no longer
7461 // fires on the guard's own account.) Say so when it happens.
7462 if app_status == ApplicationReturnStatus::RestorationFailed
7463 && matches!(
7464 self.options.get_string_value("mehrotra_algorithm", ""),
7465 Ok((ref v, true)) if v == "yes"
7466 )
7467 {
7468 tracing::warn!(
7469 target: "pounce::algorithm",
7470 "pounce: restoration failed under mehrotra_algorithm=yes. That option \
7471 disables the line search and is intended for LPs and convex QPs; on a \
7472 general NLP every trial step is accepted, so the iterate can leave the \
7473 region restoration can repair, and a successful run may stop at a \
7474 different local solution than the default algorithm finds. Retry with \
7475 the default (mehrotra_algorithm=no)."
7476 );
7477 }
7478
7479 // On convergence, fire the user-supplied callback (post-optimal
7480 // sensitivity hook, pounce#16) before flowing back through
7481 // `finalize_via_orig_nlp`. Borrowed handles into the converged
7482 // KKT state stay alive for the duration of the closure.
7483 if matches!(
7484 app_status,
7485 ApplicationReturnStatus::SolveSucceeded
7486 | ApplicationReturnStatus::SolvedToAcceptableLevel
7487 ) {
7488 if let Some(cb) = self.on_converged.as_mut() {
7489 // `data.w` is one iterate behind here: the main loop checks
7490 // convergence before it updates the Hessian, so it still holds
7491 // `W(w_k)` from the final Newton step while every other block
7492 // the hook factors (Jacobians, slacks, multipliers, `Σ`) is
7493 // read at the converged point `w*`. Re-evaluate it so the
7494 // sensitivity matrix is the KKT Jacobian at `w*` rather than a
7495 // hybrid of two iterates. Updaters with no `W` to re-evaluate
7496 // (the quasi-Newton ones) answer `None` and keep theirs.
7497 // See `set_on_converged_refreshes_hessian`.
7498 if self.on_converged_refreshes_hessian
7499 && let Some(w) = alg.bundle.hess.hessian_at_current(&alg.data, &alg.cq)
7500 {
7501 alg.data.borrow_mut().w = Some(w);
7502 }
7503 if let Some(sd) = alg.search_dir.as_mut() {
7504 let pd = sd.pd_solver_rc();
7505 // Tag the firing before it runs, so the hook can file what
7506 // it keeps under the id `finalize_via_orig_nlp` then
7507 // stamps on this attempt's payload.
7508 let id = self.on_converged_fired.get() + 1;
7509 self.on_converged_fired.set(id);
7510 alg.data.borrow_mut().on_converged_id = Some(id);
7511 cb(&alg.data, &alg.cq, &nlp_handle, pd);
7512 }
7513 }
7514 }
7515
7516 // Finalize: forward the final iterate to the user's TNLP. The
7517 // returned objective is evaluated on the *user* TNLP at the
7518 // unscaled iterate, so it overrides the scaled best-effort
7519 // value stashed in `final_objective` above (the algorithm-side
7520 // `eval_f` returns `f * obj_scale_factor`).
7521 if solver_status != SolverReturn::InvalidProblemDefinition {
7522 match finalize_via_orig_nlp(
7523 &nlp_handle,
7524 &alg,
7525 solver_status,
7526 app_status,
7527 &tnlp,
7528 &self.last_finalize,
7529 self.reports_fixed_var_duals(),
7530 ) {
7531 Ok(f_unscaled) => {
7532 self.statistics.borrow_mut().final_objective = f_unscaled;
7533 }
7534 Err(()) => {}
7535 }
7536 }
7537
7538 // End-of-solve timing report. Gated on `print_timing_statistics`
7539 // (default "no"); mirrors upstream's
7540 // `IpoptApplication::call_optimize` →
7541 // `IpTimingStatistics::PrintAllValues` call site. The report
7542 // goes to stdout (for parity with the banner / iter-row output
7543 // path) and is also fanned out to the journalist so an
7544 // `output_file` attached via `Initialize` picks it up.
7545 let print_timing = self
7546 .options
7547 .get_bool_value("print_timing_statistics", "")
7548 .ok()
7549 .and_then(|(v, f)| f.then_some(v))
7550 .unwrap_or(false);
7551 if print_timing {
7552 let report = timing.report();
7553 print!("{}", report);
7554 use pounce_common::journalist::{JournalCategory, JournalLevel};
7555 self.journalist.print(
7556 JournalLevel::J_SUMMARY,
7557 JournalCategory::J_TIMING_STATISTICS,
7558 &report,
7559 );
7560 }
7561
7562 // End-of-run summary, Ipopt-style, emitted last (after any timing
7563 // report) from the engine's own statistics (#206). Drains the eval
7564 // tallies into SolveStatistics (read AFTER finalize so the final
7565 // solution evaluation is included) and prints the summary, gated on
7566 // the same print_level as the rest of the console.
7567 self.emit_end_summary(app_status, &nlp_handle, console_output);
7568
7569 app_status
7570 }
7571
7572 /// Build an [`AlgorithmBuilder`] populated from the app's
7573 /// [`OptionsList`]. Public so callers wiring the restoration
7574 /// factory can hand the *inner* IPM a builder that mirrors the
7575 /// outer's `mu_strategy`/`mu_oracle`/line-search choices —
7576 /// matching upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`,
7577 /// which reads the same `mu_strategy` option with prefix `"resto."
7578 /// + prefix` and falls back to the outer setting.
7579 pub fn algorithm_builder_from_options(&self) -> AlgorithmBuilder {
7580 let mut builder = AlgorithmBuilder::new();
7581 // gh#857: share this application's escalation tally. Every
7582 // frontend builds the restoration provider's inner builder from
7583 // this method, so restoration escalations aggregate here too.
7584 builder.quality_escalation_counter = Some(Rc::clone(&self.quality_escalations));
7585 // Structured-KKT Phase 5b: the same reasoning, one solve later. The
7586 // partition this solve resolves is not known yet — it is derived from
7587 // a KKT layout that does not exist until `optimize` builds it — so the
7588 // builder takes the cell rather than the labels, and reads it when
7589 // restoration actually runs. Block-path factorizations record into the
7590 // restoration sink, so `linear_solver.restoration` reports them where
7591 // the rest of restoration's linear algebra is reported.
7592 if let Ok((v, _)) = self.options.get_integer_value("kkt_block_min_size", "") {
7593 builder.kkt_block_min_size = v.max(0) as usize;
7594 }
7595 builder.set_kkt_blocks_cell(Arc::clone(&self.kkt_blocks_published));
7596 builder.set_kkt_schur_summary_sink(Arc::clone(&self.resto_linsol_summary_sink));
7597
7598 // `mehrotra_algorithm` is parsed first so its cascading
7599 // defaults (mu_strategy=adaptive, mu_oracle=probing) can be
7600 // overridden by an explicit user setting of those keys
7601 // below. Mirrors `IpAlgBuilder.cpp:Mehrotra`.
7602 // `fast_step_computation` — skip the search-direction residual
7603 // check and allow an inexact linear solve. `PdSearchDirCalc` has
7604 // consumed this flag since it landed, hard-coded to `false`; the
7605 // option's read site was simply missing, so setting it did
7606 // nothing at all (gh#483 follow-up, #191 round 2).
7607 if let Ok((v, true)) = self.options.get_string_value("fast_step_computation", "") {
7608 builder.fast_step_computation = v.eq_ignore_ascii_case("yes");
7609 }
7610
7611 let mut mehrotra_on = false;
7612 if let Ok((v, found)) = self.options.get_string_value("mehrotra_algorithm", "") {
7613 if found && v == "yes" {
7614 mehrotra_on = true;
7615 builder.mehrotra_algorithm = true;
7616 builder.mu_strategy = MuStrategyChoice::Adaptive;
7617 builder.mu_oracle = crate::mu::adaptive::MuOracleKind::Probing;
7618 // `accept_every_trial_step` short-circuits the alpha
7619 // loop / filter — Mehrotra steps would otherwise be
7620 // rejected by the filter on LP-shaped problems because
7621 // the barrier objective is non-monotone along the
7622 // corrector. Mirrors upstream `IpAlgBuilder.cpp:Mehrotra`.
7623 builder.line_search.accept_every_trial_step = true;
7624 // Aggressive iterate-push defaults (`SetNumericValueIfUnset`
7625 // in upstream). The explicit user parses below will
7626 // overwrite these if the user set them explicitly.
7627 builder.init.bound_push = 10.0;
7628 builder.init.bound_frac = 0.2;
7629 builder.init.slack_bound_push = 10.0;
7630 builder.init.slack_bound_frac = 0.2;
7631 builder.init.bound_mult_init_val = 10.0;
7632 builder.init.constr_mult_init_max = 0.0;
7633 // `alpha_for_y=bound-mult` — Mehrotra wants the
7634 // equality multipliers to advance with the dual
7635 // alpha so they stay in step with z/v. Mirrors
7636 // upstream `IpIpoptAlg.cpp:InitializeImpl`.
7637 builder.line_search.alpha_for_y =
7638 crate::line_search::backtracking::AlphaForY::BoundMult;
7639 // `adaptive_mu_globalization=never-monotone-mode` —
7640 // upstream `IpIpoptAlg.cpp:148-154` enforces this:
7641 // Mehrotra disables the globalization switch entirely
7642 // (no fallback to monotone mode when convergence
7643 // stalls). Required for the unsafeguarded Mehrotra
7644 // path to function.
7645 builder.mu.adaptive_mu_globalization =
7646 crate::mu::adaptive::AdaptiveMuGlobalization::NeverMonotoneMode;
7647 // `least_square_init_primal=yes` — upstream
7648 // `IpIpoptAlg.cpp:182` enables this for the Mehrotra
7649 // cascade. Replaces the user's starting `x` with the
7650 // min-norm primal that satisfies the linearized
7651 // equality+inequality constraints. Critical on
7652 // LP-shaped problems where the user's starting point
7653 // can be wildly infeasible (e.g. nuffield2_trap).
7654 builder.init.least_square_init_primal = true;
7655 }
7656 }
7657
7658 if let Ok((v, found)) = self.options.get_string_value("mu_strategy", "") {
7659 if found {
7660 let parsed = match v.as_str() {
7661 "adaptive" => MuStrategyChoice::Adaptive,
7662 _ => MuStrategyChoice::Monotone,
7663 };
7664 if mehrotra_on && matches!(parsed, MuStrategyChoice::Monotone) {
7665 // Upstream Ipopt refuses this combination: Mehrotra
7666 // needs an affine step every iter, which only the
7667 // adaptive path computes. Keep adaptive and warn.
7668 tracing::warn!(target: "pounce::algorithm",
7669 "pounce: mehrotra_algorithm=yes requires \
7670 mu_strategy=adaptive; ignoring \
7671 mu_strategy=monotone."
7672 );
7673 } else {
7674 builder.mu_strategy = parsed;
7675 }
7676 }
7677 }
7678 if let Ok((v, found)) = self.options.get_string_value("mu_oracle", "") {
7679 if found {
7680 builder.mu_oracle = match v.as_str() {
7681 "loqo" => crate::mu::adaptive::MuOracleKind::Loqo,
7682 "probing" => crate::mu::adaptive::MuOracleKind::Probing,
7683 _ => crate::mu::adaptive::MuOracleKind::QualityFunction,
7684 };
7685 }
7686 }
7687 if let Ok((v, found)) = self
7688 .options
7689 .get_string_value("adaptive_mu_globalization", "")
7690 {
7691 if found {
7692 use crate::mu::adaptive::AdaptiveMuGlobalization;
7693 builder.mu.adaptive_mu_globalization = match v.as_str() {
7694 "kkt-error" => AdaptiveMuGlobalization::KktError,
7695 "never-monotone-mode" => AdaptiveMuGlobalization::NeverMonotoneMode,
7696 _ => AdaptiveMuGlobalization::ObjConstrFilter,
7697 };
7698 }
7699 }
7700 if let Ok((v, found)) = self.options.get_string_value("hessian_approximation", "") {
7701 if found {
7702 builder.hessian_approximation = match v.as_str() {
7703 "limited-memory" => HessianApproxChoice::LimitedMemory,
7704 "partitioned" => HessianApproxChoice::Partitioned,
7705 "finite-difference" => HessianApproxChoice::FiniteDifference,
7706 _ => HessianApproxChoice::Exact,
7707 };
7708 }
7709 }
7710 // **Upstream changes the `mu_strategy` default for a
7711 // limited-memory Hessian.** `IpAlgBuilder.cpp:1059`:
7712 //
7713 // if( !options.GetStringValue("mu_strategy", smuupdate, prefix) )
7714 // {
7715 // // Change default for quasi-Newton option (then we use adaptive)
7716 // ... if( hessian_approximation == LIMITED_MEMORY )
7717 // smuupdate = "adaptive";
7718 // }
7719 //
7720 // and again at `:920` for the restoration-phase algorithm.
7721 // Registered default is `monotone`; the quasi-Newton path takes
7722 // `adaptive` unless the caller says otherwise. pounce read the
7723 // registered default unconditionally, so every L-BFGS solve ran
7724 // a barrier schedule Ipopt does not use on that path — a
7725 // trajectory divergence on the arm the Python frontend and the
7726 // CasADi plugin select automatically (gh#746).
7727 //
7728 // The restoration sub-IPM inherits this: `run_inner_resto`
7729 // clones the configured `inner_alg_builder`, so the flag set
7730 // here is what the resto algorithm gets, matching `:920`.
7731 //
7732 // Only when unset — an explicit `mu_strategy` still wins, and
7733 // `mehrotra_algorithm` (parsed above) has already forced
7734 // adaptive on its own terms.
7735 if builder.hessian_approximation == HessianApproxChoice::LimitedMemory
7736 && !self.mu_strategy_was_set()
7737 {
7738 builder.mu_strategy = MuStrategyChoice::Adaptive;
7739 }
7740 // Limited-memory quasi-Newton update formula. Registered upstream
7741 // (`limited_memory_update_type`, IpLimMemQuasiNewtonUpdater.cpp) but
7742 // until now read nowhere on the IPM path — the updater was hard-wired
7743 // to Powell-damped BFGS. SR1 is honored too (the updater and the
7744 // low-rank/inertia path already handle its indefinite models).
7745 // Partitioned quasi-Newton knobs. `partitioned_update_type`
7746 // defaults to SR1 rather than BFGS — see
7747 // `crates::hess::partitioned_quasi_newton` for why damping is
7748 // the wrong choice on a per-constraint element.
7749 if let Ok((v, found)) = self.options.get_string_value("partitioned_update_type", "") {
7750 if found {
7751 builder.partitioned_update_type = match v.as_str() {
7752 "bfgs" => UpdateType::Bfgs,
7753 _ => UpdateType::Sr1,
7754 };
7755 builder.partitioned_update_type_was_set = true;
7756 }
7757 }
7758 if let Ok((v, found)) = self
7759 .options
7760 .get_integer_value("partitioned_max_element", "")
7761 {
7762 if found && v > 0 {
7763 builder.partitioned_max_element = v as usize;
7764 }
7765 }
7766 if let Ok((v, found)) = self.options.get_string_value("fd_hessian_pattern", "") {
7767 if found {
7768 builder.fd_hessian_pattern = match v.as_str() {
7769 "jacobian" => crate::hess::fd_hessian::FdPatternSource::Jacobian,
7770 _ => crate::hess::fd_hessian::FdPatternSource::Declared,
7771 };
7772 }
7773 }
7774 if let Ok((v, found)) = self.options.get_string_value("fd_hessian_coloring", "") {
7775 if found {
7776 builder.fd_hessian_coloring = match v.as_str() {
7777 "cpr" => crate::hess::fd_hessian::FdColoring::Cpr,
7778 _ => crate::hess::fd_hessian::FdColoring::Star,
7779 };
7780 }
7781 }
7782 if let Ok((v, found)) = self.options.get_numeric_value("fd_hessian_reuse_tol", "") {
7783 if found && v >= 0.0 {
7784 builder.fd_hessian_reuse_tol = v;
7785 }
7786 }
7787 if let Ok((v, found)) = self.options.get_string_value("partitioned_elements", "") {
7788 if found {
7789 builder.partitioned_elements = match v.as_str() {
7790 "blocks" => crate::hess::partitioned_quasi_newton::ElementMode::PrimalBlock,
7791 _ => crate::hess::partitioned_quasi_newton::ElementMode::PerConstraint,
7792 };
7793 }
7794 }
7795 if let Ok((v, found)) = self.options.get_integer_value("partitioned_block_size", "") {
7796 if found && v > 0 {
7797 builder.partitioned_block_size = v as usize;
7798 }
7799 }
7800 if let Ok((v, found)) = self
7801 .options
7802 .get_numeric_value("partitioned_curvature_cap", "")
7803 {
7804 if found && v > 0.0 {
7805 builder.partitioned_curvature_cap = v;
7806 }
7807 }
7808 if let Ok((v, found)) = self
7809 .options
7810 .get_string_value("limited_memory_update_type", "")
7811 {
7812 if found {
7813 builder.limited_memory_update_type = match v.as_str() {
7814 "sr1" => UpdateType::Sr1,
7815 _ => UpdateType::Bfgs,
7816 };
7817 }
7818 }
7819 // Limited-memory history length (`limited_memory_max_history`).
7820 if let Ok((v, found)) = self
7821 .options
7822 .get_integer_value("limited_memory_max_history", "")
7823 {
7824 if found && v >= 0 {
7825 builder.limited_memory_max_history = v as Index;
7826 }
7827 }
7828 // `limited_memory_initialization` — which formula picks the
7829 // initial Hessian scalar σ. Registered since the option port with
7830 // upstream's `scalar1` default and read nowhere until #677, so
7831 // the updater's own `Scalar2` default was the only value any
7832 // solve ever used: setting the option did nothing, and it warned
7833 // nothing. Same miss as gh#483 / #191 round 2 (which wired
7834 // `limited_memory_init_val_max`/`_min`) — this is the third
7835 // argument to that same `initial_hessian_scalar` call.
7836 //
7837 // The effective default now follows the registry (`scalar1`),
7838 // matching Ipopt. σ_scalar2/σ_scalar1 = (yᵀy·sᵀs)/(sᵀy)² ≥ 1 and
7839 // is unbounded as the curvature pair degrades, so on an
7840 // ill-conditioned problem `scalar2` inflates `B0 = σI` until it
7841 // swamps the rank-2 corrections and the step collapses.
7842 if let Ok((v, found)) = self
7843 .options
7844 .get_string_value("limited_memory_initialization", "")
7845 {
7846 if found {
7847 use crate::hess::lim_mem_quasi_newton::InitialApprox;
7848 // Every registered value is named explicitly. #551 §3
7849 // held this option back precisely because wiring only
7850 // the values that mapped would leave the rest falling
7851 // back silently — a new no-op created by the fix — so a
7852 // catch-all standing in for a real value is the one
7853 // shape to avoid here. `OptionsList` rejects any
7854 // unregistered value before this runs (`options_list.rs`
7855 // `OPTION_INVALID`), so the final arm is unreachable
7856 // rather than a fallback.
7857 builder.limited_memory_initialization = match v.as_str() {
7858 "scalar1" => InitialApprox::Scalar1,
7859 "scalar2" => InitialApprox::Scalar2,
7860 "scalar3" => InitialApprox::Scalar3,
7861 "scalar4" => InitialApprox::Scalar4,
7862 "constant" => InitialApprox::Constant,
7863 "history-max" => InitialApprox::HistoryMax,
7864 _ => InitialApprox::Scalar2,
7865 };
7866 }
7867 }
7868 // `recalc_y` / `recalc_y_feas_tol` — least-square re-estimation
7869 // of the equality multipliers once feasible (#677).
7870 //
7871 // Upstream registers `recalc_y` as `no`, but its own option text
7872 // ends "If a limited memory quasi-Newton option is chosen, this
7873 // is used by default", so upstream's effective default is
7874 // conditional on the Hessian approximation.
7875 //
7876 // **pounce does not follow that, and the discrepancy is
7877 // deliberate.** Auto-enabling it for the limited-memory path was
7878 // implemented and measured against the fixture corpus first: it
7879 // moved 16 of 57 fixtures on the L-BFGS leg and took **7 from
7880 // solved to not solved, with nothing moving the other way** —
7881 // `airport` 56 it → `SearchDirectionBecomesTooSmall` at 541,
7882 // `pooling_rt2stp` 413 it → the same at 1775, all three `jit1`
7883 // variants, `linear_eq_collapsed_box`, and `hs13_bigstart` to
7884 // the iteration cap. The signature is consistent: re-estimating
7885 // `y` on every feasible iteration overwrites Newton multipliers
7886 // that were converging, the dual never settles, and the step
7887 // vanishes short of the certificate.
7888 //
7889 // So the feature is available and off by default. That still
7890 // closes the gap that mattered — until #677 the option was
7891 // refused outright as unimplemented, so an L-BFGS user could not
7892 // reach Ipopt's behaviour at all. They can now, by asking.
7893 //
7894 // Why it is worth having: a quasi-Newton model's dual step is
7895 // computed from an approximate `W`, so L-BFGS can settle a
7896 // feasible primal and still not drive `inf_du` to tolerance —
7897 // the failure a 59,939-variable CasADi model hit, oscillating
7898 // `inf_du` between 3.6e-3 and 1.8e+01 for 300 iterations with
7899 // the objective already settled. On that shape it is the fix;
7900 // on a corpus of small well-conditioned models it is a
7901 // pessimisation. Matching upstream's conditional default needs
7902 // to explain the 7 regressions first.
7903 if let Ok((v, true)) = self.options.get_string_value("recalc_y", "") {
7904 builder.recalc_y = v == "yes";
7905 }
7906 if let Ok((v, true)) = self.options.get_numeric_value("recalc_y_feas_tol", "") {
7907 builder.recalc_y_feas_tol = v;
7908 }
7909 // `limited_memory_init_val` — σ before any curvature pair exists,
7910 // and every iteration under `constant`. Also unread until #677;
7911 // the empty-history branch hard-coded the same `1.0`.
7912 if let Ok((v, true)) = self
7913 .options
7914 .get_numeric_value("limited_memory_init_val", "")
7915 {
7916 builder.limited_memory_init_val = v;
7917 }
7918 // `limited_memory_max_skipping` (#686) — registered and unread,
7919 // and the feature behind it did not exist either: the updater
7920 // counted nothing and never discarded its history.
7921 if let Ok((v, true)) = self
7922 .options
7923 .get_integer_value("limited_memory_max_skipping", "")
7924 {
7925 if v >= 0 {
7926 builder.limited_memory_max_skipping = v as Index;
7927 }
7928 }
7929 if let Ok((v, found)) = self.options.get_string_value("line_search_method", "") {
7930 if found {
7931 builder.line_search_method = match v.as_str() {
7932 // Unreachable from a real solve since gh#981:
7933 // `cg-penalty` is refused by
7934 // `unimplemented_options::UNIMPLEMENTED_VALUES`
7935 // before the builder is configured, because the
7936 // variant assembles the plain penalty acceptor.
7937 "cg-penalty" => LineSearchChoice::CgPenalty,
7938 "penalty" => LineSearchChoice::Penalty,
7939 _ => LineSearchChoice::Filter,
7940 };
7941 }
7942 }
7943 // `accept_every_trial_step` — direct user override. Parsed
7944 // after the Mehrotra cascade so an explicit `no` still wins.
7945 if let Ok((v, found)) = self.options.get_string_value("accept_every_trial_step", "") {
7946 if found {
7947 builder.line_search.accept_every_trial_step = v == "yes";
7948 }
7949 }
7950 // `alpha_for_y` — direct user override. Parsed after the
7951 // Mehrotra cascade so an explicit value still wins.
7952 if let Ok((v, found)) = self.options.get_string_value("alpha_for_y", "") {
7953 if found {
7954 use crate::line_search::backtracking::AlphaForY;
7955 builder.line_search.alpha_for_y = match v.as_str() {
7956 "primal" => AlphaForY::Primal,
7957 "bound-mult" | "bound_mult" => AlphaForY::BoundMult,
7958 "full" => AlphaForY::Full,
7959 "min" => AlphaForY::Min,
7960 "max" => AlphaForY::Max,
7961 "primal-and-full" | "dual-and-full" => AlphaForY::Primal,
7962 _ => AlphaForY::Primal,
7963 };
7964 }
7965 }
7966 // `nlp_scaling_method` is consumed NLP-side in
7967 // `OrigIpoptNlp::determine_scaling_from_starting_point` (see the
7968 // `determine_scaling_from_starting_point` call earlier in this
7969 // method); there is no algorithm-side scaling strategy to wire.
7970 // `limited_memory_init_val_max` / `_min` — the clamp on the
7971 // initial Hessian scalar. `LimMemQuasiNewtonUpdater` consumes
7972 // both in `initial_hessian_scalar`; only the read sites were
7973 // missing, so setting either did nothing (gh#483, #191 round 2).
7974 if let Ok((v, true)) = self
7975 .options
7976 .get_numeric_value("limited_memory_init_val_max", "")
7977 {
7978 builder.limited_memory_init_val_max = v;
7979 }
7980 if let Ok((v, true)) = self
7981 .options
7982 .get_numeric_value("limited_memory_init_val_min", "")
7983 {
7984 builder.limited_memory_init_val_min = v;
7985 }
7986
7987 // Unlike the other options here, we always honor the registry
7988 // value (not just when the user set it explicitly): the option
7989 // registry default is "ma57" but `AlgorithmBuilder::default`
7990 // has `linear_solver: Feral`, so gating on `found` would
7991 // silently route default runs through Feral while the banner
7992 // (and ipopt-compatible behavior) advertises MA57.
7993 //
7994 // Record the **effective** backend, not the requested one. MA57 lives
7995 // behind the optional `ma57` cargo feature (HSL is licensed and needs a
7996 // Fortran toolchain); without it `default_backend_factory` silently
7997 // substitutes FERAL. Storing `Ma57` here therefore made
7998 // `builder.linear_solver` disagree with the backend actually built, and
7999 // consumers acted on the lie: the Schur KKT gate in
8000 // `alg_builder::build_with_backend` tests `== Feral`, so on the
8001 // pure-Rust default build — where the registry default (then upstream's
8002 // "ma57") resolved to FERAL anyway — `set_kkt_schur_block()` silently
8003 // never engaged for ANY user. Resolving here keeps the field truthful
8004 // for every consumer.
8005 //
8006 // The `_ =>` arm is now only reachable for `feral`: every other name
8007 // is refused up front by `unimplemented_linear_solver`. It used to
8008 // swallow `mumps`, `pardiso`, `ma97`, … and run FERAL instead.
8009 if let Ok((v, _found)) = self.options.get_string_value("linear_solver", "") {
8010 let requested = if v.eq_ignore_ascii_case("ma57") {
8011 LinearSolverChoice::Ma57
8012 } else {
8013 LinearSolverChoice::Feral
8014 };
8015 builder.linear_solver =
8016 if matches!(requested, LinearSolverChoice::Ma57) && !cfg!(feature = "ma57") {
8017 LinearSolverChoice::Feral
8018 } else {
8019 requested
8020 };
8021 }
8022
8023 // `linear_system_scaling` — symmetric scaling of the augmented
8024 // KKT matrix before factorization. Port of
8025 // `IpTSymLinearSolver.cpp:RegisterOptions` plumbing. Default
8026 // "none"; "ruiz" invokes the Ruiz-2001 symmetric ∞-norm
8027 // equilibration in `RuizTSymScalingMethod`. "mc19" and
8028 // "slack-based" are accepted by the registry but not yet
8029 // implemented at this layer; they fall back to no scaling
8030 // with a one-line notice.
8031 //
8032 // `slack-based` is implemented as of #677. It used to reach the
8033 // no-scaling fallback through the catch-all arm, which meant it
8034 // fell back **silently** — the comment above promised a notice
8035 // that only `mc19` actually emitted. It is not a hypothetical
8036 // value: it is what Ipopt's own recommended configuration for
8037 // large collocation NLPs uses, so the users most likely to set
8038 // it were the least likely to be told it did nothing. The
8039 // catch-all is left for genuinely unreachable input —
8040 // `OptionsList` rejects anything the registry does not list.
8041 if let Ok((v, found)) = self.options.get_string_value("linear_system_scaling", "") {
8042 if found {
8043 builder.linear_system_scaling = match v.as_str() {
8044 "ruiz" => crate::alg_builder::LinearSystemScalingChoice::Ruiz,
8045 "mc19" => crate::alg_builder::LinearSystemScalingChoice::Mc19,
8046 "slack-based" => crate::alg_builder::LinearSystemScalingChoice::SlackBased,
8047 _ => crate::alg_builder::LinearSystemScalingChoice::None,
8048 };
8049 }
8050 }
8051 if let Ok((v, found)) = self.options.get_bool_value("linear_scaling_on_demand", "") {
8052 if found {
8053 builder.linear_scaling_on_demand = v;
8054 }
8055 }
8056
8057 // Convergence tolerances (port of `IpOptErrorConvCheck.cpp`'s
8058 // `RegisterOptions` consumers). Defaults already match upstream
8059 // — only override when the user set the key explicitly.
8060 let read_num = |key: &str| -> Option<f64> {
8061 self.options
8062 .get_numeric_value(key, "")
8063 .ok()
8064 .and_then(|(v, f)| f.then_some(v))
8065 };
8066 let read_int = |key: &str| -> Option<i32> {
8067 self.options
8068 .get_integer_value(key, "")
8069 .ok()
8070 .and_then(|(v, f)| f.then_some(v))
8071 };
8072 if let Some(v) = read_num("tol") {
8073 builder.conv_check.tol = v;
8074 }
8075 if let Some(v) = read_num("obj_scale_certificate_threshold") {
8076 builder.conv_check.obj_scale_certificate_threshold = v;
8077 }
8078 if let Some(v) = read_num("primal_noise_floor_kappa") {
8079 builder.conv_check.primal_noise_floor_kappa = v;
8080 }
8081 if let Some(v) = read_num("acceptable_progress_kappa") {
8082 builder.conv_check.acceptable_progress_kappa = v;
8083 }
8084 if let Some(v) = read_num("dual_inf_scale_kappa") {
8085 builder.conv_check.dual_inf_scale_kappa = v;
8086 }
8087 if let Some(v) = read_num("kkt_fidelity_tol") {
8088 builder.kkt_fidelity_tol = v;
8089 }
8090 if let Some(v) = read_num("dual_inf_tol") {
8091 builder.conv_check.dual_inf_tol = v;
8092 // gh#983 item 1: a caller who names `dual_inf_tol` has asked for
8093 // that absolute standard. The scale-relative floor (gh#532) is a
8094 // default for a caller who has not, and it let `1e-6` certify a
8095 // point with `‖∇L‖∞ = 0.144` because the multipliers were 3.5e9
8096 // (a failed constraint qualification, which is not a reason to
8097 // loosen the test). Naming `dual_inf_scale_kappa` as well opts the
8098 // floor back in.
8099 //
8100 // gh#983 review: a value equal to the registered default is read
8101 // as "not named". Front ends that echo every default (option
8102 // files copied from Ipopt's documentation, wrappers that pass the
8103 // full table) would otherwise lose the gh#532 floor without asking
8104 // for anything. That is safe because the floor with the default
8105 // tolerance IS the default behaviour; a caller who wants the
8106 // absolute standard at `1` names `dual_inf_scale_kappa 0`, which
8107 // the option help documents.
8108 let default_tol = crate::alg_builder::ConvCheckOptions::default().dual_inf_tol;
8109 if read_num("dual_inf_scale_kappa").is_none() && v != default_tol {
8110 builder.conv_check.dual_inf_scale_kappa = 0.0;
8111 }
8112 }
8113 if let Some(v) = read_num("constr_viol_tol") {
8114 builder.conv_check.constr_viol_tol = v;
8115 }
8116 if let Some(v) = read_num("compl_inf_tol") {
8117 builder.conv_check.compl_inf_tol = v;
8118 }
8119 if let Some(v) = read_int("max_iter") {
8120 builder.conv_check.max_iter = v;
8121 }
8122 if let Some(v) = read_num("max_cpu_time") {
8123 builder.conv_check.max_cpu_time = v;
8124 }
8125 if let Some(v) = read_num("max_wall_time") {
8126 builder.conv_check.max_wall_time = v;
8127 }
8128 if let Some(v) = read_num("acceptable_tol") {
8129 builder.conv_check.acceptable_tol = v;
8130 }
8131 if let Some(v) = read_num("acceptable_dual_inf_tol") {
8132 builder.conv_check.acceptable_dual_inf_tol = v;
8133 }
8134 if let Some(v) = read_num("acceptable_constr_viol_tol") {
8135 builder.conv_check.acceptable_constr_viol_tol = v;
8136 }
8137 if let Some(v) = read_num("acceptable_compl_inf_tol") {
8138 builder.conv_check.acceptable_compl_inf_tol = v;
8139 }
8140 if let Some(v) = read_num("acceptable_obj_change_tol") {
8141 builder.conv_check.acceptable_obj_change_tol = v;
8142 }
8143 if let Some(v) = read_int("acceptable_iter") {
8144 builder.conv_check.acceptable_iter = v;
8145 }
8146 if let Some(v) = read_num("infeas_stationarity_tol") {
8147 builder.conv_check.infeas_stationarity_tol = v;
8148 }
8149 if let Some(v) = read_num("infeas_viol_kappa") {
8150 builder.conv_check.infeas_viol_kappa = v;
8151 }
8152 if let Some(v) = read_int("infeas_max_streak") {
8153 builder.conv_check.infeas_max_streak = v;
8154 }
8155
8156 // Bound-multiplier / barrier damping constants (#191). Both were
8157 // registered but never read, so user overrides were silently
8158 // dropped; the algorithm ran with the hard-coded struct defaults.
8159 // Defaults equal the registered defaults, so this changes nothing
8160 // for a run that doesn't set them.
8161 if let Some(v) = read_num("kappa_sigma") {
8162 builder.kappa_sigma = v;
8163 }
8164 if let Some(v) = read_num("ftb_centrality_floor") {
8165 builder.ftb_centrality_floor = v;
8166 }
8167 if let Some(v) = read_num("kappa_d") {
8168 builder.kappa_d = v;
8169 }
8170 // `s_max` — the cap in the `(s_d, s_c)` scaling of the KKT error
8171 // test (#551 / #677). `IpoptCalculatedQuantities` carried it as a
8172 // hard-coded 100 (the registered default) and nothing read the
8173 // option; a run that does not set it is unaffected.
8174 if let Some(v) = read_num("s_max") {
8175 builder.s_max = v;
8176 }
8177 if let Some(v) = read_num("tiny_step_tol") {
8178 builder.tiny_step_tol = v;
8179 }
8180 if let Some(v) = read_num("tiny_step_y_tol") {
8181 builder.tiny_step_y_tol = v;
8182 }
8183 if let Some(v) = read_num("diverging_iterates_tol") {
8184 builder.diverging_iterates_tol = v;
8185 }
8186 if let Some(v) = read_int("dual_diverging_streak") {
8187 builder.dual_diverging_streak = v;
8188 }
8189 if let Some(v) = read_num("dual_divergence_retry_step_tol") {
8190 builder.dual_divergence_retry_step_tol = v;
8191 }
8192 if let Some(v) = read_num("dual_divergence_retry_du_floor") {
8193 builder.dual_divergence_retry_du_floor = v;
8194 }
8195 if let Some(v) = read_int("tiny_step_dual_stall_iters") {
8196 builder.tiny_step_dual_stall_iters = v;
8197 }
8198 if let Some(v) = read_int("resto_decline_deferrals") {
8199 builder.resto_decline_deferrals = v;
8200 }
8201 if let Some(v) = read_num("resto_decline_progress_ratio") {
8202 builder.resto_decline_progress_ratio = v;
8203 }
8204 if let Some(v) = read_int("neg_curv_escapes") {
8205 builder.neg_curv_escapes = v;
8206 }
8207 if let Some(v) = read_int("limited_memory_ls_failure_restarts") {
8208 builder.limited_memory_ls_failure_restarts = v;
8209 }
8210
8211 // Barrier-parameter (μ) options — consumers in
8212 // `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp`. Both
8213 // updaters share the same option names; the builder forwards
8214 // each into whichever strategy is assembled.
8215 if let Some(v) = read_num("mu_init") {
8216 builder.mu.mu_init = v;
8217 }
8218 if let Some(v) = read_num("mu_max") {
8219 builder.mu.mu_max = v;
8220 }
8221 if let Some(v) = read_num("mu_max_fact") {
8222 builder.mu.mu_max_fact = v;
8223 }
8224 if let Some(v) = read_num("mu_min") {
8225 builder.mu.mu_min = v;
8226 }
8227 if let Some(v) = read_num("mu_target") {
8228 builder.mu.mu_target = v;
8229 }
8230 if let Some(v) = read_num("mu_linear_decrease_factor") {
8231 builder.mu.mu_linear_decrease_factor = v;
8232 }
8233 if let Some(v) = read_num("mu_superlinear_decrease_power") {
8234 builder.mu.mu_superlinear_decrease_power = v;
8235 }
8236 if let Ok((v, found)) = self
8237 .options
8238 .get_string_value("mu_allow_fast_monotone_decrease", "")
8239 {
8240 if found {
8241 builder.mu.mu_allow_fast_monotone_decrease = v == "yes";
8242 }
8243 }
8244 if let Some(v) = read_num("barrier_tol_factor") {
8245 builder.mu.barrier_tol_factor = v;
8246 }
8247 // `tau_min` — floor on the fraction-to-the-boundary parameter
8248 // (#551 / #677). Both `MonotoneMuUpdate` and `AdaptiveMuUpdate`
8249 // carried the field with upstream's 0.99 default and nothing
8250 // read the option, so an override was silently dropped. The
8251 // default equals the registered default, so this changes
8252 // nothing for a run that does not set it.
8253 if let Some(v) = read_num("tau_min") {
8254 builder.mu.tau_min = v;
8255 }
8256 if let Some(v) = read_num("sigma_max") {
8257 builder.mu.sigma_max = v;
8258 }
8259 if let Some(v) = read_num("sigma_min") {
8260 builder.mu.sigma_min = v;
8261 }
8262
8263 // Quality-function oracle knobs — consumers in
8264 // `IpQualityFunctionMuOracle.cpp:RegisterOptions`. Forwarded
8265 // to the oracle on every free-mode call.
8266 if let Ok((v, found)) = self
8267 .options
8268 .get_string_value("quality_function_norm_type", "")
8269 {
8270 if found {
8271 use crate::mu::oracle::quality_function::NormType;
8272 builder.mu.quality_function_norm_type = match v.as_str() {
8273 "1-norm" => NormType::OneNorm,
8274 "2-norm" => NormType::TwoNorm,
8275 "max-norm" => NormType::MaxNorm,
8276 _ => NormType::TwoNormSquared,
8277 };
8278 }
8279 }
8280 if let Ok((v, found)) = self
8281 .options
8282 .get_string_value("quality_function_centrality", "")
8283 {
8284 if found {
8285 use crate::mu::oracle::quality_function::CentralityType;
8286 builder.mu.quality_function_centrality = match v.as_str() {
8287 "log" => CentralityType::LogCenter,
8288 "reciprocal" => CentralityType::ReciprocalCenter,
8289 "cubed-reciprocal" => CentralityType::CubedReciprocalCenter,
8290 _ => CentralityType::None,
8291 };
8292 }
8293 }
8294 if let Ok((v, found)) = self
8295 .options
8296 .get_string_value("quality_function_balancing_term", "")
8297 {
8298 if found {
8299 use crate::mu::oracle::quality_function::BalancingTermType;
8300 builder.mu.quality_function_balancing_term = match v.as_str() {
8301 "cubic" => BalancingTermType::CubicTerm,
8302 _ => BalancingTermType::None,
8303 };
8304 }
8305 }
8306 if let Some(v) = read_int("quality_function_max_section_steps") {
8307 builder.mu.quality_function_max_section_steps = v;
8308 }
8309 if let Some(v) = read_num("quality_function_section_sigma_tol") {
8310 builder.mu.quality_function_section_sigma_tol = v;
8311 }
8312 if let Some(v) = read_num("quality_function_section_qf_tol") {
8313 builder.mu.quality_function_section_qf_tol = v;
8314 }
8315
8316 // `probing_iterate_quality_factor` — pounce-specific guard
8317 // (pounce#58) on the probing μ-oracle's input iterate. When
8318 // `curr_avrg_compl / curr_mu` exceeds this factor, the
8319 // μ-update layer signals restoration via
8320 // `IpoptData::request_resto` instead of letting probing
8321 // return `σ · mu_curr` ≫ previous μ. Default 1e4; set to ≤ 0
8322 // to disable. No upstream Ipopt counterpart.
8323 if let Some(v) = read_num("probing_iterate_quality_factor") {
8324 builder.mu.probing_iterate_quality_factor = v;
8325 }
8326
8327 // Adaptive-μ extras — consumers in
8328 // `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Only active when
8329 // `mu_strategy=adaptive`.
8330 if let Some(v) = read_num("adaptive_mu_safeguard_factor") {
8331 builder.mu.adaptive_mu_safeguard_factor = v;
8332 }
8333 if let Some(v) = read_num("adaptive_mu_monotone_init_factor") {
8334 builder.mu.adaptive_mu_monotone_init_factor = v;
8335 }
8336 if let Ok((v, found)) = self
8337 .options
8338 .get_bool_value("adaptive_mu_restore_previous_iterate", "")
8339 {
8340 if found {
8341 builder.mu.adaptive_mu_restore_previous_iterate = v;
8342 }
8343 }
8344 if let Some(v) = read_int("adaptive_mu_max_free_returns") {
8345 builder.mu.adaptive_mu_max_free_returns = v;
8346 }
8347 if let Some(v) = read_num("adaptive_mu_budget_pin_fraction") {
8348 builder.mu.adaptive_mu_budget_pin_fraction = v;
8349 }
8350 if let Some(v) = read_int("adaptive_mu_kkterror_red_iters") {
8351 if v >= 0 {
8352 builder.mu.adaptive_mu_kkterror_red_iters = v as usize;
8353 }
8354 }
8355 if let Some(v) = read_num("adaptive_mu_kkterror_red_fact") {
8356 builder.mu.adaptive_mu_kkterror_red_fact = v;
8357 }
8358 // `filter_margin_fact` / `filter_max_margin` (#551) — the margin
8359 // an entry must clear in the `obj-constr-filter` globalization
8360 // test. `AdaptiveMuUpdate` computes
8361 // `filter_margin_fact * min(filter_max_margin, err)` and has
8362 // always done so; only these two read sites were missing, so
8363 // setting either did nothing. Defaults equal the registered
8364 // defaults (1e-5 / 1.0), so an unset run is unchanged.
8365 if let Some(v) = read_num("filter_margin_fact") {
8366 builder.mu.filter_margin_fact = v;
8367 }
8368 if let Some(v) = read_num("filter_max_margin") {
8369 builder.mu.filter_max_margin = v;
8370 }
8371 if let Ok((v, found)) = self
8372 .options
8373 .get_string_value("adaptive_mu_kkt_norm_type", "")
8374 {
8375 if found {
8376 use crate::mu::adaptive::AdaptiveMuKktNorm;
8377 builder.mu.adaptive_mu_kkt_norm_type = match v.as_str() {
8378 "1-norm" => AdaptiveMuKktNorm::OneNorm,
8379 "2-norm" => AdaptiveMuKktNorm::TwoNorm,
8380 "max-norm" => AdaptiveMuKktNorm::MaxNorm,
8381 _ => AdaptiveMuKktNorm::TwoNormSquared,
8382 };
8383 }
8384 }
8385
8386 // Watchdog options — consumers in
8387 // `IpBacktrackingLineSearch.cpp:RegisterOptions`. Baked into
8388 // the `BacktrackingLineSearch` at build time.
8389 if let Some(v) = read_int("watchdog_shortened_iter_trigger") {
8390 builder.line_search.watchdog_shortened_iter_trigger = v;
8391 }
8392 if let Some(v) = read_int("watchdog_trial_iter_max") {
8393 builder.line_search.watchdog_trial_iter_max = v;
8394 }
8395 if let Some(v) = read_num("soft_resto_pderror_reduction_factor") {
8396 builder.line_search.soft_resto_pderror_reduction_factor = v;
8397 }
8398 if let Some(v) = read_int("max_soft_resto_iters") {
8399 builder.line_search.max_soft_resto_iters = v;
8400 }
8401 // `alpha_red_factor` (#678) and `accept_after_max_steps` (#551)
8402 // — both consumed by the α-loop in `BacktrackingLineSearch`,
8403 // both registered without a read site until those issues.
8404 // `alpha_red_factor`'s default (0.5) equals the registered one,
8405 // and `accept_after_max_steps` defaults to `-1`, which disables
8406 // the escape hatch, so neither moves a solve that leaves them
8407 // alone.
8408 if let Some(v) = read_num("alpha_red_factor") {
8409 builder.line_search.alpha_red_factor = v;
8410 }
8411 if let Some(v) = read_num("alpha_red_factor_min") {
8412 builder.line_search.alpha_red_factor_min = Some(v);
8413 }
8414 if let Some(v) = read_int("accept_after_max_steps") {
8415 builder.line_search.accept_after_max_steps = v;
8416 }
8417
8418 // Filter switching / Armijo / margin constants (#191). Consumed
8419 // by `FilterLsAcceptor` (only on the `Filter` line-search path);
8420 // registered but never read, so overrides were silently dropped.
8421 // Defaults equal the registered defaults.
8422 if let Some(v) = read_num("eta_phi") {
8423 builder.line_search.eta_phi = v;
8424 }
8425 // `delta` (#551) — the switching rule's multiplier on the
8426 // constraint violation (Eqn. (19)); `FilterLsAcceptor` has
8427 // always used it as `delta_armijo`, only the read site was
8428 // missing. Default 1.0 equals the registered default.
8429 if let Some(v) = read_num("delta") {
8430 builder.line_search.delta = v;
8431 }
8432 if let Some(v) = read_num("theta_min_fact") {
8433 builder.line_search.theta_min_fact = v;
8434 }
8435 if let Some(v) = read_num("theta_max_row_scale_kappa") {
8436 builder.line_search.theta_max_row_scale_kappa = v;
8437 }
8438 if let Some(v) = read_int("theta_max_adaptive_trigger") {
8439 builder.line_search.theta_max_adaptive_trigger = v.max(0) as u32;
8440 }
8441 if let Some(v) = read_num("theta_max_adaptive_factor") {
8442 builder.line_search.theta_max_adaptive_factor = v;
8443 }
8444 if let Some(v) = read_int("theta_max_adaptive_max_raises") {
8445 builder.line_search.theta_max_adaptive_max_raises = v.max(0) as u32;
8446 }
8447 if let Some(v) = read_num("theta_max_fact") {
8448 builder.line_search.theta_max_fact = v;
8449 }
8450 if let Some(v) = read_num("gamma_phi") {
8451 builder.line_search.gamma_phi = v;
8452 }
8453 if let Some(v) = read_num("gamma_theta") {
8454 builder.line_search.gamma_theta = v;
8455 }
8456 if let Some(v) = read_num("s_phi") {
8457 builder.line_search.s_phi = v;
8458 }
8459 if let Some(v) = read_num("s_theta") {
8460 builder.line_search.s_theta = v;
8461 }
8462 if let Some(v) = read_num("alpha_min_frac") {
8463 builder.line_search.alpha_min_frac = v;
8464 }
8465 if let Some(v) = read_num("obj_max_inc") {
8466 builder.line_search.obj_max_inc = v;
8467 }
8468 if let Some(v) = read_int("max_filter_resets") {
8469 builder.line_search.max_filter_resets = v;
8470 }
8471 if let Some(v) = read_int("filter_reset_trigger") {
8472 builder.line_search.filter_reset_trigger = v;
8473 }
8474 // `filter_theta_roundoff_retry` (gh#945).
8475 if let Ok((v, true)) = self
8476 .options
8477 .get_bool_value("filter_theta_roundoff_retry", "")
8478 {
8479 builder.line_search.filter_theta_roundoff_retry = v;
8480 }
8481 // `objective_noise_wolfe` (gh#1016).
8482 if let Ok((v, true)) = self.options.get_bool_value("objective_noise_wolfe", "") {
8483 builder.line_search.objective_noise_wolfe = v;
8484 }
8485 // Penalty line-search constants (#551), consumed by
8486 // `PenaltyLsAcceptor` (only on the `line_search_method=penalty`
8487 // / `cg-penalty` paths). The acceptor implements ν and the
8488 // Armijo test on the penalty merit function already; these four
8489 // were registered with no read site, so tuning the penalty
8490 // update did nothing. Defaults equal the registered defaults.
8491 if let Some(v) = read_num("nu_init") {
8492 builder.line_search.nu_init = v;
8493 }
8494 if let Some(v) = read_num("nu_inc") {
8495 builder.line_search.nu_inc = v;
8496 }
8497 if let Some(v) = read_num("rho") {
8498 builder.line_search.rho = v;
8499 }
8500 if let Some(v) = read_num("eta_penalty") {
8501 builder.line_search.eta_penalty = v;
8502 }
8503
8504 // Second-order-correction constants (#191), consumed by
8505 // `BacktrackingLineSearch`. `max_soc = 0` disables SOC.
8506 if let Some(v) = read_int("max_soc") {
8507 builder.line_search.max_soc = v;
8508 }
8509 if let Some(v) = read_num("kappa_soc") {
8510 builder.line_search.kappa_soc = v;
8511 }
8512 if let Some(v) = read_int("soc_method") {
8513 builder.line_search.soc_method = v;
8514 }
8515
8516 // Inertia-correction / Jacobian-regularization constants (#191),
8517 // consumed by `PdPerturbationHandler`. Registered but never read.
8518 if let Some(v) = read_num("max_hessian_perturbation") {
8519 builder.perturbation.max_hessian_perturbation = v;
8520 }
8521 if let Some(v) = read_num("min_hessian_perturbation") {
8522 builder.perturbation.min_hessian_perturbation = v;
8523 }
8524 if let Some(v) = read_num("perturb_inc_fact_first") {
8525 builder.perturbation.perturb_inc_fact_first = v;
8526 }
8527 if let Some(v) = read_num("perturb_inc_fact") {
8528 builder.perturbation.perturb_inc_fact = v;
8529 }
8530 if let Some(v) = read_num("perturb_dec_fact") {
8531 builder.perturbation.perturb_dec_fact = v;
8532 }
8533 if let Some(v) = read_num("first_hessian_perturbation") {
8534 builder.perturbation.first_hessian_perturbation = v;
8535 }
8536 if let Some(v) = read_num("jacobian_regularization_value") {
8537 builder.perturbation.jacobian_regularization_value = v;
8538 }
8539 if let Some(v) = read_num("jacobian_regularization_exponent") {
8540 builder.perturbation.jacobian_regularization_exponent = v;
8541 }
8542 if let Ok((v, true)) = self.options.get_bool_value("perturb_always_cd", "") {
8543 builder.perturbation.perturb_always_cd = v;
8544 }
8545 if let Some(v) = read_int("perturb_delta_c_max_rungs") {
8546 builder.perturbation.perturb_delta_c_max_rungs = v;
8547 }
8548 if let Some(v) = read_num("perturb_slack_fact") {
8549 builder.perturbation.perturb_slack_fact = v;
8550 }
8551 if let Some(v) = read_int("perturb_slack_max_rows") {
8552 builder.perturbation.perturb_slack_max_rows = v;
8553 }
8554
8555 // Iterative-refinement constants (#191), consumed by
8556 // `PdFullSpaceSolver`. Registered but never read.
8557 if let Some(v) = read_int("min_refinement_steps") {
8558 builder.refinement.min_refinement_steps = v;
8559 }
8560 if let Some(v) = read_int("max_refinement_steps") {
8561 builder.refinement.max_refinement_steps = v;
8562 }
8563 if let Some(v) = read_num("residual_ratio_max") {
8564 builder.refinement.residual_ratio_max = v;
8565 }
8566 if let Some(v) = read_num("residual_ratio_singular") {
8567 builder.refinement.residual_ratio_singular = v;
8568 }
8569 if let Some(v) = read_num("residual_improvement_factor") {
8570 builder.refinement.residual_improvement_factor = v;
8571 }
8572
8573 // Inertia-free curvature test (#551 / #677), also consumed by
8574 // `PdFullSpaceSolver`. `neg_curv_test_tol` had a field that only
8575 // ever held its 0.0 default, and `neg_curv_test_reg` had none at
8576 // all; both are now read, and the curvature test they configure
8577 // is implemented in `PdFullSpaceSolver::solve_once`. At the
8578 // registered default (`0.0`) the heuristic is off and the
8579 // inertia check runs as before.
8580 if let Some(v) = read_num("neg_curv_test_tol") {
8581 builder.refinement.neg_curv_test_tol = v;
8582 }
8583 if let Ok((v, true)) = self.options.get_bool_value("neg_curv_test_reg", "") {
8584 builder.refinement.neg_curv_test_reg = v;
8585 }
8586
8587 // Restoration-phase constants (#191). Carried on the outer builder
8588 // and copied into the `RestoAlgorithmBuilder` when the restoration
8589 // factory is minted (the frontends pass this builder in). The
8590 // restoration builder was never options-configured, so these were
8591 // registered but never read. Defaults equal the registered
8592 // defaults.
8593 if let Some(v) = read_num("bound_mult_reset_threshold") {
8594 builder.resto.bound_mult_reset_threshold = v;
8595 }
8596 if let Some(v) = read_num("constr_mult_reset_threshold") {
8597 builder.resto.constr_mult_reset_threshold = v;
8598 }
8599 if let Some(v) = read_num("resto_penalty_parameter") {
8600 builder.resto.resto_penalty_parameter = v;
8601 }
8602 if let Some(v) = read_num("resto_proximity_weight") {
8603 builder.resto.resto_proximity_weight = v;
8604 }
8605 // `required_infeasibility_reduction` (#439) — the κ_resto guard the
8606 // restoration sub-solve exits on. Registered since #191 but the
8607 // value was hardcoded at the callsite, so setting it was a silent
8608 // no-op.
8609 if let Some(v) = read_num("required_infeasibility_reduction") {
8610 builder.resto.required_infeasibility_reduction = v;
8611 }
8612 // gh#483 / #191 round 2: three restoration switches whose fields
8613 // `RestoAlgorithmBuilder` has consumed all along — the read site
8614 // was the only missing piece, so setting them did nothing.
8615 let read_yes = |key: &str| -> Option<bool> {
8616 match self.options.get_string_value(key, "") {
8617 Ok((v, true)) => Some(v.eq_ignore_ascii_case("yes")),
8618 _ => None,
8619 }
8620 };
8621 if let Some(v) = read_yes("evaluate_orig_obj_at_resto_trial") {
8622 builder.resto.evaluate_orig_obj_at_resto_trial = v;
8623 }
8624 if let Some(v) = read_yes("expect_infeasible_problem") {
8625 builder.resto.expect_infeasible_problem = v;
8626 }
8627 if let Some(v) = read_yes("start_with_resto") {
8628 builder.resto.start_with_resto = v;
8629 }
8630 // `max_resto_iter` (#551 / #677) — the cap on *successive*
8631 // restoration iterations. `RestoConvCheckAdapter` has enforced a cap
8632 // all along (returning `MaxIterExceeded` at the limit); the number
8633 // it enforced was a hard-coded constant in `resto_inner_solver.rs`,
8634 // so setting the option did nothing. The consumer's field is
8635 // `maximum_resto_iters`, not the option name — grepping for
8636 // `max_resto_iter` found only the registry (#551 caution 2).
8637 //
8638 // DEFAULT MISMATCH, LEFT AS IT IS ON PURPOSE: the registry declares
8639 // upstream's 3000000, pounce's effective cap is 3000
8640 // (`RestoOptions::default`). `read_int` fires only when the user set
8641 // the key, so an unset `max_resto_iter` still means 3000 and this
8642 // wiring is trajectory-neutral. Adopting upstream's number would let
8643 // restorations pounce currently truncates run on, which is a
8644 // trajectory change and needs its own measurement.
8645 if let Some(v) = read_int("max_resto_iter") {
8646 builder.resto.max_resto_iter = v;
8647 }
8648
8649 // Iteration-output options — consumed by `OrigIterationOutput`.
8650 if let Some(v) = read_int("print_frequency_iter") {
8651 builder.output.print_frequency_iter = v;
8652 }
8653 if let Some(v) = read_num("print_frequency_time") {
8654 builder.output.print_frequency_time = v;
8655 }
8656 if let Ok((v, found)) = self.options.get_bool_value("print_info_string", "") {
8657 if found {
8658 builder.output.print_info_string = v;
8659 }
8660 }
8661 if let Ok((v, found)) = self.options.get_string_value("inf_pr_output", "") {
8662 if found {
8663 builder.output.inf_pr_output_internal = v == "internal";
8664 }
8665 }
8666
8667 // Warm-start options — consumed by `WarmStartIterateInitializer`
8668 // (port of `IpWarmStartIterateInitializer.cpp:RegisterOptions`).
8669 // `warm_start_init_point` is the toggle that picks between the
8670 // default (cold) and warm-start initializers; the remaining
8671 // knobs are baked onto the chosen initializer at build time.
8672 if let Ok((v, found)) = self.options.get_bool_value("warm_start_init_point", "") {
8673 if found {
8674 builder.warm_start_init_point = v;
8675 }
8676 }
8677 if let Some(v) = read_num("warm_start_bound_push") {
8678 builder.warm.bound_push = v;
8679 }
8680 if let Some(v) = read_num("warm_start_bound_frac") {
8681 builder.warm.bound_frac = v;
8682 }
8683 if let Some(v) = read_num("warm_start_slack_bound_push") {
8684 builder.warm.slack_bound_push = v;
8685 }
8686 if let Some(v) = read_num("warm_start_slack_bound_frac") {
8687 builder.warm.slack_bound_frac = v;
8688 }
8689 if let Some(v) = read_num("warm_start_mult_bound_push") {
8690 builder.warm.mult_bound_push = v;
8691 }
8692 if let Some(v) = read_num("warm_start_mult_init_max") {
8693 builder.warm.mult_init_max = v;
8694 }
8695 if let Some(v) = read_num("warm_start_target_mu") {
8696 builder.warm.target_mu = v;
8697 }
8698 // gh#606: residual-adaptive recentering. `warm_start_entire_iterate`
8699 // and `warm_start_same_structure` used to be parsed here into
8700 // fields nothing read; they are refused by
8701 // `unimplemented_options` instead (they name the
8702 // `GetWarmStartIterate` TNLP surface pounce does not expose).
8703 if let Ok((v, found)) = self.options.get_string_value("warm_start_recentering", "") {
8704 if found {
8705 builder.warm.recentering = if v.eq_ignore_ascii_case("none") {
8706 crate::alg_builder::WarmStartRecentering::None
8707 } else {
8708 crate::alg_builder::WarmStartRecentering::Residual
8709 };
8710 }
8711 }
8712
8713 // `DefaultIterateInitializer` knobs — parsed after the Mehrotra
8714 // cascade so explicit user values win
8715 // (mirrors upstream's `SetNumericValueIfUnset` semantics).
8716 if let Some(v) = read_num("bound_push") {
8717 builder.init.bound_push = v;
8718 }
8719 if let Some(v) = read_num("bound_frac") {
8720 builder.init.bound_frac = v;
8721 }
8722 if let Some(v) = read_num("slack_bound_push") {
8723 builder.init.slack_bound_push = v;
8724 }
8725 if let Some(v) = read_num("slack_bound_frac") {
8726 builder.init.slack_bound_frac = v;
8727 }
8728 if let Some(v) = read_num("constr_mult_init_max") {
8729 builder.init.constr_mult_init_max = v;
8730 }
8731 if let Some(v) = read_num("bound_mult_init_val") {
8732 builder.init.bound_mult_init_val = v;
8733 }
8734 if let Ok((v, found)) = self.options.get_string_value("bound_mult_init_method", "") {
8735 if found {
8736 builder.init.bound_mult_init_method = v;
8737 }
8738 }
8739 if let Ok((v, found)) = self
8740 .options
8741 .get_string_value("least_square_init_primal", "")
8742 {
8743 if found {
8744 builder.init.least_square_init_primal = v == "yes";
8745 }
8746 }
8747 builder
8748 }
8749}
8750
8751/// Map the integer `print_level` / `file_print_level` option to the
8752/// matching [`JournalLevel`] variant. Mirrors upstream's
8753/// `static_cast<EJournalLevel>(int_value)` with clamping.
8754/// The eight block dimensions of an iterate, in canonical order
8755/// (x, s, y_c, y_d, z_l, z_u, v_l, v_u). Used to guard the debugger's
8756/// warm-restart install against a structural mismatch between solves.
8757fn iterates_dims(c: &IteratesVector) -> [i32; 8] {
8758 [
8759 c.x.dim(),
8760 c.s.dim(),
8761 c.y_c.dim(),
8762 c.y_d.dim(),
8763 c.z_l.dim(),
8764 c.z_u.dim(),
8765 c.v_l.dim(),
8766 c.v_u.dim(),
8767 ]
8768}
8769
8770fn journal_level_from_int(v: i32) -> JournalLevel {
8771 match v.clamp(0, 12) {
8772 0 => JournalLevel::J_NONE,
8773 1 => JournalLevel::J_ERROR,
8774 2 => JournalLevel::J_STRONGWARNING,
8775 3 => JournalLevel::J_SUMMARY,
8776 4 => JournalLevel::J_WARNING,
8777 5 => JournalLevel::J_ITERSUMMARY,
8778 6 => JournalLevel::J_DETAILED,
8779 7 => JournalLevel::J_MOREDETAILED,
8780 8 => JournalLevel::J_VECTOR,
8781 9 => JournalLevel::J_MOREVECTOR,
8782 10 => JournalLevel::J_MATRIX,
8783 11 => JournalLevel::J_MOREMATRIX,
8784 _ => JournalLevel::J_ALL,
8785 }
8786}
8787
8788/// MA57 backend knobs snapshotted off an `OptionsList`, ready to be
8789/// handed to a backend factory.
8790///
8791/// The type exists — rather than passing `pounce_hsl::Ma57Options`
8792/// directly — so that the factory signature is the same shape whether or
8793/// not the `ma57` cargo feature is on. `pounce-py`, `pounce-cinterface`
8794/// and `pounce-restoration` all call [`default_backend_factory`] and none
8795/// of them can name a `pounce-hsl` type; without the feature this struct
8796/// carries nothing and costs nothing.
8797///
8798/// Build it with [`ma57_config_from_options`]. It is the MA57 half of
8799/// what [`feral_config_from_options`] does for FERAL, and the asymmetry
8800/// between the two was the root cause of gh#825: FERAL's knobs were
8801/// threaded into the factory and MA57's were not, so the factory had
8802/// nothing to give MA57 and called `Ma57SolverInterface::new()` — which
8803/// hard-codes the defaults. Every `ma57_*` option was registered,
8804/// documented, accepted, and then silently discarded.
8805#[derive(Debug, Clone, Default)]
8806pub struct Ma57Config {
8807 #[cfg(feature = "ma57")]
8808 opts: pounce_hsl::Ma57Options,
8809}
8810
8811impl Ma57Config {
8812 /// The wrapped MA57 settings.
8813 #[cfg(feature = "ma57")]
8814 pub fn options(&self) -> &pounce_hsl::Ma57Options {
8815 &self.opts
8816 }
8817}
8818
8819/// Read the `ma57_*` options off `options` under `prefix`, for handing
8820/// to [`default_backend_factory`] / [`default_backend_factory_with_sink`].
8821///
8822/// `prefix` is `""` for the main IPM and `"resto."` for the restoration
8823/// sub-IPM, mirroring upstream's
8824/// `Ma57TSolverInterface::InitializeImpl(options, prefix)`. The
8825/// restoration sub-IPM builds its own backend through its own
8826/// `InnerBackendFactoryFactory` (see
8827/// `pounce_restoration::resto_inner_solver`), so the two really are
8828/// separately configurable — before gh#825 neither was.
8829///
8830/// Without the `ma57` cargo feature this returns an empty config and
8831/// does not touch `options`; nothing downstream can consume MA57
8832/// settings in that build.
8833pub fn ma57_config_from_options(
8834 options: &pounce_common::options_list::OptionsList,
8835 prefix: &str,
8836) -> Ma57Config {
8837 #[cfg(feature = "ma57")]
8838 {
8839 Ma57Config {
8840 opts: pounce_hsl::Ma57Options::from_options_list(options, prefix),
8841 }
8842 }
8843 #[cfg(not(feature = "ma57"))]
8844 {
8845 let _ = (options, prefix);
8846 Ma57Config::default()
8847 }
8848}
8849
8850/// Construct the MA57 backend for a factory, or fall back to FERAL when
8851/// the `ma57` cargo feature is off.
8852///
8853/// Factored out of the two factories below so there is exactly one place
8854/// that decides how an MA57 backend is built from a [`Ma57Config`]. Both
8855/// factories used to inline `Ma57SolverInterface::new()`, and the
8856/// duplication is half of why gh#825 was easy to miss.
8857fn make_ma57_backend(
8858 ma57_cfg: &Ma57Config,
8859 feral_fallback: impl FnOnce() -> Box<dyn SparseSymLinearSolverInterface>,
8860) -> Box<dyn SparseSymLinearSolverInterface> {
8861 #[cfg(feature = "ma57")]
8862 {
8863 let _ = feral_fallback;
8864 Box::new(pounce_hsl::Ma57SolverInterface::with_options(
8865 *ma57_cfg.options(),
8866 ))
8867 }
8868 #[cfg(not(feature = "ma57"))]
8869 {
8870 // ma57 feature not compiled in — fall back to FERAL.
8871 let _ = ma57_cfg;
8872 feral_fallback()
8873 }
8874}
8875
8876/// Default symmetric linear-solver factory, parameterized by the
8877/// pounce-extension FERAL knobs and the `ma57_*` knobs read off the
8878/// application's `OptionsList`.
8879///
8880/// FERAL (pure-Rust) is the shipping default. The HSL MA57 backend is
8881/// available when the `ma57` cargo feature is enabled; without it,
8882/// requesting `linear_solver = ma57` falls back to FERAL with a
8883/// warning printed by the journalist (see [`AlgorithmBuilder`]).
8884///
8885/// Both configs are snapshots, not live views: take them with
8886/// [`feral_config_from_options`] and [`ma57_config_from_options`] at the
8887/// point the application's options are fully populated. The factory is
8888/// called more than once per solve (the main KKT solver and, under
8889/// limited memory, the Hessian-free bypass solver), so each call gets a
8890/// fresh backend built from the same snapshot.
8891pub fn default_backend_factory(
8892 feral_cfg: pounce_feral::FeralConfig,
8893 ma57_cfg: Ma57Config,
8894) -> LinearBackendFactory {
8895 Box::new(
8896 move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
8897 match choice {
8898 LinearSolverChoice::Feral => Box::new(
8899 pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
8900 ),
8901 LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
8902 Box::new(pounce_feral::FeralSolverInterface::with_config(
8903 feral_cfg.clone(),
8904 ))
8905 }),
8906 }
8907 },
8908 )
8909}
8910
8911/// Turn a model-space block declaration into the KKT-space labels the block
8912/// solver takes (structured-KKT Phase 5b's mapper, plan §63.6).
8913///
8914/// The KKT is laid out `[x | slacks | equality duals | inequality duals]`, so
8915/// each part inherits a label from what it belongs to:
8916///
8917/// * an `x` column takes its variable's label — skipping variables fixed out
8918/// by `fixed_variable_treatment`, which are not in the KKT at all;
8919/// * a slack and its inequality dual take their constraint's label;
8920/// * an equality dual takes its constraint's label.
8921///
8922/// `var_blocks` / `con_blocks` are in the model's own indexing (`n_full_x`,
8923/// `n_full_g`); negative means shared. Returns the label per KKT index, or an
8924/// error naming the mismatch — the caller then falls back rather than
8925/// mislabel, because a label that is off by one row silently declares a
8926/// structure the matrix does not have.
8927pub fn map_block_structure_to_kkt(
8928 cls: &pounce_nlp::tnlp_adapter::BoundClassification,
8929 var_blocks: &[i32],
8930 con_blocks: &[i32],
8931) -> Result<Vec<i32>, String> {
8932 if var_blocks.len() != cls.n_full_x as usize {
8933 return Err(format!(
8934 "block structure has {} variable labels, the problem has {} variables",
8935 var_blocks.len(),
8936 cls.n_full_x
8937 ));
8938 }
8939 if con_blocks.len() != cls.n_full_g as usize {
8940 return Err(format!(
8941 "block structure has {} constraint labels, the problem has {} constraints",
8942 con_blocks.len(),
8943 cls.n_full_g
8944 ));
8945 }
8946 let n_x = cls.x_not_fixed_map.len();
8947 let n_c = cls.c_map.len();
8948 let n_d = cls.d_map.len();
8949 let mut labels = Vec::with_capacity(n_x + 2 * n_d + n_c);
8950 for &full in &cls.x_not_fixed_map {
8951 labels.push(var_blocks[full as usize]);
8952 }
8953 for &full in &cls.d_map {
8954 labels.push(con_blocks[full as usize]); // slack
8955 }
8956 for &full in &cls.c_map {
8957 labels.push(con_blocks[full as usize]); // equality dual
8958 }
8959 for &full in &cls.d_map {
8960 labels.push(con_blocks[full as usize]); // inequality dual
8961 }
8962 Ok(labels)
8963}
8964
8965/// Sink-aware variant of [`default_backend_factory`]. Identical
8966/// dispatch, but the FERAL backend is constructed with a
8967/// `LinearSolverSummary` sink so [`IpoptApplication`] can read out
8968/// aggregate post-mortem stats (factor counts, fill ratio, extremal
8969/// pivots, final inertia) after the solve returns. MA57 ignores the
8970/// sink — the HSL backend doesn't carry the same instrumentation yet.
8971pub fn default_backend_factory_with_sink(
8972 feral_cfg: pounce_feral::FeralConfig,
8973 ma57_cfg: Ma57Config,
8974 sink: Arc<Mutex<LinearSolverSummary>>,
8975) -> LinearBackendFactory {
8976 Box::new(
8977 move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
8978 match choice {
8979 LinearSolverChoice::Feral => Box::new(
8980 pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
8981 .with_summary_sink(Arc::clone(&sink)),
8982 ),
8983 LinearSolverChoice::Ma57 => make_ma57_backend(&ma57_cfg, || {
8984 Box::new(
8985 pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone())
8986 .with_summary_sink(Arc::clone(&sink)),
8987 )
8988 }),
8989 }
8990 },
8991 )
8992}
8993
8994/// Read the `feral_*` extension options off `options`, falling
8995/// back to the env-var defaults baked into [`pounce_feral::FeralConfig::from_env`]
8996/// for any knob the caller did not set explicitly. The returned
8997/// config is what every default-factory invocation (main IPM and
8998/// restoration sub-IPM) consumes.
8999pub fn feral_config_from_options(
9000 options: &pounce_common::options_list::OptionsList,
9001) -> pounce_feral::FeralConfig {
9002 feral_config_from_options_scoped(options, RefineCarveOut::InteriorPoint)
9003}
9004
9005/// Whether [`feral_config_from_options_scoped`] applies the interior-point
9006/// arm's limited-memory `feral_refine` carve-out.
9007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9008pub enum RefineCarveOut {
9009 /// Apply it: under `hessian_approximation=limited-memory`, force
9010 /// `refine = false` unless the user said otherwise. This is the shipped
9011 /// interior-point behaviour and what [`feral_config_from_options`] does.
9012 InteriorPoint,
9013 /// Skip it. For arms that do not refine the unreduced system themselves,
9014 /// where the backend loop is the only refinement in the stack.
9015 None,
9016}
9017
9018/// [`feral_config_from_options`] with the limited-memory `refine` carve-out
9019/// made explicit.
9020///
9021/// The carve-out is interior-point-specific in both of its premises, and
9022/// neither holds on the active-set SQP arm. It is justified by
9023/// `PdFullSpaceSolver::compute_residuals` already refining the *unreduced*
9024/// Newton system — the SQP arm has no such host loop, so
9025/// `pounce_qp::factor::LinearSolver` relies on the backend's own refinement
9026/// ("refinement is *implicit* in this layer", that module's docs) and turning
9027/// it off removes refinement from the stack entirely. And it keys off
9028/// `hessian_approximation`, which is not that arm's Hessian selector at all:
9029/// `sqp_hessian` is.
9030///
9031/// Measured, and this is why the parameter exists rather than the SQP path
9032/// just calling the plain function: inheriting the carve-out moved 10 of 97
9033/// fixtures on the sweep's L-BFGS leg under `algorithm=active-set-sqp`, while
9034/// the exact leg did not move at all. Some of that movement looked like a win
9035/// (`mu_fallback_point_floor` MaximumIterationsExceeded/200 ->
9036/// SolveSucceeded/8), but it was a win bought by disabling refinement for a
9037/// reason that does not apply here, and it came with `hs13_bigstart` landing
9038/// on a different point (objective 1 -> 1.0018) and `eigena2` and
9039/// `mpcc_scholtes4_biactive` likewise. Turning refinement off on this arm may
9040/// well be worth measuring on its own merits; it should not arrive as a side
9041/// effect of honouring `linear_solver`.
9042pub fn feral_config_from_options_scoped(
9043 options: &pounce_common::options_list::OptionsList,
9044 carve_out: RefineCarveOut,
9045) -> pounce_feral::FeralConfig {
9046 let mut cfg = pounce_feral::FeralConfig::from_env();
9047 // Tri-state: the `(_, true)` arm only fires when the user set the
9048 // option explicitly. Leaving it unset keeps `cfg.cascade_break` at
9049 // `None`, which inherits FERAL's `NumericParams::default()` (CB on
9050 // as of FERAL Phase B / pounce#55). `Some(false)` explicitly
9051 // disarms (reproduces pre-Phase-B behaviour, surfaces FERAL's
9052 // `DelayBudgetExceeded` on non-root cascade victims).
9053 if let Ok((v, true)) = options.get_bool_value("feral_cascade_break", "") {
9054 cfg.cascade_break = Some(v);
9055 }
9056 if let Ok((v, true)) = options.get_bool_value("feral_fma", "") {
9057 cfg.fma = v;
9058 }
9059 // Not tri-state, and deliberately not: on the limited-memory path
9060 // the IPM's default is the opposite of the library's (gh#710,
9061 // gh#698 obs 5). `FeralConfig` ships `refine = true` because a
9062 // caller that only refines its own system needs the backend loop.
9063 //
9064 // Scoped to limited-memory, because that is where the win was
9065 // measured and where it comes from. Under L-BFGS the Hessian-free
9066 // bypass batches the low-rank SMW correction into one multi-RHS
9067 // back-solve, and the backend loop refines per right-hand side, so
9068 // its cost scales with the memory depth — while what it polishes is
9069 // the *condensed* system, which is not where Waechter-Biegler 3.10
9070 // puts refinement. Turning it off takes `laptime` under
9071 // limited-memory from 1397 s to 394 s.
9072 //
9073 // The same switch is a net loss on the exact path, so it is not
9074 // applied there. It costs `NARX_CFy` 230 iterations (400 -> 630,
9075 // 173 s -> 250 s) to buy back 34 on `laptime` (380 -> 346). And the
9076 // exact path has no rung left to stand in for it: the
9077 // `increase_quality` escalation that once argued for dropping the
9078 // backend loop here proved inert and was itself removed, so with
9079 // `refine` off the host loop in `PdFullSpaceSolver` is the only
9080 // refinement in the stack — and it stops the moment it crosses
9081 // `residual_ratio_max` (1e-10), three orders looser than the ~1e-16
9082 // the backend-refined solves reach.
9083 //
9084 // Tightening that threshold instead is not the fix, and was measured
9085 // rather than assumed: it is chaotic on this corpus, flipping `deb7`
9086 // to `Error_In_Step_Computation` at 1e-12 and swinging
9087 // `pooling_rt2stp` between 107 and 199 iterations across 1e-11 to
9088 // 1e-13. Forcing extra passes via `min_refinement_steps` behaves the
9089 // same way (both fixtures break at 2). Restoring the backend loop is
9090 // trajectory-neutral next to either (`deb7` 146 -> 147,
9091 // `pooling_rt2stp` 107 -> 109).
9092 //
9093 // Ordered env-then-registry-then-option so `POUNCE_FERAL_REFINE`
9094 // still reaches this path and an explicit `feral_refine` still beats
9095 // both.
9096 //
9097 // gh#909: the registry read below takes the value and IGNORES the
9098 // found flag, which is the whole point. Reading it as `Ok((v, true))`
9099 // — the tri-state shape every other option in this function uses —
9100 // consults the user's setting and never the *registered default*, so
9101 // an unset option left `cfg.refine` at `FeralConfig::default()`'s
9102 // `true` while `pounce --print-options` reported the registry's.
9103 // Registered `no`, ran `yes`: the gh#677 shape, one option family
9104 // over. It is fixed by making the registry authoritative here rather
9105 // than by changing what runs — see below for why — so the registered
9106 // default moved to `yes` in the same commit and this read is what
9107 // keeps the two from drifting apart again.
9108 //
9109 // The scoping stays. There is no single default that is right on both
9110 // paths, and this is a two-sided trade of the same shape as
9111 // `feral_increase_quality` above: the option is the lever, and the
9112 // default is a choice rather than a fact. Measured on `e5f5830b`,
9113 // one binary, `feral_refine=no` against the default:
9114 //
9115 // fixture corpus, exact leg: 10 legs move, NO status flips, and
9116 // the net is favourable -- `issue_508_infeasible_gap_1em4`
9117 // 441 -> 245, `square_flowsheet_resto` 54 -> 47 (tot 185 -> 176),
9118 // `issue_508_infeasible_gap_1em2` 114 -> 112, `deb7` 147 -> 146,
9119 // `pooling_rt2stp` 109 -> 107, against one loss
9120 // (`mu_fallback_point_floor` 31 -> 32).
9121 // fixture corpus, lbfgs leg: identical, refine is already off there.
9122 // `NARX_CFy` (Mittelmann): 400 -> 630 iterations, 208.7 s -> 254.2 s.
9123 // `eigena2` final dual infeasibility: 3.43e-10 -> 5.20e-09, both
9124 // inside `tol`, and see `issue_540_eigena2_superlinear_tail.rs`.
9125 // AC-OPF (PGLib), gh#909: 10-18% faster, iteration counts
9126 // unchanged, solution identical to ~13 significant figures.
9127 //
9128 // So the corpus and the AC-OPF benchmarks both prefer `no` and
9129 // `NARX_CFy` pays 57% more iterations for it. Nothing here separates
9130 // those, which is why the default is scoped rather than flipped.
9131 let limited_memory = carve_out == RefineCarveOut::InteriorPoint
9132 && matches!(
9133 options.get_string_value("hessian_approximation", ""),
9134 Ok((ref s, true)) if s == "limited-memory"
9135 );
9136 if std::env::var_os("POUNCE_FERAL_REFINE").is_none() {
9137 // Deliberately `_`, not `true`: see above.
9138 if let Ok((v, _)) = options.get_bool_value("feral_refine", "") {
9139 cfg.refine = v;
9140 }
9141 if limited_memory {
9142 cfg.refine = false;
9143 }
9144 }
9145 // An explicit setting beats both the carve-out and the environment.
9146 if let Ok((v, true)) = options.get_bool_value("feral_refine", "") {
9147 cfg.refine = v;
9148 }
9149 // gh #850: `feral_increase_quality` exists because this rung is a genuine
9150 // two-sided trade, and the option is the lever — the default is left ON,
9151 // which is the 0.11 behaviour.
9152 //
9153 // Ipopt's `IncreaseQuality` contract assumes a *monotone* escalation: MA57
9154 // raises `pivtol` toward `pivtolmax`, strictly more conservative each time,
9155 // so keeping it raised for the rest of the solve can only make the
9156 // factorization safer. FERAL's ladder changes which pivots are taken, which
9157 // is lateral in trajectory terms, and it persists the same way. So it
9158 // reroutes solves, and the reroute goes both ways:
9159 //
9160 // it COSTS two whole solves, both `square_flowsheet_resto`:
9161 // exact Optimal/99 -> RestorationFailed/131 (a second-opinion rung
9162 // rescues it, at 185 iterations total)
9163 // lbfgs Optimal/178 -> 3000 iterations at the cap, rescued by nothing
9164 // it BUYS accuracy where nothing else does:
9165 // `watchdog_trial_is_not_a_divergence_verdict`'s 12-variable model ends
9166 // `SolvedToAcceptableLevel` at obj 3.7e-6 with the rung and at obj 3.42
9167 // against `f* = 0` without it — a wrong-ish answer under a
9168 // success-shaped status, which is worse than an honest failure.
9169 // and it buys iterations on five more fixture-legs (15-25%).
9170 //
9171 // There is no scoping that separates those. Measured with a process-global
9172 // firing cap on `square_flowsheet_resto`: the rung fires twice, once in the
9173 // main solve at iteration 25 and once inside restoration at `76r`, and
9174 // allowing only the first still loses the leg — so declining it just for
9175 // the restoration sub-solve would not help. Nor does a count separate them:
9176 // `deb7` and `square_flowsheet_resto` each fire it exactly twice on their
9177 // exact legs, one gaining 16% of its iterations and the other losing its
9178 // verdict.
9179 //
9180 // So the default stands, and the losing direction now recovers itself:
9181 // `feral_increase_quality_retry` (gh#857) re-solves once with this off when
9182 // a solve that actually escalated ends `Restoration_Failed` or
9183 // `Maximum_Iterations_Exceeded`.
9184 //
9185 // It is a re-solve rather than a *revertible* escalation because the
9186 // revertible one was tried. jkitchin/feral#192 landed as `reset_quality`,
9187 // was plumbed here and instrumented (376 escalations, 376 matching resets
9188 // on one solve), and recovers neither leg at either re-baselining boundary
9189 // -- the harm is the destination, not the duration. See
9190 // `dev-notes/second-opinion-promotions-in-the-sweep.md`.
9191 if let Ok((v, true)) = options.get_bool_value("feral_increase_quality", "") {
9192 cfg.increase_quality = v;
9193 }
9194 // Only consulted when `refine` is on; see `FeralConfig::refine_max_steps`
9195 // (gh#710). Registered as an integer option with lower bound 0, so the
9196 // cast cannot go negative.
9197 if let Ok((v, true)) = options.get_integer_value("feral_refine_steps", "") {
9198 cfg.refine_max_steps = v.max(0) as usize;
9199 }
9200 // Also only consulted when `refine` is on. Registered with lower bound
9201 // 0, and 0 disables the pre-check; see `FeralConfig::refine_target`.
9202 if let Ok((v, true)) = options.get_numeric_value("feral_refine_target", "") {
9203 cfg.refine_target = v.max(0.0);
9204 }
9205 // Explicit static-pivoting opt-in (feral#8 cascade breaker, pounce#254).
9206 // Same tri-state discipline: unset leaves `cfg.static_pivoting` at
9207 // whatever `from_env` resolved (`None` → inherit feral's delayed-pivot
9208 // default), so the default numeric path is unchanged.
9209 if let Ok((v, true)) = options.get_bool_value("feral_static_pivoting", "") {
9210 cfg.static_pivoting = Some(v);
9211 }
9212 if let Ok((v, true)) = options.get_numeric_value("feral_singular_pivot_floor", "") {
9213 cfg.singular_pivot_floor = v;
9214 }
9215 // Explicitly set pins an absolute floor for every dimension (`0`
9216 // disables the trigger); left unset, `None` keeps the dimension-aware
9217 // `n * eps` default (pounce gh#592).
9218 if let Ok((v, true)) = options.get_numeric_value("feral_inertia_pivot_floor", "") {
9219 cfg.inertia_pivot_floor = Some(v);
9220 }
9221 // Number option (not integer): the gate is a u64 and Index is i32, too
9222 // narrow for large flop counts or the u64::MAX reject-all sentinel. The
9223 // lower bound (0.0) rules out negatives; `as u64` then saturates a very
9224 // large finite value to u64::MAX (reject all tree-level parallelism).
9225 if let Ok((v, true)) = options.get_numeric_value("feral_min_par_flops", "") {
9226 cfg.min_par_flops = Some(v as u64);
9227 }
9228 if let Ok((v, true)) = options.get_numeric_value("feral_pivtol", "") {
9229 cfg.pivtol = v;
9230 }
9231 // Only override on explicit set so `from_env` (which itself
9232 // defaults to OrderingMethod::Auto) keeps governing unset cases.
9233 // Unrecognized tags are silently ignored — the registered enum
9234 // restricts inputs at the OptionsList layer.
9235 if let Ok((v, true)) = options.get_string_value("feral_ordering", "") {
9236 if let Some(m) = pounce_feral::parse_ordering_method(&v) {
9237 cfg.ordering = m;
9238 }
9239 }
9240 if let Ok((v, true)) = options.get_string_value("feral_ordering_preprocess", "") {
9241 if let Some(p) = pounce_feral::parse_ordering_preprocess(&v) {
9242 cfg.ordering_preprocess = p;
9243 }
9244 }
9245 // Same explicit-set discipline as `feral_ordering`: `from_env`
9246 // defaults to ScalingStrategy::Auto (FERAL's current default), so
9247 // leaving the option unset preserves existing behaviour exactly.
9248 if let Ok((v, true)) = options.get_string_value("feral_scaling", "") {
9249 if let Some(s) = pounce_feral::parse_scaling_strategy(&v) {
9250 cfg.scaling = s;
9251 }
9252 }
9253 cfg
9254}
9255
9256/// Withdraw a numerical infeasibility verdict the model's own starting point
9257/// disproves.
9258///
9259/// Applied at every site in this file that can return
9260/// `Infeasible_Problem_Detected` from a *numerical* argument — the IPM path's
9261/// restoration / cycle gates, the SQP path's infeasible-subproblem exit, and the
9262/// ℓ₁ wrapper's uncollapsed-slack certificate. Deliberately one gate rather than
9263/// three: the two preceding safeguards in this area (gh #376, gh #380) were each
9264/// added to one path and not its twin, and a hole survived both times.
9265///
9266/// Not applied to a presolve *certificate*
9267/// (`TNLP::presolve_infeasibility_proof`), which carries its own, tighter
9268/// refutation
9269/// (`pounce_presolve::witness_refutes_infeasibility`) and is a proof rather than
9270/// a numerical inference.
9271///
9272/// The replacement is `Error_In_Step_Computation`, the status this codebase
9273/// already uses for "the solve broke down and we are **not** claiming
9274/// infeasibility" — see the `cycle_exit` fallback in
9275/// [`crate::ipopt_alg::IpoptAlgorithm::invoke_restoration`], which picks between
9276/// exactly these two on exactly this question. It maps to AMPL 500, an honest
9277/// failure the caller can see, instead of AMPL 200, a wrong answer they cannot.
9278///
9279/// gh #379.
9280fn withdraw_infeasibility_if_refuted(
9281 tnlp: &Rc<RefCell<dyn TNLP>>,
9282 solver_status: SolverReturn,
9283 lo_inf: Number,
9284 up_inf: Number,
9285 tol: Number,
9286) -> SolverReturn {
9287 if solver_status != SolverReturn::LocalInfeasibility {
9288 return solver_status;
9289 }
9290 // A presolve proof is not a numerical inference; it does its own refutation.
9291 if tnlp.borrow().presolve_infeasibility_proof().is_some() {
9292 return solver_status;
9293 }
9294 match crate::infeasibility_refutation::starting_point_refutes_infeasibility(
9295 tnlp, lo_inf, up_inf, tol,
9296 ) {
9297 Some(w) => {
9298 tracing::debug!(
9299 target: "pounce::application",
9300 "[PN_INFEAS_REFUTED] the model's starting point satisfies every constraint \
9301 (max violation {:.3e}) — withdrawing Infeasible_Problem_Detected",
9302 w.max_violation
9303 );
9304 SolverReturn::ErrorInStepComputation
9305 }
9306 None => solver_status,
9307 }
9308}
9309
9310/// How well a point satisfies the **user's own** rows and bounds.
9311///
9312/// Computed from `g = c(x)` and the inner TNLP's declared bounds, in the
9313/// user's units, with no reference to whatever problem the algorithm
9314/// actually iterated on. That distinction is the whole reason this
9315/// exists: on the ℓ₁ path the IPM converges the *augmented* NLP
9316/// `c(x) − p + n = target`, whose equality rows the slacks satisfy to
9317/// machine precision by construction, and reporting that residual as
9318/// the solve's constraint violation says nothing about `c(x) − target`
9319/// (gh#794 finding P1).
9320struct OriginalSpaceFeasibility {
9321 /// Largest absolute violation of any row or bound, in the user's units.
9322 max_violation: Number,
9323 /// Every row and bound negligible at `tol`, judged scale-relative.
9324 negligible_at_tol: bool,
9325 /// The same at `acceptable_tol`.
9326 negligible_at_acceptable: bool,
9327}
9328
9329/// Measure [`OriginalSpaceFeasibility`] at `x`, given `g = c(x)`.
9330///
9331/// `is_negligible` rather than `!is_significant`, deliberately, and for
9332/// the reason that function's own documentation gives: the question here
9333/// is "did the solve converge well enough to call this point feasible",
9334/// which must never demand more precision than the solver promised, so
9335/// the threshold is clamped at `tol` from below (`tol · max(|scale|, 1)`).
9336/// The refutation path next door asks the opposite question — "is this
9337/// residual real at this row's scale" — and correctly uses the pure
9338/// relative form.
9339///
9340/// Returns `None` when the model cannot be measured (bounds unreadable, a
9341/// non-finite value). `None` means "not measured", never "feasible": the
9342/// caller keeps whatever verdict it already had.
9343fn original_space_feasibility(
9344 tnlp: &Rc<RefCell<dyn TNLP>>,
9345 x: &[Number],
9346 g: &[Number],
9347 lower_bound_inf: Number,
9348 upper_bound_inf: Number,
9349 tol: Number,
9350 acceptable_tol: Number,
9351 constr_viol_tol: Number,
9352 acceptable_constr_viol_tol: Number,
9353 noise_floor_kappa: Number,
9354) -> Option<OriginalSpaceFeasibility> {
9355 use pounce_common::tolerance::is_negligible;
9356
9357 let info = tnlp.borrow_mut().get_nlp_info()?;
9358 let n = info.n.max(0) as usize;
9359 let m = info.m.max(0) as usize;
9360 if x.len() < n || g.len() < m {
9361 return None;
9362 }
9363
9364 let mut x_l = vec![0.0; n];
9365 let mut x_u = vec![0.0; n];
9366 let mut g_l = vec![0.0; m];
9367 let mut g_u = vec![0.0; m];
9368 if !tnlp.borrow_mut().get_bounds_info(BoundsInfo {
9369 x_l: &mut x_l,
9370 x_u: &mut x_u,
9371 g_l: &mut g_l,
9372 g_u: &mut g_u,
9373 }) {
9374 return None;
9375 }
9376
9377 let mut max_violation: Number = 0.0;
9378 let mut ok_tol = true;
9379 let mut ok_acceptable = true;
9380
9381 // Only *finite, present* bounds inform a row's magnitude: letting the
9382 // `±1e19` sentinel set the scale would make every row look satisfied,
9383 // the same trap `infeasibility_refutation` documents.
9384 let present = |b: Number, is_lower: bool| -> Option<Number> {
9385 let absent = if is_lower {
9386 b <= lower_bound_inf
9387 } else {
9388 b >= upper_bound_inf
9389 };
9390 (b.is_finite() && !absent).then_some(b)
9391 };
9392
9393 // Accumulates into `max_violation` / `ok_tol` / `ok_acceptable`; a
9394 // non-positive `viol` means the side is satisfied and contributes
9395 // nothing. Returns nothing — every caller is a statement.
9396 let mut judge = |viol: Number, scale: Number| {
9397 if viol <= 0.0 {
9398 return;
9399 }
9400 max_violation = max_violation.max(viol);
9401 // The scale-relative test alone is not a feasibility standard, and
9402 // on a large-magnitude row it is not even close to one: it accepts
9403 // anything up to `tol · |row|`, which on a row near `1e10` is `1e2`
9404 // at the default `tol`. An adversary probe on this branch built a
9405 // model infeasible by exactly `50` with its row at `1e10` and got
9406 // `Solve_Succeeded` — a *worse* verdict than this branch's own
9407 // parent, which refused the same point (the old `Σ(p+n)` argument
9408 // was crude but absolute). So the wrapper has to judge feasibility
9409 // the way the rest of the solver does.
9410 //
9411 // `OptErrorConvCheck::primal_component_passes` is that standard:
9412 // an absolute `constr_viol <= constr_viol_tol`, with scale-awareness
9413 // supplied by an abstention when every row sits at its own
9414 // floating-point noise floor (gh#528/gh#590) rather than by
9415 // multiplying the tolerance by the row's magnitude. That
9416 // abstention "cannot fabricate a success on a genuinely infeasible
9417 // model: such a model's violation is pinned at its infeasibility
9418 // gap, orders above `eps ·` the row's own magnitude" — which is
9419 // exactly the property `is_negligible` lacks and the probe
9420 // exploited (`50` is `~2e7 ×` this row's floor).
9421 //
9422 // The strict gate's `primal_resolvable` cannot be reused verbatim:
9423 // it is computed by the CQ on the *augmented* NLP, whose rows the
9424 // slacks satisfy to machine precision, so it would abstain always
9425 // and accept everything. The floor is therefore recomputed here on
9426 // the user's own row, from the same `kappa · eps · magnitude` the
9427 // option documents. `primal_noise_floor_kappa = 0` opts out, as it
9428 // does for the strict gate.
9429 //
9430 // Both arms are conjoined rather than substituted: the relative
9431 // test still catches a violation that is small in absolute terms
9432 // but large for its row, which is the gh#794 P1 case itself
9433 // (`ralph1` at `2.5e-7` under a `2.5e-11` tol).
9434 let noise = noise_floor_kappa * Number::EPSILON * scale.abs();
9435 let absolute_ok = |bound: Number| viol <= bound || viol <= noise;
9436 ok_tol &= is_negligible(viol, scale, tol) && absolute_ok(constr_viol_tol);
9437 ok_acceptable &=
9438 is_negligible(viol, scale, acceptable_tol) && absolute_ok(acceptable_constr_viol_tol);
9439 };
9440
9441 for i in 0..m {
9442 let v = g[i];
9443 if !v.is_finite() {
9444 return None;
9445 }
9446 let lo = present(g_l[i], true);
9447 let hi = present(g_u[i], false);
9448 let scale = v
9449 .abs()
9450 .max(lo.map_or(0.0, Number::abs))
9451 .max(hi.map_or(0.0, Number::abs));
9452 judge(lo.map_or(0.0, |b| b - v), scale);
9453 judge(hi.map_or(0.0, |b| v - b), scale);
9454 }
9455 for j in 0..n {
9456 let v = x[j];
9457 if !v.is_finite() {
9458 return None;
9459 }
9460 let lo = present(x_l[j], true);
9461 let hi = present(x_u[j], false);
9462 let scale = v
9463 .abs()
9464 .max(lo.map_or(0.0, Number::abs))
9465 .max(hi.map_or(0.0, Number::abs));
9466 judge(lo.map_or(0.0, |b| b - v), scale);
9467 judge(hi.map_or(0.0, |b| v - b), scale);
9468 }
9469
9470 Some(OriginalSpaceFeasibility {
9471 max_violation,
9472 negligible_at_tol: ok_tol,
9473 negligible_at_acceptable: ok_acceptable,
9474 })
9475}
9476
9477/// Map upstream `SolverReturn` codes to `ApplicationReturnStatus`.
9478/// Mirrors the table in
9479/// `ref/Ipopt/AGENT_REFERENCE/MAIN_LOOP.md` ("exception → SolverReturn
9480/// map") and the corresponding switch in
9481/// `IpIpoptApplication.cpp:call_optimize`.
9482fn solver_return_to_app_status(s: SolverReturn) -> ApplicationReturnStatus {
9483 match s {
9484 SolverReturn::Success => ApplicationReturnStatus::SolveSucceeded,
9485 SolverReturn::StopAtAcceptablePoint => ApplicationReturnStatus::SolvedToAcceptableLevel,
9486 SolverReturn::FeasiblePointFound => ApplicationReturnStatus::FeasiblePointFound,
9487 SolverReturn::MaxiterExceeded => ApplicationReturnStatus::MaximumIterationsExceeded,
9488 SolverReturn::CpuTimeExceeded => ApplicationReturnStatus::MaximumCpuTimeExceeded,
9489 SolverReturn::WallTimeExceeded => ApplicationReturnStatus::MaximumWallTimeExceeded,
9490 SolverReturn::StopAtTinyStep => ApplicationReturnStatus::SearchDirectionBecomesTooSmall,
9491 SolverReturn::LocalInfeasibility => ApplicationReturnStatus::InfeasibleProblemDetected,
9492 SolverReturn::UserRequestedStop => ApplicationReturnStatus::UserRequestedStop,
9493 SolverReturn::DivergingIterates => ApplicationReturnStatus::DivergingIterates,
9494 SolverReturn::RestorationFailure => ApplicationReturnStatus::RestorationFailed,
9495 SolverReturn::ErrorInStepComputation => ApplicationReturnStatus::ErrorInStepComputation,
9496 SolverReturn::InvalidNumberDetected => ApplicationReturnStatus::InvalidNumberDetected,
9497 SolverReturn::TooFewDegreesOfFreedom => ApplicationReturnStatus::NotEnoughDegreesOfFreedom,
9498 SolverReturn::InvalidProblemDefinition => ApplicationReturnStatus::InvalidProblemDefinition,
9499 SolverReturn::InvalidOption => ApplicationReturnStatus::InvalidOption,
9500 SolverReturn::OutOfMemory => ApplicationReturnStatus::InsufficientMemory,
9501 SolverReturn::InternalError | SolverReturn::Unassigned => {
9502 ApplicationReturnStatus::InternalError
9503 }
9504 }
9505}
9506
9507/// Best-effort evaluation of the objective at the algorithm's final
9508/// `x`. Returns the *scaled* objective (`f * obj_scale_factor`); used
9509/// to populate `SolveStatistics::final_scaled_objective`.
9510fn try_eval_curr_f(
9511 nlp: &Rc<RefCell<dyn IpoptNlp>>,
9512 x: &Rc<dyn pounce_linalg::Vector>,
9513) -> Result<Number, ()> {
9514 let mut nlp_mut = nlp.borrow_mut();
9515 Ok(nlp_mut.eval_f(&**x))
9516}
9517
9518/// Trigger predicate for the Phase-3.5 ℓ₁ auto-fallback path. Returns
9519/// `true` when a status warrants a retry through the wrapper. Mirrors
9520/// ripopt#23's trigger set, extended per the audit's Refinement B
9521/// (pounce-side `Not_Enough_Degrees_Of_Freedom` is added because
9522/// pounce's DOF early-exit blocks NE-suffix problems that ripopt's
9523/// equivalent would let pass to the wrapper).
9524fn is_l1_fallback_trigger(status: ApplicationReturnStatus) -> bool {
9525 matches!(
9526 status,
9527 ApplicationReturnStatus::RestorationFailed
9528 | ApplicationReturnStatus::InfeasibleProblemDetected
9529 | ApplicationReturnStatus::SolvedToAcceptableLevel
9530 | ApplicationReturnStatus::MaximumIterationsExceeded
9531 | ApplicationReturnStatus::NotEnoughDegreesOfFreedom
9532 )
9533}
9534
9535/// Forward the final iterate back to the user's `TNLP::finalize_solution`.
9536/// We pull `x` (compressed in `x_var`-space) off the algorithm's
9537/// `data.curr`, lift it back to full TNLP indexing, and pass empty
9538/// multipliers for now (the algorithm's `y_c`, `y_d`, `z_l`, `z_u` are
9539/// in compressed split form — re-assembling them into the user's
9540/// `lambda` / `z_l` / `z_u` is mechanical but lives behind a
9541/// `OrigIpoptNlp::finalize_solution_*` accessor that's still being
9542/// fleshed out). On success returns the unscaled objective evaluated
9543/// on the user TNLP at the final iterate; returns `Err` if the final
9544/// iterate is missing.
9545/// Read a `dyn Vector`'s entries. Empty for a non-dense backing; POUNCE is
9546/// dense-only, so that is defensive rather than a supported case.
9547/// `constr_viol_tol`, `dual_inf_tol` and `compl_inf_tol`, as the user set
9548/// them (unscaled).
9549struct UserTolerances {
9550 constr_viol: Number,
9551 dual_inf: Number,
9552 compl_inf: Number,
9553}
9554
9555/// Whether a crossed-over point meets the user's tolerances in user units on
9556/// the model as declared (gh#1054).
9557///
9558/// Crossover's own acceptance reads the SQP's residuals, which live in the
9559/// scaled space. That is the right space for the never-regress comparison
9560/// against the interior iterate, and the wrong one for "is this a solution of
9561/// the user's problem": gradient-based scaling sized a row at a start point
9562/// with `x₀ = 1e4`, and at `x = (2, 0)` a violation of `1` read `3.3e-7` —
9563/// under every scaled tolerance, so `hs13_bigstart` came back
9564/// `Solve_Succeeded` at an infeasible point with every printed residual `0`.
9565/// The interior-point verdict is gated in user units (pounce#173); a point
9566/// that replaces it is held to the same gate. Stationarity and
9567/// complementarity unscale by `|df|`, as the calculated quantities do.
9568fn meets_user_tolerances(
9569 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
9570 res: &crate::sqp::SqpResult,
9571 report: &crate::crossover::CrossoverReport,
9572 tols: &UserTolerances,
9573) -> bool {
9574 let adapter = crate::sqp::IpoptNlpAdapter::new_with_declared_bounds(Rc::clone(nlp_handle));
9575 let (rows, bx) = adapter.declared_violation(&res.x);
9576 let df = adapter.obj_scale();
9577 let dual = res.final_stationarity / df;
9578 let compl = report.compl_after / df;
9579 let ok = rows <= tols.constr_viol
9580 && bx.unwrap_or(0.0) <= tols.constr_viol
9581 && dual <= tols.dual_inf
9582 && (compl.is_nan() || compl <= tols.compl_inf);
9583 if !ok {
9584 tracing::debug!(
9585 target: "pounce::crossover",
9586 "crossed-over point fails the user tolerances: rows {rows:.2e}, box {bx:?}, \
9587 dual {dual:.2e}, compl {compl:.2e}"
9588 );
9589 }
9590 ok
9591}
9592
9593/// The converged-or-not interior iterate `curr` as a crossover seed, in the
9594/// algorithm's compressed / scaled space. The SQP adapter presents that same
9595/// space, so nothing is translated beyond repacking the bound duals:
9596/// `λ_x = z_l − z_u`.
9597fn crossover_seed(
9598 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
9599 curr: &crate::iterates_vector::IteratesVector,
9600) -> crate::crossover::CrossoverSeed {
9601 crate::crossover::CrossoverSeed {
9602 x: dense_values(&*curr.x),
9603 lambda_g: {
9604 let mut v = dense_values(&*curr.y_c);
9605 v.extend(dense_values(&*curr.y_d));
9606 v
9607 },
9608 lambda_x: crate::sqp::ipopt_adapter::pack_bound_multipliers(
9609 nlp_handle,
9610 &dense_values(&*curr.z_l),
9611 &dense_values(&*curr.z_u),
9612 ),
9613 }
9614}
9615
9616/// An accepted crossover result as an interior-point iterate.
9617///
9618/// All eight components have to move together: leaving `s`, `v_l` or `v_u`
9619/// describing the interior point while `x` and the duals describe the
9620/// crossed-over one would make the calculated quantities read off an iterate
9621/// that never existed, and the residuals reported to the user would be
9622/// neither point's. The slack relations are the barrier problem's own —
9623/// `s = d(x)` and `v_l − v_u = −y_d`. `None` (with a warning) when the result
9624/// does not fit the iterate's vectors.
9625fn crossover_iterate(
9626 nlp_handle: &Rc<RefCell<dyn IpoptNlp>>,
9627 curr: &crate::iterates_vector::IteratesVector,
9628 res: &crate::sqp::SqpResult,
9629) -> Option<crate::iterates_vector::IteratesVector> {
9630 let (m_c, m_d) = {
9631 let b = nlp_handle.borrow();
9632 (b.m_eq() as usize, b.m_ineq() as usize)
9633 };
9634 let y_c = &res.lambda_g[..m_c];
9635 let y_d = &res.lambda_g[m_c..];
9636 // `s = d(x)`: the adapter's combined constraint vector is `[c ; d]`,
9637 // so the inequality block is its tail.
9638 let mut adapter = crate::sqp::IpoptNlpAdapter::new(Rc::clone(nlp_handle));
9639 let c_all = crate::sqp::SqpProblemSpec::eval_c(&mut adapter, &res.x);
9640 let s_new = &c_all[m_c..];
9641 debug_assert_eq!(s_new.len(), m_d);
9642 let (z_l, z_u) = crate::sqp::ipopt_adapter::split_bound_multipliers(nlp_handle, &res.lambda_x);
9643 let (v_l, v_u) = crate::sqp::ipopt_adapter::split_slack_multipliers(nlp_handle, y_d);
9644
9645 let mut out = curr.deep_copy();
9646 let ok = set_dense(&mut *out.x, &res.x)
9647 && set_dense(&mut *out.s, s_new)
9648 && set_dense(&mut *out.y_c, y_c)
9649 && set_dense(&mut *out.y_d, y_d)
9650 && set_dense(&mut *out.z_l, &z_l)
9651 && set_dense(&mut *out.z_u, &z_u)
9652 && set_dense(&mut *out.v_l, &v_l)
9653 && set_dense(&mut *out.v_u, &v_u);
9654 if !ok {
9655 // A non-dense backing or a length mismatch. POUNCE is dense-only,
9656 // so this is defensive — but a partially-written iterate is worse
9657 // than no crossover at all, so bail without touching `curr`.
9658 tracing::warn!(
9659 target: "pounce::crossover",
9660 "crossover result did not fit the iterate; keeping the interior point"
9661 );
9662 return None;
9663 }
9664 Some(out.freeze())
9665}
9666
9667fn dense_values(v: &dyn pounce_linalg::Vector) -> Vec<Number> {
9668 v.as_any()
9669 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
9670 .map(|d| d.expanded_values())
9671 .unwrap_or_default()
9672}
9673
9674/// Overwrite a `dyn Vector`'s entries. Returns false — writing nothing —
9675/// when the backing is not dense or the lengths disagree, so a caller
9676/// updating several components together can abandon the whole update rather
9677/// than leave a half-written iterate.
9678fn set_dense(v: &mut dyn pounce_linalg::Vector, vals: &[Number]) -> bool {
9679 match v
9680 .as_any_mut()
9681 .downcast_mut::<pounce_linalg::dense_vector::DenseVector>()
9682 {
9683 Some(d) if pounce_linalg::Vector::dim(d) as usize == vals.len() => {
9684 d.set_values(vals);
9685 true
9686 }
9687 _ => false,
9688 }
9689}
9690
9691/// An owned copy of a [`Solution`] payload already delivered to the user's
9692/// TNLP, enough to deliver it again.
9693///
9694/// Exists because a losing second-opinion retry has to be undoable. The status
9695/// a retry earns is already floored — `run_with_mu_strategy_fallback` returns
9696/// `first_status` unless the retry promotes — but the *point* was not, and the
9697/// user consumes the point. See that function for what went wrong without this.
9698#[derive(Debug, Clone)]
9699struct FinalizeSnapshot {
9700 status: SolverReturn,
9701 x: Vec<Number>,
9702 z_l: Vec<Number>,
9703 z_u: Vec<Number>,
9704 g: Vec<Number>,
9705 lambda: Vec<Number>,
9706 obj_value: Number,
9707 /// The `on_converged` firing of the attempt that produced this payload,
9708 /// `None` if the hook did not fire for it. Travels with the payload, so a
9709 /// floor replay brings the matching firing back with the answer.
9710 on_converged_id: Option<u64>,
9711}
9712
9713/// The option-table overrides (and the box flag) one retry applies for its
9714/// own solve, put back when the scope ends -- on **every** exit, unwinding
9715/// included.
9716///
9717/// The retries used to restore by hand after `optimize_constrained`
9718/// returned, so a panic inside it left the overrides in place. That is not
9719/// hypothetical for an embedder: `pounce-cinterface`'s `ffi_guard` catches the
9720/// panic and keeps the `IpoptApplication`, so the next `IpoptSolve` on that
9721/// handle ran with the scale audit's `max_iter` (`3x` the base solve's),
9722/// `obj_scaling_factor` multiplied by `k`, a CPU / wall budget shrunk to what
9723/// the audited run had left -- and, since gh#983's declared-box fix, the
9724/// variable box un-widened.
9725///
9726/// Derefs to the application, so the retry runs *through* the scope:
9727/// `scope.optimize_constrained(..)`. Each entry is restored with
9728/// [`OptionsList::restore_entry`], i.e. exactly as it stood, absence as
9729/// absence.
9730///
9731/// `on_unwind_only` is for the two retries (μ-strategy fallback, gh#884)
9732/// whose normal-path restore is deliberately *not* "as it stood" -- both
9733/// write the resolved value back with `found = true` -- and whose behaviour
9734/// on that path this guard must not change: there it only covers the unwind.
9735struct RetryOptionScope<'a> {
9736 app: &'a mut IpoptApplication,
9737 saved: Vec<(
9738 &'static str,
9739 pounce_common::options_list::OptionEntrySnapshot,
9740 )>,
9741 on_unwind_only: bool,
9742}
9743
9744impl<'a> RetryOptionScope<'a> {
9745 fn enter(app: &'a mut IpoptApplication, tags: &[&'static str], on_unwind_only: bool) -> Self {
9746 let saved = tags
9747 .iter()
9748 .map(|&t| (t, app.options.snapshot_entry(t)))
9749 .collect();
9750 Self {
9751 app,
9752 saved,
9753 on_unwind_only,
9754 }
9755 }
9756}
9757
9758impl std::ops::Deref for RetryOptionScope<'_> {
9759 type Target = IpoptApplication;
9760 fn deref(&self) -> &IpoptApplication {
9761 self.app
9762 }
9763}
9764
9765impl std::ops::DerefMut for RetryOptionScope<'_> {
9766 fn deref_mut(&mut self) -> &mut IpoptApplication {
9767 self.app
9768 }
9769}
9770
9771impl Drop for RetryOptionScope<'_> {
9772 fn drop(&mut self) {
9773 if self.on_unwind_only && !std::thread::panicking() {
9774 return;
9775 }
9776 for (tag, snap) in self.saved.drain(..) {
9777 self.app.options.restore_entry(tag, snap);
9778 }
9779 self.app.retry_keeps_declared_box.set(false);
9780 }
9781}
9782
9783/// The `final_*` half of [`SolveStatistics`] — the numbers that describe the
9784/// answer and the certificate attached to it.
9785///
9786/// Deliberately **not** the whole struct. `SolveStatistics` mixes two kinds of
9787/// number and they float back differently when a second-opinion retry loses:
9788///
9789/// * the `final_*` fields describe *the point being reported*, so they have to
9790/// agree with the status reported beside them. A `Solved_To_Acceptable_Level`
9791/// carrying a `final_kkt_error` two orders above `acceptable_tol` is
9792/// self-contradictory, and that is pounce#870.
9793/// * the wall clock (`total_wallclock_time_secs`) and the cumulative
9794/// dual-divergence flags describe *what the invocation did*: both attempts
9795/// really ran, so rewinding them would under-report the work actually
9796/// spent.
9797///
9798/// Everything else a solve report reads beside the final residuals describes
9799/// the *run that produced the returned point* (gh#983 item 5): the iteration
9800/// count and rows, the evaluation counts, the restoration tallies and
9801/// `quality_escalations`. Before that fix `deb7` at `max_iter=100` reported the
9802/// declined retry's 100 iterations beside the first attempt's objective and
9803/// point, and `eigenb2` (limited memory) 86 objective evaluations beside the
9804/// first attempt's 47 iterations and 159 evaluations. The first attempt's own
9805/// counts (`deb7`: 84 outer + 32 restoration = 116) are the ones that belong
9806/// with its point. The console's per-attempt summaries still show each
9807/// attempt's own tally.
9808///
9809/// So the certificate and the run it came from are floored together; only the
9810/// wall clock is the invocation's.
9811#[derive(Debug, Clone)]
9812struct SolutionCertificate {
9813 objective: Number,
9814 scaled_objective: Number,
9815 dual_inf: Number,
9816 constr_viol: Number,
9817 compl: Number,
9818 kkt_error: Number,
9819 unscaled_dual_inf: Number,
9820 unscaled_constr_viol: Number,
9821 unscaled_compl: Number,
9822 unscaled_kkt_error: Number,
9823 kkt_error_above_noise: Number,
9824 mu: Number,
9825 /// How far the attempt's point sits outside the model as declared --
9826 /// rows and box. Both are read off the statistics beside the residuals
9827 /// above, and both describe the *point*; leaving them out meant a
9828 /// declined retry's measurement was reported beside the base attempt's
9829 /// point (`Variable bound violation` and `final_declared_box_viol` naming
9830 /// the discarded re-solve's `9.9e-9` next to a point that sits inside
9831 /// its box).
9832 declared_constr_viol: Number,
9833 declared_box_viol: Number,
9834 /// gh#983 item 5: the iteration count and per-iteration rows of the
9835 /// attempt being floored. A solve report reads both from the statistics,
9836 /// so restoring the certificate without them left a report whose
9837 /// numbers describe the returned point and whose iteration table
9838 /// describes the declined retry.
9839 iteration_count: Index,
9840 iterations: Vec<pounce_nlp::solve_statistics::IterRecord>,
9841 /// The per-run evaluation and restoration tallies of the attempt, for the
9842 /// same reason as the iteration table: they are read off the statistics
9843 /// beside the final residuals, and a declined retry used to leave its own
9844 /// (`eigenb2` limited-memory: 86 objective evaluations reported beside the
9845 /// base attempt's 47 iterations and 159 evaluations).
9846 evals: [Index; 5],
9847 restoration: (Index, Index, Index, Number),
9848 quality_escalations: Index,
9849 /// gh#983: the scale the stationarity residual is judged against, the
9850 /// objective scale the run ended with, and the warnings raised about the
9851 /// attempt. All three describe the *point being reported*.
9852 unscaled_dual_scale: Number,
9853 obj_scaling_factor: Number,
9854 total_obj_scaling_factor: Number,
9855 start_obj_grad_max: Number,
9856 warnings: Vec<String>,
9857 /// gh#983 review item 3: the detector's verdict on this run alone.
9858 returned_run_signature: bool,
9859}
9860
9861/// A re-solve's counting state between [`IpoptApplication::begin_counted_resolve`]
9862/// and [`IpoptApplication::end_counted_resolve`].
9863struct CountedResolve {
9864 base_spent: Index,
9865 base_fallback: Option<pounce_nlp::MuStrategyFallbackRecord>,
9866}
9867
9868impl SolutionCertificate {
9869 fn of(s: &pounce_nlp::solve_statistics::SolveStatistics) -> Self {
9870 Self {
9871 objective: s.final_objective,
9872 scaled_objective: s.final_scaled_objective,
9873 dual_inf: s.final_dual_inf,
9874 constr_viol: s.final_constr_viol,
9875 compl: s.final_compl,
9876 kkt_error: s.final_kkt_error,
9877 unscaled_dual_inf: s.final_unscaled_dual_inf,
9878 unscaled_constr_viol: s.final_unscaled_constr_viol,
9879 unscaled_compl: s.final_unscaled_compl,
9880 unscaled_kkt_error: s.final_unscaled_kkt_error,
9881 kkt_error_above_noise: s.final_kkt_error_above_noise,
9882 mu: s.final_mu,
9883 declared_constr_viol: s.final_declared_constr_viol,
9884 declared_box_viol: s.final_declared_box_viol,
9885 iteration_count: s.iteration_count,
9886 iterations: s.iterations.clone(),
9887 evals: [
9888 s.num_obj_evals,
9889 s.num_constr_evals,
9890 s.num_obj_grad_evals,
9891 s.num_constr_jac_evals,
9892 s.num_hess_evals,
9893 ],
9894 restoration: (
9895 s.restoration_calls,
9896 s.restoration_inner_iters,
9897 s.restoration_outer_iters,
9898 s.restoration_wall_secs,
9899 ),
9900 quality_escalations: s.quality_escalations,
9901 unscaled_dual_scale: s.final_unscaled_dual_scale,
9902 obj_scaling_factor: s.final_obj_scaling_factor,
9903 total_obj_scaling_factor: s.final_total_obj_scaling_factor,
9904 start_obj_grad_max: s.start_obj_grad_max,
9905 warnings: s.warnings.clone(),
9906 returned_run_signature: s.returned_run_dual_divergence_signature,
9907 }
9908 }
9909
9910 fn restore_into(&self, s: &mut pounce_nlp::solve_statistics::SolveStatistics) {
9911 s.final_objective = self.objective;
9912 s.final_scaled_objective = self.scaled_objective;
9913 s.final_dual_inf = self.dual_inf;
9914 s.final_constr_viol = self.constr_viol;
9915 s.final_compl = self.compl;
9916 s.final_kkt_error = self.kkt_error;
9917 s.final_unscaled_dual_inf = self.unscaled_dual_inf;
9918 s.final_unscaled_constr_viol = self.unscaled_constr_viol;
9919 s.final_unscaled_compl = self.unscaled_compl;
9920 s.final_unscaled_kkt_error = self.unscaled_kkt_error;
9921 s.final_kkt_error_above_noise = self.kkt_error_above_noise;
9922 s.final_mu = self.mu;
9923 s.final_declared_constr_viol = self.declared_constr_viol;
9924 s.final_declared_box_viol = self.declared_box_viol;
9925 s.iteration_count = self.iteration_count;
9926 s.iterations = self.iterations.clone();
9927 s.num_obj_evals = self.evals[0];
9928 s.num_constr_evals = self.evals[1];
9929 s.num_obj_grad_evals = self.evals[2];
9930 s.num_constr_jac_evals = self.evals[3];
9931 s.num_hess_evals = self.evals[4];
9932 s.restoration_calls = self.restoration.0;
9933 s.restoration_inner_iters = self.restoration.1;
9934 s.restoration_outer_iters = self.restoration.2;
9935 s.restoration_wall_secs = self.restoration.3;
9936 s.quality_escalations = self.quality_escalations;
9937 s.final_unscaled_dual_scale = self.unscaled_dual_scale;
9938 s.final_obj_scaling_factor = self.obj_scaling_factor;
9939 s.final_total_obj_scaling_factor = self.total_obj_scaling_factor;
9940 s.start_obj_grad_max = self.start_obj_grad_max;
9941 s.warnings = self.warnings.clone();
9942 s.returned_run_dual_divergence_signature = self.returned_run_signature;
9943 }
9944}
9945
9946impl FinalizeSnapshot {
9947 /// Re-deliver this payload to `tnlp`, overwriting whatever a later attempt
9948 /// captured there.
9949 fn replay(&self, tnlp: &Rc<RefCell<dyn TNLP>>) {
9950 tnlp.borrow_mut().finalize_solution(
9951 Solution {
9952 status: self.status,
9953 x: &self.x,
9954 z_l: &self.z_l,
9955 z_u: &self.z_u,
9956 g: &self.g,
9957 lambda: &self.lambda,
9958 obj_value: self.obj_value,
9959 },
9960 &TnlpIpoptData::default(),
9961 &TnlpIpoptCq::default(),
9962 );
9963 }
9964}
9965
9966fn finalize_via_orig_nlp(
9967 nlp: &Rc<RefCell<dyn IpoptNlp>>,
9968 alg: &IpoptAlgorithm,
9969 solver_status: SolverReturn,
9970 _app_status: ApplicationReturnStatus,
9971 tnlp: &Rc<RefCell<dyn TNLP>>,
9972 sink: &RefCell<Option<FinalizeSnapshot>>,
9973 fixed_var_duals: bool,
9974) -> Result<Number, ()> {
9975 let curr = alg.data.borrow().curr.clone().ok_or(())?;
9976 // Lift compressed x_var → full-x (length `info.n`) so the user
9977 // TNLP receives the same shape it provided. With `make_parameter`
9978 // the fixed components are spliced back in by the IpoptNlp.
9979 let nlp_borrow = nlp.borrow();
9980 // `finalize_solution_x`, not `lift_x_to_full`: the reported point also
9981 // owes the user the `honor_original_bounds` projection. `f` and `g`
9982 // below are then evaluated at the point actually reported, so x/f/g
9983 // agree with each other.
9984 let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&*curr.x);
9985 let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
9986 let n = info.n as usize;
9987 let m = info.m as usize;
9988 debug_assert_eq!(x_vec.len(), n);
9989 // Lift algorithm-side multipliers back into user-space (pounce#11).
9990 // Use the `finalize_solution_*` family (not the `pack_*` family): the
9991 // final solution duals must be reported in the user's *unscaled-
9992 // Lagrangian* convention `∇f + λ·∇g + z = 0`, which divides out the
9993 // `obj_scale_factor` the algorithm threads through `eval_h`. The `pack_*`
9994 // family deliberately omits that division because it feeds the scaled
9995 // `eval_h`; calling it here left every dual scaled by `obj_scale_factor`
9996 // whenever gradient-based scaling triggered (pounce#11 F1).
9997 // Backends without overrides return empty; fall back to zero stubs so the
9998 // user sees a length-consistent vector.
9999 let mut z_l = nlp_borrow.finalize_solution_z_l(&*curr.z_l);
10000 if z_l.is_empty() {
10001 z_l = vec![0.0; n];
10002 }
10003 let mut z_u = nlp_borrow.finalize_solution_z_u(&*curr.z_u);
10004 if z_u.is_empty() {
10005 z_u = vec![0.0; n];
10006 }
10007 let mut lambda = nlp_borrow.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
10008 if lambda.is_empty() {
10009 lambda = vec![0.0; m];
10010 }
10011 if fixed_var_duals {
10012 nlp_borrow.complete_fixed_var_bound_multipliers(&x_vec, &lambda, &mut z_l, &mut z_u);
10013 }
10014 drop(nlp_borrow);
10015 // Compute g(x) via the user TNLP so the final residual is
10016 // populated for the user.
10017 let mut g_final = vec![0.0; m];
10018 let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
10019 let f_final = tnlp
10020 .borrow_mut()
10021 .eval_f(&x_vec, true)
10022 .unwrap_or(Number::NAN);
10023 let snap = FinalizeSnapshot {
10024 status: solver_status,
10025 x: x_vec,
10026 z_l,
10027 z_u,
10028 g: g_final,
10029 lambda,
10030 obj_value: f_final,
10031 on_converged_id: alg.data.borrow().on_converged_id,
10032 };
10033 snap.replay(tnlp);
10034 *sink.borrow_mut() = Some(snap);
10035 Ok(f_final)
10036}
10037
10038/// Bind SQP suboptions registered in `upstream_options.rs`
10039/// (`sqp_globalization`, `sqp_hessian`, `sqp_max_iter`, `sqp_tol`,
10040/// `sqp_constr_viol_tol`, `sqp_dual_inf_tol`, `sqp_l1_penalty`,
10041/// `sqp_bt_reduction`, `sqp_bt_min_alpha`, `sqp_print_level`,
10042/// `sqp_lbfgs_max_history`) onto
10043/// `opts`. Used by [`IpoptApplication::algorithm_builder_snapshot`]
10044/// before constructing an SQP algorithm.
10045fn apply_sqp_options(options: &OptionsList, opts: &mut crate::sqp::SqpOptions) {
10046 use crate::sqp::{SqpGlobalization, SqpHessianSource};
10047
10048 if let Ok((s, true)) = options.get_string_value("sqp_globalization", "") {
10049 opts.globalization = match s.as_str() {
10050 "filter" => SqpGlobalization::Filter,
10051 "l1-elastic" => SqpGlobalization::L1Elastic,
10052 _ => opts.globalization,
10053 };
10054 }
10055 // `hessian_approximation` is the upstream Ipopt option a frontend sets
10056 // when the caller supplies no second derivatives -- `pounce.minimize` does
10057 // it automatically, and warns that it is doing so. It was only ever read
10058 // on the IPM path, so an SQP solve ignored it and fell back to the
10059 // `Exact` default, asking the NLP for a Lagrangian Hessian that was never
10060 // provided. A zero Hessian turns the QP subproblem into an LP, which is
10061 // unbounded whenever the objective gradient has a component in the null
10062 // space of the active constraints -- so the solve died with
10063 // `Internal_Error` on problems the IPM handles without complaint:
10064 //
10065 // min (x0-3)^2 + (x1-2)^2 s.t. 4 - x0 - x1 >= 0
10066 //
10067 // (IPM: x = [2.5, 1.5]. Active-set SQP before this: Internal_Error, or
10068 // with variable bounds, a run to the box corner along the null-space
10069 // direction.)
10070 //
10071 // The quasi-Newton source picked here is the *dense Powell-damped BFGS*,
10072 // not the limited-memory one, even though the requesting option is spelled
10073 // `limited-memory`. On this active-set-SQP path L-BFGS buys nothing: its
10074 // `as_triplet` materializes a full dense `n×n` Hessian for the QP
10075 // subproblem exactly as `DampedBfgs` does (the matrix-free product
10076 // interface that would make L-BFGS cheaper is not implemented yet), and it
10077 // is markedly less robust -- it stalls with
10078 // `Search_Direction_Becomes_Too_Small` (or reports the QP subproblem
10079 // `unbounded`) on easy, well-conditioned convex QPs whenever a general
10080 // inequality is active at the optimum, returning `success=False` with a
10081 // wrong `x` (issue #358). `DampedBfgs` solves those. So the automatic
10082 // approximation the facade injects when no analytic Hessian is available
10083 // maps to the robust dense update; a caller who genuinely wants
10084 // limited-memory storage can still request it explicitly with
10085 // `sqp_hessian = "lbfgs"` below (read after this, so it wins).
10086 //
10087 // ...up to `sqp_dense_hessian_max_n` variables (gh#1054). Above that the
10088 // dense update is not a robust choice but an impossible one -- `n²`
10089 // storage, and every QP factorization dense -- so `QuasiNewtonAuto`
10090 // resolves per solve, from the model's `n`, to L-BFGS in the lifted
10091 // low-rank form that keeps the QP sparse. The paragraph above still
10092 // governs every model at or below the ceiling.
10093 //
10094 // Read this before `sqp_hessian` so an explicit setting still wins.
10095 if let Ok((s, true)) = options.get_string_value("hessian_approximation", "") {
10096 if s == "limited-memory" {
10097 opts.hessian = SqpHessianSource::QuasiNewtonAuto;
10098 }
10099 }
10100 if let Ok((s, true)) = options.get_string_value("sqp_hessian", "") {
10101 opts.hessian = match s.as_str() {
10102 "exact" => SqpHessianSource::Exact,
10103 "damped-bfgs" => SqpHessianSource::DampedBfgs,
10104 "lbfgs" => SqpHessianSource::Lbfgs,
10105 _ => opts.hessian,
10106 };
10107 }
10108 if let Ok((v, true)) = options.get_integer_value("sqp_max_iter", "") {
10109 if v >= 0 {
10110 opts.max_iter = v as u32;
10111 }
10112 }
10113 if let Ok((v, true)) = options.get_numeric_value("sqp_tol", "") {
10114 opts.tol = v;
10115 }
10116 if let Ok((v, true)) = options.get_numeric_value("sqp_constr_viol_tol", "") {
10117 opts.constr_viol_tol = v;
10118 }
10119 if let Ok((v, true)) = options.get_numeric_value("sqp_dual_inf_tol", "") {
10120 opts.dual_inf_tol = v;
10121 }
10122 if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty", "") {
10123 opts.l1_penalty = v;
10124 }
10125 if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_safety", "") {
10126 opts.l1_penalty_safety = v;
10127 }
10128 if let Ok((v, true)) = options.get_numeric_value("sqp_l1_penalty_max", "") {
10129 opts.l1_penalty_max = v;
10130 }
10131 if let Ok((v, true)) = options.get_numeric_value("sqp_bt_reduction", "") {
10132 opts.bt_reduction = v;
10133 }
10134 if let Ok((v, true)) = options.get_numeric_value("sqp_bt_min_alpha", "") {
10135 opts.bt_min_alpha = v;
10136 }
10137 if let Ok((v, true)) = options.get_integer_value("sqp_print_level", "") {
10138 opts.print_level = v.clamp(0, u8::MAX as i32) as u8;
10139 }
10140 if let Ok((v, true)) = options.get_integer_value("sqp_lbfgs_max_history", "") {
10141 if v >= 1 {
10142 opts.lbfgs_max_history = v as u32;
10143 }
10144 }
10145 // An explicit `sqp_qp_use_schur_updates` is the caller's choice and goes
10146 // to the QP options as given (`apply_qp_subproblem_options`); only an
10147 // unset one is left to the size rule.
10148 if let Ok((_, true)) = options.get_string_value("sqp_qp_use_schur_updates", "") {
10149 opts.qp_schur_min_dim = None;
10150 }
10151 if let Ok((v, true)) = options.get_integer_value("sqp_dense_hessian_max_n", "") {
10152 opts.dense_hessian_max_n = usize::try_from(v).unwrap_or(0);
10153 }
10154}
10155
10156/// Populate the active-set SQP **QP-subproblem** options
10157/// ([`pounce_qp::QpOptions`]) from the `sqp_qp_*` option family.
10158///
10159/// Sister to [`apply_sqp_options`], which handles the SQP *outer-loop*
10160/// options ([`crate::sqp::SqpOptions`]); this one feeds the inner QP
10161/// solver that `SqpAlgorithm` delegates each subproblem to. Consulted
10162/// only on the `ActiveSetSqp` path. Each knob is forwarded only when
10163/// the user explicitly set it, so the `pounce_qp` defaults stand
10164/// otherwise.
10165///
10166/// The reading itself is [`pounce_qp::ActiveSetOverrides`], shared with
10167/// `pounce_convex`'s direct active-set driver, which overlays the same
10168/// eight names onto the same `QpOptions` type. This function had its own
10169/// copy until then, and the two had drifted: this one silently ignored a
10170/// `sqp_qp_max_iter` of 0 and an unknown `sqp_qp_anti_cycling` value where
10171/// the other rejected them. Neither divergence was reachable — the
10172/// registry bounds `sqp_qp_max_iter` at 1 and restricts `anti_cycling` to
10173/// three values — but two readers of one option family is how a
10174/// reachable one starts.
10175fn apply_qp_subproblem_options(options: &OptionsList, opts: &mut pounce_qp::QpOptions) {
10176 match pounce_qp::ActiveSetOverrides::try_from_options_list(options) {
10177 Ok(overrides) => overrides.apply(opts),
10178 // Unreachable from here: this runs after `initialize()`, so every
10179 // value present has already been validated against the registered
10180 // bound that the reader re-checks. Say so out loud anyway rather
10181 // than solving with a configuration the user did not ask for —
10182 // silently dropping the whole family is exactly the failure mode
10183 // `tests/no_silent_options.rs` exists to prevent.
10184 Err(error) => tracing::error!(
10185 target: "pounce::options",
10186 %error,
10187 "sqp_qp_* options were rejected after the registry accepted them; \
10188 the QP subproblem is running on pounce-qp defaults"
10189 ),
10190 }
10191}
10192
10193/// The TNLP's own starting point as an SQP warm start, in algorithm space,
10194/// through the projections the IPM's warm initializer reads
10195/// (`prepare_warm_start`, `get_starting_x/y/z`). `None` when the NLP
10196/// declines. An unseeded bound multiplier (NaN, gh#622) counts as zero: the
10197/// SQP has no `bound_mult_init_val` of its own to resolve it to.
10198fn sqp_tnlp_warm_start(nlp_rc: &Rc<RefCell<dyn IpoptNlp>>) -> Option<crate::sqp::SqpIterates> {
10199 use pounce_linalg::dense_vector::DenseVectorSpace;
10200 let dense = |v: &dyn pounce_linalg::Vector| -> Vec<Number> {
10201 v.as_any()
10202 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
10203 .map(|d| d.expanded_values().to_vec())
10204 .unwrap_or_default()
10205 };
10206 let (z_l, z_u, x, lambda_g) = {
10207 let mut nlp = nlp_rc.borrow_mut();
10208 if !nlp.prepare_warm_start() {
10209 return None;
10210 }
10211 let (n, m_eq, m_ineq) = (nlp.n(), nlp.m_eq(), nlp.m_ineq());
10212 let (n_xl, n_xu) = (nlp.x_l().dim(), nlp.x_u().dim());
10213 let (n_dl, n_du) = (nlp.d_l().dim(), nlp.d_u().dim());
10214 let mut x = DenseVectorSpace::new(n).make_new_dense();
10215 let mut y_c = DenseVectorSpace::new(m_eq).make_new_dense();
10216 let mut y_d = DenseVectorSpace::new(m_ineq).make_new_dense();
10217 let mut z_l = DenseVectorSpace::new(n_xl).make_new_dense();
10218 let mut z_u = DenseVectorSpace::new(n_xu).make_new_dense();
10219 let mut v_l = DenseVectorSpace::new(n_dl).make_new_dense();
10220 let mut v_u = DenseVectorSpace::new(n_du).make_new_dense();
10221 let ok = nlp.get_starting_x(&mut x)
10222 && nlp.get_starting_y(&mut y_c, &mut y_d)
10223 && nlp.get_starting_z(&mut z_l, &mut z_u, &mut v_l, &mut v_u);
10224 nlp.finish_warm_start();
10225 if !ok {
10226 return None;
10227 }
10228 let finite = |v: Vec<Number>| -> Vec<Number> {
10229 v.into_iter()
10230 .map(|a| if a.is_finite() { a } else { 0.0 })
10231 .collect()
10232 };
10233 let mut lambda_g = finite(dense(&y_c));
10234 lambda_g.extend(finite(dense(&y_d)));
10235 (
10236 finite(dense(&z_l)),
10237 finite(dense(&z_u)),
10238 dense(&x),
10239 lambda_g,
10240 )
10241 };
10242 let lambda_x = crate::sqp::ipopt_adapter::pack_bound_multipliers(nlp_rc, &z_l, &z_u);
10243 Some(crate::sqp::SqpIterates {
10244 x,
10245 lambda_g,
10246 lambda_x,
10247 working: None,
10248 })
10249}
10250
10251/// Map a staged SQP warm start from the caller's space into the algorithm's:
10252/// the exact inverse of what [`finalize_via_sqp`] / [`sqp_user_iterate`] do on
10253/// the way out, so that the multipliers a solve reports, fed back unchanged,
10254/// are the iterate the next solve starts from.
10255///
10256/// | field | user space (what callers stage) | algorithm space |
10257/// |---|---|---|
10258/// | `x` | full `n`, model units | fixed columns dropped, `x̃ = d ⊙ x` |
10259/// | `lambda_x` | full `n`, `z_L − z_U` | fixed dropped, `· obj_scal ⊘ d` |
10260/// | `lambda_g` | `m`, in `g` order | `[y_c; y_d]`, `y = λ · obj_scal / {c,d}_scale` |
10261/// | `working.constraints` | `m`, in `g` order | `[c; d]` order |
10262///
10263/// (`working.bounds` goes from full `n` to the reduced columns in the
10264/// caller, which also knows which elimination an already-reduced set came
10265/// from.)
10266///
10267/// `d` is the per-variable factor of a `ScalingTnlp` below the NLP (gh#486;
10268/// `z̃ = z ⊘ d`, its `get_starting_point` rule) and `obj_scal` includes the
10269/// sign of a maximize. The `λ` rule is `OrigIpoptNlp::initialize_starting_point`'s,
10270/// the IPM's inbound half of the same inverse.
10271///
10272/// Every frontend stages user space — the C API and `pounce-py` pass the
10273/// caller's `x` / `mult_g` / `z_L − z_U` through untouched. Before this they
10274/// went in as-is: on a model whose equality is not the first row, the two
10275/// constraint multipliers landed on each other's rows, and any
10276/// `obj_scaling_factor` ≠ 1 left them off by that factor.
10277///
10278/// An `x` / `lambda_x` already of the reduced length (a problem with fixed
10279/// variables) is taken as user-unit values of the surviving columns, in
10280/// order; any other length is left alone for the dimension check to reject.
10281fn sqp_warm_start_to_algorithm(nlp: &dyn IpoptNlp, w: &mut crate::sqp::SqpIterates) {
10282 let n_alg = nlp.n() as usize;
10283 let n_full = nlp.n_full_x() as usize;
10284 let m_full = nlp.n_full_g() as usize;
10285 let m_eq = nlp.m_eq() as usize;
10286 let obj_scal = nlp.obj_scaling_factor();
10287 let d = nlp.variable_scaling();
10288 // Algorithm column -> (user column, its x-scaling factor).
10289 let cols: Vec<(usize, Number)> = (0..n_alg)
10290 .map(|k| {
10291 let j = nlp.var_x_to_full_x(k as Index) as usize;
10292 let dj = d.as_ref().and_then(|d| d.get(j).copied()).unwrap_or(1.0);
10293 (j, dj)
10294 })
10295 .collect();
10296 let to_alg = |v: &[Number], f: &dyn Fn(Number, Number) -> Number| -> Option<Vec<Number>> {
10297 if v.len() == n_full {
10298 Some(cols.iter().map(|&(j, dj)| f(v[j], dj)).collect())
10299 } else if v.len() == n_alg {
10300 Some(cols.iter().zip(v).map(|(&(_, dj), &x)| f(x, dj)).collect())
10301 } else {
10302 None
10303 }
10304 };
10305 if let Some(x) = to_alg(&w.x, &|x, dj| x * dj) {
10306 w.x = x;
10307 }
10308 if let Some(lx) = to_alg(&w.lambda_x, &|z, dj| z * obj_scal / dj) {
10309 w.lambda_x = lx;
10310 }
10311 if w.lambda_g.len() == m_full {
10312 let cs = nlp.c_scale_vec();
10313 let ds = nlp.d_scale_vec();
10314 let mut y = vec![0.0; m_full];
10315 for (i, &lam) in w.lambda_g.iter().enumerate() {
10316 let i = i as Index;
10317 if let Some(k) = nlp.full_g_to_c_block(i) {
10318 let k = k as usize;
10319 let s = cs.as_ref().map_or(1.0, |v| v[k]);
10320 y[k] = lam / s * obj_scal;
10321 } else if let Some(k) = nlp.full_g_to_d_block(i) {
10322 let k = k as usize;
10323 let s = ds.as_ref().map_or(1.0, |v| v[k]);
10324 y[m_eq + k] = lam / s * obj_scal;
10325 }
10326 }
10327 w.lambda_g = y;
10328 }
10329 if let Some(ws) = w.working.as_mut() {
10330 ws.constraints = working_set_rows_to_algorithm(nlp, std::mem::take(&mut ws.constraints));
10331 }
10332}
10333
10334/// Algorithm row `k` of the `[c; d]` constraint vector → user `g` row, or
10335/// `None` when the NLP's row count does not describe a full split.
10336fn algorithm_rows_to_user(nlp: &dyn IpoptNlp) -> Option<Vec<usize>> {
10337 let m_full = nlp.n_full_g() as usize;
10338 let m_eq = nlp.m_eq() as usize;
10339 let m_alg = m_eq + nlp.m_ineq() as usize;
10340 if m_full != m_alg {
10341 return None;
10342 }
10343 let mut map = vec![usize::MAX; m_alg];
10344 for i in 0..m_full {
10345 let k = if let Some(k) = nlp.full_g_to_c_block(i as Index) {
10346 k as usize
10347 } else if let Some(k) = nlp.full_g_to_d_block(i as Index) {
10348 m_eq + k as usize
10349 } else {
10350 return None;
10351 };
10352 if k >= m_alg || map[k] != usize::MAX {
10353 return None;
10354 }
10355 map[k] = i;
10356 }
10357 Some(map)
10358}
10359
10360/// Constraint statuses of a working set, algorithm `[c; d]` order → user
10361/// `g` order. The SQP stacks equalities first, so on a model whose first
10362/// row is an inequality the statuses published positionally landed on the
10363/// wrong rows (`[Equality, AtUpper]` for `g = [ineq, eq]`). A vector of
10364/// any other length is returned unchanged for the dimension check to judge.
10365fn working_set_rows_to_user(
10366 nlp: &dyn IpoptNlp,
10367 rows: Vec<pounce_qp::ConsStatus>,
10368) -> Vec<pounce_qp::ConsStatus> {
10369 match algorithm_rows_to_user(nlp) {
10370 Some(map) if map.len() == rows.len() => {
10371 let mut user = vec![pounce_qp::ConsStatus::Inactive; rows.len()];
10372 for (k, &i) in map.iter().enumerate() {
10373 user[i] = rows[k];
10374 }
10375 user
10376 }
10377 _ => rows,
10378 }
10379}
10380
10381/// Inverse of [`working_set_rows_to_user`]: user `g` order → `[c; d]`.
10382fn working_set_rows_to_algorithm(
10383 nlp: &dyn IpoptNlp,
10384 rows: Vec<pounce_qp::ConsStatus>,
10385) -> Vec<pounce_qp::ConsStatus> {
10386 match algorithm_rows_to_user(nlp) {
10387 Some(map) if map.len() == rows.len() => map.iter().map(|&i| rows[i]).collect(),
10388 _ => rows,
10389 }
10390}
10391
10392/// The SQP iterate in the user's index spaces, for the live-inspector window
10393/// (gh#1015). Same conversions as [`finalize_via_sqp`] — `λ_x` is packed
10394/// signed (`z_L − z_U`) and is split by sign — minus the user-callback
10395/// evaluations: `g` comes from the loop's own `[c; d]` values.
10396fn sqp_user_iterate(
10397 nlp: &Rc<RefCell<dyn IpoptNlp>>,
10398 x: &[Number],
10399 lambda_g: &[Number],
10400 lambda_x: &[Number],
10401 c_vals: &[Number],
10402 grad_lag_x: &[Number],
10403 fixed_var_duals: bool,
10404) -> crate::intermediate::UserIterate {
10405 use pounce_linalg::dense_vector::DenseVectorSpace;
10406
10407 // `finalize_solution_z_*` read the compressed x_l / x_u spaces (one
10408 // entry per finite bound, via `x_l_map`), not one entry per variable;
10409 // `split_bound_multipliers` does that split, as crossover does.
10410 let (zl, zu) = crate::sqp::ipopt_adapter::split_bound_multipliers(nlp, lambda_x);
10411 let nlp = nlp.borrow();
10412 let n_alg = nlp.n() as usize;
10413 let m_eq = nlp.m_eq() as usize;
10414 let m_ineq = nlp.m_ineq() as usize;
10415 let n_user = nlp.n_full_x() as usize;
10416 let m_user = nlp.n_full_g() as usize;
10417
10418 let x_space = DenseVectorSpace::new(n_alg as Index);
10419 let c_space = DenseVectorSpace::new(m_eq as Index);
10420 let d_space = DenseVectorSpace::new(m_ineq as Index);
10421 let dv = |space: &Rc<DenseVectorSpace>, v: &[Number]| {
10422 let mut d = space.make_new_dense();
10423 if !v.is_empty() {
10424 d.set_values(v);
10425 }
10426 d
10427 };
10428
10429 let x_full = nlp.finalize_solution_x(&dv(&x_space, x));
10430 let mut z_l = nlp.finalize_solution_z_l(&dv(&DenseVectorSpace::new(zl.len() as Index), &zl));
10431 if z_l.is_empty() {
10432 z_l = vec![0.0; n_user];
10433 }
10434 let mut z_u = nlp.finalize_solution_z_u(&dv(&DenseVectorSpace::new(zu.len() as Index), &zu));
10435 if z_u.is_empty() {
10436 z_u = vec![0.0; n_user];
10437 }
10438 let (y_c, y_d) = lambda_g.split_at(m_eq.min(lambda_g.len()));
10439 let mut lambda = nlp.finalize_solution_lambda(&dv(&c_space, y_c), &dv(&d_space, y_d));
10440 if lambda.is_empty() {
10441 lambda = vec![0.0; m_user];
10442 }
10443 if fixed_var_duals {
10444 nlp.complete_fixed_var_bound_multipliers(&x_full, &lambda, &mut z_l, &mut z_u);
10445 }
10446 // Everything here is in the space of the TNLP the NLP wraps, which
10447 // under per-variable `user-scaling` is a `ScalingTnlp` seeing
10448 // `x̃ = d ⊙ x`; that wrapper undoes the substitution on the iterate's
10449 // way through its `intermediate_callback`, by its `finalize_solution`'s
10450 // rule (`x = x̃ ⊘ d`, `z = d ⊙ z̃`).
10451 let (c, d) = c_vals.split_at(m_eq.min(c_vals.len()));
10452 let g = nlp.pack_g_for_user(&dv(&c_space, c), &dv(&d_space, d));
10453 // The SQP iterates against the same live bounds (widened only when
10454 // `bound_relax_factor` is set by name on this arm).
10455 let (x_l_alg, x_u_alg) = crate::intermediate::algorithm_x_bounds(&*nlp, &x_full);
10456 crate::intermediate::UserIterate {
10457 x: x_full,
10458 z_l,
10459 z_u,
10460 g,
10461 lambda,
10462 scaling: Some(crate::intermediate::iterate_scaling(&*nlp)),
10463 x_l_alg,
10464 x_u_alg,
10465 grad_lag_x: crate::intermediate::user_grad_lag(&*nlp, grad_lag_x),
10466 }
10467}
10468
10469/// SQP-side analog of [`finalize_via_orig_nlp`]. Hands the SQP
10470/// solution iterate to the user TNLP via the standard
10471/// `finalize_solution` callback. Multiplier lifting goes through
10472/// the same OrigIpoptNlp hooks so the user sees the same shape
10473/// regardless of which algorithm produced the iterate.
10474///
10475/// Returns the user-space objective value on success.
10476fn finalize_via_sqp(
10477 nlp: &Rc<RefCell<dyn IpoptNlp>>,
10478 res: &crate::sqp::SqpResult,
10479 solver_status: pounce_nlp::SolverReturn,
10480 tnlp: &Rc<RefCell<dyn TNLP>>,
10481 sink: &RefCell<Option<FinalizeSnapshot>>,
10482 fixed_var_duals: bool,
10483) -> Result<Number, ()> {
10484 use pounce_linalg::dense_vector::DenseVectorSpace;
10485
10486 let info = tnlp.borrow_mut().get_nlp_info().ok_or(())?;
10487 let n = info.n as usize;
10488 let m = info.m as usize;
10489
10490 // Wrap SQP slices in DenseVectors so we can pass them through
10491 // the OrigIpoptNlp lift_x_to_full / pack_*_for_user hooks.
10492 let nlp_borrow = nlp.borrow();
10493 let n_alg = nlp_borrow.n() as usize;
10494 let m_eq = nlp_borrow.m_eq() as usize;
10495 let m_ineq = nlp_borrow.m_ineq() as usize;
10496 debug_assert_eq!(res.x.len(), n_alg);
10497 debug_assert_eq!(res.lambda_g.len(), m_eq + m_ineq);
10498 debug_assert_eq!(res.lambda_x.len(), n_alg);
10499
10500 let x_space = DenseVectorSpace::new(n_alg as Index);
10501 let c_space = DenseVectorSpace::new(m_eq as Index);
10502 let d_space = DenseVectorSpace::new(m_ineq as Index);
10503
10504 let mut x_dv = x_space.make_new_dense();
10505 x_dv.set_values(&res.x);
10506 let x_vec: Vec<Number> = nlp_borrow.finalize_solution_x(&x_dv);
10507 debug_assert_eq!(x_vec.len(), n);
10508
10509 // λ_x is packed signed (z_l − z_u), one entry per variable. Split it
10510 // into the compressed x_l / x_u spaces `finalize_solution_z_*` read
10511 // (one entry per finite bound): splitting per variable put each
10512 // multiplier on the wrong variable whenever an unbounded one came
10513 // first, as crossover's `split_bound_multipliers` call already avoids.
10514 let (zl_vals, zu_vals) = crate::sqp::ipopt_adapter::split_bound_multipliers(nlp, &res.lambda_x);
10515 let mut z_l_compressed = DenseVectorSpace::new(zl_vals.len() as Index).make_new_dense();
10516 let mut z_u_compressed = DenseVectorSpace::new(zu_vals.len() as Index).make_new_dense();
10517 if !zl_vals.is_empty() {
10518 z_l_compressed.set_values(&zl_vals);
10519 }
10520 if !zu_vals.is_empty() {
10521 z_u_compressed.set_values(&zu_vals);
10522 }
10523 // `finalize_solution_*` (not `pack_*`): report unscaled-Lagrangian duals,
10524 // dividing out `obj_scale_factor` — see `finalize_via_orig_nlp` (F1).
10525 let mut z_l = nlp_borrow.finalize_solution_z_l(&z_l_compressed);
10526 if z_l.is_empty() {
10527 z_l = vec![0.0; n];
10528 }
10529 let mut z_u = nlp_borrow.finalize_solution_z_u(&z_u_compressed);
10530 if z_u.is_empty() {
10531 z_u = vec![0.0; n];
10532 }
10533
10534 // λ_g is [y_c; y_d]; split into the c/d blocks for lift.
10535 let mut y_c_dv = c_space.make_new_dense();
10536 let mut y_d_dv = d_space.make_new_dense();
10537 if m_eq > 0 {
10538 y_c_dv.set_values(&res.lambda_g[..m_eq]);
10539 }
10540 if m_ineq > 0 {
10541 y_d_dv.set_values(&res.lambda_g[m_eq..]);
10542 }
10543 let mut lambda = nlp_borrow.finalize_solution_lambda(&y_c_dv, &y_d_dv);
10544 if lambda.is_empty() {
10545 lambda = vec![0.0; m];
10546 }
10547 if fixed_var_duals {
10548 nlp_borrow.complete_fixed_var_bound_multipliers(&x_vec, &lambda, &mut z_l, &mut z_u);
10549 }
10550 drop(nlp_borrow);
10551
10552 let mut g_final = vec![0.0; m];
10553 let _ = tnlp.borrow_mut().eval_g(&x_vec, true, &mut g_final);
10554 let f_final = tnlp
10555 .borrow_mut()
10556 .eval_f(&x_vec, true)
10557 .unwrap_or(Number::NAN);
10558 let snap = FinalizeSnapshot {
10559 status: solver_status,
10560 x: x_vec,
10561 z_l,
10562 z_u,
10563 g: g_final,
10564 lambda,
10565 obj_value: f_final,
10566 // The SQP arm has no `on_converged` firing.
10567 on_converged_id: None,
10568 };
10569 snap.replay(tnlp);
10570 *sink.borrow_mut() = Some(snap);
10571 Ok(f_final)
10572}
10573
10574/// Whether `options` resolve to the adaptive μ strategy, by the rules
10575/// `IpoptApplication::algorithm_builder_from_options` applies:
10576/// `mehrotra_algorithm=yes` forces adaptive; otherwise an explicit
10577/// `mu_strategy` wins; otherwise a limited-memory Hessian takes upstream's
10578/// quasi-Newton default, adaptive (gh#746), and anything else the registered
10579/// `monotone`.
10580///
10581/// Anything that asks "would this solve already run adaptive?" must ask this,
10582/// not read the raw `mu_strategy` string: under L-BFGS the string is unset and
10583/// the solve is adaptive. The second-opinion ladder read the string, so its
10584/// `mu_strategy=adaptive` rung re-ran a limited-memory base solve
10585/// byte-for-byte (`deb7`, 1610 iterations and `f = 99.636943584` both times).
10586pub fn mu_strategy_resolves_to_adaptive(options: &OptionsList) -> bool {
10587 if matches!(
10588 options.get_string_value("mehrotra_algorithm", ""),
10589 Ok((ref v, true)) if v == "yes"
10590 ) {
10591 return true;
10592 }
10593 if let Ok((v, true)) = options.get_string_value("mu_strategy", "") {
10594 return v == "adaptive";
10595 }
10596 matches!(
10597 options.get_string_value("hessian_approximation", ""),
10598 Ok((ref v, true)) if v == "limited-memory"
10599 )
10600}
10601
10602#[cfg(test)]
10603mod block_structure_tests {
10604 use super::map_block_structure_to_kkt;
10605 use pounce_nlp::tnlp_adapter::BoundClassification;
10606
10607 /// Three variables (the middle one fixed out) and four constraints: two
10608 /// equalities (rows 0 and 3) and two inequalities (rows 1 and 2).
10609 fn classification() -> BoundClassification {
10610 BoundClassification {
10611 n_full_x: 3,
10612 n_full_g: 4,
10613 n_x_fixed: 1,
10614 x_not_fixed_map: vec![0, 2],
10615 x_fixed_map: vec![1],
10616 x_fixed_vals: vec![0.0],
10617 full_to_var: vec![0, -1, 1],
10618 x_l_map: vec![],
10619 x_u_map: vec![],
10620 n_c: 2,
10621 c_map: vec![0, 3],
10622 n_d: 2,
10623 d_map: vec![1, 2],
10624 d_l_map: vec![],
10625 d_u_map: vec![],
10626 full_to_c: vec![0, -1, -1, 1],
10627 full_to_d: vec![-1, 0, 1, -1],
10628 }
10629 }
10630
10631 /// The KKT is `[x | slacks | equality duals | inequality duals]`, so a
10632 /// declaration in the model's terms lands as: the surviving variables'
10633 /// labels, then each inequality's label twice (slack and dual) around the
10634 /// equalities' labels. A variable fixed out of the problem contributes
10635 /// nothing — reading its label anyway would shift every later one.
10636 #[test]
10637 fn model_labels_map_onto_the_kkt_layout() {
10638 let cls = classification();
10639 let var_blocks = vec![7, 99, -1]; // x0 -> block 7, x1 fixed, x2 shared
10640 let con_blocks = vec![5, 6, -1, 8]; // eq 5, ineq 6, ineq shared, eq 8
10641 let labels = map_block_structure_to_kkt(&cls, &var_blocks, &con_blocks).unwrap();
10642 assert_eq!(
10643 labels,
10644 vec![
10645 7, -1, // x: the two unfixed variables
10646 6, -1, // slacks, in d order (rows 1 and 2)
10647 5, 8, // equality duals, in c order (rows 0 and 3)
10648 6, -1, // inequality duals, same order as their slacks
10649 ]
10650 );
10651 }
10652
10653 /// A length that does not match the problem is refused rather than
10654 /// truncated: a structure off by one row declares blocks the matrix does
10655 /// not have, and the solver would only notice as a wrong answer or a
10656 /// rejected partition.
10657 #[test]
10658 fn a_mismatched_declaration_is_refused() {
10659 let cls = classification();
10660 assert!(map_block_structure_to_kkt(&cls, &[1, 2], &[1, 2, 3, 4]).is_err());
10661 assert!(map_block_structure_to_kkt(&cls, &[1, 2, 3], &[1, 2, 3]).is_err());
10662 }
10663}
10664
10665#[cfg(test)]
10666mod tests {
10667 use super::*;
10668
10669 /// pounce#748 — the flipped default is conditional on the caller not
10670 /// having named a `mu_strategy`. All four combinations, because the
10671 /// point of the condition is that an explicit strategy suppresses the
10672 /// automatic retry while an explicit `mu_strategy_fallback` does not.
10673 #[test]
10674 fn mu_strategy_fallback_default_defers_to_an_explicit_strategy() {
10675 // Nothing set: the retry is on.
10676 let app = IpoptApplication::new();
10677 assert!(app.is_mu_strategy_fallback_enabled());
10678
10679 // Caller named a strategy: the automatic retry stands down.
10680 let mut app = IpoptApplication::new();
10681 app.options_mut()
10682 .set_string_value("mu_strategy", "monotone", true, false)
10683 .unwrap();
10684 assert!(!app.is_mu_strategy_fallback_enabled());
10685
10686 // ... unless they also asked for the retry explicitly.
10687 app.options_mut()
10688 .set_string_value("mu_strategy_fallback", "yes", true, false)
10689 .unwrap();
10690 assert!(app.is_mu_strategy_fallback_enabled());
10691
10692 // An explicit "no" is honoured with no strategy set.
10693 let mut app = IpoptApplication::new();
10694 app.options_mut()
10695 .set_string_value("mu_strategy_fallback", "no", true, false)
10696 .unwrap();
10697 assert!(!app.is_mu_strategy_fallback_enabled());
10698 }
10699
10700 /// gh#990: a `mu_strategy` a POUNCE frontend pinned (the Python
10701 /// no-`hessian` path's `monotone`) is not the caller naming a strategy,
10702 /// so the default-on stall retry stays on; an explicit `no` still wins.
10703 #[test]
10704 fn a_pounce_chosen_mu_strategy_keeps_the_fallback_on() {
10705 let mut app = IpoptApplication::new();
10706 app.options_mut()
10707 .set_string_value("hessian_approximation", "limited-memory", true, false)
10708 .unwrap();
10709 app.options_mut()
10710 .set_string_value("mu_strategy", "monotone", true, false)
10711 .unwrap();
10712 assert!(!app.is_mu_strategy_fallback_enabled());
10713 app.mark_mu_strategy_pounce_chosen();
10714 assert!(app.is_mu_strategy_fallback_enabled());
10715 // The pin still decides the schedule: no limited-memory -> adaptive.
10716 assert!(!app.effective_mu_strategy_is_adaptive());
10717 app.options_mut()
10718 .set_string_value("mu_strategy_fallback", "no", true, false)
10719 .unwrap();
10720 assert!(!app.is_mu_strategy_fallback_enabled());
10721 }
10722
10723 use pounce_nlp::tnlp::{
10724 BoundsInfo, IndexStyle, IpoptCq, IpoptData, NlpInfo, ScalingRequest, Solution,
10725 SparsityRequest, StartingPoint,
10726 };
10727
10728 struct Hs071Stub;
10729 impl TNLP for Hs071Stub {
10730 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
10731 // HS071 dimensions: n=4, m=2, dense Jacobian (8 nz),
10732 // dense lower-triangular Hessian (10 nz).
10733 Some(NlpInfo {
10734 n: 4,
10735 m: 2,
10736 nnz_jac_g: 8,
10737 nnz_h_lag: 10,
10738 index_style: IndexStyle::C,
10739 })
10740 }
10741 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
10742 b.x_l.copy_from_slice(&[1.0; 4]);
10743 b.x_u.copy_from_slice(&[5.0; 4]);
10744 b.g_l.copy_from_slice(&[25.0, 40.0]);
10745 b.g_u.copy_from_slice(&[2.0e19, 40.0]);
10746 true
10747 }
10748 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
10749 sp.x.copy_from_slice(&[1.0, 5.0, 5.0, 1.0]);
10750 true
10751 }
10752 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
10753 Some(x[0] * x[3] * (x[0] + x[1] + x[2]) + x[2])
10754 }
10755 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
10756 grad.fill(0.0);
10757 true
10758 }
10759 fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
10760 g.fill(0.0);
10761 true
10762 }
10763 fn eval_jac_g(
10764 &mut self,
10765 _x: Option<&[Number]>,
10766 _new_x: bool,
10767 mode: SparsityRequest<'_>,
10768 ) -> bool {
10769 if let SparsityRequest::Structure { irow, jcol } = mode {
10770 irow.copy_from_slice(&[0, 0, 0, 0, 1, 1, 1, 1]);
10771 jcol.copy_from_slice(&[0, 1, 2, 3, 0, 1, 2, 3]);
10772 }
10773 true
10774 }
10775 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
10776 }
10777
10778 #[test]
10779 fn application_default_does_not_select_sqp() {
10780 let mut app = IpoptApplication::new();
10781 app.initialize().unwrap();
10782 assert!(!app.is_sqp_algorithm_selected());
10783 }
10784
10785 /// gh#1061: `algorithm=active-set-sqp` starts from the interior point at
10786 /// `n + m ≥ AUTO_HANDOVER_MIN_DIM` — and only there, only when the user
10787 /// asked for the active-set SQP, and never when they set
10788 /// `ipm_sqp_handover` themselves.
10789 #[test]
10790 fn active_set_sqp_hands_over_from_the_interior_point_at_size() {
10791 let big = (AUTO_HANDOVER_MIN_DIM - 1000, 1000);
10792 let small = (AUTO_HANDOVER_MIN_DIM - 1000, 999);
10793 let with = |opts: &str| {
10794 let mut app = IpoptApplication::new();
10795 app.initialize().unwrap();
10796 app.initialize_with_options_str(opts).unwrap();
10797 app
10798 };
10799 let sqp = with("algorithm active-set-sqp\n");
10800 assert!(sqp.sqp_auto_handover(big.0, big.1));
10801 assert!(!sqp.sqp_auto_handover(small.0, small.1));
10802 assert!(with("solver_selection qp-active-set\n").sqp_auto_handover(big.0, big.1));
10803 let ipm = with("");
10804 assert!(
10805 !ipm.sqp_auto_handover(big.0, big.1),
10806 "the IPM is not rerouted"
10807 );
10808 for set in ["no", "yes"] {
10809 let app = with(&format!(
10810 "algorithm active-set-sqp\nipm_sqp_handover {set}\n"
10811 ));
10812 assert!(
10813 !app.sqp_auto_handover(big.0, big.1),
10814 "ipm_sqp_handover={set} was set, so the user decides"
10815 );
10816 }
10817 }
10818
10819 #[test]
10820 fn application_routes_to_sqp_when_algorithm_option_set() {
10821 let mut app = IpoptApplication::new();
10822 app.initialize().unwrap();
10823 app.initialize_with_options_str("algorithm active-set-sqp\n")
10824 .unwrap();
10825 assert!(app.is_sqp_algorithm_selected());
10826 }
10827
10828 #[test]
10829 fn feral_min_par_flops_option_reaches_config() {
10830 let mut app = IpoptApplication::new();
10831 app.initialize().unwrap();
10832 // Unset on the OptionsList: falls through to FeralConfig::from_env,
10833 // which leaves it None (inherit feral's built-in default) when the
10834 // POUNCE_FERAL_MIN_PAR_FLOPS env var is also absent.
10835 assert_eq!(
10836 feral_config_from_options(app.options()).min_par_flops,
10837 None,
10838 "unset feral_min_par_flops should not force an override"
10839 );
10840 // Explicit set is mapped through, cast to u64. `0` is the "dispatch
10841 // on every eligible tree" setting and must survive the cast.
10842 app.initialize_with_options_str("feral_min_par_flops 0\n")
10843 .unwrap();
10844 assert_eq!(
10845 feral_config_from_options(app.options()).min_par_flops,
10846 Some(0)
10847 );
10848 // A large finite value passes through intact (5e8 > i32::MAX, which
10849 // is why this is a number option, not an integer one).
10850 app.initialize_with_options_str("feral_min_par_flops 5e8\n")
10851 .unwrap();
10852 assert_eq!(
10853 feral_config_from_options(app.options()).min_par_flops,
10854 Some(500_000_000)
10855 );
10856 }
10857
10858 #[test]
10859 fn feral_refine_steps_option_reaches_config() {
10860 let mut app = IpoptApplication::new();
10861 app.initialize().unwrap();
10862 // Unset: falls through to FeralConfig::from_env, which resolves to
10863 // feral's own DEFAULT_REFINE_MAX_STEPS. The bump to feral 0.17.0 is
10864 // deliberately behaviour-preserving here — the cap that gh#710 wants
10865 // to measure (1) is not yet the default.
10866 assert_eq!(
10867 feral_config_from_options(app.options()).refine_max_steps,
10868 pounce_feral::FeralConfig::default().refine_max_steps,
10869 "unset feral_refine_steps must keep feral's own default"
10870 );
10871 // The setting gh#710 exists to evaluate.
10872 app.initialize_with_options_str("feral_refine_steps 1\n")
10873 .unwrap();
10874 assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 1);
10875 // `0` is a legal cap (zero corrections, refined entry point still
10876 // taken) and must not be read as "unset".
10877 app.initialize_with_options_str("feral_refine_steps 0\n")
10878 .unwrap();
10879 assert_eq!(feral_config_from_options(app.options()).refine_max_steps, 0);
10880 }
10881
10882 #[test]
10883 fn feral_static_pivoting_option_reaches_config() {
10884 let mut app = IpoptApplication::new();
10885 app.initialize().unwrap();
10886 // Unset on the OptionsList: falls through to FeralConfig::from_env,
10887 // which leaves it None (inherit feral's delayed-pivot default) when
10888 // the POUNCE_FERAL_STATIC_PIVOTING env var is also absent — so the
10889 // default numeric path is unchanged.
10890 assert_eq!(
10891 feral_config_from_options(app.options()).static_pivoting,
10892 None,
10893 "unset feral_static_pivoting must not force a numeric override"
10894 );
10895 // Explicit `yes` maps to Some(true): every supernode factors with
10896 // delayed pivoting disabled (feral#8 cascade breaker).
10897 app.initialize_with_options_str("feral_static_pivoting yes\n")
10898 .unwrap();
10899 assert_eq!(
10900 feral_config_from_options(app.options()).static_pivoting,
10901 Some(true)
10902 );
10903 // Explicit `no` maps to Some(false): keep delayed pivoting on
10904 // (distinct from unset, which merely inherits the default).
10905 app.initialize_with_options_str("feral_static_pivoting no\n")
10906 .unwrap();
10907 assert_eq!(
10908 feral_config_from_options(app.options()).static_pivoting,
10909 Some(false)
10910 );
10911 }
10912
10913 /// Convex equality NLP fixture for end-to-end SQP testing
10914 /// through `IpoptApplication`:
10915 ///
10916 /// min ½(x₁² + x₂²) − x₁ − 2x₂ s.t. x₁ + x₂ = 1
10917 ///
10918 /// Closed form: x* = (0, 1), obj = -1.5, λ_g = 1.
10919 struct ConvexEqTnlp {
10920 finalize_called: std::rc::Rc<std::cell::RefCell<Option<(Vec<Number>, Number)>>>,
10921 }
10922 impl TNLP for ConvexEqTnlp {
10923 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
10924 Some(NlpInfo {
10925 n: 2,
10926 m: 1,
10927 nnz_jac_g: 2,
10928 nnz_h_lag: 2,
10929 index_style: IndexStyle::C,
10930 })
10931 }
10932 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
10933 b.x_l.copy_from_slice(&[-2.0e19; 2]);
10934 b.x_u.copy_from_slice(&[2.0e19; 2]);
10935 b.g_l.copy_from_slice(&[1.0]);
10936 b.g_u.copy_from_slice(&[1.0]);
10937 true
10938 }
10939 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
10940 sp.x.copy_from_slice(&[0.0, 0.0]);
10941 true
10942 }
10943 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
10944 Some(0.5 * (x[0] * x[0] + x[1] * x[1]) - x[0] - 2.0 * x[1])
10945 }
10946 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
10947 grad[0] = x[0] - 1.0;
10948 grad[1] = x[1] - 2.0;
10949 true
10950 }
10951 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
10952 g[0] = x[0] + x[1];
10953 true
10954 }
10955 fn eval_jac_g(
10956 &mut self,
10957 _x: Option<&[Number]>,
10958 _new_x: bool,
10959 mode: SparsityRequest<'_>,
10960 ) -> bool {
10961 match mode {
10962 SparsityRequest::Structure { irow, jcol } => {
10963 irow.copy_from_slice(&[0, 0]);
10964 jcol.copy_from_slice(&[0, 1]);
10965 }
10966 SparsityRequest::Values { values, .. } => {
10967 values.copy_from_slice(&[1.0, 1.0]);
10968 }
10969 }
10970 true
10971 }
10972 fn eval_h(
10973 &mut self,
10974 _x: Option<&[Number]>,
10975 _new_x: bool,
10976 _obj_factor: Number,
10977 _lambda: Option<&[Number]>,
10978 _new_lambda: bool,
10979 mode: SparsityRequest<'_>,
10980 ) -> bool {
10981 match mode {
10982 SparsityRequest::Structure { irow, jcol } => {
10983 irow.copy_from_slice(&[0, 1]);
10984 jcol.copy_from_slice(&[0, 1]);
10985 }
10986 SparsityRequest::Values { values, .. } => {
10987 values.copy_from_slice(&[1.0, 1.0]);
10988 }
10989 }
10990 true
10991 }
10992 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
10993 *self.finalize_called.borrow_mut() = Some((sol.x.to_vec(), sol.obj_value));
10994 }
10995 }
10996
10997 /// A TNLP that solves normally once, then declines to supply bounds.
10998 ///
10999 /// The second `optimize_constrained` therefore bails long before the
11000 /// statistics block that records `row_scaling_active`, which is the
11001 /// path the fail-closed reset exists for.
11002 struct DescribesItselfOnce {
11003 /// Flipped by the test between the two solves. A call counter
11004 /// would not work: these hooks are called more than once per
11005 /// solve, so it would trip inside the first one.
11006 refuse: std::rc::Rc<std::cell::Cell<bool>>,
11007 inner: ExactQuadratic,
11008 }
11009 impl TNLP for DescribesItselfOnce {
11010 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
11011 self.inner.get_nlp_info()
11012 }
11013 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
11014 // Declining here is a documented TNLP outcome and unwinds
11015 // cleanly; declining `get_nlp_info` mid-flight panics instead,
11016 // which would test the panic path rather than the reset.
11017 if self.refuse.get() {
11018 return false;
11019 }
11020 self.inner.get_bounds_info(b)
11021 }
11022 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
11023 self.inner.get_starting_point(sp)
11024 }
11025 fn eval_f(&mut self, x: &[Number], n: bool) -> Option<Number> {
11026 self.inner.eval_f(x, n)
11027 }
11028 fn eval_grad_f(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
11029 self.inner.eval_grad_f(x, n, g)
11030 }
11031 fn eval_g(&mut self, x: &[Number], n: bool, g: &mut [Number]) -> bool {
11032 self.inner.eval_g(x, n, g)
11033 }
11034 fn eval_jac_g(&mut self, x: Option<&[Number]>, n: bool, mode: SparsityRequest<'_>) -> bool {
11035 self.inner.eval_jac_g(x, n, mode)
11036 }
11037 fn eval_h(
11038 &mut self,
11039 x: Option<&[Number]>,
11040 n: bool,
11041 o: Number,
11042 l: Option<&[Number]>,
11043 nl: bool,
11044 mode: SparsityRequest<'_>,
11045 ) -> bool {
11046 self.inner.eval_h(x, n, o, l, nl, mode)
11047 }
11048 fn finalize_solution(&mut self, s: Solution<'_>, d: &IpoptData, c: &IpoptCq) {
11049 self.inner.finalize_solution(s, d, c)
11050 }
11051 }
11052
11053 /// gh#794 review round 2: `row_scaling_active` must be fail-closed.
11054 ///
11055 /// It is written near the end of `optimize_constrained`, from the NLP
11056 /// that solve built. A solve that bails before that point used to
11057 /// leave the *previous* solve's value in place — and the ℓ₁ outer
11058 /// loop calls `optimize_constrained` repeatedly and reads the flag
11059 /// after each call, so a stale `Some(false)` would let it mirror an
11060 /// original-units violation into the scaled family. That is exactly
11061 /// the units contract the flag was added to protect.
11062 ///
11063 /// The two solves here are the shape that matters: the first records
11064 /// a verdict, the second never gets far enough to record one.
11065 /// Removing the reset at the top of `optimize_constrained` makes this
11066 /// fail with `Some(false)` where `None` is required — checked, not
11067 /// assumed.
11068 #[test]
11069 fn row_scaling_active_is_cleared_when_a_later_solve_bails_early() {
11070 let mut app = IpoptApplication::new();
11071 app.initialize().unwrap();
11072
11073 // Solve 1: reaches the statistics block and records a verdict.
11074 // `ExactQuadratic` supplies `eval_h`, so the solve stays on the
11075 // exact-Hessian NLP path that writes this flag; a fixture without
11076 // one lands on L-BFGS instead.
11077 let refuse = std::rc::Rc::new(std::cell::Cell::new(false));
11078 let first = std::rc::Rc::new(std::cell::RefCell::new(DescribesItselfOnce {
11079 refuse: std::rc::Rc::clone(&refuse),
11080 inner: ExactQuadratic,
11081 }));
11082 let _ = app
11083 .optimize_tnlp(std::rc::Rc::clone(&first) as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
11084 assert!(
11085 app.row_scaling_active.get().is_some(),
11086 "the first solve did not record a row-scaling verdict, so this \
11087 test cannot show the second one clearing it",
11088 );
11089
11090 // Solve 2, same application: bails before recording anything.
11091 refuse.set(true);
11092 let _ = app.optimize_tnlp(first as std::rc::Rc<std::cell::RefCell<dyn TNLP>>);
11093
11094 assert_eq!(
11095 app.row_scaling_active.get(),
11096 None,
11097 "a solve that bailed before recording row scaling left the \
11098 previous solve's verdict in place; the ℓ₁ outer loop would \
11099 read it as fact and mirror an original-units violation into \
11100 the scaled family (gh#794 review round 2)",
11101 );
11102 }
11103
11104 #[test]
11105 fn application_sqp_path_solves_convex_eq_nlp_and_finalizes() {
11106 let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
11107 let tnlp = std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
11108 finalize_called: std::rc::Rc::clone(&finalize_slot),
11109 }));
11110
11111 let mut app = IpoptApplication::new();
11112 app.initialize().unwrap();
11113 app.initialize_with_options_str("algorithm active-set-sqp\n")
11114 .unwrap();
11115 let status = app.optimize_tnlp(tnlp);
11116 assert_eq!(status, ApplicationReturnStatus::SolveSucceeded);
11117
11118 // The TNLP's finalize_solution must have been invoked.
11119 let recv = finalize_slot.borrow().clone();
11120 let (x_recv, obj_recv) = recv.expect("finalize_solution was not called");
11121 assert_eq!(x_recv.len(), 2);
11122 assert!((x_recv[0] - 0.0).abs() < 1e-6, "x[0] = {}", x_recv[0]);
11123 assert!((x_recv[1] - 1.0).abs() < 1e-6, "x[1] = {}", x_recv[1]);
11124 assert!(
11125 (obj_recv - (-1.5)).abs() < 1e-6,
11126 "obj = {} but expected -1.5",
11127 obj_recv
11128 );
11129 }
11130
11131 #[test]
11132 fn application_routes_to_sqp_case_insensitively() {
11133 let mut app = IpoptApplication::new();
11134 app.initialize().unwrap();
11135 app.initialize_with_options_str("algorithm Active-Set-SQP\n")
11136 .unwrap();
11137 // get_string_value may return the value as-stored (no
11138 // normalization); the dispatch must handle case
11139 // insensitively per the c11 design choice.
11140 assert!(app.is_sqp_algorithm_selected());
11141 }
11142
11143 #[test]
11144 fn application_constructs_and_loads_options() {
11145 let mut app = IpoptApplication::new();
11146 app.initialize().unwrap();
11147 // ipopt.opt-style file: an integer-typed option registered by
11148 // the Interfaces layer.
11149 app.initialize_with_options_str("print_level 5\nfile_print_level 7\n")
11150 .unwrap();
11151 let (level, found) = app.options().get_integer_value("print_level", "").unwrap();
11152 assert!(found);
11153 assert_eq!(level, 5);
11154 }
11155
11156 #[test]
11157 fn application_sqp_suboptions_propagate_to_builder() {
11158 // All SQP suboptions are read by algorithm_builder_snapshot
11159 // and baked into the builder's `sqp` field.
11160 let mut app = IpoptApplication::new();
11161 app.initialize().unwrap();
11162 app.initialize_with_options_str(
11163 "algorithm active-set-sqp\n\
11164 sqp_globalization l1-elastic\n\
11165 sqp_hessian lbfgs\n\
11166 sqp_max_iter 17\n\
11167 sqp_tol 1e-7\n\
11168 sqp_constr_viol_tol 1e-5\n\
11169 sqp_dual_inf_tol 1e-3\n\
11170 sqp_l1_penalty 2.5\n\
11171 sqp_bt_reduction 0.25\n\
11172 sqp_bt_min_alpha 1e-10\n\
11173 sqp_print_level 2\n\
11174 sqp_lbfgs_max_history 12\n",
11175 )
11176 .unwrap();
11177 let snap = app.algorithm_builder_snapshot();
11178 assert_eq!(
11179 snap.sqp.globalization,
11180 crate::sqp::SqpGlobalization::L1Elastic
11181 );
11182 assert_eq!(snap.sqp.hessian, crate::sqp::SqpHessianSource::Lbfgs);
11183 assert_eq!(snap.sqp.max_iter, 17);
11184 assert!((snap.sqp.tol - 1e-7).abs() < 1e-18);
11185 assert!((snap.sqp.constr_viol_tol - 1e-5).abs() < 1e-18);
11186 assert!((snap.sqp.dual_inf_tol - 1e-3).abs() < 1e-18);
11187 assert!((snap.sqp.l1_penalty - 2.5).abs() < 1e-18);
11188 assert!((snap.sqp.bt_reduction - 0.25).abs() < 1e-18);
11189 assert!((snap.sqp.bt_min_alpha - 1e-10).abs() < 1e-18);
11190 assert_eq!(snap.sqp.print_level, 2);
11191 assert_eq!(snap.sqp.lbfgs_max_history, 12);
11192 }
11193
11194 /// Every `sqp_qp_*` key that [`apply_qp_subproblem_options`] reads must
11195 /// actually be *registered*, and must reach `pounce_qp::QpOptions`.
11196 ///
11197 /// The whole family was readable-but-unregistered (gh #360): the options
11198 /// registry rejected each one with OPTION_INVALID, so the reader was
11199 /// unreachable and the documented knobs were unusable. This is the guard
11200 /// that class of omission needs — it fails both if a key stops being
11201 /// registered and if a newly-read key is never registered at all.
11202 #[test]
11203 fn issue_1054_schur_updates_follow_size_unless_set() {
11204 let mut app = IpoptApplication::new();
11205 app.initialize().unwrap();
11206 app.initialize_with_options_str("algorithm active-set-sqp\n")
11207 .unwrap();
11208 assert_eq!(
11209 app.algorithm_builder_snapshot().sqp.qp_schur_min_dim,
11210 Some(crate::sqp::options::DEFAULT_QP_SCHUR_MIN_DIM)
11211 );
11212 for v in ["yes", "no"] {
11213 let mut app = IpoptApplication::new();
11214 app.initialize().unwrap();
11215 app.initialize_with_options_str(&format!(
11216 "algorithm active-set-sqp\nsqp_qp_use_schur_updates {v}\n"
11217 ))
11218 .unwrap();
11219 let snap = app.algorithm_builder_snapshot();
11220 assert_eq!(snap.sqp.qp_schur_min_dim, None, "{v}: explicit wins");
11221 assert_eq!(snap.sqp_qp.use_schur_updates, v == "yes");
11222 }
11223 }
11224
11225 #[test]
11226 fn application_sqp_qp_subproblem_options_are_registered_and_propagate() {
11227 use pounce_qp::AntiCyclingChoice;
11228
11229 // Source of truth: the keys `apply_qp_subproblem_options` reads.
11230 // Kept in step with that function by the round-trip assertions below.
11231 let mut app = IpoptApplication::new();
11232 app.initialize().unwrap();
11233 app.initialize_with_options_str(
11234 "algorithm active-set-sqp\n\
11235 sqp_qp_max_iter 37\n\
11236 sqp_qp_feas_tol 1e-7\n\
11237 sqp_qp_opt_tol 2e-7\n\
11238 sqp_qp_elastic_gamma 1e4\n\
11239 sqp_qp_anti_cycling bland\n\
11240 sqp_qp_use_schur_updates yes\n\
11241 sqp_qp_max_schur_updates_before_refactor 12\n\
11242 sqp_qp_use_homotopy yes\n\
11243 sqp_qp_certify_second_order yes\n",
11244 )
11245 .expect("every sqp_qp_* option must be registered (gh #360)");
11246
11247 let qp = &app.algorithm_builder_snapshot().sqp_qp;
11248 assert_eq!(qp.max_iter, 37);
11249 assert!((qp.feas_tol - 1e-7).abs() < 1e-20);
11250 assert!((qp.opt_tol - 2e-7).abs() < 1e-20);
11251 assert!((qp.elastic_gamma - 1e4).abs() < 1e-9);
11252 assert_eq!(qp.anti_cycling, AntiCyclingChoice::Bland);
11253 // The Schur update path was implemented but reachable only through
11254 // `SqpAlgorithm::with_qp_options`, so no CLI/library user could turn
11255 // it on — the same unreachable-knob defect gh #360 fixed for the rest
11256 // of this family.
11257 assert!(qp.use_schur_updates);
11258 assert_eq!(qp.max_schur_updates_before_refactor, 12);
11259 assert!(qp.use_homotopy);
11260 // gh #848. Off by default on this path (see
11261 // `QpOptions::sqp_subproblem`), so the value that proves the wire is
11262 // live is `yes` — asserting the default would pass on a reader that
11263 // never ran.
11264 assert!(qp.certify_second_order);
11265
11266 // Untouched options must keep the SQP subproblem base, not be
11267 // overwritten with zeros by the "explicitly set" gate. That base is
11268 // `QpOptions::default()` in every field but one; the exception is
11269 // asserted below.
11270 let mut app = IpoptApplication::new();
11271 app.initialize().unwrap();
11272 app.initialize_with_options_str("algorithm active-set-sqp\n")
11273 .unwrap();
11274 let defaults = pounce_qp::QpOptions::default();
11275 let qp = &app.algorithm_builder_snapshot().sqp_qp;
11276 assert_eq!(qp.max_iter, defaults.max_iter);
11277 assert!((qp.feas_tol - defaults.feas_tol).abs() < 1e-20);
11278 assert!((qp.opt_tol - defaults.opt_tol).abs() < 1e-20);
11279 assert_eq!(qp.anti_cycling, defaults.anti_cycling);
11280 // Off in the base QP options: the SQP driver turns it on per solve
11281 // from `n + m >= 500` (gh#1054; measured on Maros-Meszaros, see the
11282 // option text). Do not move that threshold without re-running the
11283 // comparison.
11284 assert!(!qp.use_schur_updates);
11285 assert_eq!(
11286 qp.max_schur_updates_before_refactor,
11287 defaults.max_schur_updates_before_refactor
11288 );
11289 // Off by default *on the SQP path*, and deliberately different from
11290 // `QpOptions::default()` — which is what a standalone `solve_qp`
11291 // gets, and where it is on. gh #848 / gh #856.
11292 assert!(!qp.certify_second_order);
11293 assert!(pounce_qp::QpOptions::default().certify_second_order);
11294 }
11295
11296 /// The other direction of the gh #360 guard: every **registered**
11297 /// `sqp_qp_*` option must be one `apply_qp_subproblem_options` actually
11298 /// reads.
11299 ///
11300 /// The sister test above checks read-keys-are-registered. It cannot catch
11301 /// the inverse, and the inverse happened: `sqp_qp_use_homotopy` was
11302 /// registered with the homotopy work and never wired into the reader, so
11303 /// setting it on the SQP path silently did nothing while the option's own
11304 /// documentation described what it would do. A registered knob that no
11305 /// code reads is worse than a missing one — it validates, it accepts a
11306 /// value, and it lies.
11307 ///
11308 /// Adding a new `sqp_qp_*` option therefore fails here until it is both
11309 /// read by `apply_qp_subproblem_options` and asserted in the round-trip
11310 /// test above.
11311 #[test]
11312 fn application_every_registered_sqp_qp_option_is_read_by_the_subproblem_reader() {
11313 let mut app = IpoptApplication::new();
11314 app.initialize().unwrap();
11315
11316 let mut registered: Vec<String> = app
11317 .registered_options()
11318 .registered_options_in_order()
11319 .iter()
11320 .map(|o| o.name.clone())
11321 .filter(|n| n.starts_with("sqp_qp_"))
11322 .collect();
11323 registered.sort();
11324
11325 // Kept in step by hand with the `options.get_*_value("sqp_qp_…")`
11326 // calls in `apply_qp_subproblem_options`, and cross-checked by the
11327 // round-trip assertions in the sister test.
11328 let mut read_by_the_reader = vec![
11329 "sqp_qp_anti_cycling".to_string(),
11330 "sqp_qp_certify_second_order".to_string(),
11331 "sqp_qp_elastic_gamma".to_string(),
11332 "sqp_qp_feas_tol".to_string(),
11333 "sqp_qp_max_iter".to_string(),
11334 "sqp_qp_max_schur_updates_before_refactor".to_string(),
11335 "sqp_qp_opt_tol".to_string(),
11336 "sqp_qp_use_homotopy".to_string(),
11337 "sqp_qp_use_schur_updates".to_string(),
11338 ];
11339 read_by_the_reader.sort();
11340
11341 assert_eq!(
11342 registered, read_by_the_reader,
11343 "registered sqp_qp_* options and the ones \
11344 `apply_qp_subproblem_options` reads have diverged. A key that is \
11345 registered but unread is a no-op knob with working documentation \
11346 (that is how `sqp_qp_use_homotopy` shipped); a key read but not \
11347 registered is gh #360. Wire it up in both places, assert it in \
11348 `application_sqp_qp_subproblem_options_are_registered_and_propagate`, \
11349 then add it here."
11350 );
11351 }
11352
11353 #[test]
11354 fn application_sqp_hessian_approximation_maps_to_damped_bfgs() {
11355 // The frontend sets `hessian_approximation = limited-memory` when no
11356 // exact Lagrangian Hessian is available (e.g. `pounce.minimize` with
11357 // no `hess`). On the active-set-SQP path that must resolve to the
11358 // dense Powell-damped BFGS, NOT the limited-memory update: L-BFGS
11359 // materializes the same dense Hessian for the QP subproblem yet stalls
11360 // (`Search_Direction_Becomes_Too_Small` / wrong `x`) on convex QPs with
11361 // an active inequality (issue #358); damped-BFGS solves them.
11362 let mut app = IpoptApplication::new();
11363 app.initialize().unwrap();
11364 app.initialize_with_options_str(
11365 "algorithm active-set-sqp\n\
11366 hessian_approximation limited-memory\n",
11367 )
11368 .unwrap();
11369 let snap = app.algorithm_builder_snapshot().sqp;
11370 assert_eq!(snap.hessian, crate::sqp::SqpHessianSource::QuasiNewtonAuto);
11371 // ...which is the dense update at or below the ceiling, and the
11372 // lifted limited-memory one above it (gh#1054).
11373 assert_eq!(
11374 snap.hessian
11375 .resolve(snap.dense_hessian_max_n, snap.dense_hessian_max_n),
11376 crate::sqp::SqpHessianSource::DampedBfgs
11377 );
11378 assert_eq!(
11379 snap.hessian
11380 .resolve(snap.dense_hessian_max_n + 1, snap.dense_hessian_max_n),
11381 crate::sqp::SqpHessianSource::Lbfgs
11382 );
11383
11384 // An explicit `sqp_hessian = lbfgs` is still honored (it is read after
11385 // `hessian_approximation`, so it wins): callers who genuinely want the
11386 // limited-memory update can still ask for it.
11387 let mut app = IpoptApplication::new();
11388 app.initialize().unwrap();
11389 app.initialize_with_options_str(
11390 "algorithm active-set-sqp\n\
11391 hessian_approximation limited-memory\n\
11392 sqp_hessian lbfgs\n",
11393 )
11394 .unwrap();
11395 assert_eq!(
11396 app.algorithm_builder_snapshot().sqp.hessian,
11397 crate::sqp::SqpHessianSource::Lbfgs
11398 );
11399 }
11400
11401 /// `builder.linear_solver` must name the backend that will actually be
11402 /// built, not the one the option string asked for.
11403 ///
11404 /// MA57 is behind the optional `ma57` cargo feature; without it
11405 /// `default_backend_factory` silently substitutes FERAL. Recording `Ma57`
11406 /// anyway made the field disagree with reality, and the Schur KKT gate in
11407 /// `alg_builder::build_with_backend` (which tests `== Feral`) consumed that
11408 /// disagreement — so `set_kkt_schur_block()` never engaged on the default
11409 /// pure-Rust build for any user, while the transparent fallback kept every
11410 /// answer correct and every test green.
11411 #[test]
11412 fn application_linear_solver_records_the_effective_backend() {
11413 // Default options resolve to FERAL in *every* build. The registry
11414 // used to default to upstream's "ma57", which meant an HSL build
11415 // silently ran MA57 without being asked and a pure-Rust build
11416 // advertised a backend it did not contain; the default now names
11417 // pounce's own solver and HSL is opt-in (gh#483 follow-up).
11418 let mut app = IpoptApplication::new();
11419 app.initialize().unwrap();
11420 assert_eq!(
11421 app.algorithm_builder_from_options().linear_solver,
11422 LinearSolverChoice::Feral,
11423 "the registered default is `feral`, in an ma57 build too"
11424 );
11425
11426 // An explicit ma57 request resolves the same way.
11427 let mut app = IpoptApplication::new();
11428 app.initialize().unwrap();
11429 app.initialize_with_options_str("linear_solver ma57\n")
11430 .unwrap();
11431 let got = app.algorithm_builder_from_options().linear_solver;
11432 if cfg!(feature = "ma57") {
11433 assert_eq!(got, LinearSolverChoice::Ma57);
11434 } else {
11435 assert_eq!(got, LinearSolverChoice::Feral);
11436 }
11437
11438 // An explicit feral request is honored in every build.
11439 let mut app = IpoptApplication::new();
11440 app.initialize().unwrap();
11441 app.initialize_with_options_str("linear_solver feral\n")
11442 .unwrap();
11443 assert_eq!(
11444 app.algorithm_builder_from_options().linear_solver,
11445 LinearSolverChoice::Feral
11446 );
11447 }
11448
11449 /// gh#746. `IpAlgBuilder.cpp:1059` substitutes `adaptive` for the
11450 /// registered `monotone` when `hessian_approximation` is
11451 /// limited-memory and the caller left `mu_strategy` alone. pounce
11452 /// read the registered default unconditionally, which is a
11453 /// different barrier schedule on the whole quasi-Newton arm.
11454 #[test]
11455 fn limited_memory_defaults_mu_strategy_to_adaptive() {
11456 // Exact Hessian, nothing set: monotone, as registered.
11457 let mut app = IpoptApplication::new();
11458 app.initialize().unwrap();
11459 assert_eq!(
11460 app.algorithm_builder_from_options().mu_strategy,
11461 MuStrategyChoice::Monotone,
11462 "the exact arm must keep the registered default"
11463 );
11464
11465 // Limited memory, nothing set: adaptive.
11466 let mut app = IpoptApplication::new();
11467 app.initialize().unwrap();
11468 app.initialize_with_options_str("hessian_approximation limited-memory\n")
11469 .unwrap();
11470 assert_eq!(
11471 app.algorithm_builder_from_options().mu_strategy,
11472 MuStrategyChoice::Adaptive,
11473 "limited-memory must take upstream's quasi-Newton default"
11474 );
11475
11476 // An explicit `monotone` still wins — the substitution is only
11477 // for an absent option.
11478 let mut app = IpoptApplication::new();
11479 app.initialize().unwrap();
11480 app.initialize_with_options_str(
11481 "hessian_approximation limited-memory\n\
11482 mu_strategy monotone\n",
11483 )
11484 .unwrap();
11485 assert_eq!(
11486 app.algorithm_builder_from_options().mu_strategy,
11487 MuStrategyChoice::Monotone,
11488 "an explicit mu_strategy must not be overridden"
11489 );
11490 }
11491
11492 /// The μ-strategy auto-fallback retries with the *other* strategy.
11493 /// Under limited-memory the first attempt is adaptive, so the flip
11494 /// has to be monotone — reading the registered default there would
11495 /// re-run the strategy that just stalled (gh#746).
11496 #[test]
11497 fn fallback_flip_follows_the_resolved_mu_strategy() {
11498 let mut app = IpoptApplication::new();
11499 app.initialize().unwrap();
11500 assert!(
11501 !app.effective_mu_strategy_is_adaptive(),
11502 "unset + exact resolves to monotone"
11503 );
11504
11505 let mut app = IpoptApplication::new();
11506 app.initialize().unwrap();
11507 app.initialize_with_options_str("hessian_approximation limited-memory\n")
11508 .unwrap();
11509 assert!(
11510 app.effective_mu_strategy_is_adaptive(),
11511 "unset + limited-memory resolves to adaptive"
11512 );
11513
11514 let mut app = IpoptApplication::new();
11515 app.initialize().unwrap();
11516 app.initialize_with_options_str(
11517 "hessian_approximation limited-memory\n\
11518 mu_strategy monotone\n",
11519 )
11520 .unwrap();
11521 assert!(
11522 !app.effective_mu_strategy_is_adaptive(),
11523 "an explicit monotone under limited-memory resolves to monotone"
11524 );
11525 }
11526
11527 #[test]
11528 fn application_limited_memory_options_propagate_to_builder() {
11529 use crate::hess::lim_mem_quasi_newton::UpdateType;
11530
11531 // Default: no options set -> bit-exact with Ipopt's default
11532 // (bfgs, history 6). This is what the IPM path runs unless the
11533 // user opts in, so it must not drift.
11534 let mut app = IpoptApplication::new();
11535 app.initialize().unwrap();
11536 let def = app.algorithm_builder_from_options();
11537 assert_eq!(def.limited_memory_update_type, UpdateType::Bfgs);
11538 assert_eq!(def.limited_memory_max_history, 6);
11539
11540 // `limited_memory_update_type=sr1` and a custom history length
11541 // must reach the builder (these were registered upstream but
11542 // read nowhere on the IPM path before — see #131). Honoring
11543 // them is what lets SR1 break the monotone L-BFGS stall.
11544 let mut app = IpoptApplication::new();
11545 app.initialize().unwrap();
11546 app.initialize_with_options_str(
11547 "hessian_approximation limited-memory\n\
11548 limited_memory_update_type sr1\n\
11549 limited_memory_max_history 9\n",
11550 )
11551 .unwrap();
11552 let snap = app.algorithm_builder_from_options();
11553 assert_eq!(snap.limited_memory_update_type, UpdateType::Sr1);
11554 assert_eq!(snap.limited_memory_max_history, 9);
11555 }
11556
11557 #[test]
11558 fn application_recalc_y_is_wired_and_defaults_off() {
11559 // #677. Upstream registers `recalc_y` as `no`, but its option
11560 // text ends "If a limited memory quasi-Newton option is chosen,
11561 // this is used by default" — the effective default is
11562 // conditional on the Hessian approximation. pounce refused the
11563 // option outright before this, so an L-BFGS user had no way to
11564 // reach Ipopt's behaviour.
11565
11566 // Exact Hessian: off, matching the registered default.
11567 let mut app = IpoptApplication::new();
11568 app.initialize().unwrap();
11569 let b = app.algorithm_builder_from_options();
11570 assert!(!b.recalc_y, "exact-Hessian default must stay off");
11571 assert_eq!(b.recalc_y_feas_tol, 1e-6, "default changed");
11572
11573 // Limited memory: also off. Upstream's option text says it is
11574 // used by default there; pounce deliberately does not, because
11575 // auto-enabling took 7 of 57 fixtures from solved to not solved
11576 // on the L-BFGS leg with nothing moving the other way. See the
11577 // read site in `algorithm_builder_from_options`. If this
11578 // assertion is what fails, the auto-enable is being restored —
11579 // re-run `scripts/sweep-fixtures.sh` and explain those 7 first.
11580 let mut app = IpoptApplication::new();
11581 app.initialize().unwrap();
11582 app.initialize_with_options_str("hessian_approximation limited-memory\n")
11583 .unwrap();
11584 assert!(
11585 !app.algorithm_builder_from_options().recalc_y,
11586 "limited-memory must not silently enable recalc_y"
11587 );
11588
11589 // An explicit `yes` reaches the exact-Hessian path, which
11590 // used to be refused as unimplemented.
11591 let mut app = IpoptApplication::new();
11592 app.initialize().unwrap();
11593 app.initialize_with_options_str("recalc_y yes\nrecalc_y_feas_tol 1e-3\n")
11594 .unwrap();
11595 let b = app.algorithm_builder_from_options();
11596 assert!(b.recalc_y);
11597 assert_eq!(b.recalc_y_feas_tol, 1e-3);
11598 }
11599
11600 #[test]
11601 fn application_limited_memory_initialization_propagates_to_builder() {
11602 use crate::hess::lim_mem_quasi_newton::InitialApprox;
11603
11604 // #677: registered with upstream's `scalar1` default and read
11605 // nowhere, so every limited-memory solve ran `scalar2` and
11606 // setting the option was a silent no-op. Each keyword must now
11607 // reach the builder.
11608 for (kw, want) in [
11609 ("scalar1", InitialApprox::Scalar1),
11610 ("scalar2", InitialApprox::Scalar2),
11611 ("scalar3", InitialApprox::Scalar3),
11612 ("scalar4", InitialApprox::Scalar4),
11613 ("constant", InitialApprox::Constant),
11614 ("history-max", InitialApprox::HistoryMax),
11615 ] {
11616 let mut app = IpoptApplication::new();
11617 app.initialize().unwrap();
11618 app.initialize_with_options_str(&format!(
11619 "hessian_approximation limited-memory\n\
11620 limited_memory_initialization {kw}\n"
11621 ))
11622 .unwrap();
11623 assert_eq!(
11624 app.algorithm_builder_from_options()
11625 .limited_memory_initialization,
11626 want,
11627 "limited_memory_initialization={kw} did not reach the builder"
11628 );
11629 }
11630
11631 // `limited_memory_init_val` was unread too — the empty-history
11632 // branch hard-coded the same 1.0, so the miss was invisible.
11633 let mut app = IpoptApplication::new();
11634 app.initialize().unwrap();
11635 app.initialize_with_options_str(
11636 "hessian_approximation limited-memory\n\
11637 limited_memory_init_val 4.5\n",
11638 )
11639 .unwrap();
11640 assert_eq!(
11641 app.algorithm_builder_from_options().limited_memory_init_val,
11642 4.5
11643 );
11644
11645 // The effective default matches the registry and Ipopt
11646 // (`scalar1`). This is the assertion that would have caught #677
11647 // when the option was first registered: it pins the *selection*,
11648 // which the per-formula tests in `lim_mem_quasi_newton` cannot
11649 // see. Do not relax it to make an unrelated change pass.
11650 let mut app = IpoptApplication::new();
11651 app.initialize().unwrap();
11652 let def = app.algorithm_builder_from_options();
11653 assert_eq!(def.limited_memory_initialization, InitialApprox::Scalar1);
11654 assert_eq!(def.limited_memory_init_val, 1.0);
11655 }
11656
11657 #[test]
11658 fn application_sqp_warm_start_round_trip() {
11659 // Drive the convex-equality TNLP through the SQP path
11660 // twice. The first solve produces a working set; the
11661 // second is warm-started from it. The second must converge
11662 // with zero QP solves (the first KKT check declares
11663 // optimality immediately).
11664 let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
11665 let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
11666 std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
11667 finalize_called: std::rc::Rc::clone(&finalize_slot),
11668 }));
11669
11670 let mut app = IpoptApplication::new();
11671 app.initialize().unwrap();
11672 app.initialize_with_options_str("algorithm active-set-sqp\n")
11673 .unwrap();
11674
11675 // Cold solve.
11676 let status_a = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
11677 assert_eq!(status_a, ApplicationReturnStatus::SolveSucceeded);
11678 let ws = app.last_sqp_working_set().cloned();
11679 assert!(ws.is_some(), "cold solve must yield a working set");
11680
11681 // Build the warm-start iterate from the converged finalize
11682 // payload (just x; pad multipliers to 0 since the test
11683 // problem is convex).
11684 let (x_recv, _) = finalize_slot.borrow().clone().unwrap();
11685 let warm = crate::sqp::SqpIterates {
11686 x: x_recv,
11687 lambda_g: vec![1.0],
11688 lambda_x: vec![0.0, 0.0],
11689 working: ws,
11690 };
11691 app.set_sqp_warm_start(warm);
11692
11693 // Warm solve.
11694 let status_b = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
11695 assert_eq!(status_b, ApplicationReturnStatus::SolveSucceeded);
11696 assert!(app.last_sqp_working_set().is_some());
11697 }
11698
11699 #[test]
11700 fn application_sqp_warm_start_auto_clears_after_use() {
11701 let finalize_slot = std::rc::Rc::new(std::cell::RefCell::new(None));
11702 let tnlp_rc: std::rc::Rc<std::cell::RefCell<dyn TNLP>> =
11703 std::rc::Rc::new(std::cell::RefCell::new(ConvexEqTnlp {
11704 finalize_called: std::rc::Rc::clone(&finalize_slot),
11705 }));
11706 let mut app = IpoptApplication::new();
11707 app.initialize().unwrap();
11708 app.initialize_with_options_str("algorithm active-set-sqp\n")
11709 .unwrap();
11710 app.set_sqp_warm_start(crate::sqp::SqpIterates {
11711 x: vec![0.0, 1.0],
11712 lambda_g: vec![1.0],
11713 lambda_x: vec![0.0, 0.0],
11714 working: None,
11715 });
11716 assert!(app.sqp_warm_start.is_some());
11717 let _ = app.optimize_tnlp(std::rc::Rc::clone(&tnlp_rc));
11718 assert!(
11719 app.sqp_warm_start.is_none(),
11720 "warm-start input must be auto-cleared after use"
11721 );
11722 }
11723
11724 #[test]
11725 fn application_sqp_suboptions_default_when_unset() {
11726 // Without any sqp_* settings, the snapshot should equal
11727 // SqpOptions::default().
11728 let mut app = IpoptApplication::new();
11729 app.initialize().unwrap();
11730 let snap = app.algorithm_builder_snapshot();
11731 let d = crate::sqp::SqpOptions::default();
11732 assert_eq!(snap.sqp.globalization, d.globalization);
11733 assert_eq!(snap.sqp.hessian, d.hessian);
11734 assert_eq!(snap.sqp.max_iter, d.max_iter);
11735 assert!((snap.sqp.tol - d.tol).abs() < 1e-18);
11736 assert!((snap.sqp.constr_viol_tol - d.constr_viol_tol).abs() < 1e-18);
11737 assert!((snap.sqp.dual_inf_tol - d.dual_inf_tol).abs() < 1e-18);
11738 assert!((snap.sqp.l1_penalty - d.l1_penalty).abs() < 1e-18);
11739 assert!((snap.sqp.bt_reduction - d.bt_reduction).abs() < 1e-18);
11740 assert!((snap.sqp.bt_min_alpha - d.bt_min_alpha).abs() < 1e-18);
11741 assert_eq!(snap.sqp.print_level, d.print_level);
11742 assert_eq!(snap.sqp.lbfgs_max_history, d.lbfgs_max_history);
11743 }
11744
11745 #[test]
11746 fn application_reports_problem_dimensions() {
11747 let app = IpoptApplication::new();
11748 let mut tnlp = Hs071Stub;
11749 let info = app.problem_dimensions(&mut tnlp).unwrap();
11750 assert_eq!(info.n, 4);
11751 assert_eq!(info.m, 2);
11752 assert_eq!(info.nnz_jac_g, 8);
11753 assert_eq!(info.nnz_h_lag, 10);
11754 }
11755
11756 /// Each of the four constant-derivative hints reaches the algorithm,
11757 /// and reaches its *own* slot (#551 / #677).
11758 ///
11759 /// All four were wired and consumed — gh#588 Q6 made pounce exploit
11760 /// them — but the read site looped over
11761 /// `constant_derivatives::HINT_OPTIONS`, so the registered-but-unread
11762 /// scan saw a loop variable where it needs a literal key and reported
11763 /// all four as silent no-ops. They are literals now, and this pins
11764 /// what a literal-per-name rewrite can get wrong that a loop could
11765 /// not: setting one hint must light up that hint's slot and no other,
11766 /// because `reconcile` pairs `asserted[k]` with the model's proof for
11767 /// `HINT_OPTIONS[k]` and a transposed pair would reuse the wrong
11768 /// derivative.
11769 #[test]
11770 fn each_constant_derivative_hint_lights_up_its_own_slot() {
11771 use pounce_nlp::constant_derivatives::HINT_OPTIONS;
11772
11773 let app = IpoptApplication::new();
11774 assert_eq!(
11775 app.asserted_constant_derivative_hints(),
11776 [false; 4],
11777 "no hint is asserted on a fresh options list",
11778 );
11779
11780 for (k, name) in HINT_OPTIONS.iter().enumerate() {
11781 let mut app = IpoptApplication::new();
11782 app.initialize().unwrap();
11783 app.initialize_with_options_str(&format!("{name} yes\n"))
11784 .unwrap();
11785 let mut expected = [false; 4];
11786 expected[k] = true;
11787 assert_eq!(
11788 app.asserted_constant_derivative_hints(),
11789 expected,
11790 "`{name}=yes` must set slot {k} and nothing else",
11791 );
11792
11793 // …and the registered default asks for nothing, so an
11794 // `ipopt.opt` that spells it out changes no derivative reuse.
11795 let mut app = IpoptApplication::new();
11796 app.initialize().unwrap();
11797 app.initialize_with_options_str(&format!("{name} no\n"))
11798 .unwrap();
11799 assert_eq!(
11800 app.asserted_constant_derivative_hints(),
11801 [false; 4],
11802 "`{name}=no` is the registered default and asserts nothing",
11803 );
11804 }
11805 }
11806
11807 /// `min x²` on `[-10, 10]` from `x = 1`, with an *exactly correct*
11808 /// gradient `2x`. Unconstrained and one-dimensional so the only
11809 /// thing the derivative checker can react to is its own step size
11810 /// and threshold.
11811 ///
11812 /// The forward difference at step `h` is `((1+h)² − 1)/h = 2 + h`,
11813 /// so the deviation from the analytic `2` is exactly `h`, and the
11814 /// relative test flags it when `h > tol·(2 + h)`. That makes the
11815 /// verdict a closed-form function of the two knobs under test.
11816 struct ExactQuadratic;
11817 impl TNLP for ExactQuadratic {
11818 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
11819 Some(NlpInfo {
11820 n: 1,
11821 m: 0,
11822 nnz_jac_g: 0,
11823 nnz_h_lag: 0,
11824 index_style: IndexStyle::C,
11825 })
11826 }
11827 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
11828 b.x_l.copy_from_slice(&[-10.0]);
11829 b.x_u.copy_from_slice(&[10.0]);
11830 true
11831 }
11832 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
11833 sp.x.copy_from_slice(&[1.0]);
11834 true
11835 }
11836 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
11837 Some(x[0] * x[0])
11838 }
11839 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
11840 grad[0] = 2.0 * x[0];
11841 true
11842 }
11843 fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
11844 true
11845 }
11846 fn eval_jac_g(
11847 &mut self,
11848 _x: Option<&[Number]>,
11849 _new_x: bool,
11850 _mode: SparsityRequest<'_>,
11851 ) -> bool {
11852 true
11853 }
11854 fn eval_h(
11855 &mut self,
11856 _x: Option<&[Number]>,
11857 _new_x: bool,
11858 _obj_factor: Number,
11859 _lambda: Option<&[Number]>,
11860 _new_lambda: bool,
11861 _mode: SparsityRequest<'_>,
11862 ) -> bool {
11863 true
11864 }
11865 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
11866 }
11867
11868 struct RecordingQuadratic {
11869 gradient_points: Rc<RefCell<Vec<Number>>>,
11870 objective_points: Rc<RefCell<Vec<Number>>>,
11871 x_scaling: Number,
11872 }
11873
11874 impl TNLP for RecordingQuadratic {
11875 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
11876 ExactQuadratic.get_nlp_info()
11877 }
11878
11879 fn get_bounds_info(&mut self, bounds: BoundsInfo<'_>) -> bool {
11880 ExactQuadratic.get_bounds_info(bounds)
11881 }
11882
11883 fn get_starting_point(&mut self, start: StartingPoint<'_>) -> bool {
11884 ExactQuadratic.get_starting_point(start)
11885 }
11886
11887 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
11888 self.objective_points.borrow_mut().push(x[0]);
11889 ExactQuadratic.eval_f(x, new_x)
11890 }
11891
11892 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
11893 self.gradient_points.borrow_mut().push(x[0]);
11894 grad[0] = 2.0 * x[0];
11895 true
11896 }
11897
11898 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
11899 ExactQuadratic.eval_g(x, new_x, g)
11900 }
11901
11902 fn eval_jac_g(
11903 &mut self,
11904 x: Option<&[Number]>,
11905 new_x: bool,
11906 mode: SparsityRequest<'_>,
11907 ) -> bool {
11908 ExactQuadratic.eval_jac_g(x, new_x, mode)
11909 }
11910
11911 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
11912 *req.obj_scaling = 1.0;
11913 *req.use_x_scaling = true;
11914 req.x_scaling[0] = self.x_scaling;
11915 *req.use_g_scaling = false;
11916 true
11917 }
11918
11919 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
11920 }
11921
11922 /// A floor replay makes the floor the recorded answer again, firing id
11923 /// included. The retries nest and each floors to `last_finalize`, so a
11924 /// replay that only re-delivered the payload left the inner retry's
11925 /// discarded payload there for an outer wrapper to floor to, and for
11926 /// `reported_on_converged_id` to name.
11927 #[test]
11928 fn restoring_a_floor_records_it_as_the_answer() {
11929 let snap = |x: Number, id: Option<u64>| FinalizeSnapshot {
11930 status: SolverReturn::Success,
11931 x: vec![x],
11932 z_l: vec![0.0],
11933 z_u: vec![0.0],
11934 g: vec![],
11935 lambda: vec![],
11936 obj_value: x * x,
11937 on_converged_id: id,
11938 };
11939 let app = IpoptApplication::new();
11940 let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(ExactQuadratic));
11941 let base = snap(1.0, Some(1));
11942 // The retry's payload is the last one written, as after an inner
11943 // retry that lost.
11944 *app.last_finalize.borrow_mut() = Some(snap(2.0, Some(2)));
11945 assert_eq!(app.reported_on_converged_id(), Some(2));
11946
11947 app.restore_floor(&base, &tnlp);
11948 assert!(app.answer_restored_from_floor());
11949 assert_eq!(app.reported_on_converged_id(), Some(1));
11950 // What an outer wrapper would take as its own floor.
11951 let outer_floor = app.last_finalize.borrow().clone().unwrap();
11952 assert_eq!(outer_floor.x, base.x);
11953 }
11954
11955 fn derivative_test_verdict(extra: &str) -> pounce_nlp::derivative_test::DerivativeTestReport {
11956 let mut app = IpoptApplication::new();
11957 app.initialize().unwrap();
11958 app.initialize_with_options_str(&format!("derivative_test first-order\n{extra}"))
11959 .unwrap();
11960 let opts = app.derivative_test_options();
11961 pounce_nlp::derivative_test::run(&mut ExactQuadratic, &opts).expect("a report")
11962 }
11963
11964 /// `derivative_test_perturbation` and `derivative_test_tol` were
11965 /// registered, read into [`DerivativeTestOptions`], and consumed by
11966 /// the checker — but nothing proved a *set value* reached it, which
11967 /// is the only assertion that distinguishes a read site from a
11968 /// parse-and-discard (#677, #551).
11969 ///
11970 /// Each of the three verdicts below differs from the one above it by
11971 /// exactly one option, so a knob that stopped reaching the checker
11972 /// would collapse two of them together and fail here.
11973 #[test]
11974 fn the_derivative_checker_knobs_change_the_verdict() {
11975 // Registered defaults (1e-8 / 1e-4), which are also the read
11976 // site's fallbacks: a correct gradient looks correct.
11977 let clean = derivative_test_verdict("");
11978 assert_eq!(clean.checked, 1);
11979 assert_eq!(clean.suspicious, 0, "{:#?}", clean.lines);
11980
11981 // A coarse step makes the *same correct gradient* look wrong:
11982 // deviation 0.5 > 1e-4·2.5. Only `derivative_test_perturbation`
11983 // changed, so this is that option and nothing else.
11984 let coarse = derivative_test_verdict("derivative_test_perturbation 0.5\n");
11985 assert_eq!(coarse.checked, 1);
11986 assert_eq!(
11987 coarse.suspicious, 1,
11988 "derivative_test_perturbation never reached the checker: {:#?}",
11989 coarse.lines,
11990 );
11991
11992 // …and loosening the threshold at that same coarse step clears
11993 // it again: 0.5 < 0.5·2.5. Only `derivative_test_tol` changed.
11994 let tolerant =
11995 derivative_test_verdict("derivative_test_perturbation 0.5\nderivative_test_tol 0.5\n");
11996 assert_eq!(tolerant.checked, 1);
11997 assert_eq!(
11998 tolerant.suspicious, 0,
11999 "derivative_test_tol never reached the checker: {:#?}",
12000 tolerant.lines,
12001 );
12002
12003 // The report also prints what it used, so a user reading the
12004 // output can tell which step and threshold produced the verdict.
12005 assert!(
12006 tolerant.lines[0].contains("5.0e-1"),
12007 "{:#?}",
12008 tolerant.lines,
12009 );
12010 }
12011
12012 #[test]
12013 fn ordinary_derivative_test_keeps_the_conditioned_start() {
12014 let gradient_points = Rc::new(RefCell::new(Vec::new()));
12015 let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
12016 gradient_points: Rc::clone(&gradient_points),
12017 objective_points: Rc::new(RefCell::new(Vec::new())),
12018 x_scaling: 1.0,
12019 })) as Rc<RefCell<dyn TNLP>>;
12020 let mut app = IpoptApplication::new();
12021 app.initialize().unwrap();
12022 app.initialize_with_options_str(
12023 "derivative_test first-order\n\
12024 start_point_perturbation 0.5\n\
12025 hessian_approximation limited-memory\n\
12026 max_iter 0\n\
12027 print_level 0\n",
12028 )
12029 .unwrap();
12030
12031 let _ = app.optimize_tnlp(tnlp);
12032
12033 let first = gradient_points.borrow()[0];
12034 assert!((first - 1.7666216164272852).abs() < 1e-12, "{first}");
12035 }
12036
12037 #[test]
12038 fn ordinary_derivative_test_keeps_variable_scaling() {
12039 let objective_points = Rc::new(RefCell::new(Vec::new()));
12040 let tnlp = Rc::new(RefCell::new(RecordingQuadratic {
12041 gradient_points: Rc::new(RefCell::new(Vec::new())),
12042 objective_points: Rc::clone(&objective_points),
12043 x_scaling: 0.5,
12044 })) as Rc<RefCell<dyn TNLP>>;
12045 let mut app = IpoptApplication::new();
12046 app.initialize().unwrap();
12047 app.initialize_with_options_str(
12048 "derivative_test first-order\n\
12049 derivative_test_perturbation 0.5\n\
12050 nlp_scaling_method user-scaling\n\
12051 hessian_approximation limited-memory\n\
12052 max_iter 0\n\
12053 print_level 0\n",
12054 )
12055 .unwrap();
12056
12057 let _ = app.optimize_tnlp(tnlp);
12058
12059 assert_eq!(&objective_points.borrow()[..2], &[1.0, 2.0]);
12060 }
12061
12062 // ---- gh#887: the dominance gate ------------------------------------
12063 //
12064 // These pin the rule directly, on numbers measured off real runs,
12065 // because the fixture that motivated the gate turned out not to be a
12066 // portable witness for it. `deb7` on the L-BFGS leg with
12067 // `limited_memory_ls_failure_restarts=1` reaches a *materially
12068 // different answer* on macOS and on Linux -- different objective
12069 // (99.677 vs 99.651) and, decisively, a different shape:
12070 //
12071 // | run | unscaled dual | viol | compl | ratio |
12072 // | reproducer, .nl | 7.90e4 | 1.1e-16 | 1.1e-9 | 1.5e-14 |
12073 // | reproducer, TNLP | 3.25e11 | 2.5e-16 | 2.8e-3 | 8.7e-15 |
12074 // | deb7 + rung, macOS | 9.90e1 | 8.0e-13 | 4.65e0 | 4.7e-2 |
12075 // | deb7 + rung, Linux | 5.5743e3 | 5.6e-14 | 2.08e-5 | 3.7e-9 |
12076 //
12077 // The Linux row is the one worth reading twice: that answer really is
12078 // gh#884's shape (scaled overall error 5.28e-1 against unscaled
12079 // 5.57e3, which is the `s_d` normalisation hiding a runaway exactly
12080 // as it did on `qpec_small`), so the retry there is the designed cost
12081 // and not the waste gh#887 filed. A CLI assertion of "deb7 declines"
12082 // is therefore false on Linux no matter what the threshold is, which
12083 // is why the pin lives here instead.
12084
12085 /// The dominance rule on its own, with the absolute floor switched
12086 /// off (`du_floor = 0`), so each of these tests is about one rule.
12087 fn ratio_only(dual_inf: Number, viol: Number, compl: Number) -> bool {
12088 runaway_is_the_whole_residual(dual_inf, viol, compl, 0.0)
12089 }
12090
12091 /// The gate as it actually runs: dominance *and* the detector's own
12092 /// absolute floor.
12093 fn runaway(dual_inf: Number, viol: Number, compl: Number) -> bool {
12094 runaway_is_the_whole_residual(dual_inf, viol, compl, DUAL_DIV_RETRY_DU_FLOOR)
12095 }
12096
12097 #[test]
12098 fn a_converged_point_with_a_runaway_multiplier_opens_the_retry() {
12099 // The gh#884 reproducer, both routes it reaches the gate by.
12100 assert!(runaway(7.90e4, 1.1e-16, 1.1e-9));
12101 assert!(runaway(3.25e11, 2.5e-16, 2.8e-3));
12102 // deb7 under the rung on Linux: primal exact, complementarity
12103 // eight orders under its own dual residual. Same shape.
12104 assert!(runaway(5.5743e3, 5.6e-14, 2.08e-5));
12105 }
12106
12107 #[test]
12108 fn an_unconverged_point_does_not_open_the_retry() {
12109 // deb7 under the rung on macOS: complementarity 4.65, five
12110 // percent of its own KKT error. Not a runaway multiplier on an
12111 // otherwise-converged point -- just an unconverged point.
12112 assert!(!ratio_only(9.90e1, 8.0e-13, 4.65e0));
12113 // Either residual alone is enough to close it: the gate takes the
12114 // max, so a clean complementarity does not excuse a violated
12115 // constraint. (`1.0e1` against `1.0e6` is a ratio of `1e-5`; note
12116 // that `1.0e0` there would be `1e-6` exactly, i.e. inside.)
12117 assert!(!ratio_only(1.0e6, 1.0e1, 1.0e-16));
12118 assert!(!ratio_only(1.0e6, 1.0e-16, 1.0e1));
12119 }
12120
12121 /// The dominance ratio is scale-free, and that is exactly why it
12122 /// cannot be the whole gate: a point converged to `1e-30` primal with
12123 /// a dual residual of `4.4e-1` satisfies it as comfortably as
12124 /// gh#884's `7.9e+04` does, and `4.4e-1` is not a runaway by any
12125 /// reading of the issue. Measured on the 400-model QPEC family, the
12126 /// floor alone removes 7 of 68 promotions, every one of them on an
12127 /// answer whose reported dual residual was below `1e2`.
12128 ///
12129 /// The floor is the *detector's*, so this is the answer-level gate
12130 /// asking about the same magnitude the iterate-level one did rather
12131 /// than being a strictly looser copy of it.
12132 #[test]
12133 fn a_small_dual_residual_is_not_a_runaway_however_dominant() {
12134 // Passes the ratio comfortably (1e-30 / 4.4e-1 = 2.3e-30) ...
12135 assert!(ratio_only(4.4e-1, 1.0e-30, 1.0e-30));
12136 // ... and is still refused, because it is not a runaway.
12137 assert!(!runaway(4.4e-1, 1.0e-30, 1.0e-30));
12138 // The two real `r`-family answers that reached the gate this way.
12139 assert!(!runaway(4.397e-1, 1.0e-16, 1.0e-16));
12140 assert!(!runaway(2.026e1, 1.0e-16, 1.0e-16));
12141 // Exactly at the floor is inside; a hair under is outside.
12142 assert!(runaway(DUAL_DIV_RETRY_DU_FLOOR, 0.0, 0.0));
12143 assert!(!runaway(DUAL_DIV_RETRY_DU_FLOOR * 0.999, 0.0, 0.0));
12144 }
12145
12146 #[test]
12147 fn the_threshold_is_where_the_constant_says_it_is() {
12148 // Exactly at the ratio is inside; a hair past it is outside.
12149 assert!(ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE, 0.0));
12150 assert!(!ratio_only(1.0, DUAL_DIV_RETRY_DOMINANCE * 1.001, 0.0));
12151 }
12152
12153 #[test]
12154 fn what_we_cannot_measure_does_not_buy_a_retry() {
12155 // A NaN compares false everywhere, so the condition is written so
12156 // that "we cannot tell" declines rather than retries. Each
12157 // argument in turn.
12158 assert!(!ratio_only(Number::NAN, 0.0, 0.0));
12159 assert!(!ratio_only(1.0e6, Number::NAN, 0.0));
12160 assert!(!ratio_only(1.0e6, 0.0, Number::NAN));
12161 assert!(!ratio_only(Number::INFINITY, 0.0, 0.0));
12162 // And a dual residual that is not a runaway at all: the ratio
12163 // would be meaningless, and a zero-dual point is not gh#884.
12164 assert!(!ratio_only(0.0, 0.0, 0.0));
12165 assert!(!ratio_only(-1.0, 0.0, 0.0));
12166 }
12167
12168 // ---- the promotion gate reads the ANSWER, not only the certificate --
12169 //
12170 // gh#884 ranked the two attempts on unscaled KKT error alone and
12171 // argued that this could not return a different local solution,
12172 // because "conjunct 4 requires the promoted answer to satisfy the KKT
12173 // conditions in the model's own units". Any other KKT point does too.
12174 // Measured on 400 random QPECs under the `prod_eq` lowering
12175 // (`bound_relax_factor=0 mu_strategy_fallback=no tol=1e-8`): 68
12176 // promotions, 42 of which moved the objective materially, and three
12177 // of which returned a strictly worse *feasible* point.
12178 //
12179 // Every number below is off a real run, and the two branches are
12180 // separated on purpose -- a rule that branches needs a case on each
12181 // side or the untaken one stays broken while the test is green.
12182 //
12183 // | model | base f | base viol | retry f | retry viol | branch |
12184 // |------------|-------------|-----------|-------------|------------|--------|
12185 // | qpec_small | +3.586e-28 | 1.11e-16 | +5.835e-11 | 5.47e-12 | admit |
12186 // | r116 | -1.3006e+01 | 2.22e-16 | -1.2072e+00 | 4.55e-13 | rule 1 |
12187 // | r261 | -4.7919e+00 | 1.07e-14 | -9.8563e-01 | 7.99e-14 | rule 1 |
12188 // | r201 | -2.9559e-01 | 6.25e-17 | -9.7321e-02 | 6.21e-13 | rule 1 |
12189 // | scholtes4 | +1.8176e-09 | 2.07e-25 | -6.6088e-05 | 1.09e-09 | rule 2 |
12190
12191 const ACCEPT: Number = 1e-6;
12192 /// Minimization, which is every row of the table above.
12193 const MIN: Number = 1.0;
12194 /// Maximization, i.e. `obj_scaling_factor < 0`.
12195 const MAX: Number = -1.0;
12196
12197 fn admissible(bo: Number, bv: Number, ro: Number, rv: Number) -> bool {
12198 retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN)
12199 }
12200
12201 #[test]
12202 fn the_reproducers_promotion_is_still_admissible() {
12203 // `qpec_small`: the retry's objective is *worse*, by 5.8e-11 --
12204 // deliberately, since it buys nine orders of unscaled dual
12205 // residual. Five orders inside the tolerance, so rule 1 admits
12206 // it, which is the whole point of having a tolerance at all.
12207 assert!(admissible(3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12));
12208 }
12209
12210 /// Rule 1: a strictly worse feasible point is never an upgrade.
12211 #[test]
12212 fn a_worse_feasible_objective_is_refused_however_clean_the_certificate() {
12213 // r116: -13.0057 -> -1.2072, both independently verified feasible
12214 // by `pounce verify`. The retry's unscaled KKT error is 2.9e-11
12215 // against the base attempt's 3.0e+03, so every certificate
12216 // conjunct passes and only this one refuses it.
12217 assert!(!admissible(
12218 -1.3005680756e1,
12219 2.22e-16,
12220 -1.2072337962e0,
12221 4.55e-13
12222 ));
12223 assert!(!admissible(
12224 -4.7919265770e0,
12225 1.07e-14,
12226 -9.8562977711e-1,
12227 7.99e-14
12228 ));
12229 assert!(!admissible(
12230 -2.9558632401e-1,
12231 6.25e-17,
12232 -9.7321185691e-2,
12233 6.21e-13
12234 ));
12235 }
12236
12237 /// The declared-box conjunct is scale-safe: at a bound of `1e6`, two
12238 /// points pinned to the same widened bound differ by an ulp (`1.2e-10`),
12239 /// which an absolute `1e-12` floor read as the re-solve using more of the
12240 /// widening; relative to the bound it is rounding. A genuine excess at
12241 /// that magnitude -- the bound-magnitude probe's `2.5e-6`, i.e. `2.5e-12`
12242 /// relative -- is still refused, and at a unit bound `1e-8` is.
12243 #[test]
12244 fn the_declared_box_conjunct_is_relative_to_each_bounds_magnitude() {
12245 let (lo, up) = (-1e19, 1e19);
12246 let rel = |x: &[Number], l: &[Number], u: &[Number]| {
12247 declared_box_relative_violation(x, l, u, lo, up)
12248 };
12249 let ub: Number = 1e6;
12250 let pinned: Number = ub + 1e-4; // on the widened bound (`constr_viol_tol` cap)
12251 let one_ulp_out = f64::from_bits(pinned.to_bits() + 1);
12252 assert!(
12253 one_ulp_out - pinned > 1e-12,
12254 "premise: an ulp of 1e6 exceeds 1e-12"
12255 );
12256 let (l, u) = (vec![0.0], vec![ub]);
12257 let base = rel(&[pinned], &l, &u);
12258 assert!(retry_keeps_declared_box(base, rel(&[one_ulp_out], &l, &u)));
12259 // The absolute rule this replaces declined exactly that pair.
12260 assert!(one_ulp_out - ub > (pinned - ub) + 1e-12);
12261 let genuine = rel(&[ub + 9.9995e-5], &l, &u);
12262 let base_genuine = rel(&[ub + 9.7494e-5], &l, &u);
12263 assert!(!retry_keeps_declared_box(base_genuine, genuine));
12264 // Unit bound: the widening's own size is refused, rounding is not.
12265 let (l1, u1) = (vec![0.0], vec![1.0]);
12266 assert!(!retry_keeps_declared_box(0.0, rel(&[1.0 + 1e-8], &l1, &u1)));
12267 assert!(retry_keeps_declared_box(
12268 0.0,
12269 rel(&[f64::from_bits(1.0f64.to_bits() + 1)], &l1, &u1)
12270 ));
12271 // Lower bounds, sentinels, and single-point boxes.
12272 assert_eq!(rel(&[-2e-4], &[-1e4], &[1e19]), 0.0);
12273 assert!(rel(&[-1e4 - 1e-4], &[-1e4], &[1e19]) > 0.0);
12274 assert_eq!(rel(&[5.0], &[-1e19], &[1e19]), 0.0);
12275 assert_eq!(rel(&[0.5 + 1e-8], &[0.5], &[0.5]), 0.0);
12276 assert!(rel(&[1.0], &[0.0, 0.0], &[1.0, 1.0]).is_nan());
12277 // `NaN` is "not measured".
12278 assert!(retry_keeps_declared_box(Number::NAN, Number::NAN));
12279 assert!(!retry_keeps_declared_box(0.0, Number::NAN));
12280 }
12281
12282 /// Rule 2: an objective *improvement* bought with primal slack.
12283 ///
12284 /// `scholtes4` (`benchmarks/mpcc/cases.py`, and now a CLI fixture) has
12285 /// `f* = 0` exactly -- for the MPCC and for the smooth lowering alike,
12286 /// since `x1*x2 = 0` forces one of them to zero, hence `x3 <= 0`, hence
12287 /// `f = x1 - x3 >= 0`. The retry reports `-6.61e-05`, which no feasible
12288 /// point reaches, by moving the complementarity row 16 orders further
12289 /// out. Rule 1 cannot see it: the objective got *better*.
12290 #[test]
12291 fn an_improvement_bought_with_primal_slack_is_refused() {
12292 assert!(!admissible(
12293 1.8175997416e-9,
12294 2.07e-25,
12295 -6.6088333055e-5,
12296 1.09e-9
12297 ));
12298 // The same numbers with the primal *held* would be admissible --
12299 // this is the conjunct that refuses it, not the objective move.
12300 assert!(admissible(
12301 1.8175997416e-9,
12302 2.07e-25,
12303 -6.6088333055e-5,
12304 2.07e-25
12305 ));
12306 }
12307
12308 /// The window the tolerance sits in, from both sides, so it is a
12309 /// checkable claim rather than a fitted constant: the smallest move
12310 /// that must be admitted is `qpec_small`'s `5.8e-11` and the smallest
12311 /// that must be refused is `r201`'s `0.198`, four and five orders
12312 /// away from `acceptable_tol` on either side.
12313 #[test]
12314 fn the_objective_tolerance_is_not_fitted_to_one_model() {
12315 let admit = 5.835e-11;
12316 let refuse = 0.198;
12317 assert!(admit < ACCEPT / 1.0e4, "{admit} is not well inside");
12318 assert!(refuse > ACCEPT * 1.0e4, "{refuse} is not well outside");
12319 // Scale-relative above 1, absolute below it -- the same
12320 // convention `sigma_forward_error_is_small` uses for `norm(x)`.
12321 assert!(admissible(1.0e6, 0.0, 1.0e6 + 0.5, 0.0));
12322 assert!(!admissible(1.0e6, 0.0, 1.0e6 + 5.0, 0.0));
12323 }
12324
12325 /// gh#983 item 4: `x*y == 0` from `(0.3, 0.3)`. The base attempt sits at
12326 /// `f = 2` with a violation of `3e-38` and an unscaled dual infeasibility
12327 /// of `1.4e9`; the retry reaches `f = 1` at `1.96e-13`, six orders inside
12328 /// `constr_viol_tol`. Rule 2 compared the two violations bare and refused
12329 /// it. The floor admits arithmetic noise and nothing above it: the
12330 /// scholtes4 row (`1.09e-9`) is refused in the same breath.
12331 #[test]
12332 fn a_retry_violation_at_the_noise_floor_does_not_block_an_improvement() {
12333 assert!(retry_answer_is_admissible(
12334 2.0, 3.037e-38, 1.0, 1.9584e-13, ACCEPT, MIN
12335 ));
12336 assert!(retry_answer_is_admissible(
12337 -2.0, 3.037e-38, -1.0, 1.9584e-13, ACCEPT, MAX
12338 ));
12339 // Just above the floor is still slack.
12340 assert!(!retry_answer_is_admissible(
12341 2.0,
12342 3.037e-38,
12343 1.0,
12344 RETRY_VIOL_NOISE_FLOOR * 10.0,
12345 ACCEPT,
12346 MIN
12347 ));
12348 // ... and rule 1 is untouched by the floor.
12349 assert!(!retry_answer_is_admissible(
12350 1.0, 3.037e-38, 2.0, 1.0e-13, ACCEPT, MIN
12351 ));
12352 // MacMPEC pack-comp1p-32's exact finish: the retry reaches the
12353 // published optimum at a violation three orders inside `tol`, next to
12354 // a base at 2.5e-12. Admitted.
12355 assert!(retry_answer_is_admissible(
12356 0.704, 2.5e-12, 0.652997, 2.6e-11, ACCEPT, MIN
12357 ));
12358 // scholtes4: f* = 0 exactly, and the retry buys -6.6e-5 with a
12359 // violation of 1.09e-9. Still refused.
12360 assert!(!retry_answer_is_admissible(
12361 1.82e-9, 2.07e-25, -6.61e-5, 1.09e-9, ACCEPT, MIN
12362 ));
12363 }
12364
12365 /// The audit input with every measurement given and the defaults for
12366 /// the rest (`tol 1e-8`, `acceptable_tol 1e-6`, threshold `1e-4`).
12367 #[allow(clippy::too_many_arguments)]
12368 fn audit_in(
12369 tol: Number,
12370 obj_scale: Number,
12371 rescaled: Number,
12372 start_grad: Number,
12373 final_grad: Number,
12374 kkt: Number,
12375 compl: Number,
12376 th: Number,
12377 ) -> ScaleAuditInput {
12378 ScaleAuditInput {
12379 tol,
12380 obj_scale,
12381 rescaled_obj_scale: rescaled,
12382 start_grad_max: start_grad,
12383 final_grad_max: final_grad,
12384 unscaled_kkt: kkt,
12385 unscaled_compl: compl,
12386 acceptable_tol: 1e-6,
12387 masked_threshold: th,
12388 warm_start: false,
12389 }
12390 }
12391
12392 /// `audit_in` for a warm start (`warm_start_init_point=yes`).
12393 #[allow(clippy::too_many_arguments)]
12394 fn audit_in_warm(
12395 tol: Number,
12396 obj_scale: Number,
12397 rescaled: Number,
12398 start_grad: Number,
12399 final_grad: Number,
12400 kkt: Number,
12401 compl: Number,
12402 th: Number,
12403 ) -> ScaleAuditInput {
12404 ScaleAuditInput {
12405 warm_start: true,
12406 ..audit_in(
12407 tol, obj_scale, rescaled, start_grad, final_grad, kkt, compl, th,
12408 )
12409 }
12410 }
12411
12412 /// gh#983 / CI: a warm start is an answer, so its gradient -- and the
12413 /// returned point's -- carry the model's scale only above the barrier
12414 /// level. Both branches of the warm rule need a row: the session's
12415 /// interior optimum (gradient at the complementarity) stays out, and the
12416 /// reactor warm-started at its answer (gradient `4e3` times it) is still
12417 /// up-scaled.
12418 #[test]
12419 fn a_warm_start_at_an_answer_is_not_read_as_a_tiny_objective() {
12420 let th = 1e-4;
12421 let t = |inp| scale_audit_trigger(inp);
12422 // `cold_then_warm_through_presolve`'s warm re-solve, measured.
12423 assert_eq!(
12424 t(audit_in_warm(
12425 1e-8, 1.0, 1.0, 1.0e-11, 3.726e-9, 3.72e-9, 3.72e-9, th
12426 )),
12427 None
12428 );
12429 // The same numbers on a cold start are the tiny-objective signature:
12430 // the rule is the warm start's, not a change to the cold branch.
12431 assert!(matches!(
12432 t(audit_in(
12433 1e-8, 1.0, 1.0, 1.0e-11, 3.726e-9, 3.72e-9, 3.72e-9, th
12434 )),
12435 Some(ScaleAudit::Upscale(_))
12436 ));
12437 // The reactor (fs = 1e-6) warm-started from its answer: the active
12438 // bound's multiplier keeps the gradient at 1e-5, the model's scale.
12439 match t(audit_in_warm(
12440 1e-8, 1.0, 1.0, 1e-5, 1e-5, 2.5e-9, 2.5e-9, th,
12441 )) {
12442 Some(ScaleAudit::Upscale(k)) => assert!((k - 1e5).abs() < 1.0),
12443 other => panic!("{other:?}"),
12444 }
12445 // A barrier-level seed does not mask an answer that carries scale.
12446 assert!(matches!(
12447 t(audit_in_warm(
12448 1e-8, 1.0, 1.0, 1e-11, 1e-5, 2.5e-9, 2.5e-9, th
12449 )),
12450 Some(ScaleAudit::Upscale(_))
12451 ));
12452 // The re-scale branch does not read the gradient scale at all.
12453 assert_eq!(
12454 t(audit_in_warm(
12455 1e-8, 3e-7, 1.0, 3e8, 2.9e-2, 2.9e-2, 1e-9, th
12456 )),
12457 Some(ScaleAudit::Rescale)
12458 );
12459 }
12460
12461 /// gh#983: the audit's trigger branches on *which* scaling defect shows,
12462 /// and each branch needs its own row.
12463 #[test]
12464 fn the_scale_audit_trigger_takes_each_branch_and_neither_by_default() {
12465 let th = 1e-4;
12466 let t = |inp| scale_audit_trigger(inp);
12467 // Frozen factor, non-stationary in the model's own units, and the
12468 // factor re-evaluated at the returned point (gradient 2.9e-2 -> 1) is
12469 // seven orders from the frozen one: re-scale.
12470 assert_eq!(
12471 t(audit_in(1e-8, 3e-7, 1.0, 3e8, 2.9e-2, 2.9e-2, 1e-9, th)),
12472 Some(ScaleAudit::Rescale)
12473 );
12474 // gh#983 review item 2(b): the same masked certificate on a
12475 // legitimately large objective (`hs71 x 1e8`) re-evaluates to the
12476 // factor it already has -- the re-solve would be the same solve.
12477 assert_eq!(
12478 t(audit_in(1e-8, 2.2e-7, 2.4e-7, 4.6e8, 4.2e8, 3e-6, 1e-9, th)),
12479 None
12480 );
12481 // Tiny gradient, complementarity 2.5e-4 of it (the reactor, fs = 1e-6).
12482 match t(audit_in(1e-8, 1.0, 1.0, 1e-5, 1e-5, 2.5e-9, 2.5e-9, th)) {
12483 Some(ScaleAudit::Upscale(k)) => assert!((k - 1e5).abs() < 1.0),
12484 other => panic!("{other:?}"),
12485 }
12486 // gh#983 review item 4: no band without a signal. fs = 3e-5 (gradient
12487 // 3e-4, relative complementarity 8.4e-6) and fs = 1e-3 (2.5e-7) are
12488 // both re-solved now; fs = 1 is left alone.
12489 assert!(matches!(
12490 t(audit_in(1e-8, 1.0, 1.0, 3e-4, 3e-4, 2.5e-9, 2.5e-9, th)),
12491 Some(ScaleAudit::Upscale(_))
12492 ));
12493 assert!(matches!(
12494 t(audit_in(1e-8, 1.0, 1.0, 1e-2, 1e-2, 2.5e-9, 2.5e-9, th)),
12495 Some(ScaleAudit::Upscale(_))
12496 ));
12497 assert_eq!(
12498 t(audit_in(1e-8, 1.0, 1.0, 10.0, 10.0, 2.5e-9, 2.5e-9, th)),
12499 None
12500 );
12501 // A gradient in [0.1, 1): the signature, but an up-scale below the
12502 // material factor -- warn, do not re-solve.
12503 assert_eq!(
12504 t(audit_in(1e-8, 1.0, 1.0, 0.5, 0.5, 1e-6, 1e-6, th)),
12505 Some(ScaleAudit::SmallObjective)
12506 );
12507 // gh#983 review item 2(a): a start near a stationary point
12508 // (`grad f(x0) = 2e-7`) is not a tiny objective when the gradient at
12509 // the answer is 1.
12510 assert_eq!(
12511 t(audit_in(1e-8, 1.0, 1.0, 2e-7, 1.0, 2.5e-9, 2.5e-9, th)),
12512 None
12513 );
12514 // A start measured at a stationary point (gradient exactly 0) carries
12515 // no scale: the audit stays out however small the answer's gradient.
12516 assert_eq!(
12517 t(audit_in(1e-8, 1.0, 1.0, 0.0, 2e-4, 6e-3, 2.5e-9, th)),
12518 None
12519 );
12520 // An unmeasured start (caller-owned scaling) defers to the answer.
12521 assert!(matches!(
12522 t(audit_in(
12523 1e-8,
12524 f64::NAN,
12525 f64::NAN,
12526 f64::NAN,
12527 1e-5,
12528 2.5e-9,
12529 2.5e-9,
12530 th
12531 )),
12532 Some(ScaleAudit::Upscale(_))
12533 ));
12534 // A big gradient never up-scales, whatever the complementarity.
12535 assert_eq!(
12536 t(audit_in(1e-8, 1.0, 1.0, 50.0, 50.0, 1e-3, 1e-3, th)),
12537 None
12538 );
12539 // An extreme factor at a stationary point is the honest case.
12540 assert_eq!(
12541 t(audit_in(1e-8, 1e-8, 1e-8, 1e9, 1e9, 1e-9, 1e-12, th)),
12542 None
12543 );
12544 // The masked opt-out (threshold 0) disables the re-scale branch.
12545 assert_eq!(
12546 t(audit_in(1e-8, 3e-7, 1.0, 3e8, 2.9e-2, 2.9e-2, 1e-9, 0.0)),
12547 None
12548 );
12549 // A caller who loosened tol asked for the complementarity they got.
12550 assert_eq!(
12551 t(audit_in(5e-2, 1.0, 1.0, 1e-5, 1e-5, 1e-2, 1e-2, th)),
12552 None
12553 );
12554 // NaN stays quiet.
12555 let nan = f64::NAN;
12556 assert_eq!(t(audit_in(1e-8, nan, nan, nan, nan, nan, nan, th)), None);
12557 // The upscale is capped.
12558 match t(audit_in(1e-8, 1.0, 1.0, 1e-14, 1e-14, 1.0, 1.0, th)) {
12559 Some(ScaleAudit::Upscale(k)) => assert_eq!(k, SCALE_AUDIT_MAX_UPSCALE),
12560 other => panic!("{other:?}"),
12561 }
12562 }
12563
12564 /// gh#1000 (II.22): the roundoff exemption, on the measured population
12565 /// named in `UNSCALED_DUAL_ROUNDOFF_ULPS`'s doc.
12566 #[test]
12567 fn a_residual_at_the_roundoff_floor_of_its_terms_is_not_a_miss() {
12568 // Pump network at H0_max 148: 1.6 ulps of its terms.
12569 assert!(unscaled_dual_inf_is_roundoff(5.54e-3, 1.57e13));
12570 assert!(unscaled_dual_inf_is_roundoff(7.39e-3, 2.66e13));
12571 // The downgrades that are right keep it: Haverly, the process
12572 // network's T2 and S2+T2 subproblems, the eps-perspective root, and
12573 // the gh#983 cusp.
12574 assert!(!unscaled_dual_inf_is_roundoff(1.33e-2, 6.6e10));
12575 assert!(!unscaled_dual_inf_is_roundoff(0.5, 7.55e11));
12576 assert!(!unscaled_dual_inf_is_roundoff(6.25e-2, 3.21e11));
12577 assert!(!unscaled_dual_inf_is_roundoff(3.92e-3, 5.03e9));
12578 assert!(!unscaled_dual_inf_is_roundoff(0.144, 3.55e9));
12579 // No scale measured: keep the downgrade.
12580 assert!(!unscaled_dual_inf_is_roundoff(5.54e-3, f64::NAN));
12581 assert!(!unscaled_dual_inf_is_roundoff(5.54e-3, 0.0));
12582 assert!(!unscaled_dual_inf_is_roundoff(f64::NAN, 1e13));
12583 }
12584
12585 /// gh#983 review item 7: one warning per code.
12586 #[test]
12587 fn a_warning_code_is_attached_at_most_once() {
12588 let mut w = vec!["unscaled_dual_inf_above_acceptable: downgraded".to_string()];
12589 push_warning_once(
12590 &mut w,
12591 "unscaled_dual_inf_above_acceptable: verify in your units".to_string(),
12592 );
12593 push_warning_once(&mut w, "large_dual_scale: terms of 1e9".to_string());
12594 assert_eq!(w.len(), 2, "{w:?}");
12595 assert!(w[0].ends_with("downgraded"));
12596 }
12597
12598 /// gh#983: the structured warnings, by code, on success verdicts only.
12599 #[test]
12600 fn quality_warnings_carry_a_code_and_only_on_success_verdicts() {
12601 use ApplicationReturnStatus::*;
12602 // The cusp: multipliers 3.5e9, residual 0.144.
12603 let w = quality_warnings(SolveSucceeded, 0.144, 3.55e9, 1.0, 1e-6, false);
12604 assert!(
12605 w.iter().any(|m| m.starts_with("large_dual_scale:")),
12606 "{w:?}"
12607 );
12608 assert!(
12609 w.iter()
12610 .any(|m| m.starts_with("unscaled_dual_inf_above_acceptable:")),
12611 "{w:?}"
12612 );
12613 // orthrds2-like: scale 1.6e10, residual 89.7 -> only the scale caveat
12614 // and the absolute one; never a status change (the function returns
12615 // text, not a verdict).
12616 let w = quality_warnings(SolveSucceeded, 89.7, 1.6e10, 1.0, 1e-6, false);
12617 assert!(w.iter().any(|m| m.starts_with("large_dual_scale:")));
12618 assert!(!w.iter().any(|m| m.contains("bound_relax_factor")), "{w:?}");
12619 // gh#1000: under bound_relax_factor = 0 the caveat names the option.
12620 let w = quality_warnings(SolveSucceeded, 89.7, 1.6e10, 1.0, 1e-6, true);
12621 assert!(
12622 w.iter()
12623 .any(|m| m.starts_with("large_dual_scale:") && m.contains("bound_relax_factor = 0")),
12624 "{w:?}"
12625 );
12626 // A clean point has none.
12627 assert!(quality_warnings(SolveSucceeded, 1e-9, 5.0, 1.0, 1e-6, false).is_empty());
12628 // A large objective with a stationary residual is not a failed CQ.
12629 assert!(quality_warnings(SolveSucceeded, 2.9e-7, 1.46e9, 1.0, 1e-6, false).is_empty());
12630 // 5e-4 is below the warning bar even though it exceeds acceptable_tol.
12631 assert!(quality_warnings(SolveSucceeded, 5e-4, 5.0, 1.0, 1e-6, false).is_empty());
12632 // Failures carry no success caveat.
12633 assert!(quality_warnings(RestorationFailed, 0.144, 3.55e9, 1.0, 1e-6, false).is_empty());
12634 assert!(quality_warnings(SolveSucceeded, f64::NAN, f64::NAN, 1.0, 1e-6, false).is_empty());
12635 }
12636
12637 /// A counted re-solve (gh#983 scale audit, gh#884 retry) records every
12638 /// attempt's spend, and the total survives a decline (#1140 review). The
12639 /// base attempt here ran a `mu_strategy_fallback` (30 + 11 iterations)
12640 /// and the re-solve ran none (19): its spend is measured without the
12641 /// base's sticky record, which goes back afterwards. A decline restores
12642 /// the base's tally with `restore_into`, which must not touch the total.
12643 /// No CLI fixture reaches either branch (a declined retry, a base with a
12644 /// fallback), which is why this is a unit test.
12645 #[test]
12646 fn a_counted_resolve_keeps_every_attempts_spend_through_a_decline() {
12647 use pounce_nlp::solve_statistics::MuStrategyFallbackRecord;
12648 let app = IpoptApplication::new();
12649 let fallback = MuStrategyFallbackRecord {
12650 base_iteration_count: 30,
12651 retry_iteration_count: 11,
12652 ..Default::default()
12653 };
12654 *app.mu_strategy_fallback_record.borrow_mut() = Some(fallback.clone());
12655 {
12656 let mut st = app.statistics.borrow_mut();
12657 st.iteration_count = 30;
12658 st.mu_strategy_fallback = Some(fallback.clone());
12659 }
12660 let floor = SolutionCertificate::of(&app.statistics.borrow());
12661 let mark = app.begin_counted_resolve();
12662 // The re-solve: no fallback of its own, so the copy at the end of a
12663 // run leaves the (set-aside, now empty) record empty.
12664 {
12665 let mut st = app.statistics.borrow_mut();
12666 st.iteration_count = 19;
12667 st.mu_strategy_fallback = app.mu_strategy_fallback_record.borrow().clone();
12668 }
12669 app.end_counted_resolve(mark);
12670 let st = app.statistics.borrow().clone();
12671 assert_eq!(st.retry_total_iterations, Some(30 + 11 + 19));
12672 assert_eq!(st.spent_iteration_count(), 60);
12673 // The sticky record is back, as the rule leaves it.
12674 assert_eq!(
12675 app.mu_strategy_fallback_record
12676 .borrow()
12677 .as_ref()
12678 .map(|r| r.total_iteration_count()),
12679 Some(41)
12680 );
12681 // Declined: the base's tally comes back, the total stays.
12682 floor.restore_into(&mut app.statistics.borrow_mut());
12683 let st = app.statistics.borrow();
12684 assert_eq!(st.iteration_count, 30);
12685 assert_eq!(st.retry_total_iterations, Some(60));
12686 assert_eq!(st.budget_iteration_count(), 41);
12687 }
12688
12689 /// gh#983 item 5: flooring an attempt must floor everything a solve
12690 /// report reads off the statistics, the iteration count and the row table
12691 /// included, or the report describes the declined retry.
12692 #[test]
12693 fn the_certificate_floor_carries_the_iteration_trace() {
12694 use pounce_nlp::solve_statistics::{IterRecord, SolveStatistics};
12695 let mut base = SolveStatistics::new();
12696 base.iteration_count = 64;
12697 base.iterations = vec![IterRecord::default(); 3];
12698 base.num_obj_evals = 159;
12699 base.restoration_calls = 2;
12700 let floor = SolutionCertificate::of(&base);
12701 let mut retry = SolveStatistics::new();
12702 retry.iteration_count = 15;
12703 retry.iterations = vec![IterRecord::default(); 15];
12704 retry.num_obj_evals = 86;
12705 floor.restore_into(&mut retry);
12706 assert_eq!(retry.num_obj_evals, 159);
12707 assert_eq!(retry.restoration_calls, 2);
12708 assert_eq!(retry.iteration_count, 64);
12709 assert_eq!(retry.iterations.len(), 3);
12710 }
12711
12712 /// An infeasible base attempt is not a point worth protecting, so
12713 /// both rules stand down and the certificate conjuncts decide alone.
12714 /// R2: `obj_scaling_factor < 0` poses a maximization, and
12715 /// `final_objective` is the user's **signed** objective, so both rules
12716 /// have to follow the sense. This is the table above with every
12717 /// objective negated: the same three answers must be refused and the
12718 /// same one admitted, with `MAX` instead of `MIN`.
12719 ///
12720 /// Without the normalization each row flips — rule 1 starts refusing
12721 /// genuine improvements (a regression against the behaviour before the
12722 /// conjunct existed) and rule 2 starts admitting strictly worse
12723 /// answers, which is the class it was added to block.
12724 #[test]
12725 fn the_rules_follow_the_objective_sense() {
12726 // qpec_small, mirrored: the retry is worse by 5.8e-11, well inside
12727 // the tolerance, so it is still admitted.
12728 assert!(retry_answer_is_admissible(
12729 -3.586e-28, 1.11e-16, -5.835e-11, 5.47e-12, ACCEPT, MAX
12730 ));
12731 // r116, mirrored: +13.0057 given up for +1.2072 is now a *worse*
12732 // maximum, and rule 1 must still refuse it.
12733 assert!(!retry_answer_is_admissible(
12734 1.3005680756e1,
12735 2.22e-16,
12736 1.2072337962e0,
12737 4.55e-13,
12738 ACCEPT,
12739 MAX
12740 ));
12741 // scholtes4, mirrored: +6.6088e-05 is now an improvement bought
12742 // with primal slack, and rule 2 must still refuse it.
12743 assert!(!retry_answer_is_admissible(
12744 -1.8175997416e-9,
12745 2.07e-25,
12746 6.6088333055e-5,
12747 1.09e-9,
12748 ACCEPT,
12749 MAX
12750 ));
12751 // ... and the same improvement with the primal held is admitted.
12752 assert!(retry_answer_is_admissible(
12753 -1.8175997416e-9,
12754 2.07e-25,
12755 6.6088333055e-5,
12756 2.07e-25,
12757 ACCEPT,
12758 MAX
12759 ));
12760 }
12761
12762 /// The mirror of the above, stated as the property rather than the
12763 /// rows: negating both objectives and flipping the sense must leave
12764 /// every verdict unchanged.
12765 #[test]
12766 fn negating_the_objective_and_the_sense_is_inert() {
12767 for &(bo, bv, ro, rv) in &[
12768 (3.586e-28, 1.11e-16, 5.835e-11, 5.47e-12),
12769 (-1.3005680756e1, 2.22e-16, -1.2072337962e0, 4.55e-13),
12770 (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 1.09e-9),
12771 (1.8175997416e-9, 2.07e-25, -6.6088333055e-5, 2.07e-25),
12772 (-1.0e3, 1.0e-2, 0.0, 1.0e-12),
12773 ] {
12774 assert_eq!(
12775 retry_answer_is_admissible(bo, bv, ro, rv, ACCEPT, MIN),
12776 retry_answer_is_admissible(-bo, bv, -ro, rv, ACCEPT, MAX),
12777 "verdict moved under (obj, sense) -> (-obj, -sense) at {bo:e}/{ro:e}"
12778 );
12779 }
12780 }
12781
12782 #[test]
12783 fn an_infeasible_base_attempt_protects_nothing() {
12784 assert!(admissible(-1.0e3, 1.0e-2, 0.0, 1.0e-12));
12785 // ... and what cannot be measured is refused, not admitted, the
12786 // same way `runaway_is_the_whole_residual` treats a NaN.
12787 assert!(admissible(Number::NAN, 0.0, 0.0, 0.0));
12788 assert!(!admissible(0.0, 0.0, Number::NAN, 0.0));
12789 assert!(!admissible(0.0, 0.0, -1.0, Number::NAN));
12790 }
12791}