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