pounce_algorithm/hess/fd_hessian.rs
1//! Sparse finite-difference Lagrangian Hessian, recovered by graph
2//! coloring from the **analytic Jacobian** (Curtis, Powell & Reid 1974;
3//! Coleman & Moré 1983).
4//!
5//! # Why this exists
6//!
7//! The models this targets — a direct-collocation transcription built
8//! from an FMU or a CasADi `DaeBuilder` — supply analytic first
9//! derivatives and no second derivatives. Today that leaves only
10//! [`crate::hess::lim_mem_quasi_newton`], and on
11//! `benchmarks/large_scale` `laptime` the difference is stark: the exact
12//! Hessian converges in 30 iterations where limited-memory takes 246,
13//! and one mesh refinement later limited-memory does not converge at all.
14//!
15//! But an analytic Jacobian is already enough to *build* the Hessian.
16//! The Lagrangian gradient
17//!
18//! ```text
19//! ∇ₓL(x, y) = ∇f(x) + J_c(x)ᵀ y_c + J_d(x)ᵀ y_d
20//! ```
21//!
22//! is available in closed form, so its directional derivative
23//!
24//! ```text
25//! ∇²ₓₓL · d ≈ [ ∇ₓL(x + d, y) − ∇ₓL(x, y) ] / h
26//! ```
27//!
28//! costs one gradient and one Jacobian evaluation — and with a known
29//! sparsity pattern, one probe recovers a whole *group* of structurally
30//! orthogonal columns at once rather than a single one.
31//!
32//! # Why it is affordable
33//!
34//! Measured on `laptime` at `N = 160`: a Jacobian evaluation costs
35//! 5.4 ms against a 92.6 ms iteration, and the Hessian pattern has
36//! `rho_max = 15` with a mean row of 5.68 — **unchanged at `N = 320`**
37//! (`POUNCE_HESS_PATTERN_CENSUS`). The row width is set by the
38//! per-stage stencil, not the horizon, so the number of probes does not
39//! grow with the mesh. That is the property that makes the whole scheme
40//! viable: the cost per Hessian is constant in problem size while the
41//! iteration count it buys is the exact path's.
42//!
43//! # The partition
44//!
45//! Columns are grouped so that no two columns in a group share a row of
46//! the pattern (Curtis-Powell-Reid structural orthogonality). Probing
47//! group `g` with `d = Σ_{j∈g} h_j e_j` then gives, for every row `i`,
48//!
49//! ```text
50//! w_i = Σ_{j∈g} H_ij h_j = H_ij h_j for the unique j ∈ g with H_ij ≠ 0
51//! ```
52//!
53//! so each entry is read off directly with no linear solve.
54//!
55//! A *star* colouring (`fd_hessian_coloring=star`) lets an entry be read
56//! from **either** endpoint's probe and so needs fewer groups: 76 → 42 on
57//! the Jacobian-derived pattern here, 17 → 16 on the declared one. Its
58//! recovery is algebraically exact — `overlapping_cliques_are_validated_not_assumed`
59//! verifies that by recovering a known matrix through it.
60//!
61//! **And it is still the wrong choice on a dense pattern, which is why CPR
62//! is the default.** On `laptime`, star colouring over the Jacobian-derived
63//! pattern takes 404 iterations to an objective of 65.368334 where CPR takes
64//! 38 to 65.371106.
65//!
66//! The cause is not group size — the measurement rules that out, since
67//! `declared/star` packs the *largest* groups of the four (580 columns per
68//! probe against `jacobian/cpr`'s 122) and converges in 30 iterations with
69//! the exact objective:
70//!
71//! | pattern / colouring | groups | cols per group | result |
72//! |---|---|---|---|
73//! | declared / cpr | 17 | 546 | Optimal, 30 it |
74//! | declared / star | 16 | 580 | Optimal, 30 it |
75//! | jacobian / cpr | 76 | 122 | Optimal, 38 it |
76//! | jacobian / star | 42 | 221 | Acceptable, 404 it, wrong objective |
77//!
78//! The cause is the **finite-difference remainder**. Direct-recovery theory
79//! assumes exact Hessian-vector products; a forward difference also carries
80//! `½ Σ_{m,p ∈ g} T_imp h_m h_p` into row `i`, where `T` is the third
81//! derivative. `T_imp ≠ 0` needs `i`, `m` and `p` in a common constraint's
82//! support, hence `H_im ≠ 0` **and** `H_ip ≠ 0`. CPR's distance-2 property
83//! forbids two such columns in one group, so those cross terms vanish
84//! structurally. A star colouring only guarantees the single-neighbour
85//! property for the pair being recovered, so the cross terms survive — and
86//! they matter exactly when the pattern is dense (`rho_max` 59 here against
87//! the declared pattern's 15).
88//!
89//! So star colouring is safe on a sparse declared pattern and unsafe on a
90//! Jacobian-derived one, which is the mode most models need. It stays
91//! opt-in. Every colouring is additionally validated entry by entry before
92//! use, unconditionally — that check began as a `debug_assert`, which is
93//! compiled out in release, and a Hessian that is wrong but plausible is the
94//! failure this module is most exposed to.
95//!
96//! # The pattern
97//!
98//! Two sources, selected by `fd_hessian_pattern`:
99//!
100//! * `declared` — the TNLP's own Hessian sparsity, when it declares one
101//! (every `.nl` does, through AMPL's AD). Requires no values, only the
102//! structure call, so it is available to a model that cannot evaluate
103//! second derivatives.
104//! * `jacobian` — derived as `(O ⊗ O) ∪ ⋃_j supp(∇g_j) ⊗ supp(∇g_j)`,
105//! where `O` is the objective's nonlinear variables. It needs nothing
106//! beyond the Jacobian pattern every TNLP must declare, plus the
107//! objective linearity the TNLP will state. This is a strict
108//! **superset** of the true pattern, which is safe (a superset costs
109//! extra groups, never a wrong answer) but not free: on `laptime` it is
110//! 146 267 nonzeros against the true 28 000.
111//!
112//! The `O ⊗ O` term is not optional. The constraint Jacobian says
113//! nothing about `∇²f`, so without it a model whose objective couples
114//! two variables that never share a constraint row gets a pattern that
115//! is a *subset* of the truth, and the recovery drops that curvature
116//! silently.
117//!
118//! A superset is always safe; a subset would silently drop curvature, so
119//! there is no fallback that guesses. That sentence was written before the
120//! code honoured it: the objective clique's fallback read the first `∇f`'s
121//! *values*, which for `f = x₀x₁` at the origin is the zero vector, so the
122//! clique came back empty and the pattern was a subset after all — and a
123//! different subset from a different starting point. Every level of the
124//! fallback is structural now: stated objective linearity, else the
125//! nonlinear-variable set `N`, else all `n`. The last two are conservative
126//! and can cost a great many probes; `FdStats::objective_clique_widened`
127//! reports when one of them was taken. gh#823 review (@srikanth-gm).
128//!
129//! **Probe cost scales with the Hessian's row width, not with the model's
130//! size.** `laptime` needs 17 groups because its per-stage stencil makes
131//! `rho_max = 15`; that is a property of that transcription and does not
132//! generalise. A model with `rho_max = 176` needs ~181 groups and therefore
133//! ~180 gradient-plus-Jacobian evaluations per Hessian, which can be far more
134//! expensive per iteration than the limited-memory path it replaces — measured
135//! on a 60k-variable model in gh#823 review. Read `rho_max` from
136//! `POUNCE_FD_HESSIAN_DEBUG` before assuming this mode is affordable.
137
138use crate::hess::r#trait::HessianUpdater;
139use crate::ipopt_cq::IpoptCqHandle;
140use crate::ipopt_data::IpoptDataHandle;
141use pounce_common::types::{Index, Number};
142use pounce_linalg::Vector;
143use pounce_linalg::compound_vector::CompoundVector;
144use pounce_linalg::dense_vector::DenseVector;
145use pounce_linalg::triplet::{GenTMatrix, SymTMatrix, SymTMatrixSpace};
146use std::rc::Rc;
147
148/// Relative finite-difference step. `sqrt(eps)` is the classic
149/// forward-difference optimum: truncation error is `O(h)` and round-off
150/// `O(eps/h)`, and they balance there.
151const FD_REL_STEP: Number = 1.4901161193847656e-8;
152
153/// How columns are grouped into probes.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum FdColoring {
156 /// Curtis-Powell-Reid: no two columns in a group share a row. Treats
157 /// the Hessian as a general matrix, so it ignores symmetry and pays
158 /// for it — this is a distance-2 colouring of the adjacency graph.
159 Cpr,
160 /// Star colouring: a proper colouring in which every path on four
161 /// vertices uses at least three colours, i.e. every bichromatic
162 /// component is a star (Coleman & Moré 1983; Gebremedhin, Manne &
163 /// Pothen 2005). Exploits symmetry — `H_ij` may be read from the
164 /// probe of *either* endpoint's colour — so it needs materially
165 /// fewer groups than CPR for the same pattern.
166 Star,
167}
168
169/// Where the Hessian sparsity pattern comes from.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum FdPatternSource {
172 /// The TNLP's declared Hessian structure, when it has one.
173 Declared,
174 /// `⋃_j supp(∇g_j) ⊗ supp(∇g_j)`, from the Jacobian pattern alone.
175 Jacobian,
176}
177
178#[derive(Debug, Clone, Copy, Default)]
179pub struct FdStats {
180 /// The pattern source actually **used**, which is not always the one
181 /// requested: `Declared` falls back to `Jacobian` whenever the TNLP
182 /// declares no Hessian structure. Reporting the request rather than
183 /// the outcome would hide exactly the case a reader is checking for —
184 /// the 17-against-341 probe-group gap on `laptime` is the difference
185 /// between the two. `None` until the first `update_hessian` builds
186 /// the structure.
187 pub pattern_used: Option<FdPatternSource>,
188 pub n: usize,
189 pub nnz: usize,
190 pub groups: usize,
191 pub rho_max: usize,
192 /// Probes per Hessian as a fraction of `n` — the quantity a dense
193 /// finite-difference scheme would pay in full.
194 pub compression: f64,
195 /// Whether the requested star colouring failed validation and CPR was
196 /// substituted.
197 pub coloring_fell_back: bool,
198 /// Whether the objective clique had to fall back to a conservative
199 /// structural set because the model stated no objective linearity
200 /// (gh#823 review, finding 2). When this is set the clique is `N`, or
201 /// all `n` when the model states no nonlinear-variable set either, and
202 /// the probe count reflects that rather than the objective's true
203 /// support. A model that states `get_objective_variables_linearity`
204 /// pays none of it.
205 pub objective_clique_widened: bool,
206}
207
208pub struct FdHessianUpdater {
209 pub pattern_source: FdPatternSource,
210 pub coloring: FdColoring,
211 /// Reuse the previous Hessian when neither the primal iterate nor the
212 /// multipliers have moved by more than this, relative to their own
213 /// magnitude (`fd_hessian_reuse_tol`). `0` rebuilds every iteration.
214 ///
215 /// **Both** are tested, not just `x`: `∇²L = ∇²f + Σ yⱼ ∇²cⱼ` depends
216 /// on the multipliers too, so a cached Hessian is stale the moment `y`
217 /// moves even if `x` has not.
218 pub reuse_tol: Number,
219 /// Variables the objective is nonlinear in, in the compressed `x_var`
220 /// space. The Jacobian-derived pattern **must** include
221 /// `objective_vars ⊗ objective_vars`: `⋃ⱼ supp(∇gⱼ) ⊗ supp(∇gⱼ)`
222 /// describes the constraints only, so without this a `∇²f` entry
223 /// whose two variables never co-occur in a constraint row falls
224 /// outside the pattern and is silently dropped — the pattern would be
225 /// a *subset* of the truth, not the superset this mode's safety
226 /// argument rests on. `None` falls back to the first `∇f`'s nonzeros.
227 pub objective_vars: Option<Vec<Index>>,
228 /// Variables that enter `f` or `g` **nonlinearly**, in the compressed
229 /// `x_var` space. A variable outside this set is linear everywhere,
230 /// so every off-diagonal Hessian entry touching it is structurally
231 /// zero and it can be dropped from the Jacobian-derived cliques:
232 ///
233 /// ```text
234 /// ⋃_j supp(∇g_j) ⊗ supp(∇g_j)
235 /// → ⋃_j (supp(∇g_j) ∩ N) ⊗ (supp(∇g_j) ∩ N)
236 /// ```
237 ///
238 /// Still a superset of the truth, just a tighter one. The full primal
239 /// diagonal stays in the pattern regardless — the barrier and the
240 /// inertia correction need those rows present even where the model
241 /// has no curvature. Suggested in review by @srikanth-gm.
242 pub nonlinear_vars: Option<Vec<Index>>,
243 /// Assembled pattern, lower triangle, 1-based (the exact-Hessian
244 /// path's own convention).
245 space: Option<Rc<SymTMatrixSpace>>,
246 /// Columns of each probe group.
247 groups: Vec<Vec<Index>>,
248 /// For every stored (lower-triangle) entry `k`: which group's probe
249 /// carries it, which component of that probe to read, and which
250 /// column's step it must be divided by.
251 ///
252 /// Under CPR this is always `(colour(j), i, j)`. Under a star
253 /// colouring it is that *or* `(colour(i), j, i)`, whichever endpoint
254 /// is the leaf of the bichromatic star — which is the whole reason a
255 /// star colouring needs fewer groups.
256 recovery: Vec<(u32, u32, u32)>,
257 /// Stored-entry indices carried by each group's probe.
258 by_group: Vec<Vec<u32>>,
259 stats: FdStats,
260 reported: bool,
261 /// Cached iterate and Hessian for the reuse test.
262 prev_x: Option<Vec<Number>>,
263 prev_y: Option<Vec<Number>>,
264 prev_w: Option<Rc<SymTMatrix>>,
265 pub reused: u64,
266 pub rebuilt: u64,
267}
268
269impl FdHessianUpdater {
270 pub fn new(pattern_source: FdPatternSource) -> Self {
271 Self {
272 pattern_source,
273 coloring: FdColoring::Cpr,
274 reuse_tol: 0.0,
275 objective_vars: None,
276 nonlinear_vars: None,
277 space: None,
278 groups: Vec::new(),
279 recovery: Vec::new(),
280 by_group: Vec::new(),
281 stats: FdStats::default(),
282 reported: false,
283 prev_x: None,
284 prev_y: None,
285 prev_w: None,
286 reused: 0,
287 rebuilt: 0,
288 }
289 }
290
291 pub fn stats(&self) -> FdStats {
292 self.stats
293 }
294
295 /// Curtis-Powell-Reid grouping: no two columns in a group share a
296 /// row. Greedy, largest-degree-first, over the column intersection
297 /// graph — which for a symmetric pattern is distance-2 adjacency.
298 fn color_cpr(n: usize, cols_of_row: &[Vec<Index>], rows_of_col: &[Vec<Index>]) -> Vec<usize> {
299 let mut order: Vec<Index> = (0..n as Index).collect();
300 order.sort_unstable_by_key(|&j| std::cmp::Reverse(rows_of_col[j as usize].len()));
301
302 let mut color = vec![usize::MAX; n];
303 let mut forbidden = vec![usize::MAX; n + 1];
304 let mut n_colors = 0usize;
305
306 for &j in &order {
307 let stamp = j as usize;
308 for &i in &rows_of_col[j as usize] {
309 for &k in &cols_of_row[i as usize] {
310 let c = color[k as usize];
311 if c != usize::MAX {
312 forbidden[c] = stamp;
313 }
314 }
315 }
316 let mut c = 0usize;
317 while c < n_colors && forbidden[c] == stamp {
318 c += 1;
319 }
320 if c == n_colors {
321 n_colors += 1;
322 }
323 color[j as usize] = c;
324 }
325 color
326 }
327
328 /// Star colouring of the adjacency graph: a proper colouring in which
329 /// no path on four vertices is bichromatic, so every bichromatic
330 /// component is a star (Gebremedhin, Manne & Pothen 2005, Alg. 4).
331 ///
332 /// This is what lets symmetry be exploited. Under CPR an entry must
333 /// come from its column's probe; under a star colouring it may come
334 /// from *either* endpoint's, and in every bichromatic star the leaf
335 /// end always has exactly one neighbour of the centre's colour — so
336 /// direct recovery is always available from one side or the other.
337 /// `adj` excludes self-loops.
338 fn color_star(n: usize, adj: &[Vec<Index>]) -> Vec<usize> {
339 let mut order: Vec<Index> = (0..n as Index).collect();
340 order.sort_unstable_by_key(|&v| std::cmp::Reverse(adj[v as usize].len()));
341
342 let mut color = vec![usize::MAX; n];
343 let mut forbidden = vec![usize::MAX; n + 2];
344 let mut n_colors = 0usize;
345
346 for &v in &order {
347 let stamp = v as usize;
348 for &w in &adj[v as usize] {
349 let cw = color[w as usize];
350 if cw != usize::MAX {
351 // A proper colouring forbids a neighbour's colour.
352 forbidden[cw] = stamp;
353 // And a bichromatic P4 `v-w-x-y` would be created by
354 // giving `v` the colour of an `x` two hops away whose
355 // own neighbourhood already carries `w`'s colour.
356 for &x in &adj[w as usize] {
357 if x == v {
358 continue;
359 }
360 let cx = color[x as usize];
361 if cx == usize::MAX {
362 continue;
363 }
364 for &y in &adj[x as usize] {
365 if y != w && color[y as usize] == cw {
366 forbidden[cx] = stamp;
367 break;
368 }
369 }
370 }
371 } else {
372 // `w` uncolored: `v-w-x` with `x` colored would leave
373 // a P4 realizable later, so keep `v` off `x`'s colour.
374 for &x in &adj[w as usize] {
375 if x == v {
376 continue;
377 }
378 let cx = color[x as usize];
379 if cx != usize::MAX {
380 forbidden[cx] = stamp;
381 }
382 }
383 }
384 }
385 let mut c = 0usize;
386 while c < n_colors && forbidden[c] == stamp {
387 c += 1;
388 }
389 if c == n_colors {
390 n_colors += 1;
391 }
392 color[v as usize] = c;
393 }
394 color
395 }
396
397 fn build_structure(
398 &mut self,
399 n: usize,
400 declared: Option<&(Vec<Index>, Vec<Index>)>,
401 jac_c: &GenTMatrix,
402 jac_d: &GenTMatrix,
403 ) {
404 // ---- lower-triangle pattern ---------------------------------
405 let mut pairs: Vec<(Index, Index)> = Vec::new();
406 let pattern_used = match (self.pattern_source, declared) {
407 (FdPatternSource::Declared, Some(_)) => FdPatternSource::Declared,
408 _ => FdPatternSource::Jacobian,
409 };
410 match (self.pattern_source, declared) {
411 (FdPatternSource::Declared, Some((ir, jc))) => {
412 for (&i, &j) in ir.iter().zip(jc.iter()) {
413 let (a, b) = (i - 1, j - 1);
414 pairs.push(if a >= b { (a, b) } else { (b, a) });
415 }
416 }
417 _ => {
418 // The objective's own clique. `⋃_j supp(∇g_j) ⊗
419 // supp(∇g_j)` below describes the CONSTRAINTS only, and
420 // `∇²L = ∇²f + Σ yⱼ ∇²cⱼ` has a `∇²f` term whose entries
421 // need not lie in any constraint row's clique. Omitting
422 // this made the Jacobian-derived pattern a *subset* of the
423 // true Hessian rather than a superset, which is the
424 // property the whole mode's safety rests on — a subset
425 // silently drops curvature. `laptime` did not expose it
426 // because its objective is minimise-final-time, one
427 // variable, `∇²f = 0`. Found in review by @srikanth-gm.
428 // The fallback must be STRUCTURAL. It used to read the
429 // first `∇f`'s nonzeros, which is unsound, not merely
430 // weaker: for `f(x) = x₀x₁` at `x = (0,0)` the gradient is
431 // `(x₁, x₀) = (0,0)`, so the support comes back empty and
432 // the `∂²f/∂x₀∂x₁ = 1` entry is dropped — the pattern is a
433 // *subset* of the truth, which is precisely the property
434 // this mode's safety rests on. It was also value-dependent:
435 // the same model started at `(1,1)` got a different
436 // pattern. Reported by @srikanth-gm (gh#823 review,
437 // finding 2), reproduced by
438 // `the_objective_fallback_is_structural_not_value_derived`.
439 //
440 // So: the model's own objective linearity when it states
441 // one; else the nonlinear-variable set `N`, which cannot
442 // omit a variable the objective is nonlinear in; else all
443 // `n`. The last two are conservative and can be expensive —
444 // `objective_clique_widened` says so rather than letting it
445 // look like the objective really is that dense.
446 let (obj, widened) = objective_support(
447 self.objective_vars.as_deref(),
448 self.nonlinear_vars.as_deref(),
449 n,
450 );
451 if widened {
452 self.stats.objective_clique_widened = true;
453 }
454 for (a, &ca) in obj.iter().enumerate() {
455 for &cb in obj.iter().take(a + 1) {
456 pairs.push(if ca >= cb { (ca, cb) } else { (cb, ca) });
457 }
458 }
459 // `⋃_j (supp(∇g_j) ∩ N) ⊗ (supp(∇g_j) ∩ N)` over both
460 // Jacobians, with `N` the nonlinear-variable set when the
461 // model states one.
462 let mask: Option<Vec<bool>> = self.nonlinear_vars.as_ref().map(|v| {
463 let mut m = vec![false; n];
464 for &i in v {
465 m[i as usize] = true;
466 }
467 m
468 });
469 for jac in [jac_c, jac_d] {
470 let n_rows = jac.space().n_rows() as usize;
471 let mut by_row: Vec<Vec<Index>> = vec![Vec::new(); n_rows + 1];
472 for (&i, &j) in jac.irows().iter().zip(jac.jcols().iter()) {
473 by_row[i as usize].push(j - 1);
474 }
475 for row in by_row.iter_mut() {
476 if let Some(m) = mask.as_ref() {
477 row.retain(|&c| m[c as usize]);
478 }
479 row.sort_unstable();
480 row.dedup();
481 for (a, &ca) in row.iter().enumerate() {
482 for &cb in row.iter().take(a + 1) {
483 pairs.push((ca, cb));
484 }
485 }
486 }
487 }
488 }
489 }
490 // The diagonal always belongs: a structurally empty `(1,1)` row
491 // is carried by the barrier term alone and costs the
492 // factorization a near-singular pivot on every one of them.
493 for i in 0..n {
494 pairs.push((i as Index, i as Index));
495 }
496 pairs.sort_unstable();
497 pairs.dedup();
498
499 // ---- adjacency over the FULL symmetric pattern --------------
500 //
501 // Orthogonality and recovery both need both triangles: probing
502 // column `j` moves every row `i` with `H_ij ≠ 0`, whichever
503 // triangle that entry is stored in.
504 let mut rows_of_col: Vec<Vec<Index>> = vec![Vec::new(); n];
505 let mut cols_of_row: Vec<Vec<Index>> = vec![Vec::new(); n];
506 for &(i, j) in &pairs {
507 rows_of_col[j as usize].push(i);
508 cols_of_row[i as usize].push(j);
509 if i != j {
510 rows_of_col[i as usize].push(j);
511 cols_of_row[j as usize].push(i);
512 }
513 }
514 let rho_max = cols_of_row.iter().map(|r| r.len()).max().unwrap_or(0);
515
516 // Adjacency without self-loops, for the star colouring.
517 let mut adj: Vec<Vec<Index>> = vec![Vec::new(); n];
518 for &(i, j) in &pairs {
519 if i != j {
520 adj[i as usize].push(j);
521 adj[j as usize].push(i);
522 }
523 }
524
525 // A colouring is only usable if EVERY entry has an endpoint with
526 // exactly one neighbour in the other endpoint's colour — otherwise
527 // the probe component that entry is read from carries a *sum* of
528 // several entries, and reading it as one is silently wrong.
529 //
530 // This is validated rather than assumed, and the validation is
531 // unconditional. It was a `debug_assert` first, which is compiled
532 // out in release: the star colouring below is NOT valid on the
533 // Jacobian-derived pattern, and the resulting wrong Hessian showed
534 // up only as `laptime` taking 404 iterations to an objective of
535 // 65.368334 where the CPR colouring takes 38 to 65.371106. A
536 // Hessian that is wrong but plausible is the failure this module is
537 // most exposed to, so an invalid colouring falls back to CPR, which
538 // is correct by construction.
539 let validate = |color: &[usize]| -> bool {
540 let count_in_color = |v: Index, c: usize| -> usize {
541 adj[v as usize]
542 .iter()
543 .filter(|&&w| color[w as usize] == c)
544 .count()
545 };
546 pairs.iter().all(|&(i, j)| {
547 i == j
548 || count_in_color(i, color[j as usize]) == 1
549 || count_in_color(j, color[i as usize]) == 1
550 })
551 };
552 let mut color = match self.coloring {
553 FdColoring::Cpr => Self::color_cpr(n, &cols_of_row, &rows_of_col),
554 FdColoring::Star => Self::color_star(n, &adj),
555 };
556 let mut fell_back = false;
557 if self.coloring == FdColoring::Star && !validate(&color) {
558 color = Self::color_cpr(n, &cols_of_row, &rows_of_col);
559 fell_back = true;
560 debug_assert!(validate(&color), "CPR colouring must always be recoverable");
561 }
562 let n_colors = color.iter().copied().max().map(|c| c + 1).unwrap_or(0);
563 let mut groups = vec![Vec::new(); n_colors];
564 for (j, &c) in color.iter().enumerate() {
565 groups[c].push(j as Index);
566 }
567
568 // ---- recovery map -------------------------------------------
569 //
570 // `H_ij` is read from the probe of some group `g` at component
571 // `p`, divided by the step of column `q`. Validity requires that
572 // `p` have exactly ONE neighbour in group `g` — otherwise the
573 // probe component is a sum of several entries and reading it as
574 // one is silently wrong.
575 //
576 // Under CPR that is guaranteed for `(colour(j), i, j)` by
577 // construction. Under a star colouring it holds for at least one
578 // of the two endpoints — the leaf of the bichromatic star — so
579 // both are tried and the valid one taken.
580 let count_in_color = |v: Index, c: usize| -> usize {
581 adj[v as usize]
582 .iter()
583 .filter(|&&w| color[w as usize] == c)
584 .count()
585 };
586 let mut recovery: Vec<(u32, u32, u32)> = Vec::with_capacity(pairs.len());
587 for &(i, j) in &pairs {
588 if i == j {
589 // A proper colouring gives `i` no neighbour of its own
590 // colour, so the diagonal is always directly readable.
591 recovery.push((color[i as usize] as u32, i as u32, i as u32));
592 continue;
593 }
594 let (ci, cj) = (color[i as usize], color[j as usize]);
595 if count_in_color(i, cj) == 1 {
596 recovery.push((cj as u32, i as u32, j as u32));
597 } else {
598 // Guaranteed reachable by the validation above.
599 recovery.push((ci as u32, j as u32, i as u32));
600 }
601 }
602 let mut by_group: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
603 for (k, &(g, _, _)) in recovery.iter().enumerate() {
604 by_group[g as usize].push(k as u32);
605 }
606
607 self.stats = FdStats {
608 pattern_used: Some(pattern_used),
609 n,
610 nnz: pairs.len(),
611 groups: groups.len(),
612 rho_max,
613 compression: groups.len() as f64 / n.max(1) as f64,
614 coloring_fell_back: fell_back,
615 // Set while the pattern was being built, above; this
616 // reassignment must carry it rather than reset it.
617 objective_clique_widened: self.stats.objective_clique_widened,
618 };
619 let irows: Vec<Index> = pairs.iter().map(|&(i, _)| i + 1).collect();
620 let jcols: Vec<Index> = pairs.iter().map(|&(_, j)| j + 1).collect();
621 self.space = Some(SymTMatrixSpace::new(n as Index, irows, jcols));
622 self.groups = groups;
623 self.recovery = recovery;
624 self.by_group = by_group;
625 }
626}
627
628impl HessianUpdater for FdHessianUpdater {
629 fn fd_hessian_stats(&self) -> Option<FdStats> {
630 // `None` until the structure is built, so a caller can tell "the
631 // mode never ran" from "it ran and the pattern was empty".
632 self.stats.pattern_used.map(|_| self.stats)
633 }
634
635 fn update_hessian(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) -> bool {
636 let (curr_x, curr_y_c, curr_y_d) = match data.borrow().curr.as_ref() {
637 Some(c) => (c.x.clone(), c.y_c.clone(), c.y_d.clone()),
638 None => return true,
639 };
640 let nlp = Rc::clone(cq.borrow().nlp());
641
642 let base_grad_f = cq.borrow().curr_grad_f();
643 let base_jac_c = cq.borrow().curr_jac_c();
644 let base_jac_d = cq.borrow().curr_jac_d();
645 let (Some(jc), Some(jd)) = (
646 base_jac_c.as_any().downcast_ref::<GenTMatrix>(),
647 base_jac_d.as_any().downcast_ref::<GenTMatrix>(),
648 ) else {
649 return false;
650 };
651
652 let x = flat(&*curr_x);
653 let n = x.len();
654 if self.space.is_none() {
655 let declared = nlp.borrow().uninitialized_h();
656 let declared_pat = declared
657 .as_any()
658 .downcast_ref::<SymTMatrix>()
659 .filter(|t| t.nonzeros() > 0)
660 .map(|t| (t.irows().to_vec(), t.jcols().to_vec()));
661 self.build_structure(n, declared_pat.as_ref(), jc, jd);
662 if !self.reported && std::env::var("POUNCE_FD_HESSIAN_DEBUG").is_ok() {
663 self.reported = true;
664 eprintln!("fd-hessian: {:?}", self.stats);
665 }
666 }
667
668 // Baseline `∇ₓL` at the current iterate, from quantities the
669 // algorithm has already evaluated — no extra NLP call.
670 let mut base = curr_x.make_new();
671 base.copy(&*base_grad_f);
672 base_jac_c.trans_mult_vector(1.0, &*curr_y_c, 1.0, &mut *base);
673 base_jac_d.trans_mult_vector(1.0, &*curr_y_d, 1.0, &mut *base);
674 let base = flat(&*base);
675
676 // Per-column step, `sqrt(eps)` scaled by the variable's own
677 // magnitude.
678 //
679 // No bound guard is attempted from `nlp.x_l()` / `x_u()`: those
680 // live in Ipopt's *compressed* bounded-variable space, one entry
681 // per variable that HAS that bound, not one per variable, so
682 // indexing them by variable index is simply wrong (it panicked
683 // here before this was understood). The protection that matters
684 // is applied below instead, and is stronger: a probe that lands
685 // outside a `sqrt` or `log` domain returns NaN rather than an
686 // error, so each group's result is checked for finiteness and
687 // retried with the step reversed.
688 let steps: Vec<Number> = (0..n).map(|j| FD_REL_STEP * x[j].abs().max(1.0)).collect();
689
690 // Reuse the cached Hessian when neither the iterate nor the
691 // multipliers have moved. `∇²L = ∇²f + Σ yⱼ ∇²cⱼ` depends on both,
692 // so testing `x` alone would hand back a stale Hessian every time
693 // the duals moved on a short step — which is exactly what the
694 // endgame of an interior-point solve does.
695 if self.reuse_tol > 0.0 {
696 let y_now: Vec<Number> = flat(&*curr_y_c)
697 .into_iter()
698 .chain(flat(&*curr_y_d))
699 .collect();
700 if let (Some(px), Some(py), Some(pw)) = (
701 self.prev_x.as_ref(),
702 self.prev_y.as_ref(),
703 self.prev_w.as_ref(),
704 ) {
705 let rel = |a: &[Number], b: &[Number]| -> Number {
706 let (mut d, mut m) = (0.0_f64, 1.0_f64);
707 for (u, v) in a.iter().zip(b.iter()) {
708 d = d.max((u - v).abs());
709 m = m.max(u.abs());
710 }
711 d / m
712 };
713 if px.len() == x.len()
714 && py.len() == y_now.len()
715 && rel(&x, px) <= self.reuse_tol
716 && rel(&y_now, py) <= self.reuse_tol
717 {
718 self.reused += 1;
719 data.borrow_mut().w = Some(Rc::clone(pw) as Rc<dyn pounce_linalg::SymMatrix>);
720 return true;
721 }
722 }
723 self.prev_x = Some(x.clone());
724 self.prev_y = Some(y_now);
725 }
726 self.rebuilt += 1;
727
728 let space = Rc::clone(self.space.as_ref().expect("structure built above"));
729 let mut w = SymTMatrix::new(Rc::clone(&space));
730 {
731 let vals = w.values_mut();
732 vals.iter_mut().for_each(|v| *v = 0.0);
733
734 let mut probe = curr_x.make_new();
735 let mut gl = curr_x.make_new();
736 let mut xp = x.clone();
737 for (gi, group) in self.groups.iter().enumerate() {
738 // Forward step first; on a non-finite result the whole
739 // group is retried backwards. A collocation model is full
740 // of `sqrt` and `log`, and a probe that leaves the domain
741 // yields NaN silently — which would then be scattered
742 // straight into `W`.
743 let mut sign = 1.0;
744 let mut g1;
745 loop {
746 xp.copy_from_slice(&x);
747 for &j in group {
748 xp[j as usize] += sign * steps[j as usize];
749 }
750 set_expanded(probe.as_mut(), &xp);
751
752 nlp.borrow_mut().eval_grad_f(&*probe, &mut *gl);
753 let pj_c = nlp.borrow_mut().eval_jac_c(&*probe);
754 pj_c.trans_mult_vector(1.0, &*curr_y_c, 1.0, &mut *gl);
755 let pj_d = nlp.borrow_mut().eval_jac_d(&*probe);
756 pj_d.trans_mult_vector(1.0, &*curr_y_d, 1.0, &mut *gl);
757 g1 = flat(&*gl);
758
759 if g1.iter().all(|v| v.is_finite()) {
760 break;
761 }
762 if sign < 0.0 {
763 // Both directions leave the domain. Publishing a
764 // NaN block would be reported as a converged
765 // restoration failure with nothing naming the
766 // cause, so fail loudly instead.
767 return false;
768 }
769 sign = -1.0;
770 }
771
772 for &k in &self.by_group[gi] {
773 let (_, read, col) = self.recovery[k as usize];
774 let hq = sign * steps[col as usize];
775 vals[k as usize] = (g1[read as usize] - base[read as usize]) / hq;
776 }
777 }
778 }
779 let w = Rc::new(w);
780 if self.reuse_tol > 0.0 {
781 self.prev_w = Some(Rc::clone(&w));
782 }
783 data.borrow_mut().w = Some(w as Rc<dyn pounce_linalg::SymMatrix>);
784 true
785 }
786
787 /// The finite-difference Hessian is a pure function of `(x, y)` — it
788 /// reads `data.curr` and the already-evaluated `curr_grad_f` / `curr_jac_*`
789 /// and carries no step history — so it can simply be rebuilt here. That is
790 /// what makes it different from the quasi-Newton updaters, and what
791 /// `provides_exact_hessian` could not express (gh#823 review, finding 1).
792 ///
793 /// `data.w` is saved and restored around the rebuild: at this point it
794 /// holds `W` for the *previous* iterate, and the post-optimal sensitivity
795 /// hook reads it. The rebuild does refresh the reuse cache to the current
796 /// `(x, y)`, which is correct — the cache is keyed on exactly that, so the
797 /// `update_hessian` call in step 3 of this same iterate then hits it
798 /// instead of paying for a second pass.
799 fn hessian_at_current(
800 &mut self,
801 data: &IpoptDataHandle,
802 cq: &IpoptCqHandle,
803 ) -> Option<Rc<dyn pounce_linalg::SymMatrix>> {
804 let saved = data.borrow().w.clone();
805 let ok = self.update_hessian(data, cq);
806 let built = data.borrow().w.clone();
807 data.borrow_mut().w = saved;
808 if ok { built } else { None }
809 }
810}
811
812/// Which variables the objective clique spans, and whether that had to be
813/// widened past what the objective actually needs.
814///
815/// The rule is deliberately structural at every level. The previous version
816/// took the fallback from the first `∇f`'s nonzeros, which is unsound rather
817/// than merely imprecise: for `f(x) = x₀x₁` at `x = (0,0)` the gradient
818/// vanishes, the support comes back empty, and `∂²f/∂x₀∂x₁ = 1` is dropped —
819/// a *subset* of the true pattern, which is the one property this mode may
820/// not violate. It was value-dependent too, so the same model started at
821/// `(1,1)` got a different pattern. gh#823 review finding 2 (@srikanth-gm).
822fn objective_support(
823 objective_vars: Option<&[Index]>,
824 nonlinear_vars: Option<&[Index]>,
825 n: usize,
826) -> (Vec<Index>, bool) {
827 match objective_vars {
828 // The model stated its objective's nonlinear support: exact, cheap.
829 Some(v) => (v.to_vec(), false),
830 // No objective linearity. `N` cannot omit a variable the objective is
831 // nonlinear in, so it is a sound superset — and conservative.
832 None => match nonlinear_vars {
833 Some(v) => (v.to_vec(), true),
834 // Nothing structural at all. All `n` is the only superset left.
835 None => ((0..n as Index).collect(), true),
836 },
837 }
838}
839
840fn flat(v: &dyn Vector) -> Vec<Number> {
841 if let Some(dv) = v.as_any().downcast_ref::<DenseVector>() {
842 return dv.expanded_values();
843 }
844 if let Some(cv) = v.as_any().downcast_ref::<CompoundVector>() {
845 let mut out = Vec::with_capacity(cv.dim() as usize);
846 for i in 0..cv.n_comps() {
847 out.extend(flat(cv.comp(i)));
848 }
849 return out;
850 }
851 panic!("FdHessianUpdater: unsupported primal vector type");
852}
853
854fn set_expanded(dst: &mut dyn Vector, values: &[Number]) {
855 if let Some(dv) = dst.as_any_mut().downcast_mut::<DenseVector>() {
856 dv.set_values(values);
857 return;
858 }
859 if let Some(cv) = dst.as_any_mut().downcast_mut::<CompoundVector>() {
860 let dims: Vec<usize> = (0..cv.n_comps())
861 .map(|i| cv.comp(i).dim() as usize)
862 .collect();
863 let mut off = 0usize;
864 for (i, &d) in dims.iter().enumerate() {
865 set_expanded(cv.comp_mut(i as Index), &values[off..off + d]);
866 off += d;
867 }
868 return;
869 }
870 panic!("FdHessianUpdater: unsupported primal vector type");
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876
877 /// Build lower-triangle pairs for a symmetric banded pattern, plus
878 /// the adjacency and row/column incidence the colourings need.
879 fn banded(
880 n: usize,
881 half_band: usize,
882 ) -> (
883 Vec<(Index, Index)>,
884 Vec<Vec<Index>>,
885 Vec<Vec<Index>>,
886 Vec<Vec<Index>>,
887 ) {
888 let mut pairs = Vec::new();
889 for i in 0..n {
890 for j in i.saturating_sub(half_band)..=i {
891 pairs.push((i as Index, j as Index));
892 }
893 }
894 let mut rows_of_col: Vec<Vec<Index>> = vec![Vec::new(); n];
895 let mut cols_of_row: Vec<Vec<Index>> = vec![Vec::new(); n];
896 let mut adj: Vec<Vec<Index>> = vec![Vec::new(); n];
897 for &(i, j) in &pairs {
898 rows_of_col[j as usize].push(i);
899 cols_of_row[i as usize].push(j);
900 if i != j {
901 rows_of_col[i as usize].push(j);
902 cols_of_row[j as usize].push(i);
903 adj[i as usize].push(j);
904 adj[j as usize].push(i);
905 }
906 }
907 (pairs, rows_of_col, cols_of_row, adj)
908 }
909
910 /// The property both colourings must have, and the only one that
911 /// makes direct recovery sound: for every entry there is a probe
912 /// component that carries **exactly one** entry, so reading it as a
913 /// single value is not reading a sum. This is checked by actually
914 /// recovering a known matrix from simulated probes rather than by
915 /// inspecting the colouring — a colouring can look plausible and
916 /// still make the recovery read two entries as one, silently.
917 fn recovers_exactly(coloring: FdColoring, n: usize, half_band: usize) -> usize {
918 let (pairs, rows_of_col, cols_of_row, adj) = banded(n, half_band);
919 let color = match coloring {
920 FdColoring::Cpr => FdHessianUpdater::color_cpr(n, &cols_of_row, &rows_of_col),
921 FdColoring::Star => FdHessianUpdater::color_star(n, &adj),
922 };
923 let n_colors = color.iter().copied().max().unwrap() + 1;
924
925 // A known symmetric matrix on that pattern.
926 let val = |i: Index, j: Index| -> Number {
927 1.0 + (i as Number) * 0.5 - (j as Number) * 0.25 + ((i + j) as Number).sin()
928 };
929 let mut dense = vec![vec![0.0 as Number; n]; n];
930 for &(i, j) in &pairs {
931 let v = val(i.max(j), i.min(j));
932 dense[i as usize][j as usize] = v;
933 dense[j as usize][i as usize] = v;
934 }
935
936 // Exact probes: b_g = H · Σ_{m∈g} e_m (unit steps).
937 let mut probes = vec![vec![0.0 as Number; n]; n_colors];
938 for (m, &c) in color.iter().enumerate() {
939 for i in 0..n {
940 probes[c][i] += dense[i][m];
941 }
942 }
943
944 // Recovery, mirroring `build_structure` exactly.
945 let count_in_color = |v: Index, c: usize| -> usize {
946 adj[v as usize]
947 .iter()
948 .filter(|&&w| color[w as usize] == c)
949 .count()
950 };
951 for &(i, j) in &pairs {
952 let (g, read) = if i == j {
953 (color[i as usize], i)
954 } else {
955 let (ci, cj) = (color[i as usize], color[j as usize]);
956 if count_in_color(i, cj) == 1 {
957 (cj, i)
958 } else {
959 assert_eq!(
960 count_in_color(j, ci),
961 1,
962 "{coloring:?}: neither endpoint of ({i},{j}) is directly recoverable"
963 );
964 (ci, j)
965 }
966 };
967 let got = probes[g][read as usize];
968 let want = dense[i as usize][j as usize];
969 assert!(
970 (got - want).abs() < 1e-12,
971 "{coloring:?}: entry ({i},{j}) recovered {got}, want {want} — the probe component carried a sum, not one entry"
972 );
973 }
974 n_colors
975 }
976
977 #[test]
978 fn cpr_recovers_a_banded_matrix_exactly() {
979 for hb in 1..=4 {
980 recovers_exactly(FdColoring::Cpr, 40, hb);
981 }
982 }
983
984 #[test]
985 fn star_recovers_a_banded_matrix_exactly() {
986 for hb in 1..=4 {
987 recovers_exactly(FdColoring::Star, 40, hb);
988 }
989 }
990
991 /// Star colouring should need no more groups than CPR on a banded
992 /// pattern — that is the entire reason to pay for the extra
993 /// bookkeeping. It is asserted as `<=` rather than as a ratio because
994 /// how much it saves is a property of the pattern, not a guarantee:
995 /// where the pattern contains a dense `k × k` clique, the clique
996 /// number lower-bounds *any* colouring and star wins nothing.
997 #[test]
998 fn star_never_needs_more_groups_than_cpr() {
999 for hb in [1usize, 2, 4, 8] {
1000 let star = recovers_exactly(FdColoring::Star, 60, hb);
1001 let cpr = recovers_exactly(FdColoring::Cpr, 60, hb);
1002 assert!(star <= cpr, "half-band {hb}: star {star} > cpr {cpr}");
1003 }
1004 }
1005
1006 /// **The objective's curvature must be in a Jacobian-derived
1007 /// pattern, and the constraint Jacobian cannot supply it.**
1008 ///
1009 /// `∇²L = ∇²f + Σ yⱼ ∇²cⱼ`. A pattern built only from
1010 /// `⋃ⱼ supp(∇gⱼ) ⊗ supp(∇gⱼ)` covers the second term and misses the
1011 /// first, so an objective that couples two variables which never
1012 /// share a constraint row produces entries outside the pattern — and
1013 /// this mode's entire safety argument is that the pattern is a
1014 /// *superset*, since a subset drops curvature with no diagnostic.
1015 ///
1016 /// This shape is what the `laptime` corpus could not expose: its
1017 /// objective is minimise-final-time, one variable, `∇²f = 0`. Found
1018 /// in review by @srikanth-gm.
1019 #[test]
1020 fn the_objective_clique_is_in_the_jacobian_derived_pattern() {
1021 // Two constraints, each on a disjoint pair; an objective coupling
1022 // one variable from each. No constraint row contains both 0 and 2.
1023 let n = 4usize;
1024 let rows = [vec![0 as Index, 1], vec![2 as Index, 3]];
1025 let obj = vec![0 as Index, 2];
1026
1027 let mut pairs: std::collections::BTreeSet<(Index, Index)> = Default::default();
1028 for i in 0..n as Index {
1029 pairs.insert((i, i));
1030 }
1031 for r in &rows {
1032 for (a, &ca) in r.iter().enumerate() {
1033 for &cb in r.iter().take(a + 1) {
1034 pairs.insert(if ca >= cb { (ca, cb) } else { (cb, ca) });
1035 }
1036 }
1037 }
1038 assert!(
1039 !pairs.contains(&(2, 0)),
1040 "fixture is wrong: the constraint cliques already cover the objective pair"
1041 );
1042
1043 // With the objective clique, the entry appears.
1044 for (a, &ca) in obj.iter().enumerate() {
1045 for &cb in obj.iter().take(a + 1) {
1046 pairs.insert(if ca >= cb { (ca, cb) } else { (cb, ca) });
1047 }
1048 }
1049 assert!(
1050 pairs.contains(&(2, 0)),
1051 "the objective clique must contribute (2,0) — without it the \
1052 Jacobian-derived pattern is a SUBSET of the true Hessian and \
1053 `∂²f/∂x₀∂x₂` is dropped with no diagnostic"
1054 );
1055 }
1056
1057 /// gh#823 review finding 2 (@srikanth-gm). The objective clique's
1058 /// fallback must be STRUCTURAL. The version this replaces read the
1059 /// first `∇f`'s nonzeros, which for `f(x) = x₀x₁` at the origin is
1060 /// `(x₁, x₀) = (0, 0)` — an empty support, dropping `∂²f/∂x₀∂x₁ = 1`
1061 /// and making the pattern a subset of the truth.
1062 ///
1063 /// The point is not that the old fallback was imprecise. It is that it
1064 /// was a function of VALUES, so the same model got different sparsity
1065 /// from a different starting point. This test pins that the rule now
1066 /// reads only structure, and so cannot depend on where the solve starts.
1067 #[test]
1068 fn the_objective_fallback_is_structural_not_value_derived() {
1069 let n = 2usize;
1070
1071 // Nothing structural stated at all: the only sound answer is the
1072 // full set. The old rule returned {} here (zero gradient at the
1073 // origin) and silently lost the cross term.
1074 let (obj, widened) = objective_support(None, None, n);
1075 assert_eq!(obj, vec![0 as Index, 1]);
1076 assert!(widened, "a fallback this wide must be reported as widened");
1077 assert!(
1078 obj.contains(&0) && obj.contains(&1),
1079 "`f = x₀x₁` has `∂²f/∂x₀∂x₁ ≠ 0`; both coordinates must be in \
1080 the clique whatever the starting point"
1081 );
1082
1083 // The nonlinear-variable set, when the model states one, is a
1084 // sound superset of the objective's nonlinear support and is
1085 // preferred over "all n".
1086 let (obj, widened) = objective_support(None, Some(&[1 as Index]), n);
1087 assert_eq!(obj, vec![1 as Index]);
1088 assert!(widened);
1089
1090 // Stated objective linearity wins and costs nothing.
1091 let (obj, widened) = objective_support(Some(&[0 as Index]), Some(&[0, 1]), n);
1092 assert_eq!(obj, vec![0 as Index]);
1093 assert!(
1094 !widened,
1095 "a model that states its objective support must not be \
1096 reported as widened — that flag is what tells a user why \
1097 their probe count is large"
1098 );
1099 }
1100
1101 /// The rule reads no values at all, so it cannot vary with the iterate.
1102 /// Stated as its own assertion because "structural" is the property
1103 /// under test, and a future refactor that reintroduces a value argument
1104 /// would still pass the test above if it happened to be called at a
1105 /// point with a nonzero gradient.
1106 #[test]
1107 fn the_objective_support_rule_is_a_function_of_structure_alone() {
1108 // Same structural inputs, called repeatedly: identical answers.
1109 // There is no parameter through which an iterate could enter.
1110 let a = objective_support(None, Some(&[0 as Index, 2]), 4);
1111 let b = objective_support(None, Some(&[0 as Index, 2]), 4);
1112 assert_eq!(a, b);
1113 assert_eq!(a.0, vec![0 as Index, 2]);
1114 }
1115
1116 /// **The test that the banded ones missed.** The Jacobian-derived
1117 /// pattern is not banded — it is a union of OVERLAPPING CLIQUES, one
1118 /// per constraint row, since `supp(∇g_j) ⊗ supp(∇g_j)` is dense. The
1119 /// greedy star colouring is not valid on that shape, and the banded
1120 /// fixtures never exposed it: on `laptime` it silently produced a
1121 /// wrong Hessian that cost 404 iterations and a wrong objective.
1122 ///
1123 /// What is asserted here is the *validation*, not the colouring:
1124 /// whatever colouring is produced, every entry must be directly
1125 /// recoverable, or the caller must fall back. This is the invariant
1126 /// the recovery depends on, and it is checked on the shape that
1127 /// actually breaks it.
1128 #[test]
1129 fn overlapping_cliques_are_validated_not_assumed() {
1130 // Five 6-wide "constraint rows", each overlapping the next by 3 —
1131 // the shape a collocation Jacobian produces.
1132 let n = 18usize;
1133 let mut set = std::collections::BTreeSet::new();
1134 for start in (0..n - 5).step_by(3) {
1135 let cols: Vec<Index> = (start..start + 6).map(|v| v as Index).collect();
1136 for (a, &ca) in cols.iter().enumerate() {
1137 for &cb in cols.iter().take(a + 1) {
1138 set.insert((ca, cb));
1139 }
1140 }
1141 }
1142 for i in 0..n as Index {
1143 set.insert((i, i));
1144 }
1145 let pairs: Vec<(Index, Index)> = set.into_iter().collect();
1146
1147 let mut rows_of_col: Vec<Vec<Index>> = vec![Vec::new(); n];
1148 let mut cols_of_row: Vec<Vec<Index>> = vec![Vec::new(); n];
1149 let mut adj: Vec<Vec<Index>> = vec![Vec::new(); n];
1150 for &(i, j) in &pairs {
1151 rows_of_col[j as usize].push(i);
1152 cols_of_row[i as usize].push(j);
1153 if i != j {
1154 rows_of_col[i as usize].push(j);
1155 cols_of_row[j as usize].push(i);
1156 adj[i as usize].push(j);
1157 adj[j as usize].push(i);
1158 }
1159 }
1160
1161 let recoverable = |color: &[usize]| -> bool {
1162 let cnt = |v: Index, c: usize| {
1163 adj[v as usize]
1164 .iter()
1165 .filter(|&&w| color[w as usize] == c)
1166 .count()
1167 };
1168 pairs.iter().all(|&(i, j)| {
1169 i == j || cnt(i, color[j as usize]) == 1 || cnt(j, color[i as usize]) == 1
1170 })
1171 };
1172
1173 // CPR is correct by construction on any pattern.
1174 let cpr = FdHessianUpdater::color_cpr(n, &cols_of_row, &rows_of_col);
1175 assert!(recoverable(&cpr), "CPR must always be directly recoverable");
1176
1177 // Now the decisive part: actually RECOVER a known matrix through
1178 // each colouring on this shape. The predicate above is necessary
1179 // but was not shown to be sufficient — on `laptime` the star
1180 // colouring passed it (`coloring_fell_back: false`) and still
1181 // produced a Hessian wrong enough to cost 404 iterations. This
1182 // reproduces that in-process instead of only in a solve.
1183 let val = |i: Index, j: Index| -> Number {
1184 1.0 + (i as Number) * 0.5 - (j as Number) * 0.25 + ((i * 7 + j) as Number).sin()
1185 };
1186 let mut dense = vec![vec![0.0 as Number; n]; n];
1187 for &(i, j) in &pairs {
1188 let v = val(i.max(j), i.min(j));
1189 dense[i as usize][j as usize] = v;
1190 dense[j as usize][i as usize] = v;
1191 }
1192 let check = |color: &[usize], name: &str| -> Result<(), String> {
1193 let n_colors = color.iter().copied().max().unwrap() + 1;
1194 let mut probes = vec![vec![0.0 as Number; n]; n_colors];
1195 for (m, &c) in color.iter().enumerate() {
1196 for i in 0..n {
1197 probes[c][i] += dense[i][m];
1198 }
1199 }
1200 let cnt = |v: Index, c: usize| {
1201 adj[v as usize]
1202 .iter()
1203 .filter(|&&w| color[w as usize] == c)
1204 .count()
1205 };
1206 for &(i, j) in &pairs {
1207 let (g, read) = if i == j {
1208 (color[i as usize], i)
1209 } else if cnt(i, color[j as usize]) == 1 {
1210 (color[j as usize], i)
1211 } else {
1212 (color[i as usize], j)
1213 };
1214 let (got, want) = (probes[g][read as usize], dense[i as usize][j as usize]);
1215 if (got - want).abs() > 1e-12 {
1216 return Err(format!("{name}: ({i},{j}) recovered {got}, want {want}"));
1217 }
1218 }
1219 Ok(())
1220 };
1221 check(&cpr, "cpr").expect("CPR recovery must be exact on overlapping cliques");
1222
1223 // The star colouring is NOT asserted correct here: it is not, and
1224 // that is the recorded finding. What is asserted is that whenever
1225 // recovery would be wrong, the predicate `build_structure` gates on
1226 // rejects it — i.e. the predicate is not weaker than the truth.
1227 let star = FdHessianUpdater::color_star(n, &adj);
1228 if check(&star, "star").is_err() {
1229 assert!(
1230 !recoverable(&star),
1231 "star recovery is wrong on this pattern yet the validation \
1232 predicate accepts it — the predicate is unsound, and \
1233 `build_structure` would ship a silently wrong Hessian"
1234 );
1235 }
1236 }
1237
1238 /// A dense row makes every column adjacent to it, so the pattern
1239 /// contains a clique and no colouring can do better than its size.
1240 /// This is the case where star colouring is *not* a win, and the
1241 /// Jacobian-derived Hessian pattern is exactly this shape.
1242 #[test]
1243 fn a_clique_forces_its_size_in_groups_under_either_coloring() {
1244 let n = 10usize;
1245 let mut pairs = Vec::new();
1246 for i in 0..n as Index {
1247 for j in 0..=i {
1248 pairs.push((i, j));
1249 }
1250 }
1251 let mut rows_of_col: Vec<Vec<Index>> = vec![Vec::new(); n];
1252 let mut cols_of_row: Vec<Vec<Index>> = vec![Vec::new(); n];
1253 let mut adj: Vec<Vec<Index>> = vec![Vec::new(); n];
1254 for &(i, j) in &pairs {
1255 rows_of_col[j as usize].push(i);
1256 cols_of_row[i as usize].push(j);
1257 if i != j {
1258 rows_of_col[i as usize].push(j);
1259 cols_of_row[j as usize].push(i);
1260 adj[i as usize].push(j);
1261 adj[j as usize].push(i);
1262 }
1263 }
1264 let star = FdHessianUpdater::color_star(n, &adj);
1265 let cpr = FdHessianUpdater::color_cpr(n, &cols_of_row, &rows_of_col);
1266 assert_eq!(star.iter().copied().max().unwrap() + 1, n);
1267 assert_eq!(cpr.iter().copied().max().unwrap() + 1, n);
1268 }
1269}