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}