Skip to main content

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}