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 ° {
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}