pounce_algorithm/alg_builder.rs
1//! Algorithm builder — port of `Algorithm/IpAlgBuilder.{hpp,cpp}`.
2//!
3//! Reads `OptionsList`, walks the dependency order documented in
4//! `ref/Ipopt/AGENT_REFERENCE/ARCHITECTURE.md` §"BuildBasicAlgorithm",
5//! and assembles the strategy objects needed by `IpoptAlgorithm`:
6//!
7//! * `SymLinearSolver` (MA57 / MUMPS / FERAL) → `AugSystemSolver`
8//! (`StdAugSystemSolver`) → `PdSystemSolver` (`PdFullSpaceSolver`)
9//! → `SearchDirCalculator` (`PdSearchDirCalc`).
10//! * `BacktrackingLsAcceptor` (filter / penalty / cg-penalty) →
11//! `BacktrackingLineSearch`.
12//! * `MuUpdate` (monotone / adaptive[+oracle]).
13//! * `ConvCheck` (`OptErrorConvCheck`).
14//! * `IterateInitializer` (default / warm-start) and
15//! `EqMultCalculator` (`LeastSquareMults`).
16//! * `HessianUpdater` (exact / limited-memory).
17//! * `IterationOutput` (`OrigIterationOutput`).
18//! * `NLPScalingObject` (none / user / gradient-based / equilibration-based).
19//!
20//! Phase 7 ships the option-driven dispatch surface; the assembled
21//! `IpoptAlgorithm` lands once each strategy's arithmetic does.
22
23use crate::conv_check::opt_error::OptErrorConvCheck;
24use crate::eq_mult::least_square::LeastSquareMults;
25use crate::hess::exact::ExactHessianUpdater;
26use crate::hess::lim_mem_quasi_newton::{InitialApprox, LimMemQuasiNewtonUpdater, UpdateType};
27use crate::init::default::DefaultIterateInitializer;
28use crate::init::warm_start::WarmStartIterateInitializer;
29use crate::kkt::aug_system_solver::AugSystemSolver;
30use crate::kkt::low_rank_aug_system_solver::LowRankAugSystemSolver;
31use crate::kkt::pd_full_space_solver::PdFullSpaceSolver;
32use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
33use crate::kkt::perturbation_handler::PdPerturbationHandler;
34use crate::kkt::std_aug_system_solver::StdAugSystemSolver;
35use crate::line_search::backtracking::BacktrackingLineSearch;
36use crate::line_search::filter_acceptor::FilterLsAcceptor;
37use crate::line_search::ls_acceptor::BacktrackingLsAcceptor;
38use crate::line_search::penalty_acceptor::PenaltyLsAcceptor;
39use crate::mu::adaptive::{AdaptiveMuUpdate, MuOracleKind};
40use crate::mu::monotone::MonotoneMuUpdate;
41use crate::output::orig::OrigIterationOutput;
42use pounce_common::types::{Index, Number};
43use pounce_linsol::{SparseSymLinearSolverInterface, TSymLinearSolver};
44use std::cell::RefCell;
45use std::rc::Rc;
46
47/// Backend factory — the application supplies one before calling
48/// [`AlgorithmBuilder::build`]. Mirrors upstream's
49/// `SymLinearSolverFactory` knob in `IpAlgBuilder.cpp`. The default
50/// factory wires in FERAL; MA57 is selectable when the `ma57` cargo
51/// feature is enabled.
52pub type LinearBackendFactory =
53 Box<dyn FnMut(LinearSolverChoice) -> Box<dyn SparseSymLinearSolverInterface>>;
54
55/// Top-level algorithm choice. `InteriorPoint` is pounce's default
56/// (the existing `IpoptAlgorithm`); `ActiveSetSqp` is the
57/// Phase 5b SQP driver in `crate::sqp::SqpAlgorithm`, which uses
58/// `pounce-qp` for QP subproblem solves and reuses
59/// `FilterLsAcceptor` for globalization.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum AlgorithmChoice {
62 #[default]
63 InteriorPoint,
64 ActiveSetSqp,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum LinearSolverChoice {
69 Ma57,
70 Feral,
71}
72
73/// Symmetric scaling method applied to the augmented KKT system by
74/// [`TSymLinearSolver`]. Mirrors the `linear_system_scaling` option
75/// in `IpAlgBuilder.cpp:302-318` and the `RuizTSymScalingMethod` /
76/// `Mc19TSymScalingMethod` strategies in upstream Ipopt.
77///
78/// * `None` (default) — no scaling; `TSymLinearSolver` runs with a
79/// null scaling method. Matches upstream's default.
80/// * `Ruiz` — iterative symmetric ∞-norm equilibration (Ruiz, 2001).
81/// Implemented in `pounce_linsol::RuizTSymScalingMethod`.
82/// * `Mc19` — Curtis-Reid (HSL MC19) scaling. Not yet implemented;
83/// falls back to `None` with a warning.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum LinearSystemScalingChoice {
86 #[default]
87 None,
88 Ruiz,
89 Mc19,
90 /// `slack-based` — `IpSlackBasedTSymScalingMethod`. Unlike the
91 /// others this one is a function of the iterate, not of the matrix,
92 /// so the algorithm pushes the `s`-block factors down each
93 /// iteration (see `IpoptAlgorithm::push_slack_scaling`).
94 SlackBased,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum MuStrategyChoice {
99 Monotone,
100 Adaptive,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum HessianApproxChoice {
105 Exact,
106 LimitedMemory,
107 /// Partitioned quasi-Newton: one small dense block per element
108 /// function (the objective and each constraint row), assembled into
109 /// a genuine sparse `SymTMatrix`. See
110 /// [`crate::hess::partitioned_quasi_newton`].
111 Partitioned,
112 /// Sparse finite-difference Lagrangian Hessian, recovered by graph
113 /// coloring from the analytic Jacobian. See
114 /// [`crate::hess::fd_hessian`].
115 FiniteDifference,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum LineSearchChoice {
120 Filter,
121 CgPenalty,
122 Penalty,
123}
124
125/// Assembled strategy bundle. Phase 7 ships the structural bundle;
126/// `IpoptAlgorithm::new` reads from this when it lands.
127pub struct AlgorithmBundle {
128 pub mu_update: Box<dyn crate::mu::r#trait::MuUpdate>,
129 pub conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck>,
130 pub init: Box<dyn crate::init::r#trait::IterateInitializer>,
131 pub eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator>,
132 pub hess: Box<dyn crate::hess::r#trait::HessianUpdater>,
133 pub line_search: BacktrackingLineSearch,
134 pub iter_output: Box<dyn crate::output::r#trait::IterationOutput>,
135 /// `Some` when the builder was given a [`LinearBackendFactory`];
136 /// `None` for the bare structural bundle that pre-Phase-6 unit
137 /// tests still rely on.
138 pub search_dir: Option<PdSearchDirCalc>,
139}
140
141/// Knobs read off `OptionsList` and baked into the assembled
142/// `OptErrorConvCheck`. Defaults mirror
143/// `IpOptErrorConvCheck.cpp:RegisterOptions`.
144#[derive(Debug, Clone)]
145pub struct ConvCheckOptions {
146 pub tol: Number,
147 pub dual_inf_tol: Number,
148 pub constr_viol_tol: Number,
149 pub compl_inf_tol: Number,
150 pub acceptable_tol: Number,
151 pub acceptable_dual_inf_tol: Number,
152 pub acceptable_constr_viol_tol: Number,
153 pub acceptable_compl_inf_tol: Number,
154 pub acceptable_obj_change_tol: Number,
155 pub acceptable_iter: Index,
156 pub max_iter: Index,
157 pub max_cpu_time: Number,
158 pub max_wall_time: Number,
159 pub infeas_stationarity_tol: Number,
160 pub infeas_viol_kappa: Number,
161 pub infeas_max_streak: Index,
162 /// Objective-scale floor below which a strict termination certificate is
163 /// refused while the unscaled KKT error is still above `acceptable_tol`
164 /// (gh #200). `0` disables the mechanism.
165 pub obj_scale_certificate_threshold: Number,
166 /// Safety factor on the per-row floor the **strict** gate uses to decide
167 /// when a constraint residual is finer than the row can represent
168 /// (gh #528). `0` disables the floor, restoring upstream Ipopt's
169 /// bare-absolute primal term.
170 pub primal_noise_floor_kappa: Number,
171 /// Fraction of `acceptable_tol` the KKT error and the objective may drift
172 /// across the acceptable-level streak's window while the streak still
173 /// counts as settled (gh #533). `0` disables the progress test, leaving
174 /// acceptable-level termination the bare consecutive-count criterion.
175 pub acceptable_progress_kappa: Number,
176 /// Safety factor on the scale-relative floor under `dual_inf_tol` the
177 /// **strict** gate judges the dual infeasibility against (gh #532). `0`
178 /// disables the floor, restoring upstream Ipopt's bare-absolute bound.
179 pub dual_inf_scale_kappa: Number,
180}
181
182impl Default for ConvCheckOptions {
183 fn default() -> Self {
184 Self {
185 tol: 1e-8,
186 dual_inf_tol: 1.0,
187 constr_viol_tol: 1e-4,
188 compl_inf_tol: 1e-4,
189 acceptable_tol: 1e-6,
190 acceptable_dual_inf_tol: 1e10,
191 acceptable_constr_viol_tol: 1e-2,
192 acceptable_compl_inf_tol: 1e-2,
193 acceptable_obj_change_tol: 1e20,
194 acceptable_iter: 15,
195 max_iter: 3000,
196 max_cpu_time: 1e6,
197 max_wall_time: 1e6,
198 infeas_stationarity_tol: 1e-8,
199 infeas_viol_kappa: 1e2,
200 infeas_max_streak: 5,
201 obj_scale_certificate_threshold: 1e-4,
202 primal_noise_floor_kappa: 64.0,
203 acceptable_progress_kappa: 1e-1,
204 dual_inf_scale_kappa: 1.0,
205 }
206 }
207}
208
209/// A block-parallel KKT partition published after a builder was cloned —
210/// see [`AlgorithmBuilder::kkt_blocks_shared`]. `None` inside means the outer
211/// solve resolved no partition, and the clone builds the standard solver.
212pub type KktBlocksCell =
213 std::sync::Arc<std::sync::Mutex<Option<(Vec<i32>, pounce_feral::FeralConfig)>>>;
214
215#[derive(Debug, Clone)]
216pub struct AlgorithmBuilder {
217 /// Top-level algorithm dispatch. Default `InteriorPoint` ⇒
218 /// `build_with_backend` returns the existing `AlgorithmBundle`
219 /// (consumed by `IpoptAlgorithm`). `ActiveSetSqp` ⇒ caller
220 /// must use `build_sqp_with_backend` to assemble the Phase 5b
221 /// `SqpAlgorithm`. The two builder methods sit side by side
222 /// because the assembled algorithm shape differs (IPM bundle
223 /// vs SQP struct).
224 pub algorithm: AlgorithmChoice,
225 pub linear_solver: LinearSolverChoice,
226 /// Symmetric scaling method for the augmented KKT system. Wired
227 /// into [`TSymLinearSolver`] by [`Self::build_with_backend`].
228 /// Mirrors upstream `linear_system_scaling` (`IpAlgBuilder.cpp:538-560`).
229 pub linear_system_scaling: LinearSystemScalingChoice,
230 /// Lazy-vs-eager scaling toggle (`linear_scaling_on_demand`,
231 /// `IpTSymLinearSolver.cpp:50-58`). Only consulted when
232 /// `linear_system_scaling != None`. Upstream default is `true`
233 /// (compute scaling only on the first solve that fails / shows
234 /// poor conditioning); pounce mirrors that. Set to `false` to
235 /// scale every factorization.
236 pub linear_scaling_on_demand: bool,
237 pub mu_strategy: MuStrategyChoice,
238 /// Selector forwarded to [`AdaptiveMuUpdate`] when
239 /// `mu_strategy = Adaptive`. Ignored for `Monotone`. Defaults to
240 /// `QualityFunction` per upstream's `RegisterOptions` default.
241 pub mu_oracle: MuOracleKind,
242 pub hessian_approximation: HessianApproxChoice,
243
244 /// Element update formula for
245 /// [`HessianApproxChoice::Partitioned`] (`partitioned_update_type`).
246 /// SR1 by default: a single constraint is not convex, so damped
247 /// BFGS would force every `∇²c_j` model PSD and then scale it by a
248 /// multiplier of either sign.
249 pub partitioned_update_type: UpdateType,
250 /// Whether the caller named `partitioned_update_type` explicitly, so
251 /// the block mode's BFGS default does not override them.
252 pub partitioned_update_type_was_set: bool,
253 /// Widest element that keeps a dense block under
254 /// [`HessianApproxChoice::Partitioned`]; wider elements degrade to a
255 /// diagonal approximation (`partitioned_max_element`).
256 pub partitioned_max_element: usize,
257 /// Variables the **objective** is nonlinear in, in the compressed
258 /// `x_var` space — `TNLPAdapter::objective_nonlinear_vars`. Consumed
259 /// by both the partitioned updater (as its objective element's
260 /// support) and the finite-difference updater (as the objective's
261 /// contribution to a Jacobian-derived Hessian pattern, which the
262 /// constraint Jacobian cannot supply). `None` leaves each to fall
263 /// back on the first `∇f`'s nonzeros, which is value-derived; see
264 /// that method for what it costs.
265 pub objective_nonlinear_vars: Option<Vec<Index>>,
266 /// The TNLP's declared Lagrangian Hessian sparsity in the compressed
267 /// `x_var` space, `(row >= col)`, when it has one and
268 /// `partitioned_structure` is `declared` (gh#989 item 5). Consumed by the
269 /// partitioned updater to split each element along the pattern.
270 pub hessian_declared_pattern: Option<Vec<(Index, Index)>>,
271 /// `partitioned_curvature_cap` — multiple of an element's implied
272 /// curvature that one update may reach. See
273 /// [`crate::hess::partitioned_quasi_newton`].
274 pub partitioned_curvature_cap: Number,
275 /// How the Lagrangian is split into elements under
276 /// [`HessianApproxChoice::Partitioned`] (`partitioned_elements`).
277 pub partitioned_elements: crate::hess::partitioned_quasi_newton::ElementMode,
278 /// Target primal-block width when `partitioned_elements` is
279 /// `blocks` (`partitioned_block_size`).
280 pub partitioned_block_size: usize,
281 /// Where [`HessianApproxChoice::FiniteDifference`] takes its
282 /// sparsity pattern from (`fd_hessian_pattern`).
283 pub fd_hessian_pattern: crate::hess::fd_hessian::FdPatternSource,
284 /// How finite-difference probe groups are formed
285 /// (`fd_hessian_coloring`).
286 pub fd_hessian_coloring: crate::hess::fd_hessian::FdColoring,
287 /// Relative movement in `x` AND `y` below which the previous Hessian
288 /// is reused (`fd_hessian_reuse_tol`). `0` rebuilds every iteration.
289 pub fd_hessian_reuse_tol: Number,
290 pub limited_memory_update_type: UpdateType,
291 /// History length for the limited-memory quasi-Newton approximation
292 /// (`limited_memory_max_history`). Defaults to upstream's 6.
293 pub limited_memory_max_history: i32,
294 /// `limited_memory_init_val_max` / `_min` — the clamp on the initial
295 /// Hessian scalar σ before the rank-2 updates. Upstream defaults 1e8
296 /// / 1e-8, which `LimMemQuasiNewtonUpdater` has carried as hard-coded
297 /// fields and consumed in `initial_hessian_scalar` all along; only
298 /// the read sites were missing (gh#483, #191 round 2).
299 pub limited_memory_init_val_max: Number,
300 pub limited_memory_init_val_min: Number,
301 /// `limited_memory_initialization` — which formula picks the initial
302 /// Hessian scalar σ. Matches upstream's `scalar1` (σ = sᵀy/sᵀs).
303 /// pounce shipped `scalar2` (σ = yᵀy/sᵀy) with no way to change it,
304 /// because the option was registered and never read (#677).
305 pub limited_memory_initialization: InitialApprox,
306 /// `limited_memory_init_val` — σ on the first iteration, before any
307 /// curvature pair exists, and every iteration under
308 /// `InitialApprox::Constant`. Upstream default 1.0.
309 pub limited_memory_init_val: Number,
310 /// `limited_memory_max_skipping` — consecutive skipped curvature
311 /// updates before the approximation is discarded (#686). Upstream
312 /// default 2.
313 pub limited_memory_max_skipping: Index,
314 /// Positions in the algorithm's compressed `x_var` space that enter
315 /// the problem *nonlinearly* (gh#624). `None` — the default —
316 /// approximates the Hessian over every variable, which is what the
317 /// limited-memory path has always done. When set, the quasi-Newton
318 /// update is restricted to this subspace and the Hessian is exactly
319 /// zero elsewhere. Comes from
320 /// `TNLPAdapter::quasi_newton_nonlinear_vars` (the TNLP's
321 /// `get_list_of_nonlinear_variables`, or the `num_linear_variables`
322 /// prefix fallback) and is ignored on the exact-Hessian path.
323 ///
324 /// The restoration sub-IPM must clear this: the mask indexes the
325 /// original NLP's variables, not the restoration compound primal.
326 pub limited_memory_nonlinear_vars: Option<Vec<Index>>,
327 /// Restoration only: length of the original-`x` block leading the
328 /// restoration primal, to which the limited-memory update is confined
329 /// as upstream's restoration updater does (gh#1146). See
330 /// `LimMemQuasiNewtonUpdater::leading_block`. `None` everywhere else.
331 pub limited_memory_leading_block: Option<Index>,
332 pub line_search_method: LineSearchChoice,
333 pub warm_start_init_point: bool,
334 /// `mehrotra_algorithm` — when true, [`PdSearchDirCalc`] folds
335 /// the Mehrotra second-order complementarity term into the
336 /// search-direction RHS. Mirrors upstream's
337 /// `IpAlgBuilder.cpp:Mehrotra` flag. Requires `mu_strategy =
338 /// Adaptive` so that an affine step is computed each iteration;
339 /// [`Self::build_with_backend`] does not enforce this — the
340 /// option-parser in `application.rs` is responsible for the
341 /// cascading defaults (`mu_oracle = probing` etc.).
342 pub mehrotra_algorithm: bool,
343 /// `fast_step_computation` — when true, [`PdSearchDirCalc`] accepts
344 /// the search direction without the residual check and allows an
345 /// inexact linear solve. Mirrors upstream's flag of the same name,
346 /// default `no`. The field existed and was consumed from the day the
347 /// search-direction calculator landed, hard-coded to `false`; only
348 /// the option's read site was missing, so setting it did nothing
349 /// (gh#483 follow-up, #191 round 2).
350 pub fast_step_computation: bool,
351 /// `kappa_sigma` — factor bounding how far the bound multipliers may
352 /// deviate from their primal estimates. The clamp
353 /// (`kappa_sigma_clamp`) runs after every accepted step; `< 1`
354 /// disables the correction. Mirrors `IpIpoptAlg.cpp` (Eqn. (16)),
355 /// default `1e10`. Baked onto [`crate::ipopt_alg::IpoptAlgorithm`] by
356 /// the solve path.
357 pub kappa_sigma: Number,
358 /// `ftb_centrality_floor` (pounce gh#1098) — γ of the
359 /// centrality-floored fraction-to-the-boundary rule; `0` restores
360 /// upstream's τ rule. Baked onto
361 /// [`crate::ipopt_alg::IpoptAlgorithm`] by the solve path.
362 pub ftb_centrality_floor: Number,
363 /// `recalc_y` / `recalc_y_feas_tol` — least-square re-estimation of
364 /// the equality multipliers once feasible (#677). Registered
365 /// upstream, refused by pounce as unimplemented until now. Default
366 /// `false` matches the registry; the limited-memory path turns it on
367 /// for itself in `application.rs`, as upstream's own option text
368 /// says it does.
369 pub recalc_y: bool,
370 pub recalc_y_feas_tol: Number,
371 /// `kappa_d` — weight of the linear damping term added to the barrier
372 /// objective/gradient (and dual-infeasibility) to handle one-sided
373 /// bounds. Mirrors `IpIpoptCalculatedQuantities.cpp`, default `1e-5`.
374 /// Baked onto [`crate::ipopt_cq::IpoptCalculatedQuantities`] by the
375 /// solve path.
376 pub kappa_d: Number,
377 /// `s_max` — cap on the average multiplier magnitude used to build
378 /// the `(s_d, s_c)` scaling factors of the KKT error test
379 /// (`IpIpoptCalculatedQuantities.cpp:ComputeOptimalityErrorScaling`,
380 /// the paragraph after Eqn. (6) of the implementation paper).
381 /// Registered default `100`, which is what
382 /// [`crate::ipopt_cq::IpoptCalculatedQuantities`] already carries as
383 /// its struct default, so forwarding it is behaviour-neutral for a
384 /// run that does not set it (#551 / #677). Baked onto the cq by the
385 /// solve path, next to `kappa_d`.
386 pub s_max: Number,
387 /// `tiny_step_tol` — relative primal step size below which the full
388 /// step is accepted without line search; repeated tiny steps
389 /// terminate the solve. Mirrors `IpBacktrackingLineSearch.cpp`,
390 /// default `10·EPSILON`. Baked onto
391 /// [`crate::ipopt_alg::IpoptAlgorithm`] by the solve path.
392 pub tiny_step_tol: Number,
393 /// `tiny_step_y_tol` — dual-step threshold; when both primal and dual
394 /// steps are tiny in consecutive iterations the algorithm stops at the
395 /// best attainable accuracy. Default `1e-2`.
396 pub tiny_step_y_tol: Number,
397 /// `diverging_iterates_tol` — if `max_i |x_i|` exceeds this the solve
398 /// aborts as diverging. Default `1e20`.
399 pub diverging_iterates_tol: Number,
400 /// `dual_diverging_streak` (pounce#246) — consecutive growing-dual-
401 /// infeasibility iterations before the dual-divergence guard routes to
402 /// restoration. **Default `0` (off).**
403 ///
404 /// It defaulted to `15` when introduced, on the strength of a reported
405 /// emfl050 bad-warm-start grind. That justification did not survive being
406 /// reproduced: the measurement was caller-side JAX compilation, and the
407 /// build predating the guard solves both emfl050 instances to the same
408 /// optimum in the same time (pounce#246 / pounce#250). What remained was a
409 /// knife-edge, non-monotone effect on four of 1284 MINLPLib models — so it
410 /// is opt-in rather than imposed. See `upstream_options.rs` for the full
411 /// account.
412 pub dual_diverging_streak: Index,
413 /// `dual_divergence_retry_step_tol` (gh#884) — the scale-relative
414 /// step `max_i |d_i| / (1 + |x_i|)` at or below which the biactive
415 /// dual-divergence detector calls the primal iterate *settled*.
416 /// Default `1e-5`; `0` disables the detector without disabling the
417 /// `dual_divergence_retry` option. See `upstream_options.rs` for the
418 /// measured population behind the default.
419 pub dual_divergence_retry_step_tol: Number,
420 /// `dual_divergence_retry_du_floor` (gh#884) — the *unscaled* dual
421 /// infeasibility at or above which the same detector calls the
422 /// multipliers *diverged*. Default `1e2`. Measured in the model's own
423 /// units on purpose: the `s_d`-normalised aggregate is what hid the
424 /// defect. See `upstream_options.rs`.
425 pub dual_divergence_retry_du_floor: Number,
426 /// `tiny_step_dual_stall_iters` — consecutive tiny primal steps with no
427 /// dual progress after which the tiny-step stop stops waiting for the
428 /// multiplier step. Default `10`; `0` is upstream's rule. See
429 /// `upstream_options.rs`.
430 pub tiny_step_dual_stall_iters: Index,
431 /// `resto_decline_deferrals` (gh #534) — how many times the
432 /// acceptable-point restoration decline may be deferred on a solve whose
433 /// NLP error is still contracting. Default `1`; `0` restores the pre-#534
434 /// behaviour (decline immediately, always). See `upstream_options.rs`.
435 pub resto_decline_deferrals: Index,
436 /// `resto_decline_progress_ratio` (gh #534) — required per-iteration
437 /// contraction of the NLP error before a decline is deferred. Default
438 /// `0.5`; at or above `1` the progress requirement is dropped entirely.
439 pub resto_decline_progress_ratio: Number,
440 /// `neg_curv_escapes` (gh #797) — how many times a certified stationary
441 /// point with an indefinite reduced Hessian may be left along a direction
442 /// of negative curvature instead of reported. Default `1`; `0` restores the
443 /// pre-#797 behaviour. See `upstream_options.rs`.
444 pub neg_curv_escapes: Index,
445 /// `limited_memory_ls_failure_restarts` (gh #818) — how many times a
446 /// line-search failure at an already-feasible point may re-anchor the
447 /// quasi-Newton model and retry instead of entering restoration.
448 /// Default `0`, i.e. the rung is off and a line-search failure always
449 /// hands off, which is upstream's behaviour; see
450 /// `DEFAULT_LBFGS_LS_FAILURE_RESTARTS` in `ipopt_alg.rs` for the
451 /// measurement that put it there. See `upstream_options.rs`.
452 pub limited_memory_ls_failure_restarts: Index,
453 /// `kkt_fidelity_tol` (pounce#173). Read by the algorithm as well as by the
454 /// post-solve gate, because the #200 fallback's tiebreak has to rank the two
455 /// candidate points by the status each will be *reported* under. Default
456 /// `0.0` (gate disabled).
457 pub kkt_fidelity_tol: Number,
458 pub conv_check: ConvCheckOptions,
459 pub mu: MuOptions,
460 pub line_search: LineSearchOptions,
461 pub refinement: RefinementOptions,
462 pub perturbation: PerturbationOptions,
463 pub resto: RestoOptions,
464 pub output: OutputOptions,
465 pub warm: WarmStartOptions,
466 /// SQP-specific options (consulted only when
467 /// `algorithm = ActiveSetSqp`).
468 pub sqp: crate::sqp::SqpOptions,
469 /// QP-subproblem-solver options for the active-set SQP path
470 /// (`pounce_qp::QpOptions`), threaded into the `SqpAlgorithm` via
471 /// `with_qp_options`. Consulted only when `algorithm = ActiveSetSqp`.
472 /// Populated from the `sqp_qp_*` CLI options by
473 /// `application::apply_qp_subproblem_options`.
474 pub sqp_qp: pounce_qp::QpOptions,
475 pub init: InitOptions,
476 /// Optional block-triangular / Schur KKT partition (pounce#180 item 2):
477 /// `(schur_indices, feral_cfg)`. When `Some` and the IPM path is selected
478 /// with the feral linear solver and an exact Hessian, `build_with_backend`
479 /// wraps the standard aug-system solver in a
480 /// [`crate::kkt::SchurAugSystemSolver`] over the given KKT-space indices.
481 /// The Schur solver falls back to the standard solver transparently when
482 /// the partition is unsuitable. Set via [`Self::set_kkt_schur`].
483 pub kkt_schur: Option<(Vec<usize>, pounce_feral::FeralConfig)>,
484 /// Summary sink the Schur backend records into, so the solve's
485 /// `LinearSolverSummary` covers the Schur path too. Set via
486 /// [`Self::set_kkt_schur_summary_sink`]; `None` records nothing.
487 /// Optional block-parallel KKT partition (structured-KKT Phase 5b):
488 /// `(labels, feral_cfg)`, labels in KKT space with `< 0` for the shared
489 /// border and **empty** meaning "detect it from the assembled matrix".
490 /// Honored on the IPM + feral + exact-Hessian path; the block solver falls
491 /// back to the standard one transparently. Set via [`Self::set_kkt_blocks`].
492 pub kkt_blocks: Option<(Vec<i32>, pounce_feral::FeralConfig)>,
493 /// Late-bound partition, read only when [`Self::kkt_blocks`] is `None`.
494 /// The restoration sub-IPM's builder is constructed by the frontend before
495 /// the solve starts, which is before the outer KKT layout — and therefore
496 /// the mapping from a model-space declaration onto it — is known. The
497 /// application publishes the resolved labels into this cell as it builds
498 /// the outer algorithm, and the inner builder, a clone made earlier, reads
499 /// them here when restoration actually runs.
500 ///
501 /// Sound because `AugRestoSystemSolver` reduces the 8-block restoration
502 /// KKT onto the *original* 4-block system before delegating: the matrix
503 /// the inner solver factors has the outer system's dimension and sparsity,
504 /// so the outer labels describe it exactly. `BlockAugSystemSolver` checks
505 /// the length against the assembled matrix regardless and falls back.
506 pub kkt_blocks_shared: Option<KktBlocksCell>,
507 /// Refuse a partition whose median block is narrower than this many KKT
508 /// columns (`kkt_block_min_size`; `0` disables). Below the measured
509 /// crossover the block path is slower than the monolithic one — see
510 /// [`crate::kkt::block_aug_system_solver::DEFAULT_MIN_BLOCK_SIZE`].
511 pub kkt_block_min_size: usize,
512 pub kkt_schur_summary_sink:
513 Option<std::sync::Arc<std::sync::Mutex<pounce_linsol::summary::LinearSolverSummary>>>,
514 /// Sink the standard augmented-system solver publishes the inertia of
515 /// its zero-regularization trials into (`last_inertia_unregularized`,
516 /// gh#987 item 3). Set only on the main-IPM builder, so a restoration
517 /// sub-solve's differently-shaped KKT system never overwrites it.
518 pub inertia_sink:
519 Option<std::sync::Arc<std::sync::Mutex<pounce_linsol::summary::LinearSolverSummary>>>,
520 /// Shared tally of successful linear-solver quality escalations, handed
521 /// to the assembled
522 /// [`PdFullSpaceSolver`](crate::kkt::pd_full_space_solver::PdFullSpaceSolver)
523 /// by [`Self::build_with_backend`]. `None` leaves that solver with its
524 /// own private counter, which is what every test double and every
525 /// direct builder user gets.
526 ///
527 /// The point of sharing it is the restoration sub-solve: its inner
528 /// algorithm is assembled from a *clone* of this builder
529 /// (`resto_inner_solver::run_inner_resto`), so a `Some` here makes the
530 /// sub-solve's escalations land in the same total as the main loop's.
531 /// gh#857's exact leg escalates once in each, and counting only the
532 /// main loop would report half the trajectory change.
533 pub quality_escalation_counter: Option<Rc<std::cell::Cell<u64>>>,
534}
535
536/// Knobs read off `OptionsList` and baked into
537/// [`DefaultIterateInitializer`]. Defaults mirror
538/// `IpDefaultIterateInitializer.cpp:RegisterOptions`. The Mehrotra
539/// cascade in `application.rs` overrides `bound_push`, `bound_frac`,
540/// and `bound_mult_init_val` to upstream's more-aggressive values
541/// (`10`, `0.2`, `1.0`).
542#[derive(Debug, Clone)]
543pub struct InitOptions {
544 pub bound_push: Number,
545 pub bound_frac: Number,
546 pub slack_bound_push: Number,
547 pub slack_bound_frac: Number,
548 pub constr_mult_init_max: Number,
549 pub bound_mult_init_val: Number,
550 /// `bound_mult_init_method`: `"constant"` (default) or `"mu-based"`
551 /// (matches upstream's `IpDefaultIterateInitializer.cpp`).
552 pub bound_mult_init_method: String,
553 /// `least_square_init_primal` — replace the user's starting `x`
554 /// with the min-norm primal that satisfies the linearized
555 /// constraints. Used by the Mehrotra cascade in `application.rs`
556 /// to drop iter-0 primal infeasibility on LP-shaped problems.
557 /// Mirrors upstream `IpDefaultIterateInitializer.cpp:200-222`.
558 pub least_square_init_primal: bool,
559}
560
561impl Default for InitOptions {
562 fn default() -> Self {
563 Self {
564 bound_push: 1e-2,
565 bound_frac: 1e-2,
566 slack_bound_push: 1e-2,
567 slack_bound_frac: 1e-2,
568 constr_mult_init_max: 1e3,
569 bound_mult_init_val: 1.0,
570 bound_mult_init_method: "constant".into(),
571 least_square_init_primal: false,
572 }
573 }
574}
575
576/// Knobs read off `OptionsList` and baked into
577/// [`WarmStartIterateInitializer`]. Defaults mirror
578/// `IpWarmStartIterateInitializer.cpp:RegisterOptions`.
579///
580/// Wired today: every knob above plus the gh#606 recentering pair.
581/// `warm_start_entire_iterate` / `warm_start_same_structure` are
582/// deliberately *not* here — they name the `GetWarmStartIterate` TNLP
583/// surface pounce does not expose, and are refused by
584/// [`crate::unimplemented_options`] rather than parsed into a field
585/// nothing reads (gh#606).
586#[derive(Debug, Clone)]
587pub struct WarmStartOptions {
588 pub bound_push: Number,
589 pub bound_frac: Number,
590 pub slack_bound_push: Number,
591 pub slack_bound_frac: Number,
592 pub mult_bound_push: Number,
593 pub mult_init_max: Number,
594 pub target_mu: Number,
595 /// The value a NaN-seeded bound multiplier takes: NaN in a
596 /// user-supplied `z`/`v` seed means "unseeded, use the default".
597 /// Threaded from `builder.init.bound_mult_init_val` at build time
598 /// so the Mehrotra override and any user setting stay the single
599 /// source of truth.
600 pub bound_mult_init_val: Number,
601 /// `constr_mult_init_max`, threaded from the init options at build
602 /// time (gh#606). The warm path's reconstruction of an unseeded
603 /// equality-multiplier block runs the *cold* path's least-squares
604 /// solve, so it is capped by the cold path's cap — not by
605 /// `warm_start_mult_init_max`, which caps multipliers a caller
606 /// actually supplied and is three orders looser (1e6 vs 1e3).
607 /// Measured: on `redundant_rows`, whose duplicated equality rows
608 /// make the least-squares system singular, the looser cap let an
609 /// arbitrary estimate through and cost 7 -> 25 iterations.
610 pub constr_mult_init_max: Number,
611 /// `warm_start_recentering` (gh#606). Whether the initializer
612 /// measures the supplied point and adapts μ / the multiplier fills
613 /// to it, or keeps the pre-gh#606 universal constants.
614 pub recentering: WarmStartRecentering,
615}
616
617/// Value of `warm_start_recentering` (gh#606).
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619pub enum WarmStartRecentering {
620 /// Pre-gh#606 behaviour: constant pushes and floors, `y` left at
621 /// zero when unseeded, μ untouched unless `warm_start_target_mu`
622 /// is set. The kill switch.
623 None,
624 /// Measure the supplied iterate's residuals and derive μ, the
625 /// bound-multiplier fills, and the equality-multiplier
626 /// reconstruction from them.
627 Residual,
628}
629
630impl WarmStartOptions {
631 /// `mult_init_max` as a usable cap: the registered `0` sentinel
632 /// means "no cap".
633 pub(crate) fn mult_init_max_or_inf(&self) -> Number {
634 if self.mult_init_max > 0.0 {
635 self.mult_init_max
636 } else {
637 Number::INFINITY
638 }
639 }
640}
641
642impl Default for WarmStartOptions {
643 fn default() -> Self {
644 Self {
645 bound_push: 1e-3,
646 bound_frac: 1e-3,
647 slack_bound_push: 1e-3,
648 slack_bound_frac: 1e-3,
649 mult_bound_push: 1e-3,
650 mult_init_max: 1e6,
651 target_mu: 0.0,
652 // seeded from the init options so the default has one
653 // home; build() re-resolves it from the live init options
654 // anyway (see `resolved_warm_options`)
655 bound_mult_init_val: InitOptions::default().bound_mult_init_val,
656 constr_mult_init_max: InitOptions::default().constr_mult_init_max,
657 recentering: WarmStartRecentering::Residual,
658 }
659 }
660}
661
662/// The warm-start options as the initializer actually receives them:
663/// `bound_mult_init_val` and `constr_mult_init_max` come from the
664/// (option-read, Mehrotra-resolved) init options, never from
665/// `WarmStartOptions`'s own copy. Split out of `build()` so the threading is testable.
666pub(crate) fn resolved_warm_options(
667 warm: &WarmStartOptions,
668 init: &InitOptions,
669) -> WarmStartOptions {
670 let mut w = warm.clone();
671 w.bound_mult_init_val = init.bound_mult_init_val;
672 w.constr_mult_init_max = init.constr_mult_init_max;
673 w
674}
675
676/// Knobs read off `OptionsList` and baked into the assembled
677/// `MonotoneMuUpdate` or `AdaptiveMuUpdate`. Defaults mirror
678/// `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
679/// `mu_max` defaults to the sentinel `-1`; positive values are baked
680/// into both updaters at build time (adaptive interprets `-1` as
681/// "lazy-init from `mu_max_fact * avrg_compl`").
682#[derive(Debug, Clone)]
683pub struct MuOptions {
684 pub mu_init: Number,
685 pub mu_max: Number,
686 pub mu_max_fact: Number,
687 pub mu_min: Number,
688 pub mu_target: Number,
689 pub mu_linear_decrease_factor: Number,
690 pub mu_superlinear_decrease_power: Number,
691 pub mu_allow_fast_monotone_decrease: bool,
692 pub barrier_tol_factor: Number,
693 /// `tau_min` — floor on the fraction-to-the-boundary parameter
694 /// τ = max(tau_min, 1 − μ). Registered default 0.99, which is what
695 /// both `MonotoneMuUpdate` and `AdaptiveMuUpdate` already carry as
696 /// their struct default, so forwarding it is behaviour-neutral for
697 /// a run that does not set the option (#551 / #677). Consumed by
698 /// both updaters; the adaptive one also uses it in the monotone
699 /// mode and for the post-oracle τ = max(tau_min, 1 − NLP error).
700 pub tau_min: Number,
701 /// `sigma_max` / `sigma_min` — clamp on the centering parameter σ
702 /// chosen by `QualityFunctionMuOracle`. Only consumed when
703 /// `mu_strategy=adaptive` and `mu_oracle=quality-function`.
704 /// Defaults from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
705 pub sigma_max: Number,
706 pub sigma_min: Number,
707 /// `adaptive_mu_globalization` — globalization strategy for the
708 /// adaptive μ-selection mode. Mirrors
709 /// `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Default is
710 /// `ObjConstrFilter`; the Mehrotra cascade switches to
711 /// `NeverMonotoneMode` to disable globalization entirely.
712 pub adaptive_mu_globalization: crate::mu::adaptive::AdaptiveMuGlobalization,
713 /// `quality_function_norm_type` — norm used inside the quality
714 /// function to aggregate the three KKT components. Forwarded to
715 /// `QualityFunctionMuOracle` when `mu_oracle=quality-function`.
716 pub quality_function_norm_type: crate::mu::oracle::quality_function::NormType,
717 /// `quality_function_centrality` — centrality penalty term added
718 /// to the quality function.
719 pub quality_function_centrality: crate::mu::oracle::quality_function::CentralityType,
720 /// `quality_function_balancing_term` — balancing penalty term in
721 /// the quality function (kicks in when complementarity is far
722 /// below infeasibilities).
723 pub quality_function_balancing_term: crate::mu::oracle::quality_function::BalancingTermType,
724 /// `quality_function_max_section_steps` — cap on golden-section
725 /// iterations when picking σ. Default 8.
726 pub quality_function_max_section_steps: i32,
727 /// `quality_function_section_sigma_tol` — width tolerance in
728 /// σ-space for golden section. Default 1e-2.
729 pub quality_function_section_sigma_tol: Number,
730 /// `quality_function_section_qf_tol` — relative flatness
731 /// tolerance for golden section. Default 0.0.
732 pub quality_function_section_qf_tol: Number,
733 /// `adaptive_mu_safeguard_factor` — guard for the LOQO fallback
734 /// in adaptive mode. Default 0.0.
735 pub adaptive_mu_safeguard_factor: Number,
736 /// `adaptive_mu_monotone_init_factor` — multiplier on the
737 /// average complementarity when seeding monotone mode after a
738 /// free-mode bailout. Default 0.8.
739 pub adaptive_mu_monotone_init_factor: Number,
740 /// `adaptive_mu_restore_previous_iterate` — restore the most
741 /// recent free-mode iterate when switching to fixed mode.
742 /// Default `false`.
743 pub adaptive_mu_restore_previous_iterate: bool,
744 /// `adaptive_mu_max_free_returns` (pounce#749, POUNCE
745 /// extension). Cap on how many times the adaptive strategy may
746 /// leave fixed-mu mode. `-1` = unlimited (upstream behavior).
747 pub adaptive_mu_max_free_returns: i32,
748 /// `adaptive_mu_budget_pin_fraction` (pounce#753, POUNCE
749 /// extension). Fraction of an explicitly-set `max_cpu_time` /
750 /// `max_wall_time` after which the adaptive strategy stops
751 /// returning to free-mu mode and finishes monotone. `1.0`
752 /// disables. Inert unless the caller set a time budget.
753 pub adaptive_mu_budget_pin_fraction: Number,
754 /// `adaptive_mu_kkterror_red_iters` — window length for the
755 /// `KKT_ERROR` globalization history. Default 4.
756 pub adaptive_mu_kkterror_red_iters: usize,
757 /// `adaptive_mu_kkterror_red_fact` — required relative reduction
758 /// of the KKT error over the window. Default 0.9999.
759 pub adaptive_mu_kkterror_red_fact: Number,
760 /// `adaptive_mu_kkt_norm_type` — norm used to score the iterate
761 /// in adaptive globalization decisions.
762 pub adaptive_mu_kkt_norm_type: crate::mu::adaptive::AdaptiveMuKktNorm,
763 /// `filter_margin_fact` — width factor of the margin an entry must
764 /// clear in the `obj-constr-filter` adaptive globalization test
765 /// (`margin = filter_margin_fact * min(filter_max_margin, err)`).
766 /// Only consumed when `mu_strategy=adaptive` and
767 /// `adaptive_mu_globalization=obj-constr-filter` (the default).
768 /// Default 1e-5, from `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
769 pub filter_margin_fact: Number,
770 /// `filter_max_margin` — cap on the margin above. Default 1.0,
771 /// from `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
772 pub filter_max_margin: Number,
773 /// `probing_iterate_quality_factor` (default 1e4, pounce-specific
774 /// — see pounce#58). When the probing (Mehrotra) μ-oracle is
775 /// about to read `curr_avrg_compl()` for its `mu_curr` input, a
776 /// single imbalanced `(s_i, z_i)` pair can inflate the average
777 /// 5+ orders above the stored `data.curr_mu`. The oracle then
778 /// returns `σ · mu_curr` ≫ previous μ, throwing the iterate out
779 /// of the convergence neighborhood. This guard short-circuits
780 /// that case by signalling restoration when the ratio
781 /// `curr_avrg_compl / curr_mu` exceeds the factor. Set to 0 or
782 /// any non-positive value to disable.
783 pub probing_iterate_quality_factor: Number,
784}
785
786impl Default for MuOptions {
787 fn default() -> Self {
788 Self {
789 mu_init: 0.1,
790 mu_max: -1.0,
791 mu_max_fact: 1e3,
792 mu_min: 1e-11,
793 mu_target: 0.0,
794 mu_linear_decrease_factor: 0.2,
795 mu_superlinear_decrease_power: 1.5,
796 mu_allow_fast_monotone_decrease: true,
797 barrier_tol_factor: 10.0,
798 tau_min: 0.99,
799 sigma_max: 1e2,
800 sigma_min: 1e-6,
801 adaptive_mu_globalization:
802 crate::mu::adaptive::AdaptiveMuGlobalization::ObjConstrFilter,
803 quality_function_norm_type:
804 crate::mu::oracle::quality_function::NormType::TwoNormSquared,
805 quality_function_centrality: crate::mu::oracle::quality_function::CentralityType::None,
806 quality_function_balancing_term:
807 crate::mu::oracle::quality_function::BalancingTermType::None,
808 quality_function_max_section_steps: 8,
809 quality_function_section_sigma_tol: 1e-2,
810 quality_function_section_qf_tol: 0.0,
811 adaptive_mu_safeguard_factor: 0.0,
812 adaptive_mu_monotone_init_factor: 0.8,
813 adaptive_mu_restore_previous_iterate: false,
814 adaptive_mu_max_free_returns: -1,
815 adaptive_mu_budget_pin_fraction: 0.75,
816 adaptive_mu_kkterror_red_iters: 4,
817 adaptive_mu_kkterror_red_fact: 0.9999,
818 adaptive_mu_kkt_norm_type: crate::mu::adaptive::AdaptiveMuKktNorm::TwoNormSquared,
819 filter_margin_fact: 1e-5,
820 filter_max_margin: 1.0,
821 probing_iterate_quality_factor: 1e4,
822 }
823 }
824}
825
826/// Knobs baked into the assembled [`BacktrackingLineSearch`]. Defaults
827/// mirror `IpBacktrackingLineSearch.cpp:RegisterOptions`.
828#[derive(Debug, Clone)]
829pub struct LineSearchOptions {
830 /// `alpha_red_factor` — fractional reduction applied to the trial
831 /// step size at every backtracking step
832 /// (`alpha *= alpha_red_factor`). Mirrors upstream's
833 /// `IpBacktrackingLineSearch::alpha_red_factor_`.
834 pub alpha_red_factor: Number,
835 /// `alpha_red_factor_min` — floor on one backtracking reduction,
836 /// which turns the fixed geometric trial sequence into a
837 /// safeguarded quadratic interpolation (gh#818). See
838 /// `BacktrackingLineSearch::next_alpha`.
839 ///
840 /// `None` — the default — means "let the Hessian mode decide", and
841 /// the two modes decide differently:
842 ///
843 /// * **limited-memory → `0.05`** (interpolation on). The
844 /// quasi-Newton model's *scale* can be wrong by orders of
845 /// magnitude in any direction its curvature pairs do not span, so
846 /// the acceptable `alpha` can be far below 1 and the fixed factor
847 /// spends `log(1/alpha)` objective evaluations walking to it.
848 /// * **exact → off** (`alpha_red_factor`, i.e. upstream's fixed
849 /// sequence). A Newton step's length is meaningful, the
850 /// acceptable `alpha` is normally within a few halvings of 1, and
851 /// the trial sequence is not what costs.
852 ///
853 /// The split is measured, not assumed. Forcing
854 /// `alpha_red_factor_min 0.05` onto the exact path moves 3 of the
855 /// 156 fixture-legs in `scripts/sweep-fixtures.sh`, and the one
856 /// that matters is a **status loss**: `eigena2` goes from
857 /// `SolveSucceeded`/27 to `SolvedToAcceptableLevel`/32. The other
858 /// two are `issue_508_infeasible_gap_1em4` 441 → 385 to the same
859 /// certificate — a gain — and an objective digit on
860 /// `hs13_bigstart`. One fixture giving up a solve is the whole
861 /// argument; a faster infeasibility certificate does not buy it
862 /// back. Before `ALPHA_INTERP_MIN_TRIALS` gated the interpolation
863 /// the same experiment moved 9 legs and cost
864 /// `infeasible_square_scaled_1em4` its infeasibility certificate
865 /// (`InfeasibleProblemDetected`/17 → `ErrorInStepComputation`/12);
866 /// the gate narrowed the damage, it did not remove the reason for
867 /// the split.
868 ///
869 /// An explicit `alpha_red_factor_min` from the user is honoured on
870 /// both paths — the mode-dependence is only in the default.
871 pub alpha_red_factor_min: Option<Number>,
872 pub watchdog_shortened_iter_trigger: Index,
873 pub watchdog_trial_iter_max: Index,
874 /// `soft_resto_pderror_reduction_factor` — required relative
875 /// reduction in the primal-dual error for a soft-resto step.
876 /// `0` disables the soft restoration phase.
877 pub soft_resto_pderror_reduction_factor: Number,
878 /// `max_soft_resto_iters` — cap on consecutive soft-resto
879 /// iterations before full restoration is forced.
880 pub max_soft_resto_iters: Index,
881 /// `accept_every_trial_step` — short-circuits the filter / alpha
882 /// loop and accepts the full fraction-to-the-boundary step every
883 /// outer iteration. Mirrors upstream's
884 /// `IpBacktrackingLineSearch::accept_every_trial_step_`. Drops
885 /// global convergence guarantees; only safe for problems where the
886 /// Newton step is already a descent step (LPs, convex QPs). The
887 /// Mehrotra cascade in `application.rs` flips this on.
888 pub accept_every_trial_step: bool,
889 /// `alpha_for_y` — policy for the equality-multiplier (y_c / y_d)
890 /// step length. Upstream default is `Primal`; the Mehrotra cascade
891 /// switches to `BoundMult`.
892 pub alpha_for_y: crate::line_search::backtracking::AlphaForY,
893 /// `accept_after_max_steps` — accept a trial point once this many
894 /// backtracking steps have been taken, even if it fails the
895 /// acceptor's tests. `-1` (the default) disables it, which is why
896 /// wiring it moves no default trajectory. Mirrors
897 /// `IpBacktrackingLineSearch.cpp:759-770`.
898 pub accept_after_max_steps: Index,
899
900 // Filter switching / Armijo / margin constants baked onto the
901 // assembled [`crate::line_search::filter_acceptor::FilterLsAcceptor`]
902 // (only when `line_search_method = Filter`). All were registered but
903 // never read (#191); defaults mirror `IpFilterLSAcceptor.cpp`.
904 /// `eta_phi` — relaxation factor in the Armijo condition (Eqn. (20)).
905 pub eta_phi: Number,
906 /// `delta` — multiplier on the constraint violation in the filter's
907 /// switching rule (Eqn. (19)); maps to
908 /// [`FilterLsAcceptor::delta_armijo`]. Default 1.0, from
909 /// `IpFilterLSAcceptor.cpp:RegisterOptions`.
910 pub delta: Number,
911 /// `theta_min_fact` — constraint-violation threshold factor in the
912 /// switching rule.
913 pub theta_min_fact: Number,
914 /// `theta_max_fact` — upper-bound factor for constraint violation in
915 /// the filter (Eqn. (21)).
916 pub theta_max_fact: Number,
917 /// `theta_max_row_scale_kappa` — multiplier on the constraint-row
918 /// count used as the floor of the `theta_max` reference.
919 /// **Opt-in**: default `0`, which is upstream's bare
920 /// `max(1, theta_0)` floor bit-for-bit. Set to `1` on a large model
921 /// that stalls from a feasible start. See
922 /// [`FilterLsAcceptor::theta_max_row_scale_kappa`].
923 pub theta_max_row_scale_kappa: Number,
924 /// `theta_max_adaptive_trigger` — consecutive line searches whose
925 /// every trial was refused at the `theta_max` gate before the
926 /// ceiling is raised. `0` disables the rule. See
927 /// [`FilterLsAcceptor::theta_max_adaptive_trigger`] (pounce#546).
928 pub theta_max_adaptive_trigger: u32,
929 /// Geometric factor applied to `theta_max` on each adaptive raise.
930 /// See [`FilterLsAcceptor::theta_max_adaptive_factor`].
931 pub theta_max_adaptive_factor: Number,
932 /// Cap on adaptive raises per solve, which is what keeps `theta_max`
933 /// finite. See [`FilterLsAcceptor::theta_max_adaptive_max_raises`].
934 pub theta_max_adaptive_max_raises: u32,
935 /// `gamma_phi` — filter margin factor for the barrier function
936 /// (Eqn. (18a)).
937 pub gamma_phi: Number,
938 /// `gamma_theta` — filter margin factor for the constraint violation
939 /// (Eqn. (18b)).
940 pub gamma_theta: Number,
941 /// `s_phi` — exponent for the linear barrier model in the switching
942 /// rule (Eqn. (19)).
943 pub s_phi: Number,
944 /// `s_theta` — exponent for the current constraint violation in the
945 /// switching rule (Eqn. (19)).
946 pub s_theta: Number,
947 /// `alpha_min_frac` — safety factor for the minimal step size before
948 /// switching to restoration (gamma_alpha, Eqn. (23)).
949 pub alpha_min_frac: Number,
950 /// `obj_max_inc` — max acceptable increase (orders of magnitude) of
951 /// the barrier objective for a trial point.
952 pub obj_max_inc: Number,
953 /// `max_filter_resets` — maximum number of filter resets allowed
954 /// (`0` disables the reset heuristic).
955 pub max_filter_resets: Index,
956 /// `filter_reset_trigger` — successive filter-rejected iterations that
957 /// trigger a filter reset.
958 pub filter_reset_trigger: Index,
959 /// `filter_theta_roundoff_retry` (gh#945) — whether a line search that
960 /// runs out of `alpha` at an iterate already feasible to round-off gets
961 /// one more pass with the filter's `theta` axis measured against
962 /// `theta`'s own evaluation noise, before the driver hands off to a
963 /// restoration phase that has nothing to minimize. **On by default**;
964 /// see `BacktrackingLineSearch::run_filter_line_search` and
965 /// `IpoptCq::theta_evaluation_noise_floor`.
966 pub filter_theta_roundoff_retry: bool,
967 /// `objective_noise_wolfe` (gh#1016) — whether a solve whose line search
968 /// has accepted a numerical null step also accepts trials by the
969 /// approximate-Wolfe slope test. **On by default**; see
970 /// `BacktrackingLineSearch::objective_noise_wolfe`.
971 pub objective_noise_wolfe: bool,
972
973 // Penalty-acceptor constants baked onto the assembled
974 // [`crate::line_search::penalty_acceptor::PenaltyLsAcceptor`] (only
975 // when `line_search_method = penalty` / `cg-penalty`). Defaults
976 // mirror `IpPenaltyLSAcceptor.cpp:RegisterOptions`.
977 /// `nu_init` — initial value of the penalty parameter ν.
978 pub nu_init: Number,
979 /// `nu_inc` — increment added when ν is bumped.
980 pub nu_inc: Number,
981 /// `rho` — convex-combination weight in the ν update rule.
982 pub rho: Number,
983 /// `eta_penalty` — relaxation factor in the Armijo condition on the
984 /// penalty merit function.
985 pub eta_penalty: Number,
986
987 // Second-order-correction constants baked onto the assembled
988 // [`BacktrackingLineSearch`]. Registered but never read (#191);
989 // defaults mirror `IpBacktrackingLineSearch.cpp`.
990 /// `max_soc` — max second-order-correction trial steps per iteration;
991 /// `0` disables SOC.
992 pub max_soc: Index,
993 /// `kappa_soc` — sufficient-reduction factor for a SOC step to be
994 /// continued.
995 pub kappa_soc: Number,
996 /// `soc_method` — `0` (paper method) or `1` (alpha-on-rhs variant).
997 pub soc_method: Index,
998}
999
1000impl Default for LineSearchOptions {
1001 fn default() -> Self {
1002 Self {
1003 alpha_red_factor: 0.5,
1004 alpha_red_factor_min: None,
1005 watchdog_shortened_iter_trigger: 10,
1006 watchdog_trial_iter_max: 3,
1007 soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
1008 max_soft_resto_iters: 10,
1009 accept_every_trial_step: false,
1010 alpha_for_y: crate::line_search::backtracking::AlphaForY::Primal,
1011 accept_after_max_steps: -1,
1012 eta_phi: 1e-8,
1013 delta: 1.0,
1014 theta_min_fact: 1e-4,
1015 theta_max_fact: 1e4,
1016 theta_max_row_scale_kappa: 0.0,
1017 theta_max_adaptive_trigger: 3,
1018 theta_max_adaptive_factor: 100.0,
1019 theta_max_adaptive_max_raises: 4,
1020 gamma_phi: 1e-8,
1021 gamma_theta: 1e-5,
1022 s_phi: 2.3,
1023 s_theta: 1.1,
1024 alpha_min_frac: 0.05,
1025 obj_max_inc: 5.0,
1026 max_filter_resets: 5,
1027 filter_reset_trigger: 5,
1028 filter_theta_roundoff_retry: true,
1029 objective_noise_wolfe: true,
1030 nu_init: 1e-6,
1031 nu_inc: 1e-4,
1032 rho: 0.1,
1033 eta_penalty: 1e-8,
1034 max_soc: 4,
1035 kappa_soc: 0.99,
1036 soc_method: 0,
1037 }
1038 }
1039}
1040
1041/// Inertia-correction / regularization knobs baked onto the assembled
1042/// [`crate::kkt::perturbation_handler::PdPerturbationHandler`]. Field
1043/// names use the option names; they map to the handler's `delta_xs_*` /
1044/// `delta_cd_*` fields. Defaults mirror
1045/// `IpPDPerturbationHandler.cpp:RegisterOptions`. All were registered but
1046/// never read (#191).
1047#[derive(Debug, Clone)]
1048pub struct PerturbationOptions {
1049 /// `max_hessian_perturbation` → `delta_xs_max`.
1050 pub max_hessian_perturbation: Number,
1051 /// `min_hessian_perturbation` → `delta_xs_min`.
1052 pub min_hessian_perturbation: Number,
1053 /// `perturb_inc_fact_first` → `delta_xs_first_inc_fact`.
1054 pub perturb_inc_fact_first: Number,
1055 /// `perturb_inc_fact` → `delta_xs_inc_fact`.
1056 pub perturb_inc_fact: Number,
1057 /// `perturb_dec_fact` → `delta_xs_dec_fact`.
1058 pub perturb_dec_fact: Number,
1059 /// `first_hessian_perturbation` → `delta_xs_init`.
1060 pub first_hessian_perturbation: Number,
1061 /// `jacobian_regularization_value` → `delta_cd_val`.
1062 pub jacobian_regularization_value: Number,
1063 /// `jacobian_regularization_exponent` → `delta_cd_exp`.
1064 pub jacobian_regularization_exponent: Number,
1065 /// `perturb_always_cd` — always regularize the c/d (Jacobian) block.
1066 pub perturb_always_cd: bool,
1067 /// `perturb_delta_c_max_rungs` → `delta_c_max_rungs` (pounce gh#592).
1068 pub perturb_delta_c_max_rungs: Index,
1069 /// `perturb_slack_fact` → `delta_s_fact` (pounce gh#1098).
1070 pub perturb_slack_fact: Number,
1071 /// `perturb_slack_max_rows` → `delta_s_max_rows` (pounce gh#1098).
1072 pub perturb_slack_max_rows: Index,
1073}
1074
1075impl Default for PerturbationOptions {
1076 fn default() -> Self {
1077 Self {
1078 max_hessian_perturbation: 1e20,
1079 min_hessian_perturbation: 1e-20,
1080 perturb_inc_fact_first: 100.0,
1081 perturb_inc_fact: 8.0,
1082 perturb_dec_fact: 1.0 / 3.0,
1083 first_hessian_perturbation: 1e-4,
1084 jacobian_regularization_value: 1e-8,
1085 jacobian_regularization_exponent: 0.25,
1086 perturb_always_cd: false,
1087 perturb_delta_c_max_rungs: 3,
1088 perturb_slack_fact: crate::ftb_floor::DEFAULT_PERTURB_SLACK_FACT,
1089 perturb_slack_max_rows: crate::ftb_floor::DEFAULT_PERTURB_SLACK_MAX_ROWS,
1090 }
1091 }
1092}
1093
1094/// Restoration-phase knobs carried on the outer builder and copied into
1095/// the `RestoAlgorithmBuilder` when the restoration factory is minted
1096/// (`pounce-restoration`). The restoration builder is constructed with
1097/// defaults by each frontend and never options-configured, so these were
1098/// registered but never read (#191). Defaults mirror upstream's
1099/// restoration `RegisterOptions`.
1100#[derive(Debug, Clone)]
1101pub struct RestoOptions {
1102 /// `bound_mult_reset_threshold` — reset bound multipliers to 1 after
1103 /// restoration if the largest exceeds this.
1104 pub bound_mult_reset_threshold: Number,
1105 /// `constr_mult_reset_threshold` — ignore the least-square constraint
1106 /// multiplier estimate after restoration if its norm exceeds this
1107 /// (`0` keeps the estimate).
1108 pub constr_mult_reset_threshold: Number,
1109 /// `resto_penalty_parameter` — penalty on the slack 1-norm in the
1110 /// restoration objective (`rho`).
1111 pub resto_penalty_parameter: Number,
1112 /// `resto_proximity_weight` — proximity-term weight (`eta_factor`;
1113 /// `η = eta_factor · sqrt(μ)`).
1114 pub resto_proximity_weight: Number,
1115 /// `required_infeasibility_reduction` — the restoration sub-solve
1116 /// keeps iterating until the *original* NLP's infeasibility has been
1117 /// reduced to at most this fraction of its value at restoration entry
1118 /// (`κ_resto` in `IpRestoConvCheck.cpp:58`). `0` disables the guard,
1119 /// i.e. restoration runs until the sub-NLP itself converges.
1120 pub required_infeasibility_reduction: Number,
1121 /// `evaluate_orig_obj_at_resto_trial` — evaluate the *original*
1122 /// objective at every restoration trial point, so an iterate the
1123 /// restoration problem likes but the original cannot evaluate is
1124 /// rejected there rather than after the phase exits. Upstream default
1125 /// `yes`. `RestoAlgorithmBuilder` has consumed this since it landed;
1126 /// only the read site was missing (gh#483, #191 round 2).
1127 pub evaluate_orig_obj_at_resto_trial: bool,
1128 /// `expect_infeasible_problem` — enter restoration sooner and demand
1129 /// more infeasibility reduction before leaving it. Upstream default
1130 /// `no`. Same story: consumed, never read.
1131 pub expect_infeasible_problem: bool,
1132 /// `start_with_resto` — switch to restoration in the first iteration.
1133 /// Upstream default `no`. Same story.
1134 pub start_with_resto: bool,
1135 /// `max_resto_iter` — cap on *successive* restoration iterations
1136 /// (`IpRestoConvCheck.cpp:144`'s `maximum_resto_iters`). Consumed by
1137 /// `pounce_restoration::conv_check::RestoConvCheckAdapter`, which
1138 /// returns `MaxIterExceeded` once the count is reached; the value
1139 /// used to be the hard-coded `RESTO_MAX_SUCCESSIVE_ITERS` in
1140 /// `resto_inner_solver.rs`, so setting the option did nothing
1141 /// (#551 / #677). The field is named after the option here, but the
1142 /// consumer's field is `maximum_resto_iters` — which is why grepping
1143 /// for the option name found nothing (#551 caution 2).
1144 ///
1145 /// **This default deliberately differs from the registered one.**
1146 /// `upstream_options.rs` registers Ipopt's `3000000`; pounce has
1147 /// enforced `3000` since the cap landed. Wiring the option must not
1148 /// change what an unset option does, so the effective cap stays
1149 /// `3000` and only an explicit `max_resto_iter` moves it. Raising
1150 /// the default to upstream's number is a trajectory change (it
1151 /// lets a restoration that pounce currently cuts off at 3000 keep
1152 /// going) and belongs to a change that measures it.
1153 pub max_resto_iter: i32,
1154}
1155
1156impl Default for RestoOptions {
1157 fn default() -> Self {
1158 Self {
1159 bound_mult_reset_threshold: 1e3,
1160 constr_mult_reset_threshold: 0.0,
1161 resto_penalty_parameter: 1e3,
1162 resto_proximity_weight: 1.0,
1163 required_infeasibility_reduction: 0.9,
1164 evaluate_orig_obj_at_resto_trial: true,
1165 expect_infeasible_problem: false,
1166 start_with_resto: false,
1167 // NOT the registered default (3000000) — see the field docs.
1168 max_resto_iter: 3000,
1169 }
1170 }
1171}
1172
1173/// Iterative-refinement knobs baked onto the assembled
1174/// [`crate::kkt::pd_full_space_solver::PdFullSpaceSolver`]. Defaults
1175/// mirror `IpPDFullSpaceSolver.cpp:RegisterOptions`. All were registered
1176/// but never read (#191).
1177#[derive(Debug, Clone)]
1178pub struct RefinementOptions {
1179 /// `min_refinement_steps` — minimum iterative-refinement steps per
1180 /// linear solve.
1181 pub min_refinement_steps: Index,
1182 /// `max_refinement_steps` — maximum iterative-refinement steps.
1183 pub max_refinement_steps: Index,
1184 /// `residual_ratio_max` — refine until the residual test ratio drops
1185 /// below this (or `max_refinement_steps` is reached).
1186 pub residual_ratio_max: Number,
1187 /// `residual_ratio_singular` — above this ratio after failed
1188 /// refinement, the system is declared singular.
1189 pub residual_ratio_singular: Number,
1190 /// `residual_improvement_factor` — minimum per-step reduction of the
1191 /// residual test ratio before refinement is aborted.
1192 pub residual_improvement_factor: Number,
1193 /// `neg_curv_test_tol` — tolerance α_n of the inertia-free curvature
1194 /// test of Zavala & Chiang (2014). Zero (the registered default)
1195 /// disables the heuristic and keeps the inertia check; positive
1196 /// turns the inertia check off and accepts the factorization only
1197 /// when the computed direction passes the curvature test in
1198 /// `PdFullSpaceSolver::solve_once`.
1199 pub neg_curv_test_tol: Number,
1200 /// `neg_curv_test_reg` — whether the curvature test includes the
1201 /// primal regularization δ_x‖dx‖² + δ_s‖ds‖². Registered default
1202 /// `yes`; `no` reproduces the original Ipopt form that ignores it.
1203 /// Only consulted when `neg_curv_test_tol > 0`.
1204 pub neg_curv_test_reg: bool,
1205}
1206
1207impl Default for RefinementOptions {
1208 fn default() -> Self {
1209 Self {
1210 min_refinement_steps: 1,
1211 max_refinement_steps: 10,
1212 residual_ratio_max: 1e-10,
1213 residual_ratio_singular: 1e-5,
1214 residual_improvement_factor: 0.999_999_999,
1215 neg_curv_test_tol: 0.0,
1216 neg_curv_test_reg: true,
1217 }
1218 }
1219}
1220
1221/// Knobs baked into the assembled [`OrigIterationOutput`]. Defaults
1222/// mirror `IpOrigIterationOutput.cpp:RegisterOptions` /
1223/// `IpAlgorithmRegOp.cpp`.
1224#[derive(Debug, Clone)]
1225pub struct OutputOptions {
1226 pub print_frequency_iter: Index,
1227 pub print_frequency_time: Number,
1228 /// `print_info_string` (default `false`). When on, the iter row
1229 /// ends with the contents of `IpoptData::info_string` so users
1230 /// can read the per-iteration diagnostic tags.
1231 pub print_info_string: bool,
1232 /// `inf_pr_output` — `"original"` (default) prints the unscaled
1233 /// NLP primal infeasibility; `"internal"` prints the internal
1234 /// reformulated violation. Only meaningful once NLP-side scaling
1235 /// is in play; until then both modes produce the same number.
1236 pub inf_pr_output_internal: bool,
1237}
1238
1239impl Default for OutputOptions {
1240 fn default() -> Self {
1241 Self {
1242 print_frequency_iter: 1,
1243 print_frequency_time: 0.0,
1244 print_info_string: false,
1245 inf_pr_output_internal: false,
1246 }
1247 }
1248}
1249
1250impl Default for AlgorithmBuilder {
1251 fn default() -> Self {
1252 Self {
1253 algorithm: AlgorithmChoice::default(),
1254 linear_solver: LinearSolverChoice::Feral,
1255 linear_system_scaling: LinearSystemScalingChoice::None,
1256 linear_scaling_on_demand: true,
1257 mu_strategy: MuStrategyChoice::Monotone,
1258 mu_oracle: MuOracleKind::QualityFunction,
1259 hessian_approximation: HessianApproxChoice::Exact,
1260 partitioned_update_type: UpdateType::Sr1,
1261 partitioned_update_type_was_set: false,
1262 partitioned_max_element: 64,
1263 objective_nonlinear_vars: None,
1264 hessian_declared_pattern: None,
1265 partitioned_curvature_cap: Number::INFINITY,
1266 partitioned_elements: crate::hess::partitioned_quasi_newton::ElementMode::PerConstraint,
1267 partitioned_block_size: 64,
1268 fd_hessian_pattern: crate::hess::fd_hessian::FdPatternSource::Declared,
1269 fd_hessian_coloring: crate::hess::fd_hessian::FdColoring::Cpr,
1270 fd_hessian_reuse_tol: 0.0,
1271 limited_memory_update_type: UpdateType::Bfgs,
1272 limited_memory_max_history: 6,
1273 limited_memory_init_val_max: 1e8,
1274 limited_memory_init_val_min: 1e-8,
1275 limited_memory_initialization: InitialApprox::Scalar1,
1276 limited_memory_init_val: 1.0,
1277 limited_memory_max_skipping: 2,
1278 limited_memory_nonlinear_vars: None,
1279 limited_memory_leading_block: None,
1280 line_search_method: LineSearchChoice::Filter,
1281 warm_start_init_point: false,
1282 mehrotra_algorithm: false,
1283 fast_step_computation: false,
1284 kappa_sigma: 1e10,
1285 ftb_centrality_floor: crate::ftb_floor::DEFAULT_FTB_CENTRALITY_FLOOR,
1286 recalc_y: false,
1287 recalc_y_feas_tol: 1e-6,
1288 kappa_d: 1e-5,
1289 s_max: 100.0,
1290 tiny_step_tol: 10.0 * Number::EPSILON,
1291 tiny_step_y_tol: 1e-2,
1292 diverging_iterates_tol: 1e20,
1293 dual_divergence_retry_step_tol: 1e-5,
1294 dual_divergence_retry_du_floor: 1e2,
1295 tiny_step_dual_stall_iters: 10,
1296 dual_diverging_streak: 0,
1297 resto_decline_deferrals: 1,
1298 resto_decline_progress_ratio: 0.5,
1299 neg_curv_escapes: 1,
1300 limited_memory_ls_failure_restarts: 0,
1301 kkt_fidelity_tol: 0.0,
1302 conv_check: ConvCheckOptions::default(),
1303 mu: MuOptions::default(),
1304 line_search: LineSearchOptions::default(),
1305 refinement: RefinementOptions::default(),
1306 perturbation: PerturbationOptions::default(),
1307 resto: RestoOptions::default(),
1308 output: OutputOptions::default(),
1309 warm: WarmStartOptions::default(),
1310 sqp: crate::sqp::SqpOptions::default(),
1311 sqp_qp: pounce_qp::QpOptions::sqp_subproblem(),
1312 init: InitOptions::default(),
1313 kkt_schur: None,
1314 kkt_blocks: None,
1315 kkt_blocks_shared: None,
1316 kkt_block_min_size: crate::kkt::block_aug_system_solver::DEFAULT_MIN_BLOCK_SIZE,
1317 kkt_schur_summary_sink: None,
1318 inertia_sink: None,
1319 quality_escalation_counter: None,
1320 }
1321 }
1322}
1323
1324impl AlgorithmBuilder {
1325 pub fn new() -> Self {
1326 Self::default()
1327 }
1328
1329 /// Install a Schur KKT partition (pounce#180 item 2). `schur_indices` are
1330 /// KKT-space indices (`0..dim`, the `x,s,c,d` block order the aug-system
1331 /// solver assembles); `cfg` configures the per-block feral solvers. Only
1332 /// honored on the IPM + feral + exact-Hessian path by
1333 /// [`Self::build_with_backend`]; ignored otherwise.
1334 pub fn set_kkt_schur(&mut self, schur_indices: Vec<usize>, cfg: pounce_feral::FeralConfig) {
1335 self.kkt_schur = Some((schur_indices, cfg));
1336 }
1337
1338 /// Install a block-parallel KKT partition (structured-KKT Phase 5b).
1339 /// `labels` are KKT-space block ids (`< 0` = the shared border); an empty
1340 /// vector asks the solver to detect the structure from the assembled KKT.
1341 pub fn set_kkt_blocks(&mut self, labels: Vec<i32>, cfg: pounce_feral::FeralConfig) {
1342 self.kkt_blocks = Some((labels, cfg));
1343 }
1344
1345 /// Read a partition from `cell` when none is installed directly — the
1346 /// restoration path, see [`Self::kkt_blocks_shared`].
1347 pub fn set_kkt_blocks_cell(&mut self, cell: KktBlocksCell) {
1348 self.kkt_blocks_shared = Some(cell);
1349 }
1350
1351 /// The partition to build with: an installed one first, then the
1352 /// late-bound cell.
1353 fn resolved_kkt_blocks(&self) -> Option<(Vec<i32>, pounce_feral::FeralConfig)> {
1354 self.kkt_blocks.clone().or_else(|| {
1355 self.kkt_blocks_shared
1356 .as_ref()
1357 .and_then(|c| c.lock().ok().and_then(|g| g.clone()))
1358 })
1359 }
1360
1361 /// Route the Schur backend's factorization stats into `sink` (the same
1362 /// sink the standard backend records into).
1363 pub fn set_kkt_schur_summary_sink(
1364 &mut self,
1365 sink: std::sync::Arc<std::sync::Mutex<pounce_linsol::summary::LinearSolverSummary>>,
1366 ) {
1367 self.kkt_schur_summary_sink = Some(sink);
1368 }
1369
1370 /// Assemble the strategy bundle without a search-direction
1371 /// calculator. Used by structural unit tests that don't want to
1372 /// pull in a linear-solver backend.
1373 pub fn build(&self) -> AlgorithmBundle {
1374 self.build_inner(None)
1375 }
1376
1377 /// Same as [`Self::build`] but also constructs the
1378 /// `SymLinearSolver → AugSystemSolver → PdFullSpaceSolver →
1379 /// PdSearchDirCalc` chain via the supplied `factory`.
1380 pub fn build_with_backend(&self, mut factory: LinearBackendFactory) -> AlgorithmBundle {
1381 let backend = factory(self.linear_solver);
1382 let make_scaling = || -> Option<Box<dyn pounce_linsol::TSymScalingMethod>> {
1383 match self.linear_system_scaling {
1384 LinearSystemScalingChoice::None => None,
1385 LinearSystemScalingChoice::Ruiz => {
1386 Some(Box::new(pounce_linsol::RuizTSymScalingMethod::new()))
1387 }
1388 LinearSystemScalingChoice::Mc19 => {
1389 tracing::warn!(target: "pounce::algorithm",
1390 "pounce: linear_system_scaling=mc19 not yet implemented; using no scaling"
1391 );
1392 None
1393 }
1394 LinearSystemScalingChoice::SlackBased => {
1395 Some(Box::new(pounce_linsol::SlackBasedTSymScalingMethod::new()))
1396 }
1397 }
1398 };
1399 let linsol = TSymLinearSolver::new(backend, make_scaling(), self.linear_scaling_on_demand);
1400 let with_inertia_sink = |aug: StdAugSystemSolver| match self.inertia_sink.clone() {
1401 Some(sink) => aug.with_inertia_sink(sink),
1402 None => aug,
1403 };
1404 let inner_aug = with_inertia_sink(StdAugSystemSolver::new(linsol));
1405 // Limited-memory mode publishes the Hessian as a
1406 // `LowRankUpdateSymMatrix`; wrap the standard solver in the
1407 // Sherman-Morrison-Woodbury low-rank solver so the augmented
1408 // system factorizes only the diagonal `B0` and the quasi-Newton
1409 // update is applied as a rank-`m` correction (`O(n·m)` memory).
1410 let is_lbfgs = matches!(
1411 self.hessian_approximation,
1412 HessianApproxChoice::LimitedMemory
1413 );
1414 let aug_solver: Box<dyn AugSystemSolver> = if is_lbfgs {
1415 // A second, independent inner solver for the Hessian-free
1416 // solves. Both see one W shape each for their whole life, so
1417 // neither re-runs the backend's symbolic factorization when
1418 // the other's shape comes round — see
1419 // `LowRankAugSystemSolver::with_bypass_solver` (gh#730).
1420 let bypass_linsol = TSymLinearSolver::new(
1421 factory(self.linear_solver),
1422 make_scaling(),
1423 self.linear_scaling_on_demand,
1424 );
1425 Box::new(LowRankAugSystemSolver::with_bypass_solver(
1426 Box::new(inner_aug),
1427 Box::new(with_inertia_sink(StdAugSystemSolver::new(bypass_linsol))),
1428 ))
1429 } else if let Some((labels, cfg)) = self.resolved_kkt_blocks() {
1430 // Block-parallel KKT path (structured-KKT Phase 5b). Same gates as
1431 // the Schur arm: feral-specific, and the L-BFGS low-rank wrapper
1432 // owns the (2,2) block, so this is the exact-Hessian path only.
1433 if matches!(self.linear_solver, LinearSolverChoice::Feral) {
1434 let blocks = crate::kkt::BlockAugSystemSolver::new(inner_aug, labels, cfg)
1435 .with_min_block_size(self.kkt_block_min_size);
1436 Box::new(match self.kkt_schur_summary_sink.clone() {
1437 Some(sink) => blocks.with_summary_sink(sink),
1438 None => blocks,
1439 })
1440 } else {
1441 Box::new(inner_aug)
1442 }
1443 } else if let Some((indices, cfg)) = self.kkt_schur.clone() {
1444 // Block-triangular / Schur KKT path (pounce#180 item 2). Only on the
1445 // exact-Hessian feral path — the Schur backend is feral-specific,
1446 // and the L-BFGS low-rank Woodbury wrapper owns the (2,2) block.
1447 // The Schur solver falls back to `StdAugSystemSolver` transparently
1448 // when the partition is unsuitable, so a stray hook never breaks a
1449 // solve; we gate on `linear_solver == Feral` here to avoid silently
1450 // ignoring a user's explicit MA57 selection.
1451 if matches!(self.linear_solver, LinearSolverChoice::Feral) {
1452 let schur = crate::kkt::SchurAugSystemSolver::new(inner_aug, indices, cfg);
1453 Box::new(match self.kkt_schur_summary_sink.clone() {
1454 Some(sink) => schur.with_summary_sink(sink),
1455 None => schur,
1456 })
1457 } else {
1458 Box::new(inner_aug)
1459 }
1460 } else {
1461 Box::new(inner_aug)
1462 };
1463 // Inertia-correction / Jacobian-regularization constants (#191):
1464 // registered but previously never read. Defaults equal the
1465 // registered defaults. `perturb_always_cd` goes through the setter
1466 // because it also rebuilds the initial jac-degeneracy state.
1467 let mut ph = PdPerturbationHandler::new();
1468 ph.delta_xs_max = self.perturbation.max_hessian_perturbation;
1469 ph.delta_xs_min = self.perturbation.min_hessian_perturbation;
1470 ph.delta_xs_first_inc_fact = self.perturbation.perturb_inc_fact_first;
1471 ph.delta_xs_inc_fact = self.perturbation.perturb_inc_fact;
1472 ph.delta_xs_dec_fact = self.perturbation.perturb_dec_fact;
1473 ph.delta_xs_init = self.perturbation.first_hessian_perturbation;
1474 ph.delta_cd_val = self.perturbation.jacobian_regularization_value;
1475 ph.delta_cd_exp = self.perturbation.jacobian_regularization_exponent;
1476 ph.set_perturb_always_cd(self.perturbation.perturb_always_cd);
1477 ph.delta_c_max_rungs = self.perturbation.perturb_delta_c_max_rungs;
1478 ph.delta_s_fact = self.perturbation.perturb_slack_fact;
1479 ph.delta_s_max_rows = self.perturbation.perturb_slack_max_rows;
1480 let perturb = Rc::new(RefCell::new(ph));
1481 let mut pd_solver = PdFullSpaceSolver::new(aug_solver, perturb);
1482 // Iterative-refinement constants (#191): registered but previously
1483 // never read, so overrides were silently dropped. Defaults equal
1484 // the registered defaults.
1485 pd_solver.min_refinement_steps = self.refinement.min_refinement_steps;
1486 pd_solver.max_refinement_steps = self.refinement.max_refinement_steps;
1487 pd_solver.residual_ratio_max = self.refinement.residual_ratio_max;
1488 pd_solver.residual_ratio_singular = self.refinement.residual_ratio_singular;
1489 pd_solver.residual_improvement_factor = self.refinement.residual_improvement_factor;
1490 // Inertia-free curvature test (#551 / #677). Both were registered
1491 // and never read; `neg_curv_test_tol` defaults to 0, which leaves
1492 // the heuristic off and the inertia check on, so this changes
1493 // nothing for a run that does not set it.
1494 pd_solver.neg_curv_test_tol = self.refinement.neg_curv_test_tol;
1495 pd_solver.neg_curv_test_reg = self.refinement.neg_curv_test_reg;
1496 // gh#857: share the escalation tally with the caller, so the
1497 // restoration sub-solve built from a clone of this builder counts
1498 // into the same total.
1499 if let Some(counter) = self.quality_escalation_counter.as_ref() {
1500 pd_solver.set_quality_escalation_counter(Rc::clone(counter));
1501 }
1502 let mut search_dir = PdSearchDirCalc::new(pd_solver);
1503 search_dir.mehrotra_algorithm = self.mehrotra_algorithm;
1504 search_dir.fast_step_computation = self.fast_step_computation;
1505 self.build_inner(Some(search_dir))
1506 }
1507
1508 /// Phase 5b assembly path for the SQP algorithm. Consults
1509 /// `self.algorithm`: when `ActiveSetSqp`, constructs an
1510 /// `SqpAlgorithm` using the supplied backend factory for the
1511 /// QP subproblem solver; otherwise returns `None` so the
1512 /// caller can fall back to the IPM `build_with_backend`.
1513 ///
1514 /// Sister to `build_with_backend`: the SQP algorithm doesn't
1515 /// share `AlgorithmBundle`'s shape (no mu_update / no IPM
1516 /// line search), so the two paths return different types.
1517 pub fn build_sqp_with_backend(
1518 &self,
1519 mut factory: LinearBackendFactory,
1520 ) -> Option<crate::sqp::SqpAlgorithm> {
1521 if !matches!(self.algorithm, AlgorithmChoice::ActiveSetSqp) {
1522 return None;
1523 }
1524 let backend = factory(self.linear_solver);
1525 let qp_solver = pounce_qp::ParametricActiveSetSolver::new(backend);
1526 Some(
1527 crate::sqp::SqpAlgorithm::new(qp_solver, self.sqp.clone())
1528 .with_qp_options(self.sqp_qp.clone()),
1529 )
1530 }
1531
1532 fn build_inner(&self, search_dir: Option<PdSearchDirCalc>) -> AlgorithmBundle {
1533 let mu_update: Box<dyn crate::mu::r#trait::MuUpdate> = match self.mu_strategy {
1534 MuStrategyChoice::Monotone => {
1535 let mut m = MonotoneMuUpdate::new();
1536 m.mu_init = self.mu.mu_init;
1537 // `mu_max` sentinel `-1` keeps the monotone default
1538 // (1e5); only override on a user-supplied positive.
1539 if self.mu.mu_max > 0.0 {
1540 m.mu_max = self.mu.mu_max;
1541 }
1542 m.mu_min = self.mu.mu_min;
1543 m.mu_target = self.mu.mu_target;
1544 m.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
1545 m.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
1546 m.mu_allow_fast_monotone_decrease = self.mu.mu_allow_fast_monotone_decrease;
1547 m.barrier_tol_factor = self.mu.barrier_tol_factor;
1548 m.tau_min = self.mu.tau_min;
1549 m.compl_inf_tol = self.conv_check.compl_inf_tol;
1550 Box::new(m)
1551 }
1552 MuStrategyChoice::Adaptive => {
1553 let mut adaptive = AdaptiveMuUpdate::new();
1554 adaptive.mu_oracle = self.mu_oracle;
1555 adaptive.mu_init = self.mu.mu_init;
1556 // Adaptive treats `mu_max == -1` as "lazy init from
1557 // `mu_max_fact * curr_avrg_compl`" — forward the
1558 // sentinel as-is.
1559 adaptive.mu_max = self.mu.mu_max;
1560 adaptive.mu_max_fact = self.mu.mu_max_fact;
1561 adaptive.mu_min = self.mu.mu_min;
1562 adaptive.compl_inf_tol = self.conv_check.compl_inf_tol;
1563 adaptive.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
1564 adaptive.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
1565 adaptive.barrier_tol_factor = self.mu.barrier_tol_factor;
1566 adaptive.tau_min = self.mu.tau_min;
1567 adaptive.sigma_min = self.mu.sigma_min;
1568 adaptive.sigma_max = self.mu.sigma_max;
1569 adaptive.adaptive_mu_globalization = self.mu.adaptive_mu_globalization;
1570 adaptive.qf_norm_type = self.mu.quality_function_norm_type;
1571 adaptive.qf_centrality_type = self.mu.quality_function_centrality;
1572 adaptive.qf_balancing_term = self.mu.quality_function_balancing_term;
1573 adaptive.qf_max_section_steps = self.mu.quality_function_max_section_steps;
1574 adaptive.qf_section_sigma_tol = self.mu.quality_function_section_sigma_tol;
1575 adaptive.qf_section_qf_tol = self.mu.quality_function_section_qf_tol;
1576 adaptive.probing_iterate_quality_factor = self.mu.probing_iterate_quality_factor;
1577 adaptive.probing_guard_feasible_tol = self.conv_check.tol;
1578 adaptive.adaptive_mu_safeguard_factor = self.mu.adaptive_mu_safeguard_factor;
1579 adaptive.adaptive_mu_monotone_init_factor =
1580 self.mu.adaptive_mu_monotone_init_factor;
1581 adaptive.restore_accepted_iterate = self.mu.adaptive_mu_restore_previous_iterate;
1582 adaptive.max_free_returns = self.mu.adaptive_mu_max_free_returns;
1583 adaptive.budget_pin_fraction = self.mu.adaptive_mu_budget_pin_fraction;
1584 // The pin measures against the same budget the
1585 // convergence check enforces (pounce#753); the
1586 // `conv_check` copies are the ones the application
1587 // also hands to `Deadline::new`.
1588 adaptive.max_cpu_time = self.conv_check.max_cpu_time;
1589 adaptive.max_wall_time = self.conv_check.max_wall_time;
1590 adaptive.adaptive_mu_kkterror_red_iters = self.mu.adaptive_mu_kkterror_red_iters;
1591 adaptive.adaptive_mu_kkterror_red_fact = self.mu.adaptive_mu_kkterror_red_fact;
1592 adaptive.adaptive_mu_kkt_norm = self.mu.adaptive_mu_kkt_norm_type;
1593 adaptive.filter_margin_fact = self.mu.filter_margin_fact;
1594 adaptive.filter_max_margin = self.mu.filter_max_margin;
1595 Box::new(adaptive)
1596 }
1597 };
1598
1599 let acceptor: Box<dyn BacktrackingLsAcceptor> = match self.line_search_method {
1600 LineSearchChoice::Filter => {
1601 // Filter switching / Armijo / margin constants (#191):
1602 // registered but previously never read. Set them on the
1603 // concrete acceptor before boxing; defaults equal the
1604 // registered defaults, so a run that doesn't set them is
1605 // unchanged.
1606 let mut f = FilterLsAcceptor::default();
1607 f.eta_phi = self.line_search.eta_phi;
1608 f.delta_armijo = self.line_search.delta;
1609 f.theta_min_fact = self.line_search.theta_min_fact;
1610 f.theta_max_fact = self.line_search.theta_max_fact;
1611 f.theta_max_row_scale_kappa = self.line_search.theta_max_row_scale_kappa;
1612 f.theta_max_adaptive_trigger = self.line_search.theta_max_adaptive_trigger;
1613 f.theta_max_adaptive_factor = self.line_search.theta_max_adaptive_factor;
1614 f.theta_max_adaptive_max_raises = self.line_search.theta_max_adaptive_max_raises;
1615 f.gamma_phi = self.line_search.gamma_phi;
1616 f.gamma_theta = self.line_search.gamma_theta;
1617 f.s_phi = self.line_search.s_phi;
1618 f.s_theta = self.line_search.s_theta;
1619 f.alpha_min_frac = self.line_search.alpha_min_frac;
1620 f.obj_max_inc = self.line_search.obj_max_inc;
1621 f.max_filter_resets = self.line_search.max_filter_resets;
1622 f.filter_reset_trigger = self.line_search.filter_reset_trigger;
1623 Box::new(f)
1624 }
1625 // Penalty-acceptor constants: same direct-field pattern as
1626 // the filter arm above. `reset()` re-seeds ν (and `last_nu`)
1627 // from the freshly-set `nu_init`, which `default()` had
1628 // seeded from the registered default.
1629 LineSearchChoice::Penalty | LineSearchChoice::CgPenalty => {
1630 // CG-penalty acceptor lands with the rest of the
1631 // CG-penalty path; fall back to the penalty acceptor's
1632 // surface for now.
1633 let mut p = PenaltyLsAcceptor::default();
1634 p.nu_init = self.line_search.nu_init;
1635 p.nu_inc = self.line_search.nu_inc;
1636 p.rho = self.line_search.rho;
1637 p.eta_penalty = self.line_search.eta_penalty;
1638 p.reset();
1639 Box::new(p)
1640 }
1641 };
1642 let mut line_search = BacktrackingLineSearch::new(acceptor);
1643 line_search.alpha_red_factor = self.line_search.alpha_red_factor;
1644 line_search.filter_theta_roundoff_retry = self.line_search.filter_theta_roundoff_retry;
1645 line_search.objective_noise_wolfe = self.line_search.objective_noise_wolfe;
1646 // Resolve `None` against the Hessian mode; see the field's doc
1647 // for the measurement behind the split (gh#818).
1648 line_search.alpha_red_factor_min =
1649 self.line_search
1650 .alpha_red_factor_min
1651 .unwrap_or(match self.hessian_approximation {
1652 // The criterion in the field's doc is whether the
1653 // model's *scale* is trustworthy, not whether it is
1654 // the exact Hessian. A quasi-Newton `B` can be wrong
1655 // by orders of magnitude in any direction its
1656 // curvature pairs do not span, which is as true of
1657 // the partitioned elements as of the limited-memory
1658 // ones — they are the same update on a finer
1659 // decomposition.
1660 HessianApproxChoice::LimitedMemory | HessianApproxChoice::Partitioned => 0.05,
1661 // Equal to `alpha_red_factor`, so the clamp in
1662 // `next_alpha` collapses and the sequence is upstream's.
1663 //
1664 // `FiniteDifference` belongs here rather than above:
1665 // it recovers the Lagrangian Hessian itself by
1666 // probing the analytic Jacobian, so it carries no
1667 // curvature history and its step is a Newton step
1668 // whose length is meaningful. It is the same
1669 // distinction `hessian_at_current` draws — a pure
1670 // function of `(x, y)` on one side, a history-carrying
1671 // `B` on the other. And the measurement behind the
1672 // split cuts this way too: forcing `0.05` onto a path
1673 // with a meaningful step length cost `eigena2` a
1674 // solve.
1675 HessianApproxChoice::Exact | HessianApproxChoice::FiniteDifference => {
1676 self.line_search.alpha_red_factor
1677 }
1678 });
1679 line_search.watchdog_shortened_iter_trigger =
1680 self.line_search.watchdog_shortened_iter_trigger;
1681 line_search.watchdog_trial_iter_max = self.line_search.watchdog_trial_iter_max;
1682 line_search.soft_resto_pderror_reduction_factor =
1683 self.line_search.soft_resto_pderror_reduction_factor;
1684 line_search.max_soft_resto_iters = self.line_search.max_soft_resto_iters;
1685 line_search.accept_every_trial_step = self.line_search.accept_every_trial_step;
1686 line_search.alpha_for_y = self.line_search.alpha_for_y;
1687 line_search.accept_after_max_steps = self.line_search.accept_after_max_steps;
1688 // Second-order-correction constants (#191): registered but
1689 // previously never read. Same direct-field pattern as the
1690 // watchdog knobs above.
1691 line_search.max_soc = self.line_search.max_soc;
1692 line_search.kappa_soc = self.line_search.kappa_soc;
1693 line_search.soc_method = self.line_search.soc_method;
1694
1695 let conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck> =
1696 Box::new(OptErrorConvCheck {
1697 tol: self.conv_check.tol,
1698 dual_inf_tol: self.conv_check.dual_inf_tol,
1699 constr_viol_tol: self.conv_check.constr_viol_tol,
1700 compl_inf_tol: self.conv_check.compl_inf_tol,
1701 acceptable_tol: self.conv_check.acceptable_tol,
1702 acceptable_dual_inf_tol: self.conv_check.acceptable_dual_inf_tol,
1703 acceptable_constr_viol_tol: self.conv_check.acceptable_constr_viol_tol,
1704 acceptable_compl_inf_tol: self.conv_check.acceptable_compl_inf_tol,
1705 acceptable_obj_change_tol: self.conv_check.acceptable_obj_change_tol,
1706 acceptable_iter: self.conv_check.acceptable_iter,
1707 max_iter: self.conv_check.max_iter,
1708 max_cpu_time: self.conv_check.max_cpu_time,
1709 max_wall_time: self.conv_check.max_wall_time,
1710 acceptable_count: 0,
1711 last_acceptable_obj: None,
1712 infeas_stationarity_tol: self.conv_check.infeas_stationarity_tol,
1713 infeas_viol_kappa: self.conv_check.infeas_viol_kappa,
1714 infeas_max_streak: self.conv_check.infeas_max_streak,
1715 infeas_streak: 0,
1716 obj_scale_certificate_threshold: self.conv_check.obj_scale_certificate_threshold,
1717 primal_noise_floor_kappa: self.conv_check.primal_noise_floor_kappa,
1718 acceptable_progress_kappa: self.conv_check.acceptable_progress_kappa,
1719 acceptable_window: std::collections::VecDeque::new(),
1720 acceptable_progress_refusals: 0,
1721 dual_inf_scale_kappa: self.conv_check.dual_inf_scale_kappa,
1722 dual_floor_reported: false,
1723 veto_fired: false,
1724 acceptable_veto_fired: false,
1725 masked_acceptable_veto_fired: false,
1726 veto_extra_iters: 0,
1727 rel_infeas_extra_iters: 0,
1728 prev_rel_viol: f64::NAN,
1729 });
1730
1731 let init: Box<dyn crate::init::r#trait::IterateInitializer> = if self.warm_start_init_point
1732 {
1733 Box::new(WarmStartIterateInitializer::with_options(
1734 resolved_warm_options(&self.warm, &self.init),
1735 ))
1736 } else {
1737 let mut d = DefaultIterateInitializer::with_eq_mult_calculator(Box::new(
1738 LeastSquareMults::new(),
1739 ));
1740 d.bound_push = self.init.bound_push;
1741 d.bound_frac = self.init.bound_frac;
1742 d.slack_bound_push = self.init.slack_bound_push;
1743 d.slack_bound_frac = self.init.slack_bound_frac;
1744 d.constr_mult_init_max = self.init.constr_mult_init_max;
1745 d.bound_mult_init_val = self.init.bound_mult_init_val;
1746 d.bound_mult_init_method = self.init.bound_mult_init_method.clone();
1747 d.least_square_init_primal = self.init.least_square_init_primal;
1748 Box::new(d)
1749 };
1750
1751 let eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator> =
1752 Box::new(LeastSquareMults::new());
1753
1754 let hess: Box<dyn crate::hess::r#trait::HessianUpdater> = match self.hessian_approximation {
1755 HessianApproxChoice::Exact => Box::new(ExactHessianUpdater::new()),
1756 HessianApproxChoice::LimitedMemory => Box::new(LimMemQuasiNewtonUpdater {
1757 update_type: self.limited_memory_update_type,
1758 max_history: self.limited_memory_max_history,
1759 init_val_max: self.limited_memory_init_val_max,
1760 init_val_min: self.limited_memory_init_val_min,
1761 initial_approx: self.limited_memory_initialization,
1762 init_val: self.limited_memory_init_val,
1763 max_skipping: self.limited_memory_max_skipping,
1764 nonlinear_vars: self.limited_memory_nonlinear_vars.clone(),
1765 leading_block: self.limited_memory_leading_block,
1766 ..LimMemQuasiNewtonUpdater::default()
1767 }),
1768 HessianApproxChoice::Partitioned => {
1769 let mut u =
1770 crate::hess::partitioned_quasi_newton::PartitionedQuasiNewtonUpdater::new(
1771 self.partitioned_update_type,
1772 );
1773 u.max_element = self.partitioned_max_element;
1774 u.objective_vars = self.objective_nonlinear_vars.clone();
1775 u.declared_pattern = self.hessian_declared_pattern.clone();
1776 u.curvature_cap = self.partitioned_curvature_cap;
1777 u.mode = self.partitioned_elements;
1778 u.block_size = self.partitioned_block_size;
1779 // Damped BFGS is the right pairing for a Lagrangian
1780 // block: unlike a single constraint's Hessian, the
1781 // Lagrangian's is the object the IPM wants a positive
1782 // definite model of, and the sign problem that makes
1783 // damping wrong per constraint does not arise. Only when
1784 // the caller has not named a formula.
1785 if self.partitioned_elements
1786 == crate::hess::partitioned_quasi_newton::ElementMode::PrimalBlock
1787 && !self.partitioned_update_type_was_set
1788 {
1789 u.update_type = UpdateType::Bfgs;
1790 }
1791 u.init_val_min = self.limited_memory_init_val_min;
1792 u.init_val_max = self.limited_memory_init_val_max;
1793 Box::new(u)
1794 }
1795 HessianApproxChoice::FiniteDifference => {
1796 let mut u = crate::hess::fd_hessian::FdHessianUpdater::new(self.fd_hessian_pattern);
1797 u.coloring = self.fd_hessian_coloring;
1798 u.reuse_tol = self.fd_hessian_reuse_tol;
1799 u.objective_vars = self.objective_nonlinear_vars.clone();
1800 u.nonlinear_vars = self.limited_memory_nonlinear_vars.clone();
1801 Box::new(u)
1802 }
1803 };
1804
1805 let iter_output: Box<dyn crate::output::r#trait::IterationOutput> = {
1806 use crate::output::orig::{InfPrTag, PrintInfoString};
1807 let mut o = OrigIterationOutput::new();
1808 o.print_frequency_iter = self.output.print_frequency_iter;
1809 o.print_frequency_time = self.output.print_frequency_time;
1810 o.print_info_string = if self.output.print_info_string {
1811 PrintInfoString::Yes
1812 } else {
1813 PrintInfoString::No
1814 };
1815 o.inf_pr_output = if self.output.inf_pr_output_internal {
1816 InfPrTag::Internal
1817 } else {
1818 InfPrTag::Original
1819 };
1820 Box::new(o)
1821 };
1822
1823 AlgorithmBundle {
1824 mu_update,
1825 conv_check,
1826 init,
1827 eq_mult,
1828 hess,
1829 line_search,
1830 iter_output,
1831 search_dir,
1832 }
1833 }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838 use super::*;
1839
1840 #[test]
1841 fn warm_options_take_the_init_default_not_their_own() {
1842 let mut init = InitOptions::default();
1843 init.bound_mult_init_val = 10.0; // the Mehrotra override value
1844 let mut warm = WarmStartOptions::default();
1845 warm.bound_mult_init_val = 123.0; // stale copy must lose
1846 let resolved = resolved_warm_options(&warm, &init);
1847 assert_eq!(resolved.bound_mult_init_val, 10.0);
1848 // everything else passes through untouched
1849 assert_eq!(resolved.mult_bound_push, warm.mult_bound_push);
1850 assert_eq!(resolved.target_mu, warm.target_mu);
1851 }
1852
1853 #[test]
1854 fn default_builder_assembles() {
1855 let bundle = AlgorithmBuilder::new().build();
1856 // Sanity: the placeholder traits compile and the boxed
1857 // strategies don't panic on construction.
1858 let _ = bundle.line_search.acceptor();
1859 assert!(bundle.search_dir.is_none());
1860 }
1861
1862 #[test]
1863 fn build_with_backend_assembles_search_dir_chain() {
1864 // Drive the builder with the FERAL backend factory; the
1865 // resulting bundle should expose a populated `PdSearchDirCalc`.
1866 let factory: LinearBackendFactory = Box::new(|_| {
1867 Box::new(pounce_feral::FeralSolverInterface::new())
1868 as Box<dyn SparseSymLinearSolverInterface>
1869 });
1870 let bundle = AlgorithmBuilder::new().build_with_backend(factory);
1871 assert!(bundle.search_dir.is_some());
1872 }
1873
1874 #[test]
1875 fn limited_memory_sr1_propagates() {
1876 let b = AlgorithmBuilder {
1877 hessian_approximation: HessianApproxChoice::LimitedMemory,
1878 limited_memory_update_type: UpdateType::Sr1,
1879 ..AlgorithmBuilder::default()
1880 };
1881 let _bundle = b.build();
1882 }
1883
1884 #[test]
1885 fn every_strategy_combination_assembles_without_panic() {
1886 let solvers = [LinearSolverChoice::Ma57, LinearSolverChoice::Feral];
1887 let mu = [MuStrategyChoice::Monotone, MuStrategyChoice::Adaptive];
1888 let hess = [
1889 HessianApproxChoice::Exact,
1890 HessianApproxChoice::LimitedMemory,
1891 ];
1892 let ls = [
1893 LineSearchChoice::Filter,
1894 LineSearchChoice::CgPenalty,
1895 LineSearchChoice::Penalty,
1896 ];
1897 for &linear_solver in &solvers {
1898 for &mu_strategy in &mu {
1899 for &hessian_approximation in &hess {
1900 for &line_search_method in &ls {
1901 let _ = AlgorithmBuilder {
1902 algorithm: AlgorithmChoice::default(),
1903 linear_solver,
1904 linear_system_scaling: LinearSystemScalingChoice::None,
1905 linear_scaling_on_demand: true,
1906 mu_strategy,
1907 mu_oracle: MuOracleKind::QualityFunction,
1908 hessian_approximation,
1909 partitioned_update_type: UpdateType::Sr1,
1910 partitioned_update_type_was_set: false,
1911 partitioned_max_element: 64,
1912 objective_nonlinear_vars: None,
1913 hessian_declared_pattern: None,
1914 partitioned_curvature_cap: Number::INFINITY,
1915 partitioned_elements:
1916 crate::hess::partitioned_quasi_newton::ElementMode::PerConstraint,
1917 partitioned_block_size: 64,
1918 fd_hessian_pattern: crate::hess::fd_hessian::FdPatternSource::Declared,
1919 fd_hessian_coloring: crate::hess::fd_hessian::FdColoring::Cpr,
1920 fd_hessian_reuse_tol: 0.0,
1921 limited_memory_update_type: UpdateType::Bfgs,
1922 limited_memory_max_history: 6,
1923 limited_memory_init_val_max: 1e8,
1924 limited_memory_init_val_min: 1e-8,
1925 limited_memory_initialization: InitialApprox::Scalar1,
1926 limited_memory_init_val: 1.0,
1927 limited_memory_max_skipping: 2,
1928 limited_memory_nonlinear_vars: None,
1929 limited_memory_leading_block: None,
1930 line_search_method,
1931 warm_start_init_point: false,
1932 mehrotra_algorithm: false,
1933 fast_step_computation: false,
1934 kappa_sigma: 1e10,
1935 ftb_centrality_floor: crate::ftb_floor::DEFAULT_FTB_CENTRALITY_FLOOR,
1936 recalc_y: false,
1937 recalc_y_feas_tol: 1e-6,
1938 kappa_d: 1e-5,
1939 s_max: 100.0,
1940 tiny_step_tol: 10.0 * Number::EPSILON,
1941 tiny_step_y_tol: 1e-2,
1942 diverging_iterates_tol: 1e20,
1943 dual_diverging_streak: 0,
1944 dual_divergence_retry_step_tol: 1e-5,
1945 dual_divergence_retry_du_floor: 1e2,
1946 tiny_step_dual_stall_iters: 10,
1947 resto_decline_deferrals: 1,
1948 resto_decline_progress_ratio: 0.5,
1949 neg_curv_escapes: 1,
1950 limited_memory_ls_failure_restarts: 0,
1951 kkt_fidelity_tol: 0.0,
1952 conv_check: ConvCheckOptions::default(),
1953 mu: MuOptions::default(),
1954 line_search: LineSearchOptions::default(),
1955 refinement: RefinementOptions::default(),
1956 perturbation: PerturbationOptions::default(),
1957 resto: RestoOptions::default(),
1958 output: OutputOptions::default(),
1959 warm: WarmStartOptions::default(),
1960 sqp: crate::sqp::SqpOptions::default(),
1961 sqp_qp: pounce_qp::QpOptions::sqp_subproblem(),
1962 init: InitOptions::default(),
1963 kkt_schur: None,
1964 kkt_blocks: None,
1965 kkt_blocks_shared: None,
1966 kkt_block_min_size:
1967 crate::kkt::block_aug_system_solver::DEFAULT_MIN_BLOCK_SIZE,
1968 kkt_schur_summary_sink: None,
1969 inertia_sink: None,
1970 quality_escalation_counter: None,
1971 }
1972 .build();
1973 }
1974 }
1975 }
1976 }
1977 }
1978}