Skip to main content

pounce_algorithm/hess/
partitioned_quasi_newton.rs

1//! Partitioned quasi-Newton Hessian — a **per-constraint element**
2//! updater (Griewank & Toint partitioned updating; Asprion, Chinellato &
3//! Guzzella, *J. Appl. Math.* 2014, doi:10.1155/2014/341716, applied it to
4//! direct-collocation trajectory optimization).
5//!
6//! # Why this exists
7//!
8//! [`crate::hess::lim_mem_quasi_newton::LimMemQuasiNewtonUpdater`]
9//! approximates the whole Lagrangian Hessian with `m` curvature pairs and
10//! publishes it as a [`pounce_linalg::low_rank_update_sym_matrix::LowRankUpdateSymMatrix`],
11//! which [`crate::kkt::low_rank_aug_system_solver::LowRankAugSystemSolver`]
12//! applies by Sherman-Morrison-Woodbury. Two consequences:
13//!
14//! * the `(1,1)` block the sparse factorization sees is **diagonal**, so
15//!   whatever block structure the model's Hessian has is invisible to the
16//!   linear solver's ordering — and the Schur path
17//!   ([`crate::kkt::SchurAugSystemSolver`]) is bypassed entirely
18//!   (`alg_builder.rs`, the `is_lbfgs` branch);
19//! * `m` pairs cannot represent the curvature of a 60 000-variable
20//!   collocation model, and the iteration count shows it.
21//!
22//! This updater takes the other route. The Lagrangian is a sum of
23//! *element functions* with small support,
24//!
25//! ```text
26//!     L(x, y) = f(x) + Σ_j (y_c)_j c_j(x) + Σ_j (y_d)_j d_j(x)
27//! ```
28//!
29//! so its Hessian is the assembly of the elements' own Hessians:
30//!
31//! ```text
32//!     ∇²L = ∇²f + Σ_j (y_c)_j ∇²c_j + Σ_j (y_d)_j ∇²d_j
33//! ```
34//!
35//! We keep one small dense symmetric `B_e` per element, update each from
36//! that element's **own** curvature pair, and scatter-add the weighted
37//! blocks into a [`SymTMatrix`] — the same type the exact-Hessian path
38//! publishes, so the whole downstream KKT machinery is unchanged and the
39//! factorization sees the true block-banded pattern.
40//!
41//! # Why per-constraint, and not per-primal-block
42//!
43//! Asprion et al. partition the *Lagrangian* by primal stage blocks.
44//! That is fewer, larger elements, but each element's target moves as the
45//! multipliers move. Splitting per constraint row instead gives each
46//! `B_e` a **multiplier-independent** target — `∇²c_j` is a property of
47//! the model, not of the iterate — so the approximation converges instead
48//! of chasing `y`. It also needs no new user-facing structure hook: an
49//! element's support is a row of the constraint Jacobian, whose pattern
50//! every TNLP already declares.
51//!
52//! # Why SR1 is the default here
53//!
54//! An individual constraint is not convex, so `sᵀy > 0` fails routinely
55//! and Powell-damped BFGS would force each `∇²c_j` model positive
56//! semidefinite — then multiply it by a multiplier of either sign. SR1
57//! carries the sign, and the indefiniteness reaches the IPM's inertia
58//! check, which is exactly what
59//! `dev-notes/issue-131-monotone-lbfgs-stall.md` records the damped path
60//! hiding. [`UpdateType::Bfgs`] is accepted for comparison.
61//!
62//! # Bounded element size
63//!
64//! An element with `k` nonzeros costs `k(k+1)/2` stored reals. A row that
65//! touches most of `x` — a global resource constraint, or an objective
66//! that sums over every stage — would blow that up, so elements wider
67//! than `max_element` degrade to a **diagonal** approximation
68//! (Dennis-Wolkowicz weak secant, `sᵀBs = sᵀy`) rather than being
69//! dropped: a separable objective is then still represented exactly, and
70//! a coupled one is represented approximately instead of not at all.
71
72use crate::hess::lim_mem_quasi_newton::UpdateType;
73use crate::hess::r#trait::HessianUpdater;
74use crate::ipopt_cq::IpoptCqHandle;
75use crate::ipopt_data::IpoptDataHandle;
76use pounce_common::types::{Index, Number};
77use pounce_linalg::Vector;
78use pounce_linalg::compound_vector::CompoundVector;
79use pounce_linalg::dense_vector::DenseVector;
80use pounce_linalg::triplet::{GenTMatrix, SymTMatrix, SymTMatrixSpace};
81use std::rc::Rc;
82
83/// Relative safeguard on the SR1 denominator: the update is skipped when
84/// `|wᵀs| ≤ SR1_SAFEGUARD · ‖s‖ · ‖w‖` (Nocedal & Wright §6.2, eq. 6.26,
85/// which suggests `r ∈ [1e-8, 1e-4]`).
86///
87/// This is a *direction* test only — it rejects a pair carrying no usable
88/// curvature along `s`. It is deliberately **not** the magnitude control,
89/// and trying to make it one was measured to fail from both ends. The
90/// rank-1 term `w wᵀ / wᵀs` is bounded only by `‖w‖ / (r ‖s‖)`, so at
91/// `r = 1e-8` it permits a correction `1e8` times the curvature the data
92/// implies — on `benchmarks/large_scale` `laptime` the implied element
93/// curvature `‖y_e‖/‖s_e‖` stayed a healthy 9–33 every iteration while
94/// single-update block changes reached `1.7e8` and the assembled `W` ran
95/// four orders of magnitude over the exact Lagrangian Hessian. But
96/// tightening it to `1e-4` overshoots just as badly in the other
97/// direction: element supports are small (`k ≈ 9` here) and `w` is
98/// routinely near-orthogonal to `s`, so the test then rejected 9 949 of
99/// 9 950 updates, the blocks never learned anything, and `W` read `0.14`
100/// where the exact Hessian read `2.1e3`. [`DEFAULT_CURVATURE_CAP`] is
101/// where the magnitude is bounded, in the units of the thing being
102/// modelled; this stays loose so that pairs still reach it.
103const SR1_SAFEGUARD: Number = 1e-8;
104
105/// Powell damping threshold for [`UpdateType::Bfgs`], matching the
106/// hard-coded `0.2` of the limited-memory path.
107const POWELL_THETA: Number = 0.2;
108
109/// Relative floor on the BFGS denominators `sᵀr` and `sᵀBs`. The
110/// limited-memory path tests only `> 0`, which is safe there because its
111/// `s` is a whole primal step; restricted to one element's support the
112/// same quantity goes arbitrarily small, and `r rᵀ / sᵀr` then blows up
113/// exactly as the SR1 term does. Same measurement as
114/// [`SR1_SAFEGUARD`]: unfloored, damped BFGS reached block changes of
115/// `2.9e8` — worse than SR1, because `> 0` is no floor at all.
116const BFGS_DENOM_FLOOR: Number = 1e-8;
117
118/// Cap on a single update's magnitude, as a multiple of the curvature the
119/// element's own secant pair implies (`‖y_e‖ / ‖s_e‖`).
120///
121/// **Off by default, because every finite value measured was worse than
122/// off.** The idea was to bound the update in the units of the quantity
123/// being modelled, since the relative denominator floors bound only the
124/// ratio that produced the term. It does bound it — and it makes the
125/// solver worse, non-monotonically. On `benchmarks/large_scale`
126/// `laptime` at `N = 80`, `max_iter = 1200` (true optimum 65.462928):
127///
128/// | cap | status | iters | wall | objective |
129/// |---|---|---|---|---|
130/// | 1e1 | ErrorInStepComputation | 1071 | 179 s | 65.518586 |
131/// | 1e2 | MaxIter | 1200 | 214 s | 67.202124 |
132/// | 1e6 | MaxIter | 1200 | 232 s | 80.398129 |
133/// | off | **Optimal** | 559 | 50 s | 65.462802 |
134///
135/// `1e6` being worse than both `1e1` and off is the shape of the result:
136/// this is not "less capping is better". Rejection is *selective* — it
137/// drops precisely the elements whose curvature is moving fastest,
138/// leaving those blocks stale while their neighbours update. The
139/// assembled `W` is then internally inconsistent, which costs more than a
140/// uniformly noisy but coherent model.
141///
142/// Kept as a knob rather than deleted so the non-monotonicity can be
143/// re-measured against a different element decomposition, where it may
144/// behave differently. Do not turn it on without measuring.
145const DEFAULT_CURVATURE_CAP: Number = Number::INFINITY;
146
147/// Which gradient an element reads to form its curvature pair.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum ElementSource {
150    /// The objective: gradient entries come from `grad_f` directly.
151    Objective,
152    /// A row of `J_c`; entries come from the equality Jacobian's values.
153    EqRow,
154    /// A row of `J_d`; entries come from the inequality Jacobian's values.
155    IneqRow,
156    /// A contiguous block of primal variables under
157    /// [`ElementMode::PrimalBlock`]. The element function is the
158    /// **Lagrangian itself**, restricted to the block, so its gradient is
159    /// `∇f + J_cᵀ y_c + J_dᵀ y_d` restricted, and its assembly weight is
160    /// 1 — the multiplier is already inside the function being modelled.
161    LagrangianBlock,
162}
163
164/// How the Lagrangian is split into elements.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ElementMode {
167    /// One element per constraint row (plus the objective). Each block
168    /// has a multiplier-independent target, and needs no assumption about
169    /// how the model orders its variables — but there are as many blocks
170    /// as constraints, each approximating a `∇²c_j` with no sign
171    /// structure, and the errors accumulate through the weighted sum.
172    PerConstraint,
173    /// One element per contiguous block of primal variables, modelling
174    /// the Lagrangian's restriction to that block with damped BFGS.
175    /// This is Asprion, Chinellato & Guzzella's partition: a direct
176    /// collocation transcription orders its variables by stage, so the
177    /// Lagrangian Hessian really is close to block diagonal in this
178    /// partition, and the block count is the stage count rather than the
179    /// constraint count.
180    PrimalBlock,
181}
182
183/// One element function's quasi-Newton state.
184#[derive(Debug)]
185struct Element {
186    source: ElementSource,
187    /// 0-based row index within the element's source block, i.e. the
188    /// position in `y_c` / `y_d` whose multiplier weights this element.
189    /// Stored rather than inferred from the element's position in the
190    /// table: a constraint row with no Jacobian entries produces no
191    /// element, so counting elements and counting multipliers diverge
192    /// the moment a model has one.
193    row: u32,
194    /// 0-based var-x columns this element touches, ascending and
195    /// deduplicated. `k = support.len()`.
196    support: Vec<Index>,
197    /// `true` when `b` holds the packed lower triangle (`k(k+1)/2`
198    /// reals); `false` when the element degraded to a diagonal
199    /// approximation (`k` reals).
200    dense: bool,
201    /// `B_e`. Packed lower triangle in local coordinates — entry
202    /// `(a, c)` with `a >= c` at `a(a+1)/2 + c` — or the diagonal alone
203    /// when `!dense`.
204    b: Vec<Number>,
205    /// This element's gradient at the previous iterate, local coords.
206    prev_g: Vec<Number>,
207    /// Whether `prev_g` has been written at least once.
208    has_prev: bool,
209    /// Whether `b` has taken an accepted curvature pair (drives the
210    /// one-time scalar seeding).
211    seeded: bool,
212    /// `(position in the source Jacobian's triplet arrays, local index)`
213    /// for every triplet belonging to this element's row. Empty for
214    /// [`ElementSource::Objective`], which reads `grad_f` by support
215    /// index. Duplicated `(row, col)` triplets are summed, matching the
216    /// triplet contract.
217    entries: Vec<(u32, u32)>,
218    /// Position in the assembled matrix's value array for each entry of
219    /// `b`, in the same packing.
220    map: Vec<u32>,
221}
222
223impl Element {
224    fn k(&self) -> usize {
225        self.support.len()
226    }
227}
228
229/// One-time structural census, for the measurement write-up and for
230/// `POUNCE_PARTITIONED_DEBUG`.
231#[derive(Debug, Clone, Copy, Default)]
232pub struct PartitionStats {
233    pub elements: usize,
234    pub dense_elements: usize,
235    pub diagonal_elements: usize,
236    pub max_support: usize,
237    pub total_support: usize,
238    /// Nonzeros in the assembled lower triangle, diagonal included.
239    pub assembled_nnz: usize,
240    /// Reals held across all `B_e`.
241    pub stored_reals: usize,
242}
243
244pub struct PartitionedQuasiNewtonUpdater {
245    pub update_type: UpdateType,
246    /// How the Lagrangian is split. See [`ElementMode`].
247    pub mode: ElementMode,
248    /// Target width of a primal block under [`ElementMode::PrimalBlock`]
249    /// (`partitioned_block_size`).
250    pub block_size: usize,
251    /// Widest element that keeps a dense block; wider ones degrade to a
252    /// diagonal approximation. Option `partitioned_max_element`.
253    pub max_element: usize,
254    /// Floor placed on assembled diagonal entries no active element
255    /// covers, so the `(1, 1)` block never presents a structurally empty
256    /// row to the factorization. Shares
257    /// `limited_memory_init_val_min`'s default and its rationale — see
258    /// the gh#624 discussion in
259    /// [`crate::hess::lim_mem_quasi_newton`].
260    pub init_val_min: Number,
261    /// Clamp on the magnitude of the per-element scalar seeding.
262    pub init_val_max: Number,
263    /// Multiple of the identity published on the **first** iteration,
264    /// before any element has seen a curvature pair. Mirrors
265    /// `limited_memory_init_val` and the empty-history branch of the
266    /// limited-memory updater: publishing the honest all-zero `W` there
267    /// instead hands the very first KKT solve a `(1,1)` block with no
268    /// curvature at all, and the whole trajectory is set by whatever
269    /// `delta_x` the inertia correction then has to invent.
270    pub init_val: Number,
271    /// Support of the objective element, in the compressed `x_var`
272    /// space, when the TNLP could state it
273    /// (`TNLPAdapter::objective_nonlinear_vars`). `None` falls back to
274    /// the first `∇f`'s nonzeros — see `build_structure`.
275    pub objective_vars: Option<Vec<Index>>,
276    /// Multiple of the implied curvature `‖y_e‖/‖s_e‖` that a single
277    /// update to an element block may reach
278    /// (`partitioned_curvature_cap`). See [`DEFAULT_CURVATURE_CAP`].
279    pub curvature_cap: Number,
280
281    /// Element table, built on the first call and fixed thereafter.
282    elements: Vec<Element>,
283    /// Assembled pattern, built with the element table.
284    space: Option<Rc<SymTMatrixSpace>>,
285    /// Assembled positions of the `n` diagonal entries.
286    diag_pos: Vec<u32>,
287    /// Coordinates no active element covers, which take `init_val_min`.
288    uncovered: Vec<Index>,
289    /// `x` at the previous call.
290    prev_x: Option<Vec<Number>>,
291    /// `∇f`, and the Jacobians' triplet values, at the previous call.
292    /// [`ElementMode::PrimalBlock`] needs the change in the *Lagrangian*
293    /// gradient, which is not a per-element quantity, so it is formed
294    /// once per call from these rather than element by element.
295    prev_grad_f: Option<Vec<Number>>,
296    prev_jac_c: Option<Vec<Number>>,
297    prev_jac_d: Option<Vec<Number>>,
298    stats: PartitionStats,
299    /// One-shot latch for `POUNCE_HESS_PATTERN_CENSUS`.
300    census_done: bool,
301    /// Curvature pairs accepted / skipped, cumulative — a cheap health
302    /// signal for the write-up.
303    pub accepted_updates: u64,
304    pub skipped_updates: u64,
305    /// Per-call diagnostics for `POUNCE_PARTITIONED_ORACLE`: the element
306    /// with the largest implied curvature `‖y_e‖ / ‖s_e‖`, and the
307    /// largest single-update change to any block.
308    dbg: DebugPeaks,
309}
310
311impl PartitionedQuasiNewtonUpdater {
312    pub fn new(update_type: UpdateType) -> Self {
313        Self {
314            update_type,
315            mode: ElementMode::PerConstraint,
316            block_size: 64,
317            max_element: 64,
318            init_val_min: 1e-8,
319            init_val_max: 1e8,
320            init_val: 1.0,
321            objective_vars: None,
322            curvature_cap: DEFAULT_CURVATURE_CAP,
323            elements: Vec::new(),
324            space: None,
325            diag_pos: Vec::new(),
326            uncovered: Vec::new(),
327            prev_x: None,
328            prev_grad_f: None,
329            prev_jac_c: None,
330            prev_jac_d: None,
331            stats: PartitionStats::default(),
332            census_done: false,
333            accepted_updates: 0,
334            skipped_updates: 0,
335            dbg: DebugPeaks::default(),
336        }
337    }
338
339    pub fn stats(&self) -> PartitionStats {
340        self.stats
341    }
342
343    /// Build the element table and the assembled sparsity pattern. Runs
344    /// once; both are structural and every later call reuses them, so
345    /// the backend's symbolic factorization is done a single time.
346    fn build_structure(
347        &mut self,
348        n: usize,
349        grad_f: &[Number],
350        jac_c: &GenTMatrix,
351        jac_d: &GenTMatrix,
352    ) {
353        let mut elements: Vec<Element> = Vec::new();
354
355        if self.mode == ElementMode::PrimalBlock {
356            // Contiguous blocks in the model's own variable order.
357            //
358            // The ordering assumption is load-bearing and is *checked*
359            // rather than trusted: `POUNCE_PARTITIONED_ORACLE` reports the
360            // fraction of the exact Hessian's Frobenius mass that falls
361            // inside the block-diagonal pattern. A transcription that
362            // orders by stage — which is what every direct-collocation
363            // writer does, and what `laptime` does — puts nearly all of it
364            // inside. One that does not will show a low fraction, and the
365            // partition is then simply wrong for that model rather than
366            // silently poor.
367            let bs = self.block_size.max(1);
368            let mut start = 0usize;
369            while start < n {
370                let end = (start + bs).min(n);
371                let support: Vec<Index> = (start..end).map(|i| i as Index).collect();
372                elements.push(Self::make_element(
373                    ElementSource::LagrangianBlock,
374                    0,
375                    support,
376                    Vec::new(),
377                    usize::MAX,
378                ));
379                start = end;
380            }
381            self.finish_structure(n, elements);
382            return;
383        }
384
385        // ---- objective element -------------------------------------
386        //
387        // Every constraint element takes its support from a row of the
388        // Jacobian, whose pattern the TNLP is obliged to declare. The
389        // objective has no such declaration, so the support comes from
390        // `get_objective_variables_linearity` via
391        // `TNLPAdapter::objective_nonlinear_vars` — the variables the
392        // objective is *nonlinear* in, which is exactly the rows `∇²f`
393        // can occupy.
394        //
395        // When the TNLP declines, the fallback is the first `∇f`'s
396        // nonzeros, and that pattern is *value-derived*: a coordinate
397        // whose `∂f/∂x_i` happens to vanish at the starting point is
398        // excluded for the whole solve. On `laptime`, which declares 321
399        // objective gradient nonzeros, the fallback captures 161 — so
400        // this is the live case, not a corner. Widening to all of `x` is
401        // not the alternative: that is an `n × n` element.
402        let obj_support: Vec<Index> = match self.objective_vars.clone() {
403            Some(v) => v,
404            None => (0..n)
405                .filter(|&i| grad_f[i] != 0.0)
406                .map(|i| i as Index)
407                .collect(),
408        };
409        if !obj_support.is_empty() {
410            elements.push(Self::make_element(
411                ElementSource::Objective,
412                0,
413                obj_support,
414                Vec::new(),
415                self.max_element,
416            ));
417        }
418
419        // ---- one element per constraint row -------------------------
420        for (source, jac) in [
421            (ElementSource::EqRow, jac_c),
422            (ElementSource::IneqRow, jac_d),
423        ] {
424            let n_rows = jac.space().n_rows() as usize;
425            let irows = jac.irows();
426            let jcols = jac.jcols();
427            // Bucket triplet positions by row. `irows` is 1-based.
428            let mut row_counts = vec![0u32; n_rows + 1];
429            for &i in irows {
430                row_counts[i as usize] += 1;
431            }
432            let mut row_start = vec![0u32; n_rows + 2];
433            for r in 0..=n_rows {
434                row_start[r + 1] = row_start[r] + row_counts[r];
435            }
436            let mut cursor = row_start.clone();
437            let mut by_row = vec![0u32; irows.len()];
438            for (pos, &i) in irows.iter().enumerate() {
439                let r = i as usize;
440                by_row[cursor[r] as usize] = pos as u32;
441                cursor[r] += 1;
442            }
443
444            for r in 1..=n_rows {
445                let slice = &by_row[row_start[r] as usize..row_start[r + 1] as usize];
446                if slice.is_empty() {
447                    continue;
448                }
449                // Support = sorted unique columns of this row (0-based).
450                let mut cols: Vec<Index> = slice.iter().map(|&p| jcols[p as usize] - 1).collect();
451                cols.sort_unstable();
452                cols.dedup();
453                // Local index of every triplet position in the row.
454                // Duplicated `(row, col)` triplets land on the same local
455                // index and are summed when the gradient is read.
456                let entries: Vec<(u32, u32)> = slice
457                    .iter()
458                    .map(|&p| {
459                        let c = jcols[p as usize] - 1;
460                        let local = cols.partition_point(|&x| x < c) as u32;
461                        (p, local)
462                    })
463                    .collect();
464                elements.push(Self::make_element(
465                    source,
466                    (r - 1) as u32,
467                    cols,
468                    entries,
469                    self.max_element,
470                ));
471            }
472        }
473
474        self.finish_structure(n, elements);
475    }
476
477    /// Build the assembled pattern, the per-element scatter maps and the
478    /// census from a finished element table. Shared by both
479    /// [`ElementMode`]s.
480    fn finish_structure(&mut self, n: usize, mut elements: Vec<Element>) {
481        // ---- assembled pattern --------------------------------------
482        //
483        // Union of each active element's own lower triangle, plus the
484        // full diagonal so no primal row of the `(1,1)` block is
485        // structurally empty.
486        let mut pairs: Vec<(Index, Index)> = Vec::new();
487        for i in 0..n {
488            pairs.push((i as Index, i as Index));
489        }
490        for e in &elements {
491            if e.dense {
492                for a in 0..e.k() {
493                    for c in 0..=a {
494                        pairs.push((e.support[a], e.support[c]));
495                    }
496                }
497            } else {
498                for a in 0..e.k() {
499                    pairs.push((e.support[a], e.support[a]));
500                }
501            }
502        }
503        pairs.sort_unstable();
504        pairs.dedup();
505
506        // Lookup from (row, col) to assembled position, by binary search
507        // over the sorted pair list.
508        let find =
509            |row: Index, col: Index| -> u32 { pairs.partition_point(|&p| p < (row, col)) as u32 };
510        for e in &mut elements {
511            if e.dense {
512                let mut map = Vec::with_capacity(e.k() * (e.k() + 1) / 2);
513                for a in 0..e.k() {
514                    for c in 0..=a {
515                        map.push(find(e.support[a], e.support[c]));
516                    }
517                }
518                e.map = map;
519            } else {
520                e.map = (0..e.k())
521                    .map(|a| find(e.support[a], e.support[a]))
522                    .collect();
523            }
524        }
525        self.diag_pos = (0..n).map(|i| find(i as Index, i as Index)).collect();
526
527        let mut covered = vec![false; n];
528        for e in &elements {
529            for &i in &e.support {
530                covered[i as usize] = true;
531            }
532        }
533        self.uncovered = (0..n)
534            .filter(|&i| !covered[i])
535            .map(|i| i as Index)
536            .collect();
537
538        // 1-based triplets, lower triangle, matching the exact-Hessian
539        // path's convention (`orig_ipopt_nlp.rs` pushes `i_var + 1`).
540        let irows: Vec<Index> = pairs.iter().map(|&(r, _)| r + 1).collect();
541        let jcols: Vec<Index> = pairs.iter().map(|&(_, c)| c + 1).collect();
542
543        self.stats = PartitionStats {
544            elements: elements.len(),
545            dense_elements: elements.iter().filter(|e| e.dense).count(),
546            diagonal_elements: elements.iter().filter(|e| !e.dense).count(),
547            max_support: elements.iter().map(|e| e.k()).max().unwrap_or(0),
548            total_support: elements.iter().map(|e| e.k()).sum(),
549            assembled_nnz: pairs.len(),
550            stored_reals: elements.iter().map(|e| e.b.len()).sum(),
551        };
552        self.space = Some(SymTMatrixSpace::new(n as Index, irows, jcols));
553        self.elements = elements;
554
555        if std::env::var("POUNCE_PARTITIONED_DEBUG").is_ok() {
556            eprintln!("partitioned-qn: {:?}", self.stats);
557        }
558    }
559
560    fn make_element(
561        source: ElementSource,
562        row: u32,
563        support: Vec<Index>,
564        entries: Vec<(u32, u32)>,
565        max_element: usize,
566    ) -> Element {
567        let k = support.len();
568        let dense = k <= max_element;
569        let b_len = if dense { k * (k + 1) / 2 } else { k };
570        Element {
571            source,
572            row,
573            support,
574            dense,
575            b: vec![0.0; b_len],
576            prev_g: vec![0.0; k],
577            has_prev: false,
578            seeded: false,
579            entries,
580            map: Vec::new(),
581        }
582    }
583}
584
585#[derive(Debug, Clone, Copy, Default)]
586struct DebugPeaks {
587    ratio: Number,
588    ratio_s: Number,
589    ratio_y: Number,
590    ratio_k: usize,
591    delta: Number,
592    step_norm: Number,
593}
594
595/// `out = B s` for a packed lower triangle.
596fn packed_mult(b: &[Number], s: &[Number], out: &mut [Number]) {
597    out.iter_mut().for_each(|v| *v = 0.0);
598    let mut p = 0usize;
599    for a in 0..s.len() {
600        for c in 0..=a {
601            let v = b[p];
602            p += 1;
603            if v == 0.0 {
604                continue;
605            }
606            out[a] += v * s[c];
607            if c != a {
608                out[c] += v * s[a];
609            }
610        }
611    }
612}
613
614fn dot(a: &[Number], b: &[Number]) -> Number {
615    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
616}
617
618/// Apply one curvature pair to a single element. Returns `true` when the
619/// update was accepted.
620fn update_element(
621    e: &mut Element,
622    s: &[Number],
623    y: &[Number],
624    update_type: UpdateType,
625    init_val_min: Number,
626    init_val_max: Number,
627    curvature_cap: Number,
628) -> bool {
629    let sts = dot(s, s);
630    if !(sts > 0.0) || !sts.is_finite() {
631        return false;
632    }
633    let sty = dot(s, y);
634    if !sty.is_finite() {
635        return false;
636    }
637    // The curvature this pair actually implies, and the ceiling any
638    // single update to this block is allowed to reach.
639    let s_norm = sts.sqrt();
640    let implied = dot(y, y).sqrt() / s_norm;
641    let max_delta = curvature_cap * implied;
642
643    // One-time scalar seeding: `B_e ← γ I` with γ the `scalar1` ratio of
644    // this element's own first pair, so the block starts at the right
645    // order of magnitude instead of at zero. BFGS needs γ > 0 to have a
646    // positive-definite base; SR1 takes either sign, which is the point
647    // of using it here.
648    if !e.seeded {
649        let mut gamma = sty / sts;
650        if !gamma.is_finite() || gamma == 0.0 {
651            gamma = if update_type == UpdateType::Bfgs {
652                1.0
653            } else {
654                0.0
655            };
656        }
657        if update_type == UpdateType::Bfgs && gamma <= 0.0 {
658            gamma = 1.0;
659        }
660        if gamma != 0.0 {
661            let mag = gamma.abs().clamp(init_val_min, init_val_max);
662            gamma = gamma.signum() * mag;
663        }
664        if e.dense {
665            for a in 0..e.k() {
666                e.b[a * (a + 1) / 2 + a] = gamma;
667            }
668        } else {
669            e.b.iter_mut().for_each(|v| *v = gamma);
670        }
671        e.seeded = true;
672    }
673
674    if !e.dense {
675        // Diagonal element: Dennis-Wolkowicz weak secant update, the
676        // minimum-Frobenius diagonal correction satisfying `sᵀBs = sᵀy`.
677        let s_bs: Number = (0..e.k()).map(|a| e.b[a] * s[a] * s[a]).sum();
678        let denom: Number = s.iter().map(|v| v * v * v * v).sum();
679        if !(denom > 0.0) || !denom.is_finite() {
680            return false;
681        }
682        let scale = (sty - s_bs) / denom;
683        if !scale.is_finite() {
684            return false;
685        }
686        for a in 0..e.k() {
687            e.b[a] += scale * s[a] * s[a];
688        }
689        return true;
690    }
691
692    let mut bs = vec![0.0; e.k()];
693    packed_mult(&e.b, s, &mut bs);
694
695    match update_type {
696        UpdateType::Sr1 => {
697            // w = y − Bs;  B += w wᵀ / (wᵀ s)
698            let w: Vec<Number> = y.iter().zip(bs.iter()).map(|(a, b)| a - b).collect();
699            let den = dot(&w, s);
700            let w_norm = dot(&w, &w).sqrt();
701            let s_norm = sts.sqrt();
702            // `<=`, not `<`. When the element's model already reproduces
703            // its own curvature — a linear constraint, whose `y` is
704            // identically zero, or a block the previous pair already
705            // matched — `w` is exactly zero and both sides are zero. A
706            // strict comparison lets that through and the rank-1 term
707            // divides 0 by 0, publishing a NaN Hessian; the IPM then
708            // reports a converged-looking restoration failure rather than
709            // anything that names the cause. A linear element is the
710            // common case, not a corner: every linear constraint row in
711            // the model hits this on its first pair.
712            if !den.is_finite() || w_norm == 0.0 || den.abs() <= SR1_SAFEGUARD * s_norm * w_norm {
713                return false;
714            }
715            // `‖w wᵀ/den‖_max = max|w|² / |den|`; reject before writing.
716            let w_max = w.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
717            if w_max * w_max / den.abs() > max_delta {
718                return false;
719            }
720            let mut p = 0usize;
721            for a in 0..e.k() {
722                for c in 0..=a {
723                    e.b[p] += w[a] * w[c] / den;
724                    p += 1;
725                }
726            }
727            true
728        }
729        UpdateType::Bfgs => {
730            // Powell-damped BFGS on the element block.
731            let s_bs = dot(s, &bs);
732            let bs_norm = dot(&bs, &bs).sqrt();
733            if !(s_bs > 0.0) || !s_bs.is_finite() || s_bs <= BFGS_DENOM_FLOOR * s_norm * bs_norm {
734                return false;
735            }
736            let theta = if sty >= POWELL_THETA * s_bs {
737                1.0
738            } else {
739                (1.0 - POWELL_THETA) * s_bs / (s_bs - sty)
740            };
741            if !theta.is_finite() {
742                return false;
743            }
744            let r: Vec<Number> = y
745                .iter()
746                .zip(bs.iter())
747                .map(|(yy, bb)| theta * yy + (1.0 - theta) * bb)
748                .collect();
749            let sr = dot(s, &r);
750            let r_norm = dot(&r, &r).sqrt();
751            if !(sr > 0.0) || !sr.is_finite() || sr <= BFGS_DENOM_FLOOR * s_norm * r_norm {
752                return false;
753            }
754            let r_max = r.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
755            let bs_max = bs.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
756            if r_max * r_max / sr + bs_max * bs_max / s_bs > max_delta {
757                return false;
758            }
759            let mut p = 0usize;
760            for a in 0..e.k() {
761                for c in 0..=a {
762                    e.b[p] += r[a] * r[c] / sr - bs[a] * bs[c] / s_bs;
763                    p += 1;
764                }
765            }
766            true
767        }
768    }
769}
770
771impl HessianUpdater for PartitionedQuasiNewtonUpdater {
772    fn update_hessian(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) -> bool {
773        let (curr_x, curr_y_c, curr_y_d) = match data.borrow().curr.as_ref() {
774            Some(c) => (c.x.clone(), c.y_c.clone(), c.y_d.clone()),
775            None => return true,
776        };
777        let curr_grad_f = cq.borrow().curr_grad_f();
778        let curr_jac_c = cq.borrow().curr_jac_c();
779        let curr_jac_d = cq.borrow().curr_jac_d();
780
781        let (Some(jac_c), Some(jac_d)) = (
782            curr_jac_c.as_any().downcast_ref::<GenTMatrix>(),
783            curr_jac_d.as_any().downcast_ref::<GenTMatrix>(),
784        ) else {
785            // Not the plain NLP path (the restoration sub-IPM carries a
786            // different Jacobian shape). The builder downgrades
787            // restoration to the limited-memory updater, so this is a
788            // guard, not a live branch.
789            return false;
790        };
791
792        let x = flat(&*curr_x);
793        let grad_f = flat(&*curr_grad_f);
794        let n = x.len();
795
796        if self.space.is_none() {
797            self.build_structure(n, &grad_f, jac_c, jac_d);
798        }
799        let y_c_now = flat(&*curr_y_c);
800        let y_d_now = flat(&*curr_y_d);
801
802        // ---- curvature pairs, one per element -----------------------
803        //
804        // `s` is shared (the primal step); each element gets its own `y`
805        // from its own gradient's change, so no element's pair is
806        // contaminated by another's curvature — the property the
807        // monolithic L-BFGS `y` cannot have.
808        let s_full: Option<Vec<Number>> = self
809            .prev_x
810            .as_ref()
811            .map(|p| x.iter().zip(p.iter()).map(|(a, b)| a - b).collect());
812
813        // In `PrimalBlock` mode every element reads the same vector: the
814        // Lagrangian gradient's *change*. It is formed once here, using
815        // upstream's convention that BOTH Jacobians are dotted against the
816        // CURRENT multipliers, so `y` is the difference of one fixed
817        // function rather than of two different Lagrangians
818        // (`IpLimMemQuasiNewtonUpdater.cpp:284-308`, and the same reasoning
819        // the limited-memory updater records).
820        let lagrangian_dy: Option<Vec<Number>> = if self.mode == ElementMode::PrimalBlock {
821            match (
822                self.prev_grad_f.as_ref(),
823                self.prev_jac_c.as_ref(),
824                self.prev_jac_d.as_ref(),
825            ) {
826                (Some(pg), Some(pc), Some(pd)) => {
827                    let mut dy = vec![0.0; n];
828                    for i in 0..n {
829                        dy[i] = grad_f[i] - pg[i];
830                    }
831                    for (jac, prev, mult) in [(jac_c, pc, &y_c_now), (jac_d, pd, &y_d_now)] {
832                        let (ir, jc, cur) = (jac.irows(), jac.jcols(), jac.values());
833                        for k in 0..ir.len() {
834                            let row = (ir[k] - 1) as usize;
835                            let col = (jc[k] - 1) as usize;
836                            dy[col] += (cur[k] - prev[k]) * mult[row];
837                        }
838                    }
839                    Some(dy)
840                }
841                _ => None,
842            }
843        } else {
844            None
845        };
846
847        let oracle = std::env::var("POUNCE_PARTITIONED_ORACLE").is_ok();
848        if oracle {
849            self.dbg = DebugPeaks {
850                step_norm: s_full.as_ref().map(|v| dot(v, v).sqrt()).unwrap_or(0.0),
851                ..DebugPeaks::default()
852            };
853        }
854        let mut s_loc: Vec<Number> = Vec::new();
855        let mut y_loc: Vec<Number> = Vec::new();
856        let mut g_loc: Vec<Number> = Vec::new();
857        for e in &mut self.elements {
858            let k = e.k();
859            g_loc.clear();
860            g_loc.resize(k, 0.0);
861            match e.source {
862                ElementSource::Objective => {
863                    for (a, &i) in e.support.iter().enumerate() {
864                        g_loc[a] = grad_f[i as usize];
865                    }
866                }
867                ElementSource::EqRow => {
868                    let v = jac_c.values();
869                    for &(pos, local) in &e.entries {
870                        g_loc[local as usize] += v[pos as usize];
871                    }
872                }
873                ElementSource::IneqRow => {
874                    let v = jac_d.values();
875                    for &(pos, local) in &e.entries {
876                        g_loc[local as usize] += v[pos as usize];
877                    }
878                }
879                ElementSource::LagrangianBlock => {}
880            }
881
882            let pair_ready = match e.source {
883                // The block's `y` comes from the shared Lagrangian
884                // difference, not from a stored per-element gradient.
885                ElementSource::LagrangianBlock => lagrangian_dy.is_some(),
886                _ => e.has_prev,
887            };
888            if let (Some(s_full), true) = (s_full.as_ref(), pair_ready) {
889                s_loc.clear();
890                y_loc.clear();
891                for (a, &i) in e.support.iter().enumerate() {
892                    s_loc.push(s_full[i as usize]);
893                    y_loc.push(match e.source {
894                        ElementSource::LagrangianBlock => {
895                            lagrangian_dy.as_ref().expect("checked above")[i as usize]
896                        }
897                        _ => g_loc[a] - e.prev_g[a],
898                    });
899                }
900                let before = e.b.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
901                if update_element(
902                    e,
903                    &s_loc,
904                    &y_loc,
905                    self.update_type,
906                    self.init_val_min,
907                    self.init_val_max,
908                    self.curvature_cap,
909                ) {
910                    self.accepted_updates += 1;
911                } else {
912                    self.skipped_updates += 1;
913                }
914                if oracle {
915                    let sn = dot(&s_loc, &s_loc).sqrt();
916                    let yn = dot(&y_loc, &y_loc).sqrt();
917                    let r = if sn > 0.0 { yn / sn } else { 0.0 };
918                    if r > self.dbg.ratio {
919                        self.dbg = DebugPeaks {
920                            ratio: r,
921                            ratio_s: sn,
922                            ratio_y: yn,
923                            ratio_k: e.k(),
924                            ..self.dbg
925                        };
926                    }
927                    let after = e.b.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
928                    self.dbg.delta = self.dbg.delta.max((after - before).abs());
929                }
930            }
931
932            e.prev_g.copy_from_slice(&g_loc);
933            e.has_prev = true;
934        }
935        self.prev_x = Some(x);
936        if self.mode == ElementMode::PrimalBlock {
937            self.prev_grad_f = Some(grad_f.clone());
938            self.prev_jac_c = Some(jac_c.values().to_vec());
939            self.prev_jac_d = Some(jac_d.values().to_vec());
940        }
941
942        // ---- assemble ------------------------------------------------
943        //
944        //   W = ∇²f + Σ_j (y_c)_j ∇²c_j + Σ_j (y_d)_j ∇²d_j
945        //
946        // with `obj_factor = 1`, matching
947        // `IpoptCalculatedQuantities::curr_exact_hessian`.
948        let y_c = flat(&*curr_y_c);
949        let y_d = flat(&*curr_y_d);
950        let space = Rc::clone(self.space.as_ref().expect("structure built above"));
951        let mut w = SymTMatrix::new(Rc::clone(&space));
952        // Before any element has taken a pair there is no curvature
953        // information anywhere, and `init_val · I` is the same opening
954        // model the limited-memory path's empty-history branch uses.
955        // Publishing the honest all-zero `W` instead hands the first KKT
956        // solve a `(1,1)` block with no curvature at all and lets the
957        // inertia correction invent the scale.
958        let any_seeded = self.elements.iter().any(|e| e.seeded);
959        {
960            let vals = w.values_mut();
961            vals.iter_mut().for_each(|v| *v = 0.0);
962            if !any_seeded {
963                for &p in &self.diag_pos {
964                    vals[p as usize] = self.init_val;
965                }
966            }
967            for e in self.elements.iter().filter(|_| any_seeded) {
968                let weight = match e.source {
969                    ElementSource::Objective => 1.0,
970                    ElementSource::EqRow => y_c[e.row as usize],
971                    ElementSource::IneqRow => y_d[e.row as usize],
972                    // The multiplier is already inside the modelled
973                    // function; weighting again would square it.
974                    ElementSource::LagrangianBlock => 1.0,
975                };
976                if weight == 0.0 || !weight.is_finite() {
977                    continue;
978                }
979                for (p, &m) in e.map.iter().enumerate() {
980                    vals[m as usize] += weight * e.b[p];
981                }
982            }
983            // Coordinates no element covers get the same nonzero floor
984            // the masked limited-memory diagonal uses, for the same
985            // reason: a structurally empty `(1,1)` row is carried by
986            // `Σ_x` alone and costs the factorization a near-singular
987            // pivot on every one of them.
988            if any_seeded {
989                for &i in &self.uncovered {
990                    vals[self.diag_pos[i as usize] as usize] = self.init_val_min;
991                }
992            }
993        }
994        // Direct correctness oracle (`POUNCE_PARTITIONED_ORACLE`). On a
995        // model that *does* supply second derivatives — every `.nl`, for
996        // instance — the exact Lagrangian Hessian at this very iterate,
997        // with this very `obj_factor` and these very multipliers, is one
998        // call away. Comparing against it is the only check in this
999        // module that reads a number the updater did not produce: the
1000        // unit tests pin the update formulas against themselves, and a
1001        // self-consistently wrong assembly would pass all of them.
1002        //
1003        // Never on by default: it evaluates `eval_h` every iteration,
1004        // which is precisely the cost the updater exists to avoid.
1005        if std::env::var("POUNCE_PARTITIONED_ORACLE").is_ok() {
1006            let exact = cq.borrow().curr_exact_hessian();
1007            if let Some(t) = exact.as_any().downcast_ref::<SymTMatrix>() {
1008                // Feasibility census for a sparse finite-difference
1009                // Hessian: the number of directional derivatives such a
1010                // scheme needs is set by the coloring of this pattern,
1011                // and `rho_max` (the widest symmetric row) is its
1012                // practical lower bound. Printed once.
1013                if std::env::var("POUNCE_HESS_PATTERN_CENSUS").is_ok() && !self.census_done {
1014                    self.census_done = true;
1015                    let n_h = t.space().dim() as usize;
1016                    let mut deg = vec![0usize; n_h];
1017                    for (&i, &j) in t.irows().iter().zip(t.jcols().iter()) {
1018                        let (a, b) = ((i - 1) as usize, (j - 1) as usize);
1019                        deg[a] += 1;
1020                        if a != b {
1021                            deg[b] += 1;
1022                        }
1023                    }
1024                    let rho_max = deg.iter().copied().max().unwrap_or(0);
1025                    let mean = deg.iter().sum::<usize>() as f64 / n_h as f64;
1026                    let mut hist = [0usize; 8];
1027                    for &d in &deg {
1028                        let b = (d.saturating_sub(1) / 8).min(7);
1029                        hist[b] += 1;
1030                    }
1031                    eprintln!(
1032                        "hess-pattern: n={n_h} nnz={} rho_max={rho_max} mean_row={mean:.2} \
1033                         hist(1-8,9-16,...)={hist:?}",
1034                        t.nonzeros()
1035                    );
1036                }
1037                use std::collections::HashMap;
1038                let mut mine: HashMap<(Index, Index), Number> = HashMap::new();
1039                for ((&i, &j), &v) in space
1040                    .irows()
1041                    .iter()
1042                    .zip(space.jcols().iter())
1043                    .zip(w.values().iter())
1044                {
1045                    *mine.entry((i, j)).or_insert(0.0) += v;
1046                }
1047                let (mut max_exact, mut max_err) = (0.0_f64, 0.0_f64);
1048                let (mut num, mut den) = (0.0_f64, 0.0_f64);
1049                // Frobenius mass of the exact Hessian that falls INSIDE
1050                // this updater's pattern. For `ElementMode::PrimalBlock`
1051                // this is the direct test of the variable-ordering
1052                // assumption: a transcription that orders by stage puts
1053                // nearly all of it inside, one that does not shows a low
1054                // fraction and the partition is wrong for that model.
1055                let mut captured = 0.0_f64;
1056                let mut worst = ((0, 0), 0.0, 0.0);
1057                let mut seen: HashMap<(Index, Index), bool> = HashMap::new();
1058                for ((&i, &j), &v) in t
1059                    .irows()
1060                    .iter()
1061                    .zip(t.jcols().iter())
1062                    .zip(t.values().iter())
1063                {
1064                    seen.insert((i, j), true);
1065                    let m = mine.get(&(i, j)).copied().unwrap_or(0.0);
1066                    if mine.contains_key(&(i, j)) {
1067                        captured += v * v;
1068                    }
1069                    let e = (m - v).abs();
1070                    max_exact = max_exact.max(v.abs());
1071
1072                    if e > max_err {
1073                        max_err = e;
1074                        worst = ((i, j), v, m);
1075                    }
1076                    num += e * e;
1077                    den += v * v;
1078                }
1079                // Entries this updater carries that the true Hessian does
1080                // not: the per-constraint pattern is `supp ⊗ supp`, an
1081                // over-estimate, and those entries must be ~0.
1082                let mut extra = 0.0_f64;
1083                for (&k, &v) in mine.iter() {
1084                    if !seen.contains_key(&k) {
1085                        extra = extra.max(v.abs());
1086                    }
1087                }
1088                eprintln!(
1089                    "partitioned-qn oracle: rel_fro={:.3e} max_abs_err={:.3e}                      max|exact|={:.3e} worst={:?} exact={:.6e} mine={:.6e}                      max|extra-pattern|={:.3e} pattern_captures={:.4}",
1090                    (num / den.max(1e-300)).sqrt(),
1091                    max_err,
1092                    max_exact,
1093                    worst.0,
1094                    worst.1,
1095                    worst.2,
1096                    extra,
1097                    (captured / den.max(1e-300)).sqrt()
1098                );
1099                eprintln!(
1100                    "  peaks: max|y_e|/|s_e|={:.3e} (|s_e|={:.3e} |y_e|={:.3e} k={}) \
1101                     max_block_delta={:.3e} |s|={:.3e} accepted={} skipped={}",
1102                    self.dbg.ratio,
1103                    self.dbg.ratio_s,
1104                    self.dbg.ratio_y,
1105                    self.dbg.ratio_k,
1106                    self.dbg.delta,
1107                    self.dbg.step_norm,
1108                    self.accepted_updates,
1109                    self.skipped_updates
1110                );
1111            }
1112        }
1113        if std::env::var("POUNCE_PARTITIONED_DUMP").is_ok() && space.nonzeros() <= 32 {
1114            eprintln!(
1115                "partitioned-qn W: seeded={any_seeded} irows={:?} jcols={:?} vals={:?}",
1116                space.irows(),
1117                space.jcols(),
1118                w.values()
1119            );
1120            eprintln!("  y_c={y_c:?} y_d={y_d:?}");
1121        }
1122        data.borrow_mut().w = Some(Rc::new(w));
1123        true
1124    }
1125}
1126
1127/// Read a primal-space vector's values as a flat slice. Mirrors
1128/// `lim_mem_quasi_newton::expanded_of`; kept local so the two updaters do
1129/// not share mutable helper state.
1130fn flat(v: &dyn Vector) -> Vec<Number> {
1131    if let Some(dv) = v.as_any().downcast_ref::<DenseVector>() {
1132        return dv.expanded_values();
1133    }
1134    if let Some(cv) = v.as_any().downcast_ref::<CompoundVector>() {
1135        let mut out = Vec::with_capacity(cv.dim() as usize);
1136        for i in 0..cv.n_comps() {
1137            out.extend(flat(cv.comp(i)));
1138        }
1139        return out;
1140    }
1141    panic!("PartitionedQuasiNewtonUpdater: unsupported primal vector type");
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147
1148    /// `packed_mult` agrees with a dense symmetric product, off-diagonal
1149    /// fan-out included.
1150    #[test]
1151    fn packed_mult_matches_dense() {
1152        // B = [[1, 2, 3], [2, 4, 5], [3, 5, 6]]
1153        let b = vec![1.0, 2.0, 4.0, 3.0, 5.0, 6.0];
1154        let s = vec![1.0, -2.0, 0.5];
1155        let mut out = vec![0.0; 3];
1156        packed_mult(&b, &s, &mut out);
1157        let dense = [[1.0, 2.0, 3.0], [2.0, 4.0, 5.0], [3.0, 5.0, 6.0]];
1158        for a in 0..3 {
1159            let want: Number = (0..3).map(|c| dense[a][c] * s[c]).sum();
1160            assert!(
1161                (out[a] - want).abs() < 1e-12,
1162                "row {a}: {} vs {want}",
1163                out[a]
1164            );
1165        }
1166    }
1167
1168    /// SR1 satisfies the secant equation `B_+ s = y` exactly in one step
1169    /// when the denominator is safe — the property that makes it the
1170    /// right per-element update for a nonconvex constraint.
1171    #[test]
1172    fn sr1_satisfies_the_secant_equation() {
1173        let mut e = Element {
1174            source: ElementSource::EqRow,
1175            row: 0,
1176            support: vec![0, 1, 2],
1177            dense: true,
1178            b: vec![0.0; 6],
1179            prev_g: vec![0.0; 3],
1180            has_prev: true,
1181            seeded: true,
1182            entries: Vec::new(),
1183            map: Vec::new(),
1184        };
1185        let s = vec![1.0, 0.5, -0.25];
1186        // A deliberately indefinite target: SR1 must not sanitize it.
1187        let y = vec![-2.0, 1.0, 3.0];
1188        assert!(update_element(
1189            &mut e,
1190            &s,
1191            &y,
1192            UpdateType::Sr1,
1193            1e-8,
1194            1e8,
1195            1e12
1196        ));
1197        let mut bs = vec![0.0; 3];
1198        packed_mult(&e.b, &s, &mut bs);
1199        for a in 0..3 {
1200            assert!(
1201                (bs[a] - y[a]).abs() < 1e-10,
1202                "component {a}: {} vs {}",
1203                bs[a],
1204                y[a]
1205            );
1206        }
1207    }
1208
1209    /// An element whose curvature is genuinely negative keeps a negative
1210    /// block under SR1. Damped BFGS does not, which is why SR1 is the
1211    /// default — see the module docs and issue #131.
1212    ///
1213    /// Note what carries the sign on a **one-dimensional** element: the
1214    /// scalar seeding `gamma = sᵀy/sᵀs` already satisfies the secant
1215    /// equation exactly, so the rank-1 term has nothing to add and
1216    /// `update_element` correctly declines. The property under test is
1217    /// the resulting curvature, not the return value.
1218    #[test]
1219    fn sr1_preserves_negative_curvature_where_bfgs_would_not() {
1220        let make = || Element {
1221            source: ElementSource::EqRow,
1222            row: 0,
1223            support: vec![0],
1224            dense: true,
1225            b: vec![0.0],
1226            prev_g: vec![0.0],
1227            has_prev: true,
1228            seeded: false,
1229            entries: Vec::new(),
1230            map: Vec::new(),
1231        };
1232        let s = vec![1.0];
1233        let y = vec![-3.0];
1234
1235        let mut sr1 = make();
1236        update_element(&mut sr1, &s, &y, UpdateType::Sr1, 1e-8, 1e8, 1e12);
1237        assert!(sr1.b[0] < 0.0, "SR1 kept curvature {}", sr1.b[0]);
1238        // and it is the exact secant value, not merely the right sign
1239        assert!((sr1.b[0] + 3.0).abs() < 1e-12, "{}", sr1.b[0]);
1240
1241        let mut bfgs = make();
1242        update_element(&mut bfgs, &s, &y, UpdateType::Bfgs, 1e-8, 1e8, 1e12);
1243        assert!(bfgs.b[0] > 0.0, "damped BFGS kept curvature {}", bfgs.b[0]);
1244    }
1245
1246    /// **The scalar seeding always annihilates the first SR1 update**,
1247    /// for every element and every dimension. Seeding sets `B = γI` with
1248    /// `γ = sᵀy/sᵀs`, so `Bs = γs` and the SR1 denominator is
1249    /// `wᵀs = sᵀy − γ·sᵀs ≡ 0`. That is not a defect — the seeded block
1250    /// already satisfies the secant equation along `s` — but it means an
1251    /// element's *first* pair contributes only a multiple of the
1252    /// identity and no directional information whatsoever. With ~5 000
1253    /// elements each receiving one direction per iteration, that costs a
1254    /// full iteration of information per element; see
1255    /// `dev-notes/partitioned-quasi-newton-prototype.md`.
1256    #[test]
1257    fn scalar_seeding_leaves_the_first_sr1_update_with_nothing_to_do() {
1258        let mut e = Element {
1259            source: ElementSource::EqRow,
1260            row: 0,
1261            support: vec![0, 1],
1262            dense: true,
1263            b: vec![0.0; 3],
1264            prev_g: vec![0.0; 2],
1265            has_prev: true,
1266            seeded: false,
1267            entries: Vec::new(),
1268            map: Vec::new(),
1269        };
1270        let s = vec![1.0, 0.5];
1271        let y = vec![2.0, -4.0];
1272        // Skipped, and what is left behind is exactly the seeded scalar.
1273        assert!(!update_element(
1274            &mut e,
1275            &s,
1276            &y,
1277            UpdateType::Sr1,
1278            1e-8,
1279            1e8,
1280            1e12
1281        ));
1282        assert!(e.seeded);
1283        assert_eq!(e.b[1], 0.0, "off-diagonal must still be zero");
1284        assert!(
1285            (e.b[0] - e.b[2]).abs() < 1e-15,
1286            "block must be a multiple of I"
1287        );
1288    }
1289
1290    /// The SR1-vs-BFGS contrast on a **two-dimensional** element, driven
1291    /// through the path the solver actually takes: a first pair to seed,
1292    /// then a second that the rank-1 term can act on. SR1 reproduces the
1293    /// second pair exactly and leaves the block indefinite; damped BFGS
1294    /// returns a positive definite block that does not.
1295    #[test]
1296    fn sr1_reaches_an_indefinite_block_where_bfgs_stays_definite() {
1297        let make = || Element {
1298            source: ElementSource::EqRow,
1299            row: 0,
1300            support: vec![0, 1],
1301            dense: true,
1302            b: vec![0.0; 3],
1303            prev_g: vec![0.0; 2],
1304            has_prev: true,
1305            seeded: false,
1306            entries: Vec::new(),
1307            map: Vec::new(),
1308        };
1309        let (s1, y1) = (vec![1.0, 0.0], vec![2.0, 0.0]);
1310        let (s2, y2) = (vec![0.0, 1.0], vec![0.0, -4.0]);
1311
1312        let mut sr1 = make();
1313        update_element(&mut sr1, &s1, &y1, UpdateType::Sr1, 1e-8, 1e8, 1e12);
1314        assert!(update_element(
1315            &mut sr1,
1316            &s2,
1317            &y2,
1318            UpdateType::Sr1,
1319            1e-8,
1320            1e8,
1321            1e12
1322        ));
1323        let mut bs = vec![0.0; 2];
1324        packed_mult(&sr1.b, &s2, &mut bs);
1325        for a in 0..2 {
1326            assert!(
1327                (bs[a] - y2[a]).abs() < 1e-10,
1328                "component {a}: {} vs {}",
1329                bs[a],
1330                y2[a]
1331            );
1332        }
1333        // det < 0 ⇒ one eigenvalue of each sign: the indefiniteness the
1334        // inertia correction is supposed to see.
1335        let det = sr1.b[0] * sr1.b[2] - sr1.b[1] * sr1.b[1];
1336        assert!(det < 0.0, "SR1 block determinant {det}");
1337
1338        let mut bfgs = make();
1339        update_element(&mut bfgs, &s1, &y1, UpdateType::Bfgs, 1e-8, 1e8, 1e12);
1340        assert!(update_element(
1341            &mut bfgs,
1342            &s2,
1343            &y2,
1344            UpdateType::Bfgs,
1345            1e-8,
1346            1e8,
1347            1e12
1348        ));
1349        let det_b = bfgs.b[0] * bfgs.b[2] - bfgs.b[1] * bfgs.b[1];
1350        assert!(
1351            bfgs.b[0] > 0.0 && det_b > 0.0,
1352            "damped BFGS block is positive definite: diag {} det {det_b}",
1353            bfgs.b[0]
1354        );
1355    }
1356
1357    /// The diagonal fallback satisfies the weak secant condition
1358    /// `sᵀBs = sᵀy`, which is the whole contract it is asked for.
1359    #[test]
1360    fn diagonal_element_satisfies_the_weak_secant_condition() {
1361        let mut e = Element {
1362            source: ElementSource::Objective,
1363            row: 0,
1364            support: vec![0, 1, 2],
1365            dense: false,
1366            b: vec![0.0; 3],
1367            prev_g: vec![0.0; 3],
1368            has_prev: true,
1369            seeded: true,
1370            entries: Vec::new(),
1371            map: Vec::new(),
1372        };
1373        let s = vec![1.0, -2.0, 0.5];
1374        let y = vec![0.5, 1.0, -3.0];
1375        assert!(update_element(
1376            &mut e,
1377            &s,
1378            &y,
1379            UpdateType::Sr1,
1380            1e-8,
1381            1e8,
1382            1e12
1383        ));
1384        let s_bs: Number = (0..3).map(|a| e.b[a] * s[a] * s[a]).sum();
1385        assert!(
1386            (s_bs - dot(&s, &y)).abs() < 1e-10,
1387            "{s_bs} vs {}",
1388            dot(&s, &y)
1389        );
1390    }
1391
1392    /// A parallel `y` that carries no new information leaves the block
1393    /// untouched rather than producing an unbounded rank-1 term.
1394    #[test]
1395    fn sr1_skips_a_degenerate_denominator() {
1396        let mut e = Element {
1397            source: ElementSource::EqRow,
1398            row: 0,
1399            support: vec![0, 1],
1400            dense: true,
1401            b: vec![2.0, 0.0, 2.0],
1402            prev_g: vec![0.0; 2],
1403            has_prev: true,
1404            seeded: true,
1405            entries: Vec::new(),
1406            map: Vec::new(),
1407        };
1408        let before = e.b.clone();
1409        let s = vec![1.0, 1.0];
1410        // y = B s exactly, so w = 0 and the denominator vanishes.
1411        let y = vec![2.0, 2.0];
1412        assert!(!update_element(
1413            &mut e,
1414            &s,
1415            &y,
1416            UpdateType::Sr1,
1417            1e-8,
1418            1e8,
1419            1e12
1420        ));
1421        assert_eq!(e.b, before);
1422    }
1423}