Skip to main content

pounce_algorithm/sqp/
qp_assembly.rs

1//! Build a [`pounce_qp::QpProblem`] from the NLP linearization at
2//! the current SQP iterate `(x, λ_g)`.
3//!
4//! Standard SQP QP subproblem (Nocedal-Wright §18.1):
5//!
6//! ```text
7//!     min  ½ pᵀ ∇²L(x, λ) p + ∇f(x)ᵀ p
8//!     s.t.   bl_c ≤ c(x) + ∇c(x) p ≤ bu_c
9//!            xl − x ≤ p ≤ xu − x
10//! ```
11//!
12//! The QP's general bounds are shifted RHSs: `bl_qp = bl_c − c(x)`
13//! and `bu_qp = bu_c − c(x)` (treating equalities as
14//! `bl_c = bu_c = 0`). The QP's variable bounds are `xl − x` and
15//! `xu − x`, so the QP primal `p` directly equals the SQP step.
16//!
17//! `SqpQpData` owns the sparse storage and exposes a borrowed
18//! `QpProblem` view; this is the analog of
19//! `pounce_qp::ElasticReformulation::as_qp`.
20//!
21//! # Low-rank Hessians (gh#1054)
22//!
23//! A limited-memory Hessian `B = σI + Σᵢ signᵢ vᵢ vᵢᵀ` is dense, and handing
24//! it to the QP as a dense triplet costs `n²` storage and turns every sparse
25//! factorization dense — at `n = 15 000` the first QP of a collocation model
26//! did not finish. [`SqpQpData::build_low_rank`] instead *lifts* the QP with
27//! one extra variable per term, `wᵢ = vᵢᵀ p`, held by an equality row:
28//!
29//! ```text
30//!     min  ½ σ‖p‖² + ½ Σᵢ signᵢ wᵢ² + ∇f(x)ᵀ p
31//!     s.t.   wᵢ − vᵢᵀ p = 0                      (r rows, always active)
32//!            ... the original rows and bounds on p ...
33//! ```
34//!
35//! Its Hessian is diagonal and its extra rows are `r` dense rows of
36//! length `n`, so the KKT stays sparse; eliminating `w` gives back exactly
37//! `½ pᵀ B p`. The lifted Hessian is indefinite (the `−1` terms) while its
38//! restriction to the feasible set is `B`, positive definite, which is the
39//! case the QP's inertia control is built for, so the problem is posed as
40//! [`HessianInertia::Indefinite`]. The SQP never sees the lift:
41//! [`SqpQpData::lift_working_set`] and [`SqpQpData::unlift_solution`] map
42//! working sets and solutions across it at every QP call.
43
44use pounce_common::types::{Index, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF, Number};
45use pounce_linalg::triplet::{GenTMatrix, GenTMatrixSpace, SymTMatrix, SymTMatrixSpace};
46use pounce_qp::{BoundStatus, ConsStatus, HessianInertia, QpProblem, QpSolution, WorkingSet};
47use std::rc::Rc;
48
49/// Owned linearization data for a single SQP iteration.
50///
51/// `n` / `m` are always the **NLP's** dimensions and `a` its Jacobian. When
52/// the subproblem is lifted ([`Self::build_low_rank`]) the QP the solver
53/// sees is larger: `h`, `g`, `bl`, `bu`, `xl`, `xu` hold the lifted
54/// problem's data (lengths `n + r`, `m + r`), its first `n` / `m` entries
55/// being the unlifted ones, and the lifted constraint matrix lives in the
56/// private `lift`.
57pub struct SqpQpData {
58    pub n: usize,
59    pub m: usize,
60
61    pub h: SymTMatrix,
62    pub g: Vec<Number>,
63    pub a: GenTMatrix,
64    pub bl: Vec<Number>,
65    pub bu: Vec<Number>,
66    pub xl: Vec<Number>,
67    pub xu: Vec<Number>,
68    pub hessian_inertia: HessianInertia,
69    /// `max |∇²L_ij|` of the Hessian the step is computed against — the
70    /// dense one, not the lifted diagonal, so the scale-relative QP
71    /// tolerances do not depend on whether the QP was lifted.
72    pub hess_inf: Number,
73    lift: Option<Lift>,
74}
75
76/// The lifted constraint matrix of a low-rank subproblem.
77struct Lift {
78    /// Number of lifted variables / rows.
79    r: usize,
80    /// `[A 0; −Vᵀ I]`, `(m + r) × (n + r)`.
81    a: GenTMatrix,
82}
83
84/// Sparse-triplet view of a derivative matrix. Indices are
85/// 1-based per the pounce-linalg convention; values are owned.
86#[derive(Clone)]
87pub struct Triplet {
88    pub n_rows: usize,
89    pub n_cols: usize,
90    pub irow: Vec<Index>,
91    pub jcol: Vec<Index>,
92    pub vals: Vec<Number>,
93}
94
95impl SqpQpData {
96    /// Assemble from concrete linearization arrays at iterate `x`.
97    ///
98    /// * `grad_f` — `∇f(x)`, length `n`.
99    /// * `c_vals` — `c(x)`, length `m` (may contain inequality
100    ///   slack values too; convention is `bl_c[i] ≤ c[i] ≤ bu_c[i]`).
101    /// * `bl_c`, `bu_c` — original NLP constraint bounds.
102    /// * `xl_orig`, `xu_orig` — original NLP variable bounds.
103    /// * `jac_c` — `∇c(x)` triplet, `m × n`.
104    /// * `hess_lag` — `∇²L(x, λ_g)` triplet, `n × n` symmetric.
105    pub fn build(
106        x: &[Number],
107        grad_f: &[Number],
108        c_vals: &[Number],
109        bl_c: &[Number],
110        bu_c: &[Number],
111        xl_orig: &[Number],
112        xu_orig: &[Number],
113        jac_c: Triplet,
114        hess_lag: Triplet,
115        hessian_inertia: HessianInertia,
116    ) -> Self {
117        let n = grad_f.len();
118        let m = c_vals.len();
119        assert_eq!(x.len(), n);
120        assert_eq!(bl_c.len(), m);
121        assert_eq!(bu_c.len(), m);
122        assert_eq!(xl_orig.len(), n);
123        assert_eq!(xu_orig.len(), n);
124        assert_eq!(jac_c.n_rows, m);
125        assert_eq!(jac_c.n_cols, n);
126        assert_eq!(hess_lag.n_rows, n);
127        assert_eq!(hess_lag.n_cols, n);
128
129        let h_space = SymTMatrixSpace::new(n as Index, hess_lag.irow, hess_lag.jcol);
130        let mut h = SymTMatrix::new(Rc::clone(&h_space));
131        h.set_values(&hess_lag.vals);
132
133        let a_space = GenTMatrixSpace::new(m as Index, n as Index, jac_c.irow, jac_c.jcol);
134        let mut a = GenTMatrix::new(Rc::clone(&a_space));
135        a.set_values(&jac_c.vals);
136
137        // QP general bounds: bl_c − c(x), bu_c − c(x) — but
138        // preserve ±∞ markers so the QP solver's one-sided
139        // ratio test still treats them as unbounded.
140        let mut bl = Vec::with_capacity(m);
141        let mut bu = Vec::with_capacity(m);
142        for i in 0..m {
143            bl.push(shift_bound(bl_c[i], c_vals[i], true));
144            bu.push(shift_bound(bu_c[i], c_vals[i], false));
145        }
146
147        // QP step bounds: xl_orig − x, xu_orig − x.
148        let mut xl = Vec::with_capacity(n);
149        let mut xu = Vec::with_capacity(n);
150        for i in 0..n {
151            xl.push(shift_bound(xl_orig[i], x[i], true));
152            xu.push(shift_bound(xu_orig[i], x[i], false));
153        }
154
155        let hess_inf = h.values().iter().map(|v| v.abs()).fold(0.0, f64::max);
156        Self {
157            n,
158            m,
159            h,
160            g: grad_f.to_vec(),
161            a,
162            bl,
163            bu,
164            xl,
165            xu,
166            hessian_inertia,
167            hess_inf,
168            lift: None,
169        }
170    }
171
172    /// Assemble the subproblem for a low-rank Hessian `B = σI + Σ ±vvᵀ`
173    /// without forming `B` — see the module docs. Same arguments as
174    /// [`Self::build`] otherwise.
175    #[allow(clippy::too_many_arguments)]
176    pub fn build_low_rank(
177        x: &[Number],
178        grad_f: &[Number],
179        c_vals: &[Number],
180        bl_c: &[Number],
181        bu_c: &[Number],
182        xl_orig: &[Number],
183        xu_orig: &[Number],
184        jac_c: Triplet,
185        b: &crate::sqp::lbfgs::LowRankHessian,
186    ) -> Self {
187        let n = grad_f.len();
188        let m = c_vals.len();
189        let r = b.vecs.len();
190        let diag = Triplet {
191            n_rows: n,
192            n_cols: n,
193            irow: (1..=n as Index).collect(),
194            jcol: (1..=n as Index).collect(),
195            vals: vec![b.sigma; n],
196        };
197        // The unlifted pieces, built exactly as `build` would.
198        let mut out = Self::build(
199            x,
200            grad_f,
201            c_vals,
202            bl_c,
203            bu_c,
204            xl_orig,
205            xu_orig,
206            jac_c.clone(),
207            diag,
208            HessianInertia::Psd,
209        );
210        out.hess_inf = b.max_abs_entry();
211        if r == 0 {
212            // `B = σI` is already sparse; nothing to lift.
213            return out;
214        }
215
216        let (nq, mq) = ((n + r) as Index, (m + r) as Index);
217        // H = diag(σ I_n, sign_1 … sign_r).
218        let h_space = SymTMatrixSpace::new(nq, (1..=nq).collect(), (1..=nq).collect());
219        let mut h = SymTMatrix::new(h_space);
220        let mut h_vals = vec![b.sigma; n];
221        h_vals.extend_from_slice(&b.signs);
222        h.set_values(&h_vals);
223
224        // A = [J 0; −Vᵀ I]. Exact zeros of `v` are left out of the pattern.
225        let mut irow = jac_c.irow;
226        let mut jcol = jac_c.jcol;
227        let mut vals = jac_c.vals;
228        for (i, v) in b.vecs.iter().enumerate() {
229            let row = (m + i + 1) as Index;
230            for (j, &vj) in v.iter().enumerate() {
231                if vj != 0.0 {
232                    irow.push(row);
233                    jcol.push((j + 1) as Index);
234                    vals.push(-vj);
235                }
236            }
237            irow.push(row);
238            jcol.push((n + i + 1) as Index);
239            vals.push(1.0);
240        }
241        let a_space = GenTMatrixSpace::new(mq, nq, irow, jcol);
242        let mut a = GenTMatrix::new(a_space);
243        a.set_values(&vals);
244
245        out.h = h;
246        out.g.resize(n + r, 0.0);
247        out.bl.resize(m + r, 0.0);
248        out.bu.resize(m + r, 0.0);
249        out.xl.resize(n + r, NLP_LOWER_BOUND_INF);
250        out.xu.resize(n + r, NLP_UPPER_BOUND_INF);
251        out.hessian_inertia = HessianInertia::Indefinite;
252        out.lift = Some(Lift { r, a });
253        out
254    }
255
256    /// Borrowed `QpProblem` view ready for
257    /// `pounce_qp::QpSolver::solve`.
258    pub fn as_qp(&self) -> QpProblem<'_> {
259        self.as_qp_with_row_bounds(&self.bl, &self.bu)
260    }
261
262    /// [`Self::as_qp`] with replacement general-row bounds (the
263    /// second-order correction re-centres them). `bl` / `bu` must have
264    /// [`Self::bl`]'s length, lifted rows included.
265    pub fn as_qp_with_row_bounds<'a>(
266        &'a self,
267        bl: &'a [Number],
268        bu: &'a [Number],
269    ) -> QpProblem<'a> {
270        let r = self.lift.as_ref().map_or(0, |l| l.r);
271        QpProblem {
272            n: self.n + r,
273            m: self.m + r,
274            h: &self.h,
275            g: &self.g,
276            a: self.lift.as_ref().map_or(&self.a, |l| &l.a),
277            bl,
278            bu,
279            xl: &self.xl,
280            xu: &self.xu,
281            hessian_inertia: self.hessian_inertia,
282        }
283    }
284
285    /// Whether the QP the solver sees is lifted.
286    pub fn is_lifted(&self) -> bool {
287        self.lift.is_some()
288    }
289
290    /// A working set in the NLP's dimensions, extended for the lifted QP:
291    /// the lifted rows are equalities, the lifted variables free.
292    pub fn lift_working_set(&self, w: &WorkingSet) -> WorkingSet {
293        let mut w = w.clone();
294        if let Some(l) = &self.lift {
295            w.bounds.truncate(self.n);
296            w.constraints.truncate(self.m);
297            w.bounds.resize(self.n + l.r, BoundStatus::Inactive);
298            w.constraints.resize(self.m + l.r, ConsStatus::Equality);
299        }
300        w
301    }
302
303    /// A QP solution back in the NLP's dimensions: the lifted variables'
304    /// values and the lifted rows' multipliers are dropped. Exact, not an
305    /// approximation: the lifted rows only define `w`, so the step `p` and
306    /// the multipliers of the original rows and bounds are those of the
307    /// unlifted QP.
308    pub fn unlift_solution(&self, mut sol: QpSolution) -> QpSolution {
309        if self.lift.is_some() {
310            sol.x.truncate(self.n);
311            sol.lambda_g.truncate(self.m);
312            sol.lambda_x.truncate(self.n);
313            sol.working.bounds.truncate(self.n);
314            sol.working.constraints.truncate(self.m);
315            if let Some(ray) = sol.unbounded_ray.as_mut() {
316                ray.truncate(self.n);
317            }
318        }
319        sol
320    }
321}
322
323/// Shift a bound by subtracting the current value. Preserves
324/// `NLP_*_BOUND_INF` sentinels so the QP solver's `is_finite`
325/// checks still trigger correctly.
326fn shift_bound(bound: Number, current: Number, is_lower: bool) -> Number {
327    if is_lower {
328        if bound <= NLP_LOWER_BOUND_INF {
329            NLP_LOWER_BOUND_INF
330        } else {
331            bound - current
332        }
333    } else if bound >= NLP_UPPER_BOUND_INF {
334        NLP_UPPER_BOUND_INF
335    } else {
336        bound - current
337    }
338}