Skip to main content

pounce_algorithm/sqp/
options.rs

1//! SQP outer-loop options. Mirrors the spirit of
2//! `crate::alg_builder::ConvCheckOptions` but only what the SQP
3//! driver itself needs; QP-subproblem-specific options pass
4//! through `pounce_qp::QpOptions` (which `SqpAlgorithm` owns).
5
6use pounce_common::Number;
7use pounce_common::timing::Deadline;
8use pounce_qp::HessianInertia;
9
10/// Choice of SQP globalization strategy. Defaults to filter
11/// (Fletcher-Leyffer 2002) per the design note §4.1; l1-elastic
12/// merit (Han-Powell with adaptive penalty) is the alternative.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum SqpGlobalization {
15    #[default]
16    Filter,
17    L1Elastic,
18}
19
20/// Hessian source for the SQP subproblem.
21///
22/// - `Exact`: use `nlp.eval_h(x, 1.0, λ_g, 0)` directly. Indefinite
23///   on nonconvex problems; the QP subproblem solver handles
24///   indefinite reduced Hessian via inertia control (§4.5).
25/// - `DampedBfgs`: dense `n×n` Powell-damped BFGS, guaranteed PSD
26///   (Powell 1978). See [`crate::sqp::bfgs::DampedBfgs`].
27/// - `Lbfgs`: limited-memory BFGS with a circular `(s, y)` buffer
28///   of `lbfgs_max_history` pairs (Nocedal-Wright §7.2; Byrd-
29///   Nocedal-Schnabel 1994). See [`crate::sqp::lbfgs::LBfgs`].
30/// - `QuasiNewtonAuto`: what `hessian_approximation=limited-memory`
31///   selects — `DampedBfgs` up to [`SqpOptions::dense_hessian_max_n`]
32///   variables, `Lbfgs` above it, decided per solve from the problem's
33///   `n` (gh#1054). The dense update is the more robust of the two on
34///   small models (issue #358) but needs `n²` storage and makes every QP
35///   factorization dense, which a 15 000-variable model cannot afford.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SqpHessianSource {
38    #[default]
39    Exact,
40    DampedBfgs,
41    Lbfgs,
42    QuasiNewtonAuto,
43}
44
45impl SqpHessianSource {
46    /// The concrete source a solve of `n` variables uses: resolves
47    /// `QuasiNewtonAuto`, passes every other source through.
48    pub fn resolve(self, n: usize, dense_hessian_max_n: usize) -> Self {
49        match self {
50            Self::QuasiNewtonAuto if n <= dense_hessian_max_n => Self::DampedBfgs,
51            Self::QuasiNewtonAuto => Self::Lbfgs,
52            other => other,
53        }
54    }
55
56    /// Whether this source approximates the Hessian (and is therefore
57    /// positive definite by construction) rather than evaluating it.
58    pub fn is_quasi_newton(self) -> bool {
59        !matches!(self, Self::Exact)
60    }
61}
62
63/// Default for [`SqpOptions::qp_schur_min_dim`].
64pub const DEFAULT_QP_SCHUR_MIN_DIM: usize = 500;
65
66/// Default for [`SqpOptions::qp_schur_indefinite_max_dim`]. The rule applies
67/// to warm step QPs only, so it does not depend on whether cold ones are
68/// seeded; the value is the size `algorithm=active-set-sqp` already treats as
69/// large (the interior-point handover and cold-start seed start here too),
70/// and it matches `pounce_qp::SOFT_DROP_MIN_DIM`, so a warm indefinite QP
71/// moved to the refactor path gets the soft drops as well. The reporter's QP is
72/// `n + m = 28 950`; nothing measured places the crossover more precisely.
73pub const DEFAULT_QP_SCHUR_INDEFINITE_MAX_DIM: usize = 5000;
74
75/// Default for [`SqpOptions::qp_ipm_cold_start_min_dim`]: the size at which
76/// `algorithm=active-set-sqp` already starts from the interior point and
77/// hands over (`IpoptApplication::sqp_auto_handover`), for the same reason
78/// one level down.
79pub const DEFAULT_QP_IPM_COLD_START_MIN_DIM: usize = 5000;
80
81#[derive(Debug, Clone)]
82pub struct SqpOptions {
83    pub globalization: SqpGlobalization,
84    pub hessian: SqpHessianSource,
85
86    /// KKT tolerance (max-norm) on the stationarity residual.
87    pub tol: Number,
88    /// Tolerance on constraint violation (max-norm).
89    pub constr_viol_tol: Number,
90    /// Tolerance on stationarity residual (max-norm).
91    pub dual_inf_tol: Number,
92    /// Outer-iteration cap.
93    pub max_iter: u32,
94
95    /// l1-merit penalty parameter ν. Used only when
96    /// `globalization = L1Elastic`. Filter globalization ignores
97    /// this. Default is a moderate starting value; the Han-Powell
98    /// ν-adaptation in `l1_merit_line_search` grows ν to dominate
99    /// `‖λ_qp‖_∞ + l1_penalty_safety` on every iteration.
100    pub l1_penalty: Number,
101
102    /// Additive safety margin in the Han-Powell ν update:
103    /// `ν_new = max(ν, ‖λ_qp‖_∞ + l1_penalty_safety)`. Default 0.1
104    /// per Nocedal-Wright §18.4. Only consulted when
105    /// `globalization = L1Elastic`.
106    pub l1_penalty_safety: Number,
107
108    /// Upper clamp on ν. Prevents catastrophic Armijo failure on
109    /// pathological problems where `‖λ_qp‖` momentarily spikes.
110    /// Default 1e10 — large enough not to interfere with normal
111    /// runs, small enough to keep the merit numerically stable.
112    /// Only consulted when `globalization = L1Elastic`.
113    pub l1_penalty_max: Number,
114
115    /// Backtracking line-search reduction factor.
116    pub bt_reduction: Number,
117    /// Minimum step before declaring line-search failure.
118    pub bt_min_alpha: Number,
119
120    /// `print_level`: 0 = silent, 1 = per-iteration summary,
121    /// 2+ = trace (planned).
122    pub print_level: u8,
123
124    /// Number of `(s, y)` history pairs retained when
125    /// `hessian = Lbfgs`. Mirrors the upstream
126    /// `limited_memory_max_history` default of 6 (Nocedal-Wright
127    /// recommends 3–20). Ignored for `Exact` and `DampedBfgs`.
128    pub lbfgs_max_history: u32,
129
130    /// Largest `n` at which a quasi-Newton Hessian is handed to the QP as a
131    /// dense matrix (`sqp_dense_hessian_max_n`). Above it, `Lbfgs` poses the
132    /// QP in its lifted low-rank form
133    /// ([`crate::sqp::SqpQpData::build_low_rank`]) and `QuasiNewtonAuto`
134    /// resolves to `Lbfgs` rather than `DampedBfgs`. At or below it nothing
135    /// changes from before gh#1054. Default 1000.
136    pub dense_hessian_max_n: usize,
137
138    /// Size from which the step QP uses Schur-complement updates
139    /// (gh#1054): when `Some(d)`, each solve sets
140    /// `QpOptions::use_schur_updates` to `n + m ≥ d`; `None` leaves the QP
141    /// options as given. The driver reads `sqp_qp_use_schur_updates` only
142    /// when it is set explicitly, and then passes `None` here.
143    ///
144    /// Why size: the refactor path pays a full sparse factorization per
145    /// working-set change, which is what made the cold first QP of a
146    /// 15 000-variable model run for 30 minutes, while on small models the
147    /// two paths cost the same and the Schur path is the less robust one
148    /// (measured on the CLI fixture corpus under `algorithm=active-set-sqp`).
149    /// See `docs/src/active-set-sqp.md` for the Maros-Mészáros comparison
150    /// behind the default.
151    pub qp_schur_min_dim: Option<usize>,
152
153    /// Size from which a *warm-started* step QP whose Hessian is not known
154    /// PSD goes back to the refactor path, overriding
155    /// [`Self::qp_schur_min_dim`] (gh#1083, gh#1091). `None` never does; cold
156    /// solves and PSD step QPs are not affected.
157    ///
158    /// Such a QP carries the inertia hint `Indefinite`, and on it every
159    /// *drop* refactors anyway (a rank-2 update cannot re-run inertia control
160    /// on the enlarged null space), so only adds take the update. A warm step
161    /// QP is drop-dominated: the reporter's took 187 Schur updates against
162    /// 4181 working-set changes over a 3600 s run. It then pays for the larger
163    /// `K_max` — dearer factorizations, back-solves about 5× the small KKT's —
164    /// and gets almost nothing back; on its handover QP the Schur path took
165    /// 126 s, the refactor path 72 s.
166    ///
167    /// Two step QPs carry the hint: an exact Hessian's, and the lifted
168    /// L-BFGS one above `sqp_dense_hessian_max_n`
169    /// ([`crate::sqp::SqpQpData::build_low_rank`]), whose Hessian
170    /// `diag(σI, ±1)` is indefinite although the `B` it lifts is not. The rule
171    /// reads the hint, which is what the QP solver branches on, not the
172    /// Hessian source; until gh#1091 it read the source and missed the lift.
173    ///
174    /// Cold solves keep the size rule because they are the add-dominated
175    /// ones the Schur path wins on, and at this size they are not all seeded
176    /// from the interior point: when the hint is not `Psd` the seed certifies
177    /// the Hessian PSD numerically (`ipm_seeded_step_qp`) and declines if it
178    /// is not. The lifted QP's `-1` entries never pass, and a nonconvex
179    /// model's exact Hessian need not, so such a cold QP still builds its
180    /// working set one add at a time. (A convex model's exact Hessian does
181    /// pass, and its cold step QPs are seeded.)
182    pub qp_schur_indefinite_max_dim: Option<usize>,
183
184    /// NLP size `n + m` from which a step QP solved without a working set,
185    /// or with one that pins to an infeasible point, is first solved by the
186    /// convex interior point, when its Hessian is PSD, and the active-set
187    /// solver warm-started from the working set that solution identifies
188    /// (gh#1061). `None` never does. The warm case: the reporter's second
189    /// step QP went to the ℓ1-elastic phase 1 from its infeasible warm start
190    /// and reached a 300 s cap after 564 working-set changes; seeded, 12.5 s.
191    ///
192    /// A cold active-set start builds its working set one pivot at a time,
193    /// and on a large model that is the whole cost: gh#1061's first step QP
194    /// (`n = 14 952`, 10 818 equalities, `H = I`) went through the elastic
195    /// phase 1 for more than 30 minutes on either factorization path without
196    /// finishing. The interior point solved it in 16 iterations, 4.3 s, and
197    /// the active-set solve from its working set took zero working-set
198    /// changes and one factorization, 0.4 s, to the same objective. The
199    /// active-set solve still has the last word, so the step and its
200    /// multipliers are what the active-set method certifies, and anything
201    /// short of an interior-point `Optimal` falls back to the cold start.
202    ///
203    /// At the same size a warm start that pins feasibly is given a budget of
204    /// four active-set iterations per iteration the last seed took, and is
205    /// seeded once it runs over. gh#1061's warm step QPs took 68–350
206    /// working-set changes and 51–263 s each at about one factorization per
207    /// change; on the handover QP the budget and the seed together take 34 s
208    /// against 72 s warm (126 s with Schur updates).
209    pub qp_ipm_cold_start_min_dim: Option<usize>,
210
211    /// Solve-wide time budget (`max_wall_time` / `max_cpu_time`), the
212    /// same [`Deadline`] the interior-point arm checks. `None` means
213    /// unlimited and is the default for a directly constructed driver.
214    ///
215    /// A shared handle rather than two durations, because the SQP is not
216    /// always the first thing a solve runs: crossover hands it the
217    /// interior-point phase's own deadline, and a duration measured from
218    /// `optimize` would grant it a fresh budget (gh#1054).
219    pub deadline: Option<Deadline>,
220}
221
222impl Default for SqpOptions {
223    fn default() -> Self {
224        Self {
225            globalization: SqpGlobalization::default(),
226            hessian: SqpHessianSource::default(),
227            tol: 1e-8,
228            constr_viol_tol: 1e-6,
229            dual_inf_tol: 1e-4,
230            max_iter: 200,
231            l1_penalty: 1.0,
232            l1_penalty_safety: 0.1,
233            l1_penalty_max: 1e10,
234            bt_reduction: 0.5,
235            bt_min_alpha: 1e-12,
236            print_level: 0,
237            lbfgs_max_history: 6,
238            dense_hessian_max_n: 1000,
239            qp_schur_min_dim: Some(DEFAULT_QP_SCHUR_MIN_DIM),
240            qp_schur_indefinite_max_dim: Some(DEFAULT_QP_SCHUR_INDEFINITE_MAX_DIM),
241            qp_ipm_cold_start_min_dim: Some(DEFAULT_QP_IPM_COLD_START_MIN_DIM),
242            deadline: None,
243        }
244    }
245}
246
247impl SqpOptions {
248    /// Whether a step QP of size `n + m = dim` uses Schur updates, by
249    /// [`Self::qp_schur_min_dim`] and [`Self::qp_schur_indefinite_max_dim`].
250    /// `warm` is the inertia hint of a QP warm-started from a working set,
251    /// `None` for a cold one, whose hint the rule does not read. `None` back
252    /// when the caller chose (`qp_schur_min_dim` unset), which leaves the QP
253    /// options as given.
254    pub fn step_qp_schur_updates(&self, dim: usize, warm: Option<HessianInertia>) -> Option<bool> {
255        let min = self.qp_schur_min_dim?;
256        // `!= Psd`, as in the QP solver's own drop branch: `Unknown` resets on
257        // a drop just as `Indefinite` does.
258        let refactor = warm.is_some_and(|h| h != HessianInertia::Psd)
259            && self.qp_schur_indefinite_max_dim.is_some_and(|d| dim >= d);
260        Some(dim >= min && !refactor)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    /// gh#1083 / gh#1091: from the ceiling on, a warm-started step QP not
269    /// known PSD — an exact Hessian's, or the lifted L-BFGS one — is back on
270    /// the refactor path. A cold one keeps its updates (it is the
271    /// add-dominated case, and not seeded when not PSD), as does a PSD one,
272    /// and an explicit choice (`qp_schur_min_dim = None`) is left alone.
273    #[test]
274    fn warm_indefinite_step_qps_leave_schur_updates_off_at_size() {
275        use HessianInertia::{Indefinite, Psd, Unknown};
276        let o = SqpOptions::default();
277        let (lo, hi) = (
278            DEFAULT_QP_SCHUR_MIN_DIM,
279            DEFAULT_QP_SCHUR_INDEFINITE_MAX_DIM,
280        );
281        assert!(lo < hi);
282        for warm in [None, Some(Indefinite), Some(Psd), Some(Unknown)] {
283            let at = |d| o.step_qp_schur_updates(d, warm);
284            assert_eq!(at(lo - 1), Some(false), "{warm:?}");
285            assert_eq!(at(lo), Some(true), "{warm:?}");
286            assert_eq!(at(hi - 1), Some(true), "{warm:?}");
287        }
288        for h in [Indefinite, Unknown] {
289            assert_eq!(o.step_qp_schur_updates(hi, Some(h)), Some(false), "{h:?}");
290        }
291        assert_eq!(o.step_qp_schur_updates(hi, None), Some(true), "cold");
292        assert_eq!(o.step_qp_schur_updates(hi, Some(Psd)), Some(true));
293
294        let unset = SqpOptions {
295            qp_schur_indefinite_max_dim: None,
296            ..SqpOptions::default()
297        };
298        assert_eq!(
299            unset.step_qp_schur_updates(hi, Some(Indefinite)),
300            Some(true)
301        );
302        let chosen = SqpOptions {
303            qp_schur_min_dim: None,
304            ..SqpOptions::default()
305        };
306        assert_eq!(chosen.step_qp_schur_updates(hi, Some(Indefinite)), None);
307    }
308}