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::pd_full_space_solver::PdFullSpaceSolver;
30use crate::kkt::pd_search_dir_calc::PdSearchDirCalc;
31use crate::kkt::perturbation_handler::PdPerturbationHandler;
32use crate::kkt::std_aug_system_solver::StdAugSystemSolver;
33use crate::line_search::backtracking::BacktrackingLineSearch;
34use crate::line_search::filter_acceptor::FilterLsAcceptor;
35use crate::line_search::ls_acceptor::BacktrackingLsAcceptor;
36use crate::line_search::penalty_acceptor::PenaltyLsAcceptor;
37use crate::mu::adaptive::{AdaptiveMuUpdate, MuOracleKind};
38use crate::mu::monotone::MonotoneMuUpdate;
39use crate::output::orig::OrigIterationOutput;
40use pounce_common::types::{Index, Number};
41use pounce_linsol::{SparseSymLinearSolverInterface, TSymLinearSolver};
42use std::cell::RefCell;
43use std::rc::Rc;
44
45/// Backend factory — the application supplies one before calling
46/// [`AlgorithmBuilder::build`]. Mirrors upstream's
47/// `SymLinearSolverFactory` knob in `IpAlgBuilder.cpp`. The default
48/// factory wires in FERAL; MA57 is selectable when the `ma57` cargo
49/// feature is enabled.
50pub type LinearBackendFactory =
51    Box<dyn FnMut(LinearSolverChoice) -> Box<dyn SparseSymLinearSolverInterface>>;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum LinearSolverChoice {
55    Ma57,
56    Feral,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum MuStrategyChoice {
61    Monotone,
62    Adaptive,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum HessianApproxChoice {
67    Exact,
68    LimitedMemory,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LineSearchChoice {
73    Filter,
74    CgPenalty,
75    Penalty,
76}
77
78/// Assembled strategy bundle. Phase 7 ships the structural bundle;
79/// `IpoptAlgorithm::new` reads from this when it lands.
80pub struct AlgorithmBundle {
81    pub mu_update: Box<dyn crate::mu::r#trait::MuUpdate>,
82    pub conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck>,
83    pub init: Box<dyn crate::init::r#trait::IterateInitializer>,
84    pub eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator>,
85    pub hess: Box<dyn crate::hess::r#trait::HessianUpdater>,
86    pub line_search: BacktrackingLineSearch,
87    pub iter_output: Box<dyn crate::output::r#trait::IterationOutput>,
88    /// `Some` when the builder was given a [`LinearBackendFactory`];
89    /// `None` for the bare structural bundle that pre-Phase-6 unit
90    /// tests still rely on.
91    pub search_dir: Option<PdSearchDirCalc>,
92}
93
94/// Knobs read off `OptionsList` and baked into the assembled
95/// `OptErrorConvCheck`. Defaults mirror
96/// `IpOptErrorConvCheck.cpp:RegisterOptions`.
97#[derive(Debug, Clone)]
98pub struct ConvCheckOptions {
99    pub tol: Number,
100    pub dual_inf_tol: Number,
101    pub constr_viol_tol: Number,
102    pub compl_inf_tol: Number,
103    pub acceptable_tol: Number,
104    pub acceptable_dual_inf_tol: Number,
105    pub acceptable_constr_viol_tol: Number,
106    pub acceptable_compl_inf_tol: Number,
107    pub acceptable_obj_change_tol: Number,
108    pub acceptable_iter: Index,
109    pub max_iter: Index,
110    pub max_cpu_time: Number,
111    pub max_wall_time: Number,
112    pub infeas_stationarity_tol: Number,
113    pub infeas_viol_kappa: Number,
114    pub infeas_max_streak: Index,
115}
116
117impl Default for ConvCheckOptions {
118    fn default() -> Self {
119        Self {
120            tol: 1e-8,
121            dual_inf_tol: 1.0,
122            constr_viol_tol: 1e-4,
123            compl_inf_tol: 1e-4,
124            acceptable_tol: 1e-6,
125            acceptable_dual_inf_tol: 1e10,
126            acceptable_constr_viol_tol: 1e-2,
127            acceptable_compl_inf_tol: 1e-2,
128            acceptable_obj_change_tol: 1e20,
129            acceptable_iter: 15,
130            max_iter: 3000,
131            max_cpu_time: 1e6,
132            max_wall_time: 1e6,
133            infeas_stationarity_tol: 1e-8,
134            infeas_viol_kappa: 1e2,
135            infeas_max_streak: 5,
136        }
137    }
138}
139
140#[derive(Debug, Clone)]
141pub struct AlgorithmBuilder {
142    pub linear_solver: LinearSolverChoice,
143    pub mu_strategy: MuStrategyChoice,
144    /// Selector forwarded to [`AdaptiveMuUpdate`] when
145    /// `mu_strategy = Adaptive`. Ignored for `Monotone`. Defaults to
146    /// `QualityFunction` per upstream's `RegisterOptions` default.
147    pub mu_oracle: MuOracleKind,
148    pub hessian_approximation: HessianApproxChoice,
149    pub limited_memory_update_type: UpdateType,
150    pub line_search_method: LineSearchChoice,
151    pub warm_start_init_point: bool,
152    pub conv_check: ConvCheckOptions,
153    pub mu: MuOptions,
154    pub line_search: LineSearchOptions,
155    pub output: OutputOptions,
156    pub warm: WarmStartOptions,
157}
158
159/// Knobs read off `OptionsList` and baked into
160/// [`WarmStartIterateInitializer`]. Defaults mirror
161/// `IpWarmStartIterateInitializer.cpp:RegisterOptions`.
162///
163/// Wired today: `mult_init_max` (clamps |y_c|, |y_d| and caps z/v
164/// blocks) and `target_mu` (overrides `data.curr_mu` at iter 0).
165/// The remaining knobs (`bound_push`, `bound_frac`, `slack_bound_push`,
166/// `slack_bound_frac`, `mult_bound_push`, `entire_iterate`,
167/// `same_structure`) are stored on the initializer but not yet
168/// consumed — `WarmStartIterateInitializer::set_initial_iterates`
169/// currently trusts the caller-populated `data.curr` rather than
170/// re-running the upstream `push_variables` machinery.
171#[derive(Debug, Clone)]
172pub struct WarmStartOptions {
173    pub bound_push: Number,
174    pub bound_frac: Number,
175    pub slack_bound_push: Number,
176    pub slack_bound_frac: Number,
177    pub mult_bound_push: Number,
178    pub mult_init_max: Number,
179    pub target_mu: Number,
180    pub entire_iterate: bool,
181    pub same_structure: bool,
182}
183
184impl Default for WarmStartOptions {
185    fn default() -> Self {
186        Self {
187            bound_push: 1e-3,
188            bound_frac: 1e-3,
189            slack_bound_push: 1e-3,
190            slack_bound_frac: 1e-3,
191            mult_bound_push: 1e-3,
192            mult_init_max: 1e6,
193            target_mu: 0.0,
194            entire_iterate: false,
195            same_structure: false,
196        }
197    }
198}
199
200/// Knobs read off `OptionsList` and baked into the assembled
201/// `MonotoneMuUpdate` or `AdaptiveMuUpdate`. Defaults mirror
202/// `IpMonotoneMuUpdate.cpp` / `IpAdaptiveMuUpdate.cpp:RegisterOptions`.
203/// `mu_max` defaults to the sentinel `-1`; positive values are baked
204/// into both updaters at build time (adaptive interprets `-1` as
205/// "lazy-init from `mu_max_fact * avrg_compl`").
206#[derive(Debug, Clone)]
207pub struct MuOptions {
208    pub mu_init: Number,
209    pub mu_max: Number,
210    pub mu_max_fact: Number,
211    pub mu_min: Number,
212    pub mu_target: Number,
213    pub mu_linear_decrease_factor: Number,
214    pub mu_superlinear_decrease_power: Number,
215    pub mu_allow_fast_monotone_decrease: bool,
216    pub barrier_tol_factor: Number,
217    /// `sigma_max` / `sigma_min` — clamp on the centering parameter σ
218    /// chosen by `QualityFunctionMuOracle`. Only consumed when
219    /// `mu_strategy=adaptive` and `mu_oracle=quality-function`.
220    /// Defaults from `IpQualityFunctionMuOracle.cpp:RegisterOptions`.
221    pub sigma_max: Number,
222    pub sigma_min: Number,
223}
224
225impl Default for MuOptions {
226    fn default() -> Self {
227        Self {
228            mu_init: 0.1,
229            mu_max: -1.0,
230            mu_max_fact: 1e3,
231            mu_min: 1e-11,
232            mu_target: 0.0,
233            mu_linear_decrease_factor: 0.2,
234            mu_superlinear_decrease_power: 1.5,
235            mu_allow_fast_monotone_decrease: true,
236            barrier_tol_factor: 10.0,
237            sigma_max: 1e2,
238            sigma_min: 1e-6,
239        }
240    }
241}
242
243/// Knobs baked into the assembled [`BacktrackingLineSearch`]. Defaults
244/// mirror `IpBacktrackingLineSearch.cpp:RegisterOptions`.
245#[derive(Debug, Clone)]
246pub struct LineSearchOptions {
247    pub watchdog_shortened_iter_trigger: Index,
248    pub watchdog_trial_iter_max: Index,
249    /// `soft_resto_pderror_reduction_factor` — required relative
250    /// reduction in the primal-dual error for a soft-resto step.
251    /// `0` disables the soft restoration phase.
252    pub soft_resto_pderror_reduction_factor: Number,
253    /// `max_soft_resto_iters` — cap on consecutive soft-resto
254    /// iterations before full restoration is forced.
255    pub max_soft_resto_iters: Index,
256}
257
258impl Default for LineSearchOptions {
259    fn default() -> Self {
260        Self {
261            watchdog_shortened_iter_trigger: 10,
262            watchdog_trial_iter_max: 3,
263            soft_resto_pderror_reduction_factor: 1.0 - 1e-4,
264            max_soft_resto_iters: 10,
265        }
266    }
267}
268
269/// Knobs baked into the assembled [`OrigIterationOutput`]. Defaults
270/// mirror `IpOrigIterationOutput.cpp:RegisterOptions` /
271/// `IpAlgorithmRegOp.cpp`.
272#[derive(Debug, Clone)]
273pub struct OutputOptions {
274    pub print_frequency_iter: Index,
275    pub print_frequency_time: Number,
276    /// `print_info_string` (default `false`). When on, the iter row
277    /// ends with the contents of `IpoptData::info_string` so users
278    /// can read the per-iteration diagnostic tags.
279    pub print_info_string: bool,
280    /// `inf_pr_output` — `"original"` (default) prints the unscaled
281    /// NLP primal infeasibility; `"internal"` prints the internal
282    /// reformulated violation. Only meaningful once NLP-side scaling
283    /// is in play; until then both modes produce the same number.
284    pub inf_pr_output_internal: bool,
285}
286
287impl Default for OutputOptions {
288    fn default() -> Self {
289        Self {
290            print_frequency_iter: 1,
291            print_frequency_time: 0.0,
292            print_info_string: false,
293            inf_pr_output_internal: false,
294        }
295    }
296}
297
298impl Default for AlgorithmBuilder {
299    fn default() -> Self {
300        Self {
301            linear_solver: LinearSolverChoice::Feral,
302            mu_strategy: MuStrategyChoice::Monotone,
303            mu_oracle: MuOracleKind::QualityFunction,
304            hessian_approximation: HessianApproxChoice::Exact,
305            limited_memory_update_type: UpdateType::Bfgs,
306            line_search_method: LineSearchChoice::Filter,
307            warm_start_init_point: false,
308            conv_check: ConvCheckOptions::default(),
309            mu: MuOptions::default(),
310            line_search: LineSearchOptions::default(),
311            output: OutputOptions::default(),
312            warm: WarmStartOptions::default(),
313        }
314    }
315}
316
317impl AlgorithmBuilder {
318    pub fn new() -> Self {
319        Self::default()
320    }
321
322    /// Assemble the strategy bundle without a search-direction
323    /// calculator. Used by structural unit tests that don't want to
324    /// pull in a linear-solver backend.
325    pub fn build(&self) -> AlgorithmBundle {
326        self.build_inner(None)
327    }
328
329    /// Same as [`Self::build`] but also constructs the
330    /// `SymLinearSolver → AugSystemSolver → PdFullSpaceSolver →
331    /// PdSearchDirCalc` chain via the supplied `factory`.
332    pub fn build_with_backend(&self, mut factory: LinearBackendFactory) -> AlgorithmBundle {
333        let backend = factory(self.linear_solver);
334        let linsol = TSymLinearSolver::new(backend, None, false);
335        let aug_solver = StdAugSystemSolver::new(linsol);
336        let perturb = Rc::new(RefCell::new(PdPerturbationHandler::new()));
337        let pd_solver = PdFullSpaceSolver::new(Box::new(aug_solver), perturb);
338        let search_dir = PdSearchDirCalc::new(pd_solver);
339        self.build_inner(Some(search_dir))
340    }
341
342    fn build_inner(&self, search_dir: Option<PdSearchDirCalc>) -> AlgorithmBundle {
343        let mu_update: Box<dyn crate::mu::r#trait::MuUpdate> = match self.mu_strategy {
344            MuStrategyChoice::Monotone => {
345                let mut m = MonotoneMuUpdate::new();
346                m.mu_init = self.mu.mu_init;
347                // `mu_max` sentinel `-1` keeps the monotone default
348                // (1e5); only override on a user-supplied positive.
349                if self.mu.mu_max > 0.0 {
350                    m.mu_max = self.mu.mu_max;
351                }
352                m.mu_min = self.mu.mu_min;
353                m.mu_target = self.mu.mu_target;
354                m.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
355                m.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
356                m.mu_allow_fast_monotone_decrease = self.mu.mu_allow_fast_monotone_decrease;
357                m.barrier_tol_factor = self.mu.barrier_tol_factor;
358                m.compl_inf_tol = self.conv_check.compl_inf_tol;
359                Box::new(m)
360            }
361            MuStrategyChoice::Adaptive => {
362                let mut adaptive = AdaptiveMuUpdate::new();
363                adaptive.mu_oracle = self.mu_oracle;
364                adaptive.mu_init = self.mu.mu_init;
365                // Adaptive treats `mu_max == -1` as "lazy init from
366                // `mu_max_fact * curr_avrg_compl`" — forward the
367                // sentinel as-is.
368                adaptive.mu_max = self.mu.mu_max;
369                adaptive.mu_max_fact = self.mu.mu_max_fact;
370                adaptive.mu_min = self.mu.mu_min;
371                adaptive.mu_linear_decrease_factor = self.mu.mu_linear_decrease_factor;
372                adaptive.mu_superlinear_decrease_power = self.mu.mu_superlinear_decrease_power;
373                adaptive.barrier_tol_factor = self.mu.barrier_tol_factor;
374                adaptive.sigma_min = self.mu.sigma_min;
375                adaptive.sigma_max = self.mu.sigma_max;
376                Box::new(adaptive)
377            }
378        };
379
380        let acceptor: Box<dyn BacktrackingLsAcceptor> = match self.line_search_method {
381            LineSearchChoice::Filter => Box::new(FilterLsAcceptor::default()),
382            LineSearchChoice::Penalty => Box::new(PenaltyLsAcceptor::default()),
383            // CG-penalty acceptor lands with the rest of the
384            // CG-penalty path; fall back to the penalty acceptor's
385            // surface for now.
386            LineSearchChoice::CgPenalty => Box::new(PenaltyLsAcceptor::default()),
387        };
388        let mut line_search = BacktrackingLineSearch::new(acceptor);
389        line_search.watchdog_shortened_iter_trigger =
390            self.line_search.watchdog_shortened_iter_trigger;
391        line_search.watchdog_trial_iter_max = self.line_search.watchdog_trial_iter_max;
392        line_search.soft_resto_pderror_reduction_factor =
393            self.line_search.soft_resto_pderror_reduction_factor;
394        line_search.max_soft_resto_iters = self.line_search.max_soft_resto_iters;
395
396        let conv_check: Box<dyn crate::conv_check::r#trait::ConvCheck> =
397            Box::new(OptErrorConvCheck {
398                tol: self.conv_check.tol,
399                dual_inf_tol: self.conv_check.dual_inf_tol,
400                constr_viol_tol: self.conv_check.constr_viol_tol,
401                compl_inf_tol: self.conv_check.compl_inf_tol,
402                acceptable_tol: self.conv_check.acceptable_tol,
403                acceptable_dual_inf_tol: self.conv_check.acceptable_dual_inf_tol,
404                acceptable_constr_viol_tol: self.conv_check.acceptable_constr_viol_tol,
405                acceptable_compl_inf_tol: self.conv_check.acceptable_compl_inf_tol,
406                acceptable_obj_change_tol: self.conv_check.acceptable_obj_change_tol,
407                acceptable_iter: self.conv_check.acceptable_iter,
408                max_iter: self.conv_check.max_iter,
409                max_cpu_time: self.conv_check.max_cpu_time,
410                max_wall_time: self.conv_check.max_wall_time,
411                acceptable_count: 0,
412                last_acceptable_obj: None,
413                infeas_stationarity_tol: self.conv_check.infeas_stationarity_tol,
414                infeas_viol_kappa: self.conv_check.infeas_viol_kappa,
415                infeas_max_streak: self.conv_check.infeas_max_streak,
416                infeas_streak: 0,
417            });
418
419        let init: Box<dyn crate::init::r#trait::IterateInitializer> = if self.warm_start_init_point
420        {
421            Box::new(WarmStartIterateInitializer::with_options(self.warm.clone()))
422        } else {
423            Box::new(DefaultIterateInitializer::with_eq_mult_calculator(
424                Box::new(LeastSquareMults::new()),
425            ))
426        };
427
428        let eq_mult: Box<dyn crate::eq_mult::r#trait::EqMultCalculator> =
429            Box::new(LeastSquareMults::new());
430
431        let hess: Box<dyn crate::hess::r#trait::HessianUpdater> = match self.hessian_approximation {
432            HessianApproxChoice::Exact => Box::new(ExactHessianUpdater::new()),
433            HessianApproxChoice::LimitedMemory => Box::new(LimMemQuasiNewtonUpdater {
434                update_type: self.limited_memory_update_type,
435                ..LimMemQuasiNewtonUpdater::default()
436            }),
437        };
438
439        let iter_output: Box<dyn crate::output::r#trait::IterationOutput> = {
440            use crate::output::orig::{InfPrTag, PrintInfoString};
441            let mut o = OrigIterationOutput::new();
442            o.print_frequency_iter = self.output.print_frequency_iter;
443            o.print_frequency_time = self.output.print_frequency_time;
444            o.print_info_string = if self.output.print_info_string {
445                PrintInfoString::Yes
446            } else {
447                PrintInfoString::No
448            };
449            o.inf_pr_output = if self.output.inf_pr_output_internal {
450                InfPrTag::Internal
451            } else {
452                InfPrTag::Original
453            };
454            Box::new(o)
455        };
456
457        AlgorithmBundle {
458            mu_update,
459            conv_check,
460            init,
461            eq_mult,
462            hess,
463            line_search,
464            iter_output,
465            search_dir,
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn default_builder_assembles() {
476        let bundle = AlgorithmBuilder::new().build();
477        // Sanity: the placeholder traits compile and the boxed
478        // strategies don't panic on construction.
479        let _ = bundle.line_search.acceptor();
480        assert!(bundle.search_dir.is_none());
481    }
482
483    #[test]
484    fn build_with_backend_assembles_search_dir_chain() {
485        // Drive the builder with the FERAL backend factory; the
486        // resulting bundle should expose a populated `PdSearchDirCalc`.
487        let factory: LinearBackendFactory = Box::new(|_| {
488            Box::new(pounce_feral::FeralSolverInterface::new())
489                as Box<dyn SparseSymLinearSolverInterface>
490        });
491        let bundle = AlgorithmBuilder::new().build_with_backend(factory);
492        assert!(bundle.search_dir.is_some());
493    }
494
495    #[test]
496    fn limited_memory_sr1_propagates() {
497        let b = AlgorithmBuilder {
498            hessian_approximation: HessianApproxChoice::LimitedMemory,
499            limited_memory_update_type: UpdateType::Sr1,
500            ..AlgorithmBuilder::default()
501        };
502        let _bundle = b.build();
503    }
504
505    #[test]
506    fn every_strategy_combination_assembles_without_panic() {
507        let solvers = [LinearSolverChoice::Ma57, LinearSolverChoice::Feral];
508        let mu = [MuStrategyChoice::Monotone, MuStrategyChoice::Adaptive];
509        let hess = [
510            HessianApproxChoice::Exact,
511            HessianApproxChoice::LimitedMemory,
512        ];
513        let ls = [
514            LineSearchChoice::Filter,
515            LineSearchChoice::CgPenalty,
516            LineSearchChoice::Penalty,
517        ];
518        for &linear_solver in &solvers {
519            for &mu_strategy in &mu {
520                for &hessian_approximation in &hess {
521                    for &line_search_method in &ls {
522                        let _ = AlgorithmBuilder {
523                            linear_solver,
524                            mu_strategy,
525                            mu_oracle: MuOracleKind::QualityFunction,
526                            hessian_approximation,
527                            limited_memory_update_type: UpdateType::Bfgs,
528                            line_search_method,
529                            warm_start_init_point: false,
530                            conv_check: ConvCheckOptions::default(),
531                            mu: MuOptions::default(),
532                            line_search: LineSearchOptions::default(),
533                            output: OutputOptions::default(),
534                            warm: WarmStartOptions::default(),
535                        }
536                        .build();
537                    }
538                }
539            }
540        }
541    }
542}