Skip to main content

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}