Skip to main content

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