Skip to main content

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}