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    /// 1-based upper-triangle sparsity pattern (matches DampedBfgs).
47    h_irow: Vec<Index>,
48    h_jcol: Vec<Index>,
49}
50
51impl LBfgs {
52    /// `m_history` is the number of `(s, y)` pairs to keep
53    /// (Nocedal-Wright recommends 3–20; upstream IPOPT defaults to
54    /// 6). Must be ≥ 1; a value of 0 would degenerate to plain
55    /// identity which is rarely useful.
56    pub fn new(n: usize, m_history: usize) -> Self {
57        debug_assert!(m_history >= 1);
58        let nz = n * (n + 1) / 2;
59        let mut h_irow = Vec::with_capacity(nz);
60        let mut h_jcol = Vec::with_capacity(nz);
61        for i in 0..n {
62            for j in 0..=i {
63                h_irow.push((i + 1) as Index);
64                h_jcol.push((j + 1) as Index);
65            }
66        }
67        Self {
68            n,
69            m_history,
70            pairs: VecDeque::with_capacity(m_history),
71            prev_x: None,
72            prev_grad_lag: None,
73            h_irow,
74            h_jcol,
75        }
76    }
77
78    pub fn has_prev(&self) -> bool {
79        self.prev_x.is_some()
80    }
81
82    /// Record a new pair `(s, y) = (x_new − x_prev, ∇L_new −
83    /// ∇L_prev)` if a prior iterate exists. The first call merely
84    /// stores `(x_new, ∇L_new)`.
85    pub fn update(&mut self, x_new: &[Number], grad_lag_new: &[Number]) {
86        // Hard assert (PR #50 review S5): see BFGS::update.
87        assert_eq!(x_new.len(), self.n, "LBFGS::update: x_new.len() != n");
88        assert_eq!(
89            grad_lag_new.len(),
90            self.n,
91            "LBFGS::update: grad_lag_new.len() != n"
92        );
93
94        if let (Some(prev_x), Some(prev_grad_lag)) =
95            (self.prev_x.as_ref(), self.prev_grad_lag.as_ref())
96        {
97            let s: Vec<Number> = x_new
98                .iter()
99                .zip(prev_x.iter())
100                .map(|(a, b)| a - b)
101                .collect();
102            let y: Vec<Number> = grad_lag_new
103                .iter()
104                .zip(prev_grad_lag.iter())
105                .map(|(a, b)| a - b)
106                .collect();
107            self.update_sy(&s, &y);
108        }
109
110        self.prev_x = Some(x_new.to_vec());
111        self.prev_grad_lag = Some(grad_lag_new.to_vec());
112    }
113
114    /// Record an explicit curvature pair `(s, y)`.
115    ///
116    /// Prefer this over [`Self::update`] when the caller can form `y`
117    /// itself: the SQP driver must difference `∇L` at a **single, fixed**
118    /// multiplier (see `sqp_alg::curvature_pair`), which the `(x, ∇L)`
119    /// form cannot express because it stores the previous `∇L` as
120    /// evaluated at the previous multiplier.
121    pub fn update_sy(&mut self, s: &[Number], y: &[Number]) {
122        assert_eq!(s.len(), self.n, "LBFGS::update_sy: s.len() != n");
123        assert_eq!(y.len(), self.n, "LBFGS::update_sy: y.len() != n");
124        // Skip degenerate pairs (s ≈ 0).
125        let s_norm2: Number = s.iter().map(|v| v * v).sum();
126        if s_norm2 > 1e-30 {
127            if self.pairs.len() == self.m_history {
128                self.pairs.pop_front();
129            }
130            self.pairs.push_back((s.to_vec(), y.to_vec()));
131        }
132    }
133
134    /// Materialize the current B_k as a dense `Triplet` over the
135    /// upper triangle. Always returns the full `n(n+1)/2` triplets
136    /// (the same fixed pattern across iterations, so the QP
137    /// solver's symbolic factorization stays valid).
138    pub fn as_triplet(&self) -> Triplet {
139        let b_dense = self.materialize();
140        let mut vals = Vec::with_capacity(self.h_irow.len());
141        for i in 0..self.n {
142            for j in 0..=i {
143                vals.push(b_dense[i * self.n + j]);
144            }
145        }
146        Triplet {
147            n_rows: self.n,
148            n_cols: self.n,
149            irow: self.h_irow.clone(),
150            jcol: self.h_jcol.clone(),
151            vals,
152        }
153    }
154
155    /// Build the dense `n×n` row-major B_k by seeding `B_0 = γI`
156    /// (Nocedal-Wright eq. 7.20) and replaying Powell-damped BFGS
157    /// updates for every stored pair. Returned values are
158    /// symmetric (only the lower triangle is read by `as_triplet`).
159    fn materialize(&self) -> Vec<Number> {
160        let n = self.n;
161        // Initial scaling γ: most-recent (s, y) ratio. Defaults to
162        // 1.0 when no pairs exist or sᵀy is tiny.
163        let gamma = self
164            .pairs
165            .back()
166            .and_then(|(s, y)| {
167                let sy: Number = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
168                let yy: Number = y.iter().map(|v| v * v).sum();
169                if yy > 1e-30 && sy > 1e-30 {
170                    Some(yy / sy)
171                } else {
172                    None
173                }
174            })
175            .unwrap_or(1.0);
176        let mut b = vec![0.0_f64; n * n];
177        for i in 0..n {
178            b[i * n + i] = gamma;
179        }
180
181        for (s, y) in self.pairs.iter() {
182            // bs = B · s
183            let mut bs = vec![0.0_f64; n];
184            for i in 0..n {
185                let mut acc = 0.0_f64;
186                let row = &b[i * n..i * n + n];
187                for j in 0..n {
188                    acc += row[j] * s[j];
189                }
190                bs[i] = acc;
191            }
192            let s_bs: Number = s.iter().zip(bs.iter()).map(|(a, b)| a * b).sum();
193            let s_y: Number = s.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
194
195            let theta = if s_y >= 0.2 * s_bs {
196                1.0
197            } else if s_bs - s_y > 1e-14 {
198                0.8 * s_bs / (s_bs - s_y)
199            } else {
200                1.0
201            };
202            let y_damp: Vec<Number> = y
203                .iter()
204                .zip(bs.iter())
205                .map(|(yi, bsi)| theta * yi + (1.0 - theta) * bsi)
206                .collect();
207            let s_y_damp: Number = s.iter().zip(y_damp.iter()).map(|(a, b)| a * b).sum();
208
209            if s_bs > 1e-14 && s_y_damp > 1e-14 {
210                for i in 0..n {
211                    for j in 0..n {
212                        let delta = -(bs[i] * bs[j]) / s_bs + (y_damp[i] * y_damp[j]) / s_y_damp;
213                        b[i * n + j] += delta;
214                    }
215                }
216            }
217        }
218        b
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn lbfgs_seeds_identity_with_no_pairs() {
228        let lb = LBfgs::new(3, 5);
229        let t = lb.as_triplet();
230        // Diagonal should be 1.0, off-diagonals 0.0.
231        for k in 0..t.vals.len() {
232            let i = (t.irow[k] - 1) as usize;
233            let j = (t.jcol[k] - 1) as usize;
234            let expected = if i == j { 1.0 } else { 0.0 };
235            assert!(
236                (t.vals[k] - expected).abs() < 1e-15,
237                "B[{i},{j}] = {} but expected {expected}",
238                t.vals[k]
239            );
240        }
241    }
242
243    #[test]
244    fn lbfgs_first_update_only_records_pair() {
245        let mut lb = LBfgs::new(2, 3);
246        lb.update(&[0.0, 0.0], &[1.0, 1.0]);
247        assert!(lb.has_prev());
248        assert!(lb.pairs.is_empty());
249        // Without any pairs the matrix is still γI = I.
250        let t = lb.as_triplet();
251        let diag: Vec<_> = t
252            .vals
253            .iter()
254            .enumerate()
255            .filter(|(k, _)| t.irow[*k] == t.jcol[*k])
256            .map(|(_, v)| *v)
257            .collect();
258        assert!((diag[0] - 1.0).abs() < 1e-15);
259        assert!((diag[1] - 1.0).abs() < 1e-15);
260    }
261
262    #[test]
263    fn lbfgs_quadratic_recovers_exact_hessian_at_convergence() {
264        // For the quadratic f(x) = ½ xᵀ A x with A = diag(2, 4),
265        // ∇f = Ax, so along iterates x_k = x_{k-1} + s_{k-1}:
266        // y_{k-1} = A s_{k-1}. Pumping ≥ 2 linearly independent
267        // (s, y) pairs into L-BFGS must rebuild B_2 = A exactly
268        // (up to numerical roundoff) because the rank-2 corrections
269        // collapse onto A in 2-D.
270        let mut lb = LBfgs::new(2, 5);
271        // Pair 1: s = (1, 0), y = A·s = (2, 0).
272        lb.update(&[0.0, 0.0], &[0.0, 0.0]); // record start
273        lb.update(&[1.0, 0.0], &[2.0, 0.0]); // produces (s, y) #1
274        lb.update(&[1.0, 1.0], &[2.0, 4.0]); // produces (s, y) #2 with s=(0,1), y=(0,4)
275        let t = lb.as_triplet();
276        // B should equal A = diag(2, 4).
277        let mut b = [[0.0_f64; 2]; 2];
278        for k in 0..t.vals.len() {
279            let i = (t.irow[k] - 1) as usize;
280            let j = (t.jcol[k] - 1) as usize;
281            b[i][j] = t.vals[k];
282            if i != j {
283                b[j][i] = t.vals[k];
284            }
285        }
286        assert!((b[0][0] - 2.0).abs() < 1e-9, "B[0,0] = {}", b[0][0]);
287        assert!((b[1][1] - 4.0).abs() < 1e-9, "B[1,1] = {}", b[1][1]);
288        assert!(b[0][1].abs() < 1e-9, "B[0,1] = {}", b[0][1]);
289    }
290
291    #[test]
292    fn lbfgs_history_cap_drops_oldest() {
293        let mut lb = LBfgs::new(2, 2);
294        lb.update(&[0.0, 0.0], &[0.0, 0.0]);
295        lb.update(&[1.0, 0.0], &[1.0, 0.0]);
296        lb.update(&[2.0, 0.0], &[2.0, 0.0]);
297        lb.update(&[2.0, 1.0], &[2.0, 1.0]);
298        // m_history = 2 means only the most recent 2 pairs are
299        // retained.
300        assert_eq!(lb.pairs.len(), 2);
301    }
302}