Skip to main content

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}