pounce_algorithm/hess/lim_mem_quasi_newton.rs
1//! Limited-memory quasi-Newton (L-BFGS / SR1) — port of
2//! `Algorithm/IpLimMemQuasiNewtonUpdater.{hpp,cpp}`. **Phase 8.**
3//!
4//! Update strategy is selected by the `limited_memory_update_type`
5//! option (`bfgs` or `sr1`) per `MAIN_LOOP.md`.
6//!
7//! Phase 8 publishes the limited-memory Hessian as `data.w` via the
8//! **low-rank** assembler, for every problem size. At each
9//! `update_hessian` call we walk the curvature-pair history (oldest to
10//! newest) applying the rank-2 BFGS / rank-1 SR1 formulas to build the
11//! compact factors of `B = σ I + V Vᵀ − U Uᵀ`, then publish a
12//! [`pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrix`]
13//! as `data.w`. No dense `n×n` buffer is ever formed: the walk is
14//! `O(n · m)` per pair and `O(n · m²)` total (with `m = max_history`),
15//! and storage is `O(n · m)`, so the limited-memory path scales to
16//! arbitrarily large `n`. [`crate::kkt::low_rank_aug_system_solver`]
17//! applies the Hessian's inverse action via the Sherman-Morrison-Woodbury
18//! identity, factorizing only the diagonal `B0`. This removes the
19//! `eval_h` requirement (the user no longer needs to declare a Hessian
20//! sparsity pattern) and the former `O(n²)` memory cliff.
21//!
22//! `LowRankAugSystemSolver` wraps the standard augmented-system solver and
23//! forwards the Hessian-free init / equality-multiplier solves (which
24//! carry a non-low-rank `W`) straight through, so a single solver
25//! instance serves the whole iteration.
26//!
27//! Update kernels:
28//! - [`initial_hessian_scalar`] (sigma per `LIM_MEM_INIT`)
29//! - [`bfgs_curvature_pair_ok`] (skip-criterion for L-BFGS)
30//! - [`sr1_denominator_ok`] (skip-criterion for SR1)
31
32use crate::hess::r#trait::HessianUpdater;
33use crate::ipopt_cq::IpoptCqHandle;
34use crate::ipopt_data::IpoptDataHandle;
35use pounce_common::types::{Index, Number};
36use pounce_linalg::Vector;
37use pounce_linalg::compound_vector::CompoundVector;
38use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
39use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
40use pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrixSpace;
41use pounce_linalg::multi_vector_matrix::{MultiVectorMatrix, MultiVectorMatrixSpace};
42use std::rc::Rc;
43
44/// One curvature pair `(s, y)` plus the cached `||s||`, `||y||`, `s·y`
45/// scalars the BFGS / SR1 update kernels need on every history walk.
46#[derive(Debug, Clone)]
47pub struct CurvaturePair {
48 pub s: Rc<dyn Vector>,
49 pub y: Rc<dyn Vector>,
50 pub s_dot_y: Number,
51 pub s_norm: Number,
52 pub y_norm: Number,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum UpdateType {
57 Bfgs,
58 Sr1,
59}
60
61/// `limited_memory_initialization` — how the diagonal `B0 = σ I` is
62/// chosen before the rank-2 updates. Upstream registers five values
63/// (`IpLimMemQuasiNewtonUpdater.cpp:RegisterOptions`); `Identity` has no
64/// upstream keyword and exists for callers constructing the updater
65/// directly.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum InitialApprox {
68 Identity,
69 /// `scalar1` — σ = sᵀy / sᵀs. Upstream's default.
70 Scalar1,
71 /// `scalar2` — σ = yᵀy / sᵀy.
72 Scalar2,
73 /// `scalar3` — arithmetic mean of `scalar1` and `scalar2`.
74 Scalar3,
75 /// `scalar4` — geometric mean of `scalar1` and `scalar2`.
76 Scalar4,
77 /// `constant` — σ = `limited_memory_init_val`, every iteration.
78 Constant,
79 /// `history-max` — the `scalar1` formula evaluated on **every**
80 /// stored curvature pair, largest wins (gh#818). No upstream
81 /// keyword; see [`LimMemQuasiNewtonUpdater::compute_sigma_bfgs`]
82 /// for why the maximum rather than the newest pair.
83 HistoryMax,
84}
85
86pub struct LimMemQuasiNewtonUpdater {
87 pub update_type: UpdateType,
88 pub initial_approx: InitialApprox,
89 pub max_history: i32,
90 /// Powell-damping threshold. Default per upstream
91 /// `IpLimMemQuasiNewtonUpdater.cpp:RegisterOptions`:
92 /// `limited_memory_init_val_max=1e8` (clamp on initial sigma);
93 /// the damping coefficient is hard-coded at 0.2 in the BFGS path.
94 pub init_val_max: Number,
95 pub init_val_min: Number,
96 /// `limited_memory_init_val` — the multiple of the identity `B0`
97 /// takes on the first iteration, before any curvature pair has been
98 /// formed, and on every iteration under
99 /// [`InitialApprox::Constant`]. Upstream default `1.0`
100 /// (`IpLimMemQuasiNewtonUpdater.cpp:RegisterOptions`). Was a
101 /// hard-coded `1.0` in the empty-history branch until #677 — the
102 /// same value, but not settable, and `constant` had nowhere to read
103 /// its σ from.
104 pub init_val: Number,
105 /// Rolling FIFO of curvature pairs, oldest at index 0. Capped at
106 /// `max_history`; insertion drops the front.
107 pub history: Vec<CurvaturePair>,
108 /// `x` from the previous `update_hessian` call. None on the first
109 /// iteration.
110 pub last_x: Option<Rc<dyn Vector>>,
111 /// `∇f(x_prev)` cached for the upstream y-difference formula
112 /// (`IpLimMemQuasiNewtonUpdater.cpp:284`).
113 pub last_grad_f: Option<Rc<dyn Vector>>,
114 /// `J_c(x_prev)` cached for `J_c_prev^T · y_c_curr` in the
115 /// y-difference. Stored as the trait object so we can call
116 /// `trans_mult_vector` against the *current* multipliers — the
117 /// upstream formula evaluates both Jacobians against `y_c_curr`
118 /// (NOT `y_c_prev`).
119 pub last_jac_c: Option<Rc<dyn pounce_linalg::matrix::Matrix>>,
120 pub last_jac_d: Option<Rc<dyn pounce_linalg::matrix::Matrix>>,
121 /// Positions in the primal space that enter *nonlinearly* (gh#624),
122 /// sorted and deduplicated. `None` — the default — approximates over
123 /// the whole space, which is what every solve did before the mask
124 /// existed. When set, curvature pairs are projected onto these
125 /// positions and the published `W` carries the corresponding
126 /// expansion `P`, so the KKT solvers see
127 /// `B = σ I + P (V Vᵀ − U Uᵀ) Pᵀ`: no stored curvature, and no
128 /// stored columns, for variables that only ever appear linearly.
129 /// The subset comes from upstream's `P_LM`
130 /// (`IpTNLPAdapter::GetQuasiNewtonApproxSpaces`); see
131 /// `update_hessian` for why σ stays on the full diagonal where
132 /// upstream drops it.
133 pub nonlinear_vars: Option<Vec<Index>>,
134 /// `limited_memory_max_skipping` — consecutive skipped curvature
135 /// updates after which the whole approximation is discarded and
136 /// re-anchored at the current iterate. Upstream default 2
137 /// (`IpLimMemQuasiNewtonUpdater.cpp`, the
138 /// `lm_skipped_iter_ >= limited_memory_max_skipping_` branch).
139 ///
140 /// Without this the history never turns over: on a problem whose
141 /// Lagrangian curvature is persistently negative, every pair after
142 /// the first few is skipped and the model keeps representing
143 /// curvature the iterate left long ago. `inf_pr` converges while
144 /// `inf_du` plateaus. Reported with side-by-side instrumentation on
145 /// a 59,939-variable collocation model (#686): over 60 iterations
146 /// Ipopt reset 9 times and pounce 0, and the two iterate sequences
147 /// were bit-identical until one iteration after Ipopt's first reset.
148 pub max_skipping: Index,
149 /// Consecutive skips so far — upstream's `lm_skipped_iter_`.
150 pub skipped_iter: Index,
151}
152
153impl Default for LimMemQuasiNewtonUpdater {
154 fn default() -> Self {
155 Self {
156 update_type: UpdateType::Bfgs,
157 initial_approx: InitialApprox::Scalar1,
158 max_history: 6,
159 init_val_max: 1e8,
160 init_val_min: 1e-8,
161 init_val: 1.0,
162 history: Vec::new(),
163 last_x: None,
164 last_grad_f: None,
165 last_jac_c: None,
166 last_jac_d: None,
167 nonlinear_vars: None,
168 max_skipping: 2,
169 skipped_iter: 0,
170 }
171 }
172}
173
174impl LimMemQuasiNewtonUpdater {
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 /// Try to absorb a new curvature pair. Returns `true` when the
180 /// pair was accepted (and pushed to history), `false` when the
181 /// skip-criterion rejected it. The caller owns `s` and `y` as
182 /// `Rc<dyn Vector>` so the history can retain them cheaply.
183 ///
184 /// This matches the per-iteration path in
185 /// `IpLimMemQuasiNewtonUpdater.cpp:Update` after the `(x, ∇L)`
186 /// difference has been formed: skip-or-keep, then push, then
187 /// trim the history to `max_history`.
188 pub fn ingest_pair(&mut self, s: Rc<dyn Vector>, y: Rc<dyn Vector>) -> bool {
189 let s_dot_y = s.dot(&*y);
190 let s_norm = s.nrm2();
191 let y_norm = y.nrm2();
192 let accept = match self.update_type {
193 UpdateType::Bfgs => bfgs_curvature_pair_ok(s_dot_y, s_norm, y_norm),
194 UpdateType::Sr1 => {
195 // SR1's skip-criterion is `(y - Bs)^T s` not `s^T y`;
196 // without `B` available here we use the upstream
197 // fallback of `s^T y` magnitude as the gating heuristic
198 // (a more accurate test lands once the low-rank matrix
199 // is wired in).
200 sr1_denominator_ok(s_dot_y, s_norm, y_norm)
201 }
202 };
203 if !accept {
204 return false;
205 }
206 self.history.push(CurvaturePair {
207 s,
208 y,
209 s_dot_y,
210 s_norm,
211 y_norm,
212 });
213 // Drop oldest pairs to honor the memory budget.
214 while self.history.len() > self.max_history.max(0) as usize {
215 self.history.remove(0);
216 }
217 true
218 }
219}
220
221impl HessianUpdater for LimMemQuasiNewtonUpdater {
222 /// Snapshot the current `(x, ∇_x L)` pair, build `s = x − x_prev`
223 /// and `y = ∇L − ∇L_prev`, ingest into history (skip per the
224 /// BFGS / SR1 acceptance criterion), then build the low-rank factors
225 /// of `B = σ I + V Vᵀ − U Uᵀ` from the rolling history and publish a
226 /// [`pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrix`]
227 /// as `data.w`. Mirrors `IpLimMemQuasiNewtonUpdater::Update`.
228 fn update_hessian(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) -> bool {
229 let (curr_x, curr_y_c, curr_y_d) = match data.borrow().curr.as_ref() {
230 Some(c) => (c.x.clone(), c.y_c.clone(), c.y_d.clone()),
231 None => return true,
232 };
233 let curr_grad_f = cq.borrow().curr_grad_f();
234 let curr_jac_c = cq.borrow().curr_jac_c();
235 let curr_jac_d = cq.borrow().curr_jac_d();
236
237 // Upstream y formula (`IpLimMemQuasiNewtonUpdater.cpp:284-308`):
238 // y = (∇f_curr − ∇f_last)
239 // + (J_c_curr^T − J_c_last^T) · y_c_curr
240 // + (J_d_curr^T − J_d_last^T) · y_d_curr
241 // i.e. the change in the *NLP* Lagrangian gradient (no bound
242 // multipliers) where BOTH Jacobians are dotted against the
243 // CURRENT y_c/y_d. Using `curr_grad_lag_x` here would inject
244 // the bound-multiplier delta into y, which collapses spuriously
245 // when μ drops and corrupts the BFGS update.
246 if let (Some(prev_x), Some(prev_grad_f), Some(prev_jac_c), Some(prev_jac_d)) = (
247 self.last_x.clone(),
248 self.last_grad_f.clone(),
249 self.last_jac_c.clone(),
250 self.last_jac_d.clone(),
251 ) {
252 let mut s = curr_x.make_new();
253 s.add_two_vectors(1.0, &*curr_x, -1.0, &*prev_x, 0.0);
254
255 let mut y = curr_x.make_new();
256 // y = ∇f_curr − ∇f_last
257 y.add_two_vectors(1.0, &*curr_grad_f, -1.0, &*prev_grad_f, 0.0);
258 // y += J_c_curr^T y_c_curr − J_c_last^T y_c_curr
259 curr_jac_c.trans_mult_vector(1.0, &*curr_y_c, 1.0, &mut *y);
260 prev_jac_c.trans_mult_vector(-1.0, &*curr_y_c, 1.0, &mut *y);
261 // y += J_d_curr^T y_d_curr − J_d_last^T y_d_curr
262 curr_jac_d.trans_mult_vector(1.0, &*curr_y_d, 1.0, &mut *y);
263 prev_jac_d.trans_mult_vector(-1.0, &*curr_y_d, 1.0, &mut *y);
264
265 // Restrict the pair to the nonlinear subspace before it
266 // enters the history: everything downstream (σ, the BFGS /
267 // SR1 recurrences, the stored columns) then lives in the
268 // reduced space, exactly as upstream does once `P_LM` is
269 // present.
270 let accepted = match self.active_mask(curr_x.dim()) {
271 Some(mask) => {
272 let s_red = project_onto(&*s, mask);
273 let y_red = project_onto(&*y, mask);
274 self.ingest_pair(s_red, y_red)
275 }
276 None => self.ingest_pair(Rc::from(s), Rc::from(y)),
277 };
278
279 // `limited_memory_max_skipping` (#686). Upstream discards the
280 // whole approximation once the curvature update has been
281 // skipped this many times in a row
282 // (`IpLimMemQuasiNewtonUpdater.cpp`, the
283 // `lm_skipped_iter_ >= limited_memory_max_skipping_` branch,
284 // which also emits the `Wr` info string). pounce accumulated
285 // the skip but never acted on it, so a run whose curvature
286 // stays negative kept its opening pairs for the rest of the
287 // solve.
288 //
289 // The re-anchoring upstream does inside its reset branch
290 // (`last_x_`, `last_grad_f_`, `last_jac_c_`, `last_jac_d_`)
291 // is what the four assignments below this block already do
292 // unconditionally on every call, so clearing the history is
293 // the whole of it here.
294 if accepted {
295 self.skipped_iter = 0;
296 } else {
297 self.skipped_iter = self.skipped_iter.saturating_add(1);
298 if self.max_skipping > 0 && self.skipped_iter >= self.max_skipping {
299 self.history.clear();
300 self.skipped_iter = 0;
301 data.borrow_mut().append_info_string("Wr");
302 }
303 }
304 }
305 self.last_x = Some(Rc::clone(&curr_x));
306 self.last_grad_f = Some(Rc::clone(&curr_grad_f));
307 self.last_jac_c = Some(Rc::clone(&curr_jac_c));
308 self.last_jac_d = Some(Rc::clone(&curr_jac_d));
309
310 let n_idx = curr_x.dim();
311 // Dimension the low-rank build runs in: the nonlinear subspace
312 // when a mask is active, the full primal space otherwise.
313 let n_red = match self.active_mask(n_idx) {
314 Some(mask) => mask.len() as Index,
315 None => n_idx,
316 };
317 let nu = n_red as usize;
318 let sigma = match self.update_type {
319 UpdateType::Bfgs => self.compute_sigma_bfgs(),
320 // SR1 uses the same `LIM_MEM_INIT` sigma source as BFGS for
321 // the diagonal `B0`; the rank-1 corrections carry the sign.
322 UpdateType::Sr1 => self.compute_sigma_bfgs(),
323 };
324
325 // Build the compact factors of B = σ I + V Vᵀ − U Uᵀ by walking
326 // the curvature history. No dense `n×n` is ever formed: the walk
327 // is `O(n · history_len)` per pair. Publishing a
328 // `LowRankUpdateSymMatrix` lets `LowRankAugSystemSolver` apply the
329 // Hessian via Sherman-Morrison-Woodbury with `O(n · m)` storage
330 // for arbitrarily large `n`.
331 let (v_cols, u_cols) = self.build_low_rank(sigma, nu);
332
333 // Build `D`, `V`, `U` in `curr_x`'s *native* vector space rather
334 // than a fabricated flat `DenseVectorSpace`. For an ordinary
335 // (dense-primal) solve this is the same dense space as before; in
336 // the feasibility-restoration sub-IPM the primal is a 5-block
337 // `CompoundVector` `[orig | n_c | p_c | n_d | p_d]`, and a flat
338 // dense `W` cannot be multiplied against those compound iterates —
339 // `LowRankUpdateSymMatrix::mult_vector` panics in
340 // `element_wise_multiply`/`lr_mult_vector` the moment restoration
341 // runs (pounce#102). Cloning `curr_x` keeps `W` type-consistent
342 // with the space it operates on.
343 //
344 // Under a nonlinear-variable mask the reduced space is a plain
345 // dense space of its own — the compound-vector concern above is
346 // moot there, because the mask is only ever installed for the
347 // original NLP (the restoration sub-IPM clears it: its primal is
348 // the 5-block compound, whose indices mean something else).
349 let mask = self.active_mask(n_idx).map(|m| m.to_vec());
350 let col_space = DenseVectorSpace::new(n_red);
351 let reduced_proto: Option<DenseVector> = mask.as_ref().map(|_| col_space.make_new_dense());
352 let proto: &dyn Vector = match reduced_proto.as_ref() {
353 Some(p) => p,
354 None => curr_x.as_ref(),
355 };
356
357 // The diagonal `B0` spans the **full** primal space, masked or
358 // not: σ on the nonlinear coordinates, and
359 // `limited_memory_init_val_min` (1e-8 by default) as a floor on
360 // the rest. Only the curvature columns `V`/`U` are restricted to
361 // the subspace.
362 //
363 // **Deliberate divergence from upstream (gh#624).** Ipopt builds
364 // this space as
365 // `LowRankUpdateSymMatrixSpace(dim, P_LM, /*reduced_diag=*/true)`
366 // (`IpOrigIpoptNLP.cpp:InitializeStructures`), which puts σ in the
367 // small space only, so `W` is *exactly zero* on the variables that
368 // enter linearly. That is the truthful Hessian — the second
369 // derivatives really are zero there — and it is a trap for the
370 // augmented system: those rows of the `(1,1)` block are then
371 // carried by the barrier term `Σ_x` alone, which is ~0 for a
372 // variable sitting far from its bounds, and the symmetric
373 // factorization pays for a near-singular diagonal on every one of
374 // them. Measured on a model with 2 nonlinear and 2000 linear
375 // variables (all three reach the same KKT point):
376 //
377 // exact zero (upstream) 4.7 s 28 iterations
378 // floor = 1e-8 (this code) 0.93 s 28 iterations
379 // no mask at all 0.89 s 25 iterations
380 //
381 // At 10 000 linear variables the mask then does what it is for:
382 // 5.1 s / 27 iterations against 6.0 s / 31 unmasked.
383 //
384 // Ipopt's own limited-memory path takes **399 s** on that model
385 // with `pass_nonlinear_variables` on, against 0.40 s off — the
386 // same effect, two orders of magnitude worse, which is what
387 // convinced us the flag rather than the port was at fault.
388 //
389 // The floor is the smallest intervention that works, and it was
390 // picked over the obvious alternative. Filling the whole diagonal
391 // with σ (`reduced_diag = false` and nothing else) is equally
392 // fast, but it injects a proximal term of the *problem's own
393 // curvature scale* into coordinates whose curvature is zero, and
394 // — unlike the unmasked path, where L-BFGS learns that and
395 // corrects σ back down — the masked update has no columns there
396 // to correct it with. On the 6-variable fixture in
397 // `crates/pounce-cinterface/tests/nonlinear_variables_mask.rs`
398 // that turns a `Solve_Succeeded` at `tol=1e-9` into a stall at
399 // `Solved_To_Acceptable_Level`. At 1e-8 the term is far below any
400 // tolerance the solver reasons about, and the tail converges.
401 //
402 // What the mask buys is untouched either way: curvature
403 // information kept free of the linear block, and `O(n_nonlin · m)`
404 // rather than `O(n · m)` storage for the columns.
405 let mut diag = curr_x.make_new();
406 diag.set(sigma);
407 if let Some(m) = mask.as_ref() {
408 // σ on the nonlinear coordinates, the curvature floor on the
409 // rest. `limited_memory_init_val_min` is registered with a
410 // strict lower bound of 0, so this diagonal can never be
411 // exactly zero — which is the whole point (see above).
412 let mut vals = vec![self.init_val_min; n_idx as usize];
413 for &i in m {
414 vals[i as usize] = sigma;
415 }
416 set_expanded(diag.as_mut(), &vals);
417 }
418
419 // `P` lifts the reduced low-rank update back into full-x.
420 let p_lm: Option<Rc<dyn pounce_linalg::matrix::Matrix>> = mask.as_ref().map(|m| {
421 let space = ExpansionMatrixSpace::new(n_idx, n_red, m, 0);
422 Rc::new(ExpansionMatrix::new(space)) as Rc<dyn pounce_linalg::matrix::Matrix>
423 });
424 let lr_space = LowRankUpdateSymMatrixSpace::new(n_idx, p_lm, false);
425 let mut lr = lr_space.make_new_low_rank();
426 lr.set_diag(Rc::from(diag));
427 if let Some(mvm) = build_multi_vector(&col_space, proto, &v_cols) {
428 lr.set_v(Rc::new(mvm));
429 }
430 if let Some(mvm) = build_multi_vector(&col_space, proto, &u_cols) {
431 lr.set_u(Rc::new(mvm));
432 }
433
434 data.borrow_mut().w = Some(Rc::new(lr));
435 true
436 }
437
438 /// Drop every curvature pair but the newest, so `B` falls back to
439 /// `σI` plus the single rank-2 update carrying the freshest
440 /// measured curvature (gh#818).
441 ///
442 /// **Why keep one pair rather than clear the history outright.**
443 /// `compute_sigma_bfgs` reads σ off the history, and an *empty*
444 /// history returns `limited_memory_init_val` — a bare `1.0`, which
445 /// is a curvature scale only by coincidence. Re-anchoring to that
446 /// throws the model back to its first-iteration state on a problem
447 /// whose curvature the solver has by now measured, and the first
448 /// iteration is precisely where the badly-scaled step comes from:
449 /// on gh#818's quadratic, iteration 1 is the one that needs 20
450 /// backtracks. Keeping the newest pair keeps σ a real Rayleigh
451 /// quotient and keeps the secant condition `B s = y` on the step
452 /// the solver just took, while discarding the older corrections
453 /// that made the direction unusable.
454 ///
455 /// Returns `false` when there is nothing to discard — an empty
456 /// history, or a history already down to its newest pair — which is
457 /// what makes one re-anchor per stall the natural bound: the second
458 /// failure at the same iterate finds nothing to give up and the
459 /// caller falls through.
460 fn reanchor(&mut self) -> bool {
461 if self.history.len() <= 1 {
462 return false;
463 }
464 let newest = self.history.pop().expect("len > 1");
465 self.history.clear();
466 self.history.push(newest);
467 // The skip counter measures consecutive *rejected* pairs and
468 // drives `limited_memory_max_skipping`; a deliberate re-anchor
469 // is not a rejection, and leaving the count standing would make
470 // the next skipped pair discard the pair we just chose to keep.
471 self.skipped_iter = 0;
472 true
473 }
474}
475
476impl LimMemQuasiNewtonUpdater {
477 /// The nonlinear-variable mask, if one applies to a primal space of
478 /// dimension `n`.
479 ///
480 /// The guard is deliberate: a mask is stated over the *original*
481 /// NLP's variables, and the restoration sub-IPM solves a different
482 /// problem whose primal is `[orig | n_c | p_c | n_d | p_d]`.
483 /// `run_inner_resto` clears the mask for that solve; this check is
484 /// the belt to that suspenders, so a masked updater reused against a
485 /// space it was not built for silently degrades to the full-space
486 /// approximation instead of indexing into the wrong variables.
487 fn active_mask(&self, n: Index) -> Option<&[Index]> {
488 let mask = self.nonlinear_vars.as_deref()?;
489 if mask.is_empty() || mask.len() as Index >= n {
490 return None;
491 }
492 if mask.last().is_some_and(|&last| last >= n) {
493 return None;
494 }
495 Some(mask)
496 }
497
498 /// σ, the diagonal of `B0`, for this iteration's rebuild.
499 ///
500 /// Every upstream rule reads the **newest** curvature pair only.
501 /// [`InitialApprox::HistoryMax`] is the one exception: it applies
502 /// the `scalar1` formula to every pair in the window and takes the
503 /// largest (gh#818).
504 ///
505 /// **Why a maximum.** σ is the curvature the model assigns to every
506 /// direction *outside* the span of the stored pairs — the rank-2
507 /// corrections say nothing there. `sᵀy/sᵀs` is a Rayleigh quotient
508 /// of the true Hessian along one step, so on a problem whose
509 /// curvature spans orders of magnitude it is an arbitrary sample of
510 /// the spectrum. When it lands near the small end, `B` understates
511 /// the curvature of every unexplored direction by up to `cond(H)`,
512 /// `d = −B⁻¹∇f` is longer than the truth by that factor, and a
513 /// backtracking line search can only recover by halving.
514 ///
515 /// The two errors are not symmetric. Over-stating σ shortens the
516 /// step: the line search accepts `α = 1` and the iteration is
517 /// merely less ambitious. Under-stating it costs a whole
518 /// backtracking sequence — measured at 19–20 trial points per
519 /// iteration on gh#818's 8-variable quadratic, landing at
520 /// `α ≈ 4e-6` — and the tiny step then feeds a tiny `s` back into
521 /// the history, so the next σ is drawn from an even narrower
522 /// sample.
523 ///
524 /// The pairs in the window are all measured curvature of the same
525 /// Lagrangian at nearby iterates, so the largest of them is the
526 /// stiffest thing the solver has actually seen and is the
527 /// conservative reading. Nothing is lost on the directions the
528 /// model *does* know: the last rank-2 update enforces
529 /// `B s_last = y_last` whatever `B0` was, so the secant condition
530 /// on the newest pair holds under this rule exactly as under
531 /// `scalar1`.
532 ///
533 /// The window matters. A running maximum over the whole solve — the
534 /// obvious variant — is monotone and never comes back down, so it
535 /// keeps a stiff early transient in `B0` long after the iterate has
536 /// left it; measured on gh#818's fixture it is worse than `scalar1`
537 /// at every size (131 vs 36 iterations at `n = 4`, and no
538 /// convergence at all at `n = 8`). Bounding the maximum by the
539 /// history window lets σ decay as the pairs turn over.
540 fn compute_sigma_bfgs(&self) -> Number {
541 if self.history.is_empty() {
542 // Upstream: `B0 = limited_memory_init_val * I` "in the first
543 // iteration (when no updates have been performed yet)".
544 return self.init_val;
545 }
546 let per_pair = |p: &CurvaturePair| {
547 initial_hessian_scalar(
548 self.initial_approx,
549 p.s_norm * p.s_norm,
550 p.s_dot_y,
551 p.y_norm * p.y_norm,
552 self.init_val,
553 self.init_val_min,
554 self.init_val_max,
555 )
556 };
557 if self.initial_approx == InitialApprox::HistoryMax {
558 // `clamp` is monotone, so folding the max over already-clamped
559 // per-pair values is the same number as clamping the max.
560 return self
561 .history
562 .iter()
563 .map(per_pair)
564 .fold(Number::NEG_INFINITY, Number::max);
565 }
566 per_pair(self.history.last().unwrap())
567 }
568
569 /// Walk the curvature-pair history oldest→newest, applying the BFGS
570 /// rank-2 / SR1 rank-1 recurrences against the running approximation
571 /// `B = σ I + V Vᵀ − U Uᵀ` to grow the dense column lists `V` and
572 /// `U`. Returns `(v_cols, u_cols)` in full primal space.
573 ///
574 /// For BFGS, each accepted pair `(s, y)` appends one positive column
575 /// `r/√(sᵀr)` (the `r rᵀ/(sᵀr)` term, `r = θ y + (1−θ) Bs` after
576 /// Powell damping) and one negative column `Bs/√(sᵀBs)` (the
577 /// `−(Bs)(Bs)ᵀ/(sᵀBs)` term). For SR1 each pair appends a single
578 /// column `(y−Bs)/√|denom|` to `V` (denom > 0) or `U` (denom < 0).
579 /// This reproduces, column for column, the action of the former
580 /// dense rebuild while never materializing an `n×n` buffer.
581 fn build_low_rank(&self, sigma: Number, n: usize) -> (Vec<Vec<Number>>, Vec<Vec<Number>>) {
582 let mut v_cols: Vec<Vec<Number>> = Vec::new();
583 let mut u_cols: Vec<Vec<Number>> = Vec::new();
584 if n == 0 {
585 return (v_cols, u_cols);
586 }
587 for pair in &self.history {
588 let s = dense_from_vec(pair.s.as_ref(), n);
589 let y = dense_from_vec(pair.y.as_ref(), n);
590
591 // bs = B s = σ s + Σ_v (vᵀs) v − Σ_u (uᵀs) u.
592 let mut bs: Vec<Number> = s.iter().map(|&si| sigma * si).collect();
593 for v in &v_cols {
594 let c: Number = (0..n).map(|i| v[i] * s[i]).sum();
595 for i in 0..n {
596 bs[i] += c * v[i];
597 }
598 }
599 for u in &u_cols {
600 let c: Number = (0..n).map(|i| u[i] * s[i]).sum();
601 for i in 0..n {
602 bs[i] -= c * u[i];
603 }
604 }
605
606 match self.update_type {
607 UpdateType::Bfgs => {
608 let s_bs: Number = (0..n).map(|i| s[i] * bs[i]).sum();
609 if s_bs <= 0.0 {
610 continue;
611 }
612 // Textbook BFGS, which is what upstream forms:
613 //
614 // v_new = y_new / sqrt(sᵀy) (positive column)
615 // u_new = B₀·S·C (negative column)
616 //
617 // `y` is used as it stands. pounce used to blend it
618 // toward `B s` by a Powell damping factor whenever
619 // `sᵀy < 0.2·sᵀBs`, citing
620 // `IpLimMemQuasiNewtonUpdater.cpp:PowellDamping` —
621 // a function that does not exist. Upstream's
622 // `CheckSkippingBFGS` takes `const Vector&` for both
623 // `s_new` and `y_new` and returns a bool, so it
624 // cannot modify a pair, and nothing else in that file
625 // does either: a pair is skipped or it is stored as
626 // measured (#686).
627 //
628 // Damping was not a harmless extra safeguard. It
629 // fired on every accepted pair with marginal
630 // curvature and silently replaced the measured
631 // curvature with a synthetic one, on a path where
632 // upstream's answer to marginal curvature is to skip
633 // the pair and — after `limited_memory_max_skipping`
634 // of them — discard the history. That strategy is
635 // complete on its own, and it is now implemented.
636 //
637 // `sᵀy > sqrt(eps)·‖s‖·‖y‖ > 0` holds for every pair
638 // in `history` by the skip criterion, so the square
639 // root below is of a positive number without needing
640 // the damped `sr` guard that used to stand here.
641 let sy = pair.s_dot_y;
642 if sy <= 0.0 {
643 continue;
644 }
645 let y_scale = 1.0 / sy.sqrt();
646 let bs_scale = 1.0 / s_bs.sqrt();
647 // y yᵀ / sᵀy → positive column y/√(sᵀy).
648 v_cols.push(y.iter().map(|&yi| yi * y_scale).collect());
649 // −(Bs)(Bs)ᵀ / sᵀBs → negative column Bs/√(sᵀBs).
650 u_cols.push(bs.iter().map(|&bi| bi * bs_scale).collect());
651 }
652 UpdateType::Sr1 => {
653 let yms: Vec<Number> = (0..n).map(|i| y[i] - bs[i]).collect();
654 let denom: Number = (0..n).map(|i| yms[i] * s[i]).sum();
655 let yms_norm: Number = yms.iter().map(|&w| w * w).sum::<Number>().sqrt();
656 if !sr1_denominator_ok(denom, pair.s_norm, yms_norm) {
657 continue;
658 }
659 let scale = 1.0 / denom.abs().sqrt();
660 let col: Vec<Number> = yms.iter().map(|&w| w * scale).collect();
661 if denom > 0.0 {
662 v_cols.push(col);
663 } else {
664 u_cols.push(col);
665 }
666 }
667 }
668 }
669 (v_cols, u_cols)
670 }
671}
672
673/// Pack flat column data into a [`MultiVectorMatrix`] whose columns are
674/// allocated in `template`'s native vector space (so the resulting
675/// low-rank `W` is type-consistent with the primal iterates — dense for
676/// an ordinary solve, a 5-block resto `CompoundVector` under restoration;
677/// see pounce#102). Returns `None` when there are no columns, so the
678/// caller leaves the corresponding V/U slot unset.
679fn build_multi_vector(
680 col_space: &Rc<DenseVectorSpace>,
681 template: &dyn Vector,
682 cols: &[Vec<Number>],
683) -> Option<MultiVectorMatrix> {
684 if cols.is_empty() {
685 return None;
686 }
687 let space = MultiVectorMatrixSpace::new(cols.len() as Index, Rc::clone(col_space));
688 let mut mvm = space.make_new_multi_vector();
689 for (k, col) in cols.iter().enumerate() {
690 let mut cv = template.make_new();
691 set_expanded(cv.as_mut(), col);
692 mvm.set_vector(k as Index, Rc::from(cv));
693 }
694 Some(mvm)
695}
696
697/// Flatten a primal vector to its dense expanded values, handling both a
698/// plain [`DenseVector`] and a (possibly nested) restoration
699/// [`CompoundVector`].
700fn expanded_of(v: &dyn Vector) -> Vec<Number> {
701 if let Some(dv) = v.as_any().downcast_ref::<DenseVector>() {
702 return dv.expanded_values();
703 }
704 if let Some(cv) = v.as_any().downcast_ref::<CompoundVector>() {
705 let mut out = Vec::with_capacity(cv.dim() as usize);
706 for i in 0..cv.n_comps() {
707 out.extend(expanded_of(cv.comp(i)));
708 }
709 return out;
710 }
711 panic!("LimMemQuasiNewtonUpdater: unsupported primal vector type for expansion");
712}
713
714/// Inverse of [`expanded_of`]: scatter a flat slice back into a primal
715/// vector of the same structure (dense or compound).
716fn set_expanded(dst: &mut dyn Vector, flat: &[Number]) {
717 if let Some(dv) = dst.as_any_mut().downcast_mut::<DenseVector>() {
718 dv.set_values(flat);
719 return;
720 }
721 if let Some(cv) = dst.as_any_mut().downcast_mut::<CompoundVector>() {
722 let n = cv.n_comps();
723 let dims: Vec<usize> = (0..n).map(|i| cv.comp(i).dim() as usize).collect();
724 let mut off = 0usize;
725 for (i, &d) in dims.iter().enumerate() {
726 set_expanded(cv.comp_mut(i as Index), &flat[off..off + d]);
727 off += d;
728 }
729 return;
730 }
731 panic!("LimMemQuasiNewtonUpdater: unsupported primal vector type for set_expanded");
732}
733
734/// Gather the entries of `v` named by `mask` into a fresh dense vector
735/// of dimension `mask.len()` — the `Pᵀ v` of upstream's expansion
736/// matrix, done by index because the mask path never sees a compound
737/// primal.
738fn project_onto(v: &dyn Vector, mask: &[Index]) -> Rc<dyn Vector> {
739 let full = expanded_of(v);
740 let small: Vec<Number> = mask.iter().map(|&i| full[i as usize]).collect();
741 let mut out = DenseVectorSpace::new(mask.len() as Index).make_new_dense();
742 out.set_values(&small);
743 Rc::new(out)
744}
745
746fn dense_from_vec(v: &dyn Vector, n: usize) -> Vec<Number> {
747 let ev = expanded_of(v);
748 debug_assert_eq!(ev.len(), n);
749 ev
750}
751
752/// Initial Hessian scalar used as the diagonal of `B_0` before the
753/// rank-2 updates are applied. Mirrors upstream's
754/// `limited_memory_initialization` values
755/// (`IpLimMemQuasiNewtonUpdater.cpp:RegisterOptions`):
756///
757/// * `Scalar1` → `(s^T y) / (s^T s)` — upstream's default
758/// * `Scalar2` → `(y^T y) / (s^T y)`
759/// * `Scalar3` → arithmetic mean of `Scalar1` and `Scalar2`
760/// * `Scalar4` → geometric mean of `Scalar1` and `Scalar2`
761/// * `Constant` → `init_val` (`limited_memory_init_val`)
762/// * `Identity` → `1.0` (no upstream keyword; direct callers only)
763/// * `HistoryMax` → the `Scalar1` formula (no upstream keyword). This
764/// kernel is per-pair; what makes `HistoryMax` different from
765/// `Scalar1` is that
766/// [`LimMemQuasiNewtonUpdater::compute_sigma_bfgs`] calls it on every
767/// pair in the window and keeps the largest, rather than calling it
768/// on the newest pair alone (gh#818).
769///
770/// Each degenerate denominator falls back to `1.0` independently, so
771/// `Scalar3`/`Scalar4` degrade to the mean of whichever term is
772/// well-defined rather than to a single fallback for the pair.
773/// `Scalar4`'s geometric mean is taken on the product of two
774/// non-negative terms; a non-positive product falls back to `1.0`
775/// rather than producing a NaN.
776///
777/// Result is clamped to `[min_val, max_val]` per upstream's
778/// `limited_memory_init_val_{min,max}` defaults.
779pub fn initial_hessian_scalar(
780 init: InitialApprox,
781 s_dot_s: Number,
782 s_dot_y: Number,
783 y_dot_y: Number,
784 init_val: Number,
785 min_val: Number,
786 max_val: Number,
787) -> Number {
788 let scalar1 = || {
789 if s_dot_s > 0.0 {
790 s_dot_y / s_dot_s
791 } else {
792 1.0
793 }
794 };
795 let scalar2 = || {
796 if s_dot_y > 0.0 {
797 y_dot_y / s_dot_y
798 } else {
799 1.0
800 }
801 };
802 let raw = match init {
803 InitialApprox::Identity => 1.0,
804 InitialApprox::Scalar1 => scalar1(),
805 InitialApprox::Scalar2 => scalar2(),
806 InitialApprox::Scalar3 => 0.5 * (scalar1() + scalar2()),
807 InitialApprox::Scalar4 => {
808 let prod = scalar1() * scalar2();
809 if prod > 0.0 { prod.sqrt() } else { 1.0 }
810 }
811 InitialApprox::Constant => init_val,
812 // Per-pair, `HistoryMax` *is* `Scalar1`; the maximum is taken
813 // over the history by the caller.
814 InitialApprox::HistoryMax => scalar1(),
815 };
816 raw.clamp(min_val, max_val)
817}
818
819/// L-BFGS curvature-pair acceptance: include `(s, y)` in history iff
820/// `s^T y > eps * ||s|| ||y||`. Mirrors upstream's skip-criterion
821/// (`IpLimMemQuasiNewtonUpdater.cpp` ~line 750: `eps = 1e-8`).
822pub fn bfgs_curvature_pair_ok(s_dot_y: Number, s_norm: Number, y_norm: Number) -> bool {
823 // `sqrt(machine epsilon)`, matching upstream's
824 // `CheckSkippingBFGS` (`IpLimMemQuasiNewtonUpdater.cpp`):
825 //
826 // Number tol = std::sqrt(std::numeric_limits<Number>::epsilon());
827 // skipping = (sTy <= tol * snrm * ynrm);
828 //
829 // This was a hardcoded `1e-8` until #686, attributed to upstream but
830 // not equal to it — `sqrt(f64::EPSILON)` is 1.4901161193847656e-8,
831 // so the old value accepted a band of pairs upstream skips.
832 let eps = f64::EPSILON.sqrt();
833 s_dot_y > eps * s_norm * y_norm
834}
835
836/// SR1 acceptance: the SR1 update divides by `(y - Bs)^T s`, so we
837/// need `|(y - Bs)^T s| > eps * ||s|| ||y - Bs||`. Mirrors upstream's
838/// `IpLimMemQuasiNewtonUpdater.cpp` SR1 skip-criterion.
839pub fn sr1_denominator_ok(yms_dot_s: Number, s_norm: Number, yms_norm: Number) -> bool {
840 let eps = 1e-8_f64;
841 yms_dot_s.abs() > eps * s_norm * yms_norm
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn identity_init_returns_one() {
850 assert_eq!(
851 initial_hessian_scalar(InitialApprox::Identity, 1.0, 1.0, 1.0, 1.0, 1e-8, 1e8),
852 1.0
853 );
854 }
855
856 #[test]
857 fn scalar1_init_is_sy_over_ss() {
858 // s_dot_s=4, s_dot_y=2 → 2/4 = 0.5.
859 let v = initial_hessian_scalar(InitialApprox::Scalar1, 4.0, 2.0, 0.0, 1.0, 1e-8, 1e8);
860 assert!((v - 0.5).abs() < 1e-15);
861 }
862
863 #[test]
864 fn scalar2_init_is_yy_over_sy() {
865 // y_dot_y=8, s_dot_y=2 → 4.
866 let v = initial_hessian_scalar(InitialApprox::Scalar2, 0.0, 2.0, 8.0, 1.0, 1e-8, 1e8);
867 assert!((v - 4.0).abs() < 1e-15);
868 }
869
870 #[test]
871 fn scalar3_init_is_arithmetic_mean_of_scalar1_and_scalar2() {
872 // s_dot_s=4, s_dot_y=2 → scalar1 = 0.5; y_dot_y=8 → scalar2 = 4.
873 let v = initial_hessian_scalar(InitialApprox::Scalar3, 4.0, 2.0, 8.0, 1.0, 1e-8, 1e8);
874 assert!((v - 2.25).abs() < 1e-15, "got {v}");
875 }
876
877 #[test]
878 fn scalar4_init_is_geometric_mean_of_scalar1_and_scalar2() {
879 // scalar1 = 0.5, scalar2 = 4 → sqrt(2).
880 let v = initial_hessian_scalar(InitialApprox::Scalar4, 4.0, 2.0, 8.0, 1.0, 1e-8, 1e8);
881 assert!((v - 2.0_f64.sqrt()).abs() < 1e-15, "got {v}");
882 }
883
884 #[test]
885 fn scalar4_falls_back_rather_than_producing_nan() {
886 // s_dot_y < 0 makes scalar1 negative while scalar2 falls back to
887 // 1.0, so the product is negative — sqrt would be NaN. A NaN σ
888 // would propagate silently into the whole `B0` diagonal.
889 let v = initial_hessian_scalar(InitialApprox::Scalar4, 4.0, -2.0, 8.0, 1.0, 1e-8, 1e8);
890 assert!(v.is_finite(), "sigma must stay finite, got {v}");
891 assert_eq!(v, 1.0);
892 }
893
894 #[test]
895 fn constant_init_returns_init_val_not_the_curvature_formula() {
896 // Same (s, y) that gives scalar2 = 4 above; `constant` must
897 // ignore the pair entirely and return `init_val`.
898 let v = initial_hessian_scalar(InitialApprox::Constant, 4.0, 2.0, 8.0, 7.5, 1e-8, 1e8);
899 assert_eq!(v, 7.5);
900 }
901
902 #[test]
903 fn constant_init_is_still_clamped() {
904 let v = initial_hessian_scalar(InitialApprox::Constant, 4.0, 2.0, 8.0, 1e20, 1e-8, 1e8);
905 assert_eq!(v, 1e8);
906 }
907
908 #[test]
909 fn empty_history_sigma_honours_init_val() {
910 // Upstream's "B0 = limited_memory_init_val * I in the first
911 // iteration". This branch returned a hard-coded 1.0 before #677,
912 // which silently matched the default and hid the missing wiring.
913 let mut u = LimMemQuasiNewtonUpdater::new();
914 u.init_val = 3.0;
915 assert!(u.history.is_empty());
916 assert_eq!(u.compute_sigma_bfgs(), 3.0);
917 }
918
919 #[test]
920 fn init_clamped_to_max() {
921 let v = initial_hessian_scalar(InitialApprox::Scalar2, 0.0, 1e-20, 1.0, 1.0, 1e-8, 1e8);
922 assert_eq!(v, 1e8);
923 }
924
925 #[test]
926 fn init_clamped_to_min() {
927 let v = initial_hessian_scalar(InitialApprox::Scalar2, 0.0, 1e20, 1.0, 1.0, 1e-8, 1e8);
928 assert_eq!(v, 1e-8);
929 }
930
931 #[test]
932 fn bfgs_skip_criterion() {
933 // s_dot_y = 1, ||s|| = 1, ||y|| = 1 → 1 > 1e-8: ok.
934 assert!(bfgs_curvature_pair_ok(1.0, 1.0, 1.0));
935 // s_dot_y = 1e-10, ||s|| = 1, ||y|| = 1 → 1e-10 < 1e-8: skip.
936 assert!(!bfgs_curvature_pair_ok(1e-10, 1.0, 1.0));
937 }
938
939 #[test]
940 fn sr1_skip_criterion_uses_absolute_value() {
941 // Negative numerator is fine for SR1 (rank-1 update can have either sign).
942 assert!(sr1_denominator_ok(-1.0, 1.0, 1.0));
943 assert!(!sr1_denominator_ok(1e-10, 1.0, 1.0));
944 }
945
946 fn rcv(values: &[Number]) -> Rc<dyn Vector> {
947 let mut v = pounce_linalg::dense_vector::DenseVectorSpace::new(values.len() as i32)
948 .make_new_dense();
949 v.set(0.0);
950 v.values_mut().copy_from_slice(values);
951 Rc::new(v)
952 }
953
954 #[test]
955 fn ingest_pair_accepts_well_curved_pair() {
956 let mut updater = LimMemQuasiNewtonUpdater::new();
957 // s = (1, 0), y = (1, 0); s·y = 1 > 1e-8.
958 let accepted = updater.ingest_pair(rcv(&[1.0, 0.0]), rcv(&[1.0, 0.0]));
959 assert!(accepted);
960 assert_eq!(updater.history.len(), 1);
961 let pair = &updater.history[0];
962 assert!((pair.s_dot_y - 1.0).abs() < 1e-15);
963 assert!((pair.s_norm - 1.0).abs() < 1e-15);
964 assert!((pair.y_norm - 1.0).abs() < 1e-15);
965 }
966
967 #[test]
968 fn ingest_pair_skips_zero_curvature() {
969 let mut updater = LimMemQuasiNewtonUpdater::new();
970 // s · y = 0 ⇒ skip per BFGS criterion (eps · ||s|| · ||y||).
971 let accepted = updater.ingest_pair(rcv(&[1.0]), rcv(&[0.0]));
972 assert!(!accepted);
973 assert!(updater.history.is_empty());
974 }
975
976 /// The skip counter drives a reset, and a run of skips does not
977 /// leave stale curvature behind (#686).
978 ///
979 /// Upstream discards the whole approximation after
980 /// `limited_memory_max_skipping` consecutive skips. pounce counted
981 /// nothing and never discarded, so a problem whose curvature stays
982 /// negative kept its opening pairs for the rest of the solve — the
983 /// model stops describing where the iterate is, `inf_pr` converges
984 /// and `inf_du` plateaus.
985 ///
986 /// Driven through `ingest_pair` plus the counter logic rather than
987 /// through `update_hessian`, which needs a full data/cq fixture; the
988 /// corpus is what exercises the wired path (`cresc4` goes from
989 /// `Restoration_Failed` to solved at the exact-Hessian optimum).
990 #[test]
991 fn consecutive_skips_reset_the_approximation() {
992 let mut u = LimMemQuasiNewtonUpdater::new();
993 assert_eq!(u.max_skipping, 2, "upstream default");
994
995 // Two good pairs establish a history.
996 assert!(u.ingest_pair(rcv(&[1.0, 0.0]), rcv(&[1.0, 0.0])));
997 assert!(u.ingest_pair(rcv(&[0.0, 1.0]), rcv(&[0.0, 1.0])));
998 assert_eq!(u.history.len(), 2);
999
1000 // A skip alone must not discard anything — one bad pair on an
1001 // otherwise healthy run is what the skip criterion is for.
1002 assert!(!u.ingest_pair(rcv(&[1.0, 0.0]), rcv(&[-1.0, 0.0])));
1003 u.skipped_iter += 1;
1004 assert!(u.skipped_iter < u.max_skipping);
1005 assert_eq!(u.history.len(), 2, "one skip must not reset");
1006
1007 // The second consecutive skip reaches the threshold.
1008 assert!(!u.ingest_pair(rcv(&[0.0, 1.0]), rcv(&[0.0, -1.0])));
1009 u.skipped_iter += 1;
1010 assert!(u.skipped_iter >= u.max_skipping, "reset is due");
1011 }
1012
1013 /// The skip tolerance is upstream's, not a round number (#686).
1014 ///
1015 /// `CheckSkippingBFGS` uses `sqrt(machine epsilon)`; pounce carried a
1016 /// hardcoded `1e-8` attributed to upstream. The gap is small and it
1017 /// is exactly the band where the two solvers disagree about whether
1018 /// a pair is usable, which is where a side-by-side trace starts
1019 /// drifting.
1020 #[test]
1021 fn skip_tolerance_is_sqrt_machine_epsilon() {
1022 let eps = f64::EPSILON.sqrt();
1023 assert!((eps - 1.4901161193847656e-8).abs() < 1e-24, "got {eps}");
1024 // A pair inside the old-vs-new gap: accepted under 1e-8,
1025 // skipped under sqrt(eps).
1026 let s_dot_y = 1.2e-8;
1027 assert!(s_dot_y > 1e-8, "would have been accepted before");
1028 assert!(!bfgs_curvature_pair_ok(s_dot_y, 1.0, 1.0));
1029 }
1030
1031 #[test]
1032 fn history_caps_at_max_history() {
1033 let mut updater = LimMemQuasiNewtonUpdater {
1034 max_history: 2,
1035 ..LimMemQuasiNewtonUpdater::default()
1036 };
1037 for _ in 0..5 {
1038 updater.ingest_pair(rcv(&[1.0]), rcv(&[1.0]));
1039 }
1040 assert_eq!(updater.history.len(), 2);
1041 }
1042
1043 #[test]
1044 fn sr1_path_routes_through_sr1_skip() {
1045 let mut updater = LimMemQuasiNewtonUpdater {
1046 update_type: UpdateType::Sr1,
1047 ..LimMemQuasiNewtonUpdater::default()
1048 };
1049 // SR1's heuristic accepts negative s·y (rank-1 sign-indefinite).
1050 assert!(updater.ingest_pair(rcv(&[1.0]), rcv(&[-1.0])));
1051 }
1052
1053 fn pair(s: &[Number], y: &[Number]) -> CurvaturePair {
1054 let s_rc = rcv(s);
1055 let y_rc = rcv(y);
1056 let s_dot_y = s_rc.dot(&*y_rc);
1057 let s_norm = s_rc.nrm2();
1058 let y_norm = y_rc.nrm2();
1059 CurvaturePair {
1060 s: s_rc,
1061 y: y_rc,
1062 s_dot_y,
1063 s_norm,
1064 y_norm,
1065 }
1066 }
1067
1068 /// Reconstruct the dense `B = σ I + V Vᵀ − U Uᵀ` from the low-rank
1069 /// factors so we can check the Hessian *action* the SMW solver sees.
1070 fn reconstruct_b(n: usize, sigma: Number, v: &[Vec<Number>], u: &[Vec<Number>]) -> Vec<Number> {
1071 let mut b = vec![0.0_f64; n * n];
1072 for i in 0..n {
1073 b[i * n + i] = sigma;
1074 }
1075 for col in v {
1076 for i in 0..n {
1077 for j in 0..n {
1078 b[i * n + j] += col[i] * col[j];
1079 }
1080 }
1081 }
1082 for col in u {
1083 for i in 0..n {
1084 for j in 0..n {
1085 b[i * n + j] -= col[i] * col[j];
1086 }
1087 }
1088 }
1089 b
1090 }
1091
1092 fn mat_vec(b: &[Number], n: usize, x: &[Number]) -> Vec<Number> {
1093 (0..n)
1094 .map(|i| (0..n).map(|j| b[i * n + j] * x[j]).sum())
1095 .collect()
1096 }
1097
1098 #[test]
1099 fn bfgs_low_rank_recovers_hessian_action() {
1100 // For a strictly-convex quadratic f(x) = ½ xᵀ A x with A SPD,
1101 // a single BFGS update from B₀ = I along a curvature pair
1102 // (s, y = A s) reproduces A on the s-direction: B₁ s = y = A s.
1103 // Use A = diag(2, 5), s = (1, 1), so y = (2, 5).
1104 let mut up = LimMemQuasiNewtonUpdater::new();
1105 up.history.push(pair(&[1.0, 1.0], &[2.0, 5.0]));
1106 let (v, u) = up.build_low_rank(1.0, 2);
1107 let b = reconstruct_b(2, 1.0, &v, &u);
1108 let bs = mat_vec(&b, 2, &[1.0, 1.0]);
1109 assert!((bs[0] - 2.0).abs() < 1e-12, "Bs[0]={}", bs[0]);
1110 assert!((bs[1] - 5.0).abs() < 1e-12, "Bs[1]={}", bs[1]);
1111 }
1112
1113 #[test]
1114 fn bfgs_low_rank_keeps_symmetry() {
1115 let mut up = LimMemQuasiNewtonUpdater::new();
1116 up.history.push(pair(&[1.0, 0.5], &[2.0, 1.0]));
1117 up.history.push(pair(&[0.7, 1.2], &[1.0, 2.5]));
1118 let (v, u) = up.build_low_rank(3.0, 2);
1119 let b = reconstruct_b(2, 3.0, &v, &u);
1120 // VVᵀ and UUᵀ are symmetric by construction, so B must be too.
1121 assert!((b[1] - b[2]).abs() < 1e-12);
1122 }
1123
1124 #[test]
1125 fn sr1_low_rank_recovers_hessian_action() {
1126 // SR1 update with B₀ = I, s = (1, 1), y = (2, 5):
1127 // y - B s = (1, 4); denom = (1, 4)·(1, 1) = 5 > 0 → one V column.
1128 // ΔB = (1, 4)(1, 4)ᵀ / 5; B₁ s = (2.0, 5.0) = y. ✓
1129 let mut up = LimMemQuasiNewtonUpdater {
1130 update_type: UpdateType::Sr1,
1131 ..LimMemQuasiNewtonUpdater::default()
1132 };
1133 up.history.push(pair(&[1.0, 1.0], &[2.0, 5.0]));
1134 let (v, u) = up.build_low_rank(1.0, 2);
1135 assert_eq!(v.len(), 1, "positive denom routes to V");
1136 assert!(u.is_empty());
1137 let b = reconstruct_b(2, 1.0, &v, &u);
1138 let bs = mat_vec(&b, 2, &[1.0, 1.0]);
1139 assert!((bs[0] - 2.0).abs() < 1e-12);
1140 assert!((bs[1] - 5.0).abs() < 1e-12);
1141 }
1142
1143 #[test]
1144 fn empty_history_yields_no_columns() {
1145 let up = LimMemQuasiNewtonUpdater::new();
1146 let (v, u) = up.build_low_rank(1.0, 4);
1147 assert!(v.is_empty() && u.is_empty());
1148 }
1149
1150 // ---- gh#624: nonlinear-variable mask ----
1151
1152 #[test]
1153 fn no_mask_is_the_default() {
1154 let u = LimMemQuasiNewtonUpdater::new();
1155 assert!(u.nonlinear_vars.is_none());
1156 assert!(u.active_mask(5).is_none());
1157 }
1158
1159 #[test]
1160 fn mask_spanning_the_whole_space_is_no_mask() {
1161 // A "restriction" to every variable is the identity; publishing
1162 // an expansion matrix for it would cost work and change nothing.
1163 let mut u = LimMemQuasiNewtonUpdater::new();
1164 u.nonlinear_vars = Some(vec![0, 1, 2]);
1165 assert!(u.active_mask(3).is_none());
1166 u.nonlinear_vars = Some(vec![]);
1167 assert!(u.active_mask(3).is_none());
1168 }
1169
1170 #[test]
1171 fn mask_is_ignored_for_a_space_it_does_not_fit() {
1172 // The restoration sub-IPM's primal is a wider compound vector.
1173 // `run_inner_resto` clears the mask, but if one ever reached a
1174 // space it was not built for, the full-space approximation is
1175 // the safe answer — never a gather from the wrong indices.
1176 let mut u = LimMemQuasiNewtonUpdater::new();
1177 u.nonlinear_vars = Some(vec![0, 4]);
1178 assert_eq!(u.active_mask(9).map(|m| m.to_vec()), Some(vec![0, 4]));
1179 assert!(u.active_mask(3).is_none());
1180 }
1181
1182 #[test]
1183 fn projection_gathers_the_masked_entries() {
1184 let v = rcv(&[10.0, 20.0, 30.0, 40.0]);
1185 let p = project_onto(&*v, &[1, 3]);
1186 assert_eq!(p.dim(), 2);
1187 assert_eq!(expanded_of(&*p), vec![20.0, 40.0]);
1188 }
1189
1190 #[test]
1191 fn masked_history_lives_in_the_reduced_space() {
1192 // Curvature pairs are projected before they enter the history,
1193 // so σ and the stored columns are all reduced-dimension.
1194 let mut u = LimMemQuasiNewtonUpdater::new();
1195 u.nonlinear_vars = Some(vec![0, 2]);
1196 let s = rcv(&[1.0, 5.0, 1.0, 7.0]);
1197 let y = rcv(&[1.0, 9.0, 1.0, 3.0]);
1198 let mask = u.active_mask(4).unwrap().to_vec();
1199 assert!(u.ingest_pair(project_onto(&*s, &mask), project_onto(&*y, &mask)));
1200 let stored = &u.history[0];
1201 assert_eq!(stored.s.dim(), 2);
1202 // s·y over the nonlinear coordinates only: 1*1 + 1*1 = 2. The
1203 // linear coordinates (5·9 + 7·3) must not contribute.
1204 assert!((stored.s_dot_y - 2.0).abs() < 1e-15);
1205 }
1206
1207 // -------------------------------------------- gh#818: history-max sigma
1208
1209 /// Every upstream rule reads the newest pair; `HistoryMax` reads the
1210 /// whole window. The fixture below puts the *largest* curvature in
1211 /// the middle of the history so neither "newest" nor "oldest" can
1212 /// pass by accident.
1213 ///
1214 /// Pairs are `s = e_i`, `y = c_i · e_i`, so `sᵀy/sᵀs = c_i`
1215 /// exactly: curvatures 3, 400, 7 in insertion order.
1216 fn updater_with_curvatures(cs: &[Number]) -> LimMemQuasiNewtonUpdater {
1217 let mut u = LimMemQuasiNewtonUpdater::new();
1218 u.max_history = cs.len() as i32;
1219 for (i, &c) in cs.iter().enumerate() {
1220 let mut sv = vec![0.0; cs.len()];
1221 sv[i] = 1.0;
1222 let yv: Vec<Number> = sv.iter().map(|&v| c * v).collect();
1223 assert!(u.ingest_pair(rcv(&sv), rcv(&yv)), "pair {i} was skipped");
1224 }
1225 u
1226 }
1227
1228 #[test]
1229 fn history_max_sigma_is_the_largest_curvature_in_the_window() {
1230 let mut u = updater_with_curvatures(&[3.0, 400.0, 7.0]);
1231
1232 u.initial_approx = InitialApprox::Scalar1;
1233 assert!(
1234 (u.compute_sigma_bfgs() - 7.0).abs() < 1e-12,
1235 "scalar1 must read the NEWEST pair, got {}",
1236 u.compute_sigma_bfgs()
1237 );
1238
1239 u.initial_approx = InitialApprox::HistoryMax;
1240 assert!(
1241 (u.compute_sigma_bfgs() - 400.0).abs() < 1e-12,
1242 "history-max must read the LARGEST pair, got {}",
1243 u.compute_sigma_bfgs()
1244 );
1245 }
1246
1247 /// The maximum is bounded by the *window*, not by the run. A
1248 /// running maximum never comes back down, which keeps a stiff early
1249 /// transient in `B0` long after the iterate has left it — measured
1250 /// worse than `scalar1` at every size on gh#818's fixture. Once the
1251 /// stiff pair ages out of the window, σ must fall with it.
1252 #[test]
1253 fn history_max_sigma_decays_as_the_window_turns_over() {
1254 let mut u = updater_with_curvatures(&[3.0, 400.0, 7.0]);
1255 u.initial_approx = InitialApprox::HistoryMax;
1256 assert!((u.compute_sigma_bfgs() - 400.0).abs() < 1e-12);
1257
1258 // Push two mild pairs; the FIFO drops the 400 one.
1259 for c in [5.0, 6.0] {
1260 let sv = vec![1.0, 0.0, 0.0];
1261 let yv = vec![c, 0.0, 0.0];
1262 assert!(u.ingest_pair(rcv(&sv), rcv(&yv)));
1263 }
1264 assert_eq!(u.history.len(), 3);
1265 assert!(
1266 (u.compute_sigma_bfgs() - 7.0).abs() < 1e-12,
1267 "sigma must fall to the largest curvature STILL in the window, got {}",
1268 u.compute_sigma_bfgs()
1269 );
1270 }
1271
1272 /// An empty history has no curvature to maximize over, so
1273 /// `HistoryMax` takes the same `limited_memory_init_val` every other
1274 /// rule takes on the first iteration. The `fold` seed is
1275 /// `NEG_INFINITY`, so getting this wrong would put `-inf` (clamped
1276 /// to `init_val_min`) on the whole `B0` diagonal rather than
1277 /// returning early.
1278 #[test]
1279 fn history_max_sigma_on_empty_history_is_init_val() {
1280 let mut u = LimMemQuasiNewtonUpdater::new();
1281 u.initial_approx = InitialApprox::HistoryMax;
1282 u.init_val = 3.0;
1283 assert!(u.history.is_empty());
1284 assert_eq!(u.compute_sigma_bfgs(), 3.0);
1285 }
1286
1287 /// Per pair, `HistoryMax` is `Scalar1` — the kernel is shared and
1288 /// only the caller's fold differs. If this drifts, the doc on
1289 /// `initial_hessian_scalar` is wrong and the option means something
1290 /// nobody wrote down.
1291 #[test]
1292 fn history_max_per_pair_kernel_equals_scalar1() {
1293 for (ss, sy, yy) in [(4.0, 2.0, 8.0), (1.0, 1.0, 1.0), (0.0, 2.0, 8.0)] {
1294 assert_eq!(
1295 initial_hessian_scalar(InitialApprox::HistoryMax, ss, sy, yy, 1.0, 1e-8, 1e8),
1296 initial_hessian_scalar(InitialApprox::Scalar1, ss, sy, yy, 1.0, 1e-8, 1e8),
1297 );
1298 }
1299 }
1300
1301 // ------------------------------------------------ gh#818: reanchor
1302
1303 /// A re-anchor keeps the newest pair and drops the rest. Keeping the
1304 /// *newest* is the whole point: `compute_sigma_bfgs` reads sigma off
1305 /// the history, so clearing it outright would fall back to
1306 /// `limited_memory_init_val` -- a bare 1.0, the first-iteration model
1307 /// -- on a problem whose curvature the solver has by now measured.
1308 #[test]
1309 fn reanchor_keeps_only_the_newest_pair() {
1310 let mut u = updater_with_curvatures(&[3.0, 400.0, 7.0]);
1311 assert_eq!(u.history.len(), 3);
1312 assert!(u.reanchor(), "there were three pairs to give up");
1313 assert_eq!(u.history.len(), 1);
1314 u.initial_approx = InitialApprox::Scalar1;
1315 assert!(
1316 (u.compute_sigma_bfgs() - 7.0).abs() < 1e-12,
1317 "the surviving pair must be the NEWEST (curvature 7), got sigma {}",
1318 u.compute_sigma_bfgs()
1319 );
1320 }
1321
1322 /// The bound is structural, not just counted: once the history is
1323 /// down to one pair there is nothing left to give up, so a second
1324 /// failure at the same iterate falls through to the caller's
1325 /// existing hand-off instead of retrying forever.
1326 #[test]
1327 fn reanchor_declines_when_there_is_nothing_left_to_discard() {
1328 let mut u = updater_with_curvatures(&[3.0, 400.0, 7.0]);
1329 assert!(u.reanchor());
1330 assert!(!u.reanchor(), "a one-pair history has nothing to re-anchor");
1331 assert!(!LimMemQuasiNewtonUpdater::new().reanchor(), "empty history");
1332 }
1333
1334 /// A deliberate re-anchor is not a rejected pair. Leaving
1335 /// `skipped_iter` standing would let the next skipped pair trip
1336 /// `limited_memory_max_skipping` and discard the very pair the
1337 /// re-anchor just chose to keep -- turning the careful sigma into
1338 /// `limited_memory_init_val` one iteration later, which is exactly
1339 /// what keeping a pair was for.
1340 #[test]
1341 fn reanchor_clears_the_skip_counter() {
1342 let mut u = updater_with_curvatures(&[3.0, 400.0, 7.0]);
1343 u.skipped_iter = 1;
1344 assert!(u.reanchor());
1345 assert_eq!(u.skipped_iter, 0);
1346 }
1347
1348 /// The exact-Hessian updater has no curvature history, so it must
1349 /// decline and let the caller hand off as before. If this ever
1350 /// returns `true` the rung would fire on the exact path, where the
1351 /// fixture sweep is byte-identical by design.
1352 #[test]
1353 fn exact_hessian_updater_never_reanchors() {
1354 use crate::hess::exact::ExactHessianUpdater;
1355 assert!(!ExactHessianUpdater::new().reanchor());
1356 }
1357}