Skip to main content

gam_solve/reml/reml_outer_engine/
hyper_operator.rs

1use super::*;
2
3pub(crate) fn as_implicit(op: &dyn HyperOperator) -> Option<&ImplicitHyperOperator> {
4    op.as_any().downcast_ref::<ImplicitHyperOperator>()
5}
6
7pub(crate) fn as_composite(op: &dyn HyperOperator) -> Option<&CompositeHyperOperator> {
8    op.as_any().downcast_ref::<CompositeHyperOperator>()
9}
10
11pub(crate) fn as_weighted(op: &dyn HyperOperator) -> Option<&WeightedHyperOperator> {
12    op.as_any().downcast_ref::<WeightedHyperOperator>()
13}
14
15pub(crate) trait DriftDerivTraceExt {
16    fn trace_logdet(&self, hop: &dyn HessianFactorization) -> f64;
17
18    fn trace_logdet_hessian_cross(&self, rhs: &Self, hop: &dyn HessianFactorization) -> f64;
19}
20
21impl DriftDerivTraceExt for DriftDerivResult {
22    fn trace_logdet(&self, hop: &dyn HessianFactorization) -> f64 {
23        match self {
24            Self::Dense(matrix) => hop.trace_logdet_gradient(matrix),
25            Self::Operator(operator) => hop.trace_logdet_operator(operator.as_ref()),
26        }
27    }
28
29    fn trace_logdet_hessian_cross(&self, rhs: &Self, hop: &dyn HessianFactorization) -> f64 {
30        match (self, rhs) {
31            (Self::Dense(left), Self::Dense(right)) => hop.trace_logdet_hessian_cross(left, right),
32            (Self::Dense(left), Self::Operator(right)) => {
33                hop.trace_logdet_hessian_cross_matrix_operator(left, right.as_ref())
34            }
35            (Self::Operator(left), Self::Dense(right)) => {
36                hop.trace_logdet_hessian_cross_matrix_operator(right, left.as_ref())
37            }
38            (Self::Operator(left), Self::Operator(right)) => {
39                hop.trace_logdet_hessian_cross_operator(left.as_ref(), right.as_ref())
40            }
41        }
42    }
43}
44
45#[derive(Clone)]
46pub struct CompositeHyperOperator {
47    pub dense: Option<Array2<f64>>,
48    pub operators: Vec<Arc<dyn HyperOperator>>,
49    pub dim_hint: usize,
50}
51
52/// Group composite operators by shared `(implicit_deriv, x_design, w_diag)`
53/// so every Duchon ψ-axis built atop the same implicit derivative runs
54/// through a single row-kernel sweep via
55/// `trace_projected_factor_all_axes_with_xf`. Per-axis `s_psi` and
56/// `c_x_psi_beta` are threaded in individually so the batched path matches
57/// the per-axis path exactly. Non-implicit operators and singleton groups
58/// fall through to the original per-op trace path.
59pub(crate) fn composite_trace_implicit_batched(
60    operators: &[Arc<dyn HyperOperator>],
61    factor: &Array2<f64>,
62    cache: Option<&ProjectedFactorCache>,
63) -> f64 {
64    let mut trace = 0.0;
65    let mut group_starts: Vec<Vec<usize>> = Vec::new();
66    let mut handled = vec![false; operators.len()];
67
68    for (i, op) in operators.iter().enumerate() {
69        if handled[i] {
70            continue;
71        }
72        let Some(impl_i) = as_implicit(op.as_ref()) else {
73            continue;
74        };
75        let mut group = vec![i];
76        handled[i] = true;
77        for j in (i + 1)..operators.len() {
78            if handled[j] {
79                continue;
80            }
81            if let Some(impl_j) = as_implicit(operators[j].as_ref())
82                && Arc::ptr_eq(&impl_i.implicit_deriv, &impl_j.implicit_deriv)
83                && Arc::ptr_eq(&impl_i.x_design, &impl_j.x_design)
84                && Arc::ptr_eq(impl_i.w_diag.as_arc(), impl_j.w_diag.as_arc())
85                && impl_i.p == impl_j.p
86            {
87                group.push(j);
88                handled[j] = true;
89            }
90        }
91        group_starts.push(group);
92    }
93
94    for group in &group_starts {
95        if group.len() >= 2 {
96            let lead = as_implicit(operators[group[0]].as_ref())
97                .expect("a group leader is by construction an index that matched as_implicit");
98            let xf = match cache {
99                Some(c) => lead.cached_xf(factor, c),
100                None => Arc::new(lead.compute_xf(factor)),
101            };
102            let axes: Vec<(usize, &Array2<f64>, Option<&Array1<f64>>)> = group
103                .iter()
104                .map(|&k| {
105                    let op = as_implicit(operators[k].as_ref()).expect(
106                        "a group member is by construction an index that matched as_implicit",
107                    );
108                    (op.axis, &op.s_psi, op.c_x_psi_beta.as_deref())
109                })
110                .collect();
111            let values = lead.trace_projected_factor_all_axes_with_xf(factor, xf.view(), &axes);
112            trace += values.iter().sum::<f64>();
113        } else {
114            let op = &operators[group[0]];
115            trace += match cache {
116                Some(c) => op.trace_projected_factor_cached(factor, c),
117                None => op.trace_projected_factor(factor),
118            };
119        }
120    }
121
122    for (i, op) in operators.iter().enumerate() {
123        if handled[i] {
124            continue;
125        }
126        trace += match cache {
127            Some(c) => op.trace_projected_factor_cached(factor, c),
128            None => op.trace_projected_factor(factor),
129        };
130    }
131
132    trace
133}
134
135/// Vector form of the implicit-axis trace batching used by
136/// [`CompositeHyperOperator`].  It returns one exact `tr(Fᵀ B_i F)` value per
137/// input operator while sharing the expensive `X·F` projection and Duchon
138/// row-kernel sweeps across sibling implicit ψ/ρ axes.
139pub(crate) fn trace_projected_factors_batched(
140    operators: &[Arc<dyn HyperOperator>],
141    factor: &Array2<f64>,
142    cache: &ProjectedFactorCache,
143) -> Vec<f64> {
144    let mut out = vec![0.0; operators.len()];
145    let mut handled = vec![false; operators.len()];
146
147    for i in 0..operators.len() {
148        if handled[i] {
149            continue;
150        }
151        let Some(impl_i) = as_implicit(operators[i].as_ref()) else {
152            out[i] = operators[i].trace_projected_factor_cached(factor, cache);
153            handled[i] = true;
154            continue;
155        };
156
157        let mut group = vec![i];
158        handled[i] = true;
159        for j in (i + 1)..operators.len() {
160            if handled[j] {
161                continue;
162            }
163            if let Some(impl_j) = as_implicit(operators[j].as_ref())
164                && Arc::ptr_eq(&impl_i.implicit_deriv, &impl_j.implicit_deriv)
165                && Arc::ptr_eq(&impl_i.x_design, &impl_j.x_design)
166                && Arc::ptr_eq(impl_i.w_diag.as_arc(), impl_j.w_diag.as_arc())
167                && impl_i.p == impl_j.p
168            {
169                group.push(j);
170                handled[j] = true;
171            }
172        }
173
174        if group.len() >= 2 {
175            let xf = impl_i.cached_xf(factor, cache);
176            let axes: Vec<(usize, &Array2<f64>, Option<&Array1<f64>>)> = group
177                .iter()
178                .map(|&idx| {
179                    let op = as_implicit(operators[idx].as_ref()).expect(
180                        "a group member is by construction an index that matched as_implicit",
181                    );
182                    (op.axis, &op.s_psi, op.c_x_psi_beta.as_deref())
183                })
184                .collect();
185            let values = impl_i.trace_projected_factor_all_axes_with_xf(factor, xf.view(), &axes);
186            for (&idx, value) in group.iter().zip(values) {
187                out[idx] = value;
188            }
189        } else {
190            out[i] = operators[i].trace_projected_factor_cached(factor, cache);
191        }
192    }
193
194    out
195}
196
197pub(crate) fn collect_projected_trace_terms<'a>(
198    out_idx: usize,
199    weight: f64,
200    op: &'a dyn HyperOperator,
201    factor: &Array2<f64>,
202    dense_acc: &mut [f64],
203    terms: &mut Vec<(usize, f64, &'a dyn HyperOperator)>,
204) {
205    if weight == 0.0 {
206        return;
207    }
208    if let Some(composite) = as_composite(op) {
209        if let Some(dense) = composite.dense.as_ref() {
210            dense_acc[out_idx] += weight * dense_trace_projected_factor(dense, factor);
211        }
212        for inner in &composite.operators {
213            collect_projected_trace_terms(
214                out_idx,
215                weight,
216                inner.as_ref(),
217                factor,
218                dense_acc,
219                terms,
220            );
221        }
222    } else if let Some(weighted) = as_weighted(op) {
223        for (term_weight, inner) in &weighted.terms {
224            collect_projected_trace_terms(
225                out_idx,
226                weight * *term_weight,
227                inner.as_ref(),
228                factor,
229                dense_acc,
230                terms,
231            );
232        }
233    } else {
234        terms.push((out_idx, weight, op));
235    }
236}
237
238pub(crate) fn collect_projected_matrix_terms<'a>(
239    out_idx: usize,
240    weight: f64,
241    op: &'a dyn HyperOperator,
242    factor: &Array2<f64>,
243    dense_acc: &mut [Array2<f64>],
244    terms: &mut Vec<(usize, f64, &'a dyn HyperOperator)>,
245) {
246    if weight == 0.0 {
247        return;
248    }
249    if let Some(composite) = as_composite(op) {
250        if let Some(dense) = composite.dense.as_ref() {
251            dense_acc[out_idx].scaled_add(weight, &dense_projected_matrix(dense, factor));
252        }
253        for inner in &composite.operators {
254            collect_projected_matrix_terms(
255                out_idx,
256                weight,
257                inner.as_ref(),
258                factor,
259                dense_acc,
260                terms,
261            );
262        }
263    } else if let Some(weighted) = as_weighted(op) {
264        for (term_weight, inner) in &weighted.terms {
265            collect_projected_matrix_terms(
266                out_idx,
267                weight * *term_weight,
268                inner.as_ref(),
269                factor,
270                dense_acc,
271                terms,
272            );
273        }
274    } else {
275        terms.push((out_idx, weight, op));
276    }
277}
278
279pub(crate) fn trace_projected_operator_terms_batched(
280    n_out: usize,
281    terms: &[(usize, f64, &dyn HyperOperator)],
282    factor: &Array2<f64>,
283    cache: &ProjectedFactorCache,
284) -> Vec<f64> {
285    let mut out = vec![0.0_f64; n_out];
286    let mut handled = vec![false; terms.len()];
287
288    for i in 0..terms.len() {
289        if handled[i] {
290            continue;
291        }
292        let Some(impl_i) = as_implicit(terms[i].2) else {
293            continue;
294        };
295        let mut group = vec![i];
296        handled[i] = true;
297        for j in (i + 1)..terms.len() {
298            if handled[j] {
299                continue;
300            }
301            if let Some(impl_j) = as_implicit(terms[j].2)
302                && Arc::ptr_eq(&impl_i.implicit_deriv, &impl_j.implicit_deriv)
303                && Arc::ptr_eq(&impl_i.x_design, &impl_j.x_design)
304                && Arc::ptr_eq(impl_i.w_diag.as_arc(), impl_j.w_diag.as_arc())
305                && impl_i.p == impl_j.p
306            {
307                group.push(j);
308                handled[j] = true;
309            }
310        }
311
312        // `group[0] == i`, so the leader is exactly the `impl_i` already
313        // bound above; re-deriving it through `as_implicit` would be a
314        // second lookup of a value we hold.
315        let lead = impl_i;
316        let xf = lead.cached_xf(factor, cache);
317        let axes: Vec<(usize, &Array2<f64>, Option<&Array1<f64>>)> = group
318            .iter()
319            .map(|&term_idx| {
320                let op = as_implicit(terms[term_idx].2)
321                    .expect("a group member is by construction an index that matched as_implicit");
322                (op.axis, &op.s_psi, op.c_x_psi_beta.as_deref())
323            })
324            .collect();
325        let values = lead.trace_projected_factor_all_axes_with_xf(factor, xf.view(), &axes);
326        for (&term_idx, value) in group.iter().zip(values.iter()) {
327            let (out_idx, weight, _) = terms[term_idx];
328            out[out_idx] += weight * *value;
329        }
330    }
331
332    for (i, (out_idx, weight, op)) in terms.iter().enumerate() {
333        if handled[i] {
334            continue;
335        }
336        out[*out_idx] += *weight * op.trace_projected_factor_cached(factor, cache);
337    }
338
339    out
340}
341
342pub(crate) fn projected_operator_terms_batched(
343    n_out: usize,
344    terms: &[(usize, f64, &dyn HyperOperator)],
345    factor: &Array2<f64>,
346    cache: &ProjectedFactorCache,
347) -> Vec<Array2<f64>> {
348    let rank = factor.ncols();
349    let mut out: Vec<Array2<f64>> = (0..n_out)
350        .map(|_| Array2::<f64>::zeros((rank, rank)))
351        .collect();
352    for (out_idx, weight, op) in terms.iter() {
353        let projected = op.projected_matrix_cached(factor, cache);
354        out[*out_idx].scaled_add(*weight, &projected);
355    }
356    out
357}
358
359pub(crate) fn project_hyper_operators_batched(
360    n_out: usize,
361    terms: &[(usize, f64, &dyn HyperOperator)],
362    factor: &Array2<f64>,
363    cache: &ProjectedFactorCache,
364) -> Vec<Array2<f64>> {
365    projected_operator_terms_batched(n_out, terms, factor, cache)
366}
367
368pub(crate) fn trace_logdet_drifts_projected_factor_batched(
369    drifts: &[DriftDerivResult],
370    factor: &Array2<f64>,
371    cache: &ProjectedFactorCache,
372) -> Vec<f64> {
373    let mut out = vec![0.0_f64; drifts.len()];
374    let mut terms: Vec<(usize, f64, &dyn HyperOperator)> = Vec::new();
375    for (idx, drift) in drifts.iter().enumerate() {
376        match drift {
377            DriftDerivResult::Dense(matrix) => {
378                out[idx] += dense_trace_projected_factor(matrix, factor);
379            }
380            DriftDerivResult::Operator(op) => {
381                collect_projected_trace_terms(idx, 1.0, op.as_ref(), factor, &mut out, &mut terms);
382            }
383        }
384    }
385    let batched = trace_projected_operator_terms_batched(drifts.len(), &terms, factor, cache);
386    for (dst, value) in out.iter_mut().zip(batched) {
387        *dst += value;
388    }
389    out
390}
391
392pub(crate) fn dense_spectral_trace_logdet_drifts_batched(
393    ds: &DenseSpectralOperator,
394    drifts: &[DriftDerivResult],
395) -> Vec<f64> {
396    trace_logdet_drifts_projected_factor_batched(drifts, &ds.g_factor, &ds.projected_factor_cache)
397}
398
399pub(crate) fn penalty_subspace_trace_factor(kernel: &PenaltySubspaceTrace) -> Array2<f64> {
400    let (evals, evecs) = kernel
401        .h_proj_inverse
402        .eigh(faer::Side::Lower)
403        .expect("PenaltySubspaceTrace kernel factor eigendecomposition failed");
404    let r = evals.len();
405    // F must satisfy F·Fᵀ = K exactly: the batched `tr(FᵀAF)` is consumed as
406    // the gradient of the SAME pseudo-logdet criterion whose exact kernel the
407    // per-coordinate path contracts via `h_proj_inverse` directly. The kernel
408    // eigenvalues are `1/σ_a` over the kept Hessian spectrum, so their
409    // dynamic range is the Hessian condition number — clamp ONLY the
410    // roundoff-negative tail to zero (K is PSD by construction; a negative
411    // eigenvalue is O(ε)·‖K‖ eigensolver noise, and √(max(λ,0)) is the
412    // honest PSD square root). A relative floor here is NOT a stabilization:
413    // raising `1/σ_max` to `√ε·r·(1/σ_min)` rewrites the criterion's
414    // sensitivity along exactly the stiffest directions — where the ρ-drifts
415    // `λ_k·S_k` live — inflating the analytic trace by up to `√ε·r·κ(H_pen)`
416    // (O(1) once κ ≳ 1e7) while FD differentiates the true criterion. That
417    // desync red-lined every iso-κ Duchon probit/logit FD test and starved
418    // the spatial κ-optimizer of descent directions; Gaussian was immune
419    // because the intrinsic kernel is only installed for c-nontrivial
420    // families (#901).
421    let mut root = evecs.clone();
422    for col in 0..r {
423        let scale = evals[col].max(0.0).sqrt();
424        for row in 0..r {
425            root[[row, col]] *= scale;
426        }
427    }
428    gam_linalg::faer_ndarray::fast_ab(&kernel.u_s, &root)
429}
430
431pub(crate) fn penalty_subspace_trace_drifts_batched(
432    kernel: &PenaltySubspaceTrace,
433    drifts: &[DriftDerivResult],
434) -> Vec<f64> {
435    let factor = penalty_subspace_trace_factor(kernel);
436    let cache = ProjectedFactorCache::default();
437    trace_logdet_drifts_projected_factor_batched(drifts, &factor, &cache)
438}
439
440pub(crate) fn penalty_subspace_reduce_drifts_batched(
441    kernel: &PenaltySubspaceTrace,
442    drifts: &[DriftDerivResult],
443) -> Vec<Array2<f64>> {
444    drifts
445        .iter()
446        .map(|drift| match drift {
447            DriftDerivResult::Dense(matrix) => kernel.reduce(matrix),
448            // #901 layer-2 (outer-Hessian path): reduce the operator via
449            // `U_Sᵀ·A·U_S = U_Sᵀ·A.mul_mat(U_S)` — NOT `op.to_dense()` then
450            // reduce. For the GLM cubic correction `C[v] = Xᵀdiag(c⊙Xv)X` the
451            // dense materialization computes near-null quadratic forms by
452            // cancelling O(‖C‖) entries, and the spectral kernel's `1/σ_min`
453            // then amplifies the roundoff (the +39-vs-−0.30 / ~−7.7e5 blow-up).
454            // `reduce_operator` probes through the `X·U_S` matvecs instead, so
455            // tiny² stays tiny — the same stability cure as the first-order
456            // `trace_operator` path.
457            DriftDerivResult::Operator(op) => kernel.reduce_operator(op.as_ref()),
458        })
459        .collect()
460}
461
462pub(crate) fn dense_spectral_trace_logdet_operators_batched(
463    ds: &DenseSpectralOperator,
464    operators: &[Arc<dyn HyperOperator>],
465) -> Vec<f64> {
466    if operators.is_empty() {
467        return Vec::new();
468    }
469    if log::log_enabled!(log::Level::Info) {
470        let start = std::time::Instant::now();
471        let out =
472            trace_projected_factors_batched(operators, &ds.g_factor, &ds.projected_factor_cache);
473        let implicit_count = operators.iter().filter(|op| op.is_implicit()).count();
474        dense_spectral_stage_log(
475            &format!(
476                "DenseSpectralOperator::trace_logdet_operators_batched dim={} rank={} ops={} implicit_ops={}",
477                ds.n_dim,
478                ds.g_factor.ncols(),
479                operators.len(),
480                implicit_count,
481            ),
482            start.elapsed().as_secs_f64(),
483        );
484        out
485    } else {
486        trace_projected_factors_batched(operators, &ds.g_factor, &ds.projected_factor_cache)
487    }
488}
489
490impl HyperOperator for CompositeHyperOperator {
491    fn as_any(&self) -> &(dyn std::any::Any + 'static) {
492        self
493    }
494
495    fn dim(&self) -> usize {
496        self.dim_hint
497    }
498
499    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
500        let mut out = Array1::<f64>::zeros(v.len());
501        self.mul_vec_into(v.view(), out.view_mut());
502        out
503    }
504
505    fn mul_vec_view(&self, v: ArrayView1<'_, f64>) -> Array1<f64> {
506        let mut out = Array1::<f64>::zeros(v.len());
507        self.mul_vec_into(v, out.view_mut());
508        out
509    }
510
511    fn mul_vec_into(&self, v: ArrayView1<'_, f64>, mut out: ArrayViewMut1<'_, f64>) {
512        if self.dense.is_none() && self.operators.len() == 1 {
513            self.operators[0].mul_vec_into(v, out);
514            return;
515        }
516
517        out.fill(0.0);
518        if let Some(dense) = self.dense.as_ref() {
519            dense::matvec_into(dense, v, out.view_mut());
520        }
521        for op in &self.operators {
522            op.scaled_add_mul_vec(v, 1.0, out.view_mut());
523        }
524    }
525
526    fn mul_basis_columns_into(&self, start: usize, mut out: ArrayViewMut2<'_, f64>) {
527        if self.dense.is_none() && self.operators.len() == 1 {
528            self.operators[0].mul_basis_columns_into(start, out);
529            return;
530        }
531
532        out.fill(0.0);
533        let cols = out.ncols();
534        let end = start + cols;
535        if let Some(dense) = self.dense.as_ref() {
536            out += &dense.slice(ndarray::s![.., start..end]);
537        }
538        let mut work = Array2::<f64>::zeros((out.nrows(), cols));
539        for op in &self.operators {
540            op.mul_basis_columns_into(start, work.view_mut());
541            out += &work;
542        }
543    }
544
545    fn scaled_add_mul_vec(
546        &self,
547        v: ArrayView1<'_, f64>,
548        scale: f64,
549        mut out: ArrayViewMut1<'_, f64>,
550    ) {
551        if scale == 0.0 {
552            return;
553        }
554        if self.dense.is_none() && self.operators.len() == 1 {
555            self.operators[0].scaled_add_mul_vec(v, scale, out);
556            return;
557        }
558
559        if let Some(dense) = self.dense.as_ref() {
560            dense::matvec_scaled_add_into(dense, v, scale, out.view_mut());
561        }
562        for op in &self.operators {
563            op.scaled_add_mul_vec(v, scale, out.view_mut());
564        }
565    }
566
567    /// Forward batched apply to inner operators so their `mul_mat` overrides
568    /// (matrix-free Khatri–Rao BLAS3 fuses) fire instead of the default
569    /// per-column parallel matvec — which would triple-nest rayon when an
570    /// inner op already parallelizes internally.
571    fn mul_mat(&self, factor: &Array2<f64>) -> Array2<f64> {
572        if self.dense.is_none() && self.operators.len() == 1 {
573            return self.operators[0].mul_mat(factor);
574        }
575        let p = factor.nrows();
576        let k = factor.ncols();
577        let mut out = Array2::<f64>::zeros((p, k));
578        if let Some(dense) = self.dense.as_ref() {
579            out += &dense.dot(factor);
580        }
581        for op in &self.operators {
582            out += &op.mul_mat(factor);
583        }
584        out
585    }
586
587    fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
588        if self.dense.is_none() && self.operators.len() == 1 {
589            return self.operators[0].trace_projected_factor(factor);
590        }
591
592        let mut trace = 0.0;
593        if let Some(dense) = self.dense.as_ref() {
594            let dense_factor = dense.dot(factor);
595            trace += factor
596                .iter()
597                .zip(dense_factor.iter())
598                .map(|(&f, &bf)| f * bf)
599                .sum::<f64>();
600        }
601        trace += composite_trace_implicit_batched(&self.operators, factor, None);
602        trace
603    }
604
605    fn trace_projected_factor_cached(
606        &self,
607        factor: &Array2<f64>,
608        cache: &ProjectedFactorCache,
609    ) -> f64 {
610        if self.dense.is_none() && self.operators.len() == 1 {
611            return self.operators[0].trace_projected_factor_cached(factor, cache);
612        }
613
614        let mut trace = 0.0;
615        if let Some(dense) = self.dense.as_ref() {
616            let dense_factor = dense.dot(factor);
617            trace += factor
618                .iter()
619                .zip(dense_factor.iter())
620                .map(|(&f, &bf)| f * bf)
621                .sum::<f64>();
622        }
623        trace += composite_trace_implicit_batched(&self.operators, factor, Some(cache));
624        trace
625    }
626
627    fn projected_matrix(&self, factor: &Array2<f64>) -> Array2<f64> {
628        if self.dense.is_none() && self.operators.len() == 1 {
629            return self.operators[0].projected_matrix(factor);
630        }
631
632        let rank = factor.ncols();
633        let mut projected = Array2::<f64>::zeros((rank, rank));
634        if let Some(dense) = self.dense.as_ref() {
635            let mf = gam_linalg::faer_ndarray::fast_ab(dense, factor);
636            projected += &gam_linalg::faer_ndarray::fast_atb(factor, &mf);
637        }
638        for op in &self.operators {
639            projected += &op.projected_matrix(factor);
640        }
641        projected
642    }
643
644    fn projected_matrix_cached(
645        &self,
646        factor: &Array2<f64>,
647        cache: &ProjectedFactorCache,
648    ) -> Array2<f64> {
649        if self.dense.is_none() && self.operators.len() == 1 {
650            return self.operators[0].projected_matrix_cached(factor, cache);
651        }
652
653        let rank = factor.ncols();
654        let mut projected = Array2::<f64>::zeros((rank, rank));
655        if let Some(dense) = self.dense.as_ref() {
656            let mf = gam_linalg::faer_ndarray::fast_ab(dense, factor);
657            projected += &gam_linalg::faer_ndarray::fast_atb(factor, &mf);
658        }
659        for op in &self.operators {
660            projected += &op.projected_matrix_cached(factor, cache);
661        }
662        projected
663    }
664
665    fn bilinear(&self, v: &Array1<f64>, u: &Array1<f64>) -> f64 {
666        let mut total = 0.0;
667        if let Some(dense) = self.dense.as_ref() {
668            total += dense::bilinear(dense, v.view(), u.view());
669        }
670        for op in &self.operators {
671            total += op.bilinear(v, u);
672        }
673        total
674    }
675
676    fn bilinear_view(&self, v: ArrayView1<'_, f64>, u: ArrayView1<'_, f64>) -> f64 {
677        let mut total = 0.0;
678        if let Some(dense) = self.dense.as_ref() {
679            total += dense::bilinear(dense, v, u);
680        }
681        for op in &self.operators {
682            total += op.bilinear_view(v, u);
683        }
684        total
685    }
686
687    fn to_dense(&self) -> Array2<f64> {
688        let mut out = self
689            .dense
690            .clone()
691            .unwrap_or_else(|| Array2::<f64>::zeros((self.dim_hint, self.dim_hint)));
692        for op in &self.operators {
693            out += &op.to_dense();
694        }
695        out
696    }
697
698    fn is_implicit(&self) -> bool {
699        self.operators.iter().any(|op| op.is_implicit())
700    }
701}
702
703/// Implicit Hessian-drift operator for a single anisotropic ψ_d coordinate.
704///
705/// Computes B_d · v on the fly:
706///   B_d · v = (∂X/∂ψ_d)^T (W · (X · v)) + X^T (W · ((∂X/∂ψ_d) · v)) + S_{ψ_d} · v
707///
708/// The first two terms use the implicit design-derivative operator (no dense
709/// (n × p) matrices), and S_{ψ_d} is a dense (p × p) penalty matrix (manageable).
710///
711/// Storage: the implicit operator holds O(n·k·D) radial jets, plus references
712/// to an active-basis X design operator and W (the working weights). The
713/// penalty matrix S_{ψ_d} is stored as a dense (p × p) matrix.
714/// Thread-local scratch buffers for `ImplicitHyperOperator::mul_vec_into`.
715/// Reused across PCG iterations and basis-column sweeps so each matvec
716/// avoids three fresh O(n)/O(p) allocations.
717mod implicit_matvec_scratch {
718    use std::cell::RefCell;
719
720    pub(super) struct Scratch {
721        pub x_v: Vec<f64>,
722        pub n_work: Vec<f64>,
723        pub p_work: Vec<f64>,
724    }
725
726    impl Scratch {
727        pub(crate) const fn new() -> Self {
728            Self {
729                x_v: Vec::new(),
730                n_work: Vec::new(),
731                p_work: Vec::new(),
732            }
733        }
734    }
735
736    thread_local! {
737        static SCRATCH: RefCell<Scratch> = const { RefCell::new(Scratch::new()) };
738    }
739
740    pub(super) fn with<R>(f: impl FnOnce(&mut Scratch) -> R) -> R {
741        SCRATCH.with(|cell| f(&mut cell.borrow_mut()))
742    }
743}
744
745pub struct ImplicitHyperOperator {
746    /// The implicit design-derivative operator (shared across all axes).
747    pub implicit_deriv: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
748    /// Which axis this operator is for.
749    pub axis: usize,
750    /// The active-basis design matrix X. This may be lazy / operator-backed.
751    pub(crate) x_design: std::sync::Arc<DesignMatrix>,
752    /// Working weights W (diagonal, length n) — observed-information curvature,
753    /// signed for non-canonical links. Carried as the owned [`gam_linalg::matrix::SignedWeightsArc`]
754    /// newtype so the sign character is construction-enforced at the operator
755    /// struct boundary; the function-boundary contract from `linalg/matrix.rs`
756    /// is no longer reconstructable accidentally inside `mul_vec`.
757    pub(crate) w_diag: gam_linalg::matrix::SignedWeightsArc,
758    /// Penalty derivative matrix S_{ψ_d} (p × p), dense.
759    pub s_psi: Array2<f64>,
760    /// Total basis dimension p.
761    pub(crate) p: usize,
762    /// Non-Gaussian fixed-β third-derivative correction: c ⊙ (X_{ψ_d} β̂),
763    /// length n. When present, the operator additionally applies
764    /// `Xᵀ diag(c_x_psi_beta) X v` so that the full B_d formula
765    /// `B_d v = (∂X/∂ψ_d)ᵀ W X v + Xᵀ W (∂X/∂ψ_d) v + Xᵀ diag(c ⊙ X_{ψ_d} β̂) X v + S_{ψ_d} v`
766    /// is matrix-free for non-Gaussian likelihoods. `None` for Gaussian
767    /// identity (c ≡ 0 there).
768    pub c_x_psi_beta: Option<std::sync::Arc<Array1<f64>>>,
769}
770
771impl HyperOperator for ImplicitHyperOperator {
772    fn dim(&self) -> usize {
773        self.p
774    }
775
776    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
777        // Single canonical path: route every matvec through `mul_vec_into`,
778        // which routes through `matvec_with_shared_xz_into`. The four terms of
779        // B_d are assembled there, with the third-derivative correction added
780        // by `accumulate_c_correction_xt_into` so the four matvec entry points
781        // share one inner kernel.
782        let mut out = Array1::<f64>::zeros(self.p);
783        self.mul_vec_into(v.view(), out.view_mut());
784        out
785    }
786
787    fn mul_vec_view(&self, v: ArrayView1<'_, f64>) -> Array1<f64> {
788        let mut out = Array1::<f64>::zeros(self.p);
789        self.mul_vec_into(v, out.view_mut());
790        out
791    }
792
793    fn mul_vec_into(&self, v: ArrayView1<'_, f64>, out: ArrayViewMut1<'_, f64>) {
794        assert_eq!(v.len(), self.p);
795        let n_obs = self.w_diag.len();
796        // Reuse thread-local scratch across repeated matvec calls (e.g.
797        // PCG iterations, basis-column sweeps) instead of allocating
798        // (2 n_obs + p) f64s every time.
799        implicit_matvec_scratch::with(|s| {
800            s.x_v.clear();
801            s.x_v.resize(n_obs, 0.0);
802            s.n_work.clear();
803            s.n_work.resize(n_obs, 0.0);
804            s.p_work.clear();
805            s.p_work.resize(self.p, 0.0);
806            let mut x_v_view = ndarray::ArrayViewMut1::from(s.x_v.as_mut_slice());
807            let n_work_view = ndarray::ArrayViewMut1::from(s.n_work.as_mut_slice());
808            let p_work_view = ndarray::ArrayViewMut1::from(s.p_work.as_mut_slice());
809            self.x_design.apply_view_into(v, x_v_view.view_mut());
810            self.matvec_with_shared_xz_into(x_v_view.view(), v, out, n_work_view, p_work_view);
811        });
812    }
813
814    fn mul_basis_columns_into(&self, start: usize, mut out: ArrayViewMut2<'_, f64>) {
815        let cols = out.ncols();
816        assert!(start + cols <= self.p);
817
818        let n_obs = self.w_diag.len();
819        let mut basis = Array1::<f64>::zeros(self.p);
820        let mut x_col = Array1::<f64>::zeros(n_obs);
821        let mut dx_col = Array1::<f64>::zeros(n_obs);
822        let mut weighted = Array1::<f64>::zeros(n_obs);
823        let mut term = Array1::<f64>::zeros(self.p);
824
825        for local_col in 0..cols {
826            let global_col = start + local_col;
827            let mut out_col = out.column_mut(local_col);
828            out_col.assign(&self.s_psi.column(global_col));
829
830            self.x_design.column_into(global_col, x_col.view_mut());
831            Zip::from(weighted.view_mut())
832                .and(self.w_diag.view())
833                .and(x_col.view())
834                .par_for_each(|dst, &w, &x| *dst = w * x);
835            term.assign(
836                &self
837                    .implicit_deriv
838                    .transpose_mul(self.axis, &weighted.view())
839                    .expect("radial scalar evaluation failed during implicit hyper transpose_mul"),
840            );
841            out_col += &term;
842
843            basis[global_col] = 1.0;
844            dx_col.assign(
845                &self
846                    .implicit_deriv
847                    .forward_mul(self.axis, &basis.view())
848                    .expect("radial scalar evaluation failed during implicit hyper forward_mul"),
849            );
850            basis[global_col] = 0.0;
851
852            Zip::from(weighted.view_mut())
853                .and(self.w_diag.view())
854                .and(dx_col.view())
855                .par_for_each(|dst, &w, &dx| *dst = w * dx);
856            self.x_design
857                .transpose_apply_view_into(weighted.view(), term.view_mut());
858            out_col += &term;
859
860            // Non-Gaussian third-derivative correction column j: shared kernel.
861            self.accumulate_c_correction_xt_into(
862                x_col.view(),
863                weighted.view_mut(),
864                term.view_mut(),
865                out_col,
866            );
867        }
868    }
869
870    fn bilinear(&self, v: &Array1<f64>, u: &Array1<f64>) -> f64 {
871        self.bilinear_view(v.view(), u.view())
872    }
873
874    fn bilinear_view(&self, v: ArrayView1<'_, f64>, u: ArrayView1<'_, f64>) -> f64 {
875        assert_eq!(v.len(), self.p);
876        assert_eq!(u.len(), self.p);
877
878        let x_v = self.x_design.apply_view(v);
879        let x_u = self.x_design.apply_view(u);
880        let dx_v = self
881            .implicit_deriv
882            .forward_mul(self.axis, &v)
883            .expect("radial scalar evaluation failed during implicit hyper forward_mul");
884        let dx_u = self
885            .implicit_deriv
886            .forward_mul(self.axis, &u)
887            .expect("radial scalar evaluation failed during implicit hyper forward_mul");
888
889        let w = &*self.w_diag;
890        let mut design = 0.0;
891        for i in 0..w.len() {
892            design += dx_v[i] * w[i] * x_u[i];
893            design += dx_u[i] * w[i] * x_v[i];
894        }
895
896        design += self.c_correction_bilinear(&x_v, &x_u);
897
898        let penalty = dense::bilinear(&self.s_psi, v, u);
899
900        design + penalty
901    }
902
903    fn is_implicit(&self) -> bool {
904        true
905    }
906
907    fn as_any(&self) -> &(dyn std::any::Any + 'static) {
908        self
909    }
910
911    /// Compute `tr(F^T B F)` directly via fused chunked BLAS3 GEMMs on the
912    /// shared X and the shared raw kernel matrix, bypassing the rank-many
913    /// separate matvecs the default impl would run through the lazy /
914    /// operator-backed design.
915    ///
916    /// **Why this matters:** the default trait impl is
917    ///   `let bf = self.mul_mat(F); (F ⊙ bf).sum()`
918    /// which calls `mul_vec_into` per column of `F` (rank columns). On a
919    /// lazy Duchon / Matérn / CTN design each `mul_vec_into` triggers a
920    /// full `O(n · p · kernel_eval)` row-streamed matvec — and with rank ≈ p
921    /// at large-scale shape (16D-Duchon-aniso 32 ψ-axes, p ≈ 95, n = 320 K)
922    /// the per-axis trace landed at ~30 s. With 32 axes per outer Hessian
923    /// eval and ~5 outer iters that's the ~1 hr large-scale timeout.
924    ///
925    /// Algebra:
926    /// ```text
927    ///   B_d = D_d^T W X + X^T W D_d  + X^T diag(c) X  + S_psi
928    ///   D_d = (∂X/∂ψ_d) = K_d · Z_unproject       (raw kernel · unproject)
929    ///   tr(F^T B_d F) = 2 · ⟨W ⊙ DXF, XF⟩ + ⟨c ⊙ XF, XF⟩ + tr(F^T S_psi F)
930    /// ```
931    /// where `K_d` is the raw (n × n_knots) per-pair kernel scalar matrix
932    /// for axis `d` (`q · s_combo + c · coeff_sum · φ` per (i, j) pair) and
933    /// `Z_unproject` is the identifiability/padding back-projection.
934    ///
935    /// We compute `U_knot = unproject_matrix(F)` once at (n_knots × rank),
936    /// then for each row chunk do a fused pass:
937    ///   * `XF_chunk  = X_chunk · F`        (chunk × rank)  — shared-X GEMM
938    ///   * `Kd_chunk  = row_chunk_first_raw`(chunk × n_knots) — raw kernel
939    ///   * `DXF_chunk = Kd_chunk · U_knot`  (chunk × rank)  — single GEMM
940    /// and immediately accumulate `⟨W ⊙ DXF, XF⟩` and `⟨c ⊙ XF, XF⟩` over
941    /// the chunk, never materialising full XF or DXF.
942    ///
943    /// This replaces the previous `rank`-many `forward_mul` apply loop. On
944    /// the large-scale margslope-aniso-duchon16d shard each per-axis trace
945    /// drops from ~30 s to a single chunked-GEMM cost.
946    fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
947        assert_eq!(factor.nrows(), self.p);
948        let n_obs = self.w_diag.len();
949        let rank = factor.ncols();
950        if rank == 0 || n_obs == 0 {
951            return 0.0;
952        }
953        let xf = self.compute_xf(factor);
954        self.trace_projected_factor_with_xf(factor, xf.view())
955    }
956
957    /// Cached variant — *the* hot-path optimisation for large-scale outer
958    /// gradient/Hessian sweeps. Every ψ-axis built atop the same `x_design`
959    /// (e.g. all 32 ψ-axes of a marginal-slope model, or the same axis hit
960    /// from `g_factor` and `w_factor` traces) shares one chunked
961    /// `X · F` design GEMM per `(x_design, factor)` pair via
962    /// [`ProjectedFactorCache`]. With 32 axes per outer-gradient sweep and
963    /// O(rank) more cross-axis traces inside the outer-Hessian build, the
964    /// cache turns 32× redundant `O(n · p · rank)` GEMMs into a single one
965    /// per outer iter. At large-scale shape (`n = 320 K`, `p = rank = 95`) that
966    /// is the difference between minutes and seconds of design-GEMM work.
967    fn trace_projected_factor_cached(
968        &self,
969        factor: &Array2<f64>,
970        cache: &ProjectedFactorCache,
971    ) -> f64 {
972        assert_eq!(factor.nrows(), self.p);
973        let n_obs = self.w_diag.len();
974        let rank = factor.ncols();
975        if rank == 0 || n_obs == 0 {
976            return 0.0;
977        }
978        let xf = self.cached_xf(factor, cache);
979        self.trace_projected_factor_with_xf(factor, xf.view())
980    }
981}
982
983/// The library's ONE row-chunk rule, re-exported for the implicit operator's
984/// row-streaming kernels: the definition lives beside the byte target it is
985/// derived from, shared with the GPU leverage kernel.
986pub(crate) use gam_runtime::resource::byte_balanced_row_chunk;
987
988impl ImplicitHyperOperator {
989    /// Chunked `X · F` via faer SIMD-parallel GEMM. The chunk-row sizing
990    /// targets ~8 MiB live blocks so the (chunk_n × p) row slice and
991    /// (chunk_n × rank) result both stay in L2/L3 across realistic large-scale
992    /// shapes; the kernel mirrors `xt_logdet_kernel_x_diagonal`'s sizing
993    /// rule. Caller wraps this in [`Self::cached_xf`] when invariance
994    /// across ψ-axes lets one matrix serve every axis at this `(x_design,
995    /// factor)` pair.
996    pub(crate) fn compute_xf(&self, factor: &Array2<f64>) -> Array2<f64> {
997        let n_obs = self.w_diag.len();
998        let rank = factor.ncols();
999        let mut xf = Array2::<f64>::zeros((n_obs, rank));
1000        let chunk_rows = byte_balanced_row_chunk(self.p + rank, n_obs);
1001        let mut start = 0usize;
1002        while start < n_obs {
1003            let end = (start + chunk_rows).min(n_obs);
1004            let rows = self
1005                .x_design
1006                .try_row_chunk(start..end)
1007                // SAFETY: `try_row_chunk` only fails on operator
1008                // implementation bugs — `start..end` is built from
1009                // `0..n_obs = 0..x_design.nrows()` with
1010                // `end = (start+chunk_rows).min(n_obs)`, so the range is
1011                // always a valid sub-range of `x_design`. Failure means the
1012                // operator broke its row-chunk contract.
1013                .unwrap_or_else(|err| {
1014                    // SAFETY: row range is a valid sub-range of x_design; failure means operator broke contract.
1015                    reml_contract_panic(format!(
1016                        "ImplicitHyperOperator::compute_xf row chunk failed: {err}"
1017                    ))
1018                });
1019            let block = gam_linalg::faer_ndarray::fast_ab(&rows, factor);
1020            xf.slice_mut(ndarray::s![start..end, ..]).assign(&block);
1021            start = end;
1022        }
1023        xf
1024    }
1025
1026    /// Look up `X · F` from the [`ProjectedFactorCache`] (compute-on-miss).
1027    /// Cache key combines the shared `x_design` Arc pointer and the
1028    /// factor's value fingerprint, so two `ImplicitHyperOperator` instances
1029    /// built atop the same `x_design` (e.g. axis-0 and axis-1 of a 32-axis
1030    /// ψ-block) consult the same cache slot and hit after the first
1031    /// computes.
1032    pub(crate) fn cached_xf(
1033        &self,
1034        factor: &Array2<f64>,
1035        cache: &ProjectedFactorCache,
1036    ) -> Arc<Array2<f64>> {
1037        let design_id = Arc::as_ptr(&self.x_design) as usize;
1038        let key = ProjectedFactorKey::from_factor_view(design_id, factor.view());
1039        cache.get_or_insert_with(key, || self.compute_xf(factor))
1040    }
1041
1042    /// Evaluate `tr(Fᵀ B_d F)` given a precomputed `X · F`. Pulls every
1043    /// per-axis-redundant `X · F` out of the inner loop so the cache (or
1044    /// caller-supplied matrix) covers every ψ-axis at once. The remaining
1045    /// per-axis work is the row-kernel build (`row_chunk_first_raw`),
1046    /// the `K_d · U_knot` GEMM, the fused `⟨W ⊙ DXF, XF⟩` inner products,
1047    /// and the small dense penalty contraction.
1048    pub(crate) fn trace_projected_factor_with_xf(
1049        &self,
1050        factor: &Array2<f64>,
1051        xf: ArrayView2<'_, f64>,
1052    ) -> f64 {
1053        let rank = factor.ncols();
1054        let n_obs = self.w_diag.len();
1055        assert_eq!(xf.dim(), (n_obs, rank));
1056
1057        // Once: unproject F to raw knot space → (n_knots × rank).
1058        let u_knot = self.implicit_deriv.unproject_matrix(&factor.view());
1059
1060        // Match the chunk sizing `xt_logdet_kernel_x_diagonal` uses so the
1061        // live block stays in L2/L3 across realistic large-scale shapes.
1062        let chunk_rows = byte_balanced_row_chunk(self.p + rank, n_obs);
1063
1064        let w = self.w_diag.as_ref();
1065        let c_opt = self.c_x_psi_beta.as_ref().map(|arc| arc.as_ref());
1066        let mut design_total = 0.0_f64;
1067        let mut correction_total = 0.0_f64;
1068        let mut start = 0usize;
1069        while start < n_obs {
1070            let end = (start + chunk_rows).min(n_obs);
1071            let chunk_n = end - start;
1072
1073            // Cached-or-precomputed X·F slice for this chunk.
1074            let xf_chunk = xf.slice(ndarray::s![start..end, ..]);
1075
1076            // Raw kernel scalars for axis d on this chunk, then a single
1077            // (chunk × n_knots) · (n_knots × rank) GEMM gives DXF_chunk.
1078            let kd_chunk = self
1079                .implicit_deriv
1080                .row_chunk_first_raw(self.axis, start..end)
1081                .expect("radial scalar evaluation failed during implicit hyper forward_mul_matrix");
1082            let dxf_chunk = gam_linalg::faer_ndarray::fast_ab(&kd_chunk, &u_knot);
1083
1084            // Fused inner-product accumulation.
1085            for i_local in 0..chunk_n {
1086                let i = start + i_local;
1087                let w_i = w[i];
1088                let dxf_row = dxf_chunk.row(i_local);
1089                let xf_row = xf_chunk.row(i_local);
1090                for k in 0..rank {
1091                    design_total += dxf_row[k] * w_i * xf_row[k];
1092                }
1093                if let Some(c) = c_opt {
1094                    let c_i = c[i];
1095                    for k in 0..rank {
1096                        let v = xf_row[k];
1097                        correction_total += c_i * v * v;
1098                    }
1099                }
1100            }
1101            start = end;
1102        }
1103
1104        // Penalty trace: tr(F^T S_psi F) via dense BLAS3.
1105        let s_f = self.s_psi.dot(factor);
1106        let penalty: f64 = factor.iter().zip(s_f.iter()).map(|(&f, &s)| f * s).sum();
1107
1108        2.0 * design_total + correction_total + penalty
1109    }
1110
1111    /// Batched-axis sibling of [`Self::trace_projected_factor_with_xf`].
1112    /// Returns `tr(Fᵀ B_d F)` for every `(axis, s_psi, c_x_psi_beta)` triple
1113    /// in `axes`, sharing the unproject-and-row-sweep work across axes that
1114    /// only differ in their axis index / penalty matrix / correction vector.
1115    pub(crate) fn trace_projected_factor_all_axes_with_xf(
1116        &self,
1117        factor: &Array2<f64>,
1118        xf: ArrayView2<'_, f64>,
1119        axes: &[(usize, &Array2<f64>, Option<&Array1<f64>>)],
1120    ) -> Vec<f64> {
1121        let rank = factor.ncols();
1122        let n_obs = self.w_diag.len();
1123        assert_eq!(xf.dim(), (n_obs, rank));
1124
1125        let u_knot = self.implicit_deriv.unproject_matrix(&factor.view());
1126
1127        let chunk_rows = byte_balanced_row_chunk(self.p + rank, n_obs.max(1));
1128
1129        let w = self.w_diag.as_ref();
1130        let mut design_totals = vec![0.0_f64; axes.len()];
1131        let mut correction_totals = vec![0.0_f64; axes.len()];
1132
1133        let mut start = 0usize;
1134        while start < n_obs {
1135            let end = (start + chunk_rows).min(n_obs);
1136            let chunk_n = end - start;
1137            let xf_chunk = xf.slice(ndarray::s![start..end, ..]);
1138
1139            for (axis_idx, (axis, _s_psi, c_opt_axis)) in axes.iter().enumerate() {
1140                let kd_chunk = self
1141                    .implicit_deriv
1142                    .row_chunk_first_raw(*axis, start..end)
1143                    .expect(
1144                        "radial scalar evaluation failed during \
1145                         trace_projected_factor_all_axes_with_xf",
1146                    );
1147                let dxf_chunk = gam_linalg::faer_ndarray::fast_ab(&kd_chunk, &u_knot);
1148
1149                for i_local in 0..chunk_n {
1150                    let i = start + i_local;
1151                    let w_i = w[i];
1152                    let dxf_row = dxf_chunk.row(i_local);
1153                    let xf_row = xf_chunk.row(i_local);
1154                    for k in 0..rank {
1155                        design_totals[axis_idx] += dxf_row[k] * w_i * xf_row[k];
1156                    }
1157                    if let Some(c) = c_opt_axis {
1158                        let c_i = c[i];
1159                        for k in 0..rank {
1160                            let v = xf_row[k];
1161                            correction_totals[axis_idx] += c_i * v * v;
1162                        }
1163                    }
1164                }
1165            }
1166            start = end;
1167        }
1168
1169        axes.iter()
1170            .enumerate()
1171            .map(|(idx, (_axis, s_psi, _c_opt_axis))| {
1172                let s_f = s_psi.dot(factor);
1173                let penalty: f64 = factor.iter().zip(s_f.iter()).map(|(&f, &s)| f * s).sum();
1174                2.0 * design_totals[idx] + correction_totals[idx] + penalty
1175            })
1176            .collect()
1177    }
1178
1179    pub(crate) fn accumulate_c_correction_xt_into(
1180        &self,
1181        x_col: ArrayView1<'_, f64>,
1182        mut n_work: ArrayViewMut1<'_, f64>,
1183        mut p_work: ArrayViewMut1<'_, f64>,
1184        mut out_col: ArrayViewMut1<'_, f64>,
1185    ) {
1186        let Some(c_x_psi_beta) = self.c_x_psi_beta.as_ref() else {
1187            return;
1188        };
1189        let c = c_x_psi_beta.as_ref();
1190        assert_eq!(x_col.len(), c.len());
1191        assert_eq!(n_work.len(), c.len());
1192        assert_eq!(p_work.len(), self.p);
1193
1194        for i in 0..c.len() {
1195            n_work[i] = c[i] * x_col[i];
1196        }
1197        self.x_design
1198            .transpose_apply_view_into(n_work.view(), p_work.view_mut());
1199        out_col += &p_work;
1200    }
1201
1202    pub(crate) fn c_correction_bilinear(&self, x_v: &Array1<f64>, x_u: &Array1<f64>) -> f64 {
1203        let Some(c_x_psi_beta) = self.c_x_psi_beta.as_ref() else {
1204            return 0.0;
1205        };
1206        x_v.iter()
1207            .zip(x_u.iter())
1208            .zip(c_x_psi_beta.iter())
1209            .map(|((&xv, &xu), &c)| xv * c * xu)
1210            .sum()
1211    }
1212
1213    /// Compute the design-part bilinear form u^T (X^T C_d X) z using precomputed
1214    /// shared X-multiplies, avoiding the full B_d matvec.
1215    ///
1216    /// The design part of B_d is:
1217    ///   (∂X/∂ψ_d)^T W X + X^T W (∂X/∂ψ_d)
1218    ///
1219    /// For vectors z and u, the bilinear form u^T \[design_part\] z equals:
1220    ///   ((∂X/∂ψ_d) u)^T (W (Xz)) + (Xu)^T (W ((∂X/∂ψ_d) z))
1221    ///   = 2 * (w ⊙ y_vec)^T dx_z       [when u = u, z = z]
1222    ///
1223    /// where y_vec = X u, dx_z = (∂X/∂ψ_d) z.
1224    ///
1225    /// But the full bilinear form is NOT symmetric in its dependence on z vs u
1226    /// through the design derivative, so we compute both cross-terms:
1227    ///   dx_z^T (w ⊙ y_vec) + dx_u^T (w ⊙ x_vec)
1228    ///
1229    /// # Arguments
1230    /// - `x_vec`: X z (precomputed, shared across axes)
1231    /// - `y_vec`: X u (precomputed, shared across axes)
1232    /// - `z`: the probe vector (needed for forward_mul and penalty)
1233    /// - `u`: H⁻¹ z (needed for forward_mul and penalty)
1234    ///
1235    /// # Returns
1236    /// The full bilinear form u^T B_d z = design_part + penalty_part.
1237    pub fn bilinear_with_shared_x(
1238        &self,
1239        x_vec: &Array1<f64>,
1240        y_vec: &Array1<f64>,
1241        z: &Array1<f64>,
1242        u: &Array1<f64>,
1243    ) -> f64 {
1244        // Design part: dx_z^T (w ⊙ y_vec) + dx_u^T (w ⊙ x_vec)
1245        let dx_z = self
1246            .implicit_deriv
1247            .forward_mul(self.axis, &z.view())
1248            .expect("radial scalar evaluation failed during implicit hyper forward_mul");
1249        let dx_u = self
1250            .implicit_deriv
1251            .forward_mul(self.axis, &u.view())
1252            .expect("radial scalar evaluation failed during implicit hyper forward_mul");
1253
1254        let mut design = 0.0f64;
1255        let w = &*self.w_diag;
1256        for i in 0..x_vec.len() {
1257            let wi = w[i];
1258            design += dx_z[i] * wi * y_vec[i];
1259            design += dx_u[i] * wi * x_vec[i];
1260        }
1261
1262        // Non-Gaussian fixed-β third-derivative correction:
1263        //   uᵀ Xᵀ diag(c ⊙ X_{ψ_d} β̂) X z = Σ_i (X u)_i · c_x_psi_beta_i · (X z)_i
1264        //   = Σ_i y_vec[i] · c_x_psi_beta[i] · x_vec[i]
1265        if let Some(c_x_psi_beta) = self.c_x_psi_beta.as_ref() {
1266            let c = c_x_psi_beta.as_ref();
1267            for i in 0..x_vec.len() {
1268                design += y_vec[i] * c[i] * x_vec[i];
1269            }
1270        }
1271
1272        // Penalty part: u^T S_psi z
1273        let penalty = dense::bilinear(&self.s_psi, z.view(), u.view());
1274
1275        design + penalty
1276    }
1277
1278    /// Compute the design-part contribution to A_d z without the X^T step.
1279    ///
1280    /// Returns the n-vector C_d (X z) where C_d encodes the diagonal weighting.
1281    /// Specifically: (∂X/∂ψ_d)^T maps FROM n-space, but for stochastic trace
1282    /// estimation we need q_d = A_d z = X^T (C_d x_vec) + P_d z.
1283    ///
1284    /// This method computes q_d = A_d z using the shared x_vec = X z:
1285    ///   q_d = (∂X/∂ψ_d)^T (W (X z)) + X^T (W ((∂X/∂ψ_d) z)) + S_psi z
1286    /// which is the standard mul_vec but we can share x_vec across axes.
1287    pub fn matvec_with_shared_xz_into(
1288        &self,
1289        x_vec: ArrayView1<'_, f64>,
1290        z: ArrayView1<'_, f64>,
1291        mut out: ArrayViewMut1<'_, f64>,
1292        mut n_work: ArrayViewMut1<'_, f64>,
1293        mut p_work: ArrayViewMut1<'_, f64>,
1294    ) {
1295        assert_eq!(z.len(), self.p);
1296        assert_eq!(out.len(), self.p);
1297        assert_eq!(n_work.len(), self.w_diag.len());
1298        assert_eq!(p_work.len(), self.p);
1299
1300        let w = &*self.w_diag;
1301        for i in 0..w.len() {
1302            n_work[i] = w[i] * x_vec[i];
1303        }
1304        let term1 = self
1305            .implicit_deriv
1306            .transpose_mul(self.axis, &n_work.view())
1307            .expect("radial scalar evaluation failed during implicit hyper transpose_mul");
1308        out.assign(&term1);
1309
1310        let dx_z = self
1311            .implicit_deriv
1312            .forward_mul(self.axis, &z)
1313            .expect("radial scalar evaluation failed during implicit hyper forward_mul");
1314        for i in 0..w.len() {
1315            n_work[i] = w[i] * dx_z[i];
1316        }
1317        self.x_design
1318            .transpose_apply_view_into(n_work.view(), p_work.view_mut());
1319        out += &p_work;
1320
1321        dense::matvec_into(&self.s_psi, z, p_work.view_mut());
1322        out += &p_work;
1323
1324        // Non-Gaussian fixed-β third-derivative correction.
1325        if let Some(c_x_psi_beta) = self.c_x_psi_beta.as_ref() {
1326            let c = c_x_psi_beta.as_ref();
1327            for i in 0..w.len() {
1328                n_work[i] = c[i] * x_vec[i];
1329            }
1330            self.x_design
1331                .transpose_apply_view_into(n_work.view(), p_work.view_mut());
1332            out += &p_work;
1333        }
1334    }
1335}
1336
1337/// Operator-backed fixed-β Hessian drift for sparse-exact τ coordinates.
1338///
1339/// This stays in the original sparse/native coefficient basis and computes the
1340/// exact first-order τ Hessian drift
1341///   B_τ = X_τᵀ W X + Xᵀ W X_τ + Xᵀ diag(c ⊙ X_τ β̂) X + S_τ − (H_φ)_{τ}|_β
1342/// without materializing the full dense matrix up front.
1343pub struct SparseDirectionalHyperOperator {
1344    /// Original-basis design derivative X_τ.
1345    pub(crate) x_tau: super::super::HyperDesignDerivative,
1346    /// Design matrix X in the sparse-native basis.
1347    pub(crate) x_design: DesignMatrix,
1348    /// Working weights W (diagonal) — observed-information curvature, signed
1349    /// for non-canonical links.  Carried as the owned [`gam_linalg::matrix::SignedWeightsArc`]
1350    /// newtype so the sign character is construction-enforced at the operator
1351    /// struct boundary.
1352    pub(crate) w_diag: gam_linalg::matrix::SignedWeightsArc,
1353    /// Penalty derivative S_τ.
1354    pub(crate) s_tau: Array2<f64>,
1355    /// Fixed-β non-Gaussian curvature term c ⊙ (X_τ β̂), if applicable.
1356    pub(crate) c_x_tau_beta: Option<Array1<f64>>,
1357    /// Fixed-β Firth partial Hessian drift (H_φ)_{τ}|_β, if applicable.
1358    pub(crate) firth_hphi_tau_partial: Option<Array2<f64>>,
1359    /// Total coefficient dimension.
1360    pub(crate) p: usize,
1361}
1362
1363impl HyperOperator for SparseDirectionalHyperOperator {
1364    fn dim(&self) -> usize {
1365        self.p
1366    }
1367
1368    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
1369        assert_eq!(v.len(), self.p);
1370
1371        // X v
1372        let x_v = self.x_design.matrixvectormultiply(v);
1373
1374        // X_tauᵀ (W (X v))
1375        let w_x_v = &*self.w_diag * &x_v;
1376        let term1 = self
1377            .x_tau
1378            .transpose_mul_original(&w_x_v)
1379            .expect("SparseDirectionalHyperOperator transpose product should be shape-consistent");
1380
1381        // Xᵀ (W (X_tau v))
1382        let x_tau_v = self
1383            .x_tau
1384            .forward_mul_original(v)
1385            .expect("SparseDirectionalHyperOperator forward product should be shape-consistent");
1386        let w_x_tau_v = &*self.w_diag * &x_tau_v;
1387        let term2 = self.x_design.transpose_vector_multiply(&w_x_tau_v);
1388
1389        // S_tau v
1390        let term3 = self.s_tau.dot(v);
1391
1392        let mut out = term1 + term2 + term3;
1393
1394        // Non-Gaussian fixed-beta curvature: Xᵀ diag(c ⊙ X_tau β̂) X v
1395        if let Some(c_x_tau_beta) = self.c_x_tau_beta.as_ref() {
1396            let weighted = c_x_tau_beta * &x_v;
1397            out += &self.x_design.transpose_vector_multiply(&weighted);
1398        }
1399
1400        // Firth fixed-beta partial: subtract (H_φ)_{τ}|_β v
1401        if let Some(hphi_tau_partial) = self.firth_hphi_tau_partial.as_ref() {
1402            out -= &hphi_tau_partial.dot(v);
1403        }
1404
1405        out
1406    }
1407
1408    fn is_implicit(&self) -> bool {
1409        false
1410    }
1411    fn as_any(&self) -> &(dyn std::any::Any + 'static) {
1412        self
1413    }
1414}
1415
1416/// Matrix-free GLM cubic-correction drift `C[v] = −Xᵀ diag(c ⊙ X v) X`
1417/// on the exact represented Hessian-curvature surface (sign folded into the
1418/// stored diagonal).
1419///
1420/// # Why this must stay an operator (#901 layer 2)
1421///
1422/// The spectral logdet kernel evaluates `tr(H⁺ · C)` as
1423/// `Σ_a (1/σ_a) · u_aᵀ C u_a` over the eigenpairs of `H_pen`. For a
1424/// near-null eigenvector (`σ_min ~ 1e−4` on the Duchon fixtures) the true
1425/// quadratic form is tiny — `‖X u_a‖² ≲ σ_a / w_min` — but a DENSE
1426/// materialization of `C` computes it as a cancellation across entries of
1427/// magnitude `‖C‖`, leaving roundoff `~ ε‖C‖p` that the kernel then
1428/// amplifies by `1/σ_min`. On the iso-κ Duchon binomial FD drivers this
1429/// turned a true cubic trace of `−0.30` into `+39.0`, and `~−7.7e5` on the
1430/// κ-scaled ψ arms where `‖C‖ ~ λ · ∂S/∂ψ` — the dominant #901 blow-up.
1431///
1432/// In operator form the kernel probes `C · u_a = −Xᵀ(d ⊙ (X u_a))`: the
1433/// cancellation happens inside the `X u_a` matvec (error `~ ε‖X‖‖u_a‖`),
1434/// and the quadratic form is the *square* of that already-small vector —
1435/// tiny² stays tiny, so the `1/σ_a` amplification acts on a relatively
1436/// accurate value. This is the same stability argument as evaluating
1437/// leverages via `(X u)ᵀ d (X u)` instead of `uᵀ (XᵀdX) u`.
1438pub struct GlmCurvatureCorrectionOperator {
1439    /// Design matrix X in the transformed basis (matrix-free capable).
1440    pub(crate) x_design: DesignMatrix,
1441    /// Pre-masked, sign-folded diagonal `−(c ⊙ X v)` over active rows.
1442    pub(crate) neg_c_xv: Array1<f64>,
1443    /// Total coefficient dimension.
1444    pub(crate) p: usize,
1445}
1446
1447impl HyperOperator for GlmCurvatureCorrectionOperator {
1448    fn dim(&self) -> usize {
1449        self.p
1450    }
1451
1452    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
1453        assert_eq!(v.len(), self.p);
1454        let x_v = self.x_design.matrixvectormultiply(v);
1455        let weighted = &self.neg_c_xv * &x_v;
1456        self.x_design.transpose_vector_multiply(&weighted)
1457    }
1458
1459    fn as_any(&self) -> &(dyn std::any::Any + 'static) {
1460        self
1461    }
1462
1463    fn is_implicit(&self) -> bool {
1464        false
1465    }
1466}
1467
1468// ═══════════════════════════════════════════════════════════════════════════
1469//  Data structures
1470// ═══════════════════════════════════════════════════════════════════════════