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}