pounce_algorithm/sqp/warm_start.rs
1//! Warm-start helpers — building a [`pounce_qp::WorkingSet`] from
2//! a converged IPM (or any) iterate so the next SQP solve can pick
3//! up where the IPM left off (Phase 5c §7.5 + sensitivity
4//! corrector handoff).
5//!
6//! The classifier is the **multiplier-sign + primal-distance**
7//! heuristic standard in mixed IPM/SQP warm-start pipelines
8//! (Wächter-Biegler 2006 §6; Forsgren-Gill-Wright 2002 §5). It is
9//! intentionally lossy at degenerate active sets — the QP solver
10//! will detect and correct any misclassification in the first
11//! step of the next QP, so correctness is preserved.
12
13use pounce_common::Number;
14use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
15use pounce_qp::{BoundStatus, ConsStatus, WorkingSet};
16
17/// Classify the active set at iterate `(x, λ_x, λ_g)` against the
18/// supplied bounds and constraint-bound vectors.
19///
20/// Inputs:
21/// - `lambda_x`: packed signed bound multipliers (`z_l − z_u`) of
22/// length `n`. Positive ⇒ lower bound active; negative ⇒ upper.
23/// - `lambda_g`: stacked constraint multipliers `[y_c ; y_d]` of
24/// length `m = m_eq + m_ineq`. The **opposite** sign convention to
25/// `lambda_x`: negative ⇒ lower bound active, positive ⇒ upper. Both
26/// follow from the one stationarity form `∇f + Jᵀλ_g − λ_x = 0` that
27/// pounce-qp, the SQP driver's `check_kkt`, and IPOPT's `(y, z_l, z_u)`
28/// all share; the bound block enters it negated, so its signs flip.
29/// - `m_eq`: number of leading rows to flag [`ConsStatus::Equality`]
30/// without consulting `g_l`/`g_u` — the SQP's internal
31/// equalities-first order. For rows in a **model's** own `g` order
32/// (what `finalize_solution` reports and
33/// `IpoptApplication::set_sqp_warm_start` takes), pass `0`: a row
34/// with `g_l == g_u` is classified `Equality` from its bounds anyway,
35/// wherever it sits, and the result is then in that same row order.
36/// - `x`, `x_l`, `x_u`: primal iterate and variable bounds, length
37/// `n`. The bound-classifier double-checks the primal is close
38/// to the bound (within `primal_tol`) — guards against the case
39/// where a multiplier is large but the primal hasn't actually
40/// reached the bound (e.g. near-degenerate KKT or a bad
41/// multiplier estimate).
42/// - `g`, `g_l`, `g_u`: constraint values and bounds, length `m`.
43/// Used identically for constraint rows.
44/// - `mult_tol`: multiplier-magnitude threshold; a row whose
45/// `|λ|` falls below this is classified as `Inactive`
46/// regardless of primal distance.
47/// - `primal_tol`: distance threshold between `x[i]` and `x_l[i]`
48/// / `x_u[i]` (resp. `g[i]` vs `g_l[i]` / `g_u[i]`) below which
49/// a row is treated as "at the bound".
50///
51/// Variable bounds with `x_l[i] == x_u[i]` are classified
52/// [`BoundStatus::Fixed`]; constraint rows in the first `m_eq`
53/// slots are [`ConsStatus::Equality`]. Both are unconditionally
54/// active.
55#[allow(clippy::too_many_arguments)]
56pub fn classify_working_set(
57 lambda_x: &[Number],
58 lambda_g: &[Number],
59 m_eq: usize,
60 x: &[Number],
61 x_l: &[Number],
62 x_u: &[Number],
63 g: &[Number],
64 g_l: &[Number],
65 g_u: &[Number],
66 mult_tol: Number,
67 primal_tol: Number,
68) -> WorkingSet {
69 let n = lambda_x.len();
70 let m = lambda_g.len();
71 debug_assert_eq!(x.len(), n);
72 debug_assert_eq!(x_l.len(), n);
73 debug_assert_eq!(x_u.len(), n);
74 debug_assert_eq!(g.len(), m);
75 debug_assert_eq!(g_l.len(), m);
76 debug_assert_eq!(g_u.len(), m);
77 debug_assert!(m_eq <= m);
78
79 // Bound-finiteness uses the same `NLP_*_BOUND_INF` sentinels
80 // pounce uses everywhere else (default ±1e19). Naive
81 // `.is_finite()` would falsely include `−1e19` as a real lower
82 // bound and tag any unbounded variable at that value as
83 // `AtLower` (PR #50 review A4).
84 let mut bounds = Vec::with_capacity(n);
85 for i in 0..n {
86 let lo_fin = x_l[i] > NLP_LOWER_BOUND_INF;
87 let up_fin = x_u[i] < NLP_UPPER_BOUND_INF;
88 if lo_fin && up_fin && (x_u[i] - x_l[i]).abs() < primal_tol {
89 bounds.push(BoundStatus::Fixed);
90 continue;
91 }
92 let mu = lambda_x[i];
93 let at_lo = lo_fin && (x[i] - x_l[i]).abs() < primal_tol;
94 let at_up = up_fin && (x_u[i] - x[i]).abs() < primal_tol;
95 let status = if mu > mult_tol && at_lo {
96 BoundStatus::AtLower
97 } else if mu < -mult_tol && at_up {
98 BoundStatus::AtUpper
99 } else if at_lo && mu >= 0.0 {
100 BoundStatus::AtLower
101 } else if at_up && mu <= 0.0 {
102 BoundStatus::AtUpper
103 } else {
104 BoundStatus::Inactive
105 };
106 bounds.push(status);
107 }
108
109 let mut constraints = Vec::with_capacity(m);
110 for i in 0..m {
111 if i < m_eq {
112 constraints.push(ConsStatus::Equality);
113 continue;
114 }
115 let lo_fin = g_l[i] > NLP_LOWER_BOUND_INF;
116 let up_fin = g_u[i] < NLP_UPPER_BOUND_INF;
117 if lo_fin && up_fin && (g_u[i] - g_l[i]).abs() < primal_tol {
118 constraints.push(ConsStatus::Equality);
119 continue;
120 }
121 // Constraint-row multipliers carry the OPPOSITE sign to bound
122 // multipliers, because they enter stationarity with the opposite
123 // sign: `Hx + g + Aᵀλ_g − λ_x = 0`. So `λ_g ≤ 0` at an active lower
124 // bound and `λ_g ≥ 0` at an active upper bound — the reverse of the
125 // bound rules above, and pinned by
126 // `classify_matches_pounce_qp_row_sign_convention` below.
127 //
128 // This block read the bound signs until gh#612. That is lossy rather
129 // than wrong — a row the estimate calls `Inactive` is simply one the
130 // QP has to re-add on its first pivot, and the returned solution is
131 // unaffected — which is why nothing caught it: no test asserts a
132 // working-set *estimate*, only the solutions it warm-starts. It
133 // matters here because crossover's whole purpose is to hand the
134 // active-set path an estimate that is already right (KNITRO §7 step
135 // 2), and the old rules classified every active inequality row as
136 // inactive.
137 let mu = lambda_g[i];
138 let at_lo = lo_fin && (g[i] - g_l[i]).abs() < primal_tol;
139 let at_up = up_fin && (g_u[i] - g[i]).abs() < primal_tol;
140 let status = if mu < -mult_tol && at_lo {
141 ConsStatus::AtLower
142 } else if mu > mult_tol && at_up {
143 ConsStatus::AtUpper
144 } else if at_lo && mu <= 0.0 {
145 ConsStatus::AtLower
146 } else if at_up && mu >= 0.0 {
147 ConsStatus::AtUpper
148 } else {
149 ConsStatus::Inactive
150 };
151 constraints.push(status);
152 }
153
154 WorkingSet {
155 bounds,
156 constraints,
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn classify_treats_nlp_bound_inf_sentinel_as_unbounded() {
166 // PR #50 review A4 regression. Variables with `x_l =
167 // NLP_LOWER_BOUND_INF` (the −1e19 sentinel) are unbounded
168 // below; even a primal value at exactly that sentinel must
169 // be tagged `Inactive`, not `AtLower`. Prior to the fix
170 // `is_finite()` would treat `−1e19` as a real bound.
171 let ws = classify_working_set(
172 &[0.0],
173 &[],
174 0,
175 &[-1.0e19],
176 &[NLP_LOWER_BOUND_INF],
177 &[NLP_UPPER_BOUND_INF],
178 &[],
179 &[],
180 &[],
181 1e-8,
182 1e-6,
183 );
184 assert_eq!(ws.bounds[0], BoundStatus::Inactive);
185 }
186
187 #[test]
188 fn classify_all_inactive_when_strictly_interior() {
189 // 1-D unconstrained, x* in the interior, no multipliers.
190 let ws = classify_working_set(
191 &[0.0],
192 &[],
193 0,
194 &[0.5],
195 &[-1.0],
196 &[1.0],
197 &[],
198 &[],
199 &[],
200 1e-8,
201 1e-8,
202 );
203 assert_eq!(ws.bounds[0], BoundStatus::Inactive);
204 assert!(ws.constraints.is_empty());
205 }
206
207 #[test]
208 fn classify_lower_bound_active_when_primal_at_bound_and_mult_positive() {
209 let ws = classify_working_set(
210 &[2.0],
211 &[],
212 0,
213 &[0.0],
214 &[0.0],
215 &[1.0],
216 &[],
217 &[],
218 &[],
219 1e-8,
220 1e-8,
221 );
222 assert_eq!(ws.bounds[0], BoundStatus::AtLower);
223 }
224
225 #[test]
226 fn classify_upper_bound_active_when_primal_at_bound_and_mult_negative() {
227 let ws = classify_working_set(
228 &[-2.0],
229 &[],
230 0,
231 &[1.0],
232 &[0.0],
233 &[1.0],
234 &[],
235 &[],
236 &[],
237 1e-8,
238 1e-8,
239 );
240 assert_eq!(ws.bounds[0], BoundStatus::AtUpper);
241 }
242
243 #[test]
244 fn classify_fixed_when_bounds_equal() {
245 let ws = classify_working_set(
246 &[0.0],
247 &[],
248 0,
249 &[2.0],
250 &[2.0],
251 &[2.0],
252 &[],
253 &[],
254 &[],
255 1e-8,
256 1e-8,
257 );
258 assert_eq!(ws.bounds[0], BoundStatus::Fixed);
259 }
260
261 #[test]
262 fn classify_equality_constraint_always_active() {
263 // 1 eq constraint at row 0, no ineqs.
264 let ws = classify_working_set(
265 &[],
266 &[1.0],
267 1,
268 &[],
269 &[],
270 &[],
271 &[5.0],
272 &[5.0],
273 &[5.0],
274 1e-8,
275 1e-8,
276 );
277 assert_eq!(ws.constraints[0], ConsStatus::Equality);
278 }
279
280 #[test]
281 fn classify_inequality_at_lower_bound() {
282 // λ_g ≤ 0 at an active lower bound — the row convention, opposite to
283 // the bound convention two tests up. See
284 // `classify_matches_pounce_qp_row_sign_convention`.
285 let ws = classify_working_set(
286 &[],
287 &[-3.0],
288 0,
289 &[],
290 &[],
291 &[],
292 &[1.0],
293 &[1.0],
294 &[10.0],
295 1e-8,
296 1e-8,
297 );
298 assert_eq!(ws.constraints[0], ConsStatus::AtLower);
299 }
300
301 #[test]
302 fn classify_inequality_at_upper_bound() {
303 let ws = classify_working_set(
304 &[],
305 &[3.0],
306 0,
307 &[],
308 &[],
309 &[],
310 &[10.0],
311 &[0.0],
312 &[10.0],
313 1e-8,
314 1e-8,
315 );
316 assert_eq!(ws.constraints[0], ConsStatus::AtUpper);
317 }
318
319 /// Anchor the classifier's row-sign convention to the engine that
320 /// consumes its output, rather than to a hand-written expectation.
321 ///
322 /// The classifier exists to hand `pounce-qp` a working set built from
323 /// someone else's multipliers, so "which sign means AtLower" is not ours
324 /// to choose — it is whatever `pounce-qp` returns. Asserting a literal
325 /// (`-3.0 ⇒ AtLower`) restates a belief; solving the QP and feeding its
326 /// own multipliers back through the classifier tests the agreement, and
327 /// fails if either side's convention moves. gh#612: the two had silently
328 /// disagreed on rows since the classifier was written.
329 #[test]
330 fn classify_matches_pounce_qp_row_sign_convention() {
331 use pounce_linalg::triplet::{GenTMatrix, GenTMatrixSpace, SymTMatrix, SymTMatrixSpace};
332 use pounce_qp::{HessianInertia, QpOptions, QpProblem, QpSolver};
333
334 // min ½‖x‖² s.t. x₀ + x₁ ≥ 2. Solution x = (1,1) with the row
335 // active at its lower bound.
336 let n = 2usize;
337 let mut h = SymTMatrix::new(SymTMatrixSpace::new(2, vec![1, 2], vec![1, 2]));
338 h.set_values(&[1.0, 1.0]);
339 let mut a = GenTMatrix::new(GenTMatrixSpace::new(1, 2, vec![1, 1], vec![1, 2]));
340 a.set_values(&[1.0, 1.0]);
341 let g = [0.0, 0.0];
342 let bl = [2.0];
343 let bu = [NLP_UPPER_BOUND_INF];
344 let xl = [NLP_LOWER_BOUND_INF; 2];
345 let xu = [NLP_UPPER_BOUND_INF; 2];
346 let qp = QpProblem {
347 n,
348 m: 1,
349 h: &h,
350 g: &g,
351 a: &a,
352 bl: &bl,
353 bu: &bu,
354 xl: &xl,
355 xu: &xu,
356 hessian_inertia: HessianInertia::Psd,
357 };
358 let mut solver = pounce_qp::ParametricActiveSetSolver::new(Box::new(
359 pounce_feral::FeralSolverInterface::new(),
360 ));
361 let sol = solver
362 .solve(&qp, None, &QpOptions::default())
363 .expect("QP solve");
364 assert_eq!(sol.working.constraints[0], ConsStatus::AtLower);
365
366 // Now the actual claim: re-deriving the working set from the
367 // solution the engine returned reproduces the engine's own labels.
368 let g_vals = [sol.x[0] + sol.x[1]];
369 let ws = classify_working_set(
370 &sol.lambda_x,
371 &sol.lambda_g,
372 0,
373 &sol.x,
374 &xl,
375 &xu,
376 &g_vals,
377 &bl,
378 &bu,
379 1e-8,
380 1e-6,
381 );
382 assert_eq!(
383 ws.constraints, sol.working.constraints,
384 "classifier disagrees with pounce-qp on row activity \
385 (λ_g = {:?})",
386 sol.lambda_g
387 );
388 assert_eq!(ws.bounds, sol.working.bounds);
389 }
390
391 #[test]
392 fn classify_inactive_when_primal_off_bound_despite_large_multiplier() {
393 // Bound multiplier is large but primal is mid-range —
394 // tag as Inactive, not AtLower. This guards against
395 // stale-multiplier carry from a slightly mis-aligned
396 // perturbation.
397 let ws = classify_working_set(
398 &[2.0],
399 &[],
400 0,
401 &[0.5],
402 &[0.0],
403 &[1.0],
404 &[],
405 &[],
406 &[],
407 1e-8,
408 1e-8,
409 );
410 assert_eq!(ws.bounds[0], BoundStatus::Inactive);
411 }
412}