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::{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}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum MuStrategyChoice {
94    Monotone,
95    Adaptive,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum HessianApproxChoice {
100    Exact,
101    LimitedMemory,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum LineSearchChoice {
106    Filter,
107    CgPenalty,
108    Penalty,
109}
110
111/// Assembled strategy bundle. Phase 7 ships the structural bundle;
112/// `IpoptAlgorithm::new` reads from this when it lands.
113pub struct AlgorithmBundle {
114    pub mu_update: Box<dyn crate::mu::r#trait::MuUpdate>,
115    pub conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck>,
116    pub init: Box<dyn crate::init::r#trait::IterateInitializer>,
117    pub eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator>,
118    pub hess: Box<dyn crate::hess::r#trait::HessianUpdater>,
119    pub line_search: BacktrackingLineSearch,
120    pub iter_output: Box<dyn crate::output::r#trait::IterationOutput>,
121    /// `Some` when the builder was given a [`LinearBackendFactory`];
122    /// `None` for the bare structural bundle that pre-Phase-6 unit
123    /// tests still rely on.
124    pub search_dir: Option<PdSearchDirCalc>,
125}
126
127/// Knobs read off `OptionsList` and baked into the assembled
128/// `OptErrorConvCheck`. Defaults mirror
129/// `IpOptErrorConvCheck.cpp:RegisterOptions`.
130#[derive(Debug, Clone)]
131pub struct ConvCheckOptions {
132    pub tol: Number,
133    pub dual_inf_tol: Number,
134    pub constr_viol_tol: Number,
135    pub compl_inf_tol: Number,
136    pub acceptable_tol: Number,
137    pub acceptable_dual_inf_tol: Number,
138    pub acceptable_constr_viol_tol: Number,
139    pub acceptable_compl_inf_tol: Number,
140    pub acceptable_obj_change_tol: Number,
141    pub acceptable_iter: Index,
142    pub max_iter: Index,
143    pub max_cpu_time: Number,
144    pub max_wall_time: Number,
145    pub infeas_stationarity_tol: Number,
146    pub infeas_viol_kappa: Number,
147    pub infeas_max_streak: Index,
148    /// Objective-scale floor below which a strict termination certificate is
149    /// refused while the unscaled KKT error is still above `acceptable_tol`
150    /// (gh #200). `0` disables the mechanism.
151    pub obj_scale_certificate_threshold: Number,
152}
153
154impl Default for ConvCheckOptions {
155    fn default() -> Self {
156        Self {
157            tol: 1e-8,
158            dual_inf_tol: 1.0,
159            constr_viol_tol: 1e-4,
160            compl_inf_tol: 1e-4,
161            acceptable_tol: 1e-6,
162            acceptable_dual_inf_tol: 1e10,
163            acceptable_constr_viol_tol: 1e-2,
164            acceptable_compl_inf_tol: 1e-2,
165            acceptable_obj_change_tol: 1e20,
166            acceptable_iter: 15,
167            max_iter: 3000,
168            max_cpu_time: 1e6,
169            max_wall_time: 1e6,
170            infeas_stationarity_tol: 1e-8,
171            infeas_viol_kappa: 1e2,
172            infeas_max_streak: 5,
173            obj_scale_certificate_threshold: 1e-4,
174        }
175    }
176}
177
178#[derive(Debug, Clone)]
179pub struct AlgorithmBuilder {
180    /// Top-level algorithm dispatch. Default `InteriorPoint` ⇒
181    /// `build_with_backend` returns the existing `AlgorithmBundle`
182    /// (consumed by `IpoptAlgorithm`). `ActiveSetSqp` ⇒ caller
183    /// must use `build_sqp_with_backend` to assemble the Phase 5b
184    /// `SqpAlgorithm`. The two builder methods sit side by side
185    /// because the assembled algorithm shape differs (IPM bundle
186    /// vs SQP struct).
187    pub algorithm: AlgorithmChoice,
188    pub linear_solver: LinearSolverChoice,
189    /// Symmetric scaling method for the augmented KKT system. Wired
190    /// into [`TSymLinearSolver`] by [`Self::build_with_backend`].
191    /// Mirrors upstream `linear_system_scaling` (`IpAlgBuilder.cpp:538-560`).
192    pub linear_system_scaling: LinearSystemScalingChoice,
193    /// Lazy-vs-eager scaling toggle (`linear_scaling_on_demand`,
194    /// `IpTSymLinearSolver.cpp:50-58`). Only consulted when
195    /// `linear_system_scaling != None`. Upstream default is `true`
196    /// (compute scaling only on the first solve that fails / shows
197    /// poor conditioning); pounce mirrors that. Set to `false` to
198    /// scale every factorization.
199    pub linear_scaling_on_demand: bool,
200    pub mu_strategy: MuStrategyChoice,
201    /// Selector forwarded to [`AdaptiveMuUpdate`] when
202    /// `mu_strategy = Adaptive`. Ignored for `Monotone`. Defaults to
203    /// `QualityFunction` per upstream's `RegisterOptions` default.
204    pub mu_oracle: MuOracleKind,
205    pub hessian_approximation: HessianApproxChoice,
206    pub limited_memory_update_type: UpdateType,
207    /// History length for the limited-memory quasi-Newton approximation
208    /// (`limited_memory_max_history`). Defaults to upstream's 6.
209    pub limited_memory_max_history: i32,
210    pub line_search_method: LineSearchChoice,
211    pub warm_start_init_point: bool,
212    /// `mehrotra_algorithm` — when true, [`PdSearchDirCalc`] folds
213    /// the Mehrotra second-order complementarity term into the
214    /// search-direction RHS. Mirrors upstream's
215    /// `IpAlgBuilder.cpp:Mehrotra` flag. Requires `mu_strategy =
216    /// Adaptive` so that an affine step is computed each iteration;
217    /// [`Self::build_with_backend`] does not enforce this — the
218    /// option-parser in `application.rs` is responsible for the
219    /// cascading defaults (`mu_oracle = probing` etc.).
220    pub mehrotra_algorithm: bool,
221    /// `kappa_sigma` — factor bounding how far the bound multipliers may
222    /// deviate from their primal estimates. The clamp
223    /// (`kappa_sigma_clamp`) runs after every accepted step; `< 1`
224    /// disables the correction. Mirrors `IpIpoptAlg.cpp` (Eqn. (16)),
225    /// default `1e10`. Baked onto [`crate::ipopt_alg::IpoptAlgorithm`] by
226    /// the solve path.
227    pub kappa_sigma: Number,
228    /// `kappa_d` — weight of the linear damping term added to the barrier
229    /// objective/gradient (and dual-infeasibility) to handle one-sided
230    /// bounds. Mirrors `IpIpoptCalculatedQuantities.cpp`, default `1e-5`.
231    /// Baked onto [`crate::ipopt_cq::IpoptCalculatedQuantities`] by the
232    /// solve path.
233    pub kappa_d: Number,
234    /// `tiny_step_tol` — relative primal step size below which the full
235    /// step is accepted without line search; repeated tiny steps
236    /// terminate the solve. Mirrors `IpBacktrackingLineSearch.cpp`,
237    /// default `10·EPSILON`. Baked onto
238    /// [`crate::ipopt_alg::IpoptAlgorithm`] by the solve path.
239    pub tiny_step_tol: Number,
240    /// `tiny_step_y_tol` — dual-step threshold; when both primal and dual
241    /// steps are tiny in consecutive iterations the algorithm stops at the
242    /// best attainable accuracy. Default `1e-2`.
243    pub tiny_step_y_tol: Number,
244    /// `diverging_iterates_tol` — if `max_i |x_i|` exceeds this the solve
245    /// aborts as diverging. Default `1e20`.
246    pub diverging_iterates_tol: Number,
247    /// `dual_diverging_streak` (pounce#246) — consecutive growing-dual-
248    /// infeasibility iterations before the dual-divergence guard routes to
249    /// restoration. **Default `0` (off).**
250    ///
251    /// It defaulted to `15` when introduced, on the strength of a reported
252    /// emfl050 bad-warm-start grind. That justification did not survive being
253    /// reproduced: the measurement was caller-side JAX compilation, and the
254    /// build predating the guard solves both emfl050 instances to the same
255    /// optimum in the same time (pounce#246 / pounce#250). What remained was a
256    /// knife-edge, non-monotone effect on four of 1284 MINLPLib models — so it
257    /// is opt-in rather than imposed. See `upstream_options.rs` for the full
258    /// account.
259    pub dual_diverging_streak: Index,
260    /// `kkt_fidelity_tol` (pounce#173). Read by the algorithm as well as by the
261    /// post-solve gate, because the #200 fallback's tiebreak has to rank the two
262    /// candidate points by the status each will be *reported* under. Default
263    /// `0.0` (gate disabled).
264    pub kkt_fidelity_tol: Number,
265    pub conv_check: ConvCheckOptions,
266    pub mu: MuOptions,
267    pub line_search: LineSearchOptions,
268    pub refinement: RefinementOptions,
269    pub perturbation: PerturbationOptions,
270    pub resto: RestoOptions,
271    pub output: OutputOptions,
272    pub warm: WarmStartOptions,
273    /// SQP-specific options (consulted only when
274    /// `algorithm = ActiveSetSqp`).
275    pub sqp: crate::sqp::SqpOptions,
276    /// QP-subproblem-solver options for the active-set SQP path
277    /// (`pounce_qp::QpOptions`), threaded into the `SqpAlgorithm` via
278    /// `with_qp_options`. Consulted only when `algorithm = ActiveSetSqp`.
279    /// Populated from the `sqp_qp_*` CLI options by
280    /// `application::apply_qp_subproblem_options`.
281    pub sqp_qp: pounce_qp::QpOptions,
282    pub init: InitOptions,
283    /// Optional block-triangular / Schur KKT partition (pounce#180 item 2):
284    /// `(schur_indices, feral_cfg)`. When `Some` and the IPM path is selected
285    /// with the feral linear solver and an exact Hessian, `build_with_backend`
286    /// wraps the standard aug-system solver in a
287    /// [`crate::kkt::SchurAugSystemSolver`] over the given KKT-space indices.
288    /// The Schur solver falls back to the standard solver transparently when
289    /// the partition is unsuitable. Set via [`Self::set_kkt_schur`].
290    pub kkt_schur: Option<(Vec<usize>, pounce_feral::FeralConfig)>,
291}
292
293/// Knobs read off `OptionsList` and baked into
294/// [`DefaultIterateInitializer`]. Defaults mirror
295/// `IpDefaultIterateInitializer.cpp:RegisterOptions`. The Mehrotra
296/// cascade in `application.rs` overrides `bound_push`, `bound_frac`,
297/// and `bound_mult_init_val` to upstream's more-aggressive values
298/// (`10`, `0.2`, `1.0`).
299#[derive(Debug, Clone)]
300pub struct InitOptions {
301    pub bound_push: Number,
302    pub bound_frac: Number,
303    pub slack_bound_push: Number,
304    pub slack_bound_frac: Number,
305    pub constr_mult_init_max: Number,
306    pub bound_mult_init_val: Number,
307    /// `bound_mult_init_method`: `"constant"` (default) or `"mu-based"`
308    /// (matches upstream's `IpDefaultIterateInitializer.cpp`).
309    pub bound_mult_init_method: String,
310    /// `least_square_init_primal` — replace the user's starting `x`
311    /// with the min-norm primal that satisfies the linearized
312    /// constraints. Used by the Mehrotra cascade in `application.rs`
313    /// to drop iter-0 primal infeasibility on LP-shaped problems.
314    /// Mirrors upstream `IpDefaultIterateInitializer.cpp:200-222`.
315    pub least_square_init_primal: bool,
316}
317
318impl Default for InitOptions {
319    fn default() -> Self {
320        Self {
321            bound_push: 1e-2,
322            bound_frac: 1e-2,
323            slack_bound_push: 1e-2,
324            slack_bound_frac: 1e-2,
325            constr_mult_init_max: 1e3,
326            bound_mult_init_val: 1.0,
327            bound_mult_init_method: "constant".into(),
328            least_square_init_primal: false,
329        }
330    }
331}
332
333/// Knobs read off `OptionsList` and baked into
334/// [`WarmStartIterateInitializer`]. Defaults mirror
335/// `IpWarmStartIterateInitializer.cpp:RegisterOptions`.
336///
337/// Wired today: `mult_init_max` (clamps |y_c|, |y_d| and caps z/v
338/// blocks) and `target_mu` (overrides `data.curr_mu` at iter 0).
339/// The remaining knobs (`bound_push`, `bound_frac`, `slack_bound_push`,
340/// `slack_bound_frac`, `mult_bound_push`, `entire_iterate`,
341/// `same_structure`) are stored on the initializer but not yet
342/// consumed — `WarmStartIterateInitializer::set_initial_iterates`
343/// currently trusts the caller-populated `data.curr` rather than
344/// re-running the upstream `push_variables` machinery.
345#[derive(Debug, Clone)]
346pub struct WarmStartOptions {
347    pub bound_push: Number,
348    pub bound_frac: Number,
349    pub slack_bound_push: Number,
350    pub slack_bound_frac: Number,
351    pub mult_bound_push: Number,
352    pub mult_init_max: Number,
353    pub target_mu: Number,
354    pub entire_iterate: bool,
355    pub same_structure: bool,
356}
357
358impl Default for WarmStartOptions {
359    fn default() -> Self {
360        Self {
361            bound_push: 1e-3,
362            bound_frac: 1e-3,
363            slack_bound_push: 1e-3,
364            slack_bound_frac: 1e-3,
365            mult_bound_push: 1e-3,
366            mult_init_max: 1e6,
367            target_mu: 0.0,
368            entire_iterate: false,
369            same_structure: false,
370        }
371    }
372}
373
374/// Knobs read off `OptionsList` and baked into the assembled
375/// `MonotoneMuUpdate` or `AdaptiveMuUpdate`. Defaults mirror
376/// `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
377/// `mu_max` defaults to the sentinel `-1`; positive values are baked
378/// into both updaters at build time (adaptive interprets `-1` as
379/// "lazy-init from `mu_max_fact * avrg_compl`").
380#[derive(Debug, Clone)]
381pub struct MuOptions {
382    pub mu_init: Number,
383    pub mu_max: Number,
384    pub mu_max_fact: Number,
385    pub mu_min: Number,
386    pub mu_target: Number,
387    pub mu_linear_decrease_factor: Number,
388    pub mu_superlinear_decrease_power: Number,
389    pub mu_allow_fast_monotone_decrease: bool,
390    pub barrier_tol_factor: Number,
391    /// `sigma_max` / `sigma_min` — clamp on the centering parameter σ
392    /// chosen by `QualityFunctionMuOracle`. Only consumed when
393    /// `mu_strategy=adaptive` and `mu_oracle=quality-function`.
394    /// Defaults from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
395    pub sigma_max: Number,
396    pub sigma_min: Number,
397    /// `adaptive_mu_globalization` — globalization strategy for the
398    /// adaptive μ-selection mode. Mirrors
399    /// `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Default is
400    /// `ObjConstrFilter`; the Mehrotra cascade switches to
401    /// `NeverMonotoneMode` to disable globalization entirely.
402    pub adaptive_mu_globalization: crate::mu::adaptive::AdaptiveMuGlobalization,
403    /// `quality_function_norm_type` — norm used inside the quality
404    /// function to aggregate the three KKT components. Forwarded to
405    /// `QualityFunctionMuOracle` when `mu_oracle=quality-function`.
406    pub quality_function_norm_type: crate::mu::oracle::quality_function::NormType,
407    /// `quality_function_centrality` — centrality penalty term added
408    /// to the quality function.
409    pub quality_function_centrality: crate::mu::oracle::quality_function::CentralityType,
410    /// `quality_function_balancing_term` — balancing penalty term in
411    /// the quality function (kicks in when complementarity is far
412    /// below infeasibilities).
413    pub quality_function_balancing_term: crate::mu::oracle::quality_function::BalancingTermType,
414    /// `quality_function_max_section_steps` — cap on golden-section
415    /// iterations when picking σ. Default 8.
416    pub quality_function_max_section_steps: i32,
417    /// `quality_function_section_sigma_tol` — width tolerance in
418    /// σ-space for golden section. Default 1e-2.
419    pub quality_function_section_sigma_tol: Number,
420    /// `quality_function_section_qf_tol` — relative flatness
421    /// tolerance for golden section. Default 0.0.
422    pub quality_function_section_qf_tol: Number,
423    /// `adaptive_mu_safeguard_factor` — guard for the LOQO fallback
424    /// in adaptive mode. Default 0.0.
425    pub adaptive_mu_safeguard_factor: Number,
426    /// `adaptive_mu_monotone_init_factor` — multiplier on the
427    /// average complementarity when seeding monotone mode after a
428    /// free-mode bailout. Default 0.8.
429    pub adaptive_mu_monotone_init_factor: Number,
430    /// `adaptive_mu_restore_previous_iterate` — restore the most
431    /// recent free-mode iterate when switching to fixed mode.
432    /// Default `false`.
433    pub adaptive_mu_restore_previous_iterate: bool,
434    /// `adaptive_mu_kkterror_red_iters` — window length for the
435    /// `KKT_ERROR` globalization history. Default 4.
436    pub adaptive_mu_kkterror_red_iters: usize,
437    /// `adaptive_mu_kkterror_red_fact` — required relative reduction
438    /// of the KKT error over the window. Default 0.9999.
439    pub adaptive_mu_kkterror_red_fact: Number,
440    /// `adaptive_mu_kkt_norm_type` — norm used to score the iterate
441    /// in adaptive globalization decisions.
442    pub adaptive_mu_kkt_norm_type: crate::mu::adaptive::AdaptiveMuKktNorm,
443    /// `probing_iterate_quality_factor` (default 1e4, pounce-specific
444    /// — see pounce#58). When the probing (Mehrotra) μ-oracle is
445    /// about to read `curr_avrg_compl()` for its `mu_curr` input, a
446    /// single imbalanced `(s_i, z_i)` pair can inflate the average
447    /// 5+ orders above the stored `data.curr_mu`. The oracle then
448    /// returns `σ · mu_curr` ≫ previous μ, throwing the iterate out
449    /// of the convergence neighborhood. This guard short-circuits
450    /// that case by signalling restoration when the ratio
451    /// `curr_avrg_compl / curr_mu` exceeds the factor. Set to 0 or
452    /// any non-positive value to disable.
453    pub probing_iterate_quality_factor: Number,
454}
455
456impl Default for MuOptions {
457    fn default() -> Self {
458        Self {
459            mu_init: 0.1,
460            mu_max: -1.0,
461            mu_max_fact: 1e3,
462            mu_min: 1e-11,
463            mu_target: 0.0,
464            mu_linear_decrease_factor: 0.2,
465            mu_superlinear_decrease_power: 1.5,
466            mu_allow_fast_monotone_decrease: true,
467            barrier_tol_factor: 10.0,
468            sigma_max: 1e2,
469            sigma_min: 1e-6,
470            adaptive_mu_globalization:
471                crate::mu::adaptive::AdaptiveMuGlobalization::ObjConstrFilter,
472            quality_function_norm_type:
473                crate::mu::oracle::quality_function::NormType::TwoNormSquared,
474            quality_function_centrality: crate::mu::oracle::quality_function::CentralityType::None,
475            quality_function_balancing_term:
476                crate::mu::oracle::quality_function::BalancingTermType::None,
477            quality_function_max_section_steps: 8,
478            quality_function_section_sigma_tol: 1e-2,
479            quality_function_section_qf_tol: 0.0,
480            adaptive_mu_safeguard_factor: 0.0,
481            adaptive_mu_monotone_init_factor: 0.8,
482            adaptive_mu_restore_previous_iterate: false,
483            adaptive_mu_kkterror_red_iters: 4,
484            adaptive_mu_kkterror_red_fact: 0.9999,
485            adaptive_mu_kkt_norm_type: crate::mu::adaptive::AdaptiveMuKktNorm::TwoNormSquared,
486            probing_iterate_quality_factor: 1e4,
487        }
488    }
489}
490
491/// Knobs baked into the assembled [`BacktrackingLineSearch`]. Defaults
492/// mirror `IpBacktrackingLineSearch.cpp:RegisterOptions`.
493#[derive(Debug, Clone)]
494pub struct LineSearchOptions {
495    pub watchdog_shortened_iter_trigger: Index,
496    pub watchdog_trial_iter_max: Index,
497    /// `soft_resto_pderror_reduction_factor` — required relative
498    /// reduction in the primal-dual error for a soft-resto step.
499    /// `0` disables the soft restoration phase.
500    pub soft_resto_pderror_reduction_factor: Number,
501    /// `max_soft_resto_iters` — cap on consecutive soft-resto
502    /// iterations before full restoration is forced.
503    pub max_soft_resto_iters: Index,
504    /// `accept_every_trial_step` — short-circuits the filter / alpha
505    /// loop and accepts the full fraction-to-the-boundary step every
506    /// outer iteration. Mirrors upstream's
507    /// `IpBacktrackingLineSearch::accept_every_trial_step_`. Drops
508    /// global convergence guarantees; only safe for problems where the
509    /// Newton step is already a descent step (LPs, convex QPs). The
510    /// Mehrotra cascade in `application.rs` flips this on.
511    pub accept_every_trial_step: bool,
512    /// `alpha_for_y` — policy for the equality-multiplier (y_c / y_d)
513    /// step length. Upstream default is `Primal`; the Mehrotra cascade
514    /// switches to `BoundMult`.
515    pub alpha_for_y: crate::line_search::backtracking::AlphaForY,
516
517    // Filter switching / Armijo / margin constants baked onto the
518    // assembled [`crate::line_search::filter_acceptor::FilterLsAcceptor`]
519    // (only when `line_search_method = Filter`). All were registered but
520    // never read (#191); defaults mirror `IpFilterLSAcceptor.cpp`.
521    /// `eta_phi` — relaxation factor in the Armijo condition (Eqn. (20)).
522    pub eta_phi: Number,
523    /// `theta_min_fact` — constraint-violation threshold factor in the
524    /// switching rule.
525    pub theta_min_fact: Number,
526    /// `theta_max_fact` — upper-bound factor for constraint violation in
527    /// the filter (Eqn. (21)).
528    pub theta_max_fact: Number,
529    /// `gamma_phi` — filter margin factor for the barrier function
530    /// (Eqn. (18a)).
531    pub gamma_phi: Number,
532    /// `gamma_theta` — filter margin factor for the constraint violation
533    /// (Eqn. (18b)).
534    pub gamma_theta: Number,
535    /// `s_phi` — exponent for the linear barrier model in the switching
536    /// rule (Eqn. (19)).
537    pub s_phi: Number,
538    /// `s_theta` — exponent for the current constraint violation in the
539    /// switching rule (Eqn. (19)).
540    pub s_theta: Number,
541    /// `alpha_min_frac` — safety factor for the minimal step size before
542    /// switching to restoration (gamma_alpha, Eqn. (23)).
543    pub alpha_min_frac: Number,
544    /// `obj_max_inc` — max acceptable increase (orders of magnitude) of
545    /// the barrier objective for a trial point.
546    pub obj_max_inc: Number,
547    /// `max_filter_resets` — maximum number of filter resets allowed
548    /// (`0` disables the reset heuristic).
549    pub max_filter_resets: Index,
550    /// `filter_reset_trigger` — successive filter-rejected iterations that
551    /// trigger a filter reset.
552    pub filter_reset_trigger: Index,
553
554    // Second-order-correction constants baked onto the assembled
555    // [`BacktrackingLineSearch`]. Registered but never read (#191);
556    // defaults mirror `IpBacktrackingLineSearch.cpp`.
557    /// `max_soc` — max second-order-correction trial steps per iteration;
558    /// `0` disables SOC.
559    pub max_soc: Index,
560    /// `kappa_soc` — sufficient-reduction factor for a SOC step to be
561    /// continued.
562    pub kappa_soc: Number,
563    /// `soc_method` — `0` (paper method) or `1` (alpha-on-rhs variant).
564    pub soc_method: Index,
565}
566
567impl Default for LineSearchOptions {
568    fn default() -> Self {
569        Self {
570            watchdog_shortened_iter_trigger: 10,
571            watchdog_trial_iter_max: 3,
572            soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
573            max_soft_resto_iters: 10,
574            accept_every_trial_step: false,
575            alpha_for_y: crate::line_search::backtracking::AlphaForY::Primal,
576            eta_phi: 1e-8,
577            theta_min_fact: 1e-4,
578            theta_max_fact: 1e4,
579            gamma_phi: 1e-8,
580            gamma_theta: 1e-5,
581            s_phi: 2.3,
582            s_theta: 1.1,
583            alpha_min_frac: 0.05,
584            obj_max_inc: 5.0,
585            max_filter_resets: 5,
586            filter_reset_trigger: 5,
587            max_soc: 4,
588            kappa_soc: 0.99,
589            soc_method: 0,
590        }
591    }
592}
593
594/// Inertia-correction / regularization knobs baked onto the assembled
595/// [`crate::kkt::perturbation_handler::PdPerturbationHandler`]. Field
596/// names use the option names; they map to the handler's `delta_xs_*` /
597/// `delta_cd_*` fields. Defaults mirror
598/// `IpPDPerturbationHandler.cpp:RegisterOptions`. All were registered but
599/// never read (#191).
600#[derive(Debug, Clone)]
601pub struct PerturbationOptions {
602    /// `max_hessian_perturbation` → `delta_xs_max`.
603    pub max_hessian_perturbation: Number,
604    /// `min_hessian_perturbation` → `delta_xs_min`.
605    pub min_hessian_perturbation: Number,
606    /// `perturb_inc_fact_first` → `delta_xs_first_inc_fact`.
607    pub perturb_inc_fact_first: Number,
608    /// `perturb_inc_fact` → `delta_xs_inc_fact`.
609    pub perturb_inc_fact: Number,
610    /// `perturb_dec_fact` → `delta_xs_dec_fact`.
611    pub perturb_dec_fact: Number,
612    /// `first_hessian_perturbation` → `delta_xs_init`.
613    pub first_hessian_perturbation: Number,
614    /// `jacobian_regularization_value` → `delta_cd_val`.
615    pub jacobian_regularization_value: Number,
616    /// `jacobian_regularization_exponent` → `delta_cd_exp`.
617    pub jacobian_regularization_exponent: Number,
618    /// `perturb_always_cd` — always regularize the c/d (Jacobian) block.
619    pub perturb_always_cd: bool,
620}
621
622impl Default for PerturbationOptions {
623    fn default() -> Self {
624        Self {
625            max_hessian_perturbation: 1e20,
626            min_hessian_perturbation: 1e-20,
627            perturb_inc_fact_first: 100.0,
628            perturb_inc_fact: 8.0,
629            perturb_dec_fact: 1.0 / 3.0,
630            first_hessian_perturbation: 1e-4,
631            jacobian_regularization_value: 1e-8,
632            jacobian_regularization_exponent: 0.25,
633            perturb_always_cd: false,
634        }
635    }
636}
637
638/// Restoration-phase knobs carried on the outer builder and copied into
639/// the `RestoAlgorithmBuilder` when the restoration factory is minted
640/// (`pounce-restoration`). The restoration builder is constructed with
641/// defaults by each frontend and never options-configured, so these were
642/// registered but never read (#191). Defaults mirror upstream's
643/// restoration `RegisterOptions`.
644#[derive(Debug, Clone)]
645pub struct RestoOptions {
646    /// `bound_mult_reset_threshold` — reset bound multipliers to 1 after
647    /// restoration if the largest exceeds this.
648    pub bound_mult_reset_threshold: Number,
649    /// `constr_mult_reset_threshold` — ignore the least-square constraint
650    /// multiplier estimate after restoration if its norm exceeds this
651    /// (`0` keeps the estimate).
652    pub constr_mult_reset_threshold: Number,
653    /// `resto_penalty_parameter` — penalty on the slack 1-norm in the
654    /// restoration objective (`rho`).
655    pub resto_penalty_parameter: Number,
656    /// `resto_proximity_weight` — proximity-term weight (`eta_factor`;
657    /// `η = eta_factor · sqrt(μ)`).
658    pub resto_proximity_weight: Number,
659}
660
661impl Default for RestoOptions {
662    fn default() -> Self {
663        Self {
664            bound_mult_reset_threshold: 1e3,
665            constr_mult_reset_threshold: 0.0,
666            resto_penalty_parameter: 1e3,
667            resto_proximity_weight: 1.0,
668        }
669    }
670}
671
672/// Iterative-refinement knobs baked onto the assembled
673/// [`crate::kkt::pd_full_space_solver::PdFullSpaceSolver`]. Defaults
674/// mirror `IpPDFullSpaceSolver.cpp:RegisterOptions`. All were registered
675/// but never read (#191).
676#[derive(Debug, Clone)]
677pub struct RefinementOptions {
678    /// `min_refinement_steps` — minimum iterative-refinement steps per
679    /// linear solve.
680    pub min_refinement_steps: Index,
681    /// `max_refinement_steps` — maximum iterative-refinement steps.
682    pub max_refinement_steps: Index,
683    /// `residual_ratio_max` — refine until the residual test ratio drops
684    /// below this (or `max_refinement_steps` is reached).
685    pub residual_ratio_max: Number,
686    /// `residual_ratio_singular` — above this ratio after failed
687    /// refinement, the system is declared singular.
688    pub residual_ratio_singular: Number,
689    /// `residual_improvement_factor` — minimum per-step reduction of the
690    /// residual test ratio before refinement is aborted.
691    pub residual_improvement_factor: Number,
692}
693
694impl Default for RefinementOptions {
695    fn default() -> Self {
696        Self {
697            min_refinement_steps: 1,
698            max_refinement_steps: 10,
699            residual_ratio_max: 1e-10,
700            residual_ratio_singular: 1e-5,
701            residual_improvement_factor: 0.999_999_999,
702        }
703    }
704}
705
706/// Knobs baked into the assembled [`OrigIterationOutput`]. Defaults
707/// mirror `IpOrigIterationOutput.cpp:RegisterOptions` /
708/// `IpAlgorithmRegOp.cpp`.
709#[derive(Debug, Clone)]
710pub struct OutputOptions {
711    pub print_frequency_iter: Index,
712    pub print_frequency_time: Number,
713    /// `print_info_string` (default `false`). When on, the iter row
714    /// ends with the contents of `IpoptData::info_string` so users
715    /// can read the per-iteration diagnostic tags.
716    pub print_info_string: bool,
717    /// `inf_pr_output` — `"original"` (default) prints the unscaled
718    /// NLP primal infeasibility; `"internal"` prints the internal
719    /// reformulated violation. Only meaningful once NLP-side scaling
720    /// is in play; until then both modes produce the same number.
721    pub inf_pr_output_internal: bool,
722}
723
724impl Default for OutputOptions {
725    fn default() -> Self {
726        Self {
727            print_frequency_iter: 1,
728            print_frequency_time: 0.0,
729            print_info_string: false,
730            inf_pr_output_internal: false,
731        }
732    }
733}
734
735impl Default for AlgorithmBuilder {
736    fn default() -> Self {
737        Self {
738            algorithm: AlgorithmChoice::default(),
739            linear_solver: LinearSolverChoice::Feral,
740            linear_system_scaling: LinearSystemScalingChoice::None,
741            linear_scaling_on_demand: true,
742            mu_strategy: MuStrategyChoice::Monotone,
743            mu_oracle: MuOracleKind::QualityFunction,
744            hessian_approximation: HessianApproxChoice::Exact,
745            limited_memory_update_type: UpdateType::Bfgs,
746            limited_memory_max_history: 6,
747            line_search_method: LineSearchChoice::Filter,
748            warm_start_init_point: false,
749            mehrotra_algorithm: false,
750            kappa_sigma: 1e10,
751            kappa_d: 1e-5,
752            tiny_step_tol: 10.0 * Number::EPSILON,
753            tiny_step_y_tol: 1e-2,
754            diverging_iterates_tol: 1e20,
755            dual_diverging_streak: 0,
756            kkt_fidelity_tol: 0.0,
757            conv_check: ConvCheckOptions::default(),
758            mu: MuOptions::default(),
759            line_search: LineSearchOptions::default(),
760            refinement: RefinementOptions::default(),
761            perturbation: PerturbationOptions::default(),
762            resto: RestoOptions::default(),
763            output: OutputOptions::default(),
764            warm: WarmStartOptions::default(),
765            sqp: crate::sqp::SqpOptions::default(),
766            sqp_qp: pounce_qp::QpOptions::default(),
767            init: InitOptions::default(),
768            kkt_schur: None,
769        }
770    }
771}
772
773impl AlgorithmBuilder {
774    pub fn new() -> Self {
775        Self::default()
776    }
777
778    /// Install a Schur KKT partition (pounce#180 item 2). `schur_indices` are
779    /// KKT-space indices (`0..dim`, the `x,s,c,d` block order the aug-system
780    /// solver assembles); `cfg` configures the per-block feral solvers. Only
781    /// honored on the IPM + feral + exact-Hessian path by
782    /// [`Self::build_with_backend`]; ignored otherwise.
783    pub fn set_kkt_schur(&mut self, schur_indices: Vec<usize>, cfg: pounce_feral::FeralConfig) {
784        self.kkt_schur = Some((schur_indices, cfg));
785    }
786
787    /// Assemble the strategy bundle without a search-direction
788    /// calculator. Used by structural unit tests that don't want to
789    /// pull in a linear-solver backend.
790    pub fn build(&self) -> AlgorithmBundle {
791        self.build_inner(None)
792    }
793
794    /// Same as [`Self::build`] but also constructs the
795    /// `SymLinearSolver → AugSystemSolver → PdFullSpaceSolver →
796    /// PdSearchDirCalc` chain via the supplied `factory`.
797    pub fn build_with_backend(&self, mut factory: LinearBackendFactory) -> AlgorithmBundle {
798        let backend = factory(self.linear_solver);
799        let scaling: Option<Box<dyn pounce_linsol::TSymScalingMethod>> =
800            match self.linear_system_scaling {
801                LinearSystemScalingChoice::None => None,
802                LinearSystemScalingChoice::Ruiz => {
803                    Some(Box::new(pounce_linsol::RuizTSymScalingMethod::new()))
804                }
805                LinearSystemScalingChoice::Mc19 => {
806                    tracing::warn!(target: "pounce::algorithm",
807                        "pounce: linear_system_scaling=mc19 not yet implemented; using no scaling"
808                    );
809                    None
810                }
811            };
812        let linsol = TSymLinearSolver::new(backend, scaling, self.linear_scaling_on_demand);
813        let inner_aug = StdAugSystemSolver::new(linsol);
814        // Limited-memory mode publishes the Hessian as a
815        // `LowRankUpdateSymMatrix`; wrap the standard solver in the
816        // Sherman-Morrison-Woodbury low-rank solver so the augmented
817        // system factorizes only the diagonal `B0` and the quasi-Newton
818        // update is applied as a rank-`m` correction (`O(n·m)` memory).
819        let is_lbfgs = matches!(
820            self.hessian_approximation,
821            HessianApproxChoice::LimitedMemory
822        );
823        let aug_solver: Box<dyn AugSystemSolver> = if is_lbfgs {
824            Box::new(LowRankAugSystemSolver::new(Box::new(inner_aug)))
825        } else if let Some((indices, cfg)) = self.kkt_schur.clone() {
826            // Block-triangular / Schur KKT path (pounce#180 item 2). Only on the
827            // exact-Hessian feral path — the Schur backend is feral-specific,
828            // and the L-BFGS low-rank Woodbury wrapper owns the (2,2) block.
829            // The Schur solver falls back to `StdAugSystemSolver` transparently
830            // when the partition is unsuitable, so a stray hook never breaks a
831            // solve; we gate on `linear_solver == Feral` here to avoid silently
832            // ignoring a user's explicit MA57 selection.
833            if matches!(self.linear_solver, LinearSolverChoice::Feral) {
834                Box::new(crate::kkt::SchurAugSystemSolver::new(
835                    inner_aug, indices, cfg,
836                ))
837            } else {
838                Box::new(inner_aug)
839            }
840        } else {
841            Box::new(inner_aug)
842        };
843        // Inertia-correction / Jacobian-regularization constants (#191):
844        // registered but previously never read. Defaults equal the
845        // registered defaults. `perturb_always_cd` goes through the setter
846        // because it also rebuilds the initial jac-degeneracy state.
847        let mut ph = PdPerturbationHandler::new();
848        ph.delta_xs_max = self.perturbation.max_hessian_perturbation;
849        ph.delta_xs_min = self.perturbation.min_hessian_perturbation;
850        ph.delta_xs_first_inc_fact = self.perturbation.perturb_inc_fact_first;
851        ph.delta_xs_inc_fact = self.perturbation.perturb_inc_fact;
852        ph.delta_xs_dec_fact = self.perturbation.perturb_dec_fact;
853        ph.delta_xs_init = self.perturbation.first_hessian_perturbation;
854        ph.delta_cd_val = self.perturbation.jacobian_regularization_value;
855        ph.delta_cd_exp = self.perturbation.jacobian_regularization_exponent;
856        ph.set_perturb_always_cd(self.perturbation.perturb_always_cd);
857        let perturb = Rc::new(RefCell::new(ph));
858        let mut pd_solver = PdFullSpaceSolver::new(aug_solver, perturb);
859        // Iterative-refinement constants (#191): registered but previously
860        // never read, so overrides were silently dropped. Defaults equal
861        // the registered defaults.
862        pd_solver.min_refinement_steps = self.refinement.min_refinement_steps;
863        pd_solver.max_refinement_steps = self.refinement.max_refinement_steps;
864        pd_solver.residual_ratio_max = self.refinement.residual_ratio_max;
865        pd_solver.residual_ratio_singular = self.refinement.residual_ratio_singular;
866        pd_solver.residual_improvement_factor = self.refinement.residual_improvement_factor;
867        let mut search_dir = PdSearchDirCalc::new(pd_solver);
868        search_dir.mehrotra_algorithm = self.mehrotra_algorithm;
869        self.build_inner(Some(search_dir))
870    }
871
872    /// Phase 5b assembly path for the SQP algorithm. Consults
873    /// `self.algorithm`: when `ActiveSetSqp`, constructs an
874    /// `SqpAlgorithm` using the supplied backend factory for the
875    /// QP subproblem solver; otherwise returns `None` so the
876    /// caller can fall back to the IPM `build_with_backend`.
877    ///
878    /// Sister to `build_with_backend`: the SQP algorithm doesn't
879    /// share `AlgorithmBundle`'s shape (no mu_update / no IPM
880    /// line search), so the two paths return different types.
881    pub fn build_sqp_with_backend(
882        &self,
883        mut factory: LinearBackendFactory,
884    ) -> Option<crate::sqp::SqpAlgorithm> {
885        if !matches!(self.algorithm, AlgorithmChoice::ActiveSetSqp) {
886            return None;
887        }
888        let backend = factory(self.linear_solver);
889        let qp_solver = pounce_qp::ParametricActiveSetSolver::new(backend);
890        Some(
891            crate::sqp::SqpAlgorithm::new(qp_solver, self.sqp.clone())
892                .with_qp_options(self.sqp_qp.clone()),
893        )
894    }
895
896    fn build_inner(&self, search_dir: Option<PdSearchDirCalc>) -> AlgorithmBundle {
897        let mu_update: Box<dyn crate::mu::r#trait::MuUpdate> = match self.mu_strategy {
898            MuStrategyChoice::Monotone => {
899                let mut m = MonotoneMuUpdate::new();
900                m.mu_init = self.mu.mu_init;
901                // `mu_max` sentinel `-1` keeps the monotone default
902                // (1e5); only override on a user-supplied positive.
903                if self.mu.mu_max > 0.0 {
904                    m.mu_max = self.mu.mu_max;
905                }
906                m.mu_min = self.mu.mu_min;
907                m.mu_target = self.mu.mu_target;
908                m.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
909                m.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
910                m.mu_allow_fast_monotone_decrease = self.mu.mu_allow_fast_monotone_decrease;
911                m.barrier_tol_factor = self.mu.barrier_tol_factor;
912                m.compl_inf_tol = self.conv_check.compl_inf_tol;
913                Box::new(m)
914            }
915            MuStrategyChoice::Adaptive => {
916                let mut adaptive = AdaptiveMuUpdate::new();
917                adaptive.mu_oracle = self.mu_oracle;
918                adaptive.mu_init = self.mu.mu_init;
919                // Adaptive treats `mu_max == -1` as "lazy init from
920                // `mu_max_fact * curr_avrg_compl`" — forward the
921                // sentinel as-is.
922                adaptive.mu_max = self.mu.mu_max;
923                adaptive.mu_max_fact = self.mu.mu_max_fact;
924                adaptive.mu_min = self.mu.mu_min;
925                adaptive.compl_inf_tol = self.conv_check.compl_inf_tol;
926                adaptive.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
927                adaptive.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
928                adaptive.barrier_tol_factor = self.mu.barrier_tol_factor;
929                adaptive.sigma_min = self.mu.sigma_min;
930                adaptive.sigma_max = self.mu.sigma_max;
931                adaptive.adaptive_mu_globalization = self.mu.adaptive_mu_globalization;
932                adaptive.qf_norm_type = self.mu.quality_function_norm_type;
933                adaptive.qf_centrality_type = self.mu.quality_function_centrality;
934                adaptive.qf_balancing_term = self.mu.quality_function_balancing_term;
935                adaptive.qf_max_section_steps = self.mu.quality_function_max_section_steps;
936                adaptive.qf_section_sigma_tol = self.mu.quality_function_section_sigma_tol;
937                adaptive.qf_section_qf_tol = self.mu.quality_function_section_qf_tol;
938                adaptive.probing_iterate_quality_factor = self.mu.probing_iterate_quality_factor;
939                adaptive.adaptive_mu_safeguard_factor = self.mu.adaptive_mu_safeguard_factor;
940                adaptive.adaptive_mu_monotone_init_factor =
941                    self.mu.adaptive_mu_monotone_init_factor;
942                adaptive.restore_accepted_iterate = self.mu.adaptive_mu_restore_previous_iterate;
943                adaptive.adaptive_mu_kkterror_red_iters = self.mu.adaptive_mu_kkterror_red_iters;
944                adaptive.adaptive_mu_kkterror_red_fact = self.mu.adaptive_mu_kkterror_red_fact;
945                adaptive.adaptive_mu_kkt_norm = self.mu.adaptive_mu_kkt_norm_type;
946                Box::new(adaptive)
947            }
948        };
949
950        let acceptor: Box<dyn BacktrackingLsAcceptor> = match self.line_search_method {
951            LineSearchChoice::Filter => {
952                // Filter switching / Armijo / margin constants (#191):
953                // registered but previously never read. Set them on the
954                // concrete acceptor before boxing; defaults equal the
955                // registered defaults, so a run that doesn't set them is
956                // unchanged.
957                let mut f = FilterLsAcceptor::default();
958                f.eta_phi = self.line_search.eta_phi;
959                f.theta_min_fact = self.line_search.theta_min_fact;
960                f.theta_max_fact = self.line_search.theta_max_fact;
961                f.gamma_phi = self.line_search.gamma_phi;
962                f.gamma_theta = self.line_search.gamma_theta;
963                f.s_phi = self.line_search.s_phi;
964                f.s_theta = self.line_search.s_theta;
965                f.alpha_min_frac = self.line_search.alpha_min_frac;
966                f.obj_max_inc = self.line_search.obj_max_inc;
967                f.max_filter_resets = self.line_search.max_filter_resets;
968                f.filter_reset_trigger = self.line_search.filter_reset_trigger;
969                Box::new(f)
970            }
971            LineSearchChoice::Penalty => Box::new(PenaltyLsAcceptor::default()),
972            // CG-penalty acceptor lands with the rest of the
973            // CG-penalty path; fall back to the penalty acceptor's
974            // surface for now.
975            LineSearchChoice::CgPenalty => Box::new(PenaltyLsAcceptor::default()),
976        };
977        let mut line_search = BacktrackingLineSearch::new(acceptor);
978        line_search.watchdog_shortened_iter_trigger =
979            self.line_search.watchdog_shortened_iter_trigger;
980        line_search.watchdog_trial_iter_max = self.line_search.watchdog_trial_iter_max;
981        line_search.soft_resto_pderror_reduction_factor =
982            self.line_search.soft_resto_pderror_reduction_factor;
983        line_search.max_soft_resto_iters = self.line_search.max_soft_resto_iters;
984        line_search.accept_every_trial_step = self.line_search.accept_every_trial_step;
985        line_search.alpha_for_y = self.line_search.alpha_for_y;
986        // Second-order-correction constants (#191): registered but
987        // previously never read. Same direct-field pattern as the
988        // watchdog knobs above.
989        line_search.max_soc = self.line_search.max_soc;
990        line_search.kappa_soc = self.line_search.kappa_soc;
991        line_search.soc_method = self.line_search.soc_method;
992
993        let conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck> =
994            Box::new(OptErrorConvCheck {
995                tol: self.conv_check.tol,
996                dual_inf_tol: self.conv_check.dual_inf_tol,
997                constr_viol_tol: self.conv_check.constr_viol_tol,
998                compl_inf_tol: self.conv_check.compl_inf_tol,
999                acceptable_tol: self.conv_check.acceptable_tol,
1000                acceptable_dual_inf_tol: self.conv_check.acceptable_dual_inf_tol,
1001                acceptable_constr_viol_tol: self.conv_check.acceptable_constr_viol_tol,
1002                acceptable_compl_inf_tol: self.conv_check.acceptable_compl_inf_tol,
1003                acceptable_obj_change_tol: self.conv_check.acceptable_obj_change_tol,
1004                acceptable_iter: self.conv_check.acceptable_iter,
1005                max_iter: self.conv_check.max_iter,
1006                max_cpu_time: self.conv_check.max_cpu_time,
1007                max_wall_time: self.conv_check.max_wall_time,
1008                acceptable_count: 0,
1009                last_acceptable_obj: None,
1010                infeas_stationarity_tol: self.conv_check.infeas_stationarity_tol,
1011                infeas_viol_kappa: self.conv_check.infeas_viol_kappa,
1012                infeas_max_streak: self.conv_check.infeas_max_streak,
1013                infeas_streak: 0,
1014                obj_scale_certificate_threshold: self.conv_check.obj_scale_certificate_threshold,
1015                veto_fired: false,
1016                acceptable_veto_fired: false,
1017                veto_extra_iters: 0,
1018            });
1019
1020        let init: Box<dyn crate::init::r#trait::IterateInitializer> = if self.warm_start_init_point
1021        {
1022            Box::new(WarmStartIterateInitializer::with_options(self.warm.clone()))
1023        } else {
1024            let mut d = DefaultIterateInitializer::with_eq_mult_calculator(Box::new(
1025                LeastSquareMults::new(),
1026            ));
1027            d.bound_push = self.init.bound_push;
1028            d.bound_frac = self.init.bound_frac;
1029            d.slack_bound_push = self.init.slack_bound_push;
1030            d.slack_bound_frac = self.init.slack_bound_frac;
1031            d.constr_mult_init_max = self.init.constr_mult_init_max;
1032            d.bound_mult_init_val = self.init.bound_mult_init_val;
1033            d.bound_mult_init_method = self.init.bound_mult_init_method.clone();
1034            d.least_square_init_primal = self.init.least_square_init_primal;
1035            Box::new(d)
1036        };
1037
1038        let eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator> =
1039            Box::new(LeastSquareMults::new());
1040
1041        let hess: Box<dyn crate::hess::r#trait::HessianUpdater> = match self.hessian_approximation {
1042            HessianApproxChoice::Exact => Box::new(ExactHessianUpdater::new()),
1043            HessianApproxChoice::LimitedMemory => Box::new(LimMemQuasiNewtonUpdater {
1044                update_type: self.limited_memory_update_type,
1045                max_history: self.limited_memory_max_history,
1046                ..LimMemQuasiNewtonUpdater::default()
1047            }),
1048        };
1049
1050        let iter_output: Box<dyn crate::output::r#trait::IterationOutput> = {
1051            use crate::output::orig::{InfPrTag, PrintInfoString};
1052            let mut o = OrigIterationOutput::new();
1053            o.print_frequency_iter = self.output.print_frequency_iter;
1054            o.print_frequency_time = self.output.print_frequency_time;
1055            o.print_info_string = if self.output.print_info_string {
1056                PrintInfoString::Yes
1057            } else {
1058                PrintInfoString::No
1059            };
1060            o.inf_pr_output = if self.output.inf_pr_output_internal {
1061                InfPrTag::Internal
1062            } else {
1063                InfPrTag::Original
1064            };
1065            Box::new(o)
1066        };
1067
1068        AlgorithmBundle {
1069            mu_update,
1070            conv_check,
1071            init,
1072            eq_mult,
1073            hess,
1074            line_search,
1075            iter_output,
1076            search_dir,
1077        }
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    #[test]
1086    fn default_builder_assembles() {
1087        let bundle = AlgorithmBuilder::new().build();
1088        // Sanity: the placeholder traits compile and the boxed
1089        // strategies don't panic on construction.
1090        let _ = bundle.line_search.acceptor();
1091        assert!(bundle.search_dir.is_none());
1092    }
1093
1094    #[test]
1095    fn build_with_backend_assembles_search_dir_chain() {
1096        // Drive the builder with the FERAL backend factory; the
1097        // resulting bundle should expose a populated `PdSearchDirCalc`.
1098        let factory: LinearBackendFactory = Box::new(|_| {
1099            Box::new(pounce_feral::FeralSolverInterface::new())
1100                as Box<dyn SparseSymLinearSolverInterface>
1101        });
1102        let bundle = AlgorithmBuilder::new().build_with_backend(factory);
1103        assert!(bundle.search_dir.is_some());
1104    }
1105
1106    #[test]
1107    fn limited_memory_sr1_propagates() {
1108        let b = AlgorithmBuilder {
1109            hessian_approximation: HessianApproxChoice::LimitedMemory,
1110            limited_memory_update_type: UpdateType::Sr1,
1111            ..AlgorithmBuilder::default()
1112        };
1113        let _bundle = b.build();
1114    }
1115
1116    #[test]
1117    fn every_strategy_combination_assembles_without_panic() {
1118        let solvers = [LinearSolverChoice::Ma57, LinearSolverChoice::Feral];
1119        let mu = [MuStrategyChoice::Monotone, MuStrategyChoice::Adaptive];
1120        let hess = [
1121            HessianApproxChoice::Exact,
1122            HessianApproxChoice::LimitedMemory,
1123        ];
1124        let ls = [
1125            LineSearchChoice::Filter,
1126            LineSearchChoice::CgPenalty,
1127            LineSearchChoice::Penalty,
1128        ];
1129        for &linear_solver in &solvers {
1130            for &mu_strategy in &mu {
1131                for &hessian_approximation in &hess {
1132                    for &line_search_method in &ls {
1133                        let _ = AlgorithmBuilder {
1134                            algorithm: AlgorithmChoice::default(),
1135                            linear_solver,
1136                            linear_system_scaling: LinearSystemScalingChoice::None,
1137                            linear_scaling_on_demand: true,
1138                            mu_strategy,
1139                            mu_oracle: MuOracleKind::QualityFunction,
1140                            hessian_approximation,
1141                            limited_memory_update_type: UpdateType::Bfgs,
1142                            limited_memory_max_history: 6,
1143                            line_search_method,
1144                            warm_start_init_point: false,
1145                            mehrotra_algorithm: false,
1146                            kappa_sigma: 1e10,
1147                            kappa_d: 1e-5,
1148                            tiny_step_tol: 10.0 * Number::EPSILON,
1149                            tiny_step_y_tol: 1e-2,
1150                            diverging_iterates_tol: 1e20,
1151                            dual_diverging_streak: 0,
1152                            kkt_fidelity_tol: 0.0,
1153                            conv_check: ConvCheckOptions::default(),
1154                            mu: MuOptions::default(),
1155                            line_search: LineSearchOptions::default(),
1156                            refinement: RefinementOptions::default(),
1157                            perturbation: PerturbationOptions::default(),
1158                            resto: RestoOptions::default(),
1159                            output: OutputOptions::default(),
1160                            warm: WarmStartOptions::default(),
1161                            sqp: crate::sqp::SqpOptions::default(),
1162                            sqp_qp: pounce_qp::QpOptions::default(),
1163                            init: InitOptions::default(),
1164                            kkt_schur: None,
1165                        }
1166                        .build();
1167                    }
1168                }
1169            }
1170        }
1171    }
1172}