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}
149
150impl Default for ConvCheckOptions {
151    fn default() -> Self {
152        Self {
153            tol: 1e-8,
154            dual_inf_tol: 1.0,
155            constr_viol_tol: 1e-4,
156            compl_inf_tol: 1e-4,
157            acceptable_tol: 1e-6,
158            acceptable_dual_inf_tol: 1e10,
159            acceptable_constr_viol_tol: 1e-2,
160            acceptable_compl_inf_tol: 1e-2,
161            acceptable_obj_change_tol: 1e20,
162            acceptable_iter: 15,
163            max_iter: 3000,
164            max_cpu_time: 1e6,
165            max_wall_time: 1e6,
166            infeas_stationarity_tol: 1e-8,
167            infeas_viol_kappa: 1e2,
168            infeas_max_streak: 5,
169        }
170    }
171}
172
173#[derive(Debug, Clone)]
174pub struct AlgorithmBuilder {
175    /// Top-level algorithm dispatch. Default `InteriorPoint` ⇒
176    /// `build_with_backend` returns the existing `AlgorithmBundle`
177    /// (consumed by `IpoptAlgorithm`). `ActiveSetSqp` ⇒ caller
178    /// must use `build_sqp_with_backend` to assemble the Phase 5b
179    /// `SqpAlgorithm`. The two builder methods sit side by side
180    /// because the assembled algorithm shape differs (IPM bundle
181    /// vs SQP struct).
182    pub algorithm: AlgorithmChoice,
183    pub linear_solver: LinearSolverChoice,
184    /// Symmetric scaling method for the augmented KKT system. Wired
185    /// into [`TSymLinearSolver`] by [`Self::build_with_backend`].
186    /// Mirrors upstream `linear_system_scaling` (`IpAlgBuilder.cpp:538-560`).
187    pub linear_system_scaling: LinearSystemScalingChoice,
188    /// Lazy-vs-eager scaling toggle (`linear_scaling_on_demand`,
189    /// `IpTSymLinearSolver.cpp:50-58`). Only consulted when
190    /// `linear_system_scaling != None`. Upstream default is `true`
191    /// (compute scaling only on the first solve that fails / shows
192    /// poor conditioning); pounce mirrors that. Set to `false` to
193    /// scale every factorization.
194    pub linear_scaling_on_demand: bool,
195    pub mu_strategy: MuStrategyChoice,
196    /// Selector forwarded to [`AdaptiveMuUpdate`] when
197    /// `mu_strategy = Adaptive`. Ignored for `Monotone`. Defaults to
198    /// `QualityFunction` per upstream's `RegisterOptions` default.
199    pub mu_oracle: MuOracleKind,
200    pub hessian_approximation: HessianApproxChoice,
201    pub limited_memory_update_type: UpdateType,
202    /// History length for the limited-memory quasi-Newton approximation
203    /// (`limited_memory_max_history`). Defaults to upstream's 6.
204    pub limited_memory_max_history: i32,
205    pub line_search_method: LineSearchChoice,
206    pub warm_start_init_point: bool,
207    /// `mehrotra_algorithm` — when true, [`PdSearchDirCalc`] folds
208    /// the Mehrotra second-order complementarity term into the
209    /// search-direction RHS. Mirrors upstream's
210    /// `IpAlgBuilder.cpp:Mehrotra` flag. Requires `mu_strategy =
211    /// Adaptive` so that an affine step is computed each iteration;
212    /// [`Self::build_with_backend`] does not enforce this — the
213    /// option-parser in `application.rs` is responsible for the
214    /// cascading defaults (`mu_oracle = probing` etc.).
215    pub mehrotra_algorithm: bool,
216    pub conv_check: ConvCheckOptions,
217    pub mu: MuOptions,
218    pub line_search: LineSearchOptions,
219    pub output: OutputOptions,
220    pub warm: WarmStartOptions,
221    /// SQP-specific options (consulted only when
222    /// `algorithm = ActiveSetSqp`).
223    pub sqp: crate::sqp::SqpOptions,
224    /// QP-subproblem-solver options for the active-set SQP path
225    /// (`pounce_qp::QpOptions`), threaded into the `SqpAlgorithm` via
226    /// `with_qp_options`. Consulted only when `algorithm = ActiveSetSqp`.
227    /// Populated from the `sqp_qp_*` CLI options by
228    /// `application::apply_qp_subproblem_options`.
229    pub sqp_qp: pounce_qp::QpOptions,
230    pub init: InitOptions,
231}
232
233/// Knobs read off `OptionsList` and baked into
234/// [`DefaultIterateInitializer`]. Defaults mirror
235/// `IpDefaultIterateInitializer.cpp:RegisterOptions`. The Mehrotra
236/// cascade in `application.rs` overrides `bound_push`, `bound_frac`,
237/// and `bound_mult_init_val` to upstream's more-aggressive values
238/// (`10`, `0.2`, `1.0`).
239#[derive(Debug, Clone)]
240pub struct InitOptions {
241    pub bound_push: Number,
242    pub bound_frac: Number,
243    pub slack_bound_push: Number,
244    pub slack_bound_frac: Number,
245    pub constr_mult_init_max: Number,
246    pub bound_mult_init_val: Number,
247    /// `bound_mult_init_method`: `"constant"` (default) or `"mu-based"`
248    /// (matches upstream's `IpDefaultIterateInitializer.cpp`).
249    pub bound_mult_init_method: String,
250    /// `least_square_init_primal` — replace the user's starting `x`
251    /// with the min-norm primal that satisfies the linearized
252    /// constraints. Used by the Mehrotra cascade in `application.rs`
253    /// to drop iter-0 primal infeasibility on LP-shaped problems.
254    /// Mirrors upstream `IpDefaultIterateInitializer.cpp:200-222`.
255    pub least_square_init_primal: bool,
256}
257
258impl Default for InitOptions {
259    fn default() -> Self {
260        Self {
261            bound_push: 1e-2,
262            bound_frac: 1e-2,
263            slack_bound_push: 1e-2,
264            slack_bound_frac: 1e-2,
265            constr_mult_init_max: 1e3,
266            bound_mult_init_val: 1.0,
267            bound_mult_init_method: "constant".into(),
268            least_square_init_primal: false,
269        }
270    }
271}
272
273/// Knobs read off `OptionsList` and baked into
274/// [`WarmStartIterateInitializer`]. Defaults mirror
275/// `IpWarmStartIterateInitializer.cpp:RegisterOptions`.
276///
277/// Wired today: `mult_init_max` (clamps |y_c|, |y_d| and caps z/v
278/// blocks) and `target_mu` (overrides `data.curr_mu` at iter 0).
279/// The remaining knobs (`bound_push`, `bound_frac`, `slack_bound_push`,
280/// `slack_bound_frac`, `mult_bound_push`, `entire_iterate`,
281/// `same_structure`) are stored on the initializer but not yet
282/// consumed — `WarmStartIterateInitializer::set_initial_iterates`
283/// currently trusts the caller-populated `data.curr` rather than
284/// re-running the upstream `push_variables` machinery.
285#[derive(Debug, Clone)]
286pub struct WarmStartOptions {
287    pub bound_push: Number,
288    pub bound_frac: Number,
289    pub slack_bound_push: Number,
290    pub slack_bound_frac: Number,
291    pub mult_bound_push: Number,
292    pub mult_init_max: Number,
293    pub target_mu: Number,
294    pub entire_iterate: bool,
295    pub same_structure: bool,
296}
297
298impl Default for WarmStartOptions {
299    fn default() -> Self {
300        Self {
301            bound_push: 1e-3,
302            bound_frac: 1e-3,
303            slack_bound_push: 1e-3,
304            slack_bound_frac: 1e-3,
305            mult_bound_push: 1e-3,
306            mult_init_max: 1e6,
307            target_mu: 0.0,
308            entire_iterate: false,
309            same_structure: false,
310        }
311    }
312}
313
314/// Knobs read off `OptionsList` and baked into the assembled
315/// `MonotoneMuUpdate` or `AdaptiveMuUpdate`. Defaults mirror
316/// `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
317/// `mu_max` defaults to the sentinel `-1`; positive values are baked
318/// into both updaters at build time (adaptive interprets `-1` as
319/// "lazy-init from `mu_max_fact * avrg_compl`").
320#[derive(Debug, Clone)]
321pub struct MuOptions {
322    pub mu_init: Number,
323    pub mu_max: Number,
324    pub mu_max_fact: Number,
325    pub mu_min: Number,
326    pub mu_target: Number,
327    pub mu_linear_decrease_factor: Number,
328    pub mu_superlinear_decrease_power: Number,
329    pub mu_allow_fast_monotone_decrease: bool,
330    pub barrier_tol_factor: Number,
331    /// `sigma_max` / `sigma_min` — clamp on the centering parameter σ
332    /// chosen by `QualityFunctionMuOracle`. Only consumed when
333    /// `mu_strategy=adaptive` and `mu_oracle=quality-function`.
334    /// Defaults from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
335    pub sigma_max: Number,
336    pub sigma_min: Number,
337    /// `adaptive_mu_globalization` — globalization strategy for the
338    /// adaptive μ-selection mode. Mirrors
339    /// `IpAdaptiveMuUpdate.cpp:RegisterOptions`. Default is
340    /// `ObjConstrFilter`; the Mehrotra cascade switches to
341    /// `NeverMonotoneMode` to disable globalization entirely.
342    pub adaptive_mu_globalization: crate::mu::adaptive::AdaptiveMuGlobalization,
343    /// `quality_function_norm_type` — norm used inside the quality
344    /// function to aggregate the three KKT components. Forwarded to
345    /// `QualityFunctionMuOracle` when `mu_oracle=quality-function`.
346    pub quality_function_norm_type: crate::mu::oracle::quality_function::NormType,
347    /// `quality_function_centrality` — centrality penalty term added
348    /// to the quality function.
349    pub quality_function_centrality: crate::mu::oracle::quality_function::CentralityType,
350    /// `quality_function_balancing_term` — balancing penalty term in
351    /// the quality function (kicks in when complementarity is far
352    /// below infeasibilities).
353    pub quality_function_balancing_term: crate::mu::oracle::quality_function::BalancingTermType,
354    /// `quality_function_max_section_steps` — cap on golden-section
355    /// iterations when picking σ. Default 8.
356    pub quality_function_max_section_steps: i32,
357    /// `quality_function_section_sigma_tol` — width tolerance in
358    /// σ-space for golden section. Default 1e-2.
359    pub quality_function_section_sigma_tol: Number,
360    /// `quality_function_section_qf_tol` — relative flatness
361    /// tolerance for golden section. Default 0.0.
362    pub quality_function_section_qf_tol: Number,
363    /// `adaptive_mu_safeguard_factor` — guard for the LOQO fallback
364    /// in adaptive mode. Default 0.0.
365    pub adaptive_mu_safeguard_factor: Number,
366    /// `adaptive_mu_monotone_init_factor` — multiplier on the
367    /// average complementarity when seeding monotone mode after a
368    /// free-mode bailout. Default 0.8.
369    pub adaptive_mu_monotone_init_factor: Number,
370    /// `adaptive_mu_restore_previous_iterate` — restore the most
371    /// recent free-mode iterate when switching to fixed mode.
372    /// Default `false`.
373    pub adaptive_mu_restore_previous_iterate: bool,
374    /// `adaptive_mu_kkterror_red_iters` — window length for the
375    /// `KKT_ERROR` globalization history. Default 4.
376    pub adaptive_mu_kkterror_red_iters: usize,
377    /// `adaptive_mu_kkterror_red_fact` — required relative reduction
378    /// of the KKT error over the window. Default 0.9999.
379    pub adaptive_mu_kkterror_red_fact: Number,
380    /// `adaptive_mu_kkt_norm_type` — norm used to score the iterate
381    /// in adaptive globalization decisions.
382    pub adaptive_mu_kkt_norm_type: crate::mu::adaptive::AdaptiveMuKktNorm,
383    /// `probing_iterate_quality_factor` (default 1e4, pounce-specific
384    /// — see pounce#58). When the probing (Mehrotra) μ-oracle is
385    /// about to read `curr_avrg_compl()` for its `mu_curr` input, a
386    /// single imbalanced `(s_i, z_i)` pair can inflate the average
387    /// 5+ orders above the stored `data.curr_mu`. The oracle then
388    /// returns `σ · mu_curr` ≫ previous μ, throwing the iterate out
389    /// of the convergence neighborhood. This guard short-circuits
390    /// that case by signalling restoration when the ratio
391    /// `curr_avrg_compl / curr_mu` exceeds the factor. Set to 0 or
392    /// any non-positive value to disable.
393    pub probing_iterate_quality_factor: Number,
394}
395
396impl Default for MuOptions {
397    fn default() -> Self {
398        Self {
399            mu_init: 0.1,
400            mu_max: -1.0,
401            mu_max_fact: 1e3,
402            mu_min: 1e-11,
403            mu_target: 0.0,
404            mu_linear_decrease_factor: 0.2,
405            mu_superlinear_decrease_power: 1.5,
406            mu_allow_fast_monotone_decrease: true,
407            barrier_tol_factor: 10.0,
408            sigma_max: 1e2,
409            sigma_min: 1e-6,
410            adaptive_mu_globalization:
411                crate::mu::adaptive::AdaptiveMuGlobalization::ObjConstrFilter,
412            quality_function_norm_type:
413                crate::mu::oracle::quality_function::NormType::TwoNormSquared,
414            quality_function_centrality: crate::mu::oracle::quality_function::CentralityType::None,
415            quality_function_balancing_term:
416                crate::mu::oracle::quality_function::BalancingTermType::None,
417            quality_function_max_section_steps: 8,
418            quality_function_section_sigma_tol: 1e-2,
419            quality_function_section_qf_tol: 0.0,
420            adaptive_mu_safeguard_factor: 0.0,
421            adaptive_mu_monotone_init_factor: 0.8,
422            adaptive_mu_restore_previous_iterate: false,
423            adaptive_mu_kkterror_red_iters: 4,
424            adaptive_mu_kkterror_red_fact: 0.9999,
425            adaptive_mu_kkt_norm_type: crate::mu::adaptive::AdaptiveMuKktNorm::TwoNormSquared,
426            probing_iterate_quality_factor: 1e4,
427        }
428    }
429}
430
431/// Knobs baked into the assembled [`BacktrackingLineSearch`]. Defaults
432/// mirror `IpBacktrackingLineSearch.cpp:RegisterOptions`.
433#[derive(Debug, Clone)]
434pub struct LineSearchOptions {
435    pub watchdog_shortened_iter_trigger: Index,
436    pub watchdog_trial_iter_max: Index,
437    /// `soft_resto_pderror_reduction_factor` — required relative
438    /// reduction in the primal-dual error for a soft-resto step.
439    /// `0` disables the soft restoration phase.
440    pub soft_resto_pderror_reduction_factor: Number,
441    /// `max_soft_resto_iters` — cap on consecutive soft-resto
442    /// iterations before full restoration is forced.
443    pub max_soft_resto_iters: Index,
444    /// `accept_every_trial_step` — short-circuits the filter / alpha
445    /// loop and accepts the full fraction-to-the-boundary step every
446    /// outer iteration. Mirrors upstream's
447    /// `IpBacktrackingLineSearch::accept_every_trial_step_`. Drops
448    /// global convergence guarantees; only safe for problems where the
449    /// Newton step is already a descent step (LPs, convex QPs). The
450    /// Mehrotra cascade in `application.rs` flips this on.
451    pub accept_every_trial_step: bool,
452    /// `alpha_for_y` — policy for the equality-multiplier (y_c / y_d)
453    /// step length. Upstream default is `Primal`; the Mehrotra cascade
454    /// switches to `BoundMult`.
455    pub alpha_for_y: crate::line_search::backtracking::AlphaForY,
456}
457
458impl Default for LineSearchOptions {
459    fn default() -> Self {
460        Self {
461            watchdog_shortened_iter_trigger: 10,
462            watchdog_trial_iter_max: 3,
463            soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
464            max_soft_resto_iters: 10,
465            accept_every_trial_step: false,
466            alpha_for_y: crate::line_search::backtracking::AlphaForY::Primal,
467        }
468    }
469}
470
471/// Knobs baked into the assembled [`OrigIterationOutput`]. Defaults
472/// mirror `IpOrigIterationOutput.cpp:RegisterOptions` /
473/// `IpAlgorithmRegOp.cpp`.
474#[derive(Debug, Clone)]
475pub struct OutputOptions {
476    pub print_frequency_iter: Index,
477    pub print_frequency_time: Number,
478    /// `print_info_string` (default `false`). When on, the iter row
479    /// ends with the contents of `IpoptData::info_string` so users
480    /// can read the per-iteration diagnostic tags.
481    pub print_info_string: bool,
482    /// `inf_pr_output` — `"original"` (default) prints the unscaled
483    /// NLP primal infeasibility; `"internal"` prints the internal
484    /// reformulated violation. Only meaningful once NLP-side scaling
485    /// is in play; until then both modes produce the same number.
486    pub inf_pr_output_internal: bool,
487}
488
489impl Default for OutputOptions {
490    fn default() -> Self {
491        Self {
492            print_frequency_iter: 1,
493            print_frequency_time: 0.0,
494            print_info_string: false,
495            inf_pr_output_internal: false,
496        }
497    }
498}
499
500impl Default for AlgorithmBuilder {
501    fn default() -> Self {
502        Self {
503            algorithm: AlgorithmChoice::default(),
504            linear_solver: LinearSolverChoice::Feral,
505            linear_system_scaling: LinearSystemScalingChoice::None,
506            linear_scaling_on_demand: true,
507            mu_strategy: MuStrategyChoice::Monotone,
508            mu_oracle: MuOracleKind::QualityFunction,
509            hessian_approximation: HessianApproxChoice::Exact,
510            limited_memory_update_type: UpdateType::Bfgs,
511            limited_memory_max_history: 6,
512            line_search_method: LineSearchChoice::Filter,
513            warm_start_init_point: false,
514            mehrotra_algorithm: false,
515            conv_check: ConvCheckOptions::default(),
516            mu: MuOptions::default(),
517            line_search: LineSearchOptions::default(),
518            output: OutputOptions::default(),
519            warm: WarmStartOptions::default(),
520            sqp: crate::sqp::SqpOptions::default(),
521            sqp_qp: pounce_qp::QpOptions::default(),
522            init: InitOptions::default(),
523        }
524    }
525}
526
527impl AlgorithmBuilder {
528    pub fn new() -> Self {
529        Self::default()
530    }
531
532    /// Assemble the strategy bundle without a search-direction
533    /// calculator. Used by structural unit tests that don't want to
534    /// pull in a linear-solver backend.
535    pub fn build(&self) -> AlgorithmBundle {
536        self.build_inner(None)
537    }
538
539    /// Same as [`Self::build`] but also constructs the
540    /// `SymLinearSolver → AugSystemSolver → PdFullSpaceSolver →
541    /// PdSearchDirCalc` chain via the supplied `factory`.
542    pub fn build_with_backend(&self, mut factory: LinearBackendFactory) -> AlgorithmBundle {
543        let backend = factory(self.linear_solver);
544        let scaling: Option<Box<dyn pounce_linsol::TSymScalingMethod>> =
545            match self.linear_system_scaling {
546                LinearSystemScalingChoice::None => None,
547                LinearSystemScalingChoice::Ruiz => {
548                    Some(Box::new(pounce_linsol::RuizTSymScalingMethod::new()))
549                }
550                LinearSystemScalingChoice::Mc19 => {
551                    tracing::warn!(target: "pounce::algorithm",
552                        "pounce: linear_system_scaling=mc19 not yet implemented; using no scaling"
553                    );
554                    None
555                }
556            };
557        let linsol = TSymLinearSolver::new(backend, scaling, self.linear_scaling_on_demand);
558        let inner_aug = StdAugSystemSolver::new(linsol);
559        // Limited-memory mode publishes the Hessian as a
560        // `LowRankUpdateSymMatrix`; wrap the standard solver in the
561        // Sherman-Morrison-Woodbury low-rank solver so the augmented
562        // system factorizes only the diagonal `B0` and the quasi-Newton
563        // update is applied as a rank-`m` correction (`O(n·m)` memory).
564        let aug_solver: Box<dyn AugSystemSolver> = if matches!(
565            self.hessian_approximation,
566            HessianApproxChoice::LimitedMemory
567        ) {
568            Box::new(LowRankAugSystemSolver::new(Box::new(inner_aug)))
569        } else {
570            Box::new(inner_aug)
571        };
572        let perturb = Rc::new(RefCell::new(PdPerturbationHandler::new()));
573        let pd_solver = PdFullSpaceSolver::new(aug_solver, perturb);
574        let mut search_dir = PdSearchDirCalc::new(pd_solver);
575        search_dir.mehrotra_algorithm = self.mehrotra_algorithm;
576        self.build_inner(Some(search_dir))
577    }
578
579    /// Phase 5b assembly path for the SQP algorithm. Consults
580    /// `self.algorithm`: when `ActiveSetSqp`, constructs an
581    /// `SqpAlgorithm` using the supplied backend factory for the
582    /// QP subproblem solver; otherwise returns `None` so the
583    /// caller can fall back to the IPM `build_with_backend`.
584    ///
585    /// Sister to `build_with_backend`: the SQP algorithm doesn't
586    /// share `AlgorithmBundle`'s shape (no mu_update / no IPM
587    /// line search), so the two paths return different types.
588    pub fn build_sqp_with_backend(
589        &self,
590        mut factory: LinearBackendFactory,
591    ) -> Option<crate::sqp::SqpAlgorithm> {
592        if !matches!(self.algorithm, AlgorithmChoice::ActiveSetSqp) {
593            return None;
594        }
595        let backend = factory(self.linear_solver);
596        let qp_solver = pounce_qp::ParametricActiveSetSolver::new(backend);
597        Some(
598            crate::sqp::SqpAlgorithm::new(qp_solver, self.sqp.clone())
599                .with_qp_options(self.sqp_qp.clone()),
600        )
601    }
602
603    fn build_inner(&self, search_dir: Option<PdSearchDirCalc>) -> AlgorithmBundle {
604        let mu_update: Box<dyn crate::mu::r#trait::MuUpdate> = match self.mu_strategy {
605            MuStrategyChoice::Monotone => {
606                let mut m = MonotoneMuUpdate::new();
607                m.mu_init = self.mu.mu_init;
608                // `mu_max` sentinel `-1` keeps the monotone default
609                // (1e5); only override on a user-supplied positive.
610                if self.mu.mu_max > 0.0 {
611                    m.mu_max = self.mu.mu_max;
612                }
613                m.mu_min = self.mu.mu_min;
614                m.mu_target = self.mu.mu_target;
615                m.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
616                m.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
617                m.mu_allow_fast_monotone_decrease = self.mu.mu_allow_fast_monotone_decrease;
618                m.barrier_tol_factor = self.mu.barrier_tol_factor;
619                m.compl_inf_tol = self.conv_check.compl_inf_tol;
620                Box::new(m)
621            }
622            MuStrategyChoice::Adaptive => {
623                let mut adaptive = AdaptiveMuUpdate::new();
624                adaptive.mu_oracle = self.mu_oracle;
625                adaptive.mu_init = self.mu.mu_init;
626                // Adaptive treats `mu_max == -1` as "lazy init from
627                // `mu_max_fact * curr_avrg_compl`" — forward the
628                // sentinel as-is.
629                adaptive.mu_max = self.mu.mu_max;
630                adaptive.mu_max_fact = self.mu.mu_max_fact;
631                adaptive.mu_min = self.mu.mu_min;
632                adaptive.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
633                adaptive.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
634                adaptive.barrier_tol_factor = self.mu.barrier_tol_factor;
635                adaptive.sigma_min = self.mu.sigma_min;
636                adaptive.sigma_max = self.mu.sigma_max;
637                adaptive.adaptive_mu_globalization = self.mu.adaptive_mu_globalization;
638                adaptive.qf_norm_type = self.mu.quality_function_norm_type;
639                adaptive.qf_centrality_type = self.mu.quality_function_centrality;
640                adaptive.qf_balancing_term = self.mu.quality_function_balancing_term;
641                adaptive.qf_max_section_steps = self.mu.quality_function_max_section_steps;
642                adaptive.qf_section_sigma_tol = self.mu.quality_function_section_sigma_tol;
643                adaptive.qf_section_qf_tol = self.mu.quality_function_section_qf_tol;
644                adaptive.probing_iterate_quality_factor = self.mu.probing_iterate_quality_factor;
645                adaptive.adaptive_mu_safeguard_factor = self.mu.adaptive_mu_safeguard_factor;
646                adaptive.adaptive_mu_monotone_init_factor =
647                    self.mu.adaptive_mu_monotone_init_factor;
648                adaptive.restore_accepted_iterate = self.mu.adaptive_mu_restore_previous_iterate;
649                adaptive.adaptive_mu_kkterror_red_iters = self.mu.adaptive_mu_kkterror_red_iters;
650                adaptive.adaptive_mu_kkterror_red_fact = self.mu.adaptive_mu_kkterror_red_fact;
651                adaptive.adaptive_mu_kkt_norm = self.mu.adaptive_mu_kkt_norm_type;
652                Box::new(adaptive)
653            }
654        };
655
656        let acceptor: Box<dyn BacktrackingLsAcceptor> = match self.line_search_method {
657            LineSearchChoice::Filter => Box::new(FilterLsAcceptor::default()),
658            LineSearchChoice::Penalty => Box::new(PenaltyLsAcceptor::default()),
659            // CG-penalty acceptor lands with the rest of the
660            // CG-penalty path; fall back to the penalty acceptor's
661            // surface for now.
662            LineSearchChoice::CgPenalty => Box::new(PenaltyLsAcceptor::default()),
663        };
664        let mut line_search = BacktrackingLineSearch::new(acceptor);
665        line_search.watchdog_shortened_iter_trigger =
666            self.line_search.watchdog_shortened_iter_trigger;
667        line_search.watchdog_trial_iter_max = self.line_search.watchdog_trial_iter_max;
668        line_search.soft_resto_pderror_reduction_factor =
669            self.line_search.soft_resto_pderror_reduction_factor;
670        line_search.max_soft_resto_iters = self.line_search.max_soft_resto_iters;
671        line_search.accept_every_trial_step = self.line_search.accept_every_trial_step;
672        line_search.alpha_for_y = self.line_search.alpha_for_y;
673
674        let conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck> =
675            Box::new(OptErrorConvCheck {
676                tol: self.conv_check.tol,
677                dual_inf_tol: self.conv_check.dual_inf_tol,
678                constr_viol_tol: self.conv_check.constr_viol_tol,
679                compl_inf_tol: self.conv_check.compl_inf_tol,
680                acceptable_tol: self.conv_check.acceptable_tol,
681                acceptable_dual_inf_tol: self.conv_check.acceptable_dual_inf_tol,
682                acceptable_constr_viol_tol: self.conv_check.acceptable_constr_viol_tol,
683                acceptable_compl_inf_tol: self.conv_check.acceptable_compl_inf_tol,
684                acceptable_obj_change_tol: self.conv_check.acceptable_obj_change_tol,
685                acceptable_iter: self.conv_check.acceptable_iter,
686                max_iter: self.conv_check.max_iter,
687                max_cpu_time: self.conv_check.max_cpu_time,
688                max_wall_time: self.conv_check.max_wall_time,
689                acceptable_count: 0,
690                last_acceptable_obj: None,
691                infeas_stationarity_tol: self.conv_check.infeas_stationarity_tol,
692                infeas_viol_kappa: self.conv_check.infeas_viol_kappa,
693                infeas_max_streak: self.conv_check.infeas_max_streak,
694                infeas_streak: 0,
695            });
696
697        let init: Box<dyn crate::init::r#trait::IterateInitializer> = if self.warm_start_init_point
698        {
699            Box::new(WarmStartIterateInitializer::with_options(self.warm.clone()))
700        } else {
701            let mut d = DefaultIterateInitializer::with_eq_mult_calculator(Box::new(
702                LeastSquareMults::new(),
703            ));
704            d.bound_push = self.init.bound_push;
705            d.bound_frac = self.init.bound_frac;
706            d.slack_bound_push = self.init.slack_bound_push;
707            d.slack_bound_frac = self.init.slack_bound_frac;
708            d.constr_mult_init_max = self.init.constr_mult_init_max;
709            d.bound_mult_init_val = self.init.bound_mult_init_val;
710            d.bound_mult_init_method = self.init.bound_mult_init_method.clone();
711            d.least_square_init_primal = self.init.least_square_init_primal;
712            Box::new(d)
713        };
714
715        let eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator> =
716            Box::new(LeastSquareMults::new());
717
718        let hess: Box<dyn crate::hess::r#trait::HessianUpdater> = match self.hessian_approximation {
719            HessianApproxChoice::Exact => Box::new(ExactHessianUpdater::new()),
720            HessianApproxChoice::LimitedMemory => Box::new(LimMemQuasiNewtonUpdater {
721                update_type: self.limited_memory_update_type,
722                max_history: self.limited_memory_max_history,
723                ..LimMemQuasiNewtonUpdater::default()
724            }),
725        };
726
727        let iter_output: Box<dyn crate::output::r#trait::IterationOutput> = {
728            use crate::output::orig::{InfPrTag, PrintInfoString};
729            let mut o = OrigIterationOutput::new();
730            o.print_frequency_iter = self.output.print_frequency_iter;
731            o.print_frequency_time = self.output.print_frequency_time;
732            o.print_info_string = if self.output.print_info_string {
733                PrintInfoString::Yes
734            } else {
735                PrintInfoString::No
736            };
737            o.inf_pr_output = if self.output.inf_pr_output_internal {
738                InfPrTag::Internal
739            } else {
740                InfPrTag::Original
741            };
742            Box::new(o)
743        };
744
745        AlgorithmBundle {
746            mu_update,
747            conv_check,
748            init,
749            eq_mult,
750            hess,
751            line_search,
752            iter_output,
753            search_dir,
754        }
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn default_builder_assembles() {
764        let bundle = AlgorithmBuilder::new().build();
765        // Sanity: the placeholder traits compile and the boxed
766        // strategies don't panic on construction.
767        let _ = bundle.line_search.acceptor();
768        assert!(bundle.search_dir.is_none());
769    }
770
771    #[test]
772    fn build_with_backend_assembles_search_dir_chain() {
773        // Drive the builder with the FERAL backend factory; the
774        // resulting bundle should expose a populated `PdSearchDirCalc`.
775        let factory: LinearBackendFactory = Box::new(|_| {
776            Box::new(pounce_feral::FeralSolverInterface::new())
777                as Box<dyn SparseSymLinearSolverInterface>
778        });
779        let bundle = AlgorithmBuilder::new().build_with_backend(factory);
780        assert!(bundle.search_dir.is_some());
781    }
782
783    #[test]
784    fn limited_memory_sr1_propagates() {
785        let b = AlgorithmBuilder {
786            hessian_approximation: HessianApproxChoice::LimitedMemory,
787            limited_memory_update_type: UpdateType::Sr1,
788            ..AlgorithmBuilder::default()
789        };
790        let _bundle = b.build();
791    }
792
793    #[test]
794    fn every_strategy_combination_assembles_without_panic() {
795        let solvers = [LinearSolverChoice::Ma57, LinearSolverChoice::Feral];
796        let mu = [MuStrategyChoice::Monotone, MuStrategyChoice::Adaptive];
797        let hess = [
798            HessianApproxChoice::Exact,
799            HessianApproxChoice::LimitedMemory,
800        ];
801        let ls = [
802            LineSearchChoice::Filter,
803            LineSearchChoice::CgPenalty,
804            LineSearchChoice::Penalty,
805        ];
806        for &linear_solver in &solvers {
807            for &mu_strategy in &mu {
808                for &hessian_approximation in &hess {
809                    for &line_search_method in &ls {
810                        let _ = AlgorithmBuilder {
811                            algorithm: AlgorithmChoice::default(),
812                            linear_solver,
813                            linear_system_scaling: LinearSystemScalingChoice::None,
814                            linear_scaling_on_demand: true,
815                            mu_strategy,
816                            mu_oracle: MuOracleKind::QualityFunction,
817                            hessian_approximation,
818                            limited_memory_update_type: UpdateType::Bfgs,
819                            limited_memory_max_history: 6,
820                            line_search_method,
821                            warm_start_init_point: false,
822                            mehrotra_algorithm: false,
823                            conv_check: ConvCheckOptions::default(),
824                            mu: MuOptions::default(),
825                            line_search: LineSearchOptions::default(),
826                            output: OutputOptions::default(),
827                            warm: WarmStartOptions::default(),
828                            sqp: crate::sqp::SqpOptions::default(),
829                            sqp_qp: pounce_qp::QpOptions::default(),
830                            init: InitOptions::default(),
831                        }
832                        .build();
833                    }
834                }
835            }
836        }
837    }
838}