Skip to main content

pounce_algorithm/sqp/
lbfgs.rs

1//! Limited-memory Powell-damped BFGS Hessian approximation for
2//! SQP (Nocedal-Wright §7.2; Byrd-Nocedal-Schnabel 1994; Powell
3//! 1978). Used when `SqpOptions::hessian = Lbfgs`.
4//!
5//! Maintains a fixed-length circular buffer of `(s, y)` pairs and,
6//! at each `as_triplet` query, reconstructs the dense Hessian B_k
7//! by replaying the rank-2 BFGS updates from a scaled-identity
8//! seed `B_0 = γ_k I` with `γ_k = (s_{last}·y_{last}) /
9//! (y_{last}·y_{last})` (Nocedal-Wright eq. 7.20 — the standard
10//! initial-scaling heuristic).
11//!
12//! Phase 5b commit 15 implements the dense materialization path
13//! (output is a full upper-triangular [`Triplet`] consumed by
14//! `pounce-qp`'s `SqpQpData`). A future commit can add a
15//! matrix-free product interface so the QP can avoid the
16//! `O(n²)` storage when `m_history ≪ n`.
17//!
18//! Powell damping is identical to [`crate::sqp::bfgs::DampedBfgs`]:
19//!
20//! ```text
21//!     if s·y ≥ 0.2 · s·B s :  θ = 1
22//!     else                  :  θ = 0.8 · s·B s / (s·B s − s·y)
23//!     y_damp = θ y + (1 − θ) B s
24//! ```
25//!
26//! …but `B` here is the rebuilt one, accumulated within
27//! `materialize`, so the damping factor for each historical pair
28//! is computed at replay time against the running B (matching
29//! how a full BFGS would have evolved).
30
31use crate::sqp::qp_assembly::Triplet;
32use pounce_common::types::{Index, Number};
33use std::collections::VecDeque;
34
35/// Limited-memory Powell-damped BFGS, storing up to `m_history`
36/// most-recent `(s, y)` pairs.
37pub struct LBfgs {
38    n: usize,
39    m_history: usize,
40    /// Circular buffer of (s_k, y_k) pairs, oldest first.
41    pairs: VecDeque<(Vec<Number>, Vec<Number>)>,
42    /// Previous `x` and `∇L`; the next call to [`Self::update`]
43    /// builds the next `(s, y)` from these.
44    prev_x: Option<Vec<Number>>,
45    prev_grad_lag: Option<Vec<Number>>,
46    /// Seed scale for `B_0 = γI` while no pair is stored, set by the SQP
47    /// driver's iteration-0 curvature probe ([`Self::seed_scale`]).
48    seed_gamma: Option<Number>,
49}
50
51/// `B = σI + Σᵢ signᵢ vᵢ vᵢᵀ` — the L-BFGS matrix in product form, with no
52/// `n × n` storage (gh#1054).
53///
54/// Each stored pair contributes one `−a aᵀ` and one `+b bᵀ` term,
55/// `a = B s / √(sᵀB s)` and `b = ȳ / √(sᵀȳ)` with `ȳ` the Powell-damped
56/// `y` — the same rank-2 update [`LBfgs::as_triplet`] replays densely, so
57/// the two describe one matrix. A pair the dense replay skips (`sᵀBs` or
58/// `sᵀȳ` below `1e-14`) is skipped here too.
59#[derive(Debug, Clone)]
60pub struct LowRankHessian {
61    pub sigma: Number,
62    /// The `vᵢ`, each of length `n`.
63    pub vecs: Vec<Vec<Number>>,
64    /// `−1` or `+1` per vector.
65    pub signs: Vec<Number>,
66}
67
68impl LowRankHessian {
69    /// `B v`, in `O(k n)`.
70    pub fn apply(&self, v: &[Number]) -> Vec<Number> {
71        let mut out: Vec<Number> = v.iter().map(|x| self.sigma * x).collect();
72        for (w, &sg) in self.vecs.iter().zip(self.signs.iter()) {
73            let c = sg * w.iter().zip(v.iter()).map(|(a, b)| a * b).sum::<Number>();
74            for (o, wi) in out.iter_mut().zip(w.iter()) {
75                *o += c * wi;
76            }
77        }
78        out
79    }
80
81    /// `max |B_ij|`. `B` is positive definite (BFGS with Powell damping),
82    /// and for a PSD matrix the largest entry in magnitude sits on the
83    /// diagonal (`|B_ij| ≤ √(B_ii B_jj)`), so this is the dense
84    /// matrix's max-norm exactly, at `O(k n)`.
85    pub fn max_abs_entry(&self) -> Number {
86        let n = self.vecs.first().map_or(0, Vec::len);
87        let mut diag = vec![self.sigma; n];
88        for (w, &sg) in self.vecs.iter().zip(self.signs.iter()) {
89            for (d, wi) in diag.iter_mut().zip(w.iter()) {
90                *d += sg * wi * wi;
91            }
92        }
93        diag.iter()
94            .fold(self.sigma.abs(), |acc, d| acc.max(d.abs()))
95    }
96}
97
98impl LBfgs {
99    /// `m_history` is the number of `(s, y)` pairs to keep
100    /// (Nocedal-Wright recommends 3–20; upstream IPOPT defaults to
101    /// 6). Must be ≥ 1; a value of 0 would degenerate to plain
102    /// identity which is rarely useful.
103    pub fn new(n: usize, m_history: usize) -> Self {
104        debug_assert!(m_history >= 1);
105        Self {
106            n,
107            m_history,
108            pairs: VecDeque::with_capacity(m_history),
109            prev_x: None,
110            prev_grad_lag: None,
111            seed_gamma: None,
112        }
113    }
114
115    /// Seed `B_0 = γI` from a curvature estimate taken before the first
116    /// pair exists — the SQP driver's iteration-0 probe, the same one
117    /// [`crate::sqp::bfgs::DampedBfgs::seed_scale`] takes (issue #358).
118    /// Without it the first QP solves against `B = I` whatever the
119    /// problem's scale. A non-positive or non-finite `gamma` is ignored.
120    /// Once a pair is stored the usual `yᵀy / sᵀy` scaling takes over.
121    pub fn seed_scale(&mut self, gamma: Number) {
122        if gamma.is_finite() && gamma > 0.0 {
123            self.seed_gamma = Some(gamma);
124        }
125    }
126
127    /// `B_0`'s scale: `yᵀy / sᵀy` of the newest pair (Nocedal-Wright
128    /// eq. 7.20), else the probe's seed, else `1`.
129    fn gamma(&self) -> Number {
130        self.pairs
131            .back()
132            .and_then(|(s, y)| {
133                let sy: Number = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
134                let yy: Number = y.iter().map(|v| v * v).sum();
135                if yy > 1e-30 && sy > 1e-30 {
136                    Some(yy / sy)
137                } else {
138                    None
139                }
140            })
141            .or(self.seed_gamma)
142            .unwrap_or(1.0)
143    }
144
145    /// The current `B` in product form (see [`LowRankHessian`]). Same
146    /// damping, same skip thresholds, same `γ` as [`Self::as_triplet`],
147    /// at `O(k² n)` time and `O(k n)` storage instead of `O(k n²)` and
148    /// `O(n²)`.
149    pub fn low_rank(&self) -> LowRankHessian {
150        let mut b = LowRankHessian {
151            sigma: self.gamma(),
152            vecs: Vec::with_capacity(2 * self.pairs.len()),
153            signs: Vec::with_capacity(2 * self.pairs.len()),
154        };
155        for (s, y) in self.pairs.iter() {
156            let bs = b.apply(s);
157            let s_bs: Number = s.iter().zip(bs.iter()).map(|(a, c)| a * c).sum();
158            let s_y: Number = s.iter().zip(y.iter()).map(|(a, c)| a * c).sum();
159            let theta = powell_theta(s_bs, s_y);
160            let y_damp: Vec<Number> = y
161                .iter()
162                .zip(bs.iter())
163                .map(|(yi, bsi)| theta * yi + (1.0 - theta) * bsi)
164                .collect();
165            let s_y_damp: Number = s.iter().zip(y_damp.iter()).map(|(a, c)| a * c).sum();
166            if s_bs > 1e-14 && s_y_damp > 1e-14 {
167                let ra = 1.0 / s_bs.sqrt();
168                let rb = 1.0 / s_y_damp.sqrt();
169                b.vecs.push(bs.iter().map(|v| v * ra).collect());
170                b.signs.push(-1.0);
171                b.vecs.push(y_damp.iter().map(|v| v * rb).collect());
172                b.signs.push(1.0);
173            }
174        }
175        b
176    }
177
178    pub fn has_prev(&self) -> bool {
179        self.prev_x.is_some()
180    }
181
182    /// Record a new pair `(s, y) = (x_new − x_prev, ∇L_new −
183    /// ∇L_prev)` if a prior iterate exists. The first call merely
184    /// stores `(x_new, ∇L_new)`.
185    pub fn update(&mut self, x_new: &[Number], grad_lag_new: &[Number]) {
186        // Hard assert (PR #50 review S5): see BFGS::update.
187        assert_eq!(x_new.len(), self.n, "LBFGS::update: x_new.len() != n");
188        assert_eq!(
189            grad_lag_new.len(),
190            self.n,
191            "LBFGS::update: grad_lag_new.len() != n"
192        );
193
194        if let (Some(prev_x), Some(prev_grad_lag)) =
195            (self.prev_x.as_ref(), self.prev_grad_lag.as_ref())
196        {
197            let s: Vec<Number> = x_new
198                .iter()
199                .zip(prev_x.iter())
200                .map(|(a, b)| a - b)
201                .collect();
202            let y: Vec<Number> = grad_lag_new
203                .iter()
204                .zip(prev_grad_lag.iter())
205                .map(|(a, b)| a - b)
206                .collect();
207            self.update_sy(&s, &y);
208        }
209
210        self.prev_x = Some(x_new.to_vec());
211        self.prev_grad_lag = Some(grad_lag_new.to_vec());
212    }
213
214    /// Record an explicit curvature pair `(s, y)`.
215    ///
216    /// Prefer this over [`Self::update`] when the caller can form `y`
217    /// itself: the SQP driver must difference `∇L` at a **single, fixed**
218    /// multiplier (see `sqp_alg::curvature_pair`), which the `(x, ∇L)`
219    /// form cannot express because it stores the previous `∇L` as
220    /// evaluated at the previous multiplier.
221    pub fn update_sy(&mut self, s: &[Number], y: &[Number]) {
222        assert_eq!(s.len(), self.n, "LBFGS::update_sy: s.len() != n");
223        assert_eq!(y.len(), self.n, "LBFGS::update_sy: y.len() != n");
224        // Skip degenerate pairs (s ≈ 0).
225        let s_norm2: Number = s.iter().map(|v| v * v).sum();
226        if s_norm2 > 1e-30 {
227            if self.pairs.len() == self.m_history {
228                self.pairs.pop_front();
229            }
230            self.pairs.push_back((s.to_vec(), y.to_vec()));
231        }
232    }
233
234    /// Materialize the current B_k as a dense `Triplet` over the
235    /// upper triangle. Always returns the full `n(n+1)/2` triplets
236    /// (the same fixed pattern across iterations, so the QP
237    /// solver's symbolic factorization stays valid).
238    pub fn as_triplet(&self) -> Triplet {
239        let n = self.n;
240        let nz = n * (n + 1) / 2;
241        let (mut irow, mut jcol) = (Vec::with_capacity(nz), Vec::with_capacity(nz));
242        for i in 0..n {
243            for j in 0..=i {
244                irow.push((i + 1) as Index);
245                jcol.push((j + 1) as Index);
246            }
247        }
248        Triplet {
249            n_rows: n,
250            n_cols: n,
251            irow,
252            jcol,
253            vals: lower_triangle(&self.materialize(), n),
254        }
255    }
256
257    /// Build the dense `n×n` row-major B_k by seeding `B_0 = γI`
258    /// (Nocedal-Wright eq. 7.20) and replaying Powell-damped BFGS
259    /// updates for every stored pair. Returned values are
260    /// symmetric (only the lower triangle is read by `as_triplet`).
261    fn materialize(&self) -> Vec<Number> {
262        let n = self.n;
263        // Initial scaling γ: most-recent (s, y) ratio, else the probe's
264        // seed, else 1.0.
265        let gamma = self.gamma();
266        let mut b = vec![0.0_f64; n * n];
267        for i in 0..n {
268            b[i * n + i] = gamma;
269        }
270
271        for (s, y) in self.pairs.iter() {
272            // bs = B · s
273            let mut bs = vec![0.0_f64; n];
274            for i in 0..n {
275                let mut acc = 0.0_f64;
276                let row = &b[i * n..i * n + n];
277                for j in 0..n {
278                    acc += row[j] * s[j];
279                }
280                bs[i] = acc;
281            }
282            let s_bs: Number = s.iter().zip(bs.iter()).map(|(a, b)| a * b).sum();
283            let s_y: Number = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
284
285            let theta = powell_theta(s_bs, s_y);
286            let y_damp: Vec<Number> = y
287                .iter()
288                .zip(bs.iter())
289                .map(|(yi, bsi)| theta * yi + (1.0 - theta) * bsi)
290                .collect();
291            let s_y_damp: Number = s.iter().zip(y_damp.iter()).map(|(a, b)| a * b).sum();
292
293            if s_bs > 1e-14 && s_y_damp > 1e-14 {
294                for i in 0..n {
295                    for j in 0..n {
296                        let delta = -(bs[i] * bs[j]) / s_bs + (y_damp[i] * y_damp[j]) / s_y_damp;
297                        b[i * n + j] += delta;
298                    }
299                }
300            }
301        }
302        b
303    }
304}
305
306/// Row-major dense `n × n` → its lower triangle, row by row.
307fn lower_triangle(b: &[Number], n: usize) -> Vec<Number> {
308    let mut vals = Vec::with_capacity(n * (n + 1) / 2);
309    for i in 0..n {
310        for j in 0..=i {
311            vals.push(b[i * n + j]);
312        }
313    }
314    vals
315}
316
317/// Powell's damping factor θ for a pair with curvatures `sᵀBs`, `sᵀy`.
318fn powell_theta(s_bs: Number, s_y: Number) -> Number {
319    if s_y >= 0.2 * s_bs {
320        1.0
321    } else if s_bs - s_y > 1e-14 {
322        0.8 * s_bs / (s_bs - s_y)
323    } else {
324        1.0
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn lbfgs_seeds_identity_with_no_pairs() {
334        let lb = LBfgs::new(3, 5);
335        let t = lb.as_triplet();
336        // Diagonal should be 1.0, off-diagonals 0.0.
337        for k in 0..t.vals.len() {
338            let i = (t.irow[k] - 1) as usize;
339            let j = (t.jcol[k] - 1) as usize;
340            let expected = if i == j { 1.0 } else { 0.0 };
341            assert!(
342                (t.vals[k] - expected).abs() < 1e-15,
343                "B[{i},{j}] = {} but expected {expected}",
344                t.vals[k]
345            );
346        }
347    }
348
349    #[test]
350    fn lbfgs_first_update_only_records_pair() {
351        let mut lb = LBfgs::new(2, 3);
352        lb.update(&[0.0, 0.0], &[1.0, 1.0]);
353        assert!(lb.has_prev());
354        assert!(lb.pairs.is_empty());
355        // Without any pairs the matrix is still γI = I.
356        let t = lb.as_triplet();
357        let diag: Vec<_> = t
358            .vals
359            .iter()
360            .enumerate()
361            .filter(|(k, _)| t.irow[*k] == t.jcol[*k])
362            .map(|(_, v)| *v)
363            .collect();
364        assert!((diag[0] - 1.0).abs() < 1e-15);
365        assert!((diag[1] - 1.0).abs() < 1e-15);
366    }
367
368    #[test]
369    fn lbfgs_quadratic_recovers_exact_hessian_at_convergence() {
370        // For the quadratic f(x) = ½ xᵀ A x with A = diag(2, 4),
371        // ∇f = Ax, so along iterates x_k = x_{k-1} + s_{k-1}:
372        // y_{k-1} = A s_{k-1}. Pumping ≥ 2 linearly independent
373        // (s, y) pairs into L-BFGS must rebuild B_2 = A exactly
374        // (up to numerical roundoff) because the rank-2 corrections
375        // collapse onto A in 2-D.
376        let mut lb = LBfgs::new(2, 5);
377        // Pair 1: s = (1, 0), y = A·s = (2, 0).
378        lb.update(&[0.0, 0.0], &[0.0, 0.0]); // record start
379        lb.update(&[1.0, 0.0], &[2.0, 0.0]); // produces (s, y) #1
380        lb.update(&[1.0, 1.0], &[2.0, 4.0]); // produces (s, y) #2 with s=(0,1), y=(0,4)
381        let t = lb.as_triplet();
382        // B should equal A = diag(2, 4).
383        let mut b = [[0.0_f64; 2]; 2];
384        for k in 0..t.vals.len() {
385            let i = (t.irow[k] - 1) as usize;
386            let j = (t.jcol[k] - 1) as usize;
387            b[i][j] = t.vals[k];
388            if i != j {
389                b[j][i] = t.vals[k];
390            }
391        }
392        assert!((b[0][0] - 2.0).abs() < 1e-9, "B[0,0] = {}", b[0][0]);
393        assert!((b[1][1] - 4.0).abs() < 1e-9, "B[1,1] = {}", b[1][1]);
394        assert!(b[0][1].abs() < 1e-9, "B[0,1] = {}", b[0][1]);
395    }
396
397    #[test]
398    fn lbfgs_history_cap_drops_oldest() {
399        let mut lb = LBfgs::new(2, 2);
400        lb.update(&[0.0, 0.0], &[0.0, 0.0]);
401        lb.update(&[1.0, 0.0], &[1.0, 0.0]);
402        lb.update(&[2.0, 0.0], &[2.0, 0.0]);
403        lb.update(&[2.0, 1.0], &[2.0, 1.0]);
404        // m_history = 2 means only the most recent 2 pairs are
405        // retained.
406        assert_eq!(lb.pairs.len(), 2);
407    }
408
409    /// gh#1054: the product form is the dense replay, term for term —
410    /// including a pair Powell damping rewrites (`sᵀy < 0.2 sᵀBs`) and a
411    /// seeded `γ`.
412    #[test]
413    fn low_rank_matches_the_dense_matrix() {
414        let n = 5;
415        let mut lb = LBfgs::new(n, 3);
416        lb.seed_scale(2.5);
417        let pairs: [([f64; 5], [f64; 5]); 4] = [
418            ([1.0, 0.0, 0.5, 0.0, 0.1], [3.0, 0.2, 1.0, 0.0, 0.4]),
419            ([0.0, 1.0, 0.0, -0.3, 0.0], [0.1, 2.0, 0.0, -0.5, 0.2]),
420            // Negative curvature along s: damping must engage.
421            ([0.2, 0.2, 1.0, 0.0, 0.0], [-0.1, 0.0, -0.2, 0.0, 0.0]),
422            ([0.0, 0.0, 0.0, 1.0, 1.0], [0.0, 0.3, 0.0, 4.0, 3.5]),
423        ];
424        for (s, y) in &pairs {
425            lb.update_sy(s, y);
426        }
427        let dense = lb.materialize();
428        let lr = lb.low_rank();
429        assert_eq!(lr.vecs.len(), 6, "three stored pairs, two terms each");
430        for k in 0..n {
431            let mut e = vec![0.0; n];
432            e[k] = 1.0;
433            let col = lr.apply(&e);
434            for i in 0..n {
435                assert!(
436                    (col[i] - dense[i * n + k]).abs() < 1e-12,
437                    "B[{i},{k}]: product form {} vs dense {}",
438                    col[i],
439                    dense[i * n + k]
440                );
441            }
442        }
443        let max_dense = dense.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
444        assert!((lr.max_abs_entry() - max_dense).abs() < 1e-12);
445    }
446
447    #[test]
448    fn seed_scale_sets_the_initial_matrix_until_a_pair_arrives() {
449        let mut lb = LBfgs::new(2, 3);
450        lb.seed_scale(7.0);
451        assert_eq!(lb.low_rank().sigma, 7.0);
452        lb.seed_scale(-1.0); // ignored
453        assert_eq!(lb.low_rank().sigma, 7.0);
454        lb.update_sy(&[1.0, 0.0], &[3.0, 0.0]);
455        assert_eq!(lb.low_rank().sigma, 3.0, "yᵀy / sᵀy of the newest pair");
456    }
457}