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, but it applies damped BFGS to
61//! the objective element (and the `blocks` mode's Lagrangian blocks) only:
62//! constraint elements take SR1 under it too (`element_update_type`,
63//! gh#989), because a PSD model of `∇²c_j` weighted by a negative multiplier
64//! gave the assembled `W` the wrong inertia and `bfgs` hit `max_iter` on a
65//! Radau batch reactor with no declared Hessian pattern.
66//!
67//! # Elements with a declared, incomplete pattern
68//!
69//! When the model declares its Hessian structure, an element keeps only
70//! the declared entries of its support (`Element::allowed`), and neither
71//! formula above is used on it: a rank-one or rank-two term fills every
72//! position, including the ones the model says are structurally zero.
73//! Such an element takes [`sparse_secant_update`] (Toint's minimum-change
74//! symmetric secant update restricted to the pattern) from `B = 0`, with
75//! no `γI` seeding. On a collocation row the declared group is a star —
76//! `k` free entries on `k` coordinates — so one pair determines the
77//! element exactly. gh#989 item 5: the dense updates' spurious entries,
78//! weighted by multipliers of either sign, gave the assembled `W` the wrong
79//! inertia on a Radau batch reactor, and the IPM spent the solve on a
80//! `δ_w` decaying by `1/3` per iteration (16/28/86 iterations at
81//! N = 25/100/400 against exact 12/16/18; `bfgs` at the cap). With the
82//! pattern kept: 13/15/21, `bfgs` and `blocks` the same.
83//!
84//! # Bounded element size
85//!
86//! An element with `k` nonzeros costs `k(k+1)/2` stored reals. A row that
87//! touches most of `x` — a global resource constraint, or an objective
88//! that sums over every stage — would blow that up, so elements wider
89//! than `max_element` degrade to a **diagonal** approximation
90//! (Dennis-Wolkowicz weak secant, `sᵀBs = sᵀy`) rather than being
91//! dropped: a separable objective is then still represented exactly, and
92//! a coupled one is represented approximately instead of not at all.
93
94use crate::hess::lim_mem_quasi_newton::UpdateType;
95use crate::hess::r#trait::HessianUpdater;
96use crate::ipopt_cq::IpoptCqHandle;
97use crate::ipopt_data::IpoptDataHandle;
98use pounce_common::types::{Index, Number};
99use pounce_linalg::Vector;
100use pounce_linalg::compound_vector::CompoundVector;
101use pounce_linalg::dense_vector::DenseVector;
102use pounce_linalg::triplet::{GenTMatrix, SymTMatrix, SymTMatrixSpace};
103use std::rc::Rc;
104
105/// Relative safeguard on the SR1 denominator: the update is skipped when
106/// `|wᵀs| ≤ SR1_SAFEGUARD · ‖s‖ · ‖w‖` (Nocedal & Wright §6.2, eq. 6.26,
107/// which suggests `r ∈ [1e-8, 1e-4]`).
108///
109/// This is a *direction* test only — it rejects a pair carrying no usable
110/// curvature along `s`. It is deliberately **not** the magnitude control,
111/// and trying to make it one was measured to fail from both ends. The
112/// rank-1 term `w wᵀ / wᵀs` is bounded only by `‖w‖ / (r ‖s‖)`, so at
113/// `r = 1e-8` it permits a correction `1e8` times the curvature the data
114/// implies — on `benchmarks/large_scale` `laptime` the implied element
115/// curvature `‖y_e‖/‖s_e‖` stayed a healthy 9–33 every iteration while
116/// single-update block changes reached `1.7e8` and the assembled `W` ran
117/// four orders of magnitude over the exact Lagrangian Hessian. But
118/// tightening it to `1e-4` overshoots just as badly in the other
119/// direction: element supports are small (`k ≈ 9` here) and `w` is
120/// routinely near-orthogonal to `s`, so the test then rejected 9 949 of
121/// 9 950 updates, the blocks never learned anything, and `W` read `0.14`
122/// where the exact Hessian read `2.1e3`. [`DEFAULT_CURVATURE_CAP`] is
123/// where the magnitude is bounded, in the units of the thing being
124/// modelled; this stays loose so that pairs still reach it.
125const SR1_SAFEGUARD: Number = 1e-8;
126
127/// Powell damping threshold for [`UpdateType::Bfgs`], matching the
128/// hard-coded `0.2` of the limited-memory path.
129const POWELL_THETA: Number = 0.2;
130
131/// Relative floor on the BFGS denominators `sᵀr` and `sᵀBs`. The
132/// limited-memory path tests only `> 0`, which is safe there because its
133/// `s` is a whole primal step; restricted to one element's support the
134/// same quantity goes arbitrarily small, and `r rᵀ / sᵀr` then blows up
135/// exactly as the SR1 term does. Same measurement as
136/// [`SR1_SAFEGUARD`]: unfloored, damped BFGS reached block changes of
137/// `2.9e8` — worse than SR1, because `> 0` is no floor at all.
138const BFGS_DENOM_FLOOR: Number = 1e-8;
139
140/// Cap on a single update's magnitude, as a multiple of the curvature the
141/// element's own secant pair implies (`‖y_e‖ / ‖s_e‖`).
142///
143/// **Off by default, because every finite value measured was worse than
144/// off.** The idea was to bound the update in the units of the quantity
145/// being modelled, since the relative denominator floors bound only the
146/// ratio that produced the term. It does bound it — and it makes the
147/// solver worse, non-monotonically. On `benchmarks/large_scale`
148/// `laptime` at `N = 80`, `max_iter = 1200` (true optimum 65.462928):
149///
150/// | cap | status | iters | wall | objective |
151/// |---|---|---|---|---|
152/// | 1e1 | ErrorInStepComputation | 1071 | 179 s | 65.518586 |
153/// | 1e2 | MaxIter | 1200 | 214 s | 67.202124 |
154/// | 1e6 | MaxIter | 1200 | 232 s | 80.398129 |
155/// | off | **Optimal** | 559 | 50 s | 65.462802 |
156///
157/// `1e6` being worse than both `1e1` and off is the shape of the result:
158/// this is not "less capping is better". Rejection is *selective* — it
159/// drops precisely the elements whose curvature is moving fastest,
160/// leaving those blocks stale while their neighbours update. The
161/// assembled `W` is then internally inconsistent, which costs more than a
162/// uniformly noisy but coherent model.
163///
164/// Kept as a knob rather than deleted so the non-monotonicity can be
165/// re-measured against a different element decomposition, where it may
166/// behave differently. Do not turn it on without measuring.
167const DEFAULT_CURVATURE_CAP: Number = Number::INFINITY;
168
169/// Which gradient an element reads to form its curvature pair.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum ElementSource {
172    /// The objective: gradient entries come from `grad_f` directly.
173    Objective,
174    /// A row of `J_c`; entries come from the equality Jacobian's values.
175    EqRow,
176    /// A row of `J_d`; entries come from the inequality Jacobian's values.
177    IneqRow,
178    /// A contiguous block of primal variables under
179    /// [`ElementMode::PrimalBlock`]. The element function is the
180    /// **Lagrangian itself**, restricted to the block, so its gradient is
181    /// `∇f + J_cᵀ y_c + J_dᵀ y_d` restricted, and its assembly weight is
182    /// 1 — the multiplier is already inside the function being modelled.
183    LagrangianBlock,
184}
185
186/// How the Lagrangian is split into elements.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ElementMode {
189    /// One element per constraint row (plus the objective). Each block
190    /// has a multiplier-independent target, and needs no assumption about
191    /// how the model orders its variables — but there are as many blocks
192    /// as constraints, each approximating a `∇²c_j` with no sign
193    /// structure, and the errors accumulate through the weighted sum.
194    PerConstraint,
195    /// One element per contiguous block of primal variables, modelling
196    /// the Lagrangian's restriction to that block with damped BFGS.
197    /// This is Asprion, Chinellato & Guzzella's partition: a direct
198    /// collocation transcription orders its variables by stage, so the
199    /// Lagrangian Hessian really is close to block diagonal in this
200    /// partition, and the block count is the stage count rather than the
201    /// constraint count.
202    PrimalBlock,
203}
204
205/// One element function's quasi-Newton state.
206#[derive(Debug)]
207struct Element {
208    source: ElementSource,
209    /// 0-based row index within the element's source block, i.e. the
210    /// position in `y_c` / `y_d` whose multiplier weights this element.
211    /// Stored rather than inferred from the element's position in the
212    /// table: a constraint row with no Jacobian entries produces no
213    /// element, so counting elements and counting multipliers diverge
214    /// the moment a model has one.
215    row: u32,
216    /// 0-based var-x columns this element touches, ascending and
217    /// deduplicated. `k = support.len()`.
218    support: Vec<Index>,
219    /// `true` when `b` holds the packed lower triangle (`k(k+1)/2`
220    /// reals); `false` when the element degraded to a diagonal
221    /// approximation (`k` reals).
222    dense: bool,
223    /// `B_e`. Packed lower triangle in local coordinates — entry
224    /// `(a, c)` with `a >= c` at `a(a+1)/2 + c` — or the diagonal alone
225    /// when `!dense`.
226    b: Vec<Number>,
227    /// This element's gradient at the previous iterate, local coords.
228    prev_g: Vec<Number>,
229    /// Whether `prev_g` has been written at least once.
230    has_prev: bool,
231    /// Whether `b` has taken an accepted curvature pair (drives the
232    /// one-time scalar seeding).
233    seeded: bool,
234    /// `(position in the source Jacobian's triplet arrays, local index)`
235    /// for every triplet belonging to this element's row. Empty for
236    /// [`ElementSource::Objective`], which reads `grad_f` by support
237    /// index. Duplicated `(row, col)` triplets are summed, matching the
238    /// triplet contract.
239    entries: Vec<(u32, u32)>,
240    /// Position in the assembled matrix's value array for each entry of
241    /// `b`, in the same packing. `u32::MAX` marks a packed position the
242    /// element's declared pattern excludes (never assembled).
243    map: Vec<u32>,
244    /// Which packed positions of `b` the model's declared Hessian pattern
245    /// allows, when the element came from [`PartitionedQuasiNewtonUpdater::push_split`]
246    /// and that pattern is **not** complete on the element. `None` means
247    /// every position is free (the dense formulas apply). When `Some`, the
248    /// element is updated with the pattern-constrained secant update
249    /// ([`sparse_secant_update`]) and the excluded entries stay exactly
250    /// zero (gh#989 item 5).
251    allowed: Option<Vec<bool>>,
252}
253
254impl Element {
255    fn k(&self) -> usize {
256        self.support.len()
257    }
258}
259
260/// One-time structural census, for the measurement write-up and for
261/// `POUNCE_PARTITIONED_DEBUG`.
262#[derive(Debug, Clone, Copy, Default)]
263pub struct PartitionStats {
264    pub elements: usize,
265    pub dense_elements: usize,
266    pub diagonal_elements: usize,
267    pub max_support: usize,
268    pub total_support: usize,
269    /// Nonzeros in the assembled lower triangle, diagonal included.
270    pub assembled_nnz: usize,
271    /// Reals held across all `B_e`.
272    pub stored_reals: usize,
273}
274
275pub struct PartitionedQuasiNewtonUpdater {
276    pub update_type: UpdateType,
277    /// How the Lagrangian is split. See [`ElementMode`].
278    pub mode: ElementMode,
279    /// Target width of a primal block under [`ElementMode::PrimalBlock`]
280    /// (`partitioned_block_size`).
281    pub block_size: usize,
282    /// Widest element that keeps a dense block; wider ones degrade to a
283    /// diagonal approximation. Option `partitioned_max_element`.
284    pub max_element: usize,
285    /// Floor placed on assembled diagonal entries no active element
286    /// covers, so the `(1, 1)` block never presents a structurally empty
287    /// row to the factorization. Shares
288    /// `limited_memory_init_val_min`'s default and its rationale — see
289    /// the gh#624 discussion in
290    /// [`crate::hess::lim_mem_quasi_newton`].
291    pub init_val_min: Number,
292    /// Clamp on the magnitude of the per-element scalar seeding.
293    pub init_val_max: Number,
294    /// Multiple of the identity published on the **first** iteration,
295    /// before any element has seen a curvature pair. Mirrors
296    /// `limited_memory_init_val` and the empty-history branch of the
297    /// limited-memory updater: publishing the honest all-zero `W` there
298    /// instead hands the very first KKT solve a `(1,1)` block with no
299    /// curvature at all, and the whole trajectory is set by whatever
300    /// `delta_x` the inertia correction then has to invent.
301    pub init_val: Number,
302    /// Support of the objective element, in the compressed `x_var`
303    /// space, when the TNLP could state it
304    /// (`TNLPAdapter::objective_nonlinear_vars`). `None` falls back to
305    /// the first `∇f`'s nonzeros — see `build_structure`.
306    pub objective_vars: Option<Vec<Index>>,
307    /// Multiple of the implied curvature `‖y_e‖/‖s_e‖` that a single
308    /// update to an element block may reach
309    /// (`partitioned_curvature_cap`). See [`DEFAULT_CURVATURE_CAP`].
310    pub curvature_cap: Number,
311    /// The TNLP's declared Lagrangian Hessian sparsity, `(row >= col)` in the
312    /// compressed variable space. When present, each per-constraint (and
313    /// objective) element is split into the connected groups of that pattern
314    /// restricted to its support, and coordinates the pattern never couples
315    /// are dropped. See [`Self::push_split`].
316    pub declared_pattern: Option<Vec<(Index, Index)>>,
317
318    /// Element table, built on the first call and fixed thereafter.
319    elements: Vec<Element>,
320    /// Assembled pattern, built with the element table.
321    space: Option<Rc<SymTMatrixSpace>>,
322    /// Assembled positions of the `n` diagonal entries.
323    diag_pos: Vec<u32>,
324    /// Coordinates no active element covers, which take `init_val_min`.
325    uncovered: Vec<Index>,
326    /// `x` at the previous call.
327    prev_x: Option<Vec<Number>>,
328    /// `∇f`, and the Jacobians' triplet values, at the previous call.
329    /// [`ElementMode::PrimalBlock`] needs the change in the *Lagrangian*
330    /// gradient, which is not a per-element quantity, so it is formed
331    /// once per call from these rather than element by element.
332    prev_grad_f: Option<Vec<Number>>,
333    prev_jac_c: Option<Vec<Number>>,
334    prev_jac_d: Option<Vec<Number>>,
335    stats: PartitionStats,
336    /// One-shot latch for `POUNCE_HESS_PATTERN_CENSUS`.
337    census_done: bool,
338    /// Curvature pairs accepted / skipped, cumulative — a cheap health
339    /// signal for the write-up.
340    pub accepted_updates: u64,
341    pub skipped_updates: u64,
342    /// Per-call diagnostics for `POUNCE_PARTITIONED_ORACLE`: the element
343    /// with the largest implied curvature `‖y_e‖ / ‖s_e‖`, and the
344    /// largest single-update change to any block.
345    dbg: DebugPeaks,
346}
347
348impl PartitionedQuasiNewtonUpdater {
349    pub fn new(update_type: UpdateType) -> Self {
350        Self {
351            update_type,
352            mode: ElementMode::PerConstraint,
353            block_size: 64,
354            max_element: 64,
355            init_val_min: 1e-8,
356            init_val_max: 1e8,
357            init_val: 1.0,
358            objective_vars: None,
359            curvature_cap: DEFAULT_CURVATURE_CAP,
360            declared_pattern: None,
361            elements: Vec::new(),
362            space: None,
363            diag_pos: Vec::new(),
364            uncovered: Vec::new(),
365            prev_x: None,
366            prev_grad_f: None,
367            prev_jac_c: None,
368            prev_jac_d: None,
369            stats: PartitionStats::default(),
370            census_done: false,
371            accepted_updates: 0,
372            skipped_updates: 0,
373            dbg: DebugPeaks::default(),
374        }
375    }
376
377    pub fn stats(&self) -> PartitionStats {
378        self.stats
379    }
380
381    /// Build the element table and the assembled sparsity pattern. Runs
382    /// once; both are structural and every later call reuses them, so
383    /// the backend's symbolic factorization is done a single time.
384    fn build_structure(
385        &mut self,
386        n: usize,
387        grad_f: &[Number],
388        jac_c: &GenTMatrix,
389        jac_d: &GenTMatrix,
390    ) {
391        let mut elements: Vec<Element> = Vec::new();
392
393        if self.mode == ElementMode::PrimalBlock {
394            // Contiguous blocks in the model's own variable order.
395            //
396            // The ordering assumption is load-bearing and is *checked*
397            // rather than trusted: `POUNCE_PARTITIONED_ORACLE` reports the
398            // fraction of the exact Hessian's Frobenius mass that falls
399            // inside the block-diagonal pattern. A transcription that
400            // orders by stage — which is what every direct-collocation
401            // writer does, and what `laptime` does — puts nearly all of it
402            // inside. One that does not will show a low fraction, and the
403            // partition is then simply wrong for that model rather than
404            // silently poor.
405            let bs = self.block_size.max(1);
406            if let Some(pat) = self.declared_pattern.as_ref() {
407                // gh#989 item 5: with a declared Hessian pattern the blocks
408                // are not guessed from the variable order — they are the
409                // connected components of that pattern, which are exactly
410                // the Lagrangian Hessian's diagonal blocks (no declared entry
411                // crosses a component, so the block-diagonal model has no
412                // truncation error at all). A writer that does not order by
413                // stage — an AMPL `.nl`, which puts nonlinear variables
414                // first, is one — no longer breaks the partition: on the
415                // Radau batch reactor at N = 400 contiguous blocks of 64 hit
416                // the iteration cap. A variable no declared entry mentions is
417                // linear in the Lagrangian and belongs to no block.
418                //
419                // Only when EVERY component fits in `partitioned_block_size`.
420                // Chunking a wide component cuts declared coupling, and that
421                // was measured to be worse than the contiguous partition:
422                // `laptime` at N = 80 is one 3 280-variable component, and
423                // chunking it hit the iteration cap where contiguous blocks
424                // take 166. Such a model keeps the contiguous partition.
425                let mut parent: Vec<usize> = (0..n).collect();
426                fn root(parent: &mut [usize], mut a: usize) -> usize {
427                    while parent[a] != a {
428                        parent[a] = parent[parent[a]];
429                        a = parent[a];
430                    }
431                    a
432                }
433                let mut mentioned = vec![false; n];
434                let mut adj: std::collections::HashMap<Index, Vec<Index>> = Default::default();
435                for &(r, c) in pat {
436                    let (ru, cu) = (r as usize, c as usize);
437                    if ru >= n || cu >= n {
438                        continue;
439                    }
440                    mentioned[ru] = true;
441                    mentioned[cu] = true;
442                    adj.entry(r).or_default().push(c);
443                    if r != c {
444                        adj.entry(c).or_default().push(r);
445                    }
446                    let (a, b) = (root(&mut parent, ru), root(&mut parent, cu));
447                    if a != b {
448                        parent[a.max(b)] = a.min(b);
449                    }
450                }
451                let mut comp_of_root: std::collections::HashMap<usize, usize> = Default::default();
452                let mut comps: Vec<Vec<Index>> = Vec::new();
453                for (i, _) in mentioned.iter().enumerate().filter(|(_, m)| **m) {
454                    let r = root(&mut parent, i);
455                    let g = *comp_of_root.entry(r).or_insert_with(|| {
456                        comps.push(Vec::new());
457                        comps.len() - 1
458                    });
459                    comps[g].push(i as Index);
460                }
461                if comps.iter().all(|c| c.len() <= bs) {
462                    for comp in comps {
463                        let mut el = Self::make_element(
464                            ElementSource::LagrangianBlock,
465                            0,
466                            comp,
467                            Vec::new(),
468                            usize::MAX,
469                        );
470                        el.allowed = pattern_mask(&el.support, &adj);
471                        elements.push(el);
472                    }
473                    self.finish_structure(n, elements);
474                    return;
475                }
476            }
477            let mut start = 0usize;
478            while start < n {
479                let end = (start + bs).min(n);
480                let support: Vec<Index> = (start..end).map(|i| i as Index).collect();
481                elements.push(Self::make_element(
482                    ElementSource::LagrangianBlock,
483                    0,
484                    support,
485                    Vec::new(),
486                    usize::MAX,
487                ));
488                start = end;
489            }
490            self.finish_structure(n, elements);
491            return;
492        }
493
494        // Declared-pattern adjacency (gh#989 item 5): coordinate -> every
495        // coordinate it is coupled to (itself included when the diagonal is
496        // declared), from the lower-triangle pairs.
497        let adjacency: Option<std::collections::HashMap<Index, Vec<Index>>> =
498            self.declared_pattern.as_ref().map(|pat| {
499                let mut m: std::collections::HashMap<Index, Vec<Index>> = Default::default();
500                for &(r, c) in pat {
501                    m.entry(r).or_default().push(c);
502                    if r != c {
503                        m.entry(c).or_default().push(r);
504                    }
505                }
506                m
507            });
508        // ---- objective element -------------------------------------
509        //
510        // Every constraint element takes its support from a row of the
511        // Jacobian, whose pattern the TNLP is obliged to declare. The
512        // objective has no such declaration, so the support comes from
513        // `get_objective_variables_linearity` via
514        // `TNLPAdapter::objective_nonlinear_vars` — the variables the
515        // objective is *nonlinear* in, which is exactly the rows `∇²f`
516        // can occupy.
517        //
518        // When the TNLP declines, the fallback is the first `∇f`'s
519        // nonzeros, and that pattern is *value-derived*: a coordinate
520        // whose `∂f/∂x_i` happens to vanish at the starting point is
521        // excluded for the whole solve. On `laptime`, which declares 321
522        // objective gradient nonzeros, the fallback captures 161 — so
523        // this is the live case, not a corner. Widening to all of `x` is
524        // not the alternative: that is an `n × n` element.
525        let obj_support: Vec<Index> = match self.objective_vars.clone() {
526            Some(v) => v,
527            None => (0..n)
528                .filter(|&i| grad_f[i] != 0.0)
529                .map(|i| i as Index)
530                .collect(),
531        };
532        if !obj_support.is_empty() {
533            let mut sup = obj_support;
534            sup.sort_unstable();
535            sup.dedup();
536            self.push_split(
537                &mut elements,
538                adjacency.as_ref(),
539                ElementSource::Objective,
540                0,
541                sup,
542                Vec::new(),
543            );
544        }
545
546        // ---- one element per constraint row -------------------------
547        for (source, jac) in [
548            (ElementSource::EqRow, jac_c),
549            (ElementSource::IneqRow, jac_d),
550        ] {
551            let n_rows = jac.space().n_rows() as usize;
552            let irows = jac.irows();
553            let jcols = jac.jcols();
554            // Bucket triplet positions by row. `irows` is 1-based.
555            let mut row_counts = vec![0u32; n_rows + 1];
556            for &i in irows {
557                row_counts[i as usize] += 1;
558            }
559            let mut row_start = vec![0u32; n_rows + 2];
560            for r in 0..=n_rows {
561                row_start[r + 1] = row_start[r] + row_counts[r];
562            }
563            let mut cursor = row_start.clone();
564            let mut by_row = vec![0u32; irows.len()];
565            for (pos, &i) in irows.iter().enumerate() {
566                let r = i as usize;
567                by_row[cursor[r] as usize] = pos as u32;
568                cursor[r] += 1;
569            }
570
571            for r in 1..=n_rows {
572                let slice = &by_row[row_start[r] as usize..row_start[r + 1] as usize];
573                if slice.is_empty() {
574                    continue;
575                }
576                // Support = sorted unique columns of this row (0-based).
577                let mut cols: Vec<Index> = slice.iter().map(|&p| jcols[p as usize] - 1).collect();
578                cols.sort_unstable();
579                cols.dedup();
580                // Local index of every triplet position in the row.
581                // Duplicated `(row, col)` triplets land on the same local
582                // index and are summed when the gradient is read.
583                let entries: Vec<(u32, u32)> = slice
584                    .iter()
585                    .map(|&p| {
586                        let c = jcols[p as usize] - 1;
587                        let local = cols.partition_point(|&x| x < c) as u32;
588                        (p, local)
589                    })
590                    .collect();
591                self.push_split(
592                    &mut elements,
593                    adjacency.as_ref(),
594                    source,
595                    (r - 1) as u32,
596                    cols,
597                    entries,
598                );
599            }
600        }
601
602        self.finish_structure(n, elements);
603    }
604
605    /// Build the assembled pattern, the per-element scatter maps and the
606    /// census from a finished element table. Shared by both
607    /// [`ElementMode`]s.
608    fn finish_structure(&mut self, n: usize, mut elements: Vec<Element>) {
609        // ---- assembled pattern --------------------------------------
610        //
611        // Union of each active element's own lower triangle, plus the
612        // full diagonal so no primal row of the `(1,1)` block is
613        // structurally empty.
614        let mut pairs: Vec<(Index, Index)> = Vec::new();
615        for i in 0..n {
616            pairs.push((i as Index, i as Index));
617        }
618        for e in &elements {
619            if e.dense {
620                for a in 0..e.k() {
621                    for c in 0..=a {
622                        if e.allowed.as_ref().is_none_or(|m| m[a * (a + 1) / 2 + c]) {
623                            pairs.push((e.support[a], e.support[c]));
624                        }
625                    }
626                }
627            } else {
628                for a in 0..e.k() {
629                    pairs.push((e.support[a], e.support[a]));
630                }
631            }
632        }
633        pairs.sort_unstable();
634        pairs.dedup();
635
636        // Lookup from (row, col) to assembled position, by binary search
637        // over the sorted pair list.
638        let find =
639            |row: Index, col: Index| -> u32 { pairs.partition_point(|&p| p < (row, col)) as u32 };
640        for e in &mut elements {
641            if e.dense {
642                let mut map = Vec::with_capacity(e.k() * (e.k() + 1) / 2);
643                for a in 0..e.k() {
644                    for c in 0..=a {
645                        // The full diagonal is always assembled, so a
646                        // diagonal position maps even when the declared
647                        // pattern excludes it (the BFGS convexification
648                        // shift writes there).
649                        let ok =
650                            a == c || e.allowed.as_ref().is_none_or(|m| m[a * (a + 1) / 2 + c]);
651                        map.push(if ok {
652                            find(e.support[a], e.support[c])
653                        } else {
654                            u32::MAX
655                        });
656                    }
657                }
658                e.map = map;
659            } else {
660                e.map = (0..e.k())
661                    .map(|a| find(e.support[a], e.support[a]))
662                    .collect();
663            }
664        }
665        self.diag_pos = (0..n).map(|i| find(i as Index, i as Index)).collect();
666
667        let mut covered = vec![false; n];
668        for e in &elements {
669            for &i in &e.support {
670                covered[i as usize] = true;
671            }
672        }
673        self.uncovered = (0..n)
674            .filter(|&i| !covered[i])
675            .map(|i| i as Index)
676            .collect();
677
678        // 1-based triplets, lower triangle, matching the exact-Hessian
679        // path's convention (`orig_ipopt_nlp.rs` pushes `i_var + 1`).
680        let irows: Vec<Index> = pairs.iter().map(|&(r, _)| r + 1).collect();
681        let jcols: Vec<Index> = pairs.iter().map(|&(_, c)| c + 1).collect();
682
683        self.stats = PartitionStats {
684            elements: elements.len(),
685            dense_elements: elements.iter().filter(|e| e.dense).count(),
686            diagonal_elements: elements.iter().filter(|e| !e.dense).count(),
687            max_support: elements.iter().map(|e| e.k()).max().unwrap_or(0),
688            total_support: elements.iter().map(|e| e.k()).sum(),
689            assembled_nnz: pairs.len(),
690            stored_reals: elements.iter().map(|e| e.b.len()).sum(),
691        };
692        self.space = Some(SymTMatrixSpace::new(n as Index, irows, jcols));
693        self.elements = elements;
694
695        if std::env::var("POUNCE_PARTITIONED_DEBUG").is_ok() {
696            eprintln!("partitioned-qn: {:?}", self.stats);
697        }
698    }
699
700    /// Push the element for `(source, row, support, entries)`, split along the
701    /// declared Hessian pattern when one is available (gh#989 item 5).
702    ///
703    /// `∇²c_j` lives in `support × support`, and the declared Lagrangian
704    /// pattern is a superset of it, so the connected components of the pattern
705    /// restricted to `support` are independent blocks of `∇²c_j`:
706    /// `y_comp = B_comp s_comp` holds exactly, one element per component is
707    /// the same model without the spurious cross-component entries a single
708    /// dense `k × k` block fills in, and a support coordinate the pattern never
709    /// couples (a linear one) belongs to no component and is dropped.
710    /// `entries` are `(Jacobian triplet position, local index)`; they are
711    /// remapped to each component's own local index.
712    fn push_split(
713        &self,
714        elements: &mut Vec<Element>,
715        adjacency: Option<&std::collections::HashMap<Index, Vec<Index>>>,
716        source: ElementSource,
717        row: u32,
718        support: Vec<Index>,
719        entries: Vec<(u32, u32)>,
720    ) {
721        let Some(adj) = adjacency else {
722            elements.push(Self::make_element(
723                source,
724                row,
725                support,
726                entries,
727                self.max_element,
728            ));
729            return;
730        };
731        let k = support.len();
732        // Union-find over local indices, joined by pattern pairs inside the
733        // support; `mentioned[a]` marks a coordinate with any in-support entry.
734        let mut parent: Vec<usize> = (0..k).collect();
735        fn find(parent: &mut [usize], mut a: usize) -> usize {
736            while parent[a] != a {
737                parent[a] = parent[parent[a]];
738                a = parent[a];
739            }
740            a
741        }
742        let mut mentioned = vec![false; k];
743        for a in 0..k {
744            if let Some(nbrs) = adj.get(&support[a]) {
745                for &c in nbrs {
746                    if let Ok(b) = support.binary_search(&c) {
747                        mentioned[a] = true;
748                        mentioned[b] = true;
749                        let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
750                        if ra != rb {
751                            parent[ra.max(rb)] = ra.min(rb);
752                        }
753                    }
754                }
755            }
756        }
757        // Group by root, in ascending order of the first member.
758        let mut group_of: Vec<Option<usize>> = vec![None; k];
759        let mut groups: Vec<Vec<usize>> = Vec::new();
760        let mut root_to_group: std::collections::HashMap<usize, usize> = Default::default();
761        for a in 0..k {
762            if !mentioned[a] {
763                continue;
764            }
765            let r = find(&mut parent, a);
766            let g = *root_to_group.entry(r).or_insert_with(|| {
767                groups.push(Vec::new());
768                groups.len() - 1
769            });
770            groups[g].push(a);
771            group_of[a] = Some(g);
772        }
773        let mut local_in_group = vec![0u32; k];
774        for g in &groups {
775            for (pos, &a) in g.iter().enumerate() {
776                local_in_group[a] = pos as u32;
777            }
778        }
779        let mut group_entries: Vec<Vec<(u32, u32)>> = vec![Vec::new(); groups.len()];
780        for &(p, local) in &entries {
781            if let Some(g) = group_of[local as usize] {
782                group_entries[g].push((p, local_in_group[local as usize]));
783            }
784        }
785        for (g, members) in groups.iter().enumerate() {
786            let sub: Vec<Index> = members.iter().map(|&a| support[a]).collect();
787            let ent = std::mem::take(&mut group_entries[g]);
788            let mut el = Self::make_element(source, row, sub, ent, self.max_element);
789            if el.dense {
790                // The declared pattern restricted to this group. A connected
791                // group is usually NOT complete — a collocation row's group
792                // is a star (the control coupled to each state, no
793                // state–state entries) — and a dense secant update fills the
794                // missing entries with values the true `∇²c_j` does not have.
795                el.allowed = pattern_mask(&el.support, adj);
796            }
797            elements.push(el);
798        }
799    }
800
801    fn make_element(
802        source: ElementSource,
803        row: u32,
804        support: Vec<Index>,
805        entries: Vec<(u32, u32)>,
806        max_element: usize,
807    ) -> Element {
808        let k = support.len();
809        let dense = k <= max_element;
810        let b_len = if dense { k * (k + 1) / 2 } else { k };
811        Element {
812            source,
813            row,
814            support,
815            dense,
816            b: vec![0.0; b_len],
817            prev_g: vec![0.0; k],
818            has_prev: false,
819            seeded: false,
820            entries,
821            map: Vec::new(),
822            allowed: None,
823        }
824    }
825}
826
827/// The declared pattern restricted to `support`, packed like an element's
828/// `b` (`(a, c)`, `a >= c`, at `a(a+1)/2 + c`). `None` when the restriction
829/// is complete, so the dense update formulas apply unchanged.
830fn pattern_mask(
831    support: &[Index],
832    adj: &std::collections::HashMap<Index, Vec<Index>>,
833) -> Option<Vec<bool>> {
834    let k = support.len();
835    let mut allowed = vec![false; k * (k + 1) / 2];
836    let mut complete = true;
837    for a in 0..k {
838        let nbrs = adj.get(&support[a]);
839        for c in 0..=a {
840            let ok = nbrs.is_some_and(|v| v.contains(&support[c]));
841            allowed[a * (a + 1) / 2 + c] = ok;
842            complete &= ok;
843        }
844    }
845    if complete { None } else { Some(allowed) }
846}
847
848#[derive(Debug, Clone, Copy, Default)]
849struct DebugPeaks {
850    ratio: Number,
851    ratio_s: Number,
852    ratio_y: Number,
853    ratio_k: usize,
854    delta: Number,
855    step_norm: Number,
856}
857
858/// `out = B s` for a packed lower triangle.
859fn packed_mult(b: &[Number], s: &[Number], out: &mut [Number]) {
860    out.iter_mut().for_each(|v| *v = 0.0);
861    let mut p = 0usize;
862    for a in 0..s.len() {
863        for c in 0..=a {
864            let v = b[p];
865            p += 1;
866            if v == 0.0 {
867                continue;
868            }
869            out[a] += v * s[c];
870            if c != a {
871                out[c] += v * s[a];
872            }
873        }
874    }
875}
876
877fn dot(a: &[Number], b: &[Number]) -> Number {
878    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
879}
880
881/// Pattern-constrained symmetric secant update (Toint, *Math. Comp.* 31,
882/// 1977; the sparse analogue of PSB). Finds the correction `E` of least
883/// Frobenius norm that is symmetric, vanishes outside `allowed`, and makes
884/// `(B + E) s = y`. Writing `E_ac = λ_a s_c + λ_c s_a` on the allowed
885/// positions, the secant condition is the `k × k` system
886///
887/// ```text
888///   Q λ = r,   r = y − B s,
889///   Q_aa = Σ_{c ∈ S_a} s_c² + [a ∈ S_a] s_a²,   Q_ac = s_a s_c  (c ∈ S_a, c ≠ a)
890/// ```
891///
892/// `Q` is positive semidefinite; it is solved in the minimum-norm least
893/// squares sense, so a row with no step component (`Q_aa = 0`) takes no
894/// correction instead of a division by zero.
895///
896/// Why this and not SR1 on these elements (gh#989 item 5): a collocation
897/// row's declared group is a *star* — the control coupled to each state,
898/// no state–state entries — so the true `∇²c_j` has `k` free entries on a
899/// `k`-coordinate support and one secant pair (`k` equations) determines
900/// it. The rank-1 term instead writes `w wᵀ/wᵀs` into all `k(k+1)/2`
901/// positions; those spurious entries, multiplied by a multiplier of either
902/// sign, are what gave the assembled `W` the wrong inertia on the batch
903/// reactor and the IPM a `δ_w` that decayed by `1/3` per iteration.
904///
905/// Returns `true` when a correction was applied.
906fn sparse_secant_update(
907    b: &mut [Number],
908    allowed: &[bool],
909    s: &[Number],
910    y: &[Number],
911    max_delta: Number,
912) -> bool {
913    let k = s.len();
914    let mut bs = vec![0.0; k];
915    packed_mult(b, s, &mut bs);
916    let r: Vec<Number> = y.iter().zip(bs.iter()).map(|(a, c)| a - c).collect();
917    let r_norm = dot(&r, &r).sqrt();
918    let y_norm = dot(y, y).sqrt();
919    if !r_norm.is_finite() || r_norm <= 1e-14 * y_norm.max(Number::MIN_POSITIVE) || r_norm == 0.0 {
920        return false;
921    }
922    let mut q = vec![0.0; k * k];
923    for a in 0..k {
924        for c in 0..=a {
925            if !allowed[a * (a + 1) / 2 + c] {
926                continue;
927            }
928            if a == c {
929                q[a * k + a] += 2.0 * s[a] * s[a];
930            } else {
931                q[a * k + a] += s[c] * s[c];
932                q[c * k + c] += s[a] * s[a];
933                q[a * k + c] += s[a] * s[c];
934                q[c * k + a] += s[a] * s[c];
935            }
936        }
937    }
938    let Some(lam) = min_norm_solve(&mut q, &r, k) else {
939        return false;
940    };
941    let mut e = vec![0.0; b.len()];
942    let mut e_max = 0.0_f64;
943    for a in 0..k {
944        for c in 0..=a {
945            let p = a * (a + 1) / 2 + c;
946            if allowed[p] {
947                let v = lam[a] * s[c] + lam[c] * s[a];
948                e[p] = v;
949                e_max = e_max.max(v.abs());
950            }
951        }
952    }
953    if !e_max.is_finite() || e_max > max_delta {
954        return false;
955    }
956    for (bv, ev) in b.iter_mut().zip(e.iter()) {
957        *bv += ev;
958    }
959    true
960}
961
962/// Minimum-norm solution of `Q x = r` for a small symmetric positive
963/// semidefinite `Q` (row-major, overwritten), by cyclic Jacobi
964/// eigendecomposition with eigenvalues below `1e-12 · λ_max` treated as
965/// zero. `None` only when `Q` is entirely zero or non-finite.
966fn min_norm_solve(q: &mut [Number], r: &[Number], k: usize) -> Option<Vec<Number>> {
967    let mut v = vec![0.0; k * k];
968    for i in 0..k {
969        v[i * k + i] = 1.0;
970    }
971    for _sweep in 0..60 {
972        let mut off = 0.0;
973        let mut diag = 0.0;
974        for i in 0..k {
975            diag += q[i * k + i] * q[i * k + i];
976            for j in 0..i {
977                off += q[i * k + j] * q[i * k + j];
978            }
979        }
980        if !(off.is_finite() && diag.is_finite()) {
981            return None;
982        }
983        if off <= 1e-30 * diag {
984            break;
985        }
986        for p in 0..k {
987            for qq in (p + 1)..k {
988                let apq = q[p * k + qq];
989                if apq == 0.0 {
990                    continue;
991                }
992                let app = q[p * k + p];
993                let aqq = q[qq * k + qq];
994                let theta = (aqq - app) / (2.0 * apq);
995                let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
996                let t = if theta == 0.0 { 1.0 } else { t };
997                let c = 1.0 / (t * t + 1.0).sqrt();
998                let sn = t * c;
999                for i in 0..k {
1000                    let aip = q[i * k + p];
1001                    let aiq = q[i * k + qq];
1002                    q[i * k + p] = c * aip - sn * aiq;
1003                    q[i * k + qq] = sn * aip + c * aiq;
1004                }
1005                for i in 0..k {
1006                    let api = q[p * k + i];
1007                    let aqi = q[qq * k + i];
1008                    q[p * k + i] = c * api - sn * aqi;
1009                    q[qq * k + i] = sn * api + c * aqi;
1010                }
1011                for i in 0..k {
1012                    let vip = v[i * k + p];
1013                    let viq = v[i * k + qq];
1014                    v[i * k + p] = c * vip - sn * viq;
1015                    v[i * k + qq] = sn * vip + c * viq;
1016                }
1017            }
1018        }
1019    }
1020    let lmax = (0..k).map(|i| q[i * k + i].abs()).fold(0.0_f64, f64::max);
1021    if lmax <= 0.0 || !lmax.is_finite() {
1022        return None;
1023    }
1024    let mut x = vec![0.0; k];
1025    for j in 0..k {
1026        let lj = q[j * k + j];
1027        if lj.abs() <= 1e-12 * lmax {
1028            continue;
1029        }
1030        let coef: Number = (0..k).map(|i| v[i * k + j] * r[i]).sum::<Number>() / lj;
1031        for i in 0..k {
1032            x[i] += coef * v[i * k + j];
1033        }
1034    }
1035    Some(x)
1036}
1037
1038/// Apply one curvature pair to a single element. Returns `true` when the
1039/// update was accepted.
1040fn update_element(
1041    e: &mut Element,
1042    s: &[Number],
1043    y: &[Number],
1044    update_type: UpdateType,
1045    init_val_min: Number,
1046    init_val_max: Number,
1047    curvature_cap: Number,
1048) -> bool {
1049    let sts = dot(s, s);
1050    if !(sts > 0.0) || !sts.is_finite() {
1051        return false;
1052    }
1053    let sty = dot(s, y);
1054    if !sty.is_finite() {
1055        return false;
1056    }
1057    // The curvature this pair actually implies, and the ceiling any
1058    // single update to this block is allowed to reach.
1059    let s_norm = sts.sqrt();
1060    let implied = dot(y, y).sqrt() / s_norm;
1061    let max_delta = curvature_cap * implied;
1062
1063    // Pattern-constrained element: no scalar seeding (a `γI` start writes
1064    // curvature onto diagonal entries the model declares structurally zero)
1065    // and no rank-1 formula (which fills every excluded entry). The sparse
1066    // secant update starts from `B = 0` and keeps the pattern exactly.
1067    if let Some(allowed) = e.allowed.as_ref() {
1068        let ok = sparse_secant_update(&mut e.b, allowed, s, y, max_delta);
1069        e.seeded |= ok;
1070        return ok;
1071    }
1072
1073    // One-time scalar seeding: `B_e ← γ I` with γ the `scalar1` ratio of
1074    // this element's own first pair, so the block starts at the right
1075    // order of magnitude instead of at zero. BFGS needs γ > 0 to have a
1076    // positive-definite base; SR1 takes either sign, which is the point
1077    // of using it here.
1078    if !e.seeded {
1079        let mut gamma = sty / sts;
1080        if !gamma.is_finite() || gamma == 0.0 {
1081            gamma = if update_type == UpdateType::Bfgs {
1082                1.0
1083            } else {
1084                0.0
1085            };
1086        }
1087        if update_type == UpdateType::Bfgs && gamma <= 0.0 {
1088            gamma = 1.0;
1089        }
1090        if gamma != 0.0 {
1091            let mag = gamma.abs().clamp(init_val_min, init_val_max);
1092            gamma = gamma.signum() * mag;
1093        }
1094        if e.dense {
1095            for a in 0..e.k() {
1096                e.b[a * (a + 1) / 2 + a] = gamma;
1097            }
1098        } else {
1099            e.b.iter_mut().for_each(|v| *v = gamma);
1100        }
1101        e.seeded = true;
1102    }
1103
1104    if !e.dense {
1105        // Diagonal element: Dennis-Wolkowicz weak secant update, the
1106        // minimum-Frobenius diagonal correction satisfying `sᵀBs = sᵀy`.
1107        let s_bs: Number = (0..e.k()).map(|a| e.b[a] * s[a] * s[a]).sum();
1108        let denom: Number = s.iter().map(|v| v * v * v * v).sum();
1109        if !(denom > 0.0) || !denom.is_finite() {
1110            return false;
1111        }
1112        let scale = (sty - s_bs) / denom;
1113        if !scale.is_finite() {
1114            return false;
1115        }
1116        for a in 0..e.k() {
1117            e.b[a] += scale * s[a] * s[a];
1118        }
1119        return true;
1120    }
1121
1122    let mut bs = vec![0.0; e.k()];
1123    packed_mult(&e.b, s, &mut bs);
1124
1125    match update_type {
1126        UpdateType::Sr1 => {
1127            // w = y − Bs;  B += w wᵀ / (wᵀ s)
1128            let w: Vec<Number> = y.iter().zip(bs.iter()).map(|(a, b)| a - b).collect();
1129            let den = dot(&w, s);
1130            let w_norm = dot(&w, &w).sqrt();
1131            let s_norm = sts.sqrt();
1132            // `<=`, not `<`. When the element's model already reproduces
1133            // its own curvature — a linear constraint, whose `y` is
1134            // identically zero, or a block the previous pair already
1135            // matched — `w` is exactly zero and both sides are zero. A
1136            // strict comparison lets that through and the rank-1 term
1137            // divides 0 by 0, publishing a NaN Hessian; the IPM then
1138            // reports a converged-looking restoration failure rather than
1139            // anything that names the cause. A linear element is the
1140            // common case, not a corner: every linear constraint row in
1141            // the model hits this on its first pair.
1142            if !den.is_finite() || w_norm == 0.0 || den.abs() <= SR1_SAFEGUARD * s_norm * w_norm {
1143                return false;
1144            }
1145            // `‖w wᵀ/den‖_max = max|w|² / |den|`; reject before writing.
1146            let w_max = w.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1147            if w_max * w_max / den.abs() > max_delta {
1148                return false;
1149            }
1150            let mut p = 0usize;
1151            for a in 0..e.k() {
1152                for c in 0..=a {
1153                    e.b[p] += w[a] * w[c] / den;
1154                    p += 1;
1155                }
1156            }
1157            true
1158        }
1159        UpdateType::Bfgs => {
1160            // Powell-damped BFGS on the element block.
1161            let s_bs = dot(s, &bs);
1162            let bs_norm = dot(&bs, &bs).sqrt();
1163            if !(s_bs > 0.0) || !s_bs.is_finite() || s_bs <= BFGS_DENOM_FLOOR * s_norm * bs_norm {
1164                return false;
1165            }
1166            let theta = if sty >= POWELL_THETA * s_bs {
1167                1.0
1168            } else {
1169                (1.0 - POWELL_THETA) * s_bs / (s_bs - sty)
1170            };
1171            if !theta.is_finite() {
1172                return false;
1173            }
1174            let r: Vec<Number> = y
1175                .iter()
1176                .zip(bs.iter())
1177                .map(|(yy, bb)| theta * yy + (1.0 - theta) * bb)
1178                .collect();
1179            let sr = dot(s, &r);
1180            let r_norm = dot(&r, &r).sqrt();
1181            if !(sr > 0.0) || !sr.is_finite() || sr <= BFGS_DENOM_FLOOR * s_norm * r_norm {
1182                return false;
1183            }
1184            let r_max = r.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1185            let bs_max = bs.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1186            if r_max * r_max / sr + bs_max * bs_max / s_bs > max_delta {
1187                return false;
1188            }
1189            let mut p = 0usize;
1190            for a in 0..e.k() {
1191                for c in 0..=a {
1192                    e.b[p] += r[a] * r[c] / sr - bs[a] * bs[c] / s_bs;
1193                    p += 1;
1194                }
1195            }
1196            true
1197        }
1198    }
1199}
1200
1201/// The update an element actually takes under the requested
1202/// `partitioned_update_type` (gh#989 item 5, remaining half).
1203///
1204/// Under `bfgs`, a **constraint** element (`EqRow` / `IneqRow`) takes SR1
1205/// anyway. Its target `∇²c_j` has no sign structure, and the element is
1206/// weighted by a multiplier of either sign, so Powell-damped BFGS — which
1207/// forces every block positive definite — assembles a `W` whose
1208/// constraint curvature carries the wrong sign wherever `y_j·∇²c_j` is
1209/// negative definite. On the Radau batch reactor with no declared Hessian
1210/// pattern (`partitioned_structure=jacobian`, or a Python problem without
1211/// `hessian`) that hit `max_iter` at every mesh size. Damped BFGS is sound
1212/// only where the target is the curvature of a function the method may
1213/// treat as convex: the objective element, and the `blocks` mode's
1214/// Lagrangian restrictions (Asprion et al.'s design). Elements with a
1215/// declared incomplete pattern take the sparse secant update either way.
1216fn element_update_type(requested: UpdateType, source: ElementSource) -> UpdateType {
1217    match (requested, source) {
1218        (UpdateType::Bfgs, ElementSource::EqRow | ElementSource::IneqRow) => UpdateType::Sr1,
1219        (t, _) => t,
1220    }
1221}
1222
1223impl HessianUpdater for PartitionedQuasiNewtonUpdater {
1224    fn update_hessian(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) -> bool {
1225        let (curr_x, curr_y_c, curr_y_d) = match data.borrow().curr.as_ref() {
1226            Some(c) => (c.x.clone(), c.y_c.clone(), c.y_d.clone()),
1227            None => return true,
1228        };
1229        let curr_grad_f = cq.borrow().curr_grad_f();
1230        let curr_jac_c = cq.borrow().curr_jac_c();
1231        let curr_jac_d = cq.borrow().curr_jac_d();
1232
1233        let (Some(jac_c), Some(jac_d)) = (
1234            curr_jac_c.as_any().downcast_ref::<GenTMatrix>(),
1235            curr_jac_d.as_any().downcast_ref::<GenTMatrix>(),
1236        ) else {
1237            // Not the plain NLP path (the restoration sub-IPM carries a
1238            // different Jacobian shape). The builder downgrades
1239            // restoration to the limited-memory updater, so this is a
1240            // guard, not a live branch.
1241            return false;
1242        };
1243
1244        let x = flat(&*curr_x);
1245        let grad_f = flat(&*curr_grad_f);
1246        let n = x.len();
1247
1248        if self.space.is_none() {
1249            self.build_structure(n, &grad_f, jac_c, jac_d);
1250        }
1251        let y_c_now = flat(&*curr_y_c);
1252        let y_d_now = flat(&*curr_y_d);
1253
1254        // ---- curvature pairs, one per element -----------------------
1255        //
1256        // `s` is shared (the primal step); each element gets its own `y`
1257        // from its own gradient's change, so no element's pair is
1258        // contaminated by another's curvature — the property the
1259        // monolithic L-BFGS `y` cannot have.
1260        let s_full: Option<Vec<Number>> = self
1261            .prev_x
1262            .as_ref()
1263            .map(|p| x.iter().zip(p.iter()).map(|(a, b)| a - b).collect());
1264
1265        // In `PrimalBlock` mode every element reads the same vector: the
1266        // Lagrangian gradient's *change*. It is formed once here, using
1267        // upstream's convention that BOTH Jacobians are dotted against the
1268        // CURRENT multipliers, so `y` is the difference of one fixed
1269        // function rather than of two different Lagrangians
1270        // (`IpLimMemQuasiNewtonUpdater.cpp:284-308`, and the same reasoning
1271        // the limited-memory updater records).
1272        let lagrangian_dy: Option<Vec<Number>> = if self.mode == ElementMode::PrimalBlock {
1273            match (
1274                self.prev_grad_f.as_ref(),
1275                self.prev_jac_c.as_ref(),
1276                self.prev_jac_d.as_ref(),
1277            ) {
1278                (Some(pg), Some(pc), Some(pd)) => {
1279                    let mut dy = vec![0.0; n];
1280                    for i in 0..n {
1281                        dy[i] = grad_f[i] - pg[i];
1282                    }
1283                    for (jac, prev, mult) in [(jac_c, pc, &y_c_now), (jac_d, pd, &y_d_now)] {
1284                        let (ir, jc, cur) = (jac.irows(), jac.jcols(), jac.values());
1285                        for k in 0..ir.len() {
1286                            let row = (ir[k] - 1) as usize;
1287                            let col = (jc[k] - 1) as usize;
1288                            dy[col] += (cur[k] - prev[k]) * mult[row];
1289                        }
1290                    }
1291                    Some(dy)
1292                }
1293                _ => None,
1294            }
1295        } else {
1296            None
1297        };
1298
1299        let oracle = std::env::var("POUNCE_PARTITIONED_ORACLE").is_ok();
1300        if oracle {
1301            self.dbg = DebugPeaks {
1302                step_norm: s_full.as_ref().map(|v| dot(v, v).sqrt()).unwrap_or(0.0),
1303                ..DebugPeaks::default()
1304            };
1305        }
1306        let mut s_loc: Vec<Number> = Vec::new();
1307        let mut y_loc: Vec<Number> = Vec::new();
1308        let mut g_loc: Vec<Number> = Vec::new();
1309        for e in &mut self.elements {
1310            let k = e.k();
1311            g_loc.clear();
1312            g_loc.resize(k, 0.0);
1313            match e.source {
1314                ElementSource::Objective => {
1315                    for (a, &i) in e.support.iter().enumerate() {
1316                        g_loc[a] = grad_f[i as usize];
1317                    }
1318                }
1319                ElementSource::EqRow => {
1320                    let v = jac_c.values();
1321                    for &(pos, local) in &e.entries {
1322                        g_loc[local as usize] += v[pos as usize];
1323                    }
1324                }
1325                ElementSource::IneqRow => {
1326                    let v = jac_d.values();
1327                    for &(pos, local) in &e.entries {
1328                        g_loc[local as usize] += v[pos as usize];
1329                    }
1330                }
1331                ElementSource::LagrangianBlock => {}
1332            }
1333
1334            let pair_ready = match e.source {
1335                // The block's `y` comes from the shared Lagrangian
1336                // difference, not from a stored per-element gradient.
1337                ElementSource::LagrangianBlock => lagrangian_dy.is_some(),
1338                _ => e.has_prev,
1339            };
1340            if let (Some(s_full), true) = (s_full.as_ref(), pair_ready) {
1341                s_loc.clear();
1342                y_loc.clear();
1343                for (a, &i) in e.support.iter().enumerate() {
1344                    s_loc.push(s_full[i as usize]);
1345                    y_loc.push(match e.source {
1346                        ElementSource::LagrangianBlock => {
1347                            lagrangian_dy.as_ref().expect("checked above")[i as usize]
1348                        }
1349                        _ => g_loc[a] - e.prev_g[a],
1350                    });
1351                }
1352                let before = e.b.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1353                let element_update = element_update_type(self.update_type, e.source);
1354                if update_element(
1355                    e,
1356                    &s_loc,
1357                    &y_loc,
1358                    element_update,
1359                    self.init_val_min,
1360                    self.init_val_max,
1361                    self.curvature_cap,
1362                ) {
1363                    self.accepted_updates += 1;
1364                } else {
1365                    self.skipped_updates += 1;
1366                }
1367                if oracle {
1368                    let sn = dot(&s_loc, &s_loc).sqrt();
1369                    let yn = dot(&y_loc, &y_loc).sqrt();
1370                    let r = if sn > 0.0 { yn / sn } else { 0.0 };
1371                    if r > self.dbg.ratio {
1372                        self.dbg = DebugPeaks {
1373                            ratio: r,
1374                            ratio_s: sn,
1375                            ratio_y: yn,
1376                            ratio_k: e.k(),
1377                            ..self.dbg
1378                        };
1379                    }
1380                    let after = e.b.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
1381                    self.dbg.delta = self.dbg.delta.max((after - before).abs());
1382                }
1383            }
1384
1385            e.prev_g.copy_from_slice(&g_loc);
1386            e.has_prev = true;
1387        }
1388        self.prev_x = Some(x);
1389        if self.mode == ElementMode::PrimalBlock {
1390            self.prev_grad_f = Some(grad_f.clone());
1391            self.prev_jac_c = Some(jac_c.values().to_vec());
1392            self.prev_jac_d = Some(jac_d.values().to_vec());
1393        }
1394
1395        // ---- assemble ------------------------------------------------
1396        //
1397        //   W = ∇²f + Σ_j (y_c)_j ∇²c_j + Σ_j (y_d)_j ∇²d_j
1398        //
1399        // with `obj_factor = 1`, matching
1400        // `IpoptCalculatedQuantities::curr_exact_hessian`.
1401        let y_c = flat(&*curr_y_c);
1402        let y_d = flat(&*curr_y_d);
1403        let space = Rc::clone(self.space.as_ref().expect("structure built above"));
1404        let mut w = SymTMatrix::new(Rc::clone(&space));
1405        // Before any element has taken a pair there is no curvature
1406        // information anywhere, and `init_val · I` is the same opening
1407        // model the limited-memory path's empty-history branch uses.
1408        // Publishing the honest all-zero `W` instead hands the first KKT
1409        // solve a `(1,1)` block with no curvature at all and lets the
1410        // inertia correction invent the scale.
1411        let any_seeded = self.elements.iter().any(|e| e.seeded);
1412        {
1413            let vals = w.values_mut();
1414            vals.iter_mut().for_each(|v| *v = 0.0);
1415            if !any_seeded {
1416                for &p in &self.diag_pos {
1417                    vals[p as usize] = self.init_val;
1418                }
1419            }
1420            for e in self.elements.iter().filter(|_| any_seeded) {
1421                let weight = match e.source {
1422                    ElementSource::Objective => 1.0,
1423                    ElementSource::EqRow => y_c[e.row as usize],
1424                    ElementSource::IneqRow => y_d[e.row as usize],
1425                    // The multiplier is already inside the modelled
1426                    // function; weighting again would square it.
1427                    ElementSource::LagrangianBlock => 1.0,
1428                };
1429                if weight == 0.0 || !weight.is_finite() {
1430                    continue;
1431                }
1432                for (p, &m) in e.map.iter().enumerate() {
1433                    if m != u32::MAX {
1434                        vals[m as usize] += weight * e.b[p];
1435                    }
1436                }
1437            }
1438            // Coordinates no element covers get the same nonzero floor
1439            // the masked limited-memory diagonal uses, for the same
1440            // reason: a structurally empty `(1,1)` row is carried by
1441            // `Σ_x` alone and costs the factorization a near-singular
1442            // pivot on every one of them.
1443            if any_seeded {
1444                for &i in &self.uncovered {
1445                    vals[self.diag_pos[i as usize] as usize] = self.init_val_min;
1446                }
1447            }
1448        }
1449        // Direct correctness oracle (`POUNCE_PARTITIONED_ORACLE`). On a
1450        // model that *does* supply second derivatives — every `.nl`, for
1451        // instance — the exact Lagrangian Hessian at this very iterate,
1452        // with this very `obj_factor` and these very multipliers, is one
1453        // call away. Comparing against it is the only check in this
1454        // module that reads a number the updater did not produce: the
1455        // unit tests pin the update formulas against themselves, and a
1456        // self-consistently wrong assembly would pass all of them.
1457        //
1458        // Never on by default: it evaluates `eval_h` every iteration,
1459        // which is precisely the cost the updater exists to avoid.
1460        if std::env::var("POUNCE_PARTITIONED_ORACLE").is_ok() {
1461            let exact = cq.borrow().curr_exact_hessian();
1462            if let Some(t) = exact.as_any().downcast_ref::<SymTMatrix>() {
1463                // Feasibility census for a sparse finite-difference
1464                // Hessian: the number of directional derivatives such a
1465                // scheme needs is set by the coloring of this pattern,
1466                // and `rho_max` (the widest symmetric row) is its
1467                // practical lower bound. Printed once.
1468                if std::env::var("POUNCE_HESS_PATTERN_CENSUS").is_ok() && !self.census_done {
1469                    self.census_done = true;
1470                    let n_h = t.space().dim() as usize;
1471                    let mut deg = vec![0usize; n_h];
1472                    for (&i, &j) in t.irows().iter().zip(t.jcols().iter()) {
1473                        let (a, b) = ((i - 1) as usize, (j - 1) as usize);
1474                        deg[a] += 1;
1475                        if a != b {
1476                            deg[b] += 1;
1477                        }
1478                    }
1479                    let rho_max = deg.iter().copied().max().unwrap_or(0);
1480                    let mean = deg.iter().sum::<usize>() as f64 / n_h as f64;
1481                    let mut hist = [0usize; 8];
1482                    for &d in &deg {
1483                        let b = (d.saturating_sub(1) / 8).min(7);
1484                        hist[b] += 1;
1485                    }
1486                    eprintln!(
1487                        "hess-pattern: n={n_h} nnz={} rho_max={rho_max} mean_row={mean:.2} \
1488                         hist(1-8,9-16,...)={hist:?}",
1489                        t.nonzeros()
1490                    );
1491                }
1492                use std::collections::HashMap;
1493                let mut mine: HashMap<(Index, Index), Number> = HashMap::new();
1494                for ((&i, &j), &v) in space
1495                    .irows()
1496                    .iter()
1497                    .zip(space.jcols().iter())
1498                    .zip(w.values().iter())
1499                {
1500                    *mine.entry((i, j)).or_insert(0.0) += v;
1501                }
1502                let (mut max_exact, mut max_err) = (0.0_f64, 0.0_f64);
1503                let (mut num, mut den) = (0.0_f64, 0.0_f64);
1504                // Frobenius mass of the exact Hessian that falls INSIDE
1505                // this updater's pattern. For `ElementMode::PrimalBlock`
1506                // this is the direct test of the variable-ordering
1507                // assumption: a transcription that orders by stage puts
1508                // nearly all of it inside, one that does not shows a low
1509                // fraction and the partition is wrong for that model.
1510                let mut captured = 0.0_f64;
1511                let mut worst = ((0, 0), 0.0, 0.0);
1512                let mut seen: HashMap<(Index, Index), bool> = HashMap::new();
1513                for ((&i, &j), &v) in t
1514                    .irows()
1515                    .iter()
1516                    .zip(t.jcols().iter())
1517                    .zip(t.values().iter())
1518                {
1519                    seen.insert((i, j), true);
1520                    let m = mine.get(&(i, j)).copied().unwrap_or(0.0);
1521                    if mine.contains_key(&(i, j)) {
1522                        captured += v * v;
1523                    }
1524                    let e = (m - v).abs();
1525                    max_exact = max_exact.max(v.abs());
1526
1527                    if e > max_err {
1528                        max_err = e;
1529                        worst = ((i, j), v, m);
1530                    }
1531                    num += e * e;
1532                    den += v * v;
1533                }
1534                // Entries this updater carries that the true Hessian does
1535                // not: the per-constraint pattern is `supp ⊗ supp`, an
1536                // over-estimate, and those entries must be ~0.
1537                let mut extra = 0.0_f64;
1538                for (&k, &v) in mine.iter() {
1539                    if !seen.contains_key(&k) {
1540                        extra = extra.max(v.abs());
1541                    }
1542                }
1543                eprintln!(
1544                    "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}",
1545                    (num / den.max(1e-300)).sqrt(),
1546                    max_err,
1547                    max_exact,
1548                    worst.0,
1549                    worst.1,
1550                    worst.2,
1551                    extra,
1552                    (captured / den.max(1e-300)).sqrt()
1553                );
1554                eprintln!(
1555                    "  peaks: max|y_e|/|s_e|={:.3e} (|s_e|={:.3e} |y_e|={:.3e} k={}) \
1556                     max_block_delta={:.3e} |s|={:.3e} accepted={} skipped={}",
1557                    self.dbg.ratio,
1558                    self.dbg.ratio_s,
1559                    self.dbg.ratio_y,
1560                    self.dbg.ratio_k,
1561                    self.dbg.delta,
1562                    self.dbg.step_norm,
1563                    self.accepted_updates,
1564                    self.skipped_updates
1565                );
1566            }
1567        }
1568        if std::env::var("POUNCE_PARTITIONED_DUMP").is_ok() && space.nonzeros() <= 32 {
1569            eprintln!(
1570                "partitioned-qn W: seeded={any_seeded} irows={:?} jcols={:?} vals={:?}",
1571                space.irows(),
1572                space.jcols(),
1573                w.values()
1574            );
1575            eprintln!("  y_c={y_c:?} y_d={y_d:?}");
1576        }
1577        data.borrow_mut().w = Some(Rc::new(w));
1578        true
1579    }
1580}
1581
1582/// Read a primal-space vector's values as a flat slice. Mirrors
1583/// `lim_mem_quasi_newton::expanded_of`; kept local so the two updaters do
1584/// not share mutable helper state.
1585fn flat(v: &dyn Vector) -> Vec<Number> {
1586    if let Some(dv) = v.as_any().downcast_ref::<DenseVector>() {
1587        return dv.expanded_values();
1588    }
1589    if let Some(cv) = v.as_any().downcast_ref::<CompoundVector>() {
1590        let mut out = Vec::with_capacity(cv.dim() as usize);
1591        for i in 0..cv.n_comps() {
1592            out.extend(flat(cv.comp(i)));
1593        }
1594        return out;
1595    }
1596    panic!("PartitionedQuasiNewtonUpdater: unsupported primal vector type");
1597}
1598
1599#[cfg(test)]
1600mod tests {
1601    use super::*;
1602
1603    /// gh#989 item 5: a row supported on `{0,1,2,3,4,5,6}` whose declared
1604    /// Hessian couples `(0,1)` and `(2,3)` and carries `(4,4)` alone splits
1605    /// into three elements, and coordinates 5 and 6 (no pattern entry: linear)
1606    /// are dropped. The Jacobian entries follow their coordinates.
1607    #[test]
1608    fn declared_pattern_splits_an_element_into_its_independent_blocks() {
1609        let u = PartitionedQuasiNewtonUpdater::new(UpdateType::Sr1);
1610        let pat: Vec<(Index, Index)> = vec![(1, 0), (3, 2), (4, 4), (9, 9)];
1611        let mut adj: std::collections::HashMap<Index, Vec<Index>> = Default::default();
1612        for &(r, c) in &pat {
1613            adj.entry(r).or_default().push(c);
1614            if r != c {
1615                adj.entry(c).or_default().push(r);
1616            }
1617        }
1618        let mut els = Vec::new();
1619        let entries: Vec<(u32, u32)> = (0..7).map(|i| (10 + i as u32, i as u32)).collect();
1620        u.push_split(
1621            &mut els,
1622            Some(&adj),
1623            ElementSource::EqRow,
1624            3,
1625            (0..7).collect(),
1626            entries,
1627        );
1628        let sup: Vec<Vec<Index>> = els.iter().map(|e| e.support.clone()).collect();
1629        assert_eq!(sup, vec![vec![0, 1], vec![2, 3], vec![4]]);
1630        assert_eq!(els[1].entries, vec![(12, 0), (13, 1)]);
1631        assert!(els.iter().all(|e| e.row == 3));
1632        // Without a declared pattern the row stays one dense element.
1633        let mut one = Vec::new();
1634        u.push_split(
1635            &mut one,
1636            None,
1637            ElementSource::EqRow,
1638            3,
1639            (0..7).collect(),
1640            Vec::new(),
1641        );
1642        assert_eq!(one.len(), 1);
1643        assert_eq!(one[0].support.len(), 7);
1644    }
1645
1646    /// gh#989 item 5: on a *star* pattern — a control `T` coupled to two
1647    /// states, no state–state or state diagonal entries, the shape of every
1648    /// Radau collocation row's group — the pattern-constrained secant update
1649    /// recovers the exact element Hessian from `B = 0` with a single pair,
1650    /// and the excluded entries stay exactly zero. A dense SR1 term from the
1651    /// same pair writes all six entries.
1652    #[test]
1653    fn sparse_secant_recovers_a_star_hessian_from_one_pair() {
1654        // H = [[a, b1, b2], [b1, 0, 0], [b2, 0, 0]], packed lower triangle.
1655        let (a, b1, b2) = (-3.0e-4, 2.0e-3, -7.0e-4);
1656        let h = [a, b1, 0.0, b2, 0.0, 0.0];
1657        let allowed = vec![true, true, false, true, false, false];
1658        let s = vec![0.3, -1.2, 0.8];
1659        let mut y = vec![0.0; 3];
1660        packed_mult(&h, &s, &mut y);
1661        let mut b = vec![0.0; 6];
1662        assert!(sparse_secant_update(
1663            &mut b,
1664            &allowed,
1665            &s,
1666            &y,
1667            Number::INFINITY
1668        ));
1669        for p in 0..6 {
1670            assert!(
1671                (b[p] - h[p]).abs() < 1e-15,
1672                "packed entry {p}: {} vs {}",
1673                b[p],
1674                h[p]
1675            );
1676        }
1677        // Already exact: a second pair along another direction is a no-op.
1678        let s2 = vec![-0.5, 0.1, 0.4];
1679        let mut y2 = vec![0.0; 3];
1680        packed_mult(&h, &s2, &mut y2);
1681        assert!(!sparse_secant_update(
1682            &mut b,
1683            &allowed,
1684            &s2,
1685            &y2,
1686            Number::INFINITY
1687        ));
1688    }
1689
1690    /// The pattern-constrained update satisfies the secant equation and
1691    /// never writes an excluded position, including when the pattern
1692    /// cannot be determined by one pair; a coordinate with no step
1693    /// component takes no correction rather than a division by zero.
1694    #[test]
1695    fn sparse_secant_keeps_the_pattern_and_the_secant_equation() {
1696        // 4 coordinates, allowed: (0,0), (1,0), (2,1), (3,3), (2,2).
1697        let k = 4;
1698        let mut allowed = vec![false; k * (k + 1) / 2];
1699        for (r, c) in [(0, 0), (1, 0), (2, 1), (3, 3), (2, 2)] {
1700            allowed[r * (r + 1) / 2 + c] = true;
1701        }
1702        let mut b = vec![0.0; k * (k + 1) / 2];
1703        b[0] = 1.0;
1704        let s = vec![0.5, -0.25, 1.0, 0.0];
1705        let y = vec![0.1, 0.7, -0.4, 0.0];
1706        assert!(sparse_secant_update(
1707            &mut b,
1708            &allowed,
1709            &s,
1710            &y,
1711            Number::INFINITY
1712        ));
1713        let mut bs = vec![0.0; k];
1714        packed_mult(&b, &s, &mut bs);
1715        for a in 0..k {
1716            assert!(
1717                (bs[a] - y[a]).abs() < 1e-12,
1718                "row {a}: {} vs {}",
1719                bs[a],
1720                y[a]
1721            );
1722        }
1723        for (p, &ok) in allowed.iter().enumerate() {
1724            if !ok {
1725                assert_eq!(b[p], 0.0, "excluded packed entry {p} was written");
1726            }
1727        }
1728        assert_eq!(b[9], 0.0, "(3,3) has no step component and must stay 0");
1729    }
1730
1731    /// `min_norm_solve` returns the minimum-norm solution on a singular
1732    /// PSD system (a rank-one `Q = v vᵀ`), not a blow-up.
1733    #[test]
1734    fn min_norm_solve_handles_a_singular_system() {
1735        let v = [1.0, 2.0, 0.0];
1736        let mut q = vec![0.0; 9];
1737        for i in 0..3 {
1738            for j in 0..3 {
1739                q[i * 3 + j] = v[i] * v[j];
1740            }
1741        }
1742        // r in range(Q): r = Q x* with x* = v/|v|^2 * 5 -> r = 5 v.
1743        let r = [5.0, 10.0, 0.0];
1744        let x = min_norm_solve(&mut q, &r, 3).expect("solvable");
1745        let want = [1.0, 2.0, 0.0];
1746        for i in 0..3 {
1747            assert!(
1748                (x[i] - want[i]).abs() < 1e-12,
1749                "{i}: {} vs {}",
1750                x[i],
1751                want[i]
1752            );
1753        }
1754    }
1755
1756    /// gh#989 item 5: `partitioned_elements=blocks` with a declared pattern
1757    /// takes its blocks from the pattern's connected components, not from
1758    /// contiguous index ranges. Variables 0 and 3 are coupled, as are 1, 2
1759    /// and 5 (a star on 2); variable 4 appears in no entry and is linear.
1760    #[test]
1761    fn blocks_follow_the_declared_components_not_the_index_order() {
1762        let mut u = PartitionedQuasiNewtonUpdater::new(UpdateType::Bfgs);
1763        u.mode = ElementMode::PrimalBlock;
1764        u.block_size = 64;
1765        u.declared_pattern = Some(vec![(3, 0), (2, 1), (2, 2), (5, 2)]);
1766        let empty = |rows| {
1767            GenTMatrix::new(pounce_linalg::triplet::GenTMatrixSpace::new(
1768                rows,
1769                6,
1770                Vec::new(),
1771                Vec::new(),
1772            ))
1773        };
1774        u.build_structure(6, &[0.0; 6], &empty(0), &empty(0));
1775        let sup: Vec<Vec<Index>> = u.elements.iter().map(|e| e.support.clone()).collect();
1776        assert_eq!(sup, vec![vec![0, 3], vec![1, 2, 5]]);
1777        assert_eq!(u.uncovered, vec![4]);
1778        // The star keeps only its declared entries: (1,1), (5,5), (5,1) excluded.
1779        let star = &u.elements[1];
1780        assert_eq!(
1781            star.allowed.as_deref(),
1782            Some(&[false, true, true, false, true, false][..])
1783        );
1784    }
1785
1786    /// `packed_mult` agrees with a dense symmetric product, off-diagonal
1787    /// fan-out included.
1788    #[test]
1789    fn packed_mult_matches_dense() {
1790        // B = [[1, 2, 3], [2, 4, 5], [3, 5, 6]]
1791        let b = vec![1.0, 2.0, 4.0, 3.0, 5.0, 6.0];
1792        let s = vec![1.0, -2.0, 0.5];
1793        let mut out = vec![0.0; 3];
1794        packed_mult(&b, &s, &mut out);
1795        let dense = [[1.0, 2.0, 3.0], [2.0, 4.0, 5.0], [3.0, 5.0, 6.0]];
1796        for a in 0..3 {
1797            let want: Number = (0..3).map(|c| dense[a][c] * s[c]).sum();
1798            assert!(
1799                (out[a] - want).abs() < 1e-12,
1800                "row {a}: {} vs {want}",
1801                out[a]
1802            );
1803        }
1804    }
1805
1806    /// SR1 satisfies the secant equation `B_+ s = y` exactly in one step
1807    /// when the denominator is safe — the property that makes it the
1808    /// right per-element update for a nonconvex constraint.
1809    #[test]
1810    fn sr1_satisfies_the_secant_equation() {
1811        let mut e = Element {
1812            source: ElementSource::EqRow,
1813            row: 0,
1814            support: vec![0, 1, 2],
1815            dense: true,
1816            b: vec![0.0; 6],
1817            prev_g: vec![0.0; 3],
1818            has_prev: true,
1819            seeded: true,
1820            entries: Vec::new(),
1821            map: Vec::new(),
1822            allowed: None,
1823        };
1824        let s = vec![1.0, 0.5, -0.25];
1825        // A deliberately indefinite target: SR1 must not sanitize it.
1826        let y = vec![-2.0, 1.0, 3.0];
1827        assert!(update_element(
1828            &mut e,
1829            &s,
1830            &y,
1831            UpdateType::Sr1,
1832            1e-8,
1833            1e8,
1834            1e12
1835        ));
1836        let mut bs = vec![0.0; 3];
1837        packed_mult(&e.b, &s, &mut bs);
1838        for a in 0..3 {
1839            assert!(
1840                (bs[a] - y[a]).abs() < 1e-10,
1841                "component {a}: {} vs {}",
1842                bs[a],
1843                y[a]
1844            );
1845        }
1846    }
1847
1848    /// An element whose curvature is genuinely negative keeps a negative
1849    /// block under SR1. Damped BFGS does not, which is why SR1 is the
1850    /// default — see the module docs and issue #131.
1851    ///
1852    /// Note what carries the sign on a **one-dimensional** element: the
1853    /// scalar seeding `gamma = sᵀy/sᵀs` already satisfies the secant
1854    /// equation exactly, so the rank-1 term has nothing to add and
1855    /// `update_element` correctly declines. The property under test is
1856    /// the resulting curvature, not the return value.
1857    #[test]
1858    fn sr1_preserves_negative_curvature_where_bfgs_would_not() {
1859        let make = || Element {
1860            source: ElementSource::EqRow,
1861            row: 0,
1862            support: vec![0],
1863            dense: true,
1864            b: vec![0.0],
1865            prev_g: vec![0.0],
1866            has_prev: true,
1867            seeded: false,
1868            entries: Vec::new(),
1869            map: Vec::new(),
1870            allowed: None,
1871        };
1872        let s = vec![1.0];
1873        let y = vec![-3.0];
1874
1875        let mut sr1 = make();
1876        update_element(&mut sr1, &s, &y, UpdateType::Sr1, 1e-8, 1e8, 1e12);
1877        assert!(sr1.b[0] < 0.0, "SR1 kept curvature {}", sr1.b[0]);
1878        // and it is the exact secant value, not merely the right sign
1879        assert!((sr1.b[0] + 3.0).abs() < 1e-12, "{}", sr1.b[0]);
1880
1881        let mut bfgs = make();
1882        update_element(&mut bfgs, &s, &y, UpdateType::Bfgs, 1e-8, 1e8, 1e12);
1883        assert!(bfgs.b[0] > 0.0, "damped BFGS kept curvature {}", bfgs.b[0]);
1884    }
1885
1886    /// **The scalar seeding always annihilates the first SR1 update**,
1887    /// for every element and every dimension. Seeding sets `B = γI` with
1888    /// `γ = sᵀy/sᵀs`, so `Bs = γs` and the SR1 denominator is
1889    /// `wᵀs = sᵀy − γ·sᵀs ≡ 0`. That is not a defect — the seeded block
1890    /// already satisfies the secant equation along `s` — but it means an
1891    /// element's *first* pair contributes only a multiple of the
1892    /// identity and no directional information whatsoever. With ~5 000
1893    /// elements each receiving one direction per iteration, that costs a
1894    /// full iteration of information per element; see
1895    /// `dev-notes/partitioned-quasi-newton-prototype.md`.
1896    #[test]
1897    fn scalar_seeding_leaves_the_first_sr1_update_with_nothing_to_do() {
1898        let mut e = Element {
1899            source: ElementSource::EqRow,
1900            row: 0,
1901            support: vec![0, 1],
1902            dense: true,
1903            b: vec![0.0; 3],
1904            prev_g: vec![0.0; 2],
1905            has_prev: true,
1906            seeded: false,
1907            entries: Vec::new(),
1908            map: Vec::new(),
1909            allowed: None,
1910        };
1911        let s = vec![1.0, 0.5];
1912        let y = vec![2.0, -4.0];
1913        // Skipped, and what is left behind is exactly the seeded scalar.
1914        assert!(!update_element(
1915            &mut e,
1916            &s,
1917            &y,
1918            UpdateType::Sr1,
1919            1e-8,
1920            1e8,
1921            1e12
1922        ));
1923        assert!(e.seeded);
1924        assert_eq!(e.b[1], 0.0, "off-diagonal must still be zero");
1925        assert!(
1926            (e.b[0] - e.b[2]).abs() < 1e-15,
1927            "block must be a multiple of I"
1928        );
1929    }
1930
1931    /// The SR1-vs-BFGS contrast on a **two-dimensional** element, driven
1932    /// through the path the solver actually takes: a first pair to seed,
1933    /// then a second that the rank-1 term can act on. SR1 reproduces the
1934    /// second pair exactly and leaves the block indefinite; damped BFGS
1935    /// returns a positive definite block that does not.
1936    #[test]
1937    fn sr1_reaches_an_indefinite_block_where_bfgs_stays_definite() {
1938        let make = || Element {
1939            source: ElementSource::EqRow,
1940            row: 0,
1941            support: vec![0, 1],
1942            dense: true,
1943            b: vec![0.0; 3],
1944            prev_g: vec![0.0; 2],
1945            has_prev: true,
1946            seeded: false,
1947            entries: Vec::new(),
1948            map: Vec::new(),
1949            allowed: None,
1950        };
1951        let (s1, y1) = (vec![1.0, 0.0], vec![2.0, 0.0]);
1952        let (s2, y2) = (vec![0.0, 1.0], vec![0.0, -4.0]);
1953
1954        let mut sr1 = make();
1955        update_element(&mut sr1, &s1, &y1, UpdateType::Sr1, 1e-8, 1e8, 1e12);
1956        assert!(update_element(
1957            &mut sr1,
1958            &s2,
1959            &y2,
1960            UpdateType::Sr1,
1961            1e-8,
1962            1e8,
1963            1e12
1964        ));
1965        let mut bs = vec![0.0; 2];
1966        packed_mult(&sr1.b, &s2, &mut bs);
1967        for a in 0..2 {
1968            assert!(
1969                (bs[a] - y2[a]).abs() < 1e-10,
1970                "component {a}: {} vs {}",
1971                bs[a],
1972                y2[a]
1973            );
1974        }
1975        // det < 0 ⇒ one eigenvalue of each sign: the indefiniteness the
1976        // inertia correction is supposed to see.
1977        let det = sr1.b[0] * sr1.b[2] - sr1.b[1] * sr1.b[1];
1978        assert!(det < 0.0, "SR1 block determinant {det}");
1979
1980        let mut bfgs = make();
1981        update_element(&mut bfgs, &s1, &y1, UpdateType::Bfgs, 1e-8, 1e8, 1e12);
1982        assert!(update_element(
1983            &mut bfgs,
1984            &s2,
1985            &y2,
1986            UpdateType::Bfgs,
1987            1e-8,
1988            1e8,
1989            1e12
1990        ));
1991        let det_b = bfgs.b[0] * bfgs.b[2] - bfgs.b[1] * bfgs.b[1];
1992        assert!(
1993            bfgs.b[0] > 0.0 && det_b > 0.0,
1994            "damped BFGS block is positive definite: diag {} det {det_b}",
1995            bfgs.b[0]
1996        );
1997    }
1998
1999    /// The diagonal fallback satisfies the weak secant condition
2000    /// `sᵀBs = sᵀy`, which is the whole contract it is asked for.
2001    #[test]
2002    fn diagonal_element_satisfies_the_weak_secant_condition() {
2003        let mut e = Element {
2004            source: ElementSource::Objective,
2005            row: 0,
2006            support: vec![0, 1, 2],
2007            dense: false,
2008            b: vec![0.0; 3],
2009            prev_g: vec![0.0; 3],
2010            has_prev: true,
2011            seeded: true,
2012            entries: Vec::new(),
2013            map: Vec::new(),
2014            allowed: None,
2015        };
2016        let s = vec![1.0, -2.0, 0.5];
2017        let y = vec![0.5, 1.0, -3.0];
2018        assert!(update_element(
2019            &mut e,
2020            &s,
2021            &y,
2022            UpdateType::Sr1,
2023            1e-8,
2024            1e8,
2025            1e12
2026        ));
2027        let s_bs: Number = (0..3).map(|a| e.b[a] * s[a] * s[a]).sum();
2028        assert!(
2029            (s_bs - dot(&s, &y)).abs() < 1e-10,
2030            "{s_bs} vs {}",
2031            dot(&s, &y)
2032        );
2033    }
2034
2035    /// A parallel `y` that carries no new information leaves the block
2036    /// untouched rather than producing an unbounded rank-1 term.
2037    #[test]
2038    fn sr1_skips_a_degenerate_denominator() {
2039        let mut e = Element {
2040            source: ElementSource::EqRow,
2041            row: 0,
2042            support: vec![0, 1],
2043            dense: true,
2044            b: vec![2.0, 0.0, 2.0],
2045            prev_g: vec![0.0; 2],
2046            has_prev: true,
2047            seeded: true,
2048            entries: Vec::new(),
2049            map: Vec::new(),
2050            allowed: None,
2051        };
2052        let before = e.b.clone();
2053        let s = vec![1.0, 1.0];
2054        // y = B s exactly, so w = 0 and the denominator vanishes.
2055        let y = vec![2.0, 2.0];
2056        assert!(!update_element(
2057            &mut e,
2058            &s,
2059            &y,
2060            UpdateType::Sr1,
2061            1e-8,
2062            1e8,
2063            1e12
2064        ));
2065        assert_eq!(e.b, before);
2066    }
2067}