pounce_algorithm/sqp/bfgs.rs
1//! Powell-damped BFGS Hessian approximation for SQP (Powell
2//! 1978, *Numerical Analysis Dundee 1977*). Used when
3//! `SqpOptions::hessian = DampedBfgs` — the QP subproblem's
4//! Hessian comes from this rank-2-updated matrix instead of
5//! `nlp.eval_hess_lag`.
6//!
7//! Powell's damping rule guarantees positive-definiteness of
8//! every iterate, so the QP solver doesn't have to engage
9//! inertia control to keep `∇²L`-quadratic models PD. The
10//! damping factor `θ ∈ [0, 1]` interpolates between the raw
11//! BFGS `y = ∇L_new − ∇L_old` and the conservative `B·s`:
12//!
13//! ```text
14//! if sᵀy ≥ 0.2 · sᵀ B s : θ = 1 (standard BFGS)
15//! else : θ = 0.8 · sᵀ B s / (sᵀ B s − sᵀy)
16//! y_damp = θ y + (1 − θ) B s
17//! B_new = B − (Bs · sᵀB) / (sᵀ B s)
18//! + (y_damp · y_dampᵀ) / (sᵀ y_damp)
19//! ```
20//!
21//! Storage is dense `n × n` (lower-triangle row-major); exposed
22//! to `pounce-qp` as a fully-populated [`Triplet`] over the upper
23//! triangle (1-based row/col).
24
25use crate::sqp::qp_assembly::Triplet;
26use pounce_common::types::{Index, Number};
27
28pub struct DampedBfgs {
29 n: usize,
30 /// Lower-triangle row-major storage:
31 /// `b[i*(i+1)/2 + j] = B[i, j]` for `i ≥ j`.
32 b: Vec<Number>,
33 /// Previous `x` and ∇L; updated at the end of each `update` call.
34 prev_x: Option<Vec<Number>>,
35 prev_grad_lag: Option<Vec<Number>>,
36 /// Whether the one-time initial sizing has been applied yet (see
37 /// [`Self::update`]). `false` until the first `(s, y)` pair with
38 /// `sᵀy > 0` arrives, at which point `B` is rescaled from the
39 /// identity to `γI` and this flips to `true`.
40 sized: bool,
41 /// Pre-computed sparsity pattern for `as_triplet`. Fixed:
42 /// every (i, j) with `i ≥ j`. 1-based.
43 h_irow: Vec<Index>,
44 h_jcol: Vec<Index>,
45}
46
47impl DampedBfgs {
48 pub fn new(n: usize) -> Self {
49 let nz = n * (n + 1) / 2;
50 let mut b = vec![0.0; nz];
51 let mut h_irow = Vec::with_capacity(nz);
52 let mut h_jcol = Vec::with_capacity(nz);
53 for i in 0..n {
54 for j in 0..=i {
55 if i == j {
56 b[i * (i + 1) / 2 + j] = 1.0;
57 }
58 h_irow.push((i + 1) as Index);
59 h_jcol.push((j + 1) as Index);
60 }
61 }
62 Self {
63 n,
64 b,
65 prev_x: None,
66 prev_grad_lag: None,
67 sized: false,
68 h_irow,
69 h_jcol,
70 }
71 }
72
73 /// Have we recorded a previous `(x, ∇L)`? `false` until the
74 /// first call to [`Self::update`].
75 pub fn has_prev(&self) -> bool {
76 self.prev_x.is_some()
77 }
78
79 /// Seed `B = γI` directly and mark the one-time sizing done, so the
80 /// first [`Self::update`] applies its rank-2 correction on top of
81 /// this scale instead of re-seeding from its own `(s, y)`.
82 ///
83 /// Used by the driver's iteration-0 curvature probe: the internal
84 /// sizing in [`Self::update`] cannot fire until a first `(s, y)` pair
85 /// exists, i.e. not until iteration 1 — but iteration **0** already
86 /// solves a QP against `B`, and with the identity seed that step
87 /// overshoots by `~cond(∇²L)` on an ill-conditioned problem. See the
88 /// sizing comment in [`Self::update`] for why that is fatal.
89 ///
90 /// `gamma` must be finite and strictly positive; anything else is
91 /// ignored (leaving `B = I`) rather than corrupting the matrix.
92 pub fn seed_scale(&mut self, gamma: Number) {
93 if !gamma.is_finite() || gamma <= 0.0 {
94 return;
95 }
96 for i in 0..self.n {
97 self.set(i, i, gamma);
98 }
99 self.sized = true;
100 }
101
102 /// Discard the accumulated rank-2 curvature and fall back to a
103 /// scaled identity `γI`, where `γ` is the current mean diagonal
104 /// (a scale the accumulated matrix has already vouched for).
105 /// `prev_x` / `prev_grad_lag` are retained, so the next
106 /// [`Self::update`] resumes accumulating from the reset base.
107 ///
108 /// Used as a recovery step when the QP subproblem fails: a
109 /// quasi-Newton matrix that has drifted ill-conditioned makes the
110 /// step subproblem numerically unsolvable, and that is recoverable
111 /// — far better than aborting an otherwise healthy solve.
112 /// Off-diagonals are zeroed; the diagonal keeps the problem's scale.
113 pub fn reset_to_scale(&mut self) {
114 let mut sum = 0.0;
115 let mut count = 0usize;
116 for i in 0..self.n {
117 let d = self.get(i, i);
118 if d.is_finite() && d > 0.0 {
119 sum += d;
120 count += 1;
121 }
122 }
123 let gamma = if count > 0 {
124 sum / count as Number
125 } else {
126 1.0
127 };
128 let gamma = if gamma.is_finite() && gamma > 0.0 {
129 gamma
130 } else {
131 1.0
132 };
133 for v in self.b.iter_mut() {
134 *v = 0.0;
135 }
136 for i in 0..self.n {
137 self.set(i, i, gamma);
138 }
139 }
140
141 fn idx(&self, i: usize, j: usize) -> usize {
142 debug_assert!(i < self.n && j < self.n);
143 let (lo, hi) = if i >= j { (j, i) } else { (i, j) };
144 hi * (hi + 1) / 2 + lo
145 }
146
147 fn get(&self, i: usize, j: usize) -> Number {
148 self.b[self.idx(i, j)]
149 }
150
151 fn set(&mut self, i: usize, j: usize, v: Number) {
152 let k = self.idx(i, j);
153 self.b[k] = v;
154 }
155
156 /// Apply the Powell-damped BFGS update from the previous
157 /// `(x_old, ∇L_old)` to the supplied `(x_new, ∇L_new)`. The
158 /// first call just stores the pair; subsequent calls also
159 /// modify `B`.
160 pub fn update(&mut self, x_new: &[Number], grad_lag_new: &[Number]) {
161 // Hard assert (PR #50 review S5): a length mismatch here
162 // would silently mis-compute the rank-2 update in release
163 // builds with debug_assert.
164 assert_eq!(x_new.len(), self.n, "BFGS::update: x_new.len() != n");
165 assert_eq!(
166 grad_lag_new.len(),
167 self.n,
168 "BFGS::update: grad_lag_new.len() != n"
169 );
170
171 if let (Some(prev_x), Some(prev_grad_lag)) = (self.prev_x.take(), self.prev_grad_lag.take())
172 {
173 let s: Vec<Number> = x_new
174 .iter()
175 .zip(prev_x.iter())
176 .map(|(a, b)| a - b)
177 .collect();
178 let y: Vec<Number> = grad_lag_new
179 .iter()
180 .zip(prev_grad_lag.iter())
181 .map(|(a, b)| a - b)
182 .collect();
183 self.update_sy(&s, &y);
184 }
185
186 self.prev_x = Some(x_new.to_vec());
187 self.prev_grad_lag = Some(grad_lag_new.to_vec());
188 }
189
190 /// Apply the Powell-damped rank-2 update from an explicit curvature
191 /// pair `(s, y)`.
192 ///
193 /// Prefer this over [`Self::update`] when the caller can form `y`
194 /// itself: the SQP driver must difference `∇L` at a **single, fixed**
195 /// multiplier (see the note in `sqp_alg.rs`), which the `(x, ∇L)` form
196 /// of [`Self::update`] cannot express because it stores the previous
197 /// `∇L` as evaluated at the previous multiplier.
198 pub fn update_sy(&mut self, s: &[Number], y: &[Number]) {
199 assert_eq!(s.len(), self.n, "BFGS::update_sy: s.len() != n");
200 assert_eq!(y.len(), self.n, "BFGS::update_sy: y.len() != n");
201 {
202 // One-time initial Hessian sizing (Nocedal-Wright §6.1). The
203 // identity seed `B_0 = I` is a catastrophic scale on
204 // ill-conditioned problems: when `‖∇²L‖ ≫ 1` the first QP
205 // step, computed against `B = I`, overshoots the true Newton
206 // step by a factor `~cond(∇²L)`, and the filter line search —
207 // with an empty filter at the first iterate, where the
208 // starting point is near-feasible so `θ_curr` is tiny — accepts
209 // the objective-blowing step because it happens to drive the
210 // (negligible) constraint violation to zero. The overshoot
211 // corrupts the working set and the solve then diverges to
212 // `‖x‖ ~ 1e4` before dying with
213 // `Search_Direction_Becomes_Too_Small`, on easy convex QPs
214 // (issue #358 tail: `cond(P) ≳ 1e3`). Before applying the very
215 // first rank-2 update, rescale `B` from `I` to `γI` with the
216 // Rayleigh-quotient curvature estimate `γ = sᵀy / sᵀs`, which
217 // for a quadratic lies in `[λ_min(∇²L), λ_max(∇²L)]` — a
218 // representative scale that keeps the first post-sizing step
219 // in range. Applied *once* on the first curvature pair, not
220 // every iteration: re-seeding each step would discard the
221 // curvature the persistent damped update accumulates (that
222 // re-seeding is exactly what makes the L-BFGS path oscillate).
223 // Halves the ill-conditioned-QP failure rate on a broad sweep
224 // and clears the #358 tail; a fully robust cure for extreme
225 // conditioning (`cond ≳ 1e4`) still needs the exact-Hessian or
226 // IPM path.
227 if !self.sized {
228 let s_y: Number = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
229 let s_s: Number = s.iter().map(|v| v * v).sum();
230 if s_y > 1e-30 && s_s > 1e-30 {
231 let gamma = s_y / s_s;
232 for i in 0..self.n {
233 self.set(i, i, gamma);
234 }
235 }
236 // Mark sized once a genuine pair is seen, even if the
237 // ratio was degenerate (leave B = I in that rare case) —
238 // we only ever size on the first curvature pair.
239 self.sized = true;
240 }
241
242 // bs = B · s
243 let bs: Vec<Number> = (0..self.n)
244 .map(|i| (0..self.n).map(|j| self.get(i, j) * s[j]).sum())
245 .collect();
246
247 let s_bs: Number = s.iter().zip(bs.iter()).map(|(a, b)| a * b).sum();
248 let s_y: Number = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
249
250 // Powell damping.
251 let theta = if s_y >= 0.2 * s_bs {
252 1.0
253 } else if s_bs - s_y > 1e-14 {
254 0.8 * s_bs / (s_bs - s_y)
255 } else {
256 // Pathological — fall back to the unmodified
257 // identity update (no harm done).
258 1.0
259 };
260 let y_damp: Vec<Number> = y
261 .iter()
262 .zip(bs.iter())
263 .map(|(yi, bsi)| theta * yi + (1.0 - theta) * bsi)
264 .collect();
265 let s_y_damp: Number = s.iter().zip(y_damp.iter()).map(|(a, b)| a * b).sum();
266
267 if s_bs > 1e-14 && s_y_damp > 1e-14 {
268 for i in 0..self.n {
269 for j in 0..=i {
270 let new_val = self.get(i, j) - (bs[i] * bs[j]) / s_bs
271 + (y_damp[i] * y_damp[j]) / s_y_damp;
272 self.set(i, j, new_val);
273 }
274 }
275 }
276 }
277 }
278
279 /// Produce the current B as a `Triplet` over the upper
280 /// triangle (1-based), ready to feed into `SqpQpData::build`.
281 pub fn as_triplet(&self) -> Triplet {
282 let mut vals = Vec::with_capacity(self.h_irow.len());
283 for i in 0..self.n {
284 for j in 0..=i {
285 vals.push(self.get(i, j));
286 }
287 }
288 Triplet {
289 n_rows: self.n,
290 n_cols: self.n,
291 irow: self.h_irow.clone(),
292 jcol: self.h_jcol.clone(),
293 vals,
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn diag(b: &DampedBfgs, i: usize) -> Number {
303 b.get(i, i)
304 }
305
306 #[test]
307 fn first_update_sizes_the_identity_seed() {
308 // Curvature pair along axis 0 from the quadratic f = ½·9‖x‖²
309 // (∇²f = 9I): s = (1, 0), y = 9 s = (9, 0), so the sizing
310 // factor is γ = sᵀy / sᵀs = 9. After the first update the
311 // *untouched* direction (axis 1) must carry the sized scale
312 // γ = 9, not the identity seed 1 — that is the whole point of
313 // sizing on an ill-conditioned problem (issue #358). Along the
314 // updated axis the Powell-damped rank-2 term keeps it at 9 too.
315 let mut b = DampedBfgs::new(2);
316 b.update(&[0.0, 0.0], &[0.0, 0.0]); // record start (no pair yet)
317 assert!((diag(&b, 0) - 1.0).abs() < 1e-12, "seed must be I");
318 assert!((diag(&b, 1) - 1.0).abs() < 1e-12, "seed must be I");
319 b.update(&[1.0, 0.0], &[9.0, 0.0]); // first genuine (s, y): sizes then updates
320 assert!(
321 (diag(&b, 1) - 9.0).abs() < 1e-9,
322 "off-axis diagonal should be sized to γ = 9, got {}",
323 diag(&b, 1)
324 );
325 assert!(
326 (diag(&b, 0) - 9.0).abs() < 1e-9,
327 "on-axis diagonal should be 9 after sizing + rank-2 update, got {}",
328 diag(&b, 0)
329 );
330 }
331
332 #[test]
333 fn seed_scale_sets_the_diagonal_and_marks_sized() {
334 let mut b = DampedBfgs::new(3);
335 b.seed_scale(25.0);
336 assert!(b.sized, "seeding must suppress the later one-time sizing");
337 for i in 0..3 {
338 assert!((diag(&b, i) - 25.0).abs() < 1e-12);
339 }
340 // A degenerate scale must be ignored, not written into B.
341 let mut c = DampedBfgs::new(2);
342 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
343 c.seed_scale(bad);
344 assert!(!c.sized, "seed_scale({bad}) must be refused");
345 assert!((diag(&c, 0) - 1.0).abs() < 1e-12, "B must stay I");
346 }
347 }
348
349 #[test]
350 fn reset_to_scale_drops_curvature_but_keeps_magnitude() {
351 // Build up genuine off-diagonal curvature, then reset: the
352 // off-diagonals must vanish and the diagonal must retain the
353 // matrix's own scale (its mean diagonal), not collapse to 1.
354 let mut b = DampedBfgs::new(2);
355 b.seed_scale(100.0);
356 b.update(&[0.0, 0.0], &[0.0, 0.0]);
357 b.update(&[1.0, 1.0], &[150.0, 40.0]); // rank-2 update -> off-diagonals
358 assert!(
359 b.get(1, 0).abs() > 1e-9,
360 "test precondition: expected off-diagonal curvature, got {}",
361 b.get(1, 0)
362 );
363 let mean_diag = (diag(&b, 0) + diag(&b, 1)) / 2.0;
364 b.reset_to_scale();
365 assert!(b.get(1, 0).abs() < 1e-12, "off-diagonals must be zeroed");
366 for i in 0..2 {
367 assert!(
368 (diag(&b, i) - mean_diag).abs() < 1e-9,
369 "diagonal must keep the mean scale {mean_diag}, got {}",
370 diag(&b, i)
371 );
372 }
373 assert!(
374 mean_diag > 10.0,
375 "sanity: the retained scale should reflect the problem, not 1"
376 );
377 }
378
379 #[test]
380 fn update_sy_matches_the_x_grad_lag_form() {
381 // `update_sy` is the primitive; `update` is the (s, y)-from-stored-
382 // prev convenience wrapper. Feeding the same curvature pair through
383 // either path must land on the identical matrix, so the driver's
384 // switch to `update_sy` (gh #361) changes only *which* y is formed,
385 // never how it is applied.
386 let mut via_update = DampedBfgs::new(2);
387 via_update.update(&[0.0, 0.0], &[1.0, 2.0]);
388 via_update.update(&[1.0, 3.0], &[4.0, 9.0]);
389
390 let mut via_sy = DampedBfgs::new(2);
391 via_sy.update_sy(&[1.0, 3.0], &[3.0, 7.0]); // s = x1-x0, y = g1-g0
392
393 for i in 0..2 {
394 for j in 0..=i {
395 assert!(
396 (via_update.get(i, j) - via_sy.get(i, j)).abs() < 1e-12,
397 "B[{i},{j}]: update={} update_sy={}",
398 via_update.get(i, j),
399 via_sy.get(i, j)
400 );
401 }
402 }
403 }
404
405 #[test]
406 fn sizing_happens_only_once() {
407 // A second pair must NOT re-seed the diagonal; the persistent
408 // rank-2 updates accumulate on top of the one-time sized base.
409 let mut b = DampedBfgs::new(2);
410 b.update(&[0.0, 0.0], &[0.0, 0.0]);
411 b.update(&[1.0, 0.0], &[9.0, 0.0]); // sizes to γ = 9
412 assert!(b.sized);
413 // A second pair along axis 1 with a *different* curvature (4):
414 // γ would have been 4 had we re-sized, but we must not.
415 b.update(&[1.0, 1.0], &[9.0, 4.0]); // s = (0,1), y = (0,4)
416 // Axis-0 diagonal is untouched by this axis-1 pair and must
417 // still reflect the first sizing (9), never re-seeded to 4.
418 assert!(
419 (diag(&b, 0) - 9.0).abs() < 1e-9,
420 "second pair must not re-seed; axis-0 diagonal = {}",
421 diag(&b, 0)
422 );
423 }
424}