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 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}