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}